@timekast/factory 0.1.8 → 1.0.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,47 +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/`, `package.json`, `pj-*`, or any file outside the
13
- * `core` manifest. The `core` tarball contains only `core` files, so moving
14
- * its contents in cannot reach dev-owned paths; files in the cwd that are not
15
- * in the tarball are left byte-identical.
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).
16
26
  * - `add` overwrites manifest files without prompting — the conflict prompt is
17
- * exclusive to `update` (DIST-005). Documented in the command's `--help`.
18
- * - Atomicity (§7.5): extract + validate in a temp dir before touching the cwd;
19
- * 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.
20
29
  *
21
30
  * Expected failures throw `CLIError`; the top-level handler prints + exits.
22
31
  */
23
- import { mkdtempSync, rmSync } from 'node:fs';
32
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
24
33
  import { tmpdir } from 'node:os';
25
34
  import path from 'node:path';
35
+ import { applyPlan, defaultBackupDir, validateStagedManifest } from '../lib/atomic-swap.js';
26
36
  import { CLIError } from '../lib/cli-error.js';
27
- import { PROFILES } from '../lib/constants.js';
28
- import { writeInitialLockfile } from '../lib/lockfile.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';
29
40
  import { runPreflight } from '../lib/preflight.js';
30
41
  import { detectRepo } from '../lib/repo-detection.js';
31
- import { downloadProfileTarball, moveContentsInto, stageProfileTarball } from '../lib/unpack.js';
42
+ import { downloadProfileTarball, stageProfileTarball } from '../lib/unpack.js';
43
+ import { runUpdate } from './update.js';
32
44
  /** Message shown when `add` runs outside any git repo context. */
33
45
  const NO_REPO_MESSAGE = 'No hay repositorio git aquí. Ejecuta `git init` primero, o usa `factory new` para crear un proyecto desde cero.';
34
- 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 }) {
35
51
  const cwd = process.cwd();
52
+ if (flags.core && flags.full) {
53
+ throw new CLIError('No puedes combinar `--core` con `--full`.');
54
+ }
36
55
  // 1. Require a git repo context (cwd or any ancestor). Refuse before any
37
- // network call or write — nothing is touched when there is no repo.
38
- 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);
39
60
  if (!hasRepo) {
40
61
  throw new CLIError(NO_REPO_MESSAGE);
41
62
  }
42
- // 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.
43
92
  await runPreflight();
44
- // 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).
45
100
  const tmpDir = mkdtempSync(path.join(tmpdir(), 'tk-add-'));
46
101
  const cleanup = () => {
47
102
  try {
@@ -57,22 +112,62 @@ export async function runAdd() {
57
112
  };
58
113
  process.once('SIGINT', onSignal);
59
114
  process.once('SIGTERM', onSignal);
115
+ let version;
60
116
  try {
61
- // `add` is structurally `core`-only: the literal is passed, never a flag.
62
- const tarball = await downloadProfileTarball(PROFILES.core, tmpDir);
63
- // Extract + validate the embedded manifest before touching the destination.
117
+ const tarball = await downloadProfileTarball(profile, tmpDir);
64
118
  const { stagedDir, manifestRaw } = await stageProfileTarball(tarball, tmpDir);
65
- // Move only the tarball's (core) contents into the cwd. Files outside the
66
- // manifest src/, package.json, pj-* are never visited, so they stay
67
- // byte-identical.
68
- 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));
69
136
  // Write the lockfile = the tarball's embedded manifest (no hash recompute).
70
- 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
+ }
71
142
  }
72
143
  finally {
73
144
  process.removeListener('SIGINT', onSignal);
74
145
  process.removeListener('SIGTERM', onSignal);
75
146
  cleanup();
76
147
  }
77
- console.log('\n✔ Cerebro `core` instalado. Lockfile escrito en `.timekast/lockfile.json`.');
148
+ // H5: if the repo is a Node project, add the `pnpm factory:update` convenience by
149
+ // surgically inserting the script into the EXISTING package.json. Non-fatal — a
150
+ // missing/invalid package.json just means the dev uses `npx` (the brain is already
151
+ // installed). We NEVER create a package.json in a non-Node repo.
152
+ const pkgPath = path.join(root, 'package.json');
153
+ let scriptAction = 'none';
154
+ if (existsSync(pkgPath)) {
155
+ try {
156
+ scriptAction = insertFactoryUpdateScript(pkgPath).action;
157
+ }
158
+ catch {
159
+ scriptAction = 'none'; // invalid package.json — skip the alias, brain still installed
160
+ }
161
+ }
162
+ console.log(`\n✔ Cerebro \`${profile}\` v${version} instalado. Lockfile escrito en \`.timekast/lockfile.json\`.`);
163
+ if (scriptAction === 'added' || scriptAction === 'already-correct') {
164
+ console.log('Para actualizar: `pnpm factory:update` (alias de `npx @timekast/factory update`).');
165
+ }
166
+ else {
167
+ if (scriptAction === 'conflict') {
168
+ console.warn('Aviso: `factory:update` ya existe en package.json con otro valor; no se sobrescribió.');
169
+ }
170
+ console.log('Para actualizar: `npx @timekast/factory update` desde la raíz del repo,');
171
+ console.log('o instala el CLI una vez (`npm i -g @timekast/factory`) y corre `factory update`.');
172
+ }
78
173
  }
