fdeops 3.7.8 → 3.8.1
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 +251 -43
- 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,95 @@ 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
|
+
const LAST_WRITE = '.last-write'
|
|
174
|
+
|
|
175
|
+
// Heuristic secret shapes - warn/block CLI writes so a wrong-client paste is not silent.
|
|
176
|
+
// Not a scanner product; high-signal patterns an FDE actually pastes by mistake.
|
|
177
|
+
const SECRET_PATTERNS = [
|
|
178
|
+
{ name: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
179
|
+
{ name: 'GitHub token', re: /\bghp_[A-Za-z0-9]{20,}\b/ },
|
|
180
|
+
{ name: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/ },
|
|
181
|
+
{ name: 'OpenAI-style key', re: /\bsk-[A-Za-z0-9]{20,}\b/ },
|
|
182
|
+
{ name: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
183
|
+
{ name: 'PEM private key', re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
|
|
184
|
+
{ name: 'Bearer token', re: /\bBearer\s+[A-Za-z0-9._\-]{20,}\b/ },
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
function findSecretHit(text) {
|
|
188
|
+
for (const p of SECRET_PATTERNS) {
|
|
189
|
+
if (p.re.test(String(text))) return p.name
|
|
190
|
+
}
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function refuseSecret(kind, hit) {
|
|
195
|
+
console.error(
|
|
196
|
+
`refused: ${kind} looks like a ${hit}.\n` +
|
|
197
|
+
`Do not log credentials into engagement memory. Redact first, or pass --force if this is intentional.\n` +
|
|
198
|
+
`If you already wrote one: fde log --undo`
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function recordLastWrite(eng, file, entry) {
|
|
203
|
+
const p = path.join(eng, LAST_WRITE)
|
|
204
|
+
withFileLock(p, () => {
|
|
205
|
+
atomicWriteFile(p, JSON.stringify({ file, entry, at: new Date().toISOString() }) + '\n')
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function removeExactEntryLine(md, entry) {
|
|
210
|
+
const target = entry.trim()
|
|
211
|
+
const lines = md.split('\n')
|
|
212
|
+
const idx = lines.findIndex(l => l.trim() === target)
|
|
213
|
+
if (idx === -1) return null
|
|
214
|
+
lines.splice(idx, 1)
|
|
215
|
+
while (idx < lines.length && lines[idx] === '') lines.splice(idx, 1)
|
|
216
|
+
return lines.join('\n')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
// Exclusive create lock + retry. Two parallel agent sessions (or hook + CLI)
|
|
221
|
+
// appending the same .fde file otherwise interleave/corrupt under load.
|
|
222
|
+
function withFileLock(targetPath, fn) {
|
|
223
|
+
const lockPath = targetPath + '.lock'
|
|
224
|
+
const deadline = Date.now() + 5000
|
|
225
|
+
while (true) {
|
|
226
|
+
let fd
|
|
227
|
+
try {
|
|
228
|
+
fd = fs.openSync(lockPath, 'wx')
|
|
229
|
+
} catch (e) {
|
|
230
|
+
if (e.code !== 'EEXIST') throw e
|
|
231
|
+
if (Date.now() > deadline) {
|
|
232
|
+
console.error(`could not lock ${path.basename(targetPath)} - another writer is active; retry`)
|
|
233
|
+
process.exit(1)
|
|
234
|
+
}
|
|
235
|
+
const waitUntil = Date.now() + 20
|
|
236
|
+
while (Date.now() < waitUntil) { /* spin */ }
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
return fn()
|
|
241
|
+
} finally {
|
|
242
|
+
try { fs.closeSync(fd) } catch (_) {}
|
|
243
|
+
try { fs.unlinkSync(lockPath) } catch (_) {}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function atomicWriteFile(p, content) {
|
|
249
|
+
const tmp = `${p}.${process.pid}.${Date.now()}.tmp`
|
|
250
|
+
fs.writeFileSync(tmp, content)
|
|
251
|
+
fs.renameSync(tmp, p)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function lockedAppendFile(p, text) {
|
|
255
|
+
withFileLock(p, () => { fs.appendFileSync(p, text) })
|
|
256
|
+
}
|
|
257
|
+
|
|
159
258
|
// Pull the body under a "## Heading" up to the next "##" (or EOF).
|
|
160
259
|
function sectionBody(md, heading) {
|
|
161
260
|
const lines = md.split('\n')
|
|
@@ -202,10 +301,15 @@ function appendUnderSection(md, heading, entry) {
|
|
|
202
301
|
function appendLogEntry(eng, type, entry) {
|
|
203
302
|
const p = path.join(eng, LOG_FILES[type])
|
|
204
303
|
if (type === 'contact' && /\[signal:(red|amber|green)\]/i.test(entry)) {
|
|
205
|
-
|
|
304
|
+
withFileLock(p, () => {
|
|
305
|
+
atomicWriteFile(p, appendUnderSection(readEng(eng, LOG_FILES[type]), 'Signal history', entry))
|
|
306
|
+
})
|
|
307
|
+
// Durable CLI ledger - not rewritten by agent artifact passes.
|
|
308
|
+
lockedAppendFile(path.join(eng, SIGNAL_LEDGER), `${entry}\n`)
|
|
206
309
|
} else {
|
|
207
|
-
|
|
310
|
+
lockedAppendFile(p, `\n${entry}\n`)
|
|
208
311
|
}
|
|
312
|
+
recordLastWrite(eng, LOG_FILES[type], entry)
|
|
209
313
|
}
|
|
210
314
|
|
|
211
315
|
// phase / trust / top risk / freshness - identical heuristic for status + dashboard.
|
|
@@ -215,21 +319,59 @@ function appendLogEntry(eng, type, entry) {
|
|
|
215
319
|
// signal never silently drives triage. The keyword grep survives only as the
|
|
216
320
|
// zero-effort floor when NO token exists anywhere - prose like "escalated to CTO,
|
|
217
321
|
// resolved amicably" must not flip a client amber forever.
|
|
322
|
+
function stakeholdersMemoryHealth(eng) {
|
|
323
|
+
// Hostile handoff: binary / unparseable stakeholders must not read as healthy green.
|
|
324
|
+
let buf
|
|
325
|
+
try { buf = fs.readFileSync(path.join(eng, 'stakeholders.md')) } catch (_) {
|
|
326
|
+
return { ok: true, warn: '' }
|
|
327
|
+
}
|
|
328
|
+
if (buf.includes(0)) {
|
|
329
|
+
return { ok: false, warn: 'memory unreadable - verify (binary data in stakeholders.md)' }
|
|
330
|
+
}
|
|
331
|
+
const md = buf.toString('utf8')
|
|
332
|
+
const ledger = readEng(eng, SIGNAL_LEDGER)
|
|
333
|
+
if (/\[signal:(red|amber|green)\]/i.test(md + '\n' + ledger)) {
|
|
334
|
+
return { ok: true, warn: '' }
|
|
335
|
+
}
|
|
336
|
+
const trustLine = md.match(/\*\*Trust:\*\*\s*([A-Za-z?]+)/i)
|
|
337
|
+
if (trustLine && !/^(red|amber|green)$/i.test(trustLine[1])) {
|
|
338
|
+
return { ok: false, warn: 'memory unreadable - verify (invalid trust value)' }
|
|
339
|
+
}
|
|
340
|
+
const table = parseMdTable(md)
|
|
341
|
+
const meaningful = md.split('\n').filter(l => {
|
|
342
|
+
const t = l.trim()
|
|
343
|
+
return t && !t.startsWith('#') && !t.startsWith('<!--') && !/^\|?\s*:?-{3,}/.test(t)
|
|
344
|
+
}).length
|
|
345
|
+
// Content present but no table and no structured signal → do not invent "green"
|
|
346
|
+
if (meaningful >= 3 && !table) {
|
|
347
|
+
return { ok: false, warn: 'memory unreadable - verify (stakeholders.md unparseable)' }
|
|
348
|
+
}
|
|
349
|
+
return { ok: true, warn: '' }
|
|
350
|
+
}
|
|
351
|
+
|
|
218
352
|
function computeSignals(eng) {
|
|
219
353
|
// readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
|
|
220
354
|
// to the terminal and the rendered HTML - a <private> risk must never surface.
|
|
221
355
|
const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md'); const risks = readClean(eng, 'risks.md')
|
|
356
|
+
// Prefer structured tokens from stakeholders + CLI ledger (ledger survives wipes)
|
|
357
|
+
const signalText = stake + '\n' + readClean(eng, SIGNAL_LEDGER)
|
|
222
358
|
const phase = (ctx.match(/phase[:* ]+\**([a-z-]+)/i) || [])[1] || '?'
|
|
223
359
|
let latest = null
|
|
224
|
-
for (const l of
|
|
360
|
+
for (const l of signalText.split('\n')) {
|
|
225
361
|
const sm = l.match(/\[signal:(red|amber|green)\]/i)
|
|
226
362
|
if (!sm) continue
|
|
227
363
|
const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
|
|
228
|
-
|
|
364
|
+
const text = l.replace(/^\s*-\s*/, '').replace(/\[signal:(red|amber|green)\]/i, '').replace(/\[\d{4}-\d{2}-\d{2}\]/, '').trim()
|
|
365
|
+
if (!latest || date >= latest.date) latest = { date, sig: sm[1].toLowerCase(), text }
|
|
229
366
|
}
|
|
230
|
-
|
|
231
|
-
|
|
367
|
+
const mem = stakeholdersMemoryHealth(eng)
|
|
368
|
+
let trust, signalAge = null, stale = false, trustReason = ''
|
|
369
|
+
if (!mem.ok && !latest) {
|
|
370
|
+
trust = 'amber'
|
|
371
|
+
trustReason = mem.warn
|
|
372
|
+
} else if (latest) {
|
|
232
373
|
trust = latest.sig === 'red' ? 'RED' : latest.sig
|
|
374
|
+
trustReason = (latest.text || '').slice(0, 80)
|
|
233
375
|
if (latest.date) {
|
|
234
376
|
signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(latest.date)) / 86400000))
|
|
235
377
|
stale = signalAge > 21
|
|
@@ -244,12 +386,14 @@ function computeSignals(eng) {
|
|
|
244
386
|
return /^[-|]/.test(t) && t.length > 20 && !/^\|?[-\s|]+$/.test(t) &&
|
|
245
387
|
!/risk\s*\|\s*status|mitigation/i.test(t) && !t.startsWith('<!--')
|
|
246
388
|
}) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
389
|
+
// Prefer the trust trigger (signal / memory warn) over a random risk line when triage is not green
|
|
390
|
+
const reason = (trust !== 'green' && (trustReason || mem.warn)) ? (trustReason || mem.warn) : topRisk
|
|
247
391
|
let updated = 'never', ageDays = Infinity
|
|
248
392
|
try {
|
|
249
393
|
ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
|
|
250
394
|
updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
|
|
251
395
|
} catch (_) {}
|
|
252
|
-
return { phase, trust, signalAge, stale, topRisk, updated, ageDays }
|
|
396
|
+
return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, updated, ageDays }
|
|
253
397
|
}
|
|
254
398
|
|
|
255
399
|
// ---------- dashboard content extractors (best-effort, read-only) ----------
|
|
@@ -351,7 +495,8 @@ function extractStakeholders(eng) {
|
|
|
351
495
|
// once the token is stripped, so match the token anywhere on the line rather
|
|
352
496
|
// than requiring it immediately after the date; a debrief-written signal was
|
|
353
497
|
// silently invisible to per-stakeholder matching before this.
|
|
354
|
-
sectionBody(md, 'Signal history')
|
|
498
|
+
const histText = sectionBody(md, 'Signal history') + '\n' + readEng(eng, SIGNAL_LEDGER)
|
|
499
|
+
histText.split('\n').forEach(l => {
|
|
355
500
|
const dm = l.trim().match(/^-\s*\[(\d{4}-\d{2}-\d{2})\]\s*(.*)$/i)
|
|
356
501
|
if (!dm) return
|
|
357
502
|
const sm = dm[2].match(/\[signal:(red|amber|green)\]/i)
|
|
@@ -470,7 +615,8 @@ function extractLog(eng) {
|
|
|
470
615
|
sectionBody(readClean(eng, 'risks.md'), 'Retired').split('\n').forEach(l => {
|
|
471
616
|
const m = l.trim().match(FLAT); if (m) push(m[1], m[2], 'receipt')
|
|
472
617
|
})
|
|
473
|
-
sectionBody(readClean(eng, 'stakeholders.md'), 'Signal history')
|
|
618
|
+
const signalLog = sectionBody(readClean(eng, 'stakeholders.md'), 'Signal history') + '\n' + readClean(eng, SIGNAL_LEDGER)
|
|
619
|
+
signalLog.split('\n').forEach(l => {
|
|
474
620
|
const m = l.trim().match(FLAT); if (m) push(m[1], m[2], 'note')
|
|
475
621
|
})
|
|
476
622
|
|
|
@@ -592,7 +738,7 @@ function cmdResume(args) {
|
|
|
592
738
|
const prev = readRegistry().find(r => r.workspace === cwd)
|
|
593
739
|
const kept = readRegistry().filter(r => r.workspace !== cwd).map(r => `${r.workspace} ${r.slug}`)
|
|
594
740
|
kept.push(`${cwd} ${slug}`)
|
|
595
|
-
|
|
741
|
+
withFileLock(REGISTRY, () => { atomicWriteFile(REGISTRY, kept.join('\n') + '\n') })
|
|
596
742
|
console.log(`ENGAGEMENT READY: ${fdeDir}\nbound to workspace: ${cwd}`)
|
|
597
743
|
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
744
|
// NDA surface: engagement notes must not silently leave the machine via file sync
|
|
@@ -654,8 +800,39 @@ function resumeView(md) {
|
|
|
654
800
|
return `${head}\n\n_(\u2026 ${hidden} lines of earlier session log hidden \u2014 \`fde resume --full\` or open context.md for the full history)_\n\n${tail}`
|
|
655
801
|
}
|
|
656
802
|
|
|
803
|
+
function cmdLogUndo() {
|
|
804
|
+
const eng = resolveEngagement({ forWrite: true })
|
|
805
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
806
|
+
const metaPath = path.join(eng, LAST_WRITE)
|
|
807
|
+
let meta
|
|
808
|
+
try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) } catch (_) {
|
|
809
|
+
console.error('nothing to undo - no prior fde log/debrief write recorded')
|
|
810
|
+
process.exit(1)
|
|
811
|
+
}
|
|
812
|
+
if (!meta.file || !meta.entry) { console.error('corrupt .last-write - cannot undo'); process.exit(1) }
|
|
813
|
+
const target = path.join(eng, meta.file)
|
|
814
|
+
const before = readEng(eng, meta.file)
|
|
815
|
+
const after = removeExactEntryLine(before, meta.entry)
|
|
816
|
+
if (after == null) {
|
|
817
|
+
console.error(`cannot undo - entry no longer in ${meta.file} (edited by hand?). Remove it manually.`)
|
|
818
|
+
process.exit(1)
|
|
819
|
+
}
|
|
820
|
+
withFileLock(target, () => { atomicWriteFile(target, after.endsWith('\n') ? after : after + '\n') })
|
|
821
|
+
if (/\[signal:(red|amber|green)\]/i.test(meta.entry)) {
|
|
822
|
+
const ledgerPath = path.join(eng, SIGNAL_LEDGER)
|
|
823
|
+
const led = removeExactEntryLine(readEng(eng, SIGNAL_LEDGER), meta.entry)
|
|
824
|
+
if (led != null) withFileLock(ledgerPath, () => { atomicWriteFile(ledgerPath, led.endsWith('\n') ? led : led + '\n') })
|
|
825
|
+
}
|
|
826
|
+
try { fs.unlinkSync(metaPath) } catch (_) {}
|
|
827
|
+
console.log(`undid last write → ${meta.file}`)
|
|
828
|
+
}
|
|
829
|
+
|
|
657
830
|
function cmdLog(args) {
|
|
658
831
|
args = args.slice()
|
|
832
|
+
if (args[0] === '--undo') { cmdLogUndo(); return }
|
|
833
|
+
let force = false
|
|
834
|
+
const forceIdx = args.indexOf('--force')
|
|
835
|
+
if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
|
|
659
836
|
// --signal red|amber|green (contact only) → structured token computeSignals trusts
|
|
660
837
|
let signal = ''
|
|
661
838
|
const sigIdx = args.indexOf('--signal')
|
|
@@ -665,10 +842,13 @@ function cmdLog(args) {
|
|
|
665
842
|
args.splice(sigIdx, 2)
|
|
666
843
|
}
|
|
667
844
|
const type = args[0]; const text = args.slice(1).join(' ')
|
|
668
|
-
if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green]'); process.exit(1) }
|
|
845
|
+
if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green] [--force]\n fde log --undo'); process.exit(1) }
|
|
669
846
|
if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
|
|
670
|
-
const eng = resolveEngagement()
|
|
847
|
+
const eng = resolveEngagement({ forWrite: true })
|
|
671
848
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
849
|
+
const hit = findSecretHit(text)
|
|
850
|
+
if (hit && !force) { refuseSecret('log text', hit); process.exit(1) }
|
|
851
|
+
if (hit && force) console.error(`warning: logging possible ${hit} (--force)`)
|
|
672
852
|
const date = new Date().toISOString().slice(0, 10)
|
|
673
853
|
const entry = `- [${date}] ${signal ? `[signal:${signal}] ` : ''}${text}`
|
|
674
854
|
appendLogEntry(eng, type, entry)
|
|
@@ -690,7 +870,10 @@ function cmdDebrief(args) {
|
|
|
690
870
|
const dryIdx = args.indexOf('--dry-run')
|
|
691
871
|
const dry = dryIdx !== -1
|
|
692
872
|
if (dry) args.splice(dryIdx, 1)
|
|
693
|
-
|
|
873
|
+
let force = false
|
|
874
|
+
const forceIdx = args.indexOf('--force')
|
|
875
|
+
if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
|
|
876
|
+
const eng = resolveEngagement({ forWrite: true })
|
|
694
877
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
695
878
|
let input = ''
|
|
696
879
|
if (args[0]) {
|
|
@@ -727,19 +910,31 @@ function cmdDebrief(args) {
|
|
|
727
910
|
const m = bare.match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
|
|
728
911
|
if (m) {
|
|
729
912
|
const type = m[1].toLowerCase()
|
|
730
|
-
const
|
|
913
|
+
const body = m[2]
|
|
914
|
+
const hit = findSecretHit(body)
|
|
915
|
+
if (hit && !force) {
|
|
916
|
+
console.error(`skipped ${type} line - looks like a ${hit}. Redact it, or re-run with --force.`)
|
|
917
|
+
continue
|
|
918
|
+
}
|
|
919
|
+
const entry = `- [${date}] ${body}`
|
|
731
920
|
if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`)
|
|
732
921
|
// appendLogEntry, not a blind append: a contact: line may carry an
|
|
733
922
|
// inline [signal:x] token (the skill's own convention) and must land
|
|
734
923
|
// inside "## Signal history" the same way `fde log --signal` does.
|
|
735
924
|
else appendLogEntry(eng, type, entry)
|
|
736
925
|
counts[type]++
|
|
737
|
-
} else
|
|
926
|
+
} else {
|
|
927
|
+
if (findSecretHit(line) && !force) {
|
|
928
|
+
console.error('skipped context line - looks like a secret. Redact it, or re-run with --force.')
|
|
929
|
+
continue
|
|
930
|
+
}
|
|
931
|
+
ctxLines.push(line)
|
|
932
|
+
}
|
|
738
933
|
}
|
|
739
934
|
if (ctxLines.length) {
|
|
740
935
|
const stamp = `${date} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
741
936
|
if (dry) ctxLines.forEach(l => console.log(`→ context.md - ${l}`))
|
|
742
|
-
else
|
|
937
|
+
else lockedAppendFile(path.join(eng, 'context.md'), `\n## Debrief - ${stamp}\n${ctxLines.map(l => `- ${l}`).join('\n')}\n`)
|
|
743
938
|
}
|
|
744
939
|
const plural = { decision: 'decisions', risk: 'risks', delivery: 'deliveries', contact: 'contacts' }
|
|
745
940
|
const parts = Object.keys(counts).filter(t => counts[t])
|
|
@@ -794,7 +989,7 @@ function cmdReceipts(args) {
|
|
|
794
989
|
}
|
|
795
990
|
|
|
796
991
|
function cmdCapture() {
|
|
797
|
-
const eng = resolveEngagement()
|
|
992
|
+
const eng = resolveEngagement({ forWrite: true })
|
|
798
993
|
if (!eng) process.exit(0) // silent: capture must never break a session
|
|
799
994
|
const branch = sh('git branch --show-current')
|
|
800
995
|
const lastCommit = sh("git log -1 --format='%h %s'").slice(0, 100)
|
|
@@ -812,7 +1007,7 @@ function cmdCapture() {
|
|
|
812
1007
|
if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
|
|
813
1008
|
if (changed) block += `- uncommitted: ${changed}\n`
|
|
814
1009
|
if (updated) block += `- engagement files updated: ${updated}\n`
|
|
815
|
-
try {
|
|
1010
|
+
try { lockedAppendFile(path.join(eng, 'context.md'), block) } catch (_) {}
|
|
816
1011
|
}
|
|
817
1012
|
|
|
818
1013
|
function engagementSlugFromPath(eng) {
|
|
@@ -829,7 +1024,7 @@ function cmdStatus(args) {
|
|
|
829
1024
|
const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
|
|
830
1025
|
if (!fs.existsSync(eng)) continue
|
|
831
1026
|
const s = computeSignals(eng)
|
|
832
|
-
rows.push({ name: d, phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated,
|
|
1027
|
+
rows.push({ name: d, phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: (s.reason || s.topRisk).slice(0, 60) })
|
|
833
1028
|
}
|
|
834
1029
|
} else {
|
|
835
1030
|
const eng = resolveEngagement()
|
|
@@ -838,7 +1033,7 @@ function cmdStatus(args) {
|
|
|
838
1033
|
process.exit(2)
|
|
839
1034
|
}
|
|
840
1035
|
const s = computeSignals(eng)
|
|
841
|
-
rows.push({ name: engagementSlugFromPath(eng), phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated,
|
|
1036
|
+
rows.push({ name: engagementSlugFromPath(eng), phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: (s.reason || s.topRisk).slice(0, 60) })
|
|
842
1037
|
}
|
|
843
1038
|
if (!rows.length) { console.log('no engagements yet'); return }
|
|
844
1039
|
const order = { RED: 0, amber: 1, green: 2 }
|
|
@@ -848,7 +1043,7 @@ function cmdStatus(args) {
|
|
|
848
1043
|
// "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
|
|
849
1044
|
const label = r.trust + (r.stale ? '?' : '')
|
|
850
1045
|
const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
|
|
851
|
-
console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.
|
|
1046
|
+
console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${r.phase.padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
|
|
852
1047
|
}
|
|
853
1048
|
if (!all) console.log('\n(current engagement only - pass --all for the full portfolio)')
|
|
854
1049
|
console.log('\ntrust: latest [signal:x] token in stakeholders.md wins (fde log contact --signal, fde debrief); keyword heuristic only when none exists - verify before acting.')
|
|
@@ -1609,6 +1804,24 @@ ${clientViews}
|
|
|
1609
1804
|
}
|
|
1610
1805
|
}
|
|
1611
1806
|
|
|
1807
|
+
function printUsage() {
|
|
1808
|
+
console.log(`fde - deterministic core of fdeops
|
|
1809
|
+
fde scan day-1 recon of this repo (facts, no AI)
|
|
1810
|
+
fde resume load this workspace's engagement memory (bounded)
|
|
1811
|
+
fde resume --full load the complete context.md (no bound)
|
|
1812
|
+
fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
|
|
1813
|
+
fde resume --bind show what this workspace is bound to, and what resolves
|
|
1814
|
+
fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green; --force to allow secret-like text)
|
|
1815
|
+
fde log --undo remove the last CLI log/debrief entry from memory
|
|
1816
|
+
fde debrief [file] meeting notes → memory: decision:/risk:/delivery:/contact: lines route, rest → context.md (stdin if no file; --dry-run; --force)
|
|
1817
|
+
fde receipts <term> "what did we agree?" with dates
|
|
1818
|
+
fde capture session-end memory snapshot (hooks use this)
|
|
1819
|
+
fde status [--all] current engagement status (pass --all for full portfolio)
|
|
1820
|
+
fde dashboard [--all] current engagement fieldbook (pass --all for every client)
|
|
1821
|
+
env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
|
|
1822
|
+
writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only`)
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1612
1825
|
const [cmd, ...args] = process.argv.slice(2)
|
|
1613
1826
|
switch (cmd) {
|
|
1614
1827
|
case 'scan': cmdScan(); break
|
|
@@ -1619,18 +1832,13 @@ switch (cmd) {
|
|
|
1619
1832
|
case 'capture': cmdCapture(); break
|
|
1620
1833
|
case 'status': cmdStatus(args); break
|
|
1621
1834
|
case 'dashboard': cmdDashboard(args); break
|
|
1835
|
+
case 'help':
|
|
1836
|
+
case '-h':
|
|
1837
|
+
case '--help':
|
|
1838
|
+
printUsage()
|
|
1839
|
+
break
|
|
1622
1840
|
default:
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
fde resume --full load the complete context.md (no bound)
|
|
1627
|
-
fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
|
|
1628
|
-
fde resume --bind show what this workspace is bound to, and what resolves
|
|
1629
|
-
fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green)
|
|
1630
|
-
fde debrief [file] meeting notes → memory: decision:/risk:/delivery:/contact: lines route, rest → context.md (stdin if no file; --dry-run previews)
|
|
1631
|
-
fde receipts <term> "what did we agree?" with dates
|
|
1632
|
-
fde capture session-end memory snapshot (hooks use this)
|
|
1633
|
-
fde status [--all] current engagement status (pass --all for full portfolio)
|
|
1634
|
-
fde dashboard [--all] current engagement fieldbook (pass --all for every client)
|
|
1635
|
-
env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)`)
|
|
1841
|
+
printUsage()
|
|
1842
|
+
// Missing or unknown command must fail - exit 0 made typos look like success in scripts/hooks.
|
|
1843
|
+
process.exit(1)
|
|
1636
1844
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fdeops",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.1",
|
|
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
|
|