fdeops 3.7.8 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AGENTS.md - working in the fdeops repository
2
2
 
3
- This repository **is** fdeops - the second brain for Forward Deployed Engineers. One `@fde` skill routes an entire client engagement across six domains, the `fde` CLI does the deterministic work, and per-customer memory writes itself into `.fde/` files.
3
+ This repository **is** fdeops - the second brain for Forward Deployed Engineers. One `@fde` skill routes an entire client engagement across six domains, the `fde` CLI does the deterministic work, and per-customer memory lands in `.fde/` files as a side effect of the work (you still confirm judgment).
4
4
 
5
5
  ## If you are helping use fdeops in an engagement
6
6
 
package/README.md CHANGED
@@ -37,7 +37,7 @@ A notes app stores what you type. fdeops loads the right client into your AI age
37
37
  | **After a meeting** | Notes rot in a scratch file | `fde debrief` routes decisions, risks, deliveries, and contacts into the record, dated |
38
38
  | **Scope dispute** | "Small" additions absorbed silently; no record when the sponsor asks | `fde receipts <term>` answers "when did we agree to that?" with dates |
39
39
  | **Quiet stakeholder** | Noticed three weeks too late | `fde log contact --signal amber` the day it happens; `fde status` surfaces it |
40
- | **Multiple clients** | Details blur across engagements | One folder per client, never cross-contaminated |
40
+ | **Multiple clients** | Details blur across engagements | One folder per client; bind the workspace so writes cannot land on a name-alike checkout |
41
41
 
42
42
  ---
43
43
 
@@ -66,7 +66,7 @@ fdeops' `--init` creates the engagement memory at `~/fde-engagements/garvey/.fde
66
66
  @fde I just got the brief. New client, payments platform, they want it live before their Q3 audit.
67
67
  ```
68
68
 
69
- `@fde` is the one skill fdeops installs. Describe what's happening; it routes to the right field method and the memory writes itself. Full workflow: [docs/USAGE.md](docs/USAGE.md).
69
+ `@fde` is the one skill fdeops installs. Describe what's happening; it routes to the right field method and writes matching `.fde/` artifacts — you still confirm judgment. Full workflow: [docs/USAGE.md](docs/USAGE.md).
70
70
 
71
71
  Not ready to install? `npx fdeops scan` runs on any repo you can read - day-1 recon (pure `git` + file reads, no config, no account) that maps hotspots, test gaps, and reverted attempts, and ends with the ASK ON DAY 1 questions the brief never mentions. The scan is heuristic by design - treat its output as leads to verify on day one, not findings.
72
72
 
package/bin/fde.js CHANGED
@@ -93,7 +93,11 @@ function readRegistry() {
93
93
  } catch (_) { return [] }
94
94
  }
95
95
 
96
- function resolveEngagement() {
96
+ function resolveEngagement(opts = {}) {
97
+ // opts.forWrite: memory mutations (log/debrief/capture) require an intentional
98
+ // bind - env, registry, pointer, or in-repo .fde. Basename matching is
99
+ // read-only convenience; writing on a folder-name guess contaminates clients.
100
+ const forWrite = !!opts.forWrite
97
101
  // 1) explicit env (back-compat: accept old FDEOS_ENGAGEMENT too)
98
102
  const env = (process.env.FDEOPS_ENGAGEMENT || process.env.FDEOS_ENGAGEMENT || '').replace(/^~/, HOME).trim()
99
103
  if (env && fs.existsSync(env)) return env
@@ -123,15 +127,21 @@ function resolveEngagement() {
123
127
  }
124
128
  } catch (_) {}
125
129
  }
126
- // 4) workspace dir name matches an engagement slug. This is a convenience,
127
- // NOT a binding - an unbound directory that merely happens to be named like a
128
- // client (a fork, a demo, a second client with the same codename) would
129
- // otherwise attach to that client's memory silently and get written into.
130
- // Never silent: warn on stderr so cross-client contamination can't happen
131
- // unnoticed, and tell the user how to make the binding explicit.
132
- const guess = path.join(ENGAGEMENTS_ROOT, slugify(path.basename(cwd)), '.fde')
130
+ // 4) workspace dir name matches an engagement slug. Read-only convenience.
131
+ // NEVER a write target - an unbound checkout named like a client must not
132
+ // append into that client's memory.
133
+ const slugGuess = slugify(path.basename(cwd))
134
+ const guess = path.join(ENGAGEMENTS_ROOT, slugGuess, '.fde')
133
135
  if (fs.existsSync(guess)) {
134
- process.stderr.write(`⚠ resolved engagement by directory name ("${slugify(path.basename(cwd))}"), not a saved binding. If this is the right client, run \`fde resume --init ${slugify(path.basename(cwd))}\` here to bind it; if not, you are about to read/write the WRONG client's memory.\n`)
136
+ if (forWrite) {
137
+ process.stderr.write(
138
+ `no binding for this workspace - folder name matched "${slugGuess}" but writes require an explicit bind.\n` +
139
+ `run: fde resume --init ${slugGuess}\n` +
140
+ ` or: export FDEOPS_ENGAGEMENT=${guess}\n`
141
+ )
142
+ return null
143
+ }
144
+ process.stderr.write(`⚠ resolved engagement by directory name ("${slugGuess}"), not a saved binding (read-only). If this is the right client, run \`fde resume --init ${slugGuess}\` here to bind it before logging or debriefing.\n`)
135
145
  return guess
