@triflux/core 10.51.0 → 10.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/hub/bridge.mjs +65 -1172
  2. package/hub/index.mjs +2 -18
  3. package/hub/lib/process-utils.mjs +1 -33
  4. package/hub/team/claude-daemon-control.mjs +1 -2
  5. package/hub/team/claude-session-projection.mjs +5 -73
  6. package/hub/team/nativeProxy.mjs +759 -0
  7. package/hub/team/session-context.mjs +1 -1
  8. package/hud/constants.mjs +2 -0
  9. package/hud/hud-qos-status.mjs +21 -5
  10. package/hud/providers/gemini.mjs +20 -0
  11. package/hud/renderers.mjs +79 -46
  12. package/hud/utils.mjs +9 -5
  13. package/package.json +2 -4
  14. package/scripts/lib/agent-route-policy.mjs +0 -67
  15. package/scripts/lib/cli-claude.mjs +0 -12
  16. package/scripts/lib/env-probe.mjs +0 -70
  17. package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
  18. package/scripts/lib/mcp-guard-engine.mjs +40 -98
  19. package/scripts/lib/session-presence.mjs +1 -99
  20. package/hub/adaptive-diagnostic.mjs +0 -350
  21. package/hub/adaptive-inject.mjs +0 -224
  22. package/hub/adaptive-memory.mjs +0 -277
  23. package/hub/adaptive.mjs +0 -172
  24. package/hub/assign-callbacks.mjs +0 -170
  25. package/hub/delegator/contracts.mjs +0 -38
  26. package/hub/delegator/index.mjs +0 -14
  27. package/hub/delegator/schema/delegator-tools.schema.json +0 -250
  28. package/hub/delegator/service.mjs +0 -336
  29. package/hub/delegator/tool-definitions.mjs +0 -35
  30. package/hub/hitl.mjs +0 -202
  31. package/hub/lib/known-errors.json +0 -72
  32. package/hub/lib/memory-store.mjs +0 -1001
  33. package/hub/lib/phase-manager.mjs +0 -493
  34. package/hub/lib/state-snapshot.mjs +0 -293
  35. package/hub/lib/trace-recorder.mjs +0 -153
  36. package/hub/lib/uuidv7.mjs +0 -45
  37. package/hub/memory-doctor.mjs +0 -640
  38. package/hub/middleware/quota-middleware.mjs +0 -70
  39. package/hub/middleware/request-logger.mjs +0 -191
  40. package/hub/paths.mjs +0 -24
  41. package/hub/pipeline/index.mjs +0 -163
  42. package/hub/pipeline/state.mjs +0 -191
  43. package/hub/pipeline/transitions.mjs +0 -131
  44. package/hub/reflexion.mjs +0 -290
  45. package/hub/role-contract.mjs +0 -312
  46. package/hub/router.mjs +0 -2026
  47. package/hub/session-fingerprint.mjs +0 -418
  48. package/hub/state.mjs +0 -300
  49. package/hub/team/synapse-http.mjs +0 -311
  50. package/hub/team-bridge.mjs +0 -27
  51. package/scripts/lib/claudemd-scanner.mjs +0 -26
  52. package/scripts/lib/context.mjs +0 -51
  53. package/scripts/lib/handoff.mjs +0 -187
  54. package/scripts/lib/logger.mjs +0 -122
  55. package/scripts/lib/stealth-fetch.mjs +0 -176
