ai-foreman 1.7.4 → 1.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +6 -1
  2. package/dist/activity.d.ts +63 -0
  3. package/dist/activity.js +244 -0
  4. package/dist/adapters/claude.d.ts +7 -0
  5. package/dist/adapters/claude.js +41 -1
  6. package/dist/adapters/codex.d.ts +1 -0
  7. package/dist/adapters/codex.js +65 -5
  8. package/dist/adapters/recovering.js +7 -1
  9. package/dist/adapters/types.d.ts +15 -0
  10. package/dist/agentRun.d.ts +5 -0
  11. package/dist/agentRun.js +5 -1
  12. package/dist/branch/github.js +5 -3
  13. package/dist/branch/gitlab.js +5 -3
  14. package/dist/branch/planner.d.ts +5 -0
  15. package/dist/branch/planner.js +42 -3
  16. package/dist/branch/presentation.d.ts +17 -0
  17. package/dist/branch/presentation.js +38 -0
  18. package/dist/branch/resume.d.ts +2 -0
  19. package/dist/branch/resume.js +2 -0
  20. package/dist/branch/runner.js +44 -25
  21. package/dist/buildRuns.d.ts +41 -17
  22. package/dist/buildRuns.js +243 -22
  23. package/dist/cli/events.js +24 -5
  24. package/dist/cli/runtimeAuthPrompt.js +5 -1
  25. package/dist/cli/start.js +138 -45
  26. package/dist/cli/tickets.d.ts +10 -1
  27. package/dist/cli/tickets.js +418 -79
  28. package/dist/foreman.d.ts +8 -5
  29. package/dist/foreman.js +38 -14
  30. package/dist/index.js +3 -1
  31. package/dist/providerQuestions.d.ts +23 -0
  32. package/dist/providerQuestions.js +24 -0
  33. package/dist/qaReview.d.ts +1 -0
  34. package/dist/qaReview.js +23 -15
  35. package/dist/qaSnapshot.d.ts +14 -0
  36. package/dist/qaSnapshot.js +117 -1
  37. package/dist/runtimeReadiness.js +5 -0
  38. package/dist/sources/sourceRegistry.js +13 -6
  39. package/dist/tickets/details.d.ts +17 -0
  40. package/dist/tickets/details.js +94 -0
  41. package/dist/tickets/eligibility.d.ts +28 -0
  42. package/dist/tickets/eligibility.js +71 -0
  43. package/dist/tickets/importer.js +13 -8
  44. package/dist/tickets/reset.d.ts +27 -0
  45. package/dist/tickets/reset.js +213 -0
  46. package/dist/tickets/reviewStandards.d.ts +7 -0
  47. package/dist/tickets/reviewStandards.js +53 -0
  48. package/dist/tickets/setupConfig.d.ts +22 -0
  49. package/dist/tickets/setupConfig.js +105 -1
  50. package/dist/tickets/sourceFetch.js +19 -0
  51. package/dist/tickets/stateDb.d.ts +8 -1
  52. package/dist/tickets/stateDb.js +54 -23
  53. package/dist/workflowDb.d.ts +3 -0
  54. package/dist/workflowDb.js +26 -0
  55. package/package.json +15 -3
@@ -2,7 +2,7 @@ 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, text, confirm, isCancel } from "@clack/prompts";
5
+ import { select, text, confirm, isCancel, multiselect } from "@clack/prompts";
6
6
  import { loadConfig } from "../config.js";
7
7
  import { Log } from "../log.js";
8
8
  import { Foreman } from "../foreman.js";
@@ -14,16 +14,33 @@ import { validateDocsRoot } from "../tickets/config.js";
14
14
  import { importExternalSources, importFromMarkdown, validateExternalSourceAccess } from "../tickets/importer.js";
15
15
  import { formatValidationIssues } from "../tickets/validate.js";
16
16
  import { assertEffortLevel, createRoleBuilder, makeLogPath, readOnlyPermissionConfig, } from "../agentRun.js";
17
- import { DEFAULT_TICKET_SETUP, configuredPlanningSources, defaultAppName, ensureRafiConfigForTicketSetup, externalSources, hasTicketSetupConfig, loadTicketSetupConfig, loadTicketSetupConfigWithDefaults, localSourcePaths, mergeTicketSetup, recommendedBuildDefaults, saveTicketSetupConfig, urlSources, } from "../tickets/setupConfig.js";
17
+ import { DEFAULT_TICKET_SETUP, configuredPlanningSources, configuredAppName, defaultAppName, detectDefaultBranch, detectPackageName, ensureRafiConfigForTicketSetup, externalSources, hasTicketSetupConfig, loadTicketSetupConfig, loadTicketSetupConfigWithDefaults, localSourcePaths, mergeTicketSetup, recommendedBuildDefaults, saveTicketSetupConfig, syncTicketLimits, urlSources, } from "../tickets/setupConfig.js";
18
18
  import { fetchAndSnapshotUrl, snapshotExternalLocalFile } from "../tickets/sourceFetch.js";
19
19
  import { checkpointInterview, completeInterview, createInterviewRecord, failInterview, } from "../interviews.js";
20
20
  import { applyTicketPopulation, authorizeTicketRetirements, extractTicketPopulationProposal, materializeTicketPopulation, TICKET_POPULATION_PROPOSAL_END, TICKET_POPULATION_PROPOSAL_START } from "../ticketPopulation.js";
21
21
  import { loadTickets } from "../tickets/ticketLoader.js";
22
22
  import { resolveTicketPaths } from "../tickets/config.js";
