@yagni-app/code-staging 1.2.11-staging.2122.1 → 1.2.12-staging.2137.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.
@@ -71,6 +71,7 @@
71
71
  * ceilings (`SANDBOX_RUN_BUDGET`), not interactive /go's.
72
72
  */
73
73
  import { measureOnBaseTree } from "./baseTree.js";
74
+ import { runSessionTurn as defaultRunSessionTurn } from "./sessionLane.js";
74
75
  import { snapshotWorkspace } from "./workspace.js";
75
76
  import type { WorktreeExec } from "./worktree.js";
76
77
  import { type TreeSnapshotDeps } from "./treeSnapshot.js";
@@ -79,7 +80,7 @@ import { baseTargets, makeMeasureOnBase, makeRunVerify } from "./verify.js";
79
80
  import { type RunBudget } from "./budget.js";
80
81
  import type { Finding, JsonEvent, ModelTier, PipelineStage, StageId, StageTag, StageUsage, StopReason } from "./types.js";
81
82
  /** One-line usage copy, shared by every argument error. */
82
- export declare const HEADLESS_GO_USAGE = "Usage: yagni go --headless [--cloud] --ticket-file <path> [--plan-file <path>] [--memo-file <path>] [--continue-file <path>] [--run-id <id>] [--cwd <path>] [--json]";
83
+ export declare const HEADLESS_GO_USAGE = "Usage: yagni go --headless [--cloud] [--session] --ticket-file <path> [--plan-file <path>] [--memo-file <path>] [--continue-file <path>] [--run-id <id>] [--cwd <path>] [--json]";
83
84
  /**
84
85
  * Exit codes. `verified` is deliberately narrow: only a `clean` stop means the
85
86
  * run produced a reviewed candidate, so a round_cap / no_changes / failed run
@@ -96,6 +97,11 @@ export interface HeadlessGoArgs {
96
97
  json: boolean;
97
98
  /** The one-attempt lane's session (cloud.ts): its own stage list and outputs. */
98
99
  cloud: boolean;
100
+ /**
101
+ * The session lane (`sessionLane.ts`, with `--cloud`): one turn of the run's
102
+ * one session in place of the stage graph, with the same outputs.
103
+ */
104
+ session: boolean;
99
105
  ticketFile?: string;
100
106
  planFile?: string;
101
107
  memoFile?: string;
@@ -210,6 +216,8 @@ export interface HeadlessGoDeps {
210
216
  exec?: WorktreeExec;
211
217
  /** A cloud session's tree reads (HEAD and porcelain status). Defaults to a real `git`. */
212
218
  snapshotWorkspace?: typeof snapshotWorkspace;
219
+ /** The session lane's turn (`--session`). Defaults to the real one; injected in tests. */
220
+ runSessionTurn?: typeof defaultRunSessionTurn;
213
221
  }
214
222
  /**
215
223
  * Parse the argv remainder after `go`. Accepts both `--flag value` and
@@ -75,6 +75,7 @@ import { dirname, join, relative } from "node:path";
75
75
  import { baseCommitOf, BaseTreeRestoreError, measureOnBaseTree, pathsCommittedSince, } from "./baseTree.js";
76
76
  import { CLOUD_SESSION_ENV, completeCloudSession, prepareCloudLane, readCloudEnv, RESULT_FILE_NAME, unbuiltCloudResult, } from "./cloud.js";
77
77
  import { scanChange } from "./changeScan.js";
78
+ import { runSessionTurn as defaultRunSessionTurn } from "./sessionLane.js";
78
79
  import { continuationResumePlan, parseContinuation, } from "./continuation.js";
79
80
  import { finishExec } from "./finish.js";
80
81
  import { snapshotWorkspace } from "./workspace.js";
@@ -93,7 +94,7 @@ import { codeStateHome } from "../stateHome.js";
93
94
  import { parseTierCap, TIER_CAP_ENV } from "./tierCap.js";
94
95
  import { parsePipelineTimeLeft, PIPELINE_TIME_LEFT_ENV, SANDBOX_RUN_BUDGET, } from "./budget.js";
95
96
  /** One-line usage copy, shared by every argument error. */
96
- export const HEADLESS_GO_USAGE = "Usage: yagni go --headless [--cloud] --ticket-file <path> [--plan-file <path>] [--memo-file <path>] [--continue-file <path>] [--run-id <id>] [--cwd <path>] [--json]";
97
+ export const HEADLESS_GO_USAGE = "Usage: yagni go --headless [--cloud] [--session] --ticket-file <path> [--plan-file <path>] [--memo-file <path>] [--continue-file <path>] [--run-id <id>] [--cwd <path>] [--json]";
97
98
  /** With a deadline, the final verify of a cloud session needs at least this much of it left. */
98
99
  const FINAL_VERIFY_MIN_LEFT_MS = 5 * 60_000;
99
100
  /**
@@ -125,6 +126,7 @@ export function parseHeadlessGoArgs(argv) {
125
126
  headless: false,
126
127
  json: false,
127
128
  cloud: false,
129
+ session: false,
128
130
  unknownFlags: [],
129
131
  positionals: [],
130
132
  missingValues: [],
@@ -148,6 +150,10 @@ export function parseHeadlessGoArgs(argv) {
148
150
  args.cloud = true;
149
151
  continue;
150
152
  }
153
+ if (name === "--session") {
154
+ args.session = true;
155
+ continue;
156
+ }
151
157
  const key = VALUE_FLAGS[name];
152
158
  if (key) {
153
159
  const next = inlineValue ?? argv[i + 1];
@@ -183,6 +189,9 @@ export function validateHeadlessGoArgs(args) {
183
189
  return "yagni go is headless-only: pass --headless (use /go inside a session for the interactive run).";
184
190
  if (!args.ticketFile)
185
191
  return "Missing --ticket-file.";
192
+ if (args.session && !args.cloud) {
193
+ return "--session runs the one-attempt lane's session: pass it with --cloud.";
194
+ }
186
195
  return undefined;
187
196
  }
188
197
  const isBlocking = isBlockingFinding;
@@ -387,6 +396,33 @@ export async function runHeadlessGo(argv, deps = {}) {
387
396
  cloud = { ...cloudEnv, stage: prepared.stage };
388
397
  lane = prepared.lane;
389
398
  }
399
+ // The session lane: one turn of the run's one session, in place of the
400
+ // stage graph below, with the same outputs (sessionLane.ts).
401
+ if (cloud && args.session) {
402
+ const turn = await (deps.runSessionTurn ?? defaultRunSessionTurn)({
403
+ cwd,
404
+ inputDir: dirname(args.ticketFile ?? ""),
405
+ identity: cloud.identity,
406
+ outDir: cloud.outDir,
407
+ stage: cloud.stage,
408
+ ...(continuation ? { continuation } : {}),
409
+ ...(mission?.plan ? { plan: mission.plan } : {}),
410
+ childEnv,
411
+ ...(deps.extraArgs?.length ? { extraArgs: deps.extraArgs } : {}),
412
+ ...(budget?.deadlineMs !== undefined ? { deadlineMs: budget.deadlineMs } : {}),
413
+ ...(args.runId ? { runId: args.runId } : {}),
414
+ emit: stream ? emit : () => undefined,
415
+ writeErr,
416
+ ...(deps.signal ? { signal: deps.signal } : {}),
417
+ }, {
418
+ ...(deps.exec ? { exec: deps.exec } : {}),
419
+ ...(deps.snapshotWorkspace ? { snapshot: deps.snapshotWorkspace } : {}),
420
+ ...(deps.writeFile ? { writeFile: deps.writeFile } : {}),
421
+ readFile,
422
+ ...(deps.now ? { now: deps.now } : {}),
423
+ });
424
+ return { exitCode: turn.ok ? HEADLESS_GO_EXIT.verified : HEADLESS_GO_EXIT.unverified };
425
+ }
390
426
  const cloudStage = cloud?.stage;
391
427
  // The implement diamond's recording thread, per RUN. The beats ride the
392
428
  // NDJSON stream as `pipeline_fanout` records, and the mission collector
@@ -60,6 +60,15 @@ export declare function buildStageInvocation(stage: PipelineStage, ctx: {
60
60
  lens?: ReviewLens;
61
61
  tierCap?: ModelTier;
62
62
  }): string[];
63
+ /**
64
+ * A stage passthrough moved onto a session file: `--no-session` becomes
65
+ * `--session <file>`, which pi creates when it is missing and resumes when it
66
+ * is not. The tools, the model and the system prompt stay this invocation's
67
+ * own (pi reads them from the command line on every launch, never from the
68
+ * file), so a resumed turn can carry more tools than the turn before it. The
69
+ * session lane's one session (`sessionLane.ts`).
70
+ */
71
+ export declare function onSessionFile(passthrough: string[], sessionFile: string): string[];
63
72
  /**
64
73
  * Prepend the grounding args (the pi cli script, our extension, and the default
65
74
  * `yagni` provider) before a stage passthrough. Mirrors buildLaunch's
@@ -39,7 +39,7 @@ import { clampTier } from "./tierCap.js";
39
39
  * with a `- ` bullet line, no emojis.
40
40
  */
