mandrel 2.25.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 (131) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  41. package/.agents/scripts/lib/baselines/scope.js +39 -90
  42. package/.agents/scripts/lib/baselines/writer.js +16 -11
  43. package/.agents/scripts/lib/changed-files.js +8 -1
  44. package/.agents/scripts/lib/cli-args.js +115 -1
  45. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  46. package/.agents/scripts/lib/crap-engine.js +32 -13
  47. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  48. package/.agents/scripts/lib/crap-utils.js +13 -0
  49. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  50. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  51. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  53. package/.agents/scripts/lib/git-utils.js +136 -80
  54. package/.agents/scripts/lib/import-graph.js +156 -0
  55. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  56. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  57. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  58. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  62. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  64. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  65. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  66. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  67. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  68. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  69. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  70. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  71. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  72. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  73. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  78. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  79. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  80. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  82. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  83. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  84. package/.agents/scripts/lib/story-adjacency.js +3 -3
  85. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  86. package/.agents/scripts/lib/test-tiers.js +11 -2
  87. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  88. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  91. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  92. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  93. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  94. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  96. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  97. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  98. package/.agents/scripts/lib/worktree-manager.js +14 -0
  99. package/.agents/scripts/plan-run-epilogue.js +17 -5
  100. package/.agents/scripts/providers/github/tickets.js +33 -10
  101. package/.agents/scripts/provision-git-hooks.js +85 -0
  102. package/.agents/scripts/quality-preview.js +112 -28
  103. package/.agents/scripts/resolve-stories.js +4 -1
  104. package/.agents/scripts/run-coverage.js +86 -35
  105. package/.agents/scripts/run-lint.js +20 -0
  106. package/.agents/scripts/run-tests.js +26 -36
  107. package/.agents/scripts/single-story-close.js +28 -2
  108. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  109. package/.agents/scripts/stories-wave-tick.js +214 -38
  110. package/.agents/scripts/update-coverage-baseline.js +34 -4
  111. package/.agents/scripts/update-duplication-baseline.js +209 -83
  112. package/.agents/scripts/validate-docs-freshness.js +1 -0
  113. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  114. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  115. package/.agents/skills/skills.index.json +2 -2
  116. package/.agents/workflows/audit-baselines.md +289 -0
  117. package/.agents/workflows/audit-navigability.md +5 -4
  118. package/.agents/workflows/deliver.md +13 -4
  119. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  120. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  121. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  122. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  123. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  124. package/.agents/workflows/helpers/plan-reference.md +15 -5
  125. package/.agents/workflows/memory-consolidate.md +116 -0
  126. package/.agents/workflows/plan.md +3 -0
  127. package/README.md +13 -6
  128. package/docs/CHANGELOG.md +64 -0
  129. package/package.json +9 -4
  130. package/.agents/schemas/friction-event.schema.json +0 -56
  131. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -0,0 +1,206 @@
