# v0.3.0 QA gate report

Gate run by `tester-v03` at HEAD `783a642`, on the five tasks in `testing`
(T-0012 … T-0016). Scope: `docs/product/v0.3-scope.md`
`## Release criteria for v0.3`, `docs/product/design-v0.3.md` §7 and
`## Conflicts resolved`, and each task's acceptance criteria. Every claim below
was produced by running the command shown, not by citing a test.

Scratch boards live under `/tmp/qa-boards/*`; the harness scripts under
`/tmp/qa/*.test.ts` import the real `src/` modules (no mocks) and were run with
`npx vitest run --root /tmp/qa <file> --disable-console-intercept`.
The repo's own board was not used for destructive runs.

## Baseline facts

| Fact | Command | Result |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | exit 0, no output |
| Tests | `npx vitest run` | 16 files, **264 passed (264)**, 0 skipped/todo |
| Test count > 184 | above | 264 > 184 |
| Runtime deps | `node -e "…require('./package.json').dependencies"` | exactly `{"yaml":"^2.9.0"}` |
| Working tree | `git status --short` | clean; only `node_modules/` ignored |
| Stray files in repo board | `ls -a .pi/kanban .pi/kanban/tasks \| rg 'repair\|lock\|tmp'` | none |

## Release criteria (`v0.3-scope.md`)

| # | Criterion | Command / script | Result |
|---|---|---|---|
| 1 | vitest + tsc clean, count > 184 | `npx tsc --noEmit; npx vitest run` | **PASS** — tsc exit 0; 264 passed |
| 2 | deps still exactly `yaml` | `node -e` on `package.json` | **PASS** — `{"yaml":"^2.9.0"}` |
| 3 | `reason` on Assign/Move/HistoryEntry | `rg -n "reason" src/schemas.ts src/types.ts` | **PASS** — `MoveParams:130`, `AssignParams:157`, `HistoryEntry` `types.ts:13` |
| 4 | forced takeover with reason in file + `renderTask` | scratch `kanban_assign {force, reason}`; `rg "reason:"`; `renderTask` | **PASS** — `reason: owner idle 2d` is the last key of the `force-assign` entry; `renderTask` prints `      why  owner idle 2d` |
| 5 | `validate` flags missing reason, clears with one | scratch board, two files | **PASS** — `unannotated_forced` present without reason, absent with one; `ok:true` both |
| 6 | third-party assign: no force → `wrong_owner` + hint names force; force → `force-assign` | scratch `kanban_assign` | **PASS** — `wrong_owner`, hint names `force: true`, file byte-identical; with force `force-assign` written |
| 7 | vitest test: third-party without force throws + file unchanged | `src/test/claims.test.ts:297` | **PASS** — asserts `wrong_owner`, all §2.2 envelope fields, file byte-identical, no `.lock` |
| 8 | `completeKanbanCommand("T-0")` → T-0001; `("")` no board → subcommands, no throw | scratch driver | **PASS** — label `T-0001`; no-board = 8 subcommands |
| 9 | help names Claim/Terminal/Handle/no-board, asserted by test | `src/test/surface-pack.test.ts:381-395` | **PASS** — all four substrings asserted, 14 lines, ≤76 |
| 10 | `repair-ids --dry-run` prints rename, writes nothing | scratch board; `planRepairIds` + handler | **PASS** — prints `  rename  T-0002.md -> T-0009.md`; files + counter byte-identical; no lock |
| 11 | `repair-ids --yes` renames + rewrites `id`, no `id_mismatch` | scratch board; `repairIds` | **PASS** — `T-0009.md` exists, `id: T-0009`, `validate` clean |
| 12 | counter > highest, no `counter_low` after `--yes` | scratch board | **PASS** — `counter next 3 -> 10`, highest `T-0009`, no `counter_low` |
| 13 | problem codes documented | `rg -n "id_mismatch\|unannotated_forced" README.md docs/workflow.md` | **PASS** — README:153; workflow.md:111,137,146 |
| 14 | `repair-ids` in docs + handler | `rg -n "repair-ids" README.md docs/workflow.md src/command.ts` | **PASS** — README:118,123,140; workflow.md:114; command.ts:13,51,148,356,461,512,520,575 |
| 15 | ADR 0009 exists + referenced | `ls`; `rg -n "0009" README.md docs/adr/*.md` | **PASS** — file present; README:179, ADR 0004:5 |
| 16 | backlog diff is marker-only | `git diff d65fd66..537e40d -- docs/product/backlog.md`; `git diff --stat docs/product/backlog.md` | **PASS** — v0.3 WIP commit flips only `[ ]`→`[x]`; working-tree diff empty. The review commit `783a642` appends a new post-0.3.0 §14–§17 section (sanctioned new prose, not a rewrite of existing prose) |
| 17 | version `0.3.0` + CHANGELOG `## 0.3.0` | `node -e …version`; `rg '^## 0\.' CHANGELOG.md` | **N/A by design** — `0.2.0`, only `## 0.2.0`. The criterion says "(release task only; the version is not bumped by the scope tasks)"; T-0017 owns this. Not a gate failure. |
| 18 | `counter.json next` > highest task file | `cat .pi/kanban/counter.json; ls tasks` | **PASS** — as found `next:19` > `T-0018`; after filing T-0019…T-0022, `next:23` > `T-0022` |

