mandrel 2.54.0 → 2.55.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 (114) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +4 -2
  5. package/.agents/docs/configuration.md +2 -0
  6. package/.agents/schemas/agentrc.schema.json +15 -1
  7. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  8. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  9. package/.agents/scripts/audit-to-stories.js +158 -7
  10. package/.agents/scripts/check-audit-attribution.js +119 -62
  11. package/.agents/scripts/check-test-portability.js +512 -0
  12. package/.agents/scripts/coverage-capture.js +17 -10
  13. package/.agents/scripts/evidence-gate.js +31 -4
  14. package/.agents/scripts/generate-workflows-doc.js +65 -14
  15. package/.agents/scripts/git-cleanup.js +4 -0
  16. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  17. package/.agents/scripts/lib/audit-advisories.js +195 -0
  18. package/.agents/scripts/lib/audit-attribution.js +22 -0
  19. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
  20. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  21. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
  22. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  23. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  24. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  25. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  26. package/.agents/scripts/lib/cli-args.js +26 -0
  27. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  28. package/.agents/scripts/lib/close-validation/process.js +7 -3
  29. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  30. package/.agents/scripts/lib/config/ci.js +28 -9
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  32. package/.agents/scripts/lib/config-settings-schema.js +19 -1
  33. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  34. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  35. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  36. package/.agents/scripts/lib/coverage-capture.js +77 -3
  37. package/.agents/scripts/lib/findings/route-finding.js +4 -2
  38. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  39. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  40. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  41. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  42. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  43. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  44. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  45. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  46. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  47. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  48. package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
  49. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  50. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  51. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  52. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  53. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  54. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  55. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  56. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  57. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  58. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  64. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  65. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  67. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  68. package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
  69. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  70. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  73. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  75. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  77. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  78. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  79. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
  80. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  81. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  82. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  83. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  84. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  85. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  86. package/.agents/scripts/lib/test-temp.js +167 -30
  87. package/.agents/scripts/lib/validation-evidence.js +37 -0
  88. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  91. package/.agents/scripts/merge-baseline.js +175 -21
  92. package/.agents/scripts/providers/github/errors.js +22 -1
  93. package/.agents/scripts/providers/github/issues.js +106 -1
  94. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  95. package/.agents/scripts/providers/github.js +6 -0
  96. package/.agents/scripts/resolve-stories.js +44 -34
  97. package/.agents/scripts/single-story-close.js +5 -0
  98. package/.agents/scripts/stories-wave-tick.js +37 -13
  99. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  100. package/.agents/workflows/audit-accessibility.md +16 -31
  101. package/.agents/workflows/audit-mobile.md +20 -37
  102. package/.agents/workflows/git-cleanup.md +17 -3
  103. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  104. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  105. package/.agents/workflows/helpers/deliver-reference.md +35 -14
  106. package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
  107. package/.agents/workflows/helpers/deliver-story.md +15 -12
  108. package/.agents/workflows/helpers/plan-reference.md +7 -0
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +27 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. package/package.json +7 -4
@@ -92,6 +92,13 @@ The marker keeps the operator's undelegated decisions findable after the
92
92
  fact: reviewing a `--yes` plan means scanning its decisions-made-by-default,
93
93
  not re-deriving which assumptions were really the agent's to make.
94
94
 
95
+ ## Gate #1 → the memory-pool advisory (`memoryPoolAdvisory`)
96
+
97
+ On a truthy `memoryPoolAdvisory.recommend`, name
98
+ [`/memory-consolidate`](../memory-consolidate.md) at Gate #1, quoting its
99
+ `reasons[]`. Purely advisory: a stale pool degrades recall, it does not make
100
+ the plan wrong, so it never blocks and never reroutes.
101
+
95
102
  ## Gate #1 → the light path (in-session handoff)
96
103
 
97
104
  On a confirmed `deliverLightSuggestion`, `/mandrel-plan` routes into
