@timekast/factory 2.3.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -42,13 +44,18 @@ import { applyPlan, clearUpdateState, defaultBackupDir, hasUpdateState, readUpda
42
44
  import { applyClaudeMdRetrofit, assessClaudeMd, reportClaudeMd, } from '../lib/claude-md.js';
43
45
  import { collectClaudePaths } from '../lib/claude-paths.js';
44
46
  import { CLIError } from '../lib/cli-error.js';
45
- import { CLAUDE_MD_FILE, GITATTRIBUTES_FILE, LOCKFILE_FILE, PRETTIERIGNORE_FILE, PROFILES, TIMEKAST_DIR, } from '../lib/constants.js';
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';
57
+ import { planPruneMaintenance } from '../lib/prune-update.js';
58
+ import { isRestricted, PRUNE_MARKER_PATH } from '../lib/prune-tiers.js';
52
59
  import { printRetrofitReminder } from '../lib/retrofit-reminder.js';
53
60
  import { hasProvisionState } from '../lib/provision-state.js';
54
61
  import { loginCommand } from '../lib/rail.js';
@@ -98,16 +105,52 @@ async function acquireFromBeta(profile) {
98
105
  * Hash the disk content (normalized) of every path in `paths` that exists.
99
106
  * A missing file is simply absent from the returned map.
100
107
  */
101
- function hashDiskPaths(rootDir, paths) {
108
+ function hashDiskPaths(rootDir, paths, settingsEntries = []) {
102
109
  const map = new Map();
103
110
  for (const rel of paths) {
104
111
  const abs = path.join(rootDir, rel);
105
112
  if (existsSync(abs) && statSync(abs).isFile()) {
106
- map.set(rel, normalizeThenHash(readFileSync(abs, 'utf8')));
113
+ const raw = readFileSync(abs, 'utf8');
114
+ // settings.json: only the kit's keys count, by content (the project's are its own).
115
+ map.set(rel, rel === KIT_SETTINGS_PATH
116
+ ? effectiveSettingsHash(raw, settingsEntries)
117
+ : normalizeThenHash(raw));
107
118
  }
108
119
  }
109
120
  return map;
110
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
+ }
111
154
  /** Unchanged lines kept around each change in the `[3] Ver diff` view. */
112
155
  const DIFF_CONTEXT_LINES = 3;
113
156
  /**
@@ -299,7 +342,7 @@ function runVerify(rootDir) {
299
342
  throw new CLIError('No hay lockfile que verificar. Corre `factory update` primero.');
300
343
  }
301
344
  const lock = readLockfile(rootDir);
302
- 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)]);
303
346
  const missing = [];
304
347
  const drifted = [];
305
348
  for (const entry of lock.files) {
@@ -332,15 +375,20 @@ function runVerify(rootDir) {
332
375
  * → prompt y/n · headless → commit by default (a headless run needs it). Stages ONLY
333
376
  * what the update touched (the applied manifest paths + lockfile + package.json +
334
377
  * .gitattributes) — never `git add -A`. Best-effort: no identity / no repo → warn + skip.
378
+ * In a pruned repo (`prune` given) it also untracks what the tier restricts, leaves the
379
+ * restricted applied paths out, and stages `.gitignore`, the switch and the shared kit paths
380
+ * that are not tracked yet.
335
381
  */
336
- async function maybeCommitBrain(rootDir, version, appliedPaths, flags) {
382
+ async function maybeCommitBrain(rootDir, version, appliedPaths, flags, prune = null) {
337
383
  const decision = decideCommit({
338
384
  commit: flags.commit,
339
385
  noCommit: flags.noCommit,
340
386
  isTTY: Boolean(process.stdout.isTTY),
341
387
  });
342
- if (decision === 'skip')
388
+ if (decision === 'skip') {
389
+ reportPendingPrune(prune);
343
390
  return;
391
+ }
344
392
  let doCommit = decision === 'commit';
345
393
  if (decision === 'prompt') {
346
394
  const { yes } = await prompts({
@@ -351,17 +399,31 @@ async function maybeCommitBrain(rootDir, version, appliedPaths, flags) {
351
399
  });
352
400
  doCommit = yes === true;
353
401
  }
354
- if (!doCommit)
402
+ if (!doCommit) {
403
+ reportPendingPrune(prune);
355
404
  return;
405
+ }
356
406
  const candidates = [
357
- ...appliedPaths,
407
+ // A pruned repo never stages what its tier keeps local (it is ignored, and the guard would
408
+ // refuse it); it DOES stage the shared kit paths not yet tracked (`prune-update.ts`).
409
+ ...(prune ? appliedPaths.filter((p) => !isRestricted(prune.tier, p)) : appliedPaths),
410
+ ...(prune ? [...prune.publish, GITIGNORE_FILE, PRUNE_MARKER_PATH] : []),
358
411
  `${TIMEKAST_DIR}/${LOCKFILE_FILE}`,
359
412
  ...(existsSync(path.join(rootDir, 'package.json')) ? ['package.json'] : []),
360
413
  ...(existsSync(path.join(rootDir, GITATTRIBUTES_FILE)) ? [GITATTRIBUTES_FILE] : []),
361
414
  ...(existsSync(path.join(rootDir, PRETTIERIGNORE_FILE)) ? [PRETTIERIGNORE_FILE] : []),
362
415
  ];
363
416
  try {
364
- await execa('git', ['add', '--', ...candidates], { cwd: rootDir, reject: false });
417
+ const stageable = await filterStageable(rootDir, candidates);
418
+ if (prune && prune.untrack.length > 0) {
419
+ await execa('git', ['rm', '--cached', '-q', '--ignore-unmatch', '--', ...prune.untrack], {
420
+ cwd: rootDir,
421
+ reject: false,
422
+ });
423
+ }
424
+ if (stageable.length > 0) {
425
+ await execa('git', ['add', '--', ...stageable], { cwd: rootDir, reject: false });
426
+ }
365
427
  // Nothing staged (e.g. a no-op update) → no empty commit.
366
428
  const staged = await execa('git', ['diff', '--cached', '--quiet'], {
367
429
  cwd: rootDir,
@@ -382,6 +444,34 @@ async function maybeCommitBrain(rootDir, version, appliedPaths, flags) {
382
444
  console.warn('Aviso: no se pudo commitear; el cerebro se instaló igual.');
383
445
  }
384
446
  }
447
+ /**
448
+ * The brain commit was skipped in a pruned repo: whatever the tier asks the index to change
449
+ * (untrack a restricted path, publish a newly shared one) is still pending. `prune prepare`
450
+ * does exactly that and is idempotent, so it is the one instruction to give.
451
+ */
452
+ function reportPendingPrune(prune) {
453
+ if (!prune || (prune.untrack.length === 0 && prune.publish.length === 0))
454
+ return;
455
+ console.log(`ℹ️ Repo compartido (tier \`${prune.tierName}\`): quedan ${prune.untrack.length} ruta(s) por ` +
456
+ `dejar de trackear y ${prune.publish.length} por publicar. Corre \`factory prune prepare\` ` +
457
+ 'para commitearlo.');
458
+ }
459
+ /**
460
+ * Keep only the paths `git add` can take: on disk, or in the index (a deletion to stage).
461
+ *
462
+ * ONE path that is in neither — a retired file the dev never committed, which the update just
463
+ * removed from disk — makes `git add -- a b c` exit 128 having staged NOTHING, so the brain
464
+ * commit was lost in silence (the warning below only fires when `git commit` fails, and a
465
+ * clean index commits nothing). Exported for the CLI suite.
466
+ */
467
+ export async function filterStageable(rootDir, candidates) {
468
+ const listed = await execa('git', ['ls-files', '-z', '--cached', '--', ...candidates], {
469
+ cwd: rootDir,
470
+ reject: false,
471
+ });
472
+ const inIndex = new Set(listed.exitCode === 0 ? listed.stdout.split('\0').filter(Boolean) : []);
473
+ return [...new Set(candidates)].filter((rel) => inIndex.has(rel) || existsSync(path.join(rootDir, rel)));
474
+ }
385
475
  /**
386
476
  * `--resume`: re-apply a persisted mid-flight state without re-downloading
387
477
  * (design §7.5). Reads the staged dir + plan from `.timekast/.update-state.json`.
@@ -416,19 +506,32 @@ async function runResume(rootDir, flags) {
416
506
  const claudeMd = priorLock
417
507
  ? assessClaudeMd(rootDir, priorLock)
418
508
  : undefined;
509
+ const previousSettings = readPreviousSettings(rootDir, state.backupDir);
419
510
  applyPlan(rootDir, state.stagedDir, state.plan, state.backupDir);
511
+ restoreProjectSettingsKeys(rootDir, state.plan.writes, previousSettings, kitSettingsKeysOf(priorLock));
420
512
  writeLockfile(rootDir, newLock);
421
513
  clearUpdateState(rootDir);
422
514
  const claudeMdInserted = claudeMd ? applyClaudeMdRetrofit(rootDir, claudeMd) : false;
515
+ const kitExamples = refreshKitExamples(rootDir, state.stagedDir, newLock.files.map((f) => f.path));
423
516
  // Maintain dotfiles BEFORE removing the staged dir (the .gitattributes source).
424
- const pkgMutations = maintainDerivedDotfiles(rootDir, state.stagedDir, newLock.version);
425
- await maybeCommitBrain(rootDir, newLock.version, [...state.plan.writes, ...state.plan.deletes, ...(claudeMdInserted ? [CLAUDE_MD_FILE] : [])], flags);
517
+ const pkgMutations = maintainDerivedDotfiles(rootDir, state.stagedDir, newLock);
518
+ const prune = await planPruneMaintenance(rootDir, state.stagedDir, newLock.files.map((f) => f.path));
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);
426
526
  rmSync(state.stagedDir, { recursive: true, force: true });
427
527
  // A resumed update maintains package.json exactly like a normal one, so it owes the dev the
428
528
  // same account of what it moved there (`BND-004`).
429
529
  reportPkgMutations(pkgMutations);
430
530
  if (claudeMd)
431
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 });
432
535
  console.log('✔ `update` retomado y completado.');
433
536
  // A resumed update that succeeds completed the setup → show the end-pointer
434
537
  // (PVSKILL-002 §8 edge: `--resume` SÍ lo muestra). Suppressed only in headless
@@ -472,10 +575,9 @@ function warnOnScriptConflict(action, _pkgPath) {
472
575
  * the managed blocks. Runs BEFORE any brain commit, so the blocks land before a
473
576
  * pre-commit hook could reformat the freshly-installed kit files.
474
577
  */
475
- function maintainDerivedDotfiles(rootDir, stagedDir, agentKitVersion) {
476
- const pkgMutations = maintainDerivedPkg(rootDir, agentKitVersion);
477
- syncDerivedGitattributes(rootDir, stagedDir);
478
- syncDerivedPrettierignore(rootDir, stagedDir);
578
+ function maintainDerivedDotfiles(rootDir, stagedDir, manifest) {
579
+ const pkgMutations = maintainDerivedPkg(rootDir, manifest.version);
580
+ syncDerivedDotfiles(rootDir, stagedDir, manifest);
479
581
  return pkgMutations;
480
582
  }
481
583
  /** A run that mutated nothing (no package.json, or the maintenance failed and warned). */
@@ -524,63 +626,6 @@ function maintainDerivedPkg(rootDir, agentKitVersion) {
524
626
  return emptyPkgMutations();
525
627
  }
526
628
  }
527
- /**
528
- * Sync the Factory's managed `.gitattributes` block (the EOL/binary rules that
529
- * keep the kit's committed binaries from being corrupted by CRLF→LF normalization)
530
- * into the derived repo, preserving the dev's own rules. The canonical block is
531
- * READ from the staged tarball (single SSOT), so the rules are never duplicated in
532
- * the CLI. Runs even without package.json (a non-Node derivative needs the rules
533
- * too). Best-effort like the package.json mirror: a tarball that predates the
534
- * managed block (no markers → no block) is a silent no-op, and a write failure
535
- * never fails an already-applied update.
536
- */
537
- function syncDerivedGitattributes(rootDir, stagedDir) {
538
- try {
539
- const srcPath = path.join(stagedDir, GITATTRIBUTES_FILE);
540
- if (!existsSync(srcPath))
541
- return;
542
- const block = extractManagedBlock(readFileSync(srcPath, 'utf8'));
543
- if (!block)
544
- return;
545
- const { action } = syncManagedBlock(rootDir, block);
546
- if (action === 'unchanged')
547
- return;
548
- console.log(action === 'created'
549
- ? '✔ `.gitattributes` creado con las reglas de normalización del kit.'
550
- : '✔ `.gitattributes`: bloque de reglas del kit sincronizado.');
551
- }
552
- catch {
553
- console.warn('Aviso: no se pudo sincronizar `.gitattributes`; el cerebro se instaló igual.');
554
- }
555
- }
556
- /**
557
- * Sync the Factory's managed `.prettierignore` block (keeps the derived repo's
558
- * formatter away from `.claude/**` — a pre-commit `prettier --write` over the kit
559
- * files drifts disk from the lockfile hashes and turns every later update into
560
- * false conflicts) into the derived repo, preserving the dev's own ignore rules.
561
- * Same contract as `syncDerivedGitattributes`: canonical block READ from the
562
- * staged tarball (single SSOT), best-effort, tarball without the file (pre-block
563
- * releases) → silent no-op, a write failure never fails an applied update.
564
- */
565
- function syncDerivedPrettierignore(rootDir, stagedDir) {
566
- try {
567
- const srcPath = path.join(stagedDir, PRETTIERIGNORE_FILE);
568
- if (!existsSync(srcPath))
569
- return;
570
- const block = extractManagedBlock(readFileSync(srcPath, 'utf8'));
571
- if (!block)
572
- return;
573
- const { action } = syncManagedBlock(rootDir, block, PRETTIERIGNORE_FILE);
574
- if (action === 'unchanged')
575
- return;
576
- console.log(action === 'created'
577
- ? '✔ `.prettierignore` creado con el ignore del cerebro (`.claude/`) — evita falsos conflictos por formato en futuros updates.'
578
- : '✔ `.prettierignore`: bloque del kit sincronizado (`.claude/` fuera del formatter).');
579
- }
580
- catch {
581
- console.warn('Aviso: no se pudo sincronizar `.prettierignore`; el cerebro se instaló igual.');
582
- }
583
- }
584
629
  /**
585
630
  * The kit's own test folder. It stopped shipping to derivatives (`BND-003`): those tests
586
631
  * validate KIT tooling, and inside a derivative they ran against a `src/` that may have
@@ -878,10 +923,11 @@ export async function runUpdate(flags, deps = {}) {
878
923
  if (flags.dryRun) {
879
924
  console.log('\n🔍 Vista previa: este repo aún no tiene lockfile. El primer `factory update` ' +
880
925
  'registra el cerebro; en el primer sync no hay conflictos posibles.');
926
+ await offerKitDeps(rootDir, readKitDeps(stagedDir), { dryRun: true });
881
927
  process.exitCode = 0;
882
928
  return;
883
929
  }
884
- await applyLegacy(rootDir, stagedDir, manifest);
930
+ await applyLegacy(rootDir, stagedDir, manifest, flags.yes);
885
931
  // Legacy auto-register succeeded → end-pointer (PVSKILL-002).
886
932
  printProvisionPointer({ assumeYes: flags.yes });
887
933
  return;
@@ -892,13 +938,17 @@ export async function runUpdate(flags, deps = {}) {
892
938
  ...oldLock.files.map((f) => f.path),
893
939
  ...manifest.files.map((f) => f.path),
894
940
  ]);
895
- 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
+ ]);
896
945
  const diff = diffLockfiles(oldLock, manifest, disk);
897
946
  // --dry-run: preview + STOP. No prompts, no apply, no lockfile, no commit.
898
947
  // Exit 2 when conflicts exist so the GUI branches on it (0 otherwise). `return`
899
948
  // runs the finally → tmpRoot is cleaned (no update-state was written).
900
949
  if (flags.dryRun) {
901
950
  reportDryRun(diff, manifest);
951
+ await offerKitDeps(rootDir, readKitDeps(stagedDir), { dryRun: true });
902
952
  process.exitCode = diff.conflicts.length > 0 ? DRY_RUN_CONFLICTS_EXIT : 0;
903
953
  return;
904
954
  }
@@ -928,7 +978,9 @@ export async function runUpdate(flags, deps = {}) {
928
978
  // (first entry from a stable lockfile, where `priorLock.beta` is absent).
929
979
  const stateManifest = flags.beta ? { ...manifest, beta: true } : manifest;
930
980
  writeUpdateState(rootDir, { stagedDir, backupDir, plan, newManifest: stateManifest });
981
+ const previousSettings = readPreviousSettings(rootDir, backupDir);
931
982
  applyPlan(rootDir, stagedDir, plan, backupDir);
983
+ restoreProjectSettingsKeys(rootDir, plan.writes, previousSettings, kitSettingsKeysOf(oldLock));
932
984
  // Lockfile written ONLY after a clean apply. The birth stamp is preserved
933
985
  // (agentKitVersion advances; factoryVersion stays frozen). With `--beta`,
934
986
  // `stampBeta` composes `stampBirth` and adds `beta:true`; a stable/plain
@@ -939,13 +991,23 @@ export async function runUpdate(flags, deps = {}) {
939
991
  // same commit as the rest of the update (and `CLAUDE.md` joins the staged paths —
940
992
  // in case B it is in `ignoreLocal`, so the plan never names it).
941
993
  const claudeMdInserted = applyClaudeMdRetrofit(rootDir, claudeMd);
942
- const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
943
- await maybeCommitBrain(rootDir, manifest.version, [...plan.writes, ...plan.deletes, ...(claudeMdInserted ? [CLAUDE_MD_FILE] : [])], flags);
994
+ const kitExamples = refreshKitExamples(rootDir, stagedDir, manifest.files.map((f) => f.path));
995
+ const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest);
996
+ const prune = await planPruneMaintenance(rootDir, stagedDir, manifest.files.map((f) => f.path));
997
+ await maybeCommitBrain(rootDir, manifest.version, [
998
+ ...plan.writes,
999
+ ...plan.deletes,
1000
+ ...kitExamples,
1001
+ ...(claudeMdInserted ? [CLAUDE_MD_FILE] : []),
1002
+ ], flags, prune);
944
1003
  // `theirsConflicts` IS the overwritten-local list: same source whether the dev answered
945
1004
  // the prompt, passed `--theirs-all`, or ran headless — there is no second path. The
946
1005
  // package.json mutations ride the SAME closing summary, not three loose notices.
947
1006
  reportSummary(diff, manifest, theirsConflicts, pkgMutations);
948
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 });
949
1011
  // 🔴 The retrofit reminder, on EVERY applied update and regardless of how conflicts
950
1012
  // were resolved — `--theirs-all`, `--mine-all`, per-file answers or none at all. A
951
1013
  // retrofit is not a conflict: it is work this command never attempted, living outside
@@ -1023,7 +1085,8 @@ async function confirmLegacy(profile, assumeYes) {
1023
1085
  name: 'proceed',
1024
1086
  message: `Este repo no tiene lockfile. Se hará un auto-registro por paths (perfil \`${profile}\`).\n` +
1025
1087
  `${detected}\nLos archivos del cerebro que coincidan se sobrescribirán con la última ` +
1026
- 'versión; tu `CLAUDE.md` se conserva si ya existe. ¿Continuar?',
1088
+ 'versión; tu `CLAUDE.md` se conserva si ya existe, y también tus archivos fuera de ' +
1089
+ '`.claude/` y `scripts/tools/` (la versión del kit queda al lado como `.kit-example`). ¿Continuar?',
1027
1090
  initial: false,
1028
1091
  });
1029
1092
  if (!proceed) {
@@ -1036,31 +1099,39 @@ async function confirmLegacy(profile, assumeYes) {
1036
1099
  * NOT deleted on the first sync (design §6.3). Builds the lockfile from the
1037
1100
  * normalized hashes of the installed files.
1038
1101
  */
1039
- async function applyLegacy(rootDir, stagedDir, manifest) {
1102
+ async function applyLegacy(rootDir, stagedDir, manifest, assumeYes) {
1040
1103
  const repoPaths = new Set(collectClaudePaths(rootDir));
1041
1104
  const plan = planAutoRegister(manifest, repoPaths);
1042
- // Everything in the manifest is written (kit-owned overwrite + adds). No
1043
- // deletes on the first sync. CLAUDE.md is dev-owned (rama B / write-if-absent):
1044
- // if it already exists on disk it is preserved (filtered out of writes); only
1045
- // written when absent. The lockfile still records the manifest's CLAUDE.md hash,
1046
- // so a preserved dev CLAUDE.md reads as "locally edited" on future updates →
1047
- // always ignoreLocal (rama A), never silently clobbered.
1105
+ // The brain is written whole (kit-owned overwrite + adds). No deletes on the
1106
+ // first sync. CLAUDE.md is dev-owned (rama B / write-if-absent): if it already
1107
+ // exists on disk it is preserved (filtered out of writes); only written when
1108
+ // absent. The lockfile still records the manifest's CLAUDE.md hash, so a
1109
+ // preserved dev CLAUDE.md reads as "locally edited" on future updates → always
1110
+ // ignoreLocal (rama A), never silently clobbered. The other kit files outside
1111
+ // the brain get the same treatment as in `add` (#76): one the repo already has
1112
+ // with its own content is kept, the kit's copy goes to `<path>.kit-example`, and
1113
+ // the lockfile records the kit hash (ignoreLocal until the kit changes it).
1048
1114
  const claudeMdExists = existsSync(path.join(rootDir, CLAUDE_MD_FILE));
1049
- const writes = manifest.files
1050
- .map((f) => f.path)
1051
- .filter((p) => !(p === CLAUDE_MD_FILE && claudeMdExists));
1115
+ const { writes, kept } = partitionInitialWrites(rootDir, manifest);
1052
1116
  const backupDir = defaultBackupDir(path.dirname(stagedDir));
1117
+ const previousSettings = readPreviousSettings(rootDir, backupDir);
1053
1118
  applyPlan(rootDir, stagedDir, { writes, deletes: [] }, backupDir);
1119
+ restoreProjectSettingsKeys(rootDir, writes, previousSettings, LEGACY_KIT_SETTINGS_KEYS);
1120
+ writeKitExamples(rootDir, stagedDir, kept);
1054
1121
  // The lockfile = the manifest (its hashes are already the normalized hashes of
1055
1122
  // the just-installed files) PLUS the birth stamp. A legacy auto-register is the
1056
1123
  // first sync this repo records, so the birth seal = the manifest's version.
1057
1124
  writeLockfile(rootDir, { ...manifest, factoryVersion: manifest.version });
1058
- const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
1125
+ const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest);
1126
+ // No brain commit on this path; the block is still kept in step (the index is left to
1127
+ // `prune prepare`, which the pending notice names).
1128
+ reportPendingPrune(await planPruneMaintenance(rootDir, stagedDir, manifest.files.map((f) => f.path)));
1059
1129
  reportPkgMutations(pkgMutations);
1060
1130
  if (claudeMdExists) {
1061
1131
  console.log('\nConservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
1062
1132
  '(`@.claude/rules/*`); corre `factory doctor` para detectar rules sin importar.');
1063
1133
  }
1134
+ reportKeptRepoFiles(kept);
1064
1135
  if (plan.ambiguous.length > 0) {
1065
1136
  console.log('\nArchivos en `.claude/` que no están en el manifest (revisar manualmente):');
1066
1137
  for (const p of plan.ambiguous)
@@ -1068,4 +1139,6 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
1068
1139
  }
1069
1140
  console.log(`\n✔ Auto-registro completo (v${manifest.version}, perfil ${manifest.profile}): ` +
1070
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 });
1071
1144
  }