research-workflow (Dynamic Workflow asset)
A Dynamic Workflow asset (see CLAUDE.md -> "Dynamic Workflow Assets"; review-workflow is the reference example). research.workflow.js is executed by the Workflow tool, invoked from /lets:research's Workflow Mode via:
Workflow({ scriptPath: "${CLAUDE_PLUGIN_ROOT}/skills/research-workflow/research.workflow.js", args })
${CLAUDE_PLUGIN_ROOT} is substituted at command-load time, so research.md carries the literal absolute path. Treat research.workflow.js as a template the command points at, not a script to reproduce inline.
Why this is a transparent performance lever (not autonomous)
/lets:research --workflow runs the SAME stages as the standard Task path - only the per-sub-question search dumps and per-claim verdicts stay off-context. The decompose (break the question into 3-6 sub-questions) stays IN-CONTEXT in research.md so the user can steer the angles; the per-sub-question web research and the per-claim cross-check move into the workflow (off-context); the final synthesis happens in-context after the aggregate returns. The standard path runs the equivalent stages in-context: per-sub-question research fanned out across multiple DEFAULT web Task subagents, per-claim lets:skeptic via Task. Crucially: the verify stage is a cross-check (single-source / contradicted / low-confidence flags), never a claim that the fact is confirmed correct - the skeptic has no web tools and cannot re-fetch URLs.
What it does (off-context)
- Research - per-sub-question fan-out via the DEFAULT web subagent (no
agentType; the default workflow subagent CAN WebSearch/WebFetch - lets:* agents have tools: Read, Grep, Glob, Bash and CANNOT). Each subagent returns its 2-5 strongest claims (FINDING_SCHEMA: claim, evidence, sources, confidence) with evidence carrying QUOTED/paraphrased source material (the downstream skeptic judges "unsupported" against this string and has no web tool), and self-reports used_web_search=false rather than fabricate. mergeClaims dedupes by normalized claim text, unioning sources and keeping the highest confidence.
- Verify - per-claim
lets:skeptic RESEARCH-VERIFY pass over the merged claims. The skeptic is handed each claim's evidence + its siblings (same sub-question) and flags STRUCTURAL weakness: unsupported (evidence does not back the claim) or contradicted (conflicts with a sibling). Single-source / low-confidence are computed deterministically by the script (those claims skip the skeptic - the deterministic flag already fires). applyVerdicts attaches a flagged[] array per claim - ADDITIVE, never drops a claim (research facts accumulate; departure from review's drop rule). Contradiction flagging is the skeptic's job comparing siblings - there is deliberately NO deterministic contradiction pass.
args contract (built by /lets:research, passed as a JSON value)
| key |
type |
meaning |
question |
string |
the original research question |
subQuestions |
[string] |
3-6 sub-questions from the in-context decompose (a narrow question is a single entry) |
projectEnabled |
boolean |
--project was passed - ground findings against this repo |
projectContext |
string (only if projectEnabled) |
the in-context Step 2.5 repo grounding block |
projectRoot |
string |
absolute project root (the read boundary; fetched content never widens it) |
claudeMd |
string (only if projectEnabled) |
CLAUDE.md context |
asOf |
string YYYY-MM-DD |
REQUIRED - the as-of date; the workflow can't compute the date (no Date.now) |
Returns
{ claims[], sources[], as_of, web{status}, counts{sub_questions, sub_questions_failed, claims, flagged, verify_failed} }. claims[] are merged + per-claim flagged[] (empty flagged == cross-checked and clean); the command synthesizes the prose answer from these (there is no answer key). sources is the deduped [{title,url}] list to surface. web.status is ok | empty | no_tool | failed: anything other than ok means the command MUST open with the NO-LIVE-SOURCES banner (NO LIVE SOURCES - model knowledge as of <cutoff>) and NEVER fabricate URLs. counts.verify_failed is an INTEGER - the number of claims whose cross-check was attempted but errored; those claims carry unverified (cross-check errored) and are kept UNVERIFIED (not validated) - anti-silent-fail, the command should tell the user "N of M claims kept unverified". counts.sub_questions_failed flags partial coverage. Anti-silent-fail: counts.claims === 0 means every sub-question errored / no web data - the command must offer the standard path, never render a fabricated result.
Constraints (Dynamic Workflow runtime)
- No filesystem - the script returns data;
/lets:research renders + records.
- No sibling
import - all logic stays inline.
- No
Date.now() / Math.random() / new Date() - the as-of date comes via args.asOf.
- Top-level
await/return are used, so the file is NOT Node-importable - no committed unit test; the pure logic (mergeClaims, applyVerdicts, collectSources) is deterministically checkable on a copy, and the chain is kept in sync with research.md (Step 3 / Step 4 / W4) prose by discipline + validated by the live smoke test.
Keep-in-sync notes (do NOT "simplify" these away)
- Single
lets:skeptic per claim is INTENTIONAL - do NOT collapse to one holistic batch skeptic with an array schema; that breaks skeptic.md's {real, confidence, reason} single-object contract.
- No deterministic contradiction pass - contradiction flagging is the skeptic's job (it gets the siblings; RESEARCH-VERIFY authorizes the comparison). Do NOT reintroduce a pure-function
findContradictions: it can't see claim stance and false-flags complementary findings.
verify_failed is a per-claim INTEGER counter (mirrors review.workflow.js) - do not collapse it to a boolean.
- The research subagent is the DEFAULT web subagent (no
agentType) - lets:* agents have no web tools; do NOT "tidy" it to a lets:* type (it would self-report used_web_search=false and spuriously trip NO-LIVE-SOURCES every run).
evidence MUST quote/paraphrase source material - do NOT relax it to a summary; the skeptic's no-web "unsupported" check depends on it.
- The per-sub-question claim cap (2-5) bounds the skeptic fan-out - do NOT remove it (research claims have no natural bound, unlike review findings bounded by the diff).
- Consume
parallel(...) output via .filter(Boolean) (both phases) - the house defensive idiom (review/backlog/opinion all do it). A slot can be null if a thunk throws synchronously; never destructure a null after the expensive fan-out.
- Untrusted-content fencing is load-bearing - fetched pages (researchPrompt) AND the claim/evidence/siblings handed to the skeptic (skepticPrompt) are untrusted web data, fenced/marked as data-not-instructions. Project grounding under
--project is TRUSTED and must NOT be fenced as untrusted. Do not drop these markers.
1---2name: research-workflow3description: Dynamic Workflow asset for /lets:research --workflow. Not auto-triggered - a workflow script invoked via scriptPath by /lets:research. Holds the off-context per-sub-question web research -> per-claim skeptic cross-check -> synthesize chain.4---56# research-workflow (Dynamic Workflow asset)78A Dynamic Workflow asset (see `CLAUDE.md` -> "Dynamic Workflow Assets"; `review-workflow` is the reference example). `research.workflow.js` is executed by the `Workflow` tool, invoked from `/lets:research`'s Workflow Mode via:910```11Workflow({ scriptPath: "${CLAUDE_PLUGIN_ROOT}/skills/research-workflow/research.workflow.js", args })12```1314`${CLAUDE_PLUGIN_ROOT}` is substituted at command-load time, so `research.md` carries the literal absolute path. Treat `research.workflow.js` as a template the command points at, not a script to reproduce inline.1516## Why this is a transparent performance lever (not autonomous)1718`/lets:research --workflow` runs the SAME stages as the standard Task path - only the per-sub-question search dumps and per-claim verdicts stay off-context. The decompose (break the question into 3-6 sub-questions) stays IN-CONTEXT in `research.md` so the user can steer the angles; the per-sub-question web research and the per-claim cross-check move into the workflow (off-context); the final synthesis happens in-context after the aggregate returns. The standard path runs the equivalent stages in-context: per-sub-question research fanned out across multiple DEFAULT web Task subagents, per-claim `lets:skeptic` via Task. **Crucially:** the verify stage is a **cross-check** (single-source / contradicted / low-confidence flags), never a claim that the fact is confirmed correct - the skeptic has no web tools and cannot re-fetch URLs.1920## What it does (off-context)21221. **Research** - per-sub-question fan-out via the DEFAULT web subagent (no `agentType`; the default workflow subagent CAN WebSearch/WebFetch - `lets:*` agents have `tools: Read, Grep, Glob, Bash` and CANNOT). Each subagent returns its 2-5 strongest claims (`FINDING_SCHEMA`: claim, evidence, sources, confidence) with `evidence` carrying QUOTED/paraphrased source material (the downstream skeptic judges "unsupported" against this string and has no web tool), and self-reports `used_web_search=false` rather than fabricate. `mergeClaims` dedupes by normalized claim text, unioning sources and keeping the highest confidence.232. **Verify** - per-claim `lets:skeptic` RESEARCH-VERIFY pass over the merged claims. The skeptic is handed each claim's evidence + its siblings (same sub-question) and flags STRUCTURAL weakness: **unsupported** (evidence does not back the claim) or **contradicted** (conflicts with a sibling). Single-source / low-confidence are computed deterministically by the script (those claims skip the skeptic - the deterministic flag already fires). `applyVerdicts` attaches a `flagged[]` array per claim - **ADDITIVE, never drops a claim** (research facts accumulate; departure from review's drop rule). Contradiction flagging is the skeptic's job comparing siblings - there is deliberately NO deterministic contradiction pass.2425## `args` contract (built by `/lets:research`, passed as a JSON value)2627| key | type | meaning |28|---|---|---|29| `question` | string | the original research question |30| `subQuestions` | `[string]` | 3-6 sub-questions from the in-context decompose (a narrow question is a single entry) |31| `projectEnabled` | boolean | `--project` was passed - ground findings against this repo |32| `projectContext` | string (only if `projectEnabled`) | the in-context Step 2.5 repo grounding block |33| `projectRoot` | string | absolute project root (the read boundary; fetched content never widens it) |34| `claudeMd` | string (only if `projectEnabled`) | CLAUDE.md context |35| `asOf` | string `YYYY-MM-DD` | REQUIRED - the as-of date; the workflow can't compute the date (no `Date.now`) |3637## Returns3839`{ claims[], sources[], as_of, web{status}, counts{sub_questions, sub_questions_failed, claims, flagged, verify_failed} }`. `claims[]` are merged + per-claim `flagged[]` (empty `flagged` == cross-checked and clean); the command synthesizes the prose answer from these (there is **no `answer` key**). `sources` is the deduped `[{title,url}]` list to surface. `web.status` is `ok` | `empty` | `no_tool` | `failed`: anything other than `ok` means the command MUST open with the NO-LIVE-SOURCES banner (`NO LIVE SOURCES - model knowledge as of <cutoff>`) and NEVER fabricate URLs. **`counts.verify_failed` is an INTEGER** - the number of claims whose cross-check was attempted but errored; those claims carry `unverified (cross-check errored)` and are kept UNVERIFIED (not validated) - anti-silent-fail, the command should tell the user "N of M claims kept unverified". `counts.sub_questions_failed` flags partial coverage. Anti-silent-fail: `counts.claims === 0` means every sub-question errored / no web data - the command must offer the standard path, never render a fabricated result.4041## Constraints (Dynamic Workflow runtime)4243- No filesystem - the script returns data; `/lets:research` renders + records.44- No sibling `import` - all logic stays inline.45- No `Date.now()` / `Math.random()` / `new Date()` - the as-of date comes via `args.asOf`.46- Top-level `await`/`return` are used, so the file is NOT Node-importable - no committed unit test; the pure logic (`mergeClaims`, `applyVerdicts`, `collectSources`) is deterministically checkable on a copy, and the chain is kept in sync with `research.md` (Step 3 / Step 4 / W4) prose by discipline + validated by the live smoke test.4748## Keep-in-sync notes (do NOT "simplify" these away)4950- **Single `lets:skeptic` per claim is INTENTIONAL** - do NOT collapse to one holistic batch skeptic with an array schema; that breaks `skeptic.md`'s `{real, confidence, reason}` single-object contract.51- **No deterministic contradiction pass** - contradiction flagging is the skeptic's job (it gets the siblings; RESEARCH-VERIFY authorizes the comparison). Do NOT reintroduce a pure-function `findContradictions`: it can't see claim stance and false-flags complementary findings.52- **`verify_failed` is a per-claim INTEGER counter** (mirrors review.workflow.js) - do not collapse it to a boolean.53- **The research subagent is the DEFAULT web subagent (no `agentType`)** - `lets:*` agents have no web tools; do NOT "tidy" it to a `lets:*` type (it would self-report `used_web_search=false` and spuriously trip NO-LIVE-SOURCES every run).54- **`evidence` MUST quote/paraphrase source material** - do NOT relax it to a summary; the skeptic's no-web "unsupported" check depends on it.55- **The per-sub-question claim cap (2-5) bounds the skeptic fan-out** - do NOT remove it (research claims have no natural bound, unlike review findings bounded by the diff).56- **Consume `parallel(...)` output via `.filter(Boolean)`** (both phases) - the house defensive idiom (review/backlog/opinion all do it). A slot can be null if a thunk throws synchronously; never destructure a null after the expensive fan-out.57- **Untrusted-content fencing is load-bearing** - fetched pages (researchPrompt) AND the claim/evidence/siblings handed to the skeptic (skepticPrompt) are untrusted web data, fenced/marked as data-not-instructions. Project grounding under `--project` is TRUSTED and must NOT be fenced as untrusted. Do not drop these markers.