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 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 sectionBody(md, heading, opts) {
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
- let first = ''
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
- const text = body.join('\n').trim()
651
- if (!seen) { first = text; seen = true }
652
- if (text) lastFilled = text
650
+ out.push(body.join('\n').trim())
653
651
  }
654
- if (!seen) return ''
655
- return preferLast ? (lastFilled || first) : first
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
- let inLedger = false
807
- let lastTableLine = -1
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])) { inLedger = true; continue }
810
- if (inLedger && /^##\s+/.test(lines[i])) break
811
- if (inLedger && /^\|/.test(lines[i].trim())) lastTableLine = i
812
- }
813
- if (lastTableLine === -1) md = appendUnderSection(md, 'Value ledger', row)
814
- else {
815
- lines.splice(lastTableLine + 1, 0, row)
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, /openai|anthropic|\bllm\b|gpt-|claude|embedding|vector store|model inference|inference (?:api|endpoint|server|engine)/i, 10)
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), then memory.
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
- if ((s.phase === 'close' || s.phase === 'ship') && s.openRisks > 0) {
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 ${s.phase} with ${s.openRisks} open risk(s) - retire, hand off, or move still-live ones before calling the embed done`
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
- const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger') || '')
2419
- const table = parseMdTable(ledger)
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 - delivery/decisions/trust evidence only.
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
- return /\b(llm|rag|embedding|inference|model card|agentic|openai|anthropic|vector database|vector db)\b/i.test(blob)
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
- const order = { RED: 0, amber: 1, green: 2 }
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: node bin/install.js init <engagement-name>')
296
- console.error(' Example: node bin/install.js init retailbank-payments')
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. Open your workspace for this engagement')
322
- console.log(` 2. Point your AI coding agent at: FDEOPS_ENGAGEMENT=${fdeDir}`)
323
- console.log(' 3. In the AI chat (not email), type: @fde and describe what is happening')
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(' node bin/install.js init <engagement-name>')
343
+ console.log(' fdeops init <engagement-name>')
338
344
  console.log('')
339
345
  console.log(' Example:')
340
- console.log(' node bin/install.js init garvey-payments')
341
- console.log(' (npm 3.0.0+: npx fdeops@latest init <engagement-name>)')
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(' node bin/install.js adapters <engagement-workspace>')
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
- function trustWord(t) { return t === 'green' ? 'steady' : t === 'amber' ? 'watch' : 'at risk' }
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
- const sLines = stake.split('\n').filter(l => !(/green/i.test(l) && /red|amber/i.test(l)))
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' : 'green'
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}`)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops-ingest-mcp",
3
- "version": "3.21.0",
3
+ "version": "3.22.1",
4
4
  "private": true,
5
5
  "description": "Thin stdio MCP sink for FDEOps ingest (stage → propose → apply). Zero runtime dependencies.",
6
6
  "bin": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.21.0",
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.21.0",
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",
@@ -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. Do not hand their repo to a generic coding pack.
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`. 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.
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.