ruvnet-brain 4.0.6 → 4.0.7

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.
Files changed (35) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +112 -7
  3. package/config/model-router/catalog.template.json +17 -2
  4. package/console/app.js +32 -14
  5. package/console/architecture.html +1 -1
  6. package/data/model-catalog.json +54 -0
  7. package/package.json +2 -1
  8. package/plugin/.claude-plugin/plugin.json +1 -1
  9. package/plugin/.codex-plugin/plugin.json +1 -1
  10. package/plugin/hooks/hooks.json +34 -1
  11. package/plugin/scripts/ground-ruvnet.sh +2 -1
  12. package/plugin/scripts/hook-shim.mjs +13 -6
  13. package/plugin/scripts/lesson-provenance.mjs +21 -0
  14. package/plugin/scripts/lesson-store.mjs +33 -3
  15. package/plugin/scripts/route-dispatch.sh +23 -49
  16. package/plugin/scripts/session-snapshot-contract.mjs +100 -0
  17. package/plugin/scripts/session-snapshot-hook.mjs +34 -0
  18. package/plugin/scripts/session-start-core.mjs +25 -7
  19. package/plugin/scripts/swarm-slot-recycler.mjs +89 -0
  20. package/plugin/skills/release-proof/SKILL.md +4 -4
  21. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +19 -4
  22. package/scripts/capability-registry.mjs +3 -3
  23. package/scripts/lesson-lifecycle.mjs +2 -1
  24. package/scripts/lesson-ratify.mjs +6 -3
  25. package/scripts/lesson-seed.mjs +21 -16
  26. package/scripts/model-router-catalog.mjs +16 -0
  27. package/scripts/model-router-engine.mjs +4 -1
  28. package/scripts/onboarding-console.mjs +52 -14
  29. package/scripts/provider-availability.mjs +12 -0
  30. package/scripts/release-authority.mjs +14 -2
  31. package/scripts/release-transaction-provider.mjs +236 -0
  32. package/scripts/release-transaction.mjs +214 -0
  33. package/scripts/release.mjs +67 -279
  34. package/scripts/session-snapshot-contract.mjs +1 -0
  35. package/scripts/staged-host-verifier.mjs +106 -0
@@ -13,8 +13,8 @@
13
13
  // hook-shim-bash.mjs (bash-interpreter resolution, issue #38) — colocated in this same
14
14
  // boot-frozen scripts/ dir, never resolved from the spine. No imports from the hook BODY.
15
15
  // • Typed dispatch table (red-team findings 15/16/30): each hook declares its file, interpreter,
16
- // and mode. `blocking` hooks propagate their exact exit code (route-dispatch's deliberate
17
- // exit-2 wall survives by CONTRACT); `advisory` hooks can never block a turn — any failure,
16
+ // and mode. `blocking` hooks propagate their exact exit code; `advisory` hooks can never block
17
+ // a turn — any failure,
18
18
  // including a missing file, exits 0.
19
19
  // • Containment (finding 13): a codeRoot is honored ONLY if it resolves under
20
20
  // ~/.cache/ruvnet-brain/versions/ — or is the explicit dev-mode checkout declared in
