@tianhai/pi-workflow-kit 2.8.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Read before creating or changing progress rows, or writing/re-presenting the code digest. This reference owns the unchanged progress artifact schema.
4
4
 
5
- 2. **Create the progress file** `docs/plans/<date>-<topic>/<leaf>-progress.md`, beside the design doc it belongs to (`<leaf>` is `<topic>` for a single-part topic; an umbrella part uses its roster slug), so `pwk-finalizing` finds it inside the topic folder. Keep the blank lines between the header fields: the blank lines are load-bearing, because CommonMark merges consecutive plain lines into one paragraph, so without them every preview renders the whole header as a single run-on line.
5
+ 2. **Create the progress file** `docs/plans/<date>-<topic>/<leaf>-progress.md`, beside the design doc it belongs to (`<leaf>` is `<topic>` for a single-part topic; an umbrella part uses its roster slug). Keep the blank lines between the header fields: the blank lines are load-bearing, because CommonMark merges consecutive plain lines into one paragraph.
6
6
 
7
7
  ```markdown
8
8
  # Progress: <topic>
@@ -19,7 +19,7 @@ Read before creating or changing progress rows, or writing/re-presenting the cod
19
19
 
20
20
  Feature phase: e2e-written
21
21
 
22
- <!-- a trivial design doc (no `## Feature acceptance`) starts at `implementing (0/N)` instead — there is no E2E to write -->
22
+ <!-- a trivial design doc (no `## Feature acceptance`) starts at `implementing (0/N)` -->
23
23
 
24
24
  ## Requirements
25
25
  | # | Done | Requirement | Per-req ceremony | Commit |
@@ -27,13 +27,13 @@ Read before creating or changing progress rows, or writing/re-presenting the cod
27
27
  | 1 | ⬜ | <requirement name> | — | — |
28
28
 
29
29
  ## Execution summary
30
- | R# | Requirement | How it was built | Deviated? |
31
- |----|-------------|------------------|-----------|
32
- | 1 | <requirement name> | | |
30
+ | R# | Requirement | How it was built | Deviated? | Evidence |
31
+ |----|-------------|------------------|-----------|----------|
32
+ | 1 | <requirement name> | | | |
33
33
 
34
34
  ## Deviation records
35
35
 
36
- <!-- One short paragraph per design-decision reversal (what changed, why, what was rejected), written at deviation time; leave this section empty when every Deviated? entry is mechanical. -->
36
+ <!-- One short paragraph per design-decision reversal -->
37
37
 
38
38
  ## Code digest
39
39
 
@@ -52,35 +52,34 @@ Read before creating or changing progress rows, or writing/re-presenting the cod
52
52
  - `<file>` — <what shifted inside it>
53
53
  ```
54
54
 
55
- The `## Code digest` is filled once, at the write point in the ship checkpoint — never per requirement. Fill rules: plain language, R# anchors where natural, no test names (the execution-summary rule). `### Summary` is 2–3 sentences: what the code now does differently, and why. `### Flow` uses `A -> B -> C` arrow chains, one spine or branch per bullet line, so every renderer shows each hop chain on its own line — bare label lines would merge into one run-on paragraph. `### Gotchas` lifts real risks from the review findings, one bullet each — `[ALERT]` only for reviewer-confirmed issues, never invented; with no findings, write `none beyond review findings` and mean it. `### Key files` is capped at 5 pivotal files, one bullet each: what shifted inside them.
55
+ ## Code digest
56
56
 
57
- **Flow shape** — this is the ship stop's navigable map, so it is the one digest section that carries structure, not just prose:
57
+ The `## Code digest` is filled once, at the write point in the ship checkpoint — never back-filled per requirement. Fill rules: plain language, R# anchors where natural, no test names. `### Summary` is 2–3 sentences: what the code now does differently, and why. `### Flow` uses `A -> B -> C` arrow chains, one spine or branch per bullet line. `### Gotchas` lifts real risks from the review findings, one real risk per bullet — `[ALERT]` only for reviewer-confirmed issues, never invented; with none, write `none beyond review findings`. `### Key files` is capped at 5 pivotal files, one bullet each.
58
58
 
59
- - **Spine** — the happy path as `A -> B -> C` hops, each hop named by symbol or module, with no line numbers anywhere and no file paths: symbol names survive edits and stay greppable, so a stale anchor can never mislead. Tag each hop `[R<n>]` with the requirement it comes from — that is what lets the coverage table's verdict rows be traced into the code without opening the diff.
60
- - **Branches** — every alternative and error path as `condition -> outcome` (a linear spine with no branches reads as "there are no edge cases", which is rarely true). Put ⚠️ on the genuinely surprising ones. Add a `was: <previous behavior>` clause for each requirement whose behavior changed; skip it for purely additive requirements — honest-empty, never invented.
61
- - **Inline worked values** — where a mapping is non-obvious, show it concretely (`` `implementing (2/5)` -> `execute 2/5` ``), so a reader can falsify it without running anything.
62
- - **Side effects** — a bullet naming what is read and written, only when the feature performs I/O, network, or migrations; omit it entirely otherwise rather than filling a placeholder.
63
- - **Cap: roughly 15 lines.** If the change genuinely needs more, the ship checkpoint **offers `/skill:pwk-walkthrough`** as the deep read (it regenerates a `file:line`-anchored walkthrough on demand) instead of growing the digest — the digest is what the human reads at the one remaining stop, and an unbounded Flow recreates the digging it exists to prevent.
59
+ **Flow shape** — the ship stop's navigable map, the one digest section carrying structure:
60
+ - **Spine** — the happy path, each hop named by symbol — no line numbers, no file paths (those go stale). Tag each hop `[R<n>]`, so coverage verdicts trace into the code.
61
+ - **Branches** — every alternative and error path as `condition -> outcome`; a bare spine reads as "no edge cases". Put ⚠️ on the surprising ones, and a `was: <previous behavior>` clause on each requirement whose behavior changed — skip it for purely additive ones.
62
+ - **Inline worked values** — a non-obvious mapping shown concretely (`` `implementing (2/5)` -> `execute 2/5` ``).
63
+ - **Side effects** — what is read and written, only for I/O, network, or migrations; otherwise omit the bullet.
64
+ - **Cap: roughly 15 lines.** A longer Flow gets the ship checkpoint's `/skill:pwk-walkthrough` offer instead.
64
65
 
