devflow-kit 3.12.0 → 3.12.1

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/agents/architect.md +6 -2
  3. package/dist/agents/debug.md +3 -3
  4. package/dist/cli/commands/uninstall.js +1 -1
  5. package/dist/commands/bug-analysis.md +4 -4
  6. package/dist/commands/code-review.md +4 -4
  7. package/dist/commands/dynamic-build.md +4 -2
  8. package/dist/commands/implement.md +10 -10
  9. package/dist/commands/plan.md +4 -4
  10. package/dist/commands/release.md +4 -4
  11. package/dist/commands/research.md +6 -6
  12. package/dist/commands/resolve.md +4 -4
  13. package/dist/commands/self-review.md +4 -4
  14. package/dist/compliance-off/commands/bug-analysis.md +4 -4
  15. package/dist/compliance-off/commands/code-review.md +4 -4
  16. package/dist/compliance-off/commands/dynamic-build.md +4 -2
  17. package/dist/compliance-off/commands/implement.md +10 -10
  18. package/dist/compliance-off/commands/plan.md +4 -4
  19. package/dist/compliance-off/commands/release.md +4 -4
  20. package/dist/compliance-off/commands/resolve.md +4 -4
  21. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +2 -2
  22. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +2 -2
  23. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +2 -2
  24. package/dist/curation-compliance-off/commands/bug-analysis.md +4 -4
  25. package/dist/curation-compliance-off/commands/code-review.md +4 -4
  26. package/dist/curation-compliance-off/commands/dynamic-build.md +4 -2
  27. package/dist/curation-compliance-off/commands/implement.md +10 -10
  28. package/dist/curation-compliance-off/commands/plan.md +4 -4
  29. package/dist/curation-compliance-off/commands/release.md +4 -4
  30. package/dist/curation-compliance-off/commands/resolve.md +4 -4
  31. package/dist/curation-off/agents/architect.md +6 -2
  32. package/dist/curation-off/agents/debug.md +3 -3
  33. package/dist/curation-off/commands/bug-analysis.md +4 -4
  34. package/dist/curation-off/commands/code-review.md +4 -4
  35. package/dist/curation-off/commands/dynamic-build.md +4 -2
  36. package/dist/curation-off/commands/implement.md +10 -10
  37. package/dist/curation-off/commands/plan.md +4 -4
  38. package/dist/curation-off/commands/release.md +4 -4
  39. package/dist/curation-off/commands/research.md +6 -6
  40. package/dist/curation-off/commands/resolve.md +4 -4
  41. package/dist/curation-off/commands/self-review.md +4 -4
  42. package/dist/hud/components/version-badge.js +1 -1
  43. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +2 -2
  44. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +2 -2
  45. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +2 -2
  46. package/package.json +1 -1
  47. package/src/assets/agents/architect.mds +6 -2
  48. package/src/assets/agents/debug.mds +3 -3
  49. package/src/assets/commands/_partials/_engine.mds +4 -2
  50. package/src/assets/commands/_partials/_knowledge.mds +4 -4
  51. package/src/assets/commands/implement.mds +7 -7
  52. package/src/assets/commands/research.mds +2 -2
  53. package/src/assets/mds/git/_ops_steps.mds +2 -2
  54. package/src/assets/scripts/ci-wait.cjs +10 -7
  55. package/src/assets/scripts/resolve-root.cjs +7 -3
  56. package/src/assets/scripts/verify-evidence.cjs +213 -20
  57. package/src/assets/skills/design-review/SKILL.md +19 -8
@@ -21,7 +21,7 @@ Orchestrate a single task by spawning agents.
21
21
  $ARGUMENTS
22
22
  </command-input>
23
23
 
24
- `COMMAND_INPUT` is the text above: a plan document path (an existing `.md` file), an issue reference, a task description, or empty (use the conversation).
24
+ `COMMAND_INPUT` is the text above: a plan document path, an issue reference, a task description, or empty (use the conversation).
25
25
 
26
26
  {{issue_ref_grammar()}}
27
27
 
@@ -65,7 +65,7 @@ When `EVIDENCE_POLICY` is `required`, record `TRACEABILITY: DEGRADED (compliance
65
65
 
66
66
  `PR_DESCRIPTION_GUIDANCE` is the full `## PR Description Guidance` section of the plan document. Any other input, empty input included, or a plan without that section, gives `(none)`.
67
67
 
68
- **Empty input.** When `COMMAND_INPUT` is empty — a plan handoff arrives this way, with the plan already in the conversation — there is no argument to name the branch from, and the Git agent sees none of this conversation. Write the description yourself: one line, taken from the plan's title or, with no plan, from the conversation. Send it as the setup-task `TASK_DESCRIPTION` below.
68
+ **Empty input.** When `COMMAND_INPUT` is empty — a plan handoff arrives this way with the plan already in the conversation — no argument can name the branch, and the Git agent sees none of this conversation. Write the description yourself: one line, taken from the plan's title or, with no plan, from the conversation. Send it as the setup-task `TASK_DESCRIPTION` below.
69
69
 
70
70
  Spawn Git agent:
71
71
 
@@ -93,7 +93,7 @@ The issue token is forwarded **unclassified**: routing is by SHAPE alone. Only `
93
93
  <!-- compliance:end -->
94
94
  - **Token count.** Forwarding the FIRST token alone sends `/implement fix the login bug` as `ISSUE_INPUT: fix` with no description, so the request is dropped.
95
95
 
96
- **Capture from Git agent output**, each value as written. Never re-derive one issue value from another, and never infer one from a `TRACEABILITY: DEGRADED ({reason})` status line: that line is a status, not issue content.
96
+ **Capture from Git agent output**, each value as written. Never re-derive one issue value from another, and never infer one from a `TRACEABILITY: DEGRADED ({reason})` status line.
97
97
  - `TASK_ID`: The branch name created by Git agent
98
98
  - `BASE_BRANCH`: Branch this feature was created from; stop unless it passes the setup check
99
99
  - `ISSUE_ID`: the `- **Issue ID**:` line under `### Handoff Values`, if any. `ISSUE_NUMBER` is this value.
