ruvnet-brain 4.2.2-dev → 4.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -14
- package/bin/install.mjs +11 -2
- package/console/architecture.html +12 -14
- package/console/install-mockup.html +3 -3
- package/kb/zip-extract.mjs +8 -2
- package/package.json +7 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +1 -1
- package/plugin/hooks/hooks.json +10 -0
- package/plugin/host-adapters/claude.json +11 -0
- package/plugin/host-adapters/codex.json +11 -0
- package/plugin/mcp/managed-cli-interface.mjs +77 -10
- package/plugin/mcp/server.mjs +2 -1
- package/plugin/scripts/anticipate.sh +30 -1
- package/plugin/scripts/capability-claim-evidence.mjs +431 -0
- package/plugin/scripts/capability-inventory-receipt.mjs +225 -0
- package/plugin/scripts/capability-registry.mjs +6 -0
- package/plugin/scripts/capability-routing.mjs +70 -0
- package/plugin/scripts/continuation-gate.mjs +68 -2
- package/plugin/scripts/coverage-integrity.mjs +575 -0
- package/plugin/scripts/ground-ruvnet.sh +2 -2
- package/plugin/scripts/nightly-controller.mjs +19 -2
- package/plugin/scripts/project-progression-contract.mjs +409 -0
- package/plugin/scripts/project-progression-hook.mjs +196 -0
- package/plugin/scripts/project-progression-outbox.mjs +95 -0
- package/plugin/scripts/project-progression-session-start.mjs +183 -0
- package/plugin/scripts/project-progression-store.mjs +248 -0
- package/plugin/scripts/project-store-resolver.mjs +106 -0
- package/plugin/scripts/session-snapshot-hook.mjs +29 -1
- package/plugin/scripts/session-start-core.mjs +22 -18
- package/plugin/scripts/spend-guard.mjs +6 -1
- package/scripts/adr-072-completion.mjs +98 -0
- package/scripts/behavioral-l1-l4.mjs +11 -8
- package/scripts/brain-score.mjs +7 -2
- package/scripts/build-bundle.mjs +82 -6
- package/scripts/card-from-source.mjs +32 -6
- package/scripts/corpus-aggregates.mjs +194 -0
- package/scripts/corpus-candidate.mjs +56 -36
- package/scripts/corpus-reconcile.mjs +295 -28
- package/scripts/corpus-seed-publish.mjs +3 -3
- package/scripts/coverage-integrity.mjs +3 -0
- package/scripts/gist-receipts.mjs +183 -0
- package/scripts/git-hooks/pre-push +21 -0
- package/scripts/host-registry.mjs +118 -0
- package/scripts/independent-review-receipt.mjs +499 -0
- package/scripts/ingest-gists.mjs +34 -5
- package/scripts/ingest-new-repos.mjs +5 -0
- package/scripts/metaharness-gate.mjs +58 -0
- package/scripts/nightly-gists.sh +3 -3
- package/scripts/nightly-wrapper.sh +75 -69
- package/scripts/product-integrity-contract.mjs +156 -0
- package/scripts/public-inventory.mjs +4 -0
- package/scripts/public-verification-aggregate.mjs +342 -0
- package/scripts/public-verification-finalizer.mjs +59 -0
- package/scripts/public-verification-inputs.mjs +473 -0
- package/scripts/public-verification-lane.mjs +244 -0
- package/scripts/publication-receipt.mjs +50 -6
- package/scripts/rebuild-gists-from-receipts.mjs +50 -5
- package/scripts/release-projection.mjs +86 -0
- package/scripts/release-transaction-provider.mjs +43 -102
- package/scripts/release-transaction.mjs +139 -51
- package/scripts/release.mjs +15 -7
- package/scripts/restore-local-ingests.mjs +2 -2
- package/scripts/retrieval-canary.mjs +533 -0
- package/scripts/routing-flywheel.mjs +12 -1
- package/scripts/rvf-generation.mjs +9 -3
- package/scripts/self-update.mjs +19 -44
- package/scripts/source-coverage.mjs +415 -0
- package/scripts/source-scope-receipt.mjs +84 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/wired-check.mjs +114 -24
- package/scripts/worktree-integrity.mjs +123 -0
package/scripts/wired-check.mjs
CHANGED
|
@@ -98,11 +98,10 @@ const STANDALONE = [
|
|
|
98
98
|
['fix-workstream', 'session-supervised coordination CLI run explicitly by the integration owner or an '
|
|
99
99
|
+ 'isolated writing agent to start and hand off a fix lane. Scheduling it would violate its safety '
|
|
100
100
|
+ 'boundary: it prepares evidence but never merges, pushes, publishes, deletes, or cleans worktrees'],
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
['self-update', 'launchd nightly (out-of-repo scheduler)'],
|
|
101
|
+
['self-update', 'author-run candidate rebuild; --apply is guarded by worktree-integrity.mjs and is not scheduled'],
|
|
102
|
+
['ingest-new-repos', 'author-run corpus expansion; --apply is guarded by worktree-integrity.mjs and is not scheduled'],
|
|
104
103
|
['count-chunks', 'human-run CLI — recount + restamp chunk surfaces (--check for drift); no scheduler'],
|
|
105
|
-
['brain-stamp', 'invoked by self-update.mjs
|
|
104
|
+
['brain-stamp', 'invoked by the author-run self-update.mjs candidate builder'],
|
|
106
105
|
['lesson-promote', 'human-run CLI — promotion is manual (--apply); no scheduler yet (automation is ADR-029 #4, open)'],
|
|
107
106
|
['behavioral-l1-l4', 'behavioural harness invoked by its own test file — not a product path'],
|
|
108
107
|
// A measurement harness, not a product path: it answers "would bounding the cross-encoder pool
|
|
@@ -129,8 +128,7 @@ const STANDALONE = [
|
|
|
129
128
|
+ 'com.stuartkerr.clear-claude-tmp.plist is loaded and its ProgramArguments invoke this exact file'],
|
|
130
129
|
['nightly-gists', 'launchd nightly 21:47 (out-of-repo scheduler) — confirmed live: '
|
|
131
130
|
+ 'com.ruvnet.brain-gists.plist is loaded and its ProgramArguments invoke this exact file'],
|
|
132
|
-
['nightly-wrapper', '
|
|
133
|
-
+ 'com.ruvnet.brain-nightly.plist is loaded and its ProgramArguments invoke this exact file'],
|
|
131
|
+
['nightly-wrapper', 'author-run maintenance harness, deliberately unscheduled; refuses primary, nested, and dirty worktrees'],
|
|
134
132
|
['routing-flywheel', 'launchd nightly 04:45 --dry-run (out-of-repo scheduler) — confirmed live: '
|
|
135
133
|
+ 'com.ruvnet.routing-flywheel.plist is loaded and its ProgramArguments invoke this exact file'],
|
|
136
134
|
['install-npx-witness', 'one-shot idempotent installer for the com.ruvnet.npx-witness launchd job, '
|
|
@@ -298,8 +296,21 @@ const INVENTORY_ROOTS = [
|
|
|
298
296
|
* invoker outside the roots proves the ROOTS are incomplete — the fix is to add the root, never to
|
|
299
297
|
* write an exemption. That rule caught its own author within a minute of the gate first running.
|
|
300
298
|
*/
|
|
301
|
-
|
|
299
|
+
// `dream.config.json` is executable configuration, not documentation: the Dream compiler runs its
|
|
300
|
+
// controlPlaneProbes and evaluatorEntrypoints. Excluding it made two real scheduled callers look
|
|
301
|
+
// dead while the same scanner already trusted workflow YAML and package.json command manifests.
|
|
302
|
+
const CALLER_ROOTS = [
|
|
303
|
+
'scripts', 'plugin', 'console', 'bin', '.github', '.claude', 'package.json', 'dream.config.json',
|
|
304
|
+
];
|
|
302
305
|
const CALLER_EXTS = new Set(['.mjs', '.js', '.sh', '.json', '.html', '.yml', '.yaml']);
|
|
306
|
+
export const REQUIRED_OPERATIONAL_EXPORTS = [
|
|
307
|
+
{ rel: 'scripts/corpus-reconcile.mjs', symbol: 'syncCorpusInputs' },
|
|
308
|
+
{ rel: 'scripts/corpus-reconcile.mjs', symbol: 'materializeGistReceipts' },
|
|
309
|
+
{ rel: 'scripts/corpus-reconcile.mjs', symbol: 'observeAndMaterializeGistReceipts' },
|
|
310
|
+
{ rel: 'scripts/corpus-aggregates.mjs', symbol: 'rebuildCorpusAggregates' },
|
|
311
|
+
{ rel: 'scripts/corpus-reconcile.mjs', symbol: 'reconcileCorpusUntilStable' },
|
|
312
|
+
{ rel: 'scripts/corpus-reconcile.mjs', symbol: 'reconcileAndPrepareCorpusCandidate' },
|
|
313
|
+
];
|
|
303
314
|
|
|
304
315
|
const isTestFile = (f) => /\.(test|spec)\.(mjs|js)$/.test(path.basename(f))
|
|
305
316
|
|| f.includes(`${path.sep}tests${path.sep}`) || f.startsWith(`tests${path.sep}`);
|
|
@@ -505,7 +516,33 @@ export function callersOf(mod, files, repo = REPO) {
|
|
|
505
516
|
return hits;
|
|
506
517
|
}
|
|
507
518
|
|
|
508
|
-
export function
|
|
519
|
+
export function operationalExportAudit({ repo = REPO, required = REQUIRED_OPERATIONAL_EXPORTS } = {}) {
|
|
520
|
+
const files = callerFiles(repo);
|
|
521
|
+
const rows = required.map(({ rel, symbol }) => {
|
|
522
|
+
const sourceFile = path.join(repo, rel);
|
|
523
|
+
let source = '';
|
|
524
|
+
try { source = fs.readFileSync(sourceFile, 'utf8'); } catch { /* reported as missing below */ }
|
|
525
|
+
const quoted = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
526
|
+
const exported = new RegExp(`\\bexport\\s+(?:async\\s+)?function\\s+${quoted}\\s*\\(`).test(source);
|
|
527
|
+
if (!exported) return { rel, symbol, state: 'missing', callers: [] };
|
|
528
|
+
const declaration = new RegExp(`\\bexport\\s+(?:async\\s+)?function\\s+${quoted}\\s*\\(`, 'g');
|
|
529
|
+
const invocation = new RegExp(`\\b${quoted}\\s*\\(`);
|
|
530
|
+
const callers = [];
|
|
531
|
+
for (const file of files) {
|
|
532
|
+
const callerRel = path.relative(repo, file).split(path.sep).join('/');
|
|
533
|
+
if (isTestFile(callerRel)) continue;
|
|
534
|
+
let body = '';
|
|
535
|
+
try { body = stripComments(fs.readFileSync(file, 'utf8'), path.extname(file)); } catch { continue; }
|
|
536
|
+
body = body.replace(declaration, 'export function __operational_definition__(');
|
|
537
|
+
if (invocation.test(body)) callers.push(callerRel);
|
|
538
|
+
}
|
|
539
|
+
return { rel, symbol, state: callers.length ? 'wired' : 'unwired', callers };
|
|
540
|
+
});
|
|
541
|
+
return { rows };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function audit({ repo = REPO, standalone = STANDALONE, held = HELD,
|
|
545
|
+
operationalExports = REQUIRED_OPERATIONAL_EXPORTS } = {}) {
|
|
509
546
|
const dupes = [];
|
|
510
547
|
const seen = new Map();
|
|
511
548
|
for (const [name, why] of standalone) {
|
|
@@ -532,13 +569,15 @@ export function audit({ repo = REPO, standalone = STANDALONE, held = HELD } = {}
|
|
|
532
569
|
state = 'manual';
|
|
533
570
|
const list = names.map((n) => `\`${n}\``).join(', ');
|
|
534
571
|
why = `defined as npm script ${list} and invoked by NOTHING automated — reachable only by a `
|
|
535
|
-
+ `human typing \`npm run ${names[0]}\`.
|
|
572
|
+
+ `human typing \`npm run ${names[0]}\`. This wiring audit does not establish operational `
|
|
573
|
+
+ `correctness.`;
|
|
536
574
|
}
|
|
537
575
|
}
|
|
538
576
|
}
|
|
539
577
|
rows.push({ ...m, state, callers, ...(why ? { why } : {}) });
|
|
540
578
|
}
|
|
541
|
-
|
|
579
|
+
const operationalRows = operationalExportAudit({ repo, required: operationalExports }).rows;
|
|
580
|
+
return { rows, operationalRows, dupes, inventory: all.length };
|
|
542
581
|
}
|
|
543
582
|
|
|
544
583
|
/**
|
|
@@ -567,9 +606,12 @@ export function audit({ repo = REPO, standalone = STANDALONE, held = HELD } = {}
|
|
|
567
606
|
* exists to prevent), different surface. Two new, narrow predicates, each modelling the REAL
|
|
568
607
|
* mechanism Claude Code / the lesson dispatcher actually uses, not a generic text search:
|
|
569
608
|
*
|
|
570
|
-
* CHECK B — HOOK WIRING. Walks the real reachability chain
|
|
571
|
-
*
|
|
572
|
-
* id like "route-dispatch" is not the string
|
|
609
|
+
* CHECK B — HOOK WIRING. Walks the real reachability chain for both supported hosts:
|
|
610
|
+
* plugin/hooks/hooks.json (Claude Code) → hook-shim.mjs's own TABLE (resolving its id-based
|
|
611
|
+
* indirection explicitly, since a hook id like "route-dispatch" is not the string
|
|
612
|
+
* "route-dispatch.sh"); and plugin/hooks/codex-hooks.json → bin/install.mjs's Stable Spine copy
|
|
613
|
+
* (codex-hook-wrapper.mjs installed as codex-hook.mjs) → codex-hook-adapter.mjs. It also reads
|
|
614
|
+
* this repo's own
|
|
573
615
|
* .claude/settings.json → the user's REAL ~/.claude/settings.json (what is actually installed on
|
|
574
616
|
* THIS machine — never checked before). One further hop is closed by a small fixed-point pass
|
|
575
617
|
* (the unprompted-speech runtime spawns anticipate.sh/lesson-hooks.sh as candidate producers),
|
|
@@ -671,6 +713,28 @@ function hookShimIdIn(cmd) {
|
|
|
671
713
|
return m ? m[1] : null;
|
|
672
714
|
}
|
|
673
715
|
|
|
716
|
+
/** The dispatch id after Codex's inline bootstrap and numeric timeout. Same grammar as hook-registry. */
|
|
717
|
+
function codexHookIdIn(cmd) {
|
|
718
|
+
const m = cmd.match(/"\s+\d+\s+([a-zA-Z][\w-]*)/);
|
|
719
|
+
return m ? m[1] : null;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Derive the Stable Spine wrapper copy from the installer source. The Codex manifest names the
|
|
724
|
+
* durable installed target (`codex-hook.mjs`), while the repository inventory contains its source
|
|
725
|
+
* (`codex-hook-wrapper.mjs`). Both names plus the actual copy operation must be present; otherwise
|
|
726
|
+
* there is no proven bridge and the hook stays unwired.
|
|
727
|
+
*/
|
|
728
|
+
function installedCodexHookWrapper(repo) {
|
|
729
|
+
let src = '';
|
|
730
|
+
try { src = fs.readFileSync(path.join(repo, 'bin/install.mjs'), 'utf8'); } catch { return null; }
|
|
731
|
+
const stripped = stripComments(src, '.mjs');
|
|
732
|
+
const source = stripped.match(/hookWrapperSource\s*=\s*path\.join\([^\n]*['"]([\w.-]+\.mjs)['"]\)/)?.[1];
|
|
733
|
+
const target = stripped.match(/const codexHookWrapperPath[\s\S]{0,260}?['"]([\w.-]+\.mjs)['"]\)/)?.[1];
|
|
734
|
+
const copiesSource = /fs\.copyFileSync\(hookWrapperSource,\s*tmp\)/.test(stripped);
|
|
735
|
+
return source && target && copiesSource ? { source, target } : null;
|
|
736
|
+
}
|
|
737
|
+
|
|
674
738
|
/** hook-shim.mjs's own dispatch TABLE: id -> file. Parsed, not re-implemented — it IS the authority. */
|
|
675
739
|
function hookShimTable(repo) {
|
|
676
740
|
let src = '';
|
|
@@ -691,9 +755,9 @@ function hookHeaderDeclares(src) { return HOOK_EVENT_RE.test(src.slice(0, 1600))
|
|
|
691
755
|
|
|
692
756
|
/**
|
|
693
757
|
* CHECK B — HOOK WIRING. See the file-level comment above for the full reasoning. Walks the real
|
|
694
|
-
* chain from the real entry points (plugin
|
|
695
|
-
* user's actual ~/.claude/settings.json) through
|
|
696
|
-
*
|
|
758
|
+
* chain from the real entry points (both plugin hook manifests, this repo's .claude/settings.json,
|
|
759
|
+
* the user's actual ~/.claude/settings.json) through each host's real indirection, then closes one
|
|
760
|
+
* further hop with a small fixed-point pass restricted to files already proven reachable.
|
|
697
761
|
*/
|
|
698
762
|
export function hookWiringAudit({
|
|
699
763
|
repo = REPO,
|
|
@@ -701,6 +765,7 @@ export function hookWiringAudit({
|
|
|
701
765
|
held = HOOK_HELD,
|
|
702
766
|
} = {}) {
|
|
703
767
|
const table = hookShimTable(repo);
|
|
768
|
+
const codexWrapper = installedCodexHookWrapper(repo);
|
|
704
769
|
const reached = new Map(); // basename -> Set(reason)
|
|
705
770
|
const add = (name, reason) => {
|
|
706
771
|
if (!name) return;
|
|
@@ -708,16 +773,21 @@ export function hookWiringAudit({
|
|
|
708
773
|
reached.get(name).add(reason);
|
|
709
774
|
};
|
|
710
775
|
|
|
711
|
-
const scanConfig = (file, label) => {
|
|
776
|
+
const scanConfig = (file, label, { codex = false } = {}) => {
|
|
712
777
|
const doc = readJsonSafe(file);
|
|
713
778
|
if (!doc || !doc.hooks) return;
|
|
714
779
|
for (const cmd of commandStrings(doc.hooks)) {
|
|
715
|
-
|
|
716
|
-
const
|
|
717
|
-
|
|
780
|
+
const basenames = basenamesIn(cmd);
|
|
781
|
+
for (const b of basenames) add(b, label);
|
|
782
|
+
const id = codex ? codexHookIdIn(cmd) : hookShimIdIn(cmd);
|
|
783
|
+
if (id && table[id]) add(table[id], `${label} (${codex ? 'Codex dispatch' : 'hook-shim'} id "${id}")`);
|
|
784
|
+
if (codex && codexWrapper && basenames.includes(codexWrapper.target)) {
|
|
785
|
+
add(codexWrapper.source, `${label} via bin/install.mjs Stable Spine copy (${codexWrapper.target})`);
|
|
786
|
+
}
|
|
718
787
|
}
|
|
719
788
|
};
|
|
720
789
|
scanConfig(path.join(repo, 'plugin/hooks/hooks.json'), 'plugin/hooks/hooks.json');
|
|
790
|
+
scanConfig(path.join(repo, 'plugin/hooks/codex-hooks.json'), 'plugin/hooks/codex-hooks.json', { codex: true });
|
|
721
791
|
scanConfig(path.join(repo, '.claude/settings.json'), '.claude/settings.json (this repo)');
|
|
722
792
|
scanConfig(homeSettingsFile, '~/.claude/settings.json (this machine)');
|
|
723
793
|
|
|
@@ -753,7 +823,14 @@ export function hookWiringAudit({
|
|
|
753
823
|
return { rows, plumbing: HOOK_PLUMBING };
|
|
754
824
|
}
|
|
755
825
|
|
|
756
|
-
/**
|
|
826
|
+
/**
|
|
827
|
+
* The trigger tokens requested by lesson-hooks.sh — the sole authority. A trigger reaches the gate
|
|
828
|
+
* two ways: a static `case "$EVENT" in ... TRIGGERS="<name>"` label, or a dynamic conditional append
|
|
829
|
+
* (`ARGS+=(--trigger <name>)`, e.g. `ship`, appended only when a live regex matches the real command
|
|
830
|
+
* text). Reading only the static form means a trigger whose SOLE live path is dynamic is invisible
|
|
831
|
+
* to this audit — and Check C would then depend on an unrelated dead case label merely coexisting in
|
|
832
|
+
* the file to report it correctly, which breaks the moment that dead label is ever cleaned up.
|
|
833
|
+
*/
|
|
757
834
|
function lessonHooksRequestedTriggers(repo) {
|
|
758
835
|
let src = '';
|
|
759
836
|
try { src = fs.readFileSync(path.join(repo, 'plugin/scripts/lesson-hooks.sh'), 'utf8'); } catch { return new Set(); }
|
|
@@ -762,6 +839,8 @@ function lessonHooksRequestedTriggers(repo) {
|
|
|
762
839
|
const re = /TRIGGERS="([^"]*)"/g;
|
|
763
840
|
let m;
|
|
764
841
|
while ((m = re.exec(stripped))) { for (const t of m[1].split(/\s+/)) if (t) requested.add(t); }
|
|
842
|
+
const dynRe = /ARGS\+=\(--trigger\s+"?([A-Za-z][\w-]*)"?\)/g;
|
|
843
|
+
while ((m = dynRe.exec(stripped))) { requested.add(m[1]); }
|
|
765
844
|
return requested;
|
|
766
845
|
}
|
|
767
846
|
|
|
@@ -784,9 +863,10 @@ const invokedDirectly = process.argv[1]
|
|
|
784
863
|
&& path.resolve(process.argv[1]).endsWith(`wired-check${path.extname(process.argv[1])}`);
|
|
785
864
|
|
|
786
865
|
if (invokedDirectly) {
|
|
787
|
-
const { rows, dupes, inventory } = audit();
|
|
866
|
+
const { rows, operationalRows, dupes, inventory } = audit();
|
|
788
867
|
const by = (s) => rows.filter((r) => r.state === s);
|
|
789
868
|
const unwired = by('unwired');
|
|
869
|
+
const operationalUnwired = operationalRows.filter((row) => row.state !== 'wired');
|
|
790
870
|
|
|
791
871
|
const hookAudit = hookWiringAudit();
|
|
792
872
|
const hookBy = (s) => hookAudit.rows.filter((r) => r.state === s);
|
|
@@ -805,6 +885,16 @@ if (invokedDirectly) {
|
|
|
805
885
|
console.log(` or add it to STANDALONE in this file WITH A TRUE REASON.\n`);
|
|
806
886
|
}
|
|
807
887
|
|
|
888
|
+
console.log(` ${operationalRows.length} release-critical operational export(s) checked · `
|
|
889
|
+
+ `${operationalUnwired.length} UNWIRED\n`);
|
|
890
|
+
for (const row of operationalUnwired) {
|
|
891
|
+
console.log(` ✗ ${row.rel}#${row.symbol} — exported, but no production invocation reaches it`);
|
|
892
|
+
}
|
|
893
|
+
if (operationalUnwired.length) {
|
|
894
|
+
console.log('\n Importing or re-exporting a function is not operation. A release-critical export must');
|
|
895
|
+
console.log(' be invoked through a production path whose module is itself wired.\n');
|
|
896
|
+
}
|
|
897
|
+
|
|
808
898
|
// Every exemption, every run. v1 never printed these, so 3 false reasons rotted unseen for a
|
|
809
899
|
// day inside the gate built to stop exactly that.
|
|
810
900
|
if (by('manual').length) {
|
|
@@ -821,7 +911,7 @@ if (invokedDirectly) {
|
|
|
821
911
|
if (dupes.length) console.log(` ✗ DUPLICATE exemption(s): ${dupes.join(', ')}\n`);
|
|
822
912
|
|
|
823
913
|
// ── CHECK B: HOOK WIRING ──────────────────────────────────────────────────────────────────
|
|
824
|
-
console.log(`\n ── HOOK WIRING — plugin/scripts/*.sh|*.mjs vs plugin/hooks/hooks.json, `
|
|
914
|
+
console.log(`\n ── HOOK WIRING — plugin/scripts/*.sh|*.mjs vs plugin/hooks/{hooks,codex-hooks}.json, `
|
|
825
915
|
+ `.claude/settings.json, ~/.claude/settings.json ──\n`);
|
|
826
916
|
console.log(` ${hookAudit.rows.length} hook-intended script(s) in the census`);
|
|
827
917
|
console.log(` ${hookBy('wired').length} wired · ${hookBy('held').length} held · `
|
|
@@ -862,6 +952,6 @@ if (invokedDirectly) {
|
|
|
862
952
|
}
|
|
863
953
|
}
|
|
864
954
|
|
|
865
|
-
const bad = unwired.length || dupes.length || hookUnwired.length;
|
|
955
|
+
const bad = unwired.length || operationalUnwired.length || dupes.length || hookUnwired.length;
|
|
866
956
|
process.exit(argv.includes('--check') && bad ? 1 : 0);
|
|
867
957
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* One mutation boundary for author-side automation.
|
|
4
|
+
*
|
|
5
|
+
* A clean linked worktree is the only place a corpus/update command may write. The primary
|
|
6
|
+
* checkout is never a writer, even when it is on a feature branch, because scheduled work cannot
|
|
7
|
+
* distinguish the owner's active edits from its own generated changes. This module only classifies
|
|
8
|
+
* and refuses; it never resets, cleans, removes, commits, or switches a checkout.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
function git(cwd, args) {
|
|
17
|
+
const result = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8' });
|
|
18
|
+
if (result.status !== 0) {
|
|
19
|
+
const detail = String(result.stderr || result.stdout || result.error?.message || 'unknown git error').trim();
|
|
20
|
+
throw new Error(`git ${args.join(' ')} failed: ${detail}`);
|
|
21
|
+
}
|
|
22
|
+
return String(result.stdout || '').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function samePath(left, right) {
|
|
26
|
+
try { return fs.realpathSync(left) === fs.realpathSync(right); } catch { return false; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function within(parent, candidate) {
|
|
30
|
+
const relative = path.relative(parent, candidate);
|
|
31
|
+
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function digest(value) {
|
|
35
|
+
return crypto.createHash('sha256').update(value).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function inspectMutationWorktree(root) {
|
|
39
|
+
const requestedRoot = path.resolve(root);
|
|
40
|
+
const worktreeRoot = fs.realpathSync(git(requestedRoot, ['rev-parse', '--show-toplevel']));
|
|
41
|
+
const commonDirRaw = git(worktreeRoot, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
42
|
+
const commonDir = fs.realpathSync(commonDirRaw);
|
|
43
|
+
const primaryRoot = fs.realpathSync(path.dirname(commonDir));
|
|
44
|
+
const dotGit = fs.lstatSync(path.join(worktreeRoot, '.git'));
|
|
45
|
+
const linked = dotGit.isFile();
|
|
46
|
+
const primary = samePath(worktreeRoot, primaryRoot);
|
|
47
|
+
const nestedInPrimary = !primary && within(primaryRoot, worktreeRoot);
|
|
48
|
+
const status = git(worktreeRoot, ['status', '--porcelain=v1', '--untracked-files=all']);
|
|
49
|
+
const clean = status.length === 0;
|
|
50
|
+
const allowed = linked && !primary && !nestedInPrimary && clean;
|
|
51
|
+
let reason = 'clean-linked-worktree';
|
|
52
|
+
if (primary) reason = 'primary-checkout';
|
|
53
|
+
else if (!linked) reason = 'not-linked-worktree';
|
|
54
|
+
else if (nestedInPrimary) reason = 'nested-inside-primary';
|
|
55
|
+
else if (!clean) reason = 'dirty-writer-worktree';
|
|
56
|
+
return { allowed, reason, worktreeRoot, primaryRoot, commonDir, linked, primary, nestedInPrimary, clean, status };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function assertIsolatedMutationWorktree(root, operation = 'repository mutation') {
|
|
60
|
+
const state = inspectMutationWorktree(root);
|
|
61
|
+
if (!state.allowed) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`[worktree-integrity] DENIED ${operation}: ${state.reason}. `
|
|
64
|
+
+ `Run author-side mutations in a clean linked worktree outside ${state.primaryRoot}; `
|
|
65
|
+
+ 'the primary checkout is immutable to automation.',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return state;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Seal the primary checkout around an isolated author-side mutation.
|
|
73
|
+
*
|
|
74
|
+
* A status-only comparison is insufficient: a checkout that was already dirty can be modified
|
|
75
|
+
* again while keeping the same porcelain status. Bind HEAD, the staged diff, the full tracked
|
|
76
|
+
* working diff, and the exact non-ignored untracked-file set instead. Ignored runtime state (logs,
|
|
77
|
+
* AgentDB, locks) is deliberately outside the source-checkout contract.
|
|
78
|
+
*/
|
|
79
|
+
export function snapshotPrimaryCheckout(root) {
|
|
80
|
+
const { primaryRoot } = inspectMutationWorktree(root);
|
|
81
|
+
return {
|
|
82
|
+
primaryRoot,
|
|
83
|
+
head: git(primaryRoot, ['rev-parse', 'HEAD']),
|
|
84
|
+
indexDigest: digest(git(primaryRoot, ['diff', '--binary', '--cached', 'HEAD', '--'])),
|
|
85
|
+
trackedDigest: digest(git(primaryRoot, ['diff', '--binary', 'HEAD', '--'])),
|
|
86
|
+
untracked: git(primaryRoot, ['ls-files', '--others', '--exclude-standard', '-z'])
|
|
87
|
+
.split('\0').filter(Boolean).sort(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function assertPrimaryCheckoutUnchanged(root, before) {
|
|
92
|
+
const after = snapshotPrimaryCheckout(root);
|
|
93
|
+
const fields = ['primaryRoot', 'head', 'indexDigest', 'trackedDigest', 'untracked'];
|
|
94
|
+
const changed = fields.filter((field) => JSON.stringify(after[field]) !== JSON.stringify(before[field]));
|
|
95
|
+
if (changed.length) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`[worktree-integrity] PRIMARY CHECKOUT CHANGED during isolated mutation: ${changed.join(', ')}. `
|
|
98
|
+
+ 'The candidate is invalid and must not be promoted.',
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return after;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const invokedDirectly = process.argv[1]
|
|
105
|
+
&& fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
106
|
+
if (invokedDirectly) {
|
|
107
|
+
try {
|
|
108
|
+
const command = process.argv[2];
|
|
109
|
+
if (command === 'snapshot') {
|
|
110
|
+
process.stdout.write(`${JSON.stringify(snapshotPrimaryCheckout(process.argv[3] || process.cwd()))}\n`);
|
|
111
|
+
} else if (command === 'verify') {
|
|
112
|
+
const root = process.argv[3] || process.cwd();
|
|
113
|
+
const receipt = JSON.parse(fs.readFileSync(process.argv[4], 'utf8'));
|
|
114
|
+
process.stdout.write(`${JSON.stringify(assertPrimaryCheckoutUnchanged(root, receipt))}\n`);
|
|
115
|
+
} else {
|
|
116
|
+
const state = assertIsolatedMutationWorktree(command || process.cwd(), process.argv[3] || 'repository mutation');
|
|
117
|
+
process.stdout.write(`${JSON.stringify(state)}\n`);
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error(error?.message || String(error));
|
|
121
|
+
process.exit(2);
|
|
122
|
+
}
|
|
123
|
+
}
|