@yagni-app/code-staging 0.0.0 → 0.1.0-staging.1002.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 (147) hide show
  1. package/LICENSE.md +55 -0
  2. package/README.md +166 -0
  3. package/dist/branding.d.ts +25 -0
  4. package/dist/branding.js +27 -0
  5. package/dist/claudeCompat.d.ts +78 -0
  6. package/dist/claudeCompat.js +198 -0
  7. package/dist/cli.d.ts +55 -0
  8. package/dist/cli.js +326 -0
  9. package/dist/config.d.ts +19 -0
  10. package/dist/config.js +28 -0
  11. package/dist/credentials.d.ts +41 -0
  12. package/dist/credentials.js +75 -0
  13. package/dist/distribution.d.ts +15 -0
  14. package/dist/distribution.js +45 -0
  15. package/dist/doctor.d.ts +94 -0
  16. package/dist/doctor.js +343 -0
  17. package/dist/extension/advisor.d.ts +84 -0
  18. package/dist/extension/advisor.js +101 -0
  19. package/dist/extension/askAdvisorTool.d.ts +83 -0
  20. package/dist/extension/askAdvisorTool.js +188 -0
  21. package/dist/extension/askYagniTool.d.ts +39 -0
  22. package/dist/extension/askYagniTool.js +63 -0
  23. package/dist/extension/bless.d.ts +45 -0
  24. package/dist/extension/bless.js +74 -0
  25. package/dist/extension/branding.d.ts +48 -0
  26. package/dist/extension/branding.js +119 -0
  27. package/dist/extension/chipEditor.d.ts +117 -0
  28. package/dist/extension/chipEditor.js +369 -0
  29. package/dist/extension/config.d.ts +112 -0
  30. package/dist/extension/config.js +108 -0
  31. package/dist/extension/costHud.d.ts +73 -0
  32. package/dist/extension/costHud.js +123 -0
  33. package/dist/extension/decisionCapture.d.ts +52 -0
  34. package/dist/extension/decisionCapture.js +66 -0
  35. package/dist/extension/decisions.d.ts +83 -0
  36. package/dist/extension/decisions.js +200 -0
  37. package/dist/extension/diagnostics.d.ts +41 -0
  38. package/dist/extension/diagnostics.js +110 -0
  39. package/dist/extension/index.d.ts +116 -0
  40. package/dist/extension/index.js +388 -0
  41. package/dist/extension/initDone.d.ts +28 -0
  42. package/dist/extension/initDone.js +66 -0
  43. package/dist/extension/initPass.d.ts +152 -0
  44. package/dist/extension/initPass.js +394 -0
  45. package/dist/extension/nextWorkTool.d.ts +51 -0
  46. package/dist/extension/nextWorkTool.js +80 -0
  47. package/dist/extension/permission.d.ts +91 -0
  48. package/dist/extension/permission.js +236 -0
  49. package/dist/extension/pipeline/activity.d.ts +37 -0
  50. package/dist/extension/pipeline/activity.js +151 -0
  51. package/dist/extension/pipeline/activityFeed.d.ts +59 -0
  52. package/dist/extension/pipeline/activityFeed.js +175 -0
  53. package/dist/extension/pipeline/budget.d.ts +48 -0
  54. package/dist/extension/pipeline/budget.js +68 -0
  55. package/dist/extension/pipeline/checkpoint.d.ts +31 -0
  56. package/dist/extension/pipeline/checkpoint.js +176 -0
  57. package/dist/extension/pipeline/eval.d.ts +168 -0
  58. package/dist/extension/pipeline/eval.js +182 -0
  59. package/dist/extension/pipeline/events.d.ts +56 -0
  60. package/dist/extension/pipeline/events.js +147 -0
  61. package/dist/extension/pipeline/findings.d.ts +42 -0
  62. package/dist/extension/pipeline/findings.js +144 -0
  63. package/dist/extension/pipeline/finish.d.ts +128 -0
  64. package/dist/extension/pipeline/finish.js +307 -0
  65. package/dist/extension/pipeline/goCommand.d.ts +128 -0
  66. package/dist/extension/pipeline/goCommand.js +972 -0
  67. package/dist/extension/pipeline/goCompareCommand.d.ts +48 -0
  68. package/dist/extension/pipeline/goCompareCommand.js +184 -0
  69. package/dist/extension/pipeline/goFlags.d.ts +40 -0
  70. package/dist/extension/pipeline/goFlags.js +46 -0
  71. package/dist/extension/pipeline/goStatusCommands.d.ts +59 -0
  72. package/dist/extension/pipeline/goStatusCommands.js +188 -0
  73. package/dist/extension/pipeline/invocation.d.ts +45 -0
  74. package/dist/extension/pipeline/invocation.js +64 -0
  75. package/dist/extension/pipeline/orchestrator.d.ts +131 -0
  76. package/dist/extension/pipeline/orchestrator.js +636 -0
  77. package/dist/extension/pipeline/personas.d.ts +44 -0
  78. package/dist/extension/pipeline/personas.js +248 -0
  79. package/dist/extension/pipeline/resilience.d.ts +85 -0
  80. package/dist/extension/pipeline/resilience.js +166 -0
  81. package/dist/extension/pipeline/resume.d.ts +18 -0
  82. package/dist/extension/pipeline/resume.js +106 -0
  83. package/dist/extension/pipeline/runRegistry.d.ts +112 -0
  84. package/dist/extension/pipeline/runRegistry.js +202 -0
  85. package/dist/extension/pipeline/runSession.d.ts +152 -0
  86. package/dist/extension/pipeline/runSession.js +167 -0
  87. package/dist/extension/pipeline/runState.d.ts +158 -0
  88. package/dist/extension/pipeline/runState.js +264 -0
  89. package/dist/extension/pipeline/runner.d.ts +79 -0
  90. package/dist/extension/pipeline/runner.js +298 -0
  91. package/dist/extension/pipeline/scrubSecrets.d.ts +15 -0
  92. package/dist/extension/pipeline/scrubSecrets.js +32 -0
  93. package/dist/extension/pipeline/stages.d.ts +51 -0
  94. package/dist/extension/pipeline/stages.js +113 -0
  95. package/dist/extension/pipeline/ticketResolution.d.ts +34 -0
  96. package/dist/extension/pipeline/ticketResolution.js +75 -0
  97. package/dist/extension/pipeline/types.d.ts +436 -0
  98. package/dist/extension/pipeline/types.js +48 -0
  99. package/dist/extension/pipeline/verify.d.ts +277 -0
  100. package/dist/extension/pipeline/verify.js +758 -0
  101. package/dist/extension/pipeline/workspace.d.ts +32 -0
  102. package/dist/extension/pipeline/workspace.js +73 -0
  103. package/dist/extension/pipeline/worktree.d.ts +107 -0
  104. package/dist/extension/pipeline/worktree.js +200 -0
  105. package/dist/extension/provider.d.ts +11 -0
  106. package/dist/extension/provider.js +26 -0
  107. package/dist/extension/recall.d.ts +93 -0
  108. package/dist/extension/recall.js +187 -0
  109. package/dist/extension/recordContextTool.d.ts +38 -0
  110. package/dist/extension/recordContextTool.js +85 -0
  111. package/dist/extension/recordDecisionTool.d.ts +52 -0
  112. package/dist/extension/recordDecisionTool.js +102 -0
  113. package/dist/extension/repoDocs.d.ts +81 -0
  114. package/dist/extension/repoDocs.js +260 -0
  115. package/dist/extension/resilientFetch.d.ts +60 -0
  116. package/dist/extension/resilientFetch.js +133 -0
  117. package/dist/extension/reviewTool.d.ts +34 -0
  118. package/dist/extension/reviewTool.js +81 -0
  119. package/dist/extension/spool.d.ts +92 -0
  120. package/dist/extension/spool.js +266 -0
  121. package/dist/extension/stateHome.d.ts +2 -0
  122. package/dist/extension/stateHome.js +6 -0
  123. package/dist/extension/subagents.d.ts +135 -0
  124. package/dist/extension/subagents.js +281 -0
  125. package/dist/extension/surface.d.ts +10 -0
  126. package/dist/extension/surface.js +12 -0
  127. package/dist/extension/todos.d.ts +110 -0
  128. package/dist/extension/todos.js +217 -0
  129. package/dist/extension/tokenProvider.d.ts +93 -0
  130. package/dist/extension/tokenProvider.js +234 -0
  131. package/dist/launch.d.ts +76 -0
  132. package/dist/launch.js +111 -0
  133. package/dist/login.d.ts +45 -0
  134. package/dist/login.js +142 -0
  135. package/dist/logout.d.ts +14 -0
  136. package/dist/logout.js +34 -0
  137. package/dist/paths.d.ts +31 -0
  138. package/dist/paths.js +87 -0
  139. package/dist/piPackage.d.ts +33 -0
  140. package/dist/piPackage.js +71 -0
  141. package/dist/profiles.d.ts +80 -0
  142. package/dist/profiles.js +222 -0
  143. package/dist/refresh.d.ts +70 -0
  144. package/dist/refresh.js +117 -0
  145. package/dist/upgrade.d.ts +97 -0
  146. package/dist/upgrade.js +284 -0
  147. package/package.json +39 -3
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Durable write-spool for the judgment-capture tools (spec §4, R4 write half).
3
+ *
4
+ * `record_decision` and `record_engineering_context` are metered writes that
5
+ * must never be blindly retried (invariant §0.8) — but losing a captured
6
+ * decision because the laptop lost Wi-Fi is worse. The backend now dedups on
7
+ * an `idempotencyKey` (§2), so the safe shape is:
8
+ *
9
+ * - every logical write carries a generated idempotencyKey,
10
+ * - a transport failure or 5xx appends `{endpoint, body, idempotencyKey, ts}`
11
+ * to `~/.yagni-code/spool/pending.ndjson` (dir 0700, file 0600,
12
+ * torn-line-tolerant — the same self-healing NDJSON pattern as
13
+ * pipeline/checkpoint.ts),
14
+ * - the spool is flushed at session_start and after any successful write,
15
+ * replaying with the SAME idempotencyKey (the server replays the stored
16
+ * response, so a duplicate flush is harmless),
17
+ * - entries older than 14 days are dropped with an honest notice (stale
18
+ * judgment silently landing weeks later would be worse than losing it).
19
+ *
20
+ * Everything here is FAIL-SOFT: a spool problem must never break a tool call
21
+ * or session start. Eval mode never reaches this module (the capture tools are
22
+ * not registered there).
23
+ */
24
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { friendlyFetchError, METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
27
+ import { codeStateHome } from "./stateHome.js";
28
+ /** Spooled entries older than this are dropped at flush (with a notice). */
29
+ export const MAX_SPOOL_AGE_MS = 14 * 24 * 60 * 60 * 1000;
30
+ /**
31
+ * Test seam mirroring checkpoint.ts `_setCheckpointHomeForTest`: when set, the
32
+ * spool roots here instead of `~/.yagni-code`. Pass `null` to restore.
33
+ */
34
+ let homeOverride = null;
35
+ export function _setSpoolHomeForTest(dir) {
36
+ homeOverride = dir;
37
+ }
38
+ function spoolHome() {
39
+ return codeStateHome(homeOverride);
40
+ }
41
+ function spoolDir() {
42
+ return join(spoolHome(), "spool");
43
+ }
44
+ /** The on-disk pending file (exported so tests can inspect it directly). */
45
+ export function spoolFile() {
46
+ return join(spoolDir(), "pending.ndjson");
47
+ }
48
+ /** Append one pending write. NEVER throws (fail-soft: memory loss beats a crash). */
49
+ export function appendToSpool(entry) {
50
+ try {
51
+ mkdirSync(spoolDir(), { recursive: true, mode: 0o700 });
52
+ const file = spoolFile();
53
+ // Self-heal a torn previous write (crash mid-append): if the file ends
54
+ // mid-line, prefix a newline so OUR record lands on its own line instead of
55
+ // gluing onto the fragment and losing both (mirrors checkpoint.ts).
56
+ let prefix = "";
57
+ try {
58
+ const existing = readFileSync(file);
59
+ if (existing.length > 0 && existing[existing.length - 1] !== 0x0a)
60
+ prefix = "\n";
61
+ }
62
+ catch {
63
+ /* no file yet — nothing to heal */
64
+ }
65
+ appendFileSync(file, `${prefix}${JSON.stringify(entry)}\n`, { mode: 0o600 });
66
+ }
67
+ catch {
68
+ /* fail-soft: a spool write must never break the tool call */
69
+ }
70
+ }
71
+ function isSpoolEntry(value) {
72
+ const e = value;
73
+ return (!!e &&
74
+ typeof e === "object" &&
75
+ typeof e.endpoint === "string" &&
76
+ e.endpoint.startsWith("/") &&
77
+ !!e.body &&
78
+ typeof e.body === "object" &&
79
+ typeof e.idempotencyKey === "string" &&
80
+ typeof e.ts === "number");
81
+ }
82
+ /** Load pending entries, dropping torn/garbage lines. Never throws. */
83
+ export function loadSpool() {
84
+ try {
85
+ const raw = readFileSync(spoolFile(), "utf8");
86
+ const out = [];
87
+ for (const line of raw.split("\n")) {
88
+ if (!line.trim())
89
+ continue;
90
+ try {
91
+ const obj = JSON.parse(line);
92
+ if (isSpoolEntry(obj))
93
+ out.push(obj);
94
+ }
95
+ catch {
96
+ /* unparseable (torn final write) — drop this line, keep the rest */
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+ catch {
102
+ return []; // missing/unreadable → nothing pending
103
+ }
104
+ }
105
+ /** Atomically rewrite the pending file with the surviving entries (0600). */
106
+ function rewriteSpool(entries) {
107
+ try {
108
+ const file = spoolFile();
109
+ if (entries.length === 0) {
110
+ rmSync(file, { force: true });
111
+ return;
112
+ }
113
+ mkdirSync(spoolDir(), { recursive: true, mode: 0o700 });
114
+ const tmp = join(spoolDir(), `.pending-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}.tmp`);
115
+ writeFileSync(tmp, entries.map((e) => JSON.stringify(e)).join("\n") + "\n", { mode: 0o600 });
116
+ renameSync(tmp, file);
117
+ }
118
+ catch {
119
+ /* fail-soft: worst case the next flush replays an already-deduped key */
120
+ }
121
+ }
122
+ const EMPTY_FLUSH = {
123
+ replayed: 0,
124
+ droppedExpired: 0,
125
+ droppedRejected: 0,
126
+ remaining: 0,
127
+ notices: [],
128
+ };
129
+ function plural(n) {
130
+ return n === 1 ? "" : "s";
131
+ }
132
+ let flushInFlight = null;
133
+ /**
134
+ * Replay every pending write with its ORIGINAL idempotencyKey (server-side
135
+ * dedup makes a double replay a no-op). Serialized: concurrent flushes (a
136
+ * session_start racing a post-write flush) coalesce onto one pass.
137
+ *
138
+ * Per entry: expired (>14d) → dropped with a notice; 2xx → replayed; a
139
+ * permanent 4xx (not auth/rate) → dropped with a notice (the backend will
140
+ * never accept it); transport error, 5xx, 401/403/408/429 → kept, and the
141
+ * rest of the flush stops (the backend is unreachable or the token is bad —
142
+ * hammering the remaining entries would not help). Never throws.
143
+ */
144
+ export function flushSpool(opts) {
145
+ if (!flushInFlight) {
146
+ flushInFlight = doFlush(opts)
147
+ .catch(() => EMPTY_FLUSH)
148
+ .finally(() => {
149
+ flushInFlight = null;
150
+ });
151
+ }
152
+ return flushInFlight;
153
+ }
154
+ /** Statuses that mean "try again later", not "the backend rejected this body". */
155
+ const KEEP_STATUSES = new Set([401, 403, 408, 429]);
156
+ async function doFlush(opts) {
157
+ const entries = loadSpool();
158
+ if (entries.length === 0)
159
+ return EMPTY_FLUSH;
160
+ const nowMs = (opts.now ?? Date.now)();
161
+ // Keys we have RESOLVED this pass (replayed OK, or permanently dropped). We do
162
+ // NOT rewrite from this stale snapshot — instead we re-read the current spool
163
+ // just before writing and drop only these resolved keys, so a write appended
164
+ // mid-flush (a tool that failed while we were replaying) is never clobbered.
165
+ const resolved = new Set();
166
+ let replayed = 0;
167
+ let droppedExpired = 0;
168
+ let droppedRejected = 0;
169
+ let stopped = false;
170
+ for (const entry of entries) {
171
+ if (nowMs - entry.ts > MAX_SPOOL_AGE_MS) {
172
+ droppedExpired += 1;
173
+ resolved.add(entry.idempotencyKey);
174
+ continue;
175
+ }
176
+ if (stopped)
177
+ continue;
178
+ try {
179
+ const res = await resilientFetch(`${opts.baseUrl}${entry.endpoint}`, {
180
+ method: "POST",
181
+ headers: {
182
+ "content-type": "application/json",
183
+ authorization: `Bearer ${opts.getToken() ?? ""}`,
184
+ },
185
+ // Replay verbatim; re-assert the key in case an old entry predates it.
186
+ body: JSON.stringify({ ...entry.body, idempotencyKey: entry.idempotencyKey }),
187
+ }, { fetchImpl: opts.fetchImpl, policy: METERED_POST_FETCH_POLICY });
188
+ if (res.ok) {
189
+ replayed += 1;
190
+ resolved.add(entry.idempotencyKey);
191
+ }
192
+ else if (res.status >= 500 || KEEP_STATUSES.has(res.status)) {
193
+ stopped = true;
194
+ }
195
+ else {
196
+ droppedRejected += 1;
197
+ resolved.add(entry.idempotencyKey);
198
+ }
199
+ }
200
+ catch {
201
+ stopped = true;
202
+ }
203
+ }
204
+ // Re-read + filter + rewrite are back-to-back synchronous calls with no await
205
+ // between them, so no append can interleave in that window (single-threaded).
206
+ // Everything not resolved this pass — kept entries AND any mid-flush append —
207
+ // survives. A re-appended duplicate of a replayed key is harmless (the server
208
+ // dedups on idempotencyKey; it just replays next pass).
209
+ const keep = loadSpool().filter((e) => !resolved.has(e.idempotencyKey));
210
+ rewriteSpool(keep);
211
+ const notices = [];
212
+ if (replayed > 0) {
213
+ notices.push(`Replayed ${replayed} locally spooled YAGNI record${plural(replayed)}.`);
214
+ }
215
+ if (droppedExpired > 0) {
216
+ notices.push(`Dropped ${droppedExpired} unsent YAGNI record${plural(droppedExpired)} older than 14 days from the local spool.`);
217
+ }
218
+ if (droppedRejected > 0) {
219
+ notices.push(`Discarded ${droppedRejected} spooled YAGNI record${plural(droppedRejected)} the backend rejected.`);
220
+ }
221
+ return { replayed, droppedExpired, droppedRejected, remaining: keep.length, notices };
222
+ // (keep.length reflects the post-rewrite spool, including any mid-flush append.)
223
+ }
224
+ /**
225
+ * POST one judgment write with its idempotencyKey; on transport failure or a
226
+ * 5xx, durably spool it for replay instead of losing it. A successful write
227
+ * also kicks a background flush (fail-soft, fire-and-forget) so earlier
228
+ * stranded entries drain as soon as the backend is healthy again.
229
+ */
230
+ export async function sendOrSpool(opts, label, endpoint, body, idempotencyKey, signal) {
231
+ const posted = { ...body, idempotencyKey };
232
+ let res;
233
+ try {
234
+ res = await resilientFetch(`${opts.baseUrl}${endpoint}`, {
235
+ method: "POST",
236
+ headers: {
237
+ "content-type": "application/json",
238
+ authorization: `Bearer ${opts.getToken() ?? ""}`,
239
+ },
240
+ body: JSON.stringify(posted),
241
+ }, { fetchImpl: opts.fetchImpl, signal, policy: METERED_POST_FETCH_POLICY });
242
+ }
243
+ catch (err) {
244
+ const reason = err instanceof Error ? err.message : String(err);
245
+ appendToSpool({ endpoint, body: posted, idempotencyKey, ts: (opts.now ?? Date.now)() });
246
+ return { kind: "spooled", reason };
247
+ }
248
+ if (res.ok) {
249
+ // Success proves the backend is reachable: drain anything stranded earlier.
250
+ void flushSpool(opts).catch(() => { });
251
+ let json = null;
252
+ try {
253
+ json = await res.json();
254
+ }
255
+ catch {
256
+ /* a 2xx with a non-JSON body still counts as recorded */
257
+ }
258
+ return { kind: "ok", json };
259
+ }
260
+ if (res.status >= 500) {
261
+ appendToSpool({ endpoint, body: posted, idempotencyKey, ts: (opts.now ?? Date.now)() });
262
+ return { kind: "spooled", reason: `the YAGNI backend returned HTTP ${res.status}` };
263
+ }
264
+ return { kind: "rejected", message: await friendlyFetchError(label, res) };
265
+ }
266
+ //# sourceMappingURL=spool.js.map
@@ -0,0 +1,2 @@
1
+ export declare function codeStateHome(override: string | null, env?: NodeJS.ProcessEnv, userHome?: string): string;
2
+ //# sourceMappingURL=stateHome.d.ts.map
@@ -0,0 +1,6 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ export function codeStateHome(override, env = process.env, userHome = homedir()) {
4
+ return override ?? env.YAGNI_CODE_HOME ?? join(userHome, ".yagni-code");
5
+ }
6
+ //# sourceMappingURL=stateHome.js.map
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The general `subagent` tool — delegate a self-contained task to an agent
3
+ * with its own fresh context window, optionally in parallel.
4
+ *
5
+ * Reuses the /go pipeline's battle-tested child runner (`runStage`: NDJSON
6
+ * stream folding, output caps, abort escalation) through its `personaBody`
7
+ * seam, so a subagent is exactly a grounded pi child whose system prompt is
8
+ * the agent definition's body instead of a /go persona.
9
+ *
10
+ * Agent definitions are the Claude Code format — markdown with
11
+ * name/description/tools/model frontmatter — discovered from the repo's
12
+ * `.claude/agents/` and `.pi/agents/` plus the user's `~/.claude/agents/`
13
+ * (project beats user on a name collision). A built-in `general` agent is
14
+ * always available, so the tool works in a repo with no definitions at all.
15
+ * Claude model names map onto YAGNI tiers (opus→advanced, sonnet→standard,
16
+ * haiku→efficient); unknown models run on standard.
17
+ */
18
+ import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
19
+ import { Type } from "typebox";
20
+ import { runStage } from "./pipeline/runner.js";
21
+ import type { ModelTier, PipelineStage } from "./pipeline/types.js";
22
+ export declare const SUBAGENT_TOOL_NAME = "subagent";
23
+ export declare const GENERAL_AGENT_NAME = "general";
24
+ export declare const MAX_PARALLEL_SUBAGENTS = 4;
25
+ /**
26
+ * The default tool surface a subagent gets when its definition declares none:
27
+ * the full working set plus grounded answers, mirroring what a Claude Code
28
+ * subagent would inherit.
29
+ */
30
+ export declare const DEFAULT_SUBAGENT_TOOLS: string[];
31
+ export type SubagentSource = "project-claude" | "project-pi" | "user-claude" | "builtin";
32
+ export interface SubagentDef {
33
+ name: string;
34
+ description: string;
35
+ tools?: string[];
36
+ model: ModelTier;
37
+ /** The agent's system prompt (the markdown body). */
38
+ body: string;
39
+ source: SubagentSource;
40
+ }
41
+ /** Claude Code model names → YAGNI tiers; YAGNI tiers pass through. */
42
+ export declare function mapModelTier(model: string | undefined): ModelTier;
43
+ /**
44
+ * Parse one Claude Code-format agent file. Returns null when the file has no
45
+ * description — the one field the tool cannot do without (the model picks
46
+ * agents by it).
47
+ */
48
+ export declare function parseAgentMarkdown(content: string, fallbackName: string, source: SubagentSource): SubagentDef | null;
49
+ export interface DiscoverDeps {
50
+ cwd: string;
51
+ homeDir?: string;
52
+ }
53
+ /**
54
+ * All available agents: built-in general, then user `~/.claude/agents`, then
55
+ * the repo's `.pi/agents` and `.claude/agents` — later wins on a name
56
+ * collision, so a project definition beats a user one beats the built-in.
57
+ */
58
+ export declare function discoverSubagents(deps: DiscoverDeps): SubagentDef[];
59
+ export declare function formatAgentList(agents: SubagentDef[]): string;
60
+ /**
61
+ * A subagent invocation is a synthetic pipeline stage: the task rides in as
62
+ * `{ticket}` verbatim, and the agent's body replaces the /go persona through
63
+ * the runner's personaBody seam.
64
+ */
65
+ export declare function buildSubagentStage(def: SubagentDef, task: string): {
66
+ stage: PipelineStage;
67
+ ctx: {
68
+ ticket: string;
69
+ grounded: boolean;
70
+ };
71
+ };
72
+ type SubagentParams = {
73
+ task?: string;
74
+ agent?: string;
75
+ tasks?: Array<{
76
+ task: string;
77
+ agent?: string;
78
+ }>;
79
+ };
80
+ export interface MakeSubagentToolDeps {
81
+ runStageImpl?: typeof runStage;
82
+ discover?: (deps: DiscoverDeps) => SubagentDef[];
83
+ homeDir?: string;
84
+ }
85
+ export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
86
+ name: string;
87
+ label: string;
88
+ description: string;
89
+ promptSnippet: string;
90
+ parameters: Type.TObject<{
91
+ task: Type.TOptional<Type.TString>;
92
+ agent: Type.TOptional<Type.TString>;
93
+ tasks: Type.TOptional<Type.TArray<Type.TObject<{
94
+ task: Type.TString;
95
+ agent: Type.TOptional<Type.TString>;
96
+ }>>>;
97
+ }>;
98
+ execute(_toolCallId: string, params: SubagentParams, signal?: AbortSignal, onUpdate?: (update: {
99
+ content: Array<{
100
+ type: "text";
101
+ text: string;
102
+ }>;
103
+ details: unknown;
104
+ }) => void, ctx?: ExtensionContext): Promise<{
105
+ content: {
106
+ type: "text";
107
+ text: string;
108
+ }[];
109
+ details: {};
110
+ isError: boolean;
111
+ } | {
112
+ isError?: boolean | undefined;
113
+ content: {
114
+ type: "text";
115
+ text: string;
116
+ }[];
117
+ details: {
118
+ tasks: {
119
+ agent: string;
120
+ task: string;
121
+ exitCode: number;
122
+ usage: import("./pipeline/types.js").StageUsage;
123
+ toolCalls: number;
124
+ }[];
125
+ };
126
+ }>;
127
+ };
128
+ export interface RegisterSubagentsDeps {
129
+ discover?: (deps: DiscoverDeps) => SubagentDef[];
130
+ homeDir?: string;
131
+ }
132
+ /** Wire the subagent tool and the /agents listing command. */
133
+ export declare function registerSubagents(pi: ExtensionAPI, deps?: RegisterSubagentsDeps): void;
134
+ export {};
135
+ //# sourceMappingURL=subagents.d.ts.map
@@ -0,0 +1,281 @@
1
+ /**
2
+ * The general `subagent` tool — delegate a self-contained task to an agent
3
+ * with its own fresh context window, optionally in parallel.
4
+ *
5
+ * Reuses the /go pipeline's battle-tested child runner (`runStage`: NDJSON
6
+ * stream folding, output caps, abort escalation) through its `personaBody`
7
+ * seam, so a subagent is exactly a grounded pi child whose system prompt is
8
+ * the agent definition's body instead of a /go persona.
9
+ *
10
+ * Agent definitions are the Claude Code format — markdown with
11
+ * name/description/tools/model frontmatter — discovered from the repo's
12
+ * `.claude/agents/` and `.pi/agents/` plus the user's `~/.claude/agents/`
13
+ * (project beats user on a name collision). A built-in `general` agent is
14
+ * always available, so the tool works in a repo with no definitions at all.
15
+ * Claude model names map onto YAGNI tiers (opus→advanced, sonnet→standard,
16
+ * haiku→efficient); unknown models run on standard.
17
+ */
18
+ import * as fs from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
22
+ import { Type } from "typebox";
23
+ import { runStage } from "./pipeline/runner.js";
24
+ export const SUBAGENT_TOOL_NAME = "subagent";
25
+ export const GENERAL_AGENT_NAME = "general";
26
+ export const MAX_PARALLEL_SUBAGENTS = 4;
27
+ /**
28
+ * The default tool surface a subagent gets when its definition declares none:
29
+ * the full working set plus grounded answers, mirroring what a Claude Code
30
+ * subagent would inherit.
31
+ */
32
+ export const DEFAULT_SUBAGENT_TOOLS = [
33
+ "read",
34
+ "grep",
35
+ "find",
36
+ "ls",
37
+ "bash",
38
+ "edit",
39
+ "write",
40
+ "ask_yagni",
41
+ ];
42
+ const GENERAL_BODY = `You are a capable software-engineering subagent with a fresh context window. Complete the task you are given end to end, autonomously.
43
+
44
+ You are grounded in how THIS company works: call ask_yagni before inferring a convention, an ownership rule, or anything organization-specific.
45
+
46
+ Your final message is your report back to the driving agent, which has NOT seen what you read or did. Make it compressed and complete: what you did, what you found, exact file paths and key excerpts, and anything the driver must know before continuing.`;
47
+ const GENERAL_AGENT = {
48
+ name: GENERAL_AGENT_NAME,
49
+ description: "General-purpose agent for research, multi-file changes, and self-contained tasks.",
50
+ model: "standard",
51
+ body: GENERAL_BODY,
52
+ source: "builtin",
53
+ };
54
+ const TIER_VALUES = ["peak", "advanced", "standard", "efficient"];
55
+ /** Claude Code model names → YAGNI tiers; YAGNI tiers pass through. */
56
+ export function mapModelTier(model) {
57
+ if (!model)
58
+ return "standard";
59
+ const normalized = model.trim().toLowerCase();
60
+ if (TIER_VALUES.includes(normalized))
61
+ return normalized;
62
+ if (normalized.includes("opus"))
63
+ return "advanced";
64
+ if (normalized.includes("sonnet"))
65
+ return "standard";
66
+ if (normalized.includes("haiku"))
67
+ return "efficient";
68
+ return "standard";
69
+ }
70
+ /**
71
+ * Parse one Claude Code-format agent file. Returns null when the file has no
72
+ * description — the one field the tool cannot do without (the model picks
73
+ * agents by it).
74
+ */
75
+ export function parseAgentMarkdown(content, fallbackName, source) {
76
+ const { frontmatter, body } = parseFrontmatter(content);
77
+ const description = frontmatter.description?.trim();
78
+ if (!description)
79
+ return null;
80
+ const tools = frontmatter.tools
81
+ ?.split(",")
82
+ .map((t) => t.trim())
83
+ .filter(Boolean);
84
+ return {
85
+ name: (frontmatter.name?.trim() || fallbackName).toLowerCase(),
86
+ description,
87
+ tools: tools && tools.length > 0 ? tools : undefined,
88
+ model: mapModelTier(frontmatter.model),
89
+ body: body.trim() || GENERAL_BODY,
90
+ source,
91
+ };
92
+ }
93
+ function loadAgentsFromDir(dir, source) {
94
+ let entries;
95
+ try {
96
+ entries = fs.readdirSync(dir, { withFileTypes: true });
97
+ }
98
+ catch {
99
+ return [];
100
+ }
101
+ const agents = [];
102
+ for (const entry of entries) {
103
+ if (!entry.name.endsWith(".md"))
104
+ continue;
105
+ if (!entry.isFile() && !entry.isSymbolicLink())
106
+ continue;
107
+ try {
108
+ const content = fs.readFileSync(join(dir, entry.name), "utf8");
109
+ const def = parseAgentMarkdown(content, entry.name.replace(/\.md$/, ""), source);
110
+ if (def)
111
+ agents.push(def);
112
+ }
113
+ catch {
114
+ continue;
115
+ }
116
+ }
117
+ return agents;
118
+ }
119
+ /**
120
+ * All available agents: built-in general, then user `~/.claude/agents`, then
121
+ * the repo's `.pi/agents` and `.claude/agents` — later wins on a name
122
+ * collision, so a project definition beats a user one beats the built-in.
123
+ */
124
+ export function discoverSubagents(deps) {
125
+ const home = deps.homeDir ?? homedir();
126
+ const layers = [
127
+ [GENERAL_AGENT],
128
+ loadAgentsFromDir(join(home, ".claude", "agents"), "user-claude"),
129
+ loadAgentsFromDir(join(deps.cwd, ".pi", "agents"), "project-pi"),
130
+ loadAgentsFromDir(join(deps.cwd, ".claude", "agents"), "project-claude"),
131
+ ];
132
+ const byName = new Map();
133
+ for (const layer of layers) {
134
+ for (const agent of layer)
135
+ byName.set(agent.name, agent);
136
+ }
137
+ return [...byName.values()];
138
+ }
139
+ const SOURCE_LABEL = {
140
+ builtin: "built-in",
141
+ "user-claude": "~/.claude/agents",
142
+ "project-pi": ".pi/agents",
143
+ "project-claude": ".claude/agents",
144
+ };
145
+ export function formatAgentList(agents) {
146
+ return agents
147
+ .map((a) => `${a.name} (${SOURCE_LABEL[a.source]}): ${a.description}`)
148
+ .join("\n");
149
+ }
150
+ /**
151
+ * A subagent invocation is a synthetic pipeline stage: the task rides in as
152
+ * `{ticket}` verbatim, and the agent's body replaces the /go persona through
153
+ * the runner's personaBody seam.
154
+ */
155
+ export function buildSubagentStage(def, task) {
156
+ return {
157
+ stage: {
158
+ id: "implement",
159
+ agent: def.name,
160
+ model: def.model,
161
+ tools: def.tools ?? DEFAULT_SUBAGENT_TOOLS,
162
+ taskTemplate: "{ticket}",
163
+ },
164
+ ctx: { ticket: task, grounded: true },
165
+ };
166
+ }
167
+ const parameters = Type.Object({
168
+ task: Type.Optional(Type.String({
169
+ description: "The self-contained task. Include everything needed: the subagent shares none of your context.",
170
+ })),
171
+ agent: Type.Optional(Type.String({ description: "Agent name (see /agents). Defaults to general." })),
172
+ tasks: Type.Optional(Type.Array(Type.Object({
173
+ task: Type.String(),
174
+ agent: Type.Optional(Type.String()),
175
+ }), {
176
+ description: `Run several independent tasks in parallel (max ${MAX_PARALLEL_SUBAGENTS}). Use INSTEAD of task.`,
177
+ })),
178
+ });
179
+ export function makeSubagentTool(deps = {}) {
180
+ const run = deps.runStageImpl ?? runStage;
181
+ const discover = deps.discover ?? discoverSubagents;
182
+ return {
183
+ name: SUBAGENT_TOOL_NAME,
184
+ label: "Subagent",
185
+ description: "Delegate a self-contained task to a subagent with its own fresh context window, and get back " +
186
+ "a compressed report. Use it for context-heavy exploration you don't need blow-by-blow, or to " +
187
+ "run independent tasks in parallel via `tasks`. The subagent shares NONE of your conversation: " +
188
+ "spell out the task completely. Agents come from this repo's .claude/agents and .pi/agents " +
189
+ "(list them with /agents); omit `agent` for the general-purpose one.",
190
+ promptSnippet: "subagent: delegate a self-contained task (or parallel tasks) to a fresh-context agent; returns its report.",
191
+ parameters,
192
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
193
+ const fail = (text) => ({
194
+ content: [{ type: "text", text }],
195
+ details: {},
196
+ isError: true,
197
+ });
198
+ const requested = params.tasks && params.tasks.length > 0
199
+ ? params.tasks
200
+ : params.task
201
+ ? [{ task: params.task, agent: params.agent }]
202
+ : [];
203
+ if (requested.length === 0) {
204
+ return fail("Error: pass `task` (or a `tasks` array) describing what to do.");
205
+ }
206
+ if (requested.length > MAX_PARALLEL_SUBAGENTS) {
207
+ return fail(`Error: at most ${MAX_PARALLEL_SUBAGENTS} parallel tasks per call.`);
208
+ }
209
+ const cwd = ctx?.cwd ?? process.cwd();
210
+ const agents = discover({ cwd, homeDir: deps.homeDir });
211
+ const resolved = [];
212
+ for (const req of requested) {
213
+ const name = (req.agent ?? GENERAL_AGENT_NAME).toLowerCase();
214
+ const def = agents.find((a) => a.name === name);
215
+ if (!def) {
216
+ return fail(`Error: unknown agent "${name}". Available agents:\n${formatAgentList(agents)}`);
217
+ }
218
+ resolved.push({ def, task: req.task });
219
+ }
220
+ onUpdate?.({
221
+ content: [
222
+ {
223
+ type: "text",
224
+ text: resolved.length === 1
225
+ ? `Running ${resolved[0].def.name} subagent…`
226
+ : `Running ${resolved.length} subagents in parallel…`,
227
+ },
228
+ ],
229
+ details: {},
230
+ });
231
+ const outcomes = await Promise.all(resolved.map(async ({ def, task }) => {
232
+ const { stage, ctx: stageCtx } = buildSubagentStage(def, task);
233
+ const result = await run(stage, stageCtx, {
234
+ cwd,
235
+ signal,
236
+ personaBody: () => def.body,
237
+ });
238
+ return { agent: def.name, task, result };
239
+ }));
240
+ const allFailed = outcomes.every((o) => o.result.exitCode !== 0);
241
+ const sections = outcomes.map((o) => {
242
+ const output = o.result.finalOutput.trim();
243
+ const failedNote = o.result.exitCode !== 0
244
+ ? `\n\n(subagent failed, exit ${o.result.exitCode}${o.result.stderr.trim() ? `: ${o.result.stderr.trim().slice(-500)}` : ""})`
245
+ : "";
246
+ const bodyText = output || (o.result.exitCode === 0 ? "(no output)" : "");
247
+ return outcomes.length === 1
248
+ ? `${bodyText}${failedNote}`
249
+ : `## ${o.agent}: ${o.task}\n\n${bodyText}${failedNote}`;
250
+ });
251
+ return {
252
+ content: [{ type: "text", text: sections.join("\n\n") }],
253
+ details: {
254
+ tasks: outcomes.map((o) => ({
255
+ agent: o.agent,
256
+ task: o.task,
257
+ exitCode: o.result.exitCode,
258
+ usage: o.result.usage,
259
+ toolCalls: o.result.toolCalls,
260
+ })),
261
+ },
262
+ ...(allFailed ? { isError: true } : {}),
263
+ };
264
+ },
265
+ };
266
+ }
267
+ /** Wire the subagent tool and the /agents listing command. */
268
+ export function registerSubagents(pi, deps = {}) {
269
+ const discover = deps.discover ?? discoverSubagents;
270
+ pi.registerTool(makeSubagentTool(deps));
271
+ pi.registerCommand("agents", {
272
+ description: "List the subagents available in this repo (.claude/agents, .pi/agents).",
273
+ handler: async (_args, ctx) => {
274
+ if (!ctx.hasUI)
275
+ return;
276
+ const agents = discover({ cwd: ctx.cwd ?? process.cwd(), homeDir: deps.homeDir });
277
+ ctx.ui.notify(`Available subagents:\n${formatAgentList(agents)}`, "info");
278
+ },
279
+ });
280
+ }
281
+ //# sourceMappingURL=subagents.js.map