ai-foreman 1.6.2 → 1.7.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 (51) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude.d.ts +5 -1
  3. package/dist/adapters/claude.js +41 -0
  4. package/dist/adapters/codex.d.ts +26 -28
  5. package/dist/adapters/codex.js +209 -148
  6. package/dist/adapters/recovering.d.ts +3 -1
  7. package/dist/adapters/recovering.js +2 -0
  8. package/dist/adapters/types.d.ts +29 -0
  9. package/dist/branch/planner.js +1 -1
  10. package/dist/branch/runner.d.ts +6 -0
  11. package/dist/branch/runner.js +149 -16
  12. package/dist/buildRuns.d.ts +9 -2
  13. package/dist/buildRuns.js +110 -7
  14. package/dist/cli/start.d.ts +4 -0
  15. package/dist/cli/start.js +283 -28
  16. package/dist/cli/status.js +25 -2
  17. package/dist/cli/tickets.d.ts +1 -0
  18. package/dist/cli/tickets.js +67 -27
  19. package/dist/foreman.d.ts +18 -3
  20. package/dist/foreman.js +112 -5
  21. package/dist/log.d.ts +1 -1
  22. package/dist/operationRecovery.d.ts +25 -0
  23. package/dist/operationRecovery.js +46 -0
  24. package/dist/outcomes.d.ts +16 -0
  25. package/dist/outcomes.js +27 -0
  26. package/dist/qaReview.d.ts +52 -0
  27. package/dist/qaReview.js +103 -0
  28. package/dist/qaSnapshot.d.ts +19 -0
  29. package/dist/qaSnapshot.js +88 -0
  30. package/dist/sessionLifecycle.d.ts +37 -0
  31. package/dist/sessionLifecycle.js +104 -0
  32. package/dist/statusReporter.d.ts +40 -0
  33. package/dist/statusReporter.js +31 -0
  34. package/dist/ticketPopulation.d.ts +47 -0
  35. package/dist/ticketPopulation.js +205 -0
  36. package/dist/tickets/blockers.d.ts +1 -1
  37. package/dist/tickets/blockers.js +2 -0
  38. package/dist/tickets/commands.d.ts +1 -0
  39. package/dist/tickets/commands.js +30 -1
  40. package/dist/tickets/delivery.d.ts +36 -0
  41. package/dist/tickets/delivery.js +204 -4
  42. package/dist/tickets/queue.d.ts +3 -0
  43. package/dist/tickets/queue.js +50 -1
  44. package/dist/tickets/stateDb.d.ts +2 -1
  45. package/dist/tickets/stateDb.js +24 -1
  46. package/dist/tickets/ticketSchema.d.ts +44 -0
  47. package/dist/tickets/ticketSchema.js +10 -0
  48. package/dist/tickets/validate.js +1 -1
  49. package/dist/workflowDb.d.ts +113 -0
  50. package/dist/workflowDb.js +261 -0
  51. package/package.json +16 -3