1
+ /**
2
+ * worktree/git-hooks.js
3
+ *
4
+ * Materialize the repository's git hooks directory into a linked worktree.
5
+ *
6
+ * `core.hooksPath` is resolved by git against **each working tree's own
7
+ * root**, not against the common git dir. A relative value therefore points
8
+ * at a directory that only exists in the checkout that generated it — husky
9
+ * writes `.husky/_` from its `prepare` script in the main checkout and
10
+ * self-ignores it — so every linked worktree resolves the hooks path to a
11
+ * directory that is not there. Git finds no hooks and proceeds silently:
12
+ * `commit-msg`, `pre-commit` and `pre-push` do not run for any commit made in
13
+ * a worktree, without anyone passing a bypass flag.
14
+ *
15
+ * This module closes that gap by copying the resolved hooks directory into
16
+ * the worktree at the same relative path. It is deliberately total about the
17
+ * cases where there is nothing to do — an unset or absolute `core.hooksPath`,
18
+ * or a source directory that does not exist — because a consumer project
19
+ * without husky must not fail worktree creation. Every other outcome either
20
+ * materializes the hooks or throws: a silent skip is the defect being fixed.
21
+ */
22
+
23
+ import { execFileSync } from 'node:child_process';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import * as defaultGit from '../git-utils.js';
27
+ import { assertPathContainment } from '../path-security.js';
28
+
29
+ // Skip reasons. Deliberately not exported: they are part of the result
30
+ // digest's observable contract, so callers and tests read them as the literal
31
+ // strings they are printed as, not through a symbol that could be renamed
32
+ // without anyone noticing the digest changed.
33
+ //
34
+ // hooks-path-unset — git uses the common `hooks` dir, already shared.
35
+ // hooks-path-absolute — resolves alike from every working tree already.
36
+ // source-absent — no hooks directory to mirror (no husky in this project).
37
+ // same-checkout — target is the source; copying it onto itself would
38
+ // destroy the only copy.
39
+ const SKIP_UNSET = 'hooks-path-unset';
40
+ const SKIP_ABSOLUTE = 'hooks-path-absolute';
41
+ const SKIP_SOURCE_ABSENT = 'source-absent';
42
+ const SKIP_SAME_CHECKOUT = 'same-checkout';
43
+
44
+ /**
45
+ * Read `core.hooksPath` as configured for `repoRoot`.
46
+ *
47
+ * `git config --get` exits non-zero when the key is unset, which is a
48
+ * legitimate state rather than an error, so this reports `null` for both an
49
+ * unset key and an empty value.
50
+ *
51
+ * @param {string} repoRoot
52
+ * @param {{ gitSpawn: Function }} gitImpl
53
+ * @returns {string|null}
54
+ */
55
+ function readHooksPath(repoRoot, gitImpl) {
56
+ const res = gitImpl.gitSpawn(repoRoot, 'config', '--get', 'core.hooksPath');
57
+ if (res.status !== 0) return null;
58
+ const value = (res.stdout ?? '').trim();
59
+ return value.length > 0 ? value : null;
60
+ }
61
+
62
+ /**
63
+ * Canonical form of `dir` for identity comparison.
64
+ *
65
+ * `realpathSync.native` is used so a symlinked temp root (macOS `/var` →
66
+ * `/private/var`) does not make a checkout look distinct from itself. A path
67
+ * that cannot be resolved falls back to `path.resolve`, which is enough for
68
+ * the comparison to stay conservative.
69
+ *
70
+ * @param {string} dir
71
+ * @param {typeof fs} fsImpl
72
+ * @returns {string}
73
+ */
74
+ function canonical(dir, fsImpl) {
75
+ const resolved = path.resolve(dir);
76
+ try {
77
+ return fsImpl.realpathSync.native(resolved);
78
+ } catch {
79
+ return resolved;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Names of the regular files directly inside `dir`, sorted.
85
+ *
86
+ * @param {string} dir
87
+ * @param {typeof fs} fsImpl
88
+ * @returns {string[]}
89
+ */
90
+ function hookFileNames(dir, fsImpl) {
91
+ return fsImpl
92
+ .readdirSync(dir, { withFileTypes: true })
93
+ .filter((ent) => ent.isFile())
94
+ .map((ent) => ent.name)
95
+ .sort();
96
+ }
97
+
98
+ /**
99
+ * Materialize `repoRoot`'s resolved git hooks directory into `worktree`.
100
+ *
101
+ * Idempotent, and refreshing rather than preserving: an existing target is
102
+ * replaced so an upgraded husky can never leave a stale shim behind.
103
+ *
104
+ * @param {object} opts
105
+ * @param {string} opts.repoRoot Absolute path to the checkout that owns the hooks.
106
+ * @param {string} opts.worktree Absolute path to the worktree to provision.
107
+ * @param {{ gitSpawn: Function }} [opts.gitImpl]
108
+ * @param {typeof fs} [opts.fsImpl]
109
+ * @returns {{ action: 'materialized' | 'skipped', reason?: string,
110
+ * hooksPath: string|null, source: string|null, target: string|null,
111
+ * hooks: string[] }}
112
+ * @throws {Error} when the source exists but the hooks could not be placed.
113
+ */
114
+ export function materializeGitHooks({
115
+ repoRoot,
116
+ worktree,
117
+ gitImpl = defaultGit,
118
+ fsImpl = fs,
119
+ } = {}) {
120
+ if (!repoRoot || typeof repoRoot !== 'string') {
121
+ throw new Error('worktree.hooks: repoRoot is required');
122
+ }
123
+ if (!worktree || typeof worktree !== 'string') {
124
+ throw new Error('worktree.hooks: worktree is required');
125
+ }
126
+
127
+ const skip = (reason, hooksPath = null) => ({
128
+ action: 'skipped',
129
+ reason,
130
+ hooksPath,
131
+ source: null,
132
+ target: null,
133
+ hooks: [],
134
+ });
135
+
136
+ const hooksPath = readHooksPath(repoRoot, gitImpl);
137
+ if (hooksPath === null) return skip(SKIP_UNSET);
138
+ if (path.isAbsolute(hooksPath)) return skip(SKIP_ABSOLUTE, hooksPath);
139
+
140
+ if (canonical(repoRoot, fsImpl) === canonical(worktree, fsImpl)) {
141
+ return skip(SKIP_SAME_CHECKOUT, hooksPath);
142
+ }
143
+
144
+ const source = path.resolve(repoRoot, hooksPath);
145
+ if (!fsImpl.existsSync(source)) return skip(SKIP_SOURCE_ABSENT, hooksPath);
146
+
147
+ const target = path.resolve(worktree, hooksPath);
148
+ // `core.hooksPath` is repo configuration, but it still reaches a recursive
149
+ // remove below — a `../..` value must not be able to delete outside the
150
+ // worktree it claims to provision.
151
+ assertPathContainment(
152
+ path.resolve(worktree),
153
+ target,
154
+ 'worktree.hooks: core.hooksPath',
155
+ { allowEmpty: false },
156
+ );
157
+
158
+ try {
159
+ fsImpl.rmSync(target, { recursive: true, force: true });
160
+ fsImpl.mkdirSync(path.dirname(target), { recursive: true });
161
+ fsImpl.cpSync(source, target, { recursive: true });
162
+ } catch (err) {
163
+ throw new Error(
164
+ `worktree.hooks: failed to materialize ${source} into ${target}: ${err.message}`,
165
+ );
166
+ }
167
+
168
+ // Verify rather than trust the copy. The whole point of this module is that
169
+ // a hooks directory which is absent behaves exactly like one that is
170
+ // present and empty — git runs neither, and says nothing either way.
171
+ const expected = hookFileNames(source, fsImpl);
172
+ const actual = new Set(
173
+ fsImpl.existsSync(target) ? hookFileNames(target, fsImpl) : [],
174
+ );
175
+ const missing = expected.filter((name) => !actual.has(name));
176
+ if (missing.length > 0) {
177
+ throw new Error(
178
+ `worktree.hooks: materialized ${target} is missing ${missing.length} hook(s): ${missing.join(', ')}`,
179
+ );
180
+ }
181
+
182
+ return { action: 'materialized', hooksPath, source, target, hooks: expected };
183
+ }
184
+
185
+ /**
186
+ * Resolve the checkout that owns the shared git config from `cwd`.
187
+ *
188
+ * `--git-common-dir` reports the common git dir from any working tree —
189
+ * `.git` from the main checkout, an absolute path from a linked worktree —
190
+ * so the owning checkout is its parent. This is what lets the standalone CLI
191
+ * be run from inside a worktree with no arguments.
192
+ *
193
+ * @param {string} cwd
194
+ * @param {{ execFileSyncImpl?: typeof execFileSync }} [deps]
195
+ * @returns {string}
196
+ */
197
+ export function resolveCommonCheckout(
198
+ cwd,
199
+ { execFileSyncImpl = execFileSync } = {},
200
+ ) {
201
+ const out = execFileSyncImpl('git', ['rev-parse', '--git-common-dir'], {
202
+ cwd,
203
+ encoding: 'utf8',
204
+ }).trim();
205
+ return path.dirname(path.resolve(cwd, out));
206
+ }
@@ -123,6 +123,12 @@ export async function ensure(ctx, storyId, branch) {
123
123
  applyNodeModulesStrategy(ctx, wtPath);
124
124
  phase('bootstrap');
125
125
  ctx.copyBootstrapFiles(wtPath);
126
+ // Unconditional, and deliberately not routed through `copyBootstrapFiles`:
127
+ // that path is driven by `bootstrapFiles` and its provisioner copies single
128
+ // files, while a hooks directory is a tree. A worktree without hooks accepts
129
+ // commits no gate ever sees, so this runs for every worktree the manager
130
+ // creates and throws rather than degrading.
131
+ ctx.provisionGitHooks(wtPath);
126
132
  phase('install');
127
133
  const installStatus = installDependencies(ctx, wtPath);
128
134
 
@@ -25,6 +25,7 @@ import {
25
25
  DEFAULT_WORKSPACE_FILES,
26
26
  provision as provisionWorkspace,
27
27
  } from './workspace-provisioner.js';
28
+ import { materializeGitHooks } from './worktree/git-hooks.js';
28
29
  import {
29
30
  maybeWarnWindowsPath,
30
31
  parseWorktreePorcelain,
@@ -161,6 +162,19 @@ export class WorktreeManager {
161
162
  logger: wrapped,
162
163
  });
163
164
  },
165
+ provisionGitHooks: (wtPath) => {
166
+ const result = materializeGitHooks({
167
+ repoRoot: this.repoRoot,
168
+ worktree: wtPath,
169
+ gitImpl: this.git,
170
+ });
171
+ this.logger.info(
172
+ result.action === 'materialized'
173
+ ? `worktree.bootstrap hooks materialized path=${result.target} hooks=${result.hooks.length}`
174
+ : `worktree.bootstrap hooks skipped reason=${result.reason}`,
175
+ );
176
+ return result;
177
+ },
164
178
  };
165
179
  }
