fdeops 3.27.0 → 3.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,158 +1,139 @@
1
1
  # FDEOps
2
2
 
3
- **The local engagement OS for AI coding agents.**
3
+ **Forward deployed engineering skills for AI coding agents.**
4
4
 
5
- FDEOps helps a Forward Deployed Engineer or consultant carry a client engagement from a messy brief to a defensible handover. One `@fde` entry guides the work. A local CLI keeps the record in Markdown; an offline fieldbook shows what needs attention.
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.
6
6
 
7
- | When client work goes wrong | What FDEOps helps you keep straight |
8
- |---|---|
9
- | The brief describes the wrong problem | What was requested, what you observed, and which assumptions remain untested |
10
- | Nobody can say who signs | The acceptance owner, approval scope, and unresolved authority |
11
- | A good number becomes a success claim | What was promised, measured, and accepted, with its source |
12
- | You switch clients, agents, or engineers | Decisions, constraints, evidence, and one next action in each client's record |
7
+ <a name="who-this-is-for"></a>
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.
10
+
11
+ [Install](#quick-start) · [See the dashboard](#your-daily-fieldbook) · [Documentation](docs/README.md)
13
12
 
14
- You make the judgments and obtain customer approval. The record helps you explain them later.
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" />
15
14
 
16
- <img width="1536" height="1024" alt="fdeops" src="https://github.com/user-attachments/assets/2bcb8739-55ee-445d-8a1a-8b38433b7b58" />
15
+ ## Why use it?
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
  ## Quick Start
19
22
 
20
- Requires Node.js 18+, Git, and an AI coding agent for the guided workflow. `npx` may download packages; the FDEOps CLI operates locally.
23
+ Requires Node.js 18+, Git, and an AI coding agent that supports skills. Install the FDEOps skill:
21
24
 
22
25
  ```bash
23
26
  npx skills add suboss87/fdeops --skill fde
24
27
  ```
25
28
 
26
- Open the client workspace and tell your agent:
29
+ Open a client workspace and tell your agent:
27
30
 
28
31
  ```text
29
32
  @fde this is client01
30
33
  ```
31
34
 
32
- The agent creates `~/fde-engagements/client01/.fde/` and binds the workspace to it. If the host cannot run setup, use `npx fdeops resume --init client01` to create the engagement and binding.
33
-
34
- **Try the record before using client data:** `npx fdeops demo` runs a fictional notes-to-fieldbook workflow without a model. It writes under `~/fde-engagements/.demo/` and resets that sandbox each run. Remove it with `npx fdeops demo --clean`. For repository reconnaissance without writing engagement records, use `npx fdeops scan`.
35
-
36
- <details>
37
- <summary>Host installation and offline use</summary>
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.
38
36
 
39
- Claude Code plugin:
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.
40
38
 
41
- ```text
42
- /plugin marketplace add suboss87/fdeops
43
- /plugin install fdeops@fdeops
44
- ```
45
-
46
- The plugin registers session hooks and slash commands. Skill-only installations do not register those hooks. For Cursor, Codex, Gemini, or Copilot, install the skill and run `npx fdeops adapters .` in the client workspace; this writes instruction pointers.
47
-
48
- Clone installation:
39
+ **Try it without an AI model:**
49
40
 
50
41
  ```bash
51
- git clone https://github.com/suboss87/fdeops.git
52
- cd fdeops
53
- node bin/install.js
42
+ npx fdeops demo
54
43
  ```
55
44
 
56
- Offline machines need an already-transferred checkout, Node.js, and Git. Advanced override: `FDEOPS_ENGAGEMENT`. See [installation](docs/install.md) and [host adapters](adapters/README.md).
57
-
58
- </details>
59
-
60
- ## Commands
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.
61
46
 
62
- One command per stage. Skills load automatically. Describe the situation to `@fde`; the Claude Code plugin also provides these slash commands.
47
+ ⭐ **If FDEOps makes your client work easier, star the repo.**
63
48
 
64
- | Stage | Command | Result to review |
65
- |---|---|---|
66
- | Land | `/brief` | The brief, unknowns, and who can accept the work |
67
- | Discover | `/discover` | The actual workflow and evidence behind the problem |
68
- | Plan | `/plan` | A small deliverable, constraints, and acceptance criteria |
69
- | Ship | `/ship` | Proof on their staging, release approval, and rollback |
70
- | Outcome | `/outcome` | Promised, measured, accepted, and supporting evidence |
71
- | Close | `/close` | Runbook, operating owner, and handover gaps |
49
+ ## What a working day looks like
72
50
 
73
- Daily requests: `/debrief`, `/prep`, `/receipts`, `/readout`, and `/trust`. Revisit earlier stages when evidence changes; a new incident does not require restarting discovery.
74
-
75
- ## One loop you can defend
76
-
77
- Paste messy notes after a meeting:
51
+ You come out of a meeting with this:
78
52
 
79
53
  ```text
80
- @fde Debrief: Mara agreed to CSV upload. Devon asked for ERP sync,
81
- but Mara has not approved it. The staging replay took 5 minutes;
82
- we have no comparable production baseline. Ask Mara for staging access.
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.
83
58
  ```
84
59
 
85
- 1. **Review:** the agent separates decisions, requests, unknowns, measurements, and next actions. Check the source and any conflicts with existing records.
86
- 2. **Apply:** confirm the proposed interpretation. An ERP request stays a request; a staging result stays a staging result. Saving the record does not mean the customer approved either.
87
- 3. **Defend:** ask `@fde What did we agree about ERP, and what evidence supports the result?` Review the supplied source, superseded decisions, and missing evidence before using the answer in a sponsor update.
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.
88
61
 
89
- The CLI supports this loop with `debrief --smart`, reviewed `--apply`, `recall`, `receipts`, and `defend`. `fde handoff --out successor.md` creates a new portable, redacted packet. See the [walkthrough](docs/USAGE.md) for examples. Direct CLI write commands execute when invoked; enabled session hooks automatically capture mechanical session state. Agent judgments still need review.
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.
90
63
 
91
- ## Your daily fieldbook
64
+ [Walk through notes → REVIEW → apply → evidence](docs/USAGE.md#new-here-5-minutes).
92
65
 
93
- ![FDEOps dark dashboard showing next actions and attention gaps across three fictional clients](media/fieldbook-preview.png)
66
+ ## Your daily fieldbook
94
67
 
95
- *Fictional client records in the built-in dark theme. The report works offline.*
68
+ ![Dark FDEOps fieldbook with one recommended action per client and the gaps behind it](media/fieldbook-preview.png)
96
69
 
97
70
  ```bash
98
- npx fdeops dashboard --open # this client
71
+ npx fdeops dashboard --open # current client
99
72
  npx fdeops dashboard --all --open # all clients
100
73
  ```
101
74
 
102
- Filter what needs attention, open a client, and copy **Continue next action**, **Debrief notes**, or **Review outcome** into your agent. The fieldbook is a read-only snapshot: work happens in your agent or CLI, then you regenerate the report. Review confidential information before sharing it with a sponsor or incoming engineer.
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.
103
76
 
104
- ## How Skills Work
77
+ <a name="how-skills-work"></a>
105
78
 
106
- One entry, one source of methodology, one record per client:
79
+ ## From the first meeting to handover
107
80
 
108
- ```text
109
- @fde + your situation → one relevant skill → reviewed work → .fde/ → fieldbook
110
- ```
81
+ Describe the situation to `@fde`; it selects the relevant workflow. You do not need to learn a catalog of skills.
111
82
 
112
- **All 30 skills:** Not prompts to choose from. Each reference contains steps, an artifact, and a checkpoint. The [skills guide](docs/skills.md) has three short checklists for day zero, discovery to a small ship, and POC to production. The [full reference](docs/skills-reference.md) covers all stages and overlays.
113
-
114
- Optional external sources use MCP connections configured in your agent host. Pull material on request, review its interpretation, then apply it. The CLI does not connect to those services. See [source recipes](mcp/recipes/).
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 |
115
91
 
116
- ## Engagement memory (`.fde/`)
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.
117
93
 
118
- | Record | What it preserves |
119
- |---|---|
120
- | `context.md` | Current state and next action |
121
- | `brief.md`, `reality.md`, `terrain.md` | Request, observed problem, and system constraints |
122
- | `success.md`, `stakeholders.md`, `trust-profile.md` | Acceptance criteria, people, data policy, and authority |
123
- | `decisions.md`, `risks.md`, `delivery.md` | Choices, unresolved risks, measurements, evidence, and recorded acceptance |
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.
124
95
 
125
- Markdown stays on your machine when you change hosts. Bounded `resume` and topic-based `recall` reduce what enters the active context; omitted history still needs retrieval. See the [record schema](docs/schema.md) and [verification results](docs/verification.md). Passing software tests does not establish reliable judgment from every model.
96
+ ## Use the CLI directly
126
97
 
127
- ## Who this is for
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`.
128
99
 
129
- FDEs, independent consultants, and solo agencies working inside customer systems. Sponsors and incoming engineers can review the resulting records and reports without learning the skill catalog. FDEOps supports delivery decisions; it does not replace customer authority or operate their infrastructure for you.
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` |
130
109
 
131
- ## Principles
110
+ [All commands and examples](docs/USAGE.md).
132
111
 
133
- - Verify the brief before building; keep unknowns explicit.
134
- - Name who can accept which outcome.
135
- - Keep promised, measured, and accepted results separate.
136
- - Prove a small change where the client will operate it, with a tested recovery path.
137
- - Leave a record another engineer can understand and challenge.
138
- - Keep each client separate and confirm the active binding before writing.
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.
139
113
 
140
- ## Your data stays yours
114
+ ## Your records, your control
141
115
 
142
- The CLI uses local files and Git, with no network or telemetry. Your AI host may send the material it reads to its configured model. CLI, hook, and fieldbook outputs redact `<private>` blocks; do not paste or load raw private blocks with file tools. Follow customer storage policy and review reports before sharing.
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.
143
117
 
144
- [Privacy](PRIVACY.md) · [Security](SECURITY.md)
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.
145
119
 
146
- ## Project Structure
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.
147
121
 
148
- Methodology lives in `skills/fde/`; deterministic commands and shared helpers in `bin/`; host entry points in `adapters/` and `hooks/`. `templates/`, `test/`, `evals/`, and `examples/` support the same workflow. The [repository map and documentation index](docs/REPO_LAYOUT.md) explain where to start and where changes belong.
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).
149
123
 
150
- ## Contributing
124
+ ## Find your way around
151
125
 
152
- Maintained by **[Subash Natarajan](https://www.linkedin.com/in/subashn/)**. 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).
126
+ | You want to… | Start here |
127
+ |---|---|
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) |
153
134
 
154
- [Contribution guide](CONTRIBUTING.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
135
+ ## Contribute
155
136
 
156
- ## License
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.
157
138
 
158
- MIT - use FDEOps on client work. Preserve applicable license notices when redistributing.
139
+ Built and maintained by **[Subash Natarajan](https://github.com/suboss87)**. MIT licensed; preserve applicable notices when redistributing.
package/bin/check.js CHANGED
@@ -264,39 +264,28 @@ for (const m of readme.matchAll(/(?:\]\(|src=")([^)"#\s]+)(?:\)|")/g)) {
264
264
  if (brokenLinks.length) fail(`README links to missing paths: ${brokenLinks.join(', ')}`)
265
265
  else ok('README links all resolve')
266
266
 
267
- for (const section of [
268
- 'How Skills Work',
269
- 'Quick Start',
270
- 'Engagement memory',
271
- 'Who this is for',
272
- 'Commands',
273
- 'Principles',
274
- ]) {
275
- if (!readme.includes(section)) fail(`README missing section: ${section}`)
267
+ // Validate usable entry points, not a fixed heading order or marketing copy.
268
+ // Host-specific commands and advanced setup belong in the linked guides.
269
+ for (const target of ['docs/install.md', 'docs/USAGE.md', 'docs/REPO_LAYOUT.md', 'docs/verification.md', 'PRIVACY.md']) {
270
+ if (!readme.includes(target)) fail(`README must link its public guide: ${target}`)
276
271
  }
277
- if (!readme.includes('AI coding agent')) {
278
- fail('README must say AI coding agent (not ambiguous "agent")')
272
+ if (!readme.includes('AI coding agent') || !/FDE|Forward Deployed Engineer/i.test(readme)) {
273
+ fail('README must identify the tool and its intended users')
274
+ }
275
+ if (!/npx skills add suboss87\/fdeops --skill fde/.test(readme) || !/@fde\s+this is/.test(readme)) {
276
+ fail('README must show skill installation and how to start a client')
279
277
  }
280
- ok('README clarity sections')
281
-
282
278
  for (const cmd of ['/brief', '/discover', '/plan', '/ship', '/outcome', '/close', '/debrief', '/prep', '/trust', '/receipts', '/readout']) {
283
- if (!readme.includes(cmd)) fail(`README must document slash command ${cmd}`)
279
+ if (!(readme + usage).includes(cmd)) fail(`Public usage documentation missing slash command ${cmd}`)
284
280
  }
285
281
  if (/(^|[^\w/])\/got\b/.test(readme)) fail('README must use /outcome, not /got')
286
- ok('README slash commands documented')
287
-
288
- // Front-door map is the embed left-to-right (Land → Close). After the GitHub
289
- // poster (#68) the table is the map: /brief /discover /plan /ship /outcome /close.
290
- const front = readme.slice(0, 4000)
291
- if (!['/brief', '/discover', '/plan', '/ship', '/outcome', '/close'].every(c => front.includes(c))) {
292
- fail('README must include the Land→Close command map near the top')
293
- } else ok('README command-map diagram')
282
+ ok('README entry points and documented commands')
294
283
 
295
284
  if (readme.includes('your-client-repo')) {
296
285
  fail('README must not instruct install in customer repo (your-client-repo)')
297
286
  } else ok('README no customer-repo install')
298
287
 
299
- if (!readme.includes('fde-engagements') || !/fdeops.*init.*engagement/i.test(readme)) {
288
+ if (!readme.includes('fde-engagements') || !/fdeops.*resume --init/i.test(readme + read('docs/install.md'))) {
300
289
  fail('README must document fde-engagements + init flow')
301
290
  } else ok('README engagement path')
302
291
 
@@ -334,9 +323,9 @@ if (!fs.existsSync(path.join(root, 'docs', 'USAGE.md'))) {
334
323
  fail('docs/USAGE.md missing')
335
324
  } else ok('docs/USAGE.md')
336
325
 
337
- if (!readme.includes('FDEOPS_ENGAGEMENT')) {
338
- fail('README must document FDEOPS_ENGAGEMENT')
339
- } else ok('README FDEOPS_ENGAGEMENT')
326
+ if (!read('docs/install.md').includes('FDEOPS_ENGAGEMENT')) {
327
+ fail('Installation guide must document FDEOPS_ENGAGEMENT')
328
+ } else ok('Installation override documented')
340
329
 
341
330
  const badPhrases = ['team of ten', 'solo 100x', '100x engineer']
342
331
  for (const phrase of badPhrases) {
@@ -356,12 +345,6 @@ for (const rx of derivativeFraming) {
356
345
  if (/docs\/internal|PMF_360/i.test(readme)) {
357
346
  fail('README must not link docs/internal or PMF_360')
358
347
  }
359
- if (!/One command per stage/.test(readme) || !/Skills load automatically/.test(readme)) {
360
- fail('README must formulate Commands as: one command per stage, skills load automatically')
361
- }
362
- if (!/Not prompts/.test(readme)) {
363
- fail('README catalog must say skills are not prompts')
364
- }
365
348
  if (/\b(30|31|37)\s+methods\b|\broutes methods\b|\bphase methods\b|\bfield methods\b|\bengagement methods\b/.test(readme)) {
366
349
  fail('README must call the catalog skills, not methods')
367
350
  }
package/bin/fde.js CHANGED
@@ -487,7 +487,11 @@ function formatFsError(err, action, target) {
487
487
  return `cannot ${action} ${where}${code ? ` (${code})` : ''}${err && err.message && !code ? ': ' + err.message : ''}`
488
488
  }
489
489
 
490
+ let debriefTransactionActive = false
491
+ const ownedDebriefLocks = new Set()
492
+
490
493
  function failFs(err, action, target) {
494
+ if (debriefTransactionActive || ownedDebriefLocks.size) throw new Error(formatFsError(err, action, target))
491
495
  console.error(formatFsError(err, action, target))
492
496
  process.exit(1)
493
497
  }
@@ -503,6 +507,7 @@ function refuseSymlinkWrite(p, opts = {}) {
503
507
  const msg = st.isSymbolicLink()
504
508
  ? `refused: ${path.basename(p)} is a symlink - write would leave the engagement tree. Replace it with a real file.`
505
509
  : `refused: ${path.basename(p)} is not a regular file - remove it and re-run; every write is refused while it is there.`
510
+ if ((debriefTransactionActive || ownedDebriefLocks.size) && !opts.soft) throw new Error(msg)
506
511
  if (opts.soft) return msg
507
512
  console.error(msg)
508
513
  process.exit(1)
@@ -518,6 +523,8 @@ function refuseSymlinkWrite(p, opts = {}) {
518
523
  // Exclusive create lock + retry. Two parallel agent sessions (or hook + CLI)
519
524
  // appending the same .fde file otherwise interleave/corrupt under load.
520
525
  function withFileLock(targetPath, fn, opts = {}) {
526
+ if (ownedDebriefLocks.has(targetPath)) return fn()
527
+ if (debriefTransactionActive || ownedDebriefLocks.size) opts = { ...opts, soft: true }
521
528
  const lockPath = targetPath + '.lock'
522
529
  const deadline = Date.now() + 5000
523
530
  while (true) {
@@ -1789,28 +1796,24 @@ function readDebriefInput(args) {
1789
1796
  if (args[0]) {
1790
1797
  const notesPath = args[0].replace(/^~/, HOME)
1791
1798
  let st
1792
- try { st = fs.statSync(notesPath) } catch (_) { console.error(`cannot read ${args[0]}`); process.exit(1) }
1799
+ try { st = fs.statSync(notesPath) } catch (_) { throw new Error(`cannot read ${args[0]}`) }
1793
1800
  if (st.size > DEBRIEF_MAX_BYTES) {
1794
- console.error(`debrief refused: ${args[0]} is ${st.size} bytes (max ${DEBRIEF_MAX_BYTES}). Split the notes or paste the relevant section.`)
1795
- process.exit(1)
1801
+ throw new Error(`debrief refused: ${args[0]} is ${st.size} bytes (max ${DEBRIEF_MAX_BYTES}). Split the notes or paste the relevant section.`)
1796
1802
  }
1797
1803
  let buf
1798
- try { buf = fs.readFileSync(notesPath) } catch (_) { console.error(`cannot read ${args[0]}`); process.exit(1) }
1804
+ try { buf = fs.readFileSync(notesPath) } catch (_) { throw new Error(`cannot read ${args[0]}`) }
1799
1805
  if (buf.includes(0) || looksLikeBinaryNoise(buf.toString('utf8'))) {
1800
- console.error(`debrief refused: ${args[0]} looks binary or mostly non-printable. Paste text notes only.`)
1801
- process.exit(1)
1806
+ throw new Error(`debrief refused: ${args[0]} looks binary or mostly non-printable. Paste text notes only.`)
1802
1807
  }
1803
1808
  input = buf.toString('utf8')
1804
1809
  } else {
1805
1810
  let buf
1806
1811
  try { buf = fs.readFileSync(0) } catch (_) { buf = Buffer.alloc(0) }
1807
1812
  if (Buffer.byteLength(buf) > DEBRIEF_MAX_BYTES) {
1808
- console.error(`debrief refused: stdin is over ${DEBRIEF_MAX_BYTES} bytes. Split the notes.`)
1809
- process.exit(1)
1813
+ throw new Error(`debrief refused: stdin is over ${DEBRIEF_MAX_BYTES} bytes. Split the notes.`)
1810
1814
  }
1811
1815
  if (buf.includes(0) || looksLikeBinaryNoise(buf.toString('utf8'))) {
1812
- console.error('debrief refused: stdin looks binary or mostly non-printable. Paste text notes only.')
1813
- process.exit(1)
1816
+ throw new Error('debrief refused: stdin looks binary or mostly non-printable. Paste text notes only.')
1814
1817
  }
1815
1818
  input = buf.toString('utf8')
1816
1819
  }
@@ -1823,15 +1826,29 @@ function previewLine(text, max = 240) {
1823
1826
  return `${t.slice(0, max)}… (${t.length} chars)`
1824
1827
  }
1825
1828
 
1826
- function writeProposal(eng, text) {
1829
+ function writeProposal(eng, text, { replace = false, locked = false } = {}) {
1830
+ if (!locked && ownedDebriefLocks.has(path.join(eng, DEBRIEF_PROPOSE))) return writeProposal(eng, text, { replace, locked: true })
1831
+ if (!locked) return withFileLock(path.join(eng, DEBRIEF_PROPOSE), () => {
1832
+ ownedDebriefLocks.add(path.join(eng, DEBRIEF_PROPOSE))
1833
+ try { return writeProposal(eng, text, { replace, locked: true }) }
1834
+ finally { ownedDebriefLocks.delete(path.join(eng, DEBRIEF_PROPOSE)) }
1835
+ }, { soft: true })
1836
+ if (!debriefTransactionActive) return withDebriefRecords(eng, () => writeProposal(eng, text, { replace, locked: true }), [DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL])
1827
1837
  const { clean, blocks } = splitPrivate(text, { sealDangling: true })
1828
1838
  const proposePath = path.join(eng, DEBRIEF_PROPOSE)
1829
1839
  const privatePath = path.join(eng, DEBRIEF_PRIVATE)
1840
+ if (fs.existsSync(proposePath) && !replace) {
1841
+ const existing = fs.readFileSync(proposePath, 'utf8')
1842
+ const existingPrivate = readSealedProposal(eng)
1843
+ if (existing !== clean || JSON.stringify(existingPrivate) !== JSON.stringify(blocks)) {
1844
+ throw new Error('pending proposal already exists. Review and apply it first, or explicitly replace it with fde debrief --smart <notes> --replace-proposal.')
1845
+ }
1846
+ }
1830
1847
  // Seal first. A refused or failed sidecar write must not leave behind a
1831
1848
  // proposal whose (private - redacted) marker has nothing left behind it.
1832
1849
  if (blocks.length) {
1833
1850
  const blocked = refuseSymlinkWrite(privatePath, { soft: true })
1834
- if (blocked) { console.error(blocked); process.exit(1) }
1851
+ if (blocked) throw new Error(blocked)
1835
1852
  withFileLock(privatePath, () => { atomicWriteFile(privatePath, sealedText(blocks), { mode: 0o600 }) })
1836
1853
  try { fs.chmodSync(privatePath, 0o600) } catch (_) {}
1837
1854
  } else {
@@ -1898,12 +1915,13 @@ function printDebriefReview(text, eng) {
1898
1915
  let any = false
1899
1916
  for (const [label, items] of order) {
1900
1917
  if (!items.length) {
1901
- if (['stated asks', 'proposed scope', 'next action', 'named signer (authority, not approval)'].includes(label)) console.log(` ${label}: not stated`)
1918
+ if (['stated asks', 'proposed scope', 'next action', 'named signer (authority, not approval)'].includes(label)) console.log(` ${label}: not detected - review the notes`)
1902
1919
  continue
1903
1920
  }
1904
1921
  any = true
1905
1922
  console.log(` ${label}:`)
1906
- for (const item of items) console.log(` - ${item}`)
1923
+ for (const item of items.slice(0, 5)) console.log(` - ${item}`)
1924
+ if (items.length > 5) console.log(` - ${items.length - 5} more omitted here; review the full proposal before applying.`)
1907
1925
  }
1908
1926
  if (!any) console.log(' (nothing prefixed yet - edit .debrief-propose, then apply)')
1909
1927
  const recorded = eng ? parseValueLedger(eng).rows : []
@@ -1972,7 +1990,79 @@ function readSealedProposal(eng) {
1972
1990
  } catch (_) { return [] }
1973
1991
  }
1974
1992
 
1993
+ // A debrief touches several records. Acquire every cooperating writer's lock
1994
+ // before the first append, and restore snapshots if an ordinary write fails.
1995
+ // This is not a power-loss transaction; no history is deleted or reset.
1996
+ function withDebriefRecords(eng, apply, files = ['decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md',
1997
+ 'success.md', 'context.md', SIGNAL_LEDGER, LAST_WRITE, DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL]) {
1998
+ files = files.slice().sort()
1999
+ const snapshots = new Map()
2000
+ function lockAt(index) {
2001
+ if (index < files.length) {
2002
+ const target = path.join(eng, files[index])
2003
+ if (ownedDebriefLocks.has(target)) return lockAt(index + 1)
2004
+ return withFileLock(target, () => {
2005
+ ownedDebriefLocks.add(target)
2006
+ try { return lockAt(index + 1) } finally { ownedDebriefLocks.delete(target) }
2007
+ }, { soft: true })
2008
+ }
2009
+ for (const file of files) {
2010
+ const target = path.join(eng, file)
2011
+ const blocked = refuseSymlinkWrite(target, { soft: true })
2012
+ if (blocked) throw new Error(blocked)
2013
+ snapshots.set(target, fs.existsSync(target) ? { bytes: fs.readFileSync(target), mode: fs.statSync(target).mode & 0o777 } : null)
2014
+ }
2015
+ debriefTransactionActive = true
2016
+ try { return apply() } catch (error) {
2017
+ const failed = []
2018
+ for (const [target, previous] of snapshots) {
2019
+ try {
2020
+ if (previous) atomicWriteFile(target, previous.bytes, { mode: previous.mode, soft: true })
2021
+ else if (fs.existsSync(target)) fs.unlinkSync(target)
2022
+ } catch (_) { failed.push(path.basename(target)) }
2023
+ }
2024
+ if (failed.length) throw new Error(`${error.message}; recovery could not restore ${failed.join(', ')}. Inspect these records and the pending proposal before retrying.`)
2025
+ throw new Error(`${error.message}; no record changes kept. The proposal is retained; retry after resolving the cause.`)
2026
+ } finally { debriefTransactionActive = false }
2027
+ }
2028
+ return lockAt(0)
2029
+ }
2030
+
2031
+ // Limit model-facing review output while preserving the full editable proposal.
2032
+ function boundedDebriefPreview(eng, render, { proposal = true, maxBytes = 12000 } = {}) {
2033
+ const original = console.log, originalError = console.error
2034
+ let bytes = 0, omitted = 0
2035
+ const bounded = output => (...args) => {
2036
+ const line = args.join(' ') + '\n'
2037
+ const size = Buffer.byteLength(line)
2038
+ if (bytes + size > maxBytes) { omitted++; return }
2039
+ bytes += size; output(...args)
2040
+ }
2041
+ console.log = bounded(original)
2042
+ console.error = bounded(originalError)
2043
+ let result
2044
+ try { result = render() } finally { console.log = original; console.error = originalError }
2045
+ if (omitted) console.log(proposal
2046
+ ? `\n${omitted} preview lines omitted. Review the complete proposal at ${path.join(eng, DEBRIEF_PROPOSE)} before applying.`
2047
+ : `\n${omitted} preview lines omitted. Review the full input notes before applying.`)
2048
+ return result
2049
+ }
2050
+
1975
2051
  function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
2052
+ if (!dry && !debriefTransactionActive) {
2053
+ ensureMemoryGit(eng)
2054
+ return withDebriefRecords(eng, () => {
2055
+ const result = routeDebriefInput(eng, input, { dry, force, sealed })
2056
+ // Consuming the review is part of the write. If cleanup fails, restoring
2057
+ // both the records and proposal makes the next explicit apply safe.
2058
+ for (const file of [DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL]) {
2059
+ try { fs.unlinkSync(path.join(eng, file)) } catch (error) {
2060
+ if (error.code !== 'ENOENT') throw new Error(formatFsError(error, 'remove', file))
2061
+ }
2062
+ }
2063
+ return result
2064
+ })
2065
+ }
1976
2066
  const d = new Date()
1977
2067
  const date = d.toISOString().slice(0, 10)
1978
2068
  const counts = { decision: 0, risk: 0, delivery: 0, contact: 0, next: 0, signer: 0 }
@@ -2044,6 +2134,18 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
2044
2134
  }
2045
2135
 
2046
2136
  function cmdDebrief(args) {
2137
+ const eng = resolveEngagement({ forWrite: true })
2138
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exitCode = 2; return }
2139
+ const target = path.join(eng, DEBRIEF_PROPOSE)
2140
+ try {
2141
+ withFileLock(target, () => {
2142
+ ownedDebriefLocks.add(target)
2143
+ try { return runDebrief(args, eng) } finally { ownedDebriefLocks.delete(target) }
2144
+ }, { soft: true })
2145
+ } catch (error) { console.error(error.message); process.exitCode = 1 }
2146
+ }
2147
+
2148
+ function runDebrief(args, eng) {
2047
2149
  args = args.slice()
2048
2150
  const dryIdx = args.indexOf('--dry-run')
2049
2151
  const dry = dryIdx !== -1
@@ -2058,35 +2160,45 @@ function cmdDebrief(args) {
2058
2160
  const forceIdx = args.indexOf('--force')
2059
2161
  if (forceIdx !== -1) { force = true; args.splice(forceIdx, 1) }
2060
2162
 
2061
- const eng = resolveEngagement({ forWrite: true })
2062
- if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
2163
+ const replaceIdx = args.indexOf('--replace-proposal')
2164
+ const replace = replaceIdx !== -1
2165
+ if (replace) args.splice(replaceIdx, 1)
2166
+ if (replace && !smart) throw new Error('--replace-proposal requires --smart <notes>')
2063
2167
 
2168
+ if (!smart && !apply && !dry && fs.existsSync(path.join(eng, DEBRIEF_PROPOSE))) {
2169
+ throw new Error('pending proposal already exists. Review and apply it before writing another debrief.')
2170
+ }
2171
+ if (apply && !smart && args[0] && fs.existsSync(path.join(eng, DEBRIEF_PROPOSE))) {
2172
+ throw new Error('pending proposal already exists. Use debrief --apply without a notes file to apply that review.')
2173
+ }
2064
2174
  let input = ''
2065
2175
  let sealed = []
2066
2176
  if (apply && !smart && !args[0]) {
2067
2177
  try { input = stripControlChars(fs.readFileSync(path.join(eng, DEBRIEF_PROPOSE), 'utf8')) } catch (_) {
2068
2178
  console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
2069
- process.exit(1)
2179
+ return void (process.exitCode = 1)
2070
2180
  }
2071
2181
  sealed = readSealedProposal(eng)
2072
2182
  const expected = readSealCount(eng)
2073
2183
  if (expected === null ? (!sealed.length && input.includes(PRIVATE_MARKER)) : sealed.length < expected) {
2074
2184
  console.error(`refused: the proposal seals a private note but ${DEBRIEF_PRIVATE} is missing or unreadable - applying now would drop it silently.`)
2075
2185
  console.error('re-run the propose step (fde debrief --smart <notes> | fde ingest propose <id>).')
2076
- process.exit(1)
2186
+ return void (process.exitCode = 1)
2077
2187
  }
2078
2188
  } else {
2079
2189
  input = readDebriefInput(args)
2080
2190
  }
2081
2191
 
2082
2192
  if (smart) {
2083
- const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
2193
+ const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input), { replace })
2194
+ boundedDebriefPreview(eng, () => {
2084
2195
  console.log('SMART PROPOSE (heuristic - review before apply; no new facts invented beyond line rewrites)\n')
2085
2196
  printDebriefReview(clean, eng)
2086
2197
  console.log('Prefix vocabulary (lines that route): decision: risk: delivery: contact: next: signer:')
2087
2198
  console.log('Optional on a decision: [approved: Name YYYY-MM-DD]. Missing means unconfirmed.')
2088
2199
  console.log('Everything else → context.md. Keep the prefixes; the preview gate stays.\n')
2089
2200
  routeDebriefInput(eng, clean, { dry: true, force, sealed: blocks })
2201
+ }, { maxBytes: 8000 })
2090
2202
  if (!apply) {
2091
2203
  console.log(`\nproposal saved → ${proposePath}`)
2092
2204
  console.log('confirm: fde debrief --apply')
@@ -2097,14 +2209,12 @@ function cmdDebrief(args) {
2097
2209
  sealed = blocks
2098
2210
  }
2099
2211
 
2100
- const { counts, ctxLines, privateBlocks } = routeDebriefInput(eng, input, { dry, force, sealed })
2212
+ const route = () => routeDebriefInput(eng, input, { dry, force, sealed })
2213
+ const { counts, ctxLines, privateBlocks } = boundedDebriefPreview(eng, route, { proposal: false, maxBytes: smart ? 4000 : 12000 })
2101
2214
  if (!dry) {
2102
2215
  const hash = commitMemory(eng, 'debrief', {
2103
2216
  files: ['decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md', 'success.md', 'context.md', SIGNAL_LEDGER],
2104
2217
  })
2105
- try { fs.unlinkSync(path.join(eng, DEBRIEF_PROPOSE)) } catch (_) {}
2106
- try { fs.unlinkSync(path.join(eng, DEBRIEF_PRIVATE)) } catch (_) {}
2107
- try { fs.unlinkSync(path.join(eng, DEBRIEF_SEAL)) } catch (_) {}
2108
2218
  if (hash) console.log(`memory @${hash}`)
2109
2219
  }
2110
2220
  const plural = {
@@ -2224,10 +2334,14 @@ function cmdIngest(args) {
2224
2334
  console.error(`ingest propose refused: staged item is over ${DEBRIEF_MAX_BYTES} bytes after provenance. Split it.`)
2225
2335
  process.exit(1)
2226
2336
  }
2227
- const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
2337
+ let proposal
2338
+ try { proposal = writeProposal(eng, smartProposeText(input)) } catch (error) { console.error(error.message); process.exitCode = 1; return }
2339
+ const { proposePath, clean, blocks } = proposal
2340
+ boundedDebriefPreview(eng, () => {
2228
2341
  console.log(`INGEST PROPOSE from ${path.basename(item)} (via:${source})\n`)
2229
2342
  printDebriefReview(clean, eng)
2230
2343
  routeDebriefInput(eng, clean, { dry: true, force: false, sealed: blocks })
2344
+ })
2231
2345
  console.log(`\nproposal saved → ${proposePath}`)
2232
2346
  console.log('confirm: fde ingest apply')
2233
2347
  console.log('(agent: rewrite lines with decision:/risk:/contact:/next: prefixes before apply)')
@@ -2353,13 +2467,13 @@ function cmdHandoff(args, label = 'Handoff') {
2353
2467
  const claims = selected.filter(d => !hasSource(d.text))
2354
2468
  const decisionText = d => `${d.text} (decisions.md:${d.line}, redacted view)`
2355
2469
  const next = stripTemplateNoise(sectionBody(readClean(eng, 'context.md'), 'Next action', { lastNonEmpty: true }))
2356
- const gaps = collectDoctorIssues(eng)
2470
+ const gaps = collectDoctorIssues(eng, { readiness: true })
2357
2471
  const report = context.boundedSections([
2358
2472
  `# ${label}: ${engagementSlugFromPath(eng)}\nSnapshot: ${new Date().toISOString()} · memory ${memoryHead(eng) || 'unversioned'}\nRead-only record, not proof of approval. Confirm sources with the named customer before relying on a claim. Private blocks are excluded; review remaining client information before sharing.`,
2359
2473
  `## Constraints - trust-profile.md\n${stripTemplateNoise(readClean(eng, 'trust-profile.md')) || '(missing)'}`,
2360
2474
  `## Signer and success - success.md\nSigner: ${signer || '(missing; do not infer)'}\n${success || '(missing)'}`,
2361
2475
  `## 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)'}`,
2362
- `## Accepted value - recorded assertion with source\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)'}`,
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)'}`,
2363
2477
  `## 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.`,
2364
2478
  `## Gaps before relying on this packet\n${gaps.map(g => '- ' + g).join('\n') || '(no deterministic lint gaps; human review still required)'}`,
2365
2479
  ], parsed.maxBytes)
@@ -3522,7 +3636,13 @@ function cmdDashboard(args) {
3522
3636
  const html = render.buildFieldbookHtml({ engagements, today, generatedAt: new Date().toISOString() })
3523
3637
 
3524
3638
  try {
3639
+ const isRecordPath = p => p.split(path.sep).some(part => ['.fde', '.git'].includes(part.toLowerCase()))
3640
+ if (isRecordPath(outPath)) throw new Error('save the dashboard outside .fde/ and .git/; these folders hold records, not reports')
3641
+ let existingParent = path.dirname(outPath)
3642
+ while (!fs.existsSync(existingParent)) existingParent = path.dirname(existingParent)
3643
+ if (isRecordPath(fs.realpathSync(existingParent))) throw new Error('save the dashboard outside .fde/ and .git/; this path points into a record folder')
3525
3644
  fs.mkdirSync(path.dirname(outPath), { recursive: true })
3645
+ if (isRecordPath(fs.realpathSync(path.dirname(outPath)))) throw new Error('save the dashboard outside .fde/ and .git/; this path points into a record folder')
3526
3646
  atomicWriteFile(outPath, html)
3527
3647
  } catch (e) {
3528
3648
  failFs(e, 'write fieldbook', outPath)
@@ -3883,6 +4003,7 @@ function printUsage() {
3883
4003
  fde log --undo remove the last CLI log/debrief entry from memory
3884
4004
  fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
3885
4005
  fde debrief --smart heuristic propose; REVIEW first (decided/asked/open/next/signer); --apply after one confirm
4006
+ --replace-proposal explicitly discard a pending review when proposing different notes
3886
4007
  fde ingest stage … stage raw pull into <engagement>/.inbox/ (not .fde/)
3887
4008
  fde ingest list list staged inbox items
3888
4009
  fde ingest propose <id> smart-propose a staged item → .debrief-propose (confirm before apply)
package/bin/lib/render.js CHANGED
@@ -573,7 +573,7 @@ ${railItems}
573
573
  </aside>
574
574
  <main id="fb-main" tabindex="-1" class="fb-main fb-scroll">
575
575
  <div class="fb-main-inner">
576
- <div class="fb-snapshot"><strong>Read-only snapshot</strong> &middot; Snapshot generated <time datetime="${escapeHtml(generatedAt)}">${escapeHtml(generatedAt ? generatedAt.replace('T', ' ').replace(/\.\d+Z$/, ' UTC') : today)}</time>. Re-run <code>fde dashboard</code> after updating your records. Reloading this page alone does not refresh the record.</div>
576
+ <div class="fb-snapshot"><strong>Read-only snapshot</strong> &middot; Snapshot generated <time datetime="${escapeHtml(generatedAt)}">${escapeHtml(generatedAt ? generatedAt.replace('T', ' ').replace(/\.\d+Z$/, ' UTC') : today)}</time>. After updating your records, run the dashboard command that created this file again, keeping the same <code>--all</code> and <code>--out</code> options if used. Then reload. Reloading this page alone does not refresh the record.</div>
577
577
  ${todayView}
578
578
  ${clientViews}
579
579
  <p id="fb-status" class="fb-copy-status" role="status" aria-live="polite"></p>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops-ingest-mcp",
3
- "version": "3.27.0",
3
+ "version": "3.27.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.27.0",
3
+ "version": "3.27.1",
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.0",
4
+ "version": "3.27.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",