ruvnet-brain 4.7.20 → 4.7.22
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 +618 -104
- package/console/activity.js +122 -38
- 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-process.mjs +115 -5
- package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
- package/plugin/scripts/agentdb-recall.mjs +49 -17
- 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 +241 -23
- package/plugin/scripts/continuation-gate.mjs +75 -11
- 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-gate.mjs +7 -39
- package/plugin/scripts/lesson-promote.mjs +83 -22
- package/plugin/scripts/lesson-scope.mjs +51 -0
- package/plugin/scripts/lesson-store.mjs +1 -1
- 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/ruflo-bin.mjs +14 -6
- package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
- package/plugin/scripts/session-snapshot-hook.mjs +25 -5
- package/plugin/scripts/session-start-core.mjs +7 -16
- package/plugin/scripts/session-start-health.mjs +98 -10
- package/plugin/scripts/session-start-issue-alert.mjs +63 -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-boot-surface-proof.mjs +208 -0
- 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 +116 -40
- package/scripts/job-heartbeat.sh +54 -19
- 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 +26 -20
- 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 +41 -18
- package/scripts/onboarding-console.mjs +123 -23
- 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/recall-latency-probe.mjs +48 -0
- 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 +82 -28
- 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 +13 -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';
|
|
@@ -105,6 +106,54 @@ function segmentMutates(segment) {
|
|
|
105
106
|
return /(?:^|[^0-9&>])>{1,2}\s*(?!&|\/dev\/null)(['"]?)([^\s'"]+)/.test(segment)
|
|
106
107
|
&& !TMP_PATH.test((/>{1,2}\s*['"]?([^\s'"]+)/.exec(segment) || [])[1] || '');
|
|
107
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* #369/#425: the exit status a transcript reports is the WHOLE command's. It is the status of one check only
|
|
111
|
+
* when the command is one simple command, or an `&&` chain (which reaches its end only if every part passed).
|
|
112
|
+
* Returns the reason a command is NOT such a shape (pipe, `||`, `;`, `&`, newline, substitution, subshell,
|
|
113
|
+
* redirection other than 2>&1 / /dev/null), or null. When unsure it names a reason: never binds by default.
|
|
114
|
+
*/
|
|
115
|
+
export function compoundReason(command) {
|
|
116
|
+
const s = String(command || '');
|
|
117
|
+
// A leading `!` (also after `&&`, after optional VAR=x prefixes) inverts the exit status: a failing check reports success.
|
|
118
|
+
if (/(?:^|&&)\s*(?:[A-Za-z_]\w*=\S*\s+)*!\s/.test(s)) return 'negated exit status (`!`)';
|
|
119
|
+
let q = '';
|
|
120
|
+
for (let i = 0; i < s.length; i++) {
|
|
121
|
+
const c = s[i];
|
|
122
|
+
const n = s[i + 1];
|
|
123
|
+
if (q === "'") { if (c === "'") q = ''; continue; }
|
|
124
|
+
if (c === '\\') { i++; continue; }
|
|
125
|
+
if (q === '"') {
|
|
126
|
+
if (c === '"') q = '';
|
|
127
|
+
else if (c === '`' || (c === '$' && n === '(')) return 'command substitution';
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (c === "'" || c === '"') { q = c; continue; }
|
|
131
|
+
if (c === '|') return n === '|' ? '`||` fallback' : 'pipe';
|
|
132
|
+
if (c === ';') return '`;` sequence';
|
|
133
|
+
if (c === '\n' || c === '\r') return 'multiple lines';
|
|
134
|
+
if (c === '`' || c === '(' || c === ')' || (c === '$' && n === '(')) return 'subshell or substitution';
|
|
135
|
+
if (c === '&') {
|
|
136
|
+
if (n === '&') { i++; continue; }
|
|
137
|
+
if (/^&>/.test(s.slice(i))) return 'redirection';
|
|
138
|
+
return 'background `&`';
|
|
139
|
+
}
|
|
140
|
+
if (c === '>' || c === '<') {
|
|
141
|
+
const m = /^(?:>&\d|>>?\s*\/dev\/null)/.exec(s.slice(i));
|
|
142
|
+
if (m) { i += m[0].length - 1; continue; }
|
|
143
|
+
return 'redirection';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const SAFE_REDIRECT = /\s*\d?>>?\s*\/dev\/null|\s*\d?>&\d/g;
|
|
149
|
+
/** The command a claim would name: safe redirects (2>&1, >/dev/null) are not part of it. */
|
|
150
|
+
const withoutSafeRedirects = (seg) => seg.replace(SAFE_REDIRECT, '').trim();
|
|
151
|
+
/** For an unbound segment: the leading command a claim could have named (cut at the first operator). */
|
|
152
|
+
function headsOf(seg) {
|
|
153
|
+
const lead = seg.replace(/^[\s(]*(?:\$\()?/, '');
|
|
154
|
+
const cut = (t) => t.split(/[|;&<>()`$\n]/)[0].trim();
|
|
155
|
+
return [...new Set([cut(seg), cut(lead)].filter((h) => h.length >= 4))];
|
|
156
|
+
}
|
|
108
157
|
const segmentChecks = (segment) => !TRIVIAL.test(segment) && !segmentMutates(segment);
|
|
109
158
|
function checkName(segment) {
|
|
110
159
|
const words = segment.replace(/^(?:[A-Z_][A-Z0-9_]*=\S+\s+)+/, '').split(/\s+/);
|
|
@@ -132,12 +181,24 @@ export function currentTurnRecords(lines) {
|
|
|
132
181
|
const isToolResult = Array.isArray(c) && c.some((x) => x?.type === 'tool_result');
|
|
133
182
|
if (o?.type === 'user' && (o.message?.role || 'user') === 'user' && !isToolResult && !o.isMeta && textOf(c).trim()) start = i;
|
|
134
183
|
});
|
|
135
|
-
|
|
184
|
+
// `openerId` identifies the turn by its opening record (uuid, else its position among the main-thread records),
|
|
185
|
+
// never by the prompt text: a repeated "continue" is a different turn with the same words.
|
|
186
|
+
const openerId = start < 0 ? '' : (typeof recs[start].uuid === 'string' && recs[start].uuid ? `uuid:${recs[start].uuid}` : `pos:${start}`);
|
|
187
|
+
return { boundaryFound: start >= 0, prompt: start >= 0 ? textOf(recs[start].message?.content) : '', openerId, recs: recs.slice(start + 1) };
|
|
136
188
|
}
|
|
137
189
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
190
|
+
// Codex tools that neither change state nor run a check; every other unrecognised tool is a change (fail
|
|
191
|
+
// toward asking). Grounded in codex-cli 0.164.0's tool handlers (plan, view_image, tool_search,
|
|
192
|
+
// mcp_resource/{list_mcp_resources,list_mcp_resource_templates,read_mcp_resource}, current_time,
|
|
193
|
+
// get_context_remaining, wait_for_environment, multi_agents/wait, sleep, request_user_input[_async]) and real
|
|
194
|
+
// rollouts (code-mode `wait` on a cell, `wait_agent`/`list_agents`). web_search is a hosted search, read-only.
|
|
195
|
+
// Acting tools stay changes: spawn_agent, send_message, followup_task, interrupt_agent, js, exec, update_goal.
|
|
196
|
+
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)$/;
|
|
197
|
+
|
|
198
|
+
/** Ordered events for the current turn of a Claude JSONL (or normalized Codex) transcript. */
|
|
199
|
+
export function claudeTurnEvents(lines, { host = 'claude' } = {}) {
|
|
200
|
+
const codex = host === 'codex';
|
|
201
|
+
const { boundaryFound, prompt: turnPrompt, openerId, recs: turnRecs } = currentTurnRecords(lines);
|
|
141
202
|
const results = new Map();
|
|
142
203
|
for (const o of turnRecs) {
|
|
143
204
|
const c = o?.message?.content;
|
|
@@ -145,7 +206,8 @@ export function claudeTurnEvents(lines) {
|
|
|
145
206
|
for (const r of c) {
|
|
146
207
|
if (r?.type === 'tool_result' && r.tool_use_id) {
|
|
147
208
|
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
|
|
209
|
+
present: textOf(r.content).trim().length > 0 || String(o.toolUseResult?.stdout || '').trim().length > 0,
|
|
210
|
+
...(codex ? { passed: normalizeToolOutcome(r).outcome === 'pass', nativeOk: r.is_error === false && r.status === 'completed' } : {}) });
|
|
149
211
|
}
|
|
150
212
|
}
|
|
151
213
|
}
|
|
@@ -156,23 +218,32 @@ export function claudeTurnEvents(lines) {
|
|
|
156
218
|
for (const u of c) {
|
|
157
219
|
if (u?.type !== 'tool_use') continue;
|
|
158
220
|
const input = u.input || {};
|
|
159
|
-
const
|
|
160
|
-
|
|
221
|
+
const found = results.get(u.id) || { present: false, error: false };
|
|
222
|
+
// Codex: a check needs an explicit exit 0 (commands) or a completed native status (MCP).
|
|
223
|
+
const { passed, nativeOk, ...result } = codex ? { ...found, error: found.error || !(u.name === 'Bash' ? found.passed : found.nativeOk) } : found;
|
|
224
|
+
if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(u.name) || (codex && u.name === 'apply_patch')) {
|
|
161
225
|
events.push({ kind: 'change', what: `${u.name} ${input.file_path || input.notebook_path || ''}`.trim() });
|
|
162
226
|
} else if (u.name === 'Bash') {
|
|
227
|
+
const unbound = compoundReason(input.command);
|
|
163
228
|
for (const seg of segments(input.command)) {
|
|
164
229
|
if (segmentMutates(seg)) events.push({ kind: 'change', what: seg.slice(0, 120) });
|
|
165
|
-
else if (segmentChecks(seg))
|
|
230
|
+
else if (segmentChecks(seg)) {
|
|
231
|
+
const cmd = unbound ? seg : withoutSafeRedirects(seg);
|
|
232
|
+
events.push({ kind: 'check', what: cmd.slice(0, 120), command: cmd.slice(0, 400), name: checkName(seg), ...result,
|
|
233
|
+
...(unbound ? { unbound: `the command uses ${unbound}, so its exit status is not this check's`, heads: headsOf(seg) } : {}) });
|
|
234
|
+
}
|
|
166
235
|
}
|
|
167
236
|
} else if (u.name === 'Agent' || u.name === 'Task') {
|
|
168
237
|
if (!READ_ONLY_AGENTS.test(String(input.subagent_type || ''))) events.push({ kind: 'change', what: `${u.name} ${input.subagent_type || 'agent'}` });
|
|
169
238
|
} else if (String(u.name || '').startsWith('mcp__')) {
|
|
170
239
|
events.push(MCP_MUTATING.test(u.name) ? { kind: 'change', what: u.name }
|
|
171
240
|
: { kind: 'check', what: u.name, name: u.name.split('__').pop(), ...result });
|
|
241
|
+
} else if (codex && !CODEX_NEUTRAL.test(String(u.name || ''))) {
|
|
242
|
+
events.push({ kind: 'change', what: `codex ${String(u.name || 'tool').slice(0, 60)}` });
|
|
172
243
|
}
|
|
173
244
|
}
|
|
174
245
|
}
|
|
175
|
-
return { boundaryFound, events };
|
|
246
|
+
return { boundaryFound, prompt: turnPrompt, openerId, events };
|
|
176
247
|
}
|
|
177
248
|
|
|
178
249
|
/** Verification that ran AFTER the last state change this turn, with a present, non-error result. */
|
|
@@ -180,14 +251,21 @@ export function postChangeVerification(turn) {
|
|
|
180
251
|
const events = turn?.events || [];
|
|
181
252
|
let lastChange = -1;
|
|
182
253
|
events.forEach((e, i) => { if (e.kind === 'change') lastChange = i; });
|
|
183
|
-
const
|
|
184
|
-
|
|
254
|
+
const after = events.slice(lastChange + 1).filter((e) => e.kind === 'check' && e.present && !e.error);
|
|
255
|
+
// `checks` is STRICT (binds a claim or a promise). `maskedChecks` ran with output and no error, but the command
|
|
256
|
+
// is a pipe / `||` / `;` / file redirect, so its exit status is not attributable: counted only as "a check ran".
|
|
257
|
+
const checks = after.filter((e) => !e.unbound);
|
|
258
|
+
const maskedChecks = after.filter((e) => e.unbound);
|
|
259
|
+
return { lastChange: lastChange >= 0 ? events[lastChange].what : null, checks, maskedChecks,
|
|
185
260
|
staleChecks: events.slice(0, Math.max(0, lastChange)).filter((e) => e.kind === 'check').length };
|
|
186
261
|
}
|
|
187
262
|
|
|
188
|
-
export function readClaudeTurn(transcriptPath, { read = readSettledTranscript } = {}) {
|
|
263
|
+
export function readClaudeTurn(transcriptPath, { read = readSettledTranscript, host = 'claude' } = {}) {
|
|
189
264
|
if (typeof transcriptPath !== 'string' || !/\.jsonl$/i.test(transcriptPath)) return null;
|
|
190
|
-
try {
|
|
265
|
+
try {
|
|
266
|
+
const turn = claudeTurnEvents(nativeTurnLines(read(transcriptPath, { maxMs: 0 }), host), { host });
|
|
267
|
+
return host === 'codex' && !turn.boundaryFound ? null : turn;
|
|
268
|
+
} catch { return null; }
|
|
191
269
|
}
|
|
192
270
|
|
|
193
271
|
/**
|
|
@@ -196,16 +274,19 @@ export function readClaudeTurn(transcriptPath, { read = readSettledTranscript }
|
|
|
196
274
|
*/
|
|
197
275
|
export function auditCompletionClaims(message, { turn = null, host = 'claude' } = {}) {
|
|
198
276
|
const claims = extractCompletionClaims(message);
|
|
199
|
-
if (!claims.length) return { verdict: 'NONE', claims };
|
|
277
|
+
if (!claims.length) return { verdict: 'NONE', claims, outcomes: [] };
|
|
200
278
|
const text = String(message || '');
|
|
201
279
|
const verification = turn ? postChangeVerification(turn) : null;
|
|
202
|
-
|
|
280
|
+
// Naming a check that ran is not binding it: masked checks count here so an inline mention is not 'unnamed'; transcriptOk below still uses strict checks only.
|
|
281
|
+
const names = verification ? [...verification.checks, ...verification.maskedChecks].map((c) => String(c.name || '').toLowerCase()).filter((n) => n.length >= 3) : [];
|
|
203
282
|
const namesCheck = NAMED_CHECK.test(text) || names.some((n) => text.toLowerCase().includes(n));
|
|
204
283
|
const disclosesGaps = DISCLOSES_GAPS.test(strippedProse(text).replace(/```[\s\S]*?```/g, ''))
|
|
205
284
|
|| DISCLOSES_GAPS.test(text);
|
|
206
285
|
const problems = [];
|
|
207
286
|
if (!turn) problems.push(`this host's (${host}) transcript is unavailable or not parsed, so whether a check ran after the change is UNKNOWN`);
|
|
208
|
-
else if (!verification.checks.length) {
|
|
287
|
+
else if (!verification.checks.length && verification.maskedChecks.length) {
|
|
288
|
+
problems.push('a check ran after the last change, but its command uses a pipe, `||`, `;` or a file redirect, so its exit status is not attributable to it; completion is UNKNOWN');
|
|
289
|
+
} else if (!verification.checks.length) {
|
|
209
290
|
problems.push(verification.lastChange
|
|
210
291
|
? `no end-to-end check ran this turn after the last change (${verification.lastChange})`
|
|
211
292
|
: 'no end-to-end check ran this turn');
|
|
@@ -218,7 +299,81 @@ export function auditCompletionClaims(message, { turn = null, host = 'claude' }
|
|
|
218
299
|
if (transcriptOk && namesCheck && disclosesGaps && !scopedCheck) problems.push('observed checks cover only their executed scope; whole-task completion is UNKNOWN');
|
|
219
300
|
const verdict = transcriptOk && namesCheck && disclosesGaps && scopedCheck ? 'OBSERVED_CHECK'
|
|
220
301
|
: namesCheck && disclosesGaps ? 'UNKNOWN' : 'FAIL';
|
|
221
|
-
return { verdict, claims, verification, namesCheck, disclosesGaps, problems };
|
|
302
|
+
return { verdict, claims, verification, namesCheck, disclosesGaps, problems, outcomes: claimOutcomes(claims, text, turn) };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── per-claim outcomes (#425) ───────────────────────────────────────────────────────────────────
|
|
306
|
+
// Evidence per claimed outcome, NOT completion authority: `authority:false` always, the verdict above is
|
|
307
|
+
// computed without it, and no state here is PASS. A claim is `observed-check` only when its own sentence
|
|
308
|
+
// (or a line-start `Verified:` line) NAMES the command of a check that ran after the last change, produced
|
|
309
|
+
// output and did not error. Anything else is reported as what it is: stale, failed, or unbound.
|
|
310
|
+
const VERIFIED_LINE = /(?:^|\n)[ \t]*(?:[-*•][ \t]*|\*\*)*verified\b[^\n:—–]{0,4}?(?:\*\*)?[ \t]*[:—–][ \t]*([^\n]+)/gi;
|
|
311
|
+
// Env-var prefixes are part of the command (`SKIP_HOOKS=1 npm run x` is not `npm run x`): never stripped.
|
|
312
|
+
const squash = (value) => String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
313
|
+
/** `hay` contains `cmd` as a whole command: not preceded by an env-var assignment or a word character. */
|
|
314
|
+
export function containsCommand(hay, cmd) {
|
|
315
|
+
const h = String(hay || ''); const c = String(cmd || '');
|
|
316
|
+
if (!c) return false;
|
|
317
|
+
for (let at = h.indexOf(c); at >= 0; at = h.indexOf(c, at + 1)) {
|
|
318
|
+
const before = h.slice(0, at);
|
|
319
|
+
if (/[\w./:=-]$/.test(before) || /^[\w:-]/.test(h.slice(at + c.length))) continue;
|
|
320
|
+
if (/(?:^|[\s`'"(])[A-Za-z_][A-Za-z0-9_]*=\S*\s+$/.test(before)) continue;
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function rawSentenceOf(message, claim) {
|
|
327
|
+
for (const raw of String(message || '').split(/(?<=[.!?])\s+|\n+/)) if (sentences(raw).includes(claim.text)) return raw;
|
|
328
|
+
return claim.text;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function claimOutcomes(claims, message, turn) {
|
|
332
|
+
const events = turn?.events || [];
|
|
333
|
+
let lastChange = -1;
|
|
334
|
+
events.forEach((e, i) => { if (e.kind === 'change') lastChange = i; });
|
|
335
|
+
const checks = events.map((e, i) => ({ e, i })).filter(({ e }) => e.kind === 'check' && String(e.command || '').length >= 4);
|
|
336
|
+
const verifiedLines = [...String(message || '').matchAll(VERIFIED_LINE)].map((m) => squash(m[1])).join('\n');
|
|
337
|
+
return (claims || []).map((claim) => {
|
|
338
|
+
const own = squash(rawSentenceOf(message, claim));
|
|
339
|
+
const namedAll = checks.filter(({ e }) => { const c = squash(e.command); const hit = (h) => containsCommand(own, h) || containsCommand(verifiedLines, h);
|
|
340
|
+
return hit(c) || (e.unbound && (e.heads || []).some((h) => hit(squash(h)))); });
|
|
341
|
+
const named = namedAll.filter(({ e }) => !e.unbound);
|
|
342
|
+
const good = named.filter(({ e, i }) => i > lastChange && e.present && !e.error);
|
|
343
|
+
const state = good.length ? 'observed-check'
|
|
344
|
+
: named.some(({ e, i }) => i > lastChange && !(e.present && !e.error)) ? 'failed-check'
|
|
345
|
+
: named.length ? 'stale' : 'unbound';
|
|
346
|
+
const reason = state === 'unbound' ? namedAll.find(({ e }) => e.unbound)?.e.unbound : undefined;
|
|
347
|
+
return { claim: claim.text.slice(0, 200), state, ...(reason ? { reason } : {}),
|
|
348
|
+
checks: (state === 'observed-check' ? good : named).slice(-3).map(({ e }) => ({ command: e.command, name: e.name })), authority: false };
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* #425 (sparkling): the audit category of a correction, independent of wording. A correction is delivered
|
|
354
|
+
* at most once per session + user turn + category, so rewording the answer or waiting cannot rearm it.
|
|
355
|
+
*/
|
|
356
|
+
export function completionCategory(completion) {
|
|
357
|
+
const ids = (completion?.problems || []).map((p) => /unavailable or not parsed/.test(p) ? 'transcript-unavailable'
|
|
358
|
+
: /no end-to-end check ran/.test(p) ? 'no-check' : /not attributable/.test(p) ? 'check-unattributed' : /does not name the check/.test(p) ? 'check-unnamed'
|
|
359
|
+
: /does not disclose/.test(p) ? 'gaps-undisclosed' : /observed checks cover only/.test(p) ? 'scope' : 'other');
|
|
360
|
+
return `${completion?.verdict || 'NONE'}:${[...new Set(ids)].sort().join('+')}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Correction copy for a FAIL/UNKNOWN verdict. When checks DID run, ask for an outcome-by-outcome report. */
|
|
364
|
+
export function completionCorrectionText(completion) {
|
|
365
|
+
const lead = `You claimed "${completion.claims[0].text.slice(0, 160)}" is done; ${completion.problems.join('; ')}.`;
|
|
366
|
+
return (completion.verification?.checks?.length || 0) + (completion.verification?.maskedChecks?.length || 0) > 0
|
|
367
|
+
? `${lead} A check ran, but it does not cover every claim: report outcome by outcome, naming the check that ran for each claim or restating it as UNVERIFIED.`
|
|
368
|
+
: `${lead} Run the real consumer path, or restate it as UNVERIFIED.`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Non-blocking advisory text: which claims have an observed check and which do not. */
|
|
372
|
+
export function outcomeAdvisoryText(completion) {
|
|
373
|
+
const lines = (completion.outcomes || []).map((o) => `- ${o.state}: "${o.claim.slice(0, 100)}"${o.checks.length ? ` (${o.checks.map((c) => c.command).join('; ')})` : ''}`);
|
|
374
|
+
const masked = !(completion.verification?.checks?.length) && (completion.verification?.maskedChecks?.length || 0) > 0;
|
|
375
|
+
return `A check ran after the last change${masked ? ' (its command uses a pipe, `||`, `;` or a redirect, so its exit status is not attributable to it)' : ''} and the answer names it and discloses what is not verified. Per claimed outcome:\n${lines.join('\n')}\n`
|
|
376
|
+
+ 'This is evidence per outcome, not completion authority: completion stays UNKNOWN. Not blocking: more checks or rewording cannot change it.';
|
|
222
377
|
}
|
|
223
378
|
|
|
224
379
|
// ── promises (first-person commitments) ─────────────────────────────────────────────────────────
|
|
@@ -227,6 +382,10 @@ const PROMISE_REJECT = /\?|:\*\*|\b(?:if|unless|once|when|whenever|as\s+soon\s+a
|
|
|
227
382
|
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
383
|
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
384
|
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;
|
|
385
|
+
// "hide it the same way", "check for it then": a verb whose only object is a pronoun has no referent
|
|
386
|
+
// once the turn's context is gone (#369), so no later session could ever close it honestly.
|
|
387
|
+
const PRONOUN_OBJECT = /\b(?:it|this|that|them|these|those)\b/i;
|
|
388
|
+
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
389
|
|
|
231
390
|
export const normalizePromise = (value) => String(value || '').toLowerCase().replace(/[`*_"“”'’]/g, '')
|
|
232
391
|
.replace(/[^a-z0-9./:+-]+/g, ' ').trim().slice(0, 160);
|
|
@@ -248,13 +407,31 @@ export function extractCommitments(message) {
|
|
|
248
407
|
if (PROMISE_REJECT.test(whole) || OWNER_DEPENDENT_OFFER.test(whole)
|
|
249
408
|
|| STANDING_APPROVAL.test(action.trim()) || TRIVIAL_PROMISE.test(action.trim())) continue;
|
|
250
409
|
const text = action.replace(/\s+/g, ' ').trim().replace(/[,;:]$/, '');
|
|
410
|
+
if (PRONOUN_OBJECT.test(text) && PRONOUN_ONLY.test(text)) continue;
|
|
251
411
|
const key = normalizePromise(text);
|
|
252
412
|
if (key.length < 6 || seen.has(key)) continue;
|
|
253
413
|
seen.add(key);
|
|
254
414
|
out.push({ text, key });
|
|
255
415
|
}
|
|
256
416
|
}
|
|
257
|
-
return out.slice(0, PROMISE_CAP_PER_TURN)
|
|
417
|
+
return out.slice(0, PROMISE_CAP_PER_TURN).map((p) => {
|
|
418
|
+
const evidenceCommands = promiseCommands(message, p.key);
|
|
419
|
+
return evidenceCommands.length ? { ...p, evidenceCommands } : p;
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// #369: inline-code commands in the promise's own sentence are what the promise says it will run. They are
|
|
424
|
+
// recorded at capture (max 2) so a later observed check can be matched to them; prose alone never is.
|
|
425
|
+
const COMMAND_SPAN = /`([^`\n]{4,160})`/g;
|
|
426
|
+
function promiseCommands(message, key) {
|
|
427
|
+
for (const raw of String(message || '').split(/(?<=[.!?])\s+|\n+/)) {
|
|
428
|
+
const prose = strippedProse(raw).replace(/^\s*(?:\d+[.)]|[-*•])\s+/, '').trim();
|
|
429
|
+
const action = PROMISE_LEAD.exec(prose)?.[1] ?? prose;
|
|
430
|
+
if (normalizePromise(action) !== key) continue;
|
|
431
|
+
const found = [...raw.matchAll(COMMAND_SPAN)].map((m) => squash(m[1])).filter((c) => /\s/.test(c) || c.startsWith('./'));
|
|
432
|
+
return [...new Set(found)].slice(0, 2);
|
|
433
|
+
}
|
|
434
|
+
return [];
|
|
258
435
|
}
|
|
259
436
|
|
|
260
437
|
const STOP = new Set(['the', 'a', 'an', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'with', 'it', 'is', 'are',
|
|
@@ -269,3 +446,44 @@ export function claimClosesPromise(claimText, promiseText) {
|
|
|
269
446
|
const hit = want.filter((t) => have.has(t)).length;
|
|
270
447
|
return hit >= 2 && hit / want.length >= 0.4;
|
|
271
448
|
}
|
|
449
|
+
|
|
450
|
+
const unquoted = (value) => String(value).replace(/"([^"\n]{0,300})"|“([^”\n]{0,300})”/g, (_, a, b) => a ?? b);
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The claim that closes `promiseText`, or null. Only a PASS verdict can close a promise. strippedProse()
|
|
454
|
+
* removes double-quoted text, so `"push the branch itself" is done` extracts only `is done`; that claim
|
|
455
|
+
* may still close the promise through its raw sentence (quotes kept), which becomes the evidence. The
|
|
456
|
+
* sentence must be a recognised claim outside its quotes, so quoting alone never makes a claim (#369).
|
|
457
|
+
*/
|
|
458
|
+
export function promiseClosingClaim(completion, answer, promiseText) {
|
|
459
|
+
// DORMANT: auditCompletionClaims no longer returns PASS (only OBSERVED_CHECK/UNKNOWN/FAIL/NONE), so this never closes a promise today.
|
|
460
|
+
if (completion?.verdict !== 'PASS') return null;
|
|
461
|
+
return matchingClaim(completion, answer, promiseText);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function matchingClaim(completion, answer, promiseText) {
|
|
465
|
+
const direct = completion.claims.find((c) => claimClosesPromise(c.text, promiseText));
|
|
466
|
+
if (direct) return direct;
|
|
467
|
+
for (const raw of String(answer || '').split(/(?<=[.!?])\s+|\n+/)) {
|
|
468
|
+
const own = sentences(raw);
|
|
469
|
+
const claim = completion.claims.find((c) => own.includes(c.text));
|
|
470
|
+
if (claim && claimClosesPromise(unquoted(raw), promiseText)) return { ...claim, text: raw.trim() };
|
|
471
|
+
}
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* #369: the evidence that moves a promise to the NON-completed state `evidenced`, or null. Never `done`, never
|
|
477
|
+
* PASS, never authority. ALL must hold: a command the promise itself named ran after the last change with
|
|
478
|
+
* output and no error; the answer names that command and states what is not verified; a claim sentence
|
|
479
|
+
* matches the promise; the verdict is not FAIL. (Same-session ownership is checked by the caller.)
|
|
480
|
+
*/
|
|
481
|
+
export function promiseEvidence(completion, answer, item) {
|
|
482
|
+
const commands = Array.isArray(item?.evidenceCommands) ? item.evidenceCommands.map(squash) : [];
|
|
483
|
+
if (!commands.length || !completion?.claims?.length || !['OBSERVED_CHECK', 'UNKNOWN'].includes(completion.verdict)
|
|
484
|
+
|| completion.disclosesGaps !== true) return null;
|
|
485
|
+
const said = squash(answer);
|
|
486
|
+
const check = (completion.verification?.checks || []).find((c) => !c.unbound && commands.includes(squash(c.command)) && containsCommand(said, squash(c.command)));
|
|
487
|
+
const claim = check && matchingClaim(completion, answer, item.text);
|
|
488
|
+
return claim ? { command: squash(check.command), check: check.what, claim: claim.text, auditVerdict: completion.verdict, authority: false } : null;
|
|
489
|
+
}
|