23
+ import { formatTicketDetails, getTicketDetails } from "../tickets/details.js";
24
+ import { previewTicketReset, recoverTicketResetPublications, resetTickets } from "../tickets/reset.js";
25
+ let nestedTicketCommandDepth = 0;
26
+ class TicketInterviewCancelled extends Error {
27
+ }
28
+ function cancelTicketInterview() { throw new TicketInterviewCancelled("ticket interview cancelled"); }
23
29
  function fail(msg) {
30
+ if (nestedTicketCommandDepth > 0)
31
+ throw new Error(msg);
24
32
  console.error(`foreman tickets: ${msg}`);
25
33
  process.exit(1);
26
34
  }
35
+ async function runNestedTicketCommand(action) {
36
+ nestedTicketCommandDepth += 1;
37
+ try {
38
+ return await action();
39
+ }
40
+ finally {
41
+ nestedTicketCommandDepth -= 1;
42
+ }
43
+ }
27
44
  function cwd(opts) {
28
45
  return resolve(opts.project ?? ".");
29
46
  }
@@ -148,6 +165,8 @@ async function cmdSetupInitCli(opts) {
148
165
  if (hasTicketSetupConfig(dir)) {
149
166
  console.log("foreman tickets setup: existing setup found; opening setup:update");
150
167
  await cmdSetupUpdateCli(opts);
168
+ const existing = loadTicketSetupConfigWithDefaults(dir);
169
+ await continueTrackerSetup(dir, opts, existing);
151
170
  if (interview)
152
171
  completeInterview(dir, interview);
153
172
  return;
@@ -168,41 +187,74 @@ async function cmdSetupInitCli(opts) {
168
187
  targets: parseRuntimeTargets(opts.runtime),
169
188
  });
170
189
  console.log(`foreman tickets setup: saved ticket setup in ${join(dir, "rafi-config.yaml")}`);
171
- if (!isTicketsInitialized(dir)) {
172
- if (interview)
173
- interview = checkpointInterview(dir, interview, { checkpoint: "initialize-tracker" });
174
- cmdInit(dir, {
175
- appName: opts.appName ?? defaultAppName(dir),
176
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
177
- docsRoot: opts.docsRoot,
178
- });
179
- console.log("foreman tickets setup: initialized .tickets/");
180
- }
181
- if (shouldPrompt(opts)) {
182
- if (interview)
183
- interview = checkpointInterview(dir, interview, { checkpoint: "populate-confirmation" });
184
- const populate = await confirm({
185
- message: "Run ticket population now?",
186
- initialValue: answers.sources.length > 0,
187
- });
188
- if (isCancel(populate))
189
- return;
190
- if (populate) {
191
- await cmdPopulateCli({ project: dir, yes: true });
192
- }
193
- }
194
- else {
195
- console.log(`foreman tickets setup: next — run \`foreman tickets populate --project ${shellQuote(dir)}\``);
196
- }
190
+ if (interview)
191
+ interview = checkpointInterview(dir, interview, { checkpoint: "initialize-tracker" });
192
+ await continueTrackerSetup(dir, opts, answers);
197
193
  if (interview)
198
194
  completeInterview(dir, interview);
199
195
  }
200
196
  catch (error) {
201
- if (interview)
197
+ if (interview && !(error instanceof TicketInterviewCancelled))
202
198
  failInterview(dir, interview, interview.checkpoint, error);
203
199
  throw error;
204
200
  }
205
201
  }
202
+ async function continueTrackerSetup(dir, opts, setup) {
203
+ if (!isTicketsInitialized(dir)) {
204
+ let initialize = true;
205
+ if (shouldPrompt(opts)) {
206
+ const answer = await confirm({ message: "Initialize the ticket tracker now?", initialValue: true });
207
+ if (isCancel(answer))
208
+ return;
209
+ initialize = Boolean(answer);
210
+ }
211
+ if (!initialize) {
212
+ console.log("foreman tickets setup: preferences were saved. Ticket infrastructure is still required before building.");
213
+ console.log(` rafi tickets init --project ${shellQuote(dir)}`);
214
+ console.log(` rafi tickets populate --project ${shellQuote(dir)}`);
215
+ return;
216
+ }
217
+ const initialized = await retrySetupStage("ticket initialization", async () => {
218
+ const appName = opts.appName ?? await resolveInitAppName(dir, Boolean(opts.yes || opts.defaults));
219
+ cmdInit(dir, { appName, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, docsRoot: opts.docsRoot, implementationLimit: setup.limits.implementation, viewLimit: setup.limits.view });
220
+ syncTicketLimits(dir, setup.limits);
221
+ }, shouldPrompt(opts));
222
+ if (!initialized)
223
+ return;
224
+ console.log("foreman tickets setup: initialized .tickets/");
225
+ }
226
+ if (shouldPrompt(opts)) {
227
+ const populate = await confirm({ message: "Populate tickets now?", initialValue: true });
228
+ if (isCancel(populate))
229
+ return;
230
+ if (populate)
231
+ await retrySetupStage("ticket population", () => runNestedTicketCommand(() => cmdPopulateCli({ project: dir, yes: true })), true);
232
+ else {
233
+ console.log("foreman tickets setup: population is the final prerequisite before building.");
234
+ console.log(`foreman tickets setup: run \`rafi tickets populate --project ${shellQuote(dir)}\` later.`);
235
+ }
236
+ }
237
+ else {
238
+ console.log(`foreman tickets setup: next — run \`rafi tickets populate --project ${shellQuote(dir)}\``);
239
+ }
240
+ }
241
+ async function retrySetupStage(label, action, interactive) {
242
+ while (true) {
243
+ try {
244
+ await action();
245
+ return true;
246
+ }
247
+ catch (error) {
248
+ if (!interactive)
249
+ throw error;
250
+ console.error(`foreman tickets setup: ${label} failed: ${error instanceof Error ? error.message : String(error)}`);
251
+ console.error("Fix the reported problem, then press Enter to retry; enter q to leave setup with saved progress.");
252
+ const answer = await text({ message: `Retry ${label}?`, defaultValue: "" });
253
+ if (isCancel(answer) || String(answer).trim().toLowerCase() === "q")
254
+ return false;
255
+ }
256
+ }
257
+ }
206
258
  async function cmdSetupUpdateCli(opts) {
207
259
  const dir = cwd(opts);
208
260
  if (!existsSync(dir))
@@ -225,6 +277,7 @@ async function cmdSetupUpdateCli(opts) {
225
277
  docsRoot: opts.docsRoot,
226
278
  targets: parseRuntimeTargets(opts.runtime),
227
279
  });
