fdeops 3.8.1 → 3.8.3
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 +249 -38
- package/package.json +1 -1
- package/templates/.fde/context.md +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).
|
|
@@ -349,31 +416,82 @@ function stakeholdersMemoryHealth(eng) {
|
|
|
349
416
|
return { ok: true, warn: '' }
|
|
350
417
|
}
|
|
351
418
|
|
|
419
|
+
// Subject key for a signal-history line - first real name word (same spirit as
|
|
420
|
+
// extractStakeholders). A green about Randy must not clear an amber about Denise.
|
|
421
|
+
function signalSubjectKey(text) {
|
|
422
|
+
const words = String(text).replace(/\([^)]*\)/g, '').split(/\s+/).filter(w => w && !/^(dr|mr|mrs|ms)\.?$/i.test(w))
|
|
423
|
+
const frag = (words[0] || '').replace(/[^a-z0-9]/gi, '').toLowerCase()
|
|
424
|
+
return frag.length >= 3 ? frag : ('anon:' + String(text).slice(0, 48).toLowerCase())
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function parsePhase(ctx) {
|
|
428
|
+
// Template ships "**Phase:** land | discover | ..." - that is UNSET, not land.
|
|
429
|
+
const m = ctx.match(/\*\*Phase:\*\*\s*(.+)/i) || ctx.match(/^phase[:\s*]+(.+)$/im)
|
|
430
|
+
if (!m) return '?'
|
|
431
|
+
const raw = m[1].replace(/\*/g, '').trim()
|
|
432
|
+
if (!raw || /\|/.test(raw) || /^unset$/i.test(raw) || /^[\[(]/.test(raw)) return '?'
|
|
433
|
+
const one = raw.toLowerCase().match(/^(land|discover|plan|build|ship|close)\b/)
|
|
434
|
+
return one ? one[1] : '?'
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function countOpenRisks(eng) {
|
|
438
|
+
const md = readClean(eng, 'risks.md')
|
|
439
|
+
const body = md.split(/^#{1,6}\s+Retired\b/im)[0] || md
|
|
440
|
+
let n = 0
|
|
441
|
+
for (const raw of body.split('\n')) {
|
|
442
|
+
const t = raw.trim()
|
|
443
|
+
if (!t || t.startsWith('<!--') || /^#{1,6}\s/.test(t)) continue
|
|
444
|
+
if (/risk\s*\|\s*status|mitigation/i.test(t) || /^\|?[\s|:-]+$/.test(t)) continue
|
|
445
|
+
if (/^[-*]/.test(t) || (/^\|/.test(t) && t.length > 12)) n++
|
|
446
|
+
}
|
|
447
|
+
return n
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function nextActionLine(ctx) {
|
|
451
|
+
const body = sectionBody(ctx, 'Next action')
|
|
452
|
+
for (const raw of body.split('\n')) {
|
|
453
|
+
const t = raw.trim().replace(/^[-*]\s+/, '')
|
|
454
|
+
if (t) return t.slice(0, 120)
|
|
455
|
+
}
|
|
456
|
+
return ''
|
|
457
|
+
}
|
|
458
|
+
|
|
352
459
|
function computeSignals(eng) {
|
|
353
460
|
// readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
|
|
354
461
|
// to the terminal and the rendered HTML - a <private> risk must never surface.
|
|
355
462
|
const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md'); const risks = readClean(eng, 'risks.md')
|
|
356
463
|
// Prefer structured tokens from stakeholders + CLI ledger (ledger survives wipes)
|
|
357
464
|
const signalText = stake + '\n' + readClean(eng, SIGNAL_LEDGER)
|
|
358
|
-
const phase = (ctx
|
|
359
|
-
|
|
465
|
+
const phase = parsePhase(ctx)
|
|
466
|
+
// Latest signal PER stakeholder, then worst-of those actives.
|
|
467
|
+
// Global "latest wins" let a green from person B hide a sponsor crisis on A.
|
|
468
|
+
const byPerson = new Map()
|
|
360
469
|
for (const l of signalText.split('\n')) {
|
|
361
470
|
const sm = l.match(/\[signal:(red|amber|green)\]/i)
|
|
362
471
|
if (!sm) continue
|
|
363
472
|
const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
|
|
364
473
|
const text = l.replace(/^\s*-\s*/, '').replace(/\[signal:(red|amber|green)\]/i, '').replace(/\[\d{4}-\d{2}-\d{2}\]/, '').trim()
|
|
365
|
-
|
|
474
|
+
const key = signalSubjectKey(text)
|
|
475
|
+
const prev = byPerson.get(key)
|
|
476
|
+
if (!prev || date >= prev.date) byPerson.set(key, { date, sig: sm[1].toLowerCase(), text })
|
|
477
|
+
}
|
|
478
|
+
const RANK = { red: 0, amber: 1, green: 2 }
|
|
479
|
+
let worst = null
|
|
480
|
+
for (const s of byPerson.values()) {
|
|
481
|
+
if (!worst || RANK[s.sig] < RANK[worst.sig] || (RANK[s.sig] === RANK[worst.sig] && s.date >= worst.date)) {
|
|
482
|
+
worst = s
|
|
483
|
+
}
|
|
366
484
|
}
|
|
367
485
|
const mem = stakeholdersMemoryHealth(eng)
|
|
368
486
|
let trust, signalAge = null, stale = false, trustReason = ''
|
|
369
|
-
if (!mem.ok && !
|
|
487
|
+
if (!mem.ok && !worst) {
|
|
370
488
|
trust = 'amber'
|
|
371
489
|
trustReason = mem.warn
|
|
372
|
-
} else if (
|
|
373
|
-
trust =
|
|
374
|
-
trustReason = (
|
|
375
|
-
if (
|
|
376
|
-
signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(
|
|
490
|
+
} else if (worst) {
|
|
491
|
+
trust = worst.sig === 'red' ? 'RED' : worst.sig
|
|
492
|
+
trustReason = (worst.text || '').slice(0, 80)
|
|
493
|
+
if (worst.date) {
|
|
494
|
+
signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(worst.date)) / 86400000))
|
|
377
495
|
stale = signalAge > 21
|
|
378
496
|
}
|
|
379
497
|
} else {
|
|
@@ -388,14 +506,33 @@ function computeSignals(eng) {
|
|
|
388
506
|
}) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
389
507
|
// Prefer the trust trigger (signal / memory warn) over a random risk line when triage is not green
|
|
390
508
|
const reason = (trust !== 'green' && (trustReason || mem.warn)) ? (trustReason || mem.warn) : topRisk
|
|
509
|
+
const openRisks = countOpenRisks(eng)
|
|
510
|
+
const nextAction = nextActionLine(ctx)
|
|
391
511
|
let updated = 'never', ageDays = Infinity
|
|
392
512
|
try {
|
|
393
513
|
ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
|
|
394
514
|
updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
|
|
395
515
|
} catch (_) {}
|
|
396
|
-
return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, updated, ageDays }
|
|
516
|
+
return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, openRisks, nextAction, updated, ageDays }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function resumeTriage(eng) {
|
|
520
|
+
const s = computeSignals(eng)
|
|
521
|
+
const label = s.trust + (s.stale ? '?' : '')
|
|
522
|
+
const phase = s.phase === '?' ? 'unset' : s.phase
|
|
523
|
+
const lines = [
|
|
524
|
+
`TRIAGE [${label.padEnd(6)}] phase:${phase} updated:${s.updated} open risks:${s.openRisks}`,
|
|
525
|
+
]
|
|
526
|
+
if (s.reason) {
|
|
527
|
+
const age = s.signalAge != null ? ` (${s.signalAge}d old${s.stale ? ', STALE - reconfirm' : ''})` : ''
|
|
528
|
+
lines.push(` trust: ${s.reason}${age}`)
|
|
529
|
+
}
|
|
530
|
+
if (s.nextAction) lines.push(` next: ${s.nextAction}`)
|
|
531
|
+
else lines.push(' next: (none set - add under ## Next action in context.md)')
|
|
532
|
+
return lines.join('\n')
|
|
397
533
|
}
|
|
398
534
|
|
|
535
|
+
|
|
399
536
|
// ---------- dashboard content extractors (best-effort, read-only) ----------
|
|
400
537
|
// The fieldbook's structured widgets (stakeholders, risks, log, stats) want
|
|
401
538
|
// data shapes that .fde/ markdown does not literally carry - it is written by
|
|
@@ -722,14 +859,50 @@ function cmdResume(args) {
|
|
|
722
859
|
const tpl = templatesDir()
|
|
723
860
|
if (!tpl) { console.error('templates not found - run from the fdeops clone or reinstall'); process.exit(1) }
|
|
724
861
|
const slug = slugify(name)
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
862
|
+
const engRoot = path.join(ENGAGEMENTS_ROOT, slug)
|
|
863
|
+
const fdeDir = path.join(engRoot, '.fde')
|
|
864
|
+
const existed = fs.existsSync(fdeDir)
|
|
865
|
+
|
|
866
|
+
const fillTemplates = (destFde) => {
|
|
867
|
+
for (const f of fs.readdirSync(tpl)) {
|
|
868
|
+
const src = path.join(tpl, f); const dst = path.join(destFde, f)
|
|
869
|
+
if (fs.statSync(src).isDirectory()) fs.mkdirSync(dst, { recursive: true })
|
|
870
|
+
else if (!fs.existsSync(dst)) fs.copyFileSync(src, dst)
|
|
871
|
+
}
|
|
872
|
+
fs.mkdirSync(path.join(destFde, 'retrospectives'), { recursive: true })
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
try {
|
|
876
|
+
if (!existed) {
|
|
877
|
+
// Atomic create: build under a staging dir, then rename into place.
|
|
878
|
+
// Disk-full / permission mid-copy must not leave a half-built engagement.
|
|
879
|
+
fs.mkdirSync(ENGAGEMENTS_ROOT, { recursive: true })
|
|
880
|
+
const stagingRoot = path.join(ENGAGEMENTS_ROOT, `.init-${slug}-${process.pid}`)
|
|
881
|
+
const stagingEng = path.join(stagingRoot, slug)
|
|
882
|
+
const stagingFde = path.join(stagingEng, '.fde')
|
|
883
|
+
rmTreeQuiet(stagingRoot)
|
|
884
|
+
try {
|
|
885
|
+
fs.mkdirSync(stagingFde, { recursive: true })
|
|
886
|
+
fillTemplates(stagingFde)
|
|
887
|
+
// If a partial engRoot exists from an older failed run, remove it first.
|
|
888
|
+
if (fs.existsSync(engRoot)) rmTreeQuiet(engRoot)
|
|
889
|
+
fs.renameSync(stagingEng, engRoot)
|
|
890
|
+
rmTreeQuiet(stagingRoot)
|
|
891
|
+
} catch (e) {
|
|
892
|
+
rmTreeQuiet(stagingRoot)
|
|
893
|
+
if (fs.existsSync(engRoot) && !fs.existsSync(path.join(engRoot, '.fde', 'context.md'))) {
|
|
894
|
+
rmTreeQuiet(engRoot)
|
|
895
|
+
}
|
|
896
|
+
failFs(e, 'create engagement', engRoot)
|
|
897
|
+
}
|
|
898
|
+
} else {
|
|
899
|
+
// Re-init / rebind: only fill missing template files in place.
|
|
900
|
+
fillTemplates(fdeDir)
|
|
901
|
+
}
|
|
902
|
+
} catch (e) {
|
|
903
|
+
failFs(e, 'init engagement', fdeDir)
|
|
731
904
|
}
|
|
732
|
-
|
|
905
|
+
|
|
733
906
|
// bind THIS workspace to the engagement (zero ceremony next time).
|
|
734
907
|
// A workspace binds to exactly ONE engagement: rebinding REPLACES the old
|
|
735
908
|
// line - resolution is first-match-wins, so appending a second line would
|
|
@@ -764,7 +937,9 @@ function cmdResume(args) {
|
|
|
764
937
|
console.log(`NO ENGAGEMENT for this workspace.\nexisting: ${list}\ncreate + bind one: fde resume --init <client-name>`)
|
|
765
938
|
process.exit(2)
|
|
766
939
|
}
|
|
767
|
-
|
|
940
|
+
// Monday-morning command: triage first (trust / phase / risks / next), then memory.
|
|
941
|
+
console.log(resumeTriage(eng))
|
|
942
|
+
console.log(`\nENGAGEMENT: ${eng}\n`)
|
|
768
943
|
// readClean, not fs.readFileSync: this output is what an agent loads as
|
|
769
944
|
// context, so it goes through the same <private> redaction as the dashboard.
|
|
770
945
|
const ctx = readClean(eng, 'context.md')
|
|
@@ -842,10 +1017,23 @@ function cmdLog(args) {
|
|
|
842
1017
|
args.splice(sigIdx, 2)
|
|
843
1018
|
}
|
|
844
1019
|
const type = args[0]; const text = args.slice(1).join(' ')
|
|
845
|
-
if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green] [--force]\n fde log --undo'); process.exit(1) }
|
|
846
|
-
if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
|
|
847
1020
|
const eng = resolveEngagement({ forWrite: true })
|
|
848
1021
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1022
|
+
|
|
1023
|
+
// fde log phase <land|discover|plan|build|ship|close> - advances portfolio phase
|
|
1024
|
+
if (type === 'phase') {
|
|
1025
|
+
const phase = (text || '').toLowerCase().trim()
|
|
1026
|
+
if (!['land', 'discover', 'plan', 'build', 'ship', 'close'].includes(phase)) {
|
|
1027
|
+
console.error('usage: fde log phase <land|discover|plan|build|ship|close>')
|
|
1028
|
+
process.exit(1)
|
|
1029
|
+
}
|
|
1030
|
+
setContextPhase(eng, phase)
|
|
1031
|
+
console.log(`phase → ${phase}`)
|
|
1032
|
+
return
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green] [--force]\n fde log phase <land|discover|plan|build|ship|close>\n fde log --undo'); process.exit(1) }
|
|
1036
|
+
if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
|
|
849
1037
|
const hit = findSecretHit(text)
|
|
850
1038
|
if (hit && !force) { refuseSecret('log text', hit); process.exit(1) }
|
|
851
1039
|
if (hit && force) console.error(`warning: logging possible ${hit} (--force)`)
|
|
@@ -855,6 +1043,23 @@ function cmdLog(args) {
|
|
|
855
1043
|
console.log(`logged → ${LOG_FILES[type]}${signal ? ` (signal:${signal})` : ''}`)
|
|
856
1044
|
}
|
|
857
1045
|
|
|
1046
|
+
function setContextPhase(eng, phase) {
|
|
1047
|
+
const p = path.join(eng, 'context.md')
|
|
1048
|
+
let md = readEng(eng, 'context.md')
|
|
1049
|
+
if (!md) md = '# Engagement context\n\n'
|
|
1050
|
+
if (/\*\*Phase:\*\*/i.test(md)) {
|
|
1051
|
+
md = md.replace(/\*\*Phase:\*\*\s*.*/i, `**Phase:** ${phase}`)
|
|
1052
|
+
} else {
|
|
1053
|
+
md = md.replace(/\n*$/, `\n\n**Phase:** ${phase}\n`)
|
|
1054
|
+
}
|
|
1055
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
1056
|
+
if (/\*\*Last updated:\*\*/i.test(md)) {
|
|
1057
|
+
md = md.replace(/\*\*Last updated:\*\*\s*.*/i, `**Last updated:** ${today}`)
|
|
1058
|
+
}
|
|
1059
|
+
withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
|
|
858
1063
|
// Meeting notes → structured memory. Deterministic routing, zero AI: lines that
|
|
859
1064
|
// start with decision:/risk:/delivery:/contact: (case-insensitive) go to their
|
|
860
1065
|
// LOG_FILES target as dated bullets; everything else lands in context.md as one
|
|
@@ -1007,7 +1212,7 @@ function cmdCapture() {
|
|
|
1007
1212
|
if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
|
|
1008
1213
|
if (changed) block += `- uncommitted: ${changed}\n`
|
|
1009
1214
|
if (updated) block += `- engagement files updated: ${updated}\n`
|
|
1010
|
-
try { lockedAppendFile(path.join(eng, 'context.md'), block) } catch (_) {}
|
|
1215
|
+
try { lockedAppendFile(path.join(eng, 'context.md'), block, { soft: true }) } catch (_) {}
|
|
1011
1216
|
}
|
|
1012
1217
|
|
|
1013
1218
|
function engagementSlugFromPath(eng) {
|
|
@@ -1043,10 +1248,10 @@ function cmdStatus(args) {
|
|
|
1043
1248
|
// "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
|
|
1044
1249
|
const label = r.trust + (r.stale ? '?' : '')
|
|
1045
1250
|
const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
|
|
1046
|
-
console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
|
|
1251
|
+
console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${(r.phase === '?' ? 'unset' : r.phase).padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
|
|
1047
1252
|
}
|
|
1048
1253
|
if (!all) console.log('\n(current engagement only - pass --all for the full portfolio)')
|
|
1049
|
-
console.log('\ntrust:
|
|
1254
|
+
console.log('\ntrust: worst active [signal:x] across stakeholders (latest per person) - a green from B cannot clear an amber/red on A; keyword heuristic only when none exists.')
|
|
1050
1255
|
}
|
|
1051
1256
|
|
|
1052
1257
|
// ---------- dashboard (deterministic markdown → one local HTML) ----------
|
|
@@ -1789,8 +1994,13 @@ ${clientViews}
|
|
|
1789
1994
|
<script>${dashScript()}</script>
|
|
1790
1995
|
</body></html>`
|
|
1791
1996
|
|
|
1792
|
-
|
|
1793
|
-
|
|
1997
|
+
try {
|
|
1998
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
|
1999
|
+
refuseSymlinkWrite(outPath)
|
|
2000
|
+
fs.writeFileSync(outPath, html)
|
|
2001
|
+
} catch (e) {
|
|
2002
|
+
failFs(e, 'write fieldbook', outPath)
|
|
2003
|
+
}
|
|
1794
2004
|
console.log(`fieldbook → ${outPath}`)
|
|
1795
2005
|
console.log(`${engagements.length} engagement(s) rendered · ${counts.RED} red / ${counts.amber} amber / ${counts.green} green · 0 tokens (pure render)`)
|
|
1796
2006
|
if (args.includes('--open')) {
|
|
@@ -1812,6 +2022,7 @@ function printUsage() {
|
|
|
1812
2022
|
fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
|
|
1813
2023
|
fde resume --bind show what this workspace is bound to, and what resolves
|
|
1814
2024
|
fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green; --force to allow secret-like text)
|
|
2025
|
+
fde log phase <phase> set engagement phase (land|discover|plan|build|ship|close)
|
|
1815
2026
|
fde log --undo remove the last CLI log/debrief entry from memory
|
|
1816
2027
|
fde debrief [file] meeting notes → memory: decision:/risk:/delivery:/contact: lines route, rest → context.md (stdin if no file; --dry-run; --force)
|
|
1817
2028
|
fde receipts <term> "what did we agree?" with dates
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fdeops",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.3",
|
|
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",
|