fdeops 3.7.2 → 3.7.4

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
@@ -93,9 +93,17 @@ function resolveEngagement() {
93
93
  // 1) explicit env (back-compat: accept old FDEOS_ENGAGEMENT too)
94
94
  const env = (process.env.FDEOPS_ENGAGEMENT || process.env.FDEOS_ENGAGEMENT || '').replace(/^~/, HOME).trim()
95
95
  if (env && fs.existsSync(env)) return env
96
- // 2) workspace registry binding (written by resume --init)
96
+ // 2) workspace registry binding (written by resume --init). Match the cwd OR
97
+ // any ancestor of it - FDEs run commands from src/, packages/api/, etc., not
98
+ // just the repo root where they bound. Nearest (deepest) registered ancestor
99
+ // wins, exactly like git searching upward for .git. `startsWith(workspace +
100
+ // sep)` requires a true path-boundary ancestor, so /work/repo-2 never matches
101
+ // a binding on /work/repo. Kept in lockstep with registry_engagement_dir in
102
+ // hooks/session-start, session-stop, pre-compact.
97
103
  const cwd = process.cwd()
98
- const reg = readRegistry().find(r => r.workspace === cwd)
104
+ const reg = readRegistry()
105
+ .filter(r => cwd === r.workspace || cwd.startsWith(r.workspace + path.sep))
106
+ .sort((a, b) => b.workspace.length - a.workspace.length)[0]
99
107
  if (reg) {
100
108
  const p = path.join(ENGAGEMENTS_ROOT, reg.slug, '.fde')
101
109
  if (fs.existsSync(p)) return p
@@ -111,9 +119,17 @@ function resolveEngagement() {
111
119
  }
112
120
  } catch (_) {}
113
121
  }
114
- // 4) workspace dir name matches an engagement slug
122
+ // 4) workspace dir name matches an engagement slug. This is a convenience,
123
+ // NOT a binding - an unbound directory that merely happens to be named like a
124
+ // client (a fork, a demo, a second client with the same codename) would
125
+ // otherwise attach to that client's memory silently and get written into.
126
+ // Never silent: warn on stderr so cross-client contamination can't happen
127
+ // unnoticed, and tell the user how to make the binding explicit.
115
128
  const guess = path.join(ENGAGEMENTS_ROOT, slugify(path.basename(cwd)), '.fde')
