@nyxa/nyx-agent 0.4.1 → 0.6.0

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.
Files changed (44) hide show
  1. package/README.md +58 -9
  2. package/dist/cli.js +13 -16
  3. package/dist/commands/init.js +112 -462
  4. package/dist/commands/run.js +17 -3
  5. package/dist/commands/update.js +1 -0
  6. package/dist/config/loadConfig.js +17 -3
  7. package/dist/config/schema.js +29 -146
  8. package/dist/runtime/files.js +1 -0
  9. package/dist/runtime/git.js +1 -0
  10. package/dist/runtime/gitLifecycle.js +19 -57
  11. package/dist/runtime/harness.js +26 -0
  12. package/dist/runtime/ledger.js +1 -0
  13. package/dist/runtime/paths.js +1 -12
  14. package/dist/runtime/prompts.js +103 -0
  15. package/dist/runtime/runPhase.js +85 -254
  16. package/dist/runtime/runPipeline.js +479 -0
  17. package/dist/runtime/schemas.js +52 -0
  18. package/dist/runtime/scm.js +80 -0
  19. package/dist/runtime/time.js +1 -0
  20. package/dist/runtime/validateResult.js +2 -3
  21. package/dist/runtime/workItems.js +43 -118
  22. package/package.json +2 -5
  23. package/dist/runtime/buildPrompt.js +0 -54
  24. package/dist/runtime/effectiveConfig.js +0 -14
  25. package/dist/runtime/renderTemplate.js +0 -28
  26. package/dist/runtime/runWorkflow.js +0 -680
  27. package/dist/runtime/validateWorkItem.js +0 -212
  28. package/dist/runtime/workItemAnnotations.js +0 -39
  29. package/docs/nyxagent-v0-spec.md +0 -742
  30. package/templates/default/prompts/closure.md +0 -30
  31. package/templates/default/prompts/execution.md +0 -11
  32. package/templates/default/prompts/finalize.md +0 -7
  33. package/templates/default/prompts/global-review.md +0 -24
  34. package/templates/default/prompts/global-revision.md +0 -9
  35. package/templates/default/prompts/pull-request.md +0 -23
  36. package/templates/default/prompts/repair-result.md +0 -29
  37. package/templates/default/prompts/review.md +0 -18
  38. package/templates/default/prompts/revision.md +0 -7
  39. package/templates/default/prompts/selection.md +0 -46
  40. package/templates/default/schemas/closure.schema.json +0 -35
  41. package/templates/default/schemas/global-review.schema.json +0 -60
  42. package/templates/default/schemas/pull-request.schema.json +0 -44
  43. package/templates/default/schemas/review.schema.json +0 -60
  44. package/templates/default/schemas/selection.schema.json +0 -135
