ruvnet-brain 4.7.19 → 4.7.21
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 +2 -2
- package/bin/install.mjs +494 -97
- package/kb/download-retry.mjs +93 -21
- package/kb/forge-guard-injection.mjs +277 -0
- package/kb/forge-update.mjs +166 -38
- package/kb/lifecycle-evidence-retention.mjs +151 -14
- package/kb/model-requirements.mjs +4 -1
- package/kb/refresh-run.mjs +175 -27
- package/kb/verify-citation.mjs +15 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +16 -8
- package/plugin/scripts/adr-currency-gate.mjs +2 -1
- package/plugin/scripts/advisory-contract.mjs +21 -0
- package/plugin/scripts/advocacy-route.mjs +5 -4
- package/plugin/scripts/agentdb-recall.mjs +15 -5
- package/plugin/scripts/automatic-update.mjs +9 -1
- package/plugin/scripts/brain-confirmation.mjs +2 -1
- package/plugin/scripts/brain-footprint.mjs +72 -11
- package/plugin/scripts/capability-registry.mjs +1 -1
- package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
- package/plugin/scripts/codex-overnight-notice.mjs +33 -0
- package/plugin/scripts/completion-claim-evidence.mjs +56 -12
- package/plugin/scripts/continuation-gate.mjs +28 -9
- package/plugin/scripts/decision-gate.mjs +13 -63
- package/plugin/scripts/decision-outcomes.mjs +36 -0
- package/plugin/scripts/decision-recall-gate.mjs +40 -5
- package/plugin/scripts/decision-record.mjs +8 -3
- package/plugin/scripts/degradation-watch.mjs +2 -1
- package/plugin/scripts/developer-update-lock.mjs +172 -33
- package/plugin/scripts/developer-update-owner.mjs +8 -5
- package/plugin/scripts/developer-update-policy.mjs +4 -1
- package/plugin/scripts/developer-update.mjs +155 -53
- package/plugin/scripts/doc-currency.mjs +2 -16
- package/plugin/scripts/duplicate-gate.mjs +2 -1
- package/plugin/scripts/entrypoint.mjs +34 -0
- package/plugin/scripts/footprint-io.mjs +135 -8
- package/plugin/scripts/grok-hook-adapter.mjs +2 -1
- package/plugin/scripts/ground-ruvnet.sh +6 -3
- package/plugin/scripts/grounding-answer.mjs +2 -1
- package/plugin/scripts/grounding-code-projection.mjs +2 -1
- package/plugin/scripts/grounding-substance.mjs +2 -1
- package/plugin/scripts/grounding-turn-gate.mjs +2 -2
- package/plugin/scripts/grounding-turn-mark.mjs +2 -2
- package/plugin/scripts/hook-context-budget.mjs +2 -1
- package/plugin/scripts/hook-input.mjs +2 -2
- package/plugin/scripts/hook-shim.mjs +9 -2
- package/plugin/scripts/host-update.mjs +29 -5
- package/plugin/scripts/identifier-preflight.mjs +2 -1
- package/plugin/scripts/kb-copy-proof.mjs +57 -9
- package/plugin/scripts/learn-capture.mjs +2 -1
- package/plugin/scripts/learning-store.mjs +62 -16
- package/plugin/scripts/lesson-bridge.mjs +2 -2
- package/plugin/scripts/lesson-promote.mjs +83 -22
- package/plugin/scripts/md-stamp.mjs +2 -1
- package/plugin/scripts/native-turn-transcript.mjs +98 -6
- package/plugin/scripts/nightly-scheduler.mjs +10 -5
- package/plugin/scripts/package-recommender.mjs +2 -2
- package/plugin/scripts/project-store-resolver.mjs +19 -0
- package/plugin/scripts/routing-outcome-capture.mjs +2 -1
- package/plugin/scripts/session-snapshot-hook.mjs +24 -4
- package/plugin/scripts/session-start-core.mjs +8 -16
- package/plugin/scripts/session-start-health.mjs +97 -10
- package/plugin/scripts/session-start-issue-alert.mjs +105 -0
- package/plugin/scripts/session-start-update-plane.mjs +15 -1
- package/plugin/scripts/signal-watch.mjs +2 -1
- package/plugin/scripts/spend-guard.mjs +2 -1
- package/plugin/scripts/substitution-wall.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- package/plugin/scripts/unprompted-runtime.mjs +20 -3
- package/plugin/scripts/update-apply.mjs +186 -15
- package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
- package/scripts/adr-072-completion.mjs +2 -1
- package/scripts/agentdb-context.mjs +2 -1
- package/scripts/agentdb-fleet-doctor.mjs +30 -8
- package/scripts/approved-runtime.mjs +2 -1
- package/scripts/architecture-review-lock.mjs +2 -1
- package/scripts/ascii-drift.mjs +2 -1
- package/scripts/brain-latency-50.mjs +2 -1
- package/scripts/brain-novice-50.mjs +2 -16
- package/scripts/brain-score.mjs +13 -2
- package/scripts/build-bundle.mjs +2 -1
- package/scripts/build-concepts.mjs +2 -1
- package/scripts/candidate-ci-receipt.mjs +2 -1
- package/scripts/candidate-host-evidence.mjs +2 -1
- package/scripts/card-from-source.mjs +2 -1
- package/scripts/ci/warm-brain-models.mjs +2 -1
- package/scripts/claims-verify.mjs +7 -3
- package/scripts/claude-controlled-terminal.mjs +2 -1
- package/scripts/claude-terminal-mod.mjs +2 -1
- package/scripts/code-release-corpus.mjs +2 -1
- package/scripts/codex-hook-trust-reconcile.mjs +67 -1
- package/scripts/codex-hook-trust.mjs +40 -1
- package/scripts/codex-hook-wrapper-history.mjs +2 -1
- package/scripts/codex-host-execution-proof.mjs +2 -1
- package/scripts/completion-claim-replay.mjs +2 -1
- package/scripts/console-engine.mjs +8 -3
- package/scripts/corpus-canary.mjs +2 -1
- package/scripts/corpus-candidate.mjs +2 -2
- package/scripts/corpus-coverage-sidecar.mjs +2 -1
- package/scripts/corpus-dispatch-decision.mjs +2 -1
- package/scripts/corpus-dispatch-receipt.mjs +2 -1
- package/scripts/corpus-next-seed.mjs +2 -1
- package/scripts/corpus-qa.mjs +2 -1
- package/scripts/corpus-reconcile.mjs +2 -2
- package/scripts/corpus-watchdog.mjs +2 -1
- package/scripts/count-chunks.mjs +2 -1
- package/scripts/customer-state-matrix.mjs +2 -1
- package/scripts/derive-passage-content-map.mjs +2 -1
- package/scripts/development-push-check.mjs +2 -1
- package/scripts/dispatch-receipt.mjs +2 -1
- package/scripts/doc-currency.mjs +2 -1
- package/scripts/dream-issue-gate.mjs +2 -1
- package/scripts/dual-host-deliberation.mjs +2 -16
- package/scripts/dual-host-suggest.mjs +2 -16
- package/scripts/eval-brain.mjs +7 -3
- package/scripts/execution-policy.mjs +2 -1
- package/scripts/execution-preflight.mjs +2 -1
- package/scripts/falsify.mjs +2 -1
- package/scripts/fix-metaharness-memretrieve.mjs +2 -1
- package/scripts/fix-workstream.mjs +2 -1
- package/scripts/full-hints.mjs +2 -1
- package/scripts/full-suite-gate.mjs +2 -1
- package/scripts/grok-host.mjs +68 -0
- package/scripts/hook-qualify-core.mjs +3 -1
- package/scripts/hook-qualify.mjs +2 -1
- package/scripts/hook-retirement-check.mjs +2 -1
- package/scripts/host-registry.mjs +2 -1
- package/scripts/independent-review-receipt.mjs +2 -1
- package/scripts/ingest-new-repos.mjs +2 -1
- package/scripts/ingest-repo.mjs +12 -2
- package/scripts/integration-evidence.mjs +2 -1
- package/scripts/issue-fix.mjs +7 -24
- package/scripts/issue-watch.mjs +164 -47
- package/scripts/job-heartbeat.sh +145 -13
- package/scripts/key-canary.mjs +9 -3
- package/scripts/latency-to-surface.mjs +2 -1
- package/scripts/learning-replay-cli.mjs +97 -19
- package/scripts/learning-replay-execution.mjs +2 -0
- package/scripts/learning-replay-fixture.mjs +3 -0
- package/scripts/learning-replay-proof.mjs +14 -13
- package/scripts/learning-replay.mjs +3 -3
- package/scripts/learnings.mjs +2 -1
- package/scripts/lesson-ratify.mjs +88 -40
- package/scripts/loop-checkpoint.mjs +2 -1
- package/scripts/metaharness-receipts.mjs +2 -1
- package/scripts/model-catalog.mjs +2 -1
- package/scripts/model-currency-evidence.mjs +6 -4
- package/scripts/model-currency.mjs +33 -3
- package/scripts/model-native-catalog.mjs +2 -1
- package/scripts/model-router-agent-hook.mjs +2 -1
- package/scripts/model-router-dispatch.mjs +2 -1
- package/scripts/model-router-engine.mjs +2 -1
- package/scripts/model-router-outcome.mjs +2 -1
- package/scripts/model-router-setup.mjs +2 -1
- package/scripts/model-routing-eval.mjs +2 -1
- package/scripts/model-routing-gateway.mjs +2 -1
- package/scripts/model-routing-launchers.mjs +4 -1
- package/scripts/model-terminal-gateway.mjs +2 -1
- package/scripts/model-terminal-launchers.mjs +2 -1
- package/scripts/model-weekly-analyst.mjs +2 -1
- package/scripts/model-weekly-cycle.mjs +2 -1
- package/scripts/model-weekly-qualification.mjs +2 -1
- package/scripts/nightly-gists.sh +30 -21
- package/scripts/nightly-two-run-proof.mjs +2 -1
- package/scripts/nightly-watchdog.mjs +158 -47
- package/scripts/no-silent-substitution.mjs +2 -1
- package/scripts/notify.sh +11 -6
- package/scripts/ntfy-inbox.mjs +549 -0
- package/scripts/onboarding-console.mjs +47 -8
- package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
- package/scripts/oracle/abstain-trace.mjs +2 -1
- package/scripts/oracle/doc2query-generate.mjs +2 -1
- package/scripts/oracle/doc2query-reach.mjs +2 -1
- package/scripts/oracle/judge-train.mjs +2 -1
- package/scripts/oracle/need-set-split.mjs +2 -1
- package/scripts/oracle/produce-questions.mjs +2 -16
- package/scripts/oracle/repo-recall.mjs +2 -2
- package/scripts/oracle/retrieval-accuracy.mjs +2 -2
- package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
- package/scripts/oracle/source-tree.mjs +2 -15
- package/scripts/oracle/source-units.mjs +2 -16
- package/scripts/oracle/spike-run.mjs +2 -16
- package/scripts/oracle/validate-labels.mjs +2 -16
- package/scripts/org-repo-count.mjs +2 -1
- package/scripts/package-cards.mjs +2 -1
- package/scripts/prepublication-evidence.mjs +2 -1
- package/scripts/private-overlay.mjs +2 -1
- package/scripts/product-integrity-contract.mjs +2 -1
- package/scripts/protected-release-invocation.mjs +2 -1
- package/scripts/public-verification-abandon.mjs +2 -1
- package/scripts/public-verification-aggregate.mjs +2 -1
- package/scripts/public-verification-finalizer.mjs +2 -1
- package/scripts/public-verification-inputs.mjs +2 -1
- package/scripts/public-verification-lane.mjs +2 -1
- package/scripts/publication-receipt.mjs +2 -1
- package/scripts/published-surface-probe.mjs +2 -1
- package/scripts/qe/card-lane-gate.mjs +2 -16
- package/scripts/qe/session-start-gate.mjs +2 -16
- package/scripts/qe/ux-suite.mjs +2 -1
- package/scripts/qualified-candidate-check.mjs +2 -1
- package/scripts/rebuild-gists-from-receipts.mjs +2 -1
- package/scripts/recommendation-eval.mjs +2 -1
- package/scripts/recommendation-floor.mjs +2 -1
- package/scripts/recommendation-judge-score.mjs +2 -1
- package/scripts/recommendation-real-host-score.mjs +2 -1
- package/scripts/recommendation-real-host.mjs +2 -1
- package/scripts/refresh-capability-only-store.mjs +2 -1
- package/scripts/refresh-model-catalog.mjs +2 -1
- package/scripts/rehearse-corpus-pipeline.mjs +2 -1
- package/scripts/release-authority.mjs +2 -1
- package/scripts/release-evidence-aggregate.mjs +2 -1
- package/scripts/release-payload.mjs +2 -1
- package/scripts/release-projection.mjs +2 -1
- package/scripts/release-proof.mjs +2 -1
- package/scripts/release-qualification-contract.mjs +71 -26
- package/scripts/release-qualification.mjs +8 -1
- package/scripts/release-vector.mjs +2 -1
- package/scripts/restore-local-ingests.mjs +2 -1
- package/scripts/retrieval-canary.mjs +2 -1
- package/scripts/route-cheap.mjs +2 -1
- package/scripts/route-gold-rank.mjs +2 -1
- package/scripts/route-index-memory.mjs +2 -1
- package/scripts/route-latency-warm.mjs +2 -1
- package/scripts/router-utilization.mjs +2 -1
- package/scripts/routing-flywheel.mjs +2 -1
- package/scripts/run-operational-benchmark.mjs +2 -1
- package/scripts/run-operational-benchmark.v3.mjs +2 -1
- package/scripts/rvf-index-audit.mjs +2 -16
- package/scripts/seal-gist-receipt.mjs +2 -1
- package/scripts/security-guidance-codex-compat.mjs +2 -1
- package/scripts/selfcheck.mjs +2 -1
- package/scripts/signal-watch.mjs +2 -1
- package/scripts/source-coverage.mjs +2 -1
- package/scripts/stabilization-receipt.mjs +2 -1
- package/scripts/stamp-sweep.mjs +2 -1
- package/scripts/status-honesty.mjs +2 -1
- package/scripts/sync-commands.mjs +2 -2
- package/scripts/top100-benchmark.mjs +2 -1
- package/scripts/top100-corpus.mjs +2 -1
- package/scripts/user-model-hook-install.mjs +2 -1
- package/scripts/user-model-prompt-hook.mjs +2 -1
- package/scripts/verify-bundle.mjs +3 -1
- package/scripts/verify-model-catalog.mjs +2 -1
- package/scripts/version.mjs +2 -1
- package/scripts/wired-check.mjs +11 -5
- package/scripts/worktree-integrity.mjs +2 -2
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
//
|
|
6
6
|
// must-exist the live KB, the active Stable Spine generation, each registered plugin generation
|
|
7
7
|
// may-exist bounded state: logs under a size cap, lease-held plugin generations, the newest npx
|
|
8
|
-
// copy, lifecycle evidence under its own retention policy, models, small state files
|
|
8
|
+
// copy, lifecycle evidence under its own retention policy, models, small state files,
|
|
9
|
+
// the newest rescued operational files of released KB copies (kb-copy-rescued/)
|
|
9
10
|
// must-not-exist every other full-KB copy (kb.bak-*, kb.install-preserved-*, kb.pre-update-*, …,
|
|
10
11
|
// *-quarantine-* dirs), stale install stages and forge candidates, older npx copies,
|
|
11
12
|
// stale leases, ruflo scratch debris, rotated-away log bytes
|
|
@@ -14,6 +15,7 @@
|
|
|
14
15
|
// SAFETY (non-negotiable, each one has a test that breaks it and goes red):
|
|
15
16
|
// * A KB copy is removed only after kbCopyProof() shows nothing in it is unique (every private-store file
|
|
16
17
|
// byte-identical in live, everything else of public provenance); a KEPT proof is cached (footprint-io).
|
|
18
|
+
// Its operational files (logs, ruflo scratch) are copied out and verified first (rescueOperationalFiles).
|
|
17
19
|
// * Nothing is followed through a symlink; a removal target must sit directly inside the real directory it
|
|
18
20
|
// was inventoried in, itself inside an owned root (footprint-io removeWithin).
|
|
19
21
|
// * Kept: non-terminal transaction trees; every KB sibling and npx copy while someone else holds the refresh
|
|
@@ -26,7 +28,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
26
28
|
import { isKbTree, kbCopyProof } from './kb-copy-proof.mjs';
|
|
27
29
|
import { brainLocation } from './brain-location.mjs';
|
|
28
30
|
import { RUFLO_CWD_ARTIFACTS } from './ruflo-bin.mjs';
|
|
29
|
-
import {
|
|
31
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
32
|
+
import { RESCUE_DIR, archiveRoots, archiveUniqueFiles, assessMoveLeftovers, discardArchiveIfMirrored, cachedKept, cmpVersion, isVolumeMetadata,
|
|
33
|
+
keptCopyFix, physical, pidAlive, readProofCache, rememberKept, removeWithin, rescueOperationalFiles, rotate,
|
|
30
34
|
treeBytes, truncateToTail } from './footprint-io.mjs';
|
|
31
35
|
|
|
32
36
|
export { cmpVersion, physical, treeBytes } from './footprint-io.mjs';
|
|
@@ -43,6 +47,7 @@ export const FOOTPRINT_POLICY = Object.freeze({
|
|
|
43
47
|
staleForgeCandidateMs: 24 * 3_600_000,
|
|
44
48
|
staleLeaseMs: 6 * 3_600_000, // plugin/scripts/update-apply.mjs LEASE_FRESH_MS
|
|
45
49
|
staleRufloRunMs: 3_600_000, // plugin/scripts/ruflo-bin.mjs STALE_RUN_MS
|
|
50
|
+
rescuedCopiesKept: 5, // kb-copy-rescued/<copy>: the newest five released copies' logs are kept
|
|
46
51
|
});
|
|
47
52
|
|
|
48
53
|
/** Every full-KB copy name this product (or a recovery) has ever created beside the live KB. A superset of
|
|
@@ -185,6 +190,10 @@ export function inventoryFootprint({ env = process.env, home = os.homedir(), now
|
|
|
185
190
|
}
|
|
186
191
|
};
|
|
187
192
|
const classifySibling = (name, full, st) => {
|
|
193
|
+
if (name === `${base}.archive` && st.isDirectory() && !st.isSymbolicLink()) { // #335: written only by --clean --archive
|
|
194
|
+
return void add({ id: 'kb-archive', path: full, class: 'may-exist', kind: 'kb-archive', action: 'keep', bytes: bytes(full),
|
|
195
|
+
reason: 'files unique to released KB copies, archived on request (--clean --archive); never removed automatically' });
|
|
196
|
+
}
|
|
188
197
|
{
|
|
189
198
|
const prefix = kbCopyPrefixes(base).find((p) => name.startsWith(p));
|
|
190
199
|
if (prefix === `${base}.bak-` && !BRAIN_BACKUP_STAMP.test(name.slice(prefix.length))) {
|
|
@@ -264,6 +273,20 @@ export function inventoryFootprint({ env = process.env, home = os.homedir(), now
|
|
|
264
273
|
reason: 'the one retained rotation of a capped log (replaced at the next rotation)' });
|
|
265
274
|
continue;
|
|
266
275
|
}
|
|
276
|
+
// Operational files rescued from released KB copies (footprint-io rescueOperationalFiles), one directory
|
|
277
|
+
// per copy, each file <= 2 MiB and <= 16 MiB per copy (kb-copy-proof RESCUE_*_CAP_BYTES): bounded like a
|
|
278
|
+
// rotated log, by keeping the newest few directories.
|
|
279
|
+
if (name === RESCUE_DIR && st.isDirectory() && !st.isSymbolicLink()) {
|
|
280
|
+
const rescued = names(full).map((n) => ({ n, s: lstat(path.join(full, n)) })).filter((r) => r.s)
|
|
281
|
+
.sort((a, b) => b.s.mtimeMs - a.s.mtimeMs || a.n.localeCompare(b.n));
|
|
282
|
+
rescued.forEach(({ n }, i) => {
|
|
283
|
+
const keep = i < policy.rescuedCopiesKept;
|
|
284
|
+
add({ id: 'rescued', path: path.join(full, n), class: keep ? 'may-exist' : 'must-not-exist', kind: 'rescued-kb-logs',
|
|
285
|
+
action: keep ? 'keep' : 'remove', bytes: bytes(path.join(full, n)),
|
|
286
|
+
reason: keep ? 'logs rescued from a released KB copy (one of the newest kept)' : `older than the newest ${policy.rescuedCopiesKept} rescued KB-copy logs` });
|
|
287
|
+
});
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
267
290
|
if (TEXT_LOG.test(name) && st.isFile()) {
|
|
268
291
|
const over = st.size > policy.textLogCapBytes;
|
|
269
292
|
add({ id: 'log', path: full, class: over ? 'must-not-exist' : 'may-exist', kind: 'text-log', action: over ? 'truncate' : 'keep', bytes: st.size,
|
|
@@ -366,6 +389,7 @@ export function inventoryFootprint({ env = process.env, home = os.homedir(), now
|
|
|
366
389
|
const newestVersion = npx.map((n) => n.version).sort(cmpVersion).at(-1);
|
|
367
390
|
const keepVersion = current && cmpVersion(newestVersion, current) < 0 ? null : newestVersion;
|
|
368
391
|
const keeper = npx.find((n) => n.running) || npx.filter((n) => n.version === keepVersion).sort((a, b) => b.mtime - a.mtime)[0];
|
|
392
|
+
const refVersion = keeper?.version ?? current ?? newestVersion; // #420 item 3: "older" only when strictly below what is kept
|
|
369
393
|
for (const n of npx) {
|
|
370
394
|
const keep = n === keeper || n.running;
|
|
371
395
|
// An older copy fetched < 2h ago may be running in another terminal; none goes during an update/install (S6).
|
|
@@ -373,7 +397,10 @@ export function inventoryFootprint({ env = process.env, home = os.homedir(), now
|
|
|
373
397
|
const hold = !keep && (kbBlocked || (recent ? 'fetched in the last 2h; it may be running now' : null));
|
|
374
398
|
add({ id: 'npx', path: n.dir, version: n.version, bytes: bytes(n.dir), class: keep ? 'may-exist' : 'must-not-exist', kind: 'npx-copy',
|
|
375
399
|
action: keep || hold ? (keep ? 'keep' : 'report') : 'remove', blocked: hold || null,
|
|
376
|
-
reason: keep ? (n.running ? 'the installer running now' : 'the newest installer copy')
|
|
400
|
+
reason: keep ? (n.running ? 'the installer running now' : 'the newest installer copy')
|
|
401
|
+
: cmpVersion(n.version, refVersion) < 0 ? `an older installer copy (${n.version})`
|
|
402
|
+
: cmpVersion(n.version, refVersion) === 0 ? `a duplicate installer copy at the kept version (${n.version}); one copy is enough`
|
|
403
|
+
: `an installer copy (${n.version}) newer than the kept one (${refVersion}); one copy is enough` });
|
|
377
404
|
}
|
|
378
405
|
|
|
379
406
|
// ── lifecycle evidence, judged by its own policy ────────────────────────────────────────────
|
|
@@ -390,7 +417,7 @@ function summarize({ roots, items, policy, kbCopyDirs, lockHeld }) {
|
|
|
390
417
|
const owned = items.filter((i) => i.class !== 'unowned');
|
|
391
418
|
const kbBytes = sum(items.filter((i) => i.kind === 'live-kb'));
|
|
392
419
|
const modelBytes = sum(items.filter((i) => i.kind === 'models'));
|
|
393
|
-
const budgetBytes = kbBytes + modelBytes + policy.fixedAllowanceBytes;
|
|
420
|
+
const budgetBytes = kbBytes + modelBytes + sum(items.filter((i) => i.kind === 'kb-archive')) + policy.fixedAllowanceBytes;
|
|
394
421
|
const breakdown = {};
|
|
395
422
|
for (const i of owned) {
|
|
396
423
|
const group = i.kind === 'live-kb' ? 'knowledge base' : i.kind === 'models' ? 'models' : /plugin|spine/.test(i.kind) ? 'plugins'
|
|
@@ -419,13 +446,40 @@ function summarize({ roots, items, policy, kbCopyDirs, lockHeld }) {
|
|
|
419
446
|
* every refusal is returned with its reason. `collectPluginGenerations` is the caller's lease-aware
|
|
420
447
|
* collector (bin/install.mjs prunePluginGenerations); `pruneEvidence` is kb's pruneLifecycleEvidence.
|
|
421
448
|
*/
|
|
422
|
-
export function sweepFootprint({ apply = false, collectPluginGenerations = null, pruneEvidence = null,
|
|
449
|
+
export function sweepFootprint({ apply = false, archive = false, collectPluginGenerations = null, pruneEvidence = null,
|
|
423
450
|
proveCopy = kbCopyProof, beforeRemove = null, ...options } = {}) {
|
|
424
451
|
const before = inventoryFootprint(options);
|
|
425
452
|
const { roots } = before;
|
|
426
453
|
const owned = [...new Set([roots.kbParent, roots.brainHome, ...roots.brainHomeParents, roots.npxRoot].map(physical))];
|
|
427
|
-
|
|
428
|
-
|
|
454
|
+
// #335: copies that STAY on disk this sweep may account for a later copy's files (kept siblings). Only
|
|
455
|
+
// final decisions are added: archive trees, copies a cached proof keeps (outside --archive, which re-proves
|
|
456
|
+
// them), and each copy this sweep keeps. A copy is never a reference for one evaluated before it.
|
|
457
|
+
const archiveDir = path.join(roots.kbParent, `${path.basename(roots.kbDir)}.archive`);
|
|
458
|
+
const stampDir = path.join(archiveDir, new Date(options.now ?? Date.now()).toISOString().replace(/[:.]/g, '-'));
|
|
459
|
+
const archiving = apply && archive;
|
|
460
|
+
const reArchive = (item) => archiving && ['kb-copy', 'quarantine'].includes(item.kind) && item.keptUnique && !item.blocked;
|
|
461
|
+
// Correctness relies on SORTED directory order across concurrent sweeps: inventory and archiveRoots list
|
|
462
|
+
// entries sorted, so two sweeps evaluate copies in the same order and agree which copy of a duplicate pair
|
|
463
|
+
// is the kept reference; with unsorted order each could release the copy the other kept as its reference.
|
|
464
|
+
const refs = [...archiveRoots(archiveDir), ...before.items.filter((i) => i.kind === 'kb-copy' && i.keptUnique && !reArchive(i)).map((i) => i.path)];
|
|
465
|
+
const prove = (copyDir, label = path.basename(copyDir)) => {
|
|
466
|
+
let proof = proveCopy({ copyDir, liveDir: roots.kbDir, keptSiblings: [...refs] });
|
|
467
|
+
if (!proof.disposable && archiving && proof.unique?.length) {
|
|
468
|
+
const archived = archiveUniqueFiles({ copyDir, unique: proof.unique, stampDir, label, now: options.now ?? Date.now() });
|
|
469
|
+
if (archived.ok) {
|
|
470
|
+
refs.push(archived.root);
|
|
471
|
+
const again = proveCopy({ copyDir, liveDir: roots.kbDir, keptSiblings: [...refs] });
|
|
472
|
+
if (again.disposable) proof = { ...again, reason: `archived ${archived.files.length} unique file(s) to ${archived.root}; ${again.reason}` };
|
|
473
|
+
else { // the copy is kept (e.g. unreadable metadata on the re-proof): if it still holds every archived byte, no archive is left behind
|
|
474
|
+
const discard = discardArchiveIfMirrored({ root: archived.root, copyDir, files: archived.files });
|
|
475
|
+
if (discard.discarded) {
|
|
476
|
+
refs.pop();
|
|
477
|
+
proof = { ...again, reason: `${again.reason}; not archived: the copy is kept, so its archive was removed` };
|
|
478
|
+
} else proof = { ...again, reason: `${again.reason}; the archive at ${archived.root} is kept: ${discard.reason}` };
|
|
479
|
+
}
|
|
480
|
+
} else proof = { ...proof, reason: `${proof.reason}; not archived: ${archived.reason}` };
|
|
481
|
+
}
|
|
482
|
+
if (!proof.disposable) refs.push(copyDir);
|
|
429
483
|
if (apply && !proof.disposable && isKbTree(roots.kbDir)) rememberKept(roots.brainHome, copyDir, roots.kbDir, proof); // a dry run writes nothing
|
|
430
484
|
return proof;
|
|
431
485
|
};
|
|
@@ -435,8 +489,13 @@ export function sweepFootprint({ apply = false, collectPluginGenerations = null,
|
|
|
435
489
|
kept: [{ path: roots.location.path, kind: 'unmounted-brain', reason: before.items[0].reason }], freedBytes: 0, before, after: before };
|
|
436
490
|
}
|
|
437
491
|
const record = (item, freed, note) => removed.push({ path: item.path, kind: item.kind, bytes: freed ?? item.bytes, reason: note || item.reason });
|
|
438
|
-
|
|
439
|
-
|
|
492
|
+
// Rescue retention is applied only AFTER every rescue of this sweep, and never to a destination this sweep used: a
|
|
493
|
+
// retry that re-validates an aged-out rescue and then releases its copy must not also delete that rescue (review C).
|
|
494
|
+
const rescuedNow = new Set(); const rescue = (...args) => { const dest = rescueOperationalFiles(...args); if (dest) rescuedNow.add(dest); };
|
|
495
|
+
const deferred = before.items.filter((i) => i.kind === 'rescued-kb-logs' && i.action === 'remove');
|
|
496
|
+
for (const item of [...before.items.filter((i) => !deferred.includes(i)), ...deferred]) {
|
|
497
|
+
if (deferred.includes(item) && rescuedNow.has(item.path)) { kept.push({ path: item.path, kind: item.kind, reason: 'used by a rescue in this sweep; retention applies at the next sweep' }); continue; }
|
|
498
|
+
if (!['remove', 'remove-if-proven', 'rotate', 'truncate'].includes(item.action) && !reArchive(item)) {
|
|
440
499
|
if (item.class === 'must-not-exist' || item.class === 'unowned') kept.push({ path: item.path, kind: item.kind, reason: item.blocked || item.reason, fix: item.fix || null });
|
|
441
500
|
continue;
|
|
442
501
|
}
|
|
@@ -449,6 +508,7 @@ export function sweepFootprint({ apply = false, collectPluginGenerations = null,
|
|
|
449
508
|
if (item.kind === 'kb-copy') {
|
|
450
509
|
const proof = prove(item.path);
|
|
451
510
|
if (!proof.disposable) { kept.push({ path: item.path, kind: item.kind, reason: `KEPT: ${proof.reason}`, unique: proof.unique }); continue; }
|
|
511
|
+
if (apply) rescue(item.path, proof.rescue, roots.brainHome); // throws -> the copy is kept
|
|
452
512
|
record(item, apply ? removeWithin(item.path, item.realParent, owned) : item.bytes, proof.reason);
|
|
453
513
|
continue;
|
|
454
514
|
}
|
|
@@ -456,9 +516,10 @@ export function sweepFootprint({ apply = false, collectPluginGenerations = null,
|
|
|
456
516
|
const quarantineReal = physical(item.path);
|
|
457
517
|
for (const child of names(item.path)) {
|
|
458
518
|
const childPath = path.join(item.path, child);
|
|
459
|
-
const proof = isKbTree(childPath) ? prove(childPath)
|
|
519
|
+
const proof = isKbTree(childPath) ? prove(childPath, `${path.basename(item.path)}--${child}`)
|
|
460
520
|
: { disposable: false, unique: [], reason: 'not a KB copy; not ours to judge' };
|
|
461
521
|
if (!proof.disposable) { uniqueLeft = true; kept.push({ path: childPath, kind: 'quarantined-copy', reason: `KEPT: ${proof.reason}`, unique: proof.unique }); continue; }
|
|
522
|
+
if (apply) rescue(childPath, proof.rescue, roots.brainHome, `${path.basename(item.path)}--${child}`); // throws -> kept
|
|
462
523
|
record({ path: childPath, kind: 'quarantined-copy', bytes: treeBytes(childPath) }, apply ? removeWithin(childPath, quarantineReal, owned) : undefined, proof.reason);
|
|
463
524
|
}
|
|
464
525
|
if (!uniqueLeft && apply) removeWithin(item.path, item.realParent, owned);
|
|
@@ -488,7 +549,7 @@ export async function loadKbReaders(kbDir) {
|
|
|
488
549
|
}
|
|
489
550
|
|
|
490
551
|
// CLI: the detached SessionStart sweep. `node brain-footprint.mjs --sweep [--apply] [--json]`.
|
|
491
|
-
if (
|
|
552
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
492
553
|
const argv = process.argv.slice(2);
|
|
493
554
|
const roots = footprintRoots();
|
|
494
555
|
const readers = await loadKbReaders(roots.kbDir);
|
|
@@ -622,7 +622,7 @@ export const CAPABILITIES = [
|
|
|
622
622
|
if (config.err) return row(STATE.UNKNOWN, 'managed native routing settings are unreadable; routing and savings are not inferred');
|
|
623
623
|
if (!config.missing) {
|
|
624
624
|
const value = config.value;
|
|
625
|
-
if (!value || !path.isAbsolute(value.runtimeRoot || '') || !/^[a-f0-9]{64}$/.test(value.runtimeDigest || '')) return row(STATE.UNKNOWN, 'managed routing declaration is incomplete; no execution claim is made');
|
|
625
|
+
if (!value || !path.isAbsolute(value.runtimeRoot || '') || !/^[a-f0-9]{64}$/.test(value.runtimeDigest || '')) return row(STATE.UNKNOWN, 'managed routing declaration is incomplete (no absolute runtimeRoot or 64-hex runtimeDigest), so routing could not be checked; no execution claim is made');
|
|
626
626
|
return row(STATE.UNKNOWN, 'a managed native routing runtime is configured. The retired Task dispatch hook is not a prerequisite. Configuration alone does not prove normal entry wiring, model allocation, execution or savings; those runtime facts were not checked by this declaration-only probe.');
|
|
627
627
|
}
|
|
628
628
|
return row(STATE.UNKNOWN, 'no managed routing declaration was observed in this inspected location; legacy Task routing receipts or binary presence do not establish current native routing or its absence');
|
|
@@ -13,6 +13,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { isHarnessGenerated } from './hook-input.mjs';
|
|
16
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
16
17
|
|
|
17
18
|
export const UNKNOWN_RUNTIME_TOTAL_AGENT_CEILING = 4;
|
|
18
19
|
const INPUT_LIMIT = 32 * 1024;
|
|
@@ -257,4 +258,4 @@ function main() {
|
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
260
|
|
|
260
|
-
if (
|
|
261
|
+
if (isDirectInvocation(import.meta.url)) main();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// codex-overnight-notice.mjs — the unattended nightly never reloads the shared Codex app-server (owner decision,
|
|
2
|
+
// 4.7.21 review r2): a reload reaches Codex sessions already open, and Brain hooks pause in them until re-reviewed in
|
|
3
|
+
// /hooks, which nobody is awake to do. The nightly records this one-line notice instead; the next SessionStart shows
|
|
4
|
+
// it ONCE. Same claim-before-say contract as the other once-only SessionStart lines (.star-ask-shown, turn-capture
|
|
5
|
+
// notices): the file is claimed by an atomic rename BEFORE the line is emitted, so two sessions starting together
|
|
6
|
+
// cannot both show it, and a killed session cannot repeat it.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const CODEX_OVERNIGHT_FILE = 'codex-hooks-overnight-notice.json';
|
|
11
|
+
export const CODEX_OVERNIGHT_PREFIX = '[RuvNet Brain — CODEX HOOKS CHANGED OVERNIGHT]';
|
|
12
|
+
export const CODEX_OVERNIGHT_TEXT = 'Codex hooks changed overnight; open Codex sessions will pick them up on restart or after /hooks review.';
|
|
13
|
+
|
|
14
|
+
/** Written by the unattended update. Never throws: an unrecorded notice must not fail the nightly. */
|
|
15
|
+
export function recordCodexOvernightNotice(brainHome, { now = Date.now() } = {}) {
|
|
16
|
+
try {
|
|
17
|
+
fs.mkdirSync(brainHome, { recursive: true });
|
|
18
|
+
const file = path.join(brainHome, CODEX_OVERNIGHT_FILE);
|
|
19
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
20
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ schemaVersion: 1, at: new Date(now).toISOString() })}\n`, { mode: 0o600 });
|
|
21
|
+
fs.renameSync(tmp, file);
|
|
22
|
+
return true;
|
|
23
|
+
} catch { return false; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Read by SessionStart: the line to show, at most once; '' when there is none or another session claimed it. */
|
|
27
|
+
export function claimCodexOvernightNotice(brainHome) {
|
|
28
|
+
const file = path.join(brainHome, CODEX_OVERNIGHT_FILE);
|
|
29
|
+
const claimed = `${file}.shown-${process.pid}`;
|
|
30
|
+
try { fs.renameSync(file, claimed); } catch { return ''; }
|
|
31
|
+
try { fs.rmSync(claimed, { force: true }); } catch { /* claimed already; a leftover is never shown again */ }
|
|
32
|
+
return `${CODEX_OVERNIGHT_PREFIX} ${CODEX_OVERNIGHT_TEXT}`;
|
|
33
|
+
}
|
|
@@ -16,13 +16,14 @@
|
|
|
16
16
|
* (3) the answer discloses what is NOT verified.
|
|
17
17
|
* Anything else is one correction request. No claim recognised → no verdict about the prose.
|
|
18
18
|
*
|
|
19
|
-
* HOSTS. Claude Code transcripts (`transcript_path`, JSONL) are parsed. Codex
|
|
20
|
-
*
|
|
21
|
-
* (
|
|
22
|
-
*
|
|
23
|
-
* correction text, never silently treated as proven.
|
|
19
|
+
* HOSTS. Claude Code transcripts (`transcript_path`, JSONL) are parsed. A Codex rollout is read through
|
|
20
|
+
* the shared native-turn-transcript normalizer (#423) and held to a stricter evidence rule: a command
|
|
21
|
+
* counts as a check only with an explicit exit 0 (normalizeToolOutcome `pass`), an unknown native tool
|
|
22
|
+
* is a change boundary, and a rollout without a current-turn boundary yields no evidence (UNKNOWN).
|
|
24
23
|
*/
|
|
25
24
|
import { readSettledTranscript } from './turn-outcome-capture.mjs';
|
|
25
|
+
import { nativeTurnLines } from './native-turn-transcript.mjs';
|
|
26
|
+
import { normalizeToolOutcome } from './continuity-events.mjs';
|
|
26
27
|
|
|
27
28
|
const MAX_SENTENCE = 400;
|
|
28
29
|
export const PROMISE_KIND = 'assistant-commitment';
|
|
@@ -135,8 +136,17 @@ export function currentTurnRecords(lines) {
|
|
|
135
136
|
return { boundaryFound: start >= 0, prompt: start >= 0 ? textOf(recs[start].message?.content) : '', recs: recs.slice(start + 1) };
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
|
|
139
|
-
|
|
139
|
+
// Codex tools that neither change state nor run a check; every other unrecognised tool is a change (fail
|
|
140
|
+
// toward asking). Grounded in codex-cli 0.164.0's tool handlers (plan, view_image, tool_search,
|
|
141
|
+
// mcp_resource/{list_mcp_resources,list_mcp_resource_templates,read_mcp_resource}, current_time,
|
|
142
|
+
// get_context_remaining, wait_for_environment, multi_agents/wait, sleep, request_user_input[_async]) and real
|
|
143
|
+
// rollouts (code-mode `wait` on a cell, `wait_agent`/`list_agents`). web_search is a hosted search, read-only.
|
|
144
|
+
// Acting tools stay changes: spawn_agent, send_message, followup_task, interrupt_agent, js, exec, update_goal.
|
|
145
|
+
const CODEX_NEUTRAL = /^(?:update_plan|view_image|sleep|list_agents|wait_agent|wait|request_user_input(?:_async)?|tool_search|web_search|list_mcp_resources|list_mcp_resource_templates|read_mcp_resource|current_time|get_context_remaining|wait_for_environment)$/;
|
|
146
|
+
|
|
147
|
+
/** Ordered events for the current turn of a Claude JSONL (or normalized Codex) transcript. */
|
|
148
|
+
export function claudeTurnEvents(lines, { host = 'claude' } = {}) {
|
|
149
|
+
const codex = host === 'codex';
|
|
140
150
|
const { boundaryFound, recs: turnRecs } = currentTurnRecords(lines);
|
|
141
151
|
const results = new Map();
|
|
142
152
|
for (const o of turnRecs) {
|
|
@@ -145,7 +155,8 @@ export function claudeTurnEvents(lines) {
|
|
|
145
155
|
for (const r of c) {
|
|
146
156
|
if (r?.type === 'tool_result' && r.tool_use_id) {
|
|
147
157
|
results.set(r.tool_use_id, { error: r.is_error === true || o.toolUseResult?.interrupted === true,
|
|
148
|
-
present: textOf(r.content).trim().length > 0 || String(o.toolUseResult?.stdout || '').trim().length > 0
|
|
158
|
+
present: textOf(r.content).trim().length > 0 || String(o.toolUseResult?.stdout || '').trim().length > 0,
|
|
159
|
+
...(codex ? { passed: normalizeToolOutcome(r).outcome === 'pass', nativeOk: r.is_error === false && r.status === 'completed' } : {}) });
|
|
149
160
|
}
|
|
150
161
|
}
|
|
151
162
|
}
|
|
@@ -156,8 +167,10 @@ export function claudeTurnEvents(lines) {
|
|
|
156
167
|
for (const u of c) {
|
|
157
168
|
if (u?.type !== 'tool_use') continue;
|
|
158
169
|
const input = u.input || {};
|
|
159
|
-
const
|
|
160
|
-
|
|
170
|
+
const found = results.get(u.id) || { present: false, error: false };
|
|
171
|
+
// Codex: a check needs an explicit exit 0 (commands) or a completed native status (MCP).
|
|
172
|
+
const { passed, nativeOk, ...result } = codex ? { ...found, error: found.error || !(u.name === 'Bash' ? found.passed : found.nativeOk) } : found;
|
|
173
|
+
if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(u.name) || (codex && u.name === 'apply_patch')) {
|
|
161
174
|
events.push({ kind: 'change', what: `${u.name} ${input.file_path || input.notebook_path || ''}`.trim() });
|
|
162
175
|
} else if (u.name === 'Bash') {
|
|
163
176
|
for (const seg of segments(input.command)) {
|
|
@@ -169,6 +182,8 @@ export function claudeTurnEvents(lines) {
|
|
|
169
182
|
} else if (String(u.name || '').startsWith('mcp__')) {
|
|
170
183
|
events.push(MCP_MUTATING.test(u.name) ? { kind: 'change', what: u.name }
|
|
171
184
|
: { kind: 'check', what: u.name, name: u.name.split('__').pop(), ...result });
|
|
185
|
+
} else if (codex && !CODEX_NEUTRAL.test(String(u.name || ''))) {
|
|
186
|
+
events.push({ kind: 'change', what: `codex ${String(u.name || 'tool').slice(0, 60)}` });
|
|
172
187
|
}
|
|
173
188
|
}
|
|
174
189
|
}
|
|
@@ -185,9 +200,12 @@ export function postChangeVerification(turn) {
|
|
|
185
200
|
staleChecks: events.slice(0, Math.max(0, lastChange)).filter((e) => e.kind === 'check').length };
|
|
186
201
|
}
|
|
187
202
|
|
|
188
|
-
export function readClaudeTurn(transcriptPath, { read = readSettledTranscript } = {}) {
|
|
203
|
+
export function readClaudeTurn(transcriptPath, { read = readSettledTranscript, host = 'claude' } = {}) {
|
|
189
204
|
if (typeof transcriptPath !== 'string' || !/\.jsonl$/i.test(transcriptPath)) return null;
|
|
190
|
-
try {
|
|
205
|
+
try {
|
|
206
|
+
const turn = claudeTurnEvents(nativeTurnLines(read(transcriptPath, { maxMs: 0 }), host), { host });
|
|
207
|
+
return host === 'codex' && !turn.boundaryFound ? null : turn;
|
|
208
|
+
} catch { return null; }
|
|
191
209
|
}
|
|
192
210
|
|
|
193
211
|
/**
|
|
@@ -227,6 +245,10 @@ const PROMISE_REJECT = /\?|:\*\*|\b(?:if|unless|once|when|whenever|as\s+soon\s+a
|
|
|
227
245
|
const TRIVIAL_PROMISE = /^(?:not|never|be|say|mention|note|admit|point|flag|stop|stay|leave|keep|summari[sz]e|explain|answer|respond|reply|report|remember|give|tell|send|paste|bring|hand|show|walk|notify|post|update\s+you|come\s+back|get\s+back|handle|only|stand|work)\b/i;
|
|
228
246
|
const OWNER_DEPENDENT_OFFER = /\b(?:say\s+the\s+word|(?:just|simply)\s+(?:ask|say)|ask\s+me|let\s+me\s+know|give\s+me\s+the\s+(?:word|go|nod|ok)|on\s+your\s+(?:word|go|ok|say)|up\s+to\s+you)\b/i;
|
|
229
247
|
const STANDING_APPROVAL = /^(?:ask|confirm|check\s+with\s+you|clear\s+(?:it|this|that)\s+with\s+you|run\s+.+\s+by\s+you)\b.*\b(?:before|first)\b/i;
|
|
248
|
+
// "hide it the same way", "check for it then": a verb whose only object is a pronoun has no referent
|
|
249
|
+
// once the turn's context is gone (#369), so no later session could ever close it honestly.
|
|
250
|
+
const PRONOUN_OBJECT = /\b(?:it|this|that|them|these|those)\b/i;
|
|
251
|
+
const PRONOUN_ONLY = /^\S+(?:\s+(?:it|this|that|them|these|those|the|same|way|then|again|too|also|now|later|for|to|on|in|into|at|about|with|up|out|back|down|over|off|as|well|a|an|there|here|first|next))*$/i;
|
|
230
252
|
|
|
231
253
|
export const normalizePromise = (value) => String(value || '').toLowerCase().replace(/[`*_"“”'’]/g, '')
|
|
232
254
|
.replace(/[^a-z0-9./:+-]+/g, ' ').trim().slice(0, 160);
|
|
@@ -248,6 +270,7 @@ export function extractCommitments(message) {
|
|
|
248
270
|
if (PROMISE_REJECT.test(whole) || OWNER_DEPENDENT_OFFER.test(whole)
|
|
249
271
|
|| STANDING_APPROVAL.test(action.trim()) || TRIVIAL_PROMISE.test(action.trim())) continue;
|
|
250
272
|
const text = action.replace(/\s+/g, ' ').trim().replace(/[,;:]$/, '');
|
|
273
|
+
if (PRONOUN_OBJECT.test(text) && PRONOUN_ONLY.test(text)) continue;
|
|
251
274
|
const key = normalizePromise(text);
|
|
252
275
|
if (key.length < 6 || seen.has(key)) continue;
|
|
253
276
|
seen.add(key);
|
|
@@ -269,3 +292,24 @@ export function claimClosesPromise(claimText, promiseText) {
|
|
|
269
292
|
const hit = want.filter((t) => have.has(t)).length;
|
|
270
293
|
return hit >= 2 && hit / want.length >= 0.4;
|
|
271
294
|
}
|
|
295
|
+
|
|
296
|
+
const unquoted = (value) => String(value).replace(/"([^"\n]{0,300})"|“([^”\n]{0,300})”/g, (_, a, b) => a ?? b);
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The claim that closes `promiseText`, or null. Only a PASS verdict can close a promise. strippedProse()
|
|
300
|
+
* removes double-quoted text, so `"push the branch itself" is done` extracts only `is done`; that claim
|
|
301
|
+
* may still close the promise through its raw sentence (quotes kept), which becomes the evidence. The
|
|
302
|
+
* sentence must be a recognised claim outside its quotes, so quoting alone never makes a claim (#369).
|
|
303
|
+
*/
|
|
304
|
+
export function promiseClosingClaim(completion, answer, promiseText) {
|
|
305
|
+
// DORMANT: auditCompletionClaims no longer returns PASS (only OBSERVED_CHECK/UNKNOWN/FAIL/NONE), so this never closes a promise today.
|
|
306
|
+
if (completion?.verdict !== 'PASS') return null;
|
|
307
|
+
const direct = completion.claims.find((c) => claimClosesPromise(c.text, promiseText));
|
|
308
|
+
if (direct) return direct;
|
|
309
|
+
for (const raw of String(answer || '').split(/(?<=[.!?])\s+|\n+/)) {
|
|
310
|
+
const own = sentences(raw);
|
|
311
|
+
const claim = completion.claims.find((c) => own.includes(c.text));
|
|
312
|
+
if (claim && claimClosesPromise(unquoted(raw), promiseText)) return { ...claim, text: raw.trim() };
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
@@ -53,9 +53,10 @@ import { deferralAtStop } from './deferral-evidence.mjs';
|
|
|
53
53
|
import { readSettledTranscript } from './turn-outcome-capture.mjs';
|
|
54
54
|
import { continuationProjectIdentity, authorizedContinuationObjective, authorizedPromiseItems, assistantCommitmentOwned, setAssistantCommitmentState } from './continuation-objective.mjs';
|
|
55
55
|
import {
|
|
56
|
-
auditCompletionClaims, readClaudeTurn, extractCommitments,
|
|
56
|
+
auditCompletionClaims, readClaudeTurn, extractCommitments, promiseClosingClaim,
|
|
57
57
|
PROMISE_KIND, PROMISE_CAP_OPEN,
|
|
58
58
|
} from './completion-claim-evidence.mjs';
|
|
59
|
+
import { writeAtomic } from './user-settings.mjs';
|
|
59
60
|
|
|
60
61
|
const HOME = os.homedir();
|
|
61
62
|
|
|
@@ -181,7 +182,12 @@ function load() {
|
|
|
181
182
|
function save(led) {
|
|
182
183
|
try {
|
|
183
184
|
fs.mkdirSync(path.dirname(LEDGER), { recursive: true });
|
|
184
|
-
|
|
185
|
+
// writeAtomic (not a bare writeFileSync): writeFileSync truncates the target before writing the
|
|
186
|
+
// new bytes, so a kill -9 / ENOSPC / EIO between those two steps leaves LEDGER empty — and load()'s
|
|
187
|
+
// catch-all JSON.parse failure then reads that as "nothing was ever committed", silently discarding
|
|
188
|
+
// every item a prior turn agreed to do, not just the one being added now. Same house pattern as
|
|
189
|
+
// user-settings.mjs's own writeAtomic(): temp-in-the-same-directory + fsync + rename.
|
|
190
|
+
writeAtomic(LEDGER, JSON.stringify({ ...led, updated: new Date().toISOString() }, null, 2) + '\n');
|
|
185
191
|
return true;
|
|
186
192
|
} catch { return false; /* the ledger is advisory — never break a turn over it */ }
|
|
187
193
|
}
|
|
@@ -366,15 +372,15 @@ const HOST = process.env.RUVNET_HOOK_HOST === 'codex' ? 'codex' : 'claude';
|
|
|
366
372
|
* COMPLETION CLAIMS (ADR-074 class `completion`, owner 2026-09-30: "You can never ever tell me
|
|
367
373
|
* something is done and implemented without having tested it end to end"). Audited ONCE per Stop:
|
|
368
374
|
* the verdict both requests a correction (below, under every loop guard) and is the only evidence
|
|
369
|
-
* that may close a captured promise. Claude transcripts
|
|
370
|
-
*
|
|
371
|
-
*
|
|
375
|
+
* that may close a captured promise. Claude transcripts and Codex rollouts (through the shared
|
|
376
|
+
* nativeTurnLines reader, #423) are parsed; a Codex rollout that cannot be read or has no current-turn
|
|
377
|
+
* boundary stays UNKNOWN (completion-claim-evidence.mjs header). Fails open: a throw is NONE.
|
|
372
378
|
*/
|
|
373
379
|
const completion = (() => {
|
|
374
380
|
const message = String(hookInput.last_assistant_message || '');
|
|
375
381
|
if (!message || !hookInput.session_id) return { verdict: 'NONE', claims: [] };
|
|
376
382
|
try {
|
|
377
|
-
const turn =
|
|
383
|
+
const turn = readClaudeTurn(hookInput.transcript_path, { host: HOST });
|
|
378
384
|
// Claude always sends a JSONL transcript; one we cannot read is OUR failure, and a Stop hook
|
|
379
385
|
// fails open on its own machinery (ADR-074 failure semantics) — never a correction request.
|
|
380
386
|
// Missing native evidence stays UNKNOWN; it cannot silently validate a completion claim.
|
|
@@ -401,7 +407,7 @@ function promiseBookkeeping() {
|
|
|
401
407
|
if (completion.verdict === 'PASS') {
|
|
402
408
|
for (const item of led.items) {
|
|
403
409
|
if (!assistantCommitmentOwned(item, hookInput.session_id, projectIdentity)) continue;
|
|
404
|
-
const claim = completion
|
|
410
|
+
const claim = promiseClosingClaim(completion, hookInput.last_assistant_message, item.text);
|
|
405
411
|
if (!claim) continue;
|
|
406
412
|
const verifiedCompletion = completion.verdict === 'PASS' && Boolean(claim);
|
|
407
413
|
Object.assign(item, { done: verifiedCompletion, state: verifiedCompletion ? 'completed' : item.state, doneAt: at, completionEvidence: { claim: claim.text,
|
|
@@ -715,7 +721,19 @@ if (observations.length && STOP_NUDGES_FULL) console.error(JSON.stringify({ kind
|
|
|
715
721
|
authority: false, items: observations.map(({ text, at }) => ({ text, at })) }));
|
|
716
722
|
// ONE correction per turn: a single item however many claims the answer made; the stop_hook_active
|
|
717
723
|
// exit and the cooldown lock below bound it to one request per stop episode.
|
|
718
|
-
|
|
724
|
+
// #425 (owner decision): an UNKNOWN whose ONLY problem is task scope — a check observed after the last
|
|
725
|
+
// change, named in the answer, with what is NOT verified disclosed — is not a correction. The audit
|
|
726
|
+
// recognizes check-scoped sentences only; it has no representation for a scoped task outcome, so
|
|
727
|
+
// re-running checks or rewording cannot satisfy it. The verdict stays UNKNOWN (never PASS: it closes
|
|
728
|
+
// no promise and grants no completion authority); under `full` the limitation is reported, not enforced.
|
|
729
|
+
const scopeOnlyUnknown = completion.verdict === 'UNKNOWN' && completion.namesCheck === true
|
|
730
|
+
&& completion.disclosesGaps === true && (completion.verification?.checks?.length || 0) > 0;
|
|
731
|
+
if (scopeOnlyUnknown && STOP_NUDGES_FULL) console.error(JSON.stringify({ kind: 'completion-scope-advisory',
|
|
732
|
+
authority: false, verdict: 'UNKNOWN', text: `"${completion.claims[0].text.slice(0, 160)}": a check ran after the last change`
|
|
733
|
+
+ ' and the answer names it and discloses what is not verified. This gate cannot judge whether that task outcome'
|
|
734
|
+
+ ' is complete (it only qualifies check-scoped statements), so completion stays UNKNOWN. Not blocking: more checks'
|
|
735
|
+
+ ' or rewording cannot change this verdict.' }));
|
|
736
|
+
const completionWork = ['FAIL', 'UNKNOWN'].includes(completion.verdict) && !scopeOnlyUnknown ? [{
|
|
719
737
|
text: `You claimed "${completion.claims[0].text.slice(0, 160)}" is done; ${completion.problems.join('; ')}. Run the real consumer path, or restate it as UNVERIFIED.`,
|
|
720
738
|
at: new Date(nowMs).toISOString(), derived: true, kind: 'completion-claim-integrity',
|
|
721
739
|
}] : [];
|
|
@@ -815,7 +833,8 @@ function claimCooldown(now, windowMs) {
|
|
|
815
833
|
if (Number.isFinite(prev) && (now - prev) < windowMs) return false; // fresh lock: someone forced recently
|
|
816
834
|
fs.unlinkSync(LOCK); // stale: clear it so we can re-claim
|
|
817
835
|
} catch { /* no lock yet */ }
|
|
818
|
-
|
|
836
|
+
// A fresh install has no work-ledgers/ folder until the first save(); ENOENT here is not a lost race.
|
|
837
|
+
try { fs.mkdirSync(path.dirname(LOCK), { recursive: true }); fs.writeFileSync(LOCK, new Date(now).toISOString(), { flag: 'wx' }); return true; }
|
|
819
838
|
catch { return false; } // lost the race / cannot persist → fail closed
|
|
820
839
|
}
|
|
821
840
|
if (!claimCooldown(nowMs, COOLDOWN_MS)) process.exit(EXIT_ALLOW);
|
|
@@ -25,13 +25,12 @@ import { fileURLToPath } from 'node:url';
|
|
|
25
25
|
// an install hint into the middle of a refusal reason, or manufactured stderr on an allow. Read the
|
|
26
26
|
// signature, do not infer it from the name.
|
|
27
27
|
import { resolveBash } from './hook-shim-bash.mjs';
|
|
28
|
-
import { append as appendOutcome, actionKey, recordRefusal, resolve as resolveOutcome, sweepStale } from './decision-outcomes.mjs';
|
|
28
|
+
import { append as appendOutcome, actionKey, recordRefusal, reportBudget, reportSelfSkipped, resolve as resolveOutcome, sweepStale } from './decision-outcomes.mjs';
|
|
29
29
|
// Grok sends its own tool names (`write`) and a snake_case event; every policy below reads Claude's
|
|
30
30
|
// shape. Normalised ONCE here, so the policies (four of them bash, matching tool_name textually) never
|
|
31
31
|
// see a host-native spelling — the 4.4 measurement was a Grok write silently allowed. hook-input.mjs.
|
|
32
32
|
import { normalizePayloadText } from './hook-input.mjs';
|
|
33
|
-
|
|
34
|
-
const QUIET_FULL = String(process.env.RUVNET_STOP_NUDGES || '').toLowerCase() === 'full';
|
|
33
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
35
34
|
|
|
36
35
|
const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
37
36
|
const EVENT = process.argv[2] || '';
|
|
@@ -93,8 +92,8 @@ export const MIN_HEADROOM_MS = 3000;
|
|
|
93
92
|
*
|
|
94
93
|
* 1. ALLOW ON TIMEOUT. A policy still running at the budget is killed and the write is ALLOWED — a gate
|
|
95
94
|
* that blocks because it is slow is one users switch off. Never silent: the outcome ledger records
|
|
96
|
-
* `budget-exceeded` with the policies that did not vote
|
|
97
|
-
*
|
|
95
|
+
* `budget-exceeded` with the policies that did not vote; a stderr line saying the allow was a timeout,
|
|
96
|
+
* not a verdict, is printed only on a refusal or under RUVNET_STOP_NUDGES=full (decision-outcomes.mjs).
|
|
98
97
|
*
|
|
99
98
|
* 2. OVERSIZE: ONLY THE FIRST 64 KiB IS CHECKED. The host hands this gate the payload through
|
|
100
99
|
* hook-shim.mjs, which reads at most TRANSPORT_CAP_BYTES (its TABLE entry), and the bash policies cap
|
|
@@ -206,7 +205,7 @@ export function policiesFor(event, registry = REGISTRY, all = REFUSAL_POLICIES)
|
|
|
206
205
|
/**
|
|
207
206
|
* Resolved once per invocation by the runtime block below; null means no bash on this host.
|
|
208
207
|
*
|
|
209
|
-
* Declared HERE, and not next to runPolicy() where it reads more naturally: the
|
|
208
|
+
* Declared HERE, and not next to runPolicy() where it reads more naturally: the runtime
|
|
210
209
|
* block runs during module evaluation, so a `let` declared after it sits in the temporal dead zone
|
|
211
210
|
* and the assignment throws — the identical mistake `speechEventFor` was already a hoisted
|
|
212
211
|
* `function` to avoid, recorded a few lines further down.
|
|
@@ -285,13 +284,14 @@ function payloadInput(payload) { return parsed(payload).tool_input || {}; }
|
|
|
285
284
|
/**
|
|
286
285
|
* The sub-event token unprompted-runtime switches on.
|
|
287
286
|
*
|
|
288
|
-
* A hoisted `function`, not a `const` arrow: the
|
|
287
|
+
* A hoisted `function`, not a `const` arrow: the runtime block below runs DURING module
|
|
289
288
|
* evaluation, so an arrow declared after it sits in the temporal dead zone — it threw
|
|
290
289
|
* `Cannot access 'speechEventFor' before initialization` on this gate's first live refusal.
|
|
291
290
|
*/
|
|
292
291
|
function speechEventFor(event) { return event === 'bash' ? 'PreToolUse-bash' : 'PreToolUse-write'; }
|
|
293
292
|
// ── Runtime ──────────────────────────────────────────────────────────────────────────────────────
|
|
294
|
-
|
|
293
|
+
// isDirectInvocation never throws (entrypoint.mjs), so an importer cannot be crashed by this guard.
|
|
294
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
295
295
|
const started = Date.now();
|
|
296
296
|
const payload = normalizePayloadText(readPayload());
|
|
297
297
|
const shape = classifyPayload(payload);
|
|
@@ -348,14 +348,14 @@ if (isMain()) {
|
|
|
348
348
|
}
|
|
349
349
|
} catch { /* a ledger must never break a tool call */ }
|
|
350
350
|
};
|
|
351
|
-
reportSelfSkipped({ session, selfSkipped });
|
|
351
|
+
reportSelfSkipped({ event: EVENT, session, selfSkipped });
|
|
352
352
|
if (decision.allow && shape.truncated) {
|
|
353
353
|
// OVERSIZE POLICY (header): allowed on a check of the first TRANSPORT_CAP_BYTES only. Recorded, never silent.
|
|
354
354
|
try { appendOutcome({ kind: 'payload-oversize', event: EVENT, session, bytesSeen: shape.bytes, capBytes: TRANSPORT_CAP_BYTES, tool: shape.tool, filePath: shape.filePath, ts: Date.now() }); } catch { /* a ledger must never break a tool call */ }
|
|
355
355
|
}
|
|
356
356
|
if (!decision.allow) {
|
|
357
357
|
measureFinalDecision(false);
|
|
358
|
-
reportBudget({ session, unconsulted, trace, started, budgetMs, refused: true });
|
|
358
|
+
reportBudget({ event: EVENT, session, unconsulted, trace, started, budgetMs, refused: true });
|
|
359
359
|
process.stderr.write(`${decision.reason}\n`);
|
|
360
360
|
process.exit(REFUSE);
|
|
361
361
|
}
|
|
@@ -373,65 +373,22 @@ if (isMain()) {
|
|
|
373
373
|
if (speech.skipped === 'budget') unconsulted.push(SPEECH.id);
|
|
374
374
|
if (speech.code === REFUSE) {
|
|
375
375
|
measureFinalDecision(false, [...decision.refusals, SPEECH.id]);
|
|
376
|
-
reportBudget({ session, unconsulted, trace, started, budgetMs, refused: true });
|
|
376
|
+
reportBudget({ event: EVENT, session, unconsulted, trace, started, budgetMs, refused: true });
|
|
377
377
|
process.stderr.write(`${(speech.stderr || '').trim()}\n`);
|
|
378
378
|
process.exit(REFUSE);
|
|
379
379
|
}
|
|
380
|
-
reportBudget({ session, unconsulted, trace, started, budgetMs });
|
|
380
|
+
reportBudget({ event: EVENT, session, unconsulted, trace, started, budgetMs });
|
|
381
381
|
measureFinalDecision(true);
|
|
382
382
|
const context = policyContext(results, speech.stdout || '');
|
|
383
383
|
if (context) process.stdout.write(context);
|
|
384
384
|
process.exit(ALLOW);
|
|
385
385
|
}
|
|
386
|
-
reportBudget({ session, unconsulted, trace, started, budgetMs });
|
|
386
|
+
reportBudget({ event: EVENT, session, unconsulted, trace, started, budgetMs });
|
|
387
387
|
measureFinalDecision(true);
|
|
388
388
|
const context = policyContext([...results, ...unconsulted.filter((id) => !results.some((r) => r.id === id && r.skipped)).map((id) => ({ id, skipped: 'budget' }))]);
|
|
389
389
|
if (context) process.stdout.write(context);
|
|
390
390
|
process.exit(ALLOW);
|
|
391
391
|
}
|
|
392
|
-
/**
|
|
393
|
-
* ── A BUDGET THAT CAN BE EXCEEDED SILENTLY FAILS OPEN WITHOUT SAYING SO ──────────────────────────
|
|
394
|
-
*
|
|
395
|
-
* The old loop did `break` when the budget ran out. Every remaining policy AND the speech chokepoint
|
|
396
|
-
* were then skipped with no record anywhere — the gate allowed, and nothing distinguished "five
|
|
397
|
-
* policies agreed this was fine" from "we ran out of time and stopped asking". That is this repo's
|
|
398
|
-
* signature defect wearing a different hat: silence standing in for a measurement. degradation-watch
|
|
399
|
-
* exists because a warning was printed and skimmed; this exists because nothing was printed at all.
|
|
400
|
-
*
|
|
401
|
-
* TWO CHANNELS, because each fails differently:
|
|
402
|
-
* · the outcome ledger (~/.config/ruvnet-brain/, survives `--update`) so a trip COMPOUNDS into
|
|
403
|
-
* evidence instead of scrolling past. `kind: 'budget-exceeded'` is inert in report()'s buckets —
|
|
404
|
-
* it counts neither as a refusal nor as a resolution, so it cannot move the obedience rate.
|
|
405
|
-
* · one stderr line, unconditionally, allow or refuse. Yes, that puts bytes on stderr during an
|
|
406
|
-
* exit-0 allow, which tests/unit/decision-gate.test.mjs asserts never happens on an ordinary
|
|
407
|
-
* write. That assertion is now a SECOND tripwire and is meant to be: after the parallel batch and
|
|
408
|
-
* the applicability skip, an ordinary write measures ~360ms against a 4000ms budget, so a trip
|
|
409
|
-
* there is not noise to be tolerated — it is the defect, and the suite should go red for it.
|
|
410
|
-
*/
|
|
411
|
-
function reportBudget({ session, unconsulted, trace, started, budgetMs, refused = false }) {
|
|
412
|
-
const elapsed = Date.now() - started;
|
|
413
|
-
if (process.env.RUVNET_DECISION_TRACE === '1') {
|
|
414
|
-
process.stderr.write(`[decision-gate] ${EVENT} ${elapsed}ms budget=${budgetMs}ms ${JSON.stringify(trace)}\n`);
|
|
415
|
-
}
|
|
416
|
-
if (!unconsulted.length) return;
|
|
417
|
-
try {
|
|
418
|
-
appendOutcome({ kind: 'budget-exceeded', event: EVENT, session, unconsulted, elapsedMs: elapsed, budgetMs, ts: Date.now() });
|
|
419
|
-
} catch { /* a ledger must never break a tool call */ }
|
|
420
|
-
// Quiet by default (2026-10-15): the outcome is already in the ledger above. On an ALLOW, a stderr line only
|
|
421
|
-
// makes the host show a hook "error" for a decision that was made. It is kept when the action was refused
|
|
422
|
-
// and under RUVNET_STOP_NUDGES=full.
|
|
423
|
-
if (refused || QUIET_FULL) process.stderr.write(
|
|
424
|
-
`[decision-gate] ${budgetMs}ms budget exhausted after ${elapsed}ms — ${refused ? 'REFUSED; UNCONSULTED' : 'ALLOWED WITHOUT CONSULTING'}: `
|
|
425
|
-
+ `${unconsulted.join(', ')}. These policies did not vote; ${refused ? 'another policy refused the action.' : 'this allow is a timeout, not a verdict.'}\n`,
|
|
426
|
-
);
|
|
427
|
-
}
|
|
428
|
-
/** Record and print each policy that skipped itself (exit 3), with its own one-line reason. */
|
|
429
|
-
function reportSelfSkipped({ session, selfSkipped }) {
|
|
430
|
-
for (const r of selfSkipped) {
|
|
431
|
-
try { appendOutcome({ kind: 'policy-skipped', event: EVENT, session, policy: r.id, reason: r.reason, ts: Date.now() }); } catch { /* a ledger must never break a tool call */ }
|
|
432
|
-
if (QUIET_FULL) process.stderr.write(`[decision-gate] ${r.reason || `${r.id} skipped`} — ${r.id} did not vote; it supplied no authorization verdict.\n`);
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
392
|
/**
|
|
436
393
|
* Run one policy as a CAPTURED child. Never lets its bytes touch the real streams.
|
|
437
394
|
*
|
|
@@ -494,10 +451,3 @@ function readPayload() {
|
|
|
494
451
|
if (process.stdin.isTTY) return '';
|
|
495
452
|
try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
|
|
496
453
|
}
|
|
497
|
-
/** Never able to crash a caller that merely imported this (see tests/unit/entrypoint-guard-safety). */
|
|
498
|
-
function isMain() {
|
|
499
|
-
try {
|
|
500
|
-
if (!process.argv[1]) return false;
|
|
501
|
-
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
502
|
-
} catch { return false; }
|
|
503
|
-
}
|