fdeops 3.9.13 → 3.9.15

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
@@ -107,9 +107,13 @@ function resolveEngagement(opts = {}) {
107
107
  // bind - env, registry, pointer, or in-repo .fde. Basename matching is
108
108
  // read-only convenience; writing on a folder-name guess contaminates clients.
109
109
  const forWrite = !!opts.forWrite
110
+ const accept = (p) => acceptEngagementPath(p, { forWrite })
110
111
  // 1) explicit env (back-compat: accept old FDEOS_ENGAGEMENT too)
111
112
  const env = (process.env.FDEOPS_ENGAGEMENT || process.env.FDEOS_ENGAGEMENT || '').replace(/^~/, HOME).trim()
112
- if (env && fs.existsSync(env)) return env
113
+ if (env) {
114
+ const ok = accept(env)
115
+ if (ok) return ok
116
+ }
113
117
  // 2) workspace registry binding (written by resume --init). Match the cwd OR
114
118
  // any ancestor of it - FDEs run commands from src/, packages/api/, etc., not
115
119
  // just the repo root where they bound. Nearest (deepest) registered ancestor
@@ -122,8 +126,8 @@ function resolveEngagement(opts = {}) {
122
126
  .filter(r => cwd === r.workspace || cwd.startsWith(r.workspace + path.sep))
123
127
  .sort((a, b) => b.workspace.length - a.workspace.length)[0]
124
128
  if (reg) {
125
- const p = path.join(ENGAGEMENTS_ROOT, reg.slug, '.fde')
126
- if (fs.existsSync(p)) return p
129
+ const ok = accept(path.join(ENGAGEMENTS_ROOT, reg.slug, '.fde'))
130
+ if (ok) return ok
127
131
  }
128
132
  // 3) global pointer file (back-compat: try old FDEOS-CLAUDE.md too)
129
133
  for (const ptrName of ['FDEOPS-CLAUDE.md', 'FDEOS-CLAUDE.md']) {
@@ -131,8 +135,8 @@ function resolveEngagement(opts = {}) {
131
135
  const ptr = fs.readFileSync(path.join(HOME, '.claude', ptrName), 'utf8')
132
136
  const m = ptr.match(/^(?:FDEOPS|FDEOS)_ENGAGEMENT=(.+)$/m)
133
137
  if (m) {
134
- const p = m[1].trim().replace(/^~/, HOME)
135
- if (fs.existsSync(p)) return p
138
+ const ok = accept(m[1].trim().replace(/^~/, HOME))
139
+ if (ok) return ok
136
140
  }
137
141
  } catch (_) {}
138
142
  }
@@ -150,14 +154,37 @@ function resolveEngagement(opts = {}) {
150
154
  )
151
155
  return null
152
156
  }
153
- process.stderr.write(`⚠ resolved engagement by directory name ("${slugGuess}"), not a saved binding (read-only). If this is the right client, run \`fde resume --init ${slugGuess}\` here to bind it before logging or debriefing.\n`)
154
- return guess
157
+ const ok = accept(guess)
158
+ if (ok) {
159
+ process.stderr.write(`⚠ resolved engagement by directory name ("${slugGuess}"), not a saved binding (read-only). If this is the right client, run \`fde resume --init ${slugGuess}\` here to bind it before logging or debriefing.\n`)
160
+ return ok
161
+ }
155
162
  }
156
163
  // 5) in-repo .fde (engagement-approved only)
157
- if (fs.existsSync(path.join(cwd, '.fde'))) return path.join(cwd, '.fde')
164
+ const inRepo = accept(path.join(cwd, '.fde'))
165
+ if (inRepo) return inRepo
158
166
  return null
159
167
  }
160
168
 
