fdeops 3.26.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 +73 -159
- package/bin/fde.js +212 -99
- package/bin/install.js +1 -1
- package/bin/lib/delivery-gaps.js +34 -0
- package/bin/lib/provenance.js +42 -0
- package/bin/lib/render.js +16 -30
- package/bin/lib/value-ledger.js +8 -2
- package/mcp/fdeops-ingest/package.json +1 -1
- package/package.json +1 -1
- 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/fde.js
CHANGED
|
@@ -36,6 +36,8 @@ 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')
|
|
40
|
+
const { deliverySummary } = require('./lib/delivery-gaps')
|
|
39
41
|
|
|
40
42
|
const HOME = os.homedir()
|
|
41
43
|
// FDEOPS_ENGAGEMENTS_ROOT isolates init/status/dashboard (and the registry) for
|
|
@@ -1396,7 +1398,7 @@ function cmdResume(args) {
|
|
|
1396
1398
|
console.log(`NO ENGAGEMENT for this workspace.\nexisting: ${list}\nAsk the human the client name (one question), then run: fde resume --init <client-name>\nDo not tell them to type that command.`)
|
|
1397
1399
|
process.exit(2)
|
|
1398
1400
|
}
|
|
1399
|
-
const intro = [resumeTriage(eng), ...hygieneTriageLines(eng), ...recordDigest(eng)].join('\n')
|
|
1401
|
+
const intro = [resumeTriage(eng), firstActionLine(eng), ...hygieneTriageLines(eng), ...recordDigest(eng)].join('\n')
|
|
1400
1402
|
const ctx = readClean(eng, 'context.md')
|
|
1401
1403
|
if (args.includes('--full')) {
|
|
1402
1404
|
console.log(`${intro}\n\nENGAGEMENT: ${eng}\n\n${ctx || '(no context.md yet)'}`)
|
|
@@ -1408,10 +1410,11 @@ function cmdResume(args) {
|
|
|
1408
1410
|
const success = readClean(eng, 'success.md')
|
|
1409
1411
|
const risks = readClean(eng, 'risks.md')
|
|
1410
1412
|
process.stdout.write(context.boundedSections([
|
|
1411
|
-
`${intro}\n\nENGAGEMENT: ${eng}`,
|
|
1412
1413
|
policy ? `CLIENT POLICY - trust-profile.md\n${policy}` : '',
|
|
1414
|
+
`${intro}\n\nENGAGEMENT: ${eng}`,
|
|
1413
1415
|
success ? `CURRENT GOALS & ACCEPTANCE - success.md\n${success}` : '',
|
|
1414
|
-
risks ? `
|
|
1416
|
+
risks ? `OPEN RISKS - risks.md\n${extractRisks(eng).map(r => r.text).join('\n') || '(none recorded)'}` : '',
|
|
1417
|
+
`VALUE LEDGER - delivery.md\n${parseValueLedger(eng).rows.map(r => formatValueLedgerLine(r) + '; source: ' + (r.evidence || '(missing)')).join('\n') || '(none recorded)'}`,
|
|
1415
1418
|
`WORKING CONTEXT - context.md\n${ctx ? resumeView(ctx) : '(no context.md yet)'}`,
|
|
1416
1419
|
], maxBytes))
|
|
1417
1420
|
}
|
|
@@ -1631,7 +1634,10 @@ function smartProposeText(input) {
|
|
|
1631
1634
|
// the original stays as context, so nothing is invented or lost.
|
|
1632
1635
|
for (const sentence of bare.split(/(?<=[.!?])\s+/)) {
|
|
1633
1636
|
const who = signerFromLine(sentence)
|
|
1634
|
-
if (who) {
|
|
1637
|
+
if (who) {
|
|
1638
|
+
const source = (bare.match(/\[source:\s*[^\]]+\]/i) || [])[0] || ''
|
|
1639
|
+
out.push(`signer: ${who}${source ? ` ${source}` : ''}`); break
|
|
1640
|
+
}
|
|
1635
1641
|
}
|
|
1636
1642
|
if (/^(next action|follow-?ups?|action items?|todo):\s*/i.test(bare) ||
|
|
1637
1643
|
/\b(next action|walk in with|follow up with)\b/i.test(bare)) {
|
|
@@ -1639,6 +1645,14 @@ function smartProposeText(input) {
|
|
|
1639
1645
|
out.push(`next: ${next}`)
|
|
1640
1646
|
continue
|
|
1641
1647
|
}
|
|
1648
|
+
if (/^(?:scope|proposed scope|out of scope):\s*/i.test(bare)) {
|
|
1649
|
+
out.push(`scope: ${bare}`)
|
|
1650
|
+
continue
|
|
1651
|
+
}
|
|
1652
|
+
if (/\b(?:we need|we want|customer asks?|customer asked|please|can you|could you)\b/i.test(bare)) {
|
|
1653
|
+
out.push(`ask: ${bare}`)
|
|
1654
|
+
continue
|
|
1655
|
+
}
|
|
1642
1656
|
if (/\b(we (decided|agreed)|decided:|decision:|descope|agreed to|agreement was|freeze scope|freeze prompts)\b/i.test(bare)) {
|
|
1643
1657
|
out.push(`decision: ${bare}`)
|
|
1644
1658
|
} else if (/\b(open question|who signs|unclear who|unresolved)\b/i.test(bare)) {
|
|
@@ -1674,7 +1688,7 @@ function looksLikePersonName(s) {
|
|
|
1674
1688
|
|
|
1675
1689
|
function signerFromLine(text) {
|
|
1676
1690
|
const t = String(text || '').replace(/^[-*+]\s+/, '').trim()
|
|
1677
|
-
if (!t) return ''
|
|
1691
|
+
if (!t || /\?|\b(?:not|nobody|unclear|maybe|might|whether|could|should|if|unless|pending|unconfirmed)\b/i.test(t)) return ''
|
|
1678
1692
|
// "Priya (VP Eng) signs off" → Priya. "Finance controller (Helena) signs off" → Helena.
|
|
1679
1693
|
const titled = t.match(new RegExp('\\b' + SIGNER_NAME + '\\s+\\(' + SIGNER_NAME + '\\)\\s+' + SIGNER_VERB + '\\b'))
|
|
1680
1694
|
if (titled) {
|
|
@@ -1844,12 +1858,12 @@ function stripApprovedStamp(text) {
|
|
|
1844
1858
|
|
|
1845
1859
|
// One screen a human can confirm in two minutes. The file-by-file routing
|
|
1846
1860
|
// still prints after this - agents edit prefixes; people read this.
|
|
1847
|
-
function printDebriefReview(text) {
|
|
1848
|
-
const buckets = { decided: [], asked: [], open: [], next: [], signer: [] }
|
|
1861
|
+
function printDebriefReview(text, eng) {
|
|
1862
|
+
const buckets = { decided: [], asked: [], scope: [], delivery: [], open: [], next: [], signer: [] }
|
|
1849
1863
|
for (const raw of String(text || '').split('\n')) {
|
|
1850
1864
|
const line = raw.trim().replace(/^[-*+]\s+/, '')
|
|
1851
1865
|
if (!line) continue
|
|
1852
|
-
const m = line.match(/^(decision|risk|delivery|contact|next|signer):\s*(.+)$/i)
|
|
1866
|
+
const m = line.match(/^(decision|risk|delivery|contact|next|signer|ask|scope):\s*(.+)$/i)
|
|
1853
1867
|
if (!m) continue
|
|
1854
1868
|
const type = m[1].toLowerCase()
|
|
1855
1869
|
const body = m[2]
|
|
@@ -1857,8 +1871,12 @@ function printDebriefReview(text) {
|
|
|
1857
1871
|
const who = approvedStamp(body)
|
|
1858
1872
|
const core = previewLine(stripApprovedStamp(body), 90)
|
|
1859
1873
|
buckets.decided.push(who ? `${core} (approved ${who})` : `${core} (unconfirmed)`)
|
|
1860
|
-
} else if (type === '
|
|
1874
|
+
} else if (type === 'ask') {
|
|
1861
1875
|
buckets.asked.push(previewLine(body, 100))
|
|
1876
|
+
} else if (type === 'scope') {
|
|
1877
|
+
buckets.scope.push(previewLine(body, 100))
|
|
1878
|
+
} else if (type === 'delivery') {
|
|
1879
|
+
buckets.delivery.push(previewLine(body, 100))
|
|
1862
1880
|
} else if (type === 'risk') {
|
|
1863
1881
|
buckets.open.push(previewLine(body, 100))
|
|
1864
1882
|
} else if (type === 'next') {
|
|
@@ -1870,20 +1888,33 @@ function printDebriefReview(text) {
|
|
|
1870
1888
|
console.log('REVIEW (one screen - confirm once, then apply)\n')
|
|
1871
1889
|
const order = [
|
|
1872
1890
|
['decided', buckets.decided],
|
|
1873
|
-
['
|
|
1891
|
+
['stated asks', buckets.asked],
|
|
1892
|
+
['proposed scope', buckets.scope],
|
|
1893
|
+
['reported delivery (not customer acceptance)', buckets.delivery],
|
|
1874
1894
|
['open', buckets.open],
|
|
1875
|
-
['next', buckets.next],
|
|
1876
|
-
['signer', buckets.signer],
|
|
1895
|
+
['next action', buckets.next],
|
|
1896
|
+
['named signer (authority, not approval)', buckets.signer],
|
|
1877
1897
|
]
|
|
1878
1898
|
let any = false
|
|
1879
1899
|
for (const [label, items] of order) {
|
|
1880
|
-
if (!items.length)
|
|
1900
|
+
if (!items.length) {
|
|
1901
|
+
if (['stated asks', 'proposed scope', 'next action', 'named signer (authority, not approval)'].includes(label)) console.log(` ${label}: not stated`)
|
|
1902
|
+
continue
|
|
1903
|
+
}
|
|
1881
1904
|
any = true
|
|
1882
1905
|
console.log(` ${label}:`)
|
|
1883
1906
|
for (const item of items) console.log(` - ${item}`)
|
|
1884
1907
|
}
|
|
1885
1908
|
if (!any) console.log(' (nothing prefixed yet - edit .debrief-propose, then apply)')
|
|
1886
|
-
|
|
1909
|
+
const recorded = eng ? parseValueLedger(eng).rows : []
|
|
1910
|
+
const measured = recorded.some(row => row.state !== 'unmeasured')
|
|
1911
|
+
const evidenced = recorded.some(row => !row.evidenceMissing)
|
|
1912
|
+
const accepted = recorded.some(row => row.state === 'accepted')
|
|
1913
|
+
console.log(' delivery picture (existing record; this proposal does not certify it):')
|
|
1914
|
+
console.log(` - measurement: ${measured ? 'recorded; check the value ledger' : 'missing - record the observed result'}`)
|
|
1915
|
+
console.log(` - evidence: ${evidenced ? 'recorded; review its source' : 'missing - cite the test, artifact, or source'}`)
|
|
1916
|
+
console.log(` - customer approval: ${accepted ? 'recorded for a prior outcome; not this proposal' : 'missing - request explicit acceptance after evidence review'}`)
|
|
1917
|
+
console.log(' Saving this update confirms your record, not customer acceptance.\n')
|
|
1887
1918
|
}
|
|
1888
1919
|
|
|
1889
1920
|
function latestDatedDecision(md) {
|
|
@@ -1901,8 +1932,8 @@ function formatDecisionRecord(line) {
|
|
|
1901
1932
|
const raw = String(line || '').trim().replace(/^[-*]\s*/, '')
|
|
1902
1933
|
const who = approvedStamp(raw)
|
|
1903
1934
|
const core = stripApprovedStamp(raw)
|
|
1904
|
-
const stamp = who ? `(approved ${who})` : '(unconfirmed)'
|
|
1905
|
-
return
|
|
1935
|
+
const stamp = !hasSource(raw) ? '(CLAIM - source missing)' : who ? `(approved ${who}; source recorded)` : '(unconfirmed; source recorded)'
|
|
1936
|
+
return `${stamp} ${previewLine(core, 110)}`
|
|
1906
1937
|
}
|
|
1907
1938
|
|
|
1908
1939
|
function changeReviewIssues(eng) {
|
|
@@ -2051,7 +2082,7 @@ function cmdDebrief(args) {
|
|
|
2051
2082
|
if (smart) {
|
|
2052
2083
|
const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
|
|
2053
2084
|
console.log('SMART PROPOSE (heuristic - review before apply; no new facts invented beyond line rewrites)\n')
|
|
2054
|
-
printDebriefReview(clean)
|
|
2085
|
+
printDebriefReview(clean, eng)
|
|
2055
2086
|
console.log('Prefix vocabulary (lines that route): decision: risk: delivery: contact: next: signer:')
|
|
2056
2087
|
console.log('Optional on a decision: [approved: Name YYYY-MM-DD]. Missing means unconfirmed.')
|
|
2057
2088
|
console.log('Everything else → context.md. Keep the prefixes; the preview gate stays.\n')
|
|
@@ -2195,7 +2226,7 @@ function cmdIngest(args) {
|
|
|
2195
2226
|
}
|
|
2196
2227
|
const { proposePath, clean, blocks } = writeProposal(eng, smartProposeText(input))
|
|
2197
2228
|
console.log(`INGEST PROPOSE from ${path.basename(item)} (via:${source})\n`)
|
|
2198
|
-
printDebriefReview(clean)
|
|
2229
|
+
printDebriefReview(clean, eng)
|
|
2199
2230
|
routeDebriefInput(eng, clean, { dry: true, force: false, sealed: blocks })
|
|
2200
2231
|
console.log(`\nproposal saved → ${proposePath}`)
|
|
2201
2232
|
console.log('confirm: fde ingest apply')
|
|
@@ -2269,60 +2300,80 @@ function cmdIngest(args) {
|
|
|
2269
2300
|
}
|
|
2270
2301
|
|
|
2271
2302
|
function cmdReceipts(args) {
|
|
2272
|
-
const term = args.join(' ')
|
|
2273
|
-
if (!term) { console.error('usage: fde receipts <search term>'); process.exit(
|
|
2303
|
+
const term = args.join(' ').trim()
|
|
2304
|
+
if (!term) { console.error('usage: fde receipts <search term>'); process.exit(2) }
|
|
2274
2305
|
const eng = resolveEngagement()
|
|
2275
|
-
if (!eng) { console.error('no engagement -
|
|
2276
|
-
const
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
if (rx.test(l)) hits.push(` ${f}:${i + 1} ${l.trim().slice(0, 160)}`)
|
|
2293
|
-
})
|
|
2294
|
-
}
|
|
2295
|
-
return hits
|
|
2296
|
-
}
|
|
2297
|
-
const agreed = collect(AGREEMENTS)
|
|
2298
|
-
const claimed = collect(CLAIMS)
|
|
2299
|
-
const dirty = memoryDirtyManual(eng)
|
|
2300
|
-
const dirtySet = new Set(dirty)
|
|
2301
|
-
const dirtyAgreedHits = [...new Set(
|
|
2302
|
-
agreed.map(h => (h.match(/^\s*([^:]+):/) || [])[1]).filter(f => f && dirtySet.has(f))
|
|
2303
|
-
)]
|
|
2304
|
-
if (agreed.length) {
|
|
2305
|
-
console.log('ON RECORD (dated):')
|
|
2306
|
-
agreed.forEach(h => {
|
|
2307
|
-
const file = (h.match(/^\s*([^:]+):/) || [])[1]
|
|
2308
|
-
console.log(h + (file && dirtySet.has(file) ? ' ⚠ dirty file' : ''))
|
|
2306
|
+
if (!eng) { console.error('no engagement - bind a client first'); process.exit(2) }
|
|
2307
|
+
const recordFiles = ['decisions.md', 'delivery.md', 'success.md', 'risks.md', 'stakeholders.md']
|
|
2308
|
+
const workingFiles = ['brief.md', 'assumptions.md', 'reality.md', 'context.md']
|
|
2309
|
+
const dirty = new Set(memoryDirtyManual(eng))
|
|
2310
|
+
const records = [], claims = []
|
|
2311
|
+
for (const file of [...recordFiles, ...workingFiles]) {
|
|
2312
|
+
const document = readClean(eng, file)
|
|
2313
|
+
const decisionSources = new Map()
|
|
2314
|
+
if (file === 'decisions.md') for (const entry of datedDecisions(document)) {
|
|
2315
|
+
const source = sourceReference(entry.text)
|
|
2316
|
+
for (let line = entry.line; line < entry.line + entry.text.split('\n').length; line++) decisionSources.set(line, source)
|
|
2317
|
+
}
|
|
2318
|
+
document.split('\n').forEach((line, i) => {
|
|
2319
|
+
if (!line.toLowerCase().includes(term.toLowerCase())) return
|
|
2320
|
+
const source = decisionSources.get(i + 1) || sourceReference(line)
|
|
2321
|
+
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' : ''}`
|
|
2322
|
+
;(recordFiles.includes(file) && source ? records : claims).push(hit)
|
|
2309
2323
|
})
|
|
2310
|
-
if (dirtyAgreedHits.length) {
|
|
2311
|
-
console.log(
|
|
2312
|
-
`⚠ memory dirty (uncommitted manual edits: ${dirtyAgreedHits.join(', ')}) - dated lines above may not match the tamper-evident ledger until reviewed`
|
|
2313
|
-
)
|
|
2314
|
-
}
|
|
2315
2324
|
}
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
+
const sections = ['RECEIPTS: a cited record is not proof of customer approval. File line numbers refer to the redacted view.']
|
|
2326
|
+
if (records.length) sections.push('ON RECORD (dated, source-backed):\n' + records.join('\n'))
|
|
2327
|
+
if (claims.length) sections.push('CLAIMS & working notes (verify source and approval before citing):\n' + claims.join('\n'))
|
|
2328
|
+
if (!records.length && !claims.length) sections.push(`no record of "${term}" - a gap in the record, not proof of absence`)
|
|
2329
|
+
process.stdout.write(context.boundedSections(sections))
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// Portable snapshot; stdout is read-only. --out creates a new file and never
|
|
2333
|
+
// overwrites an engagement record, existing file, or symlink.
|
|
2334
|
+
function cmdHandoff(args, label = 'Handoff') {
|
|
2335
|
+
let parsed
|
|
2336
|
+
try { parsed = context.budgetArgs(args) } catch (e) { console.error(e.message); process.exit(1) }
|
|
2337
|
+
let out = ''
|
|
2338
|
+
if (parsed.args.length) {
|
|
2339
|
+
if (parsed.args.length !== 2 || parsed.args[0] !== '--out' || !parsed.args[1] || parsed.args[1].startsWith('--')) {
|
|
2340
|
+
console.error('usage: fde handoff [--out new-file.md] [--max-bytes 4096..65536]'); process.exit(1)
|
|
2341
|
+
}
|
|
2342
|
+
out = path.resolve(parsed.args[1])
|
|
2325
2343
|
}
|
|
2344
|
+
const eng = resolveEngagement()
|
|
2345
|
+
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
2346
|
+
const success = stripTemplateNoise(readClean(eng, 'success.md'))
|
|
2347
|
+
const signer = ((success.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
2348
|
+
const ledger = parseValueLedger(eng).rows
|
|
2349
|
+
const rowText = r => `- ${r.slice || 'Unnamed slice'}: promised ${r.promised || '(missing)'}; measured ${r.measured || '(missing)'}; accepted by ${r.accepted || '(missing)'}; evidence ${r.evidence || '(missing)'}`
|
|
2350
|
+
const decisions = datedDecisions(readClean(eng, 'decisions.md'))
|
|
2351
|
+
const selected = decisions.slice(-8)
|
|
2352
|
+
const records = selected.filter(d => hasSource(d.text))
|
|
2353
|
+
const claims = selected.filter(d => !hasSource(d.text))
|
|
2354
|
+
const decisionText = d => `${d.text} (decisions.md:${d.line}, redacted view)`
|
|
2355
|
+
const next = stripTemplateNoise(sectionBody(readClean(eng, 'context.md'), 'Next action', { lastNonEmpty: true }))
|
|
2356
|
+
const gaps = collectDoctorIssues(eng)
|
|
2357
|
+
const report = context.boundedSections([
|
|
2358
|
+
`# ${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.`,
|
|
2359
|
+
`## Constraints - trust-profile.md\n${stripTemplateNoise(readClean(eng, 'trust-profile.md')) || '(missing)'}`,
|
|
2360
|
+
`## Signer and success - success.md\nSigner: ${signer || '(missing; do not infer)'}\n${success || '(missing)'}`,
|
|
2361
|
+
`## 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)'}`,
|
|
2362
|
+
`## Accepted value - recorded assertion with source\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 + ']').join('\n') || '(none)'}`,
|
|
2363
|
+
`## 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.`,
|
|
2364
|
+
`## Gaps before relying on this packet\n${gaps.map(g => '- ' + g).join('\n') || '(no deterministic lint gaps; human review still required)'}`,
|
|
2365
|
+
], parsed.maxBytes)
|
|
2366
|
+
if (!out) { process.stdout.write(report); return }
|
|
2367
|
+
try {
|
|
2368
|
+
// Exclusive creation fails closed for files and links. Resolve the parent
|
|
2369
|
+
// first so a directory link cannot redirect an export into .fde/.
|
|
2370
|
+
const parent = fs.realpathSync(path.dirname(out))
|
|
2371
|
+
const target = path.join(parent, path.basename(out))
|
|
2372
|
+
const root = fs.realpathSync(eng)
|
|
2373
|
+
if (parent.split(path.sep).includes('.fde') || target === root || target.startsWith(root + path.sep)) throw new Error('export outside .fde/; engagement records are not export destinations')
|
|
2374
|
+
fs.writeFileSync(target, report, { flag: 'wx', mode: 0o600 })
|
|
2375
|
+
console.log(`${label.toLowerCase()} → ${out}`)
|
|
2376
|
+
} catch (e) { console.error(`could not export packet: ${e.message}`); process.exit(1) }
|
|
2326
2377
|
}
|
|
2327
2378
|
|
|
2328
2379
|
function cmdRecall(args) {
|
|
@@ -2552,7 +2603,33 @@ function silentCommitIssues(eng) {
|
|
|
2552
2603
|
// Deterministic fieldbook hygiene - shared by doctor + session TRIAGE.
|
|
2553
2604
|
// Silent when clean OR brand-new (no dated work yet). Never auto-rewrites.
|
|
2554
2605
|
// High-value moments: week-start (via triage), ship/close, after real work accrues.
|
|
2555
|
-
function
|
|
2606
|
+
function successContractIssues(success) {
|
|
2607
|
+
const issues = []
|
|
2608
|
+
const text = stripTemplateNoise(String(success || ''))
|
|
2609
|
+
const checks = []
|
|
2610
|
+
let active = -1
|
|
2611
|
+
for (const line of text.split('\n')) {
|
|
2612
|
+
const header = line.match(/^(?:\*\*)?(?:Done when|Acceptance check)(?:\s*\([^\n)]*\))?:(?:\*\*)?[^\S\n]*(.*)$/i)
|
|
2613
|
+
if (header) { checks.push(header[1].trim()); active = checks.length - 1; continue }
|
|
2614
|
+
if (/^#{1,6}\s|^\*\*[^*]+:/.test(line)) { active = -1; continue }
|
|
2615
|
+
if (active !== -1 && line.trim()) checks[active] += ` ${line.trim()}`
|
|
2616
|
+
}
|
|
2617
|
+
const observable = checks.some(check => {
|
|
2618
|
+
const stimulus = /\b(?:test|drill|replay|runs?|request|sample|given|when|simulate|inject|compare|restore|verified|observed|measured)\b/i.test(check)
|
|
2619
|
+
const result = /\b(?:returns?|rejects?|matches?|equals?|arrives?|alerts?|restores?|passes?|fails?|contains?|produces?|shows?|remains?|receives?)\b/i.test(check)
|
|
2620
|
+
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.test(check)
|
|
2621
|
+
return stimulus && result && target && !/\b(?:tbd|to be defined|improve|better|satisfactory|as expected|works well)\b/i.test(check)
|
|
2622
|
+
})
|
|
2623
|
+
if (!observable) issues.push('success.md needs a binary acceptance check: state a test/input and an observable pass/fail result under **Done when:** or **Acceptance check:**; numbers alone are not a check')
|
|
2624
|
+
const signerLine = ((text.match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').replace(/\[source:[^\]]+\]/gi, '').trim()
|
|
2625
|
+
// A named primary signer may be followed by responsibilities or another
|
|
2626
|
+
// signer's role. Preserve the full record; validate only the leading name.
|
|
2627
|
+
const signer = (signerLine.match(/^((?:[A-Z]\.|[A-Z][\w'-]+)(?:\s+(?:[A-Z]\.|[A-Z][\w'-]+)){0,2})(?=\s*(?:[.,;:]|\(|$))/) || [])[1] || ''
|
|
2628
|
+
if (!looksLikePersonName(signer) || /\b(?:pending|unknown|tbd|nobody|none|unassigned|unconfirmed)\b/i.test(signer)) issues.push('success.md needs a named customer-side signer under **Stakeholder who signs off:**; a team, role, or pending name is not authority')
|
|
2629
|
+
return issues
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
function collectDoctorIssues(eng, { readiness = false } = {}) {
|
|
2556
2633
|
const issues = []
|
|
2557
2634
|
const s = computeSignals(eng)
|
|
2558
2635
|
// stripTemplateNoise: a dated example inside a template comment is not work.
|
|
@@ -2577,7 +2654,7 @@ function collectDoctorIssues(eng) {
|
|
|
2577
2654
|
issues.push(`${f} is not a regular file - reads come back empty and every write fails; remove it and re-run any fde write`)
|
|
2578
2655
|
}
|
|
2579
2656
|
}
|
|
2580
|
-
if (fresh) return issues
|
|
2657
|
+
if (fresh && !readiness) return issues
|
|
2581
2658
|
|
|
2582
2659
|
if (s.phase === '?' || s.phase === 'unset') {
|
|
2583
2660
|
if (hasDatedWork) {
|
|
@@ -2609,7 +2686,11 @@ function collectDoctorIssues(eng) {
|
|
|
2609
2686
|
issues.push(`${file} has ${unclosed} unclosed <private> - everything after it is sealed, including notes added later`)
|
|
2610
2687
|
}
|
|
2611
2688
|
}
|
|
2689
|
+
const { hasSource } = require('./lib/provenance')
|
|
2690
|
+
const unsourced = readClean(eng, 'decisions.md').split('\n').filter(line => /^[-*]\s*\[\d{4}-\d{2}-\d{2}\]/.test(line.trim()) && !hasSource(line))
|
|
2691
|
+
if (unsourced.length) issues.push(`${unsourced.length} dated decision(s) remain CLAIM: source missing - add the actual meeting, transcript, email, or artifact reference; a log date is not evidence`)
|
|
2612
2692
|
const success = readClean(eng, 'success.md')
|
|
2693
|
+
if (readiness || /^(plan|ship|outcome|close)$/.test(s.phase)) issues.push(...successContractIssues(success))
|
|
2613
2694
|
if (!firstLine(success, 80)) issues.push('success.md has no stated done-definition - fill before plan/ship')
|
|
2614
2695
|
const ctxMd = readClean(eng, 'context.md')
|
|
2615
2696
|
if (!sectionBody(ctxMd, 'Next action', { lastNonEmpty: true })) {
|
|
@@ -2802,7 +2883,7 @@ function hasValueBucket(eng) {
|
|
|
2802
2883
|
}
|
|
2803
2884
|
|
|
2804
2885
|
// Shared classification keeps CLI, dashboard, and vault acceptance consistent.
|
|
2805
|
-
const { PENDING_CELL_RE, valueState } = require('./lib/value-ledger')
|
|
2886
|
+
const { PENDING_CELL_RE, valueState, evidenceSource } = require('./lib/value-ledger')
|
|
2806
2887
|
|
|
2807
2888
|
function parseValueLedger(eng) {
|
|
2808
2889
|
// Last section with actual rows, not merely the last non-empty one: a template
|
|
@@ -2832,7 +2913,7 @@ function parseValueLedger(eng) {
|
|
|
2832
2913
|
const evidence = cell(row, idx.evidence)
|
|
2833
2914
|
const acceptanceStatus = idx.acceptanceStatus === -1 ? undefined : cell(row, idx.acceptanceStatus)
|
|
2834
2915
|
const state = valueState({ measured, accepted, acceptanceStatus, evidence })
|
|
2835
|
-
rows.push({ slice, promised, measured, accepted, evidence, evidenceMissing: !
|
|
2916
|
+
rows.push({ slice, promised, measured, accepted, evidence, evidenceMissing: !evidenceSource(evidence), state })
|
|
2836
2917
|
}
|
|
2837
2918
|
return { rows, columnMissing: idx.accepted === -1 }
|
|
2838
2919
|
}
|
|
@@ -2936,8 +3017,26 @@ function hygieneTriageLines(eng) {
|
|
|
2936
3017
|
]
|
|
2937
3018
|
}
|
|
2938
3019
|
|
|
3020
|
+
function deliverySummaryFor(eng) {
|
|
3021
|
+
const signals = computeSignals(eng)
|
|
3022
|
+
const next = stripTemplateNoise(sectionBody(readClean(eng, 'context.md'), 'Next action', { lastNonEmpty: true })).trim()
|
|
3023
|
+
const signer = ((readClean(eng, 'success.md').match(/^\*\*Stakeholder who signs off:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
3024
|
+
return deliverySummary({ signals, next, hasNext: !!next,
|
|
3025
|
+
hasSigner: !!require('./lib/value-ledger').acceptanceName(signer),
|
|
3026
|
+
highRisks: extractRisks(eng).filter(r => r.severity === 'high').length,
|
|
3027
|
+
valueRows: parseValueLedger(eng).rows,
|
|
3028
|
+
quiet: signals.ageDays !== Infinity && signals.ageDays >= 3,
|
|
3029
|
+
})
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
function firstActionLine(eng) {
|
|
3033
|
+
const action = deliverySummaryFor(eng).firstAction
|
|
3034
|
+
return ` do first: ${previewLine(action.text, 140)} (${action.source}: ${previewLine(action.reason, 140)})`
|
|
3035
|
+
}
|
|
3036
|
+
|
|
2939
3037
|
function printTriageBlock(eng) {
|
|
2940
3038
|
console.log(resumeTriage(eng))
|
|
3039
|
+
console.log(firstActionLine(eng))
|
|
2941
3040
|
for (const line of hygieneTriageLines(eng)) console.log(line)
|
|
2942
3041
|
}
|
|
2943
3042
|
|
|
@@ -2960,16 +3059,15 @@ function recordDigest(eng) {
|
|
|
2960
3059
|
const target = ((success.match(/^\*\*Baseline[^\S\n]*→[^\S\n]*target:\*\*[^\S\n]*(.*)$/m) || [])[1] || '').trim()
|
|
2961
3060
|
if (target) lines.push(` promised: ${target.slice(0, 110)}`)
|
|
2962
3061
|
}
|
|
2963
|
-
const decisions = readClean(eng, 'decisions.md').
|
|
2964
|
-
|
|
2965
|
-
for (const d of decisions) lines.push(` decided: ${formatDecisionRecord(d)}`)
|
|
3062
|
+
const decisions = datedDecisions(readClean(eng, 'decisions.md')).slice(-2)
|
|
3063
|
+
for (const d of decisions) lines.push(` decided: ${formatDecisionRecord(d.text)}; source: ${previewLine(sourceReference(d.text) || '(missing)', 100)}`)
|
|
2966
3064
|
return ['RECORD (read-only - success, delivery, decisions)', ...lines]
|
|
2967
3065
|
}
|
|
2968
3066
|
|
|
2969
|
-
function cmdDoctor() {
|
|
3067
|
+
function cmdDoctor(args = []) {
|
|
2970
3068
|
const eng = resolveEngagement()
|
|
2971
3069
|
if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
|
|
2972
|
-
const issues = collectDoctorIssues(eng)
|
|
3070
|
+
const issues = collectDoctorIssues(eng, { readiness: args.includes('--ready') })
|
|
2973
3071
|
console.log(`FDE DOCTOR - ${engagementSlugFromPath(eng)}`)
|
|
2974
3072
|
printTriageBlock(eng)
|
|
2975
3073
|
if (!issues.length) {
|
|
@@ -3399,6 +3497,7 @@ function cmdDashboard(args) {
|
|
|
3399
3497
|
e.log = extractLog(e.dir)
|
|
3400
3498
|
e.stats = extractStats(e.dir)
|
|
3401
3499
|
e.valueRows = parseValueLedger(e.dir).rows
|
|
3500
|
+
e.hasSigner = !deliverySummaryFor(e.dir).gaps.some(g => g.kind === 'signer')
|
|
3402
3501
|
e.highRisks = e.risks.filter(r => r.severity === 'high').length
|
|
3403
3502
|
e.quiet = e.signals.ageDays !== Infinity && e.signals.ageDays >= 3
|
|
3404
3503
|
e.slug = slugify(e.name)
|
|
@@ -3623,13 +3722,15 @@ function cmdVault(args) {
|
|
|
3623
3722
|
// here fabricates output: the fieldbook you see is what debrief/log actually
|
|
3624
3723
|
// wrote, so the demo cannot drift from the product.
|
|
3625
3724
|
const DEMO_SLUG = 'acme-payments'
|
|
3626
|
-
const DEMO_NOTES = `
|
|
3725
|
+
const DEMO_NOTES = `Fictional kickoff transcript - Acme payments, meeting 2026-09-10
|
|
3627
3726
|
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3727
|
+
We need read-only access to the payments repo and the last 90 days of audit logs. [source: meeting 2026-09-10]
|
|
3728
|
+
We agreed to settle on the existing Stripe connector instead of the in-house rewrite - Priya wants the Q3 audit clean first. [source: meeting 2026-09-10]
|
|
3729
|
+
Proposed scope: repair reconciliation alerts; leave the connector rewrite out pending sponsor confirmation. [source: meeting 2026-09-10]
|
|
3730
|
+
Risk: nobody can name who owns the reconciliation job; it has failed silently twice since March. [source: meeting 2026-09-10]
|
|
3731
|
+
Priya Shah signs off on the acceptance test. [source: meeting 2026-09-10]
|
|
3732
|
+
Priya is travelling for two weeks - Tom is the day-to-day contact. [source: meeting 2026-09-10]
|
|
3733
|
+
Next action: get the reconciliation runbook from Tom before touching anything. [source: meeting 2026-09-10]
|
|
3633
3734
|
|
|
3634
3735
|
<private>
|
|
3635
3736
|
Priya hinted the previous vendor was let go mid-contract. Do not repeat this to the team.
|
|
@@ -3644,14 +3745,17 @@ const DEMO_LAND_ARTIFACTS = {
|
|
|
3644
3745
|
|
|
3645
3746
|
**As stated:** clean up payment reconciliation before the Q3 audit.
|
|
3646
3747
|
**What we heard instead:** nobody owns the reconciliation job, and it fails silently.
|
|
3647
|
-
**
|
|
3748
|
+
**Proposed out of scope:** the in-house connector rewrite; sponsor confirmation is still required.
|
|
3648
3749
|
`,
|
|
3649
3750
|
'success.md': `# Success
|
|
3650
3751
|
|
|
3651
|
-
|
|
3652
|
-
|
|
3752
|
+
**Done when:** Replay a failed settlement in staging; its alert arrives at the on-call queue within 15 minutes.
|
|
3753
|
+
**Primary value bucket:** risk-mitigation
|
|
3754
|
+
**Baseline → target:** no reliable alert → an alert within 15 minutes of a simulated failure.
|
|
3755
|
+
**Explicitly out of scope:** connector rewrite (proposed; not customer-approved).
|
|
3756
|
+
**Stakeholder who signs off:** Priya Shah [source: meeting 2026-09-10]
|
|
3653
3757
|
|
|
3654
|
-
|
|
3758
|
+
Acceptance is pending. The named signer identifies authority, not an approval.
|
|
3655
3759
|
`,
|
|
3656
3760
|
}
|
|
3657
3761
|
|
|
@@ -3705,11 +3809,13 @@ function cmdDemo(args) {
|
|
|
3705
3809
|
fdeops demo - a fake client, real commands, nothing sent anywhere
|
|
3706
3810
|
|
|
3707
3811
|
Sandbox: ${root}
|
|
3708
|
-
Fake client: Acme (payments platform).
|
|
3812
|
+
Fake client: Acme (payments platform). This writes fictional notes, local Git history, and HTML inside the sandbox above.
|
|
3813
|
+
It resets that sandbox on repeat runs; real client records stay untouched.
|
|
3814
|
+
Allow under five minutes. Fictional proposal approval is automatic in this demo only.`)
|
|
3709
3815
|
|
|
3710
3816
|
demoStep('1. Monday of week 1 - create the fieldbook for this client', ['resume', '--init', DEMO_SLUG], workspace, env)
|
|
3711
3817
|
demoStep('2. You walk out of the kickoff with messy notes - hand them over', ['debrief', '--smart', notes], workspace, env)
|
|
3712
|
-
demoStep('3.
|
|
3818
|
+
demoStep('3. Demo automatically confirms the fictional record (not customer acceptance)', ['debrief', '--apply'], workspace, env)
|
|
3713
3819
|
demoStep('4. Say where you are in the engagement', ['log', 'phase', 'land'], workspace, env)
|
|
3714
3820
|
const engDir = path.join(root, DEMO_SLUG, '.fde')
|
|
3715
3821
|
console.log(`\n${demoHead('5. During land, @fde drafts the brief and the definition of done with you')}`)
|
|
@@ -3730,11 +3836,12 @@ function cmdDemo(args) {
|
|
|
3730
3836
|
// about uncommitted manual edits - correct behaviour, wrong lesson for a demo.
|
|
3731
3837
|
const landHash = commitMemory(engDir, 'land: brief + success', { files: [...Object.keys(DEMO_LAND_ARTIFACTS), 'context.md'] })
|
|
3732
3838
|
if (landHash) console.log(` memory @${landHash}`)
|
|
3733
|
-
demoStep('6.
|
|
3734
|
-
demoStep('7.
|
|
3735
|
-
demoStep('8.
|
|
3736
|
-
demoStep('9.
|
|
3737
|
-
demoStep('10.
|
|
3839
|
+
demoStep('6. Add a fictional measured result with evidence, still awaiting customer acceptance', ['log', 'delivery', 'Reconciliation alert | risk-mitigation | alert within 15 minutes | alert in 8 minutes | pending | PR#42 staging replay | revert alert rule'], workspace, env)
|
|
3840
|
+
demoStep('7. Two days later, the sponsor goes quiet', ['log', 'contact', 'Priya has not replied to two emails about the runbook', '--signal', 'amber'], workspace, env)
|
|
3841
|
+
demoStep('8. Next morning, a fresh agent session with no memory of any of this', ['resume'], workspace, env)
|
|
3842
|
+
demoStep('9. A meeting in ten minutes - what do you walk in knowing?', ['prep', 'sponsor check-in'], workspace, env)
|
|
3843
|
+
demoStep('10. Six weeks later: "we never agreed to drop the rewrite"', ['receipts', 'rewrite'], workspace, env)
|
|
3844
|
+
demoStep('11. The whole engagement on one page', ['dashboard'], workspace, env)
|
|
3738
3845
|
// cmdDashboard's default out path, computed rather than scraped from its output:
|
|
3739
3846
|
// a HOME with a space in it truncates any whitespace-delimited parse.
|
|
3740
3847
|
const html = path.join(root, 'fieldbook-current.html')
|
|
@@ -3743,8 +3850,10 @@ function cmdDemo(args) {
|
|
|
3743
3850
|
${demoHead('What just happened')}
|
|
3744
3851
|
|
|
3745
3852
|
- Every line above came from the real CLI - no canned output.
|
|
3746
|
-
- The kickoff notes became dated decisions, risks,
|
|
3747
|
-
|
|
3853
|
+
- The kickoff notes became proposed asks, scope, dated decisions, risks, and a
|
|
3854
|
+
next action. This demo applied fictional notes automatically after showing REVIEW.
|
|
3855
|
+
- PR#42 is fictional evidence for a measured result. The ledger still says claimed:
|
|
3856
|
+
naming Priya as signer does not mean she accepted the result.
|
|
3748
3857
|
- The <private> block in those notes never appears in resume, prep, receipts or
|
|
3749
3858
|
the dashboard - it is sealed in context.md and redacted from anything an agent
|
|
3750
3859
|
or a screen share can see.
|
|
@@ -3779,12 +3888,14 @@ function printUsage() {
|
|
|
3779
3888
|
fde ingest propose <id> smart-propose a staged item → .debrief-propose (confirm before apply)
|
|
3780
3889
|
fde ingest apply same as: fde debrief --apply
|
|
3781
3890
|
fde prep [label] grounded walk-in brief from existing .fde/ only
|
|
3782
|
-
fde doctor
|
|
3891
|
+
fde doctor [--ready] lint memory; --ready checks success before plan/build. Lint (stale signals, gaps). status/dashboard/resume print the same issues
|
|
3783
3892
|
fde redact <term> preview/remove lines containing a buried term (pass --apply to commit; subject never repeats the term)
|
|
3784
3893
|
fde tidy [--apply] propose consolidations; blesses hand-written dirty files when you apply
|
|
3785
3894
|
fde owner [set email] who keeps this engagement record
|
|
3786
3895
|
fde recall <topic> bounded, redacted source excerpts (--max-bytes 4096..65536)
|
|
3787
|
-
fde receipts <term>
|
|
3896
|
+
fde receipts <term> source-backed records versus claims
|
|
3897
|
+
fde defend sponsor readout: accepted assertions, claims, sources, gaps
|
|
3898
|
+
fde handoff [--out file] portable redacted packet; stdout by default, new file only
|
|
3788
3899
|
fde status [--all] value ledger, then trust (pass --all for full portfolio)
|
|
3789
3900
|
fde dashboard [--all] [--open] [--out <path>] bound fieldbook (pass --all for every client)
|
|
3790
3901
|
fde vault derived Obsidian vault of every engagement (--current for one, --redacted for a shared screen, --out <dir>)
|
|
@@ -3810,13 +3921,15 @@ switch (cmd) {
|
|
|
3810
3921
|
case 'debrief': cmdDebrief(args); break
|
|
3811
3922
|
case 'ingest': cmdIngest(args); break
|
|
3812
3923
|
case 'prep': cmdPrep(args); break
|
|
3813
|
-
case 'doctor': cmdDoctor(); break
|
|
3924
|
+
case 'doctor': cmdDoctor(args); break
|
|
3814
3925
|
case 'redact': cmdRedact(args); break
|
|
3815
3926
|
// `garden` was the name through 3.11.x; it keeps working.
|
|
3816
3927
|
case 'tidy':
|
|
3817
3928
|
case 'garden': cmdGarden(args); break
|
|
3818
3929
|
case 'owner': cmdOwner(args); break
|
|
3819
3930
|
case 'receipts': cmdReceipts(args); break
|
|
3931
|
+
case 'handoff': cmdHandoff(args); break
|
|
3932
|
+
case 'defend': cmdHandoff(args, 'Sponsor readout'); break
|
|
3820
3933
|
case 'capture': cmdCapture(); break
|
|
3821
3934
|
case 'preserve': cmdPreserve(); break
|
|
3822
3935
|
case 'status': cmdStatus(args); break
|
package/bin/install.js
CHANGED
|
@@ -377,7 +377,7 @@ function cmdInstall(opts = {}) {
|
|
|
377
377
|
// through to the CLI (fde.js reads process.argv itself, so require() is enough).
|
|
378
378
|
const FDE_SUBCOMMANDS = [
|
|
379
379
|
'demo', 'scan', 'resume', 'triage', 'log', 'debrief', 'ingest', 'prep', 'doctor', 'redact',
|
|
380
|
-
'tidy', 'garden', 'owner', 'receipts', 'capture', 'preserve', 'status', 'dashboard', 'vault', 'help',
|
|
380
|
+
'tidy', 'garden', 'owner', 'receipts', 'recall', 'handoff', 'defend', 'capture', 'preserve', 'status', 'dashboard', 'vault', 'help',
|
|
381
381
|
]
|
|
382
382
|
|
|
383
383
|
const INSTALL_SUBCOMMANDS = ['init', 'adapters', 'install']
|
|
@@ -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 }
|