ai-foreman 1.5.0 → 1.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.
- package/dist/agentRun.d.ts +3 -2
- package/dist/agentRun.js +27 -8
- package/dist/branch/git.d.ts +3 -0
- package/dist/branch/git.js +31 -5
- package/dist/branch/github.d.ts +2 -2
- package/dist/branch/github.js +2 -2
- package/dist/branch/gitlab.d.ts +3 -2
- package/dist/branch/gitlab.js +10 -4
- package/dist/branch/planner.d.ts +3 -1
- package/dist/branch/planner.js +24 -2
- package/dist/branch/runner.d.ts +10 -1
- package/dist/branch/runner.js +41 -13
- package/dist/branch/types.d.ts +4 -0
- package/dist/buildRuns.d.ts +35 -0
- package/dist/buildRuns.js +149 -0
- package/dist/cli/runtimeAuthPrompt.d.ts +1 -1
- package/dist/cli/runtimeAuthPrompt.js +2 -2
- package/dist/cli/start.js +153 -16
- package/dist/cli/status.d.ts +2 -0
- package/dist/cli/status.js +71 -55
- package/dist/cli/tickets.js +57 -10
- package/dist/foreman.d.ts +3 -1
- package/dist/foreman.js +46 -4
- package/dist/index.js +0 -0
- package/dist/interviews.d.ts +26 -4
- package/dist/interviews.js +71 -4
- package/dist/log.d.ts +1 -1
- package/dist/roles.d.ts +11 -0
- package/dist/roles.js +8 -0
- package/dist/runtimeAuth.d.ts +1 -1
- package/dist/runtimeAuth.js +6 -25
- package/dist/runtimeReadiness.d.ts +14 -0
- package/dist/runtimeReadiness.js +122 -0
- package/dist/ticketPlanning.d.ts +83 -0
- package/dist/ticketPlanning.js +287 -0
- package/dist/tickets/commands.js +16 -0
- package/dist/tickets/delivery.d.ts +38 -0
- package/dist/tickets/delivery.js +152 -0
- package/dist/tickets/importer.js +4 -0
- package/dist/tickets/renderMarkdown.js +9 -0
- package/dist/tickets/setupConfig.d.ts +8 -0
- package/dist/tickets/setupConfig.js +47 -2
- package/dist/tickets/sourceFetch.d.ts +24 -0
- package/dist/tickets/sourceFetch.js +246 -0
- package/dist/tickets/stateDb.d.ts +10 -0
- package/dist/tickets/stateDb.js +24 -0
- package/dist/tickets/ticketLoader.js +18 -0
- package/dist/tickets/ticketSchema.d.ts +93 -0
- package/dist/tickets/ticketSchema.js +17 -0
- package/dist/tickets/validate.js +12 -0
- package/package.json +29 -2
package/dist/log.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** A single structured log record. */
|
|
2
2
|
export interface LogRecord {
|
|
3
3
|
ts: string;
|
|
4
|
-
event: "batch-start" | "batch-end" | "branch-plan" | "branch-start" | "branch-session" | "branch-resume" | "branch-issue" | "branch-complete" | "branch-push" | "github-readiness-failed" | "gitlab-readiness-failed" | "pr-created" | "pr-existing" | "pr-failed" | "pr-auto-merge-enabled" | "pr-auto-merge-failed" | "mr-created" | "mr-existing" | "mr-failed" | "mr-auto-merge-enabled" | "mr-auto-merge-failed" | "branch-auto-merge-wait" | "branch-direct-merge" | "step" | "permission" | "escalation" | "needs_input" | "preflight" | "rafi-plan" | "ticket-populate" | "qa" | "qa-fix" | "error";
|
|
4
|
+
event: "batch-start" | "batch-end" | "branch-plan" | "branch-start" | "branch-session" | "branch-resume" | "branch-issue" | "branch-complete" | "branch-push" | "github-readiness-failed" | "gitlab-readiness-failed" | "pr-created" | "pr-existing" | "pr-failed" | "pr-auto-merge-enabled" | "pr-auto-merge-failed" | "mr-created" | "mr-existing" | "mr-failed" | "mr-auto-merge-enabled" | "mr-auto-merge-failed" | "branch-auto-merge-wait" | "branch-direct-merge" | "step" | "permission" | "escalation" | "needs_input" | "preflight" | "rafi-plan" | "ticket-populate" | "uninstall-proposal" | "qa" | "qa-fix" | "qa-protected-files-changed" | "error";
|
|
5
5
|
[key: string]: unknown;
|
|
6
6
|
}
|
|
7
7
|
/** Append-only JSONL logger. One line per record, plus an echo to the console. */
|
package/dist/roles.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import type { ConfigurableAgentRole } from "rafi-spec";
|
|
2
|
+
export interface AgentRoleRegistration {
|
|
3
|
+
id: ConfigurableAgentRole;
|
|
4
|
+
label: string;
|
|
5
|
+
consumers: string[];
|
|
6
|
+
configurable: boolean;
|
|
7
|
+
manifest: string;
|
|
8
|
+
commands: string[];
|
|
9
|
+
}
|
|
10
|
+
/** One registry drives compilation, settings UI, and independently launched roles. */
|
|
11
|
+
export declare const AGENT_ROLE_REGISTRY: readonly AgentRoleRegistration[];
|
|
1
12
|
export interface RoleBundle {
|
|
2
13
|
system: string;
|
|
3
14
|
skills: string[];
|
package/dist/roles.js
CHANGED
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { getAgent } from "special-agents";
|
|
14
|
+
/** One registry drives compilation, settings UI, and independently launched roles. */
|
|
15
|
+
export const AGENT_ROLE_REGISTRY = [
|
|
16
|
+
{ id: "planner", label: "Planner", consumers: ["initial planning", "ticket planning"], configurable: true, manifest: "planner", commands: ["rafi create", "rafi plan", "rafi tickets plan"] },
|
|
17
|
+
{ id: "builder", label: "Builder", consumers: ["implementation"], configurable: true, manifest: "builder", commands: ["rafi start", "rafi build:resume"] },
|
|
18
|
+
{ id: "qa", label: "QA (separate review session)", consumers: ["run-wide independent review"], configurable: true, manifest: "qa", commands: ["rafi start", "rafi build:resume"] },
|
|
19
|
+
{ id: "ticket-maker", label: "Ticket maker", consumers: ["ticket population"], configurable: true, manifest: "ticket-maker", commands: ["rafi tickets populate"] },
|
|
20
|
+
{ id: "uninstaller", label: "Uninstaller", consumers: ["non-empty uninstall instructions only"], configurable: true, manifest: "uninstaller", commands: ["rafi uninstall"] },
|
|
21
|
+
];
|
|
14
22
|
function libraryGetter(role) {
|
|
15
23
|
const a = getAgent(role);
|
|
16
24
|
return { system: a.system, skills: a.skills, model: a.model, effort: a.effort };
|
package/dist/runtimeAuth.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export declare class RuntimeAuthError extends Error {
|
|
|
15
15
|
readonly authLikely: boolean;
|
|
16
16
|
constructor(opts: RuntimeAuthErrorOptions);
|
|
17
17
|
}
|
|
18
|
-
export declare function checkRuntimeReady(projectDir: string, runtime: AgentRuntime): void
|
|
18
|
+
export declare function checkRuntimeReady(projectDir: string, runtime: AgentRuntime): Promise<void>;
|
|
19
19
|
export declare function normalizeRuntimeErrorText(runtime: AgentRuntime, text: string, exitCode?: number | null, context?: string): string;
|
|
20
20
|
export declare function isRuntimeAuthFailure(output: string): boolean;
|
|
21
21
|
export declare function runtimeCommandLabel(runtime: AgentRuntime): string;
|
package/dist/runtimeAuth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { formatRuntimeProbeFailure, probeRuntime } from "./runtimeReadiness.js";
|
|
2
2
|
export class RuntimeAuthError extends Error {
|
|
3
3
|
runtime;
|
|
4
4
|
exitCode;
|
|
@@ -15,34 +15,15 @@ export class RuntimeAuthError extends Error {
|
|
|
15
15
|
this.authLikely = isRuntimeAuthFailure(`${this.stderr}\n${this.stdout}`);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
-
export function checkRuntimeReady(projectDir, runtime) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
execFileSync("claude", ["-p", "Return exactly OK"], {
|
|
22
|
-
cwd: projectDir,
|
|
23
|
-
encoding: "utf8",
|
|
24
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
else {
|
|
28
|
-
execFileSync("codex", ["exec", "--skip-git-repo-check", "-C", projectDir, "Return exactly OK"], {
|
|
29
|
-
cwd: projectDir,
|
|
30
|
-
encoding: "utf8",
|
|
31
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
catch (err) {
|
|
36
|
-
const failure = err;
|
|
18
|
+
export async function checkRuntimeReady(projectDir, runtime) {
|
|
19
|
+
const result = await probeRuntime(projectDir, runtime);
|
|
20
|
+
if (!result.ok)
|
|
37
21
|
throw new RuntimeAuthError({
|
|
38
22
|
runtime,
|
|
39
23
|
context: "readiness check",
|
|
40
|
-
exitCode:
|
|
41
|
-
|
|
42
|
-
stderr: outputToString(failure.stderr),
|
|
43
|
-
cause: err,
|
|
24
|
+
exitCode: result.exitCode,
|
|
25
|
+
stderr: formatRuntimeProbeFailure(result),
|
|
44
26
|
});
|
|
45
|
-
}
|
|
46
27
|
}
|
|
47
28
|
export function normalizeRuntimeErrorText(runtime, text, exitCode, context = "builder turn") {
|
|
48
29
|
if (!isRuntimeAuthFailure(text))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RuntimeProbeCategory, RuntimeProbePhase, RuntimeProbeResult } from "rafi-spec";
|
|
2
|
+
import type { AgentRuntime } from "./runtimeAuth.js";
|
|
3
|
+
export declare const RUNTIME_PROBE_TIMEOUT_MS = 30000;
|
|
4
|
+
export declare const RUNTIME_DIAGNOSTIC_LIMIT: number;
|
|
5
|
+
export interface ProbeRuntimeOptions {
|
|
6
|
+
phase?: RuntimeProbePhase;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
maxDiagnosticsBytes?: number;
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
}
|
|
11
|
+
export declare function probeRuntime(cwd: string, runtime: AgentRuntime, opts?: ProbeRuntimeOptions): Promise<RuntimeProbeResult>;
|
|
12
|
+
export declare function classifyRuntimeFailure(text: string, phase?: RuntimeProbePhase): RuntimeProbeCategory;
|
|
13
|
+
export declare function sanitizeDiagnostics(text: string, maxBytes?: number): string;
|
|
14
|
+
export declare function formatRuntimeProbeFailure(result: RuntimeProbeResult): string;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export const RUNTIME_PROBE_TIMEOUT_MS = 30_000;
|
|
3
|
+
export const RUNTIME_DIAGNOSTIC_LIMIT = 8 * 1024;
|
|
4
|
+
const RELEVANT_ENV = /^(ANTHROPIC|CLAUDE|CODEX|OPENAI|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|SSL_CERT_FILE|NODE_EXTRA_CA_CERTS)(_|$)/i;
|
|
5
|
+
export async function probeRuntime(cwd, runtime, opts = {}) {
|
|
6
|
+
const executable = runtime === "claude" ? "claude" : "codex";
|
|
7
|
+
const args = runtime === "claude"
|
|
8
|
+
? ["-p", "Return exactly OK"]
|
|
9
|
+
: ["exec", "--skip-git-repo-check", "-C", cwd, "Return exactly OK"];
|
|
10
|
+
const phase = opts.phase ?? "readiness";
|
|
11
|
+
const env = opts.env ?? process.env;
|
|
12
|
+
const limit = opts.maxDiagnosticsBytes ?? RUNTIME_DIAGNOSTIC_LIMIT;
|
|
13
|
+
const timeoutMs = opts.timeoutMs ?? RUNTIME_PROBE_TIMEOUT_MS;
|
|
14
|
+
return new Promise((resolveResult) => {
|
|
15
|
+
let output = Buffer.alloc(0);
|
|
16
|
+
let timedOut = false;
|
|
17
|
+
let settled = false;
|
|
18
|
+
const spawnOpts = { cwd, env, stdio: ["ignore", "pipe", "pipe"] };
|
|
19
|
+
const child = spawn(executable, args, spawnOpts);
|
|
20
|
+
const append = (chunk) => {
|
|
21
|
+
if (output.length >= limit)
|
|
22
|
+
return;
|
|
23
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
24
|
+
output = Buffer.concat([output, bytes.subarray(0, Math.max(0, limit - output.length))]);
|
|
25
|
+
};
|
|
26
|
+
child.stdout?.on("data", append);
|
|
27
|
+
child.stderr?.on("data", append);
|
|
28
|
+
const timer = setTimeout(() => {
|
|
29
|
+
timedOut = true;
|
|
30
|
+
child.kill("SIGTERM");
|
|
31
|
+
setTimeout(() => child.kill("SIGKILL"), 1_000).unref();
|
|
32
|
+
}, timeoutMs);
|
|
33
|
+
timer.unref();
|
|
34
|
+
const finish = (exitCode, signal, spawnError) => {
|
|
35
|
+
if (settled)
|
|
36
|
+
return;
|
|
37
|
+
settled = true;
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
const diagnostics = sanitizeDiagnostics(spawnError?.message
|
|
40
|
+
? `${spawnError.message}\n${output.toString("utf8")}`
|
|
41
|
+
: output.toString("utf8"), limit);
|
|
42
|
+
const category = timedOut
|
|
43
|
+
? "timeout"
|
|
44
|
+
: spawnError?.code === "ENOENT"
|
|
45
|
+
? "missing-executable"
|
|
46
|
+
: exitCode === 0
|
|
47
|
+
? "ready"
|
|
48
|
+
: classifyRuntimeFailure(diagnostics, phase);
|
|
49
|
+
resolveResult({
|
|
50
|
+
ok: category === "ready",
|
|
51
|
+
runtime,
|
|
52
|
+
phase,
|
|
53
|
+
category,
|
|
54
|
+
executable,
|
|
55
|
+
cwd,
|
|
56
|
+
timedOut,
|
|
57
|
+
exitCode,
|
|
58
|
+
signal,
|
|
59
|
+
diagnostics,
|
|
60
|
+
environmentNames: Object.keys(env).filter((name) => RELEVANT_ENV.test(name)).sort(),
|
|
61
|
+
recoveryChoices: category === "ready" ? [] : ["retry", "switch", "cancel"],
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
child.once("error", (error) => finish(null, null, error));
|
|
65
|
+
child.once("close", (code, signal) => finish(code, signal));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export function classifyRuntimeFailure(text, phase = "readiness") {
|
|
69
|
+
const value = text.toLowerCase();
|
|
70
|
+
if (/not logged in|login required|unauthenticated|invalid authentication|expired.{0,20}token|token.{0,20}expired|\b401\b/.test(value))
|
|
71
|
+
return "authentication";
|
|
72
|
+
if (/forbidden|not authorized|unauthorized|entitlement|permission denied|\b403\b/.test(value))
|
|
73
|
+
return "authorization";
|
|
74
|
+
if (/rate.?limit|too many requests|\b429\b|quota exceeded/.test(value))
|
|
75
|
+
return "rate-limit";
|
|
76
|
+
if (/enotfound|econnreset|econnrefused|network|dns|socket hang up|timed? out|tls|certificate/.test(value))
|
|
77
|
+
return "network";
|
|
78
|
+
if (/cannot find module|module not found|sdk/.test(value))
|
|
79
|
+
return "sdk-load";
|
|
80
|
+
if (/malformed|invalid json|protocol|unexpected token/.test(value))
|
|
81
|
+
return "malformed-protocol";
|
|
82
|
+
if (/stream|agent turn|agent error/.test(value))
|
|
83
|
+
return "agent-stream";
|
|
84
|
+
if (/config|configuration|invalid model|unsupported model/.test(value))
|
|
85
|
+
return "configuration";
|
|
86
|
+
if (phase === "compiler-update")
|
|
87
|
+
return "compiler-update";
|
|
88
|
+
if (phase === "capability-discovery")
|
|
89
|
+
return "capability-discovery";
|
|
90
|
+
return "unknown";
|
|
91
|
+
}
|
|
92
|
+
export function sanitizeDiagnostics(text, maxBytes = RUNTIME_DIAGNOSTIC_LIMIT) {
|
|
93
|
+
const clean = text
|
|
94
|
+
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
95
|
+
.replace(/((?:api[_-]?key|access[_-]?token|auth(?:orization)?|password|secret)\s*[=:]\s*)(?:bearer\s+)?[^\s,;]+/gi, "$1<redacted>")
|
|
96
|
+
.replace(/\b(?:sk|rk|ghp|github_pat)_[A-Za-z0-9_-]+\b/g, "<redacted>")
|
|
97
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+\/-]+=*/gi, "Bearer <redacted>")
|
|
98
|
+
.replace(/\s+$/g, "")
|
|
99
|
+
.trim();
|
|
100
|
+
const bytes = Buffer.from(clean);
|
|
101
|
+
if (bytes.length <= maxBytes)
|
|
102
|
+
return clean;
|
|
103
|
+
return `${bytes.subarray(0, Math.max(0, maxBytes - 18)).toString("utf8")}\n... truncated ...`;
|
|
104
|
+
}
|
|
105
|
+
export function formatRuntimeProbeFailure(result) {
|
|
106
|
+
if (result.ok)
|
|
107
|
+
return `${result.executable} is ready.`;
|
|
108
|
+
const lines = [
|
|
109
|
+
`${result.executable} failed during ${result.phase} (${result.category}${result.timedOut ? ", timed out" : result.exitCode === null ? "" : `, exit ${result.exitCode}`}).`,
|
|
110
|
+
];
|
|
111
|
+
if (result.category === "authentication") {
|
|
112
|
+
lines.push(result.runtime === "claude"
|
|
113
|
+
? "Authenticate with `claude auth login --claudeai`, then retry."
|
|
114
|
+
: "Authenticate with `codex login`, then retry.");
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
lines.push("Review the diagnostic below, then retry, switch to a verified provider, or cancel.");
|
|
118
|
+
}
|
|
119
|
+
if (result.diagnostics)
|
|
120
|
+
lines.push("", result.diagnostics);
|
|
121
|
+
return lines.join("\n");
|
|
122
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { TicketDef } from "./tickets/ticketSchema.js";
|
|
2
|
+
import { type TicketStatus } from "./tickets/stateDb.js";
|
|
3
|
+
import { type DeliveryConfig } from "./tickets/delivery.js";
|
|
4
|
+
import { type TicketBuildSetupConfig } from "./tickets/setupConfig.js";
|
|
5
|
+
export declare const PROPOSAL_START = "RAFI_PROPOSAL_START";
|
|
6
|
+
export declare const PROPOSAL_END = "RAFI_PROPOSAL_END";
|
|
7
|
+
export interface TicketEdit {
|
|
8
|
+
id: string;
|
|
9
|
+
patch: Partial<Omit<TicketDef, "id">>;
|
|
10
|
+
}
|
|
11
|
+
export interface TicketSupersession {
|
|
12
|
+
replaced: string[];
|
|
13
|
+
replacements: string[];
|
|
14
|
+
reason: string;
|
|
15
|
+
}
|
|
16
|
+
export interface TicketStateChange {
|
|
17
|
+
id: string;
|
|
18
|
+
status: TicketStatus;
|
|
19
|
+
reason: string;
|
|
20
|
+
}
|
|
21
|
+
export interface SourceReconciliation {
|
|
22
|
+
source: string;
|
|
23
|
+
item: string;
|
|
24
|
+
disposition: "mapped" | "split" | "combined" | "deferred" | "excluded";
|
|
25
|
+
ticket_ids: string[];
|
|
26
|
+
reason?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface FutureWorkDisposition {
|
|
29
|
+
id: number;
|
|
30
|
+
disposition: "accepted" | "rejected" | "merged" | "queued";
|
|
31
|
+
}
|
|
32
|
+
export interface TicketPlanProposal {
|
|
33
|
+
version: 1;
|
|
34
|
+
title: string;
|
|
35
|
+
markdown: string;
|
|
36
|
+
additions: TicketDef[];
|
|
37
|
+
edits: TicketEdit[];
|
|
38
|
+
supersessions: TicketSupersession[];
|
|
39
|
+
state_changes: TicketStateChange[];
|
|
40
|
+
source_reconciliation: SourceReconciliation[];
|
|
41
|
+
delivery?: DeliveryConfig | null;
|
|
42
|
+
build_defaults?: Partial<TicketBuildSetupConfig> | null;
|
|
43
|
+
future_work: FutureWorkDisposition[];
|
|
44
|
+
next: {
|
|
45
|
+
ticket_ids: string[];
|
|
46
|
+
replace_existing: boolean;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export interface PlanningFingerprint {
|
|
50
|
+
path: string;
|
|
51
|
+
sha256: string | null;
|
|
52
|
+
}
|
|
53
|
+
export interface TicketPlanningContext {
|
|
54
|
+
tickets: TicketDef[];
|
|
55
|
+
states: Array<{
|
|
56
|
+
ticket_id: string;
|
|
57
|
+
status: TicketStatus;
|
|
58
|
+
}>;
|
|
59
|
+
futureWork: Array<{
|
|
60
|
+
id?: number;
|
|
61
|
+
summary: string;
|
|
62
|
+
disposition: string;
|
|
63
|
+
rationale: string | null;
|
|
64
|
+
}>;
|
|
65
|
+
existingNext: string[];
|
|
66
|
+
}
|
|
67
|
+
export declare function readTicketPlanningContext(projectDir: string): TicketPlanningContext;
|
|
68
|
+
export declare function ticketPlanningFingerprint(projectDir: string): PlanningFingerprint[];
|
|
69
|
+
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[];
|
|
72
|
+
export interface ApplyTicketPlanOptions {
|
|
73
|
+
expectedFingerprint: PlanningFingerprint[];
|
|
74
|
+
docsRoot: string;
|
|
75
|
+
now?: Date;
|
|
76
|
+
}
|
|
77
|
+
export interface AppliedTicketPlan {
|
|
78
|
+
added: string[];
|
|
79
|
+
edited: string[];
|
|
80
|
+
artifacts: string[];
|
|
81
|
+
backupDir: string;
|
|
82
|
+
}
|
|
83
|
+
export declare function applyApprovedTicketPlan(projectDir: string, proposal: TicketPlanProposal, opts: ApplyTicketPlanOptions): AppliedTicketPlan;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { stringify } from "yaml";
|
|
5
|
+
import { loadTicketsConfig, resolveTicketPaths, validateDocsRoot } from "./tickets/config.js";
|
|
6
|
+
import { loadTickets, validateTicketDefs } from "./tickets/ticketLoader.js";
|
|
7
|
+
import { StateDb } from "./tickets/stateDb.js";
|
|
8
|
+
import { detectCycles } from "./tickets/ticketLoader.js";
|
|
9
|
+
import { renderAndWrite } from "./tickets/renderMarkdown.js";
|
|
10
|
+
import { runAllValidation } from "./tickets/validate.js";
|
|
11
|
+
import { normalizeDeliveryConfig, validateDeliveryConfig } from "./tickets/delivery.js";
|
|
12
|
+
import { loadTicketSetupConfig, mergeTicketSetup, saveTicketSetupConfig } from "./tickets/setupConfig.js";
|
|
13
|
+
export const PROPOSAL_START = "RAFI_PROPOSAL_START";
|
|
14
|
+
export const PROPOSAL_END = "RAFI_PROPOSAL_END";
|
|
15
|
+
export function readTicketPlanningContext(projectDir) {
|
|
16
|
+
const config = loadTicketsConfig(projectDir);
|
|
17
|
+
const paths = resolveTicketPaths(config, projectDir);
|
|
18
|
+
const tickets = loadTickets(paths.tickets);
|
|
19
|
+
if (tickets.length > 0 && !existsSync(paths.stateDb))
|
|
20
|
+
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");
|
|
21
|
+
const db = new StateDb(paths.stateDb);
|
|
22
|
+
try {
|
|
23
|
+
const states = [...db.getAllStates().values()].map(({ ticket_id, status }) => ({ ticket_id, status }));
|
|
24
|
+
return {
|
|
25
|
+
tickets,
|
|
26
|
+
states,
|
|
27
|
+
futureWork: db.getFutureWork().map(({ id, summary, disposition, rationale }) => ({ id, summary, disposition, rationale })),
|
|
28
|
+
existingNext: states.filter((state) => state.status === "next").map((state) => state.ticket_id),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
db.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function ticketPlanningFingerprint(projectDir) {
|
|
36
|
+
const config = loadTicketsConfig(projectDir);
|
|
37
|
+
const paths = resolveTicketPaths(config, projectDir);
|
|
38
|
+
return ["rafi-config.yaml", relative(projectDir, paths.tickets), relative(projectDir, paths.stateDb), `${relative(projectDir, paths.stateDb)}-wal`, `${relative(projectDir, paths.stateDb)}-shm`, ".tickets/delivery.yaml"]
|
|
39
|
+
.map((path) => ({ path, sha256: fileHash(join(projectDir, path)) }));
|
|
40
|
+
}
|
|
41
|
+
export function planningFingerprintChanges(projectDir, expected) {
|
|
42
|
+
return expected.filter((item) => fileHash(join(projectDir, item.path)) !== item.sha256).map((item) => item.path);
|
|
43
|
+
}
|
|
44
|
+
export function extractTicketPlanProposal(output, existingTickets = []) {
|
|
45
|
+
const start = output.lastIndexOf(PROPOSAL_START);
|
|
46
|
+
const end = output.lastIndexOf(PROPOSAL_END);
|
|
47
|
+
if (start < 0 || end <= start)
|
|
48
|
+
throw new Error(`planner output is missing ${PROPOSAL_START}/${PROPOSAL_END}`);
|
|
49
|
+
const raw = output.slice(start + PROPOSAL_START.length, end).trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(raw);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
throw new Error(`planner proposal is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
56
|
+
}
|
|
57
|
+
const issues = validateTicketPlanProposal(parsed, existingTickets);
|
|
58
|
+
if (issues.length)
|
|
59
|
+
throw new Error(`invalid planner proposal:\n${issues.map((issue) => `- ${issue}`).join("\n")}`);
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
export function validateTicketPlanProposal(value, existingTickets = []) {
|
|
63
|
+
const issues = [];
|
|
64
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
65
|
+
return ["proposal must be an object"];
|
|
66
|
+
const proposal = value;
|
|
67
|
+
if (proposal.version !== 1)
|
|
68
|
+
issues.push("version must be 1");
|
|
69
|
+
for (const field of ["title", "markdown"])
|
|
70
|
+
if (typeof proposal[field] !== "string" || !proposal[field])
|
|
71
|
+
issues.push(`${field} must be a non-empty string`);
|
|
72
|
+
for (const field of ["additions", "edits", "supersessions", "state_changes", "source_reconciliation", "future_work"])
|
|
73
|
+
if (!Array.isArray(proposal[field]))
|
|
74
|
+
issues.push(`${field} must be an array`);
|
|
75
|
+
if (!proposal.next || typeof proposal.next !== "object" || Array.isArray(proposal.next))
|
|
76
|
+
issues.push("next must be an object");
|
|
77
|
+
if (issues.length)
|
|
78
|
+
return issues;
|
|
79
|
+
const p = proposal;
|
|
80
|
+
const staged = [...existingTickets];
|
|
81
|
+
const knownExisting = new Set(existingTickets.map((ticket) => ticket.id));
|
|
82
|
+
for (const addition of p.additions) {
|
|
83
|
+
if (!addition || typeof addition !== "object") {
|
|
84
|
+
issues.push("addition must be a ticket object");
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (knownExisting.has(addition.id) || staged.some((ticket) => ticket.id === addition.id))
|
|
88
|
+
issues.push(`addition reuses ticket ID ${addition.id}`);
|
|
89
|
+
staged.push(addition);
|
|
90
|
+
}
|
|
91
|
+
for (const edit of p.edits) {
|
|
92
|
+
if (!edit || typeof edit.id !== "string" || !edit.patch || typeof edit.patch !== "object" || Array.isArray(edit.patch)) {
|
|
93
|
+
issues.push("edit must contain id and patch");
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!staged.some((ticket) => ticket.id === edit.id))
|
|
97
|
+
issues.push(`edit references unknown ticket ${edit.id}`);
|
|
98
|
+
if ("id" in edit.patch)
|
|
99
|
+
issues.push(`edit ${edit.id} cannot change a stable ticket ID`);
|
|
100
|
+
const index = staged.findIndex((ticket) => ticket.id === edit.id);
|
|
101
|
+
if (index >= 0)
|
|
102
|
+
staged[index] = { ...staged[index], ...edit.patch, id: edit.id };
|
|
103
|
+
}
|
|
104
|
+
const ids = new Set(staged.map((ticket) => ticket.id));
|
|
105
|
+
for (const link of p.supersessions) {
|
|
106
|
+
if (!link.reason?.trim() || !link.replaced?.length || !link.replacements?.length)
|
|
107
|
+
issues.push("each supersession needs replaced, replacements, and reason");
|
|
108
|
+
for (const id of [...(link.replaced ?? []), ...(link.replacements ?? [])])
|
|
109
|
+
if (!ids.has(id))
|
|
110
|
+
issues.push(`supersession references unknown ticket ${id}`);
|
|
111
|
+
}
|
|
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 item of p.source_reconciliation) {
|
|
116
|
+
if (!item.source?.trim() || !item.item?.trim())
|
|
117
|
+
issues.push("source reconciliation needs source and item");
|
|
118
|
+
if ((item.disposition === "deferred" || item.disposition === "excluded") && !item.reason?.trim())
|
|
119
|
+
issues.push(`${item.source}:${item.item} ${item.disposition} needs a reason`);
|
|
120
|
+
for (const id of item.ticket_ids ?? [])
|
|
121
|
+
if (!ids.has(id))
|
|
122
|
+
issues.push(`source reconciliation references unknown ticket ${id}`);
|
|
123
|
+
if (["mapped", "split", "combined"].includes(item.disposition)) {
|
|
124
|
+
for (const id of item.ticket_ids ?? []) {
|
|
125
|
+
const ticket = staged.find((candidate) => candidate.id === id);
|
|
126
|
+
if (ticket && !(ticket.source_refs ?? []).some((ref) => ref.source === item.source && ref.item === item.item)) {
|
|
127
|
+
issues.push(`ticket ${id} is missing source_refs provenance for ${item.source}:${item.item}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!Array.isArray(p.next.ticket_ids) || typeof p.next.replace_existing !== "boolean")
|
|
133
|
+
issues.push("next needs ticket_ids and replace_existing");
|
|
134
|
+
else
|
|
135
|
+
for (const id of p.next.ticket_ids)
|
|
136
|
+
if (!ids.has(id))
|
|
137
|
+
issues.push(`next references unknown ticket ${id}`);
|
|
138
|
+
issues.push(...validateTicketDefs(staged).map((issue) => `${issue.path}: ${issue.message}`));
|
|
139
|
+
issues.push(...detectCycles(staged).map((cycle) => `dependency cycle: ${cycle}`));
|
|
140
|
+
if (p.delivery)
|
|
141
|
+
issues.push(...validateDeliveryConfig(p.delivery, staged).map((issue) => `delivery ${issue.path}: ${issue.message}`));
|
|
142
|
+
return [...new Set(issues)];
|
|
143
|
+
}
|
|
144
|
+
export function applyApprovedTicketPlan(projectDir, proposal, opts) {
|
|
145
|
+
const root = resolve(projectDir);
|
|
146
|
+
const drift = planningFingerprintChanges(root, opts.expectedFingerprint);
|
|
147
|
+
if (drift.length)
|
|
148
|
+
throw new Error(`planning inputs changed after proposal generation: ${drift.join(", ")}; refresh the proposal and approve again`);
|
|
149
|
+
const config = loadTicketsConfig(root);
|
|
150
|
+
const paths = resolveTicketPaths(config, root);
|
|
151
|
+
const existing = loadTickets(paths.tickets);
|
|
152
|
+
if (existing.length > 0 && !existsSync(paths.stateDb))
|
|
153
|
+
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);
|
|
155
|
+
if (proposalIssues.length)
|
|
156
|
+
throw new Error(`approved proposal is invalid:\n${proposalIssues.map((issue) => `- ${issue}`).join("\n")}`);
|
|
157
|
+
const lockPath = join(root, ".tickets", "planning.lock");
|
|
158
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
159
|
+
let lock;
|
|
160
|
+
try {
|
|
161
|
+
lock = openSync(lockPath, "wx", 0o600);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
throw new Error(`another ticket planning apply holds ${lockPath}`);
|
|
165
|
+
}
|
|
166
|
+
const lockedDrift = planningFingerprintChanges(root, opts.expectedFingerprint);
|
|
167
|
+
if (lockedDrift.length) {
|
|
168
|
+
closeSync(lock);
|
|
169
|
+
rmSync(lockPath, { force: true });
|
|
170
|
+
throw new Error(`planning inputs changed while acquiring the apply lock: ${lockedDrift.join(", ")}; refresh the proposal and approve again`);
|
|
171
|
+
}
|
|
172
|
+
const docsRoot = validateDocsRoot(root, opts.docsRoot);
|
|
173
|
+
const stamp = (opts.now ?? new Date()).toISOString().replace(/[:.]/g, "-");
|
|
174
|
+
const backupDir = join(root, ".tickets", "backups", `ticket-plan-${stamp}-${randomUUID().slice(0, 8)}`);
|
|
175
|
+
const latestPlan = join(root, docsRoot, "rafi-ticket-plan.md");
|
|
176
|
+
const historyPlan = join(root, docsRoot, "rafi-ticket-plans", `${stamp}.md`);
|
|
177
|
+
const deliveryPath = join(root, ".tickets", "delivery.yaml");
|
|
178
|
+
const configPath = join(root, "rafi-config.yaml");
|
|
179
|
+
const material = [paths.tickets, paths.stateDb, `${paths.stateDb}-wal`, `${paths.stateDb}-shm`, deliveryPath, configPath, latestPlan, historyPlan, paths.progressDoc, paths.archiveDoc];
|
|
180
|
+
mkdirSync(backupDir, { recursive: true });
|
|
181
|
+
const backedUp = [];
|
|
182
|
+
for (const original of material)
|
|
183
|
+
if (existsSync(original)) {
|
|
184
|
+
const backup = join(backupDir, `${backedUp.length}-${original.split(/[\\/]/).pop()}`);
|
|
185
|
+
copyFileSync(original, backup);
|
|
186
|
+
backedUp.push({ original, backup });
|
|
187
|
+
}
|
|
188
|
+
writeFileSync(join(backupDir, "journal.json"), `${JSON.stringify({ status: "prepared", createdAt: new Date().toISOString(), files: backedUp }, null, 2)}\n`, "utf8");
|
|
189
|
+
try {
|
|
190
|
+
const staged = stageTickets(existing, proposal);
|
|
191
|
+
atomicWrite(paths.tickets, stringify({ tickets: staged }, { lineWidth: 120 }));
|
|
192
|
+
if (proposal.delivery)
|
|
193
|
+
atomicWrite(deliveryPath, stringify(normalizeDeliveryConfig(proposal.delivery), { lineWidth: 120 }));
|
|
194
|
+
if (proposal.build_defaults) {
|
|
195
|
+
const setup = mergeTicketSetup(loadTicketSetupConfig(root), { build: proposal.build_defaults });
|
|
196
|
+
saveTicketSetupConfig(root, setup);
|
|
197
|
+
}
|
|
198
|
+
mkdirSync(dirname(latestPlan), { recursive: true });
|
|
199
|
+
mkdirSync(dirname(historyPlan), { recursive: true });
|
|
200
|
+
atomicWrite(latestPlan, `${proposal.markdown.trimEnd()}\n`);
|
|
201
|
+
atomicWrite(historyPlan, `${proposal.markdown.trimEnd()}\n`);
|
|
202
|
+
const db = new StateDb(paths.stateDb);
|
|
203
|
+
try {
|
|
204
|
+
const now = new Date().toISOString();
|
|
205
|
+
db.transaction(() => {
|
|
206
|
+
const existingNext = [...db.getAllStates().values()].filter((state) => state.status === "next").map((state) => state.ticket_id);
|
|
207
|
+
if (proposal.next.replace_existing)
|
|
208
|
+
for (const id of existingNext)
|
|
209
|
+
db.upsertState(id, { status: "planned", updated_by: "rafi tickets plan" }, now);
|
|
210
|
+
for (const link of proposal.supersessions)
|
|
211
|
+
for (const id of link.replaced) {
|
|
212
|
+
const current = db.getState(id);
|
|
213
|
+
if (current?.status !== "done")
|
|
214
|
+
db.upsertState(id, { status: "canceled", last_error: `Superseded: ${link.reason}`, updated_by: "rafi tickets plan" }, now);
|
|
215
|
+
db.insertEvent({ timestamp: now, actor: "rafi tickets plan", ticket_id: id, event_type: "superseded", old_status: current?.status ?? "planned", new_status: current?.status === "done" ? "done" : "canceled", summary: link.reason, validation: null, evidence: current?.evidence ?? null, payload_json: JSON.stringify({ replacements: link.replacements }) });
|
|
216
|
+
}
|
|
217
|
+
for (const change of proposal.state_changes) {
|
|
218
|
+
const current = db.getState(change.id);
|
|
219
|
+
if (current?.status === "done" && change.status !== "done")
|
|
220
|
+
throw new Error(`proposal cannot change completed ticket ${change.id} away from done`);
|
|
221
|
+
db.upsertState(change.id, { status: change.status, updated_by: "rafi tickets plan" }, now);
|
|
222
|
+
}
|
|
223
|
+
for (const id of proposal.next.ticket_ids)
|
|
224
|
+
db.upsertState(id, { status: "next", updated_by: "rafi tickets plan" }, now);
|
|
225
|
+
for (const disposition of proposal.future_work) {
|
|
226
|
+
if (!db.getFutureWorkById(disposition.id))
|
|
227
|
+
throw new Error(`future work item ${disposition.id} does not exist`);
|
|
228
|
+
db.updateFutureWorkDisposition(disposition.id, disposition.disposition);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
const states = db.getAllStates();
|
|
232
|
+
renderAndWrite({ config, projectDir: root, ticketDefs: staged, states, db });
|
|
233
|
+
const validation = runAllValidation(config, root, staged, states, db);
|
|
234
|
+
const errors = validation.filter((issue) => issue.severity === "error");
|
|
235
|
+
if (errors.length)
|
|
236
|
+
throw new Error(`post-apply tracker validation failed:\n${errors.map((issue) => `- ${issue.message}`).join("\n")}`);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
db.close();
|
|
240
|
+
}
|
|
241
|
+
writeFileSync(join(backupDir, "journal.json"), `${JSON.stringify({ status: "committed", committedAt: new Date().toISOString(), files: backedUp }, null, 2)}\n`, "utf8");
|
|
242
|
+
return { added: proposal.additions.map((ticket) => ticket.id), edited: proposal.edits.map((edit) => edit.id), artifacts: [relative(root, latestPlan), relative(root, historyPlan)], backupDir: relative(root, backupDir) };
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
for (const original of material)
|
|
246
|
+
if (existsSync(original) && !backedUp.some((entry) => entry.original === original))
|
|
247
|
+
rmSync(original);
|
|
248
|
+
for (const entry of backedUp)
|
|
249
|
+
copyFileSync(entry.backup, entry.original);
|
|
250
|
+
writeFileSync(join(backupDir, "journal.json"), `${JSON.stringify({ status: "rolled_back", rolledBackAt: new Date().toISOString(), error: err instanceof Error ? err.message : String(err), files: backedUp }, null, 2)}\n`, "utf8");
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
closeSync(lock);
|
|
255
|
+
rmSync(lockPath, { force: true });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function stageTickets(existing, proposal) {
|
|
259
|
+
const tickets = existing.map((ticket) => ({ ...ticket }));
|
|
260
|
+
for (const edit of proposal.edits) {
|
|
261
|
+
const index = tickets.findIndex((ticket) => ticket.id === edit.id);
|
|
262
|
+
tickets[index] = { ...tickets[index], ...edit.patch, id: edit.id };
|
|
263
|
+
}
|
|
264
|
+
tickets.push(...proposal.additions.map((ticket) => ({ ...ticket })));
|
|
265
|
+
for (const link of proposal.supersessions) {
|
|
266
|
+
for (const id of link.replaced) {
|
|
267
|
+
const ticket = tickets.find((candidate) => candidate.id === id);
|
|
268
|
+
ticket.superseded_by = [...new Set([...(ticket.superseded_by ?? []), ...link.replacements])];
|
|
269
|
+
}
|
|
270
|
+
for (const id of link.replacements) {
|
|
271
|
+
const ticket = tickets.find((candidate) => candidate.id === id);
|
|
272
|
+
ticket.supersedes = [...new Set([...(ticket.supersedes ?? []), ...link.replaced])];
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return tickets.sort((a, b) => a.order - b.order);
|
|
276
|
+
}
|
|
277
|
+
function atomicWrite(path, content) {
|
|
278
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
279
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
280
|
+
writeFileSync(tmp, content, "utf8");
|
|
281
|
+
renameSync(tmp, path);
|
|
282
|
+
}
|
|
283
|
+
function fileHash(path) {
|
|
284
|
+
if (!existsSync(path))
|
|
285
|
+
return null;
|
|
286
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
287
|
+
}
|
package/dist/tickets/commands.js
CHANGED
|
@@ -123,6 +123,22 @@ export function cmdInit(projectDir, opts) {
|
|
|
123
123
|
},
|
|
124
124
|
},
|
|
125
125
|
},
|
|
126
|
+
source_refs: {
|
|
127
|
+
type: "array",
|
|
128
|
+
items: {
|
|
129
|
+
type: "object",
|
|
130
|
+
required: ["source", "item"],
|
|
131
|
+
properties: {
|
|
132
|
+
source: { type: "string", minLength: 1 },
|
|
133
|
+
item: { type: "string", minLength: 1 },
|
|
134
|
+
url: { type: ["string", "null"] },
|
|
135
|
+
fingerprint: { type: ["string", "null"] },
|
|
136
|
+
note: { type: ["string", "null"] },
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
superseded_by: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
141
|
+
supersedes: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
126
142
|
},
|
|
127
143
|
};
|
|
128
144
|
writeFileSync(join(ticketsDir, "schema", "tickets.schema.json"), JSON.stringify(ticketSchema, null, 2), "utf8");
|