fdeops 3.22.0 → 3.22.2
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 +210 -50
- 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) {
|
|
@@ -1555,20 +1610,38 @@ function smartProposeText(input) {
|
|
|
1555
1610
|
return out.join('\n') + (out.length ? '\n' : '')
|
|
1556
1611
|
}
|
|
1557
1612
|
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
1560
|
-
//
|
|
1561
|
-
const
|
|
1613
|
+
// Kickoff English, not only "signer: Priya". "Helena signs off", "Anand Mehta
|
|
1614
|
+
// has final say", "Finance controller (Helena) signs off" all have to fill
|
|
1615
|
+
// success.md - that is the line Monday's RECORD reads.
|
|
1616
|
+
const SIGNER_VERB = '(?:signs?(?:\\s+off)?|approves|has (?:the )?final say|can say yes|owns the decision|is the (?:sponsor|signer|decision[- ]maker))'
|
|
1617
|
+
const SIGNER_NAME = '([A-Z][\\w.\'-]+(?:\\s+[A-Z][\\w.\'-]+){0,3})'
|
|
1618
|
+
const NOT_A_PERSON = /^(The|This|That|It|We|They|She|He|Staging|Budget|Prod|Production|Nobody|Someone|Everyone|Finance|Legal|Security|Platform|Engineering)\b/
|
|
1619
|
+
const ROLE_TOKEN = /\b(VP|SVP|EVP|CTO|CFO|COO|CEO|CISO|Eng|Engineer|Director|Lead|Head|Manager|Controller|Ops|Legal|Finance|Sponsor)\b/i
|
|
1620
|
+
|
|
1621
|
+
function looksLikePersonName(s) {
|
|
1622
|
+
const t = String(s || '').trim()
|
|
1623
|
+
if (!t || NOT_A_PERSON.test(t) || ROLE_TOKEN.test(t)) return false
|
|
1624
|
+
return /^[A-Z][\w.'-]+(?:\s+[A-Z][\w.'-]+){0,2}$/.test(t)
|
|
1625
|
+
}
|
|
1562
1626
|
|
|
1563
1627
|
function signerFromLine(text) {
|
|
1564
|
-
const t = String(text || '').trim()
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
const
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1628
|
+
const t = String(text || '').replace(/^[-*+]\s+/, '').trim()
|
|
1629
|
+
if (!t) return ''
|
|
1630
|
+
// "Priya (VP Eng) signs off" → Priya. "Finance controller (Helena) signs off" → Helena.
|
|
1631
|
+
const titled = t.match(new RegExp('\\b' + SIGNER_NAME + '\\s+\\(' + SIGNER_NAME + '\\)\\s+' + SIGNER_VERB + '\\b'))
|
|
1632
|
+
if (titled) {
|
|
1633
|
+
const before = titled[1].trim()
|
|
1634
|
+
const inside = titled[2].trim()
|
|
1635
|
+
if (looksLikePersonName(before) && ROLE_TOKEN.test(inside)) return before
|
|
1636
|
+
if (looksLikePersonName(inside)) return inside
|
|
1637
|
+
if (looksLikePersonName(before)) return before
|
|
1638
|
+
}
|
|
1639
|
+
const paren = t.match(new RegExp('\\(' + SIGNER_NAME + '\\)\\s+' + SIGNER_VERB + '\\b'))
|
|
1640
|
+
if (paren && looksLikePersonName(paren[1])) return paren[1].trim()
|
|
1641
|
+
const named = t.match(new RegExp('\\b' + SIGNER_NAME + '\\s+' + SIGNER_VERB + '\\b'))
|
|
1642
|
+
if (!named) return ''
|
|
1643
|
+
const who = named[1].trim()
|
|
1644
|
+
return looksLikePersonName(who) ? who : ''
|
|
1572
1645
|
}
|
|
1573
1646
|
|
|
1574
1647
|
function setSigner(eng, who) {
|
|
@@ -1577,7 +1650,10 @@ function setSigner(eng, who) {
|
|
|
1577
1650
|
let md = readEng(eng, 'success.md')
|
|
1578
1651
|
if (!md) md = '# Success definition\n\n'
|
|
1579
1652
|
const norm = (s) => String(s).replace(/\s+/g, ' ').trim().toLowerCase()
|
|
1580
|
-
|
|
1653
|
+
// [^\S\n], not \s: \s crosses newlines, so an empty field captured the next
|
|
1654
|
+
// line - and setSigner then read a filled field and filed the name as "also
|
|
1655
|
+
// named" under whatever heading followed.
|
|
1656
|
+
const line = /^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m
|
|
1581
1657
|
const m = md.match(line)
|
|
1582
1658
|
if (m && !m[1].trim()) {
|
|
1583
1659
|
md = md.replace(line, `**Stakeholder who signs off:** ${who}`)
|
|
@@ -2076,7 +2152,7 @@ function cmdReceipts(args) {
|
|
|
2076
2152
|
agreed.map(h => (h.match(/^\s*([^:]+):/) || [])[1]).filter(f => f && dirtySet.has(f))
|
|
2077
2153
|
)]
|
|
2078
2154
|
if (agreed.length) {
|
|
2079
|
-
console.log('ON RECORD (dated
|
|
2155
|
+
console.log('ON RECORD (dated):')
|
|
2080
2156
|
agreed.forEach(h => {
|
|
2081
2157
|
const file = (h.match(/^\s*([^:]+):/) || [])[1]
|
|
2082
2158
|
console.log(h + (file && dirtySet.has(file) ? ' ⚠ dirty file' : ''))
|
|
@@ -2167,8 +2243,10 @@ function cmdTriage() {
|
|
|
2167
2243
|
console.error('no engagement - run: fde resume --init <name>')
|
|
2168
2244
|
process.exit(2)
|
|
2169
2245
|
}
|
|
2170
|
-
// Session-start hooks call this - hygiene is proactive here (silent when clean)
|
|
2246
|
+
// Session-start hooks call this - hygiene is proactive here (silent when clean),
|
|
2247
|
+
// and the record digest travels with it so a fresh session knows who signs.
|
|
2171
2248
|
printTriageBlock(eng)
|
|
2249
|
+
for (const line of recordDigest(eng)) console.log(line)
|
|
2172
2250
|
const owner = readOwner(eng) || writeOwnerIfMissing(eng)
|
|
2173
2251
|
const head = memoryHead(eng)
|
|
2174
2252
|
if (owner || head) {
|
|
@@ -2397,6 +2475,13 @@ function collectDoctorIssues(eng) {
|
|
|
2397
2475
|
issues.push(
|
|
2398
2476
|
`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
2477
|
)
|
|
2478
|
+
} else if (!engagementTouchesAI(eng)) {
|
|
2479
|
+
const hit = workspaceAIHit(eng)
|
|
2480
|
+
if (hit) {
|
|
2481
|
+
issues.push(
|
|
2482
|
+
`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: …"`
|
|
2483
|
+
)
|
|
2484
|
+
}
|
|
2400
2485
|
}
|
|
2401
2486
|
issues.push(...silentCommitIssues(eng))
|
|
2402
2487
|
}
|
|
@@ -2414,6 +2499,14 @@ function collectDoctorIssues(eng) {
|
|
|
2414
2499
|
`phase is ${s.phase} with empty operating map - fill terrain.md ## Operating map (exception-led): break → who notices → workaround → evidence`
|
|
2415
2500
|
)
|
|
2416
2501
|
}
|
|
2502
|
+
// A second copy of a heading the gates read: they take the last filled one, so
|
|
2503
|
+
// the record is ambiguous rather than lost. Say so once, here.
|
|
2504
|
+
// Full heading names only: "Value" would also match "## Value ledger".
|
|
2505
|
+
for (const [file, heading] of [['terrain.md', 'Operating map'], ['delivery.md', 'Value ledger']]) {
|
|
2506
|
+
if (countSections(readClean(eng, file), heading) > 1) {
|
|
2507
|
+
issues.push(`duplicate ## ${heading} headings in ${file} - merge into one section; the gates read the last filled one`)
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2417
2510
|
const aliases = findAmbiguousStakeholders(eng)
|
|
2418
2511
|
if (aliases.length) {
|
|
2419
2512
|
const sample = aliases[0].forms.slice(0, 3).join(' / ')
|
|
@@ -2427,9 +2520,11 @@ function collectDoctorIssues(eng) {
|
|
|
2427
2520
|
}
|
|
2428
2521
|
|
|
2429
2522
|
// True when ## Operating map has at least one real exception row (not the empty template).
|
|
2523
|
+
// lastNonEmpty: an agent that appends a filled section leaves the empty template
|
|
2524
|
+
// heading above it. Reading the first match called that work invisible.
|
|
2430
2525
|
function hasOperatingMapContent(eng) {
|
|
2431
2526
|
const terrain = stripTemplateNoise(readClean(eng, 'terrain.md'))
|
|
2432
|
-
const body = sectionBody(terrain, 'Operating map')
|
|
2527
|
+
const body = sectionBody(terrain, 'Operating map', { lastNonEmpty: true })
|
|
2433
2528
|
if (!body.trim()) return false
|
|
2434
2529
|
const table = parseMdTable(body)
|
|
2435
2530
|
if (table) {
|
|
@@ -2519,7 +2614,7 @@ function hasValueBucket(eng) {
|
|
|
2519
2614
|
if (bucketLine && VALUE_BUCKET_RE.test(bucketLine[1].trim())) return true
|
|
2520
2615
|
if (!/\*\*Primary value bucket:\*\*/i.test(success) && VALUE_BUCKET_RE.test(success)) return true
|
|
2521
2616
|
|
|
2522
|
-
const ledger = stripLegendLines(stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger') || ''))
|
|
2617
|
+
const ledger = stripLegendLines(stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger', { lastNonEmpty: true }) || ''))
|
|
2523
2618
|
const table = parseMdTable(ledger)
|
|
2524
2619
|
if (table) {
|
|
2525
2620
|
const bIdx = colIndex(table.headers, /bucket/i)
|
|
@@ -2543,11 +2638,16 @@ function hasValueBucket(eng) {
|
|
|
2543
2638
|
// "pending review", "TBD.", "n/a (blocked)" and "..." are all the same thing an
|
|
2544
2639
|
// FDE means by an empty cell - nagging about them teaches people to ignore doctor.
|
|
2545
2640
|
const PENDING_CELL_RE =
|
|
2546
|
-
/^(?:pending|tbd|to ?be ?(?:measured|confirmed|determined)|n\s*\/\s*a|na|none|unknown|not
|
|
2641
|
+
/^(?:pending|tbd|to ?be ?(?:measured|confirmed|determined)|n\s*\/\s*a|na|none|unknown|not(?:\s+yet)?\s+measured|unmeasured|awaiting|\?+|\.{2,}|…|-+)(?:[^\w].*)?$/i
|
|
2547
2642
|
|
|
2548
2643
|
function parseValueLedger(eng) {
|
|
2549
|
-
|
|
2550
|
-
|
|
2644
|
+
// Last section with actual rows, not merely the last non-empty one: a template
|
|
2645
|
+
// copy appended below filled work is a header and a legend - text, but no
|
|
2646
|
+
// value - and it would otherwise shadow a real accepted row above it.
|
|
2647
|
+
const bodies = sectionBodies(readClean(eng, 'delivery.md'), 'Value ledger')
|
|
2648
|
+
.map(b => stripTemplateNoise(b || ''))
|
|
2649
|
+
const withRows = [...bodies].reverse().find(b => valueLedgerRowCount(b))
|
|
2650
|
+
const table = parseMdTable(withRows || [...bodies].reverse().find(Boolean) || '')
|
|
2551
2651
|
if (!table) return { rows: [], columnMissing: false }
|
|
2552
2652
|
const idx = {
|
|
2553
2653
|
slice: colIndex(table.headers, /slice/i),
|
|
@@ -2600,17 +2700,46 @@ function valueLedgerStatusLines(eng, opts = {}) {
|
|
|
2600
2700
|
return lines
|
|
2601
2701
|
}
|
|
2602
2702
|
|
|
2603
|
-
// AI in scope for ship/close hygiene -
|
|
2703
|
+
// AI in scope for ship/close hygiene - the FDE's own words, wherever they wrote them.
|
|
2604
2704
|
// Do not scan terrain.md: its template headers mention LLM and would false-positive every ship.
|
|
2605
2705
|
function engagementTouchesAI(eng) {
|
|
2606
2706
|
const trust = readClean(eng, 'trust-profile.md')
|
|
2607
|
-
const aiSec = stripTemplateNoise(sectionBody(trust, 'AI policy') || '')
|
|
2707
|
+
const aiSec = stripTemplateNoise(sectionBody(trust, 'AI policy', { lastNonEmpty: true }) || '')
|
|
2608
2708
|
if (aiSec.trim().length > 20) return true
|
|
2709
|
+
// Not **AI code policy:** - that field is about the FDE's own agent writing
|
|
2710
|
+
// code ("permitted with human review"), which every engagement now has. AI in
|
|
2711
|
+
// the shipped product is a different claim, and only the record's own words
|
|
2712
|
+
// below can make it.
|
|
2713
|
+
// brief/success/risks included: an engagement is often declared AI in the brief
|
|
2714
|
+
// or in a risk ("nobody can say what the accuracy was") and never again.
|
|
2609
2715
|
const blob = stripTemplateNoise([
|
|
2610
2716
|
readClean(eng, 'delivery.md'),
|
|
2611
2717
|
readClean(eng, 'decisions.md'),
|
|
2718
|
+
readClean(eng, 'brief.md'),
|
|
2719
|
+
readClean(eng, 'success.md'),
|
|
2720
|
+
readClean(eng, 'risks.md'),
|
|
2721
|
+
readClean(eng, 'assumptions.md'),
|
|
2612
2722
|
].join('\n'))
|
|
2613
|
-
|
|
2723
|
+
// No bare "prompt": "prompt response" / "prompt payment" is ordinary delivery
|
|
2724
|
+
// English and would fail every non-AI ship on a missing eval receipt.
|
|
2725
|
+
return /\bAI in scope\b|\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)
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
// The repo says AI even when the record does not. Read-only, capped, local: the
|
|
2729
|
+
// point is to refuse to run a silent green ship over an unevaluated model.
|
|
2730
|
+
function workspaceAIHit(eng) {
|
|
2731
|
+
const slug = path.basename(path.dirname(eng))
|
|
2732
|
+
const ws = readRegistry().filter(r => r.slug === slug).map(r => r.workspace)
|
|
2733
|
+
for (const dir of ws.slice(0, 3)) {
|
|
2734
|
+
let files
|
|
2735
|
+
try {
|
|
2736
|
+
if (!fs.existsSync(dir)) continue
|
|
2737
|
+
files = walk(dir, CODE_EXT, 1500)
|
|
2738
|
+
} catch (_) { continue }
|
|
2739
|
+
const hit = grepFiles(files, AI_CODE_RE, 1)[0]
|
|
2740
|
+
if (hit) return { workspace: dir, file: hit.file }
|
|
2741
|
+
}
|
|
2742
|
+
return null
|
|
2614
2743
|
}
|
|
2615
2744
|
|
|
2616
2745
|
function hasEvalReceipt(eng) {
|
|
@@ -2623,9 +2752,9 @@ function hasEvalReceipt(eng) {
|
|
|
2623
2752
|
if (/\|\s*G\d+\s*\|[^|\n]+\|[^|\n]+\|[^|\n]+\|[^|\n]+\|\s*pass\s*\|/i.test(e)) return true
|
|
2624
2753
|
}
|
|
2625
2754
|
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
|
|
2755
|
+
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
2756
|
if (/\beval (pack|receipt)[:\s].*\b(pass|SHIP)\b/i.test(del)) return true
|
|
2628
|
-
const receipts = sectionBody(del, 'Ship receipts') || ''
|
|
2757
|
+
const receipts = sectionBody(del, 'Ship receipts', { lastNonEmpty: true }) || ''
|
|
2629
2758
|
if (/\bevals\.md\b/i.test(receipts) && /\b(pass|SHIP)\b/i.test(receipts) && !/\*\([^)]*evals\.md[^)]*\)\*/i.test(receipts)) {
|
|
2630
2759
|
return true
|
|
2631
2760
|
}
|
|
@@ -2648,6 +2777,31 @@ function printTriageBlock(eng) {
|
|
|
2648
2777
|
for (const line of hygieneTriageLines(eng)) console.log(line)
|
|
2649
2778
|
}
|
|
2650
2779
|
|
|
2780
|
+
// What a session must not have to ask for: who signs, what was promised, what was
|
|
2781
|
+
// decided. Read-only, and from the same places the writers use - the signer is
|
|
2782
|
+
// success.md **Stakeholder who signs off** (what `signer:` fills), never a role
|
|
2783
|
+
// guess out of stakeholders.md, where contacts live. Bounded on purpose (<= 6
|
|
2784
|
+
// lines): this is injected into every session.
|
|
2785
|
+
function recordDigest(eng) {
|
|
2786
|
+
const success = stripTemplateNoise(readClean(eng, 'success.md'))
|
|
2787
|
+
const signer = ((success.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
2788
|
+
// "(none)" rather than a missing line: on session start, nobody named to sign
|
|
2789
|
+
// off is the fact worth seeing, not an absence to scroll past.
|
|
2790
|
+
const lines = [` signer: ${signer.slice(0, 110) || '(none)'}`]
|
|
2791
|
+
const { rows } = parseValueLedger(eng)
|
|
2792
|
+
const promisedRow = [...rows].reverse().find(r => r.promised)
|
|
2793
|
+
if (promisedRow) {
|
|
2794
|
+
lines.push(` promised: ${formatValueLedgerLine(promisedRow).slice(0, 110)}`)
|
|
2795
|
+
} else {
|
|
2796
|
+
const target = ((success.match(/^\*\*Baseline[^\S\n]*→[^\S\n]*target:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
2797
|
+
if (target) lines.push(` promised: ${target.slice(0, 110)}`)
|
|
2798
|
+
}
|
|
2799
|
+
const decisions = readClean(eng, 'decisions.md').split('\n')
|
|
2800
|
+
.filter(l => /^-\s*\[\d{4}-\d{2}-\d{2}\]/.test(l.trim())).slice(-2)
|
|
2801
|
+
for (const d of decisions) lines.push(` decided: ${d.trim().replace(/^-\s*/, '').slice(0, 110)}`)
|
|
2802
|
+
return ['RECORD (read-only - success, delivery, decisions)', ...lines]
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2651
2805
|
function cmdDoctor() {
|
|
2652
2806
|
const eng = resolveEngagement()
|
|
2653
2807
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
@@ -2985,12 +3139,14 @@ function cmdStatus(args) {
|
|
|
2985
3139
|
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
3140
|
}
|
|
2987
3141
|
if (!rows.length) { console.log('no engagements yet'); return }
|
|
2988
|
-
|
|
3142
|
+
// `new` sorts last: nothing to act on yet, unlike a green somebody confirmed.
|
|
3143
|
+
const order = { RED: 0, amber: 1, green: 2, new: 3 }
|
|
2989
3144
|
rows.sort((a, b) => order[a.trust] - order[b.trust])
|
|
2990
3145
|
console.log((all ? 'FDE PORTFOLIO' : 'FDE STATUS') + ' - value first, then trust\n')
|
|
2991
3146
|
for (const r of rows) {
|
|
2992
3147
|
for (const line of r.valueLines) console.log(line)
|
|
2993
3148
|
// "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
|
|
3149
|
+
// "new" = nobody has been asked yet; green is reserved for asked-and-fine.
|
|
2994
3150
|
const label = r.trust + (r.stale ? '?' : '')
|
|
2995
3151
|
const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
|
|
2996
3152
|
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 +3201,7 @@ function cmdDashboard(args) {
|
|
|
3045
3201
|
}
|
|
3046
3202
|
engagements = gatherEngagements({ only: eng })
|
|
3047
3203
|
}
|
|
3048
|
-
const counts = { green: 0, amber: 0, RED: 0 }
|
|
3204
|
+
const counts = { green: 0, amber: 0, RED: 0, new: 0 }
|
|
3049
3205
|
engagements.forEach(e => { counts[e.signals.trust]++ })
|
|
3050
3206
|
const today = render.formatToday(new Date())
|
|
3051
3207
|
|
|
@@ -3100,7 +3256,7 @@ function cmdDashboard(args) {
|
|
|
3100
3256
|
failFs(e, 'write fieldbook', outPath)
|
|
3101
3257
|
}
|
|
3102
3258
|
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)`)
|
|
3259
|
+
console.log(`${engagements.length} engagement(s) rendered · ${counts.RED} red / ${counts.amber} amber / ${counts.green} green / ${counts.new} new · 0 tokens (pure render)`)
|
|
3104
3260
|
if (!all) {
|
|
3105
3261
|
const current = resolveEngagement()
|
|
3106
3262
|
if (current) for (const line of hygieneTriageLines(current)) console.log(line)
|
|
@@ -3131,7 +3287,7 @@ function cliVersion() {
|
|
|
3131
3287
|
}
|
|
3132
3288
|
|
|
3133
3289
|
function valueLedgerRows(eng) {
|
|
3134
|
-
const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger') || '')
|
|
3290
|
+
const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger', { lastNonEmpty: true }) || '')
|
|
3135
3291
|
const table = parseMdTable(ledger)
|
|
3136
3292
|
if (!table) return []
|
|
3137
3293
|
const sIdx = colIndex(table.headers, /slice/i)
|
|
@@ -3480,6 +3636,10 @@ function printUsage() {
|
|
|
3480
3636
|
}
|
|
3481
3637
|
|
|
3482
3638
|
const [cmd, ...args] = process.argv.slice(2)
|
|
3639
|
+
if (args.includes('--help') || args.includes('-h') || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
3640
|
+
printUsage()
|
|
3641
|
+
process.exit(0)
|
|
3642
|
+
}
|
|
3483
3643
|
switch (cmd) {
|
|
3484
3644
|
case 'demo': cmdDemo(args); break
|
|
3485
3645
|
case 'scan': cmdScan(); break
|
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.2",
|
|
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.2",
|
|
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",
|