ai-foreman 1.3.0 → 1.4.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.
@@ -2,17 +2,18 @@ import { Command } from "commander";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { parse as parseYaml } from "yaml";
5
- import { select, isCancel } from "@clack/prompts";
5
+ import { select, text, confirm, isCancel } from "@clack/prompts";
6
6
  import { loadConfig } from "../config.js";
7
7
  import { Log } from "../log.js";
8
8
  import { Foreman } from "../foreman.js";
9
9
  import { printEvents } from "./events.js";
10
- import { cmdInit, cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdCancel, cmdDiscover, cmdAcceptFutureWork, cmdReorder, cmdRender, cmdValidate, cmdQueue, cmdArchive, } from "../tickets/commands.js";
11
- import { isTicketsInitialized, loadTicketsConfig } from "../tickets/config.js";
10
+ import { cmdInit, cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdCancel, cmdDiscover, cmdAcceptFutureWork, cmdReorder, cmdRender, cmdValidate, cmdQueue, cmdArchive, cmdReview, } from "../tickets/commands.js";
11
+ import { DEFAULT_TICKETS_CONFIG, isTicketsInitialized, loadTicketsConfig } from "../tickets/config.js";
12
12
  import { validateDocsRoot } from "../tickets/config.js";
13
- import { importFromMarkdown } from "../tickets/importer.js";
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
17
  function fail(msg) {
17
18
  console.error(`foreman tickets: ${msg}`);
18
19
  process.exit(1);
@@ -23,6 +24,10 @@ function cwd(opts) {
23
24
  function unique(values) {
24
25
  return [...new Set(values)];
25
26
  }
27
+ function optionWasProvided(command, name) {
28
+ const source = command.getOptionValueSource(name);
29
+ return source !== undefined && source !== "default";
30
+ }
26
31
  const TICKET_POPULATE_ROLE = "ticket-maker";
27
32
  const RAFI_CONFIG_FILES = ["rafi-config.yaml", "project.yaml"];
28
33
  function validateEffort(effort) {
@@ -85,6 +90,10 @@ STEP_STATUS: blocked | reason="why ticket population cannot proceed"`;
85
90
  export function resolvePopulateSources(projectDir, explicitSources, ticketsConfig) {
86
91
  if (explicitSources && explicitSources.length > 0)
87
92
  return explicitSources;
93
+ const setup = loadTicketSetupConfig(projectDir);
94
+ const savedLocalSources = localSourcePaths(setup);
95
+ if (savedLocalSources.length > 0)
96
+ return savedLocalSources;
88
97
  const rafiPlan = resolveConfiguredRafiPlanSource(projectDir, "rafi-config.yaml");
89
98
  if (rafiPlan)
90
99
  return [rafiPlan];
@@ -125,8 +134,644 @@ export function buildPopulateAgentRunOptions(opts) {
125
134
  log: opts.log,
126
135
  };
127
136
  }
137
+ async function cmdSetupInitCli(opts) {
138
+ const dir = cwd(opts);
139
+ if (!existsSync(dir))
140
+ fail(`project directory not found: ${dir}`);
141
+ if (hasTicketSetupConfig(dir)) {
142
+ console.log("foreman tickets setup: existing setup found; opening setup:update");
143
+ await cmdSetupUpdateCli(opts);
144
+ return;
145
+ }
146
+ const answers = await collectTicketSetup(dir, opts, undefined);
147
+ ensureRafiConfigForTicketSetup(dir, {
148
+ appName: opts.appName ?? defaultAppName(dir),
149
+ docsRoot: opts.docsRoot,
150
+ targets: parseRuntimeTargets(opts.runtime),
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, {
161
+ appName: opts.appName ?? defaultAppName(dir),
162
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
163
+ docsRoot: opts.docsRoot,
164
+ });
165
+ console.log("foreman tickets setup: initialized .tickets/");
166
+ }
167
+ if (shouldPrompt(opts)) {
168
+ const populate = await confirm({
169
+ message: "Run ticket population now?",
170
+ initialValue: answers.sources.length > 0,
171
+ });
172
+ if (isCancel(populate))
173
+ process.exit(0);
174
+ if (populate) {
175
+ await cmdPopulateCli({ project: dir, yes: true });
176
+ }
177
+ }
178
+ else {
179
+ console.log(`foreman tickets setup: next — run \`foreman tickets populate --project ${shellQuote(dir)}\``);
180
+ }
181
+ }
182
+ async function cmdSetupUpdateCli(opts) {
183
+ const dir = cwd(opts);
184
+ if (!existsSync(dir))
185
+ fail(`project directory not found: ${dir}`);
186
+ const current = loadTicketSetupConfigWithDefaults(dir);
187
+ const patch = await collectTicketSetupPatch(dir, opts, current);
188
+ const next = mergeTicketSetup(current, patch);
189
+ ensureRafiConfigForTicketSetup(dir, {
190
+ appName: opts.appName ?? defaultAppName(dir),
191
+ docsRoot: opts.docsRoot,
192
+ targets: parseRuntimeTargets(opts.runtime),
193
+ });
194
+ saveTicketSetupConfig(dir, next, {
195
+ appName: opts.appName ?? defaultAppName(dir),
196
+ docsRoot: opts.docsRoot,
197
+ targets: parseRuntimeTargets(opts.runtime),
198
+ });
199
+ console.log(`foreman tickets setup: updated ticket setup in ${join(dir, "rafi-config.yaml")}`);
200
+ await validateConfiguredSourcesIfRequested(next, Boolean(opts.skipAccessCheck));
201
+ if (isTicketsInitialized(dir)) {
202
+ const rows = cmdQueue(dir, 1);
203
+ if (rows.length === 0) {
204
+ console.log(`foreman tickets setup: queue is empty; run \`foreman tickets populate --project ${shellQuote(dir)}\` to import or refresh tickets.`);
205
+ }
206
+ }
207
+ }
208
+ async function collectTicketSetup(dir, opts, current) {
209
+ const patch = await collectTicketSetupPatch(dir, opts, current ?? DEFAULT_TICKET_SETUP);
210
+ const build = patch.build ?? {};
211
+ return mergeTicketSetup(current, {
212
+ ...patch,
213
+ build: Object.keys(build).length > 0 ? build : recommendedBuildDefaults(dir),
214
+ });
215
+ }
216
+ async function collectTicketSetupPatch(dir, opts, current) {
217
+ const nonInteractiveSources = sourcesFromSetupOptions(opts);
218
+ const populatePatch = {};
219
+ const buildPatch = {};
220
+ if (opts.agentPreference)
221
+ populatePatch.agent_preference = parseAgentPreference(opts.agentPreference);
222
+ if (opts.completion)
223
+ buildPatch.completion = parseCompletionMode(opts.completion);
224
+ if (opts.provider)
225
+ buildPatch.provider = parseProvider(opts.provider);
226
+ if (opts.autoMergeWait !== undefined)
227
+ buildPatch.auto_merge_wait = Boolean(opts.autoMergeWait);
228
+ if (opts.autoMergeTimeoutMinutes !== undefined) {
229
+ buildPatch.auto_merge_timeout_minutes = parseOptionalPositiveInteger(opts.autoMergeTimeoutMinutes, "--auto-merge-timeout-minutes");
230
+ }
231
+ if (!shouldPrompt(opts)) {
232
+ return {
233
+ sources: nonInteractiveSources.length > 0 ? nonInteractiveSources : current.sources,
234
+ populate: populatePatch,
235
+ build: Object.keys(buildPatch).length > 0 ? buildPatch : {},
236
+ };
237
+ }
238
+ const section = await select({
239
+ message: "Which ticket setup section should be configured?",
240
+ options: [
241
+ { value: "sources", label: "Ticket sources" },
242
+ { value: "populate", label: "Populate defaults" },
243
+ { value: "build", label: "Build defaults" },
244
+ { value: "all", label: "All sections" },
245
+ ],
246
+ });
247
+ if (isCancel(section))
248
+ process.exit(0);
249
+ let sources = nonInteractiveSources.length > 0 ? nonInteractiveSources : current.sources;
250
+ if (section === "sources" || section === "all") {
251
+ sources = await promptTicketSources(dir, current.sources);
252
+ }
253
+ if (section === "populate" || section === "all") {
254
+ const agent = await select({
255
+ message: "When both runtimes are configured, which should populate use?",
256
+ initialValue: current.populate.agent_preference,
257
+ options: [
258
+ { value: "configured", label: "Configured project default" },
259
+ { value: "claude", label: "Claude" },
260
+ { value: "codex", label: "Codex" },
261
+ ],
262
+ });
263
+ if (isCancel(agent))
264
+ process.exit(0);
265
+ populatePatch.agent_preference = agent;
266
+ }
267
+ if (section === "build" || section === "all") {
268
+ const recommended = recommendedBuildDefaults(dir);
269
+ const completion = await select({
270
+ message: "Default completion behavior for branch ticket runs:",
271
+ initialValue: current.build.completion === "none" ? recommended.completion : current.build.completion,
272
+ options: [
273
+ { value: "auto-merge", label: "PR/MR auto-merge, squash when checks pass" },
274
+ { value: "pr", label: "Create PR/MR only" },
275
+ { value: "direct-merge", label: "Direct local squash merge" },
276
+ { value: "none", label: "No branch completion action" },
277
+ ],
278
+ });
279
+ if (isCancel(completion))
280
+ process.exit(0);
281
+ buildPatch.branch_strategy = "branch-per-ticket";
282
+ buildPatch.completion = completion;
283
+ buildPatch.provider = recommended.provider;
284
+ buildPatch.pr_ready = completion === "auto-merge";
285
+ buildPatch.merge_method = "squash";
286
+ buildPatch.cleanup = true;
287
+ if (completion === "auto-merge") {
288
+ const wait = await confirm({
289
+ message: "Wait for dependency PR/MRs to merge before starting dependent tickets?",
290
+ initialValue: current.build.auto_merge_wait,
291
+ });
292
+ if (isCancel(wait))
293
+ process.exit(0);
294
+ buildPatch.auto_merge_wait = Boolean(wait);
295
+ if (wait) {
296
+ const timeout = await text({
297
+ message: "Auto-merge dependency wait timeout in minutes (blank for no timeout):",
298
+ initialValue: current.build.auto_merge_timeout_minutes === null ? "" : String(current.build.auto_merge_timeout_minutes),
299
+ defaultValue: "",
300
+ validate: (value) => {
301
+ const textValue = String(value ?? "").trim();
302
+ if (!textValue)
303
+ return undefined;
304
+ const parsed = Number.parseInt(textValue, 10);
305
+ return Number.isInteger(parsed) && parsed > 0 ? undefined : "Enter a positive integer or leave blank";
306
+ },
307
+ });
308
+ if (isCancel(timeout))
309
+ process.exit(0);
310
+ buildPatch.auto_merge_timeout_minutes = String(timeout).trim()
311
+ ? Number.parseInt(String(timeout).trim(), 10)
312
+ : null;
313
+ }
314
+ else {
315
+ buildPatch.auto_merge_timeout_minutes = null;
316
+ }
317
+ }
318
+ else {
319
+ buildPatch.auto_merge_wait = false;
320
+ buildPatch.auto_merge_timeout_minutes = null;
321
+ }
322
+ }
323
+ return {
324
+ sources,
325
+ populate: populatePatch,
326
+ build: buildPatch,
327
+ };
328
+ }
329
+ async function promptTicketSources(dir, current) {
330
+ const kind = await select({
331
+ message: "Primary ticket source:",
332
+ options: [
333
+ { value: "local", label: "Local docs, files, folders, or globs" },
334
+ { value: "linear", label: "Linear" },
335
+ { value: "jira", label: "Jira Cloud" },
336
+ { value: "none", label: "No saved source" },
337
+ ],
338
+ });
339
+ if (isCancel(kind))
340
+ process.exit(0);
341
+ if (kind === "none")
342
+ return [];
343
+ if (kind === "local") {
344
+ const existing = current.find((source) => source.type === "local");
345
+ const answer = await text({
346
+ message: "Local source paths or globs, comma-separated:",
347
+ initialValue: existing?.paths.join(", ") || `${configuredDocsPlanPath(dir)}`,
348
+ defaultValue: existing?.paths.join(", ") || `${configuredDocsPlanPath(dir)}`,
349
+ });
350
+ if (isCancel(answer))
351
+ process.exit(0);
352
+ return [{ type: "local", paths: splitCommaList(String(answer)) }];
353
+ }
354
+ if (kind === "linear") {
355
+ const team = await text({ message: "Linear team key (optional):" });
356
+ if (isCancel(team))
357
+ process.exit(0);
358
+ const filter = await text({ message: "Linear IssueFilter JSON or title search text (optional):" });
359
+ if (isCancel(filter))
360
+ process.exit(0);
361
+ return [{
362
+ type: "linear",
363
+ api_key_env: "LINEAR_API_KEY",
364
+ team_key: String(team).trim() || null,
365
+ filter: String(filter).trim() || null,
366
+ }];
367
+ }
368
+ const site = await text({
369
+ message: "Jira Cloud site URL:",
370
+ placeholder: "https://your-domain.atlassian.net",
371
+ validate: (value) => String(value ?? "").trim() ? undefined : "Enter a Jira Cloud site URL",
372
+ });
373
+ if (isCancel(site))
374
+ process.exit(0);
375
+ const jql = await text({
376
+ message: "Jira JQL:",
377
+ initialValue: "resolution = Unresolved ORDER BY priority DESC, updated DESC",
378
+ defaultValue: "resolution = Unresolved ORDER BY priority DESC, updated DESC",
379
+ });
380
+ if (isCancel(jql))
381
+ process.exit(0);
382
+ return [{
383
+ type: "jira",
384
+ site: String(site).trim(),
385
+ email_env: "JIRA_EMAIL",
386
+ token_env: "JIRA_API_TOKEN",
387
+ jql: String(jql).trim(),
388
+ }];
389
+ }
390
+ function sourcesFromSetupOptions(opts) {
391
+ const sources = [];
392
+ if (opts.localSource?.length)
393
+ sources.push({ type: "local", paths: opts.localSource });
394
+ if (opts.linear || opts.linearTeamKey || opts.linearFilter) {
395
+ sources.push({
396
+ type: "linear",
397
+ api_key_env: "LINEAR_API_KEY",
398
+ team_key: opts.linearTeamKey ?? null,
399
+ filter: opts.linearFilter ?? null,
400
+ });
401
+ }
402
+ if (opts.jiraSite || opts.jiraJql) {
403
+ if (!opts.jiraSite || !opts.jiraJql)
404
+ fail("--jira-site and --jira-jql must be passed together");
405
+ sources.push({
406
+ type: "jira",
407
+ site: opts.jiraSite,
408
+ email_env: "JIRA_EMAIL",
409
+ token_env: "JIRA_API_TOKEN",
410
+ jql: opts.jiraJql,
411
+ });
412
+ }
413
+ return sources;
414
+ }
415
+ async function validateConfiguredSourcesIfRequested(setup, skip) {
416
+ if (skip)
417
+ return;
418
+ for (const source of externalSources(setup)) {
419
+ try {
420
+ await validateExternalSourceAccess(source);
421
+ console.log(`foreman tickets setup: validated ${source.type} access`);
422
+ }
423
+ catch (err) {
424
+ console.log(`foreman tickets setup: ${source.type} access check skipped/failed: ${err instanceof Error ? err.message : String(err)}`);
425
+ }
426
+ }
427
+ }
428
+ function shouldPrompt(opts) {
429
+ return !opts.defaults && !opts.yes && process.stdin.isTTY && process.stdout.isTTY;
430
+ }
431
+ function parseRuntimeTargets(value) {
432
+ if (!value)
433
+ return undefined;
434
+ if (value === "both")
435
+ return ["claude", "codex"];
436
+ if (value === "claude" || value === "codex")
437
+ return [value];
438
+ fail("--runtime must be one of: both, claude, codex");
439
+ }
440
+ function parseCompletionMode(value) {
441
+ if (["pr", "auto-merge", "direct-merge", "none"].includes(value))
442
+ return value;
443
+ fail("--completion must be one of: pr, auto-merge, direct-merge, none");
444
+ }
445
+ function parseProvider(value) {
446
+ if (["auto", "github", "gitlab", "local"].includes(value))
447
+ return value;
448
+ fail("--provider must be one of: auto, github, gitlab, local");
449
+ }
450
+ function parseAgentPreference(value) {
451
+ if (["configured", "claude", "codex"].includes(value))
452
+ return value;
453
+ fail("--agent-preference must be one of: configured, claude, codex");
454
+ }
455
+ function parseOptionalPositiveInteger(value, label) {
456
+ if (!value.trim())
457
+ return null;
458
+ const parsed = Number.parseInt(value, 10);
459
+ if (Number.isInteger(parsed) && parsed > 0)
460
+ return parsed;
461
+ fail(`${label} must be a positive integer or blank`);
462
+ }
463
+ function splitCommaList(value) {
464
+ return value.split(",").map((part) => part.trim()).filter(Boolean);
465
+ }
466
+ function shellQuote(value) {
467
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value))
468
+ return value;
469
+ return `'${value.replace(/'/g, "'\"'\"'")}'`;
470
+ }
471
+ function configuredDocsPlanPath(dir) {
472
+ const raw = loadTicketSetupConfig(dir);
473
+ const local = localSourcePaths(raw)[0];
474
+ if (local)
475
+ return local;
476
+ const configPath = join(dir, "rafi-config.yaml");
477
+ if (existsSync(configPath)) {
478
+ const parsed = parseYaml(readFileSync(configPath, "utf8"));
479
+ const docs = parsed?.docs;
480
+ if (typeof docs?.root === "string")
481
+ return `${docs.root}/rafi-plan.md`;
482
+ }
483
+ return "docs/rafi-plan.md";
484
+ }
485
+ async function resolveInitAppName(dir, yes) {
486
+ const configured = defaultAppName(dir);
487
+ if (configured !== "My App")
488
+ return configured;
489
+ if (yes || !process.stdin.isTTY || !process.stdout.isTTY)
490
+ return configured;
491
+ const answer = await text({
492
+ message: "App name:",
493
+ initialValue: configured,
494
+ defaultValue: configured,
495
+ });
496
+ if (isCancel(answer))
497
+ process.exit(0);
498
+ return String(answer).trim() || configured;
499
+ }
500
+ async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfig, yes) {
501
+ if (explicitSources?.length)
502
+ return explicitSources;
503
+ const setup = loadTicketSetupConfig(dir);
504
+ const saved = localSourcePaths(setup);
505
+ if (saved.length > 0)
506
+ return saved;
507
+ const defaultPlan = resolvePopulateSources(dir, undefined, ticketsConfig);
508
+ if (defaultPlan?.length) {
509
+ if (yes || !process.stdin.isTTY || !process.stdout.isTTY)
510
+ return defaultPlan;
511
+ const usePlan = await confirm({
512
+ message: `Use ${defaultPlan.join(", ")} as the ticket population source?`,
513
+ initialValue: true,
514
+ });
515
+ if (isCancel(usePlan))
516
+ process.exit(0);
517
+ return usePlan ? defaultPlan : undefined;
518
+ }
519
+ if (yes || !process.stdin.isTTY || !process.stdout.isTTY) {
520
+ console.log("foreman tickets: no ticket sources were found.");
521
+ console.log("foreman tickets: options:");
522
+ console.log(" rafi plan .");
523
+ console.log(" rafi tickets setup:init");
524
+ console.log(" rafi tickets populate --sources <files-or-globs>");
525
+ console.log(" edit .tickets/tickets.yaml manually");
526
+ process.exit(2);
527
+ }
528
+ const action = await select({
529
+ message: "No ticket sources were found. What should happen next?",
530
+ options: [
531
+ { value: "plan", label: "Run planner first - rafi plan ." },
532
+ { value: "setup", label: "Configure ticket setup" },
533
+ { value: "manual", label: "Enter source paths now" },
534
+ { value: "none", label: "Do nothing" },
535
+ ],
536
+ });
537
+ if (isCancel(action) || action === "none") {
538
+ console.log("foreman tickets: cancelled");
539
+ process.exit(0);
540
+ }
541
+ if (action === "setup") {
542
+ await cmdSetupInitCli({ project: dir });
543
+ process.exit(0);
544
+ }
545
+ if (action === "plan") {
546
+ console.log(`foreman tickets: run \`rafi plan ${shellQuote(dir)}\`, then \`rafi tickets populate --project ${shellQuote(dir)}\`.`);
547
+ process.exit(0);
548
+ }
549
+ const entered = await text({
550
+ message: "Source paths or globs, comma-separated:",
551
+ validate: (value) => String(value ?? "").trim() ? undefined : "Enter at least one source path or glob",
552
+ });
553
+ if (isCancel(entered))
554
+ process.exit(0);
555
+ return splitCommaList(String(entered));
556
+ }
557
+ function reviewActionFromOptions(opts) {
558
+ const selected = [
559
+ opts.accept ? "accept" : null,
560
+ opts.dismiss ? "dismiss" : null,
561
+ opts.defer ? "defer" : null,
562
+ opts.acceptAll ? "acceptAll" : null,
563
+ opts.dismissAll ? "dismissAll" : null,
564
+ opts.deferAll ? "deferAll" : null,
565
+ ].filter(Boolean);
566
+ if (selected.length === 0)
567
+ return undefined;
568
+ if (selected.length > 1)
569
+ fail("choose only one review action");
570
+ const raw = selected[0];
571
+ const all = raw.endsWith("All");
572
+ const action = raw.replace(/All$/, "");
573
+ if (!all && opts.id === undefined)
574
+ fail("--id <n> is required unless using an all action");
575
+ const id = opts.id !== undefined ? Number(opts.id) : undefined;
576
+ if (id !== undefined && (!Number.isInteger(id) || id < 1))
577
+ fail("--id must be a positive integer");
578
+ return { action, all, id };
579
+ }
580
+ export async function cmdPopulateCli(opts) {
581
+ const dir = cwd(opts);
582
+ validateEffort(opts.effort);
583
+ if (!existsSync(dir))
584
+ fail(`project directory not found: ${dir}`);
585
+ if (!isTicketsInitialized(dir)) {
586
+ fail(`ticket tracker is not initialized in ${dir}; run \`foreman tickets init --project ${dir}\` first`);
587
+ }
588
+ const ticketsConfig = loadTicketsConfig(dir);
589
+ const setup = loadTicketSetupConfig(dir);
590
+ const explicitSources = opts.sources;
591
+ const configuredExternalSources = explicitSources?.length ? [] : externalSources(setup);
592
+ const savedLocalSources = explicitSources?.length ? [] : localSourcePaths(setup);
593
+ const sourceHints = configuredExternalSources.length > 0 && savedLocalSources.length === 0
594
+ ? undefined
595
+ : await resolvePopulateSourceSelection(dir, explicitSources, ticketsConfig, Boolean(opts.yes));
596
+ const populateAgent = opts.agent
597
+ ?? (setup?.populate.agent_preference && setup.populate.agent_preference !== "configured"
598
+ ? setup.populate.agent_preference
599
+ : undefined);
600
+ if (configuredExternalSources.length > 0) {
601
+ if (!opts.yes) {
602
+ const action = await select({
603
+ message: `Import ${configuredExternalSources.length} configured external ticket source(s)?`,
604
+ options: [
605
+ { value: "proceed", label: "Proceed - fetch external tickets and update .tickets" },
606
+ { value: "cancel", label: "Cancel" },
607
+ ],
608
+ });
609
+ if (isCancel(action) || action === "cancel") {
610
+ console.log("foreman tickets: cancelled");
611
+ process.exit(0);
612
+ }
613
+ }
614
+ const results = await importExternalSources(dir, configuredExternalSources, {
615
+ importCap: setup?.populate.import_cap ?? DEFAULT_TICKET_SETUP.populate.import_cap,
616
+ commentLimit: setup?.populate.comment_limit ?? DEFAULT_TICKET_SETUP.populate.comment_limit,
617
+ recommendSplitForXl: setup?.populate.recommend_split_for_xl ?? DEFAULT_TICKET_SETUP.populate.recommend_split_for_xl,
618
+ });
619
+ for (const result of results) {
620
+ console.log(`foreman tickets: imported ${result.fetched} ${result.provider} item(s) from ${result.sourceLabel} (${result.created} created, ${result.updated} updated)`);
621
+ console.log(`foreman tickets: snapshot ${result.snapshotPath}`);
622
+ }
623
+ cmdRender(dir);
624
+ const validation = cmdValidate(dir);
625
+ if (validation.issues.length > 0) {
626
+ console.log(`foreman tickets: ${validation.issues.length} validation issue(s) found:`);
627
+ console.log(formatValidationIssues(validation.issues));
628
+ if (!validation.clean)
629
+ process.exit(1);
630
+ }
631
+ if (!sourceHints?.length) {
632
+ console.log(`foreman tickets: imported external tickets and rendered ${ticketsConfig.paths.progressDoc}`);
633
+ return;
634
+ }
635
+ }
636
+ if (!opts.yes) {
637
+ const action = await select({
638
+ message: "Populate .tickets/tickets.yaml by letting the builder edit this project?",
639
+ options: [
640
+ { value: "proceed", label: "Proceed - builder may edit ticket files" },
641
+ { value: "cancel", label: "Cancel" },
642
+ ],
643
+ });
644
+ if (isCancel(action) || action === "cancel") {
645
+ console.log("foreman tickets: cancelled");
646
+ process.exit(0);
647
+ }
648
+ }
649
+ const logPath = makeLogPath(dir, "tickets-populate");
650
+ const log = new Log(logPath);
651
+ const config = loadConfig(join(dir, "foreman.yaml"));
652
+ let builder;
653
+ let viewer;
654
+ try {
655
+ const roleBuilder = await createRoleBuilder(buildPopulateAgentRunOptions({
656
+ projectDir: dir,
657
+ agent: populateAgent,
658
+ model: opts.model,
659
+ effort: opts.effort,
660
+ fast: opts.fast,
661
+ yes: Boolean(opts.yes),
662
+ log,
663
+ }));
664
+ builder = roleBuilder.builder;
665
+ viewer = printEvents(builder.events());
666
+ const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, dir);
667
+ console.log(`foreman tickets: populating tickets with ${roleBuilder.runtime}`);
668
+ console.log(`foreman tickets: project ${dir}`);
669
+ console.log(`foreman tickets: role ${TICKET_POPULATE_ROLE}`);
670
+ console.log(`foreman tickets: log ${logPath}\n`);
671
+ if (sourceHints?.length) {
672
+ console.log(`foreman tickets: source hints ${sourceHints.join(" ")}`);
673
+ }
674
+ const turn = await foreman.runInstruction(buildPopulateInstruction(sourceHints, ticketsConfig.paths.progressDoc));
675
+ await builder.close();
676
+ await viewer;
677
+ log.write("ticket-populate", {
678
+ statusKind: turn.status.kind,
679
+ summary: turn.status.summary,
680
+ reason: turn.status.reason,
681
+ costUsd: turn.result.costUsd,
682
+ isError: turn.result.isError,
683
+ });
684
+ if (turn.result.isError) {
685
+ fail(`builder turn errored: ${turn.result.text.slice(0, 200)}`);
686
+ }
687
+ if (turn.status.kind === "blocked") {
688
+ console.error(`foreman tickets: blocked — ${turn.status.reason ?? "builder reported blocked"}`);
689
+ process.exit(2);
690
+ }
691
+ if (turn.status.kind !== "done" && turn.status.kind !== "plan_complete") {
692
+ console.error(`foreman tickets: needs human — ${turn.status.error ?? "builder did not emit done"}`);
693
+ process.exit(2);
694
+ }
695
+ cmdRender(dir);
696
+ const validation = cmdValidate(dir);
697
+ if (validation.issues.length > 0) {
698
+ console.log(`foreman tickets: ${validation.issues.length} validation issue(s) found:`);
699
+ console.log(formatValidationIssues(validation.issues));
700
+ if (!validation.clean)
701
+ process.exit(1);
702
+ }
703
+ else {
704
+ console.log("foreman tickets: validation passed — all 4 passes clean");
705
+ }
706
+ console.log(`foreman tickets: populated .tickets/tickets.yaml and rendered ${ticketsConfig.paths.progressDoc}`);
707
+ }
708
+ catch (err) {
709
+ await builder?.close().catch(() => { });
710
+ await viewer?.catch(() => { });
711
+ fail(String(err instanceof Error ? err.message : err));
712
+ }
713
+ }
128
714
  export function buildTicketsCommand() {
129
715
  const tickets = new Command("tickets").description("Manage the structured ticket tracker for a project.");
716
+ // ── setup:init / setup:update ─────────────────────────────────────────────
717
+ tickets
718
+ .command("setup:init")
719
+ .description("Configure ticket sources, populate defaults, and build defaults in rafi-config.yaml.")
720
+ .option("-p, --project <dir>", "project directory (default: cwd)")
721
+ .option("--defaults", "skip prompts and use recommended ticket setup defaults")
722
+ .option("-y, --yes", "skip prompts where possible")
723
+ .option("--app-name <name>", "application name for a new minimal rafi-config.yaml")
724
+ .option("--docs-root <dir>", "repo-relative docs root for a new minimal rafi-config.yaml and ticket docs")
725
+ .option("--runtime <runtime>", "runtime targets for a new minimal rafi-config.yaml (both | claude | codex)")
726
+ .option("--local-source <paths...>", "saved local ticket source files, folders, or globs")
727
+ .option("--linear", "add a Linear source using LINEAR_API_KEY")
728
+ .option("--linear-team-key <key>", "Linear team key filter")
729
+ .option("--linear-filter <filter>", "Linear IssueFilter JSON or title search text")
730
+ .option("--jira-site <url>", "Jira Cloud site URL")
731
+ .option("--jira-jql <jql>", "Jira JQL query")
732
+ .option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
733
+ .option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
734
+ .option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
735
+ .option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
736
+ .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 Linear/Jira access during setup")
738
+ .action(async (opts) => {
739
+ try {
740
+ await cmdSetupInitCli(opts);
741
+ }
742
+ catch (err) {
743
+ fail(String(err instanceof Error ? err.message : err));
744
+ }
745
+ });
746
+ tickets
747
+ .command("setup:update")
748
+ .description("Update selected ticket setup sections in rafi-config.yaml.")
749
+ .option("-p, --project <dir>", "project directory (default: cwd)")
750
+ .option("--defaults", "skip prompts and keep existing values unless explicit options are provided")
751
+ .option("-y, --yes", "skip prompts where possible")
752
+ .option("--app-name <name>", "application name for a new minimal rafi-config.yaml")
753
+ .option("--docs-root <dir>", "repo-relative docs root for a new minimal rafi-config.yaml and ticket docs")
754
+ .option("--runtime <runtime>", "runtime targets for a new minimal rafi-config.yaml (both | claude | codex)")
755
+ .option("--local-source <paths...>", "replace saved local ticket source files, folders, or globs")
756
+ .option("--linear", "replace saved sources with a Linear source using LINEAR_API_KEY")
757
+ .option("--linear-team-key <key>", "Linear team key filter")
758
+ .option("--linear-filter <filter>", "Linear IssueFilter JSON or title search text")
759
+ .option("--jira-site <url>", "Jira Cloud site URL")
760
+ .option("--jira-jql <jql>", "Jira JQL query")
761
+ .option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
762
+ .option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
763
+ .option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
764
+ .option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
765
+ .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 Linear/Jira access during setup")
767
+ .action(async (opts) => {
768
+ try {
769
+ await cmdSetupUpdateCli(opts);
770
+ }
771
+ catch (err) {
772
+ fail(String(err instanceof Error ? err.message : err));
773
+ }
774
+ });
130
775
  // ── init ────────────────────────────────────────────────────────────────────
131
776
  tickets
132
777
  .command("init")
@@ -134,15 +779,26 @@ export function buildTicketsCommand() {
134
779
  .option("-p, --project <dir>", "project directory (default: cwd)")
135
780
  .option("--app-name <name>", "application name")
136
781
  .option("--timezone <tz>", "IANA timezone (e.g. America/Chicago)", "UTC")
137
- .option("--queue-limit <n>", "next-queue window size", "50")
782
+ .option("--implementation-limit <n>", "implementation queue window size", String(DEFAULT_TICKETS_CONFIG.implementationLimit))
783
+ .option("--view-limit <n>", "ticket queue display limit", String(DEFAULT_TICKETS_CONFIG.viewLimit))
784
+ .option("--queue-limit <n>", "deprecated alias for --implementation-limit")
138
785
  .option("--docs-root <dir>", "repo-relative directory for generated ticket docs")
139
- .action((opts) => {
786
+ .option("-y, --yes", "skip app-name prompt when no config/package default exists")
787
+ .action(async (opts, command) => {
140
788
  const dir = cwd(opts);
141
789
  try {
790
+ const hasImplementationLimit = optionWasProvided(command, "implementationLimit");
791
+ const hasLegacyQueueLimit = optionWasProvided(command, "queueLimit");
792
+ if (hasImplementationLimit && hasLegacyQueueLimit) {
793
+ fail("--implementation-limit and deprecated --queue-limit cannot both be passed");
794
+ }
795
+ const appName = opts.appName ?? await resolveInitAppName(dir, Boolean(opts.yes));
142
796
  cmdInit(dir, {
143
- appName: opts.appName,
797
+ appName,
144
798
  timezone: opts.timezone,
145
- queueLimit: Number(opts.queueLimit),
799
+ implementationLimit: hasImplementationLimit ? Number(opts.implementationLimit) : undefined,
800
+ viewLimit: optionWasProvided(command, "viewLimit") ? Number(opts.viewLimit) : undefined,
801
+ queueLimit: hasLegacyQueueLimit ? Number(opts.queueLimit) : undefined,
146
802
  docsRoot: opts.docsRoot,
147
803
  });
148
804
  console.log(`foreman tickets: initialized .tickets/ in ${dir}`);
@@ -164,90 +820,10 @@ export function buildTicketsCommand() {
164
820
  .option("--fast", "fast mode - lower latency")
165
821
  .option("-y, --yes", "skip confirmation prompt before letting the builder edit tickets")
166
822
  .action(async (opts) => {
167
- const dir = cwd(opts);
168
- validateEffort(opts.effort);
169
- if (!existsSync(dir))
170
- fail(`project directory not found: ${dir}`);
171
- if (!isTicketsInitialized(dir)) {
172
- fail(`ticket tracker is not initialized in ${dir}; run \`foreman tickets init --project ${dir}\` first`);
173
- }
174
- if (!opts.yes) {
175
- const action = await select({
176
- message: "Populate .tickets/tickets.yaml by letting the builder edit this project?",
177
- options: [
178
- { value: "proceed", label: "Proceed - builder may edit ticket files" },
179
- { value: "cancel", label: "Cancel" },
180
- ],
181
- });
182
- if (isCancel(action) || action === "cancel") {
183
- console.log("foreman tickets: cancelled");
184
- process.exit(0);
185
- }
186
- }
187
- const logPath = makeLogPath(dir, "tickets-populate");
188
- const log = new Log(logPath);
189
- const config = loadConfig(join(dir, "foreman.yaml"));
190
- let builder;
191
- let viewer;
192
823
  try {
193
- const roleBuilder = await createRoleBuilder(buildPopulateAgentRunOptions({
194
- projectDir: dir,
195
- agent: opts.agent,
196
- model: opts.model,
197
- effort: opts.effort,
198
- fast: opts.fast,
199
- yes: Boolean(opts.yes),
200
- log,
201
- }));
202
- builder = roleBuilder.builder;
203
- viewer = printEvents(builder.events());
204
- const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, dir);
205
- console.log(`foreman tickets: populating tickets with ${roleBuilder.runtime}`);
206
- console.log(`foreman tickets: project ${dir}`);
207
- console.log(`foreman tickets: role ${TICKET_POPULATE_ROLE}`);
208
- console.log(`foreman tickets: log ${logPath}\n`);
209
- const ticketsConfig = loadTicketsConfig(dir);
210
- const sourceHints = resolvePopulateSources(dir, opts.sources, ticketsConfig);
211
- if (sourceHints?.length) {
212
- console.log(`foreman tickets: source hints ${sourceHints.join(" ")}`);
213
- }
214
- const turn = await foreman.runInstruction(buildPopulateInstruction(sourceHints, ticketsConfig.paths.progressDoc));
215
- await builder.close();
216
- await viewer;
217
- log.write("ticket-populate", {
218
- statusKind: turn.status.kind,
219
- summary: turn.status.summary,
220
- reason: turn.status.reason,
221
- costUsd: turn.result.costUsd,
222
- isError: turn.result.isError,
223
- });
224
- if (turn.result.isError) {
225
- fail(`builder turn errored: ${turn.result.text.slice(0, 200)}`);
226
- }
227
- if (turn.status.kind === "blocked") {
228
- console.error(`foreman tickets: blocked — ${turn.status.reason ?? "builder reported blocked"}`);
229
- process.exit(2);
230
- }
231
- if (turn.status.kind !== "done" && turn.status.kind !== "plan_complete") {
232
- console.error(`foreman tickets: needs human — ${turn.status.error ?? "builder did not emit done"}`);
233
- process.exit(2);
234
- }
235
- cmdRender(dir);
236
- const validation = cmdValidate(dir);
237
- if (validation.issues.length > 0) {
238
- console.log(`foreman tickets: ${validation.issues.length} validation issue(s) found:`);
239
- console.log(formatValidationIssues(validation.issues));
240
- if (!validation.clean)
241
- process.exit(1);
242
- }
243
- else {
244
- console.log("foreman tickets: validation passed — all 4 passes clean");
245
- }
246
- console.log(`foreman tickets: populated .tickets/tickets.yaml and rendered ${ticketsConfig.paths.progressDoc}`);
824
+ await cmdPopulateCli(opts);
247
825
  }
248
826
  catch (err) {
249
- await builder?.close().catch(() => { });
250
- await viewer?.catch(() => { });
251
827
  fail(String(err instanceof Error ? err.message : err));
252
828
  }
253
829
  });
@@ -449,6 +1025,74 @@ export function buildTicketsCommand() {
449
1025
  fail(String(err instanceof Error ? err.message : err));
450
1026
  }
451
1027
  });
1028
+ // ── review recommendations ────────────────────────────────────────────────
1029
+ tickets
1030
+ .command("review")
1031
+ .description("Review pending split/combine/duplicate ticket recommendations.")
1032
+ .option("-p, --project <dir>", "project directory (default: cwd)")
1033
+ .option("--id <n>", "recommendation id to review")
1034
+ .option("--accept", "accept the selected recommendation")
1035
+ .option("--dismiss", "dismiss the selected recommendation")
1036
+ .option("--defer", "defer the selected recommendation")
1037
+ .option("--accept-all", "accept every pending recommendation")
1038
+ .option("--dismiss-all", "dismiss every pending recommendation")
1039
+ .option("--defer-all", "defer every pending recommendation")
1040
+ .action(async (opts) => {
1041
+ try {
1042
+ const dir = cwd(opts);
1043
+ const action = reviewActionFromOptions(opts);
1044
+ if (action) {
1045
+ const result = cmdReview(dir, {
1046
+ action: action.action,
1047
+ all: action.all,
1048
+ ids: action.id !== undefined ? [action.id] : undefined,
1049
+ });
1050
+ console.log(`foreman tickets: ${action.action}ed ${result.changed} recommendation(s)`);
1051
+ if (result.pending.length > 0)
1052
+ console.log(`foreman tickets: ${result.pending.length} pending recommendation(s) remain`);
1053
+ return;
1054
+ }
1055
+ const pending = cmdReview(dir).pending;
1056
+ if (pending.length === 0) {
1057
+ console.log("foreman tickets: no pending review recommendations");
1058
+ return;
1059
+ }
1060
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1061
+ for (const rec of pending) {
1062
+ console.log(`#${rec.id} [${rec.kind}] ${rec.summary}`);
1063
+ }
1064
+ console.log("foreman tickets: use --accept-all, --dismiss-all, --defer-all, or --id <n> --accept|--dismiss|--defer");
1065
+ return;
1066
+ }
1067
+ const picked = await select({
1068
+ message: "Pending recommendation:",
1069
+ options: pending.map((rec) => ({
1070
+ value: String(rec.id),
1071
+ label: `#${rec.id} [${rec.kind}] ${rec.summary}`,
1072
+ })),
1073
+ });
1074
+ if (isCancel(picked))
1075
+ process.exit(0);
1076
+ const disposition = await select({
1077
+ message: "Apply recommendation?",
1078
+ options: [
1079
+ { value: "accept", label: "Accept and apply patch" },
1080
+ { value: "defer", label: "Defer" },
1081
+ { value: "dismiss", label: "Dismiss" },
1082
+ ],
1083
+ });
1084
+ if (isCancel(disposition))
1085
+ process.exit(0);
1086
+ const result = cmdReview(dir, {
1087
+ action: disposition,
1088
+ ids: [Number(picked)],
1089
+ });
1090
+ console.log(`foreman tickets: ${disposition}ed ${result.changed} recommendation(s)`);
1091
+ }
1092
+ catch (err) {
1093
+ fail(String(err instanceof Error ? err.message : err));
1094
+ }
1095
+ });
452
1096
  // ── render ───────────────────────────────────────────────────────────────────
453
1097
  tickets
454
1098
  .command("render")
@@ -489,12 +1133,12 @@ export function buildTicketsCommand() {
489
1133
  // ── queue ─────────────────────────────────────────────────────────────────────
490
1134
  tickets
491
1135
  .command("queue")
492
- .description("Print the next-N queue to stdout.")
1136
+ .description("Print the ticket queue to stdout.")
493
1137
  .option("-p, --project <dir>", "project directory (default: cwd)")
494
- .option("--limit <n>", "override queue limit")
1138
+ .option("--limit <n>", "override view limit")
495
1139
  .action((opts) => {
496
1140
  try {
497
- const rows = cmdQueue(cwd(opts), opts.limit ? Number(opts.limit) : undefined);
1141
+ const rows = cmdQueue(cwd(opts), opts.limit !== undefined ? Number(opts.limit) : undefined);
498
1142
  if (rows.length === 0) {
499
1143
  console.log("No remaining tickets.");
500
1144
  }