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.
Files changed (51) hide show
  1. package/dist/agentRun.d.ts +3 -2
  2. package/dist/agentRun.js +27 -8
  3. package/dist/branch/git.d.ts +3 -0
  4. package/dist/branch/git.js +31 -5
  5. package/dist/branch/github.d.ts +2 -2
  6. package/dist/branch/github.js +2 -2
  7. package/dist/branch/gitlab.d.ts +3 -2
  8. package/dist/branch/gitlab.js +10 -4
  9. package/dist/branch/planner.d.ts +3 -1
  10. package/dist/branch/planner.js +24 -2
  11. package/dist/branch/runner.d.ts +10 -1
  12. package/dist/branch/runner.js +41 -13
  13. package/dist/branch/types.d.ts +4 -0
  14. package/dist/buildRuns.d.ts +35 -0
  15. package/dist/buildRuns.js +149 -0
  16. package/dist/cli/runtimeAuthPrompt.d.ts +1 -1
  17. package/dist/cli/runtimeAuthPrompt.js +2 -2
  18. package/dist/cli/start.js +153 -16
  19. package/dist/cli/status.d.ts +2 -0
  20. package/dist/cli/status.js +71 -55
  21. package/dist/cli/tickets.js +57 -10
  22. package/dist/foreman.d.ts +3 -1
  23. package/dist/foreman.js +46 -4
  24. package/dist/index.js +0 -0
  25. package/dist/interviews.d.ts +26 -4
  26. package/dist/interviews.js +71 -4
  27. package/dist/log.d.ts +1 -1
  28. package/dist/roles.d.ts +11 -0
  29. package/dist/roles.js +8 -0
  30. package/dist/runtimeAuth.d.ts +1 -1
  31. package/dist/runtimeAuth.js +6 -25
  32. package/dist/runtimeReadiness.d.ts +14 -0
  33. package/dist/runtimeReadiness.js +122 -0
  34. package/dist/ticketPlanning.d.ts +83 -0
  35. package/dist/ticketPlanning.js +287 -0
  36. package/dist/tickets/commands.js +16 -0
  37. package/dist/tickets/delivery.d.ts +38 -0
  38. package/dist/tickets/delivery.js +152 -0
  39. package/dist/tickets/importer.js +4 -0
  40. package/dist/tickets/renderMarkdown.js +9 -0
  41. package/dist/tickets/setupConfig.d.ts +8 -0
  42. package/dist/tickets/setupConfig.js +47 -2
  43. package/dist/tickets/sourceFetch.d.ts +24 -0
  44. package/dist/tickets/sourceFetch.js +246 -0
  45. package/dist/tickets/stateDb.d.ts +10 -0
  46. package/dist/tickets/stateDb.js +24 -0
  47. package/dist/tickets/ticketLoader.js +18 -0
  48. package/dist/tickets/ticketSchema.d.ts +93 -0
  49. package/dist/tickets/ticketSchema.js +17 -0
  50. package/dist/tickets/validate.js +12 -0
  51. package/package.json +29 -2
