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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +185 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +30 -4
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- 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/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- 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/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- 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-hooks-symmetry.mjs +244 -10
- 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 +244 -36
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- 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
|
+
}
|