fdeops 3.9.6 → 3.9.8

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/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 = ['scan', 'resume', 'log', 'debrief', 'receipts', 'capture', 'status', 'dashboard']
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 }