@pareto4/pp-core 1.13.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/.claude-plugin/plugin.json +3 -3
  2. package/README.md +26 -9
  3. package/agents/pp-code-reviewer.md +2 -1
  4. package/agents/pp-debug-session-manager.md +9 -6
  5. package/agents/pp-executor.md +10 -13
  6. package/agents/pp-plan-checker.md +4 -4
  7. package/agents/pp-planner.md +23 -9
  8. package/agents/pp-vault-reviewer.md +244 -21
  9. package/agents/pp-verifier.md +7 -3
  10. package/bin/install.js +10 -3
  11. package/commands/pp-execute-phase.md +1 -3
  12. package/commands/pp-ns-review.md +1 -1
  13. package/commands/pp-plan-phase.md +1 -2
  14. package/commands/pp-vault-review.md +5 -3
  15. package/commands/pp-verify-work.md +1 -2
  16. package/gemini-extension.json +1 -1
  17. package/hooks/dist/pp-check-update.js +0 -0
  18. package/hooks/dist/pp-cursor-session-start.js +1 -1
  19. package/hooks/dist/pp-statusline.js +1 -1
  20. package/hooks/dist/pp-update-banner.js +0 -0
  21. package/hooks/pp-check-update.js +0 -0
  22. package/hooks/pp-cursor-session-start.js +1 -1
  23. package/hooks/pp-statusline.js +1 -1
  24. package/hooks/pp-update-banner.js +0 -0
  25. package/package.json +14 -19
  26. package/pareto-skills/_shared/create-project-board.sh +0 -0
  27. package/pareto-skills/pp-project-setup/SKILL.md +1 -1
  28. package/pp-core/bin/check-latest-version.cjs +0 -0
  29. package/pp-core/bin/detect-build-commands.sh +2 -2
  30. package/pp-core/bin/lib/capability-activation.cjs +83 -9
  31. package/pp-core/bin/lib/capability-registry.cjs +396 -82
  32. package/pp-core/bin/lib/capability-state.cjs +160 -4
  33. package/pp-core/bin/lib/capability-writer.cjs +19 -5
  34. package/pp-core/bin/lib/check-command-router.cjs +3334 -946
  35. package/pp-core/bin/lib/clusters.cjs +3 -0
  36. package/pp-core/bin/lib/command-aliases.cjs +0 -35
  37. package/pp-core/bin/lib/commands.cjs +9 -4
  38. package/pp-core/bin/lib/compose-bindings.cjs +251 -88
  39. package/pp-core/bin/lib/config-loader.cjs +7 -103
  40. package/pp-core/bin/lib/config-schema.cjs +1 -1
  41. package/pp-core/bin/lib/config.cjs +154 -62
  42. package/pp-core/bin/lib/decisions.cjs +4 -4
  43. package/pp-core/bin/lib/discovery-vault-check.cjs +246 -0
  44. package/pp-core/bin/lib/frontmatter.cjs +1 -38
  45. package/pp-core/bin/lib/git-base-branch.cjs +13 -11
  46. package/pp-core/bin/lib/git-provider.cjs +4 -4
  47. package/pp-core/bin/lib/init-command-router.cjs +5 -8
  48. package/pp-core/bin/lib/init-contract.cjs +648 -738
  49. package/pp-core/bin/lib/init-interactive.cjs +76 -4
  50. package/pp-core/bin/lib/init.cjs +24 -51
  51. package/pp-core/bin/lib/install-profiles.cjs +1 -1
  52. package/pp-core/bin/lib/io.cjs +8 -0
  53. package/pp-core/bin/lib/loop-resolver.cjs +189 -129
  54. package/pp-core/bin/lib/org-profile.cjs +469 -152
  55. package/pp-core/bin/lib/package-identity.cjs +3 -3
  56. package/pp-core/bin/lib/phase-command-router.cjs +1 -29
  57. package/pp-core/bin/lib/phase-scope.cjs +1 -1
  58. package/pp-core/bin/lib/phase.cjs +11 -253
  59. package/pp-core/bin/lib/platform-bindings.cjs +40 -19
  60. package/pp-core/bin/lib/practices-review-check.cjs +15 -9
  61. package/pp-core/bin/lib/profile-output.cjs +1 -1
  62. package/pp-core/bin/lib/shell-command-projection.cjs +104 -1
  63. package/pp-core/bin/lib/state.cjs +11 -85
  64. package/pp-core/bin/lib/template.cjs +1 -1
  65. package/pp-core/bin/lib/vault-audit.cjs +650 -126
  66. package/pp-core/bin/lib/vault-command-router.cjs +2604 -367
  67. package/pp-core/bin/lib/vault-coordinates.cjs +13 -4
  68. package/pp-core/bin/lib/vault-index.cjs +699 -0
  69. package/pp-core/bin/lib/vault-mode.cjs +235 -0
  70. package/pp-core/bin/lib/vault-practices.cjs +139 -72
  71. package/pp-core/bin/lib/vault-review.cjs +194 -451
  72. package/pp-core/bin/lib/vault-seal.cjs +1936 -0
  73. package/pp-core/bin/lib/vault-standards.cjs +1505 -0
  74. package/pp-core/bin/lib/vault-trace.cjs +607 -254
  75. package/pp-core/bin/lib/vault-update.cjs +9 -33
  76. package/pp-core/bin/lib/vault-write-back.cjs +70 -42
  77. package/pp-core/bin/lib/vault.cjs +2734 -920
  78. package/pp-core/bin/lib/verify.cjs +0 -11
  79. package/pp-core/bin/portable-timeout.sh +1 -1
  80. package/pp-core/bin/pp-tools.cjs +184 -54
  81. package/pp-core/bin/shared/config-defaults.manifest.json +0 -7
  82. package/pp-core/bin/shared/config-schema.manifest.json +0 -3
  83. package/pp-core/bin/shared/model-catalog.json +2 -2
  84. package/pp-core/bin/verify-reapply-patches.cjs +0 -0
  85. package/pp-core/org-profile.json +26 -0
  86. package/pp-core/references/autonomous-best-effort.md +8 -7
  87. package/pp-core/references/loop-hook-dispatch.md +0 -25
  88. package/pp-core/references/planner-gap-closure.md +0 -1
  89. package/pp-core/references/planner-tdd.md +0 -1
  90. package/pp-core/references/planner-vault-practices.md +17 -34
  91. package/pp-core/references/planner-vault-standards.md +320 -0
  92. package/pp-core/references/planning-config.md +0 -8
  93. package/pp-core/references/practice-arithmetic-contract.md +121 -0
  94. package/pp-core/references/tdd.md +0 -1
  95. package/pp-core/references/verifier-practice-verdicts.md +263 -0
  96. package/pp-core/templates/README.md +1 -0
  97. package/pp-core/templates/VAULT-REVIEW.md +202 -0
  98. package/pp-core/templates/phase-prompt.md +2 -6
  99. package/pp-core/templates/practices/README.md +20 -23
  100. package/pp-core/workflows/_runtime-launcher.snippet.sh +0 -0
  101. package/pp-core/workflows/add-tests.md +55 -4
  102. package/pp-core/workflows/audit-fix.md +63 -2
  103. package/pp-core/workflows/audit-milestone.md +1 -1
  104. package/pp-core/workflows/autonomous.md +85 -11
  105. package/pp-core/workflows/check-todos.md +13 -2
  106. package/pp-core/workflows/code-review-fix.md +59 -3
  107. package/pp-core/workflows/code-review.md +10 -1
  108. package/pp-core/workflows/complete-milestone.md +1 -1
  109. package/pp-core/workflows/debug.md +54 -4
  110. package/pp-core/workflows/discovery-sprint/prefill.md +19 -7
  111. package/pp-core/workflows/discovery-sprint/prototype.md +46 -49
  112. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  113. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +1 -3
  114. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +1 -3
  115. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +1 -3
  116. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +20 -24
  117. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +1 -3
  118. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +1 -3
  119. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +1 -3
  120. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +1 -3
  121. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +1 -3
  122. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +1 -6
  123. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +1 -3
  124. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +1 -3
  125. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +1 -3
  126. package/pp-core/workflows/discovery-sprint/templates/context-template.md +1 -1
  127. package/pp-core/workflows/discovery-sprint/vault-context.md +213 -0
  128. package/pp-core/workflows/discovery-sprint.md +7 -3
  129. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +4 -4
  130. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +43 -1
  131. package/pp-core/workflows/discovery-to-build.md +10 -2
  132. package/pp-core/workflows/discuss-phase.md +3 -10
  133. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +329 -0
  134. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +3 -2
  135. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +1 -1
  136. package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +98 -0
  137. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +94 -0
  138. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +6 -8
  139. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +115 -14
  140. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +322 -20
  141. package/pp-core/workflows/execute-phase/steps/verifier-practice-payload.md +165 -0
  142. package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +163 -0
  143. package/pp-core/workflows/execute-phase.md +155 -128
  144. package/pp-core/workflows/execute-plan.md +56 -6
  145. package/pp-core/workflows/fast.md +53 -4
  146. package/pp-core/workflows/help/modes/full.md +3 -3
  147. package/pp-core/workflows/init/steps/client-context-capture.md +101 -0
  148. package/pp-core/workflows/init/steps/gitlab-provisioning.md +130 -0
  149. package/pp-core/workflows/init/steps/project-md-deferral-narration.md +66 -0
  150. package/pp-core/workflows/init/steps/reachability-outcome.md +70 -0
  151. package/pp-core/workflows/init.md +232 -272
  152. package/pp-core/workflows/new-project.md +31 -23
  153. package/pp-core/workflows/plan-phase.md +98 -45
  154. package/pp-core/workflows/quick.md +117 -3
  155. package/pp-core/workflows/secure-phase.md +1 -1
  156. package/pp-core/workflows/ship.md +57 -28
  157. package/pp-core/workflows/undo.md +1 -1
  158. package/pp-core/workflows/update.md +3 -3
  159. package/pp-core/workflows/validate-phase.md +58 -3
  160. package/pp-core/workflows/vault-review.md +282 -22
  161. package/pp-core/workflows/verify-work.md +12 -14
  162. package/scripts/changeset/cli.cjs +6 -5
  163. package/scripts/changeset/lint.cjs +0 -0
  164. package/scripts/changeset/new.cjs +33 -50
  165. package/scripts/dev-env.cjs +25 -85
  166. package/scripts/gen-capability-registry.cjs +199 -11
  167. package/scripts/gen-practice-arithmetic-contract.cjs +412 -0
  168. package/scripts/lib/allowlist-ratchet.cjs +1 -103
  169. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
  170. package/scripts/lint-docs-required.cjs +0 -0
  171. package/scripts/lint-pr-check-project-dir.cjs +1 -3
  172. package/scripts/lint-test-file-count.allowlist.json +12 -10
  173. package/scripts/practice-arithmetic-contract.allow.json +35 -0
  174. package/scripts/prompt-injection-scan.sh +22 -17
  175. package/scripts/record-vault-mode-golden.cjs +89 -0
  176. package/scripts/release-channel.cjs +0 -0
  177. package/scripts/sync-manifest-versions.cjs +5 -4
  178. package/scripts/sync-rulesets.sh +1 -1
  179. package/scripts/update-size-baseline.cjs +30 -274
  180. package/scripts/vault-calibrate.cjs +996 -0
  181. package/scripts/verify-opengsd-sweep.cjs +3 -5
  182. package/scripts/workflow-size.cjs +9 -61
  183. package/pp-core/bin/lib/dispatch-plan.cjs +0 -483
  184. package/pp-core/bin/lib/install-run.cjs +0 -536
  185. package/pp-core/bin/lib/loop-gates.cjs +0 -213
  186. package/pp-core/bin/lib/phase-manifest.cjs +0 -527
  187. package/pp-core/bin/lib/phase-tier.cjs +0 -480
  188. package/pp-core/bin/lib/plan-graph.cjs +0 -368
  189. package/pp-core/references/executor-vault-trace.md +0 -47
  190. package/pp-core/references/planner-plan-graph.md +0 -66
  191. package/pp-core/workflows/discovery-sprint/vault-fetch.md +0 -37
  192. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +0 -45
  193. package/pp-core/workflows/execute-phase/steps/install-run.md +0 -29
  194. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +0 -23
  195. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +0 -73
  196. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +0 -19
  197. package/pp-core/workflows/plan-phase/steps/phase-tier.md +0 -43
  198. package/scripts/lib/size-budget.cjs +0 -311
  199. package/scripts/lint-neutrality-names.cjs +0 -694
  200. package/scripts/lint-org-coordinates.allowlist.json +0 -35
  201. 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,186 @@ 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): the trace render arm resolves its containment boundary the same
43
+ // 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 trace verbs, 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).
51
- */
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
- ]);
64
- /**
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`).
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
+ 'practices',
72
+ 'org-profile',
73
+ 'trace',
74
+ // 05-06 (CHECK-03): the mechanical practice audit. Added HERE and nowhere
75
+ // else — the header comment above, the dispatch chain and the
76
+ // unknown-subcommand message all derive from this list, which is exactly the
77
+ // three-place drift 02-02 removed.
78
+ 'audit',
79
+ // 260915-ik2 (UAT-6-F1): the phase-manifest writer the execute-phase step
80
+ // files call — `start` at execute:pre, `record` at verify:post. Added HERE
81
+ // and nowhere else, for the same three-place reason as `audit` above.
82
+ 'manifest',
83
+ // PBI 74316 (fork-only): move the project to another client vault version.
84
+ 'update',
85
+ // 15-07 (D-06, D-13): the standards record verbs, `block` and `consulted`.
86
+ 'standards',
87
+ // 16-04 (D-28): the one door-admission verb every door and loop-point fence asks.
88
+ 'admission',
89
+ // 16-06 (D-31, D-32): the start anchor a phase or quick directory records at
90
+ // execute:pre, and the attributed back-fill of a record written before anchors.
91
+ 'anchor',
92
+ // 16-06 (D-12): the one verb that writes a door's mode-stamped record.
93
+ 'door-record',
94
+ ];
95
+ /**
96
+ * The two trace stages, declared once for the same reason the subcommand
97
+ * vocabulary is: the `--stage` validation and the operator-facing message that
98
+ * names the permitted values are both DERIVED from this list, so they cannot
99
+ * disagree. The values themselves are the frozen contract's
100
+ * (`docs/vault-trace-format.md` § The freeze contract) — this list restates the
101
+ * two stage NAMES the CLI accepts, and nothing else about the schema.
69
102
  */
70
103
  const TRACE_STAGES = ['plan', 'execute'];
104
+ /**
105
+ * The trace verbs, same discipline (Phase 03-05).
106
+ */
71
107
  const TRACE_VERBS = ['scaffold', 'render'];
72
108
  /**
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.
109
+ * The manifest verbs, same discipline (260915-ik2): the verb check and the
110
+ * operator-facing message that names the permitted verbs both derive from it.
111
+ */
112
+ const MANIFEST_VERBS = ['start', 'record'];
113
+ /**
114
+ * The standards record verbs (15-07), same discipline: the verb check and the
115
+ * message naming the permitted verbs both derive from this list.
116
+ */
117
+ const STANDARDS_VERBS = ['block', 'consulted'];
118
+ /**
119
+ * The anchor verbs (16-06), same discipline: the verb check and the message
120
+ * naming the permitted verbs both derive from this list.
121
+ */
122
+ const ANCHOR_VERBS = ['start', 'backfill', 'certify'];
123
+ /** The flags `vault door-record` takes, declared once for the check and the message (16-06). */
124
+ // 16 review iteration 3 CR-02 (D-103): no `--start`. The start comes only from
125
+ // the door's own `--begin` entry, so no argument can widen a door's range.
126
+ const DOOR_RECORD_FLAGS = ['--door', '--artifact', '--phase'];
127
+ /**
128
+ * The read cap `vault standards consulted` applies to a PLAN. EQUAL to the
129
+ * trace gate's `TRACE_ARTIFACT_BYTE_CAP` in src/check-command-router.cts, so
130
+ * the verb never refuses a plan the gate would read, nor reads one the gate
131
+ * refuses. That constant is not importable from here (an adapter never
132
+ * imports an adapter), so the integration test pins the two equal.
133
+ */
134
+ const STANDARDS_PLAN_READ_BYTES = 1024 * 1024;
135
+ /**
136
+ * The plan-identifier shape `vault trace scaffold --plan` accepts (04-14,
137
+ * TGATE-01, CR-03). Defined in `src/vault-standards.cts` since 15-05 and
138
+ * re-exported from here under the same name; the rationale stays here, beside
139
+ * the verb that first needed it.
140
+ *
141
+ * ONE FORM, READ AND WRITTEN. `filenamePlanId` in `src/check-command-router.cts`
142
+ * extracts `<this form>-PLAN.md` / `-SUMMARY.md` from an artifact's basename,
143
+ * building its pattern from this very value, and blocks when the identifier it
144
+ * extracts disagrees with the one the record carries. So the writer can emit
145
+ * every identifier the reader can demand (the trap CR-03 reproduced, where
146
+ * `--plan 01` for `07-01-PLAN.md` blocked at all four points and the remedy
147
+ * re-ran the same command), and the reader checks every form the writer emits.
148
+ * Until 15-REVIEW WR-06 the reader stopped at digits-hyphen-digits, so a
149
+ * decimal (`02.3-01-PLAN.md`, bug #3691) or lettered phase stated no
150
+ * identifier and its plan-id check was silently skipped; the writer was
151
+ * deliberately wider then, so as not to refuse a form the gate accepted
152
+ * (threat T-4-53). The shape is the plan-identifier grammar
153
+ * `agents/pp-planner.md` and `src/phase-id.cts` use: a phase segment (digits,
154
+ * an optional letter, optional decimal parts) followed by one or more numeric
155
+ * segments.
156
+ *
157
+ * What it rejects is what CR-03 actually named, and nothing more: a value that
158
+ * is not a plan identifier at all — a bare plan number (`01`), a task label
159
+ * (`T1`), a slugged identifier (`04-01-vault-trace`), or an empty string. Every
160
+ * one of those, written into a canonically named artifact, produces
161
+ * `plan_id_mismatch`.
162
+ *
163
+ * The agreement test in `tests/vault-command-router.test.cjs` and the gate-side
164
+ * half in `tests/vault-trace.integration.test.cjs` drive both sides over one
165
+ * shared value list, so an independent edit to either cannot pass.
82
166
  */
83
- const SCAFFOLD_PLAN_ID_RE = /^\d+[A-Za-z]?(?:\.\d+)*(?:-\d+)+$/;
167
+ const SCAFFOLD_PLAN_ID_RE = vaultStandardsLeaf.SCAFFOLD_PLAN_ID_RE;
84
168
  /**
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.
169
+ * The required form, in ONE wording, stated in exactly three places: this
170
+ * rejection message, `pp-core/references/planner-vault-practices.md`, and the
171
+ * planner-facing inline instruction in
172
+ * `capabilities/vault-practices/capability.json` (04-14 T2).
173
+ *
174
+ * It is a constant rather than three sentences because a form stated three ways
175
+ * is three forms (threat T-4-55). The test asserts the sentence extracted
176
+ * independently from each document is EQUAL to the other's and to this value,
177
+ * rather than asserting each document merely mentions a form.
178
+ *
179
+ * ASCII only, and no backticks: the same bytes have to survive a JSON string, a
180
+ * markdown paragraph and a terminal, and a backtick would make the markdown
181
+ * copy differ from the other two.
88
182
  */
89
183
  const SCAFFOLD_PLAN_ID_FORM = "The plan identifier must be the plan's phase-and-number identifier, exactly the " +
90
184
  'prefix of its artifact filename (for example 04-02), not a bare plan number, a task ' +
91
185
  'label or a slug.';
92
186
  /**
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.
187
+ * The quick-task half of the form (16-05, D-29), appended to both rejection
188
+ * messages after `SCAFFOLD_PLAN_ID_FORM`.
189
+ *
190
+ * A SECOND constant rather than an edit to the first, and argued: the first is
191
+ * stated verbatim in three places, and two of them are agent context the
192
+ * golden set records byte for byte (the `vault-practices` plan:pre inline
193
+ * instruction in three cells) or that sits under an exhausted size budget
194
+ * (`agents/pp-executor.md`). Rewording it would re-record golden rows Phase 16
195
+ * may not move. Its sentence is also already true of a quick task — "exactly
196
+ * the prefix of its artifact filename" — so only the refusal, the one surface
197
+ * a caller reads after being told no, needs to name the quick form.
198
+ */
199
+ const SCAFFOLD_QUICK_ID_FORM = 'A quick task uses its quick-task id instead, the same filename prefix (for example 261011-8yt).';
200
+ /**
201
+ * Bound on the bounded prefix read the `trace render` arm performs. The trace
202
+ * lives in the artifact's FRONTMATTER, so a head read is not a compromise — it
203
+ * is the exact region the parser needs, and a SUMMARY.md body of any size can
204
+ * never make this arm read more.
96
205
  */
97
206
  const TRACE_ARTIFACT_READ_BYTES = 262_144;
98
207
  /** Repo-relative artifact paths, long enough for a workstream phase tree. */
99
208
  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
209
  /**
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.
210
+ * Bound and control-character-collapse a caller-supplied path before it reaches
211
+ * a refusal message (T-03-53). The message reaches an operator's terminal and,
212
+ * on the workflow path, an agent's context — so a hostile path must not be able
213
+ * to forge what looks like an additional line of output, nor run to arbitrary
214
+ * length. Mirrors `sanitizeTraceText` in src/check-command-router.cts; that one
215
+ * also redacts remotes because it quotes ARTIFACT text, while this one only ever
216
+ * quotes the argv value the operator just typed.
107
217
  */
