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
|
@@ -344,7 +344,91 @@ export function branchTipSha({
|
|
|
344
344
|
return res.status !== 0 ? null : validSha(firstLsRemoteSha(res.stdout));
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
-
|
|
347
|
+
/* node:coverage ignore next */
|
|
348
|
+
// Story #4395: ancestry-anchor freshness check. `planCleanup` calls this
|
|
349
|
+
// before unioning `git branch --merged origin/<base>` into the ancestry
|
|
350
|
+
// signal so a stale local `<base>` (fast-forward phase skipped or
|
|
351
|
+
// `--branches` run alone) doesn't hide a branch that's already merged on
|
|
352
|
+
// the remote.
|
|
353
|
+
export function refExists(cwd, ref) {
|
|
354
|
+
const res = gitSpawn(cwd, 'rev-parse', '--verify', '--quiet', ref);
|
|
355
|
+
return res.status === 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* node:coverage ignore next */
|
|
359
|
+
// Story #4395: last-commit timestamp for the dry-run `not-merged`
|
|
360
|
+
// skip-visibility line (branch name + last-commit age).
|
|
361
|
+
export function branchLastCommitAt(cwd, branch) {
|
|
362
|
+
const res = gitSpawn(
|
|
363
|
+
cwd,
|
|
364
|
+
'log',
|
|
365
|
+
'-1',
|
|
366
|
+
'--format=%cI',
|
|
367
|
+
`refs/heads/${branch}`,
|
|
368
|
+
'--',
|
|
369
|
+
);
|
|
370
|
+
if (res.status !== 0) return null;
|
|
371
|
+
return res.stdout.trim() || null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* First non-empty trimmed stdout line — the resulting tree OID on a clean
|
|
376
|
+
* `git merge-tree --write-tree` run.
|
|
377
|
+
*
|
|
378
|
+
* @param {string} stdout
|
|
379
|
+
* @returns {string}
|
|
380
|
+
*/
|
|
381
|
+
function firstStdoutLine(stdout) {
|
|
382
|
+
const first = (stdout ?? '')
|
|
383
|
+
.split('\n')
|
|
384
|
+
.map((l) => l.trim())
|
|
385
|
+
.find(Boolean);
|
|
386
|
+
return first ?? '';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Probe content-equivalence between `base` and `branch` via
|
|
391
|
+
* `git merge-tree --write-tree <base> <branch>` (git >= 2.38, Story #4395).
|
|
392
|
+
*
|
|
393
|
+
* A clean merge (exit 0) whose resulting tree OID equals `<base>`'s own
|
|
394
|
+
* tree OID means applying `branch`'s changes on top of `base` is a no-op —
|
|
395
|
+
* `branch`'s content already lives in `base` by another route (a
|
|
396
|
+
* squash-merged Epic PR, a cherry-pick, a manual `merge --squash`) that
|
|
397
|
+
* neither the PR probe nor the ancestry check can see.
|
|
398
|
+
*
|
|
399
|
+
* Both the "unsupported" case (git < 2.38 rejects `--write-tree`) and the
|
|
400
|
+
* "real conflict" case (branch and base diverge and cannot auto-merge)
|
|
401
|
+
* surface as a non-zero exit. This probe treats them identically — the
|
|
402
|
+
* signal is inconclusive, so the caller keeps the branch's current
|
|
403
|
+
* `not-merged` classification rather than guessing.
|
|
404
|
+
*
|
|
405
|
+
* @param {{ cwd: string, base: string, branch: string, spawn?: typeof gitSpawn }} args
|
|
406
|
+
* @returns {{ supported: false } | { supported: true, equivalent: boolean }}
|
|
407
|
+
*/
|
|
408
|
+
export function probeContentEquivalent({
|
|
409
|
+
cwd,
|
|
410
|
+
base,
|
|
411
|
+
branch,
|
|
412
|
+
spawn = gitSpawn,
|
|
413
|
+
}) {
|
|
414
|
+
const merged = spawn(cwd, 'merge-tree', '--write-tree', base, branch);
|
|
415
|
+
if (merged.status !== 0) return { supported: false };
|
|
416
|
+
const mergedTree = validSha(firstStdoutLine(merged.stdout));
|
|
417
|
+
if (!mergedTree) return { supported: false };
|
|
418
|
+
const baseTreeRes = spawn(
|
|
419
|
+
cwd,
|
|
420
|
+
'rev-parse',
|
|
421
|
+
'--verify',
|
|
422
|
+
'--quiet',
|
|
423
|
+
`${base}^{tree}`,
|
|
424
|
+
);
|
|
425
|
+
if (baseTreeRes.status !== 0) return { supported: false };
|
|
426
|
+
const baseTree = validSha(baseTreeRes.stdout);
|
|
427
|
+
if (!baseTree) return { supported: false };
|
|
428
|
+
return { supported: true, equivalent: mergedTree === baseTree };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export const __testing = { validSha, firstLsRemoteSha, firstStdoutLine };
|
|
348
432
|
|
|
349
433
|
/**
|
|
350
434
|
* Pure-ish: classify a latest-PR probe row into a planner verdict.
|
|
@@ -190,6 +190,22 @@ export async function runPrunePhase(opts, cwd) {
|
|
|
190
190
|
// Branch phase
|
|
191
191
|
// =====================================================================
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Count of a plan's *actionable* candidates — the ones `executeCleanup`
|
|
195
|
+
* will actually delete given the current `--remote` setting. Story #4395
|
|
196
|
+
* always enumerates remote-only candidates in `plan.candidates` (so the
|
|
197
|
+
* operator sees them in the dry-run list), but their deletion still
|
|
198
|
+
* requires `--remote`; without it, `executeCleanup` no-ops on every
|
|
199
|
+
* `localExists: false` candidate. Counting only the actionable subset
|
|
200
|
+
* keeps the "no-candidates" short-circuit and the confirmation prompt's
|
|
201
|
+
* "Reap N" count honest about what will actually happen.
|
|
202
|
+
*/
|
|
203
|
+
function countActionableCandidates(candidates, remote) {
|
|
204
|
+
return remote
|
|
205
|
+
? candidates.length
|
|
206
|
+
: candidates.filter((c) => c.localExists !== false).length;
|
|
207
|
+
}
|
|
208
|
+
|
|
193
209
|
/**
|
|
194
210
|
* Pure: decide what the branch-reap phase should do given the plan.
|
|
195
211
|
*
|
|
@@ -209,7 +225,11 @@ export function decideBranchPhase(state) {
|
|
|
209
225
|
if (opts.dryRun) {
|
|
210
226
|
return { kind: 'dry-run', plan, result: { plan, result: null } };
|
|
211
227
|
}
|
|
212
|
-
|
|
228
|
+
const actionableCount = countActionableCandidates(
|
|
229
|
+
plan.candidates,
|
|
230
|
+
opts.remote,
|
|
231
|
+
);
|
|
232
|
+
if (actionableCount === 0) {
|
|
213
233
|
return { kind: 'no-candidates', plan, result: { plan, result: null } };
|
|
214
234
|
}
|
|
215
235
|
const executeArgs = {
|
|
@@ -218,10 +238,17 @@ export function decideBranchPhase(state) {
|
|
|
218
238
|
remote: opts.remote,
|
|
219
239
|
};
|
|
220
240
|
if (!opts.yes) {
|
|
241
|
+
const contentMergedCount = plan.candidates.filter(
|
|
242
|
+
(c) => c.detectedBy === 'content-merged',
|
|
243
|
+
).length;
|
|
244
|
+
const weakSignalNote =
|
|
245
|
+
contentMergedCount > 0
|
|
246
|
+
? ` (${contentMergedCount} content-merged — weaker signal, verify before confirming)`
|
|
247
|
+
: '';
|
|
221
248
|
return {
|
|
222
249
|
kind: 'prompt-then-execute',
|
|
223
250
|
plan,
|
|
224
|
-
promptMessage: `${TAG} Reap ${
|
|
251
|
+
promptMessage: `${TAG} Reap ${actionableCount} merged branch(es)${opts.remote ? ' (including origin)' : ''}${weakSignalNote}?`,
|
|
225
252
|
declinedResult: { plan, result: null, declined: true },
|
|
226
253
|
executeArgs,
|
|
227
254
|
};
|
|
@@ -254,11 +281,15 @@ export async function runBranchPhase(opts, cwd, baseBranch) {
|
|
|
254
281
|
include: opts.include,
|
|
255
282
|
exclude: opts.exclude,
|
|
256
283
|
});
|
|
284
|
+
// Story #4395: always enumerate remote-only merged branches so the
|
|
285
|
+
// dry-run / prompt shows them without requiring `--remote`. Deletion of
|
|
286
|
+
// a remote-only candidate still requires `--remote` — `executeCleanup`
|
|
287
|
+
// no-ops on `localExists: false` candidates otherwise (unchanged).
|
|
257
288
|
const plan = planCleanup({
|
|
258
289
|
cwd,
|
|
259
290
|
baseBranch,
|
|
260
291
|
filter,
|
|
261
|
-
includeRemoteOnly:
|
|
292
|
+
includeRemoteOnly: true,
|
|
262
293
|
});
|
|
263
294
|
emitDryRunHuman(plan, baseBranch);
|
|
264
295
|
const action = decideBranchPhase({ plan, opts, cwd });
|
|
@@ -9,10 +9,61 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const TAG = '[git-cleanup]';
|
|
12
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Pure: format a last-commit ISO timestamp as a short relative-age string
|
|
16
|
+
* for the `not-merged` skip-visibility line (Story #4395). Returns
|
|
17
|
+
* `'unknown'` when `iso` is missing or unparseable — a branch whose commit
|
|
18
|
+
* date could not be resolved (e.g. `gh`-degraded run, deleted ref) still
|
|
19
|
+
* gets a line, just without an age.
|
|
20
|
+
*
|
|
21
|
+
* @param {string|null|undefined} iso
|
|
22
|
+
* @param {number} now Epoch-ms reference clock (injectable for tests).
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function formatCommitAge(iso, now) {
|
|
26
|
+
if (!iso) return 'unknown';
|
|
27
|
+
const then = Date.parse(iso);
|
|
28
|
+
if (!Number.isFinite(then)) return 'unknown';
|
|
29
|
+
const days = Math.max(0, Math.floor((now - then) / DAY_MS));
|
|
30
|
+
if (days === 0) return 'today';
|
|
31
|
+
if (days === 1) return '1 day ago';
|
|
32
|
+
return `${days} days ago`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pure: render a single `not-merged` skip-visibility line (Story #4395).
|
|
37
|
+
* `renderDryRun` previously kept `not-merged` survivors silent; this
|
|
38
|
+
* surfaces each one with its last-commit age so the operator can see why
|
|
39
|
+
* a leftover branch isn't reaped instead of hunting for it by hand.
|
|
40
|
+
*
|
|
41
|
+
* @param {{ branch: string, reason: string, lastCommitAt?: string|null }} skip
|
|
42
|
+
* @param {{ now?: number }} [opts]
|
|
43
|
+
* @returns {string | null}
|
|
44
|
+
*/
|
|
45
|
+
export function renderNotMergedSkipLine(skip, opts = {}) {
|
|
46
|
+
if (!skip || skip.reason !== 'not-merged') return null;
|
|
47
|
+
const now = opts.now ?? Date.now();
|
|
48
|
+
const age = formatCommitAge(skip.lastCommitAt, now);
|
|
49
|
+
return `${TAG} ⏭️ ${skip.branch} skipped — not merged (last commit: ${age})`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pure: render a single content-merged candidate annotation line
|
|
54
|
+
* (Story #4395). `content-merged` is a weaker signal than a merged PR or
|
|
55
|
+
* git ancestry — this note lets the operator tell it apart in both the
|
|
56
|
+
* dry-run list and the confirmation prompt.
|
|
57
|
+
*/
|
|
58
|
+
function contentMergedNote(candidate) {
|
|
59
|
+
return candidate.detectedBy === 'content-merged'
|
|
60
|
+
? ' (weaker signal — verify before deleting)'
|
|
61
|
+
: '';
|
|
62
|
+
}
|
|
12
63
|
|
|
13
64
|
/** Pure: render the dry-run plan as the operator-facing text block. */
|
|
14
65
|
export function renderDryRun(plan, opts = {}) {
|
|
15
|
-
const { baseBranch = null } = opts;
|
|
66
|
+
const { baseBranch = null, now } = opts;
|
|
16
67
|
const lines = [
|
|
17
68
|
`${TAG} DRY RUN (nothing deleted) — ${plan.candidates.length} candidate(s)`,
|
|
18
69
|
];
|
|
@@ -23,7 +74,9 @@ export function renderDryRun(plan, opts = {}) {
|
|
|
23
74
|
const pr = c.prNumber ? `PR #${c.prNumber}` : c.detectedBy;
|
|
24
75
|
const wt = c.hasWorktree ? ` (worktree: ${c.worktreePath})` : '';
|
|
25
76
|
const remoteOnly = c.localExists === false ? ' (remote-only)' : '';
|
|
26
|
-
lines.push(
|
|
77
|
+
lines.push(
|
|
78
|
+
` • ${c.branch} — ${pr}${wt}${remoteOnly}${contentMergedNote(c)}`,
|
|
79
|
+
);
|
|
27
80
|
}
|
|
28
81
|
}
|
|
29
82
|
const skipped = plan.skipped ?? [];
|
|
@@ -40,6 +93,15 @@ export function renderDryRun(plan, opts = {}) {
|
|
|
40
93
|
const line = renderLatestPrSkipLine(skip);
|
|
41
94
|
if (line) lines.push(line);
|
|
42
95
|
}
|
|
96
|
+
for (const skip of skipped) {
|
|
97
|
+
const line = renderNotMergedSkipLine(skip, { now });
|
|
98
|
+
if (line) lines.push(line);
|
|
99
|
+
}
|
|
100
|
+
if (plan.ghDegraded) {
|
|
101
|
+
lines.push(
|
|
102
|
+
`${TAG} ⚠️ gh probe degraded — candidates rely on git-only signals (ancestry + content-equivalence) for this run`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
43
105
|
return lines;
|
|
44
106
|
}
|
|
45
107
|
|
|
@@ -47,7 +109,8 @@ export function renderDryRun(plan, opts = {}) {
|
|
|
47
109
|
* Pure: render a single latest-PR-state skip line. Returns null when the
|
|
48
110
|
* skip reason is not one of the latest-PR family — `renderDryRun` filters
|
|
49
111
|
* by truthy return value so unrelated skip reasons (`protected`,
|
|
50
|
-
* `current-head`, `filtered
|
|
112
|
+
* `current-head`, `filtered`) stay quiet here. `not-merged` gets its own
|
|
113
|
+
* renderer ({@link renderNotMergedSkipLine}).
|
|
51
114
|
*
|
|
52
115
|
* @param {{ branch: string, reason: string, prNumber?: number, tipSha?: string, mergedSha?: string }} skip
|
|
53
116
|
* @returns {string | null}
|
|
@@ -64,7 +127,10 @@ export function renderLatestPrSkipLine(skip) {
|
|
|
64
127
|
if (skip.reason === 'tip-diverged-from-merge') {
|
|
65
128
|
const tip = skip.tipSha ? skip.tipSha.slice(0, 7) : '<unknown>';
|
|
66
129
|
const merged = skip.mergedSha ? skip.mergedSha.slice(0, 7) : '<unknown>';
|
|
67
|
-
return
|
|
130
|
+
return (
|
|
131
|
+
`${TAG} ⏭️ ${skip.branch} skipped — tip ${tip} diverges from ${prRef}'s merged ${merged} (post-merge force-push); ` +
|
|
132
|
+
`resolve by deleting manually (\`git branch -D ${skip.branch}\`) or pushing the follow-up commit`
|
|
133
|
+
);
|
|
68
134
|
}
|
|
69
135
|
if (skip.reason === 'latest-pr-unknown-state') {
|
|
70
136
|
return `${TAG} ⏭️ ${skip.branch} skipped — ${prRef} has an unrecognized state`;
|
|
@@ -160,6 +226,7 @@ export function buildJsonEnvelope({
|
|
|
160
226
|
baseBranch,
|
|
161
227
|
candidates: plan.candidates,
|
|
162
228
|
skipped: plan.skipped,
|
|
229
|
+
ghDegraded: plan.ghDegraded ?? false,
|
|
163
230
|
worktrees: r.worktrees,
|
|
164
231
|
local: r.local,
|
|
165
232
|
remote: r.remote,
|
|
@@ -28,6 +28,16 @@
|
|
|
28
28
|
* lockfile around plan + execute. On lock contention the
|
|
29
29
|
* sweep is skipped (the host continues — same contract as a
|
|
30
30
|
* plan failure).
|
|
31
|
+
* - Content-merged (Story #4396, report-only): a plan candidate the
|
|
32
|
+
* `git-cleanup` planner classified `detectedBy: 'content-merged'`
|
|
33
|
+
* (Story #4395's `git merge-tree --write-tree` content-equivalence
|
|
34
|
+
* probe) is a **weaker** signal than a merged PR or git ancestry —
|
|
35
|
+
* no CI/GitHub merge check ever validated its exact diff. This
|
|
36
|
+
* engine never reaps on that signal alone: content-merged
|
|
37
|
+
* candidates are pulled out of the plan before protection +
|
|
38
|
+
* execute and surfaced under `contentMerged` in the envelope so
|
|
39
|
+
* the operator can route them to `/git-cleanup` for a confirmed,
|
|
40
|
+
* eyeballed reap.
|
|
31
41
|
* - Never touches the stash stack.
|
|
32
42
|
* - Errors are caught and surfaced in the envelope. Callers MUST NOT
|
|
33
43
|
* propagate sweep failures — the host proceeds either way.
|
|
@@ -81,6 +91,7 @@ const STORY_BRANCH_INCLUDE = 'story-*';
|
|
|
81
91
|
* localDeleted: number,
|
|
82
92
|
* remoteDeleted: number,
|
|
83
93
|
* protected: Array<{ branch: string, reason: string, worktreePath?: string|null }>,
|
|
94
|
+
* contentMerged: Array<{ branch: string, worktreePath: string|null }>,
|
|
84
95
|
* failures: Array<{ branch: string|null, scope: string, stderr?: string }>,
|
|
85
96
|
* fastForward?: object,
|
|
86
97
|
* error?: string,
|
|
@@ -137,6 +148,7 @@ export async function sweepMergedBranches({
|
|
|
137
148
|
localDeleted: 0,
|
|
138
149
|
remoteDeleted: 0,
|
|
139
150
|
protected: [],
|
|
151
|
+
contentMerged: [],
|
|
140
152
|
failures: [],
|
|
141
153
|
};
|
|
142
154
|
}
|
|
@@ -213,6 +225,31 @@ export function sweepMergedStoryBranches(args = {}) {
|
|
|
213
225
|
});
|
|
214
226
|
}
|
|
215
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Split a plan's candidates into the reapable set and the report-only
|
|
230
|
+
* `content-merged` set (Story #4396). A candidate the `git-cleanup`
|
|
231
|
+
* planner classified `detectedBy: 'content-merged'` (Story #4395's
|
|
232
|
+
* `git merge-tree --write-tree` probe) never reaches protection or
|
|
233
|
+
* `executeCleanup` — it is a weaker signal than a merged PR or git
|
|
234
|
+
* ancestry, so the engine only reports it for the operator to route to
|
|
235
|
+
* `/git-cleanup`.
|
|
236
|
+
*/
|
|
237
|
+
function partitionContentMerged(candidates) {
|
|
238
|
+
const contentMerged = [];
|
|
239
|
+
const reapCandidates = [];
|
|
240
|
+
for (const candidate of candidates) {
|
|
241
|
+
if (candidate.detectedBy === 'content-merged') {
|
|
242
|
+
contentMerged.push({
|
|
243
|
+
branch: candidate.branch,
|
|
244
|
+
worktreePath: candidate.worktreePath ?? null,
|
|
245
|
+
});
|
|
246
|
+
} else {
|
|
247
|
+
reapCandidates.push(candidate);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { contentMerged, reapCandidates };
|
|
251
|
+
}
|
|
252
|
+
|
|
216
253
|
/**
|
|
217
254
|
* Inner: the plan + protect + execute pipeline. Kept separate so the
|
|
218
255
|
* outer engine can stay focused on the lock and fast-forward wrappers.
|
|
@@ -240,7 +277,18 @@ async function runSweepUnderLock({
|
|
|
240
277
|
return zeroResult({ error: `plan: ${msg}` });
|
|
241
278
|
}
|
|
242
279
|
|
|
243
|
-
|
|
280
|
+
const { contentMerged, reapCandidates } = partitionContentMerged(
|
|
281
|
+
plan.candidates,
|
|
282
|
+
);
|
|
283
|
+
if (contentMerged.length > 0) {
|
|
284
|
+
log.info(
|
|
285
|
+
`${logTag} ${contentMerged.length} content-merged branch(es) detected (report-only, not reaped): ${contentMerged
|
|
286
|
+
.map((c) => c.branch)
|
|
287
|
+
.join(', ')}.`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (reapCandidates.length === 0) {
|
|
244
292
|
log.info(`${logTag} no merged branches to reap.`);
|
|
245
293
|
return {
|
|
246
294
|
ok: true,
|
|
@@ -249,12 +297,13 @@ async function runSweepUnderLock({
|
|
|
249
297
|
localDeleted: 0,
|
|
250
298
|
remoteDeleted: 0,
|
|
251
299
|
protected: [],
|
|
300
|
+
contentMerged,
|
|
252
301
|
failures: [],
|
|
253
302
|
};
|
|
254
303
|
}
|
|
255
304
|
|
|
256
305
|
const { reapable, protectedList } = await partitionCandidates({
|
|
257
|
-
candidates:
|
|
306
|
+
candidates: reapCandidates,
|
|
258
307
|
protectionFn,
|
|
259
308
|
protectionCtx,
|
|
260
309
|
log,
|
|
@@ -263,15 +312,16 @@ async function runSweepUnderLock({
|
|
|
263
312
|
|
|
264
313
|
if (reapable.length === 0) {
|
|
265
314
|
log.info(
|
|
266
|
-
`${logTag} all ${
|
|
315
|
+
`${logTag} all ${reapCandidates.length} candidate(s) protected; no reap.`,
|
|
267
316
|
);
|
|
268
317
|
return {
|
|
269
318
|
ok: true,
|
|
270
319
|
skipped: false,
|
|
271
|
-
candidates:
|
|
320
|
+
candidates: reapCandidates.length,
|
|
272
321
|
localDeleted: 0,
|
|
273
322
|
remoteDeleted: 0,
|
|
274
323
|
protected: protectedList,
|
|
324
|
+
contentMerged,
|
|
275
325
|
failures: [],
|
|
276
326
|
};
|
|
277
327
|
}
|
|
@@ -279,7 +329,8 @@ async function runSweepUnderLock({
|
|
|
279
329
|
return executeReap({
|
|
280
330
|
reapable,
|
|
281
331
|
protectedList,
|
|
282
|
-
|
|
332
|
+
contentMerged,
|
|
333
|
+
candidateCount: reapCandidates.length,
|
|
283
334
|
cwd,
|
|
284
335
|
executeCleanupFn,
|
|
285
336
|
log,
|
|
@@ -295,6 +346,7 @@ async function runSweepUnderLock({
|
|
|
295
346
|
function executeReap({
|
|
296
347
|
reapable,
|
|
297
348
|
protectedList,
|
|
349
|
+
contentMerged,
|
|
298
350
|
candidateCount,
|
|
299
351
|
cwd,
|
|
300
352
|
executeCleanupFn,
|
|
@@ -314,6 +366,7 @@ function executeReap({
|
|
|
314
366
|
localDeleted: 0,
|
|
315
367
|
remoteDeleted: 0,
|
|
316
368
|
protected: protectedList,
|
|
369
|
+
contentMerged,
|
|
317
370
|
failures: [{ branch: null, scope: 'execute', stderr: msg }],
|
|
318
371
|
error: `execute: ${msg}`,
|
|
319
372
|
};
|
|
@@ -346,6 +399,7 @@ function executeReap({
|
|
|
346
399
|
localDeleted,
|
|
347
400
|
remoteDeleted,
|
|
348
401
|
protected: protectedList,
|
|
402
|
+
contentMerged,
|
|
349
403
|
failures: result.failures,
|
|
350
404
|
};
|
|
351
405
|
}
|
|
@@ -450,6 +504,7 @@ function zeroResult({ error }) {
|
|
|
450
504
|
localDeleted: 0,
|
|
451
505
|
remoteDeleted: 0,
|
|
452
506
|
protected: [],
|
|
507
|
+
contentMerged: [],
|
|
453
508
|
failures: [],
|
|
454
509
|
error,
|
|
455
510
|
};
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
* @module story-body
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
|
+
import {
|
|
46
|
+
AUTHORED_MARKER_LINE_RE,
|
|
47
|
+
authoredMarkerLine,
|
|
48
|
+
} from '../framework-version.js';
|
|
45
49
|
import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js';
|
|
46
50
|
|
|
47
51
|
// ---------------------------------------------------------------------------
|
|
@@ -75,6 +79,8 @@ import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js
|
|
|
75
79
|
* @property {string|null} reason_to_exist - One-sentence cohesion reason ("why this Story exists"), or null.
|
|
76
80
|
* @property {string[]} depends_on - Blocking story slugs / issue refs.
|
|
77
81
|
* @property {number|null} estimated_test_files - Test surface count or null.
|
|
82
|
+
* @property {string|null} mandrel_version - Framework version stamped at authoring, or null.
|
|
83
|
+
* @property {string|null} authored_at - Authoring date (YYYY-MM-DD) stamped at authoring, or null.
|
|
78
84
|
*/
|
|
79
85
|
|
|
80
86
|
/**
|
|
@@ -253,14 +259,21 @@ const META_BLOCK_RE = /<!--\s*meta:\s*(\{[\s\S]*?\})\s*-->/;
|
|
|
253
259
|
* otherwise-valid Story body. A parse failure degrades to the absent-meta
|
|
254
260
|
* defaults instead of throwing.
|
|
255
261
|
*
|
|
262
|
+
* The `mandrel_version` / `authored_at` provenance stamp (written once at
|
|
263
|
+
* authoring time by the ticket-creation path) is recovered here too so a later
|
|
264
|
+
* `parse → serialize` preserves the originally-authored version verbatim
|
|
265
|
+
* rather than dropping or re-deriving it.
|
|
266
|
+
*
|
|
256
267
|
* @param {string} markdown
|
|
257
|
-
* @returns {{ wide: { reason: string }|null, reason_to_exist: string|null, estimated_test_files: number|null }}
|
|
268
|
+
* @returns {{ wide: { reason: string }|null, reason_to_exist: string|null, estimated_test_files: number|null, mandrel_version: string|null, authored_at: string|null }}
|
|
258
269
|
*/
|
|
259
270
|
function extractMeta(markdown) {
|
|
260
271
|
const result = {
|
|
261
272
|
wide: null,
|
|
262
273
|
reason_to_exist: null,
|
|
263
274
|
estimated_test_files: null,
|
|
275
|
+
mandrel_version: null,
|
|
276
|
+
authored_at: null,
|
|
264
277
|
};
|
|
265
278
|
const match = markdown.match(META_BLOCK_RE);
|
|
266
279
|
if (!match) return result;
|
|
@@ -279,6 +292,15 @@ function extractMeta(markdown) {
|
|
|
279
292
|
if (typeof parsed.estimated_test_files === 'number') {
|
|
280
293
|
result.estimated_test_files = parsed.estimated_test_files;
|
|
281
294
|
}
|
|
295
|
+
if (
|
|
296
|
+
typeof parsed.mandrel_version === 'string' &&
|
|
297
|
+
parsed.mandrel_version.trim()
|
|
298
|
+
) {
|
|
299
|
+
result.mandrel_version = parsed.mandrel_version.trim();
|
|
300
|
+
}
|
|
301
|
+
if (typeof parsed.authored_at === 'string' && parsed.authored_at.trim()) {
|
|
302
|
+
result.authored_at = parsed.authored_at.trim();
|
|
303
|
+
}
|
|
282
304
|
return result;
|
|
283
305
|
}
|
|
284
306
|
|
|
@@ -409,6 +431,14 @@ function splitSections(markdown) {
|
|
|
409
431
|
continue;
|
|
410
432
|
}
|
|
411
433
|
|
|
434
|
+
// The visible `> 🏷️ Authored with Mandrel …` provenance marker is
|
|
435
|
+
// machine-managed metadata too (emitted alongside the meta block by the
|
|
436
|
+
// authoring path). Skip it so it never bleeds into the trailing structured
|
|
437
|
+
// section (e.g. `## Verify`); the value round-trips via the meta block.
|
|
438
|
+
if (AUTHORED_MARKER_LINE_RE.test(line)) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
|
|
412
442
|
if (inPreamble) {
|
|
413
443
|
preambleLines.push(line);
|
|
414
444
|
} else if (currentSection !== null) {
|
|
@@ -456,6 +486,8 @@ function parseLegacyStringBody(input, preamble, footer) {
|
|
|
456
486
|
reason_to_exist: null,
|
|
457
487
|
depends_on: extractBlockedBy(footer),
|
|
458
488
|
estimated_test_files: null,
|
|
489
|
+
mandrel_version: null,
|
|
490
|
+
authored_at: null,
|
|
459
491
|
};
|
|
460
492
|
return {
|
|
461
493
|
body,
|
|
@@ -602,6 +634,8 @@ export function parse(input) {
|
|
|
602
634
|
const estimated_test_files = meta.estimated_test_files;
|
|
603
635
|
const wide = meta.wide;
|
|
604
636
|
const reason_to_exist = meta.reason_to_exist;
|
|
637
|
+
const mandrel_version = meta.mandrel_version;
|
|
638
|
+
const authored_at = meta.authored_at;
|
|
605
639
|
if (estimated_test_files === null) {
|
|
606
640
|
warnings.push(
|
|
607
641
|
'test-surface-unestimated: estimated_test_files not present.',
|
|
@@ -619,6 +653,8 @@ export function parse(input) {
|
|
|
619
653
|
reason_to_exist,
|
|
620
654
|
depends_on: dependsOn,
|
|
621
655
|
estimated_test_files,
|
|
656
|
+
mandrel_version,
|
|
657
|
+
authored_at,
|
|
622
658
|
};
|
|
623
659
|
|
|
624
660
|
return {
|
|
@@ -699,6 +735,16 @@ function parseStructuredObject(obj) {
|
|
|
699
735
|
);
|
|
700
736
|
}
|
|
701
737
|
|
|
738
|
+
// Provenance stamp (preserved verbatim; never re-derived here).
|
|
739
|
+
const mandrel_version =
|
|
740
|
+
typeof obj.mandrel_version === 'string' && obj.mandrel_version.trim()
|
|
741
|
+
? obj.mandrel_version.trim()
|
|
742
|
+
: null;
|
|
743
|
+
const authored_at =
|
|
744
|
+
typeof obj.authored_at === 'string' && obj.authored_at.trim()
|
|
745
|
+
? obj.authored_at.trim()
|
|
746
|
+
: null;
|
|
747
|
+
|
|
702
748
|
const body = {
|
|
703
749
|
goal,
|
|
704
750
|
changes,
|
|
@@ -710,6 +756,8 @@ function parseStructuredObject(obj) {
|
|
|
710
756
|
reason_to_exist,
|
|
711
757
|
depends_on,
|
|
712
758
|
estimated_test_files,
|
|
759
|
+
mandrel_version,
|
|
760
|
+
authored_at,
|
|
713
761
|
};
|
|
714
762
|
|
|
715
763
|
return {
|
|
@@ -812,9 +860,11 @@ const SERIALIZE_SECTIONS = [
|
|
|
812
860
|
* `estimated_test_files`). Returns the empty string when no meta field is
|
|
813
861
|
* present so {@link serialize} appends nothing.
|
|
814
862
|
*
|
|
815
|
-
* Key insertion order (`wide` → `reason_to_exist` → `estimated_test_files`
|
|
816
|
-
* is load-bearing: it fixes the serialized
|
|
817
|
-
* meta round-trip and the unit suite assert
|
|
863
|
+
* Key insertion order (`wide` → `reason_to_exist` → `estimated_test_files` →
|
|
864
|
+
* `mandrel_version` → `authored_at`) is load-bearing: it fixes the serialized
|
|
865
|
+
* JSON byte sequence the parser's meta round-trip and the unit suite assert
|
|
866
|
+
* against. The provenance stamp keys are appended **last** so every
|
|
867
|
+
* pre-existing (stamp-less) body serialises byte-identically to before.
|
|
818
868
|
*
|
|
819
869
|
* @param {StoryBody} body
|
|
820
870
|
* @returns {string}
|
|
@@ -832,10 +882,36 @@ function serializeMetaBlock(body) {
|
|
|
832
882
|
if (typeof body.estimated_test_files === 'number') {
|
|
833
883
|
metaFields.estimated_test_files = body.estimated_test_files;
|
|
834
884
|
}
|
|
885
|
+
if (typeof body.mandrel_version === 'string' && body.mandrel_version.trim()) {
|
|
886
|
+
metaFields.mandrel_version = body.mandrel_version.trim();
|
|
887
|
+
}
|
|
888
|
+
if (typeof body.authored_at === 'string' && body.authored_at.trim()) {
|
|
889
|
+
metaFields.authored_at = body.authored_at.trim();
|
|
890
|
+
}
|
|
835
891
|
if (Object.keys(metaFields).length === 0) return '';
|
|
836
892
|
return `\n\n<!-- meta: ${JSON.stringify(metaFields)} -->`;
|
|
837
893
|
}
|
|
838
894
|
|
|
895
|
+
/**
|
|
896
|
+
* Build the visible `> 🏷️ Authored with Mandrel v<version> · <date>` marker
|
|
897
|
+
* line when the body carries a complete provenance stamp
|
|
898
|
+
* (`mandrel_version` + `authored_at`). Emitted just above the meta block so it
|
|
899
|
+
* round-trips with the hidden field. Returns the empty string when either
|
|
900
|
+
* field is absent, so every pre-existing (stamp-less) body serialises
|
|
901
|
+
* byte-identically to before.
|
|
902
|
+
*
|
|
903
|
+
* @param {StoryBody} body
|
|
904
|
+
* @returns {string}
|
|
905
|
+
*/
|
|
906
|
+
function serializeAuthoredMarker(body) {
|
|
907
|
+
const version =
|
|
908
|
+
typeof body.mandrel_version === 'string' ? body.mandrel_version.trim() : '';
|
|
909
|
+
const authoredAt =
|
|
910
|
+
typeof body.authored_at === 'string' ? body.authored_at.trim() : '';
|
|
911
|
+
if (!version || !authoredAt) return '';
|
|
912
|
+
return `\n\n${authoredMarkerLine({ version, authoredAt })}`;
|
|
913
|
+
}
|
|
914
|
+
|
|
839
915
|
/**
|
|
840
916
|
* Build the optional `---` footer block (`parent` / `Epic` / `blocked by`
|
|
841
917
|
* lines). Returns the empty string when `opts.includeFooter` is falsy.
|
|
@@ -888,6 +964,7 @@ export function serialize(body, opts = {}) {
|
|
|
888
964
|
|
|
889
965
|
return (
|
|
890
966
|
sections.join('\n\n') +
|
|
967
|
+
serializeAuthoredMarker(body) +
|
|
891
968
|
serializeMetaBlock(body) +
|
|
892
969
|
serializeFooter(body, opts)
|
|
893
970
|
);
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { parseBlockedBy, parseBlocks } from '../../lib/dependency-parser.js';
|
|
25
|
+
import { stampFrameworkVersion } from '../../lib/framework-version.js';
|
|
25
26
|
import { Logger } from '../../lib/Logger.js';
|
|
26
27
|
import { TYPE_LABELS } from '../../lib/label-constants.js';
|
|
27
28
|
import { addIssueToBoard } from './board-add.js';
|
|
@@ -56,11 +57,20 @@ const SEARCH_PAGE_CAP = 10;
|
|
|
56
57
|
* arrays on the Story body authored by the decomposer; there is no
|
|
57
58
|
* server-side rendering of a four-section payload at create time.
|
|
58
59
|
*
|
|
60
|
+
* Story #4382 — this is also where a Story body is stamped, once, with the
|
|
61
|
+
* running Mandrel framework version and authoring date (hidden `mandrel_version`
|
|
62
|
+
* / `authored_at` meta field + a visible `> 🏷️ Authored with Mandrel …`
|
|
63
|
+
* marker) via {@link stampFrameworkVersion}. The stamp is immutable: a body
|
|
64
|
+
* that already carries a version (e.g. a reconciler re-create) is preserved
|
|
65
|
+
* verbatim. The `stamp` override exists for deterministic tests; production
|
|
66
|
+
* callers omit it so the running version and today's date are used.
|
|
67
|
+
*
|
|
59
68
|
* @param {{
|
|
60
69
|
* body: string,
|
|
61
70
|
* parentId: number,
|
|
62
71
|
* epicId?: number,
|
|
63
72
|
* dependencies?: number[],
|
|
73
|
+
* stamp?: { version?: string, authoredAt?: string } | false,
|
|
64
74
|
* }} opts
|
|
65
75
|
* @returns {string}
|
|
66
76
|
*
|
|
@@ -75,8 +85,15 @@ export function composeStoryBody({
|
|
|
75
85
|
parentId,
|
|
76
86
|
epicId,
|
|
77
87
|
dependencies = [],
|
|
88
|
+
stamp,
|
|
78
89
|
}) {
|
|
79
|
-
const
|
|
90
|
+
const rawHead = typeof body === 'string' ? body : '';
|
|
91
|
+
// `stamp === false` → footer-only recomposition: the caller (the reconciler
|
|
92
|
+
// UPDATE/diff path) owns stamp preservation itself and must NOT introduce a
|
|
93
|
+
// fresh authoring stamp, which would churn or bump the version on every
|
|
94
|
+
// reconcile. Every other call is a create — stamp once (immutably).
|
|
95
|
+
const head =
|
|
96
|
+
stamp === false ? rawHead : stampFrameworkVersion(rawHead, stamp ?? {});
|
|
80
97
|
const lines = ['---', `parent: #${parentId}`];
|
|
81
98
|
if (epicId !== undefined && epicId !== null) {
|
|
82
99
|
lines.push(`Epic: #${epicId}`);
|
|
@@ -32,6 +32,11 @@ allowed_tools:
|
|
|
32
32
|
Senior Project Manager + Orchestrator, acting as a **holistic critic** with
|
|
33
33
|
fresh context — deliberately *separate* from `epic-plan-decompose-author` (the
|
|
34
34
|
generator) so the pass is a fresh-context review, not a same-pass self-critique.
|
|
35
|
+
The `/plan` workflow delivers that fresh context by **dispatching this skill
|
|
36
|
+
inside a genuine sub-agent** (`Agent` tool, `subagent_type: general-purpose`) at
|
|
37
|
+
Phase 8.3, rather than activating it inline in the authoring turn — the
|
|
38
|
+
sub-agent does not inherit the conversation that authored the draft, so the
|
|
39
|
+
critic cannot grade its own homework.
|
|
35
40
|
|
|
36
41
|
> **Read [`examples.md`](./examples.md) on demand** for the extended rationale:
|
|
37
42
|
> why this critic runs with fresh context, why scope conservation is your
|
|
@@ -50,8 +55,8 @@ emit a plan the validator would reject.
|
|
|
50
55
|
|
|
51
56
|
## Inputs
|
|
52
57
|
|
|
53
|
-
The
|
|
54
|
-
reads:
|
|
58
|
+
The `/plan` workflow dispatches this skill inside a fresh-context sub-agent,
|
|
59
|
+
passing the Epic ID as the Skill argument. The Skill itself reads:
|
|
55
60
|
|
|
56
61
|
- `temp/epic-<Epic_ID>/tickets.json` — the **draft** Story array the
|
|
57
62
|
`epic-plan-decompose-author` Skill wrote. This is the consolidation input.
|