@cyber-dash-tech/revela 0.16.1 → 0.16.2

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.
@@ -25,18 +25,19 @@ Current state:
25
25
  ${workspaceRoot ? `- Current workspace root: \`${workspaceRoot}\`` : ""}
26
26
 
27
27
  Closed-loop workflow:
28
- 1. Call \`revela-decks\` action \`read\`, then \`reviewNarrative\`.
29
- 2. If current research gaps are missing or stale, call \`deriveResearchGaps\` when useful. Do not invent gaps that are not tied to a claim, objection, risk, decision, or narrative issue.
28
+ 1. Call \`revela-decks\` action \`read\`, then \`reviewNarrative\`, then \`deriveResearchTargets\`. Treat the returned \`selected\` target as the deterministic first target unless it is clearly blocked by user-only information.
29
+ 2. If current research gaps are missing or stale, call \`deriveResearchGaps\` when useful, then call \`deriveResearchTargets\` again. Do not invent gaps that are not tied to a claim, objection, risk, decision, or narrative issue.
30
30
  3. Run up to 3 research loops unless the stop conditions below are met earlier.
31
- 4. At the start of each loop, choose the highest-value 2-3 targets: open/in-progress high-priority gaps, unattached saved findings, unsupported central claims, weak evidence, high-priority objections/risks, and claim_chain_gap relations. Prefer targets that can improve readiness or materially reduce caveats. Do not repeat searches for claims already strongly supported.
32
- 5. If a target already has saved findings, prioritize binding/narrowing before doing more external search.
33
- 6. For targets needing external evidence, mark matching gaps \`in_progress\` with \`revela-decks updateResearchGap\`, then delegate search to the \`revela-research\` subagent. Ask it for source URLs, quotes/snippets, dates or locations when available, caveats, remaining gaps, and a \`## Recommended evidence bindings\` section with claimId, quote, source, supportScope, unsupportedScope, caveat, and strength. Save findings with \`revela-research-save\` under \`researches/{topic}/{axis}.md\` using \`## Data\`, \`## Cases\`, \`## Images\`, and \`## Gaps\` sections as applicable.
34
- 7. After findings are saved or existing findings are selected, read or inspect the findings file. Attach it with \`revela-decks attachResearchFindings\` when it maps to an existing research axis.
35
- 8. Automatically bind evidence when all binding criteria are met. Use \`revela-decks applyEvidenceCandidates\` for concrete candidate ids when available, or \`upsertNarrative\` to preserve canonical evidence bindings with exact source, URL/path, quote/snippet, support scope, unsupported scope, caveat, and strength.
36
- 9. Binding criteria: claimId exists; quote/snippet is traceable to the source and is not invented; source URL or workspace source path is present; supportScope and unsupportedScope are explicit; strength is strong or useful partial; caveat is preserved; binding does not expand the claim beyond the evidence.
37
- 10. If a claim or relation is broader than the evidence, narrow the claim text, supportedScope, unsupportedScope, or relation rationale through \`upsertNarrative\` only when the narrower wording preserves the user's strategic meaning and source boundaries. Do not silently change the decision ask, central recommendation, or approval meaning.
38
- 11. Update matching gaps after binding: use \`evidence_bound\` when canonical evidence was added, \`closed\` when the gap is resolved or non-researchable, \`findings_saved\` only when findings exist but binding criteria are not met, and \`open\` with notes when more external research is still warranted.
39
- 12. Re-run \`reviewNarrative\` after each loop. Compare against the previous loop: fewer open gaps, fewer unattached findings, stronger evidence, narrower unsupported scope, or clearer internal-data caveats should count as progress.
31
+ 4. At the start of each loop, use \`deriveResearchTargets\` as the target order. Work the \`selected\` target first, then the next 1-2 highest-priority targets only when they are related. Do not repeat searches for claims already strongly supported.
32
+ 5. If a target has \`findingsFile\` or \`kind: "unattached_findings"\`, inspect \`bindingDiagnostic\` before doing external search. Prefer existing findings before external research.
33
+ 6. When \`bindingDiagnostic.bindable\` is false, do not bind or package the findings as strong evidence. Report the exact \`failureReasons\` such as \`missing_quote\`, \`unclear_source\`, \`unsupported_scope\`, \`caveat_conflict\`, \`weak_source\`, \`source_mismatch\`, or \`context_only_finding\`, then either narrow the claim safely or run targeted research for the missing fields.
34
+ 7. For targets needing external evidence, mark matching gaps \`in_progress\` with \`revela-decks updateResearchGap\`, then delegate search to the \`revela-research\` subagent. Ask it for source URLs, quotes/snippets, dates or locations when available, caveats, remaining gaps, and a \`## Recommended evidence bindings\` section with claimId, quote, source, supportScope, unsupportedScope, caveat, and strength. Save findings with \`revela-research-save\` under \`researches/{topic}/{axis}.md\` using \`## Data\`, \`## Cases\`, \`## Images\`, and \`## Gaps\` sections as applicable.
35
+ 8. After findings are saved or existing findings are selected, read or inspect the findings file. Attach it with \`revela-decks attachResearchFindings\` when it maps to an existing research axis. Re-run \`deriveResearchTargets\` so the next loop sees updated \`bindingDiagnostic\` and target order.
36
+ 9. Automatically bind evidence only when all binding criteria are met and the diagnostic is \`bindable: true\` or the same fields are explicit in the findings. Use \`revela-decks applyEvidenceCandidates\` for concrete candidate ids when available, or \`upsertNarrative\` to preserve canonical evidence bindings with exact source, URL/path, quote/snippet, support scope, unsupported scope, caveat, and strength.
37
+ 10. Binding criteria: claimId exists; quote/snippet is traceable to the source and is not invented; source URL or workspace source path is present; supportScope and unsupportedScope are explicit; strength is strong or useful partial; caveat is preserved; binding does not expand the claim beyond the evidence.
38
+ 11. If a claim or relation is broader than the evidence, narrow the claim text, supportedScope, unsupportedScope, or relation rationale through \`upsertNarrative\` only when the narrower wording preserves the user's strategic meaning and source boundaries. Do not silently change the decision ask, central recommendation, or approval meaning.
39
+ 12. Update matching gaps after binding: use \`evidence_bound\` when canonical evidence was added, \`closed\` when the gap is resolved or non-researchable, \`findings_saved\` only when findings exist but binding criteria are not met, and \`open\` with notes when more external research is still warranted.
40
+ 13. Re-run \`reviewNarrative\` and \`deriveResearchTargets\` after each loop. Compare against the previous loop: fewer open gaps, fewer unattached findings, stronger evidence, narrower unsupported scope, or clearer internal-data caveats should count as progress.
40
41
 
41
42
  Stop conditions:
42
43
  - No open externally researchable gaps remain.
@@ -47,20 +48,26 @@ Stop conditions:
47
48
 
48
49
  Report format:
49
50
  - Start with \`Research loop completed after <n> round(s).\`
50
- - List bound evidence by claim id and count.
51
- - List gaps closed or moved to evidence_bound.
52
- - List claims or relations narrowed, with the remaining unsupported scope.
53
- - List remaining caveats only as one of: \`internal_data_needed\`, \`not_publicly_researchable\`, \`source_quality_limit\`, or \`still_open\`.
54
- - If no binding happened, say why binding criteria failed and what exact source type is needed next.
55
- - End with the next smallest story action, not a generic request for user confirmation.
51
+ - Then use these exact sections in order:
52
+ - \`Selected target\`: report \`kind\`, \`priority\`, \`reason\`, \`question\`, \`targetId\`, \`claimId\`, and any \`findingsFile\`.
53
+ - \`Existing findings inspected\`: for each file, report \`findingsFile\`, \`bindingDiagnostic.bindable\`, \`failureReasons\`, and which explicit fields were present: \`source\`, \`quoteOrSnippet\`, \`supportScope\`, \`unsupportedScope\`, \`caveat\`, \`strength\`. If none were inspected, write \`none\`.
54
+ - \`Attachments\`: list findings attached with axis/status, or \`none\`.
55
+ - \`Evidence bound\`: list evidence bindings by claim id, source, quote/snippet, supportScope, unsupportedScope, caveat, and strength, or \`none\`.
56
+ - \`Unbound findings\`: list every inspected but unbound findings file with structured failure reasons such as \`missing_quote\`, \`unclear_source\`, \`unsupported_scope\`, \`caveat_conflict\`, \`weak_source\`, \`source_mismatch\`, or \`context_only_finding\`. If none, write \`none\`.
57
+ - \`Gap updates\`: list gaps moved to \`in_progress\`, \`findings_saved\`, \`attached\`, \`evidence_bound\`, \`closed\`, or still \`open\` with notes.
58
+ - \`Narrative changes\`: list claims or relations narrowed, with remaining unsupported scope. If none, write \`none\`.
59
+ - \`Remaining caveats\`: use only \`internal_data_needed\`, \`not_publicly_researchable\`, \`source_quality_limit\`, or \`still_open\`.
60
+ - \`Next smallest story action\`: end with one concrete next command or action, not a generic request for confirmation.
61
+ - If no binding happened, the \`Unbound findings\` or \`Remaining caveats\` section must say why binding criteria failed and what exact source type is needed next.
56
62
 
57
63
  Rules:
58
64
  - Do not use primary-agent broad websearch. Use the \`revela-research\` subagent for external search.
59
65
  - Do not invent quotes, source paths, URLs, page references, locations, or caveats.
60
66
  - Do not treat \`researches/**/*.md\` as canonical evidence until attached or evidence-bound, but do not stop at findings_saved when binding criteria are met.
67
+ - Do not bypass \`deriveResearchTargets\`; target selection, \`selected\`, and \`bindingDiagnostic\` are deterministic inputs, not LLM judgement.
61
68
  - Do not mutate canonical claims merely to fit a source; narrow only to preserve evidence boundaries and avoid overstated claims.
62
69
  - Do not ask the user to approve each evidence binding. Ask only when binding would change strategic meaning, downgrade a central claim, rely on suspicious sources, or require narrative approval.
63
70
  - Do not store secrets, credentials, tokens, or sensitive personal information.
64
71
 
65
- Start now by reading ${DECKS_STATE_FILE} through \`revela-decks\`, reviewing current readiness, and running the first research/binding loop.`
72
+ Start now by reading ${DECKS_STATE_FILE} through \`revela-decks\`, reviewing current readiness, deriving research targets, and running the first research/binding loop from the selected target.`
66
73
  }
