@timekast/factory 1.28.1 → 2.0.1

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.
Files changed (38) hide show
  1. package/dist/commands/backlog.js +52 -15
  2. package/dist/commands/doctor.js +210 -29
  3. package/dist/commands/env.js +154 -18
  4. package/dist/commands/invite-admin.js +62 -15
  5. package/dist/commands/provision.js +5815 -763
  6. package/dist/commands/status.js +12 -2
  7. package/dist/commands/ticket.js +4 -4
  8. package/dist/commands/update.js +97 -12
  9. package/dist/commands/vault.js +2627 -0
  10. package/dist/index.js +97 -33
  11. package/dist/lib/backlog-mapping.js +111 -18
  12. package/dist/lib/backlog-parse.js +1 -1
  13. package/dist/lib/claude-md.js +320 -0
  14. package/dist/lib/cloudflare-api.js +213 -37
  15. package/dist/lib/constants.js +70 -6
  16. package/dist/lib/env-file.js +45 -7
  17. package/dist/lib/env-wizard.js +66 -2
  18. package/dist/lib/initial-migration.js +3 -2
  19. package/dist/lib/neon-api.js +145 -10
  20. package/dist/lib/package-json.js +37 -16
  21. package/dist/lib/preflight.js +92 -38
  22. package/dist/lib/provision-preflight.js +158 -100
  23. package/dist/lib/provision-state.js +652 -20
  24. package/dist/lib/publish-engine.js +1 -1
  25. package/dist/lib/rail.js +400 -0
  26. package/dist/lib/railway-api.js +851 -0
  27. package/dist/lib/vault-adopt.js +1495 -0
  28. package/dist/lib/vault-api.js +1106 -0
  29. package/dist/lib/vault-config.js +21 -0
  30. package/dist/lib/vault-deploys.js +898 -0
  31. package/dist/lib/vercel-env.js +251 -31
  32. package/dist/lib/wrapper-wiring.js +160 -0
  33. package/dist/tui/env-wizard-flow.js +17 -3
  34. package/dist/tui/menu.js +4 -22
  35. package/dist/tui/provision-flow.js +68 -13
  36. package/package.json +1 -1
  37. package/dist/commands/secrets.js +0 -71
  38. package/dist/lib/env-merge.js +0 -238
@@ -9,8 +9,10 @@
9
9
  * parser/mapping (`backlog-parse.ts` / `backlog-mapping.ts`) — this file only
10
10
  * orchestrates; it re-implements neither the HTTP nor the field mapping.
11
11
  *
12
- * READ-ONLY BY DEFAULT: with no `BACKLOG_API_KEY` / `BACKLOG_PROJECT_ID` in
13
- * `.env.local`, it prints a note and exits 0 — the push NEVER blocks `/backlog`.
12
+ * READ-ONLY BY DEFAULT: with no `BACKLOG_API_KEY` / `BACKLOG_PROJECT_ID` — read from the
13
+ * `local` environment of the repo's vault when `.timekast/provision.json` carries a `vault`
14
+ * block, else from `.env.local` (VAULT-006) — it prints a note with the real cause and exits 0:
15
+ * the push NEVER blocks `/backlog`.
14
16
  *
15
17
  * IDEMPOTENCY (smoke Hallazgo 3): a re-POST of an existing client-supplied `id`
16
18
  * returns `500` on the live API, NOT the documented `409` — so a status code is an
@@ -195,7 +197,7 @@ async function loadSyncState(apiKey, projectId, api, existing, items) {
195
197
  * abort the run — §4).
196
198
  */
