fdeops 3.7.1 → 3.7.3

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/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AGENTS.md - working in the fdeops repository
2
2
 
3
- This repository **is** fdeops - the operating system for Forward Deployed Engineers. One `@fde` skill routes an entire client engagement across six domains, the `fde` CLI does the deterministic work, and per-customer memory writes itself into `.fde/` files.
3
+ This repository **is** fdeops - the second brain for Forward Deployed Engineers. One `@fde` skill routes an entire client engagement across six domains, the `fde` CLI does the deterministic work, and per-customer memory writes itself into `.fde/` files.
4
4
 
5
5
  ## If you are helping use fdeops in an engagement
6
6
 
package/README.md CHANGED
@@ -27,6 +27,8 @@ Your AI agent's memory is scoped to a **repo**. Client work isn't: one engagemen
27
27
 
28
28
  fdeops adds the missing layer: memory scoped to the **client** - plain markdown at `~/fde-engagements/<client>/.fde/`, written as a side effect of doing the work. Local only, zero dependencies, no network, no telemetry.
29
29
 
30
+ A notes app stores what you type. fdeops loads the right client into your AI agent's context automatically and turns meetings into dated receipts you can defend - the difference is what happens without you opening it.
31
+
30
32
  ## Without fdeops vs with fdeops
31
33
 
32
34
  | Moment | Without fdeops | With fdeops |
@@ -51,9 +53,11 @@ fdeops adds the missing layer: memory scoped to the **client** - plain markdown
51
53
  **2. Bind your client workspace** - run once, inside the workspace:
52
54
 
53
55
  ```bash
54
- fde resume --init garvey
56
+ npx fdeops resume --init garvey
55
57
  ```
56
58
 
59
+ (`npx` needs nothing pre-installed. Want the bare `fde` command the rest of this README uses? `npm i -g fdeops` - the plugin install alone does not put `fde` on your PATH.)
60
+
57
61
  fdeops' `--init` creates the engagement memory at `~/fde-engagements/garvey/.fde/` (plain markdown, private to your machine) and binds this workspace to it. The hooks read that binding - context auto-loads at session start, auto-captures at session end. That is the whole setup.
58
62
 
59
63
  **3. Work**
@@ -64,7 +68,7 @@ fdeops' `--init` creates the engagement memory at `~/fde-engagements/garvey/.fde
64
68
 
65
69
  `@fde` is the one skill fdeops installs. Describe what's happening; it routes to the right field method and the memory writes itself. Full workflow: [docs/USAGE.md](docs/USAGE.md).
66
70
 
67
- Not ready to install? `npx fdeops scan` runs on any repo you can read - day-1 recon (pure `git` + file reads, no config, no account) that maps hotspots, test gaps, and reverted attempts, and ends with the ASK ON DAY 1 questions the brief never mentions.
71
+ Not ready to install? `npx fdeops scan` runs on any repo you can read - day-1 recon (pure `git` + file reads, no config, no account) that maps hotspots, test gaps, and reverted attempts, and ends with the ASK ON DAY 1 questions the brief never mentions. The scan is heuristic by design - treat its output as leads to verify on day one, not findings.
68
72
 
69
73
  <details>
70
74
  <summary><strong>Other install paths</strong> - Cursor, Codex, Copilot, Gemini CLI, local LLMs, air-gapped</summary>
@@ -80,6 +84,17 @@ Not ready to install? `npx fdeops scan` runs on any repo you can read - day-1 re
80
84
 
81
85
  ---
82
86
 
87
+ ## The week
88
+
89
+ This is the actual habit, not the 35 skills:
90
+
91
+ - **Monday morning** - open your agent, context loads, you're not re-explaining anything
92
+ - **After a meeting** - `fde debrief` turns raw notes into dated decisions, risks, and signals
93
+ - **Mid-scope-fight** - `fde receipts <term>` answers "when did we agree to that?"
94
+ - **Friday** - `fde status` gives you the sponsor update from the week's actual record
95
+
96
+ ---
97
+
83
98
  ## How it works
84
99
 
85
100
  Two hooks and one router, on top of the fieldbook:
@@ -100,7 +115,7 @@ Two hooks and one router, on top of the fieldbook:
100
115
 
101
116
  Overlays for regulated domains (AI, fintech, healthcare, government) activate on signal. fdeops complements your agent's native repo memory: CLAUDE.md holds how the *code* works; the fieldbook holds how the *engagement* works. Full matrix: [docs/skills.md](docs/skills.md).