@@ -25,8 +25,7 @@ mode from what the operator typed, announce it, act**:
25
25
 
26
26
  **Resolving a bare id.** Read live state rather than asking: `agent::done` can
27
27
  only be amended, an open unplanned issue only planned. **Announce the
28
- derivation** — "4712 is `agent::done` → amending". Ask **only** for an open
29
- Story already at `agent::ready`.
28
+ derivation**. Ask **only** for an open Story already at `agent::ready`.
30
29
 
31
30
  ## Saying what you want
32
31
 
@@ -73,10 +72,6 @@ in Key Assumptions, each a decision-made-by-default.
73
72
  **Gate #1** — STOP to confirm the sharpened plan intent and any
74
73
  duplicate-candidate review. Under `--yes`, auto-proceed.
75
74
 
76
- On a truthy `memoryPoolAdvisory.recommend`, name
77
- [`/memory-consolidate`](memory-consolidate.md) quoting its `reasons[]`;
78
- advisory.
79
-
80
75
  On a truthy `deliverLightSuggestion.suggested`, offer — advisory, never an
81
76
  automatic reroute — to deliver the seed instead; on confirm route **in this
82
77
  session** into [`helpers/deliver-light.md`](helpers/deliver-light.md), its gate
@@ -93,7 +88,9 @@ persist parses either, serializes canonical markdown and syncs top-level
93
88
 
94
89
  **Grounding = your reads + Phase 8.** Nothing inventories the repo: read each
95
90
  file you cite; persist hard-errors on any `{path, assumption}` absent from the
96
- tree. Fields: [ref](helpers/plan-reference.md).
91
+ tree — a `refactors-existing` on a path the base branch **deleted or renamed**
92
+ included. One rescue: a **never-tracked** one normalises to `creates`. Fields:
93
+ [ref](helpers/plan-reference.md).
97
94
 
98
95
  Artifacts under `temp/plan-<slug>/`: `stories.json` (**length 1 by default**;
99
96
  over-budget Specs fail closed — split or tighten, never under `docs/`); optional
@@ -100,19 +100,24 @@ Then write the receipt to `.consolidation-stamp.json` in the pool root:
100
100
  count the directory, never the plan. It is the baseline the next run measures
101
101
  growth against, so a wrong number silently mis-arms the nudge.
102
102
 
103
- The `/mandrel-plan` Phase 0 advisory re-arms on exactly two conditions: the
104
- stamp aging past `planning.memoryPool.staleAfterDays` (30), or
105
- `planning.memoryPool.growthDelta` (25) entries written since that count. Pool
106
- size alone never triggers it — a pass that keeps every entry still quiets the
107
- nudge. A stamp with no `entryCount` leaves growth unmeasured, and only the age
108
- arm can speak until the next pass writes one.
103
+ The `/mandrel-plan` Phase 0 advisory re-arms on exactly three conditions: the
104
+ stamp aging past `planning.memoryPool.staleAfterDays` (30),
105
+ `planning.memoryPool.growthDelta` (25) entries written since that count, or
106
+ `MEMORY.md` exceeding `planning.memoryPool.indexByteCeiling` (24576) bytes.
107
+ Pool size alone never triggers it — a pass that keeps every entry still quiets
108
+ the first two arms. A stamp with no `entryCount` leaves growth unmeasured, and
109
+ only the age and index arms can speak until the next pass writes one; a stamp
110
+ dated in the future reads as no stamp at all.
109
111
 
110
112
  Write it **only** after Gate #2 — the stamp asserts an operator reviewed the
111
113
  pass, so writing it early makes it a lie.
112
114
 
113
- Close with counts: entries read, corrected, merged, pruned, and the new total.
114
- Then the forecast the operator would otherwise derive by hand: when the
115
- advisory next fires, and which arm reaches it first.
115
+ Close with counts: entries read, corrected, merged, pruned, the new total, and
116
+ **the rewritten `MEMORY.md`'s size in bytes beside that count** — the index is
117
+ truncated at the byte ceiling, so a pass that pruned entries but left the
118
+ index over the cap has not fixed the loss, and the number is the only way the
119
+ operator can see that. Then the forecast the operator would otherwise derive
120
+ by hand: when the advisory next fires, and which arm reaches it first.
116
121
 
