@ijfw/memory-server 1.3.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 (106) hide show
  1. package/bin/ijfw +27 -0
  2. package/bin/ijfw-dashboard +180 -0
  3. package/bin/ijfw-dispatch-plan +41 -0
  4. package/bin/ijfw-memorize +273 -0
  5. package/bin/ijfw-memory +51 -0
  6. package/fixtures/demo-target.js +28 -0
  7. package/package.json +53 -0
  8. package/src/api-client.js +190 -0
  9. package/src/audit-roster.js +315 -0
  10. package/src/caps.js +37 -0
  11. package/src/cold-scan-runner.mjs +37 -0
  12. package/src/compute/edges.js +155 -0
  13. package/src/compute/extract.js +560 -0
  14. package/src/compute/fts5.js +420 -0
  15. package/src/compute/graph-auto-index.js +191 -0
  16. package/src/compute/graph-lock.js +114 -0
  17. package/src/compute/index.js +18 -0
  18. package/src/compute/migration-runner.js +116 -0
  19. package/src/compute/migrations/001-initial.js +23 -0
  20. package/src/compute/migrations/002-porter-stemming-source.js +139 -0
  21. package/src/compute/migrations/003-tier-semantic.js +69 -0
  22. package/src/compute/migrations/004-kg-tables.js +83 -0
  23. package/src/compute/migrations/005-stale-candidate.js +72 -0
  24. package/src/compute/python-resolver.js +106 -0
  25. package/src/compute/runner-vm.js +185 -0
  26. package/src/compute/runner.js +416 -0
  27. package/src/compute/sandbox-detect.js +122 -0
  28. package/src/compute/sandbox-linux.js +164 -0
  29. package/src/compute/sandbox-macos.js +167 -0
  30. package/src/compute/sandbox-windows.js +63 -0
  31. package/src/compute/schema.sql +118 -0
  32. package/src/compute/staleness.js +239 -0
  33. package/src/compute/synonyms.js +367 -0
  34. package/src/compute/traverse.js +180 -0
  35. package/src/cost/aggregator.js +229 -0
  36. package/src/cost/pricing.js +134 -0
  37. package/src/cost/readers/claude.js +179 -0
  38. package/src/cost/readers/codex.js +131 -0
  39. package/src/cost/readers/gemini.js +111 -0
  40. package/src/cost/savings.js +243 -0
  41. package/src/cross-dispatcher.js +437 -0
  42. package/src/cross-orchestrator-cli.js +1885 -0
  43. package/src/cross-orchestrator.js +598 -0
  44. package/src/cross-project-search.js +114 -0
  45. package/src/dashboard-client.html +1180 -0
  46. package/src/dashboard-server.js +895 -0
  47. package/src/design-companion.js +81 -0
  48. package/src/dispatch/colon-syntax.js +732 -0
  49. package/src/dispatch-planner.js +235 -0
  50. package/src/dream/cooldown.js +105 -0
  51. package/src/dream/runner.mjs +373 -0
  52. package/src/dream/staleness-wiring.js +195 -0
  53. package/src/feedback-detector.js +57 -0
  54. package/src/hero-line.js +115 -0
  55. package/src/importers/claude-mem.js +152 -0
  56. package/src/importers/cli.js +311 -0
  57. package/src/importers/common.js +84 -0
  58. package/src/importers/discover.js +235 -0
  59. package/src/importers/rtk.js +107 -0
  60. package/src/intent-router.js +221 -0
  61. package/src/lib/atomic-io.js +201 -0
  62. package/src/lib/cache.js +33 -0
  63. package/src/lib/npm-view.js +104 -0
  64. package/src/lib/status-card.js +95 -0
  65. package/src/lib/token.js +85 -0
  66. package/src/memory/fts5.js +349 -0
  67. package/src/memory/migration-runner.js +116 -0
  68. package/src/memory/migrations/001-fts5-init.js +26 -0
  69. package/src/memory/migrations/002-tier-semantic.js +60 -0
  70. package/src/memory/migrations/003-stale-candidate.js +60 -0
  71. package/src/memory/reader.js +300 -0
  72. package/src/memory/recall-counter.js +76 -0
  73. package/src/memory/schema.sql +79 -0
  74. package/src/memory/search.js +431 -0
  75. package/src/memory/staleness.js +237 -0
  76. package/src/memory/tier-promotion.js +377 -0
  77. package/src/memory/tokenize.js +63 -0
  78. package/src/project-type-detector.js +866 -0
  79. package/src/prompt-check.js +171 -0
  80. package/src/ralph-allowlist.js +88 -0
  81. package/src/receipts.js +129 -0
  82. package/src/redactor.js +107 -0
  83. package/src/sandbox.js +275 -0
  84. package/src/sanitizer.js +69 -0
  85. package/src/scan-resume.js +167 -0
  86. package/src/schema.js +82 -0
  87. package/src/search-bm25.js +108 -0
  88. package/src/server.js +1414 -0
  89. package/src/swarm-config.js +80 -0
  90. package/src/trident/dispatch.js +211 -0
  91. package/src/trident/lens-health.js +253 -0
  92. package/src/update-apply.js +79 -0
  93. package/src/update-check.js +136 -0
  94. package/src/vectors.js +178 -0
  95. package/templates/design/bento-grid.md +84 -0
  96. package/templates/design/brutalist-luxe.md +82 -0
  97. package/templates/design/cinematic-dark.md +82 -0
  98. package/templates/design/data-dense-dashboard.md +88 -0
  99. package/templates/design/editorial-warm.md +81 -0
  100. package/templates/design/glassmorphic.md +84 -0
  101. package/templates/design/magazine-editorial.md +84 -0
  102. package/templates/design/maximalist-vibrant.md +85 -0
  103. package/templates/design/neo-swiss-tech.md +85 -0
  104. package/templates/design/swiss-minimal.md +80 -0
  105. package/templates/design/terminal-native.md +83 -0
  106. package/templates/design/warm-organic.md +84 -0
