@yagni-app/code-staging 1.2.6-staging.1973.1 → 1.2.6-staging.1982.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.
@@ -47,6 +47,7 @@ import { createEscapeTally } from "./sandbox/escapeTally.js";
47
47
  import { createGrantCensus } from "./permission/grantCensus.js";
48
48
  import { runsInSandboxHarness, toolchainSpawnHookFromEnv, toolchainUserBashOperations } from "./toolchain/spawnHook.js";
49
49
  import { CLOUD_SESSION_ENV } from "./pipeline/cloud.js";
50
+ import { registerStageClock } from "./pipeline/stageClock.js";
50
51
  import { registerTelemetry as defaultRegisterTelemetry } from "./telemetry/register.js";
51
52
  import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
52
53
  import { loadPermissionRules } from "./permissionRules/loadConfig.js";
@@ -159,6 +160,10 @@ export async function registerYagni(pi, deps = {}) {
159
160
  const fetchCatalog = deps.fetchCatalog ?? defaultFetchCatalog;
160
161
  const env = deps.env ?? process.env;
161
162
  const evalMode = isEvalMode(env);
163
+ // A timed /go stage child refuses every tool once its time is up. Order does
164
+ // not matter: pi runs every tool_call handler until one blocks, and an allow
165
+ // (a result without `block`) never ends the loop, so this block always lands.
166
+ registerStageClock(pi, env, now);
162
167
  // The grounding settings switch (the judgment-loop kill switch): resolved
163
168
  // ONCE at boot from the USER-tier config (default on; a malformed config
164
169
  // fails soft to on with a sink warn). Eval mode is its own carve-out and
@@ -1074,6 +1074,13 @@ export async function runPipeline(ticket, deps) {
1074
1074
  stages.push(r);
1075
1075
  log("stage_done", { stageId: stage.id, exitCode: r.exitCode, attempt: 2 });
1076
1076
  }
1077
+ // A stage stopped at its own clock (`stageClock.ts`) spent its time
1078
+ // without writing an answer: the run's time budget, not a crash.
1079
+ if (r.timedOut && !deps.signal?.aborted) {
1080
+ log("pipeline_stop", { reason: "budget_exceeded", where: stage.id, detail: "stage_clock" });
1081
+ progress({ kind: "done", stopReason: "budget_exceeded" });
1082
+ return { stages, rounds, stopReason: "budget_exceeded", findings: [], decisionsRecorded };
1083
+ }
1077
1084
  const credit = creditExhaustionReason(r);
1078
1085
  if (isFailed(r)) {
1079
1086
  const why = credit ??
@@ -67,7 +67,7 @@ Keep it concrete; the worker executes it verbatim.`;
67
67
  * `plan-persona.md`. Its output contract rides the stage's task, so a host
68
68
  * persona that replaces this body keeps it.
69
69
  */
70
- const CLOUD_PLANNER_ROLE = `You are a planning specialist. You plan one ticket in the repository checked out in your working directory: it is installed, and its commands run. Your plan is what a builder executes, and your questions are what a person answers before anything is built. You do not change files. A fact you can settle by reading or running code is settled, not asked.`;
70
+ const CLOUD_PLANNER_ROLE = `You are a planning specialist. You plan one ticket in the repository checked out in your working directory, by reading it: you run none of its commands. Your plan is what a builder executes, and your questions are what a person answers before anything is built. You do not change files. A fact you can settle by reading code is settled, not asked.`;
71
71
  const CLOUD_PLANNER_GROUNDING = `You are grounded. Call ask_yagni before inferring a convention or making any organization- or codebase-specific assumption. To check a fact outside this repository (what is deployed, what the data holds, what a ticket or page says), call team_read with the Team's connected tools before you ask a person.`;
72
72
  const CLOUD_PLANNER_FINISH = `Complete the task fully: do not gold-plate, but do not leave it half-done. Finish the job in ONE turn: do not end your turn on an interstitial like "now let me check X". Your FINAL message MUST be the complete plan in the format the task gives. Explore until you can name the files and the steps, then write the plan.`;
73
73
  const CLOUD_PLANNER_BODY = [CLOUD_PLANNER_ROLE, CLOUD_PLANNER_GROUNDING, CLOUD_PLANNER_FINISH].join("\n\n");
@@ -96,6 +96,8 @@ export interface RunStageDeps {
96
96
  personaBody?: (stage: PipelineStage, opts: {
97
97
  grounded?: boolean;
98
98
  }) => string;
99
+ /** The clock a timed stage's deadline is read from; tests inject one. */
100
+ now?: () => number;
99
101
  }
100
102
  /**
101
103
  * Default per-line stdout ceiling (16 MiB). A legitimate NDJSON event — even one
@@ -29,6 +29,7 @@ import { finalOutputFrom, foldEvent, newEventAccumulator } from "./events.js";
29
29
  import { logEvent } from "../errorSink.js";
30
30
  import { buildStageInvocation, groundedChildArgv } from "./invocation.js";
31
31
  import { personaBody } from "./personas.js";
32
+ import { STAGE_WRAP_UP_AT_ENV, STAGE_WRAP_UP_GRACE_MS } from "./stageClock.js";
32
33
  import { clampTier, resolveTierCap } from "./tierCap.js";
33
34
  import { PER_TASK_OUTPUT_CAP, } from "./types.js";
34
35
  /**
@@ -159,10 +160,17 @@ export async function runStage(stage, ctx, deps) {
159
160
  // supplied one — its absence just means this child's completions attribute
160
161
  // to the session id alone.
161
162
  const callerLabel = deps.callerLabel ?? (ctx.lens ? `go:${stage.id}:${ctx.lens}` : `go:${stage.id}`);
163
+ // A timed stage (`stageClock.ts`): the child refuses tools from
164
+ // `wrapUpAt`, and is stopped a grace after it.
165
+ const wrapUpAt = stage.timeBudgetMs !== undefined && stage.timeBudgetMs > 0
166
+ ? (deps.now ?? Date.now)() + stage.timeBudgetMs
167
+ : undefined;
168
+ let timedOut = false;
162
169
  const childEnv = {
163
170
  ...envForCap,
164
171
  YAGNI_CALLER: callerLabel,
165
172
  ...(deps.attribution?.runId ? { YAGNI_RUN_ID: deps.attribution.runId } : {}),
173
+ ...(wrapUpAt !== undefined ? { [STAGE_WRAP_UP_AT_ENV]: String(wrapUpAt) } : {}),
166
174
  };
167
175
  // The child uses this exact Node binary. Forward only CA trust, never
168
176
  // unrelated parent loaders/inspectors or a flag in inherited NODE_OPTIONS.
@@ -221,10 +229,21 @@ export async function runStage(stage, ctx, deps) {
221
229
  }, 5000);
222
230
  killTimer.unref?.();
223
231
  };
232
+ const clock = wrapUpAt !== undefined && stage.timeBudgetMs !== undefined
233
+ ? setTimeout(() => {
234
+ if (closed)
235
+ return;
236
+ timedOut = true;
237
+ onAbort();
238
+ }, stage.timeBudgetMs + STAGE_WRAP_UP_GRACE_MS)
239
+ : undefined;
240
+ clock?.unref?.();
224
241
  const finish = (code) => {
225
242
  closed = true;
226
243
  if (killTimer)
227
244
  clearTimeout(killTimer);
245
+ if (clock)
246
+ clearTimeout(clock);
228
247
  // Detach the abort listener on normal completion so it does not
229
248
  // accumulate on the shared, long-lived pipeline signal across stages.
230
249
  if (signal)
@@ -309,6 +328,8 @@ export async function runStage(stage, ctx, deps) {
309
328
  result.errorMessage = acc.errorMessage;
310
329
  if (wasAborted)
311
330
  result.stopReason = "aborted";
331
+ if (timedOut)
332
+ result.timedOut = true;
312
333
  if (overlongLinesDropped > 0)
313
334
  result.overlongLinesDropped = overlongLinesDropped;
314
335
  if (DEBUG_DIR) {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A stage's own clock (`PipelineStage.timeBudgetMs`). The cloud plan stage
3
+ * once ran 40 minutes and more building the app, starting its server and
4
+ * copying the repository to try route layouts (staging, 2026-10-23), with
5
+ * nothing but the session's 6 hour window to stop it.
6
+ *
7
+ * Two halves, the scoping session's shape (PR #1994): the runner hands the
8
+ * child the moment its time is up (`YAGNI_STAGE_WRAP_UP_AT`, epoch ms on the
9
+ * sandbox's own clock, which parent and child share), and the child's
10
+ * extension refuses every tool call from then on with words that tell the
11
+ * model to write its answer now. A grace later the runner stops the child
12
+ * (`runner.ts`), and the orchestrator ends the run `budget_exceeded`.
13
+ */
14
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ /** The child's env: the epoch millisecond its stage's time is up. */
16
+ export declare const STAGE_WRAP_UP_AT_ENV = "YAGNI_STAGE_WRAP_UP_AT";
17
+ /** The cloud plan stage's time, in minutes. Plans on staging took 8 to 16 minutes. */
18
+ export declare const CLOUD_PLAN_MINUTES = 20;
19
+ /** How long past its time a stage child may take to write its answer before it is stopped. */
20
+ export declare const STAGE_WRAP_UP_GRACE_MS: number;
21
+ /** The refusal a tool call gets once the stage's time is up. */
22
+ export declare const STAGE_TIME_UP_REASON: string;
23
+ /** The epoch millisecond in the env, or undefined for absent or garbage. */
24
+ export declare function stageWrapUpAt(env: NodeJS.ProcessEnv): number | undefined;
25
+ /**
26
+ * Refuse every tool call once the stage's time is up. Registered by every
27
+ * pi process the extension loads in; a process whose env carries no time
28
+ * (every stage but a timed one, and every interactive session) gets nothing.
29
+ */
30
+ export declare function registerStageClock(pi: Pick<ExtensionAPI, "on">, env: NodeJS.ProcessEnv, now?: () => number): void;
31
+ //# sourceMappingURL=stageClock.d.ts.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A stage's own clock (`PipelineStage.timeBudgetMs`). The cloud plan stage
3
+ * once ran 40 minutes and more building the app, starting its server and
4
+ * copying the repository to try route layouts (staging, 2026-10-23), with
5
+ * nothing but the session's 6 hour window to stop it.
6
+ *
7
+ * Two halves, the scoping session's shape (PR #1994): the runner hands the
8
+ * child the moment its time is up (`YAGNI_STAGE_WRAP_UP_AT`, epoch ms on the
9
+ * sandbox's own clock, which parent and child share), and the child's
10
+ * extension refuses every tool call from then on with words that tell the
11
+ * model to write its answer now. A grace later the runner stops the child
12
+ * (`runner.ts`), and the orchestrator ends the run `budget_exceeded`.
13
+ */
14
+ /** The child's env: the epoch millisecond its stage's time is up. */
15
+ export const STAGE_WRAP_UP_AT_ENV = "YAGNI_STAGE_WRAP_UP_AT";
16
+ /** The cloud plan stage's time, in minutes. Plans on staging took 8 to 16 minutes. */
17
+ export const CLOUD_PLAN_MINUTES = 20;
18
+ /** How long past its time a stage child may take to write its answer before it is stopped. */
19
+ export const STAGE_WRAP_UP_GRACE_MS = 10 * 60_000;
20
+ /** The refusal a tool call gets once the stage's time is up. */
21
+ export const STAGE_TIME_UP_REASON = "Your time for this stage is up, so no more tools run. Write your final answer now from what you " +
22
+ "have already read, in the format the task gives. State anything you could not check as an " +
23
+ "assumption the build verifies.";
24
+ /** The epoch millisecond in the env, or undefined for absent or garbage. */
25
+ export function stageWrapUpAt(env) {
26
+ const raw = env[STAGE_WRAP_UP_AT_ENV]?.trim();
27
+ if (!raw || !/^\d+$/.test(raw))
28
+ return undefined;
29
+ const value = Number(raw);
30
+ return Number.isSafeInteger(value) && value > 0 ? value : undefined;
31
+ }
32
+ /**
33
+ * Refuse every tool call once the stage's time is up. Registered by every
34
+ * pi process the extension loads in; a process whose env carries no time
35
+ * (every stage but a timed one, and every interactive session) gets nothing.
36
+ */
37
+ export function registerStageClock(pi, env, now = Date.now) {
38
+ const wrapUpAt = stageWrapUpAt(env);
39
+ if (wrapUpAt === undefined)
40
+ return;
41
+ pi.on("tool_call", async () => now() >= wrapUpAt ? { block: true, reason: STAGE_TIME_UP_REASON } : undefined);
42
+ }
43
+ //# sourceMappingURL=stageClock.js.map
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import { sanitizeCallerSegment } from "../config.js";
30
30
  import { builderPersonaClause } from "./personas.js";
31
+ import { CLOUD_PLAN_MINUTES } from "./stageClock.js";
31
32
  import { MAX_REVIEW_ROUNDS, } from "./types.js";
32
33
  /** The three adversarial review angles, fanned out per round (spec §3). */
33
34
  export const REVIEW_LENSES = ["correctness", "business_fit", "does_it_hold"];
@@ -169,21 +170,34 @@ export const PLAN_JSON_CONTRACT = "End your answer with a fenced ```plan-json bl
169
170
  '- "decisionsCited" lists only decisions the read returned that the plan relies on. Omit what you do not use.';
170
171
  /**
171
172
  * The cloud plan stage's task: the input folder the host wrote, read first,
172
- * then a plan proven on the installed checkout. `inputDir` is the folder the
173
- * ticket file sits in.
173
+ * then a plan grounded in the installed checkout, inside the stage's own
174
+ * clock (`stageClock.ts`). Planning is reading: on staging (2026-10-23) one
175
+ * plan stage built the app and copied the repository for 40 minutes and more,
176
+ * and another started Docker and Supabase until the disk was full and then
177
+ * pruned images to go on. `inputDir` is the folder the ticket file sits in.
174
178
  */
175
179
  function cloudPlanTask(inputDir) {
176
180
  const at = (file) => `${inputDir.replace(/\/+$/, "")}/${file}`;
177
- return ("Plan this ticket on the repository checked out in your working directory. It is installed and its " +
178
- "commands run. Nothing is built until the plan is approved, so change nothing in the repository.\n\n" +
181
+ return ("Plan this ticket on the repository checked out in your working directory. Nothing is built or run " +
182
+ "until the plan is approved, so change nothing in the repository.\n\n" +
183
+ `You have about ${CLOUD_PLAN_MINUTES} minutes. Past them every tool call is refused and you write the plan ` +
184
+ "from what you have already read, so read first and write the plan as soon as you can name the files " +
185
+ "and the steps.\n\n" +
179
186
  "Read first:\n" +
180
187
  `- ${at("ticket.md")}: the ticket and the evidence behind it. Open every image it names with the read tool.\n` +
181
188
  `- ${at("design.md")} and ${at("images")}: the linked design and its frames, when they exist.\n` +
182
189
  `- ${at("memo.md")}: what runs in this repository.\n` +
183
190
  `- ${at("checks.json")}: the repository's checks, and which already fail on the unchanged code.\n` +
184
- `- ${at("playbook.md")}: the Team's planning rules, when it exists. Always follow them.\n` +
185
- `- ${at("plan-line.md")}: this Team's instructions for planning, when it exists.\n\n` +
186
- "Prove facts by running code here (a check, a test, a script) before you state what the code does. " +
191
+ `- ${at("playbook.md")}: the Team's planning rules. Always follow them.\n` +
192
+ `- ${at("plan-line.md")}: this Team's instructions for planning.\n\n` +
193
+ "Planning is reading code, nothing else. Settle a fact by reading files, grepping, listing folders, and " +
194
+ "reading the git history and the config. Run no tests and no type checks, build nothing, start no server, " +
195
+ "no Docker and no database (`supabase start` included), and do not copy the repository: those are the " +
196
+ "build's, and here they spend the time the plan needs and fill the sandbox's disk. When only running " +
197
+ "something would settle a fact, state it as an assumption and put the command that settles it in the " +
198
+ "plan's checks, which the build runs.\n\n" +
199
+ 'If a command fails with "no space left on device", the disk is full: stop there. Do not delete files, ' +
200
+ "prune Docker or retry. Write the plan and say what you could not check.\n\n" +
187
201
  "When the work depends on another repository the ticket's Other repositories section lists, clone it " +
188
202
  "read-only on demand with `git clone --filter=blob:none --depth 1` into ~/context/<repo> and read it there.\n\n" +
189
203
  `Call team_read with tool "${DECISIONS_READ_TOOL}" before you ask a person anything: a question an earlier ` +
@@ -215,6 +229,7 @@ export function cloudLane(inputDir) {
215
229
  model: "peak",
216
230
  tools: ["read", "grep", "find", "ls", "bash", "ask_yagni", "team_read", "prototype_read"],
217
231
  taskTemplate: cloudPlanTask(inputDir),
232
+ timeBudgetMs: CLOUD_PLAN_MINUTES * 60_000,
218
233
  },
219
234
  { ...implementStage(), model: "advanced" },
220
235
  reviewStage(),
@@ -72,6 +72,13 @@ export interface PipelineStage {
72
72
  * plan stage takes the host's `plan-persona.md` (Reeve's persona) here.
73
73
  */
74
74
  persona?: string;
75
+ /**
76
+ * The stage's own clock, in milliseconds (`stageClock.ts`). Past it the
77
+ * child refuses every tool call, so the model writes its answer from what it
78
+ * has; a grace later the runner stops the child and the result says
79
+ * `timedOut`. Absent on every stage but the cloud plan stage.
80
+ */
81
+ timeBudgetMs?: number;
75
82
  }
76
83
  /**
77
84
  * A stage list and the loop rules that go with it. The `full` lane is what
@@ -148,6 +155,8 @@ export interface StageResult {
148
155
  * whole pipeline. Absent/undefined when nothing was dropped (the normal case).
149
156
  */
150
157
  overlongLinesDropped?: number;
158
+ /** Set when the runner stopped the child at its {@link PipelineStage.timeBudgetMs} plus the grace. */
159
+ timedOut?: boolean;
151
160
  }
152
161
  /** One round of the bounded review→fix loop. */
153
162
  export interface ReviewRound {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.2.6-staging.1973.1",
3
+ "version": "1.2.6-staging.1982.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "0cc9459eb25b5a357e700f90b803ff0adf194a0e"
61
+ "yagniSourceSha": "444aad8e6af41a68786645595b40d828477d3bff"
62
62
  }