65
66
  `Feature phase` is one of: `e2e-written`, `implementing (k/N)`, `reviewing`, `ship-paused`, `done`.
66
67
 
67
- The `Setup:` header slot takes one of `Setup: pending | done | n/a` — the header starts `Setup: pending` when the design doc has a `## Setup` section, else `Setup: n/a`; the main skill's setup checkpoint flips it to `done` on approval. The phase line stays `e2e-written` through the setup stop — the Setup slot, not the phase, is what flags the not-yet-approved window, and the E2E is written after setup.
68
-
68
+ The `Setup:` header slot takes one of `Setup: pending | done | n/a` — the header starts `Setup: pending` when the design doc has a `## Setup` section, else `Setup: n/a`; the setup checkpoint flips it to `done` on approval. The phase line stays `e2e-written` through the setup stop, and the E2E is written after setup.
69
69
 
70
70
  ## Progress file
71
71
 
72
- Update the matching requirement row directly (not via pattern matching that could corrupt the table). Update `Last updated` and `Feature phase` on every change.
72
+ Update the matching row directly, never via pattern matching (which corrupts the table). Update `Last updated` and `Feature phase` on every change.
73
73
 
74
74
  **Row vocabulary (canonical — this file owns it):**
75
75
 
76
- - **Done cell:** `⬜` not started · `🔄` in progress · `✅` done · `❌` failed · `⏭` skipped. A row is **terminal** when its Done cell is `✅`, `❌`, or `⏭` — resolved, not necessarily passed. A `❌`/`⏭` row carries its reason as a suffix in the Requirement cell (finalizing's verdict greps read it), and its test coverage is reconciled in the same step — assertions removed or skipped with the reason; the suite must stay green. This skill sets all five values. A requirement under review stays `🔄`: `pwk-code-review` reports findings and never edits the Done cell — the loop only ever flips `🔄`→`✅` when the review returns clean, and findings leave the row `🔄` for another pass.
76
+ - **Done cell:** `⬜` not started · `🔄` in progress · `✅` done · `❌` failed · `⏭` skipped. A row is **terminal** when its Done cell is `✅`, `❌`, or `⏭` — resolved, not necessarily passed. A `❌`/`⏭` row carries its reason as a suffix in the Requirement cell, and its test coverage is reconciled in the same step — assertions removed or skipped with the reason, leaving the suite green. A requirement under review stays `🔄`: `pwk-code-review` never edits the Done cell — the loop only ever flips `🔄`→`✅` when the review returns clean; findings leave the row `🔄` for another pass.
77
77
  - **Per-req ceremony cell** echoes the design doc's tag, not progress: `—` (default `none`/`skip`) · `⏸ full` (fires two stops — after the slice's tests and after it is complete) · `🔎 inline` · `🔎 parallel`.
78
78
 
79
- **Execution summary rows are written in the same step as marking a requirement ✅** — never retrofitted at the end. "How it was built" = one or two plain sentences: what it does now + the approach actually taken; file names sparingly; **no test names, no code** (the human reads this at the ship checkpoint — big picture only). If the implementation departs from the design, fill the Deviated? column **at deviation time** (when the departure happens), with a one-line why — it is a log, not a stop. A departure that **reverses or alters a design decision** gets a **deviation decision-record**: a short paragraph (what changed, why, what was rejected) written into the progress file while the knowledge is fresh — mechanical deviations keep the one-liner. `pwk-finalizing`'s learning sweep harvests these records for ADRs before the docs are disposed.
80
-
79
+ **Execution summary rows are written in the same step as marking a requirement ✅** — never retrofitted. "How it was built" = one or two plain sentences: what it does now plus the approach taken, file names sparingly, **no test-name lists, no code**. The `Evidence` cell pairs **what it proves in plain words** with a pointer — a commit, a `file:line`, a command with its output line, or one probe test — single-line. No requirement is marked ✅ with an empty `Evidence` cell: either the pointer exists or the requirement is not done. A deviating implementation fills the Deviated? column **at deviation time** (when the departure happens), with a one-line why — it is a log, not a stop. A departure that **reverses or alters a design decision** gets a **deviation decision-record**: a short paragraph (what changed, why, what was rejected) written while the knowledge is fresh; mechanical deviations keep the one-liner. `pwk-finalizing`'s learning sweep harvests these records for ADRs before the docs are disposed.
81
80
 
82
81
  ## Commit entries for packet assembly
83
82
 
84
- Record immutable hexadecimal commit IDs (7–64 characters). A cell can hold one ID or an ordered, whitespace-separated list: initial implementation first, review-fix commits last. Preserve the first recorded commit when appending a fix; replacing it loses the feature base. Record a commit hash after committing, never amend a commit to include its own hash.
83
+ Record immutable hexadecimal commit IDs (7–64 characters). A cell holds one ID or an ordered, whitespace-separated list: initial implementation first, review-fix commits last. Preserve the first recorded commit when appending a fix — replacing it loses the feature base. Record a hash after committing, never amend a commit to carry its own hash.
85
84
 
