fdeops 3.9.6 → 3.9.9
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/README.md +9 -50
- package/bin/check.js +15 -5
- package/bin/fde.js +239 -912
- package/bin/install.js +6 -1
- package/bin/lib/memory.js +164 -0
- package/bin/lib/render.js +666 -0
- package/bin/lib/trust.js +197 -0
- package/hooks/session-start +3 -2
- package/package.json +1 -1
- package/skills/fde/SKILL.md +11 -9
package/bin/install.js
CHANGED
|
@@ -9,6 +9,7 @@ const HOOKS_SRC = path.join(__dirname, '..', 'hooks')
|
|
|
9
9
|
const CLAUDE_MD_SRC = path.join(__dirname, '..', 'CLAUDE.md.template')
|
|
10
10
|
const FDE_TEMPLATES_SRC = path.join(__dirname, '..', 'templates', '.fde')
|
|
11
11
|
const ADAPTERS_SRC = path.join(__dirname, '..', 'adapters')
|
|
12
|
+
const LIB_SRC = path.join(__dirname, 'lib')
|
|
12
13
|
|
|
13
14
|
const GLOBAL_SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills')
|
|
14
15
|
const GLOBAL_HOOKS_DIR = path.join(os.homedir(), '.claude', 'hooks')
|
|
@@ -97,6 +98,7 @@ function installSkills() {
|
|
|
97
98
|
const cliHome = path.join(os.homedir(), '.claude', 'fdeops')
|
|
98
99
|
fs.mkdirSync(cliHome, { recursive: true })
|
|
99
100
|
fs.copyFileSync(path.join(__dirname, 'fde.js'), path.join(cliHome, 'fde.js'))
|
|
101
|
+
copyDir(LIB_SRC, path.join(cliHome, 'lib'))
|
|
100
102
|
try { fs.chmodSync(path.join(cliHome, 'fde.js'), '755') } catch (_) {}
|
|
101
103
|
copyDir(FDE_TEMPLATES_SRC, path.join(cliHome, 'templates', '.fde'))
|
|
102
104
|
|
|
@@ -222,7 +224,10 @@ function cmdInstall() {
|
|
|
222
224
|
|
|
223
225
|
// `npx fdeops scan` must recon, not install - any fde subcommand passes straight
|
|
224
226
|
// through to the CLI (fde.js reads process.argv itself, so require() is enough).
|
|
225
|
-
const FDE_SUBCOMMANDS = [
|
|
227
|
+
const FDE_SUBCOMMANDS = [
|
|
228
|
+
'scan', 'resume', 'triage', 'log', 'debrief', 'prep', 'doctor', 'redact',
|
|
229
|
+
'garden', 'owner', 'receipts', 'capture', 'status', 'dashboard', 'help',
|
|
230
|
+
]
|
|
226
231
|
|
|
227
232
|
const arg = process.argv[2]
|
|
228
233
|
if (arg === 'init') {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('child_process')
|
|
4
|
+
|
|
5
|
+
// Ephemeral sidecar files - never treated as "manual tamper" dirt.
|
|
6
|
+
const MEMORY_EPHEMERAL = new Set(['.last-write', '.debrief-propose'])
|
|
7
|
+
|
|
8
|
+
function createMemoryApi(deps) {
|
|
9
|
+
const { fs, path, gitBinOk, writeOwnerIfMissing, atomicWriteFile } = deps
|
|
10
|
+
let _gitWarned = false
|
|
11
|
+
|
|
12
|
+
function memoryPorcelainPaths(eng) {
|
|
13
|
+
if (!eng || !fs.existsSync(path.join(eng, '.git'))) return []
|
|
14
|
+
try {
|
|
15
|
+
return execFileSync('git', ['status', '--porcelain'], {
|
|
16
|
+
cwd: eng, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
17
|
+
}).toString().split('\n').filter(Boolean).map(line => {
|
|
18
|
+
// " M path", "?? path", rename "R old -> new"
|
|
19
|
+
let p = line.slice(3).trim()
|
|
20
|
+
if (p.includes(' -> ')) p = p.split(' -> ').pop().trim()
|
|
21
|
+
return p.replace(/^"/, '').replace(/"$/, '')
|
|
22
|
+
}).filter(p => p && !MEMORY_EPHEMERAL.has(path.basename(p)))
|
|
23
|
+
} catch (_) { return [] }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function memoryDirtyManual(eng) {
|
|
27
|
+
return memoryPorcelainPaths(eng)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Commit engagement memory. When `opts.files` is set, stage ONLY those paths
|
|
31
|
+
// so a hand-edit to an unrelated record is never laundered into this write's
|
|
32
|
+
// commit (tamper-evident ledger). Unrelated dirty paths are warned, not added.
|
|
33
|
+
// Omit `files` only for intentional full-tree commits (init).
|
|
34
|
+
function commitMemory(eng, message, opts = {}) {
|
|
35
|
+
if (!eng || !fs.existsSync(path.join(eng, '.git'))) {
|
|
36
|
+
if (!ensureMemoryGit(eng)) return null
|
|
37
|
+
}
|
|
38
|
+
if (!gitBinOk()) return null
|
|
39
|
+
const owner = writeOwnerIfMissing(eng)
|
|
40
|
+
const files = Array.isArray(opts.files) ? opts.files.filter(Boolean).map(f => String(f).replace(/^\.\//, '')) : null
|
|
41
|
+
try {
|
|
42
|
+
if (files && files.length) {
|
|
43
|
+
const foreign = memoryPorcelainPaths(eng).filter(p => !files.includes(p))
|
|
44
|
+
if (foreign.length) {
|
|
45
|
+
process.stderr.write(
|
|
46
|
+
`⚠ memory has uncommitted manual edits (not part of this write): ${foreign.slice(0, 6).join(', ')}${foreign.length > 6 ? '…' : ''}\n` +
|
|
47
|
+
' they will NOT be auto-committed - review/commit/discard before relying on the ledger\n'
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
for (const f of files) {
|
|
51
|
+
const abs = path.join(eng, f)
|
|
52
|
+
if (!fs.existsSync(abs) && !memoryPorcelainPaths(eng).includes(f)) continue
|
|
53
|
+
execFileSync('git', ['add', '--', f], { cwd: eng, stdio: 'ignore', timeout: 10000 })
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
execFileSync('git', ['add', '-A'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
|
|
57
|
+
}
|
|
58
|
+
const porcelain = execFileSync('git', ['status', '--porcelain'], {
|
|
59
|
+
cwd: eng, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
60
|
+
})
|
|
61
|
+
if (!String(porcelain || '').trim()) return null
|
|
62
|
+
// If we staged specific files, refuse to commit if the index somehow picked up others
|
|
63
|
+
// (defense in depth). Re-check staged vs intended.
|
|
64
|
+
if (files && files.length) {
|
|
65
|
+
const staged = execFileSync('git', ['diff', '--cached', '--name-only'], {
|
|
66
|
+
cwd: eng, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
67
|
+
}).toString().split('\n').map(s => s.trim()).filter(Boolean)
|
|
68
|
+
const sneak = staged.filter(p => !files.includes(p) && !MEMORY_EPHEMERAL.has(path.basename(p)))
|
|
69
|
+
if (sneak.length) {
|
|
70
|
+
execFileSync('git', ['reset', '-q', 'HEAD', '--'].concat(sneak), {
|
|
71
|
+
cwd: eng, stdio: 'ignore', timeout: 10000,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const still = execFileSync('git', ['diff', '--cached', '--name-only'], {
|
|
76
|
+
cwd: eng, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
77
|
+
}).toString().trim()
|
|
78
|
+
if (!still) return null
|
|
79
|
+
const env = {
|
|
80
|
+
...process.env,
|
|
81
|
+
GIT_AUTHOR_NAME: owner.name,
|
|
82
|
+
GIT_AUTHOR_EMAIL: owner.email,
|
|
83
|
+
GIT_COMMITTER_NAME: owner.name,
|
|
84
|
+
GIT_COMMITTER_EMAIL: owner.email,
|
|
85
|
+
}
|
|
86
|
+
// -c user.* beats user.useConfigOnly=true (common on clean laptops) so git
|
|
87
|
+
// never prints "Please tell me who you are" on first init / memory writes.
|
|
88
|
+
execFileSync('git', [
|
|
89
|
+
'-c', 'commit.gpgsign=false',
|
|
90
|
+
'-c', `user.name=${owner.name}`,
|
|
91
|
+
'-c', `user.email=${owner.email}`,
|
|
92
|
+
'commit', '-m', String(message || 'memory write').slice(0, 72),
|
|
93
|
+
], {
|
|
94
|
+
cwd: eng, stdio: 'ignore', timeout: 15000, env,
|
|
95
|
+
})
|
|
96
|
+
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
97
|
+
cwd: eng, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
98
|
+
}).toString().trim()
|
|
99
|
+
} catch (_) {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function configureMemoryGitIdentity(eng, owner) {
|
|
105
|
+
if (!owner || !owner.name || !owner.email) return
|
|
106
|
+
try {
|
|
107
|
+
execFileSync('git', ['config', 'user.name', owner.name], {
|
|
108
|
+
cwd: eng, stdio: 'ignore', timeout: 5000,
|
|
109
|
+
})
|
|
110
|
+
execFileSync('git', ['config', 'user.email', owner.email], {
|
|
111
|
+
cwd: eng, stdio: 'ignore', timeout: 5000,
|
|
112
|
+
})
|
|
113
|
+
} catch (_) {}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function ensureMemoryGit(eng) {
|
|
117
|
+
if (!eng || !fs.existsSync(eng)) return false
|
|
118
|
+
if (fs.existsSync(path.join(eng, '.git'))) {
|
|
119
|
+
configureMemoryGitIdentity(eng, writeOwnerIfMissing(eng))
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
if (!gitBinOk()) {
|
|
123
|
+
if (!_gitWarned) {
|
|
124
|
+
process.stderr.write('⚠ git not found - engagement memory will not be versioned (receipts stay dated, but not tamper-evident)\n')
|
|
125
|
+
_gitWarned = true
|
|
126
|
+
}
|
|
127
|
+
writeOwnerIfMissing(eng)
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
execFileSync('git', ['init'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
|
|
132
|
+
atomicWriteFile(
|
|
133
|
+
path.join(eng, '.gitignore'),
|
|
134
|
+
['*.lock', '*.tmp', '.last-write', '.debrief-propose', ''].join('\n')
|
|
135
|
+
)
|
|
136
|
+
const owner = writeOwnerIfMissing(eng)
|
|
137
|
+
configureMemoryGitIdentity(eng, owner)
|
|
138
|
+
commitMemory(eng, 'init engagement memory')
|
|
139
|
+
return true
|
|
140
|
+
} catch (_) {
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function memoryHead(eng) {
|
|
146
|
+
if (!eng || !fs.existsSync(path.join(eng, '.git'))) return ''
|
|
147
|
+
try {
|
|
148
|
+
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
149
|
+
cwd: eng, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
150
|
+
}).toString().trim()
|
|
151
|
+
} catch (_) { return '' }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
MEMORY_EPHEMERAL,
|
|
156
|
+
memoryPorcelainPaths,
|
|
157
|
+
memoryDirtyManual,
|
|
158
|
+
commitMemory,
|
|
159
|
+
memoryHead,
|
|
160
|
+
ensureMemoryGit,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = { createMemoryApi, MEMORY_EPHEMERAL }
|