@pareto4/pp-core 1.13.0 → 1.18.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.
Files changed (204) hide show
  1. package/.claude-plugin/plugin.json +3 -3
  2. package/README.md +31 -12
  3. package/agents/pp-code-fixer.md +0 -11
  4. package/agents/pp-code-reviewer.md +1 -77
  5. package/agents/pp-debug-session-manager.md +9 -6
  6. package/agents/pp-executor.md +12 -29
  7. package/agents/pp-plan-checker.md +4 -4
  8. package/agents/pp-planner.md +34 -20
  9. package/agents/pp-vault-reviewer.md +100 -23
  10. package/agents/pp-verifier.md +1 -3
  11. package/bin/install.js +10 -3
  12. package/commands/pp-execute-phase.md +1 -3
  13. package/commands/pp-ns-manage.md +1 -1
  14. package/commands/pp-ns-review.md +1 -1
  15. package/commands/pp-plan-phase.md +1 -2
  16. package/commands/pp-vault-promote.md +7 -8
  17. package/commands/pp-vault-review.md +6 -5
  18. package/commands/pp-verify-work.md +1 -2
  19. package/gemini-extension.json +1 -1
  20. package/hooks/dist/pp-check-update.js +0 -0
  21. package/hooks/dist/pp-cursor-session-start.js +1 -1
  22. package/hooks/dist/pp-statusline.js +1 -1
  23. package/hooks/dist/pp-update-banner.js +0 -0
  24. package/hooks/pp-check-update.js +0 -0
  25. package/hooks/pp-cursor-session-start.js +1 -1
  26. package/hooks/pp-statusline.js +1 -1
  27. package/hooks/pp-update-banner.js +0 -0
  28. package/package.json +10 -18
  29. package/pareto-skills/_shared/create-project-board.sh +0 -0
  30. package/pareto-skills/pp-project-setup/SKILL.md +1 -1
  31. package/pp-core/bin/check-latest-version.cjs +0 -0
  32. package/pp-core/bin/detect-build-commands.sh +2 -2
  33. package/pp-core/bin/lib/capability-activation.cjs +83 -9
  34. package/pp-core/bin/lib/capability-registry.cjs +201 -229
  35. package/pp-core/bin/lib/capability-state.cjs +93 -1
  36. package/pp-core/bin/lib/capability-writer.cjs +3 -3
  37. package/pp-core/bin/lib/check-command-router.cjs +1906 -1514
  38. package/pp-core/bin/lib/clusters.cjs +3 -0
  39. package/pp-core/bin/lib/command-aliases.cjs +0 -35
  40. package/pp-core/bin/lib/commands.cjs +9 -4
  41. package/pp-core/bin/lib/compose-bindings.cjs +251 -88
  42. package/pp-core/bin/lib/config-loader.cjs +7 -103
  43. package/pp-core/bin/lib/config-schema.cjs +1 -1
  44. package/pp-core/bin/lib/config.cjs +151 -62
  45. package/pp-core/bin/lib/decisions.cjs +4 -4
  46. package/pp-core/bin/lib/discovery-vault-check.cjs +246 -0
  47. package/pp-core/bin/lib/frontmatter.cjs +1 -38
  48. package/pp-core/bin/lib/git-base-branch.cjs +11 -12
  49. package/pp-core/bin/lib/git-provider.cjs +9 -10
  50. package/pp-core/bin/lib/init-command-router.cjs +5 -8
  51. package/pp-core/bin/lib/init-contract.cjs +620 -738
  52. package/pp-core/bin/lib/init-interactive.cjs +62 -4
  53. package/pp-core/bin/lib/init.cjs +24 -51
  54. package/pp-core/bin/lib/install-profiles.cjs +1 -1
  55. package/pp-core/bin/lib/io.cjs +4 -2
  56. package/pp-core/bin/lib/loop-resolver.cjs +165 -129
  57. package/pp-core/bin/lib/org-profile.cjs +402 -161
  58. package/pp-core/bin/lib/package-identity.cjs +3 -3
  59. package/pp-core/bin/lib/phase-command-router.cjs +1 -29
  60. package/pp-core/bin/lib/phase-scope.cjs +1 -1
  61. package/pp-core/bin/lib/phase.cjs +11 -253
  62. package/pp-core/bin/lib/platform-bindings.cjs +40 -19
  63. package/pp-core/bin/lib/profile-output.cjs +1 -1
  64. package/pp-core/bin/lib/shell-command-projection.cjs +104 -1
  65. package/pp-core/bin/lib/state.cjs +11 -85
  66. package/pp-core/bin/lib/template.cjs +1 -1
  67. package/pp-core/bin/lib/vault-command-router.cjs +1682 -477
  68. package/pp-core/bin/lib/vault-coordinates.cjs +16 -7
  69. package/pp-core/bin/lib/vault-index.cjs +656 -0
  70. package/pp-core/bin/lib/vault-seal.cjs +1851 -0
  71. package/pp-core/bin/lib/vault-standards-promote.cjs +577 -0
  72. package/pp-core/bin/lib/vault-standards-review.cjs +293 -0
  73. package/pp-core/bin/lib/vault-standards.cjs +1504 -0
  74. package/pp-core/bin/lib/vault-trace.cjs +155 -2778
  75. package/pp-core/bin/lib/vault-update.cjs +10 -59
  76. package/pp-core/bin/lib/vault.cjs +2570 -950
  77. package/pp-core/bin/lib/verify.cjs +0 -11
  78. package/pp-core/bin/portable-timeout.sh +1 -1
  79. package/pp-core/bin/pp-tools.cjs +157 -54
  80. package/pp-core/bin/shared/config-defaults.manifest.json +0 -7
  81. package/pp-core/bin/shared/config-schema.manifest.json +0 -3
  82. package/pp-core/bin/shared/model-catalog.json +2 -2
  83. package/pp-core/bin/verify-reapply-patches.cjs +0 -0
  84. package/pp-core/org-profile.json +26 -0
  85. package/pp-core/references/autonomous-best-effort.md +8 -7
  86. package/pp-core/references/loop-hook-dispatch.md +0 -25
  87. package/pp-core/references/planner-gap-closure.md +0 -1
  88. package/pp-core/references/planner-tdd.md +0 -1
  89. package/pp-core/references/planner-vault-standards.md +311 -0
  90. package/pp-core/references/planning-config.md +0 -8
  91. package/pp-core/references/tdd.md +0 -1
  92. package/pp-core/templates/README.md +1 -0
  93. package/pp-core/templates/VAULT-REVIEW.md +56 -0
  94. package/pp-core/templates/phase-prompt.md +2 -6
  95. package/pp-core/workflows/_runtime-launcher.snippet.sh +0 -0
  96. package/pp-core/workflows/add-tests.md +55 -4
  97. package/pp-core/workflows/audit-fix.md +63 -2
  98. package/pp-core/workflows/audit-milestone.md +1 -1
  99. package/pp-core/workflows/autonomous.md +85 -11
  100. package/pp-core/workflows/check-todos.md +13 -2
  101. package/pp-core/workflows/code-review-fix.md +59 -3
  102. package/pp-core/workflows/code-review.md +10 -57
  103. package/pp-core/workflows/complete-milestone.md +1 -1
  104. package/pp-core/workflows/debug.md +54 -4
  105. package/pp-core/workflows/discovery-sprint/prefill.md +19 -7
  106. package/pp-core/workflows/discovery-sprint/prototype.md +46 -49
  107. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  108. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +1 -3
  109. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +1 -3
  110. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +1 -3
  111. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +20 -24
  112. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +1 -3
  113. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +1 -3
  114. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +1 -3
  115. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +1 -3
  116. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +1 -3
  117. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +1 -6
  118. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +1 -3
  119. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +1 -3
  120. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +1 -3
  121. package/pp-core/workflows/discovery-sprint/templates/context-template.md +1 -1
  122. package/pp-core/workflows/discovery-sprint/vault-context.md +207 -0
  123. package/pp-core/workflows/discovery-sprint.md +7 -3
  124. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +4 -4
  125. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +43 -1
  126. package/pp-core/workflows/discovery-to-build.md +10 -2
  127. package/pp-core/workflows/discuss-phase.md +3 -10
  128. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +327 -0
  129. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +3 -2
  130. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +1 -1
  131. package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +78 -0
  132. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +94 -0
  133. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +6 -8
  134. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +115 -14
  135. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +219 -20
  136. package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +147 -0
  137. package/pp-core/workflows/execute-phase.md +152 -128
  138. package/pp-core/workflows/execute-plan.md +54 -6
  139. package/pp-core/workflows/fast.md +53 -4
  140. package/pp-core/workflows/help/modes/full.md +3 -3
  141. package/pp-core/workflows/init/steps/client-context-capture.md +101 -0
  142. package/pp-core/workflows/init/steps/gitlab-provisioning.md +130 -0
  143. package/pp-core/workflows/init/steps/project-md-deferral-narration.md +66 -0
  144. package/pp-core/workflows/init/steps/reachability-outcome.md +70 -0
  145. package/pp-core/workflows/init.md +232 -272
  146. package/pp-core/workflows/new-project.md +31 -23
  147. package/pp-core/workflows/plan-phase.md +98 -45
  148. package/pp-core/workflows/quick.md +117 -3
  149. package/pp-core/workflows/secure-phase.md +1 -1
  150. package/pp-core/workflows/ship.md +57 -28
  151. package/pp-core/workflows/undo.md +1 -1
  152. package/pp-core/workflows/update.md +3 -3
  153. package/pp-core/workflows/validate-phase.md +58 -3
  154. package/pp-core/workflows/vault-review.md +161 -23
  155. package/pp-core/workflows/vault-write-back.md +73 -110
  156. package/pp-core/workflows/verify-work.md +12 -14
  157. package/scripts/changeset/cli.cjs +6 -5
  158. package/scripts/changeset/lint.cjs +0 -0
  159. package/scripts/changeset/new.cjs +33 -50
  160. package/scripts/dev-env.cjs +25 -85
  161. package/scripts/gen-capability-registry.cjs +120 -13
  162. package/scripts/lib/allowlist-ratchet.cjs +1 -103
  163. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
  164. package/scripts/lint-docs-required.cjs +0 -0
  165. package/scripts/lint-pr-check-project-dir.cjs +1 -3
  166. package/scripts/lint-test-file-count.allowlist.json +3 -19
  167. package/scripts/lint-test-file-count.cjs +5 -6
  168. package/scripts/prompt-injection-scan.sh +19 -17
  169. package/scripts/release-channel.cjs +0 -0
  170. package/scripts/sync-manifest-versions.cjs +5 -4
  171. package/scripts/sync-rulesets.sh +1 -1
  172. package/scripts/update-size-baseline.cjs +30 -274
  173. package/scripts/verify-opengsd-sweep.cjs +3 -5
  174. package/scripts/workflow-size.cjs +9 -61
  175. package/pp-core/bin/lib/dispatch-plan.cjs +0 -483
  176. package/pp-core/bin/lib/install-run.cjs +0 -536
  177. package/pp-core/bin/lib/loop-gates.cjs +0 -213
  178. package/pp-core/bin/lib/phase-manifest.cjs +0 -527
  179. package/pp-core/bin/lib/phase-tier.cjs +0 -480
  180. package/pp-core/bin/lib/plan-graph.cjs +0 -368
  181. package/pp-core/bin/lib/practices-review-check.cjs +0 -938
  182. package/pp-core/bin/lib/vault-audit.cjs +0 -2070
  183. package/pp-core/bin/lib/vault-practices.cjs +0 -1990
  184. package/pp-core/bin/lib/vault-review.cjs +0 -2359
  185. package/pp-core/bin/lib/vault-write-back.cjs +0 -877
  186. package/pp-core/references/executor-vault-trace.md +0 -47
  187. package/pp-core/references/planner-plan-graph.md +0 -66
  188. package/pp-core/references/planner-vault-practices.md +0 -112
  189. package/pp-core/templates/practices/README.md +0 -251
  190. package/pp-core/templates/practices/error-handling.md +0 -18
  191. package/pp-core/templates/practices/minimal-comments.md +0 -14
  192. package/pp-core/templates/practices/naming-convention.md +0 -18
  193. package/pp-core/templates/practices/practice-template.md +0 -23
  194. package/pp-core/workflows/discovery-sprint/vault-fetch.md +0 -37
  195. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +0 -45
  196. package/pp-core/workflows/execute-phase/steps/install-run.md +0 -29
  197. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +0 -23
  198. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +0 -73
  199. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +0 -19
  200. package/pp-core/workflows/plan-phase/steps/phase-tier.md +0 -43
  201. package/scripts/lib/size-budget.cjs +0 -311
  202. package/scripts/lint-neutrality-names.cjs +0 -694
  203. package/scripts/lint-org-coordinates.allowlist.json +0 -35
  204. package/scripts/lint-org-coordinates.cjs +0 -426
@@ -17,7 +17,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
17
17
  *
18
18
  * Arg indexing:
19
19
  * args[0] = 'vault' (family — matched by dispatchCapabilityCommand)
20
- * args[1] = subcommand (fetch | status | summary)
20
+ * args[1] = subcommand (see VAULT_SUBCOMMANDS below)
21
21
  *
22
22
  * Test seams: pass `_vault` to inject a mock vault module; pass `_core` to inject
23
23
  * a mock core module that captures `output()` calls instead of writing to real
@@ -34,76 +34,140 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
34
34
  const io = require("./io.cjs");
35
35
  const node_fs_1 = __importDefault(require("node:fs"));
36
36
  const node_path_1 = __importDefault(require("node:path"));
37
+ // 16-19 (D-107): `vault anchor certify` runs the strict trace check as a child
38
+ // process, the one spawn in this router (TRACE-04 keeps the check read-only).
39
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
40
+ const childProcess = require("node:child_process");
37
41
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
38
- // The trace render arm resolves its containment boundary from the project
39
- // root. project-root.cjs is a leaf (node builtins only), so this adds no cycle.
42
+ // CR-04 (03-11): this router's path arms resolve their containment boundary the
43
+ // same way `cmdVaultTrace` in src/check-command-router.cts does. project-root.cjs is a
44
+ // leaf (node builtins only), so this adds no cycle.
40
45
  // eslint-disable-next-line @typescript-eslint/no-require-imports
41
46
  const projectRootMod = require("./project-root.cjs");
42
47
  const { findProjectRoot } = projectRootMod;
48
+ // 15-05: the plan-identifier form moved into the vault-standards leaf (one
49
+ // definition shared by the standards verbs and the gate).
50
+ // That leaf requires only vault-index, which imports nothing, so no cycle.
51
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
52
+ const vaultStandardsLeaf = require("./vault-standards.cjs");
53
+ // 16-06 (D-14/D-31/D-35): the anchor grammar, the anchors file and the door
54
+ // record have ONE owner, the seal leaf. Its top level imports only `fs` and
55
+ // `path` (D-41), so this static import adds no cycle.
56
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
57
+ const vaultSeal = require("./vault-seal.cjs");
43
58
  const { ERROR_REASON } = io;
44
- // Default CoreModule implementation. _core seam overrides this for test injection.
45
- const _defaultCore = { output: io.output };
46
59
  /**
47
- * Pack 6 D-01: the vault router's subcommand table. `readsVerdict` marks every
48
- * subcommand that reports the vault load verdict — each one must spread the
49
- * owner's `VAULT_VERDICT_FIELDS` verbatim (tests/vault-status-check.test.cjs
50
- * enumerates this table and compares every reader against the owner).
60
+ * The subcommand vocabulary, declared once. 02-02 added `probe` (STRICT-02) and
61
+ * found the name had to be repeated in three unrelated places — the header
62
+ * comment, the dispatch chain and the unknown-subcommand message — with nothing
63
+ * keeping them in step. The message is now DERIVED from this list so the
64
+ * dispatch arm and the operator-facing vocabulary can no longer disagree.
65
+ */
66
+ const VAULT_SUBCOMMANDS = [
67
+ 'fetch',
68
+ 'status',
69
+ 'probe',
70
+ 'summary',
71
+ 'org-profile',
72
+ // PBI 74316 (fork-only): move the project to another client vault version.
73
+ 'update',
74
+ // 15-07 (D-06, D-13): the standards record verbs, `block` and `consulted`.
75
+ 'standards',
76
+ // 16-04 (D-28): the one door-admission verb every door and loop-point fence asks.
77
+ 'admission',
78
+ // 16-06 (D-31, D-32): the start anchor a phase or quick directory records at
79
+ // execute:pre, and the attributed back-fill of a record written before anchors.
80
+ 'anchor',
81
+ // 16-06 (D-12): the one verb that writes a door's mode-stamped record.
82
+ 'door-record',
83
+ ];
84
+ /**
85
+ * The standards verbs (15-07), same discipline: the verb check and the message
86
+ * naming the permitted verbs both derive from this list. `promote` writes to
87
+ * the client vault rather than a record, and answers its own refusals.
51
88
  */
52
- const VAULT_SUBCOMMANDS = Object.freeze([
53
- { name: 'fetch', readsVerdict: false },
54
- { name: 'status', readsVerdict: true },
55
- { name: 'summary', readsVerdict: false },
56
- { name: 'practices', readsVerdict: false },
57
- { name: 'org-profile', readsVerdict: false },
58
- { name: 'probe', readsVerdict: false },
59
- { name: 'trace', readsVerdict: false },
60
- { name: 'audit', readsVerdict: false },
61
- { name: 'update', readsVerdict: false },
62
- { name: 'review', readsVerdict: false },
63
- ]);
89
+ const STANDARDS_VERBS = ['block', 'consulted', 'promote'];
64
90
  /**
65
- * The two trace stages and the two trace verbs, declared once: the `--stage`
66
- * validation, the verb check and the operator-facing messages that name the
67
- * permitted values are all derived from these lists, so they cannot disagree.
68
- * The values are the frozen contract's (`docs/vault-trace-format.md`).
91
+ * The anchor verbs (16-06), same discipline: the verb check and the message
92
+ * naming the permitted verbs both derive from this list.
69
93
  */
70
- const TRACE_STAGES = ['plan', 'execute'];
71
- const TRACE_VERBS = ['scaffold', 'render'];
94
+ const ANCHOR_VERBS = ['start', 'backfill', 'certify'];
95
+ /** The flags `vault door-record` takes, declared once for the check and the message (16-06). */
96
+ // 16 review iteration 3 CR-02 (D-103): no `--start`. The start comes only from
97
+ // the door's own `--begin` entry, so no argument can widen a door's range.
98
+ const DOOR_RECORD_FLAGS = ['--door', '--artifact', '--phase'];
72
99
  /**
73
- * The plan-identifier shape `vault trace scaffold --plan` accepts: a phase
74
- * segment (digits, an optional letter, optional decimal parts) followed by one
75
- * or more numeric segments — the plan-identifier grammar `agents/pp-planner.md`
76
- * and `src/phase-id.cts` use. It is exactly the prefix of the artifact's own
77
- * filename (`04-02` for `04-02-PLAN.md`, `02.3-01` for `02.3-01-PLAN.md`), so a
78
- * trace gate comparing the recorded identifier with the filename can never be
79
- * handed one the writer could not emit. It rejects what is not a plan
80
- * identifier at all: a bare plan number (`01`), a task label (`T1`), a slug
81
- * (`04-01-vault-trace`) or an empty string.
100
+ * The read cap `vault standards consulted` applies to a PLAN. EQUAL to the
101
+ * trace gate's `TRACE_ARTIFACT_BYTE_CAP` in src/check-command-router.cts, so
102
+ * the verb never refuses a plan the gate would read, nor reads one the gate
103
+ * refuses. That constant is not importable from here (an adapter never
104
+ * imports an adapter), so the integration test pins the two equal.
82
105
  */
