fdeops 3.11.1 → 3.15.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 +189 -129
- package/adapters/AGENTS.md +4 -1
- package/adapters/GEMINI.md +4 -1
- package/adapters/LOCAL-LLM.md +1 -1
- package/adapters/README.md +2 -2
- package/adapters/copilot-instructions.md +4 -1
- package/adapters/cursor.fde.mdc +4 -2
- package/bin/check.js +74 -25
- package/bin/fde.js +92 -43
- package/bin/install.js +1 -1
- package/bin/lib/trust.js +3 -2
- package/mcp/fdeops-ingest/package.json +1 -1
- package/package.json +5 -2
- package/plugin.json +3 -3
- package/skills/fde/SKILL.md +80 -231
- package/skills/fde/references/pattern-extract.md +9 -9
- package/skills/fde/references/status.md +2 -0
- package/skills/fde/references/build.md +0 -154
- package/skills/fde/references/debug.md +0 -55
- package/skills/fde/references/observability.md +0 -103
- package/skills/fde/references/qa-live.md +0 -113
- package/skills/fde/references/security-audit.md +0 -105
- package/skills/fde/references/test-on-legacy.md +0 -108
package/bin/check.js
CHANGED
|
@@ -41,12 +41,11 @@ for (const f of requiredTemplates) {
|
|
|
41
41
|
else ok(`template ${f}`)
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
} else {
|
|
48
|
-
ok('media/demo.gif')
|
|
44
|
+
const deadMedia = ['demo.gif', 'demo.sh', 'demo.tape', 'terminal-demo.svg', 'fieldbook-dashboard.png', 'fieldbook-detail.png', 'fieldbook-walkthrough.gif']
|
|
45
|
+
for (const name of deadMedia) {
|
|
46
|
+
if (fs.existsSync(path.join(root, 'media', name))) fail(`dead media/${name} must not ship — the recorded session is session.gif`)
|
|
49
47
|
}
|
|
48
|
+
ok('no staged mock media')
|
|
50
49
|
|
|
51
50
|
for (const dir of fs.readdirSync(path.join(root, 'skills'))) {
|
|
52
51
|
const skill = path.join(root, 'skills', dir, 'SKILL.md')
|
|
@@ -57,10 +56,14 @@ for (const dir of fs.readdirSync(path.join(root, 'skills'))) {
|
|
|
57
56
|
}
|
|
58
57
|
ok('skills structure')
|
|
59
58
|
|
|
59
|
+
if (fs.existsSync(path.join(root, 'skills', 'fde', 'archive'))) {
|
|
60
|
+
fail('skills/fde/archive must not exist — unrouted methods are dead code')
|
|
61
|
+
} else ok('no archived skill dump')
|
|
62
|
+
|
|
60
63
|
// v3: one skill + phase references (progressive disclosure)
|
|
61
64
|
const requiredReferences = [
|
|
62
|
-
'land.md', 'discover.md', 'audit.md', 'plan.md', '
|
|
63
|
-
'
|
|
65
|
+
'land.md', 'discover.md', 'audit.md', 'plan.md', 'review.md',
|
|
66
|
+
'rescue.md', 'ship.md', 'sketch.md', 'close.md', 'dashboard.md',
|
|
64
67
|
'debrief.md', 'status.md', 'demo-prep.md',
|
|
65
68
|
'healthcare.md', 'fintech.md', 'gov.md',
|
|
66
69
|
'ai.md', 'eval-pack.md',
|
|
@@ -80,7 +83,7 @@ ok('phase references')
|
|
|
80
83
|
// every judgment-heavy reference carries a worked example that names the memory
|
|
81
84
|
// file the work lands in. Prose-only guidance drifts into advice nobody can apply.
|
|
82
85
|
const exampleReferences = [
|
|
83
|
-
'land.md', 'discover.md', 'plan.md', '
|
|
86
|
+
'land.md', 'discover.md', 'plan.md', 'ship.md', 'close.md',
|
|
84
87
|
'status.md', 'stakeholder-radar.md', 'options-analysis.md', 'business-case.md',
|
|
85
88
|
'assumption-audit.md', 'scope-defense.md',
|
|
86
89
|
]
|
|
@@ -118,7 +121,7 @@ ok(`router dispatch (${mentioned.length} reference targets verified) + memory co
|
|
|
118
121
|
// Every routing row must parse. A row this misses is a method that could go
|
|
119
122
|
// undocumented for free, so an unparsed row is a hard failure, not a silent skip.
|
|
120
123
|
const routed = new Set()
|
|
121
|
-
const routing = (router.split(
|
|
124
|
+
const routing = (router.split(/^## Routing[^\n]*$/m)[1] || '').split('**Overlays')[0]
|
|
122
125
|
const methodCell = line => (line.split('|')[2] || '').trim().replace(/\s*\([^)]*\)\s*$/, '')
|
|
123
126
|
for (const line of routing.split('\n')) {
|
|
124
127
|
if (!/^\|/.test(line)) continue
|
|
@@ -197,6 +200,11 @@ ok(`router dispatch (${mentioned.length} reference targets verified) + memory co
|
|
|
197
200
|
}
|
|
198
201
|
}
|
|
199
202
|
ok(`public method count is verifiable (${documented.size} documented, ${routed.size} routed)`)
|
|
203
|
+
|
|
204
|
+
const refDir = path.join(root, 'skills', 'fde', 'references')
|
|
205
|
+
const extra = fs.readdirSync(refDir).filter(f => f.endsWith('.md') && !mentioned.includes(f))
|
|
206
|
+
if (extra.length) fail(`unrouted reference file(s) — dead method: ${extra.join(', ')}`)
|
|
207
|
+
else ok('no unrouted reference files')
|
|
200
208
|
}
|
|
201
209
|
|
|
202
210
|
const install = read('bin/install.js')
|
|
@@ -215,26 +223,20 @@ if (read('package.json').includes('postinstall')) {
|
|
|
215
223
|
}
|
|
216
224
|
|
|
217
225
|
const readme = read('README.md')
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// it must stay reachable and reproducible - an orphaned gif rots silently.
|
|
226
|
-
const recordingHosts = ['README.md', 'docs/USAGE.md'].filter(f => read(f).includes('media/session.gif'))
|
|
227
|
-
if (!recordingHosts.length) {
|
|
228
|
-
fail('media/session.gif must be embedded in README.md or docs/USAGE.md (the recorded session is the proof)')
|
|
229
|
-
} else if (!recordingHosts.some(f => read(f).includes('media/record-session.sh'))) {
|
|
230
|
-
fail('link media/record-session.sh next to the recording, so it can be re-recorded')
|
|
226
|
+
if (/session\.gif|demo\.gif|<img /i.test(readme)) {
|
|
227
|
+
fail('README must not embed images — front door is text; the recording lives in docs/USAGE.md')
|
|
228
|
+
} else ok('README is text (no gif)')
|
|
229
|
+
|
|
230
|
+
const usage = read('docs/USAGE.md')
|
|
231
|
+
if (!usage.includes('media/session.gif') || !usage.includes('media/record-session.sh')) {
|
|
232
|
+
fail('docs/USAGE.md must embed media/session.gif and link media/record-session.sh')
|
|
231
233
|
} else {
|
|
232
234
|
const gifPath = path.join(root, 'media', 'session.gif')
|
|
233
235
|
const rec = path.join(root, 'media', 'record-session.sh')
|
|
234
236
|
if (!fs.existsSync(gifPath) || fs.statSync(gifPath).size < 50000) fail('media/session.gif missing or too small')
|
|
235
237
|
else if (!fs.existsSync(rec)) fail('media/record-session.sh missing - the recording must be reproducible')
|
|
236
238
|
else if (!fs.existsSync(path.join(root, 'media', 'session.cast'))) fail('media/session.cast missing - keep the source recording next to the gif')
|
|
237
|
-
else ok(
|
|
239
|
+
else ok('recorded session in docs/USAGE.md (gif + reproducible recorder + cast)')
|
|
238
240
|
}
|
|
239
241
|
|
|
240
242
|
// Every repo-relative README link and image must resolve, or the front door 404s.
|
|
@@ -249,10 +251,10 @@ else ok('README links all resolve')
|
|
|
249
251
|
|
|
250
252
|
for (const section of [
|
|
251
253
|
'How it works',
|
|
252
|
-
'
|
|
254
|
+
'Quick Start',
|
|
253
255
|
'Engagement memory',
|
|
254
256
|
'Who this is for',
|
|
255
|
-
'
|
|
257
|
+
'Commands',
|
|
256
258
|
'Principles',
|
|
257
259
|
]) {
|
|
258
260
|
if (!readme.includes(section)) fail(`README missing section: ${section}`)
|
|
@@ -262,6 +264,16 @@ if (!readme.includes('AI coding agent')) {
|
|
|
262
264
|
}
|
|
263
265
|
ok('README clarity sections')
|
|
264
266
|
|
|
267
|
+
for (const cmd of ['/brief', '/discover', '/plan', '/ship', '/got', '/close', '/debrief', '/prep', '/quiet', '/agreed', '/status']) {
|
|
268
|
+
if (!readme.includes(cmd)) fail(`README must document slash command ${cmd}`)
|
|
269
|
+
}
|
|
270
|
+
ok('README slash commands documented')
|
|
271
|
+
|
|
272
|
+
// Front-door map is the embed left-to-right (LAND → CLOSE), not a pile of situations.
|
|
273
|
+
if (!readme.slice(0, 4000).includes('LAND') || !readme.slice(0, 4000).includes('/discover')) {
|
|
274
|
+
fail('README must include the LAND→CLOSE command-map diagram near the top')
|
|
275
|
+
} else ok('README command-map diagram')
|
|
276
|
+
|
|
265
277
|
if (readme.includes('your-client-repo')) {
|
|
266
278
|
fail('README must not instruct install in customer repo (your-client-repo)')
|
|
267
279
|
} else ok('README no customer-repo install')
|
|
@@ -286,6 +298,33 @@ if (!read('skills/fde/SKILL.md').includes('npx --yes fdeops')) {
|
|
|
286
298
|
fail('skills/fde/SKILL.md must fall back to npx --yes fdeops when the CLI is not installed')
|
|
287
299
|
} else ok('SKILL.md npx CLI fallback')
|
|
288
300
|
|
|
301
|
+
// The frontmatter description is the only text every host reads before deciding
|
|
302
|
+
// to load the skill. If it triggers on "@fde" alone, an FDE who just talks about
|
|
303
|
+
// their client gets no memory - so it must carry natural-intent triggers in the
|
|
304
|
+
// "Use when …" convention, and @fde must be one of several, never the gate.
|
|
305
|
+
{
|
|
306
|
+
const fm = /^---\n([\s\S]*?)\n---/.exec(read('skills/fde/SKILL.md'))
|
|
307
|
+
const desc = fm ? (/^description:[^\S\n]*(.*)$/m.exec(fm[1]) || [])[1] || '' : ''
|
|
308
|
+
const triggers = desc.match(/Use when/g) || []
|
|
309
|
+
if (triggers.length < 4) {
|
|
310
|
+
fail(`SKILL.md description needs several "Use when …" triggers so it fires on intent (found ${triggers.length})`)
|
|
311
|
+
} else if (/Use when the human says @fde or/.test(desc)) {
|
|
312
|
+
fail('SKILL.md description must not gate on @fde - name the client-work intents first')
|
|
313
|
+
} else ok('SKILL.md description triggers on intent')
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Six invented words (fieldbook, terrain, reality, trust signal, receipts,
|
|
317
|
+
// vault) carry the method. They were never defined in one place, so a stranger
|
|
318
|
+
// met them scattered through the docs and guessed.
|
|
319
|
+
{
|
|
320
|
+
const gloss = /Words used here[\s\S]{0,1200}/.exec(readme)
|
|
321
|
+
const missing = ['fieldbook', 'reality', 'terrain', 'trust signal', 'receipts', 'vault']
|
|
322
|
+
.filter(w => !gloss || !new RegExp(w, 'i').test(gloss[0]))
|
|
323
|
+
if (!gloss) fail('README must define its invented words once (a "Words used here" line)')
|
|
324
|
+
else if (missing.length) fail(`README glossary is missing: ${missing.join(', ')}`)
|
|
325
|
+
else ok('README defines its own vocabulary')
|
|
326
|
+
}
|
|
327
|
+
|
|
289
328
|
if (!fs.existsSync(path.join(root, 'docs', 'USAGE.md'))) {
|
|
290
329
|
fail('docs/USAGE.md missing')
|
|
291
330
|
} else ok('docs/USAGE.md')
|
|
@@ -508,6 +547,16 @@ const plugin = JSON.parse(read('.claude-plugin/plugin.json'))
|
|
|
508
547
|
if (pkg.version !== plugin.version) {
|
|
509
548
|
fail(`version mismatch package.json ${pkg.version} vs plugin ${plugin.version}`)
|
|
510
549
|
} else ok('plugin version aligned')
|
|
550
|
+
if (plugin.commands !== './.claude/commands' || plugin.skills !== './skills') {
|
|
551
|
+
fail('.claude-plugin/plugin.json must declare skills and commands')
|
|
552
|
+
} else {
|
|
553
|
+
for (const cmd of ['brief', 'discover', 'plan', 'ship', 'got', 'close', 'debrief', 'prep', 'quiet', 'agreed', 'status']) {
|
|
554
|
+
const rel = `.claude/commands/${cmd}.md`
|
|
555
|
+
if (!fs.existsSync(path.join(root, rel))) fail(`${rel} missing`)
|
|
556
|
+
else if (!read(rel).includes('@fde')) fail(`${rel} must load @fde`)
|
|
557
|
+
}
|
|
558
|
+
ok('slash commands load @fde')
|
|
559
|
+
}
|
|
511
560
|
|
|
512
561
|
if (!fs.existsSync(path.join(root, 'mcp', 'fdeops-ingest', 'server.js'))) {
|
|
513
562
|
fail('mcp/fdeops-ingest/server.js missing (ingest MCP sink)')
|
package/bin/fde.js
CHANGED
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
* fde debrief --smart propose routing from messy notes; --apply commits it
|
|
19
19
|
* fde prep [label] grounded walk-in brief from existing .fde/ only
|
|
20
20
|
* fde doctor deterministic memory lint (stale signals, gaps)
|
|
21
|
-
* fde
|
|
21
|
+
* fde tidy [--apply] propose safe consolidations; apply only with --apply (was: garden)
|
|
22
22
|
* fde ingest … stage → propose → apply pull sink (.inbox/; never auto-writes .fde/)
|
|
23
23
|
* fde owner [set …] who keeps this engagement record
|
|
24
24
|
* fde receipts <term> "what did we agree?" - search memory with dates
|
|
25
25
|
* fde capture session-end snapshot → context.md (hooks use this)
|
|
26
26
|
* fde preserve pre-compaction context snapshot (hook-internal; hooks use this)
|
|
27
|
-
* fde status [--all]
|
|
27
|
+
* fde status [--all] value ledger first, then trust (pass --all for portfolio)
|
|
28
28
|
* fde dashboard [--all] current engagement fieldbook (default) or all (--all)
|
|
29
29
|
* fde vault derived Obsidian vault of the fieldbook (disposable; --redacted)
|
|
30
30
|
*/
|
|
@@ -738,11 +738,20 @@ function appendLogEntry(eng, type, entry, opts = {}) {
|
|
|
738
738
|
// degrading to "nothing found" rather than guessing when the shape does not
|
|
739
739
|
// match. Never fabricate a number, a name, or a signal that is not in the text.
|
|
740
740
|
|
|
741
|
+
const PHASES = ['land', 'discover', 'plan', 'ship', 'prove', 'close']
|
|
742
|
+
const PHASE_ALIASES = { build: 'ship' } // legacy SDLC name; public map is ship
|
|
741
743
|
const PHASE_LABELS = {
|
|
742
|
-
land: '
|
|
743
|
-
|
|
744
|
+
land: 'Land', discover: 'Discover', plan: 'Plan',
|
|
745
|
+
ship: 'Ship', prove: 'Prove', close: 'Close',
|
|
746
|
+
}
|
|
747
|
+
function canonicalPhase(phase) {
|
|
748
|
+
const p = String(phase).toLowerCase()
|
|
749
|
+
return PHASE_ALIASES[p] || p
|
|
750
|
+
}
|
|
751
|
+
function phaseLabel(phase) {
|
|
752
|
+
const p = canonicalPhase(phase)
|
|
753
|
+
return PHASE_LABELS[p] || phase
|
|
744
754
|
}
|
|
745
|
-
function phaseLabel(phase) { return PHASE_LABELS[String(phase).toLowerCase()] || phase }
|
|
746
755
|
|
|
747
756
|
// First real (non-blank, non-heading) line of a prose file, bold-label prefix
|
|
748
757
|
// stripped ("**Confirmed:** text..." -> "text...") - same spirit as the
|
|
@@ -1223,7 +1232,7 @@ function cmdResume(args) {
|
|
|
1223
1232
|
.filter(d => !d.startsWith('.') && fs.existsSync(path.join(ENGAGEMENTS_ROOT, d, '.fde')))
|
|
1224
1233
|
.join(', ') || '(none yet)'
|
|
1225
1234
|
: '(none yet)'
|
|
1226
|
-
console.log(`NO ENGAGEMENT for this workspace.\nexisting: ${list}\
|
|
1235
|
+
console.log(`NO ENGAGEMENT for this workspace.\nexisting: ${list}\nAsk the human the client name (one question), then run: fde resume --init <client-name>\nDo not tell them to type that command.`)
|
|
1227
1236
|
process.exit(2)
|
|
1228
1237
|
}
|
|
1229
1238
|
// Monday-morning: triage + proactive hygiene (silent when clean), then memory.
|
|
@@ -1312,11 +1321,11 @@ function cmdLog(args) {
|
|
|
1312
1321
|
const eng = resolveEngagement({ forWrite: true })
|
|
1313
1322
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
1314
1323
|
|
|
1315
|
-
// fde log phase <land|discover|plan|
|
|
1324
|
+
// fde log phase <land|discover|plan|ship|prove|close> - advances portfolio phase
|
|
1316
1325
|
if (type === 'phase') {
|
|
1317
|
-
const phase = (text || '').toLowerCase().trim()
|
|
1318
|
-
if (!
|
|
1319
|
-
console.error(
|
|
1326
|
+
const phase = canonicalPhase((text || '').toLowerCase().trim())
|
|
1327
|
+
if (!PHASES.includes(phase)) {
|
|
1328
|
+
console.error(`usage: fde log phase <${PHASES.join('|')}>`)
|
|
1320
1329
|
process.exit(1)
|
|
1321
1330
|
}
|
|
1322
1331
|
const hash = setContextPhase(eng, phase)
|
|
@@ -1324,7 +1333,7 @@ function cmdLog(args) {
|
|
|
1324
1333
|
return
|
|
1325
1334
|
}
|
|
1326
1335
|
|
|
1327
|
-
if (!LOG_FILES[type] || !text) { console.error(
|
|
1336
|
+
if (!LOG_FILES[type] || !text) { console.error(`usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green] [--force]\n fde log phase <${PHASES.join('|')}>\n fde log --undo`); process.exit(1) }
|
|
1328
1337
|
if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
|
|
1329
1338
|
const hit = findSecretHit(text)
|
|
1330
1339
|
if (hit && !force) { refuseSecret('log text', hit); process.exit(1) }
|
|
@@ -2091,7 +2100,7 @@ function collectDoctorIssues(eng) {
|
|
|
2091
2100
|
|
|
2092
2101
|
if (s.phase === '?' || s.phase === 'unset') {
|
|
2093
2102
|
if (hasDatedWork) {
|
|
2094
|
-
issues.push(
|
|
2103
|
+
issues.push(`phase is unset but dated work exists - run: fde log phase <${PHASES.join('|')}>`)
|
|
2095
2104
|
}
|
|
2096
2105
|
}
|
|
2097
2106
|
if (s.stale) issues.push(`trust signal is STALE (${s.signalAge}d) - reconfirm with fde log contact ... --signal`)
|
|
@@ -2161,7 +2170,7 @@ function collectDoctorIssues(eng) {
|
|
|
2161
2170
|
}
|
|
2162
2171
|
// Failure-path (exception-led operating map): required once past discover.
|
|
2163
2172
|
// Land seeds; discover fills; plan+ without a real break→owner row is wallpaper.
|
|
2164
|
-
if (/^(plan|
|
|
2173
|
+
if (/^(plan|ship|prove|close)$/.test(s.phase) && !hasOperatingMapContent(eng)) {
|
|
2165
2174
|
issues.push(
|
|
2166
2175
|
`phase is ${s.phase} with empty operating map - fill terrain.md ## Operating map (exception-led): break → who notices → workaround → evidence`
|
|
2167
2176
|
)
|
|
@@ -2295,21 +2304,59 @@ function hasValueBucket(eng) {
|
|
|
2295
2304
|
const PENDING_CELL_RE =
|
|
2296
2305
|
/^(?:pending|tbd|to ?be ?(?:measured|confirmed|determined)|n\s*\/\s*a|na|none|unknown|not measured|\?+|\.{2,}|…|-+|—+|–+)(?:[^\w].*)?$/i
|
|
2297
2306
|
|
|
2298
|
-
function
|
|
2307
|
+
function parseValueLedger(eng) {
|
|
2299
2308
|
const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger') || '')
|
|
2300
2309
|
const table = parseMdTable(ledger)
|
|
2301
|
-
if (!table) return {
|
|
2302
|
-
const
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2310
|
+
if (!table) return { rows: [], columnMissing: false }
|
|
2311
|
+
const idx = {
|
|
2312
|
+
slice: colIndex(table.headers, /slice/i),
|
|
2313
|
+
promised: colIndex(table.headers, /promised/i),
|
|
2314
|
+
measured: colIndex(table.headers, /measured/i),
|
|
2315
|
+
accepted: colIndex(table.headers, /accept/i),
|
|
2316
|
+
}
|
|
2317
|
+
const cell = (row, i) => (i === -1 ? '' : String(row[i] || '').trim())
|
|
2318
|
+
const rows = []
|
|
2306
2319
|
for (const row of table.rows) {
|
|
2307
|
-
const
|
|
2308
|
-
|
|
2309
|
-
const
|
|
2310
|
-
|
|
2320
|
+
const slice = cell(row, idx.slice)
|
|
2321
|
+
const promised = cell(row, idx.promised)
|
|
2322
|
+
const measured = cell(row, idx.measured)
|
|
2323
|
+
const accepted = cell(row, idx.accepted)
|
|
2324
|
+
if (!slice && !promised && !measured) continue
|
|
2325
|
+
const measuredPending = !measured || PENDING_CELL_RE.test(measured)
|
|
2326
|
+
const acceptedPending = idx.accepted === -1 || !accepted || PENDING_CELL_RE.test(accepted)
|
|
2327
|
+
let state = 'unmeasured'
|
|
2328
|
+
if (!measuredPending && acceptedPending) state = 'claimed'
|
|
2329
|
+
else if (!measuredPending) state = 'accepted'
|
|
2330
|
+
rows.push({ slice, promised, measured, accepted, state })
|
|
2311
2331
|
}
|
|
2312
|
-
return {
|
|
2332
|
+
return { rows, columnMissing: idx.accepted === -1 }
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
function claimedValueRows(eng) {
|
|
2336
|
+
const { rows, columnMissing } = parseValueLedger(eng)
|
|
2337
|
+
return { claimed: rows.filter(r => r.state === 'claimed').length, columnMissing }
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
function formatValueLedgerLine(r) {
|
|
2341
|
+
const name = r.slice || 'value'
|
|
2342
|
+
let body = r.promised || ''
|
|
2343
|
+
if (r.state !== 'unmeasured' && r.measured) {
|
|
2344
|
+
if (!body) body = r.measured
|
|
2345
|
+
else if (!body.includes(r.measured)) body = `${body} → ${r.measured}`
|
|
2346
|
+
}
|
|
2347
|
+
const head = body ? `${name}: ${body}` : name
|
|
2348
|
+
if (r.state === 'accepted') return `${head} · accepted by ${r.accepted}`
|
|
2349
|
+
if (r.state === 'claimed') return `${head} · claimed, not yet accepted`
|
|
2350
|
+
return `${head} · not yet measured`
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
function valueLedgerStatusLines(eng, opts = {}) {
|
|
2354
|
+
const { rows } = parseValueLedger(eng)
|
|
2355
|
+
if (!rows.length) return [' value: none yet']
|
|
2356
|
+
const cap = opts.compact ? 1 : 8
|
|
2357
|
+
const lines = rows.slice(0, cap).map(r => ` ${formatValueLedgerLine(r)}`)
|
|
2358
|
+
if (rows.length > cap) lines.push(` … ${rows.length - cap} more in delivery.md`)
|
|
2359
|
+
return lines
|
|
2313
2360
|
}
|
|
2314
2361
|
|
|
2315
2362
|
// AI in scope for ship/close hygiene — delivery/decisions/trust evidence only.
|
|
@@ -2488,18 +2535,18 @@ function cmdGarden(args) {
|
|
|
2488
2535
|
// Gardener contract (from Rowboat note_curation): no new facts, no deleted substance,
|
|
2489
2536
|
// reversible via git when healthy, confirm before apply. Mechanical only - no LLM rewrite.
|
|
2490
2537
|
if (gitHealth.ok) {
|
|
2491
|
-
console.log('
|
|
2538
|
+
console.log('TIDY (contract: no new facts · no deleted substance · reversible via memory git)')
|
|
2492
2539
|
} else if (gitHealth.reason === 'broken') {
|
|
2493
|
-
console.log('
|
|
2540
|
+
console.log('TIDY (contract: no new facts · no deleted substance · ⚠ memory git BROKEN — NOT reversible until ledger is repaired)')
|
|
2494
2541
|
} else {
|
|
2495
|
-
console.log('
|
|
2542
|
+
console.log('TIDY (contract: no new facts · no deleted substance · ⚠ memory not git-versioned — NOT reversible)')
|
|
2496
2543
|
}
|
|
2497
2544
|
console.log(resumeTriage(eng))
|
|
2498
2545
|
if (!gitHealth.ok) {
|
|
2499
2546
|
console.log(
|
|
2500
2547
|
gitHealth.reason === 'broken'
|
|
2501
|
-
? '\n⚠ ledger is UNVERSIONED (corrupt .git). Repair before trusting
|
|
2502
|
-
: '\n⚠ no memory git —
|
|
2548
|
+
? '\n⚠ ledger is UNVERSIONED (corrupt .git). Repair before trusting tidy apply: mv .fde/.git .fde/.git.broken && run any fde write to re-init.'
|
|
2549
|
+
: '\n⚠ no memory git — tidy apply cannot create a reversible commit until the ledger exists.'
|
|
2503
2550
|
)
|
|
2504
2551
|
}
|
|
2505
2552
|
const proposals = []
|
|
@@ -2539,18 +2586,18 @@ function cmdGarden(args) {
|
|
|
2539
2586
|
})
|
|
2540
2587
|
}
|
|
2541
2588
|
if (!proposals.length) {
|
|
2542
|
-
console.log('\nNothing to
|
|
2589
|
+
console.log('\nNothing to tidy.')
|
|
2543
2590
|
return
|
|
2544
2591
|
}
|
|
2545
2592
|
console.log(`\n${proposals.length} proposal(s):`)
|
|
2546
2593
|
proposals.forEach((p, i) => console.log(` ${i + 1}. [${p.kind}] ${p.text}`))
|
|
2547
2594
|
if (!apply) {
|
|
2548
|
-
console.log('\nApply mechanical items only: fde
|
|
2595
|
+
console.log('\nApply mechanical items only: fde tidy --apply')
|
|
2549
2596
|
console.log('Manual items stay yours. Every apply commits to memory git when the ledger is healthy.')
|
|
2550
2597
|
return
|
|
2551
2598
|
}
|
|
2552
2599
|
if (!gitHealth.ok && gitHealth.reason === 'broken') {
|
|
2553
|
-
console.error('refusing
|
|
2600
|
+
console.error('refusing tidy --apply while memory git is broken - repair the ledger first')
|
|
2554
2601
|
process.exit(1)
|
|
2555
2602
|
}
|
|
2556
2603
|
ensureMemoryGit(eng)
|
|
@@ -2603,9 +2650,9 @@ function cmdGarden(args) {
|
|
|
2603
2650
|
touched.add('context-archive.md')
|
|
2604
2651
|
console.log(`applied: archived ${p.sessionBlocks.length} old session-end blocks → context-archive.md`)
|
|
2605
2652
|
}
|
|
2606
|
-
const hash = commitMemory(eng, '
|
|
2653
|
+
const hash = commitMemory(eng, 'tidy', { files: [...touched] })
|
|
2607
2654
|
if (!applied) console.log('no mechanical proposals applied (manual items remain)')
|
|
2608
|
-
else console.log(`
|
|
2655
|
+
else console.log(`tidy done${hash ? ` @${hash}` : ''}`)
|
|
2609
2656
|
}
|
|
2610
2657
|
|
|
2611
2658
|
// Keep the first open-risk bullet per fingerprint; move later echoes under ## Retired.
|
|
@@ -2647,7 +2694,7 @@ function applyRiskDedupe(eng, clusters) {
|
|
|
2647
2694
|
const stamp = new Date().toISOString().slice(0, 10)
|
|
2648
2695
|
const block = retiredLines.map(l => {
|
|
2649
2696
|
const body = l.trim().replace(/^-\s*/, '')
|
|
2650
|
-
return `- [${stamp}] (
|
|
2697
|
+
return `- [${stamp}] (tidy dedupe) ${body}`
|
|
2651
2698
|
}).join('\n')
|
|
2652
2699
|
out = appendUnderSection(out, 'Retired', block)
|
|
2653
2700
|
withFileLock(p, () => { atomicWriteFile(p, out.endsWith('\n') ? out : out + '\n') })
|
|
@@ -2669,7 +2716,7 @@ function cmdStatus(args) {
|
|
|
2669
2716
|
if (!fs.existsSync(eng)) continue
|
|
2670
2717
|
const s = computeSignals(eng)
|
|
2671
2718
|
const note = [s.memoryWarn, (s.dirtyFiles && s.dirtyFiles.length) ? `dirty:${s.dirtyFiles.length}` : '', s.reason || s.topRisk].filter(Boolean).join(' · ').slice(0, 70)
|
|
2672
|
-
rows.push({ name: d, phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: note, memoryWarn: s.memoryWarn, dirtyFiles: s.dirtyFiles })
|
|
2719
|
+
rows.push({ name: d, phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: note, memoryWarn: s.memoryWarn, dirtyFiles: s.dirtyFiles, valueLines: valueLedgerStatusLines(eng, { compact: true }) })
|
|
2673
2720
|
}
|
|
2674
2721
|
} else {
|
|
2675
2722
|
const eng = resolveEngagement()
|
|
@@ -2679,13 +2726,14 @@ function cmdStatus(args) {
|
|
|
2679
2726
|
}
|
|
2680
2727
|
const s = computeSignals(eng)
|
|
2681
2728
|
const note = [s.memoryWarn, (s.dirtyFiles && s.dirtyFiles.length) ? `dirty:${s.dirtyFiles.length}` : '', s.reason || s.topRisk].filter(Boolean).join(' · ').slice(0, 70)
|
|
2682
|
-
rows.push({ name: engagementSlugFromPath(eng), phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: note, memoryWarn: s.memoryWarn, dirtyFiles: s.dirtyFiles })
|
|
2729
|
+
rows.push({ name: engagementSlugFromPath(eng), phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: note, memoryWarn: s.memoryWarn, dirtyFiles: s.dirtyFiles, valueLines: valueLedgerStatusLines(eng) })
|
|
2683
2730
|
}
|
|
2684
2731
|
if (!rows.length) { console.log('no engagements yet'); return }
|
|
2685
2732
|
const order = { RED: 0, amber: 1, green: 2 }
|
|
2686
2733
|
rows.sort((a, b) => order[a.trust] - order[b.trust])
|
|
2687
|
-
console.log((all ? 'FDE PORTFOLIO' : 'FDE STATUS') + ' -
|
|
2734
|
+
console.log((all ? 'FDE PORTFOLIO' : 'FDE STATUS') + ' - value first, then trust\n')
|
|
2688
2735
|
for (const r of rows) {
|
|
2736
|
+
for (const line of r.valueLines) console.log(line)
|
|
2689
2737
|
// "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
|
|
2690
2738
|
const label = r.trust + (r.stale ? '?' : '')
|
|
2691
2739
|
const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
|
|
@@ -3139,7 +3187,7 @@ function printUsage() {
|
|
|
3139
3187
|
fde resume --bind show what this workspace is bound to, and what resolves
|
|
3140
3188
|
fde triage TRIAGE block only (hooks / Cursor session entry)
|
|
3141
3189
|
fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green; --force to allow secret-like text)
|
|
3142
|
-
fde log phase <phase> set engagement phase (land|discover|plan|
|
|
3190
|
+
fde log phase <phase> set engagement phase (land|discover|plan|ship|prove|close)
|
|
3143
3191
|
fde log --undo remove the last CLI log/debrief entry from memory
|
|
3144
3192
|
fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
|
|
3145
3193
|
fde debrief --smart heuristic propose (prefix + light keywords); agent routes, CLI gates → --apply
|
|
@@ -3150,14 +3198,13 @@ function printUsage() {
|
|
|
3150
3198
|
fde prep [label] grounded walk-in brief from existing .fde/ only
|
|
3151
3199
|
fde doctor lint engagement memory (stale signals, gaps)
|
|
3152
3200
|
fde redact <term> preview/remove lines containing a buried term (pass --apply to commit)
|
|
3153
|
-
fde
|
|
3201
|
+
fde tidy [--apply] propose safe consolidations (contract: no new facts; git-reversible)
|
|
3154
3202
|
fde owner [set email] who keeps this engagement record
|
|
3155
3203
|
fde receipts <term> "what did we agree?" with dates
|
|
3156
|
-
fde
|
|
3157
|
-
fde preserve pre-compaction context snapshot (hook-internal; hooks use this)
|
|
3158
|
-
fde status [--all] current engagement status (pass --all for full portfolio)
|
|
3204
|
+
fde status [--all] value ledger, then trust (pass --all for full portfolio)
|
|
3159
3205
|
fde dashboard [--all] current engagement fieldbook (pass --all for every client)
|
|
3160
3206
|
fde vault derived Obsidian vault of every engagement (--current for one, --redacted for a shared screen, --out <dir>)
|
|
3207
|
+
hooks call these; you do not: capture (session-end snapshot), preserve (pre-compaction snapshot)
|
|
3161
3208
|
env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
|
|
3162
3209
|
writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only
|
|
3163
3210
|
.fde/ is git-versioned locally for tamper-evident receipts (no remote, no telemetry)
|
|
@@ -3176,6 +3223,8 @@ switch (cmd) {
|
|
|
3176
3223
|
case 'prep': cmdPrep(args); break
|
|
3177
3224
|
case 'doctor': cmdDoctor(); break
|
|
3178
3225
|
case 'redact': cmdRedact(args); break
|
|
3226
|
+
// `garden` was the name through 3.11.x; it keeps working.
|
|
3227
|
+
case 'tidy':
|
|
3179
3228
|
case 'garden': cmdGarden(args); break
|
|
3180
3229
|
case 'owner': cmdOwner(args); break
|
|
3181
3230
|
case 'receipts': cmdReceipts(args); break
|
package/bin/install.js
CHANGED
|
@@ -355,7 +355,7 @@ function cmdInstall(opts = {}) {
|
|
|
355
355
|
// through to the CLI (fde.js reads process.argv itself, so require() is enough).
|
|
356
356
|
const FDE_SUBCOMMANDS = [
|
|
357
357
|
'demo', 'scan', 'resume', 'triage', 'log', 'debrief', 'ingest', 'prep', 'doctor', 'redact',
|
|
358
|
-
'garden', 'owner', 'receipts', 'capture', 'preserve', 'status', 'dashboard', 'vault', 'help',
|
|
358
|
+
'tidy', 'garden', 'owner', 'receipts', 'capture', 'preserve', 'status', 'dashboard', 'vault', 'help',
|
|
359
359
|
]
|
|
360
360
|
|
|
361
361
|
const INSTALL_SUBCOMMANDS = ['init', 'adapters', 'install']
|
package/bin/lib/trust.js
CHANGED
|
@@ -64,8 +64,9 @@ function createTrustApi(deps) {
|
|
|
64
64
|
if (!m) return '?'
|
|
65
65
|
const raw = m[1].replace(/\*/g, '').trim()
|
|
66
66
|
if (!raw || /\|/.test(raw) || /^unset$/i.test(raw) || /^[\[(]/.test(raw)) return '?'
|
|
67
|
-
const one = raw.toLowerCase().match(/^(land|discover|plan|build|ship|close)\b/)
|
|
68
|
-
|
|
67
|
+
const one = raw.toLowerCase().match(/^(land|discover|plan|build|ship|prove|close)\b/)
|
|
68
|
+
if (!one) return '?'
|
|
69
|
+
return one[1] === 'build' ? 'ship' : one[1]
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
function countOpenRisks(eng) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fdeops",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.15.0",
|
|
4
|
+
"description": "Forward deployed engineering skills for AI coding agents. Your agent forgets the client every morning - the sponsor, the promise, who signed off. FDEOps keeps that as dated markdown on your laptop: one @fde skill, a deterministic local CLI, and hooks that make it automatic. Claude Code plugin and any agent that loads skills.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fdeops": "bin/install.js",
|
|
7
7
|
"fde": "bin/fde.js"
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"ai-agent",
|
|
31
31
|
"skills",
|
|
32
32
|
"plugin",
|
|
33
|
+
"engagement-memory",
|
|
34
|
+
"agent-memory",
|
|
35
|
+
"client-context",
|
|
33
36
|
"fde",
|
|
34
37
|
"forward-deployed",
|
|
35
38
|
"forward-deployed-engineer",
|
package/plugin.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "fdeops",
|
|
4
|
-
"version": "3.
|
|
5
|
-
"description": "
|
|
4
|
+
"version": "3.15.0",
|
|
5
|
+
"description": "Forward deployed engineering skills for AI coding agents: per-client memory in local .fde/ files, one @fde skill. Local-only, no network.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Subash Natarajan",
|
|
8
8
|
"url": "https://github.com/suboss87"
|
|
9
9
|
},
|
|
10
|
-
"homepage": "https://
|
|
10
|
+
"homepage": "https://github.com/suboss87/fdeops",
|
|
11
11
|
"repository": "https://github.com/suboss87/FDEOps",
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"keywords": [
|