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/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
- String(md || '')
212
- .replace(/<private>[\s\S]*?<\/private>/gi, '(private - redacted)')
213
- .replace(/<private>[\s\S]*$/i, '(private - redacted)')
214
- .replace(/<!--[\s\S]*?-->/g, '')
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
- fs.writeFileSync(tmp, content)
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
- const cells = r => r.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map(c => c.trim())
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
- return entries.slice(0, 15)
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).filter(d => !d.startsWith('.')).join(', ') || '(none yet)'
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 routeDebriefInput(eng, input, { dry, force }) {
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
- for (const raw of input.split('\n')) {
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 lockedAppendFile(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
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 proposed = smartProposeText(input)
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, proposed, { dry: true, force })
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 = proposed
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 proposed = smartProposeText(input)
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, proposed, { dry: true, force: false })
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