ai-foreman 1.7.5 → 1.7.7

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 (78) hide show
  1. package/README.md +17 -2
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +29 -4
  4. package/dist/adapters/claude.d.ts +33 -2
  5. package/dist/adapters/claude.js +208 -17
  6. package/dist/adapters/codex.d.ts +11 -1
  7. package/dist/adapters/codex.js +195 -20
  8. package/dist/adapters/recovering.d.ts +8 -2
  9. package/dist/adapters/recovering.js +23 -3
  10. package/dist/adapters/sessionFailure.d.ts +18 -0
  11. package/dist/adapters/sessionFailure.js +51 -0
  12. package/dist/adapters/types.d.ts +52 -9
  13. package/dist/agentRun.d.ts +4 -1
  14. package/dist/agentRun.js +96 -9
  15. package/dist/branch/currentGuard.d.ts +34 -0
  16. package/dist/branch/currentGuard.js +65 -0
  17. package/dist/branch/github.js +3 -2
  18. package/dist/branch/gitlab.js +3 -2
  19. package/dist/branch/planner.d.ts +5 -0
  20. package/dist/branch/planner.js +37 -3
  21. package/dist/branch/prefix.d.ts +8 -0
  22. package/dist/branch/prefix.js +26 -0
  23. package/dist/branch/resume.d.ts +2 -0
  24. package/dist/branch/resume.js +8 -0
  25. package/dist/branch/runner.d.ts +14 -2
  26. package/dist/branch/runner.js +77 -17
  27. package/dist/buildRuns.d.ts +51 -18
  28. package/dist/buildRuns.js +318 -31
  29. package/dist/cli/handoffs.d.ts +2 -0
  30. package/dist/cli/handoffs.js +74 -0
  31. package/dist/cli/start.js +1040 -62
  32. package/dist/cli/tickets.d.ts +10 -1
  33. package/dist/cli/tickets.js +615 -80
  34. package/dist/continuity.d.ts +86 -0
  35. package/dist/continuity.js +365 -0
  36. package/dist/foreman.d.ts +9 -5
  37. package/dist/foreman.js +76 -20
  38. package/dist/handoffs.d.ts +69 -0
  39. package/dist/handoffs.js +326 -0
  40. package/dist/log.d.ts +1 -1
  41. package/dist/permissions/policy.d.ts +5 -1
  42. package/dist/permissions/policy.js +48 -1
  43. package/dist/qaReview.d.ts +7 -6
  44. package/dist/qaReview.js +31 -20
  45. package/dist/sessionAvailability.d.ts +15 -0
  46. package/dist/sessionAvailability.js +57 -0
  47. package/dist/sessionIdentity.d.ts +33 -0
  48. package/dist/sessionIdentity.js +132 -0
  49. package/dist/sessionLifecycle.d.ts +102 -3
  50. package/dist/sessionLifecycle.js +578 -20
  51. package/dist/statusReporter.d.ts +58 -8
  52. package/dist/statusReporter.js +210 -14
  53. package/dist/ticketPlanning.js +8 -0
  54. package/dist/ticketPopulation.js +88 -12
  55. package/dist/tickets/commands.js +20 -12
  56. package/dist/tickets/details.d.ts +17 -0
  57. package/dist/tickets/details.js +94 -0
  58. package/dist/tickets/eligibility.d.ts +28 -0
  59. package/dist/tickets/eligibility.js +71 -0
  60. package/dist/tickets/groupReset.d.ts +25 -0
  61. package/dist/tickets/groupReset.js +279 -0
  62. package/dist/tickets/groups.d.ts +36 -0
  63. package/dist/tickets/groups.js +68 -0
  64. package/dist/tickets/importer.js +27 -3
  65. package/dist/tickets/recommendations.js +31 -7
  66. package/dist/tickets/reset.d.ts +28 -0
  67. package/dist/tickets/reset.js +214 -0
  68. package/dist/tickets/reviewStandards.d.ts +7 -0
  69. package/dist/tickets/reviewStandards.js +53 -0
  70. package/dist/tickets/setupConfig.d.ts +23 -0
  71. package/dist/tickets/setupConfig.js +115 -3
  72. package/dist/tickets/stateDb.d.ts +42 -1
  73. package/dist/tickets/stateDb.js +261 -23
  74. package/dist/tickets/ticketLoader.js +5 -2
  75. package/dist/tickets/validate.js +4 -0
  76. package/dist/workflowDb.d.ts +102 -2
  77. package/dist/workflowDb.js +460 -8
  78. package/package.json +35 -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,35 @@ 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
- import { applyTicketPopulation, authorizeTicketRetirements, extractTicketPopulationProposal, materializeTicketPopulation, TICKET_POPULATION_PROPOSAL_END, TICKET_POPULATION_PROPOSAL_START } from "../ticketPopulation.js";
20
+ import { applyTicketPopulation, authorizeTicketRetirements, extractTicketPopulationProposal, materializeTicketPopulation, recoverTicketPublications, 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 { applyResolvedTicketReset, previewTicketReset, recoverTicketResetPublications, resetTickets, resolveTicketResetSelection, TicketResetDependencyConflictError } from "../tickets/reset.js";
25
+ import { validateBranchPrefix } from "../branch/prefix.js";
26
+ import { listTicketGroups, previewTicketGroupRepair, repairTicketGroups } from "../tickets/groups.js";
27
+ let nestedTicketCommandDepth = 0;
28
+ class TicketInterviewCancelled extends Error {
29
+ }
30
+ function cancelTicketInterview() { throw new TicketInterviewCancelled("ticket interview cancelled"); }
23
31
  function fail(msg) {
32
+ if (nestedTicketCommandDepth > 0)
33
+ throw new Error(msg);
24
34
  console.error(`foreman tickets: ${msg}`);
25
35
  process.exit(1);
26
36
  }
37
+ async function runNestedTicketCommand(action) {
38
+ nestedTicketCommandDepth += 1;
39
+ try {
40
+ return await action();
41
+ }
42
+ finally {
43
+ nestedTicketCommandDepth -= 1;
44
+ }
45
+ }
27
46
  function cwd(opts) {
28
47
  return resolve(opts.project ?? ".");
29
48
  }
@@ -148,6 +167,8 @@ async function cmdSetupInitCli(opts) {
148
167
  if (hasTicketSetupConfig(dir)) {
149
168
  console.log("foreman tickets setup: existing setup found; opening setup:update");
150
169
  await cmdSetupUpdateCli(opts);
170
+ const existing = loadTicketSetupConfigWithDefaults(dir);
171
+ await continueTrackerSetup(dir, opts, existing);
151
172
  if (interview)
152
173
  completeInterview(dir, interview);
153
174
  return;
@@ -168,41 +189,74 @@ async function cmdSetupInitCli(opts) {
168
189
  targets: parseRuntimeTargets(opts.runtime),
169
190
  });
170
191
  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
- }
192
+ if (interview)
193
+ interview = checkpointInterview(dir, interview, { checkpoint: "initialize-tracker" });
194
+ await continueTrackerSetup(dir, opts, answers);
197
195
  if (interview)
198
196
  completeInterview(dir, interview);
199
197
  }