@@ -1,9 +1,12 @@
1
+ import { existsSync, readFileSync } from "fs"
2
+ import { resolve, sep } from "path"
1
3
  import type { DecksState } from "../decks-state"
2
4
  import { recordWorkspaceAction } from "../workspace-state/actions"
3
5
  import { stableResearchGapId } from "./hash"
4
6
  import { normalizeNarrativeState } from "./normalize"
5
7
  import { reviewNarrativeState } from "./readiness"
6
8
  import type {
9
+ NarrativeClaim,
7
10
  NarrativeReadinessIssue,
8
11
  NarrativeResearchGap,
9
12
  NarrativeResearchGapStatus,
@@ -11,6 +14,62 @@ import type {
11
14
  NarrativeStateV1,
12
15
  } from "./types"
13
16
 
17
+ export type ResearchTargetKind =
18
+ | "research_gap"
19
+ | "missing_evidence"
20
+ | "weak_evidence"
21
+ | "unsupported_scope"
22
+ | "unhandled_objection"
23
+ | "high_severity_risk"
24
+ | "unattached_findings"
25
+ | "claim_chain_gap"
26
+
27
+ export type EvidenceBindingFailureReason =
28
+ | "missing_quote"
29
+ | "unclear_source"
30
+ | "over_broad_claim"
31
+ | "weak_source"
32
+ | "unsupported_scope"
33
+ | "caveat_conflict"
34
+ | "source_mismatch"
35
+ | "context_only_finding"
36
+
37
+ export interface ResearchTarget {
38
+ id: string
39
+ kind: ResearchTargetKind
40
+ targetType: NarrativeResearchGapTargetType | "findings" | "relation"
41
+ targetId?: string
42
+ priority: "high" | "medium" | "low"
43
+ reason: string
44
+ question: string
45
+ status?: NarrativeResearchGapStatus | "unattached"
46
+ findingsFile?: string
47
+ claimId?: string
48
+ claimText?: string
49
+ requiredEvidence: string[]
50
+ bindingFailureReasons?: EvidenceBindingFailureReason[]
51
+ bindingDiagnostic?: EvidenceBindingDiagnostic
52
+ }
53
+
54
+ export interface ResearchTargetsResult {
55
+ targets: ResearchTarget[]
56
+ selected?: ResearchTarget
57
+ }
58
+
59
+ export interface EvidenceBindingDiagnostic {
60
+ findingsFile: string
61
+ bindable: boolean
62
+ failureReasons: EvidenceBindingFailureReason[]
63
+ explicit: {
64
+ source: boolean
65
+ quoteOrSnippet: boolean
66
+ supportScope: boolean
67
+ unsupportedScope: boolean
68
+ caveat: boolean
69
+ strength: boolean
70
+ }
71
+ }
72
+
14
73
  export interface UpsertResearchGapInput {
15
74
  id?: string
16
75
  targetType?: NarrativeResearchGapTargetType
@@ -46,6 +105,20 @@ export interface CloseResearchGapResult {
46
105
  gap?: NarrativeResearchGap
47
106
  }
48
107
 
108
+ export function deriveResearchTargets(state: DecksState, options: { now?: string; workspaceRoot?: string } = {}): ResearchTargetsResult {
109
+ const reviewed = reviewNarrativeState(state, { now: options.now })
110
+ const narrative = reviewed.state.narrative!
111
+ const targets = dedupeTargets([
112
+ ...targetsFromResearchGaps(narrative, options.workspaceRoot),
113
+ ...targetsFromClaims(narrative),
114
+ ...targetsFromObjections(narrative),
115
+ ...targetsFromRisks(narrative),
116
+ ...targetsFromReadinessIssues(reviewed.result.issues),
117
+ ...targetsFromUnattachedFindings(reviewed.state, narrative, options.workspaceRoot),
118
+ ]).sort(compareResearchTargets)
119
+ return { targets, selected: targets[0] }
120
+ }
121
+
49
122
  export function deriveResearchGapsFromReadiness(state: DecksState, options: { now?: string } = {}): { state: DecksState; result: ResearchGapMutationResult } {
50
123
  const reviewed = reviewNarrativeState(state, { now: options.now })
51
124
  return upsertResearchGapsInState(reviewed.state, gapsFromIssues(reviewed.state.narrative!, reviewed.result.issues), options)
@@ -137,6 +210,265 @@ function gapsFromIssues(narrative: NarrativeStateV1, issues: NarrativeReadinessI
137
210
  })
138
211
  }
139
212
 
213
+ function targetsFromResearchGaps(narrative: NarrativeStateV1, workspaceRoot: string | undefined): ResearchTarget[] {
214
+ return (narrative.researchGaps ?? [])
215
+ .filter((gap) => gap.status !== "closed" && gap.status !== "evidence_bound")
216
+ .map((gap) => {
217
+ const claim = gap.targetType === "claim" ? narrative.claims.find((item) => item.id === gap.targetId) : undefined
218
+ const bindingDiagnostic = gap.findingsFile ? evidenceBindingDiagnostic(workspaceRoot, gap.findingsFile) : undefined
219
+ return {
220
+ id: `gap:${gap.id}`,
221
+ kind: "research_gap",
222
+ targetType: gap.targetType,
223
+ targetId: gap.targetId,
224
+ priority: gap.priority,
225
+ reason: gap.status === "findings_saved" || gap.status === "attached"
226
+ ? "Saved findings exist; inspect and bind explicit evidence before launching new research."
227
+ : "Open canonical research gap needs findings or binding progress.",
228
+ question: gap.question,
229
+ status: gap.status,
230
+ findingsFile: gap.findingsFile,
231
+ claimId: claim?.id,
232
+ claimText: claim?.text,
233
+ requiredEvidence: requiredEvidenceForClaim(claim),
234
+ bindingFailureReasons: bindingDiagnostic?.failureReasons ?? (gap.findingsFile ? ["missing_quote", "unclear_source", "unsupported_scope"] : undefined),
235
+ bindingDiagnostic,
236
+ } satisfies ResearchTarget
237
+ })
238
+ }
239
+
240
+ function targetsFromClaims(narrative: NarrativeStateV1): ResearchTarget[] {
241
+ return narrative.claims.flatMap((claim) => {
242
+ const targets: ResearchTarget[] = []
243
+ if (claim.evidenceRequired && claim.evidenceStatus === "missing") {
244
+ targets.push(claimTarget("missing_evidence", claim, claim.importance === "central" ? "high" : "medium", "Evidence-required claim has no bound support."))
245
+ }
246
+ if (claim.evidenceRequired && (claim.evidenceStatus === "weak" || claim.evidenceStatus === "partial")) {
247
+ targets.push(claimTarget("weak_evidence", claim, claim.importance === "central" ? "high" : "medium", `Claim evidence is ${claim.evidenceStatus}; strengthen source trace or narrow scope.`))
248
+ }
249
+ if (claim.unsupportedScope) {
250
+ targets.push(claimTarget("unsupported_scope", claim, claim.importance === "central" ? "high" : "medium", "Claim records unsupported scope that needs evidence, narrowing, or explicit caveat."))
251
+ }
252
+ return targets
253
+ })
254
+ }
255
+
256
+ function targetsFromObjections(narrative: NarrativeStateV1): ResearchTarget[] {
257
+ return narrative.objections
258
+ .filter((objection) => objection.priority === "high" && !objection.response)
259
+ .map((objection) => ({
260
+ id: `objection:${objection.id}`,
261
+ kind: "unhandled_objection",
262
+ targetType: "objection",
263
+ targetId: objection.id,
264
+ priority: "high",
265
+ reason: "High-priority objection has no recorded response or evidence boundary.",
266
+ question: `Find response or evidence for objection: ${objection.text}`,
267
+ claimId: objection.claimId,
268
+ claimText: narrative.claims.find((claim) => claim.id === objection.claimId)?.text,
269
+ requiredEvidence: ["response evidence or boundary", "source", "quote/snippet", "caveat"],
270
+ } satisfies ResearchTarget))
271
+ }
272
+
273
+ function targetsFromRisks(narrative: NarrativeStateV1): ResearchTarget[] {
274
+ return narrative.risks
275
+ .filter((risk) => risk.severity === "high" && !risk.mitigation)
276
+ .map((risk) => ({
277
+ id: `risk:${risk.id}`,
278
+ kind: "high_severity_risk",
279
+ targetType: "risk",
280
+ targetId: risk.id,
281
+ priority: "high",
282
+ reason: "High-severity risk has no mitigation or evidence boundary.",
283
+ question: `Find mitigation, evidence boundary, or caveat for risk: ${risk.text}`,
284
+ claimId: risk.claimId,
285
+ claimText: narrative.claims.find((claim) => claim.id === risk.claimId)?.text,
286
+ requiredEvidence: ["mitigation evidence or boundary", "source", "quote/snippet", "caveat"],
287
+ } satisfies ResearchTarget))
288
+ }
289
+
290
+ function targetsFromReadinessIssues(issues: NarrativeReadinessIssue[]): ResearchTarget[] {
291
+ return issues.flatMap((issue) => {
292
+ if (issue.type !== "claim_chain_gap") return []
293
+ return [{
294
+ id: `issue:${issue.type}:${stableResearchGapId(issue.message)}`,
295
+ kind: "claim_chain_gap",
296
+ targetType: "relation",
297
+ priority: issue.severity === "blocker" ? "high" : "medium",
298
+ reason: issue.message,
299
+ question: issue.suggestedAction,
300
+ requiredEvidence: ["claim relation rationale", "supporting source or explicit user rationale", "caveat if relation is assumption-based"],
301
+ } satisfies ResearchTarget]
302
+ })
303
+ }
304
+
305
+ function targetsFromUnattachedFindings(state: DecksState, narrative: NarrativeStateV1, workspaceRoot: string | undefined): ResearchTarget[] {
306
+ return (state.actions ?? []).flatMap((action) => {
307
+ if (action.type !== "research.findings_saved") return []
308
+ const path = typeof action.outputs?.path === "string" ? action.outputs.path : undefined
309
+ if (!path || isFindingsAttachedOrBound(state, narrative, path)) return []
310
+ const bindingDiagnostic = evidenceBindingDiagnostic(workspaceRoot, path)
311
+ return [{
312
+ id: `findings:${path}`,
313
+ kind: "unattached_findings",
314
+ targetType: "findings",
315
+ targetId: path,
316
+ priority: "medium",
317
+ reason: "Saved findings are not attached to a research axis or bound as canonical evidence.",
318
+ question: `Inspect and attach or bind saved findings: ${path}`,
319
+ status: "unattached",
320
+ findingsFile: path,
321
+ requiredEvidence: ["source", "quote/snippet", "support scope", "unsupported scope", "caveat", "strength"],
322
+ bindingFailureReasons: bindingDiagnostic?.failureReasons ?? ["missing_quote", "unclear_source", "context_only_finding", "unsupported_scope"],
323
+ bindingDiagnostic,
324
+ } satisfies ResearchTarget]
325
+ })
326
+ }
327
+
328
+ function evidenceBindingDiagnostic(workspaceRoot: string | undefined, findingsFile: string): EvidenceBindingDiagnostic | undefined {
329
+ const text = readWorkspaceText(workspaceRoot, findingsFile)
330
+ if (text === undefined) return undefined
331
+
332
+ const explicit = {
333
+ source: hasSourceTrace(text),
334
+ quoteOrSnippet: hasQuoteOrSnippet(text),
335
+ supportScope: hasField(text, ["support scope", "supported scope", "supports", "support"]),
336
+ unsupportedScope: hasField(text, ["unsupported scope", "unsupported", "not supported", "gaps"]),
337
+ caveat: hasField(text, ["caveat", "limitation", "limits", "boundary"]),
338
+ strength: hasField(text, ["strength", "support strength", "evidence strength"]),
339
+ }
340
+ const failureReasons: EvidenceBindingFailureReason[] = []
341
+ if (!explicit.quoteOrSnippet) failureReasons.push("missing_quote")
342
+ if (!explicit.source) failureReasons.push("unclear_source")
343
+ if (!explicit.supportScope || !explicit.unsupportedScope) failureReasons.push("unsupported_scope")
344
+ if (!explicit.caveat) failureReasons.push("caveat_conflict")
345
+ if (!explicit.strength) failureReasons.push("weak_source")
346
+ if (looksContextOnly(text, explicit)) failureReasons.push("context_only_finding")
347
+
348
+ return {
349
+ findingsFile,
350
+ bindable: failureReasons.length === 0,
351
+ failureReasons,
352
+ explicit,
353
+ }
354
+ }
355
+
356
+ function readWorkspaceText(workspaceRoot: string | undefined, relativePath: string): string | undefined {
357
+ if (!workspaceRoot) return undefined
358
+ const root = resolve(workspaceRoot)
359
+ const target = resolve(root, relativePath)
360
+ if (target !== root && !target.startsWith(root + sep)) return undefined
361
+ if (!existsSync(target)) return undefined
362
+ return readFileSync(target, "utf-8")
363
+ }
364
+
365
+ function hasSourceTrace(text: string): boolean {
366
+ return /^sources:\s*$/im.test(text)
367
+ || /\[source:\s*[^\]]+\]/i.test(text)
368
+ || /^\s*-?\s*source:\s*\S+/im.test(text)
369
+ || /^\s*source\s+(path|url):\s*\S+/im.test(text)
370
+ }
371
+
372
+ function hasQuoteOrSnippet(text: string): boolean {
373
+ return hasField(text, ["quote", "snippet"])
374
+ || /["“][^"”]{20,}["”]/.test(text)
375
+ || /^>\s*\S.{20,}/m.test(text)
376
+ }
377
+
378
+ function hasField(text: string, labels: string[]): boolean {
379
+ return labels.some((label) => new RegExp(`(^|\\n)\\s*(?:[-*]\\s*)?${escapeRegex(label)}\\s*[::]\\s*\\S`, "i").test(text)
380
+ || new RegExp(`^##+\\s+.*${escapeRegex(label)}`, "im").test(text))
381
+ }
382
+
383
+ function looksContextOnly(text: string, explicit: EvidenceBindingDiagnostic["explicit"]): boolean {
384
+ return /^##+\s+data\b/im.test(text) && !explicit.quoteOrSnippet && (!explicit.supportScope || !explicit.caveat)
385
+ }
386
+
387
+ function escapeRegex(value: string): string {
388
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
389
+ }
390
+
391
+ function claimTarget(kind: Extract<ResearchTargetKind, "missing_evidence" | "weak_evidence" | "unsupported_scope">, claim: NarrativeClaim, priority: "high" | "medium", reason: string): ResearchTarget {
392
+ return {
393
+ id: `claim:${kind}:${claim.id}`,
394
+ kind,
395
+ targetType: "claim",
396
+ targetId: claim.id,
397
+ priority,
398
+ reason,
399
+ question: questionForClaimTarget(kind, claim),
400
+ claimId: claim.id,
401
+ claimText: claim.text,
402
+ requiredEvidence: requiredEvidenceForClaim(claim),
403
+ bindingFailureReasons: bindingFailuresForClaim(claim),
404
+ }
405
+ }
406
+
407
+ function questionForClaimTarget(kind: ResearchTargetKind, claim: NarrativeClaim): string {
408
+ if (kind === "missing_evidence") return `Find evidence for claim: ${claim.text}`
409
+ if (kind === "weak_evidence") return `Strengthen evidence for claim: ${claim.text}`
410
+ if (kind === "unsupported_scope") return `Resolve unsupported scope for claim: ${claim.text}`
411
+ return claim.text
412
+ }
413
+
414
+ function requiredEvidenceForClaim(claim: NarrativeClaim | undefined): string[] {
415
+ const base = ["source", "quote/snippet", "support scope", "unsupported scope", "caveat", "strength"]
416
+ if (!claim) return base
417
+ if (claim.unsupportedScope) return [...base, `address unsupported scope: ${claim.unsupportedScope}`]
418
+ return base
419
+ }
420
+
421
+ function bindingFailuresForClaim(claim: NarrativeClaim): EvidenceBindingFailureReason[] {
422
+ const reasons: EvidenceBindingFailureReason[] = ["missing_quote", "unclear_source"]
423
+ if (claim.evidenceStatus === "weak") reasons.push("weak_source")
424
+ if (claim.unsupportedScope) reasons.push("unsupported_scope", "over_broad_claim")
425
+ return reasons
426
+ }
427
+
428
+ function isFindingsAttachedOrBound(state: DecksState, narrative: NarrativeStateV1, path: string): boolean {
429
+ const attached = Object.values(state.decks ?? {}).some((deck) => deck.researchPlan.some((axis) => axis.findingsFile === path))
430
+ const bound = narrative.evidenceBindings.some((binding) => binding.findingsFile === path)
431
+ return attached || bound
432
+ }
433
+
434
+ function dedupeTargets(targets: ResearchTarget[]): ResearchTarget[] {
435
+ const seen = new Set<string>()
436
+ const result: ResearchTarget[] = []
437
+ for (const target of targets) {
438
+ const key = `${target.kind}:${target.targetType}:${target.targetId ?? target.claimId ?? target.question}`
439
+ if (seen.has(key)) continue
440
+ seen.add(key)
441
+ result.push(target)
442
+ }
443
+ return result
444
+ }
445
+
446
+ function compareResearchTargets(a: ResearchTarget, b: ResearchTarget): number {
447
+ return priorityValue(a.priority) - priorityValue(b.priority)
448
+ || kindValue(a.kind) - kindValue(b.kind)
449
+ || a.question.localeCompare(b.question)
450
+ }
451
+
452
+ function priorityValue(priority: ResearchTarget["priority"]): number {
453
+ if (priority === "high") return 0
454
+ if (priority === "medium") return 1
455
+ return 2
456
+ }
457
+
458
+ function kindValue(kind: ResearchTargetKind): number {
459
+ const values: Record<ResearchTargetKind, number> = {
460
+ research_gap: 0,
461
+ unattached_findings: 1,
462
+ missing_evidence: 2,
463
+ weak_evidence: 3,
464
+ unsupported_scope: 4,
465
+ unhandled_objection: 5,
466
+ high_severity_risk: 6,
467
+ claim_chain_gap: 7,
468
+ }
469
+ return values[kind]
470
+ }
471
+
140
472
  function researchableIssue(issue: NarrativeReadinessIssue): boolean {
141
473
  return issue.type === "missing_evidence" || issue.type === "weak_evidence" || issue.type === "unsupported_scope" || issue.type === "unhandled_objection" || issue.type === "missing_risk"
142
474
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/tools/decks.ts CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  } from "../lib/narrative-state/readiness"
31
31
  import { compileDeckPlanFromNarrative } from "../lib/narrative-state/render-plan"
32
32
  import { backfillSlideClaimRefsFromCoverage } from "../lib/narrative-state/coverage"
33
- import { closeResearchGapInState, deriveResearchGapsFromReadiness, updateResearchGapInState, upsertResearchGapsInState } from "../lib/narrative-state/research-gaps"
33
+ import { closeResearchGapInState, deriveResearchGapsFromReadiness, deriveResearchTargets, updateResearchGapInState, upsertResearchGapsInState } from "../lib/narrative-state/research-gaps"
34
34
  import { normalizeCanonicalNarrativeState, normalizeNarrativeState } from "../lib/narrative-state/normalize"
35
35
  import { narrativeToBrief } from "../lib/narrative-state/project-compat"
36
36
  import type { NarrativeStateV1 } from "../lib/narrative-state/types"
@@ -68,7 +68,7 @@ export default tool({
68
68
  "It stores workspace narrative state, active deck specs, per-slide content/layout/components, and computes narrative or deck readiness.",
69
69
  args: {
70
70
  action: tool.schema
71
- .enum(["read", "init", "upsertDeck", "upsertSlides", "upsertNarrative", "compileDeckPlan", "confirmDeckPlan", "backfillClaimRefs", "review", "reviewNarrative", "approveNarrative", "deriveResearchGaps", "upsertResearchGaps", "updateResearchGap", "closeResearchGap", "applyEvidenceCandidates", "attachResearchFindings", "remember"])
71
+ .enum(["read", "init", "upsertDeck", "upsertSlides", "upsertNarrative", "compileDeckPlan", "confirmDeckPlan", "backfillClaimRefs", "review", "reviewNarrative", "approveNarrative", "deriveResearchGaps", "deriveResearchTargets", "upsertResearchGaps", "updateResearchGap", "closeResearchGap", "applyEvidenceCandidates", "attachResearchFindings", "remember"])
72
72
  .describe("Action to perform on DECKS.json."),
73
73
  summary: tool.schema.boolean().optional().describe("For read: return a compact summary instead of full state."),
74
74
  goal: tool.schema.string().optional().describe("For upsertDeck: deck goal."),
@@ -484,6 +484,11 @@ export default tool({
484
484
  return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, result: derived.result, narrative: derived.state.narrative }, null, 2)
485
485
  }
486
486
 
487
+ if (args.action === "deriveResearchTargets") {
488
+ const result = deriveResearchTargets(state, { workspaceRoot })
489
+ return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, result }, null, 2)
490
+ }
491
+
487
492
  if (args.action === "upsertResearchGaps") {
488
493
  if (!args.researchGaps?.length) return JSON.stringify({ ok: false, error: "researchGaps are required for upsertResearchGaps" })
489
494
  const upserted = upsertResearchGapsInState(state, args.researchGaps as any[])