mandrel 2.58.0 → 2.60.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 (124) hide show
  1. package/.agents/README.md +17 -12
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +12 -13
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +9 -5
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +5 -7
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/runtime-deps.json +7 -2
  13. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  14. package/.agents/schemas/agentrc.schema.json +6 -11
  15. package/.agents/schemas/crap-baseline.schema.json +1 -1
  16. package/.agents/schemas/crap-report.schema.json +1 -1
  17. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  18. package/.agents/scripts/README.md +11 -1
  19. package/.agents/scripts/acceptance-eval.js +25 -27
  20. package/.agents/scripts/ceremony-derive.js +15 -10
  21. package/.agents/scripts/check-context-budget.js +148 -228
  22. package/.agents/scripts/check-schema-references.js +5 -3
  23. package/.agents/scripts/check-workflow-citations.js +33 -147
  24. package/.agents/scripts/coverage-capture.js +7 -4
  25. package/.agents/scripts/deliver-light.js +41 -100
  26. package/.agents/scripts/deliver-run.js +631 -0
  27. package/.agents/scripts/file-ci-gap.js +59 -11
  28. package/.agents/scripts/install-matrix-assert.js +48 -3
  29. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
  30. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  31. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
  32. package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
  33. package/.agents/scripts/lib/changed-files.js +30 -0
  34. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  35. package/.agents/scripts/lib/config/explain.js +1 -3
  36. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  37. package/.agents/scripts/lib/config-resolver.js +1 -0
  38. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  39. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  40. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  41. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  42. package/.agents/scripts/lib/crap-engine.js +2 -2
  43. package/.agents/scripts/lib/crap-utils.js +21 -5
  44. package/.agents/scripts/lib/doc-tiers.js +4 -2
  45. package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
  46. package/.agents/scripts/lib/escomplex-kernel.js +298 -0
  47. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  48. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  49. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  50. package/.agents/scripts/lib/gh-exec.js +160 -0
  51. package/.agents/scripts/lib/maintainability-engine.js +3 -3
  52. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  53. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  54. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  55. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  56. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  57. package/.agents/scripts/lib/orchestration/plan-context.js +44 -50
  58. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +104 -119
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +41 -25
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
  64. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  65. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  66. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  67. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  68. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  69. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  70. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  71. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  72. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  73. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  74. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
  75. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
  76. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  77. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  78. package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
  79. package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
  80. package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
  81. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
  82. package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
  83. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  84. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  85. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  86. package/.agents/scripts/lib/templates/decomposer-prompts.js +28 -33
  87. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  88. package/.agents/scripts/merge-baseline.js +4 -5
  89. package/.agents/scripts/plan-context.js +117 -28
  90. package/.agents/scripts/plan-persist.js +79 -39
  91. package/.agents/scripts/plan-run-epilogue.js +11 -8
  92. package/.agents/scripts/pr-watch-with-update.js +9 -2
  93. package/.agents/scripts/run-verify.js +13 -6
  94. package/.agents/scripts/single-story-init.js +7 -57
  95. package/.agents/scripts/stories-wave-tick.js +160 -26
  96. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  97. package/.agents/skills/skills.index.json +2 -12
  98. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  99. package/.agents/workflows/audit-to-stories.md +14 -11
  100. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  101. package/.agents/workflows/helpers/code-review.md +4 -2
  102. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  103. package/.agents/workflows/helpers/deliver-light.md +92 -101
  104. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  105. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  106. package/.agents/workflows/helpers/deliver-story.md +17 -18
  107. package/.agents/workflows/helpers/plan-reference.md +82 -60
  108. package/.agents/workflows/mandrel-deliver.md +47 -31
  109. package/.agents/workflows/mandrel-plan.md +32 -30
  110. package/.agents/workflows/mandrel-update.md +36 -21
  111. package/README.md +3 -3
  112. package/docs/CHANGELOG.md +43 -0
  113. package/lib/cli/registry.js +45 -25
  114. package/lib/cli/update.js +376 -17
  115. package/lib/migrations/index.js +2 -0
  116. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  117. package/package.json +8 -2
  118. package/.agents/schemas/model-attribution.schema.json +0 -53
  119. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  120. package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
  121. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  122. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
  123. package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
  124. package/.agents/skills/core/scope-triage/SKILL.md +0 -48
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Story authoring guidance — the two prose constants the story-author prompt
3
- * and the `core/scope-triage` skill both cite, stated once so the surfaces
4
- * cannot drift.
3
+ * cites, stated once so no second copy can drift.
5
4
  *
