@dreki-gg/pi-subagent 0.5.0 → 0.7.0

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 (32) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +99 -49
  3. package/agents/advisor.md +37 -0
  4. package/agents/bug-prover.md +42 -0
  5. package/agents/docs-scout.md +2 -2
  6. package/agents/manager.md +52 -0
  7. package/agents/planner.md +16 -1
  8. package/agents/reviewer.md +65 -8
  9. package/agents/scout.md +5 -1
  10. package/agents/validator.md +42 -0
  11. package/agents/worker.md +39 -3
  12. package/docs/orchestration-principles.md +132 -0
  13. package/docs/plans/01_structured-handoffs-and-synthesis.plan.md +376 -0
  14. package/docs/plans/02_clean-context-review-loop.plan.md +240 -0
  15. package/docs/plans/03_parallel-recon-single-writer-docs.plan.md +199 -0
  16. package/docs/plans/04_manager-workflow.plan.md +248 -0
  17. package/docs/plans/05_advisor-routing.plan.md +244 -0
  18. package/extensions/subagent/agent-result-utils.ts +12 -0
  19. package/extensions/subagent/agent-runner-types.ts +23 -0
  20. package/extensions/subagent/{delegate-executor.ts → agent-runner.ts} +10 -29
  21. package/extensions/subagent/agents.ts +11 -0
  22. package/extensions/subagent/handoffs.ts +273 -0
  23. package/extensions/subagent/index.ts +494 -196
  24. package/extensions/subagent/{delegate-args.ts → run-agent-args.ts} +35 -29
  25. package/package.json +3 -6
  26. package/skills/spawn-subagents/SKILL.md +80 -8
  27. package/extensions/subagent/delegate-types.ts +0 -62
  28. package/extensions/subagent/delegate-ui.ts +0 -132
  29. package/extensions/subagent/workflows.ts +0 -150
  30. package/prompts/implement-and-review.md +0 -10
  31. package/prompts/implement.md +0 -10
  32. package/prompts/scout-and-plan.md +0 -9
