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
@@ -25,11 +25,11 @@ by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`
25
25
  fails when it drifts from the on-disk workflow set. To change a command’s
26
26
  description, edit the workflow file’s front-matter and regenerate.
27
27
 
28
- ## Commands (27)
28
+ ## Commands (26)
29
29
 
30
30
  | Command | Description |
31
31
  | --- | --- |
32
- | `/agents-update` | npm-era upgrade wraparound for a Mandrel consumer. Runs `mandrel update` (resolve newest non-major version → install → re-materialize `.agents/` → migrate → doctor → surface changelog) as the single mechanical step, then walks the operator through the judgment wraparound the CLI deliberately leaves unowned: reconcile `.agentrc.json`, install the Epic #1386 quality-gate surface, refresh the harness permission allowlist, reconcile the consumer's `AGENTS.md` / runbooks against the surfaced changelog, and stage + commit the staged lockfile bump. |
32
+ | `/agents-update` | npm-era upgrade wraparound for a Mandrel consumer. Runs `mandrel update` (resolve newest published version → install → re-materialize `.agents/` → migrate → doctor → surface changelog) as the single mechanical step, then walks the operator through the judgment wraparound the CLI deliberately leaves unowned: reconcile `.agentrc.json`, install the Epic #1386 quality-gate surface, refresh the harness permission allowlist, reconcile the consumer's `AGENTS.md` / runbooks against the surfaced changelog, and stage + commit the staged lockfile bump. |
33
33
  | `/audit-architecture` | Audit architectural boundaries, module coupling, and layering violations; emit a structured findings report keyed to High/Medium/Low severity. |
34
34
  | `/audit-clean-code` | Audit code smells, dead code, complexity hotspots, and maintainability-index outliers; emit a structured findings report. |
35
35
  | `/audit-dependencies` | Audit `package.json` for unused, outdated, and major-version-stale dependencies; surface Node-engine drift and propose upgrade batches. |
@@ -44,14 +44,13 @@ description, edit the workflow file’s front-matter and regenerate.
44
44
  | `/audit-sre` | "Audit production-readiness for a release candidate: SLOs, observability, runbooks, error budgets, and rollback paths." |
45
45
  | `/audit-to-stories` | Convert findings produced by the audit-\* workflows into actionable GitHub Stories. Reads temp/audits/audit-\*-results.md, groups findings cross-audit, deduplicates against existing Issues by fingerprint, and either chains into /plan --idea or opens standalone Stories. |
46
46
  | `/audit-ux-ui` | Audit UX/UI consistency and design system adherence |
47
- | `/deliver` | Unified delivery entry point. Inspects the ticket type(s) and Epic-reference state of the supplied IDs, then routes to the Epic wave loop or the standalone multi-Story fan-out — preserving every flag and the parallel-delivery contract of the retired commands. |
47
+ | `/deliver` | Unified delivery entry point. Inspects the ticket type(s) and Epic-reference state of the supplied IDs, composes a sequential segment plan over any mix of Epics and standalone Stories, then delegates each segment to the Epic wave loop or the standalone multi-Story fan-out — preserving every flag and the parallel-delivery contract of the retired commands. |
48
48
  | `/explain` | Walk the operator through a code change until they genuinely understand it. Targets a PR, a branch, or the working-tree diff, then drives the `core/knowledge-transfer` skill (restate-first, why-ladder, mastery gates, persistent checklist) with an operator-controlled stop at every checkpoint. |
49
49
  | `/git-cleanup` | Tidy the local checkout in four phases: fast-forward `main`, prune stale remote-tracking refs, sweep merged branches (squash-aware), and triage `git stash` entries — each step gated by operator confirmation. |
50
50
  | `/git-commit-all` | Stage every untracked and modified file, then create a single conventional-commit on the current branch (no push). |
51
51
  | `/git-merge-pr` | Analyze, validate, resolve conflicts, and merge a given pull request by number. |
52
52
  | `/git-pr-all` | Stage all outstanding changes, commit, push to a feature branch, and open a pull request with native auto-merge enabled. |
53
53
  | `/git-push` | Commit all outstanding changes then push to the remote repository. |
54
- | `/onboard` | Guided first-run onboarding for a freshly installed Mandrel. Detects the consumer stack, offers to scaffold any missing docsContextFiles, runs `mandrel doctor` as a readiness gate, and hands off to a started /plan. The whole path is designed to take about 15 minutes from a clean checkout to a planned Epic. |
55
54
  | `/plan` | Unified planning entry point. Routes a seed idea (via scope triage) or an existing Epic ID to the right planning path — the full Epic pipeline (PRD, Tech Spec, Acceptance Spec, decomposition) or the standalone-Story authoring path — and absorbs every planning flag. |
56
55
  | `/qa-assist` | Human-led QA assist loop — ingest one operator observation, enrich it with repro + root-cause (file:line) + a coverage verdict, ask clarifying questions when it is ambiguous, and append a redacted ledger item to a persistent, resumable rolling session under temp/qa/ |
57
56
  | `/qa-explore` | Agent-led exploratory-QA loop — the agent Plans a surface with an explicit static-vs-drive method choice, drives it (browser MCP or static), and captures ledger items read-only, then Triages — a bounded per-surface session, HITL-gated at every phase transition, routed through the shared dedup/coverage/classification/missing-test/redaction/session core under temp/qa/ |
@@ -7,12 +7,12 @@
7
7
  "minimatch": "^10.0.0",
8
8
  "picomatch": "^4.0.4",
9
9
  "string-argv": "^0.3.2",
10
- "typescript": ">=5.0.0",
11
10
  "typhonjs-escomplex": "^0.1.0"
12
11
  },
13
12
  "optionalDependencies": {
14
13
  "@commitlint/load": "^21.0.0",
15
14
  "chokidar": "^5.0.0",
16
- "jscpd": "^4.0.0"
15
+ "jscpd": "^4.0.0",
16
+ "typescript": ">=5.0.0"
17
17
  }
18
18
  }
@@ -87,7 +87,7 @@ when Stryker itself fails to run.
87
87
  - [`/docs/architecture.md`](../../docs/architecture.md) — system
88
88
  architecture; the "Key Scripts" section lists the standard
89
89
  orchestration entrypoints.
90
- - [`/docs/quality-gates.md`](../../docs/quality-gates.md) — coverage,
90
+ - [`.agents/docs/quality-gates.md`](../docs/quality-gates.md) — coverage,
91
91
  CRAP, and maintainability baselines + floors.
92
92
  - `package.json` `scripts` — the canonical list of standard CLIs
93
93
  (`test`, `verify`, `coverage:update`, …).
@@ -15,13 +15,7 @@
15
15
  * @see docs/v5-implementation-plan.md Sprint 1C
16
16
  */
17
17
 
18
- import fs from 'node:fs';
19
- import path from 'node:path';
20
18
  import { applyBranchProtection } from './lib/bootstrap/branch-protection.js';
21
- import {
22
- CI_WORKFLOW_RELATIVE_PATH,
23
- renderCiWorkflow,
24
- } from './lib/bootstrap/ci-workflow-template.js';
25
19
  import {
26
20
  compareSemver,
27
21
  MIN_GH_VERSION,
@@ -55,7 +49,7 @@ import {
55
49
  import { createProvider } from './lib/provider-factory.js';
56
50
 
57
51
  const PROJECTS_DOC_POINTER =
58
- 'See docs/project-board.md for the manual Projects V2 setup checklist.';
52
+ 'Configure the board views manually in the GitHub Projects UI.';
59
53
 
60
54
  /**
61
55
  * Detect that an error is a not-found / 404 signal across the surfaces
@@ -266,106 +260,6 @@ async function ensureProjectFields(provider, project, log) {
266
260
  return fields;
267
261
  }
268
262
 
269
- /**
270
- * Create or additively-merge branch protection on `baseBranch` (typically
271
- * `main`) so the `delivery.quality.prGate.checks` suite is required
272
- * before merge. Behaviour rules:
273
- *
274
- * - `enforceBranchProtection: false` → skip, log the opt-out, return a
275
- * `{ status: 'skipped' }` summary.
276
- * - `prGate.checks` empty or absent → skip with a clear log, since there
277
- * is nothing to enforce.
278
- * - Existing protection rule → preserve every existing required-check
279
- * context and append only the missing prGate names.
280
- * - No existing rule → create a fresh one carrying just the prGate
281
- * contexts plus minimal sensible defaults (strict status checks).
282
- *
283
- * Errors (insufficient scopes, repo permission denied, etc.) are logged
284
- * and return a `{ status: 'failed' }` summary so the bootstrap CLI
285
- * surfaces a non-fatal warning rather than aborting the entire run —
286
- * matching how the project-board provisioning steps degrade.
287
- */
288
- async function ensureMainBranchProtection(
289
- provider,
290
- { baseBranch, prGate },
291
- log,
292
- ) {
293
- if (prGate?.enforceBranchProtection === false) {
294
- log(
295
- `[bootstrap] Branch protection on '${baseBranch}': skipped (delivery.quality.prGate.enforceBranchProtection=false).`,
296
- );
297
- return { status: 'skipped', reason: 'opt-out' };
298
- }
299
-
300
- const checkNames = (prGate?.checks ?? [])
301
- .map((c) => c?.name)
302
- .filter((n) => typeof n === 'string' && n.length > 0);
303
- if (checkNames.length === 0) {
304
- log(
305
- `[bootstrap] Branch protection on '${baseBranch}': skipped (no prGate.checks configured).`,
306
- );
307
- return { status: 'skipped', reason: 'no-checks' };
308
- }
309
-
310
- try {
311
- const result = await provider.setBranchProtection(baseBranch, {
312
- contexts: checkNames,
313
- });
314
- const verb = result.created ? 'Created' : 'Updated';
315
- const addedSuffix = result.added.length
316
- ? ` (added: ${result.added.join(', ')})`
317
- : ' (all required checks already present)';
318
- log(
319
- `[bootstrap] Branch protection on '${baseBranch}': ${verb} rule${addedSuffix}.`,
320
- );
321
- return { status: result.created ? 'created' : 'merged', ...result };
322
- } catch (err) {
323
- log(
324
- `[bootstrap] Branch protection on '${baseBranch}': failed — ${err.message}. Proceeding without it.`,
325
- );
326
- return { status: 'failed', reason: err.message };
327
- }
328
- }
329
-
330
- /**
331
- * Render the stabilized-quality-gates CI workflow template into a project
332
- * checkout. Idempotent on the byte level: when `.github/workflows/ci.yml`
333
- * already matches the rendered template, no write occurs and the action is
334
- * `unchanged`. When the file is absent the action is `created`. When the
335
- * file exists with operator-authored differences the helper preserves it
336
- * and returns `custom-workflow-skip` along with the rendered body so the
337
- * bootstrap caller (or `/agents-update`) can offer a side-by-side diff.
338
- *
339
- * Network-free; safe to invoke under tests with a tmp `projectRoot`.
340
- *
341
- * @param {object} args
342
- * @param {string} args.projectRoot - Repo root (must contain or accept
343
- * `.github/workflows/`).
344
- * @param {object} [args.template] - Forwarded to `renderCiWorkflow`.
345
- * @param {boolean} [args.write=true] - When `false`, the helper computes
346
- * the would-be action without touching disk. Used by the
347
- * bootstrap CLI's dry-run mode.
348
- * @returns {{ action: 'created'|'unchanged'|'custom-workflow-skip',
349
- * path: string, rendered: string }}
350
- */
351
- export function ensureCiWorkflow(args) {
352
- const projectRoot = args.projectRoot;
353
- const rendered = renderCiWorkflow(args.template);
354
- const target = path.join(projectRoot, CI_WORKFLOW_RELATIVE_PATH);
355
- if (!fs.existsSync(target)) {
356
- if (args.write !== false) {
357
- fs.mkdirSync(path.dirname(target), { recursive: true });
358
- fs.writeFileSync(target, rendered, 'utf8');
359
- }
360
- return { action: 'created', path: target, rendered };
361
- }
362
- const existing = fs.readFileSync(target, 'utf8');
363
- if (existing === rendered) {
364
- return { action: 'unchanged', path: target, rendered };
365
- }
366
- return { action: 'custom-workflow-skip', path: target, rendered };
367
- }
368
-
369
263
  /**
370
264
  * Run the idempotent bootstrap sequence.
371
265
  *
@@ -397,6 +291,7 @@ export function ensureCiWorkflow(args) {
397
291
  * github?: object,
398
292
  * baseBranch?: string,
399
293
  * githubAdminApproved?: boolean,
294
+ * isTTY?: boolean,
400
295
  * }} [opts] - `githubAdminApproved` MUST be `true` for any GitHub mutation to
401
296
  * occur; any other value (absent / `false`) is treated as "not approved"
402
297
  * and the run is a verified no-op.
@@ -463,12 +358,12 @@ export async function runBootstrap(config, opts = {}) {
463
358
  // Consumer-facing bootstrap promotes the framework's CI-gates-only
464
359
  // stance: branch protection with enforce_admins + 0-approval-count and
465
360
  // the squash-only merge-method allowlist. Behavior-shifting drift on
466
- // either step routes through the HITL confirm gate — non-TTY runs abort
467
- // with a clear stderr message rather than silently apply.
361
+ // branch protection routes through the HITL confirm gate — non-TTY runs
362
+ // abort with a clear stderr message rather than silently apply. The
363
+ // merge-method step differs by design (Story #4045 A4): non-TTY without an
364
+ // assume override default-applies the framework stance with an explicit
365
+ // log line (see mergeMethodsHitlConfirm below).
468
366
  //
469
- // The legacy `ensureMainBranchProtection` helper is preserved (re-
470
- // exported below) so the Epic #1142 Story #1157 contract tests stay
471
- // green; `applyBranchProtection` is its consumer-parity successor.
472
367
  // Post-reshape: bootstrap reads from the new `project` + `github` blocks
473
368
  // exclusively. The legacy "agent settings" opt was removed in Epic #2880.
474
369
  const projectCfg = opts.project ?? config.project ?? {};
@@ -493,10 +388,22 @@ export async function runBootstrap(config, opts = {}) {
493
388
  hitlConfirm,
494
389
  log,
495
390
  });
391
+
392
+ // Merge-methods gate (Story #4045 A4): under non-TTY without an explicit
393
+ // assume override there is no operator to consult, and the default HITL
394
+ // gate declines every non-TTY prompt — which would make applyMergeMethods'
395
+ // documented non-TTY default-apply branch unreachable. Skip the gate in
396
+ // that case so the merge-method stance default-applies with its explicit
397
+ // log line. Interactive runs (and explicit --assume-yes/--assume-no, and
398
+ // injected gates) keep the loud confirm/decline behaviour.
399
+ const stdoutIsTTY = opts.isTTY ?? Boolean(process.stdout.isTTY);
400
+ const mergeMethodsHitlConfirm =
401
+ opts.hitlConfirm ??
402
+ (stdoutIsTTY || opts.assumeYes || opts.assumeNo ? hitlConfirm : undefined);
496
403
  const mergeMethods = await applyMergeMethods({
497
404
  provider,
498
405
  settings,
499
- hitlConfirm,
406
+ hitlConfirm: mergeMethodsHitlConfirm,
500
407
  log,
501
408
  });
502
409
 
@@ -539,8 +446,9 @@ async function main() {
539
446
  }
540
447
 
541
448
  // Preflight runtime deps before the dynamic config-resolver import so
542
- // a green-field consumer who skipped `/agents-bootstrap-project` gets
543
- // a workflow hint instead of a raw `ERR_MODULE_NOT_FOUND`.
449
+ // a consumer who hasn't installed framework runtime deps yet gets a
450
+ // clear hint (`run mandrel init` or `npm install mandrel`) instead of
451
+ // a raw `ERR_MODULE_NOT_FOUND`.
544
452
  try {
545
453
  await preflightRuntimeDeps();
546
454
  } catch (err) {
@@ -569,7 +477,6 @@ async function main() {
569
477
  process.exit(1);
570
478
  }
571
479
 
572
- const installWorkflows = process.argv.includes('--install-workflows');
573
480
  // Epic #1235 Story 5 — flags let CI / non-interactive callers pin the
574
481
  // HITL gate's answer deterministically. The bootstrap is non-interactive
575
482
  // by default in non-TTY contexts (the gate returns false and aborts);
@@ -594,7 +501,6 @@ async function main() {
594
501
 
595
502
  try {
596
503
  const result = await runBootstrap(config, {
597
- installWorkflows,
598
504
  project: config.project,
599
505
  github: config.github,
600
506
  assumeYes,
@@ -617,12 +523,10 @@ async function main() {
617
523
  }
618
524
  }
619
525
 
620
- // Re-export internal helpers for test consumers (no production caller imports them).
621
526
  // Re-export the gh-preflight surface so existing test consumers can keep
622
527
  // importing it from this module after the Story #3349 split.
623
528
  export {
624
529
  compareSemver,
625
- ensureMainBranchProtection,
626
530
  isApiAccessNotFoundError,
627
531
  MIN_GH_VERSION,
628
532
  parseGhVersion,
@@ -28,6 +28,9 @@
28
28
  * @module bootstrap/ci-workflow-template
29
29
  */
30
30
 
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
33
+
31
34
  /**
32
35
  * @typedef {object} CiTemplateOptions
33
36
  * @property {string} [nodeVersion='22'] - Node major to install via setup-node.
@@ -169,3 +172,46 @@ ${crapBlock}`;
169
172
  * path without re-deriving it.
