@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.
@@ -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
@@ -22,6 +22,7 @@ import { parseProvisionFlags, runProvision } from './commands/provision.js';
22
22
  import { runPruneCommand } from './commands/prune.js';
23
23
  import { parsePublishArgs, runPublish } from './commands/publish.js';
24
24
  import { parseStatusFlags, runStatus } from './commands/status.js';
25
+ import { parseUsageFlags, runUsage } from './commands/usage.js';
25
26
  import { runTicketCommand } from './commands/ticket.js';
26
27
  import { parseUnpublishTargets, runUnpublish } from './commands/unpublish.js';
27
28
  import { maybeOfferAdopt, parseUpdateFlags, runUpdate } from './commands/update.js';
@@ -44,6 +45,8 @@ Comandos:
44
45
  status Reporta la versión instalada vs. la última disponible
45
46
  doctor Detecta huérfanos, conflictos y rules sin importar + aviso de seguridad
46
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
47
50
  publish <qué> Publica el entregable (proposal|mockup|both) a proposals.timekast.mx
48
51
  unpublish [qué] Baja del hub un entregable publicado (proposal|mockup|both; default both)
49
52
  provision Aprovisiona la infra de un derivado (Neon, GitHub, DNS, backlog y su destino de
@@ -165,6 +168,22 @@ Sobre \`status\` y \`doctor\`:
165
168
  gh auth, membresía del org) — termina con código 1 si falla algún check, para
166
169
  que un lanzador GUI / CI / el TUI puedan ramificar sobre el resultado.
167
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
+
168
187
  Sobre \`env\`:
169
188
  \`env push\` sube las product vars de \`.env.local\` a Vercel (production+preview),
170
189
  sin tocar las provision-owned. En un repo con bóveda (bloque \`vault\`) no escribe:
@@ -336,6 +355,13 @@ async function main(argv) {
336
355
  }
337
356
  return;
338
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
+ }
339
365
  case 'provision': {
340
366
  await runProvision(parseProvisionFlags(rest));
341
367
  return;
@@ -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
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `.claude/settings.json` is shared: the kit owns the top-level keys ITS file declares (today
3
+ * `$schema`, `hooks`, `permissions`); every other top-level key belongs to the project
4
+ * (`enabledPlugins`, `extraKnownMarketplaces`, `env`, `statusLine`…). `update` compares only
5
+ * the kit's keys, by CONTENT — a project that only added its own keys, or whose tools
6
+ * reformatted the file (prettier, `/plugin install --scope project`), is not "edited" — and
7
+ * after writing the kit's file it puts the project's keys back.
8
+ *
9
+ * The manifest entry of this path carries `keys` (the kit's keys in that version) and a
10
+ * {@link settingsContentHash} instead of the raw-bytes hash. Lockfiles written before that
11
+ * carry the raw hash; {@link KIT_SETTINGS_HISTORY} maps it to the content hash of the same file.
12
+ * Contract: `fx-extension-points` §3.
13
+ */
14
+ import { createHash } from 'node:crypto';
15
+ import KIT_SETTINGS_HISTORY_JSON from './kit-settings-history.json' with { type: 'json' };
16
+ import { normalizeThenHash } from './lockfile.js';
17
+ export const KIT_SETTINGS_PATH = '.claude/settings.json';
18
+ /** The kit's top-level keys in every `settings.json` published before entries carried `keys`. */
19
+ export const LEGACY_KIT_SETTINGS_KEYS = ['$schema', 'hooks', 'permissions'];
20
+ /**
21
+ * Raw-bytes hash (`normalizeThenHash`) → content hash over {@link LEGACY_KIT_SETTINGS_KEYS}, for
22
+ * every distinct `settings.json` the kit published before content hashes. Frozen: generated once
23
+ * from the `v*` tags (every later release records a content hash with its `keys`).
24
+ */
25
+ export const KIT_SETTINGS_HISTORY = KIT_SETTINGS_HISTORY_JSON;
26
+ /** JSON with object keys sorted at every level and no whitespace — arrays keep their order. */
27
+ export function canonicalJson(value) {
28
+ if (Array.isArray(value))
29
+ return `[${value.map(canonicalJson).join(',')}]`;
30
+ if (value !== null && typeof value === 'object') {
31
+ const obj = value;
32
+ return `{${Object.keys(obj)
33
+ .sort()
34
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`)
35
+ .join(',')}}`;
36
+ }
37
+ return JSON.stringify(value);
38
+ }
39
+ function parseObject(raw) {
40
+ try {
41
+ const parsed = JSON.parse(raw);
42
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
43
+ ? parsed
44
+ : null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * SHA-256 of the canonical JSON of the `keys` subset of `raw` (absent keys are left out).
52
+ * `null` when `raw` is not a JSON object. Parity-locked with `distribution/build-dist.ts`.
53
+ */
54
+ export function settingsContentHash(raw, keys) {
55
+ const obj = parseObject(raw);
56
+ if (!obj)
57
+ return null;
58
+ const subset = {};
59
+ for (const key of keys)
60
+ if (key in obj)
61
+ subset[key] = obj[key];
62
+ return createHash('sha256').update(canonicalJson(subset), 'utf8').digest('hex');
63
+ }
64
+ /** Whether `raw` holds the kit's keys of `entry` unchanged (the project's own keys aside). */
65
+ function matchesEntry(raw, entry) {
66
+ if (normalizeThenHash(raw) === entry.hash)
67
+ return true;
68
+ const keys = entry.keys ?? (KIT_SETTINGS_HISTORY[entry.hash] ? LEGACY_KIT_SETTINGS_KEYS : null);
69
+ const expected = entry.keys ? entry.hash : KIT_SETTINGS_HISTORY[entry.hash];
70
+ return keys !== null && expected !== undefined && settingsContentHash(raw, keys) === expected;
71
+ }
72
+ /**
73
+ * The hash `diffLockfiles` sees for `settings.json` on disk: the old or the new entry's hash when
74
+ * the kit's keys match it, the raw hash otherwise (= edited, as for any other file).
75
+ */
76
+ export function effectiveSettingsHash(raw, entries) {
77
+ for (const entry of entries)
78
+ if (entry && matchesEntry(raw, entry))
79
+ return entry.hash;
80
+ return normalizeThenHash(raw);
81
+ }
82
+ /**
83
+ * The kit's file plus the project's keys of `previousRaw` (every top-level key the kit does not
84
+ * declare). `content` is `null` when there is nothing to carry over — the kit's file stays byte
85
+ * for byte. `replaced` names the project keys the kit now declares (the kit wins).
86
+ *
87
+ * @param previousKitKeys the kit's keys BEFORE this update (old entry's `keys`, or the legacy
88
+ * ones): a previous key outside them was the project's.
89
+ */
90
+ export function mergeProjectSettingsKeys(kitRaw, previousRaw, previousKitKeys) {
91
+ const kit = parseObject(kitRaw);
92
+ const previous = parseObject(previousRaw);
93
+ if (!kit || !previous)
94
+ return { content: null, kept: [], replaced: [] };
95
+ const projectKeys = Object.keys(previous).filter((k) => !previousKitKeys.includes(k));
96
+ const kept = projectKeys.filter((k) => !(k in kit));
97
+ const replaced = projectKeys.filter((k) => k in kit);
98
+ if (kept.length === 0)
99
+ return { content: null, kept, replaced };
100
+ const merged = { ...kit };
101
+ for (const key of kept)
102
+ merged[key] = previous[key];
103
+ return { content: `${JSON.stringify(merged, null, 2)}\n`, kept, replaced };
104
+ }
@@ -65,6 +65,12 @@ export const PROFILES = {
65
65
  export const TIMEKAST_DIR = '.timekast';
66
66
  export const MANIFEST_FILE = 'manifest.json';
67
67
  export const LOCKFILE_FILE = 'lockfile.json';
68
+ /**
69
+ * The npm packages the kit's tracked files need (Factory#78), embedded by the builder next to
70
+ * `manifest.json`: `{ version, profile, packages: { name: range } }`. Read from the STAGED
71
+ * tarball by `add`/`update` (`kit-deps.ts`); never left in a derived repo.
72
+ */
73
+ export const KIT_DEPS_FILE = 'kit-deps.json';
68
74
  /**
69
75
  * Repo-root path of the runtime entrypoint markdown. It is a tracked manifest
70
76
  * file (via `track` + the `core` rename `CLAUDE.core.md → CLAUDE.md`), so it
@@ -86,6 +92,21 @@ export const CLAUDE_MD_FILE = 'CLAUDE.md';
86
92
  * split as `.husky/pre-commit` → `.husky/pre-commit.project`.
87
93
  */
88
94
  export const VITEST_SETUP_FILE = 'vitest.setup.ts';
95
+ /**
96
+ * Path prefixes of the brain proper: the two trees the kit's `.prettierignore` block has always
97
+ * covered whole. Every other path the manifest tracks (`CLAUDE.md`, `.husky/*`,
98
+ * `.github/workflows/*`, `vitest.setup.ts`) lives among the repo's own files, so the block lists
99
+ * those one by one, generated from the manifest at sync time (`renderPrettierignoreBlock`).
100
+ * Trailing slash so a prefix match cannot catch a sibling (`scripts/tools-extra/`).
101
+ */
102
+ export const BRAIN_PREFIXES = ['.claude/', 'scripts/tools/'];
103
+ /**
104
+ * Suffix of the kit's copy of a file the repo already had with its own content (`add` keeps the
105
+ * repo's and writes the kit's next to it). APPENDED to the whole name, never inserted before the
106
+ * extension: GitHub Actions runs every `*.yml` under `.github/workflows/`, so `ci.kit-example.yml`
107
+ * would run as a second CI, while `ci.yml.kit-example` is inert.
108
+ */
109
+ export const KIT_EXAMPLE_SUFFIX = '.kit-example';
89
110
  /**
90
111
  * Repo-root `.gitattributes`. NOT a tracked manifest file (absent from `track`),
91
112
  * so it never flows through the diff/lockfile engine — a derived repo's own rules
@@ -144,6 +165,10 @@ export const UNPUBLISH_SCRIPT_CMD = 'npx @timekast/factory unpublish';
144
165
  // through `npx` so it resolves without a global install.
145
166
  export const ENV_PUSH_SCRIPT_NAME = 'env:push';
146
167
  export const ENV_PUSH_SCRIPT_CMD = 'npx @timekast/factory env push';
168
+ // Agentic Dashboard: the team's local usage dashboard. It reads this machine's Claude Code
169
+ // transcripts, not the repo, so it needs nothing the brain ships and goes to every derivative.
170
+ export const DASHBOARD_SCRIPT_NAME = 'dashboard';
171
+ export const DASHBOARD_SCRIPT_CMD = 'npx -y @timekast/agentic-dashboard --open';
147
172
  /**
148
173
  * All convenience scripts the installer ensures in a Node derivative's
149
174
  * package.json (each: add if missing, never overwrite a divergent value). Keyed
@@ -157,6 +182,7 @@ export const FACTORY_SCRIPTS = {
157
182
  [PUBLISH_SCRIPT_NAME]: PUBLISH_SCRIPT_CMD,
158
183
  [UNPUBLISH_SCRIPT_NAME]: UNPUBLISH_SCRIPT_CMD,
159
184
  [ENV_PUSH_SCRIPT_NAME]: ENV_PUSH_SCRIPT_CMD,
185
+ [DASHBOARD_SCRIPT_NAME]: DASHBOARD_SCRIPT_CMD,
160
186
  };
161
187
  /**
162
188
  * The local wrapper that injects the vault's environment into the kit's scripts (VAULT-003).
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The derived project's dual-owned dotfiles — `.gitattributes` and `.prettierignore` — synced
3
+ * from the staged tarball after the brain lands. Shared by `add` and the three `update` paths
4
+ * (main, legacy auto-register, resume): `add` used to skip them, so the first commit of a fresh
5
+ * install ran the repo's `prettier --write` over every kit file and drifted them all from the
6
+ * lockfile hashes (Factory#83/#78).
7
+ *
8
+ * The generic block engine lives in `gitattributes.ts` (`extractManagedBlock`,
9
+ * `syncManagedBlock`); this module only decides WHICH block goes into WHICH file and reports it.
10
+ * Both syncs are best-effort: a tarball without the file (pre-block releases) is a silent no-op,
11
+ * and a write failure never fails an install that already happened.
12
+ */
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { BRAIN_PREFIXES, CLAUDE_MD_FILE, GITATTRIBUTES_FILE, KIT_EXAMPLE_SUFFIX, MANAGED_BLOCK_END, PRETTIERIGNORE_FILE, } from './constants.js';
16
+ import { extractManagedBlock, syncManagedBlock } from './gitattributes.js';
17
+ import { MIGRATIONS_DIR } from './initial-migration.js';
18
+ /** Config files drizzle-kit reads, in the order it would pick them. */
19
+ const DRIZZLE_CONFIG_FILES = [
20
+ 'drizzle.config.ts',
21
+ 'drizzle.config.mts',
22
+ 'drizzle.config.js',
23
+ 'drizzle.config.mjs',
24
+ 'drizzle.config.cjs',
25
+ ];
26
+ /** The `out` key of a drizzle config, as a string literal. */
27
+ const DRIZZLE_OUT_RE = /\bout\s*:\s*['"`]([^'"`]+)['"`]/;
28
+ /** The kit's migration line inside the canonical block (`src/lib/db/migrations/`). */
29
+ const KIT_MIGRATIONS_LINE = `${MIGRATIONS_DIR}/`;
30
+ /**
31
+ * Where THIS repo keeps its drizzle migrations, as an ignore line (`lib/db/migrations/`), or
32
+ * `null` to keep the kit's. The `out` of the repo's drizzle config wins; without one, a repo with
33
+ * no `src/` gets the kit path without its `src/` prefix — the adjustment a derivative born before
34
+ * `src/` makes by hand, which the sync used to revert on every update (Factory#83).
35
+ */
36
+ export function resolveMigrationsLine(root) {
37
+ for (const name of DRIZZLE_CONFIG_FILES) {
38
+ const file = path.join(root, name);
39
+ if (!existsSync(file))
40
+ continue;
41
+ const match = DRIZZLE_OUT_RE.exec(readFileSync(file, 'utf8'));
42
+ if (!match)
43
+ break;
44
+ const out = match[1].trim().replace(/^\.\//, '').replace(/\/+$/, '');
45
+ // An absolute path or one that climbs out of the repo is nothing prettier could match.
46
+ if (out === '' || out === '.' || path.isAbsolute(out) || out.split('/').includes('..'))
47
+ break;
48
+ return `${out}/`;
49
+ }
50
+ if (!existsSync(path.join(root, 'src')))
51
+ return KIT_MIGRATIONS_LINE.replace(/^src\//, '');
52
+ return null;
53
+ }
54
+ /**
55
+ * The kit files this repo's formatter must leave alone that live OUTSIDE the brain prefixes:
56
+ * every manifest path not under `BRAIN_PREFIXES`, minus `CLAUDE.md` (dev-owned content) and
57
+ * minus any path with a `<path>.kit-example` beside it — there the file on disk is the repo's
58
+ * own, and formatting it is the repo's business. Sorted, so the block is stable across runs.
59
+ */
60
+ export function kitFilesOutsideBrain(manifestPaths, root) {
61
+ return [...new Set(manifestPaths)]
62
+ .filter((p) => !BRAIN_PREFIXES.some((prefix) => p.startsWith(prefix)))
63
+ .filter((p) => p !== CLAUDE_MD_FILE)
64
+ .filter((p) => !existsSync(path.join(root, `${p}${KIT_EXAMPLE_SUFFIX}`)))
65
+ .sort();
66
+ }
67
+ /**
68
+ * Render the `.prettierignore` block for THIS repo from the canonical one the tarball ships.
69
+ * The canonical block only knows the Factory's own layout and covers `.claude/` and
70
+ * `scripts/tools/`; two things are generated at sync time, the same precedent as
71
+ * `renderGitignoreBlock` in `prune`:
72
+ *
73
+ * - the migrations line follows the repo's real drizzle `out` (`resolveMigrationsLine`);
74
+ * - a section lists the kit files tracked outside the brain prefixes (`ci.yml`,
75
+ * `vitest.setup.ts`, `.husky/*`). Prettier reformatted them in every derivative whose style
76
+ * differs from the kit's, and each one became a false conflict on the next update.
77
+ *
78
+ * Reads the repo (drizzle config, `src/`, `.kit-example` files) but writes nothing. The same
79
+ * inputs always give the same block, so a second sync is a no-op.
80
+ */
81
+ export function renderPrettierignoreBlock(canonical, ctx) {
82
+ const migrations = resolveMigrationsLine(ctx.root);
83
+ const lines = canonical
84
+ .replace(/\r\n/g, '\n')
85
+ .split('\n')
86
+ .map((line) => (migrations && line.trim() === KIT_MIGRATIONS_LINE ? migrations : line));
87
+ const present = new Set(lines.map((l) => l.trim()));
88
+ const extra = kitFilesOutsideBrain(ctx.manifestPaths, ctx.root).filter((p) => !present.has(p));
89
+ if (extra.length === 0)
90
+ return lines.join('\n');
91
+ const section = [
92
+ '# Kit files tracked outside .claude/ and scripts/tools/, generated from the manifest by',
93
+ '# factory add/update. A reformat drifts them from the lockfile just like the brain.',
94
+ ...extra,
95
+ ];
96
+ const end = lines.findIndex((l) => l.trimStart().startsWith(MANAGED_BLOCK_END));
97
+ if (end === -1)
98
+ return [...lines, ...section].join('\n');
99
+ return [...lines.slice(0, end), ...section, ...lines.slice(end)].join('\n');
100
+ }
101
+ /**
102
+ * Sync the Factory's managed `.gitattributes` block (the EOL/binary rules that
103
+ * keep the kit's committed binaries from being corrupted by CRLF→LF normalization)
104
+ * into the derived repo, preserving the dev's own rules. The canonical block is
105
+ * READ from the staged tarball (single SSOT), so the rules are never duplicated in
106
+ * the CLI. Runs even without package.json (a non-Node derivative needs the rules
107
+ * too).
108
+ */
109
+ export function syncDerivedGitattributes(rootDir, stagedDir) {
110
+ try {
111
+ const srcPath = path.join(stagedDir, GITATTRIBUTES_FILE);
112
+ if (!existsSync(srcPath))
113
+ return;
114
+ const block = extractManagedBlock(readFileSync(srcPath, 'utf8'));
115
+ if (!block)
116
+ return;
117
+ const { action } = syncManagedBlock(rootDir, block);
118
+ if (action === 'unchanged')
119
+ return;
120
+ console.log(action === 'created'
121
+ ? '✔ `.gitattributes` creado con las reglas de normalización del kit.'
122
+ : '✔ `.gitattributes`: bloque de reglas del kit sincronizado.');
123
+ }
124
+ catch {
125
+ console.warn('Aviso: no se pudo sincronizar `.gitattributes`; el cerebro se instaló igual.');
126
+ }
127
+ }
128
+ /**
129
+ * Sync the Factory's managed `.prettierignore` block (keeps the derived repo's
130
+ * formatter away from `.claude/**` — a pre-commit `prettier --write` over the kit
131
+ * files drifts disk from the lockfile hashes and turns every later update into
132
+ * false conflicts) into the derived repo, preserving the dev's own ignore rules.
133
+ * The block is rendered for this repo (`renderPrettierignoreBlock`); otherwise the
134
+ * same contract as `syncDerivedGitattributes`.
135
+ */
136
+ export function syncDerivedPrettierignore(rootDir, stagedDir, manifestPaths) {
137
+ try {
138
+ const srcPath = path.join(stagedDir, PRETTIERIGNORE_FILE);
139
+ if (!existsSync(srcPath))
140
+ return;
141
+ const canonical = extractManagedBlock(readFileSync(srcPath, 'utf8'));
142
+ if (!canonical)
143
+ return;
144
+ const block = renderPrettierignoreBlock(canonical, { manifestPaths, root: rootDir });
145
+ const { action } = syncManagedBlock(rootDir, block, PRETTIERIGNORE_FILE);
146
+ if (action === 'unchanged')
147
+ return;
148
+ console.log(action === 'created'
149
+ ? '✔ `.prettierignore` creado con el ignore del cerebro (`.claude/`) — evita falsos conflictos por formato en futuros updates.'
150
+ : '✔ `.prettierignore`: bloque del kit sincronizado (`.claude/` fuera del formatter).');
151
+ }
152
+ catch {
153
+ console.warn('Aviso: no se pudo sincronizar `.prettierignore`; el cerebro se instaló igual.');
154
+ }
155
+ }
156
+ /**
157
+ * Sync both managed blocks. `manifest` is the profile's manifest just installed (the
158
+ * `.prettierignore` block lists its files outside the brain prefixes). Must run while
159
+ * `stagedDir` still exists (it is the source of the blocks) and BEFORE any commit of the brain,
160
+ * so the ignore lands before a pre-commit hook could reformat the freshly-installed kit files.
161
+ */
162
+ export function syncDerivedDotfiles(rootDir, stagedDir, manifest) {
163
+ syncDerivedGitattributes(rootDir, stagedDir);
164
+ syncDerivedPrettierignore(rootDir, stagedDir, manifest.files.map((f) => f.path));
165
+ }
@@ -16,10 +16,7 @@
16
16
  * 🔒 TOTAL (never throws): it runs on values read from a deploy, which the CLI does not control.
17
17
  */
18
18
  export function sendingDomainOf(emailFrom) {
19
- const value = emailFrom.trim();
20
- const open = value.lastIndexOf('<');
21
- const close = value.lastIndexOf('>');
22
- const address = open !== -1 && close > open ? value.slice(open + 1, close) : value;
19
+ const address = senderAddressOf(emailFrom);
23
20
  const at = address.lastIndexOf('@');
24
21
  return at === -1
25
22
  ? ''
@@ -28,3 +25,14 @@ export function sendingDomainOf(emailFrom) {
28
25
  .trim()
29
26
  .toLowerCase();
30
27
  }
28
+ /**
29
+ * The bare address of an `EMAIL_FROM` value: the part inside the LAST `<…>` when there is one,
30
+ * else the whole value, trimmed (`Agent Fleet <noreply@x.com>` → `noreply@x.com`). Same rule as
31
+ * {@link sendingDomainOf}. 🔒 TOTAL (never throws).
32
+ */
33
+ export function senderAddressOf(emailFrom) {
34
+ const value = emailFrom.trim();
35
+ const open = value.lastIndexOf('<');
36
+ const close = value.lastIndexOf('>');
37
+ return (open !== -1 && close > open ? value.slice(open + 1, close) : value).trim();
38
+ }