86
- The helper reads only the Requirements table Commit column. A done or in-review row needs a recorded commit. Terminal `❌`/`⏭` rows may have `—` when no commit remains; this does not supply a bound for a requirement packet. If the selected or immediately previous requirement lacks a bound, stop and correct the record from Git evidence; never invent one.
85
+ The helper reads only the Requirements table Commit column. A done or in-review row needs a recorded commit; a terminal `❌`/`⏭` row may have `—`, which supplies no bound. If the selected or previous requirement lacks a bound, stop and correct the record from Git evidence; never invent one.
@@ -1,14 +1,14 @@
1
1
  # Workspace and resume details
2
2
 
3
- Read before choosing or adopting a topic branch/worktree, and whenever a matching progress file exists.
3
+ Read before adopting a topic branch/worktree, or when a matching progress file exists.
4
4
 
5
5
  ## Workspace
6
6
 
7
- 3. **Workspace — create the feature branch** — if you're already on **this topic's** feature branch, **reuse** it: a later umbrella part continues on the same umbrella branch as part 1. If on `main` — or on a different topic's feature branch, which stays that topic's own and never hosts this topic's commits — create or switch to this topic's branch: `git checkout <topic>` when it exists, else `git checkout -b <topic>` — the topic's folder slug (the `docs/plans/<date>-<topic>/` directory name, shared by every leaf of an umbrella), never a leaf slug. **`<topic>` is the folder slug** everywhere it appears in this skill, including the `../<repo>-<topic>` worktree convention below, so a standalone topic and an umbrella part name the same branch and the same worktree. For larger work, first check `git worktree list`: if a worktree already checks out this branch (the same `../<repo>-<topic>` convention), adopt it — resume the session there instead of offering a new `git worktree add`. One worktree per branch, created once: git refuses two checkouts of one branch, so a later umbrella part reuses part 1's worktree and a standalone resume reuses its own. Otherwise offer a worktree (`git worktree add ../<repo>-<topic> <topic>`) and hand off to a new session there so `pwd` is the worktree. **Uncommitted planning docs do not cross a worktree boundary**, and the design doc is still uncommitted (brainstorm never commits): when the user takes the worktree, commit the topic folder on this branch first — the same scoped add as the main skill's First-run commit — then switch this tree back to the branch it came from (e.g. `git checkout main` when it started there; git refuses `worktree add` of a branch that is still checked out here) and only then `git worktree add ../<repo>-<topic> <topic>` and hand off (the new session's First-run commit then has only its progress file to record). The adopt path above is safe only when the docs are already committed on the branch; if this tree holds uncommitted topic docs while another worktree holds the branch checkout, git refuses to commit to the branch from here — copy the topic folder into that worktree's `docs/plans/`, verify with `ls`, and remove the source copy before handing off. Wait for the user's choice.
7
+ 3. **Workspace — create the feature branch** — already on **this topic's** feature branch → **reuse** it (a later umbrella part continues on part 1's branch). Else `git checkout <topic>`, or `git checkout -b <topic>` if new — **`<topic>` is the folder slug** everywhere in this skill: the `docs/plans/<date>-<topic>/` directory name, shared by every umbrella leaf, never a leaf slug — and it names the branch and the `../<repo>-<topic>` worktree. Check `git worktree list` first: adopt a worktree already on this branch rather than adding a second (one per branch); otherwise offer `git worktree add ../<repo>-<topic> <topic>` and hand off to a new session there, so the session's working directory is the worktree. **Uncommitted planning docs do not cross a worktree boundary** (brainstorm never commits the design doc): commit the topic folder with the main skill's scoped First-run add, switch this tree back to the branch it came from (git refuses `worktree add` of a branch still checked out here), then add the worktree. If this tree holds uncommitted topic docs while another worktree holds the branch, git refuses to commit from here — copy the topic folder into that worktree's `docs/plans/`, confirm the copy landed, and remove the source copy. Wait for the user's choice.
8
8
 
9
9
  ## Resume
10
10
 
11
- Read the progress file's `Feature phase` (match the line — e.g. `grep -m1 '^Feature phase:' <file>`), the Requirements table (the first row whose Done cell is not terminal routes the next requirement — `⬜`, `🔄`, and blank all mean not-done; `✅`/`❌`/`⏭` are resolved and are skipped past), and the Execution summary rows (how prior parts were built); read from the top through the end of `## Execution summary` and stop — the sections after it (deviation records, code digest) carry nothing the resume needs, with one exception: the `ship-paused` route re-presents the ship checkpoint, so it also reads the `## Code digest` written in the progress file. Before routing: if the header reads `Setup: pending` and the design doc has a `## Setup` section, the setup checkpoint was never approved — re-run the setup verification and wait at it:
11
+ Read the progress file's `Feature phase` (match the line — e.g. `grep -m1 '^Feature phase:' <file>`), the Requirements table (the first row whose Done cell is not terminal routes the next requirement — `⬜`, `🔄`, and blank all mean not-done; `✅`/`❌`/`⏭` are resolved and are skipped past), and the Execution summary rows; stop after `## Execution summary` — later sections carry nothing the resume needs, with one exception: the `ship-paused` route re-presents the ship checkpoint, so it also reads the `## Code digest`. Before routing: a `Setup: pending` header on a design doc with a `## Setup` section means the setup checkpoint was never approved — re-run the setup verification and wait at it:
12
12
  - `e2e-written` → write the E2E if not yet present, post the notice, and continue into the implement phase (no stop). A trivial design doc has no `## Feature acceptance` — skip both and go straight into the implement phase.
13
13
  - `implementing (k/N)` → continue the next not-yet-terminal requirement.
