@selesai/code 0.5.29 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +1 -1
  3. package/dist/core/system-prompt.d.ts.map +1 -1
  4. package/dist/core/system-prompt.js +18 -0
  5. package/dist/core/system-prompt.js.map +1 -1
  6. package/dist/core/system-prompt.test.d.ts +2 -0
  7. package/dist/core/system-prompt.test.d.ts.map +1 -0
  8. package/dist/core/system-prompt.test.js +89 -0
  9. package/dist/core/system-prompt.test.js.map +1 -0
  10. package/dist/defaults/models.json +13 -45
  11. package/dist/defaults/settings.json +1 -2
  12. package/dist/extensions/copy-turn.test.ts +131 -0
  13. package/dist/extensions/copy-turn.ts +6 -1
  14. package/dist/extensions/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  15. package/dist/extensions/package.json +0 -1
  16. package/dist/extensions/pi-subagents/CHANGELOG.md +3 -0
  17. package/dist/extensions/pi-subagents/README.md +27 -32
  18. package/dist/extensions/pi-subagents/agents/architect.md +4 -4
  19. package/dist/extensions/pi-subagents/agents/builder.md +5 -4
  20. package/dist/extensions/pi-subagents/agents/commentator.md +3 -2
  21. package/dist/extensions/pi-subagents/agents/explorer.md +3 -2
  22. package/dist/extensions/pi-subagents/agents/recapper.md +3 -2
  23. package/dist/extensions/pi-subagents/agents/researcher.md +4 -3
  24. package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +2 -0
  25. package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +10 -9
  26. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +12 -11
  27. package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +10 -11
  28. package/dist/extensions/pi-subagents/src/agents/agent-management.ts +56 -9
  29. package/dist/extensions/pi-subagents/src/agents/task-aware-routing.ts +125 -0
  30. package/dist/extensions/pi-subagents/src/api/preflight.ts +1 -1
  31. package/dist/extensions/pi-subagents/src/extension/index.ts +5 -1
  32. package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
  33. package/dist/extensions/pi-subagents/src/extension/tool-description.ts +24 -7
  34. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +23 -5
  35. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +27 -1
  36. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +64 -6
  37. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +16 -1
  38. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +72 -18
  39. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +19 -5
  40. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +127 -31
  41. package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +4 -6
  42. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +63 -9
  43. package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +21 -0
  44. package/dist/extensions/pi-subagents/src/shared/types.ts +41 -2
  45. package/dist/extensions/pi-subagents/src/shared/utils.ts +29 -1
  46. package/dist/extensions/pi-subagents/src/slash/delegation-adapters.ts +5 -1
  47. package/dist/extensions/pi-subagents/src/tui/render.ts +28 -6
  48. package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +111 -6
  49. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +74 -43
  50. package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +36 -21
  51. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +5 -3
  52. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +20 -8
  53. package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +14 -7
  54. package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +81 -5
  55. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +49 -10
  56. package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +18 -2
  57. package/dist/extensions/pi-subagents/test/unit/agent-disabled.test.ts +1 -1
  58. package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +70 -6
  59. package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +161 -1
  60. package/dist/extensions/pi-subagents/test/unit/builtin-agent-documentation.test.ts +63 -0
  61. package/dist/extensions/pi-subagents/test/unit/capability-ceiling-agent-allowlist.test.ts +34 -0
  62. package/dist/extensions/pi-subagents/test/unit/delegation-api.test.ts +24 -0
  63. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +6 -1
  64. package/dist/extensions/pi-subagents/test/unit/notify.test.ts +29 -0
  65. package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +2 -0
  66. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +12 -0
  67. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +91 -1
  68. package/dist/extensions/pi-subagents/test/unit/task-aware-routing.test.ts +213 -0
  69. package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +23 -1
  70. package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +60 -9
  71. package/dist/skills/ponytail/SKILL.md +1 -3
  72. package/docs/plans/subagent-delegation/phase-0-correctness.md +265 -0
  73. package/docs/plans/subagent-delegation/phase-1-behavioral-contract.md +486 -0
  74. package/docs/plans/subagent-delegation/phase-2-context-controls.md +282 -0
  75. package/docs/plans/subagent-delegation/phase-3-advisory-routing.md +362 -0
  76. package/docs/plans/subagent-delegation/phase-4-optional-enforcement.md +381 -0
  77. package/package.json +2 -2
  78. package/dist/extensions/caveman/caveman-instructions.cjs +0 -11
  79. package/dist/extensions/caveman/index.js +0 -118
  80. package/dist/extensions/caveman/package.json +0 -8
  81. package/dist/extensions/caveman/test/extension.test.js +0 -203
  82. package/dist/extensions/caveman/test/helpers.test.js +0 -58
  83. package/dist/skills/caveman/SKILL.md +0 -50
@@ -91,6 +91,34 @@ import { usageBudgetExceededMessage, usageBudgetState } from "../shared/usage-bu
91
91
  import type { ContextMode } from "../shared/context-mode.ts";
