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 +213 -30
- package/bin/lib/memory.js +20 -0
- package/package.json +1 -1
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
|
|
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
|
|
126
|
-
if (
|
|
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
|
|
135
|
-
if (
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
-
|
|
1165
|
-
|
|
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 =>
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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.
|
|
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",
|