@@ -0,0 +1,244 @@
1
+ ---
2
+ name: "Advisor / smart-friend routing"
3
+ overview: "Add a bundled `advisor` role and lightweight escalation guidance so strong primary agents in `@dreki-gg/pi-subagent` can ask for focused second opinions on tricky cases without turning every task into a swarm. This slice is about capability routing, not replacing the main worker."
4
+ todo:
5
+ - id: "advisor-routing-1"
6
+ task: "Add a bundled `advisor` agent with a focused consultative contract: diagnose, suggest next investigation, and surface risks without taking ownership of implementation"
7
+ status: done
8
+ - id: "advisor-routing-2"
9
+ task: "Teach bundled worker/planner/reviewer prompts when to call the advisor and what context to send"
10
+ status: done
11
+ - id: "advisor-routing-3"
12
+ task: "Add a reusable prompt entry point and docs examples for advisor-assisted workflows"
13
+ status: done
14
+ ---
15
+
16
+ # Goal
17
+
18
+ Introduce a lightweight advisor pattern so primary agents can consult a strong second opinion for hard cases while keeping a single primary owner of the work.
19
+
20
+ # Context
21
+
22
+ - Parent rationale: we want capability routing and targeted escalation, not a default “everyone talks to everyone” swarm.
23
+ - Module root: `packages/subagent`
24
+ - This slice should work with the current package architecture and should not depend on a new runtime mode.
25
+ - Keep the advisor narrowly scoped: diagnose, redirect, highlight risk, or suggest the next question. The advisor should not silently become another worker.
26
+
27
+ ## What exists
28
+
29
+ Current relevant file tree on disk:
30
+
31
+ - `packages/subagent/agents/planner.md`
32
+ - `packages/subagent/agents/reviewer.md`
33
+ - `packages/subagent/agents/worker.md`
34
+ - `packages/subagent/agents/docs-scout.md`
35
+ - `packages/subagent/prompts/implement.md`
36
+ - `packages/subagent/prompts/implement-and-review.md`
37
+ - `packages/subagent/README.md`
38
+ - `packages/subagent/extensions/subagent/index.ts`
39
+ - `packages/subagent/extensions/subagent/agent-runner.ts`
40
+ - `packages/subagent/skills/spawn-subagents/SKILL.md`
41
+ - `packages/subagent/skills/write-an-agent/SKILL.md`
42
+
43
+ Actual current state on disk:
44
+
45
+ - There is no bundled `advisor` agent in `packages/subagent/agents/`.
46
+ - There is no advisor-oriented prompt template in `packages/subagent/prompts/`.
47
+ - The current bundled roles are:
48
+ - `planner` for planning (`packages/subagent/agents/planner.md`)
49
+ - `worker` for implementation (`packages/subagent/agents/worker.md`)
50
+ - `reviewer` for verification (`packages/subagent/agents/reviewer.md`)
51
+ - plus `scout`, `docs-scout`, and `ux-designer`
52
+ - The package runner already supports spawned agents consulting tools as needed:
53
+ - in `packages/subagent/extensions/subagent/index.ts:325-328`, `runSingleAgent()` passes `--tools` only when frontmatter declares them
54
+ - in `packages/subagent/extensions/subagent/agent-runner.ts:73-76`, `runAgent()` behaves the same way
55
+ - therefore bundled agents with omitted `tools:` can keep access to the broader tool surface, including `subagent`
56
+ - The current prompt contracts do **not** teach any role when to escalate:
57
+ - `planner.md` only says to produce a concrete plan
58
+ - `worker.md` only says to complete the task autonomously
59
+ - `reviewer.md` only says to review the diff / files
60
+ - The `spawn-subagents` skill recommends specialists and review loops, but does not mention an advisor or “consult when tricky” routing rule.
61
+
62
+ # API inventory
63
+
64
+ ## Existing agent prompt contracts that may invoke advisor
65
+
66
+ From `packages/subagent/agents/planner.md`:
67
+
68
+ ```md
69
+ You are a planning specialist.
70
+ You must NOT make any changes. Only read, analyze, and plan.
71
+ ```
72
+
73
+ From `packages/subagent/agents/worker.md`:
74
+
75
+ ```md
76
+ You are a worker agent with full capabilities.
77
+ Work autonomously to complete the assigned task. Use all available tools as needed.
78
+ ```
79
+
80
+ From `packages/subagent/agents/reviewer.md`:
81
+
82
+ ```md
83
+ You are a senior code reviewer.
84
+ Analyze code for quality, security, and maintainability.
85
+ ```
86
+
87
+ ## Existing subagent entry shapes available to an advisor pattern
88
+
89
+ From the `subagent` tool surface:
90
+
91
+ ```ts
92
+ // single consult
93
+ { agent: 'advisor', task: '...' }
94
+
95
+ // consult inside a chain
96
+ { chain: [{ agent: 'worker', task: '...' }, { agent: 'advisor', task: '... {previous} ...' }] }
97
+
98
+ // focused parallel consults
99
+ { tasks: [{ agent: 'advisor', task: '...' }, { agent: 'docs-scout', task: '...' }] }
100
+ ```
101
+
102
+ ## Proposed advisor contract
103
+
104
+ The advisor should return guidance the caller can act on immediately:
105
+
106
+ ```md
107
+ ## Assessment
108
+ - what looks tricky / risky / ambiguous
109
+
110
+ ## What to inspect next
111
+ - concrete files, commands, or questions
112
+
113
+ ## Recommendation
114
+ - best next action for the caller
115
+
116
+ ## Risks
117
+ - what could still go wrong
118
+ ```
119
+
120
+ The advisor should not claim to have implemented or verified anything it did not actually inspect.
121
+
122
+ # Tasks
123
+
124
+ ## 1. Add a bundled `advisor` agent with a focused consultative contract
125
+
126
+ ### Files
127
+ - Create `packages/subagent/agents/advisor.md`
128
+
129
+ ### What to add
130
+ Create a concise bundled agent whose sole job is to provide focused second-opinion guidance.
131
+
132
+ ### Required behavior
133
+ - Work from the caller’s question plus any provided handoff.
134
+ - Ask the caller to investigate specific files / commands when the answer depends on context the advisor has not yet inspected.
135
+ - Surface hidden risks or better approaches.
136
+ - Avoid taking over implementation.
137
+ - Prefer decisive recommendations over brainstorming lists.
138
+
139
+ ### Frontmatter guidance
140
+ - Use an existing strong model already in package conventions (default to `openai/gpt-5.4` unless there is a strong reason to diversify).
141
+ - Keep the prompt under ~100 lines per `packages/subagent/skills/write-an-agent/SKILL.md`.
142
+ - Omit `tools:` only if you want the advisor to have broad access; otherwise keep the tool list minimal and read-oriented.
143
+
144
+ ### Suggested prompt rules
145
+ 1. Diagnose the hard part, not the whole task from scratch.
146
+ 2. If more repo context is needed, say exactly what to inspect next.
147
+ 3. Return a recommendation the caller can act on immediately.
148
+ 4. Do not implement code or rewrite plans unless explicitly asked.
149
+
150
+ ## 2. Teach bundled prompts when to call the advisor and what context to send
151
+
152
+ ### Files
153
+ - Modify `packages/subagent/agents/worker.md`
154
+ - Modify `packages/subagent/agents/planner.md`
155
+ - Modify `packages/subagent/agents/reviewer.md`
156
+
157
+ ### What to change
158
+ Add small, explicit escalation rules to the existing bundled roles.
159
+
160
+ ### Recommended escalation triggers
161
+ - `worker`
162
+ - ambiguous architecture tradeoff
163
+ - persistent failing tests or unexplained errors
164
+ - merge conflicts / tangled diffs
165
+ - security-sensitive or migration-heavy changes
166
+ - `planner`
167
+ - multiple viable designs with materially different blast radius
168
+ - unclear ownership boundaries across packages
169
+ - need for sharper decomposition before implementation
170
+ - `reviewer`
171
+ - uncertainty whether an issue is real vs. intended
172
+ - suspected deeper architectural problem beyond the current diff
173
+ - need for a second opinion on severity
174
+
175
+ ### Required context to send when consulting advisor
176
+ Keep the consult lightweight:
177
+ - current role (`worker` / `planner` / `reviewer`)
178
+ - the exact question
179
+ - touched files / symbols if known
180
+ - the smallest relevant task summary
181
+ - what has already been tried or observed
182
+
183
+ ### Notes
184
+ - Do not tell every role to always call the advisor. This is an escalation path, not a mandatory step.
185
+ - Keep the primary role in charge; the advisor should inform decisions, not replace ownership.
186
+
187
+ ## 3. Add a reusable prompt entry point and docs examples for advisor-assisted workflows
188
+
189
+ ### Files
190
+ - Create `packages/subagent/prompts/consult-advisor.md`
191
+ - Modify `packages/subagent/README.md`
192
+ - Modify `packages/subagent/skills/spawn-subagents/SKILL.md`
193
+
194
+ ### What to add
195
+ Create a simple prompt template that encourages focused advisor consultation without changing core workflow semantics.
196
+
197
+ ### Suggested prompt shape
198
+
199
+ ```md
200
+ Use the subagent tool to consult the `advisor` agent about: $@
201
+
202
+ The advisor should:
203
+ 1. identify the hard part
204
+ 2. suggest the next investigation or decision
205
+ 3. surface concrete risks
206
+ 4. return a recommendation, not a full implementation
207
+ ```
208
+
209
+ ### README / skill updates
210
+ - Add `advisor` to the bundled agent list once shipped.
211
+ - Add one example such as:
212
+ - “ask advisor whether this migration should be split before sending it to worker”
213
+ - “have worker consult advisor on the failing test loop”
214
+ - In `spawn-subagents`, add guidance that advisor use is optional and should be reserved for tricky or high-risk cases.
215
+
216
+ # Files to create
217
+
218
+ - `packages/subagent/agents/advisor.md`
219
+ - `packages/subagent/prompts/consult-advisor.md`
220
+
221
+ # Files to modify
222
+
223
+ - `packages/subagent/agents/worker.md` — add narrow escalation guidance
224
+ - `packages/subagent/agents/planner.md` — add narrow escalation guidance
225
+ - `packages/subagent/agents/reviewer.md` — add narrow escalation guidance
226
+ - `packages/subagent/README.md` — document the new advisor role and one or two examples
227
+ - `packages/subagent/skills/spawn-subagents/SKILL.md` — explain when to use advisor vs. planner/reviewer
228
+
229
+ # Testing notes
230
+
231
+ - If this remains markdown-only, there is no TypeScript validation requirement.
232
+ - Manually test one focused consult, such as a planner or worker asking advisor a hard question, and verify:
233
+ - the advisor returns a recommendation rather than trying to own the full task
234
+ - the caller stays in charge of implementation or review
235
+ - the exchange is shorter and more targeted than a full additional workflow
236
+ - If you introduce any helper TS module later to render consult packets, run `bun run --filter '@dreki-gg/pi-subagent' typecheck`.
237
+
238
+ # Patterns to follow
239
+
240
+ - `packages/subagent/skills/write-an-agent/SKILL.md:15-20` and `:46-67` — keep the new agent narrow, concise, and output-contract-driven
241
+ - `packages/subagent/agents/planner.md:9-38` — example of a specialist planner role that the advisor should complement, not replace
242
+ - `packages/subagent/agents/reviewer.md:10-37` — example of a verifier role that may occasionally need escalation
243
+ - `packages/subagent/agents/worker.md:9-26` — example of a primary owner role that should remain in charge after consulting advisor
244
+ - `packages/subagent/skills/spawn-subagents/SKILL.md:23-58` — existing specialist-selection guidance to extend with optional escalation
@@ -0,0 +1,12 @@
1
+ import type { AgentResult } from './agent-runner-types.js';
2
+
3
+ export function getFinalText(result: AgentResult): string {
4
+ for (let i = result.messages.length - 1; i >= 0; i--) {
5
+ const msg = result.messages[i];
6
+ if (msg.role !== 'assistant') continue;
7
+ for (const part of msg.content) {
8
+ if (part.type === 'text') return part.text;
9
+ }
10
+ }
11
+ return '(no output)';
12
+ }
@@ -0,0 +1,23 @@
1
+ import type { Message } from '@mariozechner/pi-ai';
2
+
3
+ export interface UsageStats {
4
+ input: number;
5
+ output: number;
6
+ cacheRead: number;
7
+ cacheWrite: number;
8
+ cost: number;
9
+ contextTokens: number;
10
+ turns: number;
11
+ }
12
+
13
+ export interface AgentResult {
14
+ agent: string;
15
+ task: string;
16
+ exitCode: number;
17
+ messages: Message[];
18
+ stderr: string;
19
+ usage: UsageStats;
20
+ model?: string;
21
+ stopReason?: string;
22
+ errorMessage?: string;
23
+ }
@@ -5,8 +5,8 @@ import * as path from 'node:path';
5
5
  import { withFileMutationQueue } from '@mariozechner/pi-coding-agent';
