mandrel 1.63.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.
@@ -277,7 +277,7 @@ export function ensureDependenciesInstalled(ctx) {
277
277
  });
278
278
  if (result.status !== 0) {
279
279
  throw new Error(
280
- `[bootstrap] ${manager} install failed (exit ${result.status}). Resolve the install error and re-run.`,
280
+ `[Bootstrap] ${manager} install failed (exit ${result.status}). Resolve the install error and re-run.`,
281
281
  );
282
282
  }
283
283
  return { ran: true, manager, skipped: false };
@@ -462,7 +462,7 @@ export function runSyncCommands(ctx) {
462
462
  });
463
463
  if (result.status !== 0) {
464
464
  throw new Error(
465
- `[bootstrap] sync-claude-commands.js failed (exit ${result.status}): ${(
465
+ `[Bootstrap] sync-claude-commands.js failed (exit ${result.status}): ${(
466
466
  result.stderr ?? ''
467
467
  )
468
468
  .trim()
@@ -615,12 +615,12 @@ export function checkWindowsGitPerf(ctx) {
615
615
  const fatalNodeCheck = (result) =>
616
616
  result.ok
617
617
  ? null
618
- : `[bootstrap] Node ${result.version} is below required ${result.required}. Upgrade Node and re-run.`;
618
+ : `[Bootstrap] Node ${result.version} is below required ${result.required}. Upgrade Node and re-run.`;
619
619
 
620
620
  const fatalValidation = (result) =>
621
621
  result.ok
622
622
  ? null
623
- : `[bootstrap] .agentrc.json failed schema validation: ${JSON.stringify(
623
+ : `[Bootstrap] .agentrc.json failed schema validation: ${JSON.stringify(
624
624
  result.errors,
625
625
  null,
626
626
  2,
@@ -629,7 +629,7 @@ const fatalValidation = (result) =>
629
629
  const fatalParity = (result) =>
630
630
  result.ok
631
631
  ? null
632
- : `[bootstrap] Parity check failed — workflows missing commands: ${
632
+ : `[Bootstrap] Parity check failed — workflows missing commands: ${
633
633
  result.missingCommand.join(', ') || '(none)'
634
634
  }; orphan commands: ${result.orphanCommand.join(', ') || '(none)'}`;
635
635
 
@@ -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
@@ -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
  }
@@ -77,7 +77,7 @@ export function composeStoryBody({
77
77
  }) {
78
78
  const head = typeof body === 'string' ? body : '';
79
79
  const lines = ['---', `parent: #${parentId}`];
80
- if (epicId !== undefined && epicId !== null && epicId !== parentId) {
80
+ if (epicId !== undefined && epicId !== null) {
81
81
  lines.push(`Epic: #${epicId}`);
82
82
  }
83
83
  if (dependencies.length > 0) {
@@ -192,7 +192,18 @@ Each Agent call:
192
192
  1. Names the Story ID and instructs the child to invoke
193
193
  [`helpers/single-story-deliver`](single-story-deliver.md)
194
194
  for that Story.
195
- 2. States the **return contract** (see § 2c).
195
+ 2. States the **return contract** (see § 2c) and the **no-park rule**: the
196
+ child MUST drive the close → CI-watch → merge-confirm → `agent::done`
197
+ sequence to a terminal state *within its own turn* and end **only** by
198
+ returning the § 2c JSON object. The auto-merge wait is an
199
+ internally-blocking step (`gh pr checks --watch` blocks the turn), **not**
200
+ a reason to suspend and hand back. A child that ends its turn with
201
+ free-form prose and an unconfirmed merge (e.g. "I'll wait for the
202
+ background watch task…") has violated the contract — the wave loop cannot
203
+ advance, and the Story strands at `agent::closing` (the Story #1553 /
204
+ PR #1554 failure mode). There is no "pending" return status: the child
205
+ returns `done` (merge confirmed), `blocked` (transitioned + friction
206
+ posted), or `failed`.
196
207
  3. Reminds the child of the **non-interactive contract**: no clarifying
197
208
  questions — if stuck, transition to `agent::blocked`, post a
198
209
  `friction` comment, and exit non-zero.
@@ -209,7 +220,8 @@ Agent call has returned a result (success, blocked, or failed).
209
220
 
210
221
  ### 2c. Per-Story return contract
211
222
 
212
- Each child returns:
223
+ Each child ends its turn by returning **exactly one** JSON object — never
224
+ free-form prose:
213
225
 
214
226
  ```json
215
227
  {
@@ -223,6 +235,16 @@ Each child returns:
223
235
  }
224
236
  ```
225
237
 
238
+ The status enum is **closed** — `done`, `blocked`, or `failed`. There is no
239
+ "pending" / "waiting" status, because the close-phase auto-merge wait is
240
+ **not** a returnable suspension: the child blocks on `gh pr checks --watch`
241
+ *inside its own turn*, confirms the merge, flips `agent::done`, and only then
242
+ returns `status: "done"`. A child that returns prose instead — parking on the
243
+ CI wait with an unconfirmed merge — breaks the wave loop's ability to advance
244
+ and leaves the Story at `agent::closing` (Story #1553 / PR #1554). The
245
+ single-homed restatement of this no-park rule for the child's own perspective
246
+ is [`single-story-deliver.md` § Step 7](single-story-deliver.md#return-contract).
247
+
226
248
  ### 2d. Wave outcome handling
227
249
 
228
250
  After every Story in a wave returns:
@@ -336,6 +336,26 @@ coverage rounding, platform-conditional branches, and timing-sensitive
336
336
  tests routinely drift between the two. The agent owns the green-CI
337
337
  outcome, not just the push.
338
338
 
339
+ > **The auto-merge wait is an internally-blocking step, not a reason to end
340
+ > your turn.** This is the single most important contract of this workflow,
341
+ > and the seam where a worker most often misbehaves: it delivers up to arming
342
+ > auto-merge, then ends its turn with **free-form prose** — e.g. "I'll wait
343
+ > for the background watch task to complete" or "the next event will be its
344
+ > completion notification" — leaving the merge unconfirmed and the Story
345
+ > stranded at `agent::closing` (observed on Story #1553 / PR #1554). **Do not
346
+ > do this.** `gh pr checks <prNumber> --watch` *blocks the current turn* until
347
+ > CI resolves — that is the mechanism by which you wait. You MUST keep your
348
+ > turn alive across the wait: watch → (fix + push + re-watch on red) → confirm
349
+ > the merge (Step 5) → flip `agent::done` → run the post-merge steps → and
350
+ > only then return the terminal JSON status contract (Step 4 of
351
+ > [`deliver-stories.md` § 2c](deliver-stories.md), mirrored in
352
+ > [§ Return contract](#return-contract) for the standalone caller). The CI
353
+ > wait NEVER terminates your turn; **only** a confirmed-`MERGED` PR (→
354
+ > `status: "done"`), an `agent::blocked` transition (→ `status: "blocked"`),
355
+ > or an unrecoverable failure (→ `status: "failed"`) does. Ending your turn
356
+ > with prose and an unconfirmed merge is a contract violation — it is the very
357
+ > bug this workflow exists to prevent.
358
+
339
359
  After `single-story-close.js` succeeds, enter the watch + fix loop:
340
360
 
341
361
  ```bash
@@ -348,7 +368,9 @@ When the watch exits:
348
368
  still at `agent::closing` with its issue OPEN at this point (Step 3
349
369
  deferred the `agent::done` flip). The `Closes #<id>` footer closes the
350
370
  Story issue when the merge lands; Step 5 confirms the merge and Step 5.5
351
- flips the Story to `agent::done`. Proceed to Step 5.
371
+ flips the Story to `agent::done`. **Proceed to Step 5 within the same
372
+ turn** — do not end your turn here. Green CI is the *start* of the
373
+ merge-confirm sequence, not a terminal state (see Step 7's no-park rule).
352
374
  - **Any check ✗** — diagnose, fix, and push a new commit on
353
375
  `story-<storyId>`, then re-watch. Auto-merge stays enabled across
354
376
  retries; no need to re-arm it. The Story stays at `agent::closing`
@@ -582,6 +604,67 @@ cleanup.
582
604
 
583
605
  ---
584
606
 
607
+ ## Step 7 — Return contract (**required when dispatched as a sub-agent**) {#return-contract}
608
+
609
+ When this workflow runs as a per-Story sub-agent (dispatched by `/deliver`
610
+ via [`deliver-stories.md` § 2a/2c](deliver-stories.md)), the **only**
611
+ acceptable way to end your turn is to **return a single terminal JSON status
612
+ object** — never free-form prose:
613
+
614
+ ```json
615
+ {
616
+ "storyId": <number>,
617
+ "status": "done" | "blocked" | "failed",
618
+ "phase": "init|implementing|closing|blocked|done",
619
+ "branchDeleted": <boolean>,
620
+ "blockerCommentId": <string|null>,
621
+ "detail": "<one-liner: what changed + what was verified, e.g. PR #N merged>",
622
+ "renderedBody": "<terminal Story body>"
623
+ }
624
+ ```
625
+
626
+ This is the same envelope [`deliver-stories.md` § 2c](deliver-stories.md)
627
+ mandates; this section is its single-homed restatement for the standalone
628
+ worker so the contract is self-contained when this workflow is the entry
629
+ point.
630
+
631
+ **The auto-merge wait does not produce a fourth status.** There is no
632
+ "pending" or "waiting" terminal — the CI/auto-merge wait is handled
633
+ *internally* by blocking on `gh pr checks --watch` (Step 4) and confirming
634
+ the merge (Step 5). You return **only** when you have reached a genuinely
635
+ terminal state:
636
+
637
+ - **`status: "done"`** — the PR is confirmed `state: "MERGED"` (Step 5),
638
+ the Story carries `agent::done`, and Steps 5.5 / 6 have run. `phase: "done"`,
639
+ `branchDeleted: true`.
640
+ - **`status: "blocked"`** — you transitioned the Story to `agent::blocked`
641
+ and posted a `friction` comment (acceptance self-eval block in Step 1a, a
642
+ base-sync conflict, or an operator-blocking CI failure / Anti-Thrashing
643
+ stop in Step 4). `phase: "blocked"`, `blockerCommentId` set.
644
+ - **`status: "failed"`** — an unrecoverable failure outside the blocked
645
+ protocol. `phase` reflects where it died.
646
+
647
+ A turn that ends with prose ("I'll wait for the watch task…", "the next event
648
+ will be its completion notification…") and an **unconfirmed merge** is a
649
+ **contract violation** (the Story #1553 / PR #1554 failure mode): the parent
650
+ wave loop cannot distinguish "still working" from "done but silent", and the
651
+ Story strands at `agent::closing`. If you genuinely cannot confirm the merge,
652
+ that is a `blocked` or `failed` outcome with the JSON contract above — not a
653
+ prose hand-off.
654
+
655
+ > **Handoff discipline — report state, not process.** Populate the envelope
656
+ > with essential terminal state only (mirroring the fields
657
+ > `single-story-close.js` / `story-phase.js` already emit). Do not narrate the
658
+ > steps you took, and do not prescribe how the next stage should work. Prose
659
+ > process commentary only bloats the hydrated prompt
660
+ > (`delivery.maxTokenBudget` elision). When run **interactively** (no parent
661
+ > aggregator), this JSON envelope is optional — relay terminal state to the
662
+ > operator in prose instead — but the **no-park rule still holds**: never end
663
+ > an interactive turn with an unconfirmed merge either; block on the watch,
664
+ > confirm, and report the merged outcome.
665
+
666
+ ---
667
+
585
668
  ## Idempotence
586
669
 
587
670
  - `single-story-init.js` re-prints the same `workCwd` without recreating
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.64.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.63.0...mandrel-v1.64.0) (2026-06-14)
6
+
7
+
8
+ ### Added
9
+
10
+ * **bootstrap:** polish init/bootstrap preflight + UX copy, drop unused stack detection ([#4113](https://github.com/dsj1984/mandrel/issues/4113)) ([c775418](https://github.com/dsj1984/mandrel/commit/c77541846508047f1101158e67ac2a59c8577101))
11
+ * **cli:** mandrel init banner + Welcome prompt, sync 'Installed' wording ([#4109](https://github.com/dsj1984/mandrel/issues/4109)) ([c516d39](https://github.com/dsj1984/mandrel/commit/c516d3903080c5e6838e833dbddf20ab0d4af1cd))
12
+
13
+
14
+ ### Fixed
15
+
16
+ * 2-tier Stories un-initializable: composeStoryBody omits `Epic: #N` when epicId === parentId, blocking the /deliver wave loop ([#4102](https://github.com/dsj1984/mandrel/issues/4102)) ([#4103](https://github.com/dsj1984/mandrel/issues/4103)) ([2431cf6](https://github.com/dsj1984/mandrel/commit/2431cf6e7330b9cf98a7f54b60ba8d3df46f635c))
17
+ * **init:** set terminal:false on readline confirms so the prompt is not erased (refs [#4106](https://github.com/dsj1984/mandrel/issues/4106)) ([#4108](https://github.com/dsj1984/mandrel/issues/4108)) ([e31e767](https://github.com/dsj1984/mandrel/commit/e31e767a6d69519faf120bf08ce55d0272a46416))
18
+ * **init:** use node:readline for confirm prompts so init does not hang (refs [#4106](https://github.com/dsj1984/mandrel/issues/4106)) ([#4107](https://github.com/dsj1984/mandrel/issues/4107)) ([2b56d08](https://github.com/dsj1984/mandrel/commit/2b56d0851f403fd97c86e6097fd9d62446ee2257))
19
+
5
20
  ## [1.63.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.62.0...mandrel-v1.63.0) (2026-06-13)
6
21
 
7
22
 
package/lib/cli/init.js CHANGED
@@ -50,9 +50,10 @@
50
50
  * for `./.agents/` in the cwd
51
51
  * - `runStep` — `(cmd, args) => { status }`; runs one install/sync/bootstrap
52
52
  * step. Defaults to a `spawnSync` runner with `stdio: inherit`.
53
- * - `confirm` — `() => boolean`; reads the operator's yes/no answer (true =
54
- * configure now). Defaults to a synchronous stdin readline
55
- * prompt with yes as the default.
53
+ * - `confirm` — `() => boolean | Promise<boolean>`; reads the operator's
54
+ * yes/no answer (true = configure now). Defaults to a
55
+ * `node:readline` stdin prompt (awaited) with yes as the
56
+ * default.
56
57
  * - `stdout` — `(s) => void`; defaults to `process.stdout.write`.
57
58
  * - `isTTY` — boolean; defaults to `process.stdin.isTTY`.
58
59
  * - `exit` — `(code) => void`; defaults to `process.exit`.
@@ -66,6 +67,7 @@
66
67
  import { spawnSync } from 'node:child_process';
67
68
  import fs from 'node:fs';
68
69
  import path from 'node:path';
70
+ import readline from 'node:readline/promises';
69
71
  import { pathToFileURL } from 'node:url';
70
72
 
71
73
  // Lazily resolved at runtime so cold-start `npx mandrel init` (where
@@ -119,11 +121,29 @@ const BOOTSTRAP_SCRIPT = path.join('.agents', 'scripts', 'bootstrap.js');
119
121
  const SYNC_BIN = path.join('node_modules', PACKAGE_NAME, 'bin', 'mandrel.js');
120
122
 
121
123
  const PROMPT_TEXT =
122
- 'The Mandrel .agents package has been copied to your directory.\n' +
123
- 'Would you like to begin the interactive process to setup your local and ' +
124
- 'github environments now? [Y/n]: ';
124
+ '\n' +
125
+ 'Welcome to Mandrel!\n\n' +
126
+ 'Check .agents/README.md for more quick start instructions, flag options, and documentation.\n\n' +
127
+ 'Begin interactive setup? [Y/n]: ';
125
128
 
126
- const FILES_ONLY_HINT = 'Configure any time with: npx mandrel init\n';
129
+ const FILES_ONLY_HINT = 'Setup any time with: npx mandrel init\n';
130
+
131
+ // ASCII banner printed once at the very top of `mandrel init`, before any
132
+ // install/sync output streams to the terminal. Paste multi-line ASCII art
133
+ // between the fences below. `String.raw` keeps backslashes literal so the art
134
+ // renders exactly as pasted — the only characters to avoid inside are a
135
+ // literal backtick and the `${` sequence. The leading/trailing blank lines
136
+ // frame the art in the terminal.
137
+ const BANNER = String.raw`
138
+
139
+ ______ ___ _________ ______
140
+ ___ |/ /_____ _____________ /______________ /
141
+ __ /|_/ /_ __ '/_ __ \ __ /__ ___/ _ \_ /
142
+ _ / / / / /_/ /_ / / / /_/ / _ / / __/ /
143
+ /_/ /_/ \__,_/ /_/ /_/\__,_/ /_/ \___//_/
144
+ ____________________________________________________
145
+
146
+ `;
127
147
 
128
148
  // On win32, `npm` resolves to a `.cmd` shim that Node refuses to spawn without
129
149
  // a shell after the CVE-2024-27980 hardening; mirror update.js and set
@@ -175,7 +195,7 @@ function buildBootstrapArgs(argv, assumeYes) {
175
195
  * argv?: string[],
176
196
  * exists?: (relPath: string) => boolean,
177
197
  * runStep?: (cmd: string, args: string[]) => { status: number | null },
178
- * confirm?: () => boolean,
198
+ * confirm?: () => boolean | Promise<boolean>,
179
199
  * stdout?: (s: string) => void,
180
200
  * isTTY?: boolean,
181
201
  * afterBootstrap?: (root: string) => Promise<{ ok?: boolean } | void> | { ok?: boolean } | void,
@@ -260,7 +280,7 @@ export async function planInit({
260
280
  proceed = false;
261
281
  } else {
262
282
  stdout(PROMPT_TEXT);
263
- proceed = confirm();
283
+ proceed = await confirm();
264
284
  }
265
285
 
266
286
  if (proceed) {
@@ -338,23 +358,44 @@ function defaultRunStep(cmd, args) {
338
358
  }
339
359
 
340
360
  /**
341
- * Default `confirm` seam — synchronous yes/no prompt. Reads one line from stdin
342
- * and normalizes it to a boolean; any input other than an explicit "no"
343
- * (`n`/`no`, case-insensitive) including bare Enterdefaults to `true`
344
- * (configure), matching the `[Y/n]` convention where yes is the default.
361
+ * Default `confirm` seam — yes/no prompt read via `node:readline` (mirrors the
362
+ * prompt mechanism in `bootstrap.js`). Returns on Enter and never blocks
363
+ * waiting for EOF the way `fs.readFileSync(0)` did that EOF-blocking read hung
364
+ * `mandrel init` on an interactive TTY. Any input other than an explicit "no"
365
+ * (`n`/`no`, case-insensitive) — including bare Enter — resolves to `true`
366
+ * (configure), matching the `[Y/n]` convention where yes is the default. The
367
+ * prompt text is written by `planInit` via `stdout`, so the question string
368
+ * passed here is empty.
345
369
  *
346
- * @returns {boolean}
370
+ * `terminal: false` is **load-bearing**, not cosmetic: with terminal mode on
371
+ * (the default when stdout is a TTY) readline emits cursor-control escapes
372
+ * (`\x1b[1G\x1b[0J` — column-1 + erase-to-end-of-screen) when it takes over the
373
+ * line, which **erases the `[Y/n]:` prompt already written via `stdout`** — the
374
+ * operator then sees only the first prompt line and a dead-looking cursor.
375
+ * Disabling terminal mode leaves the pre-written prompt intact and reads the
376
+ * line via the TTY's own cooked-mode echo. `createInterface` is injectable so a
377
+ * test can assert this option is set (regression guard).
378
+ *
379
+ * @param {{ createInterface?: typeof readline.createInterface }} [opts]
380
+ * @returns {Promise<boolean>}
347
381
  */
348
- function defaultConfirm() {
349
- let answer = '';
382
+ export async function defaultConfirm({
383
+ createInterface = readline.createInterface,
384
+ } = {}) {
385
+ const rl = createInterface({
386
+ input: process.stdin,
387
+ output: process.stdout,
388
+ terminal: false,
389
+ });
350
390
  try {
351
- const buf = fs.readFileSync(0, 'utf8');
352
- answer = buf.split('\n', 1)[0].trim().toLowerCase();
391
+ const answer = (await rl.question('')).trim().toLowerCase();
392
+ return answer !== 'n' && answer !== 'no';
353
393
  } catch {
354
- // No readable line (e.g. stdin closed) → fall through to the default.
355
- answer = '';
394
+ // No readable line (e.g. stdin closed) → default to yes (configure).
395
+ return true;
396
+ } finally {
397
+ rl.close();
356
398
  }
357
- return answer !== 'n' && answer !== 'no';
358
399
  }
359
400
 
360
401
  /**
@@ -376,6 +417,10 @@ export default async function run(argv = []) {
376
417
  return;
377
418
  }
378
419
 
420
+ // Banner is the very first output — before the install + sync steps that
421
+ // planInit kicks off — so it greets the operator on a cold start.
422
+ process.stdout.write(BANNER);
423
+
379
424
  const result = await planInit({
380
425
  argv,
381
426
  exists: defaultExists,
package/lib/cli/sync.js CHANGED
@@ -242,7 +242,7 @@ export function runSync({
242
242
  write(`would prune ${path.join('.agents', rel)}\n`);
243
243
  }
244
244
  write(
245
- `✅ Dry run: ${payloadFiles.length} file(s) would be materialized, ${stale.length} stale file(s) would be pruned from ./.agents/\n`,
245
+ `✅ Dry run: ${payloadFiles.length} file(s) would be installed, ${stale.length} stale file(s) would be pruned from ./.agents/\n`,
246
246
  );
247
247
  return {
248
248
  copied: 0,
@@ -275,10 +275,10 @@ export function runSync({
275
275
 
276
276
  if (staleFiles.length > 0) {
277
277
  write(
278
- `✅ Materialized ${payloadFiles.length} file(s) into ./.agents/ (pruned ${staleFiles.length} stale file(s))\n`,
278
+ `✅ Installed ${payloadFiles.length} file(s) into ./.agents/ (pruned ${staleFiles.length} stale file(s))\n`,
279
279
  );
280
280
  } else {
281
- write(`✅ Materialized ${payloadFiles.length} file(s) into ./.agents/\n`);
281
+ write(`✅ Installed ${payloadFiles.length} file(s) into ./.agents/\n`);
282
282
  }
283
283
  return {
284
284
  copied: payloadFiles.length,