ai-foreman 1.7.22 → 1.7.23

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.
@@ -64,11 +64,15 @@ export interface TicketPlanningContext {
64
64
  }>;
65
65
  existingNext: string[];
66
66
  }
67
+ /** The tracker facts needed to validate a proposal before it is approved. */
68
+ export interface TicketPlanValidationContext {
69
+ futureWorkIds?: Iterable<number>;
70
+ }
67
71
  export declare function readTicketPlanningContext(projectDir: string): TicketPlanningContext;
68
72
  export declare function ticketPlanningFingerprint(projectDir: string): PlanningFingerprint[];
69
73
  export declare function planningFingerprintChanges(projectDir: string, expected: PlanningFingerprint[]): string[];
70
- export declare function extractTicketPlanProposal(output: string, existingTickets?: TicketDef[]): TicketPlanProposal;
71
- export declare function validateTicketPlanProposal(value: unknown, existingTickets?: TicketDef[]): string[];
74
+ export declare function extractTicketPlanProposal(output: string, existingTickets?: TicketDef[], context?: TicketPlanValidationContext): TicketPlanProposal;
75
+ export declare function validateTicketPlanProposal(value: unknown, existingTickets?: TicketDef[], context?: TicketPlanValidationContext): string[];
72
76
  export interface ApplyTicketPlanOptions {
73
77
  expectedFingerprint: PlanningFingerprint[];
74
78
  docsRoot: string;
@@ -12,6 +12,9 @@ import { normalizeDeliveryConfig, validateDeliveryConfig } from "./tickets/deliv
12
12
  import { loadTicketSetupConfig, mergeTicketSetup, saveTicketSetupConfig } from "./tickets/setupConfig.js";
13
13
  export const PROPOSAL_START = "RAFI_PROPOSAL_START";
14
14
  export const PROPOSAL_END = "RAFI_PROPOSAL_END";
15
+ const TICKET_STATUSES = new Set([
16
+ "planned", "next", "in_progress", "blocked", "done", "canceled", "obsolete",
17
+ ]);
15
18
  export function readTicketPlanningContext(projectDir) {
16
19
  const config = loadTicketsConfig(projectDir);
17
20
  const paths = resolveTicketPaths(config, projectDir);
@@ -41,7 +44,7 @@ export function ticketPlanningFingerprint(projectDir) {
41
44
  export function planningFingerprintChanges(projectDir, expected) {
42
45
  return expected.filter((item) => fileHash(join(projectDir, item.path)) !== item.sha256).map((item) => item.path);
43
46
  }
44
- export function extractTicketPlanProposal(output, existingTickets = []) {
47
+ export function extractTicketPlanProposal(output, existingTickets = [], context = {}) {
45
48
  const start = output.lastIndexOf(PROPOSAL_START);
46
49
  const end = output.lastIndexOf(PROPOSAL_END);
47
50
  if (start < 0 || end <= start)
@@ -54,12 +57,12 @@ export function extractTicketPlanProposal(output, existingTickets = []) {
54
57
  catch (err) {
55
58
  throw new Error(`planner proposal is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
56
59
  }
57
- const issues = validateTicketPlanProposal(parsed, existingTickets);
60
+ const issues = validateTicketPlanProposal(parsed, existingTickets, context);
58
61
  if (issues.length)
59
62
  throw new Error(`invalid planner proposal:\n${issues.map((issue) => `- ${issue}`).join("\n")}`);
60
63
  return parsed;
61
64
  }
62
- export function validateTicketPlanProposal(value, existingTickets = []) {
65
+ export function validateTicketPlanProposal(value, existingTickets = [], context = {}) {
63
66
  const issues = [];
64
67
  if (!value || typeof value !== "object" || Array.isArray(value))
65
68
  return ["proposal must be an object"];
@@ -109,9 +112,26 @@ export function validateTicketPlanProposal(value, existingTickets = []) {
109
112
  if (!ids.has(id))
110
113
  issues.push(`supersession references unknown ticket ${id}`);
111
114
  }
112
- for (const change of p.state_changes)
113
- if (!ids.has(change.id) || !change.reason?.trim())
114
- issues.push(`invalid state change for ${change.id}`);
115
+ for (const change of p.state_changes) {
116
+ if (!change || typeof change !== "object" || !ids.has(change.id) || !change.reason?.trim()) {
117
+ issues.push(`invalid state change for ${String(change && typeof change === "object" ? change.id : undefined)}`);
118
+ continue;
119
+ }
120
+ if (!TICKET_STATUSES.has(change.status))
121
+ issues.push(`state change for ${change.id} has invalid status ${String(change.status)}`);
122
+ }
123
+ const futureWorkIds = context.futureWorkIds ? new Set(context.futureWorkIds) : undefined;
124
+ for (const disposition of p.future_work) {
125
+ if (!disposition || typeof disposition !== "object" || !Number.isInteger(disposition.id)) {
126
+ issues.push(`future work disposition has invalid id ${String(disposition && typeof disposition === "object" ? disposition.id : undefined)}`);
127
+ continue;
128
+ }
129
+ if (!["accepted", "rejected", "merged", "queued"].includes(disposition.disposition)) {
130
+ issues.push(`future work item ${disposition.id} has invalid disposition ${String(disposition.disposition)}`);
131
+ }
132
+ if (futureWorkIds && !futureWorkIds.has(disposition.id))
133
+ issues.push(`future work item ${disposition.id} does not exist`);
134
+ }
115
135
  for (const item of p.source_reconciliation) {
116
136
  if (!item.source?.trim() || !item.item?.trim())
117
137
  issues.push("source reconciliation needs source and item");
@@ -151,7 +171,8 @@ export function applyApprovedTicketPlan(projectDir, proposal, opts) {
151
171
  const existing = loadTickets(paths.tickets);
152
172
  if (existing.length > 0 && !existsSync(paths.stateDb))
153
173
  throw new Error("tickets exist but the ignored ticket-state database is missing; restore it before planning so completed work is not treated as new");
154
- const proposalIssues = validateTicketPlanProposal(proposal, existing);
174
+ const currentContext = readTicketPlanningContext(root);
175
+ const proposalIssues = validateTicketPlanProposal(proposal, existing, { futureWorkIds: currentContext.futureWork.flatMap((item) => typeof item.id === "number" ? [item.id] : []) });
155
176
  if (proposalIssues.length)
156
177
  throw new Error(`approved proposal is invalid:\n${proposalIssues.map((issue) => `- ${issue}`).join("\n")}`);
157
178
  const lockPath = join(root, ".tickets", "planning.lock");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.22",
3
+ "version": "1.7.23",
4
4
  "description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",