fdeops 3.8.0 → 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/bin/fde.js +147 -12
- package/package.json +1 -1
package/bin/fde.js
CHANGED
|
@@ -170,6 +170,52 @@ function readClean(eng, f) { return stripPrivate(readEng(eng, f)) }
|
|
|
170
170
|
// that drops stakeholders.md "## Signal history" - skill discipline still
|
|
171
171
|
// matters, but CLI-logged trust tokens must not vanish with the markdown.
|
|
172
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
|
+
|
|
173
219
|
|
|
174
220
|
// Exclusive create lock + retry. Two parallel agent sessions (or hook + CLI)
|
|
175
221
|
// appending the same .fde file otherwise interleave/corrupt under load.
|
|
@@ -263,6 +309,7 @@ function appendLogEntry(eng, type, entry) {
|
|
|
263
309
|
} else {
|
|
264
310
|
lockedAppendFile(p, `\n${entry}\n`)
|
|
265
311
|
}
|
|
312
|
+
recordLastWrite(eng, LOG_FILES[type], entry)
|
|
266
313
|
}
|
|
267
314
|
|
|
268
315
|
// phase / trust / top risk / freshness - identical heuristic for status + dashboard.
|
|
@@ -272,6 +319,36 @@ function appendLogEntry(eng, type, entry) {
|
|
|
272
319
|
// signal never silently drives triage. The keyword grep survives only as the
|
|
273
320
|
// zero-effort floor when NO token exists anywhere - prose like "escalated to CTO,
|
|
274
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
|
+
|
|
275
352
|
function computeSignals(eng) {
|
|
276
353
|
// readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
|
|
277
354
|
// to the terminal and the rendered HTML - a <private> risk must never surface.
|
|
@@ -284,11 +361,17 @@ function computeSignals(eng) {
|
|
|
284
361
|
const sm = l.match(/\[signal:(red|amber|green)\]/i)
|
|
285
362
|
if (!sm) continue
|
|
286
363
|
const date = (l.match(/\[(\d{4}-\d{2}-\d{2})\]/) || [])[1] || ''
|
|
287
|
-
|
|
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 }
|
|
288
366
|
}
|
|
289
|
-
|
|
290
|
-
|
|
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) {
|
|
291
373
|
trust = latest.sig === 'red' ? 'RED' : latest.sig
|
|
374
|
+
trustReason = (latest.text || '').slice(0, 80)
|
|
292
375
|
if (latest.date) {
|
|
293
376
|
signalAge = Math.max(0, Math.floor((Date.now() - Date.parse(latest.date)) / 86400000))
|
|
294
377
|
stale = signalAge > 21
|
|
@@ -303,12 +386,14 @@ function computeSignals(eng) {
|
|
|
303
386
|
return /^[-|]/.test(t) && t.length > 20 && !/^\|?[-\s|]+$/.test(t) &&
|
|
304
387
|
!/risk\s*\|\s*status|mitigation/i.test(t) && !t.startsWith('<!--')
|
|
305
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
|
|
306
391
|
let updated = 'never', ageDays = Infinity
|
|
307
392
|
try {
|
|
308
393
|
ageDays = Math.floor((Date.now() - fs.statSync(path.join(eng, 'context.md')).mtimeMs) / 86400000)
|
|
309
394
|
updated = ageDays === 0 ? 'today' : `${ageDays}d ago`
|
|
310
395
|
} catch (_) {}
|
|
311
|
-
return { phase, trust, signalAge, stale, topRisk, updated, ageDays }
|
|
396
|
+
return { phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn, updated, ageDays }
|
|
312
397
|
}
|
|
313
398
|
|
|
314
399
|
// ---------- dashboard content extractors (best-effort, read-only) ----------
|
|
@@ -715,8 +800,39 @@ function resumeView(md) {
|
|
|
715
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}`
|
|
716
801
|
}
|
|
717
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
|
+
|
|
718
830
|
function cmdLog(args) {
|
|
719
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) }
|
|
720
836
|
// --signal red|amber|green (contact only) → structured token computeSignals trusts
|
|
721
837
|
let signal = ''
|
|
722
838
|
const sigIdx = args.indexOf('--signal')
|
|
@@ -726,10 +842,13 @@ function cmdLog(args) {
|
|
|
726
842
|
args.splice(sigIdx, 2)
|
|
727
843
|
}
|
|
728
844
|
const type = args[0]; const text = args.slice(1).join(' ')
|
|
729
|
-
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) }
|
|
730
846
|
if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
|
|
731
847
|
const eng = resolveEngagement({ forWrite: true })
|
|
732
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)`)
|
|
733
852
|
const date = new Date().toISOString().slice(0, 10)
|
|
734
853
|
const entry = `- [${date}] ${signal ? `[signal:${signal}] ` : ''}${text}`
|
|
735
854
|
appendLogEntry(eng, type, entry)
|
|
@@ -751,6 +870,9 @@ function cmdDebrief(args) {
|
|
|
751
870
|
const dryIdx = args.indexOf('--dry-run')
|
|
752
871
|
const dry = dryIdx !== -1
|
|
753
872
|
if (dry) args.splice(dryIdx, 1)
|
|
873
|
+
let force = false
|
|
874
|
+
const forceIdx = args.indexOf('--force')
|
|
875
|
+
if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
|
|
754
876
|
const eng = resolveEngagement({ forWrite: true })
|
|
755
877
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
756
878
|
let input = ''
|
|
@@ -788,14 +910,26 @@ function cmdDebrief(args) {
|
|
|
788
910
|
const m = bare.match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
|
|
789
911
|
if (m) {
|
|
790
912
|
const type = m[1].toLowerCase()
|
|
791
|
-
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}`
|
|
792
920
|
if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`)
|
|
793
921
|
// appendLogEntry, not a blind append: a contact: line may carry an
|
|
794
922
|
// inline [signal:x] token (the skill's own convention) and must land
|
|
795
923
|
// inside "## Signal history" the same way `fde log --signal` does.
|
|
796
924
|
else appendLogEntry(eng, type, entry)
|
|
797
925
|
counts[type]++
|
|
798
|
-
} 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
|
+
}
|
|
799
933
|
}
|
|
800
934
|
if (ctxLines.length) {
|
|
801
935
|
const stamp = `${date} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
@@ -890,7 +1024,7 @@ function cmdStatus(args) {
|
|
|
890
1024
|
const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
|
|
891
1025
|
if (!fs.existsSync(eng)) continue
|
|
892
1026
|
const s = computeSignals(eng)
|
|
893
|
-
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) })
|
|
894
1028
|
}
|
|
895
1029
|
} else {
|
|
896
1030
|
const eng = resolveEngagement()
|
|
@@ -899,7 +1033,7 @@ function cmdStatus(args) {
|
|
|
899
1033
|
process.exit(2)
|
|
900
1034
|
}
|
|
901
1035
|
const s = computeSignals(eng)
|
|
902
|
-
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) })
|
|
903
1037
|
}
|
|
904
1038
|
if (!rows.length) { console.log('no engagements yet'); return }
|
|
905
1039
|
const order = { RED: 0, amber: 1, green: 2 }
|
|
@@ -909,7 +1043,7 @@ function cmdStatus(args) {
|
|
|
909
1043
|
// "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
|
|
910
1044
|
const label = r.trust + (r.stale ? '?' : '')
|
|
911
1045
|
const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
|
|
912
|
-
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}`)
|
|
913
1047
|
}
|
|
914
1048
|
if (!all) console.log('\n(current engagement only - pass --all for the full portfolio)')
|
|
915
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.')
|
|
@@ -1677,8 +1811,9 @@ function printUsage() {
|
|
|
1677
1811
|
fde resume --full load the complete context.md (no bound)
|
|
1678
1812
|
fde resume --init <name> create + bind engagement for this workspace (rebind replaces)
|
|
1679
1813
|
fde resume --bind show what this workspace is bound to, and what resolves
|
|
1680
|
-
fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green)
|
|
1681
|
-
fde
|
|
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)
|
|
1682
1817
|
fde receipts <term> "what did we agree?" with dates
|
|
1683
1818
|
fde capture session-end memory snapshot (hooks use this)
|
|
1684
1819
|
fde status [--all] current engagement status (pass --all for full portfolio)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fdeops",
|
|
3
|
-
"version": "3.8.
|
|
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",
|