crewly 1.20.388 → 1.20.389

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 (167) hide show
  1. package/config/skills/agent/core/app-comments/execute.sh +3 -2
  2. package/config/skills/agent/core/app-comments/execute.test.sh +1 -1
  3. package/config/skills/team-leader/propose-tier-change/SKILL.md +16 -10
  4. package/config/skills/team-leader/propose-tier-change/execute.sh +4 -3
  5. package/dist/backend/backend/src/constants.d.ts +20 -0
  6. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  7. package/dist/backend/backend/src/constants.js +20 -0
  8. package/dist/backend/backend/src/constants.js.map +1 -1
  9. package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts +1 -1
  10. package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts.map +1 -1
  11. package/dist/backend/backend/src/controllers/apps/apps.controller.js +2 -2
  12. package/dist/backend/backend/src/controllers/apps/apps.controller.js.map +1 -1
  13. package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts.map +1 -1
  14. package/dist/backend/backend/src/controllers/chat/chat.controller.js +8 -0
  15. package/dist/backend/backend/src/controllers/chat/chat.controller.js.map +1 -1
  16. package/dist/backend/backend/src/controllers/owner-receipt/owner-receipt.controller.d.ts +4 -3
  17. package/dist/backend/backend/src/controllers/owner-receipt/owner-receipt.controller.d.ts.map +1 -1
  18. package/dist/backend/backend/src/controllers/owner-receipt/owner-receipt.controller.js +7 -3
  19. package/dist/backend/backend/src/controllers/owner-receipt/owner-receipt.controller.js.map +1 -1
  20. package/dist/backend/backend/src/controllers/team/team-model-tiers.controller.d.ts +2 -2
  21. package/dist/backend/backend/src/controllers/team/team-model-tiers.controller.js +3 -3
  22. package/dist/backend/backend/src/controllers/team/team-model-tiers.controller.js.map +1 -1
  23. package/dist/backend/backend/src/index.d.ts.map +1 -1
  24. package/dist/backend/backend/src/index.js +30 -18
  25. package/dist/backend/backend/src/index.js.map +1 -1
  26. package/dist/backend/backend/src/services/agent/member-relaunch.d.ts +84 -0
  27. package/dist/backend/backend/src/services/agent/member-relaunch.d.ts.map +1 -0
  28. package/dist/backend/backend/src/services/agent/member-relaunch.js +96 -0
  29. package/dist/backend/backend/src/services/agent/member-relaunch.js.map +1 -0
  30. package/dist/backend/backend/src/services/ai/prompt-builder.service.d.ts.map +1 -1
  31. package/dist/backend/backend/src/services/ai/prompt-builder.service.js +2 -0
  32. package/dist/backend/backend/src/services/ai/prompt-builder.service.js.map +1 -1
  33. package/dist/backend/backend/src/services/ai/prompt-modules/model-tiers.module.d.ts +3 -2
  34. package/dist/backend/backend/src/services/ai/prompt-modules/model-tiers.module.d.ts.map +1 -1
  35. package/dist/backend/backend/src/services/ai/prompt-modules/model-tiers.module.js +11 -4
  36. package/dist/backend/backend/src/services/ai/prompt-modules/model-tiers.module.js.map +1 -1
  37. package/dist/backend/backend/src/services/ai/prompt-modules/prompt-module.interface.d.ts +2 -0
  38. package/dist/backend/backend/src/services/ai/prompt-modules/prompt-module.interface.d.ts.map +1 -1
  39. package/dist/backend/backend/src/services/ai/prompt-modules/prompt-module.interface.js.map +1 -1
  40. package/dist/backend/backend/src/services/apps/app-comment-room.service.d.ts +10 -1
  41. package/dist/backend/backend/src/services/apps/app-comment-room.service.d.ts.map +1 -1
  42. package/dist/backend/backend/src/services/apps/app-comment-room.service.js +15 -1
  43. package/dist/backend/backend/src/services/apps/app-comment-room.service.js.map +1 -1
  44. package/dist/backend/backend/src/services/apps/apps.service.d.ts +3 -2
  45. package/dist/backend/backend/src/services/apps/apps.service.d.ts.map +1 -1
  46. package/dist/backend/backend/src/services/apps/apps.service.js +3 -2
  47. package/dist/backend/backend/src/services/apps/apps.service.js.map +1 -1
  48. package/dist/backend/backend/src/services/apps/apps.wiring.js +2 -2
  49. package/dist/backend/backend/src/services/apps/apps.wiring.js.map +1 -1
  50. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts +15 -0
  51. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
  52. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js +36 -0
  53. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
  54. package/dist/backend/backend/src/services/chat-v2/sqlite/chat-db.d.ts +1 -1
  55. package/dist/backend/backend/src/services/chat-v2/sqlite/chat-db.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/chat-v2/sqlite/chat-db.js +4 -0
  57. package/dist/backend/backend/src/services/chat-v2/sqlite/chat-db.js.map +1 -1
  58. package/dist/backend/backend/src/services/decisions/decision-card.d.ts +14 -5
  59. package/dist/backend/backend/src/services/decisions/decision-card.d.ts.map +1 -1
  60. package/dist/backend/backend/src/services/decisions/decision-card.js +19 -3
  61. package/dist/backend/backend/src/services/decisions/decision-card.js.map +1 -1
  62. package/dist/backend/backend/src/services/drive/drive-agent.service.d.ts +6 -1
  63. package/dist/backend/backend/src/services/drive/drive-agent.service.d.ts.map +1 -1
  64. package/dist/backend/backend/src/services/drive/drive-agent.service.js +17 -0
  65. package/dist/backend/backend/src/services/drive/drive-agent.service.js.map +1 -1
  66. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.d.ts +13 -0
  67. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.d.ts.map +1 -1
  68. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.js +33 -2
  69. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.js.map +1 -1
  70. package/dist/backend/backend/src/services/model-tiers/model-tier.service.d.ts +107 -13
  71. package/dist/backend/backend/src/services/model-tiers/model-tier.service.d.ts.map +1 -1
  72. package/dist/backend/backend/src/services/model-tiers/model-tier.service.js +362 -37
  73. package/dist/backend/backend/src/services/model-tiers/model-tier.service.js.map +1 -1
  74. package/dist/backend/backend/src/services/model-tiers/model-tier.store.d.ts +7 -3
  75. package/dist/backend/backend/src/services/model-tiers/model-tier.store.d.ts.map +1 -1
  76. package/dist/backend/backend/src/services/model-tiers/model-tier.store.js.map +1 -1
  77. package/dist/backend/backend/src/services/model-tiers/model-tier.wiring.d.ts +33 -2
  78. package/dist/backend/backend/src/services/model-tiers/model-tier.wiring.d.ts.map +1 -1
  79. package/dist/backend/backend/src/services/model-tiers/model-tier.wiring.js +73 -2
  80. package/dist/backend/backend/src/services/model-tiers/model-tier.wiring.js.map +1 -1
  81. package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.wiring.d.ts.map +1 -1
  82. package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.wiring.js +8 -37
  83. package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.wiring.js.map +1 -1
  84. package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts +10 -1
  85. package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts.map +1 -1
  86. package/dist/backend/backend/src/services/slack/slack-agent-post.service.js +18 -8
  87. package/dist/backend/backend/src/services/slack/slack-agent-post.service.js.map +1 -1
  88. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +7 -0
  89. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  90. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +16 -0
  91. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  92. package/dist/backend/backend/src/services/stall/stall-watcher.service.d.ts +2 -0
  93. package/dist/backend/backend/src/services/stall/stall-watcher.service.d.ts.map +1 -1
  94. package/dist/backend/backend/src/services/stall/stall-watcher.service.js +6 -0
  95. package/dist/backend/backend/src/services/stall/stall-watcher.service.js.map +1 -1
  96. package/dist/backend/backend/src/services/stall/stall-watcher.wiring.d.ts +14 -0
  97. package/dist/backend/backend/src/services/stall/stall-watcher.wiring.d.ts.map +1 -1
  98. package/dist/backend/backend/src/services/stall/stall-watcher.wiring.js +18 -2
  99. package/dist/backend/backend/src/services/stall/stall-watcher.wiring.js.map +1 -1
  100. package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-post-log.d.ts +28 -0
  101. package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-post-log.d.ts.map +1 -1
  102. package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-post-log.js +40 -2
  103. package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-post-log.js.map +1 -1
  104. package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.d.ts +40 -1
  105. package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.d.ts.map +1 -1
  106. package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.js +52 -4
  107. package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.js.map +1 -1
  108. package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.loader.d.ts +8 -0
  109. package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.loader.d.ts.map +1 -1
  110. package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.loader.js +17 -5
  111. package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.loader.js.map +1 -1
  112. package/dist/backend/backend/src/types/decision.types.d.ts +17 -2
  113. package/dist/backend/backend/src/types/decision.types.d.ts.map +1 -1
  114. package/dist/backend/backend/src/types/index.d.ts +9 -0
  115. package/dist/backend/backend/src/types/index.d.ts.map +1 -1
  116. package/dist/backend/backend/src/types/index.js.map +1 -1
  117. package/dist/backend/backend/src/utils/model-tier.utils.d.ts +23 -1
  118. package/dist/backend/backend/src/utils/model-tier.utils.d.ts.map +1 -1
  119. package/dist/backend/backend/src/utils/model-tier.utils.js +28 -0
  120. package/dist/backend/backend/src/utils/model-tier.utils.js.map +1 -1
  121. package/dist/backend/build-info.json +2 -2
  122. package/dist/cli/backend/src/constants.d.ts +20 -0
  123. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  124. package/dist/cli/backend/src/constants.js +20 -0
  125. package/dist/cli/backend/src/constants.js.map +1 -1
  126. package/dist/cli/backend/src/services/ai/prompt-builder.service.d.ts.map +1 -1
  127. package/dist/cli/backend/src/services/ai/prompt-builder.service.js +2 -0
  128. package/dist/cli/backend/src/services/ai/prompt-builder.service.js.map +1 -1
  129. package/dist/cli/backend/src/services/ai/prompt-modules/model-tiers.module.d.ts +3 -2
  130. package/dist/cli/backend/src/services/ai/prompt-modules/model-tiers.module.d.ts.map +1 -1
  131. package/dist/cli/backend/src/services/ai/prompt-modules/model-tiers.module.js +11 -4
  132. package/dist/cli/backend/src/services/ai/prompt-modules/model-tiers.module.js.map +1 -1
  133. package/dist/cli/backend/src/services/ai/prompt-modules/prompt-module.interface.d.ts +2 -0
  134. package/dist/cli/backend/src/services/ai/prompt-modules/prompt-module.interface.d.ts.map +1 -1
  135. package/dist/cli/backend/src/services/ai/prompt-modules/prompt-module.interface.js.map +1 -1
  136. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts +15 -0
  137. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
  138. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js +36 -0
  139. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
  140. package/dist/cli/backend/src/services/chat-v2/sqlite/chat-db.d.ts +1 -1
  141. package/dist/cli/backend/src/services/chat-v2/sqlite/chat-db.d.ts.map +1 -1
  142. package/dist/cli/backend/src/services/chat-v2/sqlite/chat-db.js +4 -0
  143. package/dist/cli/backend/src/services/chat-v2/sqlite/chat-db.js.map +1 -1
  144. package/dist/cli/backend/src/services/decisions/decision-card.d.ts +14 -5
  145. package/dist/cli/backend/src/services/decisions/decision-card.d.ts.map +1 -1
  146. package/dist/cli/backend/src/services/decisions/decision-card.js +19 -3
  147. package/dist/cli/backend/src/services/decisions/decision-card.js.map +1 -1
  148. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.d.ts +13 -0
  149. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.d.ts.map +1 -1
  150. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.js +33 -2
  151. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.js.map +1 -1
  152. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +7 -0
  153. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  154. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +16 -0
  155. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  156. package/dist/cli/backend/src/types/decision.types.d.ts +17 -2
  157. package/dist/cli/backend/src/types/decision.types.d.ts.map +1 -1
  158. package/dist/cli/backend/src/types/index.d.ts +9 -0
  159. package/dist/cli/backend/src/types/index.d.ts.map +1 -1
  160. package/dist/cli/backend/src/types/index.js.map +1 -1
  161. package/dist/cli/backend/src/utils/model-tier.utils.d.ts +23 -1
  162. package/dist/cli/backend/src/utils/model-tier.utils.d.ts.map +1 -1
  163. package/dist/cli/backend/src/utils/model-tier.utils.js +28 -0
  164. package/dist/cli/backend/src/utils/model-tier.utils.js.map +1 -1
  165. package/frontend/dist/assets/{index-778ed2d9.js → index-d60af958.js} +237 -237
  166. package/frontend/dist/index.html +1 -1
  167. package/package.json +1 -1