package/hub/reflexion.mjs DELETED
@@ -1,290 +0,0 @@
1
- // 오류 패턴 정규화 및 adaptive rule 관리
2
-
3
- export const ADAPTIVE_RULE_TYPE = "adaptive";
4
- const DEFAULT_CONFIDENCE = 0.5;
5
- const ADAPTIVE_PROMOTION_STEP = 0.1;
6
- const ADAPTIVE_DECAY_STEP = 0.1;
7
- const ADAPTIVE_DELETE_THRESHOLD = 0.3;
8
-
9
- function clampConfidence(value) {
10
- const next = Number(value);
11
- if (!Number.isFinite(next)) return DEFAULT_CONFIDENCE;
12
- return Math.max(0, Math.min(1, next));
13
- }
14
-
15
- function safeJson(value) {
16
- try {
17
- return JSON.stringify(value);
18
- } catch {
19
- return "";
20
- }
21
- }
22
-
23
- function pickString(...values) {
24
- return (
25
- values.find((value) => typeof value === "string" && value.trim())?.trim() ||
26
- ""
27
- );
28
- }
29
-
30
- /** @deprecated 세션 기반 decay 제거로 미사용. 세션 인식 복원 시 재활용 예정. */
31
- function pickSessionCount(...values) {
32
- const raw = values.find((value) => Number.isFinite(Number(value)));
33
- return raw == null ? 0 : Math.max(0, Math.trunc(Number(raw)));
34
- }
35
-
36
- function pickSessionId(errorContext = {}) {
37
- return pickString(
38
- errorContext.session_id,
39
- errorContext.sessionId,
40
- errorContext.context?.session_id,
41
- errorContext.context?.sessionId,
42
- );
43
- }
44
-
45
- function pickProjectSlug(errorContext = {}) {
46
- return pickString(
47
- errorContext.projectSlug,
48
- errorContext.project_slug,
49
- errorContext.context?.projectSlug,
50
- errorContext.context?.project_slug,
51
- );
52
- }
53
-
54
- function compactObject(value) {
55
- return Object.fromEntries(
56
- Object.entries(value).filter(([, entry]) => entry != null && entry !== ""),
57
- );
58
- }
59
-
60
- function buildErrorText(errorContext = {}) {
61
- const parts = [
62
- pickString(errorContext.tool_output),
63
- pickString(errorContext.error),
64
- pickString(errorContext.tool_input?.command),
65
- errorContext.tool_result == null ? "" : safeJson(errorContext.tool_result),
66
- ].filter(Boolean);
67
- return parts.join("\n").trim();
68
- }
69
-
70
- function buildAdaptiveContext(errorContext = {}) {
71
- return compactObject({
72
- source: "PostToolUseFailure",
73
- tool_name: pickString(errorContext.tool_name),
74
- agent: pickString(errorContext.agent, errorContext.context?.agent),
75
- cli: pickString(errorContext.cli, errorContext.context?.cli),
76
- command: pickString(errorContext.tool_input?.command),
77
- file: pickString(
78
- errorContext.tool_input?.file_path,
79
- errorContext.context?.file,
80
- ),
81
- project_slug: pickProjectSlug(errorContext),
82
- });
83
- }
84
-
85
- function buildAdaptiveSolution(errorContext = {}, errorText = "") {
86
- const explicit = pickString(
87
- errorContext.systemMessage,
88
- errorContext.additionalContext,
89
- errorContext.hint,
90
- );
91
- if (explicit) return explicit;
92
- const toolName = pickString(errorContext.tool_name) || "tool";
93
- const command = pickString(errorContext.tool_input?.command);
94
- const summary = errorText.split("\n")[0]?.trim() || "반복 실패 패턴";
95
- if (command)
96
- return `${toolName} 재시도 전 입력을 검증하세요: ${command}\n원인: ${summary}`;
97
- return `${toolName} 재시도 전 실패 원인을 검증하세요: ${summary}`;
98
- }
99
-
100
- function parseAdaptiveRuleId(ruleId, projectSlug = "") {
101
- const value = pickString(ruleId);
102
- if (!value) return null;
103
- const nulSeparator = value.indexOf("\u0000");
104
- if (nulSeparator > 0) {
105
- return {
106
- project_slug: value.slice(0, nulSeparator),
107
- pattern: value.slice(nulSeparator + 1),
108
- };
109
- }
110
- if (projectSlug && value.startsWith(`${projectSlug}:`)) {
111
- return {
112
- project_slug: projectSlug,
113
- pattern: value.slice(projectSlug.length + 1),
114
- };
115
- }
116
- return null;
117
- }
118
-
119
- function resolveAdaptiveRuleIdentity(
120
- ruleIdOrProjectSlug,
121
- patternOrErrorContext,
122
- ) {
123
- if (typeof patternOrErrorContext === "string") {
124
- return {
125
- project_slug: pickString(ruleIdOrProjectSlug),
126
- pattern: pickString(patternOrErrorContext),
127
- };
128
- }
129
-
130
- const errorContext =
131
- patternOrErrorContext && typeof patternOrErrorContext === "object"
132
- ? patternOrErrorContext
133
- : {};
134
- const projectSlug = pickString(pickProjectSlug(errorContext));
135
- const fromRuleId = parseAdaptiveRuleId(ruleIdOrProjectSlug, projectSlug);
136
- const errorText = pickString(
137
- errorContext.error_pattern,
138
- errorContext.pattern,
139
- errorContext.error,
140
- errorContext.errorText,
141
- errorContext.errorMessage,
142
- buildErrorText(errorContext),
143
- );
144
- const pattern = pickString(normalizeError(errorText), fromRuleId?.pattern);
145
- return {
146
- project_slug: pickString(projectSlug, fromRuleId?.project_slug),
147
- pattern,
148
- };
149
- }
150
-
151
- function listAdaptiveRulesFromStore(store, projectSlug) {
152
- if (!store?.listAdaptiveRules) return [];
153
- const rules = store.listAdaptiveRules(projectSlug);
154
- return Array.isArray(rules) ? rules : [];
155
- }
156
-
157
- /**
158
- * 에러 메시지를 정규화된 패턴 시그니처로 변환
159
- * 파일 경로, 줄 번호, 타임스탬프, UUID, 숫자 리터럴을 플레이스홀더로 치환
160
- * @param {string} errorMessage
161
- * @returns {string}
162
- */
163
- export function normalizeError(errorMessage) {
164
- if (!errorMessage || typeof errorMessage !== "string") return "";
165
- let p = errorMessage;
166
- p = p.replace(/[A-Za-z]:\\[\w\\.\-/]+/g, "<FILE>");
167
- p = p.replace(/(?:\/[\w.-]+){2,}/g, "<FILE>");
168
- p = p.replace(/:(\d+)(:\d+)?(?=[\s,)\]]|$)/g, ":<LINE>");
169
- p = p.replace(/\b[Ll]ine\s+\d+/g, "line <LINE>");
170
- p = p.replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[\w.+-]*/g, "<TIME>");
171
- p = p.replace(
172
- /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
173
- "<ID>",
174
- );
175
- p = p.replace(/\b[0-9a-f]{32,}\b/gi, "<ID>");
176
- p = p.replace(/\b\d{10,13}\b/g, "<TIME>");
177
- p = p.replace(/\b\d{4,}\b/g, "<NUM>");
178
- return p.toLowerCase().replace(/\s+/g, " ").trim();
179
- }
180
-
181
- /**
182
- * PostToolUseFailure 컨텍스트에서 adaptive rule payload 생성
183
- * @param {object} errorContext
184
- * @returns {object|null}
185
- */
186
- export function adaptiveRuleFromError(errorContext = {}) {
187
- const errorText = buildErrorText(errorContext);
188
- const pattern = normalizeError(errorText);
189
- if (!pattern) return null;
190
- return {
191
- type: ADAPTIVE_RULE_TYPE,
192
- error_pattern: pattern,
193
- error_message: errorText,
194
- context: buildAdaptiveContext(errorContext),
195
- solution: buildAdaptiveSolution(errorContext, errorText),
196
- solution_code: null,
197
- adaptive_state: (() => {
198
- const sessionId = pickSessionId(errorContext);
199
- const sessionCount = pickSessionCount(
200
- errorContext.sessionCount,
201
- errorContext.session_count,
202
- );
203
- return {
204
- project_slug: pickProjectSlug(errorContext),
205
- session_ids: sessionId ? [sessionId] : [],
206
- session_occurrences: sessionId ? 1 : 0,
207
- last_seen_session: sessionCount,
208
- last_decay_session: sessionCount,
209
- };
210
- })(),
211
- confidence: DEFAULT_CONFIDENCE,
212
- hit_count: 1,
213
- success_count: 0,
214
- };
215
- }
216
-
217
- /**
218
- * 동일 패턴이 여러 세션에서 재발하면 adaptive rule confidence를 승격
219
- * 현재 (projectSlug, pattern)과 구형 (ruleId, errorContext) 시그니처를 모두 지원한다.
220
- * @param {object} store — store-adapter 인스턴스 (findAdaptiveRule, updateRuleConfidence 필요)
221
- * @param {string} ruleIdOrProjectSlug
222
- * @param {object|string} patternOrErrorContext
223
- * @returns {object|null}
224
- */
225
- export function promoteRule(store, ruleIdOrProjectSlug, patternOrErrorContext) {
226
- if (!store.findAdaptiveRule || !store.updateRuleConfidence) return null;
227
- const identity = resolveAdaptiveRuleIdentity(
228
- ruleIdOrProjectSlug,
229
- patternOrErrorContext,
230
- );
231
- if (!identity.project_slug || !identity.pattern) return null;
232
- const rule = store.findAdaptiveRule(identity.project_slug, identity.pattern);
233
- if (!rule) return null;
234
- const promoted = clampConfidence(rule.confidence + ADAPTIVE_PROMOTION_STEP);
235
- return store.updateRuleConfidence(
236
- identity.project_slug,
237
- identity.pattern,
238
- promoted,
239
- { hit_count_increment: 1 },
240
- );
241
- }
242
-
243
- /**
244
- * 7일 이상 미관측된 adaptive rules의 confidence를 -0.1 감소, 임계값 이하면 삭제
245
- * @param {object} store — store-adapter 인스턴스 (listAdaptiveRules, updateRuleConfidence, deleteAdaptiveRule 필요)
246
- * @param {number} _sessionCount — 하위 호환용 (현재 미사용)
247
- * @param {string} [projectSlug] — 특정 프로젝트만 대상. 미지정 시 전체 스캔
248
- * @returns {{ updated: Array, deleted: string[] }}
249
- */
250
- export function decayRules(store, _sessionCount, projectSlug) {
251
- if (!store.listAdaptiveRules) return { updated: [], deleted: [] };
252
- const result = { updated: [], deleted: [] };
253
- const rules = listAdaptiveRulesFromStore(store, projectSlug);
254
- for (const rule of rules) {
255
- // 7일 이상 미관측된 규칙만 decay 대상
256
- const ageDays =
257
- (Date.now() - (rule.last_seen_ms || 0)) / (24 * 3600 * 1000);
258
- if (ageDays < 7) continue;
259
- const decayed = clampConfidence(rule.confidence - ADAPTIVE_DECAY_STEP);
260
- if (decayed <= ADAPTIVE_DELETE_THRESHOLD) {
261
- if (store.deleteAdaptiveRule(rule.project_slug, rule.pattern)) {
262
- result.deleted.push(`${rule.project_slug}:${rule.pattern}`);
263
- }
264
- continue;
265
- }
266
- if (decayed < rule.confidence) {
267
- const updated = store.updateRuleConfidence(
268
- rule.project_slug,
269
- rule.pattern,
270
- decayed,
271
- { hit_count_increment: 0 },
272
- );
273
- if (updated) result.updated.push(updated);
274
- }
275
- }
276
- return result;
277
- }
278
-
279
- /**
280
- * 신뢰도 자동 조정 (success_count / hit_count, 샘플 크기 기반 감쇠)
281
- * hit_count가 작으면 0.5(기본값)쪽으로 보수적으로 수렴
282
- * @param {object} entry - { hit_count, success_count }
283
- * @returns {number} 0~1 사이 신뢰도
284
- */
285
- export function recalcConfidence(entry) {
286
- if (!entry?.hit_count || entry.hit_count <= 0) return DEFAULT_CONFIDENCE;
287
- const ratio = entry.success_count / entry.hit_count;
288
- const decay = Math.min(1, entry.hit_count / 10);
289
- return ratio * decay + DEFAULT_CONFIDENCE * (1 - decay);
290
- }
@@ -1,312 +0,0 @@
1
- const PROJECT_ID_RE = /^prj_[A-Za-z0-9_-]{22}$/u;
2
- const SCOPE_ID_RE = /^scp_[A-Za-z0-9_-]{22}$/u;
3
- const BASE64URL_RE = /^[A-Za-z0-9_-]+$/u;
4
-
5
- export const ROLE_KINDS = Object.freeze(["cto", "lead"]);
6
- export const ROLE_REACHABILITY_STATES = Object.freeze([
7
- "electable",
8
- "elected-probing",
9
- "active",
10
- "unreachable",
11
- "quarantined",
12
- ]);
13
- export const ROLE_STATUS_SCHEMA_VERSION = "tfx.roles.v2";
14
- export const LEGACY_ROLE_SNAPSHOT_FIELDS = Object.freeze([
15
- "role",
16
- "status",
17
- "leader_agent_id",
18
- "previous_leader_agent_id",
19
- "leader_epoch",
20
- "lease_expires_ms",
21
- "candidate_source",
22
- "candidate_count",
23
- "live_candidate_count",
24
- "pending_count",
25
- "transferred_count",
26
- "last_transition_ms",
27
- "last_reason",
28
- "candidates",
29
- ]);
30
-
31
- function roleContractError(code, message = code) {
32
- const error = new Error(message);
33
- error.code = code;
34
- return error;
35
- }
36
-
37
- function isRecord(value) {
38
- return value !== null && typeof value === "object" && !Array.isArray(value);
39
- }
40
-
41
- function canonicalJsonRoleKey(key) {
42
- return key.role_kind === "lead"
43
- ? `{"project_id":${JSON.stringify(key.project_id)},"role_kind":"lead","scope_id":${JSON.stringify(key.scope_id)}}`
44
- : `{"project_id":${JSON.stringify(key.project_id)},"role_kind":"cto"}`;
45
- }
46
-
47
- /**
48
- * Validate and normalize the only two valid dynamic role identities.
49
- *
50
- * @param {unknown} input
51
- * @returns {{project_id: string, role_kind: "cto"} | {project_id: string, role_kind: "lead", scope_id: string}}
52
- */
53
- export function normalizeRoleKey(input) {
54
- if (!isRecord(input)) throw roleContractError("ROLE_KEY_INVALID");
55
-
56
- const fields = Object.keys(input);
57
- const allowed = new Set(["project_id", "role_kind", "scope_id"]);
58
- if (fields.some((field) => !allowed.has(field))) {
59
- throw roleContractError("ROLE_KEY_FIELD_UNSUPPORTED");
60
- }
61
-
62
- const { project_id: projectId, role_kind: roleKind } = input;
63
- if (typeof projectId !== "string" || !PROJECT_ID_RE.test(projectId)) {
64
- throw roleContractError("PROJECT_ID_INVALID");
65
- }
66
- if (!ROLE_KINDS.includes(roleKind)) {
67
- throw roleContractError("ROLE_KIND_UNSUPPORTED");
68
- }
69
-
70
- const hasScope = Object.hasOwn(input, "scope_id");
71
- if (roleKind === "cto") {
72
- if (hasScope) throw roleContractError("ROLE_SCOPE_FORBIDDEN");
73
- return { project_id: projectId, role_kind: "cto" };
74
- }
75
-
76
- if (!hasScope || typeof input.scope_id !== "string") {
77
- throw roleContractError("SCOPE_REQUIRED");
78
- }
79
- if (!SCOPE_ID_RE.test(input.scope_id)) {
80
- throw roleContractError("SCOPE_ID_INVALID");
81
- }
82
- return { project_id: projectId, role_kind: "lead", scope_id: input.scope_id };
83
- }
84
-
85
- /** @param {unknown} key */
86
- export function encodeRoleKey(key) {
87
- const normalized = normalizeRoleKey(key);
88
- const wire = `rk2.${Buffer.from(canonicalJsonRoleKey(normalized), "utf8").toString("base64url")}`;
89
- if (Buffer.byteLength(wire, "utf8") > 512) {
90
- throw roleContractError("ROLE_KEY_WIRE_TOO_LONG");
91
- }
92
- return wire;
93
- }
94
-
95
- /** @param {unknown} wire */
96
- export function decodeRoleKey(wire) {
97
- if (typeof wire !== "string" || Buffer.byteLength(wire, "utf8") > 512) {
98
- throw roleContractError("ROLE_KEY_WIRE_INVALID");
99
- }
100
- if (!wire.startsWith("rk2.")) throw roleContractError("ROLE_KEY_WIRE_PREFIX");
101
-
102
- const encoded = wire.slice(4);
103
- if (!encoded || !BASE64URL_RE.test(encoded)) {
104
- throw roleContractError("ROLE_KEY_NON_CANONICAL");
105
- }
106
-
107
- let parsed;
108
- try {
109
- const canonicalSource = Buffer.from(encoded, "base64url").toString("utf8");
110
- parsed = JSON.parse(canonicalSource);
111
- } catch {
112
- throw roleContractError("ROLE_KEY_NON_CANONICAL");
113
- }
114
-
115
- let normalized;
116
- try {
117
- normalized = normalizeRoleKey(parsed);
118
- } catch (error) {
119
- if (error?.code) throw error;
120
- throw roleContractError("ROLE_KEY_NON_CANONICAL");
121
- }
122
- if (encodeRoleKey(normalized) !== wire) {
123
- throw roleContractError("ROLE_KEY_NON_CANONICAL");
124
- }
125
- return normalized;
126
- }
127
-
128
- /** @param {unknown} key */
129
- export function roleTopicAddress(key) {
130
- return `topic:role:${encodeRoleKey(key)}`;
131
- }
132
-
133
- const TRANSITION_ROWS = [
134
- ["electable", "candidate_selected", "elected-probing", "reserve_holder"],
135
- ["electable", "standup_reserved", "elected-probing", "reserve_standup"],
136
- ["electable", "no_candidate", "electable", "record_blocked_no_candidate"],
137
- [
138
- "electable",
139
- "candidates_excluded",
140
- "quarantined",
141
- "schedule_exclusion_expiry",
142
- ],
143
- ["elected-probing", "probe_succeeded", "elected-probing", "send_activation"],
144
- ["elected-probing", "activation_acked", "active", "record_activation_ack"],
145
- [
146
- "elected-probing",
147
- "probe_failed",
148
- "unreachable",
149
- "exclude_failed_candidate",
150
- ],
151
- ["elected-probing", "wake_failed", "unreachable", "exclude_failed_candidate"],
152
- ["elected-probing", "ack_timeout", "unreachable", "exclude_failed_candidate"],
153
- [
154
- "elected-probing",
155
- "holder_lease_expired",
156
- "electable",
157
- "revoke_holder_epoch_fence",
158
- ],
159
- [
160
- "active",
161
- "holder_lease_expired",
162
- "electable",
163
- "revoke_holder_preserve_backlog",
164
- ],
165
- ["active", "holder_offline", "electable", "revoke_holder_preserve_backlog"],
166
- ["active", "transport_failed", "unreachable", "fence_active_authority"],
167
- ["active", "charter_reissued", "elected-probing", "reserve_reack"],
168
- [
169
- "unreachable",
170
- "alternate_candidate_available",
171
- "electable",
172
- "failover_candidate",
173
- ],
174
- ["unreachable", "retry_due", "elected-probing", "reserve_retry_epoch"],
175
- ["unreachable", "retries_exhausted", "quarantined", "schedule_quarantine"],
176
- [
177
- "quarantined",
178
- "exclusion_expired",
179
- "electable",
180
- "restore_candidate_eligibility",
181
- ],
182
- ["quarantined", "candidate_registered", "electable", "clear_quarantine"],
183
- ["quarantined", "locator_registered", "electable", "clear_quarantine"],
184
- ["quarantined", "capability_registered", "electable", "clear_quarantine"],
185
- ...ROLE_REACHABILITY_STATES.flatMap((state) => [
186
- [state, "stale_ack", state, "reject_stale_ack"],
187
- [state, "scope_closed", null, "remove_registry"],
188
- ]),
189
- ];
190
-
191
- export const ROLE_REACHABILITY_TRANSITIONS = Object.freeze(
192
- TRANSITION_ROWS.map(([from, trigger, to, action]) =>
193
- Object.freeze({ from, trigger, to, action }),
194
- ),
195
- );
196
-
197
- const TRANSITIONS_BY_KEY = new Map(
198
- ROLE_REACHABILITY_TRANSITIONS.map((transition) => [
199
- `${transition.from}:${transition.trigger}`,
200
- transition,
201
- ]),
202
- );
203
-
204
- /**
205
- * Apply one already-guarded reachability event. Guard evaluation and all side
206
- * effects deliberately remain outside this inert contract kernel.
207
- */
208
- export function reduceRoleReachabilityTransition(state, trigger) {
209
- if (!ROLE_REACHABILITY_STATES.includes(state)) {
210
- throw roleContractError("ROLE_REACHABILITY_STATE_INVALID");
211
- }
212
- const transition = TRANSITIONS_BY_KEY.get(`${state}:${trigger}`);
213
- if (!transition) throw roleContractError("INVALID_ROLE_TRANSITION");
214
- return trigger === "stale_ack"
215
- ? { state: transition.to, action: transition.action, code: "STALE_EPOCH" }
216
- : { state: transition.to, action: transition.action };
217
- }
218
-
219
- function snapshotLegacyDetails(snapshot) {
220
- const legacy = isRecord(snapshot.legacy) ? snapshot.legacy : {};
221
- return {
222
- candidate_source:
223
- legacy.candidate_source ?? snapshot.candidate_source ?? null,
224
- transferred_count:
225
- legacy.transferred_count ?? snapshot.transferred_count ?? 0,
226
- candidates: legacy.candidates ?? snapshot.candidates ?? [],
227
- };
228
- }
229
-
230
- /**
231
- * Construct the v2 status envelope in deterministic role-key order.
232
- * `generatedAtMs` is supplied by the caller so the kernel has no clock.
233
- */
234
- export function createRoleStatusEnvelopeV2({
235
- items = [],
236
- generatedAtMs,
237
- projectId,
238
- } = {}) {
239
- if (!Number.isFinite(generatedAtMs)) {
240
- throw roleContractError("ROLE_STATUS_GENERATED_AT_REQUIRED");
241
- }
242
- if (
243
- projectId !== undefined &&
244
- (typeof projectId !== "string" || !PROJECT_ID_RE.test(projectId))
245
- ) {
246
- throw roleContractError("PROJECT_ID_INVALID");
247
- }
248
- if (!Array.isArray(items))
249
- throw roleContractError("ROLE_STATUS_ITEMS_INVALID");
250
-
251
- return {
252
- schema_version: ROLE_STATUS_SCHEMA_VERSION,
253
- generated_at_ms: generatedAtMs,
254
- ...(projectId === undefined ? {} : { project_id: projectId }),
255
- items: [...items].sort((left, right) =>
256
- String(left?.role_key_wire || "").localeCompare(
257
- String(right?.role_key_wire || ""),
258
- ),
259
- ),
260
- };
261
- }
262
-
263
- /**
264
- * Project the single exact-project CTO into the legacy `roles.cto` shape.
265
- * The optional `legacy` detail preserves fields that v2 callers do not use.
266
- */
267
- export function projectLegacyCtoStatus(envelope, projectId) {
268
- if (
269
- !isRecord(envelope) ||
270
- envelope.schema_version !== ROLE_STATUS_SCHEMA_VERSION ||
271
- !Array.isArray(envelope.items)
272
- ) {
273
- throw roleContractError("ROLE_STATUS_ENVELOPE_INVALID");
274
- }
275
- if (typeof projectId !== "string" || !PROJECT_ID_RE.test(projectId)) {
276
- throw roleContractError("SCOPE_REQUIRED");
277
- }
278
-
279
- const snapshot = envelope.items.find((item) => {
280
- const key = item?.role_key;
281
- return key?.project_id === projectId && key?.role_kind === "cto";
282
- });
283
- if (!snapshot) return { cto: null };
284
-
285
- const status =
286
- snapshot.state === "active"
287
- ? "active"
288
- : snapshot.state === "electable" || snapshot.state === "elected-probing"
289
- ? "electable"
290
- : "offline";
291
- const legacy = snapshotLegacyDetails(snapshot);
292
- return {
293
- cto: {
294
- role: "cto",
295
- status,
296
- leader_agent_id:
297
- status === "active" ? (snapshot.holder_agent_id ?? null) : null,
298
- previous_leader_agent_id: snapshot.previous_holder_agent_id ?? null,
299
- leader_epoch: snapshot.epoch,
300
- lease_expires_ms:
301
- status === "active" ? (snapshot.lease_expires_ms ?? null) : null,
302
- candidate_source: legacy.candidate_source,
303
- candidate_count: snapshot.candidate_count,
304
- live_candidate_count: snapshot.live_candidate_count,
305
- pending_count: snapshot.pending_count,
306
- transferred_count: legacy.transferred_count,
307
- last_transition_ms: snapshot.last_transition_ms,
308
- last_reason: snapshot.last_reason,
309
- candidates: legacy.candidates,
310
- },
311
- };
312
- }