* 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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user