@timekast/factory 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,12 +18,15 @@ import { GITIGNORE_FILE } from '../lib/constants.js';
18
18
  import { readValueFlag } from '../lib/flags.js';
19
19
  import { commitWithPrivateIndex, trackedFiles } from '../lib/prune-git.js';
20
20
  import { checkPr, REJECTED_PACKAGE_FIELDS } from '../lib/prune-pr-check.js';
21
+ import { BRANCH_DEPLOY_RULE, VERCEL_JSON, ensureBranchDeployRule, hasBranchDeployRule, pruneVercelRelink, resolveVercelProject, } from '../lib/prune-vercel.js';
22
+ import { readVercelToken } from '../lib/provision-preflight.js';
21
23
  import { applyTierFiles, defaultPruneDeps, originRepo, pruneAudit, pruneCutover, pruneFinish, pruneInvite, } from '../lib/prune-remote.js';
22
24
  import { DEFAULT_PRUNE_TIER, PRUNE_MARKER_PATH, getTier, isRestricted, readPruneMarker, readPruneTiers, } from '../lib/prune-tiers.js';
23
25
  import { detectRepo } from '../lib/repo-detection.js';
24
26
  const PRUNE_USAGE = 'Uso: `factory prune prepare [--tier=collab] [--skip-verify]` · ' +
25
27
  '`factory prune cutover [--confirm] [--discard-develop] [--resume] [--repo=owner/nombre]` · ' +
26
- '`factory prune invite <usuario-github> [--repo=owner/nombre]` · ' +
28
+ '`factory prune vercel [--confirm] [--repo=owner/nombre] [--project=prj_…] [--team=team_…]` · ' +
29
+ '`factory prune invite <usuario-github> [--read] [--repo=owner/nombre]` · ' +
27
30
  '`factory prune finish [--repo=owner/nombre]` · `factory prune audit [--repo=owner/nombre]` · ' +
28
31
  '`factory prune check-pr <número> [--repo=owner/nombre] [--json]`.';
