@ijfw/memory-server 1.5.4 → 1.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -1
- package/src/brain/dream-pipeline.js +77 -14
- package/src/brain/dump-ingest.js +32 -0
- package/src/brain/entity-collapse.js +2 -2
- package/src/brain/export.js +60 -6
- package/src/brain/extractors/markdown.js +28 -2
- package/src/brain/layout-sentinel.js +19 -14
- package/src/brain/path-guard.js +17 -0
- package/src/brain/wiki-compiler.js +35 -39
- package/src/codex-agents.js +25 -2
- package/src/cross-orchestrator-cli.js +176 -18
- package/src/dashboard-server.js +36 -3
- package/src/dispatch/override.js +18 -2
- package/src/dispatch/signer-cli.js +14 -9
- package/src/dream/stage-runner.js +17 -0
- package/src/dream/state-file.js +15 -1
- package/src/extension-installer.js +91 -2
- package/src/extension-registry.js +15 -4
- package/src/handlers/brain-handler.js +44 -5
- package/src/lib/atomic-io.js +69 -12
- package/src/lib/shasum-verify.js +46 -22
- package/src/lib/ui-review-runner.js +7 -2
- package/src/lib/uispec-drift.js +8 -3
- package/src/lib/uispec-intake.js +5 -2
- package/src/memory/layout-migrations/001-visible-layer.js +71 -7
- package/src/memory/reader.js +111 -58
- package/src/orchestrator/merge-block-aware.js +75 -37
- package/src/orchestrator/post-done-runner.js +6 -1
- package/src/orchestrator/state-sdk.js +242 -14
- package/src/orchestrator/wave-state.js +22 -69
- package/src/recovery/checkpoint.js +30 -6
- package/src/recovery/code-fixer.js +52 -7
- package/src/runtime-mediator.js +2 -2
- package/src/server.js +57 -8
- package/src/swarm/planner.js +46 -1
- package/src/update-apply.js +27 -35
- package/src/update-check.js +6 -2
package/src/memory/reader.js
CHANGED
|
@@ -53,23 +53,47 @@ function walkMd(dir, base, depth = 0) {
|
|
|
53
53
|
return results;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Build a file entry from a path + tier label.
|
|
58
|
+
*
|
|
59
|
+
* V155-026 (v1.5.5): instead of swallowing failures as `null` (which the
|
|
60
|
+
* caller `.filter(Boolean)`-ed away), now returns a tagged shape:
|
|
61
|
+
* - `{ ok: true, entry }` when parsing succeeded
|
|
62
|
+
* - `{ ok: false, error: {...} }` when readFile / stat / parseFrontmatter
|
|
63
|
+
* threw
|
|
64
|
+
* Dashboard `/api/memory/list` + MCP `ijfw_memory_*` consumers can now warn
|
|
65
|
+
* the operator that N files were on disk but M failed to read — silent drop
|
|
66
|
+
* was previously masking corruption + permission issues.
|
|
67
|
+
*/
|
|
57
68
|
function buildEntry(full, rel, tier) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
let st;
|
|
70
|
+
try { st = statSync(full); }
|
|
71
|
+
catch (e) {
|
|
72
|
+
return { ok: false, error: { path: full, relpath: rel, tier, reason: 'stat-fail', message: e?.message || String(e) } };
|
|
73
|
+
}
|
|
74
|
+
let raw;
|
|
75
|
+
try { raw = readFileSync(full, 'utf8'); }
|
|
76
|
+
catch (e) {
|
|
77
|
+
return { ok: false, error: { path: full, relpath: rel, tier, reason: 'unreadable', message: e?.message || String(e) } };
|
|
78
|
+
}
|
|
79
|
+
let fm;
|
|
80
|
+
try { fm = parseFrontmatter(raw); }
|
|
81
|
+
catch (e) {
|
|
82
|
+
return { ok: false, error: { path: full, relpath: rel, tier, reason: 'parse-fail', message: e?.message || String(e) } };
|
|
83
|
+
}
|
|
62
84
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
85
|
+
let title = fm.title;
|
|
86
|
+
if (!title) {
|
|
87
|
+
const hm = raw.match(/^#\s+(.+)/m);
|
|
88
|
+
title = hm ? hm[1].trim() : basename(full, '.md');
|
|
89
|
+
}
|
|
68
90
|
|
|
69
|
-
|
|
70
|
-
|
|
91
|
+
const body = raw.replace(/^---[\s\S]*?---\r?\n/, '').trimStart();
|
|
92
|
+
const preview = body.slice(0, PREVIEW_CHARS).replace(/\s+/g, ' ').trim();
|
|
71
93
|
|
|
72
|
-
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
entry: {
|
|
73
97
|
path: full,
|
|
74
98
|
relpath: rel,
|
|
75
99
|
title,
|
|
@@ -79,10 +103,24 @@ function buildEntry(full, rel, tier) {
|
|
|
79
103
|
last_modified: st.mtimeMs,
|
|
80
104
|
size: st.size,
|
|
81
105
|
tier,
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Helper: partition the `walkMd` output into `{entries[], errors[]}` using the
|
|
112
|
+
* new tagged `buildEntry` shape. Pulled into one place so each Tier reader
|
|
113
|
+
* stays terse.
|
|
114
|
+
*/
|
|
115
|
+
function partitionEntries(items, tier) {
|
|
116
|
+
const entries = [];
|
|
117
|
+
const errors = [];
|
|
118
|
+
for (const { full, rel } of items) {
|
|
119
|
+
const r = buildEntry(full, rel, tier);
|
|
120
|
+
if (r && r.ok) entries.push(r.entry);
|
|
121
|
+
else if (r && r.ok === false) errors.push(r.error);
|
|
85
122
|
}
|
|
123
|
+
return { entries, errors };
|
|
86
124
|
}
|
|
87
125
|
|
|
88
126
|
/** Map a project dir path to its Claude project slug. */
|
|
@@ -117,15 +155,14 @@ function findClaudeSlug(repoRoot) {
|
|
|
117
155
|
|
|
118
156
|
/**
|
|
119
157
|
* Tier 1: Claude auto-memory files for the current project.
|
|
158
|
+
* Returns `{entries, errors}` so the caller can surface unreadable files.
|
|
120
159
|
*/
|
|
121
160
|
function readTier1(repoRoot) {
|
|
122
161
|
const slug = findClaudeSlug(repoRoot);
|
|
123
|
-
if (!slug) return [];
|
|
162
|
+
if (!slug) return { entries: [], errors: [] };
|
|
124
163
|
const memDir = join(CLAUDE_PROJS, slug, 'memory');
|
|
125
|
-
if (!existsSync(memDir)) return [];
|
|
126
|
-
return walkMd(memDir, memDir)
|
|
127
|
-
.map(({ full, rel }) => buildEntry(full, rel, 'Auto-memory'))
|
|
128
|
-
.filter(Boolean);
|
|
164
|
+
if (!existsSync(memDir)) return { entries: [], errors: [] };
|
|
165
|
+
return partitionEntries(walkMd(memDir, memDir), 'Auto-memory');
|
|
129
166
|
}
|
|
130
167
|
|
|
131
168
|
/**
|
|
@@ -139,29 +176,29 @@ function readTier2(repoRoot) {
|
|
|
139
176
|
if (existsSync(globalMem)) dirs.push(globalMem);
|
|
140
177
|
|
|
141
178
|
const files = [];
|
|
179
|
+
const errors = [];
|
|
142
180
|
for (const dir of dirs) {
|
|
143
181
|
if (!existsSync(dir)) continue;
|
|
144
|
-
const entries = walkMd(dir, dir)
|
|
145
|
-
.map(({ full, rel }) => buildEntry(full, rel, 'Project'))
|
|
146
|
-
.filter(Boolean);
|
|
182
|
+
const { entries, errors: errs } = partitionEntries(walkMd(dir, dir), 'Project');
|
|
147
183
|
// Deduplicate by path
|
|
148
184
|
for (const e of entries) {
|
|
149
|
-
if (!files.find(f => f.path === e.path)) files.push(e);
|
|
185
|
+
if (!files.find((f) => f.path === e.path)) files.push(e);
|
|
186
|
+
}
|
|
187
|
+
for (const er of errs) {
|
|
188
|
+
if (!errors.find((x) => x.path === er.path)) errors.push(er);
|
|
150
189
|
}
|
|
151
190
|
}
|
|
152
|
-
return files;
|
|
191
|
+
return { entries: files, errors };
|
|
153
192
|
}
|
|
154
193
|
|
|
155
194
|
/**
|
|
156
195
|
* Tier 3: Session records -- .md files in <repoRoot>/.ijfw/sessions/.
|
|
157
196
|
*/
|
|
158
197
|
function readTier3(repoRoot) {
|
|
159
|
-
if (!repoRoot) return [];
|
|
198
|
+
if (!repoRoot) return { entries: [], errors: [] };
|
|
160
199
|
const sessDir = join(repoRoot, '.ijfw', 'sessions');
|
|
161
|
-
if (!existsSync(sessDir)) return [];
|
|
162
|
-
return walkMd(sessDir, sessDir)
|
|
163
|
-
.map(({ full, rel }) => buildEntry(full, rel, 'Sessions'))
|
|
164
|
-
.filter(Boolean);
|
|
200
|
+
if (!existsSync(sessDir)) return { entries: [], errors: [] };
|
|
201
|
+
return partitionEntries(walkMd(sessDir, sessDir), 'Sessions');
|
|
165
202
|
}
|
|
166
203
|
|
|
167
204
|
/**
|
|
@@ -170,11 +207,11 @@ function readTier3(repoRoot) {
|
|
|
170
207
|
*/
|
|
171
208
|
function readTier4() {
|
|
172
209
|
const obsPath = join(IJFW_DIR, 'observations.jsonl');
|
|
173
|
-
if (!existsSync(obsPath)) return [];
|
|
210
|
+
if (!existsSync(obsPath)) return { entries: [], errors: [] };
|
|
174
211
|
try {
|
|
175
212
|
const lines = readFileSync(obsPath, 'utf8').split('\n').filter(Boolean);
|
|
176
213
|
const total = lines.length;
|
|
177
|
-
if (!total) return [];
|
|
214
|
+
if (!total) return { entries: [], errors: [] };
|
|
178
215
|
const st = statSync(obsPath);
|
|
179
216
|
// Count by platform for recall counts
|
|
180
217
|
const platformCounts = {};
|
|
@@ -188,20 +225,26 @@ function readTier4() {
|
|
|
188
225
|
const platformSummary = Object.entries(platformCounts)
|
|
189
226
|
.map(([p, c]) => `${p}: ${c}`)
|
|
190
227
|
.join(', ');
|
|
191
|
-
return
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
228
|
+
return {
|
|
229
|
+
entries: [{
|
|
230
|
+
path: obsPath,
|
|
231
|
+
relpath: 'observations.jsonl',
|
|
232
|
+
title: `Global observations (${total} events)`,
|
|
233
|
+
description: platformSummary || null,
|
|
234
|
+
type: 'observations',
|
|
235
|
+
preview: `${total} observation events across ${Object.keys(platformCounts).length} platforms. ${platformSummary}`,
|
|
236
|
+
last_modified: st.mtimeMs,
|
|
237
|
+
size: st.size,
|
|
238
|
+
tier: 'Global',
|
|
239
|
+
count: total,
|
|
240
|
+
}],
|
|
241
|
+
errors: [],
|
|
242
|
+
};
|
|
243
|
+
} catch (e) {
|
|
244
|
+
return {
|
|
245
|
+
entries: [],
|
|
246
|
+
errors: [{ path: obsPath, relpath: 'observations.jsonl', tier: 'Global', reason: 'unreadable', message: e?.message || String(e) }],
|
|
247
|
+
};
|
|
205
248
|
}
|
|
206
249
|
}
|
|
207
250
|
|
|
@@ -210,16 +253,23 @@ function readTier4() {
|
|
|
210
253
|
*/
|
|
211
254
|
function readTier5() {
|
|
212
255
|
const handoffPath = join(IJFW_DIR, 'HANDOFF.md');
|
|
213
|
-
if (!existsSync(handoffPath)) return [];
|
|
214
|
-
const
|
|
215
|
-
return
|
|
256
|
+
if (!existsSync(handoffPath)) return { entries: [], errors: [] };
|
|
257
|
+
const r = buildEntry(handoffPath, 'HANDOFF.md', 'Handoff');
|
|
258
|
+
if (r && r.ok) return { entries: [r.entry], errors: [] };
|
|
259
|
+
if (r && r.ok === false) return { entries: [], errors: [r.error] };
|
|
260
|
+
return { entries: [], errors: [] };
|
|
216
261
|
}
|
|
217
262
|
|
|
218
263
|
/**
|
|
219
264
|
* List all memory files across all 5 tiers.
|
|
265
|
+
*
|
|
266
|
+
* V155-026 (v1.5.5): adds `errors` field to the return so dashboard +
|
|
267
|
+
* MCP consumers can surface unreadable / corrupt-frontmatter files
|
|
268
|
+
* instead of silently dropping them via `.filter(Boolean)`.
|
|
269
|
+
*
|
|
220
270
|
* @param {string|null} repoRoot
|
|
221
271
|
* @param {string|null} tierFilter - filter to one tier label (optional)
|
|
222
|
-
* @returns {{ files: Array, total: number, root: string|null, tiers: Object }}
|
|
272
|
+
* @returns {{ files: Array, errors: Array, total: number, root: string|null, tiers: Object }}
|
|
223
273
|
*/
|
|
224
274
|
export function listMemoryFiles(repoRoot, tierFilter = null) {
|
|
225
275
|
const t1 = readTier1(repoRoot);
|
|
@@ -228,20 +278,23 @@ export function listMemoryFiles(repoRoot, tierFilter = null) {
|
|
|
228
278
|
const t4 = readTier4();
|
|
229
279
|
const t5 = readTier5();
|
|
230
280
|
|
|
231
|
-
const all = [...t1, ...t2, ...t3, ...t4, ...t5];
|
|
281
|
+
const all = [...t1.entries, ...t2.entries, ...t3.entries, ...t4.entries, ...t5.entries];
|
|
282
|
+
const allErrors = [...t1.errors, ...t2.errors, ...t3.errors, ...t4.errors, ...t5.errors];
|
|
232
283
|
|
|
233
284
|
// Compute per-tier counts before filtering
|
|
234
285
|
const tiers = {
|
|
235
|
-
'Auto-memory': t1.length,
|
|
236
|
-
'Project': t2.length,
|
|
237
|
-
'Sessions': t3.length,
|
|
238
|
-
'Global': t4.length,
|
|
239
|
-
'Handoff': t5.length,
|
|
286
|
+
'Auto-memory': t1.entries.length,
|
|
287
|
+
'Project': t2.entries.length,
|
|
288
|
+
'Sessions': t3.entries.length,
|
|
289
|
+
'Global': t4.entries.length,
|
|
290
|
+
'Handoff': t5.entries.length,
|
|
240
291
|
};
|
|
241
292
|
|
|
242
293
|
let files = all;
|
|
294
|
+
let errors = allErrors;
|
|
243
295
|
if (tierFilter) {
|
|
244
296
|
files = all.filter(f => f.tier === tierFilter);
|
|
297
|
+
errors = allErrors.filter(e => e.tier === tierFilter);
|
|
245
298
|
}
|
|
246
299
|
|
|
247
300
|
// Sort by most recently modified within each tier grouping
|
|
@@ -250,7 +303,7 @@ export function listMemoryFiles(repoRoot, tierFilter = null) {
|
|
|
250
303
|
// Use the first non-null path as the security root for /api/memory/file
|
|
251
304
|
const root = repoRoot || IJFW_DIR;
|
|
252
305
|
|
|
253
|
-
return { files, total: files.length, root, tiers };
|
|
306
|
+
return { files, errors, total: files.length, root, tiers };
|
|
254
307
|
}
|
|
255
308
|
|
|
256
309
|
/** List all known projects by scanning ~/.claude/projects/. */
|
|
@@ -90,6 +90,8 @@ const DEFAULT_TEMPLATE = pathResolve(
|
|
|
90
90
|
* - 'ERR_BAD_BLOCK' — block name not in RESERVED_BLOCKS (sh: exit 2)
|
|
91
91
|
* - 'ERR_TEMPLATE_MISSING' — target absent + no template available (sh: 3)
|
|
92
92
|
* - 'ERR_BAD_PAYLOAD' — pairs argument malformed (sh: 2)
|
|
93
|
+
* - 'ERR_BACKUP_REQUIRED' — existing target's backup failed; refusing to
|
|
94
|
+
* overwrite without rollback (V155-010)
|
|
93
95
|
*/
|
|
94
96
|
export class MergeBlockAwareError extends Error {
|
|
95
97
|
constructor(code, message) {
|
|
@@ -203,48 +205,64 @@ export function backupDirFor(targetAbsPath, opts = {}) {
|
|
|
203
205
|
* Take a millisecond-timestamped backup of `targetAbsPath` into the canonical
|
|
204
206
|
* backup directory, then prune older entries past `retain` (newest-first).
|
|
205
207
|
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
208
|
+
* V155-010: distinguish 'absent' (no prior file to back up) from 'io-error'
|
|
209
|
+
* (prior file exists but copy/mkdir/stat failed). Caller (`mergeFile`) needs
|
|
210
|
+
* to refuse the merge when an existing target had its backup fail, so the
|
|
211
|
+
* write isn't silently destructive. Returned shapes:
|
|
212
|
+
* - { taken: false, reason: 'absent', pruned: 0 } — no prior file
|
|
213
|
+
* - { taken: false, reason: 'empty', pruned: 0 } — prior file is 0 bytes
|
|
214
|
+
* - { taken: false, reason: 'io-error', error, pruned: 0 } — copy/mkdir/stat threw
|
|
215
|
+
* - { taken: true, path, pruned } — backup succeeded
|
|
216
|
+
*
|
|
217
|
+
* Mirrors the shell script's `cp` + `node -e '…sort by mtime…'` block.
|
|
218
|
+
* Retention errors remain best-effort (the backup itself is what mergeFile
|
|
219
|
+
* cares about).
|
|
209
220
|
*
|
|
210
221
|
* @param {string} targetAbsPath absolute path of the target being merged
|
|
211
222
|
* @param {{homeDir?: string, retain?: number, now?: () => number}} [opts]
|
|
212
|
-
* @returns {{taken: boolean, path?: string, pruned: number}}
|
|
223
|
+
* @returns {{taken: boolean, path?: string, pruned: number, reason?: string, error?: string}}
|
|
213
224
|
*/
|
|
214
225
|
export function rotateBackups(targetAbsPath, opts = {}) {
|
|
215
226
|
const retain = typeof opts.retain === 'number' ? opts.retain : BACKUP_RETAIN;
|
|
216
227
|
const now = typeof opts.now === 'function' ? opts.now : Date.now;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
228
|
+
if (!existsSync(targetAbsPath)) return { taken: false, reason: 'absent', pruned: 0 };
|
|
229
|
+
let st;
|
|
230
|
+
try { st = statSync(targetAbsPath); }
|
|
231
|
+
catch (e) {
|
|
232
|
+
return { taken: false, reason: 'io-error', error: e?.message || String(e), pruned: 0 };
|
|
233
|
+
}
|
|
234
|
+
if (!st || !st.size) return { taken: false, reason: 'empty', pruned: 0 };
|
|
235
|
+
const dir = backupDirFor(targetAbsPath, opts);
|
|
236
|
+
try { mkdirSync(dir, { recursive: true }); }
|
|
237
|
+
catch (e) {
|
|
238
|
+
return { taken: false, reason: 'io-error', error: e?.message || String(e), pruned: 0 };
|
|
239
|
+
}
|
|
240
|
+
const backupName = `AGENTS.md.bak.${String(now())}`;
|
|
241
|
+
const backupPath = join(dir, backupName);
|
|
242
|
+
try { copyFileSync(targetAbsPath, backupPath); }
|
|
243
|
+
catch (e) {
|
|
244
|
+
return { taken: false, reason: 'io-error', error: e?.message || String(e), pruned: 0 };
|
|
245
|
+
}
|
|
226
246
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
242
|
-
}
|
|
247
|
+
// Retention — keep newest `retain`, unlink the rest. mtime-sorted to
|
|
248
|
+
// match the shell node payload's `sort((a,b) => b.mt - a.mt)`. Retention
|
|
249
|
+
// errors stay best-effort because the backup itself already succeeded.
|
|
250
|
+
let pruned = 0;
|
|
251
|
+
try {
|
|
252
|
+
const entries = readdirSync(dir)
|
|
253
|
+
.filter((n) => n.startsWith('AGENTS.md.bak.'))
|
|
254
|
+
.map((n) => {
|
|
255
|
+
try { return { n, mt: statSync(join(dir, n)).mtimeMs }; }
|
|
256
|
+
catch { return null; }
|
|
257
|
+
})
|
|
258
|
+
.filter(Boolean)
|
|
259
|
+
.sort((a, b) => b.mt - a.mt);
|
|
260
|
+
for (const e of entries.slice(retain)) {
|
|
261
|
+
try { unlinkSync(join(dir, e.n)); pruned += 1; } catch { /* best-effort */ }
|
|
262
|
+
}
|
|
263
|
+
} catch { /* best-effort */ }
|
|
243
264
|
|
|
244
|
-
|
|
245
|
-
} catch {
|
|
246
|
-
return { taken: false, pruned: 0 };
|
|
247
|
-
}
|
|
265
|
+
return { taken: true, path: backupPath, pruned };
|
|
248
266
|
}
|
|
249
267
|
|
|
250
268
|
// ---------------------------------------------------------------------------
|
|
@@ -322,12 +340,32 @@ export function mergeFile(targetAbsPath, pairs, opts = {}) {
|
|
|
322
340
|
};
|
|
323
341
|
}
|
|
324
342
|
|
|
325
|
-
// Backup + retention
|
|
326
|
-
//
|
|
343
|
+
// Backup + retention. V155-010: when `opts.backups !== false` AND the target
|
|
344
|
+
// already exists on disk, a backup IO error MUST abort the merge — the only
|
|
345
|
+
// alternative is to overwrite real bytes with no rollback path. Callers that
|
|
346
|
+
// accept the risk (cleanup tooling, fresh installs) opt out via
|
|
347
|
+
// `opts.backups === false`. `reason === 'absent' | 'empty'` are non-errors:
|
|
348
|
+
// the target either didn't exist (seed path) or was a zero-byte placeholder.
|
|
327
349
|
let backup;
|
|
328
350
|
if (opts.backups !== false) {
|
|
329
|
-
|
|
330
|
-
|
|
351
|
+
// TR-004 (v1.5.5 Trident): `opts._rotateBackups` is an internal
|
|
352
|
+
// dependency-injection seam — test-only. The default (`rotateBackups`
|
|
353
|
+
// imported above) is what production callers always hit. Tests inject
|
|
354
|
+
// a stub returning `{taken:false, reason:'io-error', error:'simulated'}`
|
|
355
|
+
// to exercise the BLOCKER refusal path deterministically, since
|
|
356
|
+
// chmod-0o500 doesn't reliably provoke an io-error on root-as-CI
|
|
357
|
+
// runners (FS layer ignores POSIX mode for uid 0).
|
|
358
|
+
const rotator = (typeof opts._rotateBackups === 'function')
|
|
359
|
+
? opts._rotateBackups : rotateBackups;
|
|
360
|
+
const rot = rotator(abs, opts);
|
|
361
|
+
if (rot.taken && rot.path) {
|
|
362
|
+
backup = rot.path;
|
|
363
|
+
} else if (rot.reason === 'io-error') {
|
|
364
|
+
throw new MergeBlockAwareError(
|
|
365
|
+
'ERR_BACKUP_REQUIRED',
|
|
366
|
+
`mergeFile: refusing to write ${abs} — backup failed on existing target (${rot.error || 'unknown'}). Retry, or pass {backups:false} to override.`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
331
369
|
}
|
|
332
370
|
|
|
333
371
|
const res = writeAtomic(abs, next, { mode: 0o644, ensureDir: true });
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
*/
|
|
55
55
|
|
|
56
56
|
import { existsSync } from 'node:fs';
|
|
57
|
+
import { isAbsolute, join } from 'node:path';
|
|
57
58
|
import { execFileSync } from 'node:child_process';
|
|
58
59
|
import { reviewTask } from './review.js';
|
|
59
60
|
import { checkVerificationGate, recordViolation } from './verification-gate.js';
|
|
@@ -110,8 +111,12 @@ function extractClaimedCommits(reportText) {
|
|
|
110
111
|
export function runSelfCheck(reportText, projectRoot) {
|
|
111
112
|
const claimedPaths = extractClaimedPaths(reportText);
|
|
112
113
|
const claimedCommits = extractClaimedCommits(reportText);
|
|
114
|
+
// V155-019: use isAbsolute() so Windows absolute paths (C:\Users\…) aren't
|
|
115
|
+
// misclassified as relative. Previously `p.startsWith('/')` returned false
|
|
116
|
+
// for Windows absolutes → they were joined to projectRoot → existsSync
|
|
117
|
+
// false → real subagent work was flagged as missing files.
|
|
113
118
|
const filesPresent = claimedPaths.filter((p) =>
|
|
114
|
-
existsSync(p
|
|
119
|
+
existsSync(isAbsolute(p) ? p : join(projectRoot, p)),
|
|
115
120
|
);
|
|
116
121
|
let commitsPresent = [];
|
|
117
122
|
try {
|