mandrel 2.24.0 → 2.26.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -321,6 +321,16 @@ export class TicketGateway {
321
321
  * avoid a read-before-write. When other PATCH fields are present, or when
322
322
  * removing labels, computes the final label set and returns it to the
323
323
  * caller for inclusion in the PATCH.
324
+ *
325
+ * The additive POST goes through `withTransientRetry` (Story #4961) on the
326
+ * same policy as every other call in this file. Story #4952 raised the
327
+ * `/plan` write loops that reach this endpoint — the `agent::ready` flips
328
+ * and the checkpoint fan-out — off serial, which is precisely what makes
329
+ * GitHub's secondary rate limit likelier; `gh-exec` already classifies that
330
+ * as transient, so the only thing missing was a backoff behind it.
331
+ * Retry does not change what the caller observes on a genuine failure: an
332
+ * exhausted or non-transient error still throws, so `markStoriesReady`
333
+ * still collects it into the complete failure set.
324
334
  */
325
335
  async _applyLabelMutations(
326
336
  ticketId,
@@ -331,11 +341,15 @@ export class TicketGateway {
331
341
  const { add = [], remove = [] } = labelMutations;
332
342
 
333
343
  if (add.length > 0 && remove.length === 0 && !hasOtherPatchFields) {
334
- await this._gh.api({
335
- method: 'POST',
336
- endpoint: `/repos/${this.owner}/${this.repo}/issues/${ticketId}/labels`,
337
- body: { labels: add },
338
- });
344
+ await withTransientRetry(
345
+ () =>
346
+ this._gh.api({
347
+ method: 'POST',
348
+ endpoint: `/repos/${this.owner}/${this.repo}/issues/${ticketId}/labels`,
349
+ body: { labels: add },
350
+ }),
351
+ { label: `addLabels #${ticketId}`, onRetry: defaultRetryWarn },
352
+ );
339
353
  return { skipPatch: true };
340
354
  }
341
355
 
@@ -351,6 +365,11 @@ export class TicketGateway {
351
365
  }
352
366
 
353
367
  /**
368
+ * The issue PATCH is wrapped in `withTransientRetry` (Story #4961) for the
369
+ * same reason as the additive label POST above — see
370
+ * {@link TicketGateway#_applyLabelMutations}. Both are the write half of the
371
+ * fan-outs Story #4952 raised; the reads in this file were already wrapped.
372
+ *
354
373
  * @field-manifest PATCH /repos/{owner}/{repo}/issues/{n}:
355
374
  * body, assignees, state, state_reason, labels
356
375
  */
@@ -379,11 +398,15 @@ export class TicketGateway {
379
398
  }
380
399
 
381
400
  if (Object.keys(patch).length > 0) {
382
- await this._gh.api({
383
- method: 'PATCH',
384
- endpoint: `/repos/${this.owner}/${this.repo}/issues/${ticketId}`,
385
- body: patch,
386
- });
401
+ await withTransientRetry(
402
+ () =>
403
+ this._gh.api({
404
+ method: 'PATCH',
405
+ endpoint: `/repos/${this.owner}/${this.repo}/issues/${ticketId}`,
406
+ body: patch,
407
+ }),
408
+ { label: `updateTicket #${ticketId}`, onRetry: defaultRetryWarn },
409
+ );
387
410
  this.invalidateTicket(ticketId);
388
411
  }
389
412
  }
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * provision-git-hooks.js
5
+ *
6
+ * Materialize the repository's git hooks into a linked worktree, so commits
7
+ * made there are subject to the same `commit-msg` / `pre-commit` / `pre-push`
8
+ * gates as commits made in the main checkout.
9
+ *
10
+ * Worktrees created by `single-story-init.js` are provisioned automatically as
11
+ * part of `worktree.bootstrap`. This entry point exists for the worktrees the
12
+ * orchestrator never creates — a harness or hand-made `git worktree add` —
13
+ * which are otherwise unreachable. Run it from inside such a worktree with no
14
+ * arguments.
15
+ */
16
+
17
+ import path from 'node:path';
18
+ import { runAsCli } from './lib/cli-utils.js';
19
+ import {
20
+ materializeGitHooks,
21
+ resolveCommonCheckout,
22
+ } from './lib/worktree/git-hooks.js';
23
+
24
+ /**
25
+ * @param {object} [opts]
26
+ * @param {string[]} [opts.argv]
27
+ * @param {string} [opts.cwd]
28
+ * @returns {{ action: string, reason?: string, hooksPath: string|null,
29
+ * target: string|null, hooks: string[], worktree: string, repoRoot: string }}
30
+ */
31
+ export function runProvisionGitHooks({
32
+ argv = process.argv.slice(2),
33
+ cwd = process.cwd(),
34
+ } = {}) {
35
+ const positional = [];
36
+ let repoRootFlag = null;
37
+ for (let i = 0; i < argv.length; i += 1) {
38
+ if (argv[i] === '--repo-root') {
39
+ repoRootFlag = argv[i + 1];
40
+ if (!repoRootFlag) {
41
+ throw new Error('--repo-root requires a path');
42
+ }
43
+ i += 1;
44
+ continue;
45
+ }
46
+ positional.push(argv[i]);
47
+ }
48
+
49
+ const worktree = path.resolve(cwd, positional[0] ?? '.');
50
+ const repoRoot = repoRootFlag
51
+ ? path.resolve(cwd, repoRootFlag)
52
+ : resolveCommonCheckout(worktree);
53
+
54
+ const result = materializeGitHooks({ repoRoot, worktree });
55
+ return { ...result, worktree, repoRoot };
56
+ }
57
+
58
+ runAsCli(
59
+ import.meta.url,
60
+ async () => {
61
+ const result = runProvisionGitHooks();
62
+ // Compact single-line digest: this is the machine contract, and the
63
+ // hook file list is bounded by the hooks directory itself.
64
+ process.stdout.write(`${JSON.stringify(result)}\n`);
65
+ },
66
+ {
67
+ source: 'provision-git-hooks',
68
+ usage: {
69
+ invocation:
70
+ 'node .agents/scripts/provision-git-hooks.js [<worktree-path>] [--repo-root <path>]',
71
+ summary:
72
+ "Materialize the repository's git hooks into a linked worktree so its commits are gated.",
73
+ flags: [
74
+ [
75
+ '<worktree-path>',
76
+ 'Worktree to provision (default: the current directory).',
77
+ ],
78
+ [
79
+ '--repo-root <path>',
80
+ 'Checkout that owns the hooks (default: derived from --git-common-dir).',
81
+ ],
82
+ ],
83
+ },
84
+ },
85
+ );
@@ -30,6 +30,8 @@ import {
30
30
  runMaintainabilityPreview,
31
31
  } from './lib/baselines/preview-gates.js';
32
32
  import { respondToHelp } from './lib/cli-usage.js';
33
+ import { getQuality, resolveConfig } from './lib/config-resolver.js';
34
+ import { resolveCyclomaticPolicy } from './lib/cyclomatic-ceiling.js';
33
35
 
34
36
  const USAGE = {
35
37
  invocation:
@@ -46,6 +48,14 @@ const USAGE = {
46
48
  ],
47
49
  };
48
50
 
51
+ /**
52
+ * Framework default for `delivery.quality.codingGuardrails.cyclomaticFlag`,
53
+ * used only when a caller drives `mergeEnvelopes` / `renderTable` without a
54
+ * resolved config in hand (tests, and the pure-function surface). `runCli`
55
+ * always passes the resolved value.
56
+ */
57
+ const DEFAULT_CYCLOMATIC_FLAG = 8;
58
+
49
59
  /**
50
60
  * Parse `--changed-since <ref>` from argv. Defaults to `HEAD` when the flag is
51
61
  * present without a value. Returns `null` when the flag is absent so callers
@@ -97,6 +107,47 @@ export function parseStagedFlag(argv) {
97
107
  return argv.includes('--staged');
98
108
  }
99
109
 
110
+ /**
111
+ * Coerce a caller-supplied flag ceiling to a usable number, falling back to
112
+ * the framework default for anything non-finite.
113
+ *
114
+ * @param {unknown} value
115
+ * @returns {number}
116
+ */
117
+ function normalizeFlag(value) {
118
+ const n = Number(value);
119
+ return Number.isFinite(n) ? n : DEFAULT_CYCLOMATIC_FLAG;
120
+ }
121
+
122
+ /**
123
+ * Fold one CRAP violation into its per-file aggregate row. Mutates `row`.
124
+ *
125
+ * Split out of `mergeEnvelopes` (Story #4923): threading the resolved
126
+ * `cyclomaticFlag` through pushed that function from c=12 — exactly at the
127
+ * must-fix ceiling — to c=13, and the same Story starts *enforcing* that
128
+ * ceiling. Recording its own breach in `baselines/cyclomatic.json` would have
129
+ * been the first re-spend of the slack the Story reclaims.
130
+ *
131
+ * @param {{ worstCrapDelta: number, newOverCeilingMethods: number }} row
132
+ * @param {{ crap?: number, ceiling?: number, baseline?: number, cyclomatic?: number, kind?: string }} v
133
+ * @param {number} flag resolved `codingGuardrails.cyclomaticFlag`
134
+ * @returns {void}
135
+ */
136
+ function foldCrapViolation(row, v, flag) {
137
+ const crap = Number(v.crap ?? 0);
138
+ const isNew = v.kind === 'new';
139
+ const against = Number((isNew ? v.ceiling : v.baseline) ?? 0);
140
+ const delta = crap - against;
141
+ if (Number.isFinite(delta) && delta > row.worstCrapDelta) {
142
+ row.worstCrapDelta = delta;
143
+ }
144
+ if (!isNew) return;
145
+ const cyclomatic = Number(v.cyclomatic ?? 0);
146
+ if (Number.isFinite(cyclomatic) && cyclomatic > flag) {
147
+ row.newOverCeilingMethods += 1;
148
+ }
149
+ }
150
+
100
151
  /**
101
152
  * Merge an MI envelope (from `runMaintainabilityPreview`) and a CRAP
102
153
  * envelope (from `runCrapPreview`) into a per-file delta map. Pure —
@@ -109,9 +160,16 @@ export function parseStagedFlag(argv) {
109
160
  * methods (max of `crap - baseline` for matched-baseline rows, `crap`
110
161
  * for new-method rows). 0 when the file has no CRAP violations.
111
162
  * - `newOverCeilingMethods`: count of new-method violations (kind:'new')
112
- * scoring above the `c=8` ceiling (matches the column header
113
- * "new-method count over c=8" in the AC). The CRAP envelope's
114
- * `cyclomatic` field is the per-method `c` reading.
163
+ * scoring above the flag ceiling. The CRAP envelope's `cyclomatic` field
164
+ * is the per-method `c` reading.
165
+ *
166
+ * `cyclomaticFlag` is the resolved
167
+ * `delivery.quality.codingGuardrails.cyclomaticFlag` (Story #4923). It used to
168
+ * be the literal `8` written into this function and into the column header, so
169
+ * a consumer that tuned the knob saw its own value validated by the schema,
170
+ * defaulted by the bootstrap, resolved by `lib/config/quality.js` — and then
171
+ * ignored here. The parameter defaults to the framework default so a caller
172
+ * with no config in hand still gets the historical reading.
115
173
  *
116
174
  * @param {{ violations?: Array<{ file: string, drop?: number }> } | null} miEnvelope
117
175
  * @param {{ violations?: Array<{
@@ -122,6 +180,7 @@ export function parseStagedFlag(argv) {
122
180
  * cyclomatic: number,
123
181
  * kind: 'new' | 'regression' | 'drifted-regression' | string,
124
182
  * }>} | null} crapEnvelope
183
+ * @param {{ cyclomaticFlag?: number }} [opts]
125
184
  * @returns {{
126
185
  * rows: Array<{
127
186
  * file: string,
@@ -130,9 +189,15 @@ export function parseStagedFlag(argv) {
130
189
  * newOverCeilingMethods: number,
131
190
  * }>,
132
191
  * totals: { miRegressions: number, crapViolations: number },
192
+ * cyclomaticFlag: number,
133
193
  * }}
134
194
  */
135
- export function mergeEnvelopes(miEnvelope, crapEnvelope) {
195
+ export function mergeEnvelopes(
196
+ miEnvelope,
197
+ crapEnvelope,
198
+ { cyclomaticFlag = DEFAULT_CYCLOMATIC_FLAG } = {},
199
+ ) {
200
+ const flag = normalizeFlag(cyclomaticFlag);
136
201
  /** @type {Map<string, { miDrop: number, worstCrapDelta: number, newOverCeilingMethods: number }>} */
137
202
  const byFile = new Map();
138
203
  const ensure = (file) => {
@@ -155,25 +220,7 @@ export function mergeEnvelopes(miEnvelope, crapEnvelope) {
155
220
  const crapViolations = crapEnvelope?.violations ?? [];
156
221
  for (const v of crapViolations) {
157
222
  if (!v?.file) continue;
158
- const row = ensure(v.file);
159
- const crap = Number(v.crap ?? 0);
160
- if (v.kind === 'new') {
161
- const ceiling = Number(v.ceiling ?? 0);
162
- const delta = crap - ceiling;
163
- if (Number.isFinite(delta) && delta > row.worstCrapDelta) {
164
- row.worstCrapDelta = delta;
165
- }
166
- const cyclomatic = Number(v.cyclomatic ?? 0);
167
- if (Number.isFinite(cyclomatic) && cyclomatic > 8) {
168
- row.newOverCeilingMethods += 1;
169
- }
170
- } else {
171
- const baseline = Number(v.baseline ?? 0);
172
- const delta = crap - baseline;
173
- if (Number.isFinite(delta) && delta > row.worstCrapDelta) {
174
- row.worstCrapDelta = delta;
175
- }
176
- }
223
+ foldCrapViolation(ensure(v.file), v, flag);
177
224
  }
178
225
 
179
226
  const rows = Array.from(byFile.entries())
@@ -188,9 +235,33 @@ export function mergeEnvelopes(miEnvelope, crapEnvelope) {
188
235
  (crapEnvelope?.summary?.regressions ?? 0) +
189
236
  (crapEnvelope?.summary?.newViolations ?? 0),
190
237
  },
238
+ cyclomaticFlag: flag,
191
239
  };
192
240
  }
193
241
 
242
+ /**
243
+ * Render the named diagnostics a gate envelope carries, or `null` when it
244
+ * carries none (Story #4866).
245
+ *
246
+ * A diagnostic is what a gate emits *instead of* per-method verdicts when it
247
+ * has established that no verdict it could produce would be meaningful — an
248
+ * incomparable baseline, or a comparison basis whose drifted-row ratio proves
249
+ * the two sides disagree on line coordinates. It must reach the operator
250
+ * verbatim: the gate exits 0, so silence would read as a clean run.
251
+ *
252
+ * @param {Array<{ envelope: { diagnostics?: Array<{name: string, message: string}> } | null }>} results
253
+ * @returns {string | null}
254
+ */
255
+ export function renderDiagnostics(results) {
256
+ const lines = [];
257
+ for (const { envelope } of results ?? []) {
258
+ for (const d of envelope?.diagnostics ?? []) {
259
+ lines.push(`[${d.name}] ${d.message}`);
260
+ }
261
+ }
262
+ return lines.length === 0 ? null : lines.join('\n');
263
+ }
264
+
194
265
  /**
195
266
  * Compute the CLI exit code from a merge result + per-gate exit codes. Pure.
196
267
  *
@@ -216,21 +287,28 @@ export function computeExitCode(merged, miExit, crapExit) {
216
287
  }
217
288
 
218
289
  /**
219
- * Render the per-file delta table. Header columns match the AC verbatim:
220
- * "file", "MI delta", "worst CRAP delta", "new-method count over c=8".
290
+ * Render the per-file delta table. Columns:
291
+ * "file", "MI delta", "worst CRAP delta", "new-method count over c=<flag>".
292
+ *
293
+ * The last header used to hardcode `c=8`, which quietly lied to any consumer
294
+ * that had tuned `codingGuardrails.cyclomaticFlag`. It now names the value the
295
+ * count was actually taken against, read off the merge result.
221
296
  *
222
297
  * Pure — accepts pre-computed merge rows and returns a multi-line string. The
223
298
  * table renders even on a clean diff so operators see the "no drift" signal.
224
299
  *
225
- * @param {{ rows: Array<{ file: string, miDrop: number, worstCrapDelta: number, newOverCeilingMethods: number }>, totals: { miRegressions: number, crapViolations: number } }} merged
300
+ * @param {{ rows: Array<{ file: string, miDrop: number, worstCrapDelta: number, newOverCeilingMethods: number }>, totals: { miRegressions: number, crapViolations: number }, cyclomaticFlag?: number }} merged
226
301
  * @returns {string}
227
302
  */
228
303
  export function renderTable(merged) {
304
+ const flag = Number.isFinite(Number(merged?.cyclomaticFlag))
305
+ ? Number(merged.cyclomaticFlag)
306
+ : DEFAULT_CYCLOMATIC_FLAG;
229
307
  const header = [
230
308
  'file',
231
309
  'MI delta',
232
310
  'worst CRAP delta',
233
- 'new-method count over c=8',
311
+ `new-method count over c=${flag}`,
234
312
  ];
235
313
  const lines = [];
236
314
  lines.push(`| ${header.join(' | ')} |`);
@@ -251,6 +329,31 @@ export function renderTable(merged) {
251
329
  return lines.join('\n');
252
330
  }
253
331
 
332
+ /**
333
+ * Resolve `codingGuardrails.cyclomaticFlag` for the tree at `cwd`
334
+ * (Story #4923), falling back to the framework default when the config cannot
335
+ * be resolved at all.
336
+ *
337
+ * Best-effort by design: `quality:preview` is a developer-facing report, and a
338
+ * run in a tree with no readable `.agentrc.json` should still render its table
339
+ * rather than abort. Extracted from `runCli` rather than inlined so the CLI
340
+ * body stays under the cyclomatic must-fix ceiling this same Story starts
341
+ * enforcing — a gate whose own delivery breaches it is not a gate.
342
+ *
343
+ * @param {{ cwd: string, stderr: { write: (s: string) => void } }} args
344
+ * @returns {number}
345
+ */
346
+ function resolveCyclomaticFlag({ cwd, stderr }) {
347
+ try {
348
+ return resolveCyclomaticPolicy(getQuality(resolveConfig({ cwd }))).flag;
349
+ } catch (err) {
350
+ stderr.write(
351
+ `[quality:preview] config resolution failed, using cyclomaticFlag=${DEFAULT_CYCLOMATIC_FLAG}: ${err?.message ?? err}\n`,
352
+ );
353
+ return DEFAULT_CYCLOMATIC_FLAG;
354
+ }
355
+ }
356
+
254
357
  /**
255
358
  * Top-level CLI entry: invoke both per-kind preview runners, merge, render,
256
359
  * and exit with the right code. Exposed as `runCli` so tests can drive the
@@ -296,7 +399,11 @@ export async function runCli({
296
399
  const crapExit = crapResult.exitCode;
297
400
  const miEnvelope = miResult.envelope;
298
401
  const crapEnvelope = crapResult.envelope;
299
- const merged = mergeEnvelopes(miEnvelope, crapEnvelope);
402
+ // Story #4923 the over-ceiling column counts against the *resolved*
403
+ // `codingGuardrails.cyclomaticFlag`, not the literal that used to be written
404
+ // into `mergeEnvelopes` and the column header.
405
+ const cyclomaticFlag = resolveCyclomaticFlag({ cwd, stderr });
406
+ const merged = mergeEnvelopes(miEnvelope, crapEnvelope, { cyclomaticFlag });
300
407
 
301
408
  if (json) {
302
409
  stdout.write(
@@ -320,6 +427,8 @@ export async function runCli({
320
427
  : `scope=diff ref=${ref}\n\n`,
321
428
  );
322
429
  stdout.write(`${renderTable(merged)}\n`);
430
+ const diagnostics = renderDiagnostics([miResult, crapResult]);
431
+ if (diagnostics) stdout.write(`\n${diagnostics}\n`);
323
432
  if (miExit !== 0 || crapExit !== 0) {
324
433
  stderr.write(
325
434
  `\n[quality:preview] gate exits: mi=${miExit} crap=${crapExit}\n`,
@@ -24,6 +24,7 @@
24
24
  *
25
25
  * Usage:
26
26
  * node .agents/scripts/resolve-stories.js --ids 101,102
27
+ * node .agents/scripts/resolve-stories.js --ids 101-104 # inclusive range
27
28
  * node .agents/scripts/resolve-stories.js --ids 101,102 --pretty
28
29
  * node .agents/scripts/resolve-stories.js --ids 101 --no-native # skip the dependencies API
29
30
  *
@@ -65,7 +66,9 @@ blocked_by edges, with every blocker (in-set or foreign) resolved against its
65
66
  real issue state.
66
67
 
67
68
  Options:
68
- --ids <csv> Comma-separated Story issue numbers. Required.
69
+ --ids <csv> Comma-separated Story issue numbers. Required. A token may be
70
+ a single id (4922) or an inclusive dash range (4922-4926);
71
+ ranges expand in place and dedupe against the rest.
69
72
  --pretty Pretty-print the JSON envelope.
70
73
  --no-native Skip the native blocked_by read (body edges only).
71
74
  --help Show this help.
@@ -10,8 +10,16 @@
10
10
  * Node script keeps the env injection portable.
11
11
  *
12
12
  * Pipeline:
13
+ * 0. Run the full tier's preflight (`runTierPreflight`). Story #4936: this
14
+ * used to be a `pretest:coverage` npm script that CI had to name
15
+ * explicitly, because `.npmrc`'s `ignore-scripts=true` (CWE-1357
16
+ * defence, which stays) suppresses every `pre*` hook. The runner
17
+ * invokes it now, so it executes however the coverage run is started.
13
18
  * 1. Run the test suite under `NODE_V8_COVERAGE` so each worker writes
14
- * raw V8 dumps under `coverage/tmp/`.
19
+ * raw V8 dumps under `coverage/tmp/`. The suite targets are the
20
+ * shared `FULL_TIER_GLOBS` from `lib/test-tiers.js` — the same set
21
+ * `run-tests.js` walks — so the measured surface and `npm test`'s
22
+ * surface are the same set by construction.
15
23
  * 2. `c8 report` post-processes the dumps into `coverage/coverage-final.json`
16
24
  * plus the printed text table. Include/exclude scope from `.c8rc.cjs`
17
25
  * is passed explicitly because `c8 report` does not auto-load the
@@ -28,13 +36,15 @@
28
36
  * while producing an identical `coverage-final.json` artifact for the
29
37
  * CRAP gate.
30
38
  *
31
- * Test-runner concurrency: the suite spawn reuses `TEST_RUNNER_FLAGS`
32
- * from `run-tests.js` — the single source of truth for the
33
- * `--test-concurrency` value, derived at startup from the host's
34
- * available parallelism and clamped to `[TEST_CONCURRENCY_MIN,
35
- * TEST_CONCURRENCY_MAX]`. This keeps the coverage gate (which runs the
36
- * suite at every story close on both delivery paths) host-aware instead
37
- * of pinned to the historical literal of 8.
39
+ * Test-runner flags: the suite spawn reuses `TEST_RUNNER_FLAGS` from
40
+ * `lib/test-runner-contract.js` — the single source of truth for the
41
+ * `node --test` flag set, including the host-derived
42
+ * `--test-concurrency` value clamped to `[TEST_CONCURRENCY_MIN,
43
+ * TEST_CONCURRENCY_MAX]`. Story #4936: sharing the whole flag set (not
44
+ * just the concurrency number) is what stops this runner and
45
+ * `run-tests.js` disagreeing about whether a test can execute at all —
46
+ * `--experimental-test-module-mocks` decides whether `t.mock.module`
47
+ * works, and this is the *required* CI job.
38
48
  */
39
49
 
40
50
  import { spawnSync } from 'node:child_process';
@@ -46,56 +56,90 @@ import { fileURLToPath } from 'node:url';
46
56
  import { cleanupRepoTestTempArtifacts } from './cleanup-repo-test-temp.js';
47
57
  import { C8_CLI } from './lib/c8-cli-path.js';
48
58
  import { buildWebhookSafeTestEnv } from './lib/test-env.js';
49
- import { TEST_RUNNER_FLAGS } from './run-tests.js';
59
+ import {
60
+ runTierPreflight,
61
+ TEST_RUNNER_FLAGS,
62
+ } from './lib/test-runner-contract.js';
63
+ import { FULL_TIER_GLOBS } from './lib/test-tiers.js';
50
64
 
51
65
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
52
66
  const ROOT = path.resolve(__dirname, '..', '..');
53
- const COVERAGE_DIR = path.join(ROOT, 'coverage');
54
67
  const require = createRequire(import.meta.url);
55
68
  const C8_CONFIG = require('../../.c8rc.cjs');
56
- const V8_TMP = path.join(COVERAGE_DIR, 'tmp');
57
69
 
58
70
  /**
59
71
  * Build the `node --test` argv for the coverage suite spawn.
60
72
  *
61
- * Reuses the shared `TEST_RUNNER_FLAGS` (the single source of truth for
62
- * the host-aware, clamped `--test-concurrency` value) so the coverage
63
- * path never drifts from `run-tests.js`. The `runnerFlags` parameter is
64
- * injected in tests so the argv can be asserted without touching the OS.
73
+ * Reuses two shared constants so the coverage path cannot drift from
74
+ * `run-tests.js`:
75
+ *
76
+ * - `TEST_RUNNER_FLAGS` the single source of truth for the host-aware,
77
+ * clamped `--test-concurrency` value.
78
+ * - `FULL_TIER_GLOBS` — the single source of truth for *which files the
79
+ * full tier runs*. Story #4922: this script used to restate
80
+ * `tests/**\/*.test.js` on its own, so the 47 colocated `__tests__`
81
+ * suites under `lib/` and `.agents/scripts/` executed under `npm test`
82
+ * but never under the measuring run — the coverage and CRAP numbers
83
+ * were computed over code the coverage run had not executed.
84
+ *
85
+ * Both parameters are injected in tests so the argv can be asserted without
86
+ * touching the OS.
65
87
  *
66
88
  * @param {object} [opts]
67
89
  * @param {readonly string[]} [opts.runnerFlags]
68
- * @param {string} [opts.testGlob]
90
+ * @param {readonly string[]} [opts.testGlobs]
69
91
  * @returns {string[]}
70
92
  */
71
93
  export function buildCoverageTestArgs({
72
94
  runnerFlags = TEST_RUNNER_FLAGS,
73
- testGlob = 'tests/**/*.test.js',
95
+ testGlobs = FULL_TIER_GLOBS,
74
96
  } = {}) {
75
- return [...runnerFlags, testGlob];
97
+ return [...runnerFlags, ...testGlobs];
76
98
  }
77
99
 
78
100
  /**
79
- * Execute the coverage pipeline: run the suite under `NODE_V8_COVERAGE`,
80
- * post-process the dumps with `c8 report`, then gate on the coverage
81
- * baseline. Returns the first non-zero exit code across the three stages
82
- * (or the baseline check's status when both prior stages pass).
101
+ * Execute the coverage pipeline: run the full tier's preflight, run the
102
+ * suite under `NODE_V8_COVERAGE`, post-process the dumps with `c8 report`,
103
+ * then gate on the coverage baseline. Returns the first non-zero exit code
104
+ * across the stages (or the baseline check's status when the prior stages
105
+ * pass); a refused preflight short-circuits before anything is spawned or
106
+ * removed.
83
107
  *
108
+ * Every collaborator is injected so the pipeline's wiring — above all
109
+ * *that the preflight actually runs, and runs first* — is assertable
110
+ * without spawning the real suite.
111
+ *
112
+ * @param {object} [opts]
113
+ * @param {typeof spawnSync} [opts.spawn]
114
+ * @param {(opts: { tier: string, repoRoot: string }) => number} [opts.preflight]
115
+ * @param {(opts: { repoRoot: string }) => unknown} [opts.cleanup]
116
+ * @param {string} [opts.repoRoot]
84
117
  * @returns {number}
85
118
  */
86
- function runCoveragePipeline() {
87
- rmSync(COVERAGE_DIR, { recursive: true, force: true });
88
- mkdirSync(V8_TMP, { recursive: true });
119
+ export function runCoveragePipeline({
120
+ spawn = spawnSync,
121
+ preflight = runTierPreflight,
122
+ cleanup = cleanupRepoTestTempArtifacts,
123
+ repoRoot = ROOT,
124
+ } = {}) {
125
+ const coverageDir = path.join(repoRoot, 'coverage');
126
+ const v8Tmp = path.join(coverageDir, 'tmp');
127
+
128
+ const preflightStatus = preflight({ tier: 'full', repoRoot });
129
+ if (preflightStatus !== 0) return preflightStatus;
89
130
 
90
- const testRun = spawnSync(process.execPath, buildCoverageTestArgs(), {
91
- cwd: ROOT,
131
+ rmSync(coverageDir, { recursive: true, force: true });
132
+ mkdirSync(v8Tmp, { recursive: true });
133
+
134
+ const testRun = spawn(process.execPath, buildCoverageTestArgs(), {
135
+ cwd: repoRoot,
92
136
  stdio: 'inherit',
93
137
  // GIT_*-scrubbed: under a husky pre-push from a linked worktree the
94
138
  // inherited GIT_DIR poisons fixture `git init` runs (#4580).
95
- env: { ...buildWebhookSafeTestEnv(process.env), NODE_V8_COVERAGE: V8_TMP },
139
+ env: { ...buildWebhookSafeTestEnv(process.env), NODE_V8_COVERAGE: v8Tmp },
96
140
  });
97
141
 
98
- cleanupRepoTestTempArtifacts({ repoRoot: ROOT });
142
+ cleanup({ repoRoot });
99
143
 
100
144
  const includeArgs = (C8_CONFIG.include ?? []).flatMap((p) => [
101
145
  '--include',
@@ -106,29 +150,36 @@ function runCoveragePipeline() {
106
150
  p,
107
151
  ]);
108
152
 
109
- const reportRun = spawnSync(
153
+ // `c8 report` does not auto-load `.c8rc.cjs`, so every scope knob it needs
154
+ // is forwarded explicitly from the config object — including `all`, which
155
+ // is what puts a 0 % row on a source file no test ever loaded (Story
156
+ // #4922). Reading it off the config keeps `.c8rc.cjs` the one declaration.
157
+ const allArgs = C8_CONFIG.all ? ['--all'] : [];
158
+
159
+ const reportRun = spawn(
110
160
  process.execPath,
111
161
  [
112
162
  C8_CLI,
113
163
  'report',
114
164
  '--reporter=json',
115
165
  '--reporter=text',
166
+ ...allArgs,
116
167
  '--temp-directory',
117
- V8_TMP,
168
+ v8Tmp,
118
169
  ...includeArgs,
119
170
  ...excludeArgs,
120
171
  ],
121
- { cwd: ROOT, stdio: 'inherit', shell: false },
172
+ { cwd: repoRoot, stdio: 'inherit', shell: false },
122
173
  );
123
174
 
124
- const checkRun = spawnSync(
175
+ const checkRun = spawn(
125
176
  process.execPath,
126
177
  [
127
- path.join(ROOT, '.agents', 'scripts', 'check-baselines.js'),
178
+ path.join(repoRoot, '.agents', 'scripts', 'check-baselines.js'),
128
179
  '--gate',
129
180
  'coverage',
130
181
  ],
131
- { cwd: ROOT, stdio: 'inherit' },
182
+ { cwd: repoRoot, stdio: 'inherit' },
132
183
  );
133
184
 
134
185
  return testRun.status !== 0
@@ -28,11 +28,20 @@ const tasks = [
28
28
  args: ['biome', 'ci', '.'],
29
29
  },
30
30
  {
31
+ // `docs/**/*.md` sat outside these globs until PR #4970's follow-up,
32
+ // so `npm run lint` reported "0 error(s)" while the close-time
33
+ // code-review lens — which lints the whole changed surface, not just
34
+ // what this driver globs — raised pre-existing `docs/` violations
35
+ // against whichever Story happened to touch the file. Keep `docs/`
36
+ // here so the two surfaces agree. `docs/CHANGELOG.md` is linted too;
37
+ // the generator-owned rules it can never satisfy are exempted by a
38
+ // `markdownlint-disable-file` directive in its own header.
31
39
  name: 'markdownlint',
32
40
  cmd: 'npx',
33
41
  args: [
34
42
  'markdownlint-cli2',
35
43
  '.agents/**/*.md',
44
+ 'docs/**/*.md',
36
45
  '*.md',
37
46
  '!node_modules/**',
38
47
  '!.worktrees/**',
@@ -79,6 +88,17 @@ const tasks = [
79
88
  cmd: 'node',
80
89
  args: ['.agents/scripts/lint-label-vocabulary.js'],
81
90
  },
91
+ {
92
+ // GitHub Actions job-timeout gate (Story #4936). Enumerates every
93
+ // `jobs.<id>` key across `.github/workflows/*.yml` and fails when one
94
+ // sets no `timeout-minutes` (inheriting GitHub's 360-minute default) or
95
+ // sets one above the ceiling. A deadlocked Windows job burned 44 minutes
96
+ // of a runner and withheld the failing required check's logs for the
97
+ // whole time; nothing but this check would have caught the gap.
98
+ name: 'workflow-timeouts',
99
+ cmd: 'node',
100
+ args: ['.agents/scripts/check-workflow-timeouts.js'],
101
+ },
82
102
  {
83
103
  // Architecture cycle ratchet (Story #3991). Detects directed import
84
104
  // cycles under `.agents/scripts/` and fails on any cycle not in the