fdeops 3.9.10 → 3.9.11

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/check.js CHANGED
@@ -202,12 +202,16 @@ if (fs.existsSync(pmf) && !read('docs/internal/PMF_360_REVIEW.md').includes('INT
202
202
  } else if (fs.existsSync(pmf)) ok('internal PMF banner')
203
203
 
204
204
  const hook = read('hooks/session-start')
205
+ const hookCode = hook.replace(/^[ \t]*#.*$/gm, '')
205
206
  if (!hook.includes('FDEOPS_ENGAGEMENT')) {
206
207
  fail('session-start hook must read FDEOPS_ENGAGEMENT env var')
207
208
  } else ok('hook FDEOPS_ENGAGEMENT')
209
+ if (!hookCode.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" fde triage') ||
210
+ !hookCode.includes('FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CMD" triage')) {
211
+ fail('session-start must run triage with its resolved engagement')
212
+ }
208
213
  // Token discipline: SessionStart must not dump the full skill (L1 progressive disclosure).
209
214
  // Strip comments before scanning for a real `cat …SKILL.md` / BOOTSTRAP inject.
210
- const hookCode = hook.replace(/^[ \t]*#.*$/gm, '')
211
215
  if (/\$\(cat\s+"\$BOOTSTRAP"\)|cat\s+"\$BOOTSTRAP"|cat\s+[^\n]*SKILL\.md/.test(hookCode)) {
212
216
  fail('session-start must not cat SKILL.md - inject TRIAGE + bounded context + pointer only')
213
217
  }
@@ -240,10 +244,35 @@ if (!fs.existsSync(path.join(root, 'hooks', 'session-stop'))) {
240
244
  fail('hooks/session-stop missing (write-side memory backstop)')
241
245
  } else {
242
246
  const stopHook = read('hooks/session-stop')
247
+ const stopHookCode = stopHook.replace(/^[ \t]*#.*$/gm, '')
243
248
  if (!stopHook.includes('FDEOPS_ENGAGEMENT')) fail('session-stop must resolve FDEOPS_ENGAGEMENT')
244
- if (!stopHook.includes('context.md')) fail('session-stop must append to context.md')
249
+ if (!stopHookCode.includes('resolve_fde') || !stopHookCode.includes('command -v fde')) {
250
+ fail('session-stop must resolve PATH fde before plugin copies')
251
+ }
252
+ if (!/FDEOPS_ENGAGEMENT="\$ENG_DIR" (?:fde|node "\$FDE_CMD") capture/.test(stopHookCode)
253
+ && !stopHookCode.includes('run_fde "$FDE_CMD" capture')) {
254
+ fail('session-stop must delegate capture with its resolved engagement')
255
+ }
245
256
  ok('session-stop write side')
246
257
  }
258
+ const compactHook = read('hooks/pre-compact')
259
+ const compactHookCode = compactHook.replace(/^[ \t]*#.*$/gm, '')
260
+ if (!compactHookCode.includes('resolve_fde') || !compactHookCode.includes('command -v fde')) {
261
+ fail('pre-compact must resolve PATH fde before plugin copies')
262
+ }
263
+ if (!/FDEOPS_ENGAGEMENT="\$ENG_DIR" (?:fde|node "\$FDE_CMD") preserve/.test(compactHookCode)) {
264
+ fail('pre-compact must delegate preserve with its resolved engagement')
265
+ }
266
+ for (const h of ['session-stop', 'pre-compact']) {
267
+ const body = read('hooks/' + h).replace(/^[ \t]*#.*$/gm, '')
268
+ const contextTarget = /(?:"?\$(?:\{)?CONTEXT_FILE(?:\})?"?|"?\$(?:\{)?ENG_DIR(?:\})?\/context\.md"?)/.source
269
+ const redirectsToContext = new RegExp(`>{1,2}\\s*${contextTarget}`)
270
+ const teesToContext = new RegExp(`\\btee\\b[^\\n]*${contextTarget}`)
271
+ if (redirectsToContext.test(body) || teesToContext.test(body)) {
272
+ fail(`hooks/${h} must not append context.md directly`)
273
+ }
274
+ }
275
+ ok('mutation hooks delegate CLI writes')
247
276
  for (const h of ['session-start', 'session-stop', 'pre-compact']) {
248
277
  const mode = fs.statSync(path.join(root, 'hooks', h)).mode
249
278
  if (!(mode & 0o111)) fail(`hooks/${h} lost its executable bit`)
package/bin/fde.js CHANGED
@@ -22,6 +22,7 @@
22
22
  * fde owner [set …] who keeps this engagement record
23
23
  * fde receipts <term> "what did we agree?" - search memory with dates
24
24
  * fde capture session-end snapshot → context.md (hooks use this)
25
+ * fde preserve pre-compaction context snapshot (hook-internal; hooks use this)
25
26
  * fde status [--all] current engagement (default) or full portfolio (--all)
26
27
  * fde dashboard [--all] current engagement fieldbook (default) or all (--all)
27
28
  */
@@ -559,7 +560,7 @@ function parseSignalHistoryEntries(eng) {
559
560
  // Format-agnostic on token position: CLI writes "[date] [signal:x] text";
560
561
  // debrief may put the token at the end. Author tags [@x] are stripped for matching.
561
562
  const md = readClean(eng, 'stakeholders.md')
562
- const histText = sectionBody(md, 'Signal history') + '\n' + readEng(eng, SIGNAL_LEDGER)
563
+ const histText = sectionBody(md, 'Signal history') + '\n' + readClean(eng, SIGNAL_LEDGER)
563
564
  const history = []
564
565
  histText.split('\n').forEach(l => {
565
566
  const dm = l.trim().match(/^-\s*\[(\d{4}-\d{2}-\d{2})\]\s*(.*)$/i)
@@ -1181,6 +1182,11 @@ function routeDebriefInput(eng, input, { dry, force }) {
1181
1182
  if (m) {
1182
1183
  const type = m[1].toLowerCase()
1183
1184
  let body = m[2]
1185
+ const hit = findSecretHit(body)
1186
+ if (hit && !force) {
1187
+ console.error(`skipped ${type} line - looks like a ${hit}. Redact it, or re-run with --force.`)
1188
+ continue
1189
+ }
1184
1190
  if (type === 'next') {
1185
1191
  if (dry) console.log(`→ context.md ## Next action - ${body}`)
1186
1192
  else nextAction = body
@@ -1189,11 +1195,6 @@ function routeDebriefInput(eng, input, { dry, force }) {
1189
1195
  }
1190
1196
  const sigInline = (body.match(/\[signal:(red|amber|green)\]/i) || [])[1]
1191
1197
  if (sigInline) body = body.replace(/\[signal:(red|amber|green)\]/i, '').trim()
1192
- const hit = findSecretHit(body)
1193
- if (hit && !force) {
1194
- console.error(`skipped ${type} line - looks like a ${hit}. Redact it, or re-run with --force.`)
1195
- continue
1196
- }
1197
1198
  const entry = datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
1198
1199
  if (dry) console.log(`→ ${LOG_FILES[type]} ${entry}`)
1199
1200
  else appendLogEntry(eng, type, entry, { skipCommit: true })
@@ -1335,7 +1336,12 @@ function cmdCapture() {
1335
1336
  }).join(' ')
1336
1337
  if (!changed && !updated) process.exit(0) // idle session - keep memory clean
1337
1338
  const d = new Date()
1338
- const stamp = `${d.toISOString().slice(0, 10)} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
1339
+ const localDate = [
1340
+ d.getFullYear(),
1341
+ String(d.getMonth() + 1).padStart(2, '0'),
1342
+ String(d.getDate()).padStart(2, '0'),
1343
+ ].join('-')
1344
+ const stamp = `${localDate} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
1339
1345
  let block = `\n<!-- fdeops auto-capture -->\n## Session end - ${stamp}\n`
1340
1346
  if (branch) block += `- workspace: \`${branch}\` @ ${lastCommit || 'no commits yet'}\n`
1341
1347
  if (changed) block += `- uncommitted: ${changed}\n`
@@ -1347,6 +1353,36 @@ function cmdCapture() {
1347
1353
  } catch (_) {}
1348
1354
  }
1349
1355
 
1356
+ function cmdPreserve() {
1357
+ try {
1358
+ const eng = resolveEngagement({ forWrite: true })
1359
+ if (!eng || !fs.existsSync(path.join(eng, 'context.md'))) return
1360
+ const marker = '[fdeops context preserved'
1361
+ const today = new Date().toISOString().slice(0, 10)
1362
+ const decisionLines = readClean(eng, 'decisions.md').split('\n')
1363
+ if (decisionLines[decisionLines.length - 1] === '') decisionLines.pop()
1364
+ const recentDecisions = decisionLines.slice(-20).join('\n')
1365
+ const openRisks = readClean(eng, 'risks.md').split('\n')
1366
+ .filter(line => /open|active|unresolved/i.test(line))
1367
+ .slice(0, 8)
1368
+ .join('\n')
1369
+ const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
1370
+ const block = `\n---\n${marker} at ${timestamp}]\nRecent decisions (tail):\n${recentDecisions}\n\nOpen risks:\n${openRisks}\n---\n`
1371
+
1372
+ ensureMemoryGit(eng)
1373
+ const contextPath = path.join(eng, 'context.md')
1374
+ const blocked = refuseSymlinkWrite(contextPath, { soft: true })
1375
+ if (blocked) throw Object.assign(new Error(blocked), { code: 'ESYMLINK' })
1376
+ withFileLock(contextPath, () => {
1377
+ const context = readEng(eng, 'context.md')
1378
+ const preservedToday = context.split('\n')
1379
+ .some(line => line.includes(marker) && line.includes(today))
1380
+ if (!preservedToday) fs.appendFileSync(contextPath, block)
1381
+ }, { soft: true })
1382
+ commitMemory(eng, 'context preserve', { files: ['context.md'] })
1383
+ } catch (_) {}
1384
+ }
1385
+
1350
1386
  function cmdTriage() {
1351
1387
  const eng = resolveEngagement()
1352
1388
  if (!eng) {
@@ -1830,8 +1866,7 @@ function cmdDashboard(args) {
1830
1866
 
1831
1867
  try {
1832
1868
  fs.mkdirSync(path.dirname(outPath), { recursive: true })
1833
- refuseSymlinkWrite(outPath)
1834
- fs.writeFileSync(outPath, html)
1869
+ atomicWriteFile(outPath, html)
1835
1870
  } catch (e) {
1836
1871
  failFs(e, 'write fieldbook', outPath)
1837
1872
  }
@@ -1868,6 +1903,7 @@ function printUsage() {
1868
1903
  fde owner [set email] who keeps this engagement record
1869
1904
  fde receipts <term> "what did we agree?" with dates
1870
1905
  fde capture session-end memory snapshot (hooks use this)
1906
+ fde preserve pre-compaction context snapshot (hook-internal; hooks use this)
1871
1907
  fde status [--all] current engagement status (pass --all for full portfolio)
1872
1908
  fde dashboard [--all] current engagement fieldbook (pass --all for every client)
1873
1909
  env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
@@ -1889,6 +1925,7 @@ switch (cmd) {
1889
1925
  case 'owner': cmdOwner(args); break
1890
1926
  case 'receipts': cmdReceipts(args); break
1891
1927
  case 'capture': cmdCapture(); break
1928
+ case 'preserve': cmdPreserve(); break
1892
1929
  case 'status': cmdStatus(args); break
1893
1930
  case 'dashboard': cmdDashboard(args); break
1894
1931
  case 'help':
package/hooks/pre-compact CHANGED
@@ -36,6 +36,25 @@ registry_engagement_dir() {
36
36
  return 1
37
37
  }
38
38
 
39
+ # Prefer PATH `fde`, then plugin/repo copies - same order as session-start.
40
+ resolve_fde() {
41
+ if command -v fde >/dev/null 2>&1; then
42
+ printf '%s\n' "fde"
43
+ return 0
44
+ fi
45
+ for candidate in \
46
+ "${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/bin/fde.js}" \
47
+ "$(dirname "$0")/../bin/fde.js" \
48
+ "$HOME/.claude/fdeops/fde.js" \
49
+ "$HOME/.claude/plugins/fdeops/bin/fde.js"; do
50
+ if [ -n "$candidate" ] && [ -f "$candidate" ]; then
51
+ printf '%s\n' "$candidate"
52
+ return 0
53
+ fi
54
+ done
55
+ return 1
56
+ }
57
+
39
58
  ENG_DIR=$(resolve_engagement_dir "${FDEOPS_ENGAGEMENT:-${FDEOS_ENGAGEMENT:-}}")
40
59
 
41
60
  if [ -z "$ENG_DIR" ]; then
@@ -58,59 +77,13 @@ fi
58
77
 
59
78
  [ -z "$ENG_DIR" ] && exit 0
60
79
 
61
- CONTEXT_FILE="$ENG_DIR/context.md"
62
- DECISIONS_FILE="$ENG_DIR/decisions.md"
63
- RISKS_FILE="$ENG_DIR/risks.md"
64
- MARKER="[fdeops context preserved"
65
-
66
- [ -f "$CONTEXT_FILE" ] || exit 0
67
-
68
- # Avoid unbounded growth: skip if we already preserved today.
69
- if grep -Fq "$MARKER" "$CONTEXT_FILE" 2>/dev/null; then
70
- LAST=$(grep -F "$MARKER" "$CONTEXT_FILE" | tail -1)
71
- if echo "$LAST" | grep -q "$(date -u +%Y-%m-%d)"; then
72
- exit 0
80
+ FDE_CMD=$(resolve_fde || true)
81
+ if [ -n "$FDE_CMD" ]; then
82
+ if [ "$FDE_CMD" = "fde" ]; then
83
+ FDEOPS_ENGAGEMENT="$ENG_DIR" fde preserve >/dev/null 2>&1 || true
84
+ else
85
+ FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CMD" preserve >/dev/null 2>&1 || true
73
86
  fi
74
87
  fi
75
88
 
76
- # Redact <private> before extracting lines - this content is appended to
77
- # context.md, which session-start loads into the model. A closed private block
78
- # collapses to one placeholder line, so its inner text can't be tail'd or
79
- # grepped out tag-stripped. Case-insensitive, mirrors the JS /gi redactor.
80
- strip_private() {
81
- awk '
82
- BEGIN { inblock = 0 }
83
- {
84
- line = $0
85
- lc = tolower(line)
86
- if (inblock) { if (index(lc, "</private>") > 0) { inblock = 0 } next }
87
- if (index(lc, "<private>") > 0) {
88
- print "(private - redacted)"
89
- if (index(lc, "</private>") == 0) { inblock = 1 }
90
- next
91
- }
92
- print line
93
- }
94
- ' "$1"
95
- }
96
-
97
- TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
98
- LAST_DECISIONS=""
99
- OPEN_RISKS=""
100
-
101
- [ -f "$DECISIONS_FILE" ] && LAST_DECISIONS=$(strip_private "$DECISIONS_FILE" 2>/dev/null | tail -20)
102
- [ -f "$RISKS_FILE" ] && OPEN_RISKS=$(strip_private "$RISKS_FILE" 2>/dev/null | grep -iE "open|active|unresolved" | head -8)
103
-
104
- cat >> "$CONTEXT_FILE" 2>/dev/null << EOF
105
-
106
- ---
107
- $MARKER at $TIMESTAMP]
108
- Recent decisions (tail):
109
- $LAST_DECISIONS
110
-
111
- Open risks:
112
- $OPEN_RISKS
113
- ---
114
- EOF
115
-
116
89
  exit 0
@@ -68,6 +68,7 @@ fi
68
68
 
69
69
  # 5) Optional in-repo .fde (customer-approved only)
70
70
  if [ -z "$CONTEXT_FILE" ] && [ -f ".fde/context.md" ]; then
71
+ ENG_DIR=".fde"
71
72
  CONTEXT_FILE=".fde/context.md"
72
73
  fi
73
74
 
@@ -164,9 +165,9 @@ if [ -n "$CONTEXT_FILE" ] && [ -f "$CONTEXT_FILE" ]; then
164
165
  FDE_CMD=$(resolve_fde || true)
165
166
  if [ -n "$FDE_CMD" ]; then
166
167
  if [ "$FDE_CMD" = "fde" ]; then
167
- TRIAGE=$(fde triage 2>/dev/null || true)
168
+ TRIAGE=$(FDEOPS_ENGAGEMENT="$ENG_DIR" fde triage 2>/dev/null || true)
168
169
  else
169
- TRIAGE=$(node "$FDE_CMD" triage 2>/dev/null || true)
170
+ TRIAGE=$(FDEOPS_ENGAGEMENT="$ENG_DIR" node "$FDE_CMD" triage 2>/dev/null || true)
170
171
  fi
171
172
  if [ -n "$TRIAGE" ]; then
172
173
  CONTENT="$CONTENT---\n$TRIAGE\n\n"
@@ -2,11 +2,10 @@
2
2
  # fdeops SessionEnd - write-side memory backstop.
3
3
  #
4
4
  # The skill's memory contract says the agent appends a meaningful
5
- # "where we left off" to context.md before the session ends. This hook
6
- # guarantees a deterministic floor when it doesn't: date, workspace state,
7
- # and which engagement artifacts moved. Next session-start loads it back.
5
+ # "where we left off" to context.md before the session ends. This hook delegates
6
+ # the deterministic floor to the CLI, then refreshes the local dashboard.
8
7
  #
9
- # Deliberately dumb: no model calls, no network, append-only, exits silently.
8
+ # No model calls, no network, exits silently.
10
9
 
11
10
  cat >/dev/null 2>&1 || true # drain hook stdin; payload unused
12
11
 
@@ -44,6 +43,35 @@ registry_engagement_dir() {
44
43
  return 1
45
44
  }
46
45
 
46
+ # Prefer PATH `fde`, then plugin/repo copies - same order as session-start.
47
+ resolve_fde() {
48
+ if command -v fde >/dev/null 2>&1; then
49
+ printf '%s\n' "fde"
50
+ return 0
51
+ fi
52
+ for candidate in \
53
+ "${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/bin/fde.js}" \
54
+ "$(dirname "$0")/../bin/fde.js" \
55
+ "$HOME/.claude/fdeops/fde.js" \
56
+ "$HOME/.claude/plugins/fdeops/bin/fde.js"; do
57
+ if [ -n "$candidate" ] && [ -f "$candidate" ]; then
58
+ printf '%s\n' "$candidate"
59
+ return 0
60
+ fi
61
+ done
62
+ return 1
63
+ }
64
+
65
+ run_fde() {
66
+ local cmd="$1"
67
+ shift
68
+ if [ "$cmd" = "fde" ]; then
69
+ FDEOPS_ENGAGEMENT="$ENG_DIR" fde "$@" >/dev/null 2>&1 || true
70
+ else
71
+ FDEOPS_ENGAGEMENT="$ENG_DIR" node "$cmd" "$@" >/dev/null 2>&1 || true
72
+ fi
73
+ }
74
+
47
75
  ENG_DIR=""
48
76
 
49
77
  # 1) Environment variable (any agent)
@@ -72,45 +100,11 @@ if [ -z "$ENG_DIR" ] && [ -d ".fde" ]; then
72
100
  fi
73
101
 
74
102
  [ -z "$ENG_DIR" ] && exit 0
75
- CONTEXT_FILE="$ENG_DIR/context.md"
76
103
 
77
- TODAY=$(date +%F)
78
- NOW=$(date +%H:%M)
79
-
80
- # Workspace state (only if the cwd is a git repo)
81
- BRANCH=$(git -C "$PWD" branch --show-current 2>/dev/null)
82
- LAST_COMMIT=$(git -C "$PWD" log -1 --format="%h %s" 2>/dev/null | head -c 100)
83
- CHANGED=$(git -C "$PWD" status --porcelain 2>/dev/null | head -8 | sed 's/^...//' | tr '\n' ' ')
84
-
85
- # Engagement artifacts updated in the last 12h (the deliverable=memory trail)
86
- UPDATED=$(find "$ENG_DIR" -maxdepth 1 -name "*.md" ! -name "context.md" -mmin -720 2>/dev/null \
87
- | while read -r f; do basename "$f"; done | tr '\n' ' ')
88
-
89
- # Idle session (no edits, no artifact updates) → leave the memory clean.
90
- # LAST_COMMIT alone is not "movement": it exists in any repo with history.
91
- [ -z "$CHANGED" ] && [ -z "$UPDATED" ] && exit 0
92
-
93
- {
94
- printf '\n<!-- fdeops auto-capture -->\n'
95
- printf '## Session end - %s %s\n' "$TODAY" "$NOW"
96
- [ -n "$BRANCH" ] && printf -- '- workspace: `%s` @ %s\n' "$BRANCH" "${LAST_COMMIT:-no commits yet}"
97
- [ -n "$CHANGED" ] && printf -- '- uncommitted: %s\n' "$CHANGED"
98
- [ -n "$UPDATED" ] && printf -- '- engagement files updated: %s\n' "$UPDATED"
99
- } 2>/dev/null >> "$CONTEXT_FILE" || true
100
-
101
- # Refresh the local fieldbook.html so the portfolio view is current next time
102
- # it's opened. Deterministic render of .fde/ - zero tokens, best-effort, never
103
- # allowed to break the session.
104
- if command -v node >/dev/null 2>&1; then
105
- for FDE_CLI in \
106
- "${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/bin/fde.js}" \
107
- "$(dirname "$0")/../bin/fde.js" \
108
- "$HOME/.claude/fdeops/fde.js"; do
109
- if [ -n "$FDE_CLI" ] && [ -f "$FDE_CLI" ]; then
110
- node "$FDE_CLI" dashboard >/dev/null 2>&1 || true
111
- break
112
- fi
113
- done
104
+ FDE_CMD=$(resolve_fde || true)
105
+ if [ -n "$FDE_CMD" ]; then
106
+ run_fde "$FDE_CMD" capture
107
+ run_fde "$FDE_CMD" dashboard
114
108
  fi
115
109
 
116
110
  exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.9.10",
3
+ "version": "3.9.11",
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",