@@ -0,0 +1,38 @@
1
+ import type { TicketDef } from "./ticketSchema.js";
2
+ import type { TicketState } from "./stateDb.js";
3
+ import type { TicketBuildCompletionMode, TicketBuildMergeMethod, TicketBuildProvider } from "./setupConfig.js";
4
+ export declare const DELIVERY_FILE = ".tickets/delivery.yaml";
5
+ export type DeliveryBranchMode = "current" | "per-ticket" | "shared";
6
+ export type DeliveryDependencyMode = "combine" | "wait" | "stack";
7
+ export interface DeliveryUnit {
8
+ id: string;
9
+ tickets: string[];
10
+ branch_mode: DeliveryBranchMode;
11
+ completion?: TicketBuildCompletionMode;
12
+ provider?: TicketBuildProvider;
13
+ pr_ready?: boolean;
14
+ merge_method?: TicketBuildMergeMethod;
15
+ cleanup?: boolean;
16
+ depends_on?: string[];
17
+ dependency_mode?: DeliveryDependencyMode;
18
+ }
19
+ export interface DeliveryConfig {
20
+ version: 1;
21
+ units: DeliveryUnit[];
22
+ }
23
+ export interface DeliveryIssue {
24
+ path: string;
25
+ message: string;
26
+ }
27
+ export declare function loadDeliveryConfig(projectDir: string): DeliveryConfig | undefined;
28
+ export declare function normalizeDeliveryConfig(raw: unknown): DeliveryConfig;
29
+ export declare function validateDeliveryConfig(raw: unknown, ticketDefs?: TicketDef[]): DeliveryIssue[];
30
+ export interface DeliveryUnitProgress {
31
+ unit: DeliveryUnit;
32
+ completed: number;
33
+ remaining: string[];
34
+ state: "complete" | "ready" | "waiting" | "resume";
35
+ }
36
+ export declare function deliveryProgress(config: DeliveryConfig, states: Map<string, TicketState>): DeliveryUnitProgress[];
37
+ export declare function formatDeliverySummary(config: DeliveryConfig, states: Map<string, TicketState>): string[];
38
+ export declare function selectDeliveryUnitForRun(config: DeliveryConfig, states: Map<string, TicketState>, skipUnitIds?: readonly string[]): DeliveryUnitProgress | undefined;
@@ -0,0 +1,152 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse } from "yaml";
4
+ export const DELIVERY_FILE = ".tickets/delivery.yaml";
5
+ export function loadDeliveryConfig(projectDir) {
6
+ const path = join(projectDir, DELIVERY_FILE);
7
+ if (!existsSync(path))
8
+ return undefined;
9
+ const raw = parse(readFileSync(path, "utf8"));
10
+ const issues = validateDeliveryConfig(raw);
11
+ if (issues.length > 0) {
12
+ throw new Error(`${DELIVERY_FILE}:\n${issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n")}`);
13
+ }
14
+ return normalizeDeliveryConfig(raw);
15
+ }
16
+ export function normalizeDeliveryConfig(raw) {
17
+ const value = raw;
18
+ return {
19
+ version: 1,
20
+ units: value.units.map((unit) => ({
21
+ id: String(unit.id),
22
+ tickets: [...unit.tickets],
23
+ branch_mode: unit.branch_mode,
24
+ ...(unit.completion ? { completion: unit.completion } : {}),
25
+ ...(unit.provider ? { provider: unit.provider } : {}),
26
+ ...(typeof unit.pr_ready === "boolean" ? { pr_ready: unit.pr_ready } : {}),
27
+ ...(unit.merge_method ? { merge_method: unit.merge_method } : {}),
28
+ ...(typeof unit.cleanup === "boolean" ? { cleanup: unit.cleanup } : {}),
29
+ ...(Array.isArray(unit.depends_on) ? { depends_on: [...unit.depends_on] } : {}),
30
+ ...(unit.dependency_mode ? { dependency_mode: unit.dependency_mode } : {}),
31
+ })),
32
+ };
33
+ }
34
+ export function validateDeliveryConfig(raw, ticketDefs) {
35
+ const issues = [];
36
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
37
+ return [{ path: "$", message: "expected an object" }];
38
+ const value = raw;
39
+ if (value.version !== undefined && value.version !== 1)
40
+ issues.push({ path: "version", message: "expected 1" });
41
+ if (!Array.isArray(value.units))
42
+ return [...issues, { path: "units", message: "expected an array" }];
43
+ const unitIds = new Set();
44
+ const assigned = new Map();
45
+ const knownTickets = ticketDefs ? new Set(ticketDefs.map((ticket) => ticket.id)) : undefined;
46
+ for (let index = 0; index < value.units.length; index++) {
47
+ const path = `units[${index}]`;
48
+ const unit = value.units[index];
49
+ if (!unit || typeof unit !== "object" || Array.isArray(unit)) {
50
+ issues.push({ path, message: "expected an object" });
51
+ continue;
52
+ }
53
+ const u = unit;
54
+ if (typeof u.id !== "string" || !/^[A-Za-z][A-Za-z0-9-_]*$/.test(u.id))
55
+ issues.push({ path: `${path}.id`, message: "expected a stable identifier" });
56
+ else if (unitIds.has(u.id))
57
+ issues.push({ path: `${path}.id`, message: `duplicate unit ID: ${u.id}` });
58
+ else
59
+ unitIds.add(u.id);
60
+ if (!Array.isArray(u.tickets) || u.tickets.length === 0 || !u.tickets.every((id) => typeof id === "string" && id.length > 0)) {
61
+ issues.push({ path: `${path}.tickets`, message: "expected a non-empty ticket ID array" });
62
+ }
63
+ else {
64
+ for (const id of u.tickets) {
65
+ if (knownTickets && !knownTickets.has(id))
66
+ issues.push({ path: `${path}.tickets`, message: `unknown ticket: ${id}` });
67
+ const prior = assigned.get(id);
68
+ if (prior)
69
+ issues.push({ path: `${path}.tickets`, message: `${id} is already assigned to ${prior}` });
70
+ else
71
+ assigned.set(id, typeof u.id === "string" ? u.id : path);
72
+ }
73
+ }
74
+ enumValue(issues, `${path}.branch_mode`, u.branch_mode, ["current", "per-ticket", "shared"], true);
75
+ enumValue(issues, `${path}.completion`, u.completion, ["pr", "auto-merge", "direct-merge", "none"]);
76
+ enumValue(issues, `${path}.provider`, u.provider, ["auto", "github", "gitlab", "local"]);
77
+ enumValue(issues, `${path}.merge_method`, u.merge_method, ["squash", "merge", "rebase"]);
78
+ enumValue(issues, `${path}.dependency_mode`, u.dependency_mode, ["combine", "wait", "stack"]);
79
+ for (const field of ["pr_ready", "cleanup"]) {
80
+ if (u[field] !== undefined && typeof u[field] !== "boolean")
81
+ issues.push({ path: `${path}.${field}`, message: "expected a boolean" });
82
+ }
83
+ if (u.depends_on !== undefined && (!Array.isArray(u.depends_on) || !u.depends_on.every((id) => typeof id === "string"))) {
84
+ issues.push({ path: `${path}.depends_on`, message: "expected a unit ID array" });
85
+ }
86
+ }
87
+ const units = value.units.filter((unit) => Boolean(unit && typeof unit === "object" && !Array.isArray(unit)));
88
+ for (const unit of units) {
89
+ for (const dep of Array.isArray(unit.depends_on) ? unit.depends_on : []) {
90
+ if (!unitIds.has(String(dep)))
91
+ issues.push({ path: `units[${String(unit.id)}].depends_on`, message: `unknown unit: ${String(dep)}` });
92
+ if (dep === unit.id)
93
+ issues.push({ path: `units[${String(unit.id)}].depends_on`, message: "unit cannot depend on itself" });
94
+ }
95
+ }
96
+ for (const cycle of deliveryCycles(units))
97
+ issues.push({ path: "units", message: `dependency cycle: ${cycle}` });
98
+ return issues;
99
+ }
100
+ function enumValue(issues, path, value, allowed, required = false) {
101
+ if (value === undefined && !required)
102
+ return;
103
+ if (typeof value !== "string" || !allowed.includes(value))
104
+ issues.push({ path, message: `expected one of: ${allowed.join(", ")}` });
105
+ }
106
+ function deliveryCycles(units) {
107
+ const deps = new Map(units.map((unit) => [String(unit.id), (Array.isArray(unit.depends_on) ? unit.depends_on : []).map(String)]));
108
+ const visited = new Set();
109
+ const stack = new Set();
110
+ const cycles = [];
111
+ const walk = (id, path) => {
112
+ if (stack.has(id)) {
113
+ cycles.push([...path.slice(path.indexOf(id)), id].join(" -> "));
114
+ return;
115
+ }
116
+ if (visited.has(id))
117
+ return;
118
+ visited.add(id);
119
+ stack.add(id);
120
+ for (const dep of deps.get(id) ?? [])
121
+ if (deps.has(dep))
122
+ walk(dep, [...path, id]);
123
+ stack.delete(id);
124
+ };
125
+ for (const id of deps.keys())
126
+ walk(id, []);
127
+ return cycles;
128
+ }
129
+ 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));
131
+ const out = [];
132
+ for (const unit of config.units) {
133
+ const remaining = unit.tickets.filter((id) => !["done", "canceled"].includes(states.get(id)?.status ?? "planned"));
134
+ const started = unit.tickets.some((id) => ["in_progress", "blocked", "done"].includes(states.get(id)?.status ?? "planned"));
135
+ const waiting = (unit.depends_on ?? []).some((id) => !completedUnits.has(id));
136
+ out.push({
137
+ unit,
138
+ completed: unit.tickets.length - remaining.length,
139
+ remaining,
140
+ state: remaining.length === 0 ? "complete" : waiting ? "waiting" : started ? "resume" : "ready",
141
+ });
142
+ }
143
+ return out;
144
+ }
145
+ export function formatDeliverySummary(config, states) {
146
+ return deliveryProgress(config, states).map(({ unit, completed, remaining, state }) => `${unit.id}: ${completed}/${unit.tickets.length} complete; ${remaining.length} remaining; ${unit.branch_mode}; ${unit.completion ?? "repository default"}; ${state}${(unit.depends_on ?? []).length ? `; depends on ${(unit.depends_on ?? []).join(", ")}` : ""}`);
147
+ }
148
+ export function selectDeliveryUnitForRun(config, states, skipUnitIds = []) {
149
+ const skipped = new Set(skipUnitIds);
150
+ const progress = deliveryProgress(config, states).filter((item) => !skipped.has(item.unit.id));
151
+ return progress.find((item) => item.state === "resume") ?? progress.find((item) => item.state === "ready");
152
+ }
@@ -348,6 +348,10 @@ function importedItemToTicket(item, id, order, existing) {
348
348
  key: item.key ?? null,
349
349
  url: item.url ?? null,
350
350
  }),
