feat(#1689): per-plan agent_hint executor routing (#3417)

* feat(#1689): per-plan agent_hint executor routing

Option A per-plan specialist routing: a plan with an `agent_hint:` frontmatter field is dispatched to that subagent instead of gsd-executor when it resolves on the active runtime; absent/unresolved/disabled falls back to gsd-executor (byte-identical). Default-on via workflow.agent_hint_routing.

- src/phase.cts: parse agent_hint into the plan-index JSON (plan_json.agent_hint)
- agent-install-check.cts: resolveAgentHint() reuses getAgentsDir + runtime filename variants; probes project + global agent dirs; fails closed; rejects path-traversing names
- gsd-tools.cjs: 'resolve-agent' query route (fail-closed to gsd-executor; --raw/--json)
- execute-phase.md: lean per-plan reference + {EXECUTOR_TYPE} placeholder (host stays under the ADR-857 Phase 6 byte ceiling)
- execute-phase/steps/per-plan-executor-routing.md: resolution logic (Agent()-based dispatch; advisory on orchestrator-worktree)
- config: workflow.agent_hint_routing (validKey, default-on via SCHEMA_DEFAULTS, boolean validator)
- docs (CONFIGURATION.md, plan-md.md), changeset, tests/agent-hint-routing-1689.test.cjs (17 tests)

* chore(#1689): backfill changeset PR number (#3417)

* chore(#1689): regenerate install-tree fixtures for new workflow fragment

* chore(#1689): ack deliberate execute-phase.md growth (agent_hint routing)

* test(#1689): SPAWN contract allows parameterized subagent_type placeholder

agent-frontmatter's spawn-type checks scanned subagent_type="..." as a
concrete agent name. execute-phase now uses subagent_type="{EXECUTOR_TYPE}"
(a runtime placeholder resolved via resolve-agent, default gsd-executor).
Skip {TOKEN} placeholders in both the known-type and <available_agent_types>
checks; execute-phase still lists the built-in roster incl. gsd-executor.

* fix(#1689): CI conformance for the routing fragment

- per-plan-executor-routing.md: add the canonical runtime-launcher preamble to
  its gsd_run block (runtime-launcher-parity #373), matching sibling step fragments.
- agent-install-check.cts: drop a literal ~/.claude/agents path from the
  resolveAgentHint JSDoc so it does not leak into the compiled engine .cjs
  (cline install leak guard).

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-13 23:10:52 -04:00
committed by GitHub
parent 470389f3a2
commit 7976b1ca0d
34 changed files with 504 additions and 23 deletions

View File

@@ -172,21 +172,7 @@ function checkAgentsInstalled(runtime?: string, projectRoot?: string): AgentsIns
}
for (const agent of expectedAgents) {
const agentFile = path.join(agentsDir, `${agent}.md`);
const agentFileCopilot = path.join(agentsDir, `${agent}.agent.md`);
const agentFileCodex = path.join(agentsDir, `${agent}.toml`);
const agentFileKimiYaml = path.join(agentsDir, 'subagents', `${agent}.yaml`);
const agentFileKimiPrompt = path.join(agentsDir, 'subagents', `${agent}.md`);
const kimiAgentInstalled =
resolvedRuntime === 'kimi' &&
fs.existsSync(agentFileKimiYaml) &&
fs.existsSync(agentFileKimiPrompt);
if (
fs.existsSync(agentFile) ||
fs.existsSync(agentFileCopilot) ||
fs.existsSync(agentFileCodex) ||
kimiAgentInstalled
) {
if (agentFileExists(agentsDir, agent, resolvedRuntime)) {
installed.push(agent);
} else {
missing.push(agent);
@@ -359,9 +345,71 @@ function checkCodexModelPosture(runtime?: string, projectRoot?: string): CodexMo
};
}
/**
* Probe a single agents dir for `<name>` across runtime filename variants.
* Mirrors {@link checkAgentsInstalled}'s probe (`.md`, `.agent.md`, `.toml`,
* and the kimi `subagents/<name>.{yaml,md}` pair) so the two can never disagree
* about which on-disk shapes count as "installed". Not exported — internal to
* {@link resolveAgentHint}.
*/
function agentFileExists(agentsDir: string, name: string, runtime: string): boolean {
const base = path.join(agentsDir, `${name}.md`);
const copilot = path.join(agentsDir, `${name}.agent.md`);
const codex = path.join(agentsDir, `${name}.toml`);
if (fs.existsSync(base) || fs.existsSync(copilot) || fs.existsSync(codex)) {
return true;
}
// kimi requires BOTH the persona yaml and the prompt md (same as checkAgentsInstalled).
const kimiYaml = path.join(agentsDir, 'subagents', `${name}.yaml`);
const kimiPrompt = path.join(agentsDir, 'subagents', `${name}.md`);
return runtime === 'kimi' && fs.existsSync(kimiYaml) && fs.existsSync(kimiPrompt);
}
/**
* Resolve a per-plan `agent_hint` specialist name to a dispatchable subagent
* type on the active runtime (#1689). Unlike {@link checkAgentsInstalled},
* which validates the fixed GSD roster, this answers "does an agent file for
* this ARBITRARY name exist in the active runtime's agent dir(s)?" — so a plan
* can opt into a domain specialist (e.g. a Flutter engineer) that shares the
* gsd-executor contract without being part of the built-in roster.
*
* Probes BOTH the runtime-canonical agents dir ({@link getAgentsDir}, which
* honors `GSD_AGENTS_DIR`, project-local manifest-backed installs, and the
* claude install-relative path) AND the runtime's global config agents dir, so
* a specialist installed at either level is recognized. The decision in #1689
* explicitly requires consulting the active runtime's agent dir rather than
* only the Claude runtime's user-global and project-local agent dirs.
*
* @returns the name when a matching agent file exists; `null` when it does not
* (the caller falls back to `gsd-executor`). An empty/whitespace name always
* returns `null`.
*/
function resolveAgentHint(name: string, runtime?: string, projectRoot?: string): string | null {
const trimmed = String(name ?? '').trim();
if (trimmed === '') return null;
// A hint is a bare agent name. Reject path separators and `..` so a value
// like `../../README` cannot path-traverse out of the agents dir via
// path.join and match an unrelated file — that would echo an invalid
// subagent_type and block the wave, defeating fail-closed resolution.
if (trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('..')) return null;
const resolvedRuntime = runtime ?? (process.env['GSD_RUNTIME'] || 'claude');
const candidateDirs = new Set<string>();
candidateDirs.add(getAgentsDir(resolvedRuntime, projectRoot));
candidateDirs.add(path.join(getGlobalConfigDir(resolvedRuntime), 'agents'));
for (const dir of candidateDirs) {
if (agentFileExists(dir, trimmed, resolvedRuntime)) {
return trimmed;
}
}
return null;
}
export = {
getAgentsDir,
checkAgentsInstalled,
checkCodexModelPosture,
POSTURE_REASON,
resolveAgentHint,
};

View File

@@ -91,6 +91,9 @@ const SCHEMA_DEFAULTS: Record<string, unknown> = {
'planner.stall_detect_interval_minutes': 5,
'planner.stall_threshold_minutes': 10,
'git.create_tag': true,
// #1689: per-plan agent_hint executor routing — default-on. A no-op for plans
// without an agent_hint field, so existing dispatch is byte-identical.
'workflow.agent_hint_routing': true,
// Derived from the defaults manifest rather than restated, so the manifest
// stays the single source of truth for the smart-zone budget (#2630).
'workflow.smart_zone_tokens': CONFIG_DEFAULTS.smart_zone_tokens,
@@ -808,6 +811,13 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string |
}
}
// Per-plan executor routing via agent_hint frontmatter (#1689)
if (kp === 'workflow.agent_hint_routing') {
if (typeof parsedValue !== 'boolean') {
error(`Invalid workflow.agent_hint_routing '${val}'. Must be a boolean (true or false).`);
}
}
// #3086 — git.create_tag: boolean only
if (kp === 'git.create_tag') {
if (typeof parsedValue !== 'boolean') {

View File

@@ -594,6 +594,8 @@ interface RawPlan {
hasSummary: boolean;
/** #2830: true iff this plan's own SUMMARY declares `status: halted` (a designed stop). */
halted: boolean;
/** #1689: optional per-plan specialist executor hint (frontmatter `agent_hint:`). null when unset. */
agentHint: string | null;
}
/**
@@ -814,6 +816,18 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)];
}
// #1689: optional per-plan specialist executor hint. Read verbatim here; the
// orchestrator resolves it against the active runtime's agent dir at dispatch
// time (execute-phase.md -> `gsd_run query resolve-agent`), falling back to
// gsd-executor when the field is unset or the named agent does not resolve.
let agentHint: string | null = null;
const fmAgentHint = fm['agent_hint'];
if (fmAgentHint !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
const hintStr = String(fmAgentHint).trim();
agentHint = hintStr !== '' ? hintStr : null;
}
const hasSummary = !unsummarizedPlanFiles.has(planFile);
// #2830: a plan can have a SUMMARY (hasSummary=true) and still be halted —
@@ -833,6 +847,7 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
autonomous,
objective: extractObjective(content) || (fm['objective'] as string | null) || null,
filesModified,
agentHint,
taskCount,
hasSummary,
halted,
@@ -935,6 +950,7 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
autonomous: rawPlan.autonomous,
objective: rawPlan.objective,
files_modified: rawPlan.filesModified,
agent_hint: rawPlan.agentHint,
task_count: rawPlan.taskCount,
has_summary: rawPlan.hasSummary,
// #2830: additive fields — halted is this plan's OWN status; blocked_by