200
198
  catch (error) {
201
- if (interview)
199
+ if (interview && !(error instanceof TicketInterviewCancelled))
202
200
  failInterview(dir, interview, interview.checkpoint, error);
203
201
  throw error;
204
202
  }
205
203
  }
204
+ async function continueTrackerSetup(dir, opts, setup) {
205
+ if (!isTicketsInitialized(dir)) {
206
+ let initialize = true;
207
+ if (shouldPrompt(opts)) {
208
+ const answer = await confirm({ message: "Initialize the ticket tracker now?", initialValue: true });
209
+ if (isCancel(answer))
210
+ return;
211
+ initialize = Boolean(answer);
212
+ }
213
+ if (!initialize) {
214
+ console.log("foreman tickets setup: preferences were saved. Ticket infrastructure is still required before building.");
215
+ console.log(` rafi tickets init --project ${shellQuote(dir)}`);
216
+ console.log(` rafi tickets populate --project ${shellQuote(dir)}`);
217
+ return;
218
+ }
219
+ const initialized = await retrySetupStage("ticket initialization", async () => {
220
+ const appName = opts.appName ?? await resolveInitAppName(dir, Boolean(opts.yes || opts.defaults));
221
+ cmdInit(dir, { appName, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, docsRoot: opts.docsRoot, implementationLimit: setup.limits.implementation, viewLimit: setup.limits.view });
222
+ syncTicketLimits(dir, setup.limits);
223
+ }, shouldPrompt(opts));
224
+ if (!initialized)
225
+ return;
226
+ console.log("foreman tickets setup: initialized .tickets/");
227
+ }
228
+ if (shouldPrompt(opts)) {
229
+ const populate = await confirm({ message: "Populate tickets now?", initialValue: true });
230
+ if (isCancel(populate))
231
+ return;
232
+ if (populate)
233
+ await retrySetupStage("ticket population", () => runNestedTicketCommand(() => cmdPopulateCli({ project: dir, yes: true })), true);
234
+ else {
235
+ console.log("foreman tickets setup: population is the final prerequisite before building.");
236
+ console.log(`foreman tickets setup: run \`rafi tickets populate --project ${shellQuote(dir)}\` later.`);
237
+ }
238
+ }
239
+ else {
240
+ console.log(`foreman tickets setup: next — run \`rafi tickets populate --project ${shellQuote(dir)}\``);
241
+ }
242
+ }
243
+ async function retrySetupStage(label, action, interactive) {
244
+ while (true) {
245
+ try {
246
+ await action();
247
+ return true;
248
+ }
249
+ catch (error) {
250
+ if (!interactive)
251
+ throw error;
252
+ console.error(`foreman tickets setup: ${label} failed: ${error instanceof Error ? error.message : String(error)}`);
253
+ console.error("Fix the reported problem, then press Enter to retry; enter q to leave setup with saved progress.");
254
+ const answer = await text({ message: `Retry ${label}?`, defaultValue: "" });
255
+ if (isCancel(answer) || String(answer).trim().toLowerCase() === "q")
256
+ return false;
257
+ }
258
+ }
259
+ }
206
260
  async function cmdSetupUpdateCli(opts) {
207
261
  const dir = cwd(opts);
208
262
  if (!existsSync(dir))
@@ -225,6 +279,7 @@ async function cmdSetupUpdateCli(opts) {
225
279
  docsRoot: opts.docsRoot,
226
280
  targets: parseRuntimeTargets(opts.runtime),
227
281
  });
282
+ syncTicketLimits(dir, next.limits);
228
283
  console.log(`foreman tickets setup: updated ticket setup in ${join(dir, "rafi-config.yaml")}`);
229
284
  if (isTicketsInitialized(dir)) {
230
285
  const rows = cmdQueue(dir, 1);
@@ -236,7 +291,7 @@ async function cmdSetupUpdateCli(opts) {
236
291
  completeInterview(dir, interview);
237
292
  }
238
293
  catch (error) {
239
- if (interview)
294
+ if (interview && !(error instanceof TicketInterviewCancelled))
240
295
  failInterview(dir, interview, interview.checkpoint, error);
241
296
  throw error;
242
297
  }
@@ -268,6 +323,8 @@ async function collectTicketSetupPatch(dir, opts, current) {
268
323
  populatePatch.agent_preference = parseAgentPreference(opts.agentPreference);
269
324
  if (opts.branchStrategy)
270
325
  buildPatch.branch_strategy = parseBranchStrategy(opts.branchStrategy);
326
+ if (opts.branchPrefix)
327
+ buildPatch.branch_prefix = validateBranchPrefix(opts.branchPrefix);
271
328
  if (opts.completion)
272
329
  buildPatch.completion = parseCompletionMode(opts.completion);
273
330
  if (opts.provider)
@@ -302,38 +359,119 @@ async function collectTicketSetupPatch(dir, opts, current) {
302
359
  { value: "sources", label: "Ticket sources" },
303
360
  { value: "populate", label: "Populate defaults" },
304
361
  { value: "build", label: "Build defaults" },
362
+ { value: "limits", label: "Tracker limits" },
305
363
  { value: "all", label: "All sections" },
306
364
  ],
307
365
  });
308
366
  if (isCancel(section))
309
- process.exit(0);
367
+ cancelTicketInterview();
310
368
  // Do not silently persist planning hints over a completed ticket setup. They
311
369
  // are only the initial local-source suggestion for a fresh setup.
312
370
  const planningPrefill = current.sources.length === 0
313
371
  ? configuredPlanningSources(dir).map((path) => ({ type: "local", paths: [path] }))
314
372
  : current.sources;
315
373
  let sources = nonInteractiveSources.length > 0 ? nonInteractiveSources : planningPrefill;
374
+ let limits = current.limits;
316
375
  if (section === "sources" || section === "all") {
317
376
  sources = await promptTicketSources(dir, current.sources);
318
377
  }
378
+ if (section === "limits" || section === "all") {
379
+ 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.");
380
+ const keep = await confirm({ message: "Keep both default limits?", initialValue: true });
381
+ if (isCancel(keep))
382
+ cancelTicketInterview();
383
+ limits = keep ? { implementation: 500, view: 20_000 } : {
384
+ implementation: await promptPositiveInteger("Implementation window limit:", current.limits.implementation),
385
+ view: await promptPositiveInteger("View limit:", current.limits.view),
386
+ };
387
+ }
319
388
  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;
