mandrel 1.60.0 → 1.62.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 (49) hide show
  1. package/.agents/README.md +74 -32
  2. package/.agents/docs/SDLC.md +18 -12
  3. package/.agents/docs/configuration.md +61 -4
  4. package/.agents/docs/quality-gates.md +796 -0
  5. package/.agents/docs/workflows.md +3 -4
  6. package/.agents/runtime-deps.json +2 -2
  7. package/.agents/scripts/README.md +1 -1
  8. package/.agents/scripts/agents-bootstrap-github.js +23 -119
  9. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +46 -0
  10. package/.agents/scripts/lib/bootstrap/gh-preflight.js +7 -9
  11. package/.agents/scripts/lib/bootstrap/manifest.js +21 -1
  12. package/.agents/scripts/lib/bootstrap/merge-methods.js +31 -16
  13. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +32 -11
  14. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  15. package/.agents/scripts/lib/detect-package-manager.js +72 -0
  16. package/.agents/scripts/lib/errors/index.js +4 -4
  17. package/.agents/scripts/lib/label-taxonomy.js +2 -2
  18. package/.agents/scripts/lib/onboard/detect-stack.js +10 -10
  19. package/.agents/scripts/lib/onboard/init-tail.js +218 -0
  20. package/.agents/scripts/lib/onboard/scaffold-docs.js +18 -3
  21. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -6
  22. package/.agents/scripts/lib/worktree/node-modules-strategy.js +5 -2
  23. package/.agents/workflows/agents-update.md +14 -29
  24. package/.agents/workflows/deliver.md +87 -26
  25. package/.agents/workflows/helpers/agents-sync-config.md +3 -2
  26. package/.agents/workflows/helpers/deliver-epic.md +12 -5
  27. package/.agents/workflows/helpers/deliver-stories.md +13 -7
  28. package/.agents/workflows/plan.md +48 -4
  29. package/README.md +18 -30
  30. package/bin/mandrel.js +235 -16
  31. package/docs/CHANGELOG.md +36 -0
  32. package/lib/cli/doctor.js +45 -3
  33. package/lib/cli/init.js +66 -7
  34. package/lib/cli/registry.js +42 -146
  35. package/lib/cli/sync.js +122 -23
  36. package/lib/cli/uninstall.js +42 -7
  37. package/lib/cli/update.js +257 -198
  38. package/lib/cli/version-helpers.js +59 -0
  39. package/package.json +6 -6
  40. package/.agents/workflows/onboard.md +0 -208
  41. package/lib/cli/__tests__/migrate.test.js +0 -268
  42. package/lib/cli/__tests__/sync-local-zone.test.js +0 -247
  43. package/lib/cli/__tests__/sync.test.js +0 -372
  44. package/lib/cli/__tests__/update-changelog-surface.test.js +0 -357
  45. package/lib/cli/__tests__/update-major.test.js +0 -217
  46. package/lib/cli/__tests__/update-reexec.test.js +0 -513
  47. package/lib/cli/__tests__/update.test.js +0 -696
  48. package/lib/cli/__tests__/version-check.test.js +0 -398
  49. package/lib/migrations/__tests__/index.test.js +0 -216
package/bin/mandrel.js CHANGED
@@ -2,35 +2,252 @@
2
2
  // bin/mandrel.js — mandrel CLI entry point
3
3
 
4
4
  /**
5
- * Convention-based subcommand dispatcher.
5
+ * Allowlist-based subcommand dispatcher.
6
6
  *
7
- * Resolves `process.argv[2]` to `lib/cli/<name>.js` and dynamically
8
- * imports it so the subcommand surface can grow without touching this
9
- * file. Each subcommand module must export a default function `run(argv)`.
7
+ * Only modules listed in SUBCOMMANDS are dispatchable. Each entry declares the
8
+ * name, a one-line description for help output, and the set of known flags so
9
+ * the dispatcher can reject unknown flags before loading the subcommand.
10
+ *
11
+ * Supported top-level flags:
12
+ * --help / -h Print subcommand list and exit 0.
13
+ * --version Print installed version and exit 0.
14
+ *
15
+ * Each subcommand module must export a default function `run(argv)`.
10
16
  */
