fdeops 3.22.1 → 3.23.0

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
@@ -34,7 +34,7 @@ One chat. Name the client:
34
34
  @fde this is client01
35
35
  ```
36
36
 
37
- That creates `~/fde-engagements/client01/.fde/` on your laptop. Paste kickoff notes in the same thread. `@fde` picks what to check. You still decide.
37
+ That creates `~/fde-engagements/client01/.fde/` on your laptop. Paste kickoff notes in the same thread. `@fde` picks what to check. You still decide. After a meeting you get one screen: what changed, new asks, open questions, next actions. Confirm once.
38
38
 
39
39
  Day to day: [docs/USAGE.md](docs/USAGE.md).
40
40
 
package/bin/check.js CHANGED
@@ -636,6 +636,11 @@ if (apManifest.$schema !== AP_PLUGIN_SCHEMA) {
636
636
  fail(`version mismatch package.json ${pkg.version} vs plugin.json ${apManifest.version}`)
637
637
  } else ok('agent plugins manifest')
638
638
 
639
+ const ingestPkg = JSON.parse(read('mcp/fdeops-ingest/package.json'))
640
+ if (ingestPkg.version !== pkg.version) {
641
+ fail(`version mismatch package.json ${pkg.version} vs mcp/fdeops-ingest ${ingestPkg.version}`)
642
+ } else ok('ingest package version aligned')
643
+
639
644
  const apMcp = JSON.parse(read('mcp.json'))
640
645
  const apServers = apMcp.mcpServers || {}
641
646
  if (apMcp.$schema !== AP_MCP_SCHEMA) {
package/bin/fde.js CHANGED
@@ -58,6 +58,26 @@ function sh(cmd, cwd) {
58
58
  } catch (_) { return '' }
59
59
  }
60
60
 
61
+ // Hex hashes only - never a shell. True when olderHash is an ancestor of newerHash.
62
+ function gitIsAncestor(eng, olderHash, newerHash) {
63
+ if (!olderHash || !newerHash || olderHash === newerHash) return false
64
+ if (!/^[0-9a-f]{7,64}$/i.test(olderHash) || !/^[0-9a-f]{7,64}$/i.test(newerHash)) return false
65
+ try {
66
+ execFileSync('git', ['merge-base', '--is-ancestor', olderHash, newerHash], {
67
+ cwd: eng, stdio: 'ignore', timeout: 15000,
68
+ })
69
+ return true
70
+ } catch (_) { return false }
71
+ }
72
+
73
+ function gitLogHash(eng, args) {
74
+ try {
75
+ return execFileSync('git', ['log', '-1', '--format=%H', ...args], {
76
+ cwd: eng, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000,
77
+ }).trim()
78
+ } catch (_) { return '' }
79
+ }
80
+
61
81
  function slugify(name) {
62
82
  return String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'engagement'
63
83
  }
@@ -1610,20 +1630,38 @@ function smartProposeText(input) {
1610
1630
  return out.join('\n') + (out.length ? '\n' : '')
1611
1631
  }
1612
1632
 
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/
1633
+ // Kickoff English, not only "signer: Priya". "Helena signs off", "Anand Mehta
1634
+ // has final say", "Finance controller (Helena) signs off" all have to fill
1635
+ // success.md - that is the line Monday's RECORD reads.
1636
+ const SIGNER_VERB = '(?:signs?(?:\\s+off)?|approves|has (?:the )?final say|can say yes|owns the decision|is the (?:sponsor|signer|decision[- ]maker))'
1637
+ const SIGNER_NAME = '([A-Z][\\w.\'-]+(?:\\s+[A-Z][\\w.\'-]+){0,3})'
1638
+ 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/
1639
+ 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
1640
+
1641
+ function looksLikePersonName(s) {
1642
+ const t = String(s || '').trim()
1643
+ if (!t || NOT_A_PERSON.test(t) || ROLE_TOKEN.test(t)) return false
1644
+ return /^[A-Z][\w.'-]+(?:\s+[A-Z][\w.'-]+){0,2}$/.test(t)
1645
+ }
1617
1646
 
1618
1647
  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
1648
+ const t = String(text || '').replace(/^[-*+]\s+/, '').trim()
1649
+ if (!t) return ''
1650
+ // "Priya (VP Eng) signs off" → Priya. "Finance controller (Helena) signs off" → Helena.
1651
+ const titled = t.match(new RegExp('\\b' + SIGNER_NAME + '\\s+\\(' + SIGNER_NAME + '\\)\\s+' + SIGNER_VERB + '\\b'))
1652
+ if (titled) {
1653
+ const before = titled[1].trim()
1654
+ const inside = titled[2].trim()
1655
+ if (looksLikePersonName(before) && ROLE_TOKEN.test(inside)) return before
1656
+ if (looksLikePersonName(inside)) return inside
1657
+ if (looksLikePersonName(before)) return before
1658
+ }
1659
+ const paren = t.match(new RegExp('\\(' + SIGNER_NAME + '\\)\\s+' + SIGNER_VERB + '\\b'))
1660
+ if (paren && looksLikePersonName(paren[1])) return paren[1].trim()
1661
+ const named = t.match(new RegExp('\\b' + SIGNER_NAME + '\\s+' + SIGNER_VERB + '\\b'))
1662
+ if (!named) return ''
1663
+ const who = named[1].trim()
1664
+ return looksLikePersonName(who) ? who : ''
1627
1665
  }
1628
1666
 
1629
1667
  function setSigner(eng, who) {
@@ -1763,6 +1801,101 @@ function writeProposal(eng, text) {
1763
1801
  return { proposePath, clean, blocks }
1764
1802
  }
1765
1803
 
1804
+ function approvedStamp(text) {
1805
+ const m = String(text || '').match(/\[approved:\s*([^\]]+)\]/i)
1806
+ return m ? m[1].trim() : ''
1807
+ }
1808
+
1809
+ function stripApprovedStamp(text) {
1810
+ return String(text || '').replace(/\s*\[approved:\s*[^\]]+\]/i, '').trim()
1811
+ }
1812
+
1813
+ // One screen a human can confirm in two minutes. The file-by-file routing
1814
+ // still prints after this - agents edit prefixes; people read this.
1815
+ function printDebriefReview(text) {
1816
+ const buckets = { decided: [], asked: [], open: [], next: [], signer: [] }
1817
+ for (const raw of String(text || '').split('\n')) {
1818
+ const line = raw.trim().replace(/^[-*+]\s+/, '')
1819
+ if (!line) continue
1820
+ const m = line.match(/^(decision|risk|delivery|contact|next|signer):\s*(.+)$/i)
1821
+ if (!m) continue
1822
+ const type = m[1].toLowerCase()
1823
+ const body = m[2]
1824
+ if (type === 'decision') {
1825
+ const who = approvedStamp(body)
1826
+ const core = previewLine(stripApprovedStamp(body), 90)
1827
+ buckets.decided.push(who ? `${core} (approved ${who})` : `${core} (unconfirmed)`)
1828
+ } else if (type === 'delivery') {
1829
+ buckets.asked.push(previewLine(body, 100))
1830
+ } else if (type === 'risk') {
1831
+ buckets.open.push(previewLine(body, 100))
1832
+ } else if (type === 'next') {
1833
+ buckets.next.push(previewLine(body, 100))
1834
+ } else if (type === 'signer') {
1835
+ buckets.signer.push(previewLine(body, 80))
1836
+ }
1837
+ }
1838
+ console.log('REVIEW (one screen - confirm once, then apply)\n')
1839
+ const order = [
1840
+ ['decided', buckets.decided],
1841
+ ['asked', buckets.asked],
1842
+ ['open', buckets.open],
1843
+ ['next', buckets.next],
1844
+ ['signer', buckets.signer],
1845
+ ]
1846
+ let any = false
1847
+ for (const [label, items] of order) {
1848
+ if (!items.length) continue
1849
+ any = true
1850
+ console.log(` ${label}:`)
1851
+ for (const item of items) console.log(` - ${item}`)
1852
+ }
1853
+ if (!any) console.log(' (nothing prefixed yet - edit .debrief-propose, then apply)')
1854
+ console.log('')
1855
+ }
1856
+
1857
+ function latestDatedDecision(md) {
1858
+ let latest = { date: '', line: '' }
1859
+ for (const raw of String(md || '').split('\n')) {
1860
+ const t = raw.trim()
1861
+ const m = t.match(/^[-*]\s*\[(\d{4}-\d{2}-\d{2})\]/)
1862
+ if (!m) continue
1863
+ if (m[1] >= latest.date) latest = { date: m[1], line: t }
1864
+ }
1865
+ return latest
1866
+ }
1867
+
1868
+ function formatDecisionRecord(line) {
1869
+ const raw = String(line || '').trim().replace(/^[-*]\s*/, '')
1870
+ const who = approvedStamp(raw)
1871
+ const core = stripApprovedStamp(raw)
1872
+ const stamp = who ? `(approved ${who})` : '(unconfirmed)'
1873
+ return previewLine(`${core} ${stamp}`, 110)
1874
+ }
1875
+
1876
+ function changeReviewIssues(eng) {
1877
+ const issues = []
1878
+ const del = latestDeliveryEntry(readClean(eng, 'delivery.md'))
1879
+ const dec = latestDatedDecision(readClean(eng, 'decisions.md'))
1880
+ const delHash = del.line
1881
+ ? sh(`git log -1 --format=%H -S${JSON.stringify(del.line)} -- delivery.md`, eng)
1882
+ : ''
1883
+ if (del.date && dec.date && dec.date > del.date && delHash) {
1884
+ issues.push(
1885
+ 'a decision landed after the last delivery line - review whether what you are shipping still matches'
1886
+ )
1887
+ }
1888
+ if (claimedValueRows(eng).claimed && delHash) {
1889
+ const sigHash = gitLogHash(eng, ['-GStakeholder who signs off|also named:', '--', 'success.md'])
1890
+ if (sigHash && gitIsAncestor(eng, delHash, sigHash)) {
1891
+ issues.push(
1892
+ 'the signer line changed while a measured number is still unaccepted - pending acceptance may need a new yes'
1893
+ )
1894
+ }
1895
+ }
1896
+ return issues
1897
+ }
1898
+
1766
1899
  function readSealCount(eng) {
1767
1900
  try {
1768
1901
  const n = parseInt(fs.readFileSync(path.join(eng, DEBRIEF_SEAL), 'utf8').trim(), 10)
@@ -1886,7 +2019,9 @@ function cmdDebrief(args) {
1886
2019
  if (smart) {
1887
2020
  const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
1888
2021
  console.log('SMART PROPOSE (heuristic - review before apply; no new facts invented beyond line rewrites)\n')
2022
+ printDebriefReview(clean)
1889
2023
  console.log('Prefix vocabulary (lines that route): decision: risk: delivery: contact: next: signer:')
2024
+ console.log('Optional on a decision: [approved: Name YYYY-MM-DD]. Missing means unconfirmed.')
1890
2025
  console.log('Everything else → context.md. Keep the prefixes; the preview gate stays.\n')
1891
2026
  routeDebriefInput(eng, clean, { dry: true, force, sealed: blocks })
1892
2027
  if (!apply) {
@@ -2028,6 +2163,7 @@ function cmdIngest(args) {
2028
2163
  }
2029
2164
  const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
2030
2165
  console.log(`INGEST PROPOSE from ${path.basename(item)} (via:${source})\n`)
2166
+ printDebriefReview(clean)
2031
2167
  routeDebriefInput(eng, clean, { dry: true, force: false, sealed: blocks })
2032
2168
  console.log(`\nproposal saved → ${proposePath}`)
2033
2169
  console.log('confirm: fde ingest apply')
@@ -2134,7 +2270,7 @@ function cmdReceipts(args) {
2134
2270
  agreed.map(h => (h.match(/^\s*([^:]+):/) || [])[1]).filter(f => f && dirtySet.has(f))
2135
2271
  )]
2136
2272
  if (agreed.length) {
2137
- console.log('ON RECORD (dated - defensible):')
2273
+ console.log('ON RECORD (dated):')
2138
2274
  agreed.forEach(h => {
2139
2275
  const file = (h.match(/^\s*([^:]+):/) || [])[1]
2140
2276
  console.log(h + (file && dirtySet.has(file) ? ' ⚠ dirty file' : ''))
@@ -2498,6 +2634,7 @@ function collectDoctorIssues(eng) {
2498
2634
  }
2499
2635
  const reality = parseReality(readClean(eng, 'reality.md'), 220)
2500
2636
  if (reality.missing) issues.push(reality.missing)
2637
+ issues.push(...changeReviewIssues(eng))
2501
2638
  return issues
2502
2639
  }
2503
2640
 
@@ -2620,7 +2757,7 @@ function hasValueBucket(eng) {
2620
2757
  // "pending review", "TBD.", "n/a (blocked)" and "..." are all the same thing an
2621
2758
  // FDE means by an empty cell - nagging about them teaches people to ignore doctor.
2622
2759
  const PENDING_CELL_RE =
2623
- /^(?:pending|tbd|to ?be ?(?:measured|confirmed|determined)|n\s*\/\s*a|na|none|unknown|not measured|\?+|\.{2,}|…|-+|-+|-+)(?:[^\w].*)?$/i
2760
+ /^(?:pending|tbd|to ?be ?(?:measured|confirmed|determined)|n\s*\/\s*a|na|none|unknown|not(?:\s+yet)?\s+measured|unmeasured|awaiting|\?+|\.{2,}|…|-+)(?:[^\w].*)?$/i
2624
2761
 
2625
2762
  function parseValueLedger(eng) {
2626
2763
  // Last section with actual rows, not merely the last non-empty one: a template
@@ -2704,7 +2841,7 @@ function engagementTouchesAI(eng) {
2704
2841
  ].join('\n'))
2705
2842
  // No bare "prompt": "prompt response" / "prompt payment" is ordinary delivery
2706
2843
  // 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)
2844
+ 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)
2708
2845
  }
2709
2846
 
2710
2847
  // The repo says AI even when the record does not. Read-only, capped, local: the
@@ -2750,7 +2887,7 @@ function hygieneTriageLines(eng) {
2750
2887
  const top = issues[0].replace(/\s+/g, ' ').trim().slice(0, 72)
2751
2888
  return [
2752
2889
  ` hygiene: ${issues.length} issue(s) - ${top}${issues[0].length > 72 ? '…' : ''}`,
2753
- ' → say "@fde clean up the fieldbook" when ready (agent runs fde doctor; nothing auto-rewrites)',
2890
+ ' → say "@fde clean up the fieldbook" when ready (agent runs fde doctor; nothing auto-rewrites), or: fde doctor',
2754
2891
  ]
2755
2892
  }
2756
2893
 
@@ -2780,7 +2917,7 @@ function recordDigest(eng) {
2780
2917
  }
2781
2918
  const decisions = readClean(eng, 'decisions.md').split('\n')
2782
2919
  .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)}`)
