fdeops 3.24.0 → 3.26.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 +129 -181
- package/adapters/LOCAL-LLM.md +14 -102
- package/bin/check.js +3 -3
- package/bin/fde.js +310 -281
- package/bin/install.js +73 -50
- package/bin/lib/context.js +85 -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/render.js +41 -44
- package/bin/lib/value-ledger.js +34 -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 +1 -1
package/bin/fde.js
CHANGED
|
@@ -35,6 +35,7 @@ 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')
|
|
38
39
|
|
|
39
40
|
const HOME = os.homedir()
|
|
40
41
|
// FDEOPS_ENGAGEMENTS_ROOT isolates init/status/dashboard (and the registry) for
|
|
@@ -833,46 +834,49 @@ function valueLedgerRowCount(body) {
|
|
|
833
834
|
function appendValueLedgerRow(eng, cells) {
|
|
834
835
|
ensureMemoryGit(eng)
|
|
835
836
|
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
|
-
|
|
837
|
+
let row
|
|
838
|
+
withFileLock(p, () => {
|
|
839
|
+
let md = readEng(eng, 'delivery.md')
|
|
840
|
+
if (!md) md = '# Delivery log\n\n## Value ledger\n\n'
|
|
841
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
842
|
+
const cols = []
|
|
843
|
+
for (let i = 0; i < 7; i++) cols.push((cells[i] || '').replace(/\|/g, '\\|').trim() || ' ')
|
|
844
|
+
row = `| ${date} | ${cols.join(' | ')} |`
|
|
845
|
+
// Write into the same section the readers take: the last filled ## Value ledger.
|
|
846
|
+
// A row appended to the empty template heading above a filled one is a row no
|
|
847
|
+
// gate can see.
|
|
848
|
+
const lines = md.split('\n')
|
|
849
|
+
const sections = []
|
|
850
|
+
let cur = null
|
|
851
|
+
for (let i = 0; i < lines.length; i++) {
|
|
852
|
+
if (/^##\s+Value ledger\b/i.test(lines[i])) {
|
|
853
|
+
cur = { heading: i, lastTable: -1, filled: false, body: [] }
|
|
854
|
+
sections.push(cur)
|
|
855
|
+
continue
|
|
856
|
+
}
|
|
857
|
+
if (!cur) continue
|
|
858
|
+
if (/^##\s+/.test(lines[i])) { cur = null; continue }
|
|
859
|
+
cur.body.push(lines[i])
|
|
860
|
+
if (lines[i].trim()) cur.filled = true
|
|
861
|
+
if (/^\|/.test(lines[i].trim())) cur.lastTable = i
|
|
862
|
+
}
|
|
863
|
+
// Same choice parseValueLedger makes: the last section carrying rows, else the
|
|
864
|
+
// last with a body. A row written anywhere else is a row no gate can see.
|
|
865
|
+
const withRows = [...sections].reverse().find(s => valueLedgerRowCount(s.body.join('\n')))
|
|
866
|
+
const target = withRows || [...sections].reverse().find(s => s.filled) || sections[sections.length - 1]
|
|
867
|
+
if (!target) {
|
|
868
|
+
md = appendUnderSection(md, 'Value ledger', `${VALUE_LEDGER_HEADER}\n${VALUE_LEDGER_RULE}\n${row}`)
|
|
869
|
+
} else if (target.lastTable !== -1) {
|
|
870
|
+
lines.splice(target.lastTable + 1, 0, row)
|
|
871
|
+
md = lines.join('\n')
|
|
872
|
+
} else {
|
|
873
|
+
// No table under the chosen heading: a lone row would be read as the header
|
|
874
|
+
// line and the value would vanish. Lay the canonical table first.
|
|
875
|
+
lines.splice(target.heading + 1, 0, '', VALUE_LEDGER_HEADER, VALUE_LEDGER_RULE, row)
|
|
876
|
+
md = lines.join('\n')
|
|
853
877
|
}
|
|
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') })
|
|
878
|
+
atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
|
|
879
|
+
})
|
|
876
880
|
recordLastWrite(eng, 'delivery.md', row)
|
|
877
881
|
commitMemory(eng, 'log delivery', { files: ['delivery.md'] })
|
|
878
882
|
}
|
|
@@ -881,34 +885,36 @@ function retireOpenRisks(eng, needle) {
|
|
|
881
885
|
const n = String(needle || '').toLowerCase()
|
|
882
886
|
if (!n) return 0
|
|
883
887
|
const p = path.join(eng, 'risks.md')
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
888
|
+
return withFileLock(p, () => {
|
|
889
|
+
const md = readEng(eng, 'risks.md')
|
|
890
|
+
if (!md) return 0
|
|
891
|
+
const retired = []
|
|
892
|
+
const kept = []
|
|
893
|
+
let inRetired = false
|
|
894
|
+
for (const raw of md.split('\n')) {
|
|
895
|
+
const t = raw.trim()
|
|
896
|
+
if (/^#{1,6}\s+Retired\b/i.test(t)) { inRetired = true; kept.push(raw); continue }
|
|
897
|
+
if (!inRetired) {
|
|
898
|
+
const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
|
|
899
|
+
if (m && m[1].toLowerCase().includes(n)) {
|
|
900
|
+
retired.push(raw)
|
|
901
|
+
continue
|
|
902
|
+
}
|
|
897
903
|
}
|
|
904
|
+
kept.push(raw)
|
|
898
905
|
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
return retired.length
|
|
906
|
+
if (!retired.length) return 0
|
|
907
|
+
let out = kept.join('\n')
|
|
908
|
+
if (!/^#{1,6}\s+Retired\b/im.test(out)) out = out.replace(/\n*$/, '\n\n## Retired\n')
|
|
909
|
+
const stamp = new Date().toISOString().slice(0, 10)
|
|
910
|
+
const block = retired.map(l => {
|
|
911
|
+
const body = l.trim().replace(/^-\s*/, '')
|
|
912
|
+
return `- [${stamp}] (retired) ${body}`
|
|
913
|
+
}).join('\n')
|
|
914
|
+
out = appendUnderSection(out, 'Retired', block)
|
|
915
|
+
atomicWriteFile(p, out.endsWith('\n') ? out : out + '\n')
|
|
916
|
+
return retired.length
|
|
917
|
+
})
|
|
912
918
|
}
|
|
913
919
|
|
|
914
920
|
// Engagement age from the .fde/ directory's own birth time - hidden (not
|
|
@@ -1282,6 +1288,9 @@ function cmdScan() {
|
|
|
1282
1288
|
}
|
|
1283
1289
|
|
|
1284
1290
|
function cmdResume(args) {
|
|
1291
|
+
let maxBytes
|
|
1292
|
+
try { ({ args, maxBytes } = context.budgetArgs(args)) } catch (e) { console.error(e.message); process.exit(2) }
|
|
1293
|
+
|
|
1285
1294
|
const initIdx = args.indexOf('--init')
|
|
1286
1295
|
if (initIdx !== -1) {
|
|
1287
1296
|
const name = args[initIdx + 1]
|
|
@@ -1306,30 +1315,23 @@ function cmdResume(args) {
|
|
|
1306
1315
|
}
|
|
1307
1316
|
|
|
1308
1317
|
try {
|
|
1318
|
+
const installPaths = require('./lib/install-paths')
|
|
1319
|
+
installPaths.checkTree(tpl, fdeDir)
|
|
1309
1320
|
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)
|
|
1321
|
+
// Stage only the fieldbook. A pre-existing client folder can contain
|
|
1322
|
+
// contracts or source files that initialization must never remove.
|
|
1323
|
+
installPaths.mkdir(engRoot)
|
|
1324
|
+
const stagingRoot = fs.mkdtempSync(path.join(ENGAGEMENTS_ROOT, `.init-${slug}-`))
|
|
1325
|
+
const stagingFde = path.join(stagingRoot, '.fde')
|
|
1317
1326
|
try {
|
|
1318
|
-
fs.mkdirSync(stagingFde
|
|
1327
|
+
fs.mkdirSync(stagingFde)
|
|
1319
1328
|
fillTemplates(stagingFde)
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
fs.renameSync(stagingEng, engRoot)
|
|
1323
|
-
rmTreeQuiet(stagingRoot)
|
|
1324
|
-
} catch (e) {
|
|
1329
|
+
fs.renameSync(stagingFde, fdeDir)
|
|
1330
|
+
} finally {
|
|
1325
1331
|
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
1332
|
}
|
|
1331
1333
|
} else {
|
|
1332
|
-
// Re-init
|
|
1334
|
+
// Re-init only fills missing templates; existing records are preserved.
|
|
1333
1335
|
fillTemplates(fdeDir)
|
|
1334
1336
|
}
|
|
1335
1337
|
} catch (e) {
|
|
@@ -1394,17 +1396,24 @@ function cmdResume(args) {
|
|
|
1394
1396
|
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
1397
|
process.exit(2)
|
|
1396
1398
|
}
|
|
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.
|
|
1399
|
+
const intro = [resumeTriage(eng), ...hygieneTriageLines(eng), ...recordDigest(eng)].join('\n')
|
|
1405
1400
|
const ctx = readClean(eng, 'context.md')
|
|
1406
|
-
if (
|
|
1407
|
-
|
|
1401
|
+
if (args.includes('--full')) {
|
|
1402
|
+
console.log(`${intro}\n\nENGAGEMENT: ${eng}\n\n${ctx || '(no context.md yet)'}`)
|
|
1403
|
+
return
|
|
1404
|
+
}
|
|
1405
|
+
// Bound the complete command output, not only context.md's line count.
|
|
1406
|
+
// Separate allocations keep a long history from crowding out current goals.
|
|
1407
|
+
const policy = readClean(eng, 'trust-profile.md')
|
|
1408
|
+
const success = readClean(eng, 'success.md')
|
|
1409
|
+
const risks = readClean(eng, 'risks.md')
|
|
1410
|
+
process.stdout.write(context.boundedSections([
|
|
1411
|
+
`${intro}\n\nENGAGEMENT: ${eng}`,
|
|
1412
|
+
policy ? `CLIENT POLICY - trust-profile.md\n${policy}` : '',
|
|
1413
|
+
success ? `CURRENT GOALS & ACCEPTANCE - success.md\n${success}` : '',
|
|
1414
|
+
risks ? `RECORDED RISKS - risks.md\n${risks}` : '',
|
|
1415
|
+
`WORKING CONTEXT - context.md\n${ctx ? resumeView(ctx) : '(no context.md yet)'}`,
|
|
1416
|
+
], maxBytes))
|
|
1408
1417
|
}
|
|
1409
1418
|
|
|
1410
1419
|
// Token discipline: context.md grows every session (the session-stop hook
|
|
@@ -1412,18 +1421,18 @@ function cmdResume(args) {
|
|
|
1412
1421
|
// each day for less marginal signal. This returns a bounded view - the curated
|
|
1413
1422
|
// head (state / next action) plus the most recent activity - and hides the
|
|
1414
1423
|
// middle of the log behind `fde resume --full`. Pure code, zero model tokens.
|
|
1415
|
-
// The session-start hook
|
|
1424
|
+
// The session-start hook consumes this same view through fde resume.
|
|
1416
1425
|
function resumeView(md) {
|
|
1417
1426
|
const lines = md.split('\n')
|
|
1418
1427
|
// A file ending in "\n" yields a trailing "" here; drop it so the line count
|
|
1419
|
-
//
|
|
1428
|
+
// counts content lines rather than the terminal newline.
|
|
1420
1429
|
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
1421
1430
|
if (lines.length <= 160) return md
|
|
1422
1431
|
// Anchor on the "## Session end" heading, NOT the "<!-- fdeops auto-capture -->"
|
|
1423
1432
|
// comment: this text is read via readClean (stripPrivate strips HTML comments),
|
|
1424
1433
|
// so the comment is gone by the time we get here. The heading is written on the
|
|
1425
1434
|
// very next line by cmdCapture and the session-stop hook and survives redaction.
|
|
1426
|
-
//
|
|
1435
|
+
// Bound after sanitation so removed comments cannot shift the anchor.
|
|
1427
1436
|
let headEnd = lines.findIndex(l => /^##\s+Session end\b/.test(l.trim()))
|
|
1428
1437
|
if (headEnd === -1) headEnd = 120
|
|
1429
1438
|
headEnd = Math.min(headEnd, 120)
|
|
@@ -1440,25 +1449,35 @@ function cmdLogUndo() {
|
|
|
1440
1449
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1441
1450
|
const metaPath = path.join(eng, LAST_WRITE)
|
|
1442
1451
|
let meta
|
|
1443
|
-
try {
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1452
|
+
try {
|
|
1453
|
+
withFileLock(metaPath, () => {
|
|
1454
|
+
try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) } catch (_) {
|
|
1455
|
+
throw new Error('nothing to undo - no prior fde log/debrief write recorded')
|
|
1456
|
+
}
|
|
1457
|
+
if (!Object.values(LOG_FILES).includes(meta.file) || !meta.entry) {
|
|
1458
|
+
throw new Error('corrupt .last-write - cannot undo')
|
|
1459
|
+
}
|
|
1460
|
+
const target = path.join(eng, meta.file)
|
|
1461
|
+
withFileLock(target, () => {
|
|
1462
|
+
const after = removeExactEntryLine(readEng(eng, meta.file), meta.entry)
|
|
1463
|
+
if (after == null) {
|
|
1464
|
+
throw new Error(`cannot undo - entry no longer in ${meta.file} (edited by hand?). Remove it manually.`)
|
|
1465
|
+
}
|
|
1466
|
+
atomicWriteFile(target, after.endsWith('\n') ? after : after + '\n', { soft: true })
|
|
1467
|
+
}, { soft: true })
|
|
1468
|
+
if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) {
|
|
1469
|
+
const ledgerPath = path.join(eng, SIGNAL_LEDGER)
|
|
1470
|
+
withFileLock(ledgerPath, () => {
|
|
1471
|
+
const led = removeExactEntryLine(readEng(eng, SIGNAL_LEDGER), meta.entry)
|
|
1472
|
+
if (led != null) atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n', { soft: true })
|
|
1473
|
+
}, { soft: true })
|
|
1474
|
+
}
|
|
1475
|
+
try { fs.unlinkSync(metaPath) } catch (_) {}
|
|
1476
|
+
})
|
|
1477
|
+
} catch (e) {
|
|
1478
|
+
console.error(e.message)
|
|
1453
1479
|
process.exit(1)
|
|
1454
1480
|
}
|
|
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
1481
|
const undoFiles = [meta.file]
|
|
1463
1482
|
if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) undoFiles.push(SIGNAL_LEDGER)
|
|
1464
1483
|
const hash = commitMemory(eng, `undo ${meta.file}`, { files: undoFiles })
|
|
@@ -1536,18 +1555,20 @@ function cmdLog(args) {
|
|
|
1536
1555
|
function setContextPhase(eng, phase) {
|
|
1537
1556
|
ensureMemoryGit(eng)
|
|
1538
1557
|
const p = path.join(eng, 'context.md')
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1558
|
+
withFileLock(p, () => {
|
|
1559
|
+
let md = readEng(eng, 'context.md')
|
|
1560
|
+
if (!md) md = '# Engagement context\n\n'
|
|
1561
|
+
if (/\*\*Phase:\*\*/i.test(md)) {
|
|
1562
|
+
md = md.replace(/\*\*Phase:\*\*\s*.*/i, `**Phase:** ${phase}`)
|
|
1563
|
+
} else {
|
|
1564
|
+
md = md.replace(/\n*$/, `\n\n**Phase:** ${phase}\n`)
|
|
1565
|
+
}
|
|
1566
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
1567
|
+
if (/\*\*Last updated:\*\*/i.test(md)) {
|
|
1568
|
+
md = md.replace(/\*\*Last updated:\*\*\s*.*/i, `**Last updated:** ${today}`)
|
|
1569
|
+
}
|
|
1570
|
+
atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
|
|
1571
|
+
})
|
|
1551
1572
|
const hash = commitMemory(eng, `phase ${phase}`, { files: ['context.md'] })
|
|
1552
1573
|
// Proactive warn at the moment it matters - don't wait for Monday hygiene.
|
|
1553
1574
|
if (phase === 'ship' || phase === 'close') {
|
|
@@ -1674,57 +1695,61 @@ function signerFromLine(text) {
|
|
|
1674
1695
|
function setSigner(eng, who) {
|
|
1675
1696
|
ensureMemoryGit(eng)
|
|
1676
1697
|
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
|
-
|
|
1698
|
+
return withFileLock(p, () => {
|
|
1699
|
+
let md = readEng(eng, 'success.md')
|
|
1700
|
+
if (!md) md = '# Success definition\n\n'
|
|
1701
|
+
const norm = (s) => String(s).replace(/\s+/g, ' ').trim().toLowerCase()
|
|
1702
|
+
// [^\S\n], not \s: \s crosses newlines, so an empty field captured the next
|
|
1703
|
+
// line - and setSigner then read a filled field and filed the name as "also
|
|
1704
|
+
// named" under whatever heading followed.
|
|
1705
|
+
const line = /^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m
|
|
1706
|
+
const m = md.match(line)
|
|
1707
|
+
if (m && !m[1].trim()) {
|
|
1708
|
+
md = md.replace(line, `**Stakeholder who signs off:** ${who}`)
|
|
1709
|
+
} else if (m) {
|
|
1710
|
+
// Whole-name compare against the primary and every "also named" line under
|
|
1711
|
+
// it: "Sam" must not vanish inside "Samantha", and re-applying must not
|
|
1712
|
+
// stack duplicates.
|
|
1713
|
+
const start = md.indexOf(m[0]) + m[0].length
|
|
1714
|
+
const alsoNamed = []
|
|
1715
|
+
for (const l of md.slice(start).split('\n').slice(1)) {
|
|
1716
|
+
const a = l.match(/^- also named:\s*(.+)$/)
|
|
1717
|
+
if (!a) break
|
|
1718
|
+
alsoNamed.push(a[1])
|
|
1719
|
+
}
|
|
1720
|
+
const known = [m[1], ...alsoNamed].map(norm)
|
|
1721
|
+
if (known.includes(norm(who))) return false
|
|
1722
|
+
// A second, different name is a fact worth keeping next to the first, not
|
|
1723
|
+
// a silent overwrite - who signs is exactly the thing people argue about.
|
|
1724
|
+
const block = [m[0], ...alsoNamed.map(a => `- also named: ${a}`)].join('\n')
|
|
1725
|
+
md = md.replace(block, `${block}\n- also named: ${who}`)
|
|
1726
|
+
} else {
|
|
1727
|
+
md = md.replace(/\n*$/, `\n\n**Stakeholder who signs off:** ${who}\n`)
|
|
1728
|
+
}
|
|
1729
|
+
atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
|
|
1730
|
+
return true
|
|
1731
|
+
})
|
|
1709
1732
|
}
|
|
1710
1733
|
|
|
1711
1734
|
function setNextAction(eng, text) {
|
|
1712
1735
|
ensureMemoryGit(eng)
|
|
1713
1736
|
const bullet = `- ${stripControlChars(String(text).replace(/^[-*]\s+/, '').trim())}`
|
|
1714
1737
|
const p = path.join(eng, 'context.md')
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1738
|
+
withFileLock(p, () => {
|
|
1739
|
+
let md = readEng(eng, 'context.md')
|
|
1740
|
+
if (!md) md = '# Engagement context\n\n'
|
|
1741
|
+
// Collapse duplicate ## Next action headings (skill-append trap) into one.
|
|
1742
|
+
md = stripAllSections(md, 'Next action')
|
|
1743
|
+
if (/^##\s+Current state\b/im.test(md)) {
|
|
1744
|
+
md = md.replace(
|
|
1745
|
+
/(^##\s+Current state\b[^\n]*\n)([\s\S]*?)(?=^##\s|\s*$)/im,
|
|
1746
|
+
(_, h, body) => `${h}${String(body).replace(/\n*$/, '\n')}\n## Next action\n\n${bullet}\n\n`
|
|
1747
|
+
)
|
|
1748
|
+
} else {
|
|
1749
|
+
md = md.replace(/\n*$/, `\n\n## Next action\n\n${bullet}\n`)
|
|
1750
|
+
}
|
|
1751
|
+
atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
|
|
1752
|
+
})
|
|
1728
1753
|
}
|
|
1729
1754
|
|
|
1730
1755
|
function looksLikeBinaryNoise(text) {
|
|
@@ -2300,6 +2325,23 @@ function cmdReceipts(args) {
|
|
|
2300
2325
|
}
|
|
2301
2326
|
}
|
|
2302
2327
|
|
|
2328
|
+
function cmdRecall(args) {
|
|
2329
|
+
let maxBytes
|
|
2330
|
+
try { ({ args, maxBytes } = context.budgetArgs(args)) } catch (e) { console.error(e.message); process.exit(2) }
|
|
2331
|
+
const query = args.join(' ').trim()
|
|
2332
|
+
if (!query || Buffer.byteLength(query) > 2048 || args.some(a => a.startsWith('--'))) {
|
|
2333
|
+
console.error('usage: fde recall <topic> [--max-bytes 4096..65536]'); process.exit(2)
|
|
2334
|
+
}
|
|
2335
|
+
const eng = resolveEngagement()
|
|
2336
|
+
if (!eng) { console.error('no engagement - bind a client before recall'); process.exit(2) }
|
|
2337
|
+
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']
|
|
2338
|
+
const result = context.recallSections(files.map(file => ({ file, text: readClean(eng, file) })), query)
|
|
2339
|
+
process.stdout.write(context.boundedSections([
|
|
2340
|
+
`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.`,
|
|
2341
|
+
...result.sections,
|
|
2342
|
+
], maxBytes))
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2303
2345
|
function cmdCapture() {
|
|
2304
2346
|
const eng = resolveEngagement({ forWrite: true })
|
|
2305
2347
|
if (!eng) process.exit(0) // silent: capture must never break a session
|
|
@@ -2759,13 +2801,8 @@ function hasValueBucket(eng) {
|
|
|
2759
2801
|
return false
|
|
2760
2802
|
}
|
|
2761
2803
|
|
|
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
|
|
2804
|
+
// Shared classification keeps CLI, dashboard, and vault acceptance consistent.
|
|
2805
|
+
const { PENDING_CELL_RE, valueState } = require('./lib/value-ledger')
|
|
2769
2806
|
|
|
2770
2807
|
function parseValueLedger(eng) {
|
|
2771
2808
|
// Last section with actual rows, not merely the last non-empty one: a template
|
|
@@ -2780,7 +2817,8 @@ function parseValueLedger(eng) {
|
|
|
2780
2817
|
slice: colIndex(table.headers, /slice/i),
|
|
2781
2818
|
promised: colIndex(table.headers, /promised/i),
|
|
2782
2819
|
measured: colIndex(table.headers, /measured/i),
|
|
2783
|
-
accepted: colIndex(table.headers, /
|
|
2820
|
+
accepted: colIndex(table.headers, /accepted by/i),
|
|
2821
|
+
acceptanceStatus: colIndex(table.headers, /^acceptance status$/i),
|
|
2784
2822
|
evidence: colIndex(table.headers, /evidence/i),
|
|
2785
2823
|
}
|
|
2786
2824
|
const cell = (row, i) => (i === -1 ? '' : String(row[i] || '').trim())
|
|
@@ -2791,12 +2829,9 @@ function parseValueLedger(eng) {
|
|
|
2791
2829
|
const measured = cell(row, idx.measured)
|
|
2792
2830
|
const accepted = cell(row, idx.accepted)
|
|
2793
2831
|
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
2832
|
const evidence = cell(row, idx.evidence)
|
|
2833
|
+
const acceptanceStatus = idx.acceptanceStatus === -1 ? undefined : cell(row, idx.acceptanceStatus)
|
|
2834
|
+
const state = valueState({ measured, accepted, acceptanceStatus, evidence })
|
|
2800
2835
|
rows.push({ slice, promised, measured, accepted, evidence, evidenceMissing: !evidence || PENDING_CELL_RE.test(evidence), state })
|
|
2801
2836
|
}
|
|
2802
2837
|
return { rows, columnMissing: idx.accepted === -1 }
|
|
@@ -2990,19 +3025,15 @@ function cmdRedact(args) {
|
|
|
2990
3025
|
}
|
|
2991
3026
|
ensureMemoryGit(eng)
|
|
2992
3027
|
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) {
|
|
3028
|
+
for (const file of new Set(hits.map(h => h.file))) {
|
|
2999
3029
|
const abs = path.join(eng, file)
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3030
|
+
withFileLock(abs, () => {
|
|
3031
|
+
const before = readEng(eng, file)
|
|
3032
|
+
const after = before.split('\n').filter(line => !line.toLowerCase().includes(needle)).join('\n')
|
|
3033
|
+
if (after === before) return
|
|
3034
|
+
atomicWriteFile(abs, after.endsWith('\n') || after === '' ? after : after + '\n')
|
|
3035
|
+
touched.push(file)
|
|
3036
|
+
})
|
|
3006
3037
|
}
|
|
3007
3038
|
if (!touched.length) {
|
|
3008
3039
|
console.log('nothing changed')
|
|
@@ -3153,37 +3184,45 @@ function cmdGarden(args) {
|
|
|
3153
3184
|
continue
|
|
3154
3185
|
}
|
|
3155
3186
|
if (p.id !== 'archive-sessions') continue
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3187
|
+
let archived
|
|
3188
|
+
try {
|
|
3189
|
+
archived = withFileLock(path.join(eng, 'context.md'), () => {
|
|
3190
|
+
const cutDates = new Set(p.sessionBlocks.map(b => b.date))
|
|
3191
|
+
const keep = []
|
|
3192
|
+
const archive = []
|
|
3193
|
+
let mode = 'keep'
|
|
3194
|
+
let buf = []
|
|
3195
|
+
const flush = () => {
|
|
3196
|
+
if (!buf.length) return
|
|
3197
|
+
;(mode === 'archive' ? archive : keep).push(...buf)
|
|
3198
|
+
buf = []
|
|
3199
|
+
}
|
|
3200
|
+
for (const line of readEng(eng, 'context.md').split('\n')) {
|
|
3201
|
+
const m = line.match(/^##\s+Session end\s+-\s+(\d{4}-\d{2}-\d{2})\b/)
|
|
3202
|
+
if (m) {
|
|
3203
|
+
flush()
|
|
3204
|
+
mode = cutDates.has(m[1]) ? 'archive' : 'keep'
|
|
3205
|
+
} else if (/^##\s+/.test(line) && mode === 'archive') {
|
|
3206
|
+
flush()
|
|
3207
|
+
mode = 'keep'
|
|
3208
|
+
}
|
|
3209
|
+
buf.push(line)
|
|
3210
|
+
}
|
|
3172
3211
|
flush()
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3212
|
+
if (!archive.length) return false
|
|
3213
|
+
const archPath = path.join(eng, 'context-archive.md')
|
|
3214
|
+
withFileLock(archPath, () => {
|
|
3215
|
+
const prev = readEng(eng, 'context-archive.md') || '# Context archive\n\n'
|
|
3216
|
+
atomicWriteFile(archPath, prev.replace(/\n*$/, '\n\n') + archive.join('\n').trim() + '\n', { soft: true })
|
|
3217
|
+
}, { soft: true })
|
|
3218
|
+
atomicWriteFile(path.join(eng, 'context.md'), keep.join('\n').replace(/\n*$/, '\n'), { soft: true })
|
|
3219
|
+
return true
|
|
3220
|
+
}, { soft: true })
|
|
3221
|
+
} catch (e) {
|
|
3222
|
+
console.error(`tidy archive: ${e.message}`)
|
|
3223
|
+
process.exit(1)
|
|
3224
|
+
}
|
|
3225
|
+
if (!archived) continue
|
|
3187
3226
|
applied++
|
|
3188
3227
|
touched.add('context.md')
|
|
3189
3228
|
touched.add('context-archive.md')
|
|
@@ -3197,47 +3236,49 @@ function cmdGarden(args) {
|
|
|
3197
3236
|
// Keep the first open-risk bullet per fingerprint; move later echoes under ## Retired.
|
|
3198
3237
|
function applyRiskDedupe(eng, clusters) {
|
|
3199
3238
|
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++
|
|
3239
|
+
return withFileLock(p, () => {
|
|
3240
|
+
let md = readEng(eng, 'risks.md')
|
|
3241
|
+
if (!md) return 0
|
|
3242
|
+
const echoTexts = new Set()
|
|
3243
|
+
for (const group of clusters) {
|
|
3244
|
+
for (let i = 1; i < group.length; i++) echoTexts.add(group[i])
|
|
3245
|
+
}
|
|
3246
|
+
if (!echoTexts.size) return 0
|
|
3247
|
+
const retiredLines = []
|
|
3248
|
+
const kept = []
|
|
3249
|
+
let inRetired = false
|
|
3250
|
+
let moved = 0
|
|
3251
|
+
for (const raw of md.split('\n')) {
|
|
3252
|
+
const t = raw.trim()
|
|
3253
|
+
if (/^#{1,6}\s+Retired\b/i.test(t)) {
|
|
3254
|
+
inRetired = true
|
|
3255
|
+
kept.push(raw)
|
|
3223
3256
|
continue
|
|
3224
3257
|
}
|
|
3258
|
+
if (!inRetired) {
|
|
3259
|
+
const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
|
|
3260
|
+
if (m && echoTexts.has(m[1].trim())) {
|
|
3261
|
+
retiredLines.push(raw)
|
|
3262
|
+
moved++
|
|
3263
|
+
continue
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
kept.push(raw)
|
|
3225
3267
|
}
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
return moved
|
|
3268
|
+
if (!moved) return 0
|
|
3269
|
+
let out = kept.join('\n')
|
|
3270
|
+
if (!/^#{1,6}\s+Retired\b/im.test(out)) {
|
|
3271
|
+
out = out.replace(/\n*$/, '\n\n## Retired\n')
|
|
3272
|
+
}
|
|
3273
|
+
const stamp = new Date().toISOString().slice(0, 10)
|
|
3274
|
+
const block = retiredLines.map(l => {
|
|
3275
|
+
const body = l.trim().replace(/^-\s*/, '')
|
|
3276
|
+
return `- [${stamp}] (tidy dedupe) ${body}`
|
|
3277
|
+
}).join('\n')
|
|
3278
|
+
out = appendUnderSection(out, 'Retired', block)
|
|
3279
|
+
atomicWriteFile(p, out.endsWith('\n') ? out : out + '\n')
|
|
3280
|
+
return moved
|
|
3281
|
+
})
|
|
3241
3282
|
}
|
|
3242
3283
|
|
|
3243
3284
|
function engagementSlugFromPath(eng) {
|
|
@@ -3419,24 +3460,10 @@ function cliVersion() {
|
|
|
3419
3460
|
}
|
|
3420
3461
|
|
|
3421
3462
|
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
|
|
3463
|
+
return parseValueLedger(eng).rows.map(row => ({
|
|
3464
|
+
slice: row.slice, promised: row.promised,
|
|
3465
|
+
acceptedBy: row.state === 'accepted' ? row.accepted : '',
|
|
3466
|
+
}))
|
|
3440
3467
|
}
|
|
3441
3468
|
|
|
3442
3469
|
function isInside(child, parent) {
|
|
@@ -3756,6 +3783,7 @@ function printUsage() {
|
|
|
3756
3783
|
fde redact <term> preview/remove lines containing a buried term (pass --apply to commit; subject never repeats the term)
|
|
3757
3784
|
fde tidy [--apply] propose consolidations; blesses hand-written dirty files when you apply
|
|
3758
3785
|
fde owner [set email] who keeps this engagement record
|
|
3786
|
+
fde recall <topic> bounded, redacted source excerpts (--max-bytes 4096..65536)
|
|
3759
3787
|
fde receipts <term> "what did we agree?" with dates
|
|
3760
3788
|
fde status [--all] value ledger, then trust (pass --all for full portfolio)
|
|
3761
3789
|
fde dashboard [--all] [--open] [--out <path>] bound fieldbook (pass --all for every client)
|
|
@@ -3776,6 +3804,7 @@ switch (cmd) {
|
|
|
3776
3804
|
case 'demo': cmdDemo(args); break
|
|
3777
3805
|
case 'scan': cmdScan(); break
|
|
3778
3806
|
case 'resume': cmdResume(args); break
|
|
3807
|
+
case 'recall': cmdRecall(args); break
|
|
3779
3808
|
case 'triage': cmdTriage(); break
|
|
3780
3809
|
case 'log': cmdLog(args); break
|
|
3781
3810
|
case 'debrief': cmdDebrief(args); break
|