crewly 1.20.213 → 1.20.215

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 (91) hide show
  1. package/config/skills/agent/core/project-tickets/SKILL.md +42 -10
  2. package/config/skills/agent/core/project-tickets/execute.sh +45 -12
  3. package/config/skills/agent/core/project-tickets/execute.test.sh +19 -0
  4. package/dist/backend/backend/src/constants.d.ts +124 -9
  5. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  6. package/dist/backend/backend/src/constants.js +152 -8
  7. package/dist/backend/backend/src/constants.js.map +1 -1
  8. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.d.ts +9 -1
  9. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.d.ts.map +1 -1
  10. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.js +13 -1
  11. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.js.map +1 -1
  12. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.routes.d.ts +2 -1
  13. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.routes.d.ts.map +1 -1
  14. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.routes.js +4 -2
  15. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.routes.js.map +1 -1
  16. package/dist/backend/backend/src/controllers/talk/talk-transcribe.controller.d.ts +37 -0
  17. package/dist/backend/backend/src/controllers/talk/talk-transcribe.controller.d.ts.map +1 -0
  18. package/dist/backend/backend/src/controllers/talk/talk-transcribe.controller.js +97 -0
  19. package/dist/backend/backend/src/controllers/talk/talk-transcribe.controller.js.map +1 -0
  20. package/dist/backend/backend/src/controllers/talk/talk-transcribe.routes.d.ts +18 -0
  21. package/dist/backend/backend/src/controllers/talk/talk-transcribe.routes.d.ts.map +1 -0
  22. package/dist/backend/backend/src/controllers/talk/talk-transcribe.routes.js +25 -0
  23. package/dist/backend/backend/src/controllers/talk/talk-transcribe.routes.js.map +1 -0
  24. package/dist/backend/backend/src/index.d.ts.map +1 -1
  25. package/dist/backend/backend/src/index.js +22 -4
  26. package/dist/backend/backend/src/index.js.map +1 -1
  27. package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
  28. package/dist/backend/backend/src/routes/api.routes.js +4 -0
  29. package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
  30. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
  31. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +6 -0
  32. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
  33. package/dist/backend/backend/src/services/project-tickets/autopilot-speed-command.d.ts +108 -0
  34. package/dist/backend/backend/src/services/project-tickets/autopilot-speed-command.d.ts.map +1 -0
  35. package/dist/backend/backend/src/services/project-tickets/autopilot-speed-command.js +223 -0
  36. package/dist/backend/backend/src/services/project-tickets/autopilot-speed-command.js.map +1 -0
  37. package/dist/backend/backend/src/services/project-tickets/autopilot-speed.wiring.d.ts +57 -0
  38. package/dist/backend/backend/src/services/project-tickets/autopilot-speed.wiring.d.ts.map +1 -0
  39. package/dist/backend/backend/src/services/project-tickets/autopilot-speed.wiring.js +90 -0
  40. package/dist/backend/backend/src/services/project-tickets/autopilot-speed.wiring.js.map +1 -0
  41. package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.d.ts +18 -4
  42. package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.d.ts.map +1 -1
  43. package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.js +18 -4
  44. package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.js.map +1 -1
  45. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.d.ts +135 -14
  46. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.d.ts.map +1 -1
  47. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.js +111 -18
  48. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.js.map +1 -1
  49. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.d.ts +67 -1
  50. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.d.ts.map +1 -1
  51. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.js +75 -1
  52. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.js.map +1 -1
  53. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-trace.d.ts +1 -1
  54. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-trace.d.ts.map +1 -1
  55. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-trace.js +7 -0
  56. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-trace.js.map +1 -1
  57. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.d.ts +99 -4
  58. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.d.ts.map +1 -1
  59. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.js +422 -11
  60. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.js.map +1 -1
  61. package/dist/backend/backend/src/services/slack/slack-initializer.d.ts.map +1 -1
  62. package/dist/backend/backend/src/services/slack/slack-initializer.js +2 -1
  63. package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
  64. package/dist/backend/backend/src/services/talk/talk-transcribe.service.d.ts +344 -0
  65. package/dist/backend/backend/src/services/talk/talk-transcribe.service.d.ts.map +1 -0
  66. package/dist/backend/backend/src/services/talk/talk-transcribe.service.js +642 -0
  67. package/dist/backend/backend/src/services/talk/talk-transcribe.service.js.map +1 -0
  68. package/dist/backend/backend/src/types/ticket-autopilot.types.d.ts +46 -4
  69. package/dist/backend/backend/src/types/ticket-autopilot.types.d.ts.map +1 -1
  70. package/dist/backend/backend/src/types/ticket-autopilot.types.js +43 -2
  71. package/dist/backend/backend/src/types/ticket-autopilot.types.js.map +1 -1
  72. package/dist/backend/backend/src/types/v2/work-item.types.d.ts +1 -1
  73. package/dist/backend/backend/src/types/v2/work-item.types.d.ts.map +1 -1
  74. package/dist/backend/backend/src/types/v2/work-item.types.js +1 -0
  75. package/dist/backend/backend/src/types/v2/work-item.types.js.map +1 -1
  76. package/dist/backend/build-info.json +2 -2
  77. package/dist/cli/backend/src/constants.d.ts +124 -9
  78. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  79. package/dist/cli/backend/src/constants.js +152 -8
  80. package/dist/cli/backend/src/constants.js.map +1 -1
  81. package/dist/cli/backend/src/types/ticket-autopilot.types.d.ts +46 -4
  82. package/dist/cli/backend/src/types/ticket-autopilot.types.d.ts.map +1 -1
  83. package/dist/cli/backend/src/types/ticket-autopilot.types.js +43 -2
  84. package/dist/cli/backend/src/types/ticket-autopilot.types.js.map +1 -1
  85. package/dist/cli/backend/src/types/v2/work-item.types.d.ts +1 -1
  86. package/dist/cli/backend/src/types/v2/work-item.types.d.ts.map +1 -1
  87. package/dist/cli/backend/src/types/v2/work-item.types.js +1 -0
  88. package/dist/cli/backend/src/types/v2/work-item.types.js.map +1 -1
  89. package/frontend/dist/assets/{index-917db1b8.js → index-839f4374.js} +272 -272
  90. package/frontend/dist/index.html +1 -1
  91. package/package.json +1 -1
