session-orchestrator 3.19.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 (66) 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/CHANGELOG.md +80 -0
  5. package/README.md +9 -9
  6. package/commands/session.md +6 -2
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/instruction-delivery.md +350 -0
  9. package/docs/session-config-reference.md +1 -41
  10. package/docs/session-config-template.md +0 -23
  11. package/hooks/_lib/guard-source-loader.mjs +304 -91
  12. package/hooks/enforce-commands.mjs +216 -17
  13. package/hooks/enforce-scope.mjs +133 -9
  14. package/hooks/hooks-codex.json +1 -1
  15. package/hooks/hooks.json +1 -1
  16. package/hooks/on-session-start.mjs +7 -4
  17. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  18. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  19. package/package.json +2 -2
  20. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  21. package/scripts/emit-session.mjs +3 -40
  22. package/scripts/lib/command-blocker.mjs +322 -62
  23. package/scripts/lib/hardening.mjs +9 -9
  24. package/scripts/lib/learnings/affinity.mjs +434 -0
  25. package/scripts/lib/learnings/candidates.mjs +736 -0
  26. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  27. package/scripts/lib/learnings/judgment.mjs +782 -0
  28. package/scripts/lib/learnings/kebab.mjs +128 -0
  29. package/scripts/lib/learnings/select.mjs +550 -0
  30. package/scripts/lib/reconcile/emitter.mjs +107 -22
  31. package/scripts/lib/reconcile/engine.mjs +9 -15
  32. package/scripts/lib/reconcile/renderer.mjs +141 -25
  33. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  34. package/scripts/lib/reconcile/writer.mjs +95 -1
  35. package/scripts/lib/scope-gate.mjs +194 -72
  36. package/scripts/lib/session-close-backfill.mjs +2 -2
  37. package/scripts/lib/session-record-repair.mjs +551 -0
  38. package/scripts/lib/session-schema/serializer.mjs +54 -0
  39. package/scripts/lib/session-schema.mjs +1 -0
  40. package/scripts/lib/session-token-rollup.mjs +68 -6
  41. package/scripts/lib/soul-resolve.mjs +12 -0
  42. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  43. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  44. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  45. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  46. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  47. package/scripts/lib/validate/check-rules.mjs +31 -5
  48. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  49. package/scripts/print-applicable-rules.mjs +170 -7
  50. package/scripts/print-learnings-index.mjs +474 -0
  51. package/scripts/repair-invalid-sessions.mjs +209 -0
  52. package/scripts/sweep-expired-learnings.mjs +192 -32
  53. package/scripts/validate-plugin.mjs +21 -0
  54. package/skills/brainstorm/soul.md +47 -1
  55. package/skills/evolve/SKILL.md +116 -18
  56. package/skills/gitlab-ops/SKILL.md +5 -0
  57. package/skills/grill/soul.md +44 -1
  58. package/skills/plan/soul.md +46 -3
  59. package/skills/session-end/SKILL.md +1 -24
  60. package/skills/session-end/phase-3-6-tail.md +30 -1
  61. package/skills/session-end/plan-verification.md +1 -5
  62. package/skills/session-end/session-metrics-write.md +2 -0
  63. package/skills/session-start/SKILL.md +2 -0
  64. package/skills/session-start/soul.md +41 -1
  65. package/skills/wave-executor/SKILL.md +1 -5
  66. package/skills/wave-executor/wave-loop.md +36 -71
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * Reads `.orchestrator/metrics/subagents.jsonl` (or a caller-supplied path),
5
5
  * filters to a given `parent_session_id`, and sums `token_input` /
6
- * `token_output` across all matched records, skipping null/undefined values.
6
+ * `token_output` across the records whose token fields are TRUSTWORTHY — see
7
+ * § Token provenance below, which is the whole reason this module is not a
8
+ * two-line sum.
7
9
  *
8
10
  * Design notes:
9
11
  * - Pure function — no top-level side effects, no writes.
@@ -12,7 +14,44 @@
12
14
  * "session was genuinely free / cost $0".
13
15
  * - Malformed JSONL lines are silently skipped (resilience over strictness).
14
16
  * - `subagents_with_tokens` counts distinct agent_ids that have at least one