116
- if (fs.existsSync(guess)) return guess
129
+ if (fs.existsSync(guess)) {
130
+ 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`)
131
+ return guess
132
+ }
117
133
  // 5) in-repo .fde (engagement-approved only)
118
134
  if (fs.existsSync(path.join(cwd, '.fde'))) return path.join(cwd, '.fde')
119
135
  return null
@@ -157,7 +173,9 @@ function sectionBody(md, heading) {
157
173
  // zero-effort floor when NO token exists anywhere - prose like "escalated to CTO,
158
174
  // resolved amicably" must not flip a client amber forever.
159
175
  function computeSignals(eng) {
160
- const ctx = readEng(eng, 'context.md'); const stake = readEng(eng, 'stakeholders.md'); const risks = readEng(eng, 'risks.md')
176
+ // readClean, not readEng: status/dashboard echo topRisk and stakeholder lines
177
+ // to the terminal and the rendered HTML - a <private> risk must never surface.
178
+ const ctx = readClean(eng, 'context.md'); const stake = readClean(eng, 'stakeholders.md'); const risks = readClean(eng, 'risks.md')
161
179
  const phase = (ctx.match(/phase[:* ]+\**([a-z-]+)/i) || [])[1] || '?'
162
180
  let latest = null
163
181
  for (const l of stake.split('\n')) {
@@ -463,7 +481,7 @@ function cmdScan() {
463
481
  const sec = grepFiles(confFiles, /(api[_-]?key|secret|password|token)\s*[:=]\s*['"][^'"]{8,}/i, 10)
464
482
  .filter(h => !/example|template|test|sample|placeholder/i.test(h.file + h.text))
465
483
  sec.length
466
- ? sec.forEach(h => out.push(` ${h.file}:${h.line} ${h.text.replace(/(['"])([^'"]{4})[^'"]+(['"])/, '$1$2…REDACTED$3')}`))
484
+ ? sec.forEach(h => out.push(` ${h.file}:${h.line} ${h.text.replace(/(['"])[^'"]+(['"])/, '$1REDACTED$2')}`))
467
485
  : out.push(' none found')
468
486
  out.push(' (grep-grade check - run gitleaks or trufflehog for real secret coverage)')
469
487
 
@@ -657,15 +675,42 @@ function cmdReceipts(args) {
657
675
  const eng = resolveEngagement()
658
676
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
659
677
  const rx = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
660
- let found = 0
661
- for (const f of ['decisions.md', 'delivery.md', 'risks.md', 'stakeholders.md', 'context.md', 'success.md', 'brief.md', 'reality.md']) {
662
- const p = path.join(eng, f)
663
- if (!fs.existsSync(p)) continue
664
- fs.readFileSync(p, 'utf8').split('\n').forEach((l, i) => {
665
- if (rx.test(l)) { console.log(`${f}:${i + 1} ${l.trim().slice(0, 160)}`); found++ }
666
- })
678
+ // "receipts" answers "what did we AGREE?" - so dated, agreed records are the
679
+ // receipt. brief.md is the client's hypothesis and reality.md/context.md are
680
+ // working notes; a hit there is a CLAIM, not an agreement. Keeping them in the
681
+ // same list let an FDE cite a sales promise as a receipt - so they get a
682
+ // separate, clearly-labelled section that is never mistaken for the record.
683
+ const AGREEMENTS = ['decisions.md', 'delivery.md', 'success.md', 'risks.md', 'stakeholders.md']
684
+ const CLAIMS = ['brief.md', 'reality.md', 'context.md']
685
+ const collect = files => {
686
+ const hits = []
687
+ for (const f of files) {
688
+ if (!fs.existsSync(path.join(eng, f))) continue
689
+ // readClean, not raw read: receipts must not grep sealed <private> notes
690
+ // back out. Redaction can shift line numbers past a multi-line block; the
691
+ // file:line is advisory - not leaking a sealed secret is worth that.
692
+ readClean(eng, f).split('\n').forEach((l, i) => {
693
+ if (rx.test(l)) hits.push(` ${f}:${i + 1} ${l.trim().slice(0, 160)}`)
694
+ })
695
+ }
696
+ return hits
697
+ }
698
+ const agreed = collect(AGREEMENTS)
699
+ const claimed = collect(CLAIMS)
700
+ if (agreed.length) {
701
+ console.log('AGREED (dated record - defensible):')
702
+ agreed.forEach(h => console.log(h))
703
+ }
704
+ if (claimed.length) {
705
+ if (agreed.length) console.log('')
706
+ console.log('CLAIMS & working notes (stated, NOT an agreement - verify before citing):')
707
+ claimed.forEach(h => console.log(h))
708
+ }
709
+ if (!agreed.length && !claimed.length) {
710
+ console.log(`no record of "${term}" - nothing was ever logged about it. A gap in the record, not proof of absence: if it WAS agreed, log it now, dated today.`)
711
+ } else if (!agreed.length) {
712
+ console.log('\n(no dated agreement matched - only unverified claims above. If this was agreed, log it: fde log decision "...")')
667
713
  }
668
- if (!found) console.log(`no record of "${term}" - nothing was ever logged about it. A gap in the record, not proof of absence: if it WAS agreed, log it now, dated today.`)
669
714
  }
670
715
 
671
716
  function cmdCapture() {
@@ -725,9 +770,14 @@ function escapeHtml(s) {
725
770
  // Closed pairs are redacted; an unclosed <private> redacts to end-of-text so a
726
771
  // forgotten closing tag can never leak the rest of the file.
727
772
  function stripPrivate(md) {
773
+ // Redact <private> before <!-- --> so a private block that itself contains a
774
+ // comment can't survive. Closed pairs first; then any unclosed <private> to
775
+ // end-of-text so a forgotten close tag never leaks the rest of the file.
776
+ // Case-insensitive (<PRIVATE> too). This is the ONE redactor every read path
777
+ // that can reach the model or a shared artifact must go through - see readClean.
728
778
  return md
729
- .replace(/<private>[\s\S]*?<\/private>/gi, '(private - redacted from dashboard)')
730
- .replace(/<private>[\s\S]*$/i, '(private - redacted from dashboard)')
779
+ .replace(/<private>[\s\S]*?<\/private>/gi, '(private - redacted)')
780
+ .replace(/<private>[\s\S]*$/i, '(private - redacted)')
731
781
  .replace(/<!--[\s\S]*?-->/g, '')
732
782
  }
733
783
 
package/hooks/pre-compact CHANGED
@@ -18,9 +18,15 @@ resolve_engagement_dir() {
18
18
  registry_engagement_dir() {
19
19
  local reg="$HOME/fde-engagements/.registry" slug
20
20
  [ -f "$reg" ] || return 1
21
- slug=$(awk 'BEGIN{ws=ENVIRON["PWD"]}
21
+ slug=$(awk 'BEGIN{ws=ENVIRON["PWD"]; best=-1}
22
22
  { i = match($0, / [^ ]*$/)
23
- if (i > 0 && substr($0, 1, i - 1) == ws) { print substr($0, i + 1); exit } }' "$reg" 2>/dev/null)
23
+ if (i > 0) {
24
+ wpath = substr($0, 1, i - 1)
25
+ if (ws == wpath || index(ws, wpath "/") == 1) {
26
+ if (length(wpath) > best) { best = length(wpath); slug = substr($0, i + 1) }
27
+ }
28
+ } }
29
+ END { if (best >= 0) print slug }' "$reg" 2>/dev/null)
24
30
  [ -z "$slug" ] && return 1
25
31
  [ -d "$HOME/fde-engagements/$slug/.fde" ] && printf '%s\n' "$HOME/fde-engagements/$slug/.fde" && return 0
26
32
  return 1
@@ -56,19 +62,40 @@ MARKER="[fdeops context preserved"
56
62
  [ -f "$CONTEXT_FILE" ] || exit 0
57
63
 
58
64
  # 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)
65
+ if grep -Fq "$MARKER" "$CONTEXT_FILE" 2>/dev/null; then
66
+ LAST=$(grep -F "$MARKER" "$CONTEXT_FILE" | tail -1)
61
67
  if echo "$LAST" | grep -q "$(date -u +%Y-%m-%d)"; then
62
68
  exit 0
63
69
  fi
64
70
  fi
65
71
 
72
+ # Redact <private> before extracting lines - this content is appended to
73
+ # context.md, which session-start loads into the model. A closed private block
74
+ # collapses to one placeholder line, so its inner text can't be tail'd or
75
+ # grepped out tag-stripped. Case-insensitive, mirrors the JS /gi redactor.
76
+ strip_private() {
77
+ awk '
78
+ BEGIN { inblock = 0 }
79
+ {
80
+ line = $0
81
+ lc = tolower(line)
82
+ if (inblock) { if (index(lc, "</private>") > 0) { inblock = 0 } next }
83
+ if (index(lc, "<private>") > 0) {
84
+ print "(private - redacted)"
85
+ if (index(lc, "</private>") == 0) { inblock = 1 }
86
+ next
87
+ }
88
+ print line
89
+ }
90
+ ' "$1"
91
+ }
92
+
66
93
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
67
94
  LAST_DECISIONS=""
68
95
  OPEN_RISKS=""
69
96
 
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)
97
+ [ -f "$DECISIONS_FILE" ] && LAST_DECISIONS=$(strip_private "$DECISIONS_FILE" 2>/dev/null | tail -20)
98
+ [ -f "$RISKS_FILE" ] && OPEN_RISKS=$(strip_private "$RISKS_FILE" 2>/dev/null | grep -iE "open|active|unresolved" | head -8)
72
99
 
73
100
  cat >> "$CONTEXT_FILE" 2>/dev/null << EOF
74
101
 
@@ -30,9 +30,15 @@ resolve_engagement_dir() {
30
30
  registry_engagement_dir() {
31
31
  local reg="$HOME/fde-engagements/.registry" slug
32
32
  [ -f "$reg" ] || return 1
33
- slug=$(awk 'BEGIN{ws=ENVIRON["PWD"]}
33
+ slug=$(awk 'BEGIN{ws=ENVIRON["PWD"]; best=-1}
34
34
  { i = match($0, / [^ ]*$/)
35
- if (i > 0 && substr($0, 1, i - 1) == ws) { print substr($0, i + 1); exit } }' "$reg" 2>/dev/null)
35
+ if (i > 0) {
36
+ wpath = substr($0, 1, i - 1)
37
+ if (ws == wpath || index(ws, wpath "/") == 1) {
38
+ if (length(wpath) > best) { best = length(wpath); slug = substr($0, i + 1) }
39
+ }
40
+ } }
41
+ END { if (best >= 0) print slug }' "$reg" 2>/dev/null)
36
42
  [ -z "$slug" ] && return 1
37
43
  [ -d "$HOME/fde-engagements/$slug/.fde" ] && printf '%s\n' "$HOME/fde-engagements/$slug/.fde" && return 0
38
44
  return 1
@@ -86,17 +92,21 @@ fi
86
92
  # closing tag), so a line-based state machine matches the real usage; an
87
93
  # unclosed <private> redacts to end-of-file, same as the JS regex fallback.
88
94
  strip_private() {
95
+ # Detection is case-insensitive (matches the JS /gi redactor) - <PRIVATE>,
96
+ # <Private>, <private> all redact. Detect on a lowercased copy of the line;
97
+ # never print the original when a tag is present.
89
98
  awk '
90
99
  BEGIN { inblock = 0 }
91
100
  {
92
101
  line = $0
102
+ lc = tolower(line)
93
103
  if (inblock) {
94
- if (index(line, "</private>") > 0) { inblock = 0 }
104
+ if (index(lc, "</private>") > 0) { inblock = 0 }
95
105
  next
96
106
  }
97
- if (index(line, "<private>") > 0) {
107
+ if (index(lc, "<private>") > 0) {
98
108
  print "(private - redacted)"
99
- if (index(line, "</private>") == 0) { inblock = 1 }
109
+ if (index(lc, "</private>") == 0) { inblock = 1 }
100
110
  next
101
111
  }
102
112
  print line
@@ -26,9 +26,15 @@ resolve_engagement_dir() {
26
26
  registry_engagement_dir() {
27
27
  local reg="$HOME/fde-engagements/.registry" slug
28
28
  [ -f "$reg" ] || return 1
29
- slug=$(awk 'BEGIN{ws=ENVIRON["PWD"]}
29
+ slug=$(awk 'BEGIN{ws=ENVIRON["PWD"]; best=-1}
30
30
  { i = match($0, / [^ ]*$/)
31
- if (i > 0 && substr($0, 1, i - 1) == ws) { print substr($0, i + 1); exit } }' "$reg" 2>/dev/null)
31
+ if (i > 0) {
32
+ wpath = substr($0, 1, i - 1)
33
+ if (ws == wpath || index(ws, wpath "/") == 1) {
34
+ if (length(wpath) > best) { best = length(wpath); slug = substr($0, i + 1) }
35
+ }
36
+ } }
37
+ END { if (best >= 0) print slug }' "$reg" 2>/dev/null)
32
38
  [ -z "$slug" ] && return 1
33
39
  [ -d "$HOME/fde-engagements/$slug/.fde" ] && printf '%s\n' "$HOME/fde-engagements/$slug/.fde" && return 0
34
40
  return 1
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.7.2",
3
+ "version": "3.7.4",
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/",