2920
+ for (const d of decisions) lines.push(` decided: ${formatDecisionRecord(d)}`)
2784
2921
  return ['RECORD (read-only - success, delivery, decisions)', ...lines]
2785
2922
  }
2786
2923
 
@@ -3596,7 +3733,7 @@ function printUsage() {
3596
3733
  fde log phase <phase> set engagement phase (land|discover|plan|ship|outcome|close)
3597
3734
  fde log --undo remove the last CLI log/debrief entry from memory
3598
3735
  fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
3599
- fde debrief --smart heuristic propose (prints decision:/risk:/delivery:/contact:/next:); --apply after confirm
3736
+ fde debrief --smart heuristic propose; REVIEW first (decided/asked/open/next/signer); --apply after one confirm
3600
3737
  fde ingest stage … stage raw pull into <engagement>/.inbox/ (not .fde/)
3601
3738
  fde ingest list list staged inbox items
3602
3739
  fde ingest propose <id> smart-propose a staged item → .debrief-propose (confirm before apply)
@@ -3618,6 +3755,10 @@ function printUsage() {
3618
3755
  }
3619
3756
 
3620
3757
  const [cmd, ...args] = process.argv.slice(2)
3758
+ if (args.includes('--help') || args.includes('-h') || cmd === 'help' || cmd === '--help' || cmd === '-h') {
3759
+ printUsage()
3760
+ process.exit(0)
3761
+ }
3621
3762
  switch (cmd) {
3622
3763
  case 'demo': cmdDemo(args); break
3623
3764
  case 'scan': cmdScan(); break
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops-ingest-mcp",
3
- "version": "3.22.1",
3
+ "version": "3.23.0",
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.1",
3
+ "version": "3.23.0",
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.1",
4
+ "version": "3.23.0",
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",
@@ -28,7 +28,7 @@ A one-line typo or compile error in a file that will not ship. On a bound client
28
28
  | **When did we agree?** | Don't argue from memory. Search the record. | `fde receipts <term>` | - |
29
29
  | **What's the outcome?** | A number nobody signed is claimed, not delivered. | `fde status` | `references/readout.md` |
30
30
 
31
- After a meeting: `fde debrief --smart` → confirm → `--apply`. Walk-in: `fde prep`. Friday: `fde status`.
31
+ After a meeting: `fde debrief --smart` → one REVIEW screen (decided / asked / open / next / signer) → confirm once → `--apply`. Walk-in: `fde prep`. Friday: `fde status`.
32
32
 
33
33
  ## Ground loop
34
34
 
@@ -65,7 +65,7 @@ Writes need a bind (`FDEOPS_ENGAGEMENT` or registry). Never install fdeops on in
65
65
  |----------|---------|
66
66
  | where are we | `fde resume` |
67
67
  | day-1 look at the repo | `fde scan` |
68
- | debrief / pasted notes | `fde debrief --smart` → you rewrite prefixes → confirm → `--apply`. `--smart` is a gate, not a brain. `references/debrief.md` |
68
+ | debrief / pasted notes | `fde debrief --smart` → REVIEW → confirm once → `--apply`. `--smart` is a gate, not a brain. `references/debrief.md` |
69
69
  | prep me for … | `fde prep "<label>"` |
70
70
  | when did we agree | `fde receipts <term>` |
71
71
  | sponsor update / the outcome | `fde status` |
@@ -24,12 +24,13 @@
24
24
  2. Run `fde debrief --smart <notes.md>` (or `npx fdeops debrief --smart …`).
25
25
  3. Open `.debrief-propose`. If lines lack type prefixes, **rewrite them** before showing the FDE, e.g.:
26
26
  - `decision: agreed chargebacks stay phase 2 - Priya`
27
+ - `decision: freeze the API [approved: Priya 2026-09-08]` (optional; missing means unconfirmed)
27
28
  - `risk: legal may reopen scope if we slip the SOW date`
28
29
  - `contact: Priya pushed hard on Friday deck [signal:amber]`
29
30
  - `signer: Priya` (she can say yes; lands in `success.md`)
30
31
  - `next: send one-pager before Thursday 9am`
31
32
  - unprefixed lines stay context color only
32
- 4. Show the **proposed** routing in plain language (what would become decisions, risks, contacts, next).
33
+ 4. Show the **REVIEW** block first (decided / asked / open / next / signer). That is the one screen to confirm. File routing stays underneath.
33
34
  5. On FDE confirm → run `fde debrief --apply`.
34
35
  6. On reject → stop; ask what to change; do not apply.
35
36