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/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
- for (const cs of table.rows) push(cs[riskIdx])
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
- const cells = text.split('|').map(s => s.trim())
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 (_) { console.error(`cannot read ${args[0]}`); process.exit(1) }
1810
+ try { st = fs.statSync(notesPath) } catch (_) { throw new Error(`cannot read ${args[0]}`) }
1793
1811
  if (st.size > DEBRIEF_MAX_BYTES) {
1794
- console.error(`debrief refused: ${args[0]} is ${st.size} bytes (max ${DEBRIEF_MAX_BYTES}). Split the notes or paste the relevant section.`)
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 (_) { console.error(`cannot read ${args[0]}`); process.exit(1) }
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
- console.error(`debrief refused: ${args[0]} looks binary or mostly non-printable. Paste text notes only.`)
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
- console.error(`debrief refused: stdin is over ${DEBRIEF_MAX_BYTES} bytes. Split the notes.`)
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
- console.error('debrief refused: stdin looks binary or mostly non-printable. Paste text notes only.')
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) { console.error(blocked); process.exit(1) }
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 stated`)
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
- function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
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(datedEntry(eng, date, `${who} signs off`))}`)
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 eng = resolveEngagement({ forWrite: true })
2062
- if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
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.exit(1)
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.exit(1)
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 first if a line mis-routed)')
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 { counts, ctxLines, privateBlocks } = routeDebriefInput(eng, input, { dry, force, sealed })
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
- const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
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
- const sections = ['RECEIPTS: a cited record is not proof of customer approval. File line numbers refer to the redacted view.']
2326
- if (records.length) sections.push('ON RECORD (dated, source-backed):\n' + records.join('\n'))
2327
- if (claims.length) sections.push('CLAIMS & working notes (verify source and approval before citing):\n' + claims.join('\n'))
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
- `## Accepted value - recorded assertion with source\n${ledger.filter(r => r.state === 'accepted').map(rowText).join('\n') || '(none)'}\n\n## CLAIMS and unmeasured promises\n${ledger.filter(r => r.state !== 'accepted').map(r => rowText(r) + ' [' + r.state + ']').join('\n') || '(none)'}`,
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
- const target = /(?:\b(?:within|under|at most|at least|exactly|zero|no missing|no duplicate|all|every|none|true|false|pass|fail|http)\b|[<>=])/i.test(check)
2621
- return stimulus && result && target && !/\b(?:tbd|to be defined|improve|better|satisfactory|as expected|works well)\b/i.test(check)
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: state a test/input and an observable pass/fail result under **Done when:** or **Acceptance check:**; numbers alone are not a 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
- if (r.state === 'claimed') return `${head} · claimed, not yet accepted`
2936
- return `${head} · not yet measured`
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></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>`}
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> &middot; Snapshot generated <time datetime="${escapeHtml(generatedAt)}">${escapeHtml(generatedAt ? generatedAt.replace('T', ' ').replace(/\.\d+Z$/, ' UTC') : today)}</time>. Re-run <code>fde dashboard</code> after updating your records. Reloading this page alone does not refresh the record.</div>
576
+ <div class="fb-snapshot"><strong>Read-only snapshot</strong> &middot; 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>