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 +66 -16
- package/hooks/pre-compact +33 -6
- package/hooks/session-start +15 -5
- package/hooks/session-stop +8 -2
- package/package.json +4 -3
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()
|
|
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))
|
|
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
|
-
|
|
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(/(['"])
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
|
730
|
-
.replace(/<private>[\s\S]*$/i, '(private - redacted
|
|
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
|
|
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 -
|
|
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=$(
|
|
71
|
-
[ -f "$RISKS_FILE" ] && OPEN_RISKS=$(grep -iE "open|active|unresolved"
|
|
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
|
|
package/hooks/session-start
CHANGED
|
@@ -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
|
|
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(
|
|
104
|
+
if (index(lc, "</private>") > 0) { inblock = 0 }
|
|
95
105
|
next
|
|
96
106
|
}
|
|
97
|
-
if (index(
|
|
107
|
+
if (index(lc, "<private>") > 0) {
|
|
98
108
|
print "(private - redacted)"
|
|
99
|
-
if (index(
|
|
109
|
+
if (index(lc, "</private>") == 0) { inblock = 1 }
|
|
100
110
|
next
|
|
101
111
|
}
|
|
102
112
|
print line
|
package/hooks/session-stop
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
"
|
|
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/",
|