fdeops 3.27.1 → 3.28.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
@@ -2,138 +2,295 @@
2
2
 
3
3
  **Forward deployed engineering skills for AI coding agents.**
4
4
 
5
- Your agent writes code. FDEOps helps you deliver the client work around it: understand the problem, agree what done means, prove the result, and hand it over.
5
+ <a name="why-use-it"></a>
6
6
 
7
- <a name="who-this-is-for"></a>
7
+ You're on a customer site. The AI coding agent writes code in their repo. This kit is the work around that code: the brief, who can say yes, proof on their staging then live, whether they signed off, whether they can run it after you leave.
8
8
 
9
- An open-source kit for FDEs, independent consultants, and small agencies working across client projects. Use it with your coding agent. Keep a separate record for each client on your own laptop.
9
+ Notes stay on your laptop, in a separate record for each client. Review the agent's proposed changes before saving them.
10
10
 
11
- [Install](#quick-start) · [See the dashboard](#your-daily-fieldbook) · [Documentation](docs/README.md)
11
+ Keep the coding pack you already use. FDEOps adds the client brief, decisions, and evidence around that work.
12
12
 
13
- <img width="960" height="640" alt="FDEOps: forward deployed engineering from the first client meeting to handover" src="https://github.com/user-attachments/assets/2bcb8739-55ee-445d-8a1a-8b38433b7b58" />
13
+ [Quick start](#quick-start) · [Daily fieldbook](#your-daily-fieldbook) · [30 skills](#all-30-skills) · [Documentation](docs/README.md)
14
14
 
15
- ## Why use it?
15
+ <img width="960" height="640" alt="FDEOps: client delivery from the first meeting to handover" src="https://github.com/user-attachments/assets/2bcb8739-55ee-445d-8a1a-8b38433b7b58" />
16
16
 
17
- - **Pick up where you left off.** The brief, decisions, risks, and next action travel with the client record, across sessions and agents.
18
- - **Know what you can stand behind.** Keep what was promised, what was measured, and what the customer accepted separate, with evidence for each result.
19
- - **See what needs you today.** An offline dashboard shows the next step and missing evidence, measurement, or approval across your clients.
17
+ ---
20
18
 
21
19
  ## Quick Start
22
20
 
23
- Requires Node.js 18+, Git, and an AI coding agent that supports skills. Install the FDEOps skill:
21
+ **Try it in a local checkout.** Requires Node.js 18+ and Git:
22
+
23
+ ```bash
24
+ npx fdeops scan
25
+ ```
26
+
27
+ It prints what to look at on day one and the questions to ask. The scan reads local files without changing them. `npx` may download the package.
28
+
29
+ **Then install the skill:**
24
30
 
25
31
  ```bash
26
32
  npx skills add suboss87/fdeops --skill fde
27
33
  ```
28
34
 
29
- Open a client workspace and tell your agent:
35
+ One chat. Name the client:
30
36
 
31
37
  ```text
32
38
  @fde this is client01
33
39
  ```
34
40
 
35
- The agent creates `~/fde-engagements/client01/.fde/` on your laptop and links the workspace to that client. Paste the brief or meeting notes into the same conversation. `@fde` works through the situation with you; you review the proposed record before it is applied.
41
+ 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 review what changed, new asks, open questions, and next actions. Correct the proposal, then confirm the update.
42
+
43
+ Open the engagement fieldbook:
44
+
45
+ ```bash
46
+ npx fdeops dashboard --open
47
+ ```
36
48
 
37
- **Claude Code plugin or another host?** Follow the [installation guide](docs/install.md). It explains hooks, adapters, offline setup, and the CLI fallback if your agent cannot run setup. Skill-only installation does not add automatic session hooks.
49
+ Read-only HTML of the record - promised, measured, accepted, and evidence. Regenerate after you change memory. Day to day: [docs/USAGE.md](docs/USAGE.md).
38
50
 
39
- **Try it without an AI model:**
51
+ <details>
52
+ <summary><b>Claude Code</b></summary>
53
+
54
+ ```text
55
+ /plugin marketplace add suboss87/fdeops
56
+ /plugin install fdeops@fdeops
57
+ ```
58
+
59
+ The plugin adds session hooks and the slash commands below. Skill-only installation does not add hooks. See the [installation guide](docs/install.md) for setup details.
60
+
61
+ </details>
62
+
63
+ <details>
64
+ <summary><b>Cursor</b></summary>
65
+
66
+ After installing the skill, add the FDEOps instructions to the **client workspace** you have open:
40
67
 
41
68
  ```bash
42
- npx fdeops demo
69
+ npx fdeops adapters .
43
70
  ```
44
71
 
45
- The demo runs fictional notes through review, applies them in a sandbox, and prints a fieldbook path to open. It writes and resets only its demo folder under `~/fde-engagements/.demo/`. Remove the demo with `npx fdeops demo --clean`. `npx` may download packages; the FDEOps CLI itself works locally.
72
+ See [adapters/](adapters/README.md).
46
73
 
47
- ⭐ **If FDEOps makes your client work easier, star the repo.**
74
+ </details>
48
75
 
49
- ## What a working day looks like
76
+ <details>
77
+ <summary><b>Codex, other agents, and offline setup</b></summary>
50
78
 
51
- You come out of a meeting with this:
79
+ Use the skill installation above in a supported host. If the agent cannot create and bind the client folder, run this from the client workspace:
52
80
 
53
- ```text
54
- @fde Mara wants CSV upload first. Devon asked for ERP sync,
55
- but Mara has not approved that scope. The staging replay took
56
- five minutes; production has not been measured. Ask Mara for
57
- staging access before Friday. Source: kickoff meeting, 10 September.
81
+ ```bash
82
+ npx fdeops resume --init client01
58
83
  ```
59
84
 
60
- The agent shows you what changed, what was only requested, what is still unknown, and what happens next. You correct it and confirm the update. A request does not become agreed scope. A staging result does not become a production result. Naming a signer does not mean they accepted the work.
85
+ For offline use, transfer an existing checkout to a machine with Node.js and Git, then run `node bin/install.js` from that checkout. Host adapters, local models, and advanced options are covered in [docs/install.md](docs/install.md).
86
+
87
+ </details>
61
88
 
62
- Tomorrow, `@fde` resumes from that record. Before a sponsor meeting, ask what was promised, what was measured, and where the proof is. When someone takes over, give them the handoff packet.
63
89
 
64
- [Walk through notes → REVIEW → apply → evidence](docs/USAGE.md#new-here-5-minutes).
90
+ <a name="what-a-working-day-looks-like"></a>
65
91
 
66
92
  ## Your daily fieldbook
67
93
 
68
- ![Dark FDEOps fieldbook with one recommended action per client and the gaps behind it](media/fieldbook-preview.png)
94
+ See what needs your attention before you open another client thread: an open risk, missing evidence, a result waiting for acceptance, or the next action.
95
+
96
+ ![Dark FDEOps fieldbook showing next actions and delivery gaps across fictional clients](media/fieldbook-preview.png)
69
97
 
70
98
  ```bash
71
- npx fdeops dashboard --open # current client
72
- npx fdeops dashboard --all --open # all clients
99
+ npx fdeops dashboard --all --open
73
100
  ```
74
101
 
75
- Open a client, inspect its record, and copy an action into your agent to continue the work. The dashboard is a read-only snapshot. After updating the record, run the command again to refresh it. The screenshot uses fictional clients.
102
+ Open a client, inspect its record, and copy an action into your agent to continue. The dashboard is a read-only snapshot; repeat the command after updating your records to refresh it.
76
103
 
77
- <a name="how-skills-work"></a>
104
+ **Want to see the whole loop first?** `npx fdeops demo` runs fictional notes through review and prints a sample fieldbook path to open. It writes and resets its own folder under `~/fde-engagements/.demo/`, needs no AI model, and can be removed with `npx fdeops demo --clean`.
78
105
 
79
- ## From the first meeting to handover
106
+ ⭐ If FDEOps makes your client work easier, star the repo.
80
107
 
81
- Describe the situation to `@fde`; it selects the relevant workflow. You do not need to learn a catalog of skills.
108
+ ---
82
109
 
83
- | Stage | What you work through |
84
- |---|---|
85
- | Land | Understand the brief and name who can accept the work |
86
- | Discover | Check the actual workflow, constraints, and source of the problem |
87
- | Plan | Agree a small deliverable and an observable test for “done” |
88
- | Ship | Prove it on their staging, review release approval, and test recovery |
89
- | Outcome | Compare promised and measured results; record customer acceptance |
90
- | Close | Transfer the runbook, evidence, open risks, and operating ownership |
110
+ <a name="from-the-first-meeting-to-handover"></a>
91
111
 
92
- The Claude Code plugin also provides `/brief`, `/discover`, `/plan`, `/ship`, `/outcome`, and `/close`. Daily plugin shortcuts are `/debrief`, `/prep`, `/trust`, `/receipts`, and `/readout`. Other hosts use the same `@fde` entry and methodology.
112
+ ## Commands
93
113
 
94
- Each skill gives the agent steps to follow, a record to produce, and a checkpoint with you. There are 30 routed skills, plus industry overlays. Start with the [three delivery checklists](docs/skills.md#three-delivery-checklists); use the [full reference](docs/skills-reference.md) when you need the detail.
114
+ Describe the situation to `@fde`. It selects the relevant skill. The Claude Code plugin also provides these stage commands; other hosts use the same method through `@fde`.
95
115
 
96
- ## Use the CLI directly
116
+ | What you're doing | Command | Stage |
117
+ |-------------------|---------|-------|
118
+ | First days. Get the brief. Name who signs. | `/brief` | Land |
119
+ | Check the brief is the real job. | `/discover` | Discover |
120
+ | Sequence from done, not from the ticket. | `/plan` | Plan |
121
+ | Prove it on their staging, then go live. | `/ship` | Ship |
122
+ | What you promised, measured, and who accepted. | `/outcome` | Outcome |
123
+ | Hand it over. They run it without you. | `/close` | Close |
97
124
 
98
- The CLI does the file work without calling a model. Commands that change records write when you run them; `debrief --smart` stages a proposal for review before `--apply`.
125
+ Claude Code shortcuts for the moments between stages: `/debrief` (notes into the record), `/prep` (one page before you walk in), `/trust` (process gap, or they stopped trusting you), `/receipts` (find what was recorded and where it came from), `/readout` (Friday page for the sponsor; not a seventh stage).
99
126
 
100
- | You need to… | Run |
101
- |---|---|
102
- | Resume a client | `npx fdeops resume` |
103
- | Review messy notes | `npx fdeops debrief --smart notes.md` |
104
- | Apply the reviewed proposal | `npx fdeops debrief --apply` |
105
- | Find a source | `npx fdeops recall "retry decision"` |
106
- | Prepare a sponsor readout | `npx fdeops defend` |
107
- | Check readiness to plan or build | `npx fdeops doctor --ready` |
108
- | Export a successor packet | `npx fdeops handoff --out successor.md` |
127
+ You can also just say it: “Prep me for the sponsor meeting,” “What did we agree about scope?” or “Help me hand this over.”
128
+
129
+ ---
130
+
131
+ ## All 30 Skills
132
+
133
+ Thirty situations, grouped by stage. Each skill gives the agent steps to follow, a record or report to produce, and a checkpoint with you. You describe the work; `@fde` finds the skill.
134
+
135
+ Full detail: [docs/skills-reference.md](docs/skills-reference.md).
136
+
137
+ ### Land
138
+
139
+ | Skill | What it does | Use when |
140
+ |--------|--------------|----------|
141
+ | [land](skills/fde/references/land.md) | Interrogate the brief | New client, first meeting, just got the brief |
142
+ | [audit](skills/fde/references/audit.md) | Verify inherited claims | Taking over, previous consultant left |
143
+ | [who-decides](skills/fde/references/who-decides.md) | Map decision rights | Need to know who matters |
144
+ | [earn-trust](skills/fde/references/earn-trust.md) | Earn access | Need access or credibility |
145
+ | [hold-scope](skills/fde/references/hold-scope.md) | Hold scope | "Also can you…", timeline unchanged |
146
+
147
+ ### Discover
148
+
149
+ | Skill | What it does | Use when |
150
+ |--------|--------------|----------|
151
+ | [discover](skills/fde/references/discover.md) | Frame the problem | Brief feels wrong, shadow processes |
152
+ | [test-assumptions](skills/fde/references/test-assumptions.md) | Test assumptions | Brief feels too neat |
153
+ | [score-use-cases](skills/fde/references/score-use-cases.md) | Score use cases | Everything is P0 |
154
+ | [poc](skills/fde/references/poc.md) | Validate the solution | POC, spike, need to de-risk |
155
+
156
+ ### Plan
157
+
158
+ | Skill | What it does | Use when |
159
+ |--------|--------------|----------|
160
+ | [plan](skills/fde/references/plan.md) | Sequence the work | What order, what is done |
161
+ | [business-case](skills/fde/references/business-case.md) | Build the business case | Defend budget or timeline |
162
+ | [three-options](skills/fde/references/three-options.md) | Generate options | "What should we do?" |
163
+ | [pick-three](skills/fde/references/pick-three.md) | Prioritize three | Everything is urgent |
164
+
165
+ ### Ship
166
+
167
+ | Skill | What it does | Use when |
168
+ |--------|--------------|----------|
169
+ | [ship](skills/fde/references/ship.md) | Deliver the increment | Building, updating, or going live |
170
+ | [what-breaks](skills/fde/references/what-breaks.md) | Assess impact | Touching shared infrastructure |
171
+ | [rescue](skills/fde/references/rescue.md) | Resolve the incident | Down, or they went quiet |
172
+ | [review](skills/fde/references/review.md) | Review the change | Before merge, scope creep |
173
+ | [rollback](skills/fde/references/rollback.md) | Rehearse rollback | "We can always revert" |
109
174
 
110
- [All commands and examples](docs/USAGE.md).
175
+ ### Outcome
111
176
 
112
- For free-form notes, start with `@fde` so your agent can help interpret them. The CLI's `--smart` option recognizes common phrases; review its proposal because it can miss requests or next actions in ordinary prose.
177
+ | Skill | What it does | Use when |
178
+ |--------|--------------|----------|
179
+ | [readout](skills/fde/references/readout.md) | Report the outcome | Friday, sponsor update |
180
+ | [demo-prep](skills/fde/references/demo-prep.md) | Prepare the demo | Demo or exec walkthrough |
181
+ | [debrief](skills/fde/references/debrief.md) | Capture the meeting | Just left a meeting |
182
+ | [board-memo](skills/fde/references/board-memo.md) | Brief the board | Justify continued investment |
183
+ | [dashboard](skills/fde/references/dashboard.md) | Open the fieldbook | This customer, or all of them |
184
+ | [ingest](skills/fde/references/ingest.md) | Ingest sources | Transcript, Notion, Slack |
185
+ | [connect](skills/fde/references/connect.md) | Connect a source | Connect Granola |
113
186
 
114
- ## Your records, your control
187
+ ### Close
115
188
 
116
- Each client has its own `.fde/` folder of Markdown files. The brief, success criteria, decisions, risks, and delivery ledger stay readable outside FDEOps. The CLI uses local files and Git, with no network calls or telemetry. Optional [MCP sources](mcp/recipes/) are connected through your agent host, then pulled and reviewed on request.
189
+ | Skill | What it does | Use when |
190
+ |--------|--------------|----------|
191
+ | [close](skills/fde/references/close.md) | Transfer operations | Wrapping up |
192
+ | [runbook](skills/fde/references/runbook.md) | Write the runbook | They must operate without you |
193
+ | [switch-clients](skills/fde/references/switch-clients.md) | Switch engagements | 2+ clients |
194
+ | [encode-pattern](skills/fde/references/encode-pattern.md) | Encode the pattern | It will apply again |
195
+ | [red-team](skills/fde/references/red-team.md) | Challenge the plan | "Poke holes in this" |
117
196
 
118
- Default session context is capped at **16 KiB**, including constraints and selected records. Older detail stays on disk and can be retrieved with `recall`. This limits FDEOps output, not everything your agent puts into its context window.
197
+ Overlays (on signal, not on request): [ai](skills/fde/references/ai.md) · [artifacts](skills/fde/references/artifacts.md) · [fintech](skills/fde/references/fintech.md) · [healthcare](skills/fde/references/healthcare.md) · [gov](skills/fde/references/gov.md). AI companion (not a sixth overlay): [eval-pack](skills/fde/references/eval-pack.md).
119
198
 
120
- Your AI host may send what it reads to its configured model. FDEOps redacts `<private>` blocks from its CLI, hook, and dashboard outputs; do not paste or load raw private blocks through file tools. Enabled session hooks can record mechanical session state automatically. Review reports before sharing client information.
199
+ Optional pull: you add the source MCP; we **pull** on request. [mcp/recipes/](mcp/recipes/)
121
200
 
122
- The CLI and dashboard need no model. The tested small local models produced wrong or incomplete answers; read the results before relying on one for client work. [Verification and limits](docs/verification.md) · [Privacy](PRIVACY.md) · [Security](SECURITY.md).
201
+ ---
123
202
 
124
- ## Find your way around
203
+ <a name="use-the-cli-directly"></a>
204
+
205
+ ## How Skills Work
206
+
207
+ One `@fde`. One file per situation. One folder per client.
208
+
209
+ Tell the agent what is happening. It reads the client record, opens the relevant skill, and works through the situation with you. After a meeting, it proposes the decisions, open questions, and next action. You correct what it misunderstood and confirm the update.
210
+
211
+ A request stays a request until agreed. A staging result stays separate from production. Recording a result does not mean the customer accepted it.
212
+
213
+ At the next session, FDEOps supplies a short summary instead of the whole history. Older detail stays on disk; `recall` finds relevant records when needed. The default summary is capped at 16 KiB, which limits FDEOps output rather than everything your agent loads.
214
+
215
+ Before a sponsor meeting, `npx fdeops defend` separates recorded acceptance from claims and missing evidence. For a successor, `npx fdeops handoff --out successor.md` creates a portable summary with risks and sources.
216
+
217
+ For free-form notes, start with `@fde`. The CLI's `debrief --smart` recognizes common phrases; it can miss details that your agent needs to help interpret. [Follow the notes → review → apply walkthrough](docs/USAGE.md#new-here-5-minutes).
218
+
219
+ ---
220
+
221
+ ## Engagement memory (`.fde/`)
222
+
223
+ One folder per client. Plain markdown. Grep it, copy it, take it into a meeting.
224
+
225
+ | File | Holds |
226
+ |------|-------|
227
+ | `context.md` | Where you are |
228
+ | `brief.md` / `success.md` | What they asked; what “done” is and who signs |
229
+ | `reality.md` / `terrain.md` | The real problem; the map |
230
+ | `stakeholders.md` | `[signal:green\|amber\|red]` - worst active signal wins; empty is **new**, not green |
231
+ | `trust-profile.md` | Sacred data, AI policy, approval chain |
232
+ | `decisions.md` / `risks.md` / `delivery.md` | Dated choices; live risks; what shipped, evidence, rollback, acceptance |
233
+
234
+ Schema: [docs/schema.md](docs/schema.md). Fieldbook: `npx fdeops dashboard --open` (bound) or `--all --open` (portfolio).
235
+
236
+ ---
237
+
238
+ ## Who this is for
239
+
240
+ Forward deployed engineers, independent consultants, and small agencies working with customer teams. You need to carry the brief, decisions, delivery evidence, and handover across meetings, repositories, and sometimes several clients.
241
+
242
+ If your work has no client commitments or operating handover to track, a simpler project note may be enough.
243
+
244
+ <a name="your-records-your-control"></a>
245
+
246
+ ## Your data stays yours
247
+
248
+ The CLI works with local files and Git, without network calls or telemetry. Client records remain readable Markdown if you stop using FDEOps.
249
+
250
+ Your AI host may send the material it reads to its configured model. FDEOps redacts `<private>` blocks from CLI, dashboard, and hook outputs; do not load those raw blocks through the agent's file tools. Review reports before sharing client information.
251
+
252
+ You review proposed decisions. Enabled session hooks can save where the session left off automatically; direct CLI write commands update records when you run them.
253
+
254
+ The CLI and dashboard need no model. AI-assisted local use needs an agent with file and command access. Our small local-model tests produced wrong or incomplete answers, so check the [verification results](docs/verification.md) before relying on one for client work.
255
+
256
+ [Privacy](PRIVACY.md) · [Security](SECURITY.md)
257
+
258
+ ## Principles
259
+
260
+ - **Who signs** - name who can accept the work.
261
+ - **Brief vs real job** - check what happens on the floor, not only the slide.
262
+ - **Back from done** - agree how you will test success before planning the build.
263
+ - **Their staging, then live** - prove the change and agree the release and rollback.
264
+ - **Promised, measured, accepted** - keep each separate, with its evidence.
265
+ - **They run it** - hand over the knowledge and ownership, not just the code.
266
+ - **The kit says what to check. You still decide.**
267
+
268
+ ---
269
+
270
+ <a name="find-your-way-around"></a>
271
+
272
+ ## Project Structure
125
273
 
126
274
  | You want to… | Start here |
127
275
  |---|---|
128
- | Install or use FDEOps | [Documentation](docs/README.md) |
129
- | Understand or change a workflow | [The `@fde` skill](skills/fde/SKILL.md) and its `references/` |
130
- | Work on the CLI or dashboard | [`bin/`](bin/), shared helpers in `bin/lib/`, regressions in [`test/`](test/) |
131
- | Explore sample client records | [`examples/`](examples/) |
132
- | Check measured behavior and known gaps | [`evals/`](evals/) and [verification](docs/verification.md) |
133
- | Understand every folder | [Repository map](docs/REPO_LAYOUT.md) |
276
+ | Install or use FDEOps | [docs/](docs/README.md) |
277
+ | Understand or change a workflow | [skills/fde/](skills/fde/SKILL.md) and its `references/` |
278
+ | Work on the CLI or fieldbook | [bin/](bin/) and [test/](test/) |
279
+ | Walk through a client engagement | [examples/](examples/) |
280
+ | Check what has been tested | [evals/](evals/) and [verification](docs/verification.md) |
281
+
282
+ [Full repository map](docs/REPO_LAYOUT.md).
283
+
284
+ ---
285
+
286
+ <a name="contribute"></a>
287
+
288
+ ## Contributing
289
+
290
+ **[Subash Natarajan](https://www.linkedin.com/in/subashn/)**. [Issues](https://github.com/suboss87/fdeops/issues) · [Discussions](https://github.com/suboss87/fdeops/discussions) · [CONTRIBUTING.md](CONTRIBUTING.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
134
291
 
135
- ## Contribute
292
+ Skills should be **specific** (actionable steps), **verifiable** (an artifact in `.fde/`), and **minimal**. The `fde` CLI stays local-only.
136
293
 
137
- Share an anonymized failure case, a reproducible bug, or a focused improvement through [Issues](https://github.com/suboss87/FDEOps/issues) or [Discussions](https://github.com/suboss87/FDEOps/discussions). Read the [contribution guide](CONTRIBUTING.md) before changing a workflow or command.
294
+ ## License
138
295
 
139
- Built and maintained by **[Subash Natarajan](https://github.com/suboss87)**. MIT licensed; preserve applicable notices when redistributing.
296
+ MIT - use these skills on client work.
package/bin/fde.js CHANGED
@@ -840,7 +840,7 @@ function valueLedgerRowCount(body) {
840
840
  return t.rows.filter(r => r.some(c => String(c || '').trim())).length
841
841
  }
842
842
 
843
- function appendValueLedgerRow(eng, cells) {
843
+ function appendValueLedgerRow(eng, cells, { skipCommit = false } = {}) {
844
844
  ensureMemoryGit(eng)
845
845
  const p = path.join(eng, 'delivery.md')
846
846
  let row
@@ -887,7 +887,7 @@ function appendValueLedgerRow(eng, cells) {
887
887
  atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
888
888
  })
889
889
  recordLastWrite(eng, 'delivery.md', row)
890
- commitMemory(eng, 'log delivery', { files: ['delivery.md'] })
890
+ if (!skipCommit) commitMemory(eng, 'log delivery', { files: ['delivery.md'] })
891
891
  }
892
892
 
893
893
  function retireOpenRisks(eng, needle) {
@@ -983,7 +983,7 @@ const {
983
983
  countOpenRisks,
984
984
  } = createTrustApi({
985
985
  fs, path, readClean, readEng, parseMdTable, sectionBody, SIGNAL_LEDGER, memoryDirtyManual,
986
- stripTemplateNoise, stripLegendLines,
986
+ stripTemplateNoise, stripLegendLines, extractRisks,
987
987
  })
988
988
 
989
989
  // Stakeholders: columns are matched by header wording, not position - real
@@ -1099,14 +1099,14 @@ function extractStakeholders(eng) {
1099
1099
 
1100
1100
  // Risks: table rows AND dated CLI/debrief bullets. Empty template cells ignored.
1101
1101
  function extractRisks(eng) {
1102
- const md = readClean(eng, 'risks.md')
1102
+ const md = stripTemplateNoise(readClean(eng, 'risks.md'))
1103
1103
  const body = md.split(/^#{1,6}\s+Retired\b/im)[0] || md
1104
1104
  const HIGH = /critical|blocker|exposure|breach|urgent|at risk|at stake|\brace\b|rollback|no test/i
1105
1105
  const out = []
1106
1106
  const seen = new Set()
1107
1107
  const push = (text) => {
1108
1108
  const t = String(text || '').trim()
1109
- if (!t || seen.has(t.toLowerCase())) return
1109
+ if (!t || /^(?:\[[xX]\]\s|(?:closed|resolved|retired)\s*:)/i.test(t) || seen.has(t.toLowerCase())) return
1110
1110
  seen.add(t.toLowerCase())
1111
1111
  out.push({ text: t, severity: HIGH.test(t) ? 'high' : 'med' })
1112
1112
  }
@@ -1114,13 +1114,23 @@ function extractRisks(eng) {
1114
1114
  if (table) {
1115
1115
  const riskIdx = colIndex(table.headers, /^risk$/i)
1116
1116
  if (riskIdx !== -1) {
1117
- for (const cs of table.rows) push(cs[riskIdx])
1117
+ const statusIdx = colIndex(table.headers, /^status$/i)
1118
+ for (const cs of table.rows) {
1119
+ if (statusIdx !== -1 && /^(closed|resolved|retired)$/i.test((cs[statusIdx] || '').trim())) continue
1120
+ push(cs[riskIdx])
1121
+ }
1118
1122
  }
1119
1123
  }
1120
1124
  for (const raw of body.split('\n')) {
1121
1125
  const t = raw.trim()
1122
1126
  const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
1123
1127
  if (m) push(m[1])
1128
+ else {
1129
+ // Inherited Markdown may predate the dated CLI format. Keep its open
1130
+ // bullets visible; absence of a date does not mean absence of a risk.
1131
+ const bullet = t.match(/^[-*+]\s+(.*)$/)
1132
+ if (bullet && !/^\[[xX]\]\s/.test(bullet[1])) push(bullet[1].replace(/^\[ \]\s*/, ''))
1133
+ }
1124
1134
  }
1125
1135
  return out
1126
1136
  }
@@ -1541,7 +1551,8 @@ function cmdLog(args) {
1541
1551
  return
1542
1552
  }
1543
1553
  if (type === 'delivery' && text.includes('|')) {
1544
- const cells = text.split('|').map(s => s.trim())
1554
+ let cells
1555
+ try { cells = deliveryCells(text) } catch (error) { console.error(error.message); process.exitCode = 1; return }
1545
1556
  appendValueLedgerRow(eng, cells)
1546
1557
  const hash = memoryHead(eng)
1547
1558
  console.log(`logged → delivery.md (value ledger)${hash ? ` @${hash}` : ''}`)
@@ -1876,6 +1887,8 @@ function stripApprovedStamp(text) {
1876
1887
  // One screen a human can confirm in two minutes. The file-by-file routing
1877
1888
  // still prints after this - agents edit prefixes; people read this.
1878
1889
  function printDebriefReview(text, eng) {
1890
+ const repeats = repeatedDebriefStatements(eng, text)
1891
+ if (repeats.length) console.log(`REPLAY WARNING: ${repeats.length} source-backed statement(s) already recorded. Review newer facts and next action; applying again requires --allow-replay.\n`)
1879
1892
  const buckets = { decided: [], asked: [], scope: [], delivery: [], open: [], next: [], signer: [] }
1880
1893
  for (const raw of String(text || '').split('\n')) {
1881
1894
  const line = raw.trim().replace(/^[-*+]\s+/, '')
@@ -2048,11 +2061,40 @@ function boundedDebriefPreview(eng, render, { proposal = true, maxBytes = 12000
2048
2061
  return result
2049
2062
  }
2050
2063
 
2051
- function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
2064
+ function deliveryCells(text) {
2065
+ const cells = text.split('|').map(s => s.trim())
2066
+ if (cells.length !== 7) throw new Error('delivery needs exactly 7 fields: Slice | Bucket | Promised | Measured | Accepted by | Evidence | Rollback. Use pending for unknowns; omit pipes for a narrative note.')
2067
+ return cells
2068
+ }
2069
+
2070
+ // Exact sourced statement replay only; a shared source may contain new facts.
2071
+ // Do not silently deduplicate: the engineer decides whether a repeated event is intended.
2072
+ function repeatedDebriefStatements(eng, input) {
2073
+ const repeats = []
2074
+ const records = new Map()
2075
+ const normalize = value => value.replace(/\s*\|\s*/g, '|').replace(/\s+/g, ' ').trim()
2076
+ for (const raw of splitPrivate(input, { sealDangling: true }).clean.split('\n')) {
2077
+ const match = raw.trim().replace(/^[-*+]\s+/, '').match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
2078
+ if (!match || !/\[source:[^\]]+\]/i.test(match[2])) continue
2079
+ const body = match[2].trim()
2080
+ const file = LOG_FILES[match[1].toLowerCase()]
2081
+ if (!records.has(file)) {
2082
+ const statements = String(readClean(eng, file) || '').split('\n').map(line => {
2083
+ if (/^\s*\|/.test(line)) return normalize(line.trim().replace(/^\|\s*\d{4}-\d{2}-\d{2}\s*\|/, '').replace(/\|\s*$/, ''))
2084
+ return normalize(line.replace(/^\s*[-*+]\s+/, '').replace(/^(?:\[(?:\d{4}-\d{2}-\d{2}|@[^\]]+|signal:[^\]]+)\]\s*)+/, ''))
2085
+ })
2086
+ records.set(file, new Set(statements))
2087
+ }
2088
+ if (records.get(file).has(normalize(body))) repeats.push(body)
2089
+ }
2090
+ return repeats
2091
+ }
2092
+
2093
+ function routeDebriefInput(eng, input, { dry, force, sealed = [], allowReplay = false }) {
2052
2094
  if (!dry && !debriefTransactionActive) {
2053
2095
  ensureMemoryGit(eng)
2054
2096
  return withDebriefRecords(eng, () => {
2055
- const result = routeDebriefInput(eng, input, { dry, force, sealed })
2097
+ const result = routeDebriefInput(eng, input, { dry, force, sealed, allowReplay })
2056
2098
  // Consuming the review is part of the write. If cleanup fails, restoring
2057
2099
  // both the records and proposal makes the next explicit apply safe.
2058
2100
  for (const file of [DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL]) {
@@ -2063,12 +2105,14 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
2063
2105
  return result
2064
2106
  })
2065
2107
  }
2108
+ const repeats = repeatedDebriefStatements(eng, input)
2109
+ if (repeats.length && !dry && !allowReplay) throw new Error('source-backed statement already recorded; review the existing record and newer next action. Explicitly confirm a repeat with --allow-replay, or remove the repeated statement from the proposal.')
2066
2110
  const d = new Date()
2067
2111
  const date = d.toISOString().slice(0, 10)
2068
2112
  const counts = { decision: 0, risk: 0, delivery: 0, contact: 0, next: 0, signer: 0 }
2069
2113
  const ctxLines = []
2070
2114
  let nextAction = ''
2071
- ensureMemoryGit(eng)
2115
+ if (!dry) ensureMemoryGit(eng)
2072
2116
  // Sealed blocks are pulled out before routing, so a <private> block's interior
2073
2117
  // lines are never previewed and never routed into decisions/risks/stakeholders
2074
2118
  // unsealed. They land verbatim in context.md instead: the preview a human
@@ -2092,7 +2136,7 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
2092
2136
  const who = body.replace(/\s+signs?(?:\s+off)?\b.*$/i, '').trim() || body.trim()
2093
2137
  if (dry) {
2094
2138
  console.log(`→ success.md **Stakeholder who signs off:** ${previewLine(who)}`)
2095
- console.log(`→ stakeholders.md ${previewLine(datedEntry(eng, date, `${who} signs off`))}`)
2139
+ console.log(`→ stakeholders.md ${previewLine(`- [${date}] ${who} signs off`)}`)
2096
2140
  } else {
2097
2141
  setSigner(eng, who)
2098
2142
  appendLogEntry(eng, 'contact', datedEntry(eng, date, `${who} signs off`), { skipCommit: true })
@@ -2106,9 +2150,16 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
2106
2150
  counts.next++
2107
2151
  continue
2108
2152
  }
2153
+ if (type === 'delivery' && body.includes('|')) {
2154
+ const cells = deliveryCells(body)
2155
+ if (dry) console.log(`→ delivery.md ## Value ledger ${previewLine(body)}`)
2156
+ else appendValueLedgerRow(eng, cells, { skipCommit: true })
2157
+ counts.delivery++
2158
+ continue
2159
+ }
2109
2160
  const sigInline = (body.match(/\[signal:(red|amber|green)\]/i) || [])[1]
2110
2161
  if (sigInline) body = body.replace(/\[signal:(red|amber|green)\]/i, '').trim()
2111
- const entry = datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
2162
+ const entry = dry ? `- [${date}]${type === 'contact' && sigInline ? ` [signal:${sigInline.toLowerCase()}]` : ''} ${body}` : datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
2112
2163
  if (dry) console.log(`→ ${LOG_FILES[type]} ${previewLine(entry)}`)
2113
2164
  else appendLogEntry(eng, type, entry, { skipCommit: true })
2114
2165
  counts[type]++
@@ -2147,6 +2198,21 @@ function cmdDebrief(args) {
2147
2198
 
2148
2199
  function runDebrief(args, eng) {
2149
2200
  args = args.slice()
2201
+ const replayIdx = args.indexOf('--allow-replay')
2202
+ const allowReplay = replayIdx !== -1
2203
+ if (allowReplay) args.splice(replayIdx, 1)
2204
+ if (args.includes('--review')) {
2205
+ if (args.length !== 1 || allowReplay) throw new Error('use debrief --review alone to inspect the pending proposal')
2206
+ const proposal = path.join(eng, DEBRIEF_PROPOSE)
2207
+ const refused = refuseSymlinkWrite(proposal, { soft: true })
2208
+ if (refused) throw new Error(refused)
2209
+ if (!fs.existsSync(proposal)) throw new Error('nothing to review - run debrief --smart <notes> first')
2210
+ const { clean: input } = splitPrivate(fs.readFileSync(proposal, 'utf8'), { sealDangling: true })
2211
+ return boundedDebriefPreview(eng, () => {
2212
+ printDebriefReview(input, eng)
2213
+ routeDebriefInput(eng, input, { dry: true, force: false })
2214
+ })
2215
+ }
2150
2216
  const dryIdx = args.indexOf('--dry-run')
2151
2217
  const dry = dryIdx !== -1
2152
2218
  if (dry) args.splice(dryIdx, 1)
@@ -2202,14 +2268,14 @@ function runDebrief(args, eng) {
2202
2268
  if (!apply) {
2203
2269
  console.log(`\nproposal saved → ${proposePath}`)
2204
2270
  console.log('confirm: fde debrief --apply')
2205
- console.log('(edit the propose file first if a line mis-routed)')
2271
+ console.log('(edit the propose file if mis-routed; debrief --review shows the pending REVIEW)')
2206
2272
  return
2207
2273
  }
2208
2274
  input = clean
2209
2275
  sealed = blocks
2210
2276
  }
2211
2277
 
2212
- const route = () => routeDebriefInput(eng, input, { dry, force, sealed })
2278
+ const route = () => routeDebriefInput(eng, input, { dry, force, sealed, allowReplay })
2213
2279
  const { counts, ctxLines, privateBlocks } = boundedDebriefPreview(eng, route, { proposal: false, maxBytes: smart ? 4000 : 12000 })
2214
2280
  if (!dry) {
2215
2281
  const hash = commitMemory(eng, 'debrief', {
@@ -2433,12 +2499,30 @@ function cmdReceipts(args) {
2433
2499
  if (!line.toLowerCase().includes(term.toLowerCase())) return
2434
2500
  const source = decisionSources.get(i + 1) || sourceReference(line)
2435
2501
  const hit = ` ${file}:${i + 1} ${line.trim().slice(0, 160)}${source ? ` [source: ${source.slice(0, 160)}]` : ' [source missing]'}${dirty.has(file) ? ' dirty file - review manual edits' : ''}`
2436
- ;(recordFiles.includes(file) && source ? records : claims).push(hit)
2502
+ ;(recordFiles.includes(file) && source ? records : claims).push({ file, hit })
2437
2503
  })
2438
2504
  }
2439
- const sections = ['RECEIPTS: a cited record is not proof of customer approval. File line numbers refer to the redacted view.']
2440
- if (records.length) sections.push('ON RECORD (dated, source-backed):\n' + records.join('\n'))
2441
- if (claims.length) sections.push('CLAIMS & working notes (verify source and approval before citing):\n' + claims.join('\n'))
2505
+ // Alternate the latest and earliest matching lines per file. Otherwise a
2506
+ // long history can spend the entire packet on approvals before a withdrawal.
2507
+ const select = hits => {
2508
+ const groups = new Map()
2509
+ for (const { file, hit } of hits) {
2510
+ if (!groups.has(file)) groups.set(file, [])
2511
+ groups.get(file).push(hit)
2512
+ }
2513
+ const selected = []
2514
+ let latest = true
2515
+ while (selected.length < 24 && [...groups.values()].some(group => group.length)) {
2516
+ for (const group of groups.values()) {
2517
+ if (group.length && selected.length < 24) selected.push(latest ? group.pop() : group.shift())
2518
+ }
2519
+ latest = !latest
2520
+ }
2521
+ return `Selected ${selected.length} of ${hits.length} matching lines; omitted matches require a narrower search.\n` + selected.join('\n')
2522
+ }
2523
+ const sections = ['RECEIPTS: a cited record is not proof of customer approval. File line numbers refer to the redacted view. Latest and earliest matching lines are sampled; file order is not authority. Check conflicting records.']
2524
+ if (records.length) sections.push('ON RECORD (dated, source-backed):\n' + select(records))
2525
+ if (claims.length) sections.push('CLAIMS & working notes (verify source and approval before citing):\n' + select(claims))
2442
2526
  if (!records.length && !claims.length) sections.push(`no record of "${term}" - a gap in the record, not proof of absence`)
2443
2527
  process.stdout.write(context.boundedSections(sections))
2444
2528
  }
@@ -2473,7 +2557,8 @@ function cmdHandoff(args, label = 'Handoff') {
2473
2557
  `## Constraints - trust-profile.md\n${stripTemplateNoise(readClean(eng, 'trust-profile.md')) || '(missing)'}`,
2474
2558
  `## Signer and success - success.md\nSigner: ${signer || '(missing; do not infer)'}\n${success || '(missing)'}`,
2475
2559
  `## Next action - context.md\n${next || '(missing)'}\n\n## Open risks - risks.md\n${extractRisks(eng).map(r => '- ' + r.text).join('\n') || '(none recorded; not proof of no risk)'}`,
2476
- `## Accepted value - recorded assertion with source\nOnly structured value-ledger rows are summarized here; review other notes in delivery.md before presenting or handing over this record.\n${ledger.filter(r => r.state === 'accepted').map(rowText).join('\n') || '(none)'}\n\n## CLAIMS and unmeasured promises\n${ledger.filter(r => r.state !== 'accepted').map(r => rowText(r) + ' [' + r.state + ']').join('\n') || '(none)'}`,
2560
+ label === 'Handoff' ? `## Operational handoff - handoff.md\n${stripTemplateNoise(readClean(eng, 'handoff.md')) || '(missing; record recovery steps and the operating owner before rotation)'}` : '',
2561
+ `## Accepted value - recorded assertion with source\nOnly structured value-ledger rows are summarized here; review other notes in delivery.md before presenting or handing over this record.\n${ledger.filter(r => r.state === 'accepted').map(rowText).join('\n') || '(none)'}\n\n## CLAIMS and unmeasured promises\n${ledger.filter(r => r.state !== 'accepted').map(r => rowText(r) + ' [' + r.state + ']' + (r.acceptanceIssue ? '; ' + r.acceptanceIssue : '')).join('\n') || '(none)'}`,
2477
2562
  `## ON RECORD decisions - source supplied, not automatic approval\n${records.map(decisionText).join('\n') || '(none)'}\n\n## CLAIM decisions - source missing\n${claims.map(decisionText).join('\n') || '(none)'}\nSelected ${selected.length} of ${decisions.length} dated decisions. Retrieve older or conflicting decisions with fde recall.`,
2478
2563
  `## Gaps before relying on this packet\n${gaps.map(g => '- ' + g).join('\n') || '(no deterministic lint gaps; human review still required)'}`,
2479
2564
  ], parsed.maxBytes)
@@ -2499,7 +2584,7 @@ function cmdRecall(args) {
2499
2584
  }
2500
2585
  const eng = resolveEngagement()
2501
2586
  if (!eng) { console.error('no engagement - bind a client before recall'); process.exit(2) }
2502
- const files = ['context.md', 'trust-profile.md', 'success.md', 'decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md', 'brief.md', 'reality.md', 'assumptions.md', 'terrain.md']
2587
+ const files = ['context.md', 'trust-profile.md', 'success.md', 'decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md', 'brief.md', 'reality.md', 'assumptions.md', 'terrain.md', 'handoff.md']
2503
2588
  const result = context.recallSections(files.map(file => ({ file, text: readClean(eng, file) })), query)
2504
2589
  process.stdout.write(context.boundedSections([
2505
2590
  `RECALL - ${eng}\n${result.total ? `${result.sections.length} of ${result.total} matching lines; refine the query if evidence is omitted.` : 'No matching record. This is not proof that the event never happened.'}\nSources are local record assertions; verify dates, supersession and approval scope.`,
@@ -2729,12 +2814,20 @@ function successContractIssues(success) {
2729
2814
  if (active !== -1 && line.trim()) checks[active] += ` ${line.trim()}`
2730
2815
  }
2731
2816
  const observable = checks.some(check => {
2817
+ // This is a lint check, not a semantic proof. Explicit fields let any
2818
+ // domain describe its test without depending on a vocabulary of verbs.
2819
+ const target = /(?:\b(?:within|under|at most|at least|exactly|zero|no missing|no duplicate|all|every|none|true|false|pass|fail|http)\b|[<>=])/i
2820
+ const vague = /\b(?:tbd|unknown|to be defined|improve|better|satisfactory|as expected|works well)\b/i
2821
+ if (vague.test(check)) return false
2822
+ const explicit = check.match(/(?:^|\s)(?:-\s*)?Input:\s*(.+?)\s+(?:-\s*)?Pass when:\s*(.+)$/i)
2823
+ if (explicit) return explicit[1].trim().length > 3 && target.test(explicit[2])
2732
2824
  const stimulus = /\b(?:test|drill|replay|runs?|request|sample|given|when|simulate|inject|compare|restore|verified|observed|measured)\b/i.test(check)
2825
+ || /^\d+\s+[a-z]/i.test(check)
2733
2826
  const result = /\b(?:returns?|rejects?|matches?|equals?|arrives?|alerts?|restores?|passes?|fails?|contains?|produces?|shows?|remains?|receives?)\b/i.test(check)
2734
- const target = /(?:\b(?:within|under|at most|at least|exactly|zero|no missing|no duplicate|all|every|none|true|false|pass|fail|http)\b|[<>=])/i.test(check)
2735
- return stimulus && result && target && !/\b(?:tbd|to be defined|improve|better|satisfactory|as expected|works well)\b/i.test(check)
2827
+ || /\b(?:zero|no duplicate|no missing)\s+[a-z]/i.test(check)
2828
+ return stimulus && result && target.test(check)
2736
2829
  })
2737
- if (!observable) issues.push('success.md needs a binary acceptance check: state a test/input and an observable pass/fail result under **Done when:** or **Acceptance check:**; numbers alone are not a check')
2830
+ if (!observable) issues.push('success.md needs a binary acceptance check: the wording was not recognized as a test/input and observable pass/fail result under **Done when:** or **Acceptance check:**. Use Input: and Pass when: for a domain-specific check; this lint does not prove readiness')
2738
2831
  const signerLine = ((text.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').replace(/\[source:[^\]]+\]/gi, '').trim()
2739
2832
  // A named primary signer may be followed by responsibilities or another
2740
2833
  // signer's role. Preserve the full record; validate only the leading name.
@@ -2997,7 +3090,7 @@ function hasValueBucket(eng) {
2997
3090
  }
2998
3091
 
2999
3092
  // Shared classification keeps CLI, dashboard, and vault acceptance consistent.
3000
- const { PENDING_CELL_RE, valueState, evidenceSource } = require('./lib/value-ledger')
3093
+ const { PENDING_CELL_RE, valueState, evidenceSource, reconcileValueRows } = require('./lib/value-ledger')
3001
3094
 
3002
3095
  function parseValueLedger(eng) {
3003
3096
  // Last section with actual rows, not merely the last non-empty one: a template
@@ -3027,9 +3120,9 @@ function parseValueLedger(eng) {
3027
3120
  const evidence = cell(row, idx.evidence)
3028
3121
  const acceptanceStatus = idx.acceptanceStatus === -1 ? undefined : cell(row, idx.acceptanceStatus)
3029
3122
  const state = valueState({ measured, accepted, acceptanceStatus, evidence })
3030
- rows.push({ slice, promised, measured, accepted, evidence, evidenceMissing: !evidenceSource(evidence), state })
3123
+ rows.push({ slice, promised, measured, accepted, acceptanceStatus, evidence, evidenceMissing: !evidenceSource(evidence), state })
3031
3124
  }
3032
- return { rows, columnMissing: idx.accepted === -1 }
3125
+ return { rows: reconcileValueRows(rows, readClean(eng, 'success.md')), columnMissing: idx.accepted === -1 }
3033
3126
  }
3034
3127
 
3035
3128
  function claimedValueRows(eng) {
@@ -3046,8 +3139,9 @@ function formatValueLedgerLine(r) {
3046
3139
  }
3047
3140
  const head = body ? `${name}: ${body}` : name
3048
3141
  if (r.state === 'accepted') return `${head} · accepted by ${r.accepted}`
3049
- if (r.state === 'claimed') return `${head} · claimed, not yet accepted`
3050
- return `${head} · not yet measured`
3142
+ const issue = r.acceptanceIssue ? `; ${r.acceptanceIssue}` : ''
3143
+ if (r.state === 'claimed') return `${head} · claimed, not yet accepted${issue}`
3144
+ return `${head} · not yet measured${issue}`
3051
3145
  }
3052
3146
 
3053
3147
  function valueLedgerStatusLines(eng, opts = {}) {
@@ -4003,6 +4097,8 @@ function printUsage() {
4003
4097
  fde log --undo remove the last CLI log/debrief entry from memory
4004
4098
  fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
4005
4099
  fde debrief --smart heuristic propose; REVIEW first (decided/asked/open/next/signer); --apply after one confirm
4100
+ --review inspect the pending REVIEW after editing, without replacing it
4101
+ --allow-replay explicitly apply already recorded sourced statements after review
4006
4102
  --replace-proposal explicitly discard a pending review when proposing different notes
4007
4103
  fde ingest stage … stage raw pull into <engagement>/.inbox/ (not .fde/)
4008
4104
  fde ingest list list staged inbox items
package/bin/lib/render.js CHANGED
@@ -374,7 +374,7 @@ function deliveryHtml(e) {
374
374
  return `<section class="fb-block fb-delivery" aria-label="Delivery evidence">
375
375
  <div class="fb-sec-row"><h2 class="fb-sec">Delivery evidence</h2><span class="fb-count">${rows.length} outcome${rows.length === 1 ? '' : 's'}</span></div>
376
376
  <p class="fb-evidence-note">Recorded in delivery.md. Acceptance reflects the saved record, not independent verification.</p>
377
- ${rows.length ? `<div class="fb-table-scroll" role="region" aria-label="Promised, measured and accepted outcomes" tabindex="0"><table class="fb-table fb-value-table"><thead><tr><th scope="col">Outcome</th><th scope="col">Promised</th><th scope="col">Measured</th><th scope="col">Acceptance</th><th scope="col">Evidence</th></tr></thead><tbody>${rows.map(r => `<tr><th scope="row">${inlineMd(r.slice || 'Outcome')}<span class="fb-value-state t-${r.state === 'accepted' ? 'green' : 'amber'}">${labels[r.state]}</span></th><td>${cell(r.promised, 'Not recorded')}</td><td>${cell(r.measured, 'Not yet measured')}</td><td>${cell(r.accepted, 'Not recorded')}</td><td>${cell(r.evidence, 'Not recorded')}</td></tr>`).join('')}</tbody></table></div>` : `<div class="fb-empty-evidence"><strong>No delivery evidence yet</strong><p>Ask your agent to define one useful increment: the expected result, how to measure it, and who will accept it.</p></div>`}
377
+ ${rows.length ? `<div class="fb-table-scroll" role="region" aria-label="Promised, measured and accepted outcomes" tabindex="0"><table class="fb-table fb-value-table"><thead><tr><th scope="col">Outcome</th><th scope="col">Promised</th><th scope="col">Measured</th><th scope="col">Acceptance</th><th scope="col">Evidence</th></tr></thead><tbody>${rows.map(r => `<tr><th scope="row">${inlineMd(r.slice || 'Outcome')}<span class="fb-value-state t-${r.state === 'accepted' ? 'green' : 'amber'}">${labels[r.state]}</span>${r.acceptanceIssue ? `<span class="fb-muted">${inlineMd(r.acceptanceIssue)}</span>` : ''}</th><td>${cell(r.promised, 'Not recorded')}</td><td>${cell(r.measured, 'Not yet measured')}</td><td>${cell(r.accepted, 'Not recorded')}</td><td>${cell(r.evidence, 'Not recorded')}</td></tr>`).join('')}</tbody></table></div>` : `<div class="fb-empty-evidence"><strong>No delivery evidence yet</strong><p>Ask your agent to define one useful increment: the expected result, how to measure it, and who will accept it.</p></div>`}
378
378
  </section>`
379
379
  }
380
380
 
package/bin/lib/trust.js CHANGED
@@ -3,7 +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
+ stripTemplateNoise, stripLegendLines, extractRisks,
7
7
  } = deps
8
8
 
9
9
  // phase / trust / top risk / freshness - identical heuristic for status + dashboard.
@@ -129,27 +129,7 @@ function createTrustApi(deps) {
129
129
  }
130
130
 
131
131
  function countOpenRisks(eng) {
132
- const md = readClean(eng, 'risks.md')
133
- const body = md.split(/^#{1,6}\s+Retired\b/im)[0] || md
134
- let n = 0
135
- for (const raw of body.split('\n')) {
136
- const t = raw.trim()
137
- if (!t || t.startsWith('<!--') || /^#{1,6}\s/.test(t)) continue
138
- if (/risk\s*\|\s*status|mitigation/i.test(t) || /^\|?[\s|:-]+$/.test(t)) continue
139
- // Bullet risk with substance (skip empty "- " stubs). The bullet marker
140
- // must be followed by space: "**Status:** open · closed" is a legend, and
141
- // counting it as a risk reported one open risk on an empty register.
142
- if (/^[-*]\s/.test(t)) {
143
- if (t.replace(/^[-*]\s+/, '').trim()) n++
144
- continue
145
- }
146
- // Table row: first cell must have risk text (day-1 "| | open | |" placeholders don't count).
147
- if (/^\|/.test(t) && t.length > 12) {
148
- const riskCell = t.split('|').map(c => c.trim())[1] || ''
149
- if (riskCell) n++
150
- }
151
- }
152
- return n
132
+ return extractRisks(eng).length
153
133
  }
154
134
 
155
135
  function nextActionLine(ctx) {
@@ -166,7 +146,7 @@ function createTrustApi(deps) {
166
146
  function computeSignals(eng) {
167
147
  // readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
168
148
  // to the terminal and the rendered HTML - a <private> risk must never surface.
169
- const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md'); const risks = readClean(eng, 'risks.md')
149
+ const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md')
170
150
  // Prefer structured tokens from stakeholders + CLI ledger (ledger survives wipes)
171
151
  const signalText = stake + '\n' + readClean(eng, SIGNAL_LEDGER)
172
152
  const phase = parsePhase(ctx)
@@ -215,11 +195,7 @@ function createTrustApi(deps) {
215
195
  trust = sLines.some(l => /\bred\b/i.test(l)) ? 'RED'
216
196
  : sLines.some(l => /amber|gone quiet|routing around|escalat/i.test(l)) ? 'amber' : 'new'
217
197
  }
218
- const topRisk = (risks.split('\n').find(l => {
219
- const t = l.trim()
220
- return /^[-|]/.test(t) && t.length > 20 && !/^\|?[-\s|]+$/.test(t) &&
221
- !/risk\s*\|\s*status|mitigation/i.test(t) && !t.startsWith('<!--')
222
- }) || '').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
198
+ const topRisk = (extractRisks(eng)[0]?.text || '').replace(/\s+/g, ' ').trim().slice(0, 80)
223
199
  // Prefer trust trigger / memory warn over a random risk line; always keep mem.warn available
224
200
  const reason = (trustReason || mem.warn) ? (trustReason || mem.warn) : topRisk
225
201
  // What the triage line is actually quoting. A risk bullet printed under
@@ -26,6 +26,7 @@ function evidenceSource(evidence) {
26
26
  }
27
27
 
28
28
  function valueState({ measured, accepted, acceptanceStatus, evidence }) {
29
+ if (withdrawalMention({ measured, accepted, acceptanceStatus, evidence })) return 'claimed'
29
30
  if (!measured || PENDING_CELL_RE.test(measured)) return 'unmeasured'
30
31
  if (!acceptanceName(accepted) || !evidenceSource(evidence)) return 'claimed'
31
32
  // Explicit status is authoritative when the column exists. Unknown values
@@ -37,4 +38,48 @@ function valueState({ measured, accepted, acceptanceStatus, evidence }) {
37
38
  return 'accepted'
38
39
  }
39
40
 
40
- module.exports = { PENDING_CELL_RE, acceptanceName, evidenceSource, valueState }
41
+ // These checks flag explicit conflicts for human review. They do not authenticate
42
+ // approval, infer delegation, or revoke history when the current signer changes.
43
+ function withdrawalMention(row) {
44
+ return [row.measured, row.accepted, row.acceptanceStatus, row.evidence].filter(Boolean).some(value => {
45
+ const text = String(value)
46
+ .replace(/\[source:[^\]]*\]/gi, '')
47
+ .replace(/\S*[/\\]\S*/g, '') // artifact names are sources, not withdrawal events
48
+ .replace(/\b(?:not|never)\s+(?:been\s+)?(?:withdrawn|retracted|revoked|superseded)\b/gi, '')
49
+ // A withdrawn result is different from a result counting revoked tokens.
50
+ return /^(?:withdrawn|retracted|revoked|superseded)(?:\s*:|\s*$)/i.test(text.trim()) ||
51
+ /\b(?:approval|acceptance|evidence|measurement|result|assertion)\b[^.;\n]{0,40}\b(?:withdrawn|retracted|revoked|superseded)\b/i.test(text) ||
52
+ /\b(?:withdrawn|retracted|revoked|superseded|retracts?|withdraws?)\b[^.;\n]{0,25}\b(?:approval|acceptance|evidence|measurement|result|assertion)\b/i.test(text)
53
+ })
54
+ }
55
+
56
+ function scopeIssue(row, goal) {
57
+ const rowPromise = String(row.promised || '')
58
+ const goalLine = String(goal).match(/^(?:\*\*)?(?:Done when|Acceptance check):(?:\*\*)?\s*(.*)$/im)
59
+ const scopeSpecified = /\b(?:production|staging|synthetic|slides?|demo|prototype|poc)\b/i.test(rowPromise)
60
+ const promise = scopeSpecified ? rowPromise : `${rowPromise} ${goalLine ? goalLine[1] : goal}`
61
+ const acceptance = String(row.accepted || '')
62
+ const limited = acceptance.match(/\b(staging|slides?(?: design)?|demo|prototype|poc)\s+only\b/i)
63
+ if (limited) {
64
+ const scope = limited[1].toLowerCase()
65
+ const matchingGoal = scope.startsWith('slide') ? /\bslides?\b/i.test(promise) : new RegExp('\\b' + scope + '\\b', 'i').test(promise)
66
+ if (!matchingGoal || /\b(?:production|clinical use|go.live)\b/i.test(promise)) return 'approval scope is limited; review against the promised outcome'
67
+ }
68
+ const measured = String(row.measured || '')
69
+ const productionUntested = measured.split(/[;.\n]/).some(clause => /\bproduction\b/i.test(clause) && /\b(?:not|never|untested|unmeasured|pending)\b/i.test(clause))
70
+ if (/\bproduction\b/i.test(promise) && /\b(?:staging|synthetic|prototype|poc)\b/i.test(measured) && (!/\bproduction\b/i.test(measured) || productionUntested)) return 'measurement scope differs from production promise; review required'
71
+ return ''
72
+ }
73
+
74
+ function reconcileValueRows(rows, goal = '') {
75
+ const key = row => String(row.slice || '').trim().toLowerCase()
76
+ const withdrawn = new Set(rows.filter(withdrawalMention).map(key).filter(Boolean))
77
+ return rows.map(row => {
78
+ let acceptanceIssue = scopeIssue(row, goal)
79
+ if (withdrawalMention(row)) acceptanceIssue = 'withdrawal recorded; review current acceptance'
80
+ else if (withdrawn.has(key(row))) acceptanceIssue = 'conflicting withdrawal for this slice; review history before relying on acceptance'
81
+ return acceptanceIssue ? { ...row, acceptanceIssue, state: row.state === 'unmeasured' ? 'unmeasured' : 'claimed' } : row
82
+ })
83
+ }
84
+
85
+ module.exports = { PENDING_CELL_RE, acceptanceName, evidenceSource, valueState, reconcileValueRows }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops-ingest-mcp",
3
- "version": "3.27.1",
3
+ "version": "3.28.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.27.1",
3
+ "version": "3.28.0",
4
4
  "description": "Client delivery tools for Forward Deployed Engineers. One @fde skill, local Markdown engagement records, and an offline dashboard for decisions, evidence, approvals, and next actions.",
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.27.1",
4
+ "version": "3.28.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",
@@ -4,11 +4,11 @@
4
4
 
5
5
  **Read first:** `context.md` if it exists - otherwise start cold. The point of this phase is to establish ground truth, not assume it.
6
6
 
7
- ## Method - part 1: read everything that exists (you do this work)
7
+ ## Method - part 1: inspect the inherited record (you do this work)
8
8
 
9
9
  Before forming any opinion:
10
10
 
11
- 1. **Inherit the paper.** Any previous `.fde/`, docs, README claims, ADRs, ticket history the FDE can export. Read it all - the previous FDE's decisions are evidence, not verdicts.
11
+ 1. **Inherit the paper.** Start with `fde resume` and inventory the available docs, ADRs, ticket exports and operational handoff. Do not recursively load `.fde/` or raw transcripts. List the claims and unknowns, then use `fde recall <specific topic>` to retrieve bounded evidence for each consequential claim. Review the relevant source when an excerpt is insufficient; keep unrelated history on disk. Previous decisions are evidence, not verdicts.
12
12
  2. **Run the discover scans** (see `discover.md` part 1: churn, test gaps, "temporary" grep, AI components). On a takeover, add:
13
13
  ```bash
14
14
  git log --format="%an" | sort | uniq -c | sort -rn | head # who actually built this
@@ -55,7 +55,7 @@ Build without a plan in an inherited system is the fastest path to the second in
55
55
 
56
56
  ## Principles
57
57
 
58
- - Read everything that exists before forming any opinion.
58
+ - Inventory the record; verify consequential claims through targeted, bounded retrieval before forming an opinion.
59
59
  - "It should work" is not "it works." Verify.
60
60
  - The most dangerous systems are the ones everyone assumes someone else understands.
61
61
  - Don't build until `audit.md`, `terrain.md`, `reality.md` are written.
@@ -30,7 +30,7 @@
30
30
  - `signer: Priya` (she can say yes; lands in `success.md`)
31
31
  - `next: send one-pager before Thursday 9am`
32
32
  - unprefixed lines stay context color only
33
- 4. Show the **REVIEW** block first (decided / asked / open / next / signer). That is the one screen to confirm. File routing stays underneath.
33
+ 4. After editing, run `fde debrief --review` to show the pending proposal without replacing it. Show the **REVIEW** block first (decided / asked / open / next / signer). That is the one screen to confirm. File routing stays underneath.
34
34
  5. In **chat**, after that REVIEW, present a four-row card and omit empty rows:
35
35
  - Decided
36
36
  - Asked / open
@@ -74,3 +74,15 @@ Read back the 2-3 most consequential captures in one breath - so the FDE can cor
74
74
  - Verbatim quote outranks paraphrase; hesitation outranks quote.
75
75
  - Signals move on evidence, never on vibe alone.
76
76
  - A meeting with no decisions and no actions - say so; that is a finding.
77
+
78
+ ## Delivery rows and repeated updates
79
+
80
+ For a measured or promised slice, use a reviewed structured line:
81
+
82
+ ```text
83
+ delivery: Replay|risk-mitigation|zero duplicates|zero duplicates on staging|pending|[source: transcript:42]|pending
84
+ ```
85
+
86
+ The seven fields are Slice, Bucket, Promised, Measured, Accepted by, Evidence, Rollback. Keep unknowns `pending`; never infer approval. This lands in the value ledger during the same confirmed apply. A `delivery:` line without pipes stays a narrative note. Incorrect field counts refuse the write rather than shifting the meaning of cells.
87
+
88
+ A sourced statement already in the record triggers a replay warning. Before applying, compare newer facts and the current next action. Remove repeated statements from the proposal if this is an accidental re-import. Only after the engineer explicitly confirms an intentional repeat, apply with `fde debrief --apply --allow-replay`. This does not silently deduplicate history and does not authenticate sources.