Fix silent ledger death + field gaps (3.9.14) - #13
Conversation
Make corrupt memory git a loud doctor/garden failure, caveat dirty receipts, cap smart-debrief previews, route Decided: to decisions, and garden-dedupe open risk echoes. Co-authored-by: Cursor <cursoragent@cursor.com>
| const hash = commitMemory(eng, 'garden', { files: [...touched] }) | ||
| if (!applied) console.log('no mechanical proposals applied (manual items remain)') | ||
| else console.log(`garden done${hash ? ` @${hash}` : ''}`) |
There was a problem hiding this comment.
🔴 Memory tidy-up can silently commit unrelated hand-edits when nothing is tidied
When the memory tidy-up runs with confirmation but no cleanup actually changes any file, every uncommitted manual edit to the records is swept into the automatic commit (commitMemory(eng, 'garden', { files: [...touched] }) at bin/fde.js:1888), so hand-edits get laundered into the trusted history.
Impact: Hand-edited records that were meant to stay flagged as untrusted get quietly baked into the tamper-evident memory under an automated label, defeating the whole point of the ledger.
Empty files array falls through to git add -A
When touched is empty (e.g. fde garden --apply where the only proposal is the manual reconfirm-signal, or where applyRiskDedupe matched nothing and returned 0), commitMemory(eng, 'garden', { files: [] }) is invoked. In bin/lib/memory.js:40-56, opts.files is [], so files is []; the guard if (files && files.length) is false because [].length === 0, and execution falls into the else branch that runs git add -A. This stages ALL dirty paths — including foreign manual edits — and commits them under the message garden. The pre-existing code always passed ['context.md', 'context-archive.md'] (a non-empty list), so it never triggered git add -A; the new [...touched] can be empty, reintroducing the tamper-laundering the scoped-commit design was built to prevent (see comment at bin/lib/memory.js:30-33).
| const hash = commitMemory(eng, 'garden', { files: [...touched] }) | |
| if (!applied) console.log('no mechanical proposals applied (manual items remain)') | |
| else console.log(`garden done${hash ? ` @${hash}` : ''}`) | |
| const hash = touched.size ? commitMemory(eng, 'garden', { files: [...touched] }) : null | |
| if (!applied) console.log('no mechanical proposals applied (manual items remain)') | |
| else console.log(`garden done${hash ? ` @${hash}` : ''}`) |
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (!inRetired) { | ||
| const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/) | ||
| if (m && echoTexts.has(m[1].trim())) { | ||
| retiredLines.push(raw) | ||
| moved++ | ||
| continue | ||
| } |
There was a problem hiding this comment.
🟡 Duplicate-risk cleanup can retire every copy, dropping a live risk from the open list
When two open risks have byte-identical text, the cleanup moves all of them to the retired list (echoTexts.has(m[1].trim()) at bin/fde.js:1916) instead of keeping the first, so a still-open risk can vanish from the active list entirely.
Impact: A live open risk is fully retired and no longer surfaces as open, so it can silently drop off the risk radar before ship/close.
Echoes matched by text-equality, not position
applyRiskDedupe builds echoTexts from group[1..] of each cluster (bin/fde.js:1898-1901), intending to keep the first occurrence and retire later echoes. But it then retires ANY bullet whose stripped text is in echoTexts (bin/fde.js:1916). If the first bullet's text is byte-identical to a later echo (e.g. the test fixture's - [2026-05-01] PLC vendor firmware drift on line 3 and - [2026-05-20] PLC vendor firmware drift on line 3), the first bullet's text is also present in echoTexts (added via the later duplicate), so the first bullet is retired too. The whole cluster gets moved under ## Retired, leaving no open representative. The test at test/fde-cli.test.js:1507-1508 only asserts openBullets.length < 4, so it passes despite this. Matching should be by position/occurrence count (keep the first matching line, retire only subsequent ones) rather than by set membership.
Prompt for agents
In applyRiskDedupe (bin/fde.js), the dedupe logic is intended to keep the first open-risk bullet per fingerprint cluster and retire only the later echoes. It builds echoTexts from group[1..] and then retires any bullet whose stripped text is a member of echoTexts. This breaks when the first bullet and a later echo share byte-identical text: the first bullet's text is in echoTexts (contributed by the later duplicate), so the first bullet is also retired, and the entire cluster is moved under ## Retired leaving no open representative. Fix by deduping based on occurrence/position rather than raw text-set membership: for each cluster, track which fingerprint has already had its first occurrence kept, and only retire subsequent bullets. For example, maintain a per-fingerprint 'seen' set as you iterate the risks bullets, computing riskFingerprint(m[1].trim()) for each open bullet, keeping the first bullet of each cluster fingerprint and retiring the rest, so identical-text duplicates still leave one open risk on record.
Was this helpful? React with 👍 or 👎 to provide feedback.
Make corrupt memory git a loud doctor/garden failure, caveat dirty receipts, cap smart-debrief previews, route Decided: to decisions, and garden-dedupe open risk echoes.
Summary
.fde/.git(not just missing) and shouts UNVERSIONED + repair hint--applyDecided:→ decisions.mdTest plan
npm run check(65 tests; regressions for all four ground-test findings)v3.9.14→npm publishnpm view fdeops version→3.9.14Made with Cursor