mandrel 1.62.0 → 1.64.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.
Files changed (44) hide show
  1. package/.agents/scripts/agents-bootstrap-github.js +40 -48
  2. package/.agents/scripts/bootstrap.js +74 -60
  3. package/.agents/scripts/check-action-pinning.js +260 -0
  4. package/.agents/scripts/check-arch-cycles.js +38 -14
  5. package/.agents/scripts/epic-deliver-prepare.js +149 -104
  6. package/.agents/scripts/lib/baseline-snapshot.js +245 -141
  7. package/.agents/scripts/lib/bootstrap/branch-protection.js +8 -8
  8. package/.agents/scripts/lib/bootstrap/gh-preflight.js +3 -3
  9. package/.agents/scripts/lib/bootstrap/hitl-confirm.js +2 -2
  10. package/.agents/scripts/lib/bootstrap/merge-methods.js +7 -7
  11. package/.agents/scripts/lib/bootstrap/preflight.js +18 -15
  12. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +5 -5
  13. package/.agents/scripts/lib/bootstrap/prompt.js +5 -1
  14. package/.agents/scripts/lib/detect-package-manager.js +2 -2
  15. package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
  16. package/.agents/scripts/lib/onboard/init-tail.js +60 -69
  17. package/.agents/scripts/lib/orchestration/code-review.js +206 -168
  18. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
  19. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
  20. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
  21. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
  22. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
  23. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
  24. package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
  25. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
  26. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
  27. package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
  28. package/.agents/scripts/lib/signals/detectors/common.js +107 -0
  29. package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
  30. package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
  31. package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
  32. package/.agents/scripts/lib/story-body/story-body.js +102 -76
  33. package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
  34. package/.agents/scripts/providers/github/tickets.js +1 -1
  35. package/.agents/scripts/single-story-init.js +16 -3
  36. package/.agents/workflows/audit-architecture.md +9 -0
  37. package/.agents/workflows/helpers/deliver-stories.md +24 -2
  38. package/.agents/workflows/helpers/single-story-deliver.md +84 -1
  39. package/README.md +1 -1
  40. package/docs/CHANGELOG.md +43 -0
  41. package/lib/cli/init.js +66 -21
  42. package/lib/cli/sync.js +3 -3
  43. package/package.json +1 -1
  44. package/.agents/scripts/lib/onboard/detect-stack.js +0 -300
