mandrel 1.86.0 โ 1.88.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/SDLC.md +15 -3
- package/.agents/docs/configuration.md +2 -0
- package/.agents/instructions.md +7 -0
- package/.agents/rules/git-conventions.md +13 -1
- package/.agents/schemas/agentrc.schema.json +12 -0
- package/.agents/scripts/boot-sweep.js +36 -4
- package/.agents/scripts/git-cleanup.js +8 -0
- package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +107 -30
- package/.agents/scripts/lib/config/explain.js +4 -0
- package/.agents/scripts/lib/config/runners.js +13 -2
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
- package/.agents/scripts/lib/epic-plan-ideation.js +24 -3
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +19 -5
- package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +17 -0
- package/.agents/scripts/lib/framework-version.js +210 -0
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +7 -22
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +41 -5
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +34 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +102 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +85 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +34 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +71 -4
- package/.agents/scripts/lib/single-story-sweep.js +60 -5
- package/.agents/scripts/lib/story-body/story-body.js +81 -4
- package/.agents/scripts/providers/github/tickets.js +18 -1
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +7 -2
- package/.agents/skills/core/epic-plan-premortem/SKILL.md +8 -2
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +36 -8
- package/.agents/workflows/git-cleanup.md +72 -18
- package/.agents/workflows/helpers/acceptance-self-eval.md +23 -1
- package/.agents/workflows/helpers/code-review.md +83 -7
- package/.agents/workflows/helpers/deliver-epic.md +46 -7
- package/.agents/workflows/helpers/epic-audit.md +153 -12
- package/.agents/workflows/helpers/parallel-tooling.md +9 -2
- package/.agents/workflows/helpers/plan-epic.md +32 -14
- package/.agents/workflows/loops/nightly-audit.md +9 -1
- package/docs/CHANGELOG.md +22 -0
- package/package.json +1 -1
|
@@ -104,6 +104,13 @@ export function buildIdempotencyMarker(epicId, index) {
|
|
|
104
104
|
* filter ๐ด (Critical Blocker โ blocking) out; ๐ /๐ก/๐ข are non-blocking
|
|
105
105
|
* and graduate to follow-up issues.
|
|
106
106
|
*
|
|
107
|
+
* Findings the Phase 5 focused-fix routing already fixed on-branch are
|
|
108
|
+
* rendered under a **"Fixed on-branch"** heading (Story #4399) with a โ
|
|
109
|
+
* prefix so they no longer parse as open findings. As a belt-and-suspenders
|
|
110
|
+
* guard the parser also skips every line inside a Fixed-on-branch section
|
|
111
|
+
* outright, so a remediated ๐ก Medium never spawns a ghost follow-up issue
|
|
112
|
+
* even if its line retains its original severity emoji.
|
|
113
|
+
*
|
|
107
114
|
* @param {string} body
|
|
108
115
|
* @returns {Array<{ severity: 'high'|'medium'|'low', path: string, summary: string, index: number }>}
|
|
109
116
|
*/
|
|
@@ -112,9 +119,19 @@ export function parseFindings(body) {
|
|
|
112
119
|
const findings = [];
|
|
113
120
|
const lines = body.split(/\r?\n/);
|
|
114
121
|
let idx = 0;
|
|
122
|
+
let inFixedSection = false;
|
|
115
123
|
for (const line of lines) {
|
|
116
124
|
const trimmed = line.trim();
|
|
117
125
|
if (trimmed.length === 0) continue;
|
|
126
|
+
// Any markdown heading resets the Fixed-on-branch guard; a
|
|
127
|
+
// "Fixed on-branch" heading opens a section whose entries never
|
|
128
|
+
// graduate (Story #4399).
|
|
129
|
+
const headingMatch = trimmed.match(/^#{1,6}\s+(.+)$/);
|
|
130
|
+
if (headingMatch) {
|
|
131
|
+
inFixedSection = /fixed on-branch/i.test(headingMatch[1]);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (inFixedSection) continue;
|
|
118
135
|
let severity = null;
|
|
119
136
|
if (trimmed.startsWith('๐ ')) severity = 'high';
|
|
120
137
|
else if (trimmed.startsWith('๐ก')) severity = 'medium';
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// .agents/scripts/lib/framework-version.js
|
|
2
|
+
/**
|
|
3
|
+
* framework-version.js โ single source of truth for the running Mandrel
|
|
4
|
+
* framework version and the ticket-body authoring stamp.
|
|
5
|
+
*
|
|
6
|
+
* Two concerns live here:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Version resolution.** Under npm distribution the root `package.json`
|
|
9
|
+
* is the canonical version marker. {@link resolveFrameworkVersion} reads it
|
|
10
|
+
* and degrades to `'unknown'` (never throws) so a missing/unreadable
|
|
11
|
+
* manifest can never crash an authoring or hydration path. The private
|
|
12
|
+
* `getVersion()` in `lib/orchestration/context-hydration-engine.js`
|
|
13
|
+
* delegates here (DRY โ one manifest reader).
|
|
14
|
+
*
|
|
15
|
+
* 2. **Ticket-body stamp.** Epics and Stories are stamped **once at authoring
|
|
16
|
+
* time** with the running version and the authoring date, via a hybrid
|
|
17
|
+
* surface:
|
|
18
|
+
* - a hidden machine-readable field in the trailing
|
|
19
|
+
* `<!-- meta: {"mandrel_version":"โฆ","authored_at":"โฆ"} -->` block
|
|
20
|
+
* (the source of truth, queryable by tooling), and
|
|
21
|
+
* - a single visible footer line
|
|
22
|
+
* `> ๐ท๏ธ Authored with Mandrel v<version> ยท <YYYY-MM-DD>` so a human
|
|
23
|
+
* reading the raw GitHub issue sees the provenance without any tooling.
|
|
24
|
+
*
|
|
25
|
+
* The stamp is **immutable**: {@link stampFrameworkVersion} is a no-op when
|
|
26
|
+
* the body already carries a `mandrel_version`, so a later re-render or
|
|
27
|
+
* Epic-body edit preserves the originally-authored version verbatim rather
|
|
28
|
+
* than bumping it to whatever version happens to be running.
|
|
29
|
+
*
|
|
30
|
+
* This module imports only Node builtins so it can be pulled in from the
|
|
31
|
+
* story-body serializer, the ticket provider, and the Epic ideation renderer
|
|
32
|
+
* without risking an import cycle.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
|
|
39
|
+
/** Returned when the package manifest is absent or unreadable. */
|
|
40
|
+
export const FALLBACK_VERSION = 'unknown';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Trailing machine-metadata comment block: `<!-- meta: {...} -->`. Mirrors the
|
|
44
|
+
* regex the Story-body parser uses so both surfaces recognise the same block.
|
|
45
|
+
*/
|
|
46
|
+
const META_BLOCK_RE = /<!--\s*meta:\s*(\{[\s\S]*?\})\s*-->/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The visible authoring marker line. A blockquote so GitHub renders it as a
|
|
50
|
+
* callout. Used both to detect an already-emitted marker (for the strip step)
|
|
51
|
+
* and, in the Story-body parser, to skip the line during section parsing so it
|
|
52
|
+
* never pollutes the last structured section.
|
|
53
|
+
*/
|
|
54
|
+
export const AUTHORED_MARKER_LINE_RE = /^\s*>\s*๐ท๏ธ\s+Authored with Mandrel\b/;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compute the default path to the root `package.json`. This module ships inside
|
|
58
|
+
* the `mandrel` package at `<pkgRoot>/.agents/scripts/lib/framework-version.js`,
|
|
59
|
+
* so the manifest sits three directories up โ the same layout in the dev repo
|
|
60
|
+
* and the published tarball.
|
|
61
|
+
*
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
function defaultPkgPath() {
|
|
65
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
66
|
+
return path.resolve(moduleDir, '../../..', 'package.json');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the running framework version from the root `package.json`. Degrades
|
|
71
|
+
* to {@link FALLBACK_VERSION} (never throws) on any read or parse failure so a
|
|
72
|
+
* missing/unreadable manifest can never crash an authoring or hydration path.
|
|
73
|
+
*
|
|
74
|
+
* @param {{ pkgPath?: string }} [opts] - `pkgPath` override (test seam).
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function resolveFrameworkVersion({ pkgPath } = {}) {
|
|
78
|
+
try {
|
|
79
|
+
const resolved = typeof pkgPath === 'string' ? pkgPath : defaultPkgPath();
|
|
80
|
+
const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
|
81
|
+
return typeof parsed.version === 'string' && parsed.version.trim()
|
|
82
|
+
? parsed.version.trim()
|
|
83
|
+
: FALLBACK_VERSION;
|
|
84
|
+
} catch {
|
|
85
|
+
return FALLBACK_VERSION;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Format an authoring date as `YYYY-MM-DD` (UTC). Matches the date shape the
|
|
91
|
+
* rest of the authoring path uses (e.g. `qa-session`).
|
|
92
|
+
*
|
|
93
|
+
* @param {Date} [date=new Date()]
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
export function formatAuthoredDate(date = new Date()) {
|
|
97
|
+
const d =
|
|
98
|
+
date instanceof Date && !Number.isNaN(date.getTime()) ? date : new Date();
|
|
99
|
+
return d.toISOString().slice(0, 10);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build the visible authoring marker line for a given stamp. Centralised so
|
|
104
|
+
* the string is byte-identical across the two producers (the Story-body
|
|
105
|
+
* serializer and {@link stampFrameworkVersion}).
|
|
106
|
+
*
|
|
107
|
+
* @param {{ version: string, authoredAt: string }} stamp
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
export function authoredMarkerLine({ version, authoredAt }) {
|
|
111
|
+
return `> ๐ท๏ธ Authored with Mandrel v${version} ยท ${authoredAt}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read the framework stamp from a body's trailing meta block. Returns
|
|
116
|
+
* `{ version, authoredAt }` when a non-empty `mandrel_version` is present, or
|
|
117
|
+
* `null` when the body carries no stamp (or the meta block is malformed).
|
|
118
|
+
* `authoredAt` is `null` when the version is present but the date is absent.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} markdown
|
|
121
|
+
* @returns {{ version: string, authoredAt: string|null }|null}
|
|
122
|
+
*/
|
|
123
|
+
export function extractFrameworkStamp(markdown) {
|
|
124
|
+
if (typeof markdown !== 'string') return null;
|
|
125
|
+
const match = markdown.match(META_BLOCK_RE);
|
|
126
|
+
if (!match) return null;
|
|
127
|
+
let parsed;
|
|
128
|
+
try {
|
|
129
|
+
parsed = JSON.parse(match[1]);
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const version =
|
|
137
|
+
typeof parsed.mandrel_version === 'string' && parsed.mandrel_version.trim()
|
|
138
|
+
? parsed.mandrel_version.trim()
|
|
139
|
+
: null;
|
|
140
|
+
if (version === null) return null;
|
|
141
|
+
const authoredAt =
|
|
142
|
+
typeof parsed.authored_at === 'string' && parsed.authored_at.trim()
|
|
143
|
+
? parsed.authored_at.trim()
|
|
144
|
+
: null;
|
|
145
|
+
return { version, authoredAt };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Stamp a ticket body (Epic or Story markdown) with the framework version and
|
|
150
|
+
* authoring date โ **once**. When the body already carries a `mandrel_version`
|
|
151
|
+
* the body is returned verbatim (immutability: never re-derive or bump an
|
|
152
|
+
* already-authored stamp). Otherwise the version keys are merged into (or
|
|
153
|
+
* create) the trailing `<!-- meta -->` block โ appended **last** so the key
|
|
154
|
+
* order stays stable with the Story-body serializer โ and the visible marker
|
|
155
|
+
* line is (re)emitted just above it.
|
|
156
|
+
*
|
|
157
|
+
* The `version` / `authoredAt` overrides let a caller (e.g. the Epic edit path)
|
|
158
|
+
* preserve a previously-authored stamp; both default to the running version and
|
|
159
|
+
* today's date when omitted.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} markdown
|
|
162
|
+
* @param {{ version?: string, authoredAt?: string }} [stamp]
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
export function stampFrameworkVersion(markdown, stamp = {}) {
|
|
166
|
+
const body = typeof markdown === 'string' ? markdown : '';
|
|
167
|
+
|
|
168
|
+
// Immutability: a body that already carries a version is preserved verbatim.
|
|
169
|
+
if (extractFrameworkStamp(body) !== null) return body;
|
|
170
|
+
|
|
171
|
+
const version =
|
|
172
|
+
typeof stamp?.version === 'string' && stamp.version.trim()
|
|
173
|
+
? stamp.version.trim()
|
|
174
|
+
: resolveFrameworkVersion();
|
|
175
|
+
const authoredAt =
|
|
176
|
+
typeof stamp?.authoredAt === 'string' && stamp.authoredAt.trim()
|
|
177
|
+
? stamp.authoredAt.trim()
|
|
178
|
+
: formatAuthoredDate();
|
|
179
|
+
|
|
180
|
+
// Merge into any existing (version-less) meta block, appending the version
|
|
181
|
+
// keys last for stable key order.
|
|
182
|
+
const metaMatch = body.match(META_BLOCK_RE);
|
|
183
|
+
const meta = {};
|
|
184
|
+
if (metaMatch) {
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(metaMatch[1]);
|
|
187
|
+
if (
|
|
188
|
+
parsed !== null &&
|
|
189
|
+
typeof parsed === 'object' &&
|
|
190
|
+
!Array.isArray(parsed)
|
|
191
|
+
) {
|
|
192
|
+
Object.assign(meta, parsed);
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// Malformed meta comment โ drop it and re-emit a clean block.
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
meta.mandrel_version = version;
|
|
199
|
+
meta.authored_at = authoredAt;
|
|
200
|
+
|
|
201
|
+
// Strip any existing meta block / marker so both re-append canonically.
|
|
202
|
+
const head = body
|
|
203
|
+
.replace(META_BLOCK_RE, '')
|
|
204
|
+
.replace(new RegExp(AUTHORED_MARKER_LINE_RE.source, 'm'), '')
|
|
205
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
206
|
+
.trimEnd();
|
|
207
|
+
|
|
208
|
+
const marker = authoredMarkerLine({ version, authoredAt });
|
|
209
|
+
return `${head}\n\n${marker}\n\n<!-- meta: ${JSON.stringify(meta)} -->`;
|
|
210
|
+
}
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
import crypto from 'node:crypto';
|
|
20
20
|
import fs from 'node:fs';
|
|
21
21
|
import path from 'node:path';
|
|
22
|
-
import { fileURLToPath } from 'node:url';
|
|
23
22
|
import { getCommands } from '../config/commands.js';
|
|
24
23
|
import {
|
|
25
24
|
getLimits,
|
|
@@ -28,6 +27,7 @@ import {
|
|
|
28
27
|
resolveConfig,
|
|
29
28
|
} from '../config-resolver.js';
|
|
30
29
|
import { sliceEpicBodyForDelivery } from '../epic-body-sections.js';
|
|
30
|
+
import { resolveFrameworkVersion } from '../framework-version.js';
|
|
31
31
|
import { Logger } from '../Logger.js';
|
|
32
32
|
import {
|
|
33
33
|
buildEnvelope,
|
|
@@ -133,31 +133,16 @@ export function formatSkillCapsulesSection(entries) {
|
|
|
133
133
|
// ---------------------------------------------------------------------------
|
|
134
134
|
|
|
135
135
|
/**
|
|
136
|
-
* Resolve the framework version
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* `<pkgRoot>/.agents/scripts/lib/orchestration/context-hydration-engine.js`,
|
|
142
|
-
* so the package manifest sits four directories up โ the same layout in the
|
|
143
|
-
* dev repo and in the published tarball. Read that manifest's `version`.
|
|
144
|
-
*
|
|
145
|
-
* Falls back to `'unknown'` when the manifest is absent or unreadable so a
|
|
146
|
-
* missing package.json never crashes hydration.
|
|
136
|
+
* Resolve the framework version. Delegates to the shared
|
|
137
|
+
* {@link resolveFrameworkVersion} helper (single owner of the root
|
|
138
|
+
* `package.json` read) so the hydrator and the ticket-body stamp read the same
|
|
139
|
+
* source. Retained as a thin named wrapper so the mismatch-warning call sites
|
|
140
|
+
* below read against a stable local name.
|
|
147
141
|
*
|
|
148
142
|
* @returns {string}
|
|
149
143
|
*/
|
|
150
144
|
function getVersion() {
|
|
151
|
-
|
|
152
|
-
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
153
|
-
const pkgPath = path.resolve(moduleDir, '../../../..', 'package.json');
|
|
154
|
-
const parsed = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
155
|
-
return typeof parsed.version === 'string' && parsed.version.trim()
|
|
156
|
-
? parsed.version.trim()
|
|
157
|
-
: 'unknown';
|
|
158
|
-
} catch {
|
|
159
|
-
return 'unknown';
|
|
160
|
-
}
|
|
145
|
+
return resolveFrameworkVersion();
|
|
161
146
|
}
|
|
162
147
|
|
|
163
148
|
/**
|
|
@@ -152,6 +152,40 @@ export function findWorktreePathForBranch(branch, worktrees) {
|
|
|
152
152
|
return null;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Classify a `git branch -D <branch>` result. Pure โ no IO. Exported as the
|
|
157
|
+
* clean unit-test seam for the not-found rule (this module carries a
|
|
158
|
+
* `node:coverage ignore file` header, so the git-side glue is not directly
|
|
159
|
+
* covered).
|
|
160
|
+
*
|
|
161
|
+
* Story #4393 โ on the `/deliver` post-merge reap path a branch is routinely
|
|
162
|
+
* *already gone* (a prior sweep, a re-run, or GitHub's `--delete-branch`
|
|
163
|
+
* already dropped it). `git branch -D` then exits non-zero with a "not found"
|
|
164
|
+
* stderr. That is an **already-reaped success**, not a reap failure: counting
|
|
165
|
+
* it as a failure forces `reapEpicBranches().ok` false, which makes
|
|
166
|
+
* BranchCleaner classify `failed`, which flips the merged Epic to
|
|
167
|
+
* `agent::blocked` and reopens it. Only a not-found stderr is absorbed as
|
|
168
|
+
* success; every other non-zero exit stays a genuine failure.
|
|
169
|
+
*
|
|
170
|
+
* @param {{ status: number, stderr?: string }} branchDel
|
|
171
|
+
* @returns {{ branchDeleted: boolean, alreadyAbsent: boolean, stderr?: string }}
|
|
172
|
+
*/
|
|
173
|
+
export function classifyBranchDeletion(branchDel) {
|
|
174
|
+
if (branchDel?.status === 0) {
|
|
175
|
+
return { branchDeleted: true, alreadyAbsent: false };
|
|
176
|
+
}
|
|
177
|
+
const stderr = (branchDel?.stderr ?? '').trim();
|
|
178
|
+
// git's own message for a missing ref: "error: branch 'foo' not found."
|
|
179
|
+
if (/\bnot found\b/i.test(stderr)) {
|
|
180
|
+
return { branchDeleted: true, alreadyAbsent: true };
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
branchDeleted: false,
|
|
184
|
+
alreadyAbsent: false,
|
|
185
|
+
...(stderr ? { stderr } : {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
155
189
|
/**
|
|
156
190
|
* Reap a single branch. Best-effort worktree remove โ fallback to `--force`
|
|
157
191
|
* โ fallback to filesystem rm โ `git worktree prune` โ `git branch -D`.
|
|
@@ -164,7 +198,7 @@ export function findWorktreePathForBranch(branch, worktrees) {
|
|
|
164
198
|
* rmSyncFn?: (path: string, opts: object) => void,
|
|
165
199
|
* logger?: { info?: Function, warn?: Function },
|
|
166
200
|
* }} opts
|
|
167
|
-
* @returns {{ branch: string, worktreeReaped: boolean, branchDeleted: boolean, method: string, stderr?: string }}
|
|
201
|
+
* @returns {{ branch: string, worktreeReaped: boolean, branchDeleted: boolean, alreadyAbsent: boolean, method: string, stderr?: string }}
|
|
168
202
|
*/
|
|
169
203
|
export function reapBranch(opts) {
|
|
170
204
|
const { branch, cwd, worktreePath, gitSpawn, rmSyncFn, logger } = opts;
|
|
@@ -201,16 +235,18 @@ export function reapBranch(opts) {
|
|
|
201
235
|
gitSpawn(cwd, 'worktree', 'prune');
|
|
202
236
|
}
|
|
203
237
|
|
|
204
|
-
// Drop the local branch.
|
|
238
|
+
// Drop the local branch. An already-absent branch (git โ "not found") is
|
|
239
|
+
// already reaped โ classifyBranchDeletion absorbs it as success so a benign
|
|
240
|
+
// missing ref never counts as a reap failure (Story #4393).
|
|
205
241
|
const branchDel = gitSpawn(cwd, 'branch', '-D', branch);
|
|
206
|
-
const branchDeleted
|
|
207
|
-
|
|
208
|
-
!branchDeleted && branchDel.stderr ? branchDel.stderr.trim() : undefined;
|
|
242
|
+
const { branchDeleted, alreadyAbsent, stderr } =
|
|
243
|
+
classifyBranchDeletion(branchDel);
|
|
209
244
|
|
|
210
245
|
return {
|
|
211
246
|
branch,
|
|
212
247
|
worktreeReaped,
|
|
213
248
|
branchDeleted,
|
|
249
|
+
alreadyAbsent,
|
|
214
250
|
method: method ?? 'unknown',
|
|
215
251
|
...(stderr ? { stderr } : {}),
|
|
216
252
|
};
|
|
@@ -81,6 +81,10 @@
|
|
|
81
81
|
*/
|
|
82
82
|
|
|
83
83
|
import { composeStoryBody } from '../../providers/github/tickets.js';
|
|
84
|
+
import {
|
|
85
|
+
extractFrameworkStamp,
|
|
86
|
+
stampFrameworkVersion,
|
|
87
|
+
} from '../framework-version.js';
|
|
84
88
|
import { assertPlanLabelAllowList } from './epic-spec-reconciler-discriminator.js';
|
|
85
89
|
import {
|
|
86
90
|
closeOp,
|
|
@@ -233,12 +237,21 @@ function stripFooter(body) {
|
|
|
233
237
|
* `composeStoryBody` directly makes that divergence structurally
|
|
234
238
|
* impossible going forward.
|
|
235
239
|
*
|
|
240
|
+
* Story #4382 โ the create-time authoring stamp (`mandrel_version` /
|
|
241
|
+
* `authored_at` meta field + visible marker) is provenance, not spec-derived
|
|
242
|
+
* content, so this UPDATE path never mints or bumps it (which would also break
|
|
243
|
+
* this function's purity โ the stamp uses a clock). Instead the stamp already
|
|
244
|
+
* present on the **live** GH body (`obsBody`) is preserved verbatim, and a
|
|
245
|
+
* legacy stamp-less body is left stamp-less (no backfill โ see the Story's
|
|
246
|
+
* Out-of-Scope). Footer recomposition therefore runs in `stamp: false` mode.
|
|
247
|
+
*
|
|
236
248
|
* @param {{entity: string, parentSlug?: string|null, dependsOn?: string[]}} specEntity
|
|
237
249
|
* @param {string} specBody
|
|
238
250
|
* @param {{state?: StateInput}} ctx
|
|
251
|
+
* @param {string} [obsBody] - The live GH body, source of the preserved stamp.
|
|
239
252
|
* @returns {string}
|
|
240
253
|
*/
|
|
241
|
-
function composeBodyWithFooter(specEntity, specBody, ctx) {
|
|
254
|
+
function composeBodyWithFooter(specEntity, specBody, ctx, obsBody = '') {
|
|
242
255
|
const state = ctx?.state ?? {};
|
|
243
256
|
const mapping = state.mapping ?? {};
|
|
244
257
|
const parentSlug = specEntity.parentSlug ?? null;
|
|
@@ -264,7 +277,25 @@ function composeBodyWithFooter(specEntity, specBody, ctx) {
|
|
|
264
277
|
// included) or emits a canonical-form body. With the strip, the
|
|
265
278
|
// function is idempotent against its own output.
|
|
266
279
|
const head = stripFooter(specBody);
|
|
267
|
-
|
|
280
|
+
// Preserve the live body's create-time authoring stamp (Story #4382). When
|
|
281
|
+
// the spec head already carries it, `stampFrameworkVersion` is a no-op
|
|
282
|
+
// (immutable); when it doesn't, the stamp is restored from the live body so
|
|
283
|
+
// the recomposed form matches GH and no spurious body Update fires. A
|
|
284
|
+
// legacy body with no stamp stays stamp-less (no backfill).
|
|
285
|
+
const priorStamp = extractFrameworkStamp(obsBody);
|
|
286
|
+
const stampedHead = priorStamp
|
|
287
|
+
? stampFrameworkVersion(head, {
|
|
288
|
+
version: priorStamp.version,
|
|
289
|
+
authoredAt: priorStamp.authoredAt,
|
|
290
|
+
})
|
|
291
|
+
: head;
|
|
292
|
+
return composeStoryBody({
|
|
293
|
+
body: stampedHead,
|
|
294
|
+
parentId,
|
|
295
|
+
epicId,
|
|
296
|
+
dependencies,
|
|
297
|
+
stamp: false,
|
|
298
|
+
});
|
|
268
299
|
}
|
|
269
300
|
|
|
270
301
|
/**
|
|
@@ -329,7 +360,7 @@ function fieldChanges(specEntity, obs, mapping, ctx = {}) {
|
|
|
329
360
|
// Emit a body change only when the canonical form differs from
|
|
330
361
|
// what is on GH today โ and write the canonical form back, so the
|
|
331
362
|
// footer cascade-readers depend on stays intact across resumes.
|
|
332
|
-
const after = composeBodyWithFooter(specEntity, specBody, ctx);
|
|
363
|
+
const after = composeBodyWithFooter(specEntity, specBody, ctx, obsBody);
|
|
333
364
|
if (after !== obsBody) {
|
|
334
365
|
changes.body = { before: obsBody, after };
|
|
335
366
|
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from './branches-reap.js';
|
|
22
22
|
import { computeProtectedReason } from './filters.js';
|
|
23
23
|
import {
|
|
24
|
+
branchLastCommitAt,
|
|
24
25
|
branchTipSha,
|
|
25
26
|
classifyLatestPr,
|
|
26
27
|
currentBranch as defaultCurrentBranch,
|
|
@@ -28,14 +29,18 @@ import {
|
|
|
28
29
|
listMergedBranches,
|
|
29
30
|
listRemoteBranches,
|
|
30
31
|
probeAllPrs,
|
|
32
|
+
probeContentEquivalent,
|
|
31
33
|
probeLatestPr,
|
|
32
34
|
pruneRemoteTracking,
|
|
33
35
|
readProtectedConfig,
|
|
36
|
+
refExists,
|
|
34
37
|
removeWorktree,
|
|
35
38
|
worktreesByBranch,
|
|
36
39
|
} from './git-probes.js';
|
|
37
40
|
import { parsePrunedRefs } from './prune.js';
|
|
38
41
|
|
|
42
|
+
const TAG = '[git-cleanup]';
|
|
43
|
+
|
|
39
44
|
function skipEntryFromVerdict(branch, verdict) {
|
|
40
45
|
const entry = { branch, reason: verdict.reason };
|
|
41
46
|
if (verdict.prNumber != null) entry.prNumber = verdict.prNumber;
|
|
@@ -44,8 +49,37 @@ function skipEntryFromVerdict(branch, verdict) {
|
|
|
44
49
|
return entry;
|
|
45
50
|
}
|
|
46
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Story #4395 โ the third detection signal. Reached only when the branch
|
|
54
|
+
* has no reapable PR verdict and is not an ancestor of `<base>` (or
|
|
55
|
+
* `origin/<base>`). Probes content-equivalence via `git merge-tree
|
|
56
|
+
* --write-tree` and, when the probe is conclusive and the merge is a
|
|
57
|
+
* no-op, classifies the branch as `content-merged` instead of falling
|
|
58
|
+
* through to the `not-merged` skip.
|
|
59
|
+
*/
|
|
60
|
+
function evaluateContentEquivalence({
|
|
61
|
+
branch,
|
|
62
|
+
baseBranch,
|
|
63
|
+
cwd,
|
|
64
|
+
contentEquivalentFn,
|
|
65
|
+
branchLastCommitFn,
|
|
66
|
+
}) {
|
|
67
|
+
const verdict = contentEquivalentFn({ cwd, base: baseBranch, branch });
|
|
68
|
+
if (verdict?.supported && verdict.equivalent) {
|
|
69
|
+
return { detectedBy: 'content-merged' };
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
skip: {
|
|
73
|
+
branch,
|
|
74
|
+
reason: 'not-merged',
|
|
75
|
+
lastCommitAt: branchLastCommitFn(cwd, branch),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
47
80
|
function evaluateLocalBranch({
|
|
48
81
|
branch,
|
|
82
|
+
baseBranch,
|
|
49
83
|
classify,
|
|
50
84
|
filter,
|
|
51
85
|
mergedByGit,
|
|
@@ -54,6 +88,8 @@ function evaluateLocalBranch({
|
|
|
54
88
|
wtMap,
|
|
55
89
|
remoteName,
|
|
56
90
|
branchTipShaFn,
|
|
91
|
+
contentEquivalentFn,
|
|
92
|
+
branchLastCommitFn,
|
|
57
93
|
}) {
|
|
58
94
|
const protectedReason = classify(branch);
|
|
59
95
|
if (protectedReason) return { skip: { branch, reason: protectedReason } };
|
|
@@ -78,7 +114,15 @@ function evaluateLocalBranch({
|
|
|
78
114
|
} else if (mergedByGit.has(branch)) {
|
|
79
115
|
detectedBy = 'git-merged';
|
|
80
116
|
} else {
|
|
81
|
-
|
|
117
|
+
const out = evaluateContentEquivalence({
|
|
118
|
+
branch,
|
|
119
|
+
baseBranch,
|
|
120
|
+
cwd,
|
|
121
|
+
contentEquivalentFn,
|
|
122
|
+
branchLastCommitFn,
|
|
123
|
+
});
|
|
124
|
+
if (out.skip) return out;
|
|
125
|
+
detectedBy = out.detectedBy;
|
|
82
126
|
}
|
|
83
127
|
const wt = wtMap.get(branch);
|
|
84
128
|
return {
|
|
@@ -156,7 +200,43 @@ function collectRemoteOnlyCandidates({
|
|
|
156
200
|
* the per-branch fallback for head refs absent from the bulk page (a PR
|
|
157
201
|
* that fell outside the fetch window), so correctness is preserved for
|
|
158
202
|
* every branch. Injecting `prProbe` bypasses the bulk fetch entirely.
|
|
203
|
+
*
|
|
204
|
+
* Story #4395 adds three refinements:
|
|
205
|
+
* - **Content-equivalence signal.** A local branch with no reapable PR
|
|
206
|
+
* verdict and no ancestry match gets one more chance via
|
|
207
|
+
* {@link probeContentEquivalent} (`git merge-tree --write-tree`):
|
|
208
|
+
* when merging it into `baseBranch` would be a content no-op, it is
|
|
209
|
+
* classified `detectedBy: 'content-merged'` instead of skipped.
|
|
210
|
+
* - **Fresh ancestry anchor.** The ancestry signal (`git branch --merged`)
|
|
211
|
+
* is unioned against `origin/<base>` (via `refExistsFn` + `mergedLister`)
|
|
212
|
+
* whenever that remote-tracking ref exists, so a stale local `<base>`
|
|
213
|
+
* no longer hides a branch already merged on the remote.
|
|
214
|
+
* - **Graceful `gh` degradation.** A throwing `gh` runner (auth failure,
|
|
215
|
+
* rate limit, missing binary) inside the bulk index fetch or the
|
|
216
|
+
* per-branch fallback is caught, logged once, and degrades to
|
|
217
|
+
* git-only signals (ancestry + content-equivalence) rather than
|
|
218
|
+
* aborting the whole plan. The returned envelope's `ghDegraded` flag
|
|
219
|
+
* records whether this happened.
|
|
159
220
|
*/
|
|
221
|
+
function buildGuardedPrProbe({ cwd, prIndexFn, prFallback, onDegrade }) {
|
|
222
|
+
let prIndex;
|
|
223
|
+
try {
|
|
224
|
+
prIndex = prIndexFn(cwd);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
onDegrade(err);
|
|
227
|
+
prIndex = new Map();
|
|
228
|
+
}
|
|
229
|
+
return (branch, c) => {
|
|
230
|
+
if (prIndex.has(branch)) return prIndex.get(branch);
|
|
231
|
+
try {
|
|
232
|
+
return prFallback(branch, c);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
onDegrade(err);
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
160
240
|
export function planCleanup(ctx) {
|
|
161
241
|
const {
|
|
162
242
|
cwd,
|
|
@@ -170,18 +250,26 @@ export function planCleanup(ctx) {
|
|
|
170
250
|
prIndexFn = probeAllPrs,
|
|
171
251
|
prFallback = probeLatestPr,
|
|
172
252
|
branchTipShaFn = branchTipSha,
|
|
253
|
+
contentEquivalentFn = probeContentEquivalent,
|
|
254
|
+
branchLastCommitFn = branchLastCommitAt,
|
|
255
|
+
refExistsFn = refExists,
|
|
173
256
|
filter = () => true,
|
|
174
257
|
includeRemoteOnly = false,
|
|
175
258
|
remoteLister = listRemoteBranches,
|
|
176
259
|
remoteName = 'origin',
|
|
260
|
+
logger = Logger,
|
|
177
261
|
} = ctx;
|
|
262
|
+
let ghDegraded = false;
|
|
263
|
+
const onDegrade = (err) => {
|
|
264
|
+
if (ghDegraded) return;
|
|
265
|
+
ghDegraded = true;
|
|
266
|
+
logger.warn?.(
|
|
267
|
+
`${TAG} โ ๏ธ gh probe failed (${err?.message ?? err}); continuing with git-only signals`,
|
|
268
|
+
);
|
|
269
|
+
};
|
|
178
270
|
const prProbe =
|
|
179
271
|
injectedPrProbe ??
|
|
180
|
-
(
|
|
181
|
-
const prIndex = prIndexFn(cwd);
|
|
182
|
-
return (branch, c) =>
|
|
183
|
-
prIndex.has(branch) ? prIndex.get(branch) : prFallback(branch, c);
|
|
184
|
-
})();
|
|
272
|
+
buildGuardedPrProbe({ cwd, prIndexFn, prFallback, onDegrade });
|
|
185
273
|
const resolvedCurrent = currentBranchFn(cwd);
|
|
186
274
|
const resolvedConfigured = protectedConfigFn(cwd);
|
|
187
275
|
const classify = (branch) =>
|
|
@@ -193,6 +281,10 @@ export function planCleanup(ctx) {
|
|
|
193
281
|
});
|
|
194
282
|
const wtMap = worktreesFn(cwd);
|
|
195
283
|
const mergedByGit = new Set(mergedLister(cwd, baseBranch));
|
|
284
|
+
const remoteBaseRef = `${remoteName}/${baseBranch}`;
|
|
285
|
+
if (refExistsFn(cwd, remoteBaseRef)) {
|
|
286
|
+
for (const b of mergedLister(cwd, remoteBaseRef)) mergedByGit.add(b);
|
|
287
|
+
}
|
|
196
288
|
const localBranches = localLister(cwd);
|
|
197
289
|
const localSet = new Set(localBranches);
|
|
198
290
|
const candidates = [];
|
|
@@ -200,6 +292,7 @@ export function planCleanup(ctx) {
|
|
|
200
292
|
for (const branch of localBranches) {
|
|
201
293
|
const out = evaluateLocalBranch({
|
|
202
294
|
branch,
|
|
295
|
+
baseBranch,
|
|
203
296
|
classify,
|
|
204
297
|
filter,
|
|
205
298
|
mergedByGit,
|
|
@@ -208,6 +301,8 @@ export function planCleanup(ctx) {
|
|
|
208
301
|
wtMap,
|
|
209
302
|
remoteName,
|
|
210
303
|
branchTipShaFn,
|
|
304
|
+
contentEquivalentFn,
|
|
305
|
+
branchLastCommitFn,
|
|
211
306
|
});
|
|
212
307
|
if (out.skip) skipped.push(out.skip);
|
|
213
308
|
else candidates.push(out.candidate);
|
|
@@ -227,7 +322,7 @@ export function planCleanup(ctx) {
|
|
|
227
322
|
}),
|
|
228
323
|
);
|
|
229
324
|
}
|
|
230
|
-
return { candidates, skipped };
|
|
325
|
+
return { candidates, skipped, ghDegraded };
|
|
231
326
|
}
|
|
232
327
|
|
|
233
328
|
/**
|