mandrel 1.57.0 → 1.59.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 (131) hide show
  1. package/.agents/README.md +89 -87
  2. package/.agents/docs/SDLC.md +11 -7
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/audit-rules.json +20 -0
  5. package/.agents/scripts/acceptance-eval.js +20 -3
  6. package/.agents/scripts/assert-branch.js +1 -3
  7. package/.agents/scripts/bootstrap.js +1 -1
  8. package/.agents/scripts/check-arch-cycles.js +360 -0
  9. package/.agents/scripts/coverage-capture.js +24 -3
  10. package/.agents/scripts/epic-deliver-preflight.js +5 -3
  11. package/.agents/scripts/epic-deliver-prepare.js +12 -4
  12. package/.agents/scripts/epic-execute-record-wave.js +1 -1
  13. package/.agents/scripts/evidence-gate.js +1 -1
  14. package/.agents/scripts/git-rebase-and-resolve.js +1 -1
  15. package/.agents/scripts/hierarchy-gate.js +34 -14
  16. package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
  17. package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
  18. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
  19. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
  20. package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
  21. package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
  22. package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
  23. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
  24. package/.agents/scripts/lib/baselines/writer.js +1 -1
  25. package/.agents/scripts/lib/close-validation/commands.js +188 -0
  26. package/.agents/scripts/lib/close-validation/gates.js +235 -0
  27. package/.agents/scripts/lib/close-validation/process.js +101 -0
  28. package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
  29. package/.agents/scripts/lib/close-validation/runner.js +325 -0
  30. package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
  31. package/.agents/scripts/lib/config/quality.js +6 -6
  32. package/.agents/scripts/lib/config-resolver.js +2 -5
  33. package/.agents/scripts/lib/coverage-capture.js +147 -4
  34. package/.agents/scripts/lib/cpu-pool.js +14 -0
  35. package/.agents/scripts/lib/crap-utils.js +6 -11
  36. package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
  37. package/.agents/scripts/lib/git-utils.js +24 -22
  38. package/.agents/scripts/lib/maintainability-engine.js +1 -1
  39. package/.agents/scripts/lib/maintainability-utils.js +4 -187
  40. package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
  42. package/.agents/scripts/lib/orchestration/code-review.js +90 -77
  43. package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
  44. package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
  45. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
  46. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
  47. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
  48. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
  49. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
  50. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
  51. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
  52. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
  53. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
  54. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
  55. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
  56. package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
  57. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
  58. package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
  59. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
  60. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
  61. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
  62. package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
  63. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
  64. package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
  65. package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
  67. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
  68. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
  69. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
  70. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +241 -0
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +16 -3
  73. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
  74. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
  75. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
  76. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
  77. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
  78. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
  79. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
  80. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
  81. package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
  82. package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
  83. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
  84. package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
  85. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
  86. package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
  87. package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
  88. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
  89. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
  90. package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
  91. package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
  92. package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
  93. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  94. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
  95. package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
  96. package/.agents/scripts/lib/project-root.js +17 -0
  97. package/.agents/scripts/lib/story-adjacency.js +76 -0
  98. package/.agents/scripts/lib/story-lifecycle.js +1 -1
  99. package/.agents/scripts/lib/transpile.js +93 -0
  100. package/.agents/scripts/lib/wave-runner/tick.js +4 -153
  101. package/.agents/scripts/lib/workers/crap-worker.js +1 -1
  102. package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
  103. package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
  104. package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
  105. package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
  106. package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
  107. package/.agents/scripts/providers/github/tickets.js +110 -6
  108. package/.agents/scripts/run-lint.js +9 -0
  109. package/.agents/scripts/run-tests.js +24 -4
  110. package/.agents/scripts/stories-wave-tick.js +8 -5
  111. package/.agents/scripts/story-init.js +149 -10
  112. package/.agents/scripts/sync-branch-from-base.js +1 -1
  113. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
  114. package/.agents/workflows/audit-documentation.md +226 -0
  115. package/.agents/workflows/epic-deliver.md +16 -23
  116. package/.agents/workflows/epic-plan.md +1 -1
  117. package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
  118. package/.agents/workflows/helpers/single-story-deliver.md +2 -1
  119. package/.agents/workflows/onboard.md +4 -3
  120. package/.agents/workflows/story-deliver.md +1 -1
  121. package/README.md +21 -8
  122. package/lib/cli/init.js +336 -0
  123. package/package.json +2 -1
  124. package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
  125. package/.agents/scripts/lib/close-validation.js +0 -897
  126. package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
  127. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
  128. package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
  129. package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
  130. package/.agents/scripts/lib/task-utils.js +0 -26
  131. package/.agents/scripts/story-deliver-prepare.js +0 -267
