@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.
- package/dist/extension/pipeline/headlessGo.d.ts +9 -1
- package/dist/extension/pipeline/headlessGo.js +37 -1
- package/dist/extension/pipeline/invocation.d.ts +9 -0
- package/dist/extension/pipeline/invocation.js +15 -1
- package/dist/extension/pipeline/personas.d.ts +11 -0
- package/dist/extension/pipeline/personas.js +44 -0
- package/dist/extension/pipeline/runner.d.ts +7 -0
- package/dist/extension/pipeline/runner.js +29 -10
- package/dist/extension/pipeline/sessionLane.d.ts +160 -0
- package/dist/extension/pipeline/sessionLane.js +576 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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 = {
|
|
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, {
|
|
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
|
|
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 {
|
|
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,
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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 {
|
|
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.
|
|
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": "
|
|
61
|
+
"yagniSourceSha": "1cd3160a45a7677093048e44164f72e7038d4232"
|
|
62
62
|
}
|