ruvnet-brain 4.3.28 → 4.3.29
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 +1 -1
- package/bin/install.mjs +74 -7
- package/console/app.js +7 -2
- package/kb/corpus-release-identity.mjs +73 -0
- package/package.json +4 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +83 -9
- package/plugin/scripts/advocacy-route.mjs +4 -1
- package/plugin/scripts/capacity-aware-parallel-work.mjs +4 -0
- package/plugin/scripts/continuation-gate.mjs +71 -0
- package/plugin/scripts/decision-gate.mjs +29 -57
- package/plugin/scripts/ground-ruvnet.sh +77 -4
- package/plugin/scripts/grounding-stamp.sh +34 -5
- package/plugin/scripts/grounding-turn-gate.mjs +8 -2
- package/plugin/scripts/grounding-turn-mark.mjs +6 -1
- package/plugin/scripts/hook-input.mjs +54 -0
- package/plugin/scripts/hook-shim.mjs +10 -0
- package/plugin/scripts/memory-doctor.mjs +43 -0
- package/plugin/scripts/nightly-controller.mjs +6 -1
- package/plugin/scripts/project-progression-contract.mjs +17 -0
- package/plugin/scripts/project-progression-hook.mjs +18 -5
- package/plugin/scripts/project-progression-producer.mjs +16 -12
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +17 -0
- package/plugin/scripts/session-start-core.mjs +11 -5
- package/plugin/scripts/session-start-update-plane.mjs +5 -1
- package/plugin/scripts/unprompted-runtime.mjs +29 -8
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +1 -1
- package/scripts/adr-072-completion.mjs +2 -1
- package/scripts/calibrate-router.mjs +6 -6
- package/scripts/corpus-reconcile.mjs +32 -3
- package/scripts/correction-detect.mjs +10 -11
- package/scripts/dispatch-receipt.mjs +2 -2
- package/scripts/dual-host-deliberation.mjs +3 -2
- package/scripts/execution-policy.mjs +10 -2
- package/scripts/gen-console-images.mjs +0 -1
- package/scripts/gen-images.mjs +0 -4
- package/scripts/host-install-matrix.mjs +7 -4
- package/scripts/independent-review-receipt.mjs +7 -8
- package/scripts/ingest-repo.mjs +45 -1
- package/scripts/learning-replay-cli.mjs +2 -1
- package/scripts/learning-replay-fixture.mjs +2 -1
- package/scripts/learnings.mjs +19 -5
- package/scripts/lesson-migrate-agentdb.mjs +635 -0
- package/scripts/loop-checkpoint.mjs +37 -1
- package/scripts/metaharness-receipts.mjs +3 -2
- package/scripts/nightly-two-run-proof.mjs +6 -6
- package/scripts/nightly-watchdog.mjs +10 -2
- package/scripts/onboarding-console.mjs +98 -49
- package/scripts/oracle/producer-hosts.mjs +2 -1
- package/scripts/private-overlay.mjs +31 -9
- package/scripts/public-verification-aggregate.mjs +4 -3
- package/scripts/publication-receipt.mjs +9 -0
- package/scripts/qe/agentic-qe-4.3.mjs +0 -1
- package/scripts/rebuild-gists-from-receipts.mjs +1 -1
- package/scripts/reconcile-project.mjs +0 -0
- package/scripts/release.mjs +34 -82
- package/scripts/retrieval-canary.mjs +3 -2
- package/scripts/review-model-defaults.mjs +26 -0
- package/scripts/route-cheap.mjs +20 -7
- package/scripts/router-utilization.mjs +5 -5
- package/scripts/rvf-generation.mjs +68 -2
- package/scripts/single-source-check.mjs +270 -0
- package/scripts/subscription-hosts.mjs +4 -0
- package/scripts/sync-version.mjs +22 -0
- package/scripts/trismart.mjs +3 -3
- package/scripts/wired-check.mjs +40 -4
- package/console/assets/memory.webp +0 -0
- package/plugin/scripts/version-bump-gate.sh +0 -124
- package/scripts/qe/aggregate-4.3.mjs +0 -42
- package/scripts/release-convergence-watchdog.mjs +0 -112
- package/scripts/stamp-existing-rvf-generations.mjs +0 -53
- package/scripts/verify-channels.mjs +0 -196
|
@@ -92,34 +92,30 @@ export const MIN_HEADROOM_MS = 3000;
|
|
|
92
92
|
* hijack-ruvnet the managed-memory boundary (ADR-063): a correctness rule about where data
|
|
93
93
|
* goes, ahead of anything about process.
|
|
94
94
|
* ground-before-write don't write RuvNet-product code ungrounded (ADR-0012).
|
|
95
|
-
* design-wall don't ship a visual surface nobody looked at.
|
|
96
95
|
*
|
|
97
96
|
* `unprompted-speech` is LAST and is not really a peer: it is the speech chokepoint, which refuses
|
|
98
|
-
* only for a lesson the user personally opted into blocking. It is included so that Write/Edit
|
|
99
|
-
*
|
|
100
|
-
*
|
|
97
|
+
* only for a lesson the user personally opted into blocking. It is included so that Write/Edit have
|
|
98
|
+
* exactly ONE process that can refuse them — which is the entire invariant — and its allow-path
|
|
99
|
+
* stdout envelope is forwarded untouched.
|
|
100
|
+
*
|
|
101
|
+
* H5 (this repo's own dead-code audit): a 'bash' route used to sit alongside 'write' here, gating
|
|
102
|
+
* identifier-preflight, spend-guard, degradation-watch, hijack-ruvnet (a SECOND use, alongside its
|
|
103
|
+
* 'write' one) and design-wall on a PreToolUse-Bash event. It was never registered in
|
|
104
|
+
* plugin/hooks/hooks.json or codex-hooks.json — continuity-hook-policy.mjs's own header names this
|
|
105
|
+
* explicitly: "decision-gate's BASH route ... remains reachable through hook-shim's dispatch table
|
|
106
|
+
* by explicit invocation" only, never wired into the automatic plane. Removed as dead routing, not
|
|
107
|
+
* as a verdict on the four now-orphaned policies' worth: identifier-preflight.mjs, spend-guard.mjs,
|
|
108
|
+
* degradation-watch.mjs and design-wall.sh all remain in the tree with their own passing tests
|
|
109
|
+
* (each exports/exposes pure, independently-tested logic — `check`/`identifierIn`, `dependentEvent`,
|
|
110
|
+
* etc. — used elsewhere, e.g. tests/unit/lesson-gate.test.mjs imports degradation-watch.mjs's
|
|
111
|
+
* `dependentEvent` directly to cross-check lesson-hooks.sh's own pattern). Only their SELECTION by
|
|
112
|
+
* this gate's dead 'bash' route is removed here.
|
|
101
113
|
*/
|
|
102
114
|
const POLICY = (id, file, interpreter = 'bash') => ({ id, file, interpreter });
|
|
103
115
|
const REFUSAL_POLICIES = [
|
|
104
116
|
POLICY('protect-state', 'protect-brain-state.sh'),
|
|
105
|
-
// degradation-watch sits second because it decides whether ANY record this system keeps is real.
|
|
106
|
-
// Measured 2026-08-13: better_sqlite3.node was built for NODE_MODULE_VERSION 141 against a node
|
|
107
|
-
// needing 137, ruflo fell back to sql.js, and nothing persisted for three days while every write
|
|
108
|
-
// printed `[OK] Data stored successfully`. A warning was printed on every one of those writes and
|
|
109
|
-
// read. It could not stop anything, because a warning is text and text is skimmable — so this is
|
|
110
|
-
// a refusal instead. It probes only for commands whose truth DEPENDS on durable memory (a lesson
|
|
111
|
-
// store, a ship), so ordinary Bash pays nothing.
|
|
112
|
-
POLICY('degradation-watch', 'degradation-watch.mjs', 'node'),
|
|
113
|
-
// identifier-preflight is FIRST among the cheap checks and costs one file read: it refuses a
|
|
114
|
-
// command that names a model this machine's CLI does not accept. `codex exec --model gpt-5.6`
|
|
115
|
-
// (correct: gpt-5.6-sol, in ~/.codex/config.toml) printed a 400 and EXITED 0 into a redirected
|
|
116
|
-
// file on 2026-08-13, so a 50-minute audit produced nothing and there was no exit code to catch.
|
|
117
|
-
// It refuses ONLY a positively-known-wrong value and allows every unknown, because a wall that
|
|
118
|
-
// fabricates a reason is one people learn to route around.
|
|
119
|
-
POLICY('identifier-preflight', 'identifier-preflight.mjs', 'node'),
|
|
120
117
|
POLICY('hijack-ruvnet', 'hijack-ruvnet.sh'),
|
|
121
118
|
POLICY('ground-before-write', 'ground-before-write.sh'),
|
|
122
|
-
POLICY('design-wall', 'design-wall.sh'),
|
|
123
119
|
// adr-currency-gate fires on the EDIT, where the pre-push gate fires on the push. Same rule, same
|
|
124
120
|
// machinery (it calls doc-currency.mjs, never a second copy of the logic) — moved to the earliest
|
|
125
121
|
// moment it has enough information. On 2026-08-13 four ADRs went stale together and were caught
|
|
@@ -128,22 +124,12 @@ const REFUSAL_POLICIES = [
|
|
|
128
124
|
// DEBT, not change: you may edit governed code freely, but not while a document governing it is
|
|
129
125
|
// still unreconciled from the last round.
|
|
130
126
|
POLICY('adr-currency', 'adr-currency-gate.mjs', 'node'),
|
|
131
|
-
// spend-guard refuses an agent FLEET that would inherit metered API keys. The $1,600 of
|
|
132
|
-
// agentic-qe#557: ~374 headless agents billed api.anthropic.com per-token for 11 hours while the
|
|
133
|
-
// Claude Max subscription sat unused. The rule was stored, ratified and severity:high — and
|
|
134
|
-
// delivered as advisory text, which is what gets skimmed. `claude` and `codex` are the seats and
|
|
135
|
-
// are never touched; OPENROUTER is metered and deliberately allowed, because cost-optimal routing
|
|
136
|
-
// exists to spend it and a gate that fires on the feature you configured is the gate you disable.
|
|
137
|
-
POLICY('spend-guard', 'spend-guard.mjs', 'node'),
|
|
138
127
|
];
|
|
139
128
|
const SPEECH = { id: 'unprompted-speech', file: 'unprompted-runtime.mjs', interpreter: 'node' };
|
|
140
129
|
|
|
141
130
|
/** Which policies apply to which PreToolUse sub-event, mirroring the matchers they replaced. */
|
|
142
131
|
const REGISTRY = {
|
|
143
132
|
'write': ['protect-state', 'hijack-ruvnet', 'ground-before-write', 'adr-currency'],
|
|
144
|
-
// degradation-watch is bash-only on purpose: the acts it guards — `ruflo memory store`, `git
|
|
145
|
-
// push` — are commands, so the dependency is observable there and nowhere else.
|
|
146
|
-
'bash': ['protect-state', 'identifier-preflight', 'spend-guard', 'degradation-watch', 'hijack-ruvnet', 'design-wall'],
|
|
147
133
|
};
|
|
148
134
|
|
|
149
135
|
export function policiesFor(event, registry = REGISTRY, all = REFUSAL_POLICIES) {
|
|
@@ -156,33 +142,22 @@ export function policiesFor(event, registry = REGISTRY, all = REFUSAL_POLICIES)
|
|
|
156
142
|
/**
|
|
157
143
|
* ── APPLICABILITY: THE CHEAPEST POLICY IS THE ONE NEVER SPAWNED ──────────────────────────────────
|
|
158
144
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* The predicate is IMPORTED, never restated. Copying the DEPENDENT_COMMANDS regexes up here would
|
|
166
|
-
* make two answers to one question and guarantee they drift — the same reason adr-currency-gate calls
|
|
167
|
-
* doc-currency.mjs instead of carrying a second copy of the logic.
|
|
168
|
-
*
|
|
169
|
-
* FAIL TOWARD RUNNING THE POLICY. If the import fails, or the predicate throws, the policy is
|
|
170
|
-
* spawned exactly as before: this is a latency optimisation and it may never become a way to silently
|
|
171
|
-
* disable a guard.
|
|
145
|
+
* H5: this table's one entry (degradation-watch, applicable only to the now-removed 'bash' route)
|
|
146
|
+
* was removed along with that route — see the REGISTRY comment above. The mechanism itself stays:
|
|
147
|
+
* an empty table costs nothing (skipReason below returns null immediately for every policy, so every
|
|
148
|
+
* currently-registered policy is consulted exactly as if this file did not exist), and it is the
|
|
149
|
+
* correct extension point for a future policy that only applies to SOME invocations of its event.
|
|
172
150
|
*/
|
|
173
|
-
let dependentEvent = null; // set from degradation-watch.mjs at startup; null → spawn it as before
|
|
174
151
|
/**
|
|
175
152
|
* Resolved once per invocation by the runtime block below; null means no bash on this host.
|
|
176
153
|
*
|
|
177
|
-
* Declared HERE,
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
154
|
+
* Declared HERE, and not next to runPolicy() where it reads more naturally: the `if (isMain())`
|
|
155
|
+
* block runs during module evaluation, so a `let` declared after it sits in the temporal dead zone
|
|
156
|
+
* and the assignment throws — the identical mistake `speechEventFor` was already a hoisted
|
|
157
|
+
* `function` to avoid, recorded a few lines further down.
|
|
181
158
|
*/
|
|
182
159
|
let BASH = null;
|
|
183
|
-
const APPLICABILITY = {
|
|
184
|
-
'degradation-watch': (input) => (dependentEvent ? Boolean(dependentEvent(input.command)) : true),
|
|
185
|
-
};
|
|
160
|
+
const APPLICABILITY = {};
|
|
186
161
|
|
|
187
162
|
/** Returns a skip reason, or null if the policy must be consulted. */
|
|
188
163
|
export function skipReason(policy, toolInput, table = APPLICABILITY) {
|
|
@@ -239,18 +214,15 @@ if (isMain()) {
|
|
|
239
214
|
const payload = readPayload();
|
|
240
215
|
const selected = policiesFor(EVENT);
|
|
241
216
|
// An unknown event is not an occasion to refuse anything. Same rule as unprompted-runtime's
|
|
242
|
-
// "never speak on a guess", pointed at the other decision.
|
|
243
|
-
|
|
217
|
+
// "never speak on a guess", pointed at the other decision. 'write' is the only registered route
|
|
218
|
+
// (H5 removed the dead 'bash' one — see the REGISTRY comment above), so it is the only exception.
|
|
219
|
+
if (!selected.length && EVENT !== 'write') process.exit(ALLOW);
|
|
244
220
|
|
|
245
221
|
const budgetMs = Number(process.env.RUVNET_DECISION_BUDGET_MS) || DEFAULT_BUDGET_MS;
|
|
246
222
|
const deadline = started + budgetMs;
|
|
247
223
|
// Resolved ONCE. On win32 resolveBash() can shell out to `where.exe`; four bash policies meant up
|
|
248
224
|
// to four of those per tool call, for an answer that cannot change mid-invocation.
|
|
249
225
|
BASH = resolveBash();
|
|
250
|
-
// Best-effort, and deliberately not a static import: a missing or broken degradation-watch.mjs
|
|
251
|
-
// must cost us the optimisation, not the whole gate. `runPolicy` already tolerates a missing
|
|
252
|
-
// policy file; a top-level `import` of it would have made that tolerance a lie.
|
|
253
|
-
try { ({ dependentEvent } = await import('./degradation-watch.mjs')); } catch { dependentEvent = null; }
|
|
254
226
|
|
|
255
227
|
const trace = []; // one row per policy — surfaced by RUVNET_DECISION_TRACE=1
|
|
256
228
|
const unconsulted = []; // policies the budget cost us. NEVER silent; see reportBudget().
|
|
@@ -50,6 +50,23 @@ fi
|
|
|
50
50
|
TEXT=$(printf '%s' "$INPUT" | jq -r '.prompt // .user_prompt // .input // empty' 2>/dev/null)
|
|
51
51
|
[ -z "$TEXT" ] && TEXT="$INPUT"
|
|
52
52
|
|
|
53
|
+
# ── H2: HARNESS-GENERATED MESSAGES ARE NOT A USER'S PROMPT. ──────────────────────────────────────
|
|
54
|
+
# Background task notifications, slash-command scaffolding, and other harness-authored bookkeeping
|
|
55
|
+
# arrive on UserPromptSubmit exactly like real user text, wrapped in tags such as
|
|
56
|
+
# <task-notification>, <local-command-caveat>, <command-name>, <local-command-stdout>, and
|
|
57
|
+
# <system-reminder>. None of that is something a human typed, so none of Gates 1-4 below should ever
|
|
58
|
+
# fire on it — injecting a grounding directive in response to the harness's own bookkeeping message
|
|
59
|
+
# is noise on every background-task turn. This is a literal, byte-identical copy of
|
|
60
|
+
# plugin/scripts/hook-input.mjs's HARNESS_GENERATED_SHELL_PATTERN (SOURCE OF TRUTH there), kept as a
|
|
61
|
+
# copy here (not a `node hook-input.mjs` call) for the same reason Gate 1's own pattern below is a
|
|
62
|
+
# copy: this is a hot, every-prompt hook (see the bounded-read comment above), and a process spawn
|
|
63
|
+
# per prompt is exactly the kind of non-surgical cost this file's header warns against.
|
|
64
|
+
# tests/unit/hook-input-harness.test.mjs proves this copy is byte-identical to
|
|
65
|
+
# HARNESS_GENERATED_SHELL_PATTERN and that both agree with isHarnessGenerated() behaviorally.
|
|
66
|
+
if printf '%s' "$TEXT" | grep -qiE '\[Your previous response|\[Request interrupted|</?system-reminder>|</?(command-name|command-message|command-args|local-command-stdout|local-command-stderr|local-command-caveat|task-notification|function_results|function_calls|budget)\b|^[[:space:]]*Caveat:|Base directory for this skill:|This session is being continued from a previous conversation|^[[:space:]]*#[[:space:]]*claudeMd\b|\[INTELLIGENCE\]'; then
|
|
67
|
+
exit 0
|
|
68
|
+
fi
|
|
69
|
+
|
|
53
70
|
# ── QUIET-PROMPT FAST PATH. ────────────────────────────────────────────────────────────────────
|
|
54
71
|
# A hook whose output contract is silence must not pay the full stack-currency/project-state scan
|
|
55
72
|
# before discovering that nothing can fire. This mattered on a packed Windows install: immediately
|
|
@@ -403,8 +420,19 @@ fi
|
|
|
403
420
|
# The bug this kills: line "5. CLEARED TO GO" below ends every build response with "Want me to
|
|
404
421
|
# build it now?" — a question asked to an EMPTY ROOM inside a /loop. That is what a real user's
|
|
405
422
|
# "it wouldn't run autonomously" looked like from the outside.
|
|
423
|
+
#
|
|
424
|
+
# H3 (fixed): the ORIGINAL fix over-corrected into a NEW bug in the opposite direction. Matching
|
|
425
|
+
# conversational phrases — "autonomous", "unattended", "don't stop", "keep going/working", "soak
|
|
426
|
+
# run" — meant an ATTENDED human saying ordinary things ("please keep working on this", "don't stop
|
|
427
|
+
# until the tests pass") got the full AUTONOMOUS MODE block injected: "no human is watching", "NEVER
|
|
428
|
+
# halt to ask", ignore the CLEARED-TO-GO checkpoint question. A human who is plainly IN the
|
|
429
|
+
# conversation is not an empty room; inferring "nobody is watching" from prose a watching human just
|
|
430
|
+
# typed is exactly backwards. AUTON is now restricted to signals a HUMAN does not type by hand: the
|
|
431
|
+
# `<<autonomous-loop` sentinel a real unattended harness wraps its own prompts in, a `/loop` slash
|
|
432
|
+
# command LEADING the text (the actual mechanism that starts a loop — see the `loop` skill — not the
|
|
433
|
+
# bare word "loop" appearing anywhere), or the explicit RUVNET_AUTONOMOUS=1 environment signal.
|
|
406
434
|
AUTON=0
|
|
407
|
-
if printf '%s' "$TEXT" | grep -qiE '
|
|
435
|
+
if printf '%s' "$TEXT" | grep -qiE '<<autonomous-loop|^[[:space:]]*/loop\b'; then
|
|
408
436
|
AUTON=1
|
|
409
437
|
fi
|
|
410
438
|
[ "${RUVNET_AUTONOMOUS:-0}" = "1" ] && AUTON=1
|
|
@@ -492,9 +520,54 @@ if [ "$AUTON" -eq 1 ]; then
|
|
|
492
520
|
and a wait.
|
|
493
521
|
EOF
|
|
494
522
|
if [ -f "$CP_FILE" ]; then
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
523
|
+
# H3: a checkpoint from a loop that ended days or weeks ago used to be injected UNCONDITIONALLY —
|
|
524
|
+
# resumed as though it were this session's own live state, with no age check at all.
|
|
525
|
+
#
|
|
526
|
+
# NOT resolved via scripts/loop-checkpoint.mjs (a first version of this fix did, and shipped
|
|
527
|
+
# tests/unit/payload-self-contained.test.mjs RED: loop-checkpoint.mjs lives only at repo-root
|
|
528
|
+
# scripts/, never inside plugin/scripts/, and only `plugin/` reaches a real install — every
|
|
529
|
+
# shipped layout flattens it, so that reference resolved to nothing everywhere except this
|
|
530
|
+
# repo's own dogfood checkout, and the feature silently degraded to "always inject" for every
|
|
531
|
+
# real user). The 24h threshold is instead inlined here as `node -e`, no file reference at all —
|
|
532
|
+
# payload-self-contained by construction. scripts/loop-checkpoint.mjs's own exported
|
|
533
|
+
# CHECKPOINT_STALE_MS (used by scripts/single-source-check.mjs's E1 audit) is the SOURCE OF
|
|
534
|
+
# TRUTH for the number; tests/unit/ground-ruvnet-staleness-inline.test.mjs asserts this literal
|
|
535
|
+
# matches it, same drift-test idiom as Gate 1's own bash/JS pattern copy.
|
|
536
|
+
STALE_RC=9
|
|
537
|
+
STALE_AGE=""
|
|
538
|
+
if command -v node >/dev/null 2>&1; then
|
|
539
|
+
UPDATED_AT=$(node -e '
|
|
540
|
+
let raw = ""; process.stdin.on("data", (c) => { raw += c; });
|
|
541
|
+
process.stdin.on("end", () => {
|
|
542
|
+
try { const cp = JSON.parse(raw); process.stdout.write(typeof cp.updatedAt === "string" ? cp.updatedAt : ""); }
|
|
543
|
+
catch { /* empty stdout: not a valid checkpoint */ }
|
|
544
|
+
});
|
|
545
|
+
' < "$CP_FILE" 2>/dev/null)
|
|
546
|
+
if [ -n "$UPDATED_AT" ]; then
|
|
547
|
+
STALE_AGE=$(node -e '
|
|
548
|
+
const STALE_MS = 24 * 60 * 60 * 1000;
|
|
549
|
+
const ms = Date.parse(process.argv[1]);
|
|
550
|
+
if (!Number.isFinite(ms)) process.exit(2);
|
|
551
|
+
const ageMs = Date.now() - ms;
|
|
552
|
+
if (ageMs < STALE_MS) process.exit(0);
|
|
553
|
+
process.stdout.write(String(Math.floor(ageMs / 86_400_000)));
|
|
554
|
+
process.exit(1);
|
|
555
|
+
' "$UPDATED_AT" 2>/dev/null)
|
|
556
|
+
STALE_RC=$?
|
|
557
|
+
else
|
|
558
|
+
STALE_RC=2
|
|
559
|
+
fi
|
|
560
|
+
fi
|
|
561
|
+
if [ "$STALE_RC" -eq 1 ]; then
|
|
562
|
+
echo "[RuvNet Brain — a stale checkpoint (age ${STALE_AGE} days) was ignored; starting fresh rather than resuming a plan that old.]"
|
|
563
|
+
else
|
|
564
|
+
# RC 0 (fresh) — inject as intended. RC 2 (no parseable `updatedAt`) or RC 9 (no node on this
|
|
565
|
+
# host) both mean "unknown age", which must never be treated as PROVEN stale — fall back to
|
|
566
|
+
# the pre-fix behavior of injecting rather than silently dropping a checkpoint we cannot judge.
|
|
567
|
+
echo "[RuvNet Brain — RESUME: your prior checkpoint. Continue from 'next'; do not repeat done work.]"
|
|
568
|
+
cat "$CP_FILE" 2>/dev/null
|
|
569
|
+
echo ""
|
|
570
|
+
fi
|
|
498
571
|
fi
|
|
499
572
|
fi
|
|
500
573
|
|
|
@@ -72,6 +72,21 @@ case "$INPUT" in
|
|
|
72
72
|
*) exit 0 ;;
|
|
73
73
|
esac
|
|
74
74
|
|
|
75
|
+
DIR="$HOME/.cache/ruvnet-brain/grounded"
|
|
76
|
+
mkdir -p "$DIR" 2>/dev/null || exit 0
|
|
77
|
+
|
|
78
|
+
# ── 2.5. THE ANY-SEARCH MARKER (H1 / GitHub #316). ──────────────────────────────────────────────
|
|
79
|
+
# grounding-turn-gate.mjs's Stop-time check must treat ANY successful search_ruvnet this turn as
|
|
80
|
+
# satisfying "a search happened" — independent of whether the query text below happens to contain
|
|
81
|
+
# one of the recognised product terms. A query like "how should agent handoffs stay consistent"
|
|
82
|
+
# grounds just as genuinely as one that names a product by name, and nothing should require the
|
|
83
|
+
# model to re-word a real search just to satisfy a keyword scan. This mints into the SAME
|
|
84
|
+
# directory grounding-turn-gate.mjs already scans for the newest mtime (newestGroundingStampMs),
|
|
85
|
+
# so no change is needed on that side. Written unconditionally now that the success banner (step 2)
|
|
86
|
+
# is confirmed, before the QUERY parse below — a search can succeed with a query this regex cannot
|
|
87
|
+
# extract, and that must not cost it this signal.
|
|
88
|
+
: > "$DIR/.any-search" 2>/dev/null || true
|
|
89
|
+
|
|
75
90
|
# ── 3. WHICH terms — from the QUERY only, as it always was. The first raw "query" key in the JSON is
|
|
76
91
|
# tool_input's; inside tool_response text the quotes are escaped (\"query\") so they cannot match.
|
|
77
92
|
QUERY=""
|
|
@@ -79,12 +94,26 @@ re='"query"[[:space:]]*:[[:space:]]*"([^"]*)"'
|
|
|
79
94
|
[[ $INPUT =~ $re ]] && QUERY="${BASH_REMATCH[1]}"
|
|
80
95
|
[ -n "$QUERY" ] || exit 0
|
|
81
96
|
|
|
82
|
-
|
|
83
|
-
mkdir -p "$DIR" 2>/dev/null || exit 0
|
|
84
|
-
|
|
85
|
-
# Same product-term list as ground-before-write.sh — ONE list per concept, mirrored in both
|
|
97
|
+
# WRITE_GATE terms — same product-term list as ground-before-write.sh's own copy, mirrored in both
|
|
86
98
|
# files on purpose (a shared sourced file would add a dependency a blocking hook must not have).
|
|
87
|
-
|
|
99
|
+
# Do not change this list without mirroring ground-before-write.sh's copy — H1 keeps that gate's
|
|
100
|
+
# per-product WRITE semantics untouched (see decision-gate.mjs / ground-before-write.sh for why
|
|
101
|
+
# these 9 are scoped to code hand-rolling risk, not general rUv-ecosystem conversation).
|
|
102
|
+
WRITE_GATE_TERMS="agentdb metaharness ruvector aidefence agentic-flow agentic-qe ruv-swarm rvf ruflo"
|
|
103
|
+
|
|
104
|
+
# GATE-1-ONLY additions (H1 / GitHub #316): ruvnet-gate1-pattern.mjs's RUVNET_GATE1_PATTERN is the
|
|
105
|
+
# ONE owner of this vocabulary — grounding-turn-mark.mjs already arms the Stop-time turn gate from
|
|
106
|
+
# it. Before this fix, grounding-stamp.sh only recognised the 9 WRITE_GATE_TERMS above, so a search
|
|
107
|
+
# literally about "ruvnet" (or "sparc", "qudag", "claude-flow", ...) minted no per-term stamp, and
|
|
108
|
+
# combined with the missing any-search marker above, grounding-turn-gate.mjs wrongly reported "no
|
|
109
|
+
# successful search_ruvnet call this turn" even though one had just happened. Terms already covered
|
|
110
|
+
# by WRITE_GATE_TERMS are not repeated here. tests/unit/grounding-stamp-terms.test.mjs asserts
|
|
111
|
+
# WRITE_GATE_TERMS plus GATE1_ONLY_TERMS together cover every RUVNET_GATE1_PATTERN alternative, so a
|
|
112
|
+
# future addition to that pattern left unmirrored here goes red immediately (same idiom as
|
|
113
|
+
# tests/unit/ruvnet-gate1-pattern.test.mjs's byte-identity check against ground-ruvnet.sh).
|
|
114
|
+
GATE1_ONLY_TERMS="ruvnet agenticow rulake ruview rupixel ruv-fann synthlang dspy qudag safla cve-bench sparc swarm claude-flow ruv"
|
|
115
|
+
|
|
116
|
+
for t in $WRITE_GATE_TERMS $GATE1_ONLY_TERMS; do
|
|
88
117
|
[[ $QUERY == *"$t"* ]] && { : > "$DIR/$t" 2>/dev/null || true; }
|
|
89
118
|
done
|
|
90
119
|
|
|
@@ -35,8 +35,14 @@
|
|
|
35
35
|
* result). ground-before-write.sh already trusts this exact directory's file mtimes for its own
|
|
36
36
|
* 24h freshness check; this file trusts the SAME directory the SAME way, just against a
|
|
37
37
|
* narrower window (since the marker's own mtime, not "20 hours ago", is the turn boundary).
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* UPDATE (H1 / GitHub #316): per-product term files alone under-reported "was it searched" —
|
|
39
|
+
* grounding-stamp.sh used to recognise only a 9-term write-gate vocabulary that omitted `ruvnet`
|
|
40
|
+
* itself (and every other Gate-1 term), so a search literally about "ruvnet" minted nothing and
|
|
41
|
+
* this gate wrongly fired. grounding-stamp.sh now also writes a vocabulary-independent
|
|
42
|
+
* `.any-search` marker into this SAME directory on every successful search regardless of query
|
|
43
|
+
* content, so newestGroundingStampMs below (which already scans every file, by name-agnostic
|
|
44
|
+
* design) sees it with no code change needed here — a search_ruvnet call this turn always mints
|
|
45
|
+
* evidence here now, not only when its query happens to name a recognised product.
|
|
40
46
|
* - The Stop block/continue contract: `{"hookSpecificOutput":{"hookEventName":"Stop",
|
|
41
47
|
* "additionalContext":"..."}}` on stdout, exit 0. This is not a new discovery — it is the exact
|
|
42
48
|
* contract continuation-gate.mjs already uses and this repo's own tests already prove works on
|
|
@@ -34,7 +34,7 @@ import fs from 'node:fs';
|
|
|
34
34
|
import os from 'node:os';
|
|
35
35
|
import path from 'node:path';
|
|
36
36
|
import { fileURLToPath } from 'node:url';
|
|
37
|
-
import { readStdinBounded } from './hook-input.mjs';
|
|
37
|
+
import { readStdinBounded, isHarnessGenerated } from './hook-input.mjs';
|
|
38
38
|
import { ruvnetGate1Matches } from './ruvnet-gate1-pattern.mjs';
|
|
39
39
|
|
|
40
40
|
const HOME = os.homedir();
|
|
@@ -54,6 +54,11 @@ export function shouldMark(hookInput) {
|
|
|
54
54
|
if (!hookInput || hookInput.hook_event_name !== 'UserPromptSubmit') return false;
|
|
55
55
|
if (!hookInput.session_id) return false;
|
|
56
56
|
const text = String(hookInput.prompt ?? hookInput.user_prompt ?? hookInput.input ?? '');
|
|
57
|
+
// H2: a background task notification, slash-command scaffold, or other harness-authored message
|
|
58
|
+
// arrives on UserPromptSubmit exactly like real user text — arming the Stop-time grounding gate off
|
|
59
|
+
// one of these (because it happens to mention a rUv term) would demand a search_ruvnet call to
|
|
60
|
+
// close out a "turn" nobody had a hand in.
|
|
61
|
+
if (isHarnessGenerated(text)) return false;
|
|
57
62
|
return ruvnetGate1Matches(text);
|
|
58
63
|
}
|
|
59
64
|
|
|
@@ -127,6 +127,60 @@ export function rawToolResponse(ev) {
|
|
|
127
127
|
return ev.tool_response;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
// ── HARNESS-GENERATED PROMPT DETECTION (H2) ─────────────────────────────────────────────────────
|
|
131
|
+
//
|
|
132
|
+
// WHY THIS LIVES HERE, and not only in scripts/correction-detect.mjs where it started. Background
|
|
133
|
+
// task notifications, slash-command scaffolding, and other harness-authored bookkeeping arrive on
|
|
134
|
+
// UserPromptSubmit exactly like real user text — wrapped in tags such as <task-notification>,
|
|
135
|
+
// <local-command-caveat>, <command-name>, <local-command-stdout>, and <system-reminder>. None of
|
|
136
|
+
// that is something a human typed, but before this fix every UserPromptSubmit consumer that reads
|
|
137
|
+
// `prompt`/`user_prompt`/`input` reacted to it as if it were a real prompt: grounding-turn-mark.mjs
|
|
138
|
+
// could arm the Stop-time grounding gate off a harness message that happens to mention a rUv term,
|
|
139
|
+
// unprompted-runtime.mjs's producers could fire advisories at a background notification, and
|
|
140
|
+
// capacity-aware-parallel-work.mjs could recommend a parallel-work fan-out for text nobody wrote.
|
|
141
|
+
// scripts/correction-detect.mjs already carried the exact regex list needed to recognise these
|
|
142
|
+
// (HARNESS_TEMPLATES, built from live corpus evidence — see that file's header for the measured
|
|
143
|
+
// 29%-of-holdout-pool finding that justified it) but as a private, unexported-to-this-purpose copy
|
|
144
|
+
// nothing else could reuse. This is now the ONE owner; correction-detect.mjs re-exports its old name
|
|
145
|
+
// from here instead of keeping its own literal array (tests/unit/hook-input-harness.test.mjs proves
|
|
146
|
+
// the two never drift).
|
|
147
|
+
export const HARNESS_GENERATED_PATTERNS = [
|
|
148
|
+
/\[Your previous response/i,
|
|
149
|
+
/\[Request interrupted/i,
|
|
150
|
+
/<\/?system-reminder>/i,
|
|
151
|
+
/<\/?(?:command-name|command-message|command-args|local-command-stdout|local-command-stderr|local-command-caveat|task-notification|function_results|function_calls|budget)\b/i,
|
|
152
|
+
/^\s*Caveat:/i,
|
|
153
|
+
/Base directory for this skill:/i,
|
|
154
|
+
/This session is being continued from a previous conversation/i,
|
|
155
|
+
/^\s*#\s*claudeMd\b/im,
|
|
156
|
+
/\[INTELLIGENCE\]/i,
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
/** True when `promptText` is harness-authored bookkeeping rather than something a user typed. */
|
|
160
|
+
export function isHarnessGenerated(promptText) {
|
|
161
|
+
const text = typeof promptText === 'string' ? promptText : String(promptText ?? '');
|
|
162
|
+
if (!text) return false;
|
|
163
|
+
return HARNESS_GENERATED_PATTERNS.some((re) => re.test(text));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The bash-portable EQUIVALENT of isHarnessGenerated(), for ground-ruvnet.sh — a hot, every-prompt
|
|
168
|
+
* hook (see its own header on the 38s-regression bounded-read fix) that must not pay a `node` spawn
|
|
169
|
+
* per prompt just for this check. This constant is exactly what ground-ruvnet.sh embeds as its own
|
|
170
|
+
* `grep -qiE` literal (SOURCE OF TRUTH here — tests/unit/hook-input-harness.test.mjs parses
|
|
171
|
+
* ground-ruvnet.sh's copy and asserts byte-identity, same idiom as ruvnet-gate1-pattern.mjs's own
|
|
172
|
+
* copy-with-a-drift-test for Gate 1). Its relationship to HARNESS_GENERATED_PATTERNS above is
|
|
173
|
+
* BEHAVIORAL parity only, not byte-identity: that is an array of independently-flagged regexes (one
|
|
174
|
+
* uses the 'm' flag) with no single source ERE it could be copied from verbatim, so the same test
|
|
175
|
+
* instead proves real `grep -qiE` against this string agrees with isHarnessGenerated() on
|
|
176
|
+
* representative inputs — the same idiom ruvnet-gate1-pattern.test.mjs uses for ITS third,
|
|
177
|
+
* non-identity assertion. POSIX `[[:space:]]` is used instead of `\s` and plain `(...)` instead of
|
|
178
|
+
* `(?:...)` — GNU/PCRE extensions with no POSIX ERE guarantee — because this string must run under
|
|
179
|
+
* whatever `grep` a user's shell resolves to, not only the one on the machine that wrote it.
|
|
180
|
+
*/
|
|
181
|
+
export const HARNESS_GENERATED_SHELL_PATTERN =
|
|
182
|
+
'\\[Your previous response|\\[Request interrupted|</?system-reminder>|</?(command-name|command-message|command-args|local-command-stdout|local-command-stderr|local-command-caveat|task-notification|function_results|function_calls|budget)\\b|^[[:space:]]*Caveat:|Base directory for this skill:|This session is being continued from a previous conversation|^[[:space:]]*#[[:space:]]*claudeMd\\b|\\[INTELLIGENCE\\]';
|
|
183
|
+
|
|
130
184
|
/** Arbitrary dotted-path lookup (e.g. "tool_input.file_path"); "" if any segment is missing. */
|
|
131
185
|
export function field(ev, dottedPath) {
|
|
132
186
|
if (!ev || !dottedPath) return '';
|
|
@@ -108,6 +108,16 @@ const TABLE = {
|
|
|
108
108
|
// one is the sole writer of unprompted BYTES, this one is the sole author of a REFUSAL. The four
|
|
109
109
|
// policies it consults are unchanged and still individually tested; the gate only composes them.
|
|
110
110
|
'decision-gate': { file: 'decision-gate.mjs', interpreter: 'node', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 },
|
|
111
|
+
// H5 CORRECTION (2026-09-26): a first pass removed these two TABLE entries as "retired dead code",
|
|
112
|
+
// since neither id is dispatched by plugin/hooks/hooks.json, plugin/hooks/codex-hooks.json, or this
|
|
113
|
+
// repo's .claude/settings.json. That broke a REAL test:
|
|
114
|
+
// tests/integration/codex-dispatch-cwd-divergence.test.mjs fires the genuine
|
|
115
|
+
// codex-hook.mjs -> codex-hook-adapter.mjs -> hook-shim.mjs 'learn-capture' chain to prove a real,
|
|
116
|
+
// shipped cross-host CWD-divergence fix (learn-capture.sh's project-containment check, #85/#107) —
|
|
117
|
+
// it needs the id to actually resolve through this TABLE, exactly as continuity-hook-policy.mjs's
|
|
118
|
+
// own header already says: "remains reachable through hook-shim's dispatch table by explicit
|
|
119
|
+
// invocation". Restored. wired-check.mjs's H6 fix does not depend on these keys existing either
|
|
120
|
+
// way — it stopped trusting hook-shim.mjs as a blind generic spawner, not their presence here.
|
|
111
121
|
'learn-capture': { file: 'learn-capture.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
|
|
112
122
|
'learn-flush': { file: 'learn-flush.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
|
|
113
123
|
'session-snapshot': { file: 'session-snapshot-hook.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run', stdinBytes: 65536 },
|
|
@@ -45,6 +45,10 @@ import { canonicalPath, pathIdentity } from './project-identity.mjs';
|
|
|
45
45
|
|
|
46
46
|
const HOME = os.homedir();
|
|
47
47
|
const DEFAULT_SCAN_ROOTS = ['Code', 'code', 'src', 'source', 'projects', 'dev', 'work'];
|
|
48
|
+
const DEFAULT_PROJECT_VENDOR = Object.freeze([
|
|
49
|
+
'/clones/', '/node_modules/', '/vendor/', '/upstream/', '/ruvnet-repos/', '/ruvnet_repos/',
|
|
50
|
+
'/ruvnet-packages/', '/.targets/', '.claude-backup', '_snapshots',
|
|
51
|
+
]);
|
|
48
52
|
|
|
49
53
|
// Telemetry namespaces: high-volume, unembedded, zero-signal. Written by the npx hook calls.
|
|
50
54
|
// Counted separately so "you have 11,000 memories" is never mistaken for "you have 11,000 lessons".
|
|
@@ -171,6 +175,45 @@ export function candidateRoots({
|
|
|
171
175
|
return [...roots.values()].sort();
|
|
172
176
|
}
|
|
173
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Enumerate project directories once for wiring/reconciliation consumers. The result is canonical
|
|
180
|
+
* and de-duplicated by filesystem identity, so symlinked roots and case aliases cannot double-count
|
|
181
|
+
* a project. `purpose` is retained in the interface for callers that need to document a narrower
|
|
182
|
+
* scan without creating another traversal policy.
|
|
183
|
+
*/
|
|
184
|
+
export function findProjects(root, { purpose = 'general', maxDepth = 4, vendor = [] } = {}) {
|
|
185
|
+
if (typeof root !== 'string' || !root.trim()) return [];
|
|
186
|
+
const exclusions = [...DEFAULT_PROJECT_VENDOR, ...vendor].map(String);
|
|
187
|
+
const found = new Map();
|
|
188
|
+
const canonicalRoot = canonicalPath(root);
|
|
189
|
+
if (!canonicalRoot) return [];
|
|
190
|
+
const walk = (dir, depth) => {
|
|
191
|
+
if (depth > maxDepth) return;
|
|
192
|
+
let entries;
|
|
193
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
const candidate = path.join(dir, entry.name);
|
|
196
|
+
if (exclusions.some((marker) => `${candidate}/`.includes(marker))) continue;
|
|
197
|
+
if (entry.isDirectory()) {
|
|
198
|
+
if (entry.name === '.claude') {
|
|
199
|
+
const identity = pathIdentity(dir) ?? canonicalPath(dir) ?? dir;
|
|
200
|
+
found.set(identity, canonicalPath(dir) ?? dir);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
204
|
+
walk(candidate, depth + 1);
|
|
205
|
+
} else if (entry.name === '.mcp.json') {
|
|
206
|
+
const identity = pathIdentity(dir) ?? canonicalPath(dir) ?? dir;
|
|
207
|
+
found.set(identity, canonicalPath(dir) ?? dir);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
walk(canonicalRoot, 0);
|
|
212
|
+
// Keep purpose observable to profilers without changing the stable result shape.
|
|
213
|
+
void purpose;
|
|
214
|
+
return [...found.values()].sort();
|
|
215
|
+
}
|
|
216
|
+
|
|
174
217
|
// Keyed by pathIdentity for the same reason candidateRoots is: one store reached by two names is
|
|
175
218
|
// one store. Values are the canonical spelling, which is what every caller reads back.
|
|
176
219
|
function storesBelow(root) {
|
|
@@ -55,7 +55,12 @@ function schedulerEnvironment(env) {
|
|
|
55
55
|
throw new Error('RUVNET_CONSOLE_ROOT must be an absolute path');
|
|
56
56
|
}
|
|
57
57
|
const isolatedHome = path.resolve(fixtureRoot);
|
|
58
|
-
|
|
58
|
+
// The real system home, NOT os.homedir() — os.homedir() reads process.env.HOME, which a caller
|
|
59
|
+
// isolating a fixture has typically ALREADY set to this same isolatedHome (consoleFixtureEnvironment
|
|
60
|
+
// does exactly this), so comparing against it made this guard trip on every correctly-isolated
|
|
61
|
+
// fixture instead of only on an accidental real-home leak. os.userInfo().homedir is the OS user
|
|
62
|
+
// database entry and ignores the HOME env var override, so it still names the real home here.
|
|
63
|
+
if (isolatedHome === path.resolve(os.userInfo().homedir)) {
|
|
59
64
|
throw new Error('RUVNET_CONSOLE_ROOT must not be the real user home in test mode');
|
|
60
65
|
}
|
|
61
66
|
return { ...env, HOME: isolatedHome, USERPROFILE: isolatedHome };
|
|
@@ -40,6 +40,23 @@ const STATE_VALUE_FIELDS = Object.freeze([
|
|
|
40
40
|
'currentGoal', 'acceptanceContract', 'activeProcess', 'activeStep', 'nextAction',
|
|
41
41
|
]);
|
|
42
42
|
|
|
43
|
+
// Canonical field ownership. Producers may fill a field only from the listed durable sources;
|
|
44
|
+
// readers/checkpoints preserve this provenance and surface conflicts instead of silently choosing.
|
|
45
|
+
export const PROGRESSION_FIELD_AUTHORITY = Object.freeze({
|
|
46
|
+
currentGoal: Object.freeze(['ledger', 'prior-head', 'owner-note', 'transcript-derived']),
|
|
47
|
+
nextAction: Object.freeze(['ledger', 'prior-head']),
|
|
48
|
+
acceptanceContract: Object.freeze(['prior-head', 'ledger']),
|
|
49
|
+
plan: Object.freeze(['ledger', 'prior-head']),
|
|
50
|
+
completed: Object.freeze(['ledger', 'prior-head']),
|
|
51
|
+
inProgress: Object.freeze(['ledger', 'prior-head']),
|
|
52
|
+
decisions: Object.freeze(['ledger', 'owner-note', 'prior-head']),
|
|
53
|
+
changedFiles: Object.freeze(['git']),
|
|
54
|
+
sourceIdentity: Object.freeze(['git']),
|
|
55
|
+
});
|
|
56
|
+
export function fieldAuthorityAllows(field, source) {
|
|
57
|
+
return PROGRESSION_FIELD_AUTHORITY[field]?.includes(source) === true;
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
function eventKeyFor(value) {
|
|
44
61
|
const identityDigest = digestCanonical({
|
|
45
62
|
projectId: value.projectIdentity.id,
|
|
@@ -96,12 +96,24 @@ function toolAction(payload) {
|
|
|
96
96
|
const responseRecord = response && typeof response === 'object' && !Array.isArray(response)
|
|
97
97
|
? response
|
|
98
98
|
: null;
|
|
99
|
-
const
|
|
99
|
+
const explicitCode = responseRecord && [responseRecord.exit_code, responseRecord.exitCode, responseRecord.status]
|
|
100
100
|
.find((value) => Number.isSafeInteger(value));
|
|
101
|
-
const
|
|
102
|
-
|
|
101
|
+
const responseText = typeof response === 'string' ? response : '';
|
|
102
|
+
// Native host terminal envelopes may serialize an exact `Exit code: N` line. Do not
|
|
103
|
+
// scan arbitrary prose: tool output often quotes logs or examples containing `status: 0`.
|
|
104
|
+
const textualCode = responseText.match(/^\s*Exit code:\s*(-?\d+)\s*$/i);
|
|
105
|
+
const exitCode = Number.isSafeInteger(explicitCode)
|
|
106
|
+
? explicitCode
|
|
107
|
+
: textualCode ? Number(textualCode[1]) : undefined;
|
|
108
|
+
const interrupted = responseRecord?.interrupted === true || responseRecord?.signal === 'SIGINT';
|
|
109
|
+
const explicitError = responseRecord?.isError === true || payload.is_error === true;
|
|
110
|
+
const declaredOutcome = ['success', 'failure', 'interrupted', 'unknown'].includes(responseRecord?.outcome)
|
|
111
|
+
? responseRecord.outcome : null;
|
|
112
|
+
const failed = explicitError || (Number.isSafeInteger(exitCode) && exitCode !== 0);
|
|
113
|
+
const terminal = failed || Number.isSafeInteger(exitCode)
|
|
114
|
+
|| responseRecord?.success === true || responseRecord?.ok === true;
|
|
103
115
|
const outcome = payload.hook_event_name === 'PostToolUse'
|
|
104
|
-
? (failed ? 'failure' :
|
|
116
|
+
? (interrupted ? 'interrupted' : failed ? 'failure' : declaredOutcome || (terminal ? 'success' : 'unknown'))
|
|
105
117
|
: 'pending';
|
|
106
118
|
const observation = {
|
|
107
119
|
trigger: payload.hook_event_name,
|
|
@@ -111,6 +123,7 @@ function toolAction(payload) {
|
|
|
111
123
|
outcome,
|
|
112
124
|
...(Number.isSafeInteger(exitCode) ? { exitCode } : {}),
|
|
113
125
|
...(interrupted ? { interrupted: true } : {}),
|
|
126
|
+
...(explicitError ? { isError: true } : {}),
|
|
114
127
|
};
|
|
115
128
|
if (responseRecord) {
|
|
116
129
|
const stdout = boundedText(responseRecord.stdout);
|
|
@@ -133,7 +146,7 @@ function enrichStateWithObservation(state, payload) {
|
|
|
133
146
|
return {
|
|
134
147
|
...state,
|
|
135
148
|
commands: [...commands, observation],
|
|
136
|
-
...(observation.outcome === 'failure' ? { failures: [...failures, observation] } : {}),
|
|
149
|
+
...(observation.outcome === 'failure' || observation.outcome === 'interrupted' ? { failures: [...failures, observation] } : {}),
|
|
137
150
|
};
|
|
138
151
|
}
|
|
139
152
|
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import crypto from 'node:crypto';
|
|
32
32
|
import path from 'node:path';
|
|
33
|
-
import { digestCanonical, redactProgression, restoreProjectProgression } from './project-progression-contract.mjs';
|
|
33
|
+
import { digestCanonical, fieldAuthorityAllows, redactProgression, restoreProjectProgression } from './project-progression-contract.mjs';
|
|
34
34
|
import { readOwnerNote, readSourceIdentity, readTranscriptReference, readWorkLedger } from './project-progression-sources.mjs';
|
|
35
35
|
import { withProgressionReader } from './project-progression-reader.mjs';
|
|
36
36
|
|
|
@@ -132,30 +132,34 @@ export function buildProjectProgression({
|
|
|
132
132
|
const priorState = heads.length === 1 ? heads[0].completeProjectState : null;
|
|
133
133
|
|
|
134
134
|
const provenance = {};
|
|
135
|
-
const record = (field, sourceName) => {
|
|
135
|
+
const record = (field, sourceName) => {
|
|
136
|
+
if (sourceName !== 'none' && !fieldAuthorityAllows(field === 'sourceIdentity' ? 'sourceIdentity' : field, sourceName)) {
|
|
137
|
+
throw new Error(`source ${sourceName} is not authoritative for progression field ${field}`);
|
|
138
|
+
}
|
|
139
|
+
provenance[field] = marker(sourceName);
|
|
140
|
+
};
|
|
136
141
|
|
|
137
|
-
// GOAL — the ledger's oldest open item is what the user actually committed to
|
|
138
|
-
//
|
|
142
|
+
// GOAL — the ledger's oldest open item is what the user actually committed to. A coherent prior
|
|
143
|
+
// head carries that commitment forward; an owner note or transcript can provide context only when
|
|
144
|
+
// no durable goal exists. Neither contextual source is an instruction.
|
|
139
145
|
let currentGoal = ledger.open[0] ?? null;
|
|
140
146
|
if (currentGoal) record('currentGoal', 'ledger');
|
|
141
147
|
else if (typeof priorState?.currentGoal === 'string' && priorState.currentGoal) {
|
|
142
148
|
currentGoal = priorState.currentGoal;
|
|
143
149
|
record('currentGoal', 'prior-head');
|
|
144
|
-
} else if (transcript.derivedGoal) {
|
|
145
|
-
currentGoal = transcript.derivedGoal;
|
|
146
|
-
record('currentGoal', 'transcript-derived');
|
|
147
150
|
} else if (note?.excerpt) {
|
|
148
151
|
currentGoal = note.excerpt.split('\n')[0].slice(0, 240);
|
|
149
152
|
record('currentGoal', 'owner-note');
|
|
153
|
+
} else if (transcript.derivedGoal) {
|
|
154
|
+
currentGoal = transcript.derivedGoal;
|
|
155
|
+
record('currentGoal', 'transcript-derived');
|
|
150
156
|
} else record('currentGoal', 'none');
|
|
151
157
|
|
|
152
|
-
// NEXT ACTION —
|
|
158
|
+
// NEXT ACTION — only a ledger commitment or coherent prior state may become a resumable action.
|
|
159
|
+
// Transcript text is evidence/context, never an invented structured action.
|
|
153
160
|
let nextAction = ledger.open[1] ?? ledger.open[0] ?? null;
|
|
154
161
|
if (nextAction) record('nextAction', 'ledger');
|
|
155
|
-
else if (
|
|
156
|
-
nextAction = transcript.derivedNextAction;
|
|
157
|
-
record('nextAction', 'transcript-derived');
|
|
158
|
-
} else if (typeof priorState?.nextAction === 'string' && priorState.nextAction) {
|
|
162
|
+
else if (typeof priorState?.nextAction === 'string' && priorState.nextAction) {
|
|
159
163
|
nextAction = priorState.nextAction;
|
|
160
164
|
record('nextAction', 'prior-head');
|
|
161
165
|
} else record('nextAction', 'none');
|