fdeops 3.9.14 → 3.9.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/fde.js CHANGED
@@ -107,9 +107,13 @@ function resolveEngagement(opts = {}) {
107
107
  // bind - env, registry, pointer, or in-repo .fde. Basename matching is
108
108
  // read-only convenience; writing on a folder-name guess contaminates clients.
109
109
  const forWrite = !!opts.forWrite
110
+ const accept = (p) => acceptEngagementPath(p, { forWrite })
110
111
  // 1) explicit env (back-compat: accept old FDEOS_ENGAGEMENT too)
111
112
  const env = (process.env.FDEOPS_ENGAGEMENT || process.env.FDEOS_ENGAGEMENT || '').replace(/^~/, HOME).trim()
112
- if (env && fs.existsSync(env)) return env
113
+ if (env) {
114
+ const ok = accept(env)
115
+ if (ok) return ok
116
+ }
113
117
  // 2) workspace registry binding (written by resume --init). Match the cwd OR
114
118
  // any ancestor of it - FDEs run commands from src/, packages/api/, etc., not
115
119
  // just the repo root where they bound. Nearest (deepest) registered ancestor
@@ -122,8 +126,8 @@ function resolveEngagement(opts = {}) {
122
126
  .filter(r => cwd === r.workspace || cwd.startsWith(r.workspace + path.sep))
123
127
  .sort((a, b) => b.workspace.length - a.workspace.length)[0]
124
128
  if (reg) {
125
- const p = path.join(ENGAGEMENTS_ROOT, reg.slug, '.fde')
126
- if (fs.existsSync(p)) return p
129
+ const ok = accept(path.join(ENGAGEMENTS_ROOT, reg.slug, '.fde'))
130
+ if (ok) return ok
127
131
  }
128
132
  // 3) global pointer file (back-compat: try old FDEOS-CLAUDE.md too)
129
133
  for (const ptrName of ['FDEOPS-CLAUDE.md', 'FDEOS-CLAUDE.md']) {
@@ -131,8 +135,8 @@ function resolveEngagement(opts = {}) {
131
135
  const ptr = fs.readFileSync(path.join(HOME, '.claude', ptrName), 'utf8')
132
136
  const m = ptr.match(/^(?:FDEOPS|FDEOS)_ENGAGEMENT=(.+)$/m)
133
137
  if (m) {
134
- const p = m[1].trim().replace(/^~/, HOME)
135
- if (fs.existsSync(p)) return p
138
+ const ok = accept(m[1].trim().replace(/^~/, HOME))
139
+ if (ok) return ok
136
140
  }
137
141
  } catch (_) {}
138
142
  }
@@ -150,14 +154,37 @@ function resolveEngagement(opts = {}) {
150
154
  )
151
155
  return null
152
156
  }
153
- process.stderr.write(`⚠ resolved engagement by directory name ("${slugGuess}"), not a saved binding (read-only). If this is the right client, run \`fde resume --init ${slugGuess}\` here to bind it before logging or debriefing.\n`)
154
- return guess
157
+ const ok = accept(guess)
158
+ if (ok) {
159
+ process.stderr.write(`⚠ resolved engagement by directory name ("${slugGuess}"), not a saved binding (read-only). If this is the right client, run \`fde resume --init ${slugGuess}\` here to bind it before logging or debriefing.\n`)
160
+ return ok
161
+ }
155
162
  }
156
163
  // 5) in-repo .fde (engagement-approved only)
157
- if (fs.existsSync(path.join(cwd, '.fde'))) return path.join(cwd, '.fde')
164
+ const inRepo = accept(path.join(cwd, '.fde'))
165
+ if (inRepo) return inRepo
158
166
  return null
159
167
  }
160
168
 
