@yaawn/workflow 0.0.0-stage → 0.3.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/README.md +172 -2
- package/agents/instructions/output-styles/yaawn-eli5.instructions.md +45 -0
- package/agents/instructions/yaawn-accessibility.css.instructions.md +58 -0
- package/agents/instructions/yaawn-accessibility.html.instructions.md +90 -0
- package/agents/instructions/yaawn-accessibility.javascript.instructions.md +50 -0
- package/agents/instructions/yaawn-clean-code.typescript.instructions.md +74 -0
- package/agents/instructions/yaawn-function-coupling.typescript.instructions.md +100 -0
- package/agents/instructions/yaawn-html-css-js.html.instructions.md +32 -0
- package/agents/instructions/yaawn-open-source-first.instructions.md +38 -0
- package/agents/instructions/yaawn-personas.instructions.md +18 -0
- package/agents/instructions/yaawn-semantic-html.html.instructions.md +40 -0
- package/agents/instructions/yaawn-vanilla-css.css.instructions.md +50 -0
- package/agents/output-styles/yaawn-eli5.instructions.md +45 -0
- package/agents/personas/yaawn-developer.md +16 -0
- package/agents/personas/yaawn-product-owner.md +18 -0
- package/agents/personas/yaawn-reviewer.md +16 -0
- package/agents/skills/yaawn-clean-code/SKILL.md +61 -0
- package/agents/skills/yaawn-clean-code/references/classes.md +114 -0
- package/agents/skills/yaawn-clean-code/references/comments.md +164 -0
- package/agents/skills/yaawn-clean-code/references/error-handling.md +151 -0
- package/agents/skills/yaawn-clean-code/references/formatting.md +146 -0
- package/agents/skills/yaawn-clean-code/references/functions.md +261 -0
- package/agents/skills/yaawn-clean-code/references/meaningful-names.md +234 -0
- package/agents/skills/yaawn-clean-code/references/objects-and-data-structures.md +130 -0
- package/agents/skills/yaawn-clean-code/references/principles.md +76 -0
- package/agents/skills/yaawn-clean-code/references/simple-design-and-code-smells.md +130 -0
- package/agents/skills/yaawn-clean-code/references/unit-tests.md +114 -0
- package/agents/skills/yaawn-create-plan/SKILL.md +228 -0
- package/agents/skills/yaawn-grill-bug-report/SKILL.md +31 -0
- package/agents/skills/yaawn-grill-bug-report/references/bug-report-checklist.md +27 -0
- package/agents/skills/yaawn-grill-design/SKILL.md +68 -0
- package/agents/skills/yaawn-grill-design/references/design-checklist.md +36 -0
- package/agents/skills/yaawn-grill-other/SKILL.md +31 -0
- package/agents/skills/yaawn-grill-other/references/other-checklist.md +19 -0
- package/agents/skills/yaawn-grill-refactor/SKILL.md +31 -0
- package/agents/skills/yaawn-grill-refactor/references/refactor-checklist.md +26 -0
- package/agents/skills/yaawn-grill-spike/SKILL.md +31 -0
- package/agents/skills/yaawn-grill-spike/references/spike-checklist.md +25 -0
- package/agents/skills/yaawn-grill-story/SKILL.md +34 -0
- package/agents/skills/yaawn-grill-story/references/concept-challenge.md +67 -0
- package/agents/skills/yaawn-grill-story/references/story-checklist.md +43 -0
- package/agents/skills/yaawn-implement-plan/SKILL.md +196 -0
- package/agents/skills/yaawn-implement-plan/assets/fix-prompt.md +66 -0
- package/agents/skills/yaawn-implement-plan/assets/review-prompt.md +89 -0
- package/agents/skills/yaawn-implement-plan/assets/session-prompt.md +80 -0
- package/agents/skills/yaawn-implement-plan/scripts/agent.ts +218 -0
- package/agents/skills/yaawn-implement-plan/scripts/caffeinate.ts +56 -0
- package/agents/skills/yaawn-implement-plan/scripts/groups.ts +233 -0
- package/agents/skills/yaawn-implement-plan/scripts/overnight.ts +772 -0
- package/agents/skills/yaawn-implement-plan/scripts/parallel.ts +1176 -0
- package/agents/skills/yaawn-implement-plan/scripts/processes.ts +53 -0
- package/agents/skills/yaawn-implement-plan/scripts/stages.ts +65 -0
- package/agents/skills/yaawn-implement-plan/scripts/targets.ts +381 -0
- package/agents/skills/yaawn-implement-plan/scripts/worktrees.ts +431 -0
- package/agents/skills/yaawn-owasp-security/SKILL.md +106 -0
- package/agents/skills/yaawn-owasp-security/references/a01-broken-access-control.md +58 -0
- package/agents/skills/yaawn-owasp-security/references/a02-security-misconfiguration.md +56 -0
- package/agents/skills/yaawn-owasp-security/references/a03-software-supply-chain-failures.md +56 -0
- package/agents/skills/yaawn-owasp-security/references/a04-cryptographic-failures.md +67 -0
- package/agents/skills/yaawn-owasp-security/references/a05-injection.md +53 -0
- package/agents/skills/yaawn-owasp-security/references/a06-insecure-design.md +48 -0
- package/agents/skills/yaawn-owasp-security/references/a07-authentication-failures.md +68 -0
- package/agents/skills/yaawn-owasp-security/references/a08-software-data-integrity-failures.md +45 -0
- package/agents/skills/yaawn-owasp-security/references/a09-security-logging-alerting-failures.md +59 -0
- package/agents/skills/yaawn-owasp-security/references/a10-mishandling-exceptional-conditions.md +63 -0
- package/agents/skills/yaawn-prepare-plan/SKILL.md +110 -0
- package/agents/skills/yaawn-save-plan/SKILL.md +65 -0
- package/agents/skills/yaawn-save-plan/scripts/plan-body.ts +331 -0
- package/agents/skills/yaawn-save-plan/scripts/save-plan.ts +443 -0
- package/agents/skills/yaawn-split-story/SKILL.md +189 -0
- package/agents/skills/yaawn-split-story/references/spider.md +64 -0
- package/agents/skills/yaawn-split-story/scripts/create-issue.ts +162 -0
- package/agents/skills/yaawn-verify-executed-plan/SKILL.md +154 -0
- package/agents/skills/yaawn-verify-executed-plan/scripts/overnight.ts +362 -0
- package/agents/skills/yaawn-verify-executed-plan/scripts/pull-request.ts +131 -0
- package/agents/skills/yaawn-write-bug-report/SKILL.md +87 -0
- package/agents/skills/yaawn-write-other/SKILL.md +90 -0
- package/agents/skills/yaawn-write-refactor/SKILL.md +94 -0
- package/agents/skills/yaawn-write-spike/SKILL.md +94 -0
- package/agents/skills/yaawn-write-user-story/SKILL.md +187 -0
- package/agents/skills/yaawn-write-user-story/references/acceptance-criteria.md +186 -0
- package/agents/skills/yaawn-write-user-story/references/five-types-of-value.md +82 -0
- package/agents/skills/yaawn-write-user-story/references/user-story-fundamentals.md +75 -0
- package/agents/yaawn-glossary.md +146 -0
- package/agents/yaawn-grilling.md +75 -0
- package/agents/yaawn-worktrees.md +69 -0
- package/config/install-tasks.json +35 -0
- package/config/settings.json +23 -0
- package/config/wizard.json +128 -0
- package/github/ISSUE_TEMPLATE/yaawn-bug-report.md +59 -0
- package/github/ISSUE_TEMPLATE/yaawn-other.md +38 -0
- package/github/ISSUE_TEMPLATE/yaawn-refactor.md +38 -0
- package/github/ISSUE_TEMPLATE/yaawn-spike.md +16 -0
- package/github/ISSUE_TEMPLATE/yaawn-story.md +56 -0
- package/package.json +33 -4
- package/scripts/install.js +73 -0
- package/yaawn/gitignore +1 -0
- package/yaawn/yaawn-definition-of-done.md +5 -0
- package/yaawn/yaawn-project-glossary.md +7 -0
- package/yaawn/yaawn-user-roles.md +3 -0
- package/yaawn/yaawn-verification-commands.md +7 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
import { type ChildProcess, execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
constants,
|
|
5
|
+
createWriteStream,
|
|
6
|
+
existsSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readlinkSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
statSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { createInterface } from "node:readline";
|
|
17
|
+
import type { Readable } from "node:stream";
|
|
18
|
+
import {
|
|
19
|
+
type AgentArguments,
|
|
20
|
+
type AgentChoice,
|
|
21
|
+
type AgentName,
|
|
22
|
+
type SessionKind,
|
|
23
|
+
agentArguments,
|
|
24
|
+
agentCli,
|
|
25
|
+
readAgent,
|
|
26
|
+
transcriptExtension,
|
|
27
|
+
} from "./agent.js";
|
|
28
|
+
import { keepMacAwakeWhileRunning, printWalkAwayBanner } from "./caffeinate.js";
|
|
29
|
+
import {
|
|
30
|
+
type LaunchRequest,
|
|
31
|
+
parseLaunchRequest,
|
|
32
|
+
preflightMany,
|
|
33
|
+
readyLaneIssues,
|
|
34
|
+
runMany,
|
|
35
|
+
} from "./parallel.js";
|
|
36
|
+
import { drainOutput, killProcessGroup } from "./processes.js";
|
|
37
|
+
import { type Stage, stageMarker } from "./stages.js";
|
|
38
|
+
import {
|
|
39
|
+
type Issue,
|
|
40
|
+
executionOrder,
|
|
41
|
+
fetchIssue,
|
|
42
|
+
isDone,
|
|
43
|
+
issueRefs,
|
|
44
|
+
plansDir,
|
|
45
|
+
resolveTargets,
|
|
46
|
+
resultPath,
|
|
47
|
+
unansweredQuestionTexts,
|
|
48
|
+
} from "./targets.js";
|
|
49
|
+
import { runsInOwnWorktree, worktreePath } from "./worktrees.js";
|
|
50
|
+
|
|
51
|
+
type RunState = "pending" | "running" | "done" | "failed" | "skipped";
|
|
52
|
+
|
|
53
|
+
interface RunOutcome {
|
|
54
|
+
states: Map<number, RunState>;
|
|
55
|
+
tails: Map<number, string[]>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface SessionHandle {
|
|
59
|
+
readonly agent: AgentName;
|
|
60
|
+
readonly laneIssue: number;
|
|
61
|
+
current: ChildProcess | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface SessionSpec {
|
|
65
|
+
agent: AgentName;
|
|
66
|
+
target: Issue;
|
|
67
|
+
promptPath: string;
|
|
68
|
+
kind: SessionKind;
|
|
69
|
+
nameSuffix: string;
|
|
70
|
+
transcriptPath: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface TreeSnapshot {
|
|
74
|
+
head: string;
|
|
75
|
+
status: string;
|
|
76
|
+
trackedDiffHash: string;
|
|
77
|
+
untrackedHash: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sessionPromptPath =
|
|
81
|
+
".agents/skills/yaawn-implement-plan/assets/session-prompt.md";
|
|
82
|
+
const reviewPromptPath =
|
|
83
|
+
".agents/skills/yaawn-implement-plan/assets/review-prompt.md";
|
|
84
|
+
const fixPromptPath =
|
|
85
|
+
".agents/skills/yaawn-implement-plan/assets/fix-prompt.md";
|
|
86
|
+
const implementationPlanHeading = "## Implementation Plan";
|
|
87
|
+
const maxSessionTitleLength = 50;
|
|
88
|
+
const exitCodeTerminated = 143;
|
|
89
|
+
const failureTailLines = 20;
|
|
90
|
+
const maxReviewReportBytes = 256 * 1024;
|
|
91
|
+
const gitOutputMaxBuffer = 512 * 1024 * 1024;
|
|
92
|
+
const outsidePlansDir = ["--", ".", `:!${plansDir}`];
|
|
93
|
+
const transcriptFlags =
|
|
94
|
+
constants.O_WRONLY |
|
|
95
|
+
constants.O_CREAT |
|
|
96
|
+
constants.O_TRUNC |
|
|
97
|
+
constants.O_NOFOLLOW;
|
|
98
|
+
const scriptPath = ".agents/skills/yaawn-implement-plan/scripts/overnight.ts";
|
|
99
|
+
const usage = [
|
|
100
|
+
`Usage: npx tsx ${scriptPath} <questions|preflight|run> <issue> [<issue>...] [--max-parallel N] [--agent copilot|claude|vibe]`,
|
|
101
|
+
` npx tsx ${scriptPath} <questions|preflight|run> --ready [--max-parallel N] [--agent copilot|claude|vibe]`,
|
|
102
|
+
].join("\n");
|
|
103
|
+
|
|
104
|
+
const summaryPath = (issue: number): string =>
|
|
105
|
+
`${plansDir}/yaawn-${issue}-summary.md`;
|
|
106
|
+
|
|
107
|
+
const llmPlanPath = (issue: number): string =>
|
|
108
|
+
`${plansDir}/yaawn-${issue}-llm-plan.md`;
|
|
109
|
+
|
|
110
|
+
const implementTranscriptPath = (issue: number, agent: AgentName): string =>
|
|
111
|
+
`${plansDir}/yaawn-${issue}-overnight-session${transcriptExtension(agent)}`;
|
|
112
|
+
|
|
113
|
+
const reviewReportPath = (issue: number): string =>
|
|
114
|
+
`${plansDir}/yaawn-${issue}-review.md`;
|
|
115
|
+
|
|
116
|
+
const reviewTranscriptPath = (issue: number, agent: AgentName): string =>
|
|
117
|
+
`${plansDir}/yaawn-${issue}-review-session${transcriptExtension(agent)}`;
|
|
118
|
+
|
|
119
|
+
const fixTranscriptPath = (issue: number, agent: AgentName): string =>
|
|
120
|
+
`${plansDir}/yaawn-${issue}-fix-session${transcriptExtension(agent)}`;
|
|
121
|
+
|
|
122
|
+
function implementationPlanSection(body: string): string {
|
|
123
|
+
const lines = body.replace(/\r\n/g, "\n").split("\n");
|
|
124
|
+
const start = lines.findIndex(
|
|
125
|
+
(line) => line.trim() === implementationPlanHeading,
|
|
126
|
+
);
|
|
127
|
+
if (start === -1) return "";
|
|
128
|
+
let inFence = false;
|
|
129
|
+
const end = lines.findIndex((line, index) => {
|
|
130
|
+
if (/^ {0,3}(```|~~~)/.test(line)) inFence = !inFence;
|
|
131
|
+
return index > start && !inFence && /^## /.test(line);
|
|
132
|
+
});
|
|
133
|
+
return lines
|
|
134
|
+
.slice(start, end === -1 ? lines.length : end)
|
|
135
|
+
.join("\n")
|
|
136
|
+
.trim();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Fetched fresh right before the session starts, so answers written after
|
|
140
|
+
// the pre-flight are included.
|
|
141
|
+
function writeLlmPlan(issue: number): void {
|
|
142
|
+
const { title, body } = fetchIssue(issue);
|
|
143
|
+
writeFileSync(
|
|
144
|
+
llmPlanPath(issue),
|
|
145
|
+
`# #${issue} — ${title}\n\n${implementationPlanSection(body)}\n`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function printFailures(failures: string[]): void {
|
|
150
|
+
failures.forEach((failure) => console.log(`FAIL ${failure}`));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildPrompt(promptPath: string, issue: number): string {
|
|
154
|
+
return readFileSync(promptPath, "utf-8").replaceAll(
|
|
155
|
+
"<target>",
|
|
156
|
+
String(issue),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function keepTail(stream: Readable | null, tail: string[]): void {
|
|
161
|
+
if (!stream) return;
|
|
162
|
+
createInterface({ input: stream }).on("line", (line) => {
|
|
163
|
+
tail.push(line);
|
|
164
|
+
if (tail.length > failureTailLines) tail.shift();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function slugify(text: string): string {
|
|
169
|
+
return text
|
|
170
|
+
.normalize("NFKD")
|
|
171
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
172
|
+
.toLowerCase()
|
|
173
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
174
|
+
.replace(/^-+|-+$/g, "");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function truncateAtWord(slug: string, maxLength: number): string {
|
|
178
|
+
if (slug.length <= maxLength) return slug;
|
|
179
|
+
const words = slug.slice(0, maxLength + 1).split("-");
|
|
180
|
+
return words.length > 1
|
|
181
|
+
? words.slice(0, -1).join("-")
|
|
182
|
+
: slug.slice(0, maxLength);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function sessionName({ number, title }: Issue): string {
|
|
186
|
+
const shortTitle = truncateAtWord(slugify(title), maxSessionTitleLength);
|
|
187
|
+
return shortTitle ? `${number}-${shortTitle}` : String(number);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function implementSession(target: Issue, agent: AgentName): SessionSpec {
|
|
191
|
+
return {
|
|
192
|
+
agent,
|
|
193
|
+
target,
|
|
194
|
+
promptPath: sessionPromptPath,
|
|
195
|
+
kind: "implement",
|
|
196
|
+
nameSuffix: "",
|
|
197
|
+
transcriptPath: implementTranscriptPath(target.number, agent),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Arguments are passed as an array (no shell), so prompt text and issue
|
|
202
|
+
// titles can never be interpreted as shell syntax.
|
|
203
|
+
function reviewSession(target: Issue, agent: AgentName): SessionSpec {
|
|
204
|
+
return {
|
|
205
|
+
agent,
|
|
206
|
+
target,
|
|
207
|
+
promptPath: reviewPromptPath,
|
|
208
|
+
kind: "review",
|
|
209
|
+
nameSuffix: "-review",
|
|
210
|
+
transcriptPath: reviewTranscriptPath(target.number, agent),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function fixSession(target: Issue, agent: AgentName): SessionSpec {
|
|
215
|
+
return {
|
|
216
|
+
agent,
|
|
217
|
+
target,
|
|
218
|
+
promptPath: fixPromptPath,
|
|
219
|
+
kind: "fix",
|
|
220
|
+
nameSuffix: "-fix",
|
|
221
|
+
transcriptPath: fixTranscriptPath(target.number, agent),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// O_NOFOLLOW: a session runs with all tools allowed and could leave a
|
|
226
|
+
// symlink at the transcript path — never follow it when writing.
|
|
227
|
+
function writeTranscript(
|
|
228
|
+
stream: Readable | null,
|
|
229
|
+
transcriptPath: string,
|
|
230
|
+
tail: string[],
|
|
231
|
+
): void {
|
|
232
|
+
if (!stream) return;
|
|
233
|
+
let descriptor: number;
|
|
234
|
+
try {
|
|
235
|
+
descriptor = openSync(transcriptPath, transcriptFlags);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
tail.push(`could not write ${transcriptPath}: ${errorMessage(error)}`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const transcript = createWriteStream(transcriptPath, { fd: descriptor });
|
|
241
|
+
transcript.on("error", (error) => {
|
|
242
|
+
tail.push(`could not write ${transcriptPath}: ${error.message}`);
|
|
243
|
+
});
|
|
244
|
+
stream.on("data", (chunk: Buffer) => {
|
|
245
|
+
if (transcript.writable) transcript.write(chunk);
|
|
246
|
+
});
|
|
247
|
+
stream.on("close", () => {
|
|
248
|
+
if (transcript.writable) transcript.end();
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function startSession(spec: SessionSpec, tail: string[]): ChildProcess {
|
|
253
|
+
const command = agentCli(spec.agent, {
|
|
254
|
+
kind: spec.kind,
|
|
255
|
+
prompt: buildPrompt(spec.promptPath, spec.target.number),
|
|
256
|
+
name: `${sessionName(spec.target)}${spec.nameSuffix}`,
|
|
257
|
+
transcriptPath: spec.transcriptPath,
|
|
258
|
+
});
|
|
259
|
+
const session = spawn(command.binary, command.args, {
|
|
260
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
261
|
+
detached: true,
|
|
262
|
+
});
|
|
263
|
+
if (command.transcriptFromStdout) {
|
|
264
|
+
writeTranscript(session.stdout, spec.transcriptPath, tail);
|
|
265
|
+
}
|
|
266
|
+
keepTail(session.stdout, tail);
|
|
267
|
+
keepTail(session.stderr, tail);
|
|
268
|
+
return session;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function runSession(
|
|
272
|
+
spec: SessionSpec,
|
|
273
|
+
tail: string[],
|
|
274
|
+
handle: SessionHandle,
|
|
275
|
+
): Promise<number | null> {
|
|
276
|
+
return new Promise((resolve) => {
|
|
277
|
+
const session = startSession(spec, tail);
|
|
278
|
+
handle.current = session;
|
|
279
|
+
let settled = false;
|
|
280
|
+
const settle = (code: number | null): void => {
|
|
281
|
+
if (settled) return;
|
|
282
|
+
settled = true;
|
|
283
|
+
handle.current = null;
|
|
284
|
+
resolve(code);
|
|
285
|
+
};
|
|
286
|
+
session.on("error", (error) => {
|
|
287
|
+
tail.push(`failed to start: ${error.message}`);
|
|
288
|
+
settle(null);
|
|
289
|
+
});
|
|
290
|
+
session.on("exit", (code) => {
|
|
291
|
+
killLeftovers(session, tail);
|
|
292
|
+
void drainOutput(session).then(() => settle(code));
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The session runs in its own process group; whatever it left running
|
|
298
|
+
// (dev servers, watchers) would otherwise keep the lane open.
|
|
299
|
+
function killLeftovers(session: ChildProcess, tail: string[]): void {
|
|
300
|
+
try {
|
|
301
|
+
killProcessGroup(session, "SIGTERM");
|
|
302
|
+
} catch (error) {
|
|
303
|
+
tail.push(`could not stop leftover processes: ${errorMessage(error)}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function stopCurrentSession(handle: SessionHandle): void {
|
|
308
|
+
if (!handle.current) return;
|
|
309
|
+
try {
|
|
310
|
+
killProcessGroup(handle.current, "SIGTERM");
|
|
311
|
+
} catch (error) {
|
|
312
|
+
console.error(`could not stop the session: ${errorMessage(error)}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const shutdownSignals: NodeJS.Signals[] = ["SIGTERM", "SIGINT", "SIGHUP"];
|
|
317
|
+
|
|
318
|
+
// The parent holds the write end of stdin: when it closes, the parent is
|
|
319
|
+
// gone and the lane must not keep sessions running unattended. The stop
|
|
320
|
+
// path writes no result — the parent owns the run result.
|
|
321
|
+
// Returns a cleanup that releases stdin so a finished lane exits promptly.
|
|
322
|
+
function stopSessionOnShutdown(handle: SessionHandle): () => void {
|
|
323
|
+
let stopping = false;
|
|
324
|
+
const stop = (): void => {
|
|
325
|
+
if (stopping) return;
|
|
326
|
+
stopping = true;
|
|
327
|
+
stopCurrentSession(handle);
|
|
328
|
+
process.exit(exitCodeTerminated);
|
|
329
|
+
};
|
|
330
|
+
shutdownSignals.forEach((signal) => process.on(signal, stop));
|
|
331
|
+
process.stdin.on("end", stop);
|
|
332
|
+
process.stdin.on("close", stop);
|
|
333
|
+
process.stdin.resume();
|
|
334
|
+
return () => {
|
|
335
|
+
shutdownSignals.forEach((signal) => process.off(signal, stop));
|
|
336
|
+
process.stdin.off("end", stop);
|
|
337
|
+
process.stdin.off("close", stop);
|
|
338
|
+
process.stdin.destroy();
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function announceStage(handle: SessionHandle, stage: Stage): void {
|
|
343
|
+
console.log(stageMarker(handle.laneIssue, stage));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function errorMessage(error: unknown): string {
|
|
347
|
+
return error instanceof Error ? error.message : String(error);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function recordExitCode(
|
|
351
|
+
code: number | null,
|
|
352
|
+
label: string,
|
|
353
|
+
tail: string[],
|
|
354
|
+
): void {
|
|
355
|
+
if (code === 0) return;
|
|
356
|
+
tail.push(
|
|
357
|
+
code === null
|
|
358
|
+
? `${label} was killed by a signal`
|
|
359
|
+
: `${label} exited with code ${code}`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function git(args: string[]): string {
|
|
364
|
+
return execFileSync("git", args, {
|
|
365
|
+
encoding: "utf-8",
|
|
366
|
+
maxBuffer: gitOutputMaxBuffer,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function sha256(content: string | Buffer): string {
|
|
371
|
+
return createHash("sha256").update(content).digest("hex");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Symlinks are hashed by target path, never followed (e.g. to /dev/zero).
|
|
375
|
+
function untrackedFileContent(file: string): string | Buffer {
|
|
376
|
+
return lstatSync(file).isSymbolicLink()
|
|
377
|
+
? readlinkSync(file)
|
|
378
|
+
: readFileSync(file);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function untrackedFilesHash(): string {
|
|
382
|
+
const hash = createHash("sha256");
|
|
383
|
+
git(["ls-files", "--others", "--exclude-standard", "-z", ...outsidePlansDir])
|
|
384
|
+
.split("\0")
|
|
385
|
+
.filter(Boolean)
|
|
386
|
+
.forEach((file) =>
|
|
387
|
+
hash.update(file).update("\0").update(untrackedFileContent(file)),
|
|
388
|
+
);
|
|
389
|
+
return hash.digest("hex");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Status alone misses further edits to an already-modified file, so the
|
|
393
|
+
// tracked diff and the untracked files' contents are hashed as well.
|
|
394
|
+
function treeSnapshot(): TreeSnapshot {
|
|
395
|
+
return {
|
|
396
|
+
head: git(["rev-parse", "HEAD"]).trim(),
|
|
397
|
+
status: git([
|
|
398
|
+
"status",
|
|
399
|
+
"--porcelain",
|
|
400
|
+
"--untracked-files=all",
|
|
401
|
+
...outsidePlansDir,
|
|
402
|
+
]),
|
|
403
|
+
trackedDiffHash: sha256(
|
|
404
|
+
git(["diff", "HEAD", "--binary", ...outsidePlansDir]),
|
|
405
|
+
),
|
|
406
|
+
untrackedHash: untrackedFilesHash(),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function snapshotsMatch(before: TreeSnapshot, after: TreeSnapshot): boolean {
|
|
411
|
+
return (
|
|
412
|
+
before.head === after.head &&
|
|
413
|
+
before.status === after.status &&
|
|
414
|
+
before.trackedDiffHash === after.trackedDiffHash &&
|
|
415
|
+
before.untrackedHash === after.untrackedHash
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function removeStaleReviewReport(issue: number): void {
|
|
420
|
+
try {
|
|
421
|
+
rmSync(reviewReportPath(issue));
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function readReviewReport(issue: number): string {
|
|
428
|
+
const path = reviewReportPath(issue);
|
|
429
|
+
const { size } = statSync(path);
|
|
430
|
+
if (size > maxReviewReportBytes) {
|
|
431
|
+
throw new Error(`${path} is ${size} bytes (limit ${maxReviewReportBytes})`);
|
|
432
|
+
}
|
|
433
|
+
return readFileSync(path, "utf-8");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function verdictSection(report: string): string {
|
|
437
|
+
const lines = report.replace(/\r\n/g, "\n").split("\n");
|
|
438
|
+
const start = lines.findIndex((line) => line.trim() === "## Verdict");
|
|
439
|
+
if (start === -1) throw new Error("review report has no ## Verdict section");
|
|
440
|
+
const rest = lines.slice(start + 1);
|
|
441
|
+
const end = rest.findIndex((line) => /^#{1,2} /.test(line));
|
|
442
|
+
return (end === -1 ? rest : rest.slice(0, end)).join("\n");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function blockingCount(report: string): number {
|
|
446
|
+
const matches = [
|
|
447
|
+
...verdictSection(report).matchAll(/^blocking:[ \t]*(\d+)[ \t]*$/gm),
|
|
448
|
+
];
|
|
449
|
+
if (matches.length !== 1) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
`review verdict needs exactly one "blocking: N" line, found ${matches.length}`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
const count = Number(matches[0][1]);
|
|
455
|
+
if (!Number.isSafeInteger(count))
|
|
456
|
+
throw new Error("review blocking count is not an integer");
|
|
457
|
+
return count;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// The review runs --yolo over untrusted code, so the orchestrator — not the
|
|
461
|
+
// prompt — enforces that it leaves HEAD and the working tree untouched.
|
|
462
|
+
// Every error path fails the target (returns null). As with the implement
|
|
463
|
+
// session, a non-zero exit alone is not a failure: the report decides.
|
|
464
|
+
async function reviewTarget(
|
|
465
|
+
target: Issue,
|
|
466
|
+
tail: string[],
|
|
467
|
+
handle: SessionHandle,
|
|
468
|
+
): Promise<number | null> {
|
|
469
|
+
try {
|
|
470
|
+
removeStaleReviewReport(target.number);
|
|
471
|
+
const before = treeSnapshot();
|
|
472
|
+
const code = await runSession(
|
|
473
|
+
reviewSession(target, handle.agent),
|
|
474
|
+
tail,
|
|
475
|
+
handle,
|
|
476
|
+
);
|
|
477
|
+
recordExitCode(code, "review session", tail);
|
|
478
|
+
if (!snapshotsMatch(before, treeSnapshot())) {
|
|
479
|
+
tail.push(
|
|
480
|
+
"review failed: the review session changed HEAD or the working tree (it must be read-only)",
|
|
481
|
+
);
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
if (code === null) {
|
|
485
|
+
tail.push("review failed: the review session did not run to completion");
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
const blocking = blockingCount(readReviewReport(target.number));
|
|
489
|
+
tail.push(`review: ${blocking} blocking finding(s)`);
|
|
490
|
+
return blocking;
|
|
491
|
+
} catch (error) {
|
|
492
|
+
tail.push(`review failed: ${errorMessage(error)}`);
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function fixTarget(
|
|
498
|
+
target: Issue,
|
|
499
|
+
tail: string[],
|
|
500
|
+
handle: SessionHandle,
|
|
501
|
+
): Promise<void> {
|
|
502
|
+
const code = await runSession(fixSession(target, handle.agent), tail, handle);
|
|
503
|
+
recordExitCode(code, "fix session", tail);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function runTarget(
|
|
507
|
+
target: Issue,
|
|
508
|
+
tail: string[],
|
|
509
|
+
handle: SessionHandle,
|
|
510
|
+
): Promise<boolean> {
|
|
511
|
+
try {
|
|
512
|
+
writeLlmPlan(target.number);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
tail.push(
|
|
515
|
+
`could not write ${llmPlanPath(target.number)}: ${error instanceof Error ? error.message : String(error)}`,
|
|
516
|
+
);
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
announceStage(handle, "implement");
|
|
520
|
+
const code = await runSession(
|
|
521
|
+
implementSession(target, handle.agent),
|
|
522
|
+
tail,
|
|
523
|
+
handle,
|
|
524
|
+
);
|
|
525
|
+
// The agent CLI can exit non-zero after a session that finished normally
|
|
526
|
+
// (a shutdown race: ~/.copilot/logs shows "Cannot invoke native session
|
|
527
|
+
// after disposal has started" after a graceful shutdown). The progress log,
|
|
528
|
+
// not the exit code, decides whether the target is done — a session that
|
|
529
|
+
// really stopped early leaves unchecked steps. Keep the exit code in the
|
|
530
|
+
// failure output for diagnosis.
|
|
531
|
+
recordExitCode(code, handle.agent, tail);
|
|
532
|
+
if (!isDone(target.number)) return false;
|
|
533
|
+
announceStage(handle, "review");
|
|
534
|
+
const blocking = await reviewTarget(target, tail, handle);
|
|
535
|
+
if (blocking === null) return false;
|
|
536
|
+
if (blocking > 0) {
|
|
537
|
+
announceStage(handle, "fix");
|
|
538
|
+
await fixTarget(target, tail, handle);
|
|
539
|
+
}
|
|
540
|
+
return isDone(target.number);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// One session at a time, in dependency order. A failed target skips its
|
|
544
|
+
// dependents; independent targets still run. Stage markers carry the lane
|
|
545
|
+
// issue, which the parent matches exactly.
|
|
546
|
+
async function runInOrder(
|
|
547
|
+
ordered: Issue[],
|
|
548
|
+
handle: SessionHandle,
|
|
549
|
+
): Promise<RunOutcome> {
|
|
550
|
+
const states = new Map<number, RunState>(
|
|
551
|
+
ordered.map((issue) => [
|
|
552
|
+
issue.number,
|
|
553
|
+
isDone(issue.number) ? "done" : "pending",
|
|
554
|
+
]),
|
|
555
|
+
);
|
|
556
|
+
const tails = new Map<number, string[]>();
|
|
557
|
+
|
|
558
|
+
const setState = (issue: number, state: RunState): void => {
|
|
559
|
+
states.set(issue, state);
|
|
560
|
+
console.log(`${new Date().toISOString()} #${issue} ${state}`);
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
for (const target of ordered) {
|
|
564
|
+
if (states.get(target.number) === "done") continue;
|
|
565
|
+
if (target.dependsOn.some((dep) => states.get(dep) !== "done")) {
|
|
566
|
+
setState(target.number, "skipped");
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
setState(target.number, "running");
|
|
570
|
+
const tail: string[] = [];
|
|
571
|
+
tails.set(target.number, tail);
|
|
572
|
+
const succeeded = await runTarget(target, tail, handle);
|
|
573
|
+
setState(target.number, succeeded ? "done" : "failed");
|
|
574
|
+
}
|
|
575
|
+
return { states, tails };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function codeBlock(lines: string[]): string {
|
|
579
|
+
const longestBacktickRun = Math.max(
|
|
580
|
+
0,
|
|
581
|
+
...lines
|
|
582
|
+
.flatMap((line) => line.match(/`+/g) ?? [])
|
|
583
|
+
.map((run) => run.length),
|
|
584
|
+
);
|
|
585
|
+
const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
|
|
586
|
+
return [fence, ...lines, fence].join("\n");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function failureLines(tail: string[]): string {
|
|
590
|
+
return tail.length === 0
|
|
591
|
+
? "no output captured"
|
|
592
|
+
: `last ${tail.length} output lines:\n${codeBlock(tail)}`;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function reviewResult(issue: number): string {
|
|
596
|
+
try {
|
|
597
|
+
return `### Review\n\n${readReviewReport(issue).trim()}`;
|
|
598
|
+
} catch (error) {
|
|
599
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "";
|
|
600
|
+
return `### Review\n\ncould not read ${reviewReportPath(issue)}: ${errorMessage(error)}`;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function targetResult(
|
|
605
|
+
issue: number,
|
|
606
|
+
state: RunState,
|
|
607
|
+
tail: string[] = [],
|
|
608
|
+
): string {
|
|
609
|
+
const parts = [`#${issue} ${state}`];
|
|
610
|
+
if (existsSync(summaryPath(issue))) {
|
|
611
|
+
parts.push(readFileSync(summaryPath(issue), "utf-8").trim());
|
|
612
|
+
}
|
|
613
|
+
const review = reviewResult(issue);
|
|
614
|
+
if (review) parts.push(review);
|
|
615
|
+
if (state === "failed") parts.push(failureLines(tail));
|
|
616
|
+
return parts.join("\n");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function targetsIn(states: Map<number, RunState>, state: RunState): number[] {
|
|
620
|
+
return [...states]
|
|
621
|
+
.filter(([, targetState]) => targetState === state)
|
|
622
|
+
.map(([issue]) => issue);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function countWithRefs(issues: number[], label: string): string {
|
|
626
|
+
const refs = issues.length > 0 ? ` (${issueRefs(issues)})` : "";
|
|
627
|
+
return `${issues.length} ${label}${refs}`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function runResultLine(states: Map<number, RunState>): string {
|
|
631
|
+
const done = targetsIn(states, "done");
|
|
632
|
+
if (done.length === states.size) {
|
|
633
|
+
return `run result: all ${states.size} targets done`;
|
|
634
|
+
}
|
|
635
|
+
const failed = countWithRefs(targetsIn(states, "failed"), "failed");
|
|
636
|
+
const skipped = countWithRefs(targetsIn(states, "skipped"), "skipped");
|
|
637
|
+
return `run result: ${done.length} done, ${failed}, ${skipped}`;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function finalResult({ states, tails }: RunOutcome): string {
|
|
641
|
+
const targets = [...states].map(([issue, state]) =>
|
|
642
|
+
targetResult(issue, state, tails.get(issue)),
|
|
643
|
+
);
|
|
644
|
+
return ["--- summary", ...targets, runResultLine(states)].join("\n\n");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function reportFinalResult(parent: number, outcome: RunOutcome): void {
|
|
648
|
+
const result = finalResult(outcome);
|
|
649
|
+
console.log(`\n${result}`);
|
|
650
|
+
writeFileSync(resultPath(parent), `${result}\n`);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function run(parent: number, agent: AgentName): Promise<boolean> {
|
|
654
|
+
rmSync(resultPath(parent), { force: true });
|
|
655
|
+
const ordered = executionOrder(resolveTargets(parent));
|
|
656
|
+
await keepMacAwakeWhileRunning();
|
|
657
|
+
printWalkAwayBanner(agent);
|
|
658
|
+
const handle: SessionHandle = { agent, laneIssue: parent, current: null };
|
|
659
|
+
const releaseShutdownWatch = stopSessionOnShutdown(handle);
|
|
660
|
+
let outcome: RunOutcome;
|
|
661
|
+
try {
|
|
662
|
+
outcome = await runInOrder(ordered, handle);
|
|
663
|
+
} finally {
|
|
664
|
+
releaseShutdownWatch();
|
|
665
|
+
}
|
|
666
|
+
reportFinalResult(parent, outcome);
|
|
667
|
+
return [...outcome.states.values()].every((state) => state === "done");
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// A bad agent choice fails closed before any session starts.
|
|
671
|
+
async function runWithAgent(
|
|
672
|
+
parent: number,
|
|
673
|
+
choice: AgentChoice,
|
|
674
|
+
): Promise<boolean> {
|
|
675
|
+
if (!choice.ok) {
|
|
676
|
+
printFailures([choice.failure]);
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
return run(parent, choice.agent);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Internal: parallel.ts starts `lane <issue> --agent <name>` inside the
|
|
683
|
+
// issue's worktree, so the work never lands in the main copy.
|
|
684
|
+
async function runLaneCommand(
|
|
685
|
+
args: string[],
|
|
686
|
+
agentOption: string | null,
|
|
687
|
+
): Promise<void> {
|
|
688
|
+
if (args.length !== 1 || !/^\d+$/.test(args[0])) throw new Error(usage);
|
|
689
|
+
const parent = Number(args[0]);
|
|
690
|
+
const { title } = fetchIssue(parent);
|
|
691
|
+
if (!runsInOwnWorktree(parent, title)) {
|
|
692
|
+
throw new Error(
|
|
693
|
+
`lane #${parent} must run in its worktree ${worktreePath(parent, title)}`,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
mkdirSync(plansDir, { recursive: true });
|
|
697
|
+
process.exitCode = (await runWithAgent(parent, readAgent(agentOption)))
|
|
698
|
+
? 0
|
|
699
|
+
: 1;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function launchRequest(args: string[]): LaunchRequest {
|
|
703
|
+
try {
|
|
704
|
+
return parseLaunchRequest(args);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
throw new Error(`${errorMessage(error)}\n${usage}`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function launch(
|
|
711
|
+
command: string,
|
|
712
|
+
args: string[],
|
|
713
|
+
agentOption: string | null,
|
|
714
|
+
): Promise<void> {
|
|
715
|
+
const request = launchRequest(args);
|
|
716
|
+
mkdirSync(plansDir, { recursive: true });
|
|
717
|
+
|
|
718
|
+
if (command === "preflight")
|
|
719
|
+
process.exitCode = (await preflightMany(request, agentOption)) ? 0 : 1;
|
|
720
|
+
else if (command === "run")
|
|
721
|
+
process.exitCode = (await runMany(request, agentOption)) ? 0 : 1;
|
|
722
|
+
else throw new Error(usage);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Fails closed: an issue that cannot be fetched stops the listing, so no
|
|
726
|
+
// question is silently skipped.
|
|
727
|
+
function laneTargets(issue: number): Issue[] {
|
|
728
|
+
try {
|
|
729
|
+
return resolveTargets(issue);
|
|
730
|
+
} catch (error) {
|
|
731
|
+
throw new Error(
|
|
732
|
+
`could not fetch #${issue}: ${errorMessage(error).split("\n")[0]}`,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function targetQuestions(target: Issue): string[] {
|
|
738
|
+
return unansweredQuestionTexts(target.body).map(
|
|
739
|
+
(question) => `#${target.number} ${question}`,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Read-only: lists every lane target's unanswered open questions, so they can
|
|
744
|
+
// be asked before the slower pre-flight checks run.
|
|
745
|
+
function listQuestions(args: string[]): void {
|
|
746
|
+
const request = launchRequest(args);
|
|
747
|
+
const laneIssues = request.ready ? readyLaneIssues() : request.issues;
|
|
748
|
+
const questions = laneIssues.flatMap(laneTargets).flatMap(targetQuestions);
|
|
749
|
+
if (questions.length === 0) console.log("no unanswered questions");
|
|
750
|
+
else questions.forEach((question) => console.log(question));
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function splitAgentArguments(args: string[]): AgentArguments {
|
|
754
|
+
try {
|
|
755
|
+
return agentArguments(args);
|
|
756
|
+
} catch (error) {
|
|
757
|
+
throw new Error(`${errorMessage(error)}\n${usage}`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function main(): Promise<void> {
|
|
762
|
+
const [command, ...args] = process.argv.slice(2);
|
|
763
|
+
const { agentOption, rest } = splitAgentArguments(args);
|
|
764
|
+
if (command === "lane") await runLaneCommand(rest, agentOption);
|
|
765
|
+
else if (command === "questions") listQuestions(rest);
|
|
766
|
+
else await launch(command, rest, agentOption);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
main().catch((error: unknown) => {
|
|
770
|
+
console.error(error instanceof Error ? error.message : error);
|
|
771
|
+
process.exit(1);
|
|
772
|
+
});
|