102
117
 
103
- Works with **Claude Code** - **Cursor** - **Copilot** - **Devin** - **Gemini CLI** - **Ollama** - **LM Studio** - any model that reads a markdown file.
118
+ Works with **Claude Code** - **Cursor** - **Copilot** - **Gemini CLI** - **Ollama** - **LM Studio** - any model that reads a markdown file.
104
119
 
105
120
  ---
106
121
 
@@ -174,6 +189,7 @@ Details: [PRIVACY.md](PRIVACY.md) · [SECURITY.md](SECURITY.md)
174
189
  ## Principles
175
190
 
176
191
  - **The artifact is the memory** - producing work and recording it are one action
192
+ - **Methods, not autonomy** - each skill tells you what to check; the judgment, the trust, and the consequences stay yours
177
193
  - **Trust before production** - earn the right to touch their systems
178
194
  - **Brief is a hypothesis** - discover before building the wrong thing
179
195
  - **Evidence on every claim** - these files get defended in front of skeptical clients
package/bin/fde.js CHANGED
@@ -111,9 +111,17 @@ function resolveEngagement() {
111
111
  }
112
112
  } catch (_) {}
113
113
  }
114
- // 4) workspace dir name matches an engagement slug
114
+ // 4) workspace dir name matches an engagement slug. This is a convenience,
115
+ // NOT a binding - an unbound directory that merely happens to be named like a
116
+ // client (a fork, a demo, a second client with the same codename) would
117
+ // otherwise attach to that client's memory silently and get written into.
118
+ // Never silent: warn on stderr so cross-client contamination can't happen
119
+ // unnoticed, and tell the user how to make the binding explicit.
115
120
  const guess = path.join(ENGAGEMENTS_ROOT, slugify(path.basename(cwd)), '.fde')
116
- if (fs.existsSync(guess)) return guess
121
+ if (fs.existsSync(guess)) {
122
+ process.stderr.write(`⚠ resolved engagement by directory name ("${slugify(path.basename(cwd))}"), not a saved binding. If this is the right client, run \`fde resume --init ${slugify(path.basename(cwd))}\` here to bind it; if not, you are about to read/write the WRONG client's memory.\n`)
123
+ return guess
124
+ }
117
125
  // 5) in-repo .fde (engagement-approved only)
118
126
  if (fs.existsSync(path.join(cwd, '.fde'))) return path.join(cwd, '.fde')
119
127
  return null