389
+ const defaults = await confirm({ message: "Use the recommended population settings?", initialValue: true });
390
+ if (isCancel(defaults))
391
+ cancelTicketInterview();
392
+ if (defaults)
393
+ Object.assign(populatePatch, DEFAULT_TICKET_SETUP.populate);
394
+ else {
395
+ const handling = await select({ message: "How should saved sources be handled?", initialValue: current.populate.source_handling, options: [
396
+ { value: "saved", label: "Use saved sources" }, { value: "prompt", label: "Ask each time" }, { value: "manual", label: "Manual only" },
397
+ ] });
398
+ if (isCancel(handling))
399
+ cancelTicketInterview();
400
+ populatePatch.source_handling = handling;
401
+ const agent = await select({ message: "When both runtimes are configured, which should populate use?", initialValue: current.populate.agent_preference, options: [
402
+ { value: "configured", label: "Configured project default" }, { value: "claude", label: "Claude" }, { value: "codex", label: "Codex" },
403
+ ] });
404
+ if (isCancel(agent))
405
+ cancelTicketInterview();
406
+ populatePatch.agent_preference = agent;
407
+ populatePatch.import_cap = await promptPositiveInteger("Maximum tickets imported in one population run (separate from the implementation window):", current.populate.import_cap);
408
+ populatePatch.comment_limit = await promptNonNegativeInteger("Maximum comments imported per external ticket:", current.populate.comment_limit);
409
+ const enrichment = await select({ message: "Population enrichment policy:", initialValue: current.populate.enrichment, options: [
410
+ { value: "recommendations", label: "Deterministic recommendations" }, { value: "agent", label: "Agent enrichment" }, { value: "none", label: "No enrichment" },
411
+ ] });
412
+ if (isCancel(enrichment))
413
+ cancelTicketInterview();
414
+ populatePatch.enrichment = enrichment;
415
+ const split = await confirm({ message: "Recommend splitting XL tickets?", initialValue: current.populate.recommend_split_for_xl });
416
+ if (isCancel(split))
417
+ cancelTicketInterview();
418
+ populatePatch.recommend_split_for_xl = Boolean(split);
419
+ }
332
420
  }