@@ -16,7 +16,13 @@
16
16
  * and in-progress brakes; specs/2026-10-04-autopilot-goal-replan.md);
17
17
  * - owner questions are decision cards (specs/2026-10-01-decision-cards.md),
18
18
  * posted by the asking agent in the ticket's thread; the owner gets one
19
- * evening digest (skipped when nothing changed) that links to open cards.
19
+ * evening digest (skipped when nothing changed) that links to open cards;
20
+ * - a speed mode (Rush / Normal / Chill, specs/2026-10-04-autopilot-speed-modes.md)
21
+ * sets the replan gap and daily cap, the retry after an empty replan, the
22
+ * driver's self-review cadence and the default budget; tickets a replan
23
+ * opens must name the goal metric they move; and why the autopilot stopped
24
+ * (paused / budget / system error / waiting on the owner / no ideas) is
25
+ * traced and shown in the status and the digest.
20
26
  *
21
27
  * The approval boundary is unchanged: the autopilot only wakes the lead and
22
28
  * talks to the owner; it never makes a ticket ready or starts work itself.
@@ -33,8 +39,8 @@ import { ProjectTicketError } from './project-ticket.service.js';
33
39
  import { getTeamLeads } from '../../utils/team.utils.js';
34
40
  import { isTeamPausedNow } from '../team/team-pause.registry.js';
35
41
  import { isTeamLead, } from './project-ticket-workflow.service.js';
36
- import { OPEN_TICKET_STATUSES, closedTicketsSince, decideDigest, decideReplan, decideTriage, nextReplanBackoff, replanBackoffState, hasNeedsOwnerLabel, inFlightByAssignee, isMemberIdle, localDateKey, memberAvailability, memberResponsibility, localMidnight, selectTriageCandidates, } from './ticket-autopilot-decision.js';
37
- import { buildBudgetPausedMessage, buildDigestMessage, buildReplanBrief, buildTriageBrief, REPLAN_ASK, } from './ticket-autopilot-messages.js';
42
+ import { OPEN_TICKET_STATUSES, classifyStopReason, closedTicketsSince, decideDigest, decideReplan, decideSelfReview, decideTriage, nextReplanBackoff, replanBackoffHolds, replanBackoffState, ticketMetricRef, hasNeedsOwnerLabel, inFlightByAssignee, isMemberIdle, localDateKey, memberAvailability, memberResponsibility, localMidnight, selectTriageCandidates, } from './ticket-autopilot-decision.js';
43
+ import { buildBudgetPausedMessage, buildDigestMessage, buildReplanBrief, buildSelfReviewBrief, buildTriageBrief, REPLAN_ASK, replanMetricRejection, STOP_REASON_WORDS, } from './ticket-autopilot-messages.js';
38
44
  import { autopilotRunTrace, startReplanTrace, startTriageTrace, traceAutopilotAction, createAutopilotTicketListener } from './ticket-autopilot-trace.js';
39
45
  import { computeAutopilotStats, dayEndMs, daysBetween, dayStartMs, rangeDays, } from './ticket-autopilot-stats.js';
40
46
  import { buildRetroBrief, duplicateOf, renderRetroMarkdown, RetroInputError, validateRetroInput, } from './ticket-autopilot-retro.js';
@@ -156,8 +162,48 @@ export class TicketAutopilotService {
156
162
  const settings = resolveTicketAutopilotSettings(project.ticketAutopilot);
157
163
  return settings.enabled ? settings.maxInFlightPerMember : null;
158
164
  },
165
+ checkCreate: (project, caller, input) => this.checkReplanTicket(project, caller, input),
159
166
  };
160
167
  }
