mandrel 2.36.0 → 2.37.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/docs/configuration.md +25 -0
- package/.agents/rules/testing-standards.md +56 -0
- package/.agents/scripts/boot-sweep.js +7 -1
- package/.agents/scripts/check-context-budget.js +195 -103
- package/.agents/scripts/check-cyclomatic.js +112 -42
- package/.agents/scripts/check-generated-validator.js +202 -0
- package/.agents/scripts/check-lifecycle-lint.js +2 -68
- package/.agents/scripts/check-schema-references.js +2 -28
- package/.agents/scripts/drain-pending-cleanup.js +18 -1
- package/.agents/scripts/lib/baselines/components.js +32 -2
- package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
- package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
- package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
- package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
- package/.agents/scripts/lib/checks/loop-health.js +1 -1
- package/.agents/scripts/lib/cli-args.js +0 -2
- package/.agents/scripts/lib/close-validation/gates.js +1 -1
- package/.agents/scripts/lib/close-validation/process.js +1 -1
- package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
- package/.agents/scripts/lib/config-settings-schema.js +46 -6
- package/.agents/scripts/lib/coverage-baseline.js +2 -2
- package/.agents/scripts/lib/cpu-pool.js +90 -10
- package/.agents/scripts/lib/crap-utils.js +6 -2
- package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
- package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
- package/.agents/scripts/lib/dependency-version.js +86 -0
- package/.agents/scripts/lib/duplicate-search.js +1 -1
- package/.agents/scripts/lib/errors/index.js +1 -1
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
- package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
- package/.agents/scripts/lib/install-cmd-parser.js +1 -1
- package/.agents/scripts/lib/knip-entry-sync.js +1 -57
- package/.agents/scripts/lib/maintainability-utils.js +108 -10
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
- package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
- package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
- package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
- package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
- package/.agents/scripts/lib/preflight-runner.js +3 -3
- package/.agents/scripts/lib/qa/qa-session.js +1 -1
- package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
- package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
- package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
- package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
- package/.agents/scripts/lib/test-isolate/runner.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +14 -6
- package/.agents/scripts/lib/test-tiers.js +135 -18
- package/.agents/scripts/lib/transpile.js +16 -3
- package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
- package/.agents/scripts/pr-watch-with-update.js +305 -137
- package/.agents/scripts/providers/github/tickets.js +109 -16
- package/.agents/scripts/quality-preview.js +162 -70
- package/.agents/scripts/run-test-profile.js +8 -5
- package/.agents/scripts/run-tests.js +79 -14
- package/.agents/scripts/single-story-close.js +0 -2
- package/.agents/scripts/single-story-init.js +5 -1
- package/docs/CHANGELOG.md +32 -0
- package/lib/cli/migrate.js +100 -94
- package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
- package/lib/migrations/index.js +32 -33
- package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
- package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
- package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
- package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
- package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
- package/package.json +7 -3
- package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
- package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
- package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
- package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
- package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
- package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
- package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
- package/.agents/scripts/lib/orchestration/recut.js +0 -56
|
@@ -17,7 +17,7 @@ import { spawnSync as defaultSpawnSync } from 'node:child_process';
|
|
|
17
17
|
* @param {string} installCmd
|
|
18
18
|
* @returns {{ bin: string, args: string[], shell: boolean }}
|
|
19
19
|
*/
|
|
20
|
-
|
|
20
|
+
function parseInstallCmd(installCmd) {
|
|
21
21
|
const tokens = String(installCmd ?? '')
|
|
22
22
|
.trim()
|
|
23
23
|
.split(/\s+/)
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
resolveKnipEntryPatterns,
|
|
60
60
|
__testing as resolverTesting,
|
|
61
61
|
} from './knip-config-resolver.js';
|
|
62
|
+
import { stripJsComments } from './source-text/strip-js-comments.js';
|
|
62
63
|
|
|
63
64
|
// Only `resolveEntrySync` and `renderEntrySyncReport` are public — they are
|
|
64
65
|
// what `check-knip-entries.js` calls. Everything else is module-private and
|
|
@@ -89,63 +90,6 @@ const INVOCATION_SURFACES = Object.freeze([
|
|
|
89
90
|
{ kind: 'dir', at: SCRIPTS_DIR, test: /\.js$/ },
|
|
90
91
|
]);
|
|
91
92
|
|
|
92
|
-
/**
|
|
93
|
-
* Strip line and block comments from JavaScript source, preserving string and
|
|
94
|
-
* template literals so a `'https://…'` or a `` `${x}//y` `` is not mangled.
|
|
95
|
-
*
|
|
96
|
-
* Comments are stripped before scanning `.js` surfaces so a JSDoc paragraph
|
|
97
|
-
* that merely *names* a CLI ("superseded by `node .agents/scripts/foo.js`")
|
|
98
|
-
* cannot confer liveness on it. Several such mentions exist today; every one
|
|
99
|
-
* of them is prose about a script's internals, not a call.
|
|
100
|
-
*
|
|
101
|
-
* Replaces comment bodies with equivalent whitespace rather than deleting
|
|
102
|
-
* them, so byte offsets and line numbers survive for any future caller that
|
|
103
|
-
* wants to report a position.
|
|
104
|
-
*
|
|
105
|
-
* @param {string} source
|
|
106
|
-
* @returns {string}
|
|
107
|
-
*/
|
|
108
|
-
function stripJsComments(source) {
|
|
109
|
-
const text = String(source ?? '');
|
|
110
|
-
let out = '';
|
|
111
|
-
let i = 0;
|
|
112
|
-
const blank = (s) => s.replace(/[^\n]/g, ' ');
|
|
113
|
-
while (i < text.length) {
|
|
114
|
-
const two = text.slice(i, i + 2);
|
|
115
|
-
if (two === '//') {
|
|
116
|
-
const end = text.indexOf('\n', i);
|
|
117
|
-
const stop = end === -1 ? text.length : end;
|
|
118
|
-
out += blank(text.slice(i, stop));
|
|
119
|
-
i = stop;
|
|
120
|
-
} else if (two === '/*') {
|
|
121
|
-
const end = text.indexOf('*/', i + 2);
|
|
122
|
-
const stop = end === -1 ? text.length : end + 2;
|
|
123
|
-
out += blank(text.slice(i, stop));
|
|
124
|
-
i = stop;
|
|
125
|
-
} else {
|
|
126
|
-
const ch = text[i];
|
|
127
|
-
if (ch === "'" || ch === '"' || ch === '`') {
|
|
128
|
-
let j = i + 1;
|
|
129
|
-
while (j < text.length) {
|
|
130
|
-
if (text[j] === '\\') {
|
|
131
|
-
j += 2;
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
if (text[j] === ch) break;
|
|
135
|
-
j += 1;
|
|
136
|
-
}
|
|
137
|
-
const stop = Math.min(j + 1, text.length);
|
|
138
|
-
out += text.slice(i, stop);
|
|
139
|
-
i = stop;
|
|
140
|
-
} else {
|
|
141
|
-
out += ch;
|
|
142
|
-
i += 1;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return out;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
93
|
/**
|
|
150
94
|
* Recursively list files under `dir`, skipping `node_modules` and `.git`.
|
|
151
95
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { Minimatch } from 'minimatch';
|
|
4
4
|
import { canonicalise as canonicalisePath } from './baselines/path-canon.js';
|
|
5
5
|
import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
|
|
6
6
|
import { Logger } from './Logger.js';
|
|
@@ -27,6 +27,57 @@ const IGNORED_DIRS = new Set([
|
|
|
27
27
|
'.next',
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Compiled-matcher cache for `isIgnoredByGlobs`, keyed on the pattern list.
|
|
32
|
+
*
|
|
33
|
+
* `minimatch(path, glob)` re-parses `glob` into an AST on every call, so the
|
|
34
|
+
* scan was paying O(files × globs) glob *compilations* to answer O(files ×
|
|
35
|
+
* globs) glob *matches* — 12–17 ms over the 619-file tree against 0.5 ms
|
|
36
|
+
* once the patterns are compiled (Story #5109). The number of distinct
|
|
37
|
+
* pattern lists in a process is tiny (one per gate), and each list is
|
|
38
|
+
* config-derived and immutable, so an unbounded `Map` keyed on the joined
|
|
39
|
+
* patterns is bounded in practice.
|
|
40
|
+
*
|
|
41
|
+
* Each entry also memoises the per-path verdict. The baseline gates ask the
|
|
42
|
+
* same question about the same path many times over — `crap.json` carries
|
|
43
|
+
* thousands of method rows spread over a few hundred files, and the evaluate
|
|
44
|
+
* phase filters every row — so the second and later asks about a path cost a
|
|
45
|
+
* `Map` lookup instead of a globstar walk. Both caches are keyed on
|
|
46
|
+
* config-derived, immutable inputs, and both are bounded by the tree the
|
|
47
|
+
* process is scanning.
|
|
48
|
+
*
|
|
49
|
+
* `\u0000` is the join separator because it cannot occur in a glob, so two
|
|
50
|
+
* different lists cannot collide onto one key.
|
|
51
|
+
*
|
|
52
|
+
* @type {Map<string, {matchers: import('minimatch').Minimatch[], verdicts: Map<string, boolean>}>}
|
|
53
|
+
*/
|
|
54
|
+
const IGNORE_MATCHER_CACHE = new Map();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compile (once) the `Minimatch` instances for a pattern list, alongside the
|
|
58
|
+
* per-path verdict memo that shares their lifetime.
|
|
59
|
+
*
|
|
60
|
+
* Non-string patterns are dropped rather than compiled — `minimatch()` would
|
|
61
|
+
* have thrown on them, and the functional call site never fed it any, so
|
|
62
|
+
* dropping preserves the observable result set.
|
|
63
|
+
*
|
|
64
|
+
* @param {string[]} ignoreGlobs
|
|
65
|
+
* @returns {{matchers: import('minimatch').Minimatch[], verdicts: Map<string, boolean>}}
|
|
66
|
+
*/
|
|
67
|
+
function ignoreMatcherEntry(ignoreGlobs) {
|
|
68
|
+
const key = ignoreGlobs.join('\u0000');
|
|
69
|
+
let entry = IGNORE_MATCHER_CACHE.get(key);
|
|
70
|
+
if (entry) return entry;
|
|
71
|
+
entry = {
|
|
72
|
+
matchers: ignoreGlobs
|
|
73
|
+
.filter((g) => typeof g === 'string')
|
|
74
|
+
.map((g) => new Minimatch(g, { dot: true })),
|
|
75
|
+
verdicts: new Map(),
|
|
76
|
+
};
|
|
77
|
+
IGNORE_MATCHER_CACHE.set(key, entry);
|
|
78
|
+
return entry;
|
|
79
|
+
}
|
|
80
|
+
|
|
30
81
|
/**
|
|
31
82
|
* Test whether an absolute (or repo-relative) file path matches any of the
|
|
32
83
|
* configured `ignoreGlobs`. This is the single source of truth for how the
|
|
@@ -39,22 +90,62 @@ const IGNORED_DIRS = new Set([
|
|
|
39
90
|
*
|
|
40
91
|
* Matching mirrors `scanDirectory`: the path is reduced to a canonicalised,
|
|
41
92
|
* POSIX, repo-relative form and tested against each glob with minimatch's
|
|
42
|
-
* `{ dot: true }` so dot-prefixed roots like `.agents/` match.
|
|
93
|
+
* `{ dot: true }` so dot-prefixed roots like `.agents/` match. The patterns
|
|
94
|
+
* are compiled at most once per distinct list (see `IGNORE_MATCHER_CACHE`);
|
|
95
|
+
* the matched set is identical to the functional `minimatch()` call this
|
|
96
|
+
* replaced, which is what the `gate-scan-fast-path` test pins over the real
|
|
97
|
+
* configured `ignoreGlobs`.
|
|
43
98
|
*
|
|
44
99
|
* @param {string} filePath absolute or relative path to the source file
|
|
45
100
|
* @param {string[]} ignoreGlobs minimatch patterns; empty/absent is a no-op
|
|
46
101
|
* @param {string} [cwd] root for repo-relative resolution; defaults to cwd
|
|
47
102
|
* @returns {boolean} true when the file matches at least one ignore glob
|
|
48
103
|
*/
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Reduce an absolute-or-relative path to the canonicalised, POSIX,
|
|
106
|
+
* repo-relative form the ignore patterns are written against.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} filePath
|
|
109
|
+
* @param {string} matchCwd
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
function canonicalRelPath(filePath, matchCwd) {
|
|
52
113
|
const absFilePath = path.isAbsolute(filePath)
|
|
53
114
|
? filePath
|
|
54
115
|
: path.resolve(matchCwd, filePath);
|
|
55
116
|
const rawRel = path.relative(matchCwd, absFilePath).replace(/\\/g, '/');
|
|
56
|
-
|
|
57
|
-
|
|
117
|
+
return canonicalisePath(rawRel);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Answer "does this path match any of the entry's patterns", consulting and
|
|
122
|
+
* populating the entry's verdict memo.
|
|
123
|
+
*
|
|
124
|
+
* Split out of `isIgnoredByGlobs` rather than inlined: the memo's
|
|
125
|
+
* hit/miss test is a branch, and folding it into the caller pushed that
|
|
126
|
+
* function from cyclomatic 4 to 5 — over the per-method CRAP contract the
|
|
127
|
+
* pre-push preview enforces. The lookup belongs beside the cache it reads
|
|
128
|
+
* anyway, and the hot function keeps a flat shape.
|
|
129
|
+
*
|
|
130
|
+
* @param {{matchers: import('minimatch').Minimatch[], verdicts: Map<string, boolean>}} entry
|
|
131
|
+
* @param {string} relPath Canonicalised, POSIX, repo-relative path.
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
function memoisedIgnoreVerdict(entry, relPath) {
|
|
135
|
+
const memoised = entry.verdicts.get(relPath);
|
|
136
|
+
if (memoised !== undefined) return memoised;
|
|
137
|
+
const verdict = entry.matchers.some((m) => m.match(relPath));
|
|
138
|
+
entry.verdicts.set(relPath, verdict);
|
|
139
|
+
return verdict;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function isIgnoredByGlobs(filePath, ignoreGlobs = [], cwd) {
|
|
143
|
+
if (!Array.isArray(ignoreGlobs) || ignoreGlobs.length === 0) return false;
|
|
144
|
+
const matchCwd = cwd ?? process.cwd();
|
|
145
|
+
return memoisedIgnoreVerdict(
|
|
146
|
+
ignoreMatcherEntry(ignoreGlobs),
|
|
147
|
+
canonicalRelPath(filePath, matchCwd),
|
|
148
|
+
);
|
|
58
149
|
}
|
|
59
150
|
|
|
60
151
|
/**
|
|
@@ -107,7 +198,7 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
|
|
|
107
198
|
* Calculates maintainability scores for a list of file paths.
|
|
108
199
|
*
|
|
109
200
|
* Each file's transpile-then-analyze unit is dispatched to a
|
|
110
|
-
* worker_threads pool
|
|
201
|
+
* worker_threads pool whose width `runOnPool` resolves. Workers
|
|
111
202
|
* are recycled across files so TypeScript loads at most once per
|
|
112
203
|
* worker. The pool is bypassed for batches of fewer than
|
|
113
204
|
* `SERIAL_THRESHOLD` files because spawn overhead dominates at small
|
|
@@ -129,9 +220,16 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
|
|
|
129
220
|
* and nothing said so.
|
|
130
221
|
*
|
|
131
222
|
* @param {string[]} paths
|
|
223
|
+
* @param {{serialThreshold?: number}} [opts] `serialThreshold` overrides the
|
|
224
|
+
* pool-vs-serial cutover for this call only. Production callers omit it;
|
|
225
|
+
* the parity tests use it to drive the pooled path on a small fixture set
|
|
226
|
+
* rather than materialising 256 files to clear the cutover.
|
|
132
227
|
* @returns {Promise<Record<string, number>>}
|
|
133
228
|
*/
|
|
134
|
-
export async function calculateAll(paths) {
|
|
229
|
+
export async function calculateAll(paths, opts = {}) {
|
|
230
|
+
const serialThreshold = Number.isFinite(opts?.serialThreshold)
|
|
231
|
+
? opts.serialThreshold
|
|
232
|
+
: SERIAL_THRESHOLD;
|
|
135
233
|
const cwd = process.cwd();
|
|
136
234
|
const indexed = paths.map((p) => ({
|
|
137
235
|
abs: p,
|
|
@@ -139,7 +237,7 @@ export async function calculateAll(paths) {
|
|
|
139
237
|
}));
|
|
140
238
|
|
|
141
239
|
let perFile;
|
|
142
|
-
if (indexed.length <
|
|
240
|
+
if (indexed.length < serialThreshold) {
|
|
143
241
|
perFile = indexed.map(({ abs, relPath }) => {
|
|
144
242
|
try {
|
|
145
243
|
return { relPath, ...scoreFile(abs) };
|
|
@@ -197,6 +197,26 @@ function planFingerprintMarker(fingerprint) {
|
|
|
197
197
|
return `<!-- ${PLAN_FINGERPRINT_MARKER_PREFIX} ${fingerprint} -->`;
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
/** The one parser for the marker {@link planFingerprintMarker} renders. */
|
|
201
|
+
const PLAN_FINGERPRINT_MARKER_RE = new RegExp(
|
|
202
|
+
`<!--\\s*${PLAN_FINGERPRINT_MARKER_PREFIX}\\s*([0-9a-f]+)\\s*-->`,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Recover the plan fingerprint an issue body was stamped with, or `null` when
|
|
207
|
+
* the body carries no marker. Module-private: both readers of the marker (the
|
|
208
|
+
* resume index and the create-retry adoption probe) go through it so they can
|
|
209
|
+
* never drift into recognising different Stories as "already created".
|
|
210
|
+
*
|
|
211
|
+
* @param {unknown} body
|
|
212
|
+
* @returns {string|null}
|
|
213
|
+
*/
|
|
214
|
+
function extractPlanFingerprint(body) {
|
|
215
|
+
if (typeof body !== 'string') return null;
|
|
216
|
+
const match = body.match(PLAN_FINGERPRINT_MARKER_RE);
|
|
217
|
+
return match ? match[1] : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
200
220
|
/**
|
|
201
221
|
* Labels the authoring pass is never allowed to set. The `agent::*` axis is
|
|
202
222
|
* the runtime's lifecycle state (persist owns the terminal `agent::ready`
|
|
@@ -617,14 +637,9 @@ async function indexExistingStories(provider) {
|
|
|
617
637
|
if (title !== '') {
|
|
618
638
|
idsByTitle.set(title, [...(idsByTitle.get(title) ?? []), id]);
|
|
619
639
|
}
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
`<!--\\s*${PLAN_FINGERPRINT_MARKER_PREFIX}\\s*([0-9a-f]+)\\s*-->`,
|
|
624
|
-
),
|
|
625
|
-
);
|
|
626
|
-
if (!match) continue;
|
|
627
|
-
byFingerprint.set(match[1], {
|
|
640
|
+
const fingerprint = extractPlanFingerprint(issue?.body);
|
|
641
|
+
if (!fingerprint) continue;
|
|
642
|
+
byFingerprint.set(fingerprint, {
|
|
628
643
|
id,
|
|
629
644
|
title,
|
|
630
645
|
url: issue.html_url ?? issue.url ?? undefined,
|
|
@@ -633,6 +648,42 @@ async function indexExistingStories(provider) {
|
|
|
633
648
|
return { byFingerprint, idsByTitle };
|
|
634
649
|
}
|
|
635
650
|
|
|
651
|
+
/**
|
|
652
|
+
* Re-run the resume lookup for a single fingerprint and return the **raw**
|
|
653
|
+
* issue, or `null`.
|
|
654
|
+
*
|
|
655
|
+
* This is the probe `createIssue` calls before any retry POST (Story #5112).
|
|
656
|
+
* A create whose response was lost has already filed the issue; retrying
|
|
657
|
+
* blind duplicates it. Because the body posted carries the fingerprint
|
|
658
|
+
* marker, the same content-keyed lookup the resume path uses answers "did
|
|
659
|
+
* attempt 1 land?" authoritatively — from the server's state, not from a
|
|
660
|
+
* client-side guess about where the connection broke.
|
|
661
|
+
*
|
|
662
|
+
* Best-effort like {@link indexExistingStories}: a provider without the
|
|
663
|
+
* listing surface, or a listing that throws, yields `null` and the retry
|
|
664
|
+
* proceeds exactly as it did before.
|
|
665
|
+
*
|
|
666
|
+
* @param {{ provider: object, fingerprint: string }} args
|
|
667
|
+
* @returns {Promise<object|null>}
|
|
668
|
+
*/
|
|
669
|
+
async function findOpenStoryByPlanFingerprint({ provider, fingerprint }) {
|
|
670
|
+
if (typeof provider?.listIssuesByLabel !== 'function') return null;
|
|
671
|
+
if (typeof fingerprint !== 'string' || fingerprint.length === 0) return null;
|
|
672
|
+
let issues;
|
|
673
|
+
try {
|
|
674
|
+
issues = await provider.listIssuesByLabel({
|
|
675
|
+
state: 'open',
|
|
676
|
+
labels: TYPE_LABELS.STORY,
|
|
677
|
+
});
|
|
678
|
+
} catch {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
for (const issue of Array.isArray(issues) ? issues : []) {
|
|
682
|
+
if (extractPlanFingerprint(issue?.body) === fingerprint) return issue;
|
|
683
|
+
}
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
|
|
636
687
|
/**
|
|
637
688
|
* Warn when a Story with this title is already open but did **not** match the
|
|
638
689
|
* fingerprint — i.e. its authored content differs from what this run is about
|
|
@@ -1000,6 +1051,14 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
|
|
|
1000
1051
|
...(applyCohortLabel ? [cohortLabel] : []),
|
|
1001
1052
|
...(applyRouteLabel ? [routeLabel] : []),
|
|
1002
1053
|
],
|
|
1054
|
+
// Story #5112 — hand the provider the same content-keyed lookup this
|
|
1055
|
+
// loop's resume path uses, so a retry after a lost response adopts the
|
|
1056
|
+
// issue attempt 1 already filed instead of creating a twin.
|
|
1057
|
+
findExisting: () =>
|
|
1058
|
+
findOpenStoryByPlanFingerprint({
|
|
1059
|
+
provider,
|
|
1060
|
+
fingerprint: story.fingerprint,
|
|
1061
|
+
}),
|
|
1003
1062
|
});
|
|
1004
1063
|
const id = result?.id ?? result?.number;
|
|
1005
1064
|
if (!Number.isInteger(id)) {
|
|
@@ -1012,7 +1071,9 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
|
|
|
1012
1071
|
id,
|
|
1013
1072
|
title: story.title,
|
|
1014
1073
|
url: result.url,
|
|
1015
|
-
adopted
|
|
1074
|
+
// True when the provider's retry probe adopted an issue a lost-response
|
|
1075
|
+
// first attempt had already filed — pre-existing either way.
|
|
1076
|
+
adopted: result.adopted === true,
|
|
1016
1077
|
});
|
|
1017
1078
|
idBySlug.set(story.slug, id);
|
|
1018
1079
|
}
|
|
@@ -50,35 +50,6 @@ import { applyBehindUpdate } from './behind-recovery.js';
|
|
|
50
50
|
* values collapse to `'skipped'` so any future GitHub state we haven't
|
|
51
51
|
* enumerated still maps into the vocabulary.
|
|
52
52
|
*/
|
|
53
|
-
/**
|
|
54
|
-
* The raw check-state tokens `normalizeCheckState` recognizes (lowercased).
|
|
55
|
-
* A token absent from this set is one we have NOT enumerated — the watch
|
|
56
|
-
* path collapses it to `'skipped'` (validate-anything), but a fail-closed
|
|
57
|
-
* consumer (the auto-merge arming probe) must treat it as unknown-therefore-
|
|
58
|
-
* blocking rather than trust the `'skipped'` collapse. Exported so that
|
|
59
|
-
* stricter consumer lives here as the single vocabulary owner.
|
|
60
|
-
*/
|
|
61
|
-
export const RECOGNIZED_CHECK_STATES = Object.freeze(
|
|
62
|
-
new Set([
|
|
63
|
-
'',
|
|
64
|
-
'pending',
|
|
65
|
-
'queued',
|
|
66
|
-
'in_progress',
|
|
67
|
-
'requested',
|
|
68
|
-
'waiting',
|
|
69
|
-
'success',
|
|
70
|
-
'completed',
|
|
71
|
-
'failure',
|
|
72
|
-
'startup_failure',
|
|
73
|
-
'neutral',
|
|
74
|
-
'cancelled',
|
|
75
|
-
'timed_out',
|
|
76
|
-
'action_required',
|
|
77
|
-
'stale',
|
|
78
|
-
'skipped',
|
|
79
|
-
]),
|
|
80
|
-
);
|
|
81
|
-
|
|
82
53
|
export function normalizeCheckState(raw) {
|
|
83
54
|
const v = String(raw ?? '')
|
|
84
55
|
.trim()
|
|
@@ -325,7 +296,7 @@ export function allTerminal(outcomes) {
|
|
|
325
296
|
* red check): a `'still-running'` map means "re-arm the watch / hand off
|
|
326
297
|
* to `/loop`," never "the change is broken."
|
|
327
298
|
*/
|
|
328
|
-
|
|
299
|
+
const STILL_RUNNING = 'still-running';
|
|
329
300
|
|
|
330
301
|
/**
|
|
331
302
|
* Promote any leftover `'pending'` outcomes to the schema-valid
|
|
@@ -38,14 +38,14 @@ import { ColumnSync, columnForLabels } from './column-sync.js';
|
|
|
38
38
|
* long enough to outlast the typical GitHub built-in workflow fire
|
|
39
39
|
* timing without ballooning the close-path wall clock.
|
|
40
40
|
*/
|
|
41
|
-
|
|
41
|
+
const DEFAULT_POLL_ATTEMPTS = 4;
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Default delay between drift checks (ms). Five seconds is a sweet
|
|
45
45
|
* spot: short enough that the operator doesn't perceive the close
|
|
46
46
|
* path as hung, long enough that one re-fire usually wins the race.
|
|
47
47
|
*/
|
|
48
|
-
|
|
48
|
+
const DEFAULT_POLL_DELAY_MS = 5000;
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Production sleep function. Tests inject `sleepFn: () => Promise.resolve()`
|
|
@@ -57,7 +57,7 @@ export const CODEX_REMEDIATIONS = Object.freeze({
|
|
|
57
57
|
*
|
|
58
58
|
* Exported so tests can extend the list without monkey-patching `os`.
|
|
59
59
|
*/
|
|
60
|
-
|
|
60
|
+
const DEFAULT_PLUGIN_MARKERS = Object.freeze([
|
|
61
61
|
path.join(os.homedir(), '.claude', 'plugins', 'codex-plugin-cc'),
|
|
62
62
|
path.join(os.homedir(), '.claude', 'plugins', 'openai', 'codex-plugin-cc'),
|
|
63
63
|
]);
|
|
@@ -194,7 +194,7 @@ export function parseCodexFindings(rawStdout) {
|
|
|
194
194
|
* @param {{ baseRef: string, headRef: string, depth?: import('./types.js').ReviewDepth }} args
|
|
195
195
|
* @returns {string}
|
|
196
196
|
*/
|
|
197
|
-
|
|
197
|
+
function buildCodexReviewPrompt({ baseRef, headRef, depth }) {
|
|
198
198
|
return (
|
|
199
199
|
`/codex:review --base ${baseRef} --head ${headRef} --wait ` +
|
|
200
200
|
`${renderDepthDirective(depth)}`
|
|
@@ -214,7 +214,7 @@ export function buildCodexReviewPrompt({ baseRef, headRef, depth }) {
|
|
|
214
214
|
* @param {{ baseRef: string, headRef: string, depth?: import('./types.js').ReviewDepth }} args
|
|
215
215
|
* @returns {{ status: number, stdout: string, stderr: string }}
|
|
216
216
|
*/
|
|
217
|
-
|
|
217
|
+
function defaultInvokeCodexReview({ baseRef, headRef, depth }) {
|
|
218
218
|
const cliArgs = [
|
|
219
219
|
'--print',
|
|
220
220
|
buildCodexReviewPrompt({ baseRef, headRef, depth }),
|
|
@@ -102,7 +102,7 @@ export function renderFinding(finding) {
|
|
|
102
102
|
* @param {ReadonlyArray<string>} messages
|
|
103
103
|
* @returns {string[]} lines to append (empty when no messages)
|
|
104
104
|
*/
|
|
105
|
-
|
|
105
|
+
function renderManualPromptsSection(messages) {
|
|
106
106
|
const filtered = Array.isArray(messages)
|
|
107
107
|
? messages.filter((m) => typeof m === 'string' && m.trim().length > 0)
|
|
108
108
|
: [];
|
|
@@ -267,8 +267,14 @@ function isJsMaintainabilityFile(relPath) {
|
|
|
267
267
|
* `reportFn` forces the serial path (the injected scorer cannot cross the
|
|
268
268
|
* worker boundary).
|
|
269
269
|
*
|
|
270
|
+
* `serialThreshold` overrides the pool-vs-serial cutover for one call. It is
|
|
271
|
+
* the seam the parity test uses to drive the pooled branch: Story #5109
|
|
272
|
+
* retuned `POOL_SERIAL_THRESHOLD` from 8 to 256 against measured data, and a
|
|
273
|
+
* fixture sized to clear the old number would otherwise have gone quietly
|
|
274
|
+
* serial while still claiming to cover the pool.
|
|
275
|
+
*
|
|
270
276
|
* @param {string[]} changedFiles
|
|
271
|
-
* @param {{ reportFn?: Function, classifier?: Function, runOnPoolFn?: typeof runOnPool, headRef?: string|null, gitSpawnFn?: typeof gitSpawn, readHeadSourceFn?: typeof readHeadSource }} [deps]
|
|
277
|
+
* @param {{ reportFn?: Function, classifier?: Function, runOnPoolFn?: typeof runOnPool, headRef?: string|null, gitSpawnFn?: typeof gitSpawn, readHeadSourceFn?: typeof readHeadSource, serialThreshold?: number }} [deps]
|
|
272
278
|
* @returns {Promise<{ totalFiles: number, jsFiles: number, maintainability: object[], criticalFindings: Finding[], mediumFindings: Finding[] }>}
|
|
273
279
|
*/
|
|
274
280
|
export async function analyzeChangedFiles(
|
|
@@ -280,6 +286,7 @@ export async function analyzeChangedFiles(
|
|
|
280
286
|
headRef = null,
|
|
281
287
|
gitSpawnFn = gitSpawn,
|
|
282
288
|
readHeadSourceFn = readHeadSource,
|
|
289
|
+
serialThreshold = SERIAL_THRESHOLD,
|
|
283
290
|
} = {},
|
|
284
291
|
) {
|
|
285
292
|
const results = {
|
|
@@ -308,7 +315,7 @@ export async function analyzeChangedFiles(
|
|
|
308
315
|
const customReportFn = reportFn != null;
|
|
309
316
|
|
|
310
317
|
// Serial path: small batches, or whenever a caller injects its own scorer.
|
|
311
|
-
if (jsFiles.length <
|
|
318
|
+
if (jsFiles.length < serialThreshold || customReportFn) {
|
|
312
319
|
for (let i = 0; i < jsFiles.length; i += 1) {
|
|
313
320
|
const relPath = jsFiles[i];
|
|
314
321
|
const source = sources[i];
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*
|
|
29
29
|
* @type {Readonly<Record<ReviewDepth, string>>}
|
|
30
30
|
*/
|
|
31
|
-
|
|
31
|
+
const DEPTH_DIRECTIVES = Object.freeze({
|
|
32
32
|
light:
|
|
33
33
|
'Review depth: LIGHT. Run a single pass focused on spec adherence over the ' +
|
|
34
34
|
'changed surface — confirm the change matches its stated intent. Reduce the ' +
|
|
@@ -54,7 +54,7 @@ export const DEPTH_DIRECTIVES = Object.freeze({
|
|
|
54
54
|
* @param {unknown} depth
|
|
55
55
|
* @returns {ReviewDepth}
|
|
56
56
|
*/
|
|
57
|
-
|
|
57
|
+
function normalizeDepth(depth) {
|
|
58
58
|
return depth === 'light' || depth === 'deep' ? depth : 'standard';
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -59,7 +59,7 @@ export const SECURITY_REVIEW_REMEDIATIONS = Object.freeze({
|
|
|
59
59
|
* @param {{ spawnFn?: typeof spawnSync }} [opts]
|
|
60
60
|
* @returns {boolean}
|
|
61
61
|
*/
|
|
62
|
-
|
|
62
|
+
function defaultProbeClaudeCli(opts = {}) {
|
|
63
63
|
const spawnFn = opts.spawnFn ?? spawnSync;
|
|
64
64
|
try {
|
|
65
65
|
const result = spawnFn('claude', ['--version'], {
|
|
@@ -162,7 +162,7 @@ export function parseSecurityReviewFindings(rawStdout) {
|
|
|
162
162
|
* (Story #3937) so a high-risk Epic instructs the model toward a deeper
|
|
163
163
|
* second-pass review while a low-risk one keeps it light.
|
|
164
164
|
*/
|
|
165
|
-
|
|
165
|
+
const SECURITY_REVIEW_INVOKE_PROMPT =
|
|
166
166
|
'Run /security-review against the diff `{baseRef}`...`{headRef}` ' +
|
|
167
167
|
'for {scopeLabel} #{ticketId}. {depthDirective} After the review, emit ' +
|
|
168
168
|
'ONLY a JSON array of findings on stdout with this exact shape:\n\n' +
|
|
@@ -208,7 +208,7 @@ export function buildSecurityReviewPrompt(input) {
|
|
|
208
208
|
* @param {ReviewInput} input
|
|
209
209
|
* @returns {{ status: number, stdout: string, stderr: string }}
|
|
210
210
|
*/
|
|
211
|
-
|
|
211
|
+
function defaultInvokeSecurityReview(input) {
|
|
212
212
|
const prompt = buildSecurityReviewPrompt(input);
|
|
213
213
|
const result = spawnSync('claude', ['--print', prompt], {
|
|
214
214
|
encoding: 'utf-8',
|
|
@@ -87,6 +87,50 @@ export function resolveWaitForMerge({
|
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Flags this CLI advertised in its `--help` descriptor and never implemented
|
|
92
|
+
* (Story #5100). `--dry-run` was transcribed into the usage block by the
|
|
93
|
+
* #4750 self-describing sweep and read by nothing in this pipeline —
|
|
94
|
+
* `git log -S dryRun` over it is empty in every revision — so an operator who
|
|
95
|
+
* trusted it got a real base-sync merge, an `agent::blocked` Story, and a
|
|
96
|
+
* terminal envelope that then captured `deliver-recover`'s routing.
|
|
97
|
+
* `--no-evidence` names a working flag on the gate wrappers
|
|
98
|
+
* (`evidence-gate.js`), but the slot this CLI parsed had no reader at all.
|
|
99
|
+
*
|
|
100
|
+
* Deleting them from the descriptor is NOT enough. `parseSprintArgs` runs
|
|
101
|
+
* `parseArgs` with `strict: false`, so an unknown flag is silently ignored and
|
|
102
|
+
* the close proceeds for real — the identical injury, now with no help text to
|
|
103
|
+
* explain it. Fail closed instead, exactly as `parseMergeWatchMode` does for an
|
|
104
|
+
* unrecognized mode: throw during option parsing, before the first phase, so
|
|
105
|
+
* the CLI boundary reports `failed` at phase `init` and nothing is mutated.
|
|
106
|
+
*/
|
|
107
|
+
const RETIRED_FLAGS = Object.freeze({
|
|
108
|
+
'--dry-run':
|
|
109
|
+
'this pipeline has never had a dry-run mode; it was advertised in error.',
|
|
110
|
+
'--no-evidence':
|
|
111
|
+
'per-close evidence control was never wired here; the working flag of that name belongs to the gate wrappers.',
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Reject a retired flag before any phase runs.
|
|
116
|
+
*
|
|
117
|
+
* Matches `--flag` and `--flag=value`; a bare `--` terminator or a positional
|
|
118
|
+
* that merely contains the text is not a flag and does not trip it.
|
|
119
|
+
*
|
|
120
|
+
* @param {string[]} argv argv tail (`process.argv.slice(2)`)
|
|
121
|
+
* @throws {Error} naming the flag, why it is gone, and that nothing was mutated
|
|
122
|
+
*/
|
|
123
|
+
function assertNoRetiredFlags(argv) {
|
|
124
|
+
for (const [flag, why] of Object.entries(RETIRED_FLAGS)) {
|
|
125
|
+
const present = argv.some((a) => a === flag || a.startsWith(`${flag}=`));
|
|
126
|
+
if (!present) continue;
|
|
127
|
+
throw new Error(
|
|
128
|
+
`${flag} was retired: ${why} Nothing was mutated — no branch, label, ` +
|
|
129
|
+
`comment, or PR was touched. Re-run without it to close for real.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
90
134
|
/**
|
|
91
135
|
* Parse and resolve all CLI / injection options for `runSingleStoryClose`.
|
|
92
136
|
*
|
|
@@ -116,7 +160,14 @@ export function parseCloseOptions({
|
|
|
116
160
|
// which is precisely what `resolveFlag` already does below, preferring the
|
|
117
161
|
// param over the parsed slot. One expression per flag now serves both
|
|
118
162
|
// callers, so a new flag is added in one place instead of two that can drift.
|
|
119
|
-
|
|
163
|
+
// The guard runs on the argv door only: an injecting caller supplies
|
|
164
|
+
// `storyIdParam` and never reads argv, so the host process's flags (a test
|
|
165
|
+
// runner's, say) are none of its business.
|
|
166
|
+
let parsed = {};
|
|
167
|
+
if (storyIdParam === undefined) {
|
|
168
|
+
assertNoRetiredFlags(process.argv.slice(2));
|
|
169
|
+
parsed = parseSprintArgs();
|
|
170
|
+
}
|
|
120
171
|
// Preserve undefined so resolveWaitForMerge can apply the closeAndLand
|
|
121
172
|
// config default when neither flag was supplied.
|
|
122
173
|
const waitForMergeExplicit = resolveFlag(
|
|
@@ -63,7 +63,7 @@ export const FORMAT_AUTOFIX_TIMEOUT_EXIT_CODE = 124;
|
|
|
63
63
|
* @param {(args: string[], opts: object) => string} git
|
|
64
64
|
* @returns {string[]}
|
|
65
65
|
*/
|
|
66
|
-
|
|
66
|
+
function listDirtyPaths(cwd, git) {
|
|
67
67
|
const out = git(['status', '--porcelain'], {
|
|
68
68
|
cwd,
|
|
69
69
|
encoding: 'utf8',
|
|
@@ -91,10 +91,7 @@ export function listDirtyPaths(cwd, git) {
|
|
|
91
91
|
* }} [opts]
|
|
92
92
|
* @returns {{ writeCmdString: string, writeCmd: string, writeArgs: string[] }}
|
|
93
93
|
*/
|
|
94
|
-
|
|
95
|
-
commands,
|
|
96
|
-
dropTrailingDot = false,
|
|
97
|
-
} = {}) {
|
|
94
|
+
function resolveFormatterCmd({ commands, dropTrailingDot = false } = {}) {
|
|
98
95
|
// `resolveFormatWriteCommand` reads `config.project.commands`; wrap the
|
|
99
96
|
// caller-supplied `commands` map into that canonical shape.
|
|
100
97
|
const writeCmdString = resolveFormatWriteCommand({ project: { commands } });
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
} from './retro-proposals.js';
|
|
25
25
|
import { upsertStructuredComment } from './ticketing.js';
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
const FOLLOW_UPS_COMMENT_TYPE = 'follow-ups';
|
|
28
28
|
|
|
29
29
|
/** Milliseconds in one day — the unit `frictionWindowDays` is expressed in. */
|
|
30
30
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|