83
- const SCAFFOLD_PLAN_ID_RE = /^\d+[A-Za-z]?(?:\.\d+)*(?:-\d+)+$/;
106
+ const STANDARDS_PLAN_READ_BYTES = 1024 * 1024;
84
107
  /**
85
- * The required form, in ONE wording, stated in this rejection message and in
86
- * `pp-core/references/planner-vault-practices.md`. ASCII only and no
87
- * backticks, so the same bytes survive a JSON string, markdown and a terminal.
108
+ * The plan-identifier shape `vault standards block --plan` accepts (04-14,
109
+ * TGATE-01, CR-03). Defined in `src/vault-standards.cts` since 15-05 and
110
+ * re-exported from here under the same name; the rationale stays here, beside
111
+ * the verb that uses it.
112
+ *
113
+ * ONE FORM, READ AND WRITTEN. `filenamePlanId` in `src/check-command-router.cts`
114
+ * extracts `<this form>-PLAN.md` / `-SUMMARY.md` from an artifact's basename,
115
+ * building its pattern from this very value, and blocks when the identifier it
116
+ * extracts disagrees with the one the record carries. So the writer can emit
117
+ * every identifier the reader can demand (the trap CR-03 reproduced, where
118
+ * `--plan 01` for `07-01-PLAN.md` blocked at all four points and the remedy
119
+ * re-ran the same command), and the reader checks every form the writer emits.
120
+ * Until 15-REVIEW WR-06 the reader stopped at digits-hyphen-digits, so a
121
+ * decimal (`02.3-01-PLAN.md`, bug #3691) or lettered phase stated no
122
+ * identifier and its plan-id check was silently skipped; the writer was
123
+ * deliberately wider then, so as not to refuse a form the gate accepted
124
+ * (threat T-4-53). The shape is the plan-identifier grammar
125
+ * `agents/pp-planner.md` and `src/phase-id.cts` use: a phase segment (digits,
126
+ * an optional letter, optional decimal parts) followed by one or more numeric
127
+ * segments.
128
+ *
129
+ * What it rejects is what CR-03 actually named, and nothing more: a value that
130
+ * is not a plan identifier at all — a bare plan number (`01`), a task label
131
+ * (`T1`), a slugged identifier (`04-01-vault-trace`), or an empty string. Every
132
+ * one of those, written into a canonically named artifact, produces
133
+ * `plan_id_mismatch`.
134
+ *
135
+ * The agreement test in `tests/vault-command-router.test.cjs` and the gate-side
136
+ * half in `tests/vault-trace.integration.test.cjs` drive both sides over one
137
+ * shared value list, so an independent edit to either cannot pass.
138
+ */
139
+ const SCAFFOLD_PLAN_ID_RE = vaultStandardsLeaf.SCAFFOLD_PLAN_ID_RE;
140
+ /**
141
+ * The required form, in ONE wording: the `vault standards block` rejection
142
+ * message (04-14 T2). A constant so the refusal and the test that pins it read
143
+ * one sentence (threat T-4-55).
144
+ *
145
+ * ASCII only, and no backticks: the same bytes have to survive a JSON string, a
146
+ * markdown paragraph and a terminal.
88
147
  */
89
148
  const SCAFFOLD_PLAN_ID_FORM = "The plan identifier must be the plan's phase-and-number identifier, exactly the " +
90
149
  'prefix of its artifact filename (for example 04-02), not a bare plan number, a task ' +
91
150
  'label or a slug.';
92
151
  /**
93
- * Bound on the head read the `trace render` arm performs. The trace lives in
94
- * the artifact's FRONTMATTER, so a head read is the exact region the parser
95
- * needs, and an artifact body of any size can never make this arm read more.
152
+ * The quick-task half of the form (16-05, D-29), appended to both rejection
153
+ * messages after `SCAFFOLD_PLAN_ID_FORM`.
154
+ *
155
+ * A second constant rather than an edit to the first: the first sentence is
156
+ * already true of a quick task — "exactly the prefix of its artifact filename"
157
+ * — so only the refusal, the one surface a caller reads after being told no,
158
+ * needs to name the quick form.
96
159
  */
97
- const TRACE_ARTIFACT_READ_BYTES = 262_144;
160
+ const SCAFFOLD_QUICK_ID_FORM = 'A quick task uses its quick-task id instead, the same filename prefix (for example 261011-8yt).';
98
161
  /** Repo-relative artifact paths, long enough for a workstream phase tree. */
99
162
  const TRACE_REFUSAL_PATH_CAP = 200;
100
- /** A `gitCommit` digest: lowercase hex at sha1 or sha256 length. */
101
- const GIT_COMMIT_DIGEST_RE = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/;
102
163
  /**
103
- * Bound and control-character-collapse a caller-supplied path before it
104
- * reaches a refusal message: the message reaches an operator's terminal and,
105
- * on the workflow path, an agent's context, so a hostile path must not forge
106
- * an extra line of output nor run to arbitrary length.
164
+ * Bound and control-character-collapse a caller-supplied path before it reaches
165
+ * a refusal message (T-03-53). The message reaches an operator's terminal and,
166
+ * on the workflow path, an agent's context — so a hostile path must not be able
167
+ * to forge what looks like an additional line of output, nor run to arbitrary
168
+ * length. Mirrors `sanitizeTraceText` in src/check-command-router.cts; that one
169
+ * also redacts remotes because it quotes ARTIFACT text, while this one only ever
170
+ * quotes the argv value the operator just typed.
107
171
  */
