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.
- package/.agents/scripts/agents-bootstrap-github.js +40 -48
- package/.agents/scripts/bootstrap.js +74 -60
- package/.agents/scripts/check-action-pinning.js +260 -0
- package/.agents/scripts/check-arch-cycles.js +38 -14
- package/.agents/scripts/epic-deliver-prepare.js +149 -104
- package/.agents/scripts/lib/baseline-snapshot.js +245 -141
- package/.agents/scripts/lib/bootstrap/branch-protection.js +8 -8
- package/.agents/scripts/lib/bootstrap/gh-preflight.js +3 -3
- package/.agents/scripts/lib/bootstrap/hitl-confirm.js +2 -2
- package/.agents/scripts/lib/bootstrap/merge-methods.js +7 -7
- package/.agents/scripts/lib/bootstrap/preflight.js +18 -15
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +5 -5
- package/.agents/scripts/lib/bootstrap/prompt.js +5 -1
- package/.agents/scripts/lib/detect-package-manager.js +2 -2
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
- package/.agents/scripts/lib/onboard/init-tail.js +60 -69
- package/.agents/scripts/lib/orchestration/code-review.js +206 -168
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
- package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
- package/.agents/scripts/lib/signals/detectors/common.js +107 -0
- package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
- package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
- package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
- package/.agents/scripts/lib/story-body/story-body.js +102 -76
- package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
- package/.agents/scripts/providers/github/tickets.js +1 -1
- package/.agents/scripts/single-story-init.js +16 -3
- package/.agents/workflows/audit-architecture.md +9 -0
- package/.agents/workflows/helpers/deliver-stories.md +24 -2
- package/.agents/workflows/helpers/single-story-deliver.md +84 -1
- package/README.md +1 -1
- package/docs/CHANGELOG.md +43 -0
- package/lib/cli/init.js +66 -21
- package/lib/cli/sync.js +3 -3
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
return envelope;
|
|
256
|
+
return { skipped: [{ reason: 'toggle-disabled' }] };
|
|
264
257
|
}
|
|
265
|
-
|
|
266
258
|
if (!Number.isInteger(epicId) || epicId < 1) {
|
|
267
|
-
|
|
268
|
-
return envelope;
|
|
259
|
+
return { errors: [`${spec.fnName}: missing or invalid epicId`] };
|
|
269
260
|
}
|
|
270
261
|
if (!provider || typeof provider.getTicketComments !== 'function') {
|
|
271
|
-
|
|
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
|
-
|
|
280
|
-
return envelope;
|
|
269
|
+
return { errors: [`${spec.fnName}: missing currentRepo {owner,repo}`] };
|
|
281
270
|
}
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
282
273
|
|
|
283
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
302
|
-
return envelope;
|
|
295
|
+
return { skipped: [{ reason: spec.noCommentReason }] };
|
|
303
296
|
}
|
|
304
|
-
const
|
|
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
|
-
|
|
310
|
-
return envelope;
|
|
299
|
+
return { skipped: [{ reason: 'no-non-blocking-findings' }] };
|
|
311
300
|
}
|
|
301
|
+
return { findings };
|
|
302
|
+
}
|
|
312
303
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
+
envelope,
|
|
440
|
+
decorate,
|
|
386
441
|
epicId,
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
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 —
|
|
9
|
-
* Phase 2 —
|
|
10
|
-
* Phase 3 —
|
|
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-
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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[
|
|
79
|
-
'
|
|
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 = '\
|
|
65
|
+
const SCAFFOLD_PROMPT = '\nCreate placeholders? [Y/n]: ';
|
|
86
66
|
|
|
87
67
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
-
* @
|
|
87
|
+
* @param {{ createInterface?: typeof readline.createInterface }} [opts]
|
|
88
|
+
* @returns {Promise<boolean>}
|
|
92
89
|
*/
|
|
93
|
-
function
|
|
94
|
-
|
|
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
|
|
97
|
-
answer
|
|
99
|
+
const answer = (await rl.question('')).trim().toLowerCase();
|
|
100
|
+
return answer !== 'n' && answer !== 'no';
|
|
98
101
|
} catch {
|
|
99
|
-
|
|
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 ??
|
|
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:
|
|
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[
|
|
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
|
-
`[
|
|
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
|
|
203
|
-
stdout('\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[
|
|
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 {
|
|
203
|
+
return { scaffoldResult, doctorStatus, ok: false };
|
|
213
204
|
}
|
|
214
205
|
|
|
215
|
-
// --- Phase
|
|
206
|
+
// --- Phase 3: Handoff to /plan -------------------------------------------
|
|
216
207
|
stdout(PLAN_HANDOFF_TEXT);
|
|
217
|
-
return {
|
|
208
|
+
return { scaffoldResult, doctorStatus, ok: true };
|
|
218
209
|
}
|