@timekast/factory 2.3.1 → 2.4.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.
@@ -44,6 +44,7 @@ import { RAIL_TIMEKAST, RailError, RailKeyMissingError } from '../lib/rail.js';
44
44
  import { classifyDeploymentState, connectedProjects, createRailwayClient, RailwayApiError, RailwayNameTakenError, RAILWAY_SERVICE_NAME, sameRepo as sameRailwayRepo, } from '../lib/railway-api.js';
45
45
  import { isRailwayProvided, isRailwaySyncTo, railwaySyncDifferences, railwaySyncLabel, railwaySyncName, } from '../lib/vault-adopt.js';
46
46
  import { createVaultClient, formatLocation, VAULT_CI_PATH, VAULT_ENV_DEVELOP, VAULT_ENV_LOCAL, VAULT_ENV_MAIN, VAULT_LAYOUT_ENVIRONMENTS, VAULT_ROOT_PATH, VAULT_TEST_ISOLATION_KEY, } from '../lib/vault-api.js';
47
+ import { readValueFlag } from '../lib/flags.js';
47
48
  import { wireVaultWrapper } from '../lib/wrapper-wiring.js';
48
49
  /**
49
50
  * The THREE GitHub Secrets the E2E runner (`pnpm test:e2e` → `e2e-runner.ts` +
@@ -143,20 +144,6 @@ const KNOWN_DNS_ROUTES = ['org', 'cliente-cf', 'registrar'];
143
144
  /** Default verification-poll timeout (minutes) and interval (seconds) — issue §4. */
144
145
  export const DEFAULT_VERIFY_TIMEOUT_MIN = 10;
145
146
  export const VERIFY_POLL_INTERVAL_SEC = 30;
