@yagni-app/code 0.3.2 → 0.3.3

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 (58) hide show
  1. package/dist/cli.js +13 -0
  2. package/dist/extension/footer.d.ts +1 -1
  3. package/dist/extension/hooks.d.ts +111 -0
  4. package/dist/extension/hooks.js +666 -0
  5. package/dist/extension/index.d.ts +13 -6
  6. package/dist/extension/index.js +57 -7
  7. package/dist/extension/{approvedPrefixes.js → permission/approvedPrefixes.js} +1 -1
  8. package/dist/extension/permission/dbReadPolicy.d.ts +90 -0
  9. package/dist/extension/permission/dbReadPolicy.js +227 -0
  10. package/dist/extension/{execPolicy.js → permission/execPolicy.js} +41 -13
  11. package/dist/extension/{permission.d.ts → permission/gate.d.ts} +9 -2
  12. package/dist/extension/{permission.js → permission/gate.js} +103 -4
  13. package/dist/extension/{guardian.d.ts → permission/guardian.d.ts} +2 -2
  14. package/dist/extension/{guardian.js → permission/guardian.js} +1 -1
  15. package/dist/extension/permission/index.d.ts +14 -0
  16. package/dist/extension/permission/index.js +14 -0
  17. package/dist/extension/permission/packageManagerPolicy.d.ts +55 -0
  18. package/dist/extension/permission/packageManagerPolicy.js +170 -0
  19. package/dist/extension/pipeline/activityFeed.js +19 -5
  20. package/dist/extension/pipeline/checker.d.ts +99 -0
  21. package/dist/extension/pipeline/checker.js +238 -0
  22. package/dist/extension/pipeline/fanout.d.ts +116 -0
  23. package/dist/extension/pipeline/fanout.js +248 -0
  24. package/dist/extension/pipeline/fanoutBeats.d.ts +31 -0
  25. package/dist/extension/pipeline/fanoutBeats.js +86 -0
  26. package/dist/extension/pipeline/goCommand.d.ts +14 -0
  27. package/dist/extension/pipeline/goCommand.js +38 -1
  28. package/dist/extension/pipeline/headlessGo.d.ts +163 -0
  29. package/dist/extension/pipeline/headlessGo.js +333 -0
  30. package/dist/extension/pipeline/invocation.d.ts +7 -1
  31. package/dist/extension/pipeline/invocation.js +7 -1
  32. package/dist/extension/pipeline/mission.d.ts +55 -0
  33. package/dist/extension/pipeline/mission.js +70 -0
  34. package/dist/extension/pipeline/orchestrator.d.ts +48 -3
  35. package/dist/extension/pipeline/orchestrator.js +450 -9
  36. package/dist/extension/pipeline/personas.d.ts +16 -1
  37. package/dist/extension/pipeline/personas.js +117 -6
  38. package/dist/extension/pipeline/runSession.d.ts +45 -1
  39. package/dist/extension/pipeline/runState.d.ts +57 -12
  40. package/dist/extension/pipeline/runState.js +60 -18
  41. package/dist/extension/pipeline/runner.js +10 -1
  42. package/dist/extension/pipeline/stages.d.ts +84 -7
  43. package/dist/extension/pipeline/stages.js +166 -0
  44. package/dist/extension/pipeline/tierCap.d.ts +32 -0
  45. package/dist/extension/pipeline/tierCap.js +57 -0
  46. package/dist/extension/pipeline/types.d.ts +130 -1
  47. package/dist/extension/pipeline/types.js +17 -0
  48. package/dist/extension/pipeline/verify.d.ts +86 -3
  49. package/dist/extension/pipeline/verify.js +175 -6
  50. package/dist/extension/turnLog.d.ts +38 -0
  51. package/dist/extension/turnLog.js +93 -0
  52. package/dist/goHeadless.d.ts +75 -0
  53. package/dist/goHeadless.js +132 -0
  54. package/dist/paths.d.ts +9 -0
  55. package/dist/paths.js +12 -0
  56. package/package.json +2 -2
  57. /package/dist/extension/{approvedPrefixes.d.ts → permission/approvedPrefixes.d.ts} +0 -0
  58. /package/dist/extension/{execPolicy.d.ts → permission/execPolicy.d.ts} +0 -0
@@ -9,6 +9,12 @@
9
9
  * - reviewer (business-fit lens): call `review_business_match` and treat a
10
10
  * conflict with a recorded decision as at least High.
11
11
  *