108
218
  function boundedPath(value) {
109
219
  const collapsed = value
@@ -117,16 +227,94 @@ function boundedPath(value) {
117
227
  : collapsed;
118
228
  }
119
229
  /**
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.
230
+ * The gate an override recorded by the scaffold arm suppresses (04-06, SC4).
231
+ *
232
+ * `vault.status` is not a name invented here: it is the `check.query` the
233
+ * blocking vault gate is declared under at both write points
234
+ * (`capabilities/vault/capability.json`), and it is the value the freeze
235
+ * contract's own worked example carries (`docs/vault-trace-format.md`
236
+ * § The override object). Declared once so the emitted record and the
237
+ * documented example cannot drift apart. Its one owner is the standards leaf
238
+ * (15 D-35), because the standards judgement accepts an override line only
239
+ * under this gate name, and a second literal could drift from the first.
240
+ */
241
+ const TRACE_OVERRIDE_GATE = vaultStandardsLeaf.STANDARDS_OVERRIDE_GATE;
242
+ /**
243
+ * A `gitCommit` digest: lowercase hex at sha1 or sha256 length. 16-06 (D-35)
244
+ * re-binds it to the seal leaf's `ANCHOR_SHA_RE`: a per-file digest and a
245
+ * record's start/end anchors are the same shape, so they have one owner. The
246
+ * local name stays, as an alias, so this file's readers keep their wording.
247
+ */
248
+ const GIT_COMMIT_DIGEST_RE = vaultSeal.ANCHOR_SHA_RE;
249
+ // Default CoreModule implementation. _core seam overrides this for test injection.
250
+ const _defaultCore = { output: io.output };
251
+ function tracePracticesActive(cwd) {
252
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
253
+ const { isPracticesGateActive } = require('./vault.cjs');
254
+ return isPracticesGateActive(cwd);
255
+ }
256
+ /**
257
+ * 16-09 (SEAL-02, D-39) — the `vault audit` verb's answer when the practices
258
+ * switch is off, or `null` for "print nothing" (the pre-16-09 behaviour).
259
+ *
260
+ * It asks what `check vault.audit` asks, in the gate's order (D-25), through
261
+ * the same readers: bindings first (no `.platform-bindings.json` never holds
262
+ * records, D-37/D-42); standards mode active answers nothing (the C-1 cell);
263
+ * the phase argument must resolve (the same `resolveTracePhaseDir`, so an
264
+ * argument error still prints nothing, T-03-43); then the leaf's
265
+ * `collectRecordedArtifacts` and `disabledWithArtifactsVerdict`, whose message
266
+ * is the gate's message byte for byte. Which switch is off is decided by the
267
+ * gate readers, never a raw config read. It blocks nothing itself: an operator
268
+ * surface, exit 0. There is no override.
269
+ *
270
+ * From the REAL leaf, not the `_vault` seam: a governance decision must not be
271
+ * switchable by a test mock (the `tracePracticesActive` rule above).
125
272
  */
126
- function tracePracticesActive(cfgGet, cwd) {
127
- return cfgGet(cwd, 'vault.enabled', false) === true && cfgGet(cwd, 'vault.practices_enabled', false) === true;
273
+ function disabledWithArtifactsRefusal(cwd, args) {
274
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
275
+ const v = require('./vault.cjs');
276
+ let root;
277
+ try {
278
+ root = findProjectRoot(cwd) || cwd;
279
+ }
280
+ catch {
281
+ root = cwd;
282
+ }
283
+ if (!v.hasPlatformBindings(root))
284
+ return null;
285
+ if (v.isStandardsModeActive(cwd))
286
+ return null;
287
+ // The audit arm's own positional rule (below): the first token that is not a
288
+ // flag and not the `--range` value.
289
+ const auditArgs = args.slice(2);
290
+ const rangeAt = auditArgs.indexOf('--range');
291
+ const phaseArg = auditArgs.find((a, i) => !a.startsWith('--') && !(rangeAt !== -1 && i === rangeAt + 1));
292
+ if (phaseArg === undefined)
293
+ return null;
294
+ const phase = v.resolveTracePhaseDir(root, cwd, phaseArg);
295
+ if (!phase)
296
+ return null;
297
+ const whichSwitch = v.isVaultGateActive(root) ? 'vault.practices_enabled' : 'vault.enabled';
298
+ let mode;
299
+ if (whichSwitch === 'vault.practices_enabled') {
300
+ const m = v.vaultModeVerdict(cwd);
301
+ if (m.active && m.ok === true && m.mode === 'practices')
302
+ mode = 'practices';
303
+ }
304
+ const verdict = vaultSeal.disabledWithArtifactsVerdict(vaultSeal.collectRecordedArtifacts(phase.abs, { root }), {
305
+ switch: whichSwitch,
306
+ ...(mode ? { mode } : {}),
307
+ phaseRel: phase.rel,
308
+ });
309
+ if (!verdict)
310
+ return null;
311
+ return { status: 'disabled_with_artifacts', block: true, present: true, ...verdict };
128
312
  }
129
- /** The value token after each of `flags`, by index, so a flag's value is never read as a positional. */
313
+ /**
314
+ * The value token immediately following each of `flags`, by index, so a
315
+ * flag's value is never mistaken for a positional argument. Mirrors the
316
+ * `--expect` handling in the `practices promote` arm.
317
+ */
130
318
  function flagValueIndices(argv, flags) {
131
319
  const indices = new Set();
132
320
  for (const flag of flags) {
@@ -148,15 +336,26 @@ function flagValueIndices(argv, flags) {
148
336
  function renderOrgProfileReport(report) {
149
337
  const lines = [];
150
338
  if (report.pointer.value === null) {
151
- lines.push('No org profile is configured (org.profile is unset) — pp-core\'s built-in defaults apply.');
339
+ lines.push(report.profile.source === 'embedded'
340
+ ? 'No org profile package is configured (org.profile is unset) — the embedded org profile applies.'
341
+ : 'No org profile is configured (org.profile is unset) — pp-core\'s built-in defaults apply.');
342
+ }
343
+ else if (report.pointer.value.trim().toLowerCase() === 'none') {
344
+ lines.push('org.profile is "none" — no org profile applies, not even the embedded one; pp-core\'s built-in defaults apply.');
152
345
  }
153
346
  else {
154
347
  lines.push(`org.profile is set to "${report.pointer.value}".`);
155
348
  }
156
- if (report.profile.resolved) {
349
+ if (report.profile.resolved && report.profile.source === 'embedded' && report.pointer.value !== null) {
350
+ lines.push(`The org profile "${report.pointer.value}" could not be resolved — the embedded org profile (pp-core ${report.profile.version ?? 'unknown'}) applies instead.`);
351
+ }
352
+ else if (report.profile.resolved && report.profile.source === 'embedded') {
353
+ lines.push(`Using the embedded org profile shipped with pp-core ${report.profile.version ?? 'unknown'}.`);
354
+ }
355
+ else if (report.profile.resolved) {
157
356
  lines.push(`Resolved org profile "${report.profile.name}" version ${report.profile.version ?? 'unknown'} from ${report.profile.source}.`);
158
357
  }
159
- else if (report.pointer.value !== null) {
358
+ else if (report.pointer.value !== null && report.pointer.value.trim().toLowerCase() !== 'none') {
160
359
  lines.push(`The org profile "${report.pointer.value}" could not be resolved from the project or the global npm root.`);
161
360
  }
162
361
  const contributesKeys = Object.keys(report.contributes);
@@ -166,7 +365,8 @@ function renderOrgProfileReport(report) {
166
365
  else {
167
366
  lines.push('The org profile contributes:');
168
367
  for (const key of contributesKeys) {
169
- lines.push(` ${key} = ${report.contributes[key]}`);
368
+ const value = report.contributes[key];
369
+ lines.push(` ${key} = ${value === '' ? '"" (explicitly none)' : value}`);
170
370
  }
171
371
  }
172
372
  if (report.overridden.length > 0) {
@@ -192,37 +392,124 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
192
392
  const vault = _vault ?? require('./vault.cjs');
193
393
  const c = _core ?? _defaultCore;
194
394
  const subcommand = args[1];
395
+ // 13-06 (D-08, D-22): an unrecognised `vault.mode` on a project whose vault
396
+ // gate exists refuses EVERY known subcommand, here, before dispatch. One owner
397
+ // for ten arms rather than ten copies that a new arm could forget; the test
398
+ // `D-15 sweep` in tests/vault-mode.test.cjs fails if the chain below and
399
+ // VAULT_SUBCOMMANDS ever disagree, so no arm can dodge this guard.
400
+ // - Keyed on VAULT_SUBCOMMANDS: an unknown subcommand never reads the mode
401
+ // and keeps its own message below.
402
+ // - The read is a config read; its one possible spawn is loadConfig's memoised, fixed-argument
403
+ // git check-ignore, so T-24-08b's typo never reaches the `--changed` git call. On an invalid-mode
404
+ // project this refusal now precedes an arm's unknown-flag error.
405
+ // - `vaultModeVerdict` answers `{ active: false }` without reading the mode
406
+ // when the gate is off, so a vault-off project is byte-identical (golden).
407
+ // - From the REAL leaf, not the `_vault` seam, like `isInsideDir` and
408
+ // `resolveTracePhaseDir` below: a governance decision must not be
409
+ // switchable by a test mock.
410
+ // - The message is the leaf's fixed text: it names both allowed values and
411
+ // never echoes the configured one (T-13-03).
412
+ // - Quick 261015-ect (QD-11): only where `.platform-bindings.json` exists. A
413
+ // project without it never reads the mode (T-16-13): admission's own step
414
+ // 1, read the same way, and the order every `check` vault handler keeps
415
+ // (bindings-inert first, D-22). The 1.16.0 release note stated the old
416
+ // order as measured; this reverses it, and closes `/pp-fast`'s backstop,
417
+ // which asks `vault admission` with no bindings probe of its own.
418
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
419
+ const modeSeam = require('./vault.cjs');
420
+ if (VAULT_SUBCOMMANDS.includes(subcommand) && modeSeam.hasPlatformBindings(cwd)) {
421
+ const modeVerdict = modeSeam.vaultModeVerdict(cwd);
422
+ if (modeVerdict.active && modeVerdict.ok === false) {
423
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
424
+ const leaf = require('./vault-mode.cjs');
425
+ // 13-REVIEW WR-01: a resolver failure is refused like an invalid value,
426
+ // under its own typed reason.
427
+ const reason = modeVerdict.reason ?? '';
428
+ error(leaf.vaultModeRefusalMessage(reason), reason === leaf.VAULT_MODE_REASON.UNRESOLVED ? ERROR_REASON.VAULT_MODE_UNRESOLVED : ERROR_REASON.VAULT_MODE_INVALID);
429
+ return;
430
+ }
431
+ }
195
432
  if (subcommand === 'fetch') {
196
433
  c.output(vault.vaultFetch(cwd), raw);
197
434
  }
198
435
  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);
436
+ // 13-06 (D-10): `mode: standards` LAST, in standards mode only; the same
437
+ // object otherwise, so a practices-mode envelope gains no key (golden).
438
+ // 14-05 (D-11/D-14): the same five flat fields `check vault.status` carries,
439
+ // from the one state function, so the report and the gate cannot tell
440
+ // different stories (UAT F6). From the REAL module (`modeSeam`), not the
441
+ // injectable `vault` seam: a governance answer must not be switchable by a
442
+ // test mock (13-06 precedent). No `block` and no `reason`: this is a report.
443
+ // The state is null outside standards mode, so practices gains no key.
444
+ const base = vault.vaultStatus(cwd);
445
+ const indexState = modeSeam.computeVaultIndexState(cwd);
446
+ const withIndex = indexState !== null && base['present'] === true
447
+ ? { ...base, ...modeSeam.vaultIndexEnvelopeFields(indexState) }
448
+ : base;
449
+ c.output(modeSeam.withVaultMode(withIndex, cwd), raw);
204
450
  }
205
451
  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, '');
452
+ // STRICT-02: pure reachability for the client vault, the shared fallback and
453
+ // the platform repo — no clone, no cache mutation.
454
+ //
455
+ // T-02-07: reject any unrecognized `--` token BEFORE anything spawns, copying
456
+ // the `practices match` posture below verbatim. A typo must never reach a
457
+ // subprocess. The host (pp-core/bin/pp-tools.cjs) already splices out its own
458
+ // global flags before dispatch, which is why the recognized set is one wide.
459
+ const RECOGNIZED_PROBE_FLAGS = new Set(['--raw']);
460
+ const unrecognizedProbeFlag = args.slice(2).find((a) => a.startsWith('--') && !RECOGNIZED_PROBE_FLAGS.has(a));
461
+ if (unrecognizedProbeFlag !== undefined) {
462
+ error(`Unrecognized flag "${unrecognizedProbeFlag}" for vault probe. Supported: --raw.`, ERROR_REASON.SDK_FAIL_FAST);
463
+ return;
464
+ }
465
+ // The probe's ls-remote names its remote by URL, but git still reads the config of the repository the
466
+ // environment selects (measured: `protocol.file.allow=never` in a pointed-to repository's config made the
467
+ // bare runner's ls-remote fail with `transport 'file' not allowed`, while the same call with the location
468
+ // unset answered), so it runs through the owner; transport is kept (D-117).
469
+ c.output(vault.vaultProbe(cwd, { exec: _exec ?? shell_command_projection_cjs_1.execGitLocal }), raw);
470
+ }
471
+ else if (subcommand === 'summary') {
472
+ // STRICT-05 (02-02): read the STATUS, not the bare string. The write failure
473
+ // used to be swallowed inside vaultSummary, so this arm could only ever print
474
+ // success. It still exits 0 on every path — per D-VH-7 the vault and check
475
+ // commands REPORT and the workflow halts; making this exit non-zero would
476
+ // route the block into the step's `onError: skip` and reopen the fail-open.
477
+ const summary = vault.vaultSummaryResult(cwd);
478
+ if (!summary.ok) {
479
+ // Warning to stderr so the success-path stdout stays byte-identical to
480
+ // before this plan for every healthy project.
481
+ process.stderr.write(`⚠ vault summary: could not write ${summary.path ?? '.planning/vault/SUMMARY.md'} — ${summary.error ?? 'unknown error'}\n`);
210
482
  }
211
- else if (raw) {
212
- c.output(result, true, JSON.stringify(result));
483
+ if (raw) {
484
+ c.output(summary, true, JSON.stringify(summary));
213
485
  }
214
486
  else {
215
- c.output(result, true, `${result.line}\n`);
487
+ c.output(summary.index, false);
216
488
  }
217
489
  }
218
- else if (subcommand === 'summary') {
219
- c.output(vault.vaultSummary(cwd), raw);
220
- }
221
490
  else if (subcommand === 'practices') {
222
491
  const verb = args[2];
223
492
  if (verb === 'match') {
493
+ // Quick 261015-at3 (QD-02, todo practices-record-verbs-print-without-
494
+ // bindings): unless `vault admission` answers `on` this arm prints
495
+ // nothing, FIRST — before the flag guard and before the mode read. It was
496
+ // gated by no switch at all (measured 261014-na1: it printed a block on
497
+ // `vault.enabled: false`), so a record no gate reads could be produced on
498
+ // a project whose `check ship.practices` answers inert.
499
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
500
+ const owner = require('./vault.cjs');
501
+ if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
502
+ return;
503
+ // Quick 261015-ect (QD-14, at3 verifier W-1): in practices mode, silent
504
+ // where `check ship.practices` answers inert — the practices switch off —
505
+ // as the trace, audit and manifest arms already are: a block no gate
506
+ // reads is not produced. Standards mode keeps D-22's answer below on
507
+ // either switch, because the load-gate card relays that line as its
508
+ // standards-first clause (15-11) and it records no practice.
509
+ if (!modeSeam.isStandardsModeActive(cwd) && !tracePracticesActive(cwd))
510
+ return;
224
511
  // T-24-08b: reject any unrecognized `--`-prefixed argument BEFORE the
225
- // `--changed` git call (so a typo never spawns a subprocess) instead
512
+ // `--changed` git call (so a typo never spawns the diff) instead
226
513
  // of silently filtering it out of the positional file list. Matched on
227
514
  // the WHOLE token so `--files=…` is caught by the same check as
228
515
  // `--files value`. DECISION (recorded, not deferred): `--files=` is
@@ -240,9 +527,26 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
240
527
  error(`Unrecognized flag "${unrecognizedFlag}" for vault practices match. Supported: positional file paths, --changed, --raw, or --block.`, ERROR_REASON.SDK_FAIL_FAST);
241
528
  return;
242
529
  }
530
+ // 13-07 (D-12, PARK-01): in standards mode the matcher has no practice
531
+ // layer to read, so this arm answers not-applicable — one line naming the
532
+ // mode, or `--raw`'s envelope with NO match-set key, exit 0. AFTER the
533
+ // flag guard (a typo still fails first, T-24-08b) and BEFORE the
534
+ // `--changed` git call (a parked path spawns no diff, T-13-12). `--block`
535
+ // is non-raw, so it prints the same line and no `<vault_practices>` block.
536
+ // There is deliberately NO activation guard on this branch: the arm
537
+ // answers not-applicable whenever the vault gate is active, even with
538
+ // `vault.practices_enabled` false (D-22; QD-14 above gates practices mode
539
+ // only). The text is the leaf's.
540
+ if (modeSeam.isStandardsModeActive(cwd)) {
541
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
542
+ const { notApplicableMatchEnvelope } = require('./vault-mode.cjs');
543
+ const naMatch = notApplicableMatchEnvelope();
544
+ c.output(naMatch, true, raw ? JSON.stringify(naMatch) : naMatch.message);
545
+ return;
546
+ }
243
547
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
244
548
  const practices = _practices ?? require('./vault-practices.cjs');
245
- const runGit = _exec ?? shell_command_projection_cjs_1.execGit;
549
+ const runGit = _exec ?? shell_command_projection_cjs_1.execGitLocal;
246
550
  let files;
247
551
  if (args.includes('--changed')) {
248
552
  const gitResult = runGit(['diff', '--name-only', 'HEAD'], { cwd });
@@ -341,6 +645,8 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
341
645
  const coordinates = _coordinates ?? require('./vault-coordinates.cjs');
342
646
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
343
647
  const providerModule = _provider ?? require('./git-provider.cjs');
648
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
649
+ const { VAULT_MODE_CONFIG_KEY: vaultModeKey } = require('./vault-mode.cjs');
344
650
  const resolution = orgProfile.resolveOrgProfile(cwd);
345
651
  // Resolve the EFFECTIVE values by calling the real resolvers, so the
346
652
  // report shows what actually won rather than what the profile offered.
@@ -353,13 +659,32 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
353
659
  const platformHost = coordinates.resolvePlatformHost(cwd);
354
660
  const effectiveByKey = {
355
661
  'vault.provider': provider,
356
- [coordinates.SHARED_REPO_CONFIG_KEY]: shared.repo,
662
+ // An explicit opt-out (an empty string, from config or profile) reads
663
+ // back as "" rather than null, so it is not reported as overridden.
664
+ [coordinates.SHARED_REPO_CONFIG_KEY]: shared.repo ?? (shared.suppressedKind === 'explicit-opt-out' ? '' : null),
357
665
  [coordinates.SHARED_REF_CONFIG_KEY]: shared.ref,
358
666
  [coordinates.SHARED_HOST_CONFIG_KEY]: shared.host,
359
667
  [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.
668
+ // 13-05 / D-20: the two POLICY keys, through their own resolvers.
669
+ // Without these rows both read null, and a profile-seeded value that
670
+ // actually won was reported as `profile offered "true", but "null"
671
+ // won.` (reproduced on 1.15.1) — a diagnostic stating something untrue.
672
+ // The profile carries `vault.required` as the canonical string
673
+ // 'true'/'false', so String(required) compares equal when it won.
674
+ // `resolveVaultRequired` is the SAME ladder the load gate asks
675
+ // (13-REVIEW CR-02): workstream then root config, trimmed, and an
676
+ // unparseable project value falls through to the profile — so the
677
+ // report cannot say the profile lost while the gate enforces it. It is
678
+ // the policy only; with `vault.enabled` off no gate exists to apply it.
362
679
  [orgProfile.VAULT_REQUIRED_CONFIG_KEY]: String(orgProfile.resolveVaultRequired(cwd).required),
680
+ // An invalid PROJECT mode reads as the typed reason
681
+ // (`vault_mode_invalid`), never the value: the project's invalid value
682
+ // did win over the profile, and the report says so without repeating
683
+ // operator text (T-13-03).
684
+ [vaultModeKey]: (() => {
685
+ const r = orgProfile.resolveVaultMode(cwd);
686
+ return r.ok ? r.mode : r.reason;
687
+ })(),
363
688
  };
364
689
  // Build `effective`'s key list from ORG_PROFILE_ALLOWLIST rather than a
365
690
  // fresh literal array, so a future allowlist change surfaces here
@@ -380,21 +705,15 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
380
705
  overridden.push({ key, profile: profileValue, effective: effectiveValue });
381
706
  }
382
707
  }
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]);
708
+ // VAULT-EGRESS-1 (quick 261005-8py): a credential embedded in
709
+ // `vault.fallback_repo` must not reach stdout. `overridden` above compares
710
+ // the RAW values, so redaction happens here, at emission only, through the
711
+ // one owner — never a second redactor.
712
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
713
+ const { redactCloneTarget } = require('./vault.cjs');
714
+ const safe = (key, value) => (key === coordinates.SHARED_REPO_CONFIG_KEY && typeof value === 'string' && value !== ''
715
+ ? (redactCloneTarget(value) ?? value) : value);
716
+ const safeMap = (map) => Object.fromEntries(Object.entries(map).map(([k, v]) => [k, safe(k, v)]));
398
717
  const report = {
399
718
  pointer: { key: orgProfile.ORG_PROFILE_CONFIG_KEY, value: resolution.configured },
400
719
  profile: {
@@ -404,16 +723,12 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
404
723
  source: resolution.source,
405
724
  dataPath: resolution.dataPath,
406
725
  },
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
- })),
726
+ contributes: safeMap(resolution.contributes),
727
+ effective: safeMap(effective),
728
+ overridden: overridden.map((o) => ({ ...o, profile: safe(o.key, o.profile), effective: safe(o.key, o.effective) })),
414
729
  ignored: resolution.ignored,
415
730
  rejected: resolution.rejected,
416
- warnings: resolution.warnings.map(shownText),
731
+ warnings: resolution.warnings,
417
732
  };
418
733
  // Never call error() for a project with no profile, and never exit
419
734
  // non-zero for a resolution failure — this is a diagnostic, not an
@@ -427,21 +742,621 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
427
742
  }
428
743
  }
429
744
  else if (subcommand === 'trace') {
430
- routeTrace({ args, cwd, raw, error, vault, c, _exec, _trace });
745
+ // ── The practice-trace producer (Phase 03-05, TRACE-01/02/03) ──
746
+ //
747
+ // D-01's split, mechanised: the CLI owns everything that must be PROVABLE
748
+ // — the pinned `ref`, the `resolvedSha` and the considered practice-ID set
749
+ // — and the agent writes only the judgement half (`status` + `reason`).
750
+ // "Provable" cannot rest on an agent transcribing a forty-character SHA
751
+ // correctly, and a well-formedness gate by definition cannot catch a
752
+ // wrong-but-well-formed SHA.
753
+ //
754
+ // BOTH arms below are pass-throughs to `./vault-trace.cjs`. They collect
755
+ // arguments, call through, and emit. They hold NO envelope logic, NO glob
756
+ // pass and NO second read of the manifest: every provable field is the
757
+ // leaf module's business, and duplicating any of it here would create a
758
+ // second place where the trace can disagree with the gate that validates
759
+ // it.
760
+ //
761
+ // NEITHER ARM WRITES A FILE, and that is a requirement rather than a
762
+ // convenience not yet added. The producer emits text for an agent to embed
763
+ // in the single write it is already performing. A CLI that patched a
764
+ // plan's frontmatter would have to round-trip it through the shared
765
+ // serializer, which this repository documents as "a deliberately lossy
766
+ // serializer — it cannot faithfully re-emit nested object-list items" —
767
+ // so the same write that added the trace would destroy the plan's own
768
+ // must-haves block (T-03-23). If a direct write is ever wanted it must be a
769
+ // raw text splice on the delimited region, and that is not this phase's
770
+ // business. Do not add the convenience.
771
+ //
772
+ // ORDERING, and why it is this way round: the sub-verb check comes first,
773
+ // so a mistyped sub-verb is refused with the usage error, bindings or not,
774
+ // and produces no record (quick 261015-ect, QD-12 — measured and pinned).
775
+ // Then the admission verdict and the capability gate (quick 261015-at3 put
776
+ // the admission line ahead of the gate), ahead of the flag allowlist. The
777
+ // two orders both satisfy T-03-25 (nothing is required and nothing spawns
778
+ // before an unknown flag is rejected), because the gate is a bounded config
779
+ // read, not a subprocess. Only this order also satisfies D-04's
780
+ // non-negotiable clause — for every VALID sub-verb an inert project's
781
+ // output is byte-identical, on every flag path including a mistyped one
782
+ // (T-03-26).
783
+ const verb = args[2];
784
+ const traceArgs = args.slice(3);
785
+ if (verb !== 'scaffold' && verb !== 'render') {
786
+ error(`Unknown trace subcommand. Available: ${TRACE_VERBS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
787
+ return;
788
+ }
789
+ // Quick 261015-at3: the admission verdict precedes the capability gate, as
790
+ // in `routeVaultAnchor` — no `.platform-bindings.json` means `check
791
+ // vault.trace` answers inert, so no trace may be produced either.
792
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
793
+ const owner = require('./vault.cjs');
794
+ if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
795
+ return;
796
+ if (!tracePracticesActive(cwd))
797
+ return;
798
+ if (verb === 'scaffold') {
799
+ const RECOGNIZED_SCAFFOLD_FLAGS = new Set(['--stage', '--plan', '--anchors-dir', '--raw']);
800
+ const unrecognizedScaffoldFlag = traceArgs.find((a) => a.startsWith('--') && !RECOGNIZED_SCAFFOLD_FLAGS.has(a));
801
+ if (unrecognizedScaffoldFlag !== undefined) {
802
+ error(`Unrecognized flag "${unrecognizedScaffoldFlag}" for vault trace scaffold. ` +
803
+ `Supported: positional file paths, --stage <${TRACE_STAGES.join('|')}>, --plan <id>, ` +
804
+ '--anchors-dir <dir> (execute stage), --raw.', ERROR_REASON.SDK_FAIL_FAST);
805
+ return;
806
+ }
807
+ // Quick 261015-ect (QD-15): parked in standards mode, beside `practices
808
+ // match` (PARK-01) and `vault audit` (PARK-02). A standards-mode plan
809
+ // carries no practices ledger: `check ship.practices`, `check
810
+ // vault.audit` and `check vault.review` answer not_applicable here, and
811
+ // the standards `check vault.trace` reads `<vault_standards>` blocks, not
812
+ // this ledger. After admission, the practices gate and this verb's flag
813
+ // allowlist (a typo still fails first, as in the match and audit arms),
814
+ // before any read; one fixed line from the leaf, exit 0, nothing written.
815
+ if (modeSeam.isStandardsModeActive(cwd)) {
816
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
817
+ const { notApplicableTraceVerbEnvelope } = require('./vault-mode.cjs');
818
+ const naTrace = notApplicableTraceVerbEnvelope();
819
+ c.output(naTrace, true, raw ? JSON.stringify(naTrace) : naTrace.message);
820
+ return;
821
+ }
822
+ const valueIndices = flagValueIndices(traceArgs, ['--stage', '--plan', '--anchors-dir']);
823
+ const stageIdx = traceArgs.indexOf('--stage');
824
+ const stage = stageIdx !== -1 ? traceArgs[stageIdx + 1] : undefined;
825
+ // A missing stage and an invalid one share a reason deliberately: both
826
+ // are "this invocation cannot be interpreted", and splitting them would
827
+ // make the operator read two messages to learn the same two words.
828
+ if (stage === undefined || !TRACE_STAGES.includes(stage)) {
829
+ error(`Missing or unrecognized --stage value for vault trace scaffold. ` +
830
+ `Supported: ${TRACE_STAGES.join(', ')}.`, ERROR_REASON.SDK_FAIL_FAST);
831
+ return;
832
+ }
833
+ const planIdx = traceArgs.indexOf('--plan');
834
+ const planId = planIdx !== -1 ? traceArgs[planIdx + 1] : undefined;
835
+ if (planId === undefined || planId.startsWith('--')) {
836
+ error('No plan identifier was given. Usage: vault trace scaffold ' +
837
+ `--stage <${TRACE_STAGES.join('|')}> --plan <id> [files...] [--raw].`, ERROR_REASON.SDK_MISSING_ARG);
838
+ return;
839
+ }
840
+ // 04-14 (TGATE-01, CR-03): the value is PRESENT but is not a plan
841
+ // identifier. Its own rejection, below both guards and beside the other
842
+ // two argument rejections, so a vault-disabled project still reaches
843
+ // none of them.
844
+ //
845
+ // THE REASON IS THE ESTABLISHED ONE, not a third vocabulary entry. It is
846
+ // `sdk_fail_fast`, the same reason the stage validation above uses,
847
+ // because this is the same class of defect: a value was supplied and
848
+ // cannot be interpreted. `sdk_missing_arg` belongs to the branch directly
849
+ // above, where the value was consumed by a following flag and no
850
+ // identifier reached the arm at all.
851
+ //
852
+ // IT TEACHES RATHER THAN REFUSING. The message names the value that was
853
+ // given, the required form and one conforming example, and then says what
854
+ // the gate will do with the value — because a caller who is told only
855
+ // that a value is invalid has to guess, and the guess CR-03 documents is
856
+ // the one this rejection exists to stop.
857
+ //
858
+ // FENCE 3 is untouched: this arm still writes no file, and a rejection
859
+ // returns before the leaf is required, so nothing is produced and nothing
860
+ // is partially produced.
861
+ if (!SCAFFOLD_PLAN_ID_RE.test(planId)) {
862
+ error(`Invalid --plan value "${planId}" for vault trace scaffold. ` +
863
+ `${SCAFFOLD_PLAN_ID_FORM} ${SCAFFOLD_QUICK_ID_FORM} The trace gate compares the identifier a plan artifact ` +
864
+ 'records against the one its own filename states, and blocks on a disagreement.', ERROR_REASON.SDK_FAIL_FAST);
865
+ return;
866
+ }
867
+ const files = traceArgs.filter((a, i) => !a.startsWith('--') && !valueIndices.has(i));
868
+ // The execute stage resolves a `gitCommit` digest per governed file, so
869
+ // the statement asserts something falsifiable: these practices governed
870
+ // THESE artifacts at this vault SHA (D-08). The digest is the commit
871
+ // that last produced the artifact, not merely HEAD, so a subject entry
872
+ // points at the work it actually describes. A file with no commit yields
873
+ // no digest — deliberately, because the leaf module records that gap as
874
+ // a LOUD note inside the statement, and a HEAD fallback would paper over
875
+ // a real coverage hole with a plausible-looking value.
876
+ const runGit = _exec ?? shell_command_projection_cjs_1.execGitLocal;
877
+ // 16-06 (D-14/D-31/D-35): the execute-stage record's anchors, read by the
878
+ // CLI and never typed. The END anchor is HEAD now; the START anchor is
879
+ // the one `vault anchor start` recorded in the plan's directory at
880
+ // execute:pre (phase) or where quick captured its base. `--anchors-dir`
881
+ // names that directory explicitly; otherwise it is derived from `--plan`
882
+ // (`resolveScaffoldAnchorsDir`). The plan stage reads neither and its
883
+ // output is byte-identical (golden-pinned).
884
+ const anchorsDirIdx = traceArgs.indexOf('--anchors-dir');
885
+ let anchors = null;
886
+ if (anchorsDirIdx !== -1) {
887
+ const anchorsDirArg = traceArgs[anchorsDirIdx + 1];
888
+ if (stage !== 'execute') {
889
+ error('--anchors-dir applies to --stage execute only; a plan-stage ledger records no anchors.', ERROR_REASON.SDK_FAIL_FAST);
890
+ return;
891
+ }
892
+ if (anchorsDirArg === undefined || anchorsDirArg.startsWith('--')) {
893
+ error('--anchors-dir needs a directory under .planning/. Usage: vault trace scaffold --stage execute --plan <id> --anchors-dir <dir> [files...].', ERROR_REASON.SDK_MISSING_ARG);
894
+ return;
895
+ }
896
+ }
897
+ if (stage === 'execute') {
898
+ const head = runGit(['rev-parse', 'HEAD'], { cwd });
899
+ const end = head.exitCode === 0 ? head.stdout.trim() : '';
900
+ anchors = {};
901
+ if (vaultSeal.ANCHOR_SHA_RE.test(end))
902
+ anchors.endCommit = end;
903
+ const located = anchorsDirIdx !== -1
904
+ ? resolvePlanningPath(cwd, traceArgs[anchorsDirIdx + 1], 'dir')
905
+ : resolveScaffoldAnchorsDir(cwd, planId);
906
+ if (anchorsDirIdx !== -1 && !located.ok) {
907
+ error(`--anchors-dir ${located.message}`, ERROR_REASON.SDK_FAIL_FAST);
908
+ return;
909
+ }
910
+ const start = located.ok ? vaultSeal.readDirAnchors(located.abs)?.start : undefined;
911
+ if (start !== undefined) {
912
+ anchors.startCommit = start;
913
+ }
914
+ else {
915
+ // A note, not an error: the record is still written, with its END
916
+ // anchor, and the strict points type the missing start (16-07). One
917
+ // line on stderr, so the embedded block on stdout is unchanged.
918
+ process.stderr.write(`vault trace scaffold: no start anchor recorded for plan ${boundedPath(planId)} ` +
919
+ `(${located.ok ? `${located.rel} holds no ${vaultSeal.VAULT_ANCHORS_FILE}` : located.message}); ` +
920
+ 'the ledger carries endCommit only. `pp_run vault anchor start <dir>` records it at execute:pre.\n');
921
+ }
922
+ }
923
+ const fileInputs = files.map((name) => {
924
+ if (stage !== 'execute')
925
+ return name;
926
+ const res = runGit(['log', '-1', '--format=%H', '--', name], { cwd });
927
+ const digest = res.exitCode === 0 ? res.stdout.trim() : '';
928
+ return GIT_COMMIT_DIGEST_RE.test(digest) ? { name, gitCommit: digest } : name;
929
+ });
930
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
931
+ const traceMod = _trace ?? require('./vault-trace.cjs');
932
+ // No files is a REAL path, not an error: a documentation-only plan is
933
+ // still governed by the global practices, and emitting nothing there
934
+ // would make the ruled-out set unauditable for exactly the plans where a
935
+ // reviewer most wants to know what was considered.
936
+ //
937
+ // THE OVERRIDE CONTEXT (04-06, SC4 / D-13 / D-14 / D-15).
938
+ //
939
+ // The verdict is obtained HERE rather than inside the leaf, and the
940
+ // reason is the same one that put the digest mapping above in the arm:
941
+ // this is the only place that knows a trace is being written NOW, and an
942
+ // override record is a statement about now. `buildTraceStatement`
943
+ // reaching for a live verdict from inside itself would make a pure
944
+ // producer's output depend on the machine's vault state through a path
945
+ // its signature does not declare — the producer/judge split D-01 drew,
946
+ // undone.
947
+ //
948
+ // The AGENT is never the courier for these three fields. Were the gate,
949
+ // the block and the suppressed reason passed as flags, a bypass could be
950
+ // concealed by simply not passing one — and the record that a bypass
951
+ // happened is the single field a bypass most wants hidden. The CLI
952
+ // computes all three, so there is nothing for a caller to omit.
953
+ //
954
+ // D-13: the arm runs once per stage per plan, and each invocation
955
+ // re-reads the environment and re-computes the verdict at its own
956
+ // timestamp. A bypass taken at execution but not at planning is
957
+ // therefore a distinct visible event, not an inference.
958
+ //
959
+ // The common path is unchanged. `buildOverrideRecord` returns null unless
960
+ // the verdict GENUINELY blocked *and* an accepted override reason is
961
+ // present in the environment; a non-blocking verdict with the reason set
962
+ // still records nothing, because recording one would manufacture evidence
963
+ // of a bypass that never happened (D-15).
964
+ //
965
+ // Obtaining a verdict is a READ. `computeVaultVerdict` never fetches or
966
+ // throws (its one possible spawn is a read-only git check-ignore), and this arm still writes no file and
967
+ // splices no frontmatter (FENCE 3).
968
+ const verdict = vault.computeVaultVerdict(cwd);
969
+ const statement = traceMod.buildTraceStatement({
970
+ cwd,
971
+ stage,
972
+ plan: planId,
973
+ files: fileInputs,
974
+ override: {
975
+ gate: TRACE_OVERRIDE_GATE,
976
+ block: verdict.block,
977
+ suppressedReason: verdict.reason,
978
+ },
979
+ // Spread, never `anchors: null` on the plan stage: a seam that records
980
+ // its arguments (the router tests' trace mock) then sees the call it
981
+ // saw before 16-06.
982
+ ...(anchors !== null ? { anchors } : {}),
983
+ });
984
+ // The three-way emit the `practices match --block` arm established: the
985
+ // structured object is ALWAYS the output value, and the text a consumer
986
+ // embeds is the third argument. The planner embeds the block VERBATIM —
987
+ // it never renames a field and never re-derives a value, which is the
988
+ // whole point of D-01's split.
989
+ if (raw) {
990
+ c.output(statement, true, JSON.stringify(statement));
991
+ }
992
+ else {
993
+ c.output(statement, true, traceMod.serializeTraceBlock(statement));
994
+ }
995
+ }
996
+ else {
997
+ const RECOGNIZED_RENDER_FLAGS = new Set(['--raw']);
998
+ const unrecognizedRenderFlag = traceArgs.find((a) => a.startsWith('--') && !RECOGNIZED_RENDER_FLAGS.has(a));
999
+ if (unrecognizedRenderFlag !== undefined) {
1000
+ error(`Unrecognized flag "${unrecognizedRenderFlag}" for vault trace render. ` +
1001
+ 'Supported: a positional artifact path, --raw.', ERROR_REASON.SDK_FAIL_FAST);
1002
+ return;
1003
+ }
1004
+ // Quick 261015-ect (QD-15): parked in standards mode, as the scaffold
1005
+ // branch above is and for the same reason (no practices ledger is read).
1006
+ if (modeSeam.isStandardsModeActive(cwd)) {
1007
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1008
+ const { notApplicableTraceVerbEnvelope } = require('./vault-mode.cjs');
1009
+ const naRender = notApplicableTraceVerbEnvelope();
1010
+ c.output(naRender, true, raw ? JSON.stringify(naRender) : naRender.message);
1011
+ return;
1012
+ }
1013
+ const artifactPath = traceArgs.find((a) => !a.startsWith('--'));
1014
+ if (artifactPath === undefined) {
1015
+ error('No artifact path was given. Usage: vault trace render <artifact> [--raw].', ERROR_REASON.SDK_MISSING_ARG);
1016
+ return;
1017
+ }
1018
+ // CONTAINMENT (CR-04, T-03-50/51/52). `artifactPath` is an unvalidated
1019
+ // positional and the read below is unconditional, so before this branch
1020
+ // existed `vault trace render ../outside/secret.md` emitted an out-of-tree
1021
+ // file's content as the `## Practices Applied` section that
1022
+ // `agents/pp-executor.md` instructs the executor to include VERBATIM in
1023
+ // SUMMARY.md — an arbitrary file's bytes becoming governance-artifact
1024
+ // content a later reviewer agent reads.
1025
+ //
1026
+ // It sits HERE, inside the render arm, BELOW the `tracePracticesActive`
1027
+ // early return above — and must stay there. That return is the single
1028
+ // guard making the whole trace verb inert on a capability-off project, so
1029
+ // no containment decision, no project-root resolution and no read may be
1030
+ // reachable above it or an inert project stops being byte-identically
1031
+ // silent (D-04, T-03-26). Do not hoist any of this for a cheaper argument
1032
+ // check, and do not share a resolved root with the scaffold arm.
1033
+ //
1034
+ // It also sits BEFORE the read rather than being inferred from it:
1035
+ // `readHeadBounded` returns the empty string on any problem and never
1036
+ // throws, so a decision taken on its RESULT would be no decision at all.
1037
+ //
1038
+ // The predicate comes from the REAL leaf, not the `_vault` seam: a
1039
+ // security decision must not be switchable by a test mock. Same
1040
+ // `require('./vault.cjs')` the seam itself defaults to — one module, not
1041
+ // a second import path.
1042
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1043
+ const { isInsideDir } = require('./vault.cjs');
1044
+ if (artifactPath.indexOf('\u0000') !== -1) {
1045
+ error(`The artifact path is not a usable path: ${boundedPath(artifactPath)}`, ERROR_REASON.SDK_FAIL_FAST);
1046
+ return;
1047
+ }
1048
+ let projectRoot;
1049
+ try {
1050
+ projectRoot = findProjectRoot(cwd) || cwd;
1051
+ }
1052
+ catch {
1053
+ projectRoot = cwd;
1054
+ }
1055
+ let artifactAbs;
1056
+ try {
1057
+ artifactAbs = node_path_1.default.resolve(cwd, artifactPath);
1058
+ }
1059
+ catch {
1060
+ error(`The artifact path could not be resolved: ${boundedPath(artifactPath)}`, ERROR_REASON.SDK_FAIL_FAST);
1061
+ return;
1062
+ }
1063
+ if (!isInsideDir(projectRoot, artifactAbs)) {
1064
+ error(`The artifact path resolves outside the project and was not read: ${boundedPath(artifactPath)}`, ERROR_REASON.SDK_FAIL_FAST);
1065
+ return;
1066
+ }
1067
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
1068
+ const traceMod = _trace ?? require('./vault-trace.cjs');
1069
+ const content = vault.readHeadBounded(artifactAbs, TRACE_ARTIFACT_READ_BYTES);
1070
+ const parsed = traceMod.parseVaultTraceBlock(content, artifactPath);
1071
+ // THE LEDGER, AND ONLY THE LEDGER. This arm must never call the matcher.
1072
+ // Re-deriving the practice set at render time would produce a section
1073
+ // that disagrees with the frontmatter it is supposedly generated from,
1074
+ // which is the precise failure D-03 exists to prevent: the whole point of
1075
+ // GENERATING the surface is that the two can never disagree (T-03-24).
1076
+ // Adding a matcher call here looks like a robustness improvement and is
1077
+ // the exact opposite — it reintroduces the drift the generation removed.
1078
+ //
1079
+ // An absent ledger yields the parser's documented absent result and NO
1080
+ // section — not an error, and not an empty heading. An empty ledger is a
1081
+ // different thing entirely and the leaf module's renderer handles it: it
1082
+ // emits the heading plus one explanatory line, because a degraded run's
1083
+ // record must stay visible and a reader who finds no section at all
1084
+ // cannot tell a degraded run from a plan that was never vault-enabled.
1085
+ const rendered = parsed.present ? traceMod.renderPracticesApplied(parsed.statement) : '';
1086
+ if (raw) {
1087
+ c.output(parsed, true, JSON.stringify(parsed));
1088
+ }
1089
+ else {
1090
+ c.output(parsed, true, rendered);
1091
+ }
1092
+ }
431
1093
  }
432
1094
  else if (subcommand === 'audit') {
433
- routeAudit({ args, cwd, raw, error, vault, c, _exec, _audit });
1095
+ // ── The mechanical practice audit (Phase 05-06, CHECK-03/SC1) ──
1096
+ //
1097
+ // A PASS-THROUGH, on exactly the rule the two trace arms above state: it
1098
+ // collects arguments, calls through and emits. It holds NO verdict, NO
1099
+ // message, NO range and NO cap logic. `check vault.audit` serializes the
1100
+ // SAME envelope from the SAME engine, and duplicating any of it here would
1101
+ // create a second place where the operator's report can disagree with the
1102
+ // gate that blocks the build on it (D-05-02). Even the human text is the
1103
+ // leaf's — `renderAuditReport` is called over the object the leaf returned,
1104
+ // never over a copy this arm rebuilt.
1105
+ //
1106
+ // ORDERING, copied from the trace arm above because the reason carries over
1107
+ // verbatim: the capability gate is the FIRST branch, ahead of the flag
1108
+ // allowlist (the admission verdict follows it here, per QD-03 below). Both orders satisfy T-02-07/T-03-25 — nothing is required and
1109
+ // nothing spawns before an unknown flag is rejected, because the gate is a
1110
+ // bounded config read and not a subprocess. Only THIS order also satisfies
1111
+ // D-04's non-negotiable clause: an inert project's output is byte-identical
1112
+ // on every path INCLUDING a mistyped one (T-03-26).
1113
+ //
1114
+ // 16-09 (SEAL-02, D-39) — ONE exception, and it is the gate's own: a
1115
+ // switch that is off over a phase holding engine-written vault records.
1116
+ // `check vault.audit` answers that `disabled_with_artifacts`, blocking, and
1117
+ // this verb prints the SAME line from the SAME leaf (an agreeing pair). A
1118
+ // project that never recorded anything still prints nothing.
1119
+ if (!tracePracticesActive(cwd)) {
1120
+ const refusal = disabledWithArtifactsRefusal(cwd, args);
1121
+ if (refusal)
1122
+ c.output(refusal, true, raw ? JSON.stringify(refusal) : refusal.message);
1123
+ return;
1124
+ }
1125
+ // Quick 261015-at3 (QD-03, D-39): the admission verdict comes AFTER the
1126
+ // switch block above, not before it. That block's refusal already asks
1127
+ // `hasPlatformBindings` first and is the verb's half of an agreeing pair with
1128
+ // `check vault.audit`; an admission-first line would silence the
1129
+ // `vault.enabled: false`-over-records refusal the gate still answers. This
1130
+ // line closes the remaining shape — both switches on, no bindings — where
1131
+ // `check vault.audit` answers `inert`.
1132
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1133
+ const owner = require('./vault.cjs');
1134
+ if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
1135
+ return;
1136
+ // T-5-19 / T-02-07: whole-token rejection, so `--raw=json` is caught by the
1137
+ // same check as a bare unknown flag rather than slipping past a prefix test
1138
+ // and reaching a subprocess. The host (pp-core/bin/pp-tools.cjs) splices out
1139
+ // its own global flags before dispatch, which is why the recognized set is
1140
+ // one wide.
1141
+ // The flag's NAME is spelled here and in `check-command-router.cts`'s
1142
+ // `AUDIT_RANGE_FLAG`. What must not be duplicated is the POLICY — what
1143
+ // counts as a well-formed range — and it is not: both surfaces call the
1144
+ // leaf's `isValidOperatorRange`. A shared constant for the token itself
1145
+ // would mean importing the audit leaf at this file's top level, which the
1146
+ // lazy-require convention here exists to avoid, to buy agreement on seven
1147
+ // characters that a test on each surface already pins.
1148
+ const AUDIT_RANGE_FLAG = '--range';
1149
+ const RECOGNIZED_AUDIT_FLAGS = new Set(['--raw', AUDIT_RANGE_FLAG]);
1150
+ const unrecognizedAuditFlag = args.slice(2).find((a) => a.startsWith('--') && !RECOGNIZED_AUDIT_FLAGS.has(a));
1151
+ if (unrecognizedAuditFlag !== undefined) {
1152
+ error(`Unrecognized flag "${unrecognizedAuditFlag}" for vault audit. ` +
1153
+ `Supported: a positional phase directory, --raw, ${AUDIT_RANGE_FLAG} <from>..<to>.`, ERROR_REASON.SDK_FAIL_FAST);
1154
+ return;
1155
+ }
1156
+ // 13-07 (D-13, PARK-02): standards mode has no practice layer to audit.
1157
+ // AFTER the inert gate above (D-22) and the flag guard, BEFORE the range,
1158
+ // the positional and the leaf — the same place `check vault.audit` answers
1159
+ // it, so the two surfaces agree on every argument shape. Still a
1160
+ // pass-through: it emits the leaf's builder output, never its own wording
1161
+ // (D-05-02).
1162
+ if (modeSeam.isStandardsModeActive(cwd)) {
1163
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1164
+ const { notApplicableGateEnvelope } = require('./vault-mode.cjs');
1165
+ const naAudit = notApplicableGateEnvelope('vault.audit');
1166
+ c.output(naAudit, true, raw ? JSON.stringify(naAudit) : naAudit.message);
1167
+ return;
1168
+ }
1169
+ // 08-06 (D-08-19): the operator's range, and the ONE token in this arm that
1170
+ // is a flag's VALUE rather than a positional. It is located before the
1171
+ // positional is picked because the pick below is "the first token that does
1172
+ // not start with `--`" — which, for `vault audit --range a..b <dir>`, is
1173
+ // `a..b`. That would have scoped the audit to a phase directory nobody
1174
+ // named, silently and with a plausible-looking envelope: the exact class of
1175
+ // the WR-08 defect the gate surface's positional scanner was rewritten to
1176
+ // remove. Here the fix is local, because this arm has one value-bearing
1177
+ // flag and no enforcer appending others.
1178
+ const auditArgs = args.slice(2);
1179
+ const rangeAt = auditArgs.indexOf(AUDIT_RANGE_FLAG);
1180
+ const auditRangeValue = rangeAt === -1 ? undefined : auditArgs[rangeAt + 1];
1181
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
1182
+ const auditMod = _audit ?? require('./vault-audit.cjs');
1183
+ if (rangeAt !== -1) {
1184
+ // HARVESTED, NEVER RESTATED: the predicate is the leaf's own, so this
1185
+ // operator surface and the gate surface cannot disagree about what a
1186
+ // well-formed range is. A seam that does not provide it (the test mock)
1187
+ // falls back to the real module rather than to "anything goes" — a
1188
+ // missing predicate must never mean a passing one.
1189
+ //
1190
+ // Each candidate is CALLED ON ITS OWN OBJECT rather than detached into a
1191
+ // variable: an unbound method is a `this` hazard the linter is right
1192
+ // about, and nothing here needs the function as a value.
1193
+ const rangeIsWellFormed = (v) => {
1194
+ if (typeof auditMod.isValidOperatorRange === 'function') {
1195
+ return auditMod.isValidOperatorRange(v);
1196
+ }
1197
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1198
+ const leaf = require('./vault-audit.cjs');
1199
+ return typeof leaf.isValidOperatorRange === 'function' ? leaf.isValidOperatorRange(v) : false;
1200
+ };
1201
+ if (typeof auditRangeValue !== 'string' || !rangeIsWellFormed(auditRangeValue)) {
1202
+ // The refused value is NOT echoed: it failed validation, so it is not
1203
+ // trusted text, and this arm's message is read by a person whose next
1204
+ // action is to retype it (T-08-32).
1205
+ error(`${AUDIT_RANGE_FLAG} needs a well-formed <from>..<to> git range (letters, digits, `
1206
+ + '. _ / ^ ~ -, two or three dots, at most 200 characters). The supplied value is not '
1207
+ + 'reproduced here because it failed validation. Re-run with a well-formed range, or '
1208
+ + 'drop the flag to let the phase-start ladder resolve it.', ERROR_REASON.SDK_FAIL_FAST);
1209
+ return;
1210
+ }
1211
+ }
1212
+ // The phase argument. A DIRECTORY path carries the workstream partition,
1213
+ // which is why neither dispatch point has to pass `--ws`
1214
+ // (check-query-gate.md:27, and the four-times-shipped omitted-`--ws`
1215
+ // lesson behind it). The range's VALUE is excluded by index, so the flag
1216
+ // may precede the positional.
1217
+ const auditPhaseArg = auditArgs.find((a, i) => !a.startsWith('--') && !(rangeAt !== -1 && i === rangeAt + 1));
1218
+ if (auditPhaseArg === undefined) {
1219
+ error(`No phase directory was given. Usage: vault audit <phase-dir> [${AUDIT_RANGE_FLAG} <from>..<to>] [--raw].`, ERROR_REASON.SDK_MISSING_ARG);
1220
+ return;
1221
+ }
1222
+ let auditRoot;
1223
+ try {
1224
+ auditRoot = findProjectRoot(cwd) || cwd;
1225
+ }
1226
+ catch {
1227
+ auditRoot = cwd;
1228
+ }
1229
+ // 08-10 (F4). THE ARGUMENT IS PART OF THE PASS-THROUGH. This arm read the
1230
+ // positional as a raw path while `check vault.audit` resolved it through
1231
+ // `resolveTracePhaseDir` — either an existing directory OR a phase token the
1232
+ // locator resolves. Measured on the live fixture, `01` therefore produced
1233
+ // `{"status":"ok","block":false,"rangeSource":"phase-start-commit"}` from the
1234
+ // gate and `{"status":"warn","block":true,"rangeSource":"base-branch",
1235
+ // "...":"manifest-not-found"}` from the operator's own command: a blocking,
1236
+ // meaningless verdict where the gate saw a clean phase-scoped pass. That is
1237
+ // exactly the disagreement this arm's own doc comment above forbids
1238
+ // (D-05-02), so the RESOLVER is harvested — never restated — from the leaf
1239
+ // both routers already import.
1240
+ //
1241
+ // From the REAL leaf, not the `_vault` seam — same rule and same reason as
1242
+ // the `isInsideDir` harvest in the render arm above: a containment decision
1243
+ // must not be switchable by a test mock.
1244
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1245
+ const { resolveTracePhaseDir } = require('./vault.cjs');
1246
+ const resolvedAuditPhase = resolveTracePhaseDir(auditRoot, cwd, auditPhaseArg);
1247
+ if (!resolvedAuditPhase) {
1248
+ // The SAME `unresolved` shape the gate emits (check-command-router.cts),
1249
+ // not a base-branch fallback that reads as a real audit of nothing. Stated
1250
+ // only from what was measured, and naming a re-run rather than a deletion
1251
+ // (T-4-75).
1252
+ const unresolved = {
1253
+ status: 'unresolved',
1254
+ block: true,
1255
+ message: `Vault audit: "${boundedPath(auditPhaseArg)}" is neither an existing `
1256
+ + 'directory inside the project nor a phase the locator resolves, so no practice was '
1257
+ + 'compared against anything. Repair: re-run this command with the phase directory.',
1258
+ };
1259
+ c.output(unresolved, true, raw ? JSON.stringify(unresolved) : unresolved.message);
1260
+ return;
1261
+ }
1262
+ const auditResult = auditMod.runVaultAudit({
1263
+ root: auditRoot,
1264
+ phaseDir: resolvedAuditPhase.abs,
1265
+ exec: _exec ?? shell_command_projection_cjs_1.execGitLocal,
1266
+ // Spread, never `range: undefined` — the leaf keys on the key's TYPE to
1267
+ // decide whether its ladder runs.
1268
+ ...(typeof auditRangeValue === 'string' ? { range: auditRangeValue } : {}),
1269
+ });
1270
+ if (raw) {
1271
+ c.output(auditResult, true, JSON.stringify(auditResult));
1272
+ }
1273
+ else {
1274
+ c.output(auditResult, true, auditMod.renderAuditReport(auditResult));
1275
+ }
434
1276
  }
435
- else if (subcommand === 'review') {
436
- routeReview({ args, cwd, raw, c });
1277
+ else if (subcommand === 'manifest') {
1278
+ // ── The phase-manifest writer (260915-ik2, UAT-6-F1) ──
1279
+ //
1280
+ // `start` anchors `phases[NN].startCommit = HEAD` (write-once); `record`
1281
+ // writes `phases[NN].commits` from that anchor. Both are the leaf's —
1282
+ // this arm collects arguments, calls through and emits, exactly as the
1283
+ // `audit` arm above does, and in the same ORDER for the same reason: the
1284
+ // capability gate is FIRST and silent, so an inert project's output is
1285
+ // byte-identical on every path including a mistyped one (T-03-26,
1286
+ // T-ik2-08). It exits 0 on every path: the step files print the result and
1287
+ // continue, and a refusal travels as `ok: false` with a reason.
1288
+ //
1289
+ // Quick 261015-at3: the admission verdict precedes the capability gate — a
1290
+ // project with no `.platform-bindings.json` gets no manifest file written.
1291
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1292
+ const owner = require('./vault.cjs');
1293
+ if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
1294
+ return;
1295
+ if (!tracePracticesActive(cwd))
1296
+ return;
1297
+ const RECOGNIZED_MANIFEST_FLAGS = new Set(['--raw']);
1298
+ const unrecognizedManifestFlag = args.slice(2).find((a) => a.startsWith('--') && !RECOGNIZED_MANIFEST_FLAGS.has(a));
1299
+ if (unrecognizedManifestFlag !== undefined) {
1300
+ error(`Unrecognized flag "${unrecognizedManifestFlag}" for vault manifest. ` +
1301
+ `Supported: a verb (${MANIFEST_VERBS.join(' | ')}), a positional phase directory, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
1302
+ return;
1303
+ }
1304
+ const manifestPositionals = args.slice(2).filter((a) => !a.startsWith('--'));
1305
+ const manifestVerb = manifestPositionals[0];
1306
+ if (manifestVerb !== 'start' && manifestVerb !== 'record') {
1307
+ error(`Unknown vault manifest verb. Available: ${MANIFEST_VERBS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
1308
+ return;
1309
+ }
1310
+ // Quick 261015-ect (QD-15): parked in standards mode. The range this verb
1311
+ // anchors serves two gates, `vault audit` and `check vault.review`, and
1312
+ // both answer not_applicable in this mode (PARK-02, PARK-04). Its third
1313
+ // reader, `/pp-undo --phase` (pp-core/workflows/undo.md), falls back to its
1314
+ // git-log search when the file or the phase entry is missing — the path a
1315
+ // project with no bindings or with the practices switch off already takes.
1316
+ // After the verb check and before any positional or phase read; the
1317
+ // envelope carries `ok: false` and a typed reason, which is how the step
1318
+ // files read a manifest refusal (print the line and continue).
1319
+ if (modeSeam.isStandardsModeActive(cwd)) {
1320
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1321
+ const { notApplicableManifestVerbEnvelope } = require('./vault-mode.cjs');
1322
+ const naManifest = notApplicableManifestVerbEnvelope();
1323
+ c.output(naManifest, true, raw ? JSON.stringify(naManifest) : naManifest.message);
1324
+ return;
1325
+ }
1326
+ // The phase DIRECTORY, as for `audit`: the path carries the workstream
1327
+ // partition, and the manifest is written two levels above it.
1328
+ const manifestPhaseDir = manifestPositionals[1];
1329
+ if (manifestPhaseDir === undefined) {
1330
+ error('No phase directory was given. Usage: vault manifest <start|record> <phase-dir> [--raw].', ERROR_REASON.SDK_MISSING_ARG);
1331
+ return;
1332
+ }
1333
+ let manifestRoot;
1334
+ try {
1335
+ manifestRoot = findProjectRoot(cwd) || cwd;
1336
+ }
1337
+ catch {
1338
+ manifestRoot = cwd;
1339
+ }
1340
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
1341
+ const manifestMod = _audit ?? require('./vault-audit.cjs');
1342
+ const writeOpts = { root: manifestRoot, phaseDir: manifestPhaseDir, cwd, exec: _exec ?? shell_command_projection_cjs_1.execGitLocal };
1343
+ const manifestResult = manifestVerb === 'start'
1344
+ ? manifestMod.recordPhaseStart(writeOpts)
1345
+ : manifestMod.recordPhaseCommits(writeOpts);
1346
+ if (raw) {
1347
+ c.output(manifestResult, true, JSON.stringify(manifestResult));
1348
+ }
1349
+ else {
1350
+ c.output(manifestResult, true, manifestMod.renderManifestResult(manifestResult));
1351
+ }
437
1352
  }
438
1353
  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.
1354
+ // PBI 74316: `vault update [<tag>] [--yes]`. Without --yes it reports the
1355
+ // plan and writes nothing; see src/vault-update.cts for the contract.
441
1356
  const RECOGNIZED_UPDATE_FLAGS = new Set(['--raw', '--yes']);
442
1357
  const unrecognizedUpdateFlag = args.slice(2).find((a) => a.startsWith('--') && !RECOGNIZED_UPDATE_FLAGS.has(a));
443
1358
  if (unrecognizedUpdateFlag !== undefined) {
444
- error(`Unrecognized flag "${boundedPath(unrecognizedUpdateFlag)}" for vault update. Supported: --yes, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
1359
+ error(`Unrecognized flag "${unrecognizedUpdateFlag}" for vault update. Supported: --yes, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
445
1360
  return;
446
1361
  }
447
1362
  const updatePositionals = args.slice(2).filter((a) => !a.startsWith('--'));
@@ -466,109 +1381,282 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
466
1381
  c.output(updateResult, true, updateMod.renderVaultUpdate(updateResult));
467
1382
  }
468
1383
  }
1384
+ else if (subcommand === 'standards') {
1385
+ // 15-07 (D-06, D-08, D-13): the standards record verbs. The arm body lives
1386
+ // in `routeVaultStandards` below; the invalid-mode refusal above already
1387
+ // ran, because 'standards' is a member of VAULT_SUBCOMMANDS.
1388
+ routeVaultStandards({ cwd, args, raw, error, vault, c, exec: _exec ?? shell_command_projection_cjs_1.execGitLocal });
1389
+ }
1390
+ else if (subcommand === 'admission') {
1391
+ // 16-04 (D-28, D-05): the arm body lives in `admissionVerb` below.
1392
+ admissionVerb({ cwd, rest: args.slice(2), raw, error, c });
1393
+ }
1394
+ else if (subcommand === 'anchor') {
1395
+ // 16-06 (D-31, D-32): the arm body lives in `routeVaultAnchor` below.
1396
+ routeVaultAnchor({ cwd, args, raw, error, c, exec: _exec ?? shell_command_projection_cjs_1.execGitLocal });
1397
+ }
1398
+ else if (subcommand === 'door-record') {
1399
+ // 16-06 (D-12): the arm body lives in `doorRecordVerb` below.
1400
+ doorRecordVerb({ cwd, rest: args.slice(2), raw, error, c, exec: _exec ?? shell_command_projection_cjs_1.execGitLocal });
1401
+ }
469
1402
  else {
470
- error(`Unknown vault subcommand. Available: ${VAULT_SUBCOMMANDS.map((x) => x.name).join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
1403
+ error(`Unknown vault subcommand. Available: ${VAULT_SUBCOMMANDS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
471
1404
  }
472
1405
  }
473
1406
  /**
474
- * `vault trace scaffold|render` — the practice-trace producer.
1407
+ * `vault admission [--explain]` (16-04, D-28, D-05): prints exactly one of
1408
+ * `inert | off | on | config-unreadable` and exits 0. With `--explain` and
1409
+ * `config-unreadable`, one more line per unreadable file names the file and the
1410
+ * repair; `--raw` prints the verdict object instead.
475
1411
  *
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.
1412
+ * The answer comes from `vaultAdmissionVerdict` in the REAL src/vault.cts, never
1413
+ * from the injectable `_vault` seam: a governance decision must not be
1414
+ * switchable by a test mock (the mode-guard precedent in `routeVaultCommand`).
1415
+ * It stays on the cwd's own verdict, not the producer verdict the seven record
1416
+ * producers ask (quick 261015-ect, QD-10): this verb is a REPORT of the cwd,
1417
+ * which is what every fence asks, so a bindings-less linked worktree answers
1418
+ * `inert` here even where its owning checkout is admitted (QD-09 "Not changed").
480
1419
  *
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.
1420
+ * Exit 0 on every answer, because a door fence reads the TOKEN: any stdout other
1421
+ * than `on`, `off` or `inert` halts it, and its `|| echo unrecognised` fallback
1422
+ * turns a non-zero exit into a halt as well. The only non-zero paths are an
1423
+ * argument error here and the 13-06 invalid-mode refusal that precedes every
1424
+ * vault subcommand where `.platform-bindings.json` exists (QD-11); both
1425
+ * therefore halt the fence, never skip it.
486
1426
  */
487
- function routeTrace({ args, cwd, raw, error, vault, c, _exec, _trace, }) {
1427
+ function admissionVerb({ cwd, rest, raw, error, c }) {
1428
+ let explain = false;
1429
+ for (const a of rest) {
1430
+ if (a === '--raw')
1431
+ continue;
1432
+ if (a === '--explain') {
1433
+ explain = true;
1434
+ continue;
1435
+ }
1436
+ error(`Unrecognized argument "${boundedPath(a)}" for vault admission. Supported: --explain, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
1437
+ return;
1438
+ }
488
1439
  // 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);
1440
+ const owner = require('./vault.cjs');
1441
+ const verdict = owner.vaultAdmissionVerdict(cwd);
1442
+ if (raw) {
1443
+ c.output(verdict, true, JSON.stringify(verdict));
495
1444
  return;
496
1445
  }
497
- // Inert unless both vault keys are on: no output at all, before any flag check.
498
- if (!tracePracticesActive(cfgGet, cwd))
1446
+ const lines = [verdict.state];
1447
+ if (explain && verdict.state === 'config-unreadable') {
1448
+ for (const u of verdict.unreadable) {
1449
+ lines.push(`${u.file}: ${u.error} — repair: fix the JSON in ${u.file} (or remove the key that broke it) and re-run; ` +
1450
+ 'the gate cannot read this file and refuses to pass having checked nothing');
1451
+ }
1452
+ }
1453
+ c.output(verdict, true, `${lines.join('\n')}\n`);
1454
+ }
1455
+ /**
1456
+ * `vault standards block|consulted` (15-07). The CLI owns everything provable
1457
+ * about a standards record (D-06): the pin comes from the manifest, a path is
1458
+ * accepted only when the loaded INDEX resolves it (D-19), the degraded line is
1459
+ * chosen from the measured state (D-08) and the override line comes from the
1460
+ * one override builder. The planner fills reason slots and the executor fills
1461
+ * status slots; nothing else in the record is typed by an agent.
1462
+ *
1463
+ * ORDER: unless `vault admission` answers `on` (bindings present, every config
1464
+ * readable, the vault switch on) both verbs print nothing and write nothing,
1465
+ * before any argument or the mode is read — so a project without the vault is
1466
+ * byte-identical on every path including a mistyped one, and a standards record
1467
+ * cannot be produced on a project whose trace gate is inert (17-REVIEW CR-01,
1468
+ * D-13; the same gate `routeVaultAnchor` and `doorRecordVerb` ask). The verdict
1469
+ * is asked of the checkout whose vault cache the verbs read (quick 261015-at3,
1470
+ * QD-09): in a linked worktree reading its owning checkout's cache — where an
1471
+ * executor runs `consulted` and has no untracked bindings of its own — that is
1472
+ * the owning checkout, which is also where the gate reading the record runs. Then
1473
+ * practices mode answers its one not-applicable line, then the verb and the
1474
+ * flags are checked. An invalid mode never reaches here: the pre-dispatch
1475
+ * guard in `routeVaultCommand` refused it.
1476
+ *
1477
+ * Every governance read is from the REAL module, never the `_vault` seam (the
1478
+ * mode-guard precedent); only the bounded PLAN read goes through the seam, so
1479
+ * a test can count it. Neither verb writes a file.
1480
+ */
1481
+ function routeVaultStandards({ cwd, args, raw, error, vault, c, exec }) {
1482
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1483
+ const owner = require('./vault.cjs');
1484
+ if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
499
1485
  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);
506
- return;
1486
+ if (!owner.isStandardsModeActive(cwd)) {
1487
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1488
+ const { notApplicableStandardsVerbEnvelope } = require('./vault-mode.cjs');
1489
+ const na = notApplicableStandardsVerbEnvelope();
1490
+ c.output(na, true, raw ? JSON.stringify(na) : na.message);
1491
+ return;
1492
+ }
1493
+ const verb = args[2];
1494
+ const rest = args.slice(3);
1495
+ if (verb === 'block') {
1496
+ standardsBlockVerb({ cwd, rest, raw, error, c, owner });
1497
+ }
1498
+ else if (verb === 'consulted') {
1499
+ standardsConsultedVerb({ cwd, rest, raw, error, c, owner, vault, exec });
1500
+ }
1501
+ else {
1502
+ error(`Unknown standards subcommand. Available: ${STANDARDS_VERBS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
1503
+ }
1504
+ }
1505
+ function standardsBlockVerb({ cwd, rest, raw, error, c, owner }) {
1506
+ // ── arguments ──
1507
+ let planId;
1508
+ let planGiven = false;
1509
+ const positionals = [];
1510
+ for (let i = 0; i < rest.length; i += 1) {
1511
+ const a = rest[i];
1512
+ if (a === '--raw')
1513
+ continue;
1514
+ if (a === '--plan') {
1515
+ planGiven = true;
1516
+ planId = rest[i + 1];
1517
+ i += 1;
1518
+ continue;
1519
+ }
1520
+ if (a.startsWith('--plan=')) {
1521
+ planGiven = true;
1522
+ planId = a.slice('--plan='.length);
1523
+ continue;
507
1524
  }
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);
1525
+ if (a.startsWith('--')) {
1526
+ error(`Unrecognized flag "${boundedPath(a)}" for vault standards block. ` +
1527
+ 'Supported: --plan <plan-id>, positional INDEX paths, --raw.', ERROR_REASON.SDK_FAIL_FAST);
513
1528
  return;
514
1529
  }
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);
1530
+ positionals.push(a);
1531
+ }
1532
+ if (!planGiven || planId === undefined || planId === '' || planId.startsWith('--')) {
1533
+ error('No plan identifier was given. Usage: vault standards block --plan <plan-id> [<index-path>...] [--raw].', ERROR_REASON.SDK_MISSING_ARG);
1534
+ return;
1535
+ }
1536
+ if (!SCAFFOLD_PLAN_ID_RE.test(planId)) {
1537
+ error(`Invalid --plan value "${boundedPath(planId)}" for vault standards block. ${SCAFFOLD_PLAN_ID_FORM} ${SCAFFOLD_QUICK_ID_FORM}`, ERROR_REASON.SDK_FAIL_FAST);
1538
+ return;
1539
+ }
1540
+ // ── the measured state, read once, from one cache ──
1541
+ // The manifest and the INDEX come from the same directory, the one
1542
+ // `resolveVaultCacheDir` names (in a main checkout, `.planning/vault/`), so
1543
+ // the pin and the resolved paths always describe one cache. "Loaded" and the
1544
+ // stamp status are the leaf's predicates, shared with the trace gate.
1545
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1546
+ const indexLeaf = require('./vault-index.cjs');
1547
+ const cacheDir = owner.resolveVaultCacheDir(cwd).dir;
1548
+ const pin = vaultStandardsLeaf.standardsLoadedPin(owner.readManifest(cacheDir));
1549
+ const state = pin === null ? null : owner.computeVaultIndexState(cwd, { vaultDir: cacheDir });
1550
+ const indexStatus = vaultStandardsLeaf.standardsMeasuredIndexStatus(pin, state);
1551
+ // ── the selection (D-19) ──
1552
+ // The repair comes from the one owner the consulted verb and the trace gate
1553
+ // also read (15-REVIEW iter-3 WR-01), so the three cannot name different ones.
1554
+ const indexFix = vaultStandardsLeaf.standardsIndexRepair(indexStatus);
1555
+ if (indexFix !== null && positionals.length > 0) {
1556
+ error(`Vault standards block: no document can be selected: the index is ${indexStatus}. ` +
1557
+ 'Run the command with no index paths to record that state; to select documents, ' +
1558
+ `${indexFix.repair.charAt(0).toLowerCase()}${indexFix.repair.slice(1)} first.`, ERROR_REASON.SDK_FAIL_FAST);
1559
+ return;
1560
+ }
1561
+ const selectable = vaultStandardsLeaf.standardsResolvedPaths(state);
1562
+ const seen = new Set();
1563
+ for (const p of positionals) {
1564
+ // Exact string membership: no normalisation, no join, no stat (Phase 14
1565
+ // D-08). A path the INDEX does not resolve is refused in ONE wording
1566
+ // whatever the reason, so the refusal never tells a caller which class of
1567
+ // file a path names.
1568
+ if (seen.has(p)) {
1569
+ error(`Vault standards block: ${indexLeaf.displayVaultPath(p)} is listed more than once; name each INDEX path once.`, ERROR_REASON.SDK_FAIL_FAST);
520
1570
  return;
521
1571
  }
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);
1572
+ seen.add(p);
1573
+ if (!selectable.has(p)) {
1574
+ error(`Vault standards block: ${indexLeaf.displayVaultPath(p)} is not a selectable INDEX entry. ` +
1575
+ 'Choose from the entries the ## INDEX section of .planning/vault/SUMMARY.md lists, exactly as written there.', ERROR_REASON.SDK_FAIL_FAST);
525
1576
  return;
526
1577
  }
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;
1578
+ }
1579
+ // ── the record ──
1580
+ const repoText = pin !== null
1581
+ ? indexLeaf.sanitiseVaultText(owner.redactCloneTarget(pin.repo) ?? '', 200).replace(/\s+/g, '_')
1582
+ : '';
1583
+ // Document lines only on a healthy index with a selection; otherwise the
1584
+ // one degraded line the measured status implies (D-08).
1585
+ const degraded = indexStatus === 'ok' && positionals.length > 0
1586
+ ? null
1587
+ : vaultStandardsLeaf.standardsDegradedLineFor(indexStatus);
1588
+ const record = {
1589
+ planId,
1590
+ repo: pin !== null && repoText !== '' ? repoText : null,
1591
+ ref: pin?.ref ?? null,
1592
+ sha: pin?.sha ?? null,
1593
+ indexStatus,
1594
+ documents: degraded === null
1595
+ ? positionals.map((p) => ({ path: p, ref: pin.ref, sha: pin.sha, reason: '' }))
1596
+ : [],
1597
+ degraded,
1598
+ override: null,
1599
+ };
1600
+ // ── the override line (D-08): only through the one builder ──
1601
+ // Recorded only when the load gate's verdict GENUINELY blocks now and the
1602
+ // trace leaf accepts the operator's override reason from this process's
1603
+ // environment; a non-blocking verdict records nothing (D-15 of Phase 4). The
1604
+ // router names no part of the override stack (the leaf owns it all). The
1605
+ // gate name is the scaffold arm's own constant. The verdict is computed
1606
+ // against the SAME cache the stamp above measured (D-36, 15-REVIEW iter-2
1607
+ // WR-02), exactly as the trace gate recomputes it, so the suppressed reason
1608
+ // recorded here is the one the gate will compare it with, in any checkout.
1609
+ const verdict = owner.computeVaultVerdict(cwd, { vaultDir: cacheDir });
1610
+ if (verdict.block === true) {
1611
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1612
+ const traceLeaf = require('./vault-trace.cjs');
1613
+ const ov = traceLeaf.buildStandardsOverrideRecord(cwd, {
1614
+ gate: TRACE_OVERRIDE_GATE,
1615
+ block: true,
1616
+ suppressedReason: verdict.reason,
539
1617
  });
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));
547
- }
548
- else {
549
- c.output(statement, true, traceMod.serializeTraceBlock(statement));
1618
+ if (ov) {
1619
+ record.override = { gate: ov.gate, suppressedReason: ov.suppressedReason, who: ov.who, when: ov.when, reason: ov.reason };
550
1620
  }
1621
+ }
1622
+ let text;
1623
+ try {
1624
+ text = vaultStandardsLeaf.serializeStandardsBlock(record);
1625
+ }
1626
+ catch (e) {
1627
+ error(`Vault standards block could not write the record: ${boundedPath(e instanceof Error ? e.message : String(e))}`, ERROR_REASON.UNKNOWN);
551
1628
  return;
552
1629
  }
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);
1630
+ const envelope = owner.withVaultMode({
1631
+ status: 'ok',
1632
+ planId,
1633
+ stamp: { repo: record.repo, ref: record.ref, sha: record.sha, indexStatus },
1634
+ documents: record.documents.map((d) => ({ path: d.path, ref: d.ref, sha: d.sha })),
1635
+ degraded: record.degraded,
1636
+ override: record.override,
1637
+ text,
1638
+ }, cwd);
1639
+ c.output(envelope, true, raw ? JSON.stringify(envelope) : text);
1640
+ }
1641
+ function standardsConsultedVerb({ cwd, rest, raw, error, c, owner, vault, exec, }) {
1642
+ const unrecognized = rest.find((a) => a.startsWith('--') && a !== '--raw');
1643
+ if (unrecognized !== undefined) {
1644
+ error(`Unrecognized flag "${boundedPath(unrecognized)}" for vault standards consulted. ` +
1645
+ 'Supported: a positional plan path, --raw.', ERROR_REASON.SDK_FAIL_FAST);
558
1646
  return;
559
1647
  }
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);
1648
+ const positionals = rest.filter((a) => !a.startsWith('--'));
1649
+ if (positionals.length !== 1) {
1650
+ 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
1651
  return;
564
1652
  }
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.
1653
+ const artifactPath = positionals[0];
1654
+ const shown = boundedPath(artifactPath);
1655
+ // CONTAINMENT FIRST, then the size, then the read (the `trace render`
1656
+ // precedent, T-03-50): the plan path is an unvalidated positional, and a
1657
+ // decision taken on the bounded read's RESULT would be no decision.
570
1658
  if (artifactPath.indexOf('\u0000') !== -1) {
571
- error(`The artifact path is not a usable path: ${boundedPath(artifactPath)}`, ERROR_REASON.SDK_FAIL_FAST);
1659
+ error(`The plan path is not a usable path: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
572
1660
  return;
573
1661
  }
574
1662
  let projectRoot;
@@ -579,225 +1667,1374 @@ function routeTrace({ args, cwd, raw, error, vault, c, _exec, _trace, }) {
579
1667
  projectRoot = cwd;
580
1668
  }
581
1669
  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);
1670
+ if (!owner.isInsideDir(projectRoot, artifactAbs)) {
1671
+ error(`The plan path resolves outside the project and was not read: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
584
1672
  return;
585
1673
  }
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));
1674
+ let size;
1675
+ try {
1676
+ const st = node_fs_1.default.statSync(artifactAbs);
1677
+ if (!st.isFile())
1678
+ throw new Error('not a file');
1679
+ size = st.size;
598
1680
  }
599
- else {
600
- c.output(parsed, true, rendered);
1681
+ catch {
1682
+ error(`The plan path is not a readable file: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
1683
+ return;
601
1684
  }
602
- }
603
- /**
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.
617
- */
618
- function routeAudit({ args, cwd, raw, error, vault, c, _exec, _audit, }) {
619
- // 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))
1685
+ if (size > STANDARDS_PLAN_READ_BYTES) {
1686
+ error(`The plan is ${size} bytes, above the ${STANDARDS_PLAN_READ_BYTES}-byte read cap the trace gate also applies, ` +
1687
+ `and was not read: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
623
1688
  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);
1689
+ }
1690
+ const text = vault.readHeadBounded(artifactAbs, STANDARDS_PLAN_READ_BYTES);
1691
+ const parsed = vaultStandardsLeaf.parseStandardsBlock(text, { artifact: shown });
1692
+ if (parsed.record === null || parsed.problems.length > 0) {
1693
+ const first = parsed.problems[0];
1694
+ error(first
1695
+ ? `Vault standards consulted: ${first.kind}: ${first.detail}`
1696
+ : `Vault standards consulted: ${shown} carries no readable ${vaultStandardsLeaf.VAULT_STANDARDS_TAG_OPEN} record. ` +
1697
+ 'Re-run `pp-tools vault standards block --plan <plan-id> <index-path...>` and embed its output verbatim.', ERROR_REASON.SDK_FAIL_FAST);
629
1698
  return;
630
1699
  }
631
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
632
- const auditMod = _audit ?? require('./vault-audit.cjs');
1700
+ const record = parsed.record;
1701
+ // ── absolute cache paths (D-30, C-9) ──
1702
+ // Resolved through the one cache resolver, so an executor in a linked
1703
+ // worktree (which has no cache of its own) is pointed at the main checkout's.
1704
+ // Every path is contained in the cache directory: a block edited by hand
1705
+ // could name `../x.md`, and that must be refused before anyone reads it.
633
1706
  // 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);
1707
+ const indexLeaf = require('./vault-index.cjs');
1708
+ const cache = owner.resolveVaultCacheDir(cwd);
1709
+ // And every path is a RESOLVED INDEX entry of that cache (D-19, 15-REVIEW
1710
+ // WR-04): the executor is told to read each listed document in full, and a
1711
+ // withheld or credential file is in the cache too. The same reads and the
1712
+ // same set the block verb selects from, so a PLAN that never passed
1713
+ // plan:post, or was edited after it, cannot hand such a path to an executor.
1714
+ // One reason-free wording, as the block verb's: the refusal never says why.
1715
+ const loadedPin = vaultStandardsLeaf.standardsLoadedPin(owner.readManifest(cache.dir));
1716
+ const indexState = loadedPin === null ? null : owner.computeVaultIndexState(cwd, { vaultDir: cache.dir });
1717
+ const selectable = vaultStandardsLeaf.standardsResolvedPaths(indexState);
1718
+ // 15-REVIEW iter-2 WR-05: with no vault loaded, or a loaded vault whose INDEX
1719
+ // is degraded, NO document can be listed, and the per-path wording below
1720
+ // would name a loaded vault that is not there and send the reader back to
1721
+ // `vault standards block`, where the only record left to write is a degraded
1722
+ // one: a governed plan would lose its document record. So this case is
1723
+ // refused first, naming the measured cause and the repair (`vault fetch`),
1724
+ // from the one owner the block verb and the trace gate also read (iter-3
1725
+ // WR-01), so the gate cannot send the same record somewhere else.
1726
+ const indexStatus = vaultStandardsLeaf.standardsMeasuredIndexStatus(loadedPin, indexState);
1727
+ const indexFix = vaultStandardsLeaf.standardsIndexRepair(indexStatus);
1728
+ if (record.documents.length > 0 && indexFix !== null) {
1729
+ error(`Vault standards consulted: ${indexFix.cause}, so no document of ${shown} can be listed. ` +
1730
+ `${indexFix.repair}, then re-run this command.`, ERROR_REASON.SDK_FAIL_FAST);
1731
+ return;
1732
+ }
1733
+ const documents = [];
1734
+ for (const d of record.documents) {
1735
+ const cachePath = node_path_1.default.join(cache.dir, d.path);
1736
+ if (node_path_1.default.isAbsolute(d.path) || !owner.isInsideDir(cache.dir, cachePath) || cachePath === cache.dir) {
1737
+ error(`Vault standards consulted: ${indexLeaf.displayVaultPath(d.path)} in ${shown} resolves outside the vault cache ` +
1738
+ 'and was not listed. Re-run `pp-tools vault standards block` for this plan and embed its output verbatim.', ERROR_REASON.SDK_FAIL_FAST);
643
1739
  return;
644
1740
  }
1741
+ if (!selectable.has(d.path)) {
1742
+ error(`Vault standards consulted: ${indexLeaf.displayVaultPath(d.path)} in ${shown} is not a selectable INDEX entry ` +
1743
+ 'of the loaded vault and was not listed. Re-run `pp-tools vault standards block` for this plan, choosing from ' +
1744
+ 'the entries the ## INDEX section of .planning/vault/SUMMARY.md lists, and embed its output verbatim.', ERROR_REASON.SDK_FAIL_FAST);
1745
+ return;
1746
+ }
1747
+ documents.push({ path: d.path, ref: d.ref, sha: d.sha, cachePath });
645
1748
  }
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);
649
- return;
650
- }
651
- let root;
1749
+ // 16-06 (D-14/D-31/D-35): the anchor line, the section's last line. END is
1750
+ // HEAD now (the executor runs this after the last task commit); START is the
1751
+ // anchor `vault anchor start` recorded in the plan's own directory, when one
1752
+ // is there. Both come from git and the anchors file, never from the agent.
1753
+ // HEAD unreadable (no commit, git failing) writes no line at all, and the
1754
+ // strict points then type the missing anchor (16-07); a write point is
1755
+ // silent on it (D-16).
1756
+ const headRun = exec(['rev-parse', 'HEAD'], { cwd });
1757
+ const end = headRun.exitCode === 0 ? headRun.stdout.trim() : '';
1758
+ const start = vaultSeal.readDirAnchors(node_path_1.default.dirname(artifactAbs))?.start ?? null;
1759
+ const anchors = vaultSeal.ANCHOR_SHA_RE.test(end) ? { start, end } : null;
1760
+ const section = vaultStandardsLeaf.serializeConsultedSection(record, { anchors });
1761
+ const envelope = owner.withVaultMode({
1762
+ status: 'ok',
1763
+ planId: record.planId,
1764
+ stamp: { repo: record.repo, ref: record.ref, sha: record.sha },
1765
+ documents,
1766
+ // The section and this field echo one sanitised line (15-REVIEW WR-03).
1767
+ degraded: record.degraded === null ? null : vaultStandardsLeaf.standardsEchoDegraded(record.degraded),
1768
+ cacheSource: cache.source,
1769
+ section,
1770
+ }, cwd);
1771
+ c.output(envelope, true, raw ? JSON.stringify(envelope) : section);
1772
+ }
1773
+ /**
1774
+ * The real vault module, for the governance reads of the 16-06 verbs: the
1775
+ * admission verdict, containment and phase resolution must not be switchable
1776
+ * by a test mock (the mode-guard precedent in `routeVaultCommand`).
1777
+ */
1778
+ function anchorOwner() {
1779
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1780
+ return require('./vault.cjs');
1781
+ }
1782
+ /** The project root the router's other containment checks use: `findProjectRoot(cwd) || cwd`. */
1783
+ function projectRootOf(cwd) {
652
1784
  try {
653
- root = findProjectRoot(cwd) || cwd;
1785
+ return findProjectRoot(cwd) || cwd;
654
1786
  }
655
1787
  catch {
656
- root = cwd;
657
- }
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);
668
- return;
1788
+ return cwd;
669
1789
  }
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 } : {}),
675
- });
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));
1790
+ }
1791
+ /**
1792
+ * THE CONTAINMENT LADDER for a path a 16-06 verb is handed (the
1793
+ * `standardsConsultedVerb` order, T-03-50): a NUL byte is refused, the project
1794
+ * root is resolved, the path must resolve inside the root AND inside its
1795
+ * `.planning/` tree (never the tree itself), and only then is it stat'ed.
1796
+ *
1797
+ * - `dir`: an existing directory;
1798
+ * - `file`: an existing regular file (not a symlink);
1799
+ * - `artifact`: a file that may not exist yet, whose directory exists inside
1800
+ * `.planning/`; when it does exist it must be a regular file (not a
1801
+ * symlink), so a write can never be redirected out of the tree.
1802
+ *
1803
+ * `.planning/` is required, not only the root, because every path these verbs
1804
+ * write or read is a planning record; an anchors file or a door record in the
1805
+ * project's source tree would be committed as source (D-55). The refusal names
1806
+ * the bounded argv value, never file content.
1807
+ */
1808
+ function resolvePlanningPath(cwd, raw, want) {
1809
+ const shown = boundedPath(raw);
1810
+ if (raw.indexOf('\u0000') !== -1)
1811
+ return { ok: false, reason: 'path-unusable', message: `"${shown}" is not a usable path` };
1812
+ const owner = anchorOwner();
1813
+ const root = projectRootOf(cwd);
1814
+ const abs = node_path_1.default.resolve(cwd, raw);
1815
+ if (!owner.isInsideDir(root, abs)) {
1816
+ return { ok: false, reason: 'outside-project', message: `"${shown}" resolves outside the project and was not used` };
1817
+ }
1818
+ const planning = node_path_1.default.join(root, '.planning');
1819
+ const under = want === 'artifact' ? node_path_1.default.dirname(abs) : abs;
1820
+ if (!owner.isInsideDir(planning, under) || (want !== 'artifact' && owner.isInsideDir(abs, planning))) {
1821
+ return { ok: false, reason: 'outside-planning', message: `"${shown}" is not inside the project's .planning/ tree` };
1822
+ }
1823
+ const rel = node_path_1.default.relative(root, abs).split(node_path_1.default.sep).join('/');
1824
+ try {
1825
+ if (want === 'dir') {
1826
+ if (!node_fs_1.default.statSync(abs).isDirectory())
1827
+ return { ok: false, reason: 'not-a-directory', message: `"${shown}" is not a directory` };
1828
+ }
1829
+ else if (want === 'file') {
1830
+ const st = node_fs_1.default.lstatSync(abs);
1831
+ if (!st.isFile())
1832
+ return { ok: false, reason: 'not-a-file', message: `"${shown}" is not a regular file` };
1833
+ }
1834
+ else {
1835
+ if (!node_fs_1.default.statSync(node_path_1.default.dirname(abs)).isDirectory()) {
1836
+ return { ok: false, reason: 'not-a-directory', message: `the directory of "${shown}" does not exist` };
1837
+ }
1838
+ if (node_fs_1.default.existsSync(abs) || isDanglingLink(abs)) {
1839
+ const st = node_fs_1.default.lstatSync(abs);
1840
+ if (!st.isFile())
1841
+ return { ok: false, reason: 'not-a-file', message: `"${shown}" exists and is not a regular file` };
1842
+ }
1843
+ }
1844
+ }
1845
+ catch {
1846
+ return { ok: false, reason: 'not-found', message: `"${shown}" does not exist` };
1847
+ }
1848
+ return { ok: true, root, abs, rel };
1849
+ }
1850
+ /** A symlink whose target is missing: `existsSync` says false, and a write would follow it. */
1851
+ function isDanglingLink(abs) {
1852
+ try {
1853
+ return node_fs_1.default.lstatSync(abs).isSymbolicLink();
1854
+ }
1855
+ catch {
1856
+ return false;
1857
+ }
1858
+ }
1859
+ /**
1860
+ * Where the execute-stage scaffold reads a plan's START anchor from, derived
1861
+ * from `--plan` (16-06, D-31). Measured order:
1862
+ *
1863
+ * 1. the QUICK form first: the one directory `<root>/.planning/quick/<id>-*`
1864
+ * (quick task directories always live under the ROOT planning tree,
1865
+ * `src/init.cts`). Exactly one match resolves; two or more is ambiguous
1866
+ * and resolves nothing. Tried for every id, so an all-digit quick id
1867
+ * (`261011-123`, which the phase alternative of `SCAFFOLD_PLAN_ID_RE`
1868
+ * also accepts) still finds its quick directory, and no second copy of
1869
+ * the quick-id form is needed here;
1870
+ * 2. otherwise the PHASE form: the segment before the first `-` (`02.1` of
1871
+ * `02.1-03`) resolved by `resolveTracePhaseDir`, the resolver the trace
1872
+ * gate uses for a phase-number positional, which honours the active
1873
+ * workstream (`--ws`, `GSD_WORKSTREAM`, the session pointer) through the
1874
+ * phase locator.
1875
+ *
1876
+ * Unresolvable → `ok: false`, and the scaffold writes `endCommit` only.
1877
+ */
1878
+ function resolveScaffoldAnchorsDir(cwd, planId) {
1879
+ const root = projectRootOf(cwd);
1880
+ const quickRoot = node_path_1.default.join(root, '.planning', 'quick');
1881
+ let quick = [];
1882
+ try {
1883
+ quick = node_fs_1.default.readdirSync(quickRoot, { withFileTypes: true })
1884
+ .filter((d) => d.isDirectory() && d.name.startsWith(`${planId}-`))
1885
+ .map((d) => d.name);
1886
+ }
1887
+ catch {
1888
+ quick = [];
1889
+ }
1890
+ if (quick.length === 1) {
1891
+ const abs = node_path_1.default.join(quickRoot, quick[0]);
1892
+ return { ok: true, root, abs, rel: node_path_1.default.relative(root, abs).split(node_path_1.default.sep).join('/') };
1893
+ }
1894
+ if (quick.length > 1) {
1895
+ return { ok: false, reason: 'anchors-dir-ambiguous', message: `${quick.length} quick directories match .planning/quick/${boundedPath(planId)}-*` };
1896
+ }
1897
+ const dash = planId.indexOf('-');
1898
+ if (dash > 0) {
1899
+ const resolved = anchorOwner().resolveTracePhaseDir(root, cwd, planId.slice(0, dash));
1900
+ if (resolved && resolved.onlyFile === undefined)
1901
+ return { ok: true, root, abs: resolved.abs, rel: resolved.rel };
1902
+ }
1903
+ return { ok: false, reason: 'anchors-dir-unresolved', message: `no phase or quick directory resolves for plan ${boundedPath(planId)}` };
1904
+ }
1905
+ /**
1906
+ * `vault anchor <verb>` (16-06). INERT FIRST (D-13): unless the producer
1907
+ * admission verdict is `on` (bindings present, every config readable, the
1908
+ * vault gate active) the verb prints nothing and writes nothing, before any
1909
+ * argument is read, so a project without the vault is byte-identical on every
1910
+ * path, including a mistyped one. A `config-unreadable` project is halted by
1911
+ * the admission fence that precedes every call site, never here.
1912
+ *
1913
+ * The verdict is the PRODUCER's (quick 261015-ect, QD-10), the one the other
1914
+ * six record producers ask: equal to the cwd's own except in a linked worktree
1915
+ * with no bindings of its own, where it is the owning checkout's. So all seven
1916
+ * answer alike in every shape; the door fences, which probe the cwd's bindings
1917
+ * first, never reach this verb in such a worktree, so only by-hand calls move.
1918
+ */
1919
+ function routeVaultAnchor({ cwd, args, raw, error, c, exec }) {
1920
+ if (anchorOwner().vaultProducerAdmissionVerdict(cwd).state !== 'on')
1921
+ return;
1922
+ const verb = args[2];
1923
+ const rest = args.slice(3);
1924
+ if (verb === 'start') {
1925
+ anchorStartVerb({ cwd, rest, raw, error, c, exec });
1926
+ }
1927
+ else if (verb === 'backfill') {
1928
+ anchorBackfillVerb({ cwd, rest, raw, error, c, exec });
1929
+ }
1930
+ else if (verb === 'certify') {
1931
+ anchorCertifyVerb({ cwd, rest, raw, error, c, exec });
680
1932
  }
681
1933
  else {
682
- c.output(result, true, auditMod.renderAuditReport(result));
1934
+ error(`Unknown vault anchor verb. Available: ${ANCHOR_VERBS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
683
1935
  }
684
1936
  }
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`;
1937
+ /**
1938
+ * `vault anchor start <dir>` (16-06, D-31): record HEAD as the START anchor of
1939
+ * a phase or quick directory, in `<dir>/.vault-anchors.json`
1940
+ * (`{ start, recordedAt }`, written by the seal leaf's `writeDirStartAnchor`).
1941
+ * Mode-neutral: it is the start the practices-only `vault manifest start`
1942
+ * cannot give a standards-mode phase.
1943
+ *
1944
+ * Re-running at the SAME HEAD keeps the recorded start (`kept: true`, nothing
1945
+ * written). At any other HEAD it records HEAD as the new start and says which
1946
+ * start it superseded (`replaced`) — 16 review iteration 2 CR-02, D-98, which
1947
+ * reverses D-93's "keep the first start". Keeping it let every record written
1948
+ * after a resume carry the first start, so its range covered a commit made
1949
+ * between the two runs: a claim of governance nothing granted (rule 3). The
1950
+ * superseded start is kept in the directory's history, newest first, and an
1951
+ * END-only record (a linked worktree's, D-59) takes the latest start that
1952
+ * precedes its own end, so a record written before the resume still covers
1953
+ * its own work. Commits made between the last record and the resume are
1954
+ * covered by nothing and the strict gate names them; the phase's own work
1955
+ * among them is repaired with the attributed start back-fill
1956
+ * (`vault anchor backfill <summary> --from <commit>`), never by a silent
1957
+ * widening.
1958
+ *
1959
+ * Exit 0 on every refusal, which travels as `{ ok: false, reason, message }`
1960
+ * (the `vault manifest` posture: the step prints it and continues; the strict
1961
+ * points type a missing start). An argument error is the only exit 1.
1962
+ */
1963
+ function anchorStartVerb({ cwd, rest, raw, error, c, exec }) {
1964
+ const positionals = [];
1965
+ for (const a of rest) {
1966
+ if (a === '--raw')
1967
+ continue;
1968
+ if (a.startsWith('--')) {
1969
+ error(`Unrecognized flag "${boundedPath(a)}" for vault anchor start. Supported: a positional directory under .planning/, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
1970
+ return;
1971
+ }
1972
+ positionals.push(a);
1973
+ }
1974
+ if (positionals.length !== 1) {
1975
+ 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);
1976
+ return;
1977
+ }
1978
+ const emit = (result, text) => {
1979
+ c.output(result, true, raw ? JSON.stringify(result) : text);
1980
+ };
1981
+ const located = resolvePlanningPath(cwd, positionals[0], 'dir');
1982
+ if (!located.ok) {
1983
+ emit({ ok: false, action: 'start', reason: located.reason, message: located.message }, `No start anchor was recorded: ${located.message}.`);
1984
+ return;
1985
+ }
1986
+ const head = exec(['rev-parse', 'HEAD'], { cwd });
1987
+ const sha = head.exitCode === 0 ? head.stdout.trim() : '';
1988
+ if (!vaultSeal.ANCHOR_SHA_RE.test(sha)) {
1989
+ const message = 'HEAD could not be read as a full commit id (no commit yet, or git failed)';
1990
+ emit({ ok: false, action: 'start', reason: 'head-unreadable', message }, `No start anchor was recorded: ${message}.`);
1991
+ return;
1992
+ }
1993
+ const file = `${located.rel}/${vaultSeal.VAULT_ANCHORS_FILE}`;
1994
+ const existing = vaultSeal.readDirAnchors(located.abs);
1995
+ if (existing !== null && existing.start === sha) {
1996
+ 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).`);
1997
+ return;
1998
+ }
1999
+ let recorded;
2000
+ try {
2001
+ recorded = vaultSeal.writeDirStartAnchor(located.abs, sha);
2002
+ }
2003
+ catch {
2004
+ const message = `${file} could not be written`;
2005
+ emit({ ok: false, action: 'start', reason: 'write-failed', message }, `No start anchor was recorded: ${message}.`);
2006
+ return;
2007
+ }
2008
+ const replaced = existing !== null ? { replaced: existing.start } : {};
2009
+ emit({ ok: true, action: 'start', start: recorded.start, recordedAt: recorded.recordedAt, file, ...replaced }, existing !== null
2010
+ ? `Recorded start anchor ${recorded.start} in ${file}, replacing ${existing.start} as the start of every record written from now on; `
2011
+ + 'the earlier start stays in the directory\'s history for a record that ended before this one.'
2012
+ : `Recorded start anchor ${recorded.start} in ${file}.`);
690
2013
  }
2014
+ // ─── 16-19: the certificate writer (D-107, D-108) ────────────────────────────
2015
+ /** How long the strict check `vault anchor certify` runs may take before it is a `check-failed` refusal. */
2016
+ const CERTIFY_CHECK_TIMEOUT_MS = 120_000;
2017
+ /** The most stdout the certify verb reads from its child check (a larger envelope is `check-failed`). */
2018
+ const CERTIFY_CHECK_MAX_BUFFER = 8 * 1024 * 1024;
2019
+ /** At most this many problem kinds, added ends or removed ends travel in one certify refusal. */
2020
+ const CERTIFY_LIST_CAP = 20;
2021
+ /** How much of the child check's stderr a `check-failed` refusal quotes. */
2022
+ const CERTIFY_STDERR_HEAD = 120;
691
2023
  /**
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.
709
- */
710
- function routeReview({ args, cwd, raw, c, }) {
711
- const say = (envelope, text) => {
712
- c.output(envelope, true, raw ? JSON.stringify(envelope) : text);
2024
+ * `vault anchor certify <phase-dir>` (16 gap closure 16-19, D-107, D-108): the
2025
+ * ONE writer of a phase directory's `.vault-certificates.json`. A certificate
2026
+ * says "a clean strict check of this phase judged this SUMMARY's END, at this
2027
+ * HEAD, at this time"; only a certified phase record bounds a later phase's
2028
+ * SEAL-01 reach (16-18), so the certificate is the recorded fact D-101 said the
2029
+ * gate did not have.
2030
+ *
2031
+ * WHY A CHILD PROCESS. `check vault.trace` is read-only by contract (TRACE-04)
2032
+ * and its handler emits to stdout. Running it as the enforcer does, as
2033
+ * `pp-tools check vault.trace <dir> --unpaired strict --raw`, keeps its exit-0
2034
+ * posture and its envelope exactly what every gate reads, and this verb only
2035
+ * reads that envelope. The engine is the one running (`pp-tools.cjs` beside
2036
+ * this module's directory); the positional is the contained rel, after the
2037
+ * subcommand, never in an option position.
2038
+ *
2039
+ * WHY THE ENDS AND HEAD ARE COLLECTED BEFORE THE SPAWN, AND RE-CHECKED AFTER.
2040
+ * A certificate for an end the check never judged, or stamped with a HEAD the
2041
+ * check did not judge at, is a certificate for a check that never passed: the
2042
+ * shape D-106 closed at the leaf, reopened by its writer. So HEAD is read and
2043
+ * the phase's ended SUMMARY records are collected first, only those ends are
2044
+ * certified, and after the child returns a different HEAD is `head-moved` and a
2045
+ * different set of ends is `records-changed`, each writing nothing. The verb
2046
+ * refuses rather than certifying the intersection, so a concurrent writer is
2047
+ * visible and never silently narrowed; a SUMMARY added while the check ran is
2048
+ * certified by the next clean run.
2049
+ *
2050
+ * WHY THE GIT ENVIRONMENT IS SCRUBBED, FOR THE VERB'S READS AND THE CHILD
2051
+ * ALIKE. A git hook or an agent shell exports the repository pointer
2052
+ * (`GIT_DIR`, `GIT_WORK_TREE`, …), so an inherited value is ordinary, and a
2053
+ * lasting certificate must never describe another repository's HEAD or
2054
+ * verdict. The overlay comes from its one owner, `scrubbedGitEnv` in
2055
+ * `src/vault.cts` (the cache resolver's precedent, 260915-mzx R2): every
2056
+ * inherited `GIT_*` key is `undefined`, which unsets it, because neither
2057
+ * `execGit` nor `spawnSync` passes an undefined value on.
2058
+ *
2059
+ * WHY THE OVERRIDE VARIABLE IS EMPTIED (D-108). An override never certifies:
2060
+ * `checked_with_override`, an envelope carrying `override` and one carrying
2061
+ * `overrideRejected` are each refused, and the child's `VAULT_OVERRIDE_ENV` is
2062
+ * the empty string (which the trace leaf reads as absent), so an operator's
2063
+ * per-run override in the calling shell can never turn a blocking check into a
2064
+ * certificate, whatever a later engine's check does with that variable.
2065
+ *
2066
+ * WHY A QUICK DIRECTORY IS REFUSED (D-104). A quick task's record never bounds
2067
+ * a later phase, so it is never certified (D-113 keeps the leaf from reading
2068
+ * one as certified even if a file named its end).
2069
+ *
2070
+ * A skipped call certifies nothing, which only widens the next phase's reach
2071
+ * and never hides a commit (D-110). Exit 0 on every refusal, which travels as
2072
+ * `{ ok: false, action: 'certify', reason, … }`; an argument error is the only
2073
+ * exit 1. Inert before any argument is read (`routeVaultAnchor`, D-13).
2074
+ */
2075
+ function anchorCertifyVerb({ cwd, rest, raw, error, c, exec }) {
2076
+ const positionals = [];
2077
+ for (const a of rest) {
2078
+ if (a === '--raw')
2079
+ continue;
2080
+ if (a.startsWith('--')) {
2081
+ error(`Unrecognized flag "${boundedPath(a)}" for vault anchor certify. Supported: a positional phase directory under .planning/, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
2082
+ return;
2083
+ }
2084
+ positionals.push(a);
2085
+ }
2086
+ if (positionals.length !== 1) {
2087
+ 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);
2088
+ return;
2089
+ }
2090
+ const emit = (result, text) => {
2091
+ c.output(result, true, raw ? JSON.stringify(result) : text);
713
2092
  };
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);
2093
+ const refuse = (reason, message, extra = {}) => {
2094
+ emit({ ok: false, action: 'certify', reason, ...extra, message }, `Nothing was certified: ${message}.`);
2095
+ };
2096
+ const located = resolvePlanningPath(cwd, positionals[0], 'dir');
2097
+ if (!located.ok) {
2098
+ refuse(located.reason, located.message);
2099
+ return;
2100
+ }
2101
+ if (!vaultSeal.isPhaseRecordRel(`${located.rel}/x`)) {
2102
+ 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`);
2103
+ return;
2104
+ }
2105
+ const gitEnv = anchorOwner().scrubbedGitEnv(process.env);
2106
+ const readHead = () => {
2107
+ const r = exec(['rev-parse', 'HEAD'], { cwd, env: gitEnv });
2108
+ const sha = r.exitCode === 0 ? r.stdout.trim() : '';
2109
+ return vaultSeal.ANCHOR_SHA_RE.test(sha) ? sha : '';
2110
+ };
2111
+ const head = readHead();
2112
+ if (head === '') {
2113
+ refuse('head-unreadable', 'HEAD could not be read as a full commit id (no commit yet, or git failed)');
2114
+ return;
2115
+ }
2116
+ // A filesystem walk, no git: the ends of the SUMMARY records directly in this
2117
+ // directory, sorted and unique.
2118
+ const collectEnds = () => {
2119
+ const scan = vaultSeal.collectCoveringRecords(located.root);
2120
+ const prefix = `${located.rel}/`;
2121
+ const ends = scan.records
2122
+ .filter((r) => vaultSeal.isSummaryRecordRel(r.rel) && r.rel.startsWith(prefix) && r.rel.indexOf('/', prefix.length) === -1)
2123
+ .map((r) => r.end);
2124
+ return [...new Set(ends)].sort();
2125
+ };
2126
+ const endsBefore = collectEnds();
2127
+ if (endsBefore.length === 0) {
2128
+ const message = `${located.rel} has no SUMMARY with an end anchor, so there is nothing to certify; the strict check was not run`;
2129
+ emit({ ok: false, action: 'certify', reason: 'no-ended-record', message }, `Nothing was certified: ${message}.`);
719
2130
  return;
720
2131
  }
721
- const force = verb === 'scaffold' && args.includes('--force');
722
- const tokens = args.slice(3).filter((a) => a !== '--raw' && !(force && a === '--force'));
723
2132
  // 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);
2133
+ const { VAULT_OVERRIDE_ENV } = require('./vault-trace.cjs');
2134
+ const child = childProcess.spawnSync(process.execPath, [node_path_1.default.join(__dirname, '..', 'pp-tools.cjs'), 'check', 'vault.trace', located.rel, '--unpaired', 'strict', '--raw'], {
2135
+ cwd: located.root,
2136
+ encoding: 'utf8',
2137
+ timeout: CERTIFY_CHECK_TIMEOUT_MS,
2138
+ maxBuffer: CERTIFY_CHECK_MAX_BUFFER,
2139
+ env: { ...process.env, ...gitEnv, [VAULT_OVERRIDE_ENV]: '' },
2140
+ windowsHide: true,
2141
+ });
2142
+ const stderrHead = boundedPath(String(child.stderr ?? '').slice(0, CERTIFY_STDERR_HEAD));
2143
+ const failed = (why) => {
2144
+ refuse('check-failed', `the strict check of ${located.rel} could not be read (${why})`, { detail: `${why}; stderr: ${stderrHead}` });
2145
+ };
2146
+ if (child.error) {
2147
+ failed(`the check did not run: ${boundedPath(child.error.message).slice(0, CERTIFY_STDERR_HEAD)}`);
2148
+ return;
2149
+ }
2150
+ if (child.signal) {
2151
+ failed(`the check was ended by ${child.signal}`);
2152
+ return;
2153
+ }
2154
+ if (child.status !== 0) {
2155
+ failed(`the check exited ${child.status}`);
2156
+ return;
2157
+ }
2158
+ const stdout = String(child.stdout ?? '');
2159
+ if (stdout.trim() === '') {
2160
+ failed('the check printed nothing');
2161
+ return;
2162
+ }
2163
+ let envelope;
2164
+ try {
2165
+ const parsed = JSON.parse(stdout);
2166
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
2167
+ failed('the check printed JSON that is not an object');
2168
+ return;
2169
+ }
2170
+ envelope = parsed;
2171
+ }
2172
+ catch {
2173
+ failed('the check printed something that is not JSON');
2174
+ return;
2175
+ }
2176
+ // The verdict gate (D-107, D-108): a clean strict verdict, and nothing else.
2177
+ const has = (k) => Object.prototype.hasOwnProperty.call(envelope, k);
2178
+ const status = typeof envelope.status === 'string' ? boundedPath(envelope.status) : null;
2179
+ const block = typeof envelope.block === 'boolean' ? envelope.block : null;
2180
+ const clean = envelope.status === 'checked' && envelope.block === false && !has('override') && !has('overrideRejected');
2181
+ if (!clean) {
2182
+ const problems = Array.isArray(envelope.problems) ? envelope.problems : [];
2183
+ const kinds = [...new Set(problems
2184
+ .map((p) => (p && typeof p === 'object' ? p.kind : undefined))
2185
+ .filter((k) => typeof k === 'string')
2186
+ .map((k) => boundedPath(k)))].sort().slice(0, CERTIFY_LIST_CAP);
2187
+ const overridden = envelope.status === 'checked_with_override' || has('override');
2188
+ refuse(overridden ? 'overridden' : 'not-clean', `the strict check of ${located.rel} answered ${status ?? 'no status'}, block ${String(block)}`
2189
+ + `${kinds.length > 0 ? ` (${kinds.join(', ')})` : ''}`
2190
+ + `${overridden ? '; an override never certifies (D-108)' : ''}`, { status, block, kinds });
2191
+ return;
2192
+ }
2193
+ // After the child returned clean: the HEAD it judged at, then the ends it judged.
2194
+ const headAfter = readHead();
2195
+ if (headAfter !== head) {
2196
+ 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 });
2197
+ return;
2198
+ }
2199
+ const endsAfter = collectEnds();
2200
+ const beforeSet = new Set(endsBefore);
2201
+ const afterSet = new Set(endsAfter);
2202
+ const added = endsAfter.filter((e) => !beforeSet.has(e));
2203
+ const removed = endsBefore.filter((e) => !afterSet.has(e));
2204
+ if (added.length > 0 || removed.length > 0) {
2205
+ 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) });
2206
+ return;
2207
+ }
2208
+ // Only the ends collected BEFORE the spawn, stamped with the HEAD read before it.
2209
+ const file = `${located.rel}/${vaultSeal.VAULT_CERTIFICATES_FILE}`;
2210
+ const already = new Set(vaultSeal.readDirCertificates(located.abs).map((cert) => cert.end));
2211
+ const fresh = endsBefore.filter((e) => !already.has(e));
2212
+ const old = endsBefore.filter((e) => already.has(e));
2213
+ if (fresh.length > 0) {
2214
+ const at = new Date().toISOString();
2215
+ try {
2216
+ vaultSeal.appendDirCertificates(located.abs, fresh.map((end) => ({ end, head, at })));
2217
+ }
2218
+ catch (e) {
2219
+ refuse('write-failed', `${file} could not be written (${boundedPath(e instanceof Error ? e.message : String(e))})`);
2220
+ return;
2221
+ }
2222
+ }
2223
+ emit({ ok: true, action: 'certify', dir: located.rel, file, head, certified: fresh, alreadyCertified: old, ...(fresh.length === 0 ? { kept: true } : {}) }, fresh.length === 0
2224
+ ? `Kept: every end anchor of ${located.rel} is already certified (${old.length}); nothing written.`
2225
+ : `Certified ${fresh.length} end anchor(s) of ${located.rel} at HEAD ${head} after its strict check passed clean (${old.length} already certified).`);
2226
+ }
2227
+ /**
2228
+ * The text's lines, split on the same boundaries the record parsers split on
2229
+ * (`\r\n`, `\r`, `\n`), with their offsets, so an insertion lands at a line the
2230
+ * parser counted and every other byte stays where it was.
2231
+ */
2232
+ function lineSpans(text) {
2233
+ const spans = [];
2234
+ const re = /\r\n|\r|\n/g;
2235
+ let last = 0;
2236
+ let m;
2237
+ while ((m = re.exec(text)) !== null) {
2238
+ spans.push({ start: last, end: m.index, eol: m[0] });
2239
+ last = m.index + m[0].length;
2240
+ }
2241
+ spans.push({ start: last, end: text.length, eol: '' });
2242
+ return spans;
2243
+ }
2244
+ /** Insert `lines` as new lines directly after line `after`, in that line's own line ending. */
2245
+ function insertLinesAfter(text, spans, after, lines) {
2246
+ const eol = spans[after].eol || (spans.find((sp) => sp.eol !== '')?.eol ?? '\n');
2247
+ const at = spans[after].end;
2248
+ return `${text.slice(0, at)}${lines.map((l) => `${eol}${l}`).join('')}${text.slice(at)}`;
2249
+ }
2250
+ /**
2251
+ * The index of the practices ledger's `generatedAt` line, the line the
2252
+ * serializer writes the anchors after, located the way the parser locates the
2253
+ * block: the frontmatter from line 0 to the first later line opening `---`, the
2254
+ * top-level `vault:` key, its `predicate:` child, and that child's own
2255
+ * `generatedAt:` key. Also returns the indent of the predicate's keys and one
2256
+ * indentation step, so the spliced lines sit at the ledger's own indentation.
2257
+ * `null` when any of these is missing.
2258
+ */
2259
+ function ledgerGeneratedAtLine(text, spans) {
2260
+ const line = (i) => text.slice(spans[i].start, spans[i].end);
2261
+ const indentOf = (l) => l.length - l.trimStart().length;
2262
+ if (spans.length < 3 || line(0) !== '---')
2263
+ return null;
2264
+ let close = -1;
2265
+ for (let i = 1; i < spans.length; i += 1) {
2266
+ if (line(i).startsWith('---')) {
2267
+ close = i;
2268
+ break;
2269
+ }
2270
+ }
2271
+ if (close === -1)
2272
+ return null;
2273
+ const vaultAt = Array.from({ length: close - 1 }, (_, k) => k + 1).find((i) => /^vault:\s*$/.test(line(i)));
2274
+ if (vaultAt === undefined)
2275
+ return null;
2276
+ let predicateAt = -1;
2277
+ let childIndent = -1;
2278
+ for (let i = vaultAt + 1; i < close; i += 1) {
2279
+ const l = line(i);
2280
+ if (l.trim() === '')
2281
+ continue;
2282
+ const ind = indentOf(l);
2283
+ if (ind === 0)
2284
+ break;
2285
+ if (childIndent === -1)
2286
+ childIndent = ind;
2287
+ if (ind === childIndent && l.trim() === 'predicate:') {
2288
+ predicateAt = i;
2289
+ break;
2290
+ }
2291
+ }
2292
+ if (predicateAt === -1)
2293
+ return null;
2294
+ let keyIndent = -1;
2295
+ for (let i = predicateAt + 1; i < close; i += 1) {
2296
+ const l = line(i);
2297
+ if (l.trim() === '')
2298
+ continue;
2299
+ const ind = indentOf(l);
2300
+ if (ind <= childIndent)
2301
+ break;
2302
+ if (keyIndent === -1)
2303
+ keyIndent = ind;
2304
+ if (ind === keyIndent && l.trimStart().startsWith('generatedAt:')) {
2305
+ return { index: i, keyIndent, step: keyIndent - childIndent };
2306
+ }
2307
+ }
2308
+ return null;
2309
+ }
2310
+ /** The parsed statement minus its anchor keys, as text, for the before/after equality the back-fill asserts. */
2311
+ function statementWithoutAnchors(statement) {
2312
+ const st = JSON.parse(JSON.stringify(statement ?? null));
2313
+ if (st && st.predicate) {
2314
+ for (const k of ['startCommit', 'endCommit', 'anchorBackfill', 'anchorsDropped'])
2315
+ delete st.predicate[k];
2316
+ }
2317
+ return JSON.stringify(st);
2318
+ }
2319
+ /**
2320
+ * `vault anchor backfill <summary> --at <sha>` (16-06, D-32):
2321
+ * give a record written before anchors existed its anchors, ATTRIBUTED. There
2322
+ * is no silent restamp at HEAD: `--at` is required (the operator states which
2323
+ * commit the record's work ended at), it must be an ancestor of HEAD, and the
2324
+ * record gains, beside the anchor, who ran the back-fill (`git config
2325
+ * user.name`, bounded) and when. 16-07 reports a back-filled anchor as a note
2326
+ * on every later check. `--at HEAD` is allowed, and is still attributed.
2327
+ *
2328
+ * - practices (a SUMMARY's execute-stage `vault:` ledger, tried first):
2329
+ * `predicate.endCommit` = `--at` and `predicate.anchorBackfill: { by, at }`
2330
+ * (no start: the record takes its directory's, D-59), spliced after the
2331
+ * ledger's `generatedAt` line with the serializer's own lines
2332
+ * (`traceAnchorLines`), so a later `trace render` round trip agrees;
2333
+ * - standards (a `## Standards Consulted` section): the anchor line with its
2334
+ * `backfilled by <who> at <iso>` tail, inserted as the section's last line.
2335
+ *
2336
+ * Every other byte of the file is kept: the new text is the old one with lines
2337
+ * inserted, and it is parsed back before it is written — the anchors must read
2338
+ * back as written and the rest of the record must parse to exactly what it
2339
+ * parsed to before, or nothing is written. The write is a temporary sibling
2340
+ * renamed into place.
2341
+ *
2342
+ * Refused, writing nothing: a record that already carries an anchor (a valid,
2343
+ * a dropped or a malformed one: `already-anchored`), a file with no record of
2344
+ * either family (`no-record`), an `--at` that is not an ancestor of HEAD. A
2345
+ * path outside `.planning/`, a malformed sha and `--from`/`--attest` beside
2346
+ * `--at` are argument errors (exit 1), refused before any read or git call;
2347
+ * the state refusals are `ok: false` envelopes (exit 0).
2348
+ *
2349
+ * 16 review iteration 3 CR-01 (D-102): `--at --from <commit>` set a start of
2350
+ * the operator's choosing (the root commit, say), and D-98's `--from` alone
2351
+ * widened an anchored record's start the same way; either covered every commit
2352
+ * in between with one attribution and named none. Both are gone. The one repair
2353
+ * left for a commit a record's range misses is `--from <recorded start>
2354
+ * --attest <commit>` (`attestVerb`), which covers that commit alone.
2355
+ */
2356
+ function anchorBackfillVerb({ cwd, rest, raw, error, c, exec }) {
2357
+ const usage = 'Usage: vault anchor backfill <summary-path> --at <commit> [--raw], or, on a record that carries an end, '
2358
+ + '--from <a start its directory recorded> --attest <commit> [--attest <commit> ...] [--raw].';
2359
+ const positionals = [];
2360
+ const shas = {};
2361
+ const attest = [];
2362
+ for (let i = 0; i < rest.length; i += 1) {
2363
+ const a = rest[i];
2364
+ if (a === '--raw')
2365
+ continue;
2366
+ if (a === '--at' || a === '--from' || a === '--attest') {
2367
+ const v = rest[i + 1];
2368
+ if (v === undefined || v.startsWith('--')) {
2369
+ error(`${a} needs a commit. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
2370
+ return;
2371
+ }
2372
+ if (a === '--attest')
2373
+ attest.push(v);
2374
+ else
2375
+ shas[a] = v;
2376
+ i += 1;
2377
+ continue;
2378
+ }
2379
+ if (a.startsWith('--')) {
2380
+ error(`Unrecognized flag "${boundedPath(a)}" for vault anchor backfill. Supported: --at <commit>, --from <commit>, --attest <commit>, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
2381
+ return;
2382
+ }
2383
+ positionals.push(a);
2384
+ }
2385
+ if (positionals.length !== 1) {
2386
+ error(`vault anchor backfill takes exactly one SUMMARY path. ${usage}`, positionals.length === 0 ? ERROR_REASON.SDK_MISSING_ARG : ERROR_REASON.SDK_FAIL_FAST);
2387
+ return;
2388
+ }
2389
+ if (shas['--at'] === undefined && shas['--from'] === undefined) {
2390
+ 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);
2391
+ return;
2392
+ }
2393
+ // 16 review iteration 3 CR-01 (D-102): nothing widens a record's start any
2394
+ // more. `--at` gives a record with no anchor its END (its start is its
2395
+ // directory's, D-59); `--from` only bounds `--attest`, which names each
2396
+ // commit that counts as covered, one by one.
2397
+ if (shas['--at'] !== undefined && (shas['--from'] !== undefined || attest.length > 0)) {
2398
+ 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);
2399
+ return;
2400
+ }
2401
+ if (shas['--at'] === undefined && (shas['--from'] === undefined || attest.length === 0)) {
2402
+ 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);
2403
+ return;
2404
+ }
2405
+ for (const [flag, value] of [['--at', shas['--at']], ['--from', shas['--from']], ...attest.map((v) => ['--attest', v])]) {
2406
+ if (value !== undefined && !vaultSeal.ANCHOR_SHA_RE.test(value)) {
2407
+ error(`${flag} is not a full lowercase commit id (40 or 64 hex). ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
2408
+ return;
2409
+ }
2410
+ }
2411
+ const located = resolvePlanningPath(cwd, positionals[0], 'file');
2412
+ if (!located.ok) {
2413
+ error(`The SUMMARY path ${located.message}; a back-fill rewrites only a record under .planning/.`, ERROR_REASON.SDK_FAIL_FAST);
2414
+ return;
2415
+ }
2416
+ const at = shas['--at'];
2417
+ const from = shas['--from'];
2418
+ const emit = (result, text) => {
2419
+ c.output(result, true, raw ? JSON.stringify(result) : text);
2420
+ };
2421
+ const refuse = (reason, message) => {
2422
+ emit({ ok: false, action: 'backfill', file: located.rel, reason, message }, `No anchor was back-filled: ${message}.`);
2423
+ };
2424
+ const size = node_fs_1.default.statSync(located.abs).size;
2425
+ if (size > vaultSeal.TRACE_ARTIFACT_BYTE_CAP) {
2426
+ refuse('too-large', `${located.rel} is ${size} bytes, above the ${vaultSeal.TRACE_ARTIFACT_BYTE_CAP}-byte read cap the record readers apply`);
728
2427
  return;
729
2428
  }
2429
+ const text = node_fs_1.default.readFileSync(located.abs, 'utf8');
730
2430
  // 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);
743
- return;
744
- }
745
- const text = leaf.renderReviewScaffold(payload, {
746
- date: new Date().toISOString().slice(0, 10),
747
- phaseToken: target.phaseToken,
2431
+ const traceLeaf = require('./vault-trace.cjs');
2432
+ const ledger = traceLeaf.parseVaultTraceBlock(text, located.rel);
2433
+ const st = (ledger.present ? ledger.statement : null);
2434
+ const isLedger = st !== null && st._type === traceLeaf.TRACE_STATEMENT_TYPE && st.predicateType === traceLeaf.TRACE_PREDICATE_TYPE
2435
+ && st.predicate !== undefined && st.predicate.stage === 'execute';
2436
+ const section = isLedger ? null : vaultStandardsLeaf.parseConsultedSection(text);
2437
+ const isSection = section !== null && section.present && section.planId !== null;
2438
+ if (!isLedger && !isSection) {
2439
+ refuse('no-record', `${located.rel} carries no execute-stage vault: ledger and no ${vaultStandardsLeaf.STANDARDS_CONSULTED_HEADING} section to back-fill`);
2440
+ return;
2441
+ }
2442
+ const anchored = isLedger
2443
+ ? ['startCommit', 'endCommit', 'anchorBackfill', 'anchorsDropped'].some((k) => k in st.predicate)
2444
+ : section.anchors !== undefined
2445
+ || section.problems.some((p) => p.kind === vaultStandardsLeaf.STANDARDS_PROBLEM_KIND.ANCHOR_MALFORMED);
2446
+ if (at === undefined) {
2447
+ // `--from` + `--attest`: the attestation (16 review iteration 3 CR-01, D-102).
2448
+ if (!anchored) {
2449
+ 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)`);
2450
+ return;
2451
+ }
2452
+ attestVerb({
2453
+ cwd, exec, text, located, from, attest, isLedger, refuse, emit, traceLeaf,
748
2454
  });
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
2455
  return;
757
2456
  }
758
- // finalize
759
- let current;
2457
+ if (anchored) {
2458
+ 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)`);
2459
+ return;
2460
+ }
2461
+ // The shas reach git only now, shape-checked, after the subcommand (T-16-21).
2462
+ if (exec(['merge-base', '--is-ancestor', at, 'HEAD'], { cwd }).exitCode !== 0) {
2463
+ refuse('at-not-ancestor', `--at ${at} is not an ancestor of HEAD (or is not in this repository)`);
2464
+ return;
2465
+ }
2466
+ const backfill = { by: traceLeaf.resolveBackfillIdentity(cwd), at: new Date().toISOString() };
2467
+ const spans = lineSpans(text);
2468
+ let next;
2469
+ if (isLedger) {
2470
+ const where = ledgerGeneratedAtLine(text, spans);
2471
+ if (where === null) {
2472
+ refuse('rewrite-unverifiable', `the ledger in ${located.rel} has no generatedAt line where the anchors belong`);
2473
+ return;
2474
+ }
2475
+ // The serializer's lines, re-indented to this ledger's own indentation.
2476
+ const lines = traceLeaf.traceAnchorLines({ ...(from !== undefined ? { startCommit: from } : {}), endCommit: at, anchorBackfill: backfill })
2477
+ .map((l) => {
2478
+ const depth = (l.length - l.trimStart().length) / 2 - 2;
2479
+ return `${' '.repeat(where.keyIndent + depth * where.step)}${l.trimStart()}`;
2480
+ });
2481
+ next = insertLinesAfter(text, spans, where.index, lines);
2482
+ const back = traceLeaf.parseVaultTraceBlock(next, located.rel);
2483
+ const bp = back.present ? back.statement.predicate : {};
2484
+ const agrees = back.present
2485
+ && bp.endCommit === at
2486
+ && bp.startCommit === from
2487
+ && JSON.stringify(bp.anchorBackfill) === JSON.stringify(backfill)
2488
+ && !('anchorsDropped' in bp)
2489
+ && statementWithoutAnchors(back.statement) === statementWithoutAnchors(st);
2490
+ if (!agrees) {
2491
+ refuse('rewrite-unverifiable', `the back-filled ledger in ${located.rel} would not read back as written, so nothing was written`);
2492
+ return;
2493
+ }
2494
+ }
2495
+ else {
2496
+ const live = vaultStandardsLeaf.findLiveSection(text, vaultStandardsLeaf.STANDARDS_CONSULTED_HEADING);
2497
+ const lastBody = live.body[live.body.length - 1];
2498
+ const line = vaultStandardsLeaf.formatStandardsAnchorLine({ start: from ?? null, end: at, backfill });
2499
+ next = insertLinesAfter(text, spans, lastBody, [line]);
2500
+ const back = vaultStandardsLeaf.parseConsultedSection(next);
2501
+ const before = section;
2502
+ const expected = { ...(from !== undefined ? { start: from } : {}), end: at, backfill };
2503
+ const strip = (p) => JSON.stringify({ ...p, anchors: undefined, headingLine: undefined });
2504
+ const agrees = back.present
2505
+ && JSON.stringify(back.anchors) === JSON.stringify({ end: expected.end, ...(from !== undefined ? { start: from } : {}), backfill })
2506
+ && strip(back) === strip(before);
2507
+ if (!agrees) {
2508
+ refuse('rewrite-unverifiable', `the back-filled section in ${located.rel} would not read back as written, so nothing was written`);
2509
+ return;
2510
+ }
2511
+ }
2512
+ const tmp = `${located.abs}.${process.pid}.tmp`;
760
2513
  try {
761
- current = node_fs_1.default.readFileSync(abs, 'utf-8');
2514
+ node_fs_1.default.writeFileSync(tmp, next, 'utf8');
2515
+ node_fs_1.default.renameSync(tmp, located.abs);
762
2516
  }
763
2517
  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);
766
- return;
767
- }
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,
2518
+ try {
2519
+ node_fs_1.default.unlinkSync(tmp);
2520
+ }
2521
+ catch { /* nothing to clean */ }
2522
+ refuse('write-failed', `${located.rel} could not be written`);
2523
+ return;
2524
+ }
2525
+ const result = {
2526
+ ok: true,
2527
+ action: 'backfill',
2528
+ file: located.rel,
2529
+ family: isLedger ? 'practices' : 'standards',
2530
+ endCommit: at,
2531
+ ...(from !== undefined ? { startCommit: from } : {}),
2532
+ backfilledBy: backfill.by,
2533
+ backfilledAt: backfill.at,
779
2534
  };
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);
2535
+ emit(result, `Back-filled ${located.rel}: end ${at}${from !== undefined ? `, start ${from}` : ''}, by ${backfill.by} at ${backfill.at}.`);
2536
+ }
2537
+ /**
2538
+ * The attestation (16 review iteration 3 CR-01, D-102; replaces D-98's start
2539
+ * back-fill): `vault anchor backfill <summary> --from <start> --attest <commit>
2540
+ * [--attest <commit> ...]` on a record that already carries an end.
2541
+ *
2542
+ * D-98 records a new directory start on every resume, so the phase's own
2543
+ * commits made between its last record and the resume are covered by nothing,
2544
+ * and the strict gate names them. Whether such a commit is the phase's work or
2545
+ * an ungoverned one is not something the engine can tell. D-98's repair widened
2546
+ * the record's START, which covered every commit in between — an ungoverned one
2547
+ * made by someone else between the runs included — and left a note naming none
2548
+ * of them. An attestation widens nothing: each `--attest` names ONE commit, and
2549
+ * that commit alone counts as covered by the record. Every later strict check
2550
+ * lists it, with its subject and paths and who attested it. A commit nobody
2551
+ * attested stays named, and stays a block.
2552
+ *
2553
+ * Refused, writing nothing (each an `ok: false` envelope):
2554
+ * - a record with no valid end (`at-required`, `no-end`) or a malformed anchor;
2555
+ * - `--from` that is not a start the record's own directory recorded
2556
+ * (`vault anchor start` writes them, D-98 keeps the superseded ones), so no
2557
+ * attestation reaches below where the CLI saw this phase begin
2558
+ * (`from-not-recorded`), or that is not an ancestor of the record's end;
2559
+ * - an `--attest` that is not on the record's own (first-parent) line between
2560
+ * `--from` and its end, a merge commit, a commit touching no source path, one
2561
+ * the record already covers, one already attested on it, or given twice.
2562
+ *
2563
+ * The attestations are written to `<dir>/.vault-attestations.json` (the seal
2564
+ * leaf's `appendDirAttestations`), never into the record: the record's bytes,
2565
+ * and the range they state, do not change. `by` is the git `user.name` of
2566
+ * whoever ran the command — what the CLI can read, not a verified identity —
2567
+ * and the envelope says so (`byVerified: false`).
2568
+ */
2569
+ function attestVerb({ cwd, exec, text, located, from, attest, isLedger, refuse, emit, traceLeaf }) {
2570
+ const dirAbs = node_path_1.default.dirname(located.abs);
2571
+ const recordName = node_path_1.default.basename(located.abs);
2572
+ const kind = isLedger ? vaultSeal.RECORDED_ARTIFACT_KIND.SUMMARY_LEDGER : vaultSeal.RECORDED_ARTIFACT_KIND.STANDARDS_CONSULTED;
2573
+ const isAncestor = (a, d) => vaultSeal.isAnchorSha(a) && vaultSeal.isAnchorSha(d)
2574
+ && exec(['merge-base', '--is-ancestor', a, d], { cwd }).exitCode === 0;
2575
+ const anchors = vaultSeal.resolveRecordAnchors(text, kind, dirAbs, { isAncestor });
2576
+ if (anchors.problems.some((p) => p.kind === 'anchor_invalid')) {
2577
+ 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`);
2578
+ return;
2579
+ }
2580
+ const end = anchors.end;
2581
+ if (end === undefined) {
2582
+ refuse('no-end', `${located.rel} carries no valid end anchor; give it one with --at <commit> first`);
2583
+ return;
2584
+ }
2585
+ const dir = vaultSeal.readDirAnchors(dirAbs);
2586
+ const recorded = dir === null ? [] : [dir.start, ...(dir.earlier ?? []).map((e) => e.start)];
2587
+ if (!recorded.includes(from)) {
2588
+ refuse('from-not-recorded', `--from ${from} is not a start this record's directory recorded (${recorded.length} recorded, by vault anchor start); `
2589
+ + 'an attestation reaches back only to a start the CLI recorded for this phase, never to an arbitrary commit');
2590
+ return;
2591
+ }
2592
+ if (from === end || !isAncestor(from, end)) {
2593
+ refuse('from-not-ancestor', `--from ${from} is not an ancestor of the record's end ${end}`);
2594
+ return;
2595
+ }
2596
+ const cap = vaultSeal.SEAL_CANDIDATE_CAP;
2597
+ const lineRun = exec(['rev-list', '--first-parent', '--parents', `--max-count=${cap + 1}`, `^${from}`, end], { cwd });
2598
+ const rows = lineRun.exitCode === 0 ? lineRun.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).map((l) => l.split(/\s+/)) : null;
2599
+ if (rows === null || !rows.every((r) => r.every((x) => vaultSeal.isAnchorSha(x)))) {
2600
+ refuse('git-failed', 'the commits between --from and the record\'s end could not be listed');
2601
+ return;
2602
+ }
2603
+ if (rows.length > cap) {
2604
+ refuse('too-many', `more than ${cap} commits lie between --from and the record's end`);
2605
+ return;
2606
+ }
2607
+ const onLine = new Map(rows.map((r) => [r[0], r.length - 1]));
2608
+ let own = new Set();
2609
+ if (anchors.start !== undefined && isAncestor(anchors.start, end)) {
2610
+ const ownRun = exec(['rev-list', '--first-parent', `--max-count=${cap + 1}`, `^${anchors.start}`, end], { cwd });
2611
+ if (ownRun.exitCode !== 0) {
2612
+ refuse('git-failed', 'the commits the record already covers could not be listed');
2613
+ return;
2614
+ }
2615
+ own = new Set(ownRun.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean));
2616
+ }
2617
+ const already = new Set(vaultSeal.attestedFor(vaultSeal.readDirAttestations(dirAbs), recordName).map((a) => a.sha));
2618
+ const by = traceLeaf.resolveBackfillIdentity(cwd);
2619
+ const at = new Date().toISOString();
2620
+ const entries = [];
2621
+ for (const sha of attest) {
2622
+ if (entries.some((e) => e.sha === sha)) {
2623
+ refuse('attest-duplicate', `--attest ${sha} was given twice`);
2624
+ return;
2625
+ }
2626
+ if (!onLine.has(sha)) {
2627
+ refuse('attest-not-in-range', `--attest ${sha} is not on ${located.rel}'s own line between --from ${from} and its end ${end}`);
2628
+ return;
2629
+ }
2630
+ if (onLine.get(sha) > 1) {
2631
+ refuse('attest-merge', `--attest ${sha} is a merge commit; attest the commits it brought in, each by its own id`);
2632
+ return;
2633
+ }
2634
+ if (own.has(sha)) {
2635
+ refuse('attest-already-covered', `--attest ${sha} is already inside ${located.rel}'s own range; it needs no attestation`);
2636
+ return;
2637
+ }
2638
+ if (already.has(sha)) {
2639
+ refuse('attest-already-attested', `--attest ${sha} is already attested on ${located.rel}`);
2640
+ return;
2641
+ }
2642
+ const tree = exec(['diff-tree', '-r', '-z', '--root', '--no-commit-id', '--name-only', '--no-renames', sha], { cwd });
2643
+ const subj = exec(['log', '-1', '--format=%s', sha], { cwd });
2644
+ if (tree.exitCode !== 0 || subj.exitCode !== 0) {
2645
+ refuse('git-failed', `the paths or subject of ${sha} could not be read`);
2646
+ return;
2647
+ }
2648
+ const paths = tree.stdout.split('\0').filter((p) => p !== '' && !vaultSeal.isNonSourcePath(p));
2649
+ if (paths.length === 0) {
2650
+ refuse('attest-not-source', `--attest ${sha} touches no governed source path, so no check names it and there is nothing to attest`);
2651
+ return;
2652
+ }
2653
+ const flat = (v, n) => {
2654
+ const f = v.replace(/[\u0000-\u001F\u007F-\u009F]+/g, ' ').replace(/\s+/g, ' ').trim();
2655
+ return f.length > n ? `${f.slice(0, n)}…` : f;
2656
+ };
2657
+ entries.push({
2658
+ record: recordName,
2659
+ from,
2660
+ sha,
2661
+ subject: flat(subj.stdout, vaultSeal.SEAL_SUBJECT_CAP),
2662
+ paths: paths.slice(0, vaultSeal.ATTESTATION_PATHS_CAP).map((p) => flat(p, 1000)),
2663
+ by,
2664
+ at,
2665
+ });
2666
+ }
2667
+ try {
2668
+ vaultSeal.appendDirAttestations(dirAbs, entries);
2669
+ }
2670
+ catch (e) {
2671
+ refuse('write-failed', `${vaultSeal.VAULT_ATTESTATIONS_FILE} next to ${located.rel} could not be written (${boundedPath(e instanceof Error ? e.message : String(e))})`);
2672
+ return;
2673
+ }
2674
+ const listed = entries.map((e) => ({ sha: e.sha, subject: e.subject, paths: e.paths }));
2675
+ emit({
2676
+ ok: true,
2677
+ action: 'attest',
2678
+ file: located.rel,
2679
+ family: isLedger ? 'practices' : 'standards',
2680
+ from,
2681
+ endCommit: end,
2682
+ attested: listed,
2683
+ attestedBy: by,
2684
+ byVerified: false,
2685
+ attestedAt: at,
2686
+ }, `Attested ${entries.length} commit${entries.length === 1 ? '' : 's'} on ${located.rel}, by ${by} (the git user.name, not verified) at ${at}: `
2687
+ + `${entries.map((e) => `${e.sha.slice(0, 12)} "${e.subject}" (${e.paths.join(', ')})`).join('; ')}. Every later strict check lists each one.`);
2688
+ }
2689
+ /** The longest free-text value a door record line carries (the reason-clause bound, with room). */
2690
+ const DOOR_TEXT_MAX = 190;
2691
+ /**
2692
+ * A free-text value made safe for a door record line: the vault leaf's display
2693
+ * sanitiser (controls, bidi, zero-width, fence markers), backticks turned into
2694
+ * apostrophes, bounded. Empty or the literal `none` becomes `unknown`, because
2695
+ * `none` is how a door record spells an absent value.
2696
+ */
2697
+ function doorText(value, opts = {}) {
2698
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
2699
+ const indexLeaf = require('./vault-index.cjs');
2700
+ let t = indexLeaf.sanitiseVaultText(typeof value === 'string' ? value : '', DOOR_TEXT_MAX).replace(/`/g, "'").trim();
2701
+ if (opts.token === true)
2702
+ t = t.replace(/\s+/g, '_');
2703
+ return t === '' || t === 'none' ? 'unknown' : t;
2704
+ }
2705
+ /**
2706
+ * The vault coordinates a door worked under, or the degraded one-liner saying
2707
+ * why there are none (D-12 "or") — read by the SAME pin reader the mode's own
2708
+ * record verb uses, never a third reader of the manifest:
2709
+ *
2710
+ * - practices: the trace scaffold's `resolvePin` over the one resolved cache.
2711
+ * Only a `loaded` pin is recorded as coordinates; a degraded run (stale,
2712
+ * fallback tier, ref drift, miss, no cache) records its reason instead,
2713
+ * the same distinction the practices ledger draws;
2714
+ * - standards: `standardsLoadedPin` over the manifest of the one resolved
2715
+ * cache, the block verb's reader; no loaded pin records the degraded line
2716
+ * `vault standards block` would write for it.
2717
+ *
2718
+ * The repo has already been through `redactCloneTarget` (both readers go
2719
+ * through `readManifest`), so no credential reaches the record.
2720
+ */
2721
+ function doorVaultPin(cwd, mode, owner) {
2722
+ const cacheDir = owner.resolveVaultCacheDir(cwd).dir;
2723
+ if (mode === 'practices') {
2724
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
2725
+ const traceLeaf = require('./vault-trace.cjs');
2726
+ const pin = traceLeaf.resolvePin(cwd, cacheDir);
2727
+ if (pin.state === 'loaded' && typeof pin.ref === 'string' && vaultSeal.ANCHOR_SHA_RE.test(pin.resolvedSha ?? '')) {
2728
+ return { vault: { repo: doorText(pin.repo, { token: true }), ref: doorText(pin.ref, { token: true }), sha: pin.resolvedSha }, degraded: null };
2729
+ }
2730
+ return { vault: null, degraded: doorText(pin.reason ?? 'the vault was not loaded') };
2731
+ }
2732
+ const pin = vaultStandardsLeaf.standardsLoadedPin(owner.readManifest(cacheDir));
2733
+ if (pin !== null && vaultSeal.ANCHOR_SHA_RE.test(pin.sha)) {
2734
+ return { vault: { repo: doorText(pin.repo, { token: true }), ref: doorText(pin.ref, { token: true }), sha: pin.sha }, degraded: null };
2735
+ }
2736
+ const line = vaultStandardsLeaf.standardsDegradedLineFor('not-loaded');
2737
+ return { vault: null, degraded: doorText(`${line.kind}: ${line.reason}`) };
2738
+ }
2739
+ /**
2740
+ * Where a door artifact may live: a phase directory (root or workstream), a
2741
+ * quick-task directory, or `.planning/audits`. Those are exactly the
2742
+ * directories the seal readers walk (`hasRecordedVaultArtifacts`, 16-07's
2743
+ * covering-record walk), so a record written anywhere else would be a record
2744
+ * no reader ever finds: the door would believe it was recorded and nothing
2745
+ * would see it (D-55).
2746
+ */
2747
+ function isDoorArtifactLocation(rel) {
2748
+ const parts = rel.split('/');
2749
+ if (parts[0] !== '.planning')
2750
+ return false;
2751
+ if (parts.length === 4 && (parts[1] === 'phases' || parts[1] === 'quick'))
2752
+ return true;
2753
+ if (parts.length === 6 && parts[1] === 'workstreams' && parts[3] === 'phases')
2754
+ return true;
2755
+ return parts.length === 3 && parts[1] === 'audits';
2756
+ }
2757
+ /**
2758
+ * `vault door-record --begin --door <name>` (16 review CR-01): record HEAD as
2759
+ * the door's START in `.planning/.vault-door-starts.json` (the seal leaf's
2760
+ * `writeDoorStart`), before the door's agent runs. The record step then reads
2761
+ * it, so the start survives the agent's fresh shell per step and is never
2762
+ * typed. Called after admission (`on`) and the door-name check; takes no other
2763
+ * flag. Every refusal is an error (exit 1), like the record itself: a door that
2764
+ * cannot capture its start halts before its agent writes anything.
2765
+ */
2766
+ function doorBegin({ cwd, door, extra, raw, error, c, exec, usage }) {
2767
+ if (extra.length > 0) {
2768
+ error(`--begin takes only --door; ${extra.join(', ')} belong to the record step. ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
2769
+ return;
2770
+ }
2771
+ const root = projectRootOf(cwd);
2772
+ const planning = node_path_1.default.join(root, '.planning');
2773
+ try {
2774
+ if (!node_fs_1.default.statSync(planning).isDirectory())
2775
+ throw new Error('not a directory');
2776
+ }
2777
+ catch {
2778
+ error('The project has no .planning/ directory, so the door start has nowhere to be recorded.', ERROR_REASON.SDK_FAIL_FAST);
2779
+ return;
2780
+ }
2781
+ const head = exec(['rev-parse', 'HEAD'], { cwd });
2782
+ const sha = head.exitCode === 0 ? head.stdout.trim() : '';
2783
+ if (!vaultSeal.ANCHOR_SHA_RE.test(sha)) {
2784
+ 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);
2785
+ return;
2786
+ }
2787
+ let recorded;
2788
+ try {
2789
+ recorded = vaultSeal.writeDoorStart(planning, door, sha);
2790
+ }
2791
+ catch {
2792
+ error(`.planning/${vaultSeal.VAULT_DOOR_STARTS_FILE} could not be written, so no door start was recorded.`, ERROR_REASON.UNKNOWN);
2793
+ return;
2794
+ }
2795
+ const file = `.planning/${vaultSeal.VAULT_DOOR_STARTS_FILE}`;
2796
+ const result = { ok: true, action: 'begin', door, start: recorded.start, recordedAt: recorded.recordedAt, file };
2797
+ c.output(result, true, raw ? JSON.stringify(result) : `Recorded the ${door} door's start ${recorded.start.slice(0, 12)} in ${file}.`);
2798
+ }
2799
+ /**
2800
+ * `vault door-record --door <name> --artifact <path> [--phase <N|dir>]`
2801
+ * (16-06, D-12), after `vault door-record --begin --door <name>` (16 review
2802
+ * CR-01): the ONE verb that writes a door's record, a mode-stamped
2803
+ * `## Vault Door Record` section (`formatDoorRecord`, the seal leaf's grammar)
2804
+ * appended to the door's own artifact.
2805
+ *
2806
+ * The record carries the mode, the vault ref and sha (or the degraded
2807
+ * one-liner), the governing phase, the START commit the door captured before
2808
+ * its agent ran, the END commit (HEAD now) and when it was written. The start
2809
+ * is the one the door's `--begin` recorded in `.planning/`, and nothing else
2810
+ * (16 review iteration 3 CR-02, D-103: an explicit `--start` was a side door
2811
+ * that widened a record to any ancestor of HEAD with no note); nothing in the
2812
+ * record is typed by an agent (the
2813
+ * Phase 15 D-06 lesson), and no shell variable has to survive between the
2814
+ * door's steps.
2815
+ *
2816
+ * ORDER: admission first and silent (D-13: no output, no file, unless the
2817
+ * producer admission verdict answers `on` — the one every vault record producer
2818
+ * asks, quick 261015-ect QD-10: the cwd's own verdict except in a linked
2819
+ * worktree with no bindings of its own, where it is the owning checkout's; the
2820
+ * door fences probe the cwd's bindings first and never reach here there), then
2821
+ * the arguments, the door name, the artifact
2822
+ * (containment ladder, `.planning/` only, a seal-walked directory, a door
2823
+ * artifact filename), the start (anchor shape BEFORE it reaches git, then an
2824
+ * ancestor of HEAD, with the revision after the subcommand, T-16-21), the
2825
+ * phase, HEAD, the pin.
2826
+ *
2827
+ * APPEND, NEVER REWRITE: existing content is kept byte for byte and the section
2828
+ * goes after it; an absent artifact is created with a one-line header. An
2829
+ * artifact that already carries a door record is REFUSED rather than given a
2830
+ * second one, because two sections are a duplicate the reader rejects, which
2831
+ * would leave the door with no readable record at all. The composed text is
2832
+ * read back with `parseDoorRecord` before anything is written, so the verb can
2833
+ * never write a record its own reader refuses (an unclosed fence above the
2834
+ * section, say), and the write is a temporary sibling renamed into place.
2835
+ *
2836
+ * Every refusal is an error (exit 1): a door that cannot record halts its
2837
+ * fence instead of passing unrecorded.
2838
+ */
2839
+ function doorRecordVerb({ cwd, rest, raw, error, c, exec }) {
2840
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
2841
+ const owner = require('./vault.cjs');
2842
+ if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
2843
+ return;
2844
+ const usage = 'Usage: vault door-record --begin --door <name> [--raw], then vault door-record --door <name> --artifact <path under .planning/> [--phase <N|dir>] [--raw].';
2845
+ const values = {};
2846
+ let begin = false;
2847
+ for (let i = 0; i < rest.length; i += 1) {
2848
+ const a = rest[i];
2849
+ if (a === '--raw')
2850
+ continue;
2851
+ if (a === '--begin') {
2852
+ begin = true;
2853
+ continue;
2854
+ }
2855
+ if (DOOR_RECORD_FLAGS.includes(a)) {
2856
+ const v = rest[i + 1];
2857
+ if (v === undefined || v.startsWith('--')) {
2858
+ error(`${a} needs a value. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
2859
+ return;
2860
+ }
2861
+ if (values[a] !== undefined) {
2862
+ error(`${a} was given more than once. ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
2863
+ return;
2864
+ }
2865
+ values[a] = v;
2866
+ i += 1;
2867
+ continue;
2868
+ }
2869
+ 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);
2870
+ return;
2871
+ }
2872
+ if (values['--door'] === undefined) {
2873
+ error(`vault door-record needs --door. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
2874
+ return;
2875
+ }
2876
+ const door = values['--door'];
2877
+ if (!vaultSeal.DOOR_NAMES.includes(door)) {
2878
+ error(`"${boundedPath(door)}" is not a door that writes a record. Allowed doors: ${vaultSeal.DOOR_NAMES.join(', ')}.`, ERROR_REASON.SDK_FAIL_FAST);
2879
+ return;
2880
+ }
2881
+ if (begin) {
2882
+ doorBegin({ cwd, door, extra: Object.keys(values).filter((k) => k !== '--door'), raw, error, c, exec, usage });
2883
+ return;
2884
+ }
2885
+ if (values['--artifact'] === undefined) {
2886
+ error(`vault door-record needs --artifact. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
2887
+ return;
2888
+ }
2889
+ const located = resolvePlanningPath(cwd, values['--artifact'], 'artifact');
2890
+ if (!located.ok) {
2891
+ 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);
2892
+ return;
2893
+ }
2894
+ if (!isDoorArtifactLocation(located.rel)) {
2895
+ error(`The door artifact "${boundedPath(located.rel)}" is not in a phase directory, a quick-task directory or .planning/audits, ` +
2896
+ 'the directories the seal readers walk; a record anywhere else would never be read.', ERROR_REASON.SDK_FAIL_FAST);
2897
+ return;
2898
+ }
2899
+ // 16 review IN-02: the readers still recognise a `-VALIDATION.md` door
2900
+ // artifact (a legacy record stays readable), but the verb never WRITES one:
2901
+ // a `*-VALIDATION.md` file makes a phase read as validated (D-79 (3)).
2902
+ const artifactName = node_path_1.default.basename(located.abs);
2903
+ if (!vaultSeal.DOOR_ARTIFACT_FILENAME_RE.test(artifactName) || /(^|-)VALIDATION\.md$/.test(artifactName)) {
2904
+ error(`The door artifact "${boundedPath(artifactName)}" does not end in -REVIEW-FIX.md, -ADD-TESTS.md or -AUDIT-FIX.md, ` +
2905
+ 'the door artifact names the verb writes (a -VALIDATION.md file would make the phase read as validated).', ERROR_REASON.SDK_FAIL_FAST);
2906
+ return;
2907
+ }
2908
+ // The start is the one this door's `--begin` recorded in `.planning/`, and
2909
+ // only that (16 review iteration 3 CR-02, D-103: `--start` is gone), so no
2910
+ // shell variable has to survive between the two steps, the agent never types
2911
+ // a sha, and no argument can move the start. It reaches git only after it has
2912
+ // the anchor shape, and only AFTER the subcommand and its flag (T-16-21).
2913
+ const recorded = vaultSeal.readDoorStart(node_path_1.default.join(located.root, '.planning'), door);
2914
+ if (recorded === null) {
2915
+ error(`No start was recorded for the ${door} door, so its range is unknown and the record was not written. `
2916
+ + `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);
2917
+ return;
2918
+ }
2919
+ const start = recorded.start;
2920
+ if (exec(['merge-base', '--is-ancestor', start, 'HEAD'], { cwd }).exitCode !== 0) {
2921
+ 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. `
2922
+ + `Run \`pp-tools vault door-record --begin --door ${door}\` again, then redo the door's work.`, ERROR_REASON.SDK_FAIL_FAST);
2923
+ return;
2924
+ }
2925
+ const headRun = exec(['rev-parse', 'HEAD'], { cwd });
2926
+ const end = headRun.exitCode === 0 ? headRun.stdout.trim() : '';
2927
+ if (!vaultSeal.ANCHOR_SHA_RE.test(end)) {
2928
+ 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);
2929
+ return;
2930
+ }
2931
+ let phase = null;
2932
+ if (values['--phase'] !== undefined) {
2933
+ const resolved = owner.resolveTracePhaseDir(located.root, cwd, values['--phase']);
2934
+ if (!resolved) {
2935
+ error(`--phase "${boundedPath(values['--phase'])}" is neither a phase directory inside the project nor a phase the locator resolves.`, ERROR_REASON.SDK_FAIL_FAST);
2936
+ return;
2937
+ }
2938
+ phase = doorText(resolved.rel, { token: true });
2939
+ }
2940
+ const modeVerdict = owner.vaultModeVerdict(cwd);
2941
+ const mode = modeVerdict.active && modeVerdict.ok === true && (modeVerdict.mode === 'practices' || modeVerdict.mode === 'standards')
2942
+ ? modeVerdict.mode
2943
+ : null;
2944
+ if (mode === null) {
2945
+ error('The vault mode could not be resolved, so the record has no mode and was not written.', ERROR_REASON.SDK_FAIL_FAST);
2946
+ return;
2947
+ }
2948
+ const pin = doorVaultPin(cwd, mode, owner);
2949
+ let existing = '';
2950
+ if (node_fs_1.default.existsSync(located.abs)) {
2951
+ const size = node_fs_1.default.statSync(located.abs).size;
2952
+ if (size > vaultSeal.TRACE_ARTIFACT_BYTE_CAP) {
2953
+ 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);
2954
+ return;
2955
+ }
2956
+ existing = node_fs_1.default.readFileSync(located.abs, 'utf8');
2957
+ const prior = vaultSeal.parseDoorRecord(existing);
2958
+ if (!prior.ok || prior.record !== null) {
2959
+ error(`The door artifact "${boundedPath(located.rel)}" already carries a ${vaultSeal.DOOR_RECORD_HEADING} section; a second one would make both unreadable. ` +
2960
+ 'Write the door\'s artifact afresh for this run, then record it.', ERROR_REASON.SDK_FAIL_FAST);
2961
+ return;
2962
+ }
2963
+ }
2964
+ let section;
2965
+ try {
2966
+ section = vaultSeal.formatDoorRecord({
2967
+ door: door,
2968
+ mode,
2969
+ vault: pin.vault,
2970
+ degraded: pin.degraded,
2971
+ phase,
2972
+ startCommit: start,
2973
+ endCommit: end,
2974
+ recordedAt: new Date().toISOString(),
2975
+ });
2976
+ }
2977
+ catch (e) {
2978
+ error(`The door record could not be composed: ${boundedPath(e instanceof Error ? e.message : String(e))}`, ERROR_REASON.UNKNOWN);
2979
+ return;
2980
+ }
2981
+ const separator = existing === '' ? '' : existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
2982
+ const next = existing === '' ? `# ${door} door record\n\n${section}` : `${existing}${separator}${section}`;
2983
+ const readBack = vaultSeal.parseDoorRecord(next);
2984
+ if (!readBack.ok || readBack.record === null || readBack.record.startCommit !== start || readBack.record.endCommit !== end) {
2985
+ 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);
2986
+ return;
2987
+ }
2988
+ // D-100 (16 review iteration 2 WR-01): consume the `--begin` start BEFORE the
2989
+ // record is written, after every refusal above. A start that cannot be
2990
+ // consumed refuses the record; a record whose write then fails leaves the
2991
+ // door with no start, so a retry halts naming `--begin` — both arms leave
2992
+ // commits uncovered (named by SEAL-01), never a range wider than one run.
2993
+ {
2994
+ let consumed = false;
2995
+ try {
2996
+ consumed = vaultSeal.consumeDoorStart(node_path_1.default.join(located.root, '.planning'), door, start);
2997
+ }
2998
+ catch {
2999
+ consumed = false;
3000
+ }
3001
+ if (!consumed) {
3002
+ 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. `
3003
+ + `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);
3004
+ return;
3005
+ }
3006
+ }
3007
+ const tmp = `${located.abs}.${process.pid}.tmp`;
3008
+ try {
3009
+ node_fs_1.default.writeFileSync(tmp, next, 'utf8');
3010
+ node_fs_1.default.renameSync(tmp, located.abs);
3011
+ }
3012
+ catch {
3013
+ try {
3014
+ node_fs_1.default.unlinkSync(tmp);
3015
+ }
3016
+ catch { /* nothing to clean */ }
3017
+ error(`The door artifact "${boundedPath(located.rel)}" could not be written.`, ERROR_REASON.UNKNOWN);
3018
+ return;
3019
+ }
3020
+ const result = { ok: true, door, artifact: located.rel, mode, phase, startCommit: start, endCommit: end };
3021
+ c.output(result, true, raw ? JSON.stringify(result) : `Recorded the ${door} door in ${located.rel} (${start.slice(0, 12)}..${end.slice(0, 12)}).`);
793
3022
  }
794
3023
  module.exports = {
795
3024
  routeVaultCommand,
3025
+ // 13-06: exported so the D-15 sweep test and the D-08 subcommand test read
3026
+ // the vocabulary from the running module instead of restating it.
796
3027
  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.
3028
+ // 04-14: exported so the agreement and documentation tests read the SHAPE and
3029
+ // the WORDING from the running module rather than restating either. A test
3030
+ // that restated them would pass while the module drifted.
800
3031
  SCAFFOLD_PLAN_ID_RE,
801
3032
  SCAFFOLD_PLAN_ID_FORM,
802
- TRACE_STAGES,
3033
+ // 16-05 (D-29): the quick-task half both rejection messages append.
3034
+ SCAFFOLD_QUICK_ID_FORM,
3035
+ // 16-06: the anchor verbs, read by the tests from the running module.
3036
+ ANCHOR_VERBS,
3037
+ // 15-07: exported so the integration test pins it equal to the trace gate's
3038
+ // artifact read cap, which is the agreement its own comment states.
3039
+ STANDARDS_PLAN_READ_BYTES,
803
3040
  };