6
5
  * Story #5312 deleted the numeric sizing model that used to live beside
7
6
  * them: `DEFAULT_MODEL_CAPACITY` with its soft / hard session-mass ceilings,
@@ -18,12 +17,16 @@
18
17
  /**
19
18
  * `DELIVERABLE_GRANULARITY_GUIDANCE` is the **single source of truth** for the
20
19
  * deliverable-granularity definition of a Story (Story #3777). It is stated
21
- * ONCE here and consumed by BOTH the story-author prompt template and the
22
- * authoring SKILL.
20
+ * ONCE here and consumed by the story-author prompt template.
21
+ *
22
+ * Story #5332 re-anchored the definition off "a single reviewer-sized PR":
23
+ * that anchor read as a size ceiling and fragmented cohesive sweeps, so the
24
+ * only stated sizing test is now cohesion — one coherent change with one
25
+ * reason to exist.
23
26
  */
24
27
  export const DELIVERABLE_GRANULARITY_GUIDANCE = Object.freeze({
25
28
  definition:
26
- 'A Story is a **capability slice a frontier model delivers and self-verifies in one pass** — a shippable slice a reviewer would accept as a single PR, a capability or user-visible surface, **not a single module or file**. Fold module-level slices into the capability they belong to rather than emitting one Story per module.',
29
+ 'A Story is a **capability slice a frontier model delivers and self-verifies in one pass** — one coherent change with one reason to exist, a capability or user-visible surface, **not a single module or file**. Fold module-level slices into the capability they belong to rather than emitting one Story per module. A remediation sweep over one subsystem is one Story; its stages belong in `## Slicing`, not in sibling tickets.',
27
30
  singleConsumerRule:
28
31
  '**Single-consumer merge rule.** A Story whose only consumer is one sibling Story should be **merged into that sibling** rather than emitted separately — a single-consumer downstream slice is not its own unit of work.',
29
32
  envelopeFloor:
@@ -3,7 +3,6 @@ import { normalizeOwnedProvenance } from '../findings/provenance-field.js';
3
3
  import { detectCycle } from '../Graph.js';
4
4
  import { gitSpawn } from '../git-utils.js';
5
5
 
6
- import { Logger } from '../Logger.js';
7
6
  import { validateStoryFileAssumptions } from './file-assumptions.js';
8
7
  import { isExternalDependencyRef } from './plan-persist/external-deps.js';
9
8
  import {
@@ -41,38 +40,6 @@ function collectPathsFromText(text, paths) {
41
40
  }
42
41
  }
43
42
 
44
- /**
45
- * Resolve every acceptance line a Story declares, across both authoring
46
- * shapes (Story #4541).
47
- *
48
- * The canonical shape is a **serialized string body** with the criteria at
49
- * the ticket's **top level** — the machine contract persist syncs into the
50
- * body. `validateAcceptanceSubjectPrefix` used to read `body.acceptance` on
51
- * an object body only, so on every real plan it scanned nothing and the gate
52
- * silently passed. Union both sources (deduplicated) so the gate fires on
53
- * whichever surface the author used.
54
- *
55
- * @param {object} story
56
- * @returns {string[]}
57
- */
58
- function resolveAcceptanceLines(story) {
59
- const lines = new Set();
60
- if (Array.isArray(story?.acceptance)) {
61
- for (const item of story.acceptance) lines.add(String(item ?? ''));
62
- }
63
- const body = story?.body;
64
- let bodyAcceptance = null;
65
- if (typeof body === 'string' && body.trim().length > 0) {
66
- bodyAcceptance = parseStoryBodyOrThrow(story).acceptance;
67
- } else if (body !== null && typeof body === 'object') {
68
- bodyAcceptance = body.acceptance;
69
- }
70
- if (Array.isArray(bodyAcceptance)) {
71
- for (const item of bodyAcceptance) lines.add(String(item ?? ''));
72
- }
73
- return [...lines];
74
- }
75
-
76
43
  function collectTaskPathReferences(task) {
77
44
  const paths = new Set();
78
45
  const body = task.body;
@@ -283,117 +250,6 @@ export function validateAcFreshness({
283
250
  return misses.map((m) => renderMissLine(m, baseBranchRef));
284
251
  }
285
252
 
286
- /**
287
- * Allowed leading Conventional-Commits types. Mirrors the `changelog-sections`
288
- * keys in `release-please-config.json` and the `type-enum` list in
289
- * `commitlint.config.js`. When a planner LLM prescribes a commit subject in a
290
- * Task acceptance item via the "Commit subject begins with '<prefix>:'" form,
291
- * the captured prefix must reduce to one of these types (optionally followed
292
- * by a `(scope)` qualifier) — anything else fails commitlint locally and
293
- * release-please's changelog parser on `main`, so the decompose is rejected
294
- * before the Story branch is ever cut.
295
- *
296
- * Epic #2501 introduced this guard after the legacy `baseline-refresh`
297
- * leading-token prescription created a wave of commit-msg hook failures
298
- * across story-deliver sub-agents. See
299
- * `.agents/skills/core/gates-and-baselines/SKILL.md` for the canonical refresh
300
- * shape (Conventional-Commits subject + `baseline-refresh: true` body
301
- * trailer).
302
- */
303
- const ALLOWED_COMMIT_TYPES = new Set([
304
- 'feat',
305
- 'fix',
306
- 'chore',
307
- 'refactor',
308
- 'perf',
309
- 'docs',
310
- 'style',
311
- 'test',
312
- 'build',
313
- 'ci',
314
- 'revert',
315
- ]);
316
-
317
- /**
318
- * Regex matching the canonical "Commit subject begins with '<prefix>:'"
319
- * prescription shape the planner emits in `body.acceptance[]` entries.
320
- * The leading quote is captured loosely (single, double, or backtick) so the
321
- * three quoting styles the decomposer LLM has historically emitted all
322
- * match. The captured group is the prefix token *without* the trailing
323
- * colon — callers normalize by stripping an optional `(scope)` qualifier
324
- * before comparing against the allowed-types set.
325
- */
326
- const SUBJECT_PREFIX_RE = /Commit subject begins with ['"`]([^'"`]+):['"`]/g;
327
-
328
- /**
329
- * Scan every Story's `body.acceptance[]` for "Commit subject begins with
330
- * '<prefix>:'" prescriptions and reject the decompose when any captured
331
- * prefix is not a valid Conventional-Commits type.
332
- *
333
- * A captured prefix of the form `chore(baselines)` is accepted — the
334
- * leading `chore` is in the allowed-types set, and the `(scope)` qualifier
335
- * is the standard Conventional-Commits scope shape. A captured prefix of
336
- * the form `baseline-refresh` is rejected because no Conventional-Commits
337
- * type starts with that token.
338
- *
339
- * Only acceptance criteria are scanned; `body.goal` / `body.verify` /
340
- * `body.changes` are not commit-subject prescriptions by convention and
341
- * scanning them would surface false positives from prose that happens to
342
- * quote a forbidden prefix while explaining why it's forbidden.
343
- *
344
- * Both authoring shapes are covered (Story #4541): the canonical top-level
345
- * `acceptance[]` on a serialized string body, and the pre-serialize
346
- * `body.acceptance[]` object shape. Scanning only the latter made the gate
347
- * inert on every real plan.
348
- *
349
- * @param {object} opts
350
- * @param {object[]} opts.tickets - Validated ticket hierarchy.
351
- * @throws {ValidationError} when one or more Story acceptance items
352
- * prescribe a forbidden subject prefix. The error carries
353
- * `code: 'forbidden-subject-prefix'` and a `violations[]` payload
354
- * listing each `{ slug, prefix, line }` so the decompose loop can
355
- * surface the exact offending text to the operator.
356
- */
357
- export function validateAcceptanceSubjectPrefix({ tickets }) {
358
- const violations = [];
359
- const stories = (tickets ?? []).filter((t) => t.type === 'story');
360
- for (const story of stories) {
361
- for (const line of resolveAcceptanceLines(story)) {
362
- // Reset the global regex between iterations.
363
- SUBJECT_PREFIX_RE.lastIndex = 0;
364
- let match = SUBJECT_PREFIX_RE.exec(line);
365
- while (match !== null) {
366
- const rawPrefix = match[1];
367
- // Strip an optional `(scope)` qualifier — `chore(baselines)` reduces
368
- // to `chore` for the allowed-types check.
369
- const type = rawPrefix.replace(/\(.*\)$/, '').trim();
370
- if (!ALLOWED_COMMIT_TYPES.has(type)) {
371
- violations.push({
372
- slug: story.slug ?? '<unknown>',
373
- prefix: rawPrefix,
374
- line,
375
- });
376
- }
377
- match = SUBJECT_PREFIX_RE.exec(line);
378
- }
379
- }
380
- }
381
- if (violations.length === 0) return;
382
- const allowed = [...ALLOWED_COMMIT_TYPES].join('|');
383
- const lines = violations
384
- .map(
385
- (v) =>
386
- ` - "${v.slug}" → forbidden subject prefix "${v.prefix}:" in acceptance item: ${v.line}`,
387
- )
388
- .join('\n');
389
- const err = new ValidationError(
390
- `Cross-Validation Failed: ${violations.length} Story acceptance item(s) prescribe a non-Conventional-Commits subject prefix:\n${lines}\n\nAllowed leading types: ${allowed}. Use a Conventional-Commits subject (e.g. "chore(baselines): refresh ...") and a body trailer (e.g. "baseline-refresh: true") for machine-readable markers. See Epic #2501.`,
391
- { violations },
392
- );
393
- err.code = 'forbidden-subject-prefix';
394
- throw err;
395
- }
396
-
397
253
  /**
398
254
  * Render one missing-path warning with a remediation hint pointing at the
399
255
  * Story's `changes[]`. For `tests/**` paths we suggest the explicit
@@ -482,44 +338,55 @@ function assertAllTicketsAreStories({ tickets, stories }) {
482
338
  }
483
339
 
484
340
  /**
485
- * Return true when a Story object carries inline acceptance + verify
486
- * arrays — the inline-contract shape (Epic #3078) where the Story is itself the
487
- * implementation unit and acceptance / verify live on the Story body
488
- * rather than in child Task tickets.
341
+ * Return true when a Story carries a non-empty top-level `acceptance[]` —
342
+ * the inline-contract shape (Epic #3078) where the Story is itself the
343
+ * implementation unit and its criteria live on the Story rather than in
344
+ * child Task tickets.
489
345
  *
490
- * Both arrays must be present, be actual arrays, and contain at least
491
- * one entry. Either alone is insufficient — a Story with only
492
- * `acceptance[]` (no `verify[]`) cannot be implemented without a
493
- * verification handle, and a Story with only `verify[]` (no
494
- * `acceptance[]`) carries no observable criterion. Requiring both is the
495
- * inline-contract invariant every Story must satisfy.
346
+ * Story #5342 narrowed the invariant to `acceptance[]` alone. A Story with
347
+ * no observable criterion is genuinely unimplementable and nothing
348
+ * downstream can recover it; an empty `verify[]` only means the deliverer
349
+ * picks the commands, which the close gate chain runs regardless — so that
350
+ * half is a warning ({@link collectMissingVerifyWarnings}), not a refusal.
496
351
  */
497
- function hasInlineAcceptanceAndVerify(story) {
352
+ function hasInlineAcceptance(story) {
498
353
  if (story === null || typeof story !== 'object') return false;
499
- const { acceptance, verify } = story;
500
- return (
501
- Array.isArray(acceptance) &&
502
- acceptance.length > 0 &&
503
- Array.isArray(verify) &&
504
- verify.length > 0
505
- );
354
+ const { acceptance } = story;
355
+ return Array.isArray(acceptance) && acceptance.length > 0;
506
356
  }
507
357
 
508
358
  function assertEveryStoryHasInlineContract({ stories }) {
509
- // Every Story is its own implementation
510
- // unit and MUST carry a non-empty inline contract — top-level
511
- // `acceptance[]` AND `verify[]`. A Story missing either is the legacy
512
- // 4-tier shape that expected child Tasks; there is no Task tier any
513
- // more, so such a Story is unimplementable and the decompose is
514
- // rejected outright.
515
- const missing = stories.filter((s) => !hasInlineAcceptanceAndVerify(s));
359
+ const missing = stories.filter((s) => !hasInlineAcceptance(s));
516
360
  if (missing.length === 0) return;
517
361
  const list = missing.map((s) => `"${s.title}" (${s.slug})`).join(', ');
518
362
  throw new Error(
519
- `Cross-Validation Failed: ${missing.length} Story/Stories lack an inline acceptance + verify contract: ${list}. Every Story must carry non-empty top-level acceptance[] and verify[].`,
363
+ `Cross-Validation Failed: ${missing.length} Story/Stories lack an inline acceptance contract: ${list}. Every Story must carry a non-empty top-level acceptance[] — the outcomes a PR reviewer confirms once it lands.`,
520
364
  );
521
365
  }
522
366
 
367
+ /**
368
+ * One warning per Story with no `verify[]` entry (Story #5342).
369
+ *
370
+ * Demoted from the hard refusal above: an absent verify list costs the
371
+ * acceptance critic its cheapest evidence, which is worth saying on the
372
+ * dry-run, but it never makes the Story unimplementable — the deliverer
373
+ * derives the commands and the close gate chain runs either way.
374
+ *
375
+ * @param {object[]} stories
376
+ * @returns {string[]}
377
+ */
378
+ function collectMissingVerifyWarnings(stories) {
379
+ return (stories ?? [])
380
+ .filter((s) => !Array.isArray(s?.verify) || s.verify.length === 0)
381
+ .map(
382
+ (s) =>
383
+ `Story "${s.slug ?? s.title ?? '<unknown>'}" lists no verify[] entry — ` +
384
+ 'the deliverer and the acceptance critic have no mechanical check to ' +
385
+ 'read as evidence. Add the exact command or test path unless the ' +
386
+ 'Story genuinely has none.',
387
+ );
388
+ }
389
+
523
390
  /**
524
391
  * Shape-check the optional per-Story `provenance` field (Story #5045).
525
392
  *
@@ -619,19 +486,12 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
619
486
  assertAcyclic(slugAdjacency);
620
487
 
621
488
  // Story #4541 — refuse an unparseable Story body up front, with a named
622
- // error pointing at the offending section + entry. Must precede both the
623
- // subject-prefix scan and the freshness gate: each parses the body, and
624
- // the freshness gate's net-new whitelist comes from `body.changes`, so a
625
- // malformed body used to surface as a stale-path miss naming the paths the
626
- // Story had legitimately declared.
489
+ // error pointing at the offending section + entry. Must precede the
490
+ // freshness gate: it parses the body, and its net-new whitelist comes from
491
+ // `body.changes`, so a malformed body used to surface as a stale-path miss
492
+ // naming the paths the Story had legitimately declared.
627
493
  assertStoryBodiesParse({ tickets });
628
494
 
629
- // Reject any Task acceptance item that prescribes a non-Conventional-Commits
630
- // subject prefix (e.g. legacy "Commit subject begins with 'baseline-refresh:'"
631
- // from pre-Epic-#2501 planner output). Runs before the freshness gate so
632
- // the failure mode is reported up-front rather than after a git probe.
633
- validateAcceptanceSubjectPrefix({ tickets });
634
-
635
495
  // Hoist a single memoized (ref, path) → boolean probe shared across both
636
496
  // git-probe gates below. Without this, `validateAcFreshness` and
637
497
  // `validateStoryFileAssumptions` each maintain an independent cache, so a
@@ -643,7 +503,7 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
643
503
  ? makeMemoizedGitRunner(opts.gitRunner ?? defaultGitRunner)
644
504
  : null;
645
505
 
646
- const warnings = [];
506
+ const warnings = [...collectMissingVerifyWarnings(stories)];
647
507
  // Story #5312: a goal / acceptance / verify path absent at base is a
648
508
  // warning the dry-run lists, not a refusal. Skipped when the caller omits
649
509
  // `baseBranchRef` so unit tests keep their semantics; production
@@ -702,5 +562,6 @@ export const _internal = {
702
562
  assertNoUnknownDeps,
703
563
  assertAcyclic,
704
564
  attachFindingsAndErrors,
705
- hasInlineAcceptanceAndVerify,
565
+ hasInlineAcceptance,
566
+ collectMissingVerifyWarnings,
706
567
  };
@@ -77,20 +77,19 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
77
77
  'follow-ups',
78
78
  // v2 plan-run epilogue artifacts (posted on the primary Story)
79
79
  'plan-run-audit-roster',
80
- 'plan-run-sibling-coherence',
81
80
  'epic-run-state',
82
81
  'epic-run-progress',
83
82
  'epic-plan-state',
84
83
  // `parked-follow-ons` retired in Story #5114 with the module that was its
85
- // only writer. A kind the reader still recognises but nothing emits is the
86
- // same dead wiring in a new place.
84
+ // only writer, and `plan-run-sibling-coherence` in Story #5341 with the
85
+ // epilogue step that was its. Story #5367 retired two more on the same
86
+ // rule: `story-init` (its write went away in #5343 — the receipt is read
87
+ // off disk now) and `model-attribution` (its writer went with the per-Task
88
+ // progress writer in #3157, and its reader modules are deleted). A kind the
89
+ // reader still recognises but nothing emits is the same dead wiring in a
90
+ // new place.
87
91
  // Story #566 — per-phase wall-clock summary posted by single-story-close.js.
88
92
  'phase-timings',
89
- // Story #831 — story-init upserts a `story-init` comment that
90
- // surfaces `dependenciesInstalled` (and the underlying installStatus) so
91
- // downstream workflow steps don't have to infer install state from
92
- // node_modules presence.
93
- 'story-init',
94
93
  // Story #2128 — Phase 6 Epic Clarity Gate (CLI retired). Historical
95
94
  // `clarity-gate-update` comments may still exist on older tickets.
96
95
  'clarity-gate-update',
@@ -100,16 +99,6 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
100
99
  // operator can correct drift before Phase 8 decomposes from a stale
101
100
  // spec. Advisory: the run continues regardless of the report contents.
102
101
  'spec-freshness',
103
- // Story #2813 — the per-Task progress writer (since retired under
104
- // #3157) upserted a `model-attribution` comment on a Task ticket at
105
- // the moment it transitioned to `agent::executing`, recording which
106
- // Claude model was actively executing the work. One entry per Task
107
- // (upsert is idempotent across resume re-runs). Story- and Epic-level
108
- // rollups are computed at query time by `rollupModelAttribution` in
109
- // `lib/orchestration/model-attribution.js` — no Story/Epic-scope
110
- // emissions are written. Schema:
111
- // `.agents/schemas/model-attribution.schema.json`.
112
- 'model-attribution',
113
102
  // Story #2894 — `finalize/post-handoff-comment.js` upserts an
114
103
  // `epic-handoff` comment on the Epic at the end of the bus-owned
115
104
  // finalize flow (after `open-or-locate-pr`
@@ -157,13 +146,13 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
157
146
  // `graduator="<name>"` attr so independent graduators do not clobber each
158
147
  // other's comment; re-runs upsert in place.
159
148
  'cross-repo-deferred',
160
- // Epic #4474 (PR3) / v2 Stage 3 — `plan-persist.js` upserts a single
161
- // `plan-summary` comment on the primary Story at terminal persist
162
- // success, carrying risk / routing receipts and the depends_on order
163
- // table. One entry per plan; a re-persist upserts in place.
164
- 'plan-summary',
165
- // v2 Stage 3 — flat Story persist checkpoint on every created Story
166
- // (replaces epic-plan-state for new plans). plan-summary stays primary-only.
149
+ // v2 Stage 3 — the flat Story persist comment on every created Story
150
+ // (replaces epic-plan-state for new plans). Since Story #5343 it is the
151
+ // ONLY comment persist posts: the primary-Story-only `plan-summary` marker
152
+ // was retired and its content — story set, delivery order, deliver command
153
+ // — rides this marker on every Story. Story #5367 deleted the machine
154
+ // checkpoint that used to lead the body; the marker remains, and it is what
155
+ // makes a re-persist upsert in place.
167
156
  'story-plan-state',
168
157
  // Story #4535 — `plan-persist.js` upserts a `superseded-by` comment on
169
158
  // each `/mandrel-plan --tickets` source issue at persist time, naming the single
@@ -0,0 +1,155 @@
1
+ /**
2
+ * runtime-deps/dep-resolution — is a declared runtime dependency actually
3
+ * there, is it the right major, and how do we say so.
4
+ *
5
+ * `.agents/` materializes into the consumer's repository root, so every
6
+ * framework runtime dependency resolves from *their* `node_modules`. A range
7
+ * in `.agents/runtime-deps.json` therefore documents a requirement it cannot
8
+ * enforce, and the preflight guard needs to compare the range against what
9
+ * actually resolved.
10
+ *
11
+ * Deliberately major-only, and deliberately not `semver`. The framework's
12
+ * runtime ranges are all `^`, whose whole contract is "this major"; pulling in
13
+ * a semver implementation to decide one comparison would add a dependency to
14
+ * the very closure this module exists to keep honest.
15
+ *
16
+ * @module lib/runtime-deps/dep-resolution
17
+ */
18
+
19
+ /**
20
+ * Leading major number of a version or a caret/tilde range, or `null`.
21
+ *
22
+ * Module-local: `majorMismatch` is the only question callers have.
23
+ *
24
+ * Anything this cannot read as `<major>.` — `*`, a tag, a git URL, a
25
+ * `>=x <y` span — yields `null` and is treated as "not range-checked". That
26
+ * asymmetry is intentional: a conservative miss is a no-op, while a false
27
+ * positive blocks a working install.
28
+ *
29
+ * @param {string|null|undefined} spec
30
+ * @returns {number|null}
31
+ */
32
+ function majorOf(spec) {
33
+ if (typeof spec !== 'string') return null;
34
+ const match = /^[\^~]?(\d+)\./.exec(spec.trim());
35
+ return match ? Number(match[1]) : null;
36
+ }
37
+
38
+ /**
39
+ * Does `resolved` sit outside the major `range` names?
40
+ *
41
+ * Module-local: `checkRuntimeDeps` is the only caller, and exporting it only
42
+ * for a test would be a production-dead export.
43
+ *
44
+ * `false` whenever either side is unreadable, so an unparseable range or an
45
+ * unreadable installed version is never reported as a mismatch.
46
+ *
47
+ * `0.x` majors compare as written: `^0.1.0` and `0.2.1` differ in minor, not
48
+ * major, so this does not separate them. Accepted — the `0.x` packages in the
49
+ * closure are terminal, and the range this exists to enforce is
50
+ * `@babel/parser`'s `^7`.
51
+ *
52
+ * @param {string|null|undefined} range
53
+ * @param {string|null|undefined} resolved
54
+ * @returns {boolean}
55
+ */
56
+ function majorMismatch(range, resolved) {
57
+ const want = majorOf(range);
58
+ if (want === null) return false;
59
+ const got = majorOf(resolved);
60
+ if (got === null) return false;
61
+ return want !== got;
62
+ }
63
+
64
+ /**
65
+ * Is a package present in the resolvable tree?
66
+ *
67
+ * The bare specifier is tried first, then `<name>/package.json`. The fallback
68
+ * is not belt-and-braces: a package with no `main` and no `exports` — which
69
+ * `typhonjs-escomplex-commons` and `babel-runtime` both are — cannot be
70
+ * resolved by name at all, and is reached only by deep path. Probing the bare
71
+ * name alone would report such a package missing while it sits installed, and
72
+ * this guard exits the process on that verdict.
73
+ *
74
+ * @param {string} dep
75
+ * @param {(specifier: string) => string} resolve
76
+ * @returns {boolean}
77
+ */
78
+ export function isResolvable(dep, resolve) {
79
+ try {
80
+ resolve(dep);
81
+ return true;
82
+ } catch {
83
+ // fall through to the manifest probe
84
+ }
85
+ try {
86
+ resolve(`${dep}/package.json`);
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Remediation text for a resolved dependency whose major differs from the
95
+ * range the framework declares.
96
+ *
97
+ * Named separately from the missing-deps message because the remedy differs:
98
+ * the package is installed, so installing again changes nothing. What is
99
+ * wrong is the version the consumer's own tree resolves, which only they can
100
+ * change.
101
+ *
102
+ * @param {{name: string, required: string, resolved: string}[]} mismatched
103
+ * @param {{ root: string }} ctx
104
+ * @returns {string}
105
+ */
106
+ export function formatMismatchedDepsMessage(mismatched, { root }) {
107
+ const lines = mismatched.map(
108
+ (m) => ` - ${m.name}: need ${m.required}, resolved ${m.resolved}`,
109
+ );
110
+ return [
111
+ 'Mandrel framework runtime dependency version mismatch:',
112
+ ...lines,
113
+ '',
114
+ `Resolved from: ${root}`,
115
+ 'These packages are resolved from your repository, not from mandrel, so',
116
+ 'the version your tree installs is the version the framework gets. Pin a',
117
+ 'compatible major in your package.json and reinstall.',
118
+ ].join('\n');
119
+ }
120
+
121
+ /**
122
+ * Resolve each required package via the injected `resolve` seam and collect
123
+ * the ones that fail. `resolve` is typically `require.resolve` bound to the
124
+ * framework module location; it throws `MODULE_NOT_FOUND` when a package is
125
+ * absent from the resolvable `node_modules`.
126
+ *
127
+ * @param {{ required: string[], resolve: (specifier: string) => string }} opts
128
+ * @returns {{ ok: boolean, missing: string[] }}
129
+ */
130
+ export function checkRuntimeDeps({
131
+ required,
132
+ resolve,
133
+ ranges = null,
134
+ readVersion = null,
135
+ }) {
136
+ const missing = [];
137
+ const mismatched = [];
138
+ for (const dep of required) {
139
+ if (!isResolvable(dep, resolve)) {
140
+ missing.push(dep);
141
+ continue;
142
+ }
143
+ if (!ranges || !readVersion) continue;
144
+ const range = ranges[dep];
145
+ const resolved = readVersion(dep);
146
+ if (majorMismatch(range, resolved)) {
147
+ mismatched.push({ name: dep, required: range, resolved });
148
+ }
149
+ }
150
+ return {
151
+ ok: missing.length === 0 && mismatched.length === 0,
152
+ missing,
153
+ mismatched,
154
+ };
155
+ }
@@ -28,12 +28,13 @@
28
28
  */
29
29
 
30
30
  import { createRequire } from 'node:module';
31
- import { loadRuntimeDepsManifest } from './manifest.js';
31
+ import { resolveDependencyVersion } from '../dependency-version.js';
32
32
  import {
33
33
  checkRuntimeDeps,
34
- detectPackageManager,
35
- formatMissingDepsMessage,
36
- } from './preflight.js';
34
+ formatMismatchedDepsMessage,
35
+ } from './dep-resolution.js';
36
+ import { loadRuntimeDepsManifest } from './manifest.js';
37
+ import { detectPackageManager, formatMissingDepsMessage } from './preflight.js';
37
38
 
38
39
  // `require.resolve` bound to this module's location walks `node_modules`
39
40
  // upward from `.agents/scripts/lib/runtime-deps/` to the consumer root —
@@ -50,7 +51,8 @@ const frameworkRequire = createRequire(import.meta.url);
50
51
  * cwd?: string,
51
52
  * stderr?: { write: (s: string) => void },
52
53
  * exit?: (code: number) => void,
53
- * manifest?: { required: string[] },
54
+ * manifest?: { required: string[], dependencies?: Record<string,string> },
55
+ * readVersion?: (name: string) => string | null,
54
56
  * }} [opts]
55
57
  * @returns {{ ok: boolean, missing: string[] }}
56
58
  */
@@ -61,6 +63,7 @@ export function ensureRuntimeDepsInstalled(opts = {}) {
61
63
  stderr = process.stderr,
62
64
  exit = process.exit,
63
65
  manifest = safeLoadManifest(),
66
+ readVersion,
64
67
  } = opts;
65
68
 
66
69
  // A manifest we cannot read is a packaging defect the drift test owns —
@@ -70,17 +73,49 @@ export function ensureRuntimeDepsInstalled(opts = {}) {
70
73
  const result = checkRuntimeDeps({
71
74
  required: manifest.required,
72
75
  resolve: requireResolve,
76
+ ranges: manifest.dependencies ?? null,
77
+ readVersion: readVersion ?? defaultReadVersion,
73
78
  });
74
79
  if (result.ok) return result;
75
80
 
76
- const packageManager = detectPackageManager(cwd);
77
- stderr.write(
78
- `${formatMissingDepsMessage(result.missing, { root: cwd, packageManager })}\n`,
79
- );
81
+ stderr.write(`${describeFailure(result, cwd)}\n`);
80
82
  exit(1);
81
83
  return result;
82
84
  }
83
85
 
86
+ /**
87
+ * Remediation text for a failed check.
88
+ *
89
+ * Absence is reported first: a package that is not installed cannot have a
90
+ * version, and installing it is the prerequisite for any version complaint
91
+ * being actionable.
92
+ *
93
+ * @param {{ missing: string[], mismatched: {name: string, required: string, resolved: string}[] }} result
94
+ * @param {string} cwd
95
+ * @returns {string}
96
+ */
97
+ function describeFailure(result, cwd) {
98
+ if (result.missing.length === 0) {
99
+ return formatMismatchedDepsMessage(result.mismatched, { root: cwd });
100
+ }
101
+ const packageManager = detectPackageManager(cwd);
102
+ return formatMissingDepsMessage(result.missing, {
103
+ root: cwd,
104
+ packageManager,
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Read a resolved package's version through the framework's own resolution,
110
+ * so the version checked is the one the framework's imports will load.
111
+ *
112
+ * @param {string} name
113
+ * @returns {string | null}
114
+ */
115
+ function defaultReadVersion(name) {
116
+ return resolveDependencyVersion(name, frameworkRequire);
117
+ }
118
+
84
119
  /**
85
120
  * Load the manifest, swallowing a read/parse failure to `null` so the guard
86
121
  * stays inert on a packaging defect (see `ensureRuntimeDepsInstalled`).