@timekast/factory 2.3.1 → 2.5.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.
@@ -0,0 +1,516 @@
1
+ /**
2
+ * usage-scan — engine of `factory usage`: reads REAL `usage` from the Claude Code
3
+ * transcripts under a projects root (default `~/.claude/projects/`) and aggregates
4
+ * the cuts the report renders. Graduated from `project/reports/model-usage.mjs`
5
+ * (the Factory-internal prototype) into a tool any developer can run in any repo.
6
+ *
7
+ * Design constraints (Encargo C brief, 2026-08-26):
8
+ * · FULL sweep — discovers every project dir under the root by itself. ZERO
9
+ * hardcoded repo maps (the prototype's `REPOS` map is exactly the defect
10
+ * this module removes: its figures under-counted every unlisted repo).
11
+ * · No repo preflight — the data source is the developer's HOME, not the cwd.
12
+ * · Node builtins only. The CLI cannot assume anything about the host repo.
13
+ * · Privacy: only aggregates leave this module — dates, counts, tokens, model
14
+ * and agent names, repo slugs. NEVER message content, NEVER client file
15
+ * paths, NEVER code fragments. (The repo slug itself identifies the client:
16
+ * `anonymizeRepos` exists so a SHARED report can drop even that — opt-in,
17
+ * the developer's call, never the default.)
18
+ *
19
+ * Three traps of this format — all three paid for once in the prototype, all
20
+ * three fixed here and pinned by tests (`cli/tests/unit/usage-scan.test.ts`):
21
+ *
22
+ * 🔴 `toolUseResult.totalTokens` is NOT the invocation total — it reports ~1% of
23
+ * the real figure. Never read. Tokens always come from the transcript `usage`.
24
+ *
25
+ * 🔴 Collecting invocations from the PARENT transcript loses most of them: the
26
+ * `toolUseResult` record survives for a minority (measured: 19 of 128 in a
27
+ * one-day window). The subagents directory is the PRIMARY source; the parent
28
+ * record only ENRICHES (lines written, authoritative agentType, duration).
29
+ *
30
+ * 🔴 The `ctx_final × tool_calls × K` estimator was RETIRED and must not return:
31
+ * it inflated ~3×, non-uniformly (2.4×-4.4× depending on tool-call count), so
32
+ * the bias did not cancel when comparing two models either.
33
+ *
34
+ * Plus the fine print: dedup by `msg.id|requestId` (one turn persists across
35
+ * several streamed lines) — but `tool_use` blocks are counted BEFORE the dedup,
36
+ * because the streamed lines do not repeat them (measured: 29 real tool-calls
37
+ * vs 3 survivors if deduped).
38
+ */
39
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
40
+ import { join } from 'node:path';
41
+ /**
42
+ * Agents whose output is judgment, not code: `adds/dels` are 0 by definition, so
43
+ * they are measured per verdict (tokens/invocation) and per review ROUND
44
+ * (re-spawn of the same reviewer in the same session). For an executor a
45
+ * re-spawn is the next batch of work, not a retry — reading it as a round is an
46
+ * interpretation error. Agent-name based (kit-known reviewers); foreign agent
47
+ * types simply do not appear in the rounds tables.
48
+ */
49
+ export const REVIEWER_AGENTS = new Set([
50
+ 'quality-engineer',
51
+ 'architect',
52
+ 'security-auditor',
53
+ 'ui-critic',
54
+ 'product-owner',
55
+ 'project-planner',
56
+ 'skeptical-client',
57
+ 'fx-factory-reviewer',
58
+ ]);
59
+ /** Stall threshold: minutes per tool-call above which the run measured a hung command. */
60
+ const STALL_MIN_PER_TOOL = 5;
61
+ /** A stalled invocation measured the hung command, not the model. */
62
+ export const isStall = (r) => r.tools >= 5 && r.ms / 60000 / Math.max(r.tools, 1) > STALL_MIN_PER_TOOL;
63
+ /**
64
+ * Eligible for the tokens-per-delivered-line table: only what WRITES code.
65
+ * Needs the parent's `toolUseResult` (source of `toolStats`). NOT the same as
66
+ * `isStall`: a reviewer has 0 lines by definition and is not stalled — mixing
67
+ * the two (as the prototype once did) dropped the whole review panel.
68
+ */
69
+ export const writesCode = (r) => r.enriched && r.adds + r.dels > 0 && !isStall(r);
70
+ /**
71
+ * ISO timestamp → LOCAL calendar day `YYYY-MM-DD`. The window is resolved in
72
+ * local time (`resolveWindow`), so each request must be dated the same way:
73
+ * slicing the UTC string would push every evening request (UTC-6 → after
74
+ * 18:00) into the next day and the last night of a month into the next month.
75
+ * Returns '' for an unparseable timestamp (the caller skips it).
76
+ */
77
+ export const localDay = (ts) => {
78
+ const d = new Date(ts);
79
+ if (Number.isNaN(d.getTime()))
80
+ return '';
81
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
82
+ };
83
+ /** Normalize a model id for display: `claude-opus-5[1m]` → `opus-5`. */
84
+ const shortModel = (m) => (m ?? '?').replace('[1m]', '').replace('claude-', '');
85
+ /** Sum the four real usage buckets of one `usage` object. */
86
+ const usageTotal = (u) => (u.input_tokens ?? 0) +
87
+ (u.cache_creation_input_tokens ?? 0) +
88
+ (u.cache_read_input_tokens ?? 0) +
89
+ (u.output_tokens ?? 0);
90
+ /**
91
+ * Cheap file pre-filter: a `.jsonl` whose last write predates the window start
92
+ * (with a day of slack) cannot contain in-window timestamps — appends bump
93
+ * mtime. Purely an optimization; correctness never depends on it.
94
+ */
95
+ const mtimeBeforeWindow = (file, since) => {
96
+ try {
97
+ return statSync(file).mtimeMs < Date.parse(`${since}T00:00:00Z`) - 86_400_000;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ };
103
+ /**
104
+ * Resolve per-slug display names from the `cwd` each transcript line carries —
105
+ * the slug encodes the path with `-` separators AND the segments themselves
106
+ * contain hyphens, so decoding the slug is ambiguous by construction; the
107
+ * `cwd` basename is the project's real name. Collisions qualify with parent
108
+ * path segments (up to 2); a slug with no observed `cwd` (or an unresolvable
109
+ * collision) keeps the full slug — ugly but honest, never a guess.
110
+ */
111
+ function resolveRepoNames(slugs, cwdBySlug) {
112
+ const segs = (p) => p.split(/[\\/]/).filter(Boolean);
113
+ const candidate = (slug, depth) => {
114
+ const cwd = cwdBySlug.get(slug);
115
+ if (!cwd)
116
+ return null;
117
+ const s = segs(cwd);
118
+ return s.slice(Math.max(0, s.length - 1 - depth)).join('/');
119
+ };
120
+ const out = new Map();
121
+ let pending = slugs;
122
+ for (let depth = 0; depth <= 2 && pending.length; depth++) {
123
+ const byName = new Map();
124
+ const unresolvable = [];
125
+ for (const s of pending) {
126
+ const n = candidate(s, depth);
127
+ if (n === null)
128
+ unresolvable.push(s);
129
+ else
130
+ byName.set(n, [...(byName.get(n) ?? []), s]);
131
+ }
132
+ const next = [];
133
+ for (const [n, group] of byName) {
134
+ if (group.length === 1)
135
+ out.set(group[0], n);
136
+ else
137
+ next.push(...group);
138
+ }
139
+ for (const s of unresolvable)
140
+ out.set(s, s);
141
+ pending = next;
142
+ }
143
+ for (const s of pending)
144
+ out.set(s, s);
145
+ return out;
146
+ }
147
+ /**
148
+ * Index one project dir's subagent transcripts by `agentId`. They live in
149
+ * `<dir>/<session>/subagents/agent-<id>.jsonl` (+ `agent-<id>.meta.json` for
150
+ * the `agentType` — the transcript itself does not carry it). This is the
151
+ * PRIMARY invocation source (trap 2).
152
+ */
153
+ function indexSubagents(dir, slug, window, cwdSample) {
154
+ const byId = new Map();
155
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
156
+ if (!entry.isDirectory())
157
+ continue;
158
+ const subs = join(dir, entry.name, 'subagents');
159
+ if (!existsSync(subs))
160
+ continue;
161
+ for (const f of readdirSync(subs)) {
162
+ const m = /^agent-(.+)\.jsonl$/.exec(f);
163
+ if (!m)
164
+ continue;
165
+ const file = join(subs, f);
166
+ if (mtimeBeforeWindow(file, window.since))
167
+ continue;
168
+ let agentType = null;
169
+ const metaFile = join(subs, `agent-${m[1]}.meta.json`);
170
+ if (existsSync(metaFile)) {
171
+ try {
172
+ const meta = JSON.parse(readFileSync(metaFile, 'utf8'));
173
+ agentType = meta.agentType ?? null;
174
+ }
175
+ catch {
176
+ /* unreadable meta: the invocation still counts, without an agent name */
177
+ }
178
+ }
179
+ let tokens = 0;
180
+ let model = null;
181
+ let first = null;
182
+ let last = null;
183
+ let tools = 0;
184
+ let ctx = 0;
185
+ const seen = new Set();
186
+ let content;
187
+ try {
188
+ content = readFileSync(file, 'utf8');
189
+ }
190
+ catch {
191
+ continue;
192
+ }
193
+ for (const line of content.split('\n')) {
194
+ if (!line.trim())
195
+ continue;
196
+ let o;
197
+ try {
198
+ o = JSON.parse(line);
199
+ }
200
+ catch {
201
+ continue;
202
+ }
203
+ const ts = typeof o.timestamp === 'string' ? o.timestamp : null;
204
+ if (ts) {
205
+ if (!first || ts < first)
206
+ first = ts;
207
+ if (!last || ts > last)
208
+ last = ts;
209
+ }
210
+ if (!cwdSample.value && typeof o.cwd === 'string' && o.cwd)
211
+ cwdSample.value = o.cwd;
212
+ if (o.type !== 'assistant')
213
+ continue;
214
+ const msg = (o.message ?? {});
215
+ // 🔴 tool_use blocks are counted BEFORE the per-request dedup. The dedup
216
+ // exists so a turn streamed across several lines is not double-billed —
217
+ // but its tool_use blocks do NOT repeat across those lines: deduping
218
+ // them erased real calls (measured: 29 real vs 3 survivors), validated
219
+ // against the user-side tool_result count, which matched exactly.
220
+ if (Array.isArray(msg.content)) {
221
+ tools += msg.content.filter((c) => c?.type === 'tool_use').length;
222
+ }
223
+ const u = msg.usage;
224
+ if (!u)
225
+ continue;
226
+ const key = `${msg.id}|${String(o.requestId)}`;
227
+ if (seen.has(key))
228
+ continue;
229
+ seen.add(key);
230
+ model ??= shortModel(msg.model);
231
+ tokens += usageTotal(u);
232
+ // Context comes from the invocation's OWN transcript, so it never
233
+ // depends on the parent having kept its `toolUseResult`.
234
+ ctx = Math.max(ctx, (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0));
235
+ }
236
+ if (!first)
237
+ continue; // empty or unreadable file
238
+ byId.set(m[1], {
239
+ repo: slug, // display name resolved post-sweep (cwd basename)
240
+ slug,
241
+ date: localDay(first),
242
+ session: entry.name,
243
+ agent: agentType ?? '(sin meta)',
244
+ model: model ?? '?',
245
+ tokens,
246
+ ms: last && first ? Date.parse(last) - Date.parse(first) : 0,
247
+ tools,
248
+ ctx,
249
+ adds: 0,
250
+ dels: 0,
251
+ enriched: false,
252
+ });
253
+ }
254
+ }
255
+ return byId;
256
+ }
257
+ /**
258
+ * Sweep every project dir under `root` and return main-loop aggregates + the
259
+ * subagent invocation list, both windowed. `root` is a parameter (tests point
260
+ * it at a synthetic fixture tree); the command passes `~/.claude/projects`.
261
+ */
262
+ export function scanUsage(root, window) {
263
+ const invocations = [];
264
+ const mainAgg = new Map();
265
+ let reposScanned = 0;
266
+ if (!existsSync(root))
267
+ return { main: [], invocations: [], reposScanned: 0 };
268
+ const slugs = readdirSync(root, { withFileTypes: true })
269
+ .filter((e) => e.isDirectory())
270
+ .map((e) => e.name);
271
+ const cwdBySlug = new Map();
272
+ for (const slug of slugs) {
273
+ const dir = join(root, slug);
274
+ const repo = slug; // display name resolved post-sweep (cwd basename)
275
+ reposScanned++;
276
+ const cwdSample = { value: null };
277
+ const subs = indexSubagents(dir, slug, window, cwdSample);
278
+ // Dedup spans every main-loop transcript of the slug, not one file: a
279
+ // resumed or forked session re-persists earlier turns under the same
280
+ // `id|requestId` in its new file (measured ~1.1% of keys), which a
281
+ // per-file set would bill twice.
282
+ const seen = new Set();
283
+ for (const f of readdirSync(dir).filter((x) => x.endsWith('.jsonl'))) {
284
+ const file = join(dir, f);
285
+ if (mtimeBeforeWindow(file, window.since))
286
+ continue;
287
+ let content;
288
+ try {
289
+ content = readFileSync(file, 'utf8');
290
+ }
291
+ catch {
292
+ continue;
293
+ }
294
+ for (const line of content.split('\n')) {
295
+ // Only two line families matter here; skip the rest before JSON.parse.
296
+ if (!line.includes('"usage"') && !line.includes('"agentType"'))
297
+ continue;
298
+ let o;
299
+ try {
300
+ o = JSON.parse(line);
301
+ }
302
+ catch {
303
+ continue;
304
+ }
305
+ if (!cwdSample.value && typeof o.cwd === 'string' && o.cwd)
306
+ cwdSample.value = o.cwd;
307
+ const date = typeof o.timestamp === 'string' ? localDay(o.timestamp) : '';
308
+ if (!date || date < window.since || date > window.until)
309
+ continue;
310
+ // The parent's `toolUseResult` ENRICHES the already-indexed invocation —
311
+ // it contributes what the subagent transcript does not carry: toolStats
312
+ // (lines written), authoritative agentType, wall-clock duration. It
313
+ // NEVER creates the invocation (trap 2), and its `totalTokens` field is
314
+ // NEVER read (trap 1 — it reports ~1% of the real total).
315
+ const t = o.toolUseResult;
316
+ if (t && typeof t === 'object' && t.agentType && t.agentId) {
317
+ const rec = subs.get(t.agentId);
318
+ if (rec) {
319
+ rec.agent = t.agentType; // meta may be missing; this is authoritative
320
+ rec.adds = t.toolStats?.linesAdded ?? 0;
321
+ rec.dels = t.toolStats?.linesRemoved ?? 0;
322
+ if (t.totalDurationMs)
323
+ rec.ms = t.totalDurationMs;
324
+ rec.enriched = true;
325
+ // 🔴 `tools` and `ctx` are NOT overwritten: they already come from
326
+ // the invocation's own transcript for EVERY invocation. Taking them
327
+ // from here only for the enriched minority would mix two sources in
328
+ // one column and make rows incomparable.
329
+ }
330
+ }
331
+ if (o.type !== 'assistant')
332
+ continue;
333
+ const msg = (o.message ?? {});
334
+ const u = msg.usage;
335
+ if (!u)
336
+ continue;
337
+ const key = `${msg.id}|${String(o.requestId)}`; // one turn persists across lines
338
+ if (seen.has(key))
339
+ continue;
340
+ seen.add(key);
341
+ const model = shortModel(msg.model);
342
+ const aggKey = `${slug}|${date}|${model}`;
343
+ const agg = mainAgg.get(aggKey) ?? { repo, slug, date, model, tokens: 0, requests: 0 };
344
+ agg.tokens += usageTotal(u);
345
+ agg.requests++;
346
+ mainAgg.set(aggKey, agg);
347
+ }
348
+ }
349
+ for (const rec of subs.values()) {
350
+ if (rec.date >= window.since && rec.date <= window.until)
351
+ invocations.push(rec);
352
+ }
353
+ if (cwdSample.value)
354
+ cwdBySlug.set(slug, cwdSample.value);
355
+ }
356
+ // Post-sweep: resolve display names from the observed cwd basenames and
357
+ // stamp them on every row (the sweep kept the slug as a placeholder).
358
+ const names = resolveRepoNames(slugs, cwdBySlug);
359
+ for (const r of invocations)
360
+ r.repo = names.get(r.slug) ?? r.slug;
361
+ const main = [...mainAgg.values()].map((r) => ({ ...r, repo: names.get(r.slug) ?? r.slug }));
362
+ return { main, invocations, reposScanned };
363
+ }
364
+ const byTokensDesc = (a, b) => b.tokens - a.tokens;
365
+ /** Aggregate a scan into the report the renderer and `--json` consume. */
366
+ export function aggregateUsage(scan, window, opts = {}) {
367
+ const repoFilter = opts.repo ?? null;
368
+ const agentFilter = opts.agent ?? null;
369
+ const mainRows = repoFilter
370
+ ? scan.main.filter((r) => r.repo.includes(repoFilter) || r.slug.includes(repoFilter))
371
+ : scan.main;
372
+ let subRows = repoFilter
373
+ ? scan.invocations.filter((r) => r.repo.includes(repoFilter) || r.slug.includes(repoFilter))
374
+ : scan.invocations;
375
+ if (agentFilter)
376
+ subRows = subRows.filter((r) => r.agent === agentFilter);
377
+ const dayMap = new Map();
378
+ const modelMap = new Map();
379
+ const repoMap = new Map();
380
+ const agentMap = new Map();
381
+ let mainTok = 0;
382
+ let mainRequests = 0;
383
+ for (const r of mainRows) {
384
+ mainTok += r.tokens;
385
+ mainRequests += r.requests;
386
+ const d = dayMap.get(r.date) ?? { main: 0, sub: 0 };
387
+ d.main += r.tokens;
388
+ dayMap.set(r.date, d);
389
+ const m = modelMap.get(r.model) ?? { main: 0, sub: 0 };
390
+ m.main += r.tokens;
391
+ modelMap.set(r.model, m);
392
+ const p = repoMap.get(r.repo) ?? { main: 0, sub: 0 };
393
+ p.main += r.tokens;
394
+ repoMap.set(r.repo, p);
395
+ }
396
+ let subTok = 0;
397
+ let unenriched = 0;
398
+ for (const r of subRows) {
399
+ subTok += r.tokens;
400
+ if (!r.enriched)
401
+ unenriched++;
402
+ const d = dayMap.get(r.date) ?? { main: 0, sub: 0 };
403
+ d.sub += r.tokens;
404
+ dayMap.set(r.date, d);
405
+ const m = modelMap.get(r.model) ?? { main: 0, sub: 0 };
406
+ m.sub += r.tokens;
407
+ modelMap.set(r.model, m);
408
+ const p = repoMap.get(r.repo) ?? { main: 0, sub: 0 };
409
+ p.sub += r.tokens;
410
+ repoMap.set(r.repo, p);
411
+ const a = agentMap.get(r.agent) ?? { invocations: 0, tokens: 0 };
412
+ a.invocations++;
413
+ a.tokens += r.tokens;
414
+ agentMap.set(r.agent, a);
415
+ }
416
+ // Tokens per delivered unit (writes-code agents only; stalls excluded).
417
+ const workMap = new Map();
418
+ for (const r of subRows) {
419
+ if (!writesCode(r))
420
+ continue;
421
+ const k = `${r.agent}|${r.model}`;
422
+ const v = workMap.get(k) ?? { invocations: 0, tokens: 0, lines: 0, tools: 0, minutes: 0 };
423
+ v.invocations++;
424
+ v.tokens += r.tokens;
425
+ v.lines += r.adds + r.dels;
426
+ v.tools += r.tools;
427
+ v.minutes += r.ms / 60000;
428
+ workMap.set(k, v);
429
+ }
430
+ // Reviewer cost per verdict + rounds per session.
431
+ const reviewMap = new Map();
432
+ const roundsMap = new Map();
433
+ for (const r of subRows) {
434
+ if (!REVIEWER_AGENTS.has(r.agent))
435
+ continue;
436
+ const k = `${r.agent}|${r.model}`;
437
+ const v = reviewMap.get(k) ?? { invocations: 0, tokens: 0, minutes: 0, tools: 0, ctx: 0 };
438
+ v.invocations++;
439
+ v.tokens += r.tokens;
440
+ v.minutes += r.ms / 60000;
441
+ v.tools += r.tools;
442
+ v.ctx += r.ctx;
443
+ reviewMap.set(k, v);
444
+ const rk = `${r.slug}|${r.session}|${r.agent}`;
445
+ roundsMap.set(rk, (roundsMap.get(rk) ?? 0) + 1);
446
+ }
447
+ const roundsByAgent = new Map();
448
+ for (const [k, n] of roundsMap) {
449
+ const agent = k.split('|').at(-1) ?? '?';
450
+ const v = roundsByAgent.get(agent) ?? { sessions: 0, spawns: 0 };
451
+ v.sessions++;
452
+ v.spawns += n;
453
+ roundsByAgent.set(agent, v);
454
+ }
455
+ let byRepo = [...repoMap.entries()]
456
+ .map(([repo, v]) => ({ repo, main: v.main, sub: v.sub, tokens: v.main + v.sub }))
457
+ .sort(byTokensDesc);
458
+ if (opts.anon) {
459
+ byRepo = byRepo.map((r, i) => ({ ...r, repo: `repo-${String(i + 1).padStart(2, '0')}` }));
460
+ }
461
+ return {
462
+ version: 1,
463
+ window,
464
+ // Under --anon the repo filter is masked too: echoing the substring the
465
+ // user typed would put the client's name back next to `repo-01`.
466
+ filters: {
467
+ repo: opts.anon && repoFilter ? '(oculto)' : repoFilter,
468
+ agent: agentFilter,
469
+ anon: Boolean(opts.anon),
470
+ },
471
+ totals: {
472
+ main: mainTok,
473
+ sub: subTok,
474
+ total: mainTok + subTok,
475
+ mainRequests,
476
+ invocations: subRows.length,
477
+ unenriched,
478
+ agentTypes: agentMap.size,
479
+ reposScanned: scan.reposScanned,
480
+ },
481
+ byDay: [...dayMap.entries()]
482
+ .map(([date, v]) => ({ date, main: v.main, sub: v.sub }))
483
+ .sort((a, b) => (a.date < b.date ? -1 : 1)),
484
+ byModel: [...modelMap.entries()]
485
+ .map(([model, v]) => ({ model, main: v.main, sub: v.sub, tokens: v.main + v.sub }))
486
+ .filter((m) => m.tokens > 0) // e.g. `<synthetic>` rows: requests with zero usage
487
+ .sort(byTokensDesc),
488
+ byRepo,
489
+ byAgent: [...agentMap.entries()]
490
+ .map(([agent, v]) => ({ agent, invocations: v.invocations, tokens: v.tokens }))
491
+ .sort(byTokensDesc),
492
+ delivered: [...workMap.entries()]
493
+ .map(([k, v]) => {
494
+ const [agent, model] = k.split('|');
495
+ return { agent, model, ...v };
496
+ })
497
+ .sort(byTokensDesc),
498
+ reviewCost: [...reviewMap.entries()]
499
+ .map(([k, v]) => {
500
+ const [agent, model] = k.split('|');
501
+ return {
502
+ agent,
503
+ model,
504
+ invocations: v.invocations,
505
+ tokens: v.tokens,
506
+ minutes: v.minutes,
507
+ tools: v.tools,
508
+ avgCtx: v.invocations ? Math.round(v.ctx / v.invocations) : 0,
509
+ };
510
+ })
511
+ .sort(byTokensDesc),
512
+ reviewRounds: [...roundsByAgent.entries()]
513
+ .map(([agent, v]) => ({ agent, sessions: v.sessions, spawns: v.spawns }))
514
+ .sort((a, b) => b.spawns - a.spawns),
515
+ };
516
+ }
@@ -63,9 +63,13 @@ export const RETIRED_RAIL_KEYS = [
63
63
  'BACKLOG_ADMIN_TOKEN',
64
64
  'PROPOSAL_ASSETS_R2_TOKEN',
65
65
  'NEON_ORG',
66
+ // Direct-operation rail tokens: no kit command reads them, and they are still never a project's.
67
+ 'ELEVENLABS_API_KEY',
68
+ 'AWS_BILLING_ACCESS_KEY_ID',
69
+ 'AWS_BILLING_SECRET_ACCESS_KEY',
66
70
  ];
