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.
- package/.agents/agents/acceptance-critic.md +10 -6
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +3 -1
- package/.agents/instructions.md +1 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +8 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +89 -6
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +38 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +26 -4
- package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -11
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/crap-engine.js +32 -13
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +13 -0
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
- package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
- package/.agents/scripts/lib/workers/crap-worker.js +2 -10
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +112 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +26 -36
- package/.agents/scripts/single-story-close.js +28 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/stories-wave-tick.js +214 -38
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/deliver.md +13 -4
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +77 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
- package/.agents/workflows/helpers/plan-reference.md +15 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +3 -0
- package/README.md +13 -6
- package/docs/CHANGELOG.md +64 -0
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- 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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
[
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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(
|
|
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
|
-
|
|
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.
|
|
243
|
-
* "file", "MI delta", "worst CRAP delta", "new-method count over c
|
|
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
|
-
|
|
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
|
-
|
|
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(
|