fdeops 3.24.0 → 3.27.0
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 +78 -216
- package/adapters/LOCAL-LLM.md +14 -102
- package/bin/check.js +3 -3
- package/bin/fde.js +517 -375
- package/bin/install.js +74 -51
- package/bin/lib/context.js +85 -0
- package/bin/lib/delivery-gaps.js +34 -0
- package/bin/lib/fieldbook-client.js +41 -10
- package/bin/lib/font-css.js +115 -0
- package/bin/lib/install-paths.js +53 -0
- package/bin/lib/provenance.js +42 -0
- package/bin/lib/render.js +57 -74
- package/bin/lib/value-ledger.js +40 -0
- package/hooks/session-start +18 -84
- package/mcp/fdeops-ingest/package.json +1 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/skills/fde/SKILL.md +6 -5
- package/skills/fde/references/plan.md +2 -0
- package/skills/fde/references/poc.md +11 -3
- package/skills/fde/references/ship.md +3 -1
package/bin/fde.js
CHANGED
|
@@ -35,6 +35,9 @@ const { execSync, execFileSync } = require('child_process')
|
|
|
35
35
|
const { createMemoryApi } = require('./lib/memory')
|
|
36
36
|
const { createTrustApi } = require('./lib/trust')
|
|
37
37
|
const vault = require('./lib/vault')
|
|
38
|
+
const context = require('./lib/context')
|
|
39
|
+
const { sourceReference, hasSource, datedDecisions } = require('./lib/provenance')
|
|
40
|
+
const { deliverySummary } = require('./lib/delivery-gaps')
|
|
38
41
|
|
|
39
42
|
const HOME = os.homedir()
|
|
40
43
|
// FDEOPS_ENGAGEMENTS_ROOT isolates init/status/dashboard (and the registry) for
|
|
@@ -833,46 +836,49 @@ function valueLedgerRowCount(body) {
|
|
|
833
836
|
function appendValueLedgerRow(eng, cells) {
|
|
834
837
|
ensureMemoryGit(eng)
|
|
835
838
|
const p = path.join(eng, 'delivery.md')
|
|
836
|
-
let
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
839
|
+
let row
|
|
840
|
+
withFileLock(p, () => {
|
|
841
|
+
let md = readEng(eng, 'delivery.md')
|
|
842
|
+
if (!md) md = '# Delivery log\n\n## Value ledger\n\n'
|
|
843
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
844
|
+
const cols = []
|
|
845
|
+
for (let i = 0; i < 7; i++) cols.push((cells[i] || '').replace(/\|/g, '\\|').trim() || ' ')
|
|
846
|
+
row = `| ${date} | ${cols.join(' | ')} |`
|
|
847
|
+
// Write into the same section the readers take: the last filled ## Value ledger.
|
|
848
|
+
// A row appended to the empty template heading above a filled one is a row no
|
|
849
|
+
// gate can see.
|
|
850
|
+
const lines = md.split('\n')
|
|
851
|
+
const sections = []
|
|
852
|
+
let cur = null
|
|
853
|
+
for (let i = 0; i < lines.length; i++) {
|
|
854
|
+
if (/^##\s+Value ledger\b/i.test(lines[i])) {
|
|
855
|
+
cur = { heading: i, lastTable: -1, filled: false, body: [] }
|
|
856
|
+
sections.push(cur)
|
|
857
|
+
continue
|
|
858
|
+
}
|
|
859
|
+
if (!cur) continue
|
|
860
|
+
if (/^##\s+/.test(lines[i])) { cur = null; continue }
|
|
861
|
+
cur.body.push(lines[i])
|
|
862
|
+
if (lines[i].trim()) cur.filled = true
|
|
863
|
+
if (/^\|/.test(lines[i].trim())) cur.lastTable = i
|
|
864
|
+
}
|
|
865
|
+
// Same choice parseValueLedger makes: the last section carrying rows, else the
|
|
866
|
+
// last with a body. A row written anywhere else is a row no gate can see.
|
|
867
|
+
const withRows = [...sections].reverse().find(s => valueLedgerRowCount(s.body.join('\n')))
|
|
868
|
+
const target = withRows || [...sections].reverse().find(s => s.filled) || sections[sections.length - 1]
|
|
869
|
+
if (!target) {
|
|
870
|
+
md = appendUnderSection(md, 'Value ledger', `${VALUE_LEDGER_HEADER}\n${VALUE_LEDGER_RULE}\n${row}`)
|
|
871
|
+
} else if (target.lastTable !== -1) {
|
|
872
|
+
lines.splice(target.lastTable + 1, 0, row)
|
|
873
|
+
md = lines.join('\n')
|
|
874
|
+
} else {
|
|
875
|
+
// No table under the chosen heading: a lone row would be read as the header
|
|
876
|
+
// line and the value would vanish. Lay the canonical table first.
|
|
877
|
+
lines.splice(target.heading + 1, 0, '', VALUE_LEDGER_HEADER, VALUE_LEDGER_RULE, row)
|
|
878
|
+
md = lines.join('\n')
|
|
853
879
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
cur.body.push(lines[i])
|
|
857
|
-
if (lines[i].trim()) cur.filled = true
|
|
858
|
-
if (/^\|/.test(lines[i].trim())) cur.lastTable = i
|
|
859
|
-
}
|
|
860
|
-
// Same choice parseValueLedger makes: the last section carrying rows, else the
|
|
861
|
-
// last with a body. A row written anywhere else is a row no gate can see.
|
|
862
|
-
const withRows = [...sections].reverse().find(s => valueLedgerRowCount(s.body.join('\n')))
|
|
863
|
-
const target = withRows || [...sections].reverse().find(s => s.filled) || sections[sections.length - 1]
|
|
864
|
-
if (!target) {
|
|
865
|
-
md = appendUnderSection(md, 'Value ledger', `${VALUE_LEDGER_HEADER}\n${VALUE_LEDGER_RULE}\n${row}`)
|
|
866
|
-
} else if (target.lastTable !== -1) {
|
|
867
|
-
lines.splice(target.lastTable + 1, 0, row)
|
|
868
|
-
md = lines.join('\n')
|
|
869
|
-
} else {
|
|
870
|
-
// No table under the chosen heading: a lone row would be read as the header
|
|
871
|
-
// line and the value would vanish. Lay the canonical table first.
|
|
872
|
-
lines.splice(target.heading + 1, 0, '', VALUE_LEDGER_HEADER, VALUE_LEDGER_RULE, row)
|
|
873
|
-
md = lines.join('\n')
|
|
874
|
-
}
|
|
875
|
-
withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
|
|
880
|
+
atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
|
|
881
|
+
})
|
|
876
882
|
recordLastWrite(eng, 'delivery.md', row)
|
|
877
883
|
commitMemory(eng, 'log delivery', { files: ['delivery.md'] })
|
|
878
884
|
}
|
|
@@ -881,34 +887,36 @@ function retireOpenRisks(eng, needle) {
|
|
|
881
887
|
const n = String(needle || '').toLowerCase()
|
|
882
888
|
if (!n) return 0
|
|
883
889
|
const p = path.join(eng, 'risks.md')
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
890
|
+
return withFileLock(p, () => {
|
|
891
|
+
const md = readEng(eng, 'risks.md')
|
|
892
|
+
if (!md) return 0
|
|
893
|
+
const retired = []
|
|
894
|
+
const kept = []
|
|
895
|
+
let inRetired = false
|
|
896
|
+
for (const raw of md.split('\n')) {
|
|
897
|
+
const t = raw.trim()
|
|
898
|
+
if (/^#{1,6}\s+Retired\b/i.test(t)) { inRetired = true; kept.push(raw); continue }
|
|
899
|
+
if (!inRetired) {
|
|
900
|
+
const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
|
|
901
|
+
if (m && m[1].toLowerCase().includes(n)) {
|
|
902
|
+
retired.push(raw)
|
|
903
|
+
continue
|
|
904
|
+
}
|
|
897
905
|
}
|
|
906
|
+
kept.push(raw)
|
|
898
907
|
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
return retired.length
|
|
908
|
+
if (!retired.length) return 0
|
|
909
|
+
let out = kept.join('\n')
|
|
910
|
+
if (!/^#{1,6}\s+Retired\b/im.test(out)) out = out.replace(/\n*$/, '\n\n## Retired\n')
|
|
911
|
+
const stamp = new Date().toISOString().slice(0, 10)
|
|
912
|
+
const block = retired.map(l => {
|
|
913
|
+
const body = l.trim().replace(/^-\s*/, '')
|
|
914
|
+
return `- [${stamp}] (retired) ${body}`
|
|
915
|
+
}).join('\n')
|
|
916
|
+
out = appendUnderSection(out, 'Retired', block)
|
|
917
|
+
atomicWriteFile(p, out.endsWith('\n') ? out : out + '\n')
|
|
918
|
+
return retired.length
|
|
919
|
+
})
|
|
912
920
|
}
|
|
913
921
|
|
|
914
922
|
// Engagement age from the .fde/ directory's own birth time - hidden (not
|
|
@@ -1282,6 +1290,9 @@ function cmdScan() {
|
|
|
1282
1290
|
}
|
|
1283
1291
|
|
|
1284
1292
|
function cmdResume(args) {
|
|
1293
|
+
let maxBytes
|
|
1294
|
+
try { ({ args, maxBytes } = context.budgetArgs(args)) } catch (e) { console.error(e.message); process.exit(2) }
|
|
1295
|
+
|
|
1285
1296
|
const initIdx = args.indexOf('--init')
|
|
1286
1297
|
if (initIdx !== -1) {
|
|
1287
1298
|
const name = args[initIdx + 1]
|
|
@@ -1306,30 +1317,23 @@ function cmdResume(args) {
|
|
|
1306
1317
|
}
|
|
1307
1318
|
|
|
1308
1319
|
try {
|
|
1320
|
+
const installPaths = require('./lib/install-paths')
|
|
1321
|
+
installPaths.checkTree(tpl, fdeDir)
|
|
1309
1322
|
if (!existed) {
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
|
|
1313
|
-
const stagingRoot = path.join(ENGAGEMENTS_ROOT, `.init-${slug}
|
|
1314
|
-
const
|
|
1315
|
-
const stagingFde = path.join(stagingEng, '.fde')
|
|
1316
|
-
rmTreeQuiet(stagingRoot)
|
|
1323
|
+
// Stage only the fieldbook. A pre-existing client folder can contain
|
|
1324
|
+
// contracts or source files that initialization must never remove.
|
|
1325
|
+
installPaths.mkdir(engRoot)
|
|
1326
|
+
const stagingRoot = fs.mkdtempSync(path.join(ENGAGEMENTS_ROOT, `.init-${slug}-`))
|
|
1327
|
+
const stagingFde = path.join(stagingRoot, '.fde')
|
|
1317
1328
|
try {
|
|
1318
|
-
fs.mkdirSync(stagingFde
|
|
1329
|
+
fs.mkdirSync(stagingFde)
|
|
1319
1330
|
fillTemplates(stagingFde)
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
fs.renameSync(stagingEng, engRoot)
|
|
1323
|
-
rmTreeQuiet(stagingRoot)
|
|
1324
|
-
} catch (e) {
|
|
1331
|
+
fs.renameSync(stagingFde, fdeDir)
|
|
1332
|
+
} finally {
|
|
1325
1333
|
rmTreeQuiet(stagingRoot)
|
|
1326
|
-
if (fs.existsSync(engRoot) && !fs.existsSync(path.join(engRoot, '.fde', 'context.md'))) {
|
|
1327
|
-
rmTreeQuiet(engRoot)
|
|
1328
|
-
}
|
|
1329
|
-
failFs(e, 'create engagement', engRoot)
|
|
1330
1334
|
}
|
|
1331
1335
|
} else {
|
|
1332
|
-
// Re-init
|
|
1336
|
+
// Re-init only fills missing templates; existing records are preserved.
|
|
1333
1337
|
fillTemplates(fdeDir)
|
|
1334
1338
|
}
|
|
1335
1339
|
} catch (e) {
|
|
@@ -1394,17 +1398,25 @@ function cmdResume(args) {
|
|
|
1394
1398
|
console.log(`NO ENGAGEMENT for this workspace.\nexisting: ${list}\nAsk the human the client name (one question), then run: fde resume --init <client-name>\nDo not tell them to type that command.`)
|
|
1395
1399
|
process.exit(2)
|
|
1396
1400
|
}
|
|
1397
|
-
|
|
1398
|
-
// (sponsor / promise / decisions), then the session log.
|
|
1399
|
-
printTriageBlock(eng)
|
|
1400
|
-
const digest = recordDigest(eng)
|
|
1401
|
-
if (digest.length) console.log('\n' + digest.join('\n'))
|
|
1402
|
-
console.log(`\nENGAGEMENT: ${eng}\n`)
|
|
1403
|
-
// readClean, not fs.readFileSync: this output is what an agent loads as
|
|
1404
|
-
// context, so it goes through the same <private> redaction as the dashboard.
|
|
1401
|
+
const intro = [resumeTriage(eng), firstActionLine(eng), ...hygieneTriageLines(eng), ...recordDigest(eng)].join('\n')
|
|
1405
1402
|
const ctx = readClean(eng, 'context.md')
|
|
1406
|
-
if (
|
|
1407
|
-
|
|
1403
|
+
if (args.includes('--full')) {
|
|
1404
|
+
console.log(`${intro}\n\nENGAGEMENT: ${eng}\n\n${ctx || '(no context.md yet)'}`)
|
|
1405
|
+
return
|
|
1406
|
+
}
|
|
1407
|
+
// Bound the complete command output, not only context.md's line count.
|
|
1408
|
+
// Separate allocations keep a long history from crowding out current goals.
|
|
1409
|
+
const policy = readClean(eng, 'trust-profile.md')
|
|
1410
|
+
const success = readClean(eng, 'success.md')
|
|
1411
|
+
const risks = readClean(eng, 'risks.md')
|
|
1412
|
+
process.stdout.write(context.boundedSections([
|
|
1413
|
+
policy ? `CLIENT POLICY - trust-profile.md\n${policy}` : '',
|
|
1414
|
+
`${intro}\n\nENGAGEMENT: ${eng}`,
|
|
1415
|
+
success ? `CURRENT GOALS & ACCEPTANCE - success.md\n${success}` : '',
|
|
1416
|
+
risks ? `OPEN RISKS - risks.md\n${extractRisks(eng).map(r => r.text).join('\n') || '(none recorded)'}` : '',
|
|
1417
|
+
`VALUE LEDGER - delivery.md\n${parseValueLedger(eng).rows.map(r => formatValueLedgerLine(r) + '; source: ' + (r.evidence || '(missing)')).join('\n') || '(none recorded)'}`,
|
|
1418
|
+
`WORKING CONTEXT - context.md\n${ctx ? resumeView(ctx) : '(no context.md yet)'}`,
|
|
1419
|
+
], maxBytes))
|
|
1408
1420
|
}
|
|
1409
1421
|
|
|
1410
1422
|
// Token discipline: context.md grows every session (the session-stop hook
|
|
@@ -1412,18 +1424,18 @@ function cmdResume(args) {
|
|
|
1412
1424
|
// each day for less marginal signal. This returns a bounded view - the curated
|
|
1413
1425
|
// head (state / next action) plus the most recent activity - and hides the
|
|
1414
1426
|
// middle of the log behind `fde resume --full`. Pure code, zero model tokens.
|
|
1415
|
-
// The session-start hook
|
|
1427
|
+
// The session-start hook consumes this same view through fde resume.
|
|
1416
1428
|
function resumeView(md) {
|
|
1417
1429
|
const lines = md.split('\n')
|
|
1418
1430
|
// A file ending in "\n" yields a trailing "" here; drop it so the line count
|
|
1419
|
-
//
|
|
1431
|
+
// counts content lines rather than the terminal newline.
|
|
1420
1432
|
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
1421
1433
|
if (lines.length <= 160) return md
|
|
1422
1434
|
// Anchor on the "## Session end" heading, NOT the "<!-- fdeops auto-capture -->"
|
|
1423
1435
|
// comment: this text is read via readClean (stripPrivate strips HTML comments),
|
|
1424
1436
|
// so the comment is gone by the time we get here. The heading is written on the
|
|
1425
1437
|
// very next line by cmdCapture and the session-stop hook and survives redaction.
|
|
1426
|
-
//
|
|
1438
|
+
// Bound after sanitation so removed comments cannot shift the anchor.
|
|
1427
1439
|
let headEnd = lines.findIndex(l => /^##\s+Session end\b/.test(l.trim()))
|
|
1428
1440
|
if (headEnd === -1) headEnd = 120
|
|
1429
1441
|
headEnd = Math.min(headEnd, 120)
|
|
@@ -1440,25 +1452,35 @@ function cmdLogUndo() {
|
|
|
1440
1452
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1441
1453
|
const metaPath = path.join(eng, LAST_WRITE)
|
|
1442
1454
|
let meta
|
|
1443
|
-
try {
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1455
|
+
try {
|
|
1456
|
+
withFileLock(metaPath, () => {
|
|
1457
|
+
try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) } catch (_) {
|
|
1458
|
+
throw new Error('nothing to undo - no prior fde log/debrief write recorded')
|
|
1459
|
+
}
|
|
1460
|
+
if (!Object.values(LOG_FILES).includes(meta.file) || !meta.entry) {
|
|
1461
|
+
throw new Error('corrupt .last-write - cannot undo')
|
|
1462
|
+
}
|
|
1463
|
+
const target = path.join(eng, meta.file)
|
|
1464
|
+
withFileLock(target, () => {
|
|
1465
|
+
const after = removeExactEntryLine(readEng(eng, meta.file), meta.entry)
|
|
1466
|
+
if (after == null) {
|
|
1467
|
+
throw new Error(`cannot undo - entry no longer in ${meta.file} (edited by hand?). Remove it manually.`)
|
|
1468
|
+
}
|
|
1469
|
+
atomicWriteFile(target, after.endsWith('\n') ? after : after + '\n', { soft: true })
|
|
1470
|
+
}, { soft: true })
|
|
1471
|
+
if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) {
|
|
1472
|
+
const ledgerPath = path.join(eng, SIGNAL_LEDGER)
|
|
1473
|
+
withFileLock(ledgerPath, () => {
|
|
1474
|
+
const led = removeExactEntryLine(readEng(eng, SIGNAL_LEDGER), meta.entry)
|
|
1475
|
+
if (led != null) atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n', { soft: true })
|
|
1476
|
+
}, { soft: true })
|
|
1477
|
+
}
|
|
1478
|
+
try { fs.unlinkSync(metaPath) } catch (_) {}
|
|
1479
|
+
})
|
|
1480
|
+
} catch (e) {
|
|
1481
|
+
console.error(e.message)
|
|
1453
1482
|
process.exit(1)
|
|
1454
1483
|
}
|
|
1455
|
-
withFileLock(target, () => { atomicWriteFile(target, after.endsWith('\n') ? after : after + '\n') })
|
|
1456
|
-
if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) {
|
|
1457
|
-
const ledgerPath = path.join(eng, SIGNAL_LEDGER)
|
|
1458
|
-
const led = removeExactEntryLine(readEng(eng, SIGNAL_LEDGER), meta.entry)
|
|
1459
|
-
if (led != null) withFileLock(ledgerPath, () => { atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n') })
|
|
1460
|
-
}
|
|
1461
|
-
try { fs.unlinkSync(metaPath) } catch (_) {}
|
|
1462
1484
|
const undoFiles = [meta.file]
|
|
1463
1485
|
if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) undoFiles.push(SIGNAL_LEDGER)
|
|
1464
1486
|
const hash = commitMemory(eng, `undo ${meta.file}`, { files: undoFiles })
|
|
@@ -1536,18 +1558,20 @@ function cmdLog(args) {
|
|
|
1536
1558
|
function setContextPhase(eng, phase) {
|
|
1537
1559
|
ensureMemoryGit(eng)
|
|
1538
1560
|
const p = path.join(eng, 'context.md')
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1561
|
+
withFileLock(p, () => {
|
|
1562
|
+
let md = readEng(eng, 'context.md')
|
|
1563
|
+
if (!md) md = '# Engagement context\n\n'
|
|
1564
|
+
if (/\*\*Phase:\*\*/i.test(md)) {
|
|
1565
|
+
md = md.replace(/\*\*Phase:\*\*\s*.*/i, `**Phase:** ${phase}`)
|
|
1566
|
+
} else {
|
|
1567
|
+
md = md.replace(/\n*$/, `\n\n**Phase:** ${phase}\n`)
|
|
1568
|
+
}
|
|
1569
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
1570
|
+
if (/\*\*Last updated:\*\*/i.test(md)) {
|
|
1571
|
+
md = md.replace(/\*\*Last updated:\*\*\s*.*/i, `**Last updated:** ${today}`)
|
|
1572
|
+
}
|
|
1573
|
+
atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
|
|
1574
|
+
})
|
|
1551
1575
|
const hash = commitMemory(eng, `phase ${phase}`, { files: ['context.md'] })
|
|
1552
1576
|
// Proactive warn at the moment it matters - don't wait for Monday hygiene.
|
|
1553
1577
|
if (phase === 'ship' || phase === 'close') {
|
|
@@ -1610,7 +1634,10 @@ function smartProposeText(input) {
|
|
|
1610
1634
|
// the original stays as context, so nothing is invented or lost.
|
|
1611
1635
|
for (const sentence of bare.split(/(?<=[.!?])\s+/)) {
|
|
1612
1636
|
const who = signerFromLine(sentence)
|
|
1613
|
-
if (who) {
|
|
1637
|
+
if (who) {
|
|
1638
|
+
const source = (bare.match(/\[source:\s*[^\]]+\]/i) || [])[0] || ''
|
|
1639
|
+
out.push(`signer: ${who}${source ? ` ${source}` : ''}`); break
|
|
1640
|
+
}
|
|
1614
1641
|
}
|
|
1615
1642
|
if (/^(next action|follow-?ups?|action items?|todo):\s*/i.test(bare) ||
|
|
1616
1643
|
/\b(next action|walk in with|follow up with)\b/i.test(bare)) {
|
|
@@ -1618,6 +1645,14 @@ function smartProposeText(input) {
|
|
|
1618
1645
|
out.push(`next: ${next}`)
|
|
1619
1646
|
continue
|
|
1620
1647
|
}
|
|
1648
|
+
if (/^(?:scope|proposed scope|out of scope):\s*/i.test(bare)) {
|
|
1649
|
+
out.push(`scope: ${bare}`)
|
|
1650
|
+
continue
|
|
1651
|
+
}
|
|
1652
|
+
if (/\b(?:we need|we want|customer asks?|customer asked|please|can you|could you)\b/i.test(bare)) {
|
|
1653
|
+
out.push(`ask: ${bare}`)
|
|
1654
|
+
continue
|
|
1655
|
+
}
|
|
1621
1656
|
if (/\b(we (decided|agreed)|decided:|decision:|descope|agreed to|agreement was|freeze scope|freeze prompts)\b/i.test(bare)) {
|
|
1622
1657
|
out.push(`decision: ${bare}`)
|
|
1623
1658
|
} else if (/\b(open question|who signs|unclear who|unresolved)\b/i.test(bare)) {
|
|
@@ -1653,7 +1688,7 @@ function looksLikePersonName(s) {
|
|
|
1653
1688
|
|
|
1654
1689
|
function signerFromLine(text) {
|
|
1655
1690
|
const t = String(text || '').replace(/^[-*+]\s+/, '').trim()
|
|
1656
|
-
if (!t) return ''
|
|
1691
|
+
if (!t || /\?|\b(?:not|nobody|unclear|maybe|might|whether|could|should|if|unless|pending|unconfirmed)\b/i.test(t)) return ''
|
|
1657
1692
|
// "Priya (VP Eng) signs off" → Priya. "Finance controller (Helena) signs off" → Helena.
|
|
1658
1693
|
const titled = t.match(new RegExp('\\b' + SIGNER_NAME + '\\s+\\(' + SIGNER_NAME + '\\)\\s+' + SIGNER_VERB + '\\b'))
|
|
1659
1694
|
if (titled) {
|
|
@@ -1674,57 +1709,61 @@ function signerFromLine(text) {
|
|
|
1674
1709
|
function setSigner(eng, who) {
|
|
1675
1710
|
ensureMemoryGit(eng)
|
|
1676
1711
|
const p = path.join(eng, 'success.md')
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
const
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1712
|
+
return withFileLock(p, () => {
|
|
1713
|
+
let md = readEng(eng, 'success.md')
|
|
1714
|
+
if (!md) md = '# Success definition\n\n'
|
|
1715
|
+
const norm = (s) => String(s).replace(/\s+/g, ' ').trim().toLowerCase()
|
|
1716
|
+
// [^\S\n], not \s: \s crosses newlines, so an empty field captured the next
|
|
1717
|
+
// line - and setSigner then read a filled field and filed the name as "also
|
|
1718
|
+
// named" under whatever heading followed.
|
|
1719
|
+
const line = /^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m
|
|
1720
|
+
const m = md.match(line)
|
|
1721
|
+
if (m && !m[1].trim()) {
|
|
1722
|
+
md = md.replace(line, `**Stakeholder who signs off:** ${who}`)
|
|
1723
|
+
} else if (m) {
|
|
1724
|
+
// Whole-name compare against the primary and every "also named" line under
|
|
1725
|
+
// it: "Sam" must not vanish inside "Samantha", and re-applying must not
|
|
1726
|
+
// stack duplicates.
|
|
1727
|
+
const start = md.indexOf(m[0]) + m[0].length
|
|
1728
|
+
const alsoNamed = []
|
|
1729
|
+
for (const l of md.slice(start).split('\n').slice(1)) {
|
|
1730
|
+
const a = l.match(/^- also named:\s*(.+)$/)
|
|
1731
|
+
if (!a) break
|
|
1732
|
+
alsoNamed.push(a[1])
|
|
1733
|
+
}
|
|
1734
|
+
const known = [m[1], ...alsoNamed].map(norm)
|
|
1735
|
+
if (known.includes(norm(who))) return false
|
|
1736
|
+
// A second, different name is a fact worth keeping next to the first, not
|
|
1737
|
+
// a silent overwrite - who signs is exactly the thing people argue about.
|
|
1738
|
+
const block = [m[0], ...alsoNamed.map(a => `- also named: ${a}`)].join('\n')
|
|
1739
|
+
md = md.replace(block, `${block}\n- also named: ${who}`)
|
|
1740
|
+
} else {
|
|
1741
|
+
md = md.replace(/\n*$/, `\n\n**Stakeholder who signs off:** ${who}\n`)
|
|
1742
|
+
}
|
|
1743
|
+
atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
|
|
1744
|
+
return true
|
|
1745
|
+
})
|
|
1709
1746
|
}
|
|
1710
1747
|
|
|
1711
1748
|
function setNextAction(eng, text) {
|
|
1712
1749
|
ensureMemoryGit(eng)
|
|
1713
1750
|
const bullet = `- ${stripControlChars(String(text).replace(/^[-*]\s+/, '').trim())}`
|
|
1714
1751
|
const p = path.join(eng, 'context.md')
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1752
|
+
withFileLock(p, () => {
|
|
1753
|
+
let md = readEng(eng, 'context.md')
|
|
1754
|
+
if (!md) md = '# Engagement context\n\n'
|
|
1755
|
+
// Collapse duplicate ## Next action headings (skill-append trap) into one.
|
|
1756
|
+
md = stripAllSections(md, 'Next action')
|
|
1757
|
+
if (/^##\s+Current state\b/im.test(md)) {
|
|
1758
|
+
md = md.replace(
|
|
1759
|
+
/(^##\s+Current state\b[^\n]*\n)([\s\S]*?)(?=^##\s|\s*$)/im,
|
|
1760
|
+
(_, h, body) => `${h}${String(body).replace(/\n*$/, '\n')}\n## Next action\n\n${bullet}\n\n`
|
|
1761
|
+
)
|
|
1762
|
+
} else {
|
|
1763
|
+
md = md.replace(/\n*$/, `\n\n## Next action\n\n${bullet}\n`)
|
|
1764
|
+
}
|
|
1765
|
+
atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
|
|
1766
|
+
})
|
|
1728
1767
|
}
|
|
1729
1768
|
|
|
1730
1769
|
function looksLikeBinaryNoise(text) {
|
|
@@ -1819,12 +1858,12 @@ function stripApprovedStamp(text) {
|
|
|
1819
1858
|
|
|
1820
1859
|
// One screen a human can confirm in two minutes. The file-by-file routing
|
|
1821
1860
|
// still prints after this - agents edit prefixes; people read this.
|
|
1822
|
-
function printDebriefReview(text) {
|
|
1823
|
-
const buckets = { decided: [], asked: [], open: [], next: [], signer: [] }
|
|
1861
|
+
function printDebriefReview(text, eng) {
|
|
1862
|
+
const buckets = { decided: [], asked: [], scope: [], delivery: [], open: [], next: [], signer: [] }
|
|
1824
1863
|
for (const raw of String(text || '').split('\n')) {
|
|
1825
1864
|
const line = raw.trim().replace(/^[-*+]\s+/, '')
|
|
1826
1865
|
if (!line) continue
|
|
1827
|
-
const m = line.match(/^(decision|risk|delivery|contact|next|signer):\s*(.+)$/i)
|
|
1866
|
+
const m = line.match(/^(decision|risk|delivery|contact|next|signer|ask|scope):\s*(.+)$/i)
|
|
1828
1867
|
if (!m) continue
|
|
1829
1868
|
const type = m[1].toLowerCase()
|
|
1830
1869
|
const body = m[2]
|
|
@@ -1832,8 +1871,12 @@ function printDebriefReview(text) {
|
|
|
1832
1871
|
const who = approvedStamp(body)
|
|
1833
1872
|
const core = previewLine(stripApprovedStamp(body), 90)
|
|
1834
1873
|
buckets.decided.push(who ? `${core} (approved ${who})` : `${core} (unconfirmed)`)
|
|
1835
|
-
} else if (type === '
|
|
1874
|
+
} else if (type === 'ask') {
|
|
1836
1875
|
buckets.asked.push(previewLine(body, 100))
|
|
1876
|
+
} else if (type === 'scope') {
|
|
1877
|
+
buckets.scope.push(previewLine(body, 100))
|
|
1878
|
+
} else if (type === 'delivery') {
|
|
1879
|
+
buckets.delivery.push(previewLine(body, 100))
|
|
1837
1880
|
} else if (type === 'risk') {
|
|
1838
1881
|
buckets.open.push(previewLine(body, 100))
|
|
1839
1882
|
} else if (type === 'next') {
|
|
@@ -1845,20 +1888,33 @@ function printDebriefReview(text) {
|
|
|
1845
1888
|
console.log('REVIEW (one screen - confirm once, then apply)\n')
|
|
1846
1889
|
const order = [
|
|
1847
1890
|
['decided', buckets.decided],
|
|
1848
|
-
['
|
|
1891
|
+
['stated asks', buckets.asked],
|
|
1892
|
+
['proposed scope', buckets.scope],
|
|
1893
|
+
['reported delivery (not customer acceptance)', buckets.delivery],
|
|
1849
1894
|
['open', buckets.open],
|
|
1850
|
-
['next', buckets.next],
|
|
1851
|
-
['signer', buckets.signer],
|
|
1895
|
+
['next action', buckets.next],
|
|
1896
|
+
['named signer (authority, not approval)', buckets.signer],
|
|
1852
1897
|
]
|
|
1853
1898
|
let any = false
|
|
1854
1899
|
for (const [label, items] of order) {
|
|
1855
|
-
if (!items.length)
|
|
1900
|
+
if (!items.length) {
|
|
1901
|
+
if (['stated asks', 'proposed scope', 'next action', 'named signer (authority, not approval)'].includes(label)) console.log(` ${label}: not stated`)
|
|
1902
|
+
continue
|
|
1903
|
+
}
|
|
1856
1904
|
any = true
|
|
1857
1905
|
console.log(` ${label}:`)
|
|
1858
1906
|
for (const item of items) console.log(` - ${item}`)
|
|
1859
1907
|
}
|
|
1860
1908
|
if (!any) console.log(' (nothing prefixed yet - edit .debrief-propose, then apply)')
|
|
1861
|
-
|
|
1909
|
+
const recorded = eng ? parseValueLedger(eng).rows : []
|
|
1910
|
+
const measured = recorded.some(row => row.state !== 'unmeasured')
|
|
1911
|
+
const evidenced = recorded.some(row => !row.evidenceMissing)
|
|
1912
|
+
const accepted = recorded.some(row => row.state === 'accepted')
|
|
1913
|
+
console.log(' delivery picture (existing record; this proposal does not certify it):')
|
|
1914
|
+
console.log(` - measurement: ${measured ? 'recorded; check the value ledger' : 'missing - record the observed result'}`)
|
|
1915
|
+
console.log(` - evidence: ${evidenced ? 'recorded; review its source' : 'missing - cite the test, artifact, or source'}`)
|
|
1916
|
+
console.log(` - customer approval: ${accepted ? 'recorded for a prior outcome; not this proposal' : 'missing - request explicit acceptance after evidence review'}`)
|
|
1917
|
+
console.log(' Saving this update confirms your record, not customer acceptance.\n')
|
|
1862
1918
|
}
|
|
1863
1919
|
|
|
1864
1920
|
function latestDatedDecision(md) {
|
|
@@ -1876,8 +1932,8 @@ function formatDecisionRecord(line) {
|
|
|
1876
1932
|
const raw = String(line || '').trim().replace(/^[-*]\s*/, '')
|
|
1877
1933
|
const who = approvedStamp(raw)
|
|
1878
1934
|
const core = stripApprovedStamp(raw)
|
|
1879
|
-
const stamp = who ? `(approved ${who})` : '(unconfirmed)'
|
|
1880
|
-
return
|
|
1935
|
+
const stamp = !hasSource(raw) ? '(CLAIM - source missing)' : who ? `(approved ${who}; source recorded)` : '(unconfirmed; source recorded)'
|
|
1936
|
+
return `${stamp} ${previewLine(core, 110)}`
|
|
1881
1937
|
}
|
|
1882
1938
|
|
|
1883
1939
|
function changeReviewIssues(eng) {
|
|
@@ -2026,7 +2082,7 @@ function cmdDebrief(args) {
|
|
|
2026
2082
|
if (smart) {
|
|
2027
2083
|
const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
|
|
2028
2084
|
console.log('SMART PROPOSE (heuristic - review before apply; no new facts invented beyond line rewrites)\n')
|
|
2029
|
-
printDebriefReview(clean)
|
|
2085
|
+
printDebriefReview(clean, eng)
|
|
2030
2086
|
console.log('Prefix vocabulary (lines that route): decision: risk: delivery: contact: next: signer:')
|
|
2031
2087
|
console.log('Optional on a decision: [approved: Name YYYY-MM-DD]. Missing means unconfirmed.')
|
|
2032
2088
|
console.log('Everything else → context.md. Keep the prefixes; the preview gate stays.\n')
|
|
@@ -2170,7 +2226,7 @@ function cmdIngest(args) {
|
|
|
2170
2226
|
}
|
|
2171
2227
|
const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
|
|
2172
2228
|
console.log(`INGEST PROPOSE from ${path.basename(item)} (via:${source})\n`)
|
|
2173
|
-
printDebriefReview(clean)
|
|
2229
|
+
printDebriefReview(clean, eng)
|
|
2174
2230
|
routeDebriefInput(eng, clean, { dry: true, force: false, sealed: blocks })
|
|
2175
2231
|
console.log(`\nproposal saved → ${proposePath}`)
|
|
2176
2232
|
console.log('confirm: fde ingest apply')
|
|
@@ -2244,60 +2300,97 @@ function cmdIngest(args) {
|
|
|
2244
2300
|
}
|
|
2245
2301
|
|
|
2246
2302
|
function cmdReceipts(args) {
|
|
2247
|
-
const term = args.join(' ')
|
|
2248
|
-
if (!term) { console.error('usage: fde receipts <search term>'); process.exit(
|
|
2303
|
+
const term = args.join(' ').trim()
|
|
2304
|
+
if (!term) { console.error('usage: fde receipts <search term>'); process.exit(2) }
|
|
2249
2305
|
const eng = resolveEngagement()
|
|
2250
|
-
if (!eng) { console.error('no engagement -
|
|
2251
|
-
const
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
if (rx.test(l)) hits.push(` ${f}:${i + 1} ${l.trim().slice(0, 160)}`)
|
|
2268
|
-
})
|
|
2269
|
-
}
|
|
2270
|
-
return hits
|
|
2271
|
-
}
|
|
2272
|
-
const agreed = collect(AGREEMENTS)
|
|
2273
|
-
const claimed = collect(CLAIMS)
|
|
2274
|
-
const dirty = memoryDirtyManual(eng)
|
|
2275
|
-
const dirtySet = new Set(dirty)
|
|
2276
|
-
const dirtyAgreedHits = [...new Set(
|
|
2277
|
-
agreed.map(h => (h.match(/^\s*([^:]+):/) || [])[1]).filter(f => f && dirtySet.has(f))
|
|
2278
|
-
)]
|
|
2279
|
-
if (agreed.length) {
|
|
2280
|
-
console.log('ON RECORD (dated):')
|
|
2281
|
-
agreed.forEach(h => {
|
|
2282
|
-
const file = (h.match(/^\s*([^:]+):/) || [])[1]
|
|
2283
|
-
console.log(h + (file && dirtySet.has(file) ? ' ⚠ dirty file' : ''))
|
|
2306
|
+
if (!eng) { console.error('no engagement - bind a client first'); process.exit(2) }
|
|
2307
|
+
const recordFiles = ['decisions.md', 'delivery.md', 'success.md', 'risks.md', 'stakeholders.md']
|
|
2308
|
+
const workingFiles = ['brief.md', 'assumptions.md', 'reality.md', 'context.md']
|
|
2309
|
+
const dirty = new Set(memoryDirtyManual(eng))
|
|
2310
|
+
const records = [], claims = []
|
|
2311
|
+
for (const file of [...recordFiles, ...workingFiles]) {
|
|
2312
|
+
const document = readClean(eng, file)
|
|
2313
|
+
const decisionSources = new Map()
|
|
2314
|
+
if (file === 'decisions.md') for (const entry of datedDecisions(document)) {
|
|
2315
|
+
const source = sourceReference(entry.text)
|
|
2316
|
+
for (let line = entry.line; line < entry.line + entry.text.split('\n').length; line++) decisionSources.set(line, source)
|
|
2317
|
+
}
|
|
2318
|
+
document.split('\n').forEach((line, i) => {
|
|
2319
|
+
if (!line.toLowerCase().includes(term.toLowerCase())) return
|
|
2320
|
+
const source = decisionSources.get(i + 1) || sourceReference(line)
|
|
2321
|
+
const hit = ` ${file}:${i + 1} ${line.trim().slice(0, 160)}${source ? ` [source: ${source.slice(0, 160)}]` : ' [source missing]'}${dirty.has(file) ? ' dirty file - review manual edits' : ''}`
|
|
2322
|
+
;(recordFiles.includes(file) && source ? records : claims).push(hit)
|
|
2284
2323
|
})
|
|
2285
|
-
if (dirtyAgreedHits.length) {
|
|
2286
|
-
console.log(
|
|
2287
|
-
`⚠ memory dirty (uncommitted manual edits: ${dirtyAgreedHits.join(', ')}) - dated lines above may not match the tamper-evident ledger until reviewed`
|
|
2288
|
-
)
|
|
2289
|
-
}
|
|
2290
2324
|
}
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2325
|
+
const sections = ['RECEIPTS: a cited record is not proof of customer approval. File line numbers refer to the redacted view.']
|
|
2326
|
+
if (records.length) sections.push('ON RECORD (dated, source-backed):\n' + records.join('\n'))
|
|
2327
|
+
if (claims.length) sections.push('CLAIMS & working notes (verify source and approval before citing):\n' + claims.join('\n'))
|
|
2328
|
+
if (!records.length && !claims.length) sections.push(`no record of "${term}" - a gap in the record, not proof of absence`)
|
|
2329
|
+
process.stdout.write(context.boundedSections(sections))
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// Portable snapshot; stdout is read-only. --out creates a new file and never
|
|
2333
|
+
// overwrites an engagement record, existing file, or symlink.
|
|
2334
|
+
function cmdHandoff(args, label = 'Handoff') {
|
|
2335
|
+
let parsed
|
|
2336
|
+
try { parsed = context.budgetArgs(args) } catch (e) { console.error(e.message); process.exit(1) }
|
|
2337
|
+
let out = ''
|
|
2338
|
+
if (parsed.args.length) {
|
|
2339
|
+
if (parsed.args.length !== 2 || parsed.args[0] !== '--out' || !parsed.args[1] || parsed.args[1].startsWith('--')) {
|
|
2340
|
+
console.error('usage: fde handoff [--out new-file.md] [--max-bytes 4096..65536]'); process.exit(1)
|
|
2341
|
+
}
|
|
2342
|
+
out = path.resolve(parsed.args[1])
|
|
2295
2343
|
}
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2344
|
+
const eng = resolveEngagement()
|
|
2345
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
2346
|
+
const success = stripTemplateNoise(readClean(eng, 'success.md'))
|
|
2347
|
+
const signer = ((success.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
2348
|
+
const ledger = parseValueLedger(eng).rows
|
|
2349
|
+
const rowText = r => `- ${r.slice || 'Unnamed slice'}: promised ${r.promised || '(missing)'}; measured ${r.measured || '(missing)'}; accepted by ${r.accepted || '(missing)'}; evidence ${r.evidence || '(missing)'}`
|
|
2350
|
+
const decisions = datedDecisions(readClean(eng, 'decisions.md'))
|
|
2351
|
+
const selected = decisions.slice(-8)
|
|
2352
|
+
const records = selected.filter(d => hasSource(d.text))
|
|
2353
|
+
const claims = selected.filter(d => !hasSource(d.text))
|
|
2354
|
+
const decisionText = d => `${d.text} (decisions.md:${d.line}, redacted view)`
|
|
2355
|
+
const next = stripTemplateNoise(sectionBody(readClean(eng, 'context.md'), 'Next action', { lastNonEmpty: true }))
|
|
2356
|
+
const gaps = collectDoctorIssues(eng)
|
|
2357
|
+
const report = context.boundedSections([
|
|
2358
|
+
`# ${label}: ${engagementSlugFromPath(eng)}\nSnapshot: ${new Date().toISOString()} · memory ${memoryHead(eng) || 'unversioned'}\nRead-only record, not proof of approval. Confirm sources with the named customer before relying on a claim. Private blocks are excluded; review remaining client information before sharing.`,
|
|
2359
|
+
`## Constraints - trust-profile.md\n${stripTemplateNoise(readClean(eng, 'trust-profile.md')) || '(missing)'}`,
|
|
2360
|
+
`## Signer and success - success.md\nSigner: ${signer || '(missing; do not infer)'}\n${success || '(missing)'}`,
|
|
2361
|
+
`## Next action - context.md\n${next || '(missing)'}\n\n## Open risks - risks.md\n${extractRisks(eng).map(r => '- ' + r.text).join('\n') || '(none recorded; not proof of no risk)'}`,
|
|
2362
|
+
`## Accepted value - recorded assertion with source\n${ledger.filter(r => r.state === 'accepted').map(rowText).join('\n') || '(none)'}\n\n## CLAIMS and unmeasured promises\n${ledger.filter(r => r.state !== 'accepted').map(r => rowText(r) + ' [' + r.state + ']').join('\n') || '(none)'}`,
|
|
2363
|
+
`## ON RECORD decisions - source supplied, not automatic approval\n${records.map(decisionText).join('\n') || '(none)'}\n\n## CLAIM decisions - source missing\n${claims.map(decisionText).join('\n') || '(none)'}\nSelected ${selected.length} of ${decisions.length} dated decisions. Retrieve older or conflicting decisions with fde recall.`,
|
|
2364
|
+
`## Gaps before relying on this packet\n${gaps.map(g => '- ' + g).join('\n') || '(no deterministic lint gaps; human review still required)'}`,
|
|
2365
|
+
], parsed.maxBytes)
|
|
2366
|
+
if (!out) { process.stdout.write(report); return }
|
|
2367
|
+
try {
|
|
2368
|
+
// Exclusive creation fails closed for files and links. Resolve the parent
|
|
2369
|
+
// first so a directory link cannot redirect an export into .fde/.
|
|
2370
|
+
const parent = fs.realpathSync(path.dirname(out))
|
|
2371
|
+
const target = path.join(parent, path.basename(out))
|
|
2372
|
+
const root = fs.realpathSync(eng)
|
|
2373
|
+
if (parent.split(path.sep).includes('.fde') || target === root || target.startsWith(root + path.sep)) throw new Error('export outside .fde/; engagement records are not export destinations')
|
|
2374
|
+
fs.writeFileSync(target, report, { flag: 'wx', mode: 0o600 })
|
|
2375
|
+
console.log(`${label.toLowerCase()} → ${out}`)
|
|
2376
|
+
} catch (e) { console.error(`could not export packet: ${e.message}`); process.exit(1) }
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
function cmdRecall(args) {
|
|
2380
|
+
let maxBytes
|
|
2381
|
+
try { ({ args, maxBytes } = context.budgetArgs(args)) } catch (e) { console.error(e.message); process.exit(2) }
|
|
2382
|
+
const query = args.join(' ').trim()
|
|
2383
|
+
if (!query || Buffer.byteLength(query) > 2048 || args.some(a => a.startsWith('--'))) {
|
|
2384
|
+
console.error('usage: fde recall <topic> [--max-bytes 4096..65536]'); process.exit(2)
|
|
2300
2385
|
}
|
|
2386
|
+
const eng = resolveEngagement()
|
|
2387
|
+
if (!eng) { console.error('no engagement - bind a client before recall'); process.exit(2) }
|
|
2388
|
+
const files = ['context.md', 'trust-profile.md', 'success.md', 'decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md', 'brief.md', 'reality.md', 'assumptions.md', 'terrain.md']
|
|
2389
|
+
const result = context.recallSections(files.map(file => ({ file, text: readClean(eng, file) })), query)
|
|
2390
|
+
process.stdout.write(context.boundedSections([
|
|
2391
|
+
`RECALL - ${eng}\n${result.total ? `${result.sections.length} of ${result.total} matching lines; refine the query if evidence is omitted.` : 'No matching record. This is not proof that the event never happened.'}\nSources are local record assertions; verify dates, supersession and approval scope.`,
|
|
2392
|
+
...result.sections,
|
|
2393
|
+
], maxBytes))
|
|
2301
2394
|
}
|
|
2302
2395
|
|
|
2303
2396
|
function cmdCapture() {
|
|
@@ -2510,7 +2603,33 @@ function silentCommitIssues(eng) {
|
|
|
2510
2603
|
// Deterministic fieldbook hygiene - shared by doctor + session TRIAGE.
|
|
2511
2604
|
// Silent when clean OR brand-new (no dated work yet). Never auto-rewrites.
|
|
2512
2605
|
// High-value moments: week-start (via triage), ship/close, after real work accrues.
|
|
2513
|
-
function
|
|
2606
|
+
function successContractIssues(success) {
|
|
2607
|
+
const issues = []
|
|
2608
|
+
const text = stripTemplateNoise(String(success || ''))
|
|
2609
|
+
const checks = []
|
|
2610
|
+
let active = -1
|
|
2611
|
+
for (const line of text.split('\n')) {
|
|
2612
|
+
const header = line.match(/^(?:\*\*)?(?:Done when|Acceptance check)(?:\s*\([^\n)]*\))?:(?:\*\*)?[^\S\n]*(.*)$/i)
|
|
2613
|
+
if (header) { checks.push(header[1].trim()); active = checks.length - 1; continue }
|
|
2614
|
+
if (/^#{1,6}\s|^\*\*[^*]+:/.test(line)) { active = -1; continue }
|
|
2615
|
+
if (active !== -1 && line.trim()) checks[active] += ` ${line.trim()}`
|
|
2616
|
+
}
|
|
2617
|
+
const observable = checks.some(check => {
|
|
2618
|
+
const stimulus = /\b(?:test|drill|replay|runs?|request|sample|given|when|simulate|inject|compare|restore|verified|observed|measured)\b/i.test(check)
|
|
2619
|
+
const result = /\b(?:returns?|rejects?|matches?|equals?|arrives?|alerts?|restores?|passes?|fails?|contains?|produces?|shows?|remains?|receives?)\b/i.test(check)
|
|
2620
|
+
const target = /(?:\b(?:within|under|at most|at least|exactly|zero|no missing|no duplicate|all|every|none|true|false|pass|fail|http)\b|[<>=])/i.test(check)
|
|
2621
|
+
return stimulus && result && target && !/\b(?:tbd|to be defined|improve|better|satisfactory|as expected|works well)\b/i.test(check)
|
|
2622
|
+
})
|
|
2623
|
+
if (!observable) issues.push('success.md needs a binary acceptance check: state a test/input and an observable pass/fail result under **Done when:** or **Acceptance check:**; numbers alone are not a check')
|
|
2624
|
+
const signerLine = ((text.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').replace(/\[source:[^\]]+\]/gi, '').trim()
|
|
2625
|
+
// A named primary signer may be followed by responsibilities or another
|
|
2626
|
+
// signer's role. Preserve the full record; validate only the leading name.
|
|
2627
|
+
const signer = (signerLine.match(/^((?:[A-Z]\.|[A-Z][\w'-]+)(?:\s+(?:[A-Z]\.|[A-Z][\w'-]+)){0,2})(?=\s*(?:[.,;:]|\(|$))/) || [])[1] || ''
|
|
2628
|
+
if (!looksLikePersonName(signer) || /\b(?:pending|unknown|tbd|nobody|none|unassigned|unconfirmed)\b/i.test(signer)) issues.push('success.md needs a named customer-side signer under **Stakeholder who signs off:**; a team, role, or pending name is not authority')
|
|
2629
|
+
return issues
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
function collectDoctorIssues(eng, { readiness = false } = {}) {
|
|
2514
2633
|
const issues = []
|
|
2515
2634
|
const s = computeSignals(eng)
|
|
2516
2635
|
// stripTemplateNoise: a dated example inside a template comment is not work.
|
|
@@ -2535,7 +2654,7 @@ function collectDoctorIssues(eng) {
|
|
|
2535
2654
|
issues.push(`${f} is not a regular file - reads come back empty and every write fails; remove it and re-run any fde write`)
|
|
2536
2655
|
}
|
|
2537
2656
|
}
|
|
2538
|
-
if (fresh) return issues
|
|
2657
|
+
if (fresh && !readiness) return issues
|
|
2539
2658
|
|
|
2540
2659
|
if (s.phase === '?' || s.phase === 'unset') {
|
|
2541
2660
|
if (hasDatedWork) {
|
|
@@ -2567,7 +2686,11 @@ function collectDoctorIssues(eng) {
|
|
|
2567
2686
|
issues.push(`${file} has ${unclosed} unclosed <private> - everything after it is sealed, including notes added later`)
|
|
2568
2687
|
}
|
|
2569
2688
|
}
|
|
2689
|
+
const { hasSource } = require('./lib/provenance')
|
|
2690
|
+
const unsourced = readClean(eng, 'decisions.md').split('\n').filter(line => /^[-*]\s*\[\d{4}-\d{2}-\d{2}\]/.test(line.trim()) && !hasSource(line))
|
|
2691
|
+
if (unsourced.length) issues.push(`${unsourced.length} dated decision(s) remain CLAIM: source missing - add the actual meeting, transcript, email, or artifact reference; a log date is not evidence`)
|
|
2570
2692
|
const success = readClean(eng, 'success.md')
|
|
2693
|
+
if (readiness || /^(plan|ship|outcome|close)$/.test(s.phase)) issues.push(...successContractIssues(success))
|
|
2571
2694
|
if (!firstLine(success, 80)) issues.push('success.md has no stated done-definition - fill before plan/ship')
|
|
2572
2695
|
const ctxMd = readClean(eng, 'context.md')
|
|
2573
2696
|
if (!sectionBody(ctxMd, 'Next action', { lastNonEmpty: true })) {
|
|
@@ -2759,13 +2882,8 @@ function hasValueBucket(eng) {
|
|
|
2759
2882
|
return false
|
|
2760
2883
|
}
|
|
2761
2884
|
|
|
2762
|
-
//
|
|
2763
|
-
|
|
2764
|
-
// "Accepted by", or they close as claimed - the distinction the renewal turns on.
|
|
2765
|
-
// "pending review", "TBD.", "n/a (blocked)" and "..." are all the same thing an
|
|
2766
|
-
// FDE means by an empty cell - nagging about them teaches people to ignore doctor.
|
|
2767
|
-
const PENDING_CELL_RE =
|
|
2768
|
-
/^(?:pending|tbd|to ?be ?(?:measured|confirmed|determined)|n\s*\/\s*a|na|none|unknown|not(?:\s+yet)?\s+measured|unmeasured|awaiting|\?+|\.{2,}|…|-+)(?:[^\w].*)?$/i
|
|
2885
|
+
// Shared classification keeps CLI, dashboard, and vault acceptance consistent.
|
|
2886
|
+
const { PENDING_CELL_RE, valueState, evidenceSource } = require('./lib/value-ledger')
|
|
2769
2887
|
|
|
2770
2888
|
function parseValueLedger(eng) {
|
|
2771
2889
|
// Last section with actual rows, not merely the last non-empty one: a template
|
|
@@ -2780,7 +2898,8 @@ function parseValueLedger(eng) {
|
|
|
2780
2898
|
slice: colIndex(table.headers, /slice/i),
|
|
2781
2899
|
promised: colIndex(table.headers, /promised/i),
|
|
2782
2900
|
measured: colIndex(table.headers, /measured/i),
|
|
2783
|
-
accepted: colIndex(table.headers, /
|
|
2901
|
+
accepted: colIndex(table.headers, /accepted by/i),
|
|
2902
|
+
acceptanceStatus: colIndex(table.headers, /^acceptance status$/i),
|
|
2784
2903
|
evidence: colIndex(table.headers, /evidence/i),
|
|
2785
2904
|
}
|
|
2786
2905
|
const cell = (row, i) => (i === -1 ? '' : String(row[i] || '').trim())
|
|
@@ -2791,13 +2910,10 @@ function parseValueLedger(eng) {
|
|
|
2791
2910
|
const measured = cell(row, idx.measured)
|
|
2792
2911
|
const accepted = cell(row, idx.accepted)
|
|
2793
2912
|
if (!slice && !promised && !measured) continue
|
|
2794
|
-
const measuredPending = !measured || PENDING_CELL_RE.test(measured)
|
|
2795
|
-
const acceptedPending = idx.accepted === -1 || !accepted || PENDING_CELL_RE.test(accepted)
|
|
2796
|
-
let state = 'unmeasured'
|
|
2797
|
-
if (!measuredPending && acceptedPending) state = 'claimed'
|
|
2798
|
-
else if (!measuredPending) state = 'accepted'
|
|
2799
2913
|
const evidence = cell(row, idx.evidence)
|
|
2800
|
-
|
|
2914
|
+
const acceptanceStatus = idx.acceptanceStatus === -1 ? undefined : cell(row, idx.acceptanceStatus)
|
|
2915
|
+
const state = valueState({ measured, accepted, acceptanceStatus, evidence })
|
|
2916
|
+
rows.push({ slice, promised, measured, accepted, evidence, evidenceMissing: !evidenceSource(evidence), state })
|
|
2801
2917
|
}
|
|
2802
2918
|
return { rows, columnMissing: idx.accepted === -1 }
|
|
2803
2919
|
}
|
|
@@ -2901,8 +3017,26 @@ function hygieneTriageLines(eng) {
|
|
|
2901
3017
|
]
|
|
2902
3018
|
}
|
|
2903
3019
|
|
|
3020
|
+
function deliverySummaryFor(eng) {
|
|
3021
|
+
const signals = computeSignals(eng)
|
|
3022
|
+
const next = stripTemplateNoise(sectionBody(readClean(eng, 'context.md'), 'Next action', { lastNonEmpty: true })).trim()
|
|
3023
|
+
const signer = ((readClean(eng, 'success.md').match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
3024
|
+
return deliverySummary({ signals, next, hasNext: !!next,
|
|
3025
|
+
hasSigner: !!require('./lib/value-ledger').acceptanceName(signer),
|
|
3026
|
+
highRisks: extractRisks(eng).filter(r => r.severity === 'high').length,
|
|
3027
|
+
valueRows: parseValueLedger(eng).rows,
|
|
3028
|
+
quiet: signals.ageDays !== Infinity && signals.ageDays >= 3,
|
|
3029
|
+
})
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
function firstActionLine(eng) {
|
|
3033
|
+
const action = deliverySummaryFor(eng).firstAction
|
|
3034
|
+
return ` do first: ${previewLine(action.text, 140)} (${action.source}: ${previewLine(action.reason, 140)})`
|
|
3035
|
+
}
|
|
3036
|
+
|
|
2904
3037
|
function printTriageBlock(eng) {
|
|
2905
3038
|
console.log(resumeTriage(eng))
|
|
3039
|
+
console.log(firstActionLine(eng))
|
|
2906
3040
|
for (const line of hygieneTriageLines(eng)) console.log(line)
|
|
2907
3041
|
}
|
|
2908
3042
|
|
|
@@ -2925,16 +3059,15 @@ function recordDigest(eng) {
|
|
|
2925
3059
|
const target = ((success.match(/^\*\*Baseline[^\S\n]*→[^\S\n]*target:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
2926
3060
|
if (target) lines.push(` promised: ${target.slice(0, 110)}`)
|
|
2927
3061
|
}
|
|
2928
|
-
const decisions = readClean(eng, 'decisions.md').
|
|
2929
|
-
|
|
2930
|
-
for (const d of decisions) lines.push(` decided: ${formatDecisionRecord(d)}`)
|
|
3062
|
+
const decisions = datedDecisions(readClean(eng, 'decisions.md')).slice(-2)
|
|
3063
|
+
for (const d of decisions) lines.push(` decided: ${formatDecisionRecord(d.text)}; source: ${previewLine(sourceReference(d.text) || '(missing)', 100)}`)
|
|
2931
3064
|
return ['RECORD (read-only - success, delivery, decisions)', ...lines]
|
|
2932
3065
|
}
|
|
2933
3066
|
|
|
2934
|
-
function cmdDoctor() {
|
|
3067
|
+
function cmdDoctor(args = []) {
|
|
2935
3068
|
const eng = resolveEngagement()
|
|
2936
3069
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
2937
|
-
const issues = collectDoctorIssues(eng)
|
|
3070
|
+
const issues = collectDoctorIssues(eng, { readiness: args.includes('--ready') })
|
|
2938
3071
|
console.log(`FDE DOCTOR - ${engagementSlugFromPath(eng)}`)
|
|
2939
3072
|
printTriageBlock(eng)
|
|
2940
3073
|
if (!issues.length) {
|
|
@@ -2990,19 +3123,15 @@ function cmdRedact(args) {
|
|
|
2990
3123
|
}
|
|
2991
3124
|
ensureMemoryGit(eng)
|
|
2992
3125
|
const touched = []
|
|
2993
|
-
const
|
|
2994
|
-
for (const h of hits) {
|
|
2995
|
-
if (!byFile.has(h.file)) byFile.set(h.file, new Set())
|
|
2996
|
-
byFile.get(h.file).add(h.lineNo)
|
|
2997
|
-
}
|
|
2998
|
-
for (const [file, lineNos] of byFile) {
|
|
3126
|
+
for (const file of new Set(hits.map(h => h.file))) {
|
|
2999
3127
|
const abs = path.join(eng, file)
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3128
|
+
withFileLock(abs, () => {
|
|
3129
|
+
const before = readEng(eng, file)
|
|
3130
|
+
const after = before.split('\n').filter(line => !line.toLowerCase().includes(needle)).join('\n')
|
|
3131
|
+
if (after === before) return
|
|
3132
|
+
atomicWriteFile(abs, after.endsWith('\n') || after === '' ? after : after + '\n')
|
|
3133
|
+
touched.push(file)
|
|
3134
|
+
})
|
|
3006
3135
|
}
|
|
3007
3136
|
if (!touched.length) {
|
|
3008
3137
|
console.log('nothing changed')
|
|
@@ -3153,37 +3282,45 @@ function cmdGarden(args) {
|
|
|
3153
3282
|
continue
|
|
3154
3283
|
}
|
|
3155
3284
|
if (p.id !== 'archive-sessions') continue
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3285
|
+
let archived
|
|
3286
|
+
try {
|
|
3287
|
+
archived = withFileLock(path.join(eng, 'context.md'), () => {
|
|
3288
|
+
const cutDates = new Set(p.sessionBlocks.map(b => b.date))
|
|
3289
|
+
const keep = []
|
|
3290
|
+
const archive = []
|
|
3291
|
+
let mode = 'keep'
|
|
3292
|
+
let buf = []
|
|
3293
|
+
const flush = () => {
|
|
3294
|
+
if (!buf.length) return
|
|
3295
|
+
;(mode === 'archive' ? archive : keep).push(...buf)
|
|
3296
|
+
buf = []
|
|
3297
|
+
}
|
|
3298
|
+
for (const line of readEng(eng, 'context.md').split('\n')) {
|
|
3299
|
+
const m = line.match(/^##\s+Session end\s+-\s+(\d{4}-\d{2}-\d{2})\b/)
|
|
3300
|
+
if (m) {
|
|
3301
|
+
flush()
|
|
3302
|
+
mode = cutDates.has(m[1]) ? 'archive' : 'keep'
|
|
3303
|
+
} else if (/^##\s+/.test(line) && mode === 'archive') {
|
|
3304
|
+
flush()
|
|
3305
|
+
mode = 'keep'
|
|
3306
|
+
}
|
|
3307
|
+
buf.push(line)
|
|
3308
|
+
}
|
|
3172
3309
|
flush()
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3310
|
+
if (!archive.length) return false
|
|
3311
|
+
const archPath = path.join(eng, 'context-archive.md')
|
|
3312
|
+
withFileLock(archPath, () => {
|
|
3313
|
+
const prev = readEng(eng, 'context-archive.md') || '# Context archive\n\n'
|
|
3314
|
+
atomicWriteFile(archPath, prev.replace(/\n*$/, '\n\n') + archive.join('\n').trim() + '\n', { soft: true })
|
|
3315
|
+
}, { soft: true })
|
|
3316
|
+
atomicWriteFile(path.join(eng, 'context.md'), keep.join('\n').replace(/\n*$/, '\n'), { soft: true })
|
|
3317
|
+
return true
|
|
3318
|
+
}, { soft: true })
|
|
3319
|
+
} catch (e) {
|
|
3320
|
+
console.error(`tidy archive: ${e.message}`)
|
|
3321
|
+
process.exit(1)
|
|
3322
|
+
}
|
|
3323
|
+
if (!archived) continue
|
|
3187
3324
|
applied++
|
|
3188
3325
|
touched.add('context.md')
|
|
3189
3326
|
touched.add('context-archive.md')
|
|
@@ -3197,47 +3334,49 @@ function cmdGarden(args) {
|
|
|
3197
3334
|
// Keep the first open-risk bullet per fingerprint; move later echoes under ## Retired.
|
|
3198
3335
|
function applyRiskDedupe(eng, clusters) {
|
|
3199
3336
|
const p = path.join(eng, 'risks.md')
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
for (
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
const
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
}
|
|
3218
|
-
if (!inRetired) {
|
|
3219
|
-
const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
|
|
3220
|
-
if (m && echoTexts.has(m[1].trim())) {
|
|
3221
|
-
retiredLines.push(raw)
|
|
3222
|
-
moved++
|
|
3337
|
+
return withFileLock(p, () => {
|
|
3338
|
+
let md = readEng(eng, 'risks.md')
|
|
3339
|
+
if (!md) return 0
|
|
3340
|
+
const echoTexts = new Set()
|
|
3341
|
+
for (const group of clusters) {
|
|
3342
|
+
for (let i = 1; i < group.length; i++) echoTexts.add(group[i])
|
|
3343
|
+
}
|
|
3344
|
+
if (!echoTexts.size) return 0
|
|
3345
|
+
const retiredLines = []
|
|
3346
|
+
const kept = []
|
|
3347
|
+
let inRetired = false
|
|
3348
|
+
let moved = 0
|
|
3349
|
+
for (const raw of md.split('\n')) {
|
|
3350
|
+
const t = raw.trim()
|
|
3351
|
+
if (/^#{1,6}\s+Retired\b/i.test(t)) {
|
|
3352
|
+
inRetired = true
|
|
3353
|
+
kept.push(raw)
|
|
3223
3354
|
continue
|
|
3224
3355
|
}
|
|
3356
|
+
if (!inRetired) {
|
|
3357
|
+
const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
|
|
3358
|
+
if (m && echoTexts.has(m[1].trim())) {
|
|
3359
|
+
retiredLines.push(raw)
|
|
3360
|
+
moved++
|
|
3361
|
+
continue
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
kept.push(raw)
|
|
3225
3365
|
}
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
return moved
|
|
3366
|
+
if (!moved) return 0
|
|
3367
|
+
let out = kept.join('\n')
|
|
3368
|
+
if (!/^#{1,6}\s+Retired\b/im.test(out)) {
|
|
3369
|
+
out = out.replace(/\n*$/, '\n\n## Retired\n')
|
|
3370
|
+
}
|
|
3371
|
+
const stamp = new Date().toISOString().slice(0, 10)
|
|
3372
|
+
const block = retiredLines.map(l => {
|
|
3373
|
+
const body = l.trim().replace(/^-\s*/, '')
|
|
3374
|
+
return `- [${stamp}] (tidy dedupe) ${body}`
|
|
3375
|
+
}).join('\n')
|
|
3376
|
+
out = appendUnderSection(out, 'Retired', block)
|
|
3377
|
+
atomicWriteFile(p, out.endsWith('\n') ? out : out + '\n')
|
|
3378
|
+
return moved
|
|
3379
|
+
})
|
|
3241
3380
|
}
|
|
3242
3381
|
|
|
3243
3382
|
function engagementSlugFromPath(eng) {
|
|
@@ -3358,6 +3497,7 @@ function cmdDashboard(args) {
|
|
|
3358
3497
|
e.log = extractLog(e.dir)
|
|
3359
3498
|
e.stats = extractStats(e.dir)
|
|
3360
3499
|
e.valueRows = parseValueLedger(e.dir).rows
|
|
3500
|
+
e.hasSigner = !deliverySummaryFor(e.dir).gaps.some(g => g.kind === 'signer')
|
|
3361
3501
|
e.highRisks = e.risks.filter(r => r.severity === 'high').length
|
|
3362
3502
|
e.quiet = e.signals.ageDays !== Infinity && e.signals.ageDays >= 3
|
|
3363
3503
|
e.slug = slugify(e.name)
|
|
@@ -3419,24 +3559,10 @@ function cliVersion() {
|
|
|
3419
3559
|
}
|
|
3420
3560
|
|
|
3421
3561
|
function valueLedgerRows(eng) {
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
const pIdx = colIndex(table.headers, /promis/i)
|
|
3427
|
-
const aIdx = colIndex(table.headers, /accept/i)
|
|
3428
|
-
const rows = []
|
|
3429
|
-
for (const row of table.rows) {
|
|
3430
|
-
const slice = sIdx === -1 ? '' : String(row[sIdx] || '').trim()
|
|
3431
|
-
const promised = pIdx === -1 ? '' : String(row[pIdx] || '').trim()
|
|
3432
|
-
if (!slice && !promised) continue
|
|
3433
|
-
const acceptedRaw = aIdx === -1 ? '' : String(row[aIdx] || '').trim()
|
|
3434
|
-
rows.push({
|
|
3435
|
-
slice, promised,
|
|
3436
|
-
acceptedBy: !acceptedRaw || PENDING_CELL_RE.test(acceptedRaw) ? '' : acceptedRaw,
|
|
3437
|
-
})
|
|
3438
|
-
}
|
|
3439
|
-
return rows
|
|
3562
|
+
return parseValueLedger(eng).rows.map(row => ({
|
|
3563
|
+
slice: row.slice, promised: row.promised,
|
|
3564
|
+
acceptedBy: row.state === 'accepted' ? row.accepted : '',
|
|
3565
|
+
}))
|
|
3440
3566
|
}
|
|
3441
3567
|
|
|
3442
3568
|
function isInside(child, parent) {
|
|
@@ -3596,13 +3722,15 @@ function cmdVault(args) {
|
|
|
3596
3722
|
// here fabricates output: the fieldbook you see is what debrief/log actually
|
|
3597
3723
|
// wrote, so the demo cannot drift from the product.
|
|
3598
3724
|
const DEMO_SLUG = 'acme-payments'
|
|
3599
|
-
const DEMO_NOTES = `
|
|
3725
|
+
const DEMO_NOTES = `Fictional kickoff transcript - Acme payments, meeting 2026-09-10
|
|
3600
3726
|
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3727
|
+
We need read-only access to the payments repo and the last 90 days of audit logs. [source: meeting 2026-09-10]
|
|
3728
|
+
We agreed to settle on the existing Stripe connector instead of the in-house rewrite - Priya wants the Q3 audit clean first. [source: meeting 2026-09-10]
|
|
3729
|
+
Proposed scope: repair reconciliation alerts; leave the connector rewrite out pending sponsor confirmation. [source: meeting 2026-09-10]
|
|
3730
|
+
Risk: nobody can name who owns the reconciliation job; it has failed silently twice since March. [source: meeting 2026-09-10]
|
|
3731
|
+
Priya Shah signs off on the acceptance test. [source: meeting 2026-09-10]
|
|
3732
|
+
Priya is travelling for two weeks - Tom is the day-to-day contact. [source: meeting 2026-09-10]
|
|
3733
|
+
Next action: get the reconciliation runbook from Tom before touching anything. [source: meeting 2026-09-10]
|
|
3606
3734
|
|
|
3607
3735
|
<private>
|
|
3608
3736
|
Priya hinted the previous vendor was let go mid-contract. Do not repeat this to the team.
|
|
@@ -3617,14 +3745,17 @@ const DEMO_LAND_ARTIFACTS = {
|
|
|
3617
3745
|
|
|
3618
3746
|
**As stated:** clean up payment reconciliation before the Q3 audit.
|
|
3619
3747
|
**What we heard instead:** nobody owns the reconciliation job, and it fails silently.
|
|
3620
|
-
**
|
|
3748
|
+
**Proposed out of scope:** the in-house connector rewrite; sponsor confirmation is still required.
|
|
3621
3749
|
`,
|
|
3622
3750
|
'success.md': `# Success
|
|
3623
3751
|
|
|
3624
|
-
|
|
3625
|
-
|
|
3752
|
+
**Done when:** Replay a failed settlement in staging; its alert arrives at the on-call queue within 15 minutes.
|
|
3753
|
+
**Primary value bucket:** risk-mitigation
|
|
3754
|
+
**Baseline → target:** no reliable alert → an alert within 15 minutes of a simulated failure.
|
|
3755
|
+
**Explicitly out of scope:** connector rewrite (proposed; not customer-approved).
|
|
3756
|
+
**Stakeholder who signs off:** Priya Shah [source: meeting 2026-09-10]
|
|
3626
3757
|
|
|
3627
|
-
|
|
3758
|
+
Acceptance is pending. The named signer identifies authority, not an approval.
|
|
3628
3759
|
`,
|
|
3629
3760
|
}
|
|
3630
3761
|
|
|
@@ -3678,11 +3809,13 @@ function cmdDemo(args) {
|
|
|
3678
3809
|
fdeops demo - a fake client, real commands, nothing sent anywhere
|
|
3679
3810
|
|
|
3680
3811
|
Sandbox: ${root}
|
|
3681
|
-
Fake client: Acme (payments platform).
|
|
3812
|
+
Fake client: Acme (payments platform). This writes fictional notes, local Git history, and HTML inside the sandbox above.
|
|
3813
|
+
It resets that sandbox on repeat runs; real client records stay untouched.
|
|
3814
|
+
Allow under five minutes. Fictional proposal approval is automatic in this demo only.`)
|
|
3682
3815
|
|
|
3683
3816
|
demoStep('1. Monday of week 1 - create the fieldbook for this client', ['resume', '--init', DEMO_SLUG], workspace, env)
|
|
3684
3817
|
demoStep('2. You walk out of the kickoff with messy notes - hand them over', ['debrief', '--smart', notes], workspace, env)
|
|
3685
|
-
demoStep('3.
|
|
3818
|
+
demoStep('3. Demo automatically confirms the fictional record (not customer acceptance)', ['debrief', '--apply'], workspace, env)
|
|
3686
3819
|
demoStep('4. Say where you are in the engagement', ['log', 'phase', 'land'], workspace, env)
|
|
3687
3820
|
const engDir = path.join(root, DEMO_SLUG, '.fde')
|
|
3688
3821
|
console.log(`\n${demoHead('5. During land, @fde drafts the brief and the definition of done with you')}`)
|
|
@@ -3703,11 +3836,12 @@ function cmdDemo(args) {
|
|
|
3703
3836
|
// about uncommitted manual edits - correct behaviour, wrong lesson for a demo.
|
|
3704
3837
|
const landHash = commitMemory(engDir, 'land: brief + success', { files: [...Object.keys(DEMO_LAND_ARTIFACTS), 'context.md'] })
|
|
3705
3838
|
if (landHash) console.log(` memory @${landHash}`)
|
|
3706
|
-
demoStep('6.
|
|
3707
|
-
demoStep('7.
|
|
3708
|
-
demoStep('8.
|
|
3709
|
-
demoStep('9.
|
|
3710
|
-
demoStep('10.
|
|
3839
|
+
demoStep('6. Add a fictional measured result with evidence, still awaiting customer acceptance', ['log', 'delivery', 'Reconciliation alert | risk-mitigation | alert within 15 minutes | alert in 8 minutes | pending | PR#42 staging replay | revert alert rule'], workspace, env)
|
|
3840
|
+
demoStep('7. Two days later, the sponsor goes quiet', ['log', 'contact', 'Priya has not replied to two emails about the runbook', '--signal', 'amber'], workspace, env)
|
|
3841
|
+
demoStep('8. Next morning, a fresh agent session with no memory of any of this', ['resume'], workspace, env)
|
|
3842
|
+
demoStep('9. A meeting in ten minutes - what do you walk in knowing?', ['prep', 'sponsor check-in'], workspace, env)
|
|
3843
|
+
demoStep('10. Six weeks later: "we never agreed to drop the rewrite"', ['receipts', 'rewrite'], workspace, env)
|
|
3844
|
+
demoStep('11. The whole engagement on one page', ['dashboard'], workspace, env)
|
|
3711
3845
|
// cmdDashboard's default out path, computed rather than scraped from its output:
|
|
3712
3846
|
// a HOME with a space in it truncates any whitespace-delimited parse.
|
|
3713
3847
|
const html = path.join(root, 'fieldbook-current.html')
|
|
@@ -3716,8 +3850,10 @@ function cmdDemo(args) {
|
|
|
3716
3850
|
${demoHead('What just happened')}
|
|
3717
3851
|
|
|
3718
3852
|
- Every line above came from the real CLI - no canned output.
|
|
3719
|
-
- The kickoff notes became dated decisions, risks,
|
|
3720
|
-
|
|
3853
|
+
- The kickoff notes became proposed asks, scope, dated decisions, risks, and a
|
|
3854
|
+
next action. This demo applied fictional notes automatically after showing REVIEW.
|
|
3855
|
+
- PR#42 is fictional evidence for a measured result. The ledger still says claimed:
|
|
3856
|
+
naming Priya as signer does not mean she accepted the result.
|
|
3721
3857
|
- The <private> block in those notes never appears in resume, prep, receipts or
|
|
3722
3858
|
the dashboard - it is sealed in context.md and redacted from anything an agent
|
|
3723
3859
|
or a screen share can see.
|
|
@@ -3752,11 +3888,14 @@ function printUsage() {
|
|
|
3752
3888
|
fde ingest propose <id> smart-propose a staged item → .debrief-propose (confirm before apply)
|
|
3753
3889
|
fde ingest apply same as: fde debrief --apply
|
|
3754
3890
|
fde prep [label] grounded walk-in brief from existing .fde/ only
|
|
3755
|
-
fde doctor
|
|
3891
|
+
fde doctor [--ready] lint memory; --ready checks success before plan/build. Lint (stale signals, gaps). status/dashboard/resume print the same issues
|
|
3756
3892
|
fde redact <term> preview/remove lines containing a buried term (pass --apply to commit; subject never repeats the term)
|
|
3757
3893
|
fde tidy [--apply] propose consolidations; blesses hand-written dirty files when you apply
|
|
3758
3894
|
fde owner [set email] who keeps this engagement record
|
|
3759
|
-
fde
|
|
3895
|
+
fde recall <topic> bounded, redacted source excerpts (--max-bytes 4096..65536)
|
|
3896
|
+
fde receipts <term> source-backed records versus claims
|
|
3897
|
+
fde defend sponsor readout: accepted assertions, claims, sources, gaps
|
|
3898
|
+
fde handoff [--out file] portable redacted packet; stdout by default, new file only
|
|
3760
3899
|
fde status [--all] value ledger, then trust (pass --all for full portfolio)
|
|
3761
3900
|
fde dashboard [--all] [--open] [--out <path>] bound fieldbook (pass --all for every client)
|
|
3762
3901
|
fde vault derived Obsidian vault of every engagement (--current for one, --redacted for a shared screen, --out <dir>)
|
|
@@ -3776,18 +3915,21 @@ switch (cmd) {
|
|
|
3776
3915
|
case 'demo': cmdDemo(args); break
|
|
3777
3916
|
case 'scan': cmdScan(); break
|
|
3778
3917
|
case 'resume': cmdResume(args); break
|
|
3918
|
+
case 'recall': cmdRecall(args); break
|
|
3779
3919
|
case 'triage': cmdTriage(); break
|
|
3780
3920
|
case 'log': cmdLog(args); break
|
|
3781
3921
|
case 'debrief': cmdDebrief(args); break
|
|
3782
3922
|
case 'ingest': cmdIngest(args); break
|
|
3783
3923
|
case 'prep': cmdPrep(args); break
|
|
3784
|
-
case 'doctor': cmdDoctor(); break
|
|
3924
|
+
case 'doctor': cmdDoctor(args); break
|
|
3785
3925
|
case 'redact': cmdRedact(args); break
|
|
3786
3926
|
// `garden` was the name through 3.11.x; it keeps working.
|
|
3787
3927
|
case 'tidy':
|
|
3788
3928
|
case 'garden': cmdGarden(args); break
|
|
3789
3929
|
case 'owner': cmdOwner(args); break
|
|
3790
3930
|
case 'receipts': cmdReceipts(args); break
|
|
3931
|
+
case 'handoff': cmdHandoff(args); break
|
|
3932
|
+
case 'defend': cmdHandoff(args, 'Sponsor readout'); break
|
|
3791
3933
|
case 'capture': cmdCapture(); break
|
|
3792
3934
|
case 'preserve': cmdPreserve(); break
|
|
3793
3935
|
case 'status': cmdStatus(args); break
|