@timekast/factory 2.4.0 → 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.
@@ -23,8 +23,10 @@
23
23
  * Auto-register (no lockfile, pre-CLI repo, design §6.3): path-match — repo
24
24
  * paths matching the manifest are kit-owned (overwrite); `.claude/` paths absent
25
25
  * from the manifest are logged "ambiguous" and NOT deleted on the first sync;
26
- * an existing `CLAUDE.md` is preserved (write-if-absent). The dev is warned + sees
27
- * the auto-detected profile before proceeding.
26
+ * an existing `CLAUDE.md` is preserved (write-if-absent), and so is any other kit
27
+ * file outside the brain the repo already has with its own content (the kit's copy
28
+ * goes to `<path>.kit-example`). The dev is warned + sees the auto-detected profile
29
+ * before proceeding.
28
30
  *
29
31
  * `--verify`: compares disk vs lockfile and reports drift WITHOUT modifying
30
32
  * anything. `--resume`: re-applies a persisted mid-flight state without
@@ -44,8 +46,11 @@ import { collectClaudePaths } from '../lib/claude-paths.js';
44
46
  import { CLIError } from '../lib/cli-error.js';
45
47
  import { CLAUDE_MD_FILE, GITATTRIBUTES_FILE, GITIGNORE_FILE, LOCKFILE_FILE, PRETTIERIGNORE_FILE, PROFILES, TIMEKAST_DIR, } from '../lib/constants.js';
46
48
  import { decideCommit } from '../lib/commit-decision.js';
47
- import { extractManagedBlock, syncManagedBlock } from '../lib/gitattributes.js';
49
+ import { syncDerivedDotfiles } from '../lib/derived-dotfiles.js';
50
+ import { partitionInitialWrites, refreshKitExamples, reportKeptRepoFiles, writeKitExamples, } from '../lib/initial-writes.js';
51
+ import { offerKitDeps, readKitDeps } from '../lib/kit-deps.js';
48
52
  import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
53
+ import { effectiveSettingsHash, KIT_SETTINGS_PATH, LEGACY_KIT_SETTINGS_KEYS, mergeProjectSettingsKeys, } from '../lib/claude-settings.js';
49
54
  import { detectProfile, maintainKitScripts, setAgentKitVersion } from '../lib/package-json.js';
50
55
  import { runPreflight } from '../lib/preflight.js';
51
56
  import { printProvisionPointer } from '../lib/provision-pointer.js';