92
92
  import type { ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
93
93
 
94
+ /**
95
+ * Resolve a chain step's output path and effective output mode.
96
+ *
97
+ * Explicit `output` paths pass through unchanged (absolute as-is, relative under
98
+ * the chain dir). Omitted output generates a collision-safe per-child durable
99
+ * path `<chainDir>/outputs/<flat-index>-<agent>.md` unless the caller explicitly
100
+ * used `output: false`. Omitted `outputMode` resolves to `file-only` when an
101
+ * output path is active; explicit `outputMode: "inline"` is preserved exactly.
102
+ */
103
+ function resolveChainStepOutput(input: {
104
+ behaviorOutput: string | false;
105
+ rawOutput: string | false | undefined;
106
+ rawOutputMode: "inline" | "file-only" | undefined;
107
+ chainDir: string;
108
+ flatIndex: number;
109
+ agent: string;
110
+ }): { outputPath?: string; outputMode: "inline" | "file-only" } {
111
+ const explicit = typeof input.behaviorOutput === "string" ? input.behaviorOutput : undefined;
112
+ let outputPath: string | undefined;
113
+ if (explicit) {
114
+ outputPath = path.isAbsolute(explicit) ? explicit : path.join(input.chainDir, explicit);
115
+ } else if (input.behaviorOutput === false && input.rawOutput !== false && input.rawOutput !== "false") {
116
+ outputPath = path.join(input.chainDir, "outputs", `${input.flatIndex}-${input.agent}.md`);
117
+ }
118
+ return { outputPath, outputMode: input.rawOutputMode ?? (outputPath ? "file-only" : "inline") };
119
+ }
120
+
121
+
94
122
  interface ChainExecutionDetailsInput {
95
123
  results: SingleResult[];
96
124
  includeProgress?: boolean;
@@ -353,9 +381,16 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
353
381
  ? input.worktreeSetup.worktrees[taskIndex]!.agentCwd
354
382
  : resolveChildCwd(input.cwd ?? input.ctx.cwd, task.cwd);
355
383
 
356
- const outputPath = typeof behavior.output === "string"
357
- ? (path.isAbsolute(behavior.output) ? behavior.output : path.join(input.chainDir, behavior.output))
358
- : undefined;
384
+ const resolvedTaskOutput = resolveChainStepOutput({
385
+ behaviorOutput: behavior.output,
386
+ rawOutput: task.output,
387
+ rawOutputMode: task.outputMode,
388
+ chainDir: input.chainDir,
389
+ flatIndex: childIndex,
390
+ agent: task.agent,
391
+ });
392
+ const outputPath = resolvedTaskOutput.outputPath;
393
+ const effectiveOutputMode = resolvedTaskOutput.outputMode;
359
394
  taskStr = injectSingleOutputInstruction(taskStr, outputPath, taskAgentConfig);
360
395
  const interruptController = new AbortController();
361
396
  if (input.foregroundControl) {
@@ -399,7 +434,7 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
399
434
  artifactsDir: input.artifactConfig.enabled ? input.artifactsDir : undefined,
400
435
  artifactConfig: input.artifactConfig,
401
436
  outputPath,
402
- outputMode: behavior.outputMode,
437
+ outputMode: effectiveOutputMode,
403
438
  maxSubagentDepth,
404
439
  controlConfig: input.controlConfig,
405
440
  onControlEvent: input.onControlEvent,
@@ -793,10 +828,16 @@ ${step.message}` : ""}` }],
793
828
  .map((behavior, taskIndex) => suppressProgressForReadOnlyTask(behavior, parallelTemplates[taskIndex] ?? step.parallel[taskIndex]?.task, originalTask));
794
829
  for (let taskIndex = 0; taskIndex < step.parallel.length; taskIndex++) {
795
830
  const behavior = parallelBehaviors[taskIndex]!;
796
- const outputPath = typeof behavior.output === "string"
797
- ? (path.isAbsolute(behavior.output) ? behavior.output : path.join(chainDir, behavior.output))
798
- : undefined;
799
- const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Parallel chain step ${stepIndex + 1} task ${taskIndex + 1} (${step.parallel[taskIndex]!.agent})`);
831
+ const task = step.parallel[taskIndex]!;
832
+ const resolvedOutput = resolveChainStepOutput({
833
+ behaviorOutput: behavior.output,
834
+ rawOutput: task.output,
835
+ rawOutputMode: task.outputMode,
836
+ chainDir,
837
+ flatIndex: globalTaskIndex + taskIndex,
838
+ agent: task.agent,
839
+ });
840
+ const validationError = validateFileOnlyOutputMode(resolvedOutput.outputMode, resolvedOutput.outputPath, `Parallel chain step ${stepIndex + 1} task ${taskIndex + 1} (${task.agent})`);
800
841
  if (validationError) return buildChainExecutionErrorResult(validationError, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex + taskIndex }));
801
842
  }
802
843
  progressCreated = ensureParallelProgressFile(chainDir, progressCreated, parallelBehaviors);
@@ -1049,10 +1090,16 @@ ${step.message}` : ""}` }],
1049
1090
 
1050
1091
  for (let taskIndex = 0; taskIndex < dynamicParallelStep.parallel.length; taskIndex++) {
1051
1092
  const behavior = parallelBehaviors[taskIndex]!;
1052
- const outputPath = typeof behavior.output === "string"
1053
- ? (path.isAbsolute(behavior.output) ? behavior.output : path.join(chainDir, behavior.output))
1054
- : undefined;
1055
- const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Dynamic chain step ${stepIndex + 1} item ${taskIndex + 1} (${dynamicParallelStep.parallel[taskIndex]!.agent})`);
1093
+ const task = dynamicParallelStep.parallel[taskIndex]!;
1094
+ const resolvedOutput = resolveChainStepOutput({
1095
+ behaviorOutput: behavior.output,
1096
+ rawOutput: task.output,
1097
+ rawOutputMode: task.outputMode,
1098
+ chainDir,
1099
+ flatIndex: globalTaskIndex + taskIndex,
1100
+ agent: task.agent,
1101
+ });
1102
+ const validationError = validateFileOnlyOutputMode(resolvedOutput.outputMode, resolvedOutput.outputPath, `Dynamic chain step ${stepIndex + 1} item ${taskIndex + 1} (${task.agent})`);
1056
1103
  if (validationError) {
1057
1104
  dynamicGroupStatuses[stepIndex] = { status: "failed", error: validationError };
1058
1105
  return buildChainExecutionErrorResult(validationError, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex + taskIndex }));
@@ -1289,11 +1336,18 @@ ${step.message}` : ""}` }],
1289
1336
  { scope: modelScope },
1290
1337
  );
1291
1338
 
