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 +1 -1
- package/README.md +2 -2
- package/bin/fde.js +106 -33
- package/package.json +1 -1
- package/skills/fde/SKILL.md +1 -1
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
|
|
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
|
|
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
|
|
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.
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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')
|
|
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')
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
1613
|
-
|
|
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.
|
|
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",
|
package/skills/fde/SKILL.md
CHANGED
|
@@ -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
|
|