@yagni-app/code-staging 1.2.3-staging.1570.1 → 1.2.3-staging.1580.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.
@@ -32,13 +32,18 @@ import { type AdvisorLimits, type AdvisorStateHandle } from "./advisor.js";
32
32
  import type { ChildUsageHandle } from "./childUsage.js";
33
33
  import type { WorkingLineHandle } from "./workingLine.js";
34
34
  import { runStage as defaultRunStage } from "./pipeline/runner.js";
35
- import { type PipelineStage } from "./pipeline/types.js";
35
+ import { type PipelineStage, type ResiliencePolicy } from "./pipeline/types.js";
36
36
  import { type RenderTheme, type SubagentTaskProgress } from "./subagentRender.js";
37
37
  /**
38
38
  * Read-only recon plus grounding. Mirrors the `plan` stage's allowlist for the
39
39
  * reason documented there: enough tools to navigate instead of flailing on a
40
40
  * single `read`. No `bash`.
41
41
  */
42
+ /**
43
+ * A consult is one read-only question, so it keeps the 20-minute wall clock
44
+ * the /go stages had before their default grew to an hour.
45
+ */
46
+ export declare const ADVISOR_RESILIENCE_POLICY: ResiliencePolicy;
42
47
  export declare const ADVISOR_TOOLS: string[];
43
48
  /** The consult's tool list under `grounding: false` — grounding read
44
49
  * stripped via the canonical filter (grounding.ts). */
@@ -40,6 +40,11 @@ import { applyChildEvent, finalizeTask, formatWorkingMessage, newTaskProgress, p
40
40
  * reason documented there: enough tools to navigate instead of flailing on a
41
41
  * single `read`. No `bash`.
42
42
  */
43
+ /**
44
+ * A consult is one read-only question, so it keeps the 20-minute wall clock
45
+ * the /go stages had before their default grew to an hour.
46
+ */
47
+ export const ADVISOR_RESILIENCE_POLICY = { ...DEFAULT_RESILIENCE_POLICY, wallTimeoutMs: 20 * 60_000 };
43
48
  export const ADVISOR_TOOLS = ["read", "grep", "find", "ls", "ask_yagni"];
44
49
  /** The consult's tool list under `grounding: false` — grounding read
45
50
  * stripped via the canonical filter (grounding.ts). */
@@ -144,7 +149,7 @@ export function makeAskAdvisorTool(opts) {
144
149
  // driver's tool call until the user presses Esc. The synthetic stage id is
145
150
  // "plan" (read-only tools, no bash), so the wrapper's write-gate never
146
151
  // blocks a retry — re-running a consult cannot double-apply anything.
147
- const runStage = opts.runStage ?? withResilience(defaultRunStage, DEFAULT_RESILIENCE_POLICY);
152
+ const runStage = opts.runStage ?? withResilience(defaultRunStage, ADVISOR_RESILIENCE_POLICY);
148
153
  return {
149
154
  name: "ask_advisor",
150
155
  label: "Ask the advisor",
@@ -166,6 +166,8 @@ export type { MakeRecordContextToolOptions } from "./recordContextTool.js";
166
166
  export { makeRecordDecisionTool } from "./recordDecisionTool.js";
167
167
  export type { MakeRecordDecisionToolOptions } from "./recordDecisionTool.js";
168
168
  export { makeSuggestNextWorkTool, defaultNextAction } from "./nextWorkTool.js";
169
+ export { makePrototypeCreateTool, makePrototypeAttachTool, makePrototypeReadTool, makePrototypeDraftTool, makePrototypeVerificationTool, makePrototypeReviseTool, makePrototypeListTool, makePrototypeShareTool, } from "./prototypeTool.js";
170
+ export type { MakePrototypeToolOptions, PrototypeSummary } from "./prototypeTool.js";
169
171
  export type { MakeNextWorkToolOptions, DefaultNextAction, NextActionOption, } from "./nextWorkTool.js";
170
172
  export { recordDecision } from "./recordDecisionTool.js";
171
173
  export type { RecordDecisionParams } from "./recordDecisionTool.js";
@@ -13,8 +13,10 @@ import { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js
13
13
  import { makeReviewBusinessMatchTool } from "./reviewTool.js";
14
14
  import { registerCmuxBridge } from "./cmux/index.js";
15
15
  import { makeRecordEngineeringContextTool } from "./recordContextTool.js";
16
+ import { makePrototypeVerifyTool } from "./prototypeVerify.js";
16
17
  import { makeRecordDecisionTool } from "./recordDecisionTool.js";
17
18
  import { makeSuggestNextWorkTool } from "./nextWorkTool.js";
19
+ import { makePrototypeCreateTool, makePrototypeAttachTool, makePrototypeReadTool, makePrototypeDraftTool, makePrototypeVerificationTool, makePrototypeReviseTool, makePrototypeListTool, makePrototypeShareTool, } from "./prototypeTool.js";
18
20
  import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_DRIVER_GROUNDING_FREE, YAGNI_IDENTITY_GROUNDING_FREE, YAGNI_IDENTITY_ULTRA, YAGNI_IDENTITY_ULTRA_GROUNDING_FREE } from "./branding.js";
19
21
  import { claudeRulesSection } from "./claudeRules.js";
20
22
  import { ensureScratchpadDir, SCRATCHPAD_TMPDIR_ENV, scratchpadDir as scratchpadDirFor, scratchpadOwnerRootFor, scratchpadSection } from "./scratchpad.js";
@@ -408,6 +410,25 @@ export async function registerYagni(pi, deps = {}) {
408
410
  onRecorded: (id) => recordedThisSession.add(id),
409
411
  }));
410
412
  }
