fdeops 3.27.1 → 3.29.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 +229 -72
- package/bin/check.js +2 -7
- package/bin/fde.js +194 -55
- package/bin/lib/context.js +2 -2
- package/bin/lib/masking.js +138 -0
- package/bin/lib/memory.js +5 -2
- package/bin/lib/render.js +1 -1
- package/bin/lib/trust.js +4 -28
- package/bin/lib/value-ledger.js +46 -1
- package/mcp/fdeops-ingest/package.json +1 -1
- package/mcp/fdeops-ingest/server.js +7 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/fde/SKILL.md +6 -2
- package/skills/fde/references/audit.md +3 -3
- package/skills/fde/references/debrief.md +35 -24
- package/skills/fde/references/discover.md +6 -2
- package/skills/fde/references/ingest.md +6 -6
- package/skills/fde/references/land.md +2 -0
package/bin/fde.js
CHANGED
|
@@ -45,6 +45,13 @@ const HOME = os.homedir()
|
|
|
45
45
|
const ENGAGEMENTS_ROOT = ((process.env.FDEOPS_ENGAGEMENTS_ROOT || '').trim().replace(/^~/, HOME))
|
|
46
46
|
|| path.join(HOME, 'fde-engagements')
|
|
47
47
|
const REGISTRY = path.join(ENGAGEMENTS_ROOT, '.registry')
|
|
48
|
+
const masking = require('./lib/masking').createMasking(ENGAGEMENTS_ROOT)
|
|
49
|
+
function maskDisplay(text) {
|
|
50
|
+
return ['dashboard', 'vault'].includes(process.argv[2]) ? String(text) : masking.mask(text)
|
|
51
|
+
}
|
|
52
|
+
function maskedSections(sections, maxBytes) {
|
|
53
|
+
return context.boundedSections(sections.map(text => masking.mask(text)), maxBytes)
|
|
54
|
+
}
|
|
48
55
|
const DEBRIEF_MAX_BYTES = 256 * 1024
|
|
49
56
|
const CODE_EXT = ['.js', '.ts', '.tsx', '.jsx', '.py', '.java', '.go', '.rb', '.cs', '.php']
|
|
50
57
|
const CONF_EXT = CODE_EXT.concat(['.env', '.yaml', '.yml', '.json'])
|
|
@@ -114,7 +121,7 @@ function grepFiles(files, regex, cap) {
|
|
|
114
121
|
try { text = fs.readFileSync(f, 'utf8') } catch (_) { continue }
|
|
115
122
|
const lines = text.split('\n')
|
|
116
123
|
for (let i = 0; i < lines.length && hits.length < cap; i++) {
|
|
117
|
-
if (regex.test(lines[i])) hits.push({ file: path.relative(process.cwd(), f), line: i + 1, text: lines[i].trim().slice(0, 120) })
|
|
124
|
+
if (regex.test(lines[i])) hits.push({ file: path.relative(process.cwd(), f), line: i + 1, text: maskDisplay(lines[i].trim()).slice(0, 120) })
|
|
118
125
|
}
|
|
119
126
|
}
|
|
120
127
|
return hits
|
|
@@ -798,7 +805,7 @@ function firstLine(md, maxLen) {
|
|
|
798
805
|
for (const raw of md.split('\n')) {
|
|
799
806
|
const l = raw.trim()
|
|
800
807
|
if (!l || /^#{1,6}\s/.test(l)) continue
|
|
801
|
-
const clean = l.replace(/^\*\*[^*]+:\*\*\s*/, '').replace(/\*\*/g, '').replace(/^["']|["']$/g, '').trim()
|
|
808
|
+
const clean = maskDisplay(l.replace(/^\*\*[^*]+:\*\*\s*/, '').replace(/\*\*/g, '').replace(/^["']|["']$/g, '').trim())
|
|
802
809
|
if (!clean) continue
|
|
803
810
|
return clean.length > maxLen ? clean.slice(0, maxLen - 1).trim() + '…' : clean
|
|
804
811
|
}
|
|
@@ -811,7 +818,7 @@ function firstLine(md, maxLen) {
|
|
|
811
818
|
function parseReality(md, maxLen) {
|
|
812
819
|
const theory = (md.match(/\*\*Working theory:\*\*[^\S\r\n]*(.*)/i) || [])[1]
|
|
813
820
|
const hasSchema = /\*\*(Working theory|Evidence|Differs from brief how):\*\*/i.test(md)
|
|
814
|
-
const theoryText = (theory || '').trim()
|
|
821
|
+
const theoryText = maskDisplay((theory || '').trim())
|
|
815
822
|
if (theoryText) {
|
|
816
823
|
const line = theoryText.length > maxLen ? theoryText.slice(0, maxLen - 1).trim() + '…' : theoryText
|
|
817
824
|
return { line, missing: '' }
|
|
@@ -840,7 +847,7 @@ function valueLedgerRowCount(body) {
|
|
|
840
847
|
return t.rows.filter(r => r.some(c => String(c || '').trim())).length
|
|
841
848
|
}
|
|
842
849
|
|
|
843
|
-
function appendValueLedgerRow(eng, cells) {
|
|
850
|
+
function appendValueLedgerRow(eng, cells, { skipCommit = false } = {}) {
|
|
844
851
|
ensureMemoryGit(eng)
|
|
845
852
|
const p = path.join(eng, 'delivery.md')
|
|
846
853
|
let row
|
|
@@ -887,7 +894,7 @@ function appendValueLedgerRow(eng, cells) {
|
|
|
887
894
|
atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n')
|
|
888
895
|
})
|
|
889
896
|
recordLastWrite(eng, 'delivery.md', row)
|
|
890
|
-
commitMemory(eng, 'log delivery', { files: ['delivery.md'] })
|
|
897
|
+
if (!skipCommit) commitMemory(eng, 'log delivery', { files: ['delivery.md'] })
|
|
891
898
|
}
|
|
892
899
|
|
|
893
900
|
function retireOpenRisks(eng, needle) {
|
|
@@ -983,7 +990,7 @@ const {
|
|
|
983
990
|
countOpenRisks,
|
|
984
991
|
} = createTrustApi({
|
|
985
992
|
fs, path, readClean, readEng, parseMdTable, sectionBody, SIGNAL_LEDGER, memoryDirtyManual,
|
|
986
|
-
stripTemplateNoise, stripLegendLines,
|
|
993
|
+
stripTemplateNoise, stripLegendLines, extractRisks,
|
|
987
994
|
})
|
|
988
995
|
|
|
989
996
|
// Stakeholders: columns are matched by header wording, not position - real
|
|
@@ -1081,14 +1088,14 @@ function extractStakeholders(eng) {
|
|
|
1081
1088
|
for (const [key, h] of latest) {
|
|
1082
1089
|
if (byKey.has(key)) {
|
|
1083
1090
|
const cur = byKey.get(key)
|
|
1084
|
-
byKey.set(key, { ...cur, signal: h.signal, note: cur.note || h.text.slice(0, 80) })
|
|
1091
|
+
byKey.set(key, { ...cur, signal: h.signal, note: cur.note || maskDisplay(h.text).slice(0, 80) })
|
|
1085
1092
|
} else {
|
|
1086
1093
|
const name = displayNameFromSignalText(h.text)
|
|
1087
1094
|
if (!name || isSignalNameNoise(name)) continue
|
|
1088
1095
|
byKey.set(key, {
|
|
1089
1096
|
name,
|
|
1090
1097
|
role: '',
|
|
1091
|
-
note: h.text.slice(0, 80),
|
|
1098
|
+
note: maskDisplay(h.text).slice(0, 80),
|
|
1092
1099
|
signal: h.signal,
|
|
1093
1100
|
source: 'signal',
|
|
1094
1101
|
})
|
|
@@ -1099,14 +1106,14 @@ function extractStakeholders(eng) {
|
|
|
1099
1106
|
|
|
1100
1107
|
// Risks: table rows AND dated CLI/debrief bullets. Empty template cells ignored.
|
|
1101
1108
|
function extractRisks(eng) {
|
|
1102
|
-
const md = readClean(eng, 'risks.md')
|
|
1109
|
+
const md = stripTemplateNoise(readClean(eng, 'risks.md'))
|
|
1103
1110
|
const body = md.split(/^#{1,6}\s+Retired\b/im)[0] || md
|
|
1104
1111
|
const HIGH = /critical|blocker|exposure|breach|urgent|at risk|at stake|\brace\b|rollback|no test/i
|
|
1105
1112
|
const out = []
|
|
1106
1113
|
const seen = new Set()
|
|
1107
1114
|
const push = (text) => {
|
|
1108
1115
|
const t = String(text || '').trim()
|
|
1109
|
-
if (!t || seen.has(t.toLowerCase())) return
|
|
1116
|
+
if (!t || /^(?:\[[xX]\]\s|(?:closed|resolved|retired)\s*:)/i.test(t) || seen.has(t.toLowerCase())) return
|
|
1110
1117
|
seen.add(t.toLowerCase())
|
|
1111
1118
|
out.push({ text: t, severity: HIGH.test(t) ? 'high' : 'med' })
|
|
1112
1119
|
}
|
|
@@ -1114,13 +1121,23 @@ function extractRisks(eng) {
|
|
|
1114
1121
|
if (table) {
|
|
1115
1122
|
const riskIdx = colIndex(table.headers, /^risk$/i)
|
|
1116
1123
|
if (riskIdx !== -1) {
|
|
1117
|
-
|
|
1124
|
+
const statusIdx = colIndex(table.headers, /^status$/i)
|
|
1125
|
+
for (const cs of table.rows) {
|
|
1126
|
+
if (statusIdx !== -1 && /^(closed|resolved|retired)$/i.test((cs[statusIdx] || '').trim())) continue
|
|
1127
|
+
push(cs[riskIdx])
|
|
1128
|
+
}
|
|
1118
1129
|
}
|
|
1119
1130
|
}
|
|
1120
1131
|
for (const raw of body.split('\n')) {
|
|
1121
1132
|
const t = raw.trim()
|
|
1122
1133
|
const m = t.match(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*(?:\[@[^\]]+\]\s*)?(.*)$/)
|
|
1123
1134
|
if (m) push(m[1])
|
|
1135
|
+
else {
|
|
1136
|
+
// Inherited Markdown may predate the dated CLI format. Keep its open
|
|
1137
|
+
// bullets visible; absence of a date does not mean absence of a risk.
|
|
1138
|
+
const bullet = t.match(/^[-*+]\s+(.*)$/)
|
|
1139
|
+
if (bullet && !/^\[[xX]\]\s/.test(bullet[1])) push(bullet[1].replace(/^\[ \]\s*/, ''))
|
|
1140
|
+
}
|
|
1124
1141
|
}
|
|
1125
1142
|
return out
|
|
1126
1143
|
}
|
|
@@ -1416,7 +1433,7 @@ function cmdResume(args) {
|
|
|
1416
1433
|
const policy = readClean(eng, 'trust-profile.md')
|
|
1417
1434
|
const success = readClean(eng, 'success.md')
|
|
1418
1435
|
const risks = readClean(eng, 'risks.md')
|
|
1419
|
-
process.stdout.write(
|
|
1436
|
+
process.stdout.write(maskedSections([
|
|
1420
1437
|
policy ? `CLIENT POLICY - trust-profile.md\n${policy}` : '',
|
|
1421
1438
|
`${intro}\n\nENGAGEMENT: ${eng}`,
|
|
1422
1439
|
success ? `CURRENT GOALS & ACCEPTANCE - success.md\n${success}` : '',
|
|
@@ -1541,7 +1558,8 @@ function cmdLog(args) {
|
|
|
1541
1558
|
return
|
|
1542
1559
|
}
|
|
1543
1560
|
if (type === 'delivery' && text.includes('|')) {
|
|
1544
|
-
|
|
1561
|
+
let cells
|
|
1562
|
+
try { cells = deliveryCells(text) } catch (error) { console.error(error.message); process.exitCode = 1; return }
|
|
1545
1563
|
appendValueLedgerRow(eng, cells)
|
|
1546
1564
|
const hash = memoryHead(eng)
|
|
1547
1565
|
console.log(`logged → delivery.md (value ledger)${hash ? ` @${hash}` : ''}`)
|
|
@@ -1821,6 +1839,7 @@ function readDebriefInput(args) {
|
|
|
1821
1839
|
}
|
|
1822
1840
|
|
|
1823
1841
|
function previewLine(text, max = 240) {
|
|
1842
|
+
text = masking.mask(text)
|
|
1824
1843
|
const t = String(text || '').replace(/\s+/g, ' ').trim()
|
|
1825
1844
|
if (t.length <= max) return t
|
|
1826
1845
|
return `${t.slice(0, max)}… (${t.length} chars)`
|
|
@@ -1834,7 +1853,8 @@ function writeProposal(eng, text, { replace = false, locked = false } = {}) {
|
|
|
1834
1853
|
finally { ownedDebriefLocks.delete(path.join(eng, DEBRIEF_PROPOSE)) }
|
|
1835
1854
|
}, { soft: true })
|
|
1836
1855
|
if (!debriefTransactionActive) return withDebriefRecords(eng, () => writeProposal(eng, text, { replace, locked: true }), [DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL])
|
|
1837
|
-
const { clean, blocks } = splitPrivate(text, { sealDangling: true })
|
|
1856
|
+
const { clean: original, blocks } = splitPrivate(text, { sealDangling: true })
|
|
1857
|
+
const clean = masking.mask(original)
|
|
1838
1858
|
const proposePath = path.join(eng, DEBRIEF_PROPOSE)
|
|
1839
1859
|
const privatePath = path.join(eng, DEBRIEF_PRIVATE)
|
|
1840
1860
|
if (fs.existsSync(proposePath) && !replace) {
|
|
@@ -1876,6 +1896,9 @@ function stripApprovedStamp(text) {
|
|
|
1876
1896
|
// One screen a human can confirm in two minutes. The file-by-file routing
|
|
1877
1897
|
// still prints after this - agents edit prefixes; people read this.
|
|
1878
1898
|
function printDebriefReview(text, eng) {
|
|
1899
|
+
text = masking.restore(text)
|
|
1900
|
+
const repeats = repeatedDebriefStatements(eng, text)
|
|
1901
|
+
if (repeats.length) console.log(`REPLAY WARNING: ${repeats.length} source-backed statement(s) already recorded. Review newer facts and next action; applying again requires --allow-replay.\n`)
|
|
1879
1902
|
const buckets = { decided: [], asked: [], scope: [], delivery: [], open: [], next: [], signer: [] }
|
|
1880
1903
|
for (const raw of String(text || '').split('\n')) {
|
|
1881
1904
|
const line = raw.trim().replace(/^[-*+]\s+/, '')
|
|
@@ -2048,11 +2071,42 @@ function boundedDebriefPreview(eng, render, { proposal = true, maxBytes = 12000
|
|
|
2048
2071
|
return result
|
|
2049
2072
|
}
|
|
2050
2073
|
|
|
2051
|
-
function
|
|
2074
|
+
function deliveryCells(text) {
|
|
2075
|
+
const cells = text.split('|').map(s => s.trim())
|
|
2076
|
+
if (cells.length !== 7) throw new Error('delivery needs exactly 7 fields: Slice | Bucket | Promised | Measured | Accepted by | Evidence | Rollback. Use pending for unknowns; omit pipes for a narrative note.')
|
|
2077
|
+
return cells
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// Exact sourced statement replay only; a shared source may contain new facts.
|
|
2081
|
+
// Do not silently deduplicate: the engineer decides whether a repeated event is intended.
|
|
2082
|
+
function repeatedDebriefStatements(eng, input) {
|
|
2083
|
+
const repeats = []
|
|
2084
|
+
const records = new Map()
|
|
2085
|
+
const normalize = value => value.replace(/\s*\|\s*/g, '|').replace(/\s+/g, ' ').trim()
|
|
2086
|
+
for (const raw of splitPrivate(input, { sealDangling: true }).clean.split('\n')) {
|
|
2087
|
+
const match = raw.trim().replace(/^[-*+]\s+/, '').match(/^(decision|risk|delivery|contact):\s*(.+)$/i)
|
|
2088
|
+
if (!match || !/\[source:[^\]]+\]/i.test(match[2])) continue
|
|
2089
|
+
const body = match[2].trim()
|
|
2090
|
+
const file = LOG_FILES[match[1].toLowerCase()]
|
|
2091
|
+
if (!records.has(file)) {
|
|
2092
|
+
const statements = String(readClean(eng, file) || '').split('\n').map(line => {
|
|
2093
|
+
if (/^\s*\|/.test(line)) return normalize(line.trim().replace(/^\|\s*\d{4}-\d{2}-\d{2}\s*\|/, '').replace(/\|\s*$/, ''))
|
|
2094
|
+
return normalize(line.replace(/^\s*[-*+]\s+/, '').replace(/^(?:\[(?:\d{4}-\d{2}-\d{2}|@[^\]]+|signal:[^\]]+)\]\s*)+/, ''))
|
|
2095
|
+
})
|
|
2096
|
+
records.set(file, new Set(statements))
|
|
2097
|
+
}
|
|
2098
|
+
if (records.get(file).has(normalize(body))) repeats.push(body)
|
|
2099
|
+
}
|
|
2100
|
+
return repeats
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function routeDebriefInput(eng, input, { dry, force, sealed = [], allowReplay = false }) {
|
|
2104
|
+
input = masking.restore(input)
|
|
2105
|
+
masking.mask(splitPrivate(input, { sealDangling: true }).clean)
|
|
2052
2106
|
if (!dry && !debriefTransactionActive) {
|
|
2053
2107
|
ensureMemoryGit(eng)
|
|
2054
2108
|
return withDebriefRecords(eng, () => {
|
|
2055
|
-
const result = routeDebriefInput(eng, input, { dry, force, sealed })
|
|
2109
|
+
const result = routeDebriefInput(eng, input, { dry, force, sealed, allowReplay })
|
|
2056
2110
|
// Consuming the review is part of the write. If cleanup fails, restoring
|
|
2057
2111
|
// both the records and proposal makes the next explicit apply safe.
|
|
2058
2112
|
for (const file of [DEBRIEF_PROPOSE, DEBRIEF_PRIVATE, DEBRIEF_SEAL]) {
|
|
@@ -2063,12 +2117,14 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
|
|
|
2063
2117
|
return result
|
|
2064
2118
|
})
|
|
2065
2119
|
}
|
|
2120
|
+
const repeats = repeatedDebriefStatements(eng, input)
|
|
2121
|
+
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.')
|
|
2066
2122
|
const d = new Date()
|
|
2067
2123
|
const date = d.toISOString().slice(0, 10)
|
|
2068
2124
|
const counts = { decision: 0, risk: 0, delivery: 0, contact: 0, next: 0, signer: 0 }
|
|
2069
2125
|
const ctxLines = []
|
|
2070
2126
|
let nextAction = ''
|
|
2071
|
-
ensureMemoryGit(eng)
|
|
2127
|
+
if (!dry) ensureMemoryGit(eng)
|
|
2072
2128
|
// Sealed blocks are pulled out before routing, so a <private> block's interior
|
|
2073
2129
|
// lines are never previewed and never routed into decisions/risks/stakeholders
|
|
2074
2130
|
// unsealed. They land verbatim in context.md instead: the preview a human
|
|
@@ -2092,7 +2148,7 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
|
|
|
2092
2148
|
const who = body.replace(/\s+signs?(?:\s+off)?\b.*$/i, '').trim() || body.trim()
|
|
2093
2149
|
if (dry) {
|
|
2094
2150
|
console.log(`→ success.md **Stakeholder who signs off:** ${previewLine(who)}`)
|
|
2095
|
-
console.log(`→ stakeholders.md ${previewLine(
|
|
2151
|
+
console.log(`→ stakeholders.md ${previewLine(`- [${date}] ${who} signs off`)}`)
|
|
2096
2152
|
} else {
|
|
2097
2153
|
setSigner(eng, who)
|
|
2098
2154
|
appendLogEntry(eng, 'contact', datedEntry(eng, date, `${who} signs off`), { skipCommit: true })
|
|
@@ -2106,9 +2162,16 @@ function routeDebriefInput(eng, input, { dry, force, sealed = [] }) {
|
|
|
2106
2162
|
counts.next++
|
|
2107
2163
|
continue
|
|
2108
2164
|
}
|
|
2165
|
+
if (type === 'delivery' && body.includes('|')) {
|
|
2166
|
+
const cells = deliveryCells(body)
|
|
2167
|
+
if (dry) console.log(`→ delivery.md ## Value ledger ${previewLine(body)}`)
|
|
2168
|
+
else appendValueLedgerRow(eng, cells, { skipCommit: true })
|
|
2169
|
+
counts.delivery++
|
|
2170
|
+
continue
|
|
2171
|
+
}
|
|
2109
2172
|
const sigInline = (body.match(/\[signal:(red|amber|green)\]/i) || [])[1]
|
|
2110
2173
|
if (sigInline) body = body.replace(/\[signal:(red|amber|green)\]/i, '').trim()
|
|
2111
|
-
const entry = datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
|
|
2174
|
+
const entry = dry ? `- [${date}]${type === 'contact' && sigInline ? ` [signal:${sigInline.toLowerCase()}]` : ''} ${body}` : datedEntry(eng, date, body, type === 'contact' && sigInline ? sigInline.toLowerCase() : '')
|
|
2112
2175
|
if (dry) console.log(`→ ${LOG_FILES[type]} ${previewLine(entry)}`)
|
|
2113
2176
|
else appendLogEntry(eng, type, entry, { skipCommit: true })
|
|
2114
2177
|
counts[type]++
|
|
@@ -2147,6 +2210,25 @@ function cmdDebrief(args) {
|
|
|
2147
2210
|
|
|
2148
2211
|
function runDebrief(args, eng) {
|
|
2149
2212
|
args = args.slice()
|
|
2213
|
+
const replayIdx = args.indexOf('--allow-replay')
|
|
2214
|
+
const allowReplay = replayIdx !== -1
|
|
2215
|
+
if (allowReplay) args.splice(replayIdx, 1)
|
|
2216
|
+
if (args.includes('--review')) {
|
|
2217
|
+
if (args.length !== 1 || allowReplay) throw new Error('use debrief --review alone to inspect the pending proposal')
|
|
2218
|
+
const proposal = path.join(eng, DEBRIEF_PROPOSE)
|
|
2219
|
+
const refused = refuseSymlinkWrite(proposal, { soft: true })
|
|
2220
|
+
if (refused) throw new Error(refused)
|
|
2221
|
+
if (!fs.existsSync(proposal)) throw new Error('nothing to review - run debrief --smart <notes> first')
|
|
2222
|
+
const stored = fs.readFileSync(proposal, 'utf8')
|
|
2223
|
+
if (splitPrivate(stored, { sealDangling: true }).blocks.length) throw new Error('pending proposal contains raw private content: do not open it with an agent. Recreate it through debrief --smart from local notes, using --replace-proposal only after confirmation.')
|
|
2224
|
+
const { clean: input } = splitPrivate(masking.restore(stored), { sealDangling: true })
|
|
2225
|
+
const safe = masking.mask(input)
|
|
2226
|
+
if (safe !== stored) atomicWriteFile(proposal, safe, { mode: 0o600, soft: true })
|
|
2227
|
+
return boundedDebriefPreview(eng, () => {
|
|
2228
|
+
printDebriefReview(input, eng)
|
|
2229
|
+
routeDebriefInput(eng, input, { dry: true, force: false })
|
|
2230
|
+
})
|
|
2231
|
+
}
|
|
2150
2232
|
const dryIdx = args.indexOf('--dry-run')
|
|
2151
2233
|
const dry = dryIdx !== -1
|
|
2152
2234
|
if (dry) args.splice(dryIdx, 1)
|
|
@@ -2178,6 +2260,7 @@ function runDebrief(args, eng) {
|
|
|
2178
2260
|
console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
|
|
2179
2261
|
return void (process.exitCode = 1)
|
|
2180
2262
|
}
|
|
2263
|
+
input = masking.restore(input)
|
|
2181
2264
|
sealed = readSealedProposal(eng)
|
|
2182
2265
|
const expected = readSealCount(eng)
|
|
2183
2266
|
if (expected === null ? (!sealed.length && input.includes(PRIVATE_MARKER)) : sealed.length < expected) {
|
|
@@ -2202,14 +2285,14 @@ function runDebrief(args, eng) {
|
|
|
2202
2285
|
if (!apply) {
|
|
2203
2286
|
console.log(`\nproposal saved → ${proposePath}`)
|
|
2204
2287
|
console.log('confirm: fde debrief --apply')
|
|
2205
|
-
console.log('(edit the propose file
|
|
2288
|
+
console.log('(edit the propose file if mis-routed; debrief --review shows the pending REVIEW)')
|
|
2206
2289
|
return
|
|
2207
2290
|
}
|
|
2208
2291
|
input = clean
|
|
2209
2292
|
sealed = blocks
|
|
2210
2293
|
}
|
|
2211
2294
|
|
|
2212
|
-
const route = () => routeDebriefInput(eng, input, { dry, force, sealed })
|
|
2295
|
+
const route = () => routeDebriefInput(eng, input, { dry, force, sealed, allowReplay })
|
|
2213
2296
|
const { counts, ctxLines, privateBlocks } = boundedDebriefPreview(eng, route, { proposal: false, maxBytes: smart ? 4000 : 12000 })
|
|
2214
2297
|
if (!dry) {
|
|
2215
2298
|
const hash = commitMemory(eng, 'debrief', {
|
|
@@ -2399,7 +2482,7 @@ function cmdIngest(args) {
|
|
|
2399
2482
|
const body = [
|
|
2400
2483
|
'---',
|
|
2401
2484
|
`source: ${source}`,
|
|
2402
|
-
`title: ${title.replace(/\n/g, ' ')
|
|
2485
|
+
`title: ${title.replace(/\n/g, ' ')}`,
|
|
2403
2486
|
`staged: ${new Date().toISOString()}`,
|
|
2404
2487
|
`id: ${id}`,
|
|
2405
2488
|
'---',
|
|
@@ -2432,15 +2515,33 @@ function cmdReceipts(args) {
|
|
|
2432
2515
|
document.split('\n').forEach((line, i) => {
|
|
2433
2516
|
if (!line.toLowerCase().includes(term.toLowerCase())) return
|
|
2434
2517
|
const source = decisionSources.get(i + 1) || sourceReference(line)
|
|
2435
|
-
const hit = ` ${file}:${i + 1} ${line.trim().slice(0, 160)}${source ? ` [source: ${source.slice(0, 160)}]` : ' [source missing]'}${dirty.has(file) ? ' dirty file - review manual edits' : ''}`
|
|
2436
|
-
;(recordFiles.includes(file) && source ? records : claims).push(hit)
|
|
2518
|
+
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' : ''}`
|
|
2519
|
+
;(recordFiles.includes(file) && source ? records : claims).push({ file, hit })
|
|
2437
2520
|
})
|
|
2438
2521
|
}
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2522
|
+
// Alternate the latest and earliest matching lines per file. Otherwise a
|
|
2523
|
+
// long history can spend the entire packet on approvals before a withdrawal.
|
|
2524
|
+
const select = hits => {
|
|
2525
|
+
const groups = new Map()
|
|
2526
|
+
for (const { file, hit } of hits) {
|
|
2527
|
+
if (!groups.has(file)) groups.set(file, [])
|
|
2528
|
+
groups.get(file).push(hit)
|
|
2529
|
+
}
|
|
2530
|
+
const selected = []
|
|
2531
|
+
let latest = true
|
|
2532
|
+
while (selected.length < 24 && [...groups.values()].some(group => group.length)) {
|
|
2533
|
+
for (const group of groups.values()) {
|
|
2534
|
+
if (group.length && selected.length < 24) selected.push(latest ? group.pop() : group.shift())
|
|
2535
|
+
}
|
|
2536
|
+
latest = !latest
|
|
2537
|
+
}
|
|
2538
|
+
return `Selected ${selected.length} of ${hits.length} matching lines; omitted matches require a narrower search.\n` + selected.join('\n')
|
|
2539
|
+
}
|
|
2540
|
+
const sections = ['RECEIPTS: a cited record is not proof of customer approval. File line numbers refer to the redacted view. Latest and earliest matching lines are sampled; file order is not authority. Check conflicting records.']
|
|
2541
|
+
if (records.length) sections.push('ON RECORD (dated, source-backed):\n' + select(records))
|
|
2542
|
+
if (claims.length) sections.push('CLAIMS & working notes (verify source and approval before citing):\n' + select(claims))
|
|
2442
2543
|
if (!records.length && !claims.length) sections.push(`no record of "${term}" - a gap in the record, not proof of absence`)
|
|
2443
|
-
process.stdout.write(
|
|
2544
|
+
process.stdout.write(maskedSections(sections))
|
|
2444
2545
|
}
|
|
2445
2546
|
|
|
2446
2547
|
// Portable snapshot; stdout is read-only. --out creates a new file and never
|
|
@@ -2468,12 +2569,13 @@ function cmdHandoff(args, label = 'Handoff') {
|
|
|
2468
2569
|
const decisionText = d => `${d.text} (decisions.md:${d.line}, redacted view)`
|
|
2469
2570
|
const next = stripTemplateNoise(sectionBody(readClean(eng, 'context.md'), 'Next action', { lastNonEmpty: true }))
|
|
2470
2571
|
const gaps = collectDoctorIssues(eng, { readiness: true })
|
|
2471
|
-
const report =
|
|
2572
|
+
const report = maskedSections([
|
|
2472
2573
|
`# ${label}: ${engagementSlugFromPath(eng)}\nSnapshot: ${new Date().toISOString()} · memory ${memoryHead(eng) || 'unversioned'}\nRead-only record, not proof of approval. Confirm sources with the named customer before relying on a claim. Private blocks are excluded; review remaining client information before sharing.`,
|
|
2473
2574
|
`## Constraints - trust-profile.md\n${stripTemplateNoise(readClean(eng, 'trust-profile.md')) || '(missing)'}`,
|
|
2474
2575
|
`## Signer and success - success.md\nSigner: ${signer || '(missing; do not infer)'}\n${success || '(missing)'}`,
|
|
2475
2576
|
`## Next action - context.md\n${next || '(missing)'}\n\n## Open risks - risks.md\n${extractRisks(eng).map(r => '- ' + r.text).join('\n') || '(none recorded; not proof of no risk)'}`,
|
|
2476
|
-
|
|
2577
|
+
label === 'Handoff' ? `## Operational handoff - handoff.md\n${stripTemplateNoise(readClean(eng, 'handoff.md')) || '(missing; record recovery steps and the operating owner before rotation)'}` : '',
|
|
2578
|
+
`## Accepted value - recorded assertion with source\nOnly structured value-ledger rows are summarized here; review other notes in delivery.md before presenting or handing over this record.\n${ledger.filter(r => r.state === 'accepted').map(rowText).join('\n') || '(none)'}\n\n## CLAIMS and unmeasured promises\n${ledger.filter(r => r.state !== 'accepted').map(r => rowText(r) + ' [' + r.state + ']' + (r.acceptanceIssue ? '; ' + r.acceptanceIssue : '')).join('\n') || '(none)'}`,
|
|
2477
2579
|
`## ON RECORD decisions - source supplied, not automatic approval\n${records.map(decisionText).join('\n') || '(none)'}\n\n## CLAIM decisions - source missing\n${claims.map(decisionText).join('\n') || '(none)'}\nSelected ${selected.length} of ${decisions.length} dated decisions. Retrieve older or conflicting decisions with fde recall.`,
|
|
2478
2580
|
`## Gaps before relying on this packet\n${gaps.map(g => '- ' + g).join('\n') || '(no deterministic lint gaps; human review still required)'}`,
|
|
2479
2581
|
], parsed.maxBytes)
|
|
@@ -2499,9 +2601,9 @@ function cmdRecall(args) {
|
|
|
2499
2601
|
}
|
|
2500
2602
|
const eng = resolveEngagement()
|
|
2501
2603
|
if (!eng) { console.error('no engagement - bind a client before recall'); process.exit(2) }
|
|
2502
|
-
const files = ['context.md', 'trust-profile.md', 'success.md', 'decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md', 'brief.md', 'reality.md', 'assumptions.md', 'terrain.md']
|
|
2503
|
-
const result = context.recallSections(files.map(file => ({ file, text: readClean(eng, file) })), query)
|
|
2504
|
-
process.stdout.write(
|
|
2604
|
+
const files = ['context.md', 'trust-profile.md', 'success.md', 'decisions.md', 'risks.md', 'delivery.md', 'stakeholders.md', 'brief.md', 'reality.md', 'assumptions.md', 'terrain.md', 'handoff.md']
|
|
2605
|
+
const result = context.recallSections(files.map(file => ({ file, text: readClean(eng, file) })), query, 12, masking.mask)
|
|
2606
|
+
process.stdout.write(maskedSections([
|
|
2505
2607
|
`RECALL - ${eng}\n${result.total ? `${result.sections.length} of ${result.total} matching lines; refine the query if evidence is omitted.` : 'No matching record. This is not proof that the event never happened.'}\nSources are local record assertions; verify dates, supersession and approval scope.`,
|
|
2506
2608
|
...result.sections,
|
|
2507
2609
|
], maxBytes))
|
|
@@ -2512,7 +2614,7 @@ function cmdCapture() {
|
|
|
2512
2614
|
if (!eng) process.exit(0) // silent: capture must never break a session
|
|
2513
2615
|
// Workspace git facts (cwd), not the engagement memory repo.
|
|
2514
2616
|
const branch = sh('git branch --show-current')
|
|
2515
|
-
const lastCommit = sh("git log -1 --format='%h %s'")
|
|
2617
|
+
const lastCommit = sh("git log -1 --format='%h %s'")
|
|
2516
2618
|
// porcelain lines are "XY path" - sh() trims, so parse by first whitespace
|
|
2517
2619
|
const changed = sh('git status --porcelain').split('\n').filter(Boolean).slice(0, 8)
|
|
2518
2620
|
.map(l => l.trim().split(/\s+/).slice(1).join(' ')).join(' ')
|
|
@@ -2729,12 +2831,20 @@ function successContractIssues(success) {
|
|
|
2729
2831
|
if (active !== -1 && line.trim()) checks[active] += ` ${line.trim()}`
|
|
2730
2832
|
}
|
|
2731
2833
|
const observable = checks.some(check => {
|
|
2834
|
+
// This is a lint check, not a semantic proof. Explicit fields let any
|
|
2835
|
+
// domain describe its test without depending on a vocabulary of verbs.
|
|
2836
|
+
const target = /(?:\b(?:within|under|at most|at least|exactly|zero|no missing|no duplicate|all|every|none|true|false|pass|fail|http)\b|[<>=])/i
|
|
2837
|
+
const vague = /\b(?:tbd|unknown|to be defined|improve|better|satisfactory|as expected|works well)\b/i
|
|
2838
|
+
if (vague.test(check)) return false
|
|
2839
|
+
const explicit = check.match(/(?:^|\s)(?:-\s*)?Input:\s*(.+?)\s+(?:-\s*)?Pass when:\s*(.+)$/i)
|
|
2840
|
+
if (explicit) return explicit[1].trim().length > 3 && target.test(explicit[2])
|
|
2732
2841
|
const stimulus = /\b(?:test|drill|replay|runs?|request|sample|given|when|simulate|inject|compare|restore|verified|observed|measured)\b/i.test(check)
|
|
2842
|
+
|| /^\d+\s+[a-z]/i.test(check)
|
|
2733
2843
|
const result = /\b(?:returns?|rejects?|matches?|equals?|arrives?|alerts?|restores?|passes?|fails?|contains?|produces?|shows?|remains?|receives?)\b/i.test(check)
|
|
2734
|
-
|
|
2735
|
-
return stimulus && result && target
|
|
2844
|
+
|| /\b(?:zero|no duplicate|no missing)\s+[a-z]/i.test(check)
|
|
2845
|
+
return stimulus && result && target.test(check)
|
|
2736
2846
|
})
|
|
2737
|
-
if (!observable) issues.push('success.md needs a binary acceptance check:
|
|
2847
|
+
if (!observable) issues.push('success.md needs a binary acceptance check: the wording was not recognized as a test/input and observable pass/fail result under **Done when:** or **Acceptance check:**. Use Input: and Pass when: for a domain-specific check; this lint does not prove readiness')
|
|
2738
2848
|
const signerLine = ((text.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').replace(/\[source:[^\]]+\]/gi, '').trim()
|
|
2739
2849
|
// A named primary signer may be followed by responsibilities or another
|
|
2740
2850
|
// signer's role. Preserve the full record; validate only the leading name.
|
|
@@ -2849,7 +2959,7 @@ function collectDoctorIssues(eng, { readiness = false } = {}) {
|
|
|
2849
2959
|
}
|
|
2850
2960
|
const dupes = findDuplicateOpenRisks(eng)
|
|
2851
2961
|
if (dupes.length) {
|
|
2852
|
-
const sample = (dupes[0][0] || '').replace(/\s+/g, ' ').trim().slice(0, 60)
|
|
2962
|
+
const sample = maskDisplay((dupes[0][0] || '').replace(/\s+/g, ' ').trim()).slice(0, 60)
|
|
2853
2963
|
issues.push(
|
|
2854
2964
|
`${dupes.length} duplicate open-risk cluster(s) (e.g. "${sample}${sample.length >= 60 ? '…' : ''}") - consolidate or retire echoes in risks.md`
|
|
2855
2965
|
)
|
|
@@ -2997,7 +3107,7 @@ function hasValueBucket(eng) {
|
|
|
2997
3107
|
}
|
|
2998
3108
|
|
|
2999
3109
|
// Shared classification keeps CLI, dashboard, and vault acceptance consistent.
|
|
3000
|
-
const { PENDING_CELL_RE, valueState, evidenceSource } = require('./lib/value-ledger')
|
|
3110
|
+
const { PENDING_CELL_RE, valueState, evidenceSource, reconcileValueRows } = require('./lib/value-ledger')
|
|
3001
3111
|
|
|
3002
3112
|
function parseValueLedger(eng) {
|
|
3003
3113
|
// Last section with actual rows, not merely the last non-empty one: a template
|
|
@@ -3027,9 +3137,9 @@ function parseValueLedger(eng) {
|
|
|
3027
3137
|
const evidence = cell(row, idx.evidence)
|
|
3028
3138
|
const acceptanceStatus = idx.acceptanceStatus === -1 ? undefined : cell(row, idx.acceptanceStatus)
|
|
3029
3139
|
const state = valueState({ measured, accepted, acceptanceStatus, evidence })
|
|
3030
|
-
rows.push({ slice, promised, measured, accepted, evidence, evidenceMissing: !evidenceSource(evidence), state })
|
|
3140
|
+
rows.push({ slice, promised, measured, accepted, acceptanceStatus, evidence, evidenceMissing: !evidenceSource(evidence), state })
|
|
3031
3141
|
}
|
|
3032
|
-
return { rows, columnMissing: idx.accepted === -1 }
|
|
3142
|
+
return { rows: reconcileValueRows(rows, readClean(eng, 'success.md')), columnMissing: idx.accepted === -1 }
|
|
3033
3143
|
}
|
|
3034
3144
|
|
|
3035
3145
|
function claimedValueRows(eng) {
|
|
@@ -3046,8 +3156,9 @@ function formatValueLedgerLine(r) {
|
|
|
3046
3156
|
}
|
|
3047
3157
|
const head = body ? `${name}: ${body}` : name
|
|
3048
3158
|
if (r.state === 'accepted') return `${head} · accepted by ${r.accepted}`
|
|
3049
|
-
|
|
3050
|
-
return `${head} · not yet
|
|
3159
|
+
const issue = r.acceptanceIssue ? `; ${r.acceptanceIssue}` : ''
|
|
3160
|
+
if (r.state === 'claimed') return `${head} · claimed, not yet accepted${issue}`
|
|
3161
|
+
return `${head} · not yet measured${issue}`
|
|
3051
3162
|
}
|
|
3052
3163
|
|
|
3053
3164
|
function valueLedgerStatusLines(eng, opts = {}) {
|
|
@@ -3124,7 +3235,7 @@ function hasEvalReceipt(eng) {
|
|
|
3124
3235
|
function hygieneTriageLines(eng) {
|
|
3125
3236
|
const issues = collectDoctorIssues(eng)
|
|
3126
3237
|
if (!issues.length) return []
|
|
3127
|
-
const top = issues[0].replace(/\s+/g, ' ').trim().slice(0, 72)
|
|
3238
|
+
const top = maskDisplay(issues[0].replace(/\s+/g, ' ').trim()).slice(0, 72)
|
|
3128
3239
|
return [
|
|
3129
3240
|
` hygiene: ${issues.length} issue(s) - ${top}${issues[0].length > 72 ? '…' : ''}`,
|
|
3130
3241
|
' → say "@fde clean up the fieldbook" when ready (agent runs fde doctor; nothing auto-rewrites), or: fde doctor',
|
|
@@ -3164,14 +3275,14 @@ function recordDigest(eng) {
|
|
|
3164
3275
|
const signer = ((success.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
3165
3276
|
// "(none)" rather than a missing line: on session start, nobody named to sign
|
|
3166
3277
|
// off is the fact worth seeing, not an absence to scroll past.
|
|
3167
|
-
const lines = [` signer: ${signer.slice(0, 110) || '(none)'}`]
|
|
3278
|
+
const lines = [` signer: ${masking.mask(signer).slice(0, 110) || '(none)'}`]
|
|
3168
3279
|
const { rows } = parseValueLedger(eng)
|
|
3169
3280
|
const promisedRow = [...rows].reverse().find(r => r.promised)
|
|
3170
3281
|
if (promisedRow) {
|
|
3171
|
-
lines.push(` promised: ${formatValueLedgerLine(promisedRow).slice(0, 110)}`)
|
|
3282
|
+
lines.push(` promised: ${masking.mask(formatValueLedgerLine(promisedRow)).slice(0, 110)}`)
|
|
3172
3283
|
} else {
|
|
3173
3284
|
const target = ((success.match(/^\*\*Baseline[^\S\n]*→[^\S\n]*target:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
3174
|
-
if (target) lines.push(` promised: ${target.slice(0, 110)}`)
|
|
3285
|
+
if (target) lines.push(` promised: ${masking.mask(target).slice(0, 110)}`)
|
|
3175
3286
|
}
|
|
3176
3287
|
const decisions = datedDecisions(readClean(eng, 'decisions.md')).slice(-2)
|
|
3177
3288
|
for (const d of decisions) lines.push(` decided: ${formatDecisionRecord(d.text)}; source: ${previewLine(sourceReference(d.text) || '(missing)', 100)}`)
|
|
@@ -3226,7 +3337,7 @@ function cmdRedact(args) {
|
|
|
3226
3337
|
}
|
|
3227
3338
|
console.log(`REDACT - ${hits.length} matching line(s) for ${JSON.stringify(term)}`)
|
|
3228
3339
|
hits.slice(0, 20).forEach(h => {
|
|
3229
|
-
const preview = h.line.length > 100 ? h.line.slice(0, 97) + '…' : h.line
|
|
3340
|
+
const preview = h.line.length > 100 ? masking.mask(h.line).slice(0, 97) + '…' : h.line
|
|
3230
3341
|
console.log(` ${h.file}:${h.lineNo} ${preview}`)
|
|
3231
3342
|
})
|
|
3232
3343
|
if (hits.length > 20) console.log(` … +${hits.length - 20} more`)
|
|
@@ -3271,12 +3382,12 @@ function cmdPrep(args) {
|
|
|
3271
3382
|
const people = extractStakeholders(eng).slice(0, 8)
|
|
3272
3383
|
console.log('\nStakeholders (table + signal history)')
|
|
3273
3384
|
if (!people.length) console.log(' (none yet - log contacts with --signal)')
|
|
3274
|
-
else people.forEach(p => console.log(` [${p.signal}] ${p.name}${p.role ? ` - ${p.role}` : ''}${p.note ? ` · ${p.note.slice(0, 60)}` : ''}`))
|
|
3385
|
+
else people.forEach(p => console.log(` [${p.signal}] ${p.name}${p.role ? ` - ${p.role}` : ''}${p.note ? ` · ${masking.mask(p.note).slice(0, 60)}` : ''}`))
|
|
3275
3386
|
|
|
3276
3387
|
const risks = extractRisks(eng).slice(0, 5)
|
|
3277
3388
|
console.log('\nOpen risks (table + dated bullets)')
|
|
3278
3389
|
if (!risks.length) console.log(' (none logged)')
|
|
3279
|
-
else risks.forEach(r => console.log(` [${r.severity}] ${r.text.slice(0, 100)}`))
|
|
3390
|
+
else risks.forEach(r => console.log(` [${r.severity}] ${masking.mask(r.text).slice(0, 100)}`))
|
|
3280
3391
|
|
|
3281
3392
|
const success = firstLine(readClean(eng, 'success.md'), 160)
|
|
3282
3393
|
console.log('\nSuccess looks like')
|
|
@@ -3287,7 +3398,7 @@ function cmdPrep(args) {
|
|
|
3287
3398
|
.slice(-5)
|
|
3288
3399
|
console.log('\nRecent decisions')
|
|
3289
3400
|
if (!decisions.length) console.log(' (none logged)')
|
|
3290
|
-
else decisions.forEach(l => console.log(` ${l.trim().slice(0, 120)}`))
|
|
3401
|
+
else decisions.forEach(l => console.log(` ${masking.mask(l.trim()).slice(0, 120)}`))
|
|
3291
3402
|
|
|
3292
3403
|
const next = nextActionLine(readClean(eng, 'context.md'))
|
|
3293
3404
|
console.log('\nWalk in with')
|
|
@@ -3327,7 +3438,7 @@ function cmdGarden(args) {
|
|
|
3327
3438
|
}
|
|
3328
3439
|
const dupes = findDuplicateOpenRisks(eng)
|
|
3329
3440
|
if (dupes.length) {
|
|
3330
|
-
const sample = (dupes[0][0] || '').replace(/\s+/g, ' ').trim().slice(0, 50)
|
|
3441
|
+
const sample = maskDisplay((dupes[0][0] || '').replace(/\s+/g, ' ').trim()).slice(0, 50)
|
|
3331
3442
|
proposals.push({
|
|
3332
3443
|
id: 'dedupe-risks',
|
|
3333
3444
|
kind: 'apply',
|
|
@@ -3507,7 +3618,7 @@ function cmdStatus(args) {
|
|
|
3507
3618
|
const eng = path.join(ENGAGEMENTS_ROOT, d, '.fde')
|
|
3508
3619
|
if (!fs.existsSync(eng)) continue
|
|
3509
3620
|
const s = computeSignals(eng)
|
|
3510
|
-
const note = [s.memoryWarn, (s.dirtyFiles && s.dirtyFiles.length) ? `dirty:${s.dirtyFiles.length}` : '', s.reason || s.topRisk].filter(Boolean).join(' · ').slice(0, 70)
|
|
3621
|
+
const note = maskDisplay([s.memoryWarn, (s.dirtyFiles && s.dirtyFiles.length) ? `dirty:${s.dirtyFiles.length}` : '', s.reason || s.topRisk].filter(Boolean).join(' · ')).slice(0, 70)
|
|
3511
3622
|
rows.push({ name: d, phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: note, memoryWarn: s.memoryWarn, dirtyFiles: s.dirtyFiles, valueLines: valueLedgerStatusLines(eng, { compact: true }) })
|
|
3512
3623
|
}
|
|
3513
3624
|
} else {
|
|
@@ -3517,7 +3628,7 @@ function cmdStatus(args) {
|
|
|
3517
3628
|
process.exit(2)
|
|
3518
3629
|
}
|
|
3519
3630
|
const s = computeSignals(eng)
|
|
3520
|
-
const note = [s.memoryWarn, (s.dirtyFiles && s.dirtyFiles.length) ? `dirty:${s.dirtyFiles.length}` : '', s.reason || s.topRisk].filter(Boolean).join(' · ').slice(0, 70)
|
|
3631
|
+
const note = maskDisplay([s.memoryWarn, (s.dirtyFiles && s.dirtyFiles.length) ? `dirty:${s.dirtyFiles.length}` : '', s.reason || s.topRisk].filter(Boolean).join(' · ')).slice(0, 70)
|
|
3521
3632
|
rows.push({ name: engagementSlugFromPath(eng), phase: s.phase, trust: s.trust, signalAge: s.signalAge, stale: s.stale, updated: s.updated, reason: note, memoryWarn: s.memoryWarn, dirtyFiles: s.dirtyFiles, valueLines: valueLedgerStatusLines(eng) })
|
|
3522
3633
|
}
|
|
3523
3634
|
if (!rows.length) { console.log('no engagements yet'); return }
|
|
@@ -3991,6 +4102,7 @@ ${fs.existsSync(html) ? `\n Open the fieldbook: ${html}` : ''}
|
|
|
3991
4102
|
function printUsage() {
|
|
3992
4103
|
console.log(`fde - deterministic core of fdeops
|
|
3993
4104
|
fde demo the whole loop on a fake client (fde demo --clean removes it)
|
|
4105
|
+
fde privacy show masking capability and its boundaries
|
|
3994
4106
|
fde scan day-1 recon of this repo (facts, no AI)
|
|
3995
4107
|
fde resume load this workspace's engagement memory (bounded)
|
|
3996
4108
|
fde resume --full load the complete context.md (no bound)
|
|
@@ -4003,6 +4115,8 @@ function printUsage() {
|
|
|
4003
4115
|
fde log --undo remove the last CLI log/debrief entry from memory
|
|
4004
4116
|
fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
|
|
4005
4117
|
fde debrief --smart heuristic propose; REVIEW first (decided/asked/open/next/signer); --apply after one confirm
|
|
4118
|
+
--review inspect the pending REVIEW after editing, without replacing it
|
|
4119
|
+
--allow-replay explicitly apply already recorded sourced statements after review
|
|
4006
4120
|
--replace-proposal explicitly discard a pending review when proposing different notes
|
|
4007
4121
|
fde ingest stage … stage raw pull into <engagement>/.inbox/ (not .fde/)
|
|
4008
4122
|
fde ingest list list staged inbox items
|
|
@@ -4027,12 +4141,32 @@ function printUsage() {
|
|
|
4027
4141
|
ingest is a sink only - source MCPs (Granola/Gmail/…) are user-configured; never ambient sync`)
|
|
4028
4142
|
}
|
|
4029
4143
|
|
|
4030
|
-
const [cmd, ...
|
|
4144
|
+
const [cmd, ...rawArgs] = process.argv.slice(2)
|
|
4145
|
+
let outputBudget
|
|
4146
|
+
if (['resume', 'recall', 'handoff', 'defend'].includes(cmd) && !rawArgs.some(a => ['--full', '--init', '--bind', '--out'].includes(a))) {
|
|
4147
|
+
try { outputBudget = context.budgetArgs(rawArgs).maxBytes } catch (_) {}
|
|
4148
|
+
}
|
|
4149
|
+
require('./lib/masking').protectOutput(masking, { maxBytes: outputBudget })
|
|
4150
|
+
let args
|
|
4151
|
+
try {
|
|
4152
|
+
args = rawArgs.map(arg => masking.restore(arg))
|
|
4153
|
+
for (const key of ['FDEOPS_ENGAGEMENT', 'FDEOS_ENGAGEMENT']) {
|
|
4154
|
+
if (process.env[key]) process.env[key] = masking.restore(process.env[key])
|
|
4155
|
+
}
|
|
4156
|
+
// Resolve privacy-state failures before a user-authorized argument write.
|
|
4157
|
+
args.forEach(arg => masking.mask(arg))
|
|
4158
|
+
}
|
|
4159
|
+
catch (e) { console.error(e.message); process.exit(1) }
|
|
4031
4160
|
if (args.includes('--help') || args.includes('-h') || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
4032
4161
|
printUsage()
|
|
4033
4162
|
process.exit(0)
|
|
4034
4163
|
}
|
|
4164
|
+
try {
|
|
4035
4165
|
switch (cmd) {
|
|
4166
|
+
case 'privacy':
|
|
4167
|
+
if (args.length) { console.error('usage: fde privacy'); process.exitCode = 2; break }
|
|
4168
|
+
console.log(`FDEOps ${require('../package.json').version} - identifier masking enabled by default.\nCLI responses, smart proposals, handoff packets and ingest MCP results use local aliases.\nPatterns: common emails, international/US phones, SSN-shaped identifiers and supported credentials.\nNames and arbitrary sensitive prose are not detected; mark them <private>.\nRaw files, pasted chat, upstream MCP content and local dashboard/vault files bypass this protection.`)
|
|
4169
|
+
break
|
|
4036
4170
|
case 'demo': cmdDemo(args); break
|
|
4037
4171
|
case 'scan': cmdScan(); break
|
|
4038
4172
|
case 'resume': cmdResume(args); break
|
|
@@ -4066,3 +4200,8 @@ switch (cmd) {
|
|
|
4066
4200
|
// Missing or unknown command must fail - exit 0 made typos look like success in scripts/hooks.
|
|
4067
4201
|
process.exit(1)
|
|
4068
4202
|
}
|
|
4203
|
+
|
|
4204
|
+
} catch (error) {
|
|
4205
|
+
console.error(error && error.message ? error.message : "command failed")
|
|
4206
|
+
process.exitCode = 1
|
|
4207
|
+
}
|
package/bin/lib/context.js
CHANGED
|
@@ -45,7 +45,7 @@ function boundedSections(sections, maxBytes = DEFAULT_BYTES) {
|
|
|
45
45
|
|
|
46
46
|
// Literal, client-scoped lexical retrieval. Retain independently matching
|
|
47
47
|
// records, including conflicting/older ones; recency never means truth.
|
|
48
|
-
function recallSections(documents, query, maxHits = 12) {
|
|
48
|
+
function recallSections(documents, query, maxHits = 12, outputText = text => text) {
|
|
49
49
|
const words = [...new Set(query.toLocaleLowerCase().split(/\s+/).filter(Boolean))].slice(0, 16)
|
|
50
50
|
const hits = []
|
|
51
51
|
for (const { file, text } of documents) {
|
|
@@ -57,7 +57,7 @@ function recallSections(documents, query, maxHits = 12) {
|
|
|
57
57
|
if (!score) continue
|
|
58
58
|
const first = Math.max(0, i - 1)
|
|
59
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')
|
|
60
|
+
const excerpt = lines.slice(first, last).map(outputText).map(l => Buffer.byteLength(l) <= 1024 ? l : clipUtf8(l, 900) + OMITTED).join('\n')
|
|
61
61
|
hits.push({ file, line: first + 1, end: last, score, text: excerpt.trim() })
|
|
62
62
|
}
|
|
63
63
|
}
|