333
421
  if (section === "build" || section === "all") {
334
422
  const recommended = recommendedBuildDefaults(dir);
335
- const strategy = await select({
336
- message: "Default ticket work mode:",
423
+ const base = await text({ message: "Base branch:", initialValue: current.build.base_branch || detectDefaultBranch(dir), defaultValue: detectDefaultBranch(dir) });
424
+ if (isCancel(base))
425
+ cancelTicketInterview();
426
+ buildPatch.base_branch = String(base).trim();
427
+ const prefix = await text({ message: "Branch prefix:", initialValue: current.build.branch_prefix, defaultValue: current.build.branch_prefix, validate: (value) => {
428
+ try {
429
+ validateBranchPrefix(String(value));
430
+ return undefined;
431
+ }
432
+ catch (error) {
433
+ return error instanceof Error ? error.message : String(error);
434
+ }
435
+ } });
436
+ if (isCancel(prefix))
437
+ cancelTicketInterview();
438
+ buildPatch.branch_prefix = validateBranchPrefix(String(prefix));
439
+ const policyMode = await select({ message: "Use one global branch strategy or choose by inferred ticket size?", initialValue: current.build.branch_policy.mode, options: [
440
+ { value: "size", label: "Size-based: XS/S shared, M/L/XL per ticket (Recommended)" },
441
+ { value: "global", label: "One global strategy" },
442
+ ] });
443
+ if (isCancel(policyMode))
444
+ cancelTicketInterview();
445
+ let sizeMapping = { XS: "shared", S: "shared", M: "per-ticket", L: "per-ticket", XL: "per-ticket" };
446
+ if (policyMode === "size") {
447
+ 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.");
448
+ const keepMapping = await confirm({ message: "Keep the recommended size mapping?", initialValue: true });
449
+ if (isCancel(keepMapping))
450
+ cancelTicketInterview();
451
+ if (!keepMapping) {
452
+ sizeMapping = { ...sizeMapping };
453
+ for (const size of ["XS", "S", "M", "L", "XL"]) {
454
+ const allocation = await select({
455
+ message: `${size} ticket branch allocation:`,
456
+ initialValue: current.build.branch_policy.by_size[size],
457
+ options: [
458
+ { value: "shared", label: "Share with compatible tickets selected in this run" },
459
+ { value: "per-ticket", label: "Use a separate branch per ticket" },
460
+ ],
461
+ });
462
+ if (isCancel(allocation))
463
+ cancelTicketInterview();
464
+ sizeMapping[size] = allocation;
465
+ }
466
+ }
467
+ }
468
+ buildPatch.branch_policy = {
469
+ ...current.build.branch_policy,
470
+ mode: policyMode,
471
+ by_size: policyMode === "size" ? sizeMapping : current.build.branch_policy.by_size,
472
+ };
473
+ const strategy = policyMode === "size" ? "branch-per-ticket" : await select({
474
+ message: "Default global ticket work mode:",
337
475
  initialValue: current.build.branch_strategy,
338
476
  options: [
339
477
  { value: "current", label: "One branch - work the queue on the current branch" },
@@ -342,8 +480,9 @@ async function collectTicketSetupPatch(dir, opts, current) {
342
480
  ],
343
481
  });
344
482
  if (isCancel(strategy))
345
- process.exit(0);
483
+ cancelTicketInterview();
346
484
  buildPatch.branch_strategy = strategy;
485
+ buildPatch.branch_policy.global_strategy = buildPatch.branch_strategy;
347
486
  if (strategy === "current") {
348
487
  buildPatch.completion = "none";
349
488
  buildPatch.provider = "local";
@@ -352,8 +491,10 @@ async function collectTicketSetupPatch(dir, opts, current) {
352
491
  buildPatch.cleanup = true;
353
492
  buildPatch.auto_merge_wait = false;
354
493
  buildPatch.auto_merge_timeout_minutes = null;
494
+ buildPatch.validation_checklist = await promptRequirementList("validation checklist", current.build.validation_checklist);
355
495
  return {
356
496
  sources,
497
+ limits,
357
498
  populate: populatePatch,
358
499
  build: buildPatch,
359
500
  };
@@ -369,19 +510,43 @@ async function collectTicketSetupPatch(dir, opts, current) {
369
510
  ],
370
511
  });
371
512
  if (isCancel(completion))
372
- process.exit(0);
513
+ cancelTicketInterview();
373
514
  buildPatch.completion = completion;
374
515
  buildPatch.provider = recommended.provider;
375
516
  buildPatch.pr_ready = completion === "auto-merge";
376
517
  buildPatch.merge_method = "squash";
377
518
  buildPatch.cleanup = true;
519
+ if (completion === "pr" || completion === "auto-merge") {
520
+ const title = await select({ message: "PR/MR title standard:", initialValue: current.build.review.title_style, options: [
521
+ { value: "ticket-title", label: "T123: Add password reset (Recommended)" },
522
+ { value: "conventional", label: "Conventional Commit" },
523
+ { value: "none", label: "No required format" },
524
+ { value: "custom", label: "Custom template" },
525
+ ] });
526
+ if (isCancel(title))
527
+ cancelTicketInterview();
528
+ let template = null;
529
+ if (title === "custom") {
530
+ const answer = await text({ message: "Custom title template (use {id} and {title}):" });
531
+ if (isCancel(answer))
532
+ cancelTicketInterview();
533
+ template = String(answer);
534
+ }
535
+ buildPatch.review = {
536
+ ...current.build.review,
537
+ title_style: title,
538
+ title_template: template,
539
+ description_sections: await promptRequirementList("required PR/MR description sections", current.build.review.description_sections),
540
+ };
541
+ }
542
+ buildPatch.validation_checklist = await promptRequirementList("validation checklist", current.build.validation_checklist);
378
543
  if (completion === "auto-merge") {
379
544
  const wait = await confirm({
380
545
  message: "Wait for dependency PR/MRs to merge before starting dependent tickets?",
381
546
  initialValue: current.build.auto_merge_wait,
382
547
  });
383
548
  if (isCancel(wait))
384
- process.exit(0);
549
+ cancelTicketInterview();
385
550
  buildPatch.auto_merge_wait = Boolean(wait);
386
551
  if (wait) {
387
552
  const timeout = await text({
@@ -397,7 +562,7 @@ async function collectTicketSetupPatch(dir, opts, current) {
397
562
  },
398
563
  });
399
564
  if (isCancel(timeout))
400
- process.exit(0);
565
+ cancelTicketInterview();
401
566
  buildPatch.auto_merge_timeout_minutes = String(timeout).trim()
402
567
  ? Number.parseInt(String(timeout).trim(), 10)
403
568
  : null;
@@ -413,10 +578,60 @@ async function collectTicketSetupPatch(dir, opts, current) {
413
578
  }
414
579
  return {
415
580
  sources,
581
+ limits,
416
582
  populate: populatePatch,
417
583
  build: buildPatch,
418
584
  };
419
585
  }
586
+ async function promptPositiveInteger(message, initial) {
587
+ 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" });
588
+ if (isCancel(answer))
589
+ cancelTicketInterview();
590
+ return Number(answer);
591
+ }
592
+ async function promptNonNegativeInteger(message, initial) {
593
+ 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" });
594
+ if (isCancel(answer))
595
+ cancelTicketInterview();
596
+ return Number(answer);
597
+ }
598
+ async function promptRequirementList(label, initial) {
599
+ console.log(`Current recommended ${label}: ${initial.join("; ") || "none"}`);
600
+ const mode = await select({
601
+ message: `How should Rafi configure the ${label}?`,
602
+ initialValue: "keep",
603
+ options: [
604
+ { value: "keep", label: "Keep all listed requirements (Recommended)" },
605
+ { value: "choose", label: "Choose individual requirements and optionally add custom ones" },
606
+ { value: "replace", label: "Replace with a custom list" },
607
+ ],
608
+ });
609
+ if (isCancel(mode))
610
+ cancelTicketInterview();
611
+ if (mode === "keep")
612
+ return [...initial];
613
+ if (mode === "replace") {
614
+ const replacement = await text({ message: `Custom ${label} (comma-separated):`, defaultValue: "" });
615
+ if (isCancel(replacement))
616
+ cancelTicketInterview();
617
+ return splitCommaList(String(replacement));
618
+ }
619
+ const selected = initial.length ? await multiselect({
620
+ message: `Select the ${label} to keep:`,
621
+ options: initial.map((value) => ({ value, label: value })),
622
+ initialValues: initial,
623
+ required: false,
624
+ }) : [];
625
+ if (isCancel(selected))
626
+ cancelTicketInterview();
627
+ const additions = await text({ message: `Additional custom ${label} (comma-separated, optional):`, defaultValue: "" });
628
+ if (isCancel(additions))
629
+ cancelTicketInterview();
630
+ return combineRequirementSelections(selected, String(additions));
631
+ }
632
+ export function combineRequirementSelections(selected, additions) {
633
+ return [...new Set([...selected, ...splitCommaList(additions)])];
634
+ }
420
635
  async function promptTicketSources(dir, current) {
421
636
  const kind = await select({
422
637
  message: "Primary ticket source:",
@@ -429,7 +644,7 @@ async function promptTicketSources(dir, current) {
429
644
  ],
430
645
  });
431
646
  if (isCancel(kind))
432
- process.exit(0);
647
+ cancelTicketInterview();
433
648
  if (kind === "none")
434
649
  return [];
435
650
  if (kind === "local") {
@@ -440,16 +655,16 @@ async function promptTicketSources(dir, current) {
440
655
  defaultValue: existing?.paths.join(", ") || `${configuredDocsPlanPath(dir)}`,
441
656
  });
442
657
  if (isCancel(answer))
443
- process.exit(0);
658
+ cancelTicketInterview();
444
659
  return [{ type: "local", paths: splitCommaList(String(answer)) }];
445
660
  }
446
661
  if (kind === "linear") {
447
662
  const team = await text({ message: "Linear team key (optional):" });
448
663
  if (isCancel(team))
449
- process.exit(0);
664
+ cancelTicketInterview();
450
665
  const filter = await text({ message: "Linear IssueFilter JSON or title search text (optional):" });
451
666
  if (isCancel(filter))
452
- process.exit(0);
667
+ cancelTicketInterview();
453
668
  return [{
454
669
  type: "linear",
455
670
  api_key_env: "LINEAR_API_KEY",
@@ -465,7 +680,7 @@ async function promptTicketSources(dir, current) {
465
680
  validate: (value) => /^https?:\/\//i.test(String(value ?? "")) ? undefined : "Enter an HTTP(S) URL",
466
681
  });
467
682
  if (isCancel(answer))
468
- process.exit(0);
683
+ cancelTicketInterview();
469
684
  return [{ type: "url", url: String(answer).trim() }];
470
685
  }
471
686
  const site = await text({
@@ -474,14 +689,14 @@ async function promptTicketSources(dir, current) {
474
689
  validate: (value) => String(value ?? "").trim() ? undefined : "Enter a Jira Cloud site URL",
475
690
  });
476
691
  if (isCancel(site))
477
- process.exit(0);
692
+ cancelTicketInterview();
478
693
  const jql = await text({
479
694
  message: "Jira JQL:",
480
695
  initialValue: "resolution = Unresolved ORDER BY priority DESC, updated DESC",
481
696
  defaultValue: "resolution = Unresolved ORDER BY priority DESC, updated DESC",
482
697
  });
483
698
  if (isCancel(jql))
484
- process.exit(0);
699
+ cancelTicketInterview();
485
700
  return [{
486
701
  type: "jira",
487
702
  site: String(site).trim(),
@@ -568,6 +783,27 @@ function parseAgentPreference(value) {
568
783
  return value;
569
784
  fail("--agent-preference must be one of: configured, claude, codex");
570
785
  }
786
+ function parseResetScope(value) {
787
+ if (["all", "completed-and-unfinished", "unfinished"].includes(value))
788
+ return value;
789
+ throw new Error("--scope must be all, completed-and-unfinished, or unfinished");
790
+ }
791
+ function parsePositiveInteger(value, label) {
792
+ const parsed = Number.parseInt(value, 10);
793
+ if (Number.isInteger(parsed) && parsed > 0 && String(parsed) === value.trim())
794
+ return parsed;
795
+ throw new Error(`${label} must be a positive integer`);
796
+ }
797
+ function parseTicketGroupId(value) {
798
+ if (!/^TG-[1-9]\d*$/.test(value))
799
+ throw new Error("--group must use a stable ID such as TG-1");
800
+ return value;
801
+ }
802
+ function parseDeletedTicketPolicy(value) {
803
+ if (value === "ignore" || value === "restore")
804
+ return value;
805
+ throw new Error("--deleted-tickets must be ignore or restore");
806
+ }
571
807
  function parseOptionalPositiveInteger(value, label) {
572
808
  if (!value.trim())
573
809
  return null;
@@ -599,19 +835,20 @@ function configuredDocsPlanPath(dir) {
599
835
  return "docs/rafi-plan.md";
600
836
  }
601
837
  async function resolveInitAppName(dir, yes) {
602
- const configured = defaultAppName(dir);
603
- if (configured !== "My App")
604
- return configured;
838
+ const established = configuredAppName(dir);
839
+ if (established)
840
+ return established;
841
+ const detected = detectPackageName(dir) ?? "My App";
605
842
  if (yes || !process.stdin.isTTY || !process.stdout.isTTY)
606
- return configured;
843
+ return detected;
607
844
  const answer = await text({
608
845
  message: "App name:",
609
- initialValue: configured,
610
- defaultValue: configured,
846
+ initialValue: detected,
847
+ defaultValue: detected,
611
848
  });
612
849
  if (isCancel(answer))
613
- process.exit(0);
614
- return String(answer).trim() || configured;
850
+ cancelTicketInterview();
851
+ return String(answer).trim() || detected;
615
852
  }
616
853
  async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfig, yes) {
617
854
  if (explicitSources?.length)
@@ -629,7 +866,7 @@ async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfi
629
866
  initialValue: true,
630
867
  });
631
868
  if (isCancel(usePlan))
632
- process.exit(0);
869
+ return undefined;
633
870
  return usePlan ? defaultPlan : undefined;
634
871
  }
635
872
  if (yes || !process.stdin.isTTY || !process.stdout.isTTY) {
@@ -639,7 +876,7 @@ async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfi
639
876
  console.log(" rafi tickets setup:init");
640
877
  console.log(" rafi tickets populate --sources <files-or-globs>");
641
878
  console.log(" edit .tickets/tickets.yaml manually");
642
- process.exit(2);
879
+ throw new Error("no ticket sources were found; create a plan, configure sources, or pass --sources");
643
880
  }
644
881
  const action = await select({
645
882
  message: "No ticket sources were found. What should happen next?",
@@ -652,22 +889,22 @@ async function resolvePopulateSourceSelection(dir, explicitSources, ticketsConfi
652
889
  });
653
890
  if (isCancel(action) || action === "none") {
654
891
  console.log("foreman tickets: cancelled");
655
- process.exit(0);
892
+ return undefined;
656
893
  }
657
894
  if (action === "setup") {
658
895
  await cmdSetupInitCli({ project: dir });
659
- process.exit(0);
896
+ return undefined;
660
897
  }
661
898
  if (action === "plan") {
662
899
  console.log(`foreman tickets: run \`rafi plan ${shellQuote(dir)}\`, then \`rafi tickets populate --project ${shellQuote(dir)}\`.`);
663
- process.exit(0);
900
+ return undefined;
664
901
  }
665
902
  const entered = await text({
666
903
  message: "Source paths or globs, comma-separated:",
667
904
  validate: (value) => String(value ?? "").trim() ? undefined : "Enter at least one source path or glob",
668
905
  });
669
906
  if (isCancel(entered))
670
- process.exit(0);
907
+ return undefined;
671
908
  return splitCommaList(String(entered));
672
909
  }
673
910
  function reviewActionFromOptions(opts) {
@@ -733,7 +970,7 @@ export async function cmdPopulateCli(opts) {
733
970
  });
734
971
  if (isCancel(action) || action === "cancel") {
735
972
  console.log("foreman tickets: cancelled");
736
- process.exit(0);
973
+ return;
737
974
  }
738
975
  }
739
976
  const results = await importExternalSources(dir, configuredExternalSources, {
@@ -751,7 +988,7 @@ export async function cmdPopulateCli(opts) {
751
988
  console.log(`foreman tickets: ${validation.issues.length} validation issue(s) found:`);
752
989
  console.log(formatValidationIssues(validation.issues));
753
990
  if (!validation.clean)
754
- process.exit(1);
991
+ throw new Error("external ticket import failed tracker validation");
755
992
  }
756
993
  if (!sourceHints.length) {
757
994
  console.log(`foreman tickets: imported external tickets and rendered ${ticketsConfig.paths.progressDoc}`);
@@ -769,7 +1006,7 @@ export async function cmdPopulateCli(opts) {
769
1006
  });
770
1007
  if (isCancel(action) || action === "cancel") {
771
1008
  console.log("foreman tickets: cancelled");
772
- process.exit(0);
1009
+ return;
773
1010
  }
774
1011
  }
775
1012
  const logPath = makeLogPath(dir, "tickets-populate");
@@ -811,12 +1048,10 @@ export async function cmdPopulateCli(opts) {
811
1048
  fail(`builder turn errored: ${turn.result.text.slice(0, 200)}`);
812
1049
  }
813
1050
  if (turn.status.kind === "blocked") {
814
- console.error(`foreman tickets: blocked — ${turn.status.reason ?? "builder reported blocked"}`);
815
- process.exit(2);
1051
+ throw new Error(`blocked — ${turn.status.reason ?? "builder reported blocked"}`);
816
1052
  }
817
1053
  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);
1054
+ throw new Error(`needs human — ${turn.status.error ?? "builder did not emit done"}`);
820
1055
  }
821
1056
  const existing = loadTickets(resolveTicketPaths(ticketsConfig, dir).tickets);
822
1057
  const proposal = extractTicketPopulationProposal(turn.result.text);
@@ -874,8 +1109,126 @@ function loadPopulationPlan(projectDir, sources) {
874
1109
  }
875
1110
  throw new Error("ticket population requires the approved paired structured plan (normally docs/rafi-plan.json); run `rafi plan --validate` first");
876
1111
  }
877
- export function buildTicketsCommand() {
1112
+ function ticketOwnershipReceipts(projectDir, commandName, _afterWrite) {
1113
+ const candidates = [
1114
+ ".tickets/config.yaml", ".tickets/tickets.yaml", ".tickets/delivery.yaml", ".tickets/tracker-rules.md",
1115
+ ".tickets/schema/tickets.schema.json", ".tickets/migrations/001_init.sql", ".tickets/.gitignore",
1116
+ ];
1117
+ if (isTicketsInitialized(projectDir)) {
1118
+ const config = loadTicketsConfig(projectDir);
1119
+ candidates.push(config.paths.progressDoc, config.paths.archiveDoc);
1120
+ }
1121
+ else {
1122
+ candidates.push(DEFAULT_TICKETS_CONFIG.paths.progressDoc, DEFAULT_TICKETS_CONFIG.paths.archiveDoc);
1123
+ }
1124
+ return unique(candidates).filter((path) => existsSync(join(projectDir, path))).map((path) => ({ path, category: "tickets", origin: `tickets:${commandName}` }));
1125
+ }
1126
+ async function runGroupResetCli(projectDir, opts) {
1127
+ const requested = opts.allGroups
1128
+ ? { kind: "all-groups" }
1129
+ : opts.recentGroups !== undefined
1130
+ ? { kind: "recent-groups", count: parsePositiveInteger(String(opts.recentGroups), "--recent-groups") }
1131
+ : opts.group !== undefined
1132
+ ? { kind: "group", groupId: parseTicketGroupId(String(opts.group)) }
1133
+ : { kind: "group-index", position: parsePositiveInteger(String(opts.groupIndex), "--group-index") };
1134
+ const explicitPolicy = opts.deletedTickets === undefined ? undefined : parseDeletedTicketPolicy(String(opts.deletedTickets));
1135
+ const initial = resolveTicketResetSelection(projectDir, requested, { deletedTickets: "ignore" });
1136
+ const missing = initial.previewRows.filter((row) => row.definitionMissing);
1137
+ if (missing.length && !explicitPolicy && (!process.stdin.isTTY || !process.stdout.isTTY)) {
1138
+ throw new Error("group reset includes deleted ticket definitions; automation must pass --deleted-tickets ignore|restore");
1139
+ }
1140
+ let policy = explicitPolicy;
1141
+ const perTicket = {};
1142
+ const restoreDependencies = [];
1143
+ if (missing.length && !policy) {
1144
+ const answer = await select({ message: `${missing.length} group member definition(s) are missing. What should reset do?`, options: [
1145
+ { value: "ignore", label: "Ignore all missing definitions (Recommended)" },
1146
+ { value: "restore", label: "Restore all from Rafi snapshots" },
1147
+ { value: "each", label: "Decide each one" },
1148
+ ] });
1149
+ if (isCancel(answer)) {
1150
+ console.log("foreman tickets: reset cancelled; nothing changed");
1151
+ return;
1152
+ }
1153
+ if (answer === "each") {
1154
+ for (const row of missing) {
1155
+ const decision = await select({ message: `${row.ticketId}: ${row.title}`, options: [
1156
+ { value: "ignore", label: "Ignore missing definition (Recommended)" }, { value: "restore", label: "Restore definition" },
1157
+ ] });
1158
+ if (isCancel(decision)) {
1159
+ console.log("foreman tickets: reset cancelled; nothing changed");
1160
+ return;
1161
+ }
1162
+ perTicket[row.ticketId] = decision;
1163
+ }
1164
+ policy = "ignore";
1165
+ }
1166
+ else
1167
+ policy = answer;
1168
+ }
1169
+ let selection;
1170
+ while (true) {
1171
+ try {
1172
+ selection = resolveTicketResetSelection(projectDir, requested, { deletedTickets: policy ?? "ignore", perTicket, restoreDependencies });
1173
+ break;
1174
+ }
1175
+ catch (error) {
1176
+ if (!(error instanceof TicketResetDependencyConflictError) || !process.stdin.isTTY || !process.stdout.isTTY)
1177
+ throw error;
1178
+ for (const conflict of error.conflicts) {
1179
+ const decision = await select({ message: `${conflict.ticketId} depends on deleted definition ${conflict.dependencyId}.`, options: [
1180
+ { value: "dependency", label: `Restore ${conflict.dependencyId} without resetting it (Recommended)` },
1181
+ { value: "skip", label: `Skip restoring ${conflict.ticketId}` },
1182
+ { value: "cancel", label: "Cancel reset" },
1183
+ ] });
1184
+ if (isCancel(decision) || decision === "cancel") {
1185
+ console.log("foreman tickets: reset cancelled; nothing changed");
1186
+ return;
1187
+ }
1188
+ if (decision === "dependency")
1189
+ restoreDependencies.push(conflict.dependencyId);
1190
+ else
1191
+ perTicket[conflict.ticketId] = "ignore";
1192
+ }
1193
+ }
1194
+ }
1195
+ console.log(`foreman tickets reset preview (${selection.groups.map((group) => group.id).join(", ") || requested.kind}):`);
1196
+ for (const row of selection.previewRows)
1197
+ console.log(` ${row.ticketId}: ${row.title} [${row.status}]${row.definitionMissing ? row.restoreDefinition ? " — restore definition and reset" : " — missing definition, skip" : " — reset active state"}`);
1198
+ for (const dependency of selection.definitionRestorations.filter((item) => item.dependencyOnly))
1199
+ console.log(` dependency ${dependency.ticketId}: restore definition only (do not reset)`);
1200
+ if (!selection.previewRows.length)
1201
+ return;
1202
+ if (!opts.yes) {
1203
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
1204
+ throw new Error("confirmation requires a TTY or --yes");
1205
+ const approved = await confirm({ message: `Apply this frozen ${selection.previewRows.length}-member reset selection?`, initialValue: false });
1206
+ if (isCancel(approved) || !approved) {
1207
+ console.log("foreman tickets: reset cancelled; nothing changed");
1208
+ return;
1209
+ }
1210
+ }
1211
+ const result = applyResolvedTicketReset(projectDir, selection, String(opts.actor ?? "user"));
1212
+ console.log(`foreman tickets: reset ${result.tickets.length} ticket(s), restored ${result.restored.length}, ignored ${result.ignoredMissing.length} missing (audit ${result.resetId})`);
1213
+ }
1214
+ export function buildTicketsCommand(options = {}) {
878
1215
  const tickets = new Command("tickets").description("Manage the structured ticket tracker for a project.");
1216
+ tickets.hook("preAction", (_thisCommand, actionCommand) => {
1217
+ const commandOptions = actionCommand.opts();
1218
+ const projectDir = cwd(commandOptions);
1219
+ recoverTicketPublications(projectDir);
1220
+ recoverTicketResetPublications(projectDir);
1221
+ if (options.prepareOwnership && !["show", "validate", "queue"].includes(actionCommand.name())) {
1222
+ options.prepareOwnership(projectDir, ticketOwnershipReceipts(projectDir, actionCommand.name(), false));
1223
+ }
1224
+ });
1225
+ tickets.hook("postAction", (_thisCommand, actionCommand) => {
1226
+ if (!options.recordOwnership)
1227
+ return;
1228
+ const commandOptions = actionCommand.opts();
1229
+ const projectDir = cwd(commandOptions);
1230
+ options.recordOwnership(projectDir, ticketOwnershipReceipts(projectDir, actionCommand.name(), true));
1231
+ });
879
1232
  // ── setup:init / setup:update ─────────────────────────────────────────────
880
1233
  tickets
881
1234
  .command("setup:init")
@@ -895,6 +1248,7 @@ export function buildTicketsCommand() {
895
1248
  .option("--url-source <urls...>", "add public HTTP(S) source URLs")
896
1249
  .option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
897
1250
  .option("--branch-strategy <strategy>", "build branch strategy default (current | batch | branch-per-ticket)")
1251
+ .option("--branch-prefix <prefix>", "prefix for Rafi-generated branches (for example team/feature)")
898
1252
  .option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
899
1253
  .option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
900
1254
  .option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
@@ -905,6 +1259,10 @@ export function buildTicketsCommand() {
905
1259
  await cmdSetupInitCli(opts);
906
1260
  }
907
1261
  catch (err) {
1262
+ if (err instanceof TicketInterviewCancelled) {
1263
+ console.log("foreman tickets setup: cancelled; saved checkpoints were retained");
1264
+ return;
1265
+ }
908
1266
  fail(String(err instanceof Error ? err.message : err));
909
1267
  }
910
1268
  });
@@ -926,6 +1284,7 @@ export function buildTicketsCommand() {
926
1284
  .option("--url-source <urls...>", "replace saved sources with public HTTP(S) URLs")
927
1285
  .option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
928
1286
  .option("--branch-strategy <strategy>", "build branch strategy default (current | batch | branch-per-ticket)")
1287
+ .option("--branch-prefix <prefix>", "prefix for Rafi-generated branches (for example team/feature)")
929
1288
  .option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
930
1289
  .option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
931
1290
  .option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
@@ -936,6 +1295,10 @@ export function buildTicketsCommand() {
936
1295
  await cmdSetupUpdateCli(opts);
937
1296
  }
938
1297
  catch (err) {
1298
+ if (err instanceof TicketInterviewCancelled) {
1299
+ console.log("foreman tickets setup: cancelled; saved checkpoints were retained");
1300
+ return;
1301
+ }
939
1302
  fail(String(err instanceof Error ? err.message : err));
940
1303
  }
941
1304
  });
@@ -969,9 +1332,24 @@ export function buildTicketsCommand() {
969
1332
  docsRoot: opts.docsRoot,
970
1333
  });
971
1334
  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\``);
1335
+ if (shouldPrompt(opts)) {
1336
+ const populate = await confirm({ message: "Populate tickets now?", initialValue: true });
1337
+ if (!isCancel(populate) && populate)
1338
+ await runNestedTicketCommand(() => cmdPopulateCli({ project: dir }));
1339
+ else {
1340
+ console.log("foreman tickets: population is the final prerequisite before building.");
1341
+ console.log(`foreman tickets: run \`rafi tickets populate --project ${shellQuote(dir)}\` later.`);
1342
+ }
1343
+ }
1344
+ else {
1345
+ console.log(`foreman tickets: next — run \`rafi tickets populate --project ${shellQuote(dir)}\``);
1346
+ }
973
1347
  }
974
1348
  catch (err) {
1349
+ if (err instanceof TicketInterviewCancelled) {
1350
+ console.log("foreman tickets: initialization cancelled; no process exit was forced by the nested interview");
1351
+ return;
1352
+ }
975
1353
  fail(String(err instanceof Error ? err.message : err));
976
1354
  }
977
1355
  });
