ruvnet-brain 4.3.40 → 4.4.0

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 (48) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +111 -27
  3. package/kb/brain-profile.mjs +17 -2
  4. package/kb/forge-update.mjs +6 -2
  5. package/kb/lifecycle-evidence-retention.mjs +12 -9
  6. package/kb/refresh-run.mjs +17 -1
  7. package/kb/update-storage-transaction.mjs +33 -5
  8. package/package.json +1 -1
  9. package/plugin/.claude-plugin/plugin.json +1 -1
  10. package/plugin/.codex-plugin/plugin.json +1 -1
  11. package/plugin/hooks/codex-hooks.json +2 -2
  12. package/plugin/hooks/hooks.json +1 -1
  13. package/plugin/scripts/capability-registry.mjs +3 -3
  14. package/plugin/scripts/codex-hook-wrapper.mjs +7 -2
  15. package/plugin/scripts/design-wall.sh +1 -0
  16. package/plugin/scripts/ground-before-write.sh +1 -0
  17. package/plugin/scripts/ground-ruvnet.sh +3 -3
  18. package/plugin/scripts/grounding-answer.mjs +129 -0
  19. package/plugin/scripts/grounding-stamp.sh +32 -31
  20. package/plugin/scripts/grounding-turn-evidence.mjs +99 -5
  21. package/plugin/scripts/grounding-turn-gate.mjs +25 -6
  22. package/plugin/scripts/hook-shim.mjs +3 -0
  23. package/plugin/scripts/kling-preflight.sh +1 -0
  24. package/plugin/scripts/learn-capture.sh +1 -0
  25. package/plugin/scripts/project-progression-sources.mjs +16 -4
  26. package/plugin/scripts/project-progression-store.mjs +49 -1
  27. package/plugin/scripts/protect-brain-state.sh +1 -0
  28. package/plugin/scripts/route-dispatch.sh +1 -0
  29. package/plugin/scripts/session-snapshot-hook.mjs +224 -38
  30. package/plugin/scripts/session-start-health.mjs +24 -3
  31. package/plugin/scripts/session-start-update-plane.mjs +1 -1
  32. package/plugin/scripts/update-apply.mjs +22 -2
  33. package/scripts/console-instances.mjs +145 -0
  34. package/scripts/console-runtime-identity.mjs +2 -0
  35. package/scripts/corpus-canary.mjs +130 -18
  36. package/scripts/customer-seams.mjs +84 -0
  37. package/scripts/customer-state-matrix.mjs +363 -0
  38. package/scripts/full-suite-gate.mjs +155 -0
  39. package/scripts/grounding-turn-replay.mjs +11 -3
  40. package/scripts/hook-qualify-core.mjs +346 -0
  41. package/scripts/hook-qualify-hosts.mjs +115 -0
  42. package/scripts/hook-qualify.mjs +101 -0
  43. package/scripts/host-cli.mjs +115 -0
  44. package/scripts/qe/agentic-qe-4.3.mjs +0 -1
  45. package/scripts/route-gold-rank.mjs +156 -0
  46. package/scripts/route-index-memory.mjs +51 -0
  47. package/scripts/route-latency-warm.mjs +123 -0
  48. package/scripts/wired-check.mjs +17 -3
@@ -347,7 +347,7 @@ LASTV=$(cat "$VSTAMP" 2>/dev/null || echo 0)
347
347
  VJITTER=$(( ( $(hostname 2>/dev/null | cksum 2>/dev/null | cut -d' ' -f1 || echo 0) % 8641 ) - 4320 ))
348
348
  VINTERVAL=$(( 21600 + VJITTER ))
349
349
  if [ "$NOWV" -gt 0 ] && [ $((NOWV - LASTV)) -gt "$VINTERVAL" ]; then
350
- echo "$NOWV" > "$VSTAMP" 2>/dev/null
350
+ echo "$NOWV" 2>/dev/null > "$VSTAMP" # 2>/dev/null FIRST: a failed redirect prints the shell's own error otherwise
351
351
  # BACKGROUNDED (QE-0011 code#1): these are 3 sequential `curl --max-time 3` = up to ~9s. Running
352
352
  # them synchronously HERE — before the grounding gates below — risks the whole hook being killed by
353
353
  # Claude Code's ~5s hook timeout on the once/20h refresh tick, which would DROP the actual grounding
@@ -357,7 +357,7 @@ if [ "$NOWV" -gt 0 ] && [ $((NOWV - LASTV)) -gt "$VINTERVAL" ]; then
357
357
  ( for PKG in ruflo @claude-flow/cli @ruvector/rvf; do
358
358
  L=$(curl -fsS --max-time 3 "https://registry.npmjs.org/$PKG/latest" 2>/dev/null | sed -E 's/.*"version":"([^"]+)".*/\1/' | head -c 40)
359
359
  [ -n "$L" ] && echo "$PKG $L"
360
- done > "$VCACHE".tmp 2>/dev/null && mv -f "$VCACHE".tmp "$VCACHE" 2>/dev/null ) &
360
+ done 2>/dev/null > "$VCACHE".tmp && mv -f "$VCACHE".tmp "$VCACHE" 2>/dev/null ) 2>/dev/null &
361
361
  fi
362
362
  if [ -s "$VCACHE" ]; then
363
363
  OUTDATED=""
@@ -625,7 +625,7 @@ if [ -n "$METER_TMP" ]; then
625
625
  mkdir -p "$METER_LEDGER_DIR" 2>/dev/null && \
626
626
  printf '{"ts":"%s","source":"hook","class":"%s","bytes":%d,"cwd":"%s"}\n' \
627
627
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$METER_CLASS" "$METER_BYTES" "$( { pwd -W 2>/dev/null || pwd 2>/dev/null; } | sed 's/"/\\"/g')" \
628
- >> "$METER_LEDGER_DIR/token-ledger.jsonl" 2>/dev/null
628
+ 2>/dev/null >> "$METER_LEDGER_DIR/token-ledger.jsonl"
629
629
  fi
630
630
 
