fdeops 3.21.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/README.md +2 -0
- package/bin/check.js +22 -0
- package/bin/fde.js +314 -45
- package/bin/install.js +15 -9
- package/bin/lib/render.js +6 -4
- package/bin/lib/trust.js +15 -6
- package/mcp/fdeops-ingest/package.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/fde/SKILL.md +4 -2
- package/skills/fde/references/debrief.md +4 -2
- package/skills/fde/references/ingest.md +1 -1
- package/skills/fde/references/ship.md +5 -1
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ You're on a customer site. The AI coding agent writes code in their repo. This k
|
|
|
6
6
|
|
|
7
7
|
Notes stay on your laptop. Their repo stays theirs. You confirm before anything is written down.
|
|
8
8
|
|
|
9
|
+
Keep the coding pack you already use. Install this next to it. FDEOps is the client work. The other pack writes the code.
|
|
10
|
+
|
|
9
11
|
<img width="1536" height="1024" alt="fdeops" src="https://github.com/user-attachments/assets/2bcb8739-55ee-445d-8a1a-8b38433b7b58" />
|
|
10
12
|
|
|
11
13
|
---
|
package/bin/check.js
CHANGED
|
@@ -409,6 +409,28 @@ for (const f of exampleFiles) {
|
|
|
409
409
|
}
|
|
410
410
|
ok('examples walkthrough files')
|
|
411
411
|
|
|
412
|
+
// The examples are the first .fde/ a newcomer reads. They must pass the kit's
|
|
413
|
+
// own doctor, or the kit is telling people to do what its showcase does not.
|
|
414
|
+
// Tolerated: things a frozen reference copy cannot have (an owner, a memory
|
|
415
|
+
// git, a fresh trust signal).
|
|
416
|
+
{
|
|
417
|
+
const { spawnSync } = require('child_process')
|
|
418
|
+
const tolerated = /no \.owner|not git-versioned|trust signal is STALE/
|
|
419
|
+
for (const ex of fs.readdirSync(path.join(root, 'examples'))) {
|
|
420
|
+
const eng = path.join(root, 'examples', ex, '.fde')
|
|
421
|
+
if (!fs.existsSync(eng)) continue
|
|
422
|
+
const r = spawnSync(process.execPath, [path.join(root, 'bin', 'fde.js'), 'doctor'], {
|
|
423
|
+
encoding: 'utf8',
|
|
424
|
+
env: { ...process.env, FDEOPS_ENGAGEMENT: eng, HOME: fs.mkdtempSync(path.join(require('os').tmpdir(), 'fdeops-check-')) },
|
|
425
|
+
})
|
|
426
|
+
const issues = (r.stdout || '').split('\n')
|
|
427
|
+
.map(l => l.match(/^\s+\d+\.\s+(.*)$/)).filter(Boolean).map(m => m[1])
|
|
428
|
+
.filter(i => !tolerated.test(i))
|
|
429
|
+
if (issues.length) fail(`examples/${ex} fails its own doctor:\n - ${issues.join('\n - ')}`)
|
|
430
|
+
else ok(`examples/${ex} passes fde doctor`)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
412
434
|
if (fs.existsSync(path.join(root, 'tasks', 'plan.md'))) {
|
|
413
435
|
fail('tasks/plan.md should not be in public tree (move to docs/internal)')
|
|
414
436
|
}
|
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) {
|
|
@@ -1515,8 +1570,8 @@ function smartProposeText(input) {
|
|
|
1515
1570
|
out.push(`decision: ${bare.replace(/^decided:\s+/i, '')}`)
|
|
1516
1571
|
continue
|
|
1517
1572
|
}
|
|
1518
|
-
if (/^(decision|risk|delivery|contact|next):\s*/i.test(bare)) {
|
|
1519
|
-
let routed = bare.replace(/^(decision|risk|delivery|contact|next):\s*/i, (m, t) => `${t.toLowerCase()}: `)
|
|
1573
|
+
if (/^(decision|risk|delivery|contact|next|signer):\s*/i.test(bare)) {
|
|
1574
|
+
let routed = bare.replace(/^(decision|risk|delivery|contact|next|signer):\s*/i, (m, t) => `${t.toLowerCase()}: `)
|
|
1520
1575
|
if (/^contact:/i.test(routed) && !/\[signal:(red|amber|green)\]/i.test(routed)) {
|
|
1521
1576
|
const sig = inferContactSignal(routed)
|
|
1522
1577
|
if (sig) routed = routed.replace(/\s*$/, ` [signal:${sig}]`)
|
|
@@ -1524,6 +1579,12 @@ function smartProposeText(input) {
|
|
|
1524
1579
|
out.push(routed)
|
|
1525
1580
|
continue
|
|
1526
1581
|
}
|
|
1582
|
+
// Sentence-level: a signer named mid-paragraph gets its own routed line and
|
|
1583
|
+
// the original stays as context, so nothing is invented or lost.
|
|
1584
|
+
for (const sentence of bare.split(/(?<=[.!?])\s+/)) {
|
|
1585
|
+
const who = signerFromLine(sentence)
|
|
1586
|
+
if (who) { out.push(`signer: ${who}`); break }
|
|
1587
|
+
}
|
|
1527
1588
|
if (/^(next action|follow-?ups?|action items?|todo):\s*/i.test(bare) ||
|
|
1528
1589
|
/\b(next action|walk in with|follow up with)\b/i.test(bare)) {
|
|
1529
1590
|
const next = bare.replace(/^(next action|follow-?ups?|action items?|todo):\s*/i, '').trim()
|
|
@@ -1549,6 +1610,59 @@ function smartProposeText(input) {
|
|
|
1549
1610
|
return out.join('\n') + (out.length ? '\n' : '')
|
|
1550
1611
|
}
|
|
1551
1612
|
|
|
1613
|
+
// "Priya signs off" is the most expensive sentence in a kickoff and used to land
|
|
1614
|
+
// in context.md as a note. signer: fills the success.md line the whole kit
|
|
1615
|
+
// keys on, and logs the person as a contact so prep/status can see them.
|
|
1616
|
+
const SIGNER_RX = /^(?<who>[A-Z][\w.'-]+(?:\s+[A-Z][\w.'-]+){0,3}(?:\s*\([^)]{1,40}\))?)\s+(?:signs?(?:\s+off)?|approves|has (?:the )?final say|can say yes|owns the decision|is the (?:sponsor|signer|decision[- ]maker))\b/
|
|
1617
|
+
|
|
1618
|
+
function signerFromLine(text) {
|
|
1619
|
+
const t = String(text || '').trim()
|
|
1620
|
+
const m = t.match(SIGNER_RX)
|
|
1621
|
+
if (!m) return ''
|
|
1622
|
+
const who = m.groups.who.trim()
|
|
1623
|
+
// "Staging exists" / "The API is slow" also match "Capital Word + verb"; a
|
|
1624
|
+
// sentence-initial common noun is not a person.
|
|
1625
|
+
if (/^(The|This|That|It|We|They|Staging|Budget|Prod|Production|Nobody|Someone|Everyone)\b/.test(who)) return ''
|
|
1626
|
+
return who
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
function setSigner(eng, who) {
|
|
1630
|
+
ensureMemoryGit(eng)
|
|
1631
|
+
const p = path.join(eng, 'success.md')
|
|
1632
|
+
let md = readEng(eng, 'success.md')
|
|
1633
|
+
if (!md) md = '# Success definition\n\n'
|
|
1634
|
+
const norm = (s) => String(s).replace(/\s+/g, ' ').trim().toLowerCase()
|
|
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
|
|
1639
|
+
const m = md.match(line)
|
|
1640
|
+
if (m && !m[1].trim()) {
|
|
1641
|
+
md = md.replace(line, `**Stakeholder who signs off:** ${who}`)
|
|
1642
|
+
} else if (m) {
|
|
1643
|
+
// Whole-name compare against the primary and every "also named" line under
|
|
1644
|
+
// it: "Sam" must not vanish inside "Samantha", and re-applying must not
|
|
1645
|
+
// stack duplicates.
|
|
1646
|
+
const start = md.indexOf(m[0]) + m[0].length
|
|
1647
|
+
const alsoNamed = []
|
|
1648
|
+
for (const l of md.slice(start).split('\n').slice(1)) {
|
|
1649
|
+
const a = l.match(/^- also named:\s*(.+)$/)
|
|
1650
|
+
if (!a) break
|
|
1651
|
+
alsoNamed.push(a[1])
|
|
1652
|
+
}
|
|
1653
|
+
const known = [m[1], ...alsoNamed].map(norm)
|
|
1654
|
+
if (known.includes(norm(who))) return false
|
|
1655
|
+
// A second, different name is a fact worth keeping next to the first, not
|
|
1656
|
+
// a silent overwrite - who signs is exactly the thing people argue about.
|
|
1657
|
+
const block = [m[0], ...alsoNamed.map(a => `- also named: ${a}`)].join('\n')
|
|
1658
|
+
md = md.replace(block, `${block}\n- also named: ${who}`)
|
|
1659
|
+
} else {
|
|
1660
|
+
md = md.replace(/\n*$/, `\n\n**Stakeholder who signs off:** ${who}\n`)
|
|
1661
|
+
}
|
|
1662
|
+
withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
|
|
1663
|
+
return true
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1552
1666
|
function setNextAction(eng, text) {
|
|
1553
1667
|
ensureMemoryGit(eng)
|
|
1554
1668
|
const bullet = `- ${stripControlChars(String(text).replace(/^[-*]\s+/, '').trim())}`
|
|
@@ -1665,7 +1779,7 @@ function readSealedProposal(eng) {
|
|
|
1665
1779
|
function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
|
|
1666
1780
|
const d = new Date()
|
|
1667
1781
|
const date = d.toISOString().slice(0, 10)
|
|
1668
|
-
const counts = { decision: 0, risk: 0, delivery: 0, contact: 0, next: 0 }
|
|
1782
|
+
const counts = { decision: 0, risk: 0, delivery: 0, contact: 0, next: 0, signer: 0 }
|
|
1669
1783
|
const ctxLines = []
|
|
1670
1784
|
let nextAction = ''
|
|
1671
1785
|
ensureMemoryGit(eng)
|
|
@@ -1678,8 +1792,8 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
|
|
|
1678
1792
|
for (const raw of routable.split('\n')) {
|
|
1679
1793
|
let line = raw.trim()
|
|
1680
1794
|
if (!line) continue
|
|
1681
|
-
const bare = line.replace(/^[-*+]\s+/, '').replace(/^\*\*(decision|risk|delivery|contact|next):?\*\*:?\s*/i, '$1: ')
|
|
1682
|
-
const m = bare.match(/^(decision|risk|delivery|contact|next):\s*(.+)$/i)
|
|
1795
|
+
const bare = line.replace(/^[-*+]\s+/, '').replace(/^\*\*(decision|risk|delivery|contact|next|signer):?\*\*:?\s*/i, '$1: ')
|
|
1796
|
+
const m = bare.match(/^(decision|risk|delivery|contact|next|signer):\s*(.+)$/i)
|
|
1683
1797
|
if (m) {
|
|
1684
1798
|
const type = m[1].toLowerCase()
|
|
1685
1799
|
let body = m[2]
|
|
@@ -1688,6 +1802,18 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
|
|
|
1688
1802
|
console.error(`skipped ${type} line - looks like a ${hit}. Redact it, or re-run with --force.`)
|
|
1689
1803
|
continue
|
|
1690
1804
|
}
|
|
1805
|
+
if (type === 'signer') {
|
|
1806
|
+
const who = body.replace(/\s+signs?(?:\s+off)?\b.*$/i, '').trim() || body.trim()
|
|
1807
|
+
if (dry) {
|
|
1808
|
+
console.log(`→ success.md **Stakeholder who signs off:** ${previewLine(who)}`)
|
|
1809
|
+
console.log(`→ stakeholders.md ${previewLine(datedEntry(eng, date, `${who} signs off`))}`)
|
|
1810
|
+
} else {
|
|
1811
|
+
setSigner(eng, who)
|
|
1812
|
+
appendLogEntry(eng, 'contact', datedEntry(eng, date, `${who} signs off`), { skipCommit: true })
|
|
1813
|
+
}
|
|
1814
|
+
counts.signer++
|
|
1815
|
+
continue
|
|
1816
|
+
}
|
|
1691
1817
|
if (type === 'next') {
|
|
1692
1818
|
if (dry) console.log(`→ context.md ## Next action - ${previewLine(body)}`)
|
|
1693
1819
|
else nextAction = body
|
|
@@ -1760,7 +1886,7 @@ function cmdDebrief(args) {
|
|
|
1760
1886
|
if (smart) {
|
|
1761
1887
|
const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
|
|
1762
1888
|
console.log('SMART PROPOSE (heuristic - review before apply; no new facts invented beyond line rewrites)\n')
|
|
1763
|
-
console.log('Prefix vocabulary (lines that route): decision: risk: delivery: contact: next:')
|
|
1889
|
+
console.log('Prefix vocabulary (lines that route): decision: risk: delivery: contact: next: signer:')
|
|
1764
1890
|
console.log('Everything else → context.md. Keep the prefixes; the preview gate stays.\n')
|
|
1765
1891
|
routeDebriefInput(eng, clean, { dry: true, force, sealed: blocks })
|
|
1766
1892
|
if (!apply) {
|
|
@@ -1776,7 +1902,7 @@ function cmdDebrief(args) {
|
|
|
1776
1902
|
const { counts, ctxLines, privateBlocks } = routeDebriefInput(eng, input, { dry, force, sealed })
|
|
1777
1903
|
if (!dry) {
|
|
1778
1904
|
const hash = commitMemory(eng, 'debrief', {
|
|
1779
|
-
files: ['decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md', 'context.md', SIGNAL_LEDGER],
|
|
1905
|
+
files: ['decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md', 'success.md', 'context.md', SIGNAL_LEDGER],
|
|
1780
1906
|
})
|
|
1781
1907
|
try { fs.unlinkSync(path.join(eng, DEBRIEF_PROPOSE)) } catch (_) {}
|
|
1782
1908
|
try { fs.unlinkSync(path.join(eng, DEBRIEF_PRIVATE)) } catch (_) {}
|
|
@@ -1784,7 +1910,7 @@ function cmdDebrief(args) {
|
|
|
1784
1910
|
if (hash) console.log(`memory @${hash}`)
|
|
1785
1911
|
}
|
|
1786
1912
|
const plural = {
|
|
1787
|
-
decision: 'decisions', risk: 'risks', delivery: 'deliveries', contact: 'contacts', next: 'next actions',
|
|
1913
|
+
decision: 'decisions', risk: 'risks', delivery: 'deliveries', contact: 'contacts', next: 'next actions', signer: 'signers',
|
|
1788
1914
|
}
|
|
1789
1915
|
const parts = Object.keys(counts).filter(t => counts[t])
|
|
1790
1916
|
.map(t => `${counts[t]} ${counts[t] === 1 ? (t === 'next' ? 'next action' : t) : plural[t]}`)
|
|
@@ -2099,8 +2225,10 @@ function cmdTriage() {
|
|
|
2099
2225
|
console.error('no engagement - run: fde resume --init <name>')
|
|
2100
2226
|
process.exit(2)
|
|
2101
2227
|
}
|
|
2102
|
-
// 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.
|
|
2103
2230
|
printTriageBlock(eng)
|
|
2231
|
+
for (const line of recordDigest(eng)) console.log(line)
|
|
2104
2232
|
const owner = readOwner(eng) || writeOwnerIfMissing(eng)
|
|
2105
2233
|
const head = memoryHead(eng)
|
|
2106
2234
|
if (owner || head) {
|
|
@@ -2176,6 +2304,66 @@ function findDuplicateOpenRisks(eng) {
|
|
|
2176
2304
|
return [...byKey.values()].filter(g => g.length >= 2)
|
|
2177
2305
|
}
|
|
2178
2306
|
|
|
2307
|
+
// The bound client repo moved and delivery.md did not. This is the one place
|
|
2308
|
+
// the CLI can catch "we shipped code and told the record nothing" without AI:
|
|
2309
|
+
// registry gives the workspace(s) bound to this engagement, git gives commits
|
|
2310
|
+
// newer than the last dated delivery line. Local reads only.
|
|
2311
|
+
// Only dates that stamp an entry count: a ledger row's Date cell, a dated
|
|
2312
|
+
// bullet, a dated heading. "trial night 2026-06-02" inside a Measured cell is a
|
|
2313
|
+
// promise, not a receipt - and a future promise must not hide today's commits.
|
|
2314
|
+
function latestDeliveryEntry(md) {
|
|
2315
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
2316
|
+
let latest = { date: '', line: '' }
|
|
2317
|
+
for (const raw of stripTemplateNoise(md).split('\n')) {
|
|
2318
|
+
const t = raw.trim()
|
|
2319
|
+
const m = t.match(/^[-*]\s*\[(\d{4}-\d{2}-\d{2})\]/) ||
|
|
2320
|
+
t.match(/^#{1,6}\s+\[?(\d{4}-\d{2}-\d{2})(?:\]|\b)/) ||
|
|
2321
|
+
t.match(/^\|\s*(\d{4}-\d{2}-\d{2})\s*\|/)
|
|
2322
|
+
if (!m || m[1] > today) continue
|
|
2323
|
+
if (m[1] >= latest.date) latest = { date: m[1], line: t }
|
|
2324
|
+
}
|
|
2325
|
+
return latest
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
function silentCommitIssues(eng) {
|
|
2329
|
+
const slug = path.basename(path.dirname(eng))
|
|
2330
|
+
const workspaces = readRegistry().filter(r => r.slug === slug).map(r => r.workspace)
|
|
2331
|
+
if (!workspaces.length) return []
|
|
2332
|
+
const entry = latestDeliveryEntry(readClean(eng, 'delivery.md'))
|
|
2333
|
+
const lastDelivery = entry.date
|
|
2334
|
+
const out = []
|
|
2335
|
+
for (const ws of workspaces) {
|
|
2336
|
+
let st
|
|
2337
|
+
try { st = fs.statSync(ws) } catch (_) { continue }
|
|
2338
|
+
if (!st.isDirectory()) continue
|
|
2339
|
+
// The memory folder is itself a git repo; never lint it as the client repo.
|
|
2340
|
+
if (path.resolve(ws) === path.resolve(eng) || path.resolve(ws) === path.dirname(path.resolve(eng))) continue
|
|
2341
|
+
if (sh('git rev-parse --is-inside-work-tree', ws) !== 'true') continue
|
|
2342
|
+
// Memory git knows the exact moment that entry was written; dates in the
|
|
2343
|
+
// file are day-grained and would miss a commit made later the same day.
|
|
2344
|
+
// Pickaxe on the entry text, not the file: a later status edit to
|
|
2345
|
+
// delivery.md must not become the cutoff and hide commits before it.
|
|
2346
|
+
const raw = entry.line
|
|
2347
|
+
? sh(`git log -1 --format=%cI -S${JSON.stringify(entry.line)} -- delivery.md`, eng)
|
|
2348
|
+
: ''
|
|
2349
|
+
// git --since is inclusive at second grain; a commit in the same second as
|
|
2350
|
+
// the receipt is the receipt's own work, not a silent one.
|
|
2351
|
+
const stamp = raw && !Number.isNaN(Date.parse(raw)) ? new Date(Date.parse(raw) + 1000).toISOString() : ''
|
|
2352
|
+
const since = stamp ? `--since="${stamp}"`
|
|
2353
|
+
: lastDelivery ? `--since="${lastDelivery} 23:59:59"`
|
|
2354
|
+
: "--since='30 days ago'"
|
|
2355
|
+
const commits = sh(`git log ${since} --format=%h -- .`, ws).split('\n').filter(Boolean).length
|
|
2356
|
+
if (!commits) continue
|
|
2357
|
+
const where = path.basename(ws)
|
|
2358
|
+
out.push(
|
|
2359
|
+
lastDelivery
|
|
2360
|
+
? `${commits} commit(s) in ${where} since the last delivery line (${lastDelivery}) - code moved, ledger did not; log the receipt or say why nothing shipped`
|
|
2361
|
+
: `${commits} commit(s) in ${where} in 30d and delivery.md has no dated line - code moved, ledger did not; fde log delivery "slice | bucket | promised | measured | accepted | evidence | rollback"`
|
|
2362
|
+
)
|
|
2363
|
+
}
|
|
2364
|
+
return out
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2179
2367
|
// Deterministic fieldbook hygiene - shared by doctor + session TRIAGE.
|
|
2180
2368
|
// Silent when clean OR brand-new (no dated work yet). Never auto-rewrites.
|
|
2181
2369
|
// High-value moments: week-start (via triage), ship/close, after real work accrues.
|
|
@@ -2246,9 +2434,11 @@ function collectDoctorIssues(eng) {
|
|
|
2246
2434
|
'duplicate ## Next action headings in context.md - fill the first (template) section and remove extras; triage reads the last non-empty'
|
|
2247
2435
|
)
|
|
2248
2436
|
}
|
|
2249
|
-
|
|
2437
|
+
// Open, owned risks are normal mid-ship (triage already shows the count every
|
|
2438
|
+
// session). The gate is close: nothing still live when you call the embed done.
|
|
2439
|
+
if (s.phase === 'close' && s.openRisks > 0) {
|
|
2250
2440
|
issues.push(
|
|
2251
|
-
`phase is
|
|
2441
|
+
`phase is close with ${s.openRisks} open risk(s) - retire, hand off, or move still-live ones before calling the embed done`
|
|
2252
2442
|
)
|
|
2253
2443
|
}
|
|
2254
2444
|
if (s.phase === 'close' || s.phase === 'ship') {
|
|
@@ -2267,7 +2457,15 @@ function collectDoctorIssues(eng) {
|
|
|
2267
2457
|
issues.push(
|
|
2268
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`
|
|
2269
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
|
+
}
|
|
2270
2467
|
}
|
|
2468
|
+
issues.push(...silentCommitIssues(eng))
|
|
2271
2469
|
}
|
|
2272
2470
|
const dupes = findDuplicateOpenRisks(eng)
|
|
2273
2471
|
if (dupes.length) {
|
|
@@ -2283,6 +2481,14 @@ function collectDoctorIssues(eng) {
|
|
|
2283
2481
|
`phase is ${s.phase} with empty operating map - fill terrain.md ## Operating map (exception-led): break → who notices → workaround → evidence`
|
|
2284
2482
|
)
|
|
2285
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
|
+
}
|
|
2286
2492
|
const aliases = findAmbiguousStakeholders(eng)
|
|
2287
2493
|
if (aliases.length) {
|
|
2288
2494
|
const sample = aliases[0].forms.slice(0, 3).join(' / ')
|
|
@@ -2296,9 +2502,11 @@ function collectDoctorIssues(eng) {
|
|
|
2296
2502
|
}
|
|
2297
2503
|
|
|
2298
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.
|
|
2299
2507
|
function hasOperatingMapContent(eng) {
|
|
2300
2508
|
const terrain = stripTemplateNoise(readClean(eng, 'terrain.md'))
|
|
2301
|
-
const body = sectionBody(terrain, 'Operating map')
|
|
2509
|
+
const body = sectionBody(terrain, 'Operating map', { lastNonEmpty: true })
|
|
2302
2510
|
if (!body.trim()) return false
|
|
2303
2511
|
const table = parseMdTable(body)
|
|
2304
2512
|
if (table) {
|
|
@@ -2388,7 +2596,7 @@ function hasValueBucket(eng) {
|
|
|
2388
2596
|
if (bucketLine && VALUE_BUCKET_RE.test(bucketLine[1].trim())) return true
|
|
2389
2597
|
if (!/\*\*Primary value bucket:\*\*/i.test(success) && VALUE_BUCKET_RE.test(success)) return true
|
|
2390
2598
|
|
|
2391
|
-
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 }) || ''))
|
|
2392
2600
|
const table = parseMdTable(ledger)
|
|
2393
2601
|
if (table) {
|
|
2394
2602
|
const bIdx = colIndex(table.headers, /bucket/i)
|
|
@@ -2415,8 +2623,13 @@ const PENDING_CELL_RE =
|
|
|
2415
2623
|
/^(?:pending|tbd|to ?be ?(?:measured|confirmed|determined)|n\s*\/\s*a|na|none|unknown|not measured|\?+|\.{2,}|…|-+|-+|-+)(?:[^\w].*)?$/i
|
|
2416
2624
|
|
|
2417
2625
|
function parseValueLedger(eng) {
|
|
2418
|
-
|
|
2419
|
-
|
|
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) || '')
|
|
2420
2633
|
if (!table) return { rows: [], columnMissing: false }
|
|
2421
2634
|
const idx = {
|
|
2422
2635
|
slice: colIndex(table.headers, /slice/i),
|
|
@@ -2469,17 +2682,46 @@ function valueLedgerStatusLines(eng, opts = {}) {
|
|
|
2469
2682
|
return lines
|
|
2470
2683
|
}
|
|
2471
2684
|
|
|
2472
|
-
// AI in scope for ship/close hygiene -
|
|
2685
|
+
// AI in scope for ship/close hygiene - the FDE's own words, wherever they wrote them.
|
|
2473
2686
|
// Do not scan terrain.md: its template headers mention LLM and would false-positive every ship.
|
|
2474
2687
|
function engagementTouchesAI(eng) {
|
|
2475
2688
|
const trust = readClean(eng, 'trust-profile.md')
|
|
2476
|
-
const aiSec = stripTemplateNoise(sectionBody(trust, 'AI policy') || '')
|
|
2689
|
+
const aiSec = stripTemplateNoise(sectionBody(trust, 'AI policy', { lastNonEmpty: true }) || '')
|
|
2477
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.
|
|
2478
2697
|
const blob = stripTemplateNoise([
|
|
2479
2698
|
readClean(eng, 'delivery.md'),
|
|
2480
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'),
|
|
2481
2704
|
].join('\n'))
|
|
2482
|
-
|
|
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
|
|
2483
2725
|
}
|
|
2484
2726
|
|
|
2485
2727
|
function hasEvalReceipt(eng) {
|
|
@@ -2492,9 +2734,9 @@ function hasEvalReceipt(eng) {
|
|
|
2492
2734
|
if (/\|\s*G\d+\s*\|[^|\n]+\|[^|\n]+\|[^|\n]+\|[^|\n]+\|\s*pass\s*\|/i.test(e)) return true
|
|
2493
2735
|
}
|
|
2494
2736
|
const del = stripLegendLines(stripTemplateNoise(readClean(eng, 'delivery.md')))
|
|
2495
|
-
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
|
|
2496
2738
|
if (/\beval (pack|receipt)[:\s].*\b(pass|SHIP)\b/i.test(del)) return true
|
|
2497
|
-
const receipts = sectionBody(del, 'Ship receipts') || ''
|
|
2739
|
+
const receipts = sectionBody(del, 'Ship receipts', { lastNonEmpty: true }) || ''
|
|
2498
2740
|
if (/\bevals\.md\b/i.test(receipts) && /\b(pass|SHIP)\b/i.test(receipts) && !/\*\([^)]*evals\.md[^)]*\)\*/i.test(receipts)) {
|
|
2499
2741
|
return true
|
|
2500
2742
|
}
|
|
@@ -2517,6 +2759,31 @@ function printTriageBlock(eng) {
|
|
|
2517
2759
|
for (const line of hygieneTriageLines(eng)) console.log(line)
|
|
2518
2760
|
}
|
|
2519
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
|
+
|
|
2520
2787
|
function cmdDoctor() {
|
|
2521
2788
|
const eng = resolveEngagement()
|
|
2522
2789
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
@@ -2854,12 +3121,14 @@ function cmdStatus(args) {
|
|
|
2854
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) })
|
|
2855
3122
|
}
|
|
2856
3123
|
if (!rows.length) { console.log('no engagements yet'); return }
|
|
2857
|
-
|
|
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 }
|
|
2858
3126
|
rows.sort((a, b) => order[a.trust] - order[b.trust])
|
|
2859
3127
|
console.log((all ? 'FDE PORTFOLIO' : 'FDE STATUS') + ' - value first, then trust\n')
|
|
2860
3128
|
for (const r of rows) {
|
|
2861
3129
|
for (const line of r.valueLines) console.log(line)
|
|
2862
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.
|
|
2863
3132
|
const label = r.trust + (r.stale ? '?' : '')
|
|
2864
3133
|
const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
|
|
2865
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}`)
|
|
@@ -2914,7 +3183,7 @@ function cmdDashboard(args) {
|
|
|
2914
3183
|
}
|
|
2915
3184
|
engagements = gatherEngagements({ only: eng })
|
|
2916
3185
|
}
|
|
2917
|
-
const counts = { green: 0, amber: 0, RED: 0 }
|
|
3186
|
+
const counts = { green: 0, amber: 0, RED: 0, new: 0 }
|
|
2918
3187
|
engagements.forEach(e => { counts[e.signals.trust]++ })
|
|
2919
3188
|
const today = render.formatToday(new Date())
|
|
2920
3189
|
|
|
@@ -2969,7 +3238,7 @@ function cmdDashboard(args) {
|
|
|
2969
3238
|
failFs(e, 'write fieldbook', outPath)
|
|
2970
3239
|
}
|
|
2971
3240
|
console.log(`fieldbook → ${outPath}`)
|
|
2972
|
-
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)`)
|
|
2973
3242
|
if (!all) {
|
|
2974
3243
|
const current = resolveEngagement()
|
|
2975
3244
|
if (current) for (const line of hygieneTriageLines(current)) console.log(line)
|
|
@@ -3000,7 +3269,7 @@ function cliVersion() {
|
|
|
3000
3269
|
}
|
|
3001
3270
|
|
|
3002
3271
|
function valueLedgerRows(eng) {
|
|
3003
|
-
const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger') || '')
|
|
3272
|
+
const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger', { lastNonEmpty: true }) || '')
|
|
3004
3273
|
const table = parseMdTable(ledger)
|
|
3005
3274
|
if (!table) return []
|
|
3006
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()
|
|
@@ -194,6 +200,9 @@ function createTrustApi(deps) {
|
|
|
194
200
|
}) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
195
201
|
// Prefer trust trigger / memory warn over a random risk line; always keep mem.warn available
|
|
196
202
|
const reason = (trustReason || mem.warn) ? (trustReason || mem.warn) : topRisk
|
|
203
|
+
// What the triage line is actually quoting. A risk bullet printed under
|
|
204
|
+
// "trust:" read as a stakeholder problem that did not exist.
|
|
205
|
+
const reasonKind = mem.warn && trustReason === mem.warn ? 'memory' : trustReason ? 'trust' : mem.warn ? 'memory' : topRisk ? 'risk' : ''
|
|
197
206
|
const openRisks = countOpenRisks(eng)
|
|
198
207
|
const nextAction = nextActionLine(ctx)
|
|
199
208
|
let updated = 'never', ageDays = Infinity
|
|
@@ -203,7 +212,7 @@ function createTrustApi(deps) {
|
|
|
203
212
|
} catch (_) {}
|
|
204
213
|
const dirty = memoryDirtyManual(eng)
|
|
205
214
|
return {
|
|
206
|
-
phase, trust, signalAge, stale, topRisk, reason, memoryWarn: mem.warn,
|
|
215
|
+
phase, trust, signalAge, stale, topRisk, reason, reasonKind, memoryWarn: mem.warn,
|
|
207
216
|
dirtyFiles: dirty, openRisks, nextAction, updated, ageDays,
|
|
208
217
|
}
|
|
209
218
|
}
|
|
@@ -215,9 +224,9 @@ function createTrustApi(deps) {
|
|
|
215
224
|
const lines = [
|
|
216
225
|
`TRIAGE [${label.padEnd(6)}] phase:${phase} updated:${s.updated} open risks:${s.openRisks}`,
|
|
217
226
|
]
|
|
218
|
-
if (s.reason) {
|
|
219
|
-
const age = s.signalAge != null ? ` (${s.signalAge}d old${s.stale ? ', STALE - reconfirm' : ''})` : ''
|
|
220
|
-
lines.push(` trust: ${s.reason}${age}`)
|
|
227
|
+
if (s.reason && s.reasonKind !== 'memory') {
|
|
228
|
+
const age = s.reasonKind === 'trust' && s.signalAge != null ? ` (${s.signalAge}d old${s.stale ? ', STALE - reconfirm' : ''})` : ''
|
|
229
|
+
lines.push(` ${s.reasonKind === 'risk' ? 'top risk' : 'trust'}: ${s.reason}${age}`)
|
|
221
230
|
}
|
|
222
231
|
// Always surface corruption / unreadable memory - even when trust still reads green
|
|
223
232
|
if (s.memoryWarn) lines.push(` memory: ${s.memoryWarn}`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fdeops",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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",
|
package/skills/fde/SKILL.md
CHANGED
|
@@ -42,6 +42,8 @@ On someone else's site the work is not "write code, remember later." Every chang
|
|
|
42
42
|
|
|
43
43
|
A throwaway file can skip the loop. Bound client work cannot.
|
|
44
44
|
|
|
45
|
+
**Skip is loud.** Bound + a change that will ship + no this-turn line in `delivery.md` = not done. Say that. Do not call it shipped. A coding pack may write the function; `@fde` still owns done.
|
|
46
|
+
|
|
45
47
|
## Human surface vs agent plumbing
|
|
46
48
|
|
|
47
49
|
**FDE (human):** `@fde` + English, or `/brief` `/discover` `/plan` `/ship` `/outcome` `/close` `/debrief` `/prep` `/trust` `/receipts` `/readout`. Never a skill catalog.
|
|
@@ -196,8 +198,8 @@ Ready to build with no `terrain.md` / plan: discover or plan first. Takeover wit
|
|
|
196
198
|
|
|
197
199
|
- Never ask the FDE to pick a phase. That's your job.
|
|
198
200
|
- Same six stages at any scale. Overlays carry the industry. Greenfield and brownfield change the first move inside ship, not the map.
|
|
199
|
-
- Ground loop on a bound client: name → characterise → prove on their staging → go live → log.
|
|
200
|
-
- Do not call a change done until the signer in `success.md` can reject it on staging they operate.
|
|
201
|
+
- Ground loop on a bound client: name → characterise → prove on their staging → go live → log. A coding pack may write the function. `@fde` still owns done. When they disagree, their repo and the signer win.
|
|
202
|
+
- Do not call a change done until the signer in `success.md` can reject it on staging they operate. No this-turn receipt in `delivery.md` is a failed test, not a note to write later.
|
|
201
203
|
- Read `context.md` before speaking. One sharp question - never a barrage.
|
|
202
204
|
- Never invent people, meetings, or numbers - `unknown - ask:` beats a polished lie.
|
|
203
205
|
- Every phase ends with its artifact written. No artifact, no "done."
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
## Honest contract (read once)
|
|
12
12
|
|
|
13
13
|
- The `fde` CLI is **local, deterministic, no AI**. `--smart` is a **gate + writer**, not a brain.
|
|
14
|
-
- It keeps lines that already have `decision:` / `risk:` / `delivery:` / `contact:` / `next:` prefixes, plus a thin keyword pass (e.g. "we agreed", person+verb lines, "open question").
|
|
14
|
+
- It keeps lines that already have `decision:` / `risk:` / `delivery:` / `contact:` / `next:` / `signer:` prefixes, plus a thin keyword pass (e.g. "we agreed", person+verb lines, "open question", "X signs off").
|
|
15
|
+
- `signer: Priya` fills **Stakeholder who signs off** in `success.md` and logs Priya as a contact. The CLI proposes it when a sentence says someone signs off / approves / has final say. If the notes name who can say yes and the proposal does not carry a `signer:` line, add one - that is the most expensive sentence in the meeting.
|
|
15
16
|
- Real messy notes without prefixes often route **0 useful lines** - everything else lands as a context dump. That is expected. **You are the router:** rewrite `.debrief-propose` with type prefixes, then `--apply`.
|
|
16
17
|
- `.debrief-propose` is raw lines only (no routing annotations). "Edit if mis-routed" means **rewrite the line with the right prefix**, not leave a comment in the file.
|
|
17
18
|
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
- `decision: agreed chargebacks stay phase 2 - Priya`
|
|
26
27
|
- `risk: legal may reopen scope if we slip the SOW date`
|
|
27
28
|
- `contact: Priya pushed hard on Friday deck [signal:amber]`
|
|
29
|
+
- `signer: Priya` (she can say yes; lands in `success.md`)
|
|
28
30
|
- `next: send one-pager before Thursday 9am`
|
|
29
31
|
- unprefixed lines stay context color only
|
|
30
32
|
4. Show the **proposed** routing in plain language (what would become decisions, risks, contacts, next).
|
|
@@ -43,7 +45,7 @@ If `--smart` is unavailable or you already have clean prefixes:
|
|
|
43
45
|
- **Stakeholder signals** - tone shifts with evidence → green/amber/red
|
|
44
46
|
- **Risks** - new / confirmed / retired
|
|
45
47
|
- **Open questions** - what to chase next
|
|
46
|
-
2. Format lines as `decision:` / `risk:` / `delivery:` / `contact:` / `next:` (contacts may end with `[signal:green|amber|red]`).
|
|
48
|
+
2. Format lines as `decision:` / `risk:` / `delivery:` / `contact:` / `next:` / `signer:` (contacts may end with `[signal:green|amber|red]`).
|
|
47
49
|
3. Show that structured version to the FDE for confirmation.
|
|
48
50
|
4. Pipe to `fde debrief` (or write a file and run it).
|
|
49
51
|
|
|
@@ -31,7 +31,7 @@ List what you can actually call **this session**:
|
|
|
31
31
|
3. **Stage** - `fde ingest stage [--source NAME] [--title TEXT] [file|-]` writes raw text into `<engagement>/.inbox/` (outside the memory git ledger).
|
|
32
32
|
4. **List** (optional) - `fde ingest list` shows staged items when you need an id or filename.
|
|
33
33
|
5. **Propose** - `fde ingest propose <id-or-filename>` runs the debrief `--smart` path on the staged body (+ provenance line). Opens `.debrief-propose`.
|
|
34
|
-
6. **Rewrite prefixes** - same as debrief: lines without `decision:` / `risk:` / `delivery:` / `contact:` / `next:` need **you** to rewrite before showing the FDE. `--smart` is a gate, not a brain.
|
|
34
|
+
6. **Rewrite prefixes** - same as debrief: lines without `decision:` / `risk:` / `delivery:` / `contact:` / `next:` / `signer:` need **you** to rewrite before showing the FDE. `--smart` is a gate, not a brain.
|
|
35
35
|
7. **Show** the proposed routing in plain language. Wait for confirm.
|
|
36
36
|
8. **Apply** - on FDE confirm only → `fde ingest apply` (= `fde debrief --apply`). On reject → stop; ask what to change.
|
|
37
37
|
|
|
@@ -56,6 +56,10 @@ Each change is independently revertible.
|
|
|
56
56
|
- [ ] Rollback named: revert this change, or something more specific
|
|
57
57
|
- [ ] No dependency on an unmerged change (if dependent, state it and land in order)
|
|
58
58
|
- [ ] `Kill if` is written - the observation that stops this change
|
|
59
|
+
- [ ] Before-receipt captured: the failing output, number, or screen as it is today, dated in `delivery.md`, before you change anything
|
|
60
|
+
- [ ] Open PRs and uncommitted work in the area checked (`gh pr list`, `gh pr diff <n> --name-only`); overlap goes to `decisions.md` before you start
|
|
61
|
+
|
|
62
|
+
Your coding pack writes the function. This skill owns done. When they disagree with this repo, the repo wins.
|
|
59
63
|
|
|
60
64
|
**The loop.** In this order:
|
|
61
65
|
|
|
@@ -72,7 +76,7 @@ Read existing code in the area (search before creating)
|
|
|
72
76
|
|
|
73
77
|
**Prove it on their staging.** A green check on your laptop is not delivery.
|
|
74
78
|
|
|
75
|
-
- Run **their** test command, on **their** CI, with **their** fixtures. Write the command and the result in `delivery.md
|
|
79
|
+
- Run **their** test command, on **their** CI, with **their** fixtures. Write the command and the result in `delivery.md` in this turn. You do not add a runner they will not keep. If you have not run their command in this turn, you cannot write that it passed. Last session's green, "should pass," and "looks correct" are not a receipt. Missing this-turn line = not proven. Same as a failing test.
|
|
76
80
|
- If the signer in `success.md` cannot reject this on a screen they already use, it is not proven.
|
|
77
81
|
- Staging they operate beats a local demo. If you have no staging: `unknown - ask:` who owns an environment, then stop pretending it shipped.
|
|
78
82
|
- **Monday-shaped data.** Staging that is empty, synthetic, or last quarter is not next Tuesday. Before go-live, write what staging is missing (volume, PII, the batch that only runs in prod, the account that only exists in the warehouse) and what that means for the kill test. If the signer cannot reject it on a screen they already operate, with data that looks like next Tuesday, it is not proven.
|