fdeops 3.22.0 → 3.22.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 +174 -36
- package/bin/install.js +15 -9
- package/bin/lib/render.js +6 -4
- package/bin/lib/trust.js +8 -2
- package/mcp/fdeops-ingest/package.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
package/bin/fde.js
CHANGED
|
@@ -45,6 +45,9 @@ const REGISTRY = path.join(ENGAGEMENTS_ROOT, '.registry')
|
|
|
45
45
|
const DEBRIEF_MAX_BYTES = 256 * 1024
|
|
46
46
|
const CODE_EXT = ['.js', '.ts', '.tsx', '.jsx', '.py', '.java', '.go', '.rb', '.cs', '.php']
|
|
47
47
|
const CONF_EXT = CODE_EXT.concat(['.env', '.yaml', '.yml', '.json'])
|
|
48
|
+
// Bare "inference" is banned here: TypeScript codebases are full of "type
|
|
49
|
+
// inference" comments and the false positives poison the day-1 questions.
|
|
50
|
+
const AI_CODE_RE = /openai|anthropic|\bllm\b|gpt-|claude|embedding|vector store|model inference|inference (?:api|endpoint|server|engine)/i
|
|
48
51
|
// one routing table for structured appends - cmdLog and cmdDebrief share it
|
|
49
52
|
const LOG_FILES = { decision: 'decisions.md', risk: 'risks.md', delivery: 'delivery.md', contact: 'stakeholders.md' }
|
|
50
53
|
|
|
@@ -633,13 +636,10 @@ const {
|
|
|
633
636
|
// opts.lastNonEmpty: when duplicate headings exist (common skill trap: template
|
|
634
637
|
// "## Next action" left empty, agent appends a second), prefer the last filled
|
|
635
638
|
// body so triage/resume do not silently report "(none set)".
|
|
636
|
-
function
|
|
637
|
-
const preferLast = opts && opts.lastNonEmpty
|
|
639
|
+
function sectionBodies(md, heading) {
|
|
638
640
|
const lines = String(md || '').split('\n')
|
|
639
641
|
const re = new RegExp('^#{1,6}\\s+' + heading + '\\b', 'i')
|
|
640
|
-
|
|
641
|
-
let lastFilled = ''
|
|
642
|
-
let seen = false
|
|
642
|
+
const out = []
|
|
643
643
|
for (let i = 0; i < lines.length; i++) {
|
|
644
644
|
if (!re.test(lines[i].trim())) continue
|
|
645
645
|
const body = []
|
|
@@ -647,12 +647,16 @@ function sectionBody(md, heading, opts) {
|
|
|
647
647
|
if (/^#{1,6}\s/.test(lines[j].trim())) break
|
|
648
648
|
body.push(lines[j])
|
|
649
649
|
}
|
|
650
|
-
|
|
651
|
-
if (!seen) { first = text; seen = true }
|
|
652
|
-
if (text) lastFilled = text
|
|
650
|
+
out.push(body.join('\n').trim())
|
|
653
651
|
}
|
|
654
|
-
|
|
655
|
-
|
|
652
|
+
return out
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function sectionBody(md, heading, opts) {
|
|
656
|
+
const bodies = sectionBodies(md, heading)
|
|
657
|
+
if (!bodies.length) return ''
|
|
658
|
+
if (!(opts && opts.lastNonEmpty)) return bodies[0]
|
|
659
|
+
return [...bodies].reverse().find(Boolean) || bodies[0]
|
|
656
660
|
}
|
|
657
661
|
|
|
658
662
|
function countSections(md, heading) {
|
|
@@ -793,6 +797,19 @@ function parseReality(md, maxLen) {
|
|
|
793
797
|
return { line: '', missing: '' }
|
|
794
798
|
}
|
|
795
799
|
|
|
800
|
+
// Same columns as templates/.fde/delivery.md - a row without them above it is
|
|
801
|
+
// read as the header line, so the value it carries disappears.
|
|
802
|
+
const VALUE_LEDGER_HEADER = '| Date | Slice | Bucket | Promised | Measured | Accepted by | Evidence | Rollback |'
|
|
803
|
+
const VALUE_LEDGER_RULE = '|------|-------|--------|----------|----------|-------------|----------|----------|'
|
|
804
|
+
|
|
805
|
+
// A header and a legend are text, not value: a template copy of the ledger has
|
|
806
|
+
// zero rows, and must not shadow filled work above it (or receive a row).
|
|
807
|
+
function valueLedgerRowCount(body) {
|
|
808
|
+
const t = parseMdTable(stripTemplateNoise(String(body || '')))
|
|
809
|
+
if (!t) return 0
|
|
810
|
+
return t.rows.filter(r => r.some(c => String(c || '').trim())).length
|
|
811
|
+
}
|
|
812
|
+
|
|
796
813
|
function appendValueLedgerRow(eng, cells) {
|
|
797
814
|
ensureMemoryGit(eng)
|
|
798
815
|
const p = path.join(eng, 'delivery.md')
|
|
@@ -802,17 +819,37 @@ function appendValueLedgerRow(eng, cells) {
|
|
|
802
819
|
const cols = []
|
|
803
820
|
for (let i = 0; i < 7; i++) cols.push((cells[i] || '').replace(/\|/g, '\\|').trim() || ' ')
|
|
804
821
|
const row = `| ${date} | ${cols.join(' | ')} |`
|
|
822
|
+
// Write into the same section the readers take: the last filled ## Value ledger.
|
|
823
|
+
// A row appended to the empty template heading above a filled one is a row no
|
|
824
|
+
// gate can see.
|
|
805
825
|
const lines = md.split('\n')
|
|
806
|
-
|
|
807
|
-
let
|
|
826
|
+
const sections = []
|
|
827
|
+
let cur = null
|
|
808
828
|
for (let i = 0; i < lines.length; i++) {
|
|
809
|
-
if (/^##\s+Value ledger\b/i.test(lines[i])) {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
lines
|
|
829
|
+
if (/^##\s+Value ledger\b/i.test(lines[i])) {
|
|
830
|
+
cur = { heading: i, lastTable: -1, filled: false, body: [] }
|
|
831
|
+
sections.push(cur)
|
|
832
|
+
continue
|
|
833
|
+
}
|
|
834
|
+
if (!cur) continue
|
|
835
|
+
if (/^##\s+/.test(lines[i])) { cur = null; continue }
|
|
836
|
+
cur.body.push(lines[i])
|
|
837
|
+
if (lines[i].trim()) cur.filled = true
|
|
838
|
+
if (/^\|/.test(lines[i].trim())) cur.lastTable = i
|
|
839
|
+
}
|
|
840
|
+
// Same choice parseValueLedger makes: the last section carrying rows, else the
|
|
841
|
+
// last with a body. A row written anywhere else is a row no gate can see.
|
|
842
|
+
const withRows = [...sections].reverse().find(s => valueLedgerRowCount(s.body.join('\n')))
|
|
843
|
+
const target = withRows || [...sections].reverse().find(s => s.filled) || sections[sections.length - 1]
|
|
844
|
+
if (!target) {
|
|
845
|
+
md = appendUnderSection(md, 'Value ledger', `${VALUE_LEDGER_HEADER}\n${VALUE_LEDGER_RULE}\n${row}`)
|
|
846
|
+
} else if (target.lastTable !== -1) {
|
|
847
|
+
lines.splice(target.lastTable + 1, 0, row)
|
|
848
|
+
md = lines.join('\n')
|
|
849
|
+
} else {
|
|
850
|
+
// No table under the chosen heading: a lone row would be read as the header
|
|
851
|
+
// line and the value would vanish. Lay the canonical table first.
|
|
852
|
+
lines.splice(target.heading + 1, 0, '', VALUE_LEDGER_HEADER, VALUE_LEDGER_RULE, row)
|
|
816
853
|
md = lines.join('\n')
|
|
817
854
|
}
|
|
818
855
|
withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
|
|
@@ -910,6 +947,7 @@ const {
|
|
|
910
947
|
countOpenRisks,
|
|
911
948
|
} = createTrustApi({
|
|
912
949
|
fs, path, readClean, readEng, parseMdTable, sectionBody, SIGNAL_LEDGER, memoryDirtyManual,
|
|
950
|
+
stripTemplateNoise, stripLegendLines,
|
|
913
951
|
})
|
|
914
952
|
|
|
915
953
|
// Stakeholders: columns are matched by header wording, not position - real
|
|
@@ -1168,8 +1206,14 @@ function cmdScan() {
|
|
|
1168
1206
|
// NOTE: bare "inference" is banned from this regex - TypeScript codebases are
|
|
1169
1207
|
// full of "type inference" comments and the false positives poison the day-1
|
|
1170
1208
|
// questions. Model inference only, in explicit forms.
|
|
1171
|
-
const ai = grepFiles(codeFiles,
|
|
1209
|
+
const ai = grepFiles(codeFiles, AI_CODE_RE, 10)
|
|
1172
1210
|
ai.length ? ai.forEach(h => out.push(` ${h.file}:${h.line} ${h.text}`)) : out.push(' none found')
|
|
1211
|
+
// The eval gate reads the record, not the repo. A finding here that never
|
|
1212
|
+
// reaches .fde/ leaves ship/close green with no eval - so say the next move.
|
|
1213
|
+
if (ai.length) {
|
|
1214
|
+
out.push(' → the ship gate reads the record, not this scan. Put it there:')
|
|
1215
|
+
out.push(` fde log decision "AI in scope: ${path.basename(ai[0].file)} calls a model - eval receipt required before ship"`)
|
|
1216
|
+
}
|
|
1173
1217
|
|
|
1174
1218
|
// secrets (redacted)
|
|
1175
1219
|
out.push('\nPOSSIBLE HARDCODED SECRETS (values redacted):')
|
|
@@ -1323,8 +1367,11 @@ function cmdResume(args) {
|
|
|
1323
1367
|
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.`)
|
|
1324
1368
|
process.exit(2)
|
|
1325
1369
|
}
|
|
1326
|
-
// Monday-morning: triage + proactive hygiene (silent when clean),
|
|
1370
|
+
// Monday-morning: triage + proactive hygiene (silent when clean), the record
|
|
1371
|
+
// (sponsor / promise / decisions), then the session log.
|
|
1327
1372
|
printTriageBlock(eng)
|
|
1373
|
+
const digest = recordDigest(eng)
|
|
1374
|
+
if (digest.length) console.log('\n' + digest.join('\n'))
|
|
1328
1375
|
console.log(`\nENGAGEMENT: ${eng}\n`)
|
|
1329
1376
|
// readClean, not fs.readFileSync: this output is what an agent loads as
|
|
1330
1377
|
// context, so it goes through the same <private> redaction as the dashboard.
|
|
@@ -1449,6 +1496,14 @@ function cmdLog(args) {
|
|
|
1449
1496
|
appendLogEntry(eng, type, entry)
|
|
1450
1497
|
const hash = memoryHead(eng)
|
|
1451
1498
|
console.log(`logged → ${LOG_FILES[type]}${signal ? ` (signal:${signal})` : ''}${hash ? ` @${hash}` : ''}`)
|
|
1499
|
+
// A dated bullet is a note; the ship gate reads the ledger. Say what this did
|
|
1500
|
+
// NOT do, once, so promised→measured→accepted does not quietly stay unassembled.
|
|
1501
|
+
if (type === 'delivery' && !parseValueLedger(eng).rows.length) {
|
|
1502
|
+
console.log(' note: no value ledger row yet - "accepted by" is what a sponsor argues with. Add the row in delivery.md ## Value ledger (promised | measured | accepted by).')
|
|
1503
|
+
}
|
|
1504
|
+
if (type === 'contact' && !signal) {
|
|
1505
|
+
console.log(' note: no --signal, so trust is unchanged - prep and status show people who carry a signal.')
|
|
1506
|
+
}
|
|
1452
1507
|
}
|
|
1453
1508
|
|
|
1454
1509
|
function setContextPhase(eng, phase) {
|
|
@@ -1577,7 +1632,10 @@ function setSigner(eng, who) {
|
|
|
1577
1632
|
let md = readEng(eng, 'success.md')
|
|
1578
1633
|
if (!md) md = '# Success definition\n\n'
|
|
1579
1634
|
const norm = (s) => String(s).replace(/\s+/g, ' ').trim().toLowerCase()
|
|
1580
|
-
|
|
1635
|
+
// [^\S\n], not \s: \s crosses newlines, so an empty field captured the next
|
|
1636
|
+
// line - and setSigner then read a filled field and filed the name as "also
|
|
1637
|
+
// named" under whatever heading followed.
|
|
1638
|
+
const line = /^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m
|
|
1581
1639
|
const m = md.match(line)
|
|
1582
1640
|
if (m && !m[1].trim()) {
|
|
1583
1641
|
md = md.replace(line, `**Stakeholder who signs off:** ${who}`)
|
|
@@ -2167,8 +2225,10 @@ function cmdTriage() {
|
|
|
2167
2225
|
console.error('no engagement - run: fde resume --init <name>')
|
|
2168
2226
|
process.exit(2)
|
|
2169
2227
|
}
|
|
2170
|
-
// Session-start hooks call this - hygiene is proactive here (silent when clean)
|
|
2228
|
+
// Session-start hooks call this - hygiene is proactive here (silent when clean),
|
|
2229
|
+
// and the record digest travels with it so a fresh session knows who signs.
|
|
2171
2230
|
printTriageBlock(eng)
|
|
2231
|
+
for (const line of recordDigest(eng)) console.log(line)
|
|
2172
2232
|
const owner = readOwner(eng) || writeOwnerIfMissing(eng)
|
|
2173
2233
|
const head = memoryHead(eng)
|
|
2174
2234
|
if (owner || head) {
|
|
@@ -2397,6 +2457,13 @@ function collectDoctorIssues(eng) {
|
|
|
2397
2457
|
issues.push(
|
|
2398
2458
|
`phase is ${s.phase} with AI in scope but no eval receipt (evals.md Verdict or delivery Eval / Ship receipts) - required before green ship/close`
|
|
2399
2459
|
)
|
|
2460
|
+
} else if (!engagementTouchesAI(eng)) {
|
|
2461
|
+
const hit = workspaceAIHit(eng)
|
|
2462
|
+
if (hit) {
|
|
2463
|
+
issues.push(
|
|
2464
|
+
`the bound workspace calls a model (${hit.file}) but nothing in the record says AI is in scope - the eval gate is off; record it: fde log decision "AI in scope: …"`
|
|
2465
|
+
)
|
|
2466
|
+
}
|
|
2400
2467
|
}
|
|
2401
2468
|
issues.push(...silentCommitIssues(eng))
|
|
2402
2469
|
}
|
|
@@ -2414,6 +2481,14 @@ function collectDoctorIssues(eng) {
|
|
|
2414
2481
|
`phase is ${s.phase} with empty operating map - fill terrain.md ## Operating map (exception-led): break → who notices → workaround → evidence`
|
|
2415
2482
|
)
|
|
2416
2483
|
}
|
|
2484
|
+
// A second copy of a heading the gates read: they take the last filled one, so
|
|
2485
|
+
// the record is ambiguous rather than lost. Say so once, here.
|
|
2486
|
+
// Full heading names only: "Value" would also match "## Value ledger".
|
|
2487
|
+
for (const [file, heading] of [['terrain.md', 'Operating map'], ['delivery.md', 'Value ledger']]) {
|
|
2488
|
+
if (countSections(readClean(eng, file), heading) > 1) {
|
|
2489
|
+
issues.push(`duplicate ## ${heading} headings in ${file} - merge into one section; the gates read the last filled one`)
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2417
2492
|
const aliases = findAmbiguousStakeholders(eng)
|
|
2418
2493
|
if (aliases.length) {
|
|
2419
2494
|
const sample = aliases[0].forms.slice(0, 3).join(' / ')
|
|
@@ -2427,9 +2502,11 @@ function collectDoctorIssues(eng) {
|
|
|
2427
2502
|
}
|
|
2428
2503
|
|
|
2429
2504
|
// True when ## Operating map has at least one real exception row (not the empty template).
|
|
2505
|
+
// lastNonEmpty: an agent that appends a filled section leaves the empty template
|
|
2506
|
+
// heading above it. Reading the first match called that work invisible.
|
|
2430
2507
|
function hasOperatingMapContent(eng) {
|
|
2431
2508
|
const terrain = stripTemplateNoise(readClean(eng, 'terrain.md'))
|
|
2432
|
-
const body = sectionBody(terrain, 'Operating map')
|
|
2509
|
+
const body = sectionBody(terrain, 'Operating map', { lastNonEmpty: true })
|
|
2433
2510
|
if (!body.trim()) return false
|
|
2434
2511
|
const table = parseMdTable(body)
|
|
2435
2512
|
if (table) {
|
|
@@ -2519,7 +2596,7 @@ function hasValueBucket(eng) {
|
|
|
2519
2596
|
if (bucketLine && VALUE_BUCKET_RE.test(bucketLine[1].trim())) return true
|
|
2520
2597
|
if (!/\*\*Primary value bucket:\*\*/i.test(success) && VALUE_BUCKET_RE.test(success)) return true
|
|
2521
2598
|
|
|
2522
|
-
const ledger = stripLegendLines(stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger') || ''))
|
|
2599
|
+
const ledger = stripLegendLines(stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger', { lastNonEmpty: true }) || ''))
|
|
2523
2600
|
const table = parseMdTable(ledger)
|
|
2524
2601
|
if (table) {
|
|
2525
2602
|
const bIdx = colIndex(table.headers, /bucket/i)
|
|
@@ -2546,8 +2623,13 @@ const PENDING_CELL_RE =
|
|
|
2546
2623
|
/^(?:pending|tbd|to ?be ?(?:measured|confirmed|determined)|n\s*\/\s*a|na|none|unknown|not measured|\?+|\.{2,}|…|-+|-+|-+)(?:[^\w].*)?$/i
|
|
2547
2624
|
|
|
2548
2625
|
function parseValueLedger(eng) {
|
|
2549
|
-
|
|
2550
|
-
|
|
2626
|
+
// Last section with actual rows, not merely the last non-empty one: a template
|
|
2627
|
+
// copy appended below filled work is a header and a legend - text, but no
|
|
2628
|
+
// value - and it would otherwise shadow a real accepted row above it.
|
|
2629
|
+
const bodies = sectionBodies(readClean(eng, 'delivery.md'), 'Value ledger')
|
|
2630
|
+
.map(b => stripTemplateNoise(b || ''))
|
|
2631
|
+
const withRows = [...bodies].reverse().find(b => valueLedgerRowCount(b))
|
|
2632
|
+
const table = parseMdTable(withRows || [...bodies].reverse().find(Boolean) || '')
|
|
2551
2633
|
if (!table) return { rows: [], columnMissing: false }
|
|
2552
2634
|
const idx = {
|
|
2553
2635
|
slice: colIndex(table.headers, /slice/i),
|
|
@@ -2600,17 +2682,46 @@ function valueLedgerStatusLines(eng, opts = {}) {
|
|
|
2600
2682
|
return lines
|
|
2601
2683
|
}
|
|
2602
2684
|
|
|
2603
|
-
// AI in scope for ship/close hygiene -
|
|
2685
|
+
// AI in scope for ship/close hygiene - the FDE's own words, wherever they wrote them.
|
|
2604
2686
|
// Do not scan terrain.md: its template headers mention LLM and would false-positive every ship.
|
|
2605
2687
|
function engagementTouchesAI(eng) {
|
|
2606
2688
|
const trust = readClean(eng, 'trust-profile.md')
|
|
2607
|
-
const aiSec = stripTemplateNoise(sectionBody(trust, 'AI policy') || '')
|
|
2689
|
+
const aiSec = stripTemplateNoise(sectionBody(trust, 'AI policy', { lastNonEmpty: true }) || '')
|
|
2608
2690
|
if (aiSec.trim().length > 20) return true
|
|
2691
|
+
// Not **AI code policy:** - that field is about the FDE's own agent writing
|
|
2692
|
+
// code ("permitted with human review"), which every engagement now has. AI in
|
|
2693
|
+
// the shipped product is a different claim, and only the record's own words
|
|
2694
|
+
// below can make it.
|
|
2695
|
+
// brief/success/risks included: an engagement is often declared AI in the brief
|
|
2696
|
+
// or in a risk ("nobody can say what the accuracy was") and never again.
|
|
2609
2697
|
const blob = stripTemplateNoise([
|
|
2610
2698
|
readClean(eng, 'delivery.md'),
|
|
2611
2699
|
readClean(eng, 'decisions.md'),
|
|
2700
|
+
readClean(eng, 'brief.md'),
|
|
2701
|
+
readClean(eng, 'success.md'),
|
|
2702
|
+
readClean(eng, 'risks.md'),
|
|
2703
|
+
readClean(eng, 'assumptions.md'),
|
|
2612
2704
|
].join('\n'))
|
|
2613
|
-
|
|
2705
|
+
// No bare "prompt": "prompt response" / "prompt payment" is ordinary delivery
|
|
2706
|
+
// English and would fail every non-AI ship on a missing eval receipt.
|
|
2707
|
+
return /\b(llm|rag|embedding|model card|model output|model drift|agentic|openai|anthropic|vector database|vector db|fine-tun\w*|hallucinat\w*)\b|\bmodel inference\b|\binference (?:api|endpoint|server|engine)\b|\b(?:system|model|user)\s+prompts?\b|\bprompt (?:engineering|injection|template)/i.test(blob)
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
// The repo says AI even when the record does not. Read-only, capped, local: the
|
|
2711
|
+
// point is to refuse to run a silent green ship over an unevaluated model.
|
|
2712
|
+
function workspaceAIHit(eng) {
|
|
2713
|
+
const slug = path.basename(path.dirname(eng))
|
|
2714
|
+
const ws = readRegistry().filter(r => r.slug === slug).map(r => r.workspace)
|
|
2715
|
+
for (const dir of ws.slice(0, 3)) {
|
|
2716
|
+
let files
|
|
2717
|
+
try {
|
|
2718
|
+
if (!fs.existsSync(dir)) continue
|
|
2719
|
+
files = walk(dir, CODE_EXT, 1500)
|
|
2720
|
+
} catch (_) { continue }
|
|
2721
|
+
const hit = grepFiles(files, AI_CODE_RE, 1)[0]
|
|
2722
|
+
if (hit) return { workspace: dir, file: hit.file }
|
|
2723
|
+
}
|
|
2724
|
+
return null
|
|
2614
2725
|
}
|
|
2615
2726
|
|
|
2616
2727
|
function hasEvalReceipt(eng) {
|
|
@@ -2623,9 +2734,9 @@ function hasEvalReceipt(eng) {
|
|
|
2623
2734
|
if (/\|\s*G\d+\s*\|[^|\n]+\|[^|\n]+\|[^|\n]+\|[^|\n]+\|\s*pass\s*\|/i.test(e)) return true
|
|
2624
2735
|
}
|
|
2625
2736
|
const del = stripLegendLines(stripTemplateNoise(readClean(eng, 'delivery.md')))
|
|
2626
|
-
if (/#{1,6}\s+Eval\b/i.test(del) && /\b(pass|SHIP|\d+\/\d+)\b/i.test(sectionBody(del, 'Eval') || del)) return true
|
|
2737
|
+
if (/#{1,6}\s+Eval\b/i.test(del) && /\b(pass|SHIP|\d+\/\d+)\b/i.test(sectionBody(del, 'Eval', { lastNonEmpty: true }) || del)) return true
|
|
2627
2738
|
if (/\beval (pack|receipt)[:\s].*\b(pass|SHIP)\b/i.test(del)) return true
|
|
2628
|
-
const receipts = sectionBody(del, 'Ship receipts') || ''
|
|
2739
|
+
const receipts = sectionBody(del, 'Ship receipts', { lastNonEmpty: true }) || ''
|
|
2629
2740
|
if (/\bevals\.md\b/i.test(receipts) && /\b(pass|SHIP)\b/i.test(receipts) && !/\*\([^)]*evals\.md[^)]*\)\*/i.test(receipts)) {
|
|
2630
2741
|
return true
|
|
2631
2742
|
}
|
|
@@ -2648,6 +2759,31 @@ function printTriageBlock(eng) {
|
|
|
2648
2759
|
for (const line of hygieneTriageLines(eng)) console.log(line)
|
|
2649
2760
|
}
|
|
2650
2761
|
|
|
2762
|
+
// What a session must not have to ask for: who signs, what was promised, what was
|
|
2763
|
+
// decided. Read-only, and from the same places the writers use - the signer is
|
|
2764
|
+
// success.md **Stakeholder who signs off** (what `signer:` fills), never a role
|
|
2765
|
+
// guess out of stakeholders.md, where contacts live. Bounded on purpose (<= 6
|
|
2766
|
+
// lines): this is injected into every session.
|
|
2767
|
+
function recordDigest(eng) {
|
|
2768
|
+
const success = stripTemplateNoise(readClean(eng, 'success.md'))
|
|
2769
|
+
const signer = ((success.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
2770
|
+
// "(none)" rather than a missing line: on session start, nobody named to sign
|
|
2771
|
+
// off is the fact worth seeing, not an absence to scroll past.
|
|
2772
|
+
const lines = [` signer: ${signer.slice(0, 110) || '(none)'}`]
|
|
2773
|
+
const { rows } = parseValueLedger(eng)
|
|
2774
|
+
const promisedRow = [...rows].reverse().find(r => r.promised)
|
|
2775
|
+
if (promisedRow) {
|
|
2776
|
+
lines.push(` promised: ${formatValueLedgerLine(promisedRow).slice(0, 110)}`)
|
|
2777
|
+
} else {
|
|
2778
|
+
const target = ((success.match(/^\*\*Baseline[^\S\n]*→[^\S\n]*target:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
2779
|
+
if (target) lines.push(` promised: ${target.slice(0, 110)}`)
|
|
2780
|
+
}
|
|
2781
|
+
const decisions = readClean(eng, 'decisions.md').split('\n')
|
|
2782
|
+
.filter(l => /^-\s*\[\d{4}-\d{2}-\d{2}\]/.test(l.trim())).slice(-2)
|
|
2783
|
+
for (const d of decisions) lines.push(` decided: ${d.trim().replace(/^-\s*/, '').slice(0, 110)}`)
|
|
2784
|
+
return ['RECORD (read-only - success, delivery, decisions)', ...lines]
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2651
2787
|
function cmdDoctor() {
|
|
2652
2788
|
const eng = resolveEngagement()
|
|
2653
2789
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
@@ -2985,12 +3121,14 @@ function cmdStatus(args) {
|
|
|
2985
3121
|
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) })
|
|
2986
3122
|
}
|
|
2987
3123
|
if (!rows.length) { console.log('no engagements yet'); return }
|
|
2988
|
-
|
|
3124
|
+
// `new` sorts last: nothing to act on yet, unlike a green somebody confirmed.
|
|
3125
|
+
const order = { RED: 0, amber: 1, green: 2, new: 3 }
|
|
2989
3126
|
rows.sort((a, b) => order[a.trust] - order[b.trust])
|
|
2990
3127
|
console.log((all ? 'FDE PORTFOLIO' : 'FDE STATUS') + ' - value first, then trust\n')
|
|
2991
3128
|
for (const r of rows) {
|
|
2992
3129
|
for (const line of r.valueLines) console.log(line)
|
|
2993
3130
|
// "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
|
|
3131
|
+
// "new" = nobody has been asked yet; green is reserved for asked-and-fine.
|
|
2994
3132
|
const label = r.trust + (r.stale ? '?' : '')
|
|
2995
3133
|
const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
|
|
2996
3134
|
console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${(r.phase === '?' ? 'unset' : r.phase).padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
|
|
@@ -3045,7 +3183,7 @@ function cmdDashboard(args) {
|
|
|
3045
3183
|
}
|
|
3046
3184
|
engagements = gatherEngagements({ only: eng })
|
|
3047
3185
|
}
|
|
3048
|
-
const counts = { green: 0, amber: 0, RED: 0 }
|
|
3186
|
+
const counts = { green: 0, amber: 0, RED: 0, new: 0 }
|
|
3049
3187
|
engagements.forEach(e => { counts[e.signals.trust]++ })
|
|
3050
3188
|
const today = render.formatToday(new Date())
|
|
3051
3189
|
|
|
@@ -3100,7 +3238,7 @@ function cmdDashboard(args) {
|
|
|
3100
3238
|
failFs(e, 'write fieldbook', outPath)
|
|
3101
3239
|
}
|
|
3102
3240
|
console.log(`fieldbook → ${outPath}`)
|
|
3103
|
-
console.log(`${engagements.length} engagement(s) rendered · ${counts.RED} red / ${counts.amber} amber / ${counts.green} green · 0 tokens (pure render)`)
|
|
3241
|
+
console.log(`${engagements.length} engagement(s) rendered · ${counts.RED} red / ${counts.amber} amber / ${counts.green} green / ${counts.new} new · 0 tokens (pure render)`)
|
|
3104
3242
|
if (!all) {
|
|
3105
3243
|
const current = resolveEngagement()
|
|
3106
3244
|
if (current) for (const line of hygieneTriageLines(current)) console.log(line)
|
|
@@ -3131,7 +3269,7 @@ function cliVersion() {
|
|
|
3131
3269
|
}
|
|
3132
3270
|
|
|
3133
3271
|
function valueLedgerRows(eng) {
|
|
3134
|
-
const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger') || '')
|
|
3272
|
+
const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger', { lastNonEmpty: true }) || '')
|
|
3135
3273
|
const table = parseMdTable(ledger)
|
|
3136
3274
|
if (!table) return []
|
|
3137
3275
|
const sIdx = colIndex(table.headers, /slice/i)
|
package/bin/install.js
CHANGED
|
@@ -292,8 +292,8 @@ function cmdAdapters(targetDir, opts = {}) {
|
|
|
292
292
|
|
|
293
293
|
function cmdInit(engagementName) {
|
|
294
294
|
if (!engagementName) {
|
|
295
|
-
console.error(' Usage:
|
|
296
|
-
console.error(' Example:
|
|
295
|
+
console.error(' Usage: fdeops init <engagement-name>')
|
|
296
|
+
console.error(' Example: fdeops init retailbank-payments')
|
|
297
297
|
process.exit(1)
|
|
298
298
|
}
|
|
299
299
|
const slug = slugify(engagementName)
|
|
@@ -318,9 +318,15 @@ function cmdInit(engagementName) {
|
|
|
318
318
|
else if (merged > 0) console.log(` (${merged} missing template file(s) added)`)
|
|
319
319
|
console.log('')
|
|
320
320
|
console.log(' Next:')
|
|
321
|
-
console.log(' 1.
|
|
322
|
-
console.log(` 2.
|
|
323
|
-
console.log(' 3. In the AI chat
|
|
321
|
+
console.log(' 1. cd into the workspace you will work in for this client')
|
|
322
|
+
console.log(` 2. Bind it once - no env var to remember afterwards: fde resume --init ${slug}`)
|
|
323
|
+
console.log(' 3. In the AI chat, say what happened. The agent runs the CLI.')
|
|
324
|
+
console.log('')
|
|
325
|
+
// Creating the memory does not bind a workspace: the registry maps a workspace
|
|
326
|
+
// path to a slug, and this command runs wherever the human happened to be.
|
|
327
|
+
// Saying so here is the difference between a bound engagement and a silent
|
|
328
|
+
// "NO ENGAGEMENT" in every later session.
|
|
329
|
+
console.log(` (until it is bound, commands need: export FDEOPS_ENGAGEMENT=${fdeDir})`)
|
|
324
330
|
console.log('')
|
|
325
331
|
}
|
|
326
332
|
|
|
@@ -334,14 +340,14 @@ function cmdInstall(opts = {}) {
|
|
|
334
340
|
console.log(' CLI → ~/.claude/fdeops/fde.js (try: node ~/.claude/fdeops/fde.js scan)')
|
|
335
341
|
console.log('')
|
|
336
342
|
console.log(' Create an engagement (stays off customer infrastructure):')
|
|
337
|
-
console.log('
|
|
343
|
+
console.log(' fdeops init <engagement-name>')
|
|
338
344
|
console.log('')
|
|
339
345
|
console.log(' Example:')
|
|
340
|
-
console.log('
|
|
341
|
-
console.log(' (
|
|
346
|
+
console.log(' fdeops init garvey-payments')
|
|
347
|
+
console.log(' (from a clone, without installing: node bin/install.js init <engagement-name>)')
|
|
342
348
|
console.log('')
|
|
343
349
|
console.log(' Use another AI tool (Cursor, Codex, Gemini CLI, Copilot)? Wire it up:')
|
|
344
|
-
console.log('
|
|
350
|
+
console.log(' fdeops adapters <engagement-workspace>')
|
|
345
351
|
console.log('')
|
|
346
352
|
console.log(' Then open your workspace and use @fde')
|
|
347
353
|
console.log(' Docs: docs/install.md')
|
package/bin/lib/render.js
CHANGED
|
@@ -97,7 +97,8 @@ function formatLogDate(iso) {
|
|
|
97
97
|
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
|
98
98
|
return m ? `${MONTHS[parseInt(m[2], 10) - 1]} ${parseInt(m[3], 10)}` : iso
|
|
99
99
|
}
|
|
100
|
-
|
|
100
|
+
// `new` is its own word: nobody has been asked yet, which is neither steady nor at risk.
|
|
101
|
+
function trustWord(t) { return t === 'green' ? 'steady' : t === 'amber' ? 'watch' : t === 'new' ? 'new' : 'at risk' }
|
|
101
102
|
function dotClassFor(trust) { return trust === 'RED' ? 'red' : trust }
|
|
102
103
|
|
|
103
104
|
// Two-pane fieldbook: left rail (search + today + per-client nav), right main
|
|
@@ -222,10 +223,11 @@ strong{font-weight:600}
|
|
|
222
223
|
.dot.green{background:var(--green)}
|
|
223
224
|
.dot.amber{background:var(--amber);border-radius:2px}
|
|
224
225
|
.dot.red{background:transparent;border:1.5px solid var(--red)}
|
|
226
|
+
.dot.new{background:transparent;border:1.5px solid var(--ink-faint)}
|
|
225
227
|
.dot-sm{width:7px;height:7px}
|
|
226
228
|
.dot-md{width:8px;height:8px}
|
|
227
229
|
.dot-lg{width:9px;height:9px}
|
|
228
|
-
.t-green{color:var(--green)}.t-amber{color:var(--amber)}.t-red{color:var(--red)}.t-accent{color:var(--accent)}.t-faint{color:var(--ink-faint)}.t-soft{color:var(--ink-soft)}
|
|
230
|
+
.t-green{color:var(--green)}.t-amber{color:var(--amber)}.t-red{color:var(--red)}.t-accent{color:var(--accent)}.t-faint{color:var(--ink-faint)}.t-new{color:var(--ink-faint)}.t-soft{color:var(--ink-soft)}
|
|
229
231
|
.fb-hints{margin-top:26px;display:flex;gap:14px;flex-wrap:wrap;font-family:'Geist Mono',monospace;font-size:10.5px;color:var(--ink-faint);border-top:1px solid var(--line);padding-top:12px}
|
|
230
232
|
.fb-hints-spacer{margin-left:auto}
|
|
231
233
|
.fb-palette-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:60;display:none;align-items:flex-start;justify-content:center;padding-top:12vh}
|
|
@@ -586,9 +588,9 @@ function paletteItemsHtml(ordered) {
|
|
|
586
588
|
|
|
587
589
|
function buildFieldbookHtml({ engagements, today }) {
|
|
588
590
|
// rail + Today queue share one order: trust-first (red, amber, green)
|
|
589
|
-
const tierRank = { RED: 0, amber: 1, green: 2 }
|
|
591
|
+
const tierRank = { RED: 0, amber: 1, green: 2, new: 3 }
|
|
590
592
|
const ordered = engagements.slice().sort((a, b) => tierRank[a.signals.trust] - tierRank[b.signals.trust])
|
|
591
|
-
const attentionCount = engagements.filter(e => e.signals.trust !== 'green').length
|
|
593
|
+
const attentionCount = engagements.filter(e => e.signals.trust !== 'green' && e.signals.trust !== 'new').length
|
|
592
594
|
const highRiskTotal = engagements.reduce((n, e) => n + e.highRisks, 0)
|
|
593
595
|
|
|
594
596
|
const railItems = ordered.map(railItemHtml).join('\n')
|
package/bin/lib/trust.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
function createTrustApi(deps) {
|
|
4
4
|
const {
|
|
5
5
|
fs, path, readClean, readEng, parseMdTable, sectionBody, SIGNAL_LEDGER, memoryDirtyManual,
|
|
6
|
+
stripTemplateNoise, stripLegendLines,
|
|
6
7
|
} = deps
|
|
7
8
|
|
|
8
9
|
// phase / trust / top risk / freshness - identical heuristic for status + dashboard.
|
|
@@ -183,9 +184,14 @@ function createTrustApi(deps) {
|
|
|
183
184
|
stale = signalAge > 21
|
|
184
185
|
}
|
|
185
186
|
} else {
|
|
186
|
-
|
|
187
|
+
// No structured signal anywhere. Prose still gets to raise an alarm - a
|
|
188
|
+
// written "gone quiet" is worth an amber - but it never earns a green:
|
|
189
|
+
// green must mean somebody was asked and said they were fine, and a day-1
|
|
190
|
+
// template calling someone a "champion" is not that. That reads `new`.
|
|
191
|
+
const sLines = stripLegendLines(stripTemplateNoise(stake)).split('\n')
|
|
192
|
+
.filter(l => !(/green/i.test(l) && /red|amber/i.test(l)))
|
|
187
193
|
trust = sLines.some(l => /\bred\b/i.test(l)) ? 'RED'
|
|
188
|
-
: sLines.some(l => /amber|gone quiet|routing around|escalat/i.test(l)) ? 'amber' : '
|
|
194
|
+
: sLines.some(l => /amber|gone quiet|routing around|escalat/i.test(l)) ? 'amber' : 'new'
|
|
189
195
|
}
|
|
190
196
|
const topRisk = (risks.split('\n').find(l => {
|
|
191
197
|
const t = l.trim()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fdeops",
|
|
3
|
-
"version": "3.22.
|
|
3
|
+
"version": "3.22.1",
|
|
4
4
|
"description": "Forward deployed engineering skills for AI coding agents. One @fde skill for the client work around the code: who can say yes, what went live, whether they signed off. Dated markdown on your laptop. You confirm each write.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fdeops": "bin/install.js",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "fdeops",
|
|
4
|
-
"version": "3.22.
|
|
4
|
+
"version": "3.22.1",
|
|
5
5
|
"description": "Forward deployed engineering skills for AI coding agents. One @fde skill for the client work around the code. You confirm; then it lands in .fde/ on your laptop.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Subash Natarajan",
|