@@ -1,27 +1,33 @@
1
1
  /**
2
- * ModelTierService — model tiers and "Optimize usage" (crewly#1173 phase 1,
3
- * specs/2026-10-08-model-tiers.md).
2
+ * ModelTierService — model tiers and "Optimize usage" (crewly#1173 phases 1
3
+ * and 2, specs/2026-10-08-model-tiers.md).
4
4
  *
5
- * Recommendation mode: nothing here changes a member's tier on its own.
5
+ * Two change modes per team (`Team.tierChangeMode`, owner-only):
6
+ * `approve` (default) — the lead proposes, the owner approves each change;
7
+ * `auto` — the lead's changes apply at once and the owner gets a notice with
8
+ * one-tap Undo.
6
9
  *
7
10
  * 1. Review: weekly while a team has "Optimize usage" on (and on demand), the
8
11
  * team lead gets a compact usage report per member and is asked to
9
12
  * propose tier changes and task routing rules.
10
- * 2. Proposal: the lead adds changes with `propose-tier-change` and submits;
11
- * ONE owner card per review carries them all (Apply / Keep as is).
12
- * 3. Apply: only the owner's yes applies the tiers (and clears a member's
13
- * fixed model so the tier takes effect). Each member picks its new model up
14
- * at its next start.
13
+ * 2. Proposal: the lead adds changes with `propose-tier-change` and submits.
14
+ * Approve mode: ONE owner card per review carries them all (Apply / Keep
15
+ * as is). Auto mode: they apply right away, and ONE notice (Undo / Keep)
16
+ * goes to the same place.
17
+ * 3. Apply: the tiers are set (a member's fixed model is cleared so the tier
18
+ * takes effect). An idle member whose model changed is restarted so the
19
+ * new model takes effect now; a busy one picks it up at its next start.
15
20
  * 4. Quality guard: a member moved down is watched over its next settled work
16
- * items; when it is sent back clearly more often than before, a revert card
17
- * goes to the owner (again only applied on yes).
21
+ * items; when it is sent back clearly more often than before, approve mode
22
+ * sends a revert card, auto mode moves it back at once and tells the owner
23
+ * (with "Keep the lower tier" to undo the revert).
18
24
  *
19
25
  * @module services/model-tiers/model-tier.service
20
26
  */