**Only unmet bullet:** #17, explicitly deferred to the release task T-0017.
Every other bullet is green by command.

## Design §7 acceptance checks

Each numbered check was executed; only deviations are called out.

| Task | Checks run | Result |
|---|---|---|
| T-0012 | 1–11 | **PASS** — reason is the last entry key and `rg -c "^    reason: …$"` = 1; no-force → `invalid_argument` + byte-identical; 201 → `reason is 201 characters; the limit is 200.`; `\n` and `"   "` → unit/limit messages; the two adjacent render lines match and every line ≤76; a 200-char reason renders on one 76-char `why` line ending `...`; exactly one `unannotated_forced` `warning` with `ok:true`; `/kanban validate` prints `Board is valid (1 warning).` with the three-line block ≤76 (max width measured = 76); with a reason → `Board is valid.`; a pre-0.3 file released with `--yes` produces no `unannotated_forced`; `rg` finds all three fields |
| T-0013 | 1–11 | **PASS** — `wrong_owner` with exact text, envelope `task/assignee/agent/root/dir`; file byte-identical, no `.lock`; force → last entry `force-assign`; self-assign → plain `assign`; no-op leaves `rev`; own-task handoff → `wrong_owner`; `move` from unassigned `new` → plain `assign`; historic third-party `assign` validates clean; `USAGE` + `promptGuidelines` sentences verbatim; ADR referenced |
| T-0014 | 1–11 | **PASS** — label `T-0001`; `2 tasks`; no board → exactly the 8 labels `init,validate,release,repair-ids,--all,--json,status,mine`; `T-0` no board also 8; `@cod` → `["coder-1"]`, no `T-`; 25 tasks → 20, 15 handles → 10; dedup → one `coder-1`; malformed file dropped silently, no "could not be read" description; missing `tasks/` → 8; no `.lock`, mtime unchanged; no throw for missing workflow / empty / unreadable |
| T-0015 | 1–4 | **PASS** — `USAGE` is 14 lines, max width 73 (≤76); all six literal substrings; `Start with kanban_list`; `help` and `--help` both notify the exact `USAGE` string |
| T-0016 | 1–12 | **PASS** — dry-run prints the rename, nothing written, no lock; `--yes` renames + rewrites id, no `id_mismatch`; prints `Repaired 1 task id.` / `  renamed …` / `  by       @human`; `counter next 3 -> 10`; clean board `next:12` → `Nothing to repair…` byte-identical; target-exists and two-files-same-id refusals; no-`id` file skipped and the rest applies; `--dry-run --yes` refused; swap applied; `rg` finds it |

### Review rulings inside §1.2 and §1.6

- **§1.2 D1 (self-release drops the reason).** Reproduced: `kanban_assign
  {assignee: null, force: true, reason: "cleanup"}` on another handle's task
  writes a forced `unassign` with **no** `reason`; `validate` does **not** flag
  it. Matches the ruling.
