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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +80 -0
- package/README.md +9 -9
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- package/docs/session-config-reference.md +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/package.json +2 -2
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +3 -40
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/reconcile/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/scope-gate.mjs +194 -72
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +31 -5
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/evolve/SKILL.md +116 -18
- package/skills/gitlab-ops/SKILL.md +5 -0
- package/skills/grill/soul.md +44 -1
- package/skills/plan/soul.md +46 -3
- package/skills/session-end/SKILL.md +1 -24
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-start/SKILL.md +2 -0
- package/skills/session-start/soul.md +41 -1
- package/skills/wave-executor/SKILL.md +1 -5
- 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
|
|
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
|
-
*
|
|
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
|
|
36
|
-
* @property {number|null} total_token_output - Sum of token_output across matched records; null when
|
|
37
|
-
* @property {number} subagents_with_tokens - Count of distinct agent_ids
|
|
38
|
-
* @property {number} matched_records - Total count of JSONL records matched by parentSessionId
|
|
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
|
-
|
|
21
|
+
import { findProjectRoot } from '../common.mjs';
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|