fdeops 3.9.20 → 3.10.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/README.md +106 -87
- package/adapters/LOCAL-LLM.md +1 -1
- package/adapters/README.md +1 -1
- package/bin/check.js +211 -4
- package/bin/fde.js +379 -26
- package/bin/install.js +223 -20
- package/bin/lib/memory.js +2 -2
- package/bin/lib/render.js +2 -0
- package/mcp/README.md +2 -4
- package/mcp/fdeops-ingest/README.md +7 -3
- package/mcp/fdeops-ingest/package.json +1 -1
- package/mcp/fdeops-ingest/server.js +59 -28
- package/mcp/recipes/README.md +9 -8
- package/mcp/recipes/file.md +7 -7
- package/mcp/recipes/granola.md +16 -20
- package/mcp/recipes/notion.md +16 -18
- package/mcp/recipes/slack.md +61 -0
- package/mcp.json +10 -0
- package/package.json +4 -2
- package/plugin.json +21 -0
- package/skills/fde/SKILL.md +4 -4
- package/skills/fde/references/assumption-audit.md +10 -0
- package/skills/fde/references/build.md +13 -1
- package/skills/fde/references/business-case.md +10 -0
- package/skills/fde/references/close.md +11 -1
- package/skills/fde/references/discover.md +10 -0
- package/skills/fde/references/ingest-connect.md +16 -18
- package/skills/fde/references/ingest.md +5 -4
- package/skills/fde/references/land.md +20 -0
- package/skills/fde/references/options-analysis.md +10 -0
- package/skills/fde/references/plan.md +10 -0
- package/skills/fde/references/scope-defense.md +10 -0
- package/skills/fde/references/ship.md +10 -0
- package/skills/fde/references/stakeholder-radar.md +21 -0
- package/skills/fde/references/status.md +12 -2
- package/templates/.fde/delivery.md +3 -3
package/bin/fde.js
CHANGED
|
@@ -206,13 +206,97 @@ function stripControlChars(s) {
|
|
|
206
206
|
return String(s || '').replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
const PRIVATE_MARKER = '(private - redacted)'
|
|
210
|
+
// Openers tolerate whitespace and attributes (<private >, <private data-x="1">)
|
|
211
|
+
// so a near-miss tag still seals instead of failing open.
|
|
212
|
+
const PRIVATE_TAG = /<(\/)?private\b[^>]*>/gi
|
|
213
|
+
|
|
214
|
+
// Depth-aware split of a markdown body into public text and sealed blocks. A
|
|
215
|
+
// nested block seals to the outermost close, an unclosed one seals to EOF, and a
|
|
216
|
+
// stray close is dropped - a regex pair cannot do any of those safely.
|
|
217
|
+
// HTML comments go first: template hints and pasted notes hide content there, and
|
|
218
|
+
// `clean` is what debrief/ingest preview to a human and route into memory.
|
|
219
|
+
// opts.sealDangling seals an unterminated `<!--` to EOF. Only untrusted input
|
|
220
|
+
// gets that: on the read path a stray `<!--` already stored in memory would
|
|
221
|
+
// otherwise hide every line after it from every view.
|
|
222
|
+
function splitPrivate(md, opts = {}) {
|
|
223
|
+
let text = String(md || '').replace(/<!--[\s\S]*?-->/g, '')
|
|
224
|
+
if (opts.sealDangling) text = text.replace(/<!--[\s\S]*$/, '')
|
|
225
|
+
const blocks = []
|
|
226
|
+
let out = ''
|
|
227
|
+
let cursor = 0
|
|
228
|
+
let depth = 0
|
|
229
|
+
let start = 0
|
|
230
|
+
let m
|
|
231
|
+
PRIVATE_TAG.lastIndex = 0
|
|
232
|
+
while ((m = PRIVATE_TAG.exec(text))) {
|
|
233
|
+
const closing = Boolean(m[1])
|
|
234
|
+
if (!closing) {
|
|
235
|
+
if (depth === 0) {
|
|
236
|
+
out += text.slice(cursor, m.index)
|
|
237
|
+
start = m.index
|
|
238
|
+
}
|
|
239
|
+
depth++
|
|
240
|
+
} else if (depth > 0) {
|
|
241
|
+
depth--
|
|
242
|
+
if (depth === 0) {
|
|
243
|
+
blocks.push(text.slice(start, m.index + m[0].length))
|
|
244
|
+
out += PRIVATE_MARKER
|
|
245
|
+
cursor = m.index + m[0].length
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
out += text.slice(cursor, m.index)
|
|
249
|
+
cursor = m.index + m[0].length
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (depth > 0) {
|
|
253
|
+
blocks.push(text.slice(start))
|
|
254
|
+
out += PRIVATE_MARKER
|
|
255
|
+
} else {
|
|
256
|
+
out += text.slice(cursor)
|
|
257
|
+
}
|
|
258
|
+
return { clean: out, blocks }
|
|
259
|
+
}
|
|
260
|
+
|
|
209
261
|
function stripPrivate(md) {
|
|
210
|
-
return stripControlChars(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
262
|
+
return stripControlChars(splitPrivate(md).clean)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Persisted blocks must be balanced. splitPrivate() seals an unclosed block to
|
|
266
|
+
// EOF and hands it back exactly as written; storing that would leave a dangling
|
|
267
|
+
// opener that swallows every note appended to the file afterwards.
|
|
268
|
+
function sealedText(blocks) {
|
|
269
|
+
return blocks.map((b) => {
|
|
270
|
+
let open = 0
|
|
271
|
+
let m
|
|
272
|
+
PRIVATE_TAG.lastIndex = 0
|
|
273
|
+
while ((m = PRIVATE_TAG.exec(b))) {
|
|
274
|
+
if (m[1]) open = Math.max(0, open - 1)
|
|
275
|
+
else open++
|
|
276
|
+
}
|
|
277
|
+
// Balance by count, not by suffix: one block can hold several unclosed
|
|
278
|
+
// openers, and each needs its own closer or the tail still dangles.
|
|
279
|
+
return `${b}\n${'</private>\n'.repeat(open)}`
|
|
280
|
+
}).join('')
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Unbalanced markers do not leak a sealed block, but they change what is public:
|
|
284
|
+
// a stray `</private>` leaves the text after it in the clear, and a forgotten
|
|
285
|
+
// closer seals everything appended later. Counts only - never the content.
|
|
286
|
+
function privateMarkerImbalance(md) {
|
|
287
|
+
const text = String(md || '')
|
|
288
|
+
let depth = 0
|
|
289
|
+
let unclosed = 0
|
|
290
|
+
let stray = 0
|
|
291
|
+
let m
|
|
292
|
+
PRIVATE_TAG.lastIndex = 0
|
|
293
|
+
while ((m = PRIVATE_TAG.exec(text))) {
|
|
294
|
+
if (!m[1]) depth++
|
|
295
|
+
else if (depth > 0) depth--
|
|
296
|
+
else stray++
|
|
297
|
+
}
|
|
298
|
+
unclosed = depth
|
|
299
|
+
return { unclosed, stray }
|
|
216
300
|
}
|
|
217
301
|
|
|
218
302
|
// Read + redact in one step - the default way dashboard code should ever touch
|
|
@@ -351,7 +435,10 @@ function atomicWriteFile(p, content, opts = {}) {
|
|
|
351
435
|
}
|
|
352
436
|
const tmp = `${p}.${process.pid}.${Date.now()}.tmp`
|
|
353
437
|
try {
|
|
354
|
-
|
|
438
|
+
// opts.mode is set at create time: a secret must never exist world-readable,
|
|
439
|
+
// not even for the window between rename and a follow-up chmod.
|
|
440
|
+
fs.writeFileSync(tmp, content, opts.mode ? { mode: opts.mode } : undefined)
|
|
441
|
+
if (opts.mode) fs.chmodSync(tmp, opts.mode)
|
|
355
442
|
fs.renameSync(tmp, p)
|
|
356
443
|
} catch (e) {
|
|
357
444
|
try { fs.unlinkSync(tmp) } catch (_) {}
|
|
@@ -385,6 +472,10 @@ function rmTreeQuiet(dir) {
|
|
|
385
472
|
|
|
386
473
|
const OWNER_FILE = '.owner'
|
|
387
474
|
const DEBRIEF_PROPOSE = '.debrief-propose'
|
|
475
|
+
// The agent is told to open and rewrite .debrief-propose, so sealed blocks are
|
|
476
|
+
// held out of it in an owner-only sidecar that only --apply reads back.
|
|
477
|
+
const DEBRIEF_PRIVATE = '.debrief-private'
|
|
478
|
+
const DEBRIEF_SEAL = '.debrief-seal'
|
|
388
479
|
|
|
389
480
|
function gitBinOk() {
|
|
390
481
|
try {
|
|
@@ -601,12 +692,18 @@ function detectOverlay(eng) {
|
|
|
601
692
|
// prose section (e.g. risks.md's "## Retired") after the table is never
|
|
602
693
|
// swept in as rows.
|
|
603
694
|
function parseMdTable(md) {
|
|
604
|
-
|
|
695
|
+
// Split on unescaped pipes only, then unescape: a ledger full of "40% \| p95"
|
|
696
|
+
// otherwise shifts every later cell and the table reads as a different table.
|
|
697
|
+
const cells = r => r.replace(/^\s*\|/, '').replace(/(?<!\\)\|\s*$/, '')
|
|
698
|
+
.split(/(?<!\\)\|/).map(c => c.replace(/\\\|/g, '|').trim())
|
|
605
699
|
const isSep = r => r.includes('-') && /^\|?[\s:|-]+\|?$/.test(r.trim())
|
|
606
700
|
let headers = null
|
|
607
701
|
const rows = []
|
|
608
702
|
for (const raw of md.split('\n')) {
|
|
609
703
|
const t = raw.trim()
|
|
704
|
+
// A redacted row is still a row: sealing one line must not truncate the
|
|
705
|
+
// table and silently hide every row under it.
|
|
706
|
+
if (t === PRIVATE_MARKER) continue
|
|
610
707
|
if (!/^\|.*\|/.test(t)) { if (headers) break; continue }
|
|
611
708
|
if (isSep(t)) continue
|
|
612
709
|
const cs = cells(t)
|
|
@@ -796,8 +893,9 @@ function extractLog(eng) {
|
|
|
796
893
|
const FLAT = /^-\s*\[(\d{4}-\d{2}-\d{2})\]\s*(.+)$/
|
|
797
894
|
const entries = []
|
|
798
895
|
const push = (date, text, kind) => {
|
|
896
|
+
const sig = (text.match(/\[signal:(red|amber|green)\]/i) || [])[1] || ''
|
|
799
897
|
text = text.replace(/\[signal:(red|amber|green)\]\s*/i, '').trim()
|
|
800
|
-
if (date && text) entries.push({ date, text, kind })
|
|
898
|
+
if (date && text) entries.push({ date, text, kind, sig: sig.toLowerCase() })
|
|
801
899
|
}
|
|
802
900
|
|
|
803
901
|
readClean(eng, 'decisions.md').split('\n').forEach(l => {
|
|
@@ -818,7 +916,18 @@ function extractLog(eng) {
|
|
|
818
916
|
})
|
|
819
917
|
|
|
820
918
|
entries.sort((a, b) => b.date.localeCompare(a.date))
|
|
821
|
-
|
|
919
|
+
// `fde log contact` records one entry in two places (stakeholders.md "Signal
|
|
920
|
+
// history" and .signal-ledger); the timeline reads both, so collapse identical
|
|
921
|
+
// rows or the same note renders twice and looks like a double write.
|
|
922
|
+
const seen = new Set()
|
|
923
|
+
return entries.filter(e => {
|
|
924
|
+
// The signal is part of the event: the same note logged amber then red on
|
|
925
|
+
// one day is an escalation, not a duplicate.
|
|
926
|
+
const key = `${e.kind}|${e.date}|${e.sig}|${e.text}`
|
|
927
|
+
if (seen.has(key)) return false
|
|
928
|
+
seen.add(key)
|
|
929
|
+
return true
|
|
930
|
+
}).slice(0, 15)
|
|
822
931
|
}
|
|
823
932
|
|
|
824
933
|
// ---------- commands ----------
|
|
@@ -1001,7 +1110,9 @@ function cmdResume(args) {
|
|
|
1001
1110
|
const eng = resolveEngagement()
|
|
1002
1111
|
if (!eng) {
|
|
1003
1112
|
const list = fs.existsSync(ENGAGEMENTS_ROOT)
|
|
1004
|
-
? fs.readdirSync(ENGAGEMENTS_ROOT).
|
|
1113
|
+
? fs.readdirSync(ENGAGEMENTS_ROOT).sort()
|
|
1114
|
+
.filter(d => !d.startsWith('.') && fs.existsSync(path.join(ENGAGEMENTS_ROOT, d, '.fde')))
|
|
1115
|
+
.join(', ') || '(none yet)'
|
|
1005
1116
|
: '(none yet)'
|
|
1006
1117
|
console.log(`NO ENGAGEMENT for this workspace.\nexisting: ${list}\ncreate + bind one: fde resume --init <client-name>`)
|
|
1007
1118
|
process.exit(2)
|
|
@@ -1290,14 +1401,57 @@ function previewLine(text, max = 240) {
|
|
|
1290
1401
|
return `${t.slice(0, max)}… (${t.length} chars)`
|
|
1291
1402
|
}
|
|
1292
1403
|
|
|
1293
|
-
function
|
|
1404
|
+
function writeProposal(eng, text) {
|
|
1405
|
+
const { clean, blocks } = splitPrivate(text, { sealDangling: true })
|
|
1406
|
+
const proposePath = path.join(eng, DEBRIEF_PROPOSE)
|
|
1407
|
+
const privatePath = path.join(eng, DEBRIEF_PRIVATE)
|
|
1408
|
+
// Seal first. A refused or failed sidecar write must not leave behind a
|
|
1409
|
+
// proposal whose (private - redacted) marker has nothing left behind it.
|
|
1410
|
+
if (blocks.length) {
|
|
1411
|
+
const blocked = refuseSymlinkWrite(privatePath, { soft: true })
|
|
1412
|
+
if (blocked) { console.error(blocked); process.exit(1) }
|
|
1413
|
+
withFileLock(privatePath, () => { atomicWriteFile(privatePath, sealedText(blocks), { mode: 0o600 }) })
|
|
1414
|
+
try { fs.chmodSync(privatePath, 0o600) } catch (_) {}
|
|
1415
|
+
} else {
|
|
1416
|
+
try { fs.unlinkSync(privatePath) } catch (_) {}
|
|
1417
|
+
}
|
|
1418
|
+
withFileLock(proposePath, () => { atomicWriteFile(proposePath, clean) })
|
|
1419
|
+
// Receipt, so apply knows how many blocks the human actually approved. Counting
|
|
1420
|
+
// (private - redacted) markers in the proposal instead would refuse forever on
|
|
1421
|
+
// notes that merely quote the wording - the CLI prints it, so it gets pasted back.
|
|
1422
|
+
withFileLock(path.join(eng, DEBRIEF_SEAL), () => {
|
|
1423
|
+
atomicWriteFile(path.join(eng, DEBRIEF_SEAL), `${blocks.length}\n`)
|
|
1424
|
+
})
|
|
1425
|
+
return { proposePath, clean, blocks }
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function readSealCount(eng) {
|
|
1429
|
+
try {
|
|
1430
|
+
const n = parseInt(fs.readFileSync(path.join(eng, DEBRIEF_SEAL), 'utf8').trim(), 10)
|
|
1431
|
+
return Number.isInteger(n) && n >= 0 ? n : null
|
|
1432
|
+
} catch (_) { return null }
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function readSealedProposal(eng) {
|
|
1436
|
+
try {
|
|
1437
|
+
return splitPrivate(stripControlChars(fs.readFileSync(path.join(eng, DEBRIEF_PRIVATE), 'utf8'))).blocks
|
|
1438
|
+
} catch (_) { return [] }
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
|
|
1294
1442
|
const d = new Date()
|
|
1295
1443
|
const date = d.toISOString().slice(0, 10)
|
|
1296
1444
|
const counts = { decision: 0, risk: 0, delivery: 0, contact: 0, next: 0 }
|
|
1297
1445
|
const ctxLines = []
|
|
1298
1446
|
let nextAction = ''
|
|
1299
1447
|
ensureMemoryGit(eng)
|
|
1300
|
-
|
|
1448
|
+
// Sealed blocks are pulled out before routing, so a <private> block's interior
|
|
1449
|
+
// lines are never previewed and never routed into decisions/risks/stakeholders
|
|
1450
|
+
// unsealed. They land verbatim in context.md instead: the preview a human
|
|
1451
|
+
// approves is exactly what --apply writes.
|
|
1452
|
+
const { clean: routable, blocks: inlinePrivate } = splitPrivate(input, { sealDangling: true })
|
|
1453
|
+
const privateBlocks = [...inlinePrivate, ...sealed]
|
|
1454
|
+
for (const raw of routable.split('\n')) {
|
|
1301
1455
|
let line = raw.trim()
|
|
1302
1456
|
if (!line) continue
|
|
1303
1457
|
const bare = line.replace(/^[-*+]\s+/, '').replace(/^\*\*(decision|risk|delivery|contact|next):?\*\*:?\s*/i, '$1: ')
|
|
@@ -1331,12 +1485,16 @@ function routeDebriefInput(eng, input, { dry, force }) {
|
|
|
1331
1485
|
}
|
|
1332
1486
|
}
|
|
1333
1487
|
if (nextAction && !dry) setNextAction(eng, nextAction)
|
|
1334
|
-
if (ctxLines.length) {
|
|
1488
|
+
if (ctxLines.length || privateBlocks.length) {
|
|
1335
1489
|
const stamp = `${date} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
1336
1490
|
if (dry) ctxLines.forEach(l => console.log(`→ context.md - ${previewLine(l)}`))
|
|
1337
|
-
else
|
|
1491
|
+
else {
|
|
1492
|
+
const bullets = ctxLines.length ? `${ctxLines.map(l => `- ${l}`).join('\n')}\n` : ''
|
|
1493
|
+
const sealed = privateBlocks.length ? sealedText(privateBlocks) : ''
|
|
1494
|
+
lockedAppendFile(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${bullets}${sealed}`)
|
|
1495
|
+
}
|
|
1338
1496
|
}
|
|
1339
|
-
return { counts, ctxLines, date, nextAction }
|
|
1497
|
+
return { counts, ctxLines, date, nextAction, privateBlocks }
|
|
1340
1498
|
}
|
|
1341
1499
|
|
|
1342
1500
|
function cmdDebrief(args) {
|
|
@@ -1358,36 +1516,45 @@ function cmdDebrief(args) {
|
|
|
1358
1516
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1359
1517
|
|
|
1360
1518
|
let input = ''
|
|
1519
|
+
let sealed = []
|
|
1361
1520
|
if (apply && !smart && !args[0]) {
|
|
1362
1521
|
try { input = stripControlChars(fs.readFileSync(path.join(eng, DEBRIEF_PROPOSE), 'utf8')) } catch (_) {
|
|
1363
1522
|
console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
|
|
1364
1523
|
process.exit(1)
|
|
1365
1524
|
}
|
|
1525
|
+
sealed = readSealedProposal(eng)
|
|
1526
|
+
const expected = readSealCount(eng)
|
|
1527
|
+
if (expected === null ? (!sealed.length && input.includes(PRIVATE_MARKER)) : sealed.length < expected) {
|
|
1528
|
+
console.error(`refused: the proposal seals a private note but ${DEBRIEF_PRIVATE} is missing or unreadable - applying now would drop it silently.`)
|
|
1529
|
+
console.error('re-run the propose step (fde debrief --smart <notes> | fde ingest propose <id>).')
|
|
1530
|
+
process.exit(1)
|
|
1531
|
+
}
|
|
1366
1532
|
} else {
|
|
1367
1533
|
input = readDebriefInput(args)
|
|
1368
1534
|
}
|
|
1369
1535
|
|
|
1370
1536
|
if (smart) {
|
|
1371
|
-
const
|
|
1372
|
-
const proposePath = path.join(eng, DEBRIEF_PROPOSE)
|
|
1373
|
-
withFileLock(proposePath, () => { atomicWriteFile(proposePath, proposed) })
|
|
1537
|
+
const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
|
|
1374
1538
|
console.log('SMART PROPOSE (heuristic - review before apply; no new facts invented beyond line rewrites)\n')
|
|
1375
|
-
routeDebriefInput(eng,
|
|
1539
|
+
routeDebriefInput(eng, clean, { dry: true, force, sealed: blocks })
|
|
1376
1540
|
if (!apply) {
|
|
1377
1541
|
console.log(`\nproposal saved → ${proposePath}`)
|
|
1378
1542
|
console.log('confirm: fde debrief --apply')
|
|
1379
1543
|
console.log('(edit the propose file first if a line mis-routed)')
|
|
1380
1544
|
return
|
|
1381
1545
|
}
|
|
1382
|
-
input =
|
|
1546
|
+
input = clean
|
|
1547
|
+
sealed = blocks
|
|
1383
1548
|
}
|
|
1384
1549
|
|
|
1385
|
-
const { counts, ctxLines } = routeDebriefInput(eng, input, { dry, force })
|
|
1550
|
+
const { counts, ctxLines, privateBlocks } = routeDebriefInput(eng, input, { dry, force, sealed })
|
|
1386
1551
|
if (!dry) {
|
|
1387
1552
|
const hash = commitMemory(eng, 'debrief', {
|
|
1388
1553
|
files: ['decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md', 'context.md', SIGNAL_LEDGER],
|
|
1389
1554
|
})
|
|
1390
1555
|
try { fs.unlinkSync(path.join(eng, DEBRIEF_PROPOSE)) } catch (_) {}
|
|
1556
|
+
try { fs.unlinkSync(path.join(eng, DEBRIEF_PRIVATE)) } catch (_) {}
|
|
1557
|
+
try { fs.unlinkSync(path.join(eng, DEBRIEF_SEAL)) } catch (_) {}
|
|
1391
1558
|
if (hash) console.log(`memory @${hash}`)
|
|
1392
1559
|
}
|
|
1393
1560
|
const plural = {
|
|
@@ -1396,6 +1563,7 @@ function cmdDebrief(args) {
|
|
|
1396
1563
|
const parts = Object.keys(counts).filter(t => counts[t])
|
|
1397
1564
|
.map(t => `${counts[t]} ${counts[t] === 1 ? (t === 'next' ? 'next action' : t) : plural[t]}`)
|
|
1398
1565
|
if (ctxLines.length) parts.push(`${ctxLines.length} context line${ctxLines.length === 1 ? '' : 's'}`)
|
|
1566
|
+
if (privateBlocks.length) parts.push(`${privateBlocks.length} sealed private note${privateBlocks.length === 1 ? '' : 's'}`)
|
|
1399
1567
|
const verb = dry ? 'debrief would route' : 'debrief routed'
|
|
1400
1568
|
console.log(parts.length ? `${verb} → ${parts.join(', ')}` : 'debrief empty - nothing routed')
|
|
1401
1569
|
}
|
|
@@ -1506,11 +1674,9 @@ function cmdIngest(args) {
|
|
|
1506
1674
|
console.error(`ingest propose refused: staged item is over ${DEBRIEF_MAX_BYTES} bytes after provenance. Split it.`)
|
|
1507
1675
|
process.exit(1)
|
|
1508
1676
|
}
|
|
1509
|
-
const
|
|
1510
|
-
const proposePath = path.join(eng, DEBRIEF_PROPOSE)
|
|
1511
|
-
withFileLock(proposePath, () => { atomicWriteFile(proposePath, proposed) })
|
|
1677
|
+
const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
|
|
1512
1678
|
console.log(`INGEST PROPOSE from ${path.basename(item)} (via:${source})\n`)
|
|
1513
|
-
routeDebriefInput(eng,
|
|
1679
|
+
routeDebriefInput(eng, clean, { dry: true, force: false, sealed: blocks })
|
|
1514
1680
|
console.log(`\nproposal saved → ${proposePath}`)
|
|
1515
1681
|
console.log('confirm: fde ingest apply')
|
|
1516
1682
|
console.log('(agent: rewrite lines with decision:/risk:/contact:/next: prefixes before apply)')
|
|
@@ -1820,6 +1986,17 @@ function collectDoctorIssues(eng) {
|
|
|
1820
1986
|
issues.push('memory not git-versioned - next write will init, or re-run resume --init')
|
|
1821
1987
|
}
|
|
1822
1988
|
}
|
|
1989
|
+
for (const file of REDACT_FILES) {
|
|
1990
|
+
const abs = path.join(eng, file)
|
|
1991
|
+
if (!fs.existsSync(abs)) continue
|
|
1992
|
+
const { unclosed, stray } = privateMarkerImbalance(readEng(eng, file))
|
|
1993
|
+
if (stray) {
|
|
1994
|
+
issues.push(`${file} has ${stray} unmatched </private> - text after it is PUBLIC; pair or delete the marker`)
|
|
1995
|
+
}
|
|
1996
|
+
if (unclosed) {
|
|
1997
|
+
issues.push(`${file} has ${unclosed} unclosed <private> - everything after it is sealed, including notes added later`)
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
1823
2000
|
const success = readClean(eng, 'success.md')
|
|
1824
2001
|
if (!firstLine(success, 80)) issues.push('success.md has no stated done-definition - fill before plan/build')
|
|
1825
2002
|
const ctxMd = readClean(eng, 'context.md')
|
|
@@ -1841,6 +2018,12 @@ function collectDoctorIssues(eng) {
|
|
|
1841
2018
|
`phase is ${s.phase} with no value bucket (cost-save | risk-mitigation | revenue-uplift) in success.md or delivery value ledger`
|
|
1842
2019
|
)
|
|
1843
2020
|
}
|
|
2021
|
+
const value = claimedValueRows(eng)
|
|
2022
|
+
if (value.claimed) {
|
|
2023
|
+
issues.push(
|
|
2024
|
+
`${value.claimed} value ledger row(s) measured but not accepted by anyone on the customer side${value.columnMissing ? ' (no "Accepted by" column)' : ''} - a number only we agree with is claimed, not delivered; name who signed off in delivery.md`
|
|
2025
|
+
)
|
|
2026
|
+
}
|
|
1844
2027
|
if (engagementTouchesAI(eng) && !hasEvalReceipt(eng)) {
|
|
1845
2028
|
issues.push(
|
|
1846
2029
|
`phase is ${s.phase} with AI in scope but no eval receipt (evals.md Verdict or delivery Eval / Ship receipts) — required before green ship/close`
|
|
@@ -1973,6 +2156,31 @@ function hasValueBucket(eng) {
|
|
|
1973
2156
|
return false
|
|
1974
2157
|
}
|
|
1975
2158
|
|
|
2159
|
+
// A measured number the FDE calculated is not a benefit the customer agreed to.
|
|
2160
|
+
// Rows carrying a real Measured value need a named customer-side owner in
|
|
2161
|
+
// "Accepted by", or they close as claimed - the distinction the renewal turns on.
|
|
2162
|
+
// "pending review", "TBD.", "n/a (blocked)" and "..." are all the same thing an
|
|
2163
|
+
// FDE means by an empty cell - nagging about them teaches people to ignore doctor.
|
|
2164
|
+
const PENDING_CELL_RE =
|
|
2165
|
+
/^(?:pending|tbd|to ?be ?(?:measured|confirmed|determined)|n\s*\/\s*a|na|none|unknown|not measured|\?+|\.{2,}|…|-+|—+|–+)(?:[^\w].*)?$/i
|
|
2166
|
+
|
|
2167
|
+
function claimedValueRows(eng) {
|
|
2168
|
+
const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger') || '')
|
|
2169
|
+
const table = parseMdTable(ledger)
|
|
2170
|
+
if (!table) return { claimed: 0, columnMissing: false }
|
|
2171
|
+
const mIdx = colIndex(table.headers, /measured/i)
|
|
2172
|
+
if (mIdx === -1) return { claimed: 0, columnMissing: false }
|
|
2173
|
+
const aIdx = colIndex(table.headers, /accept/i)
|
|
2174
|
+
let claimed = 0
|
|
2175
|
+
for (const row of table.rows) {
|
|
2176
|
+
const measured = String(row[mIdx] || '').trim()
|
|
2177
|
+
if (!measured || PENDING_CELL_RE.test(measured)) continue
|
|
2178
|
+
const accepted = aIdx === -1 ? '' : String(row[aIdx] || '').trim()
|
|
2179
|
+
if (!accepted || PENDING_CELL_RE.test(accepted)) claimed++
|
|
2180
|
+
}
|
|
2181
|
+
return { claimed, columnMissing: aIdx === -1 }
|
|
2182
|
+
}
|
|
2183
|
+
|
|
1976
2184
|
// AI in scope for ship/close hygiene — delivery/decisions/trust evidence only.
|
|
1977
2185
|
// Do not scan terrain.md: its template headers mention LLM and would false-positive every ship.
|
|
1978
2186
|
function engagementTouchesAI(eng) {
|
|
@@ -2463,8 +2671,152 @@ function cmdDashboard(args) {
|
|
|
2463
2671
|
}
|
|
2464
2672
|
}
|
|
2465
2673
|
|
|
2674
|
+
// ---------- demo (see the value before touching a real client) ----------
|
|
2675
|
+
// Everything below runs the real commands against a throwaway engagement under
|
|
2676
|
+
// ~/fde-engagements/.demo/ - the leading dot keeps it out of every portfolio
|
|
2677
|
+
// listing (status --all, dashboard --all, resume's "existing:" line). Nothing
|
|
2678
|
+
// here fabricates output: the fieldbook you see is what debrief/log actually
|
|
2679
|
+
// wrote, so the demo cannot drift from the product.
|
|
2680
|
+
const DEMO_SLUG = 'acme-payments'
|
|
2681
|
+
const DEMO_NOTES = `Kickoff call with Acme payments team - Priya (VP Eng, sponsor), Tom (staff eng)
|
|
2682
|
+
|
|
2683
|
+
decision: settle on the existing Stripe connector instead of the in-house rewrite - Priya wants the Q3 audit clean first
|
|
2684
|
+
risk: nobody can name who owns the reconciliation job; it has failed silently twice since March
|
|
2685
|
+
delivery: read-only access to the payments repo and the last 90 days of audit logs
|
|
2686
|
+
contact: Priya is bought in but travelling for two weeks - Tom is the day-to-day decision maker
|
|
2687
|
+
next: get the reconciliation runbook from Tom before touching anything
|
|
2688
|
+
|
|
2689
|
+
<private>
|
|
2690
|
+
Priya hinted the previous vendor was let go mid-contract. Do not repeat this to the team.
|
|
2691
|
+
</private>
|
|
2692
|
+
`
|
|
2693
|
+
|
|
2694
|
+
// What `@fde land` drafts with the human in the chat. The CLI has no command for
|
|
2695
|
+
// these two files by design (they are judgment, not appends), so the demo writes
|
|
2696
|
+
// them and says so - the transcript stays honest either way.
|
|
2697
|
+
const DEMO_LAND_ARTIFACTS = {
|
|
2698
|
+
'brief.md': `# Brief - Acme payments
|
|
2699
|
+
|
|
2700
|
+
**As stated:** clean up payment reconciliation before the Q3 audit.
|
|
2701
|
+
**What we heard instead:** nobody owns the reconciliation job, and it fails silently.
|
|
2702
|
+
**Out of scope (agreed):** the in-house connector rewrite.
|
|
2703
|
+
`,
|
|
2704
|
+
'success.md': `# Success
|
|
2705
|
+
|
|
2706
|
+
- Reconciliation failures alert someone within 15 minutes, with a named owner.
|
|
2707
|
+
- The Q3 audit can trace any settlement discrepancy to a dated record.
|
|
2708
|
+
|
|
2709
|
+
**Signed off by:** Priya (VP Eng) - 2026-08-07
|
|
2710
|
+
`,
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
function demoRoot() { return path.join(ENGAGEMENTS_ROOT, '.demo') }
|
|
2714
|
+
|
|
2715
|
+
// Piping the demo into a file or a docs snippet must not litter escape codes.
|
|
2716
|
+
const DEMO_BOLD = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
|
|
2717
|
+
function demoHead(s) { return DEMO_BOLD ? `\x1b[1m${s}\x1b[0m` : s }
|
|
2718
|
+
|
|
2719
|
+
function demoStep(label, argv, cwd, env) {
|
|
2720
|
+
console.log(`\n${demoHead(label)}`)
|
|
2721
|
+
console.log(` $ fde ${argv.join(' ')}\n`)
|
|
2722
|
+
const r = require('child_process').spawnSync(process.execPath, [__filename, ...argv], {
|
|
2723
|
+
cwd, env, encoding: 'utf8',
|
|
2724
|
+
})
|
|
2725
|
+
const out = `${r.stdout || ''}${r.stderr || ''}`.trimEnd()
|
|
2726
|
+
if (out) console.log(out.split('\n').map(l => ` ${l}`).join('\n'))
|
|
2727
|
+
// doctor exits 1 on hygiene findings, resume exits 2 when unbound: a demo step
|
|
2728
|
+
// failing means the product is broken, so surface it instead of pretending.
|
|
2729
|
+
if (r.status !== 0 && !(argv[0] === 'doctor')) {
|
|
2730
|
+
console.error(`\n demo step failed (exit ${r.status}): fde ${argv.join(' ')}`)
|
|
2731
|
+
process.exit(1)
|
|
2732
|
+
}
|
|
2733
|
+
return out
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
function cmdDemo(args) {
|
|
2737
|
+
const root = demoRoot()
|
|
2738
|
+
if (args.includes('--clean')) {
|
|
2739
|
+
rmTreeQuiet(root)
|
|
2740
|
+
console.log(`removed ${root}\n(your real engagements under ${ENGAGEMENTS_ROOT} were not touched)`)
|
|
2741
|
+
return
|
|
2742
|
+
}
|
|
2743
|
+
// Always start from empty, so the demo is the same on the tenth run as the first.
|
|
2744
|
+
rmTreeQuiet(root)
|
|
2745
|
+
const workspace = path.join(root, 'acme-payments-repo')
|
|
2746
|
+
try {
|
|
2747
|
+
fs.mkdirSync(workspace, { recursive: true })
|
|
2748
|
+
} catch (e) { failFs(e, 'create demo workspace', workspace) }
|
|
2749
|
+
const notes = path.join(workspace, 'kickoff-notes.md')
|
|
2750
|
+
fs.writeFileSync(notes, DEMO_NOTES)
|
|
2751
|
+
const env = {
|
|
2752
|
+
...process.env,
|
|
2753
|
+
FDEOPS_ENGAGEMENTS_ROOT: root,
|
|
2754
|
+
// the demo must resolve to its own sandbox, never to whatever the shell points at
|
|
2755
|
+
FDEOPS_ENGAGEMENT: '',
|
|
2756
|
+
FDEOS_ENGAGEMENT: '',
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
console.log(`
|
|
2760
|
+
fdeops demo - a fake client, real commands, nothing sent anywhere
|
|
2761
|
+
|
|
2762
|
+
Sandbox: ${root}
|
|
2763
|
+
Fake client: Acme (payments platform). No data of yours is read or written.`)
|
|
2764
|
+
|
|
2765
|
+
demoStep('1. Monday of week 1 - create the fieldbook for this client', ['resume', '--init', DEMO_SLUG], workspace, env)
|
|
2766
|
+
demoStep('2. You walk out of the kickoff with messy notes - hand them over', ['debrief', '--smart', notes], workspace, env)
|
|
2767
|
+
demoStep('3. You confirm. Only now does anything enter the record', ['debrief', '--apply'], workspace, env)
|
|
2768
|
+
demoStep('4. Say where you are in the engagement', ['log', 'phase', 'land'], workspace, env)
|
|
2769
|
+
const engDir = path.join(root, DEMO_SLUG, '.fde')
|
|
2770
|
+
console.log(`\n${demoHead('5. During land, @fde drafts the brief and the definition of done with you')}`)
|
|
2771
|
+
console.log(' (the two files the agent writes with you in the chat - not a CLI command)\n')
|
|
2772
|
+
for (const [file, body] of Object.entries(DEMO_LAND_ARTIFACTS)) {
|
|
2773
|
+
try { fs.writeFileSync(path.join(engDir, file), body) } catch (e) { failFs(e, 'write demo artifact', file) }
|
|
2774
|
+
console.log(` → ${file}`)
|
|
2775
|
+
}
|
|
2776
|
+
// The header fields the agent fills during land, in place - the debrief content
|
|
2777
|
+
// below them stays untouched.
|
|
2778
|
+
const ctxPath = path.join(engDir, 'context.md')
|
|
2779
|
+
const ctx = fs.readFileSync(ctxPath, 'utf8')
|
|
2780
|
+
.replace(/^\*\*Engagement:\*\*\s*$/m, '**Engagement:** Acme payments reconciliation')
|
|
2781
|
+
.replace(/^\*\*Customer:\*\*\s*$/m, '**Customer:** Acme (fake - this is the demo)')
|
|
2782
|
+
fs.writeFileSync(ctxPath, ctx)
|
|
2783
|
+
console.log(' → context.md (engagement + customer header)')
|
|
2784
|
+
// Land them in the ledger the way the agent would, or every later step warns
|
|
2785
|
+
// about uncommitted manual edits - correct behaviour, wrong lesson for a demo.
|
|
2786
|
+
const landHash = commitMemory(engDir, 'land: brief + success', { files: [...Object.keys(DEMO_LAND_ARTIFACTS), 'context.md'] })
|
|
2787
|
+
if (landHash) console.log(` memory @${landHash}`)
|
|
2788
|
+
demoStep('6. Two days later, the sponsor goes quiet', ['log', 'contact', 'Priya has not replied to two emails about the runbook', '--signal', 'amber'], workspace, env)
|
|
2789
|
+
demoStep('7. Next morning, a fresh agent session with no memory of any of this', ['resume'], workspace, env)
|
|
2790
|
+
demoStep('8. A meeting in ten minutes - what do you walk in knowing?', ['prep', 'sponsor check-in'], workspace, env)
|
|
2791
|
+
demoStep('9. Six weeks later: "we never agreed to drop the rewrite"', ['receipts', 'rewrite'], workspace, env)
|
|
2792
|
+
demoStep('10. The whole engagement on one page', ['dashboard'], workspace, env)
|
|
2793
|
+
// cmdDashboard's default out path, computed rather than scraped from its output:
|
|
2794
|
+
// a HOME with a space in it truncates any whitespace-delimited parse.
|
|
2795
|
+
const html = path.join(root, 'fieldbook-current.html')
|
|
2796
|
+
|
|
2797
|
+
console.log(`
|
|
2798
|
+
${demoHead('What just happened')}
|
|
2799
|
+
|
|
2800
|
+
- Every line above came from the real CLI - no canned output.
|
|
2801
|
+
- The kickoff notes became dated decisions, risks, deliveries and a stakeholder
|
|
2802
|
+
signal, and you confirmed before any of it was written.
|
|
2803
|
+
- The <private> block in those notes never appears in resume, prep, receipts or
|
|
2804
|
+
the dashboard - it is sealed in context.md and redacted from anything an agent
|
|
2805
|
+
or a screen share can see.
|
|
2806
|
+
- Tomorrow's session starts from the record instead of a blank chat.
|
|
2807
|
+
${fs.existsSync(html) ? `\n Open the fieldbook: ${html}` : ''}
|
|
2808
|
+
|
|
2809
|
+
${demoHead('Your turn')} (inside your own client's workspace)
|
|
2810
|
+
|
|
2811
|
+
fde resume --init <client-name>
|
|
2812
|
+
|
|
2813
|
+
Delete this demo whenever you like: fde demo --clean
|
|
2814
|
+
`)
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2466
2817
|
function printUsage() {
|
|
2467
2818
|
console.log(`fde - deterministic core of fdeops
|
|
2819
|
+
fde demo the whole loop on a fake client (fde demo --clean removes it)
|
|
2468
2820
|
fde scan day-1 recon of this repo (facts, no AI)
|
|
2469
2821
|
fde resume load this workspace's engagement memory (bounded)
|
|
2470
2822
|
fde resume --full load the complete context.md (no bound)
|
|
@@ -2498,6 +2850,7 @@ function printUsage() {
|
|
|
2498
2850
|
|
|
2499
2851
|
const [cmd, ...args] = process.argv.slice(2)
|
|
2500
2852
|
switch (cmd) {
|
|
2853
|
+
case 'demo': cmdDemo(args); break
|
|
2501
2854
|
case 'scan': cmdScan(); break
|
|
2502
2855
|
case 'resume': cmdResume(args); break
|
|
2503
2856
|
case 'triage': cmdTriage(); break
|