ai-foreman 1.4.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/adapters/codex.js +1 -0
- package/dist/agentRun.d.ts +4 -2
- package/dist/agentRun.js +32 -9
- 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 +154 -57
- package/dist/foreman.d.ts +3 -1
- package/dist/foreman.js +46 -4
- package/dist/index.js +0 -0
- package/dist/interviews.d.ts +90 -0
- package/dist/interviews.js +251 -0
- 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 +14 -0
- package/dist/tickets/setupConfig.js +59 -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 +33 -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,9 @@ 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, 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";
|
|
18
|
+
import { checkpointInterview, completeInterview, createInterviewRecord, failInterview, } from "../interviews.js";
|
|
17
19
|
function fail(msg) {
|
|
18
20
|
console.error(`foreman tickets: ${msg}`);
|
|
19
21
|
process.exit(1);
|
|
@@ -138,73 +140,114 @@ async function cmdSetupInitCli(opts) {
|
|
|
138
140
|
const dir = cwd(opts);
|
|
139
141
|
if (!existsSync(dir))
|
|
140
142
|
fail(`project directory not found: ${dir}`);
|
|
143
|
+
let interview = beginTicketSetupInterview(dir, "tickets-setup-init", opts);
|
|
141
144
|
if (hasTicketSetupConfig(dir)) {
|
|
142
145
|
console.log("foreman tickets setup: existing setup found; opening setup:update");
|
|
143
146
|
await cmdSetupUpdateCli(opts);
|
|
147
|
+
if (interview)
|
|
148
|
+
completeInterview(dir, interview);
|
|
144
149
|
return;
|
|
145
150
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
saveTicketSetupConfig(dir, answers, {
|
|
153
|
-
appName: opts.appName ?? defaultAppName(dir),
|
|
154
|
-
docsRoot: opts.docsRoot,
|
|
155
|
-
targets: parseRuntimeTargets(opts.runtime),
|
|
156
|
-
});
|
|
157
|
-
console.log(`foreman tickets setup: saved ticket setup in ${join(dir, "rafi-config.yaml")}`);
|
|
158
|
-
await validateConfiguredSourcesIfRequested(answers, Boolean(opts.skipAccessCheck));
|
|
159
|
-
if (!isTicketsInitialized(dir)) {
|
|
160
|
-
cmdInit(dir, {
|
|
151
|
+
try {
|
|
152
|
+
const answers = await collectTicketSetup(dir, opts, undefined);
|
|
153
|
+
if (interview)
|
|
154
|
+
interview = checkpointInterview(dir, interview, { checkpoint: "save-setup", answers: { setup: answers } });
|
|
155
|
+
await validateConfiguredSourcesIfRequested(dir, answers, Boolean(opts.skipAccessCheck));
|
|
156
|
+
ensureRafiConfigForTicketSetup(dir, {
|
|
161
157
|
appName: opts.appName ?? defaultAppName(dir),
|
|
162
|
-
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
163
158
|
docsRoot: opts.docsRoot,
|
|
159
|
+
targets: parseRuntimeTargets(opts.runtime),
|
|
164
160
|
});
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
message: "Run ticket population now?",
|
|
170
|
-
initialValue: answers.sources.length > 0,
|
|
161
|
+
saveTicketSetupConfig(dir, answers, {
|
|
162
|
+
appName: opts.appName ?? defaultAppName(dir),
|
|
163
|
+
docsRoot: opts.docsRoot,
|
|
164
|
+
targets: parseRuntimeTargets(opts.runtime),
|
|
171
165
|
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
166
|
+
console.log(`foreman tickets setup: saved ticket setup in ${join(dir, "rafi-config.yaml")}`);
|
|
167
|
+
if (!isTicketsInitialized(dir)) {
|
|
168
|
+
if (interview)
|
|
169
|
+
interview = checkpointInterview(dir, interview, { checkpoint: "initialize-tracker" });
|
|
170
|
+
cmdInit(dir, {
|
|
171
|
+
appName: opts.appName ?? defaultAppName(dir),
|
|
172
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
173
|
+
docsRoot: opts.docsRoot,
|
|
174
|
+
});
|
|
175
|
+
console.log("foreman tickets setup: initialized .tickets/");
|
|
176
176
|
}
|
|
177
|
+
if (shouldPrompt(opts)) {
|
|
178
|
+
if (interview)
|
|
179
|
+
interview = checkpointInterview(dir, interview, { checkpoint: "populate-confirmation" });
|
|
180
|
+
const populate = await confirm({
|
|
181
|
+
message: "Run ticket population now?",
|
|
182
|
+
initialValue: answers.sources.length > 0,
|
|
183
|
+
});
|
|
184
|
+
if (isCancel(populate))
|
|
185
|
+
return;
|
|
186
|
+
if (populate) {
|
|
187
|
+
await cmdPopulateCli({ project: dir, yes: true });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
console.log(`foreman tickets setup: next — run \`foreman tickets populate --project ${shellQuote(dir)}\``);
|
|
192
|
+
}
|
|
193
|
+
if (interview)
|
|
194
|
+
completeInterview(dir, interview);
|
|
177
195
|
}
|
|
178
|
-
|
|
179
|
-
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (interview)
|
|
198
|
+
failInterview(dir, interview, interview.checkpoint, error);
|
|
199
|
+
throw error;
|
|
180
200
|
}
|
|
181
201
|
}
|
|
182
202
|
async function cmdSetupUpdateCli(opts) {
|
|
183
203
|
const dir = cwd(opts);
|
|
184
204
|
if (!existsSync(dir))
|
|
185
205
|
fail(`project directory not found: ${dir}`);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
206
|
+
let interview = beginTicketSetupInterview(dir, "tickets-setup-update", opts);
|
|
207
|
+
try {
|
|
208
|
+
const current = loadTicketSetupConfigWithDefaults(dir);
|
|
209
|
+
const patch = await collectTicketSetupPatch(dir, opts, current);
|
|
210
|
+
const next = mergeTicketSetup(current, patch);
|
|
211
|
+
if (interview)
|
|
212
|
+
interview = checkpointInterview(dir, interview, { checkpoint: "save-setup", answers: { setup: next } });
|
|
213
|
+
await validateConfiguredSourcesIfRequested(dir, next, Boolean(opts.skipAccessCheck));
|
|
214
|
+
ensureRafiConfigForTicketSetup(dir, {
|
|
215
|
+
appName: opts.appName ?? defaultAppName(dir),
|
|
216
|
+
docsRoot: opts.docsRoot,
|
|
217
|
+
targets: parseRuntimeTargets(opts.runtime),
|
|
218
|
+
});
|
|
219
|
+
saveTicketSetupConfig(dir, next, {
|
|
220
|
+
appName: opts.appName ?? defaultAppName(dir),
|
|
221
|
+
docsRoot: opts.docsRoot,
|
|
222
|
+
targets: parseRuntimeTargets(opts.runtime),
|
|
223
|
+
});
|
|
224
|
+
console.log(`foreman tickets setup: updated ticket setup in ${join(dir, "rafi-config.yaml")}`);
|
|
225
|
+
if (isTicketsInitialized(dir)) {
|
|
226
|
+
const rows = cmdQueue(dir, 1);
|
|
227
|
+
if (rows.length === 0) {
|
|
228
|
+
console.log(`foreman tickets setup: queue is empty; run \`foreman tickets populate --project ${shellQuote(dir)}\` to import or refresh tickets.`);
|
|
229
|
+
}
|
|
205
230
|
}
|
|
231
|
+
if (interview)
|
|
232
|
+
completeInterview(dir, interview);
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
if (interview)
|
|
236
|
+
failInterview(dir, interview, interview.checkpoint, error);
|
|
237
|
+
throw error;
|
|
206
238
|
}
|
|
207
239
|
}
|
|
240
|
+
function beginTicketSetupInterview(projectDir, workflow, opts) {
|
|
241
|
+
if (!shouldPrompt(opts))
|
|
242
|
+
return undefined;
|
|
243
|
+
const record = createInterviewRecord({
|
|
244
|
+
workflow,
|
|
245
|
+
invocation: { projectDir, ...opts },
|
|
246
|
+
checkpoint: "setup-section",
|
|
247
|
+
outputs: ["rafi-config.yaml", ".tickets/config.yaml"],
|
|
248
|
+
});
|
|
249
|
+
return checkpointInterview(projectDir, record, { checkpoint: "setup-section" });
|
|
250
|
+
}
|
|
208
251
|
async function collectTicketSetup(dir, opts, current) {
|
|
209
252
|
const patch = await collectTicketSetupPatch(dir, opts, current ?? DEFAULT_TICKET_SETUP);
|
|
210
253
|
const build = patch.build ?? {};
|
|
@@ -229,8 +272,11 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
229
272
|
buildPatch.auto_merge_timeout_minutes = parseOptionalPositiveInteger(opts.autoMergeTimeoutMinutes, "--auto-merge-timeout-minutes");
|
|
230
273
|
}
|
|
231
274
|
if (!shouldPrompt(opts)) {
|
|
275
|
+
const prefill = current.sources.length === 0
|
|
276
|
+
? configuredPlanningSources(dir).map((path) => ({ type: "local", paths: [path] }))
|
|
277
|
+
: current.sources;
|
|
232
278
|
return {
|
|
233
|
-
sources: nonInteractiveSources.length > 0 ? nonInteractiveSources :
|
|
279
|
+
sources: nonInteractiveSources.length > 0 ? nonInteractiveSources : prefill,
|
|
234
280
|
populate: populatePatch,
|
|
235
281
|
build: Object.keys(buildPatch).length > 0 ? buildPatch : {},
|
|
236
282
|
};
|
|
@@ -246,7 +292,12 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
246
292
|
});
|
|
247
293
|
if (isCancel(section))
|
|
248
294
|
process.exit(0);
|
|
249
|
-
|
|
295
|
+
// Do not silently persist planning hints over a completed ticket setup. They
|
|
296
|
+
// are only the initial local-source suggestion for a fresh setup.
|
|
297
|
+
const planningPrefill = current.sources.length === 0
|
|
298
|
+
? configuredPlanningSources(dir).map((path) => ({ type: "local", paths: [path] }))
|
|
299
|
+
: current.sources;
|
|
300
|
+
let sources = nonInteractiveSources.length > 0 ? nonInteractiveSources : planningPrefill;
|
|
250
301
|
if (section === "sources" || section === "all") {
|
|
251
302
|
sources = await promptTicketSources(dir, current.sources);
|
|
252
303
|
}
|
|
@@ -333,6 +384,7 @@ async function promptTicketSources(dir, current) {
|
|
|
333
384
|
{ value: "local", label: "Local docs, files, folders, or globs" },
|
|
334
385
|
{ value: "linear", label: "Linear" },
|
|
335
386
|
{ value: "jira", label: "Jira Cloud" },
|
|
387
|
+
{ value: "url", label: "Public URL (HTML, text, Markdown, or PDF)" },
|
|
336
388
|
{ value: "none", label: "No saved source" },
|
|
337
389
|
],
|
|
338
390
|
});
|
|
@@ -365,6 +417,17 @@ async function promptTicketSources(dir, current) {
|
|
|
365
417
|
filter: String(filter).trim() || null,
|
|
366
418
|
}];
|
|
367
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
|
+
}
|
|
368
431
|
const site = await text({
|
|
369
432
|
message: "Jira Cloud site URL:",
|
|
370
433
|
placeholder: "https://your-domain.atlassian.net",
|
|
@@ -410,9 +473,11 @@ function sourcesFromSetupOptions(opts) {
|
|
|
410
473
|
jql: opts.jiraJql,
|
|
411
474
|
});
|
|
412
475
|
}
|
|
476
|
+
for (const url of opts.urlSource ?? [])
|
|
477
|
+
sources.push({ type: "url", url });
|
|
413
478
|
return sources;
|
|
414
479
|
}
|
|
415
|
-
async function validateConfiguredSourcesIfRequested(setup, skip) {
|
|
480
|
+
async function validateConfiguredSourcesIfRequested(projectDir, setup, skip) {
|
|
416
481
|
if (skip)
|
|
417
482
|
return;
|
|
418
483
|
for (const source of externalSources(setup)) {
|
|
@@ -424,6 +489,12 @@ async function validateConfiguredSourcesIfRequested(setup, skip) {
|
|
|
424
489
|
console.log(`foreman tickets setup: ${source.type} access check skipped/failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
425
490
|
}
|
|
426
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
|
+
}
|
|
427
498
|
}
|
|
428
499
|
function shouldPrompt(opts) {
|
|
429
500
|
return !opts.defaults && !opts.yes && process.stdin.isTTY && process.stdout.isTTY;
|
|
@@ -587,12 +658,21 @@ export async function cmdPopulateCli(opts) {
|
|
|
587
658
|
}
|
|
588
659
|
const ticketsConfig = loadTicketsConfig(dir);
|
|
589
660
|
const setup = loadTicketSetupConfig(dir);
|
|
590
|
-
const explicitSources = opts.sources;
|
|
661
|
+
const explicitSources = opts.sources ? await prepareDocumentSources(dir, opts.sources) : undefined;
|
|
591
662
|
const configuredExternalSources = explicitSources?.length ? [] : externalSources(setup);
|
|
663
|
+
const configuredUrlSources = explicitSources?.length ? [] : urlSources(setup);
|
|
592
664
|
const savedLocalSources = explicitSources?.length ? [] : localSourcePaths(setup);
|
|
593
|
-
const
|
|
665
|
+
const selectedLocalHints = configuredExternalSources.length > 0 && savedLocalSources.length === 0 && configuredUrlSources.length === 0
|
|
594
666
|
? undefined
|
|
595
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]);
|
|
596
676
|
const populateAgent = opts.agent
|
|
597
677
|
?? (setup?.populate.agent_preference && setup.populate.agent_preference !== "configured"
|
|
598
678
|
? setup.populate.agent_preference
|
|
@@ -628,7 +708,7 @@ export async function cmdPopulateCli(opts) {
|
|
|
628
708
|
if (!validation.clean)
|
|
629
709
|
process.exit(1);
|
|
630
710
|
}
|
|
631
|
-
if (!sourceHints
|
|
711
|
+
if (!sourceHints.length) {
|
|
632
712
|
console.log(`foreman tickets: imported external tickets and rendered ${ticketsConfig.paths.progressDoc}`);
|
|
633
713
|
return;
|
|
634
714
|
}
|
|
@@ -668,7 +748,7 @@ export async function cmdPopulateCli(opts) {
|
|
|
668
748
|
console.log(`foreman tickets: project ${dir}`);
|
|
669
749
|
console.log(`foreman tickets: role ${TICKET_POPULATE_ROLE}`);
|
|
670
750
|
console.log(`foreman tickets: log ${logPath}\n`);
|
|
671
|
-
if (sourceHints
|
|
751
|
+
if (sourceHints.length) {
|
|
672
752
|
console.log(`foreman tickets: source hints ${sourceHints.join(" ")}`);
|
|
673
753
|
}
|
|
674
754
|
const turn = await foreman.runInstruction(buildPopulateInstruction(sourceHints, ticketsConfig.paths.progressDoc));
|
|
@@ -711,6 +791,21 @@ export async function cmdPopulateCli(opts) {
|
|
|
711
791
|
fail(String(err instanceof Error ? err.message : err));
|
|
712
792
|
}
|
|
713
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
|
+
}
|
|
714
809
|
export function buildTicketsCommand() {
|
|
715
810
|
const tickets = new Command("tickets").description("Manage the structured ticket tracker for a project.");
|
|
716
811
|
// ── setup:init / setup:update ─────────────────────────────────────────────
|
|
@@ -729,12 +824,13 @@ export function buildTicketsCommand() {
|
|
|
729
824
|
.option("--linear-filter <filter>", "Linear IssueFilter JSON or title search text")
|
|
730
825
|
.option("--jira-site <url>", "Jira Cloud site URL")
|
|
731
826
|
.option("--jira-jql <jql>", "Jira JQL query")
|
|
827
|
+
.option("--url-source <urls...>", "add public HTTP(S) source URLs")
|
|
732
828
|
.option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
|
|
733
829
|
.option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
|
|
734
830
|
.option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
|
|
735
831
|
.option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
|
|
736
832
|
.option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
|
|
737
|
-
.option("--skip-access-check", "do not validate
|
|
833
|
+
.option("--skip-access-check", "do not validate configured source access during setup")
|
|
738
834
|
.action(async (opts) => {
|
|
739
835
|
try {
|
|
740
836
|
await cmdSetupInitCli(opts);
|
|
@@ -758,12 +854,13 @@ export function buildTicketsCommand() {
|
|
|
758
854
|
.option("--linear-filter <filter>", "Linear IssueFilter JSON or title search text")
|
|
759
855
|
.option("--jira-site <url>", "Jira Cloud site URL")
|
|
760
856
|
.option("--jira-jql <jql>", "Jira JQL query")
|
|
857
|
+
.option("--url-source <urls...>", "replace saved sources with public HTTP(S) URLs")
|
|
761
858
|
.option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
|
|
762
859
|
.option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
|
|
763
860
|
.option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
|
|
764
861
|
.option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
|
|
765
862
|
.option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
|
|
766
|
-
.option("--skip-access-check", "do not validate
|
|
863
|
+
.option("--skip-access-check", "do not validate configured source access during setup")
|
|
767
864
|
.action(async (opts) => {
|
|
768
865
|
try {
|
|
769
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
|