@@ -996,6 +1374,163 @@ export function buildTicketsCommand() {
996
1374
  }
997
1375
  });
998
1376
  // ── update ──────────────────────────────────────────────────────────────────
1377
+ tickets
1378
+ .command("show <ticketId>")
1379
+ .description("Show the complete canonical ticket, active state, validation, delivery, and history.")
1380
+ .option("-p, --project <dir>", "project directory (default: cwd)")
1381
+ .option("--json", "write the complete stable record as JSON")
1382
+ .action((ticketId, opts) => {
1383
+ try {
1384
+ const details = getTicketDetails(cwd(opts), ticketId);
1385
+ if (opts.json)
1386
+ process.stdout.write(`${JSON.stringify(details, null, 2)}\n`);
1387
+ else
1388
+ for (const line of formatTicketDetails(details))
1389
+ console.log(line);
1390
+ }
1391
+ catch (err) {
1392
+ fail(String(err instanceof Error ? err.message : err));
1393
+ }
1394
+ });
1395
+ const groups = tickets.command("groups").description("List and repair durable ticket creation groups.");
1396
+ groups
1397
+ .command("list")
1398
+ .description("List immutable ticket creation groups newest first.")
1399
+ .option("-p, --project <dir>", "project directory (default: cwd)")
1400
+ .option("--json", "write the versioned stable JSON envelope")
1401
+ .action((opts) => {
1402
+ try {
1403
+ const envelope = listTicketGroups(cwd(opts));
1404
+ if (opts.json) {
1405
+ process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
1406
+ return;
1407
+ }
1408
+ if (!envelope.groups.length) {
1409
+ console.log("foreman tickets groups: no creation groups");
1410
+ return;
1411
+ }
1412
+ for (const group of envelope.groups) {
1413
+ const totals = Object.entries(group.statusTotals).map(([status, count]) => `${status}=${count}`).join(", ");
1414
+ console.log(`${group.id} index=${group.recencyPosition} origin=${group.origin} created=${group.createdAt}${group.legacy ? " legacy" : ""} ${totals}`);
1415
+ for (const member of group.members)
1416
+ console.log(` ${member.position}. ${member.ticketId} [${member.status}]${member.definitionMissing ? " — definition missing (recoverable)" : ""}`);
1417
+ for (const run of group.recoverableRuns)
1418
+ console.log(` recoverable run ${run.runId} [${run.status}] ${run.tickets.join(", ")}`);
1419
+ }
1420
+ }
1421
+ catch (error) {
1422
+ fail(String(error instanceof Error ? error.message : error));
1423
+ }
1424
+ });
1425
+ groups
1426
+ .command("repair")
1427
+ .description("Preview and group tickets created outside Rafi after the legacy migration.")
1428
+ .option("-p, --project <dir>", "project directory (default: cwd)")
1429
+ .option("-y, --yes", "approve the exact preview")
1430
+ .action(async (opts) => {
1431
+ try {
1432
+ const dir = cwd(opts);
1433
+ const preview = previewTicketGroupRepair(dir);
1434
+ console.log("foreman tickets groups repair preview:");
1435
+ console.log(preview.ticketIds.length ? ` new repair group: ${preview.ticketIds.join(", ")}` : " no ungrouped tickets");
1436
+ if (!preview.ticketIds.length)
1437
+ return;
1438
+ if (!opts.yes) {
1439
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
1440
+ throw new Error("repair approval requires a TTY or --yes");
1441
+ const approved = await confirm({ message: `Create one repair group for exactly ${preview.ticketIds.length} ticket(s)?`, initialValue: false });
1442
+ if (isCancel(approved) || !approved) {
1443
+ console.log("foreman tickets groups: repair cancelled; nothing changed");
1444
+ return;
1445
+ }
1446
+ }
1447
+ const group = repairTicketGroups(dir, preview.inputFingerprint);
1448
+ console.log(group ? `foreman tickets groups: created ${group.id} for ${group.members.map((member) => member.ticketId).join(", ")}` : "foreman tickets groups: nothing to repair");
1449
+ }
1450
+ catch (error) {
1451
+ fail(String(error instanceof Error ? error.message : error));
1452
+ }
1453
+ });
1454
+ tickets
1455
+ .command("reset [ticketId]")
1456
+ .description("Reset one ticket or an explicit ticket scope to pristine active state while retaining history.")
1457
+ .option("-p, --project <dir>", "project directory (default: cwd)")
1458
+ .option("--all", "open the interactive bulk reset scope chooser")
1459
+ .option("--scope <scope>", "bulk scope (all | completed-and-unfinished | unfinished)")
1460
+ .option("--all-groups", "reset the exact membership of every durable creation group")
1461
+ .option("--recent-groups <count>", "reset the exact membership of the newest N creation groups")
1462
+ .option("--group <TG-N>", "reset one durable creation group by stable ID")
1463
+ .option("--group-index <position>", "reset one creation group by current newest-first position")
1464
+ .option("--deleted-tickets <policy>", "missing-definition policy for automation (ignore | restore)")
1465
+ .option("--actor <actor>", "who requested the reset", "user")
1466
+ .option("-y, --yes", "confirm an explicit ticket or --scope without prompting")
1467
+ .action(async (ticketId, opts) => {
1468
+ try {
1469
+ const groupTargetCount = [opts.allGroups, opts.recentGroups, opts.group, opts.groupIndex].filter((value) => value !== undefined && value !== false).length;
1470
+ if (groupTargetCount > 0) {
1471
+ if (groupTargetCount !== 1)
1472
+ throw new Error("choose exactly one group reset target");
1473
+ if (ticketId || opts.all || opts.scope)
1474
+ throw new Error("group reset targets cannot be combined with a ticket or scope target");
1475
+ await runGroupResetCli(cwd(opts), opts);
1476
+ return;
1477
+ }
1478
+ if (ticketId && (opts.all || opts.scope))
1479
+ throw new Error("provide a ticket ID or a bulk scope, not both");
1480
+ if (!ticketId && !opts.all && !opts.scope)
1481
+ throw new Error("provide <ticketId>, --all, --scope <scope>, or one group target");
1482
+ let target = ticketId;
1483
+ if (opts.scope)
1484
+ target = parseResetScope(String(opts.scope));
1485
+ if (opts.all && !opts.scope) {
1486
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
1487
+ throw new Error("--all requires an interactive terminal; use --scope <scope> --yes for automation");
1488
+ const answer = await select({
1489
+ message: "Which tickets should return to an unworked state?",
1490
+ options: [
1491
+ { value: "all", label: "All tickets" },
1492
+ { value: "completed-and-unfinished", label: "Completed and unfinished (exclude canceled/obsolete)" },
1493
+ { value: "unfinished", label: "Unfinished only (exclude done/canceled/obsolete)" },
1494
+ ],
1495
+ });
1496
+ if (isCancel(answer)) {
1497
+ console.log("foreman tickets: reset cancelled; nothing changed");
1498
+ return;
1499
+ }
1500
+ target = answer;
1501
+ }
1502
+ if (!target)
1503
+ throw new Error("reset target was not selected");
1504
+ const preview = previewTicketReset(cwd(opts), target);
1505
+ console.log(`foreman tickets reset preview (${preview.scope}):`);
1506
+ if (!preview.tickets.length)
1507
+ console.log(" (no matching tickets)");
1508
+ for (const row of preview.tickets)
1509
+ console.log(` ${row.id}: ${row.title} [${row.status}] — clears ${row.cleared.join(", ")}`);
1510
+ if (preview.relatedRuns.length) {
1511
+ console.log(" ! Matching build/recovery metadata is not deleted. Use `rafi build:start-over` for a coherent whole-run restart.");
1512
+ for (const run of preview.relatedRuns)
1513
+ console.log(` ${run.runId.slice(0, 8)} [${run.status}] ${run.tickets.join(", ")}`);
1514
+ }
1515
+ if (!preview.tickets.length)
1516
+ return;
1517
+ const explicitlyConfirmed = Boolean(opts.yes && (ticketId || opts.scope));
1518
+ if (!explicitlyConfirmed) {
1519
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
1520
+ throw new Error("confirmation requires a TTY; pass --yes with an explicit ticket or --scope");
1521
+ const approved = await confirm({ message: `Reset ${preview.tickets.length} ticket(s)? Historical events and validation remain available.`, initialValue: false });
1522
+ if (isCancel(approved) || !approved) {
1523
+ console.log("foreman tickets: reset cancelled; nothing changed");
1524
+ return;
1525
+ }
1526
+ }
1527
+ const result = resetTickets(cwd(opts), target, String(opts.actor));
1528
+ console.log(`foreman tickets: reset ${result.tickets.length} ticket(s) (audit ${result.resetId})`);
1529
+ }
1530
+ catch (err) {
1531
+ fail(String(err instanceof Error ? err.message : err));
1532
+ }
1533
+ });
999
1534
  tickets
1000
1535
  .command("update <ticketId>")
1001
1536
  .description("Update ticket status or progress fields.")