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/cli/status.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Command } from "commander";
|
|
|
2
2
|
import { resolve, join } from "node:path";
|
|
3
3
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { findResumableBranchSessions, formatBranchContinueCommand } from "../branch/resume.js";
|
|
5
|
+
import { recoverableBuildRuns } from "../buildRuns.js";
|
|
5
6
|
function fail(message) {
|
|
6
7
|
console.error(`foreman: ${message}`);
|
|
7
8
|
process.exit(1);
|
|
@@ -10,65 +11,80 @@ export function buildStatusCommand() {
|
|
|
10
11
|
return new Command("status")
|
|
11
12
|
.description("Summarize the most recent foreman run for a project.")
|
|
12
13
|
.argument("<project>", "path to the project directory")
|
|
13
|
-
.action((project) =>
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
.action((project) => runStatus(project));
|
|
15
|
+
}
|
|
16
|
+
/** Print the existing Foreman status report for a resolved project. */
|
|
17
|
+
export function runStatus(project) {
|
|
18
|
+
const projectDir = resolve(project);
|
|
19
|
+
const dir = join(projectDir, ".foreman");
|
|
20
|
+
const recoverable = recoverableBuildRuns(projectDir);
|
|
21
|
+
if (!existsSync(dir))
|
|
22
|
+
fail(`no foreman runs found under ${dir}`);
|
|
23
|
+
const logs = readdirSync(dir)
|
|
24
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
25
|
+
.sort();
|
|
26
|
+
if (logs.length === 0) {
|
|
27
|
+
if (recoverable.length === 0)
|
|
17
28
|
fail(`no foreman runs found under ${dir}`);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
if (batchEnd)
|
|
43
|
-
console.log(`foreman:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
if (latestFailure) {
|
|
51
|
-
console.log(
|
|
52
|
-
|
|
53
|
-
console.log(
|
|
54
|
-
for (const command of latestFailure.repairCommands) {
|
|
55
|
-
console.log(` ${command}`);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
const retry = findRetryCommand(projectDir, dir, latestFailure);
|
|
59
|
-
if (retry) {
|
|
60
|
-
console.log("foreman: retry with:");
|
|
61
|
-
console.log(` ${retry}`);
|
|
62
|
-
}
|
|
63
|
-
if (latestFailure.output) {
|
|
64
|
-
console.log("foreman: last command output:");
|
|
65
|
-
console.log(indent(limitLines(latestFailure.output, 8)));
|
|
29
|
+
printRecoverable(projectDir, recoverable);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const latest = join(dir, logs[logs.length - 1]);
|
|
33
|
+
const records = readFileSync(latest, "utf8")
|
|
34
|
+
.split("\n")
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.map((line) => JSON.parse(line));
|
|
37
|
+
const steps = records.filter((r) => r.event === "step");
|
|
38
|
+
const escalations = records.filter((r) => r.event === "escalation");
|
|
39
|
+
const branchEvents = records.filter((r) => String(r.event).startsWith("branch-"));
|
|
40
|
+
const prEvents = records.filter((r) => ["pr-created", "pr-existing", "pr-failed"].includes(String(r.event)));
|
|
41
|
+
const latestFailure = findLatestGitHubFailure(records);
|
|
42
|
+
const batchEnd = [...records].reverse().find((r) => r.event === "batch-end");
|
|
43
|
+
console.log(`foreman: latest run ${logs[logs.length - 1]}`);
|
|
44
|
+
console.log(`foreman: ${steps.length} step record(s), ${escalations.length} escalation(s)`);
|
|
45
|
+
if (branchEvents.length || prEvents.length) {
|
|
46
|
+
const completed = branchEvents.filter((r) => r.event === "branch-complete").length;
|
|
47
|
+
const issues = branchEvents.filter((r) => r.event === "branch-issue").length;
|
|
48
|
+
const prs = prEvents.filter((r) => r.event === "pr-created" || r.event === "pr-existing").length;
|
|
49
|
+
console.log(`foreman: branch mode — ${completed} completed, ${issues} issue(s), ${prs} PR(s)`);
|
|
50
|
+
}
|
|
51
|
+
if (batchEnd) {
|
|
52
|
+
console.log(`foreman: outcome — ${batchEnd.outcome} (${batchEnd.completed}/${batchEnd.requested})`);
|
|
53
|
+
if (batchEnd.detail)
|
|
54
|
+
console.log(`foreman: ${batchEnd.detail}`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
console.log("ai-foreman: run is still in progress or did not finish");
|
|
58
|
+
}
|
|
59
|
+
if (latestFailure) {
|
|
60
|
+
console.log(`foreman: latest GitHub failure — ${latestFailure.code}: ${latestFailure.message}`);
|
|
61
|
+
if (latestFailure.repairCommands.length > 0) {
|
|
62
|
+
console.log("foreman: repair and verify:");
|
|
63
|
+
for (const command of latestFailure.repairCommands) {
|
|
64
|
+
console.log(` ${command}`);
|
|
66
65
|
}
|
|
67
66
|
}
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
const retry = findRetryCommand(projectDir, dir, latestFailure);
|
|
68
|
+
if (retry) {
|
|
69
|
+
console.log("foreman: retry with:");
|
|
70
|
+
console.log(` ${retry}`);
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
+
if (latestFailure.output) {
|
|
73
|
+
console.log("foreman: last command output:");
|
|
74
|
+
console.log(indent(limitLines(latestFailure.output, 8)));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const esc of escalations) {
|
|
78
|
+
console.log(` escalated: ${esc.tool} — ${esc.reason}`);
|
|
79
|
+
}
|
|
80
|
+
if (recoverable.length > 0)
|
|
81
|
+
printRecoverable(projectDir, recoverable);
|
|
82
|
+
}
|
|
83
|
+
function printRecoverable(projectDir, runs) {
|
|
84
|
+
console.log(`foreman: ${runs.length} unfinished/recoverable build run(s):`);
|
|
85
|
+
for (const run of runs)
|
|
86
|
+
console.log(` ${run.runId} ${run.currentTicket ?? run.tickets[0] ?? "unknown"} ${run.active ? "active" : run.status} checkpoint=${run.checkpoint}`);
|
|
87
|
+
console.log(`foreman: recover with \`rafi build:resume ${projectDir}\``);
|
|
72
88
|
}
|
|
73
89
|
function findLatestGitHubFailure(records) {
|
|
74
90
|
for (let i = records.length - 1; i >= 0; i--) {
|
package/dist/cli/tickets.js
CHANGED
|
@@ -13,7 +13,8 @@ import { validateDocsRoot } from "../tickets/config.js";
|
|
|
13
13
|
import { importExternalSources, importFromMarkdown, validateExternalSourceAccess } from "../tickets/importer.js";
|
|
14
14
|
import { formatValidationIssues } from "../tickets/validate.js";
|
|
15
15
|
import { assertEffortLevel, createRoleBuilder, makeLogPath, } from "../agentRun.js";
|
|
16
|
-
import { DEFAULT_TICKET_SETUP, configuredPlanningSources, defaultAppName, ensureRafiConfigForTicketSetup, externalSources, hasTicketSetupConfig, loadTicketSetupConfig, loadTicketSetupConfigWithDefaults, localSourcePaths, mergeTicketSetup, recommendedBuildDefaults, saveTicketSetupConfig, } from "../tickets/setupConfig.js";
|
|
16
|
+
import { DEFAULT_TICKET_SETUP, configuredPlanningSources, defaultAppName, ensureRafiConfigForTicketSetup, externalSources, hasTicketSetupConfig, loadTicketSetupConfig, loadTicketSetupConfigWithDefaults, localSourcePaths, mergeTicketSetup, recommendedBuildDefaults, saveTicketSetupConfig, urlSources, } from "../tickets/setupConfig.js";
|
|
17
|
+
import { fetchAndSnapshotUrl, snapshotExternalLocalFile } from "../tickets/sourceFetch.js";
|
|
17
18
|
import { checkpointInterview, completeInterview, createInterviewRecord, failInterview, } from "../interviews.js";
|
|
18
19
|
function fail(msg) {
|
|
19
20
|
console.error(`foreman tickets: ${msg}`);
|
|
@@ -151,6 +152,7 @@ async function cmdSetupInitCli(opts) {
|
|
|
151
152
|
const answers = await collectTicketSetup(dir, opts, undefined);
|
|
152
153
|
if (interview)
|
|
153
154
|
interview = checkpointInterview(dir, interview, { checkpoint: "save-setup", answers: { setup: answers } });
|
|
155
|
+
await validateConfiguredSourcesIfRequested(dir, answers, Boolean(opts.skipAccessCheck));
|
|
154
156
|
ensureRafiConfigForTicketSetup(dir, {
|
|
155
157
|
appName: opts.appName ?? defaultAppName(dir),
|
|
156
158
|
docsRoot: opts.docsRoot,
|
|
@@ -162,7 +164,6 @@ async function cmdSetupInitCli(opts) {
|
|
|
162
164
|
targets: parseRuntimeTargets(opts.runtime),
|
|
163
165
|
});
|
|
164
166
|
console.log(`foreman tickets setup: saved ticket setup in ${join(dir, "rafi-config.yaml")}`);
|
|
165
|
-
await validateConfiguredSourcesIfRequested(answers, Boolean(opts.skipAccessCheck));
|
|
166
167
|
if (!isTicketsInitialized(dir)) {
|
|
167
168
|
if (interview)
|
|
168
169
|
interview = checkpointInterview(dir, interview, { checkpoint: "initialize-tracker" });
|
|
@@ -209,6 +210,7 @@ async function cmdSetupUpdateCli(opts) {
|
|
|
209
210
|
const next = mergeTicketSetup(current, patch);
|
|
210
211
|
if (interview)
|
|
211
212
|
interview = checkpointInterview(dir, interview, { checkpoint: "save-setup", answers: { setup: next } });
|
|
213
|
+
await validateConfiguredSourcesIfRequested(dir, next, Boolean(opts.skipAccessCheck));
|
|
212
214
|
ensureRafiConfigForTicketSetup(dir, {
|
|
213
215
|
appName: opts.appName ?? defaultAppName(dir),
|
|
214
216
|
docsRoot: opts.docsRoot,
|
|
@@ -220,7 +222,6 @@ async function cmdSetupUpdateCli(opts) {
|
|
|
220
222
|
targets: parseRuntimeTargets(opts.runtime),
|
|
221
223
|
});
|
|
222
224
|
console.log(`foreman tickets setup: updated ticket setup in ${join(dir, "rafi-config.yaml")}`);
|
|
223
|
-
await validateConfiguredSourcesIfRequested(next, Boolean(opts.skipAccessCheck));
|
|
224
225
|
if (isTicketsInitialized(dir)) {
|
|
225
226
|
const rows = cmdQueue(dir, 1);
|
|
226
227
|
if (rows.length === 0) {
|
|
@@ -383,6 +384,7 @@ async function promptTicketSources(dir, current) {
|
|
|
383
384
|
{ value: "local", label: "Local docs, files, folders, or globs" },
|
|
384
385
|
{ value: "linear", label: "Linear" },
|
|
385
386
|
{ value: "jira", label: "Jira Cloud" },
|
|
387
|
+
{ value: "url", label: "Public URL (HTML, text, Markdown, or PDF)" },
|
|
386
388
|
{ value: "none", label: "No saved source" },
|
|
387
389
|
],
|
|
388
390
|
});
|
|
@@ -415,6 +417,17 @@ async function promptTicketSources(dir, current) {
|
|
|
415
417
|
filter: String(filter).trim() || null,
|
|
416
418
|
}];
|
|
417
419
|
}
|
|
420
|
+
if (kind === "url") {
|
|
421
|
+
const existing = current.find((source) => source.type === "url");
|
|
422
|
+
const answer = await text({
|
|
423
|
+
message: "Public HTTP(S) URL:",
|
|
424
|
+
initialValue: existing?.url,
|
|
425
|
+
validate: (value) => /^https?:\/\//i.test(String(value ?? "")) ? undefined : "Enter an HTTP(S) URL",
|
|
426
|
+
});
|
|
427
|
+
if (isCancel(answer))
|
|
428
|
+
process.exit(0);
|
|
429
|
+
return [{ type: "url", url: String(answer).trim() }];
|
|
430
|
+
}
|
|
418
431
|
const site = await text({
|
|
419
432
|
message: "Jira Cloud site URL:",
|
|
420
433
|
placeholder: "https://your-domain.atlassian.net",
|
|
@@ -460,9 +473,11 @@ function sourcesFromSetupOptions(opts) {
|
|
|
460
473
|
jql: opts.jiraJql,
|
|
461
474
|
});
|
|
462
475
|
}
|
|
476
|
+
for (const url of opts.urlSource ?? [])
|
|
477
|
+
sources.push({ type: "url", url });
|
|
463
478
|
return sources;
|
|
464
479
|
}
|
|
465
|
-
async function validateConfiguredSourcesIfRequested(setup, skip) {
|
|
480
|
+
async function validateConfiguredSourcesIfRequested(projectDir, setup, skip) {
|
|
466
481
|
if (skip)
|
|
467
482
|
return;
|
|
468
483
|
for (const source of externalSources(setup)) {
|
|
@@ -474,6 +489,12 @@ async function validateConfiguredSourcesIfRequested(setup, skip) {
|
|
|
474
489
|
console.log(`foreman tickets setup: ${source.type} access check skipped/failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
475
490
|
}
|
|
476
491
|
}
|
|
492
|
+
for (const source of urlSources(setup)) {
|
|
493
|
+
const fetched = await fetchAndSnapshotUrl(projectDir, source.url).catch((err) => {
|
|
494
|
+
throw new Error(`URL access validation failed for ${source.url}: ${err instanceof Error ? err.message : String(err)}`);
|
|
495
|
+
});
|
|
496
|
+
console.log(`foreman tickets setup: validated URL access (${fetched.contentType}, ${fetched.bytes} bytes)`);
|
|
497
|
+
}
|
|
477
498
|
}
|
|
478
499
|
function shouldPrompt(opts) {
|
|
479
500
|
return !opts.defaults && !opts.yes && process.stdin.isTTY && process.stdout.isTTY;
|
|
@@ -637,12 +658,21 @@ export async function cmdPopulateCli(opts) {
|
|
|
637
658
|
}
|
|
638
659
|
const ticketsConfig = loadTicketsConfig(dir);
|
|
639
660
|
const setup = loadTicketSetupConfig(dir);
|
|
640
|
-
const explicitSources = opts.sources;
|
|
661
|
+
const explicitSources = opts.sources ? await prepareDocumentSources(dir, opts.sources) : undefined;
|
|
641
662
|
const configuredExternalSources = explicitSources?.length ? [] : externalSources(setup);
|
|
663
|
+
const configuredUrlSources = explicitSources?.length ? [] : urlSources(setup);
|
|
642
664
|
const savedLocalSources = explicitSources?.length ? [] : localSourcePaths(setup);
|
|
643
|
-
const
|
|
665
|
+
const selectedLocalHints = configuredExternalSources.length > 0 && savedLocalSources.length === 0 && configuredUrlSources.length === 0
|
|
644
666
|
? undefined
|
|
645
667
|
: await resolvePopulateSourceSelection(dir, explicitSources, ticketsConfig, Boolean(opts.yes));
|
|
668
|
+
const localHints = selectedLocalHints ? await prepareDocumentSources(dir, selectedLocalHints) : undefined;
|
|
669
|
+
const urlHints = [];
|
|
670
|
+
for (const source of configuredUrlSources) {
|
|
671
|
+
const fetched = await fetchAndSnapshotUrl(dir, source.url);
|
|
672
|
+
urlHints.push(fetched.snapshotPath);
|
|
673
|
+
console.log(`foreman tickets: fetched ${source.url} -> ${fetched.snapshotPath}`);
|
|
674
|
+
}
|
|
675
|
+
const sourceHints = unique([...(localHints ?? []), ...urlHints]);
|
|
646
676
|
const populateAgent = opts.agent
|
|
647
677
|
?? (setup?.populate.agent_preference && setup.populate.agent_preference !== "configured"
|
|
648
678
|
? setup.populate.agent_preference
|
|
@@ -678,7 +708,7 @@ export async function cmdPopulateCli(opts) {
|
|
|
678
708
|
if (!validation.clean)
|
|
679
709
|
process.exit(1);
|
|
680
710
|
}
|
|
681
|
-
if (!sourceHints
|
|
711
|
+
if (!sourceHints.length) {
|
|
682
712
|
console.log(`foreman tickets: imported external tickets and rendered ${ticketsConfig.paths.progressDoc}`);
|
|
683
713
|
return;
|
|
684
714
|
}
|
|
@@ -718,7 +748,7 @@ export async function cmdPopulateCli(opts) {
|
|
|
718
748
|
console.log(`foreman tickets: project ${dir}`);
|
|
719
749
|
console.log(`foreman tickets: role ${TICKET_POPULATE_ROLE}`);
|
|
720
750
|
console.log(`foreman tickets: log ${logPath}\n`);
|
|
721
|
-
if (sourceHints
|
|
751
|
+
if (sourceHints.length) {
|
|
722
752
|
console.log(`foreman tickets: source hints ${sourceHints.join(" ")}`);
|
|
723
753
|
}
|
|
724
754
|
const turn = await foreman.runInstruction(buildPopulateInstruction(sourceHints, ticketsConfig.paths.progressDoc));
|
|
@@ -761,6 +791,21 @@ export async function cmdPopulateCli(opts) {
|
|
|
761
791
|
fail(String(err instanceof Error ? err.message : err));
|
|
762
792
|
}
|
|
763
793
|
}
|
|
794
|
+
async function prepareDocumentSources(projectDir, sources) {
|
|
795
|
+
const prepared = [];
|
|
796
|
+
for (const source of sources) {
|
|
797
|
+
if (/^https?:\/\//i.test(source)) {
|
|
798
|
+
prepared.push((await fetchAndSnapshotUrl(projectDir, source)).snapshotPath);
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
const absolute = resolve(source);
|
|
802
|
+
if (isAbsolute(source) && existsSync(absolute))
|
|
803
|
+
prepared.push(snapshotExternalLocalFile(projectDir, absolute));
|
|
804
|
+
else
|
|
805
|
+
prepared.push(source);
|
|
806
|
+
}
|
|
807
|
+
return unique(prepared);
|
|
808
|
+
}
|
|
764
809
|
export function buildTicketsCommand() {
|
|
765
810
|
const tickets = new Command("tickets").description("Manage the structured ticket tracker for a project.");
|
|
766
811
|
// ── setup:init / setup:update ─────────────────────────────────────────────
|
|
@@ -779,12 +824,13 @@ export function buildTicketsCommand() {
|
|
|
779
824
|
.option("--linear-filter <filter>", "Linear IssueFilter JSON or title search text")
|
|
780
825
|
.option("--jira-site <url>", "Jira Cloud site URL")
|
|
781
826
|
.option("--jira-jql <jql>", "Jira JQL query")
|
|
827
|
+
.option("--url-source <urls...>", "add public HTTP(S) source URLs")
|
|
782
828
|
.option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
|
|
783
829
|
.option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
|
|
784
830
|
.option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
|
|
785
831
|
.option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
|
|
786
832
|
.option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
|
|
787
|
-
.option("--skip-access-check", "do not validate
|
|
833
|
+
.option("--skip-access-check", "do not validate configured source access during setup")
|
|
788
834
|
.action(async (opts) => {
|
|
789
835
|
try {
|
|
790
836
|
await cmdSetupInitCli(opts);
|
|
@@ -808,12 +854,13 @@ export function buildTicketsCommand() {
|
|
|
808
854
|
.option("--linear-filter <filter>", "Linear IssueFilter JSON or title search text")
|
|
809
855
|
.option("--jira-site <url>", "Jira Cloud site URL")
|
|
810
856
|
.option("--jira-jql <jql>", "Jira JQL query")
|
|
857
|
+
.option("--url-source <urls...>", "replace saved sources with public HTTP(S) URLs")
|
|
811
858
|
.option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
|
|
812
859
|
.option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
|
|
813
860
|
.option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
|
|
814
861
|
.option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
|
|
815
862
|
.option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
|
|
816
|
-
.option("--skip-access-check", "do not validate
|
|
863
|
+
.option("--skip-access-check", "do not validate configured source access during setup")
|
|
817
864
|
.action(async (opts) => {
|
|
818
865
|
try {
|
|
819
866
|
await cmdSetupUpdateCli(opts);
|
package/dist/foreman.d.ts
CHANGED
|
@@ -49,13 +49,15 @@ export declare class Foreman {
|
|
|
49
49
|
private readonly qaEnabled;
|
|
50
50
|
private readonly qaMaxCycles;
|
|
51
51
|
private readonly projectDir?;
|
|
52
|
+
private readonly qaReviewer?;
|
|
52
53
|
private readonly ticketsEnabled;
|
|
53
|
-
constructor(builder: BuilderAdapter, log: Log, notificationsEnabled?: boolean, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined);
|
|
54
|
+
constructor(builder: BuilderAdapter, log: Log, notificationsEnabled?: boolean, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined);
|
|
54
55
|
/**
|
|
55
56
|
* Send one instruction and resolve any needs_input exchanges before returning.
|
|
56
57
|
* The returned result and status always reflect the final (non-needs_input) turn.
|
|
57
58
|
*/
|
|
58
59
|
private doTurn;
|
|
60
|
+
private doTurnWith;
|
|
59
61
|
/** Send a planning turn and return the builder's response text. Does not count toward steps. */
|
|
60
62
|
runPreflight(n: number, ticketsContent?: string): Promise<string>;
|
|
61
63
|
/** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
|
package/dist/foreman.js
CHANGED
|
@@ -3,6 +3,9 @@ import { MARKER_SPEC, QA_MARKER_SPEC } from "./markers.js";
|
|
|
3
3
|
import { fireNotification } from "./notify.js";
|
|
4
4
|
import { isTicketsInitialized } from "./tickets/config.js";
|
|
5
5
|
import { cmdUpdate, cmdComplete, cmdBlock, cmdImplementationQueue } from "./tickets/commands.js";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
8
|
+
import { join, relative } from "node:path";
|
|
6
9
|
/** Phrases that suggest the builder ended its turn by asking the human. */
|
|
7
10
|
const QUESTION_HINTS = [
|
|
8
11
|
"should i",
|
|
@@ -192,14 +195,16 @@ export class Foreman {
|
|
|
192
195
|
qaEnabled;
|
|
193
196
|
qaMaxCycles;
|
|
194
197
|
projectDir;
|
|
198
|
+
qaReviewer;
|
|
195
199
|
ticketsEnabled;
|
|
196
|
-
constructor(builder, log, notificationsEnabled = false, qaEnabled = true, qaMaxCycles = 3, projectDir) {
|
|
200
|
+
constructor(builder, log, notificationsEnabled = false, qaEnabled = true, qaMaxCycles = 3, projectDir, qaReviewer) {
|
|
197
201
|
this.builder = builder;
|
|
198
202
|
this.log = log;
|
|
199
203
|
this.notificationsEnabled = notificationsEnabled;
|
|
200
204
|
this.qaEnabled = qaEnabled;
|
|
201
205
|
this.qaMaxCycles = qaMaxCycles;
|
|
202
206
|
this.projectDir = projectDir;
|
|
207
|
+
this.qaReviewer = qaReviewer;
|
|
203
208
|
this.ticketsEnabled = !!(projectDir && isTicketsInitialized(projectDir));
|
|
204
209
|
}
|
|
205
210
|
/**
|
|
@@ -207,7 +212,10 @@ export class Foreman {
|
|
|
207
212
|
* The returned result and status always reflect the final (non-needs_input) turn.
|
|
208
213
|
*/
|
|
209
214
|
async doTurn(instruction) {
|
|
210
|
-
|
|
215
|
+
return this.doTurnWith(this.builder, instruction);
|
|
216
|
+
}
|
|
217
|
+
async doTurnWith(adapter, instruction) {
|
|
218
|
+
let result = await adapter.sendTurn(instruction);
|
|
211
219
|
let status = parseStepStatus(result.text);
|
|
212
220
|
while (status.kind === "needs_input") {
|
|
213
221
|
const question = status.question ?? "The builder has a question";
|
|
@@ -229,7 +237,7 @@ export class Foreman {
|
|
|
229
237
|
status = { kind: "blocked", reason: "user cancelled at input prompt" };
|
|
230
238
|
break;
|
|
231
239
|
}
|
|
232
|
-
result = await
|
|
240
|
+
result = await adapter.sendTurn(String(answer));
|
|
233
241
|
status = parseStepStatus(result.text);
|
|
234
242
|
}
|
|
235
243
|
return { result, status };
|
|
@@ -260,7 +268,15 @@ export class Foreman {
|
|
|
260
268
|
*/
|
|
261
269
|
async runQa(stepIndex) {
|
|
262
270
|
for (let cycle = 1; cycle <= this.qaMaxCycles; cycle++) {
|
|
263
|
-
const
|
|
271
|
+
const before = this.qaReviewer && this.projectDir ? fingerprintProtectedTree(this.projectDir) : undefined;
|
|
272
|
+
const qa = await this.doTurnWith(this.qaReviewer ?? this.builder, buildQaInstruction());
|
|
273
|
+
if (before && this.projectDir) {
|
|
274
|
+
const changes = changedProtectedFiles(before, fingerprintProtectedTree(this.projectDir));
|
|
275
|
+
if (changes.length > 0) {
|
|
276
|
+
this.log.write("qa-protected-files-changed", { stepIndex, cycle, paths: changes });
|
|
277
|
+
return { outcome: "needs-human", detail: `independent QA changed protected files: ${changes.join(", ")}` };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
264
280
|
this.log.write("qa", {
|
|
265
281
|
stepIndex,
|
|
266
282
|
cycle,
|
|
@@ -448,3 +464,29 @@ function lastLine(text) {
|
|
|
448
464
|
const lines = text.trim().split("\n");
|
|
449
465
|
return (lines[lines.length - 1] ?? "").slice(0, 200);
|
|
450
466
|
}
|
|
467
|
+
function fingerprintProtectedTree(root) {
|
|
468
|
+
const out = new Map();
|
|
469
|
+
const ignored = new Set([".git", "node_modules", "dist", "coverage"]);
|
|
470
|
+
const visit = (directory) => {
|
|
471
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
472
|
+
const path = join(directory, entry.name);
|
|
473
|
+
const rel = relative(root, path).replace(/\\/g, "/");
|
|
474
|
+
if (entry.isDirectory()) {
|
|
475
|
+
if (ignored.has(entry.name) || rel.startsWith(".foreman/") || rel.startsWith(".rafi/cache/"))
|
|
476
|
+
continue;
|
|
477
|
+
visit(path);
|
|
478
|
+
}
|
|
479
|
+
else if (entry.isFile() && statSync(path).size <= 10 * 1024 * 1024) {
|
|
480
|
+
out.set(rel, createHash("sha256").update(readFileSync(path)).digest("hex"));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
if (existsSync(root))
|
|
485
|
+
visit(root);
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
function changedProtectedFiles(before, after) {
|
|
489
|
+
return [...new Set([...before.keys(), ...after.keys()])]
|
|
490
|
+
.filter((path) => before.get(path) !== after.get(path))
|
|
491
|
+
.sort();
|
|
492
|
+
}
|
package/dist/index.js
CHANGED
|
File without changes
|
package/dist/interviews.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export declare const INTERVIEW_STATE_VERSION =
|
|
1
|
+
export declare const INTERVIEW_STATE_VERSION = 2;
|
|
2
2
|
export declare const INTERVIEW_DIRECTORY = ".rafi/interviews";
|
|
3
|
-
export type InterviewWorkflow = "create" | "plan" | "tickets-setup-init" | "tickets-setup-update";
|
|
4
|
-
export type InterviewStatus = "in_progress" | "needs_review" | "completed" | "incompatible";
|
|
3
|
+
export type InterviewWorkflow = "create" | "plan" | "tickets-plan" | "tickets-setup-init" | "tickets-setup-update";
|
|
4
|
+
export type InterviewStatus = "in_progress" | "paused" | "needs_review" | "completed" | "incompatible";
|
|
5
|
+
export type InterviewStageStatus = "not_offered" | "offered" | "accepted" | "running" | "completed" | "skipped" | "paused" | "failed" | "cancelled";
|
|
5
6
|
export interface InterviewRuntimeState {
|
|
6
7
|
runtime?: "claude" | "codex";
|
|
7
8
|
model?: string;
|
|
@@ -21,13 +22,31 @@ export interface InterviewFailure {
|
|
|
21
22
|
export interface InterviewRecord {
|
|
22
23
|
version: number;
|
|
23
24
|
id: string;
|
|
25
|
+
journeyId: string;
|
|
26
|
+
parentId?: string;
|
|
27
|
+
childIds: string[];
|
|
24
28
|
workflow: InterviewWorkflow;
|
|
25
29
|
status: InterviewStatus;
|
|
26
30
|
/** Arguments/context required to re-enter the runner. No environment secrets. */
|
|
27
31
|
invocation: Record<string, unknown>;
|
|
28
32
|
answers: Record<string, unknown>;
|
|
33
|
+
/** V2 name for compact persisted answers; `answers` remains as a compatibility alias. */
|
|
34
|
+
decisions: Record<string, unknown>;
|
|
29
35
|
checkpoint: string;
|
|
36
|
+
stages: Record<string, {
|
|
37
|
+
status: InterviewStageStatus;
|
|
38
|
+
updatedAt: string;
|
|
39
|
+
}>;
|
|
40
|
+
planningMode?: "standard" | "exhaustive";
|
|
30
41
|
runtime: InterviewRuntimeState;
|
|
42
|
+
runtimeAttempts: Array<{
|
|
43
|
+
runtime: "claude" | "codex";
|
|
44
|
+
at: string;
|
|
45
|
+
outcome: "ready" | "failed" | "cancelled";
|
|
46
|
+
category?: string;
|
|
47
|
+
}>;
|
|
48
|
+
sessionIds: Record<string, string>;
|
|
49
|
+
continuityLost: boolean;
|
|
31
50
|
outputs: InterviewOutputFingerprint[];
|
|
32
51
|
createdAt: string;
|
|
33
52
|
updatedAt: string;
|
|
@@ -50,11 +69,14 @@ export declare function createInterviewRecord(input: {
|
|
|
50
69
|
checkpoint: string;
|
|
51
70
|
outputs?: string[];
|
|
52
71
|
now?: Date;
|
|
72
|
+
parentId?: string;
|
|
73
|
+
journeyId?: string;
|
|
74
|
+
planningMode?: "standard" | "exhaustive";
|
|
53
75
|
}): InterviewRecord;
|
|
54
76
|
/** Ensure ignored local state exists without modifying unrelated gitignore text. */
|
|
55
77
|
export declare function ensureInterviewGitignore(projectDir: string): void;
|
|
56
78
|
export declare function saveInterviewRecord(projectDir: string, record: InterviewRecord, now?: Date): InterviewRecord;
|
|
57
|
-
export declare function checkpointInterview(projectDir: string, record: InterviewRecord, update: Partial<Pick<InterviewRecord, "answers" | "checkpoint" | "runtime" | "outputs" | "invocation" | "status">>): InterviewRecord;
|
|
79
|
+
export declare function checkpointInterview(projectDir: string, record: InterviewRecord, update: Partial<Pick<InterviewRecord, "answers" | "decisions" | "checkpoint" | "runtime" | "outputs" | "invocation" | "status" | "stages" | "planningMode" | "runtimeAttempts" | "sessionIds" | "continuityLost" | "childIds">>): InterviewRecord;
|
|
58
80
|
export declare function failInterview(projectDir: string, record: InterviewRecord, checkpoint: string, error: unknown): InterviewRecord;
|
|
59
81
|
export declare function completeInterview(projectDir: string, record: InterviewRecord, now?: Date): InterviewRecord;
|
|
60
82
|
export declare function readInterviewRecords(projectDir: string): InterviewReadResult;
|
package/dist/interviews.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { createHash, randomUUID } from "node:crypto";
|
|
8
8
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
9
9
|
import { join, relative, resolve } from "node:path";
|
|
10
|
-
export const INTERVIEW_STATE_VERSION =
|
|
10
|
+
export const INTERVIEW_STATE_VERSION = 2;
|
|
11
11
|
export const INTERVIEW_DIRECTORY = ".rafi/interviews";
|
|
12
12
|
const MAX_FAILURE_LENGTH = 1_000;
|
|
13
13
|
const COMPLETE_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
@@ -19,12 +19,21 @@ export function createInterviewRecord(input) {
|
|
|
19
19
|
return {
|
|
20
20
|
version: INTERVIEW_STATE_VERSION,
|
|
21
21
|
id: randomUUID(),
|
|
22
|
+
journeyId: input.journeyId ?? randomUUID(),
|
|
23
|
+
parentId: input.parentId,
|
|
24
|
+
childIds: [],
|
|
22
25
|
workflow: input.workflow,
|
|
23
26
|
status: "in_progress",
|
|
24
27
|
invocation: { ...input.invocation },
|
|
25
28
|
answers: { ...(input.answers ?? {}) },
|
|
29
|
+
decisions: { ...(input.answers ?? {}) },
|
|
26
30
|
checkpoint: input.checkpoint,
|
|
31
|
+
stages: { [input.checkpoint]: { status: "running", updatedAt: stamp } },
|
|
32
|
+
planningMode: input.planningMode,
|
|
27
33
|
runtime: {},
|
|
34
|
+
runtimeAttempts: [],
|
|
35
|
+
sessionIds: {},
|
|
36
|
+
continuityLost: false,
|
|
28
37
|
outputs: fingerprintOutputs(String(input.invocation.projectDir ?? ""), input.outputs ?? []),
|
|
29
38
|
createdAt: stamp,
|
|
30
39
|
updatedAt: stamp,
|
|
@@ -54,11 +63,26 @@ export function saveInterviewRecord(projectDir, record, now = new Date()) {
|
|
|
54
63
|
return next;
|
|
55
64
|
}
|
|
56
65
|
export function checkpointInterview(projectDir, record, update) {
|
|
66
|
+
const nextAnswers = update.answers ?? update.decisions ?? record.answers;
|
|
67
|
+
const stamp = new Date().toISOString();
|
|
68
|
+
const stages = update.stages ?? (update.checkpoint && update.checkpoint !== record.checkpoint
|
|
69
|
+
? {
|
|
70
|
+
...record.stages,
|
|
71
|
+
[record.checkpoint]: { status: stageCompletionStatus(record.status), updatedAt: stamp },
|
|
72
|
+
[update.checkpoint]: { status: "running", updatedAt: stamp },
|
|
73
|
+
}
|
|
74
|
+
: record.stages);
|
|
57
75
|
const next = {
|
|
58
76
|
...record,
|
|
59
77
|
...update,
|
|
60
|
-
answers:
|
|
78
|
+
answers: { ...nextAnswers },
|
|
79
|
+
decisions: { ...nextAnswers },
|
|
80
|
+
stages,
|
|
61
81
|
runtime: update.runtime ? { ...update.runtime } : record.runtime,
|
|
82
|
+
sessionIds: update.runtime?.sessionId
|
|
83
|
+
? { ...record.sessionIds, [update.runtime.runtime ?? "agent"]: update.runtime.sessionId }
|
|
84
|
+
: update.sessionIds ? { ...update.sessionIds } : record.sessionIds,
|
|
85
|
+
continuityLost: update.runtime?.continuityLost ?? update.continuityLost ?? record.continuityLost,
|
|
62
86
|
invocation: update.invocation ? { ...update.invocation } : record.invocation,
|
|
63
87
|
outputs: update.outputs ? update.outputs.map((output) => ({ ...output })) : record.outputs,
|
|
64
88
|
};
|
|
@@ -79,6 +103,7 @@ export function completeInterview(projectDir, record, now = new Date()) {
|
|
|
79
103
|
...record,
|
|
80
104
|
status: "completed",
|
|
81
105
|
completedAt: stamp,
|
|
106
|
+
stages: { ...record.stages, [record.checkpoint]: { status: "completed", updatedAt: stamp } },
|
|
82
107
|
failure: undefined,
|
|
83
108
|
}, now);
|
|
84
109
|
}
|
|
@@ -103,7 +128,7 @@ export function readInterviewRecords(projectDir) {
|
|
|
103
128
|
return { records, problems };
|
|
104
129
|
}
|
|
105
130
|
export function unfinishedInterviews(projectDir) {
|
|
106
|
-
return readInterviewRecords(projectDir).records.filter((record) => record.status === "in_progress" || record.status === "needs_review" || record.status === "incompatible");
|
|
131
|
+
return readInterviewRecords(projectDir).records.filter((record) => record.status === "in_progress" || record.status === "paused" || record.status === "needs_review" || record.status === "incompatible");
|
|
107
132
|
}
|
|
108
133
|
export function findInterviewRecord(projectDir, id) {
|
|
109
134
|
return readInterviewRecords(projectDir).records.find((record) => record.id === id || record.id.startsWith(id));
|
|
@@ -165,6 +190,8 @@ function parseInterviewRecord(value) {
|
|
|
165
190
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
166
191
|
throw new Error("invalid interview record");
|
|
167
192
|
const record = value;
|
|
193
|
+
if (record.version === 1)
|
|
194
|
+
return migrateV1Record(value);
|
|
168
195
|
if (record.version !== INTERVIEW_STATE_VERSION) {
|
|
169
196
|
// Preserve it on disk. The caller can show it and require an explicit discard.
|
|
170
197
|
if (typeof record.id === "string" && typeof record.workflow === "string") {
|
|
@@ -175,10 +202,50 @@ function parseInterviewRecord(value) {
|
|
|
175
202
|
validateRecord(record);
|
|
176
203
|
return record;
|
|
177
204
|
}
|
|
205
|
+
function migrateV1Record(value) {
|
|
206
|
+
const legacy = value;
|
|
207
|
+
if (!legacy.id || !legacy.workflow || !legacy.checkpoint || !legacy.createdAt || !legacy.updatedAt) {
|
|
208
|
+
throw new Error("invalid V1 interview record");
|
|
209
|
+
}
|
|
210
|
+
const outputs = Array.isArray(legacy.outputs) ? legacy.outputs : [];
|
|
211
|
+
const evidenceIsComplete = outputs.length > 0 && outputs.every((item) => item && typeof item.path === "string" && typeof item.sha256 === "string");
|
|
212
|
+
const answers = legacy.answers && typeof legacy.answers === "object" ? legacy.answers : {};
|
|
213
|
+
const runtime = legacy.runtime && typeof legacy.runtime === "object" ? legacy.runtime : {};
|
|
214
|
+
const stamp = legacy.updatedAt;
|
|
215
|
+
return {
|
|
216
|
+
version: 2,
|
|
217
|
+
id: legacy.id,
|
|
218
|
+
journeyId: legacy.id,
|
|
219
|
+
childIds: [],
|
|
220
|
+
workflow: legacy.workflow,
|
|
221
|
+
status: legacy.status === "completed" ? "completed" : evidenceIsComplete ? "paused" : "needs_review",
|
|
222
|
+
invocation: legacy.invocation && typeof legacy.invocation === "object" ? legacy.invocation : {},
|
|
223
|
+
answers,
|
|
224
|
+
decisions: { ...answers },
|
|
225
|
+
checkpoint: legacy.checkpoint,
|
|
226
|
+
stages: { [legacy.checkpoint]: { status: evidenceIsComplete ? "paused" : "failed", updatedAt: stamp } },
|
|
227
|
+
runtime,
|
|
228
|
+
runtimeAttempts: [],
|
|
229
|
+
sessionIds: runtime.sessionId ? { [runtime.runtime ?? "agent"]: runtime.sessionId } : {},
|
|
230
|
+
continuityLost: Boolean(runtime.continuityLost || !runtime.sessionId),
|
|
231
|
+
outputs,
|
|
232
|
+
createdAt: legacy.createdAt,
|
|
233
|
+
updatedAt: legacy.updatedAt,
|
|
234
|
+
completedAt: legacy.completedAt,
|
|
235
|
+
failure: legacy.failure,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function stageCompletionStatus(status) {
|
|
239
|
+
if (status === "paused" || status === "needs_review")
|
|
240
|
+
return "paused";
|
|
241
|
+
if (status === "completed")
|
|
242
|
+
return "completed";
|
|
243
|
+
return "completed";
|
|
244
|
+
}
|
|
178
245
|
function validateRecord(record) {
|
|
179
246
|
if (record.version !== INTERVIEW_STATE_VERSION)
|
|
180
247
|
throw new Error("unsupported interview record version");
|
|
181
|
-
if (!record.id || !record.workflow || !record.checkpoint || !record.createdAt || !record.updatedAt) {
|
|
248
|
+
if (!record.id || !record.journeyId || !record.workflow || !record.checkpoint || !record.createdAt || !record.updatedAt) {
|
|
182
249
|
throw new Error("invalid interview record");
|
|
183
250
|
}
|
|
184
251
|
}
|