@pareto4/pp-core 1.13.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/.claude-plugin/plugin.json +3 -3
  2. package/README.md +31 -12
  3. package/agents/pp-code-fixer.md +0 -11
  4. package/agents/pp-code-reviewer.md +1 -77
  5. package/agents/pp-debug-session-manager.md +9 -6
  6. package/agents/pp-executor.md +12 -29
  7. package/agents/pp-plan-checker.md +4 -4
  8. package/agents/pp-planner.md +34 -20
  9. package/agents/pp-vault-reviewer.md +100 -23
  10. package/agents/pp-verifier.md +1 -3
  11. package/bin/install.js +10 -3
  12. package/commands/pp-execute-phase.md +1 -3
  13. package/commands/pp-ns-manage.md +1 -1
  14. package/commands/pp-ns-review.md +1 -1
  15. package/commands/pp-plan-phase.md +1 -2
  16. package/commands/pp-vault-promote.md +7 -8
  17. package/commands/pp-vault-review.md +6 -5
  18. package/commands/pp-verify-work.md +1 -2
  19. package/gemini-extension.json +1 -1
  20. package/hooks/dist/pp-check-update.js +0 -0
  21. package/hooks/dist/pp-cursor-session-start.js +1 -1
  22. package/hooks/dist/pp-statusline.js +1 -1
  23. package/hooks/dist/pp-update-banner.js +0 -0
  24. package/hooks/pp-check-update.js +0 -0
  25. package/hooks/pp-cursor-session-start.js +1 -1
  26. package/hooks/pp-statusline.js +1 -1
  27. package/hooks/pp-update-banner.js +0 -0
  28. package/package.json +10 -18
  29. package/pareto-skills/_shared/create-project-board.sh +0 -0
  30. package/pareto-skills/pp-project-setup/SKILL.md +1 -1
  31. package/pp-core/bin/check-latest-version.cjs +0 -0
  32. package/pp-core/bin/detect-build-commands.sh +2 -2
  33. package/pp-core/bin/lib/capability-activation.cjs +83 -9
  34. package/pp-core/bin/lib/capability-registry.cjs +201 -229
  35. package/pp-core/bin/lib/capability-state.cjs +93 -1
  36. package/pp-core/bin/lib/capability-writer.cjs +3 -3
  37. package/pp-core/bin/lib/check-command-router.cjs +1906 -1514
  38. package/pp-core/bin/lib/clusters.cjs +3 -0
  39. package/pp-core/bin/lib/command-aliases.cjs +0 -35
  40. package/pp-core/bin/lib/commands.cjs +9 -4
  41. package/pp-core/bin/lib/compose-bindings.cjs +251 -88
  42. package/pp-core/bin/lib/config-loader.cjs +7 -103
  43. package/pp-core/bin/lib/config-schema.cjs +1 -1
  44. package/pp-core/bin/lib/config.cjs +151 -62
  45. package/pp-core/bin/lib/decisions.cjs +4 -4
  46. package/pp-core/bin/lib/discovery-vault-check.cjs +246 -0
  47. package/pp-core/bin/lib/frontmatter.cjs +1 -38
  48. package/pp-core/bin/lib/git-base-branch.cjs +11 -12
  49. package/pp-core/bin/lib/git-provider.cjs +9 -10
  50. package/pp-core/bin/lib/init-command-router.cjs +5 -8
  51. package/pp-core/bin/lib/init-contract.cjs +620 -738
  52. package/pp-core/bin/lib/init-interactive.cjs +62 -4
  53. package/pp-core/bin/lib/init.cjs +24 -51
  54. package/pp-core/bin/lib/install-profiles.cjs +1 -1
  55. package/pp-core/bin/lib/io.cjs +4 -2
  56. package/pp-core/bin/lib/loop-resolver.cjs +165 -129
  57. package/pp-core/bin/lib/org-profile.cjs +402 -161
  58. package/pp-core/bin/lib/package-identity.cjs +3 -3
  59. package/pp-core/bin/lib/phase-command-router.cjs +1 -29
  60. package/pp-core/bin/lib/phase-scope.cjs +1 -1
  61. package/pp-core/bin/lib/phase.cjs +11 -253
  62. package/pp-core/bin/lib/platform-bindings.cjs +40 -19
  63. package/pp-core/bin/lib/profile-output.cjs +1 -1
  64. package/pp-core/bin/lib/shell-command-projection.cjs +104 -1
  65. package/pp-core/bin/lib/state.cjs +11 -85
  66. package/pp-core/bin/lib/template.cjs +1 -1
  67. package/pp-core/bin/lib/vault-command-router.cjs +1682 -477
  68. package/pp-core/bin/lib/vault-coordinates.cjs +16 -7
  69. package/pp-core/bin/lib/vault-index.cjs +656 -0
  70. package/pp-core/bin/lib/vault-seal.cjs +1851 -0
  71. package/pp-core/bin/lib/vault-standards-promote.cjs +577 -0
  72. package/pp-core/bin/lib/vault-standards-review.cjs +293 -0
  73. package/pp-core/bin/lib/vault-standards.cjs +1504 -0
  74. package/pp-core/bin/lib/vault-trace.cjs +155 -2778
  75. package/pp-core/bin/lib/vault-update.cjs +10 -59
  76. package/pp-core/bin/lib/vault.cjs +2570 -950
  77. package/pp-core/bin/lib/verify.cjs +0 -11
  78. package/pp-core/bin/portable-timeout.sh +1 -1
  79. package/pp-core/bin/pp-tools.cjs +157 -54
  80. package/pp-core/bin/shared/config-defaults.manifest.json +0 -7
  81. package/pp-core/bin/shared/config-schema.manifest.json +0 -3
  82. package/pp-core/bin/shared/model-catalog.json +2 -2
  83. package/pp-core/bin/verify-reapply-patches.cjs +0 -0
  84. package/pp-core/org-profile.json +26 -0
  85. package/pp-core/references/autonomous-best-effort.md +8 -7
  86. package/pp-core/references/loop-hook-dispatch.md +0 -25
  87. package/pp-core/references/planner-gap-closure.md +0 -1
  88. package/pp-core/references/planner-tdd.md +0 -1
  89. package/pp-core/references/planner-vault-standards.md +311 -0
  90. package/pp-core/references/planning-config.md +0 -8
  91. package/pp-core/references/tdd.md +0 -1
  92. package/pp-core/templates/README.md +1 -0
  93. package/pp-core/templates/VAULT-REVIEW.md +56 -0
  94. package/pp-core/templates/phase-prompt.md +2 -6
  95. package/pp-core/workflows/_runtime-launcher.snippet.sh +0 -0
  96. package/pp-core/workflows/add-tests.md +55 -4
  97. package/pp-core/workflows/audit-fix.md +63 -2
  98. package/pp-core/workflows/audit-milestone.md +1 -1
  99. package/pp-core/workflows/autonomous.md +85 -11
  100. package/pp-core/workflows/check-todos.md +13 -2
  101. package/pp-core/workflows/code-review-fix.md +59 -3
  102. package/pp-core/workflows/code-review.md +10 -57
  103. package/pp-core/workflows/complete-milestone.md +1 -1
  104. package/pp-core/workflows/debug.md +54 -4
  105. package/pp-core/workflows/discovery-sprint/prefill.md +19 -7
  106. package/pp-core/workflows/discovery-sprint/prototype.md +46 -49
  107. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  108. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +1 -3
  109. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +1 -3
  110. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +1 -3
  111. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +20 -24
  112. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +1 -3
  113. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +1 -3
  114. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +1 -3
  115. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +1 -3
  116. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +1 -3
  117. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +1 -6
  118. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +1 -3
  119. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +1 -3
  120. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +1 -3
  121. package/pp-core/workflows/discovery-sprint/templates/context-template.md +1 -1
  122. package/pp-core/workflows/discovery-sprint/vault-context.md +207 -0
  123. package/pp-core/workflows/discovery-sprint.md +7 -3
  124. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +4 -4
  125. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +43 -1
  126. package/pp-core/workflows/discovery-to-build.md +10 -2
  127. package/pp-core/workflows/discuss-phase.md +3 -10
  128. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +327 -0
  129. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +3 -2
  130. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +1 -1
  131. package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +78 -0
  132. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +94 -0
  133. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +6 -8
  134. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +115 -14
  135. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +219 -20
  136. package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +147 -0
  137. package/pp-core/workflows/execute-phase.md +152 -128
  138. package/pp-core/workflows/execute-plan.md +54 -6
  139. package/pp-core/workflows/fast.md +53 -4
  140. package/pp-core/workflows/help/modes/full.md +3 -3
  141. package/pp-core/workflows/init/steps/client-context-capture.md +101 -0
  142. package/pp-core/workflows/init/steps/gitlab-provisioning.md +130 -0
  143. package/pp-core/workflows/init/steps/project-md-deferral-narration.md +66 -0
  144. package/pp-core/workflows/init/steps/reachability-outcome.md +70 -0
  145. package/pp-core/workflows/init.md +232 -272
  146. package/pp-core/workflows/new-project.md +31 -23
  147. package/pp-core/workflows/plan-phase.md +98 -45
  148. package/pp-core/workflows/quick.md +117 -3
  149. package/pp-core/workflows/secure-phase.md +1 -1
  150. package/pp-core/workflows/ship.md +57 -28
  151. package/pp-core/workflows/undo.md +1 -1
  152. package/pp-core/workflows/update.md +3 -3
  153. package/pp-core/workflows/validate-phase.md +58 -3
  154. package/pp-core/workflows/vault-review.md +161 -23
  155. package/pp-core/workflows/vault-write-back.md +73 -110
  156. package/pp-core/workflows/verify-work.md +12 -14
  157. package/scripts/changeset/cli.cjs +6 -5
  158. package/scripts/changeset/lint.cjs +0 -0
  159. package/scripts/changeset/new.cjs +33 -50
  160. package/scripts/dev-env.cjs +25 -85
  161. package/scripts/gen-capability-registry.cjs +120 -13
  162. package/scripts/lib/allowlist-ratchet.cjs +1 -103
  163. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
  164. package/scripts/lint-docs-required.cjs +0 -0
  165. package/scripts/lint-pr-check-project-dir.cjs +1 -3
  166. package/scripts/lint-test-file-count.allowlist.json +3 -19
  167. package/scripts/lint-test-file-count.cjs +5 -6
  168. package/scripts/prompt-injection-scan.sh +19 -17
  169. package/scripts/release-channel.cjs +0 -0
  170. package/scripts/sync-manifest-versions.cjs +5 -4
  171. package/scripts/sync-rulesets.sh +1 -1
  172. package/scripts/update-size-baseline.cjs +30 -274
  173. package/scripts/verify-opengsd-sweep.cjs +3 -5
  174. package/scripts/workflow-size.cjs +9 -61
  175. package/pp-core/bin/lib/dispatch-plan.cjs +0 -483
  176. package/pp-core/bin/lib/install-run.cjs +0 -536
  177. package/pp-core/bin/lib/loop-gates.cjs +0 -213
  178. package/pp-core/bin/lib/phase-manifest.cjs +0 -527
  179. package/pp-core/bin/lib/phase-tier.cjs +0 -480
  180. package/pp-core/bin/lib/plan-graph.cjs +0 -368
  181. package/pp-core/bin/lib/practices-review-check.cjs +0 -938
  182. package/pp-core/bin/lib/vault-audit.cjs +0 -2070
  183. package/pp-core/bin/lib/vault-practices.cjs +0 -1990
  184. package/pp-core/bin/lib/vault-review.cjs +0 -2359
  185. package/pp-core/bin/lib/vault-write-back.cjs +0 -877
  186. package/pp-core/references/executor-vault-trace.md +0 -47
  187. package/pp-core/references/planner-plan-graph.md +0 -66
  188. package/pp-core/references/planner-vault-practices.md +0 -112
  189. package/pp-core/templates/practices/README.md +0 -251
  190. package/pp-core/templates/practices/error-handling.md +0 -18
  191. package/pp-core/templates/practices/minimal-comments.md +0 -14
  192. package/pp-core/templates/practices/naming-convention.md +0 -18
  193. package/pp-core/templates/practices/practice-template.md +0 -23
  194. package/pp-core/workflows/discovery-sprint/vault-fetch.md +0 -37
  195. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +0 -45
  196. package/pp-core/workflows/execute-phase/steps/install-run.md +0 -29
  197. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +0 -23
  198. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +0 -73
  199. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +0 -19
  200. package/pp-core/workflows/plan-phase/steps/phase-tier.md +0 -43
  201. package/scripts/lib/size-budget.cjs +0 -311
  202. package/scripts/lint-neutrality-names.cjs +0 -694
  203. package/scripts/lint-org-coordinates.allowlist.json +0 -35
  204. package/scripts/lint-org-coordinates.cjs +0 -426
