fdeops 3.27.0 → 3.28.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 +216 -78
- package/bin/check.js +15 -32
- package/bin/fde.js +268 -51
- package/bin/lib/render.js +2 -2
- package/bin/lib/trust.js +4 -28
- package/bin/lib/value-ledger.js +46 -1
- package/mcp/fdeops-ingest/package.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/fde/references/audit.md +3 -3
- package/skills/fde/references/debrief.md +13 -1
package/bin/fde.js
CHANGED
|
@@ -487,7 +487,11 @@ function formatFsError(err, action, target) {
|
|
|
487
487
|
return `cannot ${action} ${where}${code ? ` (${code})` : ''}${err && err.message && !code ? ': ' + err.message : ''}`
|
|
488
488
|
}
|
|
489
489
|
|
|
490
|
+
let debriefTransactionActive = false
|
|
491
|
+
const ownedDebriefLocks = new Set()
|
|
492
|
+
|
|
490
493
|
function failFs(err, action, target) {
|
|
494
|
+
if (debriefTransactionActive || ownedDebriefLocks.size) throw new Error(formatFsError(err, action, target))
|
|
491
495
|
console.error(formatFsError(err, action, target))
|
|
492
496
|
process.exit(1)
|
|
493
497
|
}
|
|
@@ -503,6 +507,7 @@ function refuseSymlinkWrite(p, opts = {}) {
|
|
|
503
507
|
const msg = st.isSymbolicLink()
|
|
504
508
|
? `refused: ${path.basename(p)} is a symlink - write would leave the engagement tree. Replace it with a real file.`
|
|
505
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)
|
|
506
511
|
if (opts.soft) return msg
|
|
507
512
|
console.error(msg)
|
|
508
513
|
process.exit(1)
|
|
@@ -518,6 +523,8 @@ function refuseSymlinkWrite(p, opts = {}) {
|
|
|
518
523
|
// Exclusive create lock + retry. Two parallel agent sessions (or hook + CLI)
|
|
519
524
|
// appending the same .fde file otherwise interleave/corrupt under load.
|
|
520
525
|
function withFileLock(targetPath, fn, opts = {}) {
|
|
526
|
+
if (ownedDebriefLocks.has(targetPath)) return fn()
|
|
527
|
+
if (debriefTransactionActive || ownedDebriefLocks.size) opts = { ...opts, soft: true }
|
|
521
528
|
const lockPath = targetPath + '.lock'
|
|
522
529
|
const deadline = Date.now() + 5000
|
|
523
530
|
while (true) {
|
|
@@ -833,7 +840,7 @@ function valueLedgerRowCount(body) {
|
|
|
833
840
|
return t.rows.filter(r => r.some(c => String(c || '').trim())).length
|
|
834
841
|
}
|
|
835
842
|
|
|
836
|
-
function appendValueLedgerRow(eng, cells) {
|
|
843
|
+
function appendValueLedgerRow(eng, cells, { skipCommit = false } = {}) {
|
|
837
844
|
ensureMemoryGit(eng)
|
|
838
845
|
const p = path.join(eng, 'delivery.md')
|
|
839
846
|
let row
|
|
@@ -880,7 +887,7 @@ function appendValueLedgerRow(eng, cells) {
|
|
|
880
887
|
atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
|
|
881
888
|
})
|
|
882
889
|
recordLastWrite(eng, 'delivery.md', row)
|
|
883
|
-
commitMemory(eng, 'log delivery', { files: ['delivery.md'] })
|
|
890
|
+
if (!skipCommit) commitMemory(eng, 'log delivery', { files: ['delivery.md'] })
|
|
884
891
|
}
|
|
885
892
|
|
|
886
893
|
function retireOpenRisks(eng, needle) {
|
|
@@ -976,7 +983,7 @@ const {
|
|
|
976
983
|
countOpenRisks,
|
|
977
984
|
} = createTrustApi({
|
|
978
985
|
fs, path, readClean, readEng, parseMdTable, sectionBody, SIGNAL_LEDGER, memoryDirtyManual,
|
|
979
|
-
stripTemplateNoise, stripLegendLines,
|
|
986
|
+
stripTemplateNoise, stripLegendLines, extractRisks,
|
|
980
987
|
})
|
|
981
988
|
|
|
982
989
|
// Stakeholders: columns are matched by header wording, not position - real
|
|
@@ -1092,14 +1099,14 @@ function extractStakeholders(eng) {
|
|
|
1092
1099
|
|
|
1093
1100
|
// Risks: table rows AND dated CLI/debrief bullets. Empty template cells ignored.
|
|
1094
1101
|
function extractRisks(eng) {
|
|
1095
|
-
const md = readClean(eng, 'risks.md')
|
|
1102
|
+
const md = stripTemplateNoise(readClean(eng, 'risks.md'))
|
|
1096
1103
|
const body = md.split(/^#{1,6}\s+Retired\b/im)[0] || md
|
|
1097
1104
|
const HIGH = /critical|blocker|exposure|breach|urgent|at risk|at stake|\brace\b|rollback|no test/i
|
|
1098
1105
|
const out = []
|
|
1099
1106
|
const seen = new Set()
|
|
1100
1107
|
const push = (text) => {
|
|
1101
1108
|
const t = String(text || '').trim()
|
|
1102
|
-
if (!t || seen.has(t.toLowerCase())) return
|
|
1109
|
+
if (!t || /^(?:\[[xX]\]\s|(?:closed|resolved|retired)\s*:)/i.test(t) || seen.has(t.toLowerCase())) return
|
|
1103
1110
|
seen.add(t.toLowerCase())
|
|
1104
1111
|
out.push({ text: t, severity: HIGH.test(t) ? 'high' : 'med' })
|
|
1105
1112
|
}
|
|
@@ -1107,13 +1114,23 @@ function extractRisks(eng) {
|
|
|
1107
1114
|
if (table) {
|
|
1108
1115
|
const riskIdx = colIndex(table.headers, /^risk$/i)
|
|
1109
1116
|
if (riskIdx !== -1) {
|
|
1110
|
-
|
|
1117
|
+
const statusIdx = colIndex(table.headers, /^status$/i)
|
|
1118
|
+
for (const cs of table.rows) {
|
|
1119
|
+
if (statusIdx !== -1 && /^(closed|resolved|retired)$/i.test((cs[statusIdx] || '').trim())) continue
|
|
1120
|
+
push(cs[riskIdx])
|
|
1121
|
+
}
|
|
1111
1122
|
}
|
|
1112
1123
|
}
|
|
1113
1124
|
for (const raw of body.split('\n')) {
|
|
1114
1125
|
const t = raw.trim()
|
|
1115
1126
|
const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
|
|
1116
1127
|
if (m) push(m[1])
|
|
1128
|
+
else {
|
|
1129
|
+
// Inherited Markdown may predate the dated CLI format. Keep its open
|
|
1130
|
+
// bullets visible; absence of a date does not mean absence of a risk.
|
|
1131
|
+
const bullet = t.match(/^[-*+]\s+(.*)$/)
|
|
1132
|
+
if (bullet && !/^\[[xX]\]\s/.test(bullet[1])) push(bullet[1].replace(/^\[ \]\s*/, ''))
|
|
1133
|
+
}
|
|
1117
1134
|
}
|
|
1118
1135
|
return out
|
|
1119
1136
|
}
|
|
@@ -1534,7 +1551,8 @@ function cmdLog(args) {
|
|
|
1534
1551
|
return
|
|
1535
1552
|
}
|
|
1536
1553
|
if (type === 'delivery' && text.includes('|')) {
|
|
1537
|
-
|
|
1554
|
+
let cells
|
|
1555
|
+
try { cells = deliveryCells(text) } catch (error) { console.error(error.message); process.exitCode = 1; return }
|
|
1538
1556
|
appendValueLedgerRow(eng, cells)
|
|
1539
1557
|
const hash = memoryHead(eng)
|
|
1540
1558
|
console.log(`logged → delivery.md (value ledger)${hash ? ` @${hash}` : ''}`)
|
|
@@ -1789,28 +1807,24 @@ function readDebriefInput(args) {
|
|
|
1789
1807
|
if (args[0]) {
|
|
1790
1808
|
const notesPath = args[0].replace(/^~/, HOME)
|
|
1791
1809
|
let st
|
|
1792
|
-
try { st = fs.statSync(notesPath) } catch (_) {
|
|
1810
|
+
try { st = fs.statSync(notesPath) } catch (_) { throw new Error(`cannot read ${args[0]}`) }
|
|
1793
1811
|
if (st.size > DEBRIEF_MAX_BYTES) {
|
|
1794
|
-
|
|
1795
|
-
process.exit(1)
|
|
1812
|
+
throw new Error(`debrief refused: ${args[0]} is ${st.size} bytes (max ${DEBRIEF_MAX_BYTES}). Split the notes or paste the relevant section.`)
|
|
1796
1813
|
}
|
|
1797
1814
|
let buf
|
|
1798
|
-
try { buf = fs.readFileSync(notesPath) } catch (_) {
|
|
1815
|
+
try { buf = fs.readFileSync(notesPath) } catch (_) { throw new Error(`cannot read ${args[0]}`) }
|
|
1799
1816
|
if (buf.includes(0) || looksLikeBinaryNoise(buf.toString('utf8'))) {
|
|
1800
|
-
|
|
1801
|
-
process.exit(1)
|
|
1817
|
+
throw new Error(`debrief refused: ${args[0]} looks binary or mostly non-printable. Paste text notes only.`)
|
|
1802
1818
|
}
|
|
1803
1819
|
input = buf.toString('utf8')
|
|
1804
1820
|
} else {
|
|
1805
1821
|
let buf
|
|
1806
1822
|
try { buf = fs.readFileSync(0) } catch (_) { buf = Buffer.alloc(0) }
|
|
1807
1823
|
if (Buffer.byteLength(buf) > DEBRIEF_MAX_BYTES) {
|
|
1808
|
-
|
|
1809
|
-
process.exit(1)
|
|
1824
|
+
throw new Error(`debrief refused: stdin is over ${DEBRIEF_MAX_BYTES} bytes. Split the notes.`)
|
|
1810
1825
|
}
|
|
1811
1826
|
if (buf.includes(0) || looksLikeBinaryNoise(buf.toString('utf8'))) {
|
|
1812
|
-
|
|
1813
|
-
process.exit(1)
|
|
1827
|
+
throw new Error('debrief refused: stdin looks binary or mostly non-printable. Paste text notes only.')
|
|
1814
1828
|
}
|
|
1815
1829
|
input = buf.toString('utf8')
|
|
1816
1830
|
}
|
|
@@ -1823,15 +1837,29 @@ function previewLine(text, max = 240) {
|
|
|
1823
1837
|
return `${t.slice(0, max)}… (${t.length} chars)`
|
|
1824
1838
|
}
|
|
1825
1839
|
|
|
1826
|
-
function writeProposal(eng, text) {
|
|
1840
|
+
function writeProposal(eng, text, { replace = false, locked = false } = {}) {
|
|
1841
|
+
if (!locked && ownedDebriefLocks.has(path.join(eng, DEBRIEF_PROPOSE))) return writeProposal(eng, text, { replace, locked: true })
|
|
1842
|
+
if (!locked) return withFileLock(path.join(eng, DEBRIEF_PROPOSE), () => {
|
|
1843
|
+
ownedDebriefLocks.add(path.join(eng, DEBRIEF_PROPOSE))
|
|
1844
|
+
try { return writeProposal(eng, text, { replace, locked: true }) }
|
|
1845
|
+
finally { ownedDebriefLocks.delete(path.join(eng, DEBRIEF_PROPOSE)) }
|
|
1846
|
+
}, { soft: true })
|
|
1847
|
+
if (!debriefTransactionActive) return withDebriefRecords(eng, () => writeProposal(eng, text, { replace, locked: true }), [DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL])
|
|
1827
1848
|
const { clean, blocks } = splitPrivate(text, { sealDangling: true })
|
|
1828
1849
|
const proposePath = path.join(eng, DEBRIEF_PROPOSE)
|
|
1829
1850
|
const privatePath = path.join(eng, DEBRIEF_PRIVATE)
|
|
1851
|
+
if (fs.existsSync(proposePath) && !replace) {
|
|
1852
|
+
const existing = fs.readFileSync(proposePath, 'utf8')
|
|
1853
|
+
const existingPrivate = readSealedProposal(eng)
|
|
1854
|
+
if (existing !== clean || JSON.stringify(existingPrivate) !== JSON.stringify(blocks)) {
|
|
1855
|
+
throw new Error('pending proposal already exists. Review and apply it first, or explicitly replace it with fde debrief --smart <notes> --replace-proposal.')
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1830
1858
|
// Seal first. A refused or failed sidecar write must not leave behind a
|
|
1831
1859
|
// proposal whose (private - redacted) marker has nothing left behind it.
|
|
1832
1860
|
if (blocks.length) {
|
|
1833
1861
|
const blocked = refuseSymlinkWrite(privatePath, { soft: true })
|
|
1834
|
-
if (blocked)
|
|
1862
|
+
if (blocked) throw new Error(blocked)
|
|
1835
1863
|
withFileLock(privatePath, () => { atomicWriteFile(privatePath, sealedText(blocks), { mode: 0o600 }) })
|
|
1836
1864
|
try { fs.chmodSync(privatePath, 0o600) } catch (_) {}
|
|
1837
1865
|
} else {
|
|
@@ -1859,6 +1887,8 @@ function stripApprovedStamp(text) {
|
|
|
1859
1887
|
// One screen a human can confirm in two minutes. The file-by-file routing
|
|
1860
1888
|
// still prints after this - agents edit prefixes; people read this.
|
|
1861
1889
|
function printDebriefReview(text, eng) {
|
|
1890
|
+
const repeats = repeatedDebriefStatements(eng, text)
|
|
1891
|
+
if (repeats.length) console.log(`REPLAY WARNING: ${repeats.length} source-backed statement(s) already recorded. Review newer facts and next action; applying again requires --allow-replay.\n`)
|
|
1862
1892
|
const buckets = { decided: [], asked: [], scope: [], delivery: [], open: [], next: [], signer: [] }
|
|
1863
1893
|
for (const raw of String(text || '').split('\n')) {
|
|
1864
1894
|
const line = raw.trim().replace(/^[-*+]\s+/, '')
|
|
@@ -1898,12 +1928,13 @@ function printDebriefReview(text, eng) {
|
|
|
1898
1928
|
let any = false
|
|
1899
1929
|
for (const [label, items] of order) {
|
|
1900
1930
|
if (!items.length) {
|
|
1901
|
-
if (['stated asks', 'proposed scope', 'next action', 'named signer (authority, not approval)'].includes(label)) console.log(` ${label}: not
|
|
1931
|
+
if (['stated asks', 'proposed scope', 'next action', 'named signer (authority, not approval)'].includes(label)) console.log(` ${label}: not detected - review the notes`)
|
|
1902
1932
|
continue
|
|
1903
1933
|
}
|
|
1904
1934
|
any = true
|
|
1905
1935
|
console.log(` ${label}:`)
|
|
1906
|
-
for (const item of items) console.log(` - ${item}`)
|
|
1936
|
+
for (const item of items.slice(0, 5)) console.log(` - ${item}`)
|
|
1937
|
+
if (items.length > 5) console.log(` - ${items.length - 5} more omitted here; review the full proposal before applying.`)
|
|
1907
1938
|
}
|
|
1908
1939
|
if (!any) console.log(' (nothing prefixed yet - edit .debrief-propose, then apply)')
|
|
1909
1940
|
const recorded = eng ? parseValueLedger(eng).rows : []
|
|
@@ -1972,13 +2003,116 @@ function readSealedProposal(eng) {
|
|
|
1972
2003
|
} catch (_) { return [] }
|
|
1973
2004
|
}
|
|
1974
2005
|
|
|
1975
|
-
|
|
2006
|
+
// A debrief touches several records. Acquire every cooperating writer's lock
|
|
2007
|
+
// before the first append, and restore snapshots if an ordinary write fails.
|
|
2008
|
+
// This is not a power-loss transaction; no history is deleted or reset.
|
|
2009
|
+
function withDebriefRecords(eng, apply, files = ['decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md',
|
|
2010
|
+
'success.md', 'context.md', SIGNAL_LEDGER, LAST_WRITE, DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL]) {
|
|
2011
|
+
files = files.slice().sort()
|
|
2012
|
+
const snapshots = new Map()
|
|
2013
|
+
function lockAt(index) {
|
|
2014
|
+
if (index < files.length) {
|
|
2015
|
+
const target = path.join(eng, files[index])
|
|
2016
|
+
if (ownedDebriefLocks.has(target)) return lockAt(index + 1)
|
|
2017
|
+
return withFileLock(target, () => {
|
|
2018
|
+
ownedDebriefLocks.add(target)
|
|
2019
|
+
try { return lockAt(index + 1) } finally { ownedDebriefLocks.delete(target) }
|
|
2020
|
+
}, { soft: true })
|
|
2021
|
+
}
|
|
2022
|
+
for (const file of files) {
|
|
2023
|
+
const target = path.join(eng, file)
|
|
2024
|
+
const blocked = refuseSymlinkWrite(target, { soft: true })
|
|
2025
|
+
if (blocked) throw new Error(blocked)
|
|
2026
|
+
snapshots.set(target, fs.existsSync(target) ? { bytes: fs.readFileSync(target), mode: fs.statSync(target).mode & 0o777 } : null)
|
|
2027
|
+
}
|
|
2028
|
+
debriefTransactionActive = true
|
|
2029
|
+
try { return apply() } catch (error) {
|
|
2030
|
+
const failed = []
|
|
2031
|
+
for (const [target, previous] of snapshots) {
|
|
2032
|
+
try {
|
|
2033
|
+
if (previous) atomicWriteFile(target, previous.bytes, { mode: previous.mode, soft: true })
|
|
2034
|
+
else if (fs.existsSync(target)) fs.unlinkSync(target)
|
|
2035
|
+
} catch (_) { failed.push(path.basename(target)) }
|
|
2036
|
+
}
|
|
2037
|
+
if (failed.length) throw new Error(`${error.message}; recovery could not restore ${failed.join(', ')}. Inspect these records and the pending proposal before retrying.`)
|
|
2038
|
+
throw new Error(`${error.message}; no record changes kept. The proposal is retained; retry after resolving the cause.`)
|
|
2039
|
+
} finally { debriefTransactionActive = false }
|
|
2040
|
+
}
|
|
2041
|
+
return lockAt(0)
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// Limit model-facing review output while preserving the full editable proposal.
|
|
2045
|
+
function boundedDebriefPreview(eng, render, { proposal = true, maxBytes = 12000 } = {}) {
|
|
2046
|
+
const original = console.log, originalError = console.error
|
|
2047
|
+
let bytes = 0, omitted = 0
|
|
2048
|
+
const bounded = output => (...args) => {
|
|
2049
|
+
const line = args.join(' ') + '\n'
|
|
2050
|
+
const size = Buffer.byteLength(line)
|
|
2051
|
+
if (bytes + size > maxBytes) { omitted++; return }
|
|
2052
|
+
bytes += size; output(...args)
|
|
2053
|
+
}
|
|
2054
|
+
console.log = bounded(original)
|
|
2055
|
+
console.error = bounded(originalError)
|
|
2056
|
+
let result
|
|
2057
|
+
try { result = render() } finally { console.log = original; console.error = originalError }
|
|
2058
|
+
if (omitted) console.log(proposal
|
|
2059
|
+
? `\n${omitted} preview lines omitted. Review the complete proposal at ${path.join(eng, DEBRIEF_PROPOSE)} before applying.`
|
|
2060
|
+
: `\n${omitted} preview lines omitted. Review the full input notes before applying.`)
|
|
2061
|
+
return result
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
function deliveryCells(text) {
|
|
2065
|
+
const cells = text.split('|').map(s => s.trim())
|
|
2066
|
+
if (cells.length !== 7) throw new Error('delivery needs exactly 7 fields: Slice | Bucket | Promised | Measured | Accepted by | Evidence | Rollback. Use pending for unknowns; omit pipes for a narrative note.')
|
|
2067
|
+
return cells
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
// Exact sourced statement replay only; a shared source may contain new facts.
|
|
2071
|
+
// Do not silently deduplicate: the engineer decides whether a repeated event is intended.
|
|
2072
|
+
function repeatedDebriefStatements(eng, input) {
|
|
2073
|
+
const repeats = []
|
|
2074
|
+
const records = new Map()
|
|
2075
|
+
const normalize = value => value.replace(/\s*\|\s*/g, '|').replace(/\s+/g, ' ').trim()
|
|
2076
|
+
for (const raw of splitPrivate(input, { sealDangling: true }).clean.split('\n')) {
|
|
2077
|
+
const match = raw.trim().replace(/^[-*+]\s+/, '').match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
|
|
2078
|
+
if (!match || !/\[source:[^\]]+\]/i.test(match[2])) continue
|
|
2079
|
+
const body = match[2].trim()
|
|
2080
|
+
const file = LOG_FILES[match[1].toLowerCase()]
|
|
2081
|
+
if (!records.has(file)) {
|
|
2082
|
+
const statements = String(readClean(eng, file) || '').split('\n').map(line => {
|
|
2083
|
+
if (/^\s*\|/.test(line)) return normalize(line.trim().replace(/^\|\s*\d{4}-\d{2}-\d{2}\s*\|/, '').replace(/\|\s*$/, ''))
|
|
2084
|
+
return normalize(line.replace(/^\s*[-*+]\s+/, '').replace(/^(?:\[(?:\d{4}-\d{2}-\d{2}|@[^\]]+|signal:[^\]]+)\]\s*)+/, ''))
|
|
2085
|
+
})
|
|
2086
|
+
records.set(file, new Set(statements))
|
|
2087
|
+
}
|
|
2088
|
+
if (records.get(file).has(normalize(body))) repeats.push(body)
|
|
2089
|
+
}
|
|
2090
|
+
return repeats
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
function routeDebriefInput(eng, input, { dry, force, sealed = [], allowReplay = false }) {
|
|
2094
|
+
if (!dry && !debriefTransactionActive) {
|
|
2095
|
+
ensureMemoryGit(eng)
|
|
2096
|
+
return withDebriefRecords(eng, () => {
|
|
2097
|
+
const result = routeDebriefInput(eng, input, { dry, force, sealed, allowReplay })
|
|
2098
|
+
// Consuming the review is part of the write. If cleanup fails, restoring
|
|
2099
|
+
// both the records and proposal makes the next explicit apply safe.
|
|
2100
|
+
for (const file of [DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL]) {
|
|
2101
|
+
try { fs.unlinkSync(path.join(eng, file)) } catch (error) {
|
|
2102
|
+
if (error.code !== 'ENOENT') throw new Error(formatFsError(error, 'remove', file))
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
return result
|
|
2106
|
+
})
|
|
2107
|
+
}
|
|
2108
|
+
const repeats = repeatedDebriefStatements(eng, input)
|
|
2109
|
+
if (repeats.length && !dry && !allowReplay) throw new Error('source-backed statement already recorded; review the existing record and newer next action. Explicitly confirm a repeat with --allow-replay, or remove the repeated statement from the proposal.')
|
|
1976
2110
|
const d = new Date()
|
|
1977
2111
|
const date = d.toISOString().slice(0, 10)
|
|
1978
2112
|
const counts = { decision: 0, risk: 0, delivery: 0, contact: 0, next: 0, signer: 0 }
|
|
1979
2113
|
const ctxLines = []
|
|
1980
2114
|
let nextAction = ''
|
|
1981
|
-
ensureMemoryGit(eng)
|
|
2115
|
+
if (!dry) ensureMemoryGit(eng)
|
|
1982
2116
|
// Sealed blocks are pulled out before routing, so a <private> block's interior
|
|
1983
2117
|
// lines are never previewed and never routed into decisions/risks/stakeholders
|
|
1984
2118
|
// unsealed. They land verbatim in context.md instead: the preview a human
|
|
@@ -2002,7 +2136,7 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
|
|
|
2002
2136
|
const who = body.replace(/\s+signs?(?:\s+off)?\b.*$/i, '').trim() || body.trim()
|
|
2003
2137
|
if (dry) {
|
|
2004
2138
|
console.log(`→ success.md **Stakeholder who signs off:** ${previewLine(who)}`)
|
|
2005
|
-
console.log(`→ stakeholders.md ${previewLine(
|
|
2139
|
+
console.log(`→ stakeholders.md ${previewLine(`- [${date}] ${who} signs off`)}`)
|
|
2006
2140
|
} else {
|
|
2007
2141
|
setSigner(eng, who)
|
|
2008
2142
|
appendLogEntry(eng, 'contact', datedEntry(eng, date, `${who} signs off`), { skipCommit: true })
|
|
@@ -2016,9 +2150,16 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
|
|
|
2016
2150
|
counts.next++
|
|
2017
2151
|
continue
|
|
2018
2152
|
}
|
|
2153
|
+
if (type === 'delivery' && body.includes('|')) {
|
|
2154
|
+
const cells = deliveryCells(body)
|
|
2155
|
+
if (dry) console.log(`→ delivery.md ## Value ledger ${previewLine(body)}`)
|
|
2156
|
+
else appendValueLedgerRow(eng, cells, { skipCommit: true })
|
|
2157
|
+
counts.delivery++
|
|
2158
|
+
continue
|
|
2159
|
+
}
|
|
2019
2160
|
const sigInline = (body.match(/\[signal:(red|amber|green)\]/i) || [])[1]
|
|
2020
2161
|
if (sigInline) body = body.replace(/\[signal:(red|amber|green)\]/i, '').trim()
|
|
2021
|
-
const entry = datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
|
|
2162
|
+
const entry = dry ? `- [${date}]${type === 'contact' && sigInline ? ` [signal:${sigInline.toLowerCase()}]` : ''} ${body}` : datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
|
|
2022
2163
|
if (dry) console.log(`→ ${LOG_FILES[type]} ${previewLine(entry)}`)
|
|
2023
2164
|
else appendLogEntry(eng, type, entry, { skipCommit: true })
|
|
2024
2165
|
counts[type]++
|
|
@@ -2044,7 +2185,34 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
|
|
|
2044
2185
|
}
|
|
2045
2186
|
|
|
2046
2187
|
function cmdDebrief(args) {
|
|
2188
|
+
const eng = resolveEngagement({ forWrite: true })
|
|
2189
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exitCode = 2; return }
|
|
2190
|
+
const target = path.join(eng, DEBRIEF_PROPOSE)
|
|
2191
|
+
try {
|
|
2192
|
+
withFileLock(target, () => {
|
|
2193
|
+
ownedDebriefLocks.add(target)
|
|
2194
|
+
try { return runDebrief(args, eng) } finally { ownedDebriefLocks.delete(target) }
|
|
2195
|
+
}, { soft: true })
|
|
2196
|
+
} catch (error) { console.error(error.message); process.exitCode = 1 }
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
function runDebrief(args, eng) {
|
|
2047
2200
|
args = args.slice()
|
|
2201
|
+
const replayIdx = args.indexOf('--allow-replay')
|
|
2202
|
+
const allowReplay = replayIdx !== -1
|
|
2203
|
+
if (allowReplay) args.splice(replayIdx, 1)
|
|
2204
|
+
if (args.includes('--review')) {
|
|
2205
|
+
if (args.length !== 1 || allowReplay) throw new Error('use debrief --review alone to inspect the pending proposal')
|
|
2206
|
+
const proposal = path.join(eng, DEBRIEF_PROPOSE)
|
|
2207
|
+
const refused = refuseSymlinkWrite(proposal, { soft: true })
|
|
2208
|
+
if (refused) throw new Error(refused)
|
|
2209
|
+
if (!fs.existsSync(proposal)) throw new Error('nothing to review - run debrief --smart <notes> first')
|
|
2210
|
+
const { clean: input } = splitPrivate(fs.readFileSync(proposal, 'utf8'), { sealDangling: true })
|
|
2211
|
+
return boundedDebriefPreview(eng, () => {
|
|
2212
|
+
printDebriefReview(input, eng)
|
|
2213
|
+
routeDebriefInput(eng, input, { dry: true, force: false })
|
|
2214
|
+
})
|
|
2215
|
+
}
|
|
2048
2216
|
const dryIdx = args.indexOf('--dry-run')
|
|
2049
2217
|
const dry = dryIdx !== -1
|
|
2050
2218
|
if (dry) args.splice(dryIdx, 1)
|
|
@@ -2058,53 +2226,61 @@ function cmdDebrief(args) {
|
|
|
2058
2226
|
const forceIdx = args.indexOf('--force')
|
|
2059
2227
|
if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
|
|
2060
2228
|
|
|
2061
|
-
const
|
|
2062
|
-
|
|
2229
|
+
const replaceIdx = args.indexOf('--replace-proposal')
|
|
2230
|
+
const replace = replaceIdx !== -1
|
|
2231
|
+
if (replace) args.splice(replaceIdx, 1)
|
|
2232
|
+
if (replace && !smart) throw new Error('--replace-proposal requires --smart <notes>')
|
|
2063
2233
|
|
|
2234
|
+
if (!smart && !apply && !dry && fs.existsSync(path.join(eng, DEBRIEF_PROPOSE))) {
|
|
2235
|
+
throw new Error('pending proposal already exists. Review and apply it before writing another debrief.')
|
|
2236
|
+
}
|
|
2237
|
+
if (apply && !smart && args[0] && fs.existsSync(path.join(eng, DEBRIEF_PROPOSE))) {
|
|
2238
|
+
throw new Error('pending proposal already exists. Use debrief --apply without a notes file to apply that review.')
|
|
2239
|
+
}
|
|
2064
2240
|
let input = ''
|
|
2065
2241
|
let sealed = []
|
|
2066
2242
|
if (apply && !smart && !args[0]) {
|
|
2067
2243
|
try { input = stripControlChars(fs.readFileSync(path.join(eng, DEBRIEF_PROPOSE), 'utf8')) } catch (_) {
|
|
2068
2244
|
console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
|
|
2069
|
-
process.
|
|
2245
|
+
return void (process.exitCode = 1)
|
|
2070
2246
|
}
|
|
2071
2247
|
sealed = readSealedProposal(eng)
|
|
2072
2248
|
const expected = readSealCount(eng)
|
|
2073
2249
|
if (expected === null ? (!sealed.length && input.includes(PRIVATE_MARKER)) : sealed.length < expected) {
|
|
2074
2250
|
console.error(`refused: the proposal seals a private note but ${DEBRIEF_PRIVATE} is missing or unreadable - applying now would drop it silently.`)
|
|
2075
2251
|
console.error('re-run the propose step (fde debrief --smart <notes> | fde ingest propose <id>).')
|
|
2076
|
-
process.
|
|
2252
|
+
return void (process.exitCode = 1)
|
|
2077
2253
|
}
|
|
2078
2254
|
} else {
|
|
2079
2255
|
input = readDebriefInput(args)
|
|
2080
2256
|
}
|
|
2081
2257
|
|
|
2082
2258
|
if (smart) {
|
|
2083
|
-
const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
|
|
2259
|
+
const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input), { replace })
|
|
2260
|
+
boundedDebriefPreview(eng, () => {
|
|
2084
2261
|
console.log('SMART PROPOSE (heuristic - review before apply; no new facts invented beyond line rewrites)\n')
|
|
2085
2262
|
printDebriefReview(clean, eng)
|
|
2086
2263
|
console.log('Prefix vocabulary (lines that route): decision: risk: delivery: contact: next: signer:')
|
|
2087
2264
|
console.log('Optional on a decision: [approved: Name YYYY-MM-DD]. Missing means unconfirmed.')
|
|
2088
2265
|
console.log('Everything else → context.md. Keep the prefixes; the preview gate stays.\n')
|
|
2089
2266
|
routeDebriefInput(eng, clean, { dry: true, force, sealed: blocks })
|
|
2267
|
+
}, { maxBytes: 8000 })
|
|
2090
2268
|
if (!apply) {
|
|
2091
2269
|
console.log(`\nproposal saved → ${proposePath}`)
|
|
2092
2270
|
console.log('confirm: fde debrief --apply')
|
|
2093
|
-
console.log('(edit the propose file
|
|
2271
|
+
console.log('(edit the propose file if mis-routed; debrief --review shows the pending REVIEW)')
|
|
2094
2272
|
return
|
|
2095
2273
|
}
|
|
2096
2274
|
input = clean
|
|
2097
2275
|
sealed = blocks
|
|
2098
2276
|
}
|
|
2099
2277
|
|
|
2100
|
-
const
|
|
2278
|
+
const route = () => routeDebriefInput(eng, input, { dry, force, sealed, allowReplay })
|
|
2279
|
+
const { counts, ctxLines, privateBlocks } = boundedDebriefPreview(eng, route, { proposal: false, maxBytes: smart ? 4000 : 12000 })
|
|
2101
2280
|
if (!dry) {
|
|
2102
2281
|
const hash = commitMemory(eng, 'debrief', {
|
|
2103
2282
|
files: ['decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md', 'success.md', 'context.md', SIGNAL_LEDGER],
|
|
2104
2283
|
})
|
|
2105
|
-
try { fs.unlinkSync(path.join(eng, DEBRIEF_PROPOSE)) } catch (_) {}
|
|
2106
|
-
try { fs.unlinkSync(path.join(eng, DEBRIEF_PRIVATE)) } catch (_) {}
|
|
2107
|
-
try { fs.unlinkSync(path.join(eng, DEBRIEF_SEAL)) } catch (_) {}
|
|
2108
2284
|
if (hash) console.log(`memory @${hash}`)
|
|
2109
2285
|
}
|
|
2110
2286
|
const plural = {
|
|
@@ -2224,10 +2400,14 @@ function cmdIngest(args) {
|
|
|
2224
2400
|
console.error(`ingest propose refused: staged item is over ${DEBRIEF_MAX_BYTES} bytes after provenance. Split it.`)
|
|
2225
2401
|
process.exit(1)
|
|
2226
2402
|
}
|
|
2227
|
-
|
|
2403
|
+
let proposal
|
|
2404
|
+
try { proposal = writeProposal(eng, smartProposeText(input)) } catch (error) { console.error(error.message); process.exitCode = 1; return }
|
|
2405
|
+
const { proposePath, clean, blocks } = proposal
|
|
2406
|
+
boundedDebriefPreview(eng, () => {
|
|
2228
2407
|
console.log(`INGEST PROPOSE from ${path.basename(item)} (via:${source})\n`)
|
|
2229
2408
|
printDebriefReview(clean, eng)
|
|
2230
2409
|
routeDebriefInput(eng, clean, { dry: true, force: false, sealed: blocks })
|
|
2410
|
+
})
|
|
2231
2411
|
console.log(`\nproposal saved → ${proposePath}`)
|
|
2232
2412
|
console.log('confirm: fde ingest apply')
|
|
2233
2413
|
console.log('(agent: rewrite lines with decision:/risk:/contact:/next: prefixes before apply)')
|
|
@@ -2319,12 +2499,30 @@ function cmdReceipts(args) {
|
|
|
2319
2499
|
if (!line.toLowerCase().includes(term.toLowerCase())) return
|
|
2320
2500
|
const source = decisionSources.get(i + 1) || sourceReference(line)
|
|
2321
2501
|
const hit = ` ${file}:${i + 1} ${line.trim().slice(0, 160)}${source ? ` [source: ${source.slice(0, 160)}]` : ' [source missing]'}${dirty.has(file) ? ' dirty file - review manual edits' : ''}`
|
|
2322
|
-
;(recordFiles.includes(file) && source ? records : claims).push(hit)
|
|
2502
|
+
;(recordFiles.includes(file) && source ? records : claims).push({ file, hit })
|
|
2323
2503
|
})
|
|
2324
2504
|
}
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2505
|
+
// Alternate the latest and earliest matching lines per file. Otherwise a
|
|
2506
|
+
// long history can spend the entire packet on approvals before a withdrawal.
|
|
2507
|
+
const select = hits => {
|
|
2508
|
+
const groups = new Map()
|
|
2509
|
+
for (const { file, hit } of hits) {
|
|
2510
|
+
if (!groups.has(file)) groups.set(file, [])
|
|
2511
|
+
groups.get(file).push(hit)
|
|
2512
|
+
}
|
|
2513
|
+
const selected = []
|
|
2514
|
+
let latest = true
|
|
2515
|
+
while (selected.length < 24 && [...groups.values()].some(group => group.length)) {
|
|
2516
|
+
for (const group of groups.values()) {
|
|
2517
|
+
if (group.length && selected.length < 24) selected.push(latest ? group.pop() : group.shift())
|
|
2518
|
+
}
|
|
2519
|
+
latest = !latest
|
|
2520
|
+
}
|
|
2521
|
+
return `Selected ${selected.length} of ${hits.length} matching lines; omitted matches require a narrower search.\n` + selected.join('\n')
|
|
2522
|
+
}
|
|
2523
|
+
const sections = ['RECEIPTS: a cited record is not proof of customer approval. File line numbers refer to the redacted view. Latest and earliest matching lines are sampled; file order is not authority. Check conflicting records.']
|
|
2524
|
+
if (records.length) sections.push('ON RECORD (dated, source-backed):\n' + select(records))
|
|
2525
|
+
if (claims.length) sections.push('CLAIMS & working notes (verify source and approval before citing):\n' + select(claims))
|
|
2328
2526
|
if (!records.length && !claims.length) sections.push(`no record of "${term}" - a gap in the record, not proof of absence`)
|
|
2329
2527
|
process.stdout.write(context.boundedSections(sections))
|
|
2330
2528
|
}
|
|
@@ -2353,13 +2551,14 @@ function cmdHandoff(args, label = 'Handoff') {
|
|
|
2353
2551
|
const claims = selected.filter(d => !hasSource(d.text))
|
|
2354
2552
|
const decisionText = d => `${d.text} (decisions.md:${d.line}, redacted view)`
|
|
2355
2553
|
const next = stripTemplateNoise(sectionBody(readClean(eng, 'context.md'), 'Next action', { lastNonEmpty: true }))
|
|
2356
|
-
const gaps = collectDoctorIssues(eng)
|
|
2554
|
+
const gaps = collectDoctorIssues(eng, { readiness: true })
|
|
2357
2555
|
const report = context.boundedSections([
|
|
2358
2556
|
`# ${label}: ${engagementSlugFromPath(eng)}\nSnapshot: ${new Date().toISOString()} · memory ${memoryHead(eng) || 'unversioned'}\nRead-only record, not proof of approval. Confirm sources with the named customer before relying on a claim. Private blocks are excluded; review remaining client information before sharing.`,
|
|
2359
2557
|
`## Constraints - trust-profile.md\n${stripTemplateNoise(readClean(eng, 'trust-profile.md')) || '(missing)'}`,
|
|
2360
2558
|
`## Signer and success - success.md\nSigner: ${signer || '(missing; do not infer)'}\n${success || '(missing)'}`,
|
|
2361
2559
|
`## Next action - context.md\n${next || '(missing)'}\n\n## Open risks - risks.md\n${extractRisks(eng).map(r => '- ' + r.text).join('\n') || '(none recorded; not proof of no risk)'}`,
|
|
2362
|
-
|
|
2560
|
+
label === 'Handoff' ? `## Operational handoff - handoff.md\n${stripTemplateNoise(readClean(eng, 'handoff.md')) || '(missing; record recovery steps and the operating owner before rotation)'}` : '',
|
|
2561
|
+
`## 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 + ']' + (r.acceptanceIssue ? '; ' + r.acceptanceIssue : '')).join('\n') || '(none)'}`,
|
|
2363
2562
|
`## ON RECORD decisions - source supplied, not automatic approval\n${records.map(decisionText).join('\n') || '(none)'}\n\n## CLAIM decisions - source missing\n${claims.map(decisionText).join('\n') || '(none)'}\nSelected ${selected.length} of ${decisions.length} dated decisions. Retrieve older or conflicting decisions with fde recall.`,
|
|
2364
2563
|
`## Gaps before relying on this packet\n${gaps.map(g => '- ' + g).join('\n') || '(no deterministic lint gaps; human review still required)'}`,
|
|
2365
2564
|
], parsed.maxBytes)
|
|
@@ -2385,7 +2584,7 @@ function cmdRecall(args) {
|
|
|
2385
2584
|
}
|
|
2386
2585
|
const eng = resolveEngagement()
|
|
2387
2586
|
if (!eng) { console.error('no engagement - bind a client before recall'); process.exit(2) }
|
|
2388
|
-
const files = ['context.md', 'trust-profile.md', 'success.md', 'decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md', 'brief.md', 'reality.md', 'assumptions.md', 'terrain.md']
|
|
2587
|
+
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', 'handoff.md']
|
|
2389
2588
|
const result = context.recallSections(files.map(file => ({ file, text: readClean(eng, file) })), query)
|
|
2390
2589
|
process.stdout.write(context.boundedSections([
|
|
2391
2590
|
`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.`,
|
|
@@ -2615,12 +2814,20 @@ function successContractIssues(success) {
|
|
|
2615
2814
|
if (active !== -1 && line.trim()) checks[active] += ` ${line.trim()}`
|
|
2616
2815
|
}
|
|
2617
2816
|
const observable = checks.some(check => {
|
|
2817
|
+
// This is a lint check, not a semantic proof. Explicit fields let any
|
|
2818
|
+
// domain describe its test without depending on a vocabulary of verbs.
|
|
2819
|
+
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
|
|
2820
|
+
const vague = /\b(?:tbd|unknown|to be defined|improve|better|satisfactory|as expected|works well)\b/i
|
|
2821
|
+
if (vague.test(check)) return false
|
|
2822
|
+
const explicit = check.match(/(?:^|\s)(?:-\s*)?Input:\s*(.+?)\s+(?:-\s*)?Pass when:\s*(.+)$/i)
|
|
2823
|
+
if (explicit) return explicit[1].trim().length > 3 && target.test(explicit[2])
|
|
2618
2824
|
const stimulus = /\b(?:test|drill|replay|runs?|request|sample|given|when|simulate|inject|compare|restore|verified|observed|measured)\b/i.test(check)
|
|
2825
|
+
|| /^\d+\s+[a-z]/i.test(check)
|
|
2619
2826
|
const result = /\b(?:returns?|rejects?|matches?|equals?|arrives?|alerts?|restores?|passes?|fails?|contains?|produces?|shows?|remains?|receives?)\b/i.test(check)
|
|
2620
|
-
|
|
2621
|
-
return stimulus && result && target
|
|
2827
|
+
|| /\b(?:zero|no duplicate|no missing)\s+[a-z]/i.test(check)
|
|
2828
|
+
return stimulus && result && target.test(check)
|
|
2622
2829
|
})
|
|
2623
|
-
if (!observable) issues.push('success.md needs a binary acceptance check:
|
|
2830
|
+
if (!observable) issues.push('success.md needs a binary acceptance check: the wording was not recognized as a test/input and observable pass/fail result under **Done when:** or **Acceptance check:**. Use Input: and Pass when: for a domain-specific check; this lint does not prove readiness')
|
|
2624
2831
|
const signerLine = ((text.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').replace(/\[source:[^\]]+\]/gi, '').trim()
|
|
2625
2832
|
// A named primary signer may be followed by responsibilities or another
|
|
2626
2833
|
// signer's role. Preserve the full record; validate only the leading name.
|
|
@@ -2883,7 +3090,7 @@ function hasValueBucket(eng) {
|
|
|
2883
3090
|
}
|
|
2884
3091
|
|
|
2885
3092
|
// Shared classification keeps CLI, dashboard, and vault acceptance consistent.
|
|
2886
|
-
const { PENDING_CELL_RE, valueState, evidenceSource } = require('./lib/value-ledger')
|
|
3093
|
+
const { PENDING_CELL_RE, valueState, evidenceSource, reconcileValueRows } = require('./lib/value-ledger')
|
|
2887
3094
|
|
|
2888
3095
|
function parseValueLedger(eng) {
|
|
2889
3096
|
// Last section with actual rows, not merely the last non-empty one: a template
|
|
@@ -2913,9 +3120,9 @@ function parseValueLedger(eng) {
|
|
|
2913
3120
|
const evidence = cell(row, idx.evidence)
|
|
2914
3121
|
const acceptanceStatus = idx.acceptanceStatus === -1 ? undefined : cell(row, idx.acceptanceStatus)
|
|
2915
3122
|
const state = valueState({ measured, accepted, acceptanceStatus, evidence })
|
|
2916
|
-
rows.push({ slice, promised, measured, accepted, evidence, evidenceMissing: !evidenceSource(evidence), state })
|
|
3123
|
+
rows.push({ slice, promised, measured, accepted, acceptanceStatus, evidence, evidenceMissing: !evidenceSource(evidence), state })
|
|
2917
3124
|
}
|
|
2918
|
-
return { rows, columnMissing: idx.accepted === -1 }
|
|
3125
|
+
return { rows: reconcileValueRows(rows, readClean(eng, 'success.md')), columnMissing: idx.accepted === -1 }
|
|
2919
3126
|
}
|
|
2920
3127
|
|
|
2921
3128
|
function claimedValueRows(eng) {
|
|
@@ -2932,8 +3139,9 @@ function formatValueLedgerLine(r) {
|
|
|
2932
3139
|
}
|
|
2933
3140
|
const head = body ? `${name}: ${body}` : name
|
|
2934
3141
|
if (r.state === 'accepted') return `${head} · accepted by ${r.accepted}`
|
|
2935
|
-
|
|
2936
|
-
return `${head} · not yet
|
|
3142
|
+
const issue = r.acceptanceIssue ? `; ${r.acceptanceIssue}` : ''
|
|
3143
|
+
if (r.state === 'claimed') return `${head} · claimed, not yet accepted${issue}`
|
|
3144
|
+
return `${head} · not yet measured${issue}`
|
|
2937
3145
|
}
|
|
2938
3146
|
|
|
2939
3147
|
function valueLedgerStatusLines(eng, opts = {}) {
|
|
@@ -3522,7 +3730,13 @@ function cmdDashboard(args) {
|
|
|
3522
3730
|
const html = render.buildFieldbookHtml({ engagements, today, generatedAt: new Date().toISOString() })
|
|
3523
3731
|
|
|
3524
3732
|
try {
|
|
3733
|
+
const isRecordPath = p => p.split(path.sep).some(part => ['.fde', '.git'].includes(part.toLowerCase()))
|
|
3734
|
+
if (isRecordPath(outPath)) throw new Error('save the dashboard outside .fde/ and .git/; these folders hold records, not reports')
|
|
3735
|
+
let existingParent = path.dirname(outPath)
|
|
3736
|
+
while (!fs.existsSync(existingParent)) existingParent = path.dirname(existingParent)
|
|
3737
|
+
if (isRecordPath(fs.realpathSync(existingParent))) throw new Error('save the dashboard outside .fde/ and .git/; this path points into a record folder')
|
|
3525
3738
|
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
|
3739
|
+
if (isRecordPath(fs.realpathSync(path.dirname(outPath)))) throw new Error('save the dashboard outside .fde/ and .git/; this path points into a record folder')
|
|
3526
3740
|
atomicWriteFile(outPath, html)
|
|
3527
3741
|
} catch (e) {
|
|
3528
3742
|
failFs(e, 'write fieldbook', outPath)
|
|
@@ -3883,6 +4097,9 @@ function printUsage() {
|
|
|
3883
4097
|
fde log --undo remove the last CLI log/debrief entry from memory
|
|
3884
4098
|
fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
|
|
3885
4099
|
fde debrief --smart heuristic propose; REVIEW first (decided/asked/open/next/signer); --apply after one confirm
|
|
4100
|
+
--review inspect the pending REVIEW after editing, without replacing it
|
|
4101
|
+
--allow-replay explicitly apply already recorded sourced statements after review
|
|
4102
|
+
--replace-proposal explicitly discard a pending review when proposing different notes
|
|
3886
4103
|
fde ingest stage … stage raw pull into <engagement>/.inbox/ (not .fde/)
|
|
3887
4104
|
fde ingest list list staged inbox items
|
|
3888
4105
|
fde ingest propose <id> smart-propose a staged item → .debrief-propose (confirm before apply)
|
package/bin/lib/render.js
CHANGED
|
@@ -374,7 +374,7 @@ function deliveryHtml(e) {
|
|
|
374
374
|
return `<section class="fb-block fb-delivery" aria-label="Delivery evidence">
|
|
375
375
|
<div class="fb-sec-row"><h2 class="fb-sec">Delivery evidence</h2><span class="fb-count">${rows.length} outcome${rows.length === 1 ? '' : 's'}</span></div>
|
|
376
376
|
<p class="fb-evidence-note">Recorded in delivery.md. Acceptance reflects the saved record, not independent verification.</p>
|
|
377
|
-
${rows.length ? `<div class="fb-table-scroll" role="region" aria-label="Promised, measured and accepted outcomes" tabindex="0"><table class="fb-table fb-value-table"><thead><tr><th scope="col">Outcome</th><th scope="col">Promised</th><th scope="col">Measured</th><th scope="col">Acceptance</th><th scope="col">Evidence</th></tr></thead><tbody>${rows.map(r => `<tr><th scope="row">${inlineMd(r.slice || 'Outcome')}<span class="fb-value-state t-${r.state === 'accepted' ? 'green' : 'amber'}">${labels[r.state]}</span
|
|
377
|
+
${rows.length ? `<div class="fb-table-scroll" role="region" aria-label="Promised, measured and accepted outcomes" tabindex="0"><table class="fb-table fb-value-table"><thead><tr><th scope="col">Outcome</th><th scope="col">Promised</th><th scope="col">Measured</th><th scope="col">Acceptance</th><th scope="col">Evidence</th></tr></thead><tbody>${rows.map(r => `<tr><th scope="row">${inlineMd(r.slice || 'Outcome')}<span class="fb-value-state t-${r.state === 'accepted' ? 'green' : 'amber'}">${labels[r.state]}</span>${r.acceptanceIssue ? `<span class="fb-muted">${inlineMd(r.acceptanceIssue)}</span>` : ''}</th><td>${cell(r.promised, 'Not recorded')}</td><td>${cell(r.measured, 'Not yet measured')}</td><td>${cell(r.accepted, 'Not recorded')}</td><td>${cell(r.evidence, 'Not recorded')}</td></tr>`).join('')}</tbody></table></div>` : `<div class="fb-empty-evidence"><strong>No delivery evidence yet</strong><p>Ask your agent to define one useful increment: the expected result, how to measure it, and who will accept it.</p></div>`}
|
|
378
378
|
</section>`
|
|
379
379
|
}
|
|
380
380
|
|
|
@@ -573,7 +573,7 @@ ${railItems}
|
|
|
573
573
|
</aside>
|
|
574
574
|
<main id="fb-main" tabindex="-1" class="fb-main fb-scroll">
|
|
575
575
|
<div class="fb-main-inner">
|
|
576
|
-
<div class="fb-snapshot"><strong>Read-only snapshot</strong> · Snapshot generated <time datetime="${escapeHtml(generatedAt)}">${escapeHtml(generatedAt ? generatedAt.replace('T', ' ').replace(/\.\d+Z$/, ' UTC') : today)}</time>.
|
|
576
|
+
<div class="fb-snapshot"><strong>Read-only snapshot</strong> · Snapshot generated <time datetime="${escapeHtml(generatedAt)}">${escapeHtml(generatedAt ? generatedAt.replace('T', ' ').replace(/\.\d+Z$/, ' UTC') : today)}</time>. After updating your records, run the dashboard command that created this file again, keeping the same <code>--all</code> and <code>--out</code> options if used. Then reload. Reloading this page alone does not refresh the record.</div>
|
|
577
577
|
${todayView}
|
|
578
578
|
${clientViews}
|
|
579
579
|
<p id="fb-status" class="fb-copy-status" role="status" aria-live="polite"></p>
|