67
- /** Rail families by prefix (`CLOUDFLARE_*`, `GAMMA_*`) — same list, for the names §5 groups. */
68
- const RAIL_KEY_PREFIXES = ['CLOUDFLARE_', 'GAMMA_'];
71
+ /** Rail families by prefix (`CLOUDFLARE_*`, `GAMMA_*`, `APP_STORE_CONNECT_*`) — same list, for the names §5 groups. */
72
+ const RAIL_KEY_PREFIXES = ['CLOUDFLARE_', 'GAMMA_', 'APP_STORE_CONNECT_'];
69
73
  /** Keys the kit retired and nothing reads anymore: never imported, named "retirada". */
70
74
  export const RETIRED_UNREAD_KEYS = ['NEON_ORGANIZATION_ID'];
71
75
  /** Whether `key` is a rail key ({@link RETIRED_RAIL_KEYS}, or one of its prefixed families). */
@@ -335,6 +339,98 @@ export function describeWrite(write) {
335
339
  return `${where} (key propia del proyecto, nueva)`;
336
340
  return `${where}${renamed}`;
337
341
  }
342
+ /**
343
+ * Factory#72 — the writes are `create-if-missing`, so a key the vault already holds keeps its
344
+ * value, and the first sync carries THAT value to the deploy. Compare it against what the plan
345
+ * would write, at every location a sync reads (`main`, `develop`; `local` feeds no deploy). A
346
+ * reference is compared by the value it resolves to inside the plan; one whose target the plan
347
+ * does not write is skipped (its value is whatever the vault already has there).
348
+ *
349
+ * @param existing the vault's current values per location (`formatLocation`), references expanded.
350
+ */
351
+ export function vaultWriteConflicts(writes, existing, hash) {
352
+ const planned = (location, key, depth = 0) => {
353
+ const write = writes.find((w) => w.key === key && formatLocation(w.location) === formatLocation(location));
354
+ if (!write || depth > 3)
355
+ return null;
356
+ if (write.kind === 'value')
357
+ return write.value;
358
+ if (write.kind === 'reference')
359
+ return planned(write.target, write.target.key, depth + 1);
360
+ return null;
361
+ };
362
+ const conflicts = [];
363
+ for (const write of writes) {
364
+ if (write.location.env === VAULT_ENV_LOCAL || write.kind === 'minted-resend')
365
+ continue;
366
+ const location = formatLocation(write.location);
367
+ const current = existing.get(location)?.get(write.key);
368
+ const expected = planned(write.location, write.key);
369
+ if (current === undefined || expected === null || current === expected)
370
+ continue;
371
+ conflicts.push({ location, key: write.key, vault: hash(current), plan: hash(expected) });
372
+ }
373
+ return conflicts;
374
+ }
375
+ /**
376
+ * Factory#73 — a Vercel var that is ONE record for production and preview, while `develop:/`
377
+ * gets its own value: the preview sync would rewrite the shared record and production would end
378
+ * up with develop's value. A key that stays a reference to `main:/` carries the same value to
379
+ * both targets and is harmless.
380
+ */
381
+ export function dangerousSharedTargets(shared, writes) {
382
+ const developOwn = new Set(writes
383
+ .filter((w) => w.kind === 'value' && formatLocation(w.location) === formatLocation(DEVELOP_ROOT))
384
+ .map((w) => w.key));
385
+ return [...new Set(shared)].filter((key) => developOwn.has(key)).sort();
386
+ }
387
+ /** The names a repo gives its sender, in order (the kit reads `EMAIL_FROM`). */
388
+ export const SENDER_KEYS = [
389
+ 'EMAIL_FROM',
390
+ 'FROM_EMAIL',
391
+ 'MAIL_FROM',
392
+ 'RESEND_FROM',
393
+ ];
394
+ /**
395
+ * Factory#74 — the repo's sender, from any of {@link SENDER_KEYS} (production → preview →
396
+ * local). `unknown` lists other `*FROM*` keys holding an address: a sender under a name this does
397
+ * not know, which must not fall back to the kit's domain in silence.
398
+ */
399
+ export function adoptSender(values) {
400
+ const maps = [values.production, values.preview, values.local];
401
+ for (const key of SENDER_KEYS) {
402
+ for (const map of maps) {
403
+ const value = map.get(key);
404
+ if (value)
405
+ return { sender: value, unknown: [] };
406
+ }
407
+ }
408
+ const unknown = new Set();
409
+ for (const map of maps) {
410
+ for (const [key, value] of map) {
411
+ if (/FROM/.test(key) && value?.includes('@'))
412
+ unknown.add(key);
413
+ }
414
+ }
415
+ return { sender: null, unknown: [...unknown].sort() };
416
+ }
417
+ /** A key that names a database by its connection (any provider, not only Neon). */
418
+ const DATABASE_KEY_RE = /(^|_)DATABASE_URL$|_DB_URL$|^POSTGRES_URL|^TURSO_(DATABASE_URL|AUTH_TOKEN)$/;
419
+ /**
420
+ * Factory#75 — a database key whose preview value equals production's becomes a reference
421
+ * `develop:/ → main:/`: previews and local would write to the production database. Neon's
422
+ * `DATABASE_URL` is left out when Neon manages it (its branches have their own path).
423
+ */
424
+ export function sharedDatabaseKeys(writes, neonManaged) {
425
+ return writes
426
+ .filter((w) => w.kind === 'reference' &&
427
+ formatLocation(w.location) === formatLocation(DEVELOP_ROOT) &&
428
+ formatLocation(w.target) === formatLocation(MAIN_ROOT) &&
429
+ DATABASE_KEY_RE.test(w.key) &&
430
+ !(neonManaged && w.key === DATABASE_URL_KEY))
431
+ .map((w) => w.key)
432
+ .sort();
433
+ }
338
434
  /**
339
435
  * Build the entries of the plan (one per key, sorted), the confirmations the apply needs and the
340
436
  * blockers that stop it. Pure: values in, NAMES and hashes out.
@@ -947,9 +1043,11 @@ export function manualVaultProjectSteps(slug, folders) {
947
1043
  'contra borrado e import.');
948
1044
  }
949
1045
  /**
950
- * Whether the vault is OFFERED in this repo — the same rule as `vault adopt` (ADOPT-001): not in
951
- * `core`, detected by the lockfile's `profile: 'core'` OR by the absence of the wrapper
952
- * (`scripts/tools/with-vault.mjs`), never by the absence of `package.json`.
1046
+ * Whether the vault is OFFERED in this repo (what `doctor`/`status` propose): not in `core`,
1047
+ * detected by the lockfile's `profile: 'core'` OR by the absence of the wrapper
1048
+ * (`scripts/tools/with-vault.mjs`), never by the absence of `package.json`. Offered ≠ allowed:
1049
+ * `vault adopt` also ACCEPTS a `core` repo that asks for it (Factory#76) — only not proposing it
1050
+ * keeps `doctor` quiet in every `core` repo of another stack.
953
1051
  */