631
631
  exit 0
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * grounding-answer.mjs — the ONE predicate for "did search_ruvnet actually answer?", shared by the
4
+ * PostToolUse stamp (grounding-stamp.sh calls this file as a CLI) and the Stop gate
5
+ * (grounding-turn-evidence.mjs imports brainAnsweredResponse).
6
+ *
7
+ * 4.4.0 ADVERSARIAL REVIEW, BLOCKER B1. The previous predicate matched success markers anywhere in
8
+ * the tool response. But every lane echoes the MODEL's query back at structuredContent.retrieval.query
9
+ * (kb/grounded-response.mjs) — including the router-decline lane ("NO SEARCH WAS RUN",
10
+ * kb/search-outcome.mjs) and source discovery. A query that merely contained `Searched 37 RuvNet repos`
11
+ * turned a search that never ran into a 24-hour stamp, and the long-turn fallback then trusted it.
12
+ *
13
+ * THE RULE NOW: success is read from the ANSWER TEXT only — `answer` or `content[].text`, never
14
+ * `retrieval`, `grounding`, `routing` or any other echoed field — and the answer must BEGIN with the
15
+ * header the brain itself prints (kb/search-outcome.mjs, kb/card-lane.mjs renderCardHit), optionally
16
+ * after the degraded-search paragraph. Text the model controls never sits at the start of the answer.
17
+ * An oversize result counts only when the host's own notice is the WHOLE response's beginning and the
18
+ * saved file is the host's: under $HOME/.claude/projects/<p>/…/tool-results/, a regular file, not a
19
+ * link, written no later than the tool call, and itself beginning with an answer that passes this rule.
20
+ */
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const BANNER = /^Searched \d+ RuvNet repos \(/;
27
+ const CARD = /^⚡ FAST LANE — [^\n]*\n#1 repo=\S+ evidence=curated-capability-card\n/;
28
+ // The first failed repo's error is quoted inside this paragraph and can itself contain newlines, so the
29
+ // paragraph is matched up to its fixed closing sentence, bounded, not up to the first newline.
30
+ const DEGRADED = /^⚠ DEGRADED SEARCH: [\s\S]{0,4000}?\nResults below cover only the healthy repos\. Mention this degradation to the user\.\n\n/;
31
+ const EMPTY = '(no results — the search ran';
32
+ const OVERSIZE = /^Error: result \([\d,]+ characters\) exceeds maximum allowed tokens\. Output has been saved to (\S+?\.txt)\./;
33
+ const PERSISTED = /^<persisted-output>\nOutput too large \([^)]*\)\. Full output saved to: (\S+?\.txt)\n/;
34
+
35
+ /** Does this ANSWER TEXT carry the brain's own success header at its start (and not the empty result)? */
36
+ export function answerTextAnswered(text) {
37
+ const t = String(text ?? '').replace(DEGRADED, '');
38
+ if (CARD.test(t)) return true;
39
+ if (!BANNER.test(t)) return false;
40
+ const empty = t.indexOf(EMPTY);
41
+ return empty < 0 || (t.indexOf('#1 repo=') >= 0 && t.indexOf('#1 repo=') < empty);
42
+ }
43
+
44
+ /** JSON-unescape the leading string value of a truncated `{"answer":"…` document (no full parse possible). */
45
+ function leadingAnswer(raw) {
46
+ const m = /^\s*\{\s*"answer"\s*:\s*"((?:[^"\\]|\\.)*)/.exec(raw);
47
+ if (!m) return null;
48
+ try { return JSON.parse(`"${m[1]}"`); } catch {
49
+ // Truncated mid-escape: drop a dangling backslash sequence and retry once.
50
+ try { return JSON.parse(`"${m[1].replace(/\\[^"]?$|\\u[0-9a-fA-F]{0,3}$/, '')}"`); } catch { return null; }
51
+ }
52
+ }
53
+
54
+ /** The answer text of a tool response in any shape a host delivers it, or null. Never an echoed field. */
55
+ export function answerOf(response) {
56
+ if (response == null) return null;
57
+ if (Array.isArray(response)) {
58
+ const texts = response.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text);
59
+ return texts.length ? answerOf(texts[0]) : null;
60
+ }
61
+ if (typeof response === 'object') {
62
+ if (typeof response.answer === 'string') return response.answer;
63
+ if (Array.isArray(response.content)) return answerOf(response.content);
64
+ if (response.structuredContent && typeof response.structuredContent.answer === 'string') return response.structuredContent.answer;
65
+ return null;
66
+ }
67
+ const s = String(response);
68
+ if (/^\s*[{[]/.test(s)) {
69
+ try { return answerOf(JSON.parse(s)); } catch { return leadingAnswer(s); }
70
+ }
71
+ return s; // plain text content
72
+ }
73
+
74
+ /** The host's saved-result path when the response IS the host's oversize notice, else null. */
75
+ export function oversizePath(response) {
76
+ if (typeof response !== 'string') return null;
77
+ const m = OVERSIZE.exec(response) || PERSISTED.exec(response);
78
+ return m ? m[1] : null;
79
+ }
80
+
81
+ /**
82
+ * Did the brain answer? `notAfterMs`: the file must not be modified after this instant (the tool
83
+ * call's end); `notBeforeMs`: nor long before it (a file planted earlier is not this call's output).
84
+ */
85
+ export function brainAnsweredResponse(response, { home = os.homedir(), notAfterMs = null, notBeforeMs = null } = {}) {
86
+ const file = oversizePath(response);
87
+ if (!file) return answerTextAnswered(answerOf(response));
88
+ const projects = path.join(home, '.claude', 'projects') + path.sep;
89
+ if (file.includes('..') || !file.startsWith(projects)
90
+ || !/^[^\\/].*[\\/]tool-results[\\/][^\\/]+$/.test(file.slice(projects.length))) return false;
91
+ try {
92
+ const st = fs.lstatSync(file);
93
+ if (!st.isFile() || st.isSymbolicLink()) return false;
94
+ if (notAfterMs != null && st.mtimeMs > notAfterMs) return false;
95
+ if (notBeforeMs != null && st.mtimeMs < notBeforeMs) return false;
96
+ const fd = fs.openSync(file, 'r');
97
+ try {
98
+ const buf = Buffer.alloc(65536);
99
+ const head = buf.subarray(0, fs.readSync(fd, buf, 0, buf.length, 0)).toString('utf8');
100
+ return answerTextAnswered(answerOf(head));
101
+ } finally { fs.closeSync(fd); }
102
+ } catch { return false; }
103
+ }
104
+
105
+ /** CLI for grounding-stamp.sh: a PostToolUse payload on stdin → prints `answered` or nothing. Exit 0. */
106
+ async function main() {
107
+ const chunks = [];
108
+ let bytes = 0;
109
+ await new Promise((resolve) => {
110
+ const done = () => resolve();
111
+ const t = setTimeout(done, 3000); t.unref?.();
112
+ process.stdin.on('data', (c) => { if (bytes < 2_097_152) { chunks.push(c); bytes += c.length; } });
113
+ process.stdin.once('end', done); process.stdin.once('error', done);
114
+ });
115
+ try {
116
+ const payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
117
+ const now = Date.now();
118
+ if (brainAnsweredResponse(payload?.tool_response, { notAfterMs: now + 2000, notBeforeMs: now - 300_000 })) {
119
+ process.stdout.write('answered\n');
120
+ }
121
+ } catch { /* unparseable payload: nothing minted */ }
122
+ process.exit(0);
123
+ }
124
+
125
+ function isMain() {
126
+ try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
127
+ catch { return false; }
128
+ }
129
+ if (isMain()) main();
@@ -23,12 +23,10 @@
23
23
  # Measured on the pre-fix tree, in tests/unit/brain-off.test.mjs's recorded red run: five
24
24
  # distinct non-answers, five valid stamps.
25
25
  #
26
- # The success signal is the one line kb/forge-mcp-all.mjs prints on every genuinely-executed search
27
- # and on nothing else — `Searched <n> RuvNet repos (...)` — with the four known non-answers refused
28
- # explicitly first. Cheapest reliable signal in the payload: no parsing, no field extraction, plain
29
- # substring matching over the raw stdin, all of it bash builtins. The refusal markers are quote-free
30
- # on purpose: a PostToolUse payload JSON-encodes the tool response, so anything containing a double
31
- # quote would arrive as \" and never match.
26
+ # The success signal is the header the brain prints at the START of a genuine answer and nowhere
27
+ # else — `Searched <n> RuvNet repos (...)` or the fast-lane card header. Since 4.4.0 it is decided by
28
+ # grounding-answer.mjs on the PARSED answer text (substring matching over the raw payload was forged
29
+ # twice: first by the query in tool_input, then by the same query echoed in retrieval.query).
32
30
  #
33
31
  # CONTRACT: PostToolUse is non-blocking — always exit 0, swallow every failure.
34
32
 
@@ -41,36 +39,33 @@ INPUT=""
41
39
  # exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
42
40
  # is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
43
41
  # the whole thing back at once and a per-iteration cap never fires.
42
+ _l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
44
43
  while IFS= read -r -t 2 _l; do
45
44
  INPUT+="$_l"
46
- [ ${#INPUT} -ge 65536 ] && break
45
+ [ ${#INPUT} -ge 2097152 ] && break
47
46
  done
48
47
  [ -n "$_l" ] && INPUT+="$_l"
49
- INPUT="${INPUT:0:65536}"
48
+ # 2 MiB, not 64 KiB (4.4.0): the verdict below PARSES the payload, and a payload cut mid-JSON parses as
49
+ # nothing — too small a cap would silently mint nothing for a large genuine answer.
50
+ INPUT="${INPUT:0:2097152}"
50
51
  [ -n "$INPUT" ] || exit 0
51
52
 
52
- shopt -s nocasematch 2>/dev/null || true
53
-
54
- # ── 1. REFUSE the known non-answers, before anything else. Each of these minted a real 24h stamp. ──
55
- case "$INPUT" in
56
- # ADR-054: the brain is switched off. The exact phrase is pinned to the producer by test.
57
- *"RuvNet Brain is disabled"*) exit 0 ;;
58
- # The GONG: every repo failed. An outage is not grounding.
59
- *"RUVNET BRAIN IS DOWN"*) exit 0 ;;
60
- # A thrown error inside the tool.
61
- *"search_ruvnet error:"*) exit 0 ;;
62
- # The search ran and matched nothing. A real answer to the wrong question — but the brain showed
63
- # the model no source, so there is nothing for a stamp to attest to.
64
- *"(no results"*) exit 0 ;;
65
- esac
66
-
67
- # ── 2. REQUIRE the success banner. No banner ⇒ no successful search happened in this payload ⇒ no
68
- # stamp. This is what makes a missing or empty tool_response mint nothing, which is the query-only
69
- # behaviour finally gone.
70
- case "$INPUT" in
71
- *"Searched "*"RuvNet repos"*) ;;
72
- *) exit 0 ;;
73
- esac
53
+ # ── 0-2. DID THE BRAIN ANSWER? ONE predicate, plugin/scripts/grounding-answer.mjs, shared with Stop. ──
54
+ # 4.4.0 adversarial review, BLOCKER B1: matching markers anywhere in tool_response still minted from
55
+ # the MODEL's query, because every lane echoes it back at structuredContent.retrieval.query — the
56
+ # router-decline lane ("NO SEARCH WAS RUN") and source discovery included. The predicate now PARSES
57
+ # the response and reads the ANSWER TEXT only (answer / content[].text), which must BEGIN with the
58
+ # brain's own header; an oversize notice counts only as the host's whole response, pointing at the
59
+ # host's own saved file, written during this call. No node, an unparseable payload, or any other
60
+ # shape ⇒ nothing mints: a stamp that cannot be proven is not minted.
61
+ HERE="$(cd "${BASH_SOURCE[0]%/*}" 2>/dev/null && pwd)" || exit 0 # builtin expansion: no dirname on a bare PATH
62
+ # hook-shim.mjs passes the node that is running it (RUVNET_NODE_BIN); PATH is only the fallback, since a
63
+ # host may hand its hooks a PATH with no node on it.
64
+ NODE_BIN="${RUVNET_NODE_BIN:-}"
65
+ [ -n "$NODE_BIN" ] && [ -x "$NODE_BIN" ] || NODE_BIN="$(command -v node 2>/dev/null)" || NODE_BIN=""
66
+ [ -n "$NODE_BIN" ] && [ -f "$HERE/grounding-answer.mjs" ] || exit 0
67
+ VERDICT="$(printf '%s' "$INPUT" | "$NODE_BIN" "$HERE/grounding-answer.mjs" 2>/dev/null)" || VERDICT=""
68
+ [ "$VERDICT" = "answered" ] || exit 0
74
69
 
75
70
  DIR="$HOME/.cache/ruvnet-brain/grounded"
76
71
  mkdir -p "$DIR" 2>/dev/null || exit 0
@@ -89,9 +84,15 @@ mkdir -p "$DIR" 2>/dev/null || exit 0
89
84
 
90
85
  # ── 3. WHICH terms — from the QUERY only, as it always was. The first raw "query" key in the JSON is
91
86
  # tool_input's; inside tool_response text the quotes are escaped (\"query\") so they cannot match.
87
+ # Read from the tool_input segment only, so a raw "query" key inside an object-shaped response
88
+ # (Codex passes the MCP result as an object, and the result carries retrieval.query) cannot decide
89
+ # which products are stamped. Product terms match case-insensitively (a query says "RuVector").
92
90
  QUERY=""
91
+ TI="${INPUT#*\"tool_input\"}"
92
+ TI="${TI%%\"tool_response\"*}"
93
93
  re='"query"[[:space:]]*:[[:space:]]*"([^"]*)"'
94
- [[ $INPUT =~ $re ]] && QUERY="${BASH_REMATCH[1]}"
94
+ [[ $TI =~ $re ]] && QUERY="${BASH_REMATCH[1]}"
95
+ shopt -s nocasematch 2>/dev/null || true
95
96
  [ -n "$QUERY" ] || exit 0
96
97
 
97
98
  # WRITE_GATE terms — same product-term list as ground-before-write.sh's own copy, mirrored in both
@@ -32,6 +32,7 @@ import os from 'node:os';
32
32
  import path from 'node:path';
33
33
  import { fileURLToPath } from 'node:url';
34
34
  import { RUVNET_GATE1_TERMS } from './ruvnet-gate1-pattern.mjs';
35
+ import { brainAnsweredResponse } from './grounding-answer.mjs';
35
36
  import { extractClaims } from './capability-claim-evidence.mjs';
36
37
  import { currentTurnRecords, strippedProse } from './completion-claim-evidence.mjs';
37
38
 
@@ -134,12 +135,22 @@ const textOf = (c) => (typeof c === 'string' ? c : Array.isArray(c)
134
135
  const NOT_A_SOURCE = /^(?:Edit|Write|MultiEdit|NotebookEdit|TodoWrite|ToolSearch|AskUserQuestion|ExitPlanMode|SendMessage|TaskStop|Monitor|Skill|Artifact.*|EnterWorktree|ExitWorktree)$/;
135
136
  const MCP_MUTATING = /__(?:create|update|delete|remove|publish|deploy|push|write|send|set|merge|upload|patch|put|post|add|rename|move|approve|promote|rollback|cancel|buy|store|edit|import|reset|stop|terminate|spawn|execute)[a-z_-]*$/i;
136
137
 
137
- /** One tool call as evidence: what it looked at (`text`, used for binding) and how much to trust it. */
138
- export function sourceOf(name, input = {}, result = '') {
138
+ /**
139
+ * Did the brain actually answer? The ONE predicate (grounding-answer.mjs), shared with
140
+ * grounding-stamp.sh: the ANSWER TEXT (never the echoed retrieval.query) must begin with the brain's
141
+ * own header — heavy-lane banner, fast-lane card, optionally after the degraded paragraph — or the
142
+ * response is the host's oversize notice for its own saved file. `notAfterMs`: the transcript time of
143
+ * this tool result; a saved file modified later is not the tool's output.
144
+ */
145
+ export function brainAnswered(r, { home = os.homedir(), notAfterMs = null } = {}) {
146
+ return brainAnsweredResponse(r, { home, notAfterMs });
147
+ }
148
+
149
+ export function sourceOf(name, input = {}, result = '', { resultAtMs = null } = {}) {
139
150
  const n = String(name || '');
140
151
  const r = String(result || '');
141
152
  if (/(?:^|__)search_ruvnet$/.test(n)) {
142
- const ok = /Searched \d+ RuvNet repos/.test(r) && !/^\s*(?:search_ruvnet error:|.{0,200}RUVNET BRAIN IS DOWN|.{0,200}RuvNet Brain is disabled)/s.test(r);
153
+ const ok = brainAnswered(r, { notAfterMs: resultAtMs == null ? null : resultAtMs + 5000 });
143
154
  const paths = [...r.matchAll(/^path : (\S+)/gm)].map((m) => m[1]).slice(0, 20);
144
155
  return { kind: 'search_ruvnet', ref: String(input.query || ''), strength: ok ? 'strong' : 'failed', ok, text: [input.query, ...paths].join(' ') };
145
156
  }
@@ -171,7 +182,8 @@ export function turnSources(lines) {
171
182
  const results = new Map();
172
183
  for (const o of recs) {
173
184
  const c = o?.message?.content;
174
- if (Array.isArray(c)) for (const r of c) if (r?.type === 'tool_result' && r.tool_use_id) results.set(r.tool_use_id, textOf(r.content));
185
+ const at = Date.parse(o?.timestamp || '');
186
+ if (Array.isArray(c)) for (const r of c) if (r?.type === 'tool_result' && r.tool_use_id) results.set(r.tool_use_id, { text: textOf(r.content), at: Number.isFinite(at) ? at : null });
175
187
  }
176
188
  const sources = [];
177
189
  for (const o of recs) {
@@ -179,7 +191,8 @@ export function turnSources(lines) {
179
191
  if (o?.type !== 'assistant' || !Array.isArray(c)) continue;
180
192
  for (const u of c) {
181
193
  if (u?.type !== 'tool_use') continue;
182
- const s = sourceOf(u.name, u.input || {}, results.get(u.id) || '');
194
+ const res = results.get(u.id) || { text: '', at: null };
195
+ const s = sourceOf(u.name, u.input || {}, res.text, { resultAtMs: res.at });
183
196
  if (s) sources.push({ ...s, order: sources.length });
184
197
  }
185
198
  }
@@ -189,6 +202,87 @@ export function turnSources(lines) {
189
202
  /** Did a search_ruvnet call this turn return a real grounded answer? (Gate 1, from the transcript.) */
190
203
  export const searchedThisTurn = (sources) => sources.some((s) => s.kind === 'search_ruvnet' && s.ok);
191
204
 
205
+ // ── does the ANSWER assert a rUv capability? (Gate 1's trigger at Stop, 4.4.0) ───────────────────
206
+ // THE FALSE ALARM (measured 2026-09-30 on this repo's own transcripts): Gate 1 arms on any prompt
207
+ // matching RUVNET_GATE1_PATTERN, and in this repository nearly every prompt does (`ruvnet-brain`,
208
+ // "rUv", "swarm"). Of 183 real deliveries of "no successful search_ruvnet call", 172 were on turns
209
+ // whose answer asserted nothing about a rUv tool — release status, git/CI checks, disk and backup
210
+ // answers, memory writes. The directive is "search BEFORE asserting what a RuvNet tool can/cannot do",
211
+ // so the Stop check now demands a search only when the final answer actually asserts that.
212
+ // Deterministic, local (ADR-G004: no model evaluates a gate). Subjects are rUv PRODUCTS (grounded in
213
+ // ruvector ADR-029 and ruflo docs/index.md); `RuvNet Brain` / `ruvnet-brain` is THIS product and is
214
+ // grounded by reading this repo, never by search_ruvnet.
215
+ const RUV_PRODUCT = String.raw`(?:@(?:ruvector|claude-flow|metaharness|ruvnet)\/[a-z0-9-]+|ruflo|ruvector(?:-core)?|rvf(?:-[a-z]+)?|agentdb|agenticow|rulake|ruview|rupixel|ruv-fann|agentic[- ]flow|agentic[- ]qe|synthlang|qudag|safla|metaharness|cve-bench|claude[- ]flow|ruv-swarm|aidefence|aimds|ruvllm|sona|agent[- ]booster|rvlite|ospipe|reasoningbank|flow-nexus|ruvnet(?!\s+brain)|r[uU]v)`;
216
+ // "rUv's/Ruflo's (own) <up to 3 words>" or the bare product, never inside a path, filename or a
217
+ // longer identifier (`ruvnet-brain`, `ruflo-core.mjs`, `kb/ruvector`).
218
+ const RUV_SUBJECT = String.raw`(?<![\w/.@-])${RUV_PRODUCT}(?![\w-]|[./][\w])(?:(?:'|’)s)?(?:\s+own)?(?:\s+(?!(?:can|does|is|are|has|have|the|a|an|and|or|but|to|of|for|with|by|in|on|at|from|if|when|after|before|that|this|these|those|which|who|two|three|it|they|we|you|i)\b)[\w.@/-]+){0,4}?`;
219
+ // Third-person verbs only: a CLI noun phrase (`ruflo memory store`, `ruvector search`) must never read
220
+ // as subject + verb. Base forms ("turn", "ship", "store") count only right after a plural product noun
221
+ // ("rUv's tools turn …") — a lookbehind, so a rejected noun never consumes the real verb after it.
222
+ // Not \b at the end: `support-ticket` is no verb.
223
+ const PLURAL_NOUN = 'tools|packages|crates|plugins|libraries|hooks|agents|skills|servers|workers|daemons|commands|apis|clis|sdks|bindings|routers|gates|controllers';
224
+ const VERBS = 'support|provide|expose|ship|offer|export|implement|include|allow|enable|accept|return|store|require|need|handle|route|record|persist|index|cache|spawn|create|generate|compute|sort|classif|scan|detect|block|prevent|replace|wrap|call|launch|keep|clamp|turn|give|make|run|use|take|let|write';
225
+ // "will not" is a capability claim only with a capability verb: "AgentDB will not open X" is, while
226
+ // "Ruflo will not be touched by this patch" / "will not need a rebuild" report OUR change (4.4.0 nit).
227
+ const WILL_NOT_VERBS = 'run|work|support|open|load|accept|start|install|handle|read|write|store|return|expose|allow|connect|recogni[sz]e|parse|build|compile|sync|scale|persist|import|export';
228
+ // "now" makes a recency claim only with a capability verb ("Ruflo now supports X"); "AgentDB now records
229
+ // every turn" describes behaviour this repo just wired, and is not a claim about the product.
230
+ const NOW_VERBS = /\bnow\s+(?:supports|ships|works|exposes|provides|includes|offers|accepts|allows|enables|runs|requires|has|handles|can(?:not|'t|’t)?|does(?:n't|n’t|\s+not)?)\b/i;
231
+ const CAPABILITY_VERB = String.raw`(?:(?:won't|won’t|will\s+not)\s+(?:${WILL_NOT_VERBS})\b|can(?:not|'t|’t)?\s+\w+|does(?:n't|n’t|\s+not)\s+\w+|do(?:n't|n’t|\s+not)\s+\w+|has(?:n't|n’t|\s+not)\s+\w+|has\s+(?:a|an|no|its|built-in|native)\b|(?:is|are)\s+(?:able|unable|capable|designed|built|meant|backed|limited|not\s+(?:able|available|supported))\b|only\s+(?:supports?|works|runs|accepts)|comes\s+with|works\s+(?:with|by|on|only)|(?:${VERBS})(?:e?s|ies)|(?<=\b(?:${PLURAL_NOUN})\s+)(?:${VERBS}))(?![\w-])`;
232
+ // What rUv's own docs/research/source SAY is a capability claim too, in any tense.
233
+ const DOC_VERB = String.raw`(?:says?|said|found|finds|shows?|showed|marks?|marked|documents?|documented|recommends?|prescribes?|states?|reports?|measured|took|warns?)\b`;
234
+ const DOC_NOUN = String.raw`(?:research|benchmark|readme|docs?|documentation|release\s+notes|notes|code|source|adr|guidance|skill|campaign|issue|gist)`;
235
+ const RUV_CLAIM = new RegExp(`(${RUV_SUBJECT})\\s+(?:\\([^)]{0,80}\\)\\s+)?(?:now\\s+|also\\s+|already\\s+|actually\\s+|really\\s+|still\\s+|always\\s+|never\\s+|only\\s+)?${CAPABILITY_VERB}`, 'gi');
236
+ const RUV_DOC_CLAIM = new RegExp(`(?<![\\w/.@-])${RUV_PRODUCT}(?:(?:'|’)s)?(?:\\s+own)?(?:\\s+[\\w.@/-]+){0,3}?\\s+${DOC_NOUN}\\b[^.;]{0,40}?\\b${DOC_VERB}`, 'gi');
237
+ // "Ruflo is the orchestration layer and it has no hooks API": the pronoun refers back, in the same
238
+ // sentence — only to a product that OPENS the sentence as its subject (a product inside a list or a
239
+ // parenthetical is not what "they" means: "N-API builds (ruvector, rvf, …), so they don't depend…").
240
+ const RUV_COREF_CLAIM = new RegExp(`^(?:the\\s+)?(${RUV_PRODUCT})(?![\\w-]|[./][\\w])(?:(?:'|’)s)?\\s+(?:(?:is|are)\\s+(?:a|an|the)\\b|has\\b|provides?\\b|ships?\\b)[^.;!?()]{0,80}?\\b(?:and|but|so|which|because)\\s+(?:it|they)\\s+(?:also\\s+|now\\s+|still\\s+|only\\s+)?${CAPABILITY_VERB}`, 'gi');
241
+ // Not an assertion: a question, a hedge, a plan or hypothetical. Narrower than HEDGE above on purpose,
242
+ // and narrower still since the 4.4.0 review (S2): "now", "if" and "will not" no longer silence a whole
243
+ // sentence — "Ruflo now supports Windows natively", "RuVector cannot run on Windows, so if you need it
244
+ // use WSL" and "X will not run on Y" are claims. They are judged per claim in isAssertion instead.
245
+ const NOT_RUV_ASSERTION = /\?|\b(?:might|may|maybe|perhaps|probably|possibly|potentially|likely|unlikely|apparently|seems?|i\s+think|i\s+believe|i\s+suspect|not\s+sure|unsure|unverified|unconfirmed|not\s+verified|would|could|should|i(?:'ll|’ll)|we(?:'ll|’ll)|will(?!\s+not\b)|plan(?:ned)?\s+to|going\s+to|once)\b/i;
246
+ const FIRST_PERSON = /\b(?:I|we|me|my|our|us)\b/;
247
+
248
+ function isAssertion(s, m) {
249
+ // "what rUv already ships" / "whether ruflo supports X" is a noun clause, not an assertion.
250
+ if (/\b(?:what|whatever|whether)\b[^.,;:!?]{0,40}$/i.test(s.slice(0, m.index))) return false;
251
+ // A condition in the claim's OWN clause makes it hypothetical ("Ruv can't use Brain if every update
252
+ // breaks"); a condition in a later clause does not ("cannot run on Windows, so if you need it…").
253
+ const start = Math.max(0, ...[...s.slice(0, m.index).matchAll(/[,;:—–]|\b(?:so|but)\b/g)].map((x) => x.index + x[0].length));
254
+ const after = s.slice(m.index + m[0].length);
255
+ const stop = after.search(/[,;:—–]|\b(?:so|but)\b/);
256
+ if (/\b(?:if|unless)\b/i.test(s.slice(start, m.index + m[0].length + (stop < 0 ? after.length : stop)))) return false;
257
+ // "now" is a recency claim about the product — unless it reports OUR change ("AgentDB now records
258
+ // every turn … with no reliance on me remembering", a measured false alarm).
259
+ if (/\bnow\b/i.test(s) && FIRST_PERSON.test(s)) return false;
260
+ if (/\bnow\s+\w/i.test(m[0]) && !NOW_VERBS.test(m[0])) return false;
261
+ // "returns 10 results", "takes about 3 s": a measurement this turn, not a capability.
262
+ return !/^\s*(?:about\s+|around\s+|only\s+|~|≈)?\d/.test(s.slice(m.index + m[0].length));
263
+ }
264
+
265
+ /** The final answer's sentences (and table cells) that assert what a rUv product does. Never throws. */
266
+ export function ruvCapabilityClaims(rawMessage) {
267
+ const text = String(rawMessage || '')
268
+ .replace(/```[\s\S]*?```/g, '\n') // command output and code are not prose claims
269
+ .replace(/^\s*>.*$/gm, ' ') // quoted material
270
+ .replace(/"[^"\n]{0,300}"|“[^”\n]{0,300}”/g, '\n') // quoted speech: someone else's words (a break: it may carry the full stop)
271
+ .replace(/`([^`\n]{1,80})`/g, '$1') // inline code keeps its identifier
272
+ .replace(/^\s*#{1,6}\s.*$/gm, ' ') // headings name a topic
273
+ .replace(/\*\*|__/g, '')
274
+ .replace(/\|/g, '\n'); // table cells judged one by one
275
+ const out = [];
276
+ for (const raw of text.split(/(?<=[.!?;])\s+|\n+|\s+[—–]\s+/)) {
277
+ const s = raw.replace(/^[\s\-*•#>\d.)]+/, '').trim();
278
+ if (!s || s.length > 400 || NOT_RUV_ASSERTION.test(s)) continue;
279
+ const m = [...s.matchAll(RUV_CLAIM), ...s.matchAll(RUV_DOC_CLAIM), ...s.matchAll(RUV_COREF_CLAIM)].find((x) => isAssertion(s, x));
280
+ if (m) out.push({ text: s, match: m[0], subject: (m[1] || m[0]).trim().split(/\s+/)[0].replace(/(?:'|’)s$/, '').toLowerCase() });
281
+ if (out.length >= 8) break;
282
+ }
283
+ return out;
284
+ }
285
+
192
286
  // ── Stop-time audit ────────────────────────────────────────────────────────────────────────────────
193
287
  const HEDGE = /\?|\b(?:might|may|maybe|perhaps|probably|possibly|likely|unlikely|apparently|seems?|i\s+think|i\s+believe|i\s+suspect|i\s+(?:could|did)\s*n[o']?t\s+(?:confirm|verify|check)|not\s+sure|unsure|unverified|unconfirmed|not\s+verified|assum(?:e|ed|ing)|if|unless|whether|would|should|once|when)\b/i;
194
288
  const NEGATIVE = /\b(?:no\s+[\w-]+\s+(?:can|could|will)|cannot|can(?:'|’)t|can\s+not|is\s*n(?:'|’)?t\s+(?:possible|supported|able)|not\s+possible|impossible|does\s*n(?:'|’)?t\s+(?:support|allow|expose|provide|exist|let|offer)|does\s+not\s+(?:support|allow|expose|provide|exist|let|offer)|there(?:'|’)?s\s+no\s+(?:way|api|hook|setting|option)|there\s+is\s+no\s+(?:way|api|hook|setting|option)|has\s+no\s+(?:way|api|hook|setting|option)|only\s+(?:supports?|allows?|exposes?))\b/i;
@@ -72,6 +72,15 @@
72
72
  * At most ONE correction per stop episode: both checks compose into one message, and
73
73
  * stop_hook_active silences the continued stop.
74
74
  *
75
+ * 4.4.0 — GATE 1 FIRES ON A CLAIM, NOT ON A TOPIC. Gate 1 arms on any prompt that names the rUv
76
+ * stack, and in this repository that is nearly every prompt. Replayed through this decide() on 183
77
+ * real deliveries of the correction (the owner's sessions, 2026-09-12..10-01): 172 were on answers
78
+ * that asserted nothing about a rUv tool (release status, git/CI, disk/backup, memory writes). Now a
79
+ * search is demanded only when the final answer asserts a rUv capability (ruvCapabilityClaims);
80
+ * measured on a held-out set of 70 real Stop points: false positives 68/68 -> 0/68, and 2 borderline
81
+ * claims (copula, parenthetical) are missed — tests/unit/grounding-turn-false-alarm.test.mjs.
82
+ * A LONG turn (the transcript tail cannot see its start) falls back to the stamps, never to a pass.
83
+ *
75
84
  * FAILS OPEN ALWAYS. Exit 0 unconditionally — a gate that breaks a turn's completion because a
76
85
  * cache directory was unreadable would be disabled within a day.
77
86
  */
@@ -84,7 +93,7 @@ import { markerPathFor, readMarker } from './grounding-turn-mark.mjs';
84
93
  import { readSettledTranscript } from './turn-outcome-capture.mjs';
85
94
  import {
86
95
  architectureShadow, auditAssertions, correctionText, describeSources, loadVocabulary, logShadow,
87
- relayShadow, searchedThisTurn, turnSources,
96
+ relayShadow, ruvCapabilityClaims, searchedThisTurn, turnSources,
88
97
  } from './grounding-turn-evidence.mjs';
89
98
 
90
99
  const HOME = os.homedir();
@@ -145,6 +154,11 @@ export function decide({ hookInput, marker, markerMs, env = process.env, read =
145
154
  if (host === 'claude' && typeof tp === 'string' && /\.jsonl$/i.test(tp)) {
146
155
  try { turn = turnSources(read(tp, { maxMs: 0 })); } catch { turn = null; }
147
156
  }
157
+ // The transcript is read as a bounded TAIL. When the turn's opening prompt is not inside it
158
+ // (a long turn), the tail is a suffix of the turn and cannot prove a search did NOT happen
159
+ // earlier — so it is not evidence either way. Fall back to the stamp evidence (the same path
160
+ // Codex uses), never to a silent pass: `return null` here let every long turn skip the gate.
161
+ if (turn && !turn.boundaryFound) turn = null;
148
162
  const sources = turn ? turn.sources : null;
149
163
  const message = String(hookInput.last_assistant_message || '');
150
164
 
@@ -158,15 +172,20 @@ export function decide({ hookInput, marker, markerMs, env = process.env, read =
158
172
  for (const row of shadow) logShadow({ ...row, at: new Date().toISOString(), session: hookInput.session_id, host });
159
173
  }
160
174
 
161
- const grounded = marker.gate1 === false ? true
162
- : sources ? searchedThisTurn(sources) : wasGroundedSince(markerMs, newestGroundingStampMs());
175
+ // Gate 1 demands a search only when the answer ASSERTS what a rUv product does (the directive's
176
+ // own words). A status report, git/CI check or memory write on a rUv-named repo asserts nothing.
177
+ const ruvClaims = marker.gate1 === false ? [] : ruvCapabilityClaims(message);
178
+ const grounded = !ruvClaims.length
179
+ || (sources ? searchedThisTurn(sources) : wasGroundedSince(markerMs, newestGroundingStampMs()));
163
180
  if (assertion) {
164
- return correctionText(assertion) + (grounded ? '' : '\nThis turn also touched the rUv stack and no successful search_ruvnet call was recorded: call it with the product term(s).');
181
+ return correctionText(assertion) + (grounded ? '' : '\nThis turn also asserted what a rUv tool does and no successful search_ruvnet call was recorded: call it with the product term(s).');
165
182
  }
166
183
  if (grounded) return null;
167
184
  return [
168
- 'This turn touched the RuvNet / rUv stack and ground-ruvnet\'s directive required calling the',
169
- 'search_ruvnet MCP tool before asserting what any RuvNet tool can/cannot do — but no successful',
185
+ `You asserted "${ruvClaims[0].text.slice(0, 200)}" about ${ruvClaims[0].subject}`
186
+ + (ruvClaims.length > 1 ? ` (and ${ruvClaims.length - 1} more rUv capability claim(s))` : '') + ', and',
187
+ 'ground-ruvnet\'s directive requires calling the search_ruvnet MCP tool before asserting what any',
188
+ 'RuvNet tool can/cannot do — but no successful',
170
189
  sources ? `search_ruvnet call is in this turn's transcript (read this turn: ${describeSources(sources).join('; ')}).`
171
190
  : 'search_ruvnet call was recorded this turn (checked against the grounding-stamp evidence).',
172
191
  '',
@@ -344,6 +344,9 @@ function runHook(file, activeVersion = '') {
344
344
  // invocation if the user flips the switch while the hook is mid-run.
345
345
  const env = {
346
346
  ...process.env,
347
+ // The node running THIS shim, for bash bodies that need node (grounding-stamp.sh's verdict) on a
348
+ // machine where `node` is not on the PATH the host hands its hooks. Additive: older bodies ignore it.
349
+ RUVNET_NODE_BIN: process.execPath,
347
350
  ...(activeVersion ? { RUVNET_BRAIN_ACTIVE_VERSION: activeVersion } : {}),
348
351
  ...(BRAIN_OFF && entry.offBehavior === 'partial' ? { RUVNET_BRAIN_OFF: '1' } : {}),
349
352
  };
@@ -32,6 +32,7 @@
32
32
  set -uo pipefail
33
33
 
34
34
  INPUT=""
35
+ _l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
35
36
  # BOUNDED READ (2026-07-27, ADR-055 F20): an unqualified `read` never returns on a stdin that is
36
37
  # opened and never closed — measured across the mesh, 18 of 37 registered commands sat until the
37
38
  # harness killed them. Real Claude Code writes and closes, so this costs no normal turn; that is
@@ -44,6 +44,7 @@ esac
44
44
  # payload's delivery time; the size cap is ~30x the largest real payload. The trailing `[ -n "$_l" ]`
45
45
  # keeps the final unterminated line, which is what the original `||` clause was for.
46
46
  INPUT=""
47
+ _l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
47
48
  while IFS= read -r -t 2 _l; do
48
49
  INPUT+="$_l"
49
50
  [ ${#INPUT} -ge 65536 ] && break
@@ -35,7 +35,19 @@ function git(cwd, args) {
35
35
  }
36
36
 
37
37
  /**
38
- * The source identity, with each digest defined EXACTLY:
38
+ * The Brain's OWN state that may sit inside the customer's project: the AgentDB store and its sidecars
39
+ * (`.swarm/` — memory.db, -wal/-shm, the outbox and queue files) and what ruflo leaves in a cwd
40
+ * (`.claude-flow/`, `ruvector.db`). It is not the customer's source. Every capture writes `.swarm/`, so
41
+ * counting it made every later boundary look like a changed tree: the no-op path was never taken and
42
+ * memory.db grew at every boundary. Excluded by pathspec, so it does not depend on the user's gitignore
43
+ * (it only looked fine on machines whose global gitignore lists `.swarm/`).
44
+ */
45
+ export const BRAIN_STATE_PATHSPEC_EXCLUDES = Object.freeze([':(exclude).swarm', ':(exclude).claude-flow', ':(exclude)ruvector.db']);
46
+ const SOURCE_PATHSPEC = ['--', '.', ...BRAIN_STATE_PATHSPEC_EXCLUDES];
47
+
48
+ /**
49
+ * The source identity, with each digest defined EXACTLY (each one EXCLUDING the Brain's own state,
50
+ * BRAIN_STATE_PATHSPEC_EXCLUDES above):
39
51
  * trackedDigest sha256 of `git ls-files -s` (mode + blob oid + stage + path for every tracked file)
40
52
  * untrackedDigest sha256 of one `<content-sha256> <path>` line per untracked, non-ignored file —
41
53
  * the NAMES alone would call two different working trees identical
@@ -63,9 +75,9 @@ export function readSourceIdentity({ checkoutRoot, kind = 'git' } = {}) {
63
75
 
64
76
  const headBefore = git(checkoutRoot, ['rev-parse', 'HEAD'])?.trim() || 'unborn';
65
77
  const branch = git(checkoutRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])?.trim() || 'detached';
66
- const tracked = git(checkoutRoot, ['ls-files', '-s']);
67
- const untrackedList = git(checkoutRoot, ['ls-files', '--others', '--exclude-standard']);
68
- const diff = git(checkoutRoot, ['diff', 'HEAD']);
78
+ const tracked = git(checkoutRoot, ['ls-files', '-s', ...SOURCE_PATHSPEC]);
79
+ const untrackedList = git(checkoutRoot, ['ls-files', '--others', '--exclude-standard', ...SOURCE_PATHSPEC]);
80
+ const diff = git(checkoutRoot, ['diff', 'HEAD', ...SOURCE_PATHSPEC]);
69
81
  const headAfter = git(checkoutRoot, ['rev-parse', 'HEAD'])?.trim() || 'unborn';
70
82
 
71
83
  const untrackedLines = String(untrackedList ?? '').split('\n').filter(Boolean).map((relative) => {
@@ -1,4 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
2
5
  import path from 'node:path';
3
6
  import { ProgressionOutbox } from './project-progression-outbox.mjs';
4
7
  import {
@@ -132,6 +135,51 @@ function sortRejected(rows) {
132
135
  || left.reasons.join('|').localeCompare(right.reasons.join('|')));
133
136
  }
134
137
 
138
+ /**
139
+ * The working directory ruflo runs in. ruflo writes into its cwd on every invocation even when --path
140
+ * names the store (measured 2026-10-01, ruflo 3.49.0): `.claude/`, `.claude-flow/`, `ruvector.db`, and
141
+ * `<cwd>/.swarm/` holding hnsw.metadata.json — the stored snapshot CONTENT — which it also LOADS when it
142
+ * finds one there (ruflo/v3/@claude-flow/cli/src/memory/memory-initializer.ts: getMemoryRoot() = cwd).
143
+ * So the cwd must be:
144
+ * - never the project tree (it changed the customer's working tree and broke no-op capture detection),
145
+ * never inside `.swarm` (nested `.swarm/.swarm`);
146
+ * - PER PROJECT (one shared dir would pool every project's snapshot text and let one project load
147
+ * another's metadata);
148
+ * - private and ours: under the Brain's own home (never a shared /tmp name another user could pre-create
149
+ * or symlink), every directory we create checked to be a real directory, owned by us, mode 0700.
150
+ * Every ruflo call carries --path, so the store it reads and writes is unaffected by the cwd.
151
+ */
152
+ export function rufloScratchRoot(env = process.env) {
153
+ if (env.RUVNET_RUFLO_CWD_ROOT) return path.resolve(env.RUVNET_RUFLO_CWD_ROOT);
154
+ return path.join(env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'ruflo-cwd');
155
+ }
156
+
157
+ /** Create-or-verify one private directory: a real directory (not a link), owned by us, mode 0700. */
158
+ export function ensurePrivateDir(dir) {
159
+ try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (error) { if (error?.code !== 'EEXIST') throw error; }
160
+ const stat = fs.lstatSync(dir);
161
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
162
+ throw new Error(`ruflo scratch directory ${dir} is not a real directory (symlink or file); refusing to use it`);
163
+ }
164
+ if (process.platform !== 'win32') {
165
+ if (stat.uid !== process.getuid()) {
166
+ throw new Error(`ruflo scratch directory ${dir} is owned by uid ${stat.uid}, not ${process.getuid()}; refusing to use it`);
167
+ }
168
+ if ((stat.mode & 0o777) !== 0o700) {
169
+ fs.chmodSync(dir, 0o700);
170
+ if ((fs.lstatSync(dir).mode & 0o777) !== 0o700) throw new Error(`ruflo scratch directory ${dir} could not be made private (0700)`);
171
+ }
172
+ }
173
+ return dir;
174
+ }
175
+
176
+ export function rufloCwdFor(storePath, { root = rufloScratchRoot() } = {}) {
177
+ const projectKey = crypto.createHash('sha256').update(path.resolve(storePath)).digest('hex').slice(0, 32);
178
+ fs.mkdirSync(path.dirname(root), { recursive: true });
179
+ ensurePrivateDir(root);
180
+ return ensurePrivateDir(path.join(root, projectKey));
181
+ }
182
+
135
183
  export class ProjectProgressionStore {
136
184
  constructor({
137
185
  projectDir,
@@ -165,7 +213,7 @@ export class ProjectProgressionStore {
165
213
 
166
214
  run(args) {
167
215
  return this.runner(this.rufloBinary, args, {
168
- cwd: path.dirname(this.resolution.canonicalAgentDbPath),
216
+ cwd: rufloCwdFor(this.resolution.canonicalAgentDbPath),
169
217
  encoding: 'utf8',
170
218
  timeout: 120_000,
171
219
  env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
@@ -41,6 +41,7 @@ INPUT=""
41
41
  # exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
42
42
  # is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
43
43
  # the whole thing back at once and a per-iteration cap never fires.
44
+ _l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
44
45
  while IFS= read -r -t 2 _l; do
45
46
  INPUT+="$_l"
46
47
  [ ${#INPUT} -ge 65536 ] && break
@@ -44,6 +44,7 @@ INPUT=""
44
44
  # exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
45
45
  # is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
46
46
  # the whole thing back at once and a per-iteration cap never fires.
47
+ _line="" # set -u: a read that times out before any byte leaves _line unset ("unbound variable" on stderr)
47
48
  while IFS= read -r -t 2 _line; do
48
49
  INPUT+="$_line"
49
50
  [ ${#INPUT} -ge 65536 ] && break