- **§1.6 R1 (the block is the message, not the line breaks).** Reproduced: the
  rendered warning is 3 lines, wraps greedily, first line ends after the date,
  second breaks inside `kanban_assign`, max line width = **76**. Matches.

## Three fresh-agent walkthroughs

All calls used only the tool surface (`kanban_list/create/assign/move/comment`)
or `/kanban`. Boards live in `/tmp`.

### A. New agent told to "pick up the next task"

```
kanban_list {briefing:true, project:"/tmp/qa-boards/walk-a"}
  → Board  tmp/qa-boards/walk-a/.pi/kanban
    Workflow  delivery  ·  initial: new
      active  ◆ planning  ◆ building  ◆ reviewing
      other  ◇ new  ◇ done (terminal)
    Claims
      Moving a task into an active status claims it for your handle; only the
      holder can move or release it. force: true is a deliberate takeover,
      recorded as a forced assignment. A terminal status releases the claim.
      There are no leases: a claim never expires until someone releases it.
      Handles are self-asserted, like coder-1. The board does not verify them.
    Start here
      kanban_list, then kanban_list {task:"T-0001"} for one task in full.
      …

kanban_create {title:"Fix duplicate ids after a merge", agent:"pm", …} → Created T-0003  new  …
kanban_create {title:"Make an abandoned claim visible", agent:"pm", …} → Created T-0004  new  …
kanban_list {…}
  → T-0003  new     Fix duplicate ids after a merge        rev 1
    T-0004  new     Make an abandoned claim visible        rev 1
    Workflow: delivery  initial=new  …
kanban_move {task:"T-0003", to:"planning", agent:"newbie-1", …}
  → new → planning  T-0003  Fix duplicate ids after a merge
kanban_list {…}
  → T-0004  new       Make an abandoned claim visible                    rev 1
    T-0003  planning  Fix duplicate ids after a merge        @newbie-1 3s  rev 2
```

A competent agent does the right thing: the briefing states the claim rule and
the first calls, `kanban_list` shows `rev`/holder, and `kanban_move` into
`planning` claims the task with no extra flag.

**Worst friction:** the briefing's worked example hardcodes
`kanban_list {task:"T-0001"}`. This board's lowest id is T-0003, so an agent
that copies the example gets `No task T-0001 on this board.` Verdict: **doc gap,
acceptable** — the error is clean and tells the agent to run `kanban_list`.
Secondary friction: "pick up the next task" is underdetermined because the
Board has no priority/ordering field, so the agent must pick arbitrarily among
`new` tasks. Verdict: **acceptable** (product scope; the v0.3 design does not
claim a scheduler).

### B. Takeover, then the original owner returns

```
kanban_assign {task:"T-0003", assignee:"coder-2", agent:"coder-2", force:true,
               reason:"owner idle 2d; newbie-1 stopped responding", …}
  → Assigned T-0003 to @coder-2
kanban_list {task:"T-0003", …}
  → assignee  coder-2  (claimed 7s)   … rev 3
    History
      … 2026-10-31 16:56:12Z @coder-2  force-assign coder-2 (forced)
        why  owner idle 2d; newbie-1 stopped responding

# original owner comes back, uses a rev it read before the takeover
kanban_move {task:"T-0003", to:"building", agent:"newbie-1", expectedRev:2, …}
  → Error [stale]: Task T-0003 is at rev 3 (status "planning"), but the call
    expected rev 2. Re-read the task and retry.
    Read the task again with kanban_list before retrying, and pass the rev you
    just read.

# retries without expectedRev, still acting as the old owner
kanban_move {task:"T-0003", to:"building", agent:"newbie-1", …}
  → Error [wrong_owner]: Task T-0003 is claimed by "coder-2" for 13s;
    "newbie-1" cannot move it, because that would take the claim.
    Release it with /kanban release T-0003 --yes, or pass force: true to take
    over. An agent can call kanban_assign with assignee:null, force:true.

# takes it back deliberately
kanban_move {task:"T-0003", to:"building", agent:"newbie-1", force:true,
             reason:"I am back, resuming my task", …}
  → planning → building  T-0003  Fix duplicate ids after a merge
```

