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 57cd814..5c6aabc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 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. + +### 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 Skill routing clarity + switch-tools docs + cheap skill eval pack. diff --git a/bin/check.js b/bin/check.js index 7f02dbe..c77ab4e 100644 --- a/bin/check.js +++ b/bin/check.js @@ -202,12 +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 (!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') } @@ -240,10 +244,35 @@ 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('context.md')) fail('session-stop must append to context.md') + 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('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']) { + const body = read('hooks/' + h).replace(/^[ \t]*#.*$/gm, '') + 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`) + } +} +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 30157f7..4e62b3b 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 (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) */ @@ -559,7 +560,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 +1182,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 +1195,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 }) @@ -1335,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` @@ -1347,6 +1353,36 @@ 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 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) + .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) + 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 (_) {} +} + function cmdTriage() { const eng = resolveEngagement() if (!eng) { @@ -1830,8 +1866,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) } @@ -1868,6 +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 (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) @@ -1889,6 +1925,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/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..4921786 --- /dev/null +++ b/docs/plans/2026-07-20-minimal-field-hardening-design.md @@ -0,0 +1,88 @@ +# 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. Keep existing lock files intact for conservative operator recovery. +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 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 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 + +- 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. +- 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`; +- 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. + +## 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 +- 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 new file mode 100644 index 0000000..ac346a9 --- /dev/null +++ b/docs/plans/2026-07-20-minimal-field-hardening.md @@ -0,0 +1,239 @@ +# 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: 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: Cover existing stale and fresh locks** + +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: Retain conservative lock acquisition** + +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. + +An ownership-token protocol could make recovery verifiable, but is outside this minimal hardening. + +**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 4: Verify RED** + +Expected: FAIL because dashboard currently uses direct `writeFileSync`. + +**Step 5: Reuse `atomicWriteFile()`** + +Replace the direct dashboard write with `atomicWriteFile(outPath, html)`. Keep the existing output, symlink refusal, and error formatting. + +**Step 6: Verify focused and full tests** + +Run: + +```bash +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 7: 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, 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** + +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: 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: + +```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 10: 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`. diff --git a/hooks/pre-compact b/hooks/pre-compact index 81d254b..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,59 +77,13 @@ 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 +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 -# 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..ccda47c 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 @@ -44,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,45 +100,11 @@ 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 -# 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 - 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 f437a5c..0c024b8 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') @@ -48,6 +48,26 @@ function runFde(sandbox, args, opts = {}) { } } +function runHook(sandbox, name, opts = {}) { + const result = spawnSync('bash', [opts.hook || 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') } @@ -66,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']) @@ -107,6 +135,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 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 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', '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('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) @@ -199,6 +261,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=${JSON.stringify(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. @@ -456,6 +554,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 +613,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) @@ -788,6 +923,347 @@ 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('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') + 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('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('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') + 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 = {