sprag-cli 3.40.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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +637 -0
  3. package/README.md +758 -0
  4. package/bin/cli.js +801 -0
  5. package/examples/statusline-command.ps1 +43 -0
  6. package/examples/statusline-command.sh +36 -0
  7. package/package.json +62 -0
  8. package/presets/cohesion/cohesion-en.md +26 -0
  9. package/presets/doc2md/convert.py +363 -0
  10. package/presets/korean-style/LICENSE-fluent-korean +21 -0
  11. package/presets/korean-style/fluent-korean.md +52 -0
  12. package/presets/korean-style/supplement.md +93 -0
  13. package/presets/model-rules.json +115 -0
  14. package/presets/ratchet-rules.json +38 -0
  15. package/src/advice.js +564 -0
  16. package/src/agents.js +52 -0
  17. package/src/brief.js +264 -0
  18. package/src/caps-cache.js +84 -0
  19. package/src/cli-args.js +51 -0
  20. package/src/cohesion.js +70 -0
  21. package/src/commands/brief.js +31 -0
  22. package/src/commands/cohesion.js +59 -0
  23. package/src/commands/compact-window.js +93 -0
  24. package/src/commands/doc2md.js +166 -0
  25. package/src/commands/feedback.js +132 -0
  26. package/src/commands/handoff.js +33 -0
  27. package/src/commands/harness.js +459 -0
  28. package/src/commands/history.js +46 -0
  29. package/src/commands/install.js +358 -0
  30. package/src/commands/korean.js +220 -0
  31. package/src/commands/last.js +151 -0
  32. package/src/commands/mode.js +46 -0
  33. package/src/commands/route-scan.js +454 -0
  34. package/src/commands/seed.js +105 -0
  35. package/src/commands/uninstall.js +42 -0
  36. package/src/commands/update-check.js +77 -0
  37. package/src/commands/upgrade.js +68 -0
  38. package/src/compact-window.js +205 -0
  39. package/src/config.js +232 -0
  40. package/src/cost.js +253 -0
  41. package/src/debug.js +29 -0
  42. package/src/demo.js +331 -0
  43. package/src/doc2md-ledger.cjs +227 -0
  44. package/src/doc2md.cjs +997 -0
  45. package/src/fig2md-runner.cjs +21 -0
  46. package/src/fig2md.cjs +191 -0
  47. package/src/first-run-note.js +63 -0
  48. package/src/format-time.js +44 -0
  49. package/src/formatters/csv.js +8 -0
  50. package/src/formatters/json.js +3 -0
  51. package/src/formatters/statusline.js +750 -0
  52. package/src/formatters/table.js +299 -0
  53. package/src/handoff.js +161 -0
  54. package/src/harness-analyzer.cjs +264 -0
  55. package/src/harness-templates.js +153 -0
  56. package/src/harness.js +613 -0
  57. package/src/history.js +383 -0
  58. package/src/hook-manager.js +96 -0
  59. package/src/hook.cjs +196 -0
  60. package/src/installer.js +614 -0
  61. package/src/korean-lint.cjs +303 -0
  62. package/src/korean-style.js +187 -0
  63. package/src/litellm-budget.js +223 -0
  64. package/src/model-alias.js +484 -0
  65. package/src/model-rules.js +527 -0
  66. package/src/month-spend.js +47 -0
  67. package/src/parser.js +330 -0
  68. package/src/paths.js +41 -0
  69. package/src/prompt.js +52 -0
  70. package/src/route-scan.js +832 -0
  71. package/src/savings-ledger.js +137 -0
  72. package/src/seed-rules.js +280 -0
  73. package/src/session-cache.js +160 -0
  74. package/src/session-records.js +188 -0
  75. package/src/stats.js +380 -0
  76. package/src/stdin-payload.js +122 -0
  77. package/src/subagent-records.js +214 -0
  78. package/src/update-check.js +201 -0
  79. package/src/window-labels.js +64 -0
