IW-463: Help must be able to guide an agent #541

Merged
mph merged 173 commits from IW-463 into main 2026-09-09 07:57:16 +00:00
Owner

Closes #463. Supersedes and closes #439 and #202, both carried in full.

Summary

iw help could not guide anyone — not a person at a shell, not an agent driving the
CLI. Four failures, fixed together across eight phases:

  1. The help flag never reached a help printer. 4 of 25 commands handled it; the rest
    ran, or failed on an unrelated error. iw phase-merge --help merged pull request
    #434
    — a destructive action performed in answer to a request for documentation.
  2. There was no overview worth reading. iw --help printed six lines and redirected;
    iw --list printed 34 flat Purpose:/Usage: pairs scraped from source comments.
  3. The text said nothing actionable. phase-pr's three flags were all typed DUR
    with no meanings, no defaults and no exit codes — and exit 3 ("the wait is unfinished,
    call me again") lived only in a skill's text.
  4. Help lived beside the command, not in it. Four independent copies of the same facts
    already disagreed.

Every command now carries a CommandDeclaration. The parser, the rendered --help, the
grouped index, docs/commands/ and the published skill are all derived from it. The count
of independent copies of any flag's meaning is one.

Changes

Phase Delivers
1 A help request never acts, and exits 0
2 phase-pr fully self-describing, end to end
3 Declarations that cannot be forgotten — enforcement, subcommands, operands
4 12 lifecycle commands declare
5 11 remaining commands declare; the issue create 422 fixed
6 One Help entry point, grouped index, the comment scrape deleted (closes #202)
7 Plugins describe themselves — iw command-index, manifest schema, rich listing
8 docs/commands/ and the published skill generated from the registry

229 files, +35523 / -2314.

Behaviour changes callers will notice

  • Unknown flags are refused, not ignored. A typo'd flag was "run with defaults"; it is
    now exit 1 naming the token.
  • iw issue create --title needs quotes for a multi-word title. The old parser joined
    every bare token with spaces, which is what produced the bare 422 on a ---leading
    value. --title "Fix the bug" now, not --title Fix the bug.
  • iw feedback takes the same treatment — the title is a declared operand.
  • iw --list, iw --help and bare iw render the grouped index, not the old six lines.

Testing

  • Unit — 186 munit tests in core/test/, 241 in dashboard/. Green.
  • Command compilation — all 36 commands compile.
  • E2E — BATS, including test/bootstrap.bats against an extracted release tarball,
    which is the only place the release-path help classpath is exercised.
  • Every phase proved its tests fallible by mutation, reverted with line-targeted edits;
    the counts and the mutations are recorded per phase in implementation-log.md.

The .git-hooks/pre-commit scalafix step added in the final commit ran on that commit
and passed.

Review

Start with project-management/issues/IW-463/review-packet.md — goals, per-story
acceptance criteria, a reading order for entry points, two diagrams, the test summary and
files changed by area.

Lessons

LESSONS.md was triaged before this PR: 22 dropped, 17 reduced to FOLLOW-UPS.md, 22
sorted for decision of which five were walked. Three promotions landed in this branch —
docs/launcher-performance.md, a new docs/testing.md section, and the scalafix step in
.git-hooks/pre-commit. Seventeen entries keep _pending_ decisions by deliberate call:
the ground they cover is already captured elsewhere.

Poznámky k vydání

Viz project-management/issues/IW-463/release-notes.md.

Merged with main

main is merged in (3ab9cd4c). Seven files conflicted, all of them places where
main and this branch had independently fixed the same thing:

  • IssuePoller.close() — both bounded the wait after shutdownNow. Kept this
    branch's body, which also restores the interrupt flag, under the name
    PullRequestPoller already uses; dropped the duplicate constant the automerge
    left behind.
  • CaskServerTest — complementary, so both were kept. This branch stops the
    server before a test deletes what its pollers write; main's removeStateDir
    tolerates whatever a save in flight left behind.
  • FakeCommandEnv.write — the fixture now carries both a per-path failure map
    and a global write result. The specific answer wins, then the general one.
  • InitHarnessTest — both sides kept; this branch's four help and rejection
    cases move onto main's config read-back helpers, which replaced the fixture's
    config capability.
  • Init.scala, Doctor.scala, PullRequestPollerTest — call site,
    imports and imports.

Two things followed from the merge rather than from either side, and are worth a
reviewer's eye:

  1. main widened the scalafix gate to dashboard/, so this branch's poller and
    server-stop tests were linted for the first time. Three violations: a
    @volatile var in each poller test became an AtomicBoolean, and an
    AtomicReference seeded with null became one seeded with None — the idiom
    the same file already uses for startupError.
  2. main made ScalafixGate the single definition of the lint argv. The
    pre-commit step this branch adds had hardcoded a different string, which would
    have been a fifth disagreeing copy of exactly the kind this issue removes. It
    now runs the same command as pre-push, CI and ./iw ./test, and
    ScalafixGateTest pins the pre-commit hook alongside the other three.

Verified on the merged tree: ./iw ./test unit 0, ./iw ./test compile 0,
scalafix --check 0, scala-cli fmt --check 0.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa

Closes #463. Supersedes and closes #439 and #202, both carried in full. ## Summary `iw` help could not guide anyone — not a person at a shell, not an agent driving the CLI. Four failures, fixed together across eight phases: 1. **The help flag never reached a help printer.** 4 of 25 commands handled it; the rest ran, or failed on an unrelated error. `iw phase-merge --help` **merged pull request #434** — a destructive action performed in answer to a request for documentation. 2. **There was no overview worth reading.** `iw --help` printed six lines and redirected; `iw --list` printed 34 flat `Purpose:`/`Usage:` pairs scraped from source comments. 3. **The text said nothing actionable.** `phase-pr`'s three flags were all typed `DUR` with no meanings, no defaults and no exit codes — and exit 3 ("the wait is unfinished, call me again") lived only in a skill's text. 4. **Help lived beside the command, not in it.** Four independent copies of the same facts already disagreed. Every command now carries a `CommandDeclaration`. The parser, the rendered `--help`, the grouped index, `docs/commands/` and the published skill are all derived from it. The count of independent copies of any flag's meaning is **one**. ## Changes | Phase | Delivers | |---|---| | 1 | A help request never acts, and exits 0 | | 2 | `phase-pr` fully self-describing, end to end | | 3 | Declarations that cannot be forgotten — enforcement, subcommands, operands | | 4 | 12 lifecycle commands declare | | 5 | 11 remaining commands declare; the `issue create` 422 fixed | | 6 | One `Help` entry point, grouped index, the comment scrape deleted (**closes #202**) | | 7 | Plugins describe themselves — `iw command-index`, manifest schema, rich listing | | 8 | `docs/commands/` and the published skill generated from the registry | 229 files, +35523 / -2314. ## Behaviour changes callers will notice - **Unknown flags are refused, not ignored.** A typo'd flag was "run with defaults"; it is now exit 1 naming the token. - **`iw issue create --title` needs quotes for a multi-word title.** The old parser joined every bare token with spaces, which is what produced the bare 422 on a `--`-leading value. `--title "Fix the bug"` now, not `--title Fix the bug`. - **`iw feedback` takes the same treatment** — the title is a declared operand. - **`iw --list`, `iw --help` and bare `iw` render the grouped index**, not the old six lines. ## Testing - **Unit** — 186 munit tests in `core/test/`, 241 in `dashboard/`. Green. - **Command compilation** — all 36 commands compile. - **E2E** — BATS, including `test/bootstrap.bats` against an extracted release tarball, which is the only place the release-path help classpath is exercised. - Every phase proved its tests fallible by mutation, reverted with line-targeted edits; the counts and the mutations are recorded per phase in `implementation-log.md`. The `.git-hooks/pre-commit` scalafix step added in the final commit ran on that commit and passed. ## Review Start with `project-management/issues/IW-463/review-packet.md` — goals, per-story acceptance criteria, a reading order for entry points, two diagrams, the test summary and files changed by area. ## Lessons `LESSONS.md` was triaged before this PR: 22 dropped, 17 reduced to `FOLLOW-UPS.md`, 22 sorted for decision of which five were walked. Three promotions landed in this branch — `docs/launcher-performance.md`, a new `docs/testing.md` section, and the scalafix step in `.git-hooks/pre-commit`. Seventeen entries keep `_pending_` decisions by deliberate call: the ground they cover is already captured elsewhere. ## Poznámky k vydání Viz `project-management/issues/IW-463/release-notes.md`. ## Merged with main `main` is merged in (`3ab9cd4c`). Seven files conflicted, all of them places where main and this branch had independently fixed the same thing: - **`IssuePoller.close()`** — both bounded the wait after `shutdownNow`. Kept this branch's body, which also restores the interrupt flag, under the name `PullRequestPoller` already uses; dropped the duplicate constant the automerge left behind. - **`CaskServerTest`** — complementary, so both were kept. This branch stops the server before a test deletes what its pollers write; main's `removeStateDir` tolerates whatever a save in flight left behind. - **`FakeCommandEnv.write`** — the fixture now carries both a per-path failure map and a global write result. The specific answer wins, then the general one. - **`InitHarnessTest`** — both sides kept; this branch's four help and rejection cases move onto main's config read-back helpers, which replaced the fixture's config capability. - **`Init.scala`**, **`Doctor.scala`**, **`PullRequestPollerTest`** — call site, imports and imports. Two things followed from the merge rather than from either side, and are worth a reviewer's eye: 1. **main widened the scalafix gate to `dashboard/`**, so this branch's poller and server-stop tests were linted for the first time. Three violations: a `@volatile var` in each poller test became an `AtomicBoolean`, and an `AtomicReference` seeded with `null` became one seeded with `None` — the idiom the same file already uses for `startupError`. 2. **main made `ScalafixGate` the single definition of the lint argv.** The pre-commit step this branch adds had hardcoded a different string, which would have been a fifth disagreeing copy of exactly the kind this issue removes. It now runs the same command as pre-push, CI and `./iw ./test`, and `ScalafixGateTest` pins the pre-commit hook alongside the other three. Verified on the merged tree: `./iw ./test unit` 0, `./iw ./test compile` 0, `scalafix --check` 0, `scala-cli fmt --check` 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
mph added 171 commits 2026-09-08 21:29:35 +00:00
Help must be able to guide an agent: an overview index, and a --help on
every command that prints instead of executing

Artifacts:
- analysis.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0191tr3XJS93Vu5g6CQuodXa
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0191tr3XJS93Vu5g6CQuodXa
Resolved:
- analysis.md: CLARIFY 1 (iw --list JVM latency) -> DECISION 1: JVM always, but the
  Help entry point runs via plain `java -cp`, never scala-cli. Measured 0.09s release /
  0.26s dev, vs 0.95s through scala-cli. --list gets faster; <cmd> --help gets 10x faster.
- analysis.md: CLARIFY 2 (plugin command listing) -> DECISION 2: Bash discovers plugin
  directories, a generated commands.index.json describes them, nothing is executed.
  Name-only fallback on missing/stale manifest. `iw plugin-index` generator added to
  Story 5; gives dev-docs #161 its input contract.

Still open:
- analysis.md: CLARIFY 3 (load-bearing: compiler or test)
- analysis.md: CLARIFY 4 (does all of it land in IW-463)
- analysis.md: CLARIFY 5 (Story 1 intercept vs commands that already print help)
- analysis.md: CLARIFY 6 (real command surface)

Story 5 re-estimated 5-8h -> 10-14h; total 40-56h -> 45-62h.
Adds LESSONS.md with the measurements and three source findings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0191tr3XJS93Vu5g6CQuodXa
Resolved:
- analysis.md: CLARIFY 3 (load-bearing enforcement) -> DECISION 3: option C. Required
  fields with no defaults for structure; a registry-walking test for content. The
  parsed-arg accessor is keyed by the declared flag value, so reading an undeclared
  flag does not compile. Decided now, not deferred past Phase 2.
- analysis.md: CLARIFY 4 (issue scope) -> DECISION 4: option A, all in IW-463. Phases
  1-4b are additive, so any pause before Phase 5 leaves a coherent tool.
- analysis.md: CLARIFY 5 (Story 1 intercept) -> DECISION 5: option B applied uniformly,
  plugins included. A plugin without --help handling currently runs on a help request.
  Verified -h is safe to intercept; the mill crash was transient.
- analysis.md: CLARIFY 6 (command surface) -> DECISION 6: 25 top-level + 8 subcommands
  = 33 declarations; hooks excluded (verified); iw --list shows top-level only.

No CLARIFY markers remain open. Story 3 re-estimated 8-10h -> 9-12h; total 46-64h.
LESSONS.md gains the command-surface and -h findings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0191tr3XJS93Vu5g6CQuodXa
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0191tr3XJS93Vu5g6CQuodXa
Help must be able to guide an agent: an overview index, and a --help on
every command that prints instead of executing

Artifacts:
- tasks.md (8 phases, 46-64h)
- review-state.json (tasks_ready)

Story 4 split into phases 4/5 and Story 5 into phases 6/7, per the
phase-sizing policy. Phase 6 lands the built-in index and deletes the
scrape; phase 7 adds the plugin manifest machinery, leaving plugins on
DECISION 2's name-only fallback in between.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0191tr3XJS93Vu5g6CQuodXa
--needs-attention false sets it true (presence flag, value discarded);
--clear-needs-attention is the real flag. --append-action had duplicated
the implement action already present from the prior write.

Records the parser defect in LESSONS.md as evidence for the issue, and
flags the ag-create-tasks command document that recommends the broken
invocation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0191tr3XJS93Vu5g6CQuodXa
Issue: IW-463
Phase: 1 - A help request never acts

This commit marks the checkpoint for phase 1 context generation.
Use ag-revert IW-463 1 to return to this point.

🤖 Generated with Claude Code
A help request never acts

Artifacts:
- phase-01-tasks.md

🤖 Generated with Claude Code
Issue: IW-463
Phase: 2 - phase-pr describes itself

This commit marks the checkpoint for phase 2 context generation.
Use ag-revert IW-463 2 to return to this point.

🤖 Generated with Claude Code
phase-pr describes itself

Artifacts:
- phase-02-tasks.md

🤖 Generated with Claude Code
Phase 2: phase-pr describes itself
Some checks failed
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 11s
CI / test (pull_request) Has been cancelled
CI / dashboard-build (pull_request) Has been cancelled
CI / compile (pull_request) Has been cancelled
235f3fe808
- CommandDeclaration, CommandArgParser and CommandHelp: a command's flags,
  exit codes and examples live in one declaration that the parser and the
  help renderer both read
- phase-pr migrated to the declaration: --help is answered before any git or
  forge call, and unknown flags and missing values are rejected by name
  instead of being silently ignored
- Forgejo 409 reports a duplicate pull request by name when the forge says
  so, and reports the real conflict otherwise
- The exit-3 resume instruction is shell-quoted inert, so a title cannot
  inject a command into the line an agent is told to run

Issue: IW-463
Phase: 2 of 8

Code review: 3 iterations, 6 skills; 5 critical findings raised and closed.
Reviews in project-management/issues/IW-463/review-phase-02-*.md
chore(IW-463): update review-state for phase 02
Some checks failed
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 7s
CI / compile (pull_request) Successful in 1m16s
CI / dashboard-build (pull_request) Successful in 59s
CI / test (pull_request) Has been cancelled
3f016f332b
chore(IW-463): update review-state for phase 02
Some checks failed
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 8s
CI / compile (pull_request) Successful in 1m17s
CI / dashboard-build (pull_request) Successful in 1m0s
CI / test (pull_request) Has been cancelled
178536c99d
chore(IW-463): update review-state for phase 02
Some checks failed
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 8s
CI / compile (pull_request) Successful in 1m22s
CI / dashboard-build (pull_request) Successful in 1m0s
CI / test (pull_request) Has been cancelled
b88953c421
chore(IW-463): update review-state for phase 02
All checks were successful
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 8s
CI / compile (pull_request) Successful in 1m20s
CI / dashboard-build (pull_request) Successful in 1m9s
CI / test (pull_request) Successful in 10m18s
0343e84190
Rescued from a commit the batch run left unreachable. The entry diagnoses
why phase 2's gate never converged: each exit-3 resume commits and pushes a
review-state update, which cancels the CI run it is waiting for.

🤖 Generated with Claude Code
fix(IW-463): exit 1 tells the caller what a spent --timeout budget means
All checks were successful
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 9s
CI / compile (pull_request) Successful in 1m24s
CI / dashboard-build (pull_request) Successful in 1m8s
CI / test (pull_request) Successful in 10m32s
130b7870a7
A wait that uses up its whole budget leaves through exit 1, but every poll
that reached that point had read Running, so nothing failed. Exit 1 named
only red CI, an unreadable forge and a bad argument, and told the caller to
fix it and run again — which times out again on the same budget.

Found by re-running the agent-legibility gate with an isolated agent, which
answered both required questions correctly and then reported that the help
never says what happens when the budget runs out.

- exit 1 names the spent budget and says to raise --timeout
- CommandDeclarationTest gains a content rule over the subject list, so
  phase-merge and ci-gate are covered when they declare in phase 4
- PhasePrHarnessTest pins the in-loop routing, which no test covered; the
  existing budget test exercises only the pre-poll path
- Builder.register refuses a duplicate flag name, which otherwise loses the
  caller's value silently
- CommandHelpTest guards the blank line before every section header

🤖 Generated with Claude Code
Reviewed-on: #481
Issue: IW-463
Phase: 3 - Declarations that cannot be forgotten

This commit marks the checkpoint for phase 3 context generation.
Use ag-revert IW-463 3 to return to this point.

🤖 Generated with Claude Code
Issue: IW-463
Phase: 3 - Declarations that cannot be forgotten

Michal's answers, recorded 2026-09-01:

- Estimate: 12-16h accepted, keeping the CommandArgParser rewrite in this
  phase. tasks.md:17 amended, issue total 46-64h -> 49-68h.
- Leaf validation (D3.6): a leaf refuses only what it cannot turn into its
  own type, never a value's domain shape. --version banana closes;
  LESSONS 9's malformed --checkpoint does not, and that is deliberate.
- dev-docs callers: both known-wrong invocations are covered by dev-docs
  #183 (open). No new issue needed. Corrected the claim that both would
  start failing with exit 1 - only --needs-attention false does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Declarations that cannot be forgotten

107 tasks on the context's eight-step spine, Step 1 (the applicative
Args[A] rewrite and the PhasePr port) isolated as its own commit and
clean stopping point.

Artifacts:
- phase-03-tasks.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Issue: IW-463
Phase: 4 - Worktree and phase lifecycle declare

This commit marks the checkpoint for phase 4 context generation.
Use ag-revert IW-463 4 to return to this point.

🤖 Generated with Claude Code
Worktree and phase lifecycle declare

Artifacts:
- phase-04-tasks.md

🤖 Generated with Claude Code
Adds Args.mapN overloads (arity 2-8), desugaring to the existing
zip/map chain with no new Args case and no parser change. Ports
PhasePr's eight-way zip to it, replacing the eleven-line left-nested
tuple pattern with a direct case-class constructor call. Declared
content and PhasePrHarnessTest are unchanged.

Authorised by implementation-log.md:1083 (Phase 3 hand-off).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Adds CiGateArgsDeclaration.args: one declared Args[CiGateConfig] for
--timeout/--poll-window/--poll-interval, with PhaseMerge.parseDuration
as each leaf's validator, producing a CiGateConfig rather than three
strings a caller re-parses. phase-pr composes it in place of its own
three duration leaves; declared content is unchanged and
PhasePrHarnessTest is green with no changed expectation.

CiGateConfig's pollWindowMs/timeoutMs/pollIntervalMs parameter
defaults are removed (D4.3): a default here is a second answer to a
question the shared declaration already answers. Test fixtures that
constructed CiGateConfig() or a partial CiGateConfig(...) now state
all three durations explicitly.

CommandHelp.usage is made public as usageLine, so a command's own
post-parse hint (Step 3/4) can print the same "Usage: iw …" line
renderRejection does, from one place (D4.4).

CiGateArgs (core/commands/CiGate.scala) is not yet deleted: its two
remaining callers, PhaseMerge and CiGateCommand, are migrated in
Steps 5 and 7. Deleting it now would break compilation before those
commits land.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Declares the four smallest commands: worktrees (--all/--json
switches), status (optional <key> operand + --json, keeping its
post-parse "branch is not an issue id" hint but printing it through
CommandHelp.usageLine per D4.4), open (optional <key> operand +
--prompt as Args.resolved per D4.1), and analyze (required
<issue-id> operand, pass-through exit codes per D4.5, WorktreeLifecycle
group per the context's grouping rationale).

Deletes the args.contains/foldLeft/extractPrompt argv scrapes each
replaced. A missing --prompt value now says "--prompt requires a
value." (was "a text argument") and analyze's usage line moves
stdout -> stderr, per the Testing strategy table.

Adds FakeStateReader.readCallList so a --help harness case can prove
no state was read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Declares the operand commands. rm: required <key> operand plus
--force switch; parseArgs and its stdout usage literal go, replaced
by CommandRejection.reject on stderr. phase-start: required
<phase-number> operand validated with PhaseNumber.parse (D3.6) plus
--issue-id resolved from the branch; the positional filter and inline
usage go.

start re-tests D3.3's alternation and holds: no Args.Alt. Both
<issue-id> and --effort are declared optional
(Args.optionalOperand / Args.resolved), so an empty argv parses
cleanly and checkArgCombination — kept verbatim, printing
CommandHelp.usageLine instead of the Start.usage literal — is the
only place that tells a caller what start needs. The operand's and
--effort's `does` each name the other as the alternative, and the
declaration carries two examples, one per way in.

Rejection text changes at the sites the Testing strategy table names:
rm's and phase-start's missing-argument/usage lines move to the
placeholder-named MissingOperand form and from stdout to stderr;
start's --prompt/--effort MissingValue messages read "requires a
value." StartHarnessTest.scala's "neither issue id nor --effort"
case (checkArgCombination's own text) stays green unchanged, which is
the proof D4.4 was implemented rather than lost.

Behavioural note for implementation-log.md: rm's argument rejection
now happens before readConfig, so an unconfigured project invoked
with no argument sees "Missing required argument: <key>" instead of
"Cannot read configuration".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Declares the three phase commands. phase-advance: both flags
Args.resolved from the branch, per the context's worked recipe.
phase-commit: --title required, --items/--issue-id/--phase-number
Args.resolved; the inline usage string goes.

phase-merge composes the Step 2 shared Args[CiGateConfig] plus
--max-retries (validated with PhaseMergeArgs.parseMaxRetries),
--issue-id and --phase-number; CiGateArgs.parseConfig is no longer
called from this file. Declares exit 3 (it reaches
CommandResult.resume) and exit 1 naming --timeout in both meaning and
caller action, matching PhasePr's exit 1 text for a spent budget.

Adds FakeGit.getCurrentBranchCallList so a --help harness case can
prove no git call was made.

LESSONS: running a new --help/unknown-flag harness test against an
unmigrated gated command (phase-merge, before this commit) can hang
the JVM forever rather than fail an assertion, because FakeClock's
sleep is a no-op and its now never advances without an explicit call
— the poll loop spins with no exit condition. Two Mill daemon
processes for this worktree were left in that spin state and had to
be killed by PID; the daemon's own state directory
(out/mill-daemon) was left corrupt afterward ("Worker wire broken")
and had to be removed to let a fresh daemon start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Declares gate as a parent (Args.unit, no flags of its own) carrying
one subcommand, check, via CommandDeclaration.subcommand — the same
shape Config.scala uses. check's <point> stays a required positional
operand (D3.6: the validator accepts any String, its `does` names
known points as examples, not a closed list) because iw-run:601-610
reads it from argv position 2 to choose which hook files to load; a
--point flag would compile and pass every test here while silently
loading no gates.

GateCommand.run becomes CommandArgParser.dispatch + parse; the
hand-rolled usage literal and the `case "check" :: point :: _` match
go. group = PhaseLifecycle for both, per the context's reasoning:
GateCommand resolves --issue-id/--phase-number from the branch the
same way every phase command does, so its answer is about the phase
the branch is on.

Manual check (Risks 4, nothing in core/test/ can catch a shape
change here): `./iw gate check phase-pr` reports the same workflow
gate phase-pr itself evaluates, since both call WorkflowGateCheck
with the same context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Declares ci-gate: the shared Args[CiGateConfig] from Step 2, plus
--pr-url/--input/--issue-id, all Args.resolved (D4.1) since each has
an honest "when absent" sentence. resolve/resolveKey/pullRequest now
take the parsed options instead of scraping argv a second time.
ci-gate calls ReviewStatePath.resolveFrom(options.input, "--input",
key, env) with its own parsed --input value; the List[String]
resolve entry point ReviewStateCliParser used to extract for it is
deleted, closing the doc comment's own note that ci-gate was its
last caller. Declares exit 3 (reaches CommandResult.resume) with
exit 1 naming --timeout in both meaning and caller action.

With ci-gate migrated (phase-merge already was, Step 5), CiGateArgs
(core/commands/CiGate.scala) is now unreferenced and is deleted,
along with the comment calling it a second copy of the duration
defaults.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Deletes PhaseArgs.namedArg/hasFlag (no callers remain outside their
own now-deleted tests; ReviewStateCliParser.hasFlag is a distinct
helper and does not move). Adds all twelve declared commands to
CommandDeclarationTest's subjects — phase-merge and ci-gate marked
canResume/waitsOnABudget = true — reaching 20 declarations from 15
top-level subjects with no existing assertion rewritten. Adds a
CommandHelpTest case pinning that start's does strings name the
alternative flag/operand, alongside gate's parent/subcommand
rendering cases.

Adds one BATS wiring case (test/phase-start.bats): an unknown flag
on iw phase-start names the flag, creates no branch, exits 1.

Runs and records: the in-repo unknown-flag sweep (no in-repo caller
of the twelve passes an unknown flag, a --leading value, or an extra
bare token; commands/, iw-run and iw-bootstrap are unchanged from
this phase's starting commit, confirmed by diff, not just absence of
a stat line); the Open questions 3 leaf/field audit (thirteen
declarations, one documented exception — gate check's <point>, read
by iw-run from argv, not from the parsed value, unchanged from the
pre-Phase-4 code's own unused match binding); and the legibility
gate on start's rendered help (verdict: legible, Args.Alt not added,
with the caveat that no subagent tool was available in this run so
the read was not genuinely isolated).

Full implementation-log.md entry added: D4.1-D4.7 outcomes, the two
inventory corrections (10 remaining not 11; 25+9=34 declarations not
8/33), the group assignment table for all 25 top-level commands with
Open questions 1 flagged for Michal, the correction to
implementation-log.md:158-159's claim about which CiGate comments
name a phase, and what Phase 5 inherits. LESSONS.md lesson 5's
undercount is fixed to 25/34 with gate check named as the ninth
subcommand.

./iw ./test unit: core 186/186 (+ this phase's additions), dashboard
241/241. ./iw ./test e2e: 150/150 ok, including the new phase-start
case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
test/help-intercept.bats:203 was a ninth rejection-text site, outside the
eight the phase context's Testing strategy table enumerated. Under the
declared parse `iw start --help-me` is refused by name as an unknown flag
instead of falling through to the issue-ID parser.

The case's intent is unchanged: the token still reaches the command rather
than the launcher's help intercept, and the new text proves that more
directly because only the command's own parser can emit it.

Records the ninth site in implementation-log.md and two LESSONS entries.
The cold-legibility re-run of D4.4's gate found the rendered usage line
contradicted the prose it was meant to disambiguate. Michal chose to remove
the asymmetry at its source, matching the shape open/rm already use, rather
than add Args.Alt. checkArgCombination is deleted along with it: a required
operand refuses an empty argv on its own, and there is only one positional
slot left to fill, so "both given" cannot arise either.

iw start --effort <slug> becomes iw start <slug>; iw start <issue-id> is
unchanged. Effort grounding (isEffortOnHead, the two ungroundedEffort
recovery hints) is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
phase-advance, phase-commit, phase-start, phase-merge and phase-pr each
declared a byte-identical Args[Option[String]] for --issue-id, flagged as
deferred duplication in the phase's own code review. Extracted to
PhaseIssueIdArgsDeclaration.args, composed the way CiGateArgsDeclaration.args
already is. GateCommand's and CiGateCommand's own issueIdArgs are untouched
- their from/does prose differs for genuinely different semantics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Resolves Open questions 1 from the phase's group-assignment record: issue
(and the create subcommand Phase 5 declares) fit CommandGroup's six existing
groups no better than a forced placement under ProjectSettings. No
declaration binds to IssueTracking yet - issue is Phase 5's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Records, in implementation-log.md: D4.4's supersession (the gate passed on
its stated criterion, but the cold-legibility reader found the usage line
contradicting the prose, and Michal chose to remove the asymmetry at its
source rather than add Args.Alt); checkArgCombination's deletion and why;
the exact user-visible start change and every doc/test site updated for it;
and changes 2/3 with the corrected 25-command group map, so Phase 5
inherits a map with no blanks.

Annotates every phase-04-tasks.md entry whose recorded outcome these three
changes supersede, and adds a new section for the three changes themselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
"helper" used to fail IssueId parsing outright. With start's <key> now
resolved through WorktreeKey.parseArg (issue-first, effort fallback),
"helper" is a syntactically valid effort slug, so it now fails at the
grounding check instead. Update the assertion to the actual reachable
error, keeping the case's real point intact: a bare value token containing
"help" still reaches the command, not the help intercept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Recorded at the moment found while sweeping the repo for start's
--effort/<issue-id> callers: test/help-intercept.bats:207 escaped a literal
"iw start" search because the binary name is quoted separately from the
subcommand ("$PROJECT_ROOT/iw" start), which is this project's own BATS
idiom throughout. The stale assertion only surfaced when ./iw ./test ran
the BATS suite, not from ./iw ./test unit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Six review skills ran in parallel: 1 critical, 4 warnings, 7 suggestions,
no correctness defect. Full report in review-phase-04-20260902-090538.md.

Fixed here:
- PhaseArgs.scala's PURPOSE line still named namedArg/hasFlag, deleted in
  this phase (critical).
- ReviewStatePath.resolveFrom's docstring narrated the deleted List[String]
  overload, which the project's comment rules forbid.
- GateCommandHarnessTest's check --help case pinned "<point>", which the
  parent's help also renders, so it could not fail on the dispatch
  regression it covered. It now pins "Usage: iw gate check".

Adds the review packet and the aggregated review report.
- PhaseIssueIdArgsDeclaration's PURPOSE line carried a bare phase
  reference, which the comment rules forbid. Removed. The D-number
  citations elsewhere stay: they name a design decision, which has
  precedent in Config.scala, rather than a point in time.
- CommandGroup.IssueTracking has no declaration bound to it until
  Phase 5 declares `issue`, so it now says so in the code rather
  than only in the issue folder.
'iw start iw463' silently fell through IssueId parsing (needs a dash) into
EffortSlug parsing (dash-less charset), then failed effort grounding with
only a /sl-frame hint -- steering a mistyped issue ID toward framing an
effort nobody wanted. EffortSlug.looksLikeMangledIssueId is a new pure
predicate (lowercase letters immediately followed by digits, no dash);
Start.ungroundedEffort's not-framed branch appends a second sentence
naming the TEAM-123 form only when it matches. The primary message and
the framed-but-uncommitted branch are untouched. The uppercase form
(IW463) was already safe, since EffortSlug's charset rejects uppercase
before grounding is ever checked.
IW-463 Phase 4 review fix: close --issue-id and start-prompt coverage gaps
Some checks failed
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 9s
CI / compile (pull_request) Successful in 1m29s
CI / dashboard-build (pull_request) Successful in 1m22s
CI / test (pull_request) Has been cancelled
98b6cab585
All five phase-lifecycle commands compose PhaseIssueIdArgsDeclaration.args,
but phase-merge and phase-pr only ever resolved --issue-id from the branch
name in their harness tests -- an unguarded silent-swap risk in Args.mapN
(two adjacent Option[String] fields). PhaseIssueIdArgsDeclarationTest (new,
mirrors CiGateArgsDeclarationTest) covers the leaf's own contract; one
explicit --issue-id case was added to each of PhaseMergeHarnessTest and
PhasePrHarnessTest, asserting the value reaches behaviour (the review-state
path, the default PR body, the JSON output). Both were proven RED by
swapping the two args' declared order without touching the case-class
field order, then reverted.

test/start-prompt.bats's "fails without a key" case asserted
Missing-required-argument OR Usage, and the Usage branch is true for
almost any start error -- it discriminated nothing. Dropped the fallback
to match StartHarnessTest's unit-level assertion for the same case.
chore(IW-463): update review-state for phase 04
All checks were successful
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 9s
CI / compile (pull_request) Successful in 1m36s
CI / dashboard-build (pull_request) Successful in 1m33s
CI / test (pull_request) Successful in 13m17s
16ee93123e
chore(IW-463): update review-state for phase 04
All checks were successful
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 17s
CI / lint (pull_request) Successful in 10s
CI / compile (pull_request) Successful in 1m32s
CI / dashboard-build (pull_request) Successful in 1m31s
CI / test (pull_request) Successful in 12m6s
a719db24d1
Reviewed-on: #502
Issue: IW-463
Phase: 5 - Every remaining command declares

This commit marks the checkpoint for phase 5 context generation.
Use ag-revert IW-463 5 to return to this point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Every remaining command declares

Records Michal's ruling on Open questions 1: option (a) — the parser rule
that a value beginning with -- is never swallowed stands unchanged across
all 25 commands. Open questions 2 follows from it: feedback needs no
special case either. Both breaks are release-note items.

Artifacts:
- phase-05-tasks.md

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
ag-implement's documented review-state update block passes --phase, --step
and --branch; iw review-state update accepts none of them and the v2 state
file has no fields for them. Also flags that Step 1's fast-path resume
checks state.branch and state.phase, which cannot exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Records the cross-tier hit list for every refusal string the ten Phase 5
commands print today, before any migration starts (the lesson Phase 4's
red suite cost us). Confirms the group map is unchanged and checks off
the Step 0 and Confirm-before-writing tasks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Adds the CommandDeclaration for `version` (--verbose) and `projects`
(--json), migrating run() onto CommandArgParser.parse. Both now reject
--help and unknown flags through the shared parser instead of the old
args.contains scan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Both take Args.unit and now reject any argument by name instead of
silently ignoring it — a deliberate behaviour change (Risks 2), asserted
in the harness tests and recorded in the log. The Step 0 caller sweep
found no in-repo invocation passing either command an argument.

Closes Step 1 of Phase 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
server, server start, server stop and server status each declare fully
(D5.7), replacing Server.usage and the hand-rolled args.head match with
CommandArgParser.dispatch. Rejection wording moves from "Unknown
command"/hand-rolled usage to the declared parser's MissingSubcommand/
UnknownSubcommand text.

The three subcommands' declared exit-1 causes describe the actual code
rather than phase-05-context.md's D5.7 prose, which does not match
stop's and status's real failure paths — recorded as a finding in
LESSONS.md and the log rather than editing the context document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Declares `dashboard` (state-path/sample-data/dev), renames Dashboard.Args to
DashboardOptions per D5.5 and makes it private[commands], and deletes
Dashboard.parseArgs and printHelp. run() parses argv before touching
IW_DASHBOARD_JAR/HOME so --help needs neither.

Repairs the two rejection-text sites the Step 0 sweep named:
DashboardHarnessTest and dispatcher-mill-gate.bats, both moving from
"Unknown argument" to "Unknown flag: --bogus-flag.".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Declares `doctor`'s three switches (--env, --quality, --fix) into a
DoctorOptions case class. The --fix-implies-Quality / --fix-beats---env
precedence rule moves to Doctor.filterCategoryFor, a pure function beside
the command rather than a leaf (D5.2) — it depends on a sibling flag, not
on --env's own value. --fix and --env's `does` text names the interaction,
since the registry walk only checks non-blank, not complete (Risks 4).

DoctorCliFlags and its test are deleted; DoctorCliFlagsTest's three
behavioural cases move onto a new DoctorPrecedenceTest exercising
filterCategoryFor directly. Its fourth case ("ignores unrelated flags") is
not ported — that behaviour is exactly what this phase removes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Declares init's six flags into an InitOptions case class via Args.mapN's
arity-6 overload. --force is a switch; --tracker is Args.valued with an
empty-string "absent" sentinel and a closed-set leaf validator (D3.6);
--team/--repository/--team-prefix/--base-url are Args.resolved, matching
--state-path's "resolved elsewhere" shape from Step 3. --team-prefix's
TeamPrefixValidator stays a domain check after the parse, unchanged.

This widens accepted syntax to a superset: --tracker=X keeps working,
--tracker X now also works. Init.parseArg and its six call sites are
deleted. resolveTrackerType no longer needs Either, since the leaf
validator already restricts its input.

D5.1 held for init: no combine(leaves*) needed, six leaves compose
directly via Args.mapN.

Records a pre-existing hazard in LESSONS.md: Init's interactive
tracker-selection fallback recurses without a bound when a harness test's
FakePrompt queue runs dry, which hung the test JVM during RED; worked
around by queuing safety-net answers rather than fixed, which is out of
this phase's scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Declares feedback's three leaves (<title> required operand, --description
resolved, --type valued) and deletes FeedbackParser's argv half
(parseFeedbackArgs, extractFlagValue). --type validates the string and
Feedback.toIssueType re-parses it, the same knowing double-parse
CiGateArgsDeclaration and PhaseMerge already carry (D5.3) — recorded as a
third call site in LESSONS.md's pending 2026-09-02 entry. The two length
limits stay in FeedbackParser as domain checks run after the parse.

The title becomes a required operand, so an unquoted multi-word title
(iw feedback Fix the bug) is now Rejection.UnexpectedArgument — a
user-visible change alongside issue create's equivalent (Risks 3),
release-note item.

FeedbackHarnessTest's --help-files-no-issue case is asserted on the
tracker's empty call list, not exit code alone; proved RED by a temporary
mutation routing HelpRequested through createForgejoIssue, then reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Declares issue's parent (<issue-id> optional operand, IssueTracking
group) and issue create (--title required, --description resolved).
Deletes IssueCreateParser.scala (whole file, IssueCreateRequest with it)
and IssueCreateParserTest.scala; the 422 and missing-value scenarios move
to IssueHarnessTest. Deletes Issue.printCreateHelp and its --help/-h
guard; CommandHelp.render replaces both. Corrects CommandGroup
.IssueTracking's doc comment now that a declaration binds to it.

The 422 the issue is named for closes with no special-casing: the inline
form --title=--wip: x creates the issue with that exact title (asserted
on the recorded tracker call, not exit code); the quoted form
--title "--wip: x" is a named MissingValue with no request sent
(asserted on the empty tracker call list) — Michal's 2026-09-03 ruling
on Open questions 1(a), unchanged.

Finding: D5.6 says issue's run should use CommandArgParser.dispatch, but
dispatch treats any non-flag bareword as an attempted subcommand once a
declaration carries one (verified with a throwaway probe test), so
routing through dispatch would turn every "iw issue <id>" into
UnknownSubcommand and break issue fetching outright. Issue.run keeps its
existing manual head-token check for "create" instead; create is still a
full declaration for help/registry purposes. Recorded in the log for
Michal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
D5.6's instruction to route issue's run through CommandArgParser.dispatch
could not be followed as written: dispatch treated any unmatched leading
bareword as an attempted subcommand once a declaration carried one, with
no fallback to the parent's own operand, so iw issue TEST-1 would break.
Michal approved widening dispatch instead of leaving issue's hand-rolled
if in place: an unmatched bareword now falls back to ToParent when the
parent's Args declares at least one operand leaf (a repeated flag does
not count, since it is matched by name, not position). Confirmed the
other four subcommand-carrying parents (gate, server, config,
review-state) carry no operand leaf and so keep UnknownSubcommand
unchanged, proved by dedicated CommandArgParserTest cases. Issue.run now
dispatches through the shared function like every other parent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Adds the ten remaining top-level Subject entries to CommandDeclarationTest
(version, projects, register, project-context, server, dashboard, doctor,
feedback, init, issue), expanding the walk to 34 declarations across 25
top-level subjects with server's three subcommands and issue create
folded in automatically. Confirms by rg that all four ad-hoc argv parsers
are gone and nothing in core/ reads argv except CommandArgParser and
ReviewStateCliParser (ReviewState's own combine()-sanctioned reads of the
already-parsed args are pre-existing Phase 3 work, not a violation).
Checks the PhasePrOptions leaf/field invariant by hand across the six
authorship subjects and gives their does/whenToReachForIt text a human
read, confirming doctor's --env/--fix interaction wording carries the
sibling-flag interaction the registry walk cannot verify on its own.
Closes out the Step 0 rejection-text sweep with no break outside the
recorded hit list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Adds test/version.bats, the phase's one E2E wiring round trip (LESSONS
16): iw version --bogus names the flag, exits 1, prints no version line.
Given as a new one-case file rather than folding into projects.bats,
matching the per-command convention every other wiring case in test/
already follows. Confirms test/dispatcher-mill-gate.bats and
test/issue-create.bats both pass, no doctor --help case was added (the
Phase 1 intercept still answers first by design), and nothing touches
iw-run, iw-bootstrap, docs/, .iw/commands/ or any // USAGE: line.

./iw ./test read twice: the first run's exit 1 was three pre-existing
TmuxAdapterTest/TmuxAdapterSendKeysTest failures unrelated to this
phase's ten commands, root-caused to deterministic session names racing
on the one real shared tmux server rather than a regression (isolated
rerun green, full suite immediately green again with no code change in
between) and filed as a new LESSONS entry rather than fixed here. The
reliable run this phase's changes are judged on: exit 0.

Wrap-up records D5.1-D5.7's outcomes, the four-parser inventory
correction (LESSONS lesson 4, corrected in place), the registry's
25-top-level/34-declaration count, both release-note items together,
and the note for Phase 6 that IssueTracking renders as a group of one.
The dev-docs #183 cross-repo question is left open for Michal, box
unchecked as instructed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Two full-suite runs on an unchanged tree failed with different test sets,
all start_dev_server timeouts; each file passes in isolation. Diagnosed as
contention between dashboard-dev-mode.bats's real dev servers and
bloop-cleanup.bash's machine-wide bloop kill, not a Phase 5 regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Four combined runs, four different failure sets; every tier and every named
file green in isolation. The flaky set is exactly the BATS files that spawn
scala-cli, which shares one bloop server that bloop-cleanup.bash kills
machine-wide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Fixes the six warnings Michal approved from review-phase-05-20260904-080458.md
(seven counting the required-parent-operand CommandArgParserTest case already
RED-proved in the tree):

- Init.scala: --tracker's "" absent-sentinel becomes Option[String] via
  Args.valued(...).map(Option.when), keeping the closed-set validator.
  Args.resolved was considered and rejected -- it carries no validator and
  would have silently dropped D5.4's check against Constants.TrackerTypeValues.
- Init.scala: resolveTrackerType's doc comment now states the real mechanism
  (None means absent, Some means already validated) instead of a false claim.
- Feedback.scala: typeArgs now maps to FeedbackParser.IssueType at the leaf,
  so FeedbackOptions carries the domain type and validateAndSubmit no longer
  re-parses it.
- Feedback.scala: exit-1 documentation now lists the missing <title> operand
  and an invalid --type among its causes.
- InitHarnessTest.scala: dropped the stale "--help has no handling" comment
  and asserts the Usage line the Help branch actually prints.
- DoctorHarnessTest.scala: --help test asserts the Usage line, not only
  the absence of check output.
- Dropped this phase's four phase-number citations from evergreen comments
  in CommandDeclaration.scala, FeedbackParser.scala, Issue.scala and
  Doctor.scala, leaving earlier phases' citations untouched.

./mill core.compile and ./iw ./test unit (186/186 core.test, 241/241
dashboard.test) green throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Widen dev-docs #183 rather than open a second issue: mis-spelled
review-state flags, the non-existent --phase/--step/--branch block in three
workflow documents, and stray arguments to the ten commands this phase made
strict all share one root cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
chore(IW-463): register Phase 5 code review artifact
Some checks failed
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 8s
CI / compile (pull_request) Successful in 1m15s
CI / dashboard-build (pull_request) Successful in 1m2s
CI / test (pull_request) Has been cancelled
33836a55af
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
chore(IW-463): update review-state for phase 05
Some checks failed
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 13s
CI / compile (pull_request) Successful in 1m23s
CI / dashboard-build (pull_request) Successful in 1m2s
CI / test (pull_request) Has been cancelled
0dcfe0f233
chore(IW-463): update review-state for phase 05
Some checks failed
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 8s
CI / compile (pull_request) Successful in 1m15s
CI / dashboard-build (pull_request) Successful in 1m0s
CI / test (pull_request) Has been cancelled
7b437b74e7
chore(IW-463): update review-state for phase 05
Some checks failed
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 11s
CI / compile (pull_request) Successful in 1m20s
CI / dashboard-build (pull_request) Successful in 1m12s
CI / test (pull_request) Has been cancelled
ac18753d30
chore(IW-463): update review-state for phase 05
All checks were successful
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 7s
CI / compile (pull_request) Successful in 1m13s
CI / dashboard-build (pull_request) Successful in 1m0s
CI / test (pull_request) Successful in 10m7s
0ff7c2c8fb
Reviewed-on: #520
These edits were made while PR #520's CI was green and deliberately held
unpushed, so they were not part of the merge. The lesson records that the
Running-path cancellation is already fixed on main by 10d52e8f (PR #448),
and that IW-463 re-hit it only because the branch predates that fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
# Conflicts:
#	core/adapters/ForgejoClient.scala
#	core/commands/Config.scala
#	core/commands/Doctor.scala
#	core/commands/Init.scala
#	core/commands/Issue.scala
#	core/commands/Open.scala
#	core/commands/PhaseCommit.scala
#	core/commands/ProjectContext.scala
#	core/commands/Projects.scala
#	core/commands/Register.scala
#	core/commands/Rm.scala
#	core/commands/Start.scala
#	core/commands/Status.scala
#	core/model/PhaseArgs.scala
#	core/test/PhaseArgsTest.scala
#	core/test/PhasePrHarnessTest.scala
#	test/review-state.bats
main had already added --phase/--step/--branch to review-state; IW-463 was
278 commits behind and did not have them. Same shape as the phase-pr
finding: a stale branch made working tooling look broken, and a merged fix
leaves no open issue for a tracker search to find.

Narrows the dev-docs #183 widening: class 2 does not apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Issue: IW-463
Phase: 6 - One index, one truth

This commit marks the checkpoint for phase 6 context generation.
Use ag-revert IW-463 6 to return to this point.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
One index, one truth

98 tasks on the context's seven-step spine. Steps 1-3 add code and delete
nothing; Step 4 is the single irreversible commit and the rollback target.
The clean stopping point is the end of Step 3. Do not stop between Steps 4
and 5 — that state lets a plugin command run on a help request.

Three open questions still need Michal and are recorded as tasks, each with
its implementable default: iw update's help wording (D6.5(b) or (c)),
whether deciding D6.7's rule satisfies the invariant without the 25-line
edit, and whether register's 164-character does is shortened.

Artifacts:
- phase-06-tasks.md

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
All three open questions are decided before Step 1, so no question is
carried into the irreversible phase:

1. iw update's help: D6.5(b). The iw-bootstrap branch is kept and its
   placeholder gets real prose. One accepted, documented exception to
   "one source of truth" — update is answered before any jar exists.
2. Picking D6.7's rule satisfies the invariant. Phase 6 records that
   // PURPOSE: in commands/*.scala describes the shim; Phase 8 makes the
   25-line edit when it rewires the skill build.
3. The five long does strings stay. Decide from the rendered index, not
   the character count; any shortening is its own commit.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
The ruling is recorded; Step 7 must not ask again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Records the three baselines the phase is measured against (108-line --list,
the Phase 1 placeholder answering `review-state update --help`, and the thin
core.jar failing to load any command object), the thirteen-suite BATS hit list
with its five process-level failures and its four vacuous assertions, and
Michal's three rulings of 2026-09-05.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
CommandRegistry.topLevel becomes the one list of the 25 top-level commands in
production code, with lookup by name and the ordered group walk. CommandIndex
renders it: seven headings in CommandGroup's own order, one line per top-level
command, and a long sentence wrapped under a hanging indent rather than
truncated — a truncated sentence is a second description nobody can edit at its
source.

CommandDeclarationTest is re-pointed at the registry in the same commit, per
D6.1. Its 25-entry roster becomes a side table of the two facts a declaration
cannot state (canResume, waitsOnABudget) keyed by name, guarded in both
directions: a registered command with no row fails at construction, and a row
naming no registered command fails the coverage test.

Nothing outside core/ changes; nothing is deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
iw.core.commands.Help resolves a name against the registry and descends through
CommandArgParser.dispatch while the declaration carries subcommands, so
`--command review-state update --help` reaches update's own declaration by
exactly the tokens that reach it when the command runs — no second resolution
rule. The walk is pure and its answer carries the declaration it reached, so the
subcommand test asserts the dispatch rather than a string the parent also
renders.

Three exit codes for the launcher to read: 0 rendered, 1 refused, 2 not a help
request after all. The last is a safety valve, reached only when a parse
consumed the help token as a flag's value.

Nothing is wired to the launcher yet, and nothing is deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Additive: nothing is deleted and the phase remains reversible.

mill_classpath is a separate helper beside mill_jar_path, not a widening of it —
core.runClasspath returns an array where core.jar returns a scalar, its entries
carry two prefix forms, and three of them are not jars. Nothing is filtered:
java -cp ignores a missing entry at no cost, and a filter would add one stat per
entry to every help request. A contract case pins all three facts, none of which
Mill documents.

ensure_help_classpath mirrors ensure_core_jar's three tiers, resolving the
release assembly rather than the thin build/iw-core.jar — that jar carries no
scala class at all and every command object fails to load from it.

resolve_command_target lifts describe_command's name validation, namespace
decision and file resolution, with all nine of its error messages, into one
function placed above ensure_core_jar's call site. Only describe_command's
built-in branch routes through Help; the plugin and project branches keep the
scrape until Step 5.

The five bare-temp-dir suites take IW_HELP_CLASSPATH from one shared helper that
resolves it once per bats run and caches it, so no setup() queries Mill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
This is the phase's one irreversible commit and the target a revert aims at. It
carries nothing else: no rename, no reformat, no unrelated repair. Do NOT squash
it with the commits either side of it.

parse_command_header is deleted, with all fourteen of its call sites.
list_commands renders the index through Help --index and then names the plugin
and project commands it discovers; describe_command loses both its rendering
tails; the Phase 1 intercept is replaced, for built-in commands only, by an
exact-token pre-filter that resolves the target and hands its own argv to the
declaration's parser. Plugin and project commands keep a stop — deleting it for
them would let `iw kanon/implement --help` run kanon's command, which is the
defect the intercept exists to prevent. Step 5 gives that stop its wording.

Bare `iw` and `iw --help`/`-h` render the index instead of six lines pointing at
it. All 25 `// USAGE:` lines leave commands/*.scala, together with the
explanatory notes under three of them; every `// PURPOSE:` is untouched, per
D6.7, because the published skill build reads those lines as command summaries
until Phase 8 rewires it.

Every assertion this commit breaks is repaired in it. Three of them inverted
rather than changed wording: help-intercept case 8 (the help path now must
resolve a classpath, so its subject becomes "no scala-cli and no command run"),
issue-create's "names no per-command flags" (rendered help must now name them),
and cases 7a/7b, whose `!= *"was not run"*` halves would have gone vacuous the
moment that string left iw-run. Vacuous is worse than red, so both now assert
the absence of what the two live stops actually print.

A revert of this commit alone, after Steps 5-7 land, un-deletes
parse_command_header while the rewritten list_commands no longer calls it: dead
Bash, not a broken tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Deliberately a separate commit from the deletions, so the fallback's own tests
fail for one commit and are seen to fail.

Help gains --external <name>, repeated after --index and standing alone for one
command. ExternalCommand reads a name as a plugin's, a project's, or neither,
and CommandIndex owns both the index's trailing sections and the four-line
answer one external command's help request gets — so Phase 7 replaces one
renderer instead of hunting prose through Bash. Both section headings are
reproduced verbatim: eleven assertions across three suites read those two
strings, four of them negatively, and all eleven still pass unchanged.

The two fail-if-run fixtures are the guard against the one way this phase could
re-open a merged defect: `iw testplugin/loud --help` must not reach kanon-shaped
plugin code. Both cases assert the absence of the fixture's own output first, so
a regression report carries the evidence. Proved fallible by making the
pre-filter never fire: both cases then failed on the fixture's output, not on
the exit code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
iw-bootstrap's `update` branch keeps its intercept and its placeholder becomes
two lines of real prose, per D6.5(b). This is the one accepted, documented
exception to "one source of truth": `update` is answered before any release is
downloaded, so there is no jar to render from and no iw-run to delegate to, and
it has neither a command script nor a declaration.

docs/testing.md's shim example drops its `// USAGE:` line and its
`// PURPOSE: <one-line summary>` now reads as the shim's own purpose, with a
paragraph stating D6.7's rule: the declaration is the only copy of a command's
usage.

The log records D6.1-D6.11's outcomes, the four latency numbers measured end to
end through the real launcher, the plugin/project description regression that
must be named in the PR description, the corrected BATS estimate, the
six-groups-versus-seven note a reviewer reading the Gherkin will need, and that
Risks 4's --no-daemon retry is now higher priority and still not implemented.

Two lessons filed, both pending: package-release.sh's stale 0.1.0 default makes
every bootstrap.bats case skip while reporting `ok`, and the unit-tier guard
Testing (c) asks for cannot be made falsifiable — that task is left unchecked
with its reason rather than checked on a test that cannot fail.

#202 stays closed (superseded by #463 on 2026-08-27) and now carries a comment
naming the invocation that works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
The tasks.md index flip is phase-merge every other phase. Marking it
before the pull request exists claims a state that is not true yet, and
status that rots is the thing LESSONS.md forbids. The Story 5 mapping
row, which records what the phase delivers, stays as written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Cases 7a and 7b forbade a sentence no code prints, so both passed
unconditionally. Correcting the wording was not enough: with an exact-token
pre-filter neither argv reaches resolve_command_target, so no stop wording is
reachable on any path, and Help and the command refuse an unknown flag in the
same words — absence of wording cannot say which one answered.

The pre-filter now emits a trace line, and both cases assert its absence under
IW_TRACE=1. Mutating the match to a substring turns both red on that exact
assertion; the mutation is reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
C1: `command_names_in` offers only names `resolve_command_target` accepts,
so the listing and the resolver agree on what a command name is, and
`Help.answer` drops an external name it cannot read instead of refusing the
whole index. Either fix alone survives `.iw/commands/build me.scala`; both
are kept because the launcher and the renderer answer the question from
different sides.

C2: `CommandRegistryTest` reconciles `CommandRegistry.topLevel` against
`commands/*.scala` in both directions, by the launcher's own name rule.

C3: the two negatives in `issue-create.bats` and `feedback.bats` now pin
strings the success paths actually print.

C4: `dashboard --help` proves the server was not started by the absence of
the `mill_jar_path dashboard.assembly` trace, not by two strings this path
cannot produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
C5: `scripts/package-release.sh` defaults to the VERSION file, so the
obvious invocation builds the tarball `test/bootstrap.bats` looks for;
`IW_REQUIRE_RELEASE=1` turns that suite's skip into a failure; and the test
job packages a tarball before running the suites, with the flag set. Tier 2
of `ensure_help_classpath` — the tier every consumer install uses — is now
exercised on every code run instead of by five silent skips.

W12: the mill stub moves to `$INSTALL_DIR/mill`, the only mill the launcher
runs, so its marker can be created and the two absence assertions can fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
W1: `CommandRegistry.byGroup` and its two tests are gone — a second grouping
walk with a different ordering rule, read by nothing in production.
W2: `CommandIndexTest` reads `Help.indexEntries` instead of rebuilding it.
W3: `help-intercept.bats` asserts the `[iw-run] help_intercept` trace is
present for an exact `--help` token, so cases 7a and 7b's absences mean
something.
W4: `IW_HELP_CLASSPATH` is held to the bar its two siblings apply — every
entry an absolute path to a .jar or a directory of classes.
W6: the unreachable `exit 0` after `list_commands` is gone.
W7: the four flag assertions pin `name + does`, which only `flagsSection`
emits, instead of a string the usage line already carries.
W11: the shared classpath helper always resolves through Mill, so no run can
render help from a stale jar.
W13: the write-only `TARGET_PLUGIN` is gone.

Also: the inert `// USAGE:` lines are dropped from the fixtures whose tests
do not read them, `namesUnder` takes the render it reads, `docs/testing.md`
states the shim's one-line PURPOSE exception, and the help path guards on
`command -v java` as the launcher already does for scala-cli.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
W5: ten comments now describe the code as it is. Every load-bearing fact is
kept — the heading strings eleven assertions read, the eager-list
measurement, the reason the bats classpath is cached — and only the account
of what used to be there is dropped.

W14: the three comments dated to "Phase 7" state the condition instead: an
external command has no declaration because its owner ships no command
index. One greppable `TODO(IW-463)` in `CommandIndex.fallback` carries the
pointer. The three bats comments carrying the same date are rewritten too.

W8: `Answer.NotAHelpRequest` IS reachable from the launcher, contrary to the
review's reading. `CommandArgParser.looksLikeFlag` recognises a `--` prefix
only, so `-h` is a legal value for a flag: `iw phase-commit --title -h`
fires the pre-filter, leaves Help with exit 2, and dispatches the command.
The exit-2 arm stays; the comment and the test that read as coverage of it
now use argv the launcher can produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
The same CLAUDE.md rule as 4ab6dbfb, in three bats comments the review did
not list. Each states what the case pins now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
test/bootstrap.bats is the only suite that exercises the release tier of
ensure_help_classpath. An inherited IW_HELP_CLASSPATH sent all five cases
down tier 1 instead, so setup() clears it.

export_help_classpath now requires BATS_RUN_TMPDIR rather than falling back
to a fixed path that would outlive the run and serve a stale classpath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Help exits 2 when a help token turns out to be a flag's value. Nothing
proved iw-run acted on it: replacing the guard with an unconditional
exit left the whole suite green. Case 7d dispatches 'phase-commit
--title -h' and reads the command's own refusal and exit code.

ensure_help_classpath's three-tier block sat above help_classpath_is_usable,
which left the predicate carrying a contract it does not have and
ensure_help_classpath undocumented. Each function now carries its own block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
The roster gate restated iw-run's command-name regex in Scala, so a
loosened rule in the launcher would leave the gate passing against its
own stricter copy. CommandRegistryTest now reads the rule out of
command_names_in.

CommandIndex.render keeps input order, so a group's rendered sequence is
the hand-typed order of CommandRegistry.topLevel. One assertion pins the
Phase lifecycle group's sequence.

An assert in HelpTest ANDed two conditions under one message; split so a
failure names which half broke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
package-release.sh took an unvalidated version into rm -rf and mkdir -p,
so a '/..' sequence walked out of release/. It now requires X.Y.Z, the
same rule release-publish.sh applies.

release.yml ran ./iw ./test with no tarball and no IW_REQUIRE_RELEASE, so
the job that cuts the release skipped all five bootstrap cases and
reported ok. It packages from VERSION first — the name test/bootstrap.bats
builds — and requires the tarball. The tag-derived packaging step is
unchanged.

The ci.yml packaging step gets the 15-minute budget every other build step
in the file has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
docs/testing.md now covers IW_REQUIRE_RELEASE=1 (what it does, that both
workflows set it, that packaging runs first) and the commands/-to-registry
roster gate.

The dev-gate suite's --help case named VITE_DEV_URL as its subject, but the
variable is read only inside the dashboard JVM, which the help path never
starts. The assertion it made is already made by dashboard-jar-launch.bats
and help-intercept.bats, and it paid a dashboard.assembly build for it.

Three comments narrated history; two credited a negative assertion with
evidence the exit code and usage line actually carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
The test runner is an iw command, so a BATS suite it starts inherits
IW_CORE_JAR and the launcher makes no Mill query. The trace assertion
passed standalone and failed under ./iw ./test. The command's own refusal
and exit code prove the fall-through in either environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
Iteration 1: 5 critical, 14 warnings. Iteration 2 over the fixes: 0
critical, 9 warnings, all closed.

The finding worth keeping: the phase as first written re-created the
defect this issue exists to remove, one layer up. It moved the roster from
filesystem discovery to a hand-kept registry and left the launcher
resolving by file existence, with no gate. That is now gated, and the gate
reads the launcher's own rule rather than restating it.

Six assertions across the phase could not fail. Two silent coverage holes
closed, one of them created by the first fix pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
One index, one truth
All checks were successful
CI / changes (pull_request) Successful in 2s
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 7s
CI / compile (pull_request) Successful in 1m20s
CI / dashboard-build (pull_request) Successful in 1m2s
CI / test (pull_request) Successful in 10m40s
414268f153
- iw.core.commands.Help renders the grouped index, one command's help, and a parse rejection, from the declarations alone
- the comment-header scrape and all 25 // USAGE: lines are deleted; e3a53bfc is the rollback target and Steps 3-5 must not be squashed
- CommandRegistryTest gates the registry against commands/ in both directions, reading the launcher's own name rule out of iw-run
- a filename in a cloned repository can no longer empty the command index
- test/bootstrap.bats had been skipping all five cases; it now runs, and both CI workflows package a release first
- plugin and project commands degrade to name-only until Phase 7 restores rich descriptions
chore(IW-463): update review-state for phase 06
All checks were successful
CI / changes (pull_request) Successful in 2s
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 11s
CI / test (pull_request) Successful in 10m42s
CI / compile (pull_request) Successful in 1m16s
CI / dashboard-build (pull_request) Successful in 1m3s
05b9ac0308
chore(IW-463): update review-state for phase 06
All checks were successful
CI / changes (pull_request) Successful in 2s
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 7s
CI / contract (pull_request) Has been skipped
CI / compile (pull_request) Successful in 1m16s
CI / dashboard-build (pull_request) Successful in 1m2s
CI / test (pull_request) Successful in 10m32s
9c99d6b5ea
Reviewed-on: #535
Issue: IW-463
Phase: 7 - Plugins describe themselves

This commit marks the checkpoint for phase 7 context generation.
Use ag-revert IW-463 7 to return to this point.

Review found eight wrong citations, three internal contradictions and
three wrong counts; all corrected and re-verified against the tree.
Two items are surfaced for decision, not settled: D7.2 proposes an
amendment to DECISION 2, and the project-command half is out of scope
until CLARIFY 1 is answered. Risks 7 recommends splitting the phase.

🤖 Generated with Claude Code
Issue: IW-463
Phase: 7 - Plugins describe themselves

All five open questions answered by Michal on 2026-09-05. Zero CLARIFY
markers remain; each is now a dated decision with its reasoning.

- D7.2  reader token --command-index <dir>, --external stays the roster
- D7.8  object IwCommands in lib/, applied symmetrically via .iw/lib/
- D7.9  digest covers lib/ as well as commands/
- D7.10 one mismatch warning per invocation on stderr
- D7.11 eighth CommandGroup case, Authoring
- D7.12 the generator is named command-index
- D7.13 this repository ships no manifest of its own here

DECISION 2 in analysis.md gains a dated amendment recording the three
points that supersede it. The phase ships whole; the split was declined.
Estimate revised to 9-12h against tasks.md's 4-6h, accepted as a known
deviation, and tasks.md is left unamended per the Phase 3-6 precedent.

🤖 Generated with Claude Code
Plugins describe themselves

Artifacts:
- phase-07-tasks.md

🤖 Generated with Claude Code
- iw command-index writes and verifies a plugin's or project's commands.index.json from its own IwCommands.topLevel
- Help merges a manifest's facts onto the disk roster, so a plugin command gets a sentence without ever being executed
- Five degraded paths: absent, unreadable, unparseable, unknown-version and stale manifests fall back to the name-only line
- Manifest strings are stripped of terminal escapes and bidi overrides at the reader's single choke point
- A malformed or oversized manifest degrades rather than crashing the whole index
- command-index's scratch directory is per-user and owner-only, and refuses a planted symlink, file or loosened directory
- Eighth CommandGroup case, Authoring, holding command-index
- docs/plugins.md states the authoring contract for dev-docs 161
chore(IW-463): register phase 7 code review artifact
Some checks failed
CI / changes (pull_request) Successful in 2s
CI / contract (pull_request) Has been skipped
CI / compile (pull_request) Successful in 1m27s
CI / format (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 9s
CI / dashboard-build (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
1d5b9f1091
chore(IW-463): record the IssuePollerTest flake blocking pre-push
All checks were successful
CI / changes (pull_request) Successful in 5s
CI / format (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 9s
CI / compile (pull_request) Successful in 1m24s
CI / contract (pull_request) Has been skipped
CI / dashboard-build (pull_request) Successful in 56s
CI / test (pull_request) Successful in 14m19s
055007b5e6
chore(IW-463): update review-state for phase 07
All checks were successful
CI / changes (pull_request) Successful in 3s
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 8s
CI / compile (pull_request) Successful in 1m20s
CI / dashboard-build (pull_request) Successful in 1m7s
CI / test (pull_request) Successful in 14m18s
a734660ced
chore(IW-463): update review-state for phase 07
All checks were successful
CI / changes (pull_request) Successful in 3s
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 9s
CI / test (pull_request) Successful in 14m4s
CI / compile (pull_request) Successful in 1m26s
CI / dashboard-build (pull_request) Successful in 1m17s
b68bbf0bbf
Reviewed-on: #538
Help.answer's scaladoc documents the external form in prefix order,
[--command-index <dir>] --external <name>, but the match arms accept
only the suffix order. Both launcher sites emit suffix order, so the
code is the correct side and the comment is wrong.

Recorded pending, proposed home "new tracker issue": the fix touches a
file Phase 8 opens only under one of its open routes.

🤖 Generated with Claude Code
Issue: IW-463
Phase: 8 - Generated docs and skill

This commit marks the checkpoint for phase 8 context generation.
Use ag-revert IW-463 8 to return to this point.

Two rounds of review over the draft. Corrections that changed the plan:

- analysis.md:817 says docs/commands/*.md, plural, so the page roster is
  decided (A), not an open three-way question.
- build-iw-cli-skills rule 1 discovers commands by listing commands/*.scala.
  Nobody had named it. It is a second roster, and is now in scope.
- README.md:65-77 is a further hand-kept copy carrying syntax lines for ten
  registry commands. No story named it; it gets its own decision, D8.10.
- Story 6's "~0.09 s per help call" does not reproduce: 0.23-0.38 s measured.
  The conclusion survives, because there are only 26 commands.
- The skill's .input-manifest hashes none of core/commands/, where the facts
  have lived since Phase 2, so incremental builds are blind to a changed does.

D8.2 (generator mechanism) and D8.3 (the prose in review-state.md that no
declaration holds) block implementation. D8.1 needs a one-line confirm.
D8.6 and D8.10 are low-stakes. D8.4, D8.5, D8.7, D8.8 and D8.9 are taken.

Deleting SKILL.md's "Current Known State" block loses nothing the retained
rules need: its two load-bearing bullets are already stated by rule 1 of
the iw-command-creation section, which stays out of scope.

Estimate 6.5-8h against tasks.md's 4-6h, a known deviation; tasks.md is
left unamended per the Phase 3-7 precedent.

🤖 Generated with Claude Code
Issue: IW-463
Phase: 8 - Generated docs and skill

All five open questions answered by Michal on 2026-09-06. Zero CLARIFY
markers remain; each is now a dated decision with its reasoning.

- D8.1  option A - the pages that exist today, not one per command
- D8.2  route (a) - a 27th command, iw command-docs [--check]
- D8.3  option (c) - a generated page plus a hand-written companion
- D8.6  option (b) - a docs/commands/* code arm in change-scope.sh
- D8.10 option (a) - strip the syntax column from README.md:65-77

D8.2 makes command-docs the 27th registry entry, so it needs its own
declaration, shim and group, and the roster gate covers it. The document
now declares 26 as the inherited count and 27 as the post-phase count,
and every occurrence is one or the other deliberately.

D8.3 splits review-state.md into a generated page plus
docs/review-state-cards.md, and rm.md plus docs/worktree-removal.md.
The companions sit at docs/ root, which lets D8.6 treat docs/commands/
wholesale as code. The prose must land before the regeneration.

The split adds a companion map inside command-docs - the only hand-kept
thing this phase adds. It holds links, not facts, and a test asserts each
named companion exists.

Estimate 6.75-8.5h against tasks.md's 4-6h, a known deviation; tasks.md
is left unamended per the Phase 3-7 precedent.

🤖 Generated with Claude Code
Generated docs and skill

118 marked tasks across the context's six approach steps, plus 8
fallibility mutations, 25 acceptance boxes and an acceptance-to-task map
covering all 25.

Three departures from a literal reading of the context, each recorded in
the file:

- The two change-scope.sh verdict cases go into test/change-scope.bats,
  which already holds 21 cases, rather than into the new suite.
- docs/testing.md becomes mandatory, not conditional: scope item 8 and
  acceptance box A8 both require the authoring sentence.
- The companion map stays in CommandDocsCommand and the renderer takes
  the source path and the companion link as parameters, so the link text
  has one copy.

No task copies a command count into code; each reads CommandRegistry.

Artifacts:
- phase-08-tasks.md

🤖 Generated with Claude Code
Moves everything docs/commands/review-state.md and docs/commands/rm.md carry
that no CommandDeclaration can express — the schema contract, backward
compatibility, common workflows, troubleshooting, what `rm` refuses beyond its
exit codes, and the dashboard's Remove control — into two new companion pages
at docs/ root: docs/review-state-cards.md and docs/worktree-removal.md.

Committed on its own, before either docs/commands/ page is regenerated
(Phase 8 Step 3, Risks 2's containment): a single commit that both moved prose
and regenerated a page would hide any loss it caused. Nothing is deleted here;
the two docs/commands/ pages and their README.md links are re-pointed in the
next commit, once these companions exist to receive the content leaving them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
- iw command-docs writes docs/commands/ from the declaration registry; --check gates drift
- review-state.md and rm.md are generated; their unexpressible prose moved to companions first
- build-iw-cli-skills reads the registry: its roster, categories, exclusion and Known-State block deleted
- a docs/commands/* arm in change-scope.sh makes a hand edit to a generated page pay the code gate
- two review iterations closed 5 criticals; the symlink write path is guarded behind CommandEnv
chore(IW-463): register phase 8 review artifacts
Some checks failed
CI / changes (pull_request) Successful in 4s
CI / contract (pull_request) Has been skipped
CI / compile (pull_request) Successful in 1m41s
CI / dashboard-build (pull_request) Successful in 1m38s
CI / format (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 10s
CI / test (pull_request) Failing after 17m29s
c888dcaf22
fix(dashboard): close() waits for the poll in flight
Some checks failed
CI / changes (pull_request) Successful in 3s
CI / contract (pull_request) Has been skipped
CI / compile (pull_request) Successful in 1m25s
CI / format (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 9s
CI / dashboard-build (pull_request) Successful in 1m48s
CI / test (pull_request) Failing after 16m3s
f48fd685c4
IssuePoller.close() called shutdownNow() and returned, so the poller
thread could still be inside a write while the caller deleted what it
had written. IssuePollerTest's tempDir fixture does exactly that, which
is why CI failed with DirectoryNotEmptyException on a run whose diff
touched no dashboard code.

close() now follows shutdownNow() with a bounded awaitTermination, so
"closed" means the poller has let go of its files. The bound keeps a
wedged poll from holding a shutdown open; an interrupt during the wait
restores the interrupt flag.

The test pins the guarantee: a poll that rides out its interrupt must
have finished by the time close() returns. It fails against the old
body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
fix(dashboard): a started server can be stopped
All checks were successful
CI / changes (pull_request) Successful in 3s
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 8s
CI / compile (pull_request) Successful in 1m24s
CI / dashboard-build (pull_request) Successful in 1m21s
CI / test (pull_request) Successful in 17m56s
6325d59d69
CaskServer.start ran a server and returned nothing, so nothing could
stop one. Every test in CaskServerTest leaked a running server with two
live pollers, and each test's finally deleted the state directory those
pollers were still writing — which is why CI failed with
DirectoryNotEmptyException on a run whose diff touched no dashboard code.

start now returns a RunningServer that stops the listener and closes the
server; close() closes both pollers. PullRequestPoller.close() gained the
same bounded awaitTermination IssuePoller.close() has, so closing a
server means both its writers have let go.

CaskServerTest records each server by the state path it writes and stops
it at the head of every teardown, before anything is deleted. The other
three call sites discard the handle explicitly.

The PullRequestPoller test fails against the old close(): a poll that
rides out its interrupt must have finished by the time close() returns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
chore(IW-463): update review-state for phase 08
All checks were successful
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 13s
CI / compile (pull_request) Successful in 1m36s
CI / dashboard-build (pull_request) Successful in 1m30s
CI / changes (pull_request) Successful in 3s
CI / lint (pull_request) Successful in 12s
CI / test (pull_request) Successful in 18m27s
b2e31c075e
Reviewed-on: #539
workflow(IW-463): triage lessons — 22 drop, 17 follow-up, 0 fixed elsewhere, 22 decide
Some checks failed
CI / changes (pull_request) Has been cancelled
CI / compile (pull_request) Has been cancelled
CI / format (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / dashboard-build (pull_request) Has been cancelled
CI / contract (pull_request) Has been cancelled
11a9596dce
Sorted all 61 LESSONS.md entries (60 headed, plus one orphan block at :605
that carries a Decision with no heading of its own). None held an APPROVED or
REJECTED decision.

  Drop                    22   status, working notes consumed by this issue's
                               own phases, fixes already committed on this
                               branch, or duplicates of a filed issue
  Follow-up               17   work rather than knowledge; one line each in
                               project-management/issues/IW-463/FOLLOW-UPS.md
  Already fixed elsewhere  0   the one entry claiming it was stale: 10d52e8f,
                               407811a0, 4ab6dbfb and 055007b5 are all
                               ancestors of HEAD since the 2026-09-05 main merge
  Decide                  22   five walked with Michal, seventeen retained

Promotions applied, each cited in its entry's Decision line:

  docs/launcher-performance.md   new — the measured per-invocation cost of
                                 scala-cli vs a plain JVM, and which classpath
                                 to use in a release and in a dev checkout
  docs/testing.md                new section: a green unit tier does not mean
                                 ./iw loads that code; do not run a bare ./mill
                                 while ./iw ./test is running
  .git-hooks/pre-commit          scalafix --check moved forward from pre-push,
                                 so a mechanism built from a banned construct
                                 fails at the commit that writes it

One walked entry was rejected as already delivered by docs/testing.md's
"Asserting a rejection". Seventeen Decide entries keep _pending_ decisions by
Michal's call: the ground they cover is already captured elsewhere.

Nothing was removed from LESSONS.md. Promoted entries were rewritten into the
capture shape with a Surface line naming what delivers them at the trigger.

Also carries the completion-flow artifacts generated in the same session:
review-packet.md for the whole feature branch, and release-notes.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
chore(IW-463): update review-state to all_complete
All checks were successful
CI / changes (pull_request) Successful in 3s
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 8s
CI / compile (pull_request) Successful in 1m32s
CI / dashboard-build (pull_request) Successful in 55s
CI / test (pull_request) Successful in 14m11s
163d6a2331
Merge main into IW-463
All checks were successful
CI / changes (pull_request) Successful in 2s
CI / contract (pull_request) Has been skipped
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 8s
CI / compile (pull_request) Successful in 1m25s
CI / dashboard-build (pull_request) Successful in 1m5s
CI / test (pull_request) Successful in 13m23s
3ab9cd4c71
Seven conflicts, all where main and this branch had fixed the same things.

  IssuePoller.close()        Both bounded the wait after shutdownNow. Kept this
                             branch's body — it also restores the interrupt flag
                             — under the name PullRequestPoller already uses, and
                             dropped the duplicate CloseWait the automerge left
                             beside CloseTimeout.
  CaskServerTest             Complementary, so both were kept: this branch stops
                             the server before a test deletes what its pollers
                             write; main's removeStateDir tolerates whatever a
                             save in flight left behind.
  PullRequestPollerTest      Import union.
  FakeCommandEnv.write       Both a per-path failure map and a global write
                             result now live on the fixture. The specific answer
                             wins, then the general one.
  InitHarnessTest            Both sides kept: this branch's four help and
                             rejection cases, main's config read-back helpers.
                             The four cases move onto those helpers, because main
                             replaced the fixture's config capability.
  Init.scala                 Took this branch's call site, which already carried
                             the Left/Right match, and dropped configPath to
                             match main's writeConfig signature.
  Doctor.scala               Took this branch's declaration imports, less
                             ConfigSerializer — main routed every command's
                             config access through ProjectConfigFile and pins it
                             with an invariant test.

Three lint violations followed from the merge rather than from either side:
main widened the scalafix gate to dashboard/, so this branch's poller and
server-stop tests were linted for the first time. A @volatile var in each poller
test became an AtomicBoolean, and an AtomicReference seeded with null became one
seeded with None, the idiom the same file already uses for startupError.

main also made ScalafixGate the single definition of the lint argv. The
pre-commit scalafix step added on this branch hardcoded a different string, so
it now runs the same command as pre-push, CI and ./iw ./test, and
ScalafixGateTest pins the pre-commit hook alongside the other three.

Verified on the merged tree: ./iw ./test unit 0, ./iw ./test compile 0,
scalafix --check 0, scala-cli fmt --check 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WehHdHp9MRKJkrhGFiKNxa
mph merged commit 2634ced33e into main 2026-09-09 07:57:16 +00:00
Sign in to join this conversation.
No description provided.