@timekast/factory 0.1.9 → 1.1.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.
@@ -1,53 +1,102 @@
1
1
  /**
2
- * `factory add` — install the `core` brain into an existing repo (design §6.2).
2
+ * `factory add` — install the brain (`.claude/` + tracked scripts) into an
3
+ * existing repo (design §6.2).
3
4
  *
4
- * Flow: detect a git repo (cwd or ancestor) refuse with an actionable message
5
- * if none preflight (gh) download the `core` profile (literal, never `full`)
6
- * extract to temp + validate the embedded manifest move the staged contents
7
- * into the cwd (only the manifest's files) → write `.timekast/lockfile.json`.
5
+ * Profile: full-brain is the DEFAULT. `add` auto-detects from the target's
6
+ * `package.json` a `factoryVersion` field means the repo was born from the
7
+ * Factory boilerplate (sk-* + SK.md apply) `full`; its absence (non-Node repo,
8
+ * or a Node repo not derived from the kit) → `core`. `--full`/`--core` override.
9
+ *
10
+ * Flow: require a git repo → GATE on a pre-existing brain (a lockfile → reject:
11
+ * use `update`; a `.claude/` without lockfile → redirect to `update`, where the
12
+ * legacy auto-register lives with its safeguards) → preflight (gh) → resolve
13
+ * profile → download + stage + validate the manifest → install ONLY the manifest
14
+ * files via `applyPlan` (so a `full` tarball never lays down `src/`; `track`
15
+ * excludes it) → write `.timekast/lockfile.json` → ensure `factory:update` (H5).
8
16
  *
9
17
  * Invariants:
10
- * - `add` ONLY ever installs `core`. The profile is the literal `PROFILES.core`,
11
- * never a flag `full` is structurally unreachable from this command.
12
- * - `add` never touches `src/`, `pj-*`, or any file outside the `core` manifest.
13
- * The `core` tarball contains only `core` files, so moving its contents in
14
- * cannot reach dev-owned paths; cwd files not in the tarball stay byte-identical.
15
- * ONE surgical exception (H5): if the repo is a Node project a `package.json`
16
- * sits next to the installed `.claude/` `add` inserts the `factory:update`
17
- * script (a single key, never overwriting a divergent value) so the dev gets
18
- * `pnpm factory:update`, matching the `full` profile. A non-Node repo
19
- * (Python/Flutter/Go) gets NO `package.json` created — it updates via
20
- * `npx @timekast/factory update` (or a global install of the CLI).
18
+ * - `add` never touches `src/`, `pj-*`, or any file outside the manifest. It
19
+ * installs the manifest's files (`.claude/` + tracked scripts + CLAUDE.md),
20
+ * never the raw tarball payload so reusing the `full` tarball cannot reach
21
+ * `src/`. cwd files not in the manifest stay byte-identical.
22
+ * - CLAUDE.md is dev-owned (write-if-absent): if it already exists on disk it
23
+ * is preserved (filtered out of writes) + a warning; only written when absent.
24
+ * - `add` is for a repo WITHOUT the brain. A repo that already has `.claude/`
25
+ * or a lockfile is routed to `update` (never a raw re-install).
21
26
  * - `add` overwrites manifest files without prompting — the conflict prompt is
22
- * exclusive to `update` (DIST-005). Documented in the command's `--help`.
23
- * - Atomicity (§7.5): extract + validate in a temp dir before touching the cwd;
24
- * a failure mid-flight never leaves a partial `.claude/`.
27
+ * exclusive to `update` (DIST-005).
28
+ * - Atomicity (§7.5): extract + validate in a temp dir before touching the cwd.
25
29
  *
26
30
  * Expected failures throw `CLIError`; the top-level handler prints + exits.
27
31
  */
28
32
  import { existsSync, mkdtempSync, rmSync } from 'node:fs';
29
33
  import { tmpdir } from 'node:os';
30
34
  import path from 'node:path';
35
+ import { applyPlan, defaultBackupDir, validateStagedManifest } from '../lib/atomic-swap.js';
31
36
  import { CLIError } from '../lib/cli-error.js';
32
- import { PROFILES } from '../lib/constants.js';
33
- import { writeInitialLockfile } from '../lib/lockfile.js';
34
- import { insertFactoryUpdateScript } from '../lib/package-json.js';
37
+ import { CLAUDE_MD_FILE, PROFILES } from '../lib/constants.js';
38
+ import { hasLockfile, writeInitialLockfile } from '../lib/lockfile.js';
39
+ import { detectProfile, insertFactoryUpdateScript, } from '../lib/package-json.js';
35
40
  import { runPreflight } from '../lib/preflight.js';
36
41
  import { detectRepo } from '../lib/repo-detection.js';
37
- import { downloadProfileTarball, moveContentsInto, stageProfileTarball } from '../lib/unpack.js';
42
+ import { downloadProfileTarball, stageProfileTarball } from '../lib/unpack.js';
43
+ import { runUpdate } from './update.js';
38
44
  /** Message shown when `add` runs outside any git repo context. */
39
45
  const NO_REPO_MESSAGE = 'No hay repositorio git aquí. Ejecuta `git init` primero, o usa `factory new` para crear un proyecto desde cero.';
