fdeops 3.22.0 → 3.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/fde.js 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) {
@@ -1577,7 +1632,10 @@ function setSigner(eng, who) {
1577
1632
  let md = readEng(eng, 'success.md')
1578
1633
  if (!md) md = '# Success definition\n\n'
1579
1634
  const norm = (s) => String(s).replace(/\s+/g, ' ').trim().toLowerCase()
1580
- const line = /^\*\*Stakeholder who signs off:\*\*\s*(.*)$/m
1635
+ // [^\S\n], not \s: \s crosses newlines, so an empty field captured the next
1636
+ // line - and setSigner then read a filled field and filed the name as "also
1637
+ // named" under whatever heading followed.
1638
+ const line = /^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m
1581
1639
  const m = md.match(line)
1582
1640
  if (m && !m[1].trim()) {
1583
1641
  md = md.replace(line, `**Stakeholder who signs off:** ${who}`)
@@ -2167,8 +2225,10 @@ function cmdTriage() {
2167
2225
  console.error('no engagement - run: fde resume --init <name>')
2168
2226
  process.exit(2)
2169
2227
  }
2170
- // Session-start hooks call this - hygiene is proactive here (silent when clean).
2228
+ // Session-start hooks call this - hygiene is proactive here (silent when clean),
2229
+ // and the record digest travels with it so a fresh session knows who signs.
2171
2230
  printTriageBlock(eng)
2231
+ for (const line of recordDigest(eng)) console.log(line)
2172
2232
  const owner = readOwner(eng) || writeOwnerIfMissing(eng)
2173
2233
  const head = memoryHead(eng)
2174
2234
  if (owner || head) {
@@ -2397,6 +2457,13 @@ function collectDoctorIssues(eng) {
2397
2457
  issues.push(
2398
2458
  `phase is ${s.phase} with AI in scope but no eval receipt (evals.md Verdict or delivery Eval / Ship receipts) - required before green ship/close`
2399
2459
  )
2460
+ } else if (!engagementTouchesAI(eng)) {
2461
+ const hit = workspaceAIHit(eng)
2462
+ if (hit) {
2463
+ issues.push(
2464
+ `the bound workspace calls a model (${hit.file}) but nothing in the record says AI is in scope - the eval gate is off; record it: fde log decision "AI in scope: …"`
2465
+ )
2466
+ }
2400
2467
  }
2401
2468
  issues.push(...silentCommitIssues(eng))
2402
2469
  }
@@ -2414,6 +2481,14 @@ function collectDoctorIssues(eng) {
2414
2481
  `phase is ${s.phase} with empty operating map - fill terrain.md ## Operating map (exception-led): break → who notices → workaround → evidence`
2415
2482
  )
2416
2483
  }
2484
+ // A second copy of a heading the gates read: they take the last filled one, so
2485
+ // the record is ambiguous rather than lost. Say so once, here.
2486
+ // Full heading names only: "Value" would also match "## Value ledger".
2487
+ for (const [file, heading] of [['terrain.md', 'Operating map'], ['delivery.md', 'Value ledger']]) {
2488
+ if (countSections(readClean(eng, file), heading) > 1) {
2489
+ issues.push(`duplicate ## ${heading} headings in ${file} - merge into one section; the gates read the last filled one`)
2490
+ }
2491
+ }
2417
2492
  const aliases = findAmbiguousStakeholders(eng)
2418
2493
  if (aliases.length) {
2419
2494
  const sample = aliases[0].forms.slice(0, 3).join(' / ')
@@ -2427,9 +2502,11 @@ function collectDoctorIssues(eng) {
2427
2502
  }
2428
2503
 
2429
2504
  // True when ## Operating map has at least one real exception row (not the empty template).
2505
+ // lastNonEmpty: an agent that appends a filled section leaves the empty template
2506
+ // heading above it. Reading the first match called that work invisible.
2430
2507
  function hasOperatingMapContent(eng) {
2431
2508
  const terrain = stripTemplateNoise(readClean(eng, 'terrain.md'))
2432
- const body = sectionBody(terrain, 'Operating map')
2509
+ const body = sectionBody(terrain, 'Operating map', { lastNonEmpty: true })
2433
2510
  if (!body.trim()) return false
2434
2511
  const table = parseMdTable(body)
2435
2512
  if (table) {
@@ -2519,7 +2596,7 @@ function hasValueBucket(eng) {
2519
2596
  if (bucketLine && VALUE_BUCKET_RE.test(bucketLine[1].trim())) return true
2520
2597
  if (!/\*\*Primary value bucket:\*\*/i.test(success) && VALUE_BUCKET_RE.test(success)) return true
2521
2598
 
2522
- const ledger = stripLegendLines(stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger') || ''))
2599
+ const ledger = stripLegendLines(stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger', { lastNonEmpty: true }) || ''))
2523
2600
  const table = parseMdTable(ledger)
2524
2601
  if (table) {
2525
2602
  const bIdx = colIndex(table.headers, /bucket/i)
@@ -2546,8 +2623,13 @@ const PENDING_CELL_RE =
2546
2623
  /^(?:pending|tbd|to ?be ?(?:measured|confirmed|determined)|n\s*\/\s*a|na|none|unknown|not measured|\?+|\.{2,}|…|-+|-+|-+)(?:[^\w].*)?$/i
2547
2624
 
2548
2625
  function parseValueLedger(eng) {
2549
- const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger') || '')
2550
- 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) || '')
2551
2633
  if (!table) return { rows: [], columnMissing: false }
2552
2634
  const idx = {
2553
2635
  slice: colIndex(table.headers, /slice/i),
@@ -2600,17 +2682,46 @@ function valueLedgerStatusLines(eng, opts = {}) {
2600
2682
  return lines
2601
2683
  }
2602
2684
 
2603
- // AI in scope for ship/close hygiene - delivery/decisions/trust evidence only.
2685
+ // AI in scope for ship/close hygiene - the FDE's own words, wherever they wrote them.
2604
2686
  // Do not scan terrain.md: its template headers mention LLM and would false-positive every ship.
2605
2687
  function engagementTouchesAI(eng) {
2606
2688
  const trust = readClean(eng, 'trust-profile.md')
2607
- const aiSec = stripTemplateNoise(sectionBody(trust, 'AI policy') || '')
2689
+ const aiSec = stripTemplateNoise(sectionBody(trust, 'AI policy', { lastNonEmpty: true }) || '')
2608
2690
  if (aiSec.trim().length > 20) return true
2691
+ // Not **AI code policy:** - that field is about the FDE's own agent writing
2692
+ // code ("permitted with human review"), which every engagement now has. AI in
2693
+ // the shipped product is a different claim, and only the record's own words
2694
+ // below can make it.
2695
+ // brief/success/risks included: an engagement is often declared AI in the brief
2696
+ // or in a risk ("nobody can say what the accuracy was") and never again.
2609
2697
  const blob = stripTemplateNoise([
2610
2698
  readClean(eng, 'delivery.md'),
2611
2699
  readClean(eng, 'decisions.md'),
2700
+ readClean(eng, 'brief.md'),
2701
+ readClean(eng, 'success.md'),
2702
+ readClean(eng, 'risks.md'),
2703
+ readClean(eng, 'assumptions.md'),
2612
2704
  ].join('\n'))
2613
- 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
2614
2725
  }
2615
2726
 
2616
2727
  function hasEvalReceipt(eng) {
@@ -2623,9 +2734,9 @@ function hasEvalReceipt(eng) {
2623
2734
  if (/\|\s*G\d+\s*\|[^|\n]+\|[^|\n]+\|[^|\n]+\|[^|\n]+\|\s*pass\s*\|/i.test(e)) return true
2624
2735
  }
2625
2736
  const del = stripLegendLines(stripTemplateNoise(readClean(eng, 'delivery.md')))
2626
- if (/#{1,6}\s+Eval\b/i.test(del) && /\b(pass|SHIP|\d+\/\d+)\b/i.test(sectionBody(del, 'Eval') || del)) return true
2737
+ if (/#{1,6}\s+Eval\b/i.test(del) && /\b(pass|SHIP|\d+\/\d+)\b/i.test(sectionBody(del, 'Eval', { lastNonEmpty: true }) || del)) return true
2627
2738
  if (/\beval (pack|receipt)[:\s].*\b(pass|SHIP)\b/i.test(del)) return true
2628
- const receipts = sectionBody(del, 'Ship receipts') || ''
2739
+ const receipts = sectionBody(del, 'Ship receipts', { lastNonEmpty: true }) || ''
2629
2740
  if (/\bevals\.md\b/i.test(receipts) && /\b(pass|SHIP)\b/i.test(receipts) && !/\*\([^)]*evals\.md[^)]*\)\*/i.test(receipts)) {
2630
2741
  return true
2631
2742
  }
@@ -2648,6 +2759,31 @@ function printTriageBlock(eng) {
2648
2759
  for (const line of hygieneTriageLines(eng)) console.log(line)
2649
2760
  }
2650
2761
 
2762
+ // What a session must not have to ask for: who signs, what was promised, what was
2763
+ // decided. Read-only, and from the same places the writers use - the signer is
2764
+ // success.md **Stakeholder who signs off** (what `signer:` fills), never a role
2765
+ // guess out of stakeholders.md, where contacts live. Bounded on purpose (<= 6
2766
+ // lines): this is injected into every session.
2767
+ function recordDigest(eng) {
2768
+ const success = stripTemplateNoise(readClean(eng, 'success.md'))
2769
+ const signer = ((success.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
2770
+ // "(none)" rather than a missing line: on session start, nobody named to sign
2771
+ // off is the fact worth seeing, not an absence to scroll past.
2772
+ const lines = [` signer: ${signer.slice(0, 110) || '(none)'}`]
2773
+ const { rows } = parseValueLedger(eng)
2774
+ const promisedRow = [...rows].reverse().find(r => r.promised)
2775
+ if (promisedRow) {
2776
+ lines.push(` promised: ${formatValueLedgerLine(promisedRow).slice(0, 110)}`)
2777
+ } else {
2778
+ const target = ((success.match(/^\*\*Baseline[^\S\n]*→[^\S\n]*target:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
2779
+ if (target) lines.push(` promised: ${target.slice(0, 110)}`)
2780
+ }
2781
+ const decisions = readClean(eng, 'decisions.md').split('\n')
2782
+ .filter(l => /^-\s*\[\d{4}-\d{2}-\d{2}\]/.test(l.trim())).slice(-2)
2783
+ for (const d of decisions) lines.push(` decided: ${d.trim().replace(/^-\s*/, '').slice(0, 110)}`)
2784
+ return ['RECORD (read-only - success, delivery, decisions)', ...lines]
2785
+ }
2786
+
2651
2787
  function cmdDoctor() {
2652
2788
  const eng = resolveEngagement()
2653
2789
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
@@ -2985,12 +3121,14 @@ function cmdStatus(args) {
2985
3121
  rows.push({ name: engagementSlugFromPath(eng), phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: note, memoryWarn: s.memoryWarn, dirtyFiles: s.dirtyFiles, valueLines: valueLedgerStatusLines(eng) })
2986
3122
  }
2987
3123
  if (!rows.length) { console.log('no engagements yet'); return }
2988
- 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 }
2989
3126
  rows.sort((a, b) => order[a.trust] - order[b.trust])
2990
3127
  console.log((all ? 'FDE PORTFOLIO' : 'FDE STATUS') + ' - value first, then trust\n')
2991
3128
  for (const r of rows) {
2992
3129
  for (const line of r.valueLines) console.log(line)
2993
3130
  // "amber?" = structured signal went stale (>21d) - reconfirm before trusting it
3131
+ // "new" = nobody has been asked yet; green is reserved for asked-and-fine.
2994
3132
  const label = r.trust + (r.stale ? '?' : '')
2995
3133
  const sig = r.signalAge != null ? `signal ${r.signalAge}d old${r.stale ? ' (STALE - reconfirm)' : ''} ` : ''
2996
3134
  console.log(` [${label.padEnd(6)}] ${r.name.padEnd(24)} phase:${(r.phase === '?' ? 'unset' : r.phase).padEnd(10)} updated:${r.updated.padEnd(8)} ${sig}${r.reason}`)
@@ -3045,7 +3183,7 @@ function cmdDashboard(args) {
3045
3183
  }
3046
3184
  engagements = gatherEngagements({ only: eng })
3047
3185
  }
3048
- const counts = { green: 0, amber: 0, RED: 0 }
3186
+ const counts = { green: 0, amber: 0, RED: 0, new: 0 }
3049
3187
  engagements.forEach(e => { counts[e.signals.trust]++ })
3050
3188
  const today = render.formatToday(new Date())
3051
3189
 
@@ -3100,7 +3238,7 @@ function cmdDashboard(args) {
3100
3238
  failFs(e, 'write fieldbook', outPath)
3101
3239
  }
3102
3240
  console.log(`fieldbook → ${outPath}`)
3103
- console.log(`${engagements.length} engagement(s) rendered · ${counts.RED} red / ${counts.amber} amber / ${counts.green} green · 0 tokens (pure render)`)
3241
+ console.log(`${engagements.length} engagement(s) rendered · ${counts.RED} red / ${counts.amber} amber / ${counts.green} green / ${counts.new} new · 0 tokens (pure render)`)
3104
3242
  if (!all) {
3105
3243
  const current = resolveEngagement()
3106
3244
  if (current) for (const line of hygieneTriageLines(current)) console.log(line)
@@ -3131,7 +3269,7 @@ function cliVersion() {
3131
3269
  }
3132
3270
 
3133
3271
  function valueLedgerRows(eng) {
3134
- const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger') || '')
3272
+ const ledger = stripTemplateNoise(sectionBody(readClean(eng, 'delivery.md'), 'Value ledger', { lastNonEmpty: true }) || '')
3135
3273
  const table = parseMdTable(ledger)
3136
3274
  if (!table) return []
3137
3275
  const sIdx = colIndex(table.headers, /slice/i)
package/bin/install.js CHANGED
@@ -292,8 +292,8 @@ function cmdAdapters(targetDir, opts = {}) {
292
292
 
293
293
  function cmdInit(engagementName) {
294
294
  if (!engagementName) {
295
- console.error(' Usage: 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.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.22.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.22.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",