@timekast/factory 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,7 +18,7 @@ import prompts from 'prompts';
18
18
  import { CLIError } from '../lib/cli-error.js';
19
19
  import { FACTORY_ORG, PROFILES } from '../lib/constants.js';
20
20
  import { parseLockfile, writeInitialLockfile } from '../lib/lockfile.js';
21
- import { applyPackageJsonEdits } from '../lib/package-json.js';
21
+ import { applyPackageJsonEdits, kitLocalScripts } from '../lib/package-json.js';
22
22
  import { renderDerivedReadme } from '../lib/readme.js';
23
23
  import { runPreflight } from '../lib/preflight.js';
24
24
  import { downloadProfileTarball, moveContentsInto, stageProfileTarball } from '../lib/unpack.js';
@@ -103,7 +103,7 @@ export async function runNew(name) {
103
103
  const pkgPath = path.join(destDir, 'package.json');
104
104
  const hasPackageJson = existsSync(pkgPath);
105
105
  if (hasPackageJson) {
106
- const { content, scriptAlreadyPresent } = applyPackageJsonEdits(readFileSync(pkgPath, 'utf8'), validName);
106
+ const { content, scriptAlreadyPresent } = applyPackageJsonEdits(readFileSync(pkgPath, 'utf8'), validName, kitLocalScripts(destDir));
107
107
  writeFileSync(pkgPath, content, 'utf8');
108
108
  if (scriptAlreadyPresent) {
109
109
  console.warn('Aviso: `factory:update` ya existía en package.json con otro valor; no se sobrescribió.');
@@ -41,7 +41,7 @@ import prompts from 'prompts';
41
41
  import { applyPlan, clearUpdateState, defaultBackupDir, hasUpdateState, readUpdateState, validateStagedManifest, writeUpdateState, } from '../lib/atomic-swap.js';
42
42
  import { collectClaudePaths } from '../lib/claude-paths.js';
43
43
  import { CLIError } from '../lib/cli-error.js';
44
- import { CLAUDE_MD_FILE, GITATTRIBUTES_FILE, LOCKFILE_FILE, PROFILES, TIMEKAST_DIR, } from '../lib/constants.js';
44
+ import { CLAUDE_MD_FILE, GITATTRIBUTES_FILE, LOCKFILE_FILE, PRETTIERIGNORE_FILE, PROFILES, TIMEKAST_DIR, } from '../lib/constants.js';
45
45
  import { decideCommit } from '../lib/commit-decision.js';
46
46
  import { extractManagedBlock, syncManagedBlock } from '../lib/gitattributes.js';
47
47
  import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
@@ -186,6 +186,7 @@ async function maybeCommitBrain(rootDir, version, appliedPaths, flags) {
186
186
  `${TIMEKAST_DIR}/${LOCKFILE_FILE}`,
187
187
  ...(existsSync(path.join(rootDir, 'package.json')) ? ['package.json'] : []),
188
188
  ...(existsSync(path.join(rootDir, GITATTRIBUTES_FILE)) ? [GITATTRIBUTES_FILE] : []),
189
+ ...(existsSync(path.join(rootDir, PRETTIERIGNORE_FILE)) ? [PRETTIERIGNORE_FILE] : []),
189
190
  ];
190
191
  try {
191
192
  await execa('git', ['add', '--', ...candidates], { cwd: rootDir, reject: false });
@@ -251,16 +252,18 @@ function warnOnScriptConflict(action, _pkgPath) {
251
252
  }
252
253
  }
253
254
  /**
254
- * After a sync, maintain the derived project's co-owned dotfiles — `package.json`
255
- * AND `.gitattributes` — in one place. Centralized (not scattered per command) so
256
- * all THREE update paths (main, legacy auto-register, resume) are covered by
257
- * construction: a per-command call already regressed once (legacy + resume were
258
- * missed). `stagedDir` is the freshly-unpacked tarball, the source of the
259
- * `.gitattributes` managed block.
255
+ * After a sync, maintain the derived project's co-owned dotfiles — `package.json`,
256
+ * `.gitattributes` AND `.prettierignore` — in one place. Centralized (not scattered
257
+ * per command) so all THREE update paths (main, legacy auto-register, resume) are
258
+ * covered by construction: a per-command call already regressed once (legacy +
259
+ * resume were missed). `stagedDir` is the freshly-unpacked tarball, the source of
260
+ * the managed blocks. Runs BEFORE any brain commit, so the blocks land before a
261
+ * pre-commit hook could reformat the freshly-installed kit files.
260
262
  */
261
263
  function maintainDerivedDotfiles(rootDir, stagedDir, agentKitVersion) {
262
264
  maintainDerivedPkg(rootDir, agentKitVersion);
263
265
  syncDerivedGitattributes(rootDir, stagedDir);
266
+ syncDerivedPrettierignore(rootDir, stagedDir);
264
267
  }
265
268
  /**
266
269
  * Maintain the derived project's package.json: ensure the `factory:*` scripts
@@ -317,6 +320,34 @@ function syncDerivedGitattributes(rootDir, stagedDir) {
317
320
  console.warn('Aviso: no se pudo sincronizar `.gitattributes`; el cerebro se instaló igual.');
318
321
  }
319
322
  }
323
+ /**
324
+ * Sync the Factory's managed `.prettierignore` block (keeps the derived repo's
325
+ * formatter away from `.claude/**` — a pre-commit `prettier --write` over the kit
326
+ * files drifts disk from the lockfile hashes and turns every later update into
327
+ * false conflicts) into the derived repo, preserving the dev's own ignore rules.
328
+ * Same contract as `syncDerivedGitattributes`: canonical block READ from the
329
+ * staged tarball (single SSOT), best-effort, tarball without the file (pre-block
330
+ * releases) → silent no-op, a write failure never fails an applied update.
331
+ */
332
+ function syncDerivedPrettierignore(rootDir, stagedDir) {
333
+ try {
334
+ const srcPath = path.join(stagedDir, PRETTIERIGNORE_FILE);
335
+ if (!existsSync(srcPath))
336
+ return;
337
+ const block = extractManagedBlock(readFileSync(srcPath, 'utf8'));
338
+ if (!block)
339
+ return;
340
+ const { action } = syncManagedBlock(rootDir, block, PRETTIERIGNORE_FILE);
341
+ if (action === 'unchanged')
342
+ return;
343
+ console.log(action === 'created'
344
+ ? '✔ `.prettierignore` creado con el ignore del cerebro (`.claude/`) — evita falsos conflictos por formato en futuros updates.'
345
+ : '✔ `.prettierignore`: bloque del kit sincronizado (`.claude/` fuera del formatter).');
346
+ }
347
+ catch {
348
+ console.warn('Aviso: no se pudo sincronizar `.prettierignore`; el cerebro se instaló igual.');
349
+ }
350
+ }
320
351
  /** Print the deletes + kept-retired (design §7.6 — visible) + a one-line summary. */
321
352
  function reportSummary(diff, manifest) {
322
353
  if (diff.deleteSilent.length > 0) {
@@ -39,6 +39,15 @@ export const CLAUDE_MD_FILE = 'CLAUDE.md';
39
39
  * preserved byte-for-byte. See `syncManagedBlock` in `lib/gitattributes.ts`.
40
40
  */
41
41
  export const GITATTRIBUTES_FILE = '.gitattributes';
42
+ /**
43
+ * Repo-root `.prettierignore`. Same dual-owned managed-block treatment as
44
+ * `.gitattributes` (NOT tracked in the manifest — the dev's own ignore rules
45
+ * would read as local edits): the kit's block keeps the derived project's
46
+ * formatter away from `.claude/**`, whose prettier rewrite on the brain commit
47
+ * (table alignment, emphasis style) drifts disk from the lockfile hashes and
48
+ * turns every later `factory:update` into false conflicts (A2).
49
+ */
50
+ export const PRETTIERIGNORE_FILE = '.prettierignore';
42
51
  /**
43
52
  * Managed-block sentinels. These are PREFIXES (the live source lines carry extra
44
53
  * descriptive text + trailing `>>>`/`<<<`), matched with `startsWith` so detection
@@ -74,3 +83,14 @@ export const FACTORY_SCRIPTS = {
74
83
  [PUBLISH_SCRIPT_NAME]: PUBLISH_SCRIPT_CMD,
75
84
  [UNPUBLISH_SCRIPT_NAME]: UNPUBLISH_SCRIPT_CMD,
76
85
  };
86
+ /**
87
+ * Kit-local convenience script: the readiness sweep (`pnpm preflight`, consumed
88
+ * by the /deploy gate and /preflight). Unlike the npx-based `factory:*` scripts
89
+ * it shells a file that ships with the FULL brain, so the installer ensures it
90
+ * only when that file exists in the repo (file-gated — a core/non-Factory repo
91
+ * never gets a broken entry). Insert-if-missing like the rest: a dev's own
92
+ * `preflight` script is never overwritten.
93
+ */
94
+ export const PREFLIGHT_SCRIPT_NAME = 'preflight';
95
+ export const PREFLIGHT_SCRIPT_CMD = 'tsx scripts/tools/preflight.ts';
96
+ export const PREFLIGHT_SCRIPT_FILE = 'scripts/tools/preflight.ts';
@@ -1,15 +1,16 @@
1
1
  /**
2
- * Surgical sync of the Factory's managed `.gitattributes` block into a derived
3
- * project, mirroring the `package.json` script-insertion pattern (`package-json.ts`):
4
- * the delimited block is replaced verbatim, everything outside it is preserved.
2
+ * Surgical sync of a Factory-managed block into a derived project's dual-owned
3
+ * dotfile (`.gitattributes`, `.prettierignore`), mirroring the `package.json`
4
+ * script-insertion pattern (`package-json.ts`): the delimited block is replaced
5
+ * verbatim, everything outside it is preserved.
5
6
  *
6
- * Why not the lockfile/`track` engine: `.gitattributes` is dual-owned at the
7
+ * Why not the lockfile/`track` engine: these files are dual-owned at the
7
8
  * intra-file level (the Factory block + the dev's own rules share one file). The
8
- * lockfile hashes whole files, so tracking it would read the dev's rules as a
9
+ * lockfile hashes whole files, so tracking them would read the dev's rules as a
9
10
  * local edit and conflict on every update. Instead the block is synced as an
10
11
  * idempotent post-apply step, outside `diffLockfiles`/`applyPlan`.
11
12
  *
12
- * The canonical block is the single SSOT: it is READ from the `.gitattributes`
13
+ * The canonical block is the single SSOT: it is READ from the same-named file
13
14
  * that ships in the staged tarball (see `extractManagedBlock`), never duplicated
14
15
  * as a CLI constant. A derived repo's existing block is located by the
15
16
  * `MANAGED_BLOCK_START` / `_END` sentinels (prefix match), so the descriptive
@@ -49,11 +50,11 @@ export function extractManagedBlock(content) {
49
50
  return slice.join('\n');
50
51
  }
51
52
  /**
52
- * Sync `sourceBlock` (markers inclusive) into `<rootDir>/.gitattributes`,
53
- * preserving every rule outside the managed block. The whole file is normalized
54
- * to LF (it lives at repo root, outside `.claude/** text eol=lf`, so a source
55
- * packed with CRLF on an autocrlf machine is normalized here). Idempotent:
56
- * writes only when the bytes actually change.
53
+ * Sync `sourceBlock` (markers inclusive) into `<rootDir>/<fileName>` (default
54
+ * `.gitattributes`), preserving every rule outside the managed block. The whole
55
+ * file is normalized to LF (it lives at repo root, outside `.claude/** text eol=lf`,
56
+ * so a source packed with CRLF on an autocrlf machine is normalized here).
57
+ * Idempotent: writes only when the bytes actually change.
57
58
  *
58
59
  * - no file / empty file → `created` (block only)
59
60
  * - file without a start marker → `inserted` (block appended after a blank line)
@@ -61,8 +62,8 @@ export function extractManagedBlock(content) {
61
62
  * - start without end (dev clobbered half a line) → `updated`, regenerated
62
63
  * cleanly from start to EOF (NOT appended — avoids a dangling start marker)
63
64
  */
64
- export function syncManagedBlock(rootDir, sourceBlock) {
65
- const filePath = path.join(rootDir, GITATTRIBUTES_FILE);
65
+ export function syncManagedBlock(rootDir, sourceBlock, fileName = GITATTRIBUTES_FILE) {
66
+ const filePath = path.join(rootDir, fileName);
66
67
  const raw = existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
67
68
  const block = sourceBlock.replace(/\r\n/g, '\n').replace(/\n+$/, '');
68
69
  let result;
@@ -8,7 +8,7 @@
8
8
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { CLIError } from './cli-error.js';
11
- import { FACTORY_SCRIPTS, PROFILES, UPDATE_SCRIPT_NAME } from './constants.js';
11
+ import { FACTORY_SCRIPTS, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, UPDATE_SCRIPT_NAME, } from './constants.js';
12
12
  /**
13
13
  * Auto-detect the profile to install into an existing repo from its
14
14
  * `package.json`: a `factoryVersion` field means the repo was born from the
@@ -66,12 +66,12 @@ export function parsePackageJson(raw) {
66
66
  * only on a real edit) + the result for the primary `factory:update` script
67
67
  * (which drives the install messaging).
68
68
  */
69
- function ensureFactoryScripts(pkg) {
69
+ function ensureFactoryScripts(pkg, extraScripts = {}) {
70
70
  const scripts = pkg.scripts ?? {};
71
71
  pkg.scripts = scripts;
72
72
  let changed = false;
73
73
  let primary = { action: 'already-correct' };
74
- for (const [name, cmd] of Object.entries(FACTORY_SCRIPTS)) {
74
+ for (const [name, cmd] of Object.entries({ ...FACTORY_SCRIPTS, ...extraScripts })) {
75
75
  const existing = scripts[name];
76
76
  let result;
77
77
  if (existing === undefined) {
@@ -91,27 +91,41 @@ function ensureFactoryScripts(pkg) {
91
91
  return { changed, primary };
92
92
  }
93
93
  /**
94
- * Rename `package.json.name` and ensure the `factory:*` scripts exist,
95
- * preserving everything else. Returns the serialized document (with the
96
- * original indentation + trailing newline).
94
+ * The kit-local scripts to ensure for THIS repo: `preflight` only when its
95
+ * backing file ships here (full brain). File-gated so a core/non-Factory repo
96
+ * never gets an entry that shells a file it does not have — and it self-heals:
97
+ * the entry appears on the first install/update after the file lands.
98
+ */
99
+ export function kitLocalScripts(rootDir) {
100
+ return existsSync(path.join(rootDir, PREFLIGHT_SCRIPT_FILE))
101
+ ? { [PREFLIGHT_SCRIPT_NAME]: PREFLIGHT_SCRIPT_CMD }
102
+ : {};
103
+ }
104
+ /**
105
+ * Rename `package.json.name` and ensure the `factory:*` scripts (+ the
106
+ * file-gated kit-local `extraScripts`, see `kitLocalScripts`) exist, preserving
107
+ * everything else. Returns the serialized document (with the original
108
+ * indentation + trailing newline).
97
109
  *
98
110
  * If `factory:update` already exists with a different value it is left intact;
99
111
  * `scriptAlreadyPresent` reports that so the caller can warn the user.
100
112
  */
101
- export function applyPackageJsonEdits(raw, name) {
113
+ export function applyPackageJsonEdits(raw, name, extraScripts = {}) {
102
114
  const indent = detectIndent(raw);
103
115
  const pkg = parsePackageJson(raw);
104
116
  pkg.name = name;
105
- const { primary } = ensureFactoryScripts(pkg);
117
+ const { primary } = ensureFactoryScripts(pkg, extraScripts);
106
118
  const content = `${JSON.stringify(pkg, null, indent)}\n`;
107
119
  return { content, scriptAlreadyPresent: primary.action === 'conflict' };
108
120
  }
109
121
  /**
110
122
  * 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.
123
+ * plus the file-gated kit-local scripts (`preflight` when its file ships
124
+ * `kitLocalScripts` on the package.json's own directory) exist in the
125
+ * `package.json` at `pkgPath` (design §7.4). Reads, mutates only those keys,
126
+ * and re-serializes with the original indentation + trailing newline. NEVER
127
+ * overwrites a divergent value and NEVER touches `name`, deps, or any other
128
+ * script.
115
129
  *
116
130
  * Writes the file only when at least one script was added; if all are already
117
131
  * present (correct or divergent) the file is left byte-identical.
@@ -123,7 +137,7 @@ export function insertFactoryUpdateScript(pkgPath) {
123
137
  const raw = readFileSync(pkgPath, 'utf8');
124
138
  const indent = detectIndent(raw);
125
139
  const pkg = parsePackageJson(raw);
126
- const { changed, primary } = ensureFactoryScripts(pkg);
140
+ const { changed, primary } = ensureFactoryScripts(pkg, kitLocalScripts(path.dirname(pkgPath)));
127
141
  if (changed) {
128
142
  writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
129
143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",