@pareto4/pp-core 1.13.0 → 1.17.1

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