15
- * record with a non-null token_input or token_output value (coverage metric).
17
+ * TOKEN-BEARING record (coverage metric).
18
+ *
19
+ * ## Token provenance — why a bare Σ over token_input is wrong (#949)
20
+ *
21
+ * Two record classes in this ledger carry a `token_input` that must NEVER be
22
+ * summed, and both look identical to a naive reader:
23
+ *
24
+ * 1. **Pre-#949 records** (written before 2026-07-31). The producer read the
25
+ * PARENT session transcript instead of the subagent's own, so every stop
26
+ * record carries the parent's running totals. Summing them counts the parent
27
+ * once per subagent. `hooks/subagent-telemetry.mjs` § TOKEN-DATA PROVENANCE
28
+ * states the consumer obligation outright: "Consumers MUST discard token_* on
29
+ * every stop record written before this fix landed."
30
+ * 2. **Phantom stops** (#939). The harness fires `SubagentStop` for an ephemeral
31
+ * agent class that never fires `SubagentStart` and for which no subagent ever
32
+ * existed. These carry null tokens today — harmless to sum, but they inflate
33
+ * any coverage ratio computed against `matched_records`.
34
+ *
35
+ * `subagent_transcript_found === true` settles both at once and is the flag the
36
+ * producer writes for exactly this purpose. It is a sufficient cutoff on its own:
37
+ * the field did not exist before the #949 fix, so `=== true` excludes every
38
+ * pre-fix record without needing a date comparison.
39
+ *
40
+ * Measured over this repo's ledger on 2026-08-11 (3,981 records / 116 sessions):
41
+ * 73 sessions summed to 96,148,781 tokens that no agent ever spent — every one of
42
+ * them a pre-#949 parent total. Under this filter those sessions correctly report
43
+ * null ("no token data") instead.
44
+ *
45
+ * jq -r 'select(.event=="stop" and .subagent_transcript_found==true and .token_input==null)' \
46
+ * .orchestrator/metrics/subagents.jsonl | wc -l # → 0
47
+ *
48
+ * i.e. the flag never excludes a record that genuinely had tokens.
49
+ *
50
+ * FORWARD-ONLY. Session totals already written into `sessions.jsonl` by the
51
+ * unfiltered recipe are NOT recomputed — that ledger is append-only and the
52
+ * transcripts that produced the oldest records have aged out, so a rewrite would
53
+ * be reconstruction, not correction. Consumers comparing token totals across the
54
+ * 2026-08-11 boundary must treat it as a series break.
16
55
  *
17
56
  * @module session-token-rollup
18
57
  */
@@ -30,12 +69,28 @@ const DEFAULT_SUBAGENTS_PATH = '.orchestrator/metrics/subagents.jsonl';
30
69
  // Public API
31
70
  // ---------------------------------------------------------------------------
32
71
 
72
+ /**
73
+ * Is this record's token data trustworthy enough to sum? (#949)
74
+ *
75
+ * The producer sets `subagent_transcript_found: true` only when it located and
76
+ * read the subagent's OWN transcript. Every other shape — a phantom stop, a
77
+ * start record, or any record written before the flag existed — is excluded.
78
+ * See the module header § Token provenance for why this single flag is a
79
+ * sufficient cutoff and what it costs to omit it.
80
+ *
81
+ * @param {object} record — a parsed subagents.jsonl record
82
+ * @returns {boolean}
83
+ */
84
+ function isTokenBearing(record) {
85
+ return record?.subagent_transcript_found === true;
86
+ }
87
+
33
88
  /**
34
89
  * @typedef {Object} TokenRollupResult
35
- * @property {number|null} total_token_input - Sum of token_input across matched records; null when no record had a non-null value.
36
- * @property {number|null} total_token_output - Sum of token_output across matched records; null when no record had a non-null value.
37
- * @property {number} subagents_with_tokens - Count of distinct agent_ids that had at least one non-null token value.
38
- * @property {number} matched_records - Total count of JSONL records matched by parentSessionId (includes null-token records).
90
+ * @property {number|null} total_token_input - Sum of token_input across TOKEN-BEARING matched records; null when none had a non-null value.
91
+ * @property {number|null} total_token_output - Sum of token_output across TOKEN-BEARING matched records; null when none had a non-null value.
92
+ * @property {number} subagents_with_tokens - Count of distinct agent_ids with at least one token-bearing record. This is the numerator of the honest coverage ratio.
93
+ * @property {number} matched_records - Total count of JSONL records matched by parentSessionId. Counts start records, phantom stops and pre-#949 records alike, so it is NOT the denominator for a token-coverage ratio — dividing by it is what made healthy sessions read as 12% covered.
39
94
  */
40
95
 
41
96
  /**
@@ -107,6 +162,13 @@ export function rollupSessionTokens({
107
162
  const agentsWithTokens = new Set();
108
163
 
109
164
  for (const record of matched) {
165
+ // Provenance gate (#949) — a record whose tokens describe the PARENT
166
+ // transcript, or no transcript at all, contributes nothing. Skipping it
167
+ // entirely (rather than treating its values as 0) preserves the null
168
+ // sentinel: a session of only untrustworthy records reports "no data",
169
+ // which is true, instead of a fabricated 0.
170
+ if (!isTokenBearing(record)) continue;
171
+
110
172
  const inp = record.token_input;
111
173
  const out = record.token_output;
112
174
 
@@ -5,6 +5,18 @@
5
5
  * config loaded via `owner-yaml.mjs` (D1). Pure at the `resolveSoul` level;
6
6
  * `loadAndResolveSoul` performs disk I/O.
7
7
  *
8
+ * ── NO RUNTIME CALLER — read this before assuming a soul.md is resolved ──────
9
+ *
10
+ * Nothing in `scripts/`, `hooks/`, or any skill body calls either export. Skill
11
+ * bodies instruct the coordinator to read soul.md DIRECTLY, so whatever is in
12
+ * the file on disk is what the coordinator sees — an unsubstituted `{{slot}}`
13
+ * reaches the model verbatim and instructs nothing. `skills/session-start/soul.md`
14
+ * is therefore authored pre-resolved: it carries no slots, and the operator's
15
+ * `efficiency.output-level` selects one of its literal `### output-level: <value>`
16
+ * blocks (see that file's § Output Levels; the skill body performs the lookup).
17
+ * Slots that remain in other soul.md files are inert for the same reason.
18
+ * Do not add a slot to a soul.md expecting substitution — wire a caller first.
19
+ *
8
20
  * ── Slot syntax ──────────────────────────────────────────────────────────────
9
21
  *
10
22
  * {{owner.language}} → 'de' | 'en'
@@ -18,25 +18,52 @@
18
18
  import { appendFileSync, mkdirSync, existsSync } from 'node:fs';
19
19
  import path from 'node:path';
20
20
 
21
- const EVENTS_PATH = '.orchestrator/metrics/events.jsonl';
21
+ import { findProjectRoot } from '../common.mjs';
22
22
 
23
23
  /**
24
- * Emit a single tmux-layout event to events.jsonl.
24
+ * Path FRAGMENT joined against a resolved repo root at write time — NOT a
25
+ * relative path constant. A relative constant resolves against process.cwd(),
26
+ * which is how ~8k test-emitted tmux events landed in the real ledger: the
27
+ * suite spawns scripts/tmux-layout.mjs with cwd = repo root, so every
28
+ * telemetry write went straight into production telemetry.
29
+ * Same shape as scripts/lib/session-close-backfill.mjs § EVENTS_REL.
30
+ */
31
+ const EVENTS_REL = ['.orchestrator', 'metrics', 'events.jsonl'];
32
+
33
+ /**
34
+ * True when this process is a vitest run, or a child spawned by one
35
+ * (vitest sets VITEST=true and the child inherits process.env).
36
+ * @returns {boolean}
37
+ */
38
+ function isTestRunner() {
39
+ return Boolean(process.env.VITEST) || process.env.VITEST_WORKER_ID !== undefined;
40
+ }
41
+
42
+ /**
43
+ * Emit a single tmux-layout event to <repoRoot>/.orchestrator/metrics/events.jsonl.
25
44
  * Best-effort — never throws (telemetry must not block the layout itself).
26
45
  *
46
+ * Under a test runner an emit WITHOUT an explicit `repoRoot` is dropped: a test
47
+ * process has no business appending to a real ledger, and telemetry is
48
+ * best-effort by contract, so dropping is the correct degradation. Tests that
49
+ * assert on the write pass `repoRoot` and get the full write path.
50
+ *
27
51
  * @param {string} eventType - 'tmux-layout.invoked' | 'tmux-layout.degraded' | 'tmux-layout.completed'
28
52
  * @param {object} [payload] - additional fields (layout, duration_ms, reason, etc.)
53
+ * @param {{ repoRoot?: string }} [opts] - repoRoot the ledger is resolved against (default: findProjectRoot())
29
54
  */
30
- export function emit(eventType, payload = {}) {
55
+ export function emit(eventType, payload = {}, { repoRoot } = {}) {
31
56
  try {
32
- const dir = path.dirname(EVENTS_PATH);
57
+ if (!repoRoot && isTestRunner()) return;
58
+ const eventsPath = path.join(repoRoot || findProjectRoot(), ...EVENTS_REL);
59
+ const dir = path.dirname(eventsPath);
33
60
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
34
61
  const record = {
35
62
  event: eventType,
36
63
  timestamp: new Date().toISOString(),
37
64
  ...payload,
38
65
  };
39
- appendFileSync(EVENTS_PATH, JSON.stringify(record) + '\n');
66
+ appendFileSync(eventsPath, JSON.stringify(record) + '\n');
40
67
  } catch {
41
68
  // Best-effort — swallow all errors. Telemetry must not block layout.
42
69
  }
@@ -45,18 +72,24 @@ export function emit(eventType, payload = {}) {
45
72
  /**
46
73
  * Wrap a layout function with telemetry. Emits invoked → completed/degraded.
47
74
  *
75
+ * The repoRoot is taken ONLY from this explicit option — never derived from the
76
+ * wrapped call's own `projectRoot` argument. Deriving it would hand the spawned
77
+ * CLI an explicit root inside the test suite and re-open the exact
78
+ * production-ledger contamination path the emit() guard closes.
79
+ *
48
80
  * @param {string} layoutName - 'default' | 'debug'
49
81
  * @param {Function} fn - async function returning { ok, oneliner, panes, degraded, attachCommand, error? }
82
+ * @param {{ repoRoot?: string }} [opts] - repoRoot the ledger is resolved against (default: findProjectRoot())
50
83
  * @returns {Function} wrapped function with same signature
51
84
  * @throws {TypeError} synchronously when fn is not a function
52
85
  */
53
- export function withTelemetry(layoutName, fn) {
86
+ export function withTelemetry(layoutName, fn, { repoRoot } = {}) {
54
87
  if (typeof fn !== 'function') {
55
88
  throw new TypeError(`withTelemetry: fn must be a function (got ${typeof fn})`);
56
89
  }
57
90
  return async function telemetryWrapped(...args) {
58
91
  const startedAt = Date.now();
59
- emit('tmux-layout.invoked', { layout: layoutName });
92
+ emit('tmux-layout.invoked', { layout: layoutName }, { repoRoot });
60
93
  try {
61
94
  const result = await fn(...args);
62
95
  const durationMs = Date.now() - startedAt;
@@ -66,13 +99,13 @@ export function withTelemetry(layoutName, fn) {
66
99
  duration_ms: durationMs,
67
100
  panes: result.panes ?? null,
68
101
  degraded: result.degraded === true,
69
- });
102
+ }, { repoRoot });
70
103
  } else {
71
104
  emit('tmux-layout.degraded', {
72
105
  layout: layoutName,
73
106
  duration_ms: durationMs,
74
107
  reason: result?.error ?? 'unknown',
75
- });
108
+ }, { repoRoot });
76
109
  }
77
110
  return result;
78
111
  } catch (err) {
@@ -81,7 +114,7 @@ export function withTelemetry(layoutName, fn) {
81
114
  layout: layoutName,
82
115
  duration_ms: durationMs,
83
116
  reason: `exception: ${err?.message ?? String(err)}`,
84
- });
117
+ }, { repoRoot });
85
118
  throw err;
86
119
  }
87
120
  };
@@ -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
+ }