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.
Files changed (2) hide show
  1. package/bin/fde.js +131 -23
  2. 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 !== 'EEXIST') throw e
231
- if (Date.now() > deadline) {
232
- console.error(`could not lock ${path.basename(targetPath)} - another writer is active; retry`)
233
- process.exit(1)
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
- const waitUntil = Date.now() + 20
236
- while (Date.now() < waitUntil) { /* spin */ }
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
- fs.writeFileSync(tmp, content)
251
- fs.renameSync(tmp, p)
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
- withFileLock(p, () => { fs.appendFileSync(p, text) })
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 fdeDir = path.join(ENGAGEMENTS_ROOT, slug, '.fde')
726
- fs.mkdirSync(fdeDir, { recursive: true })
727
- for (const f of fs.readdirSync(tpl)) {
728
- const src = path.join(tpl, f); const dst = path.join(fdeDir, f)
729
- if (fs.statSync(src).isDirectory()) fs.mkdirSync(dst, { recursive: true })
730
- else if (!fs.existsSync(dst)) fs.copyFileSync(src, dst)
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
- fs.mkdirSync(path.join(fdeDir, 'retrospectives'), { recursive: true })
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
- fs.mkdirSync(path.dirname(outPath), { recursive: true })
1793
- fs.writeFileSync(outPath, html)
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.1",
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",