168
+ /**
169
+ * The metric rule of replan tickets (specs/2026-10-04-autopilot-speed-modes.md §3):
170
+ * a ticket created by the driver of a live goal replan of the project must
171
+ * name the goal metric it moves. The owner's, the orchestrator's and any
172
+ * ticket created outside a replan are never checked.
173
+ *
174
+ * @param project - Project
175
+ * @param caller - Who creates the ticket
176
+ * @param input - The create input
177
+ * @returns Why it is refused (sent back to the agent), or null to allow it
178
+ */
179
+ async checkReplanTicket(project, caller, input) {
180
+ if (!caller.session || caller.session === ORCHESTRATOR_SESSION_NAME)
181
+ return null;
182
+ const settings = resolveTicketAutopilotSettings(project.ticketAutopilot);
183
+ if (!settings.enabled)
184
+ return null;
185
+ const nowMs = this.now().getTime();
186
+ const ttlMs = settings.replanTtlHours * 60 * 60 * 1000;
187
+ const replan = (await this.deps.pool.getAllItems()).find((wi) => wi.metadata?.kind === TICKET_AUTOPILOT_CONSTANTS.REPLAN_METADATA_KIND &&
188
+ wi.metadata?.projectId === project.id &&
189
+ wi.target === caller.session &&
190
+ LIVE_STATUSES.has(wi.status) &&
191
+ nowMs - (Date.parse(wi.createdAt) || nowMs) < ttlMs);
192
+ if (!replan)
193
+ return null;
194
+ if (ticketMetricRef(input))
195
+ return null;
196
+ traceAutopilotAction(project, 'replan_ticket_rejected', {
197
+ summary: `${project.name}: a replan ticket without a goal metric was refused ("${String(input.title ?? '').slice(0, 80)}")`,
198
+ outcome: 'blocked',
199
+ workItemId: replan.id,
200
+ session: caller.session,
201
+ data: { title: String(input.title ?? '').slice(0, 140) },
202
+ alsoTraceId: replan.traceId ?? null,
203
+ now: this.now(),
204
+ });
205
+ return replanMetricRejection(String(input.title ?? ''), project.name);
206
+ }
161
207
  // ---------------------------------------------------------------------------
162
208
  // Settings (owner / orchestrator only)
163
209
  // ---------------------------------------------------------------------------
@@ -203,8 +249,56 @@ export class TicketAutopilotService {
203
249
  by: caller.session ?? 'owner',
204
250
  settings: result.settings,
205
251
  });
252
+ const before = resolveTicketAutopilotSettings(project.ticketAutopilot);
253
+ const after = resolveTicketAutopilotSettings(result.settings);
254
+ if (before.speedMode !== after.speedMode)
255
+ await this.onSpeedModeChanged(updated, before.speedMode, after, caller);
206
256
  return this.statusOf(updated);
207
257
  }
258
+ /**
259
+ * A project's speed mode changed: re-time a backoff that still holds to the
260
+ * new mode's retry (switching to Rush must not wait out Normal's "next
261
+ * day"), and trace the change.
262
+ *
263
+ * @param project - The saved project
264
+ * @param from - Previous mode
265
+ * @param settings - New resolved settings
266
+ * @param caller - Who changed it
267
+ */
268
+ async onSpeedModeChanged(project, from, settings, caller) {
269
+ const state = await this.loadState();
270
+ const ps = state.projects[project.id];
271
+ const now = this.now();
272
+ const backoff = ps?.replanBackoff;
273
+ if (ps && backoff && replanBackoffHolds(backoff, now.getTime(), localDateKey(now))) {
274
+ const retry = settings.emptyReplanRetry;
275
+ let resumeAt;
276
+ if (retry.unit === 'hours')
277
+ resumeAt = backoff.since + retry.amount * 60 * 60 * 1000;
278
+ else {
279
+ const d = new Date(backoff.since);
280
+ resumeAt = new Date(d.getFullYear(), d.getMonth(), d.getDate() + Math.max(1, retry.amount), 0, 0, 0, 0).getTime();
281
+ }
282
+ ps.replanBackoff = { ...backoff, resumeAt, resumeDay: localDateKey(new Date(resumeAt)) };
283
+ await this.saveState();
284
+ }
285
+ traceAutopilotAction(project, 'mode_changed', {
286
+ summary: `${project.name}: autopilot speed ${from} → ${settings.speedMode}`,
287
+ outcome: 'ok',
288
+ ...(caller.session ? { session: caller.session, actor: { kind: 'agent', session: caller.session } } : { actor: { kind: 'owner' } }),
289
+ data: {
290
+ from,
291
+ to: settings.speedMode,
292
+ replansPerDay: settings.replansPerDay,
293
+ replanMinGapMs: settings.replanMinGapMs,
294
+ selfReviewEveryMs: settings.selfReviewEveryMs,
295
+ dailyBudgetTokens: settings.dailyBudgetTokens,
296
+ budgetSource: settings.budgetSource,
297
+ },
298
+ now,
299
+ });
300
+ this.logger.info('Ticket autopilot speed mode changed', { projectId: project.id, from, to: settings.speedMode, by: caller.session ?? 'owner' });
301
+ }
208
302
  // ---------------------------------------------------------------------------
209
303
  // Triggers
210
304
  // ---------------------------------------------------------------------------
@@ -228,6 +322,8 @@ export class TicketAutopilotService {
228
322
  }
229
323
  }
230
324
  if (projects.length > 0) {
325
+ await this.processStopReasons(projects).catch((err) => this.logger.warn('Autopilot stop-reason pass failed', { error: err instanceof Error ? err.message : String(err) }));
326
+ await this.processSelfReviews(projects).catch((err) => this.logger.warn('Autopilot self-review pass failed', { error: err instanceof Error ? err.message : String(err) }));
231
327
  await this.processDigest(projects).catch((err) => this.logger.warn('Ticket digest pass failed', { error: err instanceof Error ? err.message : String(err) }));
232
328
  await this.processRetros(projects).catch((err) => this.logger.warn('Autopilot retro pass failed', { error: err instanceof Error ? err.message : String(err) }));
233
329
  }