146
- /** Read the value of a `--key=value` or `--key value` flag from an argv slice. */
147
- function readValueFlag(argv, name) {
148
- const prefix = `--${name}=`;
149
- const inline = argv.find((arg) => arg.startsWith(prefix));
150
- if (inline)
151
- return inline.slice(prefix.length);
152
- const idx = argv.indexOf(`--${name}`);
153
- if (idx !== -1) {
154
- const next = argv[idx + 1];
155
- if (next && !next.startsWith('-'))
156
- return next;
157
- }
158
- return undefined;
159
- }
160
147
  /**
161
148
  * Parse `--services=a,b,c` into the typed service list, rejecting unknown tokens
162
149
  * with a `CLIError` (a typo must not silently provision nothing). An empty/absent
@@ -0,0 +1,255 @@
1
+ /**
2
+ * `factory prune <sub>` — share a derived repo with outside collaborators WITHOUT the
3
+ * methodology. The member who runs it acts with their own `gh` session and permissions.
4
+ *
5
+ * Model (`.claude/docs/working-with-collaborators.md`): members of the org clone the repo and
6
+ * run `factory update`, which drops the whole kit on their disk — the restricted part is
7
+ * gitignored, so it never enters the repository. Collaborators fork and open PRs; they see the
8
+ * product, the always-on rules and the kit systems, never the content of the procedures.
9
+ *
10
+ * Everything here is switched on per repo by `.timekast/prune.json`, which only `prepare`
11
+ * writes. A repo without it behaves exactly as before.
12
+ */
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { execa } from 'execa';
16
+ import { CLIError } from '../lib/cli-error.js';
17
+ import { GITIGNORE_FILE } from '../lib/constants.js';
18
+ import { readValueFlag } from '../lib/flags.js';
19
+ import { commitWithPrivateIndex, trackedFiles } from '../lib/prune-git.js';
20
+ import { checkPr, REJECTED_PACKAGE_FIELDS } from '../lib/prune-pr-check.js';
21
+ import { applyTierFiles, defaultPruneDeps, originRepo, pruneAudit, pruneCutover, pruneFinish, pruneInvite, } from '../lib/prune-remote.js';
22
+ import { DEFAULT_PRUNE_TIER, PRUNE_MARKER_PATH, getTier, isRestricted, readPruneMarker, readPruneTiers, } from '../lib/prune-tiers.js';
23
+ import { detectRepo } from '../lib/repo-detection.js';
24
+ const PRUNE_USAGE = 'Uso: `factory prune prepare [--tier=collab] [--skip-verify]` · ' +
25
+ '`factory prune cutover [--confirm] [--discard-develop] [--resume] [--repo=owner/nombre]` · ' +
26
+ '`factory prune invite <usuario-github> [--repo=owner/nombre]` · ' +
27
+ '`factory prune finish [--repo=owner/nombre]` · `factory prune audit [--repo=owner/nombre]` · ' +
28
+ '`factory prune check-pr <número> [--repo=owner/nombre] [--json]`.';
29
+ export function parsePrunePrepareFlags(argv) {
30
+ return {
31
+ tier: readValueFlag(argv, 'tier') ?? DEFAULT_PRUNE_TIER,
32
+ skipVerify: argv.includes('--skip-verify'),
33
+ };
34
+ }
35
+ /**
36
+ * Write the tier's `.gitignore` block and the switch, untrack what the block now ignores, and
37
+ * commit all of it in one go. Idempotent: a second run on a prepared repo changes nothing and
38
+ * commits nothing.
39
+ */
40
+ export async function prunePrepare(rootDir, flags) {
41
+ const head = await execa('git', ['rev-parse', '--verify', '-q', 'HEAD'], {
42
+ cwd: rootDir,
43
+ reject: false,
44
+ });
45
+ if (head.exitCode !== 0) {
46
+ throw new CLIError('El repo no tiene commits todavía; haz el primer commit antes de `prune`.');
47
+ }
48
+ const tier = getTier(readPruneTiers(rootDir), flags.tier);
49
+ // The two files this command owns must not carry someone's uncommitted work: the commit
50
+ // below takes them whole.
51
+ const dirty = await execa('git', ['status', '--porcelain', '--', GITIGNORE_FILE, PRUNE_MARKER_PATH], { cwd: rootDir, reject: false });
52
+ if (dirty.stdout.trim() !== '') {
53
+ throw new CLIError(`\`${GITIGNORE_FILE}\` o \`${PRUNE_MARKER_PATH}\` tienen cambios sin commitear. ` +
54
+ 'Commitéalos o descártalos y vuelve a correr `factory prune prepare`.');
55
+ }
56
+ // 1. The block and the switch — `at` survives a re-run with the same tier, so a re-run
57
+ // changes nothing.
58
+ const prior = readPruneMarker(rootDir);
59
+ const legacyBlockRemoved = applyTierFiles(rootDir, flags.tier, tier, prior?.tier === flags.tier ? prior.at : new Date().toISOString());
60
+ // 2. Untrack what the tier restricts, and commit it with the block and the switch.
61
+ const untracked = (await trackedFiles(rootDir)).filter((p) => isRestricted(tier, p));
62
+ const commit = await commitWithPrivateIndex(rootDir, {
63
+ remove: untracked,
64
+ add: [GITIGNORE_FILE, PRUNE_MARKER_PATH],
65
+ message: `chore(prune): keep the methodology local (tier ${flags.tier})`,
66
+ });
67
+ return {
68
+ tier: flags.tier,
69
+ untracked,
70
+ legacyBlockRemoved,
71
+ ...(commit.committed ? { commit: commit.sha } : {}),
72
+ };
73
+ }
74
+ /** Group untracked paths by their first two segments, for a summary a person can read. */
75
+ function summarizePaths(paths) {
76
+ const groups = new Map();
77
+ for (const p of paths) {
78
+ const key = p.split('/').slice(0, 2).join('/');
79
+ groups.set(key, (groups.get(key) ?? 0) + 1);
80
+ }
81
+ return [...groups].sort().map(([k, n]) => ` • ${k}/ (${n})`);
82
+ }
83
+ async function runPrunePrepare(flags) {
84
+ const rootDir = requireRepoRoot();
85
+ const result = await prunePrepare(rootDir, flags);
86
+ if (result.legacyBlockRemoved) {
87
+ console.log('✔ Quité el bloque `timekast:collab` hecho a mano; lo reemplaza el bloque del kit.');
88
+ }
89
+ if (!result.commit) {
90
+ console.log(`✔ El repo ya estaba preparado con el tier \`${result.tier}\`; no cambió nada.`);
91
+ }
92
+ else {
93
+ console.log(`✔ Tier \`${result.tier}\` aplicado y commiteado (${result.commit}, sin push). ` +
94
+ `Dejé de trackear ${result.untracked.length} archivo(s); siguen en tu disco:`);
95
+ for (const line of summarizePaths(result.untracked))
96
+ console.log(line);
97
+ }
98
+ if (flags.skipVerify)
99
+ return;
100
+ const pkgPath = path.join(rootDir, 'package.json');
101
+ const hasVerify = existsSync(pkgPath) &&
102
+ Boolean(JSON.parse(readFileSync(pkgPath, 'utf8')).scripts
103
+ ?.verify);
104
+ if (!hasVerify) {
105
+ console.log('ℹ️ Este repo no tiene `pnpm verify`; no corrí la verificación.');
106
+ return;
107
+ }
108
+ console.log('\n▸ Corriendo `pnpm verify`…');
109
+ const verify = await execa('pnpm', ['verify'], { cwd: rootDir, stdio: 'inherit', reject: false });
110
+ if (verify.exitCode !== 0) {
111
+ process.exitCode = 1;
112
+ console.error('✖ `pnpm verify` falló. Revísalo antes de seguir con el corte.');
113
+ }
114
+ }
115
+ // ---------------------------------------------------------------------------
116
+ // cutover · invite · finish · audit (GitHub, with the member's `gh` session)
117
+ // ---------------------------------------------------------------------------
118
+ export function parseCutoverFlags(argv) {
119
+ return {
120
+ repo: readValueFlag(argv, 'repo'),
121
+ tier: readValueFlag(argv, 'tier') ?? DEFAULT_PRUNE_TIER,
122
+ confirm: argv.includes('--confirm'),
123
+ discardDevelop: argv.includes('--discard-develop'),
124
+ resume: argv.includes('--resume'),
125
+ };
126
+ }
127
+ async function runPruneCutover(flags, deps) {
128
+ const rootDir = requireRepoRoot();
129
+ const { preflight, result } = await pruneCutover(rootDir, flags, deps);
130
+ if (!result) {
131
+ console.log([
132
+ `✔ Preflight del corte de \`${preflight.repo}\` en verde. No toqué nada.`,
133
+ '',
134
+ 'Plan:',
135
+ ` 1. Snapshot de \`main\` (${preflight.mainSha.slice(0, 7)}) con el tier aplicado, validado sin rutas restringidas.`,
136
+ ` 2. Crear \`${preflight.next}\` (privado) y subir el snapshot como \`main\` y \`develop\` — un solo commit.`,
137
+ ` 3. Renombrar \`${preflight.repo}\` → \`${preflight.legacy}\` y \`${preflight.next}\` → \`${preflight.repo}\`.`,
138
+ ' 4. Permitir forks SOLO en el repo nuevo; sin workflows de PRs de forks.',
139
+ ...(preflight.developAhead.length > 0
140
+ ? [
141
+ ` ⚠ Se pierden ${preflight.developAhead.length} commit(s) de \`develop\` (--discard-develop).`,
142
+ ]
143
+ : []),
144
+ '',
145
+ 'Para ejecutarlo: `factory prune cutover --confirm`.',
146
+ ].join('\n'));
147
+ return;
148
+ }
149
+ console.log(`✔ Corte hecho: \`${result.repo}\` es el repo nuevo (un commit, ${result.snapshot}); ` +
150
+ `el anterior quedó como \`${result.legacy}\`.`);
151
+ for (const w of result.warnings)
152
+ console.warn(`⚠ ${w}`);
153
+ if (result.notMigrated.length > 0) {
154
+ console.log(`\nNo migró (sigue en \`${result.legacy}\`):`);
155
+ for (const line of result.notMigrated)
156
+ console.log(` • ${line}`);
157
+ }
158
+ console.log('\nSiguiente: reconecta el proyecto de Vercel al repo nuevo en el dashboard (Settings → Git), ' +
159
+ 'pide a los socios volver a clonar, y corre `factory prune finish` cuando Production despliegue.');
160
+ }
161
+ function printAudit(repo, audit) {
162
+ const clean = audit.roots.length === 1 && audit.leaks.length === 0;
163
+ if (clean) {
164
+ console.log(`✔ Historia de \`${repo}\` limpia: una raíz, ninguna ruta del tier \`${audit.tierName}\`.`);
165
+ return true;
166
+ }
167
+ if (audit.roots.length !== 1) {
168
+ console.error(`✖ \`${repo}\` tiene ${audit.roots.length} commits raíz (esperado: 1) — ` +
169
+ 'probablemente un clon viejo pusheó la historia anterior.');
170
+ }
171
+ if (audit.leaks.length > 0) {
172
+ console.error(`✖ ${audit.leaks.length} ruta(s) restringida(s) en la historia (incluye refs de PRs):`);
173
+ for (const p of audit.leaks)
174
+ console.error(` ${p}`);
175
+ }
176
+ return false;
177
+ }
178
+ // ---------------------------------------------------------------------------
179
+ // Dispatch
180
+ // ---------------------------------------------------------------------------
181
+ function requireRepoRoot() {
182
+ const root = detectRepo(process.cwd()).repoRoot;
183
+ if (!root)
184
+ throw new CLIError('Corre `factory prune` dentro de un repo git.');
185
+ return root;
186
+ }
187
+ async function repoFrom(argv) {
188
+ return readValueFlag(argv, 'repo') ?? (await originRepo(requireRepoRoot()));
189
+ }
190
+ /** `factory prune <sub> [...]` — dispatch lives here so every branch is testable. */
191
+ export async function runPruneCommand(args, deps = defaultPruneDeps) {
192
+ const sub = args[0];
193
+ const rest = args.slice(1);
194
+ switch (sub) {
195
+ case 'prepare':
196
+ await runPrunePrepare(parsePrunePrepareFlags(rest));
197
+ return;
198
+ case 'cutover':
199
+ await runPruneCutover(parseCutoverFlags(rest), deps);
200
+ return;
201
+ case 'invite': {
202
+ const user = rest.find((a) => !a.startsWith('-') && rest[rest.indexOf(a) - 1] !== '--repo');
203
+ if (!user)
204
+ throw new CLIError(`Falta el usuario de GitHub del colaborador. ${PRUNE_USAGE}`);
205
+ const repo = await repoFrom(rest);
206
+ await pruneInvite(repo, user, deps);
207
+ console.log(`✔ Invité a \`${user}\` a \`${repo}\` con lectura. Tiene que aceptar la invitación; ` +
208
+ 'después forkea y abre PRs a la rama de trabajo.');
209
+ return;
210
+ }
211
+ case 'finish': {
212
+ const repo = await repoFrom(rest);
213
+ const done = await pruneFinish(repo, deps);
214
+ console.log(`✔ \`${repo}-legacy\`: forks apagados, ${done.removedCollaborators.length} colaborador(es) ` +
215
+ 'externo(s) quitado(s) y archivado.');
216
+ if (!printAudit(repo, done.audit))
217
+ process.exitCode = 1;
218
+ return;
219
+ }
220
+ case 'check-pr': {
221
+ const raw = rest.find((a) => /^\d+$/.test(a));
222
+ if (!raw)
223
+ throw new CLIError(`Falta el número del PR. ${PRUNE_USAGE}`);
224
+ const repo = await repoFrom(rest);
225
+ const result = await checkPr(repo, Number(raw), deps);
226
+ const refused = result.rejected.length > 0 || result.packageFields.length > 0;
227
+ if (rest.includes('--json')) {
228
+ console.log(JSON.stringify({ refused, ...result }, null, 2));
229
+ }
230
+ else if (!refused) {
231
+ console.log(`✔ PR #${raw}: ningún archivo fuera del producto (${result.files.length} archivo(s)).`);
232
+ }
233
+ else {
234
+ console.error(`✖ PR #${raw} rechazado antes del checkout — toca lo que corre alrededor del producto:`);
235
+ for (const r of result.rejected)
236
+ console.error(` ${r.path} — ${r.why}`);
237
+ for (const f of result.packageFields) {
238
+ console.error(` package.json#${f} — ${REJECTED_PACKAGE_FIELDS.includes(f) ? 'comandos que corren en tu máquina' : 'revísalo a mano'}`);
239
+ }
240
+ console.error(' Regrésalo al colaborador, o que un socio rehaga ese cambio.');
241
+ }
242
+ if (refused)
243
+ process.exitCode = 1;
244
+ return;
245
+ }
246
+ case 'audit': {
247
+ const repo = await repoFrom(rest);
248
+ if (!printAudit(repo, await pruneAudit(repo, deps)))
249
+ process.exitCode = 1;
250
+ return;
251
+ }
252
+ default:
253
+ throw new CLIError(`Subcomando de prune desconocido: \`${sub ?? '(ninguno)'}\`. ${PRUNE_USAGE}`);
254
+ }
255
+ }
@@ -42,13 +42,15 @@ import { applyPlan, clearUpdateState, defaultBackupDir, hasUpdateState, readUpda
42
42
  import { applyClaudeMdRetrofit, assessClaudeMd, reportClaudeMd, } from '../lib/claude-md.js';
43
43
  import { collectClaudePaths } from '../lib/claude-paths.js';
44
44
  import { CLIError } from '../lib/cli-error.js';
45
- import { CLAUDE_MD_FILE, GITATTRIBUTES_FILE, LOCKFILE_FILE, PRETTIERIGNORE_FILE, PROFILES, TIMEKAST_DIR, } from '../lib/constants.js';
45
+ import { CLAUDE_MD_FILE, GITATTRIBUTES_FILE, GITIGNORE_FILE, LOCKFILE_FILE, PRETTIERIGNORE_FILE, PROFILES, TIMEKAST_DIR, } from '../lib/constants.js';
46
46
  import { decideCommit } from '../lib/commit-decision.js';
47
47
  import { extractManagedBlock, syncManagedBlock } from '../lib/gitattributes.js';
48
48
  import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
49
49
  import { detectProfile, maintainKitScripts, setAgentKitVersion } from '../lib/package-json.js';
50
50
  import { runPreflight } from '../lib/preflight.js';
51
51
  import { printProvisionPointer } from '../lib/provision-pointer.js';
52
+ import { planPruneMaintenance } from '../lib/prune-update.js';
53
+ import { isRestricted, PRUNE_MARKER_PATH } from '../lib/prune-tiers.js';
52
54
  import { printRetrofitReminder } from '../lib/retrofit-reminder.js';
53
55
  import { hasProvisionState } from '../lib/provision-state.js';
54
56
  import { loginCommand } from '../lib/rail.js';
@@ -332,15 +334,20 @@ function runVerify(rootDir) {
332
334
  * → prompt y/n · headless → commit by default (a headless run needs it). Stages ONLY
333
335
  * what the update touched (the applied manifest paths + lockfile + package.json +
334
336
  * .gitattributes) — never `git add -A`. Best-effort: no identity / no repo → warn + skip.
337
+ * In a pruned repo (`prune` given) it also untracks what the tier restricts, leaves the
338
+ * restricted applied paths out, and stages `.gitignore`, the switch and the shared kit paths
339
+ * that are not tracked yet.
335
340
  */
336
- async function maybeCommitBrain(rootDir, version, appliedPaths, flags) {
341
+ async function maybeCommitBrain(rootDir, version, appliedPaths, flags, prune = null) {
337
342
  const decision = decideCommit({
338
343
  commit: flags.commit,
339
344
  noCommit: flags.noCommit,
340
345
  isTTY: Boolean(process.stdout.isTTY),
341
346
  });
342
- if (decision === 'skip')
347
+ if (decision === 'skip') {
348
+ reportPendingPrune(prune);
343
349
  return;
350
+ }
344
351
  let doCommit = decision === 'commit';
345
352
  if (decision === 'prompt') {
346
353
  const { yes } = await prompts({
@@ -351,17 +358,31 @@ async function maybeCommitBrain(rootDir, version, appliedPaths, flags) {
351
358
  });
352
359
  doCommit = yes === true;
353
360
  }
354
- if (!doCommit)
361
+ if (!doCommit) {
362
+ reportPendingPrune(prune);
355
363
  return;
364
+ }
356
365
  const candidates = [
357
- ...appliedPaths,
366
+ // A pruned repo never stages what its tier keeps local (it is ignored, and the guard would
367
+ // refuse it); it DOES stage the shared kit paths not yet tracked (`prune-update.ts`).
368
+ ...(prune ? appliedPaths.filter((p) => !isRestricted(prune.tier, p)) : appliedPaths),
369
+ ...(prune ? [...prune.publish, GITIGNORE_FILE, PRUNE_MARKER_PATH] : []),
358
370
  `${TIMEKAST_DIR}/${LOCKFILE_FILE}`,
359
371
  ...(existsSync(path.join(rootDir, 'package.json')) ? ['package.json'] : []),
360
372
  ...(existsSync(path.join(rootDir, GITATTRIBUTES_FILE)) ? [GITATTRIBUTES_FILE] : []),
361
373
  ...(existsSync(path.join(rootDir, PRETTIERIGNORE_FILE)) ? [PRETTIERIGNORE_FILE] : []),
362
374
  ];
363
375
  try {
364
- await execa('git', ['add', '--', ...candidates], { cwd: rootDir, reject: false });
376
+ const stageable = await filterStageable(rootDir, candidates);
377
+ if (prune && prune.untrack.length > 0) {
378
+ await execa('git', ['rm', '--cached', '-q', '--ignore-unmatch', '--', ...prune.untrack], {
379
+ cwd: rootDir,
380
+ reject: false,
381
+ });
382
+ }
383
+ if (stageable.length > 0) {
384
+ await execa('git', ['add', '--', ...stageable], { cwd: rootDir, reject: false });
385
+ }
365
386
  // Nothing staged (e.g. a no-op update) → no empty commit.
366
387
  const staged = await execa('git', ['diff', '--cached', '--quiet'], {
367
388
  cwd: rootDir,
@@ -382,6 +403,34 @@ async function maybeCommitBrain(rootDir, version, appliedPaths, flags) {
382
403
  console.warn('Aviso: no se pudo commitear; el cerebro se instaló igual.');
383
404
  }
384
405
  }
406
+ /**
407
+ * The brain commit was skipped in a pruned repo: whatever the tier asks the index to change
408
+ * (untrack a restricted path, publish a newly shared one) is still pending. `prune prepare`
409
+ * does exactly that and is idempotent, so it is the one instruction to give.
410
+ */
411
+ function reportPendingPrune(prune) {
412
+ if (!prune || (prune.untrack.length === 0 && prune.publish.length === 0))
413
+ return;
414
+ console.log(`ℹ️ Repo compartido (tier \`${prune.tierName}\`): quedan ${prune.untrack.length} ruta(s) por ` +
415
+ `dejar de trackear y ${prune.publish.length} por publicar. Corre \`factory prune prepare\` ` +
416
+ 'para commitearlo.');
417
+ }
418
+ /**
419
+ * Keep only the paths `git add` can take: on disk, or in the index (a deletion to stage).
420
+ *
421
+ * ONE path that is in neither — a retired file the dev never committed, which the update just
422
+ * removed from disk — makes `git add -- a b c` exit 128 having staged NOTHING, so the brain
423
+ * commit was lost in silence (the warning below only fires when `git commit` fails, and a
424
+ * clean index commits nothing). Exported for the CLI suite.
425
+ */
426
+ export async function filterStageable(rootDir, candidates) {
427
+ const listed = await execa('git', ['ls-files', '-z', '--cached', '--', ...candidates], {
428
+ cwd: rootDir,
429
+ reject: false,
430
+ });
431
+ const inIndex = new Set(listed.exitCode === 0 ? listed.stdout.split('\0').filter(Boolean) : []);
432
+ return [...new Set(candidates)].filter((rel) => inIndex.has(rel) || existsSync(path.join(rootDir, rel)));
433
+ }
385
434
  /**
386
435
  * `--resume`: re-apply a persisted mid-flight state without re-downloading
387
436
  * (design §7.5). Reads the staged dir + plan from `.timekast/.update-state.json`.
@@ -422,7 +471,8 @@ async function runResume(rootDir, flags) {
422
471
  const claudeMdInserted = claudeMd ? applyClaudeMdRetrofit(rootDir, claudeMd) : false;
423
472
  // Maintain dotfiles BEFORE removing the staged dir (the .gitattributes source).
424
473
  const pkgMutations = maintainDerivedDotfiles(rootDir, state.stagedDir, newLock.version);
425
- await maybeCommitBrain(rootDir, newLock.version, [...state.plan.writes, ...state.plan.deletes, ...(claudeMdInserted ? [CLAUDE_MD_FILE] : [])], flags);
474
+ const prune = await planPruneMaintenance(rootDir, state.stagedDir, newLock.files.map((f) => f.path));
475
+ await maybeCommitBrain(rootDir, newLock.version, [...state.plan.writes, ...state.plan.deletes, ...(claudeMdInserted ? [CLAUDE_MD_FILE] : [])], flags, prune);
426
476
  rmSync(state.stagedDir, { recursive: true, force: true });
427
477
  // A resumed update maintains package.json exactly like a normal one, so it owes the dev the
428
478
  // same account of what it moved there (`BND-004`).
@@ -940,7 +990,8 @@ export async function runUpdate(flags, deps = {}) {
940
990
  // in case B it is in `ignoreLocal`, so the plan never names it).
941
991
  const claudeMdInserted = applyClaudeMdRetrofit(rootDir, claudeMd);
942
992
  const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
943
- await maybeCommitBrain(rootDir, manifest.version, [...plan.writes, ...plan.deletes, ...(claudeMdInserted ? [CLAUDE_MD_FILE] : [])], flags);
993
+ const prune = await planPruneMaintenance(rootDir, stagedDir, manifest.files.map((f) => f.path));
994
+ await maybeCommitBrain(rootDir, manifest.version, [...plan.writes, ...plan.deletes, ...(claudeMdInserted ? [CLAUDE_MD_FILE] : [])], flags, prune);
944
995
  // `theirsConflicts` IS the overwritten-local list: same source whether the dev answered
945
996
  // the prompt, passed `--theirs-all`, or ran headless — there is no second path. The
946
997
  // package.json mutations ride the SAME closing summary, not three loose notices.
@@ -1056,6 +1107,9 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
1056
1107
  // first sync this repo records, so the birth seal = the manifest's version.
1057
1108
  writeLockfile(rootDir, { ...manifest, factoryVersion: manifest.version });
1058
1109
  const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
1110
+ // No brain commit on this path; the block is still kept in step (the index is left to
1111
+ // `prune prepare`, which the pending notice names).
1112
+ reportPendingPrune(await planPruneMaintenance(rootDir, stagedDir, manifest.files.map((f) => f.path)));
1059
1113
  reportPkgMutations(pkgMutations);
1060
1114
  if (claudeMdExists) {
1061
1115
  console.log('\nConservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { envWizardFlow } from './tui/env-wizard-flow.js';
19
19
  import { parseDoctorFlags, runDoctor, runLauncherDoctor } from './commands/doctor.js';
20
20
  import { parseNewFlags, runNew } from './commands/new.js';
21
21
  import { parseProvisionFlags, runProvision } from './commands/provision.js';
22
+ import { runPruneCommand } from './commands/prune.js';
22
23
  import { parsePublishArgs, runPublish } from './commands/publish.js';
23
24
  import { parseStatusFlags, runStatus } from './commands/status.js';
24
25
  import { runTicketCommand } from './commands/ticket.js';
@@ -52,6 +53,13 @@ Comandos:
52
53
  env <sub> push: sube product vars a Vercel (day-2) · wizard: configura las variables
53
54
  (en la bóveda si el repo la tiene; sin bóveda, en .env.local)
54
55
  invite-admin Crea una invitación de super_admin para un derivado (sin password en texto plano)
56
+ prune <sub> Comparte el repo con colaboradores externos sin la metodología (lo guía /prune):
57
+ prepare: aplica el tier (.gitignore + .timekast/prune.json) y deja de trackear
58
+ lo restringido · cutover: corte a un repo cuya historia nunca tuvo la
59
+ metodología (sin --confirm sólo corre el preflight) · invite <usuario>:
60
+ colaborador externo con lectura · finish: archiva el -legacy y audita ·
61
+ audit: revisa que la historia del repo no tenga rutas restringidas ·
62
+ check-pr <n>: rechaza un PR de colaborador que toque algo fuera del producto
55
63
  vault <sub> adopt: pasa a la bóveda un repo que nació antes de ella (sin --apply sólo
56
64
  enseña el plan, por nombre y huella — nunca valores; con varios proyectos
57
65
  de Vercel, --vercel-project <id> elige el principal) · sync railway: crea
@@ -410,6 +418,12 @@ async function main(argv) {
410
418
  await runVaultCommand(rest);
411
419
  return;
412
420
  }
421
+ case 'prune': {
422
+ // `factory prune <sub>` — share a repo with outside collaborators without the
423
+ // methodology. Dispatch lives in the command module (testable branches).
424
+ await runPruneCommand(rest);
425
+ return;
426
+ }
413
427
  case 'invite-admin': {
414
428
  // Mint a single-use super_admin invite against the target branch's DB.
415
429
  // Spawns the kit script `scripts/tools/invite-admin.ts` with the resolved
@@ -104,6 +104,19 @@ export const GITATTRIBUTES_FILE = '.gitattributes';
104
104
  * turns every later `factory:update` into false conflicts (A2).
105
105
  */
106
106
  export const PRETTIERIGNORE_FILE = '.prettierignore';
107
+ /**
108
+ * Repo-root `.gitignore`. Same managed-block treatment, but ONLY in a pruned repo
109
+ * (`factory prune`): the block lists the kit paths kept local to the members, generated
110
+ * from `.claude/policy/prune-tiers.json`. A repo without the switch below never gets one.
111
+ */
112
+ export const GITIGNORE_FILE = '.gitignore';
113
+ /**
114
+ * File name, under `.timekast/`, of the per-repo switch written by `factory prune prepare`
115
+ * (`{ tier, at }`). Its OWN file, not a lockfile field: `update` rewrites the lockfile whole.
116
+ * Without it every prune behavior — the `.gitignore` block, the untracking in `update`, the
117
+ * pre-commit guard — stays off.
118
+ */
119
+ export const PRUNE_FILE = 'prune.json';
107
120
  /**
108
121
  * Managed-block sentinels. These are PREFIXES (the live source lines carry extra
109
122
  * descriptive text + trailing `>>>`/`<<<`), matched with `startsWith` so detection
@@ -0,0 +1,14 @@
1
+ /** Read the value of a `--key=value` or `--key value` flag from an argv slice. */
2
+ export function readValueFlag(argv, name) {
3
+ const prefix = `--${name}=`;
4
+ const inline = argv.find((arg) => arg.startsWith(prefix));
5
+ if (inline)
6
+ return inline.slice(prefix.length);
7
+ const idx = argv.indexOf(`--${name}`);
8
+ if (idx !== -1) {
9
+ const next = argv[idx + 1];
10
+ if (next && !next.startsWith('-'))
11
+ return next;
12
+ }
13
+ return undefined;
14
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Git plumbing for `factory prune`: list what the repo tracks, and commit a set of
3
+ * untrack + add operations WITHOUT going through the shared index.
4
+ *
5
+ * Why a private index and not the kit's usual commit-by-pathspec (`GIT.md §3.6.1`): a
6
+ * pathspec commit takes the listed paths' content from the WORKING TREE, so a path removed
7
+ * with `git rm --cached` (still on disk, now ignored) is put right back — the commit records
8
+ * no deletion and the methodology stays published. Staging the removal in the real index
9
+ * instead would leave it there for any other commit to swallow. A throwaway index seeded from
10
+ * HEAD gives both properties: the commit holds exactly these operations, and nothing another
11
+ * session staged can ride along.
12
+ */
13
+ import { existsSync, rmSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { execa } from 'execa';
16
+ import { CLIError } from './cli-error.js';
17
+ /** Every path the index tracks (POSIX, repo-relative). */
18
+ export async function trackedFiles(rootDir) {
19
+ const res = await execa('git', ['ls-files', '-z'], { cwd: rootDir, reject: false });
20
+ if (res.exitCode !== 0) {
21
+ throw new CLIError(`No se pudo listar los archivos del repo (\`git ls-files\`): ${res.stderr}`);
22
+ }
23
+ return res.stdout.split('\0').filter(Boolean);
24
+ }
25
+ /**
26
+ * Commit `remove` + `add` on top of HEAD through a private index, then bring the real index
27
+ * of those paths in line with the new HEAD. Hooks run as for any commit. Nothing to commit →
28
+ * `{ committed: false }`, no empty commit.
29
+ */
30
+ export async function commitWithPrivateIndex(rootDir, input) {
31
+ const gitDirRes = await execa('git', ['rev-parse', '--absolute-git-dir'], {
32
+ cwd: rootDir,
33
+ reject: false,
34
+ });
35
+ if (gitDirRes.exitCode !== 0) {
36
+ throw new CLIError('Esto no es un repo git.');
37
+ }
38
+ const indexFile = path.join(gitDirRes.stdout.trim(), `tk-prune-index-${process.pid}`);
39
+ const env = { ...process.env, GIT_INDEX_FILE: indexFile };
40
+ const run = (args) => execa('git', args, { cwd: rootDir, env, reject: false });
41
+ try {
42
+ const head = await execa('git', ['rev-parse', '--verify', '-q', 'HEAD'], {
43
+ cwd: rootDir,
44
+ reject: false,
45
+ });
46
+ const seeded = await run(head.exitCode === 0 ? ['read-tree', 'HEAD'] : ['read-tree', '--empty']);
47
+ if (seeded.exitCode !== 0)
48
+ throw new CLIError(`\`git read-tree\` falló: ${seeded.stderr}`);
49
+ if (input.remove.length > 0) {
50
+ const rm = await run(['rm', '--cached', '-q', '--ignore-unmatch', '--', ...input.remove]);
51
+ if (rm.exitCode !== 0)
52
+ throw new CLIError(`\`git rm --cached\` falló: ${rm.stderr}`);
53
+ }
54
+ if (input.add.length > 0) {
55
+ const add = await run(['add', '-f', '--', ...input.add]);
56
+ if (add.exitCode !== 0)
57
+ throw new CLIError(`\`git add\` falló: ${add.stderr}`);
58
+ }
59
+ const diff = await run(['diff', '--cached', '--quiet']);
60
+ if (diff.exitCode === 0)
61
+ return { committed: false };
62
+ const commit = await run(['commit', '-q', '-m', input.message]);
63
+ if (commit.exitCode !== 0) {
64
+ throw new CLIError(`El commit falló (¿un hook lo rechazó o falta identidad git?):\n${commit.stderr || commit.stdout}`);
65
+ }
66
+ const sha = await execa('git', ['rev-parse', '--short', 'HEAD'], { cwd: rootDir });
67
+ // The real index still holds the pre-commit state of these paths: reset them to the new
68
+ // HEAD so `git status` shows the removals as done, not as staged re-additions.
69
+ await execa('git', ['reset', '-q', '--', ...input.remove, ...input.add], {
70
+ cwd: rootDir,
71
+ reject: false,
72
+ });
73
+ return { committed: true, sha: sha.stdout.trim() };
74
+ }
75
+ finally {
76
+ for (const f of [indexFile, `${indexFile}.lock`]) {
77
+ if (existsSync(f))
78
+ rmSync(f, { force: true });
79
+ }
80
+ }
81
+ }