@@ -71,9 +71,9 @@ catch (e) { BRAIN_OFF = !(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')); }
71
71
  //
72
72
  // 'silence' — this hook exists to advertise, ground, or learn. Off means it does not run at all
73
73
  // and writes ZERO bytes. Nothing downstream can tell it apart from not being installed.
74
- // 'run' — this is a SAFETY WALL that guards money or honesty, not retrieval. route-dispatch
75
- // stops a subagent fan-out inheriting an expensive model; design-wall stops an
76
- // ungraded surface shipping; protect-state guards the user's own consent record.
74
+ // 'run' — this protection/audit remains relevant without retrieval. route-dispatch records
75
+ // inherited-model fan-out; design-wall stops an ungraded surface shipping;
76
+ // protect-state guards the user's own consent record.
77
77
  // None becomes acceptable because retrieval is off.
78
78
  // 'partial' — the hook splits INTERNALLY. session-start-core still runs the auto-updater heartbeat, the
79
79
  // GONG health alarm and the SLA banner (an off machine must still receive fixes,
@@ -84,7 +84,9 @@ const TABLE = {
84
84
  'session-start': { file: 'session-start-core.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'partial' },
85
85
  'ground-ruvnet': { file: 'ground-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 },
86
86
  'hijack-ruvnet': { file: 'hijack-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
87
- 'route-dispatch': { file: 'route-dispatch.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 },
87
+ // Claude Code 2.1.220 consumes Agent/Task PreToolUse results after tool_dispatch_end (#84).
88
+ // A refusal here would be late and therefore false enforcement; retain only bounded audit.
89
+ 'route-dispatch': { file: 'route-dispatch.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'run', stdinBytes: 65536 },
88
90
  'ground-before-write': { file: 'ground-before-write.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 },
89
91
  'grounding-stamp': { file: 'grounding-stamp.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
90
92
  'verify-interface': { file: 'verify-interface.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
@@ -94,6 +96,7 @@ const TABLE = {
94
96
  'protect-state': { file: 'protect-brain-state.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 },
95
97
  'learn-capture': { file: 'learn-capture.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
96
98
  'learn-flush': { file: 'learn-flush.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
99
+ 'session-snapshot': { file: 'session-snapshot-hook.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run', stdinBytes: 65536 },
97
100
  'md-stamp': { file: 'md-stamp.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
98
101
  // THE EXTERNAL-SIGNAL WATCH PLANE, W1 OBSERVED (ADR-058 §D3; DDD-0013 Context 2). PostToolUse,
99
102
  // matcher ^Bash$ (anchored — an unanchored matcher is F3/F4). Classifies gh/vercel/netlify/npm
@@ -105,6 +108,10 @@ const TABLE = {
105
108
  // session-start-core.mjs, and that surfacing already lives under SessionStart's 'partial' contract.
106
109
  'signal-watch': { file: 'signal-watch.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
107
110
  'routing-outcome': { file: 'routing-outcome-capture.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run' },
111
+ // Claude Code's synchronous TeammateIdle boundary can prevent a worker slot from going unused
112
+ // while its host-owned shared ledger has ready work. The body is read-only and fail-open; exit 2
113
+ // is reserved for one proved, unassigned, dependency-ready task. Codex has no equivalent event.
114
+ 'swarm-slot-recycler': { file: 'swarm-slot-recycler.mjs', interpreter: 'node', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 },
108
115
  // The unprompted-speech chokepoint (ADR-040 / DDD-0004). ONE runtime is the sole writer of
109
116
  // user-facing bytes for every unprompted hook: it spawns the real producers (anticipate, lesson)
110
117
  // in candidate mode, applies the per-channel policy, and writes the final envelope itself. `channel`
@@ -0,0 +1,21 @@
1
+ export const SOURCE_CLASS = Object.freeze({
2
+ CURRENT_USER: 'current-user',
3
+ IMPORTED_OWNER: 'imported-owner',
4
+ MODEL_INFERRED: 'model-inferred',
5
+ DEMONSTRATION: 'demonstration',
6
+ });
7
+
8
+ export const BUNDLED_OWNER_SEED_IDS = new Set([
9
+ 'L01-verify-with-a-capable-channel',
10
+ 'L02-check-before-you-assert',
11
+ 'L03-research-before-recommending',
12
+ 'L04-never-relay-a-number',
13
+ 'L05-version-is-the-update-signal',
14
+ 'L06-use-the-real-tool',
15
+ 'L07-blast-radius-not-social-comfort',
16
+ 'L08-status-is-a-table',
17
+ 'L09-gradeable-is-not-valuable',
18
+ 'L10-under-enumeration-is-a-tell',
19
+ 'L11-retrieval-without-volition-is-broken',
20
+ 'L12-efficiency-seeking-is-the-tell',
21
+ ]);
@@ -23,6 +23,9 @@
23
23
  import fs from 'node:fs';
24
24
  import os from 'node:os';
25
25
  import path from 'node:path';
26
+ import { BUNDLED_OWNER_SEED_IDS, SOURCE_CLASS } from './lesson-provenance.mjs';
27
+
28
+ export { BUNDLED_OWNER_SEED_IDS, SOURCE_CLASS } from './lesson-provenance.mjs';
26
29
 
27
30
  /** Resolve fixture/plugin configuration without mutating the child process account HOME. */
28
31
  export function resolveConfigRoot(env = process.env, home = os.homedir()) {
@@ -99,6 +102,8 @@ export const ORIGIN = Object.freeze({
99
102
  });
100
103
  const ORIGIN_VALUES = new Set(Object.values(ORIGIN));
101
104
 
105
+ const SOURCE_CLASS_VALUES = new Set(Object.values(SOURCE_CLASS));
106
+
102
107
  /**
103
108
  * STATUS — the ratification ladder. A lesson does not become policy by existing.
104
109
  * candidate → ratified (a human agreed) → active (in force at its trigger).
@@ -128,6 +133,11 @@ export function makeLesson(spec) {
128
133
  severity = 'normal', // 'normal' | 'high' — see weightOf()
129
134
  intendedEnforcement = null, // what it should become once a human ratifies it
130
135
  ratifiedBy = null,
136
+ sourceClass = origin === ORIGIN.USER_STATED
137
+ ? SOURCE_CLASS.CURRENT_USER
138
+ : origin === ORIGIN.MODEL_INFERRED
139
+ ? SOURCE_CLASS.MODEL_INFERRED
140
+ : SOURCE_CLASS.IMPORTED_OWNER,
131
141
  } = spec;
132
142
  const err = (m) => { throw new Error(`Lesson "${id ?? '?'}" invalid: ${m}`); };
133
143
 
@@ -151,6 +161,10 @@ export function makeLesson(spec) {
151
161
  }
152
162
 
153
163
  if (!ORIGIN_VALUES.has(origin)) err(`origin must be one of: ${[...ORIGIN_VALUES].join(', ')}`);
164
+ if (!SOURCE_CLASS_VALUES.has(sourceClass)) err(`sourceClass must be one of: ${[...SOURCE_CLASS_VALUES].join(', ')}`);
165
+ if (sourceClass === SOURCE_CLASS.CURRENT_USER && origin !== ORIGIN.USER_STATED) {
166
+ err('sourceClass:current-user requires origin:user-stated');
167
+ }
154
168
  if (!STATUS_VALUES.has(status)) err(`status must be one of: ${[...STATUS_VALUES].join(', ')}`);
155
169
 
156
170
  // THE TRUST BOUNDARY. A lesson the model wrote about itself, or one imported from a repo, cannot
@@ -166,7 +180,7 @@ export function makeLesson(spec) {
166
180
 
167
181
  return Object.freeze({
168
182
  id, statement, trigger, enforcement, evidence,
169
- surface, origin, status, severity,
183
+ surface, origin, sourceClass, status, severity,
170
184
  intendedEnforcement: intendedEnforcement ?? null,
171
185
  ratifiedBy: ratifiedBy ?? null,
172
186
  projects: [...projects],
@@ -255,7 +269,12 @@ export function loadLessons(file = STORE_PATH) {
255
269
  // to edit and delete these); a malformed entry must be dropped loudly rather than acted upon.
256
270
  const out = [];
257
271
  const dropped = [];
258
- for (const l of raw.lessons || []) {
272
+ const rows = Array.isArray(raw.lessons) ? raw.lessons : [];
273
+ const ids = new Set(rows.map((lesson) => lesson?.id));
274
+ const legacyOwnerSeed = rows.length === BUNDLED_OWNER_SEED_IDS.size
275
+ && ids.size === BUNDLED_OWNER_SEED_IDS.size
276
+ && [...BUNDLED_OWNER_SEED_IDS].every((id) => ids.has(id));
277
+ for (const stored of rows) {
259
278
  // SKIP THE BAD ROW, BUT NEVER SILENTLY. An adversarial review proved that a schema change
260
279
  // (ADR-035 proposes new enforcement values the current enum rejects) would take this store
261
280
  // from 16 lessons to 0 with NO error and exit 0 — output indistinguishable from "no lessons
@@ -264,6 +283,14 @@ export function loadLessons(file = STORE_PATH) {
264
283
  //
265
284
  // A store that empties itself quietly is the worst possible failure here, because the whole
266
285
  // product promise is "you should never have to tell me twice."
286
+ const l = legacyOwnerSeed ? {
287
+ ...stored,
288
+ origin: ORIGIN.IMPORTED,
289
+ sourceClass: SOURCE_CLASS.IMPORTED_OWNER,
290
+ status: STATUS.CANDIDATE,
291
+ demoted: true,
292
+ ratifiedBy: null,
293
+ } : stored;
267
294
  try { out.push(makeLesson(l)); } catch (e) {
268
295
  dropped.push({ id: l && l.id, why: String(e && e.message || e) });
269
296
  }
@@ -435,6 +462,7 @@ export function restore(id, lessons) {
435
462
  export function ratify(id, lessons, { by = 'user' } = {}) {
436
463
  return lessons.map((l) => {
437
464
  if (l.id !== id) return l;
465
+ if (l.sourceClass === SOURCE_CLASS.IMPORTED_OWNER || l.sourceClass === SOURCE_CLASS.DEMONSTRATION) return l;
438
466
  const target = l.intendedEnforcement || l.enforcement;
439
467
  const canBlock = l.origin === ORIGIN.USER_STATED;
440
468
  return makeLesson({
@@ -448,5 +476,7 @@ export function ratify(id, lessons, { by = 'user' } = {}) {
448
476
 
449
477
  /** Lessons awaiting a human decision — what the management surface must show first. */
450
478
  export function pending(lessons) {
451
- return lessons.filter((l) => l.status === STATUS.CANDIDATE && !l.demoted);
479
+ return lessons.filter((l) => l.status === STATUS.CANDIDATE && !l.demoted
480
+ && l.sourceClass !== SOURCE_CLASS.IMPORTED_OWNER
481
+ && l.sourceClass !== SOURCE_CLASS.DEMONSTRATION);
452
482
  }
@@ -1,5 +1,5 @@
1
1
  #!/bin/bash
2
- # route-dispatch.sh — PreToolUse gate on subagent dispatch. Ends model-inheritance-by-omission.
2
+ # route-dispatch.sh — bounded PreToolUse audit of subagent model selection.
3
3
  #
4
4
  # ─────────────────────────────────────────────────────────────────────────────────────────────────
5
5
  # THE LEAK (2026-07-13). Stuart: "What happens when I'm right here in Opus 4.8 and it has 10 things
@@ -8,8 +8,12 @@
8
8
  # A SUBAGENT INHERITS THE MAIN-LOOP MODEL UNLESS `model` IS EXPLICITLY PASSED.
9
9
  #
10
10
  # Ten agents on a Fable session = ten agents at $10/$50 per Mtok, ~10x Haiku for identical mechanical
11
- # work. The router existed; the rule to use it existed; the router's ENTIRE LIFETIME OUTPUT was 3 test
12
- # pings and $0.018 saved — because the rule was ADVISORY. So this is a wall, not advice.
11
+ # work. This hook records declared and inherited dispatches so the leak remains measurable.
12
+ #
13
+ # HOST LIMITATION (#84): Claude Code 2.1.220 registers Agent/Task PreToolUse hooks asynchronously,
14
+ # completes the subagent dispatch, and only then consumes the hook result. An exit-2 refusal is
15
+ # therefore too late to block and must not be represented as enforcement. The hook is intentionally
16
+ # silent and advisory until the host provides a synchronous pre-dispatch decision boundary.
13
17
  #
14
18
  # ─────────────────────────────────────────────────────────────────────────────────────────────────
15
19
  # THREE DEFECTS IN MY OWN FIRST VERSION, caught by asking the questions Stuart would have asked
@@ -21,14 +25,10 @@
21
25
  # (a model-router profile.json exists = they answered the two subscription questions). Everyone
22
26
  # else gets NOTHING — not even a warning. Consent is the default.
23
27
  # 2. IT REQUIRED python3. The other three plugin hooks are pure bash. A hard dependency inside a
24
- # BLOCKING hook is how you brick someone's session. Now pure bash — no interpreters.
25
- # 3. IT COULD FAIL CLOSED. A blocking hook that errors must never take the session with it. Every
26
- # unparseable/ambiguous case now FAILS OPEN (exit 0). A gate that breaks your tools is worse
27
- # than the leak it prevents.
28
+ # hook is how you brick someone's session. Now pure bash — no interpreters.
29
+ # 3. IT COULD FAIL CLOSED. Every unparseable/ambiguous case fails open (exit 0).
28
30
  #
29
- # CONTRACT (verified against this machine's live hook config):
30
- # exit 0 → allow
31
- # exit 2 + stderr → BLOCK, and stderr comes back to the model as the reason (so it retries correctly)
31
+ # CONTRACT: always exit 0 and emit no user-facing bytes. Audit receipts are best-effort only.
32
32
  # ─────────────────────────────────────────────────────────────────────────────────────────────────
33
33
 
34
34
  set -uo pipefail
@@ -93,9 +93,10 @@ TOOL_USE_ID=$(field tool_use_id)
93
93
  SESSION_ID=$(field session_id)
94
94
  DESC="${DESC// /_}"; DESC="${DESC:0:40}" # builtin substitution — no `tr`, no `cut`
95
95
 
96
- if [ -n "$MODEL" ]; then
97
- # Declared. Log it so routing is AUDITABLE, not merely claimed — a growing ledger is evidence;
98
- # a promise is not. (This log is how the $0.018-lifetime failure became visible in the first place.)
96
+ log_dispatch() {
97
+ local selected_model="$1"
98
+ local enforcement="$2"
99
+ # Log so routing is AUDITABLE, not merely claimed — a growing ledger is evidence; a promise is not.
99
100
  # `date` is the ONE external command left, and only on the ALLOW path — so its absence must be
100
101
  # silent, not a stderr spew from a hook that just said "yes". (bash's printf %()T would avoid it
101
102
  # entirely, but macOS still ships bash 3.2, which does not support it.)
@@ -105,44 +106,17 @@ if [ -n "$MODEL" ]; then
105
106
  {
106
107
  TS=$(date -u +%FT%TZ) || TS="unknown" # the one external command, and only on the allow path
107
108
  mkdir -p "$HOME/.claude/metaharness"
108
- printf '{"ts":"%s","event":"dispatch","model":"%s","agent":"%s","task":"%s","toolUseId":"%s","sessionId":"%s"}\n' \
109
- "$TS" "$MODEL" "${SUBTYPE:-unknown}" "${DESC:-unlabeled}" "${TOOL_USE_ID:-}" "${SESSION_ID:-}" \
109
+ printf '{"ts":"%s","event":"dispatch","model":"%s","enforcement":"%s","agent":"%s","task":"%s","toolUseId":"%s","sessionId":"%s"}\n' \
110
+ "$TS" "$selected_model" "$enforcement" "${SUBTYPE:-unknown}" "${DESC:-unlabeled}" "${TOOL_USE_ID:-}" "${SESSION_ID:-}" \
110
111
  >> "$HOME/.claude/metaharness/dispatch-log.jsonl"
111
112
  } 2>/dev/null || true
113
+ }
114
+
115
+ if [ -n "$MODEL" ]; then
116
+ log_dispatch "$MODEL" "declared"
112
117
  exit 0
113
118
  fi
114
119
 
115
- # ── BLOCKED: no model declared → it would silently inherit the session model. ──
116
- # `read` + `printf` are BUILTINS. The original used `cat >&2 <<EOF`, which made the BLOCK path itself
117
- # depend on an external binary — the third dependency hole found in my own hook in ten minutes.
118
- read -r -d '' BLOCK_MSG <<'EOF' || true
119
- ⛔ SUBAGENT DISPATCH BLOCKED — you did not declare a `model`.
120
-
121
- An agent with no `model` INHERITS this session's model. On an Opus session that is an Opus agent;
122
- on a Fable session it is $10/$50 per Mtok — up to 10x what the same work costs on Haiku.
123
- Inheritance-by-omission is the biggest cost leak in this harness, and an advisory rule did not fix
124
- it (the router's entire first life saved $0.018). Hence a wall.
125
-
126
- Re-issue the SAME Agent call with an explicit `model`, chosen by what the task actually IS:
127
-
128
- model: "haiku" mechanical — greps, file sweeps, log triage, mechanical edits, fixture rewrites
129
- model: "sonnet" analytical — trace a bug across files, summarize a subsystem, draft tests
130
- model: "opus" judgment — architecture, root cause, security, anything user-facing
131
- (if it truly needs the main model's judgment, ask whether it should be a
132
- subagent at all, or work you should do inline)
133
-
134
- Not sure? Ask rUv's real router — it predicts each model's quality on THIS task and returns the
135
- cheapest one that clears the bar, with your subscriptions priced at $0:
136
-
137
- node ~/.claude/model-router/bin/model-router-engine.mjs --harness claude-code --prompt "<task>" --json
138
-
139
- Then log the receipt when it returns, so the saving is visible instead of asserted:
140
-
141
- node scripts/dispatch-receipt.mjs --model <m> --inherited <this session's model> \
142
- --task "<what it did>" --total-tokens <the agent's reported total>
143
-
144
- Deliberate exception (rare — and say WHY out loud): RUVNET_ALLOW_INHERITED_MODEL=1
145
- EOF
146
- bash "$(dirname "${BASH_SOURCE[0]}")/gate-receipt.sh" route-dispatch "subagent" "would inherit the session model instead of routing to a cheaper one" 2>/dev/null || true
147
- printf '%s\n' "$BLOCK_MSG" >&2
148
- exit 2
120
+ # Missing model: record the inheritance leak, but do not emit a late refusal the host cannot enforce.
121
+ log_dispatch "inherited" "advisory-host-timing"
122
+ exit 0
@@ -0,0 +1,100 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const SNAPSHOT_SCHEMA = 'ruvnet-brain.session-snapshot';
5
+ export const SNAPSHOT_VERSION = 1;
6
+ export const SNAPSHOT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
7
+
8
+ const EVENTS = new Set(['PreCompact', 'PostCompact', 'SessionEnd']);
9
+
10
+ export function createSessionSnapshot({ event, capturedAt = new Date().toISOString() }) {
11
+ if (!EVENTS.has(event)) throw new Error(`unsupported snapshot event: ${event}`);
12
+ if (!Number.isFinite(Date.parse(capturedAt))) throw new Error('capturedAt must be an ISO-8601 timestamp');
13
+ return {
14
+ schema: SNAPSHOT_SCHEMA,
15
+ version: SNAPSHOT_VERSION,
16
+ capturedAt,
17
+ boundary: { event },
18
+ privacy: { rawTranscriptStored: false, credentialValuesStored: false },
19
+ };
20
+ }
21
+
22
+ export function validateSessionSnapshot(value) {
23
+ return Boolean(value)
24
+ && typeof value === 'object'
25
+ && !Array.isArray(value)
26
+ && value.schema === SNAPSHOT_SCHEMA
27
+ && value.version === SNAPSHOT_VERSION
28
+ && EVENTS.has(value.boundary?.event)
29
+ && Number.isFinite(Date.parse(value.capturedAt))
30
+ && value.privacy?.rawTranscriptStored === false
31
+ && value.privacy?.credentialValuesStored === false;
32
+ }
33
+
34
+ function freshness(capturedAt, now) {
35
+ const age = now - Date.parse(capturedAt);
36
+ return age >= 0 && age <= SNAPSHOT_MAX_AGE_MS;
37
+ }
38
+
39
+ function canonical(projectDir, now) {
40
+ const file = path.join(projectDir, '.swarm', 'agentdb-sessions.jsonl');
41
+ if (!fs.existsSync(file)) return { values: [], malformed: false };
42
+ try {
43
+ const stat = fs.lstatSync(file);
44
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4 * 1024 * 1024) {
45
+ return { values: [], malformed: true };
46
+ }
47
+ const lines = fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).slice(-128);
48
+ const values = [];
49
+ let malformed = false;
50
+ for (const line of lines) {
51
+ try {
52
+ const value = JSON.parse(line);
53
+ if (validateSessionSnapshot(value)) values.push({ kind: 'canonical', fresh: freshness(value.capturedAt, now), capturedAt: value.capturedAt });
54
+ else malformed = true;
55
+ } catch { malformed = true; }
56
+ }
57
+ return { values, malformed };
58
+ } catch { return { values: [], malformed: true }; }
59
+ }
60
+
61
+ function legacy(projectDir, now) {
62
+ const values = [];
63
+ let malformed = false;
64
+ for (const root of ['.claude', '.claude-flow']) {
65
+ const directory = path.join(projectDir, root, 'sessions');
66
+ if (!fs.existsSync(directory)) continue;
67
+ let names;
68
+ try {
69
+ const stat = fs.lstatSync(directory);
70
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return { values: [], malformed: true };
71
+ names = fs.readdirSync(directory).filter((name) => /^session-.*\.json$/.test(name)).slice(-64);
72
+ } catch { malformed = true; continue; }
73
+ for (const name of names) {
74
+ try {
75
+ const file = path.join(directory, name);
76
+ const stat = fs.lstatSync(file);
77
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) { malformed = true; continue; }
78
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
79
+ const capturedAt = value.endedAt || value.startedAt;
80
+ if (typeof value.id !== 'string' || !value.id || !value.context || !value.metrics || !Number.isFinite(Date.parse(capturedAt))) {
81
+ malformed = true;
82
+ continue;
83
+ }
84
+ values.push({ kind: 'legacy', fresh: freshness(capturedAt, now), capturedAt });
85
+ } catch { malformed = true; }
86
+ }
87
+ }
88
+ return { values, malformed };
89
+ }
90
+
91
+ export function inspectSessionSnapshots(projectDir, { now = Date.now() } = {}) {
92
+ const results = [canonical(projectDir, now), legacy(projectDir, now)];
93
+ const values = results.flatMap((result) => result.values);
94
+ const priority = { canonical: 0, legacy: 1 };
95
+ const fresh = values.filter((value) => value.fresh).sort((a, b) => priority[a.kind] - priority[b.kind] || Date.parse(b.capturedAt) - Date.parse(a.capturedAt))[0];
96
+ if (fresh) return fresh;
97
+ if (values.length) return values.sort((a, b) => priority[a.kind] - priority[b.kind] || Date.parse(b.capturedAt) - Date.parse(a.capturedAt))[0];
98
+ if (results.some((result) => result.malformed)) return { kind: 'malformed', fresh: false };
99
+ return { kind: 'absent', fresh: false };
100
+ }
@@ -0,0 +1,34 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createSessionSnapshot } from './session-snapshot-contract.mjs';
4
+
5
+ function regularOrAbsent(file) {
6
+ try {
7
+ const stat = fs.lstatSync(file);
8
+ return stat.isFile() && !stat.isSymbolicLink();
9
+ } catch (error) {
10
+ return error?.code === 'ENOENT';
11
+ }
12
+ }
13
+
14
+ export function writeSessionSnapshot(projectDir, event) {
15
+ const swarm = path.join(projectDir, '.swarm');
16
+ const target = path.join(swarm, 'agentdb-sessions.jsonl');
17
+ try {
18
+ if (fs.existsSync(swarm)) {
19
+ const stat = fs.lstatSync(swarm);
20
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
21
+ } else {
22
+ fs.mkdirSync(swarm, { recursive: false, mode: 0o700 });
23
+ }
24
+ if (!regularOrAbsent(target)) return false;
25
+ fs.appendFileSync(target, `${JSON.stringify(createSessionSnapshot({ event }))}\n`, { mode: 0o600 });
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
33
+ writeSessionSnapshot(process.env.CLAUDE_PROJECT_DIR || process.cwd(), process.argv[2] || 'SessionEnd');
34
+ }
@@ -62,9 +62,30 @@ const dispatchDetached = (hookDir, ttl, log, command, args = [], env = process.e
62
62
  String(ttl), log, command, ...args,
63
63
  ], { env, stdio: 'ignore', timeout: 2000 })?.status === 0;
64
64
 
65
- const surfaceIssues = (stateDir, emit, now) => {
65
+ export const maintainerIssueEntitlement = (env, home, repo, platform = process.platform) => {
66
+ const file = env.RUVNET_BRAIN_MAINTAINER_ISSUES_FILE
67
+ || path.join(home, '.config', 'ruvnet-brain', 'maintainer-issues.json');
68
+ // Windows ACL ownership is not available through this dependency-free hot path. Fail closed
69
+ // instead of weakening an owner-only promise into "any local user who can write the file".
70
+ if (platform === 'win32') return false;
71
+ let stat;
72
+ try { stat = fs.lstatSync(file); } catch { return false; }
73
+ if (!stat.isFile() || stat.isSymbolicLink()) return false;
74
+ // This is maintainer-only operational data. Refuse group/world-readable opt-ins on POSIX so a
75
+ // shared machine cannot turn a private maintainer signal into a terminal banner for other users.
76
+ if ((stat.mode & 0o077) !== 0) return false;
77
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return false;
78
+ const entitlement = json(file);
79
+ return entitlement?.enabled === true
80
+ && Array.isArray(entitlement.repos)
81
+ && entitlement.repos.includes(repo);
82
+ };
83
+
84
+ const surfaceIssues = (stateDir, emit, now, env, home, platform) => {
66
85
  const status = json(path.join(stateDir, 'open-issues.json'));
67
- if (!status?.at || now - new Date(status.at).getTime() > 6 * 3600_000) return;
86
+ const observedAt = Date.parse(status?.at || '');
87
+ if (!Number.isFinite(observedAt) || observedAt > now + 5 * 60_000 || now - observedAt > 6 * 3600_000) return;
88
+ if (!maintainerIssueEntitlement(env, home, status.repo, platform)) return;
68
89
  const open = Array.isArray(status.issues) ? status.issues : [];
69
90
  if (!open.length) return;
70
91
  const breaches = open.filter((issue) => issue.breach).sort((a, b) => b.ageHours - a.ageHours);
@@ -383,7 +404,7 @@ export async function runSessionStart({
383
404
  emit(`Offer ONCE: "Want to see your whole RuvNet stack on one page?" — installed parts, learned project knowledge and reversible fixes, read-only until clicked; later it's ${consoleInvoke}. On yes invoke ${consoleInvoke}; on no, don't re-offer.`);
384
405
  }
385
406
 
386
- surfaceIssues(stateDir, emit, now);
407
+ surfaceIssues(stateDir, emit, now, env, home, platform);
387
408
  surfaceSignals({ env, cwd, stateDir, hookDir, emit, now });
388
409
 
389
410
  const routerProfile = path.join(home, '.claude', 'model-router', 'profile.json');
@@ -476,10 +497,7 @@ export async function runSessionStart({
476
497
 
477
498
  const playbook = `${hookDir}${path.sep}..${path.sep}skills${path.sep}ruvnet-brain${path.sep}PLAYBOOK.md`;
478
499
  emit('[RuvNet Brain — standing build playbook for this session (referenced by later turns as THE PLAYBOOK)]');
479
- emit(`Full text: ${playbook} — read it before your first build response this session. Condensed:`);
480
- emit('Every build/change request: take the wheel. FIRST, silently — read the files this touches in THEIR repo; search_ruvnet what the feature technically DOES; check project memory.');
481
- emit('⛔ NO SILENT SUBSTITUTION (#1 trust-killer): never hand-roll, or aim a generic Task subagent at, work a RuvNet tool owns — QE=agentic-qe, swarms=ruflo, routing=agentic-flow, vectors=RuVector, memory=AgentDB, red/blue=@metaharness/redblue. Use the real one; if absent offer the exact install; if unusable say so out loud, every time. Never give your own code its name.');
482
- emit('Beats A-D are in that file, in full. In short: A RESPOND in one voice (hear them; THE ATTACK as one lettered plan over their real files; why it holds; what you checked; "Build it now?") · B ON A YES EXECUTE END-TO-END (SPARC with a QA gate per phase, DDD, ADRs, PARALLEL Ruflo swarm work, AgentDB persistence, frontend-design + real image generation, a PROVEN result scored to >=98, ONE ask for a missing API key) · C TAKE OVER what you do well · D keep them oriented. RUN THE PROCESS.');
500
+ emit(`Read ${playbook} before the first build response. It requires source inspection, search_ruvnet grounding, project-memory recall, and the real owning rUv tool—never a silent hand-roll or generic substitute.`);
483
501
  }
484
502
  } catch (error) {
485
503
  if (env.RUVNET_SESSION_TRACE === '1') stderr.write(`SESSION_TRACE native-fail-open ${error?.message || error}\n`);
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ // swarm-slot-recycler.mjs — Claude Code TeammateIdle recycling boundary.
3
+ //
4
+ // Claude owns the shared task files and their claim locks. This hook never edits them. It reads the
5
+ // team ledger at the synchronous TeammateIdle boundary and refuses idling only when it can prove an
6
+ // unassigned pending task is ready. Claude then performs the normal locked TaskUpdate claim. Missing,
7
+ // malformed, dependency-blocked, or ambiguous state fails open: a scheduler must not invent work.
8
+ //
9
+ // Host boundary: Claude Code exposes TeammateIdle; Codex 0.146.0 exposes SubagentStop but no
10
+ // TeammateIdle/TaskCompleted event or equivalent shared-task ledger. Do not register this body in the
11
+ // Codex manifest and do not claim Codex recycling is hook-enforced.
12
+
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { readStdinBounded } from './hook-input.mjs';
17
+
18
+ const MAX_INPUT_BYTES = 64 * 1024;
19
+ const MAX_TASK_BYTES = 256 * 1024;
20
+ const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
21
+
22
+ function allowIdle() { process.exit(0); }
23
+
24
+ let raw = Buffer.alloc(0);
25
+ try {
26
+ raw = await readStdinBounded({ maxBytes: MAX_INPUT_BYTES, idleMs: 50, emptyMs: 250 });
27
+ } catch { allowIdle(); }
28
+
29
+ let event;
30
+ try {
31
+ event = JSON.parse(raw.toString('utf8'));
32
+ } catch { allowIdle(); }
33
+
34
+ if (!event || typeof event !== 'object' || Array.isArray(event)) allowIdle();
35
+ if (event.hook_event_name !== 'TeammateIdle') allowIdle();
36
+
37
+ const teamName = String(event.team_name || '');
38
+ const teammateName = String(event.teammate_name || '');
39
+ if (!SAFE_NAME.test(teamName) || !SAFE_NAME.test(teammateName)) allowIdle();
40
+
41
+ const tasksRoot = process.env.RUVNET_CLAUDE_TASKS_DIR
42
+ || path.join(os.homedir(), '.claude', 'tasks');
43
+ const teamDir = path.resolve(tasksRoot, teamName);
44
+ const root = path.resolve(tasksRoot);
45
+ if (path.dirname(teamDir) !== root) allowIdle();
46
+
47
+ let files;
48
+ try {
49
+ files = fs.readdirSync(teamDir, { withFileTypes: true })
50
+ .filter((entry) => entry.isFile() && /^\d+\.json$/.test(entry.name))
51
+ .map((entry) => entry.name);
52
+ } catch { allowIdle(); }
53
+
54
+ const tasks = new Map();
55
+ for (const file of files) {
56
+ try {
57
+ const taskPath = path.join(teamDir, file);
58
+ const stat = fs.statSync(taskPath);
59
+ if (!stat.isFile() || stat.size > MAX_TASK_BYTES) continue;
60
+ const task = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
61
+ if (!task || typeof task !== 'object' || Array.isArray(task)) continue;
62
+ const id = String(task.id || path.basename(file, '.json'));
63
+ if (!/^\d+$/.test(id)) continue;
64
+ tasks.set(id, task);
65
+ } catch { /* one bad task cannot make another look ready */ }
66
+ }
67
+
68
+ const completed = new Set(
69
+ [...tasks].filter(([, task]) => task.status === 'completed').map(([id]) => id),
70
+ );
71
+
72
+ const ready = [...tasks]
73
+ .filter(([, task]) => task.status === 'pending')
74
+ .filter(([, task]) => !String(task.owner || '').trim())
75
+ .filter(([, task]) => {
76
+ if (!Array.isArray(task.blockedBy)) return false;
77
+ return task.blockedBy.every((id) => completed.has(String(id)));
78
+ })
79
+ .sort(([left], [right]) => Number(left) - Number(right));
80
+
81
+ if (!ready.length) allowIdle();
82
+
83
+ const [id, task] = ready[0];
84
+ const subject = String(task.subject || 'untitled task').replace(/[\r\n]+/g, ' ').slice(0, 160);
85
+ process.stderr.write(
86
+ `Ready work remains. Claim task ${id} (${subject}) now with TaskUpdate owner=${teammateName} `
87
+ + 'and status=in_progress, then execute it. Do not go idle while an unassigned, unblocked pending task exists.\n',
88
+ );
89
+ process.exit(2);
@@ -30,10 +30,10 @@ green.
30
30
  ## Candidate seal
31
31
 
32
32
  Generate the receipt from commands in the protected candidate workflow. Do not hand-author it.
33
- For generation 4.0.4, dispatch `.github/workflows/protected-release.yml` only with the full candidate
34
- SHA, sealed artifact SHA-256, exact version `4.0.4`, and the successful exact-SHA CI run ID whose
35
- named `release-qe` job produced `release-evidence-<sha>`. The workflow checks every binding before
36
- creating its sealed handoff and again after the production reviewer approves. Missing artifacts,
33
+ Dispatch `.github/workflows/protected-release.yml` only with the full candidate SHA, the exact
34
+ current version, and the successful exact-SHA CI run ID whose named `release-qe` job produced
35
+ `release-evidence-<sha>`. The workflow derives the artifact digest from those sealed bytes and checks
36
+ every binding before creating its handoff and again at the Production boundary. Missing artifacts,
37
37
  pending/red jobs, malformed inputs, version splits, and byte mismatches stop before the publisher.
38
38
  Validate it from the repository with:
39
39
 
@@ -1,6 +1,6 @@
1
1
  # THE PLAYBOOK — the standing build playbook, in full
2
2
 
3
- Updated: 2026-07-30 | Version 1.0.1
3
+ Updated: 2026-08-02 | Version 1.1.0
4
4
  Created: 2026-07-27
5
5
 
6
6
  **Read this before your first build response in a session.** `plugin/scripts/session-start.sh`
@@ -87,10 +87,25 @@ rule-compliance, cite a source the tools didn't return, or claim a check that di
87
87
  Completion, with a QA gate between phases.
88
88
  - For a non-trivial domain, model it first (DDD: bounded contexts, aggregates, domain events) and
89
89
  capture key decisions as ADRs — design before code.
90
- - Spin up PARALLEL work where it helps (a Ruflo swarm / multiple agents) instead of serial drudgery.
90
+ - **Parallel-by-default state machine for multi-part work.** Before the first spawn, decompose the
91
+ whole request, create the complete shared task list/ledger, record dependencies and give every
92
+ writing task its own worktree. Ruflo coordinates roles and state; the native host executes. Fill
93
+ available executor slots with independent ready work immediately, without asking, up to the
94
+ host's configured capacity; never oversubscribe and never put more than one writer in a worktree.
95
+ A completion moves that task to completed, unblocks its dependents, and the freed slot claims the
96
+ first unassigned, unblocked pending task immediately. Only allow a slot to idle when no such task
97
+ exists. Keep dependent integration with the designated integration owner.
98
+ - **Claude Code:** its shared task ledger and `TeammateIdle` hook make recycling enforceable: the
99
+ shipped recycler refuses idle while a ready unassigned task exists, then Claude's locked
100
+ `TaskUpdate` claim performs the transition.
101
+ - **Codex:** Codex 0.146.0 exposes no `TeammateIdle` or `TaskCompleted` hook and no equivalent
102
+ shared-task hook ledger. Initial fan-out and completion-notification recycling are guidance,
103
+ not hook enforcement: the lead must immediately dispatch the next ready ledger item when a
104
+ collaboration slot completes. State this degraded boundary if it affects the run; never call it
105
+ enforced.
91
106
  If Ruflo / RuVector MCP tools aren't available in this environment, DON'T block or stall — degrade
92
- gracefully to Claude Code's native subagents (Task) and local .rvf, and briefly note the tool that
93
- would make it better + how to add it. Never demand a tool the user doesn't have.
107
+ gracefully to the native host's agents and local .rvf, and briefly note the tool that would make
108
+ it better + how to add it. Never demand a tool the user doesn't have.
94
109
  - Persist decisions + state to AgentDB memory so nothing is lost across sessions or compaction.
95
110
  - If it has a UI, treat design as a BUILD STEP, not a coat of paint: apply the frontend-design
96
111
  discipline and GENERATE the visuals (AI image generation for UI mockups / diagrams / the explainer
@@ -515,9 +515,9 @@ export const CAPABILITIES = [
515
515
  // router was in fact never being consulted at all. A quiet week and a severed wire look identical
516
516
  // from the receipt file, so read the wire directly.
517
517
  //
518
- // Two things must both be true for anything to route: a PreToolUse gate on subagent dispatch
519
- // (plugin/scripts/route-dispatch.sh, which is what turns "declare a model" from advice into a
520
- // wall), and the opt-in profile it refuses to act without (route-dispatch.sh:46 exits 0 when
518
+ // Two things must both be true for the host-limited dispatch audit to record anything: a
519
+ // PreToolUse hook on subagent dispatch and the opt-in profile it refuses to act without
520
+ // (route-dispatch.sh exits 0 when
521
521
  // profile.json is absent). Either missing ⇒ the router cannot fire, regardless of how healthy
522
522
  // the receipt ledger looks.
523
523
  const profile = fs.existsSync(path.join(HOME, '.claude/model-router/profile.json'));