@@ -0,0 +1,484 @@
1
+ /**
2
+ * model-alias — restore a usable model name when the transcript records a
3
+ * gateway identifier instead of a Claude model id.
4
+ *
5
+ * Why this exists: behind a Bedrock / LiteLLM gateway, `message.model` in the
6
+ * transcript is an inference-profile ARN:
7
+ *
8
+ * converse/arn:aws:bedrock:<region>:<account>:application-inference-profile/<id>
9
+ *
10
+ * Nothing in that string says "opus" or "haiku", so `detectPricingTier()`
11
+ * falls through to its Sonnet default. Everything downstream then reads the
12
+ * session as Sonnet: `worthDelegating('T1', 1)` is false, so every T1 rule is
13
+ * rejected, delegation savings aggregate to zero, and cost is under-counted.
14
+ *
15
+ * The fix is a single normalization point rather than a change to the pricing
16
+ * table — plain aliases (`ap-northeast-2.anthropic.claude-opus-5[1m]`) are
17
+ * already classified correctly, region prefix and `[1m]` suffix included. So
18
+ * all that is missing is ARN → alias.
19
+ *
20
+ * Resolution order, cheapest first:
21
+ * 1. not an ARN → return the input unchanged (direct-API users
22
+ * must keep their existing behaviour)
23
+ * 2. user override → `modelAliases` in profile-map.json
24
+ * 3. learned mapping → profile id → role, learned from transcripts
25
+ * 4. otherwise → 'unknown' (never a silent Sonnet guess)
26
+ *
27
+ * A profile id is never hardcoded here. Ids differ per account and change
28
+ * with gateway config, and the ARN embeds a 12-digit AWS account id — this
29
+ * package is published to npm, so neither may live in the source.
30
+ */
31
+
32
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, createReadStream } from 'node:fs';
33
+ import { readFile, readdir } from 'node:fs/promises';
34
+ import { createInterface } from 'node:readline';
35
+ import { join, dirname, basename } from 'node:path';
36
+ import { userDataDir, claudeUserDir } from './paths.js';
37
+
38
+ /** Marker returned when a gateway id could not be resolved to a model. */
39
+ export const UNKNOWN_MODEL = 'unknown';
40
+
41
+ /** File holding user overrides plus the learned profile→role votes. */
42
+ export function profileMapPath() {
43
+ return join(userDataDir(), 'profile-map.json');
44
+ }
45
+
46
+ // `converse/` (LiteLLM) or a bare ARN, foundation- or application-scoped.
47
+ // `foundation-model` ARNs carry the model id directly in the resource part
48
+ // (`foundation-model/anthropic.claude-haiku-…`), so they are captured too.
49
+ const ARN_RE =
50
+ /arn:aws:bedrock:[^:]*:[^:]*:(?:foundation-model|(?:application-)?inference-profile)\/([A-Za-z0-9._:-]+)/;
51
+
52
+ /** True when the id came from a Bedrock gateway rather than the Claude API. */
53
+ export function isGatewayModelId(model) {
54
+ return typeof model === 'string' && model.includes('arn:aws:bedrock:');
55
+ }
56
+
57
+ /** The inference-profile id inside an ARN, or null when there is none. */
58
+ export function profileIdFrom(model) {
59
+ if (typeof model !== 'string') return null;
60
+ const m = ARN_RE.exec(model);
61
+ return m ? m[1] : null;
62
+ }
63
+
64
+ /**
65
+ * Roles a profile id can carry. 'main' is the session's own model, which the
66
+ * env declares separately from the per-tier subagent overrides.
67
+ */
68
+ const ROLES = ['main', 'opus', 'sonnet', 'haiku', 'fable'];
69
+
70
+ /**
71
+ * Alias for a role, taken from the environment Claude Code itself uses to
72
+ * pick subagent models. Returns null when the variable is absent, or when it
73
+ * is an opaque ARN that names no model (resolving one of those to another ARN
74
+ * would loop).
75
+ *
76
+ * A `foundation-model` ARN is not opaque: it spells the model out in its
77
+ * resource part, so it is unwrapped rather than rejected. Users who point
78
+ * these variables straight at an ARN — a normal way to configure a private
79
+ * gateway — used to get no delegation stats at all, and no hint as to why.
80
+ */
81
+ export function aliasForRole(role, env = process.env) {
82
+ const candidates = {
83
+ main: [env.ANTHROPIC_MODEL, env.ANTHROPIC_DEFAULT_MODEL, env.ANTHROPIC_DEFAULT_OPUS_MODEL],
84
+ opus: [env.ANTHROPIC_DEFAULT_OPUS_MODEL, env.ANTHROPIC_MODEL],
85
+ sonnet: [env.ANTHROPIC_DEFAULT_SONNET_MODEL],
86
+ haiku: [env.ANTHROPIC_DEFAULT_HAIKU_MODEL],
87
+ fable: [env.ANTHROPIC_DEFAULT_FABLE_MODEL],
88
+ }[role] || [];
89
+ for (const v of candidates) {
90
+ if (typeof v !== 'string' || !v) continue;
91
+ if (!isGatewayModelId(v)) return v;
92
+ // `arn:…:foundation-model/anthropic.claude-haiku-4-5-…` → the model id.
93
+ // An `application-inference-profile` id is a random string and stays
94
+ // rejected: guessing at it is how wrong prices get into the ledger.
95
+ const inner = profileIdFrom(v);
96
+ if (inner && /claude/i.test(inner)) return inner;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ // ── override / learned map storage ───────────────────────────────────────
102
+
103
+ let cached = null;
104
+
105
+ /** Read profile-map.json (memoized). Missing or corrupt file → empty map. */
106
+ export function loadProfileMap() {
107
+ if (cached) return cached;
108
+ let data = {};
109
+ try {
110
+ data = JSON.parse(readFileSync(profileMapPath(), 'utf8'));
111
+ } catch { /* absent on first run, and unreadable is not fatal */ }
112
+ cached = {
113
+ version: 1,
114
+ modelAliases: data.modelAliases && typeof data.modelAliases === 'object' ? data.modelAliases : {},
115
+ learned: data.learned && typeof data.learned === 'object' ? data.learned : {},
116
+ learnedAt: data.learnedAt || null,
117
+ scannedSessions: data.scannedSessions || 0,
118
+ };
119
+ return cached;
120
+ }
121
+
122
+ export function saveProfileMap(map) {
123
+ const dir = userDataDir();
124
+ mkdirSync(dir, { recursive: true });
125
+ writeFileSync(profileMapPath(), JSON.stringify(map, null, 2));
126
+ cached = map;
127
+ }
128
+
129
+ /** Drop the memoized map. Tests use this after pointing paths elsewhere. */
130
+ export function resetModelAliasCache() {
131
+ cached = null;
132
+ }
133
+
134
+ /**
135
+ * Glob match for override keys, so a user can write one entry that hides the
136
+ * account id and region: `arn:aws:bedrock:*:*:application-inference-profile/x`.
137
+ */
138
+ function globMatch(pattern, value) {
139
+ const rx = new RegExp(
140
+ '^' + pattern.split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$',
141
+ );
142
+ return rx.test(value);
143
+ }
144
+
145
+ function overrideAlias(model, map) {
146
+ for (const [pattern, alias] of Object.entries(map.modelAliases || {})) {
147
+ if (globMatch(pattern, model)) return alias;
148
+ // Overrides are usually written without the LiteLLM `converse/` prefix.
149
+ // Only meaningful for ARNs — on a plain id indexOf returns -1 and the
150
+ // slice would hand the matcher a single trailing character.
151
+ const at = model.indexOf('arn:aws:bedrock:');
152
+ if (at > 0 && globMatch(pattern, model.slice(at))) return alias;
153
+ }
154
+ return null;
155
+ }
156
+
157
+ // ── resolution ───────────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Normalize one transcript model id.
161
+ * Non-gateway ids pass through untouched; gateway ids resolve to an alias, or
162
+ * to 'unknown' when the mapping is not confident yet.
163
+ */
164
+ export function resolveModelAlias(rawModel, { env = process.env } = {}) {
165
+ if (!rawModel) return UNKNOWN_MODEL;
166
+ const model = String(rawModel);
167
+
168
+ const map = loadProfileMap();
169
+
170
+ // Overrides are consulted for EVERY id, not just ARNs. A gateway can be
171
+ // configured to report a house alias (`prod-large`, `team-fast`) that names
172
+ // no Claude family at all; those never reach the ARN branch below, and left
173
+ // alone they price as Sonnet by default. One `modelAliases` entry maps them
174
+ // back. Ids that already name a family are left untouched — an override
175
+ // pattern has to match before anything changes.
176
+ const override = overrideAlias(model, map);
177
+ if (override) return override;
178
+
179
+ if (!isGatewayModelId(model)) return model;
180
+
181
+ const pid = profileIdFrom(model);
182
+ if (!pid) return UNKNOWN_MODEL;
183
+
184
+ // Self-describing resource ids need no learning: foundation-model ARNs and
185
+ // system cross-region inference profiles both embed the model id
186
+ // (`anthropic.claude-haiku-4-5-…` / `us.anthropic.claude-haiku-4-5-…`).
187
+ // detectPricingTier() already classifies those strings, region prefix
188
+ // included — only opaque application-profile ids (random hex) fall through
189
+ // to the override / learned paths. Without this, a fresh gateway machine
190
+ // reported every run as 'unknown' until enough sessions accumulated to
191
+ // learn a mapping that the string had spelled out all along.
192
+ if (/claude/i.test(pid)) return pid;
193
+
194
+ const entry = map.learned?.[pid];
195
+ if (entry?.role) {
196
+ const alias = aliasForRole(entry.role, env);
197
+ if (alias) return alias;
198
+ }
199
+ return UNKNOWN_MODEL;
200
+ }
201
+
202
+ // ── learning ─────────────────────────────────────────────────────────────
203
+
204
+ // A single observation can be wrong: the time-adjacent parent records leak
205
+ // into a naive join, and a mis-set agent definition mislabels one run. Require
206
+ // a few observations that mostly agree before trusting a mapping.
207
+ export const MIN_VOTES = 3;
208
+ export const MIN_AGREEMENT = 0.8;
209
+
210
+ /** Role named directly by a Task call's `model` parameter. */
211
+ function roleFromModelParam(value) {
212
+ if (typeof value !== 'string') return null;
213
+ const v = value.toLowerCase();
214
+ return ROLES.find((r) => r !== 'main' && v.includes(r)) || null;
215
+ }
216
+
217
+ /** Role declared in a subagent definition's frontmatter (`model: haiku`). */
218
+ function roleFromAgentType(agentType, cache) {
219
+ if (!agentType) return null;
220
+ if (cache.has(agentType)) return cache.get(agentType);
221
+ let role = null;
222
+ for (const dir of [join(claudeUserDir(), 'agents'), join(process.cwd(), '.claude', 'agents')]) {
223
+ const file = join(dir, `${agentType}.md`);
224
+ if (!existsSync(file)) continue;
225
+ try {
226
+ const head = readFileSync(file, 'utf8').slice(0, 2000);
227
+ const m = /^model:\s*([A-Za-z0-9._-]+)/m.exec(head);
228
+ if (m) role = roleFromModelParam(m[1]);
229
+ } catch { /* unreadable definition just yields no vote */ }
230
+ if (role) break;
231
+ }
232
+ cache.set(agentType, role);
233
+ return role;
234
+ }
235
+
236
+ /**
237
+ * Record one observation.
238
+ *
239
+ * `kind` matters more than the count. An 'explicit' vote comes from a stated
240
+ * model — a `Task(model: "haiku")` parameter or an agent definition's
241
+ * frontmatter. An 'inferred' vote is circumstantial: the record carried no
242
+ * sidechain flag, so it is *probably* the parent session's own model. Mixing
243
+ * the two by volume let 4 inferred votes outrank 1 explicit one and stamped a
244
+ * haiku profile as 'main' — see tallyVotes.
245
+ */
246
+ function addVote(votes, pid, role, kind) {
247
+ if (!pid || !role) return;
248
+ const v = (votes[pid] ||= { explicit: {}, inferred: {} });
249
+ const bucket = v[kind] || (v[kind] = {});
250
+ bucket[role] = (bucket[role] || 0) + 1;
251
+ }
252
+
253
+ /**
254
+ * Read one main transcript: which profile id the session itself ran on, and
255
+ * which role each Task/Agent tool_use asked for (joined later by tool_use id).
256
+ */
257
+ async function scanMainTranscript(path, votes, requestedByToolUse, agentTypeCache) {
258
+ let sawGateway = false;
259
+ const rl = createInterface({
260
+ input: createReadStream(path, { encoding: 'utf8' }),
261
+ crlfDelay: Infinity,
262
+ });
263
+ try {
264
+ for await (const line of rl) {
265
+ if (!line.includes('arn:aws:bedrock:') && !line.includes('"Task"') && !line.includes('"Agent"')) {
266
+ continue;
267
+ }
268
+ let entry;
269
+ try {
270
+ entry = JSON.parse(line);
271
+ } catch {
272
+ continue;
273
+ }
274
+ const msg = entry.message;
275
+ if (!msg) continue;
276
+
277
+ // The session's own model: the parent side of the transcript.
278
+ if (msg.model && entry.isSidechain !== true) {
279
+ const pid = profileIdFrom(msg.model);
280
+ if (pid) {
281
+ sawGateway = true;
282
+ // Circumstantial: a subagent's records also land in the parent file
283
+ // without the flag often enough to outvote real evidence.
284
+ addVote(votes, pid, 'main', 'inferred');
285
+ }
286
+ }
287
+
288
+ if (!Array.isArray(msg.content)) continue;
289
+ for (const block of msg.content) {
290
+ if (!block || block.type !== 'tool_use') continue;
291
+ if (block.name !== 'Task' && block.name !== 'Agent') continue;
292
+ const input = block.input || {};
293
+ const role = roleFromModelParam(input.model)
294
+ || roleFromAgentType(input.subagent_type, agentTypeCache);
295
+ if (block.id && role) requestedByToolUse.set(block.id, role);
296
+ }
297
+ }
298
+ } finally {
299
+ rl.close();
300
+ }
301
+ return sawGateway;
302
+ }
303
+
304
+ /** First profile id used by a subagent transcript (a run uses exactly one). */
305
+ async function subagentProfileId(path) {
306
+ const rl = createInterface({
307
+ input: createReadStream(path, { encoding: 'utf8' }),
308
+ crlfDelay: Infinity,
309
+ });
310
+ try {
311
+ for await (const line of rl) {
312
+ if (!line.includes('arn:aws:bedrock:')) continue;
313
+ let entry;
314
+ try {
315
+ entry = JSON.parse(line);
316
+ } catch {
317
+ continue;
318
+ }
319
+ const pid = profileIdFrom(entry.message?.model);
320
+ if (pid) return pid;
321
+ }
322
+ } finally {
323
+ rl.close();
324
+ }
325
+ return null;
326
+ }
327
+
328
+ /** Subagent transcripts a session spawned (mirrors subagent-records layout). */
329
+ async function subagentFiles(sessionPath) {
330
+ const dir = join(dirname(sessionPath), basename(sessionPath, '.jsonl'), 'subagents');
331
+ try {
332
+ return (await readdir(dir)).filter((f) => f.endsWith('.jsonl')).map((f) => join(dir, f));
333
+ } catch {
334
+ return [];
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Whether two roles describe the same model. 'main' and 'opus' routinely do:
340
+ * the session model is the opus alias on a default setup, so a Task that asked
341
+ * for opus does not contradict "this is the parent's own profile".
342
+ */
343
+ function rolesAgree(a, b) {
344
+ if (!a || !b) return false;
345
+ if (a === b) return true;
346
+ const pair = new Set([a, b]);
347
+ return pair.has('main') && pair.has('opus');
348
+ }
349
+
350
+ /** Winner of one bucket, with the counts needed to judge confidence. */
351
+ function topRole(tally) {
352
+ let role = null;
353
+ let top = 0;
354
+ let total = 0;
355
+ for (const [r, n] of Object.entries(tally || {})) {
356
+ total += n;
357
+ if (n > top) { top = n; role = r; }
358
+ }
359
+ return { role, top, total };
360
+ }
361
+
362
+ /**
363
+ * Decide a role per profile id.
364
+ *
365
+ * Explicit evidence decides alone whenever there is enough of it; inferred
366
+ * evidence is only consulted when the explicit bucket is too thin. Counting
367
+ * both together is what produced the original mis-classification: a haiku
368
+ * profile appeared 16 times in parent transcripts without a sidechain flag
369
+ * against 1,702 times as a subagent, and those 16 inferred 'main' votes beat
370
+ * the single explicit 'haiku' one at exactly the 80% line.
371
+ *
372
+ * When a thin explicit bucket vetoes a decisive inference, the explicit role
373
+ * is adopted ('explicit-veto'): a stated `Task(model: ...)` is the stronger
374
+ * evidence, and it is the only evidence left once the inference is rejected.
375
+ *
376
+ * When neither bucket says anything usable the id stays unresolved. An
377
+ * 'unknown' that drops out of the aggregate beats a confident wrong answer
378
+ * that silently re-tiers every run on that profile.
379
+ */
380
+ export function tallyVotes(votes, { minVotes = MIN_VOTES, minAgreement = MIN_AGREEMENT } = {}) {
381
+ const learned = {};
382
+ for (const [pid, buckets] of Object.entries(votes)) {
383
+ // Legacy flat shape (`{ haiku: 3 }`) is read as explicit evidence.
384
+ const split = buckets && (buckets.explicit || buckets.inferred)
385
+ ? { explicit: buckets.explicit || {}, inferred: buckets.inferred || {} }
386
+ : { explicit: buckets || {}, inferred: {} };
387
+
388
+ const explicit = topRole(split.explicit);
389
+ const inferred = topRole(split.inferred);
390
+
391
+ let role = null;
392
+ let source = null;
393
+ if (explicit.total >= minVotes && explicit.top / explicit.total >= minAgreement) {
394
+ role = explicit.role;
395
+ source = 'explicit';
396
+ } else if (inferred.total >= minVotes
397
+ && inferred.top / inferred.total >= minAgreement
398
+ && (explicit.total === 0 || rolesAgree(explicit.role, inferred.role))) {
399
+ // Thin explicit evidence still vetoes a contradicting inference: one
400
+ // stated `Task(model: "haiku")` outweighs any number of "no sidechain
401
+ // flag, so probably the session model" observations.
402
+ role = inferred.role;
403
+ source = 'inferred';
404
+ } else if (explicit.total > 0
405
+ && explicit.top / explicit.total >= minAgreement
406
+ && inferred.role
407
+ && !rolesAgree(explicit.role, inferred.role)) {
408
+ // The veto above is only coherent if we then believe what did the
409
+ // vetoing. Leaving the id unresolved instead drops every delegated run
410
+ // on that profile out of the aggregate, which is how a haiku profile
411
+ // that doubles as somebody's session model reported zero delegations.
412
+ role = explicit.role;
413
+ source = 'explicit-veto';
414
+ }
415
+
416
+ learned[pid] = {
417
+ role,
418
+ source,
419
+ votes: split,
420
+ total: explicit.total + inferred.total,
421
+ };
422
+ }
423
+ return learned;
424
+ }
425
+
426
+ /**
427
+ * Learn profile id → role from transcripts and persist the result.
428
+ *
429
+ * The join is exact rather than time-windowed: `.meta.json` carries the
430
+ * `toolUseId` of the Task block that spawned the run, and that block names the
431
+ * model tier. A subagent transcript uses exactly one profile id, so the run's
432
+ * id and the requested role identify each other.
433
+ *
434
+ * @param {object} opts
435
+ * @param {string[]} opts.sessionPaths transcripts to read, newest first
436
+ * @param {number} opts.maxSessions cap on files read (learning is a scan)
437
+ * @returns {Promise<{learned: object, scannedSessions: number, gateway: boolean}>}
438
+ */
439
+ export async function learnProfileMapping({ sessionPaths = [], maxSessions = 40 } = {}) {
440
+ const votes = {};
441
+ const agentTypeCache = new Map();
442
+ let scanned = 0;
443
+ let gateway = false;
444
+
445
+ for (const sessionPath of sessionPaths.slice(0, maxSessions)) {
446
+ const requestedByToolUse = new Map();
447
+ let sawGateway = false;
448
+ try {
449
+ sawGateway = await scanMainTranscript(sessionPath, votes, requestedByToolUse, agentTypeCache);
450
+ } catch {
451
+ continue;
452
+ }
453
+ scanned += 1;
454
+
455
+ for (const jsonl of await subagentFiles(sessionPath)) {
456
+ let meta = null;
457
+ try {
458
+ meta = JSON.parse(await readFile(jsonl.replace(/\.jsonl$/, '.meta.json'), 'utf8'));
459
+ } catch { /* pre-toolUseId runs simply cast no vote */ }
460
+ const role = (meta?.toolUseId && requestedByToolUse.get(meta.toolUseId))
461
+ || roleFromAgentType(meta?.agentType, agentTypeCache);
462
+ if (!role) continue;
463
+ const pid = await subagentProfileId(jsonl);
464
+ if (!pid) continue;
465
+ sawGateway = true;
466
+ // Stated evidence: the Task call named this tier, or the agent
467
+ // definition it used did.
468
+ addVote(votes, pid, role, 'explicit');
469
+ }
470
+ if (sawGateway) gateway = true;
471
+ }
472
+
473
+ const learned = tallyVotes(votes);
474
+ const map = loadProfileMap();
475
+ const next = {
476
+ ...map,
477
+ learned,
478
+ learnedAt: new Date().toISOString(),
479
+ scannedSessions: scanned,
480
+ };
481
+ // Nothing to record on a non-gateway machine — do not create the file there.
482
+ if (gateway || Object.keys(map.learned || {}).length) saveProfileMap(next);
483
+ return { learned, scannedSessions: scanned, gateway };
484
+ }