351
+ source_refs: [
352
+ ...(existing?.source_refs ?? []).filter((ref) => !(ref.source === item.provider && ref.item === item.providerId)),
353
+ { source: item.provider, item: item.providerId, url: item.url ?? null },
354
+ ],
351
355
  };
352
356
  }
353
357
  function buildImportedNotes(item) {
@@ -2,6 +2,7 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } from "
2
2
  import { dirname, join } from "node:path";
3
3
  import { DateTime } from "luxon";
4
4
  import { buildNextQueue, buildActiveStatusRows } from "./queue.js";
5
+ import { formatDeliverySummary, loadDeliveryConfig } from "./delivery.js";
5
6
  // ── Table helpers ─────────────────────────────────────────────────────────────
6
7
  function esc(v) {
7
8
  return v.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
@@ -90,6 +91,14 @@ export function renderProgressDoc(input) {
90
91
  ["Remaining Tickets", String(queueRows.length > 0 ? "≥" + queueRows.length : "0")],
91
92
  ])));
92
93
  lines.push("");
94
+ // Delivery units are optional; repositories without delivery.yaml retain legacy defaults.
95
+ const delivery = loadDeliveryConfig(projectDir);
96
+ if (delivery) {
97
+ lines.push("## Delivery Units");
98
+ const deliveryRows = formatDeliverySummary(delivery, states);
99
+ lines.push(section("DELIVERY_UNITS", deliveryRows.length > 0 ? deliveryRows.map((row) => `- ${row}`).join("\n") : "_No delivery units configured._"));
100
+ lines.push("");
101
+ }
93
102
  // Rules
