pi-crew 0.11.4 → 0.11.5

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.
@@ -48,6 +48,7 @@ Reads the versioned `PlanRecord` list at `<stateRoot>/plans/plans.json` (ADR-4).
48
48
  | `search` | BM25-ranked agent/team discovery | Smart search |
49
49
  | `events` | Read the event log | Debug/audit |
50
50
  | `artifacts` | List run artifacts | View outputs |
51
+ | `breakdown` | Read per-task prompt-token breakdown artifacts | Debug prompt bloat (requires `PI_CREW_PROMPT_BREAKDOWN=1`) |
51
52
  | `worktrees` | List run worktree metadata | Inspect worktrees |
52
53
  | `graph` | Load/save/list run graphs | Visualization |
53
54
  | `explain` | Explain a run or task structure | Understand a run |
@@ -77,6 +78,7 @@ Reads the versioned `PlanRecord` list at `<stateRoot>/plans/plans.json` (ADR-4).
77
78
  | **`workflow-list`** | **v0.9.0** List static + dynamic workflows | Discover workflows |
78
79
  | **`workflow-save`** | **v0.9.0** Overwrite `.dwf.ts` source (requires `confirm:true`) | Update dynamic workflow |
79
80
  | **`workflow-delete`** | **v0.9.0** Delete a `.dwf.ts` (requires `confirm:true`) | Remove dynamic workflow |
81
+ | `compare` | Compare two runs side by side (US-021) | Diff before/after a fix, or two attempts at one goal |
80
82
  | `help` | Display help text | Help |
81
83
 
82
84
  ---
@@ -420,6 +422,26 @@ Append-only JSONL events: task.started, task.completed, run.blocked, etc.
420
422
 
421
423
  ---
422
424
 
425
+ ### `breakdown` — Per-task prompt-token breakdown
426
+
427
+ G18 (opt-in measurement surface): reads the per-task
428
+ `metadata/<taskId>.prompt-breakdown.json` artifacts and aggregates per-task
429
+ `estTokens` totals plus the top-5 largest prompt sections. Artifacts are
430
+ recorded only when the host Pi session is started with
431
+ `PI_CREW_PROMPT_BREAKDOWN=1` (default off — zero cost when off).
432
+
433
+ ```json
434
+ {
435
+ "action": "breakdown",
436
+ "runId": "team_...",
437
+ "taskId": "01_plan"
438
+ }
439
+ ```
440
+
441
+ `taskId` is optional — omit it to aggregate every task with a breakdown.
442
+
443
+ ---
444
+
423
445
  ### `worktrees` — Worktree metadata
424
446
 
