Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
33 changes: 31 additions & 2 deletions bin/check.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand Down Expand Up @@ -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`)
Expand Down
55 changes: 46 additions & 9 deletions bin/fde.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
* fde owner [set …] who keeps this engagement record
* fde receipts <term> "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)
*/
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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 })
Expand Down Expand Up @@ -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<!-- fdeops auto-capture -->\n## Session end - ${stamp}\n`
if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
if (changed) block += `- uncommitted: ${changed}\n`
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -1868,6 +1903,7 @@ function printUsage() {
fde owner [set email] who keeps this engagement record
fde receipts <term> "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)
Expand All @@ -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':
Expand Down
88 changes: 88 additions & 0 deletions docs/plans/2026-07-20-minimal-field-hardening-design.md
Original file line number Diff line number Diff line change
@@ -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 `<private>` 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=<resolved> fde capture
├── FDEOPS_ENGAGEMENT=<resolved> fde preserve
└── FDEOPS_ENGAGEMENT=<resolved> 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
Loading
Loading