@pareto4/pp-core 1.13.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/.claude-plugin/plugin.json +3 -3
  2. package/README.md +26 -9
  3. package/agents/pp-code-reviewer.md +2 -1
  4. package/agents/pp-debug-session-manager.md +9 -6
  5. package/agents/pp-executor.md +10 -13
  6. package/agents/pp-plan-checker.md +4 -4
  7. package/agents/pp-planner.md +23 -9
  8. package/agents/pp-vault-reviewer.md +244 -21
  9. package/agents/pp-verifier.md +7 -3
  10. package/bin/install.js +10 -3
  11. package/commands/pp-execute-phase.md +1 -3
  12. package/commands/pp-ns-review.md +1 -1
  13. package/commands/pp-plan-phase.md +1 -2
  14. package/commands/pp-vault-review.md +5 -3
  15. package/commands/pp-verify-work.md +1 -2
  16. package/gemini-extension.json +1 -1
  17. package/hooks/dist/pp-check-update.js +0 -0
  18. package/hooks/dist/pp-cursor-session-start.js +1 -1
  19. package/hooks/dist/pp-statusline.js +1 -1
  20. package/hooks/dist/pp-update-banner.js +0 -0
  21. package/hooks/pp-check-update.js +0 -0
  22. package/hooks/pp-cursor-session-start.js +1 -1
  23. package/hooks/pp-statusline.js +1 -1
  24. package/hooks/pp-update-banner.js +0 -0
  25. package/package.json +14 -19
  26. package/pareto-skills/_shared/create-project-board.sh +0 -0
  27. package/pareto-skills/pp-project-setup/SKILL.md +1 -1
  28. package/pp-core/bin/check-latest-version.cjs +0 -0
  29. package/pp-core/bin/detect-build-commands.sh +2 -2
  30. package/pp-core/bin/lib/capability-activation.cjs +83 -9
  31. package/pp-core/bin/lib/capability-registry.cjs +396 -82
  32. package/pp-core/bin/lib/capability-state.cjs +160 -4
  33. package/pp-core/bin/lib/capability-writer.cjs +19 -5
  34. package/pp-core/bin/lib/check-command-router.cjs +3334 -946
  35. package/pp-core/bin/lib/clusters.cjs +3 -0
  36. package/pp-core/bin/lib/command-aliases.cjs +0 -35
  37. package/pp-core/bin/lib/commands.cjs +9 -4
  38. package/pp-core/bin/lib/compose-bindings.cjs +251 -88
  39. package/pp-core/bin/lib/config-loader.cjs +7 -103
  40. package/pp-core/bin/lib/config-schema.cjs +1 -1
  41. package/pp-core/bin/lib/config.cjs +154 -62
  42. package/pp-core/bin/lib/decisions.cjs +4 -4
  43. package/pp-core/bin/lib/discovery-vault-check.cjs +246 -0
  44. package/pp-core/bin/lib/frontmatter.cjs +1 -38
  45. package/pp-core/bin/lib/git-base-branch.cjs +13 -11
  46. package/pp-core/bin/lib/git-provider.cjs +4 -4
  47. package/pp-core/bin/lib/init-command-router.cjs +5 -8
  48. package/pp-core/bin/lib/init-contract.cjs +648 -738
  49. package/pp-core/bin/lib/init-interactive.cjs +76 -4
  50. package/pp-core/bin/lib/init.cjs +24 -51
  51. package/pp-core/bin/lib/install-profiles.cjs +1 -1
  52. package/pp-core/bin/lib/io.cjs +8 -0
  53. package/pp-core/bin/lib/loop-resolver.cjs +189 -129
  54. package/pp-core/bin/lib/org-profile.cjs +469 -152
  55. package/pp-core/bin/lib/package-identity.cjs +3 -3
  56. package/pp-core/bin/lib/phase-command-router.cjs +1 -29
  57. package/pp-core/bin/lib/phase-scope.cjs +1 -1
  58. package/pp-core/bin/lib/phase.cjs +11 -253
  59. package/pp-core/bin/lib/platform-bindings.cjs +40 -19
  60. package/pp-core/bin/lib/practices-review-check.cjs +15 -9
  61. package/pp-core/bin/lib/profile-output.cjs +1 -1
  62. package/pp-core/bin/lib/shell-command-projection.cjs +104 -1
  63. package/pp-core/bin/lib/state.cjs +11 -85
  64. package/pp-core/bin/lib/template.cjs +1 -1
  65. package/pp-core/bin/lib/vault-audit.cjs +650 -126
  66. package/pp-core/bin/lib/vault-command-router.cjs +2604 -367
  67. package/pp-core/bin/lib/vault-coordinates.cjs +13 -4
  68. package/pp-core/bin/lib/vault-index.cjs +699 -0
  69. package/pp-core/bin/lib/vault-mode.cjs +235 -0
  70. package/pp-core/bin/lib/vault-practices.cjs +139 -72
  71. package/pp-core/bin/lib/vault-review.cjs +194 -451
  72. package/pp-core/bin/lib/vault-seal.cjs +1936 -0
  73. package/pp-core/bin/lib/vault-standards.cjs +1505 -0
  74. package/pp-core/bin/lib/vault-trace.cjs +607 -254
  75. package/pp-core/bin/lib/vault-update.cjs +9 -33
  76. package/pp-core/bin/lib/vault-write-back.cjs +70 -42
  77. package/pp-core/bin/lib/vault.cjs +2734 -920
  78. package/pp-core/bin/lib/verify.cjs +0 -11
  79. package/pp-core/bin/portable-timeout.sh +1 -1
  80. package/pp-core/bin/pp-tools.cjs +184 -54
  81. package/pp-core/bin/shared/config-defaults.manifest.json +0 -7
  82. package/pp-core/bin/shared/config-schema.manifest.json +0 -3
  83. package/pp-core/bin/shared/model-catalog.json +2 -2
  84. package/pp-core/bin/verify-reapply-patches.cjs +0 -0
  85. package/pp-core/org-profile.json +26 -0
  86. package/pp-core/references/autonomous-best-effort.md +8 -7
  87. package/pp-core/references/loop-hook-dispatch.md +0 -25
  88. package/pp-core/references/planner-gap-closure.md +0 -1
  89. package/pp-core/references/planner-tdd.md +0 -1
  90. package/pp-core/references/planner-vault-practices.md +17 -34
  91. package/pp-core/references/planner-vault-standards.md +320 -0
  92. package/pp-core/references/planning-config.md +0 -8
  93. package/pp-core/references/practice-arithmetic-contract.md +121 -0
  94. package/pp-core/references/tdd.md +0 -1
  95. package/pp-core/references/verifier-practice-verdicts.md +263 -0
  96. package/pp-core/templates/README.md +1 -0
  97. package/pp-core/templates/VAULT-REVIEW.md +202 -0
  98. package/pp-core/templates/phase-prompt.md +2 -6
  99. package/pp-core/templates/practices/README.md +20 -23
  100. package/pp-core/workflows/_runtime-launcher.snippet.sh +0 -0
  101. package/pp-core/workflows/add-tests.md +55 -4
  102. package/pp-core/workflows/audit-fix.md +63 -2
  103. package/pp-core/workflows/audit-milestone.md +1 -1
  104. package/pp-core/workflows/autonomous.md +85 -11
  105. package/pp-core/workflows/check-todos.md +13 -2
  106. package/pp-core/workflows/code-review-fix.md +59 -3
  107. package/pp-core/workflows/code-review.md +10 -1
  108. package/pp-core/workflows/complete-milestone.md +1 -1
  109. package/pp-core/workflows/debug.md +54 -4
  110. package/pp-core/workflows/discovery-sprint/prefill.md +19 -7
  111. package/pp-core/workflows/discovery-sprint/prototype.md +46 -49
  112. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  113. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +1 -3
  114. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +1 -3
  115. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +1 -3
  116. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +20 -24
  117. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +1 -3
  118. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +1 -3
  119. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +1 -3
  120. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +1 -3
  121. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +1 -3
  122. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +1 -6
  123. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +1 -3
  124. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +1 -3
  125. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +1 -3
  126. package/pp-core/workflows/discovery-sprint/templates/context-template.md +1 -1
  127. package/pp-core/workflows/discovery-sprint/vault-context.md +213 -0
  128. package/pp-core/workflows/discovery-sprint.md +7 -3
  129. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +4 -4
  130. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +43 -1
  131. package/pp-core/workflows/discovery-to-build.md +10 -2
  132. package/pp-core/workflows/discuss-phase.md +3 -10
  133. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +329 -0
  134. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +3 -2
  135. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +1 -1
  136. package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +98 -0
  137. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +94 -0
  138. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +6 -8
  139. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +115 -14
  140. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +322 -20
  141. package/pp-core/workflows/execute-phase/steps/verifier-practice-payload.md +165 -0
  142. package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +163 -0
  143. package/pp-core/workflows/execute-phase.md +155 -128
  144. package/pp-core/workflows/execute-plan.md +56 -6
  145. package/pp-core/workflows/fast.md +53 -4
  146. package/pp-core/workflows/help/modes/full.md +3 -3
  147. package/pp-core/workflows/init/steps/client-context-capture.md +101 -0
  148. package/pp-core/workflows/init/steps/gitlab-provisioning.md +130 -0
  149. package/pp-core/workflows/init/steps/project-md-deferral-narration.md +66 -0
  150. package/pp-core/workflows/init/steps/reachability-outcome.md +70 -0
  151. package/pp-core/workflows/init.md +232 -272
  152. package/pp-core/workflows/new-project.md +31 -23
  153. package/pp-core/workflows/plan-phase.md +98 -45
  154. package/pp-core/workflows/quick.md +117 -3
  155. package/pp-core/workflows/secure-phase.md +1 -1
  156. package/pp-core/workflows/ship.md +57 -28
  157. package/pp-core/workflows/undo.md +1 -1
  158. package/pp-core/workflows/update.md +3 -3
  159. package/pp-core/workflows/validate-phase.md +58 -3
  160. package/pp-core/workflows/vault-review.md +282 -22
  161. package/pp-core/workflows/verify-work.md +12 -14
  162. package/scripts/changeset/cli.cjs +6 -5
  163. package/scripts/changeset/lint.cjs +0 -0
  164. package/scripts/changeset/new.cjs +33 -50
  165. package/scripts/dev-env.cjs +25 -85
  166. package/scripts/gen-capability-registry.cjs +199 -11
  167. package/scripts/gen-practice-arithmetic-contract.cjs +412 -0
  168. package/scripts/lib/allowlist-ratchet.cjs +1 -103
  169. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
  170. package/scripts/lint-docs-required.cjs +0 -0
  171. package/scripts/lint-pr-check-project-dir.cjs +1 -3
  172. package/scripts/lint-test-file-count.allowlist.json +12 -10
  173. package/scripts/practice-arithmetic-contract.allow.json +35 -0
  174. package/scripts/prompt-injection-scan.sh +22 -17
  175. package/scripts/record-vault-mode-golden.cjs +89 -0
  176. package/scripts/release-channel.cjs +0 -0
  177. package/scripts/sync-manifest-versions.cjs +5 -4
  178. package/scripts/sync-rulesets.sh +1 -1
  179. package/scripts/update-size-baseline.cjs +30 -274
  180. package/scripts/vault-calibrate.cjs +996 -0
  181. package/scripts/verify-opengsd-sweep.cjs +3 -5
  182. package/scripts/workflow-size.cjs +9 -61
  183. package/pp-core/bin/lib/dispatch-plan.cjs +0 -483
  184. package/pp-core/bin/lib/install-run.cjs +0 -536
  185. package/pp-core/bin/lib/loop-gates.cjs +0 -213
  186. package/pp-core/bin/lib/phase-manifest.cjs +0 -527
  187. package/pp-core/bin/lib/phase-tier.cjs +0 -480
  188. package/pp-core/bin/lib/plan-graph.cjs +0 -368
  189. package/pp-core/references/executor-vault-trace.md +0 -47
  190. package/pp-core/references/planner-plan-graph.md +0 -66
  191. package/pp-core/workflows/discovery-sprint/vault-fetch.md +0 -37
  192. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +0 -45
  193. package/pp-core/workflows/execute-phase/steps/install-run.md +0 -29
  194. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +0 -23
  195. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +0 -73
  196. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +0 -19
  197. package/pp-core/workflows/plan-phase/steps/phase-tier.md +0 -43
  198. package/scripts/lib/size-budget.cjs +0 -311
  199. package/scripts/lint-neutrality-names.cjs +0 -694
  200. package/scripts/lint-org-coordinates.allowlist.json +0 -35
  201. package/scripts/lint-org-coordinates.cjs +0 -426
