From eeada45abee9f97664f0839c24d173fcd54af4cd Mon Sep 17 00:00:00 2001 From: Subash Natarajan Date: Mon, 20 Jul 2026 13:01:59 +0530 Subject: [PATCH 01/10] Plan minimal field hardening Lock the compatibility boundary before changing field-used memory and hook behavior. Co-authored-by: Cursor --- ...26-07-20-minimal-field-hardening-design.md | 87 +++++++ .../2026-07-20-minimal-field-hardening.md | 243 ++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 docs/plans/2026-07-20-minimal-field-hardening-design.md create mode 100644 docs/plans/2026-07-20-minimal-field-hardening.md diff --git a/docs/plans/2026-07-20-minimal-field-hardening-design.md b/docs/plans/2026-07-20-minimal-field-hardening-design.md new file mode 100644 index 0000000..0a43082 --- /dev/null +++ b/docs/plans/2026-07-20-minimal-field-hardening-design.md @@ -0,0 +1,87 @@ +# Minimal Field Hardening Design + +## Goal + +Remove the highest-risk integrity and privacy gaps in FDEOps v3.9.10 without changing its product model, public workflows, Markdown schema, local-only posture, or dependency footprint. + +## Scope + +This hardening release will: + +1. Route session-end and pre-compaction writes through the existing Node CLI safety layer. +2. Ensure hook-triggered CLI commands use the engagement already resolved by the hook. +3. Redact `.signal-ledger` before any dashboard or prep extraction. +4. Apply existing secret detection to `next:` debrief entries. +5. Replace the fieldbook atomically. +6. Recover conservatively from abandoned lock files. +7. Add process-level regression tests for each behavior. + +## Compatibility contract + +- Existing `.fde/` directories remain readable and writable by v3.9.10. +- Core Markdown files, headings, dated bullets, signal tokens, and `` syntax do not change. +- Existing CLI commands and output remain unchanged; one internal `preserve` command is added for the pre-compact hook. +- Hooks remain best-effort and must never block an agent session. +- FDEOps remains dependency-free, local-only, and compatible with Node 18+. +- Project `CLAUDE.md`, environment, registry, global pointer, and approved in-repo `.fde` resolution remain supported. + +## Design + +```text +hook resolves engagement + │ + ├── FDEOPS_ENGAGEMENT= fde capture + ├── FDEOPS_ENGAGEMENT= fde preserve + └── FDEOPS_ENGAGEMENT= fde dashboard/triage + │ + ▼ + symlink guard + file lock + │ + ▼ + existing Markdown +``` + +The hook remains responsible for compatibility resolution. Once resolved, it passes the exact path into the CLI. The CLI becomes the only writer, eliminating unlocked Bash appends without removing legacy pointer behavior. + +`fde preserve` reproduces the current pre-compaction block and daily deduplication. It uses redacted reads and a locked append, then records the change in the existing local memory Git repository. It exits successfully on missing engagement or write failure because compaction hooks cannot be allowed to block. + +Lock recovery uses the lock file's modification time. A lock older than 30 seconds is treated as abandoned and removed once before normal acquisition retries continue. Normal Markdown writes complete far below this threshold. + +## Failure behavior + +- Active lock: wait up to five seconds, then return the existing retry message. +- Abandoned lock: remove it and continue; no user memory is modified during recovery. +- Hook cannot locate Node or the CLI: exit successfully without writing, preserving current best-effort behavior. +- Hook CLI write fails: exit successfully; existing memory remains untouched or contains only a complete append. +- Dashboard replacement fails: retain the previous complete fieldbook and report a concise filesystem error. +- Secret-like `next:` entry: skip it unless `--force` is explicitly supplied. +- Private signal ledger content: render only the existing redaction marker. + +## Tests + +Process-level `node:test` cases will prove: + +- private canaries in `.signal-ledger` never appear in prep or dashboard output; +- secret-like `next:` lines are skipped and do not enter `context.md`; +- an abandoned lock is reclaimed while a fresh lock still blocks; +- dashboard replacement uses the atomic write path; +- session-stop invokes `capture` against the resolved engagement; +- pre-compact invokes `preserve`, retains redaction, and deduplicates daily; +- session-start triage uses the same resolved engagement as its context. + +Every behavioral change follows red-green TDD. The full `npm run check` gate must remain clean. + +## Rollout and rollback + +Ship as a patch release only after testing upgrades from a v3.9.10 sandbox. No memory migration is required. Rolling back to v3.9.10 is safe because this design writes the same Markdown structures and hidden files already understood by that release. + +## Not in scope + +- Database, daemon, service layer, or cloud synchronization +- Schema v2 or conversion to JSON/frontmatter +- New skills, agent orchestration, or UI redesign +- Installer transaction/rollback redesign +- Multi-file ACID transactions for debrief +- Encryption or management of genuinely sensitive values +- Broad resolver rewrite or removal of legacy pointers +- Changes to trust scoring or dashboard information architecture diff --git a/docs/plans/2026-07-20-minimal-field-hardening.md b/docs/plans/2026-07-20-minimal-field-hardening.md new file mode 100644 index 0000000..f6586f9 --- /dev/null +++ b/docs/plans/2026-07-20-minimal-field-hardening.md @@ -0,0 +1,243 @@ +# Minimal Field Hardening Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Close the smallest set of field-facing integrity and privacy gaps without changing FDEOps workflows or memory format. + +**Architecture:** Keep hooks as compatibility resolvers, but pass their resolved engagement explicitly to the deterministic CLI for all reads and writes. Reuse the existing redaction, lock, atomic-write, and memory-Git primitives rather than introducing a storage abstraction. + +**Tech Stack:** Node.js 18+, `node:test`, Bash hooks, dependency-free filesystem and Git CLI operations. + +--- + +### Task 1: Close privacy and secret-routing gaps + +**Files:** +- Modify: `bin/fde.js:558-575` +- Modify: `bin/fde.js:1169-1209` +- Test: `test/fde-cli.test.js` + +**Step 1: Write the failing signal-ledger privacy test** + +Create an engagement, append a signal ledger entry containing a unique `` canary, run `prep` and `dashboard`, and assert the canary is absent from stdout and generated HTML. + +**Step 2: Run the focused test and verify RED** + +Run: `node --test --test-name-pattern="signal ledger private" test/fde-cli.test.js` + +Expected: FAIL because `parseSignalHistoryEntries()` uses `readEng()` for `.signal-ledger`. + +**Step 3: Implement the minimal redaction fix** + +Change the ledger read in `parseSignalHistoryEntries()` from `readEng(eng, SIGNAL_LEDGER)` to `readClean(eng, SIGNAL_LEDGER)`. + +**Step 4: Run the focused test and verify GREEN** + +Run the same focused command. Expected: PASS. + +**Step 5: Write the failing `next:` secret test** + +Debrief a `next:` line containing an OpenAI-shaped canary key. Assert stderr reports the skip and `context.md` does not contain the canary. + +**Step 6: Run the focused test and verify RED** + +Run: `node --test --test-name-pattern="next action secret" test/fde-cli.test.js` + +Expected: FAIL because `next:` branches before `findSecretHit()`. + +**Step 7: Implement the minimal routing fix** + +Apply `findSecretHit()` before the `next:` branch, reusing the existing skip message and `--force` behavior. + +**Step 8: Verify focused and full tests** + +Run: + +```bash +node --test --test-name-pattern="signal ledger private|next action secret" test/fde-cli.test.js +npm test +``` + +Expected: all pass. + +**Step 9: Commit** + +Commit only `bin/fde.js` and `test/fde-cli.test.js` as Subash Natarajan. + +--- + +### Task 2: Recover abandoned locks and preserve complete dashboards + +**Files:** +- Modify: `bin/fde.js:273-320` +- Modify: `bin/fde.js:1831-1837` +- Test: `test/fde-cli.test.js` + +**Step 1: Write the failing abandoned-lock test** + +Create an old `decisions.md.lock`, run `fde log decision`, and assert the write succeeds and removes the lock. + +**Step 2: Verify RED** + +Run: `node --test --test-name-pattern="abandoned lock" test/fde-cli.test.js` + +Expected: FAIL after the current five-second lock timeout. + +**Step 3: Implement conservative stale-lock recovery** + +When lock acquisition sees `EEXIST`, inspect the lock mtime. If older than 30 seconds, unlink it once and retry. Preserve existing behavior for fresh locks and all other errors. + +**Step 4: Verify stale and active lock behavior** + +Add/retain a fresh-lock test that expects failure with the existing retry message. Run both focused tests and confirm PASS. + +**Step 5: Write the failing atomic-dashboard test** + +Use source-level structural validation in `bin/check.js` only if process-level failure injection is impractical; otherwise inject a rename failure and assert the old fieldbook remains byte-for-byte unchanged. + +**Step 6: Verify RED** + +Expected: FAIL because dashboard currently uses direct `writeFileSync`. + +**Step 7: Reuse `atomicWriteFile()`** + +Replace the direct dashboard write with `atomicWriteFile(outPath, html)`. Keep the existing output, symlink refusal, and error formatting. + +**Step 8: Verify focused and full tests** + +Run: + +```bash +node --test --test-name-pattern="abandoned lock|fresh lock|dashboard" test/fde-cli.test.js +npm run check +``` + +Expected: all checks pass without warnings. + +**Step 9: Commit** + +Commit the lock and dashboard hardening as Subash Natarajan. + +--- + +### Task 3: Route hook writes through the CLI + +**Files:** +- Modify: `bin/fde.js:1323-1348` +- Modify: `bin/fde.js:1851-1903` +- Modify: `hooks/session-start` +- Modify: `hooks/session-stop` +- Modify: `hooks/pre-compact` +- Modify: `bin/check.js` +- Test: `test/fde-cli.test.js` + +**Step 1: Write failing hook-contract tests** + +In isolated HOME/workspace sandboxes: + +- prove session-stop writes through `fde capture` and commits memory; +- prove pre-compact preserves the same redacted block once per UTC day; +- prove triage and context use the hook-resolved engagement when project and global pointers differ. + +**Step 2: Verify RED** + +Run: `node --test --test-name-pattern="session-stop CLI capture|pre-compact CLI preserve|hook resolved triage" test/fde-cli.test.js` + +Expected: FAIL because hooks currently append directly and do not bind delegated CLI calls. + +**Step 3: Add the internal `preserve` command** + +Move the current pre-compact extraction into `cmdPreserve()`: + +- resolve for write; +- exit zero if no engagement/context; +- deduplicate by the existing daily marker; +- use `readClean()` for decisions and risks; +- append through `lockedAppendFile(..., { soft: true })`; +- commit only `context.md`; +- swallow failures to preserve hook lifecycle. + +Add `preserve` to CLI dispatch and label it as hook-internal in help text. + +**Step 4: Delegate session-stop** + +Retain existing resolver compatibility. Once `ENG_DIR` is known, locate the packaged CLI and run: + +```bash +FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" capture +FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" dashboard +``` + +Remove direct `context.md` appends and duplicated workspace-state collection. + +**Step 5: Delegate pre-compact** + +Retain resolver compatibility, locate the CLI, and run: + +```bash +FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" preserve +``` + +Remove the raw append and duplicated redaction/extraction. + +**Step 6: Bind session-start triage** + +Pass `FDEOPS_ENGAGEMENT="$ENG_DIR"` to the existing CLI triage invocation so context and triage cannot come from different engagements. + +**Step 7: Update static hook checks** + +Change `bin/check.js` invariants to require CLI delegation and reject raw `>> "$CONTEXT_FILE"`/`cat >> "$CONTEXT_FILE"` mutation paths. + +**Step 8: Verify focused and full checks** + +Run: + +```bash +bash -n hooks/session-start hooks/session-stop hooks/pre-compact +node --test --test-name-pattern="session-stop CLI capture|pre-compact CLI preserve|hook resolved triage" test/fde-cli.test.js +npm run check +``` + +Expected: all checks pass without warnings. + +**Step 9: Commit** + +Commit the hook consolidation as Subash Natarajan. + +--- + +### Task 4: Final compatibility verification + +**Files:** +- Modify only if verification exposes a defect. + +**Step 1: Verify the clean upgrade path** + +Create a v3.9.10-style engagement fixture with no new files, then exercise `resume`, `log`, `debrief`, `prep`, `doctor`, `status`, `dashboard`, `capture`, and `preserve`. + +**Step 2: Run all deterministic checks** + +```bash +npm run check +bash -n hooks/session-start hooks/session-stop hooks/pre-compact +git diff --check +git status --short +``` + +**Step 3: Review scope and metadata** + +Confirm: + +- no dependency or schema changes; +- no generated fieldbook or engagement data in the repository; +- no author/committer identity other than `Subash Natarajan `; +- no “Cursor” attribution, trailers, or commit text; +- all commits remain independently revertible. + +**Step 4: Run independent code review** + +Use a code-review agent against the branch diff. Fix only correctness, compatibility, privacy, or test-quality findings within this design. + +**Step 5: Final commit if review required changes** + +Commit review fixes as Subash Natarajan after rerunning `npm run check`. From af2fdbdf91942452d314f9df33868eb5f5431e00 Mon Sep 17 00:00:00 2001 From: Subash Natarajan Date: Mon, 20 Jul 2026 13:13:34 +0530 Subject: [PATCH 02/10] Harden private ledger and debrief next actions --- bin/fde.js | 12 ++++++------ test/fde-cli.test.js | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/bin/fde.js b/bin/fde.js index 30157f7..d293b27 100755 --- a/bin/fde.js +++ b/bin/fde.js @@ -559,7 +559,7 @@ function parseSignalHistoryEntries(eng) { // Format-agnostic on token position: CLI writes "[date] [signal:x] text"; // debrief may put the token at the end. Author tags [@x] are stripped for matching. const md = readClean(eng, 'stakeholders.md') - const histText = sectionBody(md, 'Signal history') + '\n' + readEng(eng, SIGNAL_LEDGER) + const histText = sectionBody(md, 'Signal history') + '\n' + readClean(eng, SIGNAL_LEDGER) const history = [] histText.split('\n').forEach(l => { const dm = l.trim().match(/^-\s*\[(\d{4}-\d{2}-\d{2})\]\s*(.*)$/i) @@ -1181,6 +1181,11 @@ function routeDebriefInput(eng, input, { dry, force }) { if (m) { const type = m[1].toLowerCase() let body = m[2] + const hit = findSecretHit(body) + if (hit && !force) { + console.error(`skipped ${type} line - looks like a ${hit}. Redact it, or re-run with --force.`) + continue + } if (type === 'next') { if (dry) console.log(`→ context.md ## Next action - ${body}`) else nextAction = body @@ -1189,11 +1194,6 @@ function routeDebriefInput(eng, input, { dry, force }) { } const sigInline = (body.match(/\[signal:(red|amber|green)\]/i) || [])[1] if (sigInline) body = body.replace(/\[signal:(red|amber|green)\]/i, '').trim() - const hit = findSecretHit(body) - if (hit && !force) { - console.error(`skipped ${type} line - looks like a ${hit}. Redact it, or re-run with --force.`) - continue - } const entry = datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '') if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`) else appendLogEntry(eng, type, entry, { skipCommit: true }) diff --git a/test/fde-cli.test.js b/test/fde-cli.test.js index f437a5c..cfa29e1 100644 --- a/test/fde-cli.test.js +++ b/test/fde-cli.test.js @@ -456,6 +456,29 @@ test('CLI signal ledger keeps trust RED after Signal history is wiped', () => { assert.match(status.stdout, /\[RED\s*\]\s*ledgerco/, 'trust must read RED from .signal-ledger after wipe') }) +test('prep and dashboard redact private signal-ledger history', () => { + const sandbox = makeSandbox('private-ledger') + assert.equal(runFde(sandbox, ['resume', '--init', 'privateledger']).status, 0) + const eng = engagementPath(sandbox, 'privateledger') + const canary = 'PRIVATE_LEDGER_CANARY_7Q9X' + fs.writeFileSync(path.join(eng, '.signal-ledger'), [ + '', + `- [2026-07-20] [signal:red] ${canary} blocked rollout`, + '', + '', + ].join('\n')) + + const prep = runFde(sandbox, ['prep', 'Sponsor sync']) + assert.equal(prep.status, 0, prep.stderr) + assert.doesNotMatch(prep.stdout, new RegExp(canary), 'prep leaked private signal-ledger history') + + const out = path.join(sandbox.dir, 'private-ledger.html') + const dashboard = runFde(sandbox, ['dashboard', '--out', out]) + assert.equal(dashboard.status, 0, dashboard.stderr) + assert.doesNotMatch(fs.readFileSync(out, 'utf8'), new RegExp(canary), + 'dashboard leaked private signal-ledger history') +}) + test('dashboard scoped render and scan smoke', () => { const sandbox = makeSandbox('dash-scan') assert.equal(runFde(sandbox, ['resume', '--init', 'dashco']).status, 0) @@ -492,6 +515,20 @@ test('log refuses secret-like text and --undo removes the last entry', () => { assert.doesNotMatch(fs.readFileSync(path.join(eng, 'decisions.md'), 'utf8'), /ship the retry slice/) }) +test('debrief skips a secret-shaped next action without --force', () => { + const sandbox = makeSandbox('secret-next') + assert.equal(runFde(sandbox, ['resume', '--init', 'nextsafe']).status, 0) + const eng = engagementPath(sandbox, 'nextsafe') + const secret = 'AKIAIOSFODNN7EXAMPLE' + + const debrief = runFde(sandbox, ['debrief'], { input: `next: rotate ${secret} after demo` }) + + assert.equal(debrief.status, 0, debrief.stderr) + assert.match(debrief.stderr, /skipped next line - looks like an? AWS access key/i) + assert.doesNotMatch(fs.readFileSync(path.join(eng, 'context.md'), 'utf8'), new RegExp(secret)) + assert.match(debrief.stdout, /debrief empty - nothing routed/) +}) + test('corrupted stakeholders.md does not default to green', () => { const sandbox = makeSandbox('corrupt-mem') assert.equal(runFde(sandbox, ['resume', '--init', 'wreck']).status, 0) From af9ebaff6e7270e5c76a2f0a32a494508898b3b5 Mon Sep 17 00:00:00 2001 From: Subash Natarajan Date: Mon, 20 Jul 2026 13:44:39 +0530 Subject: [PATCH 03/10] Recover stale locks and atomically write dashboards --- bin/check.js | 14 ++++++++++++++ bin/fde.js | 17 +++++++++++++++-- test/fde-cli.test.js | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/bin/check.js b/bin/check.js index 7f02dbe..3bdd362 100644 --- a/bin/check.js +++ b/bin/check.js @@ -285,6 +285,20 @@ if (!fs.existsSync(path.join(root, 'bin', 'fde.js'))) { for (const renderSymbol of ['buildFieldbookHtml', 'dashStyles', 'dashScript', 'FONT_FACE_CSS']) { if (!cliSource.includes(renderSymbol)) fail(`CLI sources missing dashboard renderer symbol ${renderSymbol}`) } + const fdeSource = read('bin/fde.js') + const dashboardSource = fdeSource.slice( + fdeSource.indexOf('function cmdDashboard('), + fdeSource.indexOf('function printUsage('), + ) + if (!dashboardSource.includes('atomicWriteFile(outPath, html)')) { + fail('dashboard must use the existing atomic write path') + } else if (dashboardSource.includes('fs.writeFileSync(outPath, html)')) { + fail('dashboard must not write the fieldbook directly') + } else if (dashboardSource.includes('refuseSymlinkWrite(outPath)')) { + fail('dashboard atomic write must not duplicate the symlink check') + } else { + ok('dashboard atomic write path') + } if (!read('bin/install.js').includes('FDE_SUBCOMMANDS')) fail('install.js must pass fde subcommands through (npx fdeops scan)') ok('fde CLI present and wired') } diff --git a/bin/fde.js b/bin/fde.js index d293b27..5c13a2f 100755 --- a/bin/fde.js +++ b/bin/fde.js @@ -281,6 +281,20 @@ function withFileLock(targetPath, fn, opts = {}) { fd = fs.openSync(lockPath, 'wx') } catch (e) { if (e.code === 'EEXIST') { + let lockStat + try { + lockStat = fs.statSync(lockPath) + } catch (statErr) { + if (statErr.code === 'ENOENT') continue + } + if (lockStat && Date.now() - lockStat.mtimeMs > 30_000) { + try { + fs.unlinkSync(lockPath) + continue + } catch (unlinkErr) { + if (unlinkErr.code === 'ENOENT') continue + } + } if (Date.now() > deadline) { const msg = `could not lock ${path.basename(targetPath)} - another writer is active; retry` if (opts.soft) throw Object.assign(new Error(msg), { code: 'ELOCKED' }) @@ -1830,8 +1844,7 @@ function cmdDashboard(args) { try { fs.mkdirSync(path.dirname(outPath), { recursive: true }) - refuseSymlinkWrite(outPath) - fs.writeFileSync(outPath, html) + atomicWriteFile(outPath, html) } catch (e) { failFs(e, 'write fieldbook', outPath) } diff --git a/test/fde-cli.test.js b/test/fde-cli.test.js index cfa29e1..8791a78 100644 --- a/test/fde-cli.test.js +++ b/test/fde-cli.test.js @@ -107,6 +107,40 @@ test('log writes dated entries and enforces contact-only signal tokens', () => { assert.match(fs.readFileSync(path.join(eng, 'stakeholders.md'), 'utf8'), /\[signal:green\] Denise saw demo/) }) +test('log decision reclaims an abandoned lock and writes exactly once', () => { + const sandbox = makeSandbox('abandoned-lock') + assert.equal(runFde(sandbox, ['resume', '--init', 'Abandoned Lock']).status, 0) + const target = path.join(engagementPath(sandbox, 'abandoned-lock'), 'decisions.md') + const lock = target + '.lock' + const marker = 'reclaim abandoned decision lock' + fs.writeFileSync(lock, 'abandoned\n') + const stale = new Date(Date.now() - 31_000) + fs.utimesSync(lock, stale, stale) + + const log = runFde(sandbox, ['log', 'decision', marker]) + + assert.equal(log.status, 0, log.stderr) + const matches = fs.readFileSync(target, 'utf8').match(new RegExp(marker, 'g')) || [] + assert.equal(matches.length, 1) + assert.equal(fs.existsSync(lock), false) +}) + +test('log decision does not reclaim a fresh lock or modify its target', () => { + const sandbox = makeSandbox('fresh-lock') + assert.equal(runFde(sandbox, ['resume', '--init', 'Fresh Lock']).status, 0) + const target = path.join(engagementPath(sandbox, 'fresh-lock'), 'decisions.md') + const lock = target + '.lock' + const before = fs.readFileSync(target, 'utf8') + fs.writeFileSync(lock, 'active\n') + + const log = runFde(sandbox, ['log', 'decision', 'must not be written']) + + assert.notEqual(log.status, 0) + assert.match(log.stderr, /another writer is active; retry/) + assert.equal(fs.readFileSync(target, 'utf8'), before) + assert.equal(fs.existsSync(lock), true) +}) + test('debrief --dry-run routes markdown-style notes without writing files', () => { const sandbox = makeSandbox('debrief') assert.equal(runFde(sandbox, ['resume', '--init', 'Client']).status, 0) From 995840f6a51519d4c1060ee3cdd8ef23ba4e2f27 Mon Sep 17 00:00:00 2001 From: Subash Natarajan Date: Mon, 20 Jul 2026 13:50:16 +0530 Subject: [PATCH 04/10] Propagate stale lock recovery errors --- bin/fde.js | 4 ++++ test/fde-cli.test.js | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/bin/fde.js b/bin/fde.js index 5c13a2f..1f282a3 100755 --- a/bin/fde.js +++ b/bin/fde.js @@ -286,6 +286,8 @@ function withFileLock(targetPath, fn, opts = {}) { lockStat = fs.statSync(lockPath) } catch (statErr) { if (statErr.code === 'ENOENT') continue + if (opts.soft) throw statErr + failFs(statErr, 'lock', targetPath) } if (lockStat && Date.now() - lockStat.mtimeMs > 30_000) { try { @@ -293,6 +295,8 @@ function withFileLock(targetPath, fn, opts = {}) { continue } catch (unlinkErr) { if (unlinkErr.code === 'ENOENT') continue + if (opts.soft) throw unlinkErr + failFs(unlinkErr, 'lock', targetPath) } } if (Date.now() > deadline) { diff --git a/test/fde-cli.test.js b/test/fde-cli.test.js index 8791a78..5f0334d 100644 --- a/test/fde-cli.test.js +++ b/test/fde-cli.test.js @@ -141,6 +141,32 @@ test('log decision does not reclaim a fresh lock or modify its target', () => { assert.equal(fs.existsSync(lock), true) }) +test('stale lock unlink errors use the existing lock error handling', () => { + const sandbox = makeSandbox('stale-lock-unlink-error') + assert.equal(runFde(sandbox, ['resume', '--init', 'Stale Lock Error']).status, 0) + const eng = engagementPath(sandbox, 'stale-lock-error') + const target = path.join(eng, 'decisions.md') + const lock = target + '.lock' + const before = fs.readFileSync(target, 'utf8') + fs.writeFileSync(lock, 'abandoned\n') + const stale = new Date(Date.now() - 31_000) + fs.utimesSync(lock, stale, stale) + fs.chmodSync(eng, 0o555) + + let log + try { + log = runFde(sandbox, ['log', 'decision', 'must not be written']) + } finally { + fs.chmodSync(eng, 0o755) + } + + assert.notEqual(log.status, 0) + assert.match(log.stderr, /permission denied|read-only|locked down/i) + assert.doesNotMatch(log.stderr, /another writer is active; retry/) + assert.equal(fs.readFileSync(target, 'utf8'), before) + assert.equal(fs.existsSync(lock), true) +}) + test('debrief --dry-run routes markdown-style notes without writing files', () => { const sandbox = makeSandbox('debrief') assert.equal(runFde(sandbox, ['resume', '--init', 'Client']).status, 0) From 784e0659118278341a2511ce762fa13e1b185f9f Mon Sep 17 00:00:00 2001 From: Subash Natarajan Date: Mon, 20 Jul 2026 14:00:20 +0530 Subject: [PATCH 05/10] Restore conservative file locking --- bin/check.js | 14 -------- bin/fde.js | 18 ---------- test/fde-cli.test.js | 82 +++++++++++++++++++++++++------------------- 3 files changed, 46 insertions(+), 68 deletions(-) diff --git a/bin/check.js b/bin/check.js index 3bdd362..7f02dbe 100644 --- a/bin/check.js +++ b/bin/check.js @@ -285,20 +285,6 @@ if (!fs.existsSync(path.join(root, 'bin', 'fde.js'))) { for (const renderSymbol of ['buildFieldbookHtml', 'dashStyles', 'dashScript', 'FONT_FACE_CSS']) { if (!cliSource.includes(renderSymbol)) fail(`CLI sources missing dashboard renderer symbol ${renderSymbol}`) } - const fdeSource = read('bin/fde.js') - const dashboardSource = fdeSource.slice( - fdeSource.indexOf('function cmdDashboard('), - fdeSource.indexOf('function printUsage('), - ) - if (!dashboardSource.includes('atomicWriteFile(outPath, html)')) { - fail('dashboard must use the existing atomic write path') - } else if (dashboardSource.includes('fs.writeFileSync(outPath, html)')) { - fail('dashboard must not write the fieldbook directly') - } else if (dashboardSource.includes('refuseSymlinkWrite(outPath)')) { - fail('dashboard atomic write must not duplicate the symlink check') - } else { - ok('dashboard atomic write path') - } if (!read('bin/install.js').includes('FDE_SUBCOMMANDS')) fail('install.js must pass fde subcommands through (npx fdeops scan)') ok('fde CLI present and wired') } diff --git a/bin/fde.js b/bin/fde.js index 1f282a3..2c52f53 100755 --- a/bin/fde.js +++ b/bin/fde.js @@ -281,24 +281,6 @@ function withFileLock(targetPath, fn, opts = {}) { fd = fs.openSync(lockPath, 'wx') } catch (e) { if (e.code === 'EEXIST') { - let lockStat - try { - lockStat = fs.statSync(lockPath) - } catch (statErr) { - if (statErr.code === 'ENOENT') continue - if (opts.soft) throw statErr - failFs(statErr, 'lock', targetPath) - } - if (lockStat && Date.now() - lockStat.mtimeMs > 30_000) { - try { - fs.unlinkSync(lockPath) - continue - } catch (unlinkErr) { - if (unlinkErr.code === 'ENOENT') continue - if (opts.soft) throw unlinkErr - failFs(unlinkErr, 'lock', targetPath) - } - } if (Date.now() > deadline) { const msg = `could not lock ${path.basename(targetPath)} - another writer is active; retry` if (opts.soft) throw Object.assign(new Error(msg), { code: 'ELOCKED' }) diff --git a/test/fde-cli.test.js b/test/fde-cli.test.js index 5f0334d..7720bf4 100644 --- a/test/fde-cli.test.js +++ b/test/fde-cli.test.js @@ -107,22 +107,22 @@ test('log writes dated entries and enforces contact-only signal tokens', () => { assert.match(fs.readFileSync(path.join(eng, 'stakeholders.md'), 'utf8'), /\[signal:green\] Denise saw demo/) }) -test('log decision reclaims an abandoned lock and writes exactly once', () => { - const sandbox = makeSandbox('abandoned-lock') - assert.equal(runFde(sandbox, ['resume', '--init', 'Abandoned Lock']).status, 0) - const target = path.join(engagementPath(sandbox, 'abandoned-lock'), 'decisions.md') +test('log decision does not reclaim an existing stale lock', () => { + const sandbox = makeSandbox('stale-lock') + assert.equal(runFde(sandbox, ['resume', '--init', 'Stale Lock']).status, 0) + const target = path.join(engagementPath(sandbox, 'stale-lock'), 'decisions.md') const lock = target + '.lock' - const marker = 'reclaim abandoned decision lock' + const before = fs.readFileSync(target, 'utf8') fs.writeFileSync(lock, 'abandoned\n') const stale = new Date(Date.now() - 31_000) fs.utimesSync(lock, stale, stale) - const log = runFde(sandbox, ['log', 'decision', marker]) + const log = runFde(sandbox, ['log', 'decision', 'must not be written']) - assert.equal(log.status, 0, log.stderr) - const matches = fs.readFileSync(target, 'utf8').match(new RegExp(marker, 'g')) || [] - assert.equal(matches.length, 1) - assert.equal(fs.existsSync(lock), false) + assert.notEqual(log.status, 0) + assert.match(log.stderr, /another writer is active; retry/) + assert.equal(fs.readFileSync(target, 'utf8'), before) + assert.equal(fs.existsSync(lock), true) }) test('log decision does not reclaim a fresh lock or modify its target', () => { @@ -141,32 +141,6 @@ test('log decision does not reclaim a fresh lock or modify its target', () => { assert.equal(fs.existsSync(lock), true) }) -test('stale lock unlink errors use the existing lock error handling', () => { - const sandbox = makeSandbox('stale-lock-unlink-error') - assert.equal(runFde(sandbox, ['resume', '--init', 'Stale Lock Error']).status, 0) - const eng = engagementPath(sandbox, 'stale-lock-error') - const target = path.join(eng, 'decisions.md') - const lock = target + '.lock' - const before = fs.readFileSync(target, 'utf8') - fs.writeFileSync(lock, 'abandoned\n') - const stale = new Date(Date.now() - 31_000) - fs.utimesSync(lock, stale, stale) - fs.chmodSync(eng, 0o555) - - let log - try { - log = runFde(sandbox, ['log', 'decision', 'must not be written']) - } finally { - fs.chmodSync(eng, 0o755) - } - - assert.notEqual(log.status, 0) - assert.match(log.stderr, /permission denied|read-only|locked down/i) - assert.doesNotMatch(log.stderr, /another writer is active; retry/) - assert.equal(fs.readFileSync(target, 'utf8'), before) - assert.equal(fs.existsSync(lock), true) -}) - test('debrief --dry-run routes markdown-style notes without writing files', () => { const sandbox = makeSandbox('debrief') assert.equal(runFde(sandbox, ['resume', '--init', 'Client']).status, 0) @@ -259,6 +233,42 @@ test('dashboard renders local HTML and redacts private blocks', () => { assert.doesNotMatch(html, /4111-1111-1111-1111/) }) +test('dashboard rename failure preserves the existing fieldbook', () => { + const sandbox = makeSandbox('dashboard-atomic-failure') + assert.equal(runFde(sandbox, ['resume', '--init', 'Atomic Dashboard']).status, 0) + const out = path.join(sandbox.dir, 'fieldbook.html') + const before = Buffer.from('existing fieldbook bytes\n') + fs.writeFileSync(out, before) + const injector = path.join(sandbox.dir, 'fail-dashboard-rename.cjs') + fs.writeFileSync(injector, [ + "const fs = require('node:fs')", + "const path = require('node:path')", + 'const originalRenameSync = fs.renameSync', + 'fs.renameSync = function (source, destination) {', + ' const target = process.env.FDEOPS_TEST_RENAME_DEST', + ' if (target && path.resolve(String(destination)) === path.resolve(target)) {', + " const error = new Error('injected dashboard rename failure')", + " error.code = 'EIO'", + ' throw error', + ' }', + ' return originalRenameSync.apply(this, arguments)', + '}', + '', + ].join('\n')) + + const dashboard = runFde(sandbox, ['dashboard', '--out', out], { + env: { + NODE_OPTIONS: `--require=${injector}`, + FDEOPS_TEST_RENAME_DEST: out, + }, + }) + + assert.notEqual(dashboard.status, 0) + assert.match(dashboard.stderr, /cannot write fieldbook\.html \(EIO\)/) + assert.doesNotMatch(dashboard.stderr, /\n\s+at /) + assert.deepEqual(fs.readFileSync(out), before) +}) + // --- privacy regression suite: every read path that can reach the model or a // shared artifact must redact , in every tag case. Each secret marker // below reproduces a leak a brutal simulator found on 2026-07-11. From d38e6b91d0bfa93526d8eee315a428b29ff597df Mon Sep 17 00:00:00 2001 From: Subash Natarajan Date: Mon, 20 Jul 2026 14:05:33 +0530 Subject: [PATCH 06/10] Quote dashboard injector path --- test/fde-cli.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fde-cli.test.js b/test/fde-cli.test.js index 7720bf4..3351714 100644 --- a/test/fde-cli.test.js +++ b/test/fde-cli.test.js @@ -258,7 +258,7 @@ test('dashboard rename failure preserves the existing fieldbook', () => { const dashboard = runFde(sandbox, ['dashboard', '--out', out], { env: { - NODE_OPTIONS: `--require=${injector}`, + NODE_OPTIONS: `--require=${JSON.stringify(injector)}`, FDEOPS_TEST_RENAME_DEST: out, }, }) From 2556dcd9d0c9104bb18df8fb4cf62acd8c4fc029 Mon Sep 17 00:00:00 2001 From: Subash Natarajan Date: Mon, 20 Jul 2026 15:28:06 +0530 Subject: [PATCH 07/10] Delegate hook memory writes through CLI --- bin/check.js | 19 +++++- bin/fde.js | 26 ++++++++ hooks/pre-compact | 63 ++++---------------- hooks/session-start | 5 +- hooks/session-stop | 35 ++--------- test/fde-cli.test.js | 138 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 200 insertions(+), 86 deletions(-) diff --git a/bin/check.js b/bin/check.js index 7f02dbe..b750318 100644 --- a/bin/check.js +++ b/bin/check.js @@ -205,6 +205,10 @@ const hook = read('hooks/session-start') if (!hook.includes('FDEOPS_ENGAGEMENT')) { fail('session-start hook must read FDEOPS_ENGAGEMENT env var') } else ok('hook FDEOPS_ENGAGEMENT') +if (!hook.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" fde triage') || + !hook.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CMD" triage')) { + fail('session-start must run triage with its resolved engagement') +} // Token discipline: SessionStart must not dump the full skill (L1 progressive disclosure). // Strip comments before scanning for a real `cat …SKILL.md` / BOOTSTRAP inject. const hookCode = hook.replace(/^[ \t]*#.*$/gm, '') @@ -241,9 +245,22 @@ if (!fs.existsSync(path.join(root, 'hooks', 'session-stop'))) { } else { const stopHook = read('hooks/session-stop') if (!stopHook.includes('FDEOPS_ENGAGEMENT')) fail('session-stop must resolve FDEOPS_ENGAGEMENT') - if (!stopHook.includes('context.md')) fail('session-stop must append to context.md') + if (!stopHook.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" capture')) { + fail('session-stop must delegate capture with its resolved engagement') + } ok('session-stop write side') } +const compactHook = read('hooks/pre-compact') +if (!compactHook.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" preserve')) { + fail('pre-compact must delegate preserve with its resolved engagement') +} +for (const h of ['session-stop', 'pre-compact']) { + const body = read('hooks/' + h).replace(/^[ \t]*#.*$/gm, '') + if (/>>\s*"\$CONTEXT_FILE"|cat\s*>>\s*"\$CONTEXT_FILE"/.test(body)) { + fail(`hooks/${h} must not append context.md directly`) + } +} +ok('mutation hooks delegate CLI writes') for (const h of ['session-start', 'session-stop', 'pre-compact']) { const mode = fs.statSync(path.join(root, 'hooks', h)).mode if (!(mode & 0o111)) fail(`hooks/${h} lost its executable bit`) diff --git a/bin/fde.js b/bin/fde.js index 2c52f53..c674309 100755 --- a/bin/fde.js +++ b/bin/fde.js @@ -22,6 +22,7 @@ * fde owner [set …] who keeps this engagement record * fde receipts "what did we agree?" - search memory with dates * fde capture session-end snapshot → context.md (hooks use this) + * fde preserve pre-compaction context snapshot (hooks use this) * fde status [--all] current engagement (default) or full portfolio (--all) * fde dashboard [--all] current engagement fieldbook (default) or all (--all) */ @@ -1347,6 +1348,29 @@ function cmdCapture() { } catch (_) {} } +function cmdPreserve() { + try { + const eng = resolveEngagement({ forWrite: true }) + if (!eng || !fs.existsSync(path.join(eng, 'context.md'))) return + const marker = '[fdeops context preserved' + const today = new Date().toISOString().slice(0, 10) + const context = readEng(eng, 'context.md') + if (context.split('\n').some(line => line.includes(marker) && line.includes(today))) return + + const recentDecisions = readClean(eng, 'decisions.md').split('\n').slice(-20).join('\n') + const openRisks = readClean(eng, 'risks.md').split('\n') + .filter(line => /open|active|unresolved/i.test(line)) + .slice(0, 8) + .join('\n') + const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') + const block = `\n---\n${marker} at ${timestamp}]\nRecent decisions (tail):\n${recentDecisions}\n\nOpen risks:\n${openRisks}\n---\n` + + ensureMemoryGit(eng) + lockedAppendFile(path.join(eng, 'context.md'), block, { soft: true }) + commitMemory(eng, 'context preserve', { files: ['context.md'] }) + } catch (_) {} +} + function cmdTriage() { const eng = resolveEngagement() if (!eng) { @@ -1867,6 +1891,7 @@ function printUsage() { fde owner [set email] who keeps this engagement record fde receipts "what did we agree?" with dates fde capture session-end memory snapshot (hooks use this) + fde preserve pre-compaction context snapshot (hooks use this) fde status [--all] current engagement status (pass --all for full portfolio) fde dashboard [--all] current engagement fieldbook (pass --all for every client) env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry) @@ -1888,6 +1913,7 @@ switch (cmd) { case 'owner': cmdOwner(args); break case 'receipts': cmdReceipts(args); break case 'capture': cmdCapture(); break + case 'preserve': cmdPreserve(); break case 'status': cmdStatus(args); break case 'dashboard': cmdDashboard(args); break case 'help': diff --git a/hooks/pre-compact b/hooks/pre-compact index 81d254b..7cfa221 100755 --- a/hooks/pre-compact +++ b/hooks/pre-compact @@ -58,59 +58,16 @@ fi [ -z "$ENG_DIR" ] && exit 0 -CONTEXT_FILE="$ENG_DIR/context.md" -DECISIONS_FILE="$ENG_DIR/decisions.md" -RISKS_FILE="$ENG_DIR/risks.md" -MARKER="[fdeops context preserved" - -[ -f "$CONTEXT_FILE" ] || exit 0 - -# Avoid unbounded growth: skip if we already preserved today. -if grep -Fq "$MARKER" "$CONTEXT_FILE" 2>/dev/null; then - LAST=$(grep -F "$MARKER" "$CONTEXT_FILE" | tail -1) - if echo "$LAST" | grep -q "$(date -u +%Y-%m-%d)"; then - exit 0 - fi +if command -v node >/dev/null 2>&1; then + for FDE_CLI in \ + "${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/bin/fde.js}" \ + "$(dirname "$0")/../bin/fde.js" \ + "$HOME/.claude/fdeops/fde.js"; do + if [ -n "$FDE_CLI" ] && [ -f "$FDE_CLI" ]; then + FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" preserve >/dev/null 2>&1 || true + break + fi + done fi -# Redact before extracting lines - this content is appended to -# context.md, which session-start loads into the model. A closed private block -# collapses to one placeholder line, so its inner text can't be tail'd or -# grepped out tag-stripped. Case-insensitive, mirrors the JS /gi redactor. -strip_private() { - awk ' - BEGIN { inblock = 0 } - { - line = $0 - lc = tolower(line) - if (inblock) { if (index(lc, "") > 0) { inblock = 0 } next } - if (index(lc, "") > 0) { - print "(private - redacted)" - if (index(lc, "") == 0) { inblock = 1 } - next - } - print line - } - ' "$1" -} - -TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") -LAST_DECISIONS="" -OPEN_RISKS="" - -[ -f "$DECISIONS_FILE" ] && LAST_DECISIONS=$(strip_private "$DECISIONS_FILE" 2>/dev/null | tail -20) -[ -f "$RISKS_FILE" ] && OPEN_RISKS=$(strip_private "$RISKS_FILE" 2>/dev/null | grep -iE "open|active|unresolved" | head -8) - -cat >> "$CONTEXT_FILE" 2>/dev/null << EOF - ---- -$MARKER at $TIMESTAMP] -Recent decisions (tail): -$LAST_DECISIONS - -Open risks: -$OPEN_RISKS ---- -EOF - exit 0 diff --git a/hooks/session-start b/hooks/session-start index d24492c..f5889b4 100755 --- a/hooks/session-start +++ b/hooks/session-start @@ -68,6 +68,7 @@ fi # 5) Optional in-repo .fde (customer-approved only) if [ -z "$CONTEXT_FILE" ] && [ -f ".fde/context.md" ]; then + ENG_DIR=".fde" CONTEXT_FILE=".fde/context.md" fi @@ -164,9 +165,9 @@ if [ -n "$CONTEXT_FILE" ] && [ -f "$CONTEXT_FILE" ]; then FDE_CMD=$(resolve_fde || true) if [ -n "$FDE_CMD" ]; then if [ "$FDE_CMD" = "fde" ]; then - TRIAGE=$(fde triage 2>/dev/null || true) + TRIAGE=$(FDEOPS_ENGAGEMENT="$ENG_DIR" fde triage 2>/dev/null || true) else - TRIAGE=$(node "$FDE_CMD" triage 2>/dev/null || true) + TRIAGE=$(FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CMD" triage 2>/dev/null || true) fi if [ -n "$TRIAGE" ]; then CONTENT="$CONTENT---\n$TRIAGE\n\n" diff --git a/hooks/session-stop b/hooks/session-stop index 3241a84..80a006a 100755 --- a/hooks/session-stop +++ b/hooks/session-stop @@ -2,11 +2,10 @@ # fdeops SessionEnd - write-side memory backstop. # # The skill's memory contract says the agent appends a meaningful -# "where we left off" to context.md before the session ends. This hook -# guarantees a deterministic floor when it doesn't: date, workspace state, -# and which engagement artifacts moved. Next session-start loads it back. +# "where we left off" to context.md before the session ends. This hook delegates +# the deterministic floor to the CLI, then refreshes the local dashboard. # -# Deliberately dumb: no model calls, no network, append-only, exits silently. +# No model calls, no network, exits silently. cat >/dev/null 2>&1 || true # drain hook stdin; payload unused @@ -72,31 +71,6 @@ if [ -z "$ENG_DIR" ] && [ -d ".fde" ]; then fi [ -z "$ENG_DIR" ] && exit 0 -CONTEXT_FILE="$ENG_DIR/context.md" - -TODAY=$(date +%F) -NOW=$(date +%H:%M) - -# Workspace state (only if the cwd is a git repo) -BRANCH=$(git -C "$PWD" branch --show-current 2>/dev/null) -LAST_COMMIT=$(git -C "$PWD" log -1 --format="%h %s" 2>/dev/null | head -c 100) -CHANGED=$(git -C "$PWD" status --porcelain 2>/dev/null | head -8 | sed 's/^...//' | tr '\n' ' ') - -# Engagement artifacts updated in the last 12h (the deliverable=memory trail) -UPDATED=$(find "$ENG_DIR" -maxdepth 1 -name "*.md" ! -name "context.md" -mmin -720 2>/dev/null \ - | while read -r f; do basename "$f"; done | tr '\n' ' ') - -# Idle session (no edits, no artifact updates) → leave the memory clean. -# LAST_COMMIT alone is not "movement": it exists in any repo with history. -[ -z "$CHANGED" ] && [ -z "$UPDATED" ] && exit 0 - -{ - printf '\n\n' - printf '## Session end - %s %s\n' "$TODAY" "$NOW" - [ -n "$BRANCH" ] && printf -- '- workspace: `%s` @ %s\n' "$BRANCH" "${LAST_COMMIT:-no commits yet}" - [ -n "$CHANGED" ] && printf -- '- uncommitted: %s\n' "$CHANGED" - [ -n "$UPDATED" ] && printf -- '- engagement files updated: %s\n' "$UPDATED" -} 2>/dev/null >> "$CONTEXT_FILE" || true # Refresh the local fieldbook.html so the portfolio view is current next time # it's opened. Deterministic render of .fde/ - zero tokens, best-effort, never @@ -107,7 +81,8 @@ if command -v node >/dev/null 2>&1; then "$(dirname "$0")/../bin/fde.js" \ "$HOME/.claude/fdeops/fde.js"; do if [ -n "$FDE_CLI" ] && [ -f "$FDE_CLI" ]; then - node "$FDE_CLI" dashboard >/dev/null 2>&1 || true + FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" capture >/dev/null 2>&1 || true + FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" dashboard >/dev/null 2>&1 || true break fi done diff --git a/test/fde-cli.test.js b/test/fde-cli.test.js index 3351714..9cc175a 100644 --- a/test/fde-cli.test.js +++ b/test/fde-cli.test.js @@ -48,6 +48,26 @@ function runFde(sandbox, args, opts = {}) { } } +function runHook(sandbox, name, opts = {}) { + const result = spawnSync('bash', [path.join(root, 'hooks', name)], { + cwd: opts.cwd || sandbox.workspace, + env: { + ...process.env, + HOME: sandbox.home, + USERPROFILE: sandbox.home, + PWD: opts.cwd || sandbox.workspace, + PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin`, + CLAUDE_PLUGIN_ROOT: root, + FDEOPS_ENGAGEMENT: '', + FDEOS_ENGAGEMENT: '', + ...(opts.env || {}), + }, + input: opts.input, + encoding: 'utf8', + }) + return { status: result.status, stdout: result.stdout || '', stderr: result.stderr || '' } +} + function engagementPath(sandbox, slug) { return path.join(sandbox.home, 'fde-engagements', slug, '.fde') } @@ -895,6 +915,124 @@ test('session-start injects TRIAGE + pointer, not the full SKILL.md body', () => assert.doesNotMatch(out, /## Anti-invention gates/) }) +test('session-stop delegates capture to the hook-resolved engagement and commits context only', () => { + const sandbox = makeSandbox('session-stop-delegation') + const projectA = path.join(sandbox.dir, 'project-a') + const projectB = path.join(sandbox.dir, 'project-b') + const hookWorkspace = path.join(sandbox.dir, 'hook workspace') + fs.mkdirSync(projectA) + fs.mkdirSync(projectB) + fs.mkdirSync(hookWorkspace) + assert.equal(runFde(sandbox, ['resume', '--init', 'Hook A'], { cwd: projectA }).status, 0) + assert.equal(runFde(sandbox, ['resume', '--init', 'Hook B'], { cwd: projectB }).status, 0) + const engA = engagementPath(sandbox, 'hook-a') + const engB = engagementPath(sandbox, 'hook-b') + fs.writeFileSync(path.join(hookWorkspace, 'CLAUDE.md'), `FDEOPS_ENGAGEMENT=${engA}\n`) + fs.mkdirSync(path.join(sandbox.home, '.claude'), { recursive: true }) + fs.writeFileSync(path.join(sandbox.home, '.claude', 'FDEOPS-CLAUDE.md'), `FDEOPS_ENGAGEMENT=${engB}\n`) + ensureEngagementGitClean(engA) + ensureEngagementGitClean(engB) + const headA = gitInEng(engA, ['rev-parse', 'HEAD']).stdout.trim() + const headB = gitInEng(engB, ['rev-parse', 'HEAD']).stdout.trim() + const contextB = fs.readFileSync(path.join(engB, 'context.md'), 'utf8') + + const result = runHook(sandbox, 'session-stop', { cwd: hookWorkspace }) + + assert.equal(result.status, 0, result.stderr) + const contextA = fs.readFileSync(path.join(engA, 'context.md'), 'utf8') + assert.match(contextA, /\n\n## Session end - \d{4}-\d{2}-\d{2} \d{2}:\d{2}\n/) + assert.notEqual(gitInEng(engA, ['rev-parse', 'HEAD']).stdout.trim(), headA, 'capture must commit engagement A') + assert.equal(gitInEng(engA, ['log', '-1', '--format=%s']).stdout.trim(), 'session capture') + assert.equal(gitInEng(engA, ['show', '--format=', '--name-only', 'HEAD']).stdout.trim(), 'context.md') + assert.equal(gitInEng(engB, ['rev-parse', 'HEAD']).stdout.trim(), headB, 'global engagement B must not be written') + assert.equal(fs.readFileSync(path.join(engB, 'context.md'), 'utf8'), contextB) +}) + +test('pre-compact delegates preserve with private stripping, UTC-day dedupe, and memory commit', () => { + const sandbox = makeSandbox('pre-compact-delegation') + const projectA = path.join(sandbox.dir, 'project-a') + const projectB = path.join(sandbox.dir, 'project-b') + const hookWorkspace = path.join(sandbox.dir, 'compact workspace') + fs.mkdirSync(projectA) + fs.mkdirSync(projectB) + fs.mkdirSync(hookWorkspace) + assert.equal(runFde(sandbox, ['resume', '--init', 'Compact A'], { cwd: projectA }).status, 0) + assert.equal(runFde(sandbox, ['resume', '--init', 'Compact B'], { cwd: projectB }).status, 0) + const engA = engagementPath(sandbox, 'compact-a') + const engB = engagementPath(sandbox, 'compact-b') + fs.writeFileSync(path.join(hookWorkspace, 'CLAUDE.md'), `FDEOPS_ENGAGEMENT="${engA}"\n`) + fs.mkdirSync(path.join(sandbox.home, '.claude'), { recursive: true }) + fs.writeFileSync(path.join(sandbox.home, '.claude', 'FDEOPS-CLAUDE.md'), `FDEOPS_ENGAGEMENT=${engB}\n`) + fs.writeFileSync(path.join(engA, 'decisions.md'), [ + '# Decisions', + ...Array.from({ length: 22 }, (_, i) => `- decision ${i + 1}`), + '', + 'PRIVATE_DECISION_CANARY', + '', + '- final public decision', + '', + ].join('\n')) + fs.writeFileSync(path.join(engA, 'risks.md'), [ + '# Risks', + '', + '- open PRIVATE_RISK_CANARY', + '', + ...Array.from({ length: 10 }, (_, i) => `- active public risk ${i + 1}`), + '', + ].join('\n')) + ensureEngagementGitClean(engA) + ensureEngagementGitClean(engB) + const headA = gitInEng(engA, ['rev-parse', 'HEAD']).stdout.trim() + const headB = gitInEng(engB, ['rev-parse', 'HEAD']).stdout.trim() + + const first = runHook(sandbox, 'pre-compact', { cwd: hookWorkspace }) + const second = runHook(sandbox, 'pre-compact', { cwd: hookWorkspace }) + + assert.equal(first.status, 0, first.stderr) + assert.equal(second.status, 0, second.stderr) + const context = fs.readFileSync(path.join(engA, 'context.md'), 'utf8') + const today = new Date().toISOString().slice(0, 10) + assert.match(context, new RegExp( + `\\n---\\n\\[fdeops context preserved at ${today}T\\d{2}:\\d{2}:\\d{2}Z\\]\\n` + + 'Recent decisions \\(tail\\):\\n[\\s\\S]*\\n\\nOpen risks:\\n[\\s\\S]*\\n---\\n$' + )) + assert.equal((context.match(/\[fdeops context preserved/g) || []).length, 1) + assert.doesNotMatch(context, /PRIVATE_DECISION_CANARY|PRIVATE_RISK_CANARY/) + assert.match(context, /final public decision/) + assert.equal((context.match(/active public risk/g) || []).length, 8) + assert.notEqual(gitInEng(engA, ['rev-parse', 'HEAD']).stdout.trim(), headA, 'preserve must commit engagement A') + assert.equal(gitInEng(engA, ['log', '-1', '--format=%s']).stdout.trim(), 'context preserve') + assert.equal(gitInEng(engA, ['show', '--format=', '--name-only', 'HEAD']).stdout.trim(), 'context.md') + assert.equal(gitInEng(engB, ['rev-parse', 'HEAD']).stdout.trim(), headB, 'global engagement B must not be written') +}) + +test('session-start triage uses the same project-pointer engagement as rendered context', () => { + const sandbox = makeSandbox('session-start-one-engagement') + const projectA = path.join(sandbox.dir, 'project-a') + const projectB = path.join(sandbox.dir, 'project-b') + const hookWorkspace = path.join(sandbox.dir, 'start workspace') + fs.mkdirSync(projectA) + fs.mkdirSync(projectB) + fs.mkdirSync(hookWorkspace) + assert.equal(runFde(sandbox, ['resume', '--init', 'Start A'], { cwd: projectA }).status, 0) + assert.equal(runFde(sandbox, ['resume', '--init', 'Start B'], { cwd: projectB }).status, 0) + const engA = engagementPath(sandbox, 'start-a') + const engB = engagementPath(sandbox, 'start-b') + fs.writeFileSync(path.join(engA, 'context.md'), '# Engagement context\n**Phase:** land\n\n## Next action\n- A_ONLY_NEXT_ACTION\n') + fs.writeFileSync(path.join(engB, 'context.md'), '# Engagement context\n**Phase:** ship\n\n## Next action\n- B_ONLY_NEXT_ACTION\n') + fs.writeFileSync(path.join(hookWorkspace, 'CLAUDE.md'), `FDEOPS_ENGAGEMENT=${engA}\n`) + fs.mkdirSync(path.join(sandbox.home, '.claude'), { recursive: true }) + fs.writeFileSync(path.join(sandbox.home, '.claude', 'FDEOPS-CLAUDE.md'), `FDEOPS_ENGAGEMENT=${engB}\n`) + + const result = runHook(sandbox, 'session-start', { cwd: hookWorkspace }) + + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /TRIAGE\s+\[[^\]]+\]\s+phase:land/) + assert.match(result.stdout, /A_ONLY_NEXT_ACTION/) + assert.doesNotMatch(result.stdout, /TRIAGE\s+\[[^\]]+\]\s+phase:ship/) + assert.doesNotMatch(result.stdout, /B_ONLY_NEXT_ACTION/) +}) + test('memory init stays quiet when git has no global identity', () => { const sandbox = makeSandbox('no-git-id') const env = { From ebe390794d8a7dc19662f463b3842c9d0c4a8014 Mon Sep 17 00:00:00 2001 From: Subash Natarajan Date: Mon, 20 Jul 2026 15:40:59 +0530 Subject: [PATCH 08/10] Harden preserve concurrency and timestamps --- bin/check.js | 17 +++-- bin/fde.js | 24 +++++-- test/fde-cli.test.js | 156 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 183 insertions(+), 14 deletions(-) diff --git a/bin/check.js b/bin/check.js index b750318..1c9dd48 100644 --- a/bin/check.js +++ b/bin/check.js @@ -202,16 +202,16 @@ if (fs.existsSync(pmf) && !read('docs/internal/PMF_360_REVIEW.md').includes('INT } else if (fs.existsSync(pmf)) ok('internal PMF banner') const hook = read('hooks/session-start') +const hookCode = hook.replace(/^[ \t]*#.*$/gm, '') if (!hook.includes('FDEOPS_ENGAGEMENT')) { fail('session-start hook must read FDEOPS_ENGAGEMENT env var') } else ok('hook FDEOPS_ENGAGEMENT') -if (!hook.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" fde triage') || - !hook.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CMD" triage')) { +if (!hookCode.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" fde triage') || + !hookCode.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CMD" triage')) { fail('session-start must run triage with its resolved engagement') } // Token discipline: SessionStart must not dump the full skill (L1 progressive disclosure). // Strip comments before scanning for a real `cat …SKILL.md` / BOOTSTRAP inject. -const hookCode = hook.replace(/^[ \t]*#.*$/gm, '') if (/\$\(cat\s+"\$BOOTSTRAP"\)|cat\s+"\$BOOTSTRAP"|cat\s+[^\n]*SKILL\.md/.test(hookCode)) { fail('session-start must not cat SKILL.md - inject TRIAGE + bounded context + pointer only') } @@ -244,19 +244,24 @@ if (!fs.existsSync(path.join(root, 'hooks', 'session-stop'))) { fail('hooks/session-stop missing (write-side memory backstop)') } else { const stopHook = read('hooks/session-stop') + const stopHookCode = stopHook.replace(/^[ \t]*#.*$/gm, '') if (!stopHook.includes('FDEOPS_ENGAGEMENT')) fail('session-stop must resolve FDEOPS_ENGAGEMENT') - if (!stopHook.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" capture')) { + if (!stopHookCode.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" capture')) { fail('session-stop must delegate capture with its resolved engagement') } ok('session-stop write side') } const compactHook = read('hooks/pre-compact') -if (!compactHook.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" preserve')) { +const compactHookCode = compactHook.replace(/^[ \t]*#.*$/gm, '') +if (!compactHookCode.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" preserve')) { fail('pre-compact must delegate preserve with its resolved engagement') } for (const h of ['session-stop', 'pre-compact']) { const body = read('hooks/' + h).replace(/^[ \t]*#.*$/gm, '') - if (/>>\s*"\$CONTEXT_FILE"|cat\s*>>\s*"\$CONTEXT_FILE"/.test(body)) { + const contextTarget = /(?:"?\$(?:\{)?CONTEXT_FILE(?:\})?"?|"?\$(?:\{)?ENG_DIR(?:\})?\/context\.md"?)/.source + const redirectsToContext = new RegExp(`>{1,2}\\s*${contextTarget}`) + const teesToContext = new RegExp(`\\btee\\b[^\\n]*${contextTarget}`) + if (redirectsToContext.test(body) || teesToContext.test(body)) { fail(`hooks/${h} must not append context.md directly`) } } diff --git a/bin/fde.js b/bin/fde.js index c674309..7e08702 100755 --- a/bin/fde.js +++ b/bin/fde.js @@ -1336,7 +1336,12 @@ function cmdCapture() { }).join(' ') if (!changed && !updated) process.exit(0) // idle session - keep memory clean const d = new Date() - const stamp = `${d.toISOString().slice(0, 10)} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` + const localDate = [ + d.getFullYear(), + String(d.getMonth() + 1).padStart(2, '0'), + String(d.getDate()).padStart(2, '0'), + ].join('-') + const stamp = `${localDate} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` let block = `\n\n## Session end - ${stamp}\n` if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n` if (changed) block += `- uncommitted: ${changed}\n` @@ -1354,10 +1359,9 @@ function cmdPreserve() { if (!eng || !fs.existsSync(path.join(eng, 'context.md'))) return const marker = '[fdeops context preserved' const today = new Date().toISOString().slice(0, 10) - const context = readEng(eng, 'context.md') - if (context.split('\n').some(line => line.includes(marker) && line.includes(today))) return - - const recentDecisions = readClean(eng, 'decisions.md').split('\n').slice(-20).join('\n') + const decisionLines = readClean(eng, 'decisions.md').split('\n') + if (decisionLines[decisionLines.length - 1] === '') decisionLines.pop() + const recentDecisions = decisionLines.slice(-20).join('\n') const openRisks = readClean(eng, 'risks.md').split('\n') .filter(line => /open|active|unresolved/i.test(line)) .slice(0, 8) @@ -1366,7 +1370,15 @@ function cmdPreserve() { const block = `\n---\n${marker} at ${timestamp}]\nRecent decisions (tail):\n${recentDecisions}\n\nOpen risks:\n${openRisks}\n---\n` ensureMemoryGit(eng) - lockedAppendFile(path.join(eng, 'context.md'), block, { soft: true }) + const contextPath = path.join(eng, 'context.md') + const blocked = refuseSymlinkWrite(contextPath, { soft: true }) + if (blocked) throw Object.assign(new Error(blocked), { code: 'ESYMLINK' }) + withFileLock(contextPath, () => { + const context = readEng(eng, 'context.md') + const preservedToday = context.split('\n') + .some(line => line.includes(marker) && line.includes(today)) + if (!preservedToday) fs.appendFileSync(contextPath, block) + }, { soft: true }) commitMemory(eng, 'context preserve', { files: ['context.md'] }) } catch (_) {} } diff --git a/test/fde-cli.test.js b/test/fde-cli.test.js index 9cc175a..6cb19ed 100644 --- a/test/fde-cli.test.js +++ b/test/fde-cli.test.js @@ -3,7 +3,7 @@ const fs = require('node:fs') const os = require('node:os') const path = require('node:path') const test = require('node:test') -const { spawnSync } = require('node:child_process') +const { spawn, spawnSync } = require('node:child_process') const root = path.join(__dirname, '..') const fde = path.join(root, 'bin', 'fde.js') @@ -49,7 +49,7 @@ function runFde(sandbox, args, opts = {}) { } function runHook(sandbox, name, opts = {}) { - const result = spawnSync('bash', [path.join(root, 'hooks', name)], { + const result = spawnSync('bash', [opts.hook || path.join(root, 'hooks', name)], { cwd: opts.cwd || sandbox.workspace, env: { ...process.env, @@ -948,6 +948,30 @@ test('session-stop delegates capture to the hook-resolved engagement and commits assert.equal(fs.readFileSync(path.join(engB, 'context.md'), 'utf8'), contextB) }) +test('capture uses one local calendar for both date and time', () => { + const sandbox = makeSandbox('capture-local-calendar') + assert.equal(runFde(sandbox, ['resume', '--init', 'Local Clock']).status, 0) + const eng = engagementPath(sandbox, 'local-clock') + const utcHour = new Date().getUTCHours() + const timeZone = utcHour >= 10 ? 'Pacific/Kiritimati' : 'Etc/GMT+12' + const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date()).map(part => [part.type, part.value])) + const localDate = `${parts.year}-${parts.month}-${parts.day}` + assert.notEqual(localDate, new Date().toISOString().slice(0, 10), 'test timezone must cross the UTC date boundary') + + const capture = runFde(sandbox, ['capture'], { + env: { FDEOPS_ENGAGEMENT: eng, TZ: timeZone }, + }) + + assert.equal(capture.status, 0, capture.stderr) + const context = fs.readFileSync(path.join(eng, 'context.md'), 'utf8') + assert.match(context, new RegExp(`## Session end - ${localDate} \\d{2}:\\d{2}`)) +}) + test('pre-compact delegates preserve with private stripping, UTC-day dedupe, and memory commit', () => { const sandbox = makeSandbox('pre-compact-delegation') const projectA = path.join(sandbox.dir, 'project-a') @@ -1006,6 +1030,134 @@ test('pre-compact delegates preserve with private stripping, UTC-day dedupe, and assert.equal(gitInEng(engB, ['rev-parse', 'HEAD']).stdout.trim(), headB, 'global engagement B must not be written') }) +test('preserve deduplicates atomically while concurrent writers wait on the context lock', async () => { + const sandbox = makeSandbox('preserve-atomic-dedupe') + assert.equal(runFde(sandbox, ['resume', '--init', 'Atomic Preserve']).status, 0) + const eng = engagementPath(sandbox, 'atomic-preserve') + const contextPath = path.join(eng, 'context.md') + const lockPath = contextPath + '.lock' + fs.writeFileSync(lockPath, 'test barrier\n') + + const children = Array.from({ length: 6 }, () => new Promise(resolve => { + const child = spawn(process.execPath, [fde, 'preserve'], { + cwd: sandbox.workspace, + env: { + ...process.env, + HOME: sandbox.home, + USERPROFILE: sandbox.home, + FDEOPS_ENGAGEMENT: eng, + FDEOS_ENGAGEMENT: '', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stderr = '' + child.stderr.on('data', chunk => { stderr += chunk }) + child.on('close', status => resolve({ status, stderr })) + })) + await new Promise(resolve => setTimeout(resolve, 750)) + fs.unlinkSync(lockPath) + const results = await Promise.all(children) + + for (const result of results) assert.equal(result.status, 0, result.stderr) + const context = fs.readFileSync(contextPath, 'utf8') + assert.equal((context.match(/\[fdeops context preserved/g) || []).length, 1) +}) + +test('preserve commits an existing daily marker left dirty by an earlier failed commit', () => { + const sandbox = makeSandbox('preserve-commit-recovery') + assert.equal(runFde(sandbox, ['resume', '--init', 'Preserve Recovery']).status, 0) + const eng = engagementPath(sandbox, 'preserve-recovery') + const today = new Date().toISOString().slice(0, 10) + fs.appendFileSync(path.join(eng, 'context.md'), + `\n---\n[fdeops context preserved at ${today}T00:00:00Z]\nRecent decisions (tail):\n\nOpen risks:\n\n---\n`) + const before = gitInEng(eng, ['rev-parse', 'HEAD']).stdout.trim() + + const preserve = runFde(sandbox, ['preserve'], { env: { FDEOPS_ENGAGEMENT: eng } }) + + assert.equal(preserve.status, 0, preserve.stderr) + assert.notEqual(gitInEng(eng, ['rev-parse', 'HEAD']).stdout.trim(), before) + assert.equal(gitInEng(eng, ['status', '--porcelain']).stdout, '') + const context = fs.readFileSync(path.join(eng, 'context.md'), 'utf8') + assert.equal((context.match(/\[fdeops context preserved/g) || []).length, 1) +}) + +test('preserve refuses a symlinked context file without touching its target', () => { + const sandbox = makeSandbox('preserve-context-symlink') + assert.equal(runFde(sandbox, ['resume', '--init', 'Preserve Symlink']).status, 0) + const eng = engagementPath(sandbox, 'preserve-symlink') + const contextPath = path.join(eng, 'context.md') + const outside = path.join(sandbox.dir, 'outside-context.md') + fs.writeFileSync(outside, 'outside must stay unchanged\n') + fs.unlinkSync(contextPath) + fs.symlinkSync(outside, contextPath) + + const preserve = runFde(sandbox, ['preserve'], { env: { FDEOPS_ENGAGEMENT: eng } }) + + assert.equal(preserve.status, 0, preserve.stderr) + assert.equal(fs.readFileSync(outside, 'utf8'), 'outside must stay unchanged\n') + assert.equal(fs.lstatSync(contextPath).isSymbolicLink(), true) +}) + +test('preserve keeps twenty decision lines when readClean input has one terminal newline', () => { + const sandbox = makeSandbox('preserve-tail-lines') + assert.equal(runFde(sandbox, ['resume', '--init', 'Preserve Tail']).status, 0) + const eng = engagementPath(sandbox, 'preserve-tail') + fs.writeFileSync(path.join(eng, 'decisions.md'), + Array.from({ length: 21 }, (_, i) => `- intended decision ${String(i + 1).padStart(2, '0')}`).join('\n') + '\n') + + const preserve = runFde(sandbox, ['preserve'], { env: { FDEOPS_ENGAGEMENT: eng } }) + + assert.equal(preserve.status, 0, preserve.stderr) + const context = fs.readFileSync(path.join(eng, 'context.md'), 'utf8') + const decisions = context.split('Recent decisions (tail):\n')[1].split('\n\nOpen risks:')[0].split('\n') + assert.equal(decisions.length, 20) + assert.equal(decisions[0], '- intended decision 02') + assert.equal(decisions[19], '- intended decision 21') + assert.doesNotMatch(decisions.join('\n'), /intended decision 01/) +}) + +test('mutation hooks exit zero without writing when Node or the CLI is unavailable', () => { + const sandbox = makeSandbox('hooks-best-effort-missing-runtime') + assert.equal(runFde(sandbox, ['resume', '--init', 'Best Effort']).status, 0) + const eng = engagementPath(sandbox, 'best-effort') + const contextPath = path.join(eng, 'context.md') + const before = fs.readFileSync(contextPath, 'utf8') + const noNodePath = path.join(sandbox.dir, 'path-without-node') + fs.mkdirSync(noNodePath) + const shellTools = spawnSync('/bin/sh', ['-c', 'command -v bash; command -v cat; command -v sed'], { encoding: 'utf8' }) + assert.equal(shellTools.status, 0, shellTools.stderr) + for (const tool of shellTools.stdout.trim().split('\n')) { + fs.symlinkSync(tool, path.join(noNodePath, path.basename(tool))) + } + + for (const name of ['session-stop', 'pre-compact']) { + const withoutNode = runHook(sandbox, name, { + env: { + FDEOPS_ENGAGEMENT: eng, + CLAUDE_PLUGIN_ROOT: '', + PATH: noNodePath, + }, + }) + assert.equal(withoutNode.status, 0, withoutNode.stderr) + } + + const isolatedHooks = path.join(sandbox.dir, 'isolated-hooks') + fs.mkdirSync(isolatedHooks) + for (const name of ['session-stop', 'pre-compact']) { + const isolatedHook = path.join(isolatedHooks, name) + fs.copyFileSync(path.join(root, 'hooks', name), isolatedHook) + fs.chmodSync(isolatedHook, 0o755) + const withoutCli = runHook(sandbox, name, { + hook: isolatedHook, + env: { FDEOPS_ENGAGEMENT: eng, CLAUDE_PLUGIN_ROOT: '' }, + }) + assert.equal(withoutCli.status, 0, withoutCli.stderr) + } + + assert.equal(fs.readFileSync(contextPath, 'utf8'), before, + 'best-effort hooks must not bypass the CLI safety layer') +}) + test('session-start triage uses the same project-pointer engagement as rendered context', () => { const sandbox = makeSandbox('session-start-one-engagement') const projectA = path.join(sandbox.dir, 'project-a') From 8e022e5f031cef38db06a168592b305a1609e6a2 Mon Sep 17 00:00:00 2001 From: Subash Natarajan Date: Mon, 20 Jul 2026 15:47:08 +0530 Subject: [PATCH 09/10] Document reviewed minimal field hardening --- CHANGELOG.md | 11 +++++ ...26-07-20-minimal-field-hardening-design.md | 13 +++--- .../2026-07-20-minimal-field-hardening.md | 42 +++++++++---------- 3 files changed, 37 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57cd814..a985963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### Fixed +- Private signal-ledger content is redacted before prep and dashboard extraction. +- Secret detection now applies to `next:` debrief entries. +- Dashboard output replaces the existing fieldbook atomically. +- Hooks delegate capture and preserve through the explicitly resolved engagement. +- Preserve keeps daily deduplication atomic and commits only local memory changes. +- Session capture derives its date and time from one consistent local timestamp. +- Focused regressions cover privacy, locking, atomic replacement, hook delegation, deduplication, and timestamps. + ## 3.9.10 — 2026-07-20 Skill routing clarity + switch-tools docs + cheap skill eval pack. diff --git a/docs/plans/2026-07-20-minimal-field-hardening-design.md b/docs/plans/2026-07-20-minimal-field-hardening-design.md index 0a43082..4921786 100644 --- a/docs/plans/2026-07-20-minimal-field-hardening-design.md +++ b/docs/plans/2026-07-20-minimal-field-hardening-design.md @@ -13,7 +13,7 @@ This hardening release will: 3. Redact `.signal-ledger` before any dashboard or prep extraction. 4. Apply existing secret detection to `next:` debrief entries. 5. Replace the fieldbook atomically. -6. Recover conservatively from abandoned lock files. +6. Keep existing lock files intact for conservative operator recovery. 7. Add process-level regression tests for each behavior. ## Compatibility contract @@ -43,14 +43,13 @@ hook resolves engagement The hook remains responsible for compatibility resolution. Once resolved, it passes the exact path into the CLI. The CLI becomes the only writer, eliminating unlocked Bash appends without removing legacy pointer behavior. -`fde preserve` reproduces the current pre-compaction block and daily deduplication. It uses redacted reads and a locked append, then records the change in the existing local memory Git repository. It exits successfully on missing engagement or write failure because compaction hooks cannot be allowed to block. +`fde preserve` reproduces the current pre-compaction block and daily deduplication. It uses redacted reads and performs the check-and-append under one lock, then records the change in the existing local memory Git repository. It exits successfully on missing engagement or write failure because compaction hooks cannot be allowed to block. -Lock recovery uses the lock file's modification time. A lock older than 30 seconds is treated as abandoned and removed once before normal acquisition retries continue. Normal Markdown writes complete far below this threshold. +Lock acquisition does not infer ownership from file age. Any existing lock is retained; the writer waits up to five seconds and then asks the operator to retry. Automatic stale-lock recovery was removed after a Critical TOCTOU review: checking a lock's age and then unlinking it can delete a lock that another writer acquired or still owns between those operations. Safe automatic recovery would require an ownership token that is verified before deletion, which is outside this minimal hardening. ## Failure behavior -- Active lock: wait up to five seconds, then return the existing retry message. -- Abandoned lock: remove it and continue; no user memory is modified during recovery. +- Existing lock, regardless of age: wait up to five seconds, preserve the lock and target, then return the existing retry message. Manual/operator recovery is required when a lock is known to be abandoned. - Hook cannot locate Node or the CLI: exit successfully without writing, preserving current best-effort behavior. - Hook CLI write fails: exit successfully; existing memory remains untouched or contains only a complete append. - Dashboard replacement fails: retain the previous complete fieldbook and report a concise filesystem error. @@ -63,10 +62,11 @@ Process-level `node:test` cases will prove: - private canaries in `.signal-ledger` never appear in prep or dashboard output; - secret-like `next:` lines are skipped and do not enter `context.md`; -- an abandoned lock is reclaimed while a fresh lock still blocks; +- stale and fresh lock files both remain in place and block writes without modifying their targets; - dashboard replacement uses the atomic write path; - session-stop invokes `capture` against the resolved engagement; - pre-compact invokes `preserve`, retains redaction, and deduplicates daily; +- session capture uses one consistent local date and time; - session-start triage uses the same resolved engagement as its context. Every behavioral change follows red-green TDD. The full `npm run check` gate must remain clean. @@ -85,3 +85,4 @@ Ship as a patch release only after testing upgrades from a v3.9.10 sandbox. No m - Encryption or management of genuinely sensitive values - Broad resolver rewrite or removal of legacy pointers - Changes to trust scoring or dashboard information architecture +- Ownership-token lock protocol or automatic stale-lock recovery diff --git a/docs/plans/2026-07-20-minimal-field-hardening.md b/docs/plans/2026-07-20-minimal-field-hardening.md index f6586f9..ac346a9 100644 --- a/docs/plans/2026-07-20-minimal-field-hardening.md +++ b/docs/plans/2026-07-20-minimal-field-hardening.md @@ -66,55 +66,47 @@ Commit only `bin/fde.js` and `test/fde-cli.test.js` as Subash Natarajan. --- -### Task 2: Recover abandoned locks and preserve complete dashboards +### Task 2: Preserve conservative locks and complete dashboards **Files:** - Modify: `bin/fde.js:273-320` - Modify: `bin/fde.js:1831-1837` - Test: `test/fde-cli.test.js` -**Step 1: Write the failing abandoned-lock test** +**Step 1: Cover existing stale and fresh locks** -Create an old `decisions.md.lock`, run `fde log decision`, and assert the write succeeds and removes the lock. +Create old and fresh `decisions.md.lock` files, run `fde log decision`, and assert both writes time out with the existing retry message. In each case, the target and lock must remain unchanged for manual/operator recovery. -**Step 2: Verify RED** - -Run: `node --test --test-name-pattern="abandoned lock" test/fde-cli.test.js` - -Expected: FAIL after the current five-second lock timeout. - -**Step 3: Implement conservative stale-lock recovery** - -When lock acquisition sees `EEXIST`, inspect the lock mtime. If older than 30 seconds, unlink it once and retry. Preserve existing behavior for fresh locks and all other errors. +**Step 2: Retain conservative lock acquisition** -**Step 4: Verify stale and active lock behavior** +Do not infer lock ownership from modification time or unlink an existing lock. Automatic stale-lock recovery was removed after a Critical TOCTOU review: an age check followed by unlink can delete a lock another writer acquired or still owns between those operations. -Add/retain a fresh-lock test that expects failure with the existing retry message. Run both focused tests and confirm PASS. +An ownership-token protocol could make recovery verifiable, but is outside this minimal hardening. -**Step 5: Write the failing atomic-dashboard test** +**Step 3: Write the failing atomic-dashboard test** Use source-level structural validation in `bin/check.js` only if process-level failure injection is impractical; otherwise inject a rename failure and assert the old fieldbook remains byte-for-byte unchanged. -**Step 6: Verify RED** +**Step 4: Verify RED** Expected: FAIL because dashboard currently uses direct `writeFileSync`. -**Step 7: Reuse `atomicWriteFile()`** +**Step 5: Reuse `atomicWriteFile()`** Replace the direct dashboard write with `atomicWriteFile(outPath, html)`. Keep the existing output, symlink refusal, and error formatting. -**Step 8: Verify focused and full tests** +**Step 6: Verify focused and full tests** Run: ```bash -node --test --test-name-pattern="abandoned lock|fresh lock|dashboard" test/fde-cli.test.js +node --test --test-name-pattern="stale lock|fresh lock|dashboard" test/fde-cli.test.js npm run check ``` Expected: all checks pass without warnings. -**Step 9: Commit** +**Step 7: Commit** Commit the lock and dashboard hardening as Subash Natarajan. @@ -136,7 +128,7 @@ Commit the lock and dashboard hardening as Subash Natarajan. In isolated HOME/workspace sandboxes: - prove session-stop writes through `fde capture` and commits memory; -- prove pre-compact preserves the same redacted block once per UTC day; +- prove pre-compact preserves the same redacted block once per UTC day, with the dedupe check and append performed atomically under one lock; - prove triage and context use the hook-resolved engagement when project and global pointers differ. **Step 2: Verify RED** @@ -188,7 +180,11 @@ Pass `FDEOPS_ENGAGEMENT="$ENG_DIR"` to the existing CLI triage invocation so con Change `bin/check.js` invariants to require CLI delegation and reject raw `>> "$CONTEXT_FILE"`/`cat >> "$CONTEXT_FILE"` mutation paths. -**Step 8: Verify focused and full checks** +**Step 8: Cover consistent local capture timestamps** + +Run capture in a timezone where the local and UTC dates differ. Assert the session heading uses a date and time from the same local clock snapshot. + +**Step 9: Verify focused and full checks** Run: @@ -200,7 +196,7 @@ npm run check Expected: all checks pass without warnings. -**Step 9: Commit** +**Step 10: Commit** Commit the hook consolidation as Subash Natarajan. From 494006ea04d0ec71b9ad202efb38428d1aaf06ff Mon Sep 17 00:00:00 2001 From: Subash Natarajan Date: Mon, 20 Jul 2026 15:58:19 +0530 Subject: [PATCH 10/10] Ship field hardening as 3.9.11 Align mutation hooks with PATH fde discovery, add upgrade/PATH regressions, and cut the patch release. Co-authored-by: Cursor --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 9 ++++- bin/check.js | 11 +++++- bin/fde.js | 4 +- hooks/pre-compact | 36 ++++++++++++----- hooks/session-stop | 47 ++++++++++++++++------- package.json | 2 +- test/fde-cli.test.js | 79 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 158 insertions(+), 32 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1ec04ba..a3b8f1a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "fdeops", "description": "Second brain for Forward Deployed Engineers. One @fde skill - enter at day one, mid-project, or mid-fire; it routes and does the phase work; engagement memory lands in .fde/ as you confirm judgment.", - "version": "3.9.10", + "version": "3.9.11", "category": "productivity", "tags": [ "community-managed" diff --git a/CHANGELOG.md b/CHANGELOG.md index a985963..5c6aabc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,20 @@ # Changelog -## Unreleased +## 3.9.11 — 2026-07-20 + +Minimal field hardening for integrity and privacy without schema or workflow changes. ### Fixed - Private signal-ledger content is redacted before prep and dashboard extraction. - Secret detection now applies to `next:` debrief entries. - Dashboard output replaces the existing fieldbook atomically. - Hooks delegate capture and preserve through the explicitly resolved engagement. +- Mutation hooks prefer PATH `fde`, then plugin copies — same discovery order as session-start. - Preserve keeps daily deduplication atomic and commits only local memory changes. - Session capture derives its date and time from one consistent local timestamp. -- Focused regressions cover privacy, locking, atomic replacement, hook delegation, deduplication, and timestamps. + +### Added +- Focused regressions for privacy, locking, atomic replacement, hook delegation (including PATH fallback), upgrade-shaped fixtures, deduplication, and timestamps. ## 3.9.10 — 2026-07-20 diff --git a/bin/check.js b/bin/check.js index 1c9dd48..c77ab4e 100644 --- a/bin/check.js +++ b/bin/check.js @@ -246,14 +246,21 @@ if (!fs.existsSync(path.join(root, 'hooks', 'session-stop'))) { const stopHook = read('hooks/session-stop') const stopHookCode = stopHook.replace(/^[ \t]*#.*$/gm, '') if (!stopHook.includes('FDEOPS_ENGAGEMENT')) fail('session-stop must resolve FDEOPS_ENGAGEMENT') - if (!stopHookCode.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" capture')) { + if (!stopHookCode.includes('resolve_fde') || !stopHookCode.includes('command -v fde')) { + fail('session-stop must resolve PATH fde before plugin copies') + } + if (!/FDEOPS_ENGAGEMENT="\$ENG_DIR" (?:fde|node "\$FDE_CMD") capture/.test(stopHookCode) + && !stopHookCode.includes('run_fde "$FDE_CMD" capture')) { fail('session-stop must delegate capture with its resolved engagement') } ok('session-stop write side') } const compactHook = read('hooks/pre-compact') const compactHookCode = compactHook.replace(/^[ \t]*#.*$/gm, '') -if (!compactHookCode.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" preserve')) { +if (!compactHookCode.includes('resolve_fde') || !compactHookCode.includes('command -v fde')) { + fail('pre-compact must resolve PATH fde before plugin copies') +} +if (!/FDEOPS_ENGAGEMENT="\$ENG_DIR" (?:fde|node "\$FDE_CMD") preserve/.test(compactHookCode)) { fail('pre-compact must delegate preserve with its resolved engagement') } for (const h of ['session-stop', 'pre-compact']) { diff --git a/bin/fde.js b/bin/fde.js index 7e08702..4e62b3b 100755 --- a/bin/fde.js +++ b/bin/fde.js @@ -22,7 +22,7 @@ * fde owner [set …] who keeps this engagement record * fde receipts "what did we agree?" - search memory with dates * fde capture session-end snapshot → context.md (hooks use this) - * fde preserve pre-compaction context snapshot (hooks use this) + * fde preserve pre-compaction context snapshot (hook-internal; hooks use this) * fde status [--all] current engagement (default) or full portfolio (--all) * fde dashboard [--all] current engagement fieldbook (default) or all (--all) */ @@ -1903,7 +1903,7 @@ function printUsage() { fde owner [set email] who keeps this engagement record fde receipts "what did we agree?" with dates fde capture session-end memory snapshot (hooks use this) - fde preserve pre-compaction context snapshot (hooks use this) + fde preserve pre-compaction context snapshot (hook-internal; hooks use this) fde status [--all] current engagement status (pass --all for full portfolio) fde dashboard [--all] current engagement fieldbook (pass --all for every client) env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry) diff --git a/hooks/pre-compact b/hooks/pre-compact index 7cfa221..9136d5e 100755 --- a/hooks/pre-compact +++ b/hooks/pre-compact @@ -36,6 +36,25 @@ registry_engagement_dir() { return 1 } +# Prefer PATH `fde`, then plugin/repo copies - same order as session-start. +resolve_fde() { + if command -v fde >/dev/null 2>&1; then + printf '%s\n' "fde" + return 0 + fi + for candidate in \ + "${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/bin/fde.js}" \ + "$(dirname "$0")/../bin/fde.js" \ + "$HOME/.claude/fdeops/fde.js" \ + "$HOME/.claude/plugins/fdeops/bin/fde.js"; do + if [ -n "$candidate" ] && [ -f "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + ENG_DIR=$(resolve_engagement_dir "${FDEOPS_ENGAGEMENT:-${FDEOS_ENGAGEMENT:-}}") if [ -z "$ENG_DIR" ]; then @@ -58,16 +77,13 @@ fi [ -z "$ENG_DIR" ] && exit 0 -if command -v node >/dev/null 2>&1; then - for FDE_CLI in \ - "${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/bin/fde.js}" \ - "$(dirname "$0")/../bin/fde.js" \ - "$HOME/.claude/fdeops/fde.js"; do - if [ -n "$FDE_CLI" ] && [ -f "$FDE_CLI" ]; then - FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" preserve >/dev/null 2>&1 || true - break - fi - done +FDE_CMD=$(resolve_fde || true) +if [ -n "$FDE_CMD" ]; then + if [ "$FDE_CMD" = "fde" ]; then + FDEOPS_ENGAGEMENT="$ENG_DIR" fde preserve >/dev/null 2>&1 || true + else + FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CMD" preserve >/dev/null 2>&1 || true + fi fi exit 0 diff --git a/hooks/session-stop b/hooks/session-stop index 80a006a..ccda47c 100755 --- a/hooks/session-stop +++ b/hooks/session-stop @@ -43,6 +43,35 @@ registry_engagement_dir() { return 1 } +# Prefer PATH `fde`, then plugin/repo copies - same order as session-start. +resolve_fde() { + if command -v fde >/dev/null 2>&1; then + printf '%s\n' "fde" + return 0 + fi + for candidate in \ + "${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/bin/fde.js}" \ + "$(dirname "$0")/../bin/fde.js" \ + "$HOME/.claude/fdeops/fde.js" \ + "$HOME/.claude/plugins/fdeops/bin/fde.js"; do + if [ -n "$candidate" ] && [ -f "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +run_fde() { + local cmd="$1" + shift + if [ "$cmd" = "fde" ]; then + FDEOPS_ENGAGEMENT="$ENG_DIR" fde "$@" >/dev/null 2>&1 || true + else + FDEOPS_ENGAGEMENT="$ENG_DIR" node "$cmd" "$@" >/dev/null 2>&1 || true + fi +} + ENG_DIR="" # 1) Environment variable (any agent) @@ -72,20 +101,10 @@ fi [ -z "$ENG_DIR" ] && exit 0 -# Refresh the local fieldbook.html so the portfolio view is current next time -# it's opened. Deterministic render of .fde/ - zero tokens, best-effort, never -# allowed to break the session. -if command -v node >/dev/null 2>&1; then - for FDE_CLI in \ - "${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/bin/fde.js}" \ - "$(dirname "$0")/../bin/fde.js" \ - "$HOME/.claude/fdeops/fde.js"; do - if [ -n "$FDE_CLI" ] && [ -f "$FDE_CLI" ]; then - FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" capture >/dev/null 2>&1 || true - FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CLI" dashboard >/dev/null 2>&1 || true - break - fi - done +FDE_CMD=$(resolve_fde || true) +if [ -n "$FDE_CMD" ]; then + run_fde "$FDE_CMD" capture + run_fde "$FDE_CMD" dashboard fi exit 0 diff --git a/package.json b/package.json index 0a09247..6d99a28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fdeops", - "version": "3.9.10", + "version": "3.9.11", "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.", "bin": { "fdeops": "bin/install.js", diff --git a/test/fde-cli.test.js b/test/fde-cli.test.js index 6cb19ed..0c024b8 100644 --- a/test/fde-cli.test.js +++ b/test/fde-cli.test.js @@ -86,6 +86,14 @@ function ensureEngagementGitClean(eng) { } } +function tryUnlink(p) { + try { fs.unlinkSync(p) } catch (_) {} +} + +function rmTreeIfPresent(p) { + try { fs.rmSync(p, { recursive: true, force: true }) } catch (_) {} +} + test('resume --init creates templates, binds the workspace, and resume resolves from registry', () => { const sandbox = makeSandbox('resume') const init = runFde(sandbox, ['resume', '--init', 'Garvey Payments']) @@ -1158,6 +1166,77 @@ test('mutation hooks exit zero without writing when Node or the CLI is unavailab 'best-effort hooks must not bypass the CLI safety layer') }) +test('mutation hooks fall back to PATH fde when plugin copies are missing', () => { + const sandbox = makeSandbox('hooks-path-fde') + assert.equal(runFde(sandbox, ['resume', '--init', 'Path Fde']).status, 0) + const eng = engagementPath(sandbox, 'path-fde') + ensureEngagementGitClean(eng) + const beforeHead = gitInEng(eng, ['rev-parse', 'HEAD']).stdout.trim() + + const binDir = path.join(sandbox.dir, 'path-bin') + fs.mkdirSync(binDir) + const fdeShim = path.join(binDir, 'fde') + fs.writeFileSync(fdeShim, `#!/bin/sh\nexec "${process.execPath}" "${fde}" "$@"\n`) + fs.chmodSync(fdeShim, 0o755) + + const isolatedHooks = path.join(sandbox.dir, 'path-hooks') + fs.mkdirSync(isolatedHooks) + for (const name of ['session-stop', 'pre-compact']) { + const isolatedHook = path.join(isolatedHooks, name) + fs.copyFileSync(path.join(root, 'hooks', name), isolatedHook) + fs.chmodSync(isolatedHook, 0o755) + } + + const pathEnv = { + FDEOPS_ENGAGEMENT: eng, + CLAUDE_PLUGIN_ROOT: '', + PATH: `${binDir}:${path.dirname(process.execPath)}:/usr/bin:/bin`, + } + + const stop = runHook(sandbox, 'session-stop', { + hook: path.join(isolatedHooks, 'session-stop'), + env: pathEnv, + }) + assert.equal(stop.status, 0, stop.stderr) + assert.match(fs.readFileSync(path.join(eng, 'context.md'), 'utf8'), /## Session end -/) + assert.notEqual(gitInEng(eng, ['rev-parse', 'HEAD']).stdout.trim(), beforeHead, + 'PATH fde must still capture through session-stop') + + const afterCapture = gitInEng(eng, ['rev-parse', 'HEAD']).stdout.trim() + const compact = runHook(sandbox, 'pre-compact', { + hook: path.join(isolatedHooks, 'pre-compact'), + env: pathEnv, + }) + assert.equal(compact.status, 0, compact.stderr) + assert.match(fs.readFileSync(path.join(eng, 'context.md'), 'utf8'), /\[fdeops context preserved/) + assert.notEqual(gitInEng(eng, ['rev-parse', 'HEAD']).stdout.trim(), afterCapture, + 'PATH fde must still preserve through pre-compact') +}) + +test('v3.9.10-shaped engagement still works through core CLI verbs', () => { + const sandbox = makeSandbox('upgrade-fixture') + assert.equal(runFde(sandbox, ['resume', '--init', 'Upgrade Fixture']).status, 0) + const eng = engagementPath(sandbox, 'upgrade-fixture') + for (const hidden of ['.git', '.owner', '.signal-ledger', '.last-write']) { + const p = path.join(eng, hidden) + if (hidden === '.git') rmTreeIfPresent(p) + else tryUnlink(p) + } + + assert.equal(runFde(sandbox, ['resume']).status, 0) + assert.equal(runFde(sandbox, ['log', 'decision', 'keep launch date']).status, 0) + assert.equal(runFde(sandbox, ['debrief'], { input: 'contact: Denise saw demo [signal:green]\nnext: send recap\n' }).status, 0) + assert.equal(runFde(sandbox, ['prep', 'Sponsor sync']).status, 0) + const doctor = runFde(sandbox, ['doctor']) + assert.ok(doctor.status === 0 || doctor.status === 1, doctor.stderr) + assert.equal(runFde(sandbox, ['status']).status, 0) + const dash = runFde(sandbox, ['dashboard', '--out', path.join(sandbox.dir, 'upgrade.html')]) + assert.equal(dash.status, 0, dash.stderr) + assert.equal(runFde(sandbox, ['capture'], { env: { FDEOPS_ENGAGEMENT: eng } }).status, 0) + assert.equal(runFde(sandbox, ['preserve'], { env: { FDEOPS_ENGAGEMENT: eng } }).status, 0) + assert.match(fs.readFileSync(path.join(eng, 'context.md'), 'utf8'), /Session end|fdeops context preserved/) +}) + test('session-start triage uses the same project-pointer engagement as rendered context', () => { const sandbox = makeSandbox('session-start-one-engagement') const projectA = path.join(sandbox.dir, 'project-a')