@@ -100,16 +105,52 @@ async function acquireFromBeta(profile) {
100
105
  * Hash the disk content (normalized) of every path in `paths` that exists.
101
106
  * A missing file is simply absent from the returned map.
102
107
  */
103
- function hashDiskPaths(rootDir, paths) {
108
+ function hashDiskPaths(rootDir, paths, settingsEntries = []) {
104
109
  const map = new Map();
105
110
  for (const rel of paths) {
106
111
  const abs = path.join(rootDir, rel);
107
112
  if (existsSync(abs) && statSync(abs).isFile()) {
108
- map.set(rel, normalizeThenHash(readFileSync(abs, 'utf8')));
113
+ const raw = readFileSync(abs, 'utf8');
114
+ // settings.json: only the kit's keys count, by content (the project's are its own).
115
+ map.set(rel, rel === KIT_SETTINGS_PATH
116
+ ? effectiveSettingsHash(raw, settingsEntries)
117
+ : normalizeThenHash(raw));
109
118
  }
110
119
  }
111
120
  return map;
112
121
  }
122
+ /** The `settings.json` the project had before this apply: the run's backup, else the disk. */
123
+ function readPreviousSettings(rootDir, backupDir) {
124
+ for (const abs of [
125
+ path.join(backupDir, KIT_SETTINGS_PATH),
126
+ path.join(rootDir, KIT_SETTINGS_PATH),
127
+ ]) {
128
+ if (existsSync(abs) && statSync(abs).isFile())
129
+ return readFileSync(abs, 'utf8');
130
+ }
131
+ return null;
132
+ }
133
+ /**
134
+ * After the kit's `settings.json` was written, put the project's own keys back (every top-level
135
+ * key the kit does not declare) and say so. No-op when the kit's file was not written.
136
+ */
137
+ function restoreProjectSettingsKeys(rootDir, written, previous, previousKitKeys) {
138
+ if (previous === null || !written.includes(KIT_SETTINGS_PATH))
139
+ return;
140
+ const abs = path.join(rootDir, KIT_SETTINGS_PATH);
141
+ const merged = mergeProjectSettingsKeys(readFileSync(abs, 'utf8'), previous, previousKitKeys);
142
+ if (merged.content !== null) {
143
+ writeFileSync(abs, merged.content, 'utf8');
144
+ console.log(` ✔ ${KIT_SETTINGS_PATH}: conservé tus llaves (${merged.kept.join(', ')})`);
145
+ }
146
+ for (const key of merged.replaced) {
147
+ console.warn(` ⚠ ${KIT_SETTINGS_PATH}: \`${key}\` ahora la declara el kit — tu valor se reemplazó por el suyo.`);
148
+ }
149
+ }
150
+ /** The kit's keys recorded for `settings.json` in a lockfile (the legacy set when absent). */
151
+ function kitSettingsKeysOf(lock) {
152
+ return lock?.files.find((f) => f.path === KIT_SETTINGS_PATH)?.keys ?? LEGACY_KIT_SETTINGS_KEYS;
153
+ }
113
154
  /** Unchanged lines kept around each change in the `[3] Ver diff` view. */
114
155
  const DIFF_CONTEXT_LINES = 3;
115
156
  /**
@@ -301,7 +342,7 @@ function runVerify(rootDir) {
301
342
  throw new CLIError('No hay lockfile que verificar. Corre `factory update` primero.');
302
343
  }
303
344
  const lock = readLockfile(rootDir);
304
- const disk = hashDiskPaths(rootDir, lock.files.map((f) => f.path));
345
+ const disk = hashDiskPaths(rootDir, lock.files.map((f) => f.path), [lock.files.find((f) => f.path === KIT_SETTINGS_PATH)]);
305
346
  const missing = [];
306
347
  const drifted = [];
307
348
  for (const entry of lock.files) {
@@ -465,20 +506,32 @@ async function runResume(rootDir, flags) {
465
506
  const claudeMd = priorLock
466
507
  ? assessClaudeMd(rootDir, priorLock)
467
508
  : undefined;
509
+ const previousSettings = readPreviousSettings(rootDir, state.backupDir);
468
510
  applyPlan(rootDir, state.stagedDir, state.plan, state.backupDir);
511
+ restoreProjectSettingsKeys(rootDir, state.plan.writes, previousSettings, kitSettingsKeysOf(priorLock));
469
512
  writeLockfile(rootDir, newLock);
470
513
  clearUpdateState(rootDir);
471
514
  const claudeMdInserted = claudeMd ? applyClaudeMdRetrofit(rootDir, claudeMd) : false;
515
+ const kitExamples = refreshKitExamples(rootDir, state.stagedDir, newLock.files.map((f) => f.path));
472
516
  // Maintain dotfiles BEFORE removing the staged dir (the .gitattributes source).
473
- const pkgMutations = maintainDerivedDotfiles(rootDir, state.stagedDir, newLock.version);
517
+ const pkgMutations = maintainDerivedDotfiles(rootDir, state.stagedDir, newLock);
474
518
  const prune = await planPruneMaintenance(rootDir, state.stagedDir, newLock.files.map((f) => f.path));
475
- await maybeCommitBrain(rootDir, newLock.version, [...state.plan.writes, ...state.plan.deletes, ...(claudeMdInserted ? [CLAUDE_MD_FILE] : [])], flags, prune);
519
+ await maybeCommitBrain(rootDir, newLock.version, [
520
+ ...state.plan.writes,
521
+ ...state.plan.deletes,
522
+ ...kitExamples,
523
+ ...(claudeMdInserted ? [CLAUDE_MD_FILE] : []),
524
+ ], flags, prune);
525
+ const kitDeps = readKitDeps(state.stagedDir);
476
526
  rmSync(state.stagedDir, { recursive: true, force: true });
477
527
  // A resumed update maintains package.json exactly like a normal one, so it owes the dev the
478
528
  // same account of what it moved there (`BND-004`).
479
529
  reportPkgMutations(pkgMutations);
480
530
  if (claudeMd)
481
531
  reportClaudeMd(claudeMd, 'applied', claudeMdInserted);
532
+ // AFTER the brain commit: an install changes package.json and the package lockfile, which
533
+ // stay out of that commit (Factory#78).
534
+ await offerKitDeps(rootDir, kitDeps, { assumeYes: flags.yes });
482
535
  console.log('✔ `update` retomado y completado.');
483
536
  // A resumed update that succeeds completed the setup → show the end-pointer
484
537
  // (PVSKILL-002 §8 edge: `--resume` SÍ lo muestra). Suppressed only in headless
@@ -522,10 +575,9 @@ function warnOnScriptConflict(action, _pkgPath) {
522
575
  * the managed blocks. Runs BEFORE any brain commit, so the blocks land before a
523
576
  * pre-commit hook could reformat the freshly-installed kit files.
524
577
  */
525
- function maintainDerivedDotfiles(rootDir, stagedDir, agentKitVersion) {
526
- const pkgMutations = maintainDerivedPkg(rootDir, agentKitVersion);
527
- syncDerivedGitattributes(rootDir, stagedDir);
528
- syncDerivedPrettierignore(rootDir, stagedDir);
578
+ function maintainDerivedDotfiles(rootDir, stagedDir, manifest) {
579
+ const pkgMutations = maintainDerivedPkg(rootDir, manifest.version);
580
+ syncDerivedDotfiles(rootDir, stagedDir, manifest);
529
581
  return pkgMutations;
530
582
  }
531
583
  /** A run that mutated nothing (no package.json, or the maintenance failed and warned). */
@@ -574,63 +626,6 @@ function maintainDerivedPkg(rootDir, agentKitVersion) {
574
626
  return emptyPkgMutations();
575
627
  }
576
628
  }
577
- /**
578
- * Sync the Factory's managed `.gitattributes` block (the EOL/binary rules that
579
- * keep the kit's committed binaries from being corrupted by CRLF→LF normalization)
580
- * into the derived repo, preserving the dev's own rules. The canonical block is
581
- * READ from the staged tarball (single SSOT), so the rules are never duplicated in
582
- * the CLI. Runs even without package.json (a non-Node derivative needs the rules
583
- * too). Best-effort like the package.json mirror: a tarball that predates the
584
- * managed block (no markers → no block) is a silent no-op, and a write failure
585
- * never fails an already-applied update.
586
- */
587
- function syncDerivedGitattributes(rootDir, stagedDir) {
588
- try {
589
- const srcPath = path.join(stagedDir, GITATTRIBUTES_FILE);
590
- if (!existsSync(srcPath))
591
- return;
592
- const block = extractManagedBlock(readFileSync(srcPath, 'utf8'));
593
- if (!block)
594
- return;
595
- const { action } = syncManagedBlock(rootDir, block);
596
- if (action === 'unchanged')
597
- return;
598
- console.log(action === 'created'
599
- ? '✔ `.gitattributes` creado con las reglas de normalización del kit.'
600
- : '✔ `.gitattributes`: bloque de reglas del kit sincronizado.');
601
- }
602
- catch {
603
- console.warn('Aviso: no se pudo sincronizar `.gitattributes`; el cerebro se instaló igual.');
604
- }
605
- }
606
- /**
607
- * Sync the Factory's managed `.prettierignore` block (keeps the derived repo's
608
- * formatter away from `.claude/**` — a pre-commit `prettier --write` over the kit
609
- * files drifts disk from the lockfile hashes and turns every later update into
610
- * false conflicts) into the derived repo, preserving the dev's own ignore rules.
611
- * Same contract as `syncDerivedGitattributes`: canonical block READ from the
612
- * staged tarball (single SSOT), best-effort, tarball without the file (pre-block
613
- * releases) → silent no-op, a write failure never fails an applied update.
614
- */
615
- function syncDerivedPrettierignore(rootDir, stagedDir) {
616
- try {
617
- const srcPath = path.join(stagedDir, PRETTIERIGNORE_FILE);
618
- if (!existsSync(srcPath))
619
- return;
620
- const block = extractManagedBlock(readFileSync(srcPath, 'utf8'));
621
- if (!block)
622
- return;
623
- const { action } = syncManagedBlock(rootDir, block, PRETTIERIGNORE_FILE);
624
- if (action === 'unchanged')
625
- return;
626
- console.log(action === 'created'
627
- ? '✔ `.prettierignore` creado con el ignore del cerebro (`.claude/`) — evita falsos conflictos por formato en futuros updates.'
628
- : '✔ `.prettierignore`: bloque del kit sincronizado (`.claude/` fuera del formatter).');
629
- }
630
- catch {
631
- console.warn('Aviso: no se pudo sincronizar `.prettierignore`; el cerebro se instaló igual.');
632
- }
633
- }
634
629
  /**
635
630
  * The kit's own test folder. It stopped shipping to derivatives (`BND-003`): those tests
636
631
  * validate KIT tooling, and inside a derivative they ran against a `src/` that may have
@@ -928,10 +923,11 @@ export async function runUpdate(flags, deps = {}) {
928
923
  if (flags.dryRun) {
929
924
  console.log('\n🔍 Vista previa: este repo aún no tiene lockfile. El primer `factory update` ' +
930
925
  'registra el cerebro; en el primer sync no hay conflictos posibles.');
926
+ await offerKitDeps(rootDir, readKitDeps(stagedDir), { dryRun: true });
931
927
  process.exitCode = 0;
932
928
  return;
933
929
  }
934
- await applyLegacy(rootDir, stagedDir, manifest);
930
+ await applyLegacy(rootDir, stagedDir, manifest, flags.yes);
935
931
  // Legacy auto-register succeeded → end-pointer (PVSKILL-002).
936
932
  printProvisionPointer({ assumeYes: flags.yes });
937
933
  return;
@@ -942,13 +938,17 @@ export async function runUpdate(flags, deps = {}) {
942
938
  ...oldLock.files.map((f) => f.path),
943
939
  ...manifest.files.map((f) => f.path),
944
940
  ]);
945
- const disk = hashDiskPaths(rootDir, trackedPaths);
941
+ const disk = hashDiskPaths(rootDir, trackedPaths, [
942
+ oldLock.files.find((f) => f.path === KIT_SETTINGS_PATH),
943
+ manifest.files.find((f) => f.path === KIT_SETTINGS_PATH),
944
+ ]);
946
945
  const diff = diffLockfiles(oldLock, manifest, disk);
947
946
  // --dry-run: preview + STOP. No prompts, no apply, no lockfile, no commit.
948
947
  // Exit 2 when conflicts exist so the GUI branches on it (0 otherwise). `return`
949
948
  // runs the finally → tmpRoot is cleaned (no update-state was written).
950
949
  if (flags.dryRun) {
951
950
  reportDryRun(diff, manifest);
951
+ await offerKitDeps(rootDir, readKitDeps(stagedDir), { dryRun: true });
952
952
  process.exitCode = diff.conflicts.length > 0 ? DRY_RUN_CONFLICTS_EXIT : 0;
953
953
  return;
954
954
  }
@@ -978,7 +978,9 @@ export async function runUpdate(flags, deps = {}) {
978
978
  // (first entry from a stable lockfile, where `priorLock.beta` is absent).
979
979
  const stateManifest = flags.beta ? { ...manifest, beta: true } : manifest;
980
980
  writeUpdateState(rootDir, { stagedDir, backupDir, plan, newManifest: stateManifest });
981
+ const previousSettings = readPreviousSettings(rootDir, backupDir);
981
982
  applyPlan(rootDir, stagedDir, plan, backupDir);
983
+ restoreProjectSettingsKeys(rootDir, plan.writes, previousSettings, kitSettingsKeysOf(oldLock));
982
984
  // Lockfile written ONLY after a clean apply. The birth stamp is preserved
983
985
  // (agentKitVersion advances; factoryVersion stays frozen). With `--beta`,
984
986
  // `stampBeta` composes `stampBirth` and adds `beta:true`; a stable/plain
@@ -989,14 +991,23 @@ export async function runUpdate(flags, deps = {}) {
989
991
  // same commit as the rest of the update (and `CLAUDE.md` joins the staged paths —
990
992
  // in case B it is in `ignoreLocal`, so the plan never names it).
991
993
  const claudeMdInserted = applyClaudeMdRetrofit(rootDir, claudeMd);
992
- const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
994
+ const kitExamples = refreshKitExamples(rootDir, stagedDir, manifest.files.map((f) => f.path));
995
+ const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest);
993
996
  const prune = await planPruneMaintenance(rootDir, stagedDir, manifest.files.map((f) => f.path));
994
- await maybeCommitBrain(rootDir, manifest.version, [...plan.writes, ...plan.deletes, ...(claudeMdInserted ? [CLAUDE_MD_FILE] : [])], flags, prune);
997
+ await maybeCommitBrain(rootDir, manifest.version, [
998
+ ...plan.writes,
999
+ ...plan.deletes,
1000
+ ...kitExamples,
1001
+ ...(claudeMdInserted ? [CLAUDE_MD_FILE] : []),
1002
+ ], flags, prune);
995
1003
  // `theirsConflicts` IS the overwritten-local list: same source whether the dev answered
996
1004
  // the prompt, passed `--theirs-all`, or ran headless — there is no second path. The
997
1005
  // package.json mutations ride the SAME closing summary, not three loose notices.
998
1006
  reportSummary(diff, manifest, theirsConflicts, pkgMutations);
999
1007
  reportClaudeMd(claudeMd, 'applied', claudeMdInserted);
1008
+ // AFTER the brain commit: an install changes package.json and the package lockfile, which
1009
+ // stay out of that commit (Factory#78).
1010
+ await offerKitDeps(rootDir, readKitDeps(stagedDir), { assumeYes: flags.yes });
1000
1011
  // 🔴 The retrofit reminder, on EVERY applied update and regardless of how conflicts
1001
1012
  // were resolved — `--theirs-all`, `--mine-all`, per-file answers or none at all. A
1002
1013
  // retrofit is not a conflict: it is work this command never attempted, living outside
@@ -1074,7 +1085,8 @@ async function confirmLegacy(profile, assumeYes) {
1074
1085
  name: 'proceed',
1075
1086
  message: `Este repo no tiene lockfile. Se hará un auto-registro por paths (perfil \`${profile}\`).\n` +
1076
1087
  `${detected}\nLos archivos del cerebro que coincidan se sobrescribirán con la última ` +
1077
- 'versión; tu `CLAUDE.md` se conserva si ya existe. ¿Continuar?',
1088
+ 'versión; tu `CLAUDE.md` se conserva si ya existe, y también tus archivos fuera de ' +
1089
+ '`.claude/` y `scripts/tools/` (la versión del kit queda al lado como `.kit-example`). ¿Continuar?',
1078
1090
  initial: false,
1079
1091
  });
1080
1092
  if (!proceed) {
@@ -1087,26 +1099,30 @@ async function confirmLegacy(profile, assumeYes) {
1087
1099
  * NOT deleted on the first sync (design §6.3). Builds the lockfile from the
1088
1100
  * normalized hashes of the installed files.
1089
1101
  */
1090
- async function applyLegacy(rootDir, stagedDir, manifest) {
1102
+ async function applyLegacy(rootDir, stagedDir, manifest, assumeYes) {
1091
1103
  const repoPaths = new Set(collectClaudePaths(rootDir));
1092
1104
  const plan = planAutoRegister(manifest, repoPaths);
1093
- // Everything in the manifest is written (kit-owned overwrite + adds). No
1094
- // deletes on the first sync. CLAUDE.md is dev-owned (rama B / write-if-absent):
1095
- // if it already exists on disk it is preserved (filtered out of writes); only
1096
- // written when absent. The lockfile still records the manifest's CLAUDE.md hash,
1097
- // so a preserved dev CLAUDE.md reads as "locally edited" on future updates →
1098
- // always ignoreLocal (rama A), never silently clobbered.
1105
+ // The brain is written whole (kit-owned overwrite + adds). No deletes on the
1106
+ // first sync. CLAUDE.md is dev-owned (rama B / write-if-absent): if it already
1107
+ // exists on disk it is preserved (filtered out of writes); only written when
1108
+ // absent. The lockfile still records the manifest's CLAUDE.md hash, so a
1109
+ // preserved dev CLAUDE.md reads as "locally edited" on future updates → always
1110
+ // ignoreLocal (rama A), never silently clobbered. The other kit files outside
1111
+ // the brain get the same treatment as in `add` (#76): one the repo already has
1112
+ // with its own content is kept, the kit's copy goes to `<path>.kit-example`, and
1113
+ // the lockfile records the kit hash (ignoreLocal until the kit changes it).
1099
1114
  const claudeMdExists = existsSync(path.join(rootDir, CLAUDE_MD_FILE));
1100
- const writes = manifest.files
1101
- .map((f) => f.path)
1102
- .filter((p) => !(p === CLAUDE_MD_FILE && claudeMdExists));
1115
+ const { writes, kept } = partitionInitialWrites(rootDir, manifest);
1103
1116
  const backupDir = defaultBackupDir(path.dirname(stagedDir));
1117
+ const previousSettings = readPreviousSettings(rootDir, backupDir);
1104
1118
  applyPlan(rootDir, stagedDir, { writes, deletes: [] }, backupDir);
1119
+ restoreProjectSettingsKeys(rootDir, writes, previousSettings, LEGACY_KIT_SETTINGS_KEYS);
1120
+ writeKitExamples(rootDir, stagedDir, kept);
1105
1121
  // The lockfile = the manifest (its hashes are already the normalized hashes of
1106
1122
  // the just-installed files) PLUS the birth stamp. A legacy auto-register is the
1107
1123
  // first sync this repo records, so the birth seal = the manifest's version.
1108
1124
  writeLockfile(rootDir, { ...manifest, factoryVersion: manifest.version });
1109
- const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
1125
+ const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest);
1110
1126
  // No brain commit on this path; the block is still kept in step (the index is left to
1111
1127
  // `prune prepare`, which the pending notice names).
1112
1128
  reportPendingPrune(await planPruneMaintenance(rootDir, stagedDir, manifest.files.map((f) => f.path)));
@@ -1115,6 +1131,7 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
1115
1131
  console.log('\nConservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
1116
1132
  '(`@.claude/rules/*`); corre `factory doctor` para detectar rules sin importar.');
1117
1133
  }
1134
+ reportKeptRepoFiles(kept);
1118
1135
  if (plan.ambiguous.length > 0) {
1119
1136
  console.log('\nArchivos en `.claude/` que no están en el manifest (revisar manualmente):');
1120
1137
  for (const p of plan.ambiguous)
@@ -1122,4 +1139,6 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
1122
1139
  }
1123
1140
  console.log(`\n✔ Auto-registro completo (v${manifest.version}, perfil ${manifest.profile}): ` +
1124
1141
  `${plan.kitOwned.length} kit-owned, ${plan.toAdd.length} agregados, ${plan.ambiguous.length} ambiguos.`);
1142
+ // No brain commit on this path, so nothing to keep the install out of (Factory#78).
1143
+ await offerKitDeps(rootDir, readKitDeps(stagedDir), { assumeYes });
1125
1144
  }
@@ -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
+ }