@@ -0,0 +1,699 @@
1
+ "use strict";
2
+ /**
3
+ * src/vault-index.cts — the vault INDEX.md machine layer (Vault Assurance v1.2,
4
+ * Phase 14-02, D-06).
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-index.cjs`; do NOT
8
+ * hand-edit the compiled output. Behaviour is locked by
9
+ * `tests/vault-index.test.cjs`.
10
+ *
11
+ * In standards mode (`vault.mode: standards`) the client vault's own INDEX.md
12
+ * is the machine layer: one `- \`path\` — what it is; used when …` line per
13
+ * document, grouped by `##` section. This module is the ONE owner of:
14
+ * - the grammar constants (the single text the REL-02 one-pager quotes),
15
+ * - the entry head split shared with `discovery-vault-check`,
16
+ * - the vault-path classifier and its typed reasons,
17
+ * - the tolerant INDEX parser,
18
+ * - the membership resolver,
19
+ * - the frozen index reason codes and every index sentence,
20
+ * - the display sanitiser for vault-derived text,
21
+ * - the external-content fence-marker constants and `defangFenceMarkers`,
22
+ * - the standards card-line text.
23
+ * A surface that needs any of these quotes it from here rather than spelling
24
+ * its own copy, so the grammar the engine parses, the path rule discovery
25
+ * applies and the sentence an operator reads cannot drift apart.
26
+ *
27
+ * It imports NOTHING project-internal (no node builtins either). This is
28
+ * load-bearing: `src/vault.cts`, `src/discovery-vault-check.cts`,
29
+ * `src/check-command-router.cts`, `src/vault-command-router.cts`,
30
+ * `src/vault-practices.cts` and `src/practices-review-check.cts` all require
31
+ * it, and a leaf with no dependencies cannot close a require cycle among them
32
+ * (the `src/vault-mode.cts` precedent). The leaf therefore never touches the
33
+ * filesystem: resolution is pure string membership in a set the caller built
34
+ * from the manifest, and the bounded read of INDEX.md lives in `vault.cts`.
35
+ *
36
+ * Message discipline (T-13-03, T-14-04): no text here takes or echoes a
37
+ * configured value. Vault-derived text (a path, a section title, a case
38
+ * variant of the index filename) is quoted only after `sanitiseVaultText`,
39
+ * which strips terminal control and bidi characters, bounds the length and
40
+ * defangs the fence markers. The card line is built from fields its caller
41
+ * has already sanitised.
42
+ */
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.VAULT_INDEX_WITHHELD_KINDS = exports.VAULT_INDEX_UNCHECKED_CAUSES = exports.VAULT_INDEX_UNPARSEABLE_CAUSES = exports.VAULT_INDEX_REASON = exports.DISPLAY_UNSAFE_TEXT_CHAR_RE = exports.ZERO_WIDTH_SPACE = exports.EXTERNAL_CONTENT_FENCE_END = exports.EXTERNAL_CONTENT_FENCE_START = exports.VAULT_PATH_REASONS = exports.VAULT_INDEX_LIST_BOUND = exports.VAULT_TEXT_MAX = exports.VAULT_INDEX_GRAMMAR = exports.VAULT_SECTION_HEADING_PREFIX = exports.VAULT_USED_WHEN_RE = exports.VAULT_USED_WHEN_MARKER = exports.VAULT_ENTRY_SEPARATOR = exports.VAULT_ENTRY_PREFIX = exports.VAULT_INDEX_BYTE_CAP = exports.VAULT_INDEX_FILENAME = void 0;
45
+ exports.classifyVaultPath = classifyVaultPath;
46
+ exports.isSafeVaultPath = isSafeVaultPath;
47
+ exports.splitVaultEntryLine = splitVaultEntryLine;
48
+ exports.parseVaultIndex = parseVaultIndex;
49
+ exports.defangFenceMarkers = defangFenceMarkers;
50
+ exports.sanitiseVaultText = sanitiseVaultText;
51
+ exports.isDisplaySafeVaultPath = isDisplaySafeVaultPath;
52
+ exports.displayVaultPath = displayVaultPath;
53
+ exports.resolveVaultIndexEntries = resolveVaultIndexEntries;
54
+ exports.vaultIndexReasonShort = vaultIndexReasonShort;
55
+ exports.vaultIndexReasonText = vaultIndexReasonText;
56
+ exports.vaultIndexWarningMessage = vaultIndexWarningMessage;
57
+ exports.vaultIndexBlockMessage = vaultIndexBlockMessage;
58
+ exports.vaultIndexResolutionText = vaultIndexResolutionText;
59
+ exports.vaultStandardsCardLine = vaultStandardsCardLine;
60
+ // ─── Grammar (D-01, D-02, D-05) ──────────────────────────────────────────────
61
+ /** The index file, named exactly (case-sensitive) at the vault root. */
62
+ exports.VAULT_INDEX_FILENAME = 'INDEX.md';
63
+ /** The bounded read cap for INDEX.md: 256 KiB. The reader reads cap+1 bytes to detect oversize. */
64
+ exports.VAULT_INDEX_BYTE_CAP = 262144;
65
+ /** An entry line starts at column 0 with a hyphen, a space and a backtick. */
66
+ exports.VAULT_ENTRY_PREFIX = '- `';
67
+ /** Between the closing backtick and the remainder: space, em dash (U+2014), space. */
68
+ exports.VAULT_ENTRY_SEPARATOR = ' \u2014 ';
69
+ /** The canonical spelling of the used-when clause marker. */
70
+ exports.VAULT_USED_WHEN_MARKER = '; used when ';
71
+ /**
72
+ * The matcher for the used-when clause: `;`, whitespace, `used when`,
73
+ * whitespace, case-insensitive. The FIRST match splits (D-02). The split is
74
+ * never at a bare `;`: the real `practices/` entry carries `; used
75
+ * automatically …`, and splitting there would invent a clause (D-22).
76
+ */
77
+ exports.VAULT_USED_WHEN_RE = /;\s+used when\s+/i;
78
+ /** A section is opened by a level-2 ATX heading at column 0. */
79
+ exports.VAULT_SECTION_HEADING_PREFIX = '## ';
80
+ /** The six grammar values bundled, frozen, for a consumer that quotes the grammar whole. */
81
+ exports.VAULT_INDEX_GRAMMAR = Object.freeze({
82
+ filename: exports.VAULT_INDEX_FILENAME,
83
+ byteCap: exports.VAULT_INDEX_BYTE_CAP,
84
+ entryPrefix: exports.VAULT_ENTRY_PREFIX,
85
+ separator: exports.VAULT_ENTRY_SEPARATOR,
86
+ usedWhenMarker: exports.VAULT_USED_WHEN_MARKER,
87
+ sectionHeadingPrefix: exports.VAULT_SECTION_HEADING_PREFIX,
88
+ });
89
+ /** The bound on a sanitised vault-derived string (equal to vault.cts SUMMARY_HEADING_MAX). */
90
+ exports.VAULT_TEXT_MAX = 500;
91
+ /** The bound on an envelope list of index paths; the counts beside it stay exact (D-14). */
92
+ exports.VAULT_INDEX_LIST_BOUND = 20;
93
+ // ─── Path safety (D-08) ──────────────────────────────────────────────────────
94
+ /**
95
+ * Why a vault path is unsafe, in classification order. Every reason maps to
96
+ * discovery's single `path_unsafe`; INDEX keeps the typed reason.
97
+ */
98
+ exports.VAULT_PATH_REASONS = Object.freeze([
99
+ 'empty',
100
+ 'nul',
101
+ 'control_char',
102
+ 'backslash',
103
+ 'absolute',
104
+ 'traversal',
105
+ 'leading_dot',
106
+ 'dot_segment',
107
+ 'empty_segment',
108
+ // 14-REVIEW WR-02: a path that cannot be displayed as written (a bidi,
109
+ // line-separator, format or zero-width character, any whitespace other than
110
+ // a single interior U+0020, a `"`, a fence marker, or over VAULT_TEXT_MAX
111
+ // code points — `isDisplaySafeVaultPath`).
112
+ // Refusing it is what makes `resolved ⇒ the displayed path is the checked
113
+ // path` hold: nothing selectable can render under a name that is not the file.
114
+ 'display_unsafe',
115
+ ]);
116
+ // C0 controls other than NUL (NUL has its own reason), DEL, and the C1 block.
117
+ const CONTROL_CHAR_RE = /[\u0001-\u001F\u007F-\u009F]/;
118
+ // A Windows drive prefix (`C:/x`, `c:x`) is absolute (D-08 tightening 1).
119
+ const DRIVE_PREFIX_RE = /^[A-Za-z]:/;
120
+ /**
121
+ * Classify a vault-relative path. The base is discovery's rule moved verbatim
122
+ * (non-empty; no backslash, no NUL; not starting with `/` or `.`; no empty,
123
+ * `.` or `..` segment), plus D-08's two tightenings: a Windows drive prefix is
124
+ * absolute, and any C0 or C1 control character is unsafe — a path the engine
125
+ * echoes must not carry terminal control bytes.
126
+ *
127
+ * Percent-encoding and unicode (look-alike dots included) are LITERAL: never
128
+ * decoded, never normalised. Resolution is exact membership, so they cannot
129
+ * traverse. A trailing slash yields an empty last segment: `empty_segment`.
130
+ *
131
+ * Order is pinned by the leaf test: traversal is checked before leading_dot so
132
+ * `../x` reports `traversal`. `display_unsafe` is checked last (14-REVIEW
133
+ * WR-02): a path that passes every structural rule but cannot be displayed
134
+ * exactly as written (`isDisplaySafeVaultPath`) is refused, so a path that
135
+ * resolves is always echoed byte-for-byte.
136
+ */
137
+ function classifyVaultPath(p) {
138
+ if (typeof p !== 'string' || p === '')
139
+ return { ok: false, reason: 'empty' };
140
+ if (p.includes('\0'))
141
+ return { ok: false, reason: 'nul' };
142
+ if (CONTROL_CHAR_RE.test(p))
143
+ return { ok: false, reason: 'control_char' };
144
+ if (p.includes('\\'))
145
+ return { ok: false, reason: 'backslash' };
146
+ if (p.startsWith('/') || DRIVE_PREFIX_RE.test(p))
147
+ return { ok: false, reason: 'absolute' };
148
+ const segs = p.split('/');
149
+ if (segs.includes('..'))
150
+ return { ok: false, reason: 'traversal' };
151
+ if (p.startsWith('.'))
152
+ return { ok: false, reason: 'leading_dot' };
153
+ if (segs.includes('.'))
154
+ return { ok: false, reason: 'dot_segment' };
155
+ if (segs.includes(''))
156
+ return { ok: false, reason: 'empty_segment' };
157
+ // Last, so every structural reason keeps its precedence (14-REVIEW WR-02).
158
+ // The same predicate decides displayVaultPath's quoting (iteration 2 WR-01).
159
+ if (!isDisplaySafeVaultPath(p, exports.VAULT_TEXT_MAX))
160
+ return { ok: false, reason: 'display_unsafe' };
161
+ return { ok: true };
162
+ }
163
+ /** The boolean form of `classifyVaultPath`. */
164
+ function isSafeVaultPath(p) {
165
+ return classifyVaultPath(p).ok;
166
+ }
167
+ /**
168
+ * Split one already-`trimEnd()`-ed line into its entry head. The order is
169
+ * discovery's (`discovery-vault-check.cts` parseEntry): prefix → closing
170
+ * backtick → separator. Path safety is NOT judged here; each consumer applies
171
+ * `classifyVaultPath` after the split, so an unsafe path with a bad separator
172
+ * stays malformed rather than unsafe.
173
+ *
174
+ * The path is LITERAL: no trim inside the backticks (discovery extracts
175
+ * `" a.md "` as written), and empty backticks yield path `''`, which the
176
+ * classifier reports `empty`.
177
+ */
178
+ function splitVaultEntryLine(line) {
179
+ if (typeof line !== 'string' || !line.startsWith(exports.VAULT_ENTRY_PREFIX))
180
+ return null;
181
+ const close = line.indexOf('`', exports.VAULT_ENTRY_PREFIX.length);
182
+ if (close === -1)
183
+ return { malformed: true };
184
+ const after = line.slice(close + 1);
185
+ if (!after.startsWith(exports.VAULT_ENTRY_SEPARATOR))
186
+ return { malformed: true };
187
+ const rest = after.slice(exports.VAULT_ENTRY_SEPARATOR.length);
188
+ if (rest.trim() === '')
189
+ return { malformed: true };
190
+ return { path: line.slice(exports.VAULT_ENTRY_PREFIX.length, close), rest };
191
+ }
192
+ // CommonMark 0.31.2 fenced code blocks: ≤3 spaces of indent, then ≥3 backticks
193
+ // or ≥3 tildes (never mixed). A backtick fence's info string may not contain a
194
+ // backtick, or the line is not a fence opener.
195
+ const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
196
+ // A level-2 heading at column 0; `##` alone is an empty heading. `###` never
197
+ // matches because its third character is neither whitespace nor end of line.
198
+ const SECTION_HEADING_RE = /^##(?:[ \t]+(.*))?$/;
199
+ /**
200
+ * Strip an optional ATX closing sequence (`## Title ##`): a trailing run of
201
+ * `#` that is the whole title or follows a space/tab, together with that
202
+ * space/tab run. Same result as `title.replace(/(?:^|[ \t]+)#+$/, '')`, but a
203
+ * backwards scan rather than a regex: the regex retried `[ \t]+` from every
204
+ * position of a whitespace run, so one heading padded to the 256 KiB cap
205
+ * parsed in ~26 s (14-SECURITY T-14-05).
206
+ */
207
+ function stripAtxClosing(title) {
208
+ let hashes = title.length;
209
+ while (hashes > 0 && title[hashes - 1] === '#')
210
+ hashes -= 1;
211
+ if (hashes === title.length)
212
+ return title;
213
+ if (hashes === 0)
214
+ return '';
215
+ let ws = hashes;
216
+ while (ws > 0 && (title[ws - 1] === ' ' || title[ws - 1] === '\t'))
217
+ ws -= 1;
218
+ return ws === hashes ? title : title.slice(0, ws);
219
+ }
220
+ // A level-1 heading at column 0 resets the section.
221
+ const LEVEL1_HEADING_RE = /^#(?:[ \t].*)?$/;
222
+ // D-28: an entry prefix indented 1-3 spaces is a near-miss, not a nested list.
223
+ const INDENTED_ENTRY_RE = /^ {1,3}- `/;
224
+ function buildEntry(section, path, rest, line) {
225
+ const m = exports.VAULT_USED_WHEN_RE.exec(rest);
226
+ if (m) {
227
+ const usedWhen = rest.slice(m.index + m[0].length).trim();
228
+ if (usedWhen !== '') {
229
+ return { section, path, description: rest.slice(0, m.index).trim(), usedWhen, line };
230
+ }
231
+ }
232
+ // No clause, or an empty one: the whole remainder is the description and the
233
+ // key is omitted — a clause is never invented and text is never dropped.
234
+ return { section, path, description: rest.trim(), line };
235
+ }
236
+ /**
237
+ * Parse an INDEX.md text tolerantly. Never throws; a non-string is read as
238
+ * `''`. One leading BOM is stripped and CRLF is tolerated; each line is
239
+ * `trimEnd()`-ed before matching.
240
+ *
241
+ * Entries are column-0 `- \`path\` — remainder` lines outside fenced code
242
+ * blocks and HTML comments. Blank lines, paragraphs, headings other than level
243
+ * 2, other bullets and deeper-indented items are prose and ignored silently. A
244
+ * column-0 line that starts like an entry but fails the head split, or an entry
245
+ * prefix indented 1-3 spaces, is a `malformed_entry` diagnostic — never an
246
+ * error. A fence or comment still open at end of text is an `unclosed_block`
247
+ * diagnostic naming its opening line, so the entries it swallowed cannot
248
+ * vanish silently.
249
+ */
250
+ function parseVaultIndex(text) {
251
+ let src = typeof text === 'string' ? text : '';
252
+ if (src.charCodeAt(0) === 0xfeff)
253
+ src = src.slice(1);
254
+ const lines = src.split(/\r?\n/);
255
+ const sections = [];
256
+ // A Set beside the ordered list: `sections.includes` was O(S²) over a
257
+ // cap-sized index of distinct headings (14-SECURITY T-14-05).
258
+ const seenSections = new Set();
259
+ const entries = [];
260
+ const diagnostics = [];
261
+ let section = null;
262
+ let inFence = null;
263
+ let inComment = null;
264
+ for (let i = 0; i < lines.length; i += 1) {
265
+ const lineNo = i + 1;
266
+ const line = lines[i].trimEnd();
267
+ if (inFence) {
268
+ const close = FENCE_OPEN_RE.exec(line);
269
+ if (close && close[1][0] === inFence.char && close[1].length >= inFence.len && close[2].trim() === '') {
270
+ inFence = null;
271
+ }
272
+ continue;
273
+ }
274
+ if (inComment) {
275
+ if (line.includes('-->'))
276
+ inComment = null;
277
+ continue;
278
+ }
279
+ const fence = FENCE_OPEN_RE.exec(line);
280
+ if (fence && !(fence[1][0] === '`' && fence[2].includes('`'))) {
281
+ inFence = { char: fence[1][0], len: fence[1].length, openLine: lineNo };
282
+ continue;
283
+ }
284
+ const lead = line.trimStart();
285
+ if (lead.startsWith('<!--')) {
286
+ if (!lead.slice(4).includes('-->'))
287
+ inComment = { openLine: lineNo };
288
+ continue;
289
+ }
290
+ const h2 = SECTION_HEADING_RE.exec(line);
291
+ if (h2) {
292
+ const title = stripAtxClosing(h2[1] ?? '').trim();
293
+ section = title === '' ? null : title;
294
+ if (section !== null && !seenSections.has(section)) {
295
+ seenSections.add(section);
296
+ sections.push(section);
297
+ }
298
+ continue;
299
+ }
300
+ if (LEVEL1_HEADING_RE.test(line)) {
301
+ section = null;
302
+ continue;
303
+ }
304
+ if (line.startsWith(exports.VAULT_ENTRY_PREFIX)) {
305
+ const head = splitVaultEntryLine(line);
306
+ if (head === null || 'malformed' in head) {
307
+ diagnostics.push({ kind: 'malformed_entry', line: lineNo });
308
+ }
309
+ else {
310
+ entries.push(buildEntry(section, head.path, head.rest, lineNo));
311
+ }
312
+ continue;
313
+ }
314
+ if (INDENTED_ENTRY_RE.test(line)) {
315
+ diagnostics.push({ kind: 'malformed_entry', line: lineNo });
316
+ }
317
+ }
318
+ if (inFence)
319
+ diagnostics.push({ kind: 'unclosed_block', line: inFence.openLine });
320
+ if (inComment)
321
+ diagnostics.push({ kind: 'unclosed_block', line: inComment.openLine });
322
+ return { sections, entries, diagnostics };
323
+ }
324
+ // ─── Fence-marker owner (D-17, D-26) ─────────────────────────────────────────
325
+ // External-content fence markers (D-15 / T-24-05). The SUMMARY writer, the
326
+ // practice excerpt and the review check all fence untrusted vault text between
327
+ // these two literals; this leaf is their one cycle-free owner (14-04 re-points
328
+ // the three source copies here).
329
+ exports.EXTERNAL_CONTENT_FENCE_START = '<!-- vault:external-content-start -->';
330
+ exports.EXTERNAL_CONTENT_FENCE_END = '<!-- vault:external-content-end -->';
331
+ // Zero-width space (U+200B) — invisible when rendered, but breaks an exact
332
+ // string match against the literal fence marker (CR-01 / T-24-05). Used by
333
+ // `defangFenceMarkers` to neutralize a spoofed marker planted inside untrusted
334
+ // body text without visibly mangling the text for a human/agent reader.
335
+ exports.ZERO_WIDTH_SPACE = '\u200B';
336
+ /**
337
+ * Neutralize any occurrence of the external-content fence marker strings
338
+ * inside untrusted body text BEFORE it is wrapped between the real markers
339
+ * (CR-01 / T-24-05 fence-spoof escape). Without this, a practice body
340
+ * containing the literal end-fence marker (`<!-- vault:external-content-end
341
+ * -->`) could make a downstream consumer that naively splits on the exact
342
+ * marker treat the injected text as being OUTSIDE the untrusted-content
343
+ * fence — the classic "premature fence close" prompt-injection escape.
344
+ *
345
+ * Replaces each literal marker occurrence with a defanged variant (a
346
+ * zero-width space inserted immediately after the opening `<!--`) that is
347
+ * visually indistinguishable but no longer matches the real marker string —
348
+ * so only the two GENUINE markers `boundedFencedExcerpt` appends afterward
349
+ * remain real fence boundaries.
350
+ */
351
+ function defangFenceMarkers(body) {
352
+ let result = body;
353
+ for (const marker of [exports.EXTERNAL_CONTENT_FENCE_START, exports.EXTERNAL_CONTENT_FENCE_END]) {
354
+ if (result.includes(marker)) {
355
+ const defanged = `${marker.slice(0, 4)}${exports.ZERO_WIDTH_SPACE}${marker.slice(4)}`;
356
+ result = result.split(marker).join(defanged);
357
+ }
358
+ }
359
+ return result;
360
+ }
361
+ // ─── Display sanitiser (D-15, D-27) ──────────────────────────────────────────
362
+ // C0, DEL, C1, the line/paragraph separators, and the bidi formatting controls
363
+ // (embeddings/overrides U+202A-202E, isolates U+2066-2069, plus the marks
364
+ // U+200E/200F and U+061C — the Trojan Source set). JSON.stringify passes all
365
+ // of these through unescaped except C0, so --raw would carry them too.
366
+ const UNSAFE_DISPLAY_RE = /[\u0000-\u001F\u007F-\u009F\u2028\u2029\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C]/g;
367
+ /**
368
+ * Make one vault-derived string safe to echo: every control, line-separator
369
+ * and bidi character becomes a space, whitespace runs collapse to one space,
370
+ * the ends are trimmed, the fence markers are defanged, and the result is cut
371
+ * to `max` code points with a trailing `…` when longer. Pure; never throws; a
372
+ * non-string becomes `''`.
373
+ *
374
+ * Defang runs AFTER the collapse, because a control byte inside a marker
375
+ * would otherwise become a space and reassemble a live marker.
376
+ */
377
+ function sanitiseVaultText(s, max = exports.VAULT_TEXT_MAX) {
378
+ if (typeof s !== 'string')
379
+ return '';
380
+ const flat = s.replace(UNSAFE_DISPLAY_RE, ' ').replace(/\s+/g, ' ').trim();
381
+ const defanged = defangFenceMarkers(flat);
382
+ const points = Array.from(defanged);
383
+ const bound = Number.isFinite(max) && max >= 0 ? Math.floor(max) : exports.VAULT_TEXT_MAX;
384
+ if (points.length <= bound)
385
+ return defanged;
386
+ return `${points.slice(0, bound).join('')}\u2026`;
387
+ }
388
+ // The ONE character predicate for path display (14-REVIEW iteration 2, WR-01).
389
+ // A path carrying any of these is display-unsafe, and `displayVaultPath`
390
+ // escapes exactly these — so the set that decides "needs quoting" and the set
391
+ // that gets escaped cannot drift apart again. Members: everything
392
+ // UNSAFE_DISPLAY_RE strips; every whitespace character other than U+0020
393
+ // (no-break space, U+2000-200A, ideographic space, U+FEFF …), which the
394
+ // sanitiser would collapse; every format or default-ignorable code point
395
+ // (zero-width space and joiners, soft hyphen, word joiner, variation
396
+ // selectors, tag characters), which renders as nothing; and `"` and `\`, so a
397
+ // display that starts with `"` is always the quoted form and every `\` inside
398
+ // it starts an escape.
399
+ //
400
+ // The set is held in two parts so prose has one owner too (15-REVIEW iter-3
401
+ // IN-01): `DISPLAY_UNSAFE_TEXT_CHAR_RE` is every member except `"` and `\`,
402
+ // which are legal in prose and unsafe only in a path display, and a reason
403
+ // clause (`validateReasonClause` in vault-standards) refuses exactly it. The
404
+ // path predicate below is that set plus the two escape characters, never a
405
+ // second copy of it.
406
+ exports.DISPLAY_UNSAFE_TEXT_CHAR_RE = /[\u0000-\u001F\u007F-\u009F\u2028\u2029\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C]|[^\S ]|\p{Cf}|\p{Default_Ignorable_Code_Point}/u;
407
+ const DISPLAY_UNSAFE_CHAR_RE = new RegExp(`${exports.DISPLAY_UNSAFE_TEXT_CHAR_RE.source}|["\\\\]`, 'u');
408
+ const DISPLAY_UNSAFE_CHAR_RE_G = new RegExp(DISPLAY_UNSAFE_CHAR_RE.source, 'gu');
409
+ /**
410
+ * Whether a vault path can be echoed exactly as written. False for a path
411
+ * that carries a `DISPLAY_UNSAFE_CHAR_RE` character, a run of two spaces,
412
+ * leading or trailing space, a fence marker, or more than `max` code points.
413
+ * Everything `sanitiseVaultText` would rewrite is covered, so this is never
414
+ * looser than the sanitiser. The classifier's `display_unsafe` reason and
415
+ * `displayVaultPath`'s quoting both ask this one question.
416
+ */
417
+ function isDisplaySafeVaultPath(p, max = exports.VAULT_TEXT_MAX) {
418
+ if (typeof p !== 'string')
419
+ return false;
420
+ if (DISPLAY_UNSAFE_CHAR_RE.test(p))
421
+ return false;
422
+ if (p.startsWith(' ') || p.endsWith(' ') || p.includes(' '))
423
+ return false;
424
+ if (defangFenceMarkers(p) !== p)
425
+ return false;
426
+ const bound = Number.isFinite(max) && max >= 0 ? Math.floor(max) : exports.VAULT_TEXT_MAX;
427
+ return Array.from(p).length <= bound;
428
+ }
429
+ /**
430
+ * Echo a vault path WITHOUT rewriting it into a plausible different name
431
+ * (14-REVIEW WR-02, and iteration 2 WR-01). A display-safe path is returned
432
+ * as written. Any other path is returned double-quoted, with every
433
+ * `DISPLAY_UNSAFE_CHAR_RE` character escaped — `"` and `\` as `\"` and `\\`,
434
+ * the rest as `\uXXXX` per UTF-16 unit — and U+0020 kept as written, so
435
+ * padding and runs stay visible. A fence marker is broken by spelling its
436
+ * first space `\u0020`. So the quoted body holds no character that renders as
437
+ * nothing or as a different character, and removing the quotes never yields a
438
+ * display-safe path. Bounded at `max` code points inside the quotes, plus
439
+ * at most the rest of one escape and the `…`.
440
+ */
441
+ function displayVaultPath(p, max = exports.VAULT_TEXT_MAX) {
442
+ if (typeof p !== 'string')
443
+ return '';
444
+ if (isDisplaySafeVaultPath(p, max))
445
+ return p;
446
+ let escaped = p.replace(DISPLAY_UNSAFE_CHAR_RE_G, (c) => (c === '"' || c === '\\'
447
+ ? `\\${c}`
448
+ : Array.from({ length: c.length }, (_, i) => `\\u${c.charCodeAt(i).toString(16).toUpperCase().padStart(4, '0')}`).join('')));
449
+ for (const marker of [exports.EXTERNAL_CONTENT_FENCE_START, exports.EXTERNAL_CONTENT_FENCE_END]) {
450
+ escaped = escaped.split(marker).join(`${marker.slice(0, 4)}\\u0020${marker.slice(5)}`);
451
+ }
452
+ const bound = Number.isFinite(max) && max >= 0 ? Math.floor(max) : exports.VAULT_TEXT_MAX;
453
+ if (Array.from(escaped).length <= bound)
454
+ return `"${escaped}"`;
455
+ // Truncate on token boundaries, so the cut never leaves half an escape (a
456
+ // dangling `\` or `\u00`); every `\` in `escaped` starts an escape, because
457
+ // a raw `\` was escaped above. The token that crosses the bound is kept, so
458
+ // the body before the `…` is at least `bound` code points: with the `…` it is
459
+ // over-length, and removing the quotes can never yield a display-safe name.
460
+ let body = '';
461
+ let used = 0;
462
+ for (const token of escaped.match(/\\(?:["\\]|u[0-9A-F]{4})|[\s\S]/gu) ?? []) {
463
+ if (used >= bound)
464
+ break;
465
+ body += token;
466
+ used += Array.from(token).length;
467
+ }
468
+ return `"${body}\u2026"`;
469
+ }
470
+ /**
471
+ * Resolve parsed entries by exact string membership. The caller builds both
472
+ * sets from the manifest's recorded `files[]` (`resolvable` = what the SUMMARY
473
+ * tree may list; `withheld` = the credential-pattern files it omits). No path
474
+ * is ever joined, stat'ed or opened — the leaf has no filesystem — and an
475
+ * unsafe path is classified before the resolvable set is consulted, so it can
476
+ * never resolve.
477
+ *
478
+ * Order per entry: withheld → withheld · classify → unsafe · resolvable →
479
+ * resolved · else unresolved. Withheld comes FIRST (D-35, 14-SECURITY
480
+ * T-14-02): a credential file whose name is display-unsafe (`keys/a b.pem`,
481
+ * `keys/"prod".pem`) is in `files[]`, and classifying it first named it on
482
+ * the unsafe list. Withheld membership is an exact string compare that can
483
+ * only withhold more, never make an entry usable.
484
+ */
485
+ function resolveVaultIndexEntries(entries, resolvable, withheld) {
486
+ const out = [];
487
+ const counts = { entries: entries.length, resolved: 0, unresolved: 0, unsafe: 0, withheld: 0 };
488
+ for (const entry of entries) {
489
+ if (withheld.has(entry.path)) {
490
+ out.push({ ...entry, resolution: 'withheld' });
491
+ counts.withheld += 1;
492
+ continue;
493
+ }
494
+ const verdict = classifyVaultPath(entry.path);
495
+ if (!verdict.ok) {
496
+ out.push({ ...entry, resolution: 'unsafe', unsafeReason: verdict.reason });
497
+ counts.unsafe += 1;
498
+ }
499
+ else if (resolvable.has(entry.path)) {
500
+ out.push({ ...entry, resolution: 'resolved' });
501
+ counts.resolved += 1;
502
+ }
503
+ else {
504
+ out.push({ ...entry, resolution: 'unresolved' });
505
+ counts.unresolved += 1;
506
+ }
507
+ }
508
+ return { entries: out, counts };
509
+ }
510
+ // ─── Reasons (D-12) ──────────────────────────────────────────────────────────
511
+ /**
512
+ * The five degraded-index reasons. Frozen so a typo is a build error rather
513
+ * than a verdict that silently reports a different code. `UNRESOLVABLE` exists
514
+ * so a record cannot claim N governing entries when none of them names a file
515
+ * in the fetched tree (T-14-12). `UNCHECKED` (14-REVIEW iteration 2, WR-02) is
516
+ * the fail-closed arm: the engine could not run the check at all, so the record
517
+ * states that and claims nothing about INDEX.md itself — reusing
518
+ * `vault_index_unparseable` would record a fault in a file that was never read.
519
+ */
520
+ exports.VAULT_INDEX_REASON = Object.freeze({
521
+ MISSING: 'vault_index_missing',
522
+ UNPARSEABLE: 'vault_index_unparseable',
523
+ EMPTY: 'vault_index_empty',
524
+ UNRESOLVABLE: 'vault_index_unresolvable',
525
+ UNCHECKED: 'vault_index_unchecked',
526
+ });
527
+ /** The measured causes behind `vault_index_unparseable`: each one is a fact about the bytes read. */
528
+ exports.VAULT_INDEX_UNPARSEABLE_CAUSES = Object.freeze(['oversize', 'nul', 'invalid_utf8', 'unreadable']);
529
+ /**
530
+ * The measured causes behind `vault_index_unchecked` (14-REVIEW WR-01, and
531
+ * iteration 2 WR-02): the cached manifest records no file list to check
532
+ * against, or the check faulted after the manifest was read. Neither says
533
+ * anything about INDEX.md, so neither sentence does.
534
+ */
535
+ exports.VAULT_INDEX_UNCHECKED_CAUSES = Object.freeze(['no_file_list', 'check_faulted']);
536
+ const REASON_SHORT = Object.freeze({
537
+ [exports.VAULT_INDEX_REASON.MISSING]: 'missing',
538
+ [exports.VAULT_INDEX_REASON.UNPARSEABLE]: 'unparseable',
539
+ [exports.VAULT_INDEX_REASON.EMPTY]: 'empty',
540
+ [exports.VAULT_INDEX_REASON.UNRESOLVABLE]: 'unresolvable',
541
+ [exports.VAULT_INDEX_REASON.UNCHECKED]: 'unchecked',
542
+ });
543
+ const CAUSE_PHRASE = Object.freeze({
544
+ oversize: 'it is larger than the 256 KiB cap',
545
+ nul: 'it contains a NUL byte',
546
+ invalid_utf8: 'it is not valid UTF-8',
547
+ unreadable: 'it could not be opened as a regular file',
548
+ });
549
+ const UNCHECKED_CAUSE_PHRASE = Object.freeze({
550
+ no_file_list: 'the cached manifest records no file list to check it against',
551
+ check_faulted: 'the check faulted after the cached manifest was read',
552
+ });
553
+ // A case variant is a vault filename: bounded hard, it only needs to be recognisable.
554
+ const CASE_VARIANT_MAX = 80;
555
+ /** The short form of a reason (the envelope's `indexStatus` value). */
556
+ function vaultIndexReasonShort(reason) {
557
+ return REASON_SHORT[reason];
558
+ }
559
+ /**
560
+ * One sentence per reason, each naming INDEX.md, with no trailing period (the
561
+ * messages that embed it add their own). Only the measured cause is stated.
562
+ * An unknown code yields a generic sentence; the code itself is never echoed.
563
+ */
564
+ function vaultIndexReasonText(reason, detail = {}) {
565
+ const d = detail && typeof detail === 'object' ? detail : {};
566
+ switch (reason) {
567
+ case exports.VAULT_INDEX_REASON.MISSING: {
568
+ const base = 'no file named exactly INDEX.md at the vault root is listed in the fetched tree';
569
+ const variant = typeof d.caseVariant === 'string' ? sanitiseVaultText(d.caseVariant, CASE_VARIANT_MAX) : '';
570
+ return variant === '' ? base : `${base} (found \`${variant}\`; the engine reads INDEX.md exactly)`;
571
+ }
572
+ case exports.VAULT_INDEX_REASON.UNPARSEABLE: {
573
+ const base = 'INDEX.md could not be read as text';
574
+ const cause = typeof d.cause === 'string' && Object.prototype.hasOwnProperty.call(CAUSE_PHRASE, d.cause)
575
+ ? CAUSE_PHRASE[d.cause]
576
+ : '';
577
+ return cause === '' ? base : `${base}: ${cause}`;
578
+ }
579
+ case exports.VAULT_INDEX_REASON.EMPTY:
580
+ return 'INDEX.md parsed with zero entries — no line of the form - `path` — description';
581
+ case exports.VAULT_INDEX_REASON.UNRESOLVABLE:
582
+ return 'INDEX.md lists entries but none of them names a file in the fetched tree';
583
+ case exports.VAULT_INDEX_REASON.UNCHECKED: {
584
+ // States only what was measured: the check did not run. INDEX.md may be
585
+ // perfectly valid, so no sentence here says it was read or is at fault.
586
+ const base = 'the engine could not check INDEX.md against the cached file list';
587
+ const cause = typeof d.cause === 'string' && Object.prototype.hasOwnProperty.call(UNCHECKED_CAUSE_PHRASE, d.cause)
588
+ ? UNCHECKED_CAUSE_PHRASE[d.cause]
589
+ : '';
590
+ return cause === '' ? base : `${base}: ${cause}`;
591
+ }
592
+ default:
593
+ return 'INDEX.md is degraded';
594
+ }
595
+ }
596
+ /**
597
+ * The degraded-index WARNING (policy off, D-13): two lines, the second naming
598
+ * the repair. Planning continues; nothing here is a block. The unchecked arm
599
+ * (14-REVIEW iteration 2, WR-02) names the local repair — re-fetch, then
600
+ * report a persisting fault — and never sends the vault owner to edit a file
601
+ * the check did not read.
602
+ */
603
+ function vaultIndexWarningMessage(reason, detail) {
604
+ if (reason === exports.VAULT_INDEX_REASON.UNCHECKED) {
605
+ return [
606
+ `⚠ Vault (standards mode): the index check did not run — ${vaultIndexReasonText(reason, detail)}.`,
607
+ 'Planning continues without a checked index; with vault.required off this is a warning. '
608
+ + 'Remedy: re-run pp-tools vault fetch, then pp-tools check vault.status; if it persists, report it as an engine fault.',
609
+ ].join('\n');
610
+ }
611
+ return [
612
+ `⚠ Vault (standards mode): the index is degraded — ${vaultIndexReasonText(reason, detail)}.`,
613
+ 'Planning continues without a usable INDEX.md; with vault.required off this is a warning. '
614
+ + "Remedy: fix INDEX.md in the vault repo (the vault owner's), then pp-tools vault fetch "
615
+ + 'and re-run pp-tools check vault.status.',
616
+ ].join('\n');
617
+ }
618
+ /**
619
+ * The degraded-index BLOCK message (policy on, D-13), in the register of
620
+ * `buildVaultBlockMessage`: pure-read diagnosis first, then repair-only
621
+ * remedies. No remedy names deleting a file or a cache — a deletion remedy
622
+ * would let an operator clear the evidence instead of fixing the index
623
+ * (memory: gate-message-remedy-hazard).
624
+ */
625
+ function vaultIndexBlockMessage(reason, detail) {
626
+ if (reason === exports.VAULT_INDEX_REASON.UNCHECKED) {
627
+ return [
628
+ `vault.required is on and the client vault is loaded but the engine could not check its index: ${vaultIndexReasonText(reason, detail)}.`,
629
+ 'Diagnose (pure reads — none of these can change this verdict):',
630
+ ' pp-tools check vault.status --raw # this verdict, with the index fields',
631
+ "Remedy (repair only — re-fetching rewrites the cached manifest the check reads):",
632
+ ' pp-tools vault fetch, then re-run the gate; if it persists, report it as an engine fault',
633
+ ' or pp-tools config set vault.mode practices # if this project is not on standards mode yet',
634
+ ' or set PP_VAULT_OVERRIDE_REASON to a substantive explanation for an attributed, recorded override',
635
+ ].join('\n');
636
+ }
637
+ return [
638
+ `vault.required is on and the client vault is loaded but its INDEX.md is degraded: ${vaultIndexReasonText(reason, detail)}.`,
639
+ 'Diagnose (pure reads — none of these can change this verdict):',
640
+ ' pp-tools check vault.status --raw # this verdict, with the index fields',
641
+ ' pp-tools vault summary # the INDEX section names what did and did not resolve',
642
+ "Remedy (repair only — the index is the vault owner's):",
643
+ ' fix INDEX.md in the vault repo, then pp-tools vault fetch and re-run the gate',
644
+ ' or pp-tools config set vault.mode practices # if this project is not on standards mode yet',
645
+ ' or set PP_VAULT_OVERRIDE_REASON to a substantive explanation for an attributed, recorded override',
646
+ ].join('\n');
647
+ }
648
+ /**
649
+ * What a withheld entry names, ONE text for the per-entry sentence and the
650
+ * SUMMARY aggregate line (14-REVIEW IN-01). The withheld set is not only
651
+ * credential-pattern files: the engine-written `SUMMARY.md` and `INDEX.md`
652
+ * itself (14-REVIEW WR-03) are withheld too, and the old "(credential
653
+ * pattern)" label was untrue for them.
654
+ */
655
+ exports.VAULT_INDEX_WITHHELD_KINDS = '(INDEX.md itself, SUMMARY.md or a credential-pattern file)';
656
+ /**
657
+ * Why an entry did or did not resolve — the "why" SUMMARY's Not resolvable
658
+ * list prints. A withheld entry's sentence names no path (VSEC-03 count-only
659
+ * discipline). A trailing-slash path is a directory, not a document (D-08).
660
+ */
661
+ function vaultIndexResolutionText(entry) {
662
+ const e = entry && typeof entry === 'object' ? entry : {};
663
+ switch (e.resolution) {
664
+ case 'resolved':
665
+ return 'resolved';
666
+ case 'unresolved':
667
+ return 'not in the fetched tree';
668
+ case 'withheld':
669
+ return `names a file withheld from this listing ${exports.VAULT_INDEX_WITHHELD_KINDS}`;
670
+ case 'unsafe': {
671
+ const reason = exports.VAULT_PATH_REASONS.includes(String(e.unsafeReason))
672
+ ? String(e.unsafeReason)
673
+ : 'unclassified';
674
+ if (reason === 'empty_segment' && typeof e.path === 'string' && e.path.endsWith('/')) {
675
+ return 'names a directory, not a file — not readable as a document';
676
+ }
677
+ if (reason === 'display_unsafe') {
678
+ return 'cannot be displayed as written (a format, zero-width or non-space whitespace character, a double quote, a space run or padding, or over-length) — shown quoted and escaped, not readable as a document';
679
+ }
680
+ return `unsafe path (${reason})`;
681
+ }
682
+ default:
683
+ return 'not resolved';
684
+ }
685
+ }
686
+ // ─── Card line (D-15, D-28) ──────────────────────────────────────────────────
687
+ /**
688
+ * The one-line standards card shown on a healthy standards verdict. Every
689
+ * field arrives already sanitised (repo and ref through the router's manifest
690
+ * sanitiser); this function neither sanitises nor reads config. The suffix
691
+ * appears only when at least one entry is not usable. It says "not usable",
692
+ * not "unresolved" (14-REVIEW iteration 2, IN-01): the count is every entry
693
+ * that did not resolve — absent, unsafe or withheld — while the envelope's
694
+ * `indexUnresolved` lists the absent ones only, so one word must not mean both.
695
+ */
696
+ function vaultStandardsCardLine(f) {
697
+ const line = `Vault (standards mode): ${f.repo}@${f.ref} sha ${f.sha8} — ${f.fileCount} files; INDEX.md: ${f.indexEntries} entries`;
698
+ return f.unusable > 0 ? `${line} (${f.unusable} not usable — see .planning/vault/SUMMARY.md)` : line;
699
+ }