fdeops 3.26.0 → 3.27.1
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 +75 -180
- package/bin/check.js +15 -32
- package/bin/fde.js +356 -122
- package/bin/install.js +1 -1
- package/bin/lib/delivery-gaps.js +34 -0
- package/bin/lib/provenance.js +42 -0
- package/bin/lib/render.js +17 -31
- package/bin/lib/value-ledger.js +8 -2
- package/mcp/fdeops-ingest/package.json +1 -1
- package/package.json +1 -1
- 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
|
@@ -36,6 +36,8 @@ const { createMemoryApi } = require('./lib/memory')
|
|
|
36
36
|
const { createTrustApi } = require('./lib/trust')
|
|
37
37
|
const vault = require('./lib/vault')
|
|
38
38
|
const context = require('./lib/context')
|
|
39
|
+
const { sourceReference, hasSource, datedDecisions } = require('./lib/provenance')
|
|
40
|
+
const { deliverySummary } = require('./lib/delivery-gaps')
|
|
39
41
|
|
|
40
42
|
const HOME = os.homedir()
|
|
41
43
|
// FDEOPS_ENGAGEMENTS_ROOT isolates init/status/dashboard (and the registry) for
|
|
@@ -485,7 +487,11 @@ function formatFsError(err, action, target) {
|
|
|
485
487
|
return `cannot ${action} ${where}${code ? ` (${code})` : ''}${err && err.message && !code ? ': ' + err.message : ''}`
|
|
486
488
|
}
|
|
487
489
|
|
|
490
|
+
let debriefTransactionActive = false
|
|
491
|
+
const ownedDebriefLocks = new Set()
|
|
492
|
+
|
|
488
493
|
function failFs(err, action, target) {
|
|
494
|
+
if (debriefTransactionActive || ownedDebriefLocks.size) throw new Error(formatFsError(err, action, target))
|
|
489
495
|
console.error(formatFsError(err, action, target))
|
|
490
496
|
process.exit(1)
|
|
491
497
|
}
|
|
@@ -501,6 +507,7 @@ function refuseSymlinkWrite(p, opts = {}) {
|
|
|
501
507
|
const msg = st.isSymbolicLink()
|
|
502
508
|
? `refused: ${path.basename(p)} is a symlink - write would leave the engagement tree. Replace it with a real file.`
|
|
503
509
|
: `refused: ${path.basename(p)} is not a regular file - remove it and re-run; every write is refused while it is there.`
|
|
510
|
+
if ((debriefTransactionActive || ownedDebriefLocks.size) && !opts.soft) throw new Error(msg)
|
|
504
511
|
if (opts.soft) return msg
|
|
505
512
|
console.error(msg)
|
|
506
513
|
process.exit(1)
|
|
@@ -516,6 +523,8 @@ function refuseSymlinkWrite(p, opts = {}) {
|
|
|
516
523
|
// Exclusive create lock + retry. Two parallel agent sessions (or hook + CLI)
|
|
517
524
|
// appending the same .fde file otherwise interleave/corrupt under load.
|
|
518
525
|
function withFileLock(targetPath, fn, opts = {}) {
|
|
526
|
+
if (ownedDebriefLocks.has(targetPath)) return fn()
|
|
527
|
+
if (debriefTransactionActive || ownedDebriefLocks.size) opts = { ...opts, soft: true }
|
|
519
528
|
const lockPath = targetPath + '.lock'
|
|
520
529
|
const deadline = Date.now() + 5000
|
|
521
530
|
while (true) {
|
|
@@ -1396,7 +1405,7 @@ function cmdResume(args) {
|
|
|
1396
1405
|
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.`)
|
|
1397
1406
|
process.exit(2)
|
|
1398
1407
|
}
|
|
1399
|
-
const intro = [resumeTriage(eng), ...hygieneTriageLines(eng), ...recordDigest(eng)].join('\n')
|
|
1408
|
+
const intro = [resumeTriage(eng), firstActionLine(eng), ...hygieneTriageLines(eng), ...recordDigest(eng)].join('\n')
|
|
1400
1409
|
const ctx = readClean(eng, 'context.md')
|
|
1401
1410
|
if (args.includes('--full')) {
|
|
1402
1411
|
console.log(`${intro}\n\nENGAGEMENT: ${eng}\n\n${ctx || '(no context.md yet)'}`)
|
|
@@ -1408,10 +1417,11 @@ function cmdResume(args) {
|
|
|
1408
1417
|
const success = readClean(eng, 'success.md')
|
|
1409
1418
|
const risks = readClean(eng, 'risks.md')
|
|
1410
1419
|
process.stdout.write(context.boundedSections([
|
|
1411
|
-
`${intro}\n\nENGAGEMENT: ${eng}`,
|
|
1412
1420
|
policy ? `CLIENT POLICY - trust-profile.md\n${policy}` : '',
|
|
1421
|
+
`${intro}\n\nENGAGEMENT: ${eng}`,
|
|
1413
1422
|
success ? `CURRENT GOALS & ACCEPTANCE - success.md\n${success}` : '',
|
|
1414
|
-
risks ? `
|
|
1423
|
+
risks ? `OPEN RISKS - risks.md\n${extractRisks(eng).map(r => r.text).join('\n') || '(none recorded)'}` : '',
|
|
1424
|
+
`VALUE LEDGER - delivery.md\n${parseValueLedger(eng).rows.map(r => formatValueLedgerLine(r) + '; source: ' + (r.evidence || '(missing)')).join('\n') || '(none recorded)'}`,
|
|
1415
1425
|
`WORKING CONTEXT - context.md\n${ctx ? resumeView(ctx) : '(no context.md yet)'}`,
|
|
1416
1426
|
], maxBytes))
|
|
1417
1427
|
}
|
|
@@ -1631,7 +1641,10 @@ function smartProposeText(input) {
|
|
|
1631
1641
|
// the original stays as context, so nothing is invented or lost.
|
|
1632
1642
|
for (const sentence of bare.split(/(?<=[.!?])\s+/)) {
|
|
1633
1643
|
const who = signerFromLine(sentence)
|
|
1634
|
-
if (who) {
|
|
1644
|
+
if (who) {
|
|
1645
|
+
const source = (bare.match(/\[source:\s*[^\]]+\]/i) || [])[0] || ''
|
|
1646
|
+
out.push(`signer: ${who}${source ? ` ${source}` : ''}`); break
|
|
1647
|
+
}
|
|
1635
1648
|
}
|
|
1636
1649
|
if (/^(next action|follow-?ups?|action items?|todo):\s*/i.test(bare) ||
|
|
1637
1650
|
/\b(next action|walk in with|follow up with)\b/i.test(bare)) {
|
|
@@ -1639,6 +1652,14 @@ function smartProposeText(input) {
|
|
|
1639
1652
|
out.push(`next: ${next}`)
|
|
1640
1653
|
continue
|
|
1641
1654
|
}
|
|
1655
|
+
if (/^(?:scope|proposed scope|out of scope):\s*/i.test(bare)) {
|
|
1656
|
+
out.push(`scope: ${bare}`)
|
|
1657
|
+
continue
|
|
1658
|
+
}
|
|
1659
|
+
if (/\b(?:we need|we want|customer asks?|customer asked|please|can you|could you)\b/i.test(bare)) {
|
|
1660
|
+
out.push(`ask: ${bare}`)
|
|
1661
|
+
continue
|
|
1662
|
+
}
|
|
1642
1663
|
if (/\b(we (decided|agreed)|decided:|decision:|descope|agreed to|agreement was|freeze scope|freeze prompts)\b/i.test(bare)) {
|
|
1643
1664
|
out.push(`decision: ${bare}`)
|
|
1644
1665
|
} else if (/\b(open question|who signs|unclear who|unresolved)\b/i.test(bare)) {
|
|
@@ -1674,7 +1695,7 @@ function looksLikePersonName(s) {
|
|
|
1674
1695
|
|
|
1675
1696
|
function signerFromLine(text) {
|
|
1676
1697
|
const t = String(text || '').replace(/^[-*+]\s+/, '').trim()
|
|
1677
|
-
if (!t) return ''
|
|
1698
|
+
if (!t || /\?|\b(?:not|nobody|unclear|maybe|might|whether|could|should|if|unless|pending|unconfirmed)\b/i.test(t)) return ''
|
|
1678
1699
|
// "Priya (VP Eng) signs off" → Priya. "Finance controller (Helena) signs off" → Helena.
|
|
1679
1700
|
const titled = t.match(new RegExp('\\b' + SIGNER_NAME + '\\s+\\(' + SIGNER_NAME + '\\)\\s+' + SIGNER_VERB + '\\b'))
|
|
1680
1701
|
if (titled) {
|
|
@@ -1775,28 +1796,24 @@ function readDebriefInput(args) {
|
|
|
1775
1796
|
if (args[0]) {
|
|
1776
1797
|
const notesPath = args[0].replace(/^~/, HOME)
|
|
1777
1798
|
let st
|
|
1778
|
-
try { st = fs.statSync(notesPath) } catch (_) {
|
|
1799
|
+
try { st = fs.statSync(notesPath) } catch (_) { throw new Error(`cannot read ${args[0]}`) }
|
|
1779
1800
|
if (st.size > DEBRIEF_MAX_BYTES) {
|
|
1780
|
-
|
|
1781
|
-
process.exit(1)
|
|
1801
|
+
throw new Error(`debrief refused: ${args[0]} is ${st.size} bytes (max ${DEBRIEF_MAX_BYTES}). Split the notes or paste the relevant section.`)
|
|
1782
1802
|
}
|
|
1783
1803
|
let buf
|
|
1784
|
-
try { buf = fs.readFileSync(notesPath) } catch (_) {
|
|
1804
|
+
try { buf = fs.readFileSync(notesPath) } catch (_) { throw new Error(`cannot read ${args[0]}`) }
|
|
1785
1805
|
if (buf.includes(0) || looksLikeBinaryNoise(buf.toString('utf8'))) {
|
|
1786
|
-
|
|
1787
|
-
process.exit(1)
|
|
1806
|
+
throw new Error(`debrief refused: ${args[0]} looks binary or mostly non-printable. Paste text notes only.`)
|
|
1788
1807
|
}
|
|
1789
1808
|
input = buf.toString('utf8')
|
|
1790
1809
|
} else {
|
|
1791
1810
|
let buf
|
|
1792
1811
|
try { buf = fs.readFileSync(0) } catch (_) { buf = Buffer.alloc(0) }
|
|
1793
1812
|
if (Buffer.byteLength(buf) > DEBRIEF_MAX_BYTES) {
|
|
1794
|
-
|
|
1795
|
-
process.exit(1)
|
|
1813
|
+
throw new Error(`debrief refused: stdin is over ${DEBRIEF_MAX_BYTES} bytes. Split the notes.`)
|
|
1796
1814
|
}
|
|
1797
1815
|
if (buf.includes(0) || looksLikeBinaryNoise(buf.toString('utf8'))) {
|
|
1798
|
-
|
|
1799
|
-
process.exit(1)
|
|
1816
|
+
throw new Error('debrief refused: stdin looks binary or mostly non-printable. Paste text notes only.')
|
|
1800
1817
|
}
|
|
1801
1818
|
input = buf.toString('utf8')
|
|
1802
1819
|
}
|
|
@@ -1809,15 +1826,29 @@ function previewLine(text, max = 240) {
|
|
|
1809
1826
|
return `${t.slice(0, max)}… (${t.length} chars)`
|
|
1810
1827
|
}
|
|
1811
1828
|
|
|
1812
|
-
function writeProposal(eng, text) {
|
|
1829
|
+
function writeProposal(eng, text, { replace = false, locked = false } = {}) {
|
|
1830
|
+
if (!locked && ownedDebriefLocks.has(path.join(eng, DEBRIEF_PROPOSE))) return writeProposal(eng, text, { replace, locked: true })
|
|
1831
|
+
if (!locked) return withFileLock(path.join(eng, DEBRIEF_PROPOSE), () => {
|
|
1832
|
+
ownedDebriefLocks.add(path.join(eng, DEBRIEF_PROPOSE))
|
|
1833
|
+
try { return writeProposal(eng, text, { replace, locked: true }) }
|
|
1834
|
+
finally { ownedDebriefLocks.delete(path.join(eng, DEBRIEF_PROPOSE)) }
|
|
1835
|
+
}, { soft: true })
|
|
1836
|
+
if (!debriefTransactionActive) return withDebriefRecords(eng, () => writeProposal(eng, text, { replace, locked: true }), [DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL])
|
|
1813
1837
|
const { clean, blocks } = splitPrivate(text, { sealDangling: true })
|
|
1814
1838
|
const proposePath = path.join(eng, DEBRIEF_PROPOSE)
|
|
1815
1839
|
const privatePath = path.join(eng, DEBRIEF_PRIVATE)
|
|
1840
|
+
if (fs.existsSync(proposePath) && !replace) {
|
|
1841
|
+
const existing = fs.readFileSync(proposePath, 'utf8')
|
|
1842
|
+
const existingPrivate = readSealedProposal(eng)
|
|
1843
|
+
if (existing !== clean || JSON.stringify(existingPrivate) !== JSON.stringify(blocks)) {
|
|
1844
|
+
throw new Error('pending proposal already exists. Review and apply it first, or explicitly replace it with fde debrief --smart <notes> --replace-proposal.')
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1816
1847
|
// Seal first. A refused or failed sidecar write must not leave behind a
|
|
1817
1848
|
// proposal whose (private - redacted) marker has nothing left behind it.
|
|
1818
1849
|
if (blocks.length) {
|
|
1819
1850
|
const blocked = refuseSymlinkWrite(privatePath, { soft: true })
|
|
1820
|
-
if (blocked)
|
|
1851
|
+
if (blocked) throw new Error(blocked)
|
|
1821
1852
|
withFileLock(privatePath, () => { atomicWriteFile(privatePath, sealedText(blocks), { mode: 0o600 }) })
|
|
1822
1853
|
try { fs.chmodSync(privatePath, 0o600) } catch (_) {}
|
|
1823
1854
|
} else {
|
|
@@ -1844,12 +1875,12 @@ function stripApprovedStamp(text) {
|
|
|
1844
1875
|
|
|
1845
1876
|
// One screen a human can confirm in two minutes. The file-by-file routing
|
|
1846
1877
|
// still prints after this - agents edit prefixes; people read this.
|
|
1847
|
-
function printDebriefReview(text) {
|
|
1848
|
-
const buckets = { decided: [], asked: [], open: [], next: [], signer: [] }
|
|
1878
|
+
function printDebriefReview(text, eng) {
|
|
1879
|
+
const buckets = { decided: [], asked: [], scope: [], delivery: [], open: [], next: [], signer: [] }
|
|
1849
1880
|
for (const raw of String(text || '').split('\n')) {
|
|
1850
1881
|
const line = raw.trim().replace(/^[-*+]\s+/, '')
|
|
1851
1882
|
if (!line) continue
|
|
1852
|
-
const m = line.match(/^(decision|risk|delivery|contact|next|signer):\s*(.+)$/i)
|
|
1883
|
+
const m = line.match(/^(decision|risk|delivery|contact|next|signer|ask|scope):\s*(.+)$/i)
|
|
1853
1884
|
if (!m) continue
|
|
1854
1885
|
const type = m[1].toLowerCase()
|
|
1855
1886
|
const body = m[2]
|
|
@@ -1857,8 +1888,12 @@ function printDebriefReview(text) {
|
|
|
1857
1888
|
const who = approvedStamp(body)
|
|
1858
1889
|
const core = previewLine(stripApprovedStamp(body), 90)
|
|
1859
1890
|
buckets.decided.push(who ? `${core} (approved ${who})` : `${core} (unconfirmed)`)
|
|
1860
|
-
} else if (type === '
|
|
1891
|
+
} else if (type === 'ask') {
|
|
1861
1892
|
buckets.asked.push(previewLine(body, 100))
|
|
1893
|
+
} else if (type === 'scope') {
|
|
1894
|
+
buckets.scope.push(previewLine(body, 100))
|
|
1895
|
+
} else if (type === 'delivery') {
|
|
1896
|
+
buckets.delivery.push(previewLine(body, 100))
|
|
1862
1897
|
} else if (type === 'risk') {
|
|
1863
1898
|
buckets.open.push(previewLine(body, 100))
|
|
1864
1899
|
} else if (type === 'next') {
|
|
@@ -1870,20 +1905,34 @@ function printDebriefReview(text) {
|
|
|
1870
1905
|
console.log('REVIEW (one screen - confirm once, then apply)\n')
|
|
1871
1906
|
const order = [
|
|
1872
1907
|
['decided', buckets.decided],
|
|
1873
|
-
['
|
|
1908
|
+
['stated asks', buckets.asked],
|
|
1909
|
+
['proposed scope', buckets.scope],
|
|
1910
|
+
['reported delivery (not customer acceptance)', buckets.delivery],
|
|
1874
1911
|
['open', buckets.open],
|
|
1875
|
-
['next', buckets.next],
|
|
1876
|
-
['signer', buckets.signer],
|
|
1912
|
+
['next action', buckets.next],
|
|
1913
|
+
['named signer (authority, not approval)', buckets.signer],
|
|
1877
1914
|
]
|
|
1878
1915
|
let any = false
|
|
1879
1916
|
for (const [label, items] of order) {
|
|
1880
|
-
if (!items.length)
|
|
1917
|
+
if (!items.length) {
|
|
1918
|
+
if (['stated asks', 'proposed scope', 'next action', 'named signer (authority, not approval)'].includes(label)) console.log(` ${label}: not detected - review the notes`)
|
|
1919
|
+
continue
|
|
1920
|
+
}
|
|
1881
1921
|
any = true
|
|
1882
1922
|
console.log(` ${label}:`)
|
|
1883
|
-
for (const item of items) console.log(` - ${item}`)
|
|
1923
|
+
for (const item of items.slice(0, 5)) console.log(` - ${item}`)
|
|
1924
|
+
if (items.length > 5) console.log(` - ${items.length - 5} more omitted here; review the full proposal before applying.`)
|
|
1884
1925
|
}
|
|
1885
1926
|
if (!any) console.log(' (nothing prefixed yet - edit .debrief-propose, then apply)')
|
|
1886
|
-
|
|
1927
|
+
const recorded = eng ? parseValueLedger(eng).rows : []
|
|
1928
|
+
const measured = recorded.some(row => row.state !== 'unmeasured')
|
|
1929
|
+
const evidenced = recorded.some(row => !row.evidenceMissing)
|
|
1930
|
+
const accepted = recorded.some(row => row.state === 'accepted')
|
|
1931
|
+
console.log(' delivery picture (existing record; this proposal does not certify it):')
|
|
1932
|
+
console.log(` - measurement: ${measured ? 'recorded; check the value ledger' : 'missing - record the observed result'}`)
|
|
1933
|
+
console.log(` - evidence: ${evidenced ? 'recorded; review its source' : 'missing - cite the test, artifact, or source'}`)
|
|
1934
|
+
console.log(` - customer approval: ${accepted ? 'recorded for a prior outcome; not this proposal' : 'missing - request explicit acceptance after evidence review'}`)
|
|
1935
|
+
console.log(' Saving this update confirms your record, not customer acceptance.\n')
|
|
1887
1936
|
}
|
|
1888
1937
|
|
|
1889
1938
|
function latestDatedDecision(md) {
|
|
@@ -1901,8 +1950,8 @@ function formatDecisionRecord(line) {
|
|
|
1901
1950
|
const raw = String(line || '').trim().replace(/^[-*]\s*/, '')
|
|
1902
1951
|
const who = approvedStamp(raw)
|
|
1903
1952
|
const core = stripApprovedStamp(raw)
|
|
1904
|
-
const stamp = who ? `(approved ${who})` : '(unconfirmed)'
|
|
1905
|
-
return
|
|
1953
|
+
const stamp = !hasSource(raw) ? '(CLAIM - source missing)' : who ? `(approved ${who}; source recorded)` : '(unconfirmed; source recorded)'
|
|
1954
|
+
return `${stamp} ${previewLine(core, 110)}`
|
|
1906
1955
|
}
|
|
1907
1956
|
|
|
1908
1957
|
function changeReviewIssues(eng) {
|
|
@@ -1941,7 +1990,79 @@ function readSealedProposal(eng) {
|
|
|
1941
1990
|
} catch (_) { return [] }
|
|
1942
1991
|
}
|
|
1943
1992
|
|
|
1993
|
+
// A debrief touches several records. Acquire every cooperating writer's lock
|
|
1994
|
+
// before the first append, and restore snapshots if an ordinary write fails.
|
|
1995
|
+
// This is not a power-loss transaction; no history is deleted or reset.
|
|
1996
|
+
function withDebriefRecords(eng, apply, files = ['decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md',
|
|
1997
|
+
'success.md', 'context.md', SIGNAL_LEDGER, LAST_WRITE, DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL]) {
|
|
1998
|
+
files = files.slice().sort()
|
|
1999
|
+
const snapshots = new Map()
|
|
2000
|
+
function lockAt(index) {
|
|
2001
|
+
if (index < files.length) {
|
|
2002
|
+
const target = path.join(eng, files[index])
|
|
2003
|
+
if (ownedDebriefLocks.has(target)) return lockAt(index + 1)
|
|
2004
|
+
return withFileLock(target, () => {
|
|
2005
|
+
ownedDebriefLocks.add(target)
|
|
2006
|
+
try { return lockAt(index + 1) } finally { ownedDebriefLocks.delete(target) }
|
|
2007
|
+
}, { soft: true })
|
|
2008
|
+
}
|
|
2009
|
+
for (const file of files) {
|
|
2010
|
+
const target = path.join(eng, file)
|
|
2011
|
+
const blocked = refuseSymlinkWrite(target, { soft: true })
|
|
2012
|
+
if (blocked) throw new Error(blocked)
|
|
2013
|
+
snapshots.set(target, fs.existsSync(target) ? { bytes: fs.readFileSync(target), mode: fs.statSync(target).mode & 0o777 } : null)
|
|
2014
|
+
}
|
|
2015
|
+
debriefTransactionActive = true
|
|
2016
|
+
try { return apply() } catch (error) {
|
|
2017
|
+
const failed = []
|
|
2018
|
+
for (const [target, previous] of snapshots) {
|
|
2019
|
+
try {
|
|
2020
|
+
if (previous) atomicWriteFile(target, previous.bytes, { mode: previous.mode, soft: true })
|
|
2021
|
+
else if (fs.existsSync(target)) fs.unlinkSync(target)
|
|
2022
|
+
} catch (_) { failed.push(path.basename(target)) }
|
|
2023
|
+
}
|
|
2024
|
+
if (failed.length) throw new Error(`${error.message}; recovery could not restore ${failed.join(', ')}. Inspect these records and the pending proposal before retrying.`)
|
|
2025
|
+
throw new Error(`${error.message}; no record changes kept. The proposal is retained; retry after resolving the cause.`)
|
|
2026
|
+
} finally { debriefTransactionActive = false }
|
|
2027
|
+
}
|
|
2028
|
+
return lockAt(0)
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// Limit model-facing review output while preserving the full editable proposal.
|
|
2032
|
+
function boundedDebriefPreview(eng, render, { proposal = true, maxBytes = 12000 } = {}) {
|
|
2033
|
+
const original = console.log, originalError = console.error
|
|
2034
|
+
let bytes = 0, omitted = 0
|
|
2035
|
+
const bounded = output => (...args) => {
|
|
2036
|
+
const line = args.join(' ') + '\n'
|
|
2037
|
+
const size = Buffer.byteLength(line)
|
|
2038
|
+
if (bytes + size > maxBytes) { omitted++; return }
|
|
2039
|
+
bytes += size; output(...args)
|
|
2040
|
+
}
|
|
2041
|
+
console.log = bounded(original)
|
|
2042
|
+
console.error = bounded(originalError)
|
|
2043
|
+
let result
|
|
2044
|
+
try { result = render() } finally { console.log = original; console.error = originalError }
|
|
2045
|
+
if (omitted) console.log(proposal
|
|
2046
|
+
? `\n${omitted} preview lines omitted. Review the complete proposal at ${path.join(eng, DEBRIEF_PROPOSE)} before applying.`
|
|
2047
|
+
: `\n${omitted} preview lines omitted. Review the full input notes before applying.`)
|
|
2048
|
+
return result
|
|
2049
|
+
}
|
|
2050
|
+
|
|
1944
2051
|
function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
|
|
2052
|
+
if (!dry && !debriefTransactionActive) {
|
|
2053
|
+
ensureMemoryGit(eng)
|
|
2054
|
+
return withDebriefRecords(eng, () => {
|
|
2055
|
+
const result = routeDebriefInput(eng, input, { dry, force, sealed })
|
|
2056
|
+
// Consuming the review is part of the write. If cleanup fails, restoring
|
|
2057
|
+
// both the records and proposal makes the next explicit apply safe.
|
|
2058
|
+
for (const file of [DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL]) {
|
|
2059
|
+
try { fs.unlinkSync(path.join(eng, file)) } catch (error) {
|
|
2060
|
+
if (error.code !== 'ENOENT') throw new Error(formatFsError(error, 'remove', file))
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
return result
|
|
2064
|
+
})
|
|
2065
|
+
}
|
|
1945
2066
|
const d = new Date()
|
|
1946
2067
|
const date = d.toISOString().slice(0, 10)
|
|
1947
2068
|
const counts = { decision: 0, risk: 0, delivery: 0, contact: 0, next: 0, signer: 0 }
|
|
@@ -2013,6 +2134,18 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
|
|
|
2013
2134
|
}
|
|
2014
2135
|
|
|
2015
2136
|
function cmdDebrief(args) {
|
|
2137
|
+
const eng = resolveEngagement({ forWrite: true })
|
|
2138
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exitCode = 2; return }
|
|
2139
|
+
const target = path.join(eng, DEBRIEF_PROPOSE)
|
|
2140
|
+
try {
|
|
2141
|
+
withFileLock(target, () => {
|
|
2142
|
+
ownedDebriefLocks.add(target)
|
|
2143
|
+
try { return runDebrief(args, eng) } finally { ownedDebriefLocks.delete(target) }
|
|
2144
|
+
}, { soft: true })
|
|
2145
|
+
} catch (error) { console.error(error.message); process.exitCode = 1 }
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
function runDebrief(args, eng) {
|
|
2016
2149
|
args = args.slice()
|
|
2017
2150
|
const dryIdx = args.indexOf('--dry-run')
|
|
2018
2151
|
const dry = dryIdx !== -1
|
|
@@ -2027,35 +2160,45 @@ function cmdDebrief(args) {
|
|
|
2027
2160
|
const forceIdx = args.indexOf('--force')
|
|
2028
2161
|
if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
|
|
2029
2162
|
|
|
2030
|
-
const
|
|
2031
|
-
|
|
2163
|
+
const replaceIdx = args.indexOf('--replace-proposal')
|
|
2164
|
+
const replace = replaceIdx !== -1
|
|
2165
|
+
if (replace) args.splice(replaceIdx, 1)
|
|
2166
|
+
if (replace && !smart) throw new Error('--replace-proposal requires --smart <notes>')
|
|
2032
2167
|
|
|
2168
|
+
if (!smart && !apply && !dry && fs.existsSync(path.join(eng, DEBRIEF_PROPOSE))) {
|
|
2169
|
+
throw new Error('pending proposal already exists. Review and apply it before writing another debrief.')
|
|
2170
|
+
}
|
|
2171
|
+
if (apply && !smart && args[0] && fs.existsSync(path.join(eng, DEBRIEF_PROPOSE))) {
|
|
2172
|
+
throw new Error('pending proposal already exists. Use debrief --apply without a notes file to apply that review.')
|
|
2173
|
+
}
|
|
2033
2174
|
let input = ''
|
|
2034
2175
|
let sealed = []
|
|
2035
2176
|
if (apply && !smart && !args[0]) {
|
|
2036
2177
|
try { input = stripControlChars(fs.readFileSync(path.join(eng, DEBRIEF_PROPOSE), 'utf8')) } catch (_) {
|
|
2037
2178
|
console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
|
|
2038
|
-
process.
|
|
2179
|
+
return void (process.exitCode = 1)
|
|
2039
2180
|
}
|
|
2040
2181
|
sealed = readSealedProposal(eng)
|
|
2041
2182
|
const expected = readSealCount(eng)
|
|
2042
2183
|
if (expected === null ? (!sealed.length && input.includes(PRIVATE_MARKER)) : sealed.length < expected) {
|
|
2043
2184
|
console.error(`refused: the proposal seals a private note but ${DEBRIEF_PRIVATE} is missing or unreadable - applying now would drop it silently.`)
|
|
2044
2185
|
console.error('re-run the propose step (fde debrief --smart <notes> | fde ingest propose <id>).')
|
|
2045
|
-
process.
|
|
2186
|
+
return void (process.exitCode = 1)
|
|
2046
2187
|
}
|
|
2047
2188
|
} else {
|
|
2048
2189
|
input = readDebriefInput(args)
|
|
2049
2190
|
}
|
|
2050
2191
|
|
|
2051
2192
|
if (smart) {
|
|
2052
|
-
const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
|
|
2193
|
+
const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input), { replace })
|
|
2194
|
+
boundedDebriefPreview(eng, () => {
|
|
2053
2195
|
console.log('SMART PROPOSE (heuristic - review before apply; no new facts invented beyond line rewrites)\n')
|
|
2054
|
-
printDebriefReview(clean)
|
|
2196
|
+
printDebriefReview(clean, eng)
|
|
2055
2197
|
console.log('Prefix vocabulary (lines that route): decision: risk: delivery: contact: next: signer:')
|
|
2056
2198
|
console.log('Optional on a decision: [approved: Name YYYY-MM-DD]. Missing means unconfirmed.')
|
|
2057
2199
|
console.log('Everything else → context.md. Keep the prefixes; the preview gate stays.\n')
|
|
2058
2200
|
routeDebriefInput(eng, clean, { dry: true, force, sealed: blocks })
|
|
2201
|
+
}, { maxBytes: 8000 })
|
|
2059
2202
|
if (!apply) {
|
|
2060
2203
|
console.log(`\nproposal saved → ${proposePath}`)
|
|
2061
2204
|
console.log('confirm: fde debrief --apply')
|
|
@@ -2066,14 +2209,12 @@ function cmdDebrief(args) {
|
|
|
2066
2209
|
sealed = blocks
|
|
2067
2210
|
}
|
|
2068
2211
|
|
|
2069
|
-
const
|
|
2212
|
+
const route = () => routeDebriefInput(eng, input, { dry, force, sealed })
|
|
2213
|
+
const { counts, ctxLines, privateBlocks } = boundedDebriefPreview(eng, route, { proposal: false, maxBytes: smart ? 4000 : 12000 })
|
|
2070
2214
|
if (!dry) {
|
|
2071
2215
|
const hash = commitMemory(eng, 'debrief', {
|
|
2072
2216
|
files: ['decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md', 'success.md', 'context.md', SIGNAL_LEDGER],
|
|
2073
2217
|
})
|
|
2074
|
-
try { fs.unlinkSync(path.join(eng, DEBRIEF_PROPOSE)) } catch (_) {}
|
|
2075
|
-
try { fs.unlinkSync(path.join(eng, DEBRIEF_PRIVATE)) } catch (_) {}
|
|
2076
|
-
try { fs.unlinkSync(path.join(eng, DEBRIEF_SEAL)) } catch (_) {}
|
|
2077
2218
|
if (hash) console.log(`memory @${hash}`)
|
|
2078
2219
|
}
|
|
2079
2220
|
const plural = {
|
|
@@ -2193,10 +2334,14 @@ function cmdIngest(args) {
|
|
|
2193
2334
|
console.error(`ingest propose refused: staged item is over ${DEBRIEF_MAX_BYTES} bytes after provenance. Split it.`)
|
|
2194
2335
|
process.exit(1)
|
|
2195
2336
|
}
|
|
2196
|
-
|
|
2337
|
+
let proposal
|
|
2338
|
+
try { proposal = writeProposal(eng, smartProposeText(input)) } catch (error) { console.error(error.message); process.exitCode = 1; return }
|
|
2339
|
+
const { proposePath, clean, blocks } = proposal
|
|
2340
|
+
boundedDebriefPreview(eng, () => {
|
|
2197
2341
|
console.log(`INGEST PROPOSE from ${path.basename(item)} (via:${source})\n`)
|
|
2198
|
-
printDebriefReview(clean)
|
|
2342
|
+
printDebriefReview(clean, eng)
|
|
2199
2343
|
routeDebriefInput(eng, clean, { dry: true, force: false, sealed: blocks })
|
|
2344
|
+
})
|
|
2200
2345
|
console.log(`\nproposal saved → ${proposePath}`)
|
|
2201
2346
|
console.log('confirm: fde ingest apply')
|
|
2202
2347
|
console.log('(agent: rewrite lines with decision:/risk:/contact:/next: prefixes before apply)')
|
|
@@ -2269,60 +2414,80 @@ function cmdIngest(args) {
|
|
|
2269
2414
|
}
|
|
2270
2415
|
|
|
2271
2416
|
function cmdReceipts(args) {
|
|
2272
|
-
const term = args.join(' ')
|
|
2273
|
-
if (!term) { console.error('usage: fde receipts <search term>'); process.exit(
|
|
2417
|
+
const term = args.join(' ').trim()
|
|
2418
|
+
if (!term) { console.error('usage: fde receipts <search term>'); process.exit(2) }
|
|
2274
2419
|
const eng = resolveEngagement()
|
|
2275
|
-
if (!eng) { console.error('no engagement -
|
|
2276
|
-
const
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
if (rx.test(l)) hits.push(` ${f}:${i + 1} ${l.trim().slice(0, 160)}`)
|
|
2293
|
-
})
|
|
2294
|
-
}
|
|
2295
|
-
return hits
|
|
2296
|
-
}
|
|
2297
|
-
const agreed = collect(AGREEMENTS)
|
|
2298
|
-
const claimed = collect(CLAIMS)
|
|
2299
|
-
const dirty = memoryDirtyManual(eng)
|
|
2300
|
-
const dirtySet = new Set(dirty)
|
|
2301
|
-
const dirtyAgreedHits = [...new Set(
|
|
2302
|
-
agreed.map(h => (h.match(/^\s*([^:]+):/) || [])[1]).filter(f => f && dirtySet.has(f))
|
|
2303
|
-
)]
|
|
2304
|
-
if (agreed.length) {
|
|
2305
|
-
console.log('ON RECORD (dated):')
|
|
2306
|
-
agreed.forEach(h => {
|
|
2307
|
-
const file = (h.match(/^\s*([^:]+):/) || [])[1]
|
|
2308
|
-
console.log(h + (file && dirtySet.has(file) ? ' ⚠ dirty file' : ''))
|
|
2420
|
+
if (!eng) { console.error('no engagement - bind a client first'); process.exit(2) }
|
|
2421
|
+
const recordFiles = ['decisions.md', 'delivery.md', 'success.md', 'risks.md', 'stakeholders.md']
|
|
2422
|
+
const workingFiles = ['brief.md', 'assumptions.md', 'reality.md', 'context.md']
|
|
2423
|
+
const dirty = new Set(memoryDirtyManual(eng))
|
|
2424
|
+
const records = [], claims = []
|
|
2425
|
+
for (const file of [...recordFiles, ...workingFiles]) {
|
|
2426
|
+
const document = readClean(eng, file)
|
|
2427
|
+
const decisionSources = new Map()
|
|
2428
|
+
if (file === 'decisions.md') for (const entry of datedDecisions(document)) {
|
|
2429
|
+
const source = sourceReference(entry.text)
|
|
2430
|
+
for (let line = entry.line; line < entry.line + entry.text.split('\n').length; line++) decisionSources.set(line, source)
|
|
2431
|
+
}
|
|
2432
|
+
document.split('\n').forEach((line, i) => {
|
|
2433
|
+
if (!line.toLowerCase().includes(term.toLowerCase())) return
|
|
2434
|
+
const source = decisionSources.get(i + 1) || sourceReference(line)
|
|
2435
|
+
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' : ''}`
|
|
2436
|
+
;(recordFiles.includes(file) && source ? records : claims).push(hit)
|
|
2309
2437
|
})
|
|
2310
|
-
if (dirtyAgreedHits.length) {
|
|
2311
|
-
console.log(
|
|
2312
|
-
`⚠ memory dirty (uncommitted manual edits: ${dirtyAgreedHits.join(', ')}) - dated lines above may not match the tamper-evident ledger until reviewed`
|
|
2313
|
-
)
|
|
2314
|
-
}
|
|
2315
|
-
}
|
|
2316
|
-
if (claimed.length) {
|
|
2317
|
-
if (agreed.length) console.log('')
|
|
2318
|
-
console.log('CLAIMS & working notes (stated, NOT an agreement - verify before citing):')
|
|
2319
|
-
claimed.forEach(h => console.log(h))
|
|
2320
2438
|
}
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2439
|
+
const sections = ['RECEIPTS: a cited record is not proof of customer approval. File line numbers refer to the redacted view.']
|
|
2440
|
+
if (records.length) sections.push('ON RECORD (dated, source-backed):\n' + records.join('\n'))
|
|
2441
|
+
if (claims.length) sections.push('CLAIMS & working notes (verify source and approval before citing):\n' + claims.join('\n'))
|
|
2442
|
+
if (!records.length && !claims.length) sections.push(`no record of "${term}" - a gap in the record, not proof of absence`)
|
|
2443
|
+
process.stdout.write(context.boundedSections(sections))
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
// Portable snapshot; stdout is read-only. --out creates a new file and never
|
|
2447
|
+
// overwrites an engagement record, existing file, or symlink.
|
|
2448
|
+
function cmdHandoff(args, label = 'Handoff') {
|
|
2449
|
+
let parsed
|
|
2450
|
+
try { parsed = context.budgetArgs(args) } catch (e) { console.error(e.message); process.exit(1) }
|
|
2451
|
+
let out = ''
|
|
2452
|
+
if (parsed.args.length) {
|
|
2453
|
+
if (parsed.args.length !== 2 || parsed.args[0] !== '--out' || !parsed.args[1] || parsed.args[1].startsWith('--')) {
|
|
2454
|
+
console.error('usage: fde handoff [--out new-file.md] [--max-bytes 4096..65536]'); process.exit(1)
|
|
2455
|
+
}
|
|
2456
|
+
out = path.resolve(parsed.args[1])
|
|
2325
2457
|
}
|
|
2458
|
+
const eng = resolveEngagement()
|
|
2459
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
2460
|
+
const success = stripTemplateNoise(readClean(eng, 'success.md'))
|
|
2461
|
+
const signer = ((success.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
2462
|
+
const ledger = parseValueLedger(eng).rows
|
|
2463
|
+
const rowText = r => `- ${r.slice || 'Unnamed slice'}: promised ${r.promised || '(missing)'}; measured ${r.measured || '(missing)'}; accepted by ${r.accepted || '(missing)'}; evidence ${r.evidence || '(missing)'}`
|
|
2464
|
+
const decisions = datedDecisions(readClean(eng, 'decisions.md'))
|
|
2465
|
+
const selected = decisions.slice(-8)
|
|
2466
|
+
const records = selected.filter(d => hasSource(d.text))
|
|
2467
|
+
const claims = selected.filter(d => !hasSource(d.text))
|
|
2468
|
+
const decisionText = d => `${d.text} (decisions.md:${d.line}, redacted view)`
|
|
2469
|
+
const next = stripTemplateNoise(sectionBody(readClean(eng, 'context.md'), 'Next action', { lastNonEmpty: true }))
|
|
2470
|
+
const gaps = collectDoctorIssues(eng, { readiness: true })
|
|
2471
|
+
const report = context.boundedSections([
|
|
2472
|
+
`# ${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.`,
|
|
2473
|
+
`## Constraints - trust-profile.md\n${stripTemplateNoise(readClean(eng, 'trust-profile.md')) || '(missing)'}`,
|
|
2474
|
+
`## Signer and success - success.md\nSigner: ${signer || '(missing; do not infer)'}\n${success || '(missing)'}`,
|
|
2475
|
+
`## 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)'}`,
|
|
2476
|
+
`## Accepted value - recorded assertion with source\nOnly structured value-ledger rows are summarized here; review other notes in delivery.md before presenting or handing over this record.\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)'}`,
|
|
2477
|
+
`## 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.`,
|
|
2478
|
+
`## Gaps before relying on this packet\n${gaps.map(g => '- ' + g).join('\n') || '(no deterministic lint gaps; human review still required)'}`,
|
|
2479
|
+
], parsed.maxBytes)
|
|
2480
|
+
if (!out) { process.stdout.write(report); return }
|
|
2481
|
+
try {
|
|
2482
|
+
// Exclusive creation fails closed for files and links. Resolve the parent
|
|
2483
|
+
// first so a directory link cannot redirect an export into .fde/.
|
|
2484
|
+
const parent = fs.realpathSync(path.dirname(out))
|
|
2485
|
+
const target = path.join(parent, path.basename(out))
|
|
2486
|
+
const root = fs.realpathSync(eng)
|
|
2487
|
+
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')
|
|
2488
|
+
fs.writeFileSync(target, report, { flag: 'wx', mode: 0o600 })
|
|
2489
|
+
console.log(`${label.toLowerCase()} → ${out}`)
|
|
2490
|
+
} catch (e) { console.error(`could not export packet: ${e.message}`); process.exit(1) }
|
|
2326
2491
|
}
|
|
2327
2492
|
|
|
2328
2493
|
function cmdRecall(args) {
|
|
@@ -2552,7 +2717,33 @@ function silentCommitIssues(eng) {
|
|
|
2552
2717
|
// Deterministic fieldbook hygiene - shared by doctor + session TRIAGE.
|
|
2553
2718
|
// Silent when clean OR brand-new (no dated work yet). Never auto-rewrites.
|
|
2554
2719
|
// High-value moments: week-start (via triage), ship/close, after real work accrues.
|
|
2555
|
-
function
|
|
2720
|
+
function successContractIssues(success) {
|
|
2721
|
+
const issues = []
|
|
2722
|
+
const text = stripTemplateNoise(String(success || ''))
|
|
2723
|
+
const checks = []
|
|
2724
|
+
let active = -1
|
|
2725
|
+
for (const line of text.split('\n')) {
|
|
2726
|
+
const header = line.match(/^(?:\*\*)?(?:Done when|Acceptance check)(?:\s*\([^\n)]*\))?:(?:\*\*)?[^\S\n]*(.*)$/i)
|
|
2727
|
+
if (header) { checks.push(header[1].trim()); active = checks.length - 1; continue }
|
|
2728
|
+
if (/^#{1,6}\s|^\*\*[^*]+:/.test(line)) { active = -1; continue }
|
|
2729
|
+
if (active !== -1 && line.trim()) checks[active] += ` ${line.trim()}`
|
|
2730
|
+
}
|
|
2731
|
+
const observable = checks.some(check => {
|
|
2732
|
+
const stimulus = /\b(?:test|drill|replay|runs?|request|sample|given|when|simulate|inject|compare|restore|verified|observed|measured)\b/i.test(check)
|
|
2733
|
+
const result = /\b(?:returns?|rejects?|matches?|equals?|arrives?|alerts?|restores?|passes?|fails?|contains?|produces?|shows?|remains?|receives?)\b/i.test(check)
|
|
2734
|
+
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)
|
|
2735
|
+
return stimulus && result && target && !/\b(?:tbd|to be defined|improve|better|satisfactory|as expected|works well)\b/i.test(check)
|
|
2736
|
+
})
|
|
2737
|
+
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')
|
|
2738
|
+
const signerLine = ((text.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').replace(/\[source:[^\]]+\]/gi, '').trim()
|
|
2739
|
+
// A named primary signer may be followed by responsibilities or another
|
|
2740
|
+
// signer's role. Preserve the full record; validate only the leading name.
|
|
2741
|
+
const signer = (signerLine.match(/^((?:[A-Z]\.|[A-Z][\w'-]+)(?:\s+(?:[A-Z]\.|[A-Z][\w'-]+)){0,2})(?=\s*(?:[.,;:]|\(|$))/) || [])[1] || ''
|
|
2742
|
+
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')
|
|
2743
|
+
return issues
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
function collectDoctorIssues(eng, { readiness = false } = {}) {
|
|
2556
2747
|
const issues = []
|
|
2557
2748
|
const s = computeSignals(eng)
|
|
2558
2749
|
// stripTemplateNoise: a dated example inside a template comment is not work.
|
|
@@ -2577,7 +2768,7 @@ function collectDoctorIssues(eng) {
|
|
|
2577
2768
|
issues.push(`${f} is not a regular file - reads come back empty and every write fails; remove it and re-run any fde write`)
|
|
2578
2769
|
}
|
|
2579
2770
|
}
|
|
2580
|
-
if (fresh) return issues
|
|
2771
|
+
if (fresh && !readiness) return issues
|
|
2581
2772
|
|
|
2582
2773
|
if (s.phase === '?' || s.phase === 'unset') {
|
|
2583
2774
|
if (hasDatedWork) {
|
|
@@ -2609,7 +2800,11 @@ function collectDoctorIssues(eng) {
|
|
|
2609
2800
|
issues.push(`${file} has ${unclosed} unclosed <private> - everything after it is sealed, including notes added later`)
|
|
2610
2801
|
}
|
|
2611
2802
|
}
|
|
2803
|
+
const { hasSource } = require('./lib/provenance')
|
|
2804
|
+
const unsourced = readClean(eng, 'decisions.md').split('\n').filter(line => /^[-*]\s*\[\d{4}-\d{2}-\d{2}\]/.test(line.trim()) && !hasSource(line))
|
|
2805
|
+
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`)
|
|
2612
2806
|
const success = readClean(eng, 'success.md')
|
|
2807
|
+
if (readiness || /^(plan|ship|outcome|close)$/.test(s.phase)) issues.push(...successContractIssues(success))
|
|
2613
2808
|
if (!firstLine(success, 80)) issues.push('success.md has no stated done-definition - fill before plan/ship')
|
|
2614
2809
|
const ctxMd = readClean(eng, 'context.md')
|
|
2615
2810
|
if (!sectionBody(ctxMd, 'Next action', { lastNonEmpty: true })) {
|
|
@@ -2802,7 +2997,7 @@ function hasValueBucket(eng) {
|
|
|
2802
2997
|
}
|
|
2803
2998
|
|
|
2804
2999
|
// Shared classification keeps CLI, dashboard, and vault acceptance consistent.
|
|
2805
|
-
const { PENDING_CELL_RE, valueState } = require('./lib/value-ledger')
|
|
3000
|
+
const { PENDING_CELL_RE, valueState, evidenceSource } = require('./lib/value-ledger')
|
|
2806
3001
|
|
|
2807
3002
|
function parseValueLedger(eng) {
|
|
2808
3003
|
// Last section with actual rows, not merely the last non-empty one: a template
|
|
@@ -2832,7 +3027,7 @@ function parseValueLedger(eng) {
|
|
|
2832
3027
|
const evidence = cell(row, idx.evidence)
|
|
2833
3028
|
const acceptanceStatus = idx.acceptanceStatus === -1 ? undefined : cell(row, idx.acceptanceStatus)
|
|
2834
3029
|
const state = valueState({ measured, accepted, acceptanceStatus, evidence })
|
|
2835
|
-
rows.push({ slice, promised, measured, accepted, evidence, evidenceMissing: !
|
|
3030
|
+
rows.push({ slice, promised, measured, accepted, evidence, evidenceMissing: !evidenceSource(evidence), state })
|
|
2836
3031
|
}
|
|
2837
3032
|
return { rows, columnMissing: idx.accepted === -1 }
|
|
2838
3033
|
}
|
|
@@ -2936,8 +3131,26 @@ function hygieneTriageLines(eng) {
|
|
|
2936
3131
|
]
|
|
2937
3132
|
}
|
|
2938
3133
|
|
|
3134
|
+
function deliverySummaryFor(eng) {
|
|
3135
|
+
const signals = computeSignals(eng)
|
|
3136
|
+
const next = stripTemplateNoise(sectionBody(readClean(eng, 'context.md'), 'Next action', { lastNonEmpty: true })).trim()
|
|
3137
|
+
const signer = ((readClean(eng, 'success.md').match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
3138
|
+
return deliverySummary({ signals, next, hasNext: !!next,
|
|
3139
|
+
hasSigner: !!require('./lib/value-ledger').acceptanceName(signer),
|
|
3140
|
+
highRisks: extractRisks(eng).filter(r => r.severity === 'high').length,
|
|
3141
|
+
valueRows: parseValueLedger(eng).rows,
|
|
3142
|
+
quiet: signals.ageDays !== Infinity && signals.ageDays >= 3,
|
|
3143
|
+
})
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
function firstActionLine(eng) {
|
|
3147
|
+
const action = deliverySummaryFor(eng).firstAction
|
|
3148
|
+
return ` do first: ${previewLine(action.text, 140)} (${action.source}: ${previewLine(action.reason, 140)})`
|
|
3149
|
+
}
|
|
3150
|
+
|
|
2939
3151
|
function printTriageBlock(eng) {
|
|
2940
3152
|
console.log(resumeTriage(eng))
|
|
3153
|
+
console.log(firstActionLine(eng))
|
|
2941
3154
|
for (const line of hygieneTriageLines(eng)) console.log(line)
|
|
2942
3155
|
}
|
|
2943
3156
|
|
|
@@ -2960,16 +3173,15 @@ function recordDigest(eng) {
|
|
|
2960
3173
|
const target = ((success.match(/^\*\*Baseline[^\S\n]*→[^\S\n]*target:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
2961
3174
|
if (target) lines.push(` promised: ${target.slice(0, 110)}`)
|
|
2962
3175
|
}
|
|
2963
|
-
const decisions = readClean(eng, 'decisions.md').
|
|
2964
|
-
|
|
2965
|
-
for (const d of decisions) lines.push(` decided: ${formatDecisionRecord(d)}`)
|
|
3176
|
+
const decisions = datedDecisions(readClean(eng, 'decisions.md')).slice(-2)
|
|
3177
|
+
for (const d of decisions) lines.push(` decided: ${formatDecisionRecord(d.text)}; source: ${previewLine(sourceReference(d.text) || '(missing)', 100)}`)
|
|
2966
3178
|
return ['RECORD (read-only - success, delivery, decisions)', ...lines]
|
|
2967
3179
|
}
|
|
2968
3180
|
|
|
2969
|
-
function cmdDoctor() {
|
|
3181
|
+
function cmdDoctor(args = []) {
|
|
2970
3182
|
const eng = resolveEngagement()
|
|
2971
3183
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
2972
|
-
const issues = collectDoctorIssues(eng)
|
|
3184
|
+
const issues = collectDoctorIssues(eng, { readiness: args.includes('--ready') })
|
|
2973
3185
|
console.log(`FDE DOCTOR - ${engagementSlugFromPath(eng)}`)
|
|
2974
3186
|
printTriageBlock(eng)
|
|
2975
3187
|
if (!issues.length) {
|
|
@@ -3399,6 +3611,7 @@ function cmdDashboard(args) {
|
|
|
3399
3611
|
e.log = extractLog(e.dir)
|
|
3400
3612
|
e.stats = extractStats(e.dir)
|
|
3401
3613
|
e.valueRows = parseValueLedger(e.dir).rows
|
|
3614
|
+
e.hasSigner = !deliverySummaryFor(e.dir).gaps.some(g => g.kind === 'signer')
|
|
3402
3615
|
e.highRisks = e.risks.filter(r => r.severity === 'high').length
|
|
3403
3616
|
e.quiet = e.signals.ageDays !== Infinity && e.signals.ageDays >= 3
|
|
3404
3617
|
e.slug = slugify(e.name)
|
|
@@ -3423,7 +3636,13 @@ function cmdDashboard(args) {
|
|
|
3423
3636
|
const html = render.buildFieldbookHtml({ engagements, today, generatedAt: new Date().toISOString() })
|
|
3424
3637
|
|
|
3425
3638
|
try {
|
|
3639
|
+
const isRecordPath = p => p.split(path.sep).some(part => ['.fde', '.git'].includes(part.toLowerCase()))
|
|
3640
|
+
if (isRecordPath(outPath)) throw new Error('save the dashboard outside .fde/ and .git/; these folders hold records, not reports')
|
|
3641
|
+
let existingParent = path.dirname(outPath)
|
|
3642
|
+
while (!fs.existsSync(existingParent)) existingParent = path.dirname(existingParent)
|
|
3643
|
+
if (isRecordPath(fs.realpathSync(existingParent))) throw new Error('save the dashboard outside .fde/ and .git/; this path points into a record folder')
|
|
3426
3644
|
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
|
3645
|
+
if (isRecordPath(fs.realpathSync(path.dirname(outPath)))) throw new Error('save the dashboard outside .fde/ and .git/; this path points into a record folder')
|
|
3427
3646
|
atomicWriteFile(outPath, html)
|
|
3428
3647
|
} catch (e) {
|
|
3429
3648
|
failFs(e, 'write fieldbook', outPath)
|
|
@@ -3623,13 +3842,15 @@ function cmdVault(args) {
|
|
|
3623
3842
|
// here fabricates output: the fieldbook you see is what debrief/log actually
|
|
3624
3843
|
// wrote, so the demo cannot drift from the product.
|
|
3625
3844
|
const DEMO_SLUG = 'acme-payments'
|
|
3626
|
-
const DEMO_NOTES = `
|
|
3845
|
+
const DEMO_NOTES = `Fictional kickoff transcript - Acme payments, meeting 2026-09-10
|
|
3627
3846
|
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3847
|
+
We need read-only access to the payments repo and the last 90 days of audit logs. [source: meeting 2026-09-10]
|
|
3848
|
+
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]
|
|
3849
|
+
Proposed scope: repair reconciliation alerts; leave the connector rewrite out pending sponsor confirmation. [source: meeting 2026-09-10]
|
|
3850
|
+
Risk: nobody can name who owns the reconciliation job; it has failed silently twice since March. [source: meeting 2026-09-10]
|
|
3851
|
+
Priya Shah signs off on the acceptance test. [source: meeting 2026-09-10]
|
|
3852
|
+
Priya is travelling for two weeks - Tom is the day-to-day contact. [source: meeting 2026-09-10]
|
|
3853
|
+
Next action: get the reconciliation runbook from Tom before touching anything. [source: meeting 2026-09-10]
|
|
3633
3854
|
|
|
3634
3855
|
<private>
|
|
3635
3856
|
Priya hinted the previous vendor was let go mid-contract. Do not repeat this to the team.
|
|
@@ -3644,14 +3865,17 @@ const DEMO_LAND_ARTIFACTS = {
|
|
|
3644
3865
|
|
|
3645
3866
|
**As stated:** clean up payment reconciliation before the Q3 audit.
|
|
3646
3867
|
**What we heard instead:** nobody owns the reconciliation job, and it fails silently.
|
|
3647
|
-
**
|
|
3868
|
+
**Proposed out of scope:** the in-house connector rewrite; sponsor confirmation is still required.
|
|
3648
3869
|
`,
|
|
3649
3870
|
'success.md': `# Success
|
|
3650
3871
|
|
|
3651
|
-
|
|
3652
|
-
|
|
3872
|
+
**Done when:** Replay a failed settlement in staging; its alert arrives at the on-call queue within 15 minutes.
|
|
3873
|
+
**Primary value bucket:** risk-mitigation
|
|
3874
|
+
**Baseline → target:** no reliable alert → an alert within 15 minutes of a simulated failure.
|
|
3875
|
+
**Explicitly out of scope:** connector rewrite (proposed; not customer-approved).
|
|
3876
|
+
**Stakeholder who signs off:** Priya Shah [source: meeting 2026-09-10]
|
|
3653
3877
|
|
|
3654
|
-
|
|
3878
|
+
Acceptance is pending. The named signer identifies authority, not an approval.
|
|
3655
3879
|
`,
|
|
3656
3880
|
}
|
|
3657
3881
|
|
|
@@ -3705,11 +3929,13 @@ function cmdDemo(args) {
|
|
|
3705
3929
|
fdeops demo - a fake client, real commands, nothing sent anywhere
|
|
3706
3930
|
|
|
3707
3931
|
Sandbox: ${root}
|
|
3708
|
-
Fake client: Acme (payments platform).
|
|
3932
|
+
Fake client: Acme (payments platform). This writes fictional notes, local Git history, and HTML inside the sandbox above.
|
|
3933
|
+
It resets that sandbox on repeat runs; real client records stay untouched.
|
|
3934
|
+
Allow under five minutes. Fictional proposal approval is automatic in this demo only.`)
|
|
3709
3935
|
|
|
3710
3936
|
demoStep('1. Monday of week 1 - create the fieldbook for this client', ['resume', '--init', DEMO_SLUG], workspace, env)
|
|
3711
3937
|
demoStep('2. You walk out of the kickoff with messy notes - hand them over', ['debrief', '--smart', notes], workspace, env)
|
|
3712
|
-
demoStep('3.
|
|
3938
|
+
demoStep('3. Demo automatically confirms the fictional record (not customer acceptance)', ['debrief', '--apply'], workspace, env)
|
|
3713
3939
|
demoStep('4. Say where you are in the engagement', ['log', 'phase', 'land'], workspace, env)
|
|
3714
3940
|
const engDir = path.join(root, DEMO_SLUG, '.fde')
|
|
3715
3941
|
console.log(`\n${demoHead('5. During land, @fde drafts the brief and the definition of done with you')}`)
|
|
@@ -3730,11 +3956,12 @@ function cmdDemo(args) {
|
|
|
3730
3956
|
// about uncommitted manual edits - correct behaviour, wrong lesson for a demo.
|
|
3731
3957
|
const landHash = commitMemory(engDir, 'land: brief + success', { files: [...Object.keys(DEMO_LAND_ARTIFACTS), 'context.md'] })
|
|
3732
3958
|
if (landHash) console.log(` memory @${landHash}`)
|
|
3733
|
-
demoStep('6.
|
|
3734
|
-
demoStep('7.
|
|
3735
|
-
demoStep('8.
|
|
3736
|
-
demoStep('9.
|
|
3737
|
-
demoStep('10.
|
|
3959
|
+
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)
|
|
3960
|
+
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)
|
|
3961
|
+
demoStep('8. Next morning, a fresh agent session with no memory of any of this', ['resume'], workspace, env)
|
|
3962
|
+
demoStep('9. A meeting in ten minutes - what do you walk in knowing?', ['prep', 'sponsor check-in'], workspace, env)
|
|
3963
|
+
demoStep('10. Six weeks later: "we never agreed to drop the rewrite"', ['receipts', 'rewrite'], workspace, env)
|
|
3964
|
+
demoStep('11. The whole engagement on one page', ['dashboard'], workspace, env)
|
|
3738
3965
|
// cmdDashboard's default out path, computed rather than scraped from its output:
|
|
3739
3966
|
// a HOME with a space in it truncates any whitespace-delimited parse.
|
|
3740
3967
|
const html = path.join(root, 'fieldbook-current.html')
|
|
@@ -3743,8 +3970,10 @@ function cmdDemo(args) {
|
|
|
3743
3970
|
${demoHead('What just happened')}
|
|
3744
3971
|
|
|
3745
3972
|
- Every line above came from the real CLI - no canned output.
|
|
3746
|
-
- The kickoff notes became dated decisions, risks,
|
|
3747
|
-
|
|
3973
|
+
- The kickoff notes became proposed asks, scope, dated decisions, risks, and a
|
|
3974
|
+
next action. This demo applied fictional notes automatically after showing REVIEW.
|
|
3975
|
+
- PR#42 is fictional evidence for a measured result. The ledger still says claimed:
|
|
3976
|
+
naming Priya as signer does not mean she accepted the result.
|
|
3748
3977
|
- The <private> block in those notes never appears in resume, prep, receipts or
|
|
3749
3978
|
the dashboard - it is sealed in context.md and redacted from anything an agent
|
|
3750
3979
|
or a screen share can see.
|
|
@@ -3774,17 +4003,20 @@ function printUsage() {
|
|
|
3774
4003
|
fde log --undo remove the last CLI log/debrief entry from memory
|
|
3775
4004
|
fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
|
|
3776
4005
|
fde debrief --smart heuristic propose; REVIEW first (decided/asked/open/next/signer); --apply after one confirm
|
|
4006
|
+
--replace-proposal explicitly discard a pending review when proposing different notes
|
|
3777
4007
|
fde ingest stage … stage raw pull into <engagement>/.inbox/ (not .fde/)
|
|
3778
4008
|
fde ingest list list staged inbox items
|
|
3779
4009
|
fde ingest propose <id> smart-propose a staged item → .debrief-propose (confirm before apply)
|
|
3780
4010
|
fde ingest apply same as: fde debrief --apply
|
|
3781
4011
|
fde prep [label] grounded walk-in brief from existing .fde/ only
|
|
3782
|
-
fde doctor
|
|
4012
|
+
fde doctor [--ready] lint memory; --ready checks success before plan/build. Lint (stale signals, gaps). status/dashboard/resume print the same issues
|
|
3783
4013
|
fde redact <term> preview/remove lines containing a buried term (pass --apply to commit; subject never repeats the term)
|
|
3784
4014
|
fde tidy [--apply] propose consolidations; blesses hand-written dirty files when you apply
|
|
3785
4015
|
fde owner [set email] who keeps this engagement record
|
|
3786
4016
|
fde recall <topic> bounded, redacted source excerpts (--max-bytes 4096..65536)
|
|
3787
|
-
fde receipts <term>
|
|
4017
|
+
fde receipts <term> source-backed records versus claims
|
|
4018
|
+
fde defend sponsor readout: accepted assertions, claims, sources, gaps
|
|
4019
|
+
fde handoff [--out file] portable redacted packet; stdout by default, new file only
|
|
3788
4020
|
fde status [--all] value ledger, then trust (pass --all for full portfolio)
|
|
3789
4021
|
fde dashboard [--all] [--open] [--out <path>] bound fieldbook (pass --all for every client)
|
|
3790
4022
|
fde vault derived Obsidian vault of every engagement (--current for one, --redacted for a shared screen, --out <dir>)
|
|
@@ -3810,13 +4042,15 @@ switch (cmd) {
|
|
|
3810
4042
|
case 'debrief': cmdDebrief(args); break
|
|
3811
4043
|
case 'ingest': cmdIngest(args); break
|
|
3812
4044
|
case 'prep': cmdPrep(args); break
|
|
3813
|
-
case 'doctor': cmdDoctor(); break
|
|
4045
|
+
case 'doctor': cmdDoctor(args); break
|
|
3814
4046
|
case 'redact': cmdRedact(args); break
|
|
3815
4047
|
// `garden` was the name through 3.11.x; it keeps working.
|
|
3816
4048
|
case 'tidy':
|
|
3817
4049
|
case 'garden': cmdGarden(args); break
|
|
3818
4050
|
case 'owner': cmdOwner(args); break
|
|
3819
4051
|
case 'receipts': cmdReceipts(args); break
|
|
4052
|
+
case 'handoff': cmdHandoff(args); break
|
|
4053
|
+
case 'defend': cmdHandoff(args, 'Sponsor readout'); break
|
|
3820
4054
|
case 'capture': cmdCapture(); break
|
|
3821
4055
|
case 'preserve': cmdPreserve(); break
|
|
3822
4056
|
case 'status': cmdStatus(args); break
|