170
173
  */
171
174
  export const CI_WORKFLOW_RELATIVE_PATH = '.github/workflows/ci.yml';
175
+
176
+ /**
177
+ * Render the stabilized-quality-gates CI workflow template into a project
178
+ * checkout. Idempotent on the byte level: when `.github/workflows/ci.yml`
179
+ * already matches the rendered template, no write occurs and the action is
180
+ * `unchanged`. When the file is absent the action is `created`. When the
181
+ * file exists with operator-authored differences the helper preserves it
182
+ * and returns `custom-workflow-skip` along with the rendered body so the
183
+ * bootstrap caller (or `/agents-update`) can offer a side-by-side diff.
184
+ *
185
+ * Network-free; safe to invoke under tests with a tmp `projectRoot`.
186
+ *
187
+ * Moved from `agents-bootstrap-github.js` to this module so it lives
188
+ * alongside `renderCiWorkflow` and `CI_WORKFLOW_RELATIVE_PATH`
189
+ * (Story #4048 B2 — dead-code deletion from the bootstrap entry point).
190
+ *
191
+ * @param {object} args
192
+ * @param {string} args.projectRoot - Repo root (must contain or accept
193
+ * `.github/workflows/`).
194
+ * @param {object} [args.template] - Forwarded to `renderCiWorkflow`.
195
+ * @param {boolean} [args.write=true] - When `false`, the helper computes
196
+ * the would-be action without touching disk. Used by the
197
+ * bootstrap CLI's dry-run mode.
198
+ * @returns {{ action: 'created'|'unchanged'|'custom-workflow-skip',
199
+ * path: string, rendered: string }}
200
+ */
201
+ export function ensureCiWorkflow(args) {
202
+ const projectRoot = args.projectRoot;
203
+ const rendered = renderCiWorkflow(args.template);
204
+ const target = path.join(projectRoot, CI_WORKFLOW_RELATIVE_PATH);
205
+ if (!fs.existsSync(target)) {
206
+ if (args.write !== false) {
207
+ fs.mkdirSync(path.dirname(target), { recursive: true });
208
+ fs.writeFileSync(target, rendered, 'utf8');
209
+ }
210
+ return { action: 'created', path: target, rendered };
211
+ }
212
+ const existing = fs.readFileSync(target, 'utf8');
213
+ if (existing === rendered) {
214
+ return { action: 'unchanged', path: target, rendered };
215
+ }
216
+ return { action: 'custom-workflow-skip', path: target, rendered };
217
+ }
@@ -38,14 +38,12 @@ const GH_SCOPES_UNREADABLE_NOTE =
38
38
  * Framework runtime deps the consumer must have installed in