954
1052
  export function isVaultOffered(rootDir) {
955
1053
  return (detectCoreProfile(rootDir) === null && existsSync(path.join(rootDir, WITH_VAULT_SCRIPT_FILE)));
@@ -54,7 +54,7 @@
54
54
  */
55
55
  import { CLIError } from './cli-error.js';
56
56
  import { LOCAL_ONLY_KEYS } from './env-file.js';
57
- import { DEFAULT_RUNNER_OVERRIDE_KEY, RailNoSessionError, RailNotInstalledError, createExecaInfisicalRunner, loginCommand, } from './rail.js';
57
+ import { DEFAULT_RUNNER_OVERRIDE_KEY, RailNoSessionError, RailNotInstalledError, createExecaInfisicalRunner, INFISICAL_UPGRADE_HINT, loginCommand, } from './rail.js';
58
58
  import { VAULT_CONFIG } from './vault-config.js';
59
59
  // ---------------------------------------------------------------------------
60
60
  // Layout constants (ADR-005)
@@ -414,7 +414,7 @@ async function obtainSessionToken(runner, config, timeoutMs) {
414
414
  throw new RailNoSessionError(config);
415
415
  const token = result.stdout.trim();
416
416
  if (token === '' || /\s/.test(token)) {
417
- throw new VaultApiError('unrecognized', 'La bóveda devolvió una sesión que no reconozco. Actualiza `infisical` (`brew upgrade infisical`) ' +
417
+ throw new VaultApiError('unrecognized', `La bóveda devolvió una sesión que no reconozco. Actualiza \`infisical\` (${INFISICAL_UPGRADE_HINT}) ` +
418
418
  `y entra con \`${loginCommand(config)}\`.`);
419
419
  }
420
420
  return token;