94
103
  if (config.rendering.includeRulesInProgressDoc) {
95
104
  lines.push(rulesContent);
@@ -20,6 +20,9 @@ export type TicketSourceConfig = {
20
20
  email_env: string;
21
21
  token_env: string;
22
22
  jql: string;
23
+ } | {
24
+ type: "url";
25
+ url: string;
23
26
  };
24
27
  export interface TicketPopulateSetupConfig {
25
28
  source_handling: "saved" | "prompt" | "manual";
@@ -74,6 +77,11 @@ export declare function configuredPlanningSources(projectDir: string): string[];
74
77
  export declare function externalSources(setup: TicketsSetupConfig | undefined): Extract<TicketSourceConfig, {
75
78
  type: "linear" | "jira";
76
79
  }>[];
80
+ export declare function urlSources(setup: TicketsSetupConfig | undefined): Extract<TicketSourceConfig, {
81
+ type: "url";
82
+ }>[];
83
+ export declare function normalizedSourceKey(source: TicketSourceConfig): string;
84
+ export declare function dedupeTicketSources(sources: TicketSourceConfig[]): TicketSourceConfig[];
77
85
  export declare function configuredDocsRoot(projectDir: string): string | undefined;
78
86
  export declare function configuredAppName(projectDir: string): string | undefined;
79
87
  export declare function configuredHarnessTargets(projectDir: string): HarnessTarget[] | undefined;
@@ -4,7 +4,7 @@ import { join } from "node:path";
4
4
  import { parse, stringify } from "yaml";
5
5
  import { loadDefaults } from "special-agents";
6
6
  export const RAFI_CONFIG_FILE = "rafi-config.yaml";
7
- const AGENT_NAMES = ["builder", "qa", "planner", "ticket-maker"];
7
+ const AGENT_NAMES = ["builder", "qa", "planner", "ticket-maker", "uninstaller"];
8
8
  const SKILL_NAMES = [
9
9
  "better-sqlite3-rebuild",
10
10
  "grill-me",
@@ -121,6 +121,34 @@ export function configuredPlanningSources(projectDir) {
121
121
  export function externalSources(setup) {
122
122
  return (setup?.sources ?? []).filter((source) => source.type === "linear" || source.type === "jira");
123
123
  }
124
+ export function urlSources(setup) {
125
+ return (setup?.sources ?? []).filter((source) => source.type === "url");
126
+ }
127
+ export function normalizedSourceKey(source) {
128
+ if (source.type === "local")
129
+ return `local:${unique(source.paths.map((path) => path.trim())).sort().join("|")}`;
130
+ if (source.type === "url") {
131
+ const url = new URL(source.url);
132
+ url.hash = "";
133
+ url.hostname = url.hostname.toLowerCase();
134
+ if ((url.protocol === "https:" && url.port === "443") || (url.protocol === "http:" && url.port === "80"))
135
+ url.port = "";
136
+ return `url:${url.toString()}`;
137
+ }
138
+ if (source.type === "linear")
139
+ return `linear:${source.api_key_env}:${source.team_key ?? ""}:${source.filter ?? ""}`;
140
+ return `jira:${source.site.replace(/\/+$/, "").toLowerCase()}:${source.jql}`;
141
+ }
142
+ export function dedupeTicketSources(sources) {
143
+ const seen = new Set();
144
+ return sources.filter((source) => {
145
+ const key = normalizedSourceKey(source);
146
+ if (seen.has(key))
147
+ return false;
148
+ seen.add(key);
149
+ return true;
150
+ });
151
+ }
124
152
  export function configuredDocsRoot(projectDir) {
125
153
  const raw = loadRafiConfigObject(projectDir);
126
154
  const docs = raw?.docs;
@@ -271,7 +299,24 @@ function normalizeSource(value, label) {
271
299
  jql,
272
300
  };
273
301
  }
274
- throw new Error(`${label}.type: expected local, linear, or jira`);
302
+ if (raw.type === "url") {
303
+ const url = stringField(raw.url);
304
+ if (!url)
305
+ throw new Error(`${label}.url: expected a public HTTP(S) URL`);
306
+ let parsed;
307
+ try {
308
+ parsed = new URL(url);
309
+ }
310
+ catch {
311
+ throw new Error(`${label}.url: expected a valid URL`);
312
+ }
313
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || parsed.username || parsed.password) {
314
+ throw new Error(`${label}.url: expected HTTP(S) without embedded credentials`);
315
+ }
316
+ parsed.hash = "";
317
+ return { type: "url", url: parsed.toString() };
318
+ }
319
+ throw new Error(`${label}.type: expected local, linear, jira, or url`);
275
320
  }
276
321
  function normalizePopulate(value, label) {
277
322
  const raw = objectOrEmpty(value, label);
@@ -0,0 +1,24 @@
1
+ export interface SourceFetchLimits {
2
+ timeoutMs: number;
3
+ maxBytes: number;
4
+ maxRedirects: number;
5
+ }
6
+ export declare const DEFAULT_SOURCE_FETCH_LIMITS: SourceFetchLimits;
7
+ export interface FetchedSource {
8
+ requestedUrl: string;
9
+ finalUrl: string;
10
+ contentType: "html" | "text" | "markdown" | "pdf";
11
+ text: string;
12
+ bytes: number;
13
+ fingerprint: string;
14
+ snapshotPath: string;
15
+ metadataPath: string;
16
+ fetchedAt: string;
17
+ }
18
+ export declare function fetchAndSnapshotUrl(projectDir: string, input: string, limits?: Partial<SourceFetchLimits>): Promise<FetchedSource>;
19
+ export declare function normalizePublicHttpUrl(input: string): string;
20
+ export declare function assertPublicDestination(input: string): Promise<void>;
21
+ export declare function isPublicAddress(address: string): boolean;
22
+ export declare function htmlToText(html: string): string;
23
+ /** Snapshot a local file outside the repository so absolute paths are never persisted. */
24
+ export declare function snapshotExternalLocalFile(projectDir: string, inputPath: string): string;
@@ -0,0 +1,246 @@
1
+ import { createHash } from "node:crypto";
2
+ import { copyFileSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
+ import { lookup as dnsLookup } from "node:dns/promises";
4
+ import { isIP } from "node:net";
5
+ import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
6
+ import { readdirSync } from "node:fs";
7
+ import { parse as parseHtml } from "parse5";
8
+ export const DEFAULT_SOURCE_FETCH_LIMITS = {
9
+ timeoutMs: 15_000,
10
+ maxBytes: 10 * 1024 * 1024,
11
+ maxRedirects: 5,
12
+ };
13
+ export async function fetchAndSnapshotUrl(projectDir, input, limits = {}) {
14
+ const effective = { ...DEFAULT_SOURCE_FETCH_LIMITS, ...limits };
15
+ const requested = normalizePublicHttpUrl(input);
16
+ let current = requested;
17
+ let response;
18
+ for (let redirects = 0; redirects <= effective.maxRedirects; redirects++) {
19
+ await assertPublicDestination(current);
20
+ const controller = new AbortController();
21
+ const timer = setTimeout(() => controller.abort(), effective.timeoutMs);
22
+ try {
23
+ response = await fetch(current, {
24
+ redirect: "manual",
25
+ signal: controller.signal,
26
+ headers: { Accept: "text/html,text/plain,text/markdown,application/pdf;q=0.9" },
27
+ });
28
+ }
29
+ finally {
30
+ clearTimeout(timer);
31
+ }
32
+ if (response.status >= 300 && response.status < 400) {
33
+ const location = response.headers.get("location");
34
+ if (!location)
35
+ throw new Error(`URL redirect ${response.status} did not include Location`);
36
+ if (redirects === effective.maxRedirects)
37
+ throw new Error(`URL exceeded ${effective.maxRedirects} redirects`);
38
+ current = normalizePublicHttpUrl(new URL(location, current).toString());
39
+ continue;
40
+ }
41
+ break;
42
+ }
43
+ if (!response)
44
+ throw new Error("URL fetch did not produce a response");
45
+ if (!response.ok)
46
+ throw new Error(`URL fetch failed with HTTP ${response.status}`);
47
+ const declaredLength = Number(response.headers.get("content-length"));
48
+ if (Number.isFinite(declaredLength) && declaredLength > effective.maxBytes)
49
+ throw new Error(`URL content exceeds ${effective.maxBytes} byte limit`);
50
+ const bytes = await readLimitedBody(response, effective.maxBytes);
51
+ const kind = detectContentType(response.headers.get("content-type"), bytes, current);
52
+ const text = await extractSourceText(kind, bytes);
53
+ const fetchedAt = new Date().toISOString();
54
+ const fingerprint = createHash("sha256").update(bytes).digest("hex");
55
+ const importsDir = join(resolve(projectDir), ".tickets", "imports");
56
+ mkdirSync(importsDir, { recursive: true });
57
+ const stamp = fetchedAt.replace(/[:.]/g, "-");
58
+ const stem = `url-${stamp}-${fingerprint.slice(0, 12)}`;
59
+ const snapshotPath = join(importsDir, `${stem}.md`);
60
+ const metadataPath = join(importsDir, `${stem}.json`);
61
+ writeFileSync(snapshotPath, `${text.trimEnd()}\n`, "utf8");
62
+ writeFileSync(metadataPath, `${JSON.stringify({ requestedUrl: requested, finalUrl: current, fetchedAt, bytes: bytes.length, contentType: kind, fingerprint }, null, 2)}\n`, "utf8");
63
+ return { requestedUrl: requested, finalUrl: current, contentType: kind, text, bytes: bytes.length, fingerprint, snapshotPath: relative(resolve(projectDir), snapshotPath), metadataPath: relative(resolve(projectDir), metadataPath), fetchedAt };
64
+ }
65
+ export function normalizePublicHttpUrl(input) {
66
+ let url;
67
+ try {
68
+ url = new URL(input);
69
+ }
70
+ catch {
71
+ throw new Error(`invalid URL: ${input}`);
72
+ }
73
+ if (url.protocol !== "http:" && url.protocol !== "https:")
74
+ throw new Error("URL source must use HTTP or HTTPS");
75
+ if (url.username || url.password)
76
+ throw new Error("URL source must not contain credentials");
77
+ if (!url.hostname)
78
+ throw new Error("URL source must include a hostname");
79
+ url.hash = "";
80
+ return url.toString();
81
+ }
82
+ export async function assertPublicDestination(input) {
83
+ const url = new URL(normalizePublicHttpUrl(input));
84
+ const addresses = isIP(url.hostname)
85
+ ? [{ address: url.hostname }]
86
+ : await dnsLookup(url.hostname, { all: true, verbatim: true });
87
+ if (addresses.length === 0)
88
+ throw new Error(`URL hostname did not resolve: ${url.hostname}`);
89
+ for (const { address } of addresses) {
90
+ if (!isPublicAddress(address))
91
+ throw new Error(`URL destination is not public: ${url.hostname} resolved to ${address}`);
92
+ }
93
+ }
94
+ export function isPublicAddress(address) {
95
+ const normalized = address.toLowerCase().split("%")[0];
96
+ if (normalized === "::1" || normalized === "::" || normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd"))
97
+ return false;
98
+ if (normalized.startsWith("::ffff:"))
99
+ return isPublicAddress(normalized.slice(7));
100
+ if (isIP(normalized) === 6) {
101
+ return !(normalized.startsWith("ff") ||
102
+ normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb") ||
103
+ normalized.startsWith("fc") || normalized.startsWith("fd") ||
104
+ normalized.startsWith("fec") || normalized.startsWith("fed") || normalized.startsWith("fee") || normalized.startsWith("fef") ||
105
+ normalized.startsWith("2001:db8:") || normalized.startsWith("2001:10:") || normalized.startsWith("2001:2:") ||
106
+ normalized.startsWith("100:") || normalized.startsWith("64:ff9b:1:"));
107
+ }
108
+ const parts = normalized.split(".").map(Number);
109
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
110
+ return false;
111
+ const [a, b] = parts;
112
+ return !(a === 0 || a === 10 || a === 127 || a >= 224 ||
113
+ (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) ||
114
+ (a === 192 && (b === 0 || b === 88 || b === 168)) ||
115
+ (a === 100 && b >= 64 && b <= 127) || (a === 198 && (b === 18 || b === 19 || b === 51)) ||
116
+ (a === 203 && b === 0));
117
+ }
118
+ async function readLimitedBody(response, maxBytes) {
119
+ if (!response.body)
120
+ return new Uint8Array();
121
+ const reader = response.body.getReader();
122
+ const chunks = [];
123
+ let total = 0;
124
+ while (true) {
125
+ const { done, value } = await reader.read();
126
+ if (done)
127
+ break;
128
+ total += value.length;
129
+ if (total > maxBytes) {
130
+ await reader.cancel();
131
+ throw new Error(`URL content exceeds ${maxBytes} byte limit`);
132
+ }
133
+ chunks.push(value);
134
+ }
135
+ const out = new Uint8Array(total);
136
+ let offset = 0;
137
+ for (const chunk of chunks) {
138
+ out.set(chunk, offset);
139
+ offset += chunk.length;
140
+ }
141
+ return out;
142
+ }
143
+ function detectContentType(header, bytes, url) {
144
+ const mime = (header ?? "").split(";", 1)[0].trim().toLowerCase();
145
+ const magic = Buffer.from(bytes.subarray(0, 8)).toString("latin1");
146
+ if (magic.startsWith("%PDF-")) {
147
+ if (mime && mime !== "application/pdf" && mime !== "application/octet-stream")
148
+ throw new Error(`content-type ${mime} conflicts with PDF magic bytes`);
149
+ return "pdf";
150
+ }
151
+ if (["application/pdf"].includes(mime))
152
+ throw new Error("response claimed PDF but PDF magic bytes were missing");
153
+ if (["text/html", "application/xhtml+xml"].includes(mime))
154
+ return "html";
155
+ if (["text/markdown", "text/x-markdown"].includes(mime) || /\.md(?:own)?$/i.test(new URL(url).pathname))
156
+ return "markdown";
157
+ if (!mime || mime.startsWith("text/") || mime === "application/octet-stream") {
158
+ if (bytes.subarray(0, Math.min(bytes.length, 1024)).includes(0))
159
+ throw new Error("binary URL content is not supported");
160
+ if ((!mime || mime === "application/octet-stream") && /^\s*(?:<!doctype\s+html|<html\b)/i.test(Buffer.from(bytes.subarray(0, 1024)).toString("utf8")))
161
+ return "html";
162
+ return "text";
163
+ }
164
+ throw new Error(`unsupported URL content-type: ${mime}`);
165
+ }
166
+ async function extractSourceText(kind, bytes) {
167
+ if (kind === "pdf") {
168
+ const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
169
+ const document = await pdfjs.getDocument({ data: bytes, useWorkerFetch: false, isEvalSupported: false }).promise;
170
+ const pages = [];
171
+ for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber++) {
172
+ const page = await document.getPage(pageNumber);
173
+ const content = await page.getTextContent();
174
+ pages.push(content.items.map((item) => "str" in item ? item.str : "").join(" "));
175
+ page.cleanup();
176
+ }
177
+ await document.destroy();
178
+ return pages.join("\n\n");
179
+ }
180
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
181
+ return kind === "html" ? htmlToText(decoded) : decoded;
182
+ }
183
+ export function htmlToText(html) {
184
+ const doc = parseHtml(html);
185
+ const blocks = new Set(["address", "article", "aside", "blockquote", "br", "div", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "header", "li", "main", "p", "section", "table", "tr"]);
186
+ const skip = new Set(["script", "style", "noscript", "svg"]);
187
+ const out = [];
188
+ const walk = (node) => {
189
+ const named = node;
190
+ const name = named.nodeName?.toLowerCase();
191
+ if (name && skip.has(name))
192
+ return;
193
+ if (name === "#text" && named.value)
194
+ out.push(named.value);
195
+ for (const child of named.childNodes ?? [])
196
+ walk(child);
197
+ if (name && blocks.has(name))
198
+ out.push("\n");
199
+ };
200
+ for (const child of doc.childNodes)
201
+ walk(child);
202
+ return out.join(" ").replace(/[ \t]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{3,}/g, "\n\n").trim();
203
+ }
204
+ /** Snapshot a local file outside the repository so absolute paths are never persisted. */
205
+ export function snapshotExternalLocalFile(projectDir, inputPath) {
206
+ const root = realpathSync(resolve(projectDir));
207
+ const file = realpathSync(resolve(inputPath));
208
+ const rel = relative(root, file);
209
+ if (!rel.startsWith("..") && !isAbsolute(rel))
210
+ return rel;
211
+ if (statSync(file).isDirectory()) {
212
+ const hash = createHash("sha256").update(file).digest("hex").slice(0, 12);
213
+ const targetRoot = join(root, ".tickets", "imports", `local-${hash}-${basename(file).replace(/[^A-Za-z0-9._-]/g, "-")}`);
214
+ let count = 0;
215
+ let bytes = 0;
216
+ const copyDirectory = (source, target) => {
217
+ mkdirSync(target, { recursive: true });
218
+ for (const entry of readdirSync(source, { withFileTypes: true })) {
219
+ if (entry.isSymbolicLink())
220
+ continue;
221
+ const from = join(source, entry.name);
222
+ const to = join(target, entry.name);
223
+ if (entry.isDirectory())
224
+ copyDirectory(from, to);
225
+ else if (entry.isFile()) {
226
+ count++;
227
+ bytes += statSync(from).size;
228
+ if (count > 1000 || bytes > DEFAULT_SOURCE_FETCH_LIMITS.maxBytes)
229
+ throw new Error("external local source directory exceeds snapshot limits");
230
+ copyFileSync(from, to);
231
+ }
232
+ }
233
+ };
234
+ copyDirectory(file, targetRoot);
235
+ return relative(root, targetRoot);
236
+ }
237
+ if (!statSync(file).isFile())
238
+ throw new Error(`local source is not a file or directory: ${inputPath}`);
239
+ const bytes = readFileSync(file);
240
+ const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
241
+ const dir = join(root, ".tickets", "imports");
242
+ mkdirSync(dir, { recursive: true });
243
+ const target = join(dir, `local-${hash}-${basename(file).replace(/[^A-Za-z0-9._-]/g, "-")}${extname(file) ? "" : ".txt"}`);
244
+ copyFileSync(file, target);
245
+ return relative(root, target);
246
+ }
@@ -81,6 +81,14 @@ export interface ReviewRecommendation {
81
81
  source_json: string;
82
82
  updated_at: string | null;
83
83
  }
84
+ export interface OperationReceipt {
85
+ operation_id: string;
86
+ operation_type: string;
87
+ ticket_id: string | null;
88
+ run_id: string;
89
+ completed_at: string;
90
+ payload_json: string;
91
+ }
84
92
  export declare class StateDb {
85
93
  private db;
86
94
  constructor(dbPath: string);
@@ -103,5 +111,7 @@ export declare class StateDb {
103
111
  getReviewRecommendations(status?: ReviewRecommendationStatus): ReviewRecommendation[];
104
112
  getReviewRecommendation(id: number): ReviewRecommendation | undefined;
105
113
  updateReviewRecommendationStatus(id: number, status: ReviewRecommendationStatus, updatedAt: string): void;
114
+ getOperationReceipt(operationId: string): OperationReceipt | undefined;
115
+ recordOperationReceipt(receipt: OperationReceipt): boolean;
106
116
  transaction<T>(fn: () => T): T;
107
117
  }
@@ -96,12 +96,22 @@ CREATE TABLE IF NOT EXISTS review_recommendations (
96
96
  CHECK (status IN ('pending','accepted','deferred','dismissed'))
97
97
  );
98
98
 
99
+ CREATE TABLE IF NOT EXISTS operation_receipts (
100
+ operation_id TEXT PRIMARY KEY,
101
+ operation_type TEXT NOT NULL,
102
+ ticket_id TEXT,
103
+ run_id TEXT NOT NULL,
104
+ completed_at TEXT NOT NULL,
105
+ payload_json TEXT NOT NULL DEFAULT '{}'
106
+ );
107
+
99
108
  CREATE INDEX IF NOT EXISTS idx_ticket_state_status ON ticket_state(status);
100
109
  CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket_id ON ticket_events(ticket_id);
101
110
  CREATE INDEX IF NOT EXISTS idx_ticket_events_timestamp ON ticket_events(timestamp);
102
111
  CREATE INDEX IF NOT EXISTS idx_validation_snapshots_timestamp ON validation_snapshots(timestamp);
103
112
  CREATE INDEX IF NOT EXISTS idx_future_work_disposition ON future_work(disposition);
104
113
  CREATE INDEX IF NOT EXISTS idx_review_recommendations_status ON review_recommendations(status);
114
+ CREATE INDEX IF NOT EXISTS idx_operation_receipts_run ON operation_receipts(run_id);
105
115
  `;
106
116
  export class StateDb {
107
117
  db;
@@ -291,6 +301,20 @@ export class StateDb {
291
301
  .prepare("UPDATE review_recommendations SET status = ?, updated_at = ? WHERE id = ?")
292
302
  .run(status, updatedAt, id);
293
303
  }
304
+ // ── operation_receipts ───────────────────────────────────────────────────
305
+ getOperationReceipt(operationId) {
306
+ return this.db.prepare("SELECT * FROM operation_receipts WHERE operation_id = ?").get(operationId);
307
+ }
308
+ recordOperationReceipt(receipt) {
309
+ const result = this.db.prepare(`
310
+ INSERT OR IGNORE INTO operation_receipts (
311
+ operation_id,operation_type,ticket_id,run_id,completed_at,payload_json
312
+ ) VALUES (
313
+ @operation_id,@operation_type,@ticket_id,@run_id,@completed_at,@payload_json
314
+ )
315
+ `).run(receipt);
316
+ return result.changes === 1;
317
+ }
294
318
  // ── transactions ──────────────────────────────────────────────────────────
295
319
  transaction(fn) {
296
320
  return this.db.transaction(fn)();