@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.
- package/dist/commands/add.js +29 -8
- package/dist/commands/doctor.js +32 -3
- package/dist/commands/env.js +16 -4
- package/dist/commands/provision.js +85 -27
- package/dist/commands/prune.js +60 -10
- package/dist/commands/update.js +104 -85
- package/dist/commands/usage.js +303 -0
- package/dist/commands/vault.js +81 -18
- package/dist/index.js +26 -0
- 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/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/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 +21 -0
- package/package.json +1 -1
package/dist/commands/update.js
CHANGED
|
@@ -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)
|
|
27
|
-
* the
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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, [
|
|
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,
|
|
526
|
-
const pkgMutations = maintainDerivedPkg(rootDir,
|
|
527
|
-
|
|
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
|
|
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, [
|
|
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
|
|
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
|
-
//
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1098
|
-
//
|
|
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
|
|
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
|
|
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
|
+
}
|