@@ -18,7 +18,17 @@
18
18
  */
19
19
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
20
20
  import path from 'node:path';
21
+ import { CLAUDE_MD_FILE } from '../lib/constants.js';
21
22
  import { hasLockfile, normalizeThenHash, readLockfile } from '../lib/lockfile.js';
23
+ import { detectRepo } from '../lib/repo-detection.js';
24
+ /** Matches a flat always-on rule file shipped by the kit (`.claude/rules/<name>.md`). */
25
+ const RULE_PATH_RE = /^\.claude\/rules\/[^/]+\.md$/;
26
+ /**
27
+ * Matches ONLY the reserved on-demand rules heading (`Rules (on-demand reference)`
28
+ * and close variants), not any heading that merely contains "on-demand" (e.g.
29
+ * `## On-demand migration notes`).
30
+ */
31
+ const ON_DEMAND_HEADING_RE = /rules?\s*\(\s*on-demand/i;
22
32
  /**
23
33
  * The A1 security notice (design §1 + §11, decision A1). Literal text emitted at
24
34
  * the end of every `doctor` run so the dev always sees the `src/` responsibility
@@ -55,6 +65,55 @@ function collectClaudePaths(rootDir) {
55
65
  walk('.claude');
56
66
  return out;
57
67
  }
68
+ /**
69
+ * Concatenate the body of the reserved "on-demand reference" section of CLAUDE.md
70
+ * — the lines under a heading matching `ON_DEMAND_HEADING_RE`, until the next
71
+ * heading of the SAME or HIGHER level (a deeper nested heading stays inside the
72
+ * section). Rules listed there are loaded by skills on demand (NOT via `@import`),
73
+ * so they must not be flagged as unimported. Empty string when no such section.
74
+ *
75
+ * Level-aware (a nested `###` under a `##` on-demand heading does not end it) and
76
+ * specific (only the reserved heading starts it, not any "on-demand" mention).
77
+ */
78
+ function onDemandSectionText(claudeMd) {
79
+ const out = [];
80
+ let sectionLevel = 0; // 0 = not currently inside an on-demand section
81
+ for (const line of claudeMd.split('\n')) {
82
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
83
+ if (heading) {
84
+ const level = heading[1].length;
85
+ const isOnDemand = ON_DEMAND_HEADING_RE.test(heading[2]);
86
+ if (sectionLevel === 0) {
87
+ if (isOnDemand)
88
+ sectionLevel = level; // enter the section
89
+ }
90
+ else if (level <= sectionLevel) {
91
+ // sibling/higher heading ends the section (unless it is on-demand too)
92
+ sectionLevel = isOnDemand ? level : 0;
93
+ }
94
+ // a deeper heading (level > sectionLevel) stays inside → no change
95
+ continue;
96
+ }
97
+ if (sectionLevel > 0)
98
+ out.push(line);
99
+ }
100
+ return out.join('\n');
101
+ }
102
+ /**
103
+ * Collect the rule paths a CLAUDE.md actually `@import`s — a line whose trimmed
104
+ * text is `@<path>` (the real import directive). A prose mention or a fenced
105
+ * code block ("no agregues `@.claude/rules/SK.md`") is NOT a bare directive line,
106
+ * so it does not count as imported.
107
+ */
108
+ function importedRulePaths(claudeMd) {
109
+ const paths = new Set();
110
+ for (const raw of claudeMd.split('\n')) {
111
+ const line = raw.trim();
112
+ if (line.startsWith('@'))
113
+ paths.add(line.slice(1).trim());
114
+ }
115
+ return paths;
116
+ }
58
117
  /**
59
118
  * Compute the doctor diagnosis. No lockfile → `noLockfile: true` (the caller
60
119
  * renders the "run factory:update" advice). Otherwise diffs the on-disk
@@ -63,9 +122,19 @@ function collectClaudePaths(rootDir) {
63
122
  * - conflict = tracked path whose normalized disk hash != recorded hash.
64
123
  */
65
124
  export function computeDoctor(deps = {}) {
66
- const rootDir = deps.rootDir ?? process.cwd();
125
+ // Diagnose at the REPO ROOT (where the brain + lockfile live), not cwd — so
126
+ // `doctor` from a subdir of a managed repo reports the real state instead of
127
+ // "no lockfile" (consistency with `add`/`update`). Tests inject `rootDir`.
128
+ const rootDir = deps.rootDir ?? detectRepo(process.cwd()).repoRoot ?? process.cwd();
67
129
  if (!hasLockfile(rootDir)) {
68
- return { orphans: [], conflicts: [], a1Warning: A1_WARNING, noLockfile: true };
130
+ return {
131
+ orphans: [],
132
+ conflicts: [],
133
+ unimportedRules: [],
134
+ claudeMdMissing: false,
135
+ a1Warning: A1_WARNING,
136
+ noLockfile: true,
137
+ };
69
138
  }
70
139
  const lock = readLockfile(rootDir);
71
140
  const manifestPaths = new Set(lock.files.map((f) => f.path));
@@ -85,7 +154,37 @@ export function computeDoctor(deps = {}) {
85
154
  }
86
155
  }
87
156
  conflicts.sort();
88
- return { orphans, conflicts, a1Warning: A1_WARNING, noLockfile: false };
157
+ // Unimported kit rules: a kit-shipped always-on rule (`.claude/rules/*.md`)
158
+ // that is NOT `@import`ed by CLAUDE.md → it does NOT load at runtime. The check
159
+ // is strict on the `@import` directive (a prose mention does NOT count — a
160
+ // mentioned-but-not-imported always-on rule is dead). Exception: a rule listed
161
+ // under an "on-demand" heading is loaded by skills on demand, not via @import,
162
+ // so it is excluded. No CLAUDE.md on disk in a managed repo → can't check, and
163
+ // every rule is dead → flagged via `claudeMdMissing` instead.
164
+ const unimportedRules = [];
165
+ const claudeMdPath = path.join(rootDir, CLAUDE_MD_FILE);
166
+ const claudeMdMissing = !existsSync(claudeMdPath);
167
+ if (!claudeMdMissing) {
168
+ const claudeMd = readFileSync(claudeMdPath, 'utf8');
169
+ const imported = importedRulePaths(claudeMd); // bare `@<path>` directive lines
170
+ const onDemand = onDemandSectionText(claudeMd);
171
+ for (const entry of lock.files) {
172
+ if (!RULE_PATH_RE.test(entry.path))
173
+ continue;
174
+ const isOnDemand = onDemand.includes(entry.path);
175
+ if (!imported.has(entry.path) && !isOnDemand)
176
+ unimportedRules.push(entry.path);
177
+ }
178
+ unimportedRules.sort();
179
+ }
180
+ return {
181
+ orphans,
182
+ conflicts,
183
+ unimportedRules,
184
+ claudeMdMissing,
185
+ a1Warning: A1_WARNING,
186
+ noLockfile: false,
187
+ };
89
188
  }
