fdeops 3.8.0 → 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 +278 -35
- package/package.json +1 -1
package/bin/fde.js
CHANGED
|
@@ -170,10 +170,93 @@ function readClean(eng, f) { return stripPrivate(readEng(eng, f)) }
|
|
|
170
170
|
// that drops stakeholders.md "## Signal history" - skill discipline still
|
|
171
171
|
// matters, but CLI-logged trust tokens must not vanish with the markdown.
|
|
172
172
|
const SIGNAL_LEDGER = '.signal-ledger'
|
|
173
|
+
const LAST_WRITE = '.last-write'
|
|
174
|
+
|
|
175
|
+
// Heuristic secret shapes - warn/block CLI writes so a wrong-client paste is not silent.
|
|
176
|
+
// Not a scanner product; high-signal patterns an FDE actually pastes by mistake.
|
|
177
|
+
const SECRET_PATTERNS = [
|
|
178
|
+
{ name: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
179
|
+
{ name: 'GitHub token', re: /\bghp_[A-Za-z0-9]{20,}\b/ },
|
|
180
|
+
{ name: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/ },
|
|
181
|
+
{ name: 'OpenAI-style key', re: /\bsk-[A-Za-z0-9]{20,}\b/ },
|
|
182
|
+
{ name: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
183
|
+
{ name: 'PEM private key', re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
|
|
184
|
+
{ name: 'Bearer token', re: /\bBearer\s+[A-Za-z0-9._\-]{20,}\b/ },
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
function findSecretHit(text) {
|
|
188
|
+
for (const p of SECRET_PATTERNS) {
|
|
189
|
+
if (p.re.test(String(text))) return p.name
|
|
190
|
+
}
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function refuseSecret(kind, hit) {
|
|
195
|
+
console.error(
|
|
196
|
+
`refused: ${kind} looks like a ${hit}.\n` +
|
|
197
|
+
`Do not log credentials into engagement memory. Redact first, or pass --force if this is intentional.\n` +
|
|
198
|
+
`If you already wrote one: fde log --undo`
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function recordLastWrite(eng, file, entry) {
|
|
203
|
+
const p = path.join(eng, LAST_WRITE)
|
|
204
|
+
withFileLock(p, () => {
|
|
205
|
+
atomicWriteFile(p, JSON.stringify({ file, entry, at: new Date().toISOString() }) + '\n')
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function removeExactEntryLine(md, entry) {
|
|
210
|
+
const target = entry.trim()
|
|
211
|
+
const lines = md.split('\n')
|
|
212
|
+
const idx = lines.findIndex(l => l.trim() === target)
|
|
213
|
+
if (idx === -1) return null
|
|
214
|
+
lines.splice(idx, 1)
|
|
215
|
+
while (idx < lines.length && lines[idx] === '') lines.splice(idx, 1)
|
|
216
|
+
return lines.join('\n')
|
|
217
|
+
}
|
|
218
|
+
|
|
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
|
+
}
|
|
173
256
|
|
|
174
257
|
// Exclusive create lock + retry. Two parallel agent sessions (or hook + CLI)
|
|
175
258
|
// appending the same .fde file otherwise interleave/corrupt under load.
|
|
176
|
-
function withFileLock(targetPath, fn) {
|
|
259
|
+
function withFileLock(targetPath, fn, opts = {}) {
|
|
177
260
|
const lockPath = targetPath + '.lock'
|
|
178
261
|
const deadline = Date.now() + 5000
|
|
179
262
|
while (true) {
|
|
@@ -181,14 +264,19 @@ function withFileLock(targetPath, fn) {
|
|
|
181
264
|
try {
|
|
182
265
|
fd = fs.openSync(lockPath, 'wx')
|
|
183
266
|
} catch (e) {
|
|
184
|
-
if (e.code
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
188
277
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
continue
|
|
278
|
+
if (opts.soft) throw e
|
|
279
|
+
failFs(e, 'lock', targetPath)
|
|
192
280
|
}
|
|
193
281
|
try {
|
|
194
282
|
return fn()
|
|
@@ -199,14 +287,39 @@ function withFileLock(targetPath, fn) {
|
|
|
199
287
|
}
|
|
200
288
|
}
|
|
201
289
|
|
|
202
|
-
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
|
+
}
|
|
203
296
|
const tmp = `${p}.${process.pid}.${Date.now()}.tmp`
|
|
204
|
-
|
|
205
|
-
|
|
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
|
+
}
|
|
206
305
|
}
|
|
207
306
|
|
|
208
|
-
function lockedAppendFile(p, text) {
|
|
209
|
-
|
|
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 (_) {}
|
|
210
323
|
}
|
|
211
324
|
|
|
212
325
|
// Pull the body under a "## Heading" up to the next "##" (or EOF).
|
|
@@ -263,6 +376,7 @@ function appendLogEntry(eng, type, entry) {
|
|
|
263
376
|
} else {
|
|
264
377
|
lockedAppendFile(p, `\n${entry}\n`)
|
|
265
378
|
}
|
|
379
|
+
recordLastWrite(eng, LOG_FILES[type], entry)
|
|
266
380
|
}
|
|
267
381
|
|
|
268
382
|
// phase / trust / top risk / freshness - identical heuristic for status + dashboard.
|
|
@@ -272,6 +386,36 @@ function appendLogEntry(eng, type, entry) {
|
|
|
272
386
|
// signal never silently drives triage. The keyword grep survives only as the
|
|
273
387
|
// zero-effort floor when NO token exists anywhere - prose like "escalated to CTO,
|
|
274
388
|
// resolved amicably" must not flip a client amber forever.
|
|
389
|
+
function stakeholdersMemoryHealth(eng) {
|
|
390
|
+
// Hostile handoff: binary / unparseable stakeholders must not read as healthy green.
|
|
391
|
+
let buf
|
|
392
|
+
try { buf = fs.readFileSync(path.join(eng, 'stakeholders.md')) } catch (_) {
|
|
393
|
+
return { ok: true, warn: '' }
|
|
394
|
+
}
|
|
395
|
+
if (buf.includes(0)) {
|
|
396
|
+
return { ok: false, warn: 'memory unreadable - verify (binary data in stakeholders.md)' }
|
|
397
|
+
}
|
|
398
|
+
const md = buf.toString('utf8')
|
|
399
|
+
const ledger = readEng(eng, SIGNAL_LEDGER)
|
|
400
|
+
if (/\[signal:(red|amber|green)\]/i.test(md + '\n' + ledger)) {
|
|
401
|
+
return { ok: true, warn: '' }
|
|
402
|
+
}
|
|
403
|
+
const trustLine = md.match(/\*\*Trust:\*\*\s*([A-Za-z?]+)/i)
|
|
404
|
+
if (trustLine && !/^(red|amber|green)$/i.test(trustLine[1])) {
|
|
405
|
+
return { ok: false, warn: 'memory unreadable - verify (invalid trust value)' }
|
|
406
|
+
}
|
|
407
|
+
const table = parseMdTable(md)
|
|
408
|
+
const meaningful = md.split('\n').filter(l => {
|
|
409
|
+
const t = l.trim()
|
|
410
|
+
return t && !t.startsWith('#') && !t.startsWith('<!--') && !/^\|?\s*:?-{3,}/.test(t)
|
|
411
|
+
}).length
|
|
412
|
+
// Content present but no table and no structured signal → do not invent "green"
|
|
413
|
+
if (meaningful >= 3 && !table) {
|
|
414
|
+
return { ok: false, warn: 'memory unreadable - verify (stakeholders.md unparseable)' }
|
|
415
|
+
}
|
|
416
|
+
return { ok: true, warn: '' }
|
|
417
|
+
}
|
|
418
|
+
|
|
275
419
|
function computeSignals(eng) {
|
|
276
420
|
// readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
|
|
277
421
|
// to the terminal and the rendered HTML - a <private> risk must never surface.
|
|
@@ -284,11 +428,17 @@ function computeSignals(eng) {
|
|
|
284
428
|
const sm = l.match(/\[signal:(red|amber|green)\]/i)
|
|
285
429
|
if (!sm) continue
|
|
286
430
|
const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
|
|
287
|
-
|
|
431
|
+
const text = l.replace(/^\s*-\s*/, '').replace(/\[signal:(red|amber|green)\]/i, '').replace(/\[\d{4}-\d{2}-\d{2}\]/, '').trim()
|
|
432
|
+
if (!latest || date >= latest.date) latest = { date, sig: sm[1].toLowerCase(), text }
|
|
288
433
|
}
|
|
289
|
-
|
|
290
|
-
|
|
434
|
+
const mem = stakeholdersMemoryHealth(eng)
|
|
435
|
+
let trust, signalAge = null, stale = false, trustReason = ''
|
|
436
|
+
if (!mem.ok && !latest) {
|
|
437
|
+
trust = 'amber'
|
|
438
|
+
trustReason = mem.warn
|
|
439
|
+
} else if (latest) {
|
|
291
440
|
trust = latest.sig === 'red' ? 'RED' : latest.sig
|
|
441
|
+
trustReason = (latest.text || '').slice(0, 80)
|
|
292
442
|
if (latest.date) {
|
|
293
443
|
signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(latest.date)) / 86400000))
|
|
294
444
|
stale = signalAge > 21
|
|
@@ -303,12 +453,14 @@ function computeSignals(eng) {
|
|
|
303
453
|
return /^[-|]/.test(t) && t.length > 20 && !/^\|?[-\s|]+$/.test(t) &&
|
|
304
454
|
!/risk\s*\|\s*status|mitigation/i.test(t) && !t.startsWith('<!--')
|
|
305
455
|
}) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
456
|
+
// Prefer the trust trigger (signal / memory warn) over a random risk line when triage is not green
|
|
457
|
+
const reason = (trust !== 'green' && (trustReason || mem.warn)) ? (trustReason || mem.warn) : topRisk
|
|
306
458
|
let updated = 'never', ageDays = Infinity
|
|
307
459
|
try {
|
|
308
460
|
ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
|
|
309
461
|
updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
|
|
310
462
|
} catch (_) {}
|
|
311
|
-
return { phase, trust, signalAge, stale, topRisk, updated, ageDays }
|
|
463
|
+
return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, updated, ageDays }
|
|
312
464
|
}
|
|
313
465
|
|
|
314
466
|
// ---------- dashboard content extractors (best-effort, read-only) ----------
|
|
@@ -637,14 +789,50 @@ function cmdResume(args) {
|
|
|
637
789
|
const tpl = templatesDir()
|
|
638
790
|
if (!tpl) { console.error('templates not found - run from the fdeops clone or reinstall'); process.exit(1) }
|
|
639
791
|
const slug = slugify(name)
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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 })
|
|
646
803
|
}
|
|
647
|
-
|
|
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
|
+
|
|
648
836
|
// bind THIS workspace to the engagement (zero ceremony next time).
|
|
649
837
|
// A workspace binds to exactly ONE engagement: rebinding REPLACES the old
|
|
650
838
|
// line - resolution is first-match-wins, so appending a second line would
|
|
@@ -715,8 +903,39 @@ function resumeView(md) {
|
|
|
715
903
|
return `${head}\n\n_(\u2026 ${hidden} lines of earlier session log hidden \u2014 \`fde resume --full\` or open context.md for the full history)_\n\n${tail}`
|
|
716
904
|
}
|
|
717
905
|
|
|
906
|
+
function cmdLogUndo() {
|
|
907
|
+
const eng = resolveEngagement({ forWrite: true })
|
|
908
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
909
|
+
const metaPath = path.join(eng, LAST_WRITE)
|
|
910
|
+
let meta
|
|
911
|
+
try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) } catch (_) {
|
|
912
|
+
console.error('nothing to undo - no prior fde log/debrief write recorded')
|
|
913
|
+
process.exit(1)
|
|
914
|
+
}
|
|
915
|
+
if (!meta.file || !meta.entry) { console.error('corrupt .last-write - cannot undo'); process.exit(1) }
|
|
916
|
+
const target = path.join(eng, meta.file)
|
|
917
|
+
const before = readEng(eng, meta.file)
|
|
918
|
+
const after = removeExactEntryLine(before, meta.entry)
|
|
919
|
+
if (after == null) {
|
|
920
|
+
console.error(`cannot undo - entry no longer in ${meta.file} (edited by hand?). Remove it manually.`)
|
|
921
|
+
process.exit(1)
|
|
922
|
+
}
|
|
923
|
+
withFileLock(target, () => { atomicWriteFile(target, after.endsWith('\n') ? after : after + '\n') })
|
|
924
|
+
if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) {
|
|
925
|
+
const ledgerPath = path.join(eng, SIGNAL_LEDGER)
|
|
926
|
+
const led = removeExactEntryLine(readEng(eng, SIGNAL_LEDGER), meta.entry)
|
|
927
|
+
if (led != null) withFileLock(ledgerPath, () => { atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n') })
|
|
928
|
+
}
|
|
929
|
+
try { fs.unlinkSync(metaPath) } catch (_) {}
|
|
930
|
+
console.log(`undid last write → ${meta.file}`)
|
|
931
|
+
}
|
|
932
|
+
|
|
718
933
|
function cmdLog(args) {
|
|
719
934
|
args = args.slice()
|
|
935
|
+
if (args[0] === '--undo') { cmdLogUndo(); return }
|
|
936
|
+
let force = false
|
|
937
|
+
const forceIdx = args.indexOf('--force')
|
|
938
|
+
if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
|
|
720
939
|
// --signal red|amber|green (contact only) → structured token computeSignals trusts
|
|
721
940
|
let signal = ''
|
|
722
941
|
const sigIdx = args.indexOf('--signal')
|
|
@@ -726,10 +945,13 @@ function cmdLog(args) {
|
|
|
726
945
|
args.splice(sigIdx, 2)
|
|
727
946
|
}
|
|
728
947
|
const type = args[0]; const text = args.slice(1).join(' ')
|
|
729
|
-
if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green]'); process.exit(1) }
|
|
948
|
+
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) }
|
|
730
949
|
if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
|
|
731
950
|
const eng = resolveEngagement({ forWrite: true })
|
|
732
951
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
952
|
+
const hit = findSecretHit(text)
|
|
953
|
+
if (hit && !force) { refuseSecret('log text', hit); process.exit(1) }
|
|
954
|
+
if (hit && force) console.error(`warning: logging possible ${hit} (--force)`)
|
|
733
955
|
const date = new Date().toISOString().slice(0, 10)
|
|
734
956
|
const entry = `- [${date}] ${signal ? `[signal:${signal}] ` : ''}${text}`
|
|
735
957
|
appendLogEntry(eng, type, entry)
|
|
@@ -751,6 +973,9 @@ function cmdDebrief(args) {
|
|
|
751
973
|
const dryIdx = args.indexOf('--dry-run')
|
|
752
974
|
const dry = dryIdx !== -1
|
|
753
975
|
if (dry) args.splice(dryIdx, 1)
|
|
976
|
+
let force = false
|
|
977
|
+
const forceIdx = args.indexOf('--force')
|
|
978
|
+
if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
|
|
754
979
|
const eng = resolveEngagement({ forWrite: true })
|
|
755
980
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
756
981
|
let input = ''
|
|
@@ -788,14 +1013,26 @@ function cmdDebrief(args) {
|
|
|
788
1013
|
const m = bare.match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
|
|
789
1014
|
if (m) {
|
|
790
1015
|
const type = m[1].toLowerCase()
|
|
791
|
-
const
|
|
1016
|
+
const body = m[2]
|
|
1017
|
+
const hit = findSecretHit(body)
|
|
1018
|
+
if (hit && !force) {
|
|
1019
|
+
console.error(`skipped ${type} line - looks like a ${hit}. Redact it, or re-run with --force.`)
|
|
1020
|
+
continue
|
|
1021
|
+
}
|
|
1022
|
+
const entry = `- [${date}] ${body}`
|
|
792
1023
|
if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`)
|
|
793
1024
|
// appendLogEntry, not a blind append: a contact: line may carry an
|
|
794
1025
|
// inline [signal:x] token (the skill's own convention) and must land
|
|
795
1026
|
// inside "## Signal history" the same way `fde log --signal` does.
|
|
796
1027
|
else appendLogEntry(eng, type, entry)
|
|
797
1028
|
counts[type]++
|
|
798
|
-
} else
|
|
1029
|
+
} else {
|
|
1030
|
+
if (findSecretHit(line) && !force) {
|
|
1031
|
+
console.error('skipped context line - looks like a secret. Redact it, or re-run with --force.')
|
|
1032
|
+
continue
|
|
1033
|
+
}
|
|
1034
|
+
ctxLines.push(line)
|
|
1035
|
+
}
|
|
799
1036
|
}
|
|
800
1037
|
if (ctxLines.length) {
|
|
801
1038
|
const stamp = `${date} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
@@ -873,7 +1110,7 @@ function cmdCapture() {
|
|
|
873
1110
|
if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
|
|
874
1111
|
if (changed) block += `- uncommitted: ${changed}\n`
|
|
875
1112
|
if (updated) block += `- engagement files updated: ${updated}\n`
|
|
876
|
-
try { lockedAppendFile(path.join(eng, 'context.md'), block) } catch (_) {}
|
|
1113
|
+
try { lockedAppendFile(path.join(eng, 'context.md'), block, { soft: true }) } catch (_) {}
|
|
877
1114
|
}
|
|
878
1115
|
|
|
879
1116
|
function engagementSlugFromPath(eng) {
|
|
@@ -890,7 +1127,7 @@ function cmdStatus(args) {
|
|
|
890
1127
|
const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
|
|
891
1128
|
if (!fs.existsSync(eng)) continue
|
|
892
1129
|
const s = computeSignals(eng)
|
|
893
|
-
rows.push({ name: d, phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated,
|
|
1130
|
+
rows.push({ name: d, phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: (s.reason || s.topRisk).slice(0, 60) })
|
|
894
1131
|
}
|
|
895
1132
|
} else {
|
|
896
1133
|
const eng = resolveEngagement()
|
|
@@ -899,7 +1136,7 @@ function cmdStatus(args) {
|
|
|
899
1136
|
process.exit(2)
|
|
900
1137
|
}
|
|
901
1138
|
const s = computeSignals(eng)
|
|
902
|
-
rows.push({ name: engagementSlugFromPath(eng), phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated,
|
|
1139
|
+
rows.push({ name: engagementSlugFromPath(eng), phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: (s.reason || s.topRisk).slice(0, 60) })
|
|
903
1140
|
}
|
|
904
1141
|
if (!rows.length) { console.log('no engagements yet'); return }
|
|
905
1142
|
const order = { RED: 0, amber: 1, green: 2 }
|
|
@@ -909,7 +1146,7 @@ function cmdStatus(args) {
|
|
|
909
1146
|
// "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
|
|
910
1147
|
const label = r.trust + (r.stale ? '?' : '')
|
|
911
1148
|
const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
|
|
912
|
-
console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.
|
|
1149
|
+
console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
|
|
913
1150
|
}
|
|
914
1151
|
if (!all) console.log('\n(current engagement only - pass --all for the full portfolio)')
|
|
915
1152
|
console.log('\ntrust: latest [signal:x] token in stakeholders.md wins (fde log contact --signal, fde debrief); keyword heuristic only when none exists - verify before acting.')
|
|
@@ -1655,8 +1892,13 @@ ${clientViews}
|
|
|
1655
1892
|
<script>${dashScript()}</script>
|
|
1656
1893
|
</body></html>`
|
|
1657
1894
|
|
|
1658
|
-
|
|
1659
|
-
|
|
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
|
+
}
|
|
1660
1902
|
console.log(`fieldbook → ${outPath}`)
|
|
1661
1903
|
console.log(`${engagements.length} engagement(s) rendered · ${counts.RED} red / ${counts.amber} amber / ${counts.green} green · 0 tokens (pure render)`)
|
|
1662
1904
|
if (args.includes('--open')) {
|
|
@@ -1677,8 +1919,9 @@ function printUsage() {
|
|
|
1677
1919
|
fde resume --full load the complete context.md (no bound)
|
|
1678
1920
|
fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
|
|
1679
1921
|
fde resume --bind show what this workspace is bound to, and what resolves
|
|
1680
|
-
fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green)
|
|
1681
|
-
fde
|
|
1922
|
+
fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green; --force to allow secret-like text)
|
|
1923
|
+
fde log --undo remove the last CLI log/debrief entry from memory
|
|
1924
|
+
fde debrief [file] meeting notes → memory: decision:/risk:/delivery:/contact: lines route, rest → context.md (stdin if no file; --dry-run; --force)
|
|
1682
1925
|
fde receipts <term> "what did we agree?" with dates
|
|
1683
1926
|
fde capture session-end memory snapshot (hooks use this)
|
|
1684
1927
|
fde status [--all] current engagement status (pass --all for full portfolio)
|
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",
|