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 +131 -10
- package/bin/lib/memory.js +20 -0
- package/package.json +1 -1
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 =>
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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.
|
|
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",
|