mandrel 1.87.0 → 1.89.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 +18 -13
- package/.agents/audit-checklists/architecture.md +24 -0
- package/.agents/audit-checklists/clean-code.md +24 -0
- package/.agents/audit-checklists/dependencies.md +14 -0
- package/.agents/audit-checklists/devops.md +17 -0
- package/.agents/audit-checklists/documentation.md +22 -0
- package/.agents/audit-checklists/lighthouse.md +15 -0
- package/.agents/audit-checklists/navigability.md +14 -0
- package/.agents/audit-checklists/performance.md +22 -0
- package/.agents/audit-checklists/privacy.md +21 -0
- package/.agents/audit-checklists/quality.md +18 -0
- package/.agents/audit-checklists/security.md +22 -0
- package/.agents/audit-checklists/seo.md +16 -0
- package/.agents/audit-checklists/sre.md +24 -0
- package/.agents/audit-checklists/ux-ui.md +21 -0
- package/.agents/docs/SDLC.md +63 -16
- package/.agents/docs/configuration.md +5 -3
- package/.agents/instructions.md +51 -21
- package/.agents/personas/architect.md +10 -7
- package/.agents/personas/engineer.md +4 -3
- package/.agents/personas/project-manager.md +5 -2
- package/.agents/personas/refactorer.md +5 -3
- package/.agents/rules/git-conventions.md +77 -0
- package/.agents/schemas/agentrc.schema.json +16 -4
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
- package/.agents/schemas/signal-event.schema.json +28 -13
- package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
- package/.agents/scripts/check-context-budget.js +320 -0
- package/.agents/scripts/diagnose-friction.js +4 -4
- package/.agents/scripts/epic-audit-prepare.js +30 -2
- package/.agents/scripts/epic-audit-recheck.js +46 -13
- package/.agents/scripts/epic-deliver-prepare.js +80 -8
- package/.agents/scripts/epic-plan-spec.js +4 -8
- package/.agents/scripts/generate-lens-checklists.js +180 -0
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
- package/.agents/scripts/lib/audit-suite/findings.js +27 -0
- package/.agents/scripts/lib/audit-suite/index.js +9 -0
- package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
- package/.agents/scripts/lib/audit-suite/selector.js +136 -5
- package/.agents/scripts/lib/checks/loop-health.js +340 -0
- package/.agents/scripts/lib/cli-args.js +8 -0
- package/.agents/scripts/lib/config/explain.js +4 -0
- package/.agents/scripts/lib/config/runners.js +21 -2
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
- package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
- package/.agents/scripts/lib/doc-tiers.js +291 -0
- package/.agents/scripts/lib/epic-body-sections.js +5 -2
- package/.agents/scripts/lib/epic-merge-lock.js +83 -0
- package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
- package/.agents/scripts/lib/gates/friction.js +15 -5
- package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
- package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
- package/.agents/scripts/lib/observability/signal-validator.js +204 -0
- package/.agents/scripts/lib/observability/signals-writer.js +157 -54
- package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
- package/.agents/scripts/lib/orchestration/code-review.js +74 -4
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
- package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
- package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
- package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
- package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
- package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
- package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
- package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
- package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
- package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
- package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
- package/.agents/scripts/lib/planning-corpus.js +306 -0
- package/.agents/scripts/lib/signals/detectors/common.js +10 -10
- package/.agents/scripts/lib/signals/detectors/index.js +4 -4
- package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
- package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
- package/.agents/scripts/lib/signals/schema.js +56 -81
- package/.agents/scripts/lib/signals/span-tree.js +6 -5
- package/.agents/scripts/lib/story-plan.js +3 -0
- package/.agents/scripts/lib/wave-runner/tick.js +10 -2
- package/.agents/scripts/lifecycle-emit.js +39 -8
- package/.agents/scripts/providers/github/issues.js +12 -1
- package/.agents/scripts/resolve-doc-tiers.js +83 -0
- package/.agents/scripts/retro-run.js +51 -0
- package/.agents/scripts/signals-view.js +1 -1
- package/.agents/scripts/single-story-close.js +20 -1
- package/.agents/scripts/standalone-feedback-rollup.js +188 -0
- package/.agents/scripts/story-close.js +48 -0
- package/.agents/scripts/story-plan.js +51 -12
- package/.agents/scripts/validate-docs-freshness.js +69 -15
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
- package/.agents/skills/core/scope-triage/SKILL.md +61 -0
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/workflows/audit-documentation.md +82 -2
- package/.agents/workflows/helpers/code-review.md +193 -44
- package/.agents/workflows/helpers/deliver-epic.md +128 -39
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -283
- package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
- package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
- package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
- package/.agents/workflows/helpers/plan-epic.md +141 -105
- package/.agents/workflows/helpers/plan-story.md +32 -0
- package/.agents/workflows/helpers/single-story-deliver.md +43 -0
- package/.agents/workflows/loops/nightly-audit.md +9 -7
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/doctor.js +44 -0
- package/package.json +4 -3
- package/.agents/scripts/epic-plan-spec-validate.js +0 -111
- package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
|
@@ -18,15 +18,33 @@
|
|
|
18
18
|
* Detection: when a worktree is the active work tree (it exists on disk and is
|
|
19
19
|
* distinct from `cwd`), inspect `git -C <mainCheckout> status --porcelain`. Any
|
|
20
20
|
* **tracked-path** change (modified, staged, deleted, renamed) in the main
|
|
21
|
-
* checkout is the wrong-tree signal. Untracked files (`??`) are
|
|
22
|
-
* are scratch artifacts, not relocated Story work, and flagging
|
|
23
|
-
* produce false positives on every run.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
21
|
+
* checkout is the candidate wrong-tree signal. Untracked files (`??`) are
|
|
22
|
+
* ignored — they are scratch artifacts, not relocated Story work, and flagging
|
|
23
|
+
* them would produce false positives on every run.
|
|
24
|
+
*
|
|
25
|
+
* Story #4424 — the raw "main checkout is dirty" signal is too coarse for
|
|
26
|
+
* multi-session operation: uncommitted tracked-path changes in the main
|
|
27
|
+
* checkout can belong to a **different concurrent session** and have nothing to
|
|
28
|
+
* do with the Story being closed (framework-gap #4420). The guard therefore
|
|
29
|
+
* intersects the main-checkout stray tracked paths with the **Story's own
|
|
30
|
+
* diff-path set** (paths changed on the worktree branch vs the base branch,
|
|
31
|
+
* plus the worktree's uncommitted tracked changes):
|
|
32
|
+
* - **Overlap non-empty** → genuine wrong-tree signal: post a `friction`
|
|
33
|
+
* comment and throw (abort close), exactly as before.
|
|
34
|
+
* - **Disjoint** (Story diff-path set non-empty, no shared path) → another
|
|
35
|
+
* session's business: post a `friction` comment whose wording states close
|
|
36
|
+
* PROCEEDED and names the disjoint stray files (telemetry for
|
|
37
|
+
* concurrent-session hygiene), then return without throwing.
|
|
38
|
+
* - **Empty Story diff-path set** with stray paths → keep the abort (the
|
|
39
|
+
* #3364 silent empty-diff backstop; an empty set makes every stray path
|
|
40
|
+
* disjoint-by-definition, which must not downgrade the guard).
|
|
41
|
+
* - **Story-diff probe failure** with stray paths present → fall back to the
|
|
42
|
+
* coarse abort rather than fail-open, so a probe hiccup never converts a
|
|
43
|
+
* would-be abort into a silent pass.
|
|
44
|
+
*
|
|
45
|
+
* The main-checkout status probe keeps its fail-open semantics: a probe failure
|
|
46
|
+
* downgrades to a warning and skips the guard — it never blocks an otherwise
|
|
47
|
+
* valid close.
|
|
30
48
|
*/
|
|
31
49
|
|
|
32
50
|
import path from 'node:path';
|
|
@@ -44,24 +62,31 @@ import { postStructuredComment } from '../../ticketing/state.js';
|
|
|
44
62
|
*/
|
|
45
63
|
export function parsePorcelainStatus(raw) {
|
|
46
64
|
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
|
65
|
+
const unquote = (p) =>
|
|
66
|
+
p.startsWith('"') && p.endsWith('"') ? p.slice(1, -1) : p;
|
|
47
67
|
return raw
|
|
48
68
|
.split('\n')
|
|
49
69
|
.map((line) => line.replace(/\r$/, ''))
|
|
50
70
|
.filter((line) => line.length > 0)
|
|
51
|
-
.
|
|
71
|
+
.flatMap((line) => {
|
|
52
72
|
// First two chars are the status code; path begins at column 3.
|
|
53
73
|
const status = line.slice(0, 2);
|
|
54
|
-
|
|
55
|
-
// Renames/copies render as "orig -> dest"
|
|
56
|
-
|
|
74
|
+
const rawPath = line.slice(3).trim();
|
|
75
|
+
// Renames/copies render as "orig -> dest". Keep BOTH sides: the
|
|
76
|
+
// Story-diff intersection downstream must match a rename whose
|
|
77
|
+
// ORIGIN path is in the Story's footprint — collapsing to the
|
|
78
|
+
// destination only made such a stray downgrade to proceed.
|
|
79
|
+
const arrowIdx = rawPath.indexOf(' -> ');
|
|
57
80
|
if (arrowIdx !== -1) {
|
|
58
|
-
|
|
81
|
+
const origin = unquote(rawPath.slice(0, arrowIdx).trim());
|
|
82
|
+
const dest = unquote(rawPath.slice(arrowIdx + 4).trim());
|
|
83
|
+
return [
|
|
84
|
+
{ status, path: origin, untracked: status === '??' },
|
|
85
|
+
{ status, path: dest, untracked: status === '??' },
|
|
86
|
+
];
|
|
59
87
|
}
|
|
60
88
|
// Porcelain may quote paths containing special chars; strip the quotes.
|
|
61
|
-
|
|
62
|
-
filePath = filePath.slice(1, -1);
|
|
63
|
-
}
|
|
64
|
-
return { status, path: filePath, untracked: status === '??' };
|
|
89
|
+
return [{ status, path: unquote(rawPath), untracked: status === '??' }];
|
|
65
90
|
});
|
|
66
91
|
}
|
|
67
92
|
|
|
@@ -84,6 +109,20 @@ export function collectStrayTrackedPaths(entries) {
|
|
|
84
109
|
.sort();
|
|
85
110
|
}
|
|
86
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Parse `git diff --name-only` output into a list of repo-relative paths.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} raw - Raw `git diff --name-only` stdout (may be empty).
|
|
116
|
+
* @returns {string[]}
|
|
117
|
+
*/
|
|
118
|
+
export function parseDiffNameOnly(raw) {
|
|
119
|
+
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
|
120
|
+
return raw
|
|
121
|
+
.split('\n')
|
|
122
|
+
.map((line) => line.replace(/\r$/, '').trim())
|
|
123
|
+
.filter(Boolean);
|
|
124
|
+
}
|
|
125
|
+
|
|
87
126
|
/**
|
|
88
127
|
* Decide whether the wrong-tree guard applies for this close.
|
|
89
128
|
*
|
|
@@ -101,7 +140,77 @@ export function guardApplies({ cwd, worktreePath }) {
|
|
|
101
140
|
}
|
|
102
141
|
|
|
103
142
|
/**
|
|
104
|
-
*
|
|
143
|
+
* Compute the Story's own diff-path set from the worktree: the union of paths
|
|
144
|
+
* changed on the worktree branch vs the base branch (committed diff) and the
|
|
145
|
+
* worktree's uncommitted tracked changes.
|
|
146
|
+
*
|
|
147
|
+
* Both probes are keyed off the worktree, so the returned paths are
|
|
148
|
+
* repo-relative and directly comparable to the main-checkout porcelain paths.
|
|
149
|
+
* A probe failure (thrown error or non-zero git exit) returns `{ ok: false }`
|
|
150
|
+
* so the caller can fall back to the coarse abort rather than fail-open.
|
|
151
|
+
*
|
|
152
|
+
* @param {{ worktreePath: string, baseBranch: string, gitSpawnFn: Function }} opts
|
|
153
|
+
* @returns {{ ok: boolean, paths: string[], error?: string }}
|
|
154
|
+
*/
|
|
155
|
+
export function collectStoryDiffPaths({
|
|
156
|
+
worktreePath,
|
|
157
|
+
baseBranch,
|
|
158
|
+
gitSpawnFn,
|
|
159
|
+
}) {
|
|
160
|
+
let diffResult;
|
|
161
|
+
try {
|
|
162
|
+
diffResult = gitSpawnFn(
|
|
163
|
+
worktreePath,
|
|
164
|
+
'diff',
|
|
165
|
+
'--name-only',
|
|
166
|
+
`${baseBranch}...HEAD`,
|
|
167
|
+
);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
return { ok: false, paths: [], error: err?.message ?? String(err) };
|
|
170
|
+
}
|
|
171
|
+
if (!diffResult || diffResult.status !== 0) {
|
|
172
|
+
return { ok: false, paths: [], error: diffResult?.stderr || '(no stderr)' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let statusResult;
|
|
176
|
+
try {
|
|
177
|
+
statusResult = gitSpawnFn(worktreePath, 'status', '--porcelain');
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return { ok: false, paths: [], error: err?.message ?? String(err) };
|
|
180
|
+
}
|
|
181
|
+
if (!statusResult || statusResult.status !== 0) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
paths: [],
|
|
185
|
+
error: statusResult?.stderr || '(no stderr)',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const committed = parseDiffNameOnly(diffResult.stdout ?? '');
|
|
190
|
+
const uncommitted = collectStrayTrackedPaths(
|
|
191
|
+
parsePorcelainStatus(statusResult.stdout ?? ''),
|
|
192
|
+
);
|
|
193
|
+
const union = Array.from(new Set([...committed, ...uncommitted])).sort();
|
|
194
|
+
return { ok: true, paths: union };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Repo-relative path intersection. Both sides are git-emitted repo-relative
|
|
199
|
+
* paths (forward-slash separated on every platform), so plain string equality
|
|
200
|
+
* is correct regardless of which tree the probe ran in.
|
|
201
|
+
*
|
|
202
|
+
* @param {string[]} mainStray
|
|
203
|
+
* @param {string[]} storyPaths
|
|
204
|
+
* @returns {string[]} sorted intersection.
|
|
205
|
+
*/
|
|
206
|
+
export function intersectPaths(mainStray, storyPaths) {
|
|
207
|
+
const set = new Set(storyPaths);
|
|
208
|
+
return mainStray.filter((p) => set.has(p)).sort();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Format the `friction` finding body for an ABORT (overlap, empty-diff
|
|
213
|
+
* backstop, or diff-probe-failure fallback) naming the stray files.
|
|
105
214
|
*
|
|
106
215
|
* @param {{ storyId: number, strayFiles: string[], worktreePath: string }} opts
|
|
107
216
|
* @returns {string}
|
|
@@ -111,7 +220,8 @@ export function formatWrongTreeFinding({ storyId, strayFiles, worktreePath }) {
|
|
|
111
220
|
return (
|
|
112
221
|
`### wrong-tree edit detected (close aborted)\n\n` +
|
|
113
222
|
`Story #${storyId}: the main checkout has uncommitted changes under ` +
|
|
114
|
-
`tracked paths
|
|
223
|
+
`tracked paths that intersect the Story's own diff-path set while the ` +
|
|
224
|
+
`active work tree is the per-Story worktree:\n\n` +
|
|
115
225
|
`\`${worktreePath}\`\n\n` +
|
|
116
226
|
`This is the wrong-tree failure mode: edits intended for the worktree ` +
|
|
117
227
|
`landed in the main checkout instead (on Windows, \`cd\` steers the Bash ` +
|
|
@@ -127,31 +237,182 @@ export function formatWrongTreeFinding({ storyId, strayFiles, worktreePath }) {
|
|
|
127
237
|
}
|
|
128
238
|
|
|
129
239
|
/**
|
|
130
|
-
*
|
|
240
|
+
* Format the `friction` finding body for the DOWNGRADE outcome — the main
|
|
241
|
+
* checkout has stray tracked paths, but they are fully disjoint from the
|
|
242
|
+
* Story's non-empty diff-path set (another concurrent session's work). Close
|
|
243
|
+
* proceeds; this comment is telemetry for concurrent-session hygiene.
|
|
131
244
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
|
|
245
|
+
* @param {{ storyId: number, strayFiles: string[], worktreePath: string }} opts
|
|
246
|
+
* @returns {string}
|
|
247
|
+
*/
|
|
248
|
+
export function formatWrongTreeDowngradeFinding({
|
|
249
|
+
storyId,
|
|
250
|
+
strayFiles,
|
|
251
|
+
worktreePath,
|
|
252
|
+
}) {
|
|
253
|
+
const list = strayFiles.map((f) => `- \`${f}\``).join('\n');
|
|
254
|
+
return (
|
|
255
|
+
`### wrong-tree probe: disjoint main-checkout dirt (close proceeded)\n\n` +
|
|
256
|
+
`Story #${storyId}: the main checkout has uncommitted changes under ` +
|
|
257
|
+
`tracked paths while the active work tree is the per-Story worktree:\n\n` +
|
|
258
|
+
`\`${worktreePath}\`\n\n` +
|
|
259
|
+
`These stray paths are **fully disjoint** from the Story's own diff-path ` +
|
|
260
|
+
`set (committed diff vs base + uncommitted worktree changes), so they ` +
|
|
261
|
+
`belong to another concurrent session rather than this Story's work. ` +
|
|
262
|
+
`Close **proceeded** — this comment is telemetry for concurrent-session ` +
|
|
263
|
+
`hygiene, not an abort.\n\n` +
|
|
264
|
+
`**Disjoint stray files in the main checkout:**\n\n${list}\n`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Post the ABORT friction comment and throw to abort close. Shared by the
|
|
270
|
+
* overlap, empty-diff-backstop, and diff-probe-failure-fallback paths.
|
|
135
271
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
272
|
+
* @param {{
|
|
273
|
+
* storyId: number,
|
|
274
|
+
* strayFiles: string[],
|
|
275
|
+
* worktreePath: string,
|
|
276
|
+
* provider: object,
|
|
277
|
+
* progress: (tag: string, msg: string) => void,
|
|
278
|
+
* reasonTag: string,
|
|
279
|
+
* }} opts
|
|
280
|
+
* @throws {Error} always.
|
|
281
|
+
*/
|
|
282
|
+
async function abortWrongTree({
|
|
283
|
+
storyId,
|
|
284
|
+
strayFiles,
|
|
285
|
+
worktreePath,
|
|
286
|
+
provider,
|
|
287
|
+
progress,
|
|
288
|
+
reasonTag,
|
|
289
|
+
}) {
|
|
290
|
+
const body = formatWrongTreeFinding({ storyId, strayFiles, worktreePath });
|
|
291
|
+
try {
|
|
292
|
+
await postStructuredComment(provider, storyId, 'friction', body);
|
|
293
|
+
progress(
|
|
294
|
+
'WRONG-TREE',
|
|
295
|
+
`🛑 Wrong-tree edits detected (${reasonTag}): ${strayFiles.length} stray file(s). Posted friction comment to Story #${storyId}.`,
|
|
296
|
+
);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
progress(
|
|
299
|
+
'WRONG-TREE',
|
|
300
|
+
`⚠️ Failed to post wrong-tree friction comment: ${err?.message ?? err}`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
throw new Error(
|
|
305
|
+
`[single-story-close] Wrong-tree edits detected (${reasonTag}): the main ` +
|
|
306
|
+
`checkout has uncommitted tracked-path changes intersecting the Story's ` +
|
|
307
|
+
`diff-path set while the worktree (${worktreePath}) is the active work ` +
|
|
308
|
+
`tree. Close aborted to avoid an empty-diff PR. Stray files: ` +
|
|
309
|
+
`${strayFiles.join(', ')}. Relocate the edits into the worktree, ` +
|
|
310
|
+
`restore the main checkout, then re-run /single-story-deliver.`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Post the DOWNGRADE friction comment (telemetry) without throwing. Best-effort:
|
|
316
|
+
* a post failure is logged but never converts the proceed into an abort.
|
|
317
|
+
*
|
|
318
|
+
* @param {{
|
|
319
|
+
* storyId: number,
|
|
320
|
+
* strayFiles: string[],
|
|
321
|
+
* worktreePath: string,
|
|
322
|
+
* provider: object,
|
|
323
|
+
* progress: (tag: string, msg: string) => void,
|
|
324
|
+
* }} opts
|
|
325
|
+
* @returns {Promise<void>}
|
|
326
|
+
*/
|
|
327
|
+
async function reportDisjointDirt({
|
|
328
|
+
storyId,
|
|
329
|
+
strayFiles,
|
|
330
|
+
worktreePath,
|
|
331
|
+
provider,
|
|
332
|
+
progress,
|
|
333
|
+
}) {
|
|
334
|
+
const body = formatWrongTreeDowngradeFinding({
|
|
335
|
+
storyId,
|
|
336
|
+
strayFiles,
|
|
337
|
+
worktreePath,
|
|
338
|
+
});
|
|
339
|
+
try {
|
|
340
|
+
await postStructuredComment(provider, storyId, 'friction', body);
|
|
341
|
+
progress(
|
|
342
|
+
'WRONG-TREE',
|
|
343
|
+
`⚠️ Main-checkout dirt disjoint from Story diff (${strayFiles.length} stray file(s) belong to another session). Close proceeds; posted telemetry friction comment to Story #${storyId}.`,
|
|
344
|
+
);
|
|
345
|
+
} catch (err) {
|
|
346
|
+
progress(
|
|
347
|
+
'WRONG-TREE',
|
|
348
|
+
`⚠️ Failed to post disjoint-dirt friction comment: ${err?.message ?? err}`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Probe the main checkout for stray tracked-path changes.
|
|
355
|
+
*
|
|
356
|
+
* Fail-open on the probe: a thrown error or non-zero git exit returns
|
|
357
|
+
* `{ ok: false }` so the caller skips the guard rather than blocking a valid
|
|
358
|
+
* close on a git hiccup.
|
|
359
|
+
*
|
|
360
|
+
* @param {{ cwd: string, gitSpawnFn: Function, progress: Function }} opts
|
|
361
|
+
* @returns {{ ok: boolean, strayFiles: string[] }}
|
|
362
|
+
*/
|
|
363
|
+
function probeMainCheckoutStray({ cwd, gitSpawnFn, progress }) {
|
|
364
|
+
let result;
|
|
365
|
+
try {
|
|
366
|
+
result = gitSpawnFn(cwd, 'status', '--porcelain');
|
|
367
|
+
} catch (err) {
|
|
368
|
+
progress(
|
|
369
|
+
'WRONG-TREE',
|
|
370
|
+
`⚠️ Could not probe main checkout status: ${err?.message ?? err}. Skipping guard.`,
|
|
371
|
+
);
|
|
372
|
+
return { ok: false, strayFiles: [] };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (!result || result.status !== 0) {
|
|
376
|
+
progress(
|
|
377
|
+
'WRONG-TREE',
|
|
378
|
+
`⚠️ git status probe exited non-zero: ${result?.stderr || '(no stderr)'}. Skipping guard.`,
|
|
379
|
+
);
|
|
380
|
+
return { ok: false, strayFiles: [] };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const strayFiles = collectStrayTrackedPaths(
|
|
384
|
+
parsePorcelainStatus(result.stdout ?? ''),
|
|
385
|
+
);
|
|
386
|
+
return { ok: true, strayFiles };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Run the wrong-tree detection guard for `single-story-close`.
|
|
391
|
+
*
|
|
392
|
+
* When a worktree is the active work tree and the main checkout has stray
|
|
393
|
+
* tracked-path changes, the guard intersects those paths with the Story's own
|
|
394
|
+
* diff-path set (Story #4424): overlap aborts close (throws), a disjoint set
|
|
395
|
+
* downgrades to a proceed-with-telemetry `friction` comment, an empty Story
|
|
396
|
+
* diff-path set keeps the abort (empty-diff backstop), and a Story-diff probe
|
|
397
|
+
* failure falls back to the coarse abort. A main-checkout status probe failure
|
|
398
|
+
* skips the guard (fail-open on the probe, fail-closed on a confirmed positive).
|
|
140
399
|
*
|
|
141
400
|
* @param {{
|
|
142
401
|
* cwd: string,
|
|
143
402
|
* worktreePath: string|null,
|
|
403
|
+
* baseBranch: string,
|
|
144
404
|
* storyId: number,
|
|
145
405
|
* provider: object,
|
|
146
406
|
* progress: (tag: string, msg: string) => void,
|
|
147
407
|
* gitSpawn?: Function,
|
|
148
408
|
* }} args
|
|
149
|
-
* @returns {Promise<{ applied: boolean, strayFiles: string[] }>}
|
|
150
|
-
* @throws {Error} when stray
|
|
409
|
+
* @returns {Promise<{ applied: boolean, strayFiles: string[], overlap?: string[] }>}
|
|
410
|
+
* @throws {Error} when overlapping stray edits are detected in the main checkout.
|
|
151
411
|
*/
|
|
152
412
|
export async function runWrongTreeGuardPhase({
|
|
153
413
|
cwd,
|
|
154
414
|
worktreePath,
|
|
415
|
+
baseBranch = 'main',
|
|
155
416
|
storyId,
|
|
156
417
|
provider,
|
|
157
418
|
progress,
|
|
@@ -171,55 +432,78 @@ export async function runWrongTreeGuardPhase({
|
|
|
171
432
|
`Checking main checkout for stray edits (worktree-isolated Story #${storyId})...`,
|
|
172
433
|
);
|
|
173
434
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
result = gitSpawnFn(cwd, 'status', '--porcelain');
|
|
177
|
-
} catch (err) {
|
|
178
|
-
// Probe failure is non-fatal — never block a valid close on a git hiccup.
|
|
179
|
-
progress(
|
|
180
|
-
'WRONG-TREE',
|
|
181
|
-
`⚠️ Could not probe main checkout status: ${err?.message ?? err}. Skipping guard.`,
|
|
182
|
-
);
|
|
183
|
-
return { applied: false, strayFiles: [] };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (!result || result.status !== 0) {
|
|
187
|
-
progress(
|
|
188
|
-
'WRONG-TREE',
|
|
189
|
-
`⚠️ git status probe exited non-zero: ${result?.stderr || '(no stderr)'}. Skipping guard.`,
|
|
190
|
-
);
|
|
435
|
+
const mainProbe = probeMainCheckoutStray({ cwd, gitSpawnFn, progress });
|
|
436
|
+
if (!mainProbe.ok) {
|
|
191
437
|
return { applied: false, strayFiles: [] };
|
|
192
438
|
}
|
|
193
439
|
|
|
194
|
-
const strayFiles =
|
|
195
|
-
parsePorcelainStatus(result.stdout ?? ''),
|
|
196
|
-
);
|
|
197
|
-
|
|
440
|
+
const strayFiles = mainProbe.strayFiles;
|
|
198
441
|
if (strayFiles.length === 0) {
|
|
199
442
|
progress('WRONG-TREE', '✅ Main checkout clean — no wrong-tree edits.');
|
|
200
|
-
return { applied: true, strayFiles: [] };
|
|
443
|
+
return { applied: true, strayFiles: [], overlap: [] };
|
|
201
444
|
}
|
|
202
445
|
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
446
|
+
// Stray paths present — intersect with the Story's own diff-path set to tell
|
|
447
|
+
// this Story's misplaced work apart from a concurrent session's dirt.
|
|
448
|
+
const storyDiff = collectStoryDiffPaths({
|
|
449
|
+
worktreePath,
|
|
450
|
+
baseBranch,
|
|
451
|
+
gitSpawnFn,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
if (!storyDiff.ok) {
|
|
455
|
+
// A failed Story-diff probe must NOT silently convert a would-be abort into
|
|
456
|
+
// a pass — fall back to the coarse (#3364) abort behavior.
|
|
212
457
|
progress(
|
|
213
458
|
'WRONG-TREE',
|
|
214
|
-
`⚠️
|
|
459
|
+
`⚠️ Could not probe Story diff paths (${storyDiff.error}); falling back to coarse abort.`,
|
|
215
460
|
);
|
|
461
|
+
await abortWrongTree({
|
|
462
|
+
storyId,
|
|
463
|
+
strayFiles,
|
|
464
|
+
worktreePath,
|
|
465
|
+
provider,
|
|
466
|
+
progress,
|
|
467
|
+
reasonTag: 'diff-probe-failed',
|
|
468
|
+
});
|
|
216
469
|
}
|
|
217
470
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
471
|
+
if (storyDiff.paths.length === 0) {
|
|
472
|
+
// Empty-diff backstop: an empty Story diff-path set makes every stray path
|
|
473
|
+
// disjoint-by-definition; that is exactly the #3364 silent empty-diff
|
|
474
|
+
// failure mode, so keep the abort.
|
|
475
|
+
await abortWrongTree({
|
|
476
|
+
storyId,
|
|
477
|
+
strayFiles,
|
|
478
|
+
worktreePath,
|
|
479
|
+
provider,
|
|
480
|
+
progress,
|
|
481
|
+
reasonTag: 'empty-diff-backstop',
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const overlap = intersectPaths(strayFiles, storyDiff.paths);
|
|
486
|
+
|
|
487
|
+
if (overlap.length > 0) {
|
|
488
|
+
await abortWrongTree({
|
|
489
|
+
storyId,
|
|
490
|
+
strayFiles,
|
|
491
|
+
worktreePath,
|
|
492
|
+
provider,
|
|
493
|
+
progress,
|
|
494
|
+
reasonTag: 'overlap',
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Disjoint: the stray paths belong to another session. Post a proceed-wording
|
|
499
|
+
// friction comment (telemetry) and return without throwing.
|
|
500
|
+
await reportDisjointDirt({
|
|
501
|
+
storyId,
|
|
502
|
+
strayFiles,
|
|
503
|
+
worktreePath,
|
|
504
|
+
provider,
|
|
505
|
+
progress,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
return { applied: true, strayFiles, overlap: [] };
|
|
225
509
|
}
|
|
@@ -15,6 +15,7 @@ import { runAutoMergePhase } from './phases/auto-merge.js';
|
|
|
15
15
|
import { runBaseSyncPhase } from './phases/base-sync.js';
|
|
16
16
|
import { runCloseValidationPhase } from './phases/close-validation.js';
|
|
17
17
|
import { parsePrNumber, runStoryScopeReview } from './phases/code-review.js';
|
|
18
|
+
import { runConfirmMergePhase } from './phases/confirm-merge.js';
|
|
18
19
|
import { parseCloseOptions } from './phases/options.js';
|
|
19
20
|
import { ensurePullRequestWith } from './phases/pull-request.js';
|
|
20
21
|
import { pushStoryBranch } from './phases/push.js';
|
|
@@ -57,6 +58,7 @@ async function runPrePushPhases({
|
|
|
57
58
|
await runWrongTreeGuardPhase({
|
|
58
59
|
cwd,
|
|
59
60
|
worktreePath,
|
|
61
|
+
baseBranch,
|
|
60
62
|
storyId,
|
|
61
63
|
provider,
|
|
62
64
|
progress,
|
|
@@ -204,6 +206,8 @@ function closeResult({
|
|
|
204
206
|
autoMergeReason,
|
|
205
207
|
worktreeReaped,
|
|
206
208
|
leaseReleased,
|
|
209
|
+
waitedForMerge = false,
|
|
210
|
+
merged = false,
|
|
207
211
|
}) {
|
|
208
212
|
return {
|
|
209
213
|
storyId,
|
|
@@ -217,9 +221,13 @@ function closeResult({
|
|
|
217
221
|
autoMergeReason,
|
|
218
222
|
worktreeReaped,
|
|
219
223
|
leaseReleased,
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
224
|
+
waitedForMerge,
|
|
225
|
+
merged,
|
|
226
|
+
note: waitedForMerge
|
|
227
|
+
? 'Headless must-land: PR merge confirmed. Story flipped agent::closing → agent::done and the issue closed (confirmStoryMerged).'
|
|
228
|
+
: autoMergeEnabled
|
|
229
|
+
? 'PR open against baseBranch with auto-merge enabled. Story rests at agent::closing (issue stays OPEN). GitHub will squash-merge when required checks pass; run single-story-confirm-merge.js after the merge confirms to flip agent::done and close the issue (the Closes #<id> footer also auto-closes it).'
|
|
230
|
+
: 'PR open against baseBranch. Story rests at agent::closing (issue stays OPEN). Operator merges via GitHub UI; run single-story-confirm-merge.js after the merge confirms to flip agent::done (the Closes #<id> footer also auto-closes the issue).',
|
|
223
231
|
};
|
|
224
232
|
}
|
|
225
233
|
|
|
@@ -230,6 +238,8 @@ export async function runSingleStoryClose({
|
|
|
230
238
|
skipSync: skipSyncParam,
|
|
231
239
|
noAutoMerge: noAutoMergeParam,
|
|
232
240
|
noFullScopeCrap: noFullScopeCrapParam,
|
|
241
|
+
waitForMerge: waitForMergeParam,
|
|
242
|
+
noWaitForMerge: noWaitForMergeParam,
|
|
233
243
|
injectedProvider,
|
|
234
244
|
injectedConfig,
|
|
235
245
|
injectedNotify,
|
|
@@ -246,10 +256,12 @@ export async function runSingleStoryClose({
|
|
|
246
256
|
skipSyncParam,
|
|
247
257
|
noAutoMergeParam,
|
|
248
258
|
noFullScopeCrapParam,
|
|
259
|
+
waitForMergeParam,
|
|
260
|
+
noWaitForMergeParam,
|
|
249
261
|
});
|
|
250
262
|
if (!options.storyId) {
|
|
251
263
|
throw new Error(
|
|
252
|
-
'Usage: node single-story-close.js --story <STORY_ID> [--cwd <main-repo>] [--skip-validation] [--skip-sync] [--no-auto-merge] [--no-full-scope-crap]',
|
|
264
|
+
'Usage: node single-story-close.js --story <STORY_ID> [--cwd <main-repo>] [--skip-validation] [--skip-sync] [--no-auto-merge] [--no-full-scope-crap] [--wait-merge|--no-wait-merge]',
|
|
253
265
|
);
|
|
254
266
|
}
|
|
255
267
|
|
|
@@ -341,6 +353,56 @@ export async function runSingleStoryClose({
|
|
|
341
353
|
WorktreeManager,
|
|
342
354
|
});
|
|
343
355
|
const leaseReleased = await releaseLease(leaseArgs);
|
|
356
|
+
|
|
357
|
+
// Story #4428 — headless must-land: `--wait-merge` polls the just-armed
|
|
358
|
+
// PR to merge confirmation (or an explicit `agent::blocked` +
|
|
359
|
+
// `merge.unlanded`) instead of resting at `agent::closing`. Attended
|
|
360
|
+
// (non-headless) runs never set this flag, so the exit shape below is
|
|
361
|
+
// unreachable and the pre-existing early-return is byte-identical.
|
|
362
|
+
if (options.waitForMerge) {
|
|
363
|
+
const waitOutcome = await runConfirmMergePhase({
|
|
364
|
+
cwd: options.cwd,
|
|
365
|
+
storyId: options.storyId,
|
|
366
|
+
prNumber,
|
|
367
|
+
prUrl,
|
|
368
|
+
autoMergeEnabled,
|
|
369
|
+
autoMergeReason,
|
|
370
|
+
provider,
|
|
371
|
+
config,
|
|
372
|
+
progress,
|
|
373
|
+
injectedGh,
|
|
374
|
+
injectedNotify,
|
|
375
|
+
});
|
|
376
|
+
if (!waitOutcome.confirmed) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`[single-story-close] Headless must-land: PR ${prUrl} did not reach a confirmed merge ` +
|
|
379
|
+
`(blockClass=${waitOutcome.blockClass}). Story #${options.storyId} was transitioned to ` +
|
|
380
|
+
`agent::blocked with a merge.unlanded lifecycle event. Reason: ${waitOutcome.reason}`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
const result = closeResult({
|
|
384
|
+
storyId: options.storyId,
|
|
385
|
+
storyBranch,
|
|
386
|
+
baseBranch,
|
|
387
|
+
prUrl,
|
|
388
|
+
prNumber,
|
|
389
|
+
autoMergeEnabled,
|
|
390
|
+
autoMergeReason,
|
|
391
|
+
worktreeReaped,
|
|
392
|
+
leaseReleased,
|
|
393
|
+
waitedForMerge: true,
|
|
394
|
+
merged: true,
|
|
395
|
+
});
|
|
396
|
+
Logger.info(
|
|
397
|
+
`\n--- STORY CLOSE RESULT ---\n${JSON.stringify(result, null, 2)}\n--- END RESULT ---\n`,
|
|
398
|
+
);
|
|
399
|
+
progress(
|
|
400
|
+
'DONE',
|
|
401
|
+
`✅ Standalone Story #${options.storyId}: PR merged → ${prUrl}`,
|
|
402
|
+
);
|
|
403
|
+
return { success: true, result };
|
|
404
|
+
}
|
|
405
|
+
|
|
344
406
|
const result = closeResult({
|
|
345
407
|
storyId: options.storyId,
|
|
346
408
|
storyBranch,
|