413
+ if (!evalMode) {
414
+ // Prototypes v1: create/revise/list/share runnable single-file HTML
415
+ // Prototypes through the app's REST API. Workspace writes, so gated out of
416
+ // eval mode with the other external-side-effect tools.
417
+ pi.registerTool(makePrototypeReadTool(toolOpts));
418
+ pi.registerTool(makePrototypeDraftTool({ ...toolOpts, getSessionId: () => env.YAGNI_SESSION_ID }));
419
+ pi.registerTool(makePrototypeVerificationTool(toolOpts));
420
+ pi.registerTool(makePrototypeVerifyTool(toolOpts));
421
+ pi.registerTool(makePrototypeCreateTool({ ...toolOpts, getSessionId: () => env.YAGNI_SESSION_ID }));
422
+ pi.registerTool(makePrototypeAttachTool(toolOpts));
423
+ pi.registerTool(makePrototypeReviseTool(toolOpts));
424
+ pi.registerTool(makePrototypeListTool(toolOpts));
425
+ pi.registerTool(makePrototypeShareTool(toolOpts));
426
+ }
427
+ else if (env.YAGNI_MISSION_DIR && env.YAGNI_RUN_ID) {
428
+ // The server authorizes only exact revisions in this Run's approved packet.
429
+ pi.registerTool(makePrototypeReadTool(toolOpts));
430
+ pi.registerTool(makePrototypeVerifyTool(toolOpts));
431
+ }
411
432
  // YAG-574: the silent-turn reminder, driver-only. A child/subagent/advisor
412
433
  // process has no direct user to answer, so it is never nudged (same gating
413
434
  // as the delegation identity in branding.ts); eval mode is untouched so its