41
41
  export const CHILD_HONESTY_PREAMBLE = "Never fabricate file paths, file contents, function signatures, enum values, or code. " +
42
- "If you cannot find something, say so explicitly — an honest \"not found\" is more valuable " +
42
+ 'If you cannot find something, say so explicitly — an honest "not found" is more valuable ' +
43
43
  "than a plausible-sounding invention. Every file path you cite must be a path you actually " +
44
44
  "read with a tool. If you are uncertain whether something exists, say you are uncertain " +
45
45
  "rather than presenting a guess as a finding.";
@@ -77,6 +77,20 @@ export function buildStageInvocation(stage, ctx) {
77
77
  `Task: ${task}`,
78
78
  ];
79
79
  }
80
+ /**
81
+ * A stage passthrough moved onto a session file: `--no-session` becomes
82
+ * `--session <file>`, which pi creates when it is missing and resumes when it
83
+ * is not. The tools, the model and the system prompt stay this invocation's
84
+ * own (pi reads them from the command line on every launch, never from the
85
+ * file), so a resumed turn can carry more tools than the turn before it. The
86
+ * session lane's one session (`sessionLane.ts`).
87
+ */
88
+ export function onSessionFile(passthrough, sessionFile) {
89
+ const at = passthrough.indexOf("--no-session");
90
+ if (at < 0)
91
+ return [...passthrough.slice(0, -1), "--session", sessionFile, ...passthrough.slice(-1)];
92
+ return [...passthrough.slice(0, at), "--session", sessionFile, ...passthrough.slice(at + 1)];
93
+ }
80
94
  /**
81
95
  * Prepend the grounding args (the pi cli script, our extension, and the default
82
96
  * `yagni` provider) before a stage passthrough. Mirrors buildLaunch's
@@ -26,6 +26,17 @@
26
26
  */
27
27
  import type { PartitionWorkstream } from "./fanout.js";
28
28
  import type { PipelineStage } from "./types.js";
29
+ /**
30
+ * The session lane's one skill (`sessionLane.ts`): how YAGNI works a ticket
31
+ * in one session, from reading it to a committed, reported change. A
32
+ * playbook, not a contract: each turn's message says what the host needs
33
+ * back, and this says how to get there. Installed under the run user's home
34
+ * as `~/.yagni/skills/work-a-ticket/SKILL.md` and handed to every turn as its
35
+ * system prompt, so the model reads the same words at the plan, the build and
36
+ * each fix. Its words never name the harness (the branding scrub rewrites
37
+ * that word) and open with no bullet.
38
+ */
39
+ export declare const WORK_A_TICKET_SKILL = "---\nname: work-a-ticket\ndescription: How YAGNI works a ticket in one session, from reading it to a committed, honestly reported change. Read, run and plan with options and a recommendation, stop for approval, build the smallest change with subagents, verify with a fresh one, commit and report.\n---\n\n# How YAGNI works a ticket\n\nYou work one ticket for a Team, in its repository, checked out in your working directory on a machine that is yours for the whole run. A person approves your plan before anything is built. YAGNI pushes your commits, opens the pull request, waits for CI and the review, and brings you back with what they asked. Each time YAGNI resumes you, its message says which turn this is and what it needs back. This is a playbook, not a contract: use your judgement, and say what you did.\n\n## 1. Understand the ticket\n\nRead the ticket, its evidence and every image it names, the design, the memo, the checks, the Team's playbook and the Plan line's instructions. Read the code the ticket touches, and send searcher subagents for wide reads, in parallel. Settle facts by running things rather than guessing: the tests, a type check, the app, Docker, a query. Installing what a check needs is fine. Read the decision ledger (team_read with yagni_decisions_read_for_repository) and ask_yagni before you ask a person anything.\n\n## 2. Plan, then stop\n\nName the files and the steps. Where there is a real choice of approach, give the options, each with what it changes and what it costs, and recommend one. End the turn with the plan in the format the message gives, and stop. You have no edit or write tool until the plan is approved, and anything you change in tracked files before then is put back.\n\n## 3. Build\n\nThe option the person approved is binding. The plan's steps are guidance: when the code shows a better step, take it, and record the departure and why. Prefer the smallest change that meets the ticket: no drive-by refactors and no files the ticket does not need. Hand fully specified chunks of work (the files, the change, the check that proves it) to the implementer subagent, and keep the judgement calls yourself. Run the checks that prove the change. A check that already failed on the unchanged code is not yours to fix; say so.\n\n## 4. Verify\n\nBefore you finish, dispatch a fresh verification subagent on your diff, with the ticket and the plan, and fix what it finds that is real. For a large change, send two or three with different lenses and treat their agreement as confirmation.\n\n## 5. Commit and report\n\nCommit on the branch you are on, with clear messages. Never push, never open or edit a pull request, and never change the git identity or config. Write the report the message asks for last, and honestly: each check you ran with its real exit code, what you could not verify and why, and every departure from the plan and why. A check you did not run is not a pass.\n\n## Rules that always hold\n\nNever fabricate a path, a result or a check: \"not found\" and \"not verified\" are answers. Never print, commit or send a token or a variable's value. If the disk fills (\"no space left on device\"), stop and say so; do not delete or prune to go on. Call record_decision only for a consequential product or architecture call the plan did not settle.\n";
29
40
  /** Persona body keyed by the agent name referenced in `stages.ts`. */
30
41
  export declare const PERSONA_BODIES: Record<string, string>;
