fdeops 4.0.3 → 4.0.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 CHANGED
@@ -36,7 +36,7 @@ const { createMemoryApi } = require('./lib/memory')
36
36
  const { createTrustApi } = require('./lib/trust')
37
37
  const vault = require('./lib/vault')
38
38
  const context = require('./lib/context')
39
- const { sourceReference, hasSource, datedDecisions } = require('./lib/provenance')
39
+ const { sourceReference, sourceReferences, hasSource, datedDecisions } = require('./lib/provenance')
40
40
  const { deliverySummary } = require('./lib/delivery-gaps')
41
41
 
42
42
  const HOME = os.homedir()
@@ -67,8 +67,13 @@ function maskReport(value) {
67
67
  if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, maskReport(item)]))
68
68
  return value
69
69
  }
70
- function maskedSections(sections, maxBytes) {
71
- return context.boundedSections(sections.map(text => masking.mask(text)), maxBytes)
70
+ function maskedSections(sections, maxBytes = context.DEFAULT_BYTES, heading = '') {
71
+ // Reserve identity before sharing the remaining budget between excerpts.
72
+ // Pathological path lengths remain explicitly clipped rather than exhausting policy space.
73
+ const maskedHeading = masking.mask(heading)
74
+ const prefix = heading ? (Buffer.byteLength(maskedHeading) <= 1024 ? maskedHeading
75
+ : context.clipMaskedUtf8(maskedHeading, 900) + '\n[Identity path truncated; use fde resume --bind to inspect.]') + '\n\n' : ''
76
+ return prefix + context.boundedSections(sections.map(text => masking.mask(text)), maxBytes - Buffer.byteLength(prefix))
72
77
  }
73
78
  const DEBRIEF_MAX_BYTES = 256 * 1024
74
79
  const CODE_EXT = ['.js', '.ts', '.tsx', '.jsx', '.py', '.java', '.go', '.rb', '.cs', '.php']
@@ -1459,12 +1464,12 @@ function cmdResume(args) {
1459
1464
  process.stdout.write(maskedSections([
1460
1465
  policy ? `CLIENT POLICY - trust-profile.md\n${policy}` : '',
1461
1466
  preferences.work ? `WORKING PREFERENCES: ${preferences.work}. Starting help: ${preferences.start}.\n${setup.nextStep(preferences)}\nThis is a starting preference, not a client fact; current instructions and engagement state take precedence.` : '',
1462
- `${intro}\n\nENGAGEMENT: ${eng}`,
1467
+ intro,
1463
1468
  success ? `CURRENT GOALS & ACCEPTANCE - success.md\n${success}` : '',
1464
1469
  risks ? `OPEN RISKS - risks.md\n${extractRisks(eng).map(r => r.text).join('\n') || '(none recorded)'}` : '',
1465
1470
  `VALUE LEDGER - delivery.md\n${parseValueLedger(eng).rows.map(r => formatValueLedgerLine(r) + '; source: ' + (r.evidence || '(missing)')).join('\n') || '(none recorded)'}`,
1466
1471
  `WORKING CONTEXT - context.md\n${ctx ? resumeView(ctx) : '(no context.md yet)'}`,
1467
- ], maxBytes))
1472
+ ], maxBytes, `ENGAGEMENT: ${eng}`))
1468
1473
  }
1469
1474
 
1470
1475
  // Token discipline: context.md grows every session (the session-stop hook
@@ -2083,7 +2088,10 @@ function withDebriefRecords(eng, apply, files = ['decisions.md', 'risks.md', 'de
2083
2088
  } catch (_) { failed.push(path.basename(target)) }
2084
2089
  }
2085
2090
  if (failed.length) throw new Error(`${error.message}; recovery could not restore ${failed.join(', ')}. Inspect these records and the pending proposal before retrying.`)
2086
- throw new Error(`${error.message}; no record changes kept. The proposal is retained; retry after resolving the cause.`)
2091
+ const recovery = snapshots.get(path.join(eng, DEBRIEF_PROPOSE))
2092
+ ? 'The proposal is retained; inspect it with fde debrief --review before retrying.'
2093
+ : 'No pending proposal was retained; review your input before retrying.'
2094
+ throw new Error(`${error.message}; no record changes kept. ${recovery}`)
2087
2095
  } finally { debriefTransactionActive = false }
2088
2096
  }
2089
2097
  return lockAt(0)
@@ -2156,7 +2164,7 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [], allowReplay =
2156
2164
  })
2157
2165
  }
2158
2166
  const repeats = repeatedDebriefStatements(eng, input)
2159
- if (repeats.length && !dry && !allowReplay) throw new Error('source-backed statement already recorded; review the existing record and newer next action. Explicitly confirm a repeat with --allow-replay, or remove the repeated statement from the proposal.')
2167
+ if (repeats.length && !dry && !allowReplay) throw new Error('source-backed statement already recorded; review the existing record and newer next action. Explicitly confirm a repeat with --allow-replay, or remove the repeated statement from your input or pending proposal.')
2160
2168
  const d = new Date()
2161
2169
  const date = d.toISOString().slice(0, 10)
2162
2170
  const counts = { decision: 0, risk: 0, delivery: 0, contact: 0, next: 0, signer: 0 }