@@ -286,7 +286,11 @@ export async function resolveFromPicker(ctx) {
286
286
 
287
287
  const normalized = choices.map(normalizePickerChoice);
288
288
  const rl = await ctx.getRl();
289
- ctx.output.write(`${ctx.q.message}:\n`);
289
+ // The picker header uses `pickerMessage` when set, so a question can show
290
+ // list-oriented guidance here (e.g. "Select existing or press ENTER to create
291
+ // new one") while the manual-entry fall-through prompt (`askOnce`) uses the
292
+ // shorter `message` (e.g. "New GitHub repo name"). Falls back to `message`.
293
+ ctx.output.write(`${ctx.q.pickerMessage ?? ctx.q.message}:\n`);
290
294
  normalized.forEach((choice, index) => {
291
295
  ctx.output.write(` ${index + 1}) ${choice.label}\n`);
292
296
  });
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * detect-package-manager — shared lockfile-probe helper (Story #4048 B3).
3
3
  *
4
- * Five independent copies of this lockfile probe existed across the codebase:
4
+ * Several independent copies of this lockfile probe existed across the
5
+ * codebase before this consolidation:
5
6
  * - `lib/cli/update.js#detectPackageManager`
6
7
  * - `lib/bootstrap/project-bootstrap.js#detectPackageManager`
7
8
  * - `lib/runtime-deps/preflight.js#detectPackageManager`
8
- * - `lib/onboard/detect-stack.js#detectPackageManager`
9
9
  * - `lib/worktree/node-modules-strategy.js#selectInstallCommand` (inline)
10
10
  *
11
11
  * This module is the single authoritative implementation. It uses the
@@ -238,183 +238,217 @@ export async function createFollowUpIssue({
238
238
  * @param {(record: object, finding: object) => object} [opts.spec.decorateRecord]
239
239
  * @returns {Promise<{ filed: object[], skipped: object[], errors: string[] }>}
240
240
  */
241
- export async function graduate({
241
+ /**
242
+ * Validate the `graduate` preconditions (toggle, epicId, provider shape,
243
+ * currentRepo shape). Returns `null` when all preconditions pass, or a
244
+ * `{ skipped?, errors? }` partial-envelope the caller short-circuits on.
245
+ * Story #4075 — extracted from `graduate` so the orchestrating body holds
246
+ * no guard-chain branching.
247
+ */
248
+ function checkGraduatePreconditions({
242
249
  epicId,
243
250
  provider,
244
- config,
245
251
  currentRepo,
246
- frameworkRepo,
247
- gitRef = 'HEAD',
248
- classifier = defaultClassifier,
249
- ghPath = 'gh',
250
- spawnImpl,
251
- cwd,
252
- logger,
252
+ config,
253
253
  spec,
254
254
  }) {
255
- const envelope = { filed: [], skipped: [], errors: [] };
256
- const decorate =
257
- typeof spec.decorateRecord === 'function'
258
- ? spec.decorateRecord
259
- : (record) => record;
260
-
261
255
  if (!spec.isAutoFileEnabled(config)) {
262
- envelope.skipped.push({ reason: 'toggle-disabled' });
263
- return envelope;
256
+ return { skipped: [{ reason: 'toggle-disabled' }] };
264
257
  }
265
-
266
258
  if (!Number.isInteger(epicId) || epicId < 1) {
267
- envelope.errors.push(`${spec.fnName}: missing or invalid epicId`);
268
- return envelope;
259
+ return { errors: [`${spec.fnName}: missing or invalid epicId`] };
269
260
  }
270
261
  if (!provider || typeof provider.getTicketComments !== 'function') {
271
- envelope.errors.push(`${spec.fnName}: provider lacks getTicketComments`);
272
- return envelope;
262
+ return { errors: [`${spec.fnName}: provider lacks getTicketComments`] };
273
263
  }
274
264
  if (
275
265
  !currentRepo ||
276
266
  typeof currentRepo.owner !== 'string' ||
277
267
  typeof currentRepo.repo !== 'string'
278
268
  ) {
279
- envelope.errors.push(`${spec.fnName}: missing currentRepo {owner,repo}`);
280
- return envelope;
269
+ return { errors: [`${spec.fnName}: missing currentRepo {owner,repo}`] };
281
270
  }
271
+ return null;
272
+ }
282
273
 
283
- // 1. Read the structured comment off the Epic.
274
+ /**
275
+ * Read the source structured comment off the Epic and parse its findings.
276
+ * Returns `{ findings }` on success, or `{ skipped?, errors? }` for the
277
+ * no-comment / parse-empty / fetch-error short-circuits. Story #4075 —
278
+ * extracted from `graduate`.
279
+ */
280
+ async function loadGraduateFindings({ epicId, provider, spec }) {
284
281
  let comments;
285
282
  try {
286
283
  comments = await provider.getTicketComments(epicId);
287
284
  } catch (err) {
288
- envelope.errors.push(
289
- `getTicketComments failed for epic #${epicId}: ${err?.message ?? err}`,
290
- );
291
- return envelope;
292
- }
293
- if (!Array.isArray(comments) || comments.length === 0) {
294
- envelope.skipped.push({ reason: spec.noCommentReason });
295
- return envelope;
285
+ return {
286
+ errors: [
287
+ `getTicketComments failed for epic #${epicId}: ${err?.message ?? err}`,
288
+ ],
289
+ };
296
290
  }
297
- const matched = comments.filter(
291
+ const matched = (Array.isArray(comments) ? comments : []).filter(
298
292
  (c) => typeof c?.body === 'string' && c.body.includes(spec.commentMarker),
299
293
  );
300
294
  if (matched.length === 0) {
301
- envelope.skipped.push({ reason: spec.noCommentReason });
302
- return envelope;
295
+ return { skipped: [{ reason: spec.noCommentReason }] };
303
296
  }
304
- const sourceComment = matched[matched.length - 1];
305
-
306
- // 2. Parse findings.
307
- const findings = spec.parseFindings(sourceComment.body);
297
+ const findings = spec.parseFindings(matched[matched.length - 1].body);
308
298
  if (findings.length === 0) {
309
- envelope.skipped.push({ reason: 'no-non-blocking-findings' });
310
- return envelope;
299
+ return { skipped: [{ reason: 'no-non-blocking-findings' }] };
311
300
  }
301
+ return { findings };
302
+ }
312
303
 
313
- // 3. For each finding, route → idempotency probe → file.
314
- for (const finding of findings) {
315
- const exists = await probePathExists({
316
- ref: gitRef,
317
- path: finding.path,
318
- spawnImpl,
319
- cwd,
320
- });
321
- if (!exists) {
322
- envelope.skipped.push(
323
- decorate(
324
- {
325
- index: finding.index,
326
- reason: 'file-removed',
327
- path: finding.path,
328
- severity: finding.severity,
329
- },
330
- finding,
331
- ),
332
- );
333
- continue;
334
- }
304
+ /**
305
+ * Route a single finding (path-exists probe → repo routing → idempotency
306
+ * probe file) and fold the outcome into the running envelope. Story #4075
307
+ * — extracted from `graduate`'s per-finding loop body.
308
+ */
309
+ async function processGraduateFinding({
310
+ finding,
311
+ envelope,
312
+ decorate,
313
+ epicId,
314
+ currentRepo,
315
+ frameworkRepo,
316
+ classifier,
317
+ gitRef,
318
+ ghPath,
319
+ spawnImpl,
320
+ cwd,
321
+ logger,
322
+ spec,
323
+ }) {
324
+ const skip = (reason) =>
325
+ envelope.skipped.push(
326
+ decorate(
327
+ {
328
+ index: finding.index,
329
+ reason,
330
+ path: finding.path,
331
+ severity: finding.severity,
332
+ },
333
+ finding,
334
+ ),
335
+ );
335
336
 
336
- const source = classifier(finding.path, null);
337
- const routedRepo =
338
- source === 'framework' && frameworkRepo ? frameworkRepo : currentRepo;
337
+ const exists = await probePathExists({
338
+ ref: gitRef,
339
+ path: finding.path,
340
+ spawnImpl,
341
+ cwd,
342
+ });
343
+ if (!exists) return skip('file-removed');
339
344
 
340
- const isCrossRepo =
341
- routedRepo.owner !== currentRepo.owner ||
342
- routedRepo.repo !== currentRepo.repo;
343
- if (isCrossRepo) {
344
- logger?.info?.(spec.buildCrossRepoLog({ finding, routedRepo, source }));
345
- envelope.skipped.push(
346
- decorate(
347
- {
348
- index: finding.index,
349
- reason: 'cross-repo-deferred',
350
- path: finding.path,
351
- severity: finding.severity,
352
- },
353
- finding,
354
- ),
355
- );
356
- continue;
357
- }
345
+ const source = classifier(finding.path, null);
346
+ const routedRepo =
347
+ source === 'framework' && frameworkRepo ? frameworkRepo : currentRepo;
348
+ const isCrossRepo =
349
+ routedRepo.owner !== currentRepo.owner ||
350
+ routedRepo.repo !== currentRepo.repo;
351
+ if (isCrossRepo) {
352
+ logger?.info?.(spec.buildCrossRepoLog({ finding, routedRepo, source }));
353
+ return skip('cross-repo-deferred');
354
+ }
358
355
 
359
- const idMarker = spec.buildIdempotencyMarker(epicId, finding.index);
360
- const alreadyFiled = await probeMarkerExists({
361
- marker: idMarker,
362
- owner: routedRepo.owner,
363
- repo: routedRepo.repo,
364
- ghPath,
365
- spawnImpl,
366
- cwd,
367
- });
368
- if (alreadyFiled) {
369
- envelope.skipped.push(
370
- decorate(
371
- {
372
- index: finding.index,
373
- reason: 'already-filed',
374
- path: finding.path,
375
- severity: finding.severity,
376
- },
377
- finding,
378
- ),
379
- );
380
- continue;
381
- }
356
+ const idMarker = spec.buildIdempotencyMarker(epicId, finding.index);
357
+ const alreadyFiled = await probeMarkerExists({
358
+ marker: idMarker,
359
+ owner: routedRepo.owner,
360
+ repo: routedRepo.repo,
361
+ ghPath,
362
+ spawnImpl,
363
+ cwd,
364
+ });
365
+ if (alreadyFiled) return skip('already-filed');
366
+
367
+ const { title, body, labels } = spec.buildFollowUp({
368
+ finding,
369
+ source,
370
+ epicId,
371
+ idMarker,
372
+ });
373
+ const created = await createFollowUpIssue({
374
+ owner: routedRepo.owner,
375
+ repo: routedRepo.repo,
376
+ title,
377
+ body,
378
+ labels,
379
+ ghPath,
380
+ spawnImpl,
381
+ cwd,
382
+ });
383
+ if (created.error) {
384
+ envelope.errors.push(
385
+ `finding ${finding.index} (${finding.path}): ${created.error}`,
386
+ );
387
+ return;
388
+ }
389
+ envelope.filed.push(
390
+ decorate(
391
+ {
392
+ index: finding.index,
393
+ severity: finding.severity,
394
+ path: finding.path,
395
+ source,
396
+ repo: `${routedRepo.owner}/${routedRepo.repo}`,
397
+ url: created.url,
398
+ },
399
+ finding,
400
+ ),
401
+ );
402
+ }
403
+
404
+ export async function graduate({
405
+ epicId,
406
+ provider,
407
+ config,
408
+ currentRepo,
409
+ frameworkRepo,
410
+ gitRef = 'HEAD',
411
+ classifier = defaultClassifier,
412
+ ghPath = 'gh',
413
+ spawnImpl,
414
+ cwd,
415
+ logger,
416
+ spec,
417
+ }) {
418
+ const envelope = { filed: [], skipped: [], errors: [] };
419
+ const decorate =
420
+ typeof spec.decorateRecord === 'function'
421
+ ? spec.decorateRecord
422
+ : (record) => record;
382
423
 
383
- const { title, body, labels } = spec.buildFollowUp({
424
+ const precondition = checkGraduatePreconditions({
425
+ epicId,
426
+ provider,
427
+ currentRepo,
428
+ config,
429
+ spec,
430
+ });
431
+ if (precondition) return { ...envelope, ...precondition };
432
+
433
+ const loaded = await loadGraduateFindings({ epicId, provider, spec });
434
+ if (!loaded.findings) return { ...envelope, ...loaded };
435
+
436
+ for (const finding of loaded.findings) {
437
+ await processGraduateFinding({
384
438
  finding,
385
- source,
439
+ envelope,
440
+ decorate,
386
441
  epicId,
387
- idMarker,
388
- });
389
- const created = await createFollowUpIssue({
390
- owner: routedRepo.owner,
391
- repo: routedRepo.repo,
392
- title,
393
- body,
394
- labels,
442
+ currentRepo,
443
+ frameworkRepo,
444
+ classifier,
445
+ gitRef,
395
446
  ghPath,
396
447
  spawnImpl,
397
448
  cwd,
449
+ logger,
450
+ spec,
398
451
  });
399
- if (created.error) {
400
- envelope.errors.push(
401
- `finding ${finding.index} (${finding.path}): ${created.error}`,
402
- );
403
- continue;
404
- }
405
- envelope.filed.push(
406
- decorate(
407
- {
408
- index: finding.index,
409
- severity: finding.severity,
410
- path: finding.path,
411
- source,
412
- repo: `${routedRepo.owner}/${routedRepo.repo}`,
413
- url: created.url,
414
- },
415
- finding,
416
- ),
417
- );
418
452
  }
419
453
 
420
454
  return envelope;
@@ -2,18 +2,17 @@
2
2
  * init-tail.js — post-bootstrap onboarding tail for `mandrel init`.
3
3
  *
4
4
  * Called by `mandrel init` after `bootstrap.js` completes successfully on
5
- * the "configure now" path. Sequences the four phases that walk an operator
5
+ * the "configure now" path. Sequences the three phases that walk an operator
6
6
  * from a freshly bootstrapped project to a ready-to-plan workspace:
7
7
  *
8
- * Phase 1 — Detect the consumer stack (lib/onboard/detect-stack.js).
9
- * Phase 2 — Offer to scaffold missing docsContextFiles (scaffold-docs.js).
10
- * Phase 3 — Run `mandrel doctor` as a readiness gate.
11
- * Phase 4 — Print the /plan handoff next-step text.
8
+ * Phase 1 — Offer to scaffold missing docsContextFiles (scaffold-docs.js).
9
+ * Phase 2 — Run `mandrel doctor` as a readiness gate.
10
+ * Phase 3 — Print the /plan handoff next-step text.
12
11
  *
13
12
  * The whole tail is idempotent: re-running after an already-onboarded project
14
- * re-detects, re-checks, and re-offers scaffolding without duplicating stubs
15
- * (the scaffolder only writes genuinely absent files) and without modifying
16
- * anything (doctor is read-only).
13
+ * re-checks and re-offers scaffolding without duplicating stubs (the scaffolder
14
+ * only writes genuinely absent files) and without modifying anything (doctor is
15
+ * read-only).
17
16
  *
18
17
  * Injectable seams: `runDoctor`, `stdout`, `confirmScaffold`, and `isTTY`
19
18
  * allow the unit suite to drive every branch without real I/O.
@@ -22,10 +21,9 @@
22
21
  */
23
22
 
24
23
  import { spawnSync as defaultSpawnSync } from 'node:child_process';
25
- import fs from 'node:fs';
26
24
  import path from 'node:path';
25
+ import readline from 'node:readline/promises';
27
26
 
28
- import { detectStack } from './detect-stack.js';
29
27
  import { STUB_MARKER, scaffoldDocs } from './scaffold-docs.js';
30
28
 
31
29
  // ---------------------------------------------------------------------------
@@ -38,7 +36,7 @@ import { STUB_MARKER, scaffoldDocs } from './scaffold-docs.js';
38
36
  * @type {string}
39
37
  */
40
38
  export const PLAN_HANDOFF_TEXT =
41
- '\n✅ Mandrel is ready. Start your first Epic:\n\n' +
39
+ '\n✅ Mandrel is ready. Start your first project:\n\n' +
42
40
  ' /plan --idea "<one-line description of what you want to build>"\n\n' +
43
41
  'Or, if you already have a `type::epic` Issue open:\n\n' +
44
42
  ' /plan <epicId>\n';
@@ -47,24 +45,6 @@ export const PLAN_HANDOFF_TEXT =
47
45
  // Internal helpers
48
46
  // ---------------------------------------------------------------------------
49
47
 
50
- /**
51
- * Format a stack-detection result as a human-readable report line.
52
- *
53
- * @param {{ packageManager: string|null, testRunner: string|null, primaryLanguage: string|null }} stack
54
- * @returns {string}
55
- */
56
- function formatStackReport(stack) {
57
- const pm = stack.packageManager ?? '(unknown)';
58
- const runner = stack.testRunner ?? '(unknown)';
59
- const lang = stack.primaryLanguage ?? '(unknown)';
60
- return (
61
- '\n[init] Stack detection:\n' +
62
- ` Package manager : ${pm}\n` +
63
- ` Test runner : ${runner}\n` +
64
- ` Primary language: ${lang}\n`
65
- );
66
- }
67
-
68
48
  /**
69
49
  * Format a list of missing docs as a human-readable report (no prompt).
70
50
  *
@@ -75,30 +55,54 @@ function formatMissingList(missing) {
75
55
  if (missing.length === 0) return '';
76
56
  const list = missing.map((f) => ` • ${f}`).join('\n');
77
57
  return (
78
- '\n[init] The following docsContextFiles are missing — agents load\n' +
79
- 'these before every task:\n' +
58
+ '\n[Final Checks] The following docsContextFiles are missing,\n' +
59
+ 'agents will load degraded context until you create them:\n' +
80
60
  `${list}\n`
81
61
  );
82
62
  }
83
63
 
84
64
  /** Prompt text shown only on a TTY when asking to scaffold. */
85
- const SCAFFOLD_PROMPT = '\nScaffold stubs now? [y/N]: ';
65
+ const SCAFFOLD_PROMPT = '\nCreate placeholders? [Y/n]: ';
86
66
 
87
67
  /**
88
- * Synchronous y/N read from stdin (fd 0). Returns `false` on any read error
89
- * or when the user enters something other than `y` / `yes`.
68
+ * Async y/N read from stdin via `node:readline` (mirrors the prompt mechanism
69
+ * in `bootstrap.js`). Returns on Enter and never blocks waiting for EOF the way
70
+ * `fs.readFileSync(0)` did — that EOF-blocking read hung `mandrel init` on an
71
+ * interactive TTY. Yes is the default (`[Y/n]`): a bare Enter — or anything but
72
+ * an explicit `n`/`no` — resolves to `true` (create the placeholders), since the
73
+ * missing docs are known-needed and the stubs carry a `MANDREL:STUB` marker the
74
+ * `/plan` preflight still flags until they are fleshed out. A read error
75
+ * resolves to `false` so a genuine I/O failure never writes unattended. The
76
+ * prompt text is written by the caller via `stdout`, so the question string
77
+ * passed here is empty.
78
+ *
79
+ * `terminal: false` is **load-bearing**: with terminal mode on (the default
80
+ * when stdout is a TTY) readline emits cursor-control escapes
81
+ * (`\x1b[1G\x1b[0J`) that erase the `Create placeholders? [Y/n]:` prompt already
82
+ * written via the caller's `stdout`, leaving the operator staring at a blank,
83
+ * dead-looking line. Disabling terminal mode preserves the pre-written prompt
84
+ * and reads the line via the TTY's cooked-mode echo. `createInterface` is
85
+ * injectable so a test can assert this option is set (regression guard).
90
86
  *
91
- * @returns {boolean}
87
+ * @param {{ createInterface?: typeof readline.createInterface }} [opts]
88
+ * @returns {Promise<boolean>}
92
89
  */
93
- function syncConfirm() {
94
- let answer = '';
90
+ export async function readConfirm({
91
+ createInterface = readline.createInterface,
92
+ } = {}) {
93
+ const rl = createInterface({
94
+ input: process.stdin,
95
+ output: process.stdout,
96
+ terminal: false,
97
+ });
95
98
  try {
96
- const buf = fs.readFileSync(0, 'utf8');
97
- answer = buf.split('\n', 1)[0].trim().toLowerCase();
99
+ const answer = (await rl.question('')).trim().toLowerCase();
100
+ return answer !== 'n' && answer !== 'no';
98
101
  } catch {
99
- answer = '';
102
+ return false;
103
+ } finally {
104
+ rl.close();
100
105
  }
101
- return answer === 'y' || answer === 'yes';
102
106
  }
103
107
 
104
108
  // ---------------------------------------------------------------------------
@@ -119,14 +123,13 @@ function syncConfirm() {
119
123
  * - Run `mandrel doctor`; injectable for tests.
120
124
  * @param {boolean} [opts.isTTY] - Whether stdin is a TTY (defaults to
121
125
  * `Boolean(process.stdin.isTTY)`).
122
- * @returns {{
123
- * stack: { packageManager: string|null, testRunner: string|null, primaryLanguage: string|null },
126
+ * @returns {Promise<{
124
127
  * scaffoldResult: object,
125
128
  * doctorStatus: number,
126
129
  * ok: boolean,
127
- * }}
130
+ * }>}
128
131
  */
129
- export function runInitTail({
132
+ export async function runInitTail({
130
133
  root,
131
134
  stdout = (s) => process.stdout.write(s),
132
135
  confirmScaffold,
@@ -140,7 +143,7 @@ export function runInitTail({
140
143
  // it. When using the default, auto-decline on non-TTY so the scaffolder
141
144
  // never writes unattended.
142
145
  const usingDefaultConfirm = confirmScaffold == null;
143
- const confirmFn = confirmScaffold ?? (() => syncConfirm());
146
+ const confirmFn = confirmScaffold ?? readConfirm;
144
147
 
145
148
  // Default doctor runner — spawns `mandrel doctor` via the locally installed
146
149
  // bin; inherits stdio so the report streams to the terminal.
@@ -159,21 +162,12 @@ export function runInitTail({
159
162
 
160
163
  const doctorFn = runDoctor ?? defaultRunDoctor;
161
164
 
162
- // --- Phase 1: Detect the stack -------------------------------------------
163
- let stack;
164
- try {
165
- stack = detectStack(projectRoot);
166
- } catch {
167
- stack = { packageManager: null, testRunner: null, primaryLanguage: null };
168
- }
169
- stdout(formatStackReport(stack));
170
-
171
- // --- Phase 2: Offer to scaffold missing docsContextFiles -----------------
165
+ // --- Phase 1: Offer to scaffold missing docsContextFiles -----------------
172
166
  const preview = scaffoldDocs({ root: projectRoot, write: false });
173
167
  let scaffoldResult = preview;
174
168
 
175
169
  if (preview.missing.length === 0) {
176
- stdout('\n[init] All docsContextFiles are present.\n');
170
+ stdout('\n[Final Checks] All docsContextFiles are present.\n');
177
171
  } else {
178
172
  stdout(formatMissingList(preview.missing));
179
173
  // On non-TTY without an injected confirm, auto-decline so the scaffolder
@@ -181,38 +175,35 @@ export function runInitTail({
181
175
  // the prompt and consult the confirm function.
182
176
  const canPrompt = tty || !usingDefaultConfirm;
183
177
  if (canPrompt) stdout(SCAFFOLD_PROMPT);
184
- const accepted = canPrompt ? confirmFn() : false;
178
+ const accepted = canPrompt ? await confirmFn() : false;
185
179
  if (accepted) {
186
180
  scaffoldResult = scaffoldDocs({ root: projectRoot, write: true });
187
181
  if (scaffoldResult.created.length > 0) {
188
182
  stdout(
189
- `[init] Scaffolded ${scaffoldResult.created.length} stub(s). ` +
183
+ `[Final Checks] Scaffolded ${scaffoldResult.created.length} stub(s). ` +
190
184
  `Each carries a \`${STUB_MARKER}\` marker — replace placeholder ` +
191
185
  'content before planning.\n',
192
186
  );
193
187
  }
194
188
  } else {
195
- stdout(
196
- '[init] Scaffolding declined. docsContextFiles are still missing — ' +
197
- 'agents will load degraded context until you create them.\n',
198
- );
189
+ stdout('[Final Checks] Placeholders declined.\n');
199
190
  }
200
191
  }
201
192
 
202
- // --- Phase 3: Readiness gate (mandrel doctor) ----------------------------
203
- stdout('\n[init] Running mandrel doctor…\n');
193
+ // --- Phase 2: Readiness gate (mandrel doctor) ----------------------------
194
+ stdout('\n[Final Checks] Final installation summary via mandrel doctor…\n');
204
195
  const doctorResult = doctorFn();
205
196
  const doctorStatus = doctorResult?.status ?? 1;
206
197
 
207
198
  if (doctorStatus !== 0) {
208
199
  stdout(
209
- '\n[init] ❌ Doctor check failed. Resolve the remedies above and\n' +
200
+ '\n[Final Checks] ❌ Doctor check failed. Resolve the remedies above and\n' +
210
201
  'then re-run: mandrel init\n',
211
202
  );
212
- return { stack, scaffoldResult, doctorStatus, ok: false };
203
+ return { scaffoldResult, doctorStatus, ok: false };
213
204
  }
214
205
 
215
- // --- Phase 4: Handoff to /plan -------------------------------------------
206
+ // --- Phase 3: Handoff to /plan -------------------------------------------
216
207
  stdout(PLAN_HANDOFF_TEXT);
217
- return { stack, scaffoldResult, doctorStatus, ok: true };
208
+ return { scaffoldResult, doctorStatus, ok: true };
218
209
  }