40
- export async function runAdd() {
46
+ /** Parse `add`'s argv slice into flags. Unknown flags are ignored here. */
47
+ export function parseAddFlags(argv) {
48
+ return { core: argv.includes('--core'), full: argv.includes('--full') };
49
+ }
50
+ export async function runAdd(flags = { core: false, full: false }) {
41
51
  const cwd = process.cwd();
52
+ if (flags.core && flags.full) {
53
+ throw new CLIError('No puedes combinar `--core` con `--full`.');
54
+ }
42
55
  // 1. Require a git repo context (cwd or any ancestor). Refuse before any
43
- // network call or write — nothing is touched when there is no repo.
44
- const { hasRepo } = detectRepo(cwd);
56
+ // network call or write — nothing is touched when there is no repo. The
57
+ // brain lives at the REPO ROOT, so all gating + install target `root`, not
58
+ // `cwd` — running `add` from a subdir must not plant a nested second brain.
59
+ const { hasRepo, repoRoot } = detectRepo(cwd);
45
60
  if (!hasRepo) {
46
61
  throw new CLIError(NO_REPO_MESSAGE);
47
62
  }
48
- // 2. Preflight: gh installed + authed + org member. Runs before any download.
63
+ const root = repoRoot ?? cwd;
64
+ // 2. Gate on a pre-existing brain (checked at the repo root, not cwd). `add` is
65
+ // for a repo WITHOUT one; anything else routes to `update` so the raw
66
+ // `applyPlan` install never clobbers an old `.claude/` (leaving orphans, no
67
+ // confirm) — `update` has the legacy auto-register with its safeguards.
68
+ if (hasLockfile(root)) {
69
+ throw new CLIError('Este repo ya está gestionado por el Factory (tiene `.timekast/lockfile.json`). ' +
70
+ 'Para actualizar el cerebro usa `factory update`.');
71
+ }
72
+ if (existsSync(path.join(root, '.claude'))) {
73
+ // `update` opera sobre process.cwd(); si la raíz es un ancestro, no podemos
74
+ // redirigir limpio desde un subdir → pedir correr desde la raíz.
75
+ if (path.resolve(root) !== path.resolve(cwd)) {
76
+ throw new CLIError(`Este repo ya tiene un cerebro \`.claude/\` en su raíz (${root}). ` +
77
+ 'Corre `factory update` desde la raíz del repo.');
78
+ }
79
+ console.log('Este repo ya tiene `.claude/` (sin lockfile). Te redirijo a `factory update`, ' +
80
+ 'que lo registra de forma segura (preserva tus archivos propios y tu CLAUDE.md).\n');
81
+ await runUpdate({
82
+ core: flags.core,
83
+ full: flags.full,
84
+ theirsAll: false,
85
+ mineAll: false,
86
+ resume: false,
87
+ verify: false,
88
+ });
89
+ return;
90
+ }
91
+ // 3. Preflight: gh installed + authed + org member. Runs before any download.
49
92
  await runPreflight();
50
- // 3. Download + unpack into a temp dir, validate, then move into place (§7.5).
93
+ // 4. Resolve profile: flag override, else auto-detect from package.json (at root).
94
+ const profile = flags.full
95
+ ? PROFILES.full
96
+ : flags.core
97
+ ? PROFILES.core
98
+ : detectProfile(root);
99
+ // 5. Download + unpack into a temp dir, validate, then install into place (§7.5).
51
100
  const tmpDir = mkdtempSync(path.join(tmpdir(), 'tk-add-'));
52
101
  const cleanup = () => {
53
102
  try {
@@ -63,17 +112,33 @@ export async function runAdd() {
63
112
  };
64
113
  process.once('SIGINT', onSignal);
65
114
  process.once('SIGTERM', onSignal);
115
+ let version;
66
116
  try {
67
- // `add` is structurally `core`-only: the literal is passed, never a flag.
68
- const tarball = await downloadProfileTarball(PROFILES.core, tmpDir);
69
- // Extract + validate the embedded manifest before touching the destination.
117
+ const tarball = await downloadProfileTarball(profile, tmpDir);
70
118
  const { stagedDir, manifestRaw } = await stageProfileTarball(tarball, tmpDir);
71
- // Move only the tarball's (core) contents into the cwd. Files outside the
72
- // manifest src/, package.json, pj-* are never visited, so they stay
73
- // byte-identical.
74
- moveContentsInto(stagedDir, cwd);
119
+ const manifest = validateStagedManifest(stagedDir, manifestRaw);
120
+ // Guard: the downloaded tarball must actually be the profile we asked for. A
121
+ // mispublished asset (e.g. a `full` tag carrying a `core` manifest) would
122
+ // otherwise write a lockfile for the wrong profile. Refuse loudly.
123
+ if (manifest.profile !== profile) {
124
+ throw new CLIError(`El tarball descargado declara perfil \`${manifest.profile}\` pero se pidió \`${profile}\`. ` +
125
+ 'El release está mal publicado; no se modificó nada.');
126
+ }
127
+ version = manifest.version;
128
+ // Install ONLY the manifest's files (never the raw tarball payload — so a
129
+ // `full` tarball cannot lay down `src/`). CLAUDE.md is dev-owned: filtered out
130
+ // if it already exists on disk (write-if-absent + warning).
131
+ const claudeMdExists = existsSync(path.join(root, CLAUDE_MD_FILE));
132
+ const writes = manifest.files
133
+ .map((f) => f.path)
134
+ .filter((p) => !(p === CLAUDE_MD_FILE && claudeMdExists));
135
+ applyPlan(root, stagedDir, { writes, deletes: [] }, defaultBackupDir(tmpDir));
75
136
  // Write the lockfile = the tarball's embedded manifest (no hash recompute).
76
- writeInitialLockfile(cwd, manifestRaw);
137
+ writeInitialLockfile(root, manifestRaw);
138
+ if (claudeMdExists) {
139
+ console.log('Conservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
140
+ '(`@.claude/rules/*`); corre `factory doctor` para detectar rules sin importar.');
141
+ }
77
142
  }
78
143
  finally {
79
144
  process.removeListener('SIGINT', onSignal);
@@ -84,7 +149,7 @@ export async function runAdd() {
84
149
  // surgically inserting the script into the EXISTING package.json. Non-fatal — a
85
150
  // missing/invalid package.json just means the dev uses `npx` (the brain is already
86
151
  // installed). We NEVER create a package.json in a non-Node repo.
87
- const pkgPath = path.join(cwd, 'package.json');
152
+ const pkgPath = path.join(root, 'package.json');
88
153
  let scriptAction = 'none';
89
154
  if (existsSync(pkgPath)) {
90
155
  try {
@@ -94,7 +159,7 @@ export async function runAdd() {
94
159
  scriptAction = 'none'; // invalid package.json — skip the alias, brain still installed
95
160
  }
96
161
  }
97
- console.log('\n✔ Cerebro `core` instalado. Lockfile escrito en `.timekast/lockfile.json`.');
162
+ console.log(`\n✔ Cerebro \`${profile}\` v${version} instalado. Lockfile escrito en \`.timekast/lockfile.json\`.`);
98
163
  if (scriptAction === 'added' || scriptAction === 'already-correct') {
99
164
  console.log('Para actualizar: `pnpm factory:update` (alias de `npx @timekast/factory update`).');
100
165
  }
@@ -16,9 +16,20 @@
16
16
  * No lockfile (pre-CLI repo) → delegate to the "run factory:update" advice, exit
17
17
  * 0, never abort (same pattern as `status` / the §8 edge case).
18
18
  */
19
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
19
+ import { existsSync, readFileSync, statSync } from 'node:fs';
20
20
  import path from 'node:path';
21
+ import { collectClaudePaths } from '../lib/claude-paths.js';
22
+ import { CLAUDE_MD_FILE } from '../lib/constants.js';
21
23
  import { hasLockfile, normalizeThenHash, readLockfile } from '../lib/lockfile.js';
24
+ import { detectRepo } from '../lib/repo-detection.js';
25
+ /** Matches a flat always-on rule file shipped by the kit (`.claude/rules/<name>.md`). */
26
+ const RULE_PATH_RE = /^\.claude\/rules\/[^/]+\.md$/;
27
+ /**
28
+ * Matches ONLY the reserved on-demand rules heading (`Rules (on-demand reference)`
29
+ * and close variants), not any heading that merely contains "on-demand" (e.g.
30
+ * `## On-demand migration notes`).
31
+ */
32
+ const ON_DEMAND_HEADING_RE = /rules?\s*\(\s*on-demand/i;
22
33
  /**
23
34
  * The A1 security notice (design §1 + §11, decision A1). Literal text emitted at
24
35
  * the end of every `doctor` run so the dev always sees the `src/` responsibility
@@ -33,27 +44,53 @@ export const A1_WARNING = 'Aviso de seguridad (A1): el boilerplate `src/` y sus
33
44
  '(update agentico con merge inteligente), aún no disponible. Mantén `src/` y tus ' +
34
45
  'dependencias al día por tu cuenta mientras tanto.';
35
46
  /**
36
- * Recursively collect repo-relative POSIX paths of files under `.claude/`. Only
37
- * descends into `.claude/`; `src/` and everything else is invisible (design
38
- * §7.2). Mirrors the `update` engine's `collectClaudePaths`.
47
+ * Concatenate the body of the reserved "on-demand reference" section of CLAUDE.md
48
+ * the lines under a heading matching `ON_DEMAND_HEADING_RE`, until the next
49
+ * heading of the SAME or HIGHER level (a deeper nested heading stays inside the
50
+ * section). Rules listed there are loaded by skills on demand (NOT via `@import`),
51
+ * so they must not be flagged as unimported. Empty string when no such section.
52
+ *
53
+ * Level-aware (a nested `###` under a `##` on-demand heading does not end it) and
54
+ * specific (only the reserved heading starts it, not any "on-demand" mention).
39
55
  */
40
- function collectClaudePaths(rootDir) {
56
+ function onDemandSectionText(claudeMd) {
41
57
  const out = [];
42
- const walk = (rel) => {
43
- const abs = path.join(rootDir, rel);
44
- if (!existsSync(abs))
45
- return;
46
- if (statSync(abs).isDirectory()) {
47
- for (const entry of readdirSync(abs)) {
48
- walk(path.posix.join(rel, entry));
58
+ let sectionLevel = 0; // 0 = not currently inside an on-demand section
59
+ for (const line of claudeMd.split('\n')) {
60
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
61
+ if (heading) {
62
+ const level = heading[1].length;
63
+ const isOnDemand = ON_DEMAND_HEADING_RE.test(heading[2]);
64
+ if (sectionLevel === 0) {
65
+ if (isOnDemand)
66
+ sectionLevel = level; // enter the section
49
67
  }
68
+ else if (level <= sectionLevel) {
69
+ // sibling/higher heading ends the section (unless it is on-demand too)
70
+ sectionLevel = isOnDemand ? level : 0;
71
+ }
72
+ // a deeper heading (level > sectionLevel) stays inside → no change
73
+ continue;
50
74
  }
51
- else {
52
- out.push(rel);
53
- }
54
- };
55
- walk('.claude');
56
- return out;
75
+ if (sectionLevel > 0)
76
+ out.push(line);
77
+ }
78
+ return out.join('\n');
79
+ }
80
+ /**
81
+ * Collect the rule paths a CLAUDE.md actually `@import`s — a line whose trimmed
82
+ * text is `@<path>` (the real import directive). A prose mention or a fenced
83
+ * code block ("no agregues `@.claude/rules/SK.md`") is NOT a bare directive line,
84
+ * so it does not count as imported.
85
+ */
86
+ function importedRulePaths(claudeMd) {
87
+ const paths = new Set();
88
+ for (const raw of claudeMd.split('\n')) {
89
+ const line = raw.trim();
90
+ if (line.startsWith('@'))
91
+ paths.add(line.slice(1).trim());
92
+ }
93
+ return paths;
57
94
  }
58
95
  /**
59
96
  * Compute the doctor diagnosis. No lockfile → `noLockfile: true` (the caller
@@ -63,9 +100,19 @@ function collectClaudePaths(rootDir) {
63
100
  * - conflict = tracked path whose normalized disk hash != recorded hash.
64
101
  */
65
102
  export function computeDoctor(deps = {}) {
66
- const rootDir = deps.rootDir ?? process.cwd();
103
+ // Diagnose at the REPO ROOT (where the brain + lockfile live), not cwd — so
104
+ // `doctor` from a subdir of a managed repo reports the real state instead of
105
+ // "no lockfile" (consistency with `add`/`update`). Tests inject `rootDir`.
106
+ const rootDir = deps.rootDir ?? detectRepo(process.cwd()).repoRoot ?? process.cwd();
67
107
  if (!hasLockfile(rootDir)) {
68
- return { orphans: [], conflicts: [], a1Warning: A1_WARNING, noLockfile: true };
108
+ return {
109
+ orphans: [],
110
+ conflicts: [],
111
+ unimportedRules: [],
112
+ claudeMdMissing: false,
113
+ a1Warning: A1_WARNING,
114
+ noLockfile: true,
115
+ };
69
116
  }
70
117
  const lock = readLockfile(rootDir);
71
118
  const manifestPaths = new Set(lock.files.map((f) => f.path));
@@ -85,7 +132,37 @@ export function computeDoctor(deps = {}) {
85
132
  }
86
133
  }
87
134
  conflicts.sort();
88
- return { orphans, conflicts, a1Warning: A1_WARNING, noLockfile: false };
135
+ // Unimported kit rules: a kit-shipped always-on rule (`.claude/rules/*.md`)
136
+ // that is NOT `@import`ed by CLAUDE.md → it does NOT load at runtime. The check
137
+ // is strict on the `@import` directive (a prose mention does NOT count — a
138
+ // mentioned-but-not-imported always-on rule is dead). Exception: a rule listed
139
+ // under an "on-demand" heading is loaded by skills on demand, not via @import,
140
+ // so it is excluded. No CLAUDE.md on disk in a managed repo → can't check, and
141
+ // every rule is dead → flagged via `claudeMdMissing` instead.
142
+ const unimportedRules = [];
143
+ const claudeMdPath = path.join(rootDir, CLAUDE_MD_FILE);
144
+ const claudeMdMissing = !existsSync(claudeMdPath);
145
+ if (!claudeMdMissing) {
146
+ const claudeMd = readFileSync(claudeMdPath, 'utf8');
147
+ const imported = importedRulePaths(claudeMd); // bare `@<path>` directive lines
148
+ const onDemand = onDemandSectionText(claudeMd);
149
+ for (const entry of lock.files) {
150
+ if (!RULE_PATH_RE.test(entry.path))
151
+ continue;
152
+ const isOnDemand = onDemand.includes(entry.path);
153
+ if (!imported.has(entry.path) && !isOnDemand)
154
+ unimportedRules.push(entry.path);
155
+ }
156
+ unimportedRules.sort();
157
+ }
158
+ return {
159
+ orphans,
160
+ conflicts,
161
+ unimportedRules,
162
+ claudeMdMissing,
163
+ a1Warning: A1_WARNING,
164
+ noLockfile: false,
165
+ };
89
166
  }
90
167
  /** Render the doctor result to the console. The A1 notice is ALWAYS last. */
91
168
  function renderDoctor(result) {
@@ -96,10 +173,17 @@ function renderDoctor(result) {
96
173
  console.log(`\n${result.a1Warning}`);
97
174
  return;
98
175
  }
99
- if (result.orphans.length === 0 && result.conflicts.length === 0) {
100
- console.log('✔ Sin huérfanos ni conflictos pendientes.');
176
+ if (result.orphans.length === 0 &&
177
+ result.conflicts.length === 0 &&
178
+ result.unimportedRules.length === 0 &&
179
+ !result.claudeMdMissing) {
180
+ console.log('✔ Sin huérfanos, conflictos ni rules sin importar.');
101
181
  }
102
182
  else {
183
+ if (result.claudeMdMissing) {
184
+ console.log('🔴 Falta `CLAUDE.md` en la raíz: las rules del kit NO se cargan (el auto-load es por ' +
185
+ '`@import` desde ese archivo). Recupéralo (`git checkout -- CLAUDE.md`) o corre `factory update`.');
186
+ }
103
187
  if (result.conflicts.length > 0) {
104
188
  console.log('Modificados localmente respecto al lockfile (conflicto si el Factory los actualiza):');
105
189
  for (const p of result.conflicts) {
@@ -107,6 +191,12 @@ function renderDoctor(result) {
107
191
  }
108
192
  console.log(' Sugerencia: corre `factory:update --verify` para re-chequear, o `factory:update` para reconciliar.');
109
193
  }
194
+ if (result.unimportedRules.length > 0) {
195
+ console.log('\nRules del kit que tu `CLAUDE.md` no importa (no se cargan en runtime):');
196
+ for (const p of result.unimportedRules) {
197
+ console.log(` • ${p} — agrega \`@${p}\` a tu CLAUDE.md o no se aplicará`);
198
+ }
199
+ }
110
200
  if (result.orphans.length > 0) {
111
201
  console.log('\nArchivos huérfanos (en `.claude/` fuera del manifest instalado):');
112
202
  for (const p of result.orphans) {
@@ -17,7 +17,7 @@ import { execa } from 'execa';
17
17
  import prompts from 'prompts';
18
18
  import { CLIError } from '../lib/cli-error.js';
19
19
  import { FACTORY_ORG, PROFILES } from '../lib/constants.js';
20
- import { writeInitialLockfile } from '../lib/lockfile.js';
20
+ import { parseLockfile, writeInitialLockfile } from '../lib/lockfile.js';
21
21
  import { applyPackageJsonEdits } from '../lib/package-json.js';
22
22
  import { runPreflight } from '../lib/preflight.js';
23
23
  import { downloadProfileTarball, moveContentsInto, stageProfileTarball } from '../lib/unpack.js';
@@ -87,10 +87,12 @@ export async function runNew(name) {
87
87
  };
88
88
  process.once('SIGINT', onSignal);
89
89
  process.once('SIGTERM', onSignal);
90
+ let version = '';
90
91
  try {
91
92
  const tarball = await downloadProfileTarball(profile, tmpDir);
92
93
  // Extract + validate the embedded manifest before touching the destination.
93
94
  const { stagedDir, manifestRaw } = await stageProfileTarball(tarball, tmpDir);
95
+ version = parseLockfile(manifestRaw, 'manifest').version;
94
96
  // Move contents into ./<name>/ (without the Factory's git lineage).
95
97
  mkdirSync(destDir, { recursive: true });
96
98
  moveContentsInto(stagedDir, destDir);
@@ -126,6 +128,7 @@ export async function runNew(name) {
126
128
  process.removeListener('SIGTERM', onSignal);
127
129
  cleanup();
128
130
  }
129
- console.log(`\n✔ Proyecto \`${validName}\` listo. Repo: ${FACTORY_ORG}/${validName}`);
131
+ console.log(`\n✔ Proyecto \`${validName}\` listo (perfil \`${profile}\`, cerebro v${version}). ` +
132
+ `Repo: ${FACTORY_ORG}/${validName}`);
130
133
  console.log(` cd ${validName}`);
131
134
  }
@@ -4,24 +4,27 @@
4
4
  *
5
5
  * Flow:
6
6
  * 1. Preflight (gh) — reused from DIST-003, never reimplemented.
7
- * 2. Read the lockfile the STICKY profile (`core` | `full`). Download the
8
- * tarball of the SAME profile (never cross-grade). A `core` repo therefore
9
- * never receives `sk-*` (not in the core manifest); a new `kb-*` in the
10
- * core manifest does land.
7
+ * 2. Resolve the profile. With a lockfile it is STICKY (`core` | `full`),
8
+ * EXCEPT `--full` cross-grades a `core` repo → `full` (additive: adds
9
+ * sk-* + SK.md, swaps CLAUDE.md to the full one); `--core` on a `full` repo
10
+ * is a rejected downgrade. With no lockfile (legacy) the profile is
11
+ * auto-detected from package.json (`factoryVersion` → full, else core),
12
+ * overridable with `--full`/`--core`. Download the tarball of that profile.
11
13
  * 3. Stage + validate the embedded manifest (the swap preflight rejects a
12
14
  * corrupt/empty manifest before touching the FS).
13
15
  * 4. Diff the new manifest against the lockfile → the 4 buckets + conflicts.
14
16
  * 5. Resolve conflicts: `--theirs-all` / `--mine-all`, else prompt PER FILE
15
17
  * with NO default option (design §7.3) — the file is not modified until the
16
- * dev chooses.
18
+ * dev chooses. CLAUDE.md never conflicts (dev-owned → ignoreLocal).
17
19
  * 6. Apply atomically (backup + rollback guard, design §7.5).
18
20
  * 7. Write the lockfile (= the new manifest) ONLY after a clean apply.
19
21
  * 8. Surgically ensure `factory:update` is in package.json (design §7.4).
20
22
  *
21
23
  * Auto-register (no lockfile, pre-CLI repo, design §6.3): path-match — repo
22
24
  * paths matching the manifest are kit-owned (overwrite); `.claude/` paths absent
23
- * from the manifest are logged "ambiguous" and NOT deleted on the first sync.
24
- * The dev is warned before proceeding.
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.
25
28
  *
26
29
  * `--verify`: compares disk vs lockfile and reports drift WITHOUT modifying
27
30
  * anything. `--resume`: re-applies a persisted mid-flight state without
@@ -30,15 +33,18 @@
30
33
  * `update` NEVER touches `src/` — files outside the lockfile/manifest are
31
34
  * invisible (design §9 out-of-scope).
32
35
  */
33
- import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
36
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
34
37
  import { tmpdir } from 'node:os';
35
38
  import path from 'node:path';
36
39
  import prompts from 'prompts';
37
40
  import { applyPlan, clearUpdateState, defaultBackupDir, hasUpdateState, readUpdateState, validateStagedManifest, writeUpdateState, } from '../lib/atomic-swap.js';
41
+ import { collectClaudePaths } from '../lib/claude-paths.js';
38
42
  import { CLIError } from '../lib/cli-error.js';
43
+ import { CLAUDE_MD_FILE, PROFILES } from '../lib/constants.js';
39
44
  import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
40
- import { insertFactoryUpdateScript, setAgentKitVersion } from '../lib/package-json.js';
45
+ import { detectProfile, insertFactoryUpdateScript, setAgentKitVersion, } from '../lib/package-json.js';
41
46
  import { runPreflight } from '../lib/preflight.js';
47
+ import { detectRepo } from '../lib/repo-detection.js';
42
48
  import { downloadProfileTarball, stageProfileTarball } from '../lib/unpack.js';
43
49
  /** Parse `update`'s argv slice into flags. Unknown flags are ignored here. */
44
50
  export function parseUpdateFlags(argv) {
@@ -47,6 +53,8 @@ export function parseUpdateFlags(argv) {
47
53
  mineAll: argv.includes('--mine-all'),
48
54
  resume: argv.includes('--resume'),
49
55
  verify: argv.includes('--verify'),
56
+ core: argv.includes('--core'),
57
+ full: argv.includes('--full'),
50
58
  };
51
59
  }
52
60
  /** Default acquire: download the sticky-profile tarball, stage + validate it. */
@@ -57,30 +65,6 @@ async function acquireFromRelease(profile) {
57
65
  const manifest = validateStagedManifest(stagedDir, manifestRaw);
58
66
  return { stagedDir, manifest, tmpRoot };
59
67
  }
60
- /**
61
- * Recursively collect repo-relative paths of files under `.claude/` plus any
62
- * tracked root files the manifest references at the top level. Used both for
63
- * disk hashing and auto-register path-match. Only descends into `.claude/`;
64
- * `src/` and everything else is invisible (design §7.2).
65
- */
66
- function collectClaudePaths(rootDir) {
67
- const out = [];
68
- const walk = (rel) => {
69
- const abs = path.join(rootDir, rel);
70
- if (!existsSync(abs))
71
- return;
72
- if (statSync(abs).isDirectory()) {
73
- for (const entry of readdirSync(abs)) {
74
- walk(path.posix.join(rel, entry));
75
- }
76
- }
77
- else {
78
- out.push(rel);
79
- }
80
- };
81
- walk('.claude');
82
- return out;
83
- }
84
68
  /**
85
69
  * Hash the disk content (normalized) of every path in `paths` that exists.
86
70
  * A missing file is simply absent from the returned map.
@@ -216,11 +200,20 @@ function warnOnScriptConflict(action, _pkgPath) {
216
200
  * semver) are untouched.
217
201
  */
218
202
  function maintainDerivedPkg(pkgPath, agentKitVersion) {
219
- warnOnScriptConflict(insertFactoryUpdateScript(pkgPath).action, pkgPath);
220
- setAgentKitVersion(pkgPath, agentKitVersion);
203
+ // Tolerant (mirrors `add`): a malformed package.json must NOT throw AFTER the
204
+ // brain was already applied + the lockfile written — the install succeeded; the
205
+ // script/version mirror is best-effort. Warn and move on.
206
+ try {
207
+ warnOnScriptConflict(insertFactoryUpdateScript(pkgPath).action, pkgPath);
208
+ setAgentKitVersion(pkgPath, agentKitVersion);
209
+ }
210
+ catch {
211
+ console.warn('Aviso: no se pudo actualizar package.json (¿JSON inválido?); el cerebro se instaló igual. ' +
212
+ 'Corrige package.json y vuelve a correr `factory update` para sincronizar agentKitVersion.');
213
+ }
221
214
  }
222
215
  /** Print the deletes + kept-retired (design §7.6 — visible) + a one-line summary. */
223
- function reportSummary(diff) {
216
+ function reportSummary(diff, manifest) {
224
217
  if (diff.deleteSilent.length > 0) {
225
218
  console.log('\nArchivos retirados por el Factory en esta versión:');
226
219
  for (const e of diff.deleteSilent)
@@ -233,7 +226,8 @@ function reportSummary(diff) {
233
226
  }
234
227
  const unchanged = diff.unchanged.length > 0 ? ` (${diff.unchanged.length} sin cambios)` : '';
235
228
  const kept = diff.keptRetiredLocal.length > 0 ? `, ${diff.keptRetiredLocal.length} conservados` : '';
236
- console.log(`\n✔ update: ${diff.add.length} agregados, ${diff.overwriteSilent.length} actualizados${unchanged}, ` +
229
+ console.log(`\n✔ update a v${manifest.version} (perfil ${manifest.profile}): ${diff.add.length} agregados, ` +
230
+ `${diff.overwriteSilent.length} actualizados${unchanged}, ` +
237
231
  `${diff.deleteSilent.length} retirados${kept}, ${diff.conflicts.length} conflictos.`);
238
232
  }
239
233
  /**
@@ -241,7 +235,19 @@ function reportSummary(diff) {
241
235
  * network seam (tests inject a local fixture).
242
236
  */
243
237
  export async function runUpdate(flags, deps = {}) {
244
- const rootDir = process.cwd();
238
+ // Operate on the REPO ROOT (the brain + lockfile live there), not cwd — running
239
+ // `update` from a subdir of a managed repo must not miss the lockfile and
240
+ // legacy-register a second nested brain (consistency with `add`).
241
+ const rootDir = detectRepo(process.cwd()).repoRoot ?? process.cwd();
242
+ // Mutually-exclusive flag validation FIRST — before --verify/--resume
243
+ // short-circuit, so an invalid combination is always rejected (not silently
244
+ // honored by verify/resume).
245
+ if (flags.theirsAll && flags.mineAll) {
246
+ throw new CLIError('No puedes combinar `--theirs-all` con `--mine-all`.');
247
+ }
248
+ if (flags.core && flags.full) {
249
+ throw new CLIError('No puedes combinar `--core` con `--full`.');
250
+ }
245
251
  // --verify and --resume short-circuit (no download).
246
252
  if (flags.verify) {
247
253
  runVerify(rootDir);
@@ -254,28 +260,46 @@ export async function runUpdate(flags, deps = {}) {
254
260
  runResume(rootDir);
255
261
  return;
256
262
  }
257
- if (flags.theirsAll && flags.mineAll) {
258
- throw new CLIError('No puedes combinar `--theirs-all` con `--mine-all`.');
259
- }
260
263
  // Preflight (gh) before any download — reused, not reimplemented.
261
264
  await runPreflight();
262
265
  const legacy = !hasLockfile(rootDir);
263
- // Sticky profile: a lockfile pins the installed profile; a legacy repo with no
264
- // lockfile defaults to `core` (the additive baseline a pre-CLI repo carries).
266
+ // Profile resolution:
267
+ // - legacy (no lockfile): auto-detect from package.json (`factoryVersion`
268
+ // full, else core), unless a flag forces it. This is the adoption path for
269
+ // pre-CLI derivatives, so full-brain is the default for Factory repos.
270
+ // - lockfile'd: sticky to the recorded profile, EXCEPT `--full` cross-grades
271
+ // a `core` repo → `full` (additive: adds sk-* + SK.md, swaps CLAUDE.md to the
272
+ // full one). `--core` on a `full` repo is a downgrade → rejected.
265
273
  let oldLock;
266
274
  let profile;
267
275
  if (legacy) {
268
- profile = 'core';
276
+ profile = flags.full ? PROFILES.full : flags.core ? PROFILES.core : detectProfile(rootDir);
269
277
  oldLock = { version: '0.0.0', profile, files: [] };
270
- await confirmLegacy();
278
+ await confirmLegacy(profile);
271
279
  }
272
280
  else {
273
281
  oldLock = readLockfile(rootDir);
274
- profile = oldLock.profile;
282
+ if (flags.full && oldLock.profile === PROFILES.core) {
283
+ profile = PROFILES.full; // cross-grade core → full (additive)
284
+ }
285
+ else if (flags.core && oldLock.profile === PROFILES.full) {
286
+ throw new CLIError('Este repo está registrado como `full`; el downgrade a `core` no está soportado ' +
287
+ '(implicaría borrar sk-*/SK.md). Si de verdad lo quieres, hazlo a mano.');
288
+ }
289
+ else {
290
+ profile = oldLock.profile; // sticky (--full on full / --core on core = refresh)
291
+ }
275
292
  }
276
293
  const acquire = deps.acquire ?? acquireFromRelease;
277
294
  const { stagedDir, manifest, tmpRoot } = await acquire(profile);
278
295
  try {
296
+ // Guard: the downloaded tarball must match the resolved profile. A mispublished
297
+ // asset (e.g. a `full` tag carrying a `core` manifest) would otherwise write a
298
+ // lockfile for the wrong profile or silently no-op a cross-grade. Refuse loudly.
299
+ if (manifest.profile !== profile) {
300
+ throw new CLIError(`El tarball descargado declara perfil \`${manifest.profile}\` pero se resolvió \`${profile}\`. ` +
301
+ 'El release está mal publicado; no se modificó nada.');
302
+ }
279
303
  if (legacy) {
280
304
  await applyLegacy(rootDir, stagedDir, manifest);
281
305
  return;
@@ -314,19 +338,31 @@ export async function runUpdate(flags, deps = {}) {
314
338
  const pkgPath = path.join(rootDir, 'package.json');
315
339
  if (existsSync(pkgPath))
316
340
  maintainDerivedPkg(pkgPath, manifest.version);
317
- reportSummary(diff);
341
+ reportSummary(diff, manifest);
318
342
  }
319
343
  finally {
320
- rmSync(tmpRoot, { recursive: true, force: true });
344
+ // Keep the staged tarball if an update-state survives (a post-apply failure —
345
+ // e.g. writeLockfile threw after applyPlan): the persisted state points into
346
+ // tmpRoot, so deleting it would strand `--resume` with a vanished stagedDir
347
+ // and no recovery. A clean run clears the state first → tmpRoot is removed.
348
+ if (!hasUpdateState(rootDir)) {
349
+ rmSync(tmpRoot, { recursive: true, force: true });
350
+ }
321
351
  }
322
352
  }
323
353
  /** Warn the dev before a legacy auto-register sync (design §8 edge case). */
324
- async function confirmLegacy() {
354
+ async function confirmLegacy(profile) {
355
+ const detected = profile === PROFILES.full
356
+ ? 'Detectado como derivado del Factory (`factoryVersion` en package.json) → perfil `full` ' +
357
+ '(incluye sk-* + SK.md). Usa `--core` si quieres solo el cerebro base.'
358
+ : 'Detectado como repo de otro stack (sin `factoryVersion`) → perfil `core` (sin sk-*). ' +
359
+ 'Usa `--full` si es un derivado Next del Factory.';
325
360
  const { proceed } = await prompts({
326
361
  type: 'confirm',
327
362
  name: 'proceed',
328
- message: 'Este repo no tiene lockfile. Se hará un auto-registro por paths. Los archivos del cerebro ' +
329
- 'que coincidan se sobrescribirán con la última versión. ¿Continuar?',
363
+ message: `Este repo no tiene lockfile. Se hará un auto-registro por paths (perfil \`${profile}\`).\n` +
364
+ `${detected}\nLos archivos del cerebro que coincidan se sobrescribirán con la última ` +
365
+ 'versión; tu `CLAUDE.md` se conserva si ya existe. ¿Continuar?',
330
366
  initial: false,
331
367
  });
332
368
  if (!proceed) {
@@ -343,8 +379,15 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
343
379
  const repoPaths = new Set(collectClaudePaths(rootDir));
344
380
  const plan = planAutoRegister(manifest, repoPaths);
345
381
  // Everything in the manifest is written (kit-owned overwrite + adds). No
346
- // deletes on the first sync.
347
- const writes = manifest.files.map((f) => f.path);
382
+ // deletes on the first sync. CLAUDE.md is dev-owned (rama B / write-if-absent):
383
+ // if it already exists on disk it is preserved (filtered out of writes); only
384
+ // written when absent. The lockfile still records the manifest's CLAUDE.md hash,
385
+ // so a preserved dev CLAUDE.md reads as "locally edited" on future updates →
386
+ // always ignoreLocal (rama A), never silently clobbered.
387
+ const claudeMdExists = existsSync(path.join(rootDir, CLAUDE_MD_FILE));
388
+ const writes = manifest.files
389
+ .map((f) => f.path)
390
+ .filter((p) => !(p === CLAUDE_MD_FILE && claudeMdExists));
348
391
  const backupDir = defaultBackupDir(path.dirname(stagedDir));
349
392
  applyPlan(rootDir, stagedDir, { writes, deletes: [] }, backupDir);
350
393
  // The lockfile = the manifest (its hashes are already the normalized hashes of
@@ -354,11 +397,15 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
354
397
  const pkgPath = path.join(rootDir, 'package.json');
355
398
  if (existsSync(pkgPath))
356
399
  maintainDerivedPkg(pkgPath, manifest.version);
400
+ if (claudeMdExists) {
401
+ console.log('\nConservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
402
+ '(`@.claude/rules/*`); corre `factory doctor` para detectar rules sin importar.');
403
+ }
357
404
  if (plan.ambiguous.length > 0) {
358
405
  console.log('\nArchivos en `.claude/` que no están en el manifest (revisar manualmente):');
359
406
  for (const p of plan.ambiguous)
360
407
  console.log(` • ambiguo — revisar manualmente: ${p}`);
361
408
  }
362
- console.log(`\n✔ Auto-registro completo: ${plan.kitOwned.length} kit-owned, ${plan.toAdd.length} agregados, ` +
363
- `${plan.ambiguous.length} ambiguos.`);
409
+ console.log(`\n✔ Auto-registro completo (v${manifest.version}, perfil ${manifest.profile}): ` +
410
+ `${plan.kitOwned.length} kit-owned, ${plan.toAdd.length} agregados, ${plan.ambiguous.length} ambiguos.`);
364
411
  }
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs';
10
10
  import { dirname, join } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { CLIError } from './lib/cli-error.js';
13
- import { runAdd } from './commands/add.js';
13
+ import { parseAddFlags, runAdd } from './commands/add.js';
14
14
  import { runDoctor } from './commands/doctor.js';
15
15
  import { runNew } from './commands/new.js';
16
16
  import { runStatus } from './commands/status.js';
@@ -23,23 +23,27 @@ Uso:
23
23
 
24
24
  Comandos:
25
25
  new <Nombre> Crea un proyecto derivado nuevo (repo + perfil + git init)
26
- add Instala el cerebro \`core\` (.claude/) en el repo actual
26
+ add Instala el cerebro (.claude/) en el repo actual
27
27
  update Actualiza el cerebro al día sin pisar tu trabajo local
28
28
  status Reporta la versión instalada vs. la última disponible
29
- doctor Detecta huérfanos y conflictos pendientes + aviso de seguridad
29
+ doctor Detecta huérfanos, conflictos y rules sin importar + aviso de seguridad
30
30
 
31
31
  Sobre \`add\`:
32
- Requiere estar dentro de un repo git (al menos \`git init\`). Nunca toca tu
33
- \`src/\`, tu \`package.json\` ni tus archivos propios (\`pj-*\`): solo escribe los
34
- archivos del perfil \`core\`. Si un archivo del \`core\` ya existe, lo sobrescribe
35
- sin preguntar (el manejo de conflictos es exclusivo de \`update\`).
32
+ Requiere estar dentro de un repo git (al menos \`git init\`). Por default instala
33
+ el cerebro \`full\` (incluye sk-* + SK.md) si el repo es un derivado del Factory
34
+ (tiene \`factoryVersion\` en package.json); si no, instala \`core\` (sin sk-*).
35
+ Flags: --full / --core fuerzan el perfil. Nunca toca tu \`src/\` ni tus archivos
36
+ propios; instala solo los archivos del manifest. Tu \`CLAUDE.md\` se conserva si
37
+ ya existe. Si el repo ya tiene \`.claude/\` o lockfile, te redirige a \`update\`.
36
38
 
37
39
  Sobre \`update\`:
38
- Lee el perfil instalado del lockfile y baja el tarball del MISMO perfil (nunca
39
- cross-grada). Compara contra el lockfile y aplica solo cambios de \`.claude/\`:
40
- agrega lo nuevo, sobrescribe lo del kit sin cambios locales, retira lo que el
41
- Factory borró, y pregunta (sin opción default) ante un conflicto. Nunca toca
42
- \`src/\` ni tus archivos propios. Flags:
40
+ Con lockfile, es sticky al perfil instalado; sin lockfile (legacy), auto-detecta
41
+ el perfil (igual que \`add\`). Compara contra el lockfile y aplica solo cambios de
42
+ \`.claude/\`: agrega lo nuevo, sobrescribe lo del kit sin cambios locales, retira
43
+ lo que el Factory borró, y pregunta (sin opción default) ante un conflicto. Tu
44
+ \`CLAUDE.md\` nunca se sobrescribe en silencio. Nunca toca \`src/\`. Flags:
45
+ --full Sube un repo \`core\` a \`full\` (cross-grade aditivo: + sk-*/SK.md)
46
+ --core Fuerza \`core\` en un install legacy (downgrade de full→core no soportado)
43
47
  --theirs-all Resuelve todos los conflictos con la versión del Factory
44
48
  --mine-all Conserva tu versión en todos los conflictos
45
49
  --resume Retoma un update interrumpido sin re-descargar
@@ -88,7 +92,7 @@ async function main(argv) {
88
92
  return;
89
93
  }
90
94
  case 'add': {
91
- await runAdd();
95
+ await runAdd(parseAddFlags(rest));
92
96
  return;
93
97
  }
94
98
  case 'update': {
@@ -23,8 +23,27 @@
23
23
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, writeFileSync, } from 'node:fs';
24
24
  import path from 'node:path';
25
25
  import { CLIError } from './cli-error.js';
26
- import { TIMEKAST_DIR } from './constants.js';
26
+ import { CLAUDE_MD_FILE, TIMEKAST_DIR } from './constants.js';
27
27
  import { parseLockfile } from './lockfile.js';
28
+ /** Roots a manifest path may live under (mirrors `profiles.json#track`). */
29
+ const ALLOWED_ROOTS = ['.claude/', 'scripts/'];
30
+ /**
31
+ * Reject a manifest path that is unsafe to write: absolute, escaping via `..`, or
32
+ * outside the tracked scope (`.claude/**`, `scripts/**`, `CLAUDE.md`). A correct
33
+ * release never produces these — this guards a mispublished/tampered tarball from
34
+ * writing `src/` or escaping the repo root. Separator-agnostic (Windows-safe).
35
+ */
36
+ function assertSafeTrackedPath(rel) {
37
+ const segments = rel.split(/[\\/]/);
38
+ if (rel.length === 0 || path.isAbsolute(rel) || segments.includes('..')) {
39
+ throw new CLIError(`El manifest declara un path inseguro \`${rel}\` (absoluto o con \`..\`); no se tocó nada.`);
40
+ }
41
+ const tracked = rel === CLAUDE_MD_FILE || ALLOWED_ROOTS.some((root) => rel.startsWith(root));
42
+ if (!tracked) {
43
+ throw new CLIError(`El manifest declara \`${rel}\`, fuera del scope rastreado (\`.claude/\`, \`scripts/\`, \`CLAUDE.md\`). ` +
44
+ 'El release está mal construido; no se tocó nada.');
45
+ }
46
+ }
28
47
  /** Name of the resume-state sidecar inside `.timekast/`. */
29
48
  export const UPDATE_STATE_FILE = '.update-state.json';
30
49
  /** Absolute path to the resume-state sidecar inside a project root. */
@@ -80,6 +99,8 @@ export function clearUpdateState(rootDir) {
80
99
  export function validateStagedManifest(stagedDir, manifestRaw) {
81
100
  const manifest = parseLockfile(manifestRaw, 'manifest embebido');
82
101
  for (const entry of manifest.files) {
102
+ // Path safety BEFORE any existence check: never resolve an unsafe path.
103
+ assertSafeTrackedPath(entry.path);
83
104
  if (!existsSync(path.join(stagedDir, entry.path))) {
84
105
  throw new CLIError(`El tarball está incompleto: falta \`${entry.path}\` declarado en el manifest. ` +
85
106
  'No se tocó ningún archivo.');
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Collect the repo-relative POSIX paths of files under `.claude/`, EXCLUDING
3
+ * gitignored ones. Shared by `update` (legacy auto-register ambiguous report)
4
+ * and `doctor` (orphan report) so neither flags a dev-local, gitignored file
5
+ * (`settings.local.json`, `transitions/`, `.DS_Store`) as "ambiguous"/"orphan" —
6
+ * those are never kit-managed, so surfacing them is pure noise.
7
+ *
8
+ * Only descends into `.claude/`; `src/` and everything else is invisible
9
+ * (design §7.2).
10
+ */
11
+ import { existsSync, readdirSync, statSync } from 'node:fs';
12
+ import path from 'node:path';
13
+ import { spawnSync } from 'node:child_process';
14
+ /**
15
+ * Fallback ignore set, used only when `git check-ignore` is unavailable (no git
16
+ * binary, or the dir is not a git repo). Mirrors the kit's own `.gitignore`
17
+ * entries for `.claude/`. The git path is authoritative when present.
18
+ */
19
+ const IGNORE_FALLBACK_RE = /(^|\/)(\.DS_Store|settings\.local\.json)$|(^|\/)transitions\//;
20
+ /** Recursively gather repo-relative POSIX paths of files under `.claude/`. */
21
+ function walkClaude(rootDir) {
22
+ const out = [];
23
+ const walk = (rel) => {
24
+ const abs = path.join(rootDir, rel);
25
+ if (!existsSync(abs))
26
+ return;
27
+ if (statSync(abs).isDirectory()) {
28
+ for (const entry of readdirSync(abs))
29
+ walk(path.posix.join(rel, entry));
30
+ }
31
+ else {
32
+ out.push(rel);
33
+ }
34
+ };
35
+ walk('.claude');
36
+ return out;
37
+ }
38
+ /**
39
+ * Drop the gitignored paths from `paths`. Uses `git check-ignore --stdin` (the
40
+ * authoritative source — respects nested `.gitignore`, negations, etc.); falls
41
+ * back to `IGNORE_FALLBACK_RE` when git is missing or the dir is not a repo.
42
+ *
43
+ * `git check-ignore` exit codes: 0 = some paths matched (printed), 1 = none
44
+ * matched (empty output), 128 = error / not a repo. `spawnSync` does not throw,
45
+ * so 0 and 1 both yield usable stdout; >1 or a spawn error → fallback.
46
+ */
47
+ function dropGitignored(rootDir, paths) {
48
+ if (paths.length === 0)
49
+ return paths;
50
+ const res = spawnSync('git', ['-C', rootDir, 'check-ignore', '--stdin'], {
51
+ input: paths.join('\n'),
52
+ encoding: 'utf8',
53
+ });
54
+ if (res.error || res.status === null || res.status > 1) {
55
+ return paths.filter((p) => !IGNORE_FALLBACK_RE.test(p));
56
+ }
57
+ const ignored = new Set((res.stdout || '')
58
+ .split('\n')
59
+ .map((l) => l.trim())
60
+ .filter(Boolean));
61
+ return paths.filter((p) => !ignored.has(p));
62
+ }
63
+ /**
64
+ * Repo-relative POSIX paths of NON-gitignored files under `.claude/`.
65
+ */
66
+ export function collectClaudePaths(rootDir) {
67
+ return dropGitignored(rootDir, walkClaude(rootDir));
68
+ }
@@ -16,9 +16,34 @@ export const PROFILES = {
16
16
  export const TIMEKAST_DIR = '.timekast';
17
17
  export const MANIFEST_FILE = 'manifest.json';
18
18
  export const LOCKFILE_FILE = 'lockfile.json';
19
- /** The script the CLI injects into a derived project's package.json. */
19
+ /**
20
+ * Repo-root path of the runtime entrypoint markdown. It is a tracked manifest
21
+ * file (via `track` + the `core` rename `CLAUDE.core.md → CLAUDE.md`), so it
22
+ * flows through the diff/install engine. It gets special handling because it is
23
+ * dev-owned after bootstrap (it carries project-specific sections + `@import`s):
24
+ * a derived repo's edited CLAUDE.md is never silently overwritten or prompted on
25
+ * `update` (lockfile path), and never clobbered by `add`/legacy if it already
26
+ * exists on disk (path-match path). See `diffLockfiles` + the install commands.
27
+ */
28
+ export const CLAUDE_MD_FILE = 'CLAUDE.md';
29
+ /** The scripts the CLI injects into a derived project's package.json. */
20
30
  export const UPDATE_SCRIPT_NAME = 'factory:update';
21
31
  // `npx` so the script resolves in a fresh derived repo where @timekast/factory
22
32
  // is NOT a dependency (B3): a bare `@timekast/factory update` would be
23
33
  // "command not found". npx resolves the published package on demand.
24
34
  export const UPDATE_SCRIPT_CMD = 'npx @timekast/factory update';
35
+ export const DOCTOR_SCRIPT_NAME = 'factory:doctor';
36
+ export const DOCTOR_SCRIPT_CMD = 'npx @timekast/factory doctor';
37
+ export const STATUS_SCRIPT_NAME = 'factory:status';
38
+ export const STATUS_SCRIPT_CMD = 'npx @timekast/factory status';
39
+ /**
40
+ * All convenience scripts the installer ensures in a Node derivative's
41
+ * package.json (each: add if missing, never overwrite a divergent value). Keyed
42
+ * by script name → command. `factory:update` is the primary (drives the install
43
+ * messaging); `doctor`/`status` are read-only diagnostics with no other alias.
44
+ */
45
+ export const FACTORY_SCRIPTS = {
46
+ [UPDATE_SCRIPT_NAME]: UPDATE_SCRIPT_CMD,
47
+ [DOCTOR_SCRIPT_NAME]: DOCTOR_SCRIPT_CMD,
48
+ [STATUS_SCRIPT_NAME]: STATUS_SCRIPT_CMD,
49
+ };
@@ -32,7 +32,7 @@ import { createHash } from 'node:crypto';
32
32
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
33
33
  import path from 'node:path';
34
34
  import { CLIError } from './cli-error.js';
35
- import { LOCKFILE_FILE, MANIFEST_FILE, TIMEKAST_DIR } from './constants.js';
35
+ import { CLAUDE_MD_FILE, LOCKFILE_FILE, MANIFEST_FILE, TIMEKAST_DIR, } from './constants.js';
36
36
  /**
37
37
  * Write the initial lockfile in `destDir/.timekast/lockfile.json` (used by
38
38
  * `new` / `add`). On the initial install the lockfile carries the tarball's
@@ -185,6 +185,18 @@ export function diffLockfiles(oldLock, newManifest, diskHashes) {
185
185
  ignoreLocal: [],
186
186
  conflicts: [],
187
187
  };
188
+ // CLAUDE.md special-case (rama A): it is dev-owned after bootstrap, so it must
189
+ // NEVER prompt. Any path that would classify it as a `conflict` instead routes
190
+ // it to `ignoreLocal` (kept as-is, silently). The clean kit-owned branches
191
+ // (`add` / `overwriteSilent` / `unchanged`) stay normal, so a clean CLAUDE.md
192
+ // still propagates — e.g. a core→full cross-grade swaps in the full CLAUDE.md
193
+ // that imports `@.claude/rules/SK.md`, and new always-on rules reach it.
194
+ const recordConflict = (entry) => {
195
+ if (entry.path === CLAUDE_MD_FILE)
196
+ diff.ignoreLocal.push(entry.path);
197
+ else
198
+ diff.conflicts.push(entry);
199
+ };
188
200
  // Walk the new manifest: add / overwriteSilent / conflicts.
189
201
  for (const newEntry of newManifest.files) {
190
202
  const localHash = diskHashes.get(newEntry.path);
@@ -203,7 +215,7 @@ export function diffLockfiles(oldLock, newManifest, diskHashes) {
203
215
  diff.unchanged.push(newEntry);
204
216
  }
205
217
  else {
206
- diff.conflicts.push({
218
+ recordConflict({
207
219
  path: newEntry.path,
208
220
  localHash,
209
221
  registeredHash: localHash, // no prior record; treat current as baseline
@@ -233,8 +245,9 @@ export function diffLockfiles(oldLock, newManifest, diskHashes) {
233
245
  diff.unchanged.push(newEntry);
234
246
  }
235
247
  else {
236
- // Edited locally AND changed by the Factory, diverging → conflict.
237
- diff.conflicts.push({
248
+ // Edited locally AND changed by the Factory, diverging → conflict
249
+ // (CLAUDE.md routes to ignoreLocal instead — see recordConflict).
250
+ recordConflict({
238
251
  path: newEntry.path,
239
252
  localHash,
240
253
  registeredHash,
@@ -5,9 +5,35 @@
5
5
  * indentation, never rewriting the whole document. The dev's deps, scripts,
6
6
  * field order and formatting are preserved.
7
7
  */
8
- import { readFileSync, writeFileSync } from 'node:fs';
8
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import path from 'node:path';
9
10
  import { CLIError } from './cli-error.js';
10
- import { UPDATE_SCRIPT_CMD, UPDATE_SCRIPT_NAME } from './constants.js';
11
+ import { FACTORY_SCRIPTS, PROFILES, UPDATE_SCRIPT_NAME } from './constants.js';
12
+ /**
13
+ * Auto-detect the profile to install into an existing repo from its
14
+ * `package.json`: a `factoryVersion` field means the repo was born from the
15
+ * Factory boilerplate (the `sk-*` skills + `SK.md` rule apply) → `full`. Its
16
+ * absence — including a non-Node repo with no `package.json`, or an invalid one —
17
+ * means the repo is not a Factory derivative → `core`.
18
+ *
19
+ * Tolerant by design: any read/parse failure degrades to `core`, never throws.
20
+ * A `--full` / `--core` flag overrides this (the caller decides). The signal is
21
+ * one-time: once a lockfile records the profile, `update` is sticky to it.
22
+ */
23
+ export function detectProfile(rootDir) {
24
+ const pkgPath = path.join(rootDir, 'package.json');
25
+ try {
26
+ if (!existsSync(pkgPath))
27
+ return PROFILES.core;
28
+ const pkg = parsePackageJson(readFileSync(pkgPath, 'utf8'));
29
+ return typeof pkg.factoryVersion === 'string' && pkg.factoryVersion.length > 0
30
+ ? PROFILES.full
31
+ : PROFILES.core;
32
+ }
33
+ catch {
34
+ return PROFILES.core;
35
+ }
36
+ }
11
37
  /** Detect the indentation used by an existing JSON document (defaults to 2 spaces). */
12
38
  function detectIndent(raw) {
13
39
  const match = raw.match(/^(\s+)"/m);
@@ -32,26 +58,40 @@ export function parsePackageJson(raw) {
32
58
  }
33
59
  }
34
60
  /**
35
- * Pure core of the script insertion: mutate `pkg.scripts` in place to ensure
36
- * `factory:update` exists, never overwriting a divergent value. Shared by
61
+ * Pure core of the script insertion: mutate `pkg.scripts` in place to ensure ALL
62
+ * `factory:*` convenience scripts exist (`update` / `doctor` / `status`), adding
63
+ * each if missing and NEVER overwriting a divergent value (§7.4). Shared by
37
64
  * `applyPackageJsonEdits` (the `new` flow) and `insertFactoryUpdateScript`
38
- * (the `update` flow) so both follow the same §7.4 rule.
65
+ * (the `update` flow). Returns whether anything changed (so the caller writes
66
+ * only on a real edit) + the result for the primary `factory:update` script
67
+ * (which drives the install messaging).
39
68
  */
40
- function ensureUpdateScript(pkg) {
69
+ function ensureFactoryScripts(pkg) {
41
70
  const scripts = pkg.scripts ?? {};
42
- const existing = scripts[UPDATE_SCRIPT_NAME];
43
71
  pkg.scripts = scripts;
44
- if (existing === undefined) {
45
- scripts[UPDATE_SCRIPT_NAME] = UPDATE_SCRIPT_CMD;
46
- return { action: 'added' };
47
- }
48
- if (existing === UPDATE_SCRIPT_CMD) {
49
- return { action: 'already-correct' };
72
+ let changed = false;
73
+ let primary = { action: 'already-correct' };
74
+ for (const [name, cmd] of Object.entries(FACTORY_SCRIPTS)) {
75
+ const existing = scripts[name];
76
+ let result;
77
+ if (existing === undefined) {
78
+ scripts[name] = cmd;
79
+ changed = true;
80
+ result = { action: 'added' };
81
+ }
82
+ else if (existing === cmd) {
83
+ result = { action: 'already-correct' };
84
+ }
85
+ else {
86
+ result = { action: 'conflict', existingValue: existing };
87
+ }
88
+ if (name === UPDATE_SCRIPT_NAME)
89
+ primary = result;
50
90
  }
51
- return { action: 'conflict', existingValue: existing };
91
+ return { changed, primary };
52
92
  }
53
93
  /**
54
- * Rename `package.json.name` and ensure the `factory:update` script exists,
94
+ * Rename `package.json.name` and ensure the `factory:*` scripts exist,
55
95
  * preserving everything else. Returns the serialized document (with the
56
96
  * original indentation + trailing newline).
57
97
  *
@@ -62,31 +102,32 @@ export function applyPackageJsonEdits(raw, name) {
62
102
  const indent = detectIndent(raw);
63
103
  const pkg = parsePackageJson(raw);
64
104
  pkg.name = name;
65
- const result = ensureUpdateScript(pkg);
105
+ const { primary } = ensureFactoryScripts(pkg);
66
106
  const content = `${JSON.stringify(pkg, null, indent)}\n`;
67
- return { content, scriptAlreadyPresent: result.action === 'conflict' };
107
+ return { content, scriptAlreadyPresent: primary.action === 'conflict' };
68
108
  }
69
109
  /**
70
- * Surgically ensure the `factory:update` script exists in the `package.json` at
71
- * `pkgPath` (design §7.4). Reads, mutates only the single key, and re-serializes
72
- * with the original indentation + trailing newline. NEVER overwrites a divergent
73
- * value and NEVER touches `name`, deps, or any other script.
110
+ * Surgically ensure the `factory:*` scripts (`update` / `doctor` / `status`)
111
+ * exist in the `package.json` at `pkgPath` (design §7.4). Reads, mutates only
112
+ * those keys, and re-serializes with the original indentation + trailing
113
+ * newline. NEVER overwrites a divergent value and NEVER touches `name`, deps,
114
+ * or any other script.
74
115
  *
75
- * Writes the file only when something actually changed (`added`); for
76
- * `already-correct` and `conflict` the file is left byte-identical.
116
+ * Writes the file only when at least one script was added; if all are already
117
+ * present (correct or divergent) the file is left byte-identical.
77
118
  *
78
119
  * @param pkgPath Absolute path to the derived project's `package.json`.
79
- * @returns What happened, plus the existing value when it conflicts.
120
+ * @returns The result for the primary `factory:update` script (+ existing value on conflict).
80
121
  */
81
122
  export function insertFactoryUpdateScript(pkgPath) {
82
123
  const raw = readFileSync(pkgPath, 'utf8');
83
124
  const indent = detectIndent(raw);
84
125
  const pkg = parsePackageJson(raw);
85
- const result = ensureUpdateScript(pkg);
86
- if (result.action === 'added') {
126
+ const { changed, primary } = ensureFactoryScripts(pkg);
127
+ if (changed) {
87
128
  writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
88
129
  }
89
- return result;
130
+ return primary;
90
131
  }
91
132
  /**
92
133
  * Surgically set the derived project's `package.json.agentKitVersion` to the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "0.1.9",
3
+ "version": "1.1.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",