fdeops 3.8.1 → 3.8.2
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/bin/fde.js +131 -23
- package/package.json +1 -1
package/bin/fde.js
CHANGED
|
@@ -217,9 +217,46 @@ function removeExactEntryLine(md, entry) {
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
|
|
220
|
+
// Map Node fs errno codes to one-line field messages - never dump a stack at an FDE.
|
|
221
|
+
function formatFsError(err, action, target) {
|
|
222
|
+
const code = err && err.code
|
|
223
|
+
const where = path.basename(String(target || '')) || String(target || 'path')
|
|
224
|
+
if (code === 'ENOSPC') return `cannot ${action} ${where} - disk full`
|
|
225
|
+
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
|
|
226
|
+
return `cannot ${action} ${where} - permission denied (read-only or locked down)`
|
|
227
|
+
}
|
|
228
|
+
if (code === 'ELOOP') return `cannot ${action} ${where} - symlink loop`
|
|
229
|
+
if (code === 'ENOENT') return `cannot ${action} ${where} - path missing`
|
|
230
|
+
return `cannot ${action} ${where}${code ? ` (${code})` : ''}${err && err.message && !code ? ': ' + err.message : ''}`
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function failFs(err, action, target) {
|
|
234
|
+
console.error(formatFsError(err, action, target))
|
|
235
|
+
process.exit(1)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Refuse writes that would follow a symlink out of the engagement tree.
|
|
239
|
+
// Missing path is fine (new file). Soft mode returns the message instead of exiting
|
|
240
|
+
// (session capture must never crash a hook).
|
|
241
|
+
function refuseSymlinkWrite(p, opts = {}) {
|
|
242
|
+
try {
|
|
243
|
+
if (fs.lstatSync(p).isSymbolicLink()) {
|
|
244
|
+
const msg = `refused: ${path.basename(p)} is a symlink - write would leave the engagement tree. Replace it with a real file.`
|
|
245
|
+
if (opts.soft) return msg
|
|
246
|
+
console.error(msg)
|
|
247
|
+
process.exit(1)
|
|
248
|
+
}
|
|
249
|
+
} catch (e) {
|
|
250
|
+
if (e.code === 'ENOENT') return null
|
|
251
|
+
if (opts.soft) return formatFsError(e, 'check', p)
|
|
252
|
+
failFs(e, 'check', p)
|
|
253
|
+
}
|
|
254
|
+
return null
|
|
255
|
+
}
|
|
256
|
+
|
|
220
257
|
// Exclusive create lock + retry. Two parallel agent sessions (or hook + CLI)
|
|
221
258
|
// appending the same .fde file otherwise interleave/corrupt under load.
|
|
222
|
-
function withFileLock(targetPath, fn) {
|
|
259
|
+
function withFileLock(targetPath, fn, opts = {}) {
|
|
223
260
|
const lockPath = targetPath + '.lock'
|
|
224
261
|
const deadline = Date.now() + 5000
|
|
225
262
|
while (true) {
|
|
@@ -227,14 +264,19 @@ function withFileLock(targetPath, fn) {
|
|
|
227
264
|
try {
|
|
228
265
|
fd = fs.openSync(lockPath, 'wx')
|
|
229
266
|
} catch (e) {
|
|
230
|
-
if (e.code
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
267
|
+
if (e.code === 'EEXIST') {
|
|
268
|
+
if (Date.now() > deadline) {
|
|
269
|
+
const msg = `could not lock ${path.basename(targetPath)} - another writer is active; retry`
|
|
270
|
+
if (opts.soft) throw Object.assign(new Error(msg), { code: 'ELOCKED' })
|
|
271
|
+
console.error(msg)
|
|
272
|
+
process.exit(1)
|
|
273
|
+
}
|
|
274
|
+
const waitUntil = Date.now() + 20
|
|
275
|
+
while (Date.now() < waitUntil) { /* spin */ }
|
|
276
|
+
continue
|
|
234
277
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
continue
|
|
278
|
+
if (opts.soft) throw e
|
|
279
|
+
failFs(e, 'lock', targetPath)
|
|
238
280
|
}
|
|
239
281
|
try {
|
|
240
282
|
return fn()
|
|
@@ -245,14 +287,39 @@ function withFileLock(targetPath, fn) {
|
|
|
245
287
|
}
|
|
246
288
|
}
|
|
247
289
|
|
|
248
|
-
function atomicWriteFile(p, content) {
|
|
290
|
+
function atomicWriteFile(p, content, opts = {}) {
|
|
291
|
+
const blocked = refuseSymlinkWrite(p, opts)
|
|
292
|
+
if (blocked) {
|
|
293
|
+
if (opts.soft) throw Object.assign(new Error(blocked), { code: 'ESYMLINK' })
|
|
294
|
+
return
|
|
295
|
+
}
|
|
249
296
|
const tmp = `${p}.${process.pid}.${Date.now()}.tmp`
|
|
250
|
-
|
|
251
|
-
|
|
297
|
+
try {
|
|
298
|
+
fs.writeFileSync(tmp, content)
|
|
299
|
+
fs.renameSync(tmp, p)
|
|
300
|
+
} catch (e) {
|
|
301
|
+
try { fs.unlinkSync(tmp) } catch (_) {}
|
|
302
|
+
if (opts.soft) throw e
|
|
303
|
+
failFs(e, 'write', p)
|
|
304
|
+
}
|
|
252
305
|
}
|
|
253
306
|
|
|
254
|
-
function lockedAppendFile(p, text) {
|
|
255
|
-
|
|
307
|
+
function lockedAppendFile(p, text, opts = {}) {
|
|
308
|
+
const blocked = refuseSymlinkWrite(p, opts)
|
|
309
|
+
if (blocked) {
|
|
310
|
+
if (opts.soft) throw Object.assign(new Error(blocked), { code: 'ESYMLINK' })
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
withFileLock(p, () => { fs.appendFileSync(p, text) }, opts)
|
|
315
|
+
} catch (e) {
|
|
316
|
+
if (opts.soft) throw e
|
|
317
|
+
failFs(e, 'append', p)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function rmTreeQuiet(dir) {
|
|
322
|
+
try { fs.rmSync(dir, { recursive: true, force: true }) } catch (_) {}
|
|
256
323
|
}
|
|
257
324
|
|
|
258
325
|
// Pull the body under a "## Heading" up to the next "##" (or EOF).
|
|
@@ -722,14 +789,50 @@ function cmdResume(args) {
|
|
|
722
789
|
const tpl = templatesDir()
|
|
723
790
|
if (!tpl) { console.error('templates not found - run from the fdeops clone or reinstall'); process.exit(1) }
|
|
724
791
|
const slug = slugify(name)
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
792
|
+
const engRoot = path.join(ENGAGEMENTS_ROOT, slug)
|
|
793
|
+
const fdeDir = path.join(engRoot, '.fde')
|
|
794
|
+
const existed = fs.existsSync(fdeDir)
|
|
795
|
+
|
|
796
|
+
const fillTemplates = (destFde) => {
|
|
797
|
+
for (const f of fs.readdirSync(tpl)) {
|
|
798
|
+
const src = path.join(tpl, f); const dst = path.join(destFde, f)
|
|
799
|
+
if (fs.statSync(src).isDirectory()) fs.mkdirSync(dst, { recursive: true })
|
|
800
|
+
else if (!fs.existsSync(dst)) fs.copyFileSync(src, dst)
|
|
801
|
+
}
|
|
802
|
+
fs.mkdirSync(path.join(destFde, 'retrospectives'), { recursive: true })
|
|
731
803
|
}
|
|
732
|
-
|
|
804
|
+
|
|
805
|
+
try {
|
|
806
|
+
if (!existed) {
|
|
807
|
+
// Atomic create: build under a staging dir, then rename into place.
|
|
808
|
+
// Disk-full / permission mid-copy must not leave a half-built engagement.
|
|
809
|
+
fs.mkdirSync(ENGAGEMENTS_ROOT, { recursive: true })
|
|
810
|
+
const stagingRoot = path.join(ENGAGEMENTS_ROOT, `.init-${slug}-${process.pid}`)
|
|
811
|
+
const stagingEng = path.join(stagingRoot, slug)
|
|
812
|
+
const stagingFde = path.join(stagingEng, '.fde')
|
|
813
|
+
rmTreeQuiet(stagingRoot)
|
|
814
|
+
try {
|
|
815
|
+
fs.mkdirSync(stagingFde, { recursive: true })
|
|
816
|
+
fillTemplates(stagingFde)
|
|
817
|
+
// If a partial engRoot exists from an older failed run, remove it first.
|
|
818
|
+
if (fs.existsSync(engRoot)) rmTreeQuiet(engRoot)
|
|
819
|
+
fs.renameSync(stagingEng, engRoot)
|
|
820
|
+
rmTreeQuiet(stagingRoot)
|
|
821
|
+
} catch (e) {
|
|
822
|
+
rmTreeQuiet(stagingRoot)
|
|
823
|
+
if (fs.existsSync(engRoot) && !fs.existsSync(path.join(engRoot, '.fde', 'context.md'))) {
|
|
824
|
+
rmTreeQuiet(engRoot)
|
|
825
|
+
}
|
|
826
|
+
failFs(e, 'create engagement', engRoot)
|
|
827
|
+
}
|
|
828
|
+
} else {
|
|
829
|
+
// Re-init / rebind: only fill missing template files in place.
|
|
830
|
+
fillTemplates(fdeDir)
|
|
831
|
+
}
|
|
832
|
+
} catch (e) {
|
|
833
|
+
failFs(e, 'init engagement', fdeDir)
|
|
834
|
+
}
|
|
835
|
+
|
|
733
836
|
// bind THIS workspace to the engagement (zero ceremony next time).
|
|
734
837
|
// A workspace binds to exactly ONE engagement: rebinding REPLACES the old
|
|
735
838
|
// line - resolution is first-match-wins, so appending a second line would
|
|
@@ -1007,7 +1110,7 @@ function cmdCapture() {
|
|
|
1007
1110
|
if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
|
|
1008
1111
|
if (changed) block += `- uncommitted: ${changed}\n`
|
|
1009
1112
|
if (updated) block += `- engagement files updated: ${updated}\n`
|
|
1010
|
-
try { lockedAppendFile(path.join(eng, 'context.md'), block) } catch (_) {}
|
|
1113
|
+
try { lockedAppendFile(path.join(eng, 'context.md'), block, { soft: true }) } catch (_) {}
|
|
1011
1114
|
}
|
|
1012
1115
|
|
|
1013
1116
|
function engagementSlugFromPath(eng) {
|
|
@@ -1789,8 +1892,13 @@ ${clientViews}
|
|
|
1789
1892
|
<script>${dashScript()}</script>
|
|
1790
1893
|
</body></html>`
|
|
1791
1894
|
|
|
1792
|
-
|
|
1793
|
-
|
|
1895
|
+
try {
|
|
1896
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
|
1897
|
+
refuseSymlinkWrite(outPath)
|
|
1898
|
+
fs.writeFileSync(outPath, html)
|
|
1899
|
+
} catch (e) {
|
|
1900
|
+
failFs(e, 'write fieldbook', outPath)
|
|
1901
|
+
}
|
|
1794
1902
|
console.log(`fieldbook → ${outPath}`)
|
|
1795
1903
|
console.log(`${engagements.length} engagement(s) rendered · ${counts.RED} red / ${counts.amber} amber / ${counts.green} green · 0 tokens (pure render)`)
|
|
1796
1904
|
if (args.includes('--open')) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fdeops",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.2",
|
|
4
4
|
"description": "Field kit for engineers embedded in client work - a real CLI (recon, memory, portfolio), one @fde skill with field judgment on top, and hooks that make it automatic. Claude Code plugin and any agent that loads skills.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fdeops": "bin/install.js",
|