39
39
  * `node_modules/` before this script reaches the dynamic
40
40
  * `config-resolver` import. `ajv` is the sentinel — if it cannot
41
- * resolve, the operator skipped `/agents-bootstrap-project` (or its
42
- * Step 2c/2d dependency-install never ran). The list mirrors the floor
43
- * in `agents-bootstrap-project.md` Step 2c; keep them in sync.
41
+ * resolve, the framework runtime dependencies are not installed.
44
42
  */
45
43
  const REQUIRED_RUNTIME_DEPS = Object.freeze(['ajv']);
46
44
 
47
45
  const RUNTIME_DEPS_HINT =
48
- 'Run `/agents-bootstrap-project` (or `node .agents/scripts/agents-bootstrap-project.js` when present) to merge the framework runtime dependencies into your package.json and install them, then re-run this command.';
46
+ 'Run `mandrel init` (for a fresh project) or `npm install mandrel` (for an existing one) to install the framework runtime dependencies, then re-run this command.';
49
47
 
50
48
  /**
51
49
  * Default runner: synchronously execs `gh <args>` and returns
@@ -273,12 +271,12 @@ function classifyProjectScopes(scopeLine) {
273
271
  /**
274
272
  * Preflight the framework's runtime dependencies before dynamic-importing
275
273
  * `config-resolver.js` (which transitively pulls in `ajv` via
276
- * `config-settings-schema.js`). A fresh consumer who skipped
277
- * `/agents-bootstrap-project` will not have `ajv` installed, and the
278
- * raw `ERR_MODULE_NOT_FOUND` from the dynamic import is opaque. This
274
+ * `config-settings-schema.js`). A consumer who has not installed the
275
+ * framework runtime deps will not have `ajv` available, and the raw
276
+ * `ERR_MODULE_NOT_FOUND` from the dynamic import is opaque. This
279
277
  * preflight converts that into a {@link MissingRuntimeDepsError} that
280
- * names the missing packages and points the operator at the right
281
- * workflow.
278
+ * names the missing packages and points the operator at the correct
279
+ * remediation (`mandrel init` / `npm install mandrel`).
282
280
  *
283
281
  * The `resolver` seam lets tests inject a stub without touching the real
284
282
  * module graph; production uses `import.meta.resolve(specifier)`.
@@ -150,7 +150,17 @@ export function buildMutationManifest(ctx = {}) {
150
150
 
151
151
  // --- repo-config ------------------------------------------------------
152
152
  // Local repository configuration files the bootstrap seeds or extends.
153
+ // Also includes git-init, which is the most irreversible local mutation
154
+ // the bootstrap performs (B1 — uninstall ledger must record it).
153
155
  entries.push(
156
+ {
157
+ phaseGroup: PHASE_GROUPS.REPO_CONFIG,
158
+ target: rel('.git'),
159
+ action: 'run',
160
+ detail:
161
+ 'Initialize the local git repository (git init + first commit) when absent. No-op when already a git repo.',
162
+ reversible: false,
163
+ },
154
164
  {
155
165
  phaseGroup: PHASE_GROUPS.REPO_CONFIG,
156
166
  target: rel('package.json'),
@@ -209,6 +219,16 @@ export function buildMutationManifest(ctx = {}) {
209
219
  ? `${ctx.answers.owner}/${ctx.answers.repo}`
210
220
  : 'the GitHub repository';
211
221
  entries.push(
222
+ {
223
+ // B1: GitHub repo creation is the most irreversible remote mutation —
224
+ // record it so the uninstall ledger always lists it.
225
+ phaseGroup: PHASE_GROUPS.GITHUB_ADMIN,
226
+ target: `${repoSlug} (repo)`,
227
+ action: 'create',
228
+ detail:
229
+ 'Create the GitHub repository (gh repo create --source=. --push) when absent. No-op when already pushed.',
230
+ reversible: false,
231
+ },
212
232
  {
213
233
  phaseGroup: PHASE_GROUPS.GITHUB_ADMIN,
214
234
  target: `${repoSlug} labels`,
@@ -238,7 +258,7 @@ export function buildMutationManifest(ctx = {}) {
238
258
  target: `${repoSlug} merge methods`,
239
259
  action: 'configure',
240
260
  detail:
241
- 'Set the allowed pull-request merge methods to the framework stance.',
261
+ 'Set the allowed pull-request merge methods to the framework stance (squash-only, auto-merge enabled).',
242
262
  reversible: false,
243
263
  },
244
264
  );
@@ -17,10 +17,13 @@
17
17
  * No drift (live settings already match the target): no-op, returns
18
18
  * `{ status: 'unchanged' }`.
19
19
  *
20
- * Drift (any field differs from the target stance): routes the proposed
21
- * payload through `hitlConfirm`. On approval, PATCH is issued. On
22
- * decline or non-TTY (the gate returns false), the module returns
23
- * `{ status: 'skipped', reason: 'hitl-declined' }` without writing.
20
+ * Drift (any field differs from the target stance): when a `hitlConfirm`
21
+ * gate is supplied, the proposed payload routes through it — on approval
22
+ * the PATCH is issued; on decline the module returns
23
+ * `{ status: 'skipped', reason: 'hitl-declined' }` without writing (a loud
24
+ * decline, never silent). When NO gate is supplied (non-TTY, no operator
25
+ * present — Story #4045 A4), the framework stance is default-applied with
26
+ * an explicit log line.
24
27
  */