@@ -192,7 +192,7 @@ Pass `DECISIONS_CONTEXT` to Code agent (Phase 2) and Harden agent (Phase 4).
192
192
  <!-- curation:end -->
193
193
  {{knowledge_load_after_root()}}
194
194
 
195
- **Fresh base.** Before Phase 2, write `BASE_BRANCH` with the Write tool to a new file in a `mktemp -d` folder (`{name file}`). If the Write tool fails, treat the value as missing; never write it with a shell command. Then run `F="{name file}"; trap 'rm -- "$F"' EXIT; cd "{worktree}" && node "$HOME/.devflow/scripts/resolve-base.cjs" local --name-stdin < "$F"; echo "exit=$?"`. Output other than one `BASE_REF=` line (40 or 64 hex, or `none`) with `exit=0` counts as `NOTE=stop`, `REASON=resolver-unavailable`. Record `BASE_REF`. By `NOTE`: `none` continues; `info` prints one plain line; `degraded` records `TRACEABILITY: DEGRADED (base {REASON}; fix: {fix})`; `stop` (`BASE_REF=none`) stops the run, naming REASON and its fix. Fix by REASON: no-merge-base `git fetch --unshallow`; no-ref or timeout `git fetch origin <base>` (`<base>` stays as written); resolver-unavailable `re-run; if it repeats, run devflow init`; invalid-name the allowed set, never the name; any other REASON none, and `; fix: {fix}` is left out.
195
+ **Fresh base.** Before Phase 2, write `BASE_BRANCH` with the Write tool to a new file in a `mktemp -d` folder (`{name file}`). If the Write tool fails, treat the value as missing; never write it with a shell command. Then run `F="{name file}"; trap 'rm -- "$F"; rmdir -- "${F%/*}"' EXIT; cd "{worktree}" && node "$HOME/.devflow/scripts/resolve-base.cjs" local --name-stdin < "$F"; echo "exit=$?"`. Output other than one `BASE_REF=` line (40 or 64 hex, or `none`) with `exit=0` counts as `NOTE=stop`, `REASON=resolver-unavailable`. Record `BASE_REF`. By `NOTE`: `none` continues; `info` prints one plain line; `degraded` records `TRACEABILITY: DEGRADED (base {REASON}; fix: {fix})`; `stop` (`BASE_REF=none`) stops the run, naming REASON and its fix. Fix by REASON: no-merge-base `git fetch --unshallow`; no-ref or timeout `git fetch origin <base>` (`<base>` stays as written); resolver-unavailable `re-run; if it repeats, run devflow init`; invalid-name the allowed set, never the name; any other REASON none, and `; fix: {fix}` is left out.
196
196
 
197
197
  ### Phase 2: Implement
198
198
 
@@ -520,9 +520,9 @@ Any other or missing Status: report and halt; never PASS.
520
520
  <!-- compliance:end -->
521
521
 
522
522
  <!-- compliance:on -->
523
- Run once, after the gates, under every strategy. No Code agent opens a PR: the Git agent does, and it pastes `ISSUE_PR_LINK`, `PR_EXCEPTIONS` and `PR_TEST_PLAN_BLOCK` through their paste gates and scrubs the body (D11) before `gh pr create`.
523
+ Run once, after the gates, under every strategy. No Code agent opens a PR: the Git agent does, and it pastes `ISSUE_PR_LINK`, `PR_EXCEPTIONS` and `PR_TEST_PLAN_BLOCK` through their paste gates and scrubs the body (D11) before `gh pr create`. This command writes no link line and creates no PR itself: it forwards Phase 1's link line verbatim.
524
524
  <!-- compliance:off -->
525
- Run once, after the gates, under every strategy. No Code agent opens a PR: the Git agent does, and it pastes `ISSUE_PR_LINK` through its paste gate and scrubs the body (D11) before `gh pr create`.
525
+ Run once, after the gates, under every strategy. No Code agent opens a PR: the Git agent does, and it pastes `ISSUE_PR_LINK` through its paste gate and scrubs the body (D11) before `gh pr create`. This command writes no link line and creates no PR itself: it forwards Phase 1's link line verbatim.
526
526
  <!-- compliance:end -->
527
527
 
528
528
  ```
@@ -629,7 +629,7 @@ If any Git agent output emitted `TRACEABILITY: DEGRADED ({reason})` lines during
629
629
  If any Git agent output emitted `TRACEABILITY: DEGRADED ({reason})` lines during the run, or the compliance-off line of Phase 1, the fresh base step, Phase 10's push, Phase 11's push or Phase 11's script output carried one, surface them verbatim in the report under a `### Traceability` subsection.
630
630
  <!-- compliance:end -->
631
631
 
632
- If Phase 9 listed uncommitted files, show them as a `### Uncommitted files` subsection: the user commits them or discards them.
632
+ If Phase 9 listed uncommitted files, show them as a `### Uncommitted files` subsection.
633
633
 
634
634
  If Phase 1 recorded an evidence exception, show its `## Evidence Exceptions` lines in the report.
635
635
 
@@ -19,7 +19,7 @@ output-dir: dist/commands
19
19
 
20
20
  ## Input
21
21
 
22
- What follows `/research` is bound once, here. Every later step names it `COMMAND_INPUT` and never restates it:
22
+ What follows `/research` is bound once, here. Every later step names it `COMMAND_INPUT`:
23
23
 
24
24
  <command-input>
25
25
  $ARGUMENTS
@@ -69,7 +69,7 @@ Analyze the research question to infer research types needed (min 2, max 5). For
69
69
  - `RESEARCH_QUESTION`: Focused sub-question for this type