29
32
  export function parsePrunePrepareFlags(argv) {
@@ -48,25 +51,29 @@ export async function prunePrepare(rootDir, flags) {
48
51
  const tier = getTier(readPruneTiers(rootDir), flags.tier);
49
52
  // The two files this command owns must not carry someone's uncommitted work: the commit
50
53
  // below takes them whole.
51
- const dirty = await execa('git', ['status', '--porcelain', '--', GITIGNORE_FILE, PRUNE_MARKER_PATH], { cwd: rootDir, reject: false });
54
+ const dirty = await execa('git', ['status', '--porcelain', '--', GITIGNORE_FILE, PRUNE_MARKER_PATH, VERCEL_JSON], { cwd: rootDir, reject: false });
52
55
  if (dirty.stdout.trim() !== '') {
53
- throw new CLIError(`\`${GITIGNORE_FILE}\` o \`${PRUNE_MARKER_PATH}\` tienen cambios sin commitear. ` +
56
+ throw new CLIError(`\`${GITIGNORE_FILE}\`, \`${PRUNE_MARKER_PATH}\` o \`${VERCEL_JSON}\` tienen cambios sin commitear. ` +
54
57
  'Commitéalos o descártalos y vuelve a correr `factory prune prepare`.');
55
58
  }
56
59
  // 1. The block and the switch — `at` survives a re-run with the same tier, so a re-run
57
60
  // changes nothing.
58
61
  const prior = readPruneMarker(rootDir);
59
62
  const legacyBlockRemoved = applyTierFiles(rootDir, flags.tier, tier, prior?.tier === flags.tier ? prior.at : new Date().toISOString());
60
- // 2. Untrack what the tier restricts, and commit it with the block and the switch.
63
+ // 2. On Vercel, only `main` and `develop` deploy on push: a collaborator's branch would
64
+ // start a deployment Vercel blocks (author without a seat).
65
+ const branchDeployRuleAdded = ensureBranchDeployRule(rootDir) === 'added';
66
+ // 3. Untrack what the tier restricts, and commit it with the block, the switch and the rule.
61
67
  const untracked = (await trackedFiles(rootDir)).filter((p) => isRestricted(tier, p));
62
68
  const commit = await commitWithPrivateIndex(rootDir, {
63
69
  remove: untracked,
64
- add: [GITIGNORE_FILE, PRUNE_MARKER_PATH],
70
+ add: [GITIGNORE_FILE, PRUNE_MARKER_PATH, ...(branchDeployRuleAdded ? [VERCEL_JSON] : [])],
65
71
  message: `chore(prune): keep the methodology local (tier ${flags.tier})`,
66
72
  });
67
73
  return {
68
74
  tier: flags.tier,
69
75
  untracked,
76
+ branchDeployRuleAdded,
70
77
  legacyBlockRemoved,
71
78
  ...(commit.committed ? { commit: commit.sha } : {}),
72
79
  };
@@ -94,6 +101,10 @@ async function runPrunePrepare(flags) {
94
101
  `Dejé de trackear ${result.untracked.length} archivo(s); siguen en tu disco:`);
95
102
  for (const line of summarizePaths(result.untracked))
96
103
  console.log(line);
104
+ if (result.branchDeployRuleAdded) {
105
+ console.log(`✔ \`${VERCEL_JSON}\`: sólo \`main\` y \`develop\` despliegan con cada push — las ramas de ` +
106
+ 'los colaboradores no intentan un deploy que Vercel bloquearía.');
107
+ }
97
108
  }
98
109
  if (flags.skipVerify)
99
110
  return;
@@ -155,7 +166,7 @@ async function runPruneCutover(flags, deps) {
155
166
  for (const line of result.notMigrated)
156
167
  console.log(` • ${line}`);
157
168
  }
158
- console.log('\nSiguiente: reconecta el proyecto de Vercel al repo nuevo en el dashboard (Settings → Git), ' +
169
+ console.log('\nSiguiente: reconecta el proyecto de Vercel al repo nuevo (`factory prune vercel`), ' +
159
170
  'pide a los socios volver a clonar, y corre `factory prune finish` cuando Production despliegue.');
160
171
  }
161
172
  function printAudit(repo, audit) {
@@ -188,7 +199,7 @@ async function repoFrom(argv) {
188
199
  return readValueFlag(argv, 'repo') ?? (await originRepo(requireRepoRoot()));
189
200
  }
190
201
  /** `factory prune <sub> [...]` — dispatch lives here so every branch is testable. */
191
- export async function runPruneCommand(args, deps = defaultPruneDeps) {
202
+ export async function runPruneCommand(args, deps = defaultPruneDeps, vercel = {}) {
192
203
  const sub = args[0];
193
204
  const rest = args.slice(1);
194
205
  switch (sub) {
@@ -203,9 +214,48 @@ export async function runPruneCommand(args, deps = defaultPruneDeps) {
203
214
  if (!user)
204
215
  throw new CLIError(`Falta el usuario de GitHub del colaborador. ${PRUNE_USAGE}`);
205
216
  const repo = await repoFrom(rest);
206
- await pruneInvite(repo, user, deps);
207
- console.log(`✔ Invité a \`${user}\` a \`${repo}\` con lectura. Tiene que aceptar la invitación; ` +
208
- 'después forkea y abre PRs a la rama de trabajo.');
217
+ const read = rest.includes('--read');
218
+ await pruneInvite(repo, user, deps, read ? 'pull' : 'push');
219
+ console.log(read
220
+ ? `✔ Invité a \`${user}\` a \`${repo}\` con lectura. Tiene que aceptar la invitación; ` +
221
+ 'después forkea y abre PRs a la rama de trabajo.'
222
+ : `✔ Invité a \`${user}\` a \`${repo}\` con escritura. Tiene que aceptar la invitación; ` +
223
+ 'después trabaja en ramas del repo y abre PRs a la rama de trabajo. El hook `pre-push` ' +
224
+ 'del repo le rechaza el push a `main` y `develop` (no cubre la web de GitHub).');
225
+ return;
226
+ }
227
+ case 'vercel': {
228
+ const repo = await repoFrom(rest);
229
+ const { projectId, teamId } = resolveVercelProject(requireRepoRoot(), {
230
+ project: readValueFlag(rest, 'project'),
231
+ team: readValueFlag(rest, 'team'),
232
+ });
233
+ const token = await (vercel.token ?? (() => readVercelToken()))();
234
+ const result = await pruneVercelRelink({ repo, projectId, teamId, token, confirm: rest.includes('--confirm') }, { gh: deps.gh, ...(vercel.fetchImpl ? { fetchImpl: vercel.fetchImpl } : {}) });
235
+ if (hasBranchDeployRule(requireRepoRoot()) === false) {
236
+ console.warn(`⚠ \`${VERCEL_JSON}\` no limita los deploys por push: cada rama de un colaborador intentará ` +
237
+ 'un deploy que Vercel bloquea (autor sin asiento). Agrega y commitea:\n' +
238
+ ` "git": { "deploymentEnabled": ${JSON.stringify(BRANCH_DEPLOY_RULE)} }`);
239
+ }
240
+ if (result.status === 'already-linked') {
241
+ console.log(`✔ El proyecto de Vercel ya despliega desde \`${result.link.repo}\`. Nada que hacer.`);
242
+ }
243
+ else if (result.status === 'plan') {
244
+ console.log([
245
+ `Plan: el proyecto de Vercel (\`${projectId}\`) despliega desde ` +
246
+ `${result.from ? `\`${result.from.repo}\`` : 'ningún repo'}.`,
247
+ ` 1. Desconectarlo${result.from ? ` de \`${result.from.repo}\`` : ''} (variables, dominios y deploys se quedan).`,
248
+ ` 2. Conectarlo a \`${result.to}\` y releer el vínculo para confirmarlo.`,
249
+ ' No dispara ningún deploy.',
250
+ '',
251
+ 'Para ejecutarlo: `factory prune vercel --confirm`.',
252
+ ].join('\n'));
253
+ }
254
+ else {
255
+ console.log(`✔ El proyecto de Vercel ahora despliega desde \`${result.to.repo}\`` +
256
+ `${result.from ? ` (antes: \`${result.from.repo}\`)` : ''}. ` +
257
+ 'El primer deploy sale con el siguiente push a `main` (p. ej. un commit vacío).');
258
+ }
209
259
  return;
210
260
  }
211
261
  case 'finish': {
@@ -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,14 +46,18 @@ 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';
52
57
  import { planPruneMaintenance } from '../lib/prune-update.js';
53
58
  import { isRestricted, PRUNE_MARKER_PATH } from '../lib/prune-tiers.js';
54
59
  import { printRetrofitReminder } from '../lib/retrofit-reminder.js';
60
+ import { printWhatsNew } from '../lib/whats-new.js';
55
61
  import { hasProvisionState } from '../lib/provision-state.js';
56
62
  import { loginCommand } from '../lib/rail.js';
57
63
  import { resolveBrainRoot } from '../lib/repo-detection.js';
@@ -100,16 +106,52 @@ async function acquireFromBeta(profile) {
100
106
  * Hash the disk content (normalized) of every path in `paths` that exists.
101
107
  * A missing file is simply absent from the returned map.
102
108
  */
103
- function hashDiskPaths(rootDir, paths) {
109
+ function hashDiskPaths(rootDir, paths, settingsEntries = []) {
104
110
  const map = new Map();
105
111
  for (const rel of paths) {
106
112
  const abs = path.join(rootDir, rel);
107
113
  if (existsSync(abs) && statSync(abs).isFile()) {
108
- map.set(rel, normalizeThenHash(readFileSync(abs, 'utf8')));
114
+ const raw = readFileSync(abs, 'utf8');
115
+ // settings.json: only the kit's keys count, by content (the project's are its own).
116
+ map.set(rel, rel === KIT_SETTINGS_PATH
117
+ ? effectiveSettingsHash(raw, settingsEntries)
118
+ : normalizeThenHash(raw));
109
119
  }
110
120
  }
111
121
  return map;
112
122
  }
123
+ /** The `settings.json` the project had before this apply: the run's backup, else the disk. */
124
+ function readPreviousSettings(rootDir, backupDir) {
125
+ for (const abs of [
126
+ path.join(backupDir, KIT_SETTINGS_PATH),
127
+ path.join(rootDir, KIT_SETTINGS_PATH),
128
+ ]) {
129
+ if (existsSync(abs) && statSync(abs).isFile())
130
+ return readFileSync(abs, 'utf8');
131
+ }
132
+ return null;
133
+ }
134
+ /**
135
+ * After the kit's `settings.json` was written, put the project's own keys back (every top-level
136
+ * key the kit does not declare) and say so. No-op when the kit's file was not written.
137
+ */
138
+ function restoreProjectSettingsKeys(rootDir, written, previous, previousKitKeys) {
139
+ if (previous === null || !written.includes(KIT_SETTINGS_PATH))
140
+ return;
141
+ const abs = path.join(rootDir, KIT_SETTINGS_PATH);
142
+ const merged = mergeProjectSettingsKeys(readFileSync(abs, 'utf8'), previous, previousKitKeys);
143
+ if (merged.content !== null) {
144
+ writeFileSync(abs, merged.content, 'utf8');
145
+ console.log(` ✔ ${KIT_SETTINGS_PATH}: conservé tus llaves (${merged.kept.join(', ')})`);
146
+ }
147
+ for (const key of merged.replaced) {
148
+ console.warn(` ⚠ ${KIT_SETTINGS_PATH}: \`${key}\` ahora la declara el kit — tu valor se reemplazó por el suyo.`);
149
+ }
150
+ }
151
+ /** The kit's keys recorded for `settings.json` in a lockfile (the legacy set when absent). */
152
+ function kitSettingsKeysOf(lock) {
153
+ return lock?.files.find((f) => f.path === KIT_SETTINGS_PATH)?.keys ?? LEGACY_KIT_SETTINGS_KEYS;
154
+ }
113
155
  /** Unchanged lines kept around each change in the `[3] Ver diff` view. */
114
156
  const DIFF_CONTEXT_LINES = 3;
115
157
  /**
@@ -301,7 +343,7 @@ function runVerify(rootDir) {
301
343
  throw new CLIError('No hay lockfile que verificar. Corre `factory update` primero.');
302
344
  }
303
345
  const lock = readLockfile(rootDir);
304
- const disk = hashDiskPaths(rootDir, lock.files.map((f) => f.path));
346
+ const disk = hashDiskPaths(rootDir, lock.files.map((f) => f.path), [lock.files.find((f) => f.path === KIT_SETTINGS_PATH)]);
305
347
  const missing = [];
306
348
  const drifted = [];
307
349
  for (const entry of lock.files) {
@@ -465,20 +507,32 @@ async function runResume(rootDir, flags) {
465
507
  const claudeMd = priorLock
466
508
  ? assessClaudeMd(rootDir, priorLock)
467
509
  : undefined;
510
+ const previousSettings = readPreviousSettings(rootDir, state.backupDir);
468
511
  applyPlan(rootDir, state.stagedDir, state.plan, state.backupDir);
512
+ restoreProjectSettingsKeys(rootDir, state.plan.writes, previousSettings, kitSettingsKeysOf(priorLock));
469
513
  writeLockfile(rootDir, newLock);
470
514
  clearUpdateState(rootDir);
471
515
  const claudeMdInserted = claudeMd ? applyClaudeMdRetrofit(rootDir, claudeMd) : false;
516
+ const kitExamples = refreshKitExamples(rootDir, state.stagedDir, newLock.files.map((f) => f.path));
472
517
  // Maintain dotfiles BEFORE removing the staged dir (the .gitattributes source).
473
- const pkgMutations = maintainDerivedDotfiles(rootDir, state.stagedDir, newLock.version);
518
+ const pkgMutations = maintainDerivedDotfiles(rootDir, state.stagedDir, newLock);
474
519
  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);
520
+ await maybeCommitBrain(rootDir, newLock.version, [
521
+ ...state.plan.writes,
522
+ ...state.plan.deletes,
523
+ ...kitExamples,
524
+ ...(claudeMdInserted ? [CLAUDE_MD_FILE] : []),
525
+ ], flags, prune);
526
+ const kitDeps = readKitDeps(state.stagedDir);
476
527
  rmSync(state.stagedDir, { recursive: true, force: true });
477
528
  // A resumed update maintains package.json exactly like a normal one, so it owes the dev the
478
529
  // same account of what it moved there (`BND-004`).
479
530
  reportPkgMutations(pkgMutations);
480
531
  if (claudeMd)
481
532
  reportClaudeMd(claudeMd, 'applied', claudeMdInserted);
533
+ // AFTER the brain commit: an install changes package.json and the package lockfile, which
534
+ // stay out of that commit (Factory#78).
535
+ await offerKitDeps(rootDir, kitDeps, { assumeYes: flags.yes });
482
536
  console.log('✔ `update` retomado y completado.');
483
537
  // A resumed update that succeeds completed the setup → show the end-pointer
484
538
  // (PVSKILL-002 §8 edge: `--resume` SÍ lo muestra). Suppressed only in headless
@@ -522,10 +576,9 @@ function warnOnScriptConflict(action, _pkgPath) {
522
576
  * the managed blocks. Runs BEFORE any brain commit, so the blocks land before a
523
577
  * pre-commit hook could reformat the freshly-installed kit files.
524
578
  */
525
- function maintainDerivedDotfiles(rootDir, stagedDir, agentKitVersion) {
526
- const pkgMutations = maintainDerivedPkg(rootDir, agentKitVersion);
527
- syncDerivedGitattributes(rootDir, stagedDir);
528
- syncDerivedPrettierignore(rootDir, stagedDir);
579
+ function maintainDerivedDotfiles(rootDir, stagedDir, manifest) {
580
+ const pkgMutations = maintainDerivedPkg(rootDir, manifest.version);
581
+ syncDerivedDotfiles(rootDir, stagedDir, manifest);
529
582
  return pkgMutations;
530
583
  }
531
584
  /** A run that mutated nothing (no package.json, or the maintenance failed and warned). */
@@ -574,63 +627,6 @@ function maintainDerivedPkg(rootDir, agentKitVersion) {
574
627
  return emptyPkgMutations();
575
628
  }
576
629
  }
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
630
  /**
635
631
  * The kit's own test folder. It stopped shipping to derivatives (`BND-003`): those tests
636
632
  * validate KIT tooling, and inside a derivative they ran against a `src/` that may have
@@ -928,10 +924,11 @@ export async function runUpdate(flags, deps = {}) {
928
924
  if (flags.dryRun) {
929
925
  console.log('\n🔍 Vista previa: este repo aún no tiene lockfile. El primer `factory update` ' +
930
926
  'registra el cerebro; en el primer sync no hay conflictos posibles.');
927
+ await offerKitDeps(rootDir, readKitDeps(stagedDir), { dryRun: true });
931
928
  process.exitCode = 0;
932
929
  return;
933
930
  }
934
- await applyLegacy(rootDir, stagedDir, manifest);
931
+ await applyLegacy(rootDir, stagedDir, manifest, flags.yes);
935
932
  // Legacy auto-register succeeded → end-pointer (PVSKILL-002).
936
933
  printProvisionPointer({ assumeYes: flags.yes });
937
934
  return;
@@ -942,13 +939,17 @@ export async function runUpdate(flags, deps = {}) {
942
939
  ...oldLock.files.map((f) => f.path),
943
940
  ...manifest.files.map((f) => f.path),
944
941
  ]);
945
- const disk = hashDiskPaths(rootDir, trackedPaths);
942
+ const disk = hashDiskPaths(rootDir, trackedPaths, [
943
+ oldLock.files.find((f) => f.path === KIT_SETTINGS_PATH),
944
+ manifest.files.find((f) => f.path === KIT_SETTINGS_PATH),
945
+ ]);
946
946
  const diff = diffLockfiles(oldLock, manifest, disk);
947
947
  // --dry-run: preview + STOP. No prompts, no apply, no lockfile, no commit.
948
948
  // Exit 2 when conflicts exist so the GUI branches on it (0 otherwise). `return`
949
949
  // runs the finally → tmpRoot is cleaned (no update-state was written).
950
950
  if (flags.dryRun) {
951
951
  reportDryRun(diff, manifest);
952
+ await offerKitDeps(rootDir, readKitDeps(stagedDir), { dryRun: true });
952
953
  process.exitCode = diff.conflicts.length > 0 ? DRY_RUN_CONFLICTS_EXIT : 0;
953
954
  return;
954
955
  }
@@ -978,7 +979,9 @@ export async function runUpdate(flags, deps = {}) {
978
979
  // (first entry from a stable lockfile, where `priorLock.beta` is absent).
979
980
  const stateManifest = flags.beta ? { ...manifest, beta: true } : manifest;
980
981
  writeUpdateState(rootDir, { stagedDir, backupDir, plan, newManifest: stateManifest });
982
+ const previousSettings = readPreviousSettings(rootDir, backupDir);
981
983
  applyPlan(rootDir, stagedDir, plan, backupDir);
984
+ restoreProjectSettingsKeys(rootDir, plan.writes, previousSettings, kitSettingsKeysOf(oldLock));
982
985
  // Lockfile written ONLY after a clean apply. The birth stamp is preserved
983
986
  // (agentKitVersion advances; factoryVersion stays frozen). With `--beta`,
984
987
  // `stampBeta` composes `stampBirth` and adds `beta:true`; a stable/plain
@@ -989,14 +992,23 @@ export async function runUpdate(flags, deps = {}) {
989
992
  // same commit as the rest of the update (and `CLAUDE.md` joins the staged paths —
990
993
  // in case B it is in `ignoreLocal`, so the plan never names it).
991
994
  const claudeMdInserted = applyClaudeMdRetrofit(rootDir, claudeMd);
992
- const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
995
+ const kitExamples = refreshKitExamples(rootDir, stagedDir, manifest.files.map((f) => f.path));
996
+ const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest);
993
997
  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);
998
+ await maybeCommitBrain(rootDir, manifest.version, [
999
+ ...plan.writes,
1000
+ ...plan.deletes,
1001
+ ...kitExamples,
1002
+ ...(claudeMdInserted ? [CLAUDE_MD_FILE] : []),
1003
+ ], flags, prune);
995
1004
  // `theirsConflicts` IS the overwritten-local list: same source whether the dev answered
996
1005
  // the prompt, passed `--theirs-all`, or ran headless — there is no second path. The
997
1006
  // package.json mutations ride the SAME closing summary, not three loose notices.
998
1007
  reportSummary(diff, manifest, theirsConflicts, pkgMutations);
999
1008
  reportClaudeMd(claudeMd, 'applied', claudeMdInserted);
1009
+ // AFTER the brain commit: an install changes package.json and the package lockfile, which
1010
+ // stay out of that commit (Factory#78).
1011
+ await offerKitDeps(rootDir, readKitDeps(stagedDir), { assumeYes: flags.yes });
1000
1012
  // 🔴 The retrofit reminder, on EVERY applied update and regardless of how conflicts
1001
1013
  // were resolved — `--theirs-all`, `--mine-all`, per-file answers or none at all. A
1002
1014
  // retrofit is not a conflict: it is work this command never attempted, living outside
@@ -1006,6 +1018,10 @@ export async function runUpdate(flags, deps = {}) {
1006
1018
  // `oldLock.version` is the version BEFORE this update, which is what separates the guides
1007
1019
  // that just arrived from the ones that were already sitting there. A legacy repo carries
1008
1020
  // `0.0.0`, so every guide reads as new — correct: it has seen none of them.
1021
+ // What changed first, then what still needs hands: the headlines of every kit release
1022
+ // this update crossed, from the changelog that just arrived. Same reasons as the reminder
1023
+ // for printing on every outcome and headless.
1024
+ printWhatsNew({ rootDir, fromVersion: oldLock.version });
1009
1025
  printRetrofitReminder({ rootDir, fromVersion: oldLock.version, guiSignal: flags.launcher });
1010
1026
  await maybeOfferEmptyE2EBranch(rootDir, flags);
1011
1027
  // End-pointer to the next step (PVSKILL-002). Reached only after a clean apply;
@@ -1074,7 +1090,8 @@ async function confirmLegacy(profile, assumeYes) {
1074
1090
  name: 'proceed',
1075
1091
  message: `Este repo no tiene lockfile. Se hará un auto-registro por paths (perfil \`${profile}\`).\n` +
1076
1092
  `${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?',
1093
+ 'versión; tu `CLAUDE.md` se conserva si ya existe, y también tus archivos fuera de ' +
1094
+ '`.claude/` y `scripts/tools/` (la versión del kit queda al lado como `.kit-example`). ¿Continuar?',
1078
1095
  initial: false,
1079
1096
  });
1080
1097
  if (!proceed) {
@@ -1087,26 +1104,30 @@ async function confirmLegacy(profile, assumeYes) {
1087
1104
  * NOT deleted on the first sync (design §6.3). Builds the lockfile from the
1088
1105
  * normalized hashes of the installed files.
1089
1106
  */
1090
- async function applyLegacy(rootDir, stagedDir, manifest) {
1107
+ async function applyLegacy(rootDir, stagedDir, manifest, assumeYes) {
1091
1108
  const repoPaths = new Set(collectClaudePaths(rootDir));
1092
1109
  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.
1110
+ // The brain is written whole (kit-owned overwrite + adds). No deletes on the
1111
+ // first sync. CLAUDE.md is dev-owned (rama B / write-if-absent): if it already
1112
+ // exists on disk it is preserved (filtered out of writes); only written when
1113
+ // absent. The lockfile still records the manifest's CLAUDE.md hash, so a
1114
+ // preserved dev CLAUDE.md reads as "locally edited" on future updates → always
1115
+ // ignoreLocal (rama A), never silently clobbered. The other kit files outside
1116
+ // the brain get the same treatment as in `add` (#76): one the repo already has
1117
+ // with its own content is kept, the kit's copy goes to `<path>.kit-example`, and
1118
+ // the lockfile records the kit hash (ignoreLocal until the kit changes it).
1099
1119
  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));
1120
+ const { writes, kept } = partitionInitialWrites(rootDir, manifest);
1103
1121
  const backupDir = defaultBackupDir(path.dirname(stagedDir));
1122
+ const previousSettings = readPreviousSettings(rootDir, backupDir);
1104
1123
  applyPlan(rootDir, stagedDir, { writes, deletes: [] }, backupDir);
1124
+ restoreProjectSettingsKeys(rootDir, writes, previousSettings, LEGACY_KIT_SETTINGS_KEYS);
1125
+ writeKitExamples(rootDir, stagedDir, kept);
1105
1126
  // The lockfile = the manifest (its hashes are already the normalized hashes of
1106
1127
  // the just-installed files) PLUS the birth stamp. A legacy auto-register is the
1107
1128
  // first sync this repo records, so the birth seal = the manifest's version.
1108
1129
  writeLockfile(rootDir, { ...manifest, factoryVersion: manifest.version });
1109
- const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
1130
+ const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest);
1110
1131
  // No brain commit on this path; the block is still kept in step (the index is left to
1111
1132
  // `prune prepare`, which the pending notice names).
1112
1133
  reportPendingPrune(await planPruneMaintenance(rootDir, stagedDir, manifest.files.map((f) => f.path)));
@@ -1115,6 +1136,7 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
1115
1136
  console.log('\nConservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
1116
1137
  '(`@.claude/rules/*`); corre `factory doctor` para detectar rules sin importar.');
1117
1138
  }
1139
+ reportKeptRepoFiles(kept);
1118
1140
  if (plan.ambiguous.length > 0) {
1119
1141
  console.log('\nArchivos en `.claude/` que no están en el manifest (revisar manualmente):');
1120
1142
  for (const p of plan.ambiguous)
@@ -1122,4 +1144,6 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
1122
1144
  }
1123
1145
  console.log(`\n✔ Auto-registro completo (v${manifest.version}, perfil ${manifest.profile}): ` +
1124
1146
  `${plan.kitOwned.length} kit-owned, ${plan.toAdd.length} agregados, ${plan.ambiguous.length} ambiguos.`);
1147
+ // No brain commit on this path, so nothing to keep the install out of (Factory#78).
1148
+ await offerKitDeps(rootDir, readKitDeps(stagedDir), { assumeYes });
1125
1149
  }