mandrel 2.24.0 → 2.25.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.
Files changed (88) hide show
  1. package/.agents/agents/acceptance-critic.md +13 -19
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +2 -1
  7. package/.agents/docs/configuration.md +2 -1
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/rules/ci-remediation.md +68 -3
  10. package/.agents/schemas/agentrc.schema.json +6 -1
  11. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  12. package/.agents/schemas/baselines/crap.schema.json +5 -1
  13. package/.agents/schemas/crap-report.schema.json +37 -0
  14. package/.agents/scripts/acceptance-eval.js +35 -9
  15. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  16. package/.agents/scripts/audit-to-stories.js +33 -3
  17. package/.agents/scripts/check-context-budget.js +95 -14
  18. package/.agents/scripts/check-test-temp-hygiene.js +83 -0
  19. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  20. package/.agents/scripts/deliver-light.js +3 -4
  21. package/.agents/scripts/deliver-recover.js +13 -0
  22. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  23. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  24. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  25. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  26. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  27. package/.agents/scripts/lib/baseline-loader.js +0 -0
  28. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  29. package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
  30. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  31. package/.agents/scripts/lib/baselines/reader.js +10 -0
  32. package/.agents/scripts/lib/baselines/writer.js +7 -3
  33. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  34. package/.agents/scripts/lib/crap-engine.js +97 -19
  35. package/.agents/scripts/lib/crap-utils.js +73 -13
  36. package/.agents/scripts/lib/env-loader.js +46 -16
  37. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  38. package/.agents/scripts/lib/findings/severity.js +80 -2
  39. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  40. package/.agents/scripts/lib/label-constants.js +17 -0
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  43. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  44. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  45. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  46. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  47. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  48. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
  49. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
  50. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  51. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  52. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  53. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  55. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +50 -13
  56. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  57. package/.agents/scripts/lib/story-plan.js +137 -42
  58. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  59. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  60. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  61. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  62. package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
  63. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
  64. package/.agents/scripts/lib/workers/crap-worker.js +6 -3
  65. package/.agents/scripts/pr-watch-with-update.js +568 -213
  66. package/.agents/scripts/quality-preview.js +25 -0
  67. package/.agents/scripts/run-tests.js +10 -0
  68. package/.agents/scripts/single-story-close.js +6 -0
  69. package/.agents/scripts/single-story-init.js +7 -0
  70. package/.agents/scripts/stories-wave-tick.js +95 -10
  71. package/.agents/scripts/story-plan.js +65 -9
  72. package/.agents/scripts/update-crap-baseline.js +42 -4
  73. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  74. package/.agents/templates/single-story-body.md +16 -8
  75. package/.agents/workflows/audit-architecture.md +69 -13
  76. package/.agents/workflows/audit-quality.md +26 -0
  77. package/.agents/workflows/audit-to-stories.md +30 -4
  78. package/.agents/workflows/deliver.md +81 -95
  79. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  80. package/.agents/workflows/helpers/deliver-reference.md +18 -3
  81. package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
  82. package/.agents/workflows/helpers/deliver-story.md +104 -119
  83. package/.agents/workflows/helpers/plan-reference.md +34 -0
  84. package/.agents/workflows/plan.md +60 -76
  85. package/docs/CHANGELOG.md +29 -0
  86. package/lib/cli/update.js +14 -11
  87. package/lib/cli/version-check.js +9 -1
  88. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