70
70
  - `OUTPUT_PATH`: `{worktree}/.devflow/docs/research/{topic-slug}/{YYYY-MM-DD_HHMM}/{type}.md`
71
71
 
72
- **Tool availability check**: If WebSearch/WebFetch are unavailable, restrict to `codebase` type only.
72
+ **Tool availability check**: If WebSearch/WebFetch are unavailable, restrict to `codebase` type only, telling the user why.
73
73
 
74
74
  ### Phase 3: Orient (Conditional)
75
75
 
@@ -93,7 +93,7 @@ For each issue reference in `SHIPPED_ISSUES` (sequentially, the first ≤50 in l
93
93
  1. Verify on feature branch (not main/master/develop/integration/trunk/release/*/staging/production) - error if not
94
94
  2. Check for uncommitted changes. `COMMIT_UNCOMMITTED` is `true` when absent: if any, create atomic commit using `devflow:git` patterns, naming `TASK_ID` when given. When it is `false`, commit nothing — a stray file is not this operation's to commit: list each uncommitted path under `### Uncommitted files` in the output, report `Committed: no` and go on; step 4a opens the PR from HEAD.
95
95
  3. Check if branch pushed to remote - if not, push with `-u` flag. If that push is refused and the branch's open PR is cross-repository with maintainer edits off (`gh pr view --json isCrossRepository,maintainerCanModify`), emit `TRACEABILITY: DEGRADED (cannot push to fork)` and go to 4a (4a–4c edit only the PR).
96
- 4a. Check if PR exists - if not, create PR using guidance from (in priority order): (a) `PR_DESCRIPTION_GUIDANCE` if given and not `(none)`, (b) generated from branch context. Compose the PR body via the `devflow:git` template to `$DEVFLOW_BODY_RAW` (a D11 sink: it publishes at repo visibility), then append the caller blocks. Apply the Comment-sink scrub (D11) — a failed one posts neither block; the D11 paragraph below creates the PR. When `BASE_BRANCH` is given, check it first against the step-5a name check (judge the match by reading; never pass the value to a command). A mismatch ends with Status BLOCKED and no PR, naming only the allowed set. Then Write `BASE_BRANCH` to a new file in a `mktemp -d` folder (`{name file}`). If the Write tool fails, treat the value as missing; never write it with a shell command. Each PR create, the scrubber-failure one too, runs in the existing D11 shell: bind `F="{name file}"`, then `test -s "$F" && gh pr create … --base="$(cat -- "$F")"`, then, in that same shell and after the last create attempt, `rm -- "$F"`. That `rm` runs when the shell reaches it; an INT or TERM signal skips it and leaves the file in the temp directory. The D11 trap stays unchanged: its rm list does not gain the name file, and no second EXIT trap is armed. A failed `test -s` (an empty file, or none after a failed Write) ends with Status BLOCKED and no PR.
96
+ 4a. Check if PR exists - if not, create PR using guidance from (in priority order): (a) `PR_DESCRIPTION_GUIDANCE` if given and not `(none)`, (b) generated from branch context. Compose the PR body via the `devflow:git` template to `$DEVFLOW_BODY_RAW` (a D11 sink: it publishes at repo visibility), then append the caller blocks. Apply the Comment-sink scrub (D11) — a failed one posts neither block; the D11 paragraph below creates the PR. When `BASE_BRANCH` is given, check it first against the step-5a name check (judge the match by reading; never pass the value to a command). A mismatch ends with Status BLOCKED and no PR, naming only the allowed set. Then Write `BASE_BRANCH` to a new file in a `mktemp -d` folder (`{name file}`). If the Write tool fails, treat the value as missing; never write it with a shell command. Each PR create, the scrubber-failure one too, runs in the existing D11 shell: bind `F="{name file}"`, then `test -s "$F" && gh pr create … --base="$(cat -- "$F")"`, then, in that same shell and after the last create attempt, `rm -- "$F"; rmdir -- "${F%/*}"`. That removal runs when the shell reaches it; an INT or TERM signal skips it and leaves the file and its folder in the temp directory. The D11 trap stays unchanged: its rm list does not gain the name file, and no second EXIT trap is armed. A failed `test -s` (an empty file, or none after a failed Write) ends with Status BLOCKED and no PR.
97
97
  <!-- compliance:on -->
98
98
  - **Caller blocks**, in order: `PR_WAVE_BLOCK` (`check wave`), then `PR_TEST_PLAN_BLOCK` (`check block`), each when given and not `(none)`. Write it byte for byte to a new file in a `mktemp -d` folder with the Write tool, never via a shell string, and run `node "$HOME/.devflow/scripts/verify-evidence.cjs" check <wave|block> <file>; echo "exit=$?"`. Only `exit=0` admits it, verbatim; else omit it, never repaired or partly pasted, and emit `TRACEABILITY: DEGRADED (wave block does not match its grammar)` or `TRACEABILITY: DEGRADED (test-plan block does not match its grammar)` with steps 4b/4c's lines. An admitted wave block is the body's only `## Related Issues`: skip 4b.
99
99
  <!-- compliance:off -->
@@ -148,7 +148,7 @@ For each issue reference in `SHIPPED_ISSUES` (sequentially, the first ≤50 in l
148
148
  @define ensure_pr_ready_steps_from_4c():
149
149
  4c. Retitle, only when `APPLY_CONVENTIONS` is `true`: if the PR title breaks the convention in the PR Titles section of `.devflow/conventions.md`, retitle it; skip silently when that file is absent. Two rules, because the title derives from third-party PR titles:
150
150
  - **Validate before use.** Skip the retitle (leave the PR title as-is, no error) if the composed title contains any of `` $ ` \ " ' ; | & < > `` or a newline; judge the match by reading; never pass the value to a command.
151
- - **Pass from a file, never as command text.** Write the title with the Write tool to a new file in a `mktemp -d` folder (`{title file}`). If the Write tool fails, treat the value as missing; never write it with a shell command. A missing title skips the retitle after `rm -- "{title file}"`. Otherwise run `F="{title file}"; trap 'rm -- "$F"' EXIT; test -s "$F" && gh pr edit {PR_NUMBER} --title="$(cat -- "$F")"; echo "gh=$?"`.
151
+ - **Pass from a file, never as command text.** Write the title with the Write tool to a new file in a `mktemp -d` folder (`{title file}`). If the Write tool fails, treat the value as missing; never write it with a shell command. A missing title skips the retitle after `F="{title file}"; rm -- "$F"; rmdir -- "${F%/*}"`. Otherwise run `F="{title file}"; trap 'rm -- "$F"; rmdir -- "${F%/*}"' EXIT; test -s "$F" && gh pr edit {PR_NUMBER} --title="$(cat -- "$F")"; echo "gh=$?"`.
152
152
 
153
153
  On any 4xx/5xx from `gh pr edit`: emit `TRACEABILITY: DEGRADED ({reason})` and continue — a failed retitle never blocks the PR.
154
154
  5. Get the base branch
@@ -33,10 +33,12 @@
33
33
  // table of /implement and /resolve as a pure function of the status, the reason, the
34
34
  // seconds waited and the two counts, so a prompt only acts on the answer. An argv that
35
35
  // fails its gate prints the short form
36
- // `CI INDETERMINATE pr=- head=- waited=0 reason=usage` and makes no gh call. The
36
+ // `CI INDETERMINATE pr=- head=- waited=0 reason=usage`, with no NEXT line, and makes no
37
+ // gh call. The
37
38
  // boundary re-checks the CI line against LINE_RE and the 400-character bound, and the
38
- // NEXT line against the fixed set NEXT_LINES, before it writes them. A failed check
39
- // prints the one failure line and no NEXT line, as it does without the two flags.
39
+ // NEXT line against the fixed set NEXT_LINES, before it writes them. A wait that fails
40
+ // inside the script prints its INDETERMINATE line (`gh-failed` or `unparseable`) and,
41
+ // with the two flags, the NEXT line of an INDETERMINATE result that waited 0 seconds.
40
42
  // Exit 0 whenever a line was printed; a reader treats a missing or unparseable line, an
41
43
  // unlisted status or a non-zero exit as INDETERMINATE.
42
44
  //
@@ -1080,10 +1082,11 @@ async function main(argv, deps) {
1080
1082
  result = null;
1081
1083
  line = indeterminate('unparseable');
1082
1084
  }
1083
- if (result === null || parsed.waits === undefined || parsed.fixes === undefined) {
1084
- return { code: 0, stdout: line + '\n' };
1085
- }
1086
- const next = renderNext(nextAction(result, parsed.waits, parsed.fixes));
1085
+ if (parsed.waits === undefined || parsed.fixes === undefined) return { code: 0, stdout: line + '\n' };
1086
+ // D-CI-NEXT: a wait that failed inside this script is INDETERMINATE that waited 0 seconds,
1087
+ // so the gate waits again while waits remain, as the prompts did before the NEXT line.
1088
+ const failed = { status: 'INDETERMINATE', reason: null, waited: 0 };
1089
+ const next = renderNext(nextAction(result === null ? failed : result, parsed.waits, parsed.fixes));
1087
1090
  if (!checkNext(next)) return { code: 0, stdout: indeterminate('unparseable') + '\n' };
1088
1091
  return { code: 0, stdout: line + '\n' + next + '\n' };
1089
1092
  }
@@ -36,8 +36,9 @@
36
36
  // - main() returns {code, line} and never calls process.exit or throws; the single
37
37
  // `require.main === module` boundary is the only stdout write and the only
38
38
  // exitCode assignment
39
- // - every subprocess is spawned with an argv array (never a shell), stdin ignored,
40
- // a timeout and a maxBuffer — resolve-settings.cjs's runGit does this
39
+ // - every subprocess is spawned with an argv array (never a shell), stdin ignored
40
+ // and a maxBuffer, with no time limit (D-ROOT-NO-TIMEOUT): main() clears the limit
41
+ // that resolve-settings.cjs's runGit sets, and resolve-settings.cjs keeps its own
41
42
  // - a local read only: no network, nothing written
42
43
 
43
44
  'use strict';
@@ -113,7 +114,10 @@ function fail(code, reason) {
113
114
  */
114
115
  function main(argv, deps) {
115
116
  try {
116
- const exec = deps && typeof deps.exec === 'function' ? deps.exec : settings.defaultExec;
117
+ const base = deps && typeof deps.exec === 'function' ? deps.exec : settings.defaultExec;
118
+ // D-ROOT-NO-TIMEOUT: git runs with no devflow time limit, as the Bash git call of the
119
+ // prompts did; the Bash tool's own timeout bounds the call. runGit's limit is removed here.
120
+ const exec = (file, gitArgs, opts) => base(file, gitArgs, { ...opts, timeout: undefined });
117
121
  const args = parseArgs(argv);
118
122
  if (args.kind === 'usage') {
119
123
  process.stderr.write(args.usage + '\n');
@@ -68,15 +68,22 @@
68
68
  // nothing is written. The `exit:` part is written only for a number from 0
69
69
  // to 255. A TP that the plan does not list gets no line. The plan is the
70
70
  // `## Test Plan` of the `--plan` file, or of the evidence file when `--plan`
71
- // is absent. The `## Claims` section is the file's last section and is
72
- // created when absent, and so is the file when its folder exists. The
73
- // write is one append of every line, and the parser of pr-evidence.cjs
74
- // accepts each line before it. It prints one line, exit 0:
71
+ // is absent. The `## Claims` section is created when absent, at the end
72
+ // of the file; when it is not the last section the lines go at its end
73
+ // (D-CLAIM-INSERT). A missing folder inside the working directory is
74
+ // created, and so is the file (D-CLAIM-FOLDER). The write is one append of
75
+ // every line, or, for a Claims section that is not last, one whole-file
76
+ // replace through a sibling temporary file that keeps the file's mode. The
77
+ // parser of pr-evidence.cjs accepts each line before it. It prints one
78
+ // line, exit 0:
75
79
  // `CLAIM appended:<n> PASS:<n> FAIL:<n> SKIP:<n> skipped:<n>`
76
80
  // (`skipped` counts the TPs that got no line), or exit 2 with
77
81
  // `CLAIM refused (<reason>)`, the reason being `head not one sha`,
78
82
  // `evidence file tracked` (git tracks it, or cannot say it does not),
79
- // `evidence file unusable` or `plan file unusable`. A refusal writes nothing
83
+ // `evidence file unusable` (a file that does not parse, or a folder that is
84
+ // missing outside the working directory) or `plan file unusable`. A refusal
85
+ // writes nothing. A folder the run made is removed again when the run
86
+ // leaves no file in it
80
87
  // A git-tracked file counts as absent, like a tracked `.devflow/config.json`
81
88
  // (D-TRACKED-RESULT). `verify`, `publish` and `readiness` ask git about an `--evidence`
82
89
  // file before they read it, and about `--block-out`, `--comment-out`, `--stale-out` and
@@ -286,11 +293,12 @@ const USAGE = [
286
293
  * @typedef {{ ghCalls: number, runViews: number, throttled: boolean }} Budget
287
294
  *
288
295
  * @typedef {{ exec: ExecFn, env: NodeJS.ProcessEnv, cwd: string, now: () => number,
289
- * deadline: number, budget: Budget, notes: Set<string>, stderr: (text: string) => void,
290
- * ghCap?: number }} Io
296
+ * deadline: number, budget: Budget, notes: Set<string>, made: string[],
297
+ * stderr: (text: string) => void, ghCap?: number }} Io
291
298
  * The imperative shell's one context: every call goes through it, so every
292
299
  * bound is counted in one place. `notes` holds closed tokens for the stderr
293
- * summary, never input bytes. `ghCap` replaces CAPS.GH_CALLS for a stage that
300
+ * summary, never input bytes. `made` holds the folders a `claim` run created, deepest
301
+ * first (D-CLAIM-FOLDER). `ghCap` replaces CAPS.GH_CALLS for a stage that
294
302
  * carries a budget of its own (`publish`'s writes).
295
303
  *
296
304
  * @typedef {{ login: string, association: string, body: string, viewerDidAuthor: boolean, commentId: string }} PrComment
@@ -625,6 +633,59 @@ function appendTextFile(filePath, text) {
625
633
  }
626
634
  }
627
635
 
636
+ /**
637
+ * Replace the regular file `filePath` with `text` and keep its `mode`: the text goes to a
638
+ * sibling temporary file, in the same folder, which a rename then puts in the file's place.
639
+ * It refuses first what appendTextFile refuses, so the two writes of a claim agree: a file
640
+ * this process may not write, a link put in its place (O_NOFOLLOW), a FIFO (O_NONBLOCK) or
641
+ * any other object that is not a regular file. The probe opens the file for an append and
642
+ * writes nothing. A write that fails leaves the file as it was and the temporary file
643
+ * removed, so a failed replace never truncates it. The temporary file is created new
644
+ * (O_EXCL) and never through a link (O_NOFOLLOW), and takes the old mode on its own
645
+ * descriptor, since the umask would otherwise set the new file's mode. Returns whether the
646
+ * file was replaced.
647
+ *
648
+ * @param {string} filePath
649
+ * @param {string} text
650
+ * @param {number} mode the permission bits of the file it replaces
651
+ * @returns {boolean}
652
+ */
653
+ function replaceTextFile(filePath, text, mode) {
654
+ const c = fs.constants;
655
+ let probe;
656
+ try {
657
+ probe = fs.openSync(filePath, c.O_WRONLY | c.O_APPEND | (c.O_NOFOLLOW || 0) | (c.O_NONBLOCK || 0));
658
+ if (!fs.fstatSync(probe).isFile()) return false;
659
+ } catch (_) {
660
+ return false;
661
+ } finally {
662
+ if (probe !== undefined) {
663
+ try { fs.closeSync(probe); } catch (_) { /* the probe wrote nothing */ }
664
+ }
665
+ }
666
+ const temp = path.join(path.dirname(filePath), '.' + path.basename(filePath) + '.claim-' + process.pid);
667
+ let fd;
668
+ try {
669
+ fd = fs.openSync(temp, c.O_WRONLY | c.O_CREAT | c.O_EXCL | (c.O_NOFOLLOW || 0), 0o600);
670
+ } catch (_) {
671
+ return false;
672
+ }
673
+ try {
674
+ fs.fchmodSync(fd, mode);
675
+ fs.writeFileSync(fd, text, 'utf8');
676
+ fs.closeSync(fd);
677
+ fd = undefined;
678
+ fs.renameSync(temp, filePath);
679
+ return true;
680
+ } catch (_) {
681
+ if (fd !== undefined) {
682
+ try { fs.closeSync(fd); } catch (_) { /* the write already failed */ }
683
+ }
684
+ try { fs.unlinkSync(temp); } catch (_) { /* the temporary file is already gone */ }
685
+ return false;
686
+ }
687
+ }
688
+
628
689
  /**
629
690
  * @param {string} dir
630
691
  * @returns {boolean}
@@ -3098,11 +3159,84 @@ function claimRefusal(reason) {
3098
3159
  return { code: EXIT_CODES.INPUT_UNUSABLE, stdout: 'CLAIM refused (' + reason + ')\n' };
3099
3160
  }
3100
3161
 
3162
+ /** The most folders one path can hold: the bound of the walk up to the nearest folder that exists. */
3163
+ const FOLDER_DEPTH_MAX = 4096;
3164
+
3165
+ /**
3166
+ * Whether `child` lies below `parent`, by name alone. `parent` itself, and a path outside
3167
+ * it, are not below it.
3168
+ *
3169
+ * @param {string} parent absolute
3170
+ * @param {string} child absolute
3171
+ * @returns {boolean}
3172
+ */
3173
+ function isBelow(parent, child) {
3174
+ const rel = path.relative(parent, child);
3175
+ return rel !== '' && rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel);
3176
+ }
3177
+
3178
+ /**
3179
+ * D-CLAIM-FOLDER: the folder of the evidence file exists, or this run makes it. The Write
3180
+ * tool made the folders of a file in 3.11.0, so a first claim into a folder that is not
3181
+ * there yet worked. A run makes a folder only inside the working directory: the folder lies
3182
+ * below `io.cwd` by name, and the nearest folder that already exists resolves to `io.cwd` or
3183
+ * below it, so a link out of the working directory makes nothing. Each folder it makes goes
3184
+ * to `io.made`, deepest first, for removeMadeFolders. A path that holds a file where a folder
3185
+ * must be, or that the run may not make, is unusable. After each folder is made, its real path
3186
+ * must lie below the real working directory; if a link swapped in after the first check led the
3187
+ * folder out, the run is unusable and removeMadeFolders takes the folder away again. This check
3188
+ * narrows the window for such a swap; it does not close it, because the later open of the
3189
+ * evidence file resolves the path again.
3190
+ *
3191
+ * @param {Io} io
3192
+ * @param {string} dir absolute
3193
+ * @returns {boolean} whether the folder exists now
3194
+ */
3195
+ function ensureClaimFolder(io, dir) {
3196
+ if (isRealDirectory(dir)) return true;
3197
+ try {
3198
+ const root = path.resolve(io.cwd);
3199
+ if (fs.lstatSync(dir, { throwIfNoEntry: false }) !== undefined || !isBelow(root, dir)) return false;
3200
+ let near = dir;
3201
+ for (let i = 0; i < FOLDER_DEPTH_MAX && fs.lstatSync(near, { throwIfNoEntry: false }) === undefined; i++) {
3202
+ near = path.dirname(near);
3203
+ }
3204
+ const real = fs.realpathSync(near);
3205
+ const realRoot = fs.realpathSync(root);
3206
+ if (real !== realRoot && !isBelow(realRoot, real)) return false;
3207
+ let at = near;
3208
+ for (const part of path.relative(near, dir).split(path.sep)) {
3209
+ at = path.join(at, part);
3210
+ fs.mkdirSync(at);
3211
+ io.made.unshift(at);
3212
+ if (!isBelow(realRoot, fs.realpathSync(at))) return false;
3213
+ }
3214
+ return true;
3215
+ } catch (_) {
3216
+ return false;
3217
+ }
3218
+ }
3219
+
3220
+ /**
3221
+ * Take away the folders a `claim` run made. Each goes through rmdir, which refuses a folder
3222
+ * that holds anything, so a folder that holds the evidence file, or a file another process
3223
+ * put there, stays. It is never a recursive delete.
3224
+ *
3225
+ * @param {Io} io
3226
+ * @returns {void}
3227
+ */
3228
+ function removeMadeFolders(io) {
3229
+ for (const dir of io.made.splice(0)) {
3230
+ try { fs.rmdirSync(dir); } catch (_) { /* a folder that holds a file stays */ }
3231
+ }
3232
+ }
3233
+
3101
3234
  /**
3102
3235
  * The reason a `claim` run must not start, or null. Nothing is read or written before
3103
- * this: the HEAD is one 40-hex SHA, each path is a path the commands build, the evidence
3104
- * file sits in a folder that exists, and git says clearly that neither file is tracked
3105
- * (D-TRACKED-RESULT).
3236
+ * this, but the folder of the evidence file, when it is missing: the HEAD is one 40-hex
3237
+ * SHA, each path is a path the commands build, the evidence file sits in a folder that
3238
+ * exists or that the run makes inside the working directory (D-CLAIM-FOLDER), and git says
3239
+ * clearly that neither file is tracked (D-TRACKED-RESULT).
3106
3240
  *
3107
3241
  * @param {Io} io
3108
3242
  * @param {Extract<ParsedArgs, { kind: 'claim' }>} args
@@ -3110,7 +3244,7 @@ function claimRefusal(reason) {
3110
3244
  */
3111
3245
  function claimRefusalReason(io, args) {
3112
3246
  if (!SHA40_RE.test(args.head)) return 'head not one sha';
3113
- if (!EVIDENCE_PATH_RE.test(args.evidence) || !isRealDirectory(path.dirname(path.resolve(args.evidence)))) return 'evidence file unusable';
3247
+ if (!EVIDENCE_PATH_RE.test(args.evidence) || !ensureClaimFolder(io, path.dirname(path.resolve(args.evidence)))) return 'evidence file unusable';
3114
3248
  if (args.plan !== null && !EVIDENCE_PATH_RE.test(args.plan)) return 'plan file unusable';
3115
3249
  if (trackedState(io, args.evidence) !== 'untracked') return 'evidence file tracked';
3116
3250
  if (args.plan !== null && trackedState(io, args.plan) !== 'untracked') return 'plan file unusable';
@@ -3144,8 +3278,8 @@ function claimLine(line) {
3144
3278
 
3145
3279
  /**
3146
3280
  * The text to append: the lines, behind the `## Claims` heading when the file has no such
3147
- * section (a blank line first, when the file holds anything). The section is the last of
3148
- * the file, so the lines go at its end.
3281
+ * section (a blank line first, when the file holds anything). The caller appends only when
3282
+ * the section is the last of the file, so the lines go at its end.
3149
3283
  *
3150
3284
  * @param {string} text the evidence file as it is, '' when it is absent
3151
3285
  * @param {boolean} hasSection
@@ -3159,6 +3293,51 @@ function claimAppendText(text, hasSection, lines) {
3159
3293
  return hasSection ? lead + body : lead + '\n' + PE.CLAIMS_HEADING + '\n' + body;
3160
3294
  }
3161
3295
 
3296
+ /**
3297
+ * D-CLAIM-INSERT: the whole text with the lines put at the end of a `## Claims` section that
3298
+ * another section follows: behind the last row of the section that is not blank, before the
3299
+ * blank rows that precede the next heading. A row is blank as the parser of pr-evidence.cjs
3300
+ * reads it, an empty row once its CR is gone. A text with no such section comes back as it was.
3301
+ *
3302
+ * @param {string} text
3303
+ * @param {readonly string[]} lines
3304
+ * @returns {string}
3305
+ */
3306
+ function claimInsertText(text, lines) {
3307
+ const rows = text.split('\n');
3308
+ /** @param {string} row @returns {string} */
3309
+ const bare = row => row.replace(/\r$/, '');
3310
+ const start = rows.findIndex(row => bare(row) === PE.CLAIMS_HEADING);
3311
+ if (start === -1) return text;
3312
+ let end = rows.findIndex((row, i) => i > start && (bare(row) === '##' || bare(row).startsWith('## ')));
3313
+ if (end === -1) end = rows.length;
3314
+ let last = end - 1;
3315
+ while (last > start && bare(rows[last]) === '') last--;
3316
+ rows.splice(last + 1, 0, ...lines);
3317
+ return rows.join('\n');
3318
+ }
3319
+
3320
+ /**
3321
+ * Whether `inserted` is `text` with the lines added at the end of its `## Claims` section and
3322
+ * nothing else changed, as the parser of pr-evidence.cjs reads both: the Claims section grew by
3323
+ * exactly the lines, its other sections are the same, and the result parses as an evidence file.
3324
+ * This is the check the replace is held to before it writes.
3325
+ *
3326
+ * @param {string} text
3327
+ * @param {string} inserted
3328
+ * @param {readonly string[]} lines
3329
+ * @returns {boolean}
3330
+ */
3331
+ function claimInserted(text, inserted, lines) {
3332
+ const before = PE.evidenceSections(text);
3333
+ const after = PE.evidenceSections(inserted);
3334
+ if (!before.ok || !after.ok || before.value.claims === null) return false;
3335
+ return after.value.claims === before.value.claims + '\n' + lines.join('\n')
3336
+ && after.value.testPlan === before.value.testPlan
3337
+ && after.value.exceptions === before.value.exceptions
3338
+ && parseEvidenceText(inserted) !== null;
3339
+ }
3340
+
3162
3341
  /**
3163
3342
  * The TP numbers the plan lists: the `## Test Plan` of the `--plan` file, else of the
3164
3343
  * evidence file. A plan with no TP line, or no plan section, lists none. Null when the
@@ -3181,9 +3360,13 @@ function claimPlanIds(args, evidence) {
3181
3360
 
3182
3361
  /**
3183
3362
  * D-CLAIM-APPEND: `claim`. The checks of claimRefusalReason have passed (admitPaths).
3184
- * The evidence file is read as `verify` reads it, and its `## Claims` section, when it
3185
- * has one, must be its last section. Then every line is built and checked, and one append
3186
- * writes them all. A run that writes no line (every TP off the plan) changes no file.
3363
+ * The evidence file is read as `verify` reads it; a file that does not parse is refused, as
3364
+ * `verify` refuses it. Then every line is built and checked, and one write puts them all in.
3365
+ * That write is an append when the `## Claims` section is the last section or is absent. When
3366
+ * another section follows it, the write is a whole-file replace (D-CLAIM-INSERT) that adds the
3367
+ * lines at the end of the Claims section, held to claimInserted before it writes. One claim
3368
+ * run writes at a time, as the commands run it. A run that writes no line (every TP off the
3369
+ * plan) changes no file.
3187
3370
  *
3188
3371
  * @param {Io} io
3189
3372
  * @param {Extract<ParsedArgs, { kind: 'claim' }>} args
@@ -3204,7 +3387,7 @@ function runClaim(io, args) {
3204
3387
  if (text === null || evidence === null) return claimRefusal('evidence file unusable');
3205
3388
  const headings = text.split('\n').map(row => row.replace(/\r$/, '')).filter(row => row === '##' || row.startsWith('## '));
3206
3389
  const hasSection = headings.includes(PE.CLAIMS_HEADING);
3207
- if (hasSection && headings[headings.length - 1] !== PE.CLAIMS_HEADING) return claimRefusal('evidence file unusable');
3390
+ const sectionFollowed = hasSection && headings[headings.length - 1] !== PE.CLAIMS_HEADING;
3208
3391
  const listed = args.gate ? new Set() : claimPlanIds(args, evidence);
3209
3392
  if (listed === null) return claimRefusal('plan file unusable');
3210
3393
 
@@ -3222,8 +3405,16 @@ function runClaim(io, args) {
3222
3405
  lines.push(claimLine(claimLineOk(withExit) ? withExit : bare));
3223
3406
  tally[tp.outcome]++;
3224
3407
  }
3225
- if (lines.length > 0 && !appendTextFile(file, claimAppendText(text, hasSection, lines))) {
3226
- return refuse(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written');
3408
+ if (lines.length > 0) {
3409
+ let written;
3410
+ if (sectionFollowed) {
3411
+ const inserted = claimInsertText(text, lines);
3412
+ if (!claimInserted(text, inserted, lines)) return claimRefusal('evidence file unusable');
3413
+ written = replaceTextFile(file, inserted, st === undefined ? 0o600 : st.mode & 0o777);
3414
+ } else {
3415
+ written = appendTextFile(file, claimAppendText(text, hasSection, lines));
3416
+ }
3417
+ if (!written) return refuse(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written');
3227
3418
  }
3228
3419
  const skipped = args.gate ? 0 : args.tps.length - lines.length;
3229
3420
  return {
@@ -3294,6 +3485,7 @@ function main(argv, deps) {
3294
3485
  deadline: now() + DEADLINE_MS,
3295
3486
  budget: { ghCalls: 0, runViews: 0, throttled: false },
3296
3487
  notes: new Set(),
3488
+ made: [],
3297
3489
  stderr: typeof d.stderr === 'function' ? d.stderr : (text => { process.stderr.write(text); }),
3298
3490
  };
3299
3491
  const parsed = parseArgs(argv);
@@ -3330,6 +3522,7 @@ function main(argv, deps) {
3330
3522
  if (args.out !== null) outcome = writeReadinessOut(io, args.out, args.pr, outcome, null, planSource(args));
3331
3523
  } else outcome = refuse(io, EXIT_CODES.REMOTE_FAILURE, label);
3332
3524
  }
3525
+ removeMadeFolders(io);
3333
3526
  if (io.notes.size > 0) io.stderr('verify-evidence: notes: ' + [...io.notes].sort().join(',') + '\n');
3334
3527
  return outcome;
3335
3528
  }
@@ -7,7 +7,7 @@ allowed-tools: Read, Grep, Glob
7
7
 
8
8
  # Design Review
9
9
 
10
- Anti-pattern detection for implementation plans. Catches structural problems before they become code.
10
+ Anti-pattern detection for implementation plans.
11
11
 
12
12
  ## Iron Law
13
13
 
@@ -15,8 +15,7 @@ Anti-pattern detection for implementation plans. Catches structural problems bef
15
15
  >
16
16
  > A plan that implies N+1 queries will produce N+1 queries. A plan that omits error
17
17
  > handling will produce code with no error handling. Review the plan as rigorously as
18
- > you review the code — the plan is the blueprint that every implementation decision
19
- > follows.
18
+ > the code: every implementation decision follows it.
20
19
 
21
20
  ## Anti-Pattern Detection Rules
22
21
 
@@ -34,6 +33,8 @@ Anti-pattern detection for implementation plans. Catches structural problems bef
34
33
  Resolution: Replace loop with batch query: {proposed batch operation}.
35
34
  ```
36
35
 
36
+ **Suggest:** collect the IDs first and batch-fetch the related rows in one query, or eager-load them (JOIN) in the list query.
37
+
37
38
  ### 2. God Functions
38
39
 
39
40
  **What to look for in a plan:**
@@ -48,6 +49,8 @@ Resolution: Replace loop with batch query: {proposed batch operation}.
48
49
  Resolution: Split into: {responsibility 1} → {responsibility 2} → {responsibility 3}.
49
50
  ```
50
51
 
52
+ **Suggest:** one purpose per function; move notification, logging and other side effects to their own functions, and compose them at the service layer.
53
+
51
54
  ### 3. Missing Parallelism
52
55
 
53
56
  **What to look for in a plan:**
@@ -62,6 +65,8 @@ Resolution: Split into: {responsibility 1} → {responsibility 2} → {responsib
62
65
  Resolution: Execute {step A} and {step B} in parallel: {proposed parallel pattern}.
63
66
  ```
64
67
 
68
+ **Suggest:** run independent steps in parallel (`Promise.all`, goroutines, `asyncio.gather`), and name the steps that must stay in order.
69
+
65
70
  ### 4. Error Handling Gaps
66
71
 
67
72
  **What to look for in a plan:**
@@ -77,6 +82,8 @@ Resolution: Execute {step A} and {step B} in parallel: {proposed parallel patter
77
82
  Resolution: Add: {on failure scenario} → {specific recovery action}.
78
83
  ```
79
84
 
85
+ **Suggest:** for each external call, the action on timeout, 4xx and 5xx; a compensation step for each step of a multi-step operation; Result types or explicit error returns, never a silent failure.
86
+
80
87
  ### 5. Missing Caching
81
88
 
82
89
  **What to look for in a plan:**
@@ -91,6 +98,8 @@ Resolution: Add: {on failure scenario} → {specific recovery action}.
91
98
  Resolution: Cache {data} for {TTL}: {proposed cache strategy and invalidation}.
92
99
  ```
93
100
 
101
+ **Suggest:** an application cache for cross-request data, a short-TTL in-memory cache for process-local reference data, and the invalidation (on update, by TTL, or both).
102
+
94
103
  ### 6. Poor Decomposition
95
104
 
96
105
  **What to look for in a plan:**
@@ -105,6 +114,8 @@ Resolution: Cache {data} for {TTL}: {proposed cache strategy and invalidation}.
105
114
  Resolution: {concern A} belongs in {layer/module}; {concern B} belongs in {layer/module}.
106
115
  ```
107
116
 
117
+ **Suggest:** one layer per step (presentation, business logic, data access); cross-cutting concerns (logging, auth, validation) in middleware or decorators; one bounded context per module.
118
+
108
119
  ## Extended References
109
120
 
110
121
  | Reference | Content |
@@ -113,9 +124,9 @@ Resolution: {concern A} belongs in {layer/module}; {concern B} belongs in {layer
113
124
 
114
125
  ## Severity Guidelines
115
126
 
116
- | Level | Criteria |
127
+ | Level | An anti-pattern that |
117
128
  |-------|----------|
118
- | **CRITICAL** | Anti-pattern that will cause correctness failures (data loss, security breach) |
119
- | **HIGH** | Anti-pattern that will cause significant performance or maintainability problems |
120
- | **MEDIUM** | Anti-pattern that will require architectural rework post-implementation |
121
- | **LOW** | Anti-pattern that reduces code quality but is easily refactored |
129
+ | **CRITICAL** | will cause correctness failures (data loss, security breach) |
130
+ | **HIGH** | will cause significant performance or maintainability problems |
131
+ | **MEDIUM** | will require architectural rework post-implementation |
132
+ | **LOW** | reduces code quality but is easily refactored |