mandrel 1.68.0 → 1.70.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/README.md +1 -1
- package/.agents/docs/agentrc-reference.json +1 -2
- package/.agents/docs/configuration.md +2 -4
- package/.agents/docs/workflows.md +1 -1
- package/.agents/schemas/agentrc.schema.json +1 -5
- package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
- package/.agents/scripts/agents-update-preflight.js +235 -0
- package/.agents/scripts/apply-quality-bootstrap.js +79 -0
- package/.agents/scripts/audit-labels-bootstrap.js +52 -30
- package/.agents/scripts/audit-to-stories.js +54 -0
- package/.agents/scripts/bootstrap.js +13 -3
- package/.agents/scripts/epic-deliver-preflight.js +30 -13
- package/.agents/scripts/epic-deliver-prepare.js +40 -53
- package/.agents/scripts/epic-execute-record-wave.js +119 -133
- package/.agents/scripts/generate-config-docs.js +189 -94
- package/.agents/scripts/lib/audit-suite/findings.js +0 -4
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
- package/.agents/scripts/lib/baseline-snapshot.js +163 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +13 -5
- package/.agents/scripts/lib/config/baselines.js +0 -20
- package/.agents/scripts/lib/config/explain.js +0 -2
- package/.agents/scripts/lib/config/limits.js +19 -8
- package/.agents/scripts/lib/config/temp-paths.js +0 -31
- package/.agents/scripts/lib/config-settings-schema.js +1 -2
- package/.agents/scripts/lib/crap-utils.js +281 -0
- package/.agents/scripts/lib/maintainability-utils.js +32 -9
- package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
- package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -162
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
- package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
- package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
- package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
- package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
- package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
- package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
- package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
- package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
- package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
- package/.agents/scripts/lib/story-adjacency.js +14 -10
- package/.agents/scripts/lib/story-body/story-body.js +142 -65
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
- package/.agents/scripts/lib/test-tiers.js +13 -7
- package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
- package/.agents/scripts/lib/wave-runner/tick.js +446 -216
- package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
- package/.agents/scripts/lint-label-vocabulary.js +1 -1
- package/.agents/scripts/providers/github/issues.js +48 -0
- package/.agents/scripts/providers/github.js +1 -0
- package/.agents/scripts/stories-wave-tick.js +262 -161
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/agents-update.md +205 -28
- package/.agents/workflows/deliver.md +12 -9
- package/.agents/workflows/helpers/deliver-epic.md +126 -90
- package/.agents/workflows/helpers/deliver-stories.md +131 -85
- package/.agents/workflows/helpers/plan-epic.md +13 -10
- package/.agents/workflows/plan.md +1 -1
- package/README.md +20 -0
- package/docs/CHANGELOG.md +46 -0
- package/lib/cli/registry.js +49 -6
- package/lib/cli/update.js +335 -332
- package/package.json +16 -11
- package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
|
@@ -90,6 +90,7 @@ import {
|
|
|
90
90
|
} from '../crap-utils.js';
|
|
91
91
|
import {
|
|
92
92
|
calculateAll as calculateAllMi,
|
|
93
|
+
isIgnoredByGlobs as isIgnoredByGlobsMi,
|
|
93
94
|
scanDirectory as scanDirectoryMi,
|
|
94
95
|
} from '../maintainability-utils.js';
|
|
95
96
|
import { filterExcludedRows } from './kinds/maintainability.js';
|
|
@@ -291,7 +292,18 @@ function buildDefaultMaintainabilityScorer({ cwd, config, quality } = {}) {
|
|
|
291
292
|
const underTarget = targetAbsDirs.some(
|
|
292
293
|
(root) => abs === root || abs.startsWith(`${root}${path.sep}`),
|
|
293
294
|
);
|
|
294
|
-
|
|
295
|
+
// Apply `ignoreGlobs` here too — the full-scope walk drops
|
|
296
|
+
// ignore-matched files via `scanDirectoryMi`, so the diff-scope path
|
|
297
|
+
// must do the same or an ignored-but-changed file (e.g. one matched by
|
|
298
|
+
// `config-settings-schema*.js`) enters `rows` and drags the
|
|
299
|
+
// `rollup["*"].min` below the maintainability floor. Reuse the same
|
|
300
|
+
// matcher `scanDirectoryMi` uses so behaviour is identical.
|
|
301
|
+
if (
|
|
302
|
+
underTarget &&
|
|
303
|
+
!isIgnoredByGlobsMi(abs, ignoreGlobs, effectiveCwd)
|
|
304
|
+
) {
|
|
305
|
+
sourceList.push(abs);
|
|
306
|
+
}
|
|
295
307
|
}
|
|
296
308
|
}
|
|
297
309
|
const scores = await calculateAllMi(sourceList);
|
|
@@ -727,7 +739,3 @@ function validateOptions({ kind, scopeFiles, fullScope, writePath }) {
|
|
|
727
739
|
);
|
|
728
740
|
}
|
|
729
741
|
}
|
|
730
|
-
|
|
731
|
-
// Exposed for the lint/test invariant (Task #2208) so the guard can list
|
|
732
|
-
// every kind the service is contracted to dispatch without re-deriving it.
|
|
733
|
-
export const REFRESH_SERVICE_SUPPORTED_KINDS = SUPPORTED_KINDS;
|
|
@@ -38,23 +38,3 @@ export function getBaselines(config) {
|
|
|
38
38
|
maintainability: merge('maintainability'),
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Legacy entry point retained for backward-compatible imports. Story #1737
|
|
44
|
-
* retired the standalone `baselines.*` block; this helper now translates
|
|
45
|
-
* `{ lint: { path }, ... }` input onto synthetic `gates.<tier>.baselinePath`.
|
|
46
|
-
*
|
|
47
|
-
* @param {object|undefined} userBlock
|
|
48
|
-
*/
|
|
49
|
-
export function resolveBaselines(userBlock) {
|
|
50
|
-
const block = userBlock ?? {};
|
|
51
|
-
return getBaselines({
|
|
52
|
-
quality: {
|
|
53
|
-
gates: {
|
|
54
|
-
lint: { baselinePath: block.lint?.path },
|
|
55
|
-
crap: { baselinePath: block.crap?.path },
|
|
56
|
-
maintainability: { baselinePath: block.maintainability?.path },
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
});
|
|
60
|
-
}
|
|
@@ -112,8 +112,6 @@ const KEY_MEANINGS = Object.freeze({
|
|
|
112
112
|
'Allowlist of events that fire a webhook notification.',
|
|
113
113
|
|
|
114
114
|
// planning.*
|
|
115
|
-
'planning.maxTickets':
|
|
116
|
-
'Upper bound on tickets a single /plan run may create.',
|
|
117
115
|
'planning.context.maxBytes':
|
|
118
116
|
'Byte budget for the planning-context payload before summary mode kicks in.',
|
|
119
117
|
'planning.context.summaryMode':
|
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
* Limits/budgets/signals accessors (Epic #1720 Story #1739 — top-level reshape).
|
|
3
3
|
*
|
|
4
4
|
* Pre-reshape, every runtime ceiling lived under the legacy `agentSettings.limits.*` bag.
|
|
5
|
-
* Post-reshape, the surviving keys are split across
|
|
6
|
-
* `delivery.*`:
|
|
5
|
+
* Post-reshape, the surviving operator-configurable keys are split across
|
|
6
|
+
* `planning.*` and `delivery.*`:
|
|
7
7
|
*
|
|
8
|
-
* - `planning.maxTickets` (decomposer ceiling)
|
|
9
8
|
* - `planning.context.{maxBytes, summaryMode}` (planning-context budget)
|
|
10
9
|
* - `delivery.maxTokenBudget` (task-prompt hydration cap)
|
|
11
10
|
* - `delivery.execution.timeoutMs` (per-process execution timeout)
|
|
@@ -13,6 +12,14 @@
|
|
|
13
12
|
* - `delivery.signals.{hotspot, rework, retry}` (performance-signal
|
|
14
13
|
* detector thresholds — `churn` and `idle` dropped)
|
|
15
14
|
*
|
|
15
|
+
* `maxTickets` (the decomposer reviewability budget) is a **framework
|
|
16
|
+
* constant** — Story #4163 collapsed the never-overridden
|
|
17
|
+
* `planning.maxTickets` operator knob to `LIMITS_DEFAULTS.maxTickets` and
|
|
18
|
+
* removed it from the AJV schema, the published mirror, and the explain
|
|
19
|
+
* map. The persist-time over-budget gate (ADR-20260610) still reads the
|
|
20
|
+
* constant via `getLimits(config).maxTickets`; `resolveLimits` no longer
|
|
21
|
+
* reads `planning.maxTickets`, so setting it in a config is inert.
|
|
22
|
+
*
|
|
16
23
|
* Dropped entirely: `maxInstructionSteps`, `friction.*` (the LLM
|
|
17
24
|
* self-pacing thresholds rewritten as qualitative prose in
|
|
18
25
|
* `.agents/instructions.md`), `executionMaxBuffer` (now a framework-internal
|
|
@@ -55,7 +62,7 @@ export const LEASE_TTL_MS_DEFAULT = 900000;
|
|
|
55
62
|
* per-complexity budget branch anywhere in the resolver.
|
|
56
63
|
*/
|
|
57
64
|
export const LIMITS_DEFAULTS = Object.freeze({
|
|
58
|
-
maxTickets:
|
|
65
|
+
maxTickets: 80,
|
|
59
66
|
maxTokenBudget: 300000,
|
|
60
67
|
executionTimeoutMs: 600000,
|
|
61
68
|
leaseTtlMs: LEASE_TTL_MS_DEFAULT,
|
|
@@ -91,9 +98,10 @@ function mergeSignals(userSignals) {
|
|
|
91
98
|
/**
|
|
92
99
|
* Resolve the surviving limits surface against a `.agentrc.json` shape
|
|
93
100
|
* (post-reshape). Accepts the resolved-config wrapper or a partial bag —
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
101
|
+
* `maxTickets` is the framework constant `LIMITS_DEFAULTS.maxTickets`
|
|
102
|
+
* (no longer operator-configurable; Story #4163), pulls `planningContext`
|
|
103
|
+
* from `planning.*`, pulls `maxTokenBudget` and `executionTimeoutMs` from
|
|
104
|
+
* `delivery.*`, pulls signals from `delivery.signals.*`.
|
|
97
105
|
*
|
|
98
106
|
* @param {object|undefined} config
|
|
99
107
|
* @returns {{
|
|
@@ -125,7 +133,10 @@ export function resolveLimits(config) {
|
|
|
125
133
|
const lease =
|
|
126
134
|
delivery.lease && typeof delivery.lease === 'object' ? delivery.lease : {};
|
|
127
135
|
return {
|
|
128
|
-
|
|
136
|
+
// `maxTickets` is a framework constant (Story #4163) — never read from
|
|
137
|
+
// `planning.maxTickets`. The persist-time over-budget gate still reads
|
|
138
|
+
// this value via getLimits().maxTickets.
|
|
139
|
+
maxTickets: LIMITS_DEFAULTS.maxTickets,
|
|
129
140
|
maxTokenBudget: delivery.maxTokenBudget ?? LIMITS_DEFAULTS.maxTokenBudget,
|
|
130
141
|
executionTimeoutMs:
|
|
131
142
|
execution.timeoutMs ?? LIMITS_DEFAULTS.executionTimeoutMs,
|
|
@@ -56,8 +56,6 @@
|
|
|
56
56
|
import { execFileSync } from 'node:child_process';
|
|
57
57
|
import path from 'node:path';
|
|
58
58
|
|
|
59
|
-
let _resolveConfig;
|
|
60
|
-
|
|
61
59
|
/**
|
|
62
60
|
* Cache the resolved main-checkout root per spawn cwd so the
|
|
63
61
|
* `git rev-parse` shell-out runs at most once per distinct working
|
|
@@ -136,20 +134,6 @@ function anchorTempRoot(tempRoot) {
|
|
|
136
134
|
return root ? path.join(root, tempRoot) : tempRoot;
|
|
137
135
|
}
|
|
138
136
|
|
|
139
|
-
/**
|
|
140
|
-
* Lazy import of `resolveConfig` to side-step a circular module graph
|
|
141
|
-
* (`config-resolver.js` re-exports from this directory and importing it
|
|
142
|
-
* eagerly would resolve `temp-paths.js` before `lib/config/limits.js` is
|
|
143
|
-
* ready). The resolver itself caches per-root, so the inner call is cheap.
|
|
144
|
-
*/
|
|
145
|
-
async function getResolveConfig() {
|
|
146
|
-
if (!_resolveConfig) {
|
|
147
|
-
const mod = await import('../config-resolver.js');
|
|
148
|
-
_resolveConfig = mod.resolveConfig;
|
|
149
|
-
}
|
|
150
|
-
return _resolveConfig;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
137
|
/**
|
|
154
138
|
* Synchronous tempRoot extraction. Accepts the canonical full resolved
|
|
155
139
|
* config (`{ project, ... }`) and reads `project.paths.tempRoot`.
|
|
@@ -172,21 +156,6 @@ export function tempRootFrom(config) {
|
|
|
172
156
|
: 'temp';
|
|
173
157
|
}
|
|
174
158
|
|
|
175
|
-
/**
|
|
176
|
-
* Async tempRoot resolver. When the caller cannot pass a config bag, this
|
|
177
|
-
* loads the project's `.agentrc.json` via `resolveConfig` (cached per
|
|
178
|
-
* root). Most `.agents/scripts` consumers should prefer the synchronous
|
|
179
|
-
* variant by threading their already-resolved config through.
|
|
180
|
-
*
|
|
181
|
-
* @param {{ cwd?: string }} [opts]
|
|
182
|
-
* @returns {Promise<string>}
|
|
183
|
-
*/
|
|
184
|
-
export async function tempRootAsync(opts) {
|
|
185
|
-
const resolveConfig = await getResolveConfig();
|
|
186
|
-
const resolved = resolveConfig({ cwd: opts?.cwd });
|
|
187
|
-
return tempRootFrom(resolved);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
159
|
const epicId = (id) => {
|
|
191
160
|
if (!Number.isInteger(id) || id <= 0) {
|
|
192
161
|
throw new Error(
|
|
@@ -249,7 +249,7 @@ const CODEBASE_SNAPSHOT_SCHEMA = {
|
|
|
249
249
|
/**
|
|
250
250
|
* `planning.taskSizing` — Story-sizing thresholds consumed by
|
|
251
251
|
* `ticket-validator-sizing.js`. Operator overrides shallow-merge with
|
|
252
|
-
* `DEFAULT_TASK_SIZING` defaults (softFiles
|
|
252
|
+
* `DEFAULT_TASK_SIZING` defaults (softFiles 15, hardFiles 30,
|
|
253
253
|
* maxAcceptance 14, softAcceptanceCount 10 — the uniform relaxed profile
|
|
254
254
|
* from Story #3874). Story #3760 collapsed the per-profile matrix and the
|
|
255
255
|
* parallel `testSurface` axis into a flat set of knobs; the `sizingProfile`
|
|
@@ -271,7 +271,6 @@ const PLANNING_SCHEMA = {
|
|
|
271
271
|
type: 'object',
|
|
272
272
|
properties: {
|
|
273
273
|
riskHeuristics: LIST_OR_EXTENDER_OF_STRINGS,
|
|
274
|
-
maxTickets: { type: 'integer', minimum: 1 },
|
|
275
274
|
context: PLANNING_CONTEXT_SCHEMA,
|
|
276
275
|
codebaseSnapshot: CODEBASE_SNAPSHOT_SCHEMA,
|
|
277
276
|
taskSizing: TASK_SIZING_SCHEMA,
|
|
@@ -13,6 +13,10 @@ import { scanDirectory } from './maintainability-utils.js';
|
|
|
13
13
|
import { resolveTsTranspilerVersion, transpileIfNeeded } from './transpile.js';
|
|
14
14
|
|
|
15
15
|
const CRAP_WORKER_URL = new URL('./workers/crap-worker.js', import.meta.url);
|
|
16
|
+
const COMBINED_MI_CRAP_WORKER_URL = new URL(
|
|
17
|
+
'./workers/combined-mi-crap-worker.js',
|
|
18
|
+
import.meta.url,
|
|
19
|
+
);
|
|
16
20
|
|
|
17
21
|
// Pool-vs-serial cutover — single-sourced in cpu-pool.js (see the
|
|
18
22
|
// POOL_SERIAL_THRESHOLD docstring for the tuning rationale).
|
|
@@ -472,3 +476,280 @@ async function scoreFilesViaPool(queue, coverage) {
|
|
|
472
476
|
return { item, result: r };
|
|
473
477
|
});
|
|
474
478
|
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* In-process combined scorer: parse `abs` exactly once via `analyzeOnce` and
|
|
482
|
+
* derive BOTH the module MI score and the per-method CRAP rows. The reference
|
|
483
|
+
* implementation for the combined worker, used directly below
|
|
484
|
+
* `SERIAL_THRESHOLD` (matching the serial fast paths of `calculateAll` and
|
|
485
|
+
* `scanAndScore`).
|
|
486
|
+
*
|
|
487
|
+
* Return shape mirrors `combined-mi-crap-worker.js`:
|
|
488
|
+
* - `miScore` — `null` on read failure (MI dropped by the host), `0` on
|
|
489
|
+
* transpile-null / parse-error (parity with `calculateForFile` /
|
|
490
|
+
* `calculateForSource`), otherwise the module maintainability index.
|
|
491
|
+
* - `crapRows` — `null` on read/transpile/parse failure (CRAP drops the
|
|
492
|
+
* file), `[]` when coverage-skipped, otherwise the scored method rows.
|
|
493
|
+
* - `skippedFileNoCoverage` / `skippedMethodsNoCoverage` — CRAP counters.
|
|
494
|
+
*/
|
|
495
|
+
function scoreFileCombinedSerial({ abs, relPath, requireCoverage }, coverage) {
|
|
496
|
+
const entry = findCoverageEntry(coverage, relPath);
|
|
497
|
+
let source;
|
|
498
|
+
try {
|
|
499
|
+
source = fs.readFileSync(abs, 'utf-8');
|
|
500
|
+
} catch {
|
|
501
|
+
return {
|
|
502
|
+
relPath,
|
|
503
|
+
miScore: null,
|
|
504
|
+
skippedFileNoCoverage: false,
|
|
505
|
+
crapRows: null,
|
|
506
|
+
skippedMethodsNoCoverage: 0,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
const prepared = transpileIfNeeded(abs, source);
|
|
510
|
+
if (prepared === null) {
|
|
511
|
+
return {
|
|
512
|
+
relPath,
|
|
513
|
+
miScore: 0,
|
|
514
|
+
skippedFileNoCoverage: false,
|
|
515
|
+
crapRows: null,
|
|
516
|
+
skippedMethodsNoCoverage: 0,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
const {
|
|
520
|
+
miScore,
|
|
521
|
+
crapRows: rawCrapRows,
|
|
522
|
+
parseError,
|
|
523
|
+
} = analyzeOnce(prepared, entry);
|
|
524
|
+
if (parseError) {
|
|
525
|
+
return {
|
|
526
|
+
relPath,
|
|
527
|
+
miScore: 0,
|
|
528
|
+
skippedFileNoCoverage: false,
|
|
529
|
+
crapRows: null,
|
|
530
|
+
skippedMethodsNoCoverage: 0,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
if (requireCoverage && entry === null) {
|
|
534
|
+
return {
|
|
535
|
+
relPath,
|
|
536
|
+
miScore,
|
|
537
|
+
skippedFileNoCoverage: true,
|
|
538
|
+
crapRows: [],
|
|
539
|
+
skippedMethodsNoCoverage: 0,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
const crapRows = [];
|
|
543
|
+
let skippedMethodsNoCoverage = 0;
|
|
544
|
+
for (const mr of rawCrapRows) {
|
|
545
|
+
if (mr.crap === null || mr.coverage === null) {
|
|
546
|
+
skippedMethodsNoCoverage += 1;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
crapRows.push({
|
|
550
|
+
method: mr.method,
|
|
551
|
+
startLine: mr.startLine,
|
|
552
|
+
cyclomatic: mr.cyclomatic,
|
|
553
|
+
coverage: mr.coverage,
|
|
554
|
+
crap: mr.crap,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
relPath,
|
|
559
|
+
miScore,
|
|
560
|
+
skippedFileNoCoverage: false,
|
|
561
|
+
crapRows,
|
|
562
|
+
skippedMethodsNoCoverage,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function scoreFilesCombinedViaPool(queue, coverage) {
|
|
567
|
+
const enrichedQueue = queue.map((item) => ({
|
|
568
|
+
...item,
|
|
569
|
+
coverageEntry: findCoverageEntry(coverage, item.relPath),
|
|
570
|
+
}));
|
|
571
|
+
const results = await runOnPool(COMBINED_MI_CRAP_WORKER_URL, enrichedQueue, {
|
|
572
|
+
workerData: {},
|
|
573
|
+
});
|
|
574
|
+
return results.map((r, i) => {
|
|
575
|
+
const item = queue[i];
|
|
576
|
+
if (!r || r.__cpuPoolError) {
|
|
577
|
+
Logger.warn(
|
|
578
|
+
`[crap-utils] combined worker pool error for ${item.relPath}: ${r?.message ?? 'unknown'}`,
|
|
579
|
+
);
|
|
580
|
+
return { item, result: null };
|
|
581
|
+
}
|
|
582
|
+
return { item, result: r };
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Combined MI + CRAP single-pass scan. Walks the shared `targetDirs` once (or
|
|
588
|
+
* reuses `preScannedFiles`), dispatches every file through ONE worker that
|
|
589
|
+
* calls `analyzeOnce` a single time, and returns BOTH the maintainability
|
|
590
|
+
* score map and the CRAP scan result.
|
|
591
|
+
*
|
|
592
|
+
* This collapses the two independent escomplex passes the full-tree baseline
|
|
593
|
+
* regenerator used to run (`calculateAll` → maintainability worker, then
|
|
594
|
+
* `scanAndScore` → CRAP worker) into one parse per file. The outputs are
|
|
595
|
+
* shaped to be drop-in equivalents of the two passes they replace, so the
|
|
596
|
+
* downstream envelope projection + writer logic stays byte-identical:
|
|
597
|
+
*
|
|
598
|
+
* - `miScores` — `Record<relPath, number>` keyed exactly as `calculateAll`
|
|
599
|
+
* keys its result (`path.relative(cwd, abs)`, POSIX-normalised), with
|
|
600
|
+
* read-failure files (`miScore === null`) dropped. Parity target:
|
|
601
|
+
* `calculateAll(files)`.
|
|
602
|
+
* - `crap` — `{ rows, scannedFiles, skippedFilesNoCoverage,
|
|
603
|
+
* skippedMethodsNoCoverage }`, identical in shape and content to
|
|
604
|
+
* `scanAndScore({ targetDirs, coverage, ... })`. The rows are
|
|
605
|
+
* CRAP-sorted (file → startLine → method) so the result matches
|
|
606
|
+
* `scanAndScore` even before the writer re-sorts.
|
|
607
|
+
*
|
|
608
|
+
* The `requireCoverage`, `scopeFiles`, `ignoreGlobs`, and `preScannedFiles`
|
|
609
|
+
* semantics match `scanAndScore` exactly — coverage gating, scope filtering,
|
|
610
|
+
* and the single-walk reuse path behave the same. Files dropped from CRAP by
|
|
611
|
+
* the coverage gate STILL contribute their MI score (the MI pass never
|
|
612
|
+
* required coverage), preserving the two-pass behaviour where MI scores every
|
|
613
|
+
* file in the target dirs.
|
|
614
|
+
*
|
|
615
|
+
* @param {{
|
|
616
|
+
* targetDirs: string[],
|
|
617
|
+
* coverage: object|null,
|
|
618
|
+
* requireCoverage?: boolean,
|
|
619
|
+
* cwd?: string,
|
|
620
|
+
* scopeFiles?: Set<string>|string[]|null,
|
|
621
|
+
* ignoreGlobs?: string[],
|
|
622
|
+
* preScannedFiles?: string[]|null,
|
|
623
|
+
* }} params
|
|
624
|
+
* @returns {Promise<{
|
|
625
|
+
* miScores: Record<string, number>,
|
|
626
|
+
* crap: {
|
|
627
|
+
* rows: Array<{
|
|
628
|
+
* file: string, method: string, startLine: number,
|
|
629
|
+
* cyclomatic: number, coverage: number, crap: number,
|
|
630
|
+
* }>,
|
|
631
|
+
* scannedFiles: number,
|
|
632
|
+
* skippedFilesNoCoverage: number,
|
|
633
|
+
* skippedMethodsNoCoverage: number,
|
|
634
|
+
* },
|
|
635
|
+
* }>}
|
|
636
|
+
*/
|
|
637
|
+
export async function scanAndScoreCombined({
|
|
638
|
+
targetDirs,
|
|
639
|
+
coverage,
|
|
640
|
+
requireCoverage = true,
|
|
641
|
+
cwd = process.cwd(),
|
|
642
|
+
scopeFiles = null,
|
|
643
|
+
ignoreGlobs = [],
|
|
644
|
+
preScannedFiles = null,
|
|
645
|
+
}) {
|
|
646
|
+
if (!Array.isArray(targetDirs)) {
|
|
647
|
+
throw new TypeError('scanAndScoreCombined: targetDirs must be an array');
|
|
648
|
+
}
|
|
649
|
+
const scopeSet =
|
|
650
|
+
scopeFiles == null
|
|
651
|
+
? null
|
|
652
|
+
: scopeFiles instanceof Set
|
|
653
|
+
? scopeFiles
|
|
654
|
+
: new Set(scopeFiles);
|
|
655
|
+
|
|
656
|
+
// Single directory walk (or reuse the caller's pre-walked list), mirroring
|
|
657
|
+
// scanAndScore so the file discovery is byte-identical between paths.
|
|
658
|
+
const files = preScannedFiles != null ? [...preScannedFiles] : [];
|
|
659
|
+
if (preScannedFiles == null) {
|
|
660
|
+
for (const dir of targetDirs) {
|
|
661
|
+
const abs = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
|
|
662
|
+
scanDirectory(abs, files, { cwd, ignoreGlobs });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
files.sort();
|
|
666
|
+
|
|
667
|
+
// Build the work queue. Each item carries both the canonicalised relPath
|
|
668
|
+
// (CRAP's key + scope filter, matching scanAndScore) and the raw relPath
|
|
669
|
+
// (MI's key, matching calculateAll's `path.relative(cwd, p)` shape).
|
|
670
|
+
const queue = [];
|
|
671
|
+
for (const abs of files) {
|
|
672
|
+
const rawRel = path.relative(cwd, abs).replace(/\\/g, '/');
|
|
673
|
+
const relPath = canonicalisePath(rawRel);
|
|
674
|
+
if (scopeSet && !scopeSet.has(relPath)) continue;
|
|
675
|
+
queue.push({ abs, relPath, miRel: rawRel, requireCoverage });
|
|
676
|
+
}
|
|
677
|
+
const scannedFiles = queue.length;
|
|
678
|
+
|
|
679
|
+
const perFile =
|
|
680
|
+
queue.length < SERIAL_THRESHOLD
|
|
681
|
+
? queue.map((item) => ({
|
|
682
|
+
item,
|
|
683
|
+
result: scoreFileCombinedSerial(item, coverage),
|
|
684
|
+
}))
|
|
685
|
+
: await scoreFilesCombinedViaPool(queue, coverage);
|
|
686
|
+
|
|
687
|
+
// MI assembly — mirror calculateAll: drop read-failure files (miScore
|
|
688
|
+
// null), key by the raw relative path, then sort ascending so the returned
|
|
689
|
+
// object is insertion-order-stable.
|
|
690
|
+
const miEntries = [];
|
|
691
|
+
// CRAP assembly — mirror scanAndScore: file-level skip counter, drop
|
|
692
|
+
// read/transpile/parse failures, accumulate method rows.
|
|
693
|
+
const crapRows = [];
|
|
694
|
+
let skippedFilesNoCoverage = 0;
|
|
695
|
+
let skippedMethodsNoCoverage = 0;
|
|
696
|
+
|
|
697
|
+
for (const { item, result } of perFile) {
|
|
698
|
+
if (!result) continue; // unrecoverable per-file failure: drop silently
|
|
699
|
+
|
|
700
|
+
// MI side.
|
|
701
|
+
if (result.miScore !== null) {
|
|
702
|
+
miEntries.push({ relPath: item.miRel, score: result.miScore });
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// CRAP side.
|
|
706
|
+
if (result.skippedFileNoCoverage) {
|
|
707
|
+
skippedFilesNoCoverage += 1;
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
if (result.crapRows === null) {
|
|
711
|
+
if (result.error) {
|
|
712
|
+
Logger.warn(
|
|
713
|
+
`[crap-utils] failed to score ${item.relPath}: ${result.error}`,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
skippedMethodsNoCoverage += result.skippedMethodsNoCoverage ?? 0;
|
|
719
|
+
for (const mr of result.crapRows) {
|
|
720
|
+
crapRows.push({
|
|
721
|
+
file: item.relPath,
|
|
722
|
+
method: mr.method,
|
|
723
|
+
startLine: mr.startLine,
|
|
724
|
+
cyclomatic: mr.cyclomatic,
|
|
725
|
+
coverage: mr.coverage,
|
|
726
|
+
crap: mr.crap,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
miEntries.sort((a, b) =>
|
|
732
|
+
a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0,
|
|
733
|
+
);
|
|
734
|
+
const miScores = {};
|
|
735
|
+
for (const { relPath, score } of miEntries) {
|
|
736
|
+
miScores[relPath] = score;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
crapRows.sort((a, b) => {
|
|
740
|
+
if (a.file !== b.file) return a.file < b.file ? -1 : 1;
|
|
741
|
+
if (a.startLine !== b.startLine) return a.startLine - b.startLine;
|
|
742
|
+
if (a.method !== b.method) return a.method < b.method ? -1 : 1;
|
|
743
|
+
return 0;
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
return {
|
|
747
|
+
miScores,
|
|
748
|
+
crap: {
|
|
749
|
+
rows: crapRows,
|
|
750
|
+
scannedFiles,
|
|
751
|
+
skippedFilesNoCoverage,
|
|
752
|
+
skippedMethodsNoCoverage,
|
|
753
|
+
},
|
|
754
|
+
};
|
|
755
|
+
}
|
|
@@ -36,6 +36,36 @@ const IGNORED_DIRS = new Set([
|
|
|
36
36
|
'.next',
|
|
37
37
|
]);
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Test whether an absolute (or repo-relative) file path matches any of the
|
|
41
|
+
* configured `ignoreGlobs`. This is the single source of truth for how the
|
|
42
|
+
* maintainability scorer decides a file is ignored: both the full-scope
|
|
43
|
+
* directory walk (`scanDirectory` below) and the diff-scope file-list path
|
|
44
|
+
* in `refresh-service.js` MUST funnel through it so an `ignoreGlobs`-listed
|
|
45
|
+
* file is excluded identically in both scopes (a diff-scope refresh that
|
|
46
|
+
* skipped this check would let an ignored file poison the `rollup["*"].min`
|
|
47
|
+
* floor — see `buildDefaultMaintainabilityScorer`).
|
|
48
|
+
*
|
|
49
|
+
* Matching mirrors `scanDirectory`: the path is reduced to a canonicalised,
|
|
50
|
+
* POSIX, repo-relative form and tested against each glob with minimatch's
|
|
51
|
+
* `{ dot: true }` so dot-prefixed roots like `.agents/` match.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} filePath absolute or relative path to the source file
|
|
54
|
+
* @param {string[]} ignoreGlobs minimatch patterns; empty/absent is a no-op
|
|
55
|
+
* @param {string} [cwd] root for repo-relative resolution; defaults to cwd
|
|
56
|
+
* @returns {boolean} true when the file matches at least one ignore glob
|
|
57
|
+
*/
|
|
58
|
+
export function isIgnoredByGlobs(filePath, ignoreGlobs = [], cwd) {
|
|
59
|
+
if (!Array.isArray(ignoreGlobs) || ignoreGlobs.length === 0) return false;
|
|
60
|
+
const matchCwd = cwd ?? process.cwd();
|
|
61
|
+
const absFilePath = path.isAbsolute(filePath)
|
|
62
|
+
? filePath
|
|
63
|
+
: path.resolve(matchCwd, filePath);
|
|
64
|
+
const rawRel = path.relative(matchCwd, absFilePath).replace(/\\/g, '/');
|
|
65
|
+
const relPath = canonicalisePath(rawRel);
|
|
66
|
+
return ignoreGlobs.some((g) => minimatch(relPath, g, { dot: true }));
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
/**
|
|
40
70
|
* Recursively scans a directory for JS/TS source files. Accepts `.js`,
|
|
41
71
|
* `.mjs`, `.cjs`, `.ts`, `.tsx`, `.mts`, and `.cts`. Directories listed
|
|
@@ -71,15 +101,8 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
|
|
|
71
101
|
scanDirectory(filePath, fileList, opts);
|
|
72
102
|
}
|
|
73
103
|
} else if (entry.isFile() && isSupportedSourceFile(entry.name)) {
|
|
74
|
-
if (ignoreGlobs
|
|
75
|
-
|
|
76
|
-
? filePath
|
|
77
|
-
: path.resolve(filePath);
|
|
78
|
-
const rawRel = path.relative(matchCwd, absFilePath).replace(/\\/g, '/');
|
|
79
|
-
const relPath = canonicalisePath(rawRel);
|
|
80
|
-
if (ignoreGlobs.some((g) => minimatch(relPath, g, { dot: true }))) {
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
104
|
+
if (isIgnoredByGlobs(filePath, ignoreGlobs, matchCwd)) {
|
|
105
|
+
continue;
|
|
83
106
|
}
|
|
84
107
|
fileList.push(filePath);
|
|
85
108
|
}
|
|
@@ -27,8 +27,6 @@ import { buildManifest } from './manifest-builder.js';
|
|
|
27
27
|
import { STATE_LABELS } from './ticketing.js';
|
|
28
28
|
|
|
29
29
|
export const AGENT_DONE_LABEL = STATE_LABELS.DONE;
|
|
30
|
-
export const AGENT_EXECUTING_LABEL = STATE_LABELS.EXECUTING;
|
|
31
|
-
export const AGENT_READY_LABEL = STATE_LABELS.READY;
|
|
32
30
|
|
|
33
31
|
/* node:coverage ignore next */
|
|
34
32
|
export function ensureBranch(branchName, baseBranch) {
|
|
@@ -105,7 +105,12 @@ export function epicBranchHasOpenPr(opts) {
|
|
|
105
105
|
/**
|
|
106
106
|
* Build the list of branches owned by the Epic from the checkpoint.
|
|
107
107
|
*
|
|
108
|
-
*
|
|
108
|
+
* Story #4155 — the ready-set runtime records a flat per-Story status map
|
|
109
|
+
* (`stories: { [storyId]: { status, ... } }`) on the checkpoint instead of a
|
|
110
|
+
* per-wave `waves[]` history. The owned Story branches are the keys of that
|
|
111
|
+
* map.
|
|
112
|
+
*
|
|
113
|
+
* @param {{ epicId: number, stories?: Record<string, object> } | null} state
|
|
109
114
|
* @returns {{ epicBranch: string, storyBranches: string[] }}
|
|
110
115
|
*/
|
|
111
116
|
export function listEpicBranchesFromState(state) {
|
|
@@ -113,13 +118,12 @@ export function listEpicBranchesFromState(state) {
|
|
|
113
118
|
if (!Number.isInteger(epicId) || epicId <= 0) {
|
|
114
119
|
return { epicBranch: null, storyBranches: [] };
|
|
115
120
|
}
|
|
121
|
+
const storyMap =
|
|
122
|
+
state?.stories && typeof state.stories === 'object' ? state.stories : {};
|
|
116
123
|
const storyIds = new Set();
|
|
117
|
-
for (const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
storyIds.add(story.id);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
124
|
+
for (const key of Object.keys(storyMap)) {
|
|
125
|
+
const id = Number(key);
|
|
126
|
+
if (Number.isInteger(id) && id > 0) storyIds.add(id);
|
|
123
127
|
}
|
|
124
128
|
return {
|
|
125
129
|
epicBranch: `epic/${epicId}`,
|
|
@@ -89,13 +89,13 @@ async function runEmitContextPath({ epicId, provider, config, values }) {
|
|
|
89
89
|
const ctx = await buildDecompositionContext(epicId, provider, config, {
|
|
90
90
|
fullContext: values['full-context'],
|
|
91
91
|
});
|
|
92
|
-
// Surface the resolved budget on stderr so
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// Story #2798 — language
|
|
96
|
-
// budget"
|
|
92
|
+
// Surface the resolved reviewability budget on stderr so the operator
|
|
93
|
+
// sees the same value the decomposer prompt embeds. `maxTickets` is a
|
|
94
|
+
// framework constant (LIMITS_DEFAULTS.maxTickets), no longer an
|
|
95
|
+
// operator-configurable key. Story #2798 — language is "reviewability
|
|
96
|
+
// budget", not "prompt cap".
|
|
97
97
|
Logger.error(
|
|
98
|
-
`[epic-plan-decompose] Resolved
|
|
98
|
+
`[epic-plan-decompose] Resolved maxTickets reviewability budget = ${ctx.maxTickets} (framework constant).`,
|
|
99
99
|
);
|
|
100
100
|
const json = values.pretty
|
|
101
101
|
? JSON.stringify(ctx, null, 2)
|
|
@@ -22,9 +22,9 @@ import { applyBudget } from '../../planning-context-budget.js';
|
|
|
22
22
|
|
|
23
23
|
export function buildDecomposerSystemPrompt(
|
|
24
24
|
heuristics = [],
|
|
25
|
-
{ maxTickets } = {},
|
|
25
|
+
{ maxTickets, maxTokenBudget } = {},
|
|
26
26
|
) {
|
|
27
|
-
const base = renderDecomposerSystemPrompt({ maxTickets });
|
|
27
|
+
const base = renderDecomposerSystemPrompt({ maxTickets, maxTokenBudget });
|
|
28
28
|
const heuristicsStr =
|
|
29
29
|
heuristics.length > 0
|
|
30
30
|
? `### RISK HEURISTICS (planning metadata if any apply):\n- ${heuristics.join('\n- ')}`
|
|
@@ -115,9 +115,13 @@ export async function buildDecompositionContext(
|
|
|
115
115
|
const heuristics = resolveHeuristics(config);
|
|
116
116
|
const limits = getLimits(config);
|
|
117
117
|
const maxTickets = limits.maxTickets;
|
|
118
|
+
const maxTokenBudget = limits.maxTokenBudget;
|
|
118
119
|
const planningLimits = limits.planningContext;
|
|
119
120
|
const { fullContext = false } = opts;
|
|
120
|
-
const systemPrompt = buildDecomposerSystemPrompt(heuristics, {
|
|
121
|
+
const systemPrompt = buildDecomposerSystemPrompt(heuristics, {
|
|
122
|
+
maxTickets,
|
|
123
|
+
maxTokenBudget,
|
|
124
|
+
});
|
|
121
125
|
|
|
122
126
|
const budgeted = applyBudget(
|
|
123
127
|
[
|
|
@@ -137,8 +141,10 @@ export async function buildDecompositionContext(
|
|
|
137
141
|
maxTickets,
|
|
138
142
|
// Story #3875 — surface the real delivery envelope to the decomposer
|
|
139
143
|
// so Stories are sized against the hydration budget and the
|
|
140
|
-
// configured preflight ceilings rather than guessed.
|
|
141
|
-
|
|
144
|
+
// configured preflight ceilings rather than guessed. Story #4162 also
|
|
145
|
+
// threads this value into the rendered systemPrompt above as a sizing
|
|
146
|
+
// input so the prompt itself names the budget.
|
|
147
|
+
maxTokenBudget,
|
|
142
148
|
preflightCeilings: resolvePreflightCeilings(config),
|
|
143
149
|
contextMode: budgeted.mode,
|
|
144
150
|
// Story #2801 — surface the Phase 7 planning decision so the
|