From 2cf3840198dec4cf062753bdcc2125558347a652 Mon Sep 17 00:00:00 2001 From: Szymon Graczyk Date: Fri, 28 Aug 2026 13:00:57 +0200 Subject: [PATCH] feat(spec): use one research call by default --- README.md | 10 ++-- docs/technical-reference.md | 31 +++++++----- skills/spec/SKILL.md | 12 +++-- tests/integration/workflow-paths.bats | 30 ++++++++--- workflows/spec.js | 71 ++++++++++++++++++--------- 5 files changed, 105 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index f3610a2..950ec69 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ Each command gives the same steps and the same checks to every host. - **The checks are gates.** A script fails when the spec misses a section, or when an acceptance criterion stays unchecked. On Claude Code a hook blocks the turn. On another host the skill stops the agent from reporting the work as done. -- **Claude Code does the independent work in parallel.** `/sf:spec` and `/sf:document` run as - workflow scripts. Every other host does the same steps in order. +- **Claude Code runs workflow steps.** `/sf:spec` and `/sf:document` run as workflow scripts. + Every other host does the same steps in order. The spec stays on your machine. [Team Workflow](docs/team-workflow.md) shows how a team shares it through the ticket. @@ -53,6 +53,10 @@ The table shows the typical range for each phase. The example is a small CLI too | implement | 30k–120k | 5k–30k | | document | 20k–60k | 3k–10k | +By default, `/sf:spec` uses one schema-checked research call and one synthesis call. Set the +workflow input `parallelResearch: true` to use separate scope, criteria, and risk calls in +parallel. + Your token counts change with the size of the codebase, the complexity of the feature, and the number of iterations. For current prices, see [Claude pricing](https://www.anthropic.com/pricing). ## Command Reference @@ -67,7 +71,7 @@ Your token counts change with the size of the codebase, the complexity of the fe | Host | Install | What runs | |------|---------|-----------| -| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) — reads `~/.claude/skills` | `claude plugin install sf@spec-first` | The three commands, parallel subagents, and validation hooks | +| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) — reads `~/.claude/skills` | `claude plugin install sf@spec-first` | The three commands, workflow subagents, and validation hooks | | Hosts that read `.agents/skills`: pi, opencode, Codex CLI, GitHub Copilot CLI, Gemini CLI, Cursor, Zed, Amp, Goose, Crush, Kilo Code, Warp, Factory Droid, OpenHands | `./scripts/install.sh` | The three commands in order. The skills call the validation scripts, so the same checks run. | | Hosts that read their own directory: Cline (`~/.cline/skills`), Qwen Code (`~/.qwen/skills`), iFlow CLI (`~/.iflow/skills`) | `./scripts/install.sh --dir ` | Same as above | | Any other host with a skills directory | `./scripts/install.sh --dir ` | Same as above | diff --git a/docs/technical-reference.md b/docs/technical-reference.md index 6f258ef..043d935 100644 --- a/docs/technical-reference.md +++ b/docs/technical-reference.md @@ -3,8 +3,9 @@ ## Overview - 3 commands. Each one is a skill that states the steps and the gates for one agent to run in order. -- On Claude Code, `/sf:spec` and `/sf:document` run as workflow scripts, which start 4 and 6 - agents. `/sf:implement` runs the built-in Explore subagent, then `implement-minimal`. +- On Claude Code, `/sf:spec` starts 2 agents by default and `/sf:document` starts 6 agents. + `parallelResearch: true` makes `/sf:spec` start 4 agents. `/sf:implement` runs the built-in + Explore subagent, then `implement-minimal`. - `agents/` registers 1 subagent. The workflow scripts hold the other prompts, so those agents cost no session context. - Steps that write research output write it to `.sf/research/`. Git ignores this directory. The @@ -16,7 +17,8 @@ The `skills/` directory defines the commands. Each `SKILL.md` gives the steps an The steps below are the Claude Code path. -- `/sf:spec [REQUIREMENTS]` runs the `sf-spec` workflow. The workflow researches scope, criteria and risks in parallel, then writes `spec.md`. +- `/sf:spec [REQUIREMENTS]` runs the `sf-spec` workflow. By default, the workflow uses one + schema-checked research call for scope, criteria, and risks, then writes `spec.md`. - `/sf:implement [SPEC_OR_PATH]` runs the Explore subagent to find patterns. Then it runs `implement-minimal` to write the code. - `/sf:document [PATHS]` runs the `sf-document` workflow. The workflow analyzes the change, drafts the documents, then integrates them into `docs/`. @@ -41,17 +43,19 @@ needs no registration, so every prompt the workflows own lives in the script ins | Agent | Model | Effort | | --- | --- | --- | -| `scope`, `criteria`, `risks` in `sf-spec` | caller/host policy | `low` | +| `research-combined` in `sf-spec` (default) | caller/host policy | `low` | +| `scope`, `criteria`, `risks` in `sf-spec` (`parallelResearch: true`) | caller/host policy | `low` | | `artifacts`, `implementation`, `inventory` in `sf-document` | caller/host policy | `low` | | `synthesize-spec` in `sf-spec` | the caller's | the caller's | | `technical-docs`, `user-docs`, `integrate-docs` in `sf-document` | the caller's | the caller's | | `implement-minimal` | the caller's | the caller's | -The six extraction agents produce short structured output from text they are given at low effort. -They inherit the caller's or host's model policy and do not require Haiku access. The others make -judgment calls about what to build and what to write. They inherit the caller's tier, because that -tier is the user's choice for the session. A named tier would downgrade a user on Opus and raise -the bill for a user on Haiku. +The default `research-combined` agent and the three analysis agents produce short structured +output from text they are given at low effort. With `parallelResearch: true`, `sf-spec` uses the +three focused structured research agents instead. They inherit the caller's or host's model +policy and do not require Haiku access. The others make judgment calls about what to build and +what to write. They inherit the caller's tier, because that tier is the user's choice for the +session. A named tier would downgrade a user on Opus and raise the bill for a user on Haiku. `effort` accepts `low`, `medium`, `high`, `xhigh`, `max`, or an integer. @@ -82,12 +86,15 @@ the `sf-` prefix for that reason. { "requirements": "string — what to specify", "specPath": "string — where to write spec.md", - "templatePath": "string — the spec-template.md to follow" + "templatePath": "string — the spec-template.md to follow", + "parallelResearch": "boolean — false by default; true uses three parallel research calls" } ``` -Returns `{ specPath, criteria, risks }`, where the last two are counts. The three research -agents return validated objects, so this path writes no files under `research/`. +Returns `{ specPath, criteria, risks }`, where the last two are counts. By default, one +schema-checked research result contains the existing nested scope, criteria, and risks objects. +When `parallelResearch` is `true`, the three separate research agents return those same objects. +This path writes no files under `research/`. ### sf-document diff --git a/skills/spec/SKILL.md b/skills/spec/SKILL.md index 3edabc1..0907cf9 100644 --- a/skills/spec/SKILL.md +++ b/skills/spec/SKILL.md @@ -1,6 +1,6 @@ --- name: spec -description: Create specifications through parallel analysis +description: Create specifications from requirements disable-model-invocation: true argument-hint: "[REQUIREMENTS]" --- @@ -59,8 +59,10 @@ Say: "Spec written to `{output path}`. Run `/sf:implement` to build it." ## On Claude Code -Claude Code runs steps 1 to 3 as the `sf-spec` workflow — in parallel, schema-checked, and -without the research files. +Claude Code runs one schema-checked combined research call by default, then synthesis. The +combined result supplies scope, criteria, and risks without research files. Set +`parallelResearch: true` in the workflow input to run steps 1 to 3 as three parallel research +calls. Context arrives for free: - Branch: !`git branch --show-current 2>/dev/null` @@ -71,5 +73,7 @@ Context arrives for free: - Use the **AskUserQuestion** tool for both questions above, so execution pauses for the answer. - Bash: `bash ${CLAUDE_PLUGIN_ROOT}/scripts/spec-dir.sh $MODE ${CLAUDE_PROJECT_DIR:+$CLAUDE_PROJECT_DIR/.sf}` - Workflow tool, `name: "sf-spec"`. Pass `args` as a JSON object, never as a string: - `{"requirements": "$ARGUMENTS", "specPath": "", "templatePath": "${CLAUDE_SKILL_DIR}/spec-template.md"}` + `{"requirements": "$ARGUMENTS", "specPath": "", "templatePath": "${CLAUDE_SKILL_DIR}/spec-template.md"}`. + `parallelResearch` is a boolean and defaults to `false`; set it to `true` for three parallel + research calls. - Gate: `bash ${CLAUDE_PLUGIN_ROOT}/scripts/validate-spec.sh ${CLAUDE_PROJECT_DIR:+$CLAUDE_PROJECT_DIR/.sf}` diff --git a/tests/integration/workflow-paths.bats b/tests/integration/workflow-paths.bats index e7dfb79..de28c1f 100644 --- a/tests/integration/workflow-paths.bats +++ b/tests/integration/workflow-paths.bats @@ -13,10 +13,14 @@ load "$PROJECT_ROOT/tests/helpers/assertions.bash" HARNESS="$PROJECT_ROOT/tests/workflow-harness.mjs" export HARNESS -# Every research agent returns a valid result, so only the args handling decides the outcome. -SPEC_AGENTS='{"scope":{"include":["a flag"],"exclude":["a config file"]},"criteria":{"criteria":["the flag works"]},"risks":{"risks":["none"]},"synthesize-spec":"written"}' +# The default combined research agent returns the existing nested result shapes. +SPEC_AGENTS='{"research-combined":{"scope":{"include":["a flag"],"exclude":["a config file"]},"criteria":{"criteria":["the flag works"]},"risks":{"risks":["none"]}},"synthesize-spec":"written"}' export SPEC_AGENTS +# The opt-in path retains the three separate research agent results. +PARALLEL_SPEC_AGENTS='{"scope":{"include":["a flag"],"exclude":["a config file"]},"criteria":{"criteria":["the flag works"]},"risks":{"risks":["none"]},"synthesize-spec":"written"}' +export PARALLEL_SPEC_AGENTS + # The three analysis results document.js needs before it drafts anything. DOC_ANALYSIS='"artifacts":{"requirements":["a flag"],"outcomes":["the flag works"]},"implementation":{"files":["src/index.js"],"interfaces":["greet()"]},"inventory":{"docs":[{"path":"README.md","topic":"overview"}]}' export DOC_ANALYSIS @@ -37,19 +41,28 @@ run_workflow() { run node "$HARNESS" "$PROJECT_ROOT/workflows/$workflow" "$TEST_DIR/fixture.json" } -@test "sf-spec runs every agent when args is an object" { +@test "sf-spec runs combined research by default when args is an object" { run_workflow spec.js '{"args":{"requirements":"add a flag","specPath":"docs/spec.md","templatePath":"tpl.md"},"agents":'"$SPEC_AGENTS"'}' [ "$status" -eq 0 ] assert_output_contains '"specPath":"docs/spec.md"' - assert_output_contains '"agents":["scope","criteria","risks","synthesize-spec"]' + assert_output_contains '"agents":["research-combined","synthesize-spec"]' assert_output_contains '"phases":["Research","Synthesis"]' assert_output_contains '"agentOptions":' + assert_output_contains '"label":"research-combined","phase":"Research","schema":' + assert_output_contains '"effort":"low"},{"label":"synthesize-spec","phase":"Synthesis"' + refute_output_contains '"model":' +} + +@test "sf-spec runs separate research agents when parallel research is enabled" { + run_workflow spec.js '{"args":{"requirements":"add a flag","specPath":"docs/spec.md","templatePath":"tpl.md","parallelResearch":true},"agents":'"$PARALLEL_SPEC_AGENTS"'}' + + [ "$status" -eq 0 ] + assert_output_contains '"agents":["scope","criteria","risks","synthesize-spec"]' assert_output_contains '"label":"scope","phase":"Research","schema":' assert_output_contains '"effort":"low"},{"label":"criteria","phase":"Research","schema":' assert_output_contains '"effort":"low"},{"label":"risks","phase":"Research","schema":' assert_output_contains '"effort":"low"},{"label":"synthesize-spec","phase":"Synthesis"' - refute_output_contains '"model":' } @test "sf-spec treats a JSON-string args the same as an object" { @@ -72,12 +85,13 @@ run_workflow() { assert_output_contains '"agents":[]' } -@test "sf-spec skips synthesis when a research agent returns nothing" { - # A dead subagent resolves to null, so the fixture omits the risks label. - run_workflow spec.js '{"args":{"requirements":"add a flag","specPath":"docs/spec.md","templatePath":"tpl.md"},"agents":{"scope":{"include":["a flag"],"exclude":[]},"criteria":{"criteria":["it works"]}}}' +@test "sf-spec skips synthesis when combined research returns nothing" { + # A dead subagent resolves to null, so the fixture omits the combined research label. + run_workflow spec.js '{"args":{"requirements":"add a flag","specPath":"docs/spec.md","templatePath":"tpl.md"},"agents":{"synthesize-spec":"written"}}' [ "$status" -eq 0 ] assert_output_contains 'Research incomplete' + assert_output_contains '"agents":["research-combined"]' refute_output_contains 'synthesize-spec' } diff --git a/workflows/spec.js b/workflows/spec.js index 84b9709..dc58bb2 100644 --- a/workflows/spec.js +++ b/workflows/spec.js @@ -1,9 +1,9 @@ export const meta = { name: 'sf-spec', - description: 'Spec First: research scope, criteria and risks in parallel, then write spec.md', + description: 'Spec First: research scope, criteria and risks, then write spec.md', whenToUse: 'The /sf:spec skill calls this. Do not call it directly.', phases: [ - { title: 'Research', detail: 'scope, criteria and risks at the same time' }, + { title: 'Research', detail: 'structured scope, criteria and risks research' }, { title: 'Synthesis', detail: 'merge the research into spec.md' }, ], } @@ -35,11 +35,22 @@ const RISKS = { }, } +const RESEARCH = { + type: 'object', + required: ['scope', 'criteria', 'risks'], + properties: { + scope: SCOPE, + criteria: CRITERIA, + risks: RISKS, + }, +} + // The caller is a model, so args may arrive as a JSON string instead of an object. const input = typeof args === 'string' ? JSON.parse(args) : (args ?? {}) const requirements = input.requirements const specPath = input.specPath const templatePath = input.templatePath +const parallelResearch = input.parallelResearch === true if (!requirements || !specPath || !templatePath) { return { error: 'sf-spec needs requirements, specPath and templatePath in args.' } @@ -47,26 +58,42 @@ if (!requirements || !specPath || !templatePath) { phase('Research') -const [scope, criteria, risks] = await parallel([ - () => agent( - `Define the narrowest viable scope for these requirements.\n\nRequirements: ${requirements}\n\n` + - `${RULES}\nExclude everything that is not needed now. Challenge feature creep. If a ` + - `requirement is unclear, exclude it.`, - { label: 'scope', phase: 'Research', schema: SCOPE, effort: 'low' }, - ), - () => agent( - `Write the simplest testable pass/fail conditions for these requirements.\n\n` + - `Requirements: ${requirements}\n\n${RULES}\nNo enterprise metrics unless the requirements ` + - `ask for them. "It works" is better than "it is optimal".`, - { label: 'criteria', phase: 'Research', schema: CRITERIA, effort: 'low' }, - ), - () => agent( - `Identify the blockers for these requirements.\n\nRequirements: ${requirements}\n\n` + - `${RULES}\nBlockers only, not every possible risk. Challenge assumptions. Essential edge ` + - `cases only.`, - { label: 'risks', phase: 'Research', schema: RISKS, effort: 'low' }, - ), -]) +let scope +let criteria +let risks + +if (parallelResearch) { + [scope, criteria, risks] = await parallel([ + () => agent( + `Define the narrowest viable scope for these requirements.\n\nRequirements: ${requirements}\n\n` + + `${RULES}\nExclude everything that is not needed now. Challenge feature creep. If a ` + + `requirement is unclear, exclude it.`, + { label: 'scope', phase: 'Research', schema: SCOPE, effort: 'low' }, + ), + () => agent( + `Write the simplest testable pass/fail conditions for these requirements.\n\n` + + `Requirements: ${requirements}\n\n${RULES}\nNo enterprise metrics unless the requirements ` + + `ask for them. "It works" is better than "it is optimal".`, + { label: 'criteria', phase: 'Research', schema: CRITERIA, effort: 'low' }, + ), + () => agent( + `Identify the blockers for these requirements.\n\nRequirements: ${requirements}\n\n` + + `${RULES}\nBlockers only, not every possible risk. Challenge assumptions. Essential edge ` + + `cases only.`, + { label: 'risks', phase: 'Research', schema: RISKS, effort: 'low' }, + ), + ]) +} else { + const research = await agent( + `Define the narrowest viable scope, simplest testable pass/fail conditions, and blockers for ` + + `these requirements.\n\nRequirements: ${requirements}\n\n${RULES}\nExclude everything that ` + + `is not needed now. Challenge feature creep and assumptions. If a requirement is unclear, ` + + `exclude it. Blockers only, not every possible risk. Essential edge cases only. No enterprise ` + + `metrics unless the requirements ask for them. "It works" is better than "it is optimal".`, + { label: 'research-combined', phase: 'Research', schema: RESEARCH, effort: 'low' }, + ); + ({ scope, criteria, risks } = research ?? {}) +} if (!scope || !criteria || !risks) { return { error: 'Research incomplete. Rerun /sf:spec.' }