21
27
  import { randomUUID } from 'crypto';
22
28
  import { MODEL_TIER_CONSTANTS, RUNTIME_TYPES } from '../../constants.js';
23
29
  import { effectiveMemberModelId } from '../../utils/member-default-model.utils.js';
24
- import { isModelTier, parseTierModels, resolveTierModel, tierMapFor, tierRank } from '../../utils/model-tier.utils.js';
30
+ import { isModelTier, isTierChangeMode, leadChangesTiersItself, parseTierModels, resolveTierModel, tierChangeModeOf, tierMapFor, tierRank } from '../../utils/model-tier.utils.js';
25
31
  import { getTeamLeads, isTeamLead } from '../../utils/team.utils.js';
26
32
  import { resolveRate } from '../monitoring/model-pricing.js';
27
33
  import { memberLedgerKeys } from '../tl-delegation/lead-share.js';
@@ -31,6 +37,8 @@ const C = MODEL_TIER_CONSTANTS;
31
37
  const APPLY_KEY = 'a';
32
38
  const KEEP_KEY = 'b';
33
39
  const DAY_MS = 24 * 60 * 60 * 1000;
40
+ /** What the lead is told about restarts after an apply. */
41
+ const RESTART_NOTE = 'Idle members restart now on their new model; a busy member picks it up at its next start.';
34
42
  /** A refusal with an HTTP status. */