14
14
  - `reviewing` → continue/finish the feature review, then assemble the **ship** checkpoint.
@@ -1,42 +1,35 @@
1
1
  # Review mechanics and packet helper
2
2
 
3
- Read before any feature or opt-in requirement review, before refreshing a packet, and before writing the code digest from review evidence.
3
+ Read before any review, packet refresh, or digest write.
4
4
 
5
5
  ## Assemble evidence
6
6
 
7
- **Assemble the review packet first** — once, by script, so that no packet byte passes through model output (spawn arguments are model output; file reads are not). The Commit column is the packet's source of truth for both spans: the feature packet base is `git rev-parse <first Commit-column entry>^` (the parent of the first recorded commit); a per-requirement packet spans from the previous requirement's last commit (exclusive) to this requirement's last commit (inclusive), with R1's exclusive bound being its own parent. No `merge-base` guessing, no default-branch knowledge. If commits land while the review is in flight, re-run the recipe before spawning any replacement role so the packet matches HEAD:
7
+ **Assemble the review packet first** — once, by script, so that no packet byte passes through model output. The Commit column fixes both spans: feature base = `git rev-parse <first Commit-column entry>^`, the parent of the first recorded commit; a requirement packet spans the previous requirement's last commit (exclusive) to this one's last commit (inclusive), R1's exclusive bound being its own parent. No `merge-base` guessing. Commits landing mid-review → re-run the recipe before spawning a replacement role, so the packet matches HEAD:
8
8
 
9
- Use the installed skill directory, not a repository checkout of the kit. First verify `python3 --version` reports Python 3.9 or newer and that the helper exists. Missing runtime/resource → report the dependency and stop this step. No runtime auto-install or alternate-language fallback.
9
+ Use the installed skill directory, not a repository checkout; a missing runtime or helper (`node --version`) → report it and stop, with no auto-install or fallback.
10
10
 
11
11
  ```sh
12
- python3 "<executing-skill-dir>/scripts/build-review-packet.py" \
12
+ node "<executing-skill-dir>/scripts/build-review-packet.mjs" \
13
13
  --project "<repo-root>" \
14
14
  --design "docs/plans/<date>-<topic>/<leaf>-design.md" \
15
15
  --progress "docs/plans/<date>-<topic>/<leaf>-progress.md" \
16
16
  --scope feature
17
17
  ```
18
18
 
19
- For an opt-in requirement review, use the same invocation with `--scope requirement --requirement 2` (substitute the selected number). The feature destination is `<design doc's directory>/<leaf>-review-packet.md`; the requirement destination is `<design doc's directory>/<leaf>-review-packet-r<N>.md`. Requirement-suffixed packets never overwrite each other or the feature packet.
19
+ For an opt-in requirement review, the same invocation with `--scope requirement --requirement 2` (substitute the number): destination `<leaf>-review-packet-r<N>.md`, versus the feature packet's `<design doc's directory>/<leaf>-review-packet.md`. Requirement-suffixed packets never overwrite each other or the feature packet.
20
20
 
21
- `--project` resolves from the caller's cwd and must name the Git worktree root. Design/progress arguments are absolute or relative to that project, regardless of the current directory or installed skill location. Both must be matched leaf files inside one active `docs/plans/<topic>/` folder. The helper derives the destination; symlink destinations are refused. Use argument lists in programmatic invocations, never shell interpolation.
21
+ `--project` resolves from the caller's cwd and must name the Git worktree root; design/progress paths are absolute or relative to it and must be matched leaf files in one active `docs/plans/<topic>/` folder. The helper derives the destination and refuses a symlink; to pass arguments programmatically, use a list, never shell interpolation.
22
22
 
23
- The packet retains these sections, in order:
24
- - `## Commits`
25
- - `## Changed files`
26
- - `## Acceptance criteria (verbatim from the design doc)`
27
- - `## Feature acceptance (verbatim)` (empty for a trivial design)
28
- - `## Production-risk notes (verbatim, if any)`
29
- - `## Diff`
23
+ The packet retains, in order: `## Commits`, `## Changed files`, `## Acceptance criteria (verbatim from the design doc)`, `## Feature acceptance (verbatim)` (empty for a trivial design), `## Production-risk notes (verbatim, if any)`, `## Diff`.
30
24
 
31
- Criteria and production-risk content remain verbatim, including all lines in multiple risk blocks; fenced template headings do not route extraction. Feature criteria span from the first real requirement to Feature acceptance (or document end); requirement criteria include only the selected requirement block. Design-level production-risk areas are retained as review context. The packet header records fixed `Base:` and `Head:` IDs: feature uses the first recorded commit's parent through current HEAD; requirement uses the previous requirement's last recorded commit exclusively through the selected last recorded commit inclusively. The helper excludes generated `*-review-packet*.md` files from diff/stat, including committed packets.
25
+ Criteria and production-risk content stay verbatim, including every line of multiple risk blocks; a fenced template heading never routes extraction. Feature criteria span from the first real requirement to Feature acceptance (or document end); a requirement packet covers only its own block, keeping design-level production-risk areas as context. The helper excludes generated `*-review-packet*.md` files from diff/stat, including committed packets.
32
26
 