11
17
 
18
+ import { createRequire } from 'node:module';
12
19
  import path from 'node:path';
13
20
  import { fileURLToPath, pathToFileURL } from 'node:url';
14
21
 
15
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
23
 
17
- function usage(badSub) {
18
- if (badSub) {
19
- process.stderr.write(
20
- `mandrel: unknown subcommand '${badSub}'\n\nUsage: mandrel <subcommand> [args]\n`,
21
- );
22
- } else {
23
- process.stderr.write('Usage: mandrel <subcommand> [args]\n');
24
+ // ---------------------------------------------------------------------------
25
+ // Subcommand registry — the ONLY allowed dispatch targets
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * @typedef {{ description: string, knownFlags: Set<string> }} SubcommandMeta
30
+ * @type {Map<string, SubcommandMeta>}
31
+ */
32
+ const SUBCOMMANDS = new Map([
33
+ [
34
+ 'init',
35
+ {
36
+ description: 'install and configure mandrel in the current project',
37
+ knownFlags: new Set([
38
+ '--assume-yes',
39
+ '--skip-github',
40
+ '--owner',
41
+ '--repo',
42
+ '--base-branch',
43
+ '--project-number',
44
+ '--operator-handle',
45
+ '--dry-run',
46
+ ]),
47
+ },
48
+ ],
49
+ [
50
+ 'sync',
51
+ {
52
+ description: 'materialize .agents/ payload from installed package',
53
+ knownFlags: new Set(['--dry-run']),
54
+ },
55
+ ],
56
+ [
57
+ 'sync-commands',
58
+ {
59
+ description: 'regenerate .claude/commands/ from .agents/workflows/',
60
+ knownFlags: new Set(['--dry-run']),
61
+ },
62
+ ],
63
+ [
64
+ 'doctor',
65
+ {
66
+ description: 'run readiness checks and report remedies',
67
+ knownFlags: new Set([]),
68
+ },
69
+ ],
70
+ [
71
+ 'update',
72
+ {
73
+ description: 'upgrade mandrel to the newest published version',
74
+ knownFlags: new Set(['--dry-run', '--install-cmd']),
75
+ },
76
+ ],
77
+ [
78
+ 'migrate',
79
+ {
80
+ description: 'apply version-keyed migrations for a version range',
81
+ knownFlags: new Set(['--from', '--to', '--dry-run']),
82
+ },
83
+ ],
84
+ [
85
+ 'explain',
86
+ {
87
+ description: 'print resolved config values and their sources',
88
+ knownFlags: new Set(['--json']),
89
+ },
90
+ ],
91
+ [
92
+ 'uninstall',
93
+ {
94
+ description: 'reverse a recorded install using the install ledger',
95
+ knownFlags: new Set(['--include-github', '--dry-run']),
96
+ },
97
+ ],
98
+ ]);
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Helpers
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Read the installed mandrel version from the root package.json.
106
+ *
107
+ * @returns {string}
108
+ */
109
+ function installedVersion() {
110
+ const req = createRequire(import.meta.url);
111
+ const manifest = req('../package.json');
112
+ return String(manifest.version);
113
+ }
114
+
115
+ /**
116
+ * Print the help screen listing all subcommands with descriptions.
117
+ *
118
+ * @param {(s: string) => void} [write]
119
+ */
120
+ function printHelp(write = (s) => process.stdout.write(s)) {
121
+ const lines = ['Usage: mandrel <subcommand> [args]\n', '\nSubcommands:\n'];
122
+ for (const [name, meta] of SUBCOMMANDS) {
123
+ const pad = ' '.repeat(Math.max(1, 16 - name.length));
124
+ lines.push(` ${name}${pad}${meta.description}\n`);
125
+ }
126
+ lines.push(
127
+ '\nFlags:\n',
128
+ ' --help, -h Print this help message\n',
129
+ ' --version Print the installed version\n',
130
+ '\nRun `mandrel <subcommand> --help` for subcommand-specific flags.\n',
131
+ );
132
+ write(lines.join(''));
133
+ }
134
+
135
+ /**
136
+ * Suggest the closest known subcommand name for a typo (Levenshtein-1).
137
+ *
138
+ * @param {string} input
139
+ * @returns {string | undefined}
140
+ */
141
+ function suggest(input) {
142
+ for (const name of SUBCOMMANDS.keys()) {
143
+ if (levenshtein(input, name) <= 2) return name;
144
+ }
145
+ return undefined;
146
+ }
147
+
148
+ /**
149
+ * Compute Levenshtein edit distance between two strings (capped at 3 for
150
+ * performance — we only care about small distances).
151
+ *
152
+ * @param {string} a
153
+ * @param {string} b
154
+ * @returns {number}
155
+ */
156
+ function levenshtein(a, b) {
157
+ if (a === b) return 0;
158
+ if (Math.abs(a.length - b.length) > 3) return 4;
159
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
160
+ for (let i = 0; i < a.length; i++) {
161
+ const curr = [i + 1];
162
+ for (let j = 0; j < b.length; j++) {
163
+ curr[j + 1] = Math.min(
164
+ curr[j] + 1,
165
+ prev[j + 1] + 1,
166
+ prev[j] + (a[i] === b[j] ? 0 : 1),
167
+ );
168
+ }
169
+ prev.splice(0, prev.length, ...curr);
170
+ }
171
+ return prev[b.length];
172
+ }
173
+
174
+ /**
175
+ * Universal flags that all subcommands accept and that bypass per-subcommand
176
+ * flag validation. Subcommands may handle these themselves internally.
177
+ */
178
+ const UNIVERSAL_FLAGS = new Set(['--help', '-h']);
179
+
180
+ /**
181
+ * Validate the argv array against the known flags for a subcommand.
182
+ * Returns null when all flags are known; an error message string when an
183
+ * unknown flag is detected. Values following `=` or the next positional are
184
+ * allowed — only the flag name prefix is checked.
185
+ *
186
+ * Universal flags (--help, -h) are always allowed and bypass this check.
187
+ *
188
+ * @param {string} subName
189
+ * @param {Set<string>} knownFlags
190
+ * @param {string[]} argv
191
+ * @returns {string | null}
192
+ */
193
+ function findUnknownFlag(subName, knownFlags, argv) {
194
+ for (const arg of argv) {
195
+ if (!arg.startsWith('-')) continue;
196
+ // Strip value portion for `--flag=value` form.
197
+ const flagName = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
198
+ if (UNIVERSAL_FLAGS.has(flagName)) continue;
199
+ if (!knownFlags.has(flagName)) {
200
+ const names = [...knownFlags].sort().join(', ');
201
+ const hint = names.length > 0 ? ` Known flags: ${names}\n` : '';
202
+ return (
203
+ `mandrel: unknown flag '${flagName}' for subcommand '${subName}'\n` +
204
+ hint
205
+ );
206
+ }
24
207
  }
208
+ return null;
25
209
  }
26
210
 
27
- const sub = process.argv[2];
211
+ // ---------------------------------------------------------------------------
212
+ // Dispatch
213
+ // ---------------------------------------------------------------------------
214
+
215
+ const args = process.argv.slice(2);
216
+ const sub = args[0];
217
+
218
+ // --- Top-level flags (no subcommand) ---
219
+ if (!sub || sub === '--help' || sub === '-h') {
220
+ printHelp();
221
+ process.exit(0);
222
+ }
28
223
 
29
- if (!sub) {
30
- usage();
224
+ if (sub === '--version') {
225
+ process.stdout.write(`${installedVersion()}\n`);
226
+ process.exit(0);
227
+ }
228
+
229
+ // --- Subcommand lookup ---
230
+ const meta = SUBCOMMANDS.get(sub);
231
+ if (!meta) {
232
+ const subcommandList = [...SUBCOMMANDS.keys()].join(', ');
233
+ const hint = suggest(sub);
234
+ const didYouMean = hint ? `\n Did you mean '${hint}'?` : '';
235
+ process.stderr.write(
236
+ `mandrel: unknown subcommand '${sub}'${didYouMean}\n` +
237
+ ` Available subcommands: ${subcommandList}\n`,
238
+ );
239
+ process.exit(1);
240
+ }
241
+
242
+ // --- Unknown-flag rejection ---
243
+ const subArgv = args.slice(1);
244
+ const unknownFlagError = findUnknownFlag(sub, meta.knownFlags, subArgv);
245
+ if (unknownFlagError) {
246
+ process.stderr.write(unknownFlagError);
31
247
  process.exit(1);
32
248
  }
33
249
 
250
+ // --- Load and dispatch ---
34
251
  const subFile = path.resolve(__dirname, '..', 'lib', 'cli', `${sub}.js`);
35
252
  const subFileUrl = pathToFileURL(subFile).href;
36
253
 
@@ -39,7 +256,9 @@ try {
39
256
  mod = await import(subFileUrl);
40
257
  } catch (err) {
41
258
  if (err.code === 'ERR_MODULE_NOT_FOUND' && err.message.includes(subFile)) {
42
- usage(sub);
259
+ process.stderr.write(
260
+ `mandrel: subcommand '${sub}' module not found — this is a bug\n`,
261
+ );
43
262
  process.exit(1);
44
263
  }
45
264
  // Re-throw broken-module errors so they are visible rather than masked.
@@ -53,4 +272,4 @@ if (typeof mod.default !== 'function') {
53
272
  process.exit(1);
54
273
  }
55
274
 
56
- await mod.default(process.argv.slice(3));
275
+ await mod.default(subArgv);
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.62.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.61.0...mandrel-v1.62.0) (2026-06-12)
6
+
7
+
8
+ ### Added
9
+
10
+ * /deliver composes a sequential segment plan over mixed Epic and standalone-Story ID sets (refs [#4062](https://github.com/dsj1984/mandrel/issues/4062)) ([#4063](https://github.com/dsj1984/mandrel/issues/4063)) ([a83d29e](https://github.com/dsj1984/mandrel/commit/a83d29e032b7f6b6846d7988f071d6b6416df2f9))
11
+
12
+
13
+ ### Fixed
14
+
15
+ * make mandrel update no-op short-circuit drift-aware (refs [#4065](https://github.com/dsj1984/mandrel/issues/4065)) ([#4066](https://github.com/dsj1984/mandrel/issues/4066)) ([693f1cf](https://github.com/dsj1984/mandrel/commit/693f1cf5bf8fd7d29fed910659708ffceb7a54cb))
16
+
17
+ ## [1.61.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.60.0...mandrel-v1.61.0) (2026-06-12)
18
+
19
+
20
+ ### ⚠ BREAKING CHANGES
21
+
22
+ * mandrel update no longer refuses major version bumps and the --major flag is removed; major upgrades apply like any other update.
23
+
24
+ ### Added
25
+
26
+ * **init:** fold onboarding into mandrel init, retire /onboard (refs [#4045](https://github.com/dsj1984/mandrel/issues/4045)) ([#4050](https://github.com/dsj1984/mandrel/issues/4050)) ([2b2c115](https://github.com/dsj1984/mandrel/commit/2b2c115ad4f5394cefb2ecfcc72ddbd0dae6683a))
27
+ * make the CLI behave like a CLI: dispatcher, help, flag rejection, TS peer, engines floor ([#4047](https://github.com/dsj1984/mandrel/issues/4047)) ([#4053](https://github.com/dsj1984/mandrel/issues/4053)) ([c625573](https://github.com/dsj1984/mandrel/commit/c625573c9feb0908d897bb0f2778c2b22ee28b04))
28
+ * one implementation per concept: install-surface dead-code deletion and helper unification ([#4048](https://github.com/dsj1984/mandrel/issues/4048)) ([#4054](https://github.com/dsj1984/mandrel/issues/4054)) ([ffb5dc7](https://github.com/dsj1984/mandrel/commit/ffb5dc72f25fedc70829dd70c64e14e3dfa18c72))
29
+ * remediate documentation audit, remove the major-upgrade gate, and retire stranded consumer runbooks ([#4061](https://github.com/dsj1984/mandrel/issues/4061)) ([fa429e4](https://github.com/dsj1984/mandrel/commit/fa429e490b7d410938eeec24ef44ffc7857cf470))
30
+ * **update:** make upgrade loop truthful — cache bypass, sync prune, cwd anchoring (refs [#4046](https://github.com/dsj1984/mandrel/issues/4046)) ([#4051](https://github.com/dsj1984/mandrel/issues/4051)) ([1a89a7c](https://github.com/dsj1984/mandrel/commit/1a89a7cc1150f26d87a93f58e2fd940670604381))
31
+
32
+
33
+ ### Fixed
34
+
35
+ * close the 9 post-merge review findings on the install/bootstrap batch ([#4060](https://github.com/dsj1984/mandrel/issues/4060)) ([4e76441](https://github.com/dsj1984/mandrel/commit/4e76441c1620481e49ac33e4884e604dc8366f48))
36
+ * packaging and CI prove the install contract; retire the install-bootstrap review doc ([#4049](https://github.com/dsj1984/mandrel/issues/4049)) ([#4057](https://github.com/dsj1984/mandrel/issues/4057)) ([1051ed1](https://github.com/dsj1984/mandrel/commit/1051ed130f5720edcb3ca8e4cfe898ec00495eb5))
37
+ * **tests:** add shell:true to spawnSync for Windows npm.cmd compat (refs [#4049](https://github.com/dsj1984/mandrel/issues/4049)) ([#4059](https://github.com/dsj1984/mandrel/issues/4059)) ([f5abcd5](https://github.com/dsj1984/mandrel/commit/f5abcd550e7e4110372b35c56abe55931ce3058a))
38
+ * **transpile:** add typescript to devDependencies so Windows CI finds it ([#4055](https://github.com/dsj1984/mandrel/issues/4055)) ([8f0a6bd](https://github.com/dsj1984/mandrel/commit/8f0a6bdf6681286d8b691e1b9163639613ce8f9f))
39
+ * **transpile:** use pathToFileURL for ESM import on Windows ([#4056](https://github.com/dsj1984/mandrel/issues/4056)) ([2e3d210](https://github.com/dsj1984/mandrel/commit/2e3d210b8e351e8b23ae4e5272ff7d950792b29b))
40
+
5
41
  ## [1.60.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.59.0...mandrel-v1.60.0) (2026-06-11)
6
42
 
7
43
 
package/lib/cli/doctor.js CHANGED
@@ -17,11 +17,15 @@
17
17
  * Exit code 0 = all pass, 1 = any fail.
18
18
  *
19
19
  * Injectable seams (used by tests):
20
- * - `checks` — replaces the default registry array
21
- * - `write` — replaces process.stdout.write
22
- * - `exit` — replaces process.exit
20
+ * - `checks` — replaces the default registry array
21
+ * - `write` — replaces process.stdout.write
22
+ * - `exit` — replaces process.exit
23
+ * - `writeResultCache` — replaces the temp/doctor-result.json writer
23
24
  */
24
25
 
26
+ import nodeFs from 'node:fs';
27
+ import path from 'node:path';
28
+
25
29
  import { registry } from './registry.js';
26
30
 
27
31
  // ---------------------------------------------------------------------------
@@ -74,6 +78,40 @@ function formatSummary(passed, total) {
74
78
  return `❌ Not ready (${failed}/${total} checks failed)\n`;
75
79
  }
76
80
 
81
+ // ---------------------------------------------------------------------------
82
+ // Result cache
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Best-effort write of the doctor verdict to `temp/doctor-result.json` under
87
+ * the consumer root so downstream workflows (e.g. the `/plan` first-run
88
+ * preflight) can read the last recorded verdict without re-running doctor.
89
+ * `temp/` is the gitignored scratch root, so neither git nor the sync prune
90
+ * pass ever sees the cache. Any write failure is swallowed — the cache is
91
+ * advisory, never a gate.
92
+ *
93
+ * Exported for testing.
94
+ *
95
+ * @param {'ready'|'unready'} verdict
96
+ * @param {{ fs?: typeof nodeFs, cwd?: () => string }} [opts]
97
+ * @returns {void}
98
+ */
99
+ export function writeDoctorResultCache(
100
+ verdict,
101
+ { fs = nodeFs, cwd = process.cwd } = {},
102
+ ) {
103
+ try {
104
+ const dir = path.join(cwd(), 'temp');
105
+ fs.mkdirSync(dir, { recursive: true });
106
+ fs.writeFileSync(
107
+ path.join(dir, 'doctor-result.json'),
108
+ `${JSON.stringify({ verdict, checkedAt: new Date().toISOString() }, null, 2)}\n`,
109
+ );
110
+ } catch {
111
+ // Best-effort only — an unwritable temp/ must never fail the doctor run.
112
+ }
113
+ }
114
+
77
115
  // ---------------------------------------------------------------------------
78
116
  // Doctor runner (exported for testing)
79
117
  // ---------------------------------------------------------------------------
@@ -85,6 +123,7 @@ function formatSummary(passed, total) {
85
123
  * checks?: Array<{ name: string, run(opts?: unknown): { ok: boolean, detail: string, remedy?: string } }>,
86
124
  * write?: (s: string) => void,
87
125
  * exit?: (code: number) => void,
126
+ * writeResultCache?: (verdict: 'ready'|'unready') => void,
88
127
  * }} [opts]
89
128
  * @returns {void}
90
129
  */
@@ -92,6 +131,7 @@ export async function runDoctor({
92
131
  checks = registry,
93
132
  write = (s) => process.stdout.write(s),
94
133
  exit = (code) => process.exit(code),
134
+ writeResultCache = writeDoctorResultCache,
95
135
  } = {}) {
96
136
  let passed = 0;
97
137
 
@@ -104,6 +144,8 @@ export async function runDoctor({
104
144
  const total = checks.length;
105
145
  write(formatSummary(passed, total));
106
146
 
147
+ writeResultCache(passed === total ? 'ready' : 'unready');
148
+
107
149
  if (passed < total) {
108
150
  exit(1);
109
151
  }
package/lib/cli/init.js CHANGED
@@ -66,6 +66,30 @@
66
66
  import { spawnSync } from 'node:child_process';
67
67
  import fs from 'node:fs';
68
68
  import path from 'node:path';
69
+ import { pathToFileURL } from 'node:url';
70
+
71
+ // Lazily resolved at runtime so cold-start `npx mandrel init` (where
72
+ // `.agents/` is absent) does not try to import from a directory that does not
73
+ // exist yet. The import is performed after bootstrap succeeds and `.agents/`
74
+ // is guaranteed to be materialised.
75
+ let _runInitTail = null;
76
+ async function getRunInitTail(projectRoot) {
77
+ if (_runInitTail) return _runInitTail;
78
+ const tailPath = path.join(
79
+ projectRoot,
80
+ '.agents',
81
+ 'scripts',
82
+ 'lib',
83
+ 'onboard',
84
+ 'init-tail.js',
85
+ );
86
+ // pathToFileURL handles Windows drive letters correctly (a raw
87
+ // `file://C:\…` template treats the drive letter as a URL host — same
88
+ // fix as commit 2e3d210b in lib/transpile.js).
89
+ const mod = await import(pathToFileURL(tailPath).href);
90
+ _runInitTail = mod.runInitTail;
91
+ return _runInitTail;
92
+ }
69
93
 
70
94
  /**
71
95
  * Hardcoded build-time package name. NEVER read from argv or env — cold-start
@@ -154,21 +178,23 @@ function buildBootstrapArgs(argv, assumeYes) {
154
178
  * confirm?: () => boolean,
155
179
  * stdout?: (s: string) => void,
156
180
  * isTTY?: boolean,
181
+ * afterBootstrap?: (root: string) => Promise<{ ok?: boolean } | void> | { ok?: boolean } | void,
157
182
  * }} [opts]
158
- * @returns {{
183
+ * @returns {Promise<{
159
184
  * installed: boolean,
160
185
  * ranBootstrap: boolean,
161
186
  * steps: Array<{ cmd: string, args: string[] }>,
162
187
  * exitCode: number,
163
- * }}
188
+ * }>}
164
189
  */
165
- export function planInit({
190
+ export async function planInit({
166
191
  argv = [],
167
192
  exists,
168
193
  runStep,
169
194
  confirm,
170
195
  stdout = (s) => process.stdout.write(s),
171
196
  isTTY,
197
+ afterBootstrap,
172
198
  } = {}) {
173
199
  const steps = [];
174
200
 
@@ -243,11 +269,37 @@ export function planInit({
243
269
  BOOTSTRAP_SCRIPT,
244
270
  ...bootstrapArgs,
245
271
  ]);
272
+ if (bootstrapStatus !== 0) {
273
+ return {
274
+ installed,
275
+ ranBootstrap: true,
276
+ steps,
277
+ exitCode: bootstrapStatus,
278
+ };
279
+ }
280
+
281
+ // Bootstrap succeeded — run the onboarding tail: stack detection, docs
282
+ // scaffolding offer, doctor gate, and /plan handoff. A tail that reports
283
+ // `ok: false` (the doctor gate failed) makes the whole init exit
284
+ // non-zero; the tail already printed its own remediation message, and the
285
+ // earlier install/sync/bootstrap phases' results stand as completed.
286
+ if (afterBootstrap) {
287
+ const tail = await afterBootstrap(process.cwd());
288
+ if (tail && tail.ok === false) {
289
+ return {
290
+ installed,
291
+ ranBootstrap: true,
292
+ steps,
293
+ exitCode: 1,
294
+ };
295
+ }
296
+ }
297
+
246
298
  return {
247
299
  installed,
248
300
  ranBootstrap: true,
249
301
  steps,
250
- exitCode: bootstrapStatus,
302
+ exitCode: 0,
251
303
  };
252
304
  }
253
305
 
@@ -309,9 +361,9 @@ function defaultConfirm() {
309
361
  * Default export consumed by `bin/mandrel.js`.
310
362
  *
311
363
  * @param {string[]} [argv] - Subcommand arguments (after `mandrel init`).
312
- * @returns {void}
364
+ * @returns {Promise<void>}
313
365
  */
314
- export default function run(argv = []) {
366
+ export default async function run(argv = []) {
315
367
  if (argv.includes('--help') || argv.includes('-h')) {
316
368
  process.stdout.write(
317
369
  'Usage: mandrel init [bootstrap flags]\n\n' +
@@ -324,12 +376,19 @@ export default function run(argv = []) {
324
376
  return;
325
377
  }
326
378
 
327
- const result = planInit({
379
+ const result = await planInit({
328
380
  argv,
329
381
  exists: defaultExists,
330
382
  runStep: defaultRunStep,
331
383
  confirm: defaultConfirm,
332
384
  isTTY: Boolean(process.stdin.isTTY),
385
+ afterBootstrap: async (projectRoot) => {
386
+ const runInitTail = await getRunInitTail(projectRoot);
387
+ return runInitTail({
388
+ root: projectRoot,
389
+ isTTY: Boolean(process.stdin.isTTY),
390
+ });
391
+ },
333
392
  });
334
393
 
335
394
  if (result.exitCode !== 0) {