@pareto4/pp-core 1.13.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/.claude-plugin/plugin.json +3 -3
  2. package/README.md +31 -12
  3. package/agents/pp-code-fixer.md +0 -11
  4. package/agents/pp-code-reviewer.md +1 -77
  5. package/agents/pp-debug-session-manager.md +9 -6
  6. package/agents/pp-executor.md +12 -29
  7. package/agents/pp-plan-checker.md +4 -4
  8. package/agents/pp-planner.md +34 -20
  9. package/agents/pp-vault-reviewer.md +100 -23
  10. package/agents/pp-verifier.md +1 -3
  11. package/bin/install.js +10 -3
  12. package/commands/pp-execute-phase.md +1 -3
  13. package/commands/pp-ns-manage.md +1 -1
  14. package/commands/pp-ns-review.md +1 -1
  15. package/commands/pp-plan-phase.md +1 -2
  16. package/commands/pp-vault-promote.md +7 -8
  17. package/commands/pp-vault-review.md +6 -5
  18. package/commands/pp-verify-work.md +1 -2
  19. package/gemini-extension.json +1 -1
  20. package/hooks/dist/pp-check-update.js +0 -0
  21. package/hooks/dist/pp-cursor-session-start.js +1 -1
  22. package/hooks/dist/pp-statusline.js +1 -1
  23. package/hooks/dist/pp-update-banner.js +0 -0
  24. package/hooks/pp-check-update.js +0 -0
  25. package/hooks/pp-cursor-session-start.js +1 -1
  26. package/hooks/pp-statusline.js +1 -1
  27. package/hooks/pp-update-banner.js +0 -0
  28. package/package.json +10 -18
  29. package/pareto-skills/_shared/create-project-board.sh +0 -0
  30. package/pareto-skills/pp-project-setup/SKILL.md +1 -1
  31. package/pp-core/bin/check-latest-version.cjs +0 -0
  32. package/pp-core/bin/detect-build-commands.sh +2 -2
  33. package/pp-core/bin/lib/capability-activation.cjs +83 -9
  34. package/pp-core/bin/lib/capability-registry.cjs +201 -229
  35. package/pp-core/bin/lib/capability-state.cjs +93 -1
  36. package/pp-core/bin/lib/capability-writer.cjs +3 -3
  37. package/pp-core/bin/lib/check-command-router.cjs +1906 -1514
  38. package/pp-core/bin/lib/clusters.cjs +3 -0
  39. package/pp-core/bin/lib/command-aliases.cjs +0 -35
  40. package/pp-core/bin/lib/commands.cjs +9 -4
  41. package/pp-core/bin/lib/compose-bindings.cjs +251 -88
  42. package/pp-core/bin/lib/config-loader.cjs +7 -103
  43. package/pp-core/bin/lib/config-schema.cjs +1 -1
  44. package/pp-core/bin/lib/config.cjs +151 -62
  45. package/pp-core/bin/lib/decisions.cjs +4 -4
  46. package/pp-core/bin/lib/discovery-vault-check.cjs +246 -0
  47. package/pp-core/bin/lib/frontmatter.cjs +1 -38
  48. package/pp-core/bin/lib/git-base-branch.cjs +11 -12
  49. package/pp-core/bin/lib/git-provider.cjs +9 -10
  50. package/pp-core/bin/lib/init-command-router.cjs +5 -8
  51. package/pp-core/bin/lib/init-contract.cjs +620 -738
  52. package/pp-core/bin/lib/init-interactive.cjs +62 -4
  53. package/pp-core/bin/lib/init.cjs +24 -51
  54. package/pp-core/bin/lib/install-profiles.cjs +1 -1
  55. package/pp-core/bin/lib/io.cjs +4 -2
  56. package/pp-core/bin/lib/loop-resolver.cjs +165 -129
  57. package/pp-core/bin/lib/org-profile.cjs +402 -161
  58. package/pp-core/bin/lib/package-identity.cjs +3 -3
  59. package/pp-core/bin/lib/phase-command-router.cjs +1 -29
  60. package/pp-core/bin/lib/phase-scope.cjs +1 -1
  61. package/pp-core/bin/lib/phase.cjs +11 -253
  62. package/pp-core/bin/lib/platform-bindings.cjs +40 -19
  63. package/pp-core/bin/lib/profile-output.cjs +1 -1
  64. package/pp-core/bin/lib/shell-command-projection.cjs +104 -1
  65. package/pp-core/bin/lib/state.cjs +11 -85
  66. package/pp-core/bin/lib/template.cjs +1 -1
  67. package/pp-core/bin/lib/vault-command-router.cjs +1682 -477
  68. package/pp-core/bin/lib/vault-coordinates.cjs +16 -7
  69. package/pp-core/bin/lib/vault-index.cjs +656 -0
  70. package/pp-core/bin/lib/vault-seal.cjs +1851 -0
  71. package/pp-core/bin/lib/vault-standards-promote.cjs +577 -0
  72. package/pp-core/bin/lib/vault-standards-review.cjs +293 -0
  73. package/pp-core/bin/lib/vault-standards.cjs +1504 -0
  74. package/pp-core/bin/lib/vault-trace.cjs +155 -2778
  75. package/pp-core/bin/lib/vault-update.cjs +10 -59
  76. package/pp-core/bin/lib/vault.cjs +2570 -950
  77. package/pp-core/bin/lib/verify.cjs +0 -11
  78. package/pp-core/bin/portable-timeout.sh +1 -1
  79. package/pp-core/bin/pp-tools.cjs +157 -54
  80. package/pp-core/bin/shared/config-defaults.manifest.json +0 -7
  81. package/pp-core/bin/shared/config-schema.manifest.json +0 -3
  82. package/pp-core/bin/shared/model-catalog.json +2 -2
  83. package/pp-core/bin/verify-reapply-patches.cjs +0 -0
  84. package/pp-core/org-profile.json +26 -0
  85. package/pp-core/references/autonomous-best-effort.md +8 -7
  86. package/pp-core/references/loop-hook-dispatch.md +0 -25
  87. package/pp-core/references/planner-gap-closure.md +0 -1
  88. package/pp-core/references/planner-tdd.md +0 -1
  89. package/pp-core/references/planner-vault-standards.md +311 -0
  90. package/pp-core/references/planning-config.md +0 -8
  91. package/pp-core/references/tdd.md +0 -1
  92. package/pp-core/templates/README.md +1 -0
  93. package/pp-core/templates/VAULT-REVIEW.md +56 -0
  94. package/pp-core/templates/phase-prompt.md +2 -6
  95. package/pp-core/workflows/_runtime-launcher.snippet.sh +0 -0
  96. package/pp-core/workflows/add-tests.md +55 -4
  97. package/pp-core/workflows/audit-fix.md +63 -2
  98. package/pp-core/workflows/audit-milestone.md +1 -1
  99. package/pp-core/workflows/autonomous.md +85 -11
  100. package/pp-core/workflows/check-todos.md +13 -2
  101. package/pp-core/workflows/code-review-fix.md +59 -3
  102. package/pp-core/workflows/code-review.md +10 -57
  103. package/pp-core/workflows/complete-milestone.md +1 -1
  104. package/pp-core/workflows/debug.md +54 -4
  105. package/pp-core/workflows/discovery-sprint/prefill.md +19 -7
  106. package/pp-core/workflows/discovery-sprint/prototype.md +46 -49
  107. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  108. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +1 -3
  109. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +1 -3
  110. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +1 -3
  111. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +20 -24
  112. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +1 -3
  113. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +1 -3
  114. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +1 -3
  115. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +1 -3
  116. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +1 -3
  117. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +1 -6
  118. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +1 -3
  119. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +1 -3
  120. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +1 -3
  121. package/pp-core/workflows/discovery-sprint/templates/context-template.md +1 -1
  122. package/pp-core/workflows/discovery-sprint/vault-context.md +207 -0
  123. package/pp-core/workflows/discovery-sprint.md +7 -3
  124. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +4 -4
  125. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +43 -1
  126. package/pp-core/workflows/discovery-to-build.md +10 -2
  127. package/pp-core/workflows/discuss-phase.md +3 -10
  128. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +327 -0
  129. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +3 -2
  130. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +1 -1
  131. package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +78 -0
  132. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +94 -0
  133. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +6 -8
  134. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +115 -14
  135. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +219 -20
  136. package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +147 -0
  137. package/pp-core/workflows/execute-phase.md +152 -128
  138. package/pp-core/workflows/execute-plan.md +54 -6
  139. package/pp-core/workflows/fast.md +53 -4
  140. package/pp-core/workflows/help/modes/full.md +3 -3
  141. package/pp-core/workflows/init/steps/client-context-capture.md +101 -0
  142. package/pp-core/workflows/init/steps/gitlab-provisioning.md +130 -0
  143. package/pp-core/workflows/init/steps/project-md-deferral-narration.md +66 -0
  144. package/pp-core/workflows/init/steps/reachability-outcome.md +70 -0
  145. package/pp-core/workflows/init.md +232 -272
  146. package/pp-core/workflows/new-project.md +31 -23
  147. package/pp-core/workflows/plan-phase.md +98 -45
  148. package/pp-core/workflows/quick.md +117 -3
  149. package/pp-core/workflows/secure-phase.md +1 -1
  150. package/pp-core/workflows/ship.md +57 -28
  151. package/pp-core/workflows/undo.md +1 -1
  152. package/pp-core/workflows/update.md +3 -3
  153. package/pp-core/workflows/validate-phase.md +58 -3
  154. package/pp-core/workflows/vault-review.md +161 -23
  155. package/pp-core/workflows/vault-write-back.md +73 -110
  156. package/pp-core/workflows/verify-work.md +12 -14
  157. package/scripts/changeset/cli.cjs +6 -5
  158. package/scripts/changeset/lint.cjs +0 -0
  159. package/scripts/changeset/new.cjs +33 -50
  160. package/scripts/dev-env.cjs +25 -85
  161. package/scripts/gen-capability-registry.cjs +120 -13
  162. package/scripts/lib/allowlist-ratchet.cjs +1 -103
  163. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
  164. package/scripts/lint-docs-required.cjs +0 -0
  165. package/scripts/lint-pr-check-project-dir.cjs +1 -3
  166. package/scripts/lint-test-file-count.allowlist.json +3 -19
  167. package/scripts/lint-test-file-count.cjs +5 -6
  168. package/scripts/prompt-injection-scan.sh +19 -17
  169. package/scripts/release-channel.cjs +0 -0
  170. package/scripts/sync-manifest-versions.cjs +5 -4
  171. package/scripts/sync-rulesets.sh +1 -1
  172. package/scripts/update-size-baseline.cjs +30 -274
  173. package/scripts/verify-opengsd-sweep.cjs +3 -5
  174. package/scripts/workflow-size.cjs +9 -61
  175. package/pp-core/bin/lib/dispatch-plan.cjs +0 -483
  176. package/pp-core/bin/lib/install-run.cjs +0 -536
  177. package/pp-core/bin/lib/loop-gates.cjs +0 -213
  178. package/pp-core/bin/lib/phase-manifest.cjs +0 -527
  179. package/pp-core/bin/lib/phase-tier.cjs +0 -480
  180. package/pp-core/bin/lib/plan-graph.cjs +0 -368
  181. package/pp-core/bin/lib/practices-review-check.cjs +0 -938
  182. package/pp-core/bin/lib/vault-audit.cjs +0 -2070
  183. package/pp-core/bin/lib/vault-practices.cjs +0 -1990
  184. package/pp-core/bin/lib/vault-review.cjs +0 -2359
  185. package/pp-core/bin/lib/vault-write-back.cjs +0 -877
  186. package/pp-core/references/executor-vault-trace.md +0 -47
  187. package/pp-core/references/planner-plan-graph.md +0 -66
  188. package/pp-core/references/planner-vault-practices.md +0 -112
  189. package/pp-core/templates/practices/README.md +0 -251
  190. package/pp-core/templates/practices/error-handling.md +0 -18
  191. package/pp-core/templates/practices/minimal-comments.md +0 -14
  192. package/pp-core/templates/practices/naming-convention.md +0 -18
  193. package/pp-core/templates/practices/practice-template.md +0 -23
  194. package/pp-core/workflows/discovery-sprint/vault-fetch.md +0 -37
  195. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +0 -45
  196. package/pp-core/workflows/execute-phase/steps/install-run.md +0 -29
  197. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +0 -23
  198. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +0 -73
  199. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +0 -19
  200. package/pp-core/workflows/plan-phase/steps/phase-tier.md +0 -43
  201. package/scripts/lib/size-budget.cjs +0 -311
  202. package/scripts/lint-neutrality-names.cjs +0 -694
  203. package/scripts/lint-org-coordinates.allowlist.json +0 -35
  204. package/scripts/lint-org-coordinates.cjs +0 -426
