fdeops 3.23.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 +60 -153
- package/adapters/LOCAL-LLM.md +14 -102
- package/bin/check.js +3 -3
- package/bin/fde.js +334 -292
- package/bin/install.js +73 -50
- package/bin/lib/context.js +85 -0
- package/bin/lib/fieldbook-client.js +197 -0
- package/bin/lib/font-css.js +115 -0
- package/bin/lib/install-paths.js +53 -0
- package/bin/lib/render.js +165 -207
- package/bin/lib/trust.js +27 -4
- 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 +10 -6
- package/skills/fde/references/dashboard.md +5 -5
- package/skills/fde/references/debrief.md +10 -4
- package/skills/fde/references/discover.md +5 -1
- package/skills/fde/references/earn-trust.md +1 -1
- package/skills/fde/references/ingest.md +1 -1
- package/skills/fde/references/land.md +6 -6
- package/skills/fde/references/pick-three.md +1 -1
- package/skills/fde/references/plan.md +8 -0
- package/skills/fde/references/poc.md +1 -1
- package/skills/fde/references/readout.md +4 -2
- package/skills/fde/references/review.md +1 -1
- package/skills/fde/references/score-use-cases.md +1 -1
- package/skills/fde/references/ship.md +4 -2
- package/skills/fde/references/switch-clients.md +2 -2
package/bin/fde.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* fde capture session-end snapshot → context.md (hooks use this)
|
|
26
26
|
* fde preserve pre-compaction context snapshot (hook-internal; hooks use this)
|
|
27
27
|
* fde status [--all] value ledger first, then trust (pass --all for portfolio)
|
|
28
|
-
* fde dashboard [--all]
|
|
28
|
+
* fde dashboard [--all] [--open] [--out <path>] bound fieldbook, or all (--all)
|
|
29
29
|
* fde vault derived Obsidian vault of the fieldbook (disposable; --redacted)
|
|
30
30
|
*/
|
|
31
31
|
const fs = require('fs')
|
|
@@ -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
|
|
@@ -799,7 +800,7 @@ function firstLine(md, maxLen) {
|
|
|
799
800
|
// not carry Working theory / Evidence / Differs from brief, do not scrape a
|
|
800
801
|
// first line that might be the inherited brief and label it truth.
|
|
801
802
|
function parseReality(md, maxLen) {
|
|
802
|
-
const theory = (md.match(/\*\*Working theory
|
|
803
|
+
const theory = (md.match(/\*\*Working theory:\*\*[^\S\r\n]*(.*)/i) || [])[1]
|
|
803
804
|
const hasSchema = /\*\*(Working theory|Evidence|Differs from brief how):\*\*/i.test(md)
|
|
804
805
|
const theoryText = (theory || '').trim()
|
|
805
806
|
if (theoryText) {
|
|
@@ -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
|
|
@@ -961,6 +967,7 @@ function colIndex(headers, rx) { return headers.findIndex(h => rx.test(h)) }
|
|
|
961
967
|
const {
|
|
962
968
|
personFromSignalText,
|
|
963
969
|
signalSubjectKey,
|
|
970
|
+
isSignalNameNoise,
|
|
964
971
|
nextActionLine,
|
|
965
972
|
computeSignals,
|
|
966
973
|
resumeTriage,
|
|
@@ -1003,10 +1010,14 @@ function displayNameFromSignalText(text) {
|
|
|
1003
1010
|
const person = personFromSignalText(text)
|
|
1004
1011
|
if (person) return person
|
|
1005
1012
|
const t = String(text).trim()
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1013
|
+
const words = t.split(/\s+/).filter(w => {
|
|
1014
|
+
const bare = w.replace(/[^A-Za-z0-9.-]/g, '')
|
|
1015
|
+
return bare.length >= 2 && !isSignalNameNoise(bare) && !/^(dr|mr|mrs|ms)\.?$/i.test(bare)
|
|
1016
|
+
})
|
|
1017
|
+
if (!words.length) return ''
|
|
1018
|
+
const proper = words.find(w => /^[A-Z][a-z]+(?:\s+[A-Z][a-z]+)?$/.test(w) || /^[A-Z]{2,}$/.test(w))
|
|
1019
|
+
if (proper && !isSignalNameNoise(proper)) return proper.replace(/[^A-Za-z0-9. -]/g, '')
|
|
1020
|
+
return words.slice(0, 2).join(' ').replace(/[^A-Za-z0-9. -]/g, '')
|
|
1010
1021
|
}
|
|
1011
1022
|
|
|
1012
1023
|
// Stakeholders for prep/dashboard: table rows PLUS people who only appear in
|
|
@@ -1063,8 +1074,10 @@ function extractStakeholders(eng) {
|
|
|
1063
1074
|
const cur = byKey.get(key)
|
|
1064
1075
|
byKey.set(key, { ...cur, signal: h.signal, note: cur.note || h.text.slice(0, 80) })
|
|
1065
1076
|
} else {
|
|
1077
|
+
const name = displayNameFromSignalText(h.text)
|
|
1078
|
+
if (!name || isSignalNameNoise(name)) continue
|
|
1066
1079
|
byKey.set(key, {
|
|
1067
|
-
name
|
|
1080
|
+
name,
|
|
1068
1081
|
role: '',
|
|
1069
1082
|
note: h.text.slice(0, 80),
|
|
1070
1083
|
signal: h.signal,
|
|
@@ -1275,6 +1288,9 @@ function cmdScan() {
|
|
|
1275
1288
|
}
|
|
1276
1289
|
|
|
1277
1290
|
function cmdResume(args) {
|
|
1291
|
+
let maxBytes
|
|
1292
|
+
try { ({ args, maxBytes } = context.budgetArgs(args)) } catch (e) { console.error(e.message); process.exit(2) }
|
|
1293
|
+
|
|
1278
1294
|
const initIdx = args.indexOf('--init')
|
|
1279
1295
|
if (initIdx !== -1) {
|
|
1280
1296
|
const name = args[initIdx + 1]
|
|
@@ -1299,30 +1315,23 @@ function cmdResume(args) {
|
|
|
1299
1315
|
}
|
|
1300
1316
|
|
|
1301
1317
|
try {
|
|
1318
|
+
const installPaths = require('./lib/install-paths')
|
|
1319
|
+
installPaths.checkTree(tpl, fdeDir)
|
|
1302
1320
|
if (!existed) {
|
|
1303
|
-
//
|
|
1304
|
-
//
|
|
1305
|
-
|
|
1306
|
-
const stagingRoot = path.join(ENGAGEMENTS_ROOT, `.init-${slug}
|
|
1307
|
-
const
|
|
1308
|
-
const stagingFde = path.join(stagingEng, '.fde')
|
|
1309
|
-
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')
|
|
1310
1326
|
try {
|
|
1311
|
-
fs.mkdirSync(stagingFde
|
|
1327
|
+
fs.mkdirSync(stagingFde)
|
|
1312
1328
|
fillTemplates(stagingFde)
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
fs.renameSync(stagingEng, engRoot)
|
|
1329
|
+
fs.renameSync(stagingFde, fdeDir)
|
|
1330
|
+
} finally {
|
|
1316
1331
|
rmTreeQuiet(stagingRoot)
|
|
1317
|
-
} catch (e) {
|
|
1318
|
-
rmTreeQuiet(stagingRoot)
|
|
1319
|
-
if (fs.existsSync(engRoot) && !fs.existsSync(path.join(engRoot, '.fde', 'context.md'))) {
|
|
1320
|
-
rmTreeQuiet(engRoot)
|
|
1321
|
-
}
|
|
1322
|
-
failFs(e, 'create engagement', engRoot)
|
|
1323
1332
|
}
|
|
1324
1333
|
} else {
|
|
1325
|
-
// Re-init
|
|
1334
|
+
// Re-init only fills missing templates; existing records are preserved.
|
|
1326
1335
|
fillTemplates(fdeDir)
|
|
1327
1336
|
}
|
|
1328
1337
|
} catch (e) {
|
|
@@ -1387,17 +1396,24 @@ function cmdResume(args) {
|
|
|
1387
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.`)
|
|
1388
1397
|
process.exit(2)
|
|
1389
1398
|
}
|
|
1390
|
-
|
|
1391
|
-
// (sponsor / promise / decisions), then the session log.
|
|
1392
|
-
printTriageBlock(eng)
|
|
1393
|
-
const digest = recordDigest(eng)
|
|
1394
|
-
if (digest.length) console.log('\n' + digest.join('\n'))
|
|
1395
|
-
console.log(`\nENGAGEMENT: ${eng}\n`)
|
|
1396
|
-
// readClean, not fs.readFileSync: this output is what an agent loads as
|
|
1397
|
-
// context, so it goes through the same <private> redaction as the dashboard.
|
|
1399
|
+
const intro = [resumeTriage(eng), ...hygieneTriageLines(eng), ...recordDigest(eng)].join('\n')
|
|
1398
1400
|
const ctx = readClean(eng, 'context.md')
|
|
1399
|
-
if (
|
|
1400
|
-
|
|
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))
|
|
1401
1417
|
}
|
|
1402
1418
|
|
|
1403
1419
|
// Token discipline: context.md grows every session (the session-stop hook
|
|
@@ -1405,18 +1421,18 @@ function cmdResume(args) {
|
|
|
1405
1421
|
// each day for less marginal signal. This returns a bounded view - the curated
|
|
1406
1422
|
// head (state / next action) plus the most recent activity - and hides the
|
|
1407
1423
|
// middle of the log behind `fde resume --full`. Pure code, zero model tokens.
|
|
1408
|
-
// The session-start hook
|
|
1424
|
+
// The session-start hook consumes this same view through fde resume.
|
|
1409
1425
|
function resumeView(md) {
|
|
1410
1426
|
const lines = md.split('\n')
|
|
1411
1427
|
// A file ending in "\n" yields a trailing "" here; drop it so the line count
|
|
1412
|
-
//
|
|
1428
|
+
// counts content lines rather than the terminal newline.
|
|
1413
1429
|
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
1414
1430
|
if (lines.length <= 160) return md
|
|
1415
1431
|
// Anchor on the "## Session end" heading, NOT the "<!-- fdeops auto-capture -->"
|
|
1416
1432
|
// comment: this text is read via readClean (stripPrivate strips HTML comments),
|
|
1417
1433
|
// so the comment is gone by the time we get here. The heading is written on the
|
|
1418
1434
|
// very next line by cmdCapture and the session-stop hook and survives redaction.
|
|
1419
|
-
//
|
|
1435
|
+
// Bound after sanitation so removed comments cannot shift the anchor.
|
|
1420
1436
|
let headEnd = lines.findIndex(l => /^##\s+Session end\b/.test(l.trim()))
|
|
1421
1437
|
if (headEnd === -1) headEnd = 120
|
|
1422
1438
|
headEnd = Math.min(headEnd, 120)
|
|
@@ -1433,25 +1449,35 @@ function cmdLogUndo() {
|
|
|
1433
1449
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1434
1450
|
const metaPath = path.join(eng, LAST_WRITE)
|
|
1435
1451
|
let meta
|
|
1436
|
-
try {
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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)
|
|
1446
1479
|
process.exit(1)
|
|
1447
1480
|
}
|
|
1448
|
-
withFileLock(target, () => { atomicWriteFile(target, after.endsWith('\n') ? after : after + '\n') })
|
|
1449
|
-
if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) {
|
|
1450
|
-
const ledgerPath = path.join(eng, SIGNAL_LEDGER)
|
|
1451
|
-
const led = removeExactEntryLine(readEng(eng, SIGNAL_LEDGER), meta.entry)
|
|
1452
|
-
if (led != null) withFileLock(ledgerPath, () => { atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n') })
|
|
1453
|
-
}
|
|
1454
|
-
try { fs.unlinkSync(metaPath) } catch (_) {}
|
|
1455
1481
|
const undoFiles = [meta.file]
|
|
1456
1482
|
if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) undoFiles.push(SIGNAL_LEDGER)
|
|
1457
1483
|
const hash = commitMemory(eng, `undo ${meta.file}`, { files: undoFiles })
|
|
@@ -1529,18 +1555,20 @@ function cmdLog(args) {
|
|
|
1529
1555
|
function setContextPhase(eng, phase) {
|
|
1530
1556
|
ensureMemoryGit(eng)
|
|
1531
1557
|
const p = path.join(eng, 'context.md')
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
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
|
+
})
|
|
1544
1572
|
const hash = commitMemory(eng, `phase ${phase}`, { files: ['context.md'] })
|
|
1545
1573
|
// Proactive warn at the moment it matters - don't wait for Monday hygiene.
|
|
1546
1574
|
if (phase === 'ship' || phase === 'close') {
|
|
@@ -1667,57 +1695,61 @@ function signerFromLine(text) {
|
|
|
1667
1695
|
function setSigner(eng, who) {
|
|
1668
1696
|
ensureMemoryGit(eng)
|
|
1669
1697
|
const p = path.join(eng, 'success.md')
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
const
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
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
|
+
})
|
|
1702
1732
|
}
|
|
1703
1733
|
|
|
1704
1734
|
function setNextAction(eng, text) {
|
|
1705
1735
|
ensureMemoryGit(eng)
|
|
1706
1736
|
const bullet = `- ${stripControlChars(String(text).replace(/^[-*]\s+/, '').trim())}`
|
|
1707
1737
|
const p = path.join(eng, 'context.md')
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
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
|
+
})
|
|
1721
1753
|
}
|
|
1722
1754
|
|
|
1723
1755
|
function looksLikeBinaryNoise(text) {
|
|
@@ -2293,6 +2325,23 @@ function cmdReceipts(args) {
|
|
|
2293
2325
|
}
|
|
2294
2326
|
}
|
|
2295
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
|
+
|
|
2296
2345
|
function cmdCapture() {
|
|
2297
2346
|
const eng = resolveEngagement({ forWrite: true })
|
|
2298
2347
|
if (!eng) process.exit(0) // silent: capture must never break a session
|
|
@@ -2685,7 +2734,8 @@ function findAmbiguousStakeholders(eng) {
|
|
|
2685
2734
|
for (const name of forms) {
|
|
2686
2735
|
const key = signalSubjectKey(name)
|
|
2687
2736
|
if (!key || key.startsWith('anon:')) continue
|
|
2688
|
-
const norm = name.replace(/\s+/g, ' ').trim().toLowerCase()
|
|
2737
|
+
const norm = name.replace(/\([^)]*\)/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
|
|
2738
|
+
if (!norm || norm.length < 2) continue
|
|
2689
2739
|
if (!byKey.has(key)) byKey.set(key, new Set())
|
|
2690
2740
|
byKey.get(key).add(norm)
|
|
2691
2741
|
}
|
|
@@ -2751,13 +2801,8 @@ function hasValueBucket(eng) {
|
|
|
2751
2801
|
return false
|
|
2752
2802
|
}
|
|
2753
2803
|
|
|
2754
|
-
//
|
|
2755
|
-
|
|
2756
|
-
// "Accepted by", or they close as claimed - the distinction the renewal turns on.
|
|
2757
|
-
// "pending review", "TBD.", "n/a (blocked)" and "..." are all the same thing an
|
|
2758
|
-
// FDE means by an empty cell - nagging about them teaches people to ignore doctor.
|
|
2759
|
-
const PENDING_CELL_RE =
|
|
2760
|
-
/^(?: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')
|
|
2761
2806
|
|
|
2762
2807
|
function parseValueLedger(eng) {
|
|
2763
2808
|
// Last section with actual rows, not merely the last non-empty one: a template
|
|
@@ -2772,7 +2817,9 @@ function parseValueLedger(eng) {
|
|
|
2772
2817
|
slice: colIndex(table.headers, /slice/i),
|
|
2773
2818
|
promised: colIndex(table.headers, /promised/i),
|
|
2774
2819
|
measured: colIndex(table.headers, /measured/i),
|
|
2775
|
-
accepted: colIndex(table.headers, /
|
|
2820
|
+
accepted: colIndex(table.headers, /accepted by/i),
|
|
2821
|
+
acceptanceStatus: colIndex(table.headers, /^acceptance status$/i),
|
|
2822
|
+
evidence: colIndex(table.headers, /evidence/i),
|
|
2776
2823
|
}
|
|
2777
2824
|
const cell = (row, i) => (i === -1 ? '' : String(row[i] || '').trim())
|
|
2778
2825
|
const rows = []
|
|
@@ -2782,12 +2829,10 @@ function parseValueLedger(eng) {
|
|
|
2782
2829
|
const measured = cell(row, idx.measured)
|
|
2783
2830
|
const accepted = cell(row, idx.accepted)
|
|
2784
2831
|
if (!slice && !promised && !measured) continue
|
|
2785
|
-
const
|
|
2786
|
-
const
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
else if (!measuredPending) state = 'accepted'
|
|
2790
|
-
rows.push({ slice, promised, measured, accepted, state })
|
|
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 })
|
|
2835
|
+
rows.push({ slice, promised, measured, accepted, evidence, evidenceMissing: !evidence || PENDING_CELL_RE.test(evidence), state })
|
|
2791
2836
|
}
|
|
2792
2837
|
return { rows, columnMissing: idx.accepted === -1 }
|
|
2793
2838
|
}
|
|
@@ -2980,19 +3025,15 @@ function cmdRedact(args) {
|
|
|
2980
3025
|
}
|
|
2981
3026
|
ensureMemoryGit(eng)
|
|
2982
3027
|
const touched = []
|
|
2983
|
-
const
|
|
2984
|
-
for (const h of hits) {
|
|
2985
|
-
if (!byFile.has(h.file)) byFile.set(h.file, new Set())
|
|
2986
|
-
byFile.get(h.file).add(h.lineNo)
|
|
2987
|
-
}
|
|
2988
|
-
for (const [file, lineNos] of byFile) {
|
|
3028
|
+
for (const file of new Set(hits.map(h => h.file))) {
|
|
2989
3029
|
const abs = path.join(eng, file)
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
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
|
+
})
|
|
2996
3037
|
}
|
|
2997
3038
|
if (!touched.length) {
|
|
2998
3039
|
console.log('nothing changed')
|
|
@@ -3143,37 +3184,45 @@ function cmdGarden(args) {
|
|
|
3143
3184
|
continue
|
|
3144
3185
|
}
|
|
3145
3186
|
if (p.id !== 'archive-sessions') continue
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
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
|
+
}
|
|
3162
3211
|
flush()
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
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
|
|
3177
3226
|
applied++
|
|
3178
3227
|
touched.add('context.md')
|
|
3179
3228
|
touched.add('context-archive.md')
|
|
@@ -3187,47 +3236,49 @@ function cmdGarden(args) {
|
|
|
3187
3236
|
// Keep the first open-risk bullet per fingerprint; move later echoes under ## Retired.
|
|
3188
3237
|
function applyRiskDedupe(eng, clusters) {
|
|
3189
3238
|
const p = path.join(eng, 'risks.md')
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
for (
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
const
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
}
|
|
3208
|
-
if (!inRetired) {
|
|
3209
|
-
const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
|
|
3210
|
-
if (m && echoTexts.has(m[1].trim())) {
|
|
3211
|
-
retiredLines.push(raw)
|
|
3212
|
-
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)
|
|
3213
3256
|
continue
|
|
3214
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)
|
|
3215
3267
|
}
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
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
|
+
})
|
|
3231
3282
|
}
|
|
3232
3283
|
|
|
3233
3284
|
function engagementSlugFromPath(eng) {
|
|
@@ -3330,6 +3381,7 @@ function cmdDashboard(args) {
|
|
|
3330
3381
|
engagements.forEach(e => {
|
|
3331
3382
|
const ctx = readClean(e.dir, 'context.md')
|
|
3332
3383
|
e.next = (sectionBody(ctx, 'Next action', { lastNonEmpty: true }).split('\n').find(l => l.trim()) || '').trim()
|
|
3384
|
+
e.next = e.next.replace(/^[-*]\s+/, '')
|
|
3333
3385
|
e.hasNext = !!e.next
|
|
3334
3386
|
e.lastSession = firstLine(sectionBody(ctx, 'Current state'), 240)
|
|
3335
3387
|
// 220, not 140 - now that brief/reality each get their own full-width
|
|
@@ -3346,6 +3398,7 @@ function cmdDashboard(args) {
|
|
|
3346
3398
|
e.risks = extractRisks(e.dir)
|
|
3347
3399
|
e.log = extractLog(e.dir)
|
|
3348
3400
|
e.stats = extractStats(e.dir)
|
|
3401
|
+
e.valueRows = parseValueLedger(e.dir).rows
|
|
3349
3402
|
e.highRisks = e.risks.filter(r => r.severity === 'high').length
|
|
3350
3403
|
e.quiet = e.signals.ageDays !== Infinity && e.signals.ageDays >= 3
|
|
3351
3404
|
e.slug = slugify(e.name)
|
|
@@ -3363,10 +3416,11 @@ function cmdDashboard(args) {
|
|
|
3363
3416
|
...e.log.map(g => g.text), ...e.risks.map(r => r.text),
|
|
3364
3417
|
...e.stakeholders.map(p => `${p.name} ${p.role} ${p.note}`),
|
|
3365
3418
|
...e.moreSections.map(s => s.title),
|
|
3419
|
+
...e.valueRows.map(r => `${r.slice} ${r.promised} ${r.measured} ${r.accepted} ${r.evidence}`),
|
|
3366
3420
|
].join(' ').toLowerCase())
|
|
3367
3421
|
})
|
|
3368
3422
|
|
|
3369
|
-
const html = render.buildFieldbookHtml({ engagements, today })
|
|
3423
|
+
const html = render.buildFieldbookHtml({ engagements, today, generatedAt: new Date().toISOString() })
|
|
3370
3424
|
|
|
3371
3425
|
try {
|
|
3372
3426
|
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
|
@@ -3406,24 +3460,10 @@ function cliVersion() {
|
|
|
3406
3460
|
}
|
|
3407
3461
|
|
|
3408
3462
|
function valueLedgerRows(eng) {
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
const pIdx = colIndex(table.headers, /promis/i)
|
|
3414
|
-
const aIdx = colIndex(table.headers, /accept/i)
|
|
3415
|
-
const rows = []
|
|
3416
|
-
for (const row of table.rows) {
|
|
3417
|
-
const slice = sIdx === -1 ? '' : String(row[sIdx] || '').trim()
|
|
3418
|
-
const promised = pIdx === -1 ? '' : String(row[pIdx] || '').trim()
|
|
3419
|
-
if (!slice && !promised) continue
|
|
3420
|
-
const acceptedRaw = aIdx === -1 ? '' : String(row[aIdx] || '').trim()
|
|
3421
|
-
rows.push({
|
|
3422
|
-
slice, promised,
|
|
3423
|
-
acceptedBy: !acceptedRaw || PENDING_CELL_RE.test(acceptedRaw) ? '' : acceptedRaw,
|
|
3424
|
-
})
|
|
3425
|
-
}
|
|
3426
|
-
return rows
|
|
3463
|
+
return parseValueLedger(eng).rows.map(row => ({
|
|
3464
|
+
slice: row.slice, promised: row.promised,
|
|
3465
|
+
acceptedBy: row.state === 'accepted' ? row.accepted : '',
|
|
3466
|
+
}))
|
|
3427
3467
|
}
|
|
3428
3468
|
|
|
3429
3469
|
function isInside(child, parent) {
|
|
@@ -3743,9 +3783,10 @@ function printUsage() {
|
|
|
3743
3783
|
fde redact <term> preview/remove lines containing a buried term (pass --apply to commit; subject never repeats the term)
|
|
3744
3784
|
fde tidy [--apply] propose consolidations; blesses hand-written dirty files when you apply
|
|
3745
3785
|
fde owner [set email] who keeps this engagement record
|
|
3786
|
+
fde recall <topic> bounded, redacted source excerpts (--max-bytes 4096..65536)
|
|
3746
3787
|
fde receipts <term> "what did we agree?" with dates
|
|
3747
3788
|
fde status [--all] value ledger, then trust (pass --all for full portfolio)
|
|
3748
|
-
fde dashboard [--all]
|
|
3789
|
+
fde dashboard [--all] [--open] [--out <path>] bound fieldbook (pass --all for every client)
|
|
3749
3790
|
fde vault derived Obsidian vault of every engagement (--current for one, --redacted for a shared screen, --out <dir>)
|
|
3750
3791
|
hooks call these; you do not: capture (session-end snapshot), preserve (pre-compaction snapshot)
|
|
3751
3792
|
env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
|
|
@@ -3763,6 +3804,7 @@ switch (cmd) {
|
|
|
3763
3804
|
case 'demo': cmdDemo(args); break
|
|
3764
3805
|
case 'scan': cmdScan(); break
|
|
3765
3806
|
case 'resume': cmdResume(args); break
|
|
3807
|
+
case 'recall': cmdRecall(args); break
|
|
3766
3808
|
case 'triage': cmdTriage(); break
|
|
3767
3809
|
case 'log': cmdLog(args); break
|
|
3768
3810
|
case 'debrief': cmdDebrief(args); break
|