@@ -0,0 +1,479 @@
1
+ import path from "node:path";
2
+ import pc from "picocolors";
3
+ import { loadConfig } from "../config/loadConfig.js";
4
+ import { ensureDir, pathExists, readText } from "./files.js";
5
+ import { deleteBranch, removeRunWorktree, setUpRunWorktree } from "./gitLifecycle.js";
6
+ import { markWorkItemCompleted, readWorkItemLedger, writeWorkItemLedger } from "./ledger.js";
7
+ import { getNyxDir, relativeToProject } from "./paths.js";
8
+ import { buildContextBlock, buildPhasePrompt, EXECUTION_PROMPT, GLOBAL_REVIEW_PROMPT, GLOBAL_REVISION_PROMPT, REVIEW_PROMPT, REVISION_PROMPT, SELECTION_PROMPT, truncateForPrompt } from "./prompts.js";
9
+ import { runAgentPhase } from "./runPhase.js";
10
+ import { GLOBAL_REVIEW_SCHEMA, REVIEW_SCHEMA, SELECTION_SCHEMA } from "./schemas.js";
11
+ import { commitAll, commitsAhead, createPullRequest, pushBranch, rangeDiff, stageAllAndDiff } from "./scm.js";
12
+ import { createRunId } from "./time.js";
13
+ import { filterAvailable, listGitHubIssues, resolveSelectedQueue } from "./workItems.js";
14
+ const MAX_CANDIDATES = 50;
15
+ const EXCERPT_CHARS = 800;
16
+ export function defaultPipelineDependencies() {
17
+ return {
18
+ listIssues: listGitHubIssues,
19
+ runPhase: runAgentPhase,
20
+ pushBranch,
21
+ createPullRequest
22
+ };
23
+ }
24
+ /**
25
+ * The fixed NyxAgent pipeline:
26
+ *
27
+ * select -> for each item: implement -> [review/revise] -> commit
28
+ * -> [global review/revise] -> open pull request
29
+ *
30
+ * Control flow lives here in code (not in config). The agent only implements,
31
+ * reviews, and revises; every git/gh side effect is performed by the engine.
32
+ */
33
+ export async function runPipeline(input = {}, deps = defaultPipelineDependencies()) {
34
+ const projectRoot = path.resolve(input.projectRoot ?? process.cwd());
35
+ const nyxDir = getNyxDir(projectRoot);
36
+ const configPath = input.configPath ?? path.join(nyxDir, "config.json");
37
+ const config = await loadConfig(configPath);
38
+ const harness = input.harness ?? config.harness;
39
+ const runId = createRunId();
40
+ const runDir = path.join(nyxDir, "runs", runId);
41
+ await ensureDir(runDir);
42
+ console.log(pc.bold(`NyxAgent run ${runId}`));
43
+ console.log(`Harness: ${harness} · model: ${config.model} · review: ${config.review}`);
44
+ const ledger = await readWorkItemLedger(nyxDir);
45
+ // 1. Selection runs read-only in the main checkout, before any branch exists.
46
+ const candidates = filterAvailable({
47
+ candidates: await deps.listIssues({
48
+ repo: config.tracker.repo,
49
+ maxCandidates: MAX_CANDIDATES,
50
+ excerptChars: EXCERPT_CHARS
51
+ }),
52
+ completedKeys: ledger.completed_work_item_keys
53
+ });
54
+ if (candidates.length === 0) {
55
+ console.log("No open work items available. Nothing to do.");
56
+ return;
57
+ }
58
+ const selected = await runSelection({
59
+ projectRoot,
60
+ runDir,
61
+ harness,
62
+ config,
63
+ candidates,
64
+ runPhase: deps.runPhase
65
+ });
66
+ if (selected.length === 0) {
67
+ console.log("Selection chose no work items. Nothing to do.");
68
+ return;
69
+ }
70
+ const planned = selected.slice(0, config.max_iterations);
71
+ console.log(`Selected ${planned.length} work item(s):`);
72
+ for (const item of planned) {
73
+ console.log(` - ${item.title} (#${item.number})`);
74
+ }
75
+ // 2. One branch + worktree per run (created only now that there is work).
76
+ const git = await setUpRunWorktree({
77
+ projectRoot,
78
+ runId,
79
+ base: config.base_branch
80
+ });
81
+ console.log(`Branch ${git.branch} (base ${git.base})`);
82
+ let success = false;
83
+ let producedCommits = false;
84
+ let currentLedger = ledger;
85
+ const completed = [];
86
+ try {
87
+ const executionGuidance = await loadExecutionGuidance(nyxDir);
88
+ for (const [index, item] of planned.entries()) {
89
+ const iterationNumber = index + 1;
90
+ const iterationDir = path.join(runDir, "iterations", String(iterationNumber).padStart(3, "0"));
91
+ console.log(pc.cyan(`\n[${iterationNumber}/${planned.length}] ${item.title} (#${item.number})`));
92
+ await runExecution({
93
+ iterationDir,
94
+ item,
95
+ guidance: executionGuidance,
96
+ git,
97
+ harness,
98
+ config,
99
+ runPhase: deps.runPhase
100
+ });
101
+ if (config.review === "each" || config.review === "both") {
102
+ await runReviewLoop({
103
+ iterationDir,
104
+ item,
105
+ git,
106
+ harness,
107
+ config,
108
+ maxAttempts: config.review_max_attempts,
109
+ runPhase: deps.runPhase
110
+ });
111
+ }
112
+ const { committed } = await commitAll({
113
+ cwd: git.worktree,
114
+ message: buildCommitMessage(item)
115
+ });
116
+ if (committed) {
117
+ producedCommits = true;
118
+ }
119
+ else {
120
+ console.log(pc.yellow(" No changes to commit for this item."));
121
+ }
122
+ currentLedger = markWorkItemCompleted({
123
+ ledger: currentLedger,
124
+ workItem: item
125
+ });
126
+ await writeWorkItemLedger(nyxDir, currentLedger);
127
+ completed.push(item);
128
+ }
129
+ if (config.review === "all" || config.review === "both") {
130
+ const corrections = await runGlobalReviewLoop({
131
+ runDir,
132
+ git,
133
+ harness,
134
+ config,
135
+ maxAttempts: config.review_max_attempts,
136
+ runPhase: deps.runPhase
137
+ });
138
+ if (corrections) {
139
+ producedCommits = true;
140
+ }
141
+ }
142
+ if (!producedCommits || (await commitsAhead(git.worktree, git.base)) === 0) {
143
+ console.log("\nRun produced no commits; skipping pull request.");
144
+ success = true;
145
+ return;
146
+ }
147
+ await deps.pushBranch({ cwd: git.worktree, branch: git.branch });
148
+ const prUrl = await deps.createPullRequest({
149
+ cwd: git.worktree,
150
+ repo: config.tracker.repo,
151
+ base: git.base,
152
+ head: git.branch,
153
+ title: buildPrTitle(completed),
154
+ body: buildPrBody(completed)
155
+ });
156
+ console.log(pc.green(`\nPull request opened: ${prUrl}`));
157
+ success = true;
158
+ }
159
+ catch (error) {
160
+ // A failed run that already produced commits is salvaged into a draft PR so
161
+ // the work is never stranded on an orphaned branch; the error still
162
+ // propagates so the exit code reflects the failure.
163
+ await salvageFailedRun({
164
+ error,
165
+ projectRoot,
166
+ git,
167
+ producedCommits,
168
+ completed,
169
+ config,
170
+ deps
171
+ });
172
+ throw error;
173
+ }
174
+ finally {
175
+ if (success) {
176
+ await removeRunWorktree({ projectRoot, worktree: git.worktree });
177
+ if (!producedCommits) {
178
+ await deleteBranch({ projectRoot, branch: git.branch });
179
+ }
180
+ }
181
+ }
182
+ }
183
+ /**
184
+ * Failure handling that preserves work. If the run produced commits, push the
185
+ * branch and open a DRAFT pull request describing why it failed, so a human can
186
+ * finish it. Otherwise just keep the branch/worktree for debugging. The branch
187
+ * and worktree are always kept on failure.
188
+ */
189
+ async function salvageFailedRun(input) {
190
+ const location = relativeToProject(input.projectRoot, input.git.worktree);
191
+ // Best-effort: never let salvage throw and mask the original failure.
192
+ let ahead = 0;
193
+ if (input.producedCommits) {
194
+ try {
195
+ ahead = await commitsAhead(input.git.worktree, input.git.base);
196
+ }
197
+ catch {
198
+ ahead = 0;
199
+ }
200
+ }
201
+ if (ahead === 0) {
202
+ console.log(pc.red(`\nRun failed. Branch ${input.git.branch} and worktree kept for debugging: ${location}`));
203
+ return;
204
+ }
205
+ const reason = input.error instanceof Error ? input.error.message : String(input.error);
206
+ try {
207
+ await input.deps.pushBranch({
208
+ cwd: input.git.worktree,
209
+ branch: input.git.branch
210
+ });
211
+ const url = await input.deps.createPullRequest({
212
+ cwd: input.git.worktree,
213
+ repo: input.config.tracker.repo,
214
+ base: input.git.base,
215
+ head: input.git.branch,
216
+ title: buildDraftPrTitle(input.completed),
217
+ body: buildDraftPrBody(input.completed, reason),
218
+ draft: true
219
+ });
220
+ console.log(pc.yellow(`\nRun failed, but the work was salvaged into a DRAFT pull request: ${url}`));
221
+ console.log(pc.yellow(`Branch ${input.git.branch} and worktree kept: ${location}`));
222
+ }
223
+ catch (salvageError) {
224
+ const detail = salvageError instanceof Error
225
+ ? salvageError.message
226
+ : String(salvageError);
227
+ console.log(pc.red(`\nRun failed, and salvaging the work into a draft pull request also failed: ${detail}`));
228
+ console.log(pc.red(`Branch ${input.git.branch} and worktree kept for debugging: ${location}`));
229
+ }
230
+ }
231
+ async function runSelection(input) {
232
+ const context = buildContextBlock([
233
+ ["Repository", input.config.tracker.repo],
234
+ ["Max work items this run", input.config.max_iterations],
235
+ [
236
+ "Available candidates",
237
+ input.candidates.map((candidate) => ({
238
+ key: candidate.key,
239
+ number: candidate.number,
240
+ title: candidate.title,
241
+ labels: candidate.labels,
242
+ excerpt: candidate.excerpt
243
+ }))
244
+ ]
245
+ ]);
246
+ const result = await input.runPhase({
247
+ phaseId: "selection",
248
+ phaseDir: path.join(input.runDir, "selection"),
249
+ workdir: input.projectRoot,
250
+ harness: input.harness,
251
+ model: input.config.model,
252
+ reasoning: input.config.reasoning_effort,
253
+ capability: "readonly",
254
+ prompt: buildPhasePrompt({
255
+ guidance: SELECTION_PROMPT,
256
+ context,
257
+ schema: SELECTION_SCHEMA
258
+ }),
259
+ schema: SELECTION_SCHEMA
260
+ });
261
+ if (!result.ok) {
262
+ throw new Error(result.error);
263
+ }
264
+ const value = result.result;
265
+ if (value.outcome === "no_work") {
266
+ return [];
267
+ }
268
+ const resolved = resolveSelectedQueue({
269
+ keys: value.work_item_keys,
270
+ candidates: input.candidates
271
+ });
272
+ if (!resolved.ok) {
273
+ throw new Error(`Selection produced an invalid queue: ${resolved.error}`);
274
+ }
275
+ return resolved.queue;
276
+ }
277
+ async function runExecution(input) {
278
+ const context = buildContextBlock([
279
+ ["Work item", workItemSummary(input.item)],
280
+ ["Issue description", input.item.excerpt ?? "(no description provided)"],
281
+ ["Working directory", input.git.worktree],
282
+ ["Branch", `${input.git.branch} (base ${input.git.base})`]
283
+ ]);
284
+ const result = await input.runPhase({
285
+ phaseId: "execution",
286
+ phaseDir: path.join(input.iterationDir, "execution"),
287
+ workdir: input.git.worktree,
288
+ harness: input.harness,
289
+ model: input.config.model,
290
+ reasoning: input.config.reasoning_effort,
291
+ capability: "write",
292
+ prompt: buildPhasePrompt({ guidance: input.guidance, context })
293
+ });
294
+ if (!result.ok) {
295
+ throw new Error(result.error);
296
+ }
297
+ }
298
+ async function runReviewLoop(input) {
299
+ for (let attempt = 1; attempt <= input.maxAttempts; attempt += 1) {
300
+ const diff = await stageAllAndDiff(input.git.worktree);
301
+ const reviewResult = await input.runPhase({
302
+ phaseId: "review",
303
+ phaseDir: path.join(input.iterationDir, `review-${attempt}`),
304
+ workdir: input.git.worktree,
305
+ harness: input.harness,
306
+ model: input.config.model,
307
+ reasoning: input.config.reasoning_effort,
308
+ capability: "readonly",
309
+ prompt: buildPhasePrompt({
310
+ guidance: REVIEW_PROMPT,
311
+ context: buildContextBlock([
312
+ ["Work item", workItemSummary(input.item)],
313
+ ["Uncommitted changes (diff)", truncateForPrompt(diff || "(no changes)")]
314
+ ]),
315
+ schema: REVIEW_SCHEMA
316
+ }),
317
+ schema: REVIEW_SCHEMA
318
+ });
319
+ if (!reviewResult.ok) {
320
+ throw new Error(reviewResult.error);
321
+ }
322
+ const review = reviewResult.result;
323
+ console.log(` review: ${review.outcome}`);
324
+ if (review.outcome === "approved") {
325
+ return;
326
+ }
327
+ if (attempt === input.maxAttempts) {
328
+ throw new Error(`Review for #${input.item.number} not approved after ${input.maxAttempts} attempts: ${review.summary}${formatRequiredChanges(review.required_changes)}`);
329
+ }
330
+ const revision = await input.runPhase({
331
+ phaseId: "revision",
332
+ phaseDir: path.join(input.iterationDir, `revision-${attempt}`),
333
+ workdir: input.git.worktree,
334
+ harness: input.harness,
335
+ model: input.config.model,
336
+ reasoning: input.config.reasoning_effort,
337
+ capability: "write",
338
+ prompt: buildPhasePrompt({
339
+ guidance: REVISION_PROMPT,
340
+ context: buildContextBlock([
341
+ ["Work item", workItemSummary(input.item)],
342
+ ["Required changes", review.required_changes ?? []]
343
+ ])
344
+ })
345
+ });
346
+ if (!revision.ok) {
347
+ throw new Error(revision.error);
348
+ }
349
+ }
350
+ }
351
+ async function runGlobalReviewLoop(input) {
352
+ let committedCorrections = false;
353
+ for (let attempt = 1; attempt <= input.maxAttempts; attempt += 1) {
354
+ const diff = await rangeDiff(input.git.worktree, input.git.base);
355
+ const reviewResult = await input.runPhase({
356
+ phaseId: "global_review",
357
+ phaseDir: path.join(input.runDir, "final", `global-review-${attempt}`),
358
+ workdir: input.git.worktree,
359
+ harness: input.harness,
360
+ model: input.config.model,
361
+ reasoning: input.config.reasoning_effort,
362
+ capability: "readonly",
363
+ prompt: buildPhasePrompt({
364
+ guidance: GLOBAL_REVIEW_PROMPT,
365
+ context: buildContextBlock([
366
+ ["Run branch", `${input.git.branch} (base ${input.git.base})`],
367
+ [
368
+ "Combined run diff (base...HEAD)",
369
+ truncateForPrompt(diff || "(no changes)")
370
+ ]
371
+ ]),
372
+ schema: GLOBAL_REVIEW_SCHEMA
373
+ }),
374
+ schema: GLOBAL_REVIEW_SCHEMA
375
+ });
376
+ if (!reviewResult.ok) {
377
+ throw new Error(reviewResult.error);
378
+ }
379
+ const review = reviewResult.result;
380
+ console.log(`global review: ${review.outcome}`);
381
+ if (review.outcome === "approved") {
382
+ return committedCorrections;
383
+ }
384
+ if (attempt === input.maxAttempts) {
385
+ throw new Error(`Global review not approved after ${input.maxAttempts} attempts: ${review.summary}${formatRequiredChanges(review.required_changes)}`);
386
+ }
387
+ const revision = await input.runPhase({
388
+ phaseId: "global_revision",
389
+ phaseDir: path.join(input.runDir, "final", `global-revision-${attempt}`),
390
+ workdir: input.git.worktree,
391
+ harness: input.harness,
392
+ model: input.config.model,
393
+ reasoning: input.config.reasoning_effort,
394
+ capability: "write",
395
+ prompt: buildPhasePrompt({
396
+ guidance: GLOBAL_REVISION_PROMPT,
397
+ context: buildContextBlock([
398
+ ["Required changes", review.required_changes ?? []]
399
+ ])
400
+ })
401
+ });
402
+ if (!revision.ok) {
403
+ throw new Error(revision.error);
404
+ }
405
+ const { committed } = await commitAll({
406
+ cwd: input.git.worktree,
407
+ message: "Apply global review corrections"
408
+ });
409
+ if (committed) {
410
+ committedCorrections = true;
411
+ }
412
+ }
413
+ return committedCorrections;
414
+ }
415
+ async function loadExecutionGuidance(nyxDir) {
416
+ const override = path.join(nyxDir, "prompts", "execution.md");
417
+ if (await pathExists(override)) {
418
+ const text = (await readText(override)).trim();
419
+ if (text) {
420
+ return text;
421
+ }
422
+ }
423
+ return EXECUTION_PROMPT;
424
+ }
425
+ function workItemSummary(item) {
426
+ return {
427
+ key: item.key,
428
+ number: item.number,
429
+ title: item.title,
430
+ locator: item.source.locator,
431
+ url: item.url,
432
+ labels: item.labels
433
+ };
434
+ }
435
+ function buildCommitMessage(item) {
436
+ return `${item.title}\n\nWork item: ${item.source.locator}`;
437
+ }
438
+ function buildPrTitle(items) {
439
+ if (items.length === 1) {
440
+ return items[0].title;
441
+ }
442
+ return `NyxAgent: ${items.length} work items`;
443
+ }
444
+ function buildPrBody(items) {
445
+ const list = items.map((item) => `- ${item.title} (#${item.number})`).join("\n");
446
+ const closes = items.map((item) => `Closes #${item.number}`).join("\n");
447
+ return [
448
+ "Automated changes by NyxAgent.",
449
+ "",
450
+ "## Work items",
451
+ "",
452
+ list,
453
+ "",
454
+ closes
455
+ ].join("\n");
456
+ }
457
+ function buildDraftPrTitle(items) {
458
+ return `[Draft] ${buildPrTitle(items)}`;
459
+ }
460
+ function buildDraftPrBody(items, reason) {
461
+ return [
462
+ "> [!WARNING]",
463
+ "> This pull request was opened automatically by NyxAgent after the run",
464
+ "> **failed review**. The work is preserved here for a human to finish.",
465
+ "",
466
+ `**Why the run failed:** ${reason}`,
467
+ "",
468
+ buildPrBody(items)
469
+ ].join("\n");
470
+ }
471
+ /** Render review `required_changes` as a bullet list to append to a failure message. */
472
+ function formatRequiredChanges(changes) {
473
+ if (!changes || changes.length === 0) {
474
+ return "";
475
+ }
476
+ return `\n\nUnresolved review feedback:\n${changes
477
+ .map((change) => `- ${change}`)
478
+ .join("\n")}`;
479
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * JSON Schemas for the structured results NyxAgent requires from review-style
3
+ * phases. They are embedded (not files on disk) so they cannot drift from the
4
+ * engine and are shown verbatim to the agent in the phase prompt.
5
+ */
6
+ export const SELECTION_SCHEMA = {
7
+ $schema: "https://json-schema.org/draft/2020-12/schema",
8
+ type: "object",
9
+ required: ["outcome"],
10
+ properties: {
11
+ outcome: { type: "string", enum: ["selected", "no_work"] },
12
+ work_item_keys: {
13
+ type: "array",
14
+ items: { type: "string" },
15
+ description: "Ordered keys of the chosen candidates (prerequisites first)."
16
+ }
17
+ },
18
+ allOf: [
19
+ {
20
+ if: { properties: { outcome: { const: "selected" } } },
21
+ then: { required: ["work_item_keys"] }
22
+ }
23
+ ],
24
+ additionalProperties: true
25
+ };
26
+ const reviewSchema = {
27
+ $schema: "https://json-schema.org/draft/2020-12/schema",
28
+ type: "object",
29
+ required: ["outcome", "summary"],
30
+ properties: {
31
+ outcome: { type: "string", enum: ["approved", "changes_requested"] },
32
+ summary: {
33
+ type: "string",
34
+ minLength: 1,
35
+ description: "A brief assessment of the work."
36
+ },
37
+ required_changes: {
38
+ type: "array",
39
+ items: { type: "string" },
40
+ description: 'Specific, actionable changes (required when outcome is "changes_requested").'
41
+ }
42
+ },
43
+ allOf: [
44
+ {
45
+ if: { properties: { outcome: { const: "changes_requested" } } },
46
+ then: { required: ["required_changes"] }
47
+ }
48
+ ],
49
+ additionalProperties: true
50
+ };
51
+ export const REVIEW_SCHEMA = reviewSchema;
52
+ export const GLOBAL_REVIEW_SCHEMA = reviewSchema;
@@ -0,0 +1,80 @@
1
+ import { execa } from "execa";
2
+ /**
3
+ * Deterministic source-control side effects. NyxAgent — not the agent — performs
4
+ * every commit, push, and pull request, so closing the loop never depends on an
5
+ * LLM remembering to. All commands run with the process's own permissions (no
6
+ * sandbox), from the run worktree.
7
+ */
8
+ async function git(cwd, args, label) {
9
+ const result = await execa("git", args, { cwd, reject: false });
10
+ if (result.exitCode !== 0) {
11
+ const detail = (result.stderr || result.stdout || "unknown error").trim();
12
+ throw new Error(`git ${label} failed: ${detail}`);
13
+ }
14
+ return { stdout: result.stdout };
15
+ }
16
+ /** Stage everything and return the staged diff (captures new files too). */
17
+ export async function stageAllAndDiff(cwd) {
18
+ await git(cwd, ["add", "-A"], "add");
19
+ const diff = await git(cwd, ["diff", "--cached"], "diff --cached");
20
+ return diff.stdout;
21
+ }
22
+ /** The committed diff for the run: everything on HEAD that is not on `base`. */
23
+ export async function rangeDiff(cwd, base) {
24
+ const diff = await git(cwd, ["diff", `${base}..HEAD`], "diff range");
25
+ return diff.stdout;
26
+ }
27
+ /**
28
+ * Stage all changes and commit them. Returns whether a commit was actually made
29
+ * (false when there was nothing to commit).
30
+ */
31
+ export async function commitAll(input) {
32
+ await git(input.cwd, ["add", "-A"], "add");
33
+ const staged = await execa("git", ["diff", "--cached", "--quiet"], { cwd: input.cwd, reject: false });
34
+ // exit 0 = no staged changes, exit 1 = staged changes present.
35
+ if (staged.exitCode === 0) {
36
+ return { committed: false };
37
+ }
38
+ // Split a "subject\n\nbody" message into conventional subject + body args.
39
+ const [subject, ...rest] = input.message.split("\n\n");
40
+ const commitArgs = ["commit", "-m", subject];
41
+ const body = rest.join("\n\n").trim();
42
+ if (body) {
43
+ commitArgs.push("-m", body);
44
+ }
45
+ await git(input.cwd, commitArgs, "commit");
46
+ return { committed: true };
47
+ }
48
+ /** Number of commits on HEAD ahead of `base`. */
49
+ export async function commitsAhead(cwd, base) {
50
+ const result = await git(cwd, ["rev-list", "--count", `${base}..HEAD`], "rev-list");
51
+ return Number.parseInt(result.stdout.trim(), 10) || 0;
52
+ }
53
+ export async function pushBranch(input) {
54
+ await git(input.cwd, ["push", "-u", "origin", input.branch], "push");
55
+ }
56
+ export async function createPullRequest(input) {
57
+ const args = [
58
+ "pr",
59
+ "create",
60
+ "--repo",
61
+ input.repo,
62
+ "--base",
63
+ input.base,
64
+ "--head",
65
+ input.head,
66
+ "--title",
67
+ input.title,
68
+ "--body",
69
+ input.body
70
+ ];
71
+ if (input.draft) {
72
+ args.push("--draft");
73
+ }
74
+ const result = await execa("gh", args, { cwd: input.cwd, reject: false });
75
+ if (result.exitCode !== 0) {
76
+ const detail = (result.stderr || result.stdout || "unknown error").trim();
77
+ throw new Error(`gh pr create failed: ${detail}`);
78
+ }
79
+ return result.stdout.trim();
80
+ }
@@ -1,3 +1,4 @@
1
+ /** Builds a filesystem-safe, lexicographically sortable run id from a timestamp. */
1
2
  export function createRunId(date = new Date()) {
2
3
  return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[:]/g, "-");
3
4
  }
@@ -1,7 +1,6 @@
1
- import { readFile } from "node:fs/promises";
1
+ /** Validates a parsed phase result against its JSON Schema using Ajv. */
2
2
  import { Ajv2020 } from "ajv/dist/2020.js";
3
- export async function validateAgainstSchema(schemaPath, value) {
4
- const schema = JSON.parse(await readFile(schemaPath, "utf8"));
3
+ export function validateAgainstSchema(schema, value) {
5
4
  const ajv = new Ajv2020({ allErrors: true });
6
5
  const validate = ajv.compile(schema);
7
6
  const valid = validate(value);