425
447
  ```json
package/index.ts CHANGED
@@ -4,7 +4,10 @@
4
4
  * Resolution order:
5
5
  * 1. dist/index.mjs (bundle) when present AND not explicitly disabled.
6
6
  * Default since v0.9.17 close-out — benchmarks show bundle is
7
- * ~19% faster total cold-start (post-fsync) than strip-types.
7
+ * ~19% faster cold-start on a cold Node cache; with a warm
8
+ * NODE_COMPILE_CACHE the gap shrinks to ~2%. The bundle's primary
9
+ * value is correctness (vendored typebox) and deterministic startup,
10
+ * not raw speed.
8
11
  * See `scripts/bench-cold-start.mjs` for reproducible numbers.
9
12
  * 2. Inline strip-types loading — fallback when bundle is missing
10
13
  * (e.g. dev clone without `npm run build:bundle`) OR when
@@ -52,9 +55,9 @@ import { fileURLToPath } from "node:url";
52
55
  import { dirname, resolve } from "node:path";
53
56
 
54
57
  // IMPORTANT: src/ imports here MUST be dynamic (lazy `import()`), never
55
- // top-level static imports. Published npm packages ship only dist/index.mjs
56
- // in the tarball (src/ is excluded by the package.json "files" field — keeps
57
- // the package ~5MB instead of ~16MB). A top-level static
58
+ // top-level static imports. The npm tarball ships dist/index.mjs as the
59
+ // entry and ALSO includes src/ (see "files" in package.json) for source
60
+ // maps of future tooling. dist/index.mjs is ~1.6MB. A top-level static
58
61
  // `import { x } from "./src/..."` is resolved by the module loader BEFORE
59
62
  // this file's code runs, so it would break every fresh `npm install`
60
63
  // (Cannot find module './src/...'). Dynamic import() defers src/ resolution
@@ -105,7 +108,8 @@ if (!envForceOff) {
105
108
  // Lazy src/ fallback — only resolved when the bundle is unavailable
106
109
  // (dev clones without a built dist/). NEVER imported in published
107
110
  // installs, where the bundle always ships + loads above. This dynamic
108
- // import() is what lets us exclude src/ from the npm tarball.
111
+ // import() keeps startup lazy so the bundle loads only when the extension
112
+ // actually activates.
109
113
  let srcRegister:
110
114
  | typeof import("./src/extension/register.ts").registerPiTeams
111
115
  | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.4",
3
+ "version": "0.11.5",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -136,23 +136,23 @@
136
136
  "@earendil-works/pi-tui": "*"
137
137
  },
138
138
  "dependencies": {
139
- "@sinclair/typebox": "^0.34.50",
140
- "acorn": "^8.17.0",
139
+ "@sinclair/typebox": "^0.34.52",
140
+ "acorn": "^8.18.0",
141
141
  "ajv": "^8.20.0",
142
142
  "cli-highlight": "^2.1.11",
143
143
  "diff": "^5.2.0",
144
- "esbuild": "^0.28.1",
144
+ "esbuild": "^0.28.2",
145
145
  "jiti": "^2.7.0",
146
- "yaml": "^2.9.0"
146
+ "yaml": "^2.9.1"
147
147
  },
148
148
  "devDependencies": {
149
- "@biomejs/biome": "^2.5.3",
149
+ "@biomejs/biome": "^2.5.14",
150
150
  "@earendil-works/pi-agent-core": "^0.84.0",
151
151
  "@earendil-works/pi-ai": "^0.84.0",
152
152
  "@earendil-works/pi-coding-agent": "^0.84.0",
153
153
  "@earendil-works/pi-tui": "^0.84.0",
154
- "@types/node": "^25.9.5",
155
- "tsx": "^4.23.0",
154
+ "@types/node": "^25.9.8",
155
+ "tsx": "^4.23.15",
156
156
  "typescript": "^7.0.2"
157
157
  },
158
158
  "peerDependenciesMeta": {
@@ -12,7 +12,7 @@
12
12
  * pathological multi-GB runs, which pi-crew does not produce.
13
13
  *
14
14
  * Output:
15
- * docs/perf-report-<runId>.md — báo cáo tiếng Việt, bảng + 🔴 highlight
15
+ * docs/perf/perf-report-<runId>.md — báo cáo tiếng Việt, bảng + 🔴 highlight
16
16
  * bench/results/<runId>.json — structured JSON
17
17
  */
18
18
 
@@ -706,7 +706,7 @@ async function main() {
706
706
 
707
707
  // F1 (audit): validate runId to prevent path injection — runId flows into
708
708
  // join(crewRoot, "state", "runs", runId) AND into output filenames
709
- // (docs/perf-report-<runId>.md, bench/results/<runId>.json). Without this,
709
+ // (docs/perf/perf-report-<runId>.md, bench/results/<runId>.json). Without this,
710
710
  // runId="../../tmp/x" would read/write outside the intended dirs.
711
711
  if (!/^[A-Za-z0-9_.-]+$/.test(runId)) {
712
712
  process.stderr.write(`Error: invalid runId (must be alphanumeric/_/./-): ${runId}\n`);
@@ -939,7 +939,10 @@ async function main() {
939
939
 
940
940
  const resultsDir = join(process.cwd(), "bench", "results");
941
941
  mkdirSync(resultsDir, { recursive: true });
942
- const docsDir = join(process.cwd(), "docs");
942
+ // Perf reports write to docs/perf/ (runtime output, gitignored via the
943
+ // narrow `docs/perf/perf-report-team_*` pattern — curated perf docs in
944
+ // docs/perf/ stay tracked). Legacy location was docs/ top level.
945
+ const docsDir = join(process.cwd(), "docs", "perf");
943
946
  mkdirSync(docsDir, { recursive: true });
944
947
  const jsonPath = join(resultsDir, `${runId}.json`);
945
948
  writeFileSync(jsonPath, JSON.stringify(report, null, 2));
@@ -1290,7 +1290,7 @@ Use this to answer "đủ full tính năng chưa?" without re-deriving. Every us
1290
1290
 
1291
1291
  | Feature | Code entry | Verify via |
1292
1292
  |---|---|---|
1293
- | Team tool — 55 actions / 5 domains | `src/schema/team-tool-schema.ts:391-437`, dispatch in `src/extension/team-tool/` | 9a (read-only) + 9b/9c/9d/9e/9f theo domain |
1293
+ | Team tool — 56 actions / 5 domains | `src/schema/team-tool-schema.ts:391-437`, dispatch in `src/extension/team-tool/` | 9a (read-only) + 9b/9c/9d/9e/9f theo domain |
1294
1294
  | Runtime mode `child-process` (default) | `src/runtime/child-pi/` | 9b sync run + T7 |
1295
1295
  | Runtime mode `scaffold` (dry-run) | `src/runtime/task-runner/pre-execution.ts:176` | 9b `action='plan'`/`'plans'` (preview không spawn) hoặc run với `runtime.mode='scaffold'` |
1296
1296
  | Runtime mode `live-session` (experimental) | `src/runtime/live-session/` | Run với `runtime.mode='live-session'` + irc tool xuất hiện trong worker (`src/runtime/custom-tools/irc-tool.ts`) |
@@ -7,8 +7,8 @@
7
7
  *
8
8
  * 54→5 mapping:
9
9
  * run (9): run, parallel, plan, orchestrate, resume, retry, wait, steer, goal
10
- * status (16): status, list, get, events, artifacts, summary, graph, search,
11
- * health, worktrees, checkpoint, cache, explain, onboard, recommend, help
10
+ * status (17): status, list, get, events, artifacts, breakdown, summary, graph,
11
+ * search, health, worktrees, checkpoint, cache, explain, onboard, recommend, help
12
12
  * control (7): cancel, invalidate, respond, cleanup, prune, forget, doctor
13
13
  * manage (17): create, update, delete, init, config, validate, autonomy, settings,
14
14
  * workflow-create/get/list/save/delete, import, imports, export, compare
@@ -36,12 +36,13 @@ const ACTION_TO_DOMAIN: Record<string, TeamDomain> = {
36
36
  steer: "run",
37
37
  goal: "run",
38
38
 
39
- // status domain (16)
39
+ // status domain (17)
40
40
  status: "status",
41
41
  list: "status",
42
42
  get: "status",
43
43
  events: "status",
44
44
  artifacts: "status",
45
+ breakdown: "status",
45
46
  summary: "status",
46
47
  graph: "status",
47
48
  search: "status",
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * API-5 facade dispatch — Status domain router.
3
3
  *
4
- * Actions: status, list, get, events, artifacts, summary, graph, search,
5
- * health, worktrees, checkpoint, cache, explain, onboard, recommend, help.
4
+ * Actions: status, list, get, events, artifacts, breakdown, summary, graph,
5
+ * search, health, worktrees, checkpoint, cache, explain, onboard, recommend,
6
+ * help.
6
7
  *
7
8
  * Inline cases (graph, search, onboard, explain, cache, checkpoint, recommend,
8
9
  * help) are moved verbatim from the former handleTeamTool switch.
@@ -28,7 +29,7 @@ import type { PiTeamsToolResult } from "../../tool-result.ts";
28
29
  import { result, type TeamContext } from "../context.ts";
29
30
  import { handleExplain } from "../explain.ts";
30
31
  import { handleHealthMonitor } from "../health-monitor.ts";
31
- import { handleArtifacts, handleEvents, handleSummary } from "../inspect.ts";
32
+ import { handleArtifacts, handleBreakdown, handleEvents, handleSummary } from "../inspect.ts";
32
33
  import { handleWorktrees } from "../lifecycle-actions.ts";
33
34
  import { handleStatus } from "../status.ts";
34
35
 
@@ -45,6 +46,7 @@ export const STATUS_DOMAIN_ACTIONS = [
45
46
  "get",
46
47
  "events",
47
48
  "artifacts",
49
+ "breakdown",
48
50
  "summary",
49
51
  "graph",
50
52
  "search",
@@ -73,6 +75,8 @@ export async function handleStatusDomain(params: TeamToolParamsValue, ctx: TeamC
73
75
  return handleEvents(params, ctx);
74
76
  case "artifacts":
75
77
  return handleArtifacts(params, ctx);
78
+ case "breakdown":
79
+ return handleBreakdown(params, ctx);
76
80
  case "worktrees":
77
81
  return handleWorktrees(params, ctx);
78
82
  case "summary":
@@ -1,3 +1,5 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
2
4
  import { readEventsCursor } from "../../state/event-log/event-log.ts";
3
5
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
@@ -110,3 +112,91 @@ export function handleSummary(params: TeamToolParamsValue, ctx: TeamContext): Pi
110
112
  artifactsRoot: loaded.manifest.artifactsRoot,
111
113
  });
112
114
  }
115
+
116
+ // ─── G18 (SDD 2026-09-30 WI-3): prompt-token breakdown surface ──────────────
117
+
118
+ /** File suffix of the per-task breakdown artifact written by pre-execution
119
+ * (`metadata/<taskId>.prompt-breakdown.json`) when PI_CREW_PROMPT_BREAKDOWN=1. */
120
+ const BREAKDOWN_FILE_SUFFIX = ".prompt-breakdown.json";
121
+
122
+ /** Shape of one section entry in a prompt-breakdown JSON artifact
123
+ * (see writeArtifact call in src/runtime/task-runner/pre-execution.ts). */
124
+ interface PromptBreakdownSection {
125
+ chars: number;
126
+ estTokens: number;
127
+ }
128
+
129
+ /**
130
+ * G18: read the opt-in per-task prompt-token breakdown artifacts of a run and
131
+ * surface per-task totals + the top-5 largest sections. Consumers: the leader
132
+ * prompt (future W-F token decisions, G15/G17) and humans debugging prompt
133
+ * bloat. The artifacts exist only when the host session was started with
134
+ * PI_CREW_PROMPT_BREAKDOWN=1 (default off — zero cost when off, SR-02).
135
+ * Mirrors the handleArtifacts pattern: resolve run → read the manifest
136
+ * artifacts index → filter kind=metadata breakdown entries → read files.
137
+ */
138
+ export function handleBreakdown(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
139
+ if (!params.runId)
140
+ return result(
141
+ paramRequired("breakdown", "runId", "{ action: 'breakdown', runId: 'team_...' }"),
142
+ { action: "breakdown", status: "error" },
143
+ true,
144
+ );
145
+ const runCwd = locateRunCwd(params.runId, ctx.cwd);
146
+ if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "breakdown", status: "error" }, true);
147
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
148
+ if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "breakdown", status: "error" }, true);
149
+ // Index-driven lookup (no disk scan): the manifest artifacts index carries
150
+ // one metadata entry per task with a prompt breakdown.
151
+ const breakdownArtifacts = loaded.manifest.artifacts.filter(
152
+ (artifact) => artifact.kind === "metadata" && path.basename(artifact.path).endsWith(BREAKDOWN_FILE_SUFFIX),
153
+ );
154
+ const perTask: Array<{
155
+ taskId: string;
156
+ totalEstTokens: number;
157
+ topSections: Array<{ section: string; estTokens: number; chars: number }>;
158
+ }> = [];
159
+ for (const artifact of breakdownArtifacts) {
160
+ const taskId = path.basename(artifact.path).slice(0, -BREAKDOWN_FILE_SUFFIX.length);
161
+ if (params.taskId && taskId !== params.taskId) continue;
162
+ let sections: Record<string, PromptBreakdownSection>;
163
+ try {
164
+ sections = JSON.parse(fs.readFileSync(artifact.path, "utf-8")) as Record<string, PromptBreakdownSection>;
165
+ } catch {
166
+ continue; // unreadable/corrupt breakdown JSON — skip rather than fail the whole action
167
+ }
168
+ const entries = Object.entries(sections).filter((entry): entry is [string, PromptBreakdownSection] => {
169
+ const value = entry[1];
170
+ return value !== null && typeof value === "object" && typeof value.estTokens === "number";
171
+ });
172
+ const totalEstTokens = entries.reduce((sum, [, value]) => sum + value.estTokens, 0);
173
+ const topSections = [...entries]
174
+ .sort((a, b) => b[1].estTokens - a[1].estTokens)
175
+ .slice(0, 5)
176
+ .map(([section, value]) => ({ section, estTokens: value.estTokens, chars: value.chars ?? 0 }));
177
+ perTask.push({ taskId, totalEstTokens, topSections });
178
+ }
179
+ if (perTask.length === 0) {
180
+ const scope = params.taskId ? ` for task '${params.taskId}'` : "";
181
+ return result(
182
+ `No prompt-breakdown artifacts${scope} for ${loaded.manifest.runId}. Breakdown capture is opt-in: start the host Pi session with PI_CREW_PROMPT_BREAKDOWN=1 and re-run to record per-section token estimates.`,
183
+ { action: "breakdown", status: "ok", runId: loaded.manifest.runId, data: { runTotal: 0, perTask: [] } },
184
+ );
185
+ }
186
+ const runTotal = perTask.reduce((sum, task) => sum + task.totalEstTokens, 0);
187
+ const lines = [
188
+ `Prompt-token breakdown for ${loaded.manifest.runId}:`,
189
+ ...perTask.flatMap((task) => [
190
+ `- ${task.taskId}: ~${task.totalEstTokens} tokens`,
191
+ ...task.topSections.map((section) => ` - ${section.section}: ~${section.estTokens} tokens (${section.chars} chars)`),
192
+ ]),
193
+ `Run total: ~${runTotal} tokens`,
194
+ ];
195
+ return result(lines.join("\n"), {
196
+ action: "breakdown",
197
+ status: "ok",
198
+ runId: loaded.manifest.runId,
199
+ artifactsRoot: loaded.manifest.artifactsRoot,
200
+ data: { runTotal, perTask },
201
+ });
202
+ }
@@ -750,7 +750,12 @@ export async function reconcileAllStaleRuns(
750
750
  const result = reconcileStaleRun(fresh.manifest, fresh.tasks, now);
751
751
  if (result.repaired || result.verdict === "result_exists") {
752
752
  if (result.repairedTasks) {
753
- saveRunTasks(fresh.manifest, result.repairedTasks);
753
+ // NEW-1 (SDD 2026-09-30 WI-2): persist the FULL task array — the
754
+ // individual-stale branch filters `repairedTasks` down to the stale
755
+ // subset, and saveRunTasks full-overwrites tasks.json, which used to
756
+ // drop every healthy task from disk. `persistTasks` carries the full
757
+ // array; every other branch leaves it unset and falls back here.
758
+ saveRunTasks(fresh.manifest, result.persistTasks ?? result.repairedTasks);
754
759
  for (const task of result.repairedTasks) {
755
760
  try {
756
761
  upsertCrewAgent(fresh.manifest, recordFromTask(fresh.manifest, task, "scaffold"));
@@ -44,6 +44,13 @@ export interface ReconcileResult {
44
44
  detail: string;
45
45
  /** Repaired task state, returned to a locked caller for persistence. */
46
46
  repairedTasks?: TeamTaskState[];
47
+ /** NEW-1 (SDD 2026-09-30 WI-2): what the caller should PERSIST — the FULL
48
+ * task array, including tasks the repair did not touch. Only the
49
+ * individual-stale branch sets it (its `repairedTasks` is filtered down to
50
+ * the stale subset, which must NOT be full-overwrite-persisted or healthy
51
+ * tasks vanish from tasks.json). Callers fall back to `repairedTasks`
52
+ * when absent — every other branch already returns the full array there. */
53
+ persistTasks?: TeamTaskState[];
47
54
  }
48
55
 
49
56
  /**
@@ -490,6 +497,7 @@ export function reconcileStaleRun(manifest: TeamRunManifest, tasks: TeamTaskStat
490
497
  repaired: true,
491
498
  detail: `No PID; ${staleTaskIds.length} individually stale task(s) repaired: ${staleTaskIds.join(", ")}`,
492
499
  repairedTasks: repaired.filter((t) => staleTaskIds.includes(t.id)),
500
+ persistTasks: repaired, // NEW-1: persist FULL array (data-loss fix — SDD WI-2)
493
501
  };
494
502
  }
495
503
  // Fall through: no recent activity but not all tasks stale enough yet.
@@ -526,6 +526,16 @@ export function collectDependencyOutputContext(
526
526
  return { dependencies: trimmedDependencies, sharedReads };
527
527
  }
528
528
 
529
+ // ADR-5 §trust-fence: dependency output is DATA, never instructions. Mirror the
530
+ // ask/delegate seams in src/prompt/prompt-runtime.ts (:323, :426, :589): strip
531
+ // control chars and neutralize a smuggled closing fence tag so worker-controlled
532
+ // content can never close the <dependency-context> fence early (see SDD 2026-09-30 WI-1).
533
+ const DEPENDENCY_CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
534
+
535
+ function sanitizeFencedBody(body: string): string {
536
+ return body.replace(DEPENDENCY_CONTROL_CHAR_PATTERN, "").replace(/<\/dependency-context/g, "&lt;/dependency-context");
537
+ }
538
+
529
539
  export function renderDependencyOutputContext(context: DependencyOutputContext): string {
530
540
  const parts: string[] = [];
531
541
  if (context.dependencies.length) {
@@ -565,7 +575,7 @@ export function renderDependencyOutputContext(context: DependencyOutputContext):
565
575
  parts.push("", read.content.trim(), "");
566
576
  }
567
577
  }
568
- return parts.join("\n").trim();
578
+ return sanitizeFencedBody(parts.join("\n").trim());
569
579
  }
570
580
 
571
581
  export function writeTaskSharedOutput(manifest: TeamRunManifest, step: WorkflowStep, task: TeamTaskState): ArtifactDescriptor | undefined {
@@ -131,6 +131,7 @@ export async function finalizeTaskResult(ctx: TaskExecutionContext, execResult:
131
131
  const skillNames = ctx.skillNames;
132
132
  const skillPaths = ctx.skillPaths;
133
133
  const promptArtifact = ctx.promptArtifact;
134
+ const breakdownArtifact = ctx.breakdownArtifact;
134
135
  const inputsArtifact = ctx.inputsArtifact;
135
136
  const skillArtifact = ctx.skillArtifact;
136
137
  const coordinationArtifact = ctx.coordinationArtifact;
@@ -623,6 +624,7 @@ export async function finalizeTaskResult(ctx: TaskExecutionContext, execResult:
623
624
  artifacts: [
624
625
  ...manifest.artifacts,
625
626
  promptArtifact,
627
+ ...(breakdownArtifact ? [breakdownArtifact] : []),
626
628
  ...(resultArtifact ? [resultArtifact] : []),
627
629
  inputsArtifact,
628
630
  coordinationArtifact,
@@ -71,6 +71,8 @@ export interface TaskExecutionContext {
71
71
  skillPaths: string[] | undefined;
72
72
  prompt: string;
73
73
  promptArtifact: ArtifactDescriptor;
74
+ /** G18: prompt-breakdown artifact descriptor (undefined when PI_CREW_PROMPT_BREAKDOWN off). */
75
+ breakdownArtifact?: ArtifactDescriptor;
74
76
  inputsArtifact: ArtifactDescriptor;
75
77
  skillArtifact: ArtifactDescriptor | undefined;
76
78
  coordinationArtifact: ArtifactDescriptor;
@@ -343,27 +345,32 @@ export async function prepareTaskExecutionContext(
343
345
  // SR-02 phase 1: per-section breakdown artifact (opt-in via
344
346
  // PI_CREW_PROMPT_BREAKDOWN=1). Adds the SYSTEM-side pieces the prompt
345
347
  // builder cannot see (agent definition, skills, pre-step output).
346
- if (promptBreakdownEnabled()) {
347
- const sections: Record<string, number> = {
348
- "system.agentDefinition": input.agent?.systemPrompt?.length ?? 0,
349
- ...(promptResult.sections ?? {}),
350
- "dynamic.preStepOutput": preStepOutput?.length ?? 0,
351
- };
352
- writeArtifact(manifest.artifactsRoot, {
353
- kind: "metadata",
354
- relativePath: `metadata/${task.id}.prompt-breakdown.json`,
355
- content: `${JSON.stringify(
356
- Object.fromEntries(
357
- Object.entries(sections)
358
- .filter(([, chars]) => chars > 0)
359
- .map(([name, chars]) => [name, { chars, estTokens: estimateTokens(chars) }]),
360
- ),
361
- null,
362
- 2,
363
- )}\n`,
364
- producer: "prompt-breakdown",
365
- });
366
- }
348
+ // G18 (SDD Buổi-1 WI-3 follow-up): capture the descriptor so it flows
349
+ // through ctx into the manifest artifacts index — otherwise the
350
+ // `breakdown` team-tool action cannot find it on real runs.
351
+ const breakdownArtifact: ArtifactDescriptor | undefined = promptBreakdownEnabled()
352
+ ? (() => {
353
+ const sections: Record<string, number> = {
354
+ "system.agentDefinition": input.agent?.systemPrompt?.length ?? 0,
355
+ ...(promptResult.sections ?? {}),
356
+ "dynamic.preStepOutput": preStepOutput?.length ?? 0,
357
+ };
358
+ return writeArtifact(manifest.artifactsRoot, {
359
+ kind: "metadata",
360
+ relativePath: `metadata/${task.id}.prompt-breakdown.json`,
361
+ content: `${JSON.stringify(
362
+ Object.fromEntries(
363
+ Object.entries(sections)
364
+ .filter(([, chars]) => chars > 0)
365
+ .map(([name, chars]) => [name, { chars, estTokens: estimateTokens(chars) }]),
366
+ ),
367
+ null,
368
+ 2,
369
+ )}\n`,
370
+ producer: "prompt-breakdown",
371
+ });
372
+ })()
373
+ : undefined;
367
374
 
368
375
  const collectedJsonEvents: Record<string, unknown>[] | undefined = collectYieldEvents ? [] : undefined;
369
376
 
@@ -415,6 +422,7 @@ export async function prepareTaskExecutionContext(
415
422
  skillPaths,
416
423
  prompt,
417
424
  promptArtifact,
425
+ breakdownArtifact,
418
426
  inputsArtifact,
419
427
  skillArtifact,
420
428
  coordinationArtifact,
@@ -398,7 +398,7 @@ const sharedFields = {
398
398
  ),
399
399
  };
400
400
 
401
- // ─── Domain action unions (10+16+7+16+6 = 55 actions) ──────────────────────
401
+ // ─── Domain action unions (10+17+7+17+6 = 57 actions) ──────────────────────
402
402
 
403
403
  const ACTION_DESCRIPTION = "Team action. Defaults to 'list' when omitted.";
404
404
 
@@ -411,6 +411,7 @@ const STATUS_ACTIONS = [
411
411
  "get",
412
412
  "events",
413
413
  "artifacts",
414
+ "breakdown",
414
415
  "summary",
415
416
  "graph",
416
417
  "search",