@@ -41,9 +41,34 @@ import { emitSpawnTimeoutBlockedResult } from './timeout-blocked-emitter.js';
41
41
  * Run pre-merge gates and, on a clean outcome, the bounded baseline
42
42
  * auto-refresh. Returns `{ blocked }` so the locked pipeline can
43
43
  * short-circuit on a `blocked` / `blocked-timeout` gate outcome.
44
+ *
45
+ * `runPreMergeValidation` is an injectable seam (Story #3973): the
46
+ * forwarding of `config: ctx.config` into the gate builder is load-bearing
47
+ * — without it the typecheck gate ignores `project.commands.typecheck` and
48
+ * falls back to the hardcoded `npm run typecheck`, blocking every close for
49
+ * any consumer with a non-default typecheck command. The default is the
50
+ * module-level `runPreMergeValidation`; tests inject a spy to pin that
51
+ * `config` reaches the gate builder through this call site.
52
+ *
53
+ * @param {object} ctx - The locked-pipeline context bundle.
54
+ * @param {object} [deps] - Injectable collaborators.
55
+ * @param {typeof runPreMergeValidation} [deps.runPreMergeValidationFn]
56
+ * @param {typeof runAutoRefreshSafely} [deps.runAutoRefreshSafelyFn]
44
57
  */
45
- async function runGatesAndRefresh(ctx) {
46
- const gateOutcome = await runPreMergeValidation({
58
+ export async function runGatesAndRefresh(
59
+ ctx,
60
+ {
61
+ runPreMergeValidationFn = runPreMergeValidation,
62
+ runAutoRefreshSafelyFn = runAutoRefreshSafely,
63
+ } = {},
64
+ ) {
65
+ // Story #4017 — one shared cycle-state object per close cycle. The
66
+ // gate-failure attribution retry and the post-gates auto-refresh both
67
+ // funnel through `runRefreshCommit`, which keys its idempotency token
68
+ // off this object so each baseline kind is refreshed (scored +
69
+ // committed) at most once per close.
70
+ const cycleState = { refreshedKinds: new Set(), lastRefreshSha: null };
71
+ const gateOutcome = await runPreMergeValidationFn({
47
72
  cwd: ctx.cwd,
48
73
  worktreePath: ctx.worktreePath,
49
74
  epicBranch: ctx.epicBranch,
@@ -55,6 +80,7 @@ async function runGatesAndRefresh(ctx) {
55
80
  phaseTimer: ctx.phaseTimer,
56
81
  provider: ctx.provider,
57
82
  bus: ctx.bus,
83
+ cycleState,
58
84
  });
59
85
  if (gateOutcome?.status === 'blocked') {
60
86
  return {
@@ -82,7 +108,7 @@ async function runGatesAndRefresh(ctx) {
82
108
  }),
83
109
  };
84
110
  }
85
- await runAutoRefreshSafely(
111
+ await runAutoRefreshSafelyFn(
86
112
  {
87
113
  storyId: ctx.storyId,
88
114
  epicId: ctx.epicId,
@@ -90,6 +116,7 @@ async function runGatesAndRefresh(ctx) {
90
116
  epicBranch: ctx.epicBranch,
91
117
  storyBranch: ctx.storyBranch,
92
118
  config: ctx.config,
119
+ cycleState,
93
120
  },
94
121
  { progress: ctx.progress },
95
122
  );
@@ -21,14 +21,8 @@
21
21
  * read it; this helper writes the file and hands the path to the pipeline.
22
22
  */
23
23
 
24
- // Legacy key on the post-merge-pipeline / cleanup-reconciler input bag.
25
- // Built from substrings so the migrated-subsystem grep does not match it;
26
- // the downstream helpers live outside the migrated subsystem and will
27
- // rename their parameters when their own subsystems are swept.
28
- const LEGACY_PIPELINE_CONFIG_KEY = `orches${'tration'}`;
29
-
30
24
  import { mkdir, writeFile } from 'node:fs/promises';
31
- import { emitGhSpawnCount as defaultEmitGhSpawnCount } from '../../close-validation.js';
25
+ import { emitGhSpawnCount as defaultEmitGhSpawnCount } from '../../close-validation/telemetry.js';
32
26
  import { storyArtifactPath, storyTempDir } from '../../config/temp-paths.js';
33
27
  import { gitSpawn as defaultGitSpawn } from '../../git-utils.js';
34
28
  import { clearActiveStoryEnv as defaultClearActiveStoryEnv } from '../../observability/active-story-env.js';
@@ -340,18 +334,11 @@ export async function runPostMergeClose({
340
334
  // in this order — see post-merge-pipeline.js. The `perf-summary` phase
341
335
  // inside the pipeline shells out to analyze-execution.js, which is the
342
336
  // single writer of the `<!-- structured:story-perf-summary -->` comment.
343
- // Build the legacy-shape view that downstream out-of-subsystem helpers
344
- // (post-merge-pipeline.js) still consume. The migrated cleanup-reconciler
345
- // takes `delivery` directly.
346
- const legacyPipelineBlock = {
347
- provider: 'github',
348
- github: config?.github,
349
- notifications: config?.github?.notifications,
350
- worktreeIsolation: deliveryBlock?.worktreeIsolation,
351
- runners: { deliverRunner: deliveryBlock?.deliverRunner ?? {} },
352
- };
337
+ // The pipeline phases take the resolved `delivery` block directly, same
338
+ // as the cleanup-reconciler (Story #3986 the legacy `orchestration`-keyed
339
+ // input bag is gone).
353
340
  const pipelineState = await runPostMergePipeline({
354
- [LEGACY_PIPELINE_CONFIG_KEY]: legacyPipelineBlock,
341
+ delivery: deliveryBlock,
355
342
  storyId,
356
343
  epicId,
357
344
  story,
@@ -29,12 +29,12 @@
29
29
  // no-spawn spy proves the projection path never reaches a per-kind CLI
30
30
  // subprocess.
31
31
  import * as maintainabilityKind from '../../baselines/kinds/maintainability.js';
32
+ import { buildDefaultGates as defaultBuildDefaultGates } from '../../close-validation/gates.js';
32
33
  import {
33
- buildDefaultGates as defaultBuildDefaultGates,
34
34
  formatMaintainabilityProjection as defaultFormatMaintainabilityProjection,
35
35
  projectMaintainabilityRegressions as defaultProjectMaintainabilityRegressions,
36
- runCloseValidation as defaultRunCloseValidation,
37
- } from '../../close-validation.js';
36
+ } from '../../close-validation/projections/maintainability.js';
37
+ import { runCloseValidation as defaultRunCloseValidation } from '../../close-validation/runner.js';
38
38
  import { getBaselines as defaultGetBaselines } from '../../config-resolver.js';
39
39
  import { Logger as DefaultLogger } from '../../Logger.js';
40
40
 
@@ -276,7 +276,7 @@ function detectAlreadyMerged({ cwd, storyId, epicId, lsrOut, detail, git }) {
276
276
  // from the Epic history itself: locate the integration commit whose
277
277
  // subject carries `(resolves #<id>)` / `(refs #<id>)`. Without this
278
278
  // branch, detection falls to FRESH and the resumed close re-enters the
279
- // pre-merge gate chain, which crashes in `format-autofix-scoped.js` on
279
+ // pre-merge gate chain, which crashes in the scoped format-autofix step on
280
280
  // `git diff <epicBranch>...story-<id>` because the Story ref is gone.
281
281
  if (!resolvedDetail) {
282
282
  const mc = findMergeCommitForStory({ cwd, storyId, epicId, git });
@@ -431,24 +431,35 @@ export function computeRecoveryMode({ state, resume, restart } = {}) {
431
431
  };
432
432
  }
433
433
 
434
- function dropWorktreeIfPresent({ cwd, wtPath, progress, logger }) {
434
+ function dropWorktreeIfPresent({
435
+ cwd,
436
+ wtPath,
437
+ progress,
438
+ logger,
439
+ gitSpawnFn = gitSpawn,
440
+ }) {
435
441
  if (!fs.existsSync(wtPath)) return;
436
442
  progress('RESTART', `Removing worktree ${wtPath}`);
437
- const remove = gitSpawn(cwd, 'worktree', 'remove', '--force', wtPath);
443
+ const remove = gitSpawnFn(cwd, 'worktree', 'remove', '--force', wtPath);
438
444
  if (remove.status !== 0) {
439
445
  logger.error(
440
446
  `[story-close] Worktree remove failed: ${remove.stderr || 'unknown'}. ` +
441
447
  'Attempting prune to clean stale registration.',
442
448
  );
443
449
  }
444
- gitSpawn(cwd, 'worktree', 'prune');
450
+ gitSpawnFn(cwd, 'worktree', 'prune');
445
451
  }
446
452
 
447
- function recreateStoryBranchRef({ cwd, storyBranch, epicBranch, logger }) {
448
- gitSpawn(cwd, 'branch', '-D', storyBranch);
449
- const create = gitSpawn(cwd, 'branch', storyBranch, epicBranch);
453
+ function recreateStoryBranchRef({
454
+ cwd,
455
+ storyBranch,
456
+ epicBranch,
457
+ gitSpawnFn = gitSpawn,
458
+ }) {
459
+ gitSpawnFn(cwd, 'branch', '-D', storyBranch);
460
+ const create = gitSpawnFn(cwd, 'branch', storyBranch, epicBranch);
450
461
  if (create.status !== 0) {
451
- logger.fatal(
462
+ throw new Error(
452
463
  `Failed to recreate ${storyBranch} from ${epicBranch}: ${create.stderr || 'unknown'}`,
453
464
  );
454
465
  }
@@ -460,13 +471,13 @@ function reseedWorktreeIfNeeded({
460
471
  storyId,
461
472
  storyBranch,
462
473
  progress,
463
- logger,
474
+ gitSpawnFn = gitSpawn,
464
475
  }) {
465
476
  if (!wtConfig?.enabled) return;
466
477
  const wtPath = storyWorktreePath(cwd, storyId, wtConfig.root);
467
- const add = gitSpawn(cwd, 'worktree', 'add', wtPath, storyBranch);
478
+ const add = gitSpawnFn(cwd, 'worktree', 'add', wtPath, storyBranch);
468
479
  if (add.status !== 0) {
469
- logger.fatal(
480
+ throw new Error(
470
481
  `Failed to re-seed worktree at ${wtPath}: ${add.stderr || 'unknown'}`,
471
482
  );
472
483
  }
@@ -477,8 +488,12 @@ function reseedWorktreeIfNeeded({
477
488
  * Restart path: abort any in-progress merge, drop the worktree, delete the
478
489
  * story branch ref, and re-seed branch + worktree from the Epic branch. The
479
490
  * caller then falls through to the normal fresh-close flow.
491
+ *
492
+ * Throws (never `logger.fatal`) on a failed branch recreate or worktree
493
+ * re-seed, per `rules/orchestration-error-handling.md` — a failed recreate
494
+ * MUST NOT fall through into the worktree re-seed.
480
495
  */
481
- function restartStoryState({
496
+ export function restartStoryState({
482
497
  cwd,
483
498
  orchestration,
484
499
  storyId,
@@ -486,9 +501,10 @@ function restartStoryState({
486
501
  storyBranch,
487
502
  progress = () => {},
488
503
  logger = Logger,
504
+ gitSpawnFn = gitSpawn,
489
505
  } = {}) {
490
506
  progress('RESTART', `Resetting prior state for Story #${storyId}...`);
491
- gitSpawn(cwd, 'merge', '--abort');
507
+ gitSpawnFn(cwd, 'merge', '--abort');
492
508
 
493
509
  const wtConfig = orchestration?.worktreeIsolation;
494
510
  if (wtConfig?.enabled) {
@@ -497,17 +513,18 @@ function restartStoryState({
497
513
  wtPath: storyWorktreePath(cwd, storyId, wtConfig.root),
498
514
  progress,
499
515
  logger,
516
+ gitSpawnFn,
500
517
  });
501
518
  }
502
519
 
503
- recreateStoryBranchRef({ cwd, storyBranch, epicBranch, logger });
520
+ recreateStoryBranchRef({ cwd, storyBranch, epicBranch, gitSpawnFn });
504
521
  reseedWorktreeIfNeeded({
505
522
  cwd,
506
523
  wtConfig,
507
524
  storyId,
508
525
  storyBranch,
509
526
  progress,
510
- logger,
527
+ gitSpawnFn,
511
528
  });
512
529
  }
513
530
 
@@ -563,7 +580,7 @@ export function dispatchRecovery({
563
580
  restartFn = restartStoryState,
564
581
  } = {}) {
565
582
  if (resume && restart) {
566
- logger.fatal('--resume and --restart are mutually exclusive');
583
+ throw new Error('--resume and --restart are mutually exclusive');
567
584
  }
568
585
 
569
586
  const priorPhase = detectFn({ cwd, storyId, epicId });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * lib/orchestration/story-reachability.js — Story `depends_on` graph walk.
3
+ *
4
+ * Houses the transitive-predecessor closure over the story-level
5
+ * `depends_on` graph. Extracted from `./ticket-validator-conflicts.js`
6
+ * under Story #3995 to break the `file-assumptions.js ↔
7
+ * ticket-validator-conflicts.js` import cycle: both the conflict gate and
8
+ * the wave-aware file-assumption gate need this graph traversal, so
9
+ * pulling it down into a dependency-free leaf lets both import it from
10
+ * below rather than from each other.
11
+ *
12
+ * This is a pure graph utility — no I/O, no policy. Its behaviour is
13
+ * unchanged from the original `computeStoryReachability`.
14
+ */
15
+
16
+ /**
17
+ * Compute transitive predecessor sets over the story-level `depends_on`
18
+ * graph. The returned map is `Map<storySlug, Set<storySlug>>`, where the
19
+ * set contains every story reachable by following `depends_on` edges from
20
+ * the key (i.e. every story the key transitively depends on).
21
+ *
22
+ * BFS, no cycles assumed — callers must run `assertAcyclic` first.
23
+ *
24
+ * Exported so both the conflict gate (`ticket-validator-conflicts.js`)
25
+ * and the wave-aware file-assumption gate (`file-assumptions.js`) can
26
+ * reuse the same transitive-predecessor walk rather than re-deriving the
27
+ * `depends_on` closure.
28
+ */
29
+ export function computeStoryReachability(stories) {
30
+ const reach = new Map();
31
+ for (const story of stories) reach.set(story.slug, new Set());
32
+ for (const story of stories) {
33
+ const visited = reach.get(story.slug);
34
+ const stack = [...(story.depends_on ?? [])];
35
+ while (stack.length > 0) {
36
+ const next = stack.pop();
37
+ if (!reach.has(next)) continue;
38
+ if (visited.has(next)) continue;
39
+ visited.add(next);
40
+ const nextStory = stories.find((s) => s.slug === next);
41
+ if (nextStory && Array.isArray(nextStory.depends_on)) {
42
+ for (const dep of nextStory.depends_on) stack.push(dep);
43
+ }
44
+ }
45
+ }
46
+ return reach;
47
+ }
@@ -1,4 +1,5 @@
1
1
  import { collectStoryAssumptionEntries } from './file-assumptions.js';
2
+ import { computeStoryReachability } from './story-reachability.js';
2
3
 
3
4
  /**
4
5
  * Cross-Story path-conflict & implicit-dependency findings.
@@ -66,7 +67,7 @@ const DEFAULT_POLICY = Object.freeze({
66
67
  * - exact path — `lib/orchestration/lifecycle/listeners/index.js`
67
68
  * - `**` suffix — `**\/listeners/index.js` (matches any depth)
68
69
  */
69
- const DEFAULT_REGISTRY_PATTERNS = Object.freeze([
70
+ export const DEFAULT_REGISTRY_PATTERNS = Object.freeze([
70
71
  'lib/orchestration/lifecycle/listeners/index.js',
71
72
  '**/listeners/index.js',
72
73
  '**/handlers/index.js',
@@ -242,38 +243,6 @@ function indexConsumers(stories, producers) {
242
243
  return consumers;
243
244
  }
244
245
 
245
- /**
246
- * Compute transitive predecessor sets over the story-level `depends_on`
247
- * graph. The returned map is `Map<storySlug, Set<storySlug>>`, where the
248
- * set contains every story reachable by following `depends_on` edges from
249
- * the key (i.e. every story the key transitively depends on).
250
- *
251
- * BFS, no cycles assumed — callers must run `assertAcyclic` first.
252
- *
253
- * Exported so the wave-aware file-assumption gate
254
- * (`file-assumptions.js`) can reuse the same transitive-predecessor walk
255
- * rather than re-deriving the `depends_on` closure.
256
- */
257
- export function computeStoryReachability(stories) {
258
- const reach = new Map();
259
- for (const story of stories) reach.set(story.slug, new Set());
260
- for (const story of stories) {
261
- const visited = reach.get(story.slug);
262
- const stack = [...(story.depends_on ?? [])];
263
- while (stack.length > 0) {
264
- const next = stack.pop();
265
- if (!reach.has(next)) continue;
266
- if (visited.has(next)) continue;
267
- visited.add(next);
268
- const nextStory = stories.find((s) => s.slug === next);
269
- if (nextStory && Array.isArray(nextStory.depends_on)) {
270
- for (const dep of nextStory.depends_on) stack.push(dep);
271
- }
272
- }
273
- }
274
- return reach;
275
- }
276
-
277
246
  function inSameWave(reach, slugA, slugB) {
278
247
  if (slugA === slugB) return false;
279
248
  const a = reach.get(slugA);
@@ -8,26 +8,25 @@
8
8
  * Story #1848 so the verb-family split (`reads` / `state` / `bulk`) is
9
9
  * complete and the parent collapses to a pure re-export facade.
10
10
  *
11
- * Note on the cycle with `./state.js`: `cascadeCompletion` recursively
12
- * calls `transitionTicketState`, which in turn — when the cascade flag is
13
- * on calls back into `cascadeCompletion`. ESM tolerates the cycle
14
- * because every binding is resolved at call-time. Both modules complete
15
- * evaluation before any of their exported functions run.
11
+ * Story #3995 the single-ticket mutators (`transitionTicketState`,
12
+ * `toggleTasklistCheckbox`, `postStructuredComment`) moved to the leaf
13
+ * `./transition.js`, so this module now depends **downward** on
14
+ * `transition.js` and the former `state.js bulk.js` import cycle is
15
+ * gone. `cascadeCompletion` still recursively transitions parents via
16
+ * the imported `transitionTicketState`; the reverse call
17
+ * (`transitionTicketState → cascadeParentState`) is injected into
18
+ * `transition.js` by `state.js` via `registerCascadeRunner` rather than
19
+ * imported, which is what keeps the graph acyclic.
16
20
  */
17
21
 
18
22
  import { Logger } from '../../Logger.js';
19
23
  import { TYPE_LABELS } from '../../label-constants.js';
20
- import { dispatchCascadeGroups, groupByAncestor } from '../cascade-grouping.js';
21
24
  import { ALL_STATES, STATE_LABELS } from './reads.js';
22
25
  import {
23
26
  postStructuredComment,
24
27
  toggleTasklistCheckbox,
25
28
  transitionTicketState,
26
- } from './state.js';
27
-
28
- // Re-export `groupByAncestor` so external callers that imported it from
29
- // the ticketing facade continue to work after the verb-family split.
30
- export { groupByAncestor };
29
+ } from './transition.js';
31
30
 
32
31
  /**
33
32
  * Retry budget for transient `gh` failures (rate limit, secondary rate limit,
@@ -217,9 +216,8 @@ export function logCascadePartialFailures(ticketId, cascade) {
217
216
 
218
217
  /**
219
218
  * Per-parent body of {@link cascadeCompletion}. Pulled out so the outer
220
- * function can dispatch disjoint groups in parallel while each parent
221
- * still runs against its own captured logger, ensuring byte-identical
222
- * log output across serial and parallel execution paths.
219
+ * walk stays a thin sequential loop while the per-parent work runs under
220
+ * the per-parent cascade lock.
223
221
  *
224
222
  * @param {import('../../ITicketingProvider.js').ITicketingProvider} provider
225
223
  * @param {number} ticketId - The ticket whose `agent::done` transition
@@ -372,19 +370,13 @@ async function processCascadeParentLocked(
372
370
  * Then checks if parent's sub-tickets are ALL DONE.
373
371
  * If yes, transitions parent to DONE and cascades up.
374
372
  *
375
- * Parents are partitioned into disjoint groups by shared ancestor
376
- * ({@link groupByAncestor}). Groups run in parallel via `Promise.all`,
377
- * but parents **within** a group run strictly sequentially in input
378
- * order — concurrent transitions against a shared ancestor would race
379
- * the "all children done?" check. Within each parent, sibling reads
380
- * fan out via `getSubTickets(parentId, { fresh: true })` with the
381
- * concurrency cap (8) applied inside `getSubTickets`.
382
- *
383
- * Log output is captured per parent into a buffered logger and flushed
384
- * to the real {@link Logger} after all groups resolve, in the original
385
- * `parsedParents` order. The visible log stream is therefore
386
- * byte-identical to a serial baseline; only the I/O between parents in
387
- * disjoint groups overlaps.
373
+ * Parents run strictly sequentially in input order (Story #4017 —
374
+ * fan-out is <= 1 under the 3-tier hierarchy, so the former
375
+ * shared-ancestor grouping / parallel dispatch was deleted); concurrent
376
+ * transitions against a shared ancestor would race the "all children
377
+ * done?" check. Within each parent, sibling reads fan out via
378
+ * `getSubTickets(parentId, { fresh: true })` with the concurrency cap
379
+ * (8) applied inside `getSubTickets`.
388
380
  *
389
381
  * Per-parent errors are isolated: a failure updating one parent (network,
390
382
  * permission, stale ticket) never discards progress on sibling parents.
@@ -450,26 +442,19 @@ export async function cascadeCompletion(provider, ticketId, opts = {}) {
450
442
  return { cascadedTo: [], failed: [] };
451
443
  }
452
444
 
453
- // Partition parents by shared ancestor (disjoint groups run in
454
- // parallel; within-group parents stay sequential to avoid racing the
455
- // shared ancestor's "all children done?" check), then dispatch the
456
- // per-parent work via `dispatchCascadeGroups` so the buffered-flush
457
- // bookkeeping lives in `cascade-grouping.js`.
458
- const groups = await groupByAncestor(parsedParents, provider);
459
- const results = await dispatchCascadeGroups({
460
- parsedParents,
461
- groups,
462
- flushLogger: opts._logger ?? Logger,
463
- processParent: (parentId, logger) =>
464
- processCascadeParent(provider, ticketId, parentId, {
465
- notify: opts.notify,
466
- _logger: logger,
467
- }),
468
- });
469
-
445
+ // Story #4017 under the 3-tier hierarchy a ticket has at most one
446
+ // parent, so the shared-ancestor grouping / parallel-group dispatch
447
+ // machinery was deleted. Parents (fan-out <= 1
448
+ // in practice; the loop stays general for the body-reference fallback)
449
+ // run strictly sequentially, which trivially preserves the
450
+ // shared-ancestor safety invariant the grouping used to enforce.
470
451
  const cascadedTo = [];
471
452
  const failed = [];
472
- for (const r of results) {
453
+ for (const parentId of parsedParents) {
454
+ const r = await processCascadeParent(provider, ticketId, parentId, {
455
+ notify: opts.notify,
456
+ _logger: opts._logger,
457
+ });
473
458
  cascadedTo.push(...r.cascadedTo);
474
459
  failed.push(...r.failed);
475
460
  }
@@ -530,12 +515,11 @@ export function deriveParentState(siblings) {
530
515
  * recursive cascade" progress comment, Epic exclusion) are preserved
531
516
  * verbatim.
532
517
  *
533
- * Resilience matches {@link cascadeCompletion}: disjoint parent groups
534
- * run in parallel via {@link dispatchCascadeGroups}; parents within a
535
- * group run sequentially; the per-parent lock from
536
- * {@link withParentCascadeLock} prevents races on shared ancestors; and
537
- * per-parent errors are isolated so a sibling parent's failure does not
538
- * discard work on the others.
518
+ * Resilience matches {@link cascadeCompletion}: parents run
519
+ * sequentially; the per-parent lock from {@link withParentCascadeLock}
520
+ * prevents races on shared ancestors; and per-parent errors are
521
+ * isolated so a sibling parent's failure does not discard work on the
522
+ * others.
539
523
  *
540
524
  * @param {import('../../ITicketingProvider.js').ITicketingProvider} provider
541
525
  * @param {number} ticketId
@@ -572,21 +556,15 @@ export async function cascadeParentState(provider, ticketId, opts = {}) {
572
556
  const parsedParents = await resolveParentIds(provider, ticket, ticketId);
573
557
  if (parsedParents.length === 0) return { cascadedTo: [], failed: [] };
574
558
 
575
- const groups = await groupByAncestor(parsedParents, provider);
576
- const results = await dispatchCascadeGroups({
577
- parsedParents,
578
- groups,
579
- flushLogger: opts._logger ?? Logger,
580
- processParent: (parentId, logger) =>
581
- processStateCascadeParent(provider, parentId, {
582
- notify: opts.notify,
583
- _logger: logger,
584
- }),
585
- });
586
-
559
+ // Story #4017 sequential per-parent walk (fan-out <= 1 under the
560
+ // 3-tier hierarchy); see cascadeCompletion for the rationale.
587
561
  const cascadedTo = [];
588
562
  const failed = [];
589
- for (const r of results) {
563
+ for (const parentId of parsedParents) {
564
+ const r = await processStateCascadeParent(provider, parentId, {
565
+ notify: opts.notify,
566
+ _logger: opts._logger,
567
+ });
590
568
  cascadedTo.push(...r.cascadedTo);
591
569
  failed.push(...r.failed);
592
570
  }
@@ -151,6 +151,15 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
151
151
  // from it (`deriveRiskEnvelope`). One entry per Epic; re-plans upsert in
152
152
  // place. Schema: `.agents/schemas/risk-verdict.schema.json`.
153
153
  'risk-verdict',
154
+ // Story #4019 — `epic-plan-lease-guard.js` upserts a `plan-lease`
155
+ // comment on the Epic at lease-acquire time, recording the claiming
156
+ // operator and the claim timestamp. `/epic-plan` emits no
157
+ // `story.heartbeat`, so this claim-time is the liveness signal that
158
+ // makes the documented `--steal` contract decidable: a foreign claim
159
+ // older than the lease TTL is reclaimed automatically; a fresh one
160
+ // refuses with the claim age. One entry per Epic; re-acquires upsert
161
+ // in place.
162
+ 'plan-lease',
154
163
  ]);
155
164
 
156
165
  export const WAVE_TYPE_PATTERN = WAVE_MARKER_RE;