1
  /**
2
- * phases/pull-request.js — open or reuse the PR for a standalone Story.
2
+ * phases/pull-request.js — open, reuse, or decline to open the PR for a
3
+ * standalone Story.
3
4
  *
4
- * Probes for an existing open PR with `head = storyBranch`; creates one if
5
- * none exists. Returns the PR URL.
5
+ * Probes for an existing PR with `head = storyBranch`; creates one if none
6
+ * exists. Returns `{ url, alreadyMerged, created }`.
6
7
  *
7
8
  * `gh pr view --head` is not available on all `gh` versions, so we probe
8
9
  * with `gh pr list --head <branch>` and fall back to `gh pr create`.
@@ -13,17 +14,99 @@
13
14
  * error classification, timeout handling, and JSON parsing surface
14
15
  * instead of carrying its own `execFileSync('gh', …)` plumbing.
15
16
  *
17
+ * ## An already-merged PR is not an absent PR (Story #4873)
18
+ *
19
+ * The probe used to ask for `--state open` only. On the recovery path that is
20
+ * a false negative with teeth: a resumed close pushes the branch, the push
21
+ * turns the checks green, armed auto-merge lands the ORIGINAL PR server-side,
22
+ * and the resumed close then sees no OPEN PR on the head — so it opened a
23
+ * SECOND PR against a branch that is now byte-identical to base and
24
+ * squash-merged a zero-file commit onto `main`. Both halves of that are now
25
+ * closed:
26
+ *
27
+ * 1. The probe reads `--state all` and reports a MERGED PR on this head as
28
+ * the outcome (`alreadyMerged: true`), so the caller lands on the merge
29
+ * that already happened instead of manufacturing a new one. An OPEN PR
30
+ * still wins over a merged one — a re-opened head is a live PR.
31
+ * 2. Creation is REFUSED outright when the head-versus-base diff contains no
32
+ * files. An empty diff means there is nothing to merge, so a PR opened on
33
+ * it can only ever produce an empty commit.
34
+ *
35
+ * The empty-diff guard fails **open**, not closed: `computeChangeSet` returns
36
+ * `files: null` when it cannot enumerate the diff at all, and absence of
37
+ * evidence must never block a legitimate PR — only a positively-observed empty
38
+ * file list refuses.
39
+ *
16
40
  * The function still accepts an injected `gh` facade so tests can wire
17
41
  * a fake without spawning real children.
18
42
  */
19
43
 
20
44
  import { gh as defaultGh } from '../../../gh-exec.js';
21
45
  import { Logger } from '../../../Logger.js';
46
+ import { computeChangeSet as defaultComputeChangeSet } from '../../change-set.js';
22
47
  import { normalizePrTitle } from './normalize-pr-title.js';
23
48
 
24
49
  /**
25
- * Probe for an existing open PR with `head = storyBranch`; create one if
26
- * none exists. Returns the PR URL. Exported for testing.
50
+ * Pick the PR this head branch should resolve to from a `gh pr list
51
+ * --state all` projection. A live PR always wins; otherwise the first MERGED
52
+ * PR is the outcome to report. A head whose only PRs were CLOSED without
53
+ * merging resolves to nothing — there is a new PR to open.
54
+ *
55
+ * A row carrying a url but no recognizable `state` reads as live, not as
56
+ * nothing: the projection this phase asks for always includes `state`, so an
57
+ * absent one means an older/other `gh`, and the old `--state open` probe
58
+ * treated every returned row as a reusable open PR. Guessing "no PR" there is
59
+ * the failure mode with teeth — it opens a duplicate.
60
+ *
61
+ * Pure, and module-private on purpose: `ensurePullRequestWith` is the only
62
+ * caller and the only surface worth pinning, so the precedence is asserted
63
+ * through it rather than through a test-only export the production
64
+ * dead-export ratchet would then flag.
65
+ *
66
+ * @param {Array<{url?: string, state?: string, mergedAt?: string}>} rows
67
+ * @returns {{ url: string, state: 'OPEN'|'MERGED' }|null}
68
+ */
69
+ function pickHeadPullRequest(rows) {
70
+ if (!Array.isArray(rows)) return null;
71
+ let merged = null;
72
+ for (const row of rows) {
73
+ const url = String(row?.url ?? '').trim();
74
+ if (!url) continue;
75
+ const state = String(row?.state ?? '').toUpperCase();
76
+ if (state === 'MERGED' || (state !== 'OPEN' && row?.mergedAt)) {
77
+ merged ??= { url, state: 'MERGED' };
78
+ continue;
79
+ }
80
+ if (state === 'CLOSED') continue;
81
+ return { url, state: 'OPEN' };
82
+ }
83
+ return merged;
84
+ }
85
+
86
+ /**
87
+ * Enumerate the head-versus-base diff and report whether it is positively
88
+ * empty. `null` (diff unenumerable) is NOT empty — see the module header.
89
+ *
90
+ * The diff is taken against `origin/<baseBranch>` when that ref resolves,
91
+ * because the local base ref can trail the remote by exactly the merge that
92
+ * makes this diff empty — the very state the guard exists to catch. It falls
93
+ * back to the local ref, and finally to "unknown", when the remote ref cannot
94
+ * be enumerated.
95
+ *
96
+ * @returns {{ empty: boolean, baseRef: string|null }}
97
+ */
98
+ function probeEmptyDiff({ cwd, baseBranch, storyBranch, computeChangeSet }) {
99
+ for (const baseRef of [`origin/${baseBranch}`, baseBranch]) {
100
+ const set = computeChangeSet({ baseRef, headRef: storyBranch, cwd });
101
+ if (!set.enumerated) continue;
102
+ return { empty: (set.files ?? []).length === 0, baseRef };
103
+ }
104
+ return { empty: false, baseRef: null };
105
+ }
106
+
107
+ /**
108
+ * Probe for an existing PR with `head = storyBranch`; create one if none
109
+ * exists. Exported for testing.
27
110
  *
28
111
  * @param {{
29
112
  * cwd: string,
@@ -32,9 +115,10 @@ import { normalizePrTitle } from './normalize-pr-title.js';
32
115
  * storyBranch: string,
33
116
  * baseBranch: string,
34
117
  * gh?: ReturnType<typeof import('../../../gh-exec.js').createGh>,
118
+ * computeChangeSetFn?: typeof defaultComputeChangeSet,
35
119
  * progress?: (tag: string, msg: string) => void,
36
120
  * }} args
37
- * @returns {Promise<string>}
121
+ * @returns {Promise<{ url: string, alreadyMerged: boolean, created: boolean }>}
38
122
  */