117
122
  ## Constraints
118
123
 
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,33 @@ All notable changes to this project will be documented in this file.
15
15
  -->
16
16
  <!-- markdownlint-disable-file MD004 MD012 MD037 -->
17
17
 
18
+ ## [2.55.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.54.0...mandrel-v2.55.0) (2026-09-11)
19
+
20
+
21
+ ### Added
22
+
23
+ * a backgrounded capture ends the worker's turn on an unpushed branch, and close's base-sync silently spends the credit that capture was earning ([#5267](https://github.com/dsj1984/mandrel/issues/5267)) ([#5273](https://github.com/dsj1984/mandrel/issues/5273)) ([8d2bed1](https://github.com/dsj1984/mandrel/commit/8d2bed1e2132bc8b8901b58a33231b2bda5fd61c))
24
+ * epic rollup: every child state change is an edge, the parent is resolved in one call, and provider reads carry one declared shape ([#5280](https://github.com/dsj1984/mandrel/issues/5280)) ([#5294](https://github.com/dsj1984/mandrel/issues/5294)) ([c2c9b96](https://github.com/dsj1984/mandrel/commit/c2c9b9654497404c20105bc3b7edcdbd45fb307e))
25
+ * full-suite credit: a deposit path exists, the lock is liveness-aware, and stamps and evidence are keyed on content ([#5278](https://github.com/dsj1984/mandrel/issues/5278)) ([#5296](https://github.com/dsj1984/mandrel/issues/5296)) ([96c3240](https://github.com/dsj1984/mandrel/commit/96c32405025768de96d2e12680a54ed326787345))
26
+ * memory advisory: measure the index against the harness byte cap, reject future-dated stamps, tighten the skill-id schema, and correct the rename cutover text ([#5285](https://github.com/dsj1984/mandrel/issues/5285)) ([#5289](https://github.com/dsj1984/mandrel/issues/5289)) ([22389a1](https://github.com/dsj1984/mandrel/commit/22389a106cb8e3ce42b0fde288ee473b88e49b99))
27
+ * tests: a portability lint catches the Windows-only shapes before they land, and a vanished temp root is recorded instead of healed ([#5284](https://github.com/dsj1984/mandrel/issues/5284)) ([#5295](https://github.com/dsj1984/mandrel/issues/5295)) ([3358eef](https://github.com/dsj1984/mandrel/commit/3358eefeb0e5f36446b84a4f995e4b90641f64dc))
28
+
29
+
30
+ ### Fixed
31
+
32
+ * a test fixture that loses its temp root mid-run fails with an unreadable ENOENT, and every later makeTempDir in that process fails with it ([#5274](https://github.com/dsj1984/mandrel/issues/5274)) ([#5275](https://github.com/dsj1984/mandrel/issues/5275)) ([5ac8699](https://github.com/dsj1984/mandrel/commit/5ac86995b92537b33bb209492cf9c3d9a064f792))
33
+ * audit sweep: empty groups parse, the ledger commit is re-runnable from the remote base, SCA attribution is per advisory, and label-less audit Stories cannot be dispatched ([#5281](https://github.com/dsj1984/mandrel/issues/5281)) ([#5290](https://github.com/dsj1984/mandrel/issues/5290)) ([e229b0f](https://github.com/dsj1984/mandrel/commit/e229b0fb07e37e251b713a35646105cc54e791c9))
34
+ * baselines: the merge driver is installed wherever base-sync runs, the refresh ack is scoped to the tagged commit's diff, and the land-time write-back cannot launder a regression ([#5277](https://github.com/dsj1984/mandrel/issues/5277)) ([#5292](https://github.com/dsj1984/mandrel/issues/5292)) ([9354e93](https://github.com/dsj1984/mandrel/commit/9354e93f7e74f263ffaf64c6e72056784a9b74d4))
35
+ * close result: the merged flag is derived from what the run observed, failed closes report only registered gates, and advisory blocks carry their own remedy ([#5279](https://github.com/dsj1984/mandrel/issues/5279)) ([#5293](https://github.com/dsj1984/mandrel/issues/5293)) ([93414ac](https://github.com/dsj1984/mandrel/commit/93414ac756bd3f3300f869d46c28f680883edce4))
36
+ * close's result note claims a merge it never confirmed, and a timed-out advisory scan blocks with the same class and remedies as a real violation ([#5266](https://github.com/dsj1984/mandrel/issues/5266)) ([#5271](https://github.com/dsj1984/mandrel/issues/5271)) ([585a2c3](https://github.com/dsj1984/mandrel/commit/585a2c340d96b972028d2d2f469e2f6b287f7ac2))
37
+ * **deps:** override smol-toml past the GHSA-7w5x-hrqm-74c2 DoS advisory (refs [#5264](https://github.com/dsj1984/mandrel/issues/5264)) ([#5268](https://github.com/dsj1984/mandrel/issues/5268)) ([8db060c](https://github.com/dsj1984/mandrel/commit/8db060ca23a5242c1d13e4a049647be86e853504))
38
+ * git-cleanup: weak-signal remote deletes need an explicit opt-in under --yes, and the bulk PR index short-circuits its per-branch fallback ([#5283](https://github.com/dsj1984/mandrel/issues/5283)) ([#5288](https://github.com/dsj1984/mandrel/issues/5288)) ([8346932](https://github.com/dsj1984/mandrel/commit/83469327b63488ea41a2bd9fb53a37bc2f57d1c0))
39
+ * plan-persist reports two things the tree contradicts: a refactors-existing path deleted weeks ago, and a wave table promising parallelism the dispatch guard refuses ([#5265](https://github.com/dsj1984/mandrel/issues/5265)) ([#5270](https://github.com/dsj1984/mandrel/issues/5270)) ([1b5d1ff](https://github.com/dsj1984/mandrel/commit/1b5d1ffdd6e8821c833d320e93381f7804cb61c8))
40
+ * scoped lint: a degraded surface no longer discards the other surface's findings ([#5282](https://github.com/dsj1984/mandrel/issues/5282)) ([#5287](https://github.com/dsj1984/mandrel/issues/5287)) ([75005ee](https://github.com/dsj1984/mandrel/commit/75005ee925d463d46f5cb24cb3788647e08a37b2))
41
+ * **tests:** a RegExp built from a raw path can never match on Windows (refs [#5274](https://github.com/dsj1984/mandrel/issues/5274)) ([#5276](https://github.com/dsj1984/mandrel/issues/5276)) ([8d5efb2](https://github.com/dsj1984/mandrel/commit/8d5efb256d936f7dd955eff55c68d70d8abbfb9c))
42
+ * **tests:** close the two Windows-only breaks Epic [#5286](https://github.com/dsj1984/mandrel/issues/5286) left on main, and the class behind them ([#5297](https://github.com/dsj1984/mandrel/issues/5297)) ([ec419c7](https://github.com/dsj1984/mandrel/commit/ec419c7b7462f4e6e0adf76805c22735ac2e9673))
43
+ * **tests:** prove the workflows-doc drift gate on a fixture root, not the live checkout ([#5291](https://github.com/dsj1984/mandrel/issues/5291)) ([e44aa99](https://github.com/dsj1984/mandrel/commit/e44aa991027859c5d94d9dc875e80d41e1572c00))
44
+
18
45
  ## [2.54.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.53.0...mandrel-v2.54.0) (2026-09-09)
19
46
 
20
47
 
@@ -28,7 +28,8 @@ import { fileURLToPath } from 'node:url';
28
28
  import {
29
29
  BASELINE_MERGE_DRIVER_CONFIG_KEY,
30
30
  BASELINE_MERGE_DRIVER_REMEDY,
31
- declaresBaselineMergeDriver,
31
+ parseBaselineMergeDriverCommand,
32
+ probeBaselineMergeDriver,
32
33
  } from '../../.agents/scripts/lib/bootstrap/baseline-merge-driver.js';
33
34
  import {
34
35
  REQUIRED_NODE_CEILING_MAJOR,
@@ -59,8 +60,8 @@ import {
59
60
  * @param {string[]} args
60
61
  * @returns {{ status: number|null, stdout: string, stderr: string, error?: NodeJS.ErrnoException }}
61
62
  */
62
- function spawn(cmd, args) {
63
- const r = spawnSync(cmd, args, { encoding: 'utf8' });
63
+ function spawn(cmd, args, opts = {}) {
64
+ const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
64
65
  return {
65
66
  status: r.status,
66
67
  stdout: typeof r.stdout === 'string' ? r.stdout : '',
@@ -1101,34 +1102,76 @@ function runVersionCurrent({ cachePath, installedVersion, fsImpl = fs } = {}) {
1101
1102
  */
1102
1103
  export function runMergeDriver({ cwd, fsImpl = fs, runner = spawn } = {}) {
1103
1104
  const projectRoot = (cwd ?? (() => process.cwd()))();
1104
- const attributesPath = path.join(projectRoot, '.gitattributes');
1105
-
1106
- let attributes = '';
1107
- try {
1108
- attributes = fsImpl.readFileSync(attributesPath, 'utf8');
1109
- } catch {
1110
- attributes = '';
1111
- }
1112
- if (!declaresBaselineMergeDriver(attributes)) {
1105
+ // Both git calls run from `projectRoot`: the config key is per-clone, and
1106
+ // reading it from wherever `mandrel doctor` was typed answers about a
1107
+ // different repository (or none).
1108
+ const { declared, command } = probeBaselineMergeDriver({
1109
+ projectRoot,
1110
+ fsImpl,
1111
+ runGit: (args) => runner('git', args, { cwd: projectRoot }),
1112
+ });
1113
+ if (!declared) {
1113
1114
  return {
1114
1115
  ok: true,
1115
1116
  detail:
1116
1117
  'skipped — .gitattributes does not route baselines/*.json through the mandrel merge driver',
1117
1118
  };
1118
1119
  }
1119
-
1120
- const configured = runner('git', [
1121
- 'config',
1122
- '--get',
1123
- BASELINE_MERGE_DRIVER_CONFIG_KEY,
1124
- ]);
1125
- if (configured.status === 0 && configured.stdout.trim() !== '') {
1126
- return { ok: true, detail: configured.stdout.trim() };
1120
+ if (command === '') {
1121
+ return {
1122
+ ok: false,
1123
+ detail: `${BASELINE_MERGE_DRIVER_CONFIG_KEY} is unset — baselines/*.json will fall back to git's text merge, which conflicts on the generatedAt stamp and can splice rows neither branch scored`,
1124
+ remedy: BASELINE_MERGE_DRIVER_REMEDY,
1125
+ };
1127
1126
  }
1127
+ return probeConfiguredDriver(command, runner, projectRoot);
1128
+ }
1128
1129
 
1130
+ /**
1131
+ * Does the configured driver command actually RUN? (Story #5277)
1132
+ *
1133
+ * A set-but-broken key is the failure mode a presence check cannot see, and it
1134
+ * is the common one: the command names an absolute node binary, and an nvm or
1135
+ * volta version bump moves that binary out from under it months after the
1136
+ * driver was installed. Git's behaviour then is the same silence a missing key
1137
+ * produces — the driver exits non-zero, git leaves the file conflicted, and the
1138
+ * operator reads it as "baselines conflict again" rather than as a broken
1139
+ * registration. So the check executes what git would execute.
1140
+ *
1141
+ * `--help` is the probe because the driver's real invocation mutates `%A` in
1142
+ * place; the shipped CLI answers `--help` with exit 0 and touches nothing.
1143
+ *
1144
+ * The command is tokenised, never handed to a shell
1145
+ * ({@link parseBaselineMergeDriverCommand}) — it is a config value, and running
1146
+ * it through `shell: true` would make any write to that key arbitrary command
1147
+ * execution (security-baseline § Output & Rendering).
1148
+ *
1149
+ * The probe runs from `projectRoot`, because the command's script path is
1150
+ * relative to the worktree root — which is where git invokes a merge driver
1151
+ * from, and is not necessarily where `mandrel doctor` was typed.
1152
+ *
1153
+ * @param {string} command
1154
+ * @param {typeof spawn} runner
1155
+ * @param {string} projectRoot
1156
+ * @returns {{ ok: boolean, detail: string, remedy?: string }}
1157
+ */
1158
+ function probeConfiguredDriver(command, runner, projectRoot) {
1159
+ const parsed = parseBaselineMergeDriverCommand(command);
1160
+ if (!parsed) {
1161
+ return {
1162
+ ok: false,
1163
+ detail: `${BASELINE_MERGE_DRIVER_CONFIG_KEY} is set to ${JSON.stringify(command)}, which has no runnable command in it`,
1164
+ remedy: BASELINE_MERGE_DRIVER_REMEDY,
1165
+ };
1166
+ }
1167
+ const probe = runner(parsed.file, [...parsed.args, '--help'], {
1168
+ cwd: projectRoot,
1169
+ });
1170
+ if (probe.status === 0) return { ok: true, detail: command };
1171
+ const why = probe.error?.message ?? `exit ${probe.status}`;
1129
1172
  return {
1130
1173
  ok: false,
1131
- detail: `${BASELINE_MERGE_DRIVER_CONFIG_KEY} is unset — baselines/*.json will fall back to git's text merge, which conflicts on the generatedAt stamp and can splice rows neither branch scored`,
1174
+ detail: `${BASELINE_MERGE_DRIVER_CONFIG_KEY} is set to ${JSON.stringify(command)} but running it failed (${why}) — git would fall back to text-merging baselines/*.json exactly as if the key were unset`,
1132
1175
  remedy: BASELINE_MERGE_DRIVER_REMEDY,
1133
1176
  };
1134
1177
  }
package/lib/cli/sync.js CHANGED
@@ -38,14 +38,24 @@
38
38
  * {@link readVersionMarker} for how callers consume it.
39
39
  *
40
40
  * Security (Tech Spec #3459 "Postinstall safety"):
41
- * - Does nothing beyond a local file copy: no network, no shell, no writes
42
- * outside `./.agents/`.
41
+ * - No network, and no shell: every child process is spawned with argv
42
+ * tokens and `shell: false`.
43
43
  * - Logs only paths and counts, never file contents or environment values.
44
+ * - One write outside `./.agents/` (Story #5277): the per-clone
45
+ * `merge.mandrel-baseline.driver` git config, and only when the project's
46
+ * own tracked `.gitattributes` already declares the attribute that needs
47
+ * it. That half of the registration cannot ship with the repository — git
48
+ * will not execute a command chosen by whoever wrote it — so every fresh
49
+ * clone silently text-merges generated baselines until something installs
50
+ * it locally, and `sync` is the one command every consumer runs. It never
51
+ * creates or edits `.gitattributes`; a project that has not opted into the
52
+ * quality surface is left untouched.
44
53
  *
45
54
  * Injectable seams (used by lib/cli/__tests__/sync.test.js):
46
55
  * - `resolvePackageRoot` — replaces real `mandrel` resolution
47
56
  * - `fs` — replaces the node:fs surface used here
48
57
  * - `cwd` — replaces process.cwd()
58
+ * - `ensureMergeDriver` — replaces the git-config merge-driver install
49
59
  * - `write` — replaces process.stdout.write
50
60
  * - `writeErr` — replaces process.stderr.write
51
61
  * - `exit` — replaces process.exit
@@ -55,6 +65,7 @@ import nodeFs from 'node:fs';
55
65
  import { createRequire } from 'node:module';
56
66
  import path from 'node:path';
57
67
 
68
+ import { ensureBaselineMergeDriver } from '../../.agents/scripts/lib/bootstrap/baseline-merge-driver.js';
58
69
  import { LEDGER_RELATIVE_PATH } from '../../.agents/scripts/lib/bootstrap/install-ledger.js';
59
70
 
60
71
  export const PACKAGE_NAME = 'mandrel';
@@ -337,6 +348,7 @@ function listDestFiles(dir, fsImpl, prefix = '') {
337
348
  * write?: (s: string) => void,
338
349
  * writeErr?: (s: string) => void,
339
350
  * exit?: (code: number) => void,
351
+ * ensureMergeDriver?: typeof ensureBaselineMergeDriver,
340
352
  * }} [opts]
341
353
  * @returns {{ copied: number, planned: number, pruned: number, dryRun: boolean }}
342
354
  * Summary (also returned in dry-run / error paths for testability).
@@ -344,6 +356,7 @@ function listDestFiles(dir, fsImpl, prefix = '') {
344
356
  export function runSync({
345
357
  argv = [],
346
358
  resolvePackageRoot = defaultResolvePackageRoot,
359
+ ensureMergeDriver = ensureBaselineMergeDriver,
347
360
  fs = nodeFs,
348
361
  cwd = () => process.cwd(),
349
362
  write = (s) => process.stdout.write(s),
@@ -435,6 +448,18 @@ export function runSync({
435
448
  `${packageVersion}\n`,
436
449
  );
437
450
 
451
+ // Baseline merge driver (Story #5277) — config half only; see the module
452
+ // preamble's security note. Never allowed to fail the sync: a consumer
453
+ // without git, or with a read-only config, still gets their `.agents/` tree.
454
+ const driver = ensureMergeDriver({
455
+ projectRoot,
456
+ configOnly: true,
457
+ fsImpl: fs,
458
+ });
459
+ if (driver?.action === 'updated') {
460
+ write('✅ Registered the baselines/*.json merge driver for this clone\n');
461
+ }
462
+
438
463
  if (staleFiles.length > 0) {
439
464
  write(
440
465
  `✅ Installed ${payloadFiles.length} file(s) into ./.agents/ (pruned ${staleFiles.length} stale file(s))\n`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.54.0",
3
+ "version": "2.55.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",
@@ -32,6 +32,7 @@
32
32
  "baseline:agents-loc": "node baselines/agents-loc-baseline.mjs",
33
33
  "baselines:scope": "node .agents/scripts/check-baseline-scope.js",
34
34
  "baselines:prune": "node .agents/scripts/prune-baseline-orphans.js",
35
+ "baselines:merge-driver": "node .agents/scripts/merge-baseline.js --install",
35
36
  "lint:md": "markdownlint-cli2 \".agents/**/*.md\" \"*.md\" \"!node_modules/**\" \"!.worktrees/**\"",
36
37
  "lint": "node .agents/scripts/run-lint.js && node .agents/scripts/check-generated-validator.js --check && node .agents/scripts/check-pinned-override-notes.js && npm run docs:check",
37
38
  "docs:gen": "node .agents/scripts/generate-config-docs.js && node .agents/scripts/generate-workflows-doc.js && node .agents/scripts/generate-lens-checklists.js",
@@ -63,7 +64,7 @@
63
64
  "quality:watch": "node .agents/scripts/quality-watch.js",
64
65
  "sync:commands": "node bin/mandrel.js sync-commands",
65
66
  "sync:agents": "node .agents/scripts/sync-claude-agents.js",
66
- "prepare": "husky && npm run sync:commands && npm run sync:agents",
67
+ "prepare": "husky && npm run sync:commands && npm run sync:agents && npm run baselines:merge-driver",
67
68
  "postinstall": "node bin/postinstall.js mandrel sync"
68
69
  },
69
70
  "repository": {
@@ -130,10 +131,12 @@
130
131
  "//": {
131
132
  "peerDependencies.@cucumber/gherkin": "OPTIONAL peer, mirroring the `typescript` precedent, and deliberately NOT a runtime dependency. `check-gherkin-corpus.js` is the only consumer and it is opt-in behind `qa.gherkinLint`, so a consumer with no BDD tier must gain nothing from an upgrade. The devDependency alongside it is what lets this repository's own suite drive the real parser. The gate resolves it through a require path anchored at the consumer project — `.agents/` reaches a consumer by plain file copy, so a bare specifier would resolve against the consumer's module chain, which under a non-hoisting linker need not hold it.",
132
133
  "overrides.js-yaml": "COUPLED to devDependencies.markdownlint-cli2 — do not bump either alone, and do NOT drop this override. It is load-bearing: markdownlint-cli2 0.22.x pulls js-yaml 4.1.1, which carries GHSA-52cp-r559-cp3m (high) and GHSA-h67p-54hq-rp68 (moderate); removing the override was measured to reintroduce both (1 high + 1 moderate), while with it in place `npm audit` is clean. An npm override also wins over a transitive package's own pin, so this tree-wide ^4.3.2 is imposed on every js-yaml consumer regardless of what they declare — and markdownlint-cli2 0.23.x declares an exact js-yaml 5.2.1. A dry-run bump confirmed the trap: markdownlint-cli2 0.23.1 resolves against js-yaml 4.3.0, two majors off what it declares, silently. The only safe move is to raise this override and bump markdownlint-cli2 in ONE reviewed commit (first confirming cosmiconfig, under @commitlint/cli, tolerates the same major). renovate.json excludes markdownlint-cli2 from devDependency auto-merge so that pair cannot drift apart unattended. The floor has since been raised twice for advisories against the pinned range itself (most recently to ^4.3.2 for GHSA-2883-xcg3-v3hh, whose fix is 4.3.2) — raising it is safe and does NOT touch the coupling above; check-pinned-override-notes.js now fails the build if this sentence and the pin disagree.",
133
- "dependencies.js-yaml": "States the SAME range as overrides.js-yaml above. The two are deliberate duplicates — npm has no way to reference the direct range from the overrides block — so they MUST move in lockstep; changing one without the other silently splits the direct and transitive resolutions."
134
+ "dependencies.js-yaml": "States the SAME range as overrides.js-yaml above. The two are deliberate duplicates — npm has no way to reference the direct range from the overrides block — so they MUST move in lockstep; changing one without the other silently splits the direct and transitive resolutions.",
135
+ "overrides.smol-toml": "Load-bearing: markdownlint-cli2 0.22.1 declares an EXACT smol-toml 1.6.1, which carries GHSA-7w5x-hrqm-74c2 (high — denial of service via malformed TOML). The advisory’s vulnerable range is everything <= 1.7.0, so no lockfile-only resolution reaches a patched version and this tree-wide ^1.7.1 pin is what clears it. npm’s own `audit fix --force` instead proposes markdownlint-cli2 0.21.0 — a DOWNGRADE presented as a breaking change; do not take it. An npm override wins over a transitive package’s own exact pin, so markdownlint-cli2 runs against a minor it never declared: `npm run lint` was confirmed green under the forced version and is what must be re-run if this pin moves. knip declares ^1.6.1 and is satisfied natively. smol-toml is NOT a direct dependency, so unlike overrides.js-yaml there is no lockstep duplicate range to keep in sync."
134
136
  },
135
137
  "overrides": {
136
138
  "js-yaml": "^4.3.2",
137
- "markdown-it": "^14.2.0"
139
+ "markdown-it": "^14.2.0",
140
+ "smol-toml": "^1.7.1"
138
141
  }
139
142
  }