33
- A Commit cell is one immutable hexadecimal ID or an ordered whitespace-separated list. Preserve the first ID; append review-fix IDs in order. Feature assembly permits missing commits only for terminal failed/skipped rows. Requirement assembly needs an explicit selected and previous bound; it refuses to guess across a failed/skipped row without a commit. Missing relevant rows, malformed/ambiguous entries, invalid revisions, or non-ancestral order → correct the record from evidence and rerun. The helper never edits progress, commits, switches branches, or marks anything approved. It finishes all parse/Git work before atomically replacing a complete packet. A nonzero exit is failure, not review evidence: report stderr and stop delegation; an older packet remains intact but may be stale.
27
+ A Commit cell is one immutable hexadecimal ID or an ordered whitespace-separated list — preserve the first ID, append review-fix IDs in order. Only terminal failed/skipped rows may lack a commit, and requirement assembly never guesses a bound across such a row. Anything missing, malformed, or non-ancestral → correct the record from evidence and rerun. The helper never edits progress, commits, or approves, replacing a packet only after all parse/Git work succeeds. A nonzero exit is failure, not evidence: report stderr and stop delegation.
34
28
 
35
- After late review fixes, commit them and append their IDs to the selected row before refreshing a requirement packet; feature packets refresh HEAD automatically. Re-run the helper before any replacement reviewer and before digest writing if stale or missing. Packet generation is evidence assembly, not review approval. Re-green the full suite + E2E after fixes, then complete review and the human checkpoint.
29
+ After late fixes, commit, append the IDs to the selected row, and refresh the packet; feature packets refresh HEAD automatically. Re-run the helper before any replacement reviewer, and before digest writing if stale or missing. Then re-green the full suite + E2E and finish the checkpoint.
36
30
 
37
31
  ## Delegate or review inline
38
32
 
39
- - **`parallel`** (the `auto` resolution when risk content is present) — request the host’s `parallel-review` capability for four fresh-context, read-only logical roles: `pwk-spec-reviewer`, `pwk-tracing-reviewer`, `pwk-smell-reviewer`, and `pwk-hazard-reviewer`. Spawn each role with a **one-liner** — a pointer to the packet file with the role framing appended last: the checklist name of the role (`spec alignment`, `code tracing`, `code smells`, or `production hazards`). For example: `Read docs/plans/<date>-<topic>/<leaf>-review-packet.md. Your role: spec alignment.` The packet never appears in spawn arguments. Require independent execution and one collected outcome per role. The reviewer role contracts live in `agents/pwk-*-reviewer.md`; do not duplicate their checklists in the workflow instructions. Reviewers are read-only reporters; you apply smell fixes yourself (full suite + E2E must stay green, commit) and flag trace/spec/hazard findings as follow-ups for the human.
40
-
33
+ - **`parallel`** (the `auto` resolution when risk content is present) — request the host's `parallel-review` capability for four fresh-context, read-only roles: `pwk-spec-reviewer`, `pwk-tracing-reviewer`, `pwk-smell-reviewer`, `pwk-hazard-reviewer`. Spawn each with a **one-liner** pointing at the packet file, role framing last — the checklist name (`spec alignment`, `code tracing`, `code smells`, `production hazards`), e.g. `Read docs/plans/<date>-<topic>/<leaf>-review-packet.md. Your role: spec alignment.` The packet never appears in spawn arguments. Require independent execution and one collected outcome per role; the contracts live in `agents/pwk-*-reviewer.md`. Reviewers only report — you apply smell fixes (full suite + E2E green, commit) and flag trace/spec/hazard findings for the human.
41
34
  - **`inline`** — perform `/skill:pwk-code-review` over the whole diff as a single pass.