166
180
 
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Usage:
7
7
  * node .agents/scripts/plan-run-epilogue.js --stories 1,2,3
8
+ * node .agents/scripts/plan-run-epilogue.js --stories 101-104 # inclusive range
8
9
  *
9
10
  * Keyed on the delivered id set: an `adhoc-<sorted-ids>` run id is
10
11
  * synthesized from `--stories`. Story #4540 retired the `--run <planRunId>`
@@ -19,6 +20,7 @@ import { resolveConfig } from './lib/config-resolver.js';
19
20
  import { Logger } from './lib/Logger.js';
20
21
  import { runPlanRunEpilogue } from './lib/orchestration/run-epilogue.js';
21
22
  import { createProvider } from './lib/provider-factory.js';
23
+ import { expandIdList } from './lib/util/parse-id-list.js';
22
24
 
23
25
  const CLI_OPTIONS = {
24
26
  stories: { type: 'string' },
@@ -65,10 +67,17 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
65
67
  // along with the label itself. The epilogue is keyed on the delivered id
66
68
  // set, and the synthesized `adhoc-<ids>` id it already used for positional
67
69
  // runs is now the only id it needs.
68
- const stories = values.stories
69
- .split(',')
70
- .map((s) => Number(s.trim()))
71
- .filter((n) => Number.isInteger(n) && n > 0);
70
+ // Range tokens expand here too (`--stories 101-104`): /deliver blesses the
71
+ // dash range at the operator surface, so the id set the epilogue is keyed on
72
+ // must read the same shape. Rejecting a bad token is deliberate — the old
73
+ // silent filter turned a typo into an empty, wrongly-keyed rollup.
74
+ const { ids: stories, error: storiesError } = expandIdList(values.stories, {
75
+ flag: '--stories',
76
+ prefix: '[plan-run-epilogue] ',
77
+ });
78
+ if (storiesError) {
79
+ throw new Error(storiesError);
80
+ }
72
81
 
73
82
  const planRunId = `adhoc-${[...stories].sort((a, b) => a - b).join('-')}`;
74
83
 
@@ -162,7 +171,10 @@ await runAsCli(import.meta.url, main, {
162
171
  summary:
163
172
  'Close out a delivery run: roll up the delivered Stories’ signals and report the run’s loop health.',
164
173
  flags: [
165
- ['--stories <ids>', 'Comma-separated delivered Story ids (required).'],
174
+ [
175
+ '--stories <ids>',
176
+ 'Comma-separated delivered Story ids, singles or A-B ranges (required).',
177
+ ],
166
178
  ['--cwd <path>', 'Repository root (default: process cwd).'],
167
179
  ],
168
180
  },
@@ -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,6 +235,7 @@ 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
 
@@ -239,21 +287,28 @@ export function computeExitCode(merged, miExit, crapExit) {
239
287
  }
240
288
 
241
289
  /**
242
- * Render the per-file delta table. Header columns match the AC verbatim:
243
- * "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.
244
296
  *
245
297
  * Pure — accepts pre-computed merge rows and returns a multi-line string. The
246
298
  * table renders even on a clean diff so operators see the "no drift" signal.
247
299
  *
248
- * @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
249
301
  * @returns {string}
250
302
  */
251
303
  export function renderTable(merged) {
304
+ const flag = Number.isFinite(Number(merged?.cyclomaticFlag))
305
+ ? Number(merged.cyclomaticFlag)
306
+ : DEFAULT_CYCLOMATIC_FLAG;
252
307
  const header = [
253
308
  'file',
254
309
  'MI delta',
255
310
  'worst CRAP delta',
256
- 'new-method count over c=8',
311
+ `new-method count over c=${flag}`,
257
312
  ];
258
313
  const lines = [];
259
314
  lines.push(`| ${header.join(' | ')} |`);
@@ -274,6 +329,31 @@ export function renderTable(merged) {
274
329
  return lines.join('\n');
275
330
  }
276
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
+
277
357
  /**
278
358
  * Top-level CLI entry: invoke both per-kind preview runners, merge, render,
279
359
  * and exit with the right code. Exposed as `runCli` so tests can drive the
@@ -319,7 +399,11 @@ export async function runCli({
319
399
  const crapExit = crapResult.exitCode;
320
400
  const miEnvelope = miResult.envelope;
321
401
  const crapEnvelope = crapResult.envelope;
322
- 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 });
323
407
 
324
408
  if (json) {
325
409
  stdout.write(