session-orchestrator 3.17.0 → 3.20.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 (221) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Invariant canary for the #621 HISTORICAL guard banner.
4
+ *
5
+ * The banner is a single canonical literal exported as `HISTORICAL_GUARD_BANNER`
6
+ * from `scripts/lib/historical-guard.mjs` (the SSOT), and it is ALSO quoted as
7
+ * prose in the skill bodies that instruct the coordinator to render it. A reword
8
+ * on the prose side silently diverges from the code constant and weakens the
9
+ * stale-replay guard, because nothing recompiles when markdown changes.
10
+ *
11
+ * This check pins cross-file parity: every prose site that still carries the
12
+ * banner marker must reproduce the SSOT literal byte-for-byte (or an explicitly
13
+ * elided prefix of it — see `classifyBannerSite`).
14
+ *
15
+ * Why per-SITE and not per-FILE: a file-wide `includes(BANNER)` assertion stays
16
+ * green while five of six sites in the same file rot, because one intact copy
17
+ * satisfies it. Each occurrence is therefore judged independently.
18
+ *
19
+ * Deliberate non-goal — this check never pins a SITE COUNT. Sites may legitimately
20
+ * be added or removed; only DIVERGENCE is an error. Whole-site DELETION is covered
21
+ * by the placement tests in `tests/skills/session-start/` (SKILL.md only —
22
+ * presentation-format.md has no placement coverage; for that file this check is
23
+ * the only divergence gate).
24
+ *
25
+ * Known residuals, named so nobody over-reads the coverage claim:
26
+ * - Detection is marker-gated on TWO SSOT-derived phrases (`DETECTION_MARKERS`).
27
+ * A reword that destroys BOTH phrases removes the site from the census — that
28
+ * degenerate case is indistinguishable from whole-site deletion (see above).
29
+ * - Judging is per-LINE: two banner copies on ONE physical line are satisfied
30
+ * by the intact copy (unlikely in prose; accepted).
31
+ * - Only `SCAN_DIRS` markdown is censused; a future banner copy in e.g.
32
+ * `agents/` or `docs/` would be invisible until the dir is added here.
33
+ * - Authoring constraint (fail-closed, not fail-open): the elided form must be
34
+ * the whole normalized line (canonical prefix + elision marker), and a
35
+ * soft-wrapped multi-line banner is reported as divergent.
36
+ *
37
+ * Reading is done with `readFileSync`, never a `grep` spawn: a single NUL byte
38
+ * makes a text file invisible to grep-based audits (see
39
+ * `.claude/rules/anti-pattern-a-nul-byte-in-a-tracked-production-file-...md`),
40
+ * which would silently drop a rotted site from the census.
41
+ *
42
+ * Import-safety: importing this module only exposes the inspector and runner;
43
+ * the CLI path is guarded at the bottom of the file.
44
+ */
45
+
46
+ import { execFileSync } from 'node:child_process';
47
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
48
+ import path from 'node:path';
49
+ import { pathToFileURL } from 'node:url';
50
+ import { HISTORICAL_GUARD_BANNER } from '../historical-guard.mjs';
51
+
52
+ /** Directories whose markdown quotes the banner as coordinator-facing prose. */
53
+ const SCAN_DIRS = Object.freeze(['skills', 'commands']);
54
+
55
+ /** Only markdown carries prose copies of the banner. */
56
+ const MARKDOWN_EXT = '.md';
57
+
58
+ /**
59
+ * `git` is invoked with a filtered environment so an ambient `GIT_DIR` cannot
60
+ * redirect enumeration at a foreign repository — that would silently census the
61
+ * wrong file set and pass vacuously.
62
+ */
63
+ const GIT_ENV_ALLOWLIST = Object.freeze(['PATH', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', 'TZ']);
64
+
65
+ /**
66
+ * Load-bearing first sentence of the canonical banner, derived from the SSOT.
67
+ * An elided quote may never truncate below this — dropping "NOT LIVE
68
+ * INSTRUCTIONS" removes the entire guard force of the banner.
69
+ */
70
+ export const BANNER_FIRST_SENTENCE = HISTORICAL_GUARD_BANNER.slice(
71
+ 0,
72
+ HISTORICAL_GUARD_BANNER.indexOf('.') + 1,
73
+ );
74
+
75
+ /**
76
+ * Substring that marks a line as "this line is quoting the banner", derived from
77
+ * the SSOT rather than hardcoded — a second hardcoded copy of banner text inside
78
+ * the drift checker would be the very drift class this check exists to catch.
79
+ */
80
+ export const DETECTION_MARKER = BANNER_FIRST_SENTENCE.replace(/^[^A-Z]+/, '').split(' — ')[0];
81
+
82
+ /**
83
+ * Detection is a DISJUNCTION of two SSOT-derived phrases: a reword that destroys
84
+ * one marker must not hide the site from the census — only destroying both does,
85
+ * and that degenerate case equals whole-site deletion (owned by the placement
86
+ * tests for SKILL.md). Both tokens derive from the SSOT at import time.
87
+ */
88
+ export const DETECTION_MARKERS = Object.freeze([
89
+ DETECTION_MARKER,
90
+ BANNER_FIRST_SENTENCE.split(' — ')[1].replace(/\.$/, ''),
91
+ ]);
92
+
93
+ /** @param {string} text @returns {boolean} whether any detection marker is present */
94
+ function hasDetectionMarker(text) {
95
+ return DETECTION_MARKERS.some((marker) => text.includes(marker));
96
+ }
97
+
98
+ /** Explicit elision markers a prose site may use to shorten the quote. */
99
+ const ELISION_MARKERS = Object.freeze(['…', '...']);
100
+
101
+ /** Offending-text budget in the reported finding (keeps stdout bounded). */
102
+ const QUOTE_BUDGET = 160;
103
+
104
+ /**
105
+ * @typedef {{
106
+ * kind: string,
107
+ * file: string,
108
+ * line: number,
109
+ * message: string,
110
+ * }} Finding
111
+ */
112
+
113
+ /**
114
+ * @typedef {{
115
+ * file: string,
116
+ * line: number,
117
+ * form: 'full' | 'elided',
118
+ * }} BannerSite
119
+ */
120
+
121
+ /**
122
+ * Recursively collect markdown files in deterministic path order.
123
+ *
124
+ * Symlinked entries are never followed: a symlink is an operator-controlled path
125
+ * escape, and following one would scan a file outside the plugin root.
126
+ *
127
+ * @param {string} directory absolute directory path
128
+ * @returns {string[]} absolute markdown file paths, sorted
129
+ */
130
+ function walkMarkdown(directory) {
131
+ if (!existsSync(directory)) return [];
132
+ /** @type {string[]} */
133
+ const files = [];
134
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
135
+ if (entry.isSymbolicLink()) continue;
136
+ const fullPath = path.join(directory, entry.name);
137
+ if (entry.isDirectory()) {
138
+ files.push(...walkMarkdown(fullPath));
139
+ } else if (entry.isFile() && path.extname(entry.name) === MARKDOWN_EXT) {
140
+ files.push(fullPath);
141
+ }
142
+ }
143
+ return files.sort();
144
+ }
145
+
146
+ /**
147
+ * Resolve a path through symlinks, falling back to the input when it does not
148
+ * exist (so a missing root is reported by the caller, not thrown here).
149
+ *
150
+ * @param {string} target
151
+ * @returns {string}
152
+ */
153
+ function safeRealpath(target) {
154
+ try {
155
+ return realpathSync(target);
156
+ } catch {
157
+ return target;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Enumerate tracked markdown under the scan directories.
163
+ *
164
+ * `git ls-files` is the primary source (tracked-only, so scratch drafts do not
165
+ * fail the build); it is trusted ONLY when `pluginRoot` is itself the repository
166
+ * toplevel. When the root sits inside some other repository — or git is
167
+ * unavailable — enumeration falls back to a recursive filesystem walk rather
168
+ * than censusing a foreign file set.
169
+ *
170
+ * @param {string} pluginRoot absolute plugin root
171
+ * @returns {string[]} absolute markdown file paths, sorted
172
+ */
173
+ export function collectMarkdownFiles(pluginRoot) {
174
+ const env = {};
175
+ for (const key of GIT_ENV_ALLOWLIST) {
176
+ if (process.env[key] !== undefined) env[key] = process.env[key];
177
+ }
178
+
179
+ try {
180
+ const toplevel = execFileSync('git', ['rev-parse', '--show-toplevel'], {
181
+ cwd: pluginRoot,
182
+ encoding: 'utf8',
183
+ stdio: ['ignore', 'pipe', 'ignore'],
184
+ env,
185
+ }).trim();
186
+ if (toplevel && safeRealpath(toplevel) === safeRealpath(pluginRoot)) {
187
+ const output = execFileSync('git', ['ls-files', '-z', '--', ...SCAN_DIRS], {
188
+ cwd: pluginRoot,
189
+ encoding: 'utf8',
190
+ stdio: ['ignore', 'pipe', 'ignore'],
191
+ env,
192
+ });
193
+ return output
194
+ .split('\0')
195
+ .filter(Boolean)
196
+ .filter((relative) => path.extname(relative) === MARKDOWN_EXT)
197
+ .map((relative) => path.join(pluginRoot, relative))
198
+ .filter((absolute) => {
199
+ try {
200
+ return lstatSync(absolute).isFile();
201
+ } catch {
202
+ return false;
203
+ }
204
+ })
205
+ .sort();
206
+ }
207
+ } catch {
208
+ // fall through to the filesystem walk
209
+ }
210
+
211
+ return SCAN_DIRS.flatMap((dir) => walkMarkdown(path.join(pluginRoot, dir))).sort();
212
+ }
213
+
214
+ /**
215
+ * Strip the markdown decoration a prose site may wrap the banner in, so the
216
+ * comparison judges banner TEXT rather than markdown formatting.
217
+ *
218
+ * Handles leading indentation, one or more `>` blockquote prefixes, and a
219
+ * surrounding backtick run. Inner backticks are deliberately preserved — the
220
+ * full-form comparison is a substring match and does not need them removed.
221
+ *
222
+ * @param {string} line raw markdown line
223
+ * @returns {string} normalized line text
224
+ */
225
+ export function normalizeQuotedLine(line) {
226
+ let text = line.trim();
227
+ while (text.startsWith('>')) text = text.slice(1).trim();
228
+ return text.replace(/^`+/, '').replace(/`+$/, '').trim();
229
+ }
230
+
231
+ /**
232
+ * Judge one normalized banner-quoting line against the SSOT literal.
233
+ *
234
+ * Two forms are accepted:
235
+ * - `full` — the line contains the canonical banner byte-for-byte;
236
+ * - `elided` — the line is a canonical PREFIX followed by an explicit elision
237
+ * marker, and that prefix still covers the load-bearing first
238
+ * sentence. Render-template examples legitimately shorten the
239
+ * quote; a reword inside the visible part is still caught, because
240
+ * every visible character must match the SSOT.
241
+ *
242
+ * @param {string} normalized line text from `normalizeQuotedLine`
243
+ * @returns {{ok: boolean, form: 'full' | 'elided' | 'divergent'}}
244
+ */
245
+ export function classifyBannerSite(normalized) {
246
+ if (normalized.includes(HISTORICAL_GUARD_BANNER)) return { ok: true, form: 'full' };
247
+
248
+ const ellipsis = ELISION_MARKERS.find((marker) => normalized.endsWith(marker));
249
+ if (ellipsis) {
250
+ const prefix = normalized.slice(0, -ellipsis.length).trim();
251
+ const ok =
252
+ HISTORICAL_GUARD_BANNER.startsWith(prefix) && prefix.length >= BANNER_FIRST_SENTENCE.length;
253
+ return { ok, form: 'elided' };
254
+ }
255
+
256
+ return { ok: false, form: 'divergent' };
257
+ }
258
+
259
+ /**
260
+ * Inspect cross-file parity of the HISTORICAL guard banner.
261
+ *
262
+ * @param {string} pluginRoot absolute plugin root
263
+ * @returns {{ok: boolean, summary: {filesScanned: number, files: number, sites: number}, sites: BannerSite[], findings: Finding[], toolError: boolean}}
264
+ */
265
+ export function inspectBannerParity(pluginRoot) {
266
+ const result = {
267
+ ok: false,
268
+ summary: { filesScanned: 0, files: 0, sites: 0 },
269
+ /** @type {BannerSite[]} */
270
+ sites: [],
271
+ /** @type {Finding[]} */
272
+ findings: [],
273
+ toolError: false,
274
+ };
275
+
276
+ let markdownFiles;
277
+ try {
278
+ markdownFiles = collectMarkdownFiles(pluginRoot);
279
+ } catch (error) {
280
+ result.toolError = true;
281
+ result.findings.push({
282
+ kind: 'tool-error',
283
+ file: SCAN_DIRS.join(', '),
284
+ line: 1,
285
+ message: `cannot enumerate markdown: ${error instanceof Error ? error.message : String(error)}`,
286
+ });
287
+ return result;
288
+ }
289
+ result.summary.filesScanned = markdownFiles.length;
290
+
291
+ for (const filePath of markdownFiles) {
292
+ const relative = path.relative(pluginRoot, filePath);
293
+ let body;
294
+ try {
295
+ body = readFileSync(filePath, 'utf8');
296
+ } catch (error) {
297
+ result.toolError = true;
298
+ result.findings.push({
299
+ kind: 'tool-error',
300
+ file: relative,
301
+ line: 1,
302
+ message: `cannot read file: ${error instanceof Error ? error.message : String(error)}`,
303
+ });
304
+ continue;
305
+ }
306
+ if (!hasDetectionMarker(body)) continue;
307
+
308
+ let sitesInFile = 0;
309
+ const lines = body.split('\n');
310
+ for (let index = 0; index < lines.length; index += 1) {
311
+ const raw = lines[index];
312
+ if (!hasDetectionMarker(raw)) continue;
313
+ sitesInFile += 1;
314
+ result.summary.sites += 1;
315
+
316
+ const normalized = normalizeQuotedLine(raw);
317
+ const verdict = classifyBannerSite(normalized);
318
+ if (verdict.ok) {
319
+ result.sites.push({ file: relative, line: index + 1, form: verdict.form });
320
+ continue;
321
+ }
322
+
323
+ const quoted =
324
+ normalized.length > QUOTE_BUDGET ? `${normalized.slice(0, QUOTE_BUDGET)}…` : normalized;
325
+ result.findings.push({
326
+ kind: 'banner-divergence',
327
+ file: relative,
328
+ line: index + 1,
329
+ message:
330
+ `quoted banner diverges from HISTORICAL_GUARD_BANNER (SSOT: scripts/lib/historical-guard.mjs); ` +
331
+ `found: ${JSON.stringify(quoted)}`,
332
+ });
333
+ }
334
+ if (sitesInFile > 0) result.summary.files += 1;
335
+ }
336
+
337
+ result.ok = !result.toolError && result.findings.length === 0;
338
+ return result;
339
+ }
340
+
341
+ /**
342
+ * Run the human-readable validator CLI.
343
+ *
344
+ * @param {string} pluginRoot absolute plugin root
345
+ * @returns {number} 0 = pass, 1 = banner divergence, 2 = filesystem/tool failure
346
+ */
347
+ export function runCheckBannerParity(pluginRoot) {
348
+ console.log('--- Check: HISTORICAL guard banner parity (#621 invariant canary) ---');
349
+ const inspection = inspectBannerParity(pluginRoot);
350
+ if (inspection.ok) {
351
+ console.log(
352
+ ` PASS: ${inspection.summary.sites} banner site(s) across ${inspection.summary.files} file(s) match the SSOT literal ` +
353
+ `(${inspection.summary.filesScanned} markdown file(s) scanned)`,
354
+ );
355
+ console.log('');
356
+ console.log('Results: 1 passed, 0 failed');
357
+ return 0;
358
+ }
359
+
360
+ for (const item of inspection.findings) {
361
+ console.log(` FAIL: ${item.file}:${item.line} — ${item.message}`);
362
+ }
363
+ console.log('');
364
+ console.log(`Results: 0 passed, ${inspection.findings.length} failed`);
365
+ return inspection.toolError ? 2 : 1;
366
+ }
367
+
368
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
369
+ if (isMain) {
370
+ const pluginRoot = process.argv[2];
371
+ if (!pluginRoot) {
372
+ console.error('Usage: check-banner-parity.mjs <plugin-root>');
373
+ process.exit(2);
374
+ }
375
+ process.exit(runCheckBannerParity(path.resolve(pluginRoot)));
376
+ }