6
6
  import type { ResolvedPaths } from '@mariozechner/pi-coding-agent';
7
7
  import type { Message } from '@mariozechner/pi-ai';
8
- import { discoverAgentsWithPackages, type AgentScope } from './agents';
9
- import type { AgentResult, UsageStats } from './delegate-types';
8
+ import { discoverAgentsWithPackages, type AgentScope } from './agents.js';
9
+ import type { AgentResult, UsageStats } from './agent-runner-types.js';
10
10
 
11
11
  function emptyUsage(): UsageStats {
12
12
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
@@ -43,6 +43,8 @@ export type OnPhaseUpdate = (phaseName: string, agentName: string, result: Agent
43
43
  export interface RunAgentOptions {
44
44
  agentScope?: AgentScope;
45
45
  cwd?: string;
46
+ model?: string;
47
+ thinking?: string;
46
48
  onUpdate?: OnPhaseUpdate;
47
49
  phaseName?: string;
48
50
  signal?: AbortSignal;
@@ -70,9 +72,12 @@ export async function runAgent(
70
72
  };
71
73
  }
72
74
 
75
+ const selectedModel = options.model ?? agent.model;
76
+ const selectedThinking = options.thinking ?? agent.thinking;
77
+
73
78
  const args: string[] = ['--mode', 'json', '-p', '--no-session'];
74
- if (agent.model) args.push('--model', agent.model);
75
- if (agent.thinking) args.push('--thinking', agent.thinking);
79
+ if (selectedModel) args.push('--model', selectedModel);
80
+ if (selectedThinking) args.push('--thinking', selectedThinking);
76
81
  if (agent.tools && agent.tools.length > 0) args.push('--tools', agent.tools.join(','));
77
82
 
78
83
  let tmpPromptDir: string | null = null;
@@ -85,7 +90,7 @@ export async function runAgent(
85
90
  messages: [],
86
91
  stderr: '',
87
92
  usage: emptyUsage(),
88
- model: agent.model,
93
+ model: selectedModel,
89
94
  };
90
95
 
91
96
  try {
@@ -195,27 +200,3 @@ export async function runAgent(
195
200
  }
196
201
  }
197
202
  }
198
-
199
- export async function runParallel(
200
- cwd: string,
201
- agentNames: string[],
202
- task: string,
203
- options: RunAgentOptions = {},
204
- ): Promise<AgentResult[]> {
205
- const MAX_CONCURRENCY = 4;
206
- const results: AgentResult[] = new Array(agentNames.length);
207
- let nextIndex = 0;
208
-
209
- const workers = new Array(Math.min(MAX_CONCURRENCY, agentNames.length))
210
- .fill(null)
211
- .map(async () => {
212
- while (true) {
213
- const current = nextIndex++;
214
- if (current >= agentNames.length) return;
215
- results[current] = await runAgent(cwd, agentNames[current], task, options);
216
- }
217
- });
218
-
219
- await Promise.all(workers);
220
- return results;
221
- }
@@ -16,6 +16,7 @@ import type { ResolvedPaths } from '@mariozechner/pi-coding-agent';
16
16
 
17
17
  export type AgentScope = 'user' | 'project' | 'both';
18
18
  export type AgentSource = 'bundled' | 'user' | 'project' | 'package';
19
+ export type AgentSessionStrategy = 'inline' | 'fork-at';
19
20
 
20
21
  export interface AgentConfig {
21
22
  name: string;
@@ -23,6 +24,7 @@ export interface AgentConfig {
23
24
  tools?: string[];
24
25
  model?: string;
25
26
  thinking?: string;
27
+ sessionStrategy?: AgentSessionStrategy;
26
28
  systemPrompt: string;
27
29
  source: AgentSource;
28
30
  filePath: string;
@@ -36,6 +38,13 @@ export interface AgentDiscoveryResult {
36
38
  /** Bundled agents ship with the package (../../agents relative to extensions/subagent/) */
37
39
  const bundledAgentsDir = path.resolve(import.meta.dirname, '..', '..', 'agents');
38
40
 
41
+ function parseSessionStrategy(value?: string): AgentSessionStrategy | undefined {
42
+ if (!value) return undefined;
43
+ const normalized = value.trim().toLowerCase();
44
+ if (normalized === 'inline' || normalized === 'fork-at') return normalized;
45
+ return undefined;
46
+ }
47
+
39
48
  function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
40
49
  const agents: AgentConfig[] = [];
41
50
 
@@ -79,6 +88,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
79
88
  tools: tools && tools.length > 0 ? tools : undefined,
80
89
  model: frontmatter.model,
81
90
  thinking: frontmatter.thinking,
91
+ sessionStrategy: parseSessionStrategy(frontmatter.sessionStrategy),
82
92
  systemPrompt: body,
83
93
  source,
84
94
  filePath,
@@ -152,6 +162,7 @@ function loadAgentFromFile(filePath: string, source: AgentSource): AgentConfig |
152
162
  tools: tools && tools.length > 0 ? tools : undefined,
153
163
  model: frontmatter.model,
154
164
  thinking: frontmatter.thinking,
165
+ sessionStrategy: parseSessionStrategy(frontmatter.sessionStrategy),
155
166
  systemPrompt: body,
156
167
  source,
157
168
  filePath,
@@ -0,0 +1,273 @@
1
+ export interface HandoffFileRef {
2
+ path: string;
3
+ notes?: string;
4
+ }
5
+
6
+ export interface HandoffEnvelope {
7
+ version: 'subagent-handoff/v1';
8
+ sourceAgent: string;
9
+ sourceStep?: number;
10
+ task: string;
11
+ summary: string;
12
+ goal?: string;
13
+ decisions: string[];
14
+ constraints: string[];
15
+ files: HandoffFileRef[];
16
+ symbols: string[];
17
+ openQuestions: string[];
18
+ rawOutput: string;
19
+ }
20
+
21
+ export interface RenderHandoffOptions {
22
+ includeRawOutput?: boolean;
23
+ maxRawChars?: number;
24
+ }
25
+
26
+ const DEFAULT_MAX_RAW_CHARS = 4000;
27
+ const MAX_SUMMARY_ITEMS = 3;
28
+ const SECTION_RE = /^##\s+(.+)$/gm;
29
+ const CODE_SYMBOL_RE =
30
+ /^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|^(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*[:=]|^(?:export\s+)?(?:interface|type|class|enum)\s+([A-Za-z_$][\w$]*)/gm;
31
+ const BULLET_SYMBOL_RE =
32
+ /^\s*(?:[-*]|\d+\.)\s+`?([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)`?(?=\s*(?:-|:|\())/gm;
33
+ const PATH_RE = /\b(?:\.?\.?\/)?(?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+(?:\.[A-Za-z0-9_.-]+)?\b/g;
34
+ const CODE_PATH_RE = /`([^`\n]+(?:\/[A-Za-z0-9_.-]+)+[^`\n]*)`/g;
35
+
36
+ interface SectionEntry {
37
+ title: string;
38
+ body: string;
39
+ }
40
+
41
+ function normalizeWhitespace(value: string): string {
42
+ return value.replace(/\r/g, '').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
43
+ }
44
+
45
+ function normalizeSectionTitle(title: string): string {
46
+ return title.trim().toLowerCase().replace(/[\s/]+/g, ' ').replace(/[():]/g, '');
47
+ }
48
+
49
+ function splitMarkdownSections(markdown: string): { preamble: string; sections: SectionEntry[] } {
50
+ const matches = Array.from(markdown.matchAll(SECTION_RE));
51
+ if (matches.length === 0) {
52
+ return { preamble: normalizeWhitespace(markdown), sections: [] };
53
+ }
54
+
55
+ const sections: SectionEntry[] = [];
56
+ const preamble = normalizeWhitespace(markdown.slice(0, matches[0]?.index ?? 0));
57
+
58
+ for (let i = 0; i < matches.length; i++) {
59
+ const current = matches[i];
60
+ const next = matches[i + 1];
61
+ const title = current[1]?.trim() ?? '';
62
+ const start = (current.index ?? 0) + current[0].length;
63
+ const end = next?.index ?? markdown.length;
64
+ const body = normalizeWhitespace(markdown.slice(start, end));
65
+ sections.push({ title, body });
66
+ }
67
+
68
+ return { preamble, sections };
69
+ }
70
+
71
+ function getSectionBodies(sections: SectionEntry[], titles: string[]): string[] {
72
+ const wanted = new Set(titles.map(normalizeSectionTitle));
73
+ return sections
74
+ .filter((section) => wanted.has(normalizeSectionTitle(section.title)))
75
+ .map((section) => section.body)
76
+ .filter(Boolean);
77
+ }
78
+
79
+ function getFirstSectionBody(sections: SectionEntry[], titles: string[]): string | undefined {
80
+ return getSectionBodies(sections, titles)[0];
81
+ }
82
+
83
+ function extractListItems(section: string | undefined): string[] {
84
+ if (!section) return [];
85
+
86
+ const matches = Array.from(section.matchAll(/^\s*(?:[-*]|\d+\.)\s+(.+)$/gm));
87
+ if (matches.length === 0) return [];
88
+
89
+ return matches
90
+ .map((match) => normalizeWhitespace(match[1] ?? ''))
91
+ .map((item) => item.replace(/^[`*_]+|[`*_]+$/g, '').trim())
92
+ .filter(Boolean);
93
+ }
94
+
95
+ function extractParagraph(section: string | undefined): string | undefined {
96
+ if (!section) return undefined;
97
+ const paragraph = normalizeWhitespace(section.split(/\n\n+/)[0] ?? '');
98
+ const normalized = paragraph.replace(/^[-*]\s+/, '').trim();
99
+ return normalized && !isSentinelItem(normalized) ? paragraph : undefined;
100
+ }
101
+
102
+ function truncate(value: string, maxChars: number): string {
103
+ if (value.length <= maxChars) return value;
104
+ return `${value.slice(0, Math.max(0, maxChars - 14)).trimEnd()}\n… (truncated)`;
105
+ }
106
+
107
+ function isSentinelItem(value: string): boolean {
108
+ return /^(?:none|n\/a|not applicable)$/i.test(value.trim());
109
+ }
110
+
111
+ function uniq(values: string[]): string[] {
112
+ return Array.from(
113
+ new Set(values.map((value) => value.trim()).filter((value) => value && !isSentinelItem(value))),
114
+ );
115
+ }
116
+
117
+ function summarizeBullets(items: string[]): string | undefined {
118
+ const filtered = items.filter((item) => !isSentinelItem(item));
119
+ if (filtered.length === 0) return undefined;
120
+ return filtered.slice(0, MAX_SUMMARY_ITEMS).join('; ');
121
+ }
122
+
123
+ function summarizeSection(section: string | undefined): string | undefined {
124
+ if (!section) return undefined;
125
+ return summarizeBullets(extractListItems(section)) ?? extractParagraph(section);
126
+ }
127
+
128
+ function buildSummary(preamble: string, sections: SectionEntry[]): string {
129
+ const parts = [
130
+ summarizeSection(getFirstSectionBody(sections, ['Goal', 'Completed'])),
131
+ summarizeSection(getFirstSectionBody(sections, ['Plan', 'Architecture', 'Integration Notes'])),
132
+ summarizeSection(getFirstSectionBody(sections, ['Recommended Next Step', 'Notes', 'Risks'])),
133
+ extractParagraph(preamble),
134
+ ].filter((part): part is string => Boolean(part));
135
+
136
+ return parts.length > 0 ? parts.slice(0, MAX_SUMMARY_ITEMS).join(' ') : '(no summary available)';
137
+ }
138
+
139
+ function extractPaths(text: string): HandoffFileRef[] {
140
+ const seen = new Set<string>();
141
+ const files: HandoffFileRef[] = [];
142
+
143
+ const addPath = (rawPath: string, notes?: string) => {
144
+ const path = rawPath.trim().replace(/^`|`$/g, '').replace(/[),.:;]+$/g, '');
145
+ if (!path.includes('/')) return;
146
+ if (path.startsWith('http://') || path.startsWith('https://')) return;
147
+ if (seen.has(path)) return;
148
+ seen.add(path);
149
+ files.push({ path, notes });
150
+ };
151
+
152
+ for (const match of text.matchAll(CODE_PATH_RE)) {
153
+ addPath(match[1] ?? '');
154
+ }
155
+
156
+ for (const match of text.matchAll(PATH_RE)) {
157
+ addPath(match[0] ?? '');
158
+ }
159
+
160
+ return files;
161
+ }
162
+
163
+ function extractSymbols(text: string): string[] {
164
+ const symbols: string[] = [];
165
+
166
+ for (const match of text.matchAll(CODE_SYMBOL_RE)) {
167
+ const symbol = match[1] ?? match[2] ?? match[3];
168
+ if (symbol) symbols.push(symbol);
169
+ }
170
+
171
+ for (const match of text.matchAll(BULLET_SYMBOL_RE)) {
172
+ const symbol = match[1];
173
+ if (symbol && !symbol.includes('/')) symbols.push(symbol);
174
+ }
175
+
176
+ return uniq(symbols);
177
+ }
178
+
179
+ function toQuestions(items: string[]): string[] {
180
+ return items.filter(
181
+ (item) =>
182
+ /\?$/.test(item) || /\b(?:unknown|unclear|unresolved|question|follow up|todo)\b/i.test(item),
183
+ );
184
+ }
185
+
186
+ function renderList(title: string, items: string[]): string[] {
187
+ if (items.length === 0) return [];
188
+ return [`## ${title}`, ...items.map((item) => `- ${item}`), ''];
189
+ }
190
+
191
+ export function buildHandoffFromResult(input: {
192
+ agent: string;
193
+ step?: number;
194
+ task: string;
195
+ output: string;
196
+ }): HandoffEnvelope {
197
+ const output = input.output.trim() || '(no output)';
198
+ const { preamble, sections } = splitMarkdownSections(output);
199
+ const decisions = uniq(extractListItems(getFirstSectionBody(sections, ['Decisions'])));
200
+ const explicitConstraints = uniq(
201
+ extractListItems(getFirstSectionBody(sections, ['Constraints', 'Constraints or Unknowns'])),
202
+ );
203
+ const riskItems = uniq(extractListItems(getFirstSectionBody(sections, ['Risks'])));
204
+ const openQuestions = uniq([
205
+ ...extractListItems(getFirstSectionBody(sections, ['Open Questions'])),
206
+ ...toQuestions(extractListItems(getFirstSectionBody(sections, ['Notes', 'Constraints or Unknowns']))),
207
+ ]);
208
+ const goal =
209
+ extractParagraph(getFirstSectionBody(sections, ['Goal'])) ??
210
+ extractParagraph(getFirstSectionBody(sections, ['Completed'])) ??
211
+ undefined;
212
+
213
+ return {
214
+ version: 'subagent-handoff/v1',
215
+ sourceAgent: input.agent,
216
+ sourceStep: input.step,
217
+ task: input.task,
218
+ summary: buildSummary(preamble, sections),
219
+ goal,
220
+ decisions,
221
+ constraints: uniq([...explicitConstraints, ...riskItems]),
222
+ files: extractPaths(output),
223
+ symbols: extractSymbols(output),
224
+ openQuestions,
225
+ rawOutput: output,
226
+ };
227
+ }
228
+
229
+ export function renderHandoffForPrompt(
230
+ handoff: HandoffEnvelope,
231
+ options: RenderHandoffOptions = {},
232
+ ): string {
233
+ const includeRawOutput = options.includeRawOutput ?? true;
234
+ const maxRawChars = options.maxRawChars ?? DEFAULT_MAX_RAW_CHARS;
235
+ const lines: string[] = [
236
+ '## Previous Agent Handoff',
237
+ `- Source Agent: ${handoff.sourceAgent}`,
238
+ ...(handoff.sourceStep ? [`- Step: ${handoff.sourceStep}`] : []),
239
+ `- Task: ${truncate(handoff.task, 240)}`,
240
+ `- Summary: ${handoff.summary}`,
241
+ '',
242
+ ];
243
+
244
+ if (handoff.goal) {
245
+ lines.push('## Goal', handoff.goal, '');
246
+ }
247
+
248
+ lines.push(...renderList('Decisions', handoff.decisions));
249
+ lines.push(...renderList('Constraints', handoff.constraints));
250
+
251
+ if (handoff.files.length > 0) {
252
+ lines.push('## Files');
253
+ for (const file of handoff.files) {
254
+ lines.push(file.notes ? `- \`${file.path}\` - ${file.notes}` : `- \`${file.path}\``);
255
+ }
256
+ lines.push('');
257
+ }
258
+
259
+ lines.push(...renderList('Symbols', handoff.symbols));
260
+ lines.push(...renderList('Open Questions', handoff.openQuestions));
261
+
262
+ if (includeRawOutput) {
263
+ lines.push(
264
+ '## Raw Output (truncated)',
265
+ '```markdown',
266
+ truncate(handoff.rawOutput, maxRawChars),
267
+ '```',
268
+ '',
269
+ );
270
+ }
271
+
272
+ return lines.join('\n').trim();
273
+ }