169
+ // Engagement memory must be a directory. A file named .fde used to yield a
170
+ // healthy-looking green TRIAGE then raw ENOTDIR on write - refuse loudly.
171
+ function acceptEngagementPath(p, opts = {}) {
172
+ if (!p || !fs.existsSync(p)) return null
173
+ try {
174
+ const st = fs.statSync(p)
175
+ if (st.isDirectory()) return p
176
+ const msg =
177
+ `engagement path is not a directory (memory missing/broken): ${p}\n` +
178
+ ' repair: remove that file, then re-run: fde resume --init <name>'
179
+ console.error(msg)
180
+ if (opts.forWrite) process.exit(1)
181
+ return null
182
+ } catch (e) {
183
+ if (opts.forWrite) failFs(e, 'open', p)
184
+ return null
185
+ }
186
+ }
187
+
161
188
  function templatesDir() {
162
189
  for (const c of [path.join(__dirname, '..', 'templates', '.fde'), path.join(__dirname, 'templates', '.fde')]) {
163
190
  if (fs.existsSync(c)) return c
@@ -172,11 +199,19 @@ function readEng(eng, f) {
172
199
  }
173
200
 
174
201
  // Redact private notes and template hints from every model-facing read.
202
+ // Also strip terminal control chars so poisoned memory cannot smuggle ANSI
203
+ // into triage/prep/status (C0/C1 except tab/LF/CR).
204
+ function stripControlChars(s) {
205
+ return String(s || '').replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
206
+ }
207
+
175
208
  function stripPrivate(md) {
176
- return md
177
- .replace(/<private>[\s\S]*?<\/private>/gi, '(private - redacted)')
178
- .replace(/<private>[\s\S]*$/i, '(private - redacted)')
179
- .replace(/<!--[\s\S]*?-->/g, '')
209
+ return stripControlChars(
210
+ String(md || '')
211
+ .replace(/<private>[\s\S]*?<\/private>/gi, '(private - redacted)')
212
+ .replace(/<private>[\s\S]*$/i, '(private - redacted)')
213
+ .replace(/<!--[\s\S]*?-->/g, '')
214
+ )
180
215
  }
181
216
 
182
217
  // Read + redact in one step - the default way dashboard code should ever touch
@@ -239,6 +274,9 @@ function formatFsError(err, action, target) {
239
274
  const code = err && err.code
240
275
  const where = path.basename(String(target || '')) || String(target || 'path')
241
276
  if (code === 'ENOSPC') return `cannot ${action} ${where} - disk full`
277
+ if (code === 'ENOTDIR') {
278
+ return `cannot ${action} ${where} - engagement path is not a directory (memory missing/broken); remove the file and re-run fde resume --init`
279
+ }
242
280
  if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
243
281
  return `cannot ${action} ${where} - permission denied (read-only or locked down)`
244
282
  }
@@ -392,7 +430,7 @@ function datedEntry(eng, date, text, signal) {
392
430
  const bits = [`- [${date}]`]
393
431
  if (who) bits.push(`[${who}]`)
394
432
  if (signal) bits.push(`[signal:${signal}]`)
395
- bits.push(text)
433
+ bits.push(stripControlChars(text))
396
434
  return bits.join(' ')
397
435
  }
398
436
 
@@ -401,6 +439,7 @@ const {
401
439
  memoryDirtyManual,
402
440
  commitMemory,
403
441
  memoryHead,
442
+ memoryGitHealthy,
404
443
  } = createMemoryApi({ fs, path, gitBinOk, writeOwnerIfMissing, atomicWriteFile })
405
444
 
406
445
  // Pull the body under a "## Heading" up to the next "##" (or EOF).
@@ -1095,6 +1134,10 @@ function smartProposeText(input) {
1095
1134
  let bare = line
1096
1135
  .replace(/^[-*+]\s+/, '')
1097
1136
  .replace(/^\*\*(decision|risk|delivery|contact|next):?\*\*:?\s*/i, '$1: ')
1137
+ if (/^decided:\s+/i.test(bare)) {
1138
+ out.push(`decision: ${bare.replace(/^decided:\s+/i, '')}`)
1139
+ continue
1140
+ }
1098
1141
  if (/^(decision|risk|delivery|contact|next):\s*/i.test(bare)) {
1099
1142
  let routed = bare.replace(/^(decision|risk|delivery|contact|next):\s*/i, (m, t) => `${t.toLowerCase()}: `)
1100
1143
  if (/^contact:/i.test(routed) && !/\[signal:(red|amber|green)\]/i.test(routed)) {
@@ -1110,7 +1153,7 @@ function smartProposeText(input) {
1110
1153
  out.push(`next: ${next}`)
1111
1154
  continue
1112
1155
  }
1113
- if (/\b(we (decided|agreed)|decision:|descope|agreed to|agreement was|freeze scope)\b/i.test(bare)) {
1156
+ if (/\b(we (decided|agreed)|decided:|decision:|descope|agreed to|agreement was|freeze scope|freeze prompts)\b/i.test(bare)) {
1114
1157
  out.push(`decision: ${bare}`)
1115
1158
  } else if (/\b(open question|who signs|unclear who|unresolved)\b/i.test(bare)) {
1116
1159
  out.push(`risk: ${bare}`)
@@ -1131,7 +1174,7 @@ function smartProposeText(input) {
1131
1174
 
1132
1175
  function setNextAction(eng, text) {
1133
1176
  ensureMemoryGit(eng)
1134
- const bullet = `- ${String(text).replace(/^[-*]\s+/, '').trim()}`
1177
+ const bullet = `- ${stripControlChars(String(text).replace(/^[-*]\s+/, '').trim())}`
1135
1178
  const p = path.join(eng, 'context.md')
1136
1179
  let md = readEng(eng, 'context.md')
1137
1180
  if (!md) md = '# Engagement context\n\n'
@@ -1143,6 +1186,24 @@ function setNextAction(eng, text) {
1143
1186
  withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
1144
1187
  }
1145
1188
 
1189
+ function looksLikeBinaryNoise(text) {
1190
+ const s = String(text || '')
1191
+ if (!s) return false
1192
+ if (s.includes('\0')) return true
1193
+ const sample = s.slice(0, 8192)
1194
+ let ctrl = 0
1195
+ let replacement = 0
1196
+ for (let i = 0; i < sample.length; i++) {
1197
+ const c = sample.charCodeAt(i)
1198
+ if (c === 0xfffd) replacement++
1199
+ if (c === 9 || c === 10 || c === 13) continue
1200
+ if (c < 32 || (c >= 0x7f && c <= 0x9f)) ctrl++
1201
+ }
1202
+ if (!sample.length) return false
1203
+ // Mostly-control or high U+FFFD density = urandom / binary mistyped as text.
1204
+ return (ctrl / sample.length) > 0.05 || (replacement / sample.length) > 0.1
1205
+ }
1206
+
1146
1207
  function readDebriefInput(args) {
1147
1208
  let input = ''
1148
1209
  if (args[0]) {
@@ -1155,19 +1216,31 @@ function readDebriefInput(args) {
1155
1216
  }
1156
1217
  let buf
1157
1218
  try { buf = fs.readFileSync(notesPath) } catch (_) { console.error(`cannot read ${args[0]}`); process.exit(1) }
1158
- if (buf.includes(0)) {
1159
- console.error(`debrief refused: ${args[0]} looks binary (null bytes). Paste text notes only.`)
1219
+ if (buf.includes(0) || looksLikeBinaryNoise(buf.toString('utf8'))) {
1220
+ console.error(`debrief refused: ${args[0]} looks binary or mostly non-printable. Paste text notes only.`)
1160
1221
  process.exit(1)
1161
1222
  }
1162
1223
  input = buf.toString('utf8')
1163
1224
  } else {
1164
- try { input = fs.readFileSync(0, 'utf8') } catch (_) {}
1165
- if (Buffer.byteLength(input, 'utf8') > DEBRIEF_MAX_BYTES) {
1225
+ let buf
1226
+ try { buf = fs.readFileSync(0) } catch (_) { buf = Buffer.alloc(0) }
1227
+ if (Buffer.byteLength(buf) > DEBRIEF_MAX_BYTES) {
1166
1228
  console.error(`debrief refused: stdin is over ${DEBRIEF_MAX_BYTES} bytes. Split the notes.`)
1167
1229
  process.exit(1)
1168
1230
  }
1231
+ if (buf.includes(0) || looksLikeBinaryNoise(buf.toString('utf8'))) {
1232
+ console.error('debrief refused: stdin looks binary or mostly non-printable. Paste text notes only.')
1233
+ process.exit(1)
1234
+ }
1235
+ input = buf.toString('utf8')
1169
1236
  }
1170
- return input
1237
+ return stripControlChars(input)
1238
+ }
1239
+
1240
+ function previewLine(text, max = 240) {
1241
+ const t = String(text || '').replace(/\s+/g, ' ').trim()
1242
+ if (t.length <= max) return t
1243
+ return `${t.slice(0, max)}… (${t.length} chars)`
1171
1244
  }
1172
1245
 
1173
1246
  function routeDebriefInput(eng, input, { dry, force }) {
@@ -1191,7 +1264,7 @@ function routeDebriefInput(eng, input, { dry, force }) {
1191
1264
  continue
1192
1265
  }
1193
1266
  if (type === 'next') {
1194
- if (dry) console.log(`→ context.md ## Next action - ${body}`)
1267
+ if (dry) console.log(`→ context.md ## Next action - ${previewLine(body)}`)
1195
1268
  else nextAction = body
1196
1269
  counts.next++
1197
1270
  continue
@@ -1199,7 +1272,7 @@ function routeDebriefInput(eng, input, { dry, force }) {
1199
1272
  const sigInline = (body.match(/\[signal:(red|amber|green)\]/i) || [])[1]
1200
1273
  if (sigInline) body = body.replace(/\[signal:(red|amber|green)\]/i, '').trim()
1201
1274
  const entry = datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
1202
- if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`)
1275
+ if (dry) console.log(`→ ${LOG_FILES[type]} ${previewLine(entry)}`)
1203
1276
  else appendLogEntry(eng, type, entry, { skipCommit: true })
1204
1277
  counts[type]++
1205
1278
  } else {
@@ -1213,7 +1286,7 @@ function routeDebriefInput(eng, input, { dry, force }) {
1213
1286
  if (nextAction && !dry) setNextAction(eng, nextAction)
1214
1287
  if (ctxLines.length) {
1215
1288
  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}`))
1289
+ if (dry) ctxLines.forEach(l => console.log(`→ context.md - ${previewLine(l)}`))
1217
1290
  else lockedAppendFile(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
1218
1291
  }
1219
1292
  return { counts, ctxLines, date, nextAction }
@@ -1239,7 +1312,7 @@ function cmdDebrief(args) {
1239
1312
 
1240
1313
  let input = ''
1241
1314
  if (apply && !smart && !args[0]) {
1242
- try { input = fs.readFileSync(path.join(eng, DEBRIEF_PROPOSE), 'utf8') } catch (_) {
1315
+ try { input = stripControlChars(fs.readFileSync(path.join(eng, DEBRIEF_PROPOSE), 'utf8')) } catch (_) {
1243
1316
  console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
1244
1317
  process.exit(1)
1245
1318
  }
@@ -1308,9 +1381,22 @@ function cmdReceipts(args) {
1308
1381
  }
1309
1382
  const agreed = collect(AGREEMENTS)
1310
1383
  const claimed = collect(CLAIMS)
1384
+ const dirty = memoryDirtyManual(eng)
1385
+ const dirtySet = new Set(dirty)
1386
+ const dirtyAgreedHits = [...new Set(
1387
+ agreed.map(h => (h.match(/^\s*([^:]+):/) || [])[1]).filter(f => f && dirtySet.has(f))
1388
+ )]
1311
1389
  if (agreed.length) {
1312
1390
  console.log('ON RECORD (dated - defensible):')
1313
- agreed.forEach(h => console.log(h))
1391
+ agreed.forEach(h => {
1392
+ const file = (h.match(/^\s*([^:]+):/) || [])[1]
1393
+ console.log(h + (file && dirtySet.has(file) ? ' ⚠ dirty file' : ''))
1394
+ })
1395
+ if (dirtyAgreedHits.length) {
1396
+ console.log(
1397
+ `⚠ memory dirty (uncommitted manual edits: ${dirtyAgreedHits.join(', ')}) - dated lines above may not match the tamper-evident ledger until reviewed`
1398
+ )
1399
+ }
1314
1400
  }
1315
1401
  if (claimed.length) {
1316
1402
  if (agreed.length) console.log('')
@@ -1493,7 +1579,18 @@ function collectDoctorIssues(eng) {
1493
1579
  }
1494
1580
  if (s.stale) issues.push(`trust signal is STALE (${s.signalAge}d) - reconfirm with fde log contact ... --signal`)
1495
1581
  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')
1582
+ const gitHealth = memoryGitHealthy(eng)
1583
+ if (!gitHealth.ok) {
1584
+ if (gitHealth.reason === 'broken') {
1585
+ issues.push(
1586
+ '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'
1587
+ )
1588
+ } else if (gitHealth.reason === 'no-git-bin') {
1589
+ issues.push('git binary missing - engagement memory cannot be versioned (receipts stay dated, not tamper-evident)')
1590
+ } else {
1591
+ issues.push('memory not git-versioned - next write will init, or re-run resume --init')
1592
+ }
1593
+ }
1497
1594
  const success = readClean(eng, 'success.md')
1498
1595
  if (!firstLine(success, 80)) issues.push('success.md has no stated done-definition - fill before plan/build')
1499
1596
  if (!sectionBody(readClean(eng, 'context.md'), 'Next action')) {
@@ -1731,10 +1828,24 @@ function cmdGarden(args) {
1731
1828
  const apply = args.includes('--apply')
1732
1829
  const eng = resolveEngagement({ forWrite: apply })
1733
1830
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1831
+ const gitHealth = memoryGitHealthy(eng)
1734
1832
  // 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)')
1833
+ // reversible via git when healthy, confirm before apply. Mechanical only - no LLM rewrite.
1834
+ if (gitHealth.ok) {
1835
+ console.log('GARDEN (contract: no new facts · no deleted substance · reversible via memory git)')
1836
+ } else if (gitHealth.reason === 'broken') {
1837
+ console.log('GARDEN (contract: no new facts · no deleted substance · ⚠ memory git BROKEN — NOT reversible until ledger is repaired)')
1838
+ } else {
1839
+ console.log('GARDEN (contract: no new facts · no deleted substance · ⚠ memory not git-versioned — NOT reversible)')
1840
+ }
1737
1841
  console.log(resumeTriage(eng))
1842
+ if (!gitHealth.ok) {
1843
+ console.log(
1844
+ gitHealth.reason === 'broken'
1845
+ ? '\n⚠ ledger is UNVERSIONED (corrupt .git). Repair before trusting garden apply: mv .fde/.git .fde/.git.broken && run any fde write to re-init.'
1846
+ : '\n⚠ no memory git — garden apply cannot create a reversible commit until the ledger exists.'
1847
+ )
1848
+ }
1738
1849
  const proposals = []
1739
1850
  const s = computeSignals(eng)
1740
1851
  if (s.stale) {
@@ -1744,6 +1855,16 @@ function cmdGarden(args) {
1744
1855
  text: `Reconfirm stale ${s.trust} signal (${s.signalAge}d): fde log contact "…" --signal`,
1745
1856
  })
1746
1857
  }
1858
+ const dupes = findDuplicateOpenRisks(eng)
1859
+ if (dupes.length) {
1860
+ const sample = (dupes[0][0] || '').replace(/\s+/g, ' ').trim().slice(0, 50)
1861
+ proposals.push({
1862
+ id: 'dedupe-risks',
1863
+ kind: 'apply',
1864
+ text: `Consolidate ${dupes.length} duplicate open-risk cluster(s) (e.g. "${sample}${sample.length >= 50 ? '…' : ''}") — keep first, retire echoes`,
1865
+ clusters: dupes,
1866
+ })
1867
+ }
1747
1868
  const ctx = readEng(eng, 'context.md')
1748
1869
  const sessionBlocks = []
1749
1870
  const lines = ctx.split('\n')
@@ -1769,12 +1890,26 @@ function cmdGarden(args) {
1769
1890
  proposals.forEach((p, i) => console.log(` ${i + 1}. [${p.kind}] ${p.text}`))
1770
1891
  if (!apply) {
1771
1892
  console.log('\nApply mechanical items only: fde garden --apply')
1772
- console.log('Manual items stay yours. Every apply commits to memory git.')
1893
+ console.log('Manual items stay yours. Every apply commits to memory git when the ledger is healthy.')
1773
1894
  return
1774
1895
  }
1896
+ if (!gitHealth.ok && gitHealth.reason === 'broken') {
1897
+ console.error('refusing garden --apply while memory git is broken - repair the ledger first')
1898
+ process.exit(1)
1899
+ }
1775
1900
  ensureMemoryGit(eng)
1776
1901
  let applied = 0
1902
+ const touched = new Set()
1777
1903
  for (const p of proposals) {
1904
+ if (p.id === 'dedupe-risks') {
1905
+ const n = applyRiskDedupe(eng, p.clusters)
1906
+ if (n > 0) {
1907
+ applied++
1908
+ touched.add('risks.md')
1909
+ console.log(`applied: retired ${n} duplicate open-risk echo(s) → ## Retired`)
1910
+ }
1911
+ continue
1912
+ }
1778
1913
  if (p.id !== 'archive-sessions') continue
1779
1914
  const cutDates = new Set(p.sessionBlocks.map(b => b.date))
1780
1915
  const keep = []
@@ -1808,13 +1943,61 @@ function cmdGarden(args) {
1808
1943
  atomicWriteFile(path.join(eng, 'context.md'), keep.join('\n').replace(/\n*$/, '\n'))
1809
1944
  })
1810
1945
  applied++
1946
+ touched.add('context.md')
1947
+ touched.add('context-archive.md')
1811
1948
  console.log(`applied: archived ${p.sessionBlocks.length} old session-end blocks → context-archive.md`)
1812
1949
  }
1813
- const hash = commitMemory(eng, 'garden', { files: ['context.md', 'context-archive.md'] })
1950
+ const hash = commitMemory(eng, 'garden', { files: [...touched] })
1814
1951
  if (!applied) console.log('no mechanical proposals applied (manual items remain)')
1815
1952
  else console.log(`garden done${hash ? ` @${hash}` : ''}`)
1816
1953
  }
1817
1954
 
1955
+ // Keep the first open-risk bullet per fingerprint; move later echoes under ## Retired.
1956
+ function applyRiskDedupe(eng, clusters) {
1957
+ const p = path.join(eng, 'risks.md')
1958
+ let md = readEng(eng, 'risks.md')
1959
+ if (!md) return 0
1960
+ const echoTexts = new Set()
1961
+ for (const group of clusters) {
1962
+ for (let i = 1; i < group.length; i++) echoTexts.add(group[i])
1963
+ }
1964
+ if (!echoTexts.size) return 0
1965
+ const retiredLines = []
1966
+ const kept = []
1967
+ let inRetired = false
1968
+ let moved = 0
1969
+ for (const raw of md.split('\n')) {
1970
+ const t = raw.trim()
1971
+ if (/^#{1,6}\s+Retired\b/i.test(t)) {
1972
+ inRetired = true
1973
+ kept.push(raw)
1974
+ continue
1975
+ }
1976
+ if (!inRetired) {
1977
+ const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
1978
+ if (m && echoTexts.has(m[1].trim())) {
1979
+ retiredLines.push(raw)
1980
+ moved++
1981
+ continue
1982
+ }
1983
+ }
1984
+ kept.push(raw)
1985
+ }
1986
+ if (!moved) return 0
1987
+ let out = kept.join('\n')
1988
+ if (!/^#{1,6}\s+Retired\b/im.test(out)) {
1989
+ out = out.replace(/\n*$/, '\n\n## Retired\n')
1990
+ }
1991
+ const stamp = new Date().toISOString().slice(0, 10)
1992
+ const block = retiredLines.map(l => {
1993
+ const body = l.trim().replace(/^-\s*/, '')
1994
+ return `- [${stamp}] (garden dedupe) ${body}`
1995
+ }).join('\n')
1996
+ out = appendUnderSection(out, 'Retired', block)
1997
+ withFileLock(p, () => { atomicWriteFile(p, out.endsWith('\n') ? out : out + '\n') })
1998
+ return moved
1999
+ }
2000
+
1818
2001
  function engagementSlugFromPath(eng) {
1819
2002
  return path.basename(path.dirname(eng))
1820
2003
  }
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.15",
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",