@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,303 @@
1
+ /**
2
+ * `factory usage` — measure REAL Claude Code consumption from the transcripts
3
+ * in the developer's home (`~/.claude/projects/`), any repo, any machine.
4
+ *
5
+ * 🔴 Unlike `update` / `status` / `doctor`, this command has NO repo preflight
6
+ * and must never grow one: the data source is the developer's HOME, not the
7
+ * cwd. It runs from any folder — inside a derivative, inside an unrelated
8
+ * repo, or in no repo at all — and reports every project dir it finds.
9
+ *
10
+ * v1 is LOCAL-ONLY: the command reads, prints and exports JSON. Nothing leaves
11
+ * the machine. (A v2 fleet aggregation — where the team's numbers land, with
12
+ * what credential, per-developer opt-in — is a separate decision; see the
13
+ * Encargo C brief. Do not wire network calls into this command.)
14
+ *
15
+ * Privacy invariant (design-level, not preference): only AGGREGATES are ever
16
+ * printed — dates, counts, tokens, model/agent names, repo slugs. Never message
17
+ * content, never client project file paths, never code fragments. The repo slug
18
+ * itself identifies the client, so `--anon` exists for shareable reports — the
19
+ * developer's explicit call, never the default.
20
+ */
21
+ import { homedir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { CLIError } from '../lib/cli-error.js';
24
+ import { aggregateUsage, scanUsage, } from '../lib/usage-scan.js';
25
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
26
+ const MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
27
+ /** es-MX month names, index = month number - 1. */
28
+ const MONTH_NAMES = [
29
+ 'enero',
30
+ 'febrero',
31
+ 'marzo',
32
+ 'abril',
33
+ 'mayo',
34
+ 'junio',
35
+ 'julio',
36
+ 'agosto',
37
+ 'septiembre',
38
+ 'octubre',
39
+ 'noviembre',
40
+ 'diciembre',
41
+ ];
42
+ const eq = (name) => (a) => {
43
+ const p = `--${name}=`;
44
+ return a.startsWith(p) ? a.slice(p.length) : null;
45
+ };
46
+ /** Parse `factory usage` flags. Unknown flags fail loudly (no silent typos). */
47
+ export function parseUsageFlags(argv) {
48
+ const flags = {
49
+ since: null,
50
+ until: null,
51
+ month: null,
52
+ repo: null,
53
+ agent: null,
54
+ json: false,
55
+ anon: false,
56
+ };
57
+ for (const arg of argv) {
58
+ if (arg === '--json')
59
+ flags.json = true;
60
+ else if (arg === '--anon')
61
+ flags.anon = true;
62
+ else if (eq('since')(arg) !== null)
63
+ flags.since = eq('since')(arg);
64
+ else if (eq('until')(arg) !== null)
65
+ flags.until = eq('until')(arg);
66
+ else if (eq('month')(arg) !== null)
67
+ flags.month = eq('month')(arg);
68
+ else if (eq('mes')(arg) !== null)
69
+ flags.month = eq('mes')(arg);
70
+ else if (eq('repo')(arg) !== null)
71
+ flags.repo = eq('repo')(arg);
72
+ else if (eq('agent')(arg) !== null)
73
+ flags.agent = eq('agent')(arg);
74
+ else {
75
+ throw new CLIError(`Flag desconocida para \`factory usage\`: \`${arg}\`.\n` +
76
+ 'Uso: factory usage [--month=<mes|YYYY-MM> (alias --mes=)] [--since=YYYY-MM-DD] [--until=YYYY-MM-DD] ' +
77
+ '[--repo=<subcadena>] [--agent=<tipo>] [--anon] [--json]');
78
+ }
79
+ }
80
+ if (flags.month && (flags.since || flags.until)) {
81
+ throw new CLIError('`--month` y `--since`/`--until` son excluyentes: usa uno u otro.');
82
+ }
83
+ return flags;
84
+ }
85
+ /** Local date → `YYYY-MM-DD`. */
86
+ const isoDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
87
+ /** Last day of a (year, monthIndex0) month. */
88
+ const monthEnd = (year, monthIdx) => isoDay(new Date(year, monthIdx + 1, 0));
89
+ /**
90
+ * Resolve the report window. Precedence: `--month` (name or `YYYY-MM`) >
91
+ * `--since`/`--until` > default (the CURRENT calendar month, to date — the
92
+ * reference view of the brief). A month NAME resolves to its most recent
93
+ * occurrence: "julio" asked in agosto 2026 → 2026-07; asked in junio 2026 →
94
+ * 2025-07. `now` is injectable for tests.
95
+ */
96
+ export function resolveWindow(flags, now = new Date()) {
97
+ if (flags.month) {
98
+ const raw = flags.month.toLowerCase();
99
+ if (MONTH_RE.test(raw)) {
100
+ const [y, m] = raw.split('-').map(Number);
101
+ return { since: `${raw}-01`, until: monthEnd(y, m - 1) };
102
+ }
103
+ const idx = MONTH_NAMES.indexOf(raw);
104
+ if (idx === -1) {
105
+ throw new CLIError(`Mes no reconocido: \`${flags.month}\`. Acepta ${MONTH_NAMES.join(', ')} o \`YYYY-MM\`.`);
106
+ }
107
+ const year = idx > now.getMonth() ? now.getFullYear() - 1 : now.getFullYear();
108
+ const mm = String(idx + 1).padStart(2, '0');
109
+ return { since: `${year}-${mm}-01`, until: monthEnd(year, idx) };
110
+ }
111
+ for (const [name, v] of [
112
+ ['--since', flags.since],
113
+ ['--until', flags.until],
114
+ ]) {
115
+ if (v && !DATE_RE.test(v)) {
116
+ throw new CLIError(`Fecha inválida en \`${name}\`: \`${v}\` (formato: YYYY-MM-DD).`);
117
+ }
118
+ }
119
+ const today = isoDay(now);
120
+ const firstOfMonth = `${today.slice(0, 7)}-01`;
121
+ const since = flags.since ?? (flags.until ? '1970-01-01' : firstOfMonth);
122
+ const until = flags.until ?? today;
123
+ if (since > until) {
124
+ throw new CLIError(`Ventana vacía: \`--since=${since}\` es posterior a \`--until=${until}\`.`);
125
+ }
126
+ return { since, until };
127
+ }
128
+ /** Default projects root: the developer's home, never the cwd. */
129
+ export const defaultUsageRoot = () => join(homedir(), '.claude', 'projects');
130
+ const M = (t) => (t / 1e6).toFixed(1);
131
+ const pct = (part, total) => total ? `${((100 * part) / total).toFixed(1)}%` : '—';
132
+ /** Proportional stacked bar: █ = main, ▓ = subagentes; width ∝ day total / max. */
133
+ const dayBar = (main, sub, max, width = 24) => {
134
+ if (!max)
135
+ return '';
136
+ const total = main + sub;
137
+ const len = Math.max(total > 0 ? 1 : 0, Math.round((total / max) * width));
138
+ const mainLen = Math.round((main / Math.max(total, 1)) * len);
139
+ return '█'.repeat(mainLen) + '▓'.repeat(len - mainLen);
140
+ };
141
+ /** Render the human report (es-MX). Kept pure for tests: returns the text. */
142
+ export function renderUsageReport(report) {
143
+ const L = [];
144
+ const t = report.totals;
145
+ const filterNote = [
146
+ report.filters.repo ? `repo~"${report.filters.repo}"` : null,
147
+ report.filters.agent ? `agent=${report.filters.agent}` : null,
148
+ report.filters.anon ? 'anon' : null,
149
+ ]
150
+ .filter(Boolean)
151
+ .join(' · ');
152
+ L.push('');
153
+ L.push(`📊 factory usage — ${report.window.since} → ${report.window.until}` +
154
+ (filterNote ? ` [${filterNote}]` : ''));
155
+ L.push(` ${M(t.total)} M tokens · ${t.mainRequests.toLocaleString('en-US')} requests con usage real · ` +
156
+ `${t.invocations.toLocaleString('en-US')} invocaciones de subagente (${t.agentTypes} tipos) · ${t.reposScanned} repos barridos`);
157
+ if (t.total === 0) {
158
+ L.push('');
159
+ L.push(' (sin datos en la ventana — ¿existe ~/.claude/projects/ en esta máquina?)');
160
+ L.push('');
161
+ return L.join('\n');
162
+ }
163
+ const maxDay = Math.max(...report.byDay.map((d) => d.main + d.sub), 0);
164
+ L.push('');
165
+ L.push('DÍA × SUPERFICIE (M tokens · █ main loop · ▓ subagentes · barra ∝ día más caro)');
166
+ L.push(' ' + 'día'.padEnd(12) + 'main'.padStart(8) + 'subs'.padStart(8) + 'total'.padStart(8));
167
+ for (const d of report.byDay) {
168
+ L.push(' ' +
169
+ d.date.padEnd(12) +
170
+ M(d.main).padStart(8) +
171
+ M(d.sub).padStart(8) +
172
+ M(d.main + d.sub).padStart(8) +
173
+ ' ' +
174
+ dayBar(d.main, d.sub, maxDay));
175
+ }
176
+ L.push('');
177
+ L.push('POR MODELO');
178
+ for (const m of report.byModel) {
179
+ L.push(' ' +
180
+ m.model.padEnd(16) +
181
+ `${M(m.tokens)} M`.padStart(11) +
182
+ pct(m.tokens, t.total).padStart(8));
183
+ }
184
+ L.push('');
185
+ L.push('POR REPO' + (report.filters.anon ? ' (anonimizado)' : ''));
186
+ const shownRepos = report.byRepo.slice(0, 20);
187
+ for (const r of shownRepos) {
188
+ L.push(' ' +
189
+ r.repo.slice(0, 34).padEnd(35) +
190
+ `${M(r.tokens)} M`.padStart(11) +
191
+ pct(r.tokens, t.total).padStart(8));
192
+ }
193
+ if (report.byRepo.length > shownRepos.length) {
194
+ const rest = report.byRepo.slice(20);
195
+ const restTok = rest.reduce((a, r) => a + r.tokens, 0);
196
+ L.push(` … y ${rest.length} repos más`.padEnd(37) +
197
+ `${M(restTok)} M`.padStart(11) +
198
+ pct(restTok, t.total).padStart(8) +
199
+ ' (completos en --json)');
200
+ }
201
+ L.push('');
202
+ L.push('POR AGENTE (top 12 de ' + String(t.agentTypes) + ')');
203
+ L.push(' ' +
204
+ 'agente'.padEnd(26) +
205
+ 'n'.padStart(5) +
206
+ 'M tokens'.padStart(10) +
207
+ '% subs'.padStart(8) +
208
+ '% total'.padStart(9));
209
+ for (const a of report.byAgent.slice(0, 12)) {
210
+ L.push(' ' +
211
+ a.agent.slice(0, 25).padEnd(26) +
212
+ String(a.invocations).padStart(5) +
213
+ M(a.tokens).padStart(10) +
214
+ pct(a.tokens, t.sub).padStart(8) +
215
+ pct(a.tokens, t.total).padStart(9));
216
+ }
217
+ if (report.delivered.length) {
218
+ L.push('');
219
+ L.push('TOKENS POR UNIDAD ENTREGADA (agentes que escriben código; excluye colgadas)');
220
+ L.push(' ' +
221
+ 'agente'.padEnd(24) +
222
+ 'modelo'.padEnd(12) +
223
+ 'n'.padStart(4) +
224
+ 'M tokens'.padStart(10) +
225
+ 'líneas'.padStart(9) +
226
+ 'M tok/1k ln'.padStart(13));
227
+ for (const w of report.delivered) {
228
+ const perK = w.lines ? ((w.tokens / w.lines) * 1000) / 1e6 : null;
229
+ L.push(' ' +
230
+ w.agent.slice(0, 23).padEnd(24) +
231
+ w.model.padEnd(12) +
232
+ String(w.invocations).padStart(4) +
233
+ M(w.tokens).padStart(10) +
234
+ String(w.lines).padStart(9) +
235
+ (perK === null ? '—' : perK.toFixed(2)).padStart(13));
236
+ }
237
+ if (t.unenriched) {
238
+ L.push(` ℹ️ ${t.unenriched} invocaciones sin \`toolUseResult\` del padre: cuentan en el consumo,` +
239
+ ' pero sin líneas — no entran a esta tabla.');
240
+ }
241
+ }
242
+ if (report.reviewCost.length) {
243
+ L.push('');
244
+ L.push('REVISORES — costo por veredicto (no producen líneas; se miden por invocación)');
245
+ L.push(' ' +
246
+ 'agente'.padEnd(24) +
247
+ 'modelo'.padEnd(12) +
248
+ 'n'.padStart(4) +
249
+ 'M tok/inv'.padStart(11) +
250
+ 'min/inv'.padStart(9) +
251
+ 'ctx medio'.padStart(11));
252
+ for (const r of report.reviewCost) {
253
+ L.push(' ' +
254
+ r.agent.slice(0, 23).padEnd(24) +
255
+ r.model.padEnd(12) +
256
+ String(r.invocations).padStart(4) +
257
+ (r.tokens / r.invocations / 1e6).toFixed(1).padStart(11) +
258
+ (r.minutes / r.invocations).toFixed(1).padStart(9) +
259
+ `${Math.round(r.avgCtx / 1000)}k`.padStart(11));
260
+ }
261
+ L.push(' ⚠️ compara ctx medio antes de leer diferencias: revisar epics más chicos gasta menos por razones ajenas al modelo.');
262
+ }
263
+ if (report.reviewRounds.length) {
264
+ L.push('');
265
+ L.push('RONDAS DE REVISIÓN (re-spawns del mismo revisor en la misma sesión)');
266
+ for (const r of report.reviewRounds.slice(0, 8)) {
267
+ L.push(' ' +
268
+ r.agent.slice(0, 23).padEnd(24) +
269
+ `${String(r.sessions).padStart(3)} sesiones · ${String(r.spawns).padStart(3)} spawns · ` +
270
+ `${(r.spawns / r.sessions).toFixed(2)} rondas/sesión`);
271
+ }
272
+ L.push(' ⚠️ un re-spawn puede ser una fase distinta del workflow, no una re-ronda del mismo veredicto — revisa los prompts antes de leerlo como indecisión.');
273
+ }
274
+ L.push('');
275
+ L.push('REPARTO DEL CONSUMO');
276
+ L.push(` main loop: ${M(t.main).padStart(10)} M tokens ${pct(t.main, t.total)}`);
277
+ L.push(` subagentes: ${M(t.sub).padStart(10)} M tokens ${pct(t.sub, t.total)}`);
278
+ L.push(' ✔ validación cruzada: el % de subagentes debe cuadrar con "Subagents % of usage" del panel Account & Usage de tu cliente. Si no cuadra, el número de ESTA herramienta está mal.');
279
+ L.push(`\n Tokens exactos del \`usage\` de cada transcript — sin estimadores. Solo agregados: nunca contenido de mensajes ni paths de proyectos.` +
280
+ (report.filters.anon
281
+ ? ''
282
+ : ' Para compartir el reporte fuera del equipo: `--anon` anonimiza los repos.'));
283
+ L.push('');
284
+ return L.join('\n');
285
+ }
286
+ /** Entry point for `factory usage`. */
287
+ export function runUsage(flags, root = defaultUsageRoot()) {
288
+ const window = resolveWindow(flags);
289
+ const scan = scanUsage(root, window);
290
+ const report = aggregateUsage(scan, window, {
291
+ repo: flags.repo,
292
+ agent: flags.agent,
293
+ anon: flags.anon,
294
+ });
295
+ if (flags.json) {
296
+ // `--json` contract (same as `doctor --launcher --json` / `status --json`):
297
+ // machine-readable JSON to stdout, NOTHING else, exit 0. Stable field order
298
+ // + deterministic array sorting → diffable between runs.
299
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
300
+ return;
301
+ }
302
+ console.log(renderUsageReport(report));
303
+ }
@@ -3,9 +3,11 @@
3
3
  * Contract: `fx-secrets-vault §8`. The decisions live in `lib/vault-adopt.ts`; this module runs
4
4
  * them against the real services, in the order the contract fixes:
5
5
  *
6
- * Preconditions (both modes) — `core` profile → says so and does nothing; the brain up to date
7
- * (the comparison of `factory status`); no fork in the files the wrapper runs (the census of
8
- * `factory update --verify`).
6
+ * Preconditions (both modes) — the wrapper on disk (`full` and `core` both ship it; missing →
7
+ * `factory update`); the brain up to date (the comparison of `factory status`); no fork in the
8
+ * files the wrapper runs (the census of `factory update --verify`). A `core` repo is accepted:
9
+ * its own `dev`/`build` scripts read as customized and run without the vault until the team
10
+ * prefixes them with the wrapper (Factory#76).
9
11
  *
10
12
  * Plan (no `--apply`) — inventory by NAME and display hash of `.env.local`, Vercel production +
11
13
  * preview and Railway; the state of each key, where the apply would write it, the confirmations
@@ -39,11 +41,10 @@ import { WITH_VAULT_SCRIPT_FILE } from '../lib/constants.js';
39
41
  import { parseEnvBody } from '../lib/env-wizard.js';
40
42
  import { hasLockfile, readLockfile } from '../lib/lockfile.js';
41
43
  import { getConnectionUri, getNeonProjectOrg, isNeonKeyFamily, listNeonApiKeys, listNeonBranches, NEON_DEVELOP_BRANCH, NeonApiKeyListError, } from '../lib/neon-api.js';
42
- import { detectCoreProfile } from '../lib/provision-preflight.js';
43
44
  import { readDestroyTarget, readNeonRevokePending, readVaultDeclined, readVaultLink, addNeonRevokePending, } from '../lib/provision-state.js';
44
45
  import { createRailResolver, RAIL_TIMEKAST } from '../lib/rail.js';
45
46
  import { createRailwayClient, RAILWAY_TOKEN_KEY, railwayFetchTransport, } from '../lib/railway-api.js';
46
- import { buildPlanEntries, classifySensitive, createDisplayHasher, describeWrite, ENV_LOCAL_BACKUP_FILE, ENV_LOCAL_FILE, FACTORY_REPO, FACTORY_SLUG, folderOfTarget, isConnectedEmptyDeploy, isFactoryIdentity, manualVaultProjectSteps, isFactoryMode, isProjectScopedNeonKey, isRailKey, isRailwayProvided, isRailwaySyncTo, listRailwayProjects, MFA_KEY, NEON_API_KEY, planAdoptionLayout, planRailwayImport, proposeRailwaySyncs, RAILWAY_VAULT_ENVS, railwaySyncDifferences, railwaySyncLabel, railwaySyncName, readRailwayInventory, readRailwayVariables, recordedAdoptionVercelId, RESEND_KEY, retiredKeys, retireEnvLocal, sealedStopExplanation, sharedCandidatesOf, SHORTIO_KEY, wrapperDrift, writeAdoptionLink, writeDeclineMark, } from '../lib/vault-adopt.js';
47
+ import { buildPlanEntries, classifySensitive, adoptSender, createDisplayHasher, dangerousSharedTargets, describeWrite, ENV_LOCAL_BACKUP_FILE, ENV_LOCAL_FILE, FACTORY_REPO, FACTORY_SLUG, folderOfTarget, isConnectedEmptyDeploy, isFactoryIdentity, manualVaultProjectSteps, isFactoryMode, isProjectScopedNeonKey, isRailKey, isRailwayProvided, isRailwaySyncTo, listRailwayProjects, MFA_KEY, NEON_API_KEY, planAdoptionLayout, planRailwayImport, proposeRailwaySyncs, RAILWAY_VAULT_ENVS, railwaySyncDifferences, railwaySyncLabel, railwaySyncName, readRailwayInventory, readRailwayVariables, recordedAdoptionVercelId, RESEND_KEY, retiredKeys, retireEnvLocal, sealedStopExplanation, SENDER_KEYS, sharedCandidatesOf, sharedDatabaseKeys, SHORTIO_KEY, vaultWriteConflicts, wrapperDrift, writeAdoptionLink, writeDeclineMark, } from '../lib/vault-adopt.js';
47
48
  import { createVaultClient, formatLocation, VAULT_CI_PATH, VAULT_ENV_DEVELOP, VAULT_ENV_LOCAL, VAULT_ENV_MAIN, VAULT_ROOT_PATH, VAULT_TEST_ISOLATION_KEY, VaultSeamMissingError, } from '../lib/vault-api.js';
48
49
  import { LOCAL_ONLY_KEYS } from '../lib/env-file.js';
49
50
  import { buildEnvPlan, deployHostCandidates, generateAuthSecret, listEnvVarNames, listProjectDomains, locateVercelProject, readEnvVarsWithValues, relatedOriginsFor, } from '../lib/vercel-env.js';
@@ -321,6 +322,12 @@ const UNKNOWN = 'unknown';
321
322
  * reports (`resolveSuccessExitCode`), distinct from 0 so no one — person or agent — reads it as done.
322
323
  */
323
324
  export const VAULT_ADOPT_SEALED_EXIT_CODE = 3;
325
+ /** Factory#73 — why a shared Vercel record blocks (plan and apply say it the same way). */
326
+ const SHARED_RECORDS_REASON = 'Variables de Vercel que son UN solo registro para production y preview, y develop tendría su ' +
327
+ 'propio valor: el sync de preview reescribiría el registro y producción terminaría con el de develop';
328
+ /** Factory#75 — why a shared database key needs a yes. */
329
+ const SHARED_DATABASES_REASON = 'Preview tiene la MISMA base que producción: los previews y el entorno local escribirían en la ' +
330
+ 'base de producción';
324
331
  function realField(value) {
325
332
  return value && value !== UNKNOWN ? value : null;
326
333
  }
@@ -331,6 +338,7 @@ function vercelMaps(vars) {
331
338
  const branchScoped = [];
332
339
  const sensitive = [];
333
340
  const unreadableOther = new Set();
341
+ const sharedTargets = new Set();
334
342
  const note = (key, type, value, target) => {
335
343
  if (value !== null)
336
344
  return;
@@ -340,6 +348,9 @@ function vercelMaps(vars) {
340
348
  unreadableOther.add(key);
341
349
  };
342
350
  for (const env of vars) {
351
+ if (env.target.includes('production') && env.target.includes('preview') && !env.gitBranch) {
352
+ sharedTargets.add(env.key);
353
+ }
343
354
  if (env.target.includes('production')) {
344
355
  production.set(env.key, env.value);
345
356
  note(env.key, env.type, env.value, 'production');
@@ -359,6 +370,7 @@ function vercelMaps(vars) {
359
370
  branchScoped,
360
371
  sensitive,
361
372
  unreadableOther: [...unreadableOther].sort(),
373
+ sharedTargets: [...sharedTargets].sort(),
362
374
  };
363
375
  }
364
376
  /** Why a derivation needs a host, and which one. */
@@ -1011,10 +1023,14 @@ async function prepareAdoption(rootDir, flags, deps) {
1011
1023
  const resendTeamKey = hasResend ? await rail.getOptional(RESEND_KEY) : null;
1012
1024
  const replaceResend = resendTeamKey !== null &&
1013
1025
  [values.production, values.preview, values.local].some((m) => m.get(RESEND_KEY) === resendTeamKey);
1014
- const sender = values.production.get('EMAIL_FROM') ??
1015
- values.preview.get('EMAIL_FROM') ??
1016
- values.local.get('EMAIL_FROM') ??
1017
- PROVISION_EMAIL_FROM;
1026
+ // Factory#74 — the repo's own sender, under any name it uses; the kit's default only when none.
1027
+ const found = adoptSender(values);
1028
+ if (found.sender === null && replaceResend && found.unknown.length > 0) {
1029
+ throw new CLIError(`No encuentro el remitente del repo en ${SENDER_KEYS.join(', ')}, pero ${found.unknown.join(', ')} ` +
1030
+ 'tiene una dirección de correo. No acuño la key de Resend para el dominio del kit a ciegas: ' +
1031
+ 'agrega `EMAIL_FROM` con el remitente real al deploy y repite. No se escribió nada.');
1032
+ }
1033
+ const sender = found.sender ?? PROVISION_EMAIL_FROM;
1018
1034
  const resendDomain = sendingDomainOf(sender);
1019
1035
  const client = deps.vaultClient ?? createVaultClient();
1020
1036
  const githubSecretNames = repo
@@ -1207,6 +1223,8 @@ async function prepareAdoption(rootDir, flags, deps) {
1207
1223
  sealed: [],
1208
1224
  sealedExplanation: null,
1209
1225
  branchScoped: vercel.branchScoped,
1226
+ sharedRecords: dangerousSharedTargets(vercel.sharedTargets, writes),
1227
+ sharedDatabases: sharedDatabaseKeys(writes, neonProjectId !== null),
1210
1228
  confirmations: confirmations.map((c) => c.name),
1211
1229
  github,
1212
1230
  wrapper: {
@@ -1235,6 +1253,7 @@ async function prepareAdoption(rootDir, flags, deps) {
1235
1253
  hosts,
1236
1254
  hostCandidates,
1237
1255
  vaultProjectExists,
1256
+ sharedTargets: vercel.sharedTargets,
1238
1257
  vercelToken,
1239
1258
  teamId,
1240
1259
  resendTeamKey: replaceResend ? resendTeamKey : null,
@@ -1354,6 +1373,14 @@ function printPlan(plan, apply) {
1354
1373
  `${plan.branchScoped.map((v) => `${v.name} (rama ${v.branch})`).join(', ')}. ` +
1355
1374
  'Decide en Vercel (quítales la rama o bórralas) y repite. Bloquean el --apply.');
1356
1375
  }
1376
+ if (plan.sharedRecords.length > 0) {
1377
+ out(`\n🔴 ${SHARED_RECORDS_REASON}: ${plan.sharedRecords.join(', ')}. ` +
1378
+ 'Sepáralas en Vercel (un registro por entorno) y repite. Bloquean el --apply.');
1379
+ }
1380
+ if (plan.sharedDatabases.length > 0) {
1381
+ out(`\n⚠ ${SHARED_DATABASES_REASON}: ${plan.sharedDatabases.join(', ')}. ` +
1382
+ 'El --apply te pide confirmarlo; si no es lo que quieres, dale a preview su propia base en el deploy y repite.');
1383
+ }
1357
1384
  if (plan.railwaySyncs.length > 0) {
1358
1385
  out(`\nRailway${plan.railway ? ` (proyecto \`${plan.railway.project}\`, id ${plan.railway.projectId})` : ''} — ` +
1359
1386
  'syncs propuestos (confirmas cada uno; el que no confirmes no se importa ni se crea): ' +
@@ -1483,6 +1510,32 @@ async function writeLayout(prepared, projectId, deps) {
1483
1510
  }
1484
1511
  return resend;
1485
1512
  }
1513
+ /**
1514
+ * Factory#72 — the writes are `create-if-missing`: a key the vault already holds keeps its value,
1515
+ * and the first sync carries THAT value to the deploy. A vault value that differs from what the
1516
+ * deploy runs (a stale copy, a Vercel ciphertext imported by hand) stops the apply BEFORE writing.
1517
+ */
1518
+ async function assertVaultMatchesPlan(prepared, projectId) {
1519
+ const locations = new Map();
1520
+ for (const write of prepared.writes) {
1521
+ if (write.location.env !== VAULT_ENV_LOCAL) {
1522
+ locations.set(formatLocation(write.location), write.location);
1523
+ }
1524
+ }
1525
+ const existing = new Map();
1526
+ for (const [where, location] of locations) {
1527
+ existing.set(where, await prepared.client.readSecrets(projectId, location, { expandReferences: true }));
1528
+ }
1529
+ const conflicts = vaultWriteConflicts(prepared.writes, existing, createDisplayHasher());
1530
+ if (conflicts.length === 0)
1531
+ return;
1532
+ throw new CLIError('La bóveda ya tiene OTRO valor en claves que el deploy usa, y el primer sync lo llevaría al deploy: ' +
1533
+ conflicts
1534
+ .map((c) => `${c.location} ${c.key} (bóveda ${c.vault} ≠ deploy ${c.plan})`)
1535
+ .join(', ') +
1536
+ '. Lo que corre hoy es el deploy: corrige o borra esas claves en la bóveda y repite. ' +
1537
+ 'No escribí ningún secreto ni creé ningún sync.');
1538
+ }
1486
1539
  /**
1487
1540
  * Right before creating the syncs: the vault folder must hold every key its Vercel target holds
1488
1541
  * (import first — the first sync deletes nothing), except the retirements already confirmed; and
@@ -1753,6 +1806,23 @@ async function applyAdoption(prepared, deps) {
1753
1806
  `${plan.railwayConflicts.join(', ')}. Una carpeta tiene un solo valor por clave y no elijo uno a ` +
1754
1807
  'ciegas: iguálalos en Railway, o no confirmes uno de esos syncs. No se escribió nada.');
1755
1808
  }
1809
+ // Factory#73 — recomputed on the final writes: a materialized value can give develop its own.
1810
+ const sharedRecords = dangerousSharedTargets(prepared.sharedTargets, prepared.writes);
1811
+ if (sharedRecords.length > 0) {
1812
+ throw new CLIError(`${SHARED_RECORDS_REASON}: ${sharedRecords.join(', ')}. Sepáralas en Vercel (un registro por ` +
1813
+ 'entorno) y repite. No se escribió nada.');
1814
+ }
1815
+ // Factory#75 — any provider, not only Neon: a yes before previews write to production's base.
1816
+ const sharedDatabases = sharedDatabaseKeys(prepared.writes, prepared.neonProjectId !== null);
1817
+ if (sharedDatabases.length > 0) {
1818
+ prepared.questions = [
1819
+ ...prepared.questions,
1820
+ {
1821
+ name: `misma base: ${sharedDatabases.join(', ')}`,
1822
+ question: `${SHARED_DATABASES_REASON} (${sharedDatabases.join(', ')}). ¿Confirmas?`,
1823
+ },
1824
+ ];
1825
+ }
1756
1826
  await confirmAll(prepared, deps);
1757
1827
  // Resolved BEFORE writing anything: without it no Railway sync can exist, so nothing is imported.
1758
1828
  const railwayConnection = railwaySelected.length > 0 ? await requireRailwayConnection(client) : null;
@@ -1768,6 +1838,8 @@ async function applyAdoption(prepared, deps) {
1768
1838
  reportDeleteProtection(layout.project.slug, layout.deleteProtection);
1769
1839
  for (const member of layout.members.added)
1770
1840
  console.log(` + bóveda: miembro ${member}`);
1841
+ // Factory#72 — what the vault already holds must be what the deploy runs, before writing.
1842
+ await assertVaultMatchesPlan(prepared, projectId);
1771
1843
  // (2) the writes — import first.
1772
1844
  const resend = await writeLayout(prepared, projectId, deps);
1773
1845
  // (2b) ADOPT-009 — the project's Neon key, minted key-only BEFORE any sync exists (so the GitHub
@@ -2129,15 +2201,6 @@ function declineVault(rootDir, deps) {
2129
2201
  export async function runVaultAdopt(flags, deps = {}) {
2130
2202
  assertAdoptSeams(deps);
2131
2203
  const rootDir = deps.rootDir ?? process.cwd();
2132
- const core = detectCoreProfile(rootDir);
2133
- if (core !== null) {
2134
- const why = core === 'lockfile'
2135
- ? 'el lockfile declara el perfil `core`'
2136
- : `no hay lockfile y falta \`${WITH_VAULT_SCRIPT_FILE}\``;
2137
- console.log(`ℹ️ La bóveda no se ofrece en perfil \`core\` (${why}): ese perfil no trae el wrapper que la inyecta ` +
2138
- 'a los scripts. No hice nada.');
2139
- return { status: 'core' };
2140
- }
2141
2204
  if (flags.decline)
2142
2205
  return declineVault(rootDir, deps);
2143
2206
  const prepared = await prepareAdoption(rootDir, flags, deps);
package/dist/index.js CHANGED
@@ -19,8 +19,10 @@ import { envWizardFlow } from './tui/env-wizard-flow.js';
19
19
  import { parseDoctorFlags, runDoctor, runLauncherDoctor } from './commands/doctor.js';
20
20
  import { parseNewFlags, runNew } from './commands/new.js';
21
21
  import { parseProvisionFlags, runProvision } from './commands/provision.js';
22
+ import { runPruneCommand } from './commands/prune.js';
22
23
  import { parsePublishArgs, runPublish } from './commands/publish.js';
23
24
  import { parseStatusFlags, runStatus } from './commands/status.js';
25
+ import { parseUsageFlags, runUsage } from './commands/usage.js';
24
26
  import { runTicketCommand } from './commands/ticket.js';
25
27
  import { parseUnpublishTargets, runUnpublish } from './commands/unpublish.js';
26
28
  import { maybeOfferAdopt, parseUpdateFlags, runUpdate } from './commands/update.js';
@@ -43,6 +45,8 @@ Comandos:
43
45
  status Reporta la versión instalada vs. la última disponible
44
46
  doctor Detecta huérfanos, conflictos y rules sin importar + aviso de seguridad
45
47
  (--launcher: verifica el entorno — Node ≥ 22, gh, gh auth, membresía org)
48
+ usage Mide el consumo real de Claude Code de TU máquina (~/.claude/projects/)
49
+ — corre desde cualquier carpeta, no requiere repo
46
50
  publish <qué> Publica el entregable (proposal|mockup|both) a proposals.timekast.mx
47
51
  unpublish [qué] Baja del hub un entregable publicado (proposal|mockup|both; default both)
48
52
  provision Aprovisiona la infra de un derivado (Neon, GitHub, DNS, backlog y su destino de
@@ -52,6 +56,13 @@ Comandos:
52
56
  env <sub> push: sube product vars a Vercel (day-2) · wizard: configura las variables
53
57
  (en la bóveda si el repo la tiene; sin bóveda, en .env.local)
54
58
  invite-admin Crea una invitación de super_admin para un derivado (sin password en texto plano)
59
+ prune <sub> Comparte el repo con colaboradores externos sin la metodología (lo guía /prune):
60
+ prepare: aplica el tier (.gitignore + .timekast/prune.json) y deja de trackear
61
+ lo restringido · cutover: corte a un repo cuya historia nunca tuvo la
62
+ metodología (sin --confirm sólo corre el preflight) · invite <usuario>:
63
+ colaborador externo con lectura · finish: archiva el -legacy y audita ·
64
+ audit: revisa que la historia del repo no tenga rutas restringidas ·
65
+ check-pr <n>: rechaza un PR de colaborador que toque algo fuera del producto
55
66
  vault <sub> adopt: pasa a la bóveda un repo que nació antes de ella (sin --apply sólo
56
67
  enseña el plan, por nombre y huella — nunca valores; con varios proyectos
57
68
  de Vercel, --vercel-project <id> elige el principal) · sync railway: crea
@@ -157,6 +168,22 @@ Sobre \`status\` y \`doctor\`:
157
168
  gh auth, membresía del org) — termina con código 1 si falla algún check, para
158
169
  que un lanzador GUI / CI / el TUI puedan ramificar sobre el resultado.
159
170
 
171
+ Sobre \`usage\`:
172
+ Mide el consumo REAL por transcripts (\`usage\` de cada request — sin estimadores),
173
+ barriendo TODOS los directorios de \`~/.claude/projects/\`. Local-only: nada sale
174
+ de tu máquina. Cortes: día × superficie (main loop vs subagentes), por modelo,
175
+ por repo, por agente, tokens por unidad entregada y rondas de revisión. Flags:
176
+ --month=<mes|YYYY-MM> Atajo mensual ("julio" resuelve el más reciente; alias --mes=;
177
+ default: mes en curso)
178
+ --since=YYYY-MM-DD Inicio de ventana (excluyente con --month)
179
+ --until=YYYY-MM-DD Fin de ventana inclusivo (default: hoy)
180
+ --repo=<subcadena> Filtra por repo (match sobre nombre o slug)
181
+ --agent=<tipo> Filtra por tipo de agente (p.ej. imp-issue-executor)
182
+ --anon Anonimiza los repos (repo-01, repo-02…) para reportes compartibles
183
+ --json Salida machine-readable estable (diffeable entre corridas)
184
+ Privacidad: solo agregados — fechas, conteos, tokens, nombres de modelo/agente.
185
+ Nunca contenido de mensajes, paths de archivos del cliente ni código.
186
+
160
187
  Sobre \`env\`:
161
188
  \`env push\` sube las product vars de \`.env.local\` a Vercel (production+preview),
162
189
  sin tocar las provision-owned. En un repo con bóveda (bloque \`vault\`) no escribe:
@@ -328,6 +355,13 @@ async function main(argv) {
328
355
  }
329
356
  return;
330
357
  }
358
+ case 'usage': {
359
+ // 🔴 No repo preflight — ever. The data source is `~/.claude/projects/`
360
+ // (the developer's home), so this command runs from ANY folder: copying
361
+ // the update/status/doctor preflight here breaks the whole use case.
362
+ runUsage(parseUsageFlags(rest));
363
+ return;
364
+ }
331
365
  case 'provision': {
332
366
  await runProvision(parseProvisionFlags(rest));
333
367
  return;
@@ -410,6 +444,12 @@ async function main(argv) {
410
444
  await runVaultCommand(rest);
411
445
  return;
412
446
  }
447
+ case 'prune': {
448
+ // `factory prune <sub>` — share a repo with outside collaborators without the
449
+ // methodology. Dispatch lives in the command module (testable branches).
450
+ await runPruneCommand(rest);
451
+ return;
452
+ }
413
453
  case 'invite-admin': {
414
454
  // Mint a single-use super_admin invite against the target branch's DB.
415
455
  // Spawns the kit script `scripts/tools/invite-admin.ts` with the resolved
@@ -183,7 +183,7 @@ function vaultFailureReason(error, link) {
183
183
  }
184
184
  if (error instanceof RailNotInstalledError) {
185
185
  return ('El CLI de la bóveda (`infisical`) no está instalado; instálalo con ' +
186
- `\`${INFISICAL_INSTALL_HINT}\` y entra con \`${loginCommand()}\`.`);
186
+ `${INFISICAL_INSTALL_HINT} y entra con \`${loginCommand()}\`.`);
187
187
  }
188
188
  if (error instanceof VaultNoAccessError) {
189
189
  return (`Tu sesión de la bóveda no tiene acceso al proyecto \`${link.projectId}\` de este repo ` +
@@ -61,8 +61,14 @@ function dropGitignored(rootDir, paths) {
61
61
  return paths.filter((p) => !ignored.has(p));
62
62
  }
63
63
  /**
64
- * Repo-relative POSIX paths of NON-gitignored files under `.claude/`.
64
+ * The project's own Claude Code hooks (`fx-extension-points`): dev-owned, never shipped, so
65
+ * never an orphan nor an ambiguous kit file.
66
+ */
67
+ export const PROJECT_HOOKS_DIR = '.claude/hooks/project/';
68
+ /**
69
+ * Repo-relative POSIX paths of NON-gitignored files under `.claude/`, minus the project's own
70
+ * hooks ({@link PROJECT_HOOKS_DIR}).
65
71
  */
66
72
  export function collectClaudePaths(rootDir) {
67
- return dropGitignored(rootDir, walkClaude(rootDir));
73
+ return dropGitignored(rootDir, walkClaude(rootDir)).filter((p) => !p.startsWith(PROJECT_HOOKS_DIR));
68
74
  }