fdeops 3.24.0 → 3.27.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 +78 -216
- package/adapters/LOCAL-LLM.md +14 -102
- package/bin/check.js +3 -3
- package/bin/fde.js +517 -375
- package/bin/install.js +74 -51
- package/bin/lib/context.js +85 -0
- package/bin/lib/delivery-gaps.js +34 -0
- package/bin/lib/fieldbook-client.js +41 -10
- package/bin/lib/font-css.js +115 -0
- package/bin/lib/install-paths.js +53 -0
- package/bin/lib/provenance.js +42 -0
- package/bin/lib/render.js +57 -74
- package/bin/lib/value-ledger.js +40 -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 +6 -5
- package/skills/fde/references/plan.md +2 -0
- package/skills/fde/references/poc.md +11 -3
- package/skills/fde/references/ship.md +3 -1
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
|
)
|
|
@@ -361,7 +377,7 @@ function cmdInstall(opts = {}) {
|
|
|
361
377
|
// through to the CLI (fde.js reads process.argv itself, so require() is enough).
|
|
362
378
|
const FDE_SUBCOMMANDS = [
|
|
363
379
|
'demo', 'scan', 'resume', 'triage', 'log', 'debrief', 'ingest', 'prep', 'doctor', 'redact',
|
|
364
|
-
'tidy', 'garden', 'owner', 'receipts', 'capture', 'preserve', 'status', 'dashboard', 'vault', 'help',
|
|
380
|
+
'tidy', 'garden', 'owner', 'receipts', 'recall', 'handoff', 'defend', 'capture', 'preserve', 'status', 'dashboard', 'vault', 'help',
|
|
365
381
|
]
|
|
366
382
|
|
|
367
383
|
const INSTALL_SUBCOMMANDS = ['init', 'adapters', 'install']
|
|
@@ -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,34 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Pure presentation policy shared by CLI triage and the offline fieldbook.
|
|
4
|
+
// Callers supply sanitized records and classified value rows. This helper does
|
|
5
|
+
// not authenticate approval, infer missing signer fields, or modify next actions.
|
|
6
|
+
function deliverySummary(e) {
|
|
7
|
+
const signals = e.signals || {}
|
|
8
|
+
const rows = e.valueRows || []
|
|
9
|
+
const gaps = []
|
|
10
|
+
const add = (kind, text, action, source, tone = 'amber') => gaps.push({ kind, text, action, source, tone })
|
|
11
|
+
if (signals.memoryWarn) add('record', 'Record needs repair: ' + signals.memoryWarn, 'Review and repair the client record before relying on it', 'context.md', 'red')
|
|
12
|
+
if (e.highRisks) add('blocker', `${e.highRisks} high risk${e.highRisks === 1 ? '' : 's'} open`, 'Review the highest-priority open risk and confirm its owner', 'risks.md', 'red')
|
|
13
|
+
if (signals.trust === 'RED') add('trust', 'Check in with the customer: trust is at risk', 'Confirm the customer concern before continuing delivery', 'stakeholders.md', 'red')
|
|
14
|
+
if (e.hasSigner === false) add('signer', 'Acceptance owner not recorded', 'Name who can accept the outcome and confirm their authority', 'success.md')
|
|
15
|
+
const missingEvidence = rows.filter(r => r.evidenceMissing).length
|
|
16
|
+
if (missingEvidence) add('evidence', `${missingEvidence} outcome${missingEvidence === 1 ? '' : 's'} missing evidence`, 'Find the measurement source before presenting the outcome', 'delivery.md')
|
|
17
|
+
const unmeasured = rows.filter(r => r.state === 'unmeasured').length
|
|
18
|
+
if (unmeasured) add('measurement', `${unmeasured} outcome${unmeasured === 1 ? '' : 's'} not yet measured`, 'Agree how to measure the outcome and collect the result', 'delivery.md')
|
|
19
|
+
const claimed = rows.filter(r => r.state === 'claimed').length
|
|
20
|
+
if (claimed) add('acceptance', `${claimed} measured outcome${claimed === 1 ? '' : 's'} awaiting acceptance`, 'Ask the acceptance owner to review the measured outcome', 'delivery.md')
|
|
21
|
+
if (signals.trust === 'amber') add('trust', 'Check in with the customer: trust is watch', 'Check the customer concern and agree the next step', 'stakeholders.md')
|
|
22
|
+
if (signals.stale) add('stale-trust', 'Reconfirm the dated trust signal', 'Check whether the recorded customer signal still applies', 'stakeholders.md')
|
|
23
|
+
if (!e.hasNext) add('next', 'Set the next action', 'Set one next action with an owner and completion check', 'context.md')
|
|
24
|
+
if (signals.trust === 'new') add('new-trust', 'No dated trust signal yet - ask someone', 'Ask the customer how the engagement is going', 'stakeholders.md')
|
|
25
|
+
if (e.quiet) add('stale-record', 'Record last updated ' + (signals.updated || 'at an unknown time'), 'Check what changed since the last recorded update', 'context.md')
|
|
26
|
+
const first = gaps[0]
|
|
27
|
+
return {
|
|
28
|
+
gaps,
|
|
29
|
+
firstAction: first
|
|
30
|
+
? { kind: first.kind, text: first.action, reason: first.text, tone: first.tone, source: first.source }
|
|
31
|
+
: { kind: 'recorded', text: e.next || 'Review the client record', reason: 'Recorded next action', tone: '', source: 'context.md' },
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
module.exports = { deliverySummary }
|
|
@@ -6,6 +6,10 @@ module.exports = function fieldbookClient() {
|
|
|
6
6
|
const views = all('.fb-view')
|
|
7
7
|
const nav = all('.fb-nav')
|
|
8
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)')
|
|
9
13
|
const main = document.querySelector('.fb-main')
|
|
10
14
|
const status = document.getElementById('fb-status')
|
|
11
15
|
const palette = document.getElementById('fb-palette-dialog')
|
|
@@ -15,6 +19,13 @@ module.exports = function fieldbookClient() {
|
|
|
15
19
|
let returnFocus = null
|
|
16
20
|
let active = 0
|
|
17
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'))
|
|
18
29
|
function selectView(id, updateHash = true, focus = false) {
|
|
19
30
|
if (id === 'fb-main') { id = 'today'; updateHash = false }
|
|
20
31
|
if (!document.getElementById('view-' + id)) id = 'today'
|
|
@@ -27,7 +38,10 @@ module.exports = function fieldbookClient() {
|
|
|
27
38
|
})
|
|
28
39
|
if (updateHash && location.hash !== '#' + id) location.hash = id
|
|
29
40
|
main.scrollTop = 0
|
|
30
|
-
if (focus)
|
|
41
|
+
if (focus) {
|
|
42
|
+
document.querySelector('.fb-view:not([hidden]) h1').focus()
|
|
43
|
+
if (mobile.matches) showClients(false)
|
|
44
|
+
}
|
|
31
45
|
}
|
|
32
46
|
const hashId = () => location.hash.slice(1)
|
|
33
47
|
selectView(hashId() || 'today', false)
|
|
@@ -47,22 +61,34 @@ module.exports = function fieldbookClient() {
|
|
|
47
61
|
const start = Math.max(0, i - 24), end = Math.min(raw.length, i + term.length + 40)
|
|
48
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 ? '…' : '')
|
|
49
63
|
}
|
|
50
|
-
|
|
64
|
+
function filterClients() {
|
|
51
65
|
const term = search.value.trim().toLowerCase()
|
|
52
|
-
|
|
66
|
+
const matches = new Set()
|
|
53
67
|
nav.filter(n => n.dataset.target !== 'today').forEach(n => {
|
|
54
|
-
const raw = n.dataset.search || ''
|
|
55
|
-
const
|
|
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))
|
|
56
71
|
n.classList.toggle('hide', !hit)
|
|
57
|
-
if (hit)
|
|
72
|
+
if (hit) matches.add(n.dataset.target)
|
|
58
73
|
const reason = n.querySelector('.fb-nav-reason')
|
|
59
74
|
if (term && hit) reason.innerHTML = snippet(raw, term)
|
|
60
75
|
else reason.textContent = n.dataset.defaultReason || ''
|
|
61
76
|
reason.classList.toggle('hide', !reason.textContent)
|
|
62
77
|
})
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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()
|
|
66
92
|
|
|
67
93
|
function toggleTheme() {
|
|
68
94
|
const next = document.documentElement.dataset.fdeTheme === 'dark' ? 'light' : 'dark'
|
|
@@ -114,6 +140,7 @@ module.exports = function fieldbookClient() {
|
|
|
114
140
|
if (!it) return
|
|
115
141
|
palette.close()
|
|
116
142
|
if (it.dataset.target === '__theme__') toggleTheme()
|
|
143
|
+
else if (it.dataset.target === '__print__') window.print()
|
|
117
144
|
else selectView(it.dataset.target, true, true)
|
|
118
145
|
}
|
|
119
146
|
document.getElementById('fb-palette-btn').addEventListener('click', openPalette)
|
|
@@ -134,6 +161,10 @@ module.exports = function fieldbookClient() {
|
|
|
134
161
|
try {
|
|
135
162
|
await navigator.clipboard.writeText(text)
|
|
136
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)
|
|
137
168
|
} catch (_) {
|
|
138
169
|
const input = document.getElementById('fb-prompt-text')
|
|
139
170
|
input.value = text
|
|
@@ -154,7 +185,7 @@ module.exports = function fieldbookClient() {
|
|
|
154
185
|
return
|
|
155
186
|
}
|
|
156
187
|
if (event.target.closest('.fb-table-scroll')) return
|
|
157
|
-
if (event.key === '/') { event.preventDefault(); search.focus(); return }
|
|
188
|
+
if (event.key === '/') { event.preventDefault(); showClients(true); search.focus(); return }
|
|
158
189
|
if (event.key === 'j' || event.key === 'k' || (event.target.closest('.fb-rail') && ['ArrowDown', 'ArrowUp'].includes(event.key))) {
|
|
159
190
|
event.preventDefault()
|
|
160
191
|
const ids = nav.filter(n => !n.classList.contains('hide')).map(n => n.dataset.target)
|