crewly 1.20.274 → 1.20.276
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.
- package/config/roles/team-leader/tl-addon.md +7 -0
- package/config/skills/team-leader/propose-tier-change/SKILL.md +62 -0
- package/config/skills/team-leader/propose-tier-change/execute.sh +64 -0
- package/config/skills/team-leader/propose-tier-change/execute.test.sh +75 -0
- package/dist/backend/backend/src/constants.d.ts +46 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +53 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/request-types.d.ts +2 -0
- package/dist/backend/backend/src/controllers/request-types.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/team/team-model-tiers.controller.d.ts +41 -0
- package/dist/backend/backend/src/controllers/team/team-model-tiers.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/team/team-model-tiers.controller.js +112 -0
- package/dist/backend/backend/src/controllers/team/team-model-tiers.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/team/team.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/team/team.controller.js +12 -0
- package/dist/backend/backend/src/controllers/team/team.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/team/team.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/team/team.routes.js +6 -0
- package/dist/backend/backend/src/controllers/team/team.routes.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +5 -0
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-builder.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-builder.service.js +16 -0
- package/dist/backend/backend/src/services/ai/prompt-builder.service.js.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/index.d.ts +1 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/index.d.ts.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/index.js +1 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/index.js.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/model-tiers.module.d.ts +34 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/model-tiers.module.d.ts.map +1 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/model-tiers.module.js +54 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/model-tiers.module.js.map +1 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/prompt-assembly.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/prompt-assembly.service.js +2 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/prompt-assembly.service.js.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/prompt-module.interface.d.ts +13 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/prompt-module.interface.d.ts.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/prompt-module.interface.js.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts +2 -0
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.service.js +1 -0
- package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
- package/dist/backend/backend/src/services/model-tiers/model-tier.service.d.ts +251 -0
- package/dist/backend/backend/src/services/model-tiers/model-tier.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/model-tiers/model-tier.service.js +701 -0
- package/dist/backend/backend/src/services/model-tiers/model-tier.service.js.map +1 -0
- package/dist/backend/backend/src/services/model-tiers/model-tier.store.d.ts +82 -0
- package/dist/backend/backend/src/services/model-tiers/model-tier.store.d.ts.map +1 -0
- package/dist/backend/backend/src/services/model-tiers/model-tier.store.js +80 -0
- package/dist/backend/backend/src/services/model-tiers/model-tier.store.js.map +1 -0
- package/dist/backend/backend/src/services/model-tiers/model-tier.wiring.d.ts +28 -0
- package/dist/backend/backend/src/services/model-tiers/model-tier.wiring.d.ts.map +1 -0
- package/dist/backend/backend/src/services/model-tiers/model-tier.wiring.js +56 -0
- package/dist/backend/backend/src/services/model-tiers/model-tier.wiring.js.map +1 -0
- package/dist/backend/backend/src/services/model-tiers/tier-quality.d.ts +102 -0
- package/dist/backend/backend/src/services/model-tiers/tier-quality.d.ts.map +1 -0
- package/dist/backend/backend/src/services/model-tiers/tier-quality.js +116 -0
- package/dist/backend/backend/src/services/model-tiers/tier-quality.js.map +1 -0
- package/dist/backend/backend/src/services/model-tiers/tier-usage-report.d.ts +86 -0
- package/dist/backend/backend/src/services/model-tiers/tier-usage-report.d.ts.map +1 -0
- package/dist/backend/backend/src/services/model-tiers/tier-usage-report.js +148 -0
- package/dist/backend/backend/src/services/model-tiers/tier-usage-report.js.map +1 -0
- package/dist/backend/backend/src/services/skill/skill-permission.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/skill/skill-permission.service.js +1 -0
- package/dist/backend/backend/src/services/skill/skill-permission.service.js.map +1 -1
- package/dist/backend/backend/src/types/decision.types.d.ts +41 -1
- package/dist/backend/backend/src/types/decision.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/index.d.ts +20 -0
- package/dist/backend/backend/src/types/index.d.ts.map +1 -1
- package/dist/backend/backend/src/types/index.js.map +1 -1
- package/dist/backend/backend/src/utils/member-default-model.utils.d.ts +24 -8
- package/dist/backend/backend/src/utils/member-default-model.utils.d.ts.map +1 -1
- package/dist/backend/backend/src/utils/member-default-model.utils.js +33 -7
- package/dist/backend/backend/src/utils/member-default-model.utils.js.map +1 -1
- package/dist/backend/backend/src/utils/model-tier.utils.d.ts +68 -0
- package/dist/backend/backend/src/utils/model-tier.utils.d.ts.map +1 -0
- package/dist/backend/backend/src/utils/model-tier.utils.js +111 -0
- package/dist/backend/backend/src/utils/model-tier.utils.js.map +1 -0
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +46 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +53 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-builder.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-builder.service.js +16 -0
- package/dist/cli/backend/src/services/ai/prompt-builder.service.js.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/model-tiers.module.d.ts +34 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/model-tiers.module.d.ts.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/model-tiers.module.js +54 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/model-tiers.module.js.map +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/prompt-assembly.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/prompt-assembly.service.js +2 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/prompt-assembly.service.js.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/prompt-module.interface.d.ts +13 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/prompt-module.interface.d.ts.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/prompt-module.interface.js.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision.service.d.ts +2 -0
- package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision.service.js +1 -0
- package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -1
- package/dist/cli/backend/src/types/decision.types.d.ts +41 -1
- package/dist/cli/backend/src/types/decision.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/index.d.ts +20 -0
- package/dist/cli/backend/src/types/index.d.ts.map +1 -1
- package/dist/cli/backend/src/types/index.js.map +1 -1
- package/dist/cli/backend/src/utils/member-default-model.utils.d.ts +24 -8
- package/dist/cli/backend/src/utils/member-default-model.utils.d.ts.map +1 -1
- package/dist/cli/backend/src/utils/member-default-model.utils.js +33 -7
- package/dist/cli/backend/src/utils/member-default-model.utils.js.map +1 -1
- package/dist/cli/backend/src/utils/model-tier.utils.d.ts +68 -0
- package/dist/cli/backend/src/utils/model-tier.utils.d.ts.map +1 -0
- package/dist/cli/backend/src/utils/model-tier.utils.js +111 -0
- package/dist/cli/backend/src/utils/model-tier.utils.js.map +1 -0
- package/frontend/dist/assets/{index-7e5ebf78.js → index-368419df.js} +266 -266
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModelTierService — model tiers and "Optimize usage" (crewly#1173 phase 1,
|
|
3
|
+
* specs/2026-10-08-model-tiers.md).
|
|
4
|
+
*
|
|
5
|
+
* Recommendation mode: nothing here changes a member's tier on its own.
|
|
6
|
+
*
|
|
7
|
+
* 1. Review: weekly while a team has "Optimize usage" on (and on demand), the
|
|
8
|
+
* team lead gets a compact usage report per member and is asked to
|
|
9
|
+
* 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.
|
|
15
|
+
* 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).
|
|
18
|
+
*
|
|
19
|
+
* @module services/model-tiers/model-tier.service
|
|
20
|
+
*/
|
|
21
|
+
import { randomUUID } from 'crypto';
|
|
22
|
+
import { MODEL_TIER_CONSTANTS, RUNTIME_TYPES } from '../../constants.js';
|
|
23
|
+
import { effectiveMemberModelId } from '../../utils/member-default-model.utils.js';
|
|
24
|
+
import { isModelTier, parseTierModels, resolveTierModel, tierMapFor, tierRank } from '../../utils/model-tier.utils.js';
|
|
25
|
+
import { getTeamLeads, isTeamLead } from '../../utils/team.utils.js';
|
|
26
|
+
import { resolveRate } from '../monitoring/model-pricing.js';
|
|
27
|
+
import { memberLedgerKeys } from '../tl-delegation/lead-share.js';
|
|
28
|
+
import { baselineBefore, describeStats, judgeGuard, statsAfter } from './tier-quality.js';
|
|
29
|
+
import { buildTierReport, renderTierReport } from './tier-usage-report.js';
|
|
30
|
+
const C = MODEL_TIER_CONSTANTS;
|
|
31
|
+
const APPLY_KEY = 'a';
|
|
32
|
+
const KEEP_KEY = 'b';
|
|
33
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
34
|
+
/** A refusal with an HTTP status. */
|
|
35
|
+
export class ModelTierError extends Error {
|
|
36
|
+
status;
|
|
37
|
+
constructor(status, message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.status = status;
|
|
40
|
+
this.name = 'ModelTierError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function sessionOf(m) {
|
|
44
|
+
return m.sessionName || m.agentId || '';
|
|
45
|
+
}
|
|
46
|
+
function oneLine(v, max = C.MAX_REASON_CHARS) {
|
|
47
|
+
return typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '';
|
|
48
|
+
}
|
|
49
|
+
function tierLabel(tier, model) {
|
|
50
|
+
return `${tier ?? 'no tier'}${model ? ` (${model})` : ''}`;
|
|
51
|
+
}
|
|
52
|
+
/** Service. */
|
|
53
|
+
export class ModelTierService {
|
|
54
|
+
deps;
|
|
55
|
+
timer = null;
|
|
56
|
+
ticking = false;
|
|
57
|
+
constructor(deps) {
|
|
58
|
+
this.deps = deps;
|
|
59
|
+
}
|
|
60
|
+
now() {
|
|
61
|
+
return this.deps.now ? this.deps.now() : new Date();
|
|
62
|
+
}
|
|
63
|
+
/** Start the hourly tick (due reviews, stale drafts, quality guard). */
|
|
64
|
+
start() {
|
|
65
|
+
if (this.timer)
|
|
66
|
+
return;
|
|
67
|
+
this.timer = setInterval(() => void this.tick().catch((err) => this.deps.logger?.warn('Model tier tick failed', { error: String(err) })), C.TICK_MS);
|
|
68
|
+
this.timer.unref?.();
|
|
69
|
+
}
|
|
70
|
+
/** Stop the tick. */
|
|
71
|
+
stop() {
|
|
72
|
+
if (this.timer)
|
|
73
|
+
clearInterval(this.timer);
|
|
74
|
+
this.timer = null;
|
|
75
|
+
}
|
|
76
|
+
async team(teamId) {
|
|
77
|
+
const team = (await this.deps.getTeams()).find((t) => t.id === teamId);
|
|
78
|
+
if (!team)
|
|
79
|
+
throw new ModelTierError(404, 'Team not found');
|
|
80
|
+
return team;
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------- settings
|
|
83
|
+
/**
|
|
84
|
+
* The team's tier settings and review state.
|
|
85
|
+
*
|
|
86
|
+
* @param teamId - Team id
|
|
87
|
+
* @returns View
|
|
88
|
+
*/
|
|
89
|
+
async settings(teamId) {
|
|
90
|
+
const team = await this.team(teamId);
|
|
91
|
+
const state = await this.deps.store.get(teamId);
|
|
92
|
+
await this.refreshOpenDecision(teamId, state);
|
|
93
|
+
const members = (team.members ?? []).filter((m) => m.role !== 'orchestrator');
|
|
94
|
+
const tierMaps = {};
|
|
95
|
+
for (const m of members) {
|
|
96
|
+
const rt = m.runtimeType ?? RUNTIME_TYPES.CLAUDE_CODE;
|
|
97
|
+
tierMaps[rt] ??= tierMapFor(rt, team.tierModels);
|
|
98
|
+
}
|
|
99
|
+
const last = state.lastReviewAt ? Date.parse(state.lastReviewAt) : null;
|
|
100
|
+
return {
|
|
101
|
+
teamId,
|
|
102
|
+
optimizeUsage: team.optimizeUsage === true,
|
|
103
|
+
tierModels: team.tierModels ?? {},
|
|
104
|
+
routingRules: team.tierRoutingRules ?? [],
|
|
105
|
+
tierMaps,
|
|
106
|
+
members: members.map((m) => {
|
|
107
|
+
const rt = m.runtimeType ?? RUNTIME_TYPES.CLAUDE_CODE;
|
|
108
|
+
return {
|
|
109
|
+
id: m.id,
|
|
110
|
+
name: m.name,
|
|
111
|
+
isLead: isTeamLead(team, m),
|
|
112
|
+
runtime: rt,
|
|
113
|
+
tier: m.tier ?? null,
|
|
114
|
+
modelId: m.modelId ?? null,
|
|
115
|
+
model: effectiveMemberModelId(team, { ...m, runtimeType: rt }) ?? 'runtime default',
|
|
116
|
+
};
|
|
117
|
+
}),
|
|
118
|
+
review: {
|
|
119
|
+
lastReviewAt: state.lastReviewAt ?? null,
|
|
120
|
+
nextReviewAt: team.optimizeUsage ? new Date(last === null ? this.now().getTime() : last + C.REVIEW_INTERVAL_DAYS * DAY_MS).toISOString() : null,
|
|
121
|
+
drafting: !!state.draft,
|
|
122
|
+
openDecisionId: state.openDecisionId ?? null,
|
|
123
|
+
recent: state.applied.slice(-10).reverse().map((a) => ({ memberName: a.memberName, from: a.from, to: a.to, appliedAt: a.appliedAt, guard: a.guard, decisionId: a.decisionId })),
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Change the toggle, the team's tier map, or members' tiers (owner).
|
|
129
|
+
*
|
|
130
|
+
* @param teamId - Team id
|
|
131
|
+
* @param patch - Fields to change
|
|
132
|
+
* @returns The new view
|
|
133
|
+
* @throws ModelTierError(400) on invalid input
|
|
134
|
+
*/
|
|
135
|
+
async updateSettings(teamId, patch) {
|
|
136
|
+
const team = await this.team(teamId);
|
|
137
|
+
if (patch.optimizeUsage !== undefined) {
|
|
138
|
+
if (typeof patch.optimizeUsage !== 'boolean')
|
|
139
|
+
throw new ModelTierError(400, 'optimizeUsage is true or false');
|
|
140
|
+
team.optimizeUsage = patch.optimizeUsage;
|
|
141
|
+
}
|
|
142
|
+
if (patch.tierModels !== undefined) {
|
|
143
|
+
const parsed = parseTierModels(patch.tierModels);
|
|
144
|
+
if (!parsed.ok)
|
|
145
|
+
throw new ModelTierError(400, parsed.error);
|
|
146
|
+
team.tierModels = parsed.value;
|
|
147
|
+
}
|
|
148
|
+
if (patch.memberTiers !== undefined) {
|
|
149
|
+
if (!patch.memberTiers || typeof patch.memberTiers !== 'object' || Array.isArray(patch.memberTiers)) {
|
|
150
|
+
throw new ModelTierError(400, 'memberTiers is { "<memberId>": "strong"|"mid"|"weak"|"" }');
|
|
151
|
+
}
|
|
152
|
+
for (const [memberId, tier] of Object.entries(patch.memberTiers)) {
|
|
153
|
+
const m = team.members.find((x) => x.id === memberId);
|
|
154
|
+
if (!m)
|
|
155
|
+
throw new ModelTierError(400, `No member ${memberId} in this team`);
|
|
156
|
+
if (tier === '' || tier === null)
|
|
157
|
+
delete m.tier;
|
|
158
|
+
else if (isModelTier(tier))
|
|
159
|
+
m.tier = tier;
|
|
160
|
+
else
|
|
161
|
+
throw new ModelTierError(400, `Invalid tier for ${m.name}: use strong, mid or weak`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
team.updatedAt = this.now().toISOString();
|
|
165
|
+
await this.deps.saveTeam(team);
|
|
166
|
+
return this.settings(teamId);
|
|
167
|
+
}
|
|
168
|
+
// ------------------------------------------------------------------ review
|
|
169
|
+
/**
|
|
170
|
+
* Build the usage report of a team.
|
|
171
|
+
*
|
|
172
|
+
* @param teamId - Team id
|
|
173
|
+
* @returns Report
|
|
174
|
+
*/
|
|
175
|
+
async report(teamId) {
|
|
176
|
+
const team = await this.team(teamId);
|
|
177
|
+
return buildTierReport({ team, forEachEvent: this.deps.forEachEvent, workItems: await this.deps.workItems(), now: this.now() });
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Send the team lead its review: the report and what to do with it.
|
|
181
|
+
*
|
|
182
|
+
* @param teamId - Team id
|
|
183
|
+
* @param trigger - `weekly` (tick) or `on_demand` (owner / API)
|
|
184
|
+
* @returns The review id and the lead it went to
|
|
185
|
+
* @throws ModelTierError 404 unknown team, 409 no lead / a proposal still waits for the owner
|
|
186
|
+
*/
|
|
187
|
+
async startReview(teamId, trigger) {
|
|
188
|
+
const team = await this.team(teamId);
|
|
189
|
+
const lead = getTeamLeads(team)[0];
|
|
190
|
+
if (!lead || !sessionOf(lead))
|
|
191
|
+
throw new ModelTierError(409, `${team.name} has no team lead to review its usage`);
|
|
192
|
+
const state = await this.deps.store.get(teamId);
|
|
193
|
+
await this.refreshOpenDecision(teamId, state);
|
|
194
|
+
if (state.openDecisionId)
|
|
195
|
+
throw new ModelTierError(409, `The last tier proposal (${state.openDecisionId}) still waits for the owner`);
|
|
196
|
+
const report = buildTierReport({ team, forEachEvent: this.deps.forEachEvent, workItems: await this.deps.workItems(), now: this.now() });
|
|
197
|
+
const reviewId = `TR-${randomUUID().slice(0, 8)}`;
|
|
198
|
+
const at = this.now().toISOString();
|
|
199
|
+
await this.deps.store.update(teamId, (s) => ({
|
|
200
|
+
...s,
|
|
201
|
+
lastReviewAt: at,
|
|
202
|
+
lastReviewTrigger: trigger,
|
|
203
|
+
draft: { reviewId, askerSession: sessionOf(lead), startedAt: at, updatedAt: at, changes: [], routing: [] },
|
|
204
|
+
}));
|
|
205
|
+
const delivered = await this.deps.deliverToAgent(sessionOf(lead), this.reviewMessage(team, report, reviewId, trigger)).catch(() => false);
|
|
206
|
+
this.deps.logger?.info('Model tier review sent to the team lead', { teamId, lead: sessionOf(lead), reviewId, trigger, delivered });
|
|
207
|
+
return { reviewId, lead: lead.name, delivered };
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The review message for the lead.
|
|
211
|
+
*
|
|
212
|
+
* @param team - Team
|
|
213
|
+
* @param report - Usage report
|
|
214
|
+
* @param reviewId - Review id
|
|
215
|
+
* @param trigger - Why now
|
|
216
|
+
* @returns Text
|
|
217
|
+
*/
|
|
218
|
+
reviewMessage(team, report, reviewId, trigger) {
|
|
219
|
+
const skill = `bash ${this.deps.tlSkillsPath}/propose-tier-change/execute.sh`;
|
|
220
|
+
const rules = team.tierRoutingRules?.length ? `\nRouting rules in force: ${team.tierRoutingRules.map((r) => `"${r}"`).join('; ')}.` : '';
|
|
221
|
+
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.`,
|
|
223
|
+
'',
|
|
224
|
+
renderTierReport(report),
|
|
225
|
+
rules,
|
|
226
|
+
'',
|
|
227
|
+
'Do this now (a few minutes, no long analysis):',
|
|
228
|
+
'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
|
+
`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
|
+
`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).`,
|
|
232
|
+
'Do not lower a member whose work was often sent back, and do not lower yourself below mid.',
|
|
233
|
+
].join('\n');
|
|
234
|
+
}
|
|
235
|
+
// ---------------------------------------------------------------- proposals
|
|
236
|
+
/**
|
|
237
|
+
* The lead's skill call: add a change or a routing rule to the draft,
|
|
238
|
+
* submit it, or drop it.
|
|
239
|
+
*
|
|
240
|
+
* @param callerSession - The calling agent (must lead a team)
|
|
241
|
+
* @param input - One of member+tier+reason, routing, submit, clear
|
|
242
|
+
* @returns What happened
|
|
243
|
+
* @throws ModelTierError 400 bad input, 403 not a lead, 409 toggle off / nothing to submit
|
|
244
|
+
*/
|
|
245
|
+
async propose(callerSession, input) {
|
|
246
|
+
const ctx = await this.leadContext(callerSession);
|
|
247
|
+
if (input.clear === true) {
|
|
248
|
+
await this.deps.store.update(ctx.team.id, (s) => ({ ...s, draft: undefined }));
|
|
249
|
+
return { cleared: true };
|
|
250
|
+
}
|
|
251
|
+
if (input.submit === true)
|
|
252
|
+
return this.submit(ctx);
|
|
253
|
+
if (ctx.team.optimizeUsage !== true) {
|
|
254
|
+
throw new ModelTierError(409, `"Optimize usage" is off for ${ctx.team.name}. Tier proposals are only made while it is on; the owner turns it on in the team settings.`);
|
|
255
|
+
}
|
|
256
|
+
if (input.routing !== undefined) {
|
|
257
|
+
const rule = oneLine(input.routing);
|
|
258
|
+
if (!rule)
|
|
259
|
+
throw new ModelTierError(400, 'routing is one rule, e.g. "polling / formatting / sorting -> Ella"');
|
|
260
|
+
const draft = await this.updateDraft(ctx, (d) => {
|
|
261
|
+
if (!d.routing.includes(rule))
|
|
262
|
+
d.routing.push(rule);
|
|
263
|
+
if (d.routing.length > C.MAX_ROUTING_RULES)
|
|
264
|
+
throw new ModelTierError(400, `At most ${C.MAX_ROUTING_RULES} routing rules per proposal`);
|
|
265
|
+
});
|
|
266
|
+
return { added: 'routing', rule, draft: summarizeDraft(draft) };
|
|
267
|
+
}
|
|
268
|
+
const memberRef = oneLine(input.member, 100);
|
|
269
|
+
if (!memberRef)
|
|
270
|
+
throw new ModelTierError(400, 'member is the member name (or id) to move');
|
|
271
|
+
if (!isModelTier(input.tier))
|
|
272
|
+
throw new ModelTierError(400, 'tier is strong, mid or weak');
|
|
273
|
+
const reason = oneLine(input.reason);
|
|
274
|
+
if (!reason)
|
|
275
|
+
throw new ModelTierError(400, 'reason is required: the work this member does and why the tier fits');
|
|
276
|
+
const lower = memberRef.toLowerCase();
|
|
277
|
+
const member = ctx.team.members.find((m) => m.role !== 'orchestrator' && (m.id === memberRef || m.name.toLowerCase() === lower || sessionOf(m) === memberRef));
|
|
278
|
+
if (!member)
|
|
279
|
+
throw new ModelTierError(400, `No member "${memberRef}" in ${ctx.team.name}`);
|
|
280
|
+
const to = input.tier;
|
|
281
|
+
if (member.id === ctx.lead.id && tierRank(to) < tierRank('mid'))
|
|
282
|
+
throw new ModelTierError(400, 'A team lead stays at mid or above');
|
|
283
|
+
const entry = this.entryFor(ctx.team, member, to, reason);
|
|
284
|
+
if (entry.from === to && !entry.clearsModelId)
|
|
285
|
+
throw new ModelTierError(409, `${member.name} is already on ${to}`);
|
|
286
|
+
const draft = await this.updateDraft(ctx, (d) => {
|
|
287
|
+
d.changes = d.changes.filter((c) => c.memberId !== member.id);
|
|
288
|
+
d.changes.push(entry);
|
|
289
|
+
});
|
|
290
|
+
return { added: 'change', change: `${member.name}: ${tierLabel(entry.from, entry.fromModel)} -> ${tierLabel(to, entry.toModel)}`, draft: summarizeDraft(draft) };
|
|
291
|
+
}
|
|
292
|
+
/** A change entry for a member moving to `to`. */
|
|
293
|
+
entryFor(team, member, to, reason) {
|
|
294
|
+
const runtime = member.runtimeType ?? RUNTIME_TYPES.CLAUDE_CODE;
|
|
295
|
+
const fromModel = effectiveMemberModelId(team, { ...member, runtimeType: runtime }) ?? 'runtime default';
|
|
296
|
+
const toModel = resolveTierModel(runtime, to, team.tierModels) ?? 'runtime default';
|
|
297
|
+
return {
|
|
298
|
+
memberId: member.id,
|
|
299
|
+
memberName: member.name,
|
|
300
|
+
session: sessionOf(member),
|
|
301
|
+
from: member.tier ?? null,
|
|
302
|
+
to,
|
|
303
|
+
fromModel,
|
|
304
|
+
toModel,
|
|
305
|
+
...(member.modelId ? { clearsModelId: member.modelId } : {}),
|
|
306
|
+
reason,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
async updateDraft(ctx, change) {
|
|
310
|
+
const at = this.now().toISOString();
|
|
311
|
+
let error;
|
|
312
|
+
const state = await this.deps.store.update(ctx.team.id, (s) => {
|
|
313
|
+
const draft = s.draft
|
|
314
|
+
? { ...s.draft, changes: [...s.draft.changes], routing: [...s.draft.routing] }
|
|
315
|
+
: { reviewId: `TR-${randomUUID().slice(0, 8)}`, askerSession: sessionOf(ctx.lead), startedAt: at, updatedAt: at, changes: [], routing: [] };
|
|
316
|
+
try {
|
|
317
|
+
change(draft);
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
error = err;
|
|
321
|
+
return s;
|
|
322
|
+
}
|
|
323
|
+
draft.updatedAt = at;
|
|
324
|
+
return { ...s, draft };
|
|
325
|
+
});
|
|
326
|
+
if (error)
|
|
327
|
+
throw error;
|
|
328
|
+
return state.draft;
|
|
329
|
+
}
|
|
330
|
+
async submit(ctx) {
|
|
331
|
+
const state = await this.deps.store.get(ctx.team.id);
|
|
332
|
+
const draft = state.draft;
|
|
333
|
+
if (!draft || (draft.changes.length === 0 && draft.routing.length === 0)) {
|
|
334
|
+
await this.deps.store.update(ctx.team.id, (s) => ({ ...s, draft: undefined }));
|
|
335
|
+
return { submitted: false, note: 'No changes proposed; nothing goes to the owner. The review is closed.' };
|
|
336
|
+
}
|
|
337
|
+
await this.refreshOpenDecision(ctx.team.id, state);
|
|
338
|
+
if (state.openDecisionId)
|
|
339
|
+
throw new ModelTierError(409, `The last tier proposal (${state.openDecisionId}) still waits for the owner; add to it next review`);
|
|
340
|
+
const decision = await this.askOwner(ctx.team, ctx.lead, draft, 'review');
|
|
341
|
+
await this.deps.store.update(ctx.team.id, (s) => ({ ...s, draft: undefined, openDecisionId: decision.id }));
|
|
342
|
+
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
|
+
}
|
|
344
|
+
/** Post the owner card for a draft (review) or a revert. */
|
|
345
|
+
async askOwner(team, asker, draft, kind, extraBody = []) {
|
|
346
|
+
const decisions = this.deps.decisions();
|
|
347
|
+
if (!decisions)
|
|
348
|
+
throw new ModelTierError(503, 'Owner decision cards are not running on this machine, so the proposal cannot be sent.');
|
|
349
|
+
const subject = {
|
|
350
|
+
teamId: team.id,
|
|
351
|
+
teamName: team.name,
|
|
352
|
+
reviewId: draft.reviewId,
|
|
353
|
+
kind,
|
|
354
|
+
changes: draft.changes,
|
|
355
|
+
routing: draft.routing,
|
|
356
|
+
askerSession: sessionOf(asker),
|
|
357
|
+
askerName: asker.name,
|
|
358
|
+
};
|
|
359
|
+
const summary = draft.changes.map((c) => `${c.memberName} ${c.from ?? 'no tier'}→${targetLabel(c)}`).join(', ');
|
|
360
|
+
const question = kind === 'revert'
|
|
361
|
+
? `Move ${summary.replace(/→/g, ' back to ')}? Quality dropped after the change.`
|
|
362
|
+
: `Change model tiers for ${team.name}? ${asker.name} proposes${summary ? `: ${summary}` : ' routing rules only'}.`;
|
|
363
|
+
const body = [...extraBody];
|
|
364
|
+
for (const c of draft.changes) {
|
|
365
|
+
const fixed = c.clearsModelId ? ` (clears the fixed model \`${c.clearsModelId}\`)` : c.restoreModelId ? ` (restores the fixed model \`${c.restoreModelId}\`)` : '';
|
|
366
|
+
body.push(`• *${c.memberName}*: ${tierLabel(c.from, c.fromModel)} → ${tierLabel(c.clearTier ? null : c.to, c.toModel)}${fixed} — ${c.reason}`);
|
|
367
|
+
}
|
|
368
|
+
if (draft.routing.length)
|
|
369
|
+
body.push(`Routing: ${draft.routing.map((r) => `"${r}"`).join('; ')}`);
|
|
370
|
+
const saving = kind === 'review' ? this.estimateSaving(team, draft.changes) : null;
|
|
371
|
+
if (saving !== null)
|
|
372
|
+
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.');
|
|
374
|
+
const options = [
|
|
375
|
+
{ key: APPLY_KEY, label: kind === 'revert' ? 'Move back' : 'Apply', detail: 'takes effect at each member\'s next start' },
|
|
376
|
+
{ key: KEEP_KEY, label: 'Keep as is', detail: 'nothing changes' },
|
|
377
|
+
];
|
|
378
|
+
const channel = await this.deps.teamChannelOf?.(team.id).catch(() => null);
|
|
379
|
+
return decisions.askPrebuilt({
|
|
380
|
+
kind: C.DECISION_KIND,
|
|
381
|
+
asker: sessionOf(asker),
|
|
382
|
+
question,
|
|
383
|
+
title: kind === 'revert' ? `Model tiers — quality check, ${team.name}` : `Model tiers — ${team.name}`,
|
|
384
|
+
body,
|
|
385
|
+
options,
|
|
386
|
+
defaultKey: KEEP_KEY,
|
|
387
|
+
yesKey: APPLY_KEY,
|
|
388
|
+
deadline: new Date(this.now().getTime() + C.DECISION_DEADLINE_MS),
|
|
389
|
+
tierChange: subject,
|
|
390
|
+
...(channel ? { place: { slackChannelId: channel } } : {}),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Weekly USD difference of a set of changes at list price, from each
|
|
395
|
+
* member's cost over the report window scaled by the output-price ratio of
|
|
396
|
+
* its new model to its current one. Null when nothing can be priced.
|
|
397
|
+
*/
|
|
398
|
+
estimateSaving(team, changes) {
|
|
399
|
+
const report = buildTierReport({ team, forEachEvent: this.deps.forEachEvent, workItems: [], now: this.now() });
|
|
400
|
+
let total = 0;
|
|
401
|
+
let priced = false;
|
|
402
|
+
for (const c of changes) {
|
|
403
|
+
const row = report.rows.find((r) => r.memberId === c.memberId);
|
|
404
|
+
if (!row || row.costUsd <= 0)
|
|
405
|
+
continue;
|
|
406
|
+
const fromModel = this.priceableModel(row.runtime, c.fromModel, team);
|
|
407
|
+
const toModel = this.priceableModel(row.runtime, c.toModel, team);
|
|
408
|
+
if (!fromModel || !toModel)
|
|
409
|
+
continue;
|
|
410
|
+
const ratio = resolveRate(toModel).output / resolveRate(fromModel).output;
|
|
411
|
+
if (!Number.isFinite(ratio))
|
|
412
|
+
continue;
|
|
413
|
+
total += row.costUsd * (1 - ratio);
|
|
414
|
+
priced = true;
|
|
415
|
+
}
|
|
416
|
+
return priced ? Math.round(total * 100) / 100 : null;
|
|
417
|
+
}
|
|
418
|
+
/** A model name the pricing table can read ("runtime default" → the runtime's strong model). */
|
|
419
|
+
priceableModel(runtime, model, team) {
|
|
420
|
+
if (!model)
|
|
421
|
+
return null;
|
|
422
|
+
if (model !== 'runtime default')
|
|
423
|
+
return model;
|
|
424
|
+
return tierMapFor(runtime, team.tierModels).strong ?? null;
|
|
425
|
+
}
|
|
426
|
+
async leadContext(callerSession) {
|
|
427
|
+
if (!callerSession)
|
|
428
|
+
throw new ModelTierError(403, 'Only a team lead proposes tier changes (no agent identity on this call)');
|
|
429
|
+
for (const team of await this.deps.getTeams()) {
|
|
430
|
+
if (team.archived)
|
|
431
|
+
continue;
|
|
432
|
+
const me = (team.members ?? []).find((m) => sessionOf(m) === callerSession || m.sessionName === callerSession);
|
|
433
|
+
if (me && isTeamLead(team, me))
|
|
434
|
+
return { team, lead: me };
|
|
435
|
+
}
|
|
436
|
+
throw new ModelTierError(403, 'Only a team lead proposes tier changes for its team');
|
|
437
|
+
}
|
|
438
|
+
/** Clear `openDecisionId` when that decision is no longer open. */
|
|
439
|
+
async refreshOpenDecision(teamId, state) {
|
|
440
|
+
if (!state.openDecisionId)
|
|
441
|
+
return;
|
|
442
|
+
const d = await this.deps.decisions()?.get(state.openDecisionId).catch(() => null);
|
|
443
|
+
if (d && d.status === 'open')
|
|
444
|
+
return;
|
|
445
|
+
if (!d && !this.deps.decisions())
|
|
446
|
+
return;
|
|
447
|
+
state.openDecisionId = undefined;
|
|
448
|
+
await this.deps.store.update(teamId, (s) => ({ ...s, openDecisionId: undefined }));
|
|
449
|
+
}
|
|
450
|
+
// ------------------------------------------------------------------- apply
|
|
451
|
+
/**
|
|
452
|
+
* A tier decision settled: on the owner's yes apply the tiers (and routing
|
|
453
|
+
* rules); otherwise nothing changes. Returns the note for the lead.
|
|
454
|
+
*
|
|
455
|
+
* @param decision - The settled decision
|
|
456
|
+
* @returns Note for the asking lead, or null
|
|
457
|
+
*/
|
|
458
|
+
async onSettled(decision) {
|
|
459
|
+
const s = decision.tierChange;
|
|
460
|
+
if (!s)
|
|
461
|
+
return null;
|
|
462
|
+
await this.deps.store.update(s.teamId, (st) => (st.openDecisionId === decision.id ? { ...st, openDecisionId: undefined } : st));
|
|
463
|
+
if (decision.status !== 'resolved' || decision.chosenKey !== APPLY_KEY) {
|
|
464
|
+
if (s.kind === 'revert')
|
|
465
|
+
return `[MODEL TIERS] The owner kept the lower tier (${decision.id}). Nothing changed.`;
|
|
466
|
+
return `[MODEL TIERS] The owner kept the tiers as they are (${decision.id}). Nothing changed; do not propose the same change again this week.`;
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
const applied = await this.apply(s, decision.id);
|
|
470
|
+
const lines = applied.map((c) => `${c.memberName} → ${tierLabel(c.clearTier ? null : c.to, c.toModel)}`);
|
|
471
|
+
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}`;
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
this.deps.logger?.warn('Applying tier changes failed', { decisionId: decision.id, error: String(err) });
|
|
476
|
+
return `[MODEL TIERS] The owner approved ${decision.id}, but applying it failed (${err instanceof Error ? err.message : String(err)}). Tell the owner.`;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Apply an approved subject to the team and record it for the guard.
|
|
481
|
+
*
|
|
482
|
+
* @param s - Subject
|
|
483
|
+
* @param decisionId - The approving decision
|
|
484
|
+
* @returns The applied entries
|
|
485
|
+
*/
|
|
486
|
+
async apply(s, decisionId) {
|
|
487
|
+
const team = await this.team(s.teamId);
|
|
488
|
+
const items = await this.deps.workItems().catch(() => []);
|
|
489
|
+
const now = this.now();
|
|
490
|
+
const applied = [];
|
|
491
|
+
for (const c of s.changes) {
|
|
492
|
+
const m = team.members.find((x) => x.id === c.memberId);
|
|
493
|
+
if (!m)
|
|
494
|
+
continue;
|
|
495
|
+
if (c.clearTier)
|
|
496
|
+
delete m.tier;
|
|
497
|
+
else
|
|
498
|
+
m.tier = c.to;
|
|
499
|
+
if (c.clearsModelId && m.modelId === c.clearsModelId)
|
|
500
|
+
delete m.modelId;
|
|
501
|
+
if (c.restoreModelId)
|
|
502
|
+
m.modelId = c.restoreModelId;
|
|
503
|
+
const lowered = !c.clearTier && tierRank(c.to) < tierRank(effectiveFromTier(c, m.runtimeType ?? RUNTIME_TYPES.CLAUDE_CODE, team.tierModels));
|
|
504
|
+
applied.push({
|
|
505
|
+
...c,
|
|
506
|
+
appliedAt: now.toISOString(),
|
|
507
|
+
decisionId,
|
|
508
|
+
baseline: baselineBefore(items, memberLedgerKeys(m), now.getTime()),
|
|
509
|
+
guard: lowered && s.kind === 'review' ? 'watching' : 'not_watched',
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
if (s.routing.length) {
|
|
513
|
+
const rules = [...(team.tierRoutingRules ?? [])];
|
|
514
|
+
for (const r of s.routing)
|
|
515
|
+
if (!rules.includes(r))
|
|
516
|
+
rules.push(r);
|
|
517
|
+
team.tierRoutingRules = rules.slice(-20);
|
|
518
|
+
}
|
|
519
|
+
team.updatedAt = now.toISOString();
|
|
520
|
+
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}`) });
|
|
523
|
+
return applied;
|
|
524
|
+
}
|
|
525
|
+
// -------------------------------------------------------------------- tick
|
|
526
|
+
/**
|
|
527
|
+
* Hourly: start due weekly reviews, send drafts the lead never submitted,
|
|
528
|
+
* and run the quality guard.
|
|
529
|
+
*
|
|
530
|
+
* @returns What it did (for tests / logs)
|
|
531
|
+
*/
|
|
532
|
+
async tick() {
|
|
533
|
+
const out = { reviews: [], autoSubmitted: [], reverts: [] };
|
|
534
|
+
if (this.ticking)
|
|
535
|
+
return out;
|
|
536
|
+
this.ticking = true;
|
|
537
|
+
try {
|
|
538
|
+
const teams = await this.deps.getTeams();
|
|
539
|
+
const states = await this.deps.store.all();
|
|
540
|
+
const nowMs = this.now().getTime();
|
|
541
|
+
for (const team of teams) {
|
|
542
|
+
if (team.archived)
|
|
543
|
+
continue;
|
|
544
|
+
const state = states[team.id] ?? { applied: [] };
|
|
545
|
+
try {
|
|
546
|
+
if (state.applied.some((a) => a.guard === 'watching')) {
|
|
547
|
+
out.reverts.push(...(await this.guard(team)));
|
|
548
|
+
}
|
|
549
|
+
if (team.optimizeUsage !== true || team.paused)
|
|
550
|
+
continue;
|
|
551
|
+
if (state.draft && nowMs - Date.parse(state.draft.updatedAt) >= C.DRAFT_AUTO_SUBMIT_MS) {
|
|
552
|
+
const lead = team.members.find((m) => sessionOf(m) === state.draft.askerSession) ?? getTeamLeads(team)[0];
|
|
553
|
+
if (lead && (state.draft.changes.length || state.draft.routing.length)) {
|
|
554
|
+
const r = await this.submit({ team, lead });
|
|
555
|
+
if (r.submitted)
|
|
556
|
+
out.autoSubmitted.push(team.id);
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
await this.deps.store.update(team.id, (s) => ({ ...s, draft: undefined }));
|
|
560
|
+
}
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const due = !state.lastReviewAt || nowMs - Date.parse(state.lastReviewAt) >= C.REVIEW_INTERVAL_DAYS * DAY_MS;
|
|
564
|
+
if (due && !state.draft) {
|
|
565
|
+
await this.refreshOpenDecision(team.id, state);
|
|
566
|
+
if (state.openDecisionId)
|
|
567
|
+
continue;
|
|
568
|
+
const r = await this.startReview(team.id, 'weekly');
|
|
569
|
+
out.reviews.push(r.reviewId);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
catch (err) {
|
|
573
|
+
this.deps.logger?.warn('Model tier tick failed for a team', { teamId: team.id, error: err instanceof Error ? err.message : String(err) });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
finally {
|
|
578
|
+
this.ticking = false;
|
|
579
|
+
}
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Quality guard for one team: judge every watched (lowered) member and ask
|
|
584
|
+
* the owner to move back the ones whose work got worse.
|
|
585
|
+
*
|
|
586
|
+
* @param team - Team
|
|
587
|
+
* @returns Revert decision ids
|
|
588
|
+
*/
|
|
589
|
+
async guard(team) {
|
|
590
|
+
const state = await this.deps.store.get(team.id);
|
|
591
|
+
const watching = state.applied.filter((a) => a.guard === 'watching');
|
|
592
|
+
if (!watching.length)
|
|
593
|
+
return [];
|
|
594
|
+
const items = await this.deps.workItems();
|
|
595
|
+
const now = this.now();
|
|
596
|
+
const reverts = [];
|
|
597
|
+
const updates = new Map();
|
|
598
|
+
for (const a of watching) {
|
|
599
|
+
const m = team.members.find((x) => x.id === a.memberId);
|
|
600
|
+
const key = `${a.memberId}@${a.appliedAt}`;
|
|
601
|
+
if (!m || m.tier !== a.to) {
|
|
602
|
+
updates.set(key, { guard: 'ok', guardCheckedAt: now.toISOString() });
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const after = statsAfter(items, memberLedgerKeys(m), Date.parse(a.appliedAt));
|
|
606
|
+
const verdict = judgeGuard(a.baseline, after);
|
|
607
|
+
if (verdict === 'wait') {
|
|
608
|
+
if (now.getTime() - Date.parse(a.appliedAt) > C.GUARD_MAX_DAYS * DAY_MS)
|
|
609
|
+
updates.set(key, { guard: 'expired', guardCheckedAt: now.toISOString(), guardAfter: after });
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (verdict === 'ok') {
|
|
613
|
+
updates.set(key, { guard: 'ok', guardCheckedAt: now.toISOString(), guardAfter: after });
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const lead = getTeamLeads(team)[0];
|
|
617
|
+
if (!lead)
|
|
618
|
+
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
|
+
};
|
|
631
|
+
try {
|
|
632
|
+
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)}.`,
|
|
634
|
+
]);
|
|
635
|
+
updates.set(key, { guard: 'revert_proposed', guardDecisionId: d.id, guardCheckedAt: now.toISOString(), guardAfter: after });
|
|
636
|
+
reverts.push(d.id);
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
this.deps.logger?.warn('Quality guard could not ask the owner', { teamId: team.id, member: m.name, error: String(err) });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (updates.size) {
|
|
643
|
+
await this.deps.store.update(team.id, (s) => ({
|
|
644
|
+
...s,
|
|
645
|
+
applied: s.applied.map((a) => {
|
|
646
|
+
const u = updates.get(`${a.memberId}@${a.appliedAt}`);
|
|
647
|
+
return u && a.guard === 'watching' ? { ...a, ...u } : a;
|
|
648
|
+
}),
|
|
649
|
+
}));
|
|
650
|
+
}
|
|
651
|
+
return reverts;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/** "weak" / "no tier" for the target of a change. */
|
|
655
|
+
function targetLabel(c) {
|
|
656
|
+
return c.clearTier ? 'no tier' : c.to;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* The tier a member effectively ran on before a change: its tier, else the
|
|
660
|
+
* tier whose model it ran (a reviewed member on the Sonnet default = mid),
|
|
661
|
+
* else strong (the runtime default is the strongest model).
|
|
662
|
+
*
|
|
663
|
+
* @param c - Change entry
|
|
664
|
+
* @param runtime - The member's runtime
|
|
665
|
+
* @param overrides - The team's tier map
|
|
666
|
+
* @returns Tier
|
|
667
|
+
*/
|
|
668
|
+
export function effectiveFromTier(c, runtime, overrides) {
|
|
669
|
+
if (c.from)
|
|
670
|
+
return c.from;
|
|
671
|
+
const map = tierMapFor(runtime, overrides);
|
|
672
|
+
const hit = Object.keys(map).find((t) => map[t] === c.fromModel);
|
|
673
|
+
return hit ?? 'strong';
|
|
674
|
+
}
|
|
675
|
+
/** Short view of a draft for the skill output. */
|
|
676
|
+
function summarizeDraft(d) {
|
|
677
|
+
return {
|
|
678
|
+
reviewId: d.reviewId,
|
|
679
|
+
changes: d.changes.map((c) => `${c.memberName}: ${c.from ?? 'no tier'} -> ${c.to}`),
|
|
680
|
+
routing: d.routing,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
/** Process-wide instance (set by the wiring). */
|
|
684
|
+
let instance = null;
|
|
685
|
+
/**
|
|
686
|
+
* The running service, or null before it is wired.
|
|
687
|
+
*
|
|
688
|
+
* @returns Service
|
|
689
|
+
*/
|
|
690
|
+
export function getModelTierService() {
|
|
691
|
+
return instance;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Install (or clear) the running service.
|
|
695
|
+
*
|
|
696
|
+
* @param s - Service
|
|
697
|
+
*/
|
|
698
|
+
export function setModelTierService(s) {
|
|
699
|
+
instance = s;
|
|
700
|
+
}
|
|
701
|
+
//# sourceMappingURL=model-tier.service.js.map
|