280
+ syncTicketLimits(dir, next.limits);
228
281
  console.log(`foreman tickets setup: updated ticket setup in ${join(dir, "rafi-config.yaml")}`);
229
282
  if (isTicketsInitialized(dir)) {
230
283
  const rows = cmdQueue(dir, 1);
@@ -236,7 +289,7 @@ async function cmdSetupUpdateCli(opts) {
236
289
  completeInterview(dir, interview);
237
290
  }
238
291
  catch (error) {
239
- if (interview)
292
+ if (interview && !(error instanceof TicketInterviewCancelled))
240
293
  failInterview(dir, interview, interview.checkpoint, error);
241
294
  throw error;
242
295
  }
@@ -302,38 +355,107 @@ async function collectTicketSetupPatch(dir, opts, current) {
302
355
  { value: "sources", label: "Ticket sources" },
303
356
  { value: "populate", label: "Populate defaults" },
304
357
  { value: "build", label: "Build defaults" },
358
+ { value: "limits", label: "Tracker limits" },
305
359
  { value: "all", label: "All sections" },
306
360
  ],
307
361
  });
308
362
  if (isCancel(section))
309
- process.exit(0);
363
+ cancelTicketInterview();
310
364
  // Do not silently persist planning hints over a completed ticket setup. They
311
365
  // are only the initial local-source suggestion for a fresh setup.
312
366
  const planningPrefill = current.sources.length === 0
313
367
  ? configuredPlanningSources(dir).map((path) => ({ type: "local", paths: [path] }))
314
368
  : current.sources;
315
369
  let sources = nonInteractiveSources.length > 0 ? nonInteractiveSources : planningPrefill;
370
+ let limits = current.limits;
316
371
  if (section === "sources" || section === "all") {
317
372
  sources = await promptTicketSources(dir, current.sources);
318
373
  }
374
+ if (section === "limits" || section === "all") {
375
+ console.log("The defaults are a 500-ticket implementation window and a 20,000-ticket view limit. After initialization these live in .tickets/config.yaml.");
376
+ const keep = await confirm({ message: "Keep both default limits?", initialValue: true });
377
+ if (isCancel(keep))
378
+ cancelTicketInterview();
379
+ limits = keep ? { implementation: 500, view: 20_000 } : {
380
+ implementation: await promptPositiveInteger("Implementation window limit:", current.limits.implementation),
381
+ view: await promptPositiveInteger("View limit:", current.limits.view),
382
+ };
383
+ }
319
384
  if (section === "populate" || section === "all") {
320
- const agent = await select({
321
- message: "When both runtimes are configured, which should populate use?",
322
- initialValue: current.populate.agent_preference,
323
- options: [
324
- { value: "configured", label: "Configured project default" },
325
- { value: "claude", label: "Claude" },
326
- { value: "codex", label: "Codex" },
327
- ],
328
- });
329
- if (isCancel(agent))
330
- process.exit(0);
331
- populatePatch.agent_preference = agent;
385
+ const defaults = await confirm({ message: "Use the recommended population settings?", initialValue: true });
386
+ if (isCancel(defaults))
387
+ cancelTicketInterview();
388
+ if (defaults)
389
+ Object.assign(populatePatch, DEFAULT_TICKET_SETUP.populate);
390
+ else {
391
+ const handling = await select({ message: "How should saved sources be handled?", initialValue: current.populate.source_handling, options: [
392
+ { value: "saved", label: "Use saved sources" }, { value: "prompt", label: "Ask each time" }, { value: "manual", label: "Manual only" },
393
+ ] });
394
+ if (isCancel(handling))
395
+ cancelTicketInterview();
396
+ populatePatch.source_handling = handling;
397
+ const agent = await select({ message: "When both runtimes are configured, which should populate use?", initialValue: current.populate.agent_preference, options: [
398
+ { value: "configured", label: "Configured project default" }, { value: "claude", label: "Claude" }, { value: "codex", label: "Codex" },
399
+ ] });
400
+ if (isCancel(agent))
401
+ cancelTicketInterview();
402
+ populatePatch.agent_preference = agent;
403
+ populatePatch.import_cap = await promptPositiveInteger("Maximum tickets imported in one population run (separate from the implementation window):", current.populate.import_cap);
404
+ populatePatch.comment_limit = await promptNonNegativeInteger("Maximum comments imported per external ticket:", current.populate.comment_limit);
405
+ const enrichment = await select({ message: "Population enrichment policy:", initialValue: current.populate.enrichment, options: [
406
+ { value: "recommendations", label: "Deterministic recommendations" }, { value: "agent", label: "Agent enrichment" }, { value: "none", label: "No enrichment" },
407
+ ] });
408
+ if (isCancel(enrichment))
409
+ cancelTicketInterview();
410
+ populatePatch.enrichment = enrichment;
411
+ const split = await confirm({ message: "Recommend splitting XL tickets?", initialValue: current.populate.recommend_split_for_xl });
412
+ if (isCancel(split))
413
+ cancelTicketInterview();
414
+ populatePatch.recommend_split_for_xl = Boolean(split);
415
+ }
332
416
  }
