mandrel 2.59.0 → 2.60.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 (97) hide show
  1. package/.agents/README.md +11 -9
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +6 -6
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +8 -4
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +4 -5
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  13. package/.agents/schemas/agentrc.schema.json +6 -11
  14. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  15. package/.agents/scripts/README.md +11 -1
  16. package/.agents/scripts/acceptance-eval.js +25 -27
  17. package/.agents/scripts/ceremony-derive.js +15 -10
  18. package/.agents/scripts/check-context-budget.js +148 -228
  19. package/.agents/scripts/check-schema-references.js +5 -3
  20. package/.agents/scripts/check-workflow-citations.js +33 -147
  21. package/.agents/scripts/coverage-capture.js +7 -4
  22. package/.agents/scripts/deliver-light.js +41 -100
  23. package/.agents/scripts/deliver-run.js +631 -0
  24. package/.agents/scripts/file-ci-gap.js +59 -11
  25. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  26. package/.agents/scripts/lib/changed-files.js +30 -0
  27. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  28. package/.agents/scripts/lib/config/explain.js +1 -3
  29. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  30. package/.agents/scripts/lib/config-resolver.js +1 -0
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  32. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  33. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  34. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  35. package/.agents/scripts/lib/doc-tiers.js +4 -2
  36. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  37. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  38. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  39. package/.agents/scripts/lib/gh-exec.js +160 -0
  40. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  41. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  43. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  44. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  45. package/.agents/scripts/lib/orchestration/plan-context.js +13 -25
  46. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  47. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +76 -95
  48. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +35 -18
  49. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  50. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  51. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  52. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  53. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  55. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  56. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  57. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  58. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  59. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  60. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  61. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  62. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  63. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  64. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  65. package/.agents/scripts/lib/templates/decomposer-prompts.js +7 -15
  66. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  67. package/.agents/scripts/merge-baseline.js +4 -5
  68. package/.agents/scripts/plan-context.js +117 -28
  69. package/.agents/scripts/plan-persist.js +79 -28
  70. package/.agents/scripts/plan-run-epilogue.js +11 -8
  71. package/.agents/scripts/pr-watch-with-update.js +9 -2
  72. package/.agents/scripts/run-verify.js +13 -6
  73. package/.agents/scripts/single-story-init.js +7 -57
  74. package/.agents/scripts/stories-wave-tick.js +160 -26
  75. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  76. package/.agents/skills/skills.index.json +2 -2
  77. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  78. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  79. package/.agents/workflows/helpers/code-review.md +4 -2
  80. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  81. package/.agents/workflows/helpers/deliver-light.md +92 -101
  82. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  83. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  84. package/.agents/workflows/helpers/deliver-story.md +17 -18
  85. package/.agents/workflows/helpers/plan-reference.md +65 -54
  86. package/.agents/workflows/mandrel-deliver.md +47 -31
  87. package/.agents/workflows/mandrel-plan.md +22 -21
  88. package/.agents/workflows/mandrel-update.md +36 -21
  89. package/docs/CHANGELOG.md +35 -0
  90. package/lib/cli/update.js +376 -17
  91. package/lib/migrations/index.js +2 -0
  92. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  93. package/package.json +2 -1
  94. package/.agents/schemas/model-attribution.schema.json +0 -53
  95. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  96. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  97. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
@@ -10,20 +10,20 @@
10
10
  * 3. Same-wave collision refusal (`assertNoWaveCollisions`) + spec fold
11
11
  * 4. Create Story issues (`type::story` + sanitized authored labels —
12
12
  * deliberately NOT `agent::ready`), resumably via a plan fingerprint
13
- * 5. Upsert `story-plan-state` on every created Story; upsert `plan-summary`
14
- * on the primary Story
13
+ * 5. Upsert the `story-plan-state` comment on every created Story — since
14
+ * Story #5343 the one comment persist posts, carrying the plan summary
15
15
  * 6. Flip every Story to `agent::ready` — the terminal step, so `ready`
16
- * always implies "checkpoints written"
16
+ * always implies "plan comments written"
17
17
  * 7. Comment on + close the superseded `--tickets` source issues