31
42
  /**
@@ -70,6 +70,49 @@ Keep it concrete; the worker executes it verbatim.`;
70
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
+ /**
74
+ * The session lane's one skill (`sessionLane.ts`): how YAGNI works a ticket
75
+ * in one session, from reading it to a committed, reported change. A
76
+ * playbook, not a contract: each turn's message says what the host needs
77
+ * back, and this says how to get there. Installed under the run user's home
78
+ * as `~/.yagni/skills/work-a-ticket/SKILL.md` and handed to every turn as its
79
+ * system prompt, so the model reads the same words at the plan, the build and
80
+ * each fix. Its words never name the harness (the branding scrub rewrites
81
+ * that word) and open with no bullet.
82
+ */
83
+ export const WORK_A_TICKET_SKILL = `---
84
+ name: work-a-ticket
85
+ description: How YAGNI works a ticket in one session, from reading it to a committed, honestly reported change. Read, run and plan with options and a recommendation, stop for approval, build the smallest change with subagents, verify with a fresh one, commit and report.
86
+ ---
87
+
88
+ # How YAGNI works a ticket
89
+
90
+ You work one ticket for a Team, in its repository, checked out in your working directory on a machine that is yours for the whole run. A person approves your plan before anything is built. YAGNI pushes your commits, opens the pull request, waits for CI and the review, and brings you back with what they asked. Each time YAGNI resumes you, its message says which turn this is and what it needs back. This is a playbook, not a contract: use your judgement, and say what you did.
91
+
92
+ ## 1. Understand the ticket
93
+
94
+ Read the ticket, its evidence and every image it names, the design, the memo, the checks, the Team's playbook and the Plan line's instructions. Read the code the ticket touches, and send searcher subagents for wide reads, in parallel. Settle facts by running things rather than guessing: the tests, a type check, the app, Docker, a query. Installing what a check needs is fine. Read the decision ledger (team_read with yagni_decisions_read_for_repository) and ask_yagni before you ask a person anything.
95
+
96
+ ## 2. Plan, then stop
97
+
98
+ Name the files and the steps. Where there is a real choice of approach, give the options, each with what it changes and what it costs, and recommend one. End the turn with the plan in the format the message gives, and stop. You have no edit or write tool until the plan is approved, and anything you change in tracked files before then is put back.
99
+
100
+ ## 3. Build
101
+
102
+ The option the person approved is binding. The plan's steps are guidance: when the code shows a better step, take it, and record the departure and why. Prefer the smallest change that meets the ticket: no drive-by refactors and no files the ticket does not need. Hand fully specified chunks of work (the files, the change, the check that proves it) to the implementer subagent, and keep the judgement calls yourself. Run the checks that prove the change. A check that already failed on the unchanged code is not yours to fix; say so.
103
+
104
+ ## 4. Verify
105
+
106
+ Before you finish, dispatch a fresh verification subagent on your diff, with the ticket and the plan, and fix what it finds that is real. For a large change, send two or three with different lenses and treat their agreement as confirmation.
107
+
108
+ ## 5. Commit and report
109
+
110
+ Commit on the branch you are on, with clear messages. Never push, never open or edit a pull request, and never change the git identity or config. Write the report the message asks for last, and honestly: each check you ran with its real exit code, what you could not verify and why, and every departure from the plan and why. A check you did not run is not a pass.
111
+
112
+ ## Rules that always hold
113
+
114
+ Never fabricate a path, a result or a check: "not found" and "not verified" are answers. Never print, commit or send a token or a variable's value. If the disk fills ("no space left on device"), stop and say so; do not delete or prune to go on. Call record_decision only for a consequential product or architecture call the plan did not settle.
115
+ `;
73
116
  const CLOUD_PLANNER_BODY = [CLOUD_PLANNER_ROLE, CLOUD_PLANNER_GROUNDING, CLOUD_PLANNER_FINISH].join("\n\n");