A competent agent would do the right thing at every step: the stale write is
refused with the actual rev and the remedy, the claim is refused with the holder
and both remedies, and the deliberate takeover succeeds and records a `why`.

**Worst friction:** the `stale` error reports the new `rev` and `status` but
not the new **holder**, so an agent that only sees the stale error must re-read
to discover the takeover. Verdict: **acceptable/minor** — the error explicitly
tells it to re-read, and the re-read shows `@coder-2`.

### C. Merge accident → validate → repair → create

```
# two tasks created through the tool, then the board is hand-merged:
#   T-0002.md declares id: T-0009 ; counter.json next: 2 (below T-0009)
node run-kanban.mjs /tmp/qa-boards/walk-c validate
  → Board has problems.
      .pi/kanban
    Tasks:
      ⚠ T-0002  id_mismatch: /tmp/qa-boards/walk-c/.pi/kanban/tasks/T-0002.md
                declares id "T-0009" but the filename is T-0002.
      ⚠ -  counter_low: counter.json says the next id is 2, which is not greater
                than the highest existing id T-0003; a create would reuse an id.

node run-kanban.mjs /tmp/qa-boards/walk-c repair-ids --dry-run
  → Repair task ids  .pi/kanban  (dry run: nothing written)
      rename  T-0002.md -> T-0009.md
              id: T-0009  Fix duplicate ids after a merge
      counter next 2 -> 10  (raised above the highest id T-0009)
    1 file would be renamed. Nothing was written. Re-run with --yes to apply.
    Docs and comments that mention T-0002 are not rewritten.
  (files + counter byte-identical; no lock)

node run-kanban.mjs /tmp/qa-boards/walk-c repair-ids --yes
  → Repaired 1 task id.
      renamed  T-0002.md -> T-0009.md   id: T-0009
      counter  next 2 -> 10  (raised above the highest id T-0009)
      by       @human
    Run /kanban validate to confirm the board is clean.

node run-kanban.mjs /tmp/qa-boards/walk-c validate
  → Board is valid.
      .pi/kanban

kanban_create {title:"Post-repair create", agent:"pm", …}
  → Created T-0010  new  Post-repair create      # no collision with T-0009
```

A competent agent would do the right thing: `validate` names both defects,
`--dry-run` prints the exact plan and writes nothing, `--yes` applies it, and a
later `kanban_create` allocates T-0010.

**Worst friction:** `validate`'s `counter_low` message says "not greater than the
highest existing id **T-0003**" even though the board contains a file declaring
`T-0009`. `validate` excludes the `id_mismatch` file from its "highest"
calculation while `repair-ids` includes the declared id, so the two disagree.
An operator hand-fixing only the counter from that message would set `next: 4`,
which is still below T-0009 and would collide once the rename lands. Verdict:
**known bug, already recorded as backlog #16** (`validate` counts the highest id
from readable Tasks; `planRepairIds` uses filenames) — not a v0.3 regression and
not duplicated here. The repair path itself resolves it correctly (raises to 10).

## Adversarial matrix

