* fix(workstream): normalize migrate-name to valid slug * docs(context): record workstream migrate-name slug invariant * fix(catalog-cjs): balanced fallback for unknown profile (CR finding A) profiles[profile] could return undefined for any profile key absent from the catalog entry, causing downstream callers like formatAgentToModelMapAsTable to crash on .length. Add ?? profiles.balanced fallback to match the SDK adapter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(sdk): anchor path resolution on import.meta.url not cwd (CR finding B) resolve(process.cwd(), '..') breaks when Vitest is invoked from the repo root because cwd is already the repo root and '..' goes one level above. Replace with a file-relative path using fileURLToPath(new URL('../../../', import.meta.url)) anchored at the test file's location (sdk/src/query/). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: derive Group B runtime list from catalog (CR finding C) Hardcoded ['kilo', 'cline', ...] throws TypeError if a runtime name is removed from the catalog. Derive group B dynamically via Object.keys(catalog.runtimeTierDefaults).filter(r => !r.opus) so the test never goes stale and auto-covers future Group B additions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(workflow): add hermes to Step B runtime options (CR finding D) hermes appears in the Group A built-in defaults table but was missing from the AskUserQuestion options in Step B, forcing users to manually type it via 'Other (Group B or custom)'. Add explicit hermes entry for UI consistency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(config): refresh dynamic_routing tier table; fix stale L671 (findings E+F) Finding E: tier table was missing 6 heavy-tier agents and 15 standard/light agents added by this PR. Updated all three rows to match catalog routingTier assignments (33 agents total). Finding F: removed stale '18 of 31' claim and agent enumeration; replaced with accurate note that all 33 agents have explicit catalog entries. Updated authoritative source pointers to model-catalog.cjs / model-catalog.ts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(core): add profile-fallback unit tests for quality and budget (CR nitpick G) The PR introduced quality→opus and budget→haiku unknown-agent fallbacks but only balanced→sonnet and inherit→inherit were tested. Add two tests covering the remaining two branches to complete coverage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * adr: define planning workspace and worktree seam * refactor(worktree): extract worktree safety policy module * refactor(workstream): extract active workstream pointer store seam * test(worktree): cover policy branch paths and persist seam guardrails * refactor(worktree): centralize health inventory seam for W017 * fix(workspace): align SDK project path policy with CJS planningDir * refactor(query): unify SDK planning path projection seam * refactor(init): route workspace projection through planningPaths seam * docs(adr): add SDK architecture and planning path ADRs * refactor(worktree): deepen name, pointer, inventory, and config seams * docs(config): harmonize claude-opus-4-6 to 4-7 in resolve_model_ids example (CR finding 2) * fix(sdk): return undefined for model_profile='inherit' sentinel (CR finding 3) * docs(adr): renumber conflicting 0003-sdk-package-seam-module to 0007, update seam-map reference (CR finding 4) * fix(workstream): align CJS and SDK name validation to accept dots, guard path traversal via includes('..') (CR finding 5) * fix(sdk): guard writeActiveWorkstream against non-existent workstream directory, k014/k031 parity (CR finding 6) * chore(changeset): add #3269 changeset (CR finding 1 — proper changeset for this PR) * docs(inventory): register 3 new CLI modules in INVENTORY.md/MANIFEST (active-workstream-store, workstream-name-policy, worktree-safety) * fix(sdk): use relPlanningPath(workstream) in planningPaths, fix setActiveWorkstream/getActiveWorkstream name errors in workstream.ts * fix(sdk): validate GSD_WORKSTREAM in planningPaths before use (#3269 regression) planningPaths() called resolveWorkspaceContext() which returned GSD_WORKSTREAM raw (no validation). An invalid value like '../evil' was used as effectiveWorkstream, constructing a bad path; roadmapAnalyze() caught the ENOENT and returned a no-phase_count error object instead of the root ROADMAP result. Fix: validate envCtx.workstream with validateWorkstreamName() in planningPaths() before accepting it as effectiveWorkstream. Invalid env → null → root .planning/ fallback, preserving the bug-2791 contract: invalid GSD_WORKSTREAM is silently ignored and falls back to the root context (phase_count: 0 for empty root ROADMAP). The bug-2791 regression test now passes. No other call sites read GSD_WORKSTREAM without validation: query-runtime-context.ts already validates; cli.ts already validates; context-engine.ts takes a caller-validated workstream parameter. Closes #3268 (regression introduced by #3269 workstream-name-policy work). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
/**
|
|
* Session runner — orchestrates Agent SDK query() calls for plan execution.
|
|
*
|
|
* Takes a parsed plan, builds the executor prompt, configures query() options,
|
|
* processes the message stream, and extracts results into a typed PlanResult.
|
|
*/
|
|
|
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
import type { SDKMessage, SDKResultMessage, SDKResultSuccess, SDKResultError } from '@anthropic-ai/claude-agent-sdk';
|
|
import type { ParsedPlan, PlanResult, SessionOptions, SessionUsage, GSDCostUpdateEvent, PhaseStepType } from './types.js';
|
|
import { GSDEventType, PhaseType } from './types.js';
|
|
import type { GSDConfig } from './config.js';
|
|
import { buildExecutorPrompt, parseAgentTools, DEFAULT_ALLOWED_TOOLS } from './prompt-builder.js';
|
|
import type { GSDEventStream, EventStreamContext } from './event-stream.js';
|
|
import { getToolsForPhase } from './tool-scoping.js';
|
|
import { detectRuntime } from './query/helpers.js';
|
|
import { resolveRuntimeTierDefault } from './model-catalog.js';
|
|
|
|
// ─── Model resolution ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve model identifier from options or config profile.
|
|
*
|
|
* Priority: explicit model option > config model_profile > default.
|
|
*
|
|
* Runtime-aware (#2832): the profile -> Claude-id map only applies when the
|
|
* project is targeting the Claude runtime. For Codex, Gemini, OpenCode, etc.,
|
|
* forcing a Claude model id (e.g. 'claude-sonnet-4-6') silently routes the
|
|
* autonomous run through the Claude path, which is wrong for those runtimes.
|
|
* In those cases — and whenever `resolve_model_ids: "omit"` is set — leave
|
|
* `model` unset so the runtime falls back to its configured default.
|
|
*/
|
|
function resolveModel(options?: SessionOptions, config?: GSDConfig): string | undefined {
|
|
if (options?.model) return options.model;
|
|
|
|
// Honor the explicit "don't resolve model ids" config knob (#2652, #2832).
|
|
// Mirrors `query/config-query.ts` resolve_model_ids === 'omit' branch.
|
|
if ((config as Record<string, unknown> | undefined)?.resolve_model_ids === 'omit') {
|
|
return undefined;
|
|
}
|
|
|
|
// Profile -> Claude id map. Applies only on the Claude runtime.
|
|
// Use `detectRuntime` so `GSD_RUNTIME` env precedence is honored — a Codex
|
|
// run with a Claude-shaped config must NOT be silently routed to Claude.
|
|
const runtime = detectRuntime({
|
|
runtime: (config as Record<string, unknown> | undefined)?.runtime,
|
|
});
|
|
if (runtime !== 'claude') {
|
|
// Non-Claude runtimes: never inject a Claude id from the profile map.
|
|
return undefined;
|
|
}
|
|
|
|
if (config?.model_profile) {
|
|
const profile = String(config.model_profile).toLowerCase();
|
|
if (profile === 'inherit') return undefined;
|
|
const tier = profile === 'quality' ? 'opus'
|
|
: (profile === 'budget' || profile === 'speed') ? 'haiku'
|
|
: (profile === 'balanced' || profile === 'adaptive') ? 'sonnet'
|
|
: null;
|
|
if (!tier) return config.model_profile;
|
|
return resolveRuntimeTierDefault('claude', tier)?.model;
|
|
}
|
|
|
|
return undefined; // Let SDK use its default
|
|
}
|
|
|
|
// ─── Session runner ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Run a plan execution session via the Agent SDK query() function.
|
|
*
|
|
* Builds the executor prompt from the parsed plan, configures query() with
|
|
* appropriate permissions, tool restrictions, and budget limits, then iterates
|
|
* the message stream to extract the result.
|
|
*
|
|
* @param plan - Parsed plan structure
|
|
* @param config - GSD project configuration
|
|
* @param options - Session overrides (maxTurns, budget, model, etc.)
|
|
* @param agentDef - Raw agent definition content (optional, for tool/role extraction)
|
|
* @returns Typed PlanResult with cost, duration, success/error status
|
|
*/
|
|
export async function runPlanSession(
|
|
plan: ParsedPlan,
|
|
config: GSDConfig,
|
|
options?: SessionOptions,
|
|
agentDef?: string,
|
|
eventStream?: GSDEventStream,
|
|
streamContext?: EventStreamContext,
|
|
phaseDir?: string,
|
|
): Promise<PlanResult> {
|
|
// Build the executor prompt
|
|
const executorPrompt = buildExecutorPrompt(plan, { agentDef, phaseDir });
|
|
|
|
// Resolve allowed tools — from agent definition or defaults
|
|
const allowedTools = options?.allowedTools ??
|
|
(agentDef ? parseAgentTools(agentDef) : DEFAULT_ALLOWED_TOOLS);
|
|
|
|
// Resolve model
|
|
const model = resolveModel(options, config);
|
|
|
|
// Configure query options
|
|
const maxTurns = options?.maxTurns ?? 50;
|
|
const maxBudgetUsd = options?.maxBudgetUsd ?? 5.0;
|
|
const cwd = options?.cwd ?? process.cwd();
|
|
|
|
const queryStream = query({
|
|
prompt: `Execute this plan:\n\n${plan.objective || 'Execute the plan tasks below.'}`,
|
|
options: {
|
|
systemPrompt: {
|
|
type: 'preset',
|
|
preset: 'claude_code',
|
|
append: executorPrompt,
|
|
},
|
|
settingSources: ['project'],
|
|
allowedTools,
|
|
permissionMode: 'bypassPermissions',
|
|
allowDangerouslySkipPermissions: true,
|
|
maxTurns,
|
|
maxBudgetUsd,
|
|
cwd,
|
|
...(model ? { model } : {}),
|
|
},
|
|
});
|
|
|
|
return processQueryStream(queryStream, eventStream, streamContext);
|
|
}
|
|
|
|
// ─── Result extraction ───────────────────────────────────────────────────────
|
|
|
|
function isResultMessage(msg: SDKMessage): msg is SDKResultMessage {
|
|
return msg.type === 'result';
|
|
}
|
|
|
|
function isSuccessResult(msg: SDKResultMessage): msg is SDKResultSuccess {
|
|
return msg.subtype === 'success';
|
|
}
|
|
|
|
function isErrorResult(msg: SDKResultMessage): msg is SDKResultError {
|
|
return msg.subtype !== 'success';
|
|
}
|
|
|
|
function emptyUsage(): SessionUsage {
|
|
return {
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
};
|
|
}
|
|
|
|
function extractUsage(msg: SDKResultMessage): SessionUsage {
|
|
const u = msg.usage;
|
|
return {
|
|
inputTokens: u.input_tokens ?? 0,
|
|
outputTokens: u.output_tokens ?? 0,
|
|
cacheReadInputTokens: u.cache_read_input_tokens ?? 0,
|
|
cacheCreationInputTokens: u.cache_creation_input_tokens ?? 0,
|
|
};
|
|
}
|
|
|
|
function extractResult(msg: SDKResultMessage): PlanResult {
|
|
const base = {
|
|
sessionId: msg.session_id,
|
|
totalCostUsd: msg.total_cost_usd,
|
|
durationMs: msg.duration_ms,
|
|
usage: extractUsage(msg),
|
|
numTurns: msg.num_turns,
|
|
};
|
|
|
|
if (isSuccessResult(msg)) {
|
|
return {
|
|
...base,
|
|
success: true,
|
|
};
|
|
}
|
|
|
|
// Error result
|
|
const errorMsg = msg as SDKResultError;
|
|
return {
|
|
...base,
|
|
success: false,
|
|
error: {
|
|
subtype: errorMsg.subtype,
|
|
messages: errorMsg.errors ?? [],
|
|
},
|
|
};
|
|
}
|
|
|
|
// ─── Shared stream processing ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Process a query() message stream, emit events, and extract the result.
|
|
* Shared between runPlanSession and runPhaseStepSession to avoid duplication.
|
|
*/
|
|
async function processQueryStream(
|
|
queryStream: AsyncIterable<SDKMessage>,
|
|
eventStream?: GSDEventStream,
|
|
streamContext?: EventStreamContext,
|
|
): Promise<PlanResult> {
|
|
let resultMessage: SDKResultMessage | undefined;
|
|
|
|
try {
|
|
for await (const message of queryStream) {
|
|
if (eventStream) {
|
|
eventStream.mapAndEmit(message, streamContext ?? {});
|
|
}
|
|
if (isResultMessage(message)) {
|
|
resultMessage = message;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
return {
|
|
success: false,
|
|
sessionId: '',
|
|
totalCostUsd: 0,
|
|
durationMs: 0,
|
|
usage: emptyUsage(),
|
|
numTurns: 0,
|
|
error: {
|
|
subtype: 'error_during_execution',
|
|
messages: [err instanceof Error ? err.message : String(err)],
|
|
},
|
|
};
|
|
}
|
|
|
|
if (!resultMessage) {
|
|
return {
|
|
success: false,
|
|
sessionId: '',
|
|
totalCostUsd: 0,
|
|
durationMs: 0,
|
|
usage: emptyUsage(),
|
|
numTurns: 0,
|
|
error: {
|
|
subtype: 'error_during_execution',
|
|
messages: ['No result message received from query stream'],
|
|
},
|
|
};
|
|
}
|
|
|
|
const result = extractResult(resultMessage);
|
|
|
|
if (eventStream) {
|
|
const cost = eventStream.getCost();
|
|
eventStream.emitEvent({
|
|
type: GSDEventType.CostUpdate,
|
|
timestamp: new Date().toISOString(),
|
|
sessionId: resultMessage.session_id,
|
|
phase: streamContext?.phase,
|
|
planName: streamContext?.planName,
|
|
sessionCostUsd: result.totalCostUsd,
|
|
cumulativeCostUsd: cost.cumulative,
|
|
} as GSDCostUpdateEvent);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ─── Phase step session runner ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Map PhaseStepType to PhaseType for tool scoping.
|
|
* PhaseStepType includes 'advance' which has no session-level equivalent.
|
|
*/
|
|
function stepTypeToPhaseType(step: PhaseStepType): PhaseType {
|
|
const mapping: Record<string, PhaseType> = {
|
|
discuss: PhaseType.Discuss,
|
|
research: PhaseType.Research,
|
|
plan: PhaseType.Plan,
|
|
plan_check: PhaseType.Verify,
|
|
execute: PhaseType.Execute,
|
|
verify: PhaseType.Verify,
|
|
};
|
|
return mapping[step] ?? PhaseType.Execute;
|
|
}
|
|
|
|
/**
|
|
* Run a phase step session via the Agent SDK query() function.
|
|
*
|
|
* Unlike runPlanSession which takes a ParsedPlan, this accepts a raw prompt
|
|
* string and a phase step type. The prompt becomes the system prompt append,
|
|
* and tools are scoped by phase type.
|
|
*
|
|
* @param prompt - Raw prompt string to append to the system prompt
|
|
* @param phaseStep - Phase step type (determines tool scoping)
|
|
* @param config - GSD project configuration
|
|
* @param options - Session overrides (maxTurns, budget, model, etc.)
|
|
* @param eventStream - Optional event stream for observability
|
|
* @param streamContext - Optional context for event tagging
|
|
* @returns Typed PlanResult with cost, duration, success/error status
|
|
*/
|
|
export async function runPhaseStepSession(
|
|
prompt: string,
|
|
phaseStep: PhaseStepType,
|
|
config: GSDConfig,
|
|
options?: SessionOptions,
|
|
eventStream?: GSDEventStream,
|
|
streamContext?: EventStreamContext,
|
|
): Promise<PlanResult> {
|
|
const phaseType = stepTypeToPhaseType(phaseStep);
|
|
const allowedTools = options?.allowedTools ?? getToolsForPhase(phaseType);
|
|
const model = resolveModel(options, config);
|
|
const maxTurns = options?.maxTurns ?? 50;
|
|
const maxBudgetUsd = options?.maxBudgetUsd ?? 5.0;
|
|
const cwd = options?.cwd ?? process.cwd();
|
|
|
|
const queryStream = query({
|
|
prompt: `Execute this phase step: ${phaseStep}`,
|
|
options: {
|
|
systemPrompt: {
|
|
type: 'preset',
|
|
preset: 'claude_code',
|
|
append: prompt,
|
|
},
|
|
settingSources: ['project'],
|
|
allowedTools,
|
|
permissionMode: 'bypassPermissions',
|
|
allowDangerouslySkipPermissions: true,
|
|
maxTurns,
|
|
maxBudgetUsd,
|
|
cwd,
|
|
...(model ? { model } : {}),
|
|
},
|
|
});
|
|
|
|
return processQueryStream(queryStream, eventStream, streamContext);
|
|
}
|