333
417
  if (section === "build" || section === "all") {
334
418
  const recommended = recommendedBuildDefaults(dir);
335
- const strategy = await select({
336
- message: "Default ticket work mode:",
419
+ const base = await text({ message: "Base branch:", initialValue: current.build.base_branch || detectDefaultBranch(dir), defaultValue: detectDefaultBranch(dir) });
420
+ if (isCancel(base))
421
+ cancelTicketInterview();
422
+ buildPatch.base_branch = String(base).trim();
423
+ const policyMode = await select({ message: "Use one global branch strategy or choose by inferred ticket size?", initialValue: current.build.branch_policy.mode, options: [
424
+ { value: "size", label: "Size-based: XS/S shared, M/L/XL per ticket (Recommended)" },
425
+ { value: "global", label: "One global strategy" },
426
+ ] });
427
+ if (isCancel(policyMode))
428
+ cancelTicketInterview();
429
+ let sizeMapping = { XS: "shared", S: "shared", M: "per-ticket", L: "per-ticket", XL: "per-ticket" };
430
+ if (policyMode === "size") {
431
+ console.log("Rafi infers size from each canonical ticket. Recommended mapping: XS/S share a compatible run branch; M/L/XL use one branch per ticket. This never creates stack dependencies, and explicit delivery configuration wins.");
432
+ const keepMapping = await confirm({ message: "Keep the recommended size mapping?", initialValue: true });
433
+ if (isCancel(keepMapping))
434
+ cancelTicketInterview();
435
+ if (!keepMapping) {
436
+ sizeMapping = { ...sizeMapping };
437
+ for (const size of ["XS", "S", "M", "L", "XL"]) {
438
+ const allocation = await select({
439
+ message: `${size} ticket branch allocation:`,
440
+ initialValue: current.build.branch_policy.by_size[size],
441
+ options: [
442
+ { value: "shared", label: "Share with compatible tickets selected in this run" },
443
+ { value: "per-ticket", label: "Use a separate branch per ticket" },
444
+ ],
445
+ });
446
+ if (isCancel(allocation))
447
+ cancelTicketInterview();
448
+ sizeMapping[size] = allocation;
449
+ }
450
+ }
451
+ }
452
+ buildPatch.branch_policy = {
453
+ ...current.build.branch_policy,
454
+ mode: policyMode,
455
+ by_size: policyMode === "size" ? sizeMapping : current.build.branch_policy.by_size,
456
+ };
457
+ const strategy = policyMode === "size" ? "branch-per-ticket" : await select({
458
+ message: "Default global ticket work mode:",
337
459
  initialValue: current.build.branch_strategy,
338
460
  options: [
339
461
  { value: "current", label: "One branch - work the queue on the current branch" },
@@ -342,8 +464,9 @@ async function collectTicketSetupPatch(dir, opts, current) {
342
464
  ],
343
465
  });
344
466
  if (isCancel(strategy))
345
- process.exit(0);
467
+ cancelTicketInterview();
346
468
  buildPatch.branch_strategy = strategy;
469
+ buildPatch.branch_policy.global_strategy = buildPatch.branch_strategy;
347
470
  if (strategy === "current") {
348
471
  buildPatch.completion = "none";
349
472
  buildPatch.provider = "local";
@@ -352,8 +475,10 @@ async function collectTicketSetupPatch(dir, opts, current) {
352
475
  buildPatch.cleanup = true;
353
476
  buildPatch.auto_merge_wait = false;
354
477
  buildPatch.auto_merge_timeout_minutes = null;
478
+ buildPatch.validation_checklist = await promptRequirementList("validation checklist", current.build.validation_checklist);
355
479
  return {
356
480
  sources,
481
+ limits,
357
482
  populate: populatePatch,
358
483
  build: buildPatch,
359
484
  };
@@ -369,19 +494,43 @@ async function collectTicketSetupPatch(dir, opts, current) {
369
494
  ],
370
495
  });
371
496
  if (isCancel(completion))
372
- process.exit(0);
497
+ cancelTicketInterview();
373
498
  buildPatch.completion = completion;
374
499
  buildPatch.provider = recommended.provider;
375
500
  buildPatch.pr_ready = completion === "auto-merge";
376
501
  buildPatch.merge_method = "squash";
377
502
  buildPatch.cleanup = true;
503
+ if (completion === "pr" || completion === "auto-merge") {
504
+ const title = await select({ message: "PR/MR title standard:", initialValue: current.build.review.title_style, options: [
505
+ { value: "ticket-title", label: "T123: Add password reset (Recommended)" },
506
+ { value: "conventional", label: "Conventional Commit" },
507
+ { value: "none", label: "No required format" },
508
+ { value: "custom", label: "Custom template" },
509
+ ] });
510
+ if (isCancel(title))
511
+ cancelTicketInterview();
512
+ let template = null;
513
+ if (title === "custom") {
514
+ const answer = await text({ message: "Custom title template (use {id} and {title}):" });
515
+ if (isCancel(answer))
516
+ cancelTicketInterview();
517
+ template = String(answer);
518
+ }
519
+ buildPatch.review = {
520
+ ...current.build.review,
521
+ title_style: title,
522
+ title_template: template,
523
+ description_sections: await promptRequirementList("required PR/MR description sections", current.build.review.description_sections),
524
+ };
525
+ }
526
+ buildPatch.validation_checklist = await promptRequirementList("validation checklist", current.build.validation_checklist);
378
527
  if (completion === "auto-merge") {
379
528
  const wait = await confirm({
380
529
  message: "Wait for dependency PR/MRs to merge before starting dependent tickets?",
381
530
  initialValue: current.build.auto_merge_wait,
382
531
  });
383
532
  if (isCancel(wait))
384
- process.exit(0);
533
+ cancelTicketInterview();
385
534
  buildPatch.auto_merge_wait = Boolean(wait);
386
535
  if (wait) {
387
536
  const timeout = await text({
@@ -397,7 +546,7 @@ async function collectTicketSetupPatch(dir, opts, current) {
397
546
  },
398
547
  });
399
548
  if (isCancel(timeout))
400
- process.exit(0);
549
+ cancelTicketInterview();
401
550
  buildPatch.auto_merge_timeout_minutes = String(timeout).trim()
402
551
  ? Number.parseInt(String(timeout).trim(), 10)
403
552
  : null;
@@ -413,10 +562,60 @@ async function collectTicketSetupPatch(dir, opts, current) {
413
562
  }
414
563
  return {
415
564
  sources,
565
+ limits,
416
566
  populate: populatePatch,
417
567
  build: buildPatch,
418
568
  };
419
569
  }
570
+ async function promptPositiveInteger(message, initial) {
571
+ const answer = await text({ message, initialValue: String(initial), defaultValue: String(initial), validate: (value) => Number.isInteger(Number(value)) && Number(value) > 0 ? undefined : "Enter a positive integer" });
572
+ if (isCancel(answer))
573
+ cancelTicketInterview();
574
+ return Number(answer);
575
+ }
576
+ async function promptNonNegativeInteger(message, initial) {
577
+ const answer = await text({ message, initialValue: String(initial), defaultValue: String(initial), validate: (value) => Number.isInteger(Number(value)) && Number(value) >= 0 ? undefined : "Enter zero or a positive integer" });
578
+ if (isCancel(answer))
579
+ cancelTicketInterview();
580
+ return Number(answer);
581
+ }
582
+ async function promptRequirementList(label, initial) {
583
+ console.log(`Current recommended ${label}: ${initial.join("; ") || "none"}`);
584
+ const mode = await select({
585
+ message: `How should Rafi configure the ${label}?`,
586
+ initialValue: "keep",
587
+ options: [
588
+ { value: "keep", label: "Keep all listed requirements (Recommended)" },
589
+ { value: "choose", label: "Choose individual requirements and optionally add custom ones" },
590
+ { value: "replace", label: "Replace with a custom list" },
591
+ ],
592
+ });
593
+ if (isCancel(mode))
594
+ cancelTicketInterview();
595
+ if (mode === "keep")
596
+ return [...initial];
597
+ if (mode === "replace") {
598
+ const replacement = await text({ message: `Custom ${label} (comma-separated):`, defaultValue: "" });
599
+ if (isCancel(replacement))
600
+ cancelTicketInterview();
601
+ return splitCommaList(String(replacement));
602
+ }
603
+ const selected = initial.length ? await multiselect({
604
+ message: `Select the ${label} to keep:`,
605
+ options: initial.map((value) => ({ value, label: value })),
606
+ initialValues: initial,
607
+ required: false,
608
+ }) : [];
609
+ if (isCancel(selected))
610
+ cancelTicketInterview();
611
+ const additions = await text({ message: `Additional custom ${label} (comma-separated, optional):`, defaultValue: "" });
612
+ if (isCancel(additions))
613
+ cancelTicketInterview();
614
+ return combineRequirementSelections(selected, String(additions));
615
+ }
616
+ export function combineRequirementSelections(selected, additions) {
617
+ return [...new Set([...selected, ...splitCommaList(additions)])];
618
+ }
420
619
  async function promptTicketSources(dir, current) {
421
620
  const kind = await select({
422
621
  message: "Primary ticket source:",
@@ -429,7 +628,7 @@ async function promptTicketSources(dir, current) {
429
628
  ],
430
629
  });
431
630
  if (isCancel(kind))
432
- process.exit(0);
631
+ cancelTicketInterview();
433
632
  if (kind === "none")
434
633
  return [];
435
634
  if (kind === "local") {
@@ -440,16 +639,16 @@ async function promptTicketSources(dir, current) {
440
639
  defaultValue: existing?.paths.join(", ") || `${configuredDocsPlanPath(dir)}`,
441
640
  });
442
641
  if (isCancel(answer))
443
- process.exit(0);
642
+ cancelTicketInterview();
444
643
  return [{ type: "local", paths: splitCommaList(String(answer)) }];
445
644
  }
446
645
  if (kind === "linear") {
447
646
  const team = await text({ message: "Linear team key (optional):" });
448
647
  if (isCancel(team))
449
- process.exit(0);
648
+ cancelTicketInterview();
450
649
  const filter = await text({ message: "Linear IssueFilter JSON or title search text (optional):" });
451
650
  if (isCancel(filter))
452
- process.exit(0);
651
+ cancelTicketInterview();
453
652
  return [{
454
653
  type: "linear",
455
654
  api_key_env: "LINEAR_API_KEY",
@@ -465,7 +664,7 @@ async function promptTicketSources(dir, current) {
465
664
  validate: (value) => /^https?:\/\//i.test(String(value ?? "")) ? undefined : "Enter an HTTP(S) URL",
466
665
  });
467
666
  if (isCancel(answer))
468
- process.exit(0);
667
+ cancelTicketInterview();
469
668
  return [{ type: "url", url: String(answer).trim() }];
470
669
  }
471
670
  const site = await text({
@@ -474,14 +673,14 @@ async function promptTicketSources(dir, current) {
474
673
  validate: (value) => String(value ?? "").trim() ? undefined : "Enter a Jira Cloud site URL",
475
674
  });
476
675
  if (isCancel(site))
477
- process.exit(0);
676
+ cancelTicketInterview();
478
677
  const jql = await text({
479
678
  message: "Jira JQL:",
480
679
  initialValue: "resolution = Unresolved ORDER BY priority DESC, updated DESC",
481
680
  defaultValue: "resolution = Unresolved ORDER BY priority DESC, updated DESC",
482
681
  });
483
682
  if (isCancel(jql))
484
- process.exit(0);
683
+ cancelTicketInterview();
485
684
  return [{
486
685
  type: "jira",
487
686
  site: String(site).trim(),
@@ -568,6 +767,11 @@ function parseAgentPreference(value) {
568
767
  return value;
569
768
  fail("--agent-preference must be one of: configured, claude, codex");
570
769
  }
770
+ function parseResetScope(value) {
771
+ if (["all", "completed-and-unfinished", "unfinished"].includes(value))
772
+ return value;
773
+ throw new Error("--scope must be all, completed-and-unfinished, or unfinished");
774
+ }
571
775
  function parseOptionalPositiveInteger(value, label) {
572
776
  if (!value.trim())
573
777
  return null;
@@ -599,19 +803,20 @@ function configuredDocsPlanPath(dir) {
599
803
  return "docs/rafi-plan.md";
600
804
  }
601
805
  async function resolveInitAppName(dir, yes) {
602
- const configured = defaultAppName(dir);
603
- if (configured !== "My App")
604
- return configured;
806
+ const established = configuredAppName(dir);
807
+ if (established)
808
+ return established;
809
+ const detected = detectPackageName(dir) ?? "My App";
605
810
  if (yes || !process.stdin.isTTY || !process.stdout.isTTY)
606
- return configured;
811
+ return detected;
607
812
  const answer = await text({
608
813
  message: "App name:",
609
- initialValue: configured,
610
- defaultValue: configured,
814
+ initialValue: detected,
815
+ defaultValue: detected,
611
816
  });
612
817
  if (isCancel(answer))
613
- process.exit(0);
614
- return String(answer).trim() || configured;
818
+ cancelTicketInterview();
819
+ return String(answer).trim() || detected;
615
820
  }
616
821
  async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfig, yes) {
617
822
  if (explicitSources?.length)
@@ -629,7 +834,7 @@ async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfi
629
834
  initialValue: true,
630
835
  });
631
836
  if (isCancel(usePlan))
632
- process.exit(0);
837
+ return undefined;
633
838
  return usePlan ? defaultPlan : undefined;
634
839
  }
635
840
  if (yes || !process.stdin.isTTY || !process.stdout.isTTY) {
@@ -639,7 +844,7 @@ async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfi
639
844
  console.log(" rafi tickets setup:init");
640
845
  console.log(" rafi tickets populate --sources <files-or-globs>");
641
846
  console.log(" edit .tickets/tickets.yaml manually");
642
- process.exit(2);
847
+ throw new Error("no ticket sources were found; create a plan, configure sources, or pass --sources");
643
848
  }
644
849
  const action = await select({
645
850
  message: "No ticket sources were found. What should happen next?",
@@ -652,22 +857,22 @@ async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfi
652
857
  });
653
858
  if (isCancel(action) || action === "none") {
654
859
  console.log("foreman tickets: cancelled");
655
- process.exit(0);
860
+ return undefined;
656
861
  }
657
862
  if (action === "setup") {
658
863
  await cmdSetupInitCli({ project: dir });
659
- process.exit(0);
864
+ return undefined;
660
865
  }
661
866
  if (action === "plan") {
662
867
  console.log(`foreman tickets: run \`rafi plan ${shellQuote(dir)}\`, then \`rafi tickets populate --project ${shellQuote(dir)}\`.`);
663
- process.exit(0);
868
+ return undefined;
664
869
  }
665
870
  const entered = await text({
666
871
  message: "Source paths or globs, comma-separated:",
667
872
  validate: (value) => String(value ?? "").trim() ? undefined : "Enter at least one source path or glob",
668
873
  });
669
874
  if (isCancel(entered))
670
- process.exit(0);
875
+ return undefined;
671
876
  return splitCommaList(String(entered));
672
877
  }
673
878
  function reviewActionFromOptions(opts) {
@@ -733,7 +938,7 @@ export async function cmdPopulateCli(opts) {
733
938
  });
734
939
  if (isCancel(action) || action === "cancel") {
735
940
  console.log("foreman tickets: cancelled");
736
- process.exit(0);
941
+ return;
737
942
  }
738
943
  }
739
944
  const results = await importExternalSources(dir, configuredExternalSources, {
@@ -751,7 +956,7 @@ export async function cmdPopulateCli(opts) {
751
956
  console.log(`foreman tickets: ${validation.issues.length} validation issue(s) found:`);
752
957
  console.log(formatValidationIssues(validation.issues));
753
958
  if (!validation.clean)
754
- process.exit(1);
959
+ throw new Error("external ticket import failed tracker validation");
755
960
  }
756
961
  if (!sourceHints.length) {
757
962
  console.log(`foreman tickets: imported external tickets and rendered ${ticketsConfig.paths.progressDoc}`);
@@ -769,7 +974,7 @@ export async function cmdPopulateCli(opts) {
769
974
  });
770
975
  if (isCancel(action) || action === "cancel") {
771
976
  console.log("foreman tickets: cancelled");
772
- process.exit(0);
977
+ return;
773
978
  }
774
979
  }
775
980
  const logPath = makeLogPath(dir, "tickets-populate");
@@ -811,12 +1016,10 @@ export async function cmdPopulateCli(opts) {
811
1016
  fail(`builder turn errored: ${turn.result.text.slice(0, 200)}`);
812
1017
  }
813
1018
  if (turn.status.kind === "blocked") {
814
- console.error(`foreman tickets: blocked — ${turn.status.reason ?? "builder reported blocked"}`);
815
- process.exit(2);
1019
+ throw new Error(`blocked — ${turn.status.reason ?? "builder reported blocked"}`);
816
1020
  }
817
1021
  if (turn.status.kind !== "done" && turn.status.kind !== "plan_complete") {
818
- console.error(`foreman tickets: needs human — ${turn.status.error ?? "builder did not emit done"}`);
819
- process.exit(2);
1022
+ throw new Error(`needs human — ${turn.status.error ?? "builder did not emit done"}`);
820
1023
  }
821
1024
  const existing = loadTickets(resolveTicketPaths(ticketsConfig, dir).tickets);
822
1025
  const proposal = extractTicketPopulationProposal(turn.result.text);
@@ -874,8 +1077,37 @@ function loadPopulationPlan(projectDir, sources) {
874
1077
  }
875
1078
  throw new Error("ticket population requires the approved paired structured plan (normally docs/rafi-plan.json); run `rafi plan --validate` first");
876
1079
  }
877
- export function buildTicketsCommand() {
1080
+ function ticketOwnershipReceipts(projectDir, commandName, _afterWrite) {
1081
+ const candidates = [
1082
+ ".tickets/config.yaml", ".tickets/tickets.yaml", ".tickets/delivery.yaml", ".tickets/tracker-rules.md",
1083
+ ".tickets/schema/tickets.schema.json", ".tickets/migrations/001_init.sql", ".tickets/.gitignore",
1084
+ ];
1085
+ if (isTicketsInitialized(projectDir)) {
1086
+ const config = loadTicketsConfig(projectDir);
1087
+ candidates.push(config.paths.progressDoc, config.paths.archiveDoc);
1088
+ }
1089
+ else {
1090
+ candidates.push(DEFAULT_TICKETS_CONFIG.paths.progressDoc, DEFAULT_TICKETS_CONFIG.paths.archiveDoc);
1091
+ }
1092
+ return unique(candidates).filter((path) => existsSync(join(projectDir, path))).map((path) => ({ path, category: "tickets", origin: `tickets:${commandName}` }));
1093
+ }
1094
+ export function buildTicketsCommand(options = {}) {
878
1095
  const tickets = new Command("tickets").description("Manage the structured ticket tracker for a project.");
1096
+ tickets.hook("preAction", (_thisCommand, actionCommand) => {
1097
+ const commandOptions = actionCommand.opts();
1098
+ const projectDir = cwd(commandOptions);
1099
+ recoverTicketResetPublications(projectDir);
1100
+ if (options.prepareOwnership && !["show", "validate", "queue"].includes(actionCommand.name())) {
1101
+ options.prepareOwnership(projectDir, ticketOwnershipReceipts(projectDir, actionCommand.name(), false));
1102
+ }
1103
+ });
1104
+ tickets.hook("postAction", (_thisCommand, actionCommand) => {
1105
+ if (!options.recordOwnership)
1106
+ return;
1107
+ const commandOptions = actionCommand.opts();
1108
+ const projectDir = cwd(commandOptions);
1109
+ options.recordOwnership(projectDir, ticketOwnershipReceipts(projectDir, actionCommand.name(), true));
1110
+ });
879
1111
  // ── setup:init / setup:update ─────────────────────────────────────────────
880
1112
  tickets
881
1113
  .command("setup:init")
@@ -905,6 +1137,10 @@ export function buildTicketsCommand() {
905
1137
  await cmdSetupInitCli(opts);
906
1138
  }
907
1139
  catch (err) {
1140
+ if (err instanceof TicketInterviewCancelled) {
1141
+ console.log("foreman tickets setup: cancelled; saved checkpoints were retained");
1142
+ return;
1143
+ }
908
1144
  fail(String(err instanceof Error ? err.message : err));
909
1145
  }
910
1146
  });
@@ -936,6 +1172,10 @@ export function buildTicketsCommand() {
936
1172
  await cmdSetupUpdateCli(opts);
937
1173
  }
938
1174
  catch (err) {
1175
+ if (err instanceof TicketInterviewCancelled) {
1176
+ console.log("foreman tickets setup: cancelled; saved checkpoints were retained");
1177
+ return;
1178
+ }
939
1179
  fail(String(err instanceof Error ? err.message : err));
940
1180
  }
941
1181
  });
@@ -969,9 +1209,24 @@ export function buildTicketsCommand() {
969
1209
  docsRoot: opts.docsRoot,
970
1210
  });
971
1211
  console.log(`foreman tickets: initialized .tickets/ in ${dir}`);
972
- console.log(`foreman tickets: next — add tickets to .tickets/tickets.yaml and run \`foreman tickets render\``);
1212
+ if (shouldPrompt(opts)) {
1213
+ const populate = await confirm({ message: "Populate tickets now?", initialValue: true });
1214
+ if (!isCancel(populate) && populate)
1215
+ await runNestedTicketCommand(() => cmdPopulateCli({ project: dir }));
1216
+ else {
1217
+ console.log("foreman tickets: population is the final prerequisite before building.");
1218
+ console.log(`foreman tickets: run \`rafi tickets populate --project ${shellQuote(dir)}\` later.`);
1219
+ }
1220
+ }
1221
+ else {
1222
+ console.log(`foreman tickets: next — run \`rafi tickets populate --project ${shellQuote(dir)}\``);
1223
+ }
973
1224
  }
974
1225
  catch (err) {
1226
+ if (err instanceof TicketInterviewCancelled) {
1227
+ console.log("foreman tickets: initialization cancelled; no process exit was forced by the nested interview");
1228
+ return;
1229
+ }
975
1230
  fail(String(err instanceof Error ? err.message : err));
976
1231
  }
977
1232
  });