90
189
  /** Render the doctor result to the console. The A1 notice is ALWAYS last. */
91
190
  function renderDoctor(result) {
@@ -96,10 +195,17 @@ function renderDoctor(result) {
96
195
  console.log(`\n${result.a1Warning}`);
97
196
  return;
98
197
  }
99
- if (result.orphans.length === 0 && result.conflicts.length === 0) {
100
- console.log('✔ Sin huérfanos ni conflictos pendientes.');
198
+ if (result.orphans.length === 0 &&
199
+ result.conflicts.length === 0 &&
200
+ result.unimportedRules.length === 0 &&
201
+ !result.claudeMdMissing) {
202
+ console.log('✔ Sin huérfanos, conflictos ni rules sin importar.');
101
203
  }
102
204
  else {
205
+ if (result.claudeMdMissing) {
206
+ console.log('🔴 Falta `CLAUDE.md` en la raíz: las rules del kit NO se cargan (el auto-load es por ' +
207
+ '`@import` desde ese archivo). Recupéralo (`git checkout -- CLAUDE.md`) o corre `factory update`.');
208
+ }
103
209
  if (result.conflicts.length > 0) {
104
210
  console.log('Modificados localmente respecto al lockfile (conflicto si el Factory los actualiza):');
105
211
  for (const p of result.conflicts) {
@@ -107,6 +213,12 @@ function renderDoctor(result) {
107
213
  }
108
214
  console.log(' Sugerencia: corre `factory:update --verify` para re-chequear, o `factory:update` para reconciliar.');
109
215
  }
216
+ if (result.unimportedRules.length > 0) {
217
+ console.log('\nRules del kit que tu `CLAUDE.md` no importa (no se cargan en runtime):');
218
+ for (const p of result.unimportedRules) {
219
+ console.log(` • ${p} — agrega \`@${p}\` a tu CLAUDE.md o no se aplicará`);
220
+ }
221
+ }
110
222
  if (result.orphans.length > 0) {
111
223
  console.log('\nArchivos huérfanos (en `.claude/` fuera del manifest instalado):');
112
224
  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
@@ -36,9 +39,11 @@ 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';
38
41
  import { CLIError } from '../lib/cli-error.js';
42
+ import { CLAUDE_MD_FILE, PROFILES } from '../lib/constants.js';
39
43
  import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
40
- import { insertFactoryUpdateScript, setAgentKitVersion } from '../lib/package-json.js';
44
+ import { detectProfile, insertFactoryUpdateScript, setAgentKitVersion, } from '../lib/package-json.js';
41
45
  import { runPreflight } from '../lib/preflight.js';
46
+ import { detectRepo } from '../lib/repo-detection.js';
42
47
  import { downloadProfileTarball, stageProfileTarball } from '../lib/unpack.js';
43
48
  /** Parse `update`'s argv slice into flags. Unknown flags are ignored here. */
44
49
  export function parseUpdateFlags(argv) {
@@ -47,6 +52,8 @@ export function parseUpdateFlags(argv) {
47
52
  mineAll: argv.includes('--mine-all'),
48
53
  resume: argv.includes('--resume'),
49
54
  verify: argv.includes('--verify'),
55
+ core: argv.includes('--core'),
56
+ full: argv.includes('--full'),
50
57
  };
51
58
  }
52
59
  /** Default acquire: download the sticky-profile tarball, stage + validate it. */
@@ -216,11 +223,20 @@ function warnOnScriptConflict(action, _pkgPath) {
216
223
  * semver) are untouched.
217
224
  */
218
225
  function maintainDerivedPkg(pkgPath, agentKitVersion) {
219
- warnOnScriptConflict(insertFactoryUpdateScript(pkgPath).action, pkgPath);
220
- setAgentKitVersion(pkgPath, agentKitVersion);
226
+ // Tolerant (mirrors `add`): a malformed package.json must NOT throw AFTER the
227
+ // brain was already applied + the lockfile written — the install succeeded; the
228
+ // script/version mirror is best-effort. Warn and move on.
229
+ try {
230
+ warnOnScriptConflict(insertFactoryUpdateScript(pkgPath).action, pkgPath);
231
+ setAgentKitVersion(pkgPath, agentKitVersion);
232
+ }
233
+ catch {
234
+ console.warn('Aviso: no se pudo actualizar package.json (¿JSON inválido?); el cerebro se instaló igual. ' +
235
+ 'Corrige package.json y vuelve a correr `factory update` para sincronizar agentKitVersion.');
236
+ }
221
237
  }
222
238
  /** Print the deletes + kept-retired (design §7.6 — visible) + a one-line summary. */
223
- function reportSummary(diff) {
239
+ function reportSummary(diff, manifest) {
224
240
  if (diff.deleteSilent.length > 0) {
225
241
  console.log('\nArchivos retirados por el Factory en esta versión:');
226
242
  for (const e of diff.deleteSilent)
@@ -233,7 +249,8 @@ function reportSummary(diff) {
233
249
  }
234
250
  const unchanged = diff.unchanged.length > 0 ? ` (${diff.unchanged.length} sin cambios)` : '';
235
251
  const kept = diff.keptRetiredLocal.length > 0 ? `, ${diff.keptRetiredLocal.length} conservados` : '';
236
- console.log(`\n✔ update: ${diff.add.length} agregados, ${diff.overwriteSilent.length} actualizados${unchanged}, ` +
252
+ console.log(`\n✔ update a v${manifest.version} (perfil ${manifest.profile}): ${diff.add.length} agregados, ` +
253
+ `${diff.overwriteSilent.length} actualizados${unchanged}, ` +
237
254
  `${diff.deleteSilent.length} retirados${kept}, ${diff.conflicts.length} conflictos.`);
238
255
  }
239
256
  /**
@@ -241,7 +258,19 @@ function reportSummary(diff) {
241
258
  * network seam (tests inject a local fixture).
242
259
  */
243
260
  export async function runUpdate(flags, deps = {}) {
244
- const rootDir = process.cwd();
261
+ // Operate on the REPO ROOT (the brain + lockfile live there), not cwd — running
262
+ // `update` from a subdir of a managed repo must not miss the lockfile and
263
+ // legacy-register a second nested brain (consistency with `add`).
264
+ const rootDir = detectRepo(process.cwd()).repoRoot ?? process.cwd();
265
+ // Mutually-exclusive flag validation FIRST — before --verify/--resume
266
+ // short-circuit, so an invalid combination is always rejected (not silently
267
+ // honored by verify/resume).
268
+ if (flags.theirsAll && flags.mineAll) {
269
+ throw new CLIError('No puedes combinar `--theirs-all` con `--mine-all`.');
270
+ }
271
+ if (flags.core && flags.full) {
272
+ throw new CLIError('No puedes combinar `--core` con `--full`.');
273
+ }
245
274
  // --verify and --resume short-circuit (no download).
246
275
  if (flags.verify) {
247
276
  runVerify(rootDir);
@@ -254,28 +283,46 @@ export async function runUpdate(flags, deps = {}) {
254
283
  runResume(rootDir);
255
284
  return;
256
285
  }
257
- if (flags.theirsAll && flags.mineAll) {
258
- throw new CLIError('No puedes combinar `--theirs-all` con `--mine-all`.');
259
- }
260
286
  // Preflight (gh) before any download — reused, not reimplemented.
261
287
  await runPreflight();
262
288
  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).
289
+ // Profile resolution:
290
+ // - legacy (no lockfile): auto-detect from package.json (`factoryVersion`
291
+ // full, else core), unless a flag forces it. This is the adoption path for
292
+ // pre-CLI derivatives, so full-brain is the default for Factory repos.
293
+ // - lockfile'd: sticky to the recorded profile, EXCEPT `--full` cross-grades
294
+ // a `core` repo → `full` (additive: adds sk-* + SK.md, swaps CLAUDE.md to the
295
+ // full one). `--core` on a `full` repo is a downgrade → rejected.
265
296
  let oldLock;
266
297
  let profile;
267
298
  if (legacy) {
268
- profile = 'core';
299
+ profile = flags.full ? PROFILES.full : flags.core ? PROFILES.core : detectProfile(rootDir);
269
300
  oldLock = { version: '0.0.0', profile, files: [] };
270
- await confirmLegacy();
301
+ await confirmLegacy(profile);
271
302
  }
272
303
  else {
273
304
  oldLock = readLockfile(rootDir);
274
- profile = oldLock.profile;
305
+ if (flags.full && oldLock.profile === PROFILES.core) {
306
+ profile = PROFILES.full; // cross-grade core → full (additive)
307
+ }
308
+ else if (flags.core && oldLock.profile === PROFILES.full) {
309
+ throw new CLIError('Este repo está registrado como `full`; el downgrade a `core` no está soportado ' +
310
+ '(implicaría borrar sk-*/SK.md). Si de verdad lo quieres, hazlo a mano.');
311
+ }
312
+ else {
313
+ profile = oldLock.profile; // sticky (--full on full / --core on core = refresh)
314
+ }
275
315
  }
276
316
  const acquire = deps.acquire ?? acquireFromRelease;
277
317
  const { stagedDir, manifest, tmpRoot } = await acquire(profile);
278
318
  try {
319
+ // Guard: the downloaded tarball must match the resolved profile. A mispublished
320
+ // asset (e.g. a `full` tag carrying a `core` manifest) would otherwise write a
321
+ // lockfile for the wrong profile or silently no-op a cross-grade. Refuse loudly.
322
+ if (manifest.profile !== profile) {
323
+ throw new CLIError(`El tarball descargado declara perfil \`${manifest.profile}\` pero se resolvió \`${profile}\`. ` +
324
+ 'El release está mal publicado; no se modificó nada.');
325
+ }
279
326
  if (legacy) {
280
327
  await applyLegacy(rootDir, stagedDir, manifest);
281
328
  return;
@@ -314,19 +361,31 @@ export async function runUpdate(flags, deps = {}) {
314
361
  const pkgPath = path.join(rootDir, 'package.json');
315
362
  if (existsSync(pkgPath))
316
363
  maintainDerivedPkg(pkgPath, manifest.version);
317
- reportSummary(diff);
364
+ reportSummary(diff, manifest);
318
365
  }
319
366
  finally {
320
- rmSync(tmpRoot, { recursive: true, force: true });
367
+ // Keep the staged tarball if an update-state survives (a post-apply failure —
368
+ // e.g. writeLockfile threw after applyPlan): the persisted state points into
369
+ // tmpRoot, so deleting it would strand `--resume` with a vanished stagedDir
370
+ // and no recovery. A clean run clears the state first → tmpRoot is removed.
371
+ if (!hasUpdateState(rootDir)) {
372
+ rmSync(tmpRoot, { recursive: true, force: true });
373
+ }
321
374
  }
322
375
  }
323
376
  /** Warn the dev before a legacy auto-register sync (design §8 edge case). */
324
- async function confirmLegacy() {
377
+ async function confirmLegacy(profile) {
378
+ const detected = profile === PROFILES.full
379
+ ? 'Detectado como derivado del Factory (`factoryVersion` en package.json) → perfil `full` ' +
380
+ '(incluye sk-* + SK.md). Usa `--core` si quieres solo el cerebro base.'
381
+ : 'Detectado como repo de otro stack (sin `factoryVersion`) → perfil `core` (sin sk-*). ' +
382
+ 'Usa `--full` si es un derivado Next del Factory.';
325
383
  const { proceed } = await prompts({
326
384
  type: 'confirm',
327
385
  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?',
386
+ message: `Este repo no tiene lockfile. Se hará un auto-registro por paths (perfil \`${profile}\`).\n` +
387
+ `${detected}\nLos archivos del cerebro que coincidan se sobrescribirán con la última ` +
388
+ 'versión; tu `CLAUDE.md` se conserva si ya existe. ¿Continuar?',
330
389
  initial: false,
331
390
  });
332
391
  if (!proceed) {
@@ -343,8 +402,15 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
343
402
  const repoPaths = new Set(collectClaudePaths(rootDir));
344
403
  const plan = planAutoRegister(manifest, repoPaths);
345
404
  // 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);
405
+ // deletes on the first sync. CLAUDE.md is dev-owned (rama B / write-if-absent):
406
+ // if it already exists on disk it is preserved (filtered out of writes); only
407
+ // written when absent. The lockfile still records the manifest's CLAUDE.md hash,
408
+ // so a preserved dev CLAUDE.md reads as "locally edited" on future updates →
409
+ // always ignoreLocal (rama A), never silently clobbered.
410
+ const claudeMdExists = existsSync(path.join(rootDir, CLAUDE_MD_FILE));
411
+ const writes = manifest.files
412
+ .map((f) => f.path)
413
+ .filter((p) => !(p === CLAUDE_MD_FILE && claudeMdExists));
348
414
  const backupDir = defaultBackupDir(path.dirname(stagedDir));
349
415
  applyPlan(rootDir, stagedDir, { writes, deletes: [] }, backupDir);
350
416
  // The lockfile = the manifest (its hashes are already the normalized hashes of
@@ -354,11 +420,15 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
354
420
  const pkgPath = path.join(rootDir, 'package.json');
355
421
  if (existsSync(pkgPath))
356
422
  maintainDerivedPkg(pkgPath, manifest.version);
423
+ if (claudeMdExists) {
424
+ console.log('\nConservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
425
+ '(`@.claude/rules/*`); corre `factory doctor` para detectar rules sin importar.');
426
+ }
357
427
  if (plan.ambiguous.length > 0) {
358
428
  console.log('\nArchivos en `.claude/` que no están en el manifest (revisar manualmente):');
359
429
  for (const p of plan.ambiguous)
360
430
  console.log(` • ambiguo — revisar manualmente: ${p}`);
361
431
  }
362
- console.log(`\n✔ Auto-registro completo: ${plan.kitOwned.length} kit-owned, ${plan.toAdd.length} agregados, ` +
363
- `${plan.ambiguous.length} ambiguos.`);
432
+ console.log(`\n✔ Auto-registro completo (v${manifest.version}, perfil ${manifest.profile}): ` +
433
+ `${plan.kitOwned.length} kit-owned, ${plan.toAdd.length} agregados, ${plan.ambiguous.length} ambiguos.`);
364
434
  }
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.');
@@ -16,6 +16,16 @@ 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
+ /**
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';
19
29
  /** The script 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
@@ -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 { PROFILES, UPDATE_SCRIPT_CMD, 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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "0.1.8",
3
+ "version": "1.0.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",