74
117
  const WORKER_BODY = `You are a worker with full capabilities, operating in an isolated context to implement a plan. Work autonomously and use the tools as needed.
75
118
 
@@ -244,6 +287,7 @@ export const PERSONA_BODIES = {
244
287
  scout: SCOUT_BODY,
245
288
  planner: PLANNER_BODY,
246
289
  cloud_planner: CLOUD_PLANNER_BODY,
290
+ session: WORK_A_TICKET_SKILL,
247
291
  worker: WORKER_BODY,
248
292
  reviewer: REVIEWER_BODY,
249
293
  advisor: ADVISOR_BODY,
@@ -98,6 +98,13 @@ export interface RunStageDeps {
98
98
  }) => string;
99
99
  /** The clock a timed stage's deadline is read from; tests inject one. */
100
100
  now?: () => number;
101
+ /**
102
+ * The session lane's one pi session (`sessionLane.ts`): the child runs on
103
+ * this session file (`--session`, created when missing, resumed when not)
104
+ * instead of `--no-session`, so each turn continues the same conversation.
105
+ * Absent on every /go stage and subagent child.
106
+ */
107
+ sessionFile?: string;
101
108
  }
102
109
  /**
103
110
  * Default per-line stdout ceiling (16 MiB). A legitimate NDJSON event — even one
@@ -27,7 +27,7 @@ import { sessionExecArgv } from "../systemCa.js";
27
27
  import { trackChild } from "./childRegistry.js";
28
28
  import { finalOutputFrom, foldEvent, newEventAccumulator } from "./events.js";
29
29
  import { logEvent } from "../errorSink.js";
30
- import { buildStageInvocation, groundedChildArgv } from "./invocation.js";
30
+ import { buildStageInvocation, groundedChildArgv, onSessionFile } from "./invocation.js";
31
31
  import { personaBody } from "./personas.js";
32
32
  import { STAGE_WRAP_UP_AT_ENV, STAGE_WRAP_UP_GRACE_MS } from "./stageClock.js";
33
33
  import { clampTier, resolveTierCap } from "./tierCap.js";
@@ -73,7 +73,14 @@ function defaultResolveChild() {
73
73
  const extensionPath = fileURLToPath(new URL("../index.js", import.meta.url));
74
74
  return { piCli, extensionPath };
75
75
  }
76
- const EMPTY_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
76
+ const EMPTY_USAGE = {
77
+ input: 0,
78
+ output: 0,
79
+ cacheRead: 0,
80
+ cacheWrite: 0,
81
+ cost: 0,
82
+ turns: 0,
83
+ };
77
84
  /** Truncate to PER_TASK_OUTPUT_CAP bytes, never exceeding the cap. */
78
85
  function capOutput(output) {
79
86
  if (Buffer.byteLength(output, "utf8") <= PER_TASK_OUTPUT_CAP)
@@ -101,7 +108,9 @@ export async function runStage(stage, ctx, deps) {
101
108
  const effectiveTier = clampTier(stage.model, tierCap);
102
109
  const personaStage = ctx.lens ? { ...stage, lens: ctx.lens } : stage;
103
110
  // grounded defaults true; only the M6 blind eval lane passes grounded: false.
104
- const body = (deps.personaBody ?? personaBody)(personaStage, { grounded: ctx.grounded !== false });
111
+ const body = (deps.personaBody ?? personaBody)(personaStage, {
112
+ grounded: ctx.grounded !== false,
113
+ });
105
114
  const { filePath, cleanup } = await writePrompt(body);
106
115
  const result = {
107
116
  stageId: stage.id,
@@ -115,13 +124,14 @@ export async function runStage(stage, ctx, deps) {
115
124
  toolErrors: 0,
116
125
  };
117
126
  try {
118
- const passthrough = buildStageInvocation(stage, {
127
+ const invocation = buildStageInvocation(stage, {
119
128
  ticket: ctx.ticket,
120
129
  previous: ctx.previous,
121
130
  promptFilePath: filePath,
122
131
  lens: ctx.lens,
123
132
  ...(tierCap ? { tierCap } : {}),
124
133
  });
134
+ const passthrough = deps.sessionFile ? onSessionFile(invocation, deps.sessionFile) : invocation;
125
135
  const { piCli, extensionPath } = resolveChild();
126
136
  const argv = groundedChildArgv(passthrough, {
127
137
  piCli,
@@ -151,7 +161,9 @@ export async function runStage(stage, ctx, deps) {
151
161
  const m = process.memoryUsage();
152
162
  fs.appendFileSync(path.join(DEBUG_DIR, `${dbgLabel}.stream.log`), `${new Date().toISOString()} ${note} bytes=${dbgTotalBytes} maxLineBytes=${dbgMaxLineBytes} lines=${dbgLineCount} events=${eventCount} heapUsedMB=${Math.round(m.heapUsed / 1048576)} rssMB=${Math.round(m.rss / 1048576)}\n`);
153
163
  }
154
- catch { /* ignore */ }
164
+ catch {
165
+ /* ignore */
166
+ }
155
167
  };
156
168
  // YAG-471 attribution: every child builds its OWN provider config at init
157
169
  // (reading its own env), so a per-child env var becomes a per-child header.
@@ -337,10 +349,15 @@ export async function runStage(stage, ctx, deps) {
337
349
  fs.mkdirSync(DEBUG_DIR, { recursive: true });
338
350
  const finalOut = finalOutputFrom(acc);
339
351
  fs.writeFileSync(path.join(DEBUG_DIR, `${dbgLabel}.summary.json`), JSON.stringify({
340
- stageId: stage.id, lens: ctx.lens, exitCode,
341
- stopReason: result.stopReason, usage: result.usage,
342
- events_count: eventCount, total_stream_bytes: dbgTotalBytes,
343
- max_line_bytes: dbgMaxLineBytes, line_count: dbgLineCount,
352
+ stageId: stage.id,
353
+ lens: ctx.lens,
354
+ exitCode,
355
+ stopReason: result.stopReason,
356
+ usage: result.usage,
357
+ events_count: eventCount,
358
+ total_stream_bytes: dbgTotalBytes,
359
+ max_line_bytes: dbgMaxLineBytes,
360
+ line_count: dbgLineCount,
344
361
  final_output_bytes: Buffer.byteLength(finalOut, "utf8"),
345
362
  final_output: finalOut,
346
363
  }, null, 2));
@@ -354,7 +371,9 @@ export async function runStage(stage, ctx, deps) {
354
371
  });
355
372
  }
356
373
  }
357
- catch { /* ignore */ }
374
+ catch {
375
+ /* ignore */
376
+ }
358
377
  }
359
378
  return result;
360
379
  }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The session lane (`yagni go --headless --cloud --session`, 2026-10-28): a
3
+ * ticket runs as ONE pi session, on the strongest tier, in one Machine the
4
+ * host keeps for the whole run, in place of the cloud lane's stage graph.
5
+ * The model works the ticket itself, with the `subagent` tool for what it
6
+ * hands off, and the host meets it at the same checkpoints the cloud lane
7
+ * has, by the same files.
8
+ *
9
+ * Each host execution is one TURN of that session: this process runs one pi
10
+ * child on the session file the run keeps under the run user's home
11
+ * (`~/.yagni/session-lane/<attempt>/session.jsonl`), with one message that
12
+ * says what the turn is, and ends when the model ends its turn. A file that
13
+ * exists is resumed (`--session <file>`: pi restores the conversation and
14
+ * takes the tools this turn names), so the plan, the build and each fix are
15
+ * one conversation; a Machine the host had to replace has none, and the turn
16
+ * starts a fresh session whose message points at everything on disk.
17
+ *
18
+ * - Plan turn: read, search, run anything (tests, Docker, the app), no edit
19
+ * or write tool. Its last message is the plan with the plan-json block,
20
+ * asked for once more when the block is missing; this process writes
21
+ * `plan.md`, `plan.json` and `result.json` as the cloud lane's plan stage
22
+ * does (`completeCloudSession`). Anything the turn changed in tracked
23
+ * files, or committed, is put back before the plan's gate, so nothing is
24
+ * written before a person approves (the host also checks the checkout out
25
+ * again before the build, `checkOutTurnStart`).
26
+ * - Build, fix and verify turns: the write tools come on. The model builds,
27
+ * verifies with a fresh `verification` subagent, commits and never
28
+ * pushes, and writes its own account to `session-report.json`. This
29
+ * process then runs what the cloud lane's finish runs (the secret and
30
+ * binary scan, a commit of anything left, the git facts) and writes
31
+ * `result.json` with the model's checks, what it could not verify and
32
+ * where it departed from the plan folded in.
33
+ *
34
+ * Kept from the cloud lane: the run's clock (`YAGNI_PIPELINE_TIME_LEFT_MS`,
35
+ * the turn's tools refused once it is nearly spent), the tool-failure health
36
+ * gate on a turn that writes, and the report's contract with the host. Not
37
+ * kept: the review loop and its round cap, the fixer, and the plan stage's
38
+ * own clock.
39
+ */
40
+ import { type AttemptResult, type CloudIdentity, type CloudStage } from "./cloud.js";
41
+ import type { ContinuationInputs } from "./continuation.js";
42
+ import { type RunStageDeps } from "./runner.js";
43
+ import { type ModelTier, type PipelineStage, type StageResult, type StopReason } from "./types.js";
44
+ import { type VerifyCheckRun } from "./verify.js";
45
+ import { snapshotWorkspace } from "./workspace.js";
46
+ import type { WorktreeExec } from "./worktree.js";
47
+ /** The one tier the session's writer runs on; its subagents keep their own. */
48
+ export declare const SESSION_TIER: ModelTier;
49
+ /** The caller the model proxy attributes the session's own turns to. */
50
+ export declare const SESSION_CALLER_LABEL = "go:session";
51
+ /** The model's own account of a turn that builds, beside `result.json` in the output folder. */
52
+ export declare const SESSION_REPORT_FILE_NAME = "session-report.json";
53
+ /** What a plan turn may use: everything that reads or runs, nothing that edits. */
54
+ export declare const PLAN_TURN_TOOLS: string[];
55
+ /** What a turn that builds may use: the plan turn's tools and the writes. */
56
+ export declare const BUILD_TURN_TOOLS: string[];
57
+ /** The run's files under the run user's home: its one session, and the playbook it runs on. */
58
+ export declare function sessionPaths(home: string, attemptId: string): {
59
+ sessionFile: string;
60
+ skillFile: string;
61
+ };
62
+ /** One plan answer as `plan-answers.json` carries it. */
63
+ interface PlanAnswer {
64
+ answer: string;
65
+ question?: string;
66
+ }
67
+ /** The answers in `plan-answers.json`, or none when it is missing or does not read. */
68
+ export declare function readPlanAnswers(text: string | null): PlanAnswer[];
69
+ /** What a turn's message is built from. */
70
+ export interface TurnFacts {
71
+ stage: CloudStage;
72
+ /** The session file existed: the model already holds the run so far. */
73
+ resumed: boolean;
74
+ inputDir: string;
75
+ outDir: string;
76
+ answers: readonly PlanAnswer[];
77
+ continuation?: ContinuationInputs;
78
+ /** The input folder has `hand-off-answer.md`: a person answered a stop. */
79
+ handOffAnswer: boolean;
80
+ /** The input folder has `fix-round.json`: a review or CI asks for changes. */
81
+ review: boolean;
82
+ /** The input folder has `sent-back-plan.md`: a person sent the plan back. */
83
+ sentBack: boolean;
84
+ }
85
+ /** The one message a turn sends the session. Pure. */
86
+ export declare function turnMessage(facts: TurnFacts): string;
87
+ /** The model's own account of a turn that builds (`session-report.json`). */
88
+ export interface SessionReport {
89
+ summary: string | null;
90
+ checks: VerifyCheckRun[];
91
+ notVerified: string[];
92
+ departures: Array<{
93
+ from: string;
94
+ what: string;
95
+ why: string;
96
+ }>;
97
+ }
98
+ /**
99
+ * `session-report.json` as this process folds it, or null when it is missing
100
+ * or does not read. Each check keeps the shape the host reads; one with no
101
+ * command is dropped, and a baseline the model did not name is the one
102
+ * `checks.json` gives the same command, else `runnable`.
103
+ */
104
+ export declare function parseSessionReport(text: string | null, known?: ReadonlyMap<string, VerifyCheckRun["baseline"]>): SessionReport | null;
105
+ /** The report's checks and its words on `result.json`: the model's own account, never the host's proof. */
106
+ export declare function withReport(attempt: AttemptResult, report: SessionReport | null): AttemptResult;
107
+ /**
108
+ * The turn's stop as the result names it: aborted, its clock, a child that
109
+ * failed, a writing turn whose tools mostly errored (the cloud lane's health
110
+ * gate), else clean.
111
+ */
112
+ export declare function turnStop(run: StageResult, writes: boolean, aborted: boolean): {
113
+ stop: StopReason;
114
+ note?: string;
115
+ };
116
+ export interface SessionTurnInput {
117
+ cwd: string;
118
+ /** The folder the ticket file sits in: the host's input folder. */
119
+ inputDir: string;
120
+ identity: CloudIdentity;
121
+ outDir: string;
122
+ stage: CloudStage;
123
+ continuation?: ContinuationInputs;
124
+ /** The approved plan's text, the commit's title and summary. */
125
+ plan?: string;
126
+ childEnv: NodeJS.ProcessEnv;
127
+ /** The launcher's `--skill` / `--prompt-template` paths (YAG-784): every turn reads them. */
128
+ extraArgs?: string[];
129
+ /** The run's deadline on this machine's clock, when the host gave one. */
130
+ deadlineMs?: number;
131
+ /** The run id the model proxy attributes the turn's spend to. */
132
+ runId?: string;
133
+ /** One NDJSON line on the stream, or nothing when the caller streams none. */
134
+ emit: (line: Record<string, unknown>) => void;
135
+ writeErr: (line: string) => void;
136
+ signal?: AbortSignal;
137
+ }
138
+ export interface SessionTurnDeps {
139
+ runStage?: (stage: PipelineStage, ctx: {
140
+ ticket: string;
141
+ }, deps: RunStageDeps) => Promise<StageResult>;
142
+ readFile?: (path: string) => string;
143
+ writeFile?: (path: string, text: string) => void;
144
+ exists?: (path: string) => boolean;
145
+ mkdir?: (path: string) => void;
146
+ exec?: WorktreeExec;
147
+ snapshot?: typeof snapshotWorkspace;
148
+ /** The run user's home; the Machine's `/home/user`. */
149
+ home?: string;
150
+ now?: () => number;
151
+ }
152
+ export interface SessionTurnOutcome {
153
+ /** A written plan, or a clean turn whose branch carries the attempt's committed work. */
154
+ ok: boolean;
155
+ attempt: AttemptResult;
156
+ }
157
+ /** Run one turn of the run's session and write what the host reads. Never throws for the model's sake. */
158
+ export declare function runSessionTurn(input: SessionTurnInput, deps?: SessionTurnDeps): Promise<SessionTurnOutcome>;
159
+ export {};
160
+ //# sourceMappingURL=sessionLane.d.ts.map
@@ -0,0 +1,576 @@
1
+ /**
2
+ * The session lane (`yagni go --headless --cloud --session`, 2026-10-28): a
3
+ * ticket runs as ONE pi session, on the strongest tier, in one Machine the
4
+ * host keeps for the whole run, in place of the cloud lane's stage graph.
5
+ * The model works the ticket itself, with the `subagent` tool for what it
6
+ * hands off, and the host meets it at the same checkpoints the cloud lane
7
+ * has, by the same files.
8
+ *
9
+ * Each host execution is one TURN of that session: this process runs one pi
10
+ * child on the session file the run keeps under the run user's home
11
+ * (`~/.yagni/session-lane/<attempt>/session.jsonl`), with one message that
12
+ * says what the turn is, and ends when the model ends its turn. A file that
13
+ * exists is resumed (`--session <file>`: pi restores the conversation and
14
+ * takes the tools this turn names), so the plan, the build and each fix are
15
+ * one conversation; a Machine the host had to replace has none, and the turn
16
+ * starts a fresh session whose message points at everything on disk.
17
+ *
18
+ * - Plan turn: read, search, run anything (tests, Docker, the app), no edit
19
+ * or write tool. Its last message is the plan with the plan-json block,
20
+ * asked for once more when the block is missing; this process writes
21
+ * `plan.md`, `plan.json` and `result.json` as the cloud lane's plan stage
22
+ * does (`completeCloudSession`). Anything the turn changed in tracked
23
+ * files, or committed, is put back before the plan's gate, so nothing is
24
+ * written before a person approves (the host also checks the checkout out
25
+ * again before the build, `checkOutTurnStart`).
26
+ * - Build, fix and verify turns: the write tools come on. The model builds,
27
+ * verifies with a fresh `verification` subagent, commits and never
28
+ * pushes, and writes its own account to `session-report.json`. This
29
+ * process then runs what the cloud lane's finish runs (the secret and
30
+ * binary scan, a commit of anything left, the git facts) and writes
31
+ * `result.json` with the model's checks, what it could not verify and
32
+ * where it departed from the plan folded in.
33
+ *
34
+ * Kept from the cloud lane: the run's clock (`YAGNI_PIPELINE_TIME_LEFT_MS`,
35
+ * the turn's tools refused once it is nearly spent), the tool-failure health
36
+ * gate on a turn that writes, and the report's contract with the host. Not
37
+ * kept: the review loop and its round cap, the fixer, and the plan stage's
38
+ * own clock.
39
+ */
40
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
41
+ import { homedir } from "node:os";
42
+ import { join, relative } from "node:path";
43
+ import { scanChange } from "./changeScan.js";
44
+ import { completeCloudSession, RESULT_FILE_NAME, } from "./cloud.js";
45
+ import { finishExec } from "./finish.js";
46
+ import { MISSION_CHECKS_FILE_NAME, readMissionChecksFile } from "./missionChecks.js";
47
+ import { WORK_A_TICKET_SKILL } from "./personas.js";
48
+ import { extractPlanJson } from "./planOutput.js";
49
+ import { runStage as defaultRunStage } from "./runner.js";
50
+ import { DECISIONS_READ_TOOL, PLAN_JSON_CONTRACT } from "./stages.js";
51
+ import { STAGE_WRAP_UP_GRACE_MS } from "./stageClock.js";
52
+ import { MIN_TOOL_CALLS_FOR_HEALTH, TOOL_ERROR_FAIL_RATE, } from "./types.js";
53
+ import { baselineDirs, parseChangedPaths, planPaths } from "./verify.js";
54
+ import { snapshotWorkspace } from "./workspace.js";
55
+ /** The one tier the session's writer runs on; its subagents keep their own. */
56
+ export const SESSION_TIER = "peak";
57
+ /** The caller the model proxy attributes the session's own turns to. */
58
+ export const SESSION_CALLER_LABEL = "go:session";
59
+ /** The model's own account of a turn that builds, beside `result.json` in the output folder. */
60
+ export const SESSION_REPORT_FILE_NAME = "session-report.json";
61
+ /** What a plan turn may use: everything that reads or runs, nothing that edits. */
62
+ export const PLAN_TURN_TOOLS = [
63
+ "read",
64
+ "grep",
65
+ "find",
66
+ "ls",
67
+ "bash",
68
+ "ask_yagni",
69
+ "team_read",
70
+ "prototype_read",
71
+ "subagent",
72
+ ];
73
+ /** What a turn that builds may use: the plan turn's tools and the writes. */
74
+ export const BUILD_TURN_TOOLS = [
75
+ ...PLAN_TURN_TOOLS,
76
+ "edit",
77
+ "write",
78
+ "record_decision",
79
+ "prototype_verification",
80
+ "prototype_verify",
81
+ ];
82
+ /**
83
+ * How long before the run's deadline a turn's tools are refused: the
84
+ * runner's grace for its last message, and the finish after it.
85
+ */
86
+ const TURN_FINISH_RESERVE_MS = STAGE_WRAP_UP_GRACE_MS + 5 * 60_000;
87
+ /** The run's files under the run user's home: its one session, and the playbook it runs on. */
88
+ export function sessionPaths(home, attemptId) {
89
+ const root = join(home, ".yagni");
90
+ return {
91
+ sessionFile: join(root, "session-lane", attemptId, "session.jsonl"),
92
+ skillFile: join(root, "skills", "work-a-ticket", "SKILL.md"),
93
+ };
94
+ }
95
+ /** The stage boundary a turn draws on the Live stream: a build or verify turn is the implement stage. */
96
+ function feedStage(stage) {
97
+ if (stage === "plan")
98
+ return "plan";
99
+ if (stage === "fix")
100
+ return "fix";
101
+ return "implement";
102
+ }
103
+ /** The answers in `plan-answers.json`, or none when it is missing or does not read. */
104
+ export function readPlanAnswers(text) {
105
+ if (!text)
106
+ return [];
107
+ try {
108
+ const data = JSON.parse(text);
109
+ if (!Array.isArray(data.answers))
110
+ return [];
111
+ return data.answers.flatMap((entry) => {
112
+ if (!entry || typeof entry !== "object")
113
+ return [];
114
+ const { answer, question } = entry;
115
+ if (typeof answer !== "string" || !answer.trim())
116
+ return [];
117
+ return [
118
+ {
119
+ answer: answer.trim().slice(0, 1_000),
120
+ ...(typeof question === "string" && question.trim()
121
+ ? { question: question.trim().slice(0, 500) }
122
+ : {}),
123
+ },
124
+ ];
125
+ });
126
+ }
127
+ catch {
128
+ return [];
129
+ }
130
+ }
131
+ const REPORT_CONTRACT = (outDir) => `Last, write ${join(outDir, SESSION_REPORT_FILE_NAME)} as JSON, honestly: ` +
132
+ '{"summary": "<what you changed, in two or three sentences>", ' +
133
+ '"checks": [{"name": "lint", "command": "pnpm lint", "cwd": ".", "exitCode": 0, "durationMs": 41000, ' +
134
+ '"baseline": "runnable | fails_on_base | cannot_run"}], ' +
135
+ '"notVerified": ["<what you could not verify, and why>"], ' +
136
+ '"departures": [{"from": "<the plan step>", "what": "<what you did instead>", "why": "<why>"}]}. ' +
137
+ "List every check you ran on the final tree with its real exit code; a check you did not run is " +
138
+ 'not a pass, and goes in "notVerified". "baseline" is "fails_on_base" only when the check already ' +
139
+ "failed on the unchanged code. Then end your turn.";
140
+ const BUILD_RULES = (outDir) => [
141
+ "Write tools are on now. Work as your playbook says:",
142
+ "- Prefer the smallest change that meets the ticket.",
143
+ "- Hand fully specified chunks to the implementer subagent and wide reads to searcher subagents, in parallel where they touch different files; keep the judgement calls yourself.",
144
+ "- Run the checks that prove the change (checks.json, the plan's checks) and fix what your change breaks.",
145
+ "- Before you finish, dispatch a fresh verification subagent on your diff with the ticket and the plan, and fix what it finds that is real.",
146
+ "- Commit on the branch you are on. Never push and never open or edit a pull request: YAGNI pushes your commits and opens it.",
147
+ `- ${REPORT_CONTRACT(outDir)}`,
148
+ ].join("\n");
149
+ const INPUTS = (inputDir) => [
150
+ `Your inputs are in ${inputDir}:`,
151
+ "- ticket.md: the ticket and the evidence behind it. Open every image it names with the read tool.",
152
+ "- design.md and images/: the linked design and its frames, when they exist.",
153
+ "- memo.md: what runs in this repository.",
154
+ "- checks.json: the repository's checks, and which already fail on the unchanged code.",
155
+ "- playbook.md: the Team's planning rules. Always follow them.",
156
+ "- plan-line.md: this Team's instructions for planning.",
157
+ ].join("\n");
158
+ const PLAN_RULES = [
159
+ "This turn you have read, grep, find, ls, bash, ask_yagni, team_read, prototype_read and subagent, and no edit or write tool.",
160
+ "Run whatever settles a fact: the tests, a type check, the app, Docker, a query. Change no tracked file and commit nothing: anything this turn changes in the repository is put back before the plan's gate.",
161
+ `Call team_read with tool "${DECISIONS_READ_TOOL}" before you ask a person anything: a question an earlier decision on this repository already answers is not a question.`,
162
+ "End the turn with the plan as your last message: ## Goal (one sentence), ## Plan (numbered steps, each naming the file or function), ## Files to modify / create, ## Checks and ## Risks, then the plan-json block. Where there is a real choice of approach, ask it as a decision question with your recommendation and its alternatives; the plan's steps follow your recommendation.",
163
+ PLAN_JSON_CONTRACT,
164
+ "Then stop: a person approves the plan before anything is built.",
165
+ ].join("\n\n");
166
+ /** The one message a turn sends the session. Pure. */
167
+ export function turnMessage(facts) {
168
+ const at = (file) => join(facts.inputDir, file);
169
+ const parts = [];
170
+ if (facts.stage === "plan") {
171
+ if (facts.resumed && facts.sentBack) {
172
+ parts.push(`A person sent your plan back. What they asked is in ${at("ticket.md")}, and the plan they sent back is ${at("sent-back-plan.md")}. Plan again: keep what they did not ask to change.`);
173
+ }
174
+ else if (facts.resumed) {
175
+ parts.push(`Plan this ticket again from ${at("ticket.md")}.`);
176
+ }
177
+ else {
178
+ parts.push("This is the plan turn of a YAGNI attempt. Work the ticket as your playbook says, up to the plan: nothing is built until a person approves it.", INPUTS(facts.inputDir));
179
+ if (facts.sentBack) {
180
+ parts.push(`A person sent an earlier plan back: what they asked is in ${at("ticket.md")}, and that plan is ${at("sent-back-plan.md")}.`);
181
+ }
182
+ }
183
+ parts.push(PLAN_RULES);
184
+ return parts.join("\n\n");
185
+ }
186
+ const answers = facts.answers.length > 0
187
+ ? [
188
+ "Their answers:",
189
+ ...facts.answers.map((entry) => entry.question ? `- ${entry.question}: ${entry.answer}` : `- ${entry.answer}`),
190
+ ].join("\n")
191
+ : "";
192
+ if (facts.stage === "implement" && facts.resumed && !facts.continuation) {
193
+ parts.push(`The person approved your plan. The plan as approved is ${at("plan.md")}, and their answers are in ${at("plan-answers.json")}; ${at("ticket.md")} lists any answer that changes the plan.`);
194
+ if (answers)
195
+ parts.push(answers);
196
+ parts.push(`The option they picked is binding. If it is not the one you recommended, re-plan in place first: write the plan you will build to ${join(facts.outDir, "plan.md")}, then build that. The steps are guidance; record every departure from them and why.`);
197
+ }
198
+ else if (facts.stage === "fix" && facts.review) {
199
+ parts.push(`A review or the CI on this attempt's pull request asks for changes. They are in ${at("ticket.md")}, under "Review on this attempt's pull request", each with its id; the branch may hold a person's commits since your last turn, so read the code as it is now. Address every item with the smallest change that does it, write the outcomes file that section names, and commit.`);
200
+ }
201
+ else {
202
+ const why = facts.continuation?.priorStop
203
+ ? ` The last turn stopped (${facts.continuation.priorStop}) before it finished.`
204
+ : "";
205
+ parts.push(facts.resumed
206
+ ? `Pick up the attempt where it stands on this branch.${why}`
207
+ : `You are building an approved plan for this ticket, on a branch that holds whatever this attempt committed so far.${why}`);
208
+ if (!facts.resumed) {
209
+ parts.push(INPUTS(facts.inputDir), `- plan.md: the plan a person approved, and plan-answers.json, their answers. The option they picked is binding; the steps are guidance.`);
210
+ }
211
+ if (answers && !facts.resumed)
212
+ parts.push(answers);
213
+ if (facts.stage === "verify") {
214
+ parts.push("Check the work on this branch against the plan: run the checks, fix only what this change broke, and commit any fix.");
215
+ }
216
+ if (facts.review) {
217
+ parts.push(`A review or the CI on this attempt's pull request asks for changes: they are in ${at("ticket.md")}, under "Review on this attempt's pull request". Address every item and write the outcomes file that section names.`);
218
+ }
219
+ }
220
+ if (facts.handOffAnswer) {
221
+ parts.push(`Read ${at("hand-off-answer.md")} before you go on: why the attempt stopped, and what a person answered.`);
222
+ }
223
+ parts.push(BUILD_RULES(facts.outDir));
224
+ return parts.join("\n\n");
225
+ }
226
+ const MAX_REPORTED_CHECKS = 40;
227
+ const MAX_REPORT_LINES = 20;
228
+ function line(value, max = 500) {
229
+ if (typeof value !== "string")
230
+ return null;
231
+ const text = value.replace(/\s+/g, " ").trim();
232
+ return text ? text.slice(0, max) : null;
233
+ }
234
+ const BASELINES = new Set(["runnable", "fails_on_base", "cannot_run"]);
235
+ /**
236
+ * `session-report.json` as this process folds it, or null when it is missing
237
+ * or does not read. Each check keeps the shape the host reads; one with no
238
+ * command is dropped, and a baseline the model did not name is the one
239
+ * `checks.json` gives the same command, else `runnable`.
240
+ */
241
+ export function parseSessionReport(text, known = new Map()) {
242
+ if (!text)
243
+ return null;
244
+ let data;
245
+ try {
246
+ const parsed = JSON.parse(text);
247
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
248
+ return null;
249
+ data = parsed;
250
+ }
251
+ catch {
252
+ return null;
253
+ }
254
+ const list = (value) => (Array.isArray(value) ? value : []);
255
+ const checks = [];
256
+ for (const entry of list(data.checks).slice(0, MAX_REPORTED_CHECKS)) {
257
+ if (!entry || typeof entry !== "object")
258
+ continue;
259
+ const check = entry;
260
+ const command = line(check.command, 1_000);
261
+ if (!command)
262
+ continue;
263
+ const exitCode = typeof check.exitCode === "number" && Number.isInteger(check.exitCode)
264
+ ? check.exitCode
265
+ : null;
266
+ const baseline = BASELINES.has(String(check.baseline))
267
+ ? check.baseline
268
+ : (known.get(command.replace(/\s+/g, " ")) ?? "runnable");
269
+ const note = line(check.note);
270
+ checks.push({
271
+ name: line(check.name, 120) ?? command.slice(0, 120),
272
+ command,
273
+ cwd: line(check.cwd, 300) ?? ".",
274
+ exitCode,
275
+ durationMs: typeof check.durationMs === "number" && check.durationMs >= 0
276
+ ? Math.round(check.durationMs)
277
+ : 0,
278
+ baseline,
279
+ ...(note ? { note } : exitCode === null ? { note: "the session gave no exit code" } : {}),
280
+ });
281
+ }
282
+ const notVerified = list(data.notVerified)
283
+ .map((entry) => line(entry))
284
+ .filter((entry) => entry !== null)
285
+ .slice(0, MAX_REPORT_LINES);
286
+ const departures = list(data.departures)
287
+ .flatMap((entry) => {
288
+ if (!entry || typeof entry !== "object")
289
+ return [];
290
+ const record = entry;
291
+ const what = line(record.what);
292
+ if (!what)
293
+ return [];
294
+ return [{ from: line(record.from, 200) ?? "the plan", what, why: line(record.why) ?? "" }];
295
+ })
296
+ .slice(0, MAX_REPORT_LINES);
297
+ return { summary: line(data.summary, 1_500), checks, notVerified, departures };
298
+ }
299
+ /** The checks the host named in `checks.json`, by their command, with how each stands on the base. */
300
+ function knownBaselines(inputDir, readFile) {
301
+ const read = readMissionChecksFile(join(inputDir, MISSION_CHECKS_FILE_NAME), readFile);
302
+ return new Map((read.file?.checks ?? []).map((check) => [check.command.replace(/\s+/g, " "), check.status]));
303
+ }
304
+ /** The report's checks and its words on `result.json`: the model's own account, never the host's proof. */
305
+ export function withReport(attempt, report) {
306
+ if (!report) {
307
+ return {
308
+ ...attempt,
309
+ notes: [
310
+ ...attempt.notes,
311
+ `The session wrote no readable ${SESSION_REPORT_FILE_NAME}, so it reports no checks of its own.`,
312
+ ],
313
+ };
314
+ }
315
+ const notes = [
316
+ ...(report.summary ? [report.summary] : []),
317
+ ...attempt.notes,
318
+ ...report.notVerified.map((entry) => `Not verified: ${entry}`),
319
+ ...report.departures.map((entry) => `Departed from ${entry.from}: ${entry.what}${entry.why ? `, because ${entry.why}` : ""}`),
320
+ ];
321
+ return {
322
+ ...attempt,
323
+ checks: report.checks.length > 0 ? report.checks : attempt.checks,
324
+ notes,
325
+ };
326
+ }
327
+ /**
328
+ * The turn's stop as the result names it: aborted, its clock, a child that
329
+ * failed, a writing turn whose tools mostly errored (the cloud lane's health
330
+ * gate), else clean.
331
+ */
332
+ export function turnStop(run, writes, aborted) {
333
+ if (aborted || run.stopReason === "aborted")
334
+ return { stop: "aborted" };
335
+ if (run.timedOut) {
336
+ return { stop: "budget_exceeded", note: "The turn ran out of the run's time and was stopped." };
337
+ }
338
+ if (run.exitCode !== 0 || run.stopReason === "error") {
339
+ const why = run.errorMessage ? `: ${run.errorMessage.slice(0, 300)}` : "";
340
+ return { stop: "failed", note: `The session's turn ended on an error${why}.` };
341
+ }
342
+ if (writes &&
343
+ run.toolCalls >= MIN_TOOL_CALLS_FOR_HEALTH &&
344
+ run.toolErrors / run.toolCalls > TOOL_ERROR_FAIL_RATE) {
345
+ return {
346
+ stop: "failed",
347
+ note: `Tool failures: ${run.toolErrors} of ${run.toolCalls} tool calls errored, so its edits likely did not land.`,
348
+ };
349
+ }
350
+ return { stop: "clean" };
351
+ }
352
+ /** Run one turn of the run's session and write what the host reads. Never throws for the model's sake. */
353
+ export async function runSessionTurn(input, deps = {}) {
354
+ const readFile = deps.readFile ?? ((path) => readFileSync(path, "utf8"));
355
+ const writeFile = deps.writeFile ?? ((path, text) => writeFileSync(path, text));
356
+ const exists = deps.exists ?? existsSync;
357
+ const mkdir = deps.mkdir ?? ((path) => void mkdirSync(path, { recursive: true }));
358
+ const exec = deps.exec ?? finishExec;
359
+ const snapshot = deps.snapshot ?? snapshotWorkspace;
360
+ const runStage = deps.runStage ?? defaultRunStage;
361
+ const now = deps.now ?? Date.now;
362
+ const readOrNull = (path) => {
363
+ try {
364
+ return readFile(path);
365
+ }
366
+ catch {
367
+ return null;
368
+ }
369
+ };
370
+ const paths = sessionPaths(deps.home ?? homedir(), input.identity.attemptId);
371
+ const resumed = exists(paths.sessionFile);
372
+ mkdir(join(paths.sessionFile, ".."));
373
+ mkdir(join(paths.skillFile, ".."));
374
+ // The playbook, installed where the session (and anything it runs) can
375
+ // read it again, and handed to every turn as its system prompt.
376
+ writeFile(paths.skillFile, WORK_A_TICKET_SKILL);
377
+ const start = await snapshot(input.cwd, input.signal);
378
+ const baseSha = start.head;
379
+ const preRunDirt = parseChangedPaths(start.status);
380
+ const writes = input.stage !== "plan";
381
+ const stageId = feedStage(input.stage);
382
+ const timeBudgetMs = input.deadlineMs === undefined
383
+ ? undefined
384
+ : Math.max(60_000, input.deadlineMs - now() - TURN_FINISH_RESERVE_MS);
385
+ const turnStage = {
386
+ id: stageId,
387
+ agent: "session",
388
+ model: SESSION_TIER,
389
+ tools: writes ? BUILD_TURN_TOOLS : PLAN_TURN_TOOLS,
390
+ taskTemplate: "{ticket}",
391
+ ...(timeBudgetMs !== undefined ? { timeBudgetMs } : {}),
392
+ };
393
+ let decisionsRecorded = 0;
394
+ const runDeps = {
395
+ cwd: input.cwd,
396
+ env: input.childEnv,
397
+ sessionFile: paths.sessionFile,
398
+ callerLabel: SESSION_CALLER_LABEL,
399
+ ...(input.runId ? { attribution: { runId: input.runId } } : {}),
400
+ ...(input.signal ? { signal: input.signal } : {}),
401
+ ...(input.extraArgs?.length ? { extraArgs: input.extraArgs } : {}),
402
+ stageWriteCapable: writes,
403
+ personaBody: () => WORK_A_TICKET_SKILL,
404
+ // The installed playbook is the system prompt's file: kept, never cleaned up.
405
+ writePrompt: async () => ({ filePath: paths.skillFile, cleanup: () => undefined }),
406
+ onEvent: (event) => {
407
+ if (event.type === "tool_execution_end" &&
408
+ event.toolName === "record_decision" &&
409
+ !event.isError) {
410
+ decisionsRecorded += 1;
411
+ }
412
+ input.emit({ ...event, yagni: { stage: stageId } });
413
+ },
414
+ now,
415
+ };
416
+ const turn = async (message) => {
417
+ input.emit({ type: "pipeline_stage", stage: stageId, phase: "start", at: now() });
418
+ try {
419
+ return await runStage(turnStage, { ticket: message }, runDeps);
420
+ }
421
+ finally {
422
+ input.emit({ type: "pipeline_stage", stage: stageId, phase: "finish", at: now() });
423
+ }
424
+ };
425
+ const message = turnMessage({
426
+ stage: input.stage,
427
+ resumed,
428
+ inputDir: input.inputDir,
429
+ outDir: input.outDir,
430
+ answers: readPlanAnswers(readOrNull(join(input.inputDir, "plan-answers.json"))),
431
+ ...(input.continuation ? { continuation: input.continuation } : {}),
432
+ handOffAnswer: exists(join(input.inputDir, "hand-off-answer.md")),
433
+ review: exists(join(input.inputDir, "fix-round.json")),
434
+ sentBack: exists(join(input.inputDir, "sent-back-plan.md")),
435
+ });
436
+ const notes = [];
437
+ const stages = [];
438
+ let planText = "";
439
+ let ran;
440
+ try {
441
+ let run = await turn(message);
442
+ stages.push(run);
443
+ if (input.stage === "plan") {
444
+ const planned = await planWithBlock(run, turn, input.signal);
445
+ stages.push(...planned.asked);
446
+ run = planned.run;
447
+ planText = run.finalOutput;
448
+ notes.push(...(await putBackPlanTurn(input.cwd, start, exec, snapshot, input.signal)));
449
+ }
450
+ const stop = turnStop(run, writes, input.signal?.aborted === true);
451
+ if (stop.note)
452
+ notes.push(stop.note);
453
+ const planned = input.stage !== "plan" || planText.trim().length > 0;
454
+ ran = {
455
+ result: {
456
+ stages,
457
+ rounds: [],
458
+ findings: [],
459
+ stopReason: stop.stop === "clean" && !planned ? "failed" : stop.stop,
460
+ decisionsRecorded,
461
+ ...(input.stage === "plan" ? { plan: planText } : {}),
462
+ },
463
+ };
464
+ }
465
+ catch (err) {
466
+ const error = err instanceof Error ? err.message : String(err);
467
+ input.writeErr(error);
468
+ ran = { error, partial: stages };
469
+ }
470
+ const sessionSecrets = [input.childEnv.YAGNI_TOKEN, input.childEnv.GH_ENTERPRISE_TOKEN].filter((value) => Boolean(value));
471
+ const done = await completeCloudSession({
472
+ identity: input.identity,
473
+ outDir: input.outDir,
474
+ stage: input.stage,
475
+ cwd: input.cwd,
476
+ baseSha,
477
+ preRunDirt,
478
+ ...(input.plan ? { plan: input.plan } : {}),
479
+ roundsDone: input.continuation?.reviewRoundsDone ?? 0,
480
+ tree: async () => {
481
+ const tree = await snapshot(input.cwd, input.signal);
482
+ return `${tree.head}\n${tree.status}`;
483
+ },
484
+ scan: () => scanChange(input.cwd, { exec, sessionSecrets, ...(baseSha ? { base: baseSha } : {}) }, preRunDirt),
485
+ planPaths: (text) => baselineDirs(input.cwd, planPaths(text), readFile).map((dir) => relative(input.cwd, dir) || "."),
486
+ exec,
487
+ writeFile,
488
+ }, ran);
489
+ const noted = { ...done.attempt, notes: [...notes, ...done.attempt.notes] };
490
+ const attempt = writes
491
+ ? withSessionAccount(noted, parseSessionReport(readOrNull(join(input.outDir, SESSION_REPORT_FILE_NAME)), knownBaselines(input.inputDir, readFile)))
492
+ : noted;
493
+ // The cloud lane's finish wrote `result.json`; it is written again when
494
+ // this turn adds to it (the model's account, the plan turn's put-back).
495
+ if (writes || notes.length > 0)
496
+ rewriteResult(input.outDir, attempt, writeFile);
497
+ input.emit({ type: "pipeline_result", ...attempt });
498
+ const ok = writes ? done.ok && attempt.stopReason === "clean" : done.ok;
499
+ return { ok, attempt };
500
+ }
501
+ /** The re-ask that recovers a plan whose plan-json block did not read. */
502
+ const PLAN_BLOCK_REASK = "Your last message did not end with a plan-json block that reads. Send the whole plan again as " +
503
+ "your last message, ending with the plan-json block exactly as the contract says.";
504
+ /**
505
+ * A plan turn's contract is its block: a plan without one that reads is
506
+ * asked for once more in the same session, and the answer stands only when
507
+ * it carries the block. Not asked of a turn that was stopped or ran out of
508
+ * time, or that wrote no plan at all.
509
+ */
510
+ async function planWithBlock(run, turn, signal) {
511
+ const text = run.finalOutput;
512
+ if (!text.trim() || extractPlanJson(text) || signal?.aborted || run.timedOut) {
513
+ return { run, asked: [] };
514
+ }
515
+ const again = await turn(PLAN_BLOCK_REASK);
516
+ const took = again.exitCode === 0 && extractPlanJson(again.finalOutput) !== null;
517
+ return { run: took ? again : run, asked: [again] };
518
+ }
519
+ /**
520
+ * A writing turn's result with the model's own account folded in, and a
521
+ * clean turn that committed nothing read as having found nothing to change:
522
+ * the host's `no_changes`, which it finishes as a clean one when the turn
523
+ * verified work an earlier turn pushed.
524
+ */
525
+ function withSessionAccount(attempt, report) {
526
+ const folded = withReport(attempt, report);
527
+ return folded.stopReason === "clean" && folded.commits.length === 0
528
+ ? { ...folded, stopReason: "no_changes" }
529
+ : folded;
530
+ }
531
+ /** `result.json` written again; one that cannot be written says so in its own notes, which the stream carries. */
532
+ function rewriteResult(outDir, attempt, writeFile) {
533
+ try {
534
+ writeFile(join(outDir, RESULT_FILE_NAME), `${JSON.stringify(attempt, null, 2)}\n`);
535
+ }
536
+ catch (err) {
537
+ attempt.notes.push(`${RESULT_FILE_NAME} could not be written: ${err instanceof Error ? err.message : String(err)}`);
538
+ }
539
+ }
540
+ /**
541
+ * Nothing a plan turn writes outlives it (its subagents and its shell can
542
+ * write, though the turn has no edit or write tool): when its HEAD moved or
543
+ * its tracked files differ from how it started, the checkout is put back to
544
+ * its start (the tree's own dirt from before the turn with it), and a file it
545
+ * added that git would track is removed. Ignored files stay, as installs do.
546
+ * The result says what was put back, and when it could not be.
547
+ */
548
+ async function putBackPlanTurn(cwd, start, exec, snapshot, signal) {
549
+ if (!start.head)
550
+ return [];
551
+ const after = await snapshot(cwd, signal);
552
+ const entries = (status) => status.split("\n").filter((entry) => entry.trim());
553
+ const tracked = (status) => entries(status)
554
+ .filter((entry) => !entry.startsWith("??"))
555
+ .sort()
556
+ .join("\n");
557
+ const untrackedBefore = new Set(entries(start.status).filter((entry) => entry.startsWith("??")));
558
+ const added = entries(after.status)
559
+ .filter((entry) => entry.startsWith("??") && !untrackedBefore.has(entry))
560
+ .map((entry) => entry.slice(3).trim())
561
+ .filter(Boolean);
562
+ const moved = after.head !== start.head || tracked(after.status) !== tracked(start.status);
563
+ if (!moved && added.length === 0)
564
+ return [];
565
+ const run = (argv) => exec(argv, cwd, signal).catch(() => ({ code: -1, output: "" }));
566
+ const reset = moved ? await run(["git", "reset", "--hard", start.head]) : { code: 0 };
567
+ const clean = added.length > 0 ? await run(["git", "clean", "-fdq", "--", ...added]) : { code: 0 };
568
+ return reset.code === 0 && clean.code === 0
569
+ ? [
570
+ "The plan turn changed the repository; the checkout was put back to where it started before the plan's gate.",
571
+ ]
572
+ : [
573
+ "The plan turn changed the repository, and the checkout could not be put back in full; the host checks it out again before the build.",
574
+ ];
575
+ }
576
+ //# sourceMappingURL=sessionLane.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.2.11-staging.2122.1",
3
+ "version": "1.2.12-staging.2137.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": "4908d4b0b629a93a80da2015219b81ee9000b1e7"
61
+ "yagniSourceSha": "1cd3160a45a7677093048e44164f72e7038d4232"
62
62
  }