197
199
  export async function runBacklogPush(flags, rootDir = process.cwd(), deps = {}) {
198
- const creds = (deps.credentials ?? ((dir) => resolveBacklogCredentials(dir)))(rootDir);
200
+ const creds = await (deps.credentials ?? ((dir) => resolveBacklogCredentials(dir, deps.credentialDeps)))(rootDir);
199
201
  if (creds.status === 'not-configured') {
200
202
  console.log(`ℹ️ Push al backlog central omitido: ${creds.reason}`);
201
203
  console.log(' (read-only por defecto — el push nunca bloquea `/backlog`.)');
@@ -666,22 +668,35 @@ async function runWithDeadline(ms, work, deadlineSleep) {
666
668
  }
667
669
  /**
668
670
  * Orchestrate `factory backlog sync`. Read-only degradation: no credentials → note +
669
- * return (exit 0 — the sync NEVER blocks the workflow). The point sync (unless
670
- * `--heal-only`) + the self-healing heartbeat both run under a single 5s deadline;
671
- * on timeout the command cuts clean and the caller continues (fire-and-forget, §4).
671
+ * return (exit 0 — the sync NEVER blocks the workflow). The credential resolution —
672
+ * which in a repo with a vault is a network read (VAULT-006) —, the point sync (unless
673
+ * `--heal-only`) and the self-healing heartbeat ALL run under a single 5s deadline: a
674
+ * slow vault cannot stretch the heartbeat. A cut before the credentials resolve is a
675
+ * `not-configured` note; a cut after is the usual clean cut. Either way the caller
676
+ * continues (fire-and-forget, §4).
672
677
  */
673
678
  export async function runBacklogSync(flags, rootDir = process.cwd(), deps = {}) {
674
- const creds = (deps.credentials ?? ((dir) => resolveBacklogCredentials(dir)))(rootDir);
675
- if (creds.status === 'not-configured') {
676
- console.log(`ℹ️ Sync al backlog central omitido: ${creds.reason}`);
677
- console.log(' (fire-and-forget — el sync nunca bloquea el workflow.)');
678
- return;
679
- }
680
- const { apiKey, projectId } = creds;
679
+ const resolveCredentials = deps.credentials ?? ((dir) => resolveBacklogCredentials(dir, deps.credentialDeps));
681
680
  const api = deps.api ?? {};
682
- const collected = (deps.collect ?? collectBacklog)(rootDir);
681
+ let creds;
682
+ let collectFailure;
683
+ let cut = false;
683
684
  let heal = { repaired: 0, failed: 0, skippedNoUuid: 0 };
684
685
  const work = async () => {
686
+ const resolved = await resolveCredentials(rootDir);
687
+ creds = resolved;
688
+ // Resolved after the deadline already fired: the run was reported as cut — do nothing more.
689
+ if (cut || resolved.status === 'not-configured')
690
+ return;
691
+ const { apiKey, projectId } = resolved;
692
+ let collected;
693
+ try {
694
+ collected = (deps.collect ?? collectBacklog)(rootDir);
695
+ }
696
+ catch (error) {
697
+ collectFailure = { error };
698
+ return;
699
+ }
685
700
  if (!flags.healOnly) {
686
701
  await runPointSync(flags, collected, apiKey, projectId, api);
687
702
  }
@@ -689,7 +704,29 @@ export async function runBacklogSync(flags, rootDir = process.cwd(), deps = {})
689
704
  };
690
705
  const completed = await runWithDeadline(SYNC_TIMEOUT_MS, work, deps.deadlineSleep);
691
706
  if (!completed) {
692
- console.warn(`⚠ Sync al backlog central cortado a los ${SYNC_TIMEOUT_MS / 1000}s — el workflow continúa sin esperar.`);
707
+ cut = true;
708
+ if (creds === undefined) {
709
+ console.log(`ℹ️ Sync al backlog central omitido: la lectura de credenciales no terminó en ` +
710
+ `${SYNC_TIMEOUT_MS / 1000}s (la bóveda de secretos no respondió a tiempo).`);
711
+ console.log(' (fire-and-forget — el sync nunca bloquea el workflow.)');
712
+ return;
713
+ }
714
+ if (creds.status === 'configured') {
715
+ console.warn(`⚠ Sync al backlog central cortado a los ${SYNC_TIMEOUT_MS / 1000}s — el workflow continúa sin esperar.`);
716
+ return;
717
+ }
718
+ // Resolved to `not-configured` right at the deadline: narrate its real reason below.
719
+ }
720
+ if (collectFailure)
721
+ throw collectFailure.error;
722
+ // `completed` with no credentials only happens if the resolver seam rejected: fail closed.
723
+ const resolved = creds ?? {
724
+ status: 'not-configured',
725
+ reason: 'no pude resolver las credenciales del backlog central.',
726
+ };
727
+ if (resolved.status === 'not-configured') {
728
+ console.log(`ℹ️ Sync al backlog central omitido: ${resolved.reason}`);
729
+ console.log(' (fire-and-forget — el sync nunca bloquea el workflow.)');
693
730
  return;
694
731
  }
695
732
  if (heal.repaired || heal.failed) {
@@ -13,9 +13,21 @@
13
13
  * post-bootstrap, and that the patch channel for `src/` is EPIC 2 (agentic
14
14
  * update, not yet available).
15
15
  * - MISSING INITIAL MIGRATION: a derivative that ships a Drizzle schema and no
16
- * generated `.sql`, so its `vercel-build` (`pnpm build && pnpm db:migrate`)
17
- * has nothing to apply and its first deploy never creates the tables.
16
+ * generated `.sql`, so the migration step of its deploy — `vercel-build`
17
+ * (`pnpm build && pnpm db:migrate`) on Vercel, the pre-deploy (`pnpm db:migrate`)
18
+ * on Railway — has nothing to apply and its first deploy never creates the tables.
18
19
  * INFORMATIONAL — see below.
20
+ * - STALE RAIL FILE: a `~/.claude/.env` left on the machine from the file-based
21
+ * rail. The rail is read from the vault now (`fx-secrets-vault §3`), so that file
22
+ * is a dead copy of tokens nobody rotates: doctor names its absolute path and asks
23
+ * to delete it. It only checks EXISTENCE (`lstat`, no symlink follow) — it never
24
+ * opens, reads or deletes the file.
25
+ * - VAULT MODE (ADOPT-004): `bóveda` · `declinado` · `sin decidir` · `no aplica`, read from
26
+ * `.timekast/provision.json` (local, synchronous — part of `computeDoctor`). Only `sin decidir`
27
+ * suggests `factory vault adopt`: a declined repo is never asked again, `core` never. In
28
+ * `bóveda`, `runDoctor` adds the vault ↔ Vercel DRIFT by display hash as a SEPARATE, bounded
29
+ * and degradable step (`computeVaultDrift`): without a session or network it is "no
30
+ * verificable" with the cause — the offline part of the report never waits on it otherwise.
19
31
  *
20
32
  * No lockfile (pre-CLI repo) → delegate to the "run factory:update" advice, exit
21
33
  * 0, never abort (same pattern as `status` / the §8 edge case).
@@ -27,18 +39,27 @@
27
39
  * turn a notice into a blocked command through the back door. If it ever has to
28
40
  * live there, it goes as `warn` (`LauncherCheckStatus`), which never trips the exit.
29
41
  */
30
- import { existsSync, readFileSync, statSync } from 'node:fs';
42
+ import { existsSync, lstatSync, readFileSync, statSync } from 'node:fs';
43
+ import { homedir } from 'node:os';
31
44
  import path from 'node:path';
32
45
  import { CLIError } from '../lib/cli-error.js';
46
+ import { KIT_RULE_PATH_RE, RULES_DIR, bareImportPaths, resolveImportFrom, } from '../lib/claude-md.js';
33
47
  import { collectClaudePaths } from '../lib/claude-paths.js';
34
48
  import { CLAUDE_MD_FILE } from '../lib/constants.js';
35
49
  import { MIGRATIONS_DIR, SCHEMA_DIR, needsInitialMigration, readMigrationFacts, } from '../lib/initial-migration.js';
36
50
  import { hasLockfile, normalizeThenHash, readLockfile } from '../lib/lockfile.js';
37
- import { runLauncherPreflight, } from '../lib/preflight.js';
51
+ import { LAUNCHER_RAIL_TIMEOUT_MS, runLauncherPreflight, } from '../lib/preflight.js';
52
+ import { readNeonRevokePending } from '../lib/provision-state.js';
53
+ import { createRailResolver } from '../lib/rail.js';
38
54
  import { detectRepo } from '../lib/repo-detection.js';
55
+ import { readVaultMode, renderRepoVault, repoVaultReportFor, } from '../lib/vault-adopt.js';
39
56
  import { BETA_BANNER } from './status.js';
40
- /** Matches a flat always-on rule file shipped by the kit (`.claude/rules/<name>.md`). */
41
- const RULE_PATH_RE = /^\.claude\/rules\/[^/]+\.md$/;
57
+ /**
58
+ * Matches a flat always-on rule file shipped by the kit (`.claude/rules/<name>.md`).
59
+ * Imported from the retrofit module so ONE definition governs both the check here and
60
+ * the A/B/C classification `update` runs over the same set.
61
+ */
62
+ const RULE_PATH_RE = KIT_RULE_PATH_RE;
42
63
  /**
43
64
  * Matches ONLY the reserved on-demand rules heading (`Rules (on-demand reference)`
44
65
  * and close variants), not any heading that merely contains "on-demand" (e.g.
@@ -66,10 +87,67 @@ export const A1_WARNING = 'Aviso de seguridad (A1): el boilerplate `src/` y sus
66
87
  */
67
88
  export const MISSING_INITIAL_MIGRATION_NOTICE = `⚠ Falta la migración inicial: hay schema en \`${SCHEMA_DIR}/\` y ningún \`.sql\` en ` +
68
89
  `\`${MIGRATIONS_DIR}/\`.\n` +
69
- ' El deploy corre `pnpm build && pnpm db:migrate`, así que el primer deploy no crearía ' +
90
+ ' Cada deploy migra (en Vercel, `vercel-build` corre `pnpm build && pnpm db:migrate`; en ' +
91
+ 'Railway, el pre-deploy corre `pnpm db:migrate`), así que el primer deploy no crearía ' +
70
92
  'ninguna tabla. Genera la migración con `pnpm db:generate` y commitéala antes del próximo ' +
71
93
  'deploy.\n' +
72
94
  ' (Aviso informativo: `factory doctor` no bloquea — sale con éxito de todos modos.)';
95
+ /**
96
+ * Where the retired file-based rail lived, relative to the home directory. Only its
97
+ * EXISTENCE is ever checked (see {@link findStaleRailFile}).
98
+ */
99
+ export const STALE_RAIL_FILE_REL = path.join('.claude', '.env');
100
+ /**
101
+ * The notice for a rail file left on the machine. Names the ABSOLUTE path and reads
102
+ * unambiguously as "delete it, it is no longer used" — and says doctor did not touch it.
103
+ * Exported for the tests.
104
+ */
105
+ export function staleRailFileNotice(absPath) {
106
+ return (`⚠ Archivo del rail que ya no se usa: ${absPath}\n` +
107
+ ' El rail ahora se lee de la bóveda con tu sesión (`infisical login`), no de ese archivo. ' +
108
+ 'Lo que tenga es una copia de tokens que ya nadie rota: bórralo.\n' +
109
+ ' (`factory doctor` no lo abrió, no lo leyó ni lo borró.)');
110
+ }
111
+ /**
112
+ * The absolute path of a stale `~/.claude/.env`, or `null` when there is none.
113
+ * `lstat` so a symlink is reported as itself (never followed) and a directory counts
114
+ * too; nothing is opened or read. Any stat failure → `null` (nothing to report).
115
+ */
116
+ export function findStaleRailFile(homeDir) {
117
+ const abs = path.join(homeDir, STALE_RAIL_FILE_REL);
118
+ try {
119
+ lstatSync(abs);
120
+ return abs;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ /**
127
+ * The line `doctor` prints while replaced Neon keys are still alive, waiting for the delivery
128
+ * proof: how many, and which run settles them. `null` when there are none.
129
+ */
130
+ export function neonRevokePendingNotice(count) {
131
+ if (count <= 0)
132
+ return null;
133
+ return (`⚠ ${count === 1 ? '1 key de Neon anterior sigue viva' : `${count} keys de Neon anteriores siguen vivas`} ` +
134
+ 'por revocar (bloque `vault` de `.timekast/provision.json`): esperan a que el sync ' +
135
+ '`develop:/ci → secrets de GitHub Actions` confirme que entregó la nueva. Las revoca la siguiente ' +
136
+ 'corrida de `factory provision` con bóveda (p. ej. `--resume`).');
137
+ }
138
+ /** The count of {@link DoctorResult.neonRevokePending} — never throws (the mode reports errors). */
139
+ function countNeonRevokePending(rootDir) {
140
+ try {
141
+ return readNeonRevokePending(rootDir)?.keyIds.length ?? 0;
142
+ }
143
+ catch {
144
+ return 0;
145
+ }
146
+ }
147
+ /** The repo root `doctor` diagnoses: the root of the repo cwd is in, not cwd itself. */
148
+ function doctorRoot(rootDir) {
149
+ return rootDir ?? detectRepo(process.cwd()).repoRoot ?? process.cwd();
150
+ }
73
151
  /** Parse `doctor`'s argv slice into flags. Unknown flags are ignored. */
74
152
  export function parseDoctorFlags(argv) {
75
153
  return {
@@ -111,18 +189,49 @@ function onDemandSectionText(claudeMd) {
111
189
  }
112
190
  return out.join('\n');
113
191
  }
192
+ /**
193
+ * True when a path a CLAUDE.md imports is an INDEX of rules worth following: it must
194
+ * live under `.claude/rules/` and still be there after normalization (a `..` segment
195
+ * that climbs out of the dir is refused — nothing outside the rules tree is read).
196
+ */
197
+ function isRulesIndexCandidate(rel) {
198
+ if (!rel.startsWith(`${RULES_DIR}/`) || !rel.endsWith('.md'))
199
+ return false;
200
+ return path.posix.normalize(rel).startsWith(`${RULES_DIR}/`);
201
+ }
114
202
  /**
115
203
  * Collect the rule paths a CLAUDE.md actually `@import`s — a line whose trimmed
116
- * text is `@<path>` (the real import directive). A prose mention or a fenced
117
- * code block ("no agregues `@.claude/rules/SK.md`") is NOT a bare directive line,
118
- * so it does not count as imported.
204
+ * text is `@<path>` (the real import directive). A prose mention is NOT a bare
205
+ * directive line, and neither is a line inside a fenced code block: the shared
206
+ * reader (`bareImportPaths`) tracks fence state, so a doc that SHOWS the directive
207
+ * in a ``` sample is never read as performing it.
208
+ *
209
+ * 🔴 Follows an INDEX **one hop**. The kit's CLAUDE.md now imports a single
210
+ * `@.claude/rules/INDEX.md` that fans out to the six always-on rules; reading only
211
+ * the top-level file would report all six as "not imported" on every healthy repo.
212
+ * So when an imported path lives under `.claude/rules/` AND exists on disk (the kit's
213
+ * INDEX, or any index the dev wrote), its own `@<path>` lines count as imported too.
214
+ *
215
+ * The nested paths are resolved RELATIVE TO THE FILE THAT CONTAINS THEM (the Claude
216
+ * Code rule for memory imports) and normalized back to repo-relative — that is why
217
+ * `INDEX.md` lists `@CORE.md`, not `@.claude/rules/CORE.md`.
218
+ *
219
+ * One hop only: no recursion, so there is no cycle to guard and no depth to bound. An
220
+ * INDEX that is referenced but NOT on disk (the `core` profile, which ships no INDEX)
221
+ * is simply not followed — never an error, never a crash.
119
222
  */
120
- function importedRulePaths(claudeMd) {
121
- const paths = new Set();
122
- for (const raw of claudeMd.split('\n')) {
123
- const line = raw.trim();
124
- if (line.startsWith('@'))
125
- paths.add(line.slice(1).trim());
223
+ function importedRulePaths(claudeMd, rootDir) {
224
+ const direct = bareImportPaths(claudeMd);
225
+ const paths = new Set(direct);
226
+ for (const rel of direct) {
227
+ if (!isRulesIndexCandidate(rel))
228
+ continue;
229
+ const abs = path.join(rootDir, rel);
230
+ if (!existsSync(abs) || !statSync(abs).isFile())
231
+ continue;
232
+ for (const nested of bareImportPaths(readFileSync(abs, 'utf8'))) {
233
+ paths.add(resolveImportFrom(rel, nested));
234
+ }
126
235
  }
127
236
  return paths;
128
237
  }
@@ -137,11 +246,16 @@ export function computeDoctor(deps = {}) {
137
246
  // Diagnose at the REPO ROOT (where the brain + lockfile live), not cwd — so
138
247
  // `doctor` from a subdir of a managed repo reports the real state instead of
139
248
  // "no lockfile" (consistency with `add`/`update`). Tests inject `rootDir`.
140
- const rootDir = deps.rootDir ?? detectRepo(process.cwd()).repoRoot ?? process.cwd();
249
+ const rootDir = doctorRoot(deps.rootDir);
250
+ // Vault mode (ADOPT-004) — local and synchronous; the drift is `runDoctor`'s separate step.
251
+ const vault = readVaultMode(rootDir);
252
+ const neonRevokePending = countNeonRevokePending(rootDir);
141
253
  // Initial-migration gap (GAP-002). Delegated WHOLE to the provision predicate —
142
254
  // facts read from disk, decision (Factory exception included) taken there. Doctor
143
255
  // does not re-derive "is this the Factory": one copy of that rule, in one place.
144
256
  const missingInitialMigration = needsInitialMigration(readMigrationFacts(rootDir));
257
+ // Stale file-based rail on this machine (RAIL-003) — existence only, never read.
258
+ const staleRailFile = findStaleRailFile(deps.homeDir ?? homedir());
145
259
  if (!hasLockfile(rootDir)) {
146
260
  return {
147
261
  orphans: [],
@@ -152,8 +266,13 @@ export function computeDoctor(deps = {}) {
152
266
  // Always false on this branch, and by the predicate rather than by a literal:
153
267
  // no lockfile IS the Factory signal, which `needsInitialMigration` short-circuits.
154
268
  missingInitialMigration,
269
+ staleRailFile,
155
270
  noLockfile: true,
156
271
  beta: false,
272
+ vault,
273
+ // `vault adopt` requires a lockfile: suggesting it here would send the dev to a refusal.
274
+ suggestAdopt: false,
275
+ neonRevokePending,
157
276
  };
158
277
  }
159
278
  const lock = readLockfile(rootDir);
@@ -188,7 +307,8 @@ export function computeDoctor(deps = {}) {
188
307
  const claudeMdMissing = !existsSync(claudeMdPath);
189
308
  if (!claudeMdMissing) {
190
309
  const claudeMd = readFileSync(claudeMdPath, 'utf8');
191
- const imported = importedRulePaths(claudeMd); // bare `@<path>` directive lines
310
+ // Bare `@<path>` directive lines, plus one hop through an INDEX under `.claude/rules/`.
311
+ const imported = importedRulePaths(claudeMd, rootDir);
192
312
  const onDemand = onDemandSectionText(claudeMd);
193
313
  for (const entry of lock.files) {
194
314
  if (!RULE_PATH_RE.test(entry.path))
@@ -206,17 +326,28 @@ export function computeDoctor(deps = {}) {
206
326
  claudeMdMissing,
207
327
  a1Warning: A1_WARNING,
208
328
  missingInitialMigration,
329
+ staleRailFile,
209
330
  noLockfile: false,
210
331
  beta,
332
+ vault,
333
+ suggestAdopt: vault.mode === 'sin decidir',
334
+ neonRevokePending,
211
335
  };
212
336
  }
213
337
  /** Render the doctor result to the console. The A1 notice is ALWAYS last. */
214
- function renderDoctor(result) {
338
+ function renderDoctor(result, repoVault) {
339
+ const vaultLines = renderRepoVault(repoVault, { suggestAdopt: result.suggestAdopt });
340
+ const neonNotice = neonRevokePendingNotice(result.neonRevokePending);
341
+ if (neonNotice)
342
+ vaultLines.push(neonNotice);
215
343
  if (result.noLockfile) {
216
344
  // Nothing to say about migrations here: no lockfile IS the Factory signal, so
217
345
  // `missingInitialMigration` is false by construction on this branch.
218
346
  console.log('Sin lockfile detectado.');
219
347
  console.log('Ejecuta `factory:update` para inicializar el tracking de versión.');
348
+ console.log(`\n${vaultLines.join('\n')}`);
349
+ if (result.staleRailFile)
350
+ console.log(`\n${staleRailFileNotice(result.staleRailFile)}`);
220
351
  // The A1 notice is emitted even without a lockfile (always present).
221
352
  console.log(`\n${result.a1Warning}`);
222
353
  return;
@@ -263,6 +394,12 @@ function renderDoctor(result) {
263
394
  if (result.missingInitialMigration) {
264
395
  console.log(`\n${MISSING_INITIAL_MIGRATION_NOTICE}`);
265
396
  }
397
+ // Vault mode + drift (ADOPT-004): informative, never a failure.
398
+ console.log(`\n${vaultLines.join('\n')}`);
399
+ // Stale rail file (RAIL-003): machine-level, advisory, never deleted by doctor.
400
+ if (result.staleRailFile) {
401
+ console.log(`\n${staleRailFileNotice(result.staleRailFile)}`);
402
+ }
266
403
  // The A1 security notice is always emitted at the end (design §1 + §11).
267
404
  console.log(`\n${result.a1Warning}`);
268
405
  }
@@ -270,17 +407,51 @@ function renderDoctor(result) {
270
407
  * Run `factory doctor`. Always exits 0 — a missing lockfile is advisory, not a
271
408
  * failure (DIST-006 §8). The A1 notice is emitted on every run.
272
409
  *
273
- * Synchronous: the standard doctor report has no network/async seam. The
274
- * additive `--launcher` flow is async (it spawns `gh`) and lives in its own
275
- * entrypoint, {@link runLauncherDoctor}.
410
+ * The offline report (`computeDoctor`) is synchronous and has no network seam. Only a repo in
411
+ * `bóveda` mode adds the drift, a separate bounded step that degrades to "no verificable" (no
412
+ * session, no network, no Vercel project or `railway` block) and never throws for that. Outside `bóveda` nothing is
413
+ * awaited, so the report is rendered synchronously, exactly as before.
276
414
  */
277
- export function runDoctor(deps = {}) {
278
- const result = computeDoctor(deps);
279
- renderDoctor(result);
415
+ export async function runDoctor(deps = {}) {
416
+ const rootDir = doctorRoot(deps.rootDir);
417
+ const result = computeDoctor({ ...deps, rootDir });
418
+ if (result.vault.mode !== 'bóveda') {
419
+ renderDoctor(result, {
420
+ mode: result.vault.mode,
421
+ modeError: result.vault.error,
422
+ drift: { status: 'no aplica' },
423
+ });
424
+ return;
425
+ }
426
+ renderDoctor(result, await repoVaultReportFor(rootDir, result.vault, deps.drift));
427
+ }
428
+ /**
429
+ * The repo's vault report for the launcher document, within the launcher's budget. The document
430
+ * must ALWAYS come out (the launcher waits for it), so any failure here — even the Vitest seam
431
+ * refusal, which fires before any request — degrades to "no verificable" with its message.
432
+ */
433
+ async function launcherRepoVault(deps, rail) {
434
+ const rootDir = doctorRoot(deps.rootDir);
435
+ const reading = readVaultMode(rootDir);
436
+ try {
437
+ return await repoVaultReportFor(rootDir, reading, {
438
+ rail,
439
+ timeoutMs: LAUNCHER_RAIL_TIMEOUT_MS,
440
+ ...deps.drift,
441
+ });
442
+ }
443
+ catch (error) {
444
+ return {
445
+ mode: reading.mode,
446
+ modeError: reading.error,
447
+ drift: {
448
+ status: 'no verificable',
449
+ reason: error instanceof CLIError ? error.message : 'error interno al calcular el drift.',
450
+ railway: 'no verificado',
451
+ },
452
+ };
453
+ }
280
454
  }
281
- // ---------------------------------------------------------------------------
282
- // `factory doctor --launcher` (LTUI-002) — env preflight, any-platform/any-shell
283
- // ---------------------------------------------------------------------------
284
455
  /** The glyph per launcher-check status. */
285
456
  const STATUS_GLYPH = {
286
457
  ok: '✅',
@@ -340,12 +511,22 @@ export function renderLauncherDoctor(checks) {
340
511
  * Node version + the gh steps without spawning sub-processes).
341
512
  */
342
513
  export async function runLauncherDoctor(deps = {}, json = false) {
343
- const checks = await runLauncherPreflight(deps);
514
+ // ONE rail resolver for the secrets check and the drift: the vault is read once per run.
515
+ const rail = deps.rail ?? createRailResolver({ timeoutMs: LAUNCHER_RAIL_TIMEOUT_MS });
516
+ const [preflight, repoVault] = await Promise.all([
517
+ runLauncherPreflight({ ...deps, rail }),
518
+ launcherRepoVault(deps, rail),
519
+ ]);
520
+ // ADOPT-004: mode + drift ride ADDITIVELY on the `secrets` element — its id, status and message
521
+ // are untouched (RAIL-003: `warn` = keys missing / no session, never `fail`), so an installed
522
+ // launcher that predates the field ignores it (ADR-004 §3).
523
+ const checks = preflight.map((check) => check.id === 'secrets' ? { ...check, repoVault } : check);
344
524
  if (json) {
345
525
  process.stdout.write(`${JSON.stringify(checks)}\n`);
346
526
  return;
347
527
  }
348
528
  renderLauncherDoctor(checks);
529
+ console.log(`\n${renderRepoVault(repoVault, { suggestAdopt: false }).join('\n')}`);
349
530
  if (checks.some((c) => c.status === 'fail')) {
350
531
  // Non-zero exit without a duplicate error dump: the per-check ❌ lines above
351
532
  // already told the user what to fix. The empty message keeps the top-level