@@ -157,7 +165,9 @@ function sectionBody(md, heading) {
157
165
  // zero-effort floor when NO token exists anywhere - prose like "escalated to CTO,
158
166
  // resolved amicably" must not flip a client amber forever.
159
167
  function computeSignals(eng) {
160
- const ctx = readEng(eng, 'context.md'); const stake = readEng(eng, 'stakeholders.md'); const risks = readEng(eng, 'risks.md')
168
+ // readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
169
+ // to the terminal and the rendered HTML - a <private> risk must never surface.
170
+ const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md'); const risks = readClean(eng, 'risks.md')
161
171
  const phase = (ctx.match(/phase[:* ]+\**([a-z-]+)/i) || [])[1] || '?'
162
172
  let latest = null
163
173
  for (const l of stake.split('\n')) {
@@ -463,7 +473,7 @@ function cmdScan() {
463
473
  const sec = grepFiles(confFiles, /(api[_-]?key|secret|password|token)\s*[:=]\s*['"][^'"]{8,}/i, 10)
464
474
  .filter(h => !/example|template|test|sample|placeholder/i.test(h.file + h.text))
465
475
  sec.length
466
- ? sec.forEach(h => out.push(` ${h.file}:${h.line} ${h.text.replace(/(['"])([^'"]{4})[^'"]+(['"])/, '$1$2…REDACTED$3')}`))
476
+ ? sec.forEach(h => out.push(` ${h.file}:${h.line} ${h.text.replace(/(['"])[^'"]+(['"])/, '$1REDACTED$2')}`))
467
477
  : out.push(' none found')
468
478
  out.push(' (grep-grade check - run gitleaks or trufflehog for real secret coverage)')
469
479
 
@@ -547,10 +557,11 @@ function cmdResume(args) {
547
557
  process.exit(2)
548
558
  }
549
559
  console.log(`ENGAGEMENT: ${eng}\n`)
550
- try {
551
- const ctx = fs.readFileSync(path.join(eng, 'context.md'), 'utf8')
552
- console.log(args.includes('--full') ? ctx : resumeView(ctx))
553
- } catch (_) { console.log('(context.md empty - new engagement)') }
560
+ // readClean, not fs.readFileSync: this output is what an agent loads as
561
+ // context, so it goes through the same <private> redaction as the dashboard.
562
+ const ctx = readClean(eng, 'context.md')
563
+ if (!ctx) { console.log('(context.md empty - new engagement)') }
564
+ else { console.log(args.includes('--full') ? ctx : resumeView(ctx)) }
554
565
  }
555
566
 
556
567
  // Token discipline: context.md grows every session (the session-stop hook
@@ -660,7 +671,10 @@ function cmdReceipts(args) {
660
671
  for (const f of ['decisions.md', 'delivery.md', 'risks.md', 'stakeholders.md', 'context.md', 'success.md', 'brief.md', 'reality.md']) {
661
672
  const p = path.join(eng, f)
662
673
  if (!fs.existsSync(p)) continue
663
- fs.readFileSync(p, 'utf8').split('\n').forEach((l, i) => {
674
+ // readClean, not raw read: receipts must not grep sealed <private> notes back
675
+ // out. Redaction can shift line numbers past a multi-line block; the file:line
676
+ // is advisory, and not leaking a sealed secret is worth an approximate number.
677
+ readClean(eng, f).split('\n').forEach((l, i) => {
664
678
  if (rx.test(l)) { console.log(`${f}:${i + 1} ${l.trim().slice(0, 160)}`); found++ }
665
679
  })
666
680
  }
@@ -724,9 +738,14 @@ function escapeHtml(s) {
724
738
  // Closed pairs are redacted; an unclosed <private> redacts to end-of-text so a
725
739
  // forgotten closing tag can never leak the rest of the file.
726
740
  function stripPrivate(md) {
741
+ // Redact <private> before <!-- --> so a private block that itself contains a
742
+ // comment can't survive. Closed pairs first; then any unclosed <private> to
743
+ // end-of-text so a forgotten close tag never leaks the rest of the file.
744
+ // Case-insensitive (<PRIVATE> too). This is the ONE redactor every read path
745
+ // that can reach the model or a shared artifact must go through - see readClean.
727
746
  return md
728
- .replace(/<private>[\s\S]*?<\/private>/gi, '(private - redacted from dashboard)')
729
- .replace(/<private>[\s\S]*$/i, '(private - redacted from dashboard)')
747
+ .replace(/<private>[\s\S]*?<\/private>/gi, '(private - redacted)')
748
+ .replace(/<private>[\s\S]*$/i, '(private - redacted)')
730
749
  .replace(/<!--[\s\S]*?-->/g, '')
731
750
  }
732
751
 
package/hooks/pre-compact CHANGED
@@ -56,19 +56,40 @@ MARKER="[fdeops context preserved"
56
56
  [ -f "$CONTEXT_FILE" ] || exit 0
57
57
 
58
58
  # Avoid unbounded growth: skip if we already preserved today.
59
- if grep -q "$MARKER" "$CONTEXT_FILE" 2>/dev/null; then
60
- LAST=$(grep "$MARKER" "$CONTEXT_FILE" | tail -1)
59
+ if grep -Fq "$MARKER" "$CONTEXT_FILE" 2>/dev/null; then
60
+ LAST=$(grep -F "$MARKER" "$CONTEXT_FILE" | tail -1)
61
61
  if echo "$LAST" | grep -q "$(date -u +%Y-%m-%d)"; then
62
62
  exit 0
63
63
  fi
64
64
  fi
65
65
 
66
+ # Redact <private> before extracting lines - this content is appended to
67
+ # context.md, which session-start loads into the model. A closed private block
68
+ # collapses to one placeholder line, so its inner text can't be tail'd or
69
+ # grepped out tag-stripped. Case-insensitive, mirrors the JS /gi redactor.
70
+ strip_private() {
71
+ awk '
72
+ BEGIN { inblock = 0 }
73
+ {
74
+ line = $0
75
+ lc = tolower(line)
76
+ if (inblock) { if (index(lc, "</private>") > 0) { inblock = 0 } next }
77
+ if (index(lc, "<private>") > 0) {
78
+ print "(private - redacted)"
79
+ if (index(lc, "</private>") == 0) { inblock = 1 }
80
+ next
81
+ }
82
+ print line
83
+ }
84
+ ' "$1"
85
+ }
86
+
66
87
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
67
88
  LAST_DECISIONS=""
68
89
  OPEN_RISKS=""
69
90
 
70
- [ -f "$DECISIONS_FILE" ] && LAST_DECISIONS=$(tail -20 "$DECISIONS_FILE" 2>/dev/null)
71
- [ -f "$RISKS_FILE" ] && OPEN_RISKS=$(grep -iE "open|active|unresolved" "$RISKS_FILE" 2>/dev/null | head -8)
91
+ [ -f "$DECISIONS_FILE" ] && LAST_DECISIONS=$(strip_private "$DECISIONS_FILE" 2>/dev/null | tail -20)
92
+ [ -f "$RISKS_FILE" ] && OPEN_RISKS=$(strip_private "$RISKS_FILE" 2>/dev/null | grep -iE "open|active|unresolved" | head -8)
72
93
 
73
94
  cat >> "$CONTEXT_FILE" 2>/dev/null << EOF
74
95
 
@@ -79,6 +79,35 @@ if [ "$IS_FDE_PROJECT" -eq 0 ]; then
79
79
  exit 0
80
80
  fi
81
81
 
82
+ # Mirrors stripPrivate() in bin/fde.js: this is what an agent loads as
83
+ # context, so <private>...</private> blocks (sacred data, never-AI content)
84
+ # must never reach here unredacted, same guarantee as the dashboard renderer.
85
+ # <private> blocks are written one per line in the templates (own line, own
86
+ # closing tag), so a line-based state machine matches the real usage; an
87
+ # unclosed <private> redacts to end-of-file, same as the JS regex fallback.
88
+ strip_private() {
89
+ # Detection is case-insensitive (matches the JS /gi redactor) - <PRIVATE>,
90
+ # <Private>, <private> all redact. Detect on a lowercased copy of the line;
91
+ # never print the original when a tag is present.
92
+ awk '
93
+ BEGIN { inblock = 0 }
94
+ {
95
+ line = $0
96
+ lc = tolower(line)
97
+ if (inblock) {
98
+ if (index(lc, "</private>") > 0) { inblock = 0 }
99
+ next
100
+ }
101
+ if (index(lc, "<private>") > 0) {
102
+ print "(private - redacted)"
103
+ if (index(lc, "</private>") == 0) { inblock = 1 }
104
+ next
105
+ }
106
+ print line
107
+ }
108
+ ' "$1"
109
+ }
110
+
82
111
  # Token discipline: context.md grows every session (session-stop appends a
83
112
  # snapshot). Inject a bounded view - curated head + most recent activity -
84
113
  # instead of the whole log. Mirrors resumeView() in bin/fde.js; keep in sync.
@@ -106,7 +135,10 @@ if [ -n "$BOOTSTRAP" ]; then
106
135
  fi
107
136
 
108
137
  if [ -n "$CONTEXT_FILE" ] && [ -f "$CONTEXT_FILE" ]; then
109
- CONTENT="$CONTENT---\nEngagement context ($CONTEXT_FILE):\n$(bounded_context "$CONTEXT_FILE")\n"
138
+ REDACTED_CONTEXT=$(mktemp)
139
+ strip_private "$CONTEXT_FILE" > "$REDACTED_CONTEXT"
140
+ CONTENT="$CONTENT---\nEngagement context ($CONTEXT_FILE):\n$(bounded_context "$REDACTED_CONTEXT")\n"
141
+ rm -f "$REDACTED_CONTEXT"
110
142
  fi
111
143
 
112
144
  printf '%b' "$CONTENT"
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.7.1",
3
+ "version": "3.7.3",
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",
7
7
  "fde": "bin/fde.js"
8
8
  },
9
9
  "scripts": {
10
- "check": "node bin/check.js",
11
- "prepublishOnly": "node bin/check.js"
10
+ "check": "node bin/check.js && npm test",
11
+ "test": "node --test test/*.test.js",
12
+ "prepublishOnly": "node bin/check.js && npm test"
12
13
  },
13
14
  "files": [
14
15
  "bin/",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fde
3
- description: The operating system for Forward Deployed Engineers. 35 skills across 6 domains - from first meeting to final handoff. Tell it your situation, it routes to the right skill, does the work, and the engagement memory writes itself.
3
+ description: The second brain for Forward Deployed Engineers. 35 skills across 6 domains - from first meeting to final handoff. Tell it your situation, it routes to the right skill, does the work, and the engagement memory writes itself.
4
4
  ---
5
5
 
6
6
  # @fde