@@ -0,0 +1,1504 @@
1
+ "use strict";
2
+ /**
3
+ * src/vault-standards.cts — the `<vault_standards>` record (Vault Assurance
4
+ * v1.2, Phase 15-05; D-07, D-08, D-09, D-10, D-13, D-17..D-20, D-29).
5
+ *
6
+ * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
7
+ * compiles to the git-ignored `pp-core/bin/lib/vault-standards.cjs`; do NOT
8
+ * hand-edit the compiled output. Behaviour is locked by
9
+ * `tests/vault-standards.test.cjs`, which also pins the agent-facing contract
10
+ * `pp-core/references/planner-vault-standards.md` to this grammar in both
11
+ * directions (D-10).
12
+ *
13
+ * A plan records which INDEX-listed standards documents
14
+ * governed it, in ONE body block the CLI emits (`vault standards block`), and
15
+ * the plan's SUMMARY records which of them the executor read (`## Standards
16
+ * Consulted`, rendered by `vault standards consulted`). This module is the ONE
17
+ * owner of:
18
+ * - the block grammar: tags, the stamp line, the document line, the closed
19
+ * degraded set, the CLI-only override line (`STANDARDS_GRAMMAR` is the
20
+ * single text the reference quotes),
21
+ * - the reason-clause rules (D-09),
22
+ * - the `## Standards Consulted` section grammar (D-13),
23
+ * - the serializers the verbs print through and the whole-line parsers the
24
+ * gate reads through,
25
+ * - the pure judgement of a parsed record against a measured state,
26
+ * - the frozen `STANDARDS_PROBLEM_KIND` table and every standards sentence,
27
+ * - the plan-id form `SCAFFOLD_PLAN_ID_RE` (moved here from the vault
28
+ * router, which re-exports it), so the verbs and the gate share one form.
29
+ *
30
+ * It imports only `./vault-index.cjs`, which imports nothing, and (16-06) the
31
+ * seal leaf `./vault-seal.cjs` for the one anchor grammar, whose top level
32
+ * imports only `fs` and `path` (D-41). This is load-bearing: the vault router
33
+ * (15-07) and the check router (15-08) both require this leaf, and a leaf whose
34
+ * dependencies are themselves import-free at load time cannot close a require
35
+ * cycle among them (the seal leaf requires this one back LAZILY, inside its
36
+ * functions). The entry head split and the display
37
+ * sanitiser are reused from there, never re-spelled
38
+ * (Phase 14 D-06/D-17/D-32): the grammar for a `- \`path\` — …` line is one
39
+ * grammar across INDEX.md, discovery's vault context and this record.
40
+ *
41
+ * Nothing here reads config, the filesystem, the environment or the clock.
42
+ * The measured state (the loaded pin, the resolved INDEX paths, the index
43
+ * status, whether `vault.required` is on) is computed by the caller and passed
44
+ * in as a value.
45
+ *
46
+ * Message discipline (T-15-20, D-14): every text echoed from a record passes
47
+ * `sanitiseVaultText`, and every path passes `displayVaultPath`, so a record
48
+ * cannot smuggle terminal control bytes or bidi overrides into a gate message. The leaf never knows WHY a path is not a resolved INDEX
49
+ * entry (withheld, unsafe, unresolved and absent look the same to it), so no
50
+ * detail can confirm that a refused path names a credential. Every detail ends
51
+ * with a repair remedy and never names a destructive verb.
52
+ */
53
+ Object.defineProperty(exports, "__esModule", { value: true });
54
+ exports.STANDARDS_PROBLEM_KIND = exports.STANDARDS_GRAMMAR = exports.STANDARDS_DETAIL_CAP = exports.SCAFFOLD_PLAN_ID_RE = exports.STANDARDS_ANCHOR_PREFIX = exports.STANDARDS_SHA_RE = exports.STANDARDS_NONE = exports.STANDARDS_CONSULTED_NOT_READ = exports.STANDARDS_CONSULTED_READ = exports.STANDARDS_OVERRIDE_GATE = exports.STANDARDS_OVERRIDE_PREFIX = exports.STANDARDS_INDEX_STATUSES = exports.STANDARDS_COULD_NOT_CHECK_CODES = exports.STANDARDS_INDEX_MISSING_CODE = exports.STANDARDS_DEGRADED_KINDS = exports.STANDARDS_REASON_MARKER = exports.STANDARDS_REASON_MAX = exports.STANDARDS_CONSULTED_HEADING = exports.STANDARDS_STAMP_SEPARATOR = exports.STANDARDS_STAMP_PREFIX = exports.VAULT_STANDARDS_TAG_CLOSE = exports.VAULT_STANDARDS_TAG_OPEN = void 0;
55
+ exports.validateReasonClause = validateReasonClause;
56
+ exports.standardsIndexRepair = standardsIndexRepair;
57
+ exports.standardsVaultRepairLead = standardsVaultRepairLead;
58
+ exports.standardsProblemDetail = standardsProblemDetail;
59
+ exports.standardsEchoDegraded = standardsEchoDegraded;
60
+ exports.serializeStandardsBlock = serializeStandardsBlock;
61
+ exports.formatStandardsAnchorLine = formatStandardsAnchorLine;
62
+ exports.parseStandardsAnchorLine = parseStandardsAnchorLine;
63
+ exports.serializeConsultedSection = serializeConsultedSection;
64
+ exports.findLiveSection = findLiveSection;
65
+ exports.parseStandardsBlock = parseStandardsBlock;
66
+ exports.parseConsultedSection = parseConsultedSection;
67
+ exports.standardsLoadedPin = standardsLoadedPin;
68
+ exports.standardsMeasuredIndexStatus = standardsMeasuredIndexStatus;
69
+ exports.standardsResolvedPaths = standardsResolvedPaths;
70
+ exports.standardsDegradedLineFor = standardsDegradedLineFor;
71
+ exports.judgeStandardsPlan = judgeStandardsPlan;
72
+ exports.judgeConsultedSummary = judgeConsultedSummary;
73
+ exports.compareConsulted = compareConsulted;
74
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
75
+ const vaultIndexLeaf = require("./vault-index.cjs");
76
+ const { splitVaultEntryLine, displayVaultPath, sanitiseVaultText, DISPLAY_UNSAFE_TEXT_CHAR_RE, VAULT_ENTRY_PREFIX, VAULT_ENTRY_SEPARATOR, } = vaultIndexLeaf;
77
+ // 16-06 (D-35): the anchor grammar has ONE owner, the seal leaf. Static and
78
+ // cycle-free: its top level imports only Node built-ins (D-41).
79
+ const vault_seal_cjs_1 = require("./vault-seal.cjs");
80
+ // ─── Vocabulary (D-07, D-08) ─────────────────────────────────────────────────
81
+ /** The block's opening tag; counted only as a whole line at column 0. */
82
+ exports.VAULT_STANDARDS_TAG_OPEN = '<vault_standards>';
83
+ /** The block's closing tag; counted only as a whole line at column 0. */
84
+ exports.VAULT_STANDARDS_TAG_CLOSE = '</vault_standards>';
85
+ /** The first line inside the block, and inside the consulted section, starts with this. */
86
+ exports.STANDARDS_STAMP_PREFIX = 'Standards record: plan ';
87
+ /** Between the facts of a stamp or an override line: space, middle dot (U+00B7), space. */
88
+ exports.STANDARDS_STAMP_SEPARATOR = ' \u00B7 ';
89
+ /** The SUMMARY section heading: a level-2 ATX heading at column 0, written exactly. */
90
+ exports.STANDARDS_CONSULTED_HEADING = '## Standards Consulted';
91
+ /** The bound on a reason clause, in code points (D-09). */
92
+ exports.STANDARDS_REASON_MAX = 200;
93
+ /** The marker that opens a reason slot. */
94
+ exports.STANDARDS_REASON_MARKER = 'reason: ';
95
+ /**
96
+ * The closed degraded set (D-08). Exactly one of these replaces the document
97
+ * lines; never mixed with them. `consulted-none` is the planner's judgement
98
+ * and carries a reason clause; the other two are emitted by the CLI only, from
99
+ * the measured index state, and carry a reason CODE.
100
+ */
101
+ exports.STANDARDS_DEGRADED_KINDS = Object.freeze(['consulted-none', 'index-missing', 'could-not-check']);
102
+ /** The one code an `index-missing` line carries (equal to the vault-index leaf's MISSING reason). */
103
+ exports.STANDARDS_INDEX_MISSING_CODE = 'vault_index_missing';
104
+ /** The codes a `could-not-check` line may carry, closed. */
105
+ exports.STANDARDS_COULD_NOT_CHECK_CODES = Object.freeze([
106
+ 'vault_index_unparseable',
107
+ 'vault_index_empty',
108
+ 'vault_index_unresolvable',
109
+ 'vault_index_unchecked',
110
+ 'vault_not_loaded',
111
+ ]);
112
+ /**
113
+ * The INDEX.md statuses a stamp may carry: the five short forms of the
114
+ * vault-index leaf's reasons, `ok`, and `not-loaded` (no cached manifest at
115
+ * all). The leaf test pins the overlap with `vaultIndexReasonShort`.
116
+ */
117
+ exports.STANDARDS_INDEX_STATUSES = Object.freeze([
118
+ 'ok',
119
+ 'missing',
120
+ 'unparseable',
121
+ 'empty',
122
+ 'unresolvable',
123
+ 'unchecked',
124
+ 'not-loaded',
125
+ ]);
126
+ /**
127
+ * The code each non-ok status implies on the record's degraded line. `ok`
128
+ * implies document lines or `consulted-none`; `missing` implies
129
+ * `index-missing`; every other status implies `could-not-check` with exactly
130
+ * this code.
131
+ */
132
+ const STATUS_CODE = Object.freeze({
133
+ missing: exports.STANDARDS_INDEX_MISSING_CODE,
134
+ unparseable: 'vault_index_unparseable',
135
+ empty: 'vault_index_empty',
136
+ unresolvable: 'vault_index_unresolvable',
137
+ unchecked: 'vault_index_unchecked',
138
+ 'not-loaded': 'vault_not_loaded',
139
+ });
140
+ /** The override line (CLI-only, the last line before the close tag) starts with this. */
141
+ exports.STANDARDS_OVERRIDE_PREFIX = 'Override: gate ';
142
+ /**
143
+ * The one gate a standards override line may name: the blocking load gate's
144
+ * `check.query`. The block verb writes it (the vault router's
145
+ * `TRACE_OVERRIDE_GATE` is this constant), and the judgement accepts no other
146
+ * (D-35).
147
+ */
148
+ exports.STANDARDS_OVERRIDE_GATE = 'vault.status';
149
+ /**
150
+ * The timestamp form the block verb writes (`Date.prototype.toISOString`, UTC,
151
+ * milliseconds). An override line whose `at` slot is not this form was not
152
+ * written by the verb (D-35).
153
+ */
154
+ const OVERRIDE_WHEN_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
155
+ /**
156
+ * Is `s` exactly a timestamp `toISOString` would write? The form, then a
157
+ * round trip: the parsed date must print back as the same text, so a date V8
158
+ * rolls over (`2026-02-31` reads as `2026-03-03`) is refused rather than
159
+ * accepted as "a real date" (15-REVIEW iter-2 IN-01).
160
+ */
161
+ function isVerbTimestamp(s) {
162
+ if (typeof s !== 'string' || !OVERRIDE_WHEN_RE.test(s))
163
+ return false;
164
+ const d = new Date(s);
165
+ return !Number.isNaN(d.getTime()) && d.toISOString() === s;
166
+ }
167
+ /** The consulted status for a document the executor Read in full. */
168
+ exports.STANDARDS_CONSULTED_READ = 'read';
169
+ /** The consulted status for a document the executor did not read; a clause follows. */
170
+ exports.STANDARDS_CONSULTED_NOT_READ = 'not read: ';
171
+ /** The literal written in the repo, ref and sha slots when no manifest was loaded. */
172
+ exports.STANDARDS_NONE = 'none';
173
+ /** A full git object id, lowercase: the only sha form a record may carry. */
174
+ exports.STANDARDS_SHA_RE = /^[0-9a-f]{40}$/;
175
+ /**
176
+ * The optional anchor line of a `## Standards Consulted` section starts with
177
+ * this (16-06, D-14/D-31/D-35). It is in the section's own `- ` list grammar,
178
+ * and the CLI writes it as the section's last line: the commit the record's
179
+ * work started from (when the plan's directory recorded one with
180
+ * `vault anchor start`) and HEAD when the section was rendered. A record
181
+ * written before 16-06 has no such line and parses exactly as before.
182
+ */
183
+ exports.STANDARDS_ANCHOR_PREFIX = '- anchors: ';
184
+ /**
185
+ * The plan-identifier form a record must carry,
186
+ * one of two alternatives:
187
+ *
188
+ * - a PHASE plan id: one or more digits with an optional letter, optional
189
+ * `.N` decimal parts, and at least one `-N` part (`15-02`, `2.1-03`,
190
+ * `7a-10`);
191
+ * - a QUICK-TASK id (16-05, D-29; research C-5): six date digits, a hyphen
192
+ * and three lowercase base-36 characters (`261011-8yt`), exactly what
193
+ * `cmdInitQuick` builds (`yymmdd` + `-` + the two-second block of the day
194
+ * in base 36, padded to three). Before 16-05 a quick task could not record
195
+ * at all, and the refusal was intermittent: an all-digit encoding such as
196
+ * `261011-123` happened to match the phase form, a lettered one such as
197
+ * `261011-8yt` did not.
198
+ *
199
+ * It rejects a bare plan number, a task label, a slugged identifier, an
200
+ * uppercase or wrong-length quick id and the empty string, each of which would
201
+ * mismatch the plan id the gate reads from a canonical filename. The phase
202
+ * alternative is byte-identical to the pre-16-05 pattern, and the whole is one
203
+ * anchored group so `filenamePlanId` can strip the anchors and embed it.
204
+ *
205
+ * Moved verbatim from `src/vault-command-router.cts` (15-05), which re-exports
206
+ * it under the same name. The read side's filename extractor
207
+ * (`filenamePlanId` in `src/check-command-router.cts`) builds its pattern from
208
+ * this value (15-REVIEW WR-06), so the form is read and written by one owner.
209
+ */
210
+ exports.SCAFFOLD_PLAN_ID_RE = /^(?:\d+[A-Za-z]?(?:\.\d+)*(?:-\d+)+|\d{6}-[0-9a-z]{3})$/;
211
+ /** The bound on a problem detail, in code points; equal to the trace handler's detail cap. */
212
+ exports.STANDARDS_DETAIL_CAP = 400;
213
+ const SEP = exports.STANDARDS_STAMP_SEPARATOR;
214
+ const EM = VAULT_ENTRY_SEPARATOR; // the entry separator, owned by vault-index
215
+ const EM_TRIMMED = VAULT_ENTRY_SEPARATOR.trimEnd(); // ' —'
216
+ /**
217
+ * Every accepted line form, with `<placeholders>`. This is the single text the
218
+ * reference doc quotes; the doc pin test reads each value and finds it in the
219
+ * doc byte for byte.
220
+ */
221
+ exports.STANDARDS_GRAMMAR = Object.freeze({
222
+ stamp: `${exports.STANDARDS_STAMP_PREFIX}<plan-id>${SEP}vault <repo> @ <ref>${SEP}sha <40-hex>${SEP}INDEX.md <status>`,
223
+ stampNotLoaded: `${exports.STANDARDS_STAMP_PREFIX}<plan-id>${SEP}vault ${exports.STANDARDS_NONE} @ ${exports.STANDARDS_NONE}${SEP}sha ${exports.STANDARDS_NONE}${SEP}INDEX.md not-loaded`,
224
+ document: `${VAULT_ENTRY_PREFIX}<index-path>\`${EM}<ref> @ <40-hex>${EM}${exports.STANDARDS_REASON_MARKER}<one clause>`,
225
+ consultedNone: `- (consulted-none)${EM}${exports.STANDARDS_REASON_MARKER}<one clause>`,
226
+ indexMissing: `- (index-missing)${EM}${exports.STANDARDS_INDEX_MISSING_CODE}`,
227
+ couldNotCheck: '- (could-not-check)' + EM + '<code>',
228
+ override: `${exports.STANDARDS_OVERRIDE_PREFIX}<gate>${SEP}suppressed <suppressed-reason>${SEP}by <who>${SEP}at <iso-8601>${SEP}${exports.STANDARDS_REASON_MARKER}<operator text>`,
229
+ consultedStamp: `${exports.STANDARDS_STAMP_PREFIX}<plan-id>${SEP}vault <repo> @ <ref>${SEP}sha <40-hex>`,
230
+ consultedRead: `${VAULT_ENTRY_PREFIX}<index-path>\`${EM}${exports.STANDARDS_CONSULTED_READ}`,
231
+ consultedNotRead: `${VAULT_ENTRY_PREFIX}<index-path>\`${EM}${exports.STANDARDS_CONSULTED_NOT_READ}<one clause>`,
232
+ consultedUnfilled: `${VAULT_ENTRY_PREFIX}<index-path>\`${EM_TRIMMED}`,
233
+ // 16-06: the optional anchor line, in its three CLI-written forms.
234
+ consultedAnchors: `${exports.STANDARDS_ANCHOR_PREFIX}start <commit>${SEP}end <commit>`,
235
+ consultedAnchorsEndOnly: `${exports.STANDARDS_ANCHOR_PREFIX}end <commit>`,
236
+ consultedAnchorsBackfilled: `${exports.STANDARDS_ANCHOR_PREFIX}end <commit>${SEP}backfilled by <who> at <iso-8601>`,
237
+ });
238
+ /**
239
+ * The frozen standards problem kinds (D-17). Every value is `standards_` plus
240
+ * the lower-snake key, so none can collide with a trace handler kind (the leaf
241
+ * test asserts disjointness).
242
+ * The two pairing kinds are produced by the HANDLER (15-08); this leaf owns
243
+ * their texts only.
244
+ */
245
+ exports.STANDARDS_PROBLEM_KIND = Object.freeze({
246
+ BLOCK_ABSENT: 'standards_block_absent',
247
+ BLOCK_DUPLICATE: 'standards_block_duplicate',
248
+ BLOCK_UNTERMINATED: 'standards_block_unterminated',
249
+ BLOCK_MALFORMED: 'standards_block_malformed',
250
+ REASON_INVALID: 'standards_reason_invalid',
251
+ DOCUMENT_DUPLICATE: 'standards_document_duplicate',
252
+ DEGRADED_MIXED: 'standards_degraded_mixed',
253
+ NONE_WITHOUT_REASON: 'standards_none_without_reason',
254
+ PATH_NOT_RESOLVED: 'standards_path_not_resolved',
255
+ PIN_MISMATCH: 'standards_pin_mismatch',
256
+ CACHE_UNREADABLE: 'standards_cache_unreadable',
257
+ DEGRADED_UNTRUE: 'standards_degraded_untrue',
258
+ CONSULTED_ABSENT: 'standards_consulted_absent',
259
+ CONSULTED_MISMATCH: 'standards_consulted_mismatch',
260
+ PLAN_ID_MISMATCH: 'standards_plan_id_mismatch',
261
+ PAIR_MISSING: 'standards_pair_missing',
262
+ ORPHAN_SUMMARY: 'standards_orphan_summary',
263
+ // 16-06 (D-35): a line carrying the anchor prefix that is not one of the
264
+ // anchor line's forms, or a second anchor line. Appended LAST. A section with
265
+ // NO anchor line has no problem: write points are silent on a missing anchor
266
+ // (D-16); the strict points' `anchor_missing` is the trace gate's (16-07).
267
+ ANCHOR_MALFORMED: 'standards_anchor_malformed',
268
+ });
269
+ const NEWLINE_RE = /[\r\n\u0085\u2028\u2029]/;
270
+ const TILDE_FENCE_RE = /~{3,}/;
271
+ /**
272
+ * Judge one reason clause: non-empty after trim; at most
273
+ * `STANDARDS_REASON_MAX` code points (not UTF-16 units); no newline (CR, LF,
274
+ * NEL, U+2028, U+2029); no backtick at all; no code fence (a run of three
275
+ * tildes); no block tag; nothing
276
+ * the display sanitiser would rewrite (15-REVIEW WR-03: a control character
277
+ * such as ESC or BEL, a bidi override, a line or paragraph separator), judged
278
+ * by asking `sanitiseVaultText` itself, so the refused set has one owner. Runs
279
+ * of U+0020 and U+0020 at the ends are not judged here: the sanitiser only
280
+ * tidies them. Any other character the sanitiser rewrites is refused wherever
281
+ * it sits, the ends included (iter-2 WR-04). So is every character of the
282
+ * display-safety set vault-index owns (`DISPLAY_UNSAFE_TEXT_CHAR_RE`, iter-3
283
+ * IN-01), which adds the invisible format and default-ignorable code points
284
+ * the sanitiser leaves alone (zero-width space, word joiner, soft hyphen,
285
+ * tag characters): text a human reviewer cannot see must not reach a record
286
+ * an agent reads. `"` and `\` are not in that set and stay legal in prose.
287
+ * A document body pasted as a reason fails the bound or the newline rule.
288
+ * Never throws.
289
+ */
290
+ function validateReasonClause(text) {
291
+ if (typeof text !== 'string' || text.trim() === '')
292
+ return { ok: false, why: 'empty' };
293
+ if (NEWLINE_RE.test(text))
294
+ return { ok: false, why: 'newline' };
295
+ if (Array.from(text.trim()).length > exports.STANDARDS_REASON_MAX)
296
+ return { ok: false, why: 'too_long' };
297
+ if (text.includes('`'))
298
+ return { ok: false, why: 'backtick' };
299
+ if (TILDE_FENCE_RE.test(text)) {
300
+ return { ok: false, why: 'fence_marker' };
301
+ }
302
+ if (text.includes(exports.VAULT_STANDARDS_TAG_OPEN) || text.includes(exports.VAULT_STANDARDS_TAG_CLOSE)) {
303
+ return { ok: false, why: 'block_tag' };
304
+ }
305
+ // 15-REVIEW iter-2 WR-04: the comparison side tidies U+0020 ONLY. `trim()`
306
+ // strips every Unicode whitespace character (tab, U+000B, U+000C, U+FEFF,
307
+ // U+00A0 ...), so it agreed with the sanitiser about those characters at
308
+ // either end and let them through; the parser trims line ends only, so one
309
+ // right after `reason: ` survived into the record.
310
+ const spacesTidied = text.replace(/ +/g, ' ').replace(/^ | $/g, '');
311
+ if (sanitiseVaultText(text, exports.STANDARDS_REASON_MAX * 4) !== spacesTidied || DISPLAY_UNSAFE_TEXT_CHAR_RE.test(text)) {
312
+ return { ok: false, why: 'control' };
313
+ }
314
+ return { ok: true };
315
+ }
316
+ const WHY_TEXT = Object.freeze({
317
+ empty: 'empty',
318
+ too_long: `longer than ${exports.STANDARDS_REASON_MAX} characters`,
319
+ newline: 'more than one line',
320
+ backtick: 'carrying a backtick',
321
+ fence_marker: 'carrying a fence marker',
322
+ block_tag: 'carrying a block tag',
323
+ control: 'carrying a control, bidi, separator or invisible format character',
324
+ });
325
+ const ECHO_MAX = 80;
326
+ const PATH_MAX = 120;
327
+ const PLAN_ID_ECHO_MAX = 40;
328
+ function echo(s, max = ECHO_MAX) {
329
+ return sanitiseVaultText(typeof s === 'string' ? s : '', max);
330
+ }
331
+ function echoPath(p) {
332
+ return displayVaultPath(typeof p === 'string' ? p : '', PATH_MAX);
333
+ }
334
+ function echoPlanId(p) {
335
+ const t = echo(p, PLAN_ID_ECHO_MAX);
336
+ return t === '' ? '<plan-id>' : t;
337
+ }
338
+ function subjectOf(ctx) {
339
+ const a = echo(ctx.artifact, ECHO_MAX);
340
+ return a === '' ? 'The artifact' : a;
341
+ }
342
+ function lineSuffix(ctx) {
343
+ return typeof ctx.line === 'number' && Number.isFinite(ctx.line) ? ` (line ${Math.floor(ctx.line)})` : '';
344
+ }
345
+ function blockRemedy(planId) {
346
+ return `Re-run \`pp-tools vault standards block --plan ${echoPlanId(planId)} <index-path...>\`, embed its output verbatim and fill only the reason slots.`;
347
+ }
348
+ const CONSULTED_REMEDY = 'Re-run `pp-tools vault standards consulted <plan-path>`, embed its section verbatim in the SUMMARY, fill each status slot with `read` or `not read: <one clause>`, and commit the SUMMARY.';
349
+ /** The repair for a malformed anchor line (16-06): regenerate it, or back-fill an unanchored record, attributed. */
350
+ const ANCHOR_REMEDY = 'Restore the anchor line `pp-tools vault standards consulted <plan-path>` printed, or, for a record that never had one, run `pp-tools vault anchor backfill <summary> --at <sha>`.';
351
+ /**
352
+ * The cause and the repair for a measured index status on which no document
353
+ * can be listed (15-REVIEW iter-3 WR-01), or null on `ok`. ONE owner for the
354
+ * four surfaces that state it: the block verb's refusal, the consulted verb's
355
+ * refusal, and the trace gate's details on a document record
356
+ * (`standards_path_not_resolved`, `standards_pin_mismatch`,
357
+ * `standards_cache_unreadable`) and on a degraded record
358
+ * (`standards_degraded_untrue`, the D-29 advisory). Before it, the gate sent a
359
+ * document record on a degraded INDEX back to "choose from its entries" while
360
+ * the verb named the INDEX repair: following the gate's remedy replaced a
361
+ * governed plan's document record with a degraded one. The repair is a
362
+ * sentence stem with no final stop, so each surface appends its own re-run.
363
+ * An unknown status fails closed to the INDEX repair, never to null.
364
+ */
365
+ function standardsIndexRepair(status) {
366
+ return status === 'ok' ? null : indexRepairFor(status);
367
+ }
368
+ const LOAD_REPAIR = 'Run `pp-tools vault fetch` to load the vault';
369
+ const INDEX_REPAIR = 'Repair INDEX.md in the vault repo and run `pp-tools vault fetch`';
370
+ /** `standardsIndexRepair` for a status the caller already knows is not `ok`. */
371
+ function indexRepairFor(status) {
372
+ if (status === 'not-loaded')
373
+ return { cause: 'no vault is loaded', repair: LOAD_REPAIR };
374
+ return {
375
+ cause: isIndexStatus(status) && status !== 'ok' ? `the index is ${status}` : 'the index is in an unknown state',
376
+ repair: INDEX_REPAIR,
377
+ };
378
+ }
379
+ /**
380
+ * The lead the trace gate's blocking message puts before its "repair each
381
+ * file in place" footer when a listed standards problem names a vault repair
382
+ * from `standardsIndexRepair` (iter-3 WR-01), else ''. The footer points at
383
+ * the two record producers; while the vault is not loaded or its INDEX is
384
+ * degraded, regenerating a record can only write a degraded one, so the
385
+ * vault repair the line names comes first. Detected by the owner's own
386
+ * repair text, which `composeDetail` never truncates.
387
+ */
388
+ function standardsVaultRepairLead(problems) {
389
+ const kinds = Object.values(exports.STANDARDS_PROBLEM_KIND);
390
+ const repairs = [LOAD_REPAIR, INDEX_REPAIR];
391
+ let names = false;
392
+ for (const p of problems ?? []) {
393
+ const kind = p?.kind;
394
+ const detail = p?.detail;
395
+ if (typeof kind === 'string' && kinds.includes(kind) && typeof detail === 'string' && repairs.some((r) => detail.includes(r)))
396
+ names = true;
397
+ }
398
+ return names
399
+ ? 'A line above names a vault repair: make it first, because until the vault loads with a usable INDEX.md, regenerating a record can only write a degraded one. '
400
+ : '';
401
+ }
402
+ function lowerFirst(s) {
403
+ return `${s.charAt(0).toLowerCase()}${s.slice(1)}`;
404
+ }
405
+ /** The load repair (no manifest at gate time), the only remedy `standards_cache_unreadable` needs. */
406
+ const FETCH_REMEDY = `${LOAD_REPAIR}, then re-run this check.`;
407
+ /**
408
+ * Join a variable head and a constant remedy under the detail cap. The head is
409
+ * cut (with `…`) so the remedy is never truncated: the remedy is what makes a
410
+ * detail actionable.
411
+ */
412
+ function composeDetail(head, remedy) {
413
+ const remedyLen = Array.from(remedy).length;
414
+ const room = exports.STANDARDS_DETAIL_CAP - remedyLen - 1;
415
+ const points = Array.from(head);
416
+ const cut = points.length <= room ? head : `${points.slice(0, Math.max(0, room - 1)).join('')}\u2026`;
417
+ return `${cut} ${remedy}`;
418
+ }
419
+ /**
420
+ * The finished detail for one kind. Every echoed value passes the sanitiser
421
+ * (paths through `displayVaultPath`); every detail ends with a repair remedy
422
+ * and is at most `STANDARDS_DETAIL_CAP` code points. The texts never name a
423
+ * destructive verb (INVARIANT 4's class; the leaf test sweeps all 17).
424
+ */
425
+ function standardsProblemDetail(kind, ctx = {}) {
426
+ const c = ctx && typeof ctx === 'object' ? ctx : {};
427
+ const subject = subjectOf(c);
428
+ const where = c.where === 'section' ? `the ${exports.STANDARDS_CONSULTED_HEADING} section` : `the ${exports.VAULT_STANDARDS_TAG_OPEN} block`;
429
+ const sectionRemedy = c.where === 'section';
430
+ const path = echoPath(c.path);
431
+ switch (kind) {
432
+ case exports.STANDARDS_PROBLEM_KIND.BLOCK_ABSENT:
433
+ return composeDetail(`${subject} carries no ${exports.VAULT_STANDARDS_TAG_OPEN} block at column 0 outside code fences, comments and frontmatter; every plan carries one while the vault is on, including a plan that writes no code.`, blockRemedy(c.planId));
434
+ case exports.STANDARDS_PROBLEM_KIND.BLOCK_DUPLICATE:
435
+ return composeDetail(`${subject} carries ${typeof c.count === 'number' ? c.count : 'more than one'} ${exports.VAULT_STANDARDS_TAG_OPEN} blocks; the gate reads exactly one. Replace them with a single regenerated block.`, blockRemedy(c.planId));
436
+ case exports.STANDARDS_PROBLEM_KIND.BLOCK_UNTERMINATED:
437
+ return composeDetail(`${subject} opens ${exports.VAULT_STANDARDS_TAG_OPEN}${lineSuffix(c)} and never closes it with ${exports.VAULT_STANDARDS_TAG_CLOSE} at column 0.`, blockRemedy(c.planId));
438
+ case exports.STANDARDS_PROBLEM_KIND.BLOCK_MALFORMED:
439
+ return composeDetail(`${subject}: ${where} ${echo(c.part, 160) || 'does not match the standards grammar'}${lineSuffix(c)}.`, sectionRemedy ? CONSULTED_REMEDY : blockRemedy(c.planId));
440
+ case exports.STANDARDS_PROBLEM_KIND.REASON_INVALID: {
441
+ const which = c.where === 'section' ? `the not-read clause for ${path}` : c.path ? `the reason for ${path}` : 'the consulted-none reason';
442
+ return composeDetail(`${subject}: ${which} is ${WHY_TEXT[c.why ?? 'empty'] ?? 'invalid'}; a reason is one clause of at most ${exports.STANDARDS_REASON_MAX} characters with no newline, backtick, fence marker, block tag, control or invisible character.`, 'Rewrite it as one short clause.');
443
+ }
444
+ case exports.STANDARDS_PROBLEM_KIND.DOCUMENT_DUPLICATE:
445
+ return composeDetail(`${subject}: ${where} lists ${path} more than once; each INDEX path appears once.`, sectionRemedy ? CONSULTED_REMEDY : blockRemedy(c.planId));
446
+ case exports.STANDARDS_PROBLEM_KIND.DEGRADED_MIXED:
447
+ return composeDetail(`${subject}: the ${exports.VAULT_STANDARDS_TAG_OPEN} block carries a degraded line and document lines together; a record is one or the other.`, blockRemedy(c.planId));
448
+ case exports.STANDARDS_PROBLEM_KIND.NONE_WITHOUT_REASON:
449
+ return composeDetail(`${subject} records consulted-none with an empty reason slot; it must say why no indexed standards document applies to this plan.`, 'Fill the reason slot with one clause.');
450
+ case exports.STANDARDS_PROBLEM_KIND.PATH_NOT_RESOLVED: {
451
+ // iter-3 WR-01: on a degraded INDEX there are no entries to choose
452
+ // from, and the only record the block verb can then write is a degraded
453
+ // one, so the detail names the measured status and the INDEX repair.
454
+ const fix = c.indexStatus === undefined ? null : standardsIndexRepair(c.indexStatus);
455
+ if (fix !== null) {
456
+ return composeDetail(`${subject} lists standards documents, but ${fix.cause}${c.indexStatus === 'not-loaded' ? '' : ' at the loaded sha'}, so no listed document can be checked against it.`, `${fix.repair}, then re-run this check.`);
457
+ }
458
+ return composeDetail(`${subject} lists ${path}, which is not a resolved INDEX entry at the loaded sha.`, `Re-read the ## INDEX section of .planning/vault/SUMMARY.md, choose from its entries, and ${lowerFirst(blockRemedy(c.planId))}`);
459
+ }
460
+ case exports.STANDARDS_PROBLEM_KIND.PIN_MISMATCH: {
461
+ const what = c.path ? `${path} at` : 'its stamp at';
462
+ const against = sectionRemedy ? `the plan's ${exports.VAULT_STANDARDS_TAG_OPEN} block records` : 'the loaded vault is';
463
+ const fix = sectionRemedy || c.indexStatus === undefined ? null : standardsIndexRepair(c.indexStatus);
464
+ return composeDetail(`${subject} records ${what} ${echo(c.recorded) || '<none>'}, but ${against} ${echo(c.loaded) || '<none>'}; a standards record must name the pin the documents were read at.`, sectionRemedy
465
+ ? CONSULTED_REMEDY
466
+ : fix !== null
467
+ ? `${fix.repair}, then re-read the documents at the loaded pin. ${blockRemedy(c.planId)}`
468
+ : `Re-read the documents at the loaded pin. ${blockRemedy(c.planId)}`);
469
+ }
470
+ case exports.STANDARDS_PROBLEM_KIND.CACHE_UNREADABLE:
471
+ return composeDetail(`${subject} names standards documents, but the vault cache could not be read at gate time, so its pins and paths cannot be checked.`, FETCH_REMEDY);
472
+ case exports.STANDARDS_PROBLEM_KIND.DEGRADED_UNTRUE:
473
+ if (c.required === true && c.overrideUnverified === true) {
474
+ // D-35: an override line counts only when the load gate's own verdict,
475
+ // recomputed now, blocks for the reason the line records.
476
+ return composeDetail(`${subject} records ${echo(c.recorded) || 'a degraded line'} while vault.required is on, and its override line does not match the load gate's blocking verdict now, so it suppresses nothing.`, `Re-run \`pp-tools vault standards block --plan ${echoPlanId(c.planId)}\` with PP_VAULT_OVERRIDE_REASON set where it runs, and embed its output verbatim.`);
477
+ }
478
+ if (c.required === true) {
479
+ // iter-3 WR-01: the repair follows the measured status (no vault
480
+ // loaded needs a fetch, not an INDEX restore); an unknown or absent
481
+ // status keeps the INDEX repair, as before.
482
+ const fix = indexRepairFor(c.indexStatus ?? 'missing');
483
+ return composeDetail(`${subject} records ${echo(c.recorded) || 'a degraded line'} while vault.required is on: the load gate should have stopped this plan, and the block carries no override line.`, `${fix.repair}, then re-run \`pp-tools vault standards block --plan ${echoPlanId(c.planId)}\`; to pass the load gate on purpose, set PP_VAULT_OVERRIDE_REASON where the block verb runs.`);
484
+ }
485
+ return composeDetail(`${subject} records ${echo(c.recorded) || 'a degraded line'}, but the gate measures INDEX.md ${echo(c.measured) || 'in another state'}.`, blockRemedy(c.planId));
486
+ case exports.STANDARDS_PROBLEM_KIND.CONSULTED_ABSENT:
487
+ return composeDetail(`${subject} has no ${exports.STANDARDS_CONSULTED_HEADING} section at column 0 outside code fences and comments.`, CONSULTED_REMEDY);
488
+ case exports.STANDARDS_PROBLEM_KIND.CONSULTED_MISMATCH: {
489
+ let head;
490
+ switch (c.form) {
491
+ case 'unfilled':
492
+ head = `${subject}: the status slot for ${path} is empty; it must say \`read\` or \`not read: <one clause>\`.`;
493
+ break;
494
+ case 'missing':
495
+ head = `${subject}: ${path} is in the plan's ${exports.VAULT_STANDARDS_TAG_OPEN} block but not in its ${exports.STANDARDS_CONSULTED_HEADING} section.`;
496
+ break;
497
+ case 'extra':
498
+ head = `${subject}: ${path} is in the ${exports.STANDARDS_CONSULTED_HEADING} section but not in the plan's ${exports.VAULT_STANDARDS_TAG_OPEN} block.`;
499
+ break;
500
+ default:
501
+ head = `${subject}: the ${exports.STANDARDS_CONSULTED_HEADING} section and the plan's ${exports.VAULT_STANDARDS_TAG_OPEN} block disagree: a degraded record is echoed by its exact degraded line, and a document record by its documents.`;
502
+ }
503
+ return composeDetail(head, CONSULTED_REMEDY);
504
+ }
505
+ case exports.STANDARDS_PROBLEM_KIND.PLAN_ID_MISMATCH:
506
+ return composeDetail(`${subject} records plan ${echoPlanId(c.planId)}, but its filename names plan ${echoPlanId(c.filenamePlanId)}.`, sectionRemedy ? CONSULTED_REMEDY : blockRemedy(c.filenamePlanId ?? c.planId));
507
+ case exports.STANDARDS_PROBLEM_KIND.PAIR_MISSING:
508
+ return composeDetail(`${subject}: plan ${echoPlanId(c.planId)} has no SUMMARY carrying its ${exports.STANDARDS_CONSULTED_HEADING} section, which this gate point requires.`, `Execute the plan, then ${CONSULTED_REMEDY.charAt(0).toLowerCase()}${CONSULTED_REMEDY.slice(1)}`);
509
+ case exports.STANDARDS_PROBLEM_KIND.ORPHAN_SUMMARY:
510
+ return composeDetail(`${subject} carries ${exports.STANDARDS_CONSULTED_HEADING} for plan ${echoPlanId(c.planId)}, but no PLAN in this phase records that plan id.`, `Render the section from the PLAN this SUMMARY belongs to. ${CONSULTED_REMEDY}`);
511
+ case exports.STANDARDS_PROBLEM_KIND.ANCHOR_MALFORMED:
512
+ // 16-06: the anchor line is the CLI's; a hand-typed or edited one is
513
+ // dropped, never read as a commit, and this names the part, not the value.
514
+ return composeDetail(`${subject}: the ${exports.STANDARDS_CONSULTED_HEADING} section ${echo(c.part, 160) || 'carries an anchor line outside its grammar'}${lineSuffix(c)}; the anchor line is written by the CLI, never typed.`, ANCHOR_REMEDY);
515
+ default:
516
+ return composeDetail(`${subject}: the standards record does not match its grammar.`, blockRemedy(c.planId));
517
+ }
518
+ }
519
+ function problem(kind, ctx) {
520
+ const out = { kind, detail: standardsProblemDetail(kind, ctx) };
521
+ if (typeof ctx.path === 'string')
522
+ out.path = ctx.path;
523
+ if (typeof ctx.line === 'number')
524
+ out.line = ctx.line;
525
+ return out;
526
+ }
527
+ // ─── Serializers ─────────────────────────────────────────────────────────────
528
+ function slot(v) {
529
+ return typeof v === 'string' && v !== '' ? v : exports.STANDARDS_NONE;
530
+ }
531
+ function reasonSlot(reason) {
532
+ const r = typeof reason === 'string' ? reason : '';
533
+ return r === '' ? exports.STANDARDS_REASON_MARKER.trimEnd() : `${exports.STANDARDS_REASON_MARKER}${r}`;
534
+ }
535
+ /**
536
+ * A degraded line as it may be echoed (15-REVIEW WR-03, defence in depth for
537
+ * the `vault standards consulted` output and its `--raw` envelope): a
538
+ * consulted-none clause the reason rules accept is returned byte for byte, so
539
+ * the section still matches its block; any other clause is sanitised first.
540
+ * The CLI-only kinds carry a code from a closed set and are returned as they are.
541
+ */
542
+ function standardsEchoDegraded(d) {
543
+ if (d.kind !== 'consulted-none' || d.reason === '' || validateReasonClause(d.reason).ok)
544
+ return { kind: d.kind, reason: d.reason };
545
+ return { kind: d.kind, reason: sanitiseVaultText(d.reason, exports.STANDARDS_REASON_MAX + 1) };
546
+ }
547
+ function degradedLine(d) {
548
+ if (d.kind === 'consulted-none')
549
+ return `- (consulted-none)${EM}${reasonSlot(standardsEchoDegraded(d).reason)}`;
550
+ return `- (${d.kind})${EM}${d.reason}`;
551
+ }
552
+ function stampLine(r, withStatus) {
553
+ const base = `${exports.STANDARDS_STAMP_PREFIX}${r.planId}${SEP}vault ${slot(r.repo)} @ ${slot(r.ref)}${SEP}sha ${slot(r.sha)}`;
554
+ return withStatus ? `${base}${SEP}INDEX.md ${r.indexStatus}` : base;
555
+ }
556
+ function token(s) {
557
+ const t = sanitiseVaultText(typeof s === 'string' ? s : '', ECHO_MAX).replace(/\s+/g, '_');
558
+ return t === '' ? 'unknown' : t;
559
+ }
560
+ function overrideLine(o) {
561
+ const who = sanitiseVaultText(o.who, ECHO_MAX).split(SEP).join(' - ') || 'unknown';
562
+ const reason = sanitiseVaultText(o.reason, exports.STANDARDS_REASON_MAX + 1) || 'unspecified';
563
+ return `${exports.STANDARDS_OVERRIDE_PREFIX}${token(o.gate)}${SEP}suppressed ${token(o.suppressedReason)}${SEP}by ${who}${SEP}at ${token(o.when)}${SEP}${exports.STANDARDS_REASON_MARKER}${reason}`;
564
+ }
565
+ /**
566
+ * Check a record is one the parser accepts back; throws a plain Error naming
567
+ * the first violation. The verbs only ever serialize records they measured,
568
+ * so a throw here is an engine bug, never an operator input.
569
+ */
570
+ function assertSerializable(r) {
571
+ if (!r || typeof r !== 'object')
572
+ throw new Error('serializeStandardsBlock: record is not an object');
573
+ if (typeof r.planId !== 'string' || !exports.SCAFFOLD_PLAN_ID_RE.test(r.planId)) {
574
+ throw new Error('serializeStandardsBlock: planId does not match SCAFFOLD_PLAN_ID_RE');
575
+ }
576
+ if (!exports.STANDARDS_INDEX_STATUSES.includes(r.indexStatus)) {
577
+ throw new Error('serializeStandardsBlock: indexStatus is outside STANDARDS_INDEX_STATUSES');
578
+ }
579
+ const documents = Array.isArray(r.documents) ? r.documents : [];
580
+ if (r.degraded && documents.length > 0) {
581
+ throw new Error('serializeStandardsBlock: a degraded record carries no document lines');
582
+ }
583
+ if (r.degraded && !exports.STANDARDS_DEGRADED_KINDS.includes(r.degraded.kind)) {
584
+ throw new Error('serializeStandardsBlock: degraded kind is outside STANDARDS_DEGRADED_KINDS');
585
+ }
586
+ if (!r.degraded && documents.length === 0) {
587
+ throw new Error('serializeStandardsBlock: a record carries document lines or one degraded line');
588
+ }
589
+ for (const d of documents) {
590
+ if (!exports.STANDARDS_SHA_RE.test(String(d.sha)) || typeof d.ref !== 'string' || /\s/.test(d.ref) || d.ref === '') {
591
+ throw new Error('serializeStandardsBlock: a document line needs a ref and a 40-hex sha');
592
+ }
593
+ }
594
+ const stampProblem = checkRecordConsistency({ indexStatus: r.indexStatus, sha: slot(r.sha), documents, degraded: r.degraded });
595
+ if (stampProblem !== null)
596
+ throw new Error(`serializeStandardsBlock: ${stampProblem}`);
597
+ }
598
+ /**
599
+ * The block text the `vault standards block` verb prints: the open tag, the
600
+ * stamp, the document lines (empty reason slots by default) or the one
601
+ * degraded line, the override line when present, the close tag, and a
602
+ * trailing newline. An empty slot is written `reason:` with no trailing space,
603
+ * so an editor that strips trailing whitespace cannot change the record; the
604
+ * parser accepts `reason:` and `reason: <clause>`.
605
+ */
606
+ function serializeStandardsBlock(record) {
607
+ assertSerializable(record);
608
+ const lines = [exports.VAULT_STANDARDS_TAG_OPEN, stampLine(record, true)];
609
+ if (record.degraded) {
610
+ lines.push(degradedLine(record.degraded));
611
+ }
612
+ else {
613
+ for (const d of record.documents) {
614
+ lines.push(`${VAULT_ENTRY_PREFIX}${d.path}\`${EM}${d.ref} @ ${d.sha}${EM}${reasonSlot(d.reason)}`);
615
+ }
616
+ }
617
+ if (record.override)
618
+ lines.push(overrideLine(record.override));
619
+ lines.push(exports.VAULT_STANDARDS_TAG_CLOSE);
620
+ return `${lines.join('\n')}\n`;
621
+ }
622
+ /**
623
+ * The section's anchor line (16-06, D-35): `- anchors: [start <commit> · ]end
624
+ * <commit>[ · backfilled by <who> at <iso-8601>]`. Throws on a value its own
625
+ * parser would refuse, so the writer can never emit a line its reader rejects;
626
+ * the verbs pass only shas they read from git and a `by` the seal leaf
627
+ * normalised, so a throw is an engine bug, never an operator input.
628
+ */
629
+ function formatStandardsAnchorLine(anchors) {
630
+ const a = anchors && typeof anchors === 'object' ? anchors : {};
631
+ if (typeof a.end !== 'string' || !vault_seal_cjs_1.ANCHOR_SHA_RE.test(a.end))
632
+ throw new Error('formatStandardsAnchorLine: end is not a full lowercase commit id');
633
+ const hasStart = a.start !== undefined && a.start !== null;
634
+ if (hasStart && (typeof a.start !== 'string' || !vault_seal_cjs_1.ANCHOR_SHA_RE.test(a.start))) {
635
+ throw new Error('formatStandardsAnchorLine: start is not a full lowercase commit id');
636
+ }
637
+ const hasBackfill = a.backfill !== undefined && a.backfill !== null;
638
+ if (hasBackfill && !(0, vault_seal_cjs_1.isAnchorBackfill)(a.backfill))
639
+ throw new Error('formatStandardsAnchorLine: backfill is not { by, at }');
640
+ const facts = [...(hasStart ? [`start ${a.start}`] : []), `end ${a.end}`];
641
+ const tail = hasBackfill ? `${SEP}backfilled by ${a.backfill.by} at ${a.backfill.at}` : '';
642
+ return `${exports.STANDARDS_ANCHOR_PREFIX}${facts.join(SEP)}${tail}`;
643
+ }
644
+ /** `- anchors: [start H · ]end H[ · backfilled by <who> at <at>]`; H is the seal leaf's anchor shape. */
645
+ const ANCHOR_LINE_RE = /^- anchors: (?:start ([0-9a-f]{40}|[0-9a-f]{64}) \u00B7 )?end ([0-9a-f]{40}|[0-9a-f]{64})(?: \u00B7 backfilled by (.+) at (\S+))?$/;
646
+ /**
647
+ * Read one line as the anchor line, tri-state like `parseDegradedLine`: `null`
648
+ * when the line is not an anchor line at all (it does not open with the
649
+ * prefix), `{ ok: false, part }` when it opens with the prefix but is not one of
650
+ * the forms (a short or uppercase sha, a missing `end`, a malformed back-fill
651
+ * tail), `{ ok: true, value }` otherwise. The shas are checked against the seal
652
+ * leaf's `ANCHOR_SHA_RE` and the back-fill against `isAnchorBackfill`, so the
653
+ * grammar has one owner. Never throws.
654
+ */
655
+ function parseStandardsAnchorLine(line) {
656
+ if (typeof line !== 'string' || !line.startsWith(exports.STANDARDS_ANCHOR_PREFIX.trimEnd()))
657
+ return null;
658
+ const m = ANCHOR_LINE_RE.exec(line);
659
+ if (!m) {
660
+ return { ok: false, part: `carries an anchor line that is not \`- anchors: [start <commit>${SEP}]end <commit>\` with full lowercase commit ids` };
661
+ }
662
+ const value = { end: m[2] };
663
+ if (m[1] !== undefined)
664
+ value.start = m[1];
665
+ if (!vault_seal_cjs_1.ANCHOR_SHA_RE.test(value.end) || (value.start !== undefined && !vault_seal_cjs_1.ANCHOR_SHA_RE.test(value.start))) {
666
+ return { ok: false, part: 'carries an anchor line whose commit is not a full lowercase commit id' };
667
+ }
668
+ if (m[3] !== undefined) {
669
+ const backfill = { by: m[3], at: m[4] };
670
+ if (!(0, vault_seal_cjs_1.isAnchorBackfill)(backfill)) {
671
+ return { ok: false, part: 'carries an anchor line whose back-fill is not `backfilled by <who> at <iso-8601>`' };
672
+ }
673
+ value.backfill = backfill;
674
+ }
675
+ return { ok: true, value };
676
+ }
677
+ /**
678
+ * The `## Standards Consulted` section the `vault standards consulted` verb
679
+ * prints: the heading, a blank line, the stamp (no INDEX.md status), then one
680
+ * entry per block document with an EMPTY status slot — a default of `read`
681
+ * would let a pasted section claim reads that never happened. A degraded
682
+ * record echoes its degraded line instead. `opts.entries` fills the slots (the
683
+ * tests and the reference examples use it); the CLI never does.
684
+ *
685
+ * 16-06: `opts.anchors` appends the anchor line LAST. Without it the output is
686
+ * byte-identical to the pre-16-06 serializer.
687
+ */
688
+ function serializeConsultedSection(record, opts = {}) {
689
+ const lines = [exports.STANDARDS_CONSULTED_HEADING, '', stampLine(record, false)];
690
+ if (record.degraded) {
691
+ lines.push(degradedLine(record.degraded));
692
+ }
693
+ else {
694
+ const filled = new Map();
695
+ for (const e of opts.entries ?? [])
696
+ filled.set(e.path, e);
697
+ for (const d of record.documents) {
698
+ const e = filled.get(d.path);
699
+ const head = `${VAULT_ENTRY_PREFIX}${d.path}\``;
700
+ if (!e || e.status === 'unfilled')
701
+ lines.push(`${head}${EM_TRIMMED}`);
702
+ else if (e.status === 'read')
703
+ lines.push(`${head}${EM}${exports.STANDARDS_CONSULTED_READ}`);
704
+ else
705
+ lines.push(`${head}${EM}${exports.STANDARDS_CONSULTED_NOT_READ}${e.reason ?? ''}`.trimEnd());
706
+ }
707
+ }
708
+ if (opts.anchors)
709
+ lines.push(formatStandardsAnchorLine(opts.anchors));
710
+ return `${lines.join('\n')}\n`;
711
+ }
712
+ // ─── Whole-line scanning ─────────────────────────────────────────────────────
713
+ // The fence-open shape of the vault-index leaf (CommonMark: ≤3 spaces of
714
+ // indent, ≥3 backticks or ≥3 tildes; a backtick fence's info string may not
715
+ // carry a backtick). A closing fence uses the same character, at least as long.
716
+ const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
717
+ // Any ATX heading at column 0 ends the consulted section.
718
+ const ATX_HEADING_RE = /^#{1,6}(?:[ \t]|$)/;
719
+ // So does a thematic break (CommonMark: up to three spaces of indent, then
720
+ // three or more of one of - * _, optionally spaced). The shipped SUMMARY
721
+ // template closes every SUMMARY with `---` and an italic footer, so a section
722
+ // placed last must end there, not run into the footer (15-REVIEW WR-05).
723
+ const THEMATIC_BREAK_RE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
724
+ function splitLines(text) {
725
+ let src = typeof text === 'string' ? text : '';
726
+ if (src.charCodeAt(0) === 0xfeff)
727
+ src = src.slice(1);
728
+ return src.replace(/\r\n?/g, '\n').split('\n').map((l) => l.trimEnd());
729
+ }
730
+ /**
731
+ * The index of the first body line: past a YAML frontmatter that opens on
732
+ * line 1 with `---` and closes with a later `---` line. An unclosed
733
+ * frontmatter is not a frontmatter.
734
+ */
735
+ function bodyStart(lines) {
736
+ if (lines[0] !== '---')
737
+ return 0;
738
+ const close = lines.indexOf('---', 1);
739
+ return close > 0 ? close + 1 : 0;
740
+ }
741
+ /**
742
+ * A scanner over the "live" lines of a markdown body: outside the frontmatter,
743
+ * fenced code and HTML comments. `stop` lets a caller consume a region itself
744
+ * (a block's interior) by returning the index to resume after.
745
+ */
746
+ function liveLineIndexes(lines, from, stop) {
747
+ const out = [];
748
+ let inFence = null;
749
+ let inComment = false;
750
+ for (let i = from; i < lines.length; i += 1) {
751
+ const line = lines[i];
752
+ if (inFence) {
753
+ const close = FENCE_OPEN_RE.exec(line);
754
+ if (close && close[1][0] === inFence.char && close[1].length >= inFence.len && close[2].trim() === '')
755
+ inFence = null;
756
+ continue;
757
+ }
758
+ if (inComment) {
759
+ if (line.includes('-->'))
760
+ inComment = false;
761
+ continue;
762
+ }
763
+ const fence = FENCE_OPEN_RE.exec(line);
764
+ if (fence && !(fence[1][0] === '`' && fence[2].includes('`'))) {
765
+ inFence = { char: fence[1][0], len: fence[1].length };
766
+ continue;
767
+ }
768
+ const lead = line.trimStart();
769
+ if (lead.startsWith('<!--')) {
770
+ if (!lead.slice(4).includes('-->'))
771
+ inComment = true;
772
+ continue;
773
+ }
774
+ out.push(i);
775
+ if (stop) {
776
+ const next = stop(i);
777
+ if (next !== null)
778
+ i = next;
779
+ }
780
+ }
781
+ return out;
782
+ }
783
+ /**
784
+ * Find a section by its exact heading line (16-03). The heading counts only as
785
+ * a whole column-0 line outside the frontmatter, fenced code and HTML comments;
786
+ * the section runs to the next ATX heading, the next thematic break or the end
787
+ * of the text. This is the rule `parseConsultedSection` applies, and it is
788
+ * exported so `src/vault-seal.cts` (the door-record parser) and the vault
789
+ * router ask this scanner instead of keeping a second one.
790
+ * Never throws.
791
+ */
792
+ function findLiveSection(text, heading) {
793
+ const lines = splitLines(text);
794
+ const live = liveLineIndexes(lines, bodyStart(lines));
795
+ const headings = live.filter((i) => lines[i] === heading);
796
+ const body = [];
797
+ if (headings.length > 0) {
798
+ for (let i = headings[0] + 1; i < lines.length; i += 1) {
799
+ if (ATX_HEADING_RE.test(lines[i]) || THEMATIC_BREAK_RE.test(lines[i]))
800
+ break;
801
+ if (lines[i].trim() !== '')
802
+ body.push(i);
803
+ }
804
+ }
805
+ return { lines, headings, body };
806
+ }
807
+ // ─── Line parsers ────────────────────────────────────────────────────────────
808
+ const DEGRADED_RE = /^- \((consulted-none|index-missing|could-not-check)\) \u2014 (.*)$/;
809
+ const DEGRADED_PREFIX_RE = /^- \(/;
810
+ const DOCUMENT_TAIL_RE = /^(\S+) @ ([0-9a-f]{40}) \u2014 reason:(?: (.*))?$/;
811
+ const OVERRIDE_RE = /^Override: gate (\S+) \u00B7 suppressed (\S+) \u00B7 by (.+?) \u00B7 at (\S+) \u00B7 reason: (.+)$/;
812
+ function parseDegradedLine(line) {
813
+ if (!DEGRADED_PREFIX_RE.test(line))
814
+ return null;
815
+ const m = DEGRADED_RE.exec(line);
816
+ if (!m)
817
+ return { ok: false, part: 'carries a degraded line outside the closed set (consulted-none, index-missing, could-not-check)' };
818
+ const kind = m[1];
819
+ const rest = m[2];
820
+ if (kind === 'consulted-none') {
821
+ if (rest !== 'reason:' && !rest.startsWith(exports.STANDARDS_REASON_MARKER)) {
822
+ return { ok: false, part: 'carries a consulted-none line without its `reason:` slot' };
823
+ }
824
+ return { ok: true, value: { kind, reason: rest === 'reason:' ? '' : rest.slice(exports.STANDARDS_REASON_MARKER.length) } };
825
+ }
826
+ if (kind === 'index-missing') {
827
+ if (rest !== exports.STANDARDS_INDEX_MISSING_CODE)
828
+ return { ok: false, part: `carries an index-missing line whose code is not ${exports.STANDARDS_INDEX_MISSING_CODE}` };
829
+ return { ok: true, value: { kind, reason: rest } };
830
+ }
831
+ if (!exports.STANDARDS_COULD_NOT_CHECK_CODES.includes(rest)) {
832
+ return { ok: false, part: 'carries a could-not-check line whose code is outside the closed set' };
833
+ }
834
+ return { ok: true, value: { kind, reason: rest } };
835
+ }
836
+ function parseStampLine(line, withStatus) {
837
+ if (!line.startsWith(exports.STANDARDS_STAMP_PREFIX))
838
+ return { ok: false, part: 'does not open with its stamp line' };
839
+ const facts = line.slice(exports.STANDARDS_STAMP_PREFIX.length).split(SEP);
840
+ const want = withStatus ? 4 : 3;
841
+ if (facts.length !== want) {
842
+ return {
843
+ ok: false,
844
+ part: withStatus
845
+ ? `has a stamp with ${facts.length} of its 4 facts (plan, vault, sha, INDEX.md status)`
846
+ : `has a stamp with ${facts.length} of its 3 facts (plan, vault, sha) and no INDEX.md status`,
847
+ };
848
+ }
849
+ const [planId, vaultFact, shaFact, statusFact] = facts;
850
+ if (!exports.SCAFFOLD_PLAN_ID_RE.test(planId))
851
+ return { ok: false, part: 'has a stamp whose plan id is not a plan identifier such as 15-02' };
852
+ const v = /^vault (\S+) @ (\S+)$/.exec(vaultFact);
853
+ if (!v)
854
+ return { ok: false, part: 'has a stamp whose vault fact is not `vault <repo> @ <ref>`' };
855
+ const s = /^sha (\S+)$/.exec(shaFact);
856
+ if (!s || !(exports.STANDARDS_SHA_RE.test(s[1]) || s[1] === exports.STANDARDS_NONE)) {
857
+ return { ok: false, part: 'has a stamp whose sha is not 40 lowercase hex characters' };
858
+ }
859
+ let indexStatus = null;
860
+ if (withStatus) {
861
+ const st = /^INDEX\.md (\S+)$/.exec(statusFact);
862
+ if (!st || !exports.STANDARDS_INDEX_STATUSES.includes(st[1])) {
863
+ return { ok: false, part: `has a stamp whose INDEX.md status is not one of ${exports.STANDARDS_INDEX_STATUSES.join(', ')}` };
864
+ }
865
+ indexStatus = st[1];
866
+ }
867
+ return { ok: true, value: { planId, repo: v[1], ref: v[2], sha: s[1], indexStatus } };
868
+ }
869
+ /**
870
+ * The record's internal agreement: the stamp's INDEX.md status implies which
871
+ * line the record carries, and `none` in the sha slot is written exactly when
872
+ * INDEX.md is `not-loaded`, in both directions (15-REVIEW WR-02: the verb
873
+ * never writes another shape, so `sha none` on a missing or unchecked index
874
+ * is an edit, and accepting it would let that edit skip the pin comparison).
875
+ * Returns the violated part, or null.
876
+ */
877
+ function checkRecordConsistency(r) {
878
+ const hasDocs = r.documents.length > 0;
879
+ if (r.indexStatus === 'ok') {
880
+ if (r.degraded && r.degraded.kind !== 'consulted-none')
881
+ return `has a stamp saying INDEX.md ok but carries the ${r.degraded.kind} line`;
882
+ }
883
+ else {
884
+ const code = STATUS_CODE[r.indexStatus];
885
+ const kind = r.indexStatus === 'missing' ? 'index-missing' : 'could-not-check';
886
+ if (hasDocs || !r.degraded || r.degraded.kind !== kind || r.degraded.reason !== code) {
887
+ return `has a stamp saying INDEX.md ${r.indexStatus}, which requires the line \`- (${kind}) \u2014 ${code}\``;
888
+ }
889
+ }
890
+ if (r.sha === exports.STANDARDS_NONE && r.indexStatus !== 'not-loaded') {
891
+ return 'has a stamp whose sha is not 40 lowercase hex characters (none is written only when INDEX.md is not-loaded)';
892
+ }
893
+ if (r.indexStatus === 'not-loaded' && r.sha !== exports.STANDARDS_NONE) {
894
+ return 'has a stamp saying INDEX.md not-loaded with a sha (a record with no loaded manifest writes sha none)';
895
+ }
896
+ return null;
897
+ }
898
+ /** The unfilled consulted slot: a head that ends at the separator. */
899
+ function splitUnfilledEntry(line) {
900
+ if (!line.endsWith(EM_TRIMMED))
901
+ return null;
902
+ // Reuse the one head grammar: complete the separator with a probe tail.
903
+ const probe = splitVaultEntryLine(`${line} _`);
904
+ if (probe === null || 'malformed' in probe || probe.rest !== '_')
905
+ return null;
906
+ return probe.path;
907
+ }
908
+ // ─── parseStandardsBlock ─────────────────────────────────────────────────────
909
+ function parseBlockInterior(lines, open, close, opts) {
910
+ const problems = [];
911
+ const base = { artifact: opts.artifact, where: 'block' };
912
+ const malformed = (part, idx, planId) => problems.push(problem(exports.STANDARDS_PROBLEM_KIND.BLOCK_MALFORMED, { ...base, part, line: idx + 1, planId }));
913
+ const body = [];
914
+ for (let i = open + 1; i < close; i += 1)
915
+ if (lines[i].trim() !== '')
916
+ body.push(i);
917
+ if (body.length === 0 || !lines[body[0]].startsWith(exports.STANDARDS_STAMP_PREFIX)) {
918
+ const at = body.length === 0 ? open : body[0];
919
+ malformed('does not open with its stamp line', at);
920
+ return { record: null, problems };
921
+ }
922
+ const stamp = parseStampLine(lines[body[0]], true);
923
+ if (!stamp.ok) {
924
+ malformed(stamp.part, body[0]);
925
+ return { record: null, problems };
926
+ }
927
+ const planId = stamp.value.planId;
928
+ const documents = [];
929
+ const degradedLines = [];
930
+ let override = null;
931
+ let overrideAt = -1;
932
+ const seen = new Set();
933
+ let structural = false;
934
+ for (let k = 1; k < body.length; k += 1) {
935
+ const idx = body[k];
936
+ const line = lines[idx];
937
+ if (overrideAt !== -1) {
938
+ malformed('carries a line after its override line; the override line is last', idx, planId);
939
+ structural = true;
940
+ break;
941
+ }
942
+ if (line.startsWith(exports.STANDARDS_OVERRIDE_PREFIX)) {
943
+ const m = OVERRIDE_RE.exec(line);
944
+ if (!m) {
945
+ malformed('carries an override line that does not match the override grammar', idx, planId);
946
+ structural = true;
947
+ break;
948
+ }
949
+ override = { gate: m[1], suppressedReason: m[2], who: m[3], when: m[4], reason: m[5] };
950
+ overrideAt = idx;
951
+ continue;
952
+ }
953
+ if (line.startsWith(exports.STANDARDS_STAMP_PREFIX)) {
954
+ malformed('carries a second stamp line', idx, planId);
955
+ structural = true;
956
+ break;
957
+ }
958
+ const deg = parseDegradedLine(line);
959
+ if (deg !== null) {
960
+ if (!deg.ok) {
961
+ malformed(deg.part, idx, planId);
962
+ structural = true;
963
+ break;
964
+ }
965
+ degradedLines.push(deg.value);
966
+ continue;
967
+ }
968
+ const head = splitVaultEntryLine(line);
969
+ if (head === null) {
970
+ malformed('carries a line that is neither a document line, a degraded line nor the override line', idx, planId);
971
+ structural = true;
972
+ break;
973
+ }
974
+ if ('malformed' in head) {
975
+ malformed('carries a document line whose head is not - `<index-path>` followed by the separator', idx, planId);
976
+ structural = true;
977
+ break;
978
+ }
979
+ const tail = DOCUMENT_TAIL_RE.exec(head.rest);
980
+ if (!tail) {
981
+ malformed('carries a document line whose tail is not `<ref> @ <40-hex> \u2014 reason:`', idx, planId);
982
+ structural = true;
983
+ break;
984
+ }
985
+ if (seen.has(head.path)) {
986
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.DOCUMENT_DUPLICATE, { ...base, path: head.path, line: idx + 1, planId }));
987
+ }
988
+ seen.add(head.path);
989
+ documents.push({ path: head.path, ref: tail[1], sha: tail[2], reason: tail[3] ?? '', line: idx + 1 });
990
+ }
991
+ if (structural)
992
+ return { record: null, problems };
993
+ if (degradedLines.length > 0 && documents.length > 0) {
994
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.DEGRADED_MIXED, { ...base, planId }));
995
+ return { record: null, problems };
996
+ }
997
+ if (degradedLines.length > 1) {
998
+ malformed('carries more than one degraded line; a degraded record has exactly one', open, planId);
999
+ return { record: null, problems };
1000
+ }
1001
+ if (degradedLines.length === 0 && documents.length === 0) {
1002
+ malformed('carries neither a document line nor a degraded line', open, planId);
1003
+ return { record: null, problems };
1004
+ }
1005
+ const record = {
1006
+ planId,
1007
+ repo: stamp.value.repo,
1008
+ ref: stamp.value.ref,
1009
+ sha: stamp.value.sha,
1010
+ indexStatus: stamp.value.indexStatus,
1011
+ documents,
1012
+ degraded: degradedLines[0] ?? null,
1013
+ override,
1014
+ };
1015
+ const inconsistent = checkRecordConsistency(record);
1016
+ if (inconsistent !== null) {
1017
+ malformed(inconsistent, body[0], planId);
1018
+ return { record: null, problems };
1019
+ }
1020
+ return { record, problems };
1021
+ }
1022
+ /**
1023
+ * Read the `<vault_standards>` block out of a PLAN.md text. Never throws; a
1024
+ * non-string is read as `''`.
1025
+ *
1026
+ * Tags count only as whole lines at column 0 (no indent: more restrictive than
1027
+ * CommonMark's three spaces, and where the CLI puts them), outside the YAML
1028
+ * frontmatter, fenced code and HTML comments — a tag quoted in prose or in an
1029
+ * example is not a block (Pitfall 5). The interior runs to the first column-0
1030
+ * close tag. Two blocks report `standards_block_duplicate` and the first is
1031
+ * still parsed; an open without a close reports `standards_block_unterminated`;
1032
+ * none reports `standards_block_absent`.
1033
+ *
1034
+ * Emptiness of a reason slot is NOT a parse problem: the verb emits empty
1035
+ * slots, and the judgement decides whether an empty slot is acceptable.
1036
+ */
1037
+ function parseStandardsBlock(text, opts = {}) {
1038
+ const o = opts && typeof opts === 'object' ? opts : {};
1039
+ const lines = splitLines(text);
1040
+ const opens = [];
1041
+ liveLineIndexes(lines, bodyStart(lines), (i) => {
1042
+ if (lines[i] !== exports.VAULT_STANDARDS_TAG_OPEN)
1043
+ return null;
1044
+ let j = i + 1;
1045
+ while (j < lines.length && lines[j] !== exports.VAULT_STANDARDS_TAG_CLOSE)
1046
+ j += 1;
1047
+ if (j >= lines.length) {
1048
+ opens.push({ open: i, close: null });
1049
+ return lines.length;
1050
+ }
1051
+ opens.push({ open: i, close: j });
1052
+ return j;
1053
+ });
1054
+ if (opens.length === 0) {
1055
+ return {
1056
+ present: false,
1057
+ record: null,
1058
+ problems: [problem(exports.STANDARDS_PROBLEM_KIND.BLOCK_ABSENT, { artifact: o.artifact })],
1059
+ openLine: null,
1060
+ closeLine: null,
1061
+ };
1062
+ }
1063
+ const first = opens[0];
1064
+ if (first.close === null) {
1065
+ return {
1066
+ present: true,
1067
+ record: null,
1068
+ problems: [problem(exports.STANDARDS_PROBLEM_KIND.BLOCK_UNTERMINATED, { artifact: o.artifact, line: first.open + 1 })],
1069
+ openLine: first.open + 1,
1070
+ closeLine: null,
1071
+ };
1072
+ }
1073
+ const interior = parseBlockInterior(lines, first.open, first.close, o);
1074
+ const problems = [...interior.problems];
1075
+ if (opens.length > 1) {
1076
+ problems.unshift(problem(exports.STANDARDS_PROBLEM_KIND.BLOCK_DUPLICATE, {
1077
+ artifact: o.artifact, count: opens.length, planId: interior.record?.planId,
1078
+ }));
1079
+ }
1080
+ return { present: true, record: interior.record, problems, openLine: first.open + 1, closeLine: first.close + 1 };
1081
+ }
1082
+ // ─── parseConsultedSection ───────────────────────────────────────────────────
1083
+ function emptySection(problems, present, headingLine) {
1084
+ return { present, planId: null, repo: null, ref: null, sha: null, entries: [], degraded: null, problems, headingLine };
1085
+ }
1086
+ /**
1087
+ * Read the `## Standards Consulted` section out of a SUMMARY.md text. Never
1088
+ * throws. The heading counts only as a whole column-0 line, written exactly,
1089
+ * outside the frontmatter, fenced code and HTML comments. The section runs to
1090
+ * the next ATX heading, the next thematic break (the SUMMARY template's `---`
1091
+ * footer rule) or the end of the text, and holds only the CLI's lines:
1092
+ * the stamp, then one entry per block document (or the one degraded line),
1093
+ * blank lines aside. Any other line is malformed; the reference says so.
1094
+ */
1095
+ function parseConsultedSection(text, opts = {}) {
1096
+ const o = opts && typeof opts === 'object' ? opts : {};
1097
+ const base = { artifact: o.artifact, where: 'section' };
1098
+ const { lines, headings, body } = findLiveSection(text, exports.STANDARDS_CONSULTED_HEADING);
1099
+ if (headings.length === 0) {
1100
+ return emptySection([problem(exports.STANDARDS_PROBLEM_KIND.CONSULTED_ABSENT, { artifact: o.artifact })], false, null);
1101
+ }
1102
+ const h = headings[0];
1103
+ const malformed = (part, idx) => problem(exports.STANDARDS_PROBLEM_KIND.BLOCK_MALFORMED, { ...base, part, line: idx + 1 });
1104
+ if (headings.length > 1) {
1105
+ return emptySection([malformed('appears more than once', headings[1])], true, h + 1);
1106
+ }
1107
+ if (body.length === 0)
1108
+ return emptySection([malformed('has no stamp line', h)], true, h + 1);
1109
+ const stamp = parseStampLine(lines[body[0]], false);
1110
+ if (!stamp.ok)
1111
+ return emptySection([malformed(stamp.part, body[0])], true, h + 1);
1112
+ const problems = [];
1113
+ const entries = [];
1114
+ const degradedLines = [];
1115
+ const seen = new Set();
1116
+ // 16-06: at most ONE anchor line, anywhere after the stamp. A malformed or a
1117
+ // second one is a problem of its own kind and is DROPPED (never read as a
1118
+ // commit), while the rest of the record still parses, so the section's
1119
+ // other judgements still run.
1120
+ let anchors;
1121
+ let anchorLines = 0;
1122
+ for (let k = 1; k < body.length; k += 1) {
1123
+ const idx = body[k];
1124
+ const line = lines[idx];
1125
+ const anchor = parseStandardsAnchorLine(line);
1126
+ if (anchor !== null) {
1127
+ anchorLines += 1;
1128
+ if (anchorLines > 1) {
1129
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.ANCHOR_MALFORMED, {
1130
+ ...base, part: 'carries more than one anchor line; the section has at most one', line: idx + 1,
1131
+ }));
1132
+ anchors = undefined;
1133
+ }
1134
+ else if (!anchor.ok) {
1135
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.ANCHOR_MALFORMED, { ...base, part: anchor.part, line: idx + 1 }));
1136
+ }
1137
+ else {
1138
+ anchors = anchor.value;
1139
+ }
1140
+ continue;
1141
+ }
1142
+ const deg = parseDegradedLine(line);
1143
+ if (deg !== null) {
1144
+ if (!deg.ok)
1145
+ return emptySection([malformed(deg.part, idx)], true, h + 1);
1146
+ degradedLines.push(deg.value);
1147
+ continue;
1148
+ }
1149
+ let entry = null;
1150
+ const unfilled = splitUnfilledEntry(line);
1151
+ if (unfilled !== null) {
1152
+ entry = { path: unfilled, status: 'unfilled', line: idx + 1 };
1153
+ }
1154
+ else {
1155
+ const head = splitVaultEntryLine(line);
1156
+ if (head !== null && !('malformed' in head)) {
1157
+ if (head.rest === exports.STANDARDS_CONSULTED_READ) {
1158
+ entry = { path: head.path, status: 'read', line: idx + 1 };
1159
+ }
1160
+ else if (head.rest.startsWith(exports.STANDARDS_CONSULTED_NOT_READ) || head.rest === exports.STANDARDS_CONSULTED_NOT_READ.trimEnd()) {
1161
+ entry = { path: head.path, status: 'not-read', reason: head.rest.slice(exports.STANDARDS_CONSULTED_NOT_READ.length), line: idx + 1 };
1162
+ }
1163
+ }
1164
+ }
1165
+ if (entry === null) {
1166
+ return emptySection([malformed('carries a line that is not the stamp, a degraded line, or an entry ending `read`, `not read: <one clause>` or the empty slot', idx)], true, h + 1);
1167
+ }
1168
+ if (seen.has(entry.path)) {
1169
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.DOCUMENT_DUPLICATE, { ...base, path: entry.path, line: idx + 1 }));
1170
+ }
1171
+ seen.add(entry.path);
1172
+ entries.push(entry);
1173
+ }
1174
+ if (degradedLines.length > 0 && entries.length > 0) {
1175
+ return emptySection([malformed('carries a degraded line and entries together', h)], true, h + 1);
1176
+ }
1177
+ if (degradedLines.length > 1) {
1178
+ return emptySection([malformed('carries more than one degraded line', h)], true, h + 1);
1179
+ }
1180
+ if (degradedLines.length === 0 && entries.length === 0) {
1181
+ return emptySection([malformed('carries neither an entry nor a degraded line', h)], true, h + 1);
1182
+ }
1183
+ const parsed = {
1184
+ present: true,
1185
+ planId: stamp.value.planId,
1186
+ repo: stamp.value.repo,
1187
+ ref: stamp.value.ref,
1188
+ sha: stamp.value.sha,
1189
+ entries,
1190
+ degraded: degradedLines[0] ?? null,
1191
+ problems,
1192
+ headingLine: h + 1,
1193
+ };
1194
+ // Reconstructed only when present and well-formed, and only from a single
1195
+ // anchor line: a section with two keeps neither.
1196
+ if (anchors !== undefined && anchorLines === 1)
1197
+ parsed.anchors = anchors;
1198
+ return parsed;
1199
+ }
1200
+ function isIndexStatus(s) {
1201
+ return typeof s === 'string' && exports.STANDARDS_INDEX_STATUSES.includes(s);
1202
+ }
1203
+ /**
1204
+ * Whether a degraded line states what the gate measured (D-20): consulted-none
1205
+ * needs a healthy index; index-missing needs `missing`; could-not-check needs
1206
+ * a non-ok status whose code is exactly the recorded one.
1207
+ */
1208
+ function degradedAgrees(d, status) {
1209
+ if (!isIndexStatus(status))
1210
+ return false;
1211
+ if (d.kind === 'consulted-none')
1212
+ return status === 'ok';
1213
+ if (status === 'ok')
1214
+ return false;
1215
+ if (d.kind === 'index-missing')
1216
+ return status === 'missing';
1217
+ return STATUS_CODE[status] === d.reason;
1218
+ }
1219
+ /**
1220
+ * Whether an override line agrees with the load gate NOW (D-35): it names the
1221
+ * load gate, carries the verb's timestamp form, and the load gate's own
1222
+ * verdict, recomputed at gate time, blocks for exactly the reason the line
1223
+ * says it suppressed. A line that fails this suppresses nothing.
1224
+ *
1225
+ * What this does NOT prove (15-REVIEW iter-2 WR-03): who wrote the line. It is
1226
+ * text in a file anyone can edit and carries no signature, and every field
1227
+ * checked here can be read off `check vault.status`, so a line typed by hand
1228
+ * whose fields match is accepted exactly as the verb's own would be. The
1229
+ * guarantee is agreement with the measured state, not CLI provenance; the bound
1230
+ * on a matching line is the execute:pre load gate (which blocks unless
1231
+ * `PP_VAULT_OVERRIDE_REASON` is set where it runs) and the strict points, which
1232
+ * block on any recorded override. `who` and the reason text are not used.
1233
+ */
1234
+ function overrideAgrees(o, verdict) {
1235
+ if (!verdict || verdict.block !== true || typeof verdict.reason !== 'string' || verdict.reason === '')
1236
+ return false;
1237
+ if (o.gate !== exports.STANDARDS_OVERRIDE_GATE)
1238
+ return false;
1239
+ if (!isVerbTimestamp(o.when))
1240
+ return false;
1241
+ return o.suppressedReason === verdict.reason;
1242
+ }
1243
+ function degradedLabel(d) {
1244
+ return d.kind === 'consulted-none' ? 'consulted-none' : `${d.kind} (${d.reason})`;
1245
+ }
1246
+ /**
1247
+ * The ONE pin comparison, for the stamp and for every document line (D-18):
1248
+ * a recorded pin matches only when BOTH its ref and its full sha equal the
1249
+ * loaded manifest's.
1250
+ */
1251
+ function pinDiffers(ref, sha, manifest) {
1252
+ return ref !== manifest.ref || sha !== manifest.sha;
1253
+ }
1254
+ function pinText(ref, sha) {
1255
+ return `${typeof ref === 'string' ? ref : exports.STANDARDS_NONE} @ ${typeof sha === 'string' ? sha : exports.STANDARDS_NONE}`;
1256
+ }
1257
+ /** The D-29 first-arm message: the measured state and the remedy; never a claim that a document was consulted. */
1258
+ function degradedAdvisory(record, d, status, artifact) {
1259
+ return composeDetail(`${echo(artifact) || 'The artifact'} records ${echo(degradedLabel(d))} and the gate measures INDEX.md ${status} too, so no standards document governed this plan; vault.required is off, so this does not block.`,
1260
+ // iter-3 WR-01: the repair for the measured status, from the one owner.
1261
+ `To govern it, ${lowerFirst(indexRepairFor(status).repair)}, then re-run \`pp-tools vault standards block --plan ${echoPlanId(record.planId)} <index-path...>\` and embed its output.`);
1262
+ }
1263
+ /**
1264
+ * The pin a manifest yields, or null when no vault is loaded (15-07). A
1265
+ * manifest with no 40-hex `resolvedSha`, or no single-token ref, is "not
1266
+ * loaded": a miss record (`source: miss`, every coordinate null) and a damaged
1267
+ * file both land here. ONE predicate for the `vault standards block` verb,
1268
+ * which writes the record, and the trace gate, which measures it, so the two
1269
+ * cannot disagree about whether a vault was loaded. Pure: the caller reads the
1270
+ * manifest.
1271
+ */
1272
+ function standardsLoadedPin(manifest) {
1273
+ if (!manifest || typeof manifest !== 'object')
1274
+ return null;
1275
+ const m = manifest;
1276
+ if (typeof m.resolvedSha !== 'string' || !exports.STANDARDS_SHA_RE.test(m.resolvedSha))
1277
+ return null;
1278
+ if (typeof m.ref !== 'string' || !/^\S+$/.test(m.ref))
1279
+ return null;
1280
+ return { repo: typeof m.repo === 'string' ? m.repo : null, ref: m.ref, sha: m.resolvedSha };
1281
+ }
1282
+ /**
1283
+ * The stamp status for a measured state (15-07): `not-loaded` when there is no
1284
+ * loaded pin or no index state, whatever the index function answered (on a
1285
+ * miss record it answers a missing index, and the record must not say so);
1286
+ * `ok`; else the short form of the degraded reason. An unknown reason fails
1287
+ * closed to `unchecked`. Shared by the verb and the gate, like the pin above.
1288
+ */
1289
+ function standardsMeasuredIndexStatus(pin, state) {
1290
+ if (pin === null || !state || typeof state !== 'object')
1291
+ return 'not-loaded';
1292
+ if (state.status === 'ok')
1293
+ return 'ok';
1294
+ for (const [status, code] of Object.entries(STATUS_CODE)) {
1295
+ if (status !== 'not-loaded' && code === state.reason)
1296
+ return status;
1297
+ }
1298
+ return 'unchecked';
1299
+ }
1300
+ /**
1301
+ * The INDEX paths a standards record may name (D-19): exactly the entries that
1302
+ * RESOLVED at the loaded sha, never a file merely present in the cache. ONE
1303
+ * set for the three surfaces that ask it, so they cannot disagree: the block
1304
+ * verb (what may be selected), the consulted verb (what an executor is told to
1305
+ * read, 15-REVIEW WR-04) and the trace gate (what a record may list). Pure:
1306
+ * the caller computes the index state, and passes null when no vault is loaded.
1307
+ */
1308
+ function standardsResolvedPaths(state) {
1309
+ const entries = state && Array.isArray(state.entries) ? state.entries : [];
1310
+ return new Set(entries.filter((e) => e.resolution === 'resolved').map((e) => e.path));
1311
+ }
1312
+ /**
1313
+ * The degraded line a measured status implies when the planner selects no
1314
+ * document: `consulted-none` (empty reason slot) on `ok`, else the CLI-only
1315
+ * line carrying that status's code. The same table `checkRecordConsistency`
1316
+ * enforces, so the verb cannot write a record the parser refuses.
1317
+ */
1318
+ function standardsDegradedLineFor(indexStatus) {
1319
+ if (indexStatus === 'ok')
1320
+ return { kind: 'consulted-none', reason: '' };
1321
+ return { kind: indexStatus === 'missing' ? 'index-missing' : 'could-not-check', reason: STATUS_CODE[indexStatus] };
1322
+ }
1323
+ /**
1324
+ * Judge one parsed PLAN record against the measured state. Pure: no reads, no
1325
+ * config, no clock. Order: the parse problems (a null record returns only
1326
+ * them); the plan id; the reason clauses; then, when the record needs the
1327
+ * cache (document lines or consulted-none) and no manifest was read,
1328
+ * `standards_cache_unreadable` and stop; then the stamp pin; then per document
1329
+ * its resolution and pin (on a degraded INDEX, one resolution problem naming
1330
+ * the measured status and `standardsIndexRepair`'s repair, iter-3 WR-01);
1331
+ * then the degraded arms of D-20/D-29:
1332
+ * - a degraded line that disagrees with the measured state: untrue;
1333
+ * - index-missing / could-not-check that agrees, `vault.required` off:
1334
+ * no problem, an advisory;
1335
+ * - agrees, `vault.required` on, no override line: untrue (the load gate
1336
+ * should have stopped the plan);
1337
+ * - agrees, `vault.required` on, override line present: no problem only
1338
+ * when the line agrees with `measured.loadVerdict` (D-35: the load gate's
1339
+ * verdict at gate time blocks for the reason the line records, under the
1340
+ * gate name and timestamp form the verb writes); otherwise untrue, so a
1341
+ * line that disagrees with the measured verdict removes nothing. A line
1342
+ * whose fields match is accepted whoever typed it: the guarantee is
1343
+ * agreement with measured state, not provenance (iter-2 WR-03).
1344
+ * A degraded record can therefore pass only where the load gate itself would
1345
+ * have let the build continue. The kind table stays at 17: agreement is never
1346
+ * a kind.
1347
+ */
1348
+ function judgeStandardsPlan(parsed, measured, ctx) {
1349
+ const problems = Array.isArray(parsed?.problems) ? [...parsed.problems] : [];
1350
+ const record = parsed?.record ?? null;
1351
+ if (record === null)
1352
+ return { problems, advisory: null, overridden: false };
1353
+ const artifact = typeof ctx?.artifact === 'string' ? ctx.artifact : '';
1354
+ const base = { artifact, planId: record.planId };
1355
+ const overridden = record.override !== null && record.override !== undefined;
1356
+ if (typeof ctx?.filenamePlanId === 'string' && ctx.filenamePlanId !== '' && ctx.filenamePlanId !== record.planId) {
1357
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.PLAN_ID_MISMATCH, { ...base, filenamePlanId: ctx.filenamePlanId }));
1358
+ }
1359
+ for (const doc of record.documents) {
1360
+ const v = validateReasonClause(doc.reason);
1361
+ if (!v.ok)
1362
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.REASON_INVALID, { ...base, path: doc.path, why: v.why, line: doc.line }));
1363
+ }
1364
+ const d = record.degraded;
1365
+ if (d && d.kind === 'consulted-none') {
1366
+ if (d.reason.trim() === '') {
1367
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.NONE_WITHOUT_REASON, base));
1368
+ }
1369
+ else {
1370
+ const v = validateReasonClause(d.reason);
1371
+ if (!v.ok)
1372
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.REASON_INVALID, { ...base, why: v.why }));
1373
+ }
1374
+ }
1375
+ const manifest = measured && measured.manifest ? measured.manifest : null;
1376
+ const needsCache = record.documents.length > 0 || (d !== null && d.kind === 'consulted-none');
1377
+ if (manifest === null && needsCache) {
1378
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.CACHE_UNREADABLE, base));
1379
+ return { problems, advisory: null, overridden };
1380
+ }
1381
+ // iter-3 WR-01: a document record judged where the INDEX is degraded gets
1382
+ // ONE path problem naming the measured status and the INDEX repair (the
1383
+ // consulted verb's words, from `standardsIndexRepair`), never one "choose
1384
+ // from its entries" per path: there are no entries to choose from. Its pin
1385
+ // problems are still reported, each naming the same repair first. An
1386
+ // unknown status keeps the per-path arm, which still blocks.
1387
+ const docStatus = measured ? measured.indexStatus : undefined;
1388
+ const degradedIndex = record.documents.length > 0 && isIndexStatus(docStatus) && docStatus !== 'ok' ? docStatus : undefined;
1389
+ // A loaded pin is always compared, `none` included (15-REVIEW WR-02): a
1390
+ // record with no pin judged where a vault IS loaded was written against
1391
+ // another vault state, and D-29's pin arm applies to it.
1392
+ if (manifest !== null && pinDiffers(String(record.ref), String(record.sha), manifest)) {
1393
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.PIN_MISMATCH, {
1394
+ ...base, recorded: pinText(record.ref, record.sha), loaded: pinText(manifest.ref, manifest.sha),
1395
+ ...(degradedIndex !== undefined ? { indexStatus: degradedIndex } : {}),
1396
+ }));
1397
+ }
1398
+ const resolved = measured && measured.resolvedPaths instanceof Set ? measured.resolvedPaths : new Set();
1399
+ if (degradedIndex !== undefined) {
1400
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.PATH_NOT_RESOLVED, { ...base, indexStatus: degradedIndex }));
1401
+ }
1402
+ for (const doc of record.documents) {
1403
+ if (degradedIndex === undefined && !resolved.has(doc.path)) {
1404
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.PATH_NOT_RESOLVED, { ...base, path: doc.path, line: doc.line }));
1405
+ }
1406
+ if (manifest !== null && pinDiffers(doc.ref, doc.sha, manifest)) {
1407
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.PIN_MISMATCH, {
1408
+ ...base, path: doc.path, line: doc.line, recorded: pinText(doc.ref, doc.sha), loaded: pinText(manifest.ref, manifest.sha),
1409
+ ...(degradedIndex !== undefined ? { indexStatus: degradedIndex } : {}),
1410
+ }));
1411
+ }
1412
+ }
1413
+ let advisory = null;
1414
+ if (d) {
1415
+ const status = measured ? measured.indexStatus : undefined;
1416
+ if (!degradedAgrees(d, status)) {
1417
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.DEGRADED_UNTRUE, {
1418
+ ...base, recorded: degradedLabel(d), measured: isIndexStatus(status) ? status : 'in an unknown state',
1419
+ }));
1420
+ }
1421
+ else if (d.kind !== 'consulted-none') {
1422
+ if (measured.required !== true) {
1423
+ advisory = degradedAdvisory(record, d, status, artifact);
1424
+ }
1425
+ else if (!overridden) {
1426
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.DEGRADED_UNTRUE, {
1427
+ ...base, recorded: degradedLabel(d), required: true, indexStatus: status,
1428
+ }));
1429
+ }
1430
+ else if (!overrideAgrees(record.override, measured.loadVerdict)) {
1431
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.DEGRADED_UNTRUE, {
1432
+ ...base, recorded: degradedLabel(d), required: true, overrideUnverified: true,
1433
+ }));
1434
+ }
1435
+ }
1436
+ }
1437
+ return { problems, advisory, overridden };
1438
+ }
1439
+ /**
1440
+ * Judge one parsed SUMMARY section on its own: the parse problems (absent,
1441
+ * malformed, duplicate entry), the plan id against the filename, every
1442
+ * unfilled status slot (a mismatch naming the path and both accepted forms),
1443
+ * and every `not read:` clause by the reason rules.
1444
+ */
1445
+ function judgeConsultedSummary(parsed, ctx) {
1446
+ const problems = Array.isArray(parsed?.problems) ? [...parsed.problems] : [];
1447
+ if (!parsed || !parsed.present || parsed.planId === null)
1448
+ return problems;
1449
+ const artifact = typeof ctx?.artifact === 'string' ? ctx.artifact : '';
1450
+ const base = { artifact, planId: parsed.planId, where: 'section' };
1451
+ if (typeof ctx?.filenamePlanId === 'string' && ctx.filenamePlanId !== '' && ctx.filenamePlanId !== parsed.planId) {
1452
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.PLAN_ID_MISMATCH, { ...base, filenamePlanId: ctx.filenamePlanId }));
1453
+ }
1454
+ for (const e of parsed.entries) {
1455
+ if (e.status === 'unfilled') {
1456
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.CONSULTED_MISMATCH, { ...base, path: e.path, line: e.line, form: 'unfilled' }));
1457
+ }
1458
+ else if (e.status === 'not-read') {
1459
+ const v = validateReasonClause(e.reason ?? '');
1460
+ if (!v.ok)
1461
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.REASON_INVALID, { ...base, path: e.path, line: e.line, why: v.why }));
1462
+ }
1463
+ }
1464
+ return problems;
1465
+ }
1466
+ /**
1467
+ * Compare a PLAN's record with its paired SUMMARY section (D-13): the section
1468
+ * stamp's ref and sha must equal the block's; a degraded block is echoed by
1469
+ * exactly its degraded line; a document block is matched path for path, every
1470
+ * block document once and nothing extra. A null side compares to nothing:
1471
+ * pairing (`standards_pair_missing`, `standards_orphan_summary`) is the
1472
+ * handler's, by recorded plan id.
1473
+ */
1474
+ function compareConsulted(record, section, ctx) {
1475
+ if (!record || !section || !section.present || section.planId === null)
1476
+ return [];
1477
+ const problems = [];
1478
+ const base = {
1479
+ artifact: typeof ctx?.artifact === 'string' ? ctx.artifact : '', planId: record.planId, where: 'section',
1480
+ };
1481
+ if (section.ref !== record.ref || section.sha !== record.sha) {
1482
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.PIN_MISMATCH, {
1483
+ ...base, recorded: pinText(section.ref, section.sha), loaded: pinText(record.ref, record.sha),
1484
+ }));
1485
+ }
1486
+ if (record.degraded || section.degraded) {
1487
+ const same = record.degraded !== null && section.degraded !== null && section.entries.length === 0
1488
+ && section.degraded.kind === record.degraded.kind && section.degraded.reason === record.degraded.reason;
1489
+ if (!same)
1490
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.CONSULTED_MISMATCH, { ...base, form: 'degraded' }));
1491
+ return problems;
1492
+ }
1493
+ const inSection = new Set(section.entries.map((e) => e.path));
1494
+ const inBlock = new Set(record.documents.map((doc) => doc.path));
1495
+ for (const doc of record.documents) {
1496
+ if (!inSection.has(doc.path))
1497
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.CONSULTED_MISMATCH, { ...base, path: doc.path, form: 'missing' }));
1498
+ }
1499
+ for (const e of section.entries) {
1500
+ if (!inBlock.has(e.path))
1501
+ problems.push(problem(exports.STANDARDS_PROBLEM_KIND.CONSULTED_MISMATCH, { ...base, path: e.path, line: e.line, form: 'extra' }));
1502
+ }
1503
+ return problems;
1504
+ }