@@ -0,0 +1,1851 @@
1
+ "use strict";
2
+ /**
3
+ * src/vault-seal.cts — the seal leaf (Vault Assurance v1.2, Phase 16-03).
4
+ *
5
+ * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
6
+ * compiles to the git-ignored `pp-core/bin/lib/vault-seal.cjs`; do NOT
7
+ * hand-edit the compiled output. Behaviour is locked by
8
+ * `tests/vault-seal.test.cjs`.
9
+ *
10
+ * Every defect this workstream shipped was one contract restated on N
11
+ * surfaces with one surface left behind. Phase 16 adds four contracts that
12
+ * several later plans need, so each gets ONE owner here, built and pinned
13
+ * before any consumer exists:
14
+ *
15
+ * - artifact presence (D-20): does a directory, or a project, hold
16
+ * engine-written vault records? `collectRecordedArtifacts` /
17
+ * `hasRecordedVaultArtifacts`;
18
+ * - the non-source path class (D-15, D-39): which repo paths are not
19
+ * governed source? `isNonSourcePath`, with `isPlanningPath` as its
20
+ * `.planning/` half;
21
+ * - the anchor grammar (D-14, D-35): the one shape a recorded commit may
22
+ * have before it can reach a git argv. `ANCHOR_SHA_RE`;
23
+ * - the door-record grammar (D-12): `formatDoorRecord` / `parseDoorRecord`.
24
+ *
25
+ * It also reads and writes the per-directory anchors file
26
+ * (`.vault-anchors.json`) the start anchor lives in (D-31).
27
+ *
28
+ * LAYERING (D-41). The TOP LEVEL of this file imports only Node built-ins
29
+ * (`fs`, `path`). That is load-bearing: `vault-trace.cts` and
30
+ * `vault-standards.cts` are expected to import `ANCHOR_SHA_RE` from here in
31
+ * 16-06, and a top-level import back into either would close a load-time
32
+ * require cycle. The modules this leaf needs (`vault-standards`,
33
+ * `project-root`, `vault`) are therefore required LAZILY, inside the
34
+ * functions that use them, which is the `readVaultGateActive` precedent in
35
+ * `src/vault.cts`. By the time any of those
36
+ * functions runs, every module has finished loading, so the lazy edge cannot
37
+ * observe a half-initialised export object.
38
+ *
39
+ * Message discipline: nothing here echoes a value it refused. A refused door
40
+ * record field, or a dropped anchor, is reported by naming the FIELD, never by
41
+ * reproducing what was found, so planning-tree text cannot inject terminal
42
+ * escapes or agent-directed prose through a refusal.
43
+ */
44
+ var __importDefault = (this && this.__importDefault) || function (mod) {
45
+ return (mod && mod.__esModule) ? mod : { "default": mod };
46
+ };
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.SEAL_GIT_TIMEOUT_MS = exports.SEAL_SUBJECT_CAP = exports.SEAL_EXAMINE_CAP = exports.SEAL_CANDIDATE_CAP = exports.UNCOVERED_PATHS_PER_COMMIT_CAP = exports.COVERING_RECORD_CAP = exports.UNCOVERED_COMMIT_CAP = exports.DISABLED_WITH_ARTIFACTS_REPAIR = exports.DISABLED_WITH_ARTIFACTS_LIST_CAP = exports.VAULT_SWITCH_KEY = exports.DISABLED_WITH_ARTIFACTS_STATUS = exports.DOOR_ARTIFACT_FILENAME_RE = exports.RECORDED_ARTIFACT_KIND = exports.RECORDED_ARTIFACT_DIR_CAP = exports.TRACE_ARTIFACT_COUNT_CAP = exports.TRACE_ARTIFACT_BYTE_CAP = exports.DOOR_RECORD_PROBLEM_KIND = exports.DOOR_NAMES = exports.DOOR_RECORD_HEADING = exports.VAULT_DOOR_STARTS_FILE = exports.CERTIFICATE_CAP = exports.VAULT_CERTIFICATES_FILE = exports.ATTESTATION_PATHS_CAP = exports.ATTESTATION_CAP = exports.VAULT_ATTESTATIONS_FILE = exports.DIR_START_HISTORY_CAP = exports.VAULT_ANCHORS_FILE = exports.NON_SOURCE_PATH_CLASSES = exports.ANCHOR_BACKFILL_WHO_MAX = exports.ANCHOR_SHA_RE = void 0;
49
+ exports.isAnchorSha = isAnchorSha;
50
+ exports.isIsoInstant = isIsoInstant;
51
+ exports.isAnchorBackfill = isAnchorBackfill;
52
+ exports.normaliseAnchorBackfillWho = normaliseAnchorBackfillWho;
53
+ exports.isPlanningPath = isPlanningPath;
54
+ exports.nonSourceClassOf = nonSourceClassOf;
55
+ exports.isNonSourcePath = isNonSourcePath;
56
+ exports.readDirAnchors = readDirAnchors;
57
+ exports.writeDirStartAnchor = writeDirStartAnchor;
58
+ exports.readDirAttestations = readDirAttestations;
59
+ exports.appendDirAttestations = appendDirAttestations;
60
+ exports.readDirCertificates = readDirCertificates;
61
+ exports.appendDirCertificates = appendDirCertificates;
62
+ exports.readDoorStart = readDoorStart;
63
+ exports.writeDoorStart = writeDoorStart;
64
+ exports.consumeDoorStart = consumeDoorStart;
65
+ exports.formatDoorRecord = formatDoorRecord;
66
+ exports.parseDoorRecord = parseDoorRecord;
67
+ exports.collectRecordedArtifacts = collectRecordedArtifacts;
68
+ exports.formatDisabledWithArtifacts = formatDisabledWithArtifacts;
69
+ exports.disabledWithArtifactsRepair = disabledWithArtifactsRepair;
70
+ exports.disabledWithArtifactsVerdict = disabledWithArtifactsVerdict;
71
+ exports.hasRecordedVaultArtifacts = hasRecordedVaultArtifacts;
72
+ exports.extractRecordAnchors = extractRecordAnchors;
73
+ exports.resolveRecordAnchors = resolveRecordAnchors;
74
+ exports.sealIsAncestorVia = sealIsAncestorVia;
75
+ exports.attestedFor = attestedFor;
76
+ exports.collectCoveringRecords = collectCoveringRecords;
77
+ exports.isPhaseRecordRel = isPhaseRecordRel;
78
+ exports.isSummaryRecordRel = isSummaryRecordRel;
79
+ exports.computeUncoveredSourceCommits = computeUncoveredSourceCommits;
80
+ const node_fs_1 = __importDefault(require("node:fs"));
81
+ const node_path_1 = __importDefault(require("node:path"));
82
+ // ─── Anchor grammar (D-14, D-35) ─────────────────────────────────────────────
83
+ /**
84
+ * A recorded anchor: a full commit id, lowercase hex, at sha1 (40) or sha256
85
+ * (64) length. Abbreviated ids, uppercase, and anything that could be read as
86
+ * an option (`--output=x`) are refused, so a value that passes can be placed
87
+ * in a git argv as a revision. The shape of `GIT_COMMIT_DIGEST_RE` in
88
+ * `src/vault-command-router.cts`, which 16-06 re-binds to this constant.
89
+ */
90
+ exports.ANCHOR_SHA_RE = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/;
91
+ /** True only for a string that matches `ANCHOR_SHA_RE`. Never throws. */
92
+ function isAnchorSha(value) {
93
+ return typeof value === 'string' && exports.ANCHOR_SHA_RE.test(value);
94
+ }
95
+ /** The bound on `by`, in code points (the trace's `boundedToken` width). */
96
+ exports.ANCHOR_BACKFILL_WHO_MAX = 80;
97
+ /**
98
+ * Characters a `by` value may not carry: C0/C1 controls, a backtick (it would
99
+ * open inline code on a markdown line), bidi controls, zero-width characters,
100
+ * line/paragraph separators and the BOM.
101
+ */
102
+ const BACKFILL_WHO_UNSAFE_RE = /[\u0000-\u001F\u007F-\u009F`\u200B-\u200F\u2028-\u202E\u2066-\u2069\uFEFF]/;
103
+ /** Is `s` exactly a timestamp `toISOString` would write (form, then a round trip)? */
104
+ function isIsoInstant(s) {
105
+ if (typeof s !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(s))
106
+ return false;
107
+ const d = new Date(s);
108
+ return !Number.isNaN(d.getTime()) && d.toISOString() === s;
109
+ }
110
+ /**
111
+ * Is this a well-formed back-fill attribution? `by` is a non-empty string of at
112
+ * most `ANCHOR_BACKFILL_WHO_MAX` code points, already trimmed, with none of the
113
+ * unsafe characters above; `at` round-trips through `toISOString` (a rolled-over
114
+ * date such as `2026-02-31` does not). Never throws.
115
+ */
116
+ function isAnchorBackfill(value) {
117
+ if (!value || typeof value !== 'object' || Array.isArray(value))
118
+ return false;
119
+ const v = value;
120
+ const by = v.by;
121
+ if (typeof by !== 'string' || by === '' || by.trim() !== by)
122
+ return false;
123
+ if (Array.from(by).length > exports.ANCHOR_BACKFILL_WHO_MAX || BACKFILL_WHO_UNSAFE_RE.test(by))
124
+ return false;
125
+ return isIsoInstant(v.at);
126
+ }
127
+ /**
128
+ * Turn a raw identity (a git `user.name`, an environment value) into a `by`
129
+ * value `isAnchorBackfill` accepts: unsafe characters become spaces, runs of
130
+ * whitespace collapse, the ends are trimmed, the length is capped. An identity
131
+ * with nothing left is the literal `unknown`, never an empty string, so "we
132
+ * did not record who" stays readable as a fact.
133
+ */
134
+ function normaliseAnchorBackfillWho(raw) {
135
+ const flat = String(typeof raw === 'string' ? raw : '')
136
+ .replace(new RegExp(BACKFILL_WHO_UNSAFE_RE.source, 'g'), ' ')
137
+ .replace(/\s+/g, ' ')
138
+ .trim();
139
+ const capped = Array.from(flat).slice(0, exports.ANCHOR_BACKFILL_WHO_MAX).join('').trim();
140
+ return capped === '' ? 'unknown' : capped;
141
+ }
142
+ // ─── Non-source path class (D-15, D-39) ──────────────────────────────────────
143
+ /**
144
+ * The seven classes of path that are NOT governed source. A commit that
145
+ * touches only these never needs a record (SEAL-01). D-39 adds the last three
146
+ * (agent instructions, not client code); that is the less restrictive reading,
147
+ * taken on the operator's behalf and open to reversal.
148
+ *
149
+ * `claude-md` and `agents-md` are top-level files and so already inside
150
+ * `top-level-markdown`; they are listed on their own so the D-39 rows stay
151
+ * visible and a reversal removes one row, not a rule.
152
+ */
153
+ exports.NON_SOURCE_PATH_CLASSES = Object.freeze([
154
+ Object.freeze({ id: 'planning', description: 'anything under .planning/ (either separator)' }),
155
+ Object.freeze({ id: 'docs', description: 'anything under the top-level docs/ directory' }),
156
+ Object.freeze({ id: 'changeset', description: 'anything under the top-level .changeset/ directory' }),
157
+ Object.freeze({ id: 'claude-dir', description: 'anything under the top-level .claude/ directory (D-39)' }),
158
+ Object.freeze({ id: 'claude-md', description: 'the top-level CLAUDE.md (D-39)' }),
159
+ Object.freeze({ id: 'agents-md', description: 'the top-level AGENTS.md (D-39)' }),
160
+ Object.freeze({ id: 'top-level-markdown', description: 'a .md file at the repository root (no directory part)' }),
161
+ ]);
162
+ /**
163
+ * The `.planning/` prefixes, both separators. Held as data so this file, like
164
+ * every other file under `src/`, carries no `.planning` prefix literal in a
165
+ * prefix test of its own: the one-owner pin counts those.
166
+ */
167
+ const PLANNING_PREFIXES = Object.freeze(['.planning/', '.planning\\']);
168
+ /** The directory classes: prefix (always with the trailing slash) to class id. */
169
+ const DIRECTORY_CLASSES = Object.freeze([
170
+ Object.freeze(['docs/', 'docs']),
171
+ Object.freeze(['.changeset/', 'changeset']),
172
+ Object.freeze(['.claude/', 'claude-dir']),
173
+ ]);
174
+ /**
175
+ * True for a `.planning/`-prefixed path, with either separator. The one
176
+ * owner of this test: the classifier below and the commit_docs guard in
177
+ * `src/commands.cts` call it. Case-sensitive; `..planning/x` and
178
+ * `.planningx/y` are not planning paths. Never throws.
179
+ */
180
+ function isPlanningPath(p) {
181
+ if (typeof p !== 'string')
182
+ return false;
183
+ return PLANNING_PREFIXES.some((prefix) => p.startsWith(prefix));
184
+ }
185
+ /**
186
+ * The class a path belongs to, or `null` when it is governed source.
187
+ *
188
+ * Backslashes are normalised to `/` first. Directory classes are prefix
189
+ * matches WITH the trailing slash, so a file literally named `docs` and a
190
+ * directory `docsx/` are source. Top-level markdown means no `/` at all and a
191
+ * `.md` suffix, so a nested `packages/x/README.md` is source. Case-sensitive:
192
+ * `Docs/x.md` is source. Never throws.
193
+ */
194
+ function nonSourceClassOf(p) {
195
+ if (typeof p !== 'string' || p === '')
196
+ return null;
197
+ const n = p.replace(/\\/g, '/');
198
+ if (isPlanningPath(n))
199
+ return 'planning';
200
+ for (const [prefix, id] of DIRECTORY_CLASSES) {
201
+ if (n.startsWith(prefix))
202
+ return id;
203
+ }
204
+ if (n === 'CLAUDE.md')
205
+ return 'claude-md';
206
+ if (n === 'AGENTS.md')
207
+ return 'agents-md';
208
+ if (!n.includes('/') && n.endsWith('.md'))
209
+ return 'top-level-markdown';
210
+ return null;
211
+ }
212
+ /** True when the path is in one of the seven non-source classes. Never throws. */
213
+ function isNonSourcePath(p) {
214
+ return nonSourceClassOf(p) !== null;
215
+ }
216
+ // ─── Anchors file (D-31) ─────────────────────────────────────────────────────
217
+ /** The per-directory file holding a phase or quick directory's start anchor. */
218
+ exports.VAULT_ANCHORS_FILE = '.vault-anchors.json';
219
+ /** The anchors file is a few dozen bytes; anything past this is not one. */
220
+ const ANCHORS_FILE_BYTE_CAP = 64 * 1024;
221
+ /** An ISO-8601 UTC instant as `Date.prototype.toISOString` writes it. */
222
+ const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
223
+ /** How many superseded starts a directory keeps (oldest dropped first; dropping only narrows coverage). */
224
+ exports.DIR_START_HISTORY_CAP = 32;
225
+ function isDirStartEntry(v) {
226
+ if (!v || typeof v !== 'object' || Array.isArray(v))
227
+ return false;
228
+ const o = v;
229
+ return isAnchorSha(o.start) && typeof o.recordedAt === 'string' && ISO_UTC_RE.test(o.recordedAt);
230
+ }
231
+ /**
232
+ * Read a directory's anchors file. `null` when the file is missing, larger
233
+ * than a few kilobytes, not JSON, or does not carry a valid `start` anchor and
234
+ * an ISO `recordedAt`. Never throws: a missing anchor is a state the strict
235
+ * readers type, not an exception.
236
+ */
237
+ function readDirAnchors(dirAbs) {
238
+ try {
239
+ const abs = node_path_1.default.join(dirAbs, exports.VAULT_ANCHORS_FILE);
240
+ const st = node_fs_1.default.statSync(abs);
241
+ if (!st.isFile() || st.size > ANCHORS_FILE_BYTE_CAP)
242
+ return null;
243
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(abs, 'utf8'));
244
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
245
+ return null;
246
+ const o = parsed;
247
+ if (!isAnchorSha(o.start))
248
+ return null;
249
+ if (typeof o.recordedAt !== 'string' || !ISO_UTC_RE.test(o.recordedAt))
250
+ return null;
251
+ // A malformed history entry is dropped, never repaired: a missing earlier
252
+ // start can only make a record cover LESS (D-98, rule 3).
253
+ const earlier = Array.isArray(o.earlier)
254
+ ? o.earlier.filter(isDirStartEntry).slice(0, exports.DIR_START_HISTORY_CAP).map((e) => ({ start: e.start, recordedAt: e.recordedAt }))
255
+ : [];
256
+ return { start: o.start, recordedAt: o.recordedAt, ...(earlier.length > 0 ? { earlier } : {}) };
257
+ }
258
+ catch {
259
+ return null;
260
+ }
261
+ }
262
+ /**
263
+ * Record a directory's start anchor. The start it supersedes is not lost: it
264
+ * moves to the front of `earlier` (newest first, `DIR_START_HISTORY_CAP`
265
+ * kept), where an END-only record written before the new start still finds
266
+ * the start that preceded its own end (16 review iteration 2 CR-02, D-98).
267
+ * Every record written from now on takes the new start, so nothing committed
268
+ * before it is claimed by them. Refuses a value that is not an anchor BEFORE
269
+ * anything is written (throws), because the caller is the engine and an
270
+ * invalid sha there is a bug, not input. The clock is injected (`now`), per
271
+ * the project's pure-function convention. Writes to a temporary sibling and
272
+ * renames, so a reader never sees half a file.
273
+ */
274
+ function writeDirStartAnchor(dirAbs, sha, opts = {}) {
275
+ if (!isAnchorSha(sha)) {
276
+ throw new Error('writeDirStartAnchor: the start anchor is not a full lowercase commit id (40 or 64 hex)');
277
+ }
278
+ const now = typeof opts.now === 'function' ? opts.now : () => new Date();
279
+ const previous = readDirAnchors(dirAbs);
280
+ const earlier = previous === null
281
+ ? []
282
+ : [{ start: previous.start, recordedAt: previous.recordedAt }, ...(previous.earlier ?? [])]
283
+ .filter((e) => e.start !== sha)
284
+ .slice(0, exports.DIR_START_HISTORY_CAP);
285
+ const record = { start: sha, recordedAt: now().toISOString(), ...(earlier.length > 0 ? { earlier } : {}) };
286
+ const abs = node_path_1.default.join(dirAbs, exports.VAULT_ANCHORS_FILE);
287
+ const tmp = `${abs}.${process.pid}.tmp`;
288
+ node_fs_1.default.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
289
+ node_fs_1.default.renameSync(tmp, abs);
290
+ return record;
291
+ }
292
+ // ─── Attestations file (16 review iteration 3 CR-01, D-102) ──────────────────
293
+ //
294
+ // A resume records a new start (D-98), so the phase's own commits made between
295
+ // its last record and the resume are covered by nothing, and the engine cannot
296
+ // tell them from an ungoverned commit. The operator's repair is to ATTEST each
297
+ // such commit, one by one, on the record that holds that work: never to widen
298
+ // the record's range (iteration 3 CR-01: a widened start covered every commit
299
+ // in between, an ungoverned one included, and the note that remained named
300
+ // none of them). An attestation covers exactly the commit it names, only when
301
+ // that commit lies on the record's own line between a start this directory
302
+ // recorded and the record's end, and every later strict check lists it with
303
+ // its subject, its paths and who attested it — a git `user.name`, recorded as
304
+ // given and never verified. `vault anchor backfill --from --attest` is the one
305
+ // writer.
306
+ /** The per-directory file holding the attestations made on that directory's SUMMARY records. */
307
+ exports.VAULT_ATTESTATIONS_FILE = '.vault-attestations.json';
308
+ /** At most this many attestations are kept per directory; the writer refuses more. */
309
+ exports.ATTESTATION_CAP = 200;
310
+ /** At most this many source paths are kept per attested commit. */
311
+ exports.ATTESTATION_PATHS_CAP = 20;
312
+ /** The attestations file's read cap. */
313
+ const ATTESTATIONS_FILE_BYTE_CAP = 256 * 1024;
314
+ const ATTEST_TEXT_UNSAFE_RE = /[\u0000-\u001F\u007F-\u009F]/;
315
+ /** `SEAL_SUBJECT_CAP` plus its ellipsis, restated because that constant is declared further down this file. */
316
+ const ATTEST_SUBJECT_MAX = 121;
317
+ function isAttestation(v) {
318
+ if (!v || typeof v !== 'object' || Array.isArray(v))
319
+ return false;
320
+ const o = v;
321
+ if (typeof o.record !== 'string' || o.record.includes('/') || o.record.includes('\\') || !/(^|-)SUMMARY\.md$/.test(o.record))
322
+ return false;
323
+ if (!isAnchorSha(o.from) || !isAnchorSha(o.sha))
324
+ return false;
325
+ if (typeof o.subject !== 'string' || o.subject.length > ATTEST_SUBJECT_MAX || ATTEST_TEXT_UNSAFE_RE.test(o.subject))
326
+ return false;
327
+ if (!Array.isArray(o.paths) || o.paths.length > exports.ATTESTATION_PATHS_CAP)
328
+ return false;
329
+ if (!o.paths.every((p) => typeof p === 'string' && p !== '' && p.length <= 1024 && !ATTEST_TEXT_UNSAFE_RE.test(p)))
330
+ return false;
331
+ return isAnchorBackfill({ by: o.by, at: o.at });
332
+ }
333
+ /**
334
+ * A directory's attestations, in the order they were made. A missing,
335
+ * oversized or unparsable file, or a malformed entry, reads as none: a lost
336
+ * attestation can only make a record cover LESS. Never throws.
337
+ */
338
+ function readDirAttestations(dirAbs) {
339
+ try {
340
+ const abs = node_path_1.default.join(dirAbs, exports.VAULT_ATTESTATIONS_FILE);
341
+ const st = node_fs_1.default.lstatSync(abs);
342
+ if (!st.isFile() || st.size > ATTESTATIONS_FILE_BYTE_CAP)
343
+ return [];
344
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(abs, 'utf8'));
345
+ const list = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed.attestations : null;
346
+ if (!Array.isArray(list))
347
+ return [];
348
+ return list.filter(isAttestation).slice(0, exports.ATTESTATION_CAP)
349
+ .map((a) => ({ record: a.record, from: a.from, sha: a.sha, subject: a.subject, paths: [...a.paths], by: a.by, at: a.at }));
350
+ }
351
+ catch {
352
+ return [];
353
+ }
354
+ }
355
+ /**
356
+ * Append attestations to a directory's file. Throws, writing nothing, on an
357
+ * entry that is not well formed, a commit already attested for the same
358
+ * record, more than `ATTESTATION_CAP` in all, or a symlinked file: the caller
359
+ * is the engine, and each of those is a bug or a refusal it states first.
360
+ * Writes a temporary sibling and renames it.
361
+ */
362
+ function appendDirAttestations(dirAbs, entries) {
363
+ if (!Array.isArray(entries) || entries.length === 0 || !entries.every(isAttestation)) {
364
+ throw new Error('appendDirAttestations: an attestation is not well formed');
365
+ }
366
+ const abs = node_path_1.default.join(dirAbs, exports.VAULT_ATTESTATIONS_FILE);
367
+ try {
368
+ if (node_fs_1.default.lstatSync(abs).isSymbolicLink())
369
+ throw new Error('appendDirAttestations: the attestations file is a symlink');
370
+ }
371
+ catch (e) {
372
+ if (e?.code !== 'ENOENT')
373
+ throw e;
374
+ }
375
+ const existing = readDirAttestations(dirAbs);
376
+ const seen = new Set(existing.map((a) => `${a.record}\u0000${a.sha}`));
377
+ for (const a of entries) {
378
+ const key = `${a.record}\u0000${a.sha}`;
379
+ if (seen.has(key))
380
+ throw new Error('appendDirAttestations: that commit is already attested for this record');
381
+ seen.add(key);
382
+ }
383
+ const next = [...existing, ...entries];
384
+ if (next.length > exports.ATTESTATION_CAP)
385
+ throw new Error(`appendDirAttestations: more than ${exports.ATTESTATION_CAP} attestations in one directory`);
386
+ const tmp = `${abs}.${process.pid}.tmp`;
387
+ node_fs_1.default.writeFileSync(tmp, `${JSON.stringify({ attestations: next }, null, 2)}\n`, 'utf8');
388
+ node_fs_1.default.renameSync(tmp, abs);
389
+ return next;
390
+ }
391
+ // ─── Certificates file (16 gap closure 16-18, D-107) ─────────────────────────
392
+ //
393
+ // SEAL-01's lower bound (computeUncoveredSourceCommits (c2)) let any earlier
394
+ // PHASE record's end bound a later phase's reach, whether or not that phase's
395
+ // own strict check had passed: nothing recorded said so (D-101's reason for
396
+ // filing, D-106's ruling to fix it). A phase whose strict check named an
397
+ // ungoverned commit and was then abandoned kept its SUMMARY, and that SUMMARY's
398
+ // end hid the commit from every later phase. A certificate is that missing
399
+ // fact: an engine-written entry saying "this record's END was judged by a clean
400
+ // strict check of its phase". Its one writer is `vault anchor certify
401
+ // <phase-dir>` (plan 16-19), which runs `check vault.trace --unpaired strict`
402
+ // itself and certifies only on `status: checked`, `block: false` and no
403
+ // override (D-108), so the check stays read-only (TRACE-04). It is keyed by
404
+ // the record's END sha, so a SUMMARY written or re-anchored later is
405
+ // uncertified until the next clean strict check. A malformed entry is dropped
406
+ // on read and the record reads as UNCERTIFIED: the direction that bounds less.
407
+ /** The per-directory file holding the certificates for that directory's SUMMARY records' ends. */
408
+ exports.VAULT_CERTIFICATES_FILE = '.vault-certificates.json';
409
+ /** At most this many certificates are kept per directory; the writer refuses more. */
410
+ exports.CERTIFICATE_CAP = 200;
411
+ /** The certificates file's read cap. */
412
+ const CERTIFICATES_FILE_BYTE_CAP = 64 * 1024;
413
+ function isSealCertificate(v) {
414
+ if (!v || typeof v !== 'object' || Array.isArray(v))
415
+ return false;
416
+ const o = v;
417
+ return isAnchorSha(o.end) && isAnchorSha(o.head) && typeof o.at === 'string' && ISO_UTC_RE.test(o.at);
418
+ }
419
+ /**
420
+ * A directory's certificates, in the order they were written. A missing file,
421
+ * a file that is not a regular file (a symlink included), one over the byte
422
+ * cap, not JSON, not an object or without a `certified` array reads as none;
423
+ * a malformed entry is dropped and the rest kept, never repaired. Never
424
+ * throws: a lost certificate can only make a record bound LESS.
425
+ */
426
+ function readDirCertificates(dirAbs) {
427
+ try {
428
+ const abs = node_path_1.default.join(dirAbs, exports.VAULT_CERTIFICATES_FILE);
429
+ const st = node_fs_1.default.lstatSync(abs);
430
+ if (!st.isFile() || st.size > CERTIFICATES_FILE_BYTE_CAP)
431
+ return [];
432
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(abs, 'utf8'));
433
+ const list = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed.certified : null;
434
+ if (!Array.isArray(list))
435
+ return [];
436
+ return list.filter(isSealCertificate).slice(0, exports.CERTIFICATE_CAP).map((c) => ({ end: c.end, head: c.head, at: c.at }));
437
+ }
438
+ catch {
439
+ return [];
440
+ }
441
+ }
442
+ /**
443
+ * Append certificates to a directory's file and return the full list. Throws,
444
+ * writing nothing, on an empty list, an entry that is not well formed, a
445
+ * symlinked file, or more than `CERTIFICATE_CAP` in all: the caller is the
446
+ * engine, and each of those is a bug or a refusal it states first. A repeated
447
+ * `end` (already certified, or twice in `entries`) keeps the FIRST entry, so
448
+ * certifying the same end again changes nothing. Writes a temporary sibling
449
+ * and renames it.
450
+ */
451
+ function appendDirCertificates(dirAbs, entries) {
452
+ if (!Array.isArray(entries) || entries.length === 0 || !entries.every(isSealCertificate)) {
453
+ throw new Error('appendDirCertificates: a certificate is not well formed');
454
+ }
455
+ const abs = node_path_1.default.join(dirAbs, exports.VAULT_CERTIFICATES_FILE);
456
+ try {
457
+ if (node_fs_1.default.lstatSync(abs).isSymbolicLink())
458
+ throw new Error('appendDirCertificates: the certificates file is a symlink');
459
+ }
460
+ catch (e) {
461
+ if (e?.code !== 'ENOENT')
462
+ throw e;
463
+ }
464
+ const next = readDirCertificates(dirAbs);
465
+ const seen = new Set(next.map((c) => c.end));
466
+ for (const c of entries) {
467
+ if (seen.has(c.end))
468
+ continue;
469
+ seen.add(c.end);
470
+ next.push({ end: c.end, head: c.head, at: c.at });
471
+ }
472
+ if (next.length > exports.CERTIFICATE_CAP)
473
+ throw new Error(`appendDirCertificates: more than ${exports.CERTIFICATE_CAP} certificates in one directory`);
474
+ const tmp = `${abs}.${process.pid}.tmp`;
475
+ node_fs_1.default.writeFileSync(tmp, `${JSON.stringify({ certified: next }, null, 2)}\n`, 'utf8');
476
+ node_fs_1.default.renameSync(tmp, abs);
477
+ return next;
478
+ }
479
+ // ─── Door starts file (16 review CR-01) ──────────────────────────────────────
480
+ //
481
+ // A door captures its START before its agent runs and writes its record after
482
+ // the door's last source commit, many steps later. A shell variable cannot
483
+ // carry the start between the two (each agent step runs a fresh shell), and the
484
+ // agent never types a sha (D-12). So `vault door-record --begin` records the
485
+ // start HERE, in the project's `.planning/` (never a phase directory: that
486
+ // directory's `.vault-anchors.json` is the phase's own start, D-59), keyed by
487
+ // door name, and the record step reads it back. A later `--begin` of the same
488
+ // door replaces its entry: every door run begins afresh, so an interrupted
489
+ // run's commits stay uncovered (and SEAL-01 names them) rather than a stale
490
+ // start widening a later run's range. A successful record CONSUMES the entry
491
+ // (16 review iteration 2 WR-01, D-100), so that "afresh" no longer rests on the
492
+ // prose step running: a record with no fresh `--begin` halts.
493
+ /** The project-level file holding each door's most recent start (`.planning/` root). */
494
+ exports.VAULT_DOOR_STARTS_FILE = '.vault-door-starts.json';
495
+ /** The whole file, or `{}` when it is missing, oversized, not JSON or not an object. Never throws. */
496
+ function readDoorStarts(planningAbs) {
497
+ try {
498
+ const abs = node_path_1.default.join(planningAbs, exports.VAULT_DOOR_STARTS_FILE);
499
+ const st = node_fs_1.default.lstatSync(abs);
500
+ if (!st.isFile() || st.size > ANCHORS_FILE_BYTE_CAP)
501
+ return {};
502
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(abs, 'utf8'));
503
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
504
+ return {};
505
+ return parsed;
506
+ }
507
+ catch {
508
+ return {};
509
+ }
510
+ }
511
+ /**
512
+ * The start the named door's last `--begin` recorded, or `null` when there is
513
+ * none or it is malformed (not an anchor, no ISO `recordedAt`). Never throws.
514
+ */
515
+ function readDoorStart(planningAbs, door) {
516
+ if (!exports.DOOR_NAMES.includes(door))
517
+ return null;
518
+ const all = readDoorStarts(planningAbs);
519
+ const entry = Object.prototype.hasOwnProperty.call(all, door) ? all[door] : undefined;
520
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
521
+ return null;
522
+ const o = entry;
523
+ if (!isAnchorSha(o.start))
524
+ return null;
525
+ if (typeof o.recordedAt !== 'string' || !ISO_UTC_RE.test(o.recordedAt))
526
+ return null;
527
+ return { start: o.start, recordedAt: o.recordedAt };
528
+ }
529
+ /**
530
+ * Record `door`'s start, replacing its earlier entry and keeping the other
531
+ * doors' valid entries. Throws on a door name or sha the engine should never
532
+ * pass (a bug, not input), before anything is written; writes a temporary
533
+ * sibling and renames it, so a reader never sees half a file. The file must
534
+ * not be a symlink (a write is never redirected out of the tree).
535
+ */
536
+ function writeDoorStart(planningAbs, door, sha, opts = {}) {
537
+ if (!exports.DOOR_NAMES.includes(door))
538
+ throw new Error('writeDoorStart: not a door that writes a record');
539
+ if (!isAnchorSha(sha))
540
+ throw new Error('writeDoorStart: the start is not a full lowercase commit id (40 or 64 hex)');
541
+ const abs = node_path_1.default.join(planningAbs, exports.VAULT_DOOR_STARTS_FILE);
542
+ try {
543
+ if (node_fs_1.default.lstatSync(abs).isSymbolicLink())
544
+ throw new Error('writeDoorStart: the door starts file is a symlink');
545
+ }
546
+ catch (e) {
547
+ if (e?.code !== 'ENOENT')
548
+ throw e;
549
+ }
550
+ const now = typeof opts.now === 'function' ? opts.now : () => new Date();
551
+ const record = { start: sha, recordedAt: now().toISOString() };
552
+ const next = {};
553
+ for (const name of exports.DOOR_NAMES) {
554
+ if (name === door)
555
+ next[name] = record;
556
+ else {
557
+ const kept = readDoorStart(planningAbs, name);
558
+ if (kept)
559
+ next[name] = kept;
560
+ }
561
+ }
562
+ const tmp = `${abs}.${process.pid}.tmp`;
563
+ node_fs_1.default.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
564
+ node_fs_1.default.renameSync(tmp, abs);
565
+ return record;
566
+ }
567
+ /**
568
+ * Consume `door`'s start (16 review iteration 2 WR-01, D-100): remove its entry
569
+ * when, and only when, it still holds `start` — the value the record about to
570
+ * be written read — keeping every other door's valid entry. A record therefore
571
+ * uses a `--begin` once: a second record without a fresh `--begin` finds no
572
+ * start and halts naming `--begin`, instead of reusing run 1's start and
573
+ * covering everything committed since. An entry that changed in between (a
574
+ * newer `--begin`) is left alone and `false` is returned. Throws on a door name
575
+ * the engine should never pass, or when the file is a symlink; writes a
576
+ * temporary sibling and renames it.
577
+ */
578
+ function consumeDoorStart(planningAbs, door, start) {
579
+ if (!exports.DOOR_NAMES.includes(door))
580
+ throw new Error('consumeDoorStart: not a door that writes a record');
581
+ const abs = node_path_1.default.join(planningAbs, exports.VAULT_DOOR_STARTS_FILE);
582
+ if (node_fs_1.default.lstatSync(abs).isSymbolicLink())
583
+ throw new Error('consumeDoorStart: the door starts file is a symlink');
584
+ const current = readDoorStart(planningAbs, door);
585
+ if (current === null || current.start !== start)
586
+ return false;
587
+ const next = {};
588
+ for (const name of exports.DOOR_NAMES) {
589
+ if (name === door)
590
+ continue;
591
+ const kept = readDoorStart(planningAbs, name);
592
+ if (kept)
593
+ next[name] = kept;
594
+ }
595
+ const tmp = `${abs}.${process.pid}.tmp`;
596
+ node_fs_1.default.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
597
+ node_fs_1.default.renameSync(tmp, abs);
598
+ return true;
599
+ }
600
+ // ─── Door-record grammar (D-12) ──────────────────────────────────────────────
601
+ /** The heading a door record opens with, at column 0. */
602
+ exports.DOOR_RECORD_HEADING = '## Vault Door Record';
603
+ /** The doors that write a door record (D-11). */
604
+ exports.DOOR_NAMES = Object.freeze(['code-review-fix', 'validate-phase', 'add-tests', 'audit-fix']);
605
+ /**
606
+ * The modes a door record may name; `null` is written as `none`. `practices`
607
+ * is still accepted so a door record written by an older engine (which had a
608
+ * practices mode) parses; this engine writes only `standards`.
609
+ */
610
+ const DOOR_MODES = Object.freeze(['practices', 'standards']);
611
+ /** How an absent value is spelled on its line. */
612
+ const DOOR_NONE = 'none';
613
+ /** Why a door record did not parse. Frozen, so a typo is a build error. */
614
+ exports.DOOR_RECORD_PROBLEM_KIND = Object.freeze({
615
+ DUPLICATE: 'door_record_duplicate',
616
+ MALFORMED: 'door_record_malformed',
617
+ FIELD_INVALID: 'door_record_field_invalid',
618
+ });
619
+ /** The line keys, in the one order a record is written and read. */
620
+ const DOOR_RECORD_KEYS = Object.freeze([
621
+ 'door',
622
+ 'mode',
623
+ 'vault-repo',
624
+ 'vault-ref',
625
+ 'vault-sha',
626
+ 'degraded',
627
+ 'phase',
628
+ 'start-commit',
629
+ 'end-commit',
630
+ 'recorded-at',
631
+ ]);
632
+ /** `- key: value`, the one line shape inside the section. */
633
+ const DOOR_LINE_RE = /^- ([a-z-]+): (.*)$/;
634
+ /**
635
+ * Is this free-text value safe to place on a record line an agent reads?
636
+ * Asks the standards leaf's reason-clause judge, which owns the refused set
637
+ * (newline, backtick, control, bidi, separator and zero-width characters, a
638
+ * fence marker, a block tag, empty, over 200 code points). Required lazily;
639
+ * see the layering note in the header.
640
+ */
641
+ function isDisplaySafeValue(value) {
642
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
643
+ const standards = require('./vault-standards.cjs');
644
+ return standards.validateReasonClause(value).ok && value !== DOOR_NONE && value.trim() === value;
645
+ }
646
+ /** The record's ten line values, in key order; throws naming the field on a refused value. */
647
+ function doorRecordValues(rec) {
648
+ const refuse = (field) => {
649
+ throw new Error(`formatDoorRecord: the ${field} field is not valid for a door record`);
650
+ };
651
+ const r = (rec && typeof rec === 'object' ? rec : {});
652
+ if (!exports.DOOR_NAMES.includes(r.door))
653
+ refuse('door');
654
+ if (r.mode !== null && !DOOR_MODES.includes(r.mode))
655
+ refuse('mode');
656
+ const text = (field, v) => {
657
+ if (v === null)
658
+ return DOOR_NONE;
659
+ if (typeof v !== 'string' || !isDisplaySafeValue(v))
660
+ return refuse(field);
661
+ return v;
662
+ };
663
+ const vault = r.vault === undefined ? refuse('vault') : r.vault;
664
+ if (vault !== null && (typeof vault !== 'object' || Array.isArray(vault)))
665
+ refuse('vault');
666
+ const degraded = r.degraded === undefined ? refuse('degraded') : r.degraded;
667
+ // Exactly one of the two: the coordinates of a loaded vault, or the
668
+ // degraded one-liner saying why there were none (D-12 "or").
669
+ if ((vault === null) === (degraded === null))
670
+ refuse('vault');
671
+ if (vault !== null && !isAnchorSha(vault.sha))
672
+ refuse('vault-sha');
673
+ if (!isAnchorSha(r.startCommit))
674
+ refuse('start-commit');
675
+ if (!isAnchorSha(r.endCommit))
676
+ refuse('end-commit');
677
+ if (typeof r.recordedAt !== 'string' || !ISO_UTC_RE.test(r.recordedAt))
678
+ refuse('recorded-at');
679
+ return {
680
+ door: r.door,
681
+ mode: r.mode === null ? DOOR_NONE : r.mode,
682
+ 'vault-repo': vault === null ? DOOR_NONE : text('vault-repo', vault.repo),
683
+ 'vault-ref': vault === null ? DOOR_NONE : text('vault-ref', vault.ref),
684
+ 'vault-sha': vault === null ? DOOR_NONE : vault.sha,
685
+ degraded: text('degraded', degraded),
686
+ phase: r.phase === undefined ? refuse('phase') : text('phase', r.phase),
687
+ 'start-commit': r.startCommit,
688
+ 'end-commit': r.endCommit,
689
+ 'recorded-at': r.recordedAt,
690
+ };
691
+ }
692
+ /**
693
+ * Serialise a door record: the heading line, then one `- key: value` line per
694
+ * field in the fixed key order, then a newline. An absent value is written
695
+ * `none`. Throws, naming the field, on any value the parser would refuse, so
696
+ * the writer can never emit a record its own reader rejects.
697
+ */
698
+ function formatDoorRecord(rec) {
699
+ const values = doorRecordValues(rec);
700
+ const lines = [exports.DOOR_RECORD_HEADING, ...DOOR_RECORD_KEYS.map((k) => `- ${k}: ${values[k]}`)];
701
+ return `${lines.join('\n')}\n`;
702
+ }
703
+ /**
704
+ * Read a door record back out of a text (an artifact a door wrote into).
705
+ *
706
+ * The heading counts only as a whole column-0 line outside the frontmatter,
707
+ * fenced code and HTML comments; the section ends at the next ATX heading,
708
+ * thematic break or the end of the text — the `parseConsultedSection` rule,
709
+ * asked of the standards leaf's own scanner (`findLiveSection`). No heading →
710
+ * `{ ok: true, record: null }`. Two headings → a duplicate problem. Inside the
711
+ * section only the ten key lines, in order, each once, are legal; blank lines
712
+ * aside, anything else is malformed. Never throws.
713
+ */
714
+ function parseDoorRecord(text) {
715
+ try {
716
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
717
+ const standards = require('./vault-standards.cjs');
718
+ const { lines, headings, body } = standards.findLiveSection(text, exports.DOOR_RECORD_HEADING);
719
+ if (headings.length === 0)
720
+ return { ok: true, record: null };
721
+ const fail = (kind, field, idx) => ({
722
+ ok: false,
723
+ record: null,
724
+ problems: [{ kind, field, line: idx === null ? null : idx + 1 }],
725
+ });
726
+ if (headings.length > 1)
727
+ return fail(exports.DOOR_RECORD_PROBLEM_KIND.DUPLICATE, null, headings[1]);
728
+ if (body.length !== DOOR_RECORD_KEYS.length) {
729
+ return fail(exports.DOOR_RECORD_PROBLEM_KIND.MALFORMED, null, body.length > DOOR_RECORD_KEYS.length ? body[DOOR_RECORD_KEYS.length] : headings[0]);
730
+ }
731
+ const raw = {};
732
+ for (let k = 0; k < DOOR_RECORD_KEYS.length; k += 1) {
733
+ const m = DOOR_LINE_RE.exec(lines[body[k]]);
734
+ if (!m || m[1] !== DOOR_RECORD_KEYS[k])
735
+ return fail(exports.DOOR_RECORD_PROBLEM_KIND.MALFORMED, DOOR_RECORD_KEYS[k], body[k]);
736
+ raw[DOOR_RECORD_KEYS[k]] = m[2];
737
+ }
738
+ const at = (key) => body[DOOR_RECORD_KEYS.indexOf(key)];
739
+ const invalid = (key) => fail(exports.DOOR_RECORD_PROBLEM_KIND.FIELD_INVALID, key, at(key));
740
+ const textOrNull = (key) => {
741
+ const v = raw[key];
742
+ if (v === DOOR_NONE)
743
+ return null;
744
+ return isDisplaySafeValue(v) ? v : undefined;
745
+ };
746
+ if (!exports.DOOR_NAMES.includes(raw.door))
747
+ return invalid('door');
748
+ if (raw.mode !== DOOR_NONE && !DOOR_MODES.includes(raw.mode))
749
+ return invalid('mode');
750
+ const repo = textOrNull('vault-repo');
751
+ if (repo === undefined)
752
+ return invalid('vault-repo');
753
+ const ref = textOrNull('vault-ref');
754
+ if (ref === undefined)
755
+ return invalid('vault-ref');
756
+ const sha = raw['vault-sha'] === DOOR_NONE ? null : raw['vault-sha'];
757
+ if (sha !== null && !isAnchorSha(sha))
758
+ return invalid('vault-sha');
759
+ const coords = [repo, ref, sha];
760
+ if (coords.some((c) => c === null) && coords.some((c) => c !== null))
761
+ return invalid('vault-repo');
762
+ const degraded = textOrNull('degraded');
763
+ if (degraded === undefined)
764
+ return invalid('degraded');
765
+ if ((sha === null) === (degraded === null))
766
+ return invalid('degraded');
767
+ const phase = textOrNull('phase');
768
+ if (phase === undefined)
769
+ return invalid('phase');
770
+ if (!isAnchorSha(raw['start-commit']))
771
+ return invalid('start-commit');
772
+ if (!isAnchorSha(raw['end-commit']))
773
+ return invalid('end-commit');
774
+ if (!ISO_UTC_RE.test(raw['recorded-at']))
775
+ return invalid('recorded-at');
776
+ return {
777
+ ok: true,
778
+ record: {
779
+ door: raw.door,
780
+ mode: raw.mode === DOOR_NONE ? null : raw.mode,
781
+ vault: sha === null ? null : { repo: repo, ref: ref, sha },
782
+ degraded,
783
+ phase,
784
+ startCommit: raw['start-commit'],
785
+ endCommit: raw['end-commit'],
786
+ recordedAt: raw['recorded-at'],
787
+ },
788
+ };
789
+ }
790
+ catch {
791
+ return { ok: false, record: null, problems: [{ kind: exports.DOOR_RECORD_PROBLEM_KIND.MALFORMED, field: null, line: null }] };
792
+ }
793
+ }
794
+ // ─── Recorded-artifact presence (D-20) ───────────────────────────────────────
795
+ /**
796
+ * A single artifact is read whole; anything larger is skipped, not streamed.
797
+ * MOVED here from `src/check-command-router.cts` by 16-03 and read back from
798
+ * there (the `TRACE_REGENERATE_COMMAND` precedent), so the trace gate's scan
799
+ * and this predicate's scan cannot be capped differently.
800
+ */
801
+ exports.TRACE_ARTIFACT_BYTE_CAP = 1024 * 1024;
802
+ /** A directory with more candidate artifacts than this is not a phase directory. */
803
+ exports.TRACE_ARTIFACT_COUNT_CAP = 200;
804
+ /** How many planning directories the project-level predicate visits at most. */
805
+ exports.RECORDED_ARTIFACT_DIR_CAP = 2000;
806
+ /** What an engine-written record found in a directory is. Frozen; one id per D-20 record type. */
807
+ exports.RECORDED_ARTIFACT_KIND = Object.freeze({
808
+ STANDARDS_BLOCK: 'standards_block',
809
+ STANDARDS_CONSULTED: 'standards_consulted',
810
+ DOOR_RECORD: 'door_record',
811
+ });
812
+ /**
813
+ * The artifacts a door writes its record into (16-15): `-REVIEW-FIX.md`,
814
+ * `-VALIDATION.md`, `-ADD-TESTS.md`, `-AUDIT-FIX.md`, alone or after a prefix.
815
+ */
816
+ exports.DOOR_ARTIFACT_FILENAME_RE = Object.freeze(/(^|-)(REVIEW-FIX|VALIDATION|ADD-TESTS|AUDIT-FIX)\.md$/);
817
+ /** The router's own filename rules for the two record-bearing artifacts. */
818
+ const PLAN_FILENAME_RE = /(^|-)PLAN\.md$/;
819
+ const SUMMARY_FILENAME_RE = /(^|-)SUMMARY\.md$/;
820
+ function readers() {
821
+ /* eslint-disable @typescript-eslint/no-require-imports */
822
+ return {
823
+ standards: require('./vault-standards.cjs'),
824
+ };
825
+ /* eslint-enable @typescript-eslint/no-require-imports */
826
+ }
827
+ /** The project root a directory belongs to (the router's rule: `findProjectRoot(x) || x`). */
828
+ function resolveRoot(root, from) {
829
+ if (typeof root === 'string' && root !== '')
830
+ return root;
831
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
832
+ const projectRoot = require('./project-root.cjs');
833
+ return projectRoot.findProjectRoot(from) || from;
834
+ }
835
+ /** Every record kind one candidate's text holds, each once. */
836
+ function classifyCandidate(r, text, role) {
837
+ const kinds = [];
838
+ // A parse FAILURE is not presence: a malformed record is the trace gate's
839
+ // business, and this predicate only answers whether the engine wrote one.
840
+ if (!role.doorOnly && role.plan) {
841
+ const block = r.standards.parseStandardsBlock(text);
842
+ if (block.present && block.record !== null)
843
+ kinds.push(exports.RECORDED_ARTIFACT_KIND.STANDARDS_BLOCK);
844
+ }
845
+ if (!role.doorOnly && role.summary) {
846
+ const section = r.standards.parseConsultedSection(text);
847
+ if (section.present && section.planId !== null)
848
+ kinds.push(exports.RECORDED_ARTIFACT_KIND.STANDARDS_CONSULTED);
849
+ }
850
+ const door = parseDoorRecord(text);
851
+ if (door.ok && door.record !== null)
852
+ kinds.push(exports.RECORDED_ARTIFACT_KIND.DOOR_RECORD);
853
+ return kinds;
854
+ }
855
+ /**
856
+ * The engine-written vault records one directory holds (phase-level, D-20).
857
+ *
858
+ * The scan shape of the router's `collectTraceArtifacts`: `readdir`, sort,
859
+ * filename rules, a `seen` counter, a cap without `break`. Candidates are
860
+ * `*PLAN.md`, `*SUMMARY.md` and the door artifacts. Each candidate under the
861
+ * byte cap is read ONCE and classified by the real parsers, never a regex:
862
+ * - `standards_block`: a parsed `<vault_standards>` record (PLAN);
863
+ * - `standards_consulted`: a parsed `## Standards Consulted` section (SUMMARY);
864
+ * - `door_record`: a parsed door record (any candidate).
865
+ * A missing directory reads as empty.
866
+ *
867
+ * This reader does NOT check `.platform-bindings.json`. Its callers are the
868
+ * handlers, which are bindings-inert before they get here (D-37), and the
869
+ * project-level predicate below, which checks bindings first (D-42).
870
+ */
871
+ function collectRecordedArtifacts(dirAbs, opts = {}) {
872
+ const fsx = opts.fs ?? node_fs_1.default;
873
+ const byteCap = typeof opts.byteCap === 'number' ? opts.byteCap : exports.TRACE_ARTIFACT_BYTE_CAP;
874
+ const countCap = typeof opts.countCap === 'number' ? opts.countCap : exports.TRACE_ARTIFACT_COUNT_CAP;
875
+ const doorOnly = opts.doorOnly === true;
876
+ let entries;
877
+ try {
878
+ entries = fsx.readdirSync(dirAbs).map(String);
879
+ }
880
+ catch {
881
+ return { artifacts: [], truncated: false, seen: 0, skipped: 0 };
882
+ }
883
+ const root = resolveRoot(opts.root, dirAbs);
884
+ const relOf = (abs) => node_path_1.default.relative(root, abs).split(node_path_1.default.sep).join('/') || '.';
885
+ const r = readers();
886
+ const artifacts = [];
887
+ let seen = 0;
888
+ let admitted = 0;
889
+ let skipped = 0;
890
+ for (const name of entries.sort()) {
891
+ if (opts.onlyFile !== undefined && name !== opts.onlyFile)
892
+ continue;
893
+ const plan = PLAN_FILENAME_RE.test(name);
894
+ const summary = SUMMARY_FILENAME_RE.test(name);
895
+ const door = exports.DOOR_ARTIFACT_FILENAME_RE.test(name);
896
+ if (doorOnly ? !door : !(plan || summary || door))
897
+ continue;
898
+ // The router's 04-07 rule: count every candidate, admit no more than the cap.
899
+ seen += 1;
900
+ if (admitted >= countCap)
901
+ continue;
902
+ admitted += 1;
903
+ const abs = node_path_1.default.join(dirAbs, name);
904
+ let text;
905
+ try {
906
+ const st = fsx.statSync(abs);
907
+ if (!st.isFile() || st.size > byteCap) {
908
+ skipped += 1;
909
+ continue;
910
+ }
911
+ text = String(fsx.readFileSync(abs, 'utf8'));
912
+ }
913
+ catch {
914
+ skipped += 1;
915
+ continue;
916
+ }
917
+ for (const kind of classifyCandidate(r, text, { plan, summary, doorOnly })) {
918
+ artifacts.push({ rel: relOf(abs), abs, kind });
919
+ if (opts.stopAtFirst === true)
920
+ return { artifacts, truncated: false, seen, skipped };
921
+ }
922
+ }
923
+ return { artifacts, truncated: seen > countCap, seen, skipped };
924
+ }
925
+ // ─── disabled_with_artifacts (SEAL-02 handlers, 16-09; D-21, D-22, D-39) ─────
926
+ /**
927
+ * The status the trace gate answers when `vault.enabled` is off over a phase
928
+ * that holds engine-written records. The literal is restated at the emitting
929
+ * line (the producer-audit and FENCE 2 probes read emission sites); this
930
+ * constant is what tests compare against.
931
+ */
932
+ exports.DISABLED_WITH_ARTIFACTS_STATUS = 'disabled_with_artifacts';
933
+ /** The one switch that can silence the vault gates. */
934
+ exports.VAULT_SWITCH_KEY = 'vault.enabled';
935
+ /** How many artifact paths a verdict lists before it only counts. */
936
+ exports.DISABLED_WITH_ARTIFACTS_LIST_CAP = 20;
937
+ /**
938
+ * The repair, and only the repair (D-39, INVARIANT 4): turn the switch back on
939
+ * so the records can be checked. There is no override for this verdict, and
940
+ * the records are never named as something to get rid of.
941
+ */
942
+ exports.DISABLED_WITH_ARTIFACTS_REPAIR = 'so the gate can check them. This verdict has no override; the records stay as they are.';
943
+ /**
944
+ * The one-line message (D-39). Every value it interpolates is engine-derived
945
+ * (a count, the resolver's repo-relative directory), never artifact text.
946
+ */
947
+ function formatDisabledWithArtifacts(facts) {
948
+ const n = facts.artifactCount;
949
+ const unread = typeof facts.unreadCount === 'number' && facts.unreadCount > 0 ? facts.unreadCount : 0;
950
+ const where = facts.phaseRel === '' ? 'this phase' : facts.phaseRel;
951
+ const found = `${n} recorded vault artifact${n === 1 ? '' : 's'} in ${where}`;
952
+ const what = unread === 0
953
+ ? `${found} cannot be checked`
954
+ : `${found}, and ${unread} candidate file${unread === 1 ? '' : 's'} there not read (over the size or count `
955
+ + 'cap, or unreadable), so a record cannot be ruled out; none of it can be checked';
956
+ return `Vault records ${n > 0 ? 'present' : 'not ruled out'} while \`${exports.VAULT_SWITCH_KEY}\` is off `
957
+ + `(${exports.DISABLED_WITH_ARTIFACTS_STATUS}): ${what}, so this phase cannot be recorded as governed (SEAL-02). `
958
+ + disabledWithArtifactsRepair();
959
+ }
960
+ /**
961
+ * The repair sentence alone: the message's tail, and the trusted tail of the
962
+ * gate's problem detail, so the two can never name different repairs.
963
+ */
964
+ function disabledWithArtifactsRepair() {
965
+ return `Repair: set \`${exports.VAULT_SWITCH_KEY}\` to true ${exports.DISABLED_WITH_ARTIFACTS_REPAIR}`;
966
+ }
967
+ /**
968
+ * The `disabled_with_artifacts` verdict body over one phase's scan, or `null`
969
+ * when the scan found no record (the caller then answers exactly as before).
970
+ *
971
+ * Paths are de-duplicated (one file can hold two records) and listed in scan
972
+ * order up to `DISABLED_WITH_ARTIFACTS_LIST_CAP`; `artifactCount` counts them
973
+ * all.
974
+ *
975
+ * ABSENCE MUST BE PROVEN (16-09, D-69, amending D-44's handler sentence). A
976
+ * scan that found no record but did not read every candidate (a file over the
977
+ * byte cap, an unreadable one, or more candidates than the count cap) cannot
978
+ * say "never recorded", so it answers this verdict with `artifactCount: 0` and
979
+ * `unreadCount`, never `null`. A `null` here makes the handler answer
980
+ * `inactive` / `inert`, `block: false`: a dispatched seal gate passing where it
981
+ * proved nothing. Turning the switch back on lets the trace gate name the
982
+ * unread file itself (`artifact_too_large`, `artifact_unreadable`,
983
+ * `artifacts_truncated`).
984
+ */
985
+ function disabledWithArtifactsVerdict(scan, opts) {
986
+ const admitted = Math.min(scan.seen, exports.TRACE_ARTIFACT_COUNT_CAP);
987
+ const unread = scan.skipped + (scan.truncated ? scan.seen - admitted : 0);
988
+ if (scan.artifacts.length === 0 && unread === 0)
989
+ return null;
990
+ const rels = [];
991
+ const kinds = [];
992
+ for (const a of scan.artifacts) {
993
+ if (!rels.includes(a.rel))
994
+ rels.push(a.rel);
995
+ if (!kinds.includes(a.kind))
996
+ kinds.push(a.kind);
997
+ }
998
+ return {
999
+ artifacts: rels.slice(0, exports.DISABLED_WITH_ARTIFACTS_LIST_CAP),
1000
+ artifactCount: rels.length,
1001
+ ...(unread > 0 ? { unreadCount: unread } : {}),
1002
+ kinds,
1003
+ message: formatDisabledWithArtifacts({
1004
+ artifactCount: rels.length,
1005
+ phaseRel: opts.phaseRel,
1006
+ ...(unread > 0 ? { unreadCount: unread } : {}),
1007
+ }),
1008
+ };
1009
+ }
1010
+ /** The sub-directories of a planning root, sorted; `null` when absence cannot be proven. */
1011
+ function childDirs(fsx, dirAbs) {
1012
+ try {
1013
+ return fsx.readdirSync(dirAbs, { withFileTypes: true })
1014
+ .filter((d) => d.isDirectory())
1015
+ .map((d) => node_path_1.default.join(dirAbs, d.name))
1016
+ .sort();
1017
+ }
1018
+ catch (e) {
1019
+ const code = e?.code;
1020
+ return code === 'ENOENT' || code === 'ENOTDIR' ? [] : null;
1021
+ }
1022
+ }
1023
+ /** Raised inside the enumeration when absence can no longer be proven (D-44). */
1024
+ class CannotProveAbsence extends Error {
1025
+ }
1026
+ /** Every directory the project-level predicate reads, in order, lazily. */
1027
+ function* planningDirs(fsx, planning) {
1028
+ const listed = (dir) => {
1029
+ const out = childDirs(fsx, dir);
1030
+ if (out === null)
1031
+ throw new CannotProveAbsence();
1032
+ return out;
1033
+ };
1034
+ for (const dir of listed(node_path_1.default.join(planning, 'phases')))
1035
+ yield { dir, doorOnly: false };
1036
+ for (const ws of listed(node_path_1.default.join(planning, 'workstreams'))) {
1037
+ for (const dir of listed(node_path_1.default.join(ws, 'phases')))
1038
+ yield { dir, doorOnly: false };
1039
+ }
1040
+ // Quick task directories are always under the ROOT planning tree, never a
1041
+ // workstream's (src/init.cts quick-dir resolution).
1042
+ for (const dir of listed(node_path_1.default.join(planning, 'quick')))
1043
+ yield { dir, doorOnly: false };
1044
+ yield { dir: node_path_1.default.join(planning, 'audits'), doorOnly: true };
1045
+ }
1046
+ /**
1047
+ * Does this project hold ANY engine-written vault record? (project-level, D-20)
1048
+ *
1049
+ * The question the resolver's injected seal predicate asks (16-08): a seal
1050
+ * gate renders for a config-inactive capability only when this is true.
1051
+ *
1052
+ * - No `.platform-bindings.json` at the project root → false, first, before
1053
+ * any planning file is read (D-42): a non-Pareto project never holds
1054
+ * recorded artifacts, even if it ran a vault verb that wrote one, because
1055
+ * every vault handler answers it inert first (D-37), and a seal that
1056
+ * rendered there would dispatch a gate whose handler cannot block.
1057
+ * - Otherwise every phase directory (root and workstream), every quick
1058
+ * directory and the door artifacts in `.planning/audits` are scanned with
1059
+ * `collectRecordedArtifacts`; the first record found answers true and
1060
+ * nothing more is read.
1061
+ * - When absence cannot be PROVEN — the directory cap is passed, a
1062
+ * directory's candidates were truncated or skipped, or a planning root
1063
+ * could not be listed for a reason other than not existing — the answer is
1064
+ * true (D-44). A false here silences the seal, so it must mean "looked
1065
+ * everywhere and found nothing". A true only lets the seal gate render; its
1066
+ * handler then reads the phase it is asked about and blocks only on the
1067
+ * records actually there.
1068
+ */
1069
+ function hasRecordedVaultArtifacts(cwd, opts = {}) {
1070
+ const fsx = opts.fs ?? node_fs_1.default;
1071
+ let root;
1072
+ try {
1073
+ root = resolveRoot(undefined, cwd);
1074
+ // 16-05 (D-37): the one bindings predicate the five vault handlers ask
1075
+ // first, required lazily (D-41: this leaf imports only fs and path at load
1076
+ // time) and handed this function's file system, so the seam still holds.
1077
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1078
+ const vaultMod = require('./vault.cjs');
1079
+ if (!vaultMod.hasPlatformBindings(root, fsx))
1080
+ return false;
1081
+ }
1082
+ catch {
1083
+ return false;
1084
+ }
1085
+ try {
1086
+ let visited = 0;
1087
+ for (const { dir, doorOnly } of planningDirs(fsx, node_path_1.default.join(root, '.planning'))) {
1088
+ visited += 1;
1089
+ if (visited > exports.RECORDED_ARTIFACT_DIR_CAP)
1090
+ return true;
1091
+ const scan = collectRecordedArtifacts(dir, { root, fs: fsx, doorOnly, stopAtFirst: true });
1092
+ if (scan.artifacts.length > 0)
1093
+ return true;
1094
+ if (scan.truncated || scan.skipped > 0)
1095
+ return true;
1096
+ }
1097
+ return false;
1098
+ }
1099
+ catch {
1100
+ return true;
1101
+ }
1102
+ }
1103
+ /**
1104
+ * Read a record's start, end and back-fill anchors, by record kind.
1105
+ *
1106
+ * - `standards_consulted`: the parsed section's optional anchor line
1107
+ * (`anchors: { start?, end, backfill? }`, 16-06);
1108
+ * - `door_record`: the record's `startCommit` / `endCommit` (a door record is
1109
+ * written by its door, so it is never back-filled).
1110
+ * Every other kind, and every record written before Phase 16, has none: all
1111
+ * three are `undefined`. A value that is present but fails its grammar is
1112
+ * DROPPED and reported in `problems` by field, so 16-07 can type it; it never
1113
+ * reaches a caller that might place it in a git argv (T-16-07). Never throws.
1114
+ *
1115
+ * 16-06 re-premised `backfill` (D-55): 16-03 read it as a sha taken from the
1116
+ * back-fill object's `at`. D-32 makes the back-fill an attribution — who and
1117
+ * when — while the sha the operator supplied with `--at` becomes the record's
1118
+ * END anchor, so `backfill` is now that `{ by, at }` pair.
1119
+ */
1120
+ function extractRecordAnchors(text, kind) {
1121
+ const out = { start: undefined, end: undefined, backfill: undefined, problems: [] };
1122
+ const take = (field, value) => {
1123
+ if (value === undefined || value === null)
1124
+ return;
1125
+ if (isAnchorSha(value))
1126
+ out[field] = value;
1127
+ else
1128
+ out.problems.push({ kind: 'anchor_invalid', field });
1129
+ };
1130
+ const takeBackfill = (value) => {
1131
+ if (value === undefined || value === null)
1132
+ return;
1133
+ if (isAnchorBackfill(value))
1134
+ out.backfill = { by: value.by, at: value.at };
1135
+ else
1136
+ out.problems.push({ kind: 'anchor_invalid', field: 'backfill' });
1137
+ };
1138
+ try {
1139
+ const body = typeof text === 'string' ? text : '';
1140
+ if (kind === exports.RECORDED_ARTIFACT_KIND.STANDARDS_CONSULTED) {
1141
+ const standards = readers().standards;
1142
+ const section = standards.parseConsultedSection(body);
1143
+ if (section.present !== true || section.planId === null) {
1144
+ out.problems.push({ kind: 'record_unparsable', field: 'record' });
1145
+ return out;
1146
+ }
1147
+ const anchors = (section.anchors && typeof section.anchors === 'object' ? section.anchors : {});
1148
+ take('start', anchors.start);
1149
+ take('end', anchors.end);
1150
+ takeBackfill(anchors.backfill);
1151
+ // A prefixed anchor line that did not parse is dropped by the parser and
1152
+ // reported as `standards_anchor_malformed`; it is an invalid anchor here.
1153
+ const problems = Array.isArray(section.problems) ? section.problems : [];
1154
+ if (problems.some((p) => p && p.kind === standards.STANDARDS_PROBLEM_KIND.ANCHOR_MALFORMED)) {
1155
+ out.problems.push({ kind: 'anchor_invalid', field: 'end' });
1156
+ }
1157
+ }
1158
+ else if (kind === exports.RECORDED_ARTIFACT_KIND.DOOR_RECORD) {
1159
+ const parsed = parseDoorRecord(body);
1160
+ if (!parsed.ok || parsed.record === null) {
1161
+ out.problems.push({ kind: 'record_unparsable', field: 'record' });
1162
+ return out;
1163
+ }
1164
+ take('start', parsed.record.startCommit);
1165
+ take('end', parsed.record.endCommit);
1166
+ }
1167
+ }
1168
+ catch {
1169
+ out.problems.push({ kind: 'record_unparsable', field: 'record' });
1170
+ }
1171
+ return out;
1172
+ }
1173
+ // ─── SEAL-01: commits no record covers (16-07; D-15, D-16, D-31) ─────────────
1174
+ //
1175
+ // THE QUESTION. A phase's records end at their END anchors. A commit made after
1176
+ // them is invisible to every reader that judges records, so an unrecorded
1177
+ // change could ship under a phase whose records all say "governed". The strict
1178
+ // trace points (ship:pre, verify:post on a full-phase run, quick's
1179
+ // execute:post) ask instead: which commits reachable from HEAD are reachable
1180
+ // from none of this phase's ends, and are covered by no other record's range?
1181
+ //
1182
+ // THE RANGE RULE (D-31, research C-7). A commit c is covered iff some record r
1183
+ // has start_r < c <= end_r in ancestry: `rev-list start_r..end_r`, start
1184
+ // exclusive, end inclusive. The simpler ancestor rule ("c is an ancestor of
1185
+ // some recorded end") was measured to hide an ungoverned commit followed by ANY
1186
+ // later record (E1 -> X -> Q: X is an ancestor of Q's end), which is a record
1187
+ // claiming governance it never had. Per-record calls are required: the union of
1188
+ // ranges is not `ends --not starts`.
1189
+ //
1190
+ // ASSUMPTIONS (16-RESEARCH A1, A3), stated where they bind:
1191
+ // - A1: merge commits are excluded (`--no-merges`); the commits a merge brings
1192
+ // in are examined themselves. An "evil merge" carrying its own source edit
1193
+ // is not seen.
1194
+ // - A3: a shallow clone may not hold an old anchor; that reads as an unknown
1195
+ // anchor, and the router's remedy names `git fetch --unshallow`.
1196
+ //
1197
+ // SAFETY OF THE ARGV (T-16-25). Every recorded sha is checked against
1198
+ // `ANCHOR_SHA_RE` before any git call. Revisions for `rev-list` go on STDIN,
1199
+ // never in argv (research Pitfall 6); a sha placed in argv (`merge-base`,
1200
+ // `diff-tree`, `log`) comes after the subcommand and is hex by construction,
1201
+ // so it can never be read as an option.
1202
+ //
1203
+ // BOUNDS (T-16-28). Every call is the injected bounded `execGit` (timeout, no
1204
+ // throw; ENOBUFS and a timeout come back as a failed exit). Each count has a
1205
+ // cap, and passing one is a typed failure, never a silent partial answer.
1206
+ /** At most this many uncovered source commits are listed; the rest are counted. */
1207
+ exports.UNCOVERED_COMMIT_CAP = 50;
1208
+ /**
1209
+ * At most this many covering records may need their range computed. Only a
1210
+ * record whose END is itself one of the candidates can cover a candidate (see
1211
+ * `computeUncoveredSourceCommits`), so this counts the records written after
1212
+ * the phase being judged, not every record the project holds.
1213
+ */
1214
+ exports.COVERING_RECORD_CAP = 200;
1215
+ /** At most this many source paths are named per uncovered commit. */
1216
+ exports.UNCOVERED_PATHS_PER_COMMIT_CAP = 20;
1217
+ /**
1218
+ * At most this many commits after the phase's ends are examined at all, and at
1219
+ * most this many commits a single record's range may hold. 5000 full ids are
1220
+ * ~205 KB of `rev-list` output (sha-1) or ~325 KB (sha-256), well inside
1221
+ * the 1 MiB child-process output buffer `execGit` runs with.
1222
+ */
1223
+ exports.SEAL_CANDIDATE_CAP = 5000;
1224
+ /** At most this many uncovered commits have their paths read (one `diff-tree` each). */
1225
+ exports.SEAL_EXAMINE_CAP = 500;
1226
+ /** A commit subject is bounded to this many characters before it leaves the leaf. */
1227
+ exports.SEAL_SUBJECT_CAP = 120;
1228
+ /** Each git call's timeout. */
1229
+ exports.SEAL_GIT_TIMEOUT_MS = 10_000;
1230
+ /**
1231
+ * A record's anchors as the seal judges them: `extractRecordAnchors`, plus ONE
1232
+ * fallback for the start.
1233
+ *
1234
+ * A SUMMARY record (the standards section) that carries an END but no START
1235
+ * takes the start its own directory's `.vault-anchors.json` records. That file
1236
+ * is the one `vault standards consulted` reads the start from when it writes
1237
+ * the record, so the fallback reads the same CLI-recorded value from the same
1238
+ * place, at a later moment (D-59). It exists for the worktree executor (deferred-items item 12): the
1239
+ * anchors file is untracked, a linked worktree does not have it, so a record
1240
+ * written there carries the end only; the strict points run in the checkout
1241
+ * that wrote the file, where the start is readable.
1242
+ *
1243
+ * Not applied when the record's own anchor was present and malformed (a
1244
+ * `problems` entry): a hand-edited start is reported as `anchor_invalid` at
1245
+ * every point (D-58), and must not be quietly replaced by another value. Not
1246
+ * applied to a door record, whose start is its own `--begin` start and whose
1247
+ * directory's anchors file is the phase's, not the door's. Never throws.
1248
+ */
1249
+ function resolveRecordAnchors(text, kind, dirAbs, opts = {}) {
1250
+ const anchors = extractRecordAnchors(text, kind);
1251
+ const out = { ...anchors, startFrom: anchors.start !== undefined ? 'record' : undefined };
1252
+ const summaryKind = kind === exports.RECORDED_ARTIFACT_KIND.STANDARDS_CONSULTED;
1253
+ if (!summaryKind || anchors.end === undefined || anchors.start !== undefined)
1254
+ return out;
1255
+ if (anchors.problems.some((p) => p.kind === 'anchor_invalid'))
1256
+ return out;
1257
+ const dir = opts.dirAnchors !== undefined ? opts.dirAnchors : readDirAnchors(dirAbs);
1258
+ if (!dir || !isAnchorSha(dir.start))
1259
+ return out;
1260
+ // D-98: the LATEST recorded start that precedes this record's own end. A
1261
+ // start recorded after the record's work (a resume) is not its start, so a
1262
+ // run-1 worktree record keeps run 1's start and a later one takes the later
1263
+ // start. Without an ancestry check (no git), the current start alone — the
1264
+ // reading that covers least.
1265
+ const candidates = [dir.start, ...(dir.earlier ?? []).map((e) => e.start)];
1266
+ const end = anchors.end;
1267
+ const chosen = typeof opts.isAncestor === 'function'
1268
+ ? candidates.find((c) => {
1269
+ try {
1270
+ return opts.isAncestor(c, end);
1271
+ }
1272
+ catch {
1273
+ return false;
1274
+ }
1275
+ })
1276
+ : candidates[0];
1277
+ if (chosen !== undefined) {
1278
+ out.start = chosen;
1279
+ out.startFrom = 'directory';
1280
+ }
1281
+ return out;
1282
+ }
1283
+ /**
1284
+ * The ancestry predicate the strict gate injects: one bounded `merge-base
1285
+ * --is-ancestor` per question, shapes checked before git is reached, any
1286
+ * failure read as "not an ancestor" (the reading that covers less).
1287
+ */
1288
+ function sealIsAncestorVia(execGit, root, timeoutMs = exports.SEAL_GIT_TIMEOUT_MS) {
1289
+ return (ancestor, descendant) => {
1290
+ if (!isAnchorSha(ancestor) || !isAnchorSha(descendant))
1291
+ return false;
1292
+ try {
1293
+ const r = execGit(['merge-base', '--is-ancestor', ancestor, descendant], { cwd: root, timeout: timeoutMs });
1294
+ return r.exitCode === 0 && (r.error === undefined || r.error === null) && (r.signal === undefined || r.signal === null);
1295
+ }
1296
+ catch {
1297
+ return false;
1298
+ }
1299
+ };
1300
+ }
1301
+ /** The attestations a directory holds for one record file, as the seal reads them. */
1302
+ function attestedFor(attestations, recordName) {
1303
+ return attestations.filter((a) => a.record === recordName).map((a) => ({ sha: a.sha, from: a.from }));
1304
+ }
1305
+ /** A cheap pre-filter: a record written before 16-06 carries neither marker and is not parsed. */
1306
+ const CONSULTED_ANCHOR_MARKER = '- anchors: ';
1307
+ /**
1308
+ * Every record in the project's planning tree that carries an END anchor.
1309
+ *
1310
+ * Walks the roots `hasRecordedVaultArtifacts` walks, in its order: phase
1311
+ * directories (root and every workstream), quick-task directories, and the
1312
+ * door artifacts in `.planning/audits`. In each directory the candidates are
1313
+ * `*SUMMARY.md` (a standards section) and the door
1314
+ * artifacts (a door record), each read once under `TRACE_ARTIFACT_BYTE_CAP`.
1315
+ * A record carrying an end but no start is returned with `start` undefined
1316
+ * (it covers nothing, D-31) unless its directory's anchors file supplies one
1317
+ * (`resolveRecordAnchors`, D-59).
1318
+ *
1319
+ * `exclude` names records (repo-relative) the caller judges itself: the trace
1320
+ * gate passes the phase's own SUMMARYs, which are its END anchors, not its
1321
+ * coverage. The caller is bindings-inert before it gets here (D-37). Never
1322
+ * throws.
1323
+ */
1324
+ function collectCoveringRecords(root, opts = {}) {
1325
+ const fsx = opts.fs ?? node_fs_1.default;
1326
+ const exclude = new Set(opts.exclude ?? []);
1327
+ const records = [];
1328
+ let incomplete = false;
1329
+ let unread = 0;
1330
+ const relOf = (abs) => node_path_1.default.relative(root, abs).split(node_path_1.default.sep).join('/');
1331
+ try {
1332
+ let visited = 0;
1333
+ for (const { dir, doorOnly } of planningDirs(fsx, node_path_1.default.join(root, '.planning'))) {
1334
+ visited += 1;
1335
+ if (visited > exports.RECORDED_ARTIFACT_DIR_CAP) {
1336
+ incomplete = true;
1337
+ break;
1338
+ }
1339
+ let entries;
1340
+ try {
1341
+ entries = fsx.readdirSync(dir).map(String).sort();
1342
+ }
1343
+ catch (e) {
1344
+ const code = e?.code;
1345
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
1346
+ incomplete = true;
1347
+ continue;
1348
+ }
1349
+ let dirAnchors;
1350
+ let dirAttestations;
1351
+ let dirCertifiedEnds;
1352
+ let admitted = 0;
1353
+ for (const name of entries) {
1354
+ const summary = !doorOnly && SUMMARY_FILENAME_RE.test(name);
1355
+ const door = exports.DOOR_ARTIFACT_FILENAME_RE.test(name);
1356
+ if (!summary && !door)
1357
+ continue;
1358
+ const abs = node_path_1.default.join(dir, name);
1359
+ const rel = relOf(abs);
1360
+ if (exclude.has(rel))
1361
+ continue;
1362
+ if (admitted >= exports.TRACE_ARTIFACT_COUNT_CAP) {
1363
+ incomplete = true;
1364
+ break;
1365
+ }
1366
+ admitted += 1;
1367
+ let text;
1368
+ try {
1369
+ const st = fsx.statSync(abs);
1370
+ if (!st.isFile() || st.size > exports.TRACE_ARTIFACT_BYTE_CAP) {
1371
+ unread += 1;
1372
+ incomplete = true;
1373
+ continue;
1374
+ }
1375
+ text = String(fsx.readFileSync(abs, 'utf8'));
1376
+ }
1377
+ catch {
1378
+ unread += 1;
1379
+ incomplete = true;
1380
+ continue;
1381
+ }
1382
+ const tries = [];
1383
+ if (summary && text.includes(CONSULTED_ANCHOR_MARKER))
1384
+ tries.push(exports.RECORDED_ARTIFACT_KIND.STANDARDS_CONSULTED);
1385
+ if (door && text.includes(exports.DOOR_RECORD_HEADING))
1386
+ tries.push(exports.RECORDED_ARTIFACT_KIND.DOOR_RECORD);
1387
+ for (const kind of tries) {
1388
+ if (dirAnchors === undefined && kind !== exports.RECORDED_ARTIFACT_KIND.DOOR_RECORD)
1389
+ dirAnchors = readDirAnchors(dir);
1390
+ const a = resolveRecordAnchors(text, kind, dir, { dirAnchors: dirAnchors ?? null, isAncestor: opts.isAncestor });
1391
+ if (a.end === undefined)
1392
+ continue;
1393
+ const rec = { rel, start: a.start, end: a.end, backfill: a.backfill, startFrom: a.startFrom };
1394
+ if (kind !== exports.RECORDED_ARTIFACT_KIND.DOOR_RECORD) {
1395
+ if (dirAttestations === undefined)
1396
+ dirAttestations = readDirAttestations(dir);
1397
+ const att = attestedFor(dirAttestations, name);
1398
+ if (att.length > 0)
1399
+ rec.attested = att;
1400
+ // 16-18 D-107: read once per directory, for a PHASE record only (a
1401
+ // quick task's record is never certified: it never bounds, D-104);
1402
+ // set only, never `false`, so an uncertified record reads as before.
1403
+ if (isPhaseRecordRel(rel)) {
1404
+ if (dirCertifiedEnds === undefined)
1405
+ dirCertifiedEnds = new Set(readDirCertificates(dir).map((c) => c.end));
1406
+ if (dirCertifiedEnds.has(a.end))
1407
+ rec.certified = true;
1408
+ }
1409
+ }
1410
+ records.push(rec);
1411
+ break;
1412
+ }
1413
+ }
1414
+ }
1415
+ }
1416
+ catch {
1417
+ incomplete = true;
1418
+ }
1419
+ return { records, incomplete, unread };
1420
+ }
1421
+ /**
1422
+ * Is this record a PHASE plan's (a file directly inside a `phases/<dir>/`
1423
+ * directory, at the root or in a workstream)? A quick task's lives in
1424
+ * `quick/<dir>/` and a door artifact may sit in `audits/`. Read off the
1425
+ * repo-relative path the walk produced (16 review iteration 3 WR-02, D-104).
1426
+ */
1427
+ function isPhaseRecordRel(rel) {
1428
+ const parts = String(rel).split('/');
1429
+ return parts.length >= 3 && parts[parts.length - 3] === 'phases';
1430
+ }
1431
+ /**
1432
+ * Is this record a SUMMARY (a phase plan's or a quick task's), as opposed to
1433
+ * a door artifact? Read off the file name alone; whether it is a PHASE record
1434
+ * is `isPhaseRecordRel`'s question. The one owner of summary-ness for SEAL-01's
1435
+ * lower bound (c2) and for `vault anchor certify` (16 gap closure 16-18, D-107).
1436
+ */
1437
+ function isSummaryRecordRel(rel) {
1438
+ return SUMMARY_FILENAME_RE.test(String(rel).split('/').pop() ?? '');
1439
+ }
1440
+ const DEFAULT_SEAL_CAPS = Object.freeze({
1441
+ uncovered: exports.UNCOVERED_COMMIT_CAP,
1442
+ coveringRecords: exports.COVERING_RECORD_CAP,
1443
+ pathsPerCommit: exports.UNCOVERED_PATHS_PER_COMMIT_CAP,
1444
+ candidates: exports.SEAL_CANDIDATE_CAP,
1445
+ examine: exports.SEAL_EXAMINE_CAP,
1446
+ });
1447
+ /** One line of untrusted text, controls collapsed and bounded; the router's sanitiser still runs on it. */
1448
+ function boundedLine(value, cap) {
1449
+ const flat = value.replace(/[\u0000-\u001F\u007F-\u009F]+/g, ' ').replace(/\s+/g, ' ').trim();
1450
+ return flat.length > cap ? `${flat.slice(0, cap)}…` : flat;
1451
+ }
1452
+ /**
1453
+ * Which commits after this phase's END anchors touch governed source and are
1454
+ * covered by no other record's range? (SEAL-01, D-15, D-16, D-31)
1455
+ *
1456
+ * In order, every step through the injected bounded `execGit` with `cwd: root`:
1457
+ * (a) every sha in `phaseEnds` and in `coveringRecords` must pass
1458
+ * `ANCHOR_SHA_RE`, or `anchor_invalid`, before any git call;
1459
+ * (b) every phase end must exist: `rev-list --no-walk --ignore-missing
1460
+ * --stdin`, the printed set compared with the input set, else
1461
+ * `anchor_unknown` naming the missing ones;
1462
+ * (c) every phase end must be an ancestor of HEAD: `merge-base --is-ancestor
1463
+ * <end> HEAD`, else `anchor_not_ancestor` naming them;
1464
+ * (c2) the lower bound (16 review CR-04): without `phaseRecords`, the
1465
+ * phase's ends. With them, each own record whose start is an ancestor of
1466
+ * its end (`merge-base --is-ancestor`) contributes its start..end range;
1467
+ * their common base (`merge-base --octopus` when they differ) is walked
1468
+ * back (`rev-list --max-count=<cap+1>`), and the ends of OTHER phase
1469
+ * plans' SUMMARY records found there that are CERTIFIED (`certified ===
1470
+ * true`: their directory's certificates file holds the end, written only
1471
+ * by `vault anchor certify` after a clean strict check of that phase —
1472
+ * 16 gap closure 16-18, D-107, closing D-106) are the lower bound, so the
1473
+ * commits between the latest certified phase record and this phase's
1474
+ * start are candidates too. An UNCERTIFIED phase record (its own strict
1475
+ * check blocked, never ran, was overridden, or ran before this engine)
1476
+ * covers its own range but never bounds, so the commit its gate named is
1477
+ * named again here; a quick task's record (its one strict gate records
1478
+ * nothing, D-104) and a door record (no gate, D-90) never bound. No
1479
+ * certified phase record in the window (D-109): the oldest point where a
1480
+ * windowed quick or uncertified phase SUMMARY record started (its end
1481
+ * without a usable start), via `merge-base --octopus` with the base;
1482
+ * with none, the base itself (nothing before it was governed). A full
1483
+ * window with an earlier SUMMARY record beyond it is `cap_exceeded`,
1484
+ * naming `vault anchor certify` as the repair. An own record with no
1485
+ * usable start keeps its end as a bound, as before;
1486
+ * (d) the candidates: `rev-list --parents --max-count=<cap+1> HEAD --stdin`
1487
+ * with one `^<bound>` line per bound on stdin; merge commits (two or more
1488
+ * parents) are reachable but never candidates (research A1). MEASURED
1489
+ * FORM (git 2.55.0): a `^<sha>` line is the revision syntax `rev-list
1490
+ * --stdin` has always read; a `--not` line on stdin also works on 2.55.0,
1491
+ * but pseudo-options on stdin are newer than `--stdin` itself, so the
1492
+ * portable spelling is used. More than the cap is `cap_exceeded`;
1493
+ * (e) coverage: only a record whose END is itself reachable here can cover a
1494
+ * candidate (an end reachable from a bound puts its whole range there
1495
+ * too, and an end that is not an ancestor of HEAD, or unknown, covers
1496
+ * nothing on this branch, so neither needs a git call); a merge commit
1497
+ * counts as reachable here (16 review WR-05). This phase's own ranges
1498
+ * cover too, and are not counted against the record cap. For each
1499
+ * such record: no start → skipped (`no_start`); `merge-base
1500
+ * --is-ancestor <start> <end>` exit 1 → skipped (`start_not_ancestor`),
1501
+ * 128 → skipped (`start_unknown`); otherwise `rev-list --first-parent
1502
+ * --max-count=<cap+1> --stdin` with `^<start>` and `<end>` on stdin, and
1503
+ * its commits are covered: the record's own line only, never what a merge
1504
+ * brought in from another branch (16 review WR-02). A skipped record covers nothing; it is never a failure. More
1505
+ * than `coveringRecords` such records is `cap_exceeded`;
1506
+ * (f) the uncovered candidates, in `rev-list` order, up to `examine`: the
1507
+ * paths from `diff-tree -r -z --root --no-commit-id --name-only
1508
+ * --no-renames <sha>` (`--no-renames`, so moving source into `docs/` still
1509
+ * lists the source path; `-z`, so a non-ASCII path is not C-quoted), the
1510
+ * commit kept when at least one path is not `isNonSourcePath`, its
1511
+ * subject from `log -1 --format=%s <sha>`. The scan stops after
1512
+ * `uncovered` source commits (`truncated`). A scan stopped by `examine`
1513
+ * with none found is `cap_exceeded`: the rest could not be checked.
1514
+ * Any non-zero exit, a timeout or ENOBUFS (`execGit` reports both as a failed
1515
+ * exit) is `git_failed`. Never throws.
1516
+ */
1517
+ function computeUncoveredSourceCommits(opts) {
1518
+ const caps = { ...DEFAULT_SEAL_CAPS, ...(opts.caps ?? {}) };
1519
+ const timeout = typeof opts.timeoutMs === 'number' ? opts.timeoutMs : exports.SEAL_GIT_TIMEOUT_MS;
1520
+ const git = (args, input) => {
1521
+ try {
1522
+ return opts.execGit(args, { cwd: opts.root, timeout, ...(input !== undefined ? { input } : {}) });
1523
+ }
1524
+ catch (e) {
1525
+ // The real execGit never throws; an injected one might, and a throw here
1526
+ // must still become a typed failure rather than escape the gate.
1527
+ return { exitCode: 1, stdout: '', stderr: String(e?.message ?? e), error: e };
1528
+ }
1529
+ };
1530
+ const hard = (r) => (r.error !== undefined && r.error !== null)
1531
+ || (r.signal !== undefined && r.signal !== null);
1532
+ const failed = (args, r) => ({
1533
+ ok: false,
1534
+ reason: 'git_failed',
1535
+ detail: `git ${args[0]} failed (exit ${r.exitCode})${r.stderr ? `: ${boundedLine(r.stderr, exports.SEAL_SUBJECT_CAP)}` : ''}`,
1536
+ });
1537
+ const lines = (out) => out.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== '');
1538
+ // (a) the grammar, before any git call.
1539
+ const ends = [...new Set(Array.isArray(opts.phaseEnds) ? opts.phaseEnds : [])];
1540
+ if (ends.length === 0)
1541
+ return { ok: false, reason: 'anchor_invalid', detail: 'no phase end anchor was given' };
1542
+ if (!ends.every(isAnchorSha)) {
1543
+ return { ok: false, reason: 'anchor_invalid', detail: 'a phase end anchor is not a full lowercase commit id' };
1544
+ }
1545
+ const covering = Array.isArray(opts.coveringRecords) ? opts.coveringRecords : [];
1546
+ for (const r of covering) {
1547
+ if (!isAnchorSha(r.end) || (r.start !== undefined && !isAnchorSha(r.start))) {
1548
+ return { ok: false, reason: 'anchor_invalid', detail: 'a covering record anchor is not a full lowercase commit id' };
1549
+ }
1550
+ }
1551
+ const own = Array.isArray(opts.phaseRecords) ? opts.phaseRecords : null;
1552
+ for (const r of own ?? []) {
1553
+ if (!isAnchorSha(r.end) || (r.start !== undefined && !isAnchorSha(r.start))) {
1554
+ return { ok: false, reason: 'anchor_invalid', detail: 'a phase record anchor is not a full lowercase commit id' };
1555
+ }
1556
+ }
1557
+ // (b) every phase end exists.
1558
+ const existArgs = ['rev-list', '--no-walk', '--ignore-missing', '--stdin'];
1559
+ const exist = git(existArgs, `${ends.join('\n')}\n`);
1560
+ if (exist.exitCode !== 0 || hard(exist))
1561
+ return failed(existArgs, exist);
1562
+ const present = new Set(lines(exist.stdout));
1563
+ const unknown = ends.filter((e) => !present.has(e));
1564
+ if (unknown.length > 0) {
1565
+ return { ok: false, reason: 'anchor_unknown', anchors: unknown, detail: `${unknown.length} end anchor(s) are not in this repository` };
1566
+ }
1567
+ // (c) every phase end is an ancestor of HEAD.
1568
+ const notAncestor = [];
1569
+ for (const end of ends) {
1570
+ const args = ['merge-base', '--is-ancestor', end, 'HEAD'];
1571
+ const r = git(args);
1572
+ if (r.exitCode === 0 && !hard(r))
1573
+ continue;
1574
+ if (r.exitCode === 1 && !hard(r)) {
1575
+ notAncestor.push(end);
1576
+ continue;
1577
+ }
1578
+ return failed(args, r);
1579
+ }
1580
+ if (notAncestor.length > 0) {
1581
+ return { ok: false, reason: 'anchor_not_ancestor', anchors: notAncestor, detail: `${notAncestor.length} end anchor(s) are not ancestors of HEAD` };
1582
+ }
1583
+ // (c2) the lower bound (16 review CR-04). Without the phase's own records
1584
+ // its ends bound the candidates, as D-15 first wrote it. With them, the
1585
+ // phase claims only its own start..end ranges: the candidates reach back to
1586
+ // the latest earlier record's end, so an ungoverned commit made after that
1587
+ // end and before this phase's start (E1 -> X -> P, start = X) is named here,
1588
+ // at this phase's strict point, not only by an earlier phase whose gate may
1589
+ // have run before X existed.
1590
+ const lower = own === null ? ends : [];
1591
+ const ownRanges = [];
1592
+ if (own !== null) {
1593
+ const tops = [];
1594
+ const bounds = new Set();
1595
+ for (const r of own) {
1596
+ let valid = false;
1597
+ if (r.start !== undefined) {
1598
+ const args = ['merge-base', '--is-ancestor', r.start, r.end];
1599
+ const res = git(args);
1600
+ if (hard(res))
1601
+ return failed(args, res);
1602
+ valid = res.exitCode === 0;
1603
+ }
1604
+ if (valid) {
1605
+ ownRanges.push(r);
1606
+ if (!tops.includes(r.start))
1607
+ tops.push(r.start);
1608
+ }
1609
+ else {
1610
+ // No usable start (D-59 already reports a missing one): its end keeps
1611
+ // bounding the candidates, exactly as before this rule.
1612
+ bounds.add(r.end);
1613
+ }
1614
+ }
1615
+ for (const e of ends)
1616
+ if (!own.some((r) => r.end === e))
1617
+ bounds.add(e);
1618
+ if (tops.length === 0) {
1619
+ for (const e of ends)
1620
+ bounds.add(e);
1621
+ }
1622
+ else {
1623
+ let base = tops[0];
1624
+ if (tops.length > 1) {
1625
+ const mbArgs = ['merge-base', '--octopus', ...tops];
1626
+ const mb = git(mbArgs);
1627
+ if (mb.exitCode !== 0 || hard(mb))
1628
+ return failed(mbArgs, mb);
1629
+ base = lines(mb.stdout)[0] ?? '';
1630
+ if (!isAnchorSha(base))
1631
+ return { ok: false, reason: 'git_failed', detail: 'git merge-base printed a line that is not a commit id' };
1632
+ }
1633
+ const winArgs = ['rev-list', `--max-count=${caps.candidates + 1}`, '--stdin'];
1634
+ const win = git(winArgs, `${base}\n`);
1635
+ if (win.exitCode !== 0 || hard(win))
1636
+ return failed(winArgs, win);
1637
+ const window = new Set(lines(win.stdout));
1638
+ // Only a CERTIFIED phase plan's SUMMARY bounds (16 gap closure 16-18,
1639
+ // D-107, closing the residual D-104 stated and D-106 ruled must be
1640
+ // fixed). A bound says "every commit up to here was judged by a strict
1641
+ // check that passed", and only a certificate records that: it is written
1642
+ // by `vault anchor certify` alone, after that verb has itself run the
1643
+ // phase's strict check and read `status: checked`, `block: false` and no
1644
+ // override (D-108), and it is keyed by the record's END, so a SUMMARY
1645
+ // written later is uncertified until the next clean check. An
1646
+ // uncertified phase record — its own strict check blocked (an abandoned
1647
+ // phase keeps its SUMMARY), never ran, was overridden, or ran before this
1648
+ // engine — covers its own range but never bounds, so the commit its gate
1649
+ // named is named again at this phase. A quick task's record never bounds
1650
+ // (its one strict gate writes no record of passing, D-104), nor does a
1651
+ // door record (it has no gate of its own, D-90); both still COVER. No
1652
+ // fixpoint is needed: a certificate is only ever written after a clean
1653
+ // strict check whose own bound was itself a certified end or the point
1654
+ // where governance began, so trusting it never trusts an unjudged range.
1655
+ const bounding = covering.filter((r) => isSummaryRecordRel(r.rel) && isPhaseRecordRel(r.rel) && r.certified === true);
1656
+ const earlier = [...new Set(bounding.map((r) => r.end).filter((e) => window.has(e)))];
1657
+ if (earlier.length > 0) {
1658
+ for (const e of earlier)
1659
+ bounds.add(e);
1660
+ }
1661
+ else {
1662
+ if (window.size > caps.candidates) {
1663
+ // The window did not reach the first commit: an earlier record may
1664
+ // end further back than the cap, in which case the commits between
1665
+ // it and this start cannot all be examined. Any SUMMARY counts here
1666
+ // (a quick or an uncertified one too): it says governance had begun
1667
+ // further back. Both details name the repair (D-109): certifying the
1668
+ // earlier phases, oldest first, gives this check a trusted bound,
1669
+ // and each certification is bounded by its own window.
1670
+ const repair = "; repair: certify the earlier phases oldest first (`pp-tools vault anchor certify <phase-dir>` once its strict check passes)";
1671
+ const beyond = covering.filter((r) => isSummaryRecordRel(r.rel) && !window.has(r.end));
1672
+ if (beyond.length > caps.coveringRecords) {
1673
+ return { ok: false, reason: 'cap_exceeded', detail: `${beyond.length} records would need an ancestry check against this phase's start, above the ${caps.coveringRecords}-record cap${repair}` };
1674
+ }
1675
+ for (const r of beyond) {
1676
+ const args = ['merge-base', '--is-ancestor', r.end, base];
1677
+ const res = git(args);
1678
+ if (hard(res))
1679
+ return failed(args, res);
1680
+ if (res.exitCode === 0) {
1681
+ return { ok: false, reason: 'cap_exceeded', detail: `more than ${caps.candidates} commits lie between this phase's start and the latest earlier record's end${repair}` };
1682
+ }
1683
+ }
1684
+ }
1685
+ // No certified phase record ends before this phase's start (D-109).
1686
+ // A quick record or an uncertified phase record that does no longer
1687
+ // bounds by its end, but it says governance had begun: the bound
1688
+ // reaches back to where the oldest such record started (its end, when
1689
+ // it has no usable start), so the commits between it and this start
1690
+ // are candidates, and each record's own commits are covered by its
1691
+ // range. With none, the start itself is where governance begins:
1692
+ // nothing before it was governed. Only a bounding record (a certified
1693
+ // phase SUMMARY) is skipped here, and none is in the window.
1694
+ const reach = [];
1695
+ for (const r of covering) {
1696
+ if (!isSummaryRecordRel(r.rel) || (isPhaseRecordRel(r.rel) && r.certified === true) || !window.has(r.end))
1697
+ continue;
1698
+ let point = r.end;
1699
+ if (r.start !== undefined) {
1700
+ const args = ['merge-base', '--is-ancestor', r.start, r.end];
1701
+ const res = git(args);
1702
+ if (hard(res))
1703
+ return failed(args, res);
1704
+ if (res.exitCode === 0)
1705
+ point = r.start;
1706
+ }
1707
+ if (!reach.includes(point))
1708
+ reach.push(point);
1709
+ }
1710
+ if (reach.length === 0) {
1711
+ bounds.add(base);
1712
+ }
1713
+ else {
1714
+ const mbArgs = ['merge-base', '--octopus', base, ...reach];
1715
+ const mb = git(mbArgs);
1716
+ if (mb.exitCode !== 0 || hard(mb))
1717
+ return failed(mbArgs, mb);
1718
+ const oldest = lines(mb.stdout)[0] ?? '';
1719
+ if (!isAnchorSha(oldest))
1720
+ return { ok: false, reason: 'git_failed', detail: 'git merge-base printed a line that is not a commit id' };
1721
+ bounds.add(oldest);
1722
+ }
1723
+ }
1724
+ }
1725
+ lower.push(...bounds);
1726
+ }
1727
+ // (d) the candidates: every commit reachable from HEAD and from none of the
1728
+ // lower bounds, read with its parents so the merge commits stay in the set
1729
+ // that decides which covering records are relevant (16 review WR-05) and
1730
+ // out of the set that is classified (research A1).
1731
+ const candArgs = ['rev-list', '--parents', `--max-count=${caps.candidates + 1}`, 'HEAD', '--stdin'];
1732
+ const cand = git(candArgs, `${lower.map((e) => `^${e}`).join('\n')}\n`);
1733
+ if (cand.exitCode !== 0 || hard(cand))
1734
+ return failed(candArgs, cand);
1735
+ const reachable = lines(cand.stdout).map((l) => l.split(/\s+/));
1736
+ if (!reachable.every((row) => row.every(isAnchorSha)))
1737
+ return { ok: false, reason: 'git_failed', detail: 'git rev-list printed a line that is not a commit id' };
1738
+ if (reachable.length > caps.candidates) {
1739
+ return { ok: false, reason: 'cap_exceeded', detail: `more than ${caps.candidates} commits are reachable from HEAD and from none of this phase's bounds` };
1740
+ }
1741
+ const reachableSet = new Set(reachable.map((row) => row[0]));
1742
+ const candidates = reachable.filter((row) => row.length <= 2).map((row) => row[0]);
1743
+ // (e) coverage, from the records whose end is itself reachable here: the
1744
+ // other records (counted against the cap, D-62) and this phase's own ranges.
1745
+ const relevantOthers = covering.filter((r) => reachableSet.has(r.end));
1746
+ if (relevantOthers.length > caps.coveringRecords) {
1747
+ return { ok: false, reason: 'cap_exceeded', detail: `${relevantOthers.length} records end after this phase, above the ${caps.coveringRecords}-record cap` };
1748
+ }
1749
+ const relevant = [...relevantOthers, ...ownRanges.filter((r) => reachableSet.has(r.end))];
1750
+ const covered = new Set();
1751
+ const skippedRecords = [];
1752
+ for (const r of relevant) {
1753
+ // 16 review iteration 3 CR-01 (D-102): an attested commit covers ITSELF
1754
+ // only, and only when it is on the record's own line between the start it
1755
+ // names and the record's end. Nothing between is covered by it, so an
1756
+ // ungoverned commit beside an attested one stays a candidate.
1757
+ const byFrom = new Map();
1758
+ for (const a of Array.isArray(r.attested) ? r.attested : []) {
1759
+ if (!isAnchorSha(a.sha) || !isAnchorSha(a.from))
1760
+ continue;
1761
+ if (!byFrom.has(a.from))
1762
+ byFrom.set(a.from, new Set());
1763
+ byFrom.get(a.from).add(a.sha);
1764
+ }
1765
+ for (const [from, shas] of byFrom) {
1766
+ const attArgs = ['rev-list', '--first-parent', `--max-count=${caps.candidates + 1}`, '--stdin'];
1767
+ const att = git(attArgs, `^${from}\n${r.end}\n`);
1768
+ if (att.exitCode !== 0 || hard(att))
1769
+ return failed(attArgs, att);
1770
+ const line = lines(att.stdout);
1771
+ if (line.length > caps.candidates) {
1772
+ return { ok: false, reason: 'cap_exceeded', detail: `an attested record's line holds more than ${caps.candidates} commits` };
1773
+ }
1774
+ for (const c of line)
1775
+ if (shas.has(c))
1776
+ covered.add(c);
1777
+ }
1778
+ if (r.start === undefined) {
1779
+ skippedRecords.push({ rel: r.rel, reason: 'no_start' });
1780
+ continue;
1781
+ }
1782
+ const ancArgs = ['merge-base', '--is-ancestor', r.start, r.end];
1783
+ const anc = git(ancArgs);
1784
+ if (hard(anc))
1785
+ return failed(ancArgs, anc);
1786
+ if (anc.exitCode === 1) {
1787
+ skippedRecords.push({ rel: r.rel, reason: 'start_not_ancestor' });
1788
+ continue;
1789
+ }
1790
+ if (anc.exitCode !== 0) {
1791
+ skippedRecords.push({ rel: r.rel, reason: 'start_unknown' });
1792
+ continue;
1793
+ }
1794
+ // 16 review WR-02: a record covers its OWN line only. `--first-parent`
1795
+ // walks from its end back to its start along the line the work was done
1796
+ // on, so commits a merge brought into the range from another branch (a
1797
+ // teammate's ungoverned source on `main`, merged mid-task) stay candidates
1798
+ // instead of being counted as governed by a record that never saw them.
1799
+ const rangeArgs = ['rev-list', '--first-parent', `--max-count=${caps.candidates + 1}`, '--stdin'];
1800
+ const range = git(rangeArgs, `^${r.start}\n${r.end}\n`);
1801
+ if (range.exitCode !== 0 || hard(range))
1802
+ return failed(rangeArgs, range);
1803
+ const inRange = lines(range.stdout);
1804
+ if (inRange.length > caps.candidates) {
1805
+ return { ok: false, reason: 'cap_exceeded', detail: `a covering record's range holds more than ${caps.candidates} commits` };
1806
+ }
1807
+ for (const c of inRange)
1808
+ covered.add(c);
1809
+ }
1810
+ // (f) classify the uncovered candidates.
1811
+ const uncoveredAll = candidates.filter((c) => !covered.has(c));
1812
+ const uncovered = [];
1813
+ let examined = 0;
1814
+ for (const sha of uncoveredAll) {
1815
+ if (uncovered.length >= caps.uncovered || examined >= caps.examine)
1816
+ break;
1817
+ examined += 1;
1818
+ const treeArgs = ['diff-tree', '-r', '-z', '--root', '--no-commit-id', '--name-only', '--no-renames', sha];
1819
+ const tree = git(treeArgs);
1820
+ if (tree.exitCode !== 0 || hard(tree))
1821
+ return failed(treeArgs, tree);
1822
+ const sourcePaths = tree.stdout.split('\0').filter((p) => p !== '' && !isNonSourcePath(p));
1823
+ if (sourcePaths.length === 0)
1824
+ continue;
1825
+ const subjArgs = ['log', '-1', '--format=%s', sha];
1826
+ const subj = git(subjArgs);
1827
+ if (subj.exitCode !== 0 || hard(subj))
1828
+ return failed(subjArgs, subj);
1829
+ uncovered.push({
1830
+ sha,
1831
+ shortSha: sha.slice(0, 12),
1832
+ subject: boundedLine(subj.stdout, exports.SEAL_SUBJECT_CAP),
1833
+ sourcePaths: sourcePaths.slice(0, caps.pathsPerCommit),
1834
+ pathsTruncated: sourcePaths.length > caps.pathsPerCommit,
1835
+ sourcePathCount: sourcePaths.length,
1836
+ });
1837
+ }
1838
+ const truncated = examined < uncoveredAll.length;
1839
+ if (truncated && uncovered.length === 0) {
1840
+ return { ok: false, reason: 'cap_exceeded', detail: `${uncoveredAll.length} commits are covered by no record and only ${examined} could be examined` };
1841
+ }
1842
+ return {
1843
+ ok: true,
1844
+ uncovered,
1845
+ truncated,
1846
+ candidateCount: candidates.length,
1847
+ uncoveredCount: uncoveredAll.length,
1848
+ examined,
1849
+ skippedRecords,
1850
+ };
1851
+ }