fdeops 3.9.13 → 3.9.14

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
@@ -401,6 +401,7 @@ const {
401
401
  memoryDirtyManual,
402
402
  commitMemory,
403
403
  memoryHead,
404
+ memoryGitHealthy,
404
405
  } = createMemoryApi({ fs, path, gitBinOk, writeOwnerIfMissing, atomicWriteFile })
405
406
 
406
407
  // Pull the body under a "## Heading" up to the next "##" (or EOF).
@@ -1095,6 +1096,10 @@ function smartProposeText(input) {
1095
1096
  let bare = line
1096
1097
  .replace(/^[-*+]\s+/, '')
1097
1098
  .replace(/^\*\*(decision|risk|delivery|contact|next):?\*\*:?\s*/i, '$1: ')
1099
+ if (/^decided:\s+/i.test(bare)) {
1100
+ out.push(`decision: ${bare.replace(/^decided:\s+/i, '')}`)
1101
+ continue
1102
+ }
1098
1103
  if (/^(decision|risk|delivery|contact|next):\s*/i.test(bare)) {
1099
1104
  let routed = bare.replace(/^(decision|risk|delivery|contact|next):\s*/i, (m, t) => `${t.toLowerCase()}: `)
1100
1105
  if (/^contact:/i.test(routed) && !/\[signal:(red|amber|green)\]/i.test(routed)) {
@@ -1110,7 +1115,7 @@ function smartProposeText(input) {
1110
1115
  out.push(`next: ${next}`)
1111
1116
  continue
1112
1117
  }
1113
- if (/\b(we (decided|agreed)|decision:|descope|agreed to|agreement was|freeze scope)\b/i.test(bare)) {
1118
+ if (/\b(we (decided|agreed)|decided:|decision:|descope|agreed to|agreement was|freeze scope|freeze prompts)\b/i.test(bare)) {
1114
1119
  out.push(`decision: ${bare}`)
1115
1120
  } else if (/\b(open question|who signs|unclear who|unresolved)\b/i.test(bare)) {
1116
1121
  out.push(`risk: ${bare}`)
@@ -1170,6 +1175,12 @@ function readDebriefInput(args) {
1170
1175
  return input
1171
1176
  }
1172
1177
 
1178
+ function previewLine(text, max = 240) {
1179
+ const t = String(text || '').replace(/\s+/g, ' ').trim()
1180
+ if (t.length <= max) return t
1181
+ return `${t.slice(0, max)}… (${t.length} chars)`
1182
+ }
1183
+
1173
1184
  function routeDebriefInput(eng, input, { dry, force }) {
1174
1185
  const d = new Date()
1175
1186
  const date = d.toISOString().slice(0, 10)
@@ -1191,7 +1202,7 @@ function routeDebriefInput(eng, input, { dry, force }) {
1191
1202
  continue
1192
1203
  }
1193
1204
  if (type === 'next') {
1194
- if (dry) console.log(`→ context.md ## Next action - ${body}`)
1205
+ if (dry) console.log(`→ context.md ## Next action - ${previewLine(body)}`)
1195
1206
  else nextAction = body
1196
1207
  counts.next++
1197
1208
  continue
@@ -1199,7 +1210,7 @@ function routeDebriefInput(eng, input, { dry, force }) {
1199
1210
  const sigInline = (body.match(/\[signal:(red|amber|green)\]/i) || [])[1]
1200
1211
  if (sigInline) body = body.replace(/\[signal:(red|amber|green)\]/i, '').trim()
1201
1212
  const entry = datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
1202
- if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`)
1213
+ if (dry) console.log(`→ ${LOG_FILES[type]} ${previewLine(entry)}`)
1203
1214
  else appendLogEntry(eng, type, entry, { skipCommit: true })
1204
1215
  counts[type]++
1205
1216
  } else {
@@ -1213,7 +1224,7 @@ function routeDebriefInput(eng, input, { dry, force }) {
1213
1224
  if (nextAction && !dry) setNextAction(eng, nextAction)
1214
1225
  if (ctxLines.length) {
1215
1226
  const stamp = `${date} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
1216
- if (dry) ctxLines.forEach(l => console.log(`→ context.md - ${l}`))
1227
+ if (dry) ctxLines.forEach(l => console.log(`→ context.md - ${previewLine(l)}`))
1217
1228
  else lockedAppendFile(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
1218
1229
  }
1219
1230
  return { counts, ctxLines, date, nextAction }
@@ -1308,9 +1319,22 @@ function cmdReceipts(args) {
1308
1319
  }
1309
1320
  const agreed = collect(AGREEMENTS)
1310
1321
  const claimed = collect(CLAIMS)
1322
+ const dirty = memoryDirtyManual(eng)
1323
+ const dirtySet = new Set(dirty)
1324
+ const dirtyAgreedHits = [...new Set(
1325
+ agreed.map(h => (h.match(/^\s*([^:]+):/) || [])[1]).filter(f => f && dirtySet.has(f))
1326
+ )]
1311
1327
  if (agreed.length) {
1312
1328
  console.log('ON RECORD (dated - defensible):')
1313
- agreed.forEach(h => console.log(h))
1329
+ agreed.forEach(h => {
1330
+ const file = (h.match(/^\s*([^:]+):/) || [])[1]
1331
+ console.log(h + (file && dirtySet.has(file) ? ' ⚠ dirty file' : ''))
1332
+ })
1333
+ if (dirtyAgreedHits.length) {
1334
+ console.log(
1335
+ `⚠ memory dirty (uncommitted manual edits: ${dirtyAgreedHits.join(', ')}) - dated lines above may not match the tamper-evident ledger until reviewed`
1336
+ )
1337
+ }
1314
1338
  }
1315
1339
  if (claimed.length) {
1316
1340
  if (agreed.length) console.log('')
@@ -1493,7 +1517,18 @@ function collectDoctorIssues(eng) {
1493
1517
  }
1494
1518
  if (s.stale) issues.push(`trust signal is STALE (${s.signalAge}d) - reconfirm with fde log contact ... --signal`)
1495
1519
  if (!readOwner(eng)) issues.push('no .owner - run any write or: fde owner set you@firm.com')
1496
- if (!fs.existsSync(path.join(eng, '.git'))) issues.push('memory not git-versioned - next write will init, or re-run resume --init')
1520
+ const gitHealth = memoryGitHealthy(eng)
1521
+ if (!gitHealth.ok) {
1522
+ if (gitHealth.reason === 'broken') {
1523
+ issues.push(
1524
+ 'memory git is BROKEN (UNVERSIONED) - receipts are not tamper-evident; repair: mv .fde/.git .fde/.git.broken && re-run any fde write (or resume --init) to re-init the ledger'
1525
+ )
1526
+ } else if (gitHealth.reason === 'no-git-bin') {
1527
+ issues.push('git binary missing - engagement memory cannot be versioned (receipts stay dated, not tamper-evident)')
1528
+ } else {
1529
+ issues.push('memory not git-versioned - next write will init, or re-run resume --init')
1530
+ }
1531
+ }
1497
1532
  const success = readClean(eng, 'success.md')
1498
1533
  if (!firstLine(success, 80)) issues.push('success.md has no stated done-definition - fill before plan/build')
1499
1534
  if (!sectionBody(readClean(eng, 'context.md'), 'Next action')) {
@@ -1731,10 +1766,24 @@ function cmdGarden(args) {
1731
1766
  const apply = args.includes('--apply')
1732
1767
  const eng = resolveEngagement({ forWrite: apply })
1733
1768
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1769
+ const gitHealth = memoryGitHealthy(eng)
1734
1770
  // Gardener contract (from Rowboat note_curation): no new facts, no deleted substance,
1735
- // reversible via git, confirm before apply. Mechanical only - no LLM rewrite.
1736
- console.log('GARDEN (contract: no new facts · no deleted substance · reversible via memory git)')
1771
+ // reversible via git when healthy, confirm before apply. Mechanical only - no LLM rewrite.
1772
+ if (gitHealth.ok) {
1773
+ console.log('GARDEN (contract: no new facts · no deleted substance · reversible via memory git)')
1774
+ } else if (gitHealth.reason === 'broken') {
1775
+ console.log('GARDEN (contract: no new facts · no deleted substance · ⚠ memory git BROKEN — NOT reversible until ledger is repaired)')
1776
+ } else {
1777
+ console.log('GARDEN (contract: no new facts · no deleted substance · ⚠ memory not git-versioned — NOT reversible)')
1778
+ }
1737
1779
  console.log(resumeTriage(eng))
1780
+ if (!gitHealth.ok) {
1781
+ console.log(
1782
+ gitHealth.reason === 'broken'
1783
+ ? '\n⚠ ledger is UNVERSIONED (corrupt .git). Repair before trusting garden apply: mv .fde/.git .fde/.git.broken && run any fde write to re-init.'
1784
+ : '\n⚠ no memory git — garden apply cannot create a reversible commit until the ledger exists.'
1785
+ )
1786
+ }
1738
1787
  const proposals = []
1739
1788
  const s = computeSignals(eng)
1740
1789
  if (s.stale) {
@@ -1744,6 +1793,16 @@ function cmdGarden(args) {
1744
1793
  text: `Reconfirm stale ${s.trust} signal (${s.signalAge}d): fde log contact "…" --signal`,
1745
1794
  })
1746
1795
  }
1796
+ const dupes = findDuplicateOpenRisks(eng)
1797
+ if (dupes.length) {
1798
+ const sample = (dupes[0][0] || '').replace(/\s+/g, ' ').trim().slice(0, 50)
1799
+ proposals.push({
1800
+ id: 'dedupe-risks',
1801
+ kind: 'apply',
1802
+ text: `Consolidate ${dupes.length} duplicate open-risk cluster(s) (e.g. "${sample}${sample.length >= 50 ? '…' : ''}") — keep first, retire echoes`,
1803
+ clusters: dupes,
1804
+ })
1805
+ }
1747
1806
  const ctx = readEng(eng, 'context.md')
1748
1807
  const sessionBlocks = []
1749
1808
  const lines = ctx.split('\n')
@@ -1769,12 +1828,26 @@ function cmdGarden(args) {
1769
1828
  proposals.forEach((p, i) => console.log(` ${i + 1}. [${p.kind}] ${p.text}`))
1770
1829
  if (!apply) {
1771
1830
  console.log('\nApply mechanical items only: fde garden --apply')
1772
- console.log('Manual items stay yours. Every apply commits to memory git.')
1831
+ console.log('Manual items stay yours. Every apply commits to memory git when the ledger is healthy.')
1773
1832
  return
1774
1833
  }
1834
+ if (!gitHealth.ok && gitHealth.reason === 'broken') {
1835
+ console.error('refusing garden --apply while memory git is broken - repair the ledger first')
1836
+ process.exit(1)
1837
+ }
1775
1838
  ensureMemoryGit(eng)
1776
1839
  let applied = 0
1840
+ const touched = new Set()
1777
1841
  for (const p of proposals) {
1842
+ if (p.id === 'dedupe-risks') {
1843
+ const n = applyRiskDedupe(eng, p.clusters)
1844
+ if (n > 0) {
1845
+ applied++
1846
+ touched.add('risks.md')
1847
+ console.log(`applied: retired ${n} duplicate open-risk echo(s) → ## Retired`)
1848
+ }
1849
+ continue
1850
+ }
1778
1851
  if (p.id !== 'archive-sessions') continue
1779
1852
  const cutDates = new Set(p.sessionBlocks.map(b => b.date))
1780
1853
  const keep = []
@@ -1808,13 +1881,61 @@ function cmdGarden(args) {
1808
1881
  atomicWriteFile(path.join(eng, 'context.md'), keep.join('\n').replace(/\n*$/, '\n'))
1809
1882
  })
1810
1883
  applied++
1884
+ touched.add('context.md')
1885
+ touched.add('context-archive.md')
1811
1886
  console.log(`applied: archived ${p.sessionBlocks.length} old session-end blocks → context-archive.md`)
1812
1887
  }
1813
- const hash = commitMemory(eng, 'garden', { files: ['context.md', 'context-archive.md'] })
1888
+ const hash = commitMemory(eng, 'garden', { files: [...touched] })
1814
1889
  if (!applied) console.log('no mechanical proposals applied (manual items remain)')
1815
1890
  else console.log(`garden done${hash ? ` @${hash}` : ''}`)
1816
1891
  }
1817
1892
 
1893
+ // Keep the first open-risk bullet per fingerprint; move later echoes under ## Retired.
1894
+ function applyRiskDedupe(eng, clusters) {
1895
+ const p = path.join(eng, 'risks.md')
1896
+ let md = readEng(eng, 'risks.md')
1897
+ if (!md) return 0
1898
+ const echoTexts = new Set()
1899
+ for (const group of clusters) {
1900
+ for (let i = 1; i < group.length; i++) echoTexts.add(group[i])
1901
+ }
1902
+ if (!echoTexts.size) return 0
1903
+ const retiredLines = []
1904
+ const kept = []
1905
+ let inRetired = false
1906
+ let moved = 0
1907
+ for (const raw of md.split('\n')) {
1908
+ const t = raw.trim()
1909
+ if (/^#{1,6}\s+Retired\b/i.test(t)) {
1910
+ inRetired = true
1911
+ kept.push(raw)
1912
+ continue
1913
+ }
1914
+ if (!inRetired) {
1915
+ const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
1916
+ if (m && echoTexts.has(m[1].trim())) {
1917
+ retiredLines.push(raw)
1918
+ moved++
1919
+ continue
1920
+ }
1921
+ }
1922
+ kept.push(raw)
1923
+ }
1924
+ if (!moved) return 0
1925
+ let out = kept.join('\n')
1926
+ if (!/^#{1,6}\s+Retired\b/im.test(out)) {
1927
+ out = out.replace(/\n*$/, '\n\n## Retired\n')
1928
+ }
1929
+ const stamp = new Date().toISOString().slice(0, 10)
1930
+ const block = retiredLines.map(l => {
1931
+ const body = l.trim().replace(/^-\s*/, '')
1932
+ return `- [${stamp}] (garden dedupe) ${body}`
1933
+ }).join('\n')
1934
+ out = appendUnderSection(out, 'Retired', block)
1935
+ withFileLock(p, () => { atomicWriteFile(p, out.endsWith('\n') ? out : out + '\n') })
1936
+ return moved
1937
+ }
1938
+
1818
1939
  function engagementSlugFromPath(eng) {
1819
1940
  return path.basename(path.dirname(eng))
1820
1941
  }
package/bin/lib/memory.js CHANGED
@@ -151,12 +151,32 @@ function createMemoryApi(deps) {
151
151
  } catch (_) { return '' }
152
152
  }
153
153
 
154
+ // True only when .git exists AND can resolve HEAD. A corrupt ledger (broken
155
+ // HEAD / missing objects) still has a .git directory — existsSync alone lied.
156
+ function memoryGitHealthy(eng) {
157
+ if (!eng) return { ok: false, reason: 'missing' }
158
+ if (!fs.existsSync(path.join(eng, '.git'))) return { ok: false, reason: 'missing' }
159
+ if (!gitBinOk()) return { ok: false, reason: 'no-git-bin' }
160
+ try {
161
+ execFileSync('git', ['rev-parse', '--verify', 'HEAD'], {
162
+ cwd: eng, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
163
+ })
164
+ execFileSync('git', ['status', '--porcelain'], {
165
+ cwd: eng, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'],
166
+ })
167
+ return { ok: true, reason: '' }
168
+ } catch (_) {
169
+ return { ok: false, reason: 'broken' }
170
+ }
171
+ }
172
+
154
173
  return {
155
174
  MEMORY_EPHEMERAL,
156
175
  memoryPorcelainPaths,
157
176
  memoryDirtyManual,
158
177
  commitMemory,
159
178
  memoryHead,
179
+ memoryGitHealthy,
160
180
  ensureMemoryGit,
161
181
  }
162
182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.9.13",
3
+ "version": "3.9.14",
4
4
  "description": "Field kit for engineers embedded in client work - a real CLI (recon, memory, portfolio), one @fde skill with field judgment on top, and hooks that make it automatic. Claude Code plugin and any agent that loads skills.",
5
5
  "bin": {
6
6
  "fdeops": "bin/install.js",