42
- - **Fallback** — if the host has no compatible parallel-review capability, cannot prove the requested read-only/fresh-context/bounded constraints, or delegation fails, perform the missing review work inline. Retain successful delegated reports and do not mark the feature fully reviewed while a required role is missing.
35
+ - **Fallback** — no compatible parallel-review capability, unprovable read-only/fresh-context/bounded constraints, or failed delegation → perform the missing review work inline. Retain successful delegated reports; never mark the feature fully reviewed while a required role is missing.
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build review evidence from explicit, immutable Git bounds.
4
+ *
5
+ * Usage: node build-review-packet.mjs --project <root> --design <path> --progress <path> --scope feature|requirement [--requirement N]
6
+ */
7
+ import { spawnSync } from "node:child_process";
8
+ import { constants, lstatSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
10
+ import { parseArgs } from "node:util";
11
+
12
+ class PacketError extends Error {}
13
+
14
+ /** Keep original lines for evidence; inspect headings only outside fenced examples. */
15
+ class Document {
16
+ constructor(path) {
17
+ // A strict decode, as Python's open(encoding="utf-8") is: reading as utf8 would replace
18
+ // invalid bytes and build a packet from damaged evidence.
19
+ this.lines = new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(path)).split(/(?<=\n)/);
20
+ this.headings = [];
21
+ this.visible = new Set();
22
+ let fence = null;
23
+ this.lines.forEach((line, index) => {
24
+ const stripped = line.replace(/\r?\n$/, "");
25
+ const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(stripped);
26
+ if (fence) {
27
+ if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length && !marker[2].trim()) fence = null;
28
+ return;
29
+ }
30
+ if (marker) {
31
+ fence = marker[1];
32
+ return;
33
+ }
34
+ this.visible.add(index);
35
+ const heading = /^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(stripped);
36
+ if (heading) this.headings.push([index, heading[1].length, heading[2]]);
37
+ });
38
+ if (fence) throw new PacketError(`${basename(path)}: unclosed code fence`);
39
+ }
40
+
41
+ section(title, level = 2, required = false) {
42
+ const matches = this.headings.filter(([, depth, text]) => depth === level && text === title);
43
+ if (matches.length > 1 || (required && matches.length === 0)) {
44
+ throw new PacketError(`expected one ${title} section; found ${matches.length}`);
45
+ }
46
+ if (matches.length === 0) return null;
47
+ const start = matches[0][0];
48
+ const end = this.headings.find(([index, depth]) => index > start && depth <= level)?.[0] ?? this.lines.length;
49
+ return [start, end];
50
+ }
51
+
52
+ text(start, end) {
53
+ return this.lines.slice(start, end).join("");
54
+ }
55
+ }
56
+
57
+ function git(project, ...args) {
58
+ const result = spawnSync("git", ["-C", String(project), ...args], {
59
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" },
60
+ encoding: "utf8",
61
+ });
62
+ if (result.status !== 0) {
63
+ throw new PacketError(`git ${args.join(" ")} failed: ${(result.stderr ?? "").trim()}`);
64
+ }
65
+ return result.stdout;
66
+ }
67
+
68
+ const isSymlink = (path) => {
69
+ try {
70
+ return lstatSync(path).isSymbolicLink();
71
+ } catch {
72
+ return false;
73
+ }
74
+ };
75
+
76
+ function resolveInput(project, value) {
77
+ const path = isAbsolute(value) ? value : join(project, value);
78
+ return realpathSync(path);
79
+ }
80
+
81
+ /** POSIX-style relative segments, or null when the child is outside the parent. */
82
+ function relativeParts(child, parent) {
83
+ const rel = relative(parent, child);
84
+ if (!rel || rel === "" || rel.startsWith("..")) return null;
85
+ return rel.split(/[\\/]/);
86
+ }
87
+
88
+ function validateDestination(path) {
89
+ if (isSymlink(path)) throw new PacketError("packet destination must be a regular file, not a symlink or directory");
90
+ try {
91
+ if (!lstatSync(path).isFile()) {
92
+ throw new PacketError("packet destination must be a regular file, not a symlink or directory");
93
+ }
94
+ } catch (error) {
95
+ if (error instanceof PacketError) throw error;
96
+ }
97
+ }
98
+
99
+ function paths(args) {
100
+ const project = realpathSync(resolve(args.project));
101
+ if (resolve(git(project, "rev-parse", "--show-toplevel").trim()) !== project) {
102
+ throw new PacketError("--project must be the Git worktree root");
103
+ }
104
+ for (const value of [args.design, args.progress]) {
105
+ const supplied = resolve(isAbsolute(value) ? value : join(project, value));
106
+ const lexicalProject = resolve(args.project);
107
+ let rel = relativeParts(supplied, lexicalProject);
108
+ if (!rel) rel = relativeParts(supplied, project);
109
+ if (!rel) throw new PacketError(`${value} is outside the project`);
110
+ let current = project;
111
+ for (const part of rel) {
112
+ current = join(current, part);
113
+ if (isSymlink(current)) throw new PacketError("design/progress paths must not traverse symlinks");
114
+ }
115
+ }
116
+ const design = resolveInput(project, args.design);
117
+ const progress = resolveInput(project, args.progress);
118
+ const relativeTopic = relativeParts(design, join(project, "docs", "plans"));
119
+ if (
120
+ !relativeTopic ||
121
+ relativeTopic.length !== 2 ||
122
+ relativeTopic[0] === "completed" ||
123
+ !design.endsWith("-design.md")
124
+ ) {
125
+ throw new PacketError("design must be docs/plans/<topic>/<leaf>-design.md in an active topic");
126
+ }
127
+ const leaf = basename(design).slice(0, -"-design.md".length);
128
+ if (!leaf || progress !== join(dirname(design), `${leaf}-progress.md`)) {
129
+ throw new PacketError("progress must be the matching <leaf>-progress.md beside the design");
130
+ }
131
+ for (const directory of [
132
+ join(project, "docs"),
133
+ join(project, "docs", "plans"),
134
+ join(project, "docs", "plans", relativeTopic[0]),
135
+ ]) {
136
+ if (isSymlink(directory)) throw new PacketError("symlink topic directories are not writable packet destinations");
137
+ }
138
+ const suffix = args.scope === "feature" ? "" : `-r${args.requirement}`;
139
+ const destination = join(dirname(design), `${leaf}-review-packet${suffix}.md`);
140
+ validateDestination(destination);
141
+ return { project, design, progress, destination };
142
+ }
143
+
144
+ function requirements(document) {
145
+ const region = document.section("Requirements", 2, true);
146
+ const headings = [];
147
+ for (const [index, depth, title] of document.headings) {
148
+ const match = /^R([1-9][0-9]*):\s*\S/.exec(title);
149
+ if (depth === 3 && /^R[0-9]/.test(title) && !match) throw new PacketError("malformed requirement heading");
150
+ if (depth === 3 && match) {
151
+ if (!(region[0] < index && index < region[1])) {
152
+ throw new PacketError("requirement heading outside Requirements section");
153
+ }
154
+ headings.push([Number(match[1]), index]);
155
+ }
156
+ }
157
+ const numbers = headings.map(([number]) => number);
158
+ if (numbers.length === 0 || new Set(numbers).size !== numbers.length) {
159
+ throw new PacketError("missing or duplicate requirement headings");
160
+ }
161
+ return { headings, region };
162
+ }
163
+
164
+ function progressRows(document, project, design) {
165
+ const firstHeading = document.headings.find(([, depth]) => depth === 2)?.[0] ?? document.lines.length;
166
+ const pointers = [...document.visible]
167
+ .filter((index) => index < firstHeading && document.lines[index].startsWith("Design: "))
168
+ .map((index) => document.lines[index].trim().slice(8));
169
+ if (pointers.length !== 1 || resolveInput(project, pointers[0]) !== design) {
170
+ throw new PacketError("progress Design header must identify the selected design exactly once");
171
+ }
172
+ const [start, end] = document.section("Requirements", 2, true);
173
+ const lines = [];
174
+ for (let index = start + 1; index < end; index += 1) {
175
+ if (document.visible.has(index) && document.lines[index].trim()) lines.push(document.lines[index].trim());
176
+ }
177
+ if (lines.length < 3) throw new PacketError("missing Requirements table");
178
+
179
+ const cells = (line) => {
180
+ if (!line.startsWith("|") || !line.endsWith("|")) throw new PacketError("malformed Requirements table row");
181
+ return line
182
+ .split(/(?<!\\)\|/)
183
+ .slice(1, -1)
184
+ .map((cell) => cell.trim());
185
+ };
186
+
187
+ const header = cells(lines[0]);
188
+ if (header.join("|") !== ["#", "Done", "Requirement", "Per-req ceremony", "Commit"].join("|")) {
189
+ throw new PacketError("Requirements table must have #, Done, Requirement, Per-req ceremony, Commit columns");
190
+ }
191
+ const separator = cells(lines[1]);
192
+ if (separator.length !== 5 || !separator.every((cell) => /^:?-+:?$/.test(cell))) {
193
+ throw new PacketError("malformed Requirements table separator");
194
+ }
195
+ const rows = new Map();
196
+ for (const line of lines.slice(2)) {
197
+ const row = cells(line);
198
+ if (row.length !== 5 || !/^[1-9][0-9]*$/.test(row[0])) throw new PacketError("malformed Requirements table row");
199
+ const number = Number(row[0]);
200
+ if (rows.has(number)) throw new PacketError(`duplicate R${number} progress row`);
201
+ if (!["", "⬜", "🔄", "✅", "❌", "⏭"].includes(row[1])) throw new PacketError(`R${number}: invalid Done cell`);
202
+ rows.set(number, row);
203
+ }
204
+ return rows;
205
+ }
206
+
207
+ function bounds(project, rows, headings, args) {
208
+ const numbers = headings.map(([number]) => number);
209
+ const position = args.scope === "requirement" ? numbers.indexOf(args.requirement) : null;
210
+ const relevant = position === null ? numbers : numbers.slice(Math.max(0, position - 1), position + 1);
211
+ if ([...rows.keys()].some((number) => !numbers.includes(number))) {
212
+ throw new PacketError("progress contains requirements absent from the design");
213
+ }
214
+ const recorded = new Map();
215
+ let previous = null;
216
+ const currentHead = git(project, "rev-parse", "--verify", "HEAD^{commit}").trim();
217
+ for (const number of relevant) {
218
+ if (!rows.has(number)) throw new PacketError(`R${number}: missing progress row`);
219
+ const row = rows.get(number);
220
+ const value = row[4];
221
+ if (value === "" || value === "—" || value === "-") {
222
+ if (!["❌", "⏭"].includes(row[1])) throw new PacketError(`R${number}: missing Commit entry`);
223
+ recorded.set(number, []);
224
+ continue;
225
+ }
226
+ // An ordered whitespace-separated list supports review-fix commits without losing the
227
+ // first feature commit. Other formats are ambiguous, never mined for hashes.
228
+ const entries = value.split(/\s+/).filter(Boolean);
229
+ if (!entries.every((entry) => /^[0-9a-fA-F]{7,64}$/.test(entry))) {
230
+ throw new PacketError(`R${number}: Commit must contain only ordered hexadecimal commit IDs`);
231
+ }
232
+ const commits = [];
233
+ for (const entry of entries) {
234
+ const commit = git(project, "rev-parse", "--verify", `${entry}^{commit}`).trim();
235
+ if (previous) {
236
+ if (previous === commit) throw new PacketError("duplicate or overlapping recorded commits");
237
+ git(project, "merge-base", "--is-ancestor", previous, commit);
238
+ }
239
+ git(project, "merge-base", "--is-ancestor", commit, currentHead);
240
+ commits.push(commit);
241
+ previous = commit;
242
+ }
243
+ recorded.set(number, commits);
244
+ }
245
+ if (args.scope === "feature") {
246
+ const first = [...recorded.values()].find((commits) => commits.length > 0)?.[0] ?? null;
247
+ if (!first) throw new PacketError("no recorded commits; cannot infer a feature base");
248
+ return [git(project, "rev-parse", "--verify", `${first}^`).trim(), currentHead];
249
+ }
250
+ const selected = recorded.get(args.requirement);
251
+ if (!selected?.length) throw new PacketError("selected requirement has no recorded bound");
252
+ if (position === 0) return [git(project, "rev-parse", "--verify", `${selected[0]}^`).trim(), selected.at(-1)];
253
+ const prior = recorded.get(numbers[position - 1]);
254
+ if (!prior?.length) throw new PacketError("previous requirement has no recorded bound; cannot guess a range");
255
+ return [prior.at(-1), selected.at(-1)];
256
+ }
257
+
258
+ function evidence(document, headings, region, args) {
259
+ const feature = document.section("Feature acceptance");
260
+ if (feature && feature[0] < region[1]) throw new PacketError("Feature acceptance must follow Requirements");
261
+ let start;
262
+ let end;
263
+ if (args.scope === "feature") {
264
+ start = headings[0][1];
265
+ end = feature ? feature[0] : document.lines.length;
266
+ } else {
267
+ const position = headings.findIndex(([number]) => number === args.requirement);
268
+ start = headings[position][1];
269
+ end = position + 1 < headings.length ? headings[position + 1][1] : region[1];
270
+ }
271
+ const criteria = document.text(start, end);
272
+ let acceptance = "";
273
+ if (feature) {
274
+ const stop =
275
+ document.headings.find(
276
+ ([index, depth, title]) =>
277
+ feature[0] < index && index < feature[1] && depth === 3 && title.startsWith("Feature review:"),
278
+ )?.[0] ?? feature[1];
279
+ acceptance = document.text(feature[0], stop);
280
+ }
281
+ const notes = [];
282
+ for (const [index, depth, title] of document.headings) {
283
+ if (depth === 3 && title === "Production-risk notes" && start <= index && index < end) {
284
+ const stop =
285
+ document.headings.find(([i, d, t]) => i > index && (d <= 2 || (d === 3 && /^R[0-9]+:/.test(t))))?.[0] ??
286
+ document.lines.length;
287
+ notes.push(document.text(index, Math.min(stop, end)));
288
+ }
289
+ }
290
+ const risk = document.section("Production-risk areas");
291
+ if (risk) notes.push(document.text(risk[0], risk[1]));
292
+ return { criteria, acceptance, notes: notes.join("") };
293
+ }
294
+
295
+ function build(args) {
296
+ const { project, design: designPath, progress: progressPath, destination } = paths(args);
297
+ const design = new Document(designPath);
298
+ const { headings, region } = requirements(design);
299
+ if (args.scope === "requirement" && !headings.some(([number]) => number === args.requirement)) {
300
+ throw new PacketError("selected requirement is absent from the design");
301
+ }
302
+ const rows = progressRows(new Document(progressPath), project, designPath);
303
+ const [base, head] = bounds(project, rows, headings, args);
304
+ const { criteria, acceptance, notes } = evidence(design, headings, region, args);
305
+ const span = `${base}..${head}`;
306
+ const pathspec = ["--", ".", ":(glob,exclude)**/*-review-packet*.md"];
307
+ const sections = [
308
+ ["Commits", git(project, "log", "--oneline", "--no-decorate", span)],
309
+ ["Changed files", git(project, "diff", "--no-ext-diff", "--no-textconv", "--stat", span, ...pathspec)],
310
+ ["Acceptance criteria (verbatim from the design doc)", criteria],
311
+ ["Feature acceptance (verbatim)", acceptance],
312
+ ["Production-risk notes (verbatim, if any)", notes],
313
+ ["Diff", git(project, "diff", "--no-ext-diff", "--no-textconv", "--no-color", span, ...pathspec)],
314
+ ];
315
+ const label = args.scope === "feature" ? "feature review" : `requirement R${args.requirement} review`;
316
+ // The blank line between Base and Head is load-bearing: CommonMark merges consecutive
317
+ // plain lines into one paragraph in every preview.
318
+ let packet = `# Review packet: ${basename(dirname(designPath))} — ${label}\n\nBase: ${base}\n\nHead: ${head}\n\n`;
319
+ for (const [title, content] of sections) packet += `## ${title}\n\n${content}\n`;
320
+
321
+ // Every parse and Git read finishes before a temporary file exists, so a failure in
322
+ // assembly leaves an earlier complete packet intact. Replacement is same-directory.
323
+ let temporary = null;
324
+ try {
325
+ validateDestination(destination);
326
+ temporary = join(dirname(destination), `.${basename(destination)}.${process.pid}${Date.now()}`);
327
+ writeFileSync(temporary, packet, {
328
+ encoding: "utf8",
329
+ flag: constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
330
+ });
331
+ validateDestination(destination);
332
+ renameSync(temporary, destination);
333
+ temporary = null;
334
+ } finally {
335
+ if (temporary) {
336
+ try {
337
+ unlinkSync(temporary);
338
+ } catch {
339
+ // The temporary file is already gone.
340
+ }
341
+ }
342
+ }
343
+ console.log(`Wrote ${destination}\nRange: ${base}..${head}`);
344
+ }
345
+
346
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
347
+ console.log(`usage: node build-review-packet.mjs --project <root> --design <path> --progress <path> --scope feature|requirement [--requirement N]
348
+
349
+ Build review evidence from explicit, immutable Git bounds, with Node's standard library only.
350
+
351
+ --project resolves from the caller cwd and must be a Git worktree root. --design and --progress are absolute or relative to --project, not the skill or cwd. Output is <leaf>-review-packet[-rN].md beside the matched docs in docs/plans/<topic>/. Commit cells contain ordered whitespace-separated hexadecimal commit IDs, not revision expressions.`);
352
+ process.exit(0);
353
+ }
354
+
355
+ const { values } = (() => {
356
+ try {
357
+ return parseArgs({
358
+ args: process.argv.slice(2),
359
+ allowPositionals: false,
360
+ options: {
361
+ project: { type: "string" },
362
+ design: { type: "string" },
363
+ progress: { type: "string" },
364
+ scope: { type: "string" },
365
+ requirement: { type: "string" },
366
+ },
367
+ });
368
+ } catch (error) {
369
+ console.error(`error: ${error.message}`);
370
+ process.exit(2);
371
+ }
372
+ })();
373
+ for (const required of ["project", "design", "progress", "scope"]) {
374
+ if (!values[required]) {
375
+ console.error(`error: --${required} is required`);
376
+ process.exit(2);
377
+ }
378
+ }
379
+ if (!["feature", "requirement"].includes(values.scope)) {
380
+ console.error("error: --scope must be feature or requirement");
381
+ process.exit(2);
382
+ }
383
+ const requirement = values.requirement === undefined ? undefined : Number(values.requirement);
384
+ if ((values.scope === "requirement") !== (requirement !== undefined)) {
385
+ console.error("error: --requirement N is required only for --scope requirement");
386
+ process.exit(2);
387
+ }
388
+ try {
389
+ build({
390
+ project: values.project,
391
+ design: values.design,
392
+ progress: values.progress,
393
+ scope: values.scope,
394
+ requirement,
395
+ });
396
+ } catch (error) {
397
+ console.error(`error: ${error.message}`);
398
+ process.exit(1);
399
+ }