mandrel 2.24.0 → 2.26.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  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/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -38,11 +38,14 @@
38
38
  */
39
39
 
40
40
  import { rm } from 'node:fs/promises';
41
- import path from 'node:path';
42
41
  import { getLimits, PROJECT_ROOT } from '../../config-resolver.js';
43
42
  import { gitSpawn } from '../../git-utils.js';
44
43
  import { Logger } from '../../Logger.js';
45
44
  import { sweepTempRetention } from '../../temp-retention.js';
45
+ import {
46
+ concurrentMap,
47
+ FANOUT_CONCURRENCY,
48
+ } from '../../util/concurrent-map.js';
46
49
  import {
47
50
  deriveStoryShape,
48
51
  LITE_ROUTE_LABEL,
@@ -410,6 +413,198 @@ export async function reapStalePlanDirs({
410
413
  return { reaped: result.purged.map((entry) => entry.path) };
411
414
  }
412
415
 
416
+ /**
417
+ * Fail closed on a payload that cannot be persisted, and warn on an
418
+ * explicitly-authorized over-budget one. Extracted from `runPlanPersist`
419
+ * (Story #4926) so the entry point carries the flow, not the guards.
420
+ *
421
+ * @param {unknown} rawStories
422
+ * @param {{ maxTickets: number, allowOverBudget: boolean }} limits
423
+ * @returns {void}
424
+ * @throws {Error} On an empty payload or an unauthorized over-budget one.
425
+ */
426
+ function assertPersistablePlan(rawStories, { maxTickets, allowOverBudget }) {
427
+ if (!Array.isArray(rawStories) || rawStories.length === 0) {
428
+ throw new Error(
429
+ '[plan-persist] stories payload must be a non-empty array ' +
430
+ '(--stories <file>). Default is one Story.',
431
+ );
432
+ }
433
+ if (rawStories.length <= maxTickets) return;
434
+ if (!allowOverBudget) {
435
+ throw new Error(
436
+ `[plan-persist] Stories (${rawStories.length}) exceed the reviewability ` +
437
+ `budget (${maxTickets}). Re-scope, or rerun with --allow-over-budget.`,
438
+ );
439
+ }
440
+ Logger.warn(
441
+ `[plan-persist] Persisting an over-budget plan: ${rawStories.length} ` +
442
+ `Stories vs. budget ${maxTickets} (--allow-over-budget).`,
443
+ );
444
+ }
445
+
446
+ /**
447
+ * Enforce the draft-reachability critic: orphans throw, a skip is ledgered.
448
+ *
449
+ * @param {{ status: string, reasons: string[], orphans?: object[] }} reachability
450
+ * @param {object} config
451
+ * @returns {Promise<void>}
452
+ * @throws {Error} `PLAN_REACHABILITY_ORPHANS` when the draft orphans a Story.
453
+ */
454
+ async function enforceReachability(reachability, config) {
455
+ if (reachability.status === 'orphans') {
456
+ const err = new Error(renderReachabilityOrphans(reachability));
457
+ err.code = 'PLAN_REACHABILITY_ORPHANS';
458
+ err.orphans = reachability.orphans;
459
+ throw err;
460
+ }
461
+ Logger.info(`[plan-persist] reachability: ${reachability.reasons[0]}`);
462
+ if (reachability.status === 'skipped') {
463
+ await appendCriticSkip(
464
+ {
465
+ critic: 'reachability',
466
+ reasons: reachability.reasons,
467
+ cli: 'plan-persist',
468
+ },
469
+ config,
470
+ );
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Write the per-Story checkpoint, upsert the plan-summary comment, and flip
476
+ * every created Story to `agent::ready`. Terminal ordering is load-bearing:
477
+ * `agent::ready` lands last so it can honestly mean "fully persisted"
478
+ * (Story #4541). A dry run performs none of it.
479
+ *
480
+ * **The checkpoints fan out; the phase boundary does not** (Story #4952). The
481
+ * per-Story upserts are independent of one another and run under bounded
482
+ * concurrency, but the `await` on that whole fan-out is what keeps the
483
+ * Story #4541 invariant intact: *every* checkpoint is on its ticket before the
484
+ * first `agent::ready` flip is issued, so `ready` still means "fully
485
+ * persisted" and a `/deliver` that picks a Story up cannot read a null
486
+ * checkpoint. Concurrency inside the phase is safe; overlapping the phases is
487
+ * the race this ordering exists to close.
488
+ *
489
+ * @param {object} args
490
+ * @returns {Promise<void>}
491
+ */
492
+ async function persistStoryArtifacts({
493
+ provider,
494
+ created,
495
+ primary,
496
+ route,
497
+ summaryBody,
498
+ }) {
499
+ const cohort = created.map((createdStory) => ({
500
+ slug: createdStory.slug,
501
+ id: createdStory.id,
502
+ }));
503
+ await concurrentMap(
504
+ created,
505
+ (story) =>
506
+ writeCheckpointV2(provider, story.id, {
507
+ persist: {
508
+ completedAt: new Date().toISOString(),
509
+ storyCount: created.length,
510
+ primaryStoryId: primary.id,
511
+ stories: cohort,
512
+ },
513
+ // Ledger the authored route verdict — the recorded reason and the
514
+ // per-Story shape evidence, including a shape-refused claim — on plan
515
+ // state (Story #4722). No authored verdict writes no block: absence
516
+ // is the standard full path.
517
+ ...(route ? { route } : {}),
518
+ }),
519
+ // The per-Story checkpoint upserts (Story #4952): each targets a
520
+ // different issue and reads nothing another writes, so this loop was
521
+ // serial only by construction — but see {@link persistStoryArtifacts}
522
+ // for the phase ordering that is *not* incidental.
523
+ { concurrency: FANOUT_CONCURRENCY },
524
+ );
525
+ await upsertStructuredComment(
526
+ provider,
527
+ primary.id,
528
+ PLAN_SUMMARY_COMMENT_TYPE,
529
+ summaryBody,
530
+ );
531
+ await markStoriesReady({ provider, created });
532
+ }
533
+
534
+ /**
535
+ * Remove this run's plan directory on terminal success, then sweep the
536
+ * abandoned ones terminal-success cleanup can never reach (Story #4541).
537
+ *
538
+ * @param {{ config: object, planDir: string|null, skipCleanup: boolean }} args
539
+ * @returns {Promise<void>}
540
+ */
541
+ async function cleanupPlanDirs({ config, planDir, skipCleanup }) {
542
+ if (!skipCleanup && planDir) {
543
+ try {
544
+ await rm(planDir, { recursive: true, force: true });
545
+ } catch (err) {
546
+ Logger.warn(`[plan-persist] temp cleanup skipped: ${err.message}`);
547
+ }
548
+ }
549
+ await reapStalePlanDirs({ config, keepDir: skipCleanup ? planDir : null });
550
+ }
551
+
552
+ /**
553
+ * Log the effective complexity route (Story #4722). Lite is upheld by the
554
+ * shape backstop; anything else reports why it fell back to full.
555
+ *
556
+ * @param {object|null} route
557
+ * @param {boolean} isLiteRoute
558
+ * @returns {void}
559
+ */
560
+ function logEffectiveRoute(route, isLiteRoute) {
561
+ if (isLiteRoute) {
562
+ Logger.info(
563
+ `[plan-persist] ceremony-lite route upheld by the shape backstop: ` +
564
+ `created Stories carry the ${LITE_ROUTE_LABEL} hint ` +
565
+ `(recorded reason: ${route.authored.reason}). /deliver re-derives ` +
566
+ 'the route from each Story body — the label is never the control signal.',
567
+ );
568
+ return;
569
+ }
570
+ if (route) {
571
+ Logger.warn(
572
+ `[plan-persist] ${route.reasons.join('; ')} — persisting as full ` +
573
+ '(no route hint label).',
574
+ );
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Log the operator-facing persist epilogue: adoption, the ready primary, the
580
+ * deliver command, and the cohort grouping label.
581
+ *
582
+ * @param {{ created: object[], primary: object, planRunLabel: string }} args
583
+ * @returns {void}
584
+ */
585
+ function logPersistEpilogue({ created, primary, planRunLabel }) {
586
+ const adopted = created.filter((story) => story.adopted);
587
+ if (adopted.length > 0) {
588
+ Logger.info(
589
+ `[plan-persist] resumed ${adopted.length} of ${created.length} Story(ies) ` +
590
+ `from a previous persist: ${adopted.map((s2) => `#${s2.id}`).join(', ')}.`,
591
+ );
592
+ }
593
+ Logger.info(
594
+ `[plan-persist] Persisted ${created.length} Story(ies)` +
595
+ `; primary #${primary.id} is agent::ready.`,
596
+ );
597
+ Logger.info(
598
+ `[plan-persist] Deliver with: /deliver ${created.map((s2) => s2.id).join(' ')}`,
599
+ );
600
+ // Metadata only — a GitHub filter for the cohort this run authored, never
601
+ // a delivery-resolution input (/deliver stays ids-only, Story #4540).
602
+ Logger.info(
603
+ `[plan-persist] Cohort grouping label: ${planRunLabel} — filter with ` +
604
+ `label:${planRunLabel}`,
605
+ );
606
+ }
607
+
413
608
  /**
414
609
  * Execute the flat Story persist end to end.
415
610
  *
@@ -451,6 +646,7 @@ export async function runPlanPersist({
451
646
  stories: rawStories = null,
452
647
  techSpecContent = null,
453
648
  planAcceptance = null,
649
+ planContextEnvelope = null,
454
650
  } = artifacts ?? {};
455
651
  const {
456
652
  forceReview = false,
@@ -474,26 +670,10 @@ export async function runPlanPersist({
474
670
  // through the shared standalone ledger (Story #4541).
475
671
  const runStartedAt = opts.metricsSince ?? new Date().toISOString();
476
672
 
477
- if (!Array.isArray(rawStories) || rawStories.length === 0) {
478
- throw new Error(
479
- '[plan-persist] stories payload must be a non-empty array ' +
480
- '(--stories <file>). Default is one Story.',
481
- );
482
- }
483
-
484
- const maxTickets = getLimits(config).maxTickets;
485
- if (rawStories.length > maxTickets && !allowOverBudget) {
486
- throw new Error(
487
- `[plan-persist] Stories (${rawStories.length}) exceed the reviewability ` +
488
- `budget (${maxTickets}). Re-scope, or rerun with --allow-over-budget.`,
489
- );
490
- }
491
- if (rawStories.length > maxTickets && allowOverBudget) {
492
- Logger.warn(
493
- `[plan-persist] Persisting an over-budget plan: ${rawStories.length} ` +
494
- `Stories vs. budget ${maxTickets} (--allow-over-budget).`,
495
- );
496
- }
673
+ assertPersistablePlan(rawStories, {
674
+ maxTickets: getLimits(config).maxTickets,
675
+ allowOverBudget,
676
+ });
497
677
 
498
678
  Logger.info(
499
679
  `[plan-persist] Running cross-validation on ${rawStories.length} Story ticket(s)...`,
@@ -515,29 +695,19 @@ export async function runPlanPersist({
515
695
  tickets: rawStories,
516
696
  config,
517
697
  });
518
- if (reachability.status === 'orphans') {
519
- const err = new Error(renderReachabilityOrphans(reachability));
520
- err.code = 'PLAN_REACHABILITY_ORPHANS';
521
- err.orphans = reachability.orphans;
522
- throw err;
523
- }
524
- Logger.info(`[plan-persist] reachability: ${reachability.reasons[0]}`);
525
- if (reachability.status === 'skipped') {
526
- await appendCriticSkip(
527
- {
528
- critic: 'reachability',
529
- reasons: reachability.reasons,
530
- cli: 'plan-persist',
531
- },
532
- config,
533
- );
534
- }
698
+ await enforceReachability(reachability, config);
535
699
 
536
700
  // Split policy + inline Spec fold (over-budget Specs fail closed — no docs/).
537
701
  const { stories } = assemblePlanStories(rawStories, {
538
702
  sharedSpec: techSpecContent,
539
703
  planAcceptance: planAcceptance ?? undefined,
540
704
  sourceTicketIds,
705
+ // The seed this plan was authored from is the provenance source: an audit
706
+ // sweep's Single-plan seed carries the `audit-fingerprints` /
707
+ // `audit-semantic-keys` footers, which assembly copies into every persisted
708
+ // Story body so the next sweep recognises what it already planned
709
+ // (Story #4877). Empty for a `--tickets` run, which is a no-op.
710
+ provenanceSource: planContextEnvelope?.seed?.content ?? '',
541
711
  });
542
712
 
543
713
  // Effective complexity route (Story #4722): the planner's authored lite
@@ -552,19 +722,7 @@ export async function runPlanPersist({
552
722
  injectedRules,
553
723
  });
554
724
  const isLiteRoute = route?.route === 'lite';
555
- if (isLiteRoute) {
556
- Logger.info(
557
- `[plan-persist] ceremony-lite route upheld by the shape backstop: ` +
558
- `created Stories carry the ${LITE_ROUTE_LABEL} hint ` +
559
- `(recorded reason: ${route.authored.reason}). /deliver re-derives ` +
560
- 'the route from each Story body — the label is never the control signal.',
561
- );
562
- } else if (route) {
563
- Logger.warn(
564
- `[plan-persist] ${route.reasons.join('; ')} — persisting as full ` +
565
- '(no route hint label).',
566
- );
567
- }
725
+ logEffectiveRoute(route, isLiteRoute);
568
726
 
569
727
  const { created, planRunLabel } = await createStoryIssues({
570
728
  provider,
@@ -609,35 +767,13 @@ export async function runPlanPersist({
609
767
  });
610
768
 
611
769
  if (!dryRun) {
612
- for (const story of created) {
613
- await writeCheckpointV2(provider, story.id, {
614
- persist: {
615
- completedAt: new Date().toISOString(),
616
- storyCount: created.length,
617
- primaryStoryId: primary.id,
618
- stories: created.map((createdStory) => ({
619
- slug: createdStory.slug,
620
- id: createdStory.id,
621
- })),
622
- },
623
- // Ledger the authored route verdict — the recorded reason and the
624
- // per-Story shape evidence, including a shape-refused claim — on plan
625
- // state (Story #4722). No authored verdict writes no block: absence
626
- // is the standard full path.
627
- ...(route ? { route } : {}),
628
- });
629
- }
630
- await upsertStructuredComment(
770
+ await persistStoryArtifacts({
631
771
  provider,
632
- primary.id,
633
- PLAN_SUMMARY_COMMENT_TYPE,
772
+ created,
773
+ primary,
774
+ route,
634
775
  summaryBody,
635
- );
636
-
637
- // Terminal step: every checkpoint above is now on every Story, so
638
- // `agent::ready` can honestly mean "fully persisted" (Story #4541).
639
- // Anything that picks a Story up from here reads a real checkpoint.
640
- await markStoriesReady({ provider, created });
776
+ });
641
777
  }
642
778
 
643
779
  const supersede = await runSupersedePhase({
@@ -653,37 +789,8 @@ export async function runPlanPersist({
653
789
  // any) rather than reading as a clean no-op — Story #4554.
654
790
  supersede.sourceTicketOrigin = sourceTicketOrigin;
655
791
 
656
- if (!skipCleanup && planDir) {
657
- try {
658
- await rm(planDir, { recursive: true, force: true });
659
- } catch (err) {
660
- Logger.warn(`[plan-persist] temp cleanup skipped: ${err.message}`);
661
- }
662
- }
663
- // Terminal-success cleanup only ever removes *this* run's planDir, so
664
- // abandoned ones accumulated forever. Sweep them (Story #4541).
665
- await reapStalePlanDirs({ config, keepDir: skipCleanup ? planDir : null });
666
-
667
- const adopted = created.filter((story) => story.adopted);
668
- if (adopted.length > 0) {
669
- Logger.info(
670
- `[plan-persist] resumed ${adopted.length} of ${created.length} Story(ies) ` +
671
- `from a previous persist: ${adopted.map((s2) => `#${s2.id}`).join(', ')}.`,
672
- );
673
- }
674
- Logger.info(
675
- `[plan-persist] Persisted ${created.length} Story(ies)` +
676
- `; primary #${primary.id} is agent::ready.`,
677
- );
678
- Logger.info(
679
- `[plan-persist] Deliver with: /deliver ${created.map((s2) => s2.id).join(' ')}`,
680
- );
681
- // Metadata only — a GitHub filter for the cohort this run authored, never
682
- // a delivery-resolution input (/deliver stays ids-only, Story #4540).
683
- Logger.info(
684
- `[plan-persist] Cohort grouping label: ${planRunLabel} — filter with ` +
685
- `label:${planRunLabel}`,
686
- );
792
+ await cleanupPlanDirs({ config, planDir, skipCleanup });
793
+ logPersistEpilogue({ created, primary, planRunLabel });
687
794
 
688
795
  return {
689
796
  stories: created,
@@ -17,12 +17,17 @@
17
17
 
18
18
  import { createHash } from 'node:crypto';
19
19
  import { applyBlockedByDependencies } from '../../../providers/github/blocked-by-add.js';
20
+ import { carryProvenanceFooters } from '../../findings/route-finding.js';
20
21
  import { Logger } from '../../Logger.js';
21
22
  import { AGENT_LABELS, TYPE_LABELS } from '../../label-constants.js';
22
23
  import {
23
24
  parse as parseStoryBody,
24
25
  serialize as serializeStoryBody,
25
26
  } from '../../story-body/story-body.js';
27
+ import {
28
+ concurrentMap,
29
+ FANOUT_CONCURRENCY,
30
+ } from '../../util/concurrent-map.js';
26
31
  import { assertSpecWithinBudget } from '../spec-spill.js';
27
32
  import { assertAcceptancePartition } from '../split-policy-validator.js';
28
33
  import {
@@ -392,7 +397,18 @@ function assembleOnePlanStory(ticket, opts) {
392
397
  });
393
398
  // Body first: the fingerprint is an identity over the *assembled* content,
394
399
  // so it cannot be computed until that content exists.
395
- const body = serializeStoryBody({ ...folded, depends_on });
400
+ const serialized = serializeStoryBody({ ...folded, depends_on });
401
+ // Carry audit dedup provenance out of the seed this plan was authored from
402
+ // (Story #4877). The audit sweep's Single-plan path stamps the
403
+ // `audit-fingerprints` / `audit-semantic-keys` footers into the seed it hands
404
+ // `/plan`; without this the persisted Story carries no provenance and the
405
+ // next sweep re-files work it already planned. Mechanical on purpose — the
406
+ // authoring agent is not asked to notice HTML comments in a one-pager. A
407
+ // non-audit seed carries no footers, so this is a no-op there.
408
+ const { body } = carryProvenanceFooters({
409
+ from: opts.provenanceSource ?? '',
410
+ into: serialized,
411
+ });
396
412
  const fingerprint = planStoryFingerprint({ slug, title, body });
397
413
  return {
398
414
  story: {
@@ -783,6 +799,14 @@ async function ensurePersistLabel({
783
799
  * (see `ensureCohortLabel`). `/deliver` never reads it — delivery stays
784
800
  * ids-only over live state (Story #4540's actual point).
785
801
  *
802
+ * **The create loop stays serial and dependency-ordered** — deliberately, and
803
+ * unlike every other per-Story loop on this path (Story #4952). It is not an
804
+ * independent fan-out: `renderStoryBodyForCreate(story, idBySlug)` resolves a
805
+ * Story's `depends_on` slugs to the real issue ids its siblings were just
806
+ * minted with, and `idBySlug` is filled *in loop order* by the POSTs
807
+ * themselves. Running it concurrently would render `#undefined` dependency
808
+ * refs for any Story whose dependency had not yet returned an id.
809
+ *
786
810
  * **Sibling order is mirrored into native GitHub `blocked_by` edges** once
787
811
  * every id is known (Story #4544), so plan-created order stops depending on
788
812
  * prose. That pass is non-fatal — see `mirrorNativeDependencyEdges`.
@@ -948,6 +972,16 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
948
972
  * Fails closed: an un-flipped Story is invisible to `/deliver`, which is the
949
973
  * safe direction — the operator is told exactly which ids need the label.
950
974
  *
975
+ * **Collect failures, never fast-fail** (preserved verbatim under the
976
+ * Story #4952 concurrency conversion). Every Story is attempted even when an
977
+ * earlier one's PATCH rejects, because the whole value of the closing error is
978
+ * naming the *complete* set of ids that still need the label by hand. The
979
+ * mapper below therefore absorbs its own rejection into a per-Story outcome
980
+ * rather than letting `concurrentMap`'s first-rejection-wins policy abandon
981
+ * the remaining flips. `concurrentMap` preserves input order, so `readied[]`
982
+ * and the reported failures come back in `created[]` order exactly as the
983
+ * serial loop produced them.
984
+ *
951
985
  * @param {object} args
952
986
  * @param {object} args.provider
953
987
  * @param {Array<{ id: number, slug: string }>} args.created
@@ -960,18 +994,32 @@ export async function markStoriesReady({ provider, created }) {
960
994
  'Stories to agent::ready.',
961
995
  );
962
996
  }
963
- const readied = [];
964
- const failed = [];
965
- for (const story of created) {
966
- try {
967
- await provider.updateTicket(story.id, {
968
- labels: { add: [AGENT_LABELS.READY] },
969
- });
970
- readied.push(story.id);
971
- } catch (err) {
972
- failed.push(`#${story.id} (${story.slug}): ${err.message}`);
973
- }
974
- }
997
+ const outcomes = await concurrentMap(
998
+ created,
999
+ async (story) => {
1000
+ try {
1001
+ await provider.updateTicket(story.id, {
1002
+ labels: { add: [AGENT_LABELS.READY] },
1003
+ });
1004
+ return { id: story.id, failure: null };
1005
+ } catch (err) {
1006
+ return {
1007
+ id: story.id,
1008
+ failure: `#${story.id} (${story.slug}): ${err.message}`,
1009
+ };
1010
+ }
1011
+ },
1012
+ // The terminal per-Story `agent::ready` PATCHes (Story #4952): each flip
1013
+ // is an independent single-issue write, so the loop was serial only by
1014
+ // construction. A latency fix, not a throughput one.
1015
+ { concurrency: FANOUT_CONCURRENCY },
1016
+ );
1017
+ const readied = outcomes
1018
+ .filter((outcome) => outcome.failure === null)
1019
+ .map((outcome) => outcome.id);
1020
+ const failed = outcomes
1021
+ .filter((outcome) => outcome.failure !== null)
1022
+ .map((outcome) => outcome.failure);
975
1023
  if (failed.length > 0) {
976
1024
  throw new Error(
977
1025
  `[plan-persist] ${failed.length} Story(ies) were created with their ` +
@@ -36,6 +36,10 @@
36
36
  */
37
37
 
38
38
  import { Logger } from '../../Logger.js';
39
+ import {
40
+ concurrentMap,
41
+ FANOUT_CONCURRENCY,
42
+ } from '../../util/concurrent-map.js';
39
43
  import { upsertStructuredComment } from '../ticketing.js';
40
44
 
41
45
  /** Structured-comment type marking a source issue as superseded. */
@@ -440,41 +444,94 @@ export async function closeSupersededTickets({
440
444
  const createdBySlug = new Map(
441
445
  (created ?? []).map((story) => [story.slug, story]),
442
446
  );
443
-
444
- const report = emptyReport({ enabled: true, dryRun });
445
-
446
- for (const story of stories ?? []) {
447
- const createdStory = createdBySlug.get(story.slug);
448
- for (const { id, note } of story.supersedes ?? []) {
447
+ const units = collectSupersedeUnits(stories, createdBySlug);
448
+
449
+ // Story #4952 — one bounded fan-out across distinct source tickets. The
450
+ // mapper inherits `closeOneSupersededTicket`'s never-throw contract (and
451
+ // resolves the two pre-write outcomes itself), so `concurrentMap`'s
452
+ // first-rejection-wins policy can never fire and no unit is abandoned
453
+ // because a sibling failed. Input order is preserved, so the report arrays
454
+ // below read exactly as the serial nested loop wrote them.
455
+ const outcomes = await concurrentMap(
456
+ units,
457
+ ({ id, note, createdStory }) => {
449
458
  if (!createdStory) {
450
- report.skipped.push({ ticket: id, reason: 'story-not-created' });
451
- continue;
452
- }
453
- if (dryRun) {
454
- report.planned.push({ ticket: id, storySlug: createdStory.slug });
455
- continue;
459
+ return { outcome: 'skipped', reason: 'story-not-created' };
456
460
  }
457
- const result = await closeOneSupersededTicket({
461
+ if (dryRun) return { outcome: 'planned' };
462
+ return closeOneSupersededTicket({
458
463
  provider,
459
464
  id,
460
465
  note,
461
466
  story: createdStory,
462
467
  sourceTicketIds: sources,
463
468
  });
464
- if (result.outcome === 'closed') {
465
- report.closed.push(id);
466
- } else if (result.outcome === 'skipped') {
467
- report.skipped.push({ ticket: id, reason: result.reason });
468
- } else {
469
- report.failed.push({ ticket: id, reason: result.reason });
470
- }
471
- }
472
- }
469
+ },
470
+ // The per-source-ticket close (Story #4952) fans out across **distinct**
471
+ // tickets `assertSupersedePartition` has already failed the run closed
472
+ // if two Stories claim the same id, so no two units in flight can touch
473
+ // the same issue. Within one unit the probe → comment → close sequence
474
+ // stays strictly ordered: commenting on an issue the probe reported
475
+ // closed, or closing one the comment never landed on, is the whole
476
+ // failure mode this phase is careful about.
477
+ { concurrency: FANOUT_CONCURRENCY },
478
+ );
479
+
480
+ const report = emptyReport({ enabled: true, dryRun });
481
+ outcomes.forEach((result, index) => {
482
+ recordSupersedeOutcome(report, units[index], result);
483
+ });
473
484
 
474
485
  logSupersedeReport(report);
475
486
  return report;
476
487
  }
477
488
 
489
+ /**
490
+ * Flatten the per-Story `supersedes[]` maps into one list of close units, in
491
+ * the nested iteration order the report arrays are expected to follow.
492
+ *
493
+ * @param {Array<{ slug: string, supersedes?: Array<{ id: number, note: string|null }> }>|undefined} stories
494
+ * @param {Map<string, { slug: string, id: number, title: string }>} createdBySlug
495
+ * @returns {Array<{ id: number, note: string|null, createdStory: object|undefined }>}
496
+ */
497
+ function collectSupersedeUnits(stories, createdBySlug) {
498
+ const units = [];
499
+ for (const story of stories ?? []) {
500
+ const createdStory = createdBySlug.get(story.slug);
501
+ for (const { id, note } of story.supersedes ?? []) {
502
+ units.push({ id, note, createdStory });
503
+ }
504
+ }
505
+ return units;
506
+ }
507
+
508
+ /**
509
+ * File one unit's outcome onto the report.
510
+ *
511
+ * @param {SupersedeReport} report
512
+ * @param {{ id: number, createdStory: object|undefined }} unit
513
+ * @param {{ outcome: string, reason?: string }} result
514
+ * @returns {void}
515
+ */
516
+ function recordSupersedeOutcome(report, unit, result) {
517
+ if (result.outcome === 'closed') {
518
+ report.closed.push(unit.id);
519
+ return;
520
+ }
521
+ if (result.outcome === 'planned') {
522
+ report.planned.push({
523
+ ticket: unit.id,
524
+ storySlug: unit.createdStory.slug,
525
+ });
526
+ return;
527
+ }
528
+ if (result.outcome === 'skipped') {
529
+ report.skipped.push({ ticket: unit.id, reason: result.reason });
530
+ return;
531
+ }
532
+ report.failed.push({ ticket: unit.id, reason: result.reason });
533
+ }
534
+
478
535
  /**
479
536
  * Surface the supersede outcome on the console so a partial failure is
480
537
  * visible without reading the JSON envelope.