@@ -0,0 +1,205 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { stringify } from "yaml";
5
+ import { WorkflowDb } from "./workflowDb.js";
6
+ import { cmdRender, cmdValidate } from "./tickets/commands.js";
7
+ import { validateTicketDefs } from "./tickets/ticketLoader.js";
8
+ import { normalizeDeliveryConfig, validateDeliveryConfig } from "./tickets/delivery.js";
9
+ import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
10
+ import { StateDb } from "./tickets/stateDb.js";
11
+ export const TICKET_POPULATION_PROPOSAL_START = "RAFI_TICKET_POPULATION_PROPOSAL_START";
12
+ export const TICKET_POPULATION_PROPOSAL_END = "RAFI_TICKET_POPULATION_PROPOSAL_END";
13
+ export function extractTicketPopulationProposal(output) {
14
+ const start = output.lastIndexOf(TICKET_POPULATION_PROPOSAL_START);
15
+ const end = output.lastIndexOf(TICKET_POPULATION_PROPOSAL_END);
16
+ if (start < 0 || end <= start)
17
+ throw new Error(`ticket-maker output is missing ${TICKET_POPULATION_PROPOSAL_START}/${TICKET_POPULATION_PROPOSAL_END}`);
18
+ const raw = output.slice(start + TICKET_POPULATION_PROPOSAL_START.length, end).trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
19
+ try {
20
+ return JSON.parse(raw);
21
+ }
22
+ catch (error) {
23
+ throw new Error(`ticket-maker proposal is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
24
+ }
25
+ }
26
+ export function validateTicketPopulationProposal(proposal, plan, existing) {
27
+ const issues = [];
28
+ if (proposal.version !== 1 || proposal.plan_id !== plan.plan_id || proposal.revision !== plan.revision)
29
+ issues.push("proposal plan identity does not match the approved structured plan");
30
+ const planned = new Set(plan.slices.map((slice) => slice.slice_ref));
31
+ const mapped = new Set();
32
+ for (const ticket of proposal.tickets) {
33
+ if (!planned.has(ticket.slice_ref))
34
+ issues.push(`unknown slice mapping ${ticket.slice_ref}`);
35
+ if (mapped.has(ticket.slice_ref))
36
+ issues.push(`duplicate slice mapping ${ticket.slice_ref}`);
37
+ else
38
+ mapped.add(ticket.slice_ref);
39
+ for (const dep of ticket.depends_on)
40
+ if (!planned.has(dep))
41
+ issues.push(`slice ${ticket.slice_ref} depends on unknown slice ${dep}`);
42
+ }
43
+ for (const ref of planned)
44
+ if (!mapped.has(ref))
45
+ issues.push(`missing ticket mapping for slice ${ref}`);
46
+ const expectedRetirements = existing.filter((ticket) => ticket.plan_ref?.plan_id === plan.plan_id && !planned.has(ticket.plan_ref.slice_ref) && ticket.plan_ref.revision < plan.revision).map((ticket) => ticket.id).sort();
47
+ if (JSON.stringify([...proposal.retirements].sort()) !== JSON.stringify(expectedRetirements))
48
+ issues.push(`retirements must exactly match removed populated slices: ${expectedRetirements.join(",") || "none"}`);
49
+ return issues;
50
+ }
51
+ export function materializeTicketPopulation(proposal, plan, existing) {
52
+ const issues = validateTicketPopulationProposal(proposal, plan, existing);
53
+ if (issues.length)
54
+ throw new Error(issues.join("; "));
55
+ const byPlanSlice = new Map(existing.filter((ticket) => ticket.plan_ref).map((ticket) => [`${ticket.plan_ref.plan_id}:${ticket.plan_ref.slice_ref}`, ticket]));
56
+ const reserved = new Set(existing.map((ticket) => ticket.id));
57
+ const sliceToTicket = new Map();
58
+ for (const slice of proposal.tickets) {
59
+ const retained = byPlanSlice.get(`${plan.plan_id}:${slice.slice_ref}`);
60
+ const id = retained?.id ?? allocateTicketId(reserved);
61
+ reserved.add(id);
62
+ sliceToTicket.set(slice.slice_ref, id);
63
+ }
64
+ const materialized = proposal.tickets.map((slice, index) => {
65
+ const retained = byPlanSlice.get(`${plan.plan_id}:${slice.slice_ref}`);
66
+ return {
67
+ ...(retained ?? {}), id: sliceToTicket.get(slice.slice_ref), order: retained?.order ?? nextOrder(existing, index),
68
+ title: slice.title, area: slice.area, priority: slice.priority, size: slice.size, risk: slice.risk,
69
+ depends_on: slice.depends_on.map((ref) => sliceToTicket.get(ref)), summary: slice.summary,
70
+ acceptance: [...slice.acceptance], required_tests: [...slice.required_tests], likely_files: [...slice.likely_files],
71
+ rollback: slice.rollback, notes: slice.notes, plan_ref: { plan_id: plan.plan_id, revision: plan.revision, slice_ref: slice.slice_ref },
72
+ };
73
+ });
74
+ const ownedIds = new Set(materialized.map((ticket) => ticket.id));
75
+ const tickets = [...existing.filter((ticket) => !ownedIds.has(ticket.id)), ...materialized].sort((a, b) => a.order - b.order);
76
+ const delivery = normalizeDeliveryConfig({
77
+ version: 1, plan: { plan_id: plan.plan_id, revision: plan.revision },
78
+ units: plan.delivery_units.map((unit) => ({
79
+ id: unit.id, tickets: unit.slice_refs.map((ref) => sliceToTicket.get(ref)), branch_mode: unit.branch_mode,
80
+ completion: unit.completion, provider: unit.provider, pr_ready: unit.pr_ready, merge_method: unit.merge_method,
81
+ cleanup: unit.cleanup, depends_on: unit.depends_on, dependency_mode: unit.dependency_mode,
82
+ })),
83
+ stacks: plan.stacks.map((stack) => ({ id: stack.stack_id, name: stack.name, units: stack.units, status: "planned" })),
84
+ });
85
+ const validation = [...validateTicketDefs(tickets).map((issue) => `${issue.path}: ${issue.message}`), ...validateDeliveryConfig(delivery, tickets).map((issue) => `${issue.path}: ${issue.message}`)];
86
+ if (validation.length)
87
+ throw new Error(`materialized ticket population is invalid:\n${validation.map((issue) => `- ${issue}`).join("\n")}`);
88
+ return { tickets, delivery, retirements: [...proposal.retirements], sliceToTicket };
89
+ }
90
+ export function authorizeTicketRetirements(retirements, input) {
91
+ if (!retirements.length)
92
+ return;
93
+ if (input.computerRun) {
94
+ const authorized = [...(input.authorizedIds ?? [])].sort();
95
+ if (JSON.stringify(authorized) !== JSON.stringify([...retirements].sort()))
96
+ throw new Error(`computer-run retirement requires exact authorized ticket IDs: ${retirements.join(",")}`);
97
+ }
98
+ else if (!input.interactiveConfirmed)
99
+ throw new Error(`retirement confirmation required for: ${retirements.join(",")}`);
100
+ }
101
+ export function applyTicketPopulation(projectDir, materialized, runId, now = new Date()) {
102
+ const root = resolve(projectDir);
103
+ const config = loadTicketsConfig(root);
104
+ const paths = resolveTicketPaths(config, root);
105
+ const workflow = new WorkflowDb(root);
106
+ let actualRunId = runId;
107
+ let lease;
108
+ try {
109
+ if (!actualRunId)
110
+ actualRunId = workflow.createRun({ kind: "ticket-populate", originalWork: { tickets: materialized.tickets.map((ticket) => ticket.id) }, state: {} }, now).runId;
111
+ lease = workflow.acquireLease(actualRunId, undefined, now);
112
+ const deliveryPath = join(root, ".tickets", "delivery.yaml");
113
+ const stage = join(root, ".rafi", "staging", randomUUID());
114
+ mkdirSync(stage, { recursive: true });
115
+ const stagedTickets = join(stage, "tickets.yaml");
116
+ const stagedDelivery = join(stage, "delivery.yaml");
117
+ const tx = workflow.beginPublication(actualRunId, { files: [{ staged: stagedTickets, target: paths.tickets }, { staged: stagedDelivery, target: deliveryPath }], stage }, {
118
+ [paths.tickets]: fileDigest(paths.tickets), [deliveryPath]: fileDigest(deliveryPath),
119
+ }, now);
120
+ fsyncWrite(stagedTickets, stringify({ tickets: materialized.tickets }, { lineWidth: 120 }));
121
+ fsyncWrite(stagedDelivery, stringify(materialized.delivery, { lineWidth: 120 }));
122
+ workflow.updatePublication(tx.transactionId, "staged", now);
123
+ const db = new StateDb(paths.stateDb);
124
+ try {
125
+ db.transaction(() => {
126
+ const at = now.toISOString();
127
+ for (const ticket of materialized.tickets)
128
+ if (!db.getState(ticket.id))
129
+ db.upsertState(ticket.id, { status: "planned", updated_by: "rafi tickets populate" }, at);
130
+ for (const id of materialized.retirements) {
131
+ const prior = db.getState(id);
132
+ if (prior?.status === "done")
133
+ continue;
134
+ db.upsertState(id, { status: "obsolete", completed_at: at, updated_by: "rafi tickets populate" }, at);
135
+ db.insertEvent({ timestamp: at, actor: "rafi tickets populate", ticket_id: id, event_type: "obsolete", old_status: prior?.status ?? "planned", new_status: "obsolete", summary: "Removed by approved plan revision", validation: null, evidence: null, payload_json: JSON.stringify({ runId: actualRunId }) });
136
+ }
137
+ });
138
+ }
139
+ finally {
140
+ db.close();
141
+ }
142
+ workflow.updatePublication(tx.transactionId, "tracker_committed", now);
143
+ publishStaged(stagedTickets, paths.tickets);
144
+ publishStaged(stagedDelivery, deliveryPath);
145
+ workflow.updatePublication(tx.transactionId, "published", now);
146
+ cmdRender(root);
147
+ const validation = cmdValidate(root);
148
+ if (!validation.clean)
149
+ throw new Error(`published ticket population failed validation: ${validation.issues.map((issue) => issue.message).join("; ")}`);
150
+ workflow.updatePublication(tx.transactionId, "committed", now);
151
+ workflow.transition(actualRunId, { status: "completed", checkpoint: "population-committed", remainingWork: {}, state: { transactionId: tx.transactionId } }, now);
152
+ rmSync(stage, { recursive: true, force: true });
153
+ return { runId: actualRunId, transactionId: tx.transactionId };
154
+ }
155
+ finally {
156
+ if (lease)
157
+ workflow.releaseLease(lease, now);
158
+ workflow.close();
159
+ }
160
+ }
161
+ /** Startup recovery deterministically finishes publications after tracker commit. */
162
+ export function recoverTicketPublications(projectDir) {
163
+ const workflow = new WorkflowDb(projectDir);
164
+ const recovered = [];
165
+ try {
166
+ for (const tx of workflow.incompletePublications()) {
167
+ const intent = tx.intent;
168
+ if (["tracker_committed", "published"].includes(tx.status)) {
169
+ if (tx.status === "tracker_committed")
170
+ for (const file of intent.files)
171
+ if (existsSync(file.staged))
172
+ publishStaged(file.staged, file.target);
173
+ workflow.updatePublication(tx.transactionId, "committed");
174
+ recovered.push(tx.transactionId);
175
+ rmSync(intent.stage, { recursive: true, force: true });
176
+ }
177
+ else {
178
+ rmSync(intent.stage, { recursive: true, force: true });
179
+ workflow.updatePublication(tx.transactionId, "rolled_back");
180
+ recovered.push(tx.transactionId);
181
+ }
182
+ }
183
+ return recovered;
184
+ }
185
+ finally {
186
+ workflow.close();
187
+ }
188
+ }
189
+ function allocateTicketId(reserved) { let n = 1; while (reserved.has(`T${String(n).padStart(3, "0")}`))
190
+ n++; return `T${String(n).padStart(3, "0")}`; }
191
+ function nextOrder(existing, offset) { return Math.max(-1, ...existing.map((ticket) => ticket.order)) + 1 + offset; }
192
+ function fileDigest(path) { return existsSync(path) ? createHash("sha256").update(readFileSync(path)).digest("hex") : null; }
193
+ function fsyncWrite(path, value) { mkdirSync(dirname(path), { recursive: true }); const fd = openSync(path, "wx", 0o600); try {
194
+ writeFileSync(fd, value);
195
+ fsyncSync(fd);
196
+ }
197
+ finally {
198
+ closeSync(fd);
199
+ } }
200
+ function publishStaged(staged, target) { mkdirSync(dirname(target), { recursive: true }); const temp = `${target}.${process.pid}.${randomUUID()}.tmp`; copyFileSync(staged, temp); const fd = openSync(temp, "r"); try {
201
+ fsyncSync(fd);
202
+ }
203
+ finally {
204
+ closeSync(fd);
205
+ } renameSync(temp, target); }
@@ -1,5 +1,5 @@
1
1
  import type { TicketDef } from "./ticketSchema.js";
2
2
  import type { TicketState } from "./stateDb.js";
3
3
  export declare function resolveBlockers(ticket: TicketDef, states: Map<string, TicketState>): string[];
4
- export type DisplayStatus = "next" | "in_progress" | "blocked" | "done" | "canceled";
4
+ export type DisplayStatus = "next" | "in_progress" | "blocked" | "done" | "canceled" | "obsolete";
5
5
  export declare function computeDisplayStatus(storedStatus: string, blockedBy: string[]): DisplayStatus;
@@ -14,6 +14,8 @@ export function computeDisplayStatus(storedStatus, blockedBy) {
14
14
  return "done";
15
15
  if (storedStatus === "canceled")
16
16
  return "canceled";
17
+ if (storedStatus === "obsolete")
18
+ return "obsolete";
17
19
  if (storedStatus === "in_progress")
18
20
  return "in_progress";
19
21
  if (blockedBy.length > 0 || storedStatus === "blocked")
@@ -89,6 +89,7 @@ export interface ValidateResult {
89
89
  }
90
90
  export declare function cmdValidate(projectDir: string): ValidateResult;
91
91
  export declare function cmdQueue(projectDir: string, limit?: number): import("./queue.js").QueueRow[];
92
+ export declare function cmdStackQueue(projectDir: string, refresh?: boolean): string[];
92
93
  export declare function cmdImplementationQueue(projectDir: string): import("./queue.js").QueueRow[];
93
94
  export declare function cmdArchive(projectDir: string, _opts: {
94
95
  olderThanDays?: number;
@@ -7,7 +7,10 @@ import { StateDb } from "./stateDb.js";
7
7
  import { nowTimestamp, logEvent } from "./events.js";
8
8
  import { renderAndWrite, renderTrackerRules } from "./renderMarkdown.js";
9
9
  import { runAllValidation } from "./validate.js";
10
- import { buildNextQueue } from "./queue.js";
10
+ import { buildNextQueue, formatStackAwareQueue } from "./queue.js";
11
+ import { loadDeliveryConfig, saveDeliveryConfig } from "./delivery.js";
12
+ import { checkGitHubPrMerged } from "../branch/github.js";
13
+ import { checkGitLabMrMerged } from "../branch/gitlab.js";
11
14
  import { cmdReviewRecommendations } from "./recommendations.js";
12
15
  export function openContext(projectDir) {
13
16
  const config = loadTicketsConfig(projectDir);
@@ -470,6 +473,32 @@ export function cmdQueue(projectDir, limit) {
470
473
  return buildNextQueue(ctx.tickets, states, limit ?? ctx.config.viewLimit);
471
474
  });
472
475
  }
476
+ export function cmdStackQueue(projectDir, refresh = false) {
477
+ return withContext(projectDir, (ctx) => {
478
+ let delivery = loadDeliveryConfig(projectDir);
479
+ if (delivery && refresh) {
480
+ const unitById = new Map(delivery.units.map((unit) => [unit.id, unit]));
481
+ delivery = { ...delivery, stacks: (delivery.stacks ?? []).map((stack) => {
482
+ const links = stack.review_links ?? [];
483
+ const providers = new Set(stack.units.map((id) => unitById.get(id)?.provider).filter((value) => value === "github" || value === "gitlab"));
484
+ if (!links.length || providers.size !== 1)
485
+ return { ...stack, remote_stale: true };
486
+ const provider = [...providers][0];
487
+ const checks = links.map((link) => provider === "gitlab" ? checkGitLabMrMerged(projectDir, link) : checkGitHubPrMerged(projectDir, link));
488
+ if (checks.some((check) => !check.ok))
489
+ return { ...stack, remote_stale: true };
490
+ return {
491
+ ...stack,
492
+ status: checks.every((check) => check.ok && check.merged) ? "merged" : "awaiting_review",
493
+ review_links: checks.map((check, index) => check.ok ? (check.url ?? links[index]) : links[index]),
494
+ remote_checked_at: new Date().toISOString(), remote_stale: false,
495
+ };
496
+ }) };
497
+ saveDeliveryConfig(projectDir, delivery);
498
+ }
499
+ return formatStackAwareQueue(ctx.tickets, ctx.db.getAllStates(), delivery);
500
+ });
501
+ }
473
502
  export function cmdImplementationQueue(projectDir) {
474
503
  return withContext(projectDir, (ctx) => {
475
504
  const states = ctx.db.getAllStates();
@@ -16,17 +16,45 @@ export interface DeliveryUnit {
16
16
  depends_on?: string[];
17
17
  dependency_mode?: DeliveryDependencyMode;
18
18
  }
19
+ export type StackDeliveryState = "planned" | "in_progress" | "partial" | "blocked" | "awaiting_review" | "merged";
20
+ export interface DeliveryStack {
21
+ id: string;
22
+ name: string;
23
+ /** Straight root-to-tip chain of delivery unit IDs. */
24
+ units: string[];
25
+ status?: StackDeliveryState;
26
+ completed_prefix?: string[];
27
+ next_ticket?: string;
28
+ review_links?: string[];
29
+ remote_checked_at?: string;
30
+ remote_stale?: boolean;
31
+ /** Stable PR-node branch identities retained for partial-stack recovery. */
32
+ published_branches?: Record<string, string>;
33
+ }
19
34
  export interface DeliveryConfig {
20
35
  version: 1;
36
+ plan?: {
37
+ plan_id: string;
38
+ revision: number;
39
+ };
21
40
  units: DeliveryUnit[];
41
+ stacks?: DeliveryStack[];
22
42
  }
23
43
  export interface DeliveryIssue {
24
44
  path: string;
25
45
  message: string;
26
46
  }
27
47
  export declare function loadDeliveryConfig(projectDir: string): DeliveryConfig | undefined;
48
+ export declare function saveDeliveryConfig(projectDir: string, config: DeliveryConfig): void;
28
49
  export declare function normalizeDeliveryConfig(raw: unknown): DeliveryConfig;
29
50
  export declare function validateDeliveryConfig(raw: unknown, ticketDefs?: TicketDef[]): DeliveryIssue[];
51
+ export interface NormalizedStackNode {
52
+ stackId: string;
53
+ unitId: string;
54
+ ticketId: string;
55
+ depth: number;
56
+ }
57
+ export declare function normalizeStackNodes(stack: DeliveryStack, units: DeliveryUnit[], ticketDefs?: TicketDef[]): NormalizedStackNode[];
30
58
  export interface DeliveryUnitProgress {
31
59
  unit: DeliveryUnit;
32
60
  completed: number;
@@ -36,3 +64,11 @@ export interface DeliveryUnitProgress {
36
64
  export declare function deliveryProgress(config: DeliveryConfig, states: Map<string, TicketState>): DeliveryUnitProgress[];
37
65
  export declare function formatDeliverySummary(config: DeliveryConfig, states: Map<string, TicketState>): string[];
38
66
  export declare function selectDeliveryUnitForRun(config: DeliveryConfig, states: Map<string, TicketState>, skipUnitIds?: readonly string[]): DeliveryUnitProgress | undefined;
67
+ export interface StackBatchSelection {
68
+ stacks: DeliveryStack[];
69
+ tickets: string[];
70
+ error?: string;
71
+ }
72
+ /** Complete preflight simulation for exact `--stacks N` selection. */
73
+ export declare function selectStacksForRun(config: DeliveryConfig, ticketDefs: TicketDef[], states: Map<string, TicketState>, count: number): StackBatchSelection;
74
+ export declare function updateStackDeliveryState(projectDir: string, stackIds: readonly string[], status: StackDeliveryState, patch?: Partial<DeliveryStack>): DeliveryConfig;
@@ -1,6 +1,7 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { parse } from "yaml";
3
+ import { parse, stringify } from "yaml";
4
+ import { randomUUID } from "node:crypto";
4
5
  export const DELIVERY_FILE = ".tickets/delivery.yaml";
5
6
  export function loadDeliveryConfig(projectDir) {
6
7
  const path = join(projectDir, DELIVERY_FILE);
@@ -13,10 +14,20 @@ export function loadDeliveryConfig(projectDir) {
13
14
  }
14
15
  return normalizeDeliveryConfig(raw);
15
16
  }
17
+ export function saveDeliveryConfig(projectDir, config) {
18
+ const issues = validateDeliveryConfig(config);
19
+ if (issues.length)
20
+ throw new Error(`${DELIVERY_FILE}: ${issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
21
+ const target = join(projectDir, DELIVERY_FILE);
22
+ const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
23
+ writeFileSync(temp, stringify(config, { lineWidth: 120 }), "utf8");
24
+ renameSync(temp, target);
25
+ }
16
26
  export function normalizeDeliveryConfig(raw) {
17
27
  const value = raw;
18
28
  return {
19
29
  version: 1,
30
+ ...(value.plan && typeof value.plan === "object" ? { plan: { plan_id: String(value.plan.plan_id), revision: Number(value.plan.revision) } } : {}),
20
31
  units: value.units.map((unit) => ({
21
32
  id: String(unit.id),
22
33
  tickets: [...unit.tickets],
@@ -29,6 +40,16 @@ export function normalizeDeliveryConfig(raw) {
29
40
  ...(Array.isArray(unit.depends_on) ? { depends_on: [...unit.depends_on] } : {}),
30
41
  ...(unit.dependency_mode ? { dependency_mode: unit.dependency_mode } : {}),
31
42
  })),
43
+ ...(Array.isArray(value.stacks) ? { stacks: value.stacks.map((stack) => ({
44
+ id: String(stack.id), name: String(stack.name), units: [...stack.units],
45
+ ...(stack.status ? { status: stack.status } : {}),
46
+ ...(Array.isArray(stack.completed_prefix) ? { completed_prefix: [...stack.completed_prefix] } : {}),
47
+ ...(stack.next_ticket ? { next_ticket: String(stack.next_ticket) } : {}),
48
+ ...(Array.isArray(stack.review_links) ? { review_links: [...stack.review_links] } : {}),
49
+ ...(stack.remote_checked_at ? { remote_checked_at: String(stack.remote_checked_at) } : {}),
50
+ ...(typeof stack.remote_stale === "boolean" ? { remote_stale: stack.remote_stale } : {}),
51
+ ...(stack.published_branches && typeof stack.published_branches === "object" ? { published_branches: { ...stack.published_branches } } : {}),
52
+ })) } : {}),
32
53
  };
33
54
  }
34
55
  export function validateDeliveryConfig(raw, ticketDefs) {
@@ -38,6 +59,11 @@ export function validateDeliveryConfig(raw, ticketDefs) {
38
59
  const value = raw;
39
60
  if (value.version !== undefined && value.version !== 1)
40
61
  issues.push({ path: "version", message: "expected 1" });
62
+ if (value.plan !== undefined) {
63
+ const plan = value.plan;
64
+ if (!plan || typeof plan !== "object" || typeof plan.plan_id !== "string" || !Number.isInteger(plan.revision) || Number(plan.revision) < 1)
65
+ issues.push({ path: "plan", message: "expected plan_id and positive integer revision" });
66
+ }
41
67
  if (!Array.isArray(value.units))
42
68
  return [...issues, { path: "units", message: "expected an array" }];
43
69
  const unitIds = new Set();
@@ -95,6 +121,113 @@ export function validateDeliveryConfig(raw, ticketDefs) {
95
121
  }
96
122
  for (const cycle of deliveryCycles(units))
97
123
  issues.push({ path: "units", message: `dependency cycle: ${cycle}` });
124
+ issues.push(...validateDeliveryStacks(value.stacks, units, ticketDefs, value.plan));
125
+ return issues;
126
+ }
127
+ export function normalizeStackNodes(stack, units, ticketDefs = []) {
128
+ const order = new Map(ticketDefs.map((ticket) => [ticket.id, ticket.order]));
129
+ const byId = new Map(units.map((unit) => [unit.id, unit]));
130
+ const nodes = [];
131
+ for (const unitId of stack.units) {
132
+ const unit = byId.get(unitId);
133
+ if (!unit)
134
+ continue;
135
+ const tickets = [...unit.tickets].sort((a, b) => (order.get(a) ?? Number.MAX_SAFE_INTEGER) - (order.get(b) ?? Number.MAX_SAFE_INTEGER));
136
+ if (unit.branch_mode === "shared")
137
+ nodes.push({ stackId: stack.id, unitId, ticketId: tickets.join("+"), depth: nodes.length + 1 });
138
+ else
139
+ for (const ticketId of tickets)
140
+ nodes.push({ stackId: stack.id, unitId, ticketId, depth: nodes.length + 1 });
141
+ }
142
+ return nodes;
143
+ }
144
+ function validateDeliveryStacks(raw, units, ticketDefs, plan) {
145
+ if (raw === undefined)
146
+ return [];
147
+ if (!Array.isArray(raw))
148
+ return [{ path: "stacks", message: "expected an array" }];
149
+ const issues = [];
150
+ const byId = new Map(units.map((unit) => [String(unit.id), unit]));
151
+ const stackIds = new Set();
152
+ const assigned = new Set();
153
+ const ticketById = new Map((ticketDefs ?? []).map((ticket) => [ticket.id, ticket]));
154
+ const queueIndex = new Map((ticketDefs ?? []).slice().sort((a, b) => a.order - b.order).map((ticket, index) => [ticket.id, index]));
155
+ const stackedTicketIds = new Set();
156
+ for (let index = 0; index < raw.length; index++) {
157
+ const stack = raw[index];
158
+ const path = `stacks[${index}]`;
159
+ if (!stack || typeof stack !== "object" || typeof stack.id !== "string" || !stack.id) {
160
+ issues.push({ path: `${path}.id`, message: "expected stable stack ID" });
161
+ continue;
162
+ }
163
+ if (stackIds.has(stack.id))
164
+ issues.push({ path: `${path}.id`, message: `duplicate stack ID ${stack.id}` });
165
+ else
166
+ stackIds.add(stack.id);
167
+ if (typeof stack.name !== "string" || !stack.name.trim())
168
+ issues.push({ path: `${path}.name`, message: "expected a name" });
169
+ if (!Array.isArray(stack.units) || stack.units.length === 0 || !stack.units.every((id) => typeof id === "string")) {
170
+ issues.push({ path: `${path}.units`, message: "expected ordered unit IDs" });
171
+ continue;
172
+ }
173
+ for (const unitId of stack.units) {
174
+ const unit = byId.get(unitId);
175
+ if (!unit) {
176
+ issues.push({ path: `${path}.units`, message: `unknown unit ${unitId}` });
177
+ continue;
178
+ }
179
+ if (assigned.has(unitId))
180
+ issues.push({ path: `${path}.units`, message: `unit ${unitId} belongs to more than one stack` });
181
+ else
182
+ assigned.add(unitId);
183
+ if (unit.branch_mode === "current")
184
+ issues.push({ path: `${path}.units`, message: `current-branch unit ${unitId} cannot be stacked` });
185
+ if (unit.completion !== "pr")
186
+ issues.push({ path: `${path}.units`, message: `stacked unit ${unitId} must use completion: pr` });
187
+ if (!["github", "gitlab"].includes(String(unit.provider)))
188
+ issues.push({ path: `${path}.units`, message: `stacked unit ${unitId} must use github or gitlab` });
189
+ if (unit.dependency_mode !== "stack")
190
+ issues.push({ path: `${path}.units`, message: `stacked unit ${unitId} must use dependency_mode: stack` });
191
+ for (const ticketId of Array.isArray(unit.tickets) ? unit.tickets : [])
192
+ stackedTicketIds.add(ticketId);
193
+ }
194
+ const providers = new Set(stack.units.map((id) => String(byId.get(id)?.provider ?? "")));
195
+ if (providers.size > 1)
196
+ issues.push({ path: `${path}.units`, message: "all nodes in a stack must use the same provider" });
197
+ const typed = { id: stack.id, name: String(stack.name), units: stack.units };
198
+ const nodes = normalizeStackNodes(typed, units, ticketDefs);
199
+ if (nodes.length > 5)
200
+ issues.push({ path: `${path}.units`, message: `normalized PR depth ${nodes.length} exceeds maximum 5` });
201
+ const rootDeps = Array.isArray(byId.get(typed.units[0])?.depends_on) ? byId.get(typed.units[0]).depends_on : [];
202
+ if (rootDeps.length)
203
+ issues.push({ path: `${path}.units`, message: `root unit ${typed.units[0]} cannot have a stack parent` });
204
+ for (let unitIndex = 1; unitIndex < typed.units.length; unitIndex++) {
205
+ const id = typed.units[unitIndex];
206
+ const predecessor = typed.units[unitIndex - 1];
207
+ const deps = Array.isArray(byId.get(id)?.depends_on) ? byId.get(id).depends_on : [];
208
+ if (deps.length !== 1 || deps[0] !== predecessor)
209
+ issues.push({ path: `${path}.units`, message: `unit ${id} must depend only on immediate predecessor ${predecessor}` });
210
+ }
211
+ for (const unitId of typed.units) {
212
+ const unit = byId.get(unitId);
213
+ if (unit?.branch_mode !== "per-ticket")
214
+ continue;
215
+ const tickets = unit.tickets.slice().sort((a, b) => (queueIndex.get(a) ?? 0) - (queueIndex.get(b) ?? 0));
216
+ for (let ticketIndex = 1; ticketIndex < tickets.length; ticketIndex++) {
217
+ const deps = ticketById.get(tickets[ticketIndex])?.depends_on ?? [];
218
+ if (!deps.includes(tickets[ticketIndex - 1]))
219
+ issues.push({ path: `${path}.units`, message: `per-ticket node ${tickets[ticketIndex]} must depend on predecessor ${tickets[ticketIndex - 1]}` });
220
+ }
221
+ }
222
+ const indices = typed.units.flatMap((unitId) => Array.isArray(byId.get(unitId)?.tickets) ? byId.get(unitId).tickets : [])
223
+ .map((ticketId) => queueIndex.get(ticketId)).filter((value) => value !== undefined).sort((a, b) => a - b);
224
+ if (indices.some((value, position) => position > 0 && value !== indices[position - 1] + 1))
225
+ issues.push({ path: `${path}.units`, message: "stack tickets must be contiguous in queue order" });
226
+ }
227
+ if (plan && ticketDefs)
228
+ for (const ticket of ticketDefs)
229
+ if (stackedTicketIds.has(ticket.id) && ticket.plan_ref && ticket.plan_ref.plan_id !== plan.plan_id)
230
+ issues.push({ path: "plan", message: `stack ticket ${ticket.id} belongs to plan ${ticket.plan_ref.plan_id}, not ${plan.plan_id}` });
98
231
  return issues;
99
232
  }
100
233
  function enumValue(issues, path, value, allowed, required = false) {
@@ -127,10 +260,10 @@ function deliveryCycles(units) {
127
260
  return cycles;
128
261
  }
129
262
  export function deliveryProgress(config, states) {
130
- const completedUnits = new Set(config.units.filter((unit) => unit.tickets.every((id) => ["done", "canceled"].includes(states.get(id)?.status ?? "planned"))).map((unit) => unit.id));
263
+ const completedUnits = new Set(config.units.filter((unit) => unit.tickets.every((id) => ["done", "canceled", "obsolete"].includes(states.get(id)?.status ?? "planned"))).map((unit) => unit.id));
131
264
  const out = [];
132
265
  for (const unit of config.units) {
133
- const remaining = unit.tickets.filter((id) => !["done", "canceled"].includes(states.get(id)?.status ?? "planned"));
266
+ const remaining = unit.tickets.filter((id) => !["done", "canceled", "obsolete"].includes(states.get(id)?.status ?? "planned"));
134
267
  const started = unit.tickets.some((id) => ["in_progress", "blocked", "done"].includes(states.get(id)?.status ?? "planned"));
135
268
  const waiting = (unit.depends_on ?? []).some((id) => !completedUnits.has(id));
136
269
  out.push({
@@ -150,3 +283,70 @@ export function selectDeliveryUnitForRun(config, states, skipUnitIds = []) {
150
283
  const progress = deliveryProgress(config, states).filter((item) => !skipped.has(item.unit.id));
151
284
  return progress.find((item) => item.state === "resume") ?? progress.find((item) => item.state === "ready");
152
285
  }
286
+ /** Complete preflight simulation for exact `--stacks N` selection. */
287
+ export function selectStacksForRun(config, ticketDefs, states, count) {
288
+ if (!Number.isInteger(count) || count < 1)
289
+ return { stacks: [], tickets: [], error: "--stacks must be a positive integer" };
290
+ const stacks = config.stacks ?? [];
291
+ const unitById = new Map(config.units.map((unit) => [unit.id, unit]));
292
+ const defs = new Map(ticketDefs.map((ticket) => [ticket.id, ticket]));
293
+ const candidates = [...stacks].sort((a, b) => {
294
+ const ap = stackStarted(a, unitById, states) ? 0 : 1;
295
+ const bp = stackStarted(b, unitById, states) ? 0 : 1;
296
+ return ap - bp;
297
+ });
298
+ const firstPartial = candidates.find((stack) => stackStarted(stack, unitById, states) && !stackComplete(stack, unitById, states));
299
+ if (firstPartial) {
300
+ const blocker = externalStackBlocker(firstPartial, unitById, defs, states);
301
+ if (blocker)
302
+ return { stacks: [], tickets: [], error: `partial stack ${firstPartial.name} (${firstPartial.id}) is blocked by ${blocker}; finish or unblock it before starting a newer stack` };
303
+ }
304
+ const selected = [];
305
+ const tickets = [];
306
+ for (const stack of candidates) {
307
+ if (stackComplete(stack, unitById, states))
308
+ continue;
309
+ const blocker = externalStackBlocker(stack, unitById, defs, states);
310
+ if (blocker) {
311
+ if (stack === firstPartial)
312
+ return { stacks: [], tickets: [], error: `partial stack ${stack.id} is blocked by ${blocker}` };
313
+ continue;
314
+ }
315
+ selected.push(stack);
316
+ for (const unitId of stack.units)
317
+ for (const id of unitById.get(unitId)?.tickets ?? [])
318
+ if (!terminalDelivery(states.get(id)?.status))
319
+ tickets.push(id);
320
+ if (selected.length === count)
321
+ break;
322
+ }
323
+ if (selected.length !== count)
324
+ return { stacks: [], tickets: [], error: `requested exactly ${count} complete eligible stack(s), but only ${selected.length} can legally run` };
325
+ return { stacks: selected, tickets };
326
+ }
327
+ export function updateStackDeliveryState(projectDir, stackIds, status, patch = {}) {
328
+ const config = loadDeliveryConfig(projectDir);
329
+ if (!config)
330
+ throw new Error(`${DELIVERY_FILE} not found`);
331
+ const selected = new Set(stackIds);
332
+ let matched = 0;
333
+ const next = { ...config, stacks: (config.stacks ?? []).map((stack) => selected.has(stack.id) ? (matched++, { ...stack, ...patch, id: stack.id, name: stack.name, units: stack.units, status }) : stack) };
334
+ if (matched !== selected.size)
335
+ throw new Error(`unknown stack IDs: ${[...selected].filter((id) => !next.stacks?.some((stack) => stack.id === id)).join(",")}`);
336
+ const target = join(projectDir, DELIVERY_FILE);
337
+ const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
338
+ writeFileSync(temp, stringify(next, { lineWidth: 120 }), "utf8");
339
+ renameSync(temp, target);
340
+ return next;
341
+ }
342
+ function terminalDelivery(status) { return status === "done" || status === "canceled" || status === "obsolete"; }
343
+ function stackStarted(stack, units, states) { return stack.units.some((unit) => (units.get(unit)?.tickets ?? []).some((id) => ["in_progress", "blocked", "done"].includes(states.get(id)?.status ?? "planned"))); }
344
+ function stackComplete(stack, units, states) { return stack.units.every((unit) => (units.get(unit)?.tickets ?? []).every((id) => terminalDelivery(states.get(id)?.status))); }
345
+ function externalStackBlocker(stack, units, defs, states) {
346
+ const inside = new Set(stack.units.flatMap((unit) => units.get(unit)?.tickets ?? []));
347
+ for (const id of inside)
348
+ for (const dep of defs.get(id)?.depends_on ?? [])
349
+ if (!inside.has(dep) && !terminalDelivery(states.get(dep)?.status))
350
+ return dep;
351
+ return undefined;
352
+ }
@@ -1,5 +1,6 @@
1
1
  import type { TicketDef } from "./ticketSchema.js";
2
2
  import type { TicketState } from "./stateDb.js";
3
+ import type { DeliveryConfig } from "./delivery.js";
3
4
  export interface QueueRow {
4
5
  rank: number;
5
6
  ticket: string;
@@ -34,3 +35,5 @@ export interface ActiveStatusRow {
34
35
  }
35
36
  export declare function buildNextQueue(ticketDefs: TicketDef[], states: Map<string, TicketState>, queueLimit: number): QueueRow[];
36
37
  export declare function buildActiveStatusRows(queueRows: QueueRow[], ticketDefs: TicketDef[], states: Map<string, TicketState>): ActiveStatusRow[];
38
+ /** Human-readable queue blocks keep a whole unfinished stack and its resume point visible. */
39
+ export declare function formatStackAwareQueue(ticketDefs: TicketDef[], states: Map<string, TicketState>, delivery?: DeliveryConfig): string[];