39
123
  export async function ensurePullRequestWith({
40
124
  cwd: _cwd,
@@ -43,6 +127,7 @@ export async function ensurePullRequestWith({
43
127
  storyBranch,
44
128
  baseBranch,
45
129
  gh = defaultGh,
130
+ computeChangeSetFn = defaultComputeChangeSet,
46
131
  progress = () => {},
47
132
  }) {
48
133
  // `cwd` is preserved on the call signature for backwards compatibility
@@ -51,22 +136,26 @@ export async function ensurePullRequestWith({
51
136
  // before invoking the phase, so the effective cwd matches the legacy
52
137
  // `execFileSync('gh', …, { cwd })` shape.
53
138
  try {
54
- // `gh pr list --head <branch> --state open --json url` returns a
55
- // JSON array of `{ url }` rows; an empty array means no open PR on
56
- // this head. We pick the first entry's URL (matching the legacy
57
- // `-q '.[0].url // empty'` projection on our side) so the typed
58
- // `gh.pr.list` facade can stay generic.
139
+ // `gh pr list --head <branch> --state all --json url,state,mergedAt`
140
+ // returns a JSON array of rows; an empty array means this head has never
141
+ // had a PR. `--state all` (not `open`) is load-bearing see the module
142
+ // header's duplicate-empty-PR note.
59
143
  const rows = await gh.pr.list(
60
- ['--head', storyBranch, '--state', 'open'],
61
- ['url'],
144
+ ['--head', storyBranch, '--state', 'all'],
145
+ ['url', 'state', 'mergedAt'],
62
146
  );
63
- const existing =
64
- Array.isArray(rows) && rows.length > 0
65
- ? String(rows[0]?.url ?? '').trim()
66
- : '';
67
- if (existing) {
68
- progress('PR', `Reusing existing PR: ${existing}`);
69
- return existing;
147
+ const existing = pickHeadPullRequest(rows);
148
+ if (existing?.state === 'OPEN') {
149
+ progress('PR', `Reusing existing PR: ${existing.url}`);
150
+ return { url: existing.url, alreadyMerged: false, created: false };
151
+ }
152
+ if (existing?.state === 'MERGED') {
153
+ progress(
154
+ 'PR',
155
+ `✅ PR for ${storyBranch} is already MERGED: ${existing.url} — ` +
156
+ 'reporting that outcome instead of opening a second PR.',
157
+ );
158
+ return { url: existing.url, alreadyMerged: true, created: false };
70
159
  }
71
160
  } catch (err) {
72
161
  // `gh pr list` failure is recoverable — fall through to create. Log
@@ -76,6 +165,25 @@ export async function ensurePullRequestWith({
76
165
  );
77
166
  }
78
167
 
168
+ // Nothing to merge → nothing to open. Refused before `gh pr create` so the
169
+ // failure names the empty diff rather than surfacing later as a zero-file
170
+ // squash commit on the base branch.
171
+ const emptyDiff = probeEmptyDiff({
172
+ cwd: _cwd ?? process.cwd(),
173
+ baseBranch,
174
+ storyBranch,
175
+ computeChangeSet: computeChangeSetFn,
176
+ });
177
+ if (emptyDiff.empty) {
178
+ throw new Error(
179
+ `[single-story-close] refusing to open a pull request for ${storyBranch}: ` +
180
+ `the head-versus-base diff (${emptyDiff.baseRef}...${storyBranch}) contains no files. ` +
181
+ 'An empty diff has nothing to merge, and a PR opened on it can only squash an empty ' +
182
+ 'commit onto the base branch. If the work already landed, confirm the merge instead: ' +
183
+ `node .agents/scripts/single-story-confirm-merge.js --story ${storyId}`,
184
+ );
185
+ }
186
+
79
187
  progress('PR', `Opening PR for ${storyBranch} → ${baseBranch}...`);
80
188
  // The repo squash-merges and GitHub uses the PR title as the squash
81
189
  // subject on `main`. A raw human issue title is not a Conventional
@@ -110,7 +218,7 @@ export async function ensurePullRequestWith({
110
218
  ]);
111
219
  const url = (createResult?.stdout ?? '').trim();
112
220
  progress('PR', `✅ Opened: ${url}`);
113
- return url;
221
+ return { url, alreadyMerged: false, created: true };
114
222
  } catch (err) {
115
223
  throw new Error(
116
224
  `[single-story-close] \`gh pr create\` failed: ${err?.message ?? err}`,
@@ -12,6 +12,7 @@ import { createProvider } from '../../provider-factory.js';
12
12
  import { flipLabelAndNotify } from '../../single-story/story-merged-notify.js';
13
13
  import { WorktreeManager } from '../../worktree-manager.js';
14
14
  import { runCodeReview as runCodeReviewDefault } from '../code-review.js';
15
+ import { resolveRunScopedConfig } from '../run-scoped-config.js';
15
16
  import { releaseStoryLease } from '../single-story-lease-guard.js';
16
17
  import {
17
18
  buildTerminalEnvelope,
@@ -144,6 +145,7 @@ async function runPrePushPhases({
144
145
  worktreePath,
145
146
  config,
146
147
  baseBranch,
148
+ baseConfirmed,
147
149
  storyBranch,
148
150
  storyId,
149
151
  provider,
@@ -185,6 +187,7 @@ async function runPrePushPhases({
185
187
  cwd,
186
188
  worktreePath,
187
189
  baseBranch,
190
+ baseConfirmed,
188
191
  storyBranch,
189
192
  storyId,
190
193
  provider,
@@ -210,7 +213,7 @@ async function openAndReviewPr({
210
213
  setPhase('push');
211
214
  pushStoryBranch({ cwd, storyBranch, gitSync, progress });
212
215
  setPhase('pull-request');
213
- const prUrl = await ensurePullRequestWith({
216
+ const { url: prUrl, alreadyMerged } = await ensurePullRequestWith({
214
217
  cwd,
215
218
  storyId,
216
219
  storyTitle: story.title,
@@ -220,6 +223,13 @@ async function openAndReviewPr({
220
223
  progress,
221
224
  });
222
225
  const prNumber = parsePrNumber(prUrl);
226
+ // Story #4873 — the head's PR already merged (an armed PR that landed while
227
+ // a previous close invocation was between phases). There is nothing left to
228
+ // review and nothing left to arm; the confirm phase probes the PR, observes
229
+ // MERGED, and lands the Story on the merge that actually happened.
230
+ if (alreadyMerged) {
231
+ return { prUrl, prNumber, alreadyMerged: true };
232
+ }
223
233
  setPhase('code-review');
224
234
  const reviewOutcome = await runStoryScopeReview({
225
235
  cwd,
@@ -245,7 +255,7 @@ async function openAndReviewPr({
245
255
  'Auto-merge was not enabled. Remediate the findings posted to the PR and re-run `/deliver`.',
246
256
  );
247
257
  }
248
- return { prUrl, prNumber };
258
+ return { prUrl, prNumber, alreadyMerged: false };
249
259
  }
250
260
 
251
261
  async function releaseLease({
@@ -443,7 +453,6 @@ async function runClosePipeline({
443
453
  const startedAtMs = Date.now();
444
454
  const config = injectedConfig || resolveConfig({ cwd: options.cwd });
445
455
  const provider = injectedProvider || createProvider(config);
446
- const baseBranch = config.project?.baseBranch ?? 'main';
447
456
  const storyBranch = getStoryBranch(options.storyId);
448
457
 
449
458
  progress('INIT', `Closing standalone Story #${options.storyId}...`);
@@ -462,6 +471,22 @@ async function runClosePipeline({
462
471
  );
463
472
  }
464
473
 
474
+ // Story #4891 — the base branch this run was SEEDED from, read back off the
475
+ // run's `story-init` receipt rather than re-resolved from a config file that
476
+ // may have changed during the whole implementation window. Throws (fail
477
+ // closed, naming both values) when the pin and current config disagree —
478
+ // deliberately here, before the gate chain, format-autofix and base-sync,
479
+ // so a wrong base is never merged into the Story branch. `baseConfirmed`
480
+ // gates the base-merge remediation advice further down.
481
+ const { values: runScoped, confirmed: baseConfirmed } =
482
+ await resolveRunScopedConfig({
483
+ provider,
484
+ storyId: options.storyId,
485
+ config,
486
+ progress,
487
+ });
488
+ const baseBranch = runScoped.baseBranch;
489
+
465
490
  const worktreePath = resolveWorktreePath({
466
491
  cwd: options.cwd,
467
492
  config,
@@ -483,6 +508,7 @@ async function runClosePipeline({
483
508
  ...options,
484
509
  config,
485
510
  baseBranch,
511
+ baseConfirmed,
486
512
  storyBranch,
487
513
  provider,
488
514
  worktreePath,
@@ -493,7 +519,7 @@ async function runClosePipeline({
493
519
  leaseArgs,
494
520
  );
495
521
 
496
- const { prUrl, prNumber } = await releaseLeaseOnBlock(
522
+ const { prUrl, prNumber, alreadyMerged } = await releaseLeaseOnBlock(
497
523
  () =>
498
524
  openAndReviewPr({
499
525
  cwd: options.cwd,
@@ -527,20 +553,31 @@ async function runClosePipeline({
527
553
  WorktreeManager,
528
554
  });
529
555
  setPhase('auto-merge');
556
+ // An already-merged head PR (Story #4873) has nothing to arm — `gh pr merge`
557
+ // against it fails, which would report the arm as a fault and block a Story
558
+ // whose work is already on the base branch. Skip the arm and let the confirm
559
+ // phase observe the merge that happened.
530
560
  const {
531
561
  autoMergeEnabled,
532
562
  autoMergeReason,
533
563
  localCleanupDeferred,
534
564
  directMerged,
535
- } = await runAutoMergePhase({
536
- cwd: options.cwd,
537
- prNumber,
538
- prUrl,
539
- noAutoMerge: options.noAutoMerge,
540
- autoMergePolicy: getCiDelivery(config).autoMerge,
541
- gh: injectedGh,
542
- progress,
543
- });
565
+ } = alreadyMerged
566
+ ? {
567
+ autoMergeEnabled: true,
568
+ autoMergeReason: null,
569
+ localCleanupDeferred: false,
570
+ directMerged: false,
571
+ }
572
+ : await runAutoMergePhase({
573
+ cwd: options.cwd,
574
+ prNumber,
575
+ prUrl,
576
+ noAutoMerge: options.noAutoMerge,
577
+ autoMergePolicy: getCiDelivery(config).autoMerge,
578
+ gh: injectedGh,
579
+ progress,
580
+ });
544
581
  await flipLabelAndNotify({
545
582
  provider,
546
583
  notifyFn: injectedNotify,
@@ -0,0 +1,77 @@
1
+ /**
2
+ * lib/reserved-test-ids.js — the framework's one declaration of which
3
+ * Epic / Story ids are **synthetic** (Story #4892).
4
+ *
5
+ * The band `999000–999999` has been reserved for test fixtures since the
6
+ * post-test temp reaper shipped, but it lived as a private regexp inside
7
+ * `cleanup-repo-test-temp.js` (`/^epic-999\d{3}$/`) and nothing else could
8
+ * consult it. Two independent surfaces need the same rule, and spelling it
9
+ * twice is how they would drift:
10
+ *
11
+ * 1. **Write side.** A test that spawns a real CLI at the repository root
12
+ * inherits the real state directory, so its fixture telemetry can land
13
+ * in the operator's live `temp/` signals tree. The post-run guard in
14
+ * `check-test-temp-hygiene.js` fails the run when a stream file owned by
15
+ * a reserved id survives there.
16
+ * 2. **Read side.** The retro composer renders the distinct Stories a
17
+ * friction bucket spans as recurrence evidence in a body that is filed
18
+ * as a real GitHub issue. A reserved id is synthetic by construction, so
19
+ * it can never be resolved to a real issue and must never be published
20
+ * as evidence (issue #4870 named `#999999` as a contributing Story).
21
+ *
22
+ * Deliberately dependency-free: the write side is a CLI guard and the read
23
+ * side is a pure composer, so the shared rule must not drag config, fs, or
24
+ * git resolution into either.
25
+ */
26
+
27
+ /** First id in the reserved test-fixture band. */
28
+ const RESERVED_TEST_ID_MIN = 999000;
29
+
30
+ /** Last id in the reserved test-fixture band. */
31
+ const RESERVED_TEST_ID_MAX = 999999;
32
+
33
+ /**
34
+ * Human-readable band, for guard failure messages that have to tell an
35
+ * operator which ids they may not use for real work.
36
+ */
37
+ export const RESERVED_TEST_ID_BAND = `${RESERVED_TEST_ID_MIN}–${RESERVED_TEST_ID_MAX}`;
38
+
39
+ /**
40
+ * Is `id` inside the reserved test-fixture band?
41
+ *
42
+ * Exact-band membership, because this is the predicate the temp reaper and
43
+ * the pollution guard share: both classify an on-disk directory that a test
44
+ * created, and over-reaching (treating every large id as reserved) would let
45
+ * one of them delete or condemn a directory the band never claimed.
46
+ *
47
+ * @param {unknown} id
48
+ * @returns {boolean}
49
+ */
50
+ export function isReservedTestId(id) {
51
+ return (
52
+ Number.isInteger(id) &&
53
+ id >= RESERVED_TEST_ID_MIN &&
54
+ id <= RESERVED_TEST_ID_MAX
55
+ );
56
+ }
57
+
58
+ /**
59
+ * May `id` be published as ticket evidence in operator-visible output?
60
+ *
61
+ * The floor is the reserved band, not the band itself: an id at or above
62
+ * `RESERVED_TEST_ID_MIN` is synthetic by construction (the framework reserves
63
+ * that space for fixtures, and no repository has minted that many issues), so
64
+ * nothing at or beyond the floor can be resolved to a real issue. A
65
+ * non-integer or non-positive id is not a ticket reference at all.
66
+ *
67
+ * This is a plausibility bound, not an existence probe: it is the strongest
68
+ * statement a caller with no ticket-provider access can make, and it is what
69
+ * keeps the invariant true at every call site instead of only where somebody
70
+ * remembered to wire a probe.
71
+ *
72
+ * @param {unknown} id
73
+ * @returns {boolean}
74
+ */
75
+ export function isPublishableTicketId(id) {
76
+ return Number.isInteger(id) && id > 0 && id < RESERVED_TEST_ID_MIN;
77
+ }
@@ -9,16 +9,24 @@
9
9
  * - DEFAULT_REFINE_THRESHOLD — seed length below which refinement
10
10
  * is auto-suggested.
11
11
  * - REQUIRED_SECTIONS — canonical section headings the body
12
- * must carry to be accepted by
13
- * /single-story-deliver.
12
+ * must carry itself. Story #4874: the
13
+ * `## Acceptance` / `## Verify` sections
14
+ * are deliberately NOT among them —
15
+ * those lists are the ticket's top-level
16
+ * machine contract and persist
17
+ * synthesizes their sections, exactly as
18
+ * the story-author prompt instructs.
14
19
  * - rankDuplicateCandidates({ seed, openStories, maxResults })
15
20
  * — Jaccard-overlap ranking of open
16
21
  * Stories whose titles fuzzy-match
17
22
  * the seed.
18
- * - validateStoryBody(body) — schema-light shape check:
19
- * required sections present, no
20
- * `Epic:` reference, AC checklist
21
- * non-empty.
23
+ * - validateStoryBody(body, contract)
24
+ * schema-light shape check against the
25
+ * canonical Story-body contract: `##
26
+ * Goal` / `## Changes` present, no
27
+ * `Epic:` reference, and an
28
+ * acceptance/verify contract resolvable
29
+ * from the top-level arrays or the body.
22
30
  * - buildContextEnvelope(opts) — assemble the context envelope the
23
31
  * host LLM consumes.
24
32
  */
@@ -26,21 +34,25 @@
26
34
  import { readFile } from 'node:fs/promises';
27
35
  import path from 'node:path';
28
36
  import { overlapScore, tokenize } from './duplicate-search.js';
37
+ import { parse as parseStoryBody } from './story-body/story-body.js';
29
38
 
30
39
  export const DEFAULT_REFINE_THRESHOLD = 200;
31
40
  export const DEFAULT_DUPLICATE_MAX_RESULTS = 5;
32
41
  export const DEFAULT_DUPLICATE_MIN_SCORE = 0.15;
33
42
 
34
- export const REQUIRED_SECTIONS = [
35
- 'Context',
36
- 'Acceptance Criteria',
37
- 'Out of Scope',
38
- 'Notes',
39
- ];
43
+ /**
44
+ * The sections the authored body must carry itself. Story #4874 reconciled
45
+ * this list with the story-author prompt: the prompt tells the author to
46
+ * write `acceptance[]` / `verify[]` **once** at the ticket's top level and
47
+ * omit the matching body sections, so demanding those sections here made a
48
+ * prompt-faithful body unpersistable and cost a re-author round.
49
+ */
50
+ export const REQUIRED_SECTIONS = ['Goal', 'Changes'];
51
+
52
+ /** The contract lists persist synthesizes into the body from the top level. */
53
+ const CONTRACT_FIELDS = /** @type {const} */ (['acceptance', 'verify']);
40
54
 
41
55
  const EPIC_REF_PATTERN = /^\s*Epic:\s*#\d+/m;
42
- const AC_HEADING_PATTERN = /^##\s+Acceptance Criteria\s*$/m;
43
- const CHECKLIST_PATTERN = /^\s*-\s*\[\s?\]/m;
44
56
 
45
57
  /**
46
58
  * Rank open Stories by title-overlap with the seed. Reuses the same
@@ -115,54 +127,137 @@ export function shouldRefine({
115
127
  }
116
128
 
117
129
  /**
118
- * Schema-light validator for a standalone-Story body. Used by the persist
119
- * path *and* by tests asserting `--dry-run` output stability. The check
120
- * is deliberately tolerant of authoring whitespace: it asserts the
121
- * canonical headings are present, that no `Epic: #N` reference leaks
122
- * (the standalone contract), and that the Acceptance Criteria section
123
- * carries at least one unchecked checklist item.
130
+ * Collect the violations for one contract list (`acceptance` / `verify`).
131
+ *
132
+ * The list may be authored at the ticket's top level (the shape the
133
+ * story-author prompt prescribes) or written into the body as a section —
134
+ * either satisfies the contract, and the top level is preferred because
135
+ * persist synthesizes the section from it. Only two shapes are violations:
136
+ * the list is nowhere, or it is in both places and the two disagree (fail
137
+ * closed rather than guess which is authoritative, mirroring
138
+ * `plan-persist`'s `syncContractFieldFromTopLevel`).
139
+ *
140
+ * @param {'acceptance'|'verify'} field
141
+ * @param {unknown} bodyList The parsed body's section entries.
142
+ * @param {unknown} topLevel The ticket's top-level array.
143
+ * @returns {string[]}
144
+ */
145
+ function collectContractErrors(field, bodyList, topLevel) {
146
+ const inBody = (Array.isArray(bodyList) ? bodyList : []).map(String);
147
+ const inTicket = (Array.isArray(topLevel) ? topLevel : []).map(String);
148
+ if (inBody.length === 0 && inTicket.length === 0) {
149
+ return [
150
+ `${field} must list at least one entry — author it as the ticket's top-level ${field}[] array; persist synthesizes the "## ${field[0].toUpperCase()}${field.slice(1)}" section from it`,
151
+ ];
152
+ }
153
+ if (
154
+ inBody.length > 0 &&
155
+ inTicket.length > 0 &&
156
+ (inBody.length !== inTicket.length ||
157
+ inBody.some((v, i) => v !== inTicket[i]))
158
+ ) {
159
+ return [
160
+ `${field} disagrees between the body section and the top-level ${field}[] array — author it once, at the top level`,
161
+ ];
162
+ }
163
+ return [];
164
+ }
165
+
166
+ /**
167
+ * Schema-light validator for a standalone-Story body, stated against the
168
+ * same authoring shape the story-author prompt prescribes (Story #4874).
169
+ *
170
+ * It parses the body with the canonical Story-body parser and asserts:
171
+ * a non-empty `## Goal`, at least one `## Changes` entry, no leaking
172
+ * `Epic: #N` reference (the standalone contract), and an acceptance +
173
+ * verify contract resolvable from the ticket's top-level arrays **or** the
174
+ * body's own sections. It deliberately does not demand the `## Acceptance`
175
+ * / `## Verify` sections of the author — persist synthesizes them.
124
176
  *
125
177
  * @param {string} body
178
+ * @param {{ acceptance?: string[], verify?: string[] }} [contract]
179
+ * The ticket's top-level contract arrays, when the caller has them.
126
180
  * @returns {{ ok:boolean, errors:string[] }}
127
181
  */
128
- export function validateStoryBody(body) {
129
- const errors = [];
182
+ export function validateStoryBody(body, contract = {}) {
130
183
  if (typeof body !== 'string' || body.trim().length === 0) {
131
184
  return { ok: false, errors: ['body is empty'] };
132
185
  }
133
186
 
134
- for (const section of REQUIRED_SECTIONS) {
135
- const pattern = new RegExp(`^##\\s+${escapeRegex(section)}\\s*$`, 'm');
136
- if (!pattern.test(body)) {
137
- errors.push(`missing required section: "## ${section}"`);
138
- }
187
+ let parsed;
188
+ try {
189
+ parsed = parseStoryBody(body).body;
190
+ } catch (err) {
191
+ return {
192
+ ok: false,
193
+ errors: [`body is not a parseable Story body: ${err.message}`],
194
+ };
139
195
  }
140
196
 
197
+ const errors = [];
198
+ if (typeof parsed.goal !== 'string' || parsed.goal.trim() === '') {
199
+ errors.push('missing required section: "## Goal"');
200
+ }
201
+ if (!Array.isArray(parsed.changes) || parsed.changes.length === 0) {
202
+ errors.push('missing required section: "## Changes"');
203
+ }
141
204
  if (EPIC_REF_PATTERN.test(body)) {
142
205
  errors.push(
143
206
  'body contains an "Epic: #N" reference — standalone Stories must not link to an Epic',
144
207
  );
145
208
  }
146
-
147
- // AC checklist non-empty: extract from "## Acceptance Criteria" to the
148
- // next "## " heading (or EOF) and assert at least one checklist item.
149
- const acStart = body.search(AC_HEADING_PATTERN);
150
- if (acStart !== -1) {
151
- const rest = body.slice(acStart);
152
- const nextHeading = rest.slice(1).search(/^##\s+/m);
153
- const acBlock = nextHeading === -1 ? rest : rest.slice(0, nextHeading + 1);
154
- if (!CHECKLIST_PATTERN.test(acBlock)) {
155
- errors.push(
156
- 'Acceptance Criteria section has no unchecked checklist items (`- [ ] ...`)',
157
- );
158
- }
209
+ for (const field of CONTRACT_FIELDS) {
210
+ errors.push(
211
+ ...collectContractErrors(field, parsed[field], contract?.[field]),
212
+ );
159
213
  }
160
214
 
161
215
  return { ok: errors.length === 0, errors };
162
216
  }
163
217
 
164
- function escapeRegex(s) {
165
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
218
+ /**
219
+ * Render the canonical `## Acceptance` / `## Verify` sections into a body
220
+ * that omits them, from the ticket's top-level arrays (Story #4874).
221
+ *
222
+ * This is the synthesis the story-author prompt promises: the author writes
223
+ * each list once at top level, and the persisted GitHub issue still reads as
224
+ * a complete executable document. A body that already carries a section is
225
+ * returned untouched — `validateStoryBody` has already refused the case
226
+ * where the two disagree.
227
+ *
228
+ * @param {string} body
229
+ * @param {{ acceptance?: string[], verify?: string[] }} [contract]
230
+ * @returns {string}
231
+ */
232
+ export function synthesizeContractSections(body, contract = {}) {
233
+ let parsed;
234
+ try {
235
+ parsed = parseStoryBody(body).body;
236
+ } catch {
237
+ return body;
238
+ }
239
+ const blocks = [];
240
+ if (
241
+ (parsed.acceptance ?? []).length === 0 &&
242
+ Array.isArray(contract.acceptance) &&
243
+ contract.acceptance.length > 0
244
+ ) {
245
+ const items = contract.acceptance
246
+ .map((a, i) => `- [ ] AC-${i + 1}: ${a}`)
247
+ .join('\n');
248
+ blocks.push(`## Acceptance\n${items}`);
249
+ }
250
+ if (
251
+ (parsed.verify ?? []).length === 0 &&
252
+ Array.isArray(contract.verify) &&
253
+ contract.verify.length > 0
254
+ ) {
255
+ blocks.push(
256
+ `## Verify\n${contract.verify.map((v) => `- ${v}`).join('\n')}`,
257
+ );
258
+ }
259
+ if (blocks.length === 0) return body;
260
+ return `${body.trimEnd()}\n\n${blocks.join('\n\n')}\n`;
166
261
  }
167
262
 
168
263
  /**