@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.
- package/README.md +13 -1
- package/docs/developer-usage-guide.md +18 -1
- package/docs/workflow-phases.md +5 -4
- package/extensions/workflow-guard.ts +66 -3
- package/package.json +1 -1
- package/skills/pwk-brainstorming/SKILL.md +36 -19
- package/skills/pwk-brainstorming/references/design-format.md +14 -14
- package/skills/pwk-brainstorming/references/umbrella.md +7 -8
- package/skills/pwk-brainstorming/scripts/validate-design.mjs +239 -0
- package/skills/pwk-code-review/SKILL.md +5 -5
- package/skills/pwk-executing-tasks/SKILL.md +66 -63
- package/skills/pwk-executing-tasks/references/progress-format.md +20 -21
- package/skills/pwk-executing-tasks/references/resume.md +3 -3
- package/skills/pwk-executing-tasks/references/review.md +12 -19
- package/skills/pwk-executing-tasks/scripts/build-review-packet.mjs +399 -0
- package/skills/pwk-executing-tasks/scripts/progress.mjs +180 -0
- package/skills/pwk-executing-tasks/scripts/ref.mjs +71 -0
- package/skills/pwk-executing-tasks/scripts/stage-check.mjs +48 -0
- package/skills/pwk-finalizing/SKILL.md +33 -32
- package/skills/pwk-status/SKILL.md +17 -7
- package/skills/pwk-walkthrough/SKILL.md +1 -1
- package/skills/pwk-brainstorming/scripts/validate-design.py +0 -194
- package/skills/pwk-executing-tasks/scripts/build-review-packet.py +0 -320
|
@@ -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)
|
|
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)`
|
|
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
|
|
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
|
-
|
|
55
|
+
## Code digest
|
|
56
56
|
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
- **
|
|
61
|
-
- **
|
|
62
|
-
- **
|
|
63
|
-
- **
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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** —
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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** —
|
|
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
|
+
}
|