35
43
  export class ModelTierError extends Error {
36
44
  status;
@@ -54,6 +62,8 @@ export class ModelTierService {
54
62
  deps;
55
63
  timer = null;
56
64
  ticking = false;
65
+ restarts = Promise.resolve();
66
+ submitting = new Set();
57
67
  constructor(deps) {
58
68
  this.deps = deps;
59
69
  }
@@ -100,6 +110,7 @@ export class ModelTierService {
100
110
  return {
101
111
  teamId,
102
112
  optimizeUsage: team.optimizeUsage === true,
113
+ tierChangeMode: tierChangeModeOf(team),
103
114
  tierModels: team.tierModels ?? {},
104
115
  routingRules: team.tierRoutingRules ?? [],
105
116
  tierMaps,
@@ -139,6 +150,11 @@ export class ModelTierService {
139
150
  throw new ModelTierError(400, 'optimizeUsage is true or false');
140
151
  team.optimizeUsage = patch.optimizeUsage;
141
152
  }
153
+ if (patch.tierChangeMode !== undefined) {
154
+ if (!isTierChangeMode(patch.tierChangeMode))
155
+ throw new ModelTierError(400, 'tierChangeMode is "approve" or "auto"');
156
+ team.tierChangeMode = patch.tierChangeMode;
157
+ }
142
158
  if (patch.tierModels !== undefined) {
143
159
  const parsed = parseTierModels(patch.tierModels);
144
160
  if (!parsed.ok)
@@ -218,8 +234,13 @@ export class ModelTierService {
218
234
  reviewMessage(team, report, reviewId, trigger) {
219
235
  const skill = `bash ${this.deps.tlSkillsPath}/propose-tier-change/execute.sh`;
220
236
  const rules = team.tierRoutingRules?.length ? `\nRouting rules in force: ${team.tierRoutingRules.map((r) => `"${r}"`).join('; ')}.` : '';
237
+ const auto = leadChangesTiersItself(team);
238
+ const who = auto ? 'you decide: your changes apply as soon as you submit, and the owner is told and can undo them' : 'the owner approves every change';
239
+ const send = auto
240
+ ? `4. Send it: ${skill} --submit. The changes apply at once: idle members restart on their new model, busy ones switch at their next start, and the owner gets one notice with Undo. If nothing should change, still run --submit (nothing is posted).`
241
+ : `4. Send it: ${skill} --submit. One card goes to the owner; nothing changes until the owner says yes. If nothing should change, still run --submit (no card is posted).`;
221
242
  return [
222
- `[MODEL TIER REVIEW] ${trigger === 'weekly' ? 'Weekly' : 'Requested'} usage review for team ${team.name} (${reviewId}). "Optimize usage" is on; the owner approves every change.`,
243
+ `[MODEL TIER REVIEW] ${trigger === 'weekly' ? 'Weekly' : 'Requested'} usage review for team ${team.name} (${reviewId}). "Optimize usage" is on; ${who}.`,
223
244
  '',
224
245
  renderTierReport(report),
225
246
  rules,
@@ -228,7 +249,7 @@ export class ModelTierService {
228
249
  '1. For each member, decide whether its work needs its tier. strong = hard reasoning, design, reviewing others. mid = normal implementation and writing. weak = routine work: polling, checks, formatting, sorting, first-pass triage, summaries.',
229
250
  `2. Propose a change for each member that should move: ${skill} --member "<name>" --tier weak --reason "<the work it does and why the tier fits>"`,
230
251
  `3. Propose routing rules for incoming work: ${skill} --routing "polling / formatting / sorting -> <member>"`,
231
- `4. Send it: ${skill} --submit. One card goes to the owner; nothing changes until the owner says yes. If nothing should change, still run --submit (no card is posted).`,
252
+ send,
232
253
  'Do not lower a member whose work was often sent back, and do not lower yourself below mid.',
233
254
  ].join('\n');
234
255
  }
@@ -328,6 +349,18 @@ export class ModelTierService {
328
349
  return state.draft;
329
350
  }
330
351
  async submit(ctx) {
352
+ // The hourly auto-submit and the lead's own --submit can overlap; one wins.
353
+ if (this.submitting.has(ctx.team.id))
354
+ throw new ModelTierError(409, 'This review is being submitted already');
355
+ this.submitting.add(ctx.team.id);
356
+ try {
357
+ return await this.submitOnce(ctx);
358
+ }
359
+ finally {
360
+ this.submitting.delete(ctx.team.id);
361
+ }
362
+ }
363
+ async submitOnce(ctx) {
331
364
  const state = await this.deps.store.get(ctx.team.id);
332
365
  const draft = state.draft;
333
366
  if (!draft || (draft.changes.length === 0 && draft.routing.length === 0)) {
@@ -337,10 +370,128 @@ export class ModelTierService {
337
370
  await this.refreshOpenDecision(ctx.team.id, state);
338
371
  if (state.openDecisionId)
339
372
  throw new ModelTierError(409, `The last tier proposal (${state.openDecisionId}) still waits for the owner; add to it next review`);
373
+ if (leadChangesTiersItself(ctx.team))
374
+ return this.applyNow(ctx, draft);
340
375
  const decision = await this.askOwner(ctx.team, ctx.lead, draft, 'review');
341
376
  await this.deps.store.update(ctx.team.id, (s) => ({ ...s, draft: undefined, openDecisionId: decision.id }));
342
377
  return { submitted: true, decisionId: decision.id, changes: draft.changes.length, routing: draft.routing.length, note: 'One card went to the owner. Nothing changes until the owner says yes; you will be told the answer.' };
343
378
  }
379
+ /**
380
+ * Auto mode: apply the lead's draft at once (the same apply path as the
381
+ * owner's Apply), then post ONE notice where the card would go, whose Undo
382
+ * puts back exactly these changes.
383
+ *
384
+ * @param ctx - The lead and its team
385
+ * @param draft - The submitted draft
386
+ * @returns What happened (for the skill output)
387
+ */
388
+ async applyNow(ctx, draft) {
389
+ if (!this.deps.decisions())
390
+ throw new ModelTierError(503, 'Owner notices are not running on this machine, so the change cannot be applied (the owner must be able to undo it).');
391
+ const before = ctx.team.tierRoutingRules ?? [];
392
+ const subject = {
393
+ teamId: ctx.team.id,
394
+ teamName: ctx.team.name,
395
+ reviewId: draft.reviewId,
396
+ kind: 'review',
397
+ changes: draft.changes,
398
+ routing: draft.routing,
399
+ askerSession: sessionOf(ctx.lead),
400
+ askerName: ctx.lead.name,
401
+ };
402
+ // Priced before the apply: the estimate compares each member's current model with its new one.
403
+ const saving = this.estimateSaving(ctx.team, draft.changes);
404
+ const applied = await this.apply(subject, draft.reviewId);
405
+ await this.deps.store.update(ctx.team.id, (s) => ({ ...s, draft: undefined }));
406
+ const team = await this.team(ctx.team.id);
407
+ const undo = {
408
+ ...subject,
409
+ kind: 'undo',
410
+ notice: 'applied',
411
+ undoOf: draft.reviewId,
412
+ changes: applied.map((c) => reverseEntry(c, team, 'the owner undid it')),
413
+ routing: before.filter((r) => !(team.tierRoutingRules ?? []).includes(r)),
414
+ removeRouting: draft.routing.filter((r) => !before.includes(r)),
415
+ };
416
+ let noticeId = null;
417
+ try {
418
+ noticeId = (await this.postNotice(team, ctx.lead, undo, applied, draft.routing, saving)).id;
419
+ }
420
+ catch (err) {
421
+ this.deps.logger?.warn('Tier changes applied but the owner notice could not be posted', { teamId: team.id, error: err instanceof Error ? err.message : String(err) });
422
+ }
423
+ return {
424
+ submitted: true,
425
+ applied: true,
426
+ changes: applied.length,
427
+ routing: draft.routing.length,
428
+ ...(noticeId ? { noticeId } : {}),
429
+ note: `Applied now. ${RESTART_NOTE} ${noticeId ? 'The owner got one notice and can undo it.' : 'The owner notice could not be posted; tell the owner what you changed.'}`,
430
+ };
431
+ }
432
+ /**
433
+ * Post an auto-mode notice: the change already happened; its one action
434
+ * (`APPLY_KEY`) applies `subject` — the undo. Default and plain "yes" = keep.
435
+ *
436
+ * @param team - Team
437
+ * @param asker - The lead (its bot posts it, it gets the outcome)
438
+ * @param subject - The undo the action applies
439
+ * @param done - What happened (the lead's changes, or the guard's move back)
440
+ * @param routing - Routing rules that went in (applied notices)
441
+ * @param saving - Weekly saving estimate of the lead's changes, or null
442
+ * @returns The notice decision
443
+ */
444
+ async postNotice(team, asker, subject, done, routing, saving) {
445
+ const decisions = this.deps.decisions();
446
+ if (!decisions)
447
+ throw new ModelTierError(503, 'Owner notices are not running on this machine.');
448
+ const reverted = subject.notice === 'reverted';
449
+ const body = [];
450
+ let question;
451
+ let options;
452
+ if (reverted) {
453
+ const c = done[0];
454
+ const m = subject.changes[0];
455
+ question = `${c.memberName}'s work was sent back more often on the ${c.from} tier, so it was moved back to ${tierLabel(c.clearTier ? null : c.to, c.toModel)}.`;
456
+ body.push(`${c.reason[0].toUpperCase()}${c.reason.slice(1)}.`);
457
+ options = [
458
+ { key: APPLY_KEY, label: 'Keep the lower tier', detail: `moves ${m.memberName} to ${m.to} again` },
459
+ { key: KEEP_KEY, label: 'OK', detail: 'leave it moved back' },
460
+ ];
461
+ }
462
+ else {
463
+ const summary = done.map((c) => `${c.memberName} → ${targetLabel(c)}`).join(', ');
464
+ question = summary ? `${asker.name} changed model tiers for ${team.name}: ${summary}.` : `${asker.name} set new work routing for ${team.name}.`;
465
+ for (const c of done) {
466
+ const fixed = c.clearsModelId ? ` (its fixed model \`${c.clearsModelId}\` was cleared)` : '';
467
+ body.push(`• *${c.memberName}*: ${tierLabel(c.from, c.fromModel)} → ${tierLabel(c.clearTier ? null : c.to, c.toModel)}${fixed} — ${c.reason}`);
468
+ }
469
+ if (routing.length)
470
+ body.push(`New work routing: ${routing.map((r) => `"${r}"`).join('; ')}`);
471
+ if (saving !== null)
472
+ body.push(`Estimated change: ${saving >= 0 ? 'saves' : 'adds'} about $${Math.abs(saving).toFixed(2)} a week at API list price (from the last ${C.REPORT_WINDOW_DAYS} days).`);
473
+ options = [
474
+ { key: APPLY_KEY, label: 'Undo', detail: 'puts these back as they were' },
475
+ { key: KEEP_KEY, label: 'Keep', detail: 'nothing more to do' },
476
+ ];
477
+ }
478
+ if (done.length)
479
+ body.push('Idle members switch to the new model now; busy ones at their next start.');
480
+ const channel = await this.deps.teamChannelOf?.(team.id).catch(() => null);
481
+ return decisions.askPrebuilt({
482
+ kind: C.DECISION_KIND,
483
+ asker: sessionOf(asker),
484
+ question,
485
+ title: reverted ? `Model tiers — quality check, ${team.name}` : `Model tiers — ${team.name}`,
486
+ body,
487
+ options,
488
+ defaultKey: KEEP_KEY,
489
+ yesKey: KEEP_KEY,
490
+ deadline: new Date(this.now().getTime() + C.NOTICE_DEADLINE_MS),
491
+ tierChange: subject,
492
+ ...(channel ? { place: { slackChannelId: channel } } : {}),
493
+ });
494
+ }
344
495
  /** Post the owner card for a draft (review) or a revert. */
345
496
  async askOwner(team, asker, draft, kind, extraBody = []) {
346
497
  const decisions = this.deps.decisions();
@@ -370,9 +521,9 @@ export class ModelTierService {
370
521
  const saving = kind === 'review' ? this.estimateSaving(team, draft.changes) : null;
371
522
  if (saving !== null)
372
523
  body.push(`Estimated change: ${saving >= 0 ? 'saves' : 'adds'} about $${Math.abs(saving).toFixed(2)} a week at API list price (from the last ${C.REPORT_WINDOW_DAYS} days).`);
373
- body.push('Applies at each member\'s next start.');
524
+ body.push('On Apply, idle members switch to the new model right away; busy ones at their next start.');
374
525
  const options = [
375
- { key: APPLY_KEY, label: kind === 'revert' ? 'Move back' : 'Apply', detail: 'takes effect at each member\'s next start' },
526
+ { key: APPLY_KEY, label: kind === 'revert' ? 'Move back' : 'Apply', detail: 'idle members switch now, busy ones at their next start' },
376
527
  { key: KEEP_KEY, label: 'Keep as is', detail: 'nothing changes' },
377
528
  ];
378
529
  const channel = await this.deps.teamChannelOf?.(team.id).catch(() => null);
@@ -460,6 +611,8 @@ export class ModelTierService {
460
611
  if (!s)
461
612
  return null;
462
613
  await this.deps.store.update(s.teamId, (st) => (st.openDecisionId === decision.id ? { ...st, openDecisionId: undefined } : st));
614
+ if (s.notice)
615
+ return this.onNoticeSettled(decision, s);
463
616
  if (decision.status !== 'resolved' || decision.chosenKey !== APPLY_KEY) {
464
617
  if (s.kind === 'revert')
465
618
  return `[MODEL TIERS] The owner kept the lower tier (${decision.id}). Nothing changed.`;
@@ -469,7 +622,7 @@ export class ModelTierService {
469
622
  const applied = await this.apply(s, decision.id);
470
623
  const lines = applied.map((c) => `${c.memberName} → ${tierLabel(c.clearTier ? null : c.to, c.toModel)}`);
471
624
  const routing = s.routing.length ? ` Routing rules now in force: ${s.routing.map((r) => `"${r}"`).join('; ')}. Route new work by them.` : '';
472
- return `[MODEL TIERS] The owner approved ${decision.id}: ${lines.join(', ') || 'routing rules only'}. Each member picks up its new model at its next start; to apply now, stop-agent + start-agent a member while it is idle.${routing}`;
625
+ return `[MODEL TIERS] The owner approved ${decision.id}: ${lines.join(', ') || 'routing rules only'}. ${RESTART_NOTE}${routing}`;
473
626
  }
474
627
  catch (err) {
475
628
  this.deps.logger?.warn('Applying tier changes failed', { decisionId: decision.id, error: String(err) });
@@ -477,10 +630,42 @@ export class ModelTierService {
477
630
  }
478
631
  }
479
632
  /**
480
- * Apply an approved subject to the team and record it for the guard.
633
+ * An auto-mode notice settled: its one action undoes what the notice is
634
+ * about; anything else (Keep, the deadline, skip) leaves it as it is.
635
+ *
636
+ * @param decision - The settled notice
637
+ * @param s - Its subject (the undo)
638
+ * @returns Note for the lead, or null
639
+ */
640
+ async onNoticeSettled(decision, s) {
641
+ if (decision.status !== 'resolved' || decision.chosenKey !== APPLY_KEY)
642
+ return null;
643
+ try {
644
+ const applied = await this.apply(s, decision.id);
645
+ const lines = applied.map((c) => `${c.memberName} → ${tierLabel(c.clearTier ? null : c.to, c.toModel)}`);
646
+ const skipped = s.changes.length - applied.length;
647
+ const skippedNote = skipped > 0 ? ` ${skipped} member(s) were left as they are: their tier changed again since.` : '';
648
+ if (s.notice === 'reverted') {
649
+ return `[MODEL TIERS] The owner chose to keep the lower tier after the quality check: ${lines.join(', ') || 'nothing to change'}. It is not watched again.${skippedNote} ${RESTART_NOTE}`;
650
+ }
651
+ const routing = s.removeRouting?.length ? ` Routing rules removed: ${s.removeRouting.map((r) => `"${r}"`).join('; ')}.` : '';
652
+ return `[MODEL TIERS] The owner undid your tier changes: ${lines.join(', ') || 'routing rules only'}.${routing}${skippedNote} Do not make the same change again this week. ${RESTART_NOTE}`;
653
+ }
654
+ catch (err) {
655
+ this.deps.logger?.warn('Undoing tier changes failed', { decisionId: decision.id, error: String(err) });
656
+ return `[MODEL TIERS] The owner tapped Undo, but undoing failed (${err instanceof Error ? err.message : String(err)}). Tell the owner.`;
657
+ }
658
+ }
659
+ /**
660
+ * Apply a subject to the team (owner's Apply, the lead's change in auto
661
+ * mode, an undo or a revert), record it for the guard, and restart idle
662
+ * members whose model changed.
663
+ *
664
+ * An `undo` changes only members still on the tier it expects (`from`): a
665
+ * member whose tier changed again since is left alone.
481
666
  *
482
667
  * @param s - Subject
483
- * @param decisionId - The approving decision
668
+ * @param decisionId - The approving decision, or the batch id of an auto apply
484
669
  * @returns The applied entries
485
670
  */
486
671
  async apply(s, decisionId) {
@@ -488,27 +673,57 @@ export class ModelTierService {
488
673
  const items = await this.deps.workItems().catch(() => []);
489
674
  const now = this.now();
490
675
  const applied = [];
676
+ const modelChanged = [];
677
+ // An undo puts back only members whose latest change is the one undone:
678
+ // a later change (by the owner's card or another batch) wins.
679
+ const latest = new Map();
680
+ if (s.kind === 'undo')
681
+ for (const a of (await this.deps.store.get(s.teamId)).applied)
682
+ latest.set(a.memberId, a.decisionId);
491
683
  for (const c of s.changes) {
492
684
  const m = team.members.find((x) => x.id === c.memberId);
493
- if (!m)
685
+ if (!m || m.role === 'orchestrator')
494
686
  continue;
687
+ if (s.kind === 'undo' && ((m.tier ?? null) !== c.from || (s.undoOf && latest.get(m.id) !== s.undoOf)))
688
+ continue;
689
+ const runtime = m.runtimeType ?? RUNTIME_TYPES.CLAUDE_CODE;
690
+ const modelBefore = effectiveMemberModelId(team, { ...m, runtimeType: runtime });
691
+ // What the member had right now, not when the change was proposed: the
692
+ // record (and the undo built from it) must put back this state.
693
+ const tierBefore = m.tier ?? null;
694
+ const fixedBefore = m.modelId;
495
695
  if (c.clearTier)
496
696
  delete m.tier;
497
697
  else
498
698
  m.tier = c.to;
499
699
  if (c.clearsModelId && m.modelId === c.clearsModelId)
500
700
  delete m.modelId;
501
- if (c.restoreModelId)
701
+ // Never overwrite a fixed model someone set after the change being reverted.
702
+ if (c.restoreModelId && !m.modelId)
502
703
  m.modelId = c.restoreModelId;
503
- const lowered = !c.clearTier && tierRank(c.to) < tierRank(effectiveFromTier(c, m.runtimeType ?? RUNTIME_TYPES.CLAUDE_CODE, team.tierModels));
704
+ if (effectiveMemberModelId(team, { ...m, runtimeType: runtime }) !== modelBefore && sessionOf(m))
705
+ modelChanged.push(sessionOf(m));
706
+ const actual = { ...c, from: tierBefore, fromModel: modelBefore };
707
+ delete actual.clearsModelId;
708
+ if (fixedBefore && !m.modelId)
709
+ actual.clearsModelId = fixedBefore;
710
+ const lowered = !c.clearTier && tierRank(c.to) < tierRank(effectiveFromTier(actual, runtime, team.tierModels));
504
711
  applied.push({
505
- ...c,
712
+ ...actual,
506
713
  appliedAt: now.toISOString(),
507
714
  decisionId,
508
715
  baseline: baselineBefore(items, memberLedgerKeys(m), now.getTime()),
509
716
  guard: lowered && s.kind === 'review' ? 'watching' : 'not_watched',
510
717
  });
511
718
  }
719
+ // Removal first, so an undo can take its rules out and put back the ones they pushed out.
720
+ if (s.removeRouting?.length) {
721
+ const rules = (team.tierRoutingRules ?? []).filter((r) => !s.removeRouting.includes(r));
722
+ if (rules.length)
723
+ team.tierRoutingRules = rules;
724
+ else
725
+ delete team.tierRoutingRules;
726
+ }
512
727
  if (s.routing.length) {
513
728
  const rules = [...(team.tierRoutingRules ?? [])];
514
729
  for (const r of s.routing)
@@ -518,10 +733,44 @@ export class ModelTierService {
518
733
  }
519
734
  team.updatedAt = now.toISOString();
520
735
  await this.deps.saveTeam(team);
521
- await this.deps.store.update(s.teamId, (st) => ({ ...st, applied: [...st.applied, ...applied] }));
522
- this.deps.logger?.info('Model tier changes applied', { teamId: s.teamId, decisionId, changes: applied.map((a) => `${a.memberName}:${a.to}`) });
736
+ const undone = new Set(s.kind === 'undo' && s.undoOf ? applied.map((a) => a.memberId) : []);
737
+ await this.deps.store.update(s.teamId, (st) => ({
738
+ ...st,
739
+ applied: [
740
+ ...st.applied.map((a) => (a.decisionId === s.undoOf && undone.has(a.memberId) && a.guard !== 'auto_reverted' ? { ...a, guard: 'undone', guardCheckedAt: now.toISOString() } : a)),
741
+ ...applied,
742
+ ],
743
+ }));
744
+ this.deps.logger?.info('Model tier changes applied', { teamId: s.teamId, decisionId, kind: s.kind, changes: applied.map((a) => `${a.memberName}:${a.to}`) });
745
+ this.restartIdle(modelChanged);
523
746
  return applied;
524
747
  }
748
+ /**
749
+ * Restart, one after another in the background, each member whose model
750
+ * changed and that is idle; busy or stopped members pick the model up at
751
+ * their next start.
752
+ *
753
+ * @param sessions - Members whose effective model changed
754
+ */
755
+ restartIdle(sessions) {
756
+ const restart = this.deps.restartIfIdle;
757
+ if (!restart || !sessions.length)
758
+ return;
759
+ this.restarts = this.restarts.then(async () => {
760
+ for (const session of new Set(sessions)) {
761
+ const outcome = await restart(session).catch(() => 'failed');
762
+ this.deps.logger?.info('Model tier change: restart of an idle member', { session, outcome });
763
+ }
764
+ });
765
+ }
766
+ /**
767
+ * Resolves once the restarts scheduled so far are done (tests, shutdown).
768
+ *
769
+ * @returns Promise
770
+ */
771
+ restartsSettled() {
772
+ return this.restarts;
773
+ }
525
774
  // -------------------------------------------------------------------- tick
526
775
  /**
527
776
  * Hourly: start due weekly reviews, send drafts the lead never submitted,
@@ -579,6 +828,57 @@ export class ModelTierService {
579
828
  }
580
829
  return out;
581
830
  }
831
+ /**
832
+ * Auto mode: move a member whose work got worse back at once (the revert
833
+ * the approve-mode card would apply), tell the lead, and post the owner a
834
+ * notice whose one action — "Keep the lower tier" — undoes the move back.
835
+ *
836
+ * @param team - Team
837
+ * @param lead - Its lead (posts the notice)
838
+ * @param a - The watched change
839
+ * @param back - The reverse entry
840
+ * @returns The notice id, or null when it could not be posted / nothing moved
841
+ */
842
+ async autoRevert(team, lead, a, back) {
843
+ const batch = `TG-${randomUUID().slice(0, 8)}`;
844
+ const subject = {
845
+ teamId: team.id,
846
+ teamName: team.name,
847
+ reviewId: batch,
848
+ kind: 'revert',
849
+ changes: [back],
850
+ routing: [],
851
+ askerSession: sessionOf(lead),
852
+ askerName: lead.name,
853
+ };
854
+ const done = await this.apply(subject, batch);
855
+ if (!done.length)
856
+ return null;
857
+ const forward = {
858
+ memberId: a.memberId,
859
+ memberName: a.memberName,
860
+ session: a.session,
861
+ from: back.clearTier ? null : back.to,
862
+ to: a.to,
863
+ fromModel: back.toModel,
864
+ toModel: a.toModel,
865
+ ...(a.clearsModelId ? { clearsModelId: a.clearsModelId } : {}),
866
+ reason: a.reason,
867
+ };
868
+ const undo = { ...subject, kind: 'undo', notice: 'reverted', undoOf: batch, changes: [forward] };
869
+ let id = null;
870
+ try {
871
+ id = (await this.postNotice(await this.team(team.id), lead, undo, done, [], null)).id;
872
+ }
873
+ catch (err) {
874
+ this.deps.logger?.warn('Member moved back, but the owner notice could not be posted', { teamId: team.id, member: a.memberName, error: String(err) });
875
+ }
876
+ const to = tierLabel(back.clearTier ? null : back.to, back.toModel);
877
+ await this.deps
878
+ .deliverToAgent(sessionOf(lead), `[MODEL TIERS] Quality check: ${a.memberName}'s work was sent back more often on ${a.to} (${back.reason}), so it was moved back to ${to}. The owner was told and can keep the lower tier. Do not lower ${a.memberName} again this month unless its work changes. ${RESTART_NOTE}`)
879
+ .catch(() => false);
880
+ return id;
881
+ }
582
882
  /**
583
883
  * Quality guard for one team: judge every watched (lowered) member and ask
584
884
  * the owner to move back the ones whose work got worse.
@@ -616,21 +916,21 @@ export class ModelTierService {
616
916
  const lead = getTeamLeads(team)[0];
617
917
  if (!lead)
618
918
  continue;
619
- const back = {
620
- memberId: m.id,
621
- memberName: m.name,
622
- session: sessionOf(m),
623
- from: a.to,
624
- to: a.from ?? effectiveFromTier(a, m.runtimeType ?? RUNTIME_TYPES.CLAUDE_CODE, team.tierModels),
625
- ...(a.from === null ? { clearTier: true } : {}),
626
- fromModel: a.toModel,
627
- toModel: a.fromModel,
628
- ...(a.clearsModelId ? { restoreModelId: a.clearsModelId } : {}),
629
- reason: `since moving to ${a.to}: ${describeStats(after)}; before: ${describeStats(a.baseline)}`,
630
- };
919
+ const back = reverseEntry(a, team, `since moving to ${a.to}: ${describeStats(after)}; before: ${describeStats(a.baseline)}`);
920
+ if (leadChangesTiersItself(team)) {
921
+ try {
922
+ const id = await this.autoRevert(team, lead, a, back);
923
+ updates.set(key, { guard: 'auto_reverted', guardDecisionId: id ?? undefined, guardCheckedAt: now.toISOString(), guardAfter: after });
924
+ reverts.push(id ?? `reverted:${m.name}`);
925
+ }
926
+ catch (err) {
927
+ this.deps.logger?.warn('Quality guard could not move a member back', { teamId: team.id, member: m.name, error: String(err) });
928
+ }
929
+ continue;
930
+ }
631
931
  try {
632
932
  const d = await this.askOwner(team, lead, { reviewId: `guard-${a.decisionId}`, changes: [back], routing: [] }, 'revert', [
633
- `${m.name} was moved to ${a.to} in ${a.decisionId}. Since then ${describeStats(after)}; before the change ${describeStats(a.baseline)}.`,
933
+ `${m.name} was moved to ${a.to}. Since then ${describeStats(after)}; before the change ${describeStats(a.baseline)}.`,
634
934
  ]);
635
935
  updates.set(key, { guard: 'revert_proposed', guardDecisionId: d.id, guardCheckedAt: now.toISOString(), guardAfter: after });
636
936
  reverts.push(d.id);
@@ -651,6 +951,31 @@ export class ModelTierService {
651
951
  return reverts;
652
952
  }
653
953
  }
954
+ /**
955
+ * The entry that puts a change back: from its target to the tier (and fixed
956
+ * model) the member had before it.
957
+ *
958
+ * @param c - The change to reverse
959
+ * @param team - Team (tier map)
960
+ * @param reason - Why it is put back
961
+ * @returns The reverse entry
962
+ */
963
+ export function reverseEntry(c, team, reason) {
964
+ const m = team.members.find((x) => x.id === c.memberId);
965
+ const runtime = m?.runtimeType ?? RUNTIME_TYPES.CLAUDE_CODE;
966
+ return {
967
+ memberId: c.memberId,
968
+ memberName: c.memberName,
969
+ session: c.session,
970
+ from: c.to,
971
+ to: c.from ?? effectiveFromTier(c, runtime, team.tierModels),
972
+ ...(c.from === null ? { clearTier: true } : {}),
973
+ fromModel: c.toModel,
974
+ toModel: c.fromModel,
975
+ ...(c.clearsModelId ? { restoreModelId: c.clearsModelId } : {}),
976
+ reason,
977
+ };
978
+ }
654
979
  /** "weak" / "no tier" for the target of a change. */
655
980
  function targetLabel(c) {
656
981
  return c.clearTier ? 'no tier' : c.to;