1292
- const outputPath = typeof behavior.output === "string"
1293
- ? (path.isAbsolute(behavior.output) ? behavior.output : path.join(chainDir, behavior.output))
1294
- : undefined;
1339
+ const resolvedStepOutput = resolveChainStepOutput({
1340
+ behaviorOutput: behavior.output,
1341
+ rawOutput: stepOverride.output,
1342
+ rawOutputMode: seqStep.outputMode,
1343
+ chainDir,
1344
+ flatIndex: globalTaskIndex,
1345
+ agent: seqStep.agent,
1346
+ });
1347
+ const outputPath = resolvedStepOutput.outputPath;
1348
+ const effectiveOutputMode = resolvedStepOutput.outputMode;
1295
1349
  stepTask = injectSingleOutputInstruction(stepTask, outputPath, agentConfig);
1296
- const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Chain step ${stepIndex + 1} (${seqStep.agent})`);
1350
+ const validationError = validateFileOnlyOutputMode(effectiveOutputMode, outputPath, `Chain step ${stepIndex + 1} (${seqStep.agent})`);
1297
1351
  if (validationError) {
1298
1352
  return buildChainExecutionErrorResult(validationError, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }));
1299
1353
  }
@@ -1339,7 +1393,7 @@ ${step.message}` : ""}` }],
1339
1393
  dynamicChildren,
1340
1394
  dynamicGroupStatuses,
1341
1395
  });
1342
- r = await runSync(ctx.cwd, agents, seqStep.agent, stepTask, {
1396
+ r = await runSync(ctx.cwd, agents, seqStep.agent, stepTask, {
1343
1397
  parentSessionId: ctx.sessionManager.getSessionId() ?? undefined,
1344
1398
  capabilityCeiling: params.capabilityCeiling,
1345
1399
  context: params.contextForAgent?.(seqStep.agent),
@@ -1358,7 +1412,7 @@ ${step.message}` : ""}` }],
1358
1412
  artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
1359
1413
  artifactConfig,
1360
1414
  outputPath,
1361
- outputMode: behavior.outputMode,
1415
+ outputMode: effectiveOutputMode,
1362
1416
  maxSubagentDepth,
1363
1417
  controlConfig,
1364
1418
  onControlEvent,
@@ -62,7 +62,7 @@ import { resolveEffectiveThinking } from "../../shared/model-info.ts";
62
62
  import { MISSING_STRUCTURED_OUTPUT_CALL_ERROR, readStructuredOutput } from "../shared/structured-output.ts";
63
63
  import { formatProcessSignalError, isUnexplainedProcessSignal } from "../shared/process-signal.ts";
64
64
  import { readChildToolDiagnosticError } from "../shared/tool-availability.ts";
65
- import { captureSingleOutputSnapshot, extractChildWrittenOutput, formatSavedOutputReference, injectOutputPathSystemPrompt, resolveSingleOutput, validateFileOnlyOutputMode, type SingleOutputSnapshot } from "../shared/single-output.ts";
65
+ import { captureSingleOutputSnapshot, extractChildWrittenOutput, formatBoundedPersistenceFallback, formatSavedOutputReference, injectOutputPathSystemPrompt, resolveSingleOutput, validateFileOnlyOutputMode, type SingleOutputSnapshot } from "../shared/single-output.ts";
66
66
  import {
67
67
  buildModelCandidates,
68
68
  formatModelAttemptNote,
@@ -1282,7 +1282,12 @@ async function runSingleAttempt(
1282
1282
  reason: "completion_guard",
1283
1283
  }));
1284
1284
  }
1285
- if (options.outputPath && result.exitCode === 0) {
1285
+ // Persist whenever an output path is configured and meaningful child output
1286
+ // exists, not only for successful runs: failed children with output keep a
1287
+ // readable result file and a saved-output reference instead of raw inline
1288
+ // output. Synthetic failures without output (startup errors, empty responses)
1289
+ // stay outputState "absent" and are not promoted to a bogus saved reference.
1290
+ if (options.outputPath && (fullOutput.trim() || result.exitCode === 0)) {
1286
1291
  const resolvedOutput = resolveSingleOutput(options.outputPath, fullOutput, shared.outputSnapshot);
1287
1292
  fullOutput = stripAcceptanceReport(resolvedOutput.fullOutput);
1288
1293
  result.savedOutputPath = resolvedOutput.savedPath;
@@ -1295,9 +1300,18 @@ async function runSingleAttempt(
1295
1300
  artifactOutputByResult.set(result, fullOutput);
1296
1301
  acceptanceOutputByResult.set(result, acceptanceOutput);
1297
1302
  result.outputMode = options.outputMode ?? "inline";
1298
- result.finalOutput = options.outputMode === "file-only" && result.savedOutputPath && result.outputReference
1299
- ? result.outputReference.message
1300
- : fullOutput;
1303
+ const hasSavedReference = result.savedOutputPath && result.outputReference ? true : false;
1304
+ result.finalOutput = hasSavedReference && (options.outputMode === "file-only" || result.exitCode !== 0)
1305
+ ? result.outputReference!.message
1306
+ : result.outputSaveError && options.outputPath
1307
+ ? formatBoundedPersistenceFallback({
1308
+ error: result.outputSaveError,
1309
+ outputPath: options.outputPath,
1310
+ fullOutput,
1311
+ exitCode: result.exitCode,
1312
+ processError: result.error,
1313
+ })
1314
+ : fullOutput;
1301
1315
  result.controlEvents = allControlEvents.length ? allControlEvents : undefined;
1302
1316
  if (options.onUpdate) {
1303
1317
  const finalText = result.finalOutput || result.error || "(no output)";
@@ -25,7 +25,6 @@ import { handleWatchdogToolAction, WATCHDOG_TOOL_ACTIONS } from "../../watchdog/
25
25
  import type { MainWatchdogRuntime } from "../../watchdog/runtime.ts";
26
26
  import { buildModelCandidates, normalizeParentModel, resolveEffectiveSubagentModel, resolveModelCandidate, type ParentModel } from "../shared/model-fallback.ts";
27
27
  import type { ModelScopeConfig } from "../shared/model-scope.ts";
28
- import { aggregateParallelOutputs } from "../shared/parallel-utils.ts";
29
28
  import { recordRun } from "../shared/run-history.ts";
30
29
  import {
31
30
  buildChainInstructions,
@@ -57,7 +56,7 @@ import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
57
56
  import { usageBudgetExceededMessage, usageBudgetState, validateUsageBudgetConfig } from "../shared/usage-budget.ts";
58
57
  import { intersectSubagentCapabilityCeilings, resolveCurrentSubagentCapabilityCeiling, type ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
59
58
  import { isAgentContractV1 } from "../shared/agent-contract.ts";
60
- import { finalizeSingleOutput, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
59
+ import { finalizeSingleOutput, formatBoundedPersistenceFallback, formatSavedOutputReference, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
61
60
  import { cleanupStructuredOutputRuntime, createStructuredOutputRuntime } from "../shared/structured-output.ts";
62
61
  import { compactForegroundDetails, getSingleResultOutput, mapConcurrent, readStatus, resolveChildCwd, sumResultsCost, sumResultsUsage } from "../../shared/utils.ts";
63
62
  import { DEFAULT_GLOBAL_CONCURRENCY_LIMIT, Semaphore } from "../shared/parallel-utils.ts";
@@ -521,7 +520,12 @@ function updateRememberedForegroundChild(state: SubagentState, input: { runId: s
521
520
  updatedAt,
522
521
  ...(input.result.exitCode !== undefined ? { exitCode: input.result.exitCode } : {}),
523
522
  ...(input.result.error ? { error: input.result.error } : {}),
524
- ...(input.result.finalOutput ? { finalOutput: input.result.finalOutput } : {}),
523
+ // Reference-first remembered state: when an authoritative saved output path
524
+ // exists, do not retain the full final output in memory; status/transcript
525
+ // views recover it from `savedOutputPath` via `rememberedForegroundChildOutput`
526
+ // (runs/background/run-status.ts) and the TUI via `resultOutputForUi`
527
+ // (tui/render.ts).
528
+ ...(input.result.finalOutput && !input.result.savedOutputPath ? { finalOutput: input.result.finalOutput } : {}),
525
529
  outputState: input.result.outputState,
526
530
  outputMode: input.result.outputMode,
527
531
  savedOutputPath: input.result.savedOutputPath,
@@ -541,10 +545,11 @@ function updateRememberedForegroundChild(state: SubagentState, input: { runId: s
541
545
  ...(input.result.capabilityAudit ? { capabilityAudit: input.result.capabilityAudit } : {}),
542
546
  };
543
547
  trimRememberedForegroundRuns(state);
544
- const output = getSingleResultOutput(input.result).trim();
548
+ const reference = input.result.outputReference?.message;
549
+ const output = reference ?? getSingleResultOutput(input.result).trim();
545
550
  const success = terminalStatus === "completed";
546
551
  const summary = !success && input.result.error
547
- ? `${input.result.error}${output ? `\n\nOutput:\n${output}` : ""}`
552
+ ? `${input.result.error}${output ? `\n\n${output}` : ""}`
548
553
  : output || input.result.error || "Detached child exited without final output.";
549
554
  // A detached callback may outlive its extension runtime. Stale sessions are
550
555
  // intentionally dropped rather than routed through a replacement runtime.
@@ -1288,7 +1293,7 @@ async function resumeAsyncRun(input: {
1288
1293
  };
1289
1294
  }
1290
1295
  const runId = randomUUID().slice(0, 8);
1291
- const artifactConfig: ArtifactConfig = { ...DEFAULT_ARTIFACT_CONFIG, enabled: input.params.artifacts !== false, dir: input.deps.config.artifactDir ?? DEFAULT_ARTIFACT_CONFIG.dir };
1296
+ const artifactConfig: ArtifactConfig = { ...DEFAULT_ARTIFACT_CONFIG, enabled: input.params.artifacts === true, dir: input.deps.config.artifactDir ?? DEFAULT_ARTIFACT_CONFIG.dir };
1292
1297
  const availableModels = input.ctx.modelRegistry.getAvailable().map(toModelInfo);
1293
1298
  const contextPolicy = resolveExplicitContextPolicy(input.params);
1294
1299
  const workflowTask = (input.params.task ?? followUp) || undefined;
@@ -1354,7 +1359,7 @@ async function resumeAsyncRun(input: {
1354
1359
 
1355
1360
  const runId = randomUUID().slice(0, 8);
1356
1361
  const recoveryAgentConfig = recoveryDescriptor ? applySteeringRecoveryAgentConfig(agentConfig, recoveryDescriptor) : agentConfig;
1357
- const artifactConfig: ArtifactConfig = recoveryDescriptor?.artifactConfig ?? { ...DEFAULT_ARTIFACT_CONFIG, enabled: input.params.artifacts !== false, dir: input.deps.config.artifactDir ?? DEFAULT_ARTIFACT_CONFIG.dir };
1362
+ const artifactConfig: ArtifactConfig = recoveryDescriptor?.artifactConfig ?? { ...DEFAULT_ARTIFACT_CONFIG, enabled: input.params.artifacts === true, dir: input.deps.config.artifactDir ?? DEFAULT_ARTIFACT_CONFIG.dir };
1358
1363
  const artifactsDir = recoveryDescriptor?.artifactsDir ?? getArtifactsDir(parentSessionFile, effectiveCwd, artifactConfig.dir);
1359
1364
  const availableModels = input.ctx.modelRegistry.getAvailable().map(toModelInfo);
1360
1365
  const parentModel = input.parentModel;
@@ -1435,14 +1440,82 @@ async function resumeAsyncRun(input: {
1435
1440
  }
1436
1441
 
1437
1442
  function resultSummaryForIntercom(result: SingleResult): string {
1438
- const output = getSingleResultOutput(result);
1439
- if (result.exitCode !== 0 && result.error) {
1440
- return output ? `${result.error}\n\nOutput:\n${output}` : result.error;
1443
+ const reference = result.outputReference?.message;
1444
+ if (reference) {
1445
+ return result.exitCode !== 0 && result.error ? `${result.error}\n\n${reference}` : reference;
1441
1446
  }
1442
- return output || result.error || "(no output)";
1447
+ if (result.savedOutputPath) {
1448
+ try {
1449
+ const ref = formatSavedOutputReference(result.savedOutputPath, fs.readFileSync(result.savedOutputPath, "utf-8"));
1450
+ return result.exitCode !== 0 && result.error ? `${result.error}\n\n${ref.message}` : ref.message;
1451
+ } catch (error) {
1452
+ const fallback = formatBoundedPersistenceFallback({
1453
+ error: `Failed to read saved output: ${error instanceof Error ? error.message : String(error)}`,
1454
+ outputPath: result.savedOutputPath,
1455
+ fullOutput: getSingleResultOutput(result),
1456
+ exitCode: result.exitCode,
1457
+ processError: result.error,
1458
+ });
1459
+ return result.exitCode !== 0 && result.error ? `${result.error}\n\n${fallback}` : fallback;
1460
+ }
1461
+ }
1462
+ if (result.outputSaveError) {
1463
+ return formatBoundedPersistenceFallback({
1464
+ error: result.outputSaveError,
1465
+ outputPath: result.savedOutputPath,
1466
+ fullOutput: getSingleResultOutput(result),
1467
+ exitCode: result.exitCode,
1468
+ processError: result.error,
1469
+ });
1470
+ }
1471
+ // Legacy / `output: false` child without a durable saved path: bounded fallback
1472
+ // only, never the raw full output.
1473
+ return formatBoundedPersistenceFallback({
1474
+ error: "No saved output path is available for this child.",
1475
+ fullOutput: getSingleResultOutput(result),
1476
+ exitCode: result.exitCode,
1477
+ processError: result.error,
1478
+ });
1479
+ }
1480
+
1481
+ /**
1482
+ * Compact terminal per-child line for bridge-off foreground parallel content:
1483
+ * status plus either the saved-output reference (default/file-only), the legacy
1484
+ * inline full output when explicitly requested, or the bounded fallback.
1485
+ */
1486
+ function formatParallelChildResult(result: SingleResult, index: number): string {
1487
+ const status = resolveSubagentResultStatus({
1488
+ exitCode: result.exitCode,
1489
+ interrupted: result.interrupted,
1490
+ detached: result.detached,
1491
+ processSignal: result.processSignal,
1492
+ timedOut: result.timedOut,
1493
+ stopped: result.stopped,
1494
+ turnBudgetExceeded: result.turnBudgetExceeded,
1495
+ });
1496
+ const header = `=== Task ${index + 1}: ${result.agent} [${status}] ===`;
1497
+ if (result.outputMode === "inline") {
1498
+ const output = getSingleResultOutput(result).trim();
1499
+ return `${header}\n${output || "(no output)"}`;
1500
+ }
1501
+ const reference = result.outputReference?.message;
1502
+ if (reference) {
1503
+ const body = result.exitCode !== 0 && result.error ? `${result.error}\n${reference}` : reference;
1504
+ return `${header}\n${body}`;
1505
+ }
1506
+ return `${header}\n${formatBoundedPersistenceFallback({
1507
+ error: result.outputSaveError ?? "No saved output path is available for this child.",
1508
+ outputPath: result.savedOutputPath,
1509
+ fullOutput: getSingleResultOutput(result),
1510
+ exitCode: result.exitCode,
1511
+ processError: result.error,
1512
+ })}`;
1443
1513
  }
1444
1514
 
1445
1515
  function formatFailedSingleRunOutput(result: SingleResult, displayOutput: string): string {
1516
+ // Reference-first and persistence-failure fallbacks already carry error/status
1517
+ // and must not be re-wrapped as raw "Output:" text.
1518
+ if (result.savedOutputPath || result.outputSaveError) return displayOutput;
1446
1519
  const error = result.error || "Failed";
1447
1520
  const output = displayOutput.trim();
1448
1521
  const lines = [error];
@@ -2287,7 +2360,6 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
2287
2360
  }
2288
2361
  const rawOutput = params.output !== undefined ? params.output : a.output;
2289
2362
  const effectiveOutput = normalizeSingleOutputOverride(rawOutput, a.output);
2290
- const effectiveOutputMode = params.outputMode ?? "inline";
2291
2363
  const normalizedSkills = normalizeSkillInput(params.skill);
2292
2364
  const skills = normalizedSkills === false ? [] : normalizedSkills;
2293
2365
  const maxSubagentDepth = resolveChildMaxSubagentDepth(currentMaxSubagentDepth, a.maxSubagentDepth);
@@ -2309,7 +2381,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
2309
2381
  context: contextPolicy.contextForAgent(params.agent!),
2310
2382
  skills,
2311
2383
  output: effectiveOutput,
2312
- outputMode: effectiveOutputMode,
2384
+ outputMode: params.outputMode,
2313
2385
  outputBaseDir: resolveSingleRunOutputBaseDir(deps, artifactsDir, id),
2314
2386
  modelOverride,
2315
2387
  thinkingOverride: thinkingOverrideForTask(params.agent!, 0, modelOverride),
@@ -2610,6 +2682,19 @@ function resolveSingleRunOutputBaseDir(deps: ExecutorDeps, artifactsDir: string,
2610
2682
  : path.join(artifactsDir, "outputs", runId);
2611
2683
  }
2612
2684
 
2685
+ /**
2686
+ * Generated per-run durable output path for foreground single runs when the
2687
+ * caller omits `output` (and does not set `output: false`):
2688
+ * `<singleRunOutputBaseDir>/<runId>/result.md`. The base is the configured
2689
+ * `singleRunOutputBaseDir` when present, otherwise `{artifactsDir}/outputs`.
2690
+ */
2691
+ function resolveGeneratedSingleOutputPath(deps: ExecutorDeps, artifactsDir: string, runId: string): string {
2692
+ const base = deps.config.singleRunOutputBaseDir
2693
+ ? path.resolve(deps.expandTilde(deps.config.singleRunOutputBaseDir))
2694
+ : path.join(artifactsDir, "outputs");
2695
+ return path.join(base, runId, "result.md");
2696
+ }
2697
+
2613
2698
  function buildChainWorktreeTaskCwdError(chain: ChainStep[], sharedCwd: string): string | undefined {
2614
2699
  for (let stepIndex = 0; stepIndex < chain.length; stepIndex++) {
2615
2700
  const step = chain[stepIndex]!;
@@ -3068,8 +3153,19 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
3068
3153
 
3069
3154
  const behaviors = tasks.map((task, index) => {
3070
3155
  let behavior = suppressProgressForReadOnlyTask(resolveStepBehavior(agentConfigs[index]!, behaviorOverrides[index]!), taskTexts[index]);
3071
- if (behaviorOverrides[index]?.output === undefined && typeof behavior.output === "string" && !path.isAbsolute(behavior.output)) {
3072
- behavior = { ...behavior, output: path.join("parallel-0", `${index}-${task.agent}`, behavior.output) };
3156
+ if (behaviorOverrides[index]?.output === undefined) {
3157
+ if (typeof behavior.output === "string" && !path.isAbsolute(behavior.output)) {
3158
+ behavior = { ...behavior, output: path.join("parallel-0", `${index}-${task.agent}`, behavior.output) };
3159
+ } else if (behavior.output === false && behaviorOverrides[index]?.output !== false && behaviorOverrides[index]?.output !== "false") {
3160
+ // Omitted output: generate a collision-safe per-task durable path in the
3161
+ // existing per-task namespace (parallel-0/<index>-<agent>/result.md).
3162
+ behavior = { ...behavior, output: path.join("parallel-0", `${index}-${task.agent}`, "result.md") };
3163
+ }
3164
+ }
3165
+ // Omitted outputMode resolves to file-only whenever an output path is active;
3166
+ // explicit `outputMode: "inline"` is preserved exactly.
3167
+ if (behaviorOverrides[index]?.outputMode === undefined && typeof behavior.output === "string") {
3168
+ behavior = { ...behavior, outputMode: "file-only" };
3073
3169
  }
3074
3170
  return behavior;
3075
3171
  });
@@ -3235,21 +3331,12 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
3235
3331
  const worktreeSuffix = handoff?.suffix ?? "";
3236
3332
  const ok = results.filter((result) => result.exitCode === 0).length;
3237
3333
  const downgradeNote = backgroundRequestedWhileClarifying ? " (background requested, but clarify kept this run foreground)" : "";
3238
- const aggregatedOutput = aggregateParallelOutputs(
3239
- results.map((result) => ({
3240
- agent: result.agent,
3241
- output: result.truncation?.text || getSingleResultOutput(result),
3242
- exitCode: result.exitCode,
3243
- error: result.error,
3244
- timedOut: result.timedOut,
3245
- })),
3246
- (i, agent) => `=== Task ${i + 1}: ${agent} ===`,
3247
- );
3334
+ const perChildResults = results.map((result, i) => formatParallelChildResult(result, i)).join("\n\n");
3248
3335
 
3249
3336
  const summary = `${ok}/${results.length} succeeded${downgradeNote}`;
3250
3337
  const fullContent = worktreeSuffix
3251
- ? `${summary}\n\n${aggregatedOutput}\n\n${worktreeSuffix}`
3252
- : `${summary}\n\n${aggregatedOutput}`;
3338
+ ? `${summary}\n\n${perChildResults}\n\n${worktreeSuffix}`
3339
+ : `${summary}\n\n${perChildResults}`;
3253
3340
 
3254
3341
  return {
3255
3342
  content: [{ type: "text", text: fullContent }],
@@ -3309,7 +3396,6 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3309
3396
  let skillOverride: string[] | false | undefined = normalizeSkillInput(params.skill);
3310
3397
  const rawOutput = params.output !== undefined ? params.output : agentConfig.output;
3311
3398
  let effectiveOutput = normalizeSingleOutputOverride(rawOutput, agentConfig.output);
3312
- const effectiveOutputMode = params.outputMode ?? "inline";
3313
3399
  const currentMaxSubagentDepth = resolveCurrentMaxSubagentDepth(deps.config.maxSubagentDepth);
3314
3400
  const maxSubagentDepth = resolveChildMaxSubagentDepth(currentMaxSubagentDepth, agentConfig.maxSubagentDepth);
3315
3401
 
@@ -3381,7 +3467,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3381
3467
  context: contextPolicy.contextForAgent(params.agent!),
3382
3468
  skills: skillOverride === false ? [] : skillOverride,
3383
3469
  output: effectiveOutput,
3384
- outputMode: effectiveOutputMode,
3470
+ outputMode: params.outputMode,
3385
3471
  outputBaseDir: resolveSingleRunOutputBaseDir(deps, artifactsDir, id),
3386
3472
  modelOverride,
3387
3473
  thinkingOverride: thinkingOverrideForTask(params.agent!, 0, modelOverride),
@@ -3410,7 +3496,16 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3410
3496
  task = wrapForkTask(task);
3411
3497
  }
3412
3498
  const cleanTask = task;
3413
- const outputPath = resolveSingleOutputPath(effectiveOutput, ctx.cwd, effectiveCwd, resolveSingleRunOutputBaseDir(deps, artifactsDir, runId));
3499
+ // Omitted output resolves to a generated per-run durable path
3500
+ // (`<singleRunOutputBaseDir>/<runId>/result.md`); omitted outputMode resolves to
3501
+ // file-only whenever an output path is active. Explicit paths, `output: false`,
3502
+ // and explicit `outputMode: "inline"` are preserved exactly.
3503
+ const outputPath = typeof effectiveOutput === "string"
3504
+ ? resolveSingleOutputPath(effectiveOutput, ctx.cwd, effectiveCwd, resolveSingleRunOutputBaseDir(deps, artifactsDir, runId))
3505
+ : effectiveOutput === false
3506
+ ? undefined
3507
+ : resolveGeneratedSingleOutputPath(deps, artifactsDir, runId);
3508
+ const effectiveOutputMode = params.outputMode ?? (outputPath ? "file-only" : "inline");
3414
3509
  const validationError = validateFileOnlyOutputMode(effectiveOutputMode, outputPath, `Single run (${params.agent})`);
3415
3510
  if (validationError) {
3416
3511
  return { content: [{ type: "text", text: validationError }], isError: true, details: { mode: "single", results: [] } };
@@ -3549,6 +3644,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3549
3644
  savedPath: r.savedOutputPath,
3550
3645
  outputReference: r.outputReference,
3551
3646
  saveError: r.outputSaveError,
3647
+ error: r.error,
3552
3648
  });
3553
3649
  if (foregroundControl) {
3554
3650
  updateForegroundNestedProjection(foregroundControl);
@@ -4225,7 +4321,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4225
4321
 
4226
4322
  const artifactConfig: ArtifactConfig = {
4227
4323
  ...DEFAULT_ARTIFACT_CONFIG,
4228
- enabled: effectiveParams.artifacts !== false,
4324
+ enabled: effectiveParams.artifacts === true,
4229
4325
  dir: deps.config.artifactDir ?? DEFAULT_ARTIFACT_CONFIG.dir,
4230
4326
  };
4231
4327
  const artifactsDir = getArtifactsDir(parentSessionFile, effectiveCwd, artifactConfig.dir);
@@ -22,7 +22,7 @@ import type {
22
22
  SubagentRunMode,
23
23
  } from "../../shared/types.ts";
24
24
  import { isAgentContractV1 } from "./agent-contract.ts";
25
- import { classifyTaskMutationIntent, taskMayMutate } from "./task-intent.ts";
25
+ import { classifyTaskMutationIntent, resolveAgentRoutingRole, taskMayMutate } from "./task-intent.ts";
26
26
 
27
27
  const LEVEL_RANK: Record<Exclude<AcceptanceLevel, "auto">, number> = {
28
28
  none: 0,
@@ -82,7 +82,6 @@ function inferLevel(input: {
82
82
  dynamic?: boolean;
83
83
  dynamicGroup?: boolean;
84
84
  }): { level: Exclude<AcceptanceLevel, "auto">; reasons: string[]; criteria: string[]; evidence: AcceptanceEvidenceKind[]; review?: { agent?: string; required?: boolean } } {
85
- const agent = input.agentName.toLowerCase();
86
85
  const task = input.task?.toLowerCase() ?? "";
87
86
  const reasons: string[] = [];
88
87
  // Declared roles replace name heuristics, so use the full writer grammar to detect explicit mutation independently of the actual agent name.
@@ -94,11 +93,10 @@ function inferLevel(input: {
94
93
  && !/\b(?:do not|don't|must not)\s+patch\b/.test(task)
95
94
  && /\bpatch\s+(?:(?:\.{0,2}[\\/])?(?:[\w.-]+[\\/])+[\w.-]+|[\w.-]+\.[a-z0-9]+\b|(?:the\s+)?parser\b)/.test(task);
96
95
  const taskMayWrite = readOnlyTask ? false : taskMayMutate(input.task ?? "") || intent.kind === "implementation" || rolePatchTask;
97
- const readOnlyAgent = input.acceptanceRole === "read-only"
98
- || (input.acceptanceRole === undefined && /\b(?:architect|commentator|explorer|recapper|researcher|analyst)\b/.test(agent));
96
+ const routingRole = resolveAgentRoutingRole(input.agentName, input.acceptanceRole);
97
+ const readOnlyAgent = routingRole === "read-only";
99
98
  const writeTask = taskMayWrite
100
- || (input.acceptanceRole === "writer" && !readOnlyTask)
101
- || (input.acceptanceRole === undefined && /\bbuilder\b/.test(agent) && !readOnlyTask);
99
+ || (routingRole === "writer" && !readOnlyTask);
102
100
  const inferredReadOnly = readOnlyTask || (input.acceptanceRole === "read-only" && !taskMayWrite);
103
101
  const roleResolvesReadOnly = input.acceptanceRole !== undefined && inferredReadOnly;
104
102
  const keywordRiskReadOnly = input.acceptanceRole === undefined ? intent.kind === "read-only" : inferredReadOnly;
@@ -1,9 +1,19 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { Message } from "@earendil-works/pi-ai";
4
- import type { OutputMode, SavedOutputReference } from "../../shared/types.ts";
4
+ import type { MaxOutputConfig, OutputMode, SavedOutputReference } from "../../shared/types.ts";
5
+ import { truncateOutput } from "../../shared/types.ts";
5
6
  import { hasMutationToolCapability } from "./completion-guard.ts";
6
7
 
8
+ /**
9
+ * Fixed internal context-fallback cap used when persisted output is unavailable
10
+ * (persistence/read failure, `output: false`, or legacy results without a saved
11
+ * path). Deliberately not exposed as a public parameter; reuse `truncateOutput()`
12
+ * so the excerpt is bounded by both lines and bytes and never falls back to
13
+ * unbounded output.
14
+ */
15
+ export const CONTEXT_FALLBACK_LIMIT: Required<MaxOutputConfig> = { bytes: 4096, lines: 80 };
16
+
7
17
  export interface SingleOutputSnapshot {
8
18
  exists: boolean;
9
19
  mtimeMs?: number;
@@ -125,6 +135,34 @@ function formatByteSize(bytes: number): string {
125
135
  return `${value.toFixed(1)} ${units[unitIndex]}`;
126
136
  }
127
137
 
138
+ /**
139
+ * Bounded fallback for deliveries where the durable full output cannot be
140
+ * referenced: persistence/write errors, unreadable saved paths, or no saved
141
+ * path at all (legacy/`output: false`). Contains process status, the intended
142
+ * output path, the concrete error, and a bounded 80-line/4-KiB excerpt of the
143
+ * available output. Never claims a full artifact exists.
144
+ */
145
+ export function formatBoundedPersistenceFallback(input: {
146
+ error: string;
147
+ outputPath?: string;
148
+ fullOutput: string;
149
+ exitCode: number;
150
+ processError?: string;
151
+ }): string {
152
+ const status = input.processError?.trim()
153
+ ? `Process status: ${input.processError}`
154
+ : `Process status: ${input.exitCode === 0 ? "completed" : `failed (exit ${input.exitCode})`}`;
155
+ const bounded = truncateOutput(input.fullOutput, CONTEXT_FALLBACK_LIMIT);
156
+ const lines = [
157
+ `[Full output unavailable] ${input.error}`,
158
+ status,
159
+ ...(input.outputPath ? [`Intended output path: ${input.outputPath}`] : []),
160
+ "Full output is unavailable; showing a bounded excerpt (first 80 lines / 4 KiB).",
161
+ bounded.text,
162
+ ];
163
+ return lines.join("\n");
164
+ }
165
+
128
166
  export function formatSavedOutputReference(savedPath: string, fullOutput: string): SavedOutputReference {
129
167
  const absolutePath = path.resolve(savedPath);
130
168
  const bytes = Buffer.byteLength(fullOutput, "utf-8");
@@ -217,19 +255,35 @@ export function finalizeSingleOutput(params: {
217
255
  savedPath?: string;
218
256
  outputReference?: SavedOutputReference;
219
257
  saveError?: string;
258
+ error?: string;
220
259
  }): { displayOutput: string; savedPath?: string; outputReference?: SavedOutputReference; saveError?: string } {
221
- let displayOutput = params.truncatedOutput || params.fullOutput;
222
- if (params.exitCode === 0 && params.savedPath) {
260
+ if (params.savedPath) {
223
261
  const outputReference = params.outputReference ?? formatSavedOutputReference(params.savedPath, params.fullOutput);
224
- if (params.outputMode === "file-only") {
262
+ if (params.exitCode === 0 && params.outputMode === "file-only") {
225
263
  return { displayOutput: outputReference.message, savedPath: params.savedPath, outputReference };
226
264
  }
227
- displayOutput += `\n\n${outputReference.message}`;
265
+ if (params.exitCode !== 0) {
266
+ // Failed runs with a successfully persisted result surface the error/status
267
+ // plus the saved-output reference, never raw child output.
268
+ const status = params.error?.trim() || `Subagent failed with exit code ${params.exitCode}`;
269
+ return { displayOutput: `${status}\n\n${outputReference.message}`, savedPath: params.savedPath, outputReference };
270
+ }
271
+ const displayOutput = `${params.truncatedOutput || params.fullOutput}\n\n${outputReference.message}`;
228
272
  return { displayOutput, savedPath: params.savedPath, outputReference };
229
273
  }
230
- if (params.exitCode === 0 && params.saveError && params.outputPath) {
231
- displayOutput += `\n\nOutput file error: ${params.outputPath}\n${params.saveError}`;
232
- return { displayOutput, saveError: params.saveError };
274
+ if (params.saveError && params.outputPath) {
275
+ // Persistence/read failure: only the bounded fallback is delivered. It must
276
+ // not claim a full artifact exists.
277
+ return {
278
+ displayOutput: formatBoundedPersistenceFallback({
279
+ error: params.saveError,
280
+ outputPath: params.outputPath,
281
+ fullOutput: params.fullOutput,
282
+ exitCode: params.exitCode,
283
+ processError: params.error,
284
+ }),
285
+ saveError: params.saveError,
286
+ };
233
287
  }
234
- return { displayOutput };
288
+ return { displayOutput: params.truncatedOutput || params.fullOutput };
235
289
  }
@@ -1,3 +1,5 @@
1
+ import type { AcceptanceRole } from "../../shared/types.ts";
2
+
1
3
  /**
2
4
  * Shared task mutation-intent classifier.
3
5
  *
@@ -139,6 +141,25 @@ function isReviewerStyleAgent(agent: string): boolean {
139
141
  return /\b(?:architect|commentator|explorer|recapper|researcher)\b/i.test(agent);
140
142
  }
141
143
 
144
+ /**
145
+ * Resolve an agent's acceptance routing role: a declared `acceptanceRole`
146
+ * always wins; otherwise the established name heuristics apply (builder-named
147
+ * agents are writers; architect/commentator/explorer/recapper/researcher/
148
+ * analyst-named agents are read-only). This is the single shared source of
149
+ * truth for acceptance inference and task-aware advisory routing so the two
150
+ * cannot drift. Alias values are never role signals.
151
+ */
152
+ export function resolveAgentRoutingRole(
153
+ agentName: string,
154
+ acceptanceRole?: AcceptanceRole,
155
+ ): "writer" | "read-only" | undefined {
156
+ if (acceptanceRole !== undefined) return acceptanceRole;
157
+ const agent = agentName.toLowerCase();
158
+ if (/\bbuilder\b/.test(agent)) return "writer";
159
+ if (/\b(?:architect|commentator|explorer|recapper|researcher|analyst)\b/.test(agent)) return "read-only";
160
+ return undefined;
161
+ }
162
+
142
163
  function hasImplementationIntent(agent: string, taskText: string): boolean {
143
164
  if (isReviewerStyleAgent(agent)) return REVIEWER_REQUIRED_EDIT_PATTERNS.some((pattern) => pattern.test(taskText));
144
165
  if (agent === "builder") return WORKER_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText));