@timekast/factory 2.4.0 → 2.6.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.
- package/dist/commands/add.js +29 -8
- package/dist/commands/doctor.js +37 -8
- package/dist/commands/env.js +340 -8
- package/dist/commands/provision.js +327 -42
- package/dist/commands/prune.js +60 -10
- package/dist/commands/update.js +109 -85
- package/dist/commands/usage.js +303 -0
- package/dist/commands/vault.js +81 -18
- package/dist/index.js +52 -3
- package/dist/lib/backlog-mapping.js +1 -1
- package/dist/lib/claude-paths.js +8 -2
- package/dist/lib/claude-settings.js +104 -0
- package/dist/lib/constants.js +26 -0
- package/dist/lib/derived-dotfiles.js +165 -0
- package/dist/lib/email-from.js +12 -4
- package/dist/lib/env-file.js +4 -0
- package/dist/lib/env-wizard.js +1 -1
- package/dist/lib/initial-writes.js +113 -0
- package/dist/lib/kit-deps.js +163 -0
- package/dist/lib/kit-settings-history.json +13 -0
- package/dist/lib/lockfile.js +10 -5
- package/dist/lib/prune-remote.js +8 -6
- package/dist/lib/prune-vercel.js +137 -0
- package/dist/lib/public-env-keys.json +2 -0
- package/dist/lib/rail.js +5 -3
- package/dist/lib/railway-api.js +3 -0
- package/dist/lib/usage-scan.js +516 -0
- package/dist/lib/vault-adopt.js +103 -5
- package/dist/lib/vault-api.js +2 -2
- package/dist/lib/vercel-env.js +29 -0
- package/dist/lib/whats-new.js +123 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/lib/vault-adopt.js
CHANGED
|
@@ -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
|
|
951
|
-
*
|
|
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)));
|
package/dist/lib/vault-api.js
CHANGED
|
@@ -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',
|
|
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;
|