18
18
  * (Story #4535) — bookkeeping only; never fails the run
19
19
  * 8. Temp cleanup at terminal success + a stale-plan-dir reap
20
20
  *
21
21
  * **Why `agent::ready` moved to the end (Story #4541).** Issues used to be
22
- * born `agent::ready` in the creating POST while the checkpoints were
22
+ * born `agent::ready` in the creating POST while the plan comments were
23
23
  * written afterwards. Anything picking a Story up in that window — or after
24
- * a comment failure aborted the loop — read the checkpoint as `null`
25
- * (`story-plan-state.js` degrades missing/malformed to `null`). Creating
26
- * unlabelled, writing checkpoints, then flipping closes that race.
24
+ * a comment failure aborted the loop — found a ready Story carrying none of
25
+ * the operator's delivery instructions. Creating unlabelled, writing the
26
+ * comments, then flipping closes that race.
27
27
  *
28
28
  * **No authored risk artifact (Story #4542).** Persist neither requires nor
29
29
  * accepts a risk verdict, derives no envelope from one, and computes no
@@ -75,48 +75,48 @@ import {
75
75
  createStoryIssues,
76
76
  markStoriesReady,
77
77
  } from './story-ops.js';
78
- import {
79
- buildPlanSummaryCommentBody,
80
- buildWaveTable,
81
- PLAN_SUMMARY_COMMENT_TYPE,
82
- } from './summary.js';
78
+ import { buildPlanSummaryCommentBody, buildWaveTable } from './summary.js';
83
79
  import { closeSupersededTickets } from './supersede-ops.js';
84
80
  import { assertNoWaveCollisions } from './wave-collision-gate.js';
85
81
 
86
- /** Checkpoint schema version written on each Story's story-plan-state. */
87
- const PLAN_CHECKPOINT_SCHEMA_VERSION_V2 = 2;
88
-
89
- /** Structured-comment type for the per-plan Story checkpoint. */
82
+ /** Structured-comment type for the per-plan Story comment. */
90
83
  const STORY_PLAN_STATE_TYPE = 'story-plan-state';
91
84
 
92
85
  /**
93
- * Write the `story-plan-state` checkpoint on a Story.
86
+ * Write the plan comment on a Story — the **one** comment persist posts per
87
+ * Story (Story #5343).
88
+ *
89
+ * It carries the human plan summary: the created Story set, the delivery order
90
+ * and the exact deliver command. That summary used to be a second
91
+ * `plan-summary` comment on the primary Story only, which meant the operator's
92
+ * instructions lived on a different marker from the rest — and on a different
93
+ * ticket from four Stories out of five.
94
+ *
95
+ * **No machine payload (Story #5367).** The comment used to lead with a fenced
96
+ * JSON checkpoint (a persist receipt: completion time, Story count, the
97
+ * cohort). Nothing ever read it back — the one module that parsed it had no
98
+ * production importer — so the payload and its readers are gone. The
99
+ * `story-plan-state` marker stays: it is what makes a re-persist upsert this
100
+ * comment in place instead of appending a second one.
94
101
  *
95
102
  * @param {object} provider
96
103
  * @param {number} storyId
97
- * @param {object} state
104
+ * @param {string} summary Rendered plan-summary markdown. The comment exists
105
+ * to carry it, so an empty summary is a caller bug rather than a degenerate
106
+ * comment worth posting.
107
+ * @returns {Promise<void>}
98
108
  */
99
- export async function writeCheckpointV2(provider, storyId, state) {
109
+ export async function writePlanSummaryComment(provider, storyId, summary) {
100
110
  if (!Number.isInteger(storyId)) {
101
- throw new TypeError('writeCheckpointV2 requires a numeric storyId');
111
+ throw new TypeError('writePlanSummaryComment requires a numeric storyId');
112
+ }
113
+ const body = summary?.trim();
114
+ if (!body) {
115
+ throw new TypeError(
116
+ 'writePlanSummaryComment requires a non-empty plan summary',
117
+ );
102
118
  }
103
- const body = [
104
- '### story-plan-state',
105
- '',
106
- '```json',
107
- JSON.stringify(
108
- {
109
- version: PLAN_CHECKPOINT_SCHEMA_VERSION_V2,
110
- storyId,
111
- ...state,
112
- },
113
- null,
114
- 2,
115
- ),
116
- '```',
117
- ].join('\n');
118
119
  await upsertStructuredComment(provider, storyId, STORY_PLAN_STATE_TYPE, body);
119
- return state;
120
120
  }
121
121
 
122
122
  /**
@@ -130,7 +130,7 @@ export async function writeCheckpointV2(provider, storyId, state) {
130
130
  * the helper applied are reported alongside so the operator sees what was
131
131
  * rewritten.
132
132
  *
133
- * The returned freshness counts feed the posted `plan-summary`'s freshness
133
+ * The returned freshness counts feed the posted summary's freshness
134
134
  * line: every warning is a reference the base branch disagreed with, so it
135
135
  * counts as `stale` there rather than the comment reading "clean" on a run
136
136
  * that had something to say.
@@ -197,8 +197,9 @@ function collectTextHygieneWarnings(rawStories) {
197
197
 
198
198
  /**
199
199
  * Print every warning the run collected under one heading. The dry-run is
200
- * where an operator reads these; the persist prints the same list so a
201
- * `--chain-on-clean` run loses nothing.
200
+ * where an operator reads these; the persist prints the same list so the
201
+ * chained run loses nothing on stderr, as the chain's envelope merge keeps
202
+ * it from losing anything on stdout (Story #5361).
202
203
  *
203
204
  * @param {string[]} warnings
204
205
  * @returns {void}
@@ -248,7 +249,7 @@ async function runSupersedePhase(args) {
248
249
  }
249
250
 
250
251
  /**
251
- * Render the plan-metrics line for the **posted** `plan-summary` comment,
252
+ * Render the plan-metrics line for the **posted** summary section,
252
253
  * scoped to this invocation.
253
254
  *
254
255
  * The ordering hazard this closes: the ledger record for the current run is
@@ -313,7 +314,7 @@ async function renderRunScopedPlanMetricsLine({
313
314
  * writes — and the passes that scan `body.acceptance` / `body.verify` were
314
315
  * inert on the canonical top-level authoring shape as a result. Findings the
315
316
  * raw pass already reported are dropped so the same collision is not
316
- * announced twice per run; the rest are returned for the plan-summary
317
+ * announced twice per run; the rest are returned for the summary
317
318
  * comment, which is where these findings stop being a stderr line nobody
318
319
  * keeps. Every finding is advisory (Story #5312).
319
320
  *
@@ -419,56 +420,33 @@ async function enforceReachability(reachability, config) {
419
420
  }
420
421
 
421
422
  /**
422
- * Write the per-Story checkpoint, upsert the plan-summary comment, and flip
423
- * every created Story to `agent::ready`. Terminal ordering is load-bearing:
424
- * `agent::ready` lands last so it can honestly mean "fully persisted"
425
- * (Story #4541). A dry run performs none of it.
423
+ * Write the per-Story plan comment — one comment per Story, carrying the plan
424
+ * summary (Story #5343) — and flip every created Story to `agent::ready`.
425
+ * Terminal ordering is load-bearing: `agent::ready` lands last so it can
426
+ * honestly mean "fully persisted" (Story #4541). A dry run performs none of it.
426
427
  *
427
- * **The checkpoints fan out; the phase boundary does not** (Story #4952). The
428
+ * **The comments fan out; the phase boundary does not** (Story #4952). The
428
429
  * per-Story upserts are independent of one another and run under bounded
429
430
  * concurrency, but the `await` on that whole fan-out is what keeps the
430
- * Story #4541 invariant intact: *every* checkpoint is on its ticket before the
431
- * first `agent::ready` flip is issued, so `ready` still means "fully
432
- * persisted" and a `/mandrel-deliver` that picks a Story up cannot read a null
433
- * checkpoint. Concurrency inside the phase is safe; overlapping the phases is
434
- * the race this ordering exists to close.
431
+ * Story #4541 invariant intact: *every* Story carries its plan comment before
432
+ * the first `agent::ready` flip is issued, so `ready` still means "fully
433
+ * persisted" and the operator's instructions are never missing from a ticket
434
+ * something else may already be picking up. Concurrency inside the phase is
435
+ * safe; overlapping the phases is the race this ordering exists to close.
435
436
  *
436
437
  * @param {object} args
437
438
  * @returns {Promise<void>}
438
439
  */
439
- async function persistStoryArtifacts({
440
- provider,
441
- created,
442
- primary,
443
- summaryBody,
444
- }) {
445
- const cohort = created.map((createdStory) => ({
446
- slug: createdStory.slug,
447
- id: createdStory.id,
448
- }));
440
+ async function persistStoryArtifacts({ provider, created, summaryBody }) {
449
441
  await concurrentMap(
450
442
  created,
451
- (story) =>
452
- writeCheckpointV2(provider, story.id, {
453
- persist: {
454
- completedAt: new Date().toISOString(),
455
- storyCount: created.length,
456
- primaryStoryId: primary.id,
457
- stories: cohort,
458
- },
459
- }),
460
- // The per-Story checkpoint upserts (Story #4952): each targets a
443
+ (story) => writePlanSummaryComment(provider, story.id, summaryBody),
444
+ // The per-Story comment upserts (Story #4952): each targets a
461
445
  // different issue and reads nothing another writes, so this loop was
462
446
  // serial only by construction — but see {@link persistStoryArtifacts}
463
447
  // for the phase ordering that is *not* incidental.
464
448
  { concurrency: FANOUT_CONCURRENCY },
465
449
  );
466
- await upsertStructuredComment(
467
- provider,
468
- primary.id,
469
- PLAN_SUMMARY_COMMENT_TYPE,
470
- summaryBody,
471
- );
472
450
  await markStoriesReady({ provider, created });
473
451
  }
474
452
 
@@ -623,18 +601,26 @@ export async function runPlanPersist({
623
601
 
624
602
  // Inline Spec fold (Specs stay inline, never under docs/).
625
603
  const seedContent = planContextEnvelope?.seed?.content ?? '';
626
- const { stories: assembled } = assemblePlanStories(rawStories, {
627
- sharedSpec: techSpecContent,
628
- sourceTicketIds,
629
- // The seed this plan was authored from: an audit sweep's Single-plan seed
630
- // carries the `audit-fingerprints` / `audit-semantic-keys` footers, and
631
- // assembly copies them into the persisted Story bodies so the next sweep
632
- // recognises what it already planned (Story #4877). Since Story #5045 this
633
- // is the **fallback** — it is carried onto every Story that did not
634
- // attribute its own `provenance`, which keeps an un-attributed plan exactly
635
- // as recall-safe as it was. Empty for a `--tickets` run, a no-op there.
636
- provenanceSource: seedContent,
637
- });
604
+ const { stories: assembled, warnings: supersedeWarnings } =
605
+ assemblePlanStories(rawStories, {
606
+ sharedSpec: techSpecContent,
607
+ sourceTicketIds,
608
+ // The seed this plan was authored from: an audit sweep's Single-plan seed
609
+ // carries the `audit-fingerprints` / `audit-semantic-keys` footers, and
610
+ // assembly copies them into the persisted Story bodies so the next sweep
611
+ // recognises what it already planned (Story #4877). Since Story #5045 this
612
+ // is the **fallback** — it is carried onto every Story that did not
613
+ // attribute its own `provenance`, which keeps an un-attributed plan exactly
614
+ // as recall-safe as it was. Empty for a `--tickets` run, a no-op there.
615
+ provenanceSource: seedContent,
616
+ });
617
+
618
+ // Story #5342: the source ids assembly assigned to the primary Story by
619
+ // default. They ride the same list every other dry-run warning does, so a
620
+ // default-assigned supersede is visible in the output the operator already
621
+ // reads rather than only in the tracker afterwards.
622
+ warnings.push(...supersedeWarnings);
623
+ logWarnings(supersedeWarnings);
638
624
 
639
625
  // Stamp the `audit::*` labels the dedup corpus is listed by. Without them a
640
626
  // Story this path files is absent from the pool an indexed sweep matches
@@ -722,12 +708,7 @@ export async function runPlanPersist({
722
708
  });
723
709
 
724
710
  if (!dryRun) {
725
- await persistStoryArtifacts({
726
- provider,
727
- created,
728
- primary,
729
- summaryBody,
730
- });
711
+ await persistStoryArtifacts({ provider, created, summaryBody });
731
712
  }
732
713
 
733
714
  // Story #5139 — the container Epic is created LAST among the writes: its
@@ -39,8 +39,8 @@ import {
39
39
  isExternalDependencyRef,
40
40
  } from './external-deps.js';
41
41
  import {
42
- assertSupersedePartition,
43
42
  normalizeSupersedes,
43
+ resolveSupersedePartition,
44
44
  } from './supersede-ops.js';
45
45
 
46
46
  /**
@@ -157,7 +157,7 @@ const PLAN_FINGERPRINT_LENGTH = 16;
157
157
  *
158
158
  * 1. A later, unrelated plan that reused a slug **and** title adopted the
159
159
  * stale open Story — never rewriting its body or Spec, and landing this
160
- * run's checkpoints, ready-flip, and supersede comments on the wrong
160
+ * run's plan comments, ready-flip, and supersede comments on the wrong
161
161
  * issue.
162
162
  * 2. A legitimate resume after the operator edited `stories.json` adopted
163
163
  * the pre-edit Story and kept its stale body, discarding the edit.
@@ -513,9 +513,19 @@ function assertSharedSpecAllowed(tickets, sharedSpec) {
513
513
 
514
514
  /**
515
515
  * Assemble markdown bodies for every Story: normalize → fold spec →
516
- * assertSupersedePartition → serialize.
516
+ * order by dependency → resolveSupersedePartition → serialize.
517
517
  *
518
- * The partition check runs **before** any GitHub write so a mis-authored
518
+ * **The dependency sort runs here, once** (Story #5361). It used to run again
519
+ * inside the create loop, which meant "the primary Story" was derived twice
520
+ * from two different orderings: supersede assignment took the first *authored*
521
+ * Story, while the plan summary took the first *created*
522
+ * one. A draft whose authoring order differed from its dependency order made
523
+ * the `superseded-by` comment name a different Story from the summary. One
524
+ * sort, one ordered list threaded on to every consumer, so `stories[0]` is the
525
+ * only primary there is. The sort also refuses an unknown sibling or a cycle,
526
+ * which now fails the write-free pass rather than the first create.
527
+ *
528
+ * The partition pass runs **before** any GitHub write so a mis-authored
519
529
  * plan never leaves Stories live against an inconsistent tracker. Story #5332
520
530
  * retired the acceptance partition that used to run beside it: it refused
521
531
  * only byte-identical acceptance text across siblings, and the split gate is
@@ -526,7 +536,7 @@ function assertSharedSpecAllowed(tickets, sharedSpec) {
526
536
  * @param {object} [opts]
527
537
  * @param {string|null} [opts.sharedSpec]
528
538
  * @param {number[]} [opts.sourceTicketIds] Ids passed to `/mandrel-plan --tickets`.
529
- * @returns {{ stories: Array<{ slug: string, title: string, body: string, acceptance: string[], depends_on: string[], supersedes: Array<{ id: number, note: string|null }> }> }}
539
+ * @returns {{ stories: Array<{ slug: string, title: string, body: string, acceptance: string[], depends_on: string[], supersedes: Array<{ id: number, note: string|null }> }>, warnings: string[] }}
530
540
  */
531
541
  export function assemblePlanStories(tickets, opts = {}) {
532
542
  if (!Array.isArray(tickets) || tickets.length === 0) {
@@ -537,13 +547,16 @@ export function assemblePlanStories(tickets, opts = {}) {
537
547
 
538
548
  assertSharedSpecAllowed(tickets, opts.sharedSpec);
539
549
 
540
- const stories = tickets.map(
541
- (ticket) => assembleOnePlanStory(ticket, opts).story,
550
+ const stories = orderStoriesByDependencies(
551
+ tickets.map((ticket) => assembleOnePlanStory(ticket, opts).story),
542
552
  );
543
553
 
544
- assertSupersedePartition(stories, opts.sourceTicketIds ?? []);
554
+ const warnings = resolveSupersedePartition(
555
+ stories,
556
+ opts.sourceTicketIds ?? [],
557
+ );
545
558
 
546
- return { stories };
559
+ return { stories, warnings };
547
560
  }
548
561
 
549
562
  function orderStoriesByDependencies(stories) {
@@ -901,12 +914,13 @@ async function ensurePersistLabel({
901
914
  * Create Story issues via `provider.createIssue`, resumably.
902
915
  *
903
916
  * **Stories are born without `agent::ready`** (Story #4541). They used to
904
- * carry it in the creating POST while the `story-plan-state` checkpoint was
917
+ * carry it in the creating POST while the `story-plan-state` comment was
905
918
  * upserted afterwards, so anything that picked a Story up inside that window —
906
- * or after a comment failure aborted the loop — read the checkpoint as `null`.
907
- * Creation now applies `type::story` plus the sanitized authored labels only;
908
- * `markStoriesReady` performs the flip as the terminal step, once every
909
- * checkpoint is on the ticket.
919
+ * or after a comment failure aborted the loop — found a ready Story carrying
920
+ * none of the operator's delivery instructions. Creation now applies
921
+ * `type::story` plus the sanitized authored labels only; `markStoriesReady`
922
+ * performs the flip as the terminal step, once every plan comment is on the
923
+ * ticket.
910
924
  *
911
925
  * **The loop is resumable, and adoption is content-keyed.** Each body carries
912
926
  * a plan-fingerprint marker, and the open `type::story` backlog is indexed by
@@ -1002,7 +1016,10 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
1002
1016
  const created = [];
1003
1017
  const idBySlug = new Map();
1004
1018
 
1005
- for (const story of orderStoriesByDependencies(list)) {
1019
+ // Already in dependency order: `assemblePlanStories` sorted once, and
1020
+ // sorting again here is what gave the run a second, disagreeing notion of
1021
+ // which Story is primary (Story #5361).
1022
+ for (const story of list) {
1006
1023
  const already = byFingerprint.get(story.fingerprint);
1007
1024
  if (!already) warnOnDivergentSameTitleStory(story, idsByTitle);
1008
1025
  if (already) {
@@ -1087,8 +1104,8 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
1087
1104
  * (Story #4541).
1088
1105
  *
1089
1106
  * This is what makes `agent::ready` *mean* "fully persisted": by the time it
1090
- * lands, the Story's `story-plan-state` checkpoint is already on the ticket, so
1091
- * a `/mandrel-deliver` that picks it up cannot read a null checkpoint.
1107
+ * lands, the Story's `story-plan-state` comment is already on the ticket, so a
1108
+ * `/mandrel-deliver` that picks it up always has the plan summary beside it.
1092
1109
  *
1093
1110
  * Fails closed: an un-flipped Story is invisible to `/mandrel-deliver`, which is the
1094
1111
  * safe direction — the operator is told exactly which ids need the label.
@@ -1144,7 +1161,7 @@ export async function markStoriesReady({ provider, created }) {
1144
1161
  if (failed.length > 0) {
1145
1162
  throw new Error(
1146
1163
  `[plan-persist] ${failed.length} Story(ies) were created with their ` +
1147
- 'checkpoints but could not be flipped to agent::ready:\n' +
1164
+ 'plan comments but could not be flipped to agent::ready:\n' +
1148
1165
  `${failed.map((f) => ` - ${f}`).join('\n')}\n` +
1149
1166
  'They are invisible to /mandrel-deliver until the label lands. Re-run persist ' +
1150
1167
  '(it resumes rather than duplicating) or add the label by hand.',
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * summary.js — plan-persist terminal summary (v2 Stage 3).
3
3
  *
4
- * Upserts a single `plan-summary` structured comment on the primary Story
5
- * at terminal success. Carries the persist receipts — including whether the
6
- * operator forced a review stop — and the dry-run `depends_on` ordering table
7
- * for the rare N>1 plan.
4
+ * Renders the persist receipts — the created Story set, whether the operator
5
+ * forced a review stop, the `depends_on` ordering table and the exact deliver
6
+ * command. Story #5343 moved that rendering onto the **`story-plan-state`**
7
+ * marker every created Story already carries: it used to be a second,
8
+ * primary-Story-only `plan-summary` comment, which cost one more write per
9
+ * plan and split the operator's reading between two markers. Story #5367
10
+ * deleted the machine checkpoint that shared the marker with it, so this
11
+ * rendering is now the whole body of the one comment persist posts.
8
12
  *
9
13
  * Story #4542 removed the risk / review-routing line: no risk level, gate
10
14
  * decision, or acceptance disposition is computed at plan time any more, so
@@ -16,11 +20,6 @@
16
20
  import { computeStoryWaves } from '../dependency-analyzer.js';
17
21
  import { renderPredictedSerialisationLines } from './wave-serialisation.js';
18
22
 
19
- /**
20
- * Structured-comment type for the persist summary.
21
- */
22
- export const PLAN_SUMMARY_COMMENT_TYPE = 'plan-summary';
23
-
24
23
  /**
25
24
  * Compute the dry-run wave assignment for a validated ticket set.
26
25
  *
@@ -114,7 +113,8 @@ function renderSharedEditorLines(conflictFindings) {
114
113
  }
115
114
 
116
115
  /**
117
- * Build the `plan-summary` structured-comment body.
116
+ * Build the body of each Story's `story-plan-state` comment (Story #5343;
117
+ * Story #5367 made it the whole body).
118
118
  *
119
119
  * @param {object} input
120
120
  * @returns {string}
@@ -169,7 +169,7 @@ export function buildPlanSummaryCommentBody({
169
169
  : '/mandrel-deliver <storyId> [<storyId> ...]';
170
170
 
171
171
  return [
172
- `### 📋 Plan Summary — Story #${epicId} is \`agent::ready\``,
172
+ `#### 📋 Plan Summary — Story #${epicId} is \`agent::ready\``,
173
173
  '',
174
174
  `- ${ticketCount} Story ticket(s) persisted: ${storyList}.`,
175
175
  ...reviewLines,
@@ -15,12 +15,12 @@
15
15
  *
16
16
  * Two halves, deliberately separated by the `createIssue` boundary:
17
17
  *
18
- * 1. **`assertSupersedePartition`** — a plan-time, fail-closed check that
19
- * runs *before* any GitHub write. Same fail-closed shape as the
20
- * every id passed to `--tickets` must be claimed by exactly one Story,
21
- * and no Story may claim an id that was not a source ticket. A partial
22
- * supersede map is a planning error, not something to paper over at
23
- * write time.
18
+ * 1. **`resolveSupersedePartition`** — the plan-time completion pass that
19
+ * runs *before* any GitHub write. No id may be claimed by two Stories
20
+ * and no Story may claim an id that was not a source ticket — both fail
21
+ * closed. A source id nobody claimed is assigned to the primary Story
22
+ * with a warning (Story #5342): the plan is replacing it either way, so
23
+ * the only open question was bookkeeping.
24
24
  * 2. **`closeSupersededTickets`** — the bookkeeping pass that runs *after*
25
25
  * the Stories exist. It **never throws**: a throw here would leave the
26
26
  * run half-done with Stories already live. An already-closed, deleted,
@@ -279,18 +279,12 @@ function describeStoryIds(entries) {
279
279
  }
280
280
 
281
281
  /**
282
- * Fail closed on a partial supersede map.
282
+ * Index which Story claims each source id.
283
283
  *
284
- * Runs **before** `createIssue` so a mis-authored map never leaves Stories
285
- * live against an inconsistent tracker.
286
- *
287
- * @param {Array<{ slug: string, supersedes: Array<{ id: number }> }>} stories
288
- * @param {number[]} sourceTicketIds Ids passed to `/mandrel-plan --tickets`.
284
+ * @param {Array<{ slug: string, supersedes?: Array<{ id: number }> }>} list
285
+ * @returns {Map<number, string[]>} id → claiming slugs, in draft order.
289
286
  */
290
- export function assertSupersedePartition(stories, sourceTicketIds = []) {
291
- const list = Array.isArray(stories) ? stories : [];
292
- const sources = new Set(sourceTicketIds);
293
-
287
+ function indexSupersedeClaims(list) {
294
288
  /** @type {Map<number, string[]>} */
295
289
  const claims = new Map();
296
290
  for (const story of list) {
@@ -300,9 +294,47 @@ export function assertSupersedePartition(stories, sourceTicketIds = []) {
300
294
  claims.set(id, owners);
301
295
  }
302
296
  }
297
+ return claims;
298
+ }
303
299
 
304
- const errors = [];
300
+ /**
301
+ * Complete the supersede map, refusing only what the plan gets wrong.
302
+ *
303
+ * Two halves, split by who can be right (Story #5342):
304
+ *
305
+ * - **Refused.** A Story claiming an id that was never a source ticket, and
306
+ * two Stories claiming the same id. Both name an intent the run cannot
307
+ * act on — the first would comment on and close an issue nobody asked
308
+ * about, the second cannot say which Story replaced it — so they fail
309
+ * closed, **before** `createIssue`, and no Story goes live against an
310
+ * inconsistent tracker.
311
+ * - **Assigned with a warning.** A source id no Story claimed. Every id
312
+ * passed to `--tickets` is being replaced by this plan by construction;
313
+ * which Story records it is a bookkeeping detail, and the primary Story
314
+ * is the answer the operator would have given. Refusing cost a whole
315
+ * re-author round to type back a fact the run already knew.
316
+ *
317
+ * Mutates the unclaimed ids onto the primary Story's `supersedes[]`.
318
+ *
319
+ * **`stories[0]` is the primary, and it is the *only* derivation of it**
320
+ * (Story #5361): `assemblePlanStories` hands this list over already sorted by
321
+ * `orderStoriesByDependencies`, which is the same order the create loop files
322
+ * the Stories in — so the `superseded-by` comment this assignment produces
323
+ * can never name a different Story from the checkpoint and the plan summary.
324
+ * A non-empty list is the caller's contract (assembly refuses an empty
325
+ * draft before it gets here).
326
+ *
327
+ * @param {Array<{ slug: string, supersedes: Array<{ id: number, note: string|null }> }>} stories
328
+ * Dependency-ordered and non-empty.
329
+ * @param {number[]} sourceTicketIds Ids passed to `/mandrel-plan --tickets`.
330
+ * @returns {string[]} One warning per id assigned by default.
331
+ */
332
+ export function resolveSupersedePartition(stories, sourceTicketIds = []) {
333
+ const list = Array.isArray(stories) ? stories : [];
334
+ const sources = new Set(sourceTicketIds);
335
+ const claims = indexSupersedeClaims(list);
305
336
 
337
+ const errors = [];
306
338
  for (const [id, owners] of claims) {
307
339
  if (owners.length > 1) {
308
340
  errors.push(
@@ -317,23 +349,25 @@ export function assertSupersedePartition(stories, sourceTicketIds = []) {
317
349
  );
318
350
  }
319
351
  }
320
-
321
- for (const id of sources) {
322
- if (!claims.has(id)) {
323
- errors.push(
324
- `source ticket #${id} is not claimed by any Story's supersedes[] — ` +
325
- 'a partial supersede map is a planning error. Claim it, or drop it ' +
326
- 'from --tickets.',
327
- );
328
- }
329
- }
330
-
331
352
  if (errors.length > 0) {
332
353
  throw new Error(
333
354
  `[plan-persist] supersede partition failed with ${errors.length} ` +
334
355
  `error(s):\n${errors.map((e) => ` - ${e}`).join('\n')}`,
335
356
  );
336
357
  }
358
+
359
+ const primary = list[0];
360
+ const warnings = [];
361
+ for (const id of sources) {
362
+ if (claims.has(id)) continue;
363
+ primary.supersedes = [...(primary.supersedes ?? []), { id, note: null }];
364
+ warnings.push(
365
+ `source ticket #${id} was claimed by no Story's supersedes[] — ` +
366
+ `assigned to the primary Story "${primary.slug}". Author the claim ` +
367
+ 'explicitly if another Story is the one that replaces it.',
368
+ );
369
+ }
370
+ return warnings;
337
371
  }
338
372
 
339
373
  /**
@@ -567,7 +601,7 @@ export async function closeSupersededTickets({
567
601
  });
568
602
  },
569
603
  // The per-source-ticket close (Story #4952) fans out across **distinct**
570
- // tickets — `assertSupersedePartition` has already failed the run closed
604
+ // tickets — `resolveSupersedePartition` has already failed the run closed
571
605
  // if two Stories claim the same id, so no two units in flight can touch
572
606
  // the same issue. Within one unit the probe → comment → close sequence
573
607
  // stays strictly ordered: commenting on an issue the probe reported
@@ -75,19 +75,22 @@ export const DEFAULT_DIFF_WIDTH = Object.freeze({
75
75
  * `audit-rules.json`?
76
76
  *
77
77
  * This is the **single source** of the derived level — the review depth
78
- * ({@link resolveDepth}), the acceptance-critic fresh-vs-inline routing
79
- * (`ceremony-routing.js#resolveCeremonyForRisk`), and the dispatch-side
80
- * complexity routing (`complexity-gate.js#deriveStoryShape`, Story #4722)
81
- * all consume what this returns, so no ceremony decision can disagree about
82
- * how risky a change is. Dispatch reads the **predicted** shape (the Story's
83
- * declared `changes[]` footprint) and close reads the **actual** diff — one
84
- * taxonomy, two read points, which is what keeps a lite-shaped Story whose
85
- * footprint touches a sensitive path on the full route with its fresh critic.
78
+ * ({@link resolveDepth}) and the dispatch-side complexity routing
79
+ * (`complexity-gate.js#deriveStoryShape`, Story #4722) both consume what this
80
+ * returns, so no risk decision can disagree about how risky a change is.
81
+ * Dispatch reads the **predicted** shape (the Story's declared `changes[]`
82
+ * footprint) and close reads the **actual** diff — one taxonomy, two read
83
+ * points, which is what keeps a lite-shaped Story whose footprint touches a
84
+ * sensitive path on the full route and under a deep review.
85
+ *
86
+ * The acceptance verdict owner is **not** downstream of this level: Story
87
+ * #5343 re-based `ceremony-routing.js#resolveCeremonyForRisk` on the ceremony
88
+ * profile alone, and Story #5366 removed the level from its signature.
86
89
  *
87
90
  * Returns `null` — the fail-safe "no derivable signal" level — when the change
88
- * set is empty/unknown or the manifest cannot be read. Both downstream
89
- * consumers treat `null` as the more thorough posture (`standard` depth, a
90
- * `fresh` critic), so a derivation failure never buys a change less checking.
91
+ * set is empty/unknown or the manifest cannot be read. Both consumers treat
92
+ * `null` as the more thorough posture (`standard` depth, the conservative
93
+ * `full` route), so a derivation failure never buys a change less checking.
91
94
  *
92
95
  * Total: never throws.
93
96
  *