fdeops 3.23.0 → 3.26.0
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 +60 -153
- package/adapters/LOCAL-LLM.md +14 -102
- package/bin/check.js +3 -3
- package/bin/fde.js +334 -292
- package/bin/install.js +73 -50
- package/bin/lib/context.js +85 -0
- package/bin/lib/fieldbook-client.js +197 -0
- package/bin/lib/font-css.js +115 -0
- package/bin/lib/install-paths.js +53 -0
- package/bin/lib/render.js +165 -207
- package/bin/lib/trust.js +27 -4
- package/bin/lib/value-ledger.js +34 -0
- package/hooks/session-start +18 -84
- package/mcp/fdeops-ingest/package.json +1 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/skills/fde/SKILL.md +10 -6
- package/skills/fde/references/dashboard.md +5 -5
- package/skills/fde/references/debrief.md +10 -4
- package/skills/fde/references/discover.md +5 -1
- package/skills/fde/references/earn-trust.md +1 -1
- package/skills/fde/references/ingest.md +1 -1
- package/skills/fde/references/land.md +6 -6
- package/skills/fde/references/pick-three.md +1 -1
- package/skills/fde/references/plan.md +8 -0
- package/skills/fde/references/poc.md +1 -1
- package/skills/fde/references/readout.md +4 -2
- package/skills/fde/references/review.md +1 -1
- package/skills/fde/references/score-use-cases.md +1 -1
- package/skills/fde/references/ship.md +4 -2
- package/skills/fde/references/switch-clients.md +2 -2
package/bin/install.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const path = require('path')
|
|
5
5
|
const os = require('os')
|
|
6
|
+
const { checkPath, checkTree, copyFile, writeFile, mkdir } = require('./lib/install-paths')
|
|
6
7
|
|
|
7
8
|
const SKILLS_SRC = path.join(__dirname, '..', 'skills')
|
|
8
9
|
const HOOKS_SRC = path.join(__dirname, '..', 'hooks')
|
|
@@ -17,28 +18,30 @@ const HOOK_SCRIPTS = ['session-start', 'session-stop', 'pre-compact']
|
|
|
17
18
|
const ENGAGEMENTS_ROOT = path.join(os.homedir(), 'fde-engagements')
|
|
18
19
|
|
|
19
20
|
function copyDir(src, dest) {
|
|
20
|
-
|
|
21
|
+
checkTree(src, dest)
|
|
22
|
+
mkdir(dest)
|
|
21
23
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
22
24
|
const srcPath = path.join(src, entry.name)
|
|
23
25
|
const destPath = path.join(dest, entry.name)
|
|
24
26
|
if (entry.isDirectory()) {
|
|
25
27
|
copyDir(srcPath, destPath)
|
|
26
28
|
} else {
|
|
27
|
-
|
|
29
|
+
copyFile(srcPath, destPath)
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
function copyTemplateTree(src, dest, onlyMissing) {
|
|
35
|
+
checkTree(src, dest)
|
|
33
36
|
let merged = 0
|
|
34
|
-
|
|
37
|
+
mkdir(dest)
|
|
35
38
|
for (const name of fs.readdirSync(src)) {
|
|
36
39
|
const srcPath = path.join(src, name)
|
|
37
40
|
const destPath = path.join(dest, name)
|
|
38
41
|
if (fs.statSync(srcPath).isDirectory()) {
|
|
39
42
|
merged += copyTemplateTree(srcPath, destPath, onlyMissing)
|
|
40
43
|
} else if (!onlyMissing || !fs.existsSync(destPath)) {
|
|
41
|
-
|
|
44
|
+
copyFile(srcPath, destPath)
|
|
42
45
|
if (onlyMissing) merged++
|
|
43
46
|
}
|
|
44
47
|
}
|
|
@@ -62,17 +65,17 @@ const MANAGED_MARKER = '.fdeops-managed'
|
|
|
62
65
|
function markManaged(dir) {
|
|
63
66
|
let version = 'unknown'
|
|
64
67
|
try { version = require(path.join(__dirname, '..', 'package.json')).version } catch (_) {}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
71
|
-
} catch (_) {}
|
|
68
|
+
writeFile(
|
|
69
|
+
path.join(dir, MANAGED_MARKER),
|
|
70
|
+
`managed-by: fdeops\nversion: ${version}\ninstalled: ${new Date().toISOString()}\n` +
|
|
71
|
+
'Delete this file to make fdeops treat the directory as yours and leave it alone.\n',
|
|
72
|
+
)
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
function isManaged(dir) {
|
|
75
|
-
|
|
76
|
+
const marker = path.join(dir, MANAGED_MARKER)
|
|
77
|
+
checkPath(marker)
|
|
78
|
+
return fs.existsSync(marker) && /^managed-by: fdeops$/m.test(fs.readFileSync(marker, 'utf8'))
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
// A skill "directory" that is really a symlink points somewhere outside
|
|
@@ -89,6 +92,7 @@ function isLink(p) {
|
|
|
89
92
|
// directory whose name matches one we ship, and only to overwrite - never to delete.
|
|
90
93
|
function wasInstalledByUs(dir) {
|
|
91
94
|
try {
|
|
95
|
+
checkPath(path.join(dir, 'SKILL.md'))
|
|
92
96
|
const md = fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf8')
|
|
93
97
|
const fm = (md.match(/^---\n([\s\S]*?)\n---/) || [])[1]
|
|
94
98
|
if (!fm) return false
|
|
@@ -106,6 +110,7 @@ const LEGACY_SKILL_DIRS = [
|
|
|
106
110
|
]
|
|
107
111
|
|
|
108
112
|
function removeLegacySkills(opts = {}) {
|
|
113
|
+
checkPath(GLOBAL_SKILLS_DIR)
|
|
109
114
|
let removed = 0
|
|
110
115
|
const skipped = []
|
|
111
116
|
const links = []
|
|
@@ -128,24 +133,26 @@ function installSkillDirs(opts = {}) {
|
|
|
128
133
|
const skipped = []
|
|
129
134
|
const links = []
|
|
130
135
|
const failed = []
|
|
131
|
-
|
|
136
|
+
mkdir(GLOBAL_SKILLS_DIR)
|
|
132
137
|
for (const entry of fs.readdirSync(SKILLS_SRC, { withFileTypes: true })) {
|
|
133
138
|
const src = path.join(SKILLS_SRC, entry.name)
|
|
134
139
|
const dest = path.join(GLOBAL_SKILLS_DIR, entry.name)
|
|
135
|
-
if (!entry.isDirectory()) {
|
|
140
|
+
if (!entry.isDirectory()) { copyFile(src, dest); continue }
|
|
136
141
|
if (isLink(dest)) { links.push(entry.name); continue }
|
|
137
|
-
if (fs.existsSync(dest) && !isManaged(dest) && !opts.force) {
|
|
138
|
-
// Installs predating the marker are still ours: adopt a same-named dir
|
|
139
|
-
// whose SKILL.md is recognizably fdeops', so upgrades keep working.
|
|
140
|
-
if (!wasInstalledByUs(dest)) {
|
|
141
|
-
skipped.push(entry.name)
|
|
142
|
-
continue
|
|
143
|
-
}
|
|
144
|
-
console.log(` adopt ~/.claude/skills/${entry.name} (earlier fdeops install)`)
|
|
145
|
-
}
|
|
146
|
-
// One unwritable skill dir must not abort the install with a stack trace:
|
|
147
|
-
// say it in human terms, place the rest, and exit non-zero at the end.
|
|
148
142
|
try {
|
|
143
|
+
checkTree(src, dest)
|
|
144
|
+
checkPath(path.join(dest, MANAGED_MARKER))
|
|
145
|
+
if (fs.existsSync(dest) && !isManaged(dest) && !opts.force) {
|
|
146
|
+
// Installs predating the marker are still ours: adopt a same-named dir
|
|
147
|
+
// whose SKILL.md is recognizably fdeops', so upgrades keep working.
|
|
148
|
+
if (!wasInstalledByUs(dest)) {
|
|
149
|
+
skipped.push(entry.name)
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
console.log(` adopt ~/.claude/skills/${entry.name} (earlier fdeops install)`)
|
|
153
|
+
}
|
|
154
|
+
// One unwritable skill dir must not abort the install with a stack trace:
|
|
155
|
+
// say it in human terms, place the rest, and exit non-zero at the end.
|
|
149
156
|
copyDir(src, dest)
|
|
150
157
|
markManaged(dest)
|
|
151
158
|
} catch (e) {
|
|
@@ -174,6 +181,7 @@ function reportCollisions(paths, verb) {
|
|
|
174
181
|
|
|
175
182
|
function reportLinks(names) {
|
|
176
183
|
if (!names.length) return
|
|
184
|
+
installIncomplete = true
|
|
177
185
|
console.log(` skip ${names.length} skill path(s) that are symlinks - fdeops will not write through them:`)
|
|
178
186
|
for (const name of names) console.log(` ~/.claude/skills/${name} -> ${readLinkQuiet(path.join(GLOBAL_SKILLS_DIR, name))}`)
|
|
179
187
|
console.log(' remove the link if you want fdeops to install at that path itself')
|
|
@@ -190,7 +198,7 @@ function reportFailures(failures) {
|
|
|
190
198
|
installIncomplete = true
|
|
191
199
|
console.log(` error ${failures.length} skill dir(s) could not be written:`)
|
|
192
200
|
for (const f of failures) {
|
|
193
|
-
const why = f.code === 'EACCES' || f.code === 'EPERM' ? 'permission denied' : f.code
|
|
201
|
+
const why = f.code === 'EACCES' || f.code === 'EPERM' ? 'permission denied' : f.code === 'UNSAFE_PATH' ? 'unsafe symlink or non-regular path' : f.code
|
|
194
202
|
console.log(` ~/.claude/skills/${f.name} - ${why} at ${f.path}`)
|
|
195
203
|
}
|
|
196
204
|
console.log(' fix the permissions (or remove the directory) and re-run - the rest of the install continued')
|
|
@@ -204,26 +212,26 @@ function installSkills(opts = {}) {
|
|
|
204
212
|
reportCollisions(placed.skipped, 'overwriting them')
|
|
205
213
|
reportLinks([...new Set([...legacy.links, ...placed.links])])
|
|
206
214
|
reportFailures(placed.failed)
|
|
207
|
-
|
|
215
|
+
mkdir(GLOBAL_HOOKS_DIR)
|
|
208
216
|
for (const name of HOOK_SCRIPTS) {
|
|
209
217
|
const src = path.join(HOOKS_SRC, name)
|
|
210
218
|
if (!fs.existsSync(src)) continue
|
|
211
219
|
const dest = path.join(GLOBAL_HOOKS_DIR, `fdeops-${name}`)
|
|
212
|
-
|
|
220
|
+
copyFile(src, dest)
|
|
213
221
|
try {
|
|
214
222
|
fs.chmodSync(dest, '755')
|
|
215
223
|
} catch (_) {}
|
|
216
224
|
}
|
|
217
225
|
const globalPointer = path.join(os.homedir(), '.claude', 'FDEOPS-CLAUDE.md')
|
|
218
226
|
if (!fs.existsSync(globalPointer)) {
|
|
219
|
-
|
|
227
|
+
copyFile(CLAUDE_MD_SRC, globalPointer)
|
|
220
228
|
}
|
|
221
|
-
|
|
229
|
+
copyFile(CLAUDE_MD_SRC, path.join(os.homedir(), '.claude', 'FDEOPS-CLAUDE.md.template'))
|
|
222
230
|
|
|
223
231
|
// the fde CLI + templates, so the skill can call it from any workspace
|
|
224
232
|
const cliHome = path.join(os.homedir(), '.claude', 'fdeops')
|
|
225
|
-
|
|
226
|
-
|
|
233
|
+
mkdir(cliHome)
|
|
234
|
+
copyFile(path.join(__dirname, 'fde.js'), path.join(cliHome, 'fde.js'))
|
|
227
235
|
copyDir(LIB_SRC, path.join(cliHome, 'lib'))
|
|
228
236
|
try { fs.chmodSync(path.join(cliHome, 'fde.js'), '755') } catch (_) {}
|
|
229
237
|
copyDir(FDE_TEMPLATES_SRC, path.join(cliHome, 'templates', '.fde'))
|
|
@@ -245,7 +253,8 @@ const ADAPTER_TARGETS = [
|
|
|
245
253
|
const FDE_MARKER = '<!-- fdeops adapter - points your AI tool at @fde; safe to keep -->'
|
|
246
254
|
|
|
247
255
|
function placePointer(destPath, content, label, appendable) {
|
|
248
|
-
|
|
256
|
+
checkPath(destPath)
|
|
257
|
+
mkdir(path.dirname(destPath))
|
|
249
258
|
if (fs.existsSync(destPath)) {
|
|
250
259
|
const existing = fs.readFileSync(destPath, 'utf8')
|
|
251
260
|
if (/FDEOS|fdeops/i.test(existing)) {
|
|
@@ -256,16 +265,17 @@ function placePointer(destPath, content, label, appendable) {
|
|
|
256
265
|
console.log(` skip ${label} (exists - left untouched)`)
|
|
257
266
|
return
|
|
258
267
|
}
|
|
259
|
-
|
|
268
|
+
writeFile(destPath, `${existing.trimEnd()}\n\n${FDE_MARKER}\n\n${content}`)
|
|
260
269
|
console.log(` append ${label}`)
|
|
261
270
|
return
|
|
262
271
|
}
|
|
263
|
-
|
|
272
|
+
writeFile(destPath, content)
|
|
264
273
|
console.log(` write ${label}`)
|
|
265
274
|
}
|
|
266
275
|
|
|
267
276
|
function cmdAdapters(targetDir, opts = {}) {
|
|
268
277
|
const dest = path.resolve(targetDir || process.cwd())
|
|
278
|
+
checkPath(dest)
|
|
269
279
|
console.log('')
|
|
270
280
|
console.log(` fdeops cross-platform adapters → ${dest}`)
|
|
271
281
|
console.log(' One brain (skills/fde/SKILL.md). These are thin pointers per tool.')
|
|
@@ -275,6 +285,7 @@ function cmdAdapters(targetDir, opts = {}) {
|
|
|
275
285
|
// that file - `adapters` alone wrote pointers to a brain that didn't exist
|
|
276
286
|
// yet, a dangling reference for anyone following the documented Cursor/Codex
|
|
277
287
|
// path. installSkills() is idempotent (safe to call every run).
|
|
288
|
+
checkPath(path.join(GLOBAL_SKILLS_DIR, 'fde', 'SKILL.md'))
|
|
278
289
|
if (!fs.existsSync(path.join(GLOBAL_SKILLS_DIR, 'fde', 'SKILL.md'))) {
|
|
279
290
|
installSkills(opts)
|
|
280
291
|
console.log(' Skills → ~/.claude/skills/ (installed - the pointers below need this)')
|
|
@@ -282,11 +293,16 @@ function cmdAdapters(targetDir, opts = {}) {
|
|
|
282
293
|
}
|
|
283
294
|
for (const a of ADAPTER_TARGETS) {
|
|
284
295
|
if (!fs.existsSync(a.src)) { console.log(` skip ${a.label} (template missing)`); continue }
|
|
285
|
-
|
|
296
|
+
try {
|
|
297
|
+
placePointer(path.join(dest, a.dest), fs.readFileSync(a.src, 'utf8'), a.label, a.appendable)
|
|
298
|
+
} catch (e) {
|
|
299
|
+
installIncomplete = true
|
|
300
|
+
console.error(` error ${a.label}: ${e.message}`)
|
|
301
|
+
}
|
|
286
302
|
}
|
|
287
303
|
console.log('')
|
|
288
304
|
console.log(' Open this workspace in Claude Code, Cursor, Codex, Gemini CLI, or Copilot')
|
|
289
|
-
console.log(' and type @fde - each tool now routes to the same engagement brain.')
|
|
305
|
+
console.log(installIncomplete ? ' Some adapters could not be installed; fix the errors above and run again.' : ' and type @fde - each tool now routes to the same engagement brain.')
|
|
290
306
|
console.log('')
|
|
291
307
|
}
|
|
292
308
|
|
|
@@ -304,7 +320,7 @@ function cmdInit(engagementName) {
|
|
|
304
320
|
|
|
305
321
|
const pointer = path.join(root, 'ENGAGEMENT.md')
|
|
306
322
|
if (!fs.existsSync(pointer)) {
|
|
307
|
-
|
|
323
|
+
writeFile(
|
|
308
324
|
pointer,
|
|
309
325
|
`# ${engagementName}\n\nEngagement root: \`${fdeDir}\`\n\nPoint your **AI coding agent** at this folder (not a human colleague). Add to ~/.claude/FDEOPS-CLAUDE.md:\n\n\`\`\`\nFDEOPS_ENGAGEMENT=${fdeDir}\n\`\`\`\n\nOpen your workspace. In the AI chat, type \`@fde\`.\n`,
|
|
310
326
|
)
|
|
@@ -435,16 +451,23 @@ function handOffToCli(verb) {
|
|
|
435
451
|
require(path.join(__dirname, 'fde.js'))
|
|
436
452
|
}
|
|
437
453
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
454
|
+
try {
|
|
455
|
+
if (!raw && askedVersion) {
|
|
456
|
+
console.log(require(path.join(__dirname, '..', 'package.json')).version)
|
|
457
|
+
} else if (!raw && askedHelp) {
|
|
458
|
+
handOffToCli('help')
|
|
459
|
+
} else if (arg === 'init') {
|
|
460
|
+
cmdInit(positional[1])
|
|
461
|
+
} else if (arg === 'adapters') {
|
|
462
|
+
cmdAdapters(positional[1], { force })
|
|
463
|
+
} else if (arg && arg !== 'install') {
|
|
464
|
+
handOffToCli(arg)
|
|
465
|
+
} else {
|
|
466
|
+
cmdInstall({ force })
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
} catch (e) {
|
|
470
|
+
console.error(` fdeops: ${e.message}`)
|
|
471
|
+
process.exitCode = 1
|
|
450
472
|
}
|
|
473
|
+
if (installIncomplete) process.exitCode = 1
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BYTES = 16384
|
|
4
|
+
const MIN_BYTES = 4096
|
|
5
|
+
const MAX_BYTES = 65536
|
|
6
|
+
const OMITTED = '\n[Excerpt truncated; retrieve the source before relying on omitted details.]'
|
|
7
|
+
|
|
8
|
+
// A byte ceiling is deterministic across hosts. It is deliberately not called
|
|
9
|
+
// a token count: tokenization varies by model and language.
|
|
10
|
+
function clipUtf8(text, bytes) {
|
|
11
|
+
if (Buffer.byteLength(text) <= bytes) return text
|
|
12
|
+
const buf = Buffer.from(text)
|
|
13
|
+
let end = Math.max(0, bytes)
|
|
14
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80) end--
|
|
15
|
+
return buf.subarray(0, end).toString('utf8')
|
|
16
|
+
}
|
|
17
|
+
function budgetArgs(args) {
|
|
18
|
+
const rest = [...args]
|
|
19
|
+
let maxBytes = DEFAULT_BYTES
|
|
20
|
+
const at = rest.indexOf('--max-bytes')
|
|
21
|
+
if (at !== -1) {
|
|
22
|
+
const raw = rest[at + 1]
|
|
23
|
+
maxBytes = Number(raw)
|
|
24
|
+
if (!/^\d+$/.test(raw || '') || !Number.isSafeInteger(maxBytes) || maxBytes < MIN_BYTES || maxBytes > MAX_BYTES) {
|
|
25
|
+
throw new Error(`--max-bytes must be an integer from ${MIN_BYTES} to ${MAX_BYTES}`)
|
|
26
|
+
}
|
|
27
|
+
rest.splice(at, 2)
|
|
28
|
+
if (rest.includes('--max-bytes')) throw new Error('supply --max-bytes only once')
|
|
29
|
+
}
|
|
30
|
+
return { args: rest, maxBytes }
|
|
31
|
+
}
|
|
32
|
+
function boundedSections(sections, maxBytes = DEFAULT_BYTES) {
|
|
33
|
+
const footer = '\n\nCONTEXT: selected excerpts, not the complete record or proof of approval. Use fde recall <topic> for relevant evidence. If policy or constraints are truncated, retrieve them before acting. Verify conflicting decisions.\n'
|
|
34
|
+
let out = ''
|
|
35
|
+
const filled = sections.filter(s => s && s.trim())
|
|
36
|
+
for (let i = 0; i < filled.length; i++) {
|
|
37
|
+
const available = maxBytes - Buffer.byteLength(out + footer) - 2
|
|
38
|
+
const share = Math.max(0, Math.floor(available / (filled.length - i)))
|
|
39
|
+
const text = filled[i]
|
|
40
|
+
const next = Buffer.byteLength(text) <= share ? text : clipUtf8(text, Math.max(0, share - Buffer.byteLength(OMITTED))) + OMITTED
|
|
41
|
+
out += (out ? '\n\n' : '') + next
|
|
42
|
+
}
|
|
43
|
+
return out + footer
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Literal, client-scoped lexical retrieval. Retain independently matching
|
|
47
|
+
// records, including conflicting/older ones; recency never means truth.
|
|
48
|
+
function recallSections(documents, query, maxHits = 12) {
|
|
49
|
+
const words = [...new Set(query.toLocaleLowerCase().split(/\s+/).filter(Boolean))].slice(0, 16)
|
|
50
|
+
const hits = []
|
|
51
|
+
for (const { file, text } of documents) {
|
|
52
|
+
const lines = text.split('\n')
|
|
53
|
+
for (let i = 0; i < lines.length; i++) {
|
|
54
|
+
const line = lines[i]
|
|
55
|
+
const lower = line.toLocaleLowerCase()
|
|
56
|
+
const score = words.reduce((n, word) => n + Number(lower.includes(word)), 0)
|
|
57
|
+
if (!score) continue
|
|
58
|
+
const first = Math.max(0, i - 1)
|
|
59
|
+
const last = Math.min(lines.length, i + 3)
|
|
60
|
+
const excerpt = lines.slice(first, last).map(l => Buffer.byteLength(l) <= 1024 ? l : clipUtf8(l, 900) + OMITTED).join('\n')
|
|
61
|
+
hits.push({ file, line: first + 1, end: last, score, text: excerpt.trim() })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
hits.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file) || b.line - a.line)
|
|
65
|
+
// Round-robin files, taking recent and older matches so a long topic does
|
|
66
|
+
// not hide its latest change or original constraint. Omission stays explicit.
|
|
67
|
+
const groups = new Map()
|
|
68
|
+
for (const hit of hits) {
|
|
69
|
+
if (!groups.has(hit.file)) groups.set(hit.file, [])
|
|
70
|
+
groups.get(hit.file).push(hit)
|
|
71
|
+
}
|
|
72
|
+
const selected = []
|
|
73
|
+
let oldest = false
|
|
74
|
+
while (selected.length < maxHits && [...groups.values()].some(g => g.length)) {
|
|
75
|
+
for (const group of groups.values()) {
|
|
76
|
+
if (group.length && selected.length < maxHits) selected.push(oldest ? group.pop() : group.shift())
|
|
77
|
+
}
|
|
78
|
+
oldest = !oldest
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
total: hits.length,
|
|
82
|
+
sections: selected.map(h => `${h.file}:${h.line}-${h.end} (lines in redacted view)\n${h.text}`),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
module.exports = { DEFAULT_BYTES, clipUtf8, budgetArgs, boundedSections, recallSections }
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Embedded verbatim in the offline fieldbook. No imports, network, or record writes.
|
|
4
|
+
module.exports = function fieldbookClient() {
|
|
5
|
+
const all = selector => Array.from(document.querySelectorAll(selector))
|
|
6
|
+
const views = all('.fb-view')
|
|
7
|
+
const nav = all('.fb-nav')
|
|
8
|
+
const search = document.getElementById('fb-search')
|
|
9
|
+
const filter = document.getElementById('fb-filter')
|
|
10
|
+
const rail = document.getElementById('fb-client-rail')
|
|
11
|
+
const clientsButton = document.getElementById('fb-clients-btn')
|
|
12
|
+
const mobile = window.matchMedia('(max-width:640px)')
|
|
13
|
+
const main = document.querySelector('.fb-main')
|
|
14
|
+
const status = document.getElementById('fb-status')
|
|
15
|
+
const palette = document.getElementById('fb-palette-dialog')
|
|
16
|
+
const paletteInput = document.getElementById('fb-palette-input')
|
|
17
|
+
const items = all('.fb-palette-item')
|
|
18
|
+
const promptDialog = document.getElementById('fb-prompt-dialog')
|
|
19
|
+
let returnFocus = null
|
|
20
|
+
let active = 0
|
|
21
|
+
const currentId = () => document.querySelector('.fb-view:not([hidden])').id.replace('view-', '')
|
|
22
|
+
function showClients(show) {
|
|
23
|
+
rail.classList.toggle('is-collapsed', !show)
|
|
24
|
+
clientsButton.setAttribute('aria-expanded', String(show))
|
|
25
|
+
}
|
|
26
|
+
showClients(!mobile.matches)
|
|
27
|
+
mobile.addEventListener('change', event => showClients(!event.matches))
|
|
28
|
+
clientsButton.addEventListener('click', () => showClients(clientsButton.getAttribute('aria-expanded') !== 'true'))
|
|
29
|
+
function selectView(id, updateHash = true, focus = false) {
|
|
30
|
+
if (id === 'fb-main') { id = 'today'; updateHash = false }
|
|
31
|
+
if (!document.getElementById('view-' + id)) id = 'today'
|
|
32
|
+
views.forEach(v => { v.hidden = v.id !== 'view-' + id })
|
|
33
|
+
nav.forEach(n => {
|
|
34
|
+
const selected = n.dataset.target === id
|
|
35
|
+
n.classList.toggle('active', selected)
|
|
36
|
+
if (selected) n.setAttribute('aria-current', 'page')
|
|
37
|
+
else n.removeAttribute('aria-current')
|
|
38
|
+
})
|
|
39
|
+
if (updateHash && location.hash !== '#' + id) location.hash = id
|
|
40
|
+
main.scrollTop = 0
|
|
41
|
+
if (focus) {
|
|
42
|
+
document.querySelector('.fb-view:not([hidden]) h1').focus()
|
|
43
|
+
if (mobile.matches) showClients(false)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const hashId = () => location.hash.slice(1)
|
|
47
|
+
selectView(hashId() || 'today', false)
|
|
48
|
+
window.addEventListener('hashchange', () => selectView(hashId(), false))
|
|
49
|
+
const skip = document.querySelector('.fb-skip')
|
|
50
|
+
if (skip) skip.addEventListener('click', event => {
|
|
51
|
+
event.preventDefault()
|
|
52
|
+
main.focus()
|
|
53
|
+
})
|
|
54
|
+
nav.forEach(n => n.addEventListener('click', () => selectView(n.dataset.target, true, true)))
|
|
55
|
+
all('[data-nav]').forEach(n => n.addEventListener('click', () => selectView(n.dataset.nav, true, true)))
|
|
56
|
+
|
|
57
|
+
// Only escaped fragments become markup; engagement strings never become code.
|
|
58
|
+
const esc = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
59
|
+
function snippet(raw, term) {
|
|
60
|
+
const i = raw.indexOf(term)
|
|
61
|
+
const start = Math.max(0, i - 24), end = Math.min(raw.length, i + term.length + 40)
|
|
62
|
+
return (start ? '…' : '') + esc(raw.slice(start, i)) + '<mark>' + esc(raw.slice(i, i + term.length)) + '</mark>' + esc(raw.slice(i + term.length, end)) + (end < raw.length ? '…' : '')
|
|
63
|
+
}
|
|
64
|
+
function filterClients() {
|
|
65
|
+
const term = search.value.trim().toLowerCase()
|
|
66
|
+
const matches = new Set()
|
|
67
|
+
nav.filter(n => n.dataset.target !== 'today').forEach(n => {
|
|
68
|
+
const raw = (n.dataset.search || '').toLowerCase()
|
|
69
|
+
const inGroup = filter.value === 'all' || (n.dataset.attention === 'true') === (filter.value === 'attention')
|
|
70
|
+
const hit = inGroup && (!term || raw.includes(term))
|
|
71
|
+
n.classList.toggle('hide', !hit)
|
|
72
|
+
if (hit) matches.add(n.dataset.target)
|
|
73
|
+
const reason = n.querySelector('.fb-nav-reason')
|
|
74
|
+
if (term && hit) reason.innerHTML = snippet(raw, term)
|
|
75
|
+
else reason.textContent = n.dataset.defaultReason || ''
|
|
76
|
+
reason.classList.toggle('hide', !reason.textContent)
|
|
77
|
+
})
|
|
78
|
+
const count = matches.size
|
|
79
|
+
all('[data-client]').forEach(row => row.classList.toggle('hide', !matches.has(row.dataset.client)))
|
|
80
|
+
const filtering = Boolean(term) || filter.value !== 'all'
|
|
81
|
+
document.getElementById('fb-search-empty').hidden = !filtering || count > 0
|
|
82
|
+
const empty = document.getElementById('fb-results-empty')
|
|
83
|
+
if (empty) empty.hidden = !filtering || count > 0
|
|
84
|
+
all('[data-filter-section]').forEach(section => { section.hidden = !Array.from(section.querySelectorAll('[data-client]')).some(row => !row.classList.contains('hide')) })
|
|
85
|
+
const total = nav.length - 1
|
|
86
|
+
document.getElementById('fb-search-count').textContent = count + ' of ' + total + ' client' + (total === 1 ? '' : 's') + (filtering ? ' match' : '')
|
|
87
|
+
}
|
|
88
|
+
search.addEventListener('input', filterClients)
|
|
89
|
+
filter.addEventListener('change', filterClients)
|
|
90
|
+
all('[data-reset-filters]').forEach(button => button.addEventListener('click', () => { search.value = ''; filter.value = 'all'; filterClients() }))
|
|
91
|
+
filterClients()
|
|
92
|
+
|
|
93
|
+
function toggleTheme() {
|
|
94
|
+
const next = document.documentElement.dataset.fdeTheme === 'dark' ? 'light' : 'dark'
|
|
95
|
+
document.documentElement.dataset.fdeTheme = next
|
|
96
|
+
try { localStorage.setItem('fde-fieldbook-theme', next) } catch (_) {}
|
|
97
|
+
themeLabel()
|
|
98
|
+
}
|
|
99
|
+
function themeLabel() {
|
|
100
|
+
const dark = document.documentElement.dataset.fdeTheme === 'dark'
|
|
101
|
+
document.getElementById('fde-theme-btn').textContent = dark ? 'Light theme' : 'Dark theme'
|
|
102
|
+
}
|
|
103
|
+
themeLabel()
|
|
104
|
+
document.getElementById('fde-theme-btn').addEventListener('click', toggleTheme)
|
|
105
|
+
document.getElementById('fb-print-btn').addEventListener('click', () => window.print())
|
|
106
|
+
|
|
107
|
+
function openDialog(dialog) {
|
|
108
|
+
returnFocus = document.activeElement
|
|
109
|
+
dialog.showModal()
|
|
110
|
+
}
|
|
111
|
+
;[palette, promptDialog].forEach(dialog => {
|
|
112
|
+
dialog.addEventListener('close', () => { if (returnFocus) returnFocus.focus() })
|
|
113
|
+
dialog.addEventListener('click', event => { if (event.target === dialog) dialog.close() })
|
|
114
|
+
// Native modal dialogs isolate background controls; contain Tab explicitly
|
|
115
|
+
// so keyboard focus never escapes into browser chrome between controls.
|
|
116
|
+
dialog.addEventListener('keydown', event => {
|
|
117
|
+
if (event.key !== 'Tab') return
|
|
118
|
+
const controls = Array.from(dialog.querySelectorAll('input, textarea, button')).filter(el => !el.hidden && !el.classList.contains('hide'))
|
|
119
|
+
const first = controls[0], last = controls[controls.length - 1]
|
|
120
|
+
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
|
121
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
all('[data-close-dialog]').forEach(button => button.addEventListener('click', () => button.closest('dialog').close()))
|
|
125
|
+
const visibleItems = () => items.filter(it => !it.classList.contains('hide'))
|
|
126
|
+
function highlight() {
|
|
127
|
+
const visible = visibleItems()
|
|
128
|
+
items.forEach(it => it.classList.remove('active'))
|
|
129
|
+
if (visible[active]) visible[active].classList.add('active')
|
|
130
|
+
}
|
|
131
|
+
function filterPalette() {
|
|
132
|
+
const term = paletteInput.value.trim().toLowerCase()
|
|
133
|
+
items.forEach(it => it.classList.toggle('hide', !it.textContent.toLowerCase().includes(term)))
|
|
134
|
+
active = 0
|
|
135
|
+
highlight()
|
|
136
|
+
document.getElementById('fb-palette-empty').hidden = visibleItems().length > 0
|
|
137
|
+
}
|
|
138
|
+
function openPalette() { openDialog(palette); paletteInput.value = ''; filterPalette(); paletteInput.focus() }
|
|
139
|
+
function runItem(it) {
|
|
140
|
+
if (!it) return
|
|
141
|
+
palette.close()
|
|
142
|
+
if (it.dataset.target === '__theme__') toggleTheme()
|
|
143
|
+
else if (it.dataset.target === '__print__') window.print()
|
|
144
|
+
else selectView(it.dataset.target, true, true)
|
|
145
|
+
}
|
|
146
|
+
document.getElementById('fb-palette-btn').addEventListener('click', openPalette)
|
|
147
|
+
paletteInput.addEventListener('input', filterPalette)
|
|
148
|
+
items.forEach(it => it.addEventListener('click', () => runItem(it)))
|
|
149
|
+
paletteInput.addEventListener('keydown', event => {
|
|
150
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
151
|
+
event.preventDefault()
|
|
152
|
+
active = Math.max(0, Math.min(visibleItems().length - 1, active + (event.key === 'ArrowDown' ? 1 : -1)))
|
|
153
|
+
highlight()
|
|
154
|
+
if (visibleItems()[active]) visibleItems()[active].scrollIntoView({ block: 'nearest' })
|
|
155
|
+
}
|
|
156
|
+
if (event.key === 'Enter') { event.preventDefault(); runItem(visibleItems()[active]) }
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
all('[data-prompt]').forEach(button => button.addEventListener('click', async () => {
|
|
160
|
+
const text = button.dataset.prompt
|
|
161
|
+
try {
|
|
162
|
+
await navigator.clipboard.writeText(text)
|
|
163
|
+
status.textContent = 'Prompt copied. Paste it into your coding agent; review changes before saving.'
|
|
164
|
+
const label = button.textContent
|
|
165
|
+
button.textContent = 'Copied'
|
|
166
|
+
button.disabled = true
|
|
167
|
+
setTimeout(() => { button.textContent = label; button.disabled = false }, 1800)
|
|
168
|
+
} catch (_) {
|
|
169
|
+
const input = document.getElementById('fb-prompt-text')
|
|
170
|
+
input.value = text
|
|
171
|
+
openDialog(promptDialog)
|
|
172
|
+
input.focus(); input.select()
|
|
173
|
+
}
|
|
174
|
+
}))
|
|
175
|
+
document.addEventListener('keydown', event => {
|
|
176
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
|
177
|
+
event.preventDefault()
|
|
178
|
+
if (!promptDialog.open) { if (palette.open) palette.close(); else openPalette() }
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if (palette.open || promptDialog.open || event.metaKey || event.ctrlKey || event.altKey) return
|
|
182
|
+
const typing = event.target.matches('input, textarea, select') || event.target.isContentEditable
|
|
183
|
+
if (typing) {
|
|
184
|
+
if (event.key === 'Escape') { search.value = ''; search.dispatchEvent(new Event('input')); event.target.blur() }
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
if (event.target.closest('.fb-table-scroll')) return
|
|
188
|
+
if (event.key === '/') { event.preventDefault(); showClients(true); search.focus(); return }
|
|
189
|
+
if (event.key === 'j' || event.key === 'k' || (event.target.closest('.fb-rail') && ['ArrowDown', 'ArrowUp'].includes(event.key))) {
|
|
190
|
+
event.preventDefault()
|
|
191
|
+
const ids = nav.filter(n => !n.classList.contains('hide')).map(n => n.dataset.target)
|
|
192
|
+
const delta = event.key === 'j' || event.key === 'ArrowDown' ? 1 : -1
|
|
193
|
+
const next = ids[Math.max(0, Math.min(ids.length - 1, ids.indexOf(currentId()) + delta))]
|
|
194
|
+
if (next) selectView(next)
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
}
|