@@ -996,6 +1251,90 @@ export function buildTicketsCommand() {
996
1251
  }
997
1252
  });
998
1253
  // ── update ──────────────────────────────────────────────────────────────────
1254
+ tickets
1255
+ .command("show <ticketId>")
1256
+ .description("Show the complete canonical ticket, active state, validation, delivery, and history.")
1257
+ .option("-p, --project <dir>", "project directory (default: cwd)")
1258
+ .option("--json", "write the complete stable record as JSON")
1259
+ .action((ticketId, opts) => {
1260
+ try {
1261
+ const details = getTicketDetails(cwd(opts), ticketId);
1262
+ if (opts.json)
1263
+ process.stdout.write(`${JSON.stringify(details, null, 2)}\n`);
1264
+ else
1265
+ for (const line of formatTicketDetails(details))
1266
+ console.log(line);
1267
+ }
1268
+ catch (err) {
1269
+ fail(String(err instanceof Error ? err.message : err));
1270
+ }
1271
+ });
1272
+ tickets
1273
+ .command("reset [ticketId]")
1274
+ .description("Reset one ticket or an explicit ticket scope to pristine active state while retaining history.")
1275
+ .option("-p, --project <dir>", "project directory (default: cwd)")
1276
+ .option("--all", "open the interactive bulk reset scope chooser")
1277
+ .option("--scope <scope>", "bulk scope (all | completed-and-unfinished | unfinished)")
1278
+ .option("--actor <actor>", "who requested the reset", "user")
1279
+ .option("-y, --yes", "confirm an explicit ticket or --scope without prompting")
1280
+ .action(async (ticketId, opts) => {
1281
+ try {
1282
+ if (ticketId && (opts.all || opts.scope))
1283
+ throw new Error("provide a ticket ID or a bulk scope, not both");
1284
+ if (!ticketId && !opts.all && !opts.scope)
1285
+ throw new Error("provide <ticketId>, --all, or --scope <scope>");
1286
+ let target = ticketId;
1287
+ if (opts.scope)
1288
+ target = parseResetScope(String(opts.scope));
1289
+ if (opts.all && !opts.scope) {
1290
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
1291
+ throw new Error("--all requires an interactive terminal; use --scope <scope> --yes for automation");
1292
+ const answer = await select({
1293
+ message: "Which tickets should return to an unworked state?",
1294
+ options: [
1295
+ { value: "all", label: "All tickets" },
1296
+ { value: "completed-and-unfinished", label: "Completed and unfinished (exclude canceled/obsolete)" },
1297
+ { value: "unfinished", label: "Unfinished only (exclude done/canceled/obsolete)" },
1298
+ ],
1299
+ });
1300
+ if (isCancel(answer)) {
1301
+ console.log("foreman tickets: reset cancelled; nothing changed");
1302
+ return;
1303
+ }
1304
+ target = answer;
1305
+ }
1306
+ if (!target)
1307
+ throw new Error("reset target was not selected");
1308
+ const preview = previewTicketReset(cwd(opts), target);
1309
+ console.log(`foreman tickets reset preview (${preview.scope}):`);
1310
+ if (!preview.tickets.length)
1311
+ console.log(" (no matching tickets)");
1312
+ for (const row of preview.tickets)
1313
+ console.log(` ${row.id}: ${row.title} [${row.status}] — clears ${row.cleared.join(", ")}`);
1314
+ if (preview.relatedRuns.length) {
1315
+ console.log(" ! Matching build/recovery metadata is not deleted. Use `rafi build:start-over` for a coherent whole-run restart.");
1316
+ for (const run of preview.relatedRuns)
1317
+ console.log(` ${run.runId.slice(0, 8)} [${run.status}] ${run.tickets.join(", ")}`);
1318
+ }
1319
+ if (!preview.tickets.length)
1320
+ return;
1321
+ const explicitlyConfirmed = Boolean(opts.yes && (ticketId || opts.scope));
1322
+ if (!explicitlyConfirmed) {
1323
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
1324
+ throw new Error("confirmation requires a TTY; pass --yes with an explicit ticket or --scope");
1325
+ const approved = await confirm({ message: `Reset ${preview.tickets.length} ticket(s)? Historical events and validation remain available.`, initialValue: false });
1326
+ if (isCancel(approved) || !approved) {
1327
+ console.log("foreman tickets: reset cancelled; nothing changed");
1328
+ return;
1329
+ }
1330
+ }
1331
+ const result = resetTickets(cwd(opts), target, String(opts.actor));
1332
+ console.log(`foreman tickets: reset ${result.tickets.length} ticket(s) (audit ${result.resetId})`);
1333
+ }
1334
+ catch (err) {
1335
+ fail(String(err instanceof Error ? err.message : err));
1336
+ }
1337
+ });
999
1338
  tickets
1000
1339
  .command("update <ticketId>")
1001
1340
  .description("Update ticket status or progress fields.")