fdeops 3.22.0 → 3.22.2

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