mandrel 2.15.0 → 2.17.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 +1 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +28 -6
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-light.js +31 -3
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/process.js +61 -15
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- package/.agents/scripts/lib/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
- package/.agents/scripts/lib/orchestration/plan-context.js +190 -10
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +38 -15
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-context.js +4 -1
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/update-crap-baseline.js +13 -0
- package/.agents/workflows/helpers/deliver-light.md +34 -8
- package/.agents/workflows/helpers/plan-reference.md +27 -6
- package/.agents/workflows/plan.md +4 -2
- package/.agents/workflows/prototype.md +104 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +41 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// .agents/scripts/check-baseline-drift.js — scheduled full-scope baseline
|
|
4
|
+
// drift check (Story #4776).
|
|
5
|
+
//
|
|
6
|
+
// The three per-PR enforcement sites (close-validation, pre-push, CI) are all
|
|
7
|
+
// diff-scoped: they compare the files a branch touched against their committed
|
|
8
|
+
// baseline rows. A file nobody touches after its row is written is therefore
|
|
9
|
+
// never re-scored, so drift introduced indirectly — a dependency getting more
|
|
10
|
+
// complex, coverage moving underneath a method — stays invisible indefinitely.
|
|
11
|
+
//
|
|
12
|
+
// This CLI is the periodic full-scope counterpart. It re-scores every target
|
|
13
|
+
// directory through the same scorer that writes the baseline, prints a per-row
|
|
14
|
+
// before/after table for everything that moved beyond the gate's tolerance,
|
|
15
|
+
// and exits non-zero when it finds any — so a consumer can wire it as a
|
|
16
|
+
// scheduled CI job without wrapping it in verdict-parsing glue. Wiring it is
|
|
17
|
+
// deliberately consumer-side work; nothing in this repo schedules it.
|
|
18
|
+
//
|
|
19
|
+
// Exit codes:
|
|
20
|
+
// 0 — no drift (or every kind skipped: disabled gate, no baseline, no scorer)
|
|
21
|
+
// 1 — drift detected in at least one kind
|
|
22
|
+
// 2 — the check itself could not run
|
|
23
|
+
|
|
24
|
+
// Fail-fast if the framework's runtime deps are not installed — must be the
|
|
25
|
+
// first import so the check runs before any third-party-importing sibling
|
|
26
|
+
// module is evaluated (Story #3432).
|
|
27
|
+
import './lib/runtime-deps/ensure-installed.js';
|
|
28
|
+
import {
|
|
29
|
+
DRIFT_KINDS,
|
|
30
|
+
detectBaselineDrift,
|
|
31
|
+
formatDriftReport,
|
|
32
|
+
} from './lib/baselines/drift-detector.js';
|
|
33
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
34
|
+
|
|
35
|
+
export const HELP_TEXT = `Usage: node .agents/scripts/check-baseline-drift.js [options]
|
|
36
|
+
|
|
37
|
+
Re-score the configured baselines FULL-SCOPE and report every row whose
|
|
38
|
+
current score has drifted from its committed baseline by more than the
|
|
39
|
+
gate's tolerance — including files untouched by any recent diff, which the
|
|
40
|
+
diff-scoped gates structurally cannot see.
|
|
41
|
+
|
|
42
|
+
Options:
|
|
43
|
+
--gate <kind> Restrict to one kind (repeatable). Default: ${DRIFT_KINDS.join(', ')}.
|
|
44
|
+
--tolerance <n> Override the per-gate absolute tolerance.
|
|
45
|
+
--json Emit the machine-readable report instead of the table.
|
|
46
|
+
-h, --help Show this help.
|
|
47
|
+
|
|
48
|
+
Exit codes: 0 no drift · 1 drift detected · 2 the check could not run.
|
|
49
|
+
|
|
50
|
+
On drift, refresh with the printed \`*:update -- --full-scope\` command and
|
|
51
|
+
commit the result with a \`baseline-refresh:\` tagged subject (non-empty body).`;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse the CLI surface. Unknown flags are rejected so a typo'd `--gate`
|
|
55
|
+
* cannot silently widen a scheduled job's scope.
|
|
56
|
+
*
|
|
57
|
+
* @param {string[]} argv
|
|
58
|
+
* @returns {{ kinds: string[], tolerance: number|null, json: boolean }}
|
|
59
|
+
*/
|
|
60
|
+
export function parseArgs(argv = []) {
|
|
61
|
+
const kinds = [];
|
|
62
|
+
let tolerance = null;
|
|
63
|
+
let json = false;
|
|
64
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
65
|
+
const arg = argv[i];
|
|
66
|
+
if (arg === '--gate' && argv[i + 1]) {
|
|
67
|
+
const kind = argv[i + 1];
|
|
68
|
+
if (!DRIFT_KINDS.includes(kind)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`[drift] unknown --gate "${kind}"; expected one of ${DRIFT_KINDS.join(', ')}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
kinds.push(kind);
|
|
74
|
+
i += 1;
|
|
75
|
+
} else if (arg === '--tolerance' && argv[i + 1]) {
|
|
76
|
+
const value = Number(argv[i + 1]);
|
|
77
|
+
if (!Number.isFinite(value)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`[drift] --tolerance must be a number (got ${argv[i + 1]})`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
tolerance = value;
|
|
83
|
+
i += 1;
|
|
84
|
+
} else if (arg === '--json') {
|
|
85
|
+
json = true;
|
|
86
|
+
} else {
|
|
87
|
+
throw new Error(`[drift] unrecognised argument "${arg}"`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
kinds: kinds.length > 0 ? kinds : [...DRIFT_KINDS],
|
|
92
|
+
tolerance,
|
|
93
|
+
json,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run the drift check and render it. Returns the process exit code rather
|
|
99
|
+
* than exiting, so the whole path is unit-testable.
|
|
100
|
+
*
|
|
101
|
+
* @param {{ argv?: string[], cwd?: string, detect?: typeof detectBaselineDrift }} opts
|
|
102
|
+
* @returns {Promise<{ exitCode: number, output: string }>}
|
|
103
|
+
*/
|
|
104
|
+
export async function runCheckBaselineDrift({
|
|
105
|
+
argv = [],
|
|
106
|
+
cwd = process.cwd(),
|
|
107
|
+
detect = detectBaselineDrift,
|
|
108
|
+
} = {}) {
|
|
109
|
+
const args = parseArgs(argv);
|
|
110
|
+
const run = await detect({
|
|
111
|
+
kinds: args.kinds,
|
|
112
|
+
cwd,
|
|
113
|
+
tolerance: args.tolerance,
|
|
114
|
+
});
|
|
115
|
+
const output = args.json
|
|
116
|
+
? JSON.stringify({ schemaVersion: '1', ...run }, null, 2)
|
|
117
|
+
: formatDriftReport(run);
|
|
118
|
+
return { exitCode: run.ok ? 0 : 1, output };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function main() {
|
|
122
|
+
let result;
|
|
123
|
+
try {
|
|
124
|
+
result = await runCheckBaselineDrift({ argv: process.argv.slice(2) });
|
|
125
|
+
} catch (err) {
|
|
126
|
+
process.stdout.write(`${err?.message ?? String(err)}\n`);
|
|
127
|
+
process.exit(2);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
process.stdout.write(`${result.output}\n`);
|
|
131
|
+
process.exit(result.exitCode);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
runAsCli(import.meta.url, main, {
|
|
135
|
+
source: 'check-baseline-drift',
|
|
136
|
+
usage: HELP_TEXT,
|
|
137
|
+
exitCode: 2,
|
|
138
|
+
});
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import path from 'node:path';
|
|
25
25
|
import { getChangedFiles } from './lib/changed-files.js';
|
|
26
|
+
import { isDirectInvocation } from './lib/cli-utils.js';
|
|
26
27
|
import { getQuality, resolveConfig } from './lib/config-resolver.js';
|
|
27
28
|
import {
|
|
28
29
|
anyChangedUnderTargets,
|
|
@@ -35,7 +36,13 @@ import {
|
|
|
35
36
|
import { Logger } from './lib/Logger.js';
|
|
36
37
|
import { hasNpmScript, readPackageScripts } from './lib/npm-scripts.js';
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Parse the full `process.argv` (index 2 onward) into the capture options.
|
|
41
|
+
*
|
|
42
|
+
* @param {string[]} argv
|
|
43
|
+
* @returns {{ skipWhenNoCrapFiles: boolean, ref: string, cwd: string }}
|
|
44
|
+
*/
|
|
45
|
+
export function parseArgs(argv) {
|
|
39
46
|
const out = {
|
|
40
47
|
skipWhenNoCrapFiles: false,
|
|
41
48
|
ref: 'main',
|
|
@@ -50,13 +57,50 @@ function parseArgs(argv) {
|
|
|
50
57
|
return out;
|
|
51
58
|
}
|
|
52
59
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
/**
|
|
61
|
+
* The capture decision core, extracted from the CLI shell so the whole
|
|
62
|
+
* decision table (disabled gate, missing npm script, changed-file skip,
|
|
63
|
+
* freshness skip, capture + stamp) is reachable without spawning
|
|
64
|
+
* `npm run test:coverage` or touching the real filesystem.
|
|
65
|
+
*
|
|
66
|
+
* Every seam on the optional final `deps` parameter defaults to the real
|
|
67
|
+
* implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so the CLI
|
|
68
|
+
* shell below and any production caller are unchanged.
|
|
69
|
+
*
|
|
70
|
+
* @param {string[]} [argv] Full `process.argv`-shaped array.
|
|
71
|
+
* @param {{
|
|
72
|
+
* resolveConfigImpl?: typeof resolveConfig,
|
|
73
|
+
* getQualityImpl?: typeof getQuality,
|
|
74
|
+
* getChangedFilesImpl?: typeof getChangedFiles,
|
|
75
|
+
* readPackageScriptsImpl?: typeof readPackageScripts,
|
|
76
|
+
* hasNpmScriptImpl?: typeof hasNpmScript,
|
|
77
|
+
* isCoverageFreshImpl?: typeof isCoverageFresh,
|
|
78
|
+
* runCaptureImpl?: typeof runCapture,
|
|
79
|
+
* computeContentDigestImpl?: typeof computeContentDigest,
|
|
80
|
+
* writeCaptureStampImpl?: typeof writeCaptureStamp,
|
|
81
|
+
* logger?: { info: Function, warn: Function, error: Function },
|
|
82
|
+
* }} [deps]
|
|
83
|
+
* @returns {number} process exit code
|
|
84
|
+
*/
|
|
85
|
+
export function runCoverageCapture(argv = process.argv, deps = {}) {
|
|
86
|
+
const {
|
|
87
|
+
resolveConfigImpl = resolveConfig,
|
|
88
|
+
getQualityImpl = getQuality,
|
|
89
|
+
getChangedFilesImpl = getChangedFiles,
|
|
90
|
+
readPackageScriptsImpl = readPackageScripts,
|
|
91
|
+
hasNpmScriptImpl = hasNpmScript,
|
|
92
|
+
isCoverageFreshImpl = isCoverageFresh,
|
|
93
|
+
runCaptureImpl = runCapture,
|
|
94
|
+
computeContentDigestImpl = computeContentDigest,
|
|
95
|
+
writeCaptureStampImpl = writeCaptureStamp,
|
|
96
|
+
logger = Logger,
|
|
97
|
+
} = deps;
|
|
98
|
+
const args = parseArgs(argv);
|
|
99
|
+
const config = resolveConfigImpl({ cwd: args.cwd });
|
|
100
|
+
const { crap, coverage } = getQualityImpl(config);
|
|
57
101
|
|
|
58
102
|
if (crap.enabled === false) {
|
|
59
|
-
|
|
103
|
+
logger.info('[coverage-capture] CRAP gate disabled — skipping capture.');
|
|
60
104
|
return 0;
|
|
61
105
|
}
|
|
62
106
|
|
|
@@ -67,8 +111,8 @@ function main() {
|
|
|
67
111
|
// defined it. Surface a one-line, fix-naming diagnostic instead of
|
|
68
112
|
// spawning `npm run test:coverage` only to propagate npm's opaque
|
|
69
113
|
// "Missing script" exit code.
|
|
70
|
-
if (!
|
|
71
|
-
|
|
114
|
+
if (!hasNpmScriptImpl(readPackageScriptsImpl(args.cwd), 'test:coverage')) {
|
|
115
|
+
logger.error(
|
|
72
116
|
'[coverage-capture] ✖ No "test:coverage" script in package.json. ' +
|
|
73
117
|
'Add one (e.g. "test:coverage": "node --test --experimental-test-coverage") ' +
|
|
74
118
|
'or disable the CRAP gate via delivery.quality.gates.crap.enabled=false.',
|
|
@@ -79,45 +123,45 @@ function main() {
|
|
|
79
123
|
if (args.skipWhenNoCrapFiles) {
|
|
80
124
|
let changed;
|
|
81
125
|
try {
|
|
82
|
-
changed =
|
|
126
|
+
changed = getChangedFilesImpl({ ref: args.ref, cwd: args.cwd });
|
|
83
127
|
} catch (err) {
|
|
84
128
|
// A bad ref must not silently relax the gate. Fall through to the
|
|
85
129
|
// freshness check so coverage still gets captured if needed.
|
|
86
|
-
|
|
130
|
+
logger.warn(
|
|
87
131
|
`[coverage-capture] ⚠ ${err?.message ?? err} — falling back to freshness check.`,
|
|
88
132
|
);
|
|
89
133
|
changed = null;
|
|
90
134
|
}
|
|
91
135
|
if (changed && !anyChangedUnderTargets(changed, crap.targetDirs)) {
|
|
92
|
-
|
|
136
|
+
logger.info(
|
|
93
137
|
`[coverage-capture] No changed files under [${crap.targetDirs.join(', ')}] — skipping capture.`,
|
|
94
138
|
);
|
|
95
139
|
return 0;
|
|
96
140
|
}
|
|
97
141
|
}
|
|
98
142
|
|
|
99
|
-
const freshness =
|
|
143
|
+
const freshness = isCoverageFreshImpl({
|
|
100
144
|
coveragePath: crap.coveragePath,
|
|
101
145
|
targetDirs: crap.targetDirs,
|
|
102
146
|
cwd: args.cwd,
|
|
103
147
|
});
|
|
104
148
|
if (freshness.fresh) {
|
|
105
|
-
|
|
149
|
+
logger.info(
|
|
106
150
|
`[coverage-capture] Coverage at ${path.resolve(args.cwd, crap.coveragePath)} is ${freshness.reason} — skipping capture.`,
|
|
107
151
|
);
|
|
108
152
|
return 0;
|
|
109
153
|
}
|
|
110
154
|
|
|
111
|
-
|
|
155
|
+
logger.info(
|
|
112
156
|
`[coverage-capture] Coverage at ${crap.coveragePath} is ${freshness.reason}; running npm run test:coverage…`,
|
|
113
157
|
);
|
|
114
|
-
const code =
|
|
158
|
+
const code = runCaptureImpl({
|
|
115
159
|
cwd: args.cwd,
|
|
116
160
|
timeoutMs: coverage?.timeoutMs,
|
|
117
|
-
log: (m) =>
|
|
161
|
+
log: (m) => logger.info(m),
|
|
118
162
|
});
|
|
119
163
|
if (code !== 0) {
|
|
120
|
-
|
|
164
|
+
logger.error(
|
|
121
165
|
`[coverage-capture] ✖ npm run test:coverage exited ${code}. Fix failing tests or coverage-threshold breaches before re-running the CRAP gate.`,
|
|
122
166
|
);
|
|
123
167
|
return code;
|
|
@@ -127,24 +171,29 @@ function main() {
|
|
|
127
171
|
// freshness checks are content-aware (mtime churn from branch switches no
|
|
128
172
|
// longer invalidates). Best-effort — a missing stamp just means the next
|
|
129
173
|
// check falls back to the mtime heuristic.
|
|
130
|
-
const digest =
|
|
174
|
+
const digest = computeContentDigestImpl(args.cwd, crap.targetDirs);
|
|
131
175
|
if (
|
|
132
176
|
digest &&
|
|
133
|
-
|
|
177
|
+
writeCaptureStampImpl({
|
|
134
178
|
cwd: args.cwd,
|
|
135
179
|
coveragePath: crap.coveragePath,
|
|
136
180
|
digest,
|
|
137
181
|
})
|
|
138
182
|
) {
|
|
139
|
-
|
|
183
|
+
logger.info('[coverage-capture] Wrote content-digest capture stamp.');
|
|
140
184
|
}
|
|
141
185
|
return code;
|
|
142
186
|
}
|
|
143
187
|
|
|
144
188
|
// cli-opt-out: synchronous main returns an exit code that is forwarded via process.exit(code); runAsCli's async-main signature does not preserve the result code.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
189
|
+
// The direct-invocation guard keeps `import`ing this module (from the unit
|
|
190
|
+
// tests that drive `runCoverageCapture` with injected seams) side-effect free;
|
|
191
|
+
// invoked as a CLI the behaviour — exit code and log lines — is unchanged.
|
|
192
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
193
|
+
try {
|
|
194
|
+
process.exit(runCoverageCapture());
|
|
195
|
+
} catch (err) {
|
|
196
|
+
Logger.error('[coverage-capture] unexpected error:', err);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
150
199
|
}
|
|
@@ -79,18 +79,29 @@ import { createProvider } from './lib/provider-factory.js';
|
|
|
79
79
|
const HELP = `\
|
|
80
80
|
Usage:
|
|
81
81
|
deliver-light.js --prompt <text> [--creates csv] [--refactors csv]
|
|
82
|
-
[--acceptance n] [--
|
|
82
|
+
[--acceptance n] [--kinds csv] [--magnitude m]
|
|
83
|
+
[--uncertainty u] [--route lite|full] [--reason <text>]
|
|
83
84
|
[--amends '#id'] [--yes]
|
|
84
85
|
deliver-light.js --backstop --story <id>
|
|
85
86
|
|
|
86
87
|
The thin /deliver-light entry point: suitability gate → inline receipt Story →
|
|
87
88
|
the same single-story-init.js / single-story-close.js engine /deliver uses.
|
|
88
89
|
|
|
90
|
+
The gate judges EFFORT and RISK, not artifact counts: N instances of one
|
|
91
|
+
mechanical edit is one kind at N sites. It rejects only clearly-epic work; the
|
|
92
|
+
--backstop pass enforces size against the actual diff.
|
|
93
|
+
|
|
89
94
|
Gate options:
|
|
90
95
|
--prompt <text> Operator prompt describing the change. Required for the gate.
|
|
91
96
|
--creates <csv> Predicted NEW file paths (comma-separated).
|
|
92
97
|
--refactors <csv> Predicted edited/existing file paths (comma-separated).
|
|
93
|
-
--acceptance <n> Predicted acceptance-criteria count (default 1).
|
|
98
|
+
--acceptance <n> Predicted acceptance-criteria count (default 1). Not capped.
|
|
99
|
+
--kinds <csv> Distinct KINDS of change (default: one per assumption, so
|
|
100
|
+
N same-shaped edits count once).
|
|
101
|
+
--magnitude <m> Coarse effort bucket: trivial | moderate | substantial
|
|
102
|
+
(default moderate; substantial routes to /plan).
|
|
103
|
+
--uncertainty <u> determined (the request fixes the shape) | needs-design
|
|
104
|
+
(default determined; needs-design routes to /plan).
|
|
94
105
|
--route <r> Ledgered model verdict route: lite | full.
|
|
95
106
|
--reason <text> Recorded reason for a lite verdict (required for lite).
|
|
96
107
|
--amends <#id> Mark this as an amendment of an existing issue.
|
|
@@ -166,17 +177,25 @@ export function synthesizeAcceptance(count) {
|
|
|
166
177
|
* creates?: string[],
|
|
167
178
|
* refactors?: string[],
|
|
168
179
|
* acceptance?: number,
|
|
180
|
+
* kinds?: string[],
|
|
181
|
+
* magnitude?: string,
|
|
182
|
+
* uncertainty?: string,
|
|
169
183
|
* route?: string,
|
|
170
184
|
* reason?: string,
|
|
171
185
|
* yes?: boolean,
|
|
172
186
|
* injectedRules?: object,
|
|
173
|
-
* }} args
|
|
187
|
+
* }} args `kinds` / `magnitude` / `uncertainty` are the declared effort-and-risk
|
|
188
|
+
* axes the gate judges (Story #4764); omitting them declares no signal, not a
|
|
189
|
+
* small one — an unrecognized bucket fails closed.
|
|
174
190
|
* @returns {{ action: string, suitability: object, outcome: object }}
|
|
175
191
|
*/
|
|
176
192
|
export function runLightGate({
|
|
177
193
|
creates = [],
|
|
178
194
|
refactors = [],
|
|
179
195
|
acceptance,
|
|
196
|
+
kinds,
|
|
197
|
+
magnitude,
|
|
198
|
+
uncertainty,
|
|
180
199
|
route,
|
|
181
200
|
reason,
|
|
182
201
|
yes = false,
|
|
@@ -186,6 +205,9 @@ export function runLightGate({
|
|
|
186
205
|
const suitability = deriveLightSuitability({
|
|
187
206
|
predictedChanges,
|
|
188
207
|
predictedAcceptance: synthesizeAcceptance(acceptance),
|
|
208
|
+
predictedKinds: kinds,
|
|
209
|
+
predictedMagnitude: magnitude,
|
|
210
|
+
predictedUncertainty: uncertainty,
|
|
189
211
|
verdict: { route, reason },
|
|
190
212
|
injectedRules,
|
|
191
213
|
});
|
|
@@ -355,6 +377,9 @@ export async function runGateMode(values, deps = {}) {
|
|
|
355
377
|
acceptance: values.acceptance
|
|
356
378
|
? Number.parseInt(String(values.acceptance), 10)
|
|
357
379
|
: 1,
|
|
380
|
+
kinds: parseCsvPaths(values.kinds),
|
|
381
|
+
magnitude: values.magnitude,
|
|
382
|
+
uncertainty: values.uncertainty,
|
|
358
383
|
route: values.route,
|
|
359
384
|
reason: values.reason,
|
|
360
385
|
yes: values.yes === true,
|
|
@@ -417,6 +442,9 @@ async function main() {
|
|
|
417
442
|
creates: { type: 'string' },
|
|
418
443
|
refactors: { type: 'string' },
|
|
419
444
|
acceptance: { type: 'string' },
|
|
445
|
+
kinds: { type: 'string' },
|
|
446
|
+
magnitude: { type: 'string' },
|
|
447
|
+
uncertainty: { type: 'string' },
|
|
420
448
|
route: { type: 'string' },
|
|
421
449
|
reason: { type: 'string' },
|
|
422
450
|
amends: { type: 'string' },
|
|
@@ -82,19 +82,44 @@ export function parseArgv(argv) {
|
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
84
|
* Probe and report. Exported for testing.
|
|
85
|
+
*
|
|
86
|
+
* The optional final `deps` parameter is the module's injectable seam
|
|
87
|
+
* (`.agents/rules/test-seams.md` rules 1-2): config resolution, provider
|
|
88
|
+
* construction, the probe itself, the renderer, and the log sink each default
|
|
89
|
+
* to the real implementation, so the CLI path and every production caller are
|
|
90
|
+
* unchanged. The pre-existing `injected*` fields on the first argument stay
|
|
91
|
+
* supported for callers already threading a resolved provider/config.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} [args]
|
|
94
|
+
* @param {{
|
|
95
|
+
* resolveConfigImpl?: typeof resolveConfig,
|
|
96
|
+
* createProviderImpl?: typeof createProvider,
|
|
97
|
+
* recoverStoryImpl?: typeof recoverStory,
|
|
98
|
+
* renderRecoveryImpl?: typeof renderRecovery,
|
|
99
|
+
* logger?: { info: Function },
|
|
100
|
+
* }} [deps]
|
|
85
101
|
*/
|
|
86
|
-
export async function runDeliverRecover(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
102
|
+
export async function runDeliverRecover(
|
|
103
|
+
{
|
|
104
|
+
storyId: storyIdParam,
|
|
105
|
+
cwd: cwdParam,
|
|
106
|
+
json: jsonParam,
|
|
107
|
+
reprobe: reprobeParam,
|
|
108
|
+
argv,
|
|
109
|
+
injectedProvider,
|
|
110
|
+
injectedConfig,
|
|
111
|
+
injectedGh,
|
|
112
|
+
injectedGitSpawn,
|
|
113
|
+
injectedSleepFn,
|
|
114
|
+
} = {},
|
|
115
|
+
{
|
|
116
|
+
resolveConfigImpl = resolveConfig,
|
|
117
|
+
createProviderImpl = createProvider,
|
|
118
|
+
recoverStoryImpl = recoverStory,
|
|
119
|
+
renderRecoveryImpl = renderRecovery,
|
|
120
|
+
logger = Logger,
|
|
121
|
+
} = {},
|
|
122
|
+
) {
|
|
98
123
|
const parsed =
|
|
99
124
|
storyIdParam !== undefined
|
|
100
125
|
? {
|
|
@@ -106,7 +131,7 @@ export async function runDeliverRecover({
|
|
|
106
131
|
: parseArgv(argv ?? process.argv.slice(2));
|
|
107
132
|
|
|
108
133
|
if (parsed.help) {
|
|
109
|
-
|
|
134
|
+
logger.info(HELP);
|
|
110
135
|
return { success: true, result: null };
|
|
111
136
|
}
|
|
112
137
|
if (!Number.isInteger(parsed.storyId) || parsed.storyId <= 0) {
|
|
@@ -116,10 +141,10 @@ export async function runDeliverRecover({
|
|
|
116
141
|
}
|
|
117
142
|
|
|
118
143
|
const cwd = parsed.cwd ?? PROJECT_ROOT;
|
|
119
|
-
const config = injectedConfig ||
|
|
120
|
-
const provider = injectedProvider ||
|
|
144
|
+
const config = injectedConfig || resolveConfigImpl({ cwd });
|
|
145
|
+
const provider = injectedProvider || createProviderImpl(config);
|
|
121
146
|
|
|
122
|
-
const recovery = await
|
|
147
|
+
const recovery = await recoverStoryImpl({
|
|
123
148
|
storyId: parsed.storyId,
|
|
124
149
|
cwd,
|
|
125
150
|
provider,
|
|
@@ -130,8 +155,10 @@ export async function runDeliverRecover({
|
|
|
130
155
|
...(injectedSleepFn ? { sleepFn: injectedSleepFn } : {}),
|
|
131
156
|
});
|
|
132
157
|
|
|
133
|
-
|
|
134
|
-
parsed.json
|
|
158
|
+
logger.info(
|
|
159
|
+
parsed.json
|
|
160
|
+
? JSON.stringify(recovery, null, 2)
|
|
161
|
+
: renderRecoveryImpl(recovery),
|
|
135
162
|
);
|
|
136
163
|
return { success: true, result: recovery };
|
|
137
164
|
}
|
|
@@ -39,8 +39,44 @@ const progress = Logger.createProgress('drain-pending-cleanup', {
|
|
|
39
39
|
stderr: false,
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The drain core, extracted from the CLI shell so the whole decision table —
|
|
44
|
+
* empty manifest, dry-run report, drain + escalate reporting — is reachable
|
|
45
|
+
* without a real worktree ledger or a real process kill.
|
|
46
|
+
*
|
|
47
|
+
* Every seam on the optional final `deps` parameter defaults to the real
|
|
48
|
+
* implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main` and
|
|
49
|
+
* every production caller are unchanged.
|
|
50
|
+
*
|
|
51
|
+
* @param {string[]} [argv]
|
|
52
|
+
* @param {{
|
|
53
|
+
* resolveConfigImpl?: typeof resolveConfig,
|
|
54
|
+
* readManifestImpl?: typeof readManifest,
|
|
55
|
+
* findHoldersInPathImpl?: typeof findHoldersInPath,
|
|
56
|
+
* forceDrainImpl?: typeof forceDrainPendingCleanup,
|
|
57
|
+
* gitImpl?: typeof gitUtils,
|
|
58
|
+
* projectRoot?: string,
|
|
59
|
+
* progressImpl?: (phase: string, message: string) => void,
|
|
60
|
+
* logger?: { error: Function },
|
|
61
|
+
* }} [deps]
|
|
62
|
+
* @returns {Promise<{ drained: number[], remaining: number } | { remaining: number }>}
|
|
63
|
+
*/
|
|
64
|
+
export async function runDrainPendingCleanup(
|
|
65
|
+
argv = process.argv.slice(2),
|
|
66
|
+
deps = {},
|
|
67
|
+
) {
|
|
68
|
+
const {
|
|
69
|
+
resolveConfigImpl = resolveConfig,
|
|
70
|
+
readManifestImpl = readManifest,
|
|
71
|
+
findHoldersInPathImpl = findHoldersInPath,
|
|
72
|
+
forceDrainImpl = forceDrainPendingCleanup,
|
|
73
|
+
gitImpl = gitUtils,
|
|
74
|
+
projectRoot = PROJECT_ROOT,
|
|
75
|
+
progressImpl = progress,
|
|
76
|
+
logger = Logger,
|
|
77
|
+
} = deps;
|
|
43
78
|
const { values } = parseArgs({
|
|
79
|
+
args: argv,
|
|
44
80
|
options: {
|
|
45
81
|
escalate: { type: 'boolean', default: true },
|
|
46
82
|
'dry-run': { type: 'boolean', default: false },
|
|
@@ -49,20 +85,23 @@ async function main() {
|
|
|
49
85
|
strict: false,
|
|
50
86
|
});
|
|
51
87
|
|
|
52
|
-
const config =
|
|
88
|
+
const config = resolveConfigImpl();
|
|
53
89
|
const wtConfig = config.delivery?.worktreeIsolation;
|
|
54
90
|
const worktreeRoot = path.resolve(
|
|
55
|
-
|
|
91
|
+
projectRoot,
|
|
56
92
|
values['worktree-root'] ?? wtConfig?.root ?? '.worktrees',
|
|
57
93
|
);
|
|
58
94
|
|
|
59
|
-
const before =
|
|
95
|
+
const before = readManifestImpl(worktreeRoot);
|
|
60
96
|
if (before.length === 0) {
|
|
61
|
-
|
|
62
|
-
|
|
97
|
+
progressImpl(
|
|
98
|
+
'SCAN',
|
|
99
|
+
'pending-cleanup manifest is empty — nothing to drain.',
|
|
100
|
+
);
|
|
101
|
+
return { remaining: 0 };
|
|
63
102
|
}
|
|
64
103
|
|
|
65
|
-
|
|
104
|
+
progressImpl(
|
|
66
105
|
'SCAN',
|
|
67
106
|
`pending-cleanup manifest has ${before.length} entry(ies): ${before
|
|
68
107
|
.map((e) => `story-${e.storyId}(attempts=${e.attempts ?? 0})`)
|
|
@@ -71,8 +110,8 @@ async function main() {
|
|
|
71
110
|
|
|
72
111
|
if (values['dry-run']) {
|
|
73
112
|
for (const entry of before) {
|
|
74
|
-
const holders =
|
|
75
|
-
|
|
113
|
+
const holders = findHoldersInPathImpl(entry.path);
|
|
114
|
+
progressImpl(
|
|
76
115
|
'DRY-RUN',
|
|
77
116
|
`story-${entry.storyId} path=${entry.path} holders=${holders.length}` +
|
|
78
117
|
(holders.length > 0
|
|
@@ -80,23 +119,23 @@ async function main() {
|
|
|
80
119
|
: ''),
|
|
81
120
|
);
|
|
82
121
|
}
|
|
83
|
-
return;
|
|
122
|
+
return { remaining: before.length };
|
|
84
123
|
}
|
|
85
124
|
|
|
86
|
-
const result = await
|
|
87
|
-
repoRoot:
|
|
125
|
+
const result = await forceDrainImpl({
|
|
126
|
+
repoRoot: projectRoot,
|
|
88
127
|
worktreeRoot,
|
|
89
|
-
git:
|
|
128
|
+
git: gitImpl,
|
|
90
129
|
escalate: values.escalate,
|
|
91
130
|
logger: {
|
|
92
|
-
info: (m) =>
|
|
93
|
-
warn: (m) =>
|
|
94
|
-
error: (m) =>
|
|
131
|
+
info: (m) => progressImpl('DRAIN', m),
|
|
132
|
+
warn: (m) => progressImpl('DRAIN', `⚠️ ${m}`),
|
|
133
|
+
error: (m) => logger.error(`[drain-pending-cleanup] ${m}`),
|
|
95
134
|
},
|
|
96
135
|
});
|
|
97
136
|
|
|
98
137
|
if (result.drained.length > 0) {
|
|
99
|
-
|
|
138
|
+
progressImpl(
|
|
100
139
|
'DRAIN',
|
|
101
140
|
`✅ drained ${result.drained.length} entry(ies): ${result.drained
|
|
102
141
|
.map((id) => `story-${id}`)
|
|
@@ -107,10 +146,10 @@ async function main() {
|
|
|
107
146
|
const summary = result.escalated
|
|
108
147
|
.map((id) => `story-${id}=[${(result.killedPids[id] ?? []).join(',')}]`)
|
|
109
148
|
.join(', ');
|
|
110
|
-
|
|
149
|
+
progressImpl('ESCALATE', `terminated holders: ${summary}`);
|
|
111
150
|
}
|
|
112
151
|
if (result.noHolders && result.noHolders.length > 0) {
|
|
113
|
-
|
|
152
|
+
progressImpl(
|
|
114
153
|
'ESCALATE',
|
|
115
154
|
`⚠️ no user-mode holders for: ${result.noHolders
|
|
116
155
|
.map((id) => `story-${id}`)
|
|
@@ -120,7 +159,7 @@ async function main() {
|
|
|
120
159
|
);
|
|
121
160
|
}
|
|
122
161
|
if (result.persistent.length > 0) {
|
|
123
|
-
|
|
162
|
+
progressImpl(
|
|
124
163
|
'PERSIST',
|
|
125
164
|
`⚠️ persistent-lock remains on: ${result.persistent
|
|
126
165
|
.map((id) => `story-${id}`)
|
|
@@ -128,7 +167,7 @@ async function main() {
|
|
|
128
167
|
);
|
|
129
168
|
}
|
|
130
169
|
if (result.stillPending.length > 0) {
|
|
131
|
-
|
|
170
|
+
progressImpl(
|
|
132
171
|
'STILL-PENDING',
|
|
133
172
|
`⚠️ still-pending (below threshold): ${result.stillPending
|
|
134
173
|
.map((id) => `story-${id}`)
|
|
@@ -136,12 +175,17 @@ async function main() {
|
|
|
136
175
|
);
|
|
137
176
|
}
|
|
138
177
|
|
|
139
|
-
const after =
|
|
140
|
-
|
|
178
|
+
const after = readManifestImpl(worktreeRoot);
|
|
179
|
+
progressImpl(
|
|
141
180
|
'DONE',
|
|
142
181
|
`pending-cleanup manifest now has ${after.length} entry(ies). ` +
|
|
143
182
|
`Drained=${result.drained.length}, escalated=${result.escalated.length}, persistent=${result.persistent.length}.`,
|
|
144
183
|
);
|
|
184
|
+
return { drained: result.drained, remaining: after.length };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function main() {
|
|
188
|
+
await runDrainPendingCleanup();
|
|
145
189
|
}
|
|
146
190
|
|
|
147
191
|
runAsCli(import.meta.url, main, {
|