| # | Probe | Expected | Actual | Verdict |
|---|---|---|---|---|
| 1 | Unicode + RTL + emoji title and reason | stored verbatim, renders, no crash | `title: مرحبا بالعالم 🎉 — 修复重复 ID`, `reason: مرحبا 👋 owner idle 2d — тест`; renders on the `why` line | **clean** |
| 2 | 200-char reason | accepted | `ok:true`, one 212-byte YAML line | **clean** |
| 3 | 201-char reason | `invalid_argument` | `reason is 201 characters; the limit is 200.` | **clean** |
| 4 | Reason with `\n`, `\t`, `0x7F` | `invalid_argument` | `reason must be a single line.` | **clean** |
| 5 | Blank `"   "` reason | `invalid_argument` | `reason must be 1-200 characters.` | **clean** |
| 6 | Reason without `force` | `invalid_argument`, byte-identical | `reason requires force: true.`; file unchanged | **clean** |
| 7 | Task file hand-edited to empty `title` | readable failure, board still works | `kanban_list` → `fileErrors` + `⚠ 1 task file could not be read; the board shows the rest.`; `validate` → `unreadable_task`; `move`/`assign` → `invalid_task` with the parse message; no stack trace | **clean** |
| 8 | Workflow `initial:` names a missing status | clean refusal | `validate` → workflow error `"initial" refers to unknown status "phantom".`; `kanban_list` → `Workflow unavailable (⚠ run /kanban validate)`; `kanban_create` → `invalid_workflow` + `Fix the workflow file, then retry.`; no stack trace | **clean** |
| 9 | `kanban_move` to an undeclared status | clean refusal | `invalid_transition`: `Unknown status "nonsense". Declared statuses: new, planning (active), …` | **clean** |
| 10 | `kanban_move` along an undeclared edge | clean refusal, state unchanged | `invalid_transition`: `"working" cannot move to "done". Allowed: planning.`; file still `status: working` | **clean** |
| 11 | `kanban_move` to the current status | clean refusal | `already_in_status`: `Task T-0004 is already in "new", assigned to "coder-2"; "pm" does not hold the claim.` | **clean** |
| 12 | Killed `repair-ids` / `.repair.<token>.md` leftover | board still usable | `validate` → `Board is valid.`; `/kanban` lists the readable task; `repair-ids` → `1 file was skipped and needs a hand edit: .repair.a1b2c3.md  interrupted repair: restore or delete by hand`; a later `create` does not collide | **clean, not bricked** |
| 13 | Leftover **plus** a genuine rename | rename applies, leftover reported | `--yes` → `Repaired 1 task id.` + `1 file was skipped…`; `T-0009.md` created, leftover untouched; `validate` clean | **clean** |
| 14 | Rename target already exists | refusal, nothing written | `Cannot repair: T-0009.md already exists and T-0002.md declares id T-0009.` + `Nothing written. Rename or delete one of them by hand, then re-run.`; files unchanged, no lock | **clean** |
| 15 | Two files declare the same id | refusal, nothing written | `Cannot repair: two files declare the same id.` + both filenames | **clean** |
| 16 | File with no string `id` | skipped, rest applies | `skipped: no string "id" in frontmatter`; the other rename applied | **clean** |
| 17 | Swap `T-0002 ↔ T-0005` | both applied | `T-0002.md=T-0002`, `T-0005.md=T-0005`, no `id_mismatch` | **clean** |
| 18 | `repair-ids --dry-run --yes` | refusal, nothing written | `/kanban repair-ids takes one of two flags.` … `Both at once is ambiguous; nothing was written.` | **clean** |
| 19 | `validate` warning line width (chars) | ≤76 | max `[...line].length` = **76** | **clean** |
| 20 | `/kanban --json` with a repaired board | valid JSON, no throw | parses; `root/dir/workflow/tasks` present | **clean** |

No probe crashed with a raw stack trace or corrupted a board. The only
"surprise" is #20-internal to walkthrough C (`counter_low` naming a lower
highest), which is backlog #16.

## Docs conformance

| Surface | Check | Result |
|---|---|---|
| `/kanban help` vs `design-v0.3.md` §4 | byte comparison of the fenced block to `USAGE` | **identical**: 793 bytes, 14 lines, max width 73 |
| `help` and `--help` handler output | fake `ctx.ui.notify` capture | both notify exactly `USAGE` |
| README: `reason` | claim vs behaviour | accurate |
| README: third-party rule | claim vs behaviour | accurate (uniform, both directions, `force-assign` + `forced`) |
| README: completions (caps) | claim vs behaviour | **inaccurate** — promises "every handle the board has seen"; actually 10 handles / 20 ids → filed **T-0020** |
| README: `repair-ids` | claim vs behaviour | behaviour accurate; but the command list still calls `release` "the one subcommand that writes", contradicting the next paragraph → filed **T-0019** |
| `docs/workflow.md` | codes + repair-ids + reason | accurate |
| `docs/product/backlog.md` `[x]` | 12 boxes | all 12 verified true (reason accept/render/flag, third-party force/guideline/record, completions ids+handles/help/test, validate id/counter/dry-run) |

