mandrel 2.66.0 → 2.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/acceptance-critic.md +2 -2
- package/.agents/agents/story-worker.md +15 -11
- package/.agents/docs/agentrc-reference.json +5 -2
- package/.agents/docs/configuration.md +38 -2
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +2 -1
- package/.agents/rules/git-conventions-reference.md +5 -5
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/schemas/agentrc.schema.json +20 -2
- package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/boot-sweep.js +97 -9
- package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
- package/.agents/scripts/clean-temp.js +54 -0
- package/.agents/scripts/clean-worktrees.js +593 -0
- package/.agents/scripts/coverage-capture.js +65 -9
- package/.agents/scripts/drain-pending-cleanup.js +5 -4
- package/.agents/scripts/evidence-gate.js +106 -8
- package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
- package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
- package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
- package/.agents/scripts/lib/child-exec.js +39 -1
- package/.agents/scripts/lib/clean-temp.js +440 -0
- package/.agents/scripts/lib/close-validation/gates.js +59 -19
- package/.agents/scripts/lib/close-validation/process.js +23 -24
- package/.agents/scripts/lib/close-validation/runner.js +71 -40
- package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
- package/.agents/scripts/lib/config/quality.js +7 -1
- package/.agents/scripts/lib/config/temp-paths.js +15 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +12 -3
- package/.agents/scripts/lib/coverage-baseline.js +78 -5
- package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
- package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
- package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
- package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
- package/.agents/scripts/lib/coverage-capture.js +121 -81
- package/.agents/scripts/lib/full-suite-lock.js +49 -46
- package/.agents/scripts/lib/full-suite-queue.js +83 -8
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +4 -1
- package/.agents/scripts/lib/orchestration/code-review.js +15 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
- package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
- package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
- package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
- package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
- package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
- package/.agents/scripts/lib/process-group.js +1 -1
- package/.agents/scripts/lib/single-story-sweep.js +2 -2
- package/.agents/scripts/lib/supervised-suite.js +247 -0
- package/.agents/scripts/lib/temp-removal.js +110 -0
- package/.agents/scripts/lib/temp-retention.js +122 -73
- package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
- package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
- package/.agents/scripts/quality-preview.js +112 -14
- package/.agents/scripts/single-story-init.js +120 -17
- package/.agents/scripts/stories-wave-tick.js +47 -0
- package/.agents/scripts/story-review-compute.js +207 -0
- package/.agents/scripts/update-coverage-baseline.js +15 -10
- package/.agents/scripts/update-crap-baseline.js +12 -2
- package/.agents/scripts/update-maintainability-baseline.js +12 -2
- package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
- package/.agents/workflows/clean-temp.md +67 -0
- package/.agents/workflows/clean-worktrees.md +63 -0
- package/.agents/workflows/git-deliver.md +1 -1
- package/.agents/workflows/helpers/acceptance-self-eval.md +3 -2
- package/.agents/workflows/helpers/code-review.md +7 -5
- package/.agents/workflows/helpers/deliver-digest.md +39 -36
- package/.agents/workflows/helpers/deliver-reference.md +115 -5
- package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +2 -1
- package/docs/CHANGELOG.md +39 -0
- package/lib/cli/registry.js +125 -18
- package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
- package/package.json +1 -1
|
@@ -37,6 +37,8 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
|
|
|
37
37
|
'check-test-temp-hygiene.js',
|
|
38
38
|
'check-windows-git-perf.js',
|
|
39
39
|
'ceremony-derive.js',
|
|
40
|
+
'clean-temp.js',
|
|
41
|
+
'clean-worktrees.js',
|
|
40
42
|
'cleanup-repo-test-temp.js',
|
|
41
43
|
'coverage-capture.js',
|
|
42
44
|
'deliver-light.js',
|
|
@@ -51,7 +53,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
|
|
|
51
53
|
'generate-lens-checklists.js',
|
|
52
54
|
'generate-skills-index.js',
|
|
53
55
|
'generate-workflows-doc.js',
|
|
54
|
-
'git
|
|
56
|
+
'clean-git.js',
|
|
55
57
|
'lint-issue-body.js',
|
|
56
58
|
'mandrel-update-preflight.js',
|
|
57
59
|
'merge-baseline.js',
|
|
@@ -73,6 +75,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
|
|
|
73
75
|
'single-story-confirm-merge.js',
|
|
74
76
|
'single-story-init.js',
|
|
75
77
|
'stories-wave-tick.js',
|
|
78
|
+
'story-review-compute.js',
|
|
76
79
|
'sync-agentrc.js',
|
|
77
80
|
'sync-claude-agents.js',
|
|
78
81
|
'sync-claude-commands.js',
|
|
@@ -103,10 +103,13 @@ function resolveScopeEnvelope(opts, config) {
|
|
|
103
103
|
* createReviewProviderFn?: typeof createReviewProvider,
|
|
104
104
|
* upsertCommentFn?: typeof upsertStructuredComment,
|
|
105
105
|
* renderFindingsFn?: typeof renderFindings,
|
|
106
|
+
* deferPost?: boolean,
|
|
106
107
|
* }} opts
|
|
107
108
|
* @returns {Promise<{
|
|
108
109
|
* status: 'ok'|'no-changes'|'invalid',
|
|
109
110
|
* severity: { critical: number, high: number, medium: number, suggestion: number },
|
|
111
|
+
* findings?: Array<object>,
|
|
112
|
+
* providerName?: string,
|
|
110
113
|
* report?: string,
|
|
111
114
|
* posted: boolean,
|
|
112
115
|
* postedCommentId: number|null,
|
|
@@ -196,9 +199,10 @@ async function resolvePromptMessages(reviewProvider, reviewInput, logger) {
|
|
|
196
199
|
}
|
|
197
200
|
|
|
198
201
|
/**
|
|
199
|
-
* Posting failure is non-fatal and surfaces as `posted: false`.
|
|
202
|
+
* Posting failure is non-fatal and surfaces as `posted: false`. Also posts a
|
|
203
|
+
* `deferPost` review's held report.
|
|
200
204
|
*/
|
|
201
|
-
async function postReviewComment({
|
|
205
|
+
export async function postReviewComment({
|
|
202
206
|
upsertCommentFn,
|
|
203
207
|
provider,
|
|
204
208
|
commentTargetId,
|
|
@@ -230,6 +234,12 @@ async function postReviewComment({
|
|
|
230
234
|
}
|
|
231
235
|
}
|
|
232
236
|
|
|
237
|
+
/** A `deferPost` review holds its report for the caller to post. */
|
|
238
|
+
async function postUnlessDeferred(opts, post) {
|
|
239
|
+
if (opts.deferPost) return { posted: false, postedCommentId: null };
|
|
240
|
+
return postReviewComment(post);
|
|
241
|
+
}
|
|
242
|
+
|
|
233
243
|
/**
|
|
234
244
|
* Which review provider(s) raised the critical findings. A chain reports its
|
|
235
245
|
* own per-entry attribution; any other provider owns every critical.
|
|
@@ -309,7 +319,7 @@ async function executeReviewPipeline({ opts, config, envelope }) {
|
|
|
309
319
|
degradations,
|
|
310
320
|
});
|
|
311
321
|
|
|
312
|
-
const { posted, postedCommentId } = await
|
|
322
|
+
const { posted, postedCommentId } = await postUnlessDeferred(opts, {
|
|
313
323
|
upsertCommentFn,
|
|
314
324
|
provider,
|
|
315
325
|
commentTargetId,
|
|
@@ -320,6 +330,8 @@ async function executeReviewPipeline({ opts, config, envelope }) {
|
|
|
320
330
|
return {
|
|
321
331
|
status: 'ok',
|
|
322
332
|
severity,
|
|
333
|
+
findings,
|
|
334
|
+
providerName,
|
|
323
335
|
report,
|
|
324
336
|
posted,
|
|
325
337
|
postedCommentId,
|
|
@@ -26,6 +26,11 @@ export function pollIntervalMs(checksStatus, intervalSeconds) {
|
|
|
26
26
|
}
|
|
27
27
|
export const DEFAULT_MAX_BUDGET_SECONDS = 3600;
|
|
28
28
|
|
|
29
|
+
/** CI never reddens in the first minute, so only green or red polls on. */
|
|
30
|
+
export function probeSettlesAsyncWait(checksStatus) {
|
|
31
|
+
return checksStatus !== 'success' && checksStatus !== 'failure';
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
/** Bounds every `gh` spawn so a hang degrades to the probe-error path. */
|
|
30
35
|
export const MERGE_WAIT_GH_TIMEOUT_MS = 60_000;
|
|
31
36
|
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* plan-runner/worktree-sweep.js —
|
|
2
|
+
* plan-runner/worktree-sweep.js — boot sweep that removes
|
|
3
3
|
* `.worktrees/story-<id>/` entries whose Story is closed or `agent::done`,
|
|
4
|
-
* after draining the pending-cleanup manifest.
|
|
4
|
+
* after draining the pending-cleanup manifest. Called by `runBootSweep`
|
|
5
|
+
* (`boot-sweep.js`) under the shared merged-branch sweep lock.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Removal goes through `removeWorktreeWithRecovery` — the same seam close
|
|
8
|
+
* uses: Windows lock retries, an `fs.rm` fallback that discards residue a
|
|
9
|
+
* done Story's merged branch no longer needs, and the pending-cleanup
|
|
10
|
+
* hand-off when that exhausts. Branches are never deleted here; the
|
|
11
|
+
* merged-branch sweep owns them.
|
|
12
|
+
*
|
|
13
|
+
* Two trees are never touched: an open Story's, and the one the running
|
|
14
|
+
* process was loaded from or is working in (`findRunningCodeInside`).
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
17
|
import path from 'node:path';
|
|
@@ -13,8 +19,13 @@ import * as defaultGit from '../../git-utils.js';
|
|
|
13
19
|
import { NOOP_LOGGER } from '../../Logger.js';
|
|
14
20
|
import { AGENT_LABELS } from '../../label-constants.js';
|
|
15
21
|
import { concurrentMap } from '../../util/concurrent-map.js';
|
|
16
|
-
import {
|
|
22
|
+
import { canonicalPath } from '../../worktree/canonical-path.js';
|
|
23
|
+
import { parseWorktreePorcelain, samePath } from '../../worktree/inspector.js';
|
|
17
24
|
import { forceDrainPendingCleanup } from '../../worktree/lifecycle/force-drain.js';
|
|
25
|
+
import {
|
|
26
|
+
findRunningCodeInside,
|
|
27
|
+
removeWorktreeWithRecovery,
|
|
28
|
+
} from '../../worktree/lifecycle/reap.js';
|
|
18
29
|
|
|
19
30
|
const TICKET_READ_CONCURRENCY = 8;
|
|
20
31
|
|
|
@@ -27,33 +38,32 @@ function isStoryDone(ticket) {
|
|
|
27
38
|
return labels.includes(DONE_LABEL);
|
|
28
39
|
}
|
|
29
40
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The Story id of a `<worktreeRoot>/story-<id>` entry, else `null` — a
|
|
43
|
+
* `story-<id>` directory anywhere else (e.g. `.claude/worktrees/`) is not
|
|
44
|
+
* this sweep's to judge.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} wtPath
|
|
47
|
+
* @param {string} worktreeRoot
|
|
48
|
+
* @param {string} platform
|
|
49
|
+
* @returns {number|null}
|
|
50
|
+
*/
|
|
51
|
+
function storyIdFromPath(wtPath, worktreeRoot, platform) {
|
|
52
|
+
const parent = path.dirname(canonicalPath(wtPath));
|
|
53
|
+
if (!samePath(parent, canonicalPath(worktreeRoot), platform)) return null;
|
|
54
|
+
return defaultGit.parseStoryBranch(path.basename(path.resolve(wtPath)));
|
|
34
55
|
}
|
|
35
56
|
|
|
36
57
|
/**
|
|
37
|
-
*
|
|
58
|
+
* Validate and normalise the options bag.
|
|
38
59
|
*
|
|
39
60
|
* @param {object} opts
|
|
40
|
-
* @
|
|
41
|
-
* @param {string} opts.repoRoot Absolute path to the main checkout.
|
|
42
|
-
* @param {object} [opts.git]
|
|
43
|
-
* @param {object} [opts.logger]
|
|
44
|
-
* @returns {Promise<{
|
|
45
|
-
* reaped: Array<{ storyId: number, path: string }>,
|
|
46
|
-
* skipped: Array<{ storyId: number|null, path: string, reason: string }>,
|
|
47
|
-
* }>}
|
|
61
|
+
* @returns {object}
|
|
48
62
|
*/
|
|
49
|
-
|
|
63
|
+
function resolveSweepOptions(opts) {
|
|
50
64
|
const ctx = opts.ctx;
|
|
51
65
|
const provider = opts.provider ?? ctx?.provider;
|
|
52
66
|
const repoRoot = opts.repoRoot ?? ctx?.cwd;
|
|
53
|
-
const git = opts.git ?? defaultGit;
|
|
54
|
-
const logger = opts.logger ?? ctx?.logger ?? NOOP_LOGGER;
|
|
55
|
-
const fsRm = opts.fsRm;
|
|
56
|
-
const worktreeRoot = opts.worktreeRoot;
|
|
57
67
|
if (!provider || typeof provider.getTicket !== 'function') {
|
|
58
68
|
throw new Error(
|
|
59
69
|
'sweepStaleStoryWorktrees: provider with getTicket(id) is required',
|
|
@@ -62,107 +72,149 @@ export async function sweepStaleStoryWorktrees(opts = {}) {
|
|
|
62
72
|
if (!repoRoot || typeof repoRoot !== 'string') {
|
|
63
73
|
throw new Error('sweepStaleStoryWorktrees: repoRoot is required');
|
|
64
74
|
}
|
|
75
|
+
const platform = opts.platform ?? process.platform;
|
|
76
|
+
return {
|
|
77
|
+
provider,
|
|
78
|
+
repoRoot,
|
|
79
|
+
platform,
|
|
80
|
+
git: opts.git ?? defaultGit,
|
|
81
|
+
logger: opts.logger ?? ctx?.logger ?? NOOP_LOGGER,
|
|
82
|
+
fsRm: opts.fsRm,
|
|
83
|
+
sleepFn: opts.sleepFn,
|
|
84
|
+
worktreeRoot: path.resolve(
|
|
85
|
+
opts.worktreeRoot ?? path.join(repoRoot, '.worktrees'),
|
|
86
|
+
),
|
|
87
|
+
guardPaths: [process.cwd(), ...(opts.runningPaths ?? [])],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
65
90
|
|
|
66
|
-
|
|
67
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Read every Story worktree's ticket; each mapper captures its own error so
|
|
93
|
+
* one provider hiccup cannot abort the sweep via concurrentMap's
|
|
94
|
+
* first-rejection-wins policy.
|
|
95
|
+
*/
|
|
96
|
+
function readStoryTickets(entries, o) {
|
|
97
|
+
return concurrentMap(
|
|
98
|
+
entries,
|
|
99
|
+
async (entry) => {
|
|
100
|
+
const wtPath = entry.path;
|
|
101
|
+
const storyId = wtPath
|
|
102
|
+
? storyIdFromPath(wtPath, o.worktreeRoot, o.platform)
|
|
103
|
+
: null;
|
|
104
|
+
if (storyId === null) return { kind: 'non-story' };
|
|
105
|
+
try {
|
|
106
|
+
const ticket = await o.provider.getTicket(storyId);
|
|
107
|
+
return { kind: 'ok', wtPath, storyId, ticket };
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return { kind: 'provider-error', wtPath, storyId, error: err };
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
{ concurrency: TICKET_READ_CONCURRENCY },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Why a read entry must be kept, or `null` when it may be removed.
|
|
118
|
+
*
|
|
119
|
+
* @returns {string|null}
|
|
120
|
+
*/
|
|
121
|
+
function keepReason(r, o) {
|
|
122
|
+
if (r.kind === 'provider-error') {
|
|
123
|
+
o.logger.warn(
|
|
124
|
+
`worktree-sweep: provider.getTicket(#${r.storyId}) failed: ${r.error.message}`,
|
|
125
|
+
);
|
|
126
|
+
return `provider-error: ${r.error.message}`;
|
|
127
|
+
}
|
|
128
|
+
if (!isStoryDone(r.ticket)) return 'story-open';
|
|
129
|
+
const selfPath = findRunningCodeInside(o, r.wtPath, o.guardPaths);
|
|
130
|
+
if (selfPath) {
|
|
131
|
+
o.logger.warn(
|
|
132
|
+
`worktree-sweep: skipped storyId=${r.storyId} path=${r.wtPath} — the running process uses it (${selfPath})`,
|
|
133
|
+
);
|
|
134
|
+
return 'running-from-target-tree';
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function removeOne(r, o) {
|
|
140
|
+
const ctx = {
|
|
141
|
+
repoRoot: o.repoRoot,
|
|
142
|
+
git: o.git,
|
|
143
|
+
logger: o.logger,
|
|
144
|
+
platform: o.platform,
|
|
145
|
+
worktreeRoot: o.worktreeRoot,
|
|
146
|
+
listCache: { list: null, ts: 0 },
|
|
147
|
+
fsRm: o.fsRm,
|
|
148
|
+
};
|
|
149
|
+
const res = await removeWorktreeWithRecovery(ctx, r.wtPath, {
|
|
150
|
+
storyId: r.storyId,
|
|
151
|
+
...(o.sleepFn ? { sleepFn: o.sleepFn, retryDelay: 0 } : {}),
|
|
152
|
+
});
|
|
153
|
+
return res.removed
|
|
154
|
+
? null
|
|
155
|
+
: `remove-failed: ${res.reason ?? 'worktree-remove-failed'}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Never touches a worktree whose Story is still open, nor the one the
|
|
160
|
+
* running process uses.
|
|
161
|
+
*
|
|
162
|
+
* @param {object} opts
|
|
163
|
+
* @param {object} opts.provider Only `getTicket(id)` is required.
|
|
164
|
+
* @param {string} opts.repoRoot Absolute path to the main checkout.
|
|
165
|
+
* @param {object} [opts.git]
|
|
166
|
+
* @param {object} [opts.logger]
|
|
167
|
+
* @param {string[]} [opts.runningPaths] Extra paths the running process owns.
|
|
168
|
+
* @returns {Promise<{
|
|
169
|
+
* reaped: Array<{ storyId: number, path: string }>,
|
|
170
|
+
* skipped: Array<{ storyId: number|null, path: string, reason: string }>,
|
|
171
|
+
* }>}
|
|
172
|
+
*/
|
|
173
|
+
export async function sweepStaleStoryWorktrees(opts = {}) {
|
|
174
|
+
const o = resolveSweepOptions(opts);
|
|
68
175
|
|
|
69
176
|
// Drain the pending-cleanup manifest first so entries whose Windows locks
|
|
70
177
|
// have released self-heal instead of accumulating.
|
|
71
178
|
const drainResult = await forceDrainPendingCleanup({
|
|
72
|
-
repoRoot,
|
|
73
|
-
worktreeRoot:
|
|
74
|
-
git,
|
|
75
|
-
fsRm,
|
|
76
|
-
logger,
|
|
179
|
+
repoRoot: o.repoRoot,
|
|
180
|
+
worktreeRoot: o.worktreeRoot,
|
|
181
|
+
git: o.git,
|
|
182
|
+
fsRm: o.fsRm,
|
|
183
|
+
logger: o.logger,
|
|
77
184
|
});
|
|
78
185
|
|
|
79
186
|
const reaped = [];
|
|
80
187
|
const skipped = [];
|
|
81
188
|
|
|
82
|
-
const listRes = git.gitSpawn(repoRoot, 'worktree', 'list', '--porcelain');
|
|
189
|
+
const listRes = o.git.gitSpawn(o.repoRoot, 'worktree', 'list', '--porcelain');
|
|
83
190
|
if (listRes.status !== 0) {
|
|
84
|
-
logger.warn(
|
|
191
|
+
o.logger.warn(
|
|
85
192
|
`worktree-sweep: git worktree list failed: ${listRes.stderr || listRes.stdout || 'unknown'}`,
|
|
86
193
|
);
|
|
87
194
|
return { reaped, skipped };
|
|
88
195
|
}
|
|
89
196
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// the sweep via concurrentMap's first-rejection-wins policy.
|
|
94
|
-
const reads = await concurrentMap(
|
|
95
|
-
entries,
|
|
96
|
-
async (entry) => {
|
|
97
|
-
const wtPath = entry.path;
|
|
98
|
-
if (!wtPath) return { kind: 'no-path' };
|
|
99
|
-
const storyId = storyIdFromPath(wtPath);
|
|
100
|
-
if (storyId === null) return { kind: 'non-story' };
|
|
101
|
-
try {
|
|
102
|
-
const ticket = await provider.getTicket(storyId);
|
|
103
|
-
return { kind: 'ok', wtPath, storyId, ticket };
|
|
104
|
-
} catch (err) {
|
|
105
|
-
return { kind: 'provider-error', wtPath, storyId, error: err };
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
{ concurrency: TICKET_READ_CONCURRENCY },
|
|
197
|
+
const reads = await readStoryTickets(
|
|
198
|
+
parseWorktreePorcelain(listRes.stdout || ''),
|
|
199
|
+
o,
|
|
109
200
|
);
|
|
110
201
|
|
|
111
202
|
// Removes stay sequential: they mutate .git/worktrees/, and racing git's
|
|
112
203
|
// locking on Windows can leave a partial admin dir the next remove trips on.
|
|
113
204
|
for (const r of reads) {
|
|
114
|
-
if (r.kind === '
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
path: r.wtPath,
|
|
119
|
-
reason: `provider-error: ${r.error.message}`,
|
|
120
|
-
});
|
|
121
|
-
logger.warn(
|
|
122
|
-
`worktree-sweep: provider.getTicket(#${r.storyId}) failed: ${r.error.message}`,
|
|
123
|
-
);
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (!isStoryDone(r.ticket)) {
|
|
128
|
-
skipped.push({
|
|
129
|
-
storyId: r.storyId,
|
|
130
|
-
path: r.wtPath,
|
|
131
|
-
reason: 'story-open',
|
|
132
|
-
});
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const res = git.gitSpawn(
|
|
137
|
-
repoRoot,
|
|
138
|
-
'worktree',
|
|
139
|
-
'remove',
|
|
140
|
-
'--force',
|
|
141
|
-
r.wtPath,
|
|
142
|
-
);
|
|
143
|
-
if (res.status !== 0) {
|
|
144
|
-
const reason = (
|
|
145
|
-
res.stderr ||
|
|
146
|
-
res.stdout ||
|
|
147
|
-
'worktree-remove-failed'
|
|
148
|
-
).trim();
|
|
149
|
-
skipped.push({
|
|
150
|
-
storyId: r.storyId,
|
|
151
|
-
path: r.wtPath,
|
|
152
|
-
reason: `remove-failed: ${reason}`,
|
|
153
|
-
});
|
|
154
|
-
logger.warn(
|
|
155
|
-
`worktree-sweep: failed to reap storyId=${r.storyId} path=${r.wtPath}: ${reason}`,
|
|
156
|
-
);
|
|
205
|
+
if (r.kind === 'non-story') continue;
|
|
206
|
+
const reason = keepReason(r, o) ?? (await removeOne(r, o));
|
|
207
|
+
if (reason) {
|
|
208
|
+
skipped.push({ storyId: r.storyId, path: r.wtPath, reason });
|
|
157
209
|
continue;
|
|
158
210
|
}
|
|
159
211
|
reaped.push({ storyId: r.storyId, path: r.wtPath });
|
|
160
|
-
logger.info(
|
|
212
|
+
o.logger.info(
|
|
161
213
|
`worktree-sweep: reaped stale worktree storyId=${r.storyId} path=${r.wtPath}`,
|
|
162
214
|
);
|
|
163
215
|
}
|
|
164
216
|
|
|
165
|
-
git.gitSpawn(repoRoot, 'worktree', 'prune');
|
|
217
|
+
o.git.gitSpawn(o.repoRoot, 'worktree', 'prune');
|
|
166
218
|
|
|
167
219
|
return {
|
|
168
220
|
reaped,
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* review-deposit.js — the held Story-scope review a worker computes at push
|
|
3
|
+
* time, and its identity.
|
|
4
|
+
*
|
|
5
|
+
* The identity is the **diff digest**: a sha256 of the exact
|
|
6
|
+
* `git diff --no-color <base>...<head>` text the review provider is handed.
|
|
7
|
+
* A clean base-sync merge moves HEAD without changing that text, while any
|
|
8
|
+
* commit that changes the diff changes it — so close adopts a deposit, and
|
|
9
|
+
* posts a held result, on digest equality, never on SHA equality.
|
|
10
|
+
*
|
|
11
|
+
* The file lives beside the terminal envelope; nothing deletes it but the
|
|
12
|
+
* Story temp purge. A mismatched deposit is simply not adopted.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import nodeFs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { storyReviewDepositPath } from '../config/temp-paths.js';
|
|
20
|
+
import { resolveSharedBaseRef } from './review-base-ref.js';
|
|
21
|
+
|
|
22
|
+
const DEPOSIT_KIND = 'story-review-deposit';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{ cwd: string, ref: string, gitSpawnFn: Function }} args
|
|
26
|
+
* @returns {string|null} the commit `ref` points at.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveRefSha({ cwd, ref, gitSpawnFn }) {
|
|
29
|
+
try {
|
|
30
|
+
const probe = gitSpawnFn(
|
|
31
|
+
cwd,
|
|
32
|
+
'rev-parse',
|
|
33
|
+
'--verify',
|
|
34
|
+
'--quiet',
|
|
35
|
+
`${ref}^{commit}`,
|
|
36
|
+
);
|
|
37
|
+
const sha = probe?.status === 0 ? String(probe.stdout ?? '').trim() : '';
|
|
38
|
+
return sha.length > 0 ? sha : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* sha256 of the three-dot diff text, spawned exactly as the review provider
|
|
46
|
+
* spawns it. `null` when the diff cannot be read — an unknown identity that
|
|
47
|
+
* never matches anything.
|
|
48
|
+
*
|
|
49
|
+
* @param {{ cwd: string, baseRef: string|null, headRef: string|null,
|
|
50
|
+
* gitSpawnFn: Function }} args
|
|
51
|
+
* @returns {string|null}
|
|
52
|
+
*/
|
|
53
|
+
export function computeReviewDiffDigest({ cwd, baseRef, headRef, gitSpawnFn }) {
|
|
54
|
+
if (!baseRef || !headRef) return null;
|
|
55
|
+
try {
|
|
56
|
+
const diff = gitSpawnFn(
|
|
57
|
+
cwd,
|
|
58
|
+
'diff',
|
|
59
|
+
'--no-color',
|
|
60
|
+
`${baseRef}...${headRef}`,
|
|
61
|
+
);
|
|
62
|
+
if (diff?.status !== 0) return null;
|
|
63
|
+
return createHash('sha256')
|
|
64
|
+
.update(String(diff.stdout ?? ''), 'utf8')
|
|
65
|
+
.digest('hex');
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Flatten a computed review `result` into the deposit record.
|
|
73
|
+
*
|
|
74
|
+
* @param {{ storyId: number, headSha: string, baseRef: string,
|
|
75
|
+
* diffDigest: string, result: object, createdAt?: string }} args
|
|
76
|
+
* @returns {object}
|
|
77
|
+
*/
|
|
78
|
+
export function buildReviewDeposit({
|
|
79
|
+
storyId,
|
|
80
|
+
headSha,
|
|
81
|
+
baseRef,
|
|
82
|
+
diffDigest,
|
|
83
|
+
result,
|
|
84
|
+
createdAt = new Date().toISOString(),
|
|
85
|
+
}) {
|
|
86
|
+
return {
|
|
87
|
+
kind: DEPOSIT_KIND,
|
|
88
|
+
storyId,
|
|
89
|
+
headSha,
|
|
90
|
+
baseRef,
|
|
91
|
+
diffDigest,
|
|
92
|
+
createdAt,
|
|
93
|
+
provider: result.providerName ?? null,
|
|
94
|
+
severity: result.severity,
|
|
95
|
+
halted: !!result.halted,
|
|
96
|
+
criticalByProvider: result.criticalByProvider ?? {},
|
|
97
|
+
findings: Array.isArray(result.findings) ? result.findings : [],
|
|
98
|
+
report: result.report ?? '',
|
|
99
|
+
degraded: !!result.degraded,
|
|
100
|
+
degradations: Array.isArray(result.degradations) ? result.degradations : [],
|
|
101
|
+
blockerReason: result.blockerReason ?? null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The deposit as `computeStoryScopeReview`'s `{ result }` shape, ready for
|
|
107
|
+
* `settleStoryScopeReview` to post.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} deposit
|
|
110
|
+
* @returns {{ result: object }}
|
|
111
|
+
*/
|
|
112
|
+
export function depositAsComputedReview(deposit) {
|
|
113
|
+
return {
|
|
114
|
+
result: {
|
|
115
|
+
status: 'ok',
|
|
116
|
+
severity: deposit.severity,
|
|
117
|
+
findings: deposit.findings,
|
|
118
|
+
providerName: deposit.provider,
|
|
119
|
+
report: deposit.report,
|
|
120
|
+
posted: false,
|
|
121
|
+
postedCommentId: null,
|
|
122
|
+
halted: deposit.halted,
|
|
123
|
+
criticalByProvider: deposit.criticalByProvider,
|
|
124
|
+
degraded: deposit.degraded,
|
|
125
|
+
degradations: deposit.degradations,
|
|
126
|
+
blockerReason: deposit.blockerReason,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Atomic write (pid-scoped tmp + rename): close may read it mid-write.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} deposit
|
|
135
|
+
* @param {{ config?: object, fsImpl?: typeof nodeFs }} [deps]
|
|
136
|
+
* @returns {string} the path written.
|
|
137
|
+
*/
|
|
138
|
+
export function writeReviewDeposit(deposit, { config, fsImpl = nodeFs } = {}) {
|
|
139
|
+
const target = storyReviewDepositPath(deposit.storyId, config);
|
|
140
|
+
fsImpl.mkdirSync(path.dirname(target), { recursive: true });
|
|
141
|
+
const tmpPath = `${target}.${process.pid}.tmp`;
|
|
142
|
+
fsImpl.writeFileSync(
|
|
143
|
+
tmpPath,
|
|
144
|
+
`${JSON.stringify(deposit, null, 2)}\n`,
|
|
145
|
+
'utf8',
|
|
146
|
+
);
|
|
147
|
+
fsImpl.renameSync(tmpPath, target);
|
|
148
|
+
return target;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {unknown} record
|
|
153
|
+
* @param {number} storyId
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
function isDeposit(record, storyId) {
|
|
157
|
+
return (
|
|
158
|
+
record?.kind === DEPOSIT_KIND &&
|
|
159
|
+
record.storyId === storyId &&
|
|
160
|
+
typeof record.diffDigest === 'string' &&
|
|
161
|
+
record.diffDigest.length > 0 &&
|
|
162
|
+
typeof record.report === 'string' &&
|
|
163
|
+
typeof record.severity === 'object' &&
|
|
164
|
+
record.severity !== null
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {number} storyId
|
|
170
|
+
* @param {{ config?: object, fsImpl?: typeof nodeFs }} [deps]
|
|
171
|
+
* @returns {object|null} the Story's deposit, or `null` when absent or
|
|
172
|
+
* malformed.
|
|
173
|
+
*/
|
|
174
|
+
function readReviewDeposit(storyId, { config, fsImpl = nodeFs } = {}) {
|
|
175
|
+
try {
|
|
176
|
+
const raw = fsImpl.readFileSync(
|
|
177
|
+
storyReviewDepositPath(storyId, config),
|
|
178
|
+
'utf8',
|
|
179
|
+
);
|
|
180
|
+
const record = JSON.parse(raw);
|
|
181
|
+
return isDeposit(record, storyId) ? record : null;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Close's held-review start point: the base ref, the diff digest of
|
|
189
|
+
* `origin/<base>...<sha>`, and the worker deposit when its digest matches.
|
|
190
|
+
*
|
|
191
|
+
* @param {{ cwd: string, storyId: number|string, sha: string,
|
|
192
|
+
* baseBranch: string, gitSpawnFn: Function, config?: object,
|
|
193
|
+
* readDepositFn?: typeof readReviewDeposit }} args
|
|
194
|
+
* @returns {{ baseRef: string|null, diffDigest: string|null,
|
|
195
|
+
* deposit: object|null }}
|
|
196
|
+
*/
|
|
197
|
+
export function probeHeldReviewDiff({
|
|
198
|
+
cwd,
|
|
199
|
+
storyId,
|
|
200
|
+
sha,
|
|
201
|
+
baseBranch,
|
|
202
|
+
gitSpawnFn,
|
|
203
|
+
config,
|
|
204
|
+
readDepositFn = readReviewDeposit,
|
|
205
|
+
}) {
|
|
206
|
+
const base = resolveSharedBaseRef({ baseBranch, cwd, gitSpawnFn });
|
|
207
|
+
const baseRef = base.resolved ? base.ref : null;
|
|
208
|
+
const diffDigest = computeReviewDiffDigest({
|
|
209
|
+
cwd,
|
|
210
|
+
baseRef,
|
|
211
|
+
headRef: sha,
|
|
212
|
+
gitSpawnFn,
|
|
213
|
+
});
|
|
214
|
+
const deposit = diffDigest
|
|
215
|
+
? readDepositFn(Number(storyId), { config })
|
|
216
|
+
: null;
|
|
217
|
+
const matches = deposit?.diffDigest === diffDigest;
|
|
218
|
+
return { baseRef, diffDigest, deposit: matches ? deposit : null };
|
|
219
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* @typedef {import('./types.js').ReviewProvider} ReviewProvider
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { spawnCaptureAsync } from '../../child-exec.js';
|
|
19
19
|
import { gitSpawn } from '../../git-utils.js';
|
|
20
20
|
import { PROJECT_ROOT } from '../../project-root.js';
|
|
21
21
|
import { parseProviderFindings } from './parse-findings.js';
|
|
@@ -62,14 +62,15 @@ function buildPrompt(input, diff) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
* The prompt rides stdin so no shell ever quotes it.
|
|
65
|
+
* The prompt rides stdin so no shell ever quotes it. Asynchronous: the
|
|
66
|
+
* review overlaps close's gate children, whose output must keep draining.
|
|
66
67
|
*
|
|
67
68
|
* @param {string} prompt
|
|
68
|
-
* @param {Function} [run] - `
|
|
69
|
-
* @returns {{ status: number, stdout: string, stderr: string }}
|
|
69
|
+
* @param {Function} [run] - `(file, args, options)` seam for tests.
|
|
70
|
+
* @returns {Promise<{ status: number, stdout: string, stderr: string }>}
|
|
70
71
|
*/
|
|
71
72
|
function invokeClaude(prompt, run) {
|
|
72
|
-
return
|
|
73
|
+
return spawnCaptureAsync('claude', [...CLAUDE_ARGS], {
|
|
73
74
|
cwd: PROJECT_ROOT,
|
|
74
75
|
input: prompt,
|
|
75
76
|
shell: process.platform === 'win32',
|
|
@@ -144,7 +145,7 @@ function assertInput(input) {
|
|
|
144
145
|
* gitSpawnFn?: typeof gitSpawn,
|
|
145
146
|
* spawnFn?: Function,
|
|
146
147
|
* logger?: { info?: Function, warn?: Function },
|
|
147
|
-
* }} [deps] - `spawnFn` replaces
|
|
148
|
+
* }} [deps] - `spawnFn` replaces the async runner for the `claude` call.
|
|
148
149
|
* @returns {ReviewProvider}
|
|
149
150
|
*/
|
|
150
151
|
export function createCodeReviewProviderForRegistry(deps = {}) {
|
|
@@ -183,7 +184,10 @@ export function createCodeReviewProviderForRegistry(deps = {}) {
|
|
|
183
184
|
logger?.info?.(
|
|
184
185
|
`[code-review] Invoking claude --print --effort low for Story #${ticketId} (${baseRef}...${headRef})...`,
|
|
185
186
|
);
|
|
186
|
-
const result = invokeClaude(
|
|
187
|
+
const result = await invokeClaude(
|
|
188
|
+
buildPrompt(input, diff.stdout),
|
|
189
|
+
spawnFn,
|
|
190
|
+
);
|
|
187
191
|
if (result.status !== 0) {
|
|
188
192
|
logger?.warn?.(
|
|
189
193
|
`[code-review] claude exited ${result.status}; emitting advisory.`,
|