25
28
 
26
29
  export const TARGET_MERGE_METHODS = Object.freeze({
@@ -81,20 +84,32 @@ export async function applyMergeMethods({
81
84
  return { status: 'unchanged' };
82
85
  }
83
86
 
84
- const approved =
85
- typeof hitlConfirm === 'function'
86
- ? await hitlConfirm({
87
- summary:
88
- 'Repo merge-method settings diverge from the framework hands-off-pipeline stance.',
89
- current,
90
- proposed: target,
91
- })
92
- : false;
93
- if (!approved) {
87
+ let approved;
88
+ if (typeof hitlConfirm === 'function') {
89
+ approved = await hitlConfirm({
90
+ summary:
91
+ 'Repo merge-method settings diverge from the framework hands-off-pipeline stance.',
92
+ current,
93
+ proposed: target,
94
+ });
95
+ if (!approved) {
96
+ log(
97
+ '[bootstrap] Merge methods: HITL declined — leaving operator settings ' +
98
+ 'untouched. Note: auto-merge will remain disabled until the merge-method ' +
99
+ 'settings match the framework stance (allow_squash_merge: true, ' +
100
+ 'allow_auto_merge: true, delete_branch_on_merge: true).',
101
+ );
102
+ return { status: 'skipped', reason: 'hitl-declined', diff };
103
+ }
104
+ } else {
105
+ // Non-TTY: no operator present to confirm. Default-apply the framework
106
+ // stance and log explicitly so the consequence is never silent.
94
107
  log(
95
- '[bootstrap] Merge methods: drift detected; HITL declined / non-TTY — leaving operator settings untouched.',
108
+ '[bootstrap] Merge methods: non-TTY — applying framework stance automatically ' +
109
+ '(allow_squash_merge, allow_auto_merge, delete_branch_on_merge). ' +
110
+ 'To opt out, pass a hitlConfirm gate or set github.mergeMethods overrides in .agentrc.json.',
96
111
  );
97
- return { status: 'skipped', reason: 'hitl-declined', diff };
112
+ approved = true;
98
113
  }
99
114
 
100
115
  try {
@@ -1,6 +1,5 @@
1
1
  /**
2
- * bootstrap/project-bootstrap — deterministic port of the
3
- * `/agents-bootstrap-project` workflow (Story #2074, hard cutover).
2
+ * bootstrap/project-bootstrap — deterministic project bootstrap steps.
4
3
  *
5
4
  * Each exported `ensure*` function is one step of the bootstrap. Every step
6
5
  * is idempotent and additive — re-running on an already-bootstrapped clone
@@ -16,6 +15,8 @@ import { spawnSync as defaultSpawnSync } from 'node:child_process';
16
15
  import fs from 'node:fs';
17
16
  import os from 'node:os';
18
17
  import path from 'node:path';
18
+ import { pathToFileURL } from 'node:url';
19
+ import { detectPackageManager as detectPm } from '../detect-package-manager.js';
19
20
  import { LEDGER_RELATIVE_PATH } from './install-ledger.js';
20
21
  import { PHASE_GROUPS, previewMutationManifest } from './manifest.js';
21
22
  import { applyQualityBootstrap } from './quality-bootstrap.js';
@@ -61,8 +62,8 @@ export const GITIGNORE_BLOCKS = Object.freeze({
61
62
  block:
62
63
  '\n# Project-scoped MCP config carries secrets — keep out of git.\n.mcp.json\n',
63
64
  },
64
- // Story #3894: `.env` holds real secrets (`/onboard` instructs operators to
65
- // put `GITHUB_TOKEN` here). It MUST be ignored by default so a cold-start
65
+ // Story #3894: `.env` holds real secrets (`mandrel init` instructs operators
66
+ // to put `GITHUB_TOKEN` here). It MUST be ignored by default so a cold-start
66
67
  // provision never stages/pushes it. The pattern matches a bare `.env` (with
67
68
  // an optional trailing slash) but deliberately NOT `.env.example`, the
68
69
  // committed placeholder that `security-baseline.md` § Secrets Management
@@ -112,7 +113,23 @@ function writeJson(p, obj, fsImpl = fs) {
112
113
  fsImpl.writeFileSync(p, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
113
114
  }
114
115
 
115
- /** Matches root `package.json` `engines.node`. */
116
+ /**
117
+ * The minimum Node.js patch version mandrel requires.
118
+ *
119
+ * Rationale: `22.22.1` is the Node 22 LTS patch that ships with the
120
+ * `node:sqlite` built-in (`--experimental-sqlite` removed from flag
121
+ * requirement in 22.5.0, but the module stabilised at 22.22.1 per the
122
+ * Node 22 LTS changelog). Pinning to this patch prevents silent failures on
123
+ * older 22.x installs that lack the built-in. CI exercises `node-version: 22`
124
+ * which resolves to the latest 22.x (always ≥ 22.22.1 in the current GHA
125
+ * environment), so CI validates the contract without exercising the exact
126
+ * patch floor — the floor is enforced at install time, not in CI.
127
+ *
128
+ * Single source of truth: `registry.js` and any other consumer MUST import
129
+ * this constant rather than duplicating it.
130
+ *
131
+ * Matches `package.json` `engines.node` (`>=22.22.1 <25`).
132
+ */
116
133
  export const REQUIRED_NODE_FLOOR = '22.22.1';
117
134
  export const REQUIRED_NODE_CEILING_MAJOR = 25;
118
135
 
@@ -152,16 +169,18 @@ export function checkNodeVersion(version = process.versions.node) {
152
169
 
153
170
  /**
154
171
  * Detect the package manager based on lockfile presence. Defaults to
155
- * `npm` when no lock is found.
172
+ * `npm` when no lock is found (including the `null` case from the shared
173
+ * helper where no Node manifest exists at all).
174
+ *
175
+ * Delegates to the shared `detectPackageManager` helper
176
+ * (Story #4048 B3 — one implementation per concept).
156
177
  *
157
178
  * @param {string} projectRoot
158
179
  * @param {typeof fs} [fsImpl]
180
+ * @returns {'pnpm'|'yarn'|'npm'}
159
181
  */
160
182
  export function detectPackageManager(projectRoot, fsImpl = fs) {
161
- if (fsImpl.existsSync(path.join(projectRoot, 'pnpm-lock.yaml')))
162
- return 'pnpm';
163
- if (fsImpl.existsSync(path.join(projectRoot, 'yarn.lock'))) return 'yarn';
164
- return 'npm';
183
+ return detectPm(projectRoot, (p) => fsImpl.existsSync(p)) ?? 'npm';
165
184
  }
166
185
 
167
186
  /**
@@ -328,7 +347,9 @@ export async function validateAgentrc(ctx) {
328
347
  if (!fsImpl.existsSync(schemaModule)) {
329
348
  return { ok: false, errors: ['config-settings-schema.js not found'] };
330
349
  }
331
- const mod = await import(`file://${schemaModule.replace(/\\/g, '/')}`);
350
+ // pathToFileURL handles Windows drive letters and percent-encoding
351
+ // correctly (same fix as commit 2e3d210b in lib/transpile.js).
352
+ const mod = await import(pathToFileURL(schemaModule).href);
332
353
  const validate = mod.getAgentrcValidator();
333
354
  const data = readJsonIfExists(
334
355
  path.join(ctx.projectRoot, '.agentrc.json'),
@@ -69,7 +69,7 @@ export function syncAgentrc(opts) {
69
69
  status: 'missing-config',
70
70
  changes: [],
71
71
  errors: [
72
- `No .agentrc.json at ${configPath}. Run /agents-bootstrap-project first.`,
72
+ `No .agentrc.json at ${configPath}. Run \`mandrel init\` (new project) or \`node .agents/scripts/bootstrap.js\` to create it.`,
73
73
  ],
74
74
  configPath,
75
75
  wrote: false,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * detect-package-manager — shared lockfile-probe helper (Story #4048 B3).
3
+ *
4
+ * Five independent copies of this lockfile probe existed across the codebase:
5
+ * - `lib/cli/update.js#detectPackageManager`
6
+ * - `lib/bootstrap/project-bootstrap.js#detectPackageManager`
7
+ * - `lib/runtime-deps/preflight.js#detectPackageManager`
8
+ * - `lib/onboard/detect-stack.js#detectPackageManager`
9
+ * - `lib/worktree/node-modules-strategy.js#selectInstallCommand` (inline)
10
+ *
11
+ * This module is the single authoritative implementation. It uses the
12
+ * strictest semantics from the prior copies: detects `bun` in addition to
13
+ * pnpm/yarn/npm, returns `null` when the directory has no Node manifest at
14
+ * all (not even `package.json`), and optionally reports `workspaceRoot` for
15
+ * pnpm (the `update.js` caller's unique requirement).
16
+ *
17
+ * All callers must handle `null` explicitly — it means the directory carries
18
+ * no recognizable Node toolchain, so callers that need a concrete fallback
19
+ * should coerce: `detectPm(root) ?? 'npm'`.
20
+ *
21
+ * Injectable seams: the `exists` parameter replaces `fs.existsSync` so
22
+ * callers can drive the function with an in-memory fixture in unit tests.
23
+ *
24
+ * Builtins only — this module runs before third-party packages are
25
+ * guaranteed to be present and is also imported from the worktree and
26
+ * runtime-deps preflight guards.
27
+ */
28
+
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+
32
+ /**
33
+ * Detect the package manager from lockfile and manifest presence.
34
+ *
35
+ * Precedence: `pnpm-lock.yaml` > `yarn.lock` > `bun.lockb` >
36
+ * `package-lock.json` > `package.json` (npm without a lockfile yet) > `null`.
37
+ *
38
+ * @param {string} root - Absolute directory to probe (consumer project root).
39
+ * @param {(p: string) => boolean} [exists=fs.existsSync] - Path existence probe.
40
+ * @returns {'pnpm'|'yarn'|'bun'|'npm'|null}
41
+ */
42
+ export function detectPackageManager(root, exists = fs.existsSync) {
43
+ if (exists(path.join(root, 'pnpm-lock.yaml'))) return 'pnpm';
44
+ if (exists(path.join(root, 'yarn.lock'))) return 'yarn';
45
+ if (exists(path.join(root, 'bun.lockb'))) return 'bun';
46
+ if (exists(path.join(root, 'package-lock.json'))) return 'npm';
47
+ if (exists(path.join(root, 'package.json'))) return 'npm';
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Detect the package manager and whether the directory is a pnpm workspace
53
+ * root. The `workspaceRoot` flag is `true` only for pnpm when
54
+ * `pnpm-workspace.yaml` is present alongside the lockfile — the signal that
55
+ * `pnpm add` must carry `-w` to target the workspace-root manifest.
56
+ *
57
+ * Used by `lib/cli/update.js` which needs both pieces of information to
58
+ * construct the correct install command.
59
+ *
60
+ * @param {string} root - Absolute directory to probe.
61
+ * @param {(p: string) => boolean} [exists=fs.existsSync] - Path existence probe.
62
+ * @returns {{ packageManager: 'pnpm'|'yarn'|'bun'|'npm', workspaceRoot: boolean }}
63
+ */
64
+ export function detectPackageManagerWithWorkspace(
65
+ root,
66
+ exists = fs.existsSync,
67
+ ) {
68
+ const pm = detectPackageManager(root, exists) ?? 'npm';
69
+ const workspaceRoot =
70
+ pm === 'pnpm' && exists(path.join(root, 'pnpm-workspace.yaml'));
71
+ return { packageManager: pm, workspaceRoot };
72
+ }