## Verdicts

| Task | Verdict | One line of evidence |
|---|---|---|
| T-0012 `reason` | **PASS** | `kanban_assign {force:true, reason:"owner idle 2d"}` writes `reason:` as the last key of the `force-assign` entry and `renderTask` prints `      why  owner idle 2d`; `validate` shows one `unannotated_forced` warning (max width 76). |
| T-0013 third-party force | **PASS** | `kanban_assign {assignee:"coder-2", agent:"reviewer"}` without `force` → `Error [wrong_owner] … cannot assign it to "coder-2" without force.` with hint `Pass force: true …`, file byte-identical; with `force` → `force-assign`. |
| T-0014 completions | **PASS** | `completeKanbanCommand("T-0", board)` → `T-0001`; `("")` no board → the 8 labels `init,validate,release,repair-ids,--all,--json,status,mine`; caps 20/10 verified on 25 tasks / 15 handles. |
| T-0015 help card | **PASS** | `USAGE` byte-identical to design §4 (793 bytes, 14 lines, ≤76), and both `help`/`--help` notify it unchanged. |
| T-0016 `repair-ids` | **PASS** | `--dry-run` on `T-0002.md` declaring `T-0009` prints the rename and writes nothing; `--yes` renames to `T-0009.md`, rewrites `id`, raises counter `2 -> 10`, `validate` clean. |

## Defects filed (all post-release, none block v0.3.0)

| Task | Title | Severity | Class |
|---|---|---|---|
| T-0019 | README says `/kanban release` is "the one subcommand that writes" | post-release | doc (released, unassigned) |
| T-0020 | README promise of "every handle" ignores the 10/20 completion caps | post-release | doc (released, unassigned) |
| T-0021 | Absolute board paths outside cwd lose their leading slash | post-release | UX (pre-existing 0.2.0; released, unassigned) |
| T-0022 | design §1.2 table lists a trailing `assign` that `moveTask` never writes | post-release | doc/impl mismatch (released, unassigned) |

Already recorded, not duplicated: **#16** (validate vs repair-ids highest-id
disagreement — reproduced in walkthrough C) and **#15** (`--as` with no value
reaches `invalid_handle` quoting an empty string — reproduced).

## Residual risk / read before tagging

1. **The gate is green except release criterion #17**, which the scope defers to
   T-0017 (`package.json` → `0.3.0`, `CHANGELOG.md` `## 0.3.0` above
   `## 0.2.0`). Do not tag until T-0017 lands and `git diff --stat` shows no
   other changes.
2. **The repo's own board is not warning-free**: `/kanban validate` reports
   `Board is valid (10 warnings)` — ten historic `force-assign` entries without
   reasons (T-0001…T-0009, T-0011). This is exactly §1.7 backward compatibility:
   old takeovers stay valid and become visible. Expected, not a defect, but it
   will be the first thing a reviewer sees on upgrade.
3. **#16 is live**: on a board with an `id_mismatch`, `validate`'s `counter_low`
   can name a lower "highest existing id" than `repair-ids` will raise the
   counter to. `repair-ids` resolves it correctly; a human hand-editing the
   counter from the `validate` message alone can under-raise it. Recorded as
   backlog #16; consider it before teaching operators to fix counters by hand.
4. **Two tests are the sole evidence for "nothing was written"** on the
   completion and repair-ids paths (`completions.test.ts` "never writes …",
   `repair-ids.test.ts` "leaves every task file and counter.json
   byte-identical"). I re-ran both behaviours live on scratch boards, so this is
   corroborated, not test-only.
5. **Doc claims to fix before the next release**, not before this tag: README
   "the one subcommand that writes" (T-0019) and "every handle" (T-0020). They
   are user-facing inaccuracy, not behaviour.
6. **Unicode/RTL is stored and rendered without escaping**, which is correct for
   Markdown/YAML, but a terminal with poor BiDi handling will render mixed
   Arabic/Chinese titles oddly. Cosmetic; no action requested.