12
+ * The implement diamond adds two more roles: `orchestrator` (the read-only
13
+ * partitioner, carrying the ```partition output contract `parsePartition` reads)
14
+ * and `synthesizer` (the seam-reconciler that writes the review handoff). Its
15
+ * parallel builders reuse the `worker` body plus a per-workstream clause from
16
+ * {@link builderPersonaClause}.
17
+ *
12
18
  * The reviewer also carries the strict, parseable findings output contract so
13
19
  * the `{previous}` handoff into the fix stage parses (see `findings.ts`).
14
20
  *
@@ -95,6 +101,66 @@ The reasoning, grounded in what you actually read. Cite files and lines.
95
101
  The specific evidence that would flip this. If you were not able to verify something load-bearing, say so here rather than hedging the recommendation.
96
102
 
97
103
  Be terse and decisive. The caller is mid-task and paying peak rates for your turn.`;
104
+ const ORCHESTRATOR_INTRO = `You are a build orchestrator. You receive an approved implementation plan and decide ONE thing: whether the build can safely be split into parallel workstreams with disjoint file sets, or must run as a single writer. You do not implement anything.`;
105
+ const ORCHESTRATOR_GROUNDING = `You are grounded. Call ask_yagni before inferring a convention, an ownership boundary, or how this company splits work; a partition that cuts across a boundary this company treats as one surface is a bad partition even when the file sets look clean.`;
106
+ const ORCHESTRATOR_RULES = `You are READ-ONLY. You have read, grep, find, ls and read-only bash. Do not write, edit, build, or run anything that changes the tree: the builders you partition for do the work.
107
+
108
+ Your bias is CONSERVATIVE. Parallel writers earn their width; they do not get it by default. Fan ONLY when the plan splits into obviously clean surfaces whose file sets do not touch. When in doubt, when the plan is one cluster of coupled edits, when the repo context you were given is thin, or when you cannot name each workstream's files with confidence, return single-writer with the reason. A single-writer verdict is a good answer, not a failure; a guessed partition is the one outcome that is worse than running serially.
109
+
110
+ How to judge:
111
+ 1. Read the plan's own "Files to modify / create" list first; it is the strongest evidence you have.
112
+ 2. Group the work by surface (package, layer, directory), not by plan step ordering.
113
+ 3. Check the groups for shared files, shared types, and edits that must land together. Any of those means fewer workstreams, or one.
114
+ 4. Give each workstream a tier: "efficient" for mechanical work (test fixture updates, renames, copy sweeps, mechanical follow-through), "standard" for load-bearing work that carries judgment. This is cost fitting, not perspective: never split the same work across two workstreams.`;
115
+ /**
116
+ * The machine-readable partition contract the orchestrator must emit, kept in
117
+ * lockstep with `parsePartition` (`fanout.ts`). Separate from the body so the
118
+ * contract text has one home and the tests can read it directly.
119
+ */
120
+ const PARTITION_CONTRACT = `=== Partition output contract (required) ===
121
+ End your answer with a fenced block in EXACTLY this form and nothing after it:
122
+
123
+ \`\`\`partition
124
+ {
125
+ "mode": "fan",
126
+ "width": 2,
127
+ "reason": "why this width, in one sentence",
128
+ "workstreams": [
129
+ { "name": "backend-routes", "task": "what this builder does", "files": ["packages/backend/src/routes"], "tier": "standard" }
130
+ ]
131
+ }
132
+ \`\`\`
133
+
134
+ Rules the parser enforces, so getting them wrong wastes the run:
135
+ - "mode" is "fan" or "single". A single-writer verdict is just {"mode": "single", "reason": "..."} with no width and no workstreams.
136
+ - "width" is 2, 4 or 8, and equals the number of workstreams.
137
+ - "reason" is always required, on both modes.
138
+ - Every workstream needs a name, a task, and at least one file claim.
139
+ - A file claim is a path PREFIX from the repo root: a directory claims its whole subtree. Claim the DIRECTORIES of any files a workstream will create, so a planned new file is not an out-of-claim edit.
140
+ - Claims must be pairwise DISJOINT across workstreams, nesting included: claiming a directory in one workstream and a file inside it in another is an overlap and fails the run.
141
+ - "tier" is "standard" (default) or "efficient".`;
142
+ const SYNTHESIZER_INTRO = `You are a synthesizer. Several builders just worked in parallel in this ONE shared working tree, each pinned to its own files. Their diffs are already in place: you are not applying patches, you are reconciling the seams between them and writing the single handoff the reviewers will read.`;
143
+ const SYNTHESIZER_GROUNDING = `You are grounded. Call ask_yagni before guessing about anything organization- or codebase-specific, and call record_decision for any product-intent call you are forced to make while reconciling.`;
144
+ const SYNTHESIZER_RULES = `What to reconcile:
145
+ 1. Seams between workstreams: imports that do not line up, a shared type each builder assumed differently, duplicated helpers, a call site left on the old signature.
146
+ 2. The checker's findings, which are deterministic and name real files.
147
+ 3. Any out-of-claim edits reported to you, plus anything a builder flagged as needing a change outside its claims.
148
+
149
+ Make the minimal edits that make the union coherent. Do not re-litigate a builder's approach, do not refactor beyond the seams, and do not run git add, git commit, or git stash; committing is the finish stage's job.
150
+
151
+ Output:
152
+ ## Completed
153
+ The change as ONE piece of work, not a list of builders.
154
+ ## Files changed
155
+ - \`path\` - what changed
156
+ ## Seams reconciled
157
+ What did not line up between workstreams, and what you did about it.
158
+ ## Open findings
159
+ Anything the checker or a builder reported that is still not fixed, named honestly so the reviewers can pick it up. Say "none" when there are none.
160
+ ## Notes
161
+ Anything the reviewer should know (exact file paths, key functions touched).`;
162
+ const ORCHESTRATOR_BODY = [ORCHESTRATOR_INTRO, ORCHESTRATOR_GROUNDING, ORCHESTRATOR_RULES].join("\n\n");
163
+ const SYNTHESIZER_BODY = [SYNTHESIZER_INTRO, SYNTHESIZER_GROUNDING, SYNTHESIZER_RULES].join("\n\n");
98
164
  const GUARDIAN_BODY = `You are a safety reviewer. You review shell commands before they run and decide one of three outcomes: allow, ask, or deny. You may read files for context (a script the command runs, a config it touches) but you never write or execute anything. Your final output is ONLY a JSON verdict — no prose around it.
99
165
 
100
166
  Risk taxonomy:
@@ -126,6 +192,8 @@ export const PERSONA_BODIES = {
126
192
  reviewer: REVIEWER_BODY,
127
193
  advisor: ADVISOR_BODY,
128
194
  guardian: GUARDIAN_BODY,
195
+ orchestrator: [ORCHESTRATOR_BODY, PARTITION_CONTRACT].join("\n\n"),
196
+ synthesizer: SYNTHESIZER_BODY,
129
197
  };
130
198
  /**
131
199
  * Grounding-FREE persona bodies for the M6 grounded-vs-blind eval ONLY. These are
@@ -217,6 +285,11 @@ export const BLIND_PERSONA_BODIES = {
217
285
  reviewer: REVIEWER_BLIND,
218
286
  advisor: ADVISOR_BLIND,
219
287
  guardian: GUARDIAN_BODY,
288
+ // The diamond's own agents: same roles with the grounding paragraph dropped.
289
+ // No eval lane fans implement today, but `personaBody` throws on an unknown
290
+ // agent, so a missing twin would be a crash rather than a degraded measurement.
291
+ orchestrator: [ORCHESTRATOR_INTRO, ORCHESTRATOR_RULES, PARTITION_CONTRACT].join("\n\n"),
292
+ synthesizer: [SYNTHESIZER_INTRO, SYNTHESIZER_RULES].join("\n\n"),
220
293
  };
221
294
  /** The lens-specific clause appended to the reviewer body, one per review angle. */
222
295
  const LENS_CLAUSES = {
@@ -249,10 +322,45 @@ const BLIND_LENS_CLAUSES = {
249
322
  business_fit: "Lens: BUSINESS-FIT. Does this change match the apparent product intent and the conventions visible in the code? Right code doing the wrong thing is exactly the failure you exist to catch. A clear mismatch is at least High.",
250
323
  does_it_hold: LENS_CLAUSES.does_it_hold,
251
324
  };
325
+ /**
326
+ * The shared-worktree discipline every fan-out builder runs under. All builders
327
+ * edit the SAME tree concurrently (there are no per-child worktrees), so the
328
+ * rules that keep them out of each other's way are prompt discipline: stay
329
+ * inside your claims, never touch the git index (parallel index writes race on
330
+ * index.lock, and committing is FINISH's job anyway), and leave test execution
331
+ * to the deterministic checker that runs on the merged tree.
332
+ */
333
+ const SHARED_TREE_RULES = `Shared working tree rules:
334
+ - Never run a git index operation: no git add, no git commit, no git stash, no git checkout, no git reset. Other builders may be writing this same tree right now, and committing is the finish stage's job.
335
+ - Do not run the repo's full test suite. A deterministic checker runs typecheck and the repo's own tests on the merged result after the fan; your job is the code.`;
336
+ /**
337
+ * The per-workstream clause appended to a fan-out builder's (or fixer's) persona:
338
+ * the files it owns, what its siblings own, and the shared-tree discipline. With
339
+ * no workstream (the single-writer path, including its fix turns) only the
340
+ * shared-tree rules apply, since there is nothing to pin against.
341
+ */
342
+ export function builderPersonaClause(workstream, siblings = []) {
343
+ if (!workstream)
344
+ return SHARED_TREE_RULES;
345
+ const parts = [
346
+ `You are building ONE workstream of a plan that was split across parallel builders: "${workstream.name}".`,
347
+ ["Files you own (path prefixes; a directory covers its whole subtree):", ...workstream.files.map((f) => `- ${f}`)].join("\n"),
348
+ "Create new files only inside the paths you own. Edit nothing outside them.",
349
+ ];
350
+ if (siblings.length > 0) {
351
+ parts.push([
352
+ "Other builders are working in this same tree right now. Their files, so you know where the seams are (read them if you need to, never edit them):",
353
+ ...siblings.map((s) => `- ${s.name}: ${s.files.join(", ")}`),
354
+ ].join("\n"));
355
+ }
356
+ parts.push("If your workstream genuinely needs a change outside the paths you own, do NOT make it. Name the file and the change in your Notes so the synthesizer reconciles that seam.", SHARED_TREE_RULES);
357
+ return parts.join("\n\n");
358
+ }
252
359
  /**
253
360
  * Resolve the system-prompt body for a stage. For non-review stages this is the
254
361
  * role body; for the review stage it appends the lens-specific clause and the
255
- * required findings output contract so the reviewer's output parses.
362
+ * required findings output contract so the reviewer's output parses. A stage
363
+ * carrying a `personaClause` (the fan-out builders) gets it appended last.
256
364
  *
257
365
  * `grounded` defaults true (every real /go stage). The M6 eval passes
258
366
  * `grounded: false` to select the grounding-free bodies + lens clauses for the
@@ -264,10 +372,13 @@ export function personaBody(stage, opts = {}) {
264
372
  const base = bodies[stage.agent];
265
373
  if (!base)
266
374
  throw new Error(`No persona body for agent "${stage.agent}"`);
267
- if (stage.id !== "review")
268
- return base;
269
- const lens = stage.lens ?? "correctness";
270
- const clause = (grounded ? LENS_CLAUSES : BLIND_LENS_CLAUSES)[lens];
271
- return [base, clause, FINDINGS_CONTRACT].join("\n\n");
375
+ const parts = [base];
376
+ if (stage.id === "review") {
377
+ const lens = stage.lens ?? "correctness";
378
+ parts.push((grounded ? LENS_CLAUSES : BLIND_LENS_CLAUSES)[lens], FINDINGS_CONTRACT);
379
+ }
380
+ if (stage.personaClause?.trim())
381
+ parts.push(stage.personaClause.trim());
382
+ return parts.join("\n\n");
272
383
  }
273
384
  //# sourceMappingURL=personas.js.map
@@ -89,12 +89,56 @@ export interface StartArgs {
89
89
  repo?: string;
90
90
  branch?: string;
91
91
  }
92
- /** A pipeline stage boundary event. */
92
+ /**
93
+ * The implement diamond's partition verdict as the run records it: the fan and
94
+ * its width, or the single-writer degrade, always with the reason that produced
95
+ * it. Rides one implement stage beat (see {@link StageArgs}).
96
+ */
97
+ export interface StageFanoutDecision {
98
+ mode: "fan" | "single";
99
+ width?: number;
100
+ reason: string;
101
+ }
102
+ /**
103
+ * One fan-out child on a stage beat: the workstream's name, the tier it runs on,
104
+ * how many paths it claimed, and the state the run has actually OBSERVED. There
105
+ * is no "pending" state on purpose: a child appears the moment it starts, so a
106
+ * surface can only ever paint children that really ran.
107
+ */
108
+ export interface StageChildRow {
109
+ name: string;
110
+ tier?: "standard" | "efficient";
111
+ state: "running" | "done" | "failed";
112
+ /** How many path claims the partition gave this workstream; absent when unrecorded. */
113
+ files?: number;
114
+ note?: string;
115
+ }
116
+ /** One bounded fix turn: which findings it answered and which children it re-engaged. */
117
+ export interface StageFixTurn {
118
+ turn: number;
119
+ findings: number;
120
+ reengaged: string[];
121
+ }
122
+ /**
123
+ * A pipeline stage boundary event.
124
+ *
125
+ * `fanout` / `children` / `fixTurns` are the implement diamond's additive
126
+ * payloads (spec "Recording and the run surface"). They ride INTERIM implement
127
+ * beats and are append-only: an older CLI simply never sends them, and a backend
128
+ * that does not know them ignores them, so the contract stays backwards
129
+ * compatible in both directions. Every beat carries at most one of them.
130
+ */
93
131
  export interface StageArgs {
94
132
  stage: string;
95
133
  phase: "start" | "finish";
96
134
  round?: number;
97
135
  findings?: number;
136
+ /** The diamond's verdict, sent once per implement stage the moment it resolves. */
137
+ fanout?: StageFanoutDecision;
138
+ /** The whole per-workstream roster, re-sent on every child state change. */
139
+ children?: StageChildRow[];
140
+ /** Every fix turn recorded so far, re-sent as each new one lands. */
141
+ fixTurns?: StageFixTurn[];
98
142
  }
99
143
  /** An in-loop correction captured during the run. */
100
144
  export interface CorrectionArgs {
@@ -23,6 +23,7 @@
23
23
  * the panel's cross-lens interleaving is preserved by code that never moved.
24
24
  */
25
25
  import type { ActivityLine, FeedStageId, JsonEvent, PipelineProgress, ReviewLens, StageTag, StopReason } from "./types.js";
26
+ import type { PartitionWidth } from "./fanout.js";
26
27
  /** The fixed stage order shown by both surfaces (v1 stages + the post-loop FINISH row). */
27
28
  export declare const STAGE_ORDER: FeedStageId[];
28
29
  /**
@@ -48,11 +49,18 @@ export interface AgentUsage {
48
49
  export interface AgentNode {
49
50
  /**
50
51
  * Stable across paints: the stage id, `review:<lens>:r<round>` for a lens
51
- * child, or `fix:r<round>` for a per-round fix execution.
52
+ * child, `implement:<workstream>` for a fan-out builder, or `fix:r<round>` for
53
+ * a per-round fix execution.
52
54
  */
53
55
  id: string;
54
56
  stageId: FeedStageId;
55
57
  lens?: ReviewLens;
58
+ /**
59
+ * Which fan-out child of the implement diamond this node is (the direct
60
+ * analogue of `lens`). A re-engaged builder keeps its node across fix turns, so
61
+ * one workstream is one row for the whole stage.
62
+ */
63
+ workstream?: string;
56
64
  round?: number;
57
65
  status: AgentStatus;
58
66
  summary: string;
@@ -80,6 +88,18 @@ export interface RoundTally {
80
88
  * no pids, no absolute paths — so it stays portable to a non-local executor at
81
89
  * zero cost. The tab does not need them.
82
90
  */
91
+ /** The implement diamond's recorded verdict, as the desktop reads it. */
92
+ export interface FanoutRecord {
93
+ mode: "fan" | "single";
94
+ width?: PartitionWidth;
95
+ reason: string;
96
+ }
97
+ /** One recorded fix turn of the implement diamond. */
98
+ export interface FixTurnRecord {
99
+ turn: number;
100
+ findings: number;
101
+ reengaged: string[];
102
+ }
83
103
  export interface GoRunState {
84
104
  v: 1;
85
105
  runId: string;
@@ -103,16 +123,26 @@ export interface GoRunState {
103
123
  * `UNTRACKED_NOTICE` in goCommand.ts): there is no server id to give it.
104
124
  */
105
125
  serverRunId?: string;
126
+ /**
127
+ * How the implement stage resolved to build (the diamond's partition verdict,
128
+ * or its recorded single-writer degrade). Absent on a run whose implement stage
129
+ * never reported one, so an older run reads exactly as it did before.
130
+ */
131
+ fanout?: FanoutRecord;
132
+ /** The diamond's bounded fix turns, in order. Absent when none ran. */
133
+ fixTurns?: FixTurnRecord[];
106
134
  }
107
135
  /**
108
- * Stable agent id: a lens child is keyed by its lens AND round, and fix — the
109
- * only lens-less stage that re-executes per round — by its round, so each
110
- * round's fix keeps its own clock and usage instead of accumulating tokens on a
111
- * timer that resets (the "181k tok in 59s" artifact). Review stage-level events
112
- * stay on the bare `review` node: they mark the round boundary, and the lens
113
- * children carry the round's real work.
136
+ * Stable agent id: a lens child is keyed by its lens AND round, a fan-out builder
137
+ * by its workstream, and fix — the only lens-less stage that re-executes per
138
+ * round — by its round, so each round's fix keeps its own clock and usage instead
139
+ * of accumulating tokens on a timer that resets (the "181k tok in 59s" artifact).
140
+ * Review stage-level events stay on the bare `review` node: they mark the round
141
+ * boundary, and the lens children carry the round's real work. The implement
142
+ * diamond behaves the same way: the bare `implement` node is the stage boundary,
143
+ * and the workstream children carry the parallel build.
114
144
  */
115
- export declare function agentId(stageId: FeedStageId, lens?: ReviewLens, round?: number): string;
145
+ export declare function agentId(stageId: FeedStageId, lens?: ReviewLens, round?: number, workstream?: string): string;
116
146
  export interface RunStateInit {
117
147
  runId: string;
118
148
  ticket: string;
@@ -130,14 +160,18 @@ export declare class RunState {
130
160
  private findingsBlocking;
131
161
  private readonly rounds;
132
162
  private readonly agents;
163
+ private fanout;
164
+ private readonly fixTurns;
165
+ /** Width the partition asked for; the children themselves are the honest denominator. */
166
+ private fanoutWidth;
133
167
  constructor(init: RunStateInit);
134
168
  /** The lens-less stage agent, or undefined for an unknown stage. */
135
169
  private stageAgent;
136
170
  /**
137
- * The agent for a tag, created on demand. Lens children are inserted directly
138
- * after their parent stage and any existing siblings, so the array order still
139
- * reads top-to-bottom for the desktop and the lens-less filter still yields
140
- * STAGE_ORDER for the terminal.
171
+ * The agent for a tag, created on demand. Lens and workstream children are
172
+ * inserted directly after their parent stage and any existing siblings, so the
173
+ * array order still reads top-to-bottom for the desktop and the child-less
174
+ * filter still yields STAGE_ORDER for the terminal.
141
175
  */
142
176
  private ensureAgent;
143
177
  /** Fold a structured progress signal. `at` is only read by the desktop surface. */
@@ -165,6 +199,17 @@ export declare class RunState {
165
199
  * not a stale bare placeholder.
166
200
  */
167
201
  stageAgents(): AgentNode[];
202
+ /**
203
+ * The implement diamond's compact row summary for the terminal: how the stage
204
+ * resolved to build, how many children it fanned into, and how many of them
205
+ * have finished (a failed child is finished too). Undefined until the stage
206
+ * reports a verdict, so a run that never fanned renders exactly as before.
207
+ */
208
+ fanoutRow(): {
209
+ mode: "fan" | "single";
210
+ total: number;
211
+ done: number;
212
+ } | undefined;
168
213
  header(): {
169
214
  ticket: string;
170
215
  round: number;
@@ -34,16 +34,20 @@ function emptyUsage() {
34
34
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
35
35
  }
36
36
  /**
37
- * Stable agent id: a lens child is keyed by its lens AND round, and fix — the
38
- * only lens-less stage that re-executes per round — by its round, so each
39
- * round's fix keeps its own clock and usage instead of accumulating tokens on a
40
- * timer that resets (the "181k tok in 59s" artifact). Review stage-level events
41
- * stay on the bare `review` node: they mark the round boundary, and the lens
42
- * children carry the round's real work.
37
+ * Stable agent id: a lens child is keyed by its lens AND round, a fan-out builder
38
+ * by its workstream, and fix — the only lens-less stage that re-executes per
39
+ * round — by its round, so each round's fix keeps its own clock and usage instead
40
+ * of accumulating tokens on a timer that resets (the "181k tok in 59s" artifact).
41
+ * Review stage-level events stay on the bare `review` node: they mark the round
42
+ * boundary, and the lens children carry the round's real work. The implement
43
+ * diamond behaves the same way: the bare `implement` node is the stage boundary,
44
+ * and the workstream children carry the parallel build.
43
45
  */
44
- export function agentId(stageId, lens, round) {
46
+ export function agentId(stageId, lens, round, workstream) {
45
47
  if (lens)
46
48
  return `${stageId}:${lens}:r${round ?? 1}`;
49
+ if (workstream)
50
+ return `${stageId}:${workstream}`;
47
51
  if (stageId === "fix" && round !== undefined)
48
52
  return `fix:r${round}`;
49
53
  return stageId;
@@ -60,6 +64,10 @@ export class RunState {
60
64
  findingsBlocking = 0;
61
65
  rounds = [];
62
66
  agents;
67
+ fanout;
68
+ fixTurns = [];
69
+ /** Width the partition asked for; the children themselves are the honest denominator. */
70
+ fanoutWidth;
63
71
  constructor(init) {
64
72
  this.runId = init.runId;
65
73
  this.ticket = init.ticket;
@@ -78,13 +86,14 @@ export class RunState {
78
86
  return this.agents.find((a) => a.id === stageId);
79
87
  }
80
88
  /**
81
- * The agent for a tag, created on demand. Lens children are inserted directly
82
- * after their parent stage and any existing siblings, so the array order still
83
- * reads top-to-bottom for the desktop and the lens-less filter still yields
84
- * STAGE_ORDER for the terminal.
89
+ * The agent for a tag, created on demand. Lens and workstream children are
90
+ * inserted directly after their parent stage and any existing siblings, so the
91
+ * array order still reads top-to-bottom for the desktop and the child-less
92
+ * filter still yields STAGE_ORDER for the terminal.
85
93
  */
86
- ensureAgent(stageId, lens, round, at) {
87
- const id = agentId(stageId, lens, round);
94
+ ensureAgent(tag, at) {
95
+ const { stageId, lens, round, workstream } = tag;
96
+ const id = agentId(stageId, lens, round, workstream);
88
97
  const found = this.agents.find((a) => a.id === id);
89
98
  if (found)
90
99
  return found;
@@ -92,6 +101,7 @@ export class RunState {
92
101
  id,
93
102
  stageId,
94
103
  ...(lens ? { lens } : {}),
104
+ ...(workstream ? { workstream } : {}),
95
105
  ...(round !== undefined ? { round } : {}),
96
106
  status: "active",
97
107
  summary: "",
@@ -122,7 +132,7 @@ export class RunState {
122
132
  // own child; a bare stage signal (including review's round boundary,
123
133
  // which must return to active even though a prior round left it done)
124
134
  // lands on the pre-seeded stage node.
125
- const target = this.ensureAgent(p.stageId, p.lens, p.round, at);
135
+ const target = this.ensureAgent(p, at);
126
136
  target.status = "active";
127
137
  target.startedAt = at;
128
138
  // `delete`, not `= undefined`: an explicitly-undefined property still
@@ -133,7 +143,7 @@ export class RunState {
133
143
  case "stage_done": {
134
144
  if (typeof p.round === "number")
135
145
  this.round = p.round;
136
- const target = this.ensureAgent(p.stageId, p.lens, p.round, at);
146
+ const target = this.ensureAgent(p, at);
137
147
  if (p.degraded === "failed") {
138
148
  target.status = "failed";
139
149
  }
@@ -149,6 +159,19 @@ export class RunState {
149
159
  target.degraded = p.degraded;
150
160
  break;
151
161
  }
162
+ case "fanout": {
163
+ this.fanout = {
164
+ mode: p.mode,
165
+ ...(p.width ? { width: p.width } : {}),
166
+ reason: p.reason,
167
+ };
168
+ this.fanoutWidth = p.width ?? p.workstreams?.length;
169
+ break;
170
+ }
171
+ case "fix_turn": {
172
+ this.fixTurns.push({ turn: p.turn, findings: p.findings, reengaged: [...p.reengaged] });
173
+ break;
174
+ }
152
175
  case "findings": {
153
176
  this.round = p.round;
154
177
  this.findingsTotal = p.total;
@@ -181,7 +204,7 @@ export class RunState {
181
204
  * actions ride BOTH the agent's own list and the terminal's flat ring buffer.
182
205
  */
183
206
  applyLine(line, tag, at = 0) {
184
- const target = this.ensureAgent(tag.stageId, tag.lens, tag.round, at);
207
+ const target = this.ensureAgent(tag, at);
185
208
  if (line.kind === "narration") {
186
209
  target.summary = line.text;
187
210
  return;
@@ -207,7 +230,7 @@ export class RunState {
207
230
  applyUsage(ev, tag, at = 0) {
208
231
  if (ev.type !== "message_end" || ev.message?.role !== "assistant")
209
232
  return;
210
- const target = this.ensureAgent(tag.stageId, tag.lens, tag.round, at);
233
+ const target = this.ensureAgent(tag, at);
211
234
  target.usage.turns += 1;
212
235
  const u = ev.message.usage;
213
236
  if (!u)
@@ -240,11 +263,28 @@ export class RunState {
240
263
  */
241
264
  stageAgents() {
242
265
  return STAGE_ORDER.flatMap((id) => {
243
- const perRound = this.agents.filter((a) => a.stageId === id && a.lens === undefined && a.id !== id);
266
+ const perRound = this.agents.filter((a) => a.stageId === id && a.lens === undefined && a.workstream === undefined && a.id !== id);
244
267
  const row = perRound[perRound.length - 1] ?? this.stageAgent(id);
245
268
  return row ? [row] : [];
246
269
  });
247
270
  }
271
+ /**
272
+ * The implement diamond's compact row summary for the terminal: how the stage
273
+ * resolved to build, how many children it fanned into, and how many of them
274
+ * have finished (a failed child is finished too). Undefined until the stage
275
+ * reports a verdict, so a run that never fanned renders exactly as before.
276
+ */
277
+ fanoutRow() {
278
+ if (!this.fanout)
279
+ return undefined;
280
+ const children = this.agents.filter((a) => a.workstream !== undefined);
281
+ const done = children.filter((a) => a.status === "done" || a.status === "failed").length;
282
+ return {
283
+ mode: this.fanout.mode,
284
+ total: this.fanoutWidth ?? children.length,
285
+ done,
286
+ };
287
+ }
248
288
  header() {
249
289
  return {
250
290
  ticket: this.ticket,
@@ -269,6 +309,8 @@ export class RunState {
269
309
  ...(this.stopReason ? { stopReason: this.stopReason } : {}),
270
310
  agents: this.agents,
271
311
  ...(this.serverRunId !== undefined ? { serverRunId: this.serverRunId } : {}),
312
+ ...(this.fanout ? { fanout: this.fanout } : {}),
313
+ ...(this.fixTurns.length > 0 ? { fixTurns: this.fixTurns } : {}),
272
314
  };
273
315
  }
274
316
  }
@@ -27,6 +27,7 @@ import { trackChild } from "./childRegistry.js";
27
27
  import { finalOutputFrom, foldEvent, newEventAccumulator } from "./events.js";
28
28
  import { buildStageInvocation, groundedChildArgv } from "./invocation.js";
29
29
  import { personaBody } from "./personas.js";
30
+ import { clampTier, resolveTierCap } from "./tierCap.js";
30
31
  import { PER_TASK_OUTPUT_CAP, } from "./types.js";
31
32
  /**
32
33
  * Default per-line stdout ceiling (16 MiB). A legitimate NDJSON event — even one
@@ -85,6 +86,12 @@ export async function runStage(stage, ctx, deps) {
85
86
  const spawnFn = deps.spawn ?? nodeSpawn;
86
87
  const writePrompt = deps.writePrompt ?? defaultWritePrompt;
87
88
  const resolveChild = deps.resolveChild ?? defaultResolveChild;
89
+ // The run's tier ceiling (`YAGNI_GO_TIER_CAP`), resolved ONCE here — the
90
+ // single seam every stage, review lens, format re-ask and subagent child
91
+ // passes through. Absent on a normal run, so tiers stay the pipeline's own.
92
+ const envForCap = deps.env ?? process.env;
93
+ const tierCap = resolveTierCap(envForCap);
94
+ const effectiveTier = clampTier(stage.model, tierCap);
88
95
  const personaStage = ctx.lens ? { ...stage, lens: ctx.lens } : stage;
89
96
  // grounded defaults true; only the M6 blind eval lane passes grounded: false.
90
97
  const body = (deps.personaBody ?? personaBody)(personaStage, { grounded: ctx.grounded !== false });
@@ -92,6 +99,7 @@ export async function runStage(stage, ctx, deps) {
92
99
  const result = {
93
100
  stageId: stage.id,
94
101
  agent: stage.agent,
102
+ tier: effectiveTier,
95
103
  exitCode: 0,
96
104
  finalOutput: "",
97
105
  usage: { ...EMPTY_USAGE },
@@ -105,6 +113,7 @@ export async function runStage(stage, ctx, deps) {
105
113
  previous: ctx.previous,
106
114
  promptFilePath: filePath,
107
115
  lens: ctx.lens,
116
+ ...(tierCap ? { tierCap } : {}),
108
117
  });
109
118
  const { piCli, extensionPath } = resolveChild();
110
119
  const argv = groundedChildArgv(passthrough, { piCli, extensionPath });
@@ -141,7 +150,7 @@ export async function runStage(stage, ctx, deps) {
141
150
  // to the session id alone.
142
151
  const callerLabel = deps.callerLabel ?? (ctx.lens ? `go:${stage.id}:${ctx.lens}` : `go:${stage.id}`);
143
152
  const childEnv = {
144
- ...(deps.env ?? process.env),
153
+ ...envForCap,
145
154
  YAGNI_CALLER: callerLabel,
146
155
  ...(deps.attribution?.runId ? { YAGNI_RUN_ID: deps.attribution.runId } : {}),
147
156
  };
@@ -18,7 +18,15 @@
18
18
  *
19
19
  * `selectStages(lane)` returns the full list for v1 and is the seam where v2
20
20
  * complexity-lanes plug in without a rewrite.
21
+ *
22
+ * The implement stage additionally derives a DIAMOND from this same data:
23
+ * `orchestratorStage` (peak partitioner) → `builderStage` per workstream (the
24
+ * implement def at the workstream's own tier) → `synthesizerStage` (seam
25
+ * reconciler) → `fixerStage` for the bounded fix turns. Every one of them keeps
26
+ * `id: "implement"`, so `StageId` never widens and the feed, the five-stage
27
+ * rail, and the tier cap treat the whole diamond as today's single stage.
21
28
  */
29
+ import type { PartitionWorkstream } from "./fanout.js";
22
30
  import type { PipelineStage, ReviewLens } from "./types.js";
23
31
  /** The three adversarial review angles, fanned out per round (spec §3). */
24
32
  export declare const REVIEW_LENSES: ReviewLens[];
@@ -41,13 +49,82 @@ export declare function reviewStage(): PipelineStage;
41
49
  */
42
50
  export declare function selectStages(_lane?: "full"): PipelineStage[];
43
51
  /**
44
- * The findings-contract re-ask stage (spec §3e): when a HEALTHY lens returns
45
- * non-empty prose with no ```findings fence, the orchestrator re-invokes it
46
- * ONCE on the cheap `efficient` tier with this minimal prompt a pure
47
- * format-recovery pass, not a second review. The lens's original output rides
48
- * in as `{previous}`. Tools are the minimal REAL list `["read"]`:
49
- * `buildStageInvocation` joins `--tools` with a comma, so an empty array would
50
- * emit the broken `--tools ""`.
52
+ * The partitioner's caller label. Deliberately a `go:*` caller and never
53
+ * `subagent:*`: the server clamps `subagent:*` callers to `advanced`, which would
54
+ * silently downgrade the one peak judgment call in the whole implement stage.
55
+ */
56
+ export declare const PARTITION_CALLER_LABEL = "go:implement:partition";
57
+ /**
58
+ * The per-builder caller label, mirroring review's `go:review:<lens>` so
59
+ * `GET /spend?runId=` attributes the fan per workstream for free. Workstream
60
+ * names come from a model, so the segment is sanitized into the proxy's caller
61
+ * charset and the whole label is kept inside its 64-char cap.
62
+ */
63
+ export declare function workstreamCallerLabel(name: string): string;
64
+ /**
65
+ * The repo context handed to the partitioner. BOTH fields are optional by
66
+ * contract: an interactive /go has the map stage's brief, a mission run (which
67
+ * skips map and plan) has the injected scoping memo, and a run may have neither.
68
+ * Thin context is not a problem to paper over — it is exactly the case where the
69
+ * conservative bias says single-writer.
70
+ */
71
+ export interface PartitionContext {
72
+ /** The map stage's compressed repo brief, when the map stage ran. */
73
+ repoBrief?: string;
74
+ /** Mission mode: the cached scoping memo that stands in for the map brief. */
75
+ missionMemo?: string;
76
+ /**
77
+ * `go.fanout = "always"` (benchmark / eval lanes): the run is pinned to the
78
+ * diamond, so the conservative tie-break flips to fanning. It is a bias, not a
79
+ * command — an honestly unsplittable plan still comes back single-writer,
80
+ * because a guessed partition is the one thing this contract refuses.
81
+ */
82
+ pinned?: boolean;
83
+ }
84
+ /**
85
+ * The peak-tier partitioner (spec "Partition contract"): reads the approved plan
86
+ * plus whatever repo context this run actually has and returns a ```partition
87
+ * block. It runs INSIDE the implement boundary (`id: "implement"`), so the feed,
88
+ * the five-stage rail, and the tier cap all keep working unchanged.
89
+ */
90
+ export declare function orchestratorStage(ctx?: PartitionContext): PipelineStage;
91
+ /**
92
+ * One parallel builder: the implement stage re-entered at the workstream's
93
+ * assigned tier, tagged with its workstream, and pinned by a persona clause to
94
+ * the files it claimed (siblings' claims ride along so the seams stay aligned).
95
+ */
96
+ export declare function builderStage(workstream: PartitionWorkstream, siblings?: PartitionWorkstream[]): PipelineStage;
97
+ /**
98
+ * The seam-reconciler (spec decision 5). Not a patch applier: with one shared
99
+ * worktree the union diff is already in place, so this child fixes what does not
100
+ * line up between builders and writes the implement handoff the review loop reads.
101
+ */
102
+ export declare function synthesizerStage(): PipelineStage;
103
+ /**
104
+ * The synthesizer's caller label, in the same `go:implement:*` family as the
105
+ * partitioner and the builders, so `GET /spend?runId=` attributes the reconciler's
106
+ * own spend rather than folding it into the anonymous implement default.
107
+ */
108
+ export declare const SYNTHESIZER_CALLER_LABEL = "go:implement:synthesizer";
109
+ /**
110
+ * The synthesizer re-engaged inside a fix turn (spec decision 7): findings the
111
+ * checker could not attribute to any one workstream's claims are seam work, which
112
+ * is precisely this child's job. It re-writes the implement summary afterwards, so
113
+ * its output remains the handoff the review loop reads.
114
+ */
115
+ export declare function synthesizerFixStage(findings: string): PipelineStage;
116
+ /**
117
+ * A bounded fix turn (spec decision 7): the builder re-entered at its ORIGINAL
118
+ * tier with the verify findings scoped to its claims. `workstream` is absent on
119
+ * the single-writer path, where the one builder is re-engaged the same way.
120
+ */
121
+ export declare function fixerStage(workstream: PartitionWorkstream | undefined, findings: string, siblings?: PartitionWorkstream[]): PipelineStage;
122
+ /**
123
+ * The partition-contract re-ask: the same cheap format-recovery pass the review
124
+ * lenses get (`reaskStage`), pointed at the ```partition block instead of
125
+ * ```findings. One attempt only — a partitioner that still will not emit a
126
+ * readable block degrades to single-writer, never to a guessed partition.
51
127
  */
128
+ export declare function partitionReaskStage(orchestrator: PipelineStage): PipelineStage;
52
129
  export declare function reaskStage(review: PipelineStage): PipelineStage;
53
130
  //# sourceMappingURL=stages.d.ts.map