@@ -422,14 +518,18 @@ export class TicketAutopilotService {
422
518
  const held = (m) => Math.max(inFlight.get(sessionOf(m)) ?? 0, m.sessionName ? inFlight.get(m.sessionName) ?? 0 : 0, m.agentId ? inFlight.get(m.agentId) ?? 0 : 0);
423
519
  const idleWithRoom = members.some((m) => isIdle(m) && held(m) < c.settings.maxInFlightPerMember);
424
520
  const replansToday = c.ps.replans?.day === today ? c.ps.replans.count : 0;
425
- // The last replan is over: did it open tickets? None → back off longer.
426
- this.assessLastReplan(project, c.ps, c.tickets, c.liveReplanId, nowMs);
521
+ // The last replan is over: did it open tickets? None → wait for the mode's retry.
522
+ this.assessLastReplan(project, c.ps, c.tickets, c.liveReplanId, nowMs, c.settings.emptyReplanRetry);
427
523
  // Cheap gates first (in-memory state only); the goal is read last.
428
524
  const gates = {
429
525
  enabled: c.settings.enabled,
430
526
  driver: c.driver.session,
431
527
  maxReplansPerDay: c.settings.replansPerDay,
432
528
  replansToday,
529
+ // The speed mode's gap counts from the last replan, across days.
530
+ ...(c.ps.replans?.lastAt !== undefined ? { lastReplanAt: c.ps.replans.lastAt } : {}),
531
+ minGapMs: c.settings.replanMinGapMs,
532
+ now: nowMs,
433
533
  usedTodayTokens: c.spent,
434
534
  dailyBudgetTokens: c.budget,
435
535
  liveTriage: c.liveTriage,
@@ -450,7 +550,7 @@ export class TicketAutopilotService {
450
550
  let backedOff = false;
451
551
  if (c.ps.replanBackoff) {
452
552
  const changedAt = this.deps.goalChangedAt ? await this.deps.goalChangedAt(project).catch(() => null) : null;
453
- const state = replanBackoffState(c.ps.replanBackoff, { today, tickets: c.tickets, goalChangedAt: changedAt });
553
+ const state = replanBackoffState(c.ps.replanBackoff, { today, tickets: c.tickets, goalChangedAt: changedAt, now: nowMs });
454
554
  if (state === 'lifted') {
455
555
  this.logger.info('Goal replan backoff lifted (new ticket or goal change)', { projectId: project.id, streak: c.ps.replanBackoff.streak });
456
556
  delete c.ps.replanBackoff;
@@ -481,6 +581,7 @@ export class TicketAutopilotService {
481
581
  members: await this.briefMembers(c.teams, c.tickets, c.idleSession),
482
582
  maxInFlightPerMember: c.settings.maxInFlightPerMember,
483
583
  now: nowMs,
584
+ lastSelfReview: c.ps.selfReviews?.[c.ps.selfReviews.length - 1] ?? null,
484
585
  });
485
586
  const workItem = createWorkItem({
486
587
  type: C.REPLAN_WORK_ITEM_TYPE,
@@ -522,6 +623,7 @@ export class TicketAutopilotService {
522
623
  experiments: experiments.length,
523
624
  replansToday: count,
524
625
  replansPerDay: c.settings.replansPerDay,
626
+ speedMode: c.settings.speedMode,
525
627
  },
526
628
  alsoTraceId: workItem.traceId ?? null,
527
629
  now: c.now,
@@ -577,21 +679,23 @@ export class TicketAutopilotService {
577
679
  /**
578
680
  * Record the outcome of the last goal replan once it is no longer live:
579
681
  * a ticket created since it was queued clears the backoff; none ("there
580
- * are none") backs off one step longer (2, 4, 7 days).
682
+ * are none") waits for the speed mode's retry (Rush 1 h, Normal the next
683
+ * day, Chill a week).
581
684
  *
582
685
  * @param project - Project
583
686
  * @param ps - Its bookkeeping
584
687
  * @param tickets - Its tickets
585
688
  * @param liveReplanId - The live replan, if any
586
689
  * @param nowMs - Clock
690
+ * @param retry - The speed mode's retry after an empty replan
587
691
  */
588
- assessLastReplan(project, ps, tickets, liveReplanId, nowMs) {
692
+ assessLastReplan(project, ps, tickets, liveReplanId, nowMs, retry) {
589
693
  const last = ps.replans;
590
694
  if (!last?.lastWorkItemId || last.lastAt === undefined || last.assessed !== false)
591
695
  return;
592
696
  if (liveReplanId === last.lastWorkItemId)
593
697
  return;
594
- const next = nextReplanBackoff({ replanAt: last.lastAt, replanDay: last.day, tickets, previous: ps.replanBackoff, now: nowMs });
698
+ const next = nextReplanBackoff({ replanAt: last.lastAt, replanDay: last.day, tickets, previous: ps.replanBackoff, retry, now: nowMs });
595
699
  if (next)
596
700
  ps.replanBackoff = next;
597
701
  else
@@ -600,7 +704,7 @@ export class TicketAutopilotService {
600
704
  this.logger.info(next ? 'Goal replan opened no tickets: backing off' : 'Goal replan opened tickets', {
601
705
  projectId: project.id,
602
706
  workItemId: last.lastWorkItemId,
603
- ...(next ? { streak: next.streak, resumeDay: next.resumeDay } : {}),
707
+ ...(next ? { streak: next.streak, resumeDay: next.resumeDay, resumeAt: next.resumeAt } : {}),
604
708
  });
605
709
  }
606
710
  /**
@@ -712,12 +816,17 @@ export class TicketAutopilotService {
712
816
  links.set(t.id, link);
713
817
  }
714
818
  }
819
+ const ps = state.projects[project.id];
820
+ const review = ps?.selfReviews?.[ps.selfReviews.length - 1] ?? null;
821
+ const freshReview = review && now.getTime() - (Date.parse(review.at) || 0) <= TICKET_AUTOPILOT_CONSTANTS.SELF_REVIEW_DIGEST_MAX_AGE_MS ? review : null;
715
822
  sections.push({
716
823
  name: project.name,
717
824
  doneToday: tickets.filter((t) => t.status === 'done' && (Date.parse(t.updatedAt) || 0) >= midnight),
718
825
  inProgress: tickets.filter((t) => t.status === 'in_progress'),
719
826
  waitingOnOwner,
720
827
  ...(links.size > 0 ? { links } : {}),
828
+ ...(ps?.stop ? { stopReason: ps.stop.reason } : {}),
829
+ ...(freshReview ? { selfReview: freshReview } : {}),
721
830
  });
722
831
  }
723
832
  const message = buildDigestMessage(sections);
@@ -1428,6 +1537,297 @@ export class TicketAutopilotService {
1428
1537
  return null;
1429
1538
  }
1430
1539
  // ---------------------------------------------------------------------------
1540
+ // Stop reasons and self-review (specs/2026-10-04-autopilot-speed-modes.md)
1541
+ // ---------------------------------------------------------------------------
1542
+ /**
1543
+ * Why a project's autopilot is not producing work right now (see
1544
+ * {@link classifyStopReason}). Reads only: the pool, the tickets, the ledger.
1545
+ *
1546
+ * @param project - Project
1547
+ * @param ps - Its bookkeeping (listed tickets, replan backoff)
1548
+ * @param items - The pool's items (read once per pass)
1549
+ * @returns The reason, or null while running / between replans
1550
+ */
1551
+ async stopReasonOf(project, ps, items) {
1552
+ const C = TICKET_AUTOPILOT_CONSTANTS;
1553
+ const settings = resolveTicketAutopilotSettings(project.ticketAutopilot);
1554
+ const now = this.now();
1555
+ const nowMs = now.getTime();
1556
+ const allTeams = await this.projectTeams(project, { includePaused: true });
1557
+ const activeTeams = allTeams.filter((t) => !isTeamPausedNow(t));
1558
+ const spent = this.usedToday(allTeams);
1559
+ const budget = this.budgetToday(settings, allTeams).tokens;
1560
+ const { tickets } = await this.deps.tickets.list(project.path);
1561
+ const kinds = new Set([C.TRIAGE_METADATA_KIND, C.REPLAN_METADATA_KIND, C.SELF_REVIEW_METADATA_KIND]);
1562
+ const ttlMs = settings.replanTtlHours * 60 * 60 * 1000;
1563
+ const members = activeTeams.flatMap((t) => t.members ?? []);
1564
+ const idleSessions = new Set(members.filter((m) => isMemberIdle(m)).flatMap((m) => [sessionOf(m), m.sessionName, m.agentId].filter((x) => !!x)));
1565
+ let liveAutopilotItem = false;
1566
+ let stuckDelivery = false;
1567
+ let failedRecently = 0;
1568
+ for (const wi of items) {
1569
+ const md = wi.metadata ?? {};
1570
+ const link = md.projectTicket;
1571
+ const ofProject = md.projectId === project.id || (!!link?.projectPath && !!project.path && path.resolve(link.projectPath) === path.resolve(project.path));
1572
+ if (!ofProject)
1573
+ continue;
1574
+ const age = nowMs - (Date.parse(wi.createdAt) || nowMs);
1575
+ if (typeof md.kind === 'string' && kinds.has(md.kind) && LIVE_STATUSES.has(wi.status)) {
1576
+ // A live triage or replan is the autopilot producing work; a self-review is not (it never masks a stop).
1577
+ const producing = md.kind !== C.SELF_REVIEW_METADATA_KIND;
1578
+ if (producing && !(md.kind === C.REPLAN_METADATA_KIND && age >= ttlMs))
1579
+ liveAutopilotItem = true;
1580
+ // Queued for long while its target sits idle: the wake never landed.
1581
+ if (wi.status === 'queued' && age >= C.STOP_STUCK_DELIVERY_MS && !!wi.target && idleSessions.has(wi.target))
1582
+ stuckDelivery = true;
1583
+ }
1584
+ if (wi.status === 'failed') {
1585
+ const at = Date.parse(wi.statusChangedAt ?? wi.completedAt ?? wi.createdAt) || 0;
1586
+ if (nowMs - at <= C.STOP_SYSTEM_ERROR_LOOKBACK_MS)
1587
+ failedRecently += 1;
1588
+ }
1589
+ }
1590
+ const selection = selectTriageCandidates({ tickets, teams: activeTeams, now: nowMs, listed: ps.listed });
1591
+ const waitingOnOwner = tickets.filter((t) => t.status === 'review' ||
1592
+ (OPEN_TICKET_STATUSES.has(t.status) && (hasNeedsOwnerLabel(t) || t.labels.includes(C.RETRO_PENDING_LABEL)))).length;
1593
+ return classifyStopReason({
1594
+ teamsTotal: allTeams.length,
1595
+ teamsActive: activeTeams.length,
1596
+ usedTodayTokens: spent,
1597
+ dailyBudgetTokens: budget,
1598
+ inProgress: tickets.filter((t) => t.status === 'in_progress').length,
1599
+ ready: tickets.filter((t) => t.status === 'ready').length,
1600
+ toTriage: selection.candidates.length,
1601
+ liveAutopilotItem,
1602
+ failedRecently,
1603
+ stuckDelivery,
1604
+ waitingOnOwner,
1605
+ emptyReplanBackoff: replanBackoffHolds(ps.replanBackoff, nowMs, localDateKey(now)),
1606
+ }).reason;
1607
+ }
1608
+ /**
1609
+ * Classify every enabled project's stop reason and trace each change
1610
+ * (`stopped` with the reason, `resumed` when the work moves again).
1611
+ *
1612
+ * @param projects - Enabled projects
1613
+ */
1614
+ async processStopReasons(projects) {
1615
+ const state = await this.loadState();
1616
+ const items = await this.deps.pool.getAllItems();
1617
+ const now = this.now();
1618
+ let dirty = false;
1619
+ for (const project of projects) {
1620
+ const ps = (state.projects[project.id] ??= {});
1621
+ let reason;
1622
+ try {
1623
+ reason = await this.stopReasonOf(project, ps, items);
1624
+ }
1625
+ catch (err) {
1626
+ this.logger.warn('Could not classify the autopilot stop reason', { projectId: project.id, error: err instanceof Error ? err.message : String(err) });
1627
+ continue;
1628
+ }
1629
+ const previous = ps.stop?.reason ?? null;
1630
+ if (reason === previous)
1631
+ continue;
1632
+ dirty = true;
1633
+ if (reason) {
1634
+ ps.stop = { reason, since: now.getTime() };
1635
+ traceAutopilotAction(project, 'stopped', {
1636
+ summary: `${project.name}: autopilot stopped — ${STOP_REASON_WORDS[reason]}`,
1637
+ outcome: 'blocked',
1638
+ data: { reason, ...(previous ? { previous } : {}) },
1639
+ now,
1640
+ });
1641
+ this.logger.info('Ticket autopilot stopped', { projectId: project.id, reason });
1642
+ }
1643
+ else {
1644
+ const since = ps.stop?.since;
1645
+ delete ps.stop;
1646
+ traceAutopilotAction(project, 'resumed', {
1647
+ summary: `${project.name}: autopilot moving again (was: ${previous ? STOP_REASON_WORDS[previous] : 'stopped'})`,
1648
+ outcome: 'ok',
1649
+ data: { previous: previous ?? '', ...(since ? { stoppedMs: now.getTime() - since } : {}) },
1650
+ now,
1651
+ });
1652
+ this.logger.info('Ticket autopilot moving again', { projectId: project.id, previous });
1653
+ }
1654
+ }
1655
+ if (dirty)
1656
+ await this.saveState();
1657
+ }
1658
+ /**
1659
+ * Ask each enabled project's driver for a short self-review at the speed
1660
+ * mode's cadence (Rush hourly, Normal daily, Chill weekly): one live at a
1661
+ * time, never for a paused team (no driver), never over the budget, and
1662
+ * skipped when nothing changed since the last one and nobody is idle.
1663
+ * Projects without a goal get none.
1664
+ *
1665
+ * @param projects - Enabled projects
1666
+ * @returns WorkItems created
1667
+ */
1668
+ async processSelfReviews(projects) {
1669
+ const C = TICKET_AUTOPILOT_CONSTANTS;
1670
+ if (!this.deps.goalOf)
1671
+ return [];
1672
+ const state = await this.loadState();
1673
+ const now = this.now();
1674
+ const nowMs = now.getTime();
1675
+ const out = [];
1676
+ let items = null;
1677
+ for (const project of projects) {
1678
+ const settings = resolveTicketAutopilotSettings(project.ticketAutopilot);
1679
+ const ps = (state.projects[project.id] ??= {});
1680
+ const ask = ps.selfReviewAsk;
1681
+ // Not due: decided on bookkeeping alone, no reads.
1682
+ if (ask && nowMs - ask.at < settings.selfReviewEveryMs)
1683
+ continue;
1684
+ const teams = await this.projectTeams(project);
1685
+ const driver = this.resolveDriver(settings, teams);
1686
+ const spendTeams = await this.projectTeams(project, { includePaused: true });
1687
+ const spent = this.usedToday(spendTeams);
1688
+ const budget = this.budgetToday(settings, spendTeams).tokens;
1689
+ items ??= await this.deps.pool.getAllItems();
1690
+ let live = false;
1691
+ for (const wi of items) {
1692
+ if (wi.metadata?.kind !== C.SELF_REVIEW_METADATA_KIND || wi.metadata?.projectId !== project.id || !LIVE_STATUSES.has(wi.status))
1693
+ continue;
1694
+ const age = nowMs - (Date.parse(wi.createdAt) || nowMs);
1695
+ if (age < C.SELF_REVIEW_LIVE_MAX_MS)
1696
+ live = true;
1697
+ else if (wi.status === 'queued')
1698
+ await this.deps.pool.cancelQueued(wi.id, 'self-review never picked up; replaced at the next cadence').catch(() => undefined);
1699
+ }
1700
+ const { tickets } = await this.deps.tickets.list(project.path);
1701
+ const latestTicketAt = tickets.reduce((m, t) => Math.max(m, Date.parse(t.updatedAt) || 0), 0);
1702
+ const stopReason = ps.stop?.reason ?? null;
1703
+ let changed = !ask || latestTicketAt > ask.ticketsAt || stopReason !== ask.stopReason;
1704
+ if (!changed && ask && this.deps.goalChangedAt) {
1705
+ const goalAt = await this.deps.goalChangedAt(project).catch(() => null);
1706
+ changed = typeof goalAt === 'number' && goalAt > ask.at;
1707
+ }
1708
+ const members = teams.flatMap((t) => t.members ?? []);
1709
+ const decision = decideSelfReview({
1710
+ enabled: settings.enabled,
1711
+ driver: driver?.session ?? null,
1712
+ now: nowMs,
1713
+ everyMs: settings.selfReviewEveryMs,
1714
+ ...(ask ? { lastAskedAt: ask.at } : {}),
1715
+ live,
1716
+ usedTodayTokens: spent,
1717
+ dailyBudgetTokens: budget,
1718
+ changed,
1719
+ anyoneIdle: members.some((m) => isMemberIdle(m)),
1720
+ });
1721
+ if (decision.action === 'skip' || !driver) {
1722
+ this.logger.debug('Autopilot self-review not asked', { projectId: project.id, decision });
1723
+ continue;
1724
+ }
1725
+ const goal = await this.deps.goalOf(project, now).catch(() => null);
1726
+ if (!goal || !goal.text.trim()) {
1727
+ // No goal, nothing to review against: try again at the next cadence.
1728
+ ps.selfReviewAsk = { at: nowMs, workItemId: '', ticketsAt: latestTicketAt, stopReason };
1729
+ continue;
1730
+ }
1731
+ const since = ask?.at ?? nowMs - 24 * 60 * 60 * 1000;
1732
+ const cadence = settings.selfReviewEveryMs <= 60 * 60 * 1000 ? 'hourly' : settings.selfReviewEveryMs <= 24 * 60 * 60 * 1000 ? 'daily' : 'weekly';
1733
+ const brief = buildSelfReviewBrief({
1734
+ project: { id: project.id, name: project.name },
1735
+ mode: settings.speedMode,
1736
+ cadence,
1737
+ goal: goal.text,
1738
+ closedSince: tickets.filter((t) => (t.status === 'done' || t.status === 'cancelled') && (Date.parse(t.updatedAt) || 0) >= since).length,
1739
+ open: {
1740
+ ready: tickets.filter((t) => t.status === 'ready').length,
1741
+ inProgress: tickets.filter((t) => t.status === 'in_progress').length,
1742
+ backlog: tickets.filter((t) => t.status === 'backlog').length,
1743
+ waitingOnOwner: tickets.filter((t) => t.status === 'review' || (OPEN_TICKET_STATUSES.has(t.status) && hasNeedsOwnerLabel(t))).length,
1744
+ },
1745
+ stopReason,
1746
+ previous: ps.selfReviews?.[ps.selfReviews.length - 1] ?? null,
1747
+ });
1748
+ const workItem = createWorkItem({
1749
+ type: C.SELF_REVIEW_WORK_ITEM_TYPE,
1750
+ owner: 'team_lead',
1751
+ target: driver.session,
1752
+ title: `Self-review: ${project.name}`,
1753
+ description: `Short self-review of ${project.name} against its goal: the gap to the target, what moved it, and the next bet.`,
1754
+ briefMarkdown: capBrief(brief),
1755
+ metadata: {
1756
+ kind: C.SELF_REVIEW_METADATA_KIND,
1757
+ projectId: project.id,
1758
+ projectPath: project.path,
1759
+ teamId: driver.teamId,
1760
+ requiresVerification: false,
1761
+ speedMode: settings.speedMode,
1762
+ },
1763
+ });
1764
+ workItem.createdAt = now.toISOString();
1765
+ workItem.targetSource = 'assigned';
1766
+ await this.deps.pool.addToPool(workItem);
1767
+ ps.selfReviewAsk = { at: nowMs, workItemId: workItem.id, ticketsAt: latestTicketAt, stopReason };
1768
+ traceAutopilotAction(project, 'self_review_scheduled', {
1769
+ summary: `${project.name}: ${driver.session} asked for a ${cadence} self-review (${settings.speedMode})`,
1770
+ outcome: 'queued',
1771
+ workItemId: workItem.id,
1772
+ session: driver.session,
1773
+ data: { speedMode: settings.speedMode, cadence },
1774
+ now,
1775
+ });
1776
+ this.logger.info('Autopilot self-review asked', { projectId: project.id, driver: driver.session, workItemId: workItem.id, speedMode: settings.speedMode });
1777
+ out.push(workItem);
1778
+ }
1779
+ await this.saveState();
1780
+ return out;
1781
+ }
1782
+ /**
1783
+ * The driver filed a self-review: kept on the project (newest last,
1784
+ * capped), shown in the status and the digest, and quoted in the next
1785
+ * goal replan brief.
1786
+ *
1787
+ * @param ref - Project id, name or path
1788
+ * @param body - `{ gap, moved?, nextBet }`
1789
+ * @param caller - Owner, orchestrator or a lead of a project team
1790
+ * @returns The stored record
1791
+ * @throws ProjectTicketError(400/403/404)
1792
+ */
1793
+ async submitSelfReview(ref, body, caller) {
1794
+ const C = TICKET_AUTOPILOT_CONSTANTS;
1795
+ const project = await this.deps.workflow.resolveProject(ref);
1796
+ await this.requireReader(caller, project);
1797
+ const b = (body && typeof body === 'object' ? body : {});
1798
+ const field = (key, required) => {
1799
+ const raw = b[key];
1800
+ const v = typeof raw === 'string' ? raw.replace(/\s+/g, ' ').trim() : '';
1801
+ if (required && !v)
1802
+ throw new ProjectTicketError(400, `${key} is required`);
1803
+ if (v.length > C.SELF_REVIEW_FIELD_MAX_CHARS)
1804
+ throw new ProjectTicketError(400, `${key} is longer than ${C.SELF_REVIEW_FIELD_MAX_CHARS} characters`);
1805
+ return v;
1806
+ };
1807
+ const now = this.now();
1808
+ const record = {
1809
+ at: now.toISOString(),
1810
+ by: caller.session ?? 'owner',
1811
+ gap: field('gap', true),
1812
+ moved: field('moved', false),
1813
+ nextBet: field('nextBet', true),
1814
+ };
1815
+ const state = await this.loadState();
1816
+ const ps = (state.projects[project.id] ??= {});
1817
+ ps.selfReviews = [...(ps.selfReviews ?? []), record].slice(-C.SELF_REVIEW_HISTORY);
1818
+ await this.saveState();
1819
+ traceAutopilotAction(project, 'self_review_filed', {
1820
+ summary: `${record.by} filed a self-review of ${project.name}: next bet ${record.nextBet.slice(0, 120)}`,
1821
+ outcome: 'ok',
1822
+ ...(caller.session ? { session: caller.session, actor: { kind: 'agent', session: caller.session } } : { actor: { kind: 'owner' } }),
1823
+ ...(ps.selfReviewAsk?.workItemId ? { workItemId: ps.selfReviewAsk.workItemId } : {}),
1824
+ data: { gap: record.gap.slice(0, 200), nextBet: record.nextBet.slice(0, 200) },
1825
+ now,
1826
+ });
1827
+ this.logger.info('Autopilot self-review filed', { projectId: project.id, by: record.by });
1828
+ return record;
1829
+ }
1830
+ // ---------------------------------------------------------------------------
1431
1831
  // Helpers
1432
1832
  // ---------------------------------------------------------------------------
1433
1833
  /**
@@ -1465,8 +1865,19 @@ export class TicketAutopilotService {
1465
1865
  replanInFlight: liveOf(TICKET_AUTOPILOT_CONSTANTS.REPLAN_METADATA_KIND),
1466
1866
  replansToday: ps?.replans?.day === today ? ps.replans.count : 0,
1467
1867
  lastReplanAt: ps?.replans?.lastAt ? new Date(ps.replans.lastAt).toISOString() : null,
1468
- replanBackoffUntil: ps?.replanBackoff && today < ps.replanBackoff.resumeDay ? ps.replanBackoff.resumeDay : null,
1868
+ replanBackoffUntil: replanBackoffHolds(ps?.replanBackoff, nowMs, today) ? (ps?.replanBackoff?.resumeDay ?? null) : null,
1869
+ replanBackoffUntilAt: ps?.replanBackoff && replanBackoffHolds(ps.replanBackoff, nowMs, today)
1870
+ ? new Date(ps.replanBackoff.resumeAt ?? new Date(`${ps.replanBackoff.resumeDay}T00:00:00`).getTime()).toISOString()
1871
+ : null,
1469
1872
  retroOn: await this.retroOn(project),
1873
+ speedMode: settings.speedMode,
1874
+ stopReason: ps?.stop?.reason ?? null,
1875
+ stopReasonText: ps?.stop ? STOP_REASON_WORDS[ps.stop.reason] : null,
1876
+ stoppedSince: ps?.stop ? new Date(ps.stop.since).toISOString() : null,
1877
+ lastSelfReview: ps?.selfReviews?.[ps.selfReviews.length - 1] ?? null,
1878
+ nextSelfReviewAt: settings.enabled
1879
+ ? new Date(ps?.selfReviewAsk ? ps.selfReviewAsk.at + settings.selfReviewEveryMs : nowMs).toISOString()
1880
+ : null,
1470
1881
  };
1471
1882
  }
1472
1883
  /**