package/src/sandbox.js ADDED
@@ -0,0 +1,275 @@
1
+ /**
2
+ * sandbox.js -- ijfw_run sandbox module
3
+ * Runs shell commands, caps output, summarizes for LLM context, spills full
4
+ * output to disk so the context window isn't flooded.
5
+ *
6
+ * Zero external dependencies -- Node.js built-ins only.
7
+ */
8
+
9
+ import { spawn } from 'child_process';
10
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, unlinkSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { homedir } from 'os';
13
+
14
+ const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
15
+ const MAX_LINES = 50_000;
16
+ const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
17
+
18
+ // Strip ANSI escape sequences.
19
+ function stripAnsi(s) {
20
+ return s.replace(/\x1b\[[0-9;]*[mGKHFJK]/g, ''); // eslint-disable-line no-control-regex
21
+ }
22
+
23
+ // Sanitize a label string for use as a filename.
24
+ function sanitizeLabel(label) {
25
+ return String(label).replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 128);
26
+ }
27
+
28
+ /**
29
+ * runCommand(command, opts) → { stdout, exitCode, signal, durationMs, lines, bytes, timedOut }
30
+ *
31
+ * Spawns command in a shell. Merges stdout+stderr in arrival order, capped at
32
+ * MAX_BYTES / MAX_LINES. Kills the process after TIMEOUT_MS.
33
+ */
34
+ export function runCommand(command, opts = {}) {
35
+ return new Promise((resolve) => {
36
+ const cwd = opts.cwd || process.cwd();
37
+ const start = Date.now();
38
+ let timedOut = false;
39
+ let totalBytes = 0;
40
+ let capped = false;
41
+ const chunks = [];
42
+
43
+ const child = spawn(command, [], {
44
+ shell: true,
45
+ cwd,
46
+ env: process.env,
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ });
49
+
50
+ const timer = setTimeout(() => {
51
+ timedOut = true;
52
+ try { child.kill('SIGKILL'); } catch {}
53
+ }, TIMEOUT_MS);
54
+
55
+ function onData(chunk) {
56
+ if (capped) return;
57
+ const remaining = MAX_BYTES - totalBytes;
58
+ if (chunk.length >= remaining) {
59
+ chunks.push(chunk.slice(0, remaining));
60
+ totalBytes += remaining;
61
+ capped = true;
62
+ try { child.kill('SIGKILL'); } catch {}
63
+ } else {
64
+ chunks.push(chunk);
65
+ totalBytes += chunk.length;
66
+ }
67
+ }
68
+
69
+ child.stdout.on('data', onData);
70
+ child.stderr.on('data', onData);
71
+
72
+ child.on('close', (code, signal) => {
73
+ clearTimeout(timer);
74
+ const raw = Buffer.concat(chunks).toString('utf8');
75
+ const lines = raw.split('\n').length;
76
+ const truncLines = Math.min(lines, MAX_LINES);
77
+ // If over MAX_LINES, trim to MAX_LINES worth of lines
78
+ const stdout = lines > MAX_LINES
79
+ ? raw.split('\n').slice(0, MAX_LINES).join('\n')
80
+ : raw;
81
+ const bytes = Buffer.byteLength(stdout, 'utf8');
82
+ resolve({
83
+ stdout,
84
+ exitCode: code,
85
+ signal,
86
+ durationMs: Date.now() - start,
87
+ lines: truncLines,
88
+ bytes,
89
+ timedOut,
90
+ });
91
+ });
92
+
93
+ child.on('error', (err) => {
94
+ clearTimeout(timer);
95
+ resolve({
96
+ stdout: `spawn error: ${err.message}`,
97
+ exitCode: 1,
98
+ signal: null,
99
+ durationMs: Date.now() - start,
100
+ lines: 1,
101
+ bytes: 0,
102
+ timedOut: false,
103
+ });
104
+ });
105
+ });
106
+ }
107
+
108
+ /**
109
+ * detectDomain(output) → 'test' | 'build' | 'grep' | 'log' | 'raw'
110
+ * Heuristics on ANSI-stripped output.
111
+ */
112
+ export function detectDomain(output) {
113
+ const lines = output.split('\n');
114
+ const sample = lines.slice(0, 500); // only inspect leading lines for speed
115
+
116
+ // test: pass/fail keywords + test count or suite name
117
+ if (
118
+ /\b(PASS|FAIL|passed|failed|\u2713|\u2717|ok|not ok)\b/i.test(output) &&
119
+ (/\d+\s+(test|spec|suite|passing|failing|pending)/i.test(output) || /^(PASS|FAIL)\s+/m.test(output))
120
+ ) {
121
+ return 'test';
122
+ }
123
+
124
+ // build: compiler/bundler error patterns
125
+ if (/error\[E\d|ERROR TS\d|SyntaxError|\b(webpack|vite|rollup|tsc|cargo)\b/i.test(output)) {
126
+ return 'build';
127
+ }
128
+
129
+ // grep: majority of lines are file:line: pattern
130
+ const grepLike = sample.filter(l => /^[^:]+:\d+:/.test(l)).length;
131
+ if (sample.length > 5 && grepLike / sample.length > 0.6) {
132
+ return 'grep';
133
+ }
134
+
135
+ // log: majority of lines start with ISO timestamp or [INFO]/[ERROR]/[WARN]
136
+ const logLike = sample.filter(l =>
137
+ /^\d{4}-\d{2}-\d{2}[T ]/.test(l) ||
138
+ /^\[?(INFO|ERROR|WARN|DEBUG)\]?[\s:]/i.test(l)
139
+ ).length;
140
+ if (sample.length > 5 && logLike / sample.length > 0.4) {
141
+ return 'log';
142
+ }
143
+
144
+ return 'raw';
145
+ }
146
+
147
+ /**
148
+ * summarize(output, domain, command, exitCode, durationMs) → string
149
+ */
150
+ export function summarize(output, domain, command, exitCode, durationMs) {
151
+ const lines = output.split('\n');
152
+ const lineCount = lines.length;
153
+ const header = `[ijfw_run] ${command} exit=${exitCode} ${durationMs}ms | ${lineCount} lines`;
154
+
155
+ const parts = [header];
156
+
157
+ if (domain === 'test') {
158
+ // Extract pass/fail counts
159
+ const countMatch = output.match(/(\d+)\s+(?:test|spec|suite)s?\s+(?:passed|passing)/i)
160
+ || output.match(/Tests?:\s+(\d+)\s+passed/i);
161
+ const failMatch = output.match(/(\d+)\s+(?:test|spec|suite)s?\s+(?:failed|failing)/i)
162
+ || output.match(/Tests?:.*?(\d+)\s+failed/i);
163
+ if (countMatch || failMatch) {
164
+ const p = countMatch ? countMatch[1] : '?';
165
+ const f = failMatch ? failMatch[1] : '0';
166
+ parts.push(`Tests: ${p} passed, ${f} failed`);
167
+ }
168
+ // Failing test names
169
+ const failLines = lines.filter(l => /\b(FAIL|\u2717|not ok|\u00d7 )\b/.test(l) || /^\s+●/.test(l));
170
+ if (failLines.length > 0) {
171
+ parts.push('Failures:');
172
+ failLines.slice(0, 10).forEach(l => parts.push(' ' + l.trim()));
173
+ }
174
+ } else if (domain === 'build') {
175
+ const errorLines = lines.filter(l => /\b(error|Error|ERROR)\b/.test(l));
176
+ parts.push(`Build errors (${errorLines.length}):`);
177
+ errorLines.slice(0, 20).forEach(l => parts.push(' ' + l.trim()));
178
+ parts.push(`exit: ${exitCode}`);
179
+ } else if (domain === 'grep') {
180
+ const paths = new Set();
181
+ lines.forEach(l => {
182
+ const m = l.match(/^([^:]+):\d+:/);
183
+ if (m) paths.add(m[1]);
184
+ });
185
+ parts.push(`Matches: ${lineCount} lines | ${paths.size} unique files`);
186
+ Array.from(paths).slice(0, 10).forEach(p => parts.push(' ' + p));
187
+ } else if (domain === 'log') {
188
+ const notable = lines.filter(l => /\b(ERROR|WARN)\b/i.test(l));
189
+ parts.push(`Log: ${lineCount} lines | ${notable.length} ERROR/WARN`);
190
+ notable.slice(0, 20).forEach(l => parts.push(' ' + l.trim()));
191
+ } else {
192
+ // raw fallback
193
+ const first = lines.slice(0, 15);
194
+ const last = lines.slice(-5);
195
+ const omitted = Math.max(0, lineCount - 20);
196
+ first.forEach(l => parts.push(l));
197
+ if (omitted > 0) {
198
+ parts.push(`... (${omitted} lines omitted) ...`);
199
+ last.forEach(l => parts.push(l));
200
+ }
201
+ }
202
+
203
+ // Reliability tail: always append last 10 raw lines (except for raw which already includes them)
204
+ if (domain !== 'raw') {
205
+ parts.push('--- last 10 lines ---');
206
+ lines.slice(-10).forEach(l => parts.push(l));
207
+ }
208
+
209
+ return parts.join('\n');
210
+ }
211
+
212
+ /**
213
+ * writeToSandbox(label, command, output, meta) → sandboxPath (the label)
214
+ */
215
+ export function writeToSandbox(label, command, output, meta) {
216
+ const dir = join(process.env.HOME || homedir(), '.ijfw', 'session-sandbox');
217
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
218
+
219
+ const safeLabel = sanitizeLabel(label);
220
+ const txtPath = join(dir, `${safeLabel}.txt`);
221
+ const jsonPath = join(dir, `${safeLabel}.json`);
222
+
223
+ writeFileSync(txtPath, output, { encoding: 'utf8', mode: 0o600 });
224
+ writeFileSync(jsonPath, JSON.stringify({
225
+ label: safeLabel,
226
+ command,
227
+ timestamp: new Date().toISOString(),
228
+ exitCode: meta.exitCode,
229
+ lines: meta.lines,
230
+ bytes: meta.bytes,
231
+ }), { encoding: 'utf8', mode: 0o600 });
232
+
233
+ return safeLabel;
234
+ }
235
+
236
+ /**
237
+ * purgeSandboxOld(maxAgeMs) -- deletes .txt + .json pairs older than maxAgeMs.
238
+ * Called on every ijfw_run invocation. Fast (stat+unlink only).
239
+ */
240
+ export function purgeSandboxOld(maxAgeMs = 24 * 60 * 60 * 1000) {
241
+ const dir = join(process.env.HOME || homedir(), '.ijfw', 'session-sandbox');
242
+ if (!existsSync(dir)) return;
243
+ const now = Date.now();
244
+ try {
245
+ const files = readdirSync(dir).filter(f => f.endsWith('.json'));
246
+ for (const f of files) {
247
+ const jsonPath = join(dir, f);
248
+ try {
249
+ const st = statSync(jsonPath);
250
+ if (now - st.mtimeMs > maxAgeMs) {
251
+ const base = f.slice(0, -5); // strip .json
252
+ try { unlinkSync(join(dir, `${base}.txt`)); } catch {}
253
+ try { unlinkSync(jsonPath); } catch {}
254
+ }
255
+ } catch { /* skip unreadable entries */ }
256
+ }
257
+ } catch { /* dir unreadable -- skip */ }
258
+ }
259
+
260
+ /**
261
+ * readFromSandbox(label) → string | null
262
+ */
263
+ export function readFromSandbox(label) {
264
+ const safeLabel = sanitizeLabel(label);
265
+ const dir = join(process.env.HOME || homedir(), '.ijfw', 'session-sandbox');
266
+ const txtPath = join(dir, `${safeLabel}.txt`);
267
+ if (!existsSync(txtPath)) return null;
268
+ try {
269
+ return readFileSync(txtPath, 'utf8');
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+
275
+ export { stripAnsi };
@@ -0,0 +1,69 @@
1
+ // --- Content sanitizer (defense against prompt-injection via stored memory) ---
2
+ //
3
+ // Stored content is read back and injected into LLM context on every recall.
4
+ // An attacker who can write to .ijfw/memory/ (rogue dep, malicious teammate
5
+ // commit, compromised plugin) controls future sessions unless we neutralize
6
+ // the structural and semantic markdown features they could weaponize.
7
+ //
8
+ // Extracted from server.js in Phase 6 (audit finding X2) so ijfw-memorize
9
+ // and any other memory writer can apply the same defang before append.
10
+
11
+ export function sanitizeContent(s) {
12
+ if (typeof s !== 'string') return '';
13
+ let out = s;
14
+
15
+ // 1. Strip C0/C1 control characters (incl. NUL) except tab and newline.
16
+ // oxlint-disable-next-line no-control-regex -- intentional: sanitize control chars from stored content
17
+ out = out.replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, '');
18
+
19
+ // 2. Strip Unicode bidi/zero-width/format chars used to hide payloads.
20
+ // U+200B-U+200F, U+202A-U+202E, U+2066-U+2069, U+FEFF
21
+ out = out.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, '');
22
+
23
+ // 3. Defang ANY heading prefix (1+ hashes, optional whitespace) -- entry must
24
+ // never produce a structural ## section that mimics a journal timestamp.
25
+ out = out.replace(/^[ \t]*#+[ \t]+/gm, '> ');
26
+
27
+ // 4. Defang setext-style headings (=== or --- under a line) -- strip the underline.
28
+ out = out.replace(/^[ \t]*[=-]{3,}[ \t]*$/gm, '');
29
+
30
+ // 5. Neutralize fenced code blocks (``` and ~~~) so attacker can't open a fence
31
+ // that swallows surrounding journal structure as "code".
32
+ out = out.replace(/^[ \t]*(```|~~~).*$/gm, '> $1');
33
+
34
+ // 6. Neutralize HTML/XML-style tags that LLMs may parse as instructions
35
+ // (<system>, </assistant>, <instructions>, etc.) -- escape angle brackets.
36
+ out = out.replace(/[<>]/g, ch => (ch === '<' ? '&lt;' : '&gt;'));
37
+
38
+ // 7. Collapse to single line -- multi-line stored content can't fake new
39
+ // journal sections. Newlines become " | " for readability.
40
+ out = out.replace(/\r\n?|\n/g, ' | ');
41
+
42
+ return out;
43
+ }
44
+
45
+ export function sanitizeForSandbox(s) {
46
+ if (typeof s !== 'string') return '';
47
+ let out = s;
48
+
49
+ // 1. Strip ANSI escape codes (colors, cursor movement).
50
+ // oxlint-disable-next-line no-control-regex -- intentional: strip ANSI from sandbox output
51
+ out = out.replace(/\x1b\[[0-9;]*[mGKHF]/g, '');
52
+
53
+ // 2. Strip lines starting with # (headings) -- defang prompt-injection via markdown headings.
54
+ out = out.replace(/^[ \t]*#+[ \t].*/gm, '');
55
+
56
+ // 3. Neutralize fenced code blocks (``` and ~~~).
57
+ out = out.replace(/^[ \t]*(```|~~~).*$/gm, '');
58
+
59
+ // 4. Strip <system>, <prompt>, <assistant> tag patterns (open and close).
60
+ out = out.replace(/<\/?(system|prompt|assistant)[^>]*>/gi, '');
61
+
62
+ // 5. Truncate any single line exceeding 2000 chars (minified JS, base64 blobs).
63
+ out = out
64
+ .split('\n')
65
+ .map(line => (line.length > 2000 ? line.slice(0, 200) + '...[truncated]' : line))
66
+ .join('\n');
67
+
68
+ return out;
69
+ }
@@ -0,0 +1,167 @@
1
+ // IJFW v1.3.0 Alpha -- A3 cross-session checkpoint+resume helper (V3-F2).
2
+ //
3
+ // Persists scan progress at <project>/.ijfw/scan-state.json so a crash
4
+ // mid-walk on a 100k-file repo never re-scans from zero. State shape:
5
+ //
6
+ // {
7
+ // scan_id: string -- opaque id, regenerated per restart
8
+ // started_at: ISO8601 -- when the run began
9
+ // last_path_walked: string -- absolute path of last entry visited
10
+ // files_scanned: number
11
+ // total_estimate: number
12
+ // attempts: number -- bump on each resume; 3 caps it
13
+ // incomplete: bool -- false on clean finish, true on guardrail
14
+ // session_id: string? -- forensic only; not used for resume
15
+ // }
16
+ //
17
+ // shouldResume() rules (per spec):
18
+ // - state.incomplete must be true
19
+ // - state.started_at must be <24h old
20
+ // - state.attempts must be <3 (so a 3rd attempt triggers fresh restart;
21
+ // a 4th is rejected by the cap)
22
+ //
23
+ // Atomic write: tmp + rename. POSIX rename(2) is atomic on the same fs.
24
+
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, copyFileSync } from 'fs';
26
+ import { join } from 'path';
27
+
28
+ const STATE_FILE = 'scan-state.json';
29
+ const LOCK_FILE = 'scan-state.json.lock';
30
+ const STALENESS_MS = 24 * 60 * 60 * 1000; // 24h
31
+ const ATTEMPT_CAP = 3;
32
+ // P3-M6: stale-lock reclamation -- a lock older than this and with a dead
33
+ // owner PID is reclaimable. Mirrors lock.sh's STALE_AGE_SECONDS=60.
34
+ const LOCK_STALE_MS = 60 * 1000;
35
+
36
+ function statePath(projectRoot) {
37
+ return join(String(projectRoot), '.ijfw', STATE_FILE);
38
+ }
39
+
40
+ export function loadScanState(projectRoot) {
41
+ const path = statePath(projectRoot);
42
+ if (!existsSync(path)) return null;
43
+ try {
44
+ const raw = readFileSync(path, 'utf8');
45
+ const parsed = JSON.parse(raw);
46
+ if (parsed && typeof parsed === 'object') return parsed;
47
+ } catch { /* fall through */ }
48
+ return null;
49
+ }
50
+
51
+ export function writeScanState(projectRoot, state) {
52
+ const dir = join(String(projectRoot), '.ijfw');
53
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
54
+ const finalPath = statePath(projectRoot);
55
+ const tmpPath = `${finalPath}.tmp.${process.pid}.${Date.now()}`;
56
+ const safe = {
57
+ scan_id: String(state.scan_id || ''),
58
+ started_at: String(state.started_at || new Date().toISOString()),
59
+ last_path_walked: String(state.last_path_walked || ''),
60
+ files_scanned: Number.isFinite(state.files_scanned) ? state.files_scanned : 0,
61
+ total_estimate: Number.isFinite(state.total_estimate) ? state.total_estimate : 0,
62
+ attempts: Number.isFinite(state.attempts) ? state.attempts : 1,
63
+ incomplete: state.incomplete !== false,
64
+ session_id: state.session_id || null,
65
+ };
66
+ // P3-H3: persist accumulated partial walk state (counters, fingerprint
67
+ // sample, manifest hits, dir hits, ext totals, pattern hits) so resume
68
+ // continues to add to it rather than restarting at zero.
69
+ if (state.partial && typeof state.partial === 'object') {
70
+ safe.partial = state.partial;
71
+ }
72
+ writeFileSync(tmpPath, JSON.stringify(safe, null, 2) + '\n', 'utf8');
73
+ // P3-H5: cross-mount symlink layouts raise EXDEV from rename; copy +
74
+ // unlink keeps the write durable on dotfile setups where .ijfw/ lives
75
+ // on a different filesystem than the temp file.
76
+ try {
77
+ renameSync(tmpPath, finalPath);
78
+ } catch (err) {
79
+ if (!err || err.code !== 'EXDEV') throw err;
80
+ try {
81
+ copyFileSync(tmpPath, finalPath);
82
+ } finally {
83
+ try { unlinkSync(tmpPath); } catch { /* best-effort */ }
84
+ }
85
+ }
86
+ return finalPath;
87
+ }
88
+
89
+ // P3-M6: TOCTOU lock helpers for scan-state.json. Mirrors the noclobber
90
+ // CAS pattern used by lock.sh -- exclusive create, PID + epoch_ms inside,
91
+ // stale-lock reclamation on dead owners or age > LOCK_STALE_MS.
92
+ function lockPath(projectRoot) {
93
+ return join(String(projectRoot), '.ijfw', LOCK_FILE);
94
+ }
95
+
96
+ function isPidAlive(pid) {
97
+ if (!Number.isFinite(pid) || pid <= 0) return false;
98
+ try {
99
+ process.kill(pid, 0);
100
+ return true;
101
+ } catch (err) {
102
+ if (err && err.code === 'EPERM') return true;
103
+ return false;
104
+ }
105
+ }
106
+
107
+ function reclaimIfStale(lp) {
108
+ if (!existsSync(lp)) return;
109
+ let raw;
110
+ try { raw = readFileSync(lp, 'utf8'); } catch { return; }
111
+ const lines = String(raw).split(/\r?\n/);
112
+ const pid = Number(lines[0]);
113
+ const ts = Number(lines[1]);
114
+ const ageOk = Number.isFinite(ts) && (Date.now() - ts) <= LOCK_STALE_MS;
115
+ if (isPidAlive(pid) && ageOk) return;
116
+ try { unlinkSync(lp); } catch { /* best-effort */ }
117
+ }
118
+
119
+ /**
120
+ * acquireScanLock(projectRoot) -> { released } | null
121
+ * Returns null when another live writer holds the lock; the caller MUST
122
+ * skip its write rather than racing. On success, callers must invoke
123
+ * the returned `released` function (or implicitly release on process exit).
124
+ */
125
+ export function acquireScanLock(projectRoot) {
126
+ const dir = join(String(projectRoot), '.ijfw');
127
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
128
+ const lp = lockPath(projectRoot);
129
+ reclaimIfStale(lp);
130
+ const payload = String(process.pid) + '\n' + String(Date.now()) + '\n';
131
+ try {
132
+ writeFileSync(lp, payload, { encoding: 'utf8', flag: 'wx' });
133
+ } catch (err) {
134
+ if (err && err.code === 'EEXIST') return null;
135
+ throw err;
136
+ }
137
+ let released = false;
138
+ return {
139
+ released: () => {
140
+ if (released) return;
141
+ released = true;
142
+ try { unlinkSync(lp); } catch { /* best-effort */ }
143
+ },
144
+ };
145
+ }
146
+
147
+ export function shouldResume(state) {
148
+ if (!state || typeof state !== 'object') return false;
149
+ if (state.incomplete !== true) return false;
150
+ if (!state.started_at || typeof state.started_at !== 'string') return false;
151
+ const startedMs = Date.parse(state.started_at);
152
+ if (!Number.isFinite(startedMs)) return false;
153
+ const ageMs = Date.now() - startedMs;
154
+ if (ageMs > STALENESS_MS) return false;
155
+ const attempts = Number.isFinite(state.attempts) ? state.attempts : 0;
156
+ if (attempts >= ATTEMPT_CAP) return false;
157
+ return true;
158
+ }
159
+
160
+ export function clearScanState(projectRoot) {
161
+ const path = statePath(projectRoot);
162
+ if (existsSync(path)) {
163
+ try { unlinkSync(path); } catch { /* best-effort */ }
164
+ }
165
+ }
166
+
167
+ export const __test = { STALENESS_MS, ATTEMPT_CAP, LOCK_STALE_MS, statePath, lockPath };
package/src/schema.js ADDED
@@ -0,0 +1,82 @@
1
+ // --- Memory schema versioning (audit R1) ---
2
+ // Changes to memory file structure bump this constant. Readers auto-migrate
3
+ // legacy files on next touch (prepend-only, no data loss). Gives us room
4
+ // to evolve the on-disk format in future waves without silent corruption.
5
+
6
+ import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
7
+ import { join, dirname, basename } from 'node:path';
8
+
9
+ export const MEMORY_SCHEMA = 'v1';
10
+ export const SCHEMA_HEADER = `<!-- ijfw-schema: ${MEMORY_SCHEMA} -->`;
11
+ export const LEGACY_HEADER_RE = /^<!-- ijfw[- ]schema[:\s][^>]*-->/;
12
+
13
+ export function ensureSchemaHeader(filepath) {
14
+ if (!existsSync(filepath)) {
15
+ writeFileSync(filepath, SCHEMA_HEADER + '\n\n');
16
+ return 'created';
17
+ }
18
+ const cur = readFileSync(filepath, 'utf-8');
19
+ if (cur.startsWith(SCHEMA_HEADER)) return 'current';
20
+ writeFileSync(filepath, SCHEMA_HEADER + '\n\n' + cur);
21
+ return 'migrated';
22
+ }
23
+
24
+ // W3.2 / ST4 -- corruption recovery.
25
+ // If a memory file is non-zero but fails a structure sanity check,
26
+ // quarantine it to <name>.corrupt.<ts> and seed a fresh file. Returns
27
+ // 'ok' | 'recovered' | 'created'. Called before any read that treats
28
+ // the file as canonical (knowledge.md, handoff.md, project-journal.md).
29
+ //
30
+ // Sanity: file must parse as UTF-8 and either (a) start with an ijfw
31
+ // schema header or (b) be plain markdown with a leading `#` heading.
32
+ // Otherwise we treat it as corrupt and recover. Conservative by design:
33
+ // false positives cost a rename, not data.
34
+ export function recoverIfCorrupt(filepath) {
35
+ if (!existsSync(filepath)) return 'ok';
36
+ let cur;
37
+ try {
38
+ cur = readFileSync(filepath, 'utf-8');
39
+ } catch (e) {
40
+ return quarantine(filepath, cur, `read-failed:${e.code || e.message}`);
41
+ }
42
+ if (!cur) return 'ok'; // empty file is fine
43
+ // Well-formed: schema header OR legacy header OR markdown heading.
44
+ if (cur.startsWith(SCHEMA_HEADER)) return 'ok';
45
+ if (LEGACY_HEADER_RE.test(cur)) return 'ok';
46
+ if (/^\s*#/.test(cur)) return 'ok';
47
+ // Binary-ish? Look for high ratio of non-printable bytes.
48
+ const sample = cur.slice(0, 2048);
49
+ // oxlint-disable-next-line no-control-regex -- intentional: binary corruption detection
50
+ const bad = (sample.match(/[\u0000-\u0008\u000E-\u001F]/g) || []).length;
51
+ if (bad / Math.max(1, sample.length) > 0.02) {
52
+ return quarantine(filepath, cur, 'binary-content');
53
+ }
54
+ // Default: treat as ok -- preserve user's plain-text content even without
55
+ // structure markers. We only recover on true corruption.
56
+ return 'ok';
57
+ }
58
+
59
+ function quarantine(filepath, content, reason) {
60
+ try {
61
+ const ts = Date.now();
62
+ const quarantinePath = join(dirname(filepath), `${basename(filepath)}.corrupt.${ts}`);
63
+ if (content != null) {
64
+ writeFileSync(quarantinePath, `<!-- ijfw-quarantine reason=${reason} at=${new Date(ts).toISOString()} -->\n\n${content}`);
65
+ } else {
66
+ // Y4 -- renameSync can transiently fail on Windows when an indexer or
67
+ // AV holds a short lock on the file. Retry a few times with 50ms spacing.
68
+ let renamed = false;
69
+ for (let i = 0; i < 4 && !renamed; i++) {
70
+ try { renameSync(filepath, quarantinePath); renamed = true; }
71
+ catch {
72
+ const wait = Date.now() + 50;
73
+ while (Date.now() < wait) { /* busy-wait; sync path */ }
74
+ }
75
+ }
76
+ }
77
+ writeFileSync(filepath, SCHEMA_HEADER + '\n\n');
78
+ return 'recovered';
79
+ } catch {
80
+ return 'ok'; // never block a caller over recovery failure
81
+ }
82
+ }