108
172
  function boundedPath(value) {
109
173
  const collapsed = value
@@ -117,25 +181,20 @@ function boundedPath(value) {
117
181
  : collapsed;
118
182
  }
119
183
  /**
120
- * Is the vault-practices capability fully active for this project? Both
121
- * `vault.enabled` and `vault.practices_enabled` must be strictly `true` — the
122
- * same requires-closure the promote preflight and `ship.practices` apply. The
123
- * trace arms gate on this FIRST, before the flag allowlist, so an inert project
124
- * stays byte-identically silent on every path, a mistyped one included.
184
+ * The gate an override recorded by `vault standards block` suppresses (04-06, SC4).
185
+ *
186
+ * `vault.status` is not a name invented here: it is the `check.query` the
187
+ * blocking vault gate is declared under at both write points
188
+ * (`capabilities/vault/capability.json`), and it is the gate the override
189
+ * line names (`docs/vault-standards-mode.md` § Gates, "The override").
190
+ * Declared once so the emitted record and the documented contract cannot
191
+ * drift apart. Its one owner is the standards leaf
192
+ * (15 D-35), because the standards judgement accepts an override line only
193
+ * under this gate name, and a second literal could drift from the first.
125
194
  */
126
- function tracePracticesActive(cfgGet, cwd) {
127
- return cfgGet(cwd, 'vault.enabled', false) === true && cfgGet(cwd, 'vault.practices_enabled', false) === true;
128
- }
129
- /** The value token after each of `flags`, by index, so a flag's value is never read as a positional. */
130
- function flagValueIndices(argv, flags) {
131
- const indices = new Set();
132
- for (const flag of flags) {
133
- const i = argv.indexOf(flag);
134
- if (i !== -1)
135
- indices.add(i + 1);
136
- }
137
- return indices;
138
- }
195
+ const TRACE_OVERRIDE_GATE = vaultStandardsLeaf.STANDARDS_OVERRIDE_GATE;
196
+ // Default CoreModule implementation. _core seam overrides this for test injection.
197
+ const _defaultCore = { output: io.output };
139
198
  /**
140
199
  * Render a short plain-text `vault org-profile` report from its structured
141
200
  * shape — one line naming the pointer, one naming the resolution, a
@@ -148,15 +207,26 @@ function flagValueIndices(argv, flags) {
148
207
  function renderOrgProfileReport(report) {
149
208
  const lines = [];
150
209
  if (report.pointer.value === null) {
151
- lines.push('No org profile is configured (org.profile is unset) — pp-core\'s built-in defaults apply.');
210
+ lines.push(report.profile.source === 'embedded'
211
+ ? 'No org profile package is configured (org.profile is unset) — the embedded org profile applies.'
212
+ : 'No org profile is configured (org.profile is unset) — pp-core\'s built-in defaults apply.');
213
+ }
214
+ else if (report.pointer.value.trim().toLowerCase() === 'none') {
215
+ lines.push('org.profile is "none" — no org profile applies, not even the embedded one; pp-core\'s built-in defaults apply.');
152
216
  }
153
217
  else {
154
218
  lines.push(`org.profile is set to "${report.pointer.value}".`);
155
219
  }
156
- if (report.profile.resolved) {
220
+ if (report.profile.resolved && report.profile.source === 'embedded' && report.pointer.value !== null) {
221
+ lines.push(`The org profile "${report.pointer.value}" could not be resolved — the embedded org profile (pp-core ${report.profile.version ?? 'unknown'}) applies instead.`);
222
+ }
223
+ else if (report.profile.resolved && report.profile.source === 'embedded') {
224
+ lines.push(`Using the embedded org profile shipped with pp-core ${report.profile.version ?? 'unknown'}.`);
225
+ }
226
+ else if (report.profile.resolved) {
157
227
  lines.push(`Resolved org profile "${report.profile.name}" version ${report.profile.version ?? 'unknown'} from ${report.profile.source}.`);
158
228
  }
159
- else if (report.pointer.value !== null) {
229
+ else if (report.pointer.value !== null && report.pointer.value.trim().toLowerCase() !== 'none') {
160
230
  lines.push(`The org profile "${report.pointer.value}" could not be resolved from the project or the global npm root.`);
161
231
  }
162
232
  const contributesKeys = Object.keys(report.contributes);
@@ -166,7 +236,8 @@ function renderOrgProfileReport(report) {
166
236
  else {
167
237
  lines.push('The org profile contributes:');
168
238
  for (const key of contributesKeys) {
169
- lines.push(` ${key} = ${report.contributes[key]}`);
239
+ const value = report.contributes[key];
240
+ lines.push(` ${key} = ${value === '' ? '"" (explicitly none)' : value}`);
170
241
  }
171
242
  }
172
243
  if (report.overridden.length > 0) {
@@ -187,146 +258,74 @@ function renderOrgProfileReport(report) {
187
258
  return lines.join('\n');
188
259
  }
189
260
  // ─── Implementation ───────────────────────────────────────────────────────────
190
- function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _exec, _writeBack, _orgProfile, _coordinates, _provider, _trace, _audit, _update, }) {
261
+ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _exec, _orgProfile, _coordinates, _provider, _update, }) {
191
262
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
192
263
  const vault = _vault ?? require('./vault.cjs');
193
264
  const c = _core ?? _defaultCore;
194
265
  const subcommand = args[1];
266
+ // From the REAL leaf, not the `_vault` seam: a governance answer must not be
267
+ // switchable by a test mock.
268
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
269
+ const indexSeam = require('./vault.cjs');
195
270
  if (subcommand === 'fetch') {
196
271
  c.output(vault.vaultFetch(cwd), raw);
197
272
  }
198
273
  else if (subcommand === 'status') {
199
- // Pack 6 D-01: the manifest read is unchanged; the verdict fields come
200
- // from the one owner, never from a second derivation here.
201
- const status = vault.vaultStatus(cwd);
202
- const verdict = vault.computeVaultVerdict && vault.verdictFields ? vault.verdictFields(vault.computeVaultVerdict(cwd)) : {};
203
- c.output({ ...status, ...verdict }, raw);
274
+ // 14-05 (D-11/D-14): the same five flat fields `check vault.status` carries,
275
+ // from the one state function, so the report and the gate cannot tell
276
+ // different stories (UAT F6). From the REAL module (`indexSeam`), not the
277
+ // injectable `vault` seam: a governance answer must not be switchable by a
278
+ // test mock (13-06 precedent). No `block` and no `reason`: this is a report.
279
+ // The state is null with the vault off, so the envelope gains no key.
280
+ const base = vault.vaultStatus(cwd);
281
+ const indexState = indexSeam.computeVaultIndexState(cwd);
282
+ const withIndex = indexState !== null && base['present'] === true
283
+ ? { ...base, ...indexSeam.vaultIndexEnvelopeFields(indexState) }
284
+ : base;
285
+ c.output(withIndex, raw);
204
286
  }
205
287
  else if (subcommand === 'probe') {
206
- // Pack 6 D-09: one reachability line, empty when the vault is disabled (D-06).
207
- const result = vault.vaultProbe ? vault.vaultProbe(cwd) : { line: '', enabled: false };
208
- if (!result.enabled) {
209
- c.output(result, true, '');
210
- }
211
- else if (raw) {
212
- c.output(result, true, JSON.stringify(result));
213
- }
214
- else {
215
- c.output(result, true, `${result.line}\n`);
288
+ // STRICT-02: pure reachability for the client vault, the shared fallback and
289
+ // the platform repo — no clone, no cache mutation.
290
+ //
291
+ // T-02-07: reject any unrecognized `--` token BEFORE anything spawns. A typo must never reach a
292
+ // subprocess. The host (pp-core/bin/pp-tools.cjs) already splices out its own
293
+ // global flags before dispatch, which is why the recognized set is one wide.
294
+ const RECOGNIZED_PROBE_FLAGS = new Set(['--raw']);
295
+ const unrecognizedProbeFlag = args.slice(2).find((a) => a.startsWith('--') && !RECOGNIZED_PROBE_FLAGS.has(a));
296
+ if (unrecognizedProbeFlag !== undefined) {
297
+ error(`Unrecognized flag "${unrecognizedProbeFlag}" for vault probe. Supported: --raw.`, ERROR_REASON.SDK_FAIL_FAST);
298
+ return;
216
299
  }
300
+ // The probe's ls-remote names its remote by URL, but git still reads the config of the repository the
301
+ // environment selects (measured: `protocol.file.allow=never` in a pointed-to repository's config made the
302
+ // bare runner's ls-remote fail with `transport 'file' not allowed`, while the same call with the location
303
+ // unset answered), so it runs through the owner; transport is kept (D-117).
304
+ c.output(vault.vaultProbe(cwd, { exec: _exec ?? shell_command_projection_cjs_1.execGitLocal }), raw);
217
305
  }
218
306
  else if (subcommand === 'summary') {
219
- c.output(vault.vaultSummary(cwd), raw);
220
- }
221
- else if (subcommand === 'practices') {
222
- const verb = args[2];
223
- if (verb === 'match') {
224
- // T-24-08b: reject any unrecognized `--`-prefixed argument BEFORE the
225
- // `--changed` git call (so a typo never spawns a subprocess) instead
226
- // of silently filtering it out of the positional file list. Matched on
227
- // the WHOLE token so `--files=…` is caught by the same check as
228
- // `--files value`. DECISION (recorded, not deferred): `--files=` is
229
- // NOT implemented as a comma-splitting alias here, unlike
230
- // `/pp-code-review`'s `--files=a,b` (pp-core/workflows/code-review.md).
231
- // The auditor marked the alias "optional and secondary"; the required
232
- // behavior is loud failure, and the error message below names the
233
- // supported forms instead. The host (pp-core/bin/pp-tools.cjs) already
234
- // splices out its own global flags (--json-errors/--cwd/--ws/--raw/
235
- // --pick/--default) before dispatch, which is why the recognized set
236
- // here is only two flags wide.
237
- const RECOGNIZED_MATCH_FLAGS = new Set(['--changed', '--raw', '--block']);
238
- const unrecognizedFlag = args.slice(3).find((a) => a.startsWith('--') && !RECOGNIZED_MATCH_FLAGS.has(a));
239
- if (unrecognizedFlag !== undefined) {
240
- error(`Unrecognized flag "${unrecognizedFlag}" for vault practices match. Supported: positional file paths, --changed, --raw, or --block.`, ERROR_REASON.SDK_FAIL_FAST);
241
- return;
242
- }
243
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
244
- const practices = _practices ?? require('./vault-practices.cjs');
245
- const runGit = _exec ?? shell_command_projection_cjs_1.execGit;
246
- let files;
247
- if (args.includes('--changed')) {
248
- const gitResult = runGit(['diff', '--name-only', 'HEAD'], { cwd });
249
- if (gitResult.exitCode !== 0) {
250
- error('Could not determine changed files: git diff failed (WR-02)', ERROR_REASON.SDK_FAIL_FAST);
251
- return;
252
- }
253
- files = gitResult.stdout.split('\n').map((f) => f.trim()).filter((f) => f.length > 0);
254
- }
255
- else {
256
- files = args.slice(3).filter((a) => !a.startsWith('--'));
257
- }
258
- const result = practices.matchPractices({ cwd, files });
259
- if (args.includes('--block')) {
260
- // T-25-08: emit the finished <vault_practices> block so the planner
261
- // embeds it VERBATIM. The planner never renames a field or applies a
262
- // fence itself — that transformation used to be prose-only, which is
263
- // what left the phase's prompt-injection mitigation unenforced.
264
- c.output(result, true, practices.renderPracticesBlock(result));
265
- }
266
- else if (raw) {
267
- c.output(result, true, JSON.stringify(result));
268
- }
269
- else {
270
- c.output(result, true, practices.renderPracticesReport(result));
271
- }
307
+ // STRICT-05 (02-02): read the STATUS, not the bare string. The write failure
308
+ // used to be swallowed inside vaultSummary, so this arm could only ever print
309
+ // success. It still exits 0 on every path — per D-VH-7 the vault and check
310
+ // commands REPORT and the workflow halts; making this exit non-zero would
311
+ // route the block into the step's `onError: skip` and reopen the fail-open.
312
+ const summary = vault.vaultSummaryResult(cwd);
313
+ if (!summary.ok) {
314
+ // Warning to stderr so the success-path stdout stays byte-identical to
315
+ // before this plan for every healthy project.
316
+ process.stderr.write(`⚠ vault summary: could not write ${summary.path ?? '.planning/vault/SUMMARY.md'} — ${summary.error ?? 'unknown error'}\n`);
272
317
  }
273
- else if (verb === 'promote') {
274
- // T-29-16: reject any unrecognized `--`-prefixed argument BEFORE the
275
- // lazy require of the write-back module and before any subprocess can
276
- // spawn — mirrors the `match` branch's T-24-08b guard exactly. Matched
277
- // on the WHOLE token so `--name=value` is caught by the same check as
278
- // `--name value`. The recognized set holds exactly three flags — no
279
- // flag that disables, bypasses, or relaxes any guard exists.
280
- const RECOGNIZED_PROMOTE_FLAGS = new Set(['--confirmed', '--expect', '--raw']);
281
- const promoteArgs = args.slice(3);
282
- const unrecognizedPromoteFlag = promoteArgs.find((a) => a.startsWith('--') && !RECOGNIZED_PROMOTE_FLAGS.has(a));
283
- if (unrecognizedPromoteFlag !== undefined) {
284
- error(`Unrecognized flag "${unrecognizedPromoteFlag}" for vault practices promote. Supported: --confirmed, --expect <token>, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
285
- return;
286
- }
287
- // Positional file path: the first token that is not `--`-prefixed and
288
- // is not the value immediately following `--expect`.
289
- const expectFlagIdx = promoteArgs.indexOf('--expect');
290
- const expectValueIdx = expectFlagIdx !== -1 ? expectFlagIdx + 1 : -1;
291
- const filePath = promoteArgs.find((a, i) => !a.startsWith('--') && i !== expectValueIdx);
292
- if (filePath === undefined) {
293
- error('No practice file was given. Usage: vault practices promote <file> [--confirmed --expect <token>] [--raw].', ERROR_REASON.SDK_MISSING_ARG);
294
- return;
295
- }
296
- const confirmed = promoteArgs.includes('--confirmed');
297
- const expect = expectValueIdx !== -1 ? promoteArgs[expectValueIdx] : undefined;
298
- if (confirmed && (expect === undefined || expect.startsWith('--'))) {
299
- error('This practice was not pushed: the confirmation is missing the preview token it needs. ' +
300
- 'Run the promotion without confirming first, then pass the token it prints.', ERROR_REASON.VAULT_PROMOTE_CONCURRENT_EDIT);
301
- return;
302
- }
303
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
304
- const writeBack = _writeBack ?? require('./vault-write-back.cjs');
305
- const verdict = writeBack.promotePractice({ cwd, filePath, confirmed, expect });
306
- // Emit the structured verdict BEFORE any error call — a refusal still
307
- // carries its verdict to stdout. This ordering is the whole point of
308
- // the uniform D-16 refusal contract and is what lets a wrapper
309
- // workflow render results without scraping prose.
310
- if (raw) {
311
- c.output(verdict, true, JSON.stringify(verdict));
312
- }
313
- else {
314
- c.output(verdict, true, writeBack.renderPromoteReport(verdict));
315
- }
316
- if (verdict.status === 'refused') {
317
- // An action command's refusal must be observable in the exit code —
318
- // do not adopt the ship gate's always-exit-zero convention here.
319
- error(verdict.message ?? 'This practice was not promoted.', verdict.reason ?? ERROR_REASON.UNKNOWN);
320
- }
318
+ if (raw) {
319
+ c.output(summary, true, JSON.stringify(summary));
321
320
  }
322
321
  else {
323
- error('Unknown practices subcommand. Available: match, promote', ERROR_REASON.SDK_UNKNOWN_COMMAND);
322
+ c.output(summary.index, false);
324
323
  }
325
324
  }
326
325
  else if (subcommand === 'org-profile') {
327
326
  // T-33-13: reject any unrecognized `--`-prefixed argument BEFORE any
328
- // lazy require or resolution work — mirrors the `practices match`
329
- // T-24-08b guard exactly, so a typo can never trigger filesystem work.
327
+ // lazy require or resolution work (T-24-08b), so a typo can never trigger
328
+ // filesystem work.
330
329
  const RECOGNIZED_ORG_PROFILE_FLAGS = new Set(['--raw']);
331
330
  const unrecognizedOrgProfileFlag = args
332
331
  .slice(2)
@@ -353,12 +352,23 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
353
352
  const platformHost = coordinates.resolvePlatformHost(cwd);
354
353
  const effectiveByKey = {
355
354
  'vault.provider': provider,
356
- [coordinates.SHARED_REPO_CONFIG_KEY]: shared.repo,
355
+ // An explicit opt-out (an empty string, from config or profile) reads
356
+ // back as "" rather than null, so it is not reported as overridden.
357
+ [coordinates.SHARED_REPO_CONFIG_KEY]: shared.repo ?? (shared.suppressedKind === 'explicit-opt-out' ? '' : null),
357
358
  [coordinates.SHARED_REF_CONFIG_KEY]: shared.ref,
358
359
  [coordinates.SHARED_HOST_CONFIG_KEY]: shared.host,
359
360
  [coordinates.PLATFORM_HOST_CONFIG_KEY]: platformHost.host,
360
- // The policy row, through the same ladder the load gate reads, so a
361
- // profile value that won is not reported as overridden by null.
361
+ // 13-05 / D-20: the two POLICY keys, through their own resolvers.
362
+ // Without these rows both read null, and a profile-seeded value that
363
+ // actually won was reported as `profile offered "true", but "null"
364
+ // won.` (reproduced on 1.15.1) — a diagnostic stating something untrue.
365
+ // The profile carries `vault.required` as the canonical string
366
+ // 'true'/'false', so String(required) compares equal when it won.
367
+ // `resolveVaultRequired` is the SAME ladder the load gate asks
368
+ // (13-REVIEW CR-02): workstream then root config, trimmed, and an
369
+ // unparseable project value falls through to the profile — so the
370
+ // report cannot say the profile lost while the gate enforces it. It is
371
+ // the policy only; with `vault.enabled` off no gate exists to apply it.
362
372
  [orgProfile.VAULT_REQUIRED_CONFIG_KEY]: String(orgProfile.resolveVaultRequired(cwd).required),
363
373
  };
364
374
  // Build `effective`'s key list from ORG_PROFILE_ALLOWLIST rather than a
@@ -380,21 +390,15 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
380
390
  overridden.push({ key, profile: profileValue, effective: effectiveValue });
381
391
  }
382
392
  }
383
- // Pack 6 D-07: the drift comparison above ran on RAW values (two repos
384
- // differing only by a credential still report drift); what is EMITTED is
385
- // display-safe — the shared-repo coordinate through the owner, prose
386
- // through the prose redactor.
387
- const shownRepo = (key, v) => key === coordinates.SHARED_REPO_CONFIG_KEY && typeof v === 'string' && vault.redactCloneTarget
388
- ? vault.redactCloneTarget(v)
389
- : v;
390
- const shownText = (t) => (vault.redactRemoteText ? vault.redactRemoteText(t) : t);
391
- const shownContributes = {};
392
- for (const key of Object.keys(resolution.contributes)) {
393
- shownContributes[key] = shownRepo(key, resolution.contributes[key]) ?? '';
394
- }
395
- const shownEffective = {};
396
- for (const key of Object.keys(effective))
397
- shownEffective[key] = shownRepo(key, effective[key]);
393
+ // VAULT-EGRESS-1 (quick 261005-8py): a credential embedded in
394
+ // `vault.fallback_repo` must not reach stdout. `overridden` above compares
395
+ // the RAW values, so redaction happens here, at emission only, through the
396
+ // one owner — never a second redactor.
397
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
398
+ const { redactCloneTarget } = require('./vault.cjs');
399
+ const safe = (key, value) => (key === coordinates.SHARED_REPO_CONFIG_KEY && typeof value === 'string' && value !== ''
400
+ ? (redactCloneTarget(value) ?? value) : value);
401
+ const safeMap = (map) => Object.fromEntries(Object.entries(map).map(([k, v]) => [k, safe(k, v)]));
398
402
  const report = {
399
403
  pointer: { key: orgProfile.ORG_PROFILE_CONFIG_KEY, value: resolution.configured },
400
404
  profile: {
@@ -404,16 +408,12 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
404
408
  source: resolution.source,
405
409
  dataPath: resolution.dataPath,
406
410
  },
407
- contributes: shownContributes,
408
- effective: shownEffective,
409
- overridden: overridden.map((o) => ({
410
- key: o.key,
411
- profile: shownRepo(o.key, o.profile) ?? '',
412
- effective: shownRepo(o.key, o.effective),
413
- })),
411
+ contributes: safeMap(resolution.contributes),
412
+ effective: safeMap(effective),
413
+ overridden: overridden.map((o) => ({ ...o, profile: safe(o.key, o.profile), effective: safe(o.key, o.effective) })),
414
414
  ignored: resolution.ignored,
415
415
  rejected: resolution.rejected,
416
- warnings: resolution.warnings.map(shownText),
416
+ warnings: resolution.warnings,
417
417
  };
418
418
  // Never call error() for a project with no profile, and never exit
419
419
  // non-zero for a resolution failure — this is a diagnostic, not an
@@ -426,22 +426,13 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
426
426
  c.output(report, true, renderOrgProfileReport(report));
427
427
  }
428
428
  }
429
- else if (subcommand === 'trace') {
430
- routeTrace({ args, cwd, raw, error, vault, c, _exec, _trace });
431
- }
432
- else if (subcommand === 'audit') {
433
- routeAudit({ args, cwd, raw, error, vault, c, _exec, _audit });
434
- }
435
- else if (subcommand === 'review') {
436
- routeReview({ args, cwd, raw, c });
437
- }
438
429
  else if (subcommand === 'update') {
439
- // `vault update [<tag>] [--yes]`: without --yes it reports the plan and
440
- // writes nothing; see src/vault-update.cts for the contract.
430
+ // PBI 74316: `vault update [<tag>] [--yes]`. Without --yes it reports the
431
+ // plan and writes nothing; see src/vault-update.cts for the contract.
441
432
  const RECOGNIZED_UPDATE_FLAGS = new Set(['--raw', '--yes']);
442
433
  const unrecognizedUpdateFlag = args.slice(2).find((a) => a.startsWith('--') && !RECOGNIZED_UPDATE_FLAGS.has(a));
443
434
  if (unrecognizedUpdateFlag !== undefined) {
444
- error(`Unrecognized flag "${boundedPath(unrecognizedUpdateFlag)}" for vault update. Supported: --yes, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
435
+ error(`Unrecognized flag "${unrecognizedUpdateFlag}" for vault update. Supported: --yes, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
445
436
  return;
446
437
  }
447
438
  const updatePositionals = args.slice(2).filter((a) => !a.startsWith('--'));
@@ -466,109 +457,349 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
466
457
  c.output(updateResult, true, updateMod.renderVaultUpdate(updateResult));
467
458
  }
468
459
  }
460
+ else if (subcommand === 'standards') {
461
+ // 15-07 (D-06, D-08, D-13): the standards record verbs. The arm body lives
462
+ // in `routeVaultStandards` below.
463
+ routeVaultStandards({ cwd, args, raw, error, vault, c, exec: _exec ?? shell_command_projection_cjs_1.execGitLocal });
464
+ }
465
+ else if (subcommand === 'admission') {
466
+ // 16-04 (D-28, D-05): the arm body lives in `admissionVerb` below.
467
+ admissionVerb({ cwd, rest: args.slice(2), raw, error, c });
468
+ }
469
+ else if (subcommand === 'anchor') {
470
+ // 16-06 (D-31, D-32): the arm body lives in `routeVaultAnchor` below.
471
+ routeVaultAnchor({ cwd, args, raw, error, c, exec: _exec ?? shell_command_projection_cjs_1.execGitLocal });
472
+ }
473
+ else if (subcommand === 'door-record') {
474
+ // 16-06 (D-12): the arm body lives in `doorRecordVerb` below.
475
+ doorRecordVerb({ cwd, rest: args.slice(2), raw, error, c, exec: _exec ?? shell_command_projection_cjs_1.execGitLocal });
476
+ }
469
477
  else {
470
- error(`Unknown vault subcommand. Available: ${VAULT_SUBCOMMANDS.map((x) => x.name).join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
478
+ error(`Unknown vault subcommand. Available: ${VAULT_SUBCOMMANDS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
471
479
  }
472
480
  }
473
481
  /**
474
- * `vault trace scaffold|render` — the practice-trace producer.
475
- *
476
- * The CLI owns everything that must be PROVABLE — the pinned ref, the
477
- * resolved sha, the practice-id set and the per-file digests — and the agent
478
- * writes only the judgement half (`status`, `reason`, `tasks`). Provability
479
- * cannot rest on an agent transcribing a forty-character sha.
480
- *
481
- * NEITHER ARM WRITES A FILE. The producer emits text the agent embeds in the
482
- * single write it already performs. Patching a plan's frontmatter here would
483
- * have to round-trip it through the shared serializer, which cannot re-emit
484
- * nested object lists, so the same write would damage the plan's own
485
- * must-haves block.
482
+ * `vault admission [--explain]` (16-04, D-28, D-05): prints exactly one of
483
+ * `inert | off | on | config-unreadable` and exits 0. With `--explain` and
484
+ * `config-unreadable`, one more line per unreadable file names the file and the
485
+ * repair; `--raw` prints the verdict object instead.
486
+ *
487
+ * The answer comes from `vaultAdmissionVerdict` in the REAL src/vault.cts, never
488
+ * from the injectable `_vault` seam: a governance decision must not be
489
+ * switchable by a test mock.
490
+ * It stays on the cwd's own verdict, not the producer verdict the seven record
491
+ * producers ask (quick 261015-ect, QD-10): this verb is a REPORT of the cwd,
492
+ * which is what every fence asks, so a bindings-less linked worktree answers
493
+ * `inert` here even where its owning checkout is admitted (QD-09 "Not changed").
494
+ *
495
+ * Exit 0 on every answer, because a door fence reads the TOKEN: any stdout other
496
+ * than `on`, `off` or `inert` halts it, and its `|| echo unrecognised` fallback
497
+ * turns a non-zero exit into a halt as well. The only non-zero paths are an
498
+ * argument error here, which therefore halts the fence, never skips it.
486
499
  */
487
- function routeTrace({ args, cwd, raw, error, vault, c, _exec, _trace, }) {
500
+ function admissionVerb({ cwd, rest, raw, error, c }) {
501
+ let explain = false;
502
+ for (const a of rest) {
503
+ if (a === '--raw')
504
+ continue;
505
+ if (a === '--explain') {
506
+ explain = true;
507
+ continue;
508
+ }
509
+ error(`Unrecognized argument "${boundedPath(a)}" for vault admission. Supported: --explain, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
510
+ return;
511
+ }
488
512
  // eslint-disable-next-line @typescript-eslint/no-require-imports
489
- const realVault = require('./vault.cjs');
490
- const cfgGet = vault.cfgGet ? vault.cfgGet.bind(vault) : realVault.cfgGet;
491
- const verb = args[2];
492
- const traceArgs = args.slice(3);
493
- if (verb !== 'scaffold' && verb !== 'render') {
494
- error(`Unknown trace subcommand. Available: ${TRACE_VERBS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
513
+ const owner = require('./vault.cjs');
514
+ const verdict = owner.vaultAdmissionVerdict(cwd);
515
+ if (raw) {
516
+ c.output(verdict, true, JSON.stringify(verdict));
517
+ return;
518
+ }
519
+ const lines = [verdict.state];
520
+ if (explain && verdict.state === 'config-unreadable') {
521
+ for (const u of verdict.unreadable) {
522
+ lines.push(`${u.file}: ${u.error} — repair: fix the JSON in ${u.file} (or remove the key that broke it) and re-run; ` +
523
+ 'the gate cannot read this file and refuses to pass having checked nothing');
524
+ }
525
+ }
526
+ c.output(verdict, true, `${lines.join('\n')}\n`);
527
+ }
528
+ /**
529
+ * `vault standards block|consulted` (15-07). The CLI owns everything provable
530
+ * about a standards record (D-06): the pin comes from the manifest, a path is
531
+ * accepted only when the loaded INDEX resolves it (D-19), the degraded line is
532
+ * chosen from the measured state (D-08) and the override line comes from the
533
+ * one override builder. The planner fills reason slots and the executor fills
534
+ * status slots; nothing else in the record is typed by an agent.
535
+ *
536
+ * ORDER: unless `vault admission` answers `on` (bindings present, every config
537
+ * readable, the vault switch on) both verbs print nothing and write nothing,
538
+ * before any argument is read — so a project without the vault is
539
+ * byte-identical on every path including a mistyped one, and a standards record
540
+ * cannot be produced on a project whose trace gate is inert (17-REVIEW CR-01,
541
+ * D-13; the same gate `routeVaultAnchor` and `doorRecordVerb` ask). The verdict
542
+ * is asked of the checkout whose vault cache the verbs read (quick 261015-at3,
543
+ * QD-09): in a linked worktree reading its owning checkout's cache — where an
544
+ * executor runs `consulted` and has no untracked bindings of its own — that is
545
+ * the owning checkout, which is also where the gate reading the record runs. Then
546
+ * the verb and the flags are checked.
547
+ *
548
+ * Every governance read is from the REAL module, never the `_vault` seam; only the bounded PLAN read goes through the seam, so
549
+ * a test can count it. Neither verb writes a file.
550
+ */
551
+ function routeVaultStandards({ cwd, args, raw, error, vault, c, exec }) {
552
+ // `promote` is an operator action, not a record producer: it is dispatched
553
+ // BEFORE the producer admission below, so a vault that is off answers with
554
+ // the promote module's own activation refusal instead of printing nothing.
555
+ if (args[2] === 'promote') {
556
+ standardsPromoteVerb({ cwd, rest: args.slice(3), raw, error, c, exec });
495
557
  return;
496
558
  }
497
- // Inert unless both vault keys are on: no output at all, before any flag check.
498
- if (!tracePracticesActive(cfgGet, cwd))
559
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
560
+ const owner = require('./vault.cjs');
561
+ if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
499
562
  return;
500
- if (verb === 'scaffold') {
501
- const RECOGNIZED_SCAFFOLD_FLAGS = new Set(['--stage', '--plan', '--raw']);
502
- const unrecognized = traceArgs.find((a) => a.startsWith('--') && !RECOGNIZED_SCAFFOLD_FLAGS.has(a));
503
- if (unrecognized !== undefined) {
504
- error(`Unrecognized flag "${unrecognized}" for vault trace scaffold. ` +
505
- `Supported: positional file paths, --stage <${TRACE_STAGES.join('|')}>, --plan <id>, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
563
+ const verb = args[2];
564
+ const rest = args.slice(3);
565
+ if (verb === 'block') {
566
+ standardsBlockVerb({ cwd, rest, raw, error, c, owner });
567
+ }
568
+ else if (verb === 'consulted') {
569
+ standardsConsultedVerb({ cwd, rest, raw, error, c, owner, vault, exec });
570
+ }
571
+ else {
572
+ error(`Unknown standards subcommand. Available: ${STANDARDS_VERBS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
573
+ }
574
+ }
575
+ /** The flags `vault standards promote` takes that carry a value. */
576
+ const PROMOTE_VALUE_FLAGS = ['--path', '--entry', '--section', '--expect'];
577
+ /**
578
+ * `vault standards promote <file> [--path <vault-path>] [--entry "<what it is>;
579
+ * used when <…>"] [--section <INDEX section>] [--confirmed --expect <token>]
580
+ * [--raw]` (FORK-ONLY): add a standards document from this project to the
581
+ * client vault with its INDEX.md entry. Without `--confirmed` it only
582
+ * previews; see src/vault-standards-promote.cts for the guards and the two
583
+ * runs. An unrecognised flag is refused BEFORE the module loads or anything
584
+ * spawns (T-29-16); no flag disables or relaxes a guard. The verdict is
585
+ * written to stdout first, then a refusal also exits non-zero: an action
586
+ * command's refusal must be observable in the exit code.
587
+ */
588
+ function standardsPromoteVerb({ cwd, rest, raw, error, c, exec }) {
589
+ const usage = 'Usage: vault standards promote <file> [--path <vault-path>] [--entry "<what it is>; used when <...>"] '
590
+ + '[--section <INDEX section>] [--confirmed --expect <token>] [--raw].';
591
+ const values = {};
592
+ let confirmed = false;
593
+ let filePath;
594
+ for (let i = 0; i < rest.length; i += 1) {
595
+ const a = rest[i];
596
+ if (a === '--raw')
597
+ continue;
598
+ if (a === '--confirmed') {
599
+ confirmed = true;
600
+ continue;
601
+ }
602
+ if (PROMOTE_VALUE_FLAGS.includes(a)) {
603
+ const v = rest[i + 1];
604
+ if (v === undefined || v.startsWith('--')) {
605
+ error(`${a} needs a value. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
606
+ return;
607
+ }
608
+ values[a] = v;
609
+ i += 1;
610
+ continue;
611
+ }
612
+ if (a.startsWith('--')) {
613
+ error(`Unrecognized flag "${boundedPath(a)}" for vault standards promote. ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
506
614
  return;
507
615
  }
508
- const valueIndices = flagValueIndices(traceArgs, ['--stage', '--plan']);
509
- const stageIdx = traceArgs.indexOf('--stage');
510
- const stage = stageIdx !== -1 ? traceArgs[stageIdx + 1] : undefined;
511
- if (stage === undefined || !TRACE_STAGES.includes(stage)) {
512
- error(`Missing or unrecognized --stage value for vault trace scaffold. Supported: ${TRACE_STAGES.join(', ')}.`, ERROR_REASON.SDK_FAIL_FAST);
616
+ if (filePath !== undefined) {
617
+ error(`vault standards promote takes exactly one document. ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
513
618
  return;
514
619
  }
515
- const planIdx = traceArgs.indexOf('--plan');
516
- const planId = planIdx !== -1 ? traceArgs[planIdx + 1] : undefined;
517
- if (planId === undefined || planId.startsWith('--')) {
518
- error('No plan identifier was given. Usage: vault trace scaffold ' +
519
- `--stage <${TRACE_STAGES.join('|')}> --plan <id> [files...] [--raw].`, ERROR_REASON.SDK_MISSING_ARG);
620
+ filePath = a;
621
+ }
622
+ if (filePath === undefined) {
623
+ error(`No standards document was given. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
624
+ return;
625
+ }
626
+ if (confirmed && values['--expect'] === undefined) {
627
+ error('This standards document was not pushed: the confirmation is missing the preview token. '
628
+ + 'Run the promotion without --confirmed first, then pass the token it prints.', ERROR_REASON.VAULT_PROMOTE_CONCURRENT_EDIT);
629
+ return;
630
+ }
631
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
632
+ const promote = require('./vault-standards-promote.cjs');
633
+ const verdict = promote.promoteStandard({
634
+ cwd,
635
+ filePath,
636
+ vaultPath: values['--path'] ?? null,
637
+ entry: values['--entry'] ?? null,
638
+ section: values['--section'] ?? null,
639
+ confirmed,
640
+ expect: values['--expect'] ?? null,
641
+ _exec: exec,
642
+ });
643
+ c.output(verdict, true, raw ? JSON.stringify(verdict) : promote.renderPromoteReport(verdict));
644
+ if (verdict.status === 'refused') {
645
+ error(String(verdict.message), String(verdict.reason));
646
+ }
647
+ }
648
+ function standardsBlockVerb({ cwd, rest, raw, error, c, owner }) {
649
+ // ── arguments ──
650
+ let planId;
651
+ let planGiven = false;
652
+ const positionals = [];
653
+ for (let i = 0; i < rest.length; i += 1) {
654
+ const a = rest[i];
655
+ if (a === '--raw')
656
+ continue;
657
+ if (a === '--plan') {
658
+ planGiven = true;
659
+ planId = rest[i + 1];
660
+ i += 1;
661
+ continue;
662
+ }
663
+ if (a.startsWith('--plan=')) {
664
+ planGiven = true;
665
+ planId = a.slice('--plan='.length);
666
+ continue;
667
+ }
668
+ if (a.startsWith('--')) {
669
+ error(`Unrecognized flag "${boundedPath(a)}" for vault standards block. ` +
670
+ 'Supported: --plan <plan-id>, positional INDEX paths, --raw.', ERROR_REASON.SDK_FAIL_FAST);
520
671
  return;
521
672
  }
522
- if (!SCAFFOLD_PLAN_ID_RE.test(planId)) {
523
- error(`Invalid --plan value "${boundedPath(planId)}" for vault trace scaffold. ${SCAFFOLD_PLAN_ID_FORM} ` +
524
- 'A trace gate compares the identifier a plan artifact records against the one its own filename states.', ERROR_REASON.SDK_FAIL_FAST);
673
+ positionals.push(a);
674
+ }
675
+ if (!planGiven || planId === undefined || planId === '' || planId.startsWith('--')) {
676
+ error('No plan identifier was given. Usage: vault standards block --plan <plan-id> [<index-path>...] [--raw].', ERROR_REASON.SDK_MISSING_ARG);
677
+ return;
678
+ }
679
+ if (!SCAFFOLD_PLAN_ID_RE.test(planId)) {
680
+ error(`Invalid --plan value "${boundedPath(planId)}" for vault standards block. ${SCAFFOLD_PLAN_ID_FORM} ${SCAFFOLD_QUICK_ID_FORM}`, ERROR_REASON.SDK_FAIL_FAST);
681
+ return;
682
+ }
683
+ // ── the measured state, read once, from one cache ──
684
+ // The manifest and the INDEX come from the same directory, the one
685
+ // `resolveVaultCacheDir` names (in a main checkout, `.planning/vault/`), so
686
+ // the pin and the resolved paths always describe one cache. "Loaded" and the
687
+ // stamp status are the leaf's predicates, shared with the trace gate.
688
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
689
+ const indexLeaf = require('./vault-index.cjs');
690
+ const cacheDir = owner.resolveVaultCacheDir(cwd).dir;
691
+ const pin = vaultStandardsLeaf.standardsLoadedPin(owner.readManifest(cacheDir));
692
+ const state = pin === null ? null : owner.computeVaultIndexState(cwd, { vaultDir: cacheDir });
693
+ const indexStatus = vaultStandardsLeaf.standardsMeasuredIndexStatus(pin, state);
694
+ // ── the selection (D-19) ──
695
+ // The repair comes from the one owner the consulted verb and the trace gate
696
+ // also read (15-REVIEW iter-3 WR-01), so the three cannot name different ones.
697
+ const indexFix = vaultStandardsLeaf.standardsIndexRepair(indexStatus);
698
+ if (indexFix !== null && positionals.length > 0) {
699
+ error(`Vault standards block: no document can be selected: the index is ${indexStatus}. ` +
700
+ 'Run the command with no index paths to record that state; to select documents, ' +
701
+ `${indexFix.repair.charAt(0).toLowerCase()}${indexFix.repair.slice(1)} first.`, ERROR_REASON.SDK_FAIL_FAST);
702
+ return;
703
+ }
704
+ const selectable = vaultStandardsLeaf.standardsResolvedPaths(state);
705
+ const seen = new Set();
706
+ for (const p of positionals) {
707
+ // Exact string membership: no normalisation, no join, no stat (Phase 14
708
+ // D-08). A path the INDEX does not resolve is refused in ONE wording
709
+ // whatever the reason, so the refusal never tells a caller which class of
710
+ // file a path names.
711
+ if (seen.has(p)) {
712
+ error(`Vault standards block: ${indexLeaf.displayVaultPath(p)} is listed more than once; name each INDEX path once.`, ERROR_REASON.SDK_FAIL_FAST);
525
713
  return;
526
714
  }
527
- const files = traceArgs.filter((a, i) => !a.startsWith('--') && !valueIndices.has(i));
528
- // The execute stage records, per governed file, the commit that last
529
- // produced it, so the statement asserts something falsifiable. A file with
530
- // no commit yields no digest — the leaf records that gap as a loud note; a
531
- // HEAD fallback would paper over a real coverage hole.
532
- const runGit = _exec ?? shell_command_projection_cjs_1.execGit;
533
- const fileInputs = files.map((name) => {
534
- if (stage !== 'execute')
535
- return name;
536
- const res = runGit(['log', '-1', '--format=%H', '--', name], { cwd });
537
- const digest = res.exitCode === 0 ? res.stdout.trim() : '';
538
- return GIT_COMMIT_DIGEST_RE.test(digest) ? { name, gitCommit: digest } : name;
539
- });
540
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
541
- const traceMod = _trace ?? require('./vault-trace.cjs');
542
- // No files is a REAL path: a documentation-only plan is still governed by
543
- // the global practices, and its ruled-out set must stay auditable.
544
- const statement = traceMod.buildTraceStatement({ cwd, stage, plan: planId, files: fileInputs });
545
- if (raw) {
546
- c.output(statement, true, JSON.stringify(statement));
715
+ seen.add(p);
716
+ if (!selectable.has(p)) {
717
+ error(`Vault standards block: ${indexLeaf.displayVaultPath(p)} is not a selectable INDEX entry. ` +
718
+ 'Choose from the entries the ## INDEX section of .planning/vault/SUMMARY.md lists, exactly as written there.', ERROR_REASON.SDK_FAIL_FAST);
719
+ return;
547
720
  }
548
- else {
549
- c.output(statement, true, traceMod.serializeTraceBlock(statement));
721
+ }
722
+ // ── the record ──
723
+ const repoText = pin !== null
724
+ ? indexLeaf.sanitiseVaultText(owner.redactCloneTarget(pin.repo) ?? '', 200).replace(/\s+/g, '_')
725
+ : '';
726
+ // Document lines only on a healthy index with a selection; otherwise the
727
+ // one degraded line the measured status implies (D-08).
728
+ const degraded = indexStatus === 'ok' && positionals.length > 0
729
+ ? null
730
+ : vaultStandardsLeaf.standardsDegradedLineFor(indexStatus);
731
+ const record = {
732
+ planId,
733
+ repo: pin !== null && repoText !== '' ? repoText : null,
734
+ ref: pin?.ref ?? null,
735
+ sha: pin?.sha ?? null,
736
+ indexStatus,
737
+ documents: degraded === null
738
+ ? positionals.map((p) => ({ path: p, ref: pin.ref, sha: pin.sha, reason: '' }))
739
+ : [],
740
+ degraded,
741
+ override: null,
742
+ };
743
+ // ── the override line (D-08): only through the one builder ──
744
+ // Recorded only when the load gate's verdict GENUINELY blocks now and the
745
+ // trace leaf accepts the operator's override reason from this process's
746
+ // environment; a non-blocking verdict records nothing (D-15 of Phase 4). The
747
+ // router names no part of the override stack (the leaf owns it all). The
748
+ // gate name is `TRACE_OVERRIDE_GATE`. The verdict is computed
749
+ // against the SAME cache the stamp above measured (D-36, 15-REVIEW iter-2
750
+ // WR-02), exactly as the trace gate recomputes it, so the suppressed reason
751
+ // recorded here is the one the gate will compare it with, in any checkout.
752
+ const verdict = owner.computeVaultVerdict(cwd, { vaultDir: cacheDir });
753
+ if (verdict.block === true) {
754
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
755
+ const traceLeaf = require('./vault-trace.cjs');
756
+ const ov = traceLeaf.buildStandardsOverrideRecord(cwd, {
757
+ gate: TRACE_OVERRIDE_GATE,
758
+ block: true,
759
+ suppressedReason: verdict.reason,
760
+ });
761
+ if (ov) {
762
+ record.override = { gate: ov.gate, suppressedReason: ov.suppressedReason, who: ov.who, when: ov.when, reason: ov.reason };
550
763
  }
764
+ }
765
+ let text;
766
+ try {
767
+ text = vaultStandardsLeaf.serializeStandardsBlock(record);
768
+ }
769
+ catch (e) {
770
+ error(`Vault standards block could not write the record: ${boundedPath(e instanceof Error ? e.message : String(e))}`, ERROR_REASON.UNKNOWN);
551
771
  return;
552
772
  }
553
- // ── render ──
554
- const RECOGNIZED_RENDER_FLAGS = new Set(['--raw']);
555
- const unrecognizedRender = traceArgs.find((a) => a.startsWith('--') && !RECOGNIZED_RENDER_FLAGS.has(a));
556
- if (unrecognizedRender !== undefined) {
557
- error(`Unrecognized flag "${unrecognizedRender}" for vault trace render. Supported: a positional artifact path, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
773
+ const envelope = {
774
+ status: 'ok',
775
+ planId,
776
+ stamp: { repo: record.repo, ref: record.ref, sha: record.sha, indexStatus },
777
+ documents: record.documents.map((d) => ({ path: d.path, ref: d.ref, sha: d.sha })),
778
+ degraded: record.degraded,
779
+ override: record.override,
780
+ text,
781
+ };
782
+ c.output(envelope, true, raw ? JSON.stringify(envelope) : text);
783
+ }
784
+ function standardsConsultedVerb({ cwd, rest, raw, error, c, owner, vault, exec, }) {
785
+ const unrecognized = rest.find((a) => a.startsWith('--') && a !== '--raw');
786
+ if (unrecognized !== undefined) {
787
+ error(`Unrecognized flag "${boundedPath(unrecognized)}" for vault standards consulted. ` +
788
+ 'Supported: a positional plan path, --raw.', ERROR_REASON.SDK_FAIL_FAST);
558
789
  return;
559
790
  }
560
- const artifactPath = traceArgs.find((a) => !a.startsWith('--'));
561
- if (artifactPath === undefined) {
562
- error('No artifact path was given. Usage: vault trace render <artifact> [--raw].', ERROR_REASON.SDK_MISSING_ARG);
791
+ const positionals = rest.filter((a) => !a.startsWith('--'));
792
+ if (positionals.length !== 1) {
793
+ error('vault standards consulted takes exactly one plan path. Usage: vault standards consulted <plan-path> [--raw].', positionals.length === 0 ? ERROR_REASON.SDK_MISSING_ARG : ERROR_REASON.SDK_FAIL_FAST);
563
794
  return;
564
795
  }
565
- // CONTAINMENT, decided BEFORE the read (the bounded reader returns '' on any
566
- // problem and never throws, so a decision taken on its result would be none).
567
- // The rendered section is included verbatim in SUMMARY.md, so an out-of-tree
568
- // file must never become its content. The predicate is the real leaf's, not
569
- // the `_vault` seam's: a security decision is not switchable by a mock.
796
+ const artifactPath = positionals[0];
797
+ const shown = boundedPath(artifactPath);
798
+ // CONTAINMENT FIRST, then the size, then the read (T-03-50): the plan path
799
+ // is an unvalidated positional, and a decision taken on the bounded read's
800
+ // RESULT would be no decision.
570
801
  if (artifactPath.indexOf('\u0000') !== -1) {
571
- error(`The artifact path is not a usable path: ${boundedPath(artifactPath)}`, ERROR_REASON.SDK_FAIL_FAST);
802
+ error(`The plan path is not a usable path: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
572
803
  return;
573
804
  }
574
805
  let projectRoot;
@@ -579,225 +810,1199 @@ function routeTrace({ args, cwd, raw, error, vault, c, _exec, _trace, }) {
579
810
  projectRoot = cwd;
580
811
  }
581
812
  const artifactAbs = node_path_1.default.resolve(cwd, artifactPath);
582
- if (!realVault.isInsideDir(projectRoot, artifactAbs)) {
583
- error(`The artifact path resolves outside the project and was not read: ${boundedPath(artifactPath)}`, ERROR_REASON.SDK_FAIL_FAST);
813
+ if (!owner.isInsideDir(projectRoot, artifactAbs)) {
814
+ error(`The plan path resolves outside the project and was not read: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
584
815
  return;
585
816
  }
586
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
587
- const traceMod = _trace ?? require('./vault-trace.cjs');
588
- const readHead = vault.readHeadBounded ? vault.readHeadBounded.bind(vault) : realVault.readHeadBounded;
589
- const content = readHead(artifactAbs, TRACE_ARTIFACT_READ_BYTES);
590
- const parsed = traceMod.parseVaultTraceBlock(content, artifactPath);
591
- // THE LEDGER, AND ONLY THE LEDGER: this arm never calls the matcher, so the
592
- // rendered section cannot disagree with the frontmatter it is generated
593
- // from. An absent ledger yields no section; an empty one yields the heading
594
- // plus one explanatory line (the leaf's renderer).
595
- const rendered = parsed.present ? traceMod.renderPracticesApplied(parsed.statement) : '';
596
- if (raw) {
597
- c.output(parsed, true, JSON.stringify(parsed));
817
+ let size;
818
+ try {
819
+ const st = node_fs_1.default.statSync(artifactAbs);
820
+ if (!st.isFile())
821
+ throw new Error('not a file');
822
+ size = st.size;
598
823
  }
599
- else {
600
- c.output(parsed, true, rendered);
824
+ catch {
825
+ error(`The plan path is not a readable file: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
826
+ return;
827
+ }
828
+ if (size > STANDARDS_PLAN_READ_BYTES) {
829
+ error(`The plan is ${size} bytes, above the ${STANDARDS_PLAN_READ_BYTES}-byte read cap the trace gate also applies, ` +
830
+ `and was not read: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
831
+ return;
832
+ }
833
+ const text = vault.readHeadBounded(artifactAbs, STANDARDS_PLAN_READ_BYTES);
834
+ const parsed = vaultStandardsLeaf.parseStandardsBlock(text, { artifact: shown });
835
+ if (parsed.record === null || parsed.problems.length > 0) {
836
+ const first = parsed.problems[0];
837
+ error(first
838
+ ? `Vault standards consulted: ${first.kind}: ${first.detail}`
839
+ : `Vault standards consulted: ${shown} carries no readable ${vaultStandardsLeaf.VAULT_STANDARDS_TAG_OPEN} record. ` +
840
+ 'Re-run `pp-tools vault standards block --plan <plan-id> <index-path...>` and embed its output verbatim.', ERROR_REASON.SDK_FAIL_FAST);
841
+ return;
842
+ }
843
+ const record = parsed.record;
844
+ // ── absolute cache paths (D-30, C-9) ──
845
+ // Resolved through the one cache resolver, so an executor in a linked
846
+ // worktree (which has no cache of its own) is pointed at the main checkout's.
847
+ // Every path is contained in the cache directory: a block edited by hand
848
+ // could name `../x.md`, and that must be refused before anyone reads it.
849
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
850
+ const indexLeaf = require('./vault-index.cjs');
851
+ const cache = owner.resolveVaultCacheDir(cwd);
852
+ // And every path is a RESOLVED INDEX entry of that cache (D-19, 15-REVIEW
853
+ // WR-04): the executor is told to read each listed document in full, and a
854
+ // withheld or credential file is in the cache too. The same reads and the
855
+ // same set the block verb selects from, so a PLAN that never passed
856
+ // plan:post, or was edited after it, cannot hand such a path to an executor.
857
+ // One reason-free wording, as the block verb's: the refusal never says why.
858
+ const loadedPin = vaultStandardsLeaf.standardsLoadedPin(owner.readManifest(cache.dir));
859
+ const indexState = loadedPin === null ? null : owner.computeVaultIndexState(cwd, { vaultDir: cache.dir });
860
+ const selectable = vaultStandardsLeaf.standardsResolvedPaths(indexState);
861
+ // 15-REVIEW iter-2 WR-05: with no vault loaded, or a loaded vault whose INDEX
862
+ // is degraded, NO document can be listed, and the per-path wording below
863
+ // would name a loaded vault that is not there and send the reader back to
864
+ // `vault standards block`, where the only record left to write is a degraded
865
+ // one: a governed plan would lose its document record. So this case is
866
+ // refused first, naming the measured cause and the repair (`vault fetch`),
867
+ // from the one owner the block verb and the trace gate also read (iter-3
868
+ // WR-01), so the gate cannot send the same record somewhere else.
869
+ const indexStatus = vaultStandardsLeaf.standardsMeasuredIndexStatus(loadedPin, indexState);
870
+ const indexFix = vaultStandardsLeaf.standardsIndexRepair(indexStatus);
871
+ if (record.documents.length > 0 && indexFix !== null) {
872
+ error(`Vault standards consulted: ${indexFix.cause}, so no document of ${shown} can be listed. ` +
873
+ `${indexFix.repair}, then re-run this command.`, ERROR_REASON.SDK_FAIL_FAST);
874
+ return;
601
875
  }
876
+ const documents = [];
877
+ for (const d of record.documents) {
878
+ const cachePath = node_path_1.default.join(cache.dir, d.path);
879
+ if (node_path_1.default.isAbsolute(d.path) || !owner.isInsideDir(cache.dir, cachePath) || cachePath === cache.dir) {
880
+ error(`Vault standards consulted: ${indexLeaf.displayVaultPath(d.path)} in ${shown} resolves outside the vault cache ` +
881
+ 'and was not listed. Re-run `pp-tools vault standards block` for this plan and embed its output verbatim.', ERROR_REASON.SDK_FAIL_FAST);
882
+ return;
883
+ }
884
+ if (!selectable.has(d.path)) {
885
+ error(`Vault standards consulted: ${indexLeaf.displayVaultPath(d.path)} in ${shown} is not a selectable INDEX entry ` +
886
+ 'of the loaded vault and was not listed. Re-run `pp-tools vault standards block` for this plan, choosing from ' +
887
+ 'the entries the ## INDEX section of .planning/vault/SUMMARY.md lists, and embed its output verbatim.', ERROR_REASON.SDK_FAIL_FAST);
888
+ return;
889
+ }
890
+ documents.push({ path: d.path, ref: d.ref, sha: d.sha, cachePath });
891
+ }
892
+ // 16-06 (D-14/D-31/D-35): the anchor line, the section's last line. END is
893
+ // HEAD now (the executor runs this after the last task commit); START is the
894
+ // anchor `vault anchor start` recorded in the plan's own directory, when one
895
+ // is there. Both come from git and the anchors file, never from the agent.
896
+ // HEAD unreadable (no commit, git failing) writes no line at all, and the
897
+ // strict points then type the missing anchor (16-07); a write point is
898
+ // silent on it (D-16).
899
+ const headRun = exec(['rev-parse', 'HEAD'], { cwd });
900
+ const end = headRun.exitCode === 0 ? headRun.stdout.trim() : '';
901
+ const start = vaultSeal.readDirAnchors(node_path_1.default.dirname(artifactAbs))?.start ?? null;
902
+ const anchors = vaultSeal.ANCHOR_SHA_RE.test(end) ? { start, end } : null;
903
+ const section = vaultStandardsLeaf.serializeConsultedSection(record, { anchors });
904
+ const envelope = {
905
+ status: 'ok',
906
+ planId: record.planId,
907
+ stamp: { repo: record.repo, ref: record.ref, sha: record.sha },
908
+ documents,
909
+ // The section and this field echo one sanitised line (15-REVIEW WR-03).
910
+ degraded: record.degraded === null ? null : vaultStandardsLeaf.standardsEchoDegraded(record.degraded),
911
+ cacheSource: cache.source,
912
+ section,
913
+ };
914
+ c.output(envelope, true, raw ? JSON.stringify(envelope) : section);
602
915
  }
603
916
  /**
604
- * `vault audit <phase> [--range <from>..<to>] [--raw]` — the operator's view of
605
- * the mechanical practice audit.
606
- *
607
- * A PASS-THROUGH: it collects arguments, calls `runVaultAudit` and prints the
608
- * leaf's own envelope (`--raw`) or the leaf's own report. `check vault.audit`
609
- * serializes the same envelope from the same engine, and the phase argument is
610
- * resolved by the same `resolveDecisionPhaseDir`, so the report cannot disagree
611
- * with the gate that blocks on it.
612
- *
613
- * Inert (no output at all) unless both vault keys are on, decided before the
614
- * flag allowlist so an inert project is silent on every path. A malformed
615
- * `--range` is refused by the leaf's own predicate and never echoed. The
616
- * range's value is excluded by index, so the flag may precede the phase.
917
+ * The real vault module, for the governance reads of the 16-06 verbs: the
918
+ * admission verdict, containment and phase resolution must not be switchable
919
+ * by a test mock.
617
920
  */
618
- function routeAudit({ args, cwd, raw, error, vault, c, _exec, _audit, }) {
921
+ function anchorOwner() {
619
922
  // eslint-disable-next-line @typescript-eslint/no-require-imports
620
- const realVault = require('./vault.cjs');
621
- const cfgGet = vault.cfgGet ? vault.cfgGet.bind(vault) : realVault.cfgGet;
622
- if (!tracePracticesActive(cfgGet, cwd))
623
- return;
624
- const auditArgs = args.slice(2);
625
- const RECOGNIZED_AUDIT_FLAGS = new Set(['--raw', '--range']);
626
- const unrecognized = auditArgs.find((a) => a.startsWith('--') && !RECOGNIZED_AUDIT_FLAGS.has(a));
627
- if (unrecognized !== undefined) {
628
- error(`Unrecognized flag "${boundedPath(unrecognized)}" for vault audit. Supported: a phase directory or number, --raw, --range <from>..<to>.`, ERROR_REASON.SDK_FAIL_FAST);
923
+ return require('./vault.cjs');
924
+ }
925
+ /** The project root the router's other containment checks use: `findProjectRoot(cwd) || cwd`. */
926
+ function projectRootOf(cwd) {
927
+ try {
928
+ return findProjectRoot(cwd) || cwd;
929
+ }
930
+ catch {
931
+ return cwd;
932
+ }
933
+ }
934
+ /**
935
+ * THE CONTAINMENT LADDER for a path a 16-06 verb is handed (the
936
+ * `standardsConsultedVerb` order, T-03-50): a NUL byte is refused, the project
937
+ * root is resolved, the path must resolve inside the root AND inside its
938
+ * `.planning/` tree (never the tree itself), and only then is it stat'ed.
939
+ *
940
+ * - `dir`: an existing directory;
941
+ * - `file`: an existing regular file (not a symlink);
942
+ * - `artifact`: a file that may not exist yet, whose directory exists inside
943
+ * `.planning/`; when it does exist it must be a regular file (not a
944
+ * symlink), so a write can never be redirected out of the tree.
945
+ *
946
+ * `.planning/` is required, not only the root, because every path these verbs
947
+ * write or read is a planning record; an anchors file or a door record in the
948
+ * project's source tree would be committed as source (D-55). The refusal names
949
+ * the bounded argv value, never file content.
950
+ */
951
+ function resolvePlanningPath(cwd, raw, want) {
952
+ const shown = boundedPath(raw);
953
+ if (raw.indexOf('\u0000') !== -1)
954
+ return { ok: false, reason: 'path-unusable', message: `"${shown}" is not a usable path` };
955
+ const owner = anchorOwner();
956
+ const root = projectRootOf(cwd);
957
+ const abs = node_path_1.default.resolve(cwd, raw);
958
+ if (!owner.isInsideDir(root, abs)) {
959
+ return { ok: false, reason: 'outside-project', message: `"${shown}" resolves outside the project and was not used` };
960
+ }
961
+ const planning = node_path_1.default.join(root, '.planning');
962
+ const under = want === 'artifact' ? node_path_1.default.dirname(abs) : abs;
963
+ if (!owner.isInsideDir(planning, under) || (want !== 'artifact' && owner.isInsideDir(abs, planning))) {
964
+ return { ok: false, reason: 'outside-planning', message: `"${shown}" is not inside the project's .planning/ tree` };
965
+ }
966
+ const rel = node_path_1.default.relative(root, abs).split(node_path_1.default.sep).join('/');
967
+ try {
968
+ if (want === 'dir') {
969
+ if (!node_fs_1.default.statSync(abs).isDirectory())
970
+ return { ok: false, reason: 'not-a-directory', message: `"${shown}" is not a directory` };
971
+ }
972
+ else if (want === 'file') {
973
+ const st = node_fs_1.default.lstatSync(abs);
974
+ if (!st.isFile())
975
+ return { ok: false, reason: 'not-a-file', message: `"${shown}" is not a regular file` };
976
+ }
977
+ else {
978
+ if (!node_fs_1.default.statSync(node_path_1.default.dirname(abs)).isDirectory()) {
979
+ return { ok: false, reason: 'not-a-directory', message: `the directory of "${shown}" does not exist` };
980
+ }
981
+ if (node_fs_1.default.existsSync(abs) || isDanglingLink(abs)) {
982
+ const st = node_fs_1.default.lstatSync(abs);
983
+ if (!st.isFile())
984
+ return { ok: false, reason: 'not-a-file', message: `"${shown}" exists and is not a regular file` };
985
+ }
986
+ }
987
+ }
988
+ catch {
989
+ return { ok: false, reason: 'not-found', message: `"${shown}" does not exist` };
990
+ }
991
+ return { ok: true, root, abs, rel };
992
+ }
993
+ /** A symlink whose target is missing: `existsSync` says false, and a write would follow it. */
994
+ function isDanglingLink(abs) {
995
+ try {
996
+ return node_fs_1.default.lstatSync(abs).isSymbolicLink();
997
+ }
998
+ catch {
999
+ return false;
1000
+ }
1001
+ }
1002
+ /**
1003
+ * `vault anchor <verb>` (16-06). INERT FIRST (D-13): unless the producer
1004
+ * admission verdict is `on` (bindings present, every config readable, the
1005
+ * vault gate active) the verb prints nothing and writes nothing, before any
1006
+ * argument is read, so a project without the vault is byte-identical on every
1007
+ * path, including a mistyped one. A `config-unreadable` project is halted by
1008
+ * the admission fence that precedes every call site, never here.
1009
+ *
1010
+ * The verdict is the PRODUCER's (quick 261015-ect, QD-10), the one the other
1011
+ * six record producers ask: equal to the cwd's own except in a linked worktree
1012
+ * with no bindings of its own, where it is the owning checkout's. So all seven
1013
+ * answer alike in every shape; the door fences, which probe the cwd's bindings
1014
+ * first, never reach this verb in such a worktree, so only by-hand calls move.
1015
+ */
1016
+ function routeVaultAnchor({ cwd, args, raw, error, c, exec }) {
1017
+ if (anchorOwner().vaultProducerAdmissionVerdict(cwd).state !== 'on')
629
1018
  return;
1019
+ const verb = args[2];
1020
+ const rest = args.slice(3);
1021
+ if (verb === 'start') {
1022
+ anchorStartVerb({ cwd, rest, raw, error, c, exec });
630
1023
  }
631
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
632
- const auditMod = _audit ?? require('./vault-audit.cjs');
633
- // eslint-disable-next-line @typescript-eslint/no-require-imports
634
- const checkRouter = require('./check-command-router.cjs');
635
- const rangeAt = auditArgs.indexOf('--range');
636
- const rangeValue = rangeAt === -1 ? undefined : auditArgs[rangeAt + 1];
637
- if (rangeAt !== -1) {
638
- // The real leaf's predicate: a range check is not switchable by a test mock.
639
- // eslint-disable-next-line @typescript-eslint/no-require-imports
640
- const leaf = require('./vault-audit.cjs');
641
- if (typeof rangeValue !== 'string' || !leaf.isValidOperatorRange(rangeValue)) {
642
- error(checkRouter.AUDIT_RANGE_REFUSED_MESSAGE, ERROR_REASON.SDK_FAIL_FAST);
1024
+ else if (verb === 'backfill') {
1025
+ anchorBackfillVerb({ cwd, rest, raw, error, c, exec });
1026
+ }
1027
+ else if (verb === 'certify') {
1028
+ anchorCertifyVerb({ cwd, rest, raw, error, c, exec });
1029
+ }
1030
+ else {
1031
+ error(`Unknown vault anchor verb. Available: ${ANCHOR_VERBS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
1032
+ }
1033
+ }
1034
+ /**
1035
+ * `vault anchor start <dir>` (16-06, D-31): record HEAD as the START anchor of
1036
+ * a phase or quick directory, in `<dir>/.vault-anchors.json`
1037
+ * (`{ start, recordedAt }`, written by the seal leaf's `writeDirStartAnchor`).
1038
+ *
1039
+ * Re-running at the SAME HEAD keeps the recorded start (`kept: true`, nothing
1040
+ * written). At any other HEAD it records HEAD as the new start and says which
1041
+ * start it superseded (`replaced`) — 16 review iteration 2 CR-02, D-98, which
1042
+ * reverses D-93's "keep the first start". Keeping it let every record written
1043
+ * after a resume carry the first start, so its range covered a commit made
1044
+ * between the two runs: a claim of governance nothing granted (rule 3). The
1045
+ * superseded start is kept in the directory's history, newest first, and an
1046
+ * END-only record (a linked worktree's, D-59) takes the latest start that
1047
+ * precedes its own end, so a record written before the resume still covers
1048
+ * its own work. Commits made between the last record and the resume are
1049
+ * covered by nothing and the strict gate names them; the phase's own work
1050
+ * among them is repaired with the attributed start back-fill
1051
+ * (`vault anchor backfill <summary> --from <commit>`), never by a silent
1052
+ * widening.
1053
+ *
1054
+ * Exit 0 on every refusal, which travels as `{ ok: false, reason, message }`
1055
+ * (the step prints it and continues; the strict points type a missing
1056
+ * start). An argument error is the only exit 1.
1057
+ */
1058
+ function anchorStartVerb({ cwd, rest, raw, error, c, exec }) {
1059
+ const positionals = [];
1060
+ for (const a of rest) {
1061
+ if (a === '--raw')
1062
+ continue;
1063
+ if (a.startsWith('--')) {
1064
+ error(`Unrecognized flag "${boundedPath(a)}" for vault anchor start. Supported: a positional directory under .planning/, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
643
1065
  return;
644
1066
  }
1067
+ positionals.push(a);
1068
+ }
1069
+ if (positionals.length !== 1) {
1070
+ error('vault anchor start takes exactly one directory. Usage: vault anchor start <phase-or-quick-dir> [--raw].', positionals.length === 0 ? ERROR_REASON.SDK_MISSING_ARG : ERROR_REASON.SDK_FAIL_FAST);
1071
+ return;
645
1072
  }
646
- const phaseArg = auditArgs.find((a, i) => !a.startsWith('--') && !(rangeAt !== -1 && i === rangeAt + 1));
647
- if (phaseArg === undefined) {
648
- error('No phase directory or phase number was given. Usage: vault audit <phase-dir|phase-number> [--range <from>..<to>] [--raw].', ERROR_REASON.SDK_MISSING_ARG);
1073
+ const emit = (result, text) => {
1074
+ c.output(result, true, raw ? JSON.stringify(result) : text);
1075
+ };
1076
+ const located = resolvePlanningPath(cwd, positionals[0], 'dir');
1077
+ if (!located.ok) {
1078
+ emit({ ok: false, action: 'start', reason: located.reason, message: located.message }, `No start anchor was recorded: ${located.message}.`);
649
1079
  return;
650
1080
  }
651
- let root;
1081
+ const head = exec(['rev-parse', 'HEAD'], { cwd });
1082
+ const sha = head.exitCode === 0 ? head.stdout.trim() : '';
1083
+ if (!vaultSeal.ANCHOR_SHA_RE.test(sha)) {
1084
+ const message = 'HEAD could not be read as a full commit id (no commit yet, or git failed)';
1085
+ emit({ ok: false, action: 'start', reason: 'head-unreadable', message }, `No start anchor was recorded: ${message}.`);
1086
+ return;
1087
+ }
1088
+ const file = `${located.rel}/${vaultSeal.VAULT_ANCHORS_FILE}`;
1089
+ const existing = vaultSeal.readDirAnchors(located.abs);
1090
+ if (existing !== null && existing.start === sha) {
1091
+ emit({ ok: true, action: 'start', start: existing.start, recordedAt: existing.recordedAt, file, kept: true }, `Kept the start anchor ${existing.start} already recorded in ${file} (HEAD has not moved since it was recorded).`);
1092
+ return;
1093
+ }
1094
+ let recorded;
652
1095
  try {
653
- root = findProjectRoot(cwd) || cwd;
1096
+ recorded = vaultSeal.writeDirStartAnchor(located.abs, sha);
654
1097
  }
655
1098
  catch {
656
- root = cwd;
1099
+ const message = `${file} could not be written`;
1100
+ emit({ ok: false, action: 'start', reason: 'write-failed', message }, `No start anchor was recorded: ${message}.`);
1101
+ return;
657
1102
  }
658
- const phase = checkRouter.resolveDecisionPhaseDir(root, cwd, phaseArg);
659
- if (!phase) {
660
- const unresolved = {
661
- status: 'unresolved',
662
- block: true,
663
- message: `Vault audit: "${boundedPath(phaseArg)}" is neither an existing directory inside the project nor a `
664
- + 'phase the locator resolves, so no practice was compared against anything. Repair: re-run this '
665
- + 'command with the phase directory or number.',
666
- };
667
- c.output(unresolved, true, raw ? JSON.stringify(unresolved) : unresolved.message);
1103
+ const replaced = existing !== null ? { replaced: existing.start } : {};
1104
+ emit({ ok: true, action: 'start', start: recorded.start, recordedAt: recorded.recordedAt, file, ...replaced }, existing !== null
1105
+ ? `Recorded start anchor ${recorded.start} in ${file}, replacing ${existing.start} as the start of every record written from now on; `
1106
+ + 'the earlier start stays in the directory\'s history for a record that ended before this one.'
1107
+ : `Recorded start anchor ${recorded.start} in ${file}.`);
1108
+ }
1109
+ // ─── 16-19: the certificate writer (D-107, D-108) ────────────────────────────
1110
+ /** How long the strict check `vault anchor certify` runs may take before it is a `check-failed` refusal. */
1111
+ const CERTIFY_CHECK_TIMEOUT_MS = 120_000;
1112
+ /** The most stdout the certify verb reads from its child check (a larger envelope is `check-failed`). */
1113
+ const CERTIFY_CHECK_MAX_BUFFER = 8 * 1024 * 1024;
1114
+ /** At most this many problem kinds, added ends or removed ends travel in one certify refusal. */
1115
+ const CERTIFY_LIST_CAP = 20;
1116
+ /** How much of the child check's stderr a `check-failed` refusal quotes. */
1117
+ const CERTIFY_STDERR_HEAD = 120;
1118
+ /**
1119
+ * `vault anchor certify <phase-dir>` (16 gap closure 16-19, D-107, D-108): the
1120
+ * ONE writer of a phase directory's `.vault-certificates.json`. A certificate
1121
+ * says "a clean strict check of this phase judged this SUMMARY's END, at this
1122
+ * HEAD, at this time"; only a certified phase record bounds a later phase's
1123
+ * SEAL-01 reach (16-18), so the certificate is the recorded fact D-101 said the
1124
+ * gate did not have.
1125
+ *
1126
+ * WHY A CHILD PROCESS. `check vault.trace` is read-only by contract (TRACE-04)
1127
+ * and its handler emits to stdout. Running it as the enforcer does, as
1128
+ * `pp-tools check vault.trace <dir> --unpaired strict --raw`, keeps its exit-0
1129
+ * posture and its envelope exactly what every gate reads, and this verb only
1130
+ * reads that envelope. The engine is the one running (`pp-tools.cjs` beside
1131
+ * this module's directory); the positional is the contained rel, after the
1132
+ * subcommand, never in an option position.
1133
+ *
1134
+ * WHY THE ENDS AND HEAD ARE COLLECTED BEFORE THE SPAWN, AND RE-CHECKED AFTER.
1135
+ * A certificate for an end the check never judged, or stamped with a HEAD the
1136
+ * check did not judge at, is a certificate for a check that never passed: the
1137
+ * shape D-106 closed at the leaf, reopened by its writer. So HEAD is read and
1138
+ * the phase's ended SUMMARY records are collected first, only those ends are
1139
+ * certified, and after the child returns a different HEAD is `head-moved` and a
1140
+ * different set of ends is `records-changed`, each writing nothing. The verb
1141
+ * refuses rather than certifying the intersection, so a concurrent writer is
1142
+ * visible and never silently narrowed; a SUMMARY added while the check ran is
1143
+ * certified by the next clean run.
1144
+ *
1145
+ * WHY THE GIT ENVIRONMENT IS SCRUBBED, FOR THE VERB'S READS AND THE CHILD
1146
+ * ALIKE. A git hook or an agent shell exports the repository pointer
1147
+ * (`GIT_DIR`, `GIT_WORK_TREE`, …), so an inherited value is ordinary, and a
1148
+ * lasting certificate must never describe another repository's HEAD or
1149
+ * verdict. The overlay comes from its one owner, `scrubbedGitEnv` in
1150
+ * `src/vault.cts` (the cache resolver's precedent, 260915-mzx R2): every
1151
+ * inherited `GIT_*` key is `undefined`, which unsets it, because neither
1152
+ * `execGit` nor `spawnSync` passes an undefined value on.
1153
+ *
1154
+ * WHY THE OVERRIDE VARIABLE IS EMPTIED (D-108). An override never certifies:
1155
+ * `checked_with_override`, an envelope carrying `override` and one carrying
1156
+ * `overrideRejected` are each refused, and the child's `VAULT_OVERRIDE_ENV` is
1157
+ * the empty string (which the trace leaf reads as absent), so an operator's
1158
+ * per-run override in the calling shell can never turn a blocking check into a
1159
+ * certificate, whatever a later engine's check does with that variable.
1160
+ *
1161
+ * WHY A QUICK DIRECTORY IS REFUSED (D-104). A quick task's record never bounds
1162
+ * a later phase, so it is never certified (D-113 keeps the leaf from reading
1163
+ * one as certified even if a file named its end).
1164
+ *
1165
+ * A skipped call certifies nothing, which only widens the next phase's reach
1166
+ * and never hides a commit (D-110). Exit 0 on every refusal, which travels as
1167
+ * `{ ok: false, action: 'certify', reason, … }`; an argument error is the only
1168
+ * exit 1. Inert before any argument is read (`routeVaultAnchor`, D-13).
1169
+ */
1170
+ function anchorCertifyVerb({ cwd, rest, raw, error, c, exec }) {
1171
+ const positionals = [];
1172
+ for (const a of rest) {
1173
+ if (a === '--raw')
1174
+ continue;
1175
+ if (a.startsWith('--')) {
1176
+ error(`Unrecognized flag "${boundedPath(a)}" for vault anchor certify. Supported: a positional phase directory under .planning/, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
1177
+ return;
1178
+ }
1179
+ positionals.push(a);
1180
+ }
1181
+ if (positionals.length !== 1) {
1182
+ error('vault anchor certify takes exactly one phase directory. Usage: vault anchor certify <phase-dir> [--raw].', positionals.length === 0 ? ERROR_REASON.SDK_MISSING_ARG : ERROR_REASON.SDK_FAIL_FAST);
1183
+ return;
1184
+ }
1185
+ const emit = (result, text) => {
1186
+ c.output(result, true, raw ? JSON.stringify(result) : text);
1187
+ };
1188
+ const refuse = (reason, message, extra = {}) => {
1189
+ emit({ ok: false, action: 'certify', reason, ...extra, message }, `Nothing was certified: ${message}.`);
1190
+ };
1191
+ const located = resolvePlanningPath(cwd, positionals[0], 'dir');
1192
+ if (!located.ok) {
1193
+ refuse(located.reason, located.message);
1194
+ return;
1195
+ }
1196
+ if (!vaultSeal.isPhaseRecordRel(`${located.rel}/x`)) {
1197
+ refuse('not-a-phase-dir', `${located.rel} is not a phase directory; a quick task's or a door's record never bounds a later phase (D-104), so it is never certified`);
1198
+ return;
1199
+ }
1200
+ const gitEnv = anchorOwner().scrubbedGitEnv(process.env);
1201
+ const readHead = () => {
1202
+ const r = exec(['rev-parse', 'HEAD'], { cwd, env: gitEnv });
1203
+ const sha = r.exitCode === 0 ? r.stdout.trim() : '';
1204
+ return vaultSeal.ANCHOR_SHA_RE.test(sha) ? sha : '';
1205
+ };
1206
+ const head = readHead();
1207
+ if (head === '') {
1208
+ refuse('head-unreadable', 'HEAD could not be read as a full commit id (no commit yet, or git failed)');
1209
+ return;
1210
+ }
1211
+ // A filesystem walk, no git: the ends of the SUMMARY records directly in this
1212
+ // directory, sorted and unique.
1213
+ const collectEnds = () => {
1214
+ const scan = vaultSeal.collectCoveringRecords(located.root);
1215
+ const prefix = `${located.rel}/`;
1216
+ const ends = scan.records
1217
+ .filter((r) => vaultSeal.isSummaryRecordRel(r.rel) && r.rel.startsWith(prefix) && r.rel.indexOf('/', prefix.length) === -1)
1218
+ .map((r) => r.end);
1219
+ return [...new Set(ends)].sort();
1220
+ };
1221
+ const endsBefore = collectEnds();
1222
+ if (endsBefore.length === 0) {
1223
+ const message = `${located.rel} has no SUMMARY with an end anchor, so there is nothing to certify; the strict check was not run`;
1224
+ emit({ ok: false, action: 'certify', reason: 'no-ended-record', message }, `Nothing was certified: ${message}.`);
668
1225
  return;
669
1226
  }
670
- const result = auditMod.runVaultAudit({
671
- root,
672
- phaseDir: phase.abs,
673
- exec: _exec ?? shell_command_projection_cjs_1.execGit,
674
- ...(typeof rangeValue === 'string' ? { range: rangeValue } : {}),
1227
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1228
+ const { VAULT_OVERRIDE_ENV } = require('./vault-trace.cjs');
1229
+ const child = childProcess.spawnSync(process.execPath, [node_path_1.default.join(__dirname, '..', 'pp-tools.cjs'), 'check', 'vault.trace', located.rel, '--unpaired', 'strict', '--raw'], {
1230
+ cwd: located.root,
1231
+ encoding: 'utf8',
1232
+ timeout: CERTIFY_CHECK_TIMEOUT_MS,
1233
+ maxBuffer: CERTIFY_CHECK_MAX_BUFFER,
1234
+ env: { ...process.env, ...gitEnv, [VAULT_OVERRIDE_ENV]: '' },
1235
+ windowsHide: true,
675
1236
  });
676
- // The leaf's object, untouched: the operator view and the gate serialize the
677
- // same envelope, and nothing between the leaf and the screen rebuilds it.
678
- if (raw) {
679
- c.output(result, true, JSON.stringify(result));
1237
+ const stderrHead = boundedPath(String(child.stderr ?? '').slice(0, CERTIFY_STDERR_HEAD));
1238
+ const failed = (why) => {
1239
+ refuse('check-failed', `the strict check of ${located.rel} could not be read (${why})`, { detail: `${why}; stderr: ${stderrHead}` });
1240
+ };
1241
+ if (child.error) {
1242
+ failed(`the check did not run: ${boundedPath(child.error.message).slice(0, CERTIFY_STDERR_HEAD)}`);
1243
+ return;
680
1244
  }
681
- else {
682
- c.output(result, true, auditMod.renderAuditReport(result));
1245
+ if (child.signal) {
1246
+ failed(`the check was ended by ${child.signal}`);
1247
+ return;
1248
+ }
1249
+ if (child.status !== 0) {
1250
+ failed(`the check exited ${child.status}`);
1251
+ return;
683
1252
  }
1253
+ const stdout = String(child.stdout ?? '');
1254
+ if (stdout.trim() === '') {
1255
+ failed('the check printed nothing');
1256
+ return;
1257
+ }
1258
+ let envelope;
1259
+ try {
1260
+ const parsed = JSON.parse(stdout);
1261
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1262
+ failed('the check printed JSON that is not an object');
1263
+ return;
1264
+ }
1265
+ envelope = parsed;
1266
+ }
1267
+ catch {
1268
+ failed('the check printed something that is not JSON');
1269
+ return;
1270
+ }
1271
+ // The verdict gate (D-107, D-108): a clean strict verdict, and nothing else.
1272
+ const has = (k) => Object.prototype.hasOwnProperty.call(envelope, k);
1273
+ const status = typeof envelope.status === 'string' ? boundedPath(envelope.status) : null;
1274
+ const block = typeof envelope.block === 'boolean' ? envelope.block : null;
1275
+ const clean = envelope.status === 'checked' && envelope.block === false && !has('override') && !has('overrideRejected');
1276
+ if (!clean) {
1277
+ const problems = Array.isArray(envelope.problems) ? envelope.problems : [];
1278
+ const kinds = [...new Set(problems
1279
+ .map((p) => (p && typeof p === 'object' ? p.kind : undefined))
1280
+ .filter((k) => typeof k === 'string')
1281
+ .map((k) => boundedPath(k)))].sort().slice(0, CERTIFY_LIST_CAP);
1282
+ const overridden = envelope.status === 'checked_with_override' || has('override');
1283
+ refuse(overridden ? 'overridden' : 'not-clean', `the strict check of ${located.rel} answered ${status ?? 'no status'}, block ${String(block)}`
1284
+ + `${kinds.length > 0 ? ` (${kinds.join(', ')})` : ''}`
1285
+ + `${overridden ? '; an override never certifies (D-108)' : ''}`, { status, block, kinds });
1286
+ return;
1287
+ }
1288
+ // After the child returned clean: the HEAD it judged at, then the ends it judged.
1289
+ const headAfter = readHead();
1290
+ if (headAfter !== head) {
1291
+ refuse('head-moved', `HEAD moved from ${head} to ${headAfter || 'an unreadable HEAD'} while the strict check of ${located.rel} ran; re-run certify`, { head, headAfter: headAfter || null });
1292
+ return;
1293
+ }
1294
+ const endsAfter = collectEnds();
1295
+ const beforeSet = new Set(endsBefore);
1296
+ const afterSet = new Set(endsAfter);
1297
+ const added = endsAfter.filter((e) => !beforeSet.has(e));
1298
+ const removed = endsBefore.filter((e) => !afterSet.has(e));
1299
+ if (added.length > 0 || removed.length > 0) {
1300
+ refuse('records-changed', `the ended records of ${located.rel} changed while its strict check ran (${added.length} added, ${removed.length} removed); re-run certify`, { added: added.slice(0, CERTIFY_LIST_CAP), removed: removed.slice(0, CERTIFY_LIST_CAP) });
1301
+ return;
1302
+ }
1303
+ // Only the ends collected BEFORE the spawn, stamped with the HEAD read before it.
1304
+ const file = `${located.rel}/${vaultSeal.VAULT_CERTIFICATES_FILE}`;
1305
+ const already = new Set(vaultSeal.readDirCertificates(located.abs).map((cert) => cert.end));
1306
+ const fresh = endsBefore.filter((e) => !already.has(e));
1307
+ const old = endsBefore.filter((e) => already.has(e));
1308
+ if (fresh.length > 0) {
1309
+ const at = new Date().toISOString();
1310
+ try {
1311
+ vaultSeal.appendDirCertificates(located.abs, fresh.map((end) => ({ end, head, at })));
1312
+ }
1313
+ catch (e) {
1314
+ refuse('write-failed', `${file} could not be written (${boundedPath(e instanceof Error ? e.message : String(e))})`);
1315
+ return;
1316
+ }
1317
+ }
1318
+ emit({ ok: true, action: 'certify', dir: located.rel, file, head, certified: fresh, alreadyCertified: old, ...(fresh.length === 0 ? { kept: true } : {}) }, fresh.length === 0
1319
+ ? `Kept: every end anchor of ${located.rel} is already certified (${old.length}); nothing written.`
1320
+ : `Certified ${fresh.length} end anchor(s) of ${located.rel} at HEAD ${head} after its strict check passed clean (${old.length} already certified).`);
1321
+ }
1322
+ /**
1323
+ * The text's lines, split on the same boundaries the record parsers split on
1324
+ * (`\r\n`, `\r`, `\n`), with their offsets, so an insertion lands at a line the
1325
+ * parser counted and every other byte stays where it was.
1326
+ */
1327
+ function lineSpans(text) {
1328
+ const spans = [];
1329
+ const re = /\r\n|\r|\n/g;
1330
+ let last = 0;
1331
+ let m;
1332
+ while ((m = re.exec(text)) !== null) {
1333
+ spans.push({ start: last, end: m.index, eol: m[0] });
1334
+ last = m.index + m[0].length;
1335
+ }
1336
+ spans.push({ start: last, end: text.length, eol: '' });
1337
+ return spans;
684
1338
  }
685
- const REVIEW_VERBS = ['scaffold', 'finalize'];
686
- const REVIEW_USAGE = 'vault review scaffold|finalize <phase> [--force] [--raw]';
687
- /** The artifact a review writes in its phase directory: `<NN>-VAULT-REVIEW.md`. */
688
- function reviewArtifactName(phaseToken) {
689
- return `${phaseToken ? `${phaseToken}-` : ''}VAULT-REVIEW.md`;
1339
+ /** Insert `lines` as new lines directly after line `after`, in that line's own line ending. */
1340
+ function insertLinesAfter(text, spans, after, lines) {
1341
+ const eol = spans[after].eol || (spans.find((sp) => sp.eol !== '')?.eol ?? '\n');
1342
+ const at = spans[after].end;
1343
+ return `${text.slice(0, at)}${lines.map((l) => `${eol}${l}`).join('')}${text.slice(at)}`;
690
1344
  }
691
1345
  /**
692
- * `vault review scaffold|finalize <phase> [--force] [--raw]` — the artifact half
693
- * of `/pp-vault-review`. The derivation is `check vault-review`'s, through the
694
- * same exported resolver and composer, so all three see one phase and one
695
- * payload.
696
- *
697
- * - `scaffold` writes `<phase-dir>/<NN>-VAULT-REVIEW.md` from the payload:
698
- * every computed section, every derived row, one `TODO` row per practice
699
- * the agent must judge, and the three computed lines. It does not replace
700
- * an existing review unless `--force` is given.
701
- * - `finalize` re-stamps the rate, counts and coverage lines from the rows the
702
- * agent wrote (the only writer of those lines), writes the file back when
703
- * that changed it, and reports every problem: the validator's (a `TODO`
704
- * left, a missing reason, a reclassified derived row, a score) and the
705
- * population's (a practice dropped, added or duplicated).
706
- *
707
- * Exit 0 on every path and no `block` key on any envelope: the review reports
708
- * and never gates, so a problem is a repair item, never a halt.
1346
+ * `vault anchor backfill <summary> --at <sha>` (16-06, D-32):
1347
+ * give a record written before anchors existed its anchors, ATTRIBUTED. There
1348
+ * is no silent restamp at HEAD: `--at` is required (the operator states which
1349
+ * commit the record's work ended at), it must be an ancestor of HEAD, and the
1350
+ * record gains, beside the anchor, who ran the back-fill (`git config
1351
+ * user.name`, bounded) and when. 16-07 reports a back-filled anchor as a note
1352
+ * on every later check. `--at HEAD` is allowed, and is still attributed.
1353
+ *
1354
+ * The record is a SUMMARY's `## Standards Consulted` section: the anchor line
1355
+ * with its `backfilled by <who> at <iso>` tail is inserted as the section's
1356
+ * last line (no start: the record takes its directory's, D-59).
1357
+ *
1358
+ * Every other byte of the file is kept: the new text is the old one with lines
1359
+ * inserted, and it is parsed back before it is written — the anchors must read
1360
+ * back as written and the rest of the record must parse to exactly what it
1361
+ * parsed to before, or nothing is written. The write is a temporary sibling
1362
+ * renamed into place.
1363
+ *
1364
+ * Refused, writing nothing: a record that already carries an anchor (a valid,
1365
+ * a dropped or a malformed one: `already-anchored`), a file with no
1366
+ * Standards Consulted section (`no-record`), an `--at` that is not an ancestor of HEAD. A
1367
+ * path outside `.planning/`, a malformed sha and `--from`/`--attest` beside
1368
+ * `--at` are argument errors (exit 1), refused before any read or git call;
1369
+ * the state refusals are `ok: false` envelopes (exit 0).
1370
+ *
1371
+ * 16 review iteration 3 CR-01 (D-102): `--at --from <commit>` set a start of
1372
+ * the operator's choosing (the root commit, say), and D-98's `--from` alone
1373
+ * widened an anchored record's start the same way; either covered every commit
1374
+ * in between with one attribution and named none. Both are gone. The one repair
1375
+ * left for a commit a record's range misses is `--from <recorded start>
1376
+ * --attest <commit>` (`attestVerb`), which covers that commit alone.
709
1377
  */
710
- function routeReview({ args, cwd, raw, c, }) {
711
- const say = (envelope, text) => {
712
- c.output(envelope, true, raw ? JSON.stringify(envelope) : text);
1378
+ function anchorBackfillVerb({ cwd, rest, raw, error, c, exec }) {
1379
+ const usage = 'Usage: vault anchor backfill <summary-path> --at <commit> [--raw], or, on a record that carries an end, '
1380
+ + '--from <a start its directory recorded> --attest <commit> [--attest <commit> ...] [--raw].';
1381
+ const positionals = [];
1382
+ const shas = {};
1383
+ const attest = [];
1384
+ for (let i = 0; i < rest.length; i += 1) {
1385
+ const a = rest[i];
1386
+ if (a === '--raw')
1387
+ continue;
1388
+ if (a === '--at' || a === '--from' || a === '--attest') {
1389
+ const v = rest[i + 1];
1390
+ if (v === undefined || v.startsWith('--')) {
1391
+ error(`${a} needs a commit. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
1392
+ return;
1393
+ }
1394
+ if (a === '--attest')
1395
+ attest.push(v);
1396
+ else
1397
+ shas[a] = v;
1398
+ i += 1;
1399
+ continue;
1400
+ }
1401
+ if (a.startsWith('--')) {
1402
+ error(`Unrecognized flag "${boundedPath(a)}" for vault anchor backfill. Supported: --at <commit>, --from <commit>, --attest <commit>, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
1403
+ return;
1404
+ }
1405
+ positionals.push(a);
1406
+ }
1407
+ if (positionals.length !== 1) {
1408
+ error(`vault anchor backfill takes exactly one SUMMARY path. ${usage}`, positionals.length === 0 ? ERROR_REASON.SDK_MISSING_ARG : ERROR_REASON.SDK_FAIL_FAST);
1409
+ return;
1410
+ }
1411
+ if (shas['--at'] === undefined && shas['--from'] === undefined) {
1412
+ error(`vault anchor backfill needs --at <commit>: there is no silent restamp at HEAD; name the commit the record's work ended at. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
1413
+ return;
1414
+ }
1415
+ // 16 review iteration 3 CR-01 (D-102): nothing widens a record's start any
1416
+ // more. `--at` gives a record with no anchor its END (its start is its
1417
+ // directory's, D-59); `--from` only bounds `--attest`, which names each
1418
+ // commit that counts as covered, one by one.
1419
+ if (shas['--at'] !== undefined && (shas['--from'] !== undefined || attest.length > 0)) {
1420
+ error(`--at gives a record its end and nothing else; --from and --attest are for a record that already carries one (they attest single commits, they never widen its range). ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
1421
+ return;
1422
+ }
1423
+ if (shas['--at'] === undefined && (shas['--from'] === undefined || attest.length === 0)) {
1424
+ error(`${shas['--from'] === undefined ? '--attest needs --from <a start the record\'s directory recorded>' : '--from needs at least one --attest <commit>: each commit is attested by its own full id, and nothing else is covered'}. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
1425
+ return;
1426
+ }
1427
+ for (const [flag, value] of [['--at', shas['--at']], ['--from', shas['--from']], ...attest.map((v) => ['--attest', v])]) {
1428
+ if (value !== undefined && !vaultSeal.ANCHOR_SHA_RE.test(value)) {
1429
+ error(`${flag} is not a full lowercase commit id (40 or 64 hex). ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
1430
+ return;
1431
+ }
1432
+ }
1433
+ const located = resolvePlanningPath(cwd, positionals[0], 'file');
1434
+ if (!located.ok) {
1435
+ error(`The SUMMARY path ${located.message}; a back-fill rewrites only a record under .planning/.`, ERROR_REASON.SDK_FAIL_FAST);
1436
+ return;
1437
+ }
1438
+ const at = shas['--at'];
1439
+ const from = shas['--from'];
1440
+ const emit = (result, text) => {
1441
+ c.output(result, true, raw ? JSON.stringify(result) : text);
713
1442
  };
714
- const verb = args[2];
715
- if (!REVIEW_VERBS.includes(verb)) {
716
- const statement = `Vault review: "${boundedPath(typeof verb === 'string' ? verb : '')}" is not a review verb, so nothing was ` +
717
- `reviewed. Usage: ${REVIEW_USAGE}.`;
718
- say({ status: 'usage', statement }, statement);
1443
+ const refuse = (reason, message) => {
1444
+ emit({ ok: false, action: 'backfill', file: located.rel, reason, message }, `No anchor was back-filled: ${message}.`);
1445
+ };
1446
+ const size = node_fs_1.default.statSync(located.abs).size;
1447
+ if (size > vaultSeal.TRACE_ARTIFACT_BYTE_CAP) {
1448
+ refuse('too-large', `${located.rel} is ${size} bytes, above the ${vaultSeal.TRACE_ARTIFACT_BYTE_CAP}-byte read cap the record readers apply`);
719
1449
  return;
720
1450
  }
721
- const force = verb === 'scaffold' && args.includes('--force');
722
- const tokens = args.slice(3).filter((a) => a !== '--raw' && !(force && a === '--force'));
1451
+ const text = node_fs_1.default.readFileSync(located.abs, 'utf8');
723
1452
  // eslint-disable-next-line @typescript-eslint/no-require-imports
724
- const checkRouter = require('./check-command-router.cjs');
725
- const target = checkRouter.resolveVaultReviewTarget(cwd, tokens, REVIEW_USAGE);
726
- if (!target.ok) {
727
- say({ status: target.status, statement: target.statement }, target.statement);
1453
+ const traceLeaf = require('./vault-trace.cjs');
1454
+ const section = vaultStandardsLeaf.parseConsultedSection(text);
1455
+ if (!section.present || section.planId === null) {
1456
+ refuse('no-record', `${located.rel} carries no ${vaultStandardsLeaf.STANDARDS_CONSULTED_HEADING} section to back-fill`);
728
1457
  return;
729
1458
  }
730
- // eslint-disable-next-line @typescript-eslint/no-require-imports
731
- const leaf = require('./vault-review.cjs');
732
- const payload = checkRouter.composeVaultReviewPayload(target.root, target.phase, target.phaseToken);
733
- const name = reviewArtifactName(target.phaseToken);
734
- const file = `${target.phase.rel}/${name}`;
735
- const abs = node_path_1.default.join(target.phase.abs, name);
736
- const partition = (payload.partition ?? {});
737
- const coverage = typeof payload.mechanicalCoverageLine === 'string' ? payload.mechanicalCoverageLine : null;
738
- if (verb === 'scaffold') {
739
- if (node_fs_1.default.existsSync(abs) && !force) {
740
- const statement = `Vault review: ${file} already exists, so it was left as it is. Re-run with --force to replace it ` +
741
- 'with a fresh scaffold, or run vault review finalize to check it.';
742
- say({ status: 'exists', file, statement }, statement);
1459
+ const anchored = section.anchors !== undefined
1460
+ || section.problems.some((p) => p.kind === vaultStandardsLeaf.STANDARDS_PROBLEM_KIND.ANCHOR_MALFORMED);
1461
+ if (at === undefined) {
1462
+ // `--from` + `--attest`: the attestation (16 review iteration 3 CR-01, D-102).
1463
+ if (!anchored) {
1464
+ refuse('at-required', `${located.rel} carries no anchor yet; give it its end with --at <commit> (--from and --attest attest single commits on a record that already carries an end)`);
743
1465
  return;
744
1466
  }
745
- const text = leaf.renderReviewScaffold(payload, {
746
- date: new Date().toISOString().slice(0, 10),
747
- phaseToken: target.phaseToken,
1467
+ attestVerb({
1468
+ cwd, exec, text, located, from, attest, refuse, emit, traceLeaf,
748
1469
  });
749
- node_fs_1.default.writeFileSync(abs, text, 'utf-8');
750
- const toJudge = Array.isArray(partition.remainder) ? partition.remainder.length : 0;
751
- const derived = (Array.isArray(partition.naUniverse) ? partition.naUniverse.length : 0) +
752
- (Array.isArray(partition.naNoFile) ? partition.naNoFile.length : 0);
753
- const statement = `Vault review: wrote ${file} (arm: ${String(payload.arm)}; ${toJudge} practice(s) to judge, ` +
754
- `${derived} derived N-A${payload.refusesCleanRender === true ? '; no clean review may be rendered from it' : ''}).`;
755
- say({ status: 'written', file, arm: payload.arm, toJudge, derived, refusesCleanRender: payload.refusesCleanRender === true, statement }, statement);
756
1470
  return;
757
1471
  }
758
- // finalize
759
- let current;
1472
+ if (anchored) {
1473
+ refuse('already-anchored', `${located.rel} already carries an anchor; a back-fill only gives anchors to a record that never had one (a commit its range misses is attested with --from and --attest)`);
1474
+ return;
1475
+ }
1476
+ // The shas reach git only now, shape-checked, after the subcommand (T-16-21).
1477
+ if (exec(['merge-base', '--is-ancestor', at, 'HEAD'], { cwd }).exitCode !== 0) {
1478
+ refuse('at-not-ancestor', `--at ${at} is not an ancestor of HEAD (or is not in this repository)`);
1479
+ return;
1480
+ }
1481
+ const backfill = { by: traceLeaf.resolveBackfillIdentity(cwd), at: new Date().toISOString() };
1482
+ const spans = lineSpans(text);
1483
+ const live = vaultStandardsLeaf.findLiveSection(text, vaultStandardsLeaf.STANDARDS_CONSULTED_HEADING);
1484
+ const lastBody = live.body[live.body.length - 1];
1485
+ const line = vaultStandardsLeaf.formatStandardsAnchorLine({ start: from ?? null, end: at, backfill });
1486
+ const next = insertLinesAfter(text, spans, lastBody, [line]);
1487
+ const back = vaultStandardsLeaf.parseConsultedSection(next);
1488
+ const before = section;
1489
+ const expected = { ...(from !== undefined ? { start: from } : {}), end: at, backfill };
1490
+ const strip = (p) => JSON.stringify({ ...p, anchors: undefined, headingLine: undefined });
1491
+ const agrees = back.present
1492
+ && JSON.stringify(back.anchors) === JSON.stringify({ end: expected.end, ...(from !== undefined ? { start: from } : {}), backfill })
1493
+ && strip(back) === strip(before);
1494
+ if (!agrees) {
1495
+ refuse('rewrite-unverifiable', `the back-filled section in ${located.rel} would not read back as written, so nothing was written`);
1496
+ return;
1497
+ }
1498
+ const tmp = `${located.abs}.${process.pid}.tmp`;
760
1499
  try {
761
- current = node_fs_1.default.readFileSync(abs, 'utf-8');
1500
+ node_fs_1.default.writeFileSync(tmp, next, 'utf8');
1501
+ node_fs_1.default.renameSync(tmp, located.abs);
762
1502
  }
763
1503
  catch {
764
- const statement = `Vault review: ${file} does not exist, so there is nothing to finalize. Run vault review scaffold first.`;
765
- say({ status: 'absent', file, statement }, statement);
1504
+ try {
1505
+ node_fs_1.default.unlinkSync(tmp);
1506
+ }
1507
+ catch { /* nothing to clean */ }
1508
+ refuse('write-failed', `${located.rel} could not be written`);
766
1509
  return;
767
1510
  }
768
- const stamped = leaf.stampReviewLines(current, { coverageLine: coverage });
769
- if (stamped.stamped && stamped.text !== current)
770
- node_fs_1.default.writeFileSync(abs, stamped.text, 'utf-8');
771
- const result = leaf.validateReviewArtifact(stamped.text, { expectedCoverageLine: coverage });
772
- const problems = [...result.problems, ...leaf.compareReviewPopulation(result.rows, partition)];
773
- const envelope = {
774
- status: problems.length === 0 ? 'valid' : 'problems',
775
- file,
776
- rateLine: result.expectedRateLine,
777
- countsLine: result.expectedCountsLine,
778
- problems,
1511
+ const result = {
1512
+ ok: true,
1513
+ action: 'backfill',
1514
+ file: located.rel,
1515
+ family: 'standards',
1516
+ endCommit: at,
1517
+ ...(from !== undefined ? { startCommit: from } : {}),
1518
+ backfilledBy: backfill.by,
1519
+ backfilledAt: backfill.at,
779
1520
  };
780
- const text = [
781
- `Vault review: ${file}`,
782
- result.expectedRateLine,
783
- result.expectedCountsLine,
784
- ...(problems.length === 0
785
- ? ['No repair items.']
786
- : [
787
- `Repair items (${problems.length}) — fix them in the artifact, then run vault review finalize again:`,
788
- ...problems.map((p) => `- ${p.kind}${p.id ? ` (${p.id})` : ''}${p.line ? `, line ${p.line}` : ''}: ${p.detail}`),
789
- ]),
790
- 'This review reports and does not gate.',
791
- ].join('\n');
792
- say(envelope, text);
1521
+ emit(result, `Back-filled ${located.rel}: end ${at}${from !== undefined ? `, start ${from}` : ''}, by ${backfill.by} at ${backfill.at}.`);
1522
+ }
1523
+ /**
1524
+ * The attestation (16 review iteration 3 CR-01, D-102; replaces D-98's start
1525
+ * back-fill): `vault anchor backfill <summary> --from <start> --attest <commit>
1526
+ * [--attest <commit> ...]` on a record that already carries an end.
1527
+ *
1528
+ * D-98 records a new directory start on every resume, so the phase's own
1529
+ * commits made between its last record and the resume are covered by nothing,
1530
+ * and the strict gate names them. Whether such a commit is the phase's work or
1531
+ * an ungoverned one is not something the engine can tell. D-98's repair widened
1532
+ * the record's START, which covered every commit in between — an ungoverned one
1533
+ * made by someone else between the runs included — and left a note naming none
1534
+ * of them. An attestation widens nothing: each `--attest` names ONE commit, and
1535
+ * that commit alone counts as covered by the record. Every later strict check
1536
+ * lists it, with its subject and paths and who attested it. A commit nobody
1537
+ * attested stays named, and stays a block.
1538
+ *
1539
+ * Refused, writing nothing (each an `ok: false` envelope):
1540
+ * - a record with no valid end (`at-required`, `no-end`) or a malformed anchor;
1541
+ * - `--from` that is not a start the record's own directory recorded
1542
+ * (`vault anchor start` writes them, D-98 keeps the superseded ones), so no
1543
+ * attestation reaches below where the CLI saw this phase begin
1544
+ * (`from-not-recorded`), or that is not an ancestor of the record's end;
1545
+ * - an `--attest` that is not on the record's own (first-parent) line between
1546
+ * `--from` and its end, a merge commit, a commit touching no source path, one
1547
+ * the record already covers, one already attested on it, or given twice.
1548
+ *
1549
+ * The attestations are written to `<dir>/.vault-attestations.json` (the seal
1550
+ * leaf's `appendDirAttestations`), never into the record: the record's bytes,
1551
+ * and the range they state, do not change. `by` is the git `user.name` of
1552
+ * whoever ran the command — what the CLI can read, not a verified identity —
1553
+ * and the envelope says so (`byVerified: false`).
1554
+ */
1555
+ function attestVerb({ cwd, exec, text, located, from, attest, refuse, emit, traceLeaf }) {
1556
+ const dirAbs = node_path_1.default.dirname(located.abs);
1557
+ const recordName = node_path_1.default.basename(located.abs);
1558
+ const kind = vaultSeal.RECORDED_ARTIFACT_KIND.STANDARDS_CONSULTED;
1559
+ const isAncestor = (a, d) => vaultSeal.isAnchorSha(a) && vaultSeal.isAnchorSha(d)
1560
+ && exec(['merge-base', '--is-ancestor', a, d], { cwd }).exitCode === 0;
1561
+ const anchors = vaultSeal.resolveRecordAnchors(text, kind, dirAbs, { isAncestor });
1562
+ if (anchors.problems.some((p) => p.kind === 'anchor_invalid')) {
1563
+ refuse('anchors-malformed', `an anchor of ${located.rel} is malformed or was dropped; restore the value the CLI wrote before attesting a commit on it`);
1564
+ return;
1565
+ }
1566
+ const end = anchors.end;
1567
+ if (end === undefined) {
1568
+ refuse('no-end', `${located.rel} carries no valid end anchor; give it one with --at <commit> first`);
1569
+ return;
1570
+ }
1571
+ const dir = vaultSeal.readDirAnchors(dirAbs);
1572
+ const recorded = dir === null ? [] : [dir.start, ...(dir.earlier ?? []).map((e) => e.start)];
1573
+ if (!recorded.includes(from)) {
1574
+ refuse('from-not-recorded', `--from ${from} is not a start this record's directory recorded (${recorded.length} recorded, by vault anchor start); `
1575
+ + 'an attestation reaches back only to a start the CLI recorded for this phase, never to an arbitrary commit');
1576
+ return;
1577
+ }
1578
+ if (from === end || !isAncestor(from, end)) {
1579
+ refuse('from-not-ancestor', `--from ${from} is not an ancestor of the record's end ${end}`);
1580
+ return;
1581
+ }
1582
+ const cap = vaultSeal.SEAL_CANDIDATE_CAP;
1583
+ const lineRun = exec(['rev-list', '--first-parent', '--parents', `--max-count=${cap + 1}`, `^${from}`, end], { cwd });
1584
+ const rows = lineRun.exitCode === 0 ? lineRun.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).map((l) => l.split(/\s+/)) : null;
1585
+ if (rows === null || !rows.every((r) => r.every((x) => vaultSeal.isAnchorSha(x)))) {
1586
+ refuse('git-failed', 'the commits between --from and the record\'s end could not be listed');
1587
+ return;
1588
+ }
1589
+ if (rows.length > cap) {
1590
+ refuse('too-many', `more than ${cap} commits lie between --from and the record's end`);
1591
+ return;
1592
+ }
1593
+ const onLine = new Map(rows.map((r) => [r[0], r.length - 1]));
1594
+ let own = new Set();
1595
+ if (anchors.start !== undefined && isAncestor(anchors.start, end)) {
1596
+ const ownRun = exec(['rev-list', '--first-parent', `--max-count=${cap + 1}`, `^${anchors.start}`, end], { cwd });
1597
+ if (ownRun.exitCode !== 0) {
1598
+ refuse('git-failed', 'the commits the record already covers could not be listed');
1599
+ return;
1600
+ }
1601
+ own = new Set(ownRun.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean));
1602
+ }
1603
+ const already = new Set(vaultSeal.attestedFor(vaultSeal.readDirAttestations(dirAbs), recordName).map((a) => a.sha));
1604
+ const by = traceLeaf.resolveBackfillIdentity(cwd);
1605
+ const at = new Date().toISOString();
1606
+ const entries = [];
1607
+ for (const sha of attest) {
1608
+ if (entries.some((e) => e.sha === sha)) {
1609
+ refuse('attest-duplicate', `--attest ${sha} was given twice`);
1610
+ return;
1611
+ }
1612
+ if (!onLine.has(sha)) {
1613
+ refuse('attest-not-in-range', `--attest ${sha} is not on ${located.rel}'s own line between --from ${from} and its end ${end}`);
1614
+ return;
1615
+ }
1616
+ if (onLine.get(sha) > 1) {
1617
+ refuse('attest-merge', `--attest ${sha} is a merge commit; attest the commits it brought in, each by its own id`);
1618
+ return;
1619
+ }
1620
+ if (own.has(sha)) {
1621
+ refuse('attest-already-covered', `--attest ${sha} is already inside ${located.rel}'s own range; it needs no attestation`);
1622
+ return;
1623
+ }
1624
+ if (already.has(sha)) {
1625
+ refuse('attest-already-attested', `--attest ${sha} is already attested on ${located.rel}`);
1626
+ return;
1627
+ }
1628
+ const tree = exec(['diff-tree', '-r', '-z', '--root', '--no-commit-id', '--name-only', '--no-renames', sha], { cwd });
1629
+ const subj = exec(['log', '-1', '--format=%s', sha], { cwd });
1630
+ if (tree.exitCode !== 0 || subj.exitCode !== 0) {
1631
+ refuse('git-failed', `the paths or subject of ${sha} could not be read`);
1632
+ return;
1633
+ }
1634
+ const paths = tree.stdout.split('\0').filter((p) => p !== '' && !vaultSeal.isNonSourcePath(p));
1635
+ if (paths.length === 0) {
1636
+ refuse('attest-not-source', `--attest ${sha} touches no governed source path, so no check names it and there is nothing to attest`);
1637
+ return;
1638
+ }
1639
+ const flat = (v, n) => {
1640
+ const f = v.replace(/[\u0000-\u001F\u007F-\u009F]+/g, ' ').replace(/\s+/g, ' ').trim();
1641
+ return f.length > n ? `${f.slice(0, n)}…` : f;
1642
+ };
1643
+ entries.push({
1644
+ record: recordName,
1645
+ from,
1646
+ sha,
1647
+ subject: flat(subj.stdout, vaultSeal.SEAL_SUBJECT_CAP),
1648
+ paths: paths.slice(0, vaultSeal.ATTESTATION_PATHS_CAP).map((p) => flat(p, 1000)),
1649
+ by,
1650
+ at,
1651
+ });
1652
+ }
1653
+ try {
1654
+ vaultSeal.appendDirAttestations(dirAbs, entries);
1655
+ }
1656
+ catch (e) {
1657
+ refuse('write-failed', `${vaultSeal.VAULT_ATTESTATIONS_FILE} next to ${located.rel} could not be written (${boundedPath(e instanceof Error ? e.message : String(e))})`);
1658
+ return;
1659
+ }
1660
+ const listed = entries.map((e) => ({ sha: e.sha, subject: e.subject, paths: e.paths }));
1661
+ emit({
1662
+ ok: true,
1663
+ action: 'attest',
1664
+ file: located.rel,
1665
+ family: 'standards',
1666
+ from,
1667
+ endCommit: end,
1668
+ attested: listed,
1669
+ attestedBy: by,
1670
+ byVerified: false,
1671
+ attestedAt: at,
1672
+ }, `Attested ${entries.length} commit${entries.length === 1 ? '' : 's'} on ${located.rel}, by ${by} (the git user.name, not verified) at ${at}: `
1673
+ + `${entries.map((e) => `${e.sha.slice(0, 12)} "${e.subject}" (${e.paths.join(', ')})`).join('; ')}. Every later strict check lists each one.`);
1674
+ }
1675
+ /** The longest free-text value a door record line carries (the reason-clause bound, with room). */
1676
+ const DOOR_TEXT_MAX = 190;
1677
+ /**
1678
+ * A free-text value made safe for a door record line: the vault leaf's display
1679
+ * sanitiser (controls, bidi, zero-width), backticks turned into
1680
+ * apostrophes, bounded. Empty or the literal `none` becomes `unknown`, because
1681
+ * `none` is how a door record spells an absent value.
1682
+ */
1683
+ function doorText(value, opts = {}) {
1684
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1685
+ const indexLeaf = require('./vault-index.cjs');
1686
+ let t = indexLeaf.sanitiseVaultText(typeof value === 'string' ? value : '', DOOR_TEXT_MAX).replace(/`/g, "'").trim();
1687
+ if (opts.token === true)
1688
+ t = t.replace(/\s+/g, '_');
1689
+ return t === '' || t === 'none' ? 'unknown' : t;
1690
+ }
1691
+ /**
1692
+ * The vault coordinates a door worked under, or the degraded one-liner saying
1693
+ * why there are none (D-12 "or") — read by the standards block verb's own pin
1694
+ * reader, `standardsLoadedPin` over the manifest of the one resolved cache; no
1695
+ * loaded pin records the degraded line `vault standards block` would write.
1696
+ *
1697
+ * The repo has already been through `redactCloneTarget` (`readManifest`), so
1698
+ * no credential reaches the record.
1699
+ */
1700
+ function doorVaultPin(cwd, owner) {
1701
+ const cacheDir = owner.resolveVaultCacheDir(cwd).dir;
1702
+ const pin = vaultStandardsLeaf.standardsLoadedPin(owner.readManifest(cacheDir));
1703
+ if (pin !== null && vaultSeal.ANCHOR_SHA_RE.test(pin.sha)) {
1704
+ return { vault: { repo: doorText(pin.repo, { token: true }), ref: doorText(pin.ref, { token: true }), sha: pin.sha }, degraded: null };
1705
+ }
1706
+ const line = vaultStandardsLeaf.standardsDegradedLineFor('not-loaded');
1707
+ return { vault: null, degraded: doorText(`${line.kind}: ${line.reason}`) };
1708
+ }
1709
+ /**
1710
+ * Where a door artifact may live: a phase directory (root or workstream), a
1711
+ * quick-task directory, or `.planning/audits`. Those are exactly the
1712
+ * directories the seal readers walk (`hasRecordedVaultArtifacts`, 16-07's
1713
+ * covering-record walk), so a record written anywhere else would be a record
1714
+ * no reader ever finds: the door would believe it was recorded and nothing
1715
+ * would see it (D-55).
1716
+ */
1717
+ function isDoorArtifactLocation(rel) {
1718
+ const parts = rel.split('/');
1719
+ if (parts[0] !== '.planning')
1720
+ return false;
1721
+ if (parts.length === 4 && (parts[1] === 'phases' || parts[1] === 'quick'))
1722
+ return true;
1723
+ if (parts.length === 6 && parts[1] === 'workstreams' && parts[3] === 'phases')
1724
+ return true;
1725
+ return parts.length === 3 && parts[1] === 'audits';
1726
+ }
1727
+ /**
1728
+ * `vault door-record --begin --door <name>` (16 review CR-01): record HEAD as
1729
+ * the door's START in `.planning/.vault-door-starts.json` (the seal leaf's
1730
+ * `writeDoorStart`), before the door's agent runs. The record step then reads
1731
+ * it, so the start survives the agent's fresh shell per step and is never
1732
+ * typed. Called after admission (`on`) and the door-name check; takes no other
1733
+ * flag. Every refusal is an error (exit 1), like the record itself: a door that
1734
+ * cannot capture its start halts before its agent writes anything.
1735
+ */
1736
+ function doorBegin({ cwd, door, extra, raw, error, c, exec, usage }) {
1737
+ if (extra.length > 0) {
1738
+ error(`--begin takes only --door; ${extra.join(', ')} belong to the record step. ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
1739
+ return;
1740
+ }
1741
+ const root = projectRootOf(cwd);
1742
+ const planning = node_path_1.default.join(root, '.planning');
1743
+ try {
1744
+ if (!node_fs_1.default.statSync(planning).isDirectory())
1745
+ throw new Error('not a directory');
1746
+ }
1747
+ catch {
1748
+ error('The project has no .planning/ directory, so the door start has nowhere to be recorded.', ERROR_REASON.SDK_FAIL_FAST);
1749
+ return;
1750
+ }
1751
+ const head = exec(['rev-parse', 'HEAD'], { cwd });
1752
+ const sha = head.exitCode === 0 ? head.stdout.trim() : '';
1753
+ if (!vaultSeal.ANCHOR_SHA_RE.test(sha)) {
1754
+ error('HEAD could not be read as a full commit id (no commit yet, or git failed), so no door start was recorded.', ERROR_REASON.SDK_FAIL_FAST);
1755
+ return;
1756
+ }
1757
+ let recorded;
1758
+ try {
1759
+ recorded = vaultSeal.writeDoorStart(planning, door, sha);
1760
+ }
1761
+ catch {
1762
+ error(`.planning/${vaultSeal.VAULT_DOOR_STARTS_FILE} could not be written, so no door start was recorded.`, ERROR_REASON.UNKNOWN);
1763
+ return;
1764
+ }
1765
+ const file = `.planning/${vaultSeal.VAULT_DOOR_STARTS_FILE}`;
1766
+ const result = { ok: true, action: 'begin', door, start: recorded.start, recordedAt: recorded.recordedAt, file };
1767
+ c.output(result, true, raw ? JSON.stringify(result) : `Recorded the ${door} door's start ${recorded.start.slice(0, 12)} in ${file}.`);
1768
+ }
1769
+ /**
1770
+ * `vault door-record --door <name> --artifact <path> [--phase <N|dir>]`
1771
+ * (16-06, D-12), after `vault door-record --begin --door <name>` (16 review
1772
+ * CR-01): the ONE verb that writes a door's record, a mode-stamped
1773
+ * `## Vault Door Record` section (`formatDoorRecord`, the seal leaf's grammar)
1774
+ * appended to the door's own artifact.
1775
+ *
1776
+ * The record carries the mode, the vault ref and sha (or the degraded
1777
+ * one-liner), the governing phase, the START commit the door captured before
1778
+ * its agent ran, the END commit (HEAD now) and when it was written. The start
1779
+ * is the one the door's `--begin` recorded in `.planning/`, and nothing else
1780
+ * (16 review iteration 3 CR-02, D-103: an explicit `--start` was a side door
1781
+ * that widened a record to any ancestor of HEAD with no note); nothing in the
1782
+ * record is typed by an agent (the
1783
+ * Phase 15 D-06 lesson), and no shell variable has to survive between the
1784
+ * door's steps.
1785
+ *
1786
+ * ORDER: admission first and silent (D-13: no output, no file, unless the
1787
+ * producer admission verdict answers `on` — the one every vault record producer
1788
+ * asks, quick 261015-ect QD-10: the cwd's own verdict except in a linked
1789
+ * worktree with no bindings of its own, where it is the owning checkout's; the
1790
+ * door fences probe the cwd's bindings first and never reach here there), then
1791
+ * the arguments, the door name, the artifact
1792
+ * (containment ladder, `.planning/` only, a seal-walked directory, a door
1793
+ * artifact filename), the start (anchor shape BEFORE it reaches git, then an
1794
+ * ancestor of HEAD, with the revision after the subcommand, T-16-21), the
1795
+ * phase, HEAD, the pin.
1796
+ *
1797
+ * APPEND, NEVER REWRITE: existing content is kept byte for byte and the section
1798
+ * goes after it; an absent artifact is created with a one-line header. An
1799
+ * artifact that already carries a door record is REFUSED rather than given a
1800
+ * second one, because two sections are a duplicate the reader rejects, which
1801
+ * would leave the door with no readable record at all. The composed text is
1802
+ * read back with `parseDoorRecord` before anything is written, so the verb can
1803
+ * never write a record its own reader refuses (an unclosed fence above the
1804
+ * section, say), and the write is a temporary sibling renamed into place.
1805
+ *
1806
+ * Every refusal is an error (exit 1): a door that cannot record halts its
1807
+ * fence instead of passing unrecorded.
1808
+ */
1809
+ function doorRecordVerb({ cwd, rest, raw, error, c, exec }) {
1810
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1811
+ const owner = require('./vault.cjs');
1812
+ if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
1813
+ return;
1814
+ const usage = 'Usage: vault door-record --begin --door <name> [--raw], then vault door-record --door <name> --artifact <path under .planning/> [--phase <N|dir>] [--raw].';
1815
+ const values = {};
1816
+ let begin = false;
1817
+ for (let i = 0; i < rest.length; i += 1) {
1818
+ const a = rest[i];
1819
+ if (a === '--raw')
1820
+ continue;
1821
+ if (a === '--begin') {
1822
+ begin = true;
1823
+ continue;
1824
+ }
1825
+ if (DOOR_RECORD_FLAGS.includes(a)) {
1826
+ const v = rest[i + 1];
1827
+ if (v === undefined || v.startsWith('--')) {
1828
+ error(`${a} needs a value. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
1829
+ return;
1830
+ }
1831
+ if (values[a] !== undefined) {
1832
+ error(`${a} was given more than once. ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
1833
+ return;
1834
+ }
1835
+ values[a] = v;
1836
+ i += 1;
1837
+ continue;
1838
+ }
1839
+ error(`Unrecognized argument "${boundedPath(a)}" for vault door-record. Supported: --begin, ${DOOR_RECORD_FLAGS.map((f) => `${f} <value>`).join(', ')}, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
1840
+ return;
1841
+ }
1842
+ if (values['--door'] === undefined) {
1843
+ error(`vault door-record needs --door. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
1844
+ return;
1845
+ }
1846
+ const door = values['--door'];
1847
+ if (!vaultSeal.DOOR_NAMES.includes(door)) {
1848
+ error(`"${boundedPath(door)}" is not a door that writes a record. Allowed doors: ${vaultSeal.DOOR_NAMES.join(', ')}.`, ERROR_REASON.SDK_FAIL_FAST);
1849
+ return;
1850
+ }
1851
+ if (begin) {
1852
+ doorBegin({ cwd, door, extra: Object.keys(values).filter((k) => k !== '--door'), raw, error, c, exec, usage });
1853
+ return;
1854
+ }
1855
+ if (values['--artifact'] === undefined) {
1856
+ error(`vault door-record needs --artifact. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
1857
+ return;
1858
+ }
1859
+ const located = resolvePlanningPath(cwd, values['--artifact'], 'artifact');
1860
+ if (!located.ok) {
1861
+ error(`The door artifact ${located.message}; a door record is written only into a door's own artifact under .planning/.`, ERROR_REASON.SDK_FAIL_FAST);
1862
+ return;
1863
+ }
1864
+ if (!isDoorArtifactLocation(located.rel)) {
1865
+ error(`The door artifact "${boundedPath(located.rel)}" is not in a phase directory, a quick-task directory or .planning/audits, ` +
1866
+ 'the directories the seal readers walk; a record anywhere else would never be read.', ERROR_REASON.SDK_FAIL_FAST);
1867
+ return;
1868
+ }
1869
+ // 16 review IN-02: the readers still recognise a `-VALIDATION.md` door
1870
+ // artifact (a legacy record stays readable), but the verb never WRITES one:
1871
+ // a `*-VALIDATION.md` file makes a phase read as validated (D-79 (3)).
1872
+ const artifactName = node_path_1.default.basename(located.abs);
1873
+ if (!vaultSeal.DOOR_ARTIFACT_FILENAME_RE.test(artifactName) || /(^|-)VALIDATION\.md$/.test(artifactName)) {
1874
+ error(`The door artifact "${boundedPath(artifactName)}" does not end in -REVIEW-FIX.md, -ADD-TESTS.md or -AUDIT-FIX.md, ` +
1875
+ 'the door artifact names the verb writes (a -VALIDATION.md file would make the phase read as validated).', ERROR_REASON.SDK_FAIL_FAST);
1876
+ return;
1877
+ }
1878
+ // The start is the one this door's `--begin` recorded in `.planning/`, and
1879
+ // only that (16 review iteration 3 CR-02, D-103: `--start` is gone), so no
1880
+ // shell variable has to survive between the two steps, the agent never types
1881
+ // a sha, and no argument can move the start. It reaches git only after it has
1882
+ // the anchor shape, and only AFTER the subcommand and its flag (T-16-21).
1883
+ const recorded = vaultSeal.readDoorStart(node_path_1.default.join(located.root, '.planning'), door);
1884
+ if (recorded === null) {
1885
+ error(`No start was recorded for the ${door} door, so its range is unknown and the record was not written. `
1886
+ + `Run \`pp-tools vault door-record --begin --door ${door}\` before the door's agent runs; a start recorded now covers only commits made after it.`, ERROR_REASON.SDK_FAIL_FAST);
1887
+ return;
1888
+ }
1889
+ const start = recorded.start;
1890
+ if (exec(['merge-base', '--is-ancestor', start, 'HEAD'], { cwd }).exitCode !== 0) {
1891
+ error(`The start the ${door} door's --begin recorded (${start}) is not an ancestor of HEAD (or is not in this repository), so it cannot be the commit this door started from. `
1892
+ + `Run \`pp-tools vault door-record --begin --door ${door}\` again, then redo the door's work.`, ERROR_REASON.SDK_FAIL_FAST);
1893
+ return;
1894
+ }
1895
+ const headRun = exec(['rev-parse', 'HEAD'], { cwd });
1896
+ const end = headRun.exitCode === 0 ? headRun.stdout.trim() : '';
1897
+ if (!vaultSeal.ANCHOR_SHA_RE.test(end)) {
1898
+ error('HEAD could not be read as a full commit id, so the record has no end commit and was not written.', ERROR_REASON.SDK_FAIL_FAST);
1899
+ return;
1900
+ }
1901
+ let phase = null;
1902
+ if (values['--phase'] !== undefined) {
1903
+ const resolved = owner.resolveTracePhaseDir(located.root, cwd, values['--phase']);
1904
+ if (!resolved) {
1905
+ error(`--phase "${boundedPath(values['--phase'])}" is neither a phase directory inside the project nor a phase the locator resolves.`, ERROR_REASON.SDK_FAIL_FAST);
1906
+ return;
1907
+ }
1908
+ phase = doorText(resolved.rel, { token: true });
1909
+ }
1910
+ if (!owner.isVaultGateActive(cwd)) {
1911
+ error('The vault is off for this project, so there is no vault record to write.', ERROR_REASON.SDK_FAIL_FAST);
1912
+ return;
1913
+ }
1914
+ // The record grammar keeps its `mode` field; standards is the only mode.
1915
+ const mode = 'standards';
1916
+ const pin = doorVaultPin(cwd, owner);
1917
+ let existing = '';
1918
+ if (node_fs_1.default.existsSync(located.abs)) {
1919
+ const size = node_fs_1.default.statSync(located.abs).size;
1920
+ if (size > vaultSeal.TRACE_ARTIFACT_BYTE_CAP) {
1921
+ error(`The door artifact is ${size} bytes, above the ${vaultSeal.TRACE_ARTIFACT_BYTE_CAP}-byte read cap the seal readers apply; a record there would never be read.`, ERROR_REASON.SDK_FAIL_FAST);
1922
+ return;
1923
+ }
1924
+ existing = node_fs_1.default.readFileSync(located.abs, 'utf8');
1925
+ const prior = vaultSeal.parseDoorRecord(existing);
1926
+ if (!prior.ok || prior.record !== null) {
1927
+ error(`The door artifact "${boundedPath(located.rel)}" already carries a ${vaultSeal.DOOR_RECORD_HEADING} section; a second one would make both unreadable. ` +
1928
+ 'Write the door\'s artifact afresh for this run, then record it.', ERROR_REASON.SDK_FAIL_FAST);
1929
+ return;
1930
+ }
1931
+ }
1932
+ let section;
1933
+ try {
1934
+ section = vaultSeal.formatDoorRecord({
1935
+ door: door,
1936
+ mode,
1937
+ vault: pin.vault,
1938
+ degraded: pin.degraded,
1939
+ phase,
1940
+ startCommit: start,
1941
+ endCommit: end,
1942
+ recordedAt: new Date().toISOString(),
1943
+ });
1944
+ }
1945
+ catch (e) {
1946
+ error(`The door record could not be composed: ${boundedPath(e instanceof Error ? e.message : String(e))}`, ERROR_REASON.UNKNOWN);
1947
+ return;
1948
+ }
1949
+ const separator = existing === '' ? '' : existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
1950
+ const next = existing === '' ? `# ${door} door record\n\n${section}` : `${existing}${separator}${section}`;
1951
+ const readBack = vaultSeal.parseDoorRecord(next);
1952
+ if (!readBack.ok || readBack.record === null || readBack.record.startCommit !== start || readBack.record.endCommit !== end) {
1953
+ error(`The door record would not read back from "${boundedPath(located.rel)}" (an unclosed code fence or comment above it, for example), so nothing was written.`, ERROR_REASON.SDK_FAIL_FAST);
1954
+ return;
1955
+ }
1956
+ // D-100 (16 review iteration 2 WR-01): consume the `--begin` start BEFORE the
1957
+ // record is written, after every refusal above. A start that cannot be
1958
+ // consumed refuses the record; a record whose write then fails leaves the
1959
+ // door with no start, so a retry halts naming `--begin` — both arms leave
1960
+ // commits uncovered (named by SEAL-01), never a range wider than one run.
1961
+ {
1962
+ let consumed = false;
1963
+ try {
1964
+ consumed = vaultSeal.consumeDoorStart(node_path_1.default.join(located.root, '.planning'), door, start);
1965
+ }
1966
+ catch {
1967
+ consumed = false;
1968
+ }
1969
+ if (!consumed) {
1970
+ error(`The ${door} door's start could not be consumed from .planning/${vaultSeal.VAULT_DOOR_STARTS_FILE} (it changed or could not be rewritten), so the record was not written. `
1971
+ + `Run \`pp-tools vault door-record --begin --door ${door}\` again, then redo the door's work; a start recorded now covers only commits made after it.`, ERROR_REASON.SDK_FAIL_FAST);
1972
+ return;
1973
+ }
1974
+ }
1975
+ const tmp = `${located.abs}.${process.pid}.tmp`;
1976
+ try {
1977
+ node_fs_1.default.writeFileSync(tmp, next, 'utf8');
1978
+ node_fs_1.default.renameSync(tmp, located.abs);
1979
+ }
1980
+ catch {
1981
+ try {
1982
+ node_fs_1.default.unlinkSync(tmp);
1983
+ }
1984
+ catch { /* nothing to clean */ }
1985
+ error(`The door artifact "${boundedPath(located.rel)}" could not be written.`, ERROR_REASON.UNKNOWN);
1986
+ return;
1987
+ }
1988
+ const result = { ok: true, door, artifact: located.rel, mode, phase, startCommit: start, endCommit: end };
1989
+ c.output(result, true, raw ? JSON.stringify(result) : `Recorded the ${door} door in ${located.rel} (${start.slice(0, 12)}..${end.slice(0, 12)}).`);
793
1990
  }
794
1991
  module.exports = {
795
1992
  routeVaultCommand,
1993
+ // 13-06: exported so the D-15 sweep test and the D-08 subcommand test read
1994
+ // the vocabulary from the running module instead of restating it.
796
1995
  VAULT_SUBCOMMANDS,
797
- // The scaffold's plan-identifier shape and its one wording, exported so a
798
- // trace gate's filename extractor and the planner reference are checked
799
- // against the running module rather than a restated copy.
1996
+ // 04-14: exported so the agreement and documentation tests read the SHAPE and
1997
+ // the WORDING from the running module rather than restating either. A test
1998
+ // that restated them would pass while the module drifted.
800
1999
  SCAFFOLD_PLAN_ID_RE,
801
2000
  SCAFFOLD_PLAN_ID_FORM,
802
- TRACE_STAGES,
2001
+ // 16-05 (D-29): the quick-task half both rejection messages append.
2002
+ SCAFFOLD_QUICK_ID_FORM,
2003
+ // 16-06: the anchor verbs, read by the tests from the running module.
2004
+ ANCHOR_VERBS,
2005
+ // 15-07: exported so the integration test pins it equal to the trace gate's
2006
+ // artifact read cap, which is the agreement its own comment states.
2007
+ STANDARDS_PLAN_READ_BYTES,
803
2008
  };