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 +82 -20
- package/package.json +1 -1
- package/skills/fde/SKILL.md +2 -1
- package/skills/fde/references/build.md +1 -1
- package/skills/fde/references/review.md +20 -3
- package/skills/fde/references/ship.md +24 -1
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
|
|
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
|
|
126
|
-
if (
|
|
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
|
|
135
|
-
if (
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
-
|
|
1170
|
-
|
|
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.
|
|
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",
|
package/skills/fde/SKILL.md
CHANGED
|
@@ -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
|
|
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) **
|
|
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`
|
|
16
|
-
|
|
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
|
|
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
|
|
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.
|