@@ -2547,15 +2555,20 @@ function cmdReceipts(args) {
2547
2555
  const records = [], claims = []
2548
2556
  for (const file of [...recordFiles, ...workingFiles]) {
2549
2557
  const document = readClean(eng, file)
2558
+ const scaffold = new Set((templatesDir() ? readClean(templatesDir(), file) : '').split('\n').map(line => line.trim()))
2550
2559
  const decisionSources = new Map()
2551
2560
  if (file === 'decisions.md') for (const entry of datedDecisions(document)) {
2552
- const source = sourceReference(entry.text)
2553
- for (let line = entry.line; line < entry.line + entry.text.split('\n').length; line++) decisionSources.set(line, source)
2561
+ for (let line = entry.line; line < entry.line + entry.text.split('\n').length; line++) decisionSources.set(line, entry.text)
2554
2562
  }
2555
2563
  document.split('\n').forEach((line, i) => {
2556
2564
  if (!line.toLowerCase().includes(term.toLowerCase())) return
2557
- const source = decisionSources.get(i + 1) || sourceReference(line)
2558
- const hit = ` ${file}:${i + 1} ${masking.mask(line.trim()).slice(0, 160)}${source ? ` [source: ${masking.mask(source).slice(0, 160)}]` : ' [source missing]'}${dirty.has(file) ? ' dirty file - review manual edits' : ''}`
2565
+ const sourceText = decisionSources.get(i + 1) || line
2566
+ const source = sourceReference(sourceText)
2567
+ if (!source && scaffold.has(line.trim())) return
2568
+ const sources = sourceReferences(sourceText)
2569
+ const attribution = masking.mask(sources.join('; '))
2570
+ const displayed = Buffer.byteLength(attribution) <= 320 ? attribution : context.clipMaskedUtf8(attribution, 240) + '… [sources truncated; use fde recall]'
2571
+ const hit = ` ${file}:${i + 1} ${context.clipMaskedUtf8(masking.mask(line.trim()), 160)}${source ? ` [${sources.length > 1 ? 'sources' : 'source'}: ${displayed}]` : ' [source missing]'}${dirty.has(file) ? ' dirty file - review manual edits' : ''}`
2559
2572
  ;(recordFiles.includes(file) && source ? records : claims).push({ file, hit })
2560
2573
  })
2561
2574
  }
@@ -14,6 +14,13 @@ function clipUtf8(text, bytes) {
14
14
  while (end > 0 && (buf[end] & 0xc0) === 0x80) end--
15
15
  return buf.subarray(0, end).toString('utf8')
16
16
  }
17
+ // Masking aliases must stay whole so they remain recognizable and reversible.
18
+ function clipMaskedUtf8(text, bytes) {
19
+ const clipped = clipUtf8(text, bytes)
20
+ if (clipped === text) return text
21
+ const open = clipped.lastIndexOf('[[')
22
+ return open > clipped.lastIndexOf(']]') ? clipped.slice(0, open) : clipped
23
+ }
17
24
  function budgetArgs(args, defaultBytes = DEFAULT_BYTES) {
18
25
  const rest = [...args]
19
26
  let maxBytes = defaultBytes
@@ -91,4 +98,4 @@ function recallSections(documents, query, maxHits = 12, outputText = text => tex
91
98
  sections: selected.map(h => `${h.file}:${h.line}-${h.end} (lines in redacted view)\n${h.text}`),
92
99
  }
93
100
  }
94
- module.exports = { DEFAULT_BYTES, clipUtf8, budgetArgs, boundedSections, recallSections }
101
+ module.exports = { DEFAULT_BYTES, clipUtf8, clipMaskedUtf8, budgetArgs, boundedSections, recallSections }
@@ -40,3 +40,13 @@ function datedDecisions(value) {
40
40
  return entries.sort((a, b) => a.date.localeCompare(b.date) || a.line - b.line)
41
41
  }
42
42
  module.exports.datedDecisions = datedDecisions
43
+
44
+ // Display every explicit attribution without selecting a primary source.
45
+ // Keep classification through sourceReference unchanged.
46
+ function sourceReferences(value) {
47
+ const explicit = String(value || '').match(/\[source:[^\]\n]+\]/gi) || []
48
+ if (explicit.length) return [...new Set(explicit.map(sourceReference).filter(Boolean))]
49
+ const fallback = sourceReference(value)
50
+ return fallback ? [fallback] : []
51
+ }
52
+ module.exports.sourceReferences = sourceReferences
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops-ingest-mcp",
3
- "version": "4.0.3",
3
+ "version": "4.0.4",
4
4
  "private": true,
5
5
  "description": "Thin stdio MCP sink for FDEOps ingest (stage → propose → apply). Zero runtime dependencies.",
6
6
  "bin": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "4.0.3",
3
+ "version": "4.0.4",
4
4
  "description": "Client delivery tools for Forward Deployed Engineers. One @fde skill, local Markdown engagement records, and an offline dashboard for decisions, evidence, approvals, and next actions.",
5
5
  "bin": {
6
6
  "fdeops": "bin/install.js",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "fdeops",
4
- "version": "4.0.3",
4
+ "version": "4.0.4",
5
5
  "description": "Forward deployed engineering skills for AI coding agents. One @fde skill for the client work around the code. You confirm; then it lands in .fde/ on your laptop.",
6
6
  "author": {
7
7
  "name": "Subash Natarajan",