@@ -444,6 +465,7 @@ export async function registerYagni(pi, deps = {}) {
444
465
  // map → plan → implement → review → fix, each child grounded by inheritance.
445
466
  registerGoCommand(pi, {
446
467
  childUsage,
468
+ canPrepareDesign: () => !evalMode && modeHolder.get() === "auto",
447
469
  attribution,
448
470
  // The grounding switch threads into every /go run: `grounded: false`
449
471
  // selects the existing blind personas + blind lens clauses (the M6
@@ -2029,6 +2051,8 @@ export { makeReviewBusinessMatchTool } from "./reviewTool.js";
2029
2051
  export { makeRecordEngineeringContextTool } from "./recordContextTool.js";
2030
2052
  export { makeRecordDecisionTool } from "./recordDecisionTool.js";
2031
2053
  export { makeSuggestNextWorkTool, defaultNextAction } from "./nextWorkTool.js";
2054
+ // Prototypes v1: the four prototype tools (create/revise/list/share).
2055
+ export { makePrototypeCreateTool, makePrototypeAttachTool, makePrototypeReadTool, makePrototypeDraftTool, makePrototypeVerificationTool, makePrototypeReviseTool, makePrototypeListTool, makePrototypeShareTool, } from "./prototypeTool.js";
2032
2056
  export { recordDecision } from "./recordDecisionTool.js";
2033
2057
  // Onramp Door B (as amended by ADR-0033): the first-run welcome (write-free,
2034
2058
  // free text first-class) + the explicit /setup-team drafting flow.
@@ -51,9 +51,9 @@ export function createModeHolder(initial = "auto") {
51
51
  };
52
52
  }
53
53
  export const DEFAULT_PERMISSION_POLICY = {
54
- planBlockTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status"],
55
- reviewConfirmTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status"],
56
- alwaysConfirmTools: ["file_ticket", "update_ticket_status"],
54
+ planBlockTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status", "prototype_share", "prototype_create", "prototype_attach", "prototype_revise", "prototype_draft", "prototype_verification", "prototype_verify"],
55
+ reviewConfirmTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status", "prototype_share", "prototype_create", "prototype_attach", "prototype_revise", "prototype_draft", "prototype_verification", "prototype_verify"],
56
+ alwaysConfirmTools: ["file_ticket", "update_ticket_status", "prototype_share"],
57
57
  };
58
58
  export function allowRuleFloorVerdict(toolName, params, mode, policy) {
59
59
  // Floor 0 — plan mode: the mode's contract (no mutations without Guardian
@@ -78,4 +78,36 @@ export declare const DEFAULT_STAGE_ESTIMATE_MS: Record<TimedStageKind, number>;
78
78
  * so the only safe place to stop is before it begins.
79
79
  */
80
80
  export declare function stageWontFit(budget: RunBudget, kind: TimedStageKind, expectedMs: number, nowMs?: number): string | null;
81
+ /**
82
+ * The shortest wall clock any /go stage gets: the old fixed limit. A run close
83
+ * to its deadline keeps this much rather than a stage being cut off sooner than
84
+ * it ever was.
85
+ */
86
+ export declare const STAGE_WALL_FLOOR_MS: number;
87
+ /** Time the verify gate is allowed after the last write, on top of the stage estimates. */
88
+ export declare const VERIFY_ALLOWANCE_MS: number;
89
+ /**
90
+ * What must still run after a stage stops: a review round, one fix, and the
91
+ * verify gate. Held back from the stage's wall clock so a long stage still
92
+ * leaves the run time to review what it wrote.
93
+ */
94
+ export declare const STAGE_WALL_RESERVE_MS: number;
95
+ /**
96
+ * Where a stage's wall clock came from: the build's remaining time, the
97
+ * 20-minute floor because the deadline was close, or the no-deadline default.
98
+ */
99
+ export type StageWallSource = "deadline" | "floor" | "default";
100
+ /**
101
+ * The wall clock for a stage spawning at `nowMs`. With a deadline (sandbox and
102
+ * mission runs) a working stage gets the build's remaining time less
103
+ * {@link STAGE_WALL_RESERVE_MS}, never under {@link STAGE_WALL_FLOOR_MS}: the
104
+ * idle timer is the check for a stuck model, and stopping a live stage at a
105
+ * fixed limit throws away work the run had time for. Without a deadline
106
+ * (interactive /go, where a person can abort) `fallbackMs` applies. The
107
+ * source rides the stage's attempt record.
108
+ */
109
+ export declare function stageWall(budget: RunBudget, fallbackMs: number, nowMs?: number): {
110
+ ms: number;
111
+ source: StageWallSource;
112
+ };
81
113
  //# sourceMappingURL=budget.d.ts.map
@@ -107,4 +107,33 @@ export function stageWontFit(budget, kind, expectedMs, nowMs = Date.now()) {
107
107
  return null;
108
108
  return `not enough time left for a ${kind} stage (${Math.max(0, Math.round(leftMs / 1000))}s left, expected ${Math.round(expectedMs / 1000)}s)`;
109
109
  }
110
+ /**
111
+ * The shortest wall clock any /go stage gets: the old fixed limit. A run close
112
+ * to its deadline keeps this much rather than a stage being cut off sooner than
113
+ * it ever was.
114
+ */
115
+ export const STAGE_WALL_FLOOR_MS = 20 * 60_000;
116
+ /** Time the verify gate is allowed after the last write, on top of the stage estimates. */
117
+ export const VERIFY_ALLOWANCE_MS = 5 * 60_000;
118
+ /**
119
+ * What must still run after a stage stops: a review round, one fix, and the
120
+ * verify gate. Held back from the stage's wall clock so a long stage still
121
+ * leaves the run time to review what it wrote.
122
+ */
123
+ export const STAGE_WALL_RESERVE_MS = DEFAULT_STAGE_ESTIMATE_MS.review + DEFAULT_STAGE_ESTIMATE_MS.fix + VERIFY_ALLOWANCE_MS;
124
+ /**
125
+ * The wall clock for a stage spawning at `nowMs`. With a deadline (sandbox and
126
+ * mission runs) a working stage gets the build's remaining time less
127
+ * {@link STAGE_WALL_RESERVE_MS}, never under {@link STAGE_WALL_FLOOR_MS}: the
128
+ * idle timer is the check for a stuck model, and stopping a live stage at a
129
+ * fixed limit throws away work the run had time for. Without a deadline
130
+ * (interactive /go, where a person can abort) `fallbackMs` applies. The
131
+ * source rides the stage's attempt record.
132
+ */
133
+ export function stageWall(budget, fallbackMs, nowMs = Date.now()) {
134
+ if (budget.deadlineMs === undefined)
135
+ return { ms: fallbackMs, source: "default" };
136
+ const leftMs = budget.deadlineMs - nowMs - STAGE_WALL_RESERVE_MS;
137
+ return leftMs > STAGE_WALL_FLOOR_MS ? { ms: leftMs, source: "deadline" } : { ms: STAGE_WALL_FLOOR_MS, source: "floor" };
138
+ }
110
139
  //# sourceMappingURL=budget.js.map
@@ -60,6 +60,7 @@
60
60
  */
61
61
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
62
62
  import type { SpendResponse } from "../costHud.js";
63
+ import { draftPrototype } from "../prototypeTool.js";
63
64
  import { type CrashReporter } from "../crashReport.js";
64
65
  import { runFinish as defaultRunFinish } from "./finish.js";
65
66
  import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
@@ -144,6 +145,8 @@ export interface RegisterGoDeps {
144
145
  * test) simply means "not ultra", which is today's behavior.
145
146
  */
146
147
  isUltra?: () => boolean;
148
+ canPrepareDesign?: () => boolean;
149
+ draftPrototype?: typeof draftPrototype;
147
150
  /**
148
151
  * The boot-time `grounding` settings switch, threaded into the orchestrator's
149
152
  * `grounded` flag: false runs the whole pipeline on the existing blind
@@ -69,6 +69,7 @@ import { RunState } from "./runState.js";
69
69
  import { aggregateRunUsage } from "./budget.js";
70
70
  import { formatRunCostTable } from "./runCostTable.js";
71
71
  import { makeCombinedCheckpointStore, makeFileCheckpointStore, makePiJournalCheckpointStore, } from "./checkpoint.js";
72
+ import { absoluteUrl, draftPrototype } from "../prototypeTool.js";
72
73
  import { getToken as defaultGetToken, resolveBaseUrl } from "../config.js";
73
74
  import { makeCrashReporter } from "../crashReport.js";
74
75
  import { logEvent } from "../errorSink.js";
@@ -785,6 +786,35 @@ export function registerGoCommand(pi, deps = {}) {
785
786
  let result = await runPipeline(ticket, {
786
787
  cwd: runCwd,
787
788
  signal: runSignal,
789
+ // Interactive /go tracks no backend work item (#2134), so the draft
790
+ // is private to this session and keyed on the run: a resumed run
791
+ // reuses its prototype instead of drafting a second one.
792
+ ...(deps.canPrepareDesign?.() ? {
793
+ prepareDesign: async ({ ticket: designTicket, exploration, signal }) => {
794
+ if (!deps.canPrepareDesign?.())
795
+ return undefined;
796
+ const baseUrl = deps.baseUrl ?? resolveBaseUrl();
797
+ try {
798
+ const draft = await (deps.draftPrototype ?? draftPrototype)({ baseUrl, getToken: deps.getToken ?? defaultGetToken }, {
799
+ title: designTicket.slice(0, 160), spec: `${designTicket}\n\nRepository exploration:\n${exploration}`,
800
+ sessionId: (deps.env ?? process.env).YAGNI_SESSION_ID,
801
+ automatic: true, requestKey: `go:${sessionKey}`,
802
+ }, signal);
803
+ if (draft.reference) {
804
+ notify(`Prototype ready: ${absoluteUrl(baseUrl, draft.prototype?.url ?? draft.reference.url ?? `/prototypes/${draft.reference.prototypeId}`)}`, "info");
805
+ return `## Prototype design reference\n${JSON.stringify(draft.reference)}\nRead this exact revision with prototype_read. Review it with the plan. Implement with the repository's real components. Preserve this reference through implementation and review. Run prototype_verify against the implemented local app with agreed interaction states; it captures mobile/desktop screenshots and records observed checks or explicitly unverified when no browser is available. Visually inspect those screenshots against this design and document intentional departures. Document intentional departures. Never substitute a newer revision or share automatically.`;
806
+ }
807
+ if (draft.status === "failed")
808
+ notify("Prototype drafting failed. Continue without it or retry with prototype_draft before approving the plan.", "warning");
809
+ return undefined;
810
+ }
811
+ catch {
812
+ if (!signal?.aborted)
813
+ notify("Prototype drafting unavailable. Continue without it or retry before plan approval.", "warning");
814
+ return undefined;
815
+ }
816
+ },
817
+ } : {}),
788
818
  fanout: fanoutMode,
789
819
  grounded: goGrounded,
790
820
  ...(isUltra ? { isUltra } : {}),
@@ -26,11 +26,18 @@
26
26
  */
27
27
  import { type RunBudget } from "./budget.js";
28
28
  import { type MissionInputs } from "./mission.js";
29
+ import { type ResilienceAttemptRecord, type RunStageFn } from "./resilience.js";
29
30
  import { runStage as defaultRunStage } from "./runner.js";
30
31
  import { type VerifyOutcome, type WorkstreamCheckResult, type WorkstreamCheckTarget } from "./verify.js";
31
32
  import { snapshotWorkspace as defaultSnapshotWorkspace } from "./workspace.js";
32
- import { type CheckpointStore, type FanoutBudgetVerdict, type FanoutMode, type PipelineStage, type JsonEvent, type PipelineProgress, type PipelineResult, type ResumePlan, type StageId, type StageResult, type StageTag } from "./types.js";
33
+ import { type CheckpointStore, type FanoutBudgetVerdict, type FanoutMode, type PipelineStage, type JsonEvent, type PipelineProgress, type PipelineResult, type ResiliencePolicy, type ResumePlan, type StageId, type StageResult, type StageTag } from "./types.js";
33
34
  export interface RunPipelineDeps {
35
+ /** Bounded external design preparation; never runs inside the read-only planner or approved missions. */
36
+ prepareDesign?: (input: {
37
+ ticket: string;
38
+ exploration: string;
39
+ signal?: AbortSignal;
40
+ }) => Promise<string | undefined>;
34
41
  cwd: string;
35
42
  signal?: AbortSignal;
36
43
  runStage?: typeof defaultRunStage;
@@ -174,5 +181,19 @@ export declare class PipelineStageError extends Error {
174
181
  readonly partial: StageResult[];
175
182
  constructor(stageId: string, message: string, partial: StageResult[]);
176
183
  }
184
+ /**
185
+ * The resilience policy for a run's stages. With a deadline, a stage that
186
+ * spent its wall clock spent the build's time: a retry would only get the
187
+ * floor and run past the deadline, so it fails instead of retrying.
188
+ */
189
+ export declare function stageRunnerPolicy(budget: RunBudget): ResiliencePolicy;
190
+ /**
191
+ * The pipeline's default stage runner: `base` under the R1 resilience wrapper
192
+ * with {@link stageRunnerPolicy}, each stage's wall clock taken from the run's
193
+ * deadline when it spawns (budget.ts#stageWall). A run with no deadline keeps
194
+ * the policy's fixed wall clock. Exported so a test can pin the wall a stage
195
+ * actually gets.
196
+ */
197
+ export declare function resilientStageRunner(base: RunStageFn, budget: RunBudget, clock: () => number, telemetry: (rec: ResilienceAttemptRecord) => void): RunStageFn;
177
198
  export declare function runPipeline(ticket: string, deps: RunPipelineDeps): Promise<PipelineResult>;
178
199
  //# sourceMappingURL=orchestrator.d.ts.map
@@ -24,7 +24,7 @@
24
24
  * (an aborted/crashed child has empty output, which otherwise parses to zero
25
25
  * findings — i.e. a false "reviewed and clean").
26
26
  */
27
- import { addRunUsage, aggregateRunUsage, DEFAULT_RUN_BUDGET, DEFAULT_STAGE_ESTIMATE_MS, EMPTY_RUN_USAGE, exceedsBudget, stageWontFit, } from "./budget.js";
27
+ import { addRunUsage, aggregateRunUsage, DEFAULT_RUN_BUDGET, DEFAULT_STAGE_ESTIMATE_MS, EMPTY_RUN_USAGE, exceedsBudget, stageWall, stageWontFit, } from "./budget.js";
28
28
  import { attributeFindings, checkerFindings, composeFixNote, composeResidue, composeSynthesizerInput, mergeFindings, parseOpenFindings, renderFindings, stripOpenFindings, } from "./checker.js";
29
29
  import { auditClaims, parsePartition } from "./fanout.js";
30
30
  import { extractFindingsBlock, hasBlockingFindings, parseFindings, shouldStop, unionFindings } from "./findings.js";
@@ -188,6 +188,27 @@ async function mapWithConcurrencyLimit(items, concurrency, fn) {
188
188
  await Promise.all(workers);
189
189
  return results;
190
190
  }
191
+ /**
192
+ * The resilience policy for a run's stages. With a deadline, a stage that
193
+ * spent its wall clock spent the build's time: a retry would only get the
194
+ * floor and run past the deadline, so it fails instead of retrying.
195
+ */
196
+ export function stageRunnerPolicy(budget) {
197
+ return budget.deadlineMs === undefined ? DEFAULT_RESILIENCE_POLICY : { ...DEFAULT_RESILIENCE_POLICY, retryWallTimeout: false };
198
+ }
199
+ /**
200
+ * The pipeline's default stage runner: `base` under the R1 resilience wrapper
201
+ * with {@link stageRunnerPolicy}, each stage's wall clock taken from the run's
202
+ * deadline when it spawns (budget.ts#stageWall). A run with no deadline keeps
203
+ * the policy's fixed wall clock. Exported so a test can pin the wall a stage
204
+ * actually gets.
205
+ */
206
+ export function resilientStageRunner(base, budget, clock, telemetry) {
207
+ return withResilience(base, stageRunnerPolicy(budget), {
208
+ telemetry,
209
+ wallFor: () => stageWall(budget, DEFAULT_RESILIENCE_POLICY.wallTimeoutMs, clock()),
210
+ });
211
+ }
191
212
  export async function runPipeline(ticket, deps) {
192
213
  const log = (event, data) => deps.logger?.(event, data);
193
214
  // R1: wrap the real child runner with in-loop resilience (idle/wall timeout +
@@ -195,10 +216,11 @@ export async function runPipeline(ticket, deps) {
195
216
  // across its up-to-8 spawns. A caller that injects its own `runStage` (the unit
196
217
  // tests, a custom lane) bypasses the wrapper untouched. Per-attempt telemetry
197
218
  // rides the existing logger seam, so resume + the checkpoint journal are unaffected.
219
+ // With a deadline (sandbox / mission runs) each stage's wall clock is the
220
+ // time left less a reserve for review, a fix and verify, so a live stage is
221
+ // not killed at a fixed limit the run had room to exceed.
198
222
  const runStageFn = deps.runStage ??
199
- withResilience(defaultRunStage, DEFAULT_RESILIENCE_POLICY, {
200
- telemetry: (rec) => log("resilience_attempt", rec),
201
- });
223
+ resilientStageRunner(defaultRunStage, deps.budget ?? DEFAULT_RUN_BUDGET, deps.now ?? Date.now, (rec) => log("resilience_attempt", rec));
202
224
  const snapshotFn = deps.snapshotWorkspace ?? defaultSnapshotWorkspace;
203
225
  const runVerifyFn = deps.runVerify ?? makeRunVerify();
204
226
  const runWorkstreamCheckFn = deps.runWorkstreamCheck ?? makeWorkstreamCheck();
@@ -313,6 +335,11 @@ export async function runPipeline(ticket, deps) {
313
335
  const rounds = resume ? [...resume.priorRounds] : [];
314
336
  let round = resume ? resume.round : 1;
315
337
  let reviewInput = resume ? resume.reviewInput : "";
338
+ const designMarker = "## Pinned design context";
339
+ const resumedDesignOffset = reviewInput.indexOf(designMarker);
340
+ let designContext = mission?.plan
341
+ ? `${designMarker}\nApproved mission plan (preserve exact prototype references):\n${mission.plan}`
342
+ : resumedDesignOffset >= 0 ? reviewInput.slice(resumedDesignOffset) : undefined;
316
343
  let stopReason = "clean";
317
344
  // P4: honest note carried out when the verify gate could not give a verdict on
318
345
  // the final round (no command / unrunnable), so /go never claims a build it did
@@ -727,6 +754,12 @@ export async function runPipeline(ticket, deps) {
727
754
  baselinePaths = parseChangedPaths(beforeBuild.status);
728
755
  // Sequential build half — stop at the first failure (mirrors the chain).
729
756
  for (const stage of buildStages) {
757
+ if (stage.id === "plan" && !mission && deps.prepareDesign && !deps.signal?.aborted && !overBudget()) {
758
+ const prepared = await deps.prepareDesign({ ticket: promptTicket, exploration: previous ?? "", signal: deps.signal });
759
+ designContext = prepared ? `${designMarker}\n${prepared}` : undefined;
760
+ if (designContext)
761
+ previous = `${previous ?? ""}\n\n${designContext}`;
762
+ }
730
763
  progress({ kind: "stage_start", stageId: stage.id });
731
764
  stageEvent(stage.id, "start");
732
765
  // The implement stage is a DIAMOND (partition → parallel builders →
@@ -817,12 +850,12 @@ export async function runPipeline(ticket, deps) {
817
850
  progress({ kind: "stage_done", stageId: stage.id });
818
851
  // The plan finish carries its finalOutput so /go can record the plan onto the
819
852
  // work item; no other boundary streams the full stage text.
820
- stageEvent(stage.id, "finish", stage.id === "plan" ? { output: r.finalOutput } : undefined);
853
+ stageEvent(stage.id, "finish", stage.id === "plan" ? { output: designContext ? `${r.finalOutput}\n\n${designContext}` : r.finalOutput } : undefined);
821
854
  // The map brief is the partitioner's repo context on an interactive run
822
855
  // (mission runs skip map and hand it the injected memo instead).
823
856
  if (stage.id === "map")
824
857
  mapBrief = r.finalOutput;
825
- previous = r.finalOutput;
858
+ previous = designContext ? `${r.finalOutput}\n\n${designContext}` : r.finalOutput;
826
859
  // R3-b: stop honestly if the build half alone blew the run budget.
827
860
  const over = overBudget();
828
861
  if (over) {
@@ -1042,7 +1075,7 @@ export async function runPipeline(ticket, deps) {
1042
1075
  const fixStartedAt = clock();
1043
1076
  progress({ kind: "stage_start", stageId: "fix", round });
1044
1077
  stageEvent("fix", "start", { round });
1045
- const fixResult = await runStageFn(fix, { ticket: promptTicket, previous: serializeBlocking(findings), grounded }, stageDepsFor({ stageId: "fix", round }));
1078
+ const fixResult = await runStageFn(fix, { ticket: promptTicket, previous: designContext ? `${serializeBlocking(findings)}\n\n${designContext}` : serializeBlocking(findings), grounded }, stageDepsFor({ stageId: "fix", round }));
1046
1079
  observedMs.fix = clock() - fixStartedAt;
1047
1080
  stages.push(fixResult);
1048
1081
  log("stage_done", { stageId: "fix", exitCode: fixResult.exitCode });
@@ -1092,8 +1125,8 @@ export async function runPipeline(ticket, deps) {
1092
1125
  // crash now can resume at the NEXT round against this fix's output. Snapshot
1093
1126
  // the post-fix tree as the resume consistency key.
1094
1127
  const afterFix = await snapshotFn(deps.cwd, deps.signal);
1095
- writeCheckpoint(mkRecord("round_done", { round, reviewInput: fixResult.finalOutput, findings, snapshot: afterFix, usage: currentUsage() }));
1096
- reviewInput = fixResult.finalOutput;
1128
+ reviewInput = designContext ? `${fixResult.finalOutput}\n\n${designContext}` : fixResult.finalOutput;
1129
+ writeCheckpoint(mkRecord("round_done", { round, reviewInput, findings, snapshot: afterFix, usage: currentUsage() }));
1097
1130
  round += 1;
1098
1131
  }
1099
1132
  log("pipeline_stop", { reason: stopReason, round });
@@ -27,6 +27,7 @@
27
27
  * Pure-ish: all impurity (timers, sleep, clock, randomness) sits behind injectable
28
28
  * seams so the wrapper is unit-tested without real waits.
29
29
  */
30
+ import type { StageWallSource } from "./budget.js";
30
31
  import type { RunStageDeps } from "./runner.js";
31
32
  import { type PipelineStage, type ResiliencePolicy, type ReviewLens, type StageResult } from "./types.js";
32
33
  export type { ResiliencePolicy } from "./types.js";
@@ -54,6 +55,13 @@ export interface ResilienceAttemptRecord {
54
55
  /** 1-based attempt number. */
55
56
  attempt: number;
56
57
  outcome: "ok" | "transient" | "fatal" | "timeout" | "aborted";
58
+ /** The wall clock this attempt was given (ms). */
59
+ wallTimeoutMs?: number;
60
+ /**
61
+ * Where that wall clock came from, when the caller said: the build's time
62
+ * left, the floor because the deadline was close, or the no-deadline default.
63
+ */
64
+ wallSource?: StageWallSource;
57
65
  /** Which timer fired, when the attempt timed out. */
58
66
  timeoutKind?: "idle" | "wall";
59
67
  /** On a timeout: ms the attempt spent with a tool running (the rest went to the model). */
@@ -76,6 +84,16 @@ export interface ResilienceSeams {
76
84
  }
77
85
  export interface WithResilienceOpts {
78
86
  telemetry?: (rec: ResilienceAttemptRecord) => void;
87
+ /**
88
+ * The wall clock for one attempt, asked when the attempt spawns so a later
89
+ * stage (or a retry) gets the time actually left. Absent, every attempt gets
90
+ * `policy.wallTimeoutMs`. The idle window always comes from the policy.
91
+ * `source` rides the attempt record as `wallSource`.
92
+ */
93
+ wallFor?: (stage: PipelineStage) => {
94
+ ms: number;
95
+ source?: StageWallSource;
96
+ };
79
97
  seams?: ResilienceSeams;
80
98
  }
81
99
  /**
@@ -163,7 +163,9 @@ export function withResilience(base, policy, opts = {}) {
163
163
  }
164
164
  fireTimeout("idle");
165
165
  };
166
- const wallTimer = setTimeout(() => fireTimeout("wall"), policy.wallTimeoutMs);
166
+ const wall = opts.wallFor?.(stage);
167
+ const wallTimeoutMs = wall?.ms ?? policy.wallTimeoutMs;
168
+ const wallTimer = setTimeout(() => fireTimeout("wall"), wallTimeoutMs);
167
169
  wallTimer.unref?.();
168
170
  armIdle();
169
171
  const originalOnEvent = deps.onEvent;
@@ -207,6 +209,8 @@ export function withResilience(base, policy, opts = {}) {
207
209
  ...(deps.taskIndex !== undefined ? { taskIndex: deps.taskIndex } : {}),
208
210
  attempt,
209
211
  outcome,
212
+ wallTimeoutMs,
213
+ ...(wall?.source ? { wallSource: wall.source } : {}),
210
214
  ...(timedOut && timeoutKind ? { timeoutKind } : {}),
211
215
  ...(timedOut ? { toolMs, modelBound } : {}),
212
216
  exitCode: last.exitCode,
@@ -229,7 +233,7 @@ export function withResilience(base, policy, opts = {}) {
229
233
  const transient = classifyTransient(last, timedOut);
230
234
  // A wall-clock exhaustion is a "task too big" signal, not a stall: a
231
235
  // lane that opts out (subagents) fails honestly instead of re-burning
232
- // the same 20 minutes per attempt. Idle stalls stay retryable.
236
+ // the same wall clock per attempt. Idle stalls stay retryable.
233
237
  const wallNotRetryable = timedOut && timeoutKind === "wall" && policy.retryWallTimeout === false;
234
238
  // No double-apply: a write stage that already started working is never re-run.
235
239
  const blockedByWriteGate = isWriteStage && sawAnyEvent;
@@ -41,9 +41,9 @@ export const REVIEW_LENSES = ["correctness", "business_fit", "does_it_hold"];
41
41
  */
42
42
  export const REQUIRED_LENSES = ["correctness"];
43
43
  /** Read-only recon + grounding tools shared by scout and reviewer. */
44
- const READ_TOOLS = ["read", "grep", "find", "ls", "bash"];
44
+ const READ_TOOLS = ["read", "grep", "find", "ls", "bash", "prototype_read"];
45
45
  /** Full write tools for the worker stages. */
46
- const WRITE_TOOLS = ["read", "write", "edit", "bash", "grep", "find", "ls"];
46
+ const WRITE_TOOLS = ["read", "write", "edit", "bash", "grep", "find", "ls", "prototype_read", "prototype_verification", "prototype_verify"];
47
47
  const TICKET_CONTEXT = "Follow the approved plan and its explicit constraints, including approved answers. " +
48
48
  "Prior summaries and review findings do not authorize expanding scope or publication " +
49
49
  "permissions. Report conflicts requiring a scope change for approval.\n\n" +
@@ -65,7 +65,7 @@ export const PIPELINE_V1 = [
65
65
  // Read-only recon (read/grep/find/ls) so the planner can actually navigate
66
66
  // instead of flailing on a single `read` and ending mid-exploration; NO bash
67
67
  // (it must not build/run), and ask_yagni for grounding.
68
- tools: ["read", "grep", "find", "ls", "ask_yagni"],
68
+ tools: ["read", "grep", "find", "ls", "ask_yagni", "prototype_read"],
69
69
  taskTemplate: "Turn the ticket and the repo map into an ordered, grounded implementation " +
70
70
  "plan. Call ask_yagni before inferring a convention.\n\n" +
71
71
  "Ticket: {ticket}\n\nRepo map:\n{previous}",
@@ -78,7 +78,7 @@ export const PIPELINE_V1 = [
78
78
  taskTemplate: TICKET_CONTEXT +
79
79
  "Implement the plan. Write the code against it, and call record_decision " +
80
80
  "only for a consequential product-intent or architecture call the plan did " +
81
- "not settle (never for taste, naming, or formatting).\n\nPlan:\n{previous}",
81
+ "not settle (never for taste, naming, or formatting). If the plan names a prototype revision, read that exact revision with prototype_read, implement with the repository components, then call prototype_verify against the local app with the agreed interaction states. Inspect its mobile/desktop screenshots against the design and document departures; unavailable browser checks remain unverified.\n\nPlan:\n{previous}",
82
82
  },
83
83
  {
84
84
  id: "review",
@@ -95,7 +95,7 @@ export const PIPELINE_V1 = [
95
95
  tools: [...WRITE_TOOLS, "ask_yagni"],
96
96
  taskTemplate: TICKET_CONTEXT +
97
97
  "Reconcile ONLY the blocking review findings against the plan. Leave " +
98
- "non-blocking findings alone.\n\nBlocking findings:\n{previous}",
98
+ "non-blocking findings alone. When fixing a prototype-backed UI, run prototype_verify again against the pinned revision and inspect the captured screenshots; never substitute a newer design.\n\nBlocking findings:\n{previous}",
99
99
  },
100
100
  ];
101
101
  /** The single review stage the orchestrator fans out across REVIEW_LENSES. */
@@ -527,18 +527,21 @@ export interface ResiliencePolicy {
527
527
  /**
528
528
  * Whether a WALL-clock exhaustion is retryable. Default true (the /go
529
529
  * doctrine: a wedged stage is worth another attempt). A lane whose children
530
- * run read-only, fast work (subagents) sets false: a 20-minute burn means
531
- * the task is too big, and re-running it just multiplies the spend before
530
+ * run read-only, fast work (subagents) sets false: a full wall-clock burn
531
+ * means the task is too big, and re-running it just multiplies the spend before
532
532
  * the same honest fail. Idle-stall timeouts stay retryable either way.
533
533
  */
534
534
  retryWallTimeout?: boolean;
535
535
  }
536
536
  /**
537
537
  * Default resilience policy. Generous timeouts so a legitimately long but live
538
- * stage is never killed (the idle window sits well under the 5-min run heartbeat,
539
- * and the wall window bounds a truly wedged child); three attempts with short,
540
- * jittered backoff so a single transient blip across the up-to-8 child spawns no
541
- * longer terminates the whole run.
538
+ * stage is never killed: the idle window (well under the 5-min run heartbeat) is
539
+ * the check for a stuck model, and the wall window only bounds a runaway that
540
+ * keeps emitting events. The wall is 60 minutes for an interactive /go, where a
541
+ * person can abort; a run with a deadline replaces it per stage with the time
542
+ * left (budget.ts#stageWall). Three attempts with short, jittered
543
+ * backoff so a single transient blip across the up-to-8 child spawns no longer
544
+ * terminates the whole run.
542
545
  */
543
546
  export declare const DEFAULT_RESILIENCE_POLICY: ResiliencePolicy;
544
547
  /**
@@ -17,14 +17,17 @@ export const MAX_REVIEW_ROUNDS = 3;
17
17
  export const MAX_FIX_TURNS = 3;
18
18
  /**
19
19
  * Default resilience policy. Generous timeouts so a legitimately long but live
20
- * stage is never killed (the idle window sits well under the 5-min run heartbeat,
21
- * and the wall window bounds a truly wedged child); three attempts with short,
22
- * jittered backoff so a single transient blip across the up-to-8 child spawns no
23
- * longer terminates the whole run.
20
+ * stage is never killed: the idle window (well under the 5-min run heartbeat) is
21
+ * the check for a stuck model, and the wall window only bounds a runaway that
22
+ * keeps emitting events. The wall is 60 minutes for an interactive /go, where a
23
+ * person can abort; a run with a deadline replaces it per stage with the time
24
+ * left (budget.ts#stageWall). Three attempts with short, jittered
25
+ * backoff so a single transient blip across the up-to-8 child spawns no longer
26
+ * terminates the whole run.
24
27
  */
25
28
  export const DEFAULT_RESILIENCE_POLICY = {
26
29
  idleTimeoutMs: 4 * 60_000,
27
- wallTimeoutMs: 20 * 60_000,
30
+ wallTimeoutMs: 60 * 60_000,
28
31
  maxAttempts: 3,
29
32
  backoffBaseMs: 1_000,
30
33
  backoffMaxMs: 30_000,