169
+ // Engagement memory must be a directory. A file named .fde used to yield a
170
+ // healthy-looking green TRIAGE then raw ENOTDIR on write - refuse loudly.
171
+ function acceptEngagementPath(p, opts = {}) {
172
+ if (!p || !fs.existsSync(p)) return null
173
+ try {
174
+ const st = fs.statSync(p)
175
+ if (st.isDirectory()) return p
176
+ const msg =
177
+ `engagement path is not a directory (memory missing/broken): ${p}\n` +
178
+ ' repair: remove that file, then re-run: fde resume --init <name>'
179
+ console.error(msg)
180
+ if (opts.forWrite) process.exit(1)
181
+ return null
182
+ } catch (e) {
183
+ if (opts.forWrite) failFs(e, 'open', p)
184
+ return null
185
+ }
186
+ }
187
+
161
188
  function templatesDir() {
162
189
  for (const c of [path.join(__dirname, '..', 'templates', '.fde'), path.join(__dirname, 'templates', '.fde')]) {
163
190
  if (fs.existsSync(c)) return c
@@ -172,11 +199,19 @@ function readEng(eng, f) {
172
199
  }
173
200
 
174
201
  // Redact private notes and template hints from every model-facing read.
202
+ // Also strip terminal control chars so poisoned memory cannot smuggle ANSI
203
+ // into triage/prep/status (C0/C1 except tab/LF/CR).
204
+ function stripControlChars(s) {
205
+ return String(s || '').replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
206
+ }
207
+
175
208
  function stripPrivate(md) {
176
- return md
177
- .replace(/<private>[\s\S]*?<\/private>/gi, '(private - redacted)')
178
- .replace(/<private>[\s\S]*$/i, '(private - redacted)')
179
- .replace(/<!--[\s\S]*?-->/g, '')
209
+ return stripControlChars(
210
+ String(md || '')
211
+ .replace(/<private>[\s\S]*?<\/private>/gi, '(private - redacted)')
212
+ .replace(/<private>[\s\S]*$/i, '(private - redacted)')
213
+ .replace(/<!--[\s\S]*?-->/g, '')
214
+ )
180
215
  }
181
216
 
182
217
  // Read + redact in one step - the default way dashboard code should ever touch
@@ -239,6 +274,9 @@ function formatFsError(err, action, target) {
239
274
  const code = err && err.code
240
275
  const where = path.basename(String(target || '')) || String(target || 'path')
241
276
  if (code === 'ENOSPC') return `cannot ${action} ${where} - disk full`
277
+ if (code === 'ENOTDIR') {
278
+ return `cannot ${action} ${where} - engagement path is not a directory (memory missing/broken); remove the file and re-run fde resume --init`
279
+ }
242
280
  if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
243
281
  return `cannot ${action} ${where} - permission denied (read-only or locked down)`
244
282
  }
@@ -392,7 +430,7 @@ function datedEntry(eng, date, text, signal) {
392
430
  const bits = [`- [${date}]`]
393
431
  if (who) bits.push(`[${who}]`)
394
432
  if (signal) bits.push(`[signal:${signal}]`)
395
- bits.push(text)
433
+ bits.push(stripControlChars(text))
396
434
  return bits.join(' ')
397
435
  }
398
436
 
@@ -1136,7 +1174,7 @@ function smartProposeText(input) {
1136
1174
 
1137
1175
  function setNextAction(eng, text) {
1138
1176
  ensureMemoryGit(eng)
1139
- const bullet = `- ${String(text).replace(/^[-*]\s+/, '').trim()}`
1177
+ const bullet = `- ${stripControlChars(String(text).replace(/^[-*]\s+/, '').trim())}`
1140
1178
  const p = path.join(eng, 'context.md')
1141
1179
  let md = readEng(eng, 'context.md')
1142
1180
  if (!md) md = '# Engagement context\n\n'
@@ -1148,6 +1186,24 @@ function setNextAction(eng, text) {
1148
1186
  withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
1149
1187
  }
1150
1188
 
1189
+ function looksLikeBinaryNoise(text) {
1190
+ const s = String(text || '')
1191
+ if (!s) return false
1192
+ if (s.includes('\0')) return true
1193
+ const sample = s.slice(0, 8192)
1194
+ let ctrl = 0
1195
+ let replacement = 0
1196
+ for (let i = 0; i < sample.length; i++) {
1197
+ const c = sample.charCodeAt(i)
1198
+ if (c === 0xfffd) replacement++
1199
+ if (c === 9 || c === 10 || c === 13) continue
1200
+ if (c < 32 || (c >= 0x7f && c <= 0x9f)) ctrl++
1201
+ }
1202
+ if (!sample.length) return false
1203
+ // Mostly-control or high U+FFFD density = urandom / binary mistyped as text.
1204
+ return (ctrl / sample.length) > 0.05 || (replacement / sample.length) > 0.1
1205
+ }
1206
+
1151
1207
  function readDebriefInput(args) {
1152
1208
  let input = ''
1153
1209
  if (args[0]) {
@@ -1160,19 +1216,25 @@ function readDebriefInput(args) {
1160
1216
  }
1161
1217
  let buf
1162
1218
  try { buf = fs.readFileSync(notesPath) } catch (_) { console.error(`cannot read ${args[0]}`); process.exit(1) }
1163
- if (buf.includes(0)) {
1164
- console.error(`debrief refused: ${args[0]} looks binary (null bytes). Paste text notes only.`)
1219
+ if (buf.includes(0) || looksLikeBinaryNoise(buf.toString('utf8'))) {
1220
+ console.error(`debrief refused: ${args[0]} looks binary or mostly non-printable. Paste text notes only.`)
1165
1221
  process.exit(1)
1166
1222
  }
1167
1223
  input = buf.toString('utf8')
1168
1224
  } else {
1169
- try { input = fs.readFileSync(0, 'utf8') } catch (_) {}
1170
- if (Buffer.byteLength(input, 'utf8') > DEBRIEF_MAX_BYTES) {
1225
+ let buf
1226
+ try { buf = fs.readFileSync(0) } catch (_) { buf = Buffer.alloc(0) }
1227
+ if (Buffer.byteLength(buf) > DEBRIEF_MAX_BYTES) {
1171
1228
  console.error(`debrief refused: stdin is over ${DEBRIEF_MAX_BYTES} bytes. Split the notes.`)
1172
1229
  process.exit(1)
1173
1230
  }
1231
+ if (buf.includes(0) || looksLikeBinaryNoise(buf.toString('utf8'))) {
1232
+ console.error('debrief refused: stdin looks binary or mostly non-printable. Paste text notes only.')
1233
+ process.exit(1)
1234
+ }
1235
+ input = buf.toString('utf8')
1174
1236
  }
1175
- return input
1237
+ return stripControlChars(input)
1176
1238
  }
1177
1239
 
1178
1240
  function previewLine(text, max = 240) {
@@ -1250,7 +1312,7 @@ function cmdDebrief(args) {
1250
1312
 
1251
1313
  let input = ''
1252
1314
  if (apply && !smart && !args[0]) {
1253
- try { input = fs.readFileSync(path.join(eng, DEBRIEF_PROPOSE), 'utf8') } catch (_) {
1315
+ try { input = stripControlChars(fs.readFileSync(path.join(eng, DEBRIEF_PROPOSE), 'utf8')) } catch (_) {
1254
1316
  console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
1255
1317
  process.exit(1)
1256
1318
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.9.14",
3
+ "version": "3.9.16",
4
4
  "description": "Field kit for engineers embedded in client work - a real CLI (recon, memory, portfolio), one @fde skill with field judgment on top, and hooks that make it automatic. Claude Code plugin and any agent that loads skills.",
5
5
  "bin": {
6
6
  "fdeops": "bin/install.js",
@@ -49,7 +49,7 @@ These stop confident fiction. They are not optional soft tips.
49
49
  | Invent a stakeholder, meeting, or quote to make the narrative rich | **Stop.** Write `unknown - ask: <question>`. One fake name poisons every real citation. |
50
50
  | Route to a phase because it "feels senior" while the signal is muddy | **Stop.** Playback + one natural question, or name the ambiguity ("discover or rescue — leaning X because…"). |
51
51
  | Fill `success.md` / `terrain.md` with plausible defaults when the brief is thin | **Stop.** Run **brief interrogation** in land/discover (one Q + GUESS + confidence) until you can write without guessing, or leave gaps explicit. |
52
- | Ship / go-live / irreversible change with "probably fine" | **Stop.** Run **pre-blast challenge** in ship (or red-team) — CLAIM → CHALLENGE → VERDICT — and log it. |
52
+ | Ship / go-live / irreversible change with "probably fine" | **Stop.** Run **intent vs diff** (KEEP/JUSTIFY/SPLIT/DROP) then **pre-blast challenge** in ship (or red-team) — CLAIM → CHALLENGE → VERDICT — and log both. |
53
53
  | Grill the FDE with a checklist when they're mid-flow | **Stop.** Playback rule wins. Probe only when a missing fact changes the next move. |
54
54
 
55
55
  When NOT to interrogate or challenge: unambiguous one-liners, mechanical ops, FDE explicitly asked for speed, answer already in `.fde/`.
@@ -243,6 +243,7 @@ Getting to production without surprises.
243
243
  |----------|-------|-----------|
244
244
  | Ready to deploy, going live, pre-flight check | ship | `references/ship.md` |
245
245
  | Review this change, is it safe, does it match what we agreed | review | `references/review.md` |
246
+ | Diff grew / scope creep in the PR / "did we only build what we said" / KEEP JUSTIFY SPLIT DROP | review (+ ship if going live) | `references/review.md` Stage 1 · `references/ship.md` Intent vs diff |
246
247
  | "We can always revert" - need to actually test the escape route | rollback-drill | `references/rollback-drill.md` |
247
248
  | Need to test from user perspective, "works on my machine" | qa-live | `references/qa-live.md` |
248
249
 
@@ -58,7 +58,7 @@ The spec becomes the test list - every line is something to verify after build.
58
58
  - `[DEFERRED]` intentionally left for a later task (state which one)
59
59
  Surface the results to the FDE. If any scenario fails, fix it before cleanup. This is not optional - the spec is the contract.
60
60
  9. **Cleanup pass after it works.** Dedupe repeated mechanics into the smallest service module; behavior unchanged; re-run the same tests. If you wrote 200 lines and 50 would do, rewrite before review.
61
- 10. **Review gate (before merge):** two stages, in order - (a) **scope**: does the diff match the approved spec and `decisions.md`, nothing more? (b) **safety**: blast radius honest, tests meaningful, rollback real, secrets absent. Fix real findings, re-verify, repeat until clean or blocked on a human decision.
61
+ 10. **Review gate (before merge):** two stages, in order - (a) **intent vs diff**: every path KEEP / JUSTIFY / SPLIT / DROP against the stated slice in `decisions.md` (see `review.md` Stage 1); (b) **safety**: blast radius honest, tests meaningful, rollback real, secrets absent. Fix real findings, re-verify, repeat until clean or blocked on a human decision.
62
62
  11. **Log and deliver.** Update the artifacts (below). Visible progress beats invisible perfection - every 2–3 tasks something shown to a stakeholder.
63
63
 
64
64
  **Touching existing code - classify before changing:**
@@ -12,13 +12,29 @@ Thousands of lines or dozens of unrelated files → **stop**, recommend the spli
12
12
 
13
13
  ## Stage 1 - did we build what we agreed? (you do this work)
14
14
 
15
- Check the diff against `decisions.md` - what was *explicitly decided*, not what seems right:
16
- - Matches agreed scope?
15
+ Check the diff against the **one-line intent** in `decisions.md` / acceptance criteria — what was *explicitly decided*, not what seems right:
16
+
17
+ ```bash
18
+ git diff <base>...HEAD --stat
19
+ ```
20
+
21
+ For each touched path (or logical hunk), assign one verdict:
22
+
23
+ | Verdict | Meaning |
24
+ |---------|---------|
25
+ | **KEEP** | Required for the stated intent |
26
+ | **JUSTIFY** | Adjacent but must ship now — write one sentence why, or SPLIT |
27
+ | **SPLIT** | Real work for another PR / Next / kill list — do not merge with this slice |
28
+ | **DROP** | Noise / drive-by — revert before Pass |
29
+
30
+ Also check:
17
31
  - Any sacred system from `trust-profile.md` touched?
18
32
  - Any sensitive data newly in scope?
19
33
  - Rollback path defined before build still honoured?
20
34
 
21
- **Stage 1 fails → stop.** Quality review on out-of-scope code is wasted work. Record the specific mismatch in `decisions.md`.
35
+ **Stage 1 fails → stop** if any SPLIT/DROP remains, or JUSTIFY lacks a written sentence. Quality review on out-of-scope code is wasted work. Record the mismatch (and the KEEP/JUSTIFY/SPLIT/DROP tally) in `decisions.md`.
36
+
37
+ Stakeholder "also can you…" mid-build is `scope-defense` — different axis. This stage is **code vs claim**.
22
38
 
23
39
  ## Stage 2 - is it safe to live with?
24
40
 
@@ -48,6 +64,7 @@ Five dimensions, line-specific ("line 47 fails under concurrent writes - no lock
48
64
  ## Principles
49
65
 
50
66
  - Stage 1 before Stage 2. Wrong scope reviewed well is still wrong scope.
67
+ - KEEP / JUSTIFY / SPLIT / DROP — every path gets a verdict; silent extras fail Stage 1.
51
68
  - Specific or silent - vague concerns waste everyone's time.
52
69
  - No rollback path = first finding.
53
70
  - A clean review proves this diff is safe as agreed - not that the feature was right.
@@ -63,6 +63,28 @@ Score each dimension green/amber/red. This is the gate, not a suggestion:
63
63
 
64
64
  Write the readiness score (including value + receipts) to `delivery.md` before deploying. The score is the evidence if anything goes wrong.
65
65
 
66
+ ## Intent vs diff (before pre-blast)
67
+
68
+ Ship the change you intended — not the drift that snuck in. Run this on the deploy branch against the **one-line intent** from `decisions.md` / `success.md` (the slice you said you were building).
69
+
70
+ ```bash
71
+ git diff <base>...HEAD --stat
72
+ git diff <base>...HEAD
73
+ ```
74
+
75
+ Score every touched path (or logical hunk):
76
+
77
+ | Path / change | Verdict | Rule |
78
+ |---------------|---------|------|
79
+ | | **KEEP** | Directly required for the stated intent |
80
+ | | **JUSTIFY** | Adjacent but load-bearing — one sentence why it must ship *now*, or split |
81
+ | | **SPLIT** | Real work, wrong PR — park in `decisions.md` kill/Next; do not deploy with this slice |
82
+ | | **DROP** | Noise (format-only, drive-by rename, unrelated tidy) — revert before ship |
83
+
84
+ **Any SPLIT or DROP still in the tree = fix-first.** JUSTIFY without a written sentence = treat as SPLIT. Log a one-line receipt in `delivery.md`: `intent vs diff: KEEP n · JUSTIFY n · SPLIT n · DROP n — <intent>`.
85
+
86
+ This is **code drift**, not stakeholder "also can you…" (that is `scope-defense`). Same family as review Stage 1 — ship refuses green when the diff outgrew the claim.
87
+
66
88
  ## Pre-blast challenge (before the deploy button)
67
89
 
68
90
  For any non-trivial go-live (shared infra, regulated data, irreversible migration, or first prod touch), run this once before canary — not as theater, as a stop-the-line check:
@@ -162,7 +184,7 @@ Adoption isn't a handoff-stage problem - it starts during build. Software that l
162
184
 
163
185
  ## Checkpoint
164
186
 
165
- Before 100%: canary clean, business metric verified, pulse written into `delivery.md`. Also green: value bucket named, audit receipt dated, eval receipt **n/a or pass**. Missing any of those → not green. For enterprise-scale: scale-readiness gate passed before broad rollout.
187
+ Before 100%: canary clean, business metric verified, pulse written into `delivery.md`. Also green: value bucket named, audit receipt dated, eval receipt **n/a or pass**, **intent vs diff clean** (no unresolved SPLIT/DROP). Missing any of those → not green. For enterprise-scale: scale-readiness gate passed before broad rollout.
166
188
 
167
189
  ## Principles
168
190
 
@@ -170,6 +192,7 @@ Before 100%: canary clean, business metric verified, pulse written into `deliver
170
192
  - Roll back on any canary anomaly; investigate safely.
171
193
  - Verify the business metric, not just the technical one.
172
194
  - No value bucket, no green ship. No pulse, no done.
195
+ - Diff larger than the stated intent without KEEP/JUSTIFY receipts = fix-first.
173
196
  - AI path without eval receipt = fix-first; non-AI ships leave eval as n/a.
174
197
  - Scale readiness is organizational, not just technical. Check all 8 dimensions.
175
198
  - Adoption is measured from day one, not hoped for at launch.