136
146
  }
137
147
  // 5) in-repo .fde (engagement-approved only)
@@ -156,6 +166,49 @@ function readEng(eng, f) {
156
166
  // a markdown file, so a forgotten stripPrivate() call can't leak a <private> block.
157
167
  function readClean(eng, f) { return stripPrivate(readEng(eng, f)) }
158
168
 
169
+ // CLI-owned append-only mirror of [signal:x] lines. Survives an agent rewrite
170
+ // that drops stakeholders.md "## Signal history" - skill discipline still
171
+ // matters, but CLI-logged trust tokens must not vanish with the markdown.
172
+ const SIGNAL_LEDGER = '.signal-ledger'
173
+
174
+ // Exclusive create lock + retry. Two parallel agent sessions (or hook + CLI)
175
+ // appending the same .fde file otherwise interleave/corrupt under load.
176
+ function withFileLock(targetPath, fn) {
177
+ const lockPath = targetPath + '.lock'
178
+ const deadline = Date.now() + 5000
179
+ while (true) {
180
+ let fd
181
+ try {
182
+ fd = fs.openSync(lockPath, 'wx')
183
+ } catch (e) {
184
+ if (e.code !== 'EEXIST') throw e
185
+ if (Date.now() > deadline) {
186
+ console.error(`could not lock ${path.basename(targetPath)} - another writer is active; retry`)
187
+ process.exit(1)
188
+ }
189
+ const waitUntil = Date.now() + 20
190
+ while (Date.now() < waitUntil) { /* spin */ }
191
+ continue
192
+ }
193
+ try {
194
+ return fn()
195
+ } finally {
196
+ try { fs.closeSync(fd) } catch (_) {}
197
+ try { fs.unlinkSync(lockPath) } catch (_) {}
198
+ }
199
+ }
200
+ }
201
+
202
+ function atomicWriteFile(p, content) {
203
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`
204
+ fs.writeFileSync(tmp, content)
205
+ fs.renameSync(tmp, p)
206
+ }
207
+
208
+ function lockedAppendFile(p, text) {
209
+ withFileLock(p, () => { fs.appendFileSync(p, text) })
210
+ }
211
+
159
212
  // Pull the body under a "## Heading" up to the next "##" (or EOF).
160
213
  function sectionBody(md, heading) {
161
214
  const lines = md.split('\n')
@@ -202,9 +255,13 @@ function appendUnderSection(md, heading, entry) {
202
255
  function appendLogEntry(eng, type, entry) {
203
256
  const p = path.join(eng, LOG_FILES[type])
204
257
  if (type === 'contact' && /\[signal:(red|amber|green)\]/i.test(entry)) {
205
- fs.writeFileSync(p, appendUnderSection(readEng(eng, LOG_FILES[type]), 'Signal history', entry))
258
+ withFileLock(p, () => {
259
+ atomicWriteFile(p, appendUnderSection(readEng(eng, LOG_FILES[type]), 'Signal history', entry))
260
+ })
261
+ // Durable CLI ledger - not rewritten by agent artifact passes.
262
+ lockedAppendFile(path.join(eng, SIGNAL_LEDGER), `${entry}\n`)
206
263
  } else {
207
- fs.appendFileSync(p, `\n${entry}\n`)
264
+ lockedAppendFile(p, `\n${entry}\n`)
208
265
  }
209
266
  }
210
267
 
@@ -219,9 +276,11 @@ function computeSignals(eng) {
219
276
  // readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
220
277
  // to the terminal and the rendered HTML - a <private> risk must never surface.
221
278
  const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md'); const risks = readClean(eng, 'risks.md')
279
+ // Prefer structured tokens from stakeholders + CLI ledger (ledger survives wipes)
280
+ const signalText = stake + '\n' + readClean(eng, SIGNAL_LEDGER)
222
281
  const phase = (ctx.match(/phase[:* ]+\**([a-z-]+)/i) || [])[1] || '?'
223
282
  let latest = null
224
- for (const l of stake.split('\n')) {
283
+ for (const l of signalText.split('\n')) {
225
284
  const sm = l.match(/\[signal:(red|amber|green)\]/i)
226
285
  if (!sm) continue
227
286
  const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
@@ -351,7 +410,8 @@ function extractStakeholders(eng) {
351
410
  // once the token is stripped, so match the token anywhere on the line rather
352
411
  // than requiring it immediately after the date; a debrief-written signal was
353
412
  // silently invisible to per-stakeholder matching before this.
354
- sectionBody(md, 'Signal history').split('\n').forEach(l => {
413
+ const histText = sectionBody(md, 'Signal history') + '\n' + readEng(eng, SIGNAL_LEDGER)
414
+ histText.split('\n').forEach(l => {
355
415
  const dm = l.trim().match(/^-\s*\[(\d{4}-\d{2}-\d{2})\]\s*(.*)$/i)
356
416
  if (!dm) return
357
417
  const sm = dm[2].match(/\[signal:(red|amber|green)\]/i)
@@ -470,7 +530,8 @@ function extractLog(eng) {
470
530
  sectionBody(readClean(eng, 'risks.md'), 'Retired').split('\n').forEach(l => {
471
531
  const m = l.trim().match(FLAT); if (m) push(m[1], m[2], 'receipt')
472
532
  })
473
- sectionBody(readClean(eng, 'stakeholders.md'), 'Signal history').split('\n').forEach(l => {
533
+ const signalLog = sectionBody(readClean(eng, 'stakeholders.md'), 'Signal history') + '\n' + readClean(eng, SIGNAL_LEDGER)
534
+ signalLog.split('\n').forEach(l => {
474
535
  const m = l.trim().match(FLAT); if (m) push(m[1], m[2], 'note')
475
536
  })
476
537
 
@@ -592,7 +653,7 @@ function cmdResume(args) {
592
653
  const prev = readRegistry().find(r => r.workspace === cwd)
593
654
  const kept = readRegistry().filter(r => r.workspace !== cwd).map(r => `${r.workspace} ${r.slug}`)
594
655
  kept.push(`${cwd} ${slug}`)
595
- fs.writeFileSync(REGISTRY, kept.join('\n') + '\n')
656
+ withFileLock(REGISTRY, () => { atomicWriteFile(REGISTRY, kept.join('\n') + '\n') })
596
657
  console.log(`ENGAGEMENT READY: ${fdeDir}\nbound to workspace: ${cwd}`)
597
658
  if (prev && prev.slug !== slug) console.log(`rebound: this workspace previously wrote to "${prev.slug}" - that memory is untouched; sessions here now write to "${slug}"`)
598
659
  // NDA surface: engagement notes must not silently leave the machine via file sync
@@ -667,7 +728,7 @@ function cmdLog(args) {
667
728
  const type = args[0]; const text = args.slice(1).join(' ')
668
729
  if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green]'); process.exit(1) }
669
730
  if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
670
- const eng = resolveEngagement()
731
+ const eng = resolveEngagement({ forWrite: true })
671
732
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
672
733
  const date = new Date().toISOString().slice(0, 10)
673
734
  const entry = `- [${date}] ${signal ? `[signal:${signal}] ` : ''}${text}`
@@ -690,7 +751,7 @@ function cmdDebrief(args) {
690
751
  const dryIdx = args.indexOf('--dry-run')
691
752
  const dry = dryIdx !== -1
692
753
  if (dry) args.splice(dryIdx, 1)
693
- const eng = resolveEngagement()
754
+ const eng = resolveEngagement({ forWrite: true })
694
755
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
695
756
  let input = ''
696
757
  if (args[0]) {
@@ -739,7 +800,7 @@ function cmdDebrief(args) {
739
800
  if (ctxLines.length) {
740
801
  const stamp = `${date} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
741
802
  if (dry) ctxLines.forEach(l => console.log(`→ context.md - ${l}`))
742
- else fs.appendFileSync(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
803
+ else lockedAppendFile(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
743
804
  }
744
805
  const plural = { decision: 'decisions', risk: 'risks', delivery: 'deliveries', contact: 'contacts' }
745
806
  const parts = Object.keys(counts).filter(t => counts[t])
@@ -794,7 +855,7 @@ function cmdReceipts(args) {
794
855
  }
795
856
 
796
857
  function cmdCapture() {
797
- const eng = resolveEngagement()
858
+ const eng = resolveEngagement({ forWrite: true })
798
859
  if (!eng) process.exit(0) // silent: capture must never break a session
799
860
  const branch = sh('git branch --show-current')
800
861
  const lastCommit = sh("git log -1 --format='%h %s'").slice(0, 100)
@@ -812,7 +873,7 @@ function cmdCapture() {
812
873
  if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
813
874
  if (changed) block += `- uncommitted: ${changed}\n`
814
875
  if (updated) block += `- engagement files updated: ${updated}\n`
815
- try { fs.appendFileSync(path.join(eng, 'context.md'), block) } catch (_) {}
876
+ try { lockedAppendFile(path.join(eng, 'context.md'), block) } catch (_) {}
816
877
  }
817
878
 
818
879
  function engagementSlugFromPath(eng) {
@@ -1609,18 +1670,8 @@ ${clientViews}
1609
1670
  }
1610
1671
  }
1611
1672
 
1612
- const [cmd, ...args] = process.argv.slice(2)
1613
- switch (cmd) {
1614
- case 'scan': cmdScan(); break
1615
- case 'resume': cmdResume(args); break
1616
- case 'log': cmdLog(args); break
1617
- case 'debrief': cmdDebrief(args); break
1618
- case 'receipts': cmdReceipts(args); break
1619
- case 'capture': cmdCapture(); break
1620
- case 'status': cmdStatus(args); break
1621
- case 'dashboard': cmdDashboard(args); break
1622
- default:
1623
- console.log(`fde - deterministic core of fdeops
1673
+ function printUsage() {
1674
+ console.log(`fde - deterministic core of fdeops
1624
1675
  fde scan day-1 recon of this repo (facts, no AI)
1625
1676
  fde resume load this workspace's engagement memory (bounded)
1626
1677
  fde resume --full load the complete context.md (no bound)
@@ -1632,5 +1683,27 @@ switch (cmd) {
1632
1683
  fde capture session-end memory snapshot (hooks use this)
1633
1684
  fde status [--all] current engagement status (pass --all for full portfolio)
1634
1685
  fde dashboard [--all] current engagement fieldbook (pass --all for every client)
1635
- env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)`)
1686
+ env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
1687
+ writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only`)
1688
+ }
1689
+
1690
+ const [cmd, ...args] = process.argv.slice(2)
1691
+ switch (cmd) {
1692
+ case 'scan': cmdScan(); break
1693
+ case 'resume': cmdResume(args); break
1694
+ case 'log': cmdLog(args); break
1695
+ case 'debrief': cmdDebrief(args); break
1696
+ case 'receipts': cmdReceipts(args); break
1697
+ case 'capture': cmdCapture(); break
1698
+ case 'status': cmdStatus(args); break
1699
+ case 'dashboard': cmdDashboard(args); break
1700
+ case 'help':
1701
+ case '-h':
1702
+ case '--help':
1703
+ printUsage()
1704
+ break
1705
+ default:
1706
+ printUsage()
1707
+ // Missing or unknown command must fail - exit 0 made typos look like success in scripts/hooks.
1708
+ process.exit(1)
1636
1709
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.7.8",
3
+ "version": "3.8.0",
4
4
  "description": "Field kit for engineers embedded in client work - a real CLI (recon, memory, portfolio), one @fde skill with field judgment on top, and hooks that make it automatic. Claude Code plugin and any agent that loads skills.",
5
5
  "bin": {
6
6
  "fdeops": "bin/install.js",
@@ -38,7 +38,7 @@ This is what makes fdeops a second brain instead of a chat window.
38
38
  - Data tagged `<private>` in `trust-profile.md` (sacred data, PHI, cardholder, classified) **never enters your context or any subagent prompt** - work around it, never with it.
39
39
  - Locked-down engagement (no AI on their code)? Use the CLI + the fieldbook only. The memory layer is the FDE's own notes, not customer code.
40
40
 
41
- **Engagement path - zero ceremony.** Run `fde resume` (fallback: `node ~/.claude/fdeops/fde.js resume`). The **workspace registry** (written once by `fde resume --init <name>`) is the normal path; resolution order is env var override → registry → pointer file → workspace-name match → `./.fde`. It prints a **bounded** view of `context.md` - the curated head (state, next action) plus the most recent activity, with the older session log collapsed (use `fde resume --full` when you genuinely need the whole history). If it reports NO ENGAGEMENT: confirm the client name in conversation (one question), then run `fde resume --init <name>` yourself - the one setup step; the FDE never runs setup commands. Never install fdeops on infrastructure the FDE does not control.
41
+ **Engagement path - zero ceremony.** Run `fde resume` (fallback: `node ~/.claude/fdeops/fde.js resume`). The **workspace registry** (written once by `fde resume --init <name>`) is the normal path; resolution order is env var override → registry → pointer file → workspace-name match (read-only) → `./.fde`. Writes require a bind (or `FDEOPS_ENGAGEMENT`), not folder name alone. It prints a **bounded** view of `context.md` - the curated head (state, next action) plus the most recent activity, with the older session log collapsed (use `fde resume --full` when you genuinely need the whole history). If it reports NO ENGAGEMENT: confirm the client name in conversation (one question), then run `fde resume --init <name>` yourself - the one setup step; the FDE never runs setup commands. Never install fdeops on infrastructure the FDE does not control.
42
42
 
43
43
  **The `fde` CLI does the deterministic work - use it instead of improvising shell:**
44
44