mandrel 2.63.0 → 2.65.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 (70) hide show
  1. package/.agents/agents/acceptance-critic.md +3 -2
  2. package/.agents/agents/auditor.md +3 -2
  3. package/.agents/agents/plan-critic.md +3 -2
  4. package/.agents/agents/story-worker.md +2 -2
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +1 -9
  7. package/.agents/docs/configuration.md +8 -7
  8. package/.agents/schemas/agentrc.schema.json +6 -13
  9. package/.agents/schemas/audit-rules.schema.json +1 -1
  10. package/.agents/schemas/story-deliver-terminal.schema.json +5 -0
  11. package/.agents/scripts/bootstrap.js +8 -2
  12. package/.agents/scripts/check-context-budget.js +1 -1
  13. package/.agents/scripts/lib/ITicketingProvider.js +1 -3
  14. package/.agents/scripts/lib/audit-suite/findings.js +1 -17
  15. package/.agents/scripts/lib/audit-suite/frontmatter.js +0 -28
  16. package/.agents/scripts/lib/audit-suite/index.js +0 -6
  17. package/.agents/scripts/lib/audit-suite/selector.js +0 -31
  18. package/.agents/scripts/lib/bootstrap/agents-md-fold.js +156 -0
  19. package/.agents/scripts/lib/bootstrap/commit-push.js +1 -1
  20. package/.agents/scripts/lib/bootstrap/manifest.js +2 -2
  21. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +13 -29
  22. package/.agents/scripts/lib/config/review-chain-default.js +13 -0
  23. package/.agents/scripts/lib/config-settings-schema-delivery.js +2 -2
  24. package/.agents/scripts/lib/config-settings-schema-quality.js +11 -13
  25. package/.agents/scripts/lib/doc-tiers.js +25 -6
  26. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  27. package/.agents/scripts/lib/observability/metrics-ledger.js +0 -72
  28. package/.agents/scripts/lib/orchestration/ci-red-handling.js +73 -0
  29. package/.agents/scripts/lib/orchestration/code-review.js +11 -6
  30. package/.agents/scripts/lib/orchestration/deliver-recover.js +56 -11
  31. package/.agents/scripts/lib/orchestration/epic-rollup.js +29 -12
  32. package/.agents/scripts/lib/orchestration/merge-block-class.js +20 -4
  33. package/.agents/scripts/lib/orchestration/merge-poll.js +41 -22
  34. package/.agents/scripts/lib/orchestration/required-checks.js +147 -0
  35. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +203 -0
  36. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +6 -4
  37. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -2
  38. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +1 -0
  39. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +0 -12
  40. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +135 -20
  41. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +112 -82
  42. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +72 -5
  43. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +12 -87
  44. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +3 -0
  45. package/.agents/scripts/lib/templates/decomposer-prompts.js +5 -24
  46. package/.agents/scripts/pr-watch-with-update.js +13 -19
  47. package/.agents/scripts/providers/github/issues.js +14 -23
  48. package/.agents/scripts/sync-claude-agents.js +1 -1
  49. package/.agents/workflows/audit-quality.md +42 -7
  50. package/.agents/workflows/helpers/acceptance-self-eval.md +1 -1
  51. package/.agents/workflows/helpers/code-review.md +15 -38
  52. package/.agents/workflows/helpers/deliver-reference.md +4 -2
  53. package/.agents/workflows/helpers/deliver-story.md +3 -0
  54. package/.agents/workflows/helpers/plan-reference.md +9 -8
  55. package/.agents/workflows/mandrel-deliver.md +2 -1
  56. package/.agents/workflows/mandrel-plan.md +10 -7
  57. package/.agents/workflows/mandrel-update.md +5 -3
  58. package/docs/CHANGELOG.md +38 -0
  59. package/lib/cli/claude-code-version.js +73 -0
  60. package/lib/cli/doctor.js +2 -2
  61. package/lib/cli/registry.js +9 -0
  62. package/lib/cli/uninstall.js +37 -9
  63. package/lib/migrations/index.js +2 -0
  64. package/lib/migrations/steps/2.65.0-fold-claude-md-into-agents-md.js +38 -0
  65. package/package.json +2 -1
  66. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +0 -99
  67. package/.agents/scripts/lib/audit-suite/runner.js +0 -205
  68. package/.agents/scripts/lib/audit-suite/substitutions.js +0 -96
  69. package/.agents/scripts/lib/audit-suite/workflow-loader.js +0 -37
  70. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +0 -234
@@ -282,11 +282,61 @@ async function stepPlanRunLabelReap({
282
282
  }
283
283
 
284
284
  /**
285
- * Run the post-land tail, sequentially. The ref reap and fast-forward (the
286
- * local-checkout mutations, reap first) run under a cross-process lock on the
287
- * main checkout, since concurrent closes race on the base ref and worktree
288
- * registry; GitHub steps stay outside it. The lock is best-effort: on
289
- * timeout the mutations run anyway.
285
+ * The local-checkout mutations (ref reap first, then fast-forward) under a
286
+ * cross-process lock on the main checkout, since concurrent closes race on
287
+ * the base ref and worktree registry. Best-effort: on timeout they run anyway.
288
+ */
289
+ async function runLockedLocalSteps({
290
+ storyId,
291
+ storyBranch,
292
+ baseBranch,
293
+ cwd,
294
+ config,
295
+ progress,
296
+ gitSpawnFn,
297
+ planFastForwardFn,
298
+ executeFastForwardFn,
299
+ acquireLockWithWaitFn,
300
+ }) {
301
+ const lockCfg = config?.delivery?.postLandLock ?? {};
302
+ const lock = await acquireLockWithWaitFn({
303
+ lockPath: postLandLockPath(cwd),
304
+ waitMs: lockCfg.waitMs,
305
+ pollMs: lockCfg.pollMs,
306
+ timeoutMs: lockCfg.timeoutMs,
307
+ ownerId: `post-land-${storyId}`,
308
+ });
309
+ if (!lock.acquired) {
310
+ progress?.(
311
+ 'POST-LAND',
312
+ `⚠️ post-land lock not acquired (${lock.reason}); proceeding unserialized.`,
313
+ );
314
+ }
315
+ try {
316
+ const refCleanup = await step(
317
+ () => stepRefCleanup({ cwd, storyBranch, progress, gitSpawnFn }),
318
+ { name: 'local ref cleanup', progress },
319
+ );
320
+ const baseFastForward = await step(
321
+ () =>
322
+ stepBaseFastForward({
323
+ cwd,
324
+ baseBranch,
325
+ progress,
326
+ planFastForwardFn,
327
+ executeFastForwardFn,
328
+ }),
329
+ { name: 'base fast-forward', progress },
330
+ );
331
+ return { refCleanup, baseFastForward };
332
+ } finally {
333
+ if (lock.acquired) lock.release();
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Run the post-land tail: friction markers, then the GitHub steps and the
339
+ * locked local steps concurrently, then temp purge, then lease release.
290
340
  *
291
341
  * @param {object} args
292
342
  * @param {number} args.storyId
@@ -348,87 +398,67 @@ export async function runPostLandTail({
348
398
  config,
349
399
  });
350
400
 
351
- const followUps = await step(
352
- () =>
353
- stepFollowUps({
354
- storyId,
355
- provider,
356
- config,
357
- cwd,
358
- progress,
359
- captureStoryFollowUpsFn,
360
- }),
361
- { name: 'follow-up capture', progress },
362
- );
363
- const statusResync = await step(
364
- () =>
365
- stepStatusResync({
366
- storyId,
367
- provider,
368
- config,
369
- progress,
370
- reassertStatusColumnFn,
371
- }),
372
- { name: 'status-column resync', progress },
373
- );
374
- await step(
375
- () =>
376
- stepPlanRunLabelReap({
377
- storyId,
378
- provider,
379
- progress,
380
- reapPlanRunLabelsForStoryFn,
381
- }),
382
- { name: 'plan-run label reap', progress },
383
- );
384
-
385
- const epicRollup = await step(
386
- () =>
387
- stepEpicRollup({
388
- storyId,
389
- provider,
390
- config,
391
- progress,
392
- rollUpEpicForStoryFn,
393
- }),
394
- { name: 'epic rollup', progress },
395
- );
396
-
397
- const lockCfg = config?.delivery?.postLandLock ?? {};
398
- const lock = await acquireLockWithWaitFn({
399
- lockPath: postLandLockPath(cwd),
400
- waitMs: lockCfg.waitMs,
401
- pollMs: lockCfg.pollMs,
402
- timeoutMs: lockCfg.timeoutMs,
403
- ownerId: `post-land-${storyId}`,
404
- });
405
- if (!lock.acquired) {
406
- progress?.(
407
- 'POST-LAND',
408
- `⚠️ post-land lock not acquired (${lock.reason}); proceeding unserialized.`,
409
- );
410
- }
411
- let refCleanup;
412
- let baseFastForward;
413
- try {
414
- refCleanup = await step(
415
- () => stepRefCleanup({ cwd, storyBranch, progress, gitSpawnFn }),
416
- { name: 'local ref cleanup', progress },
417
- );
418
- baseFastForward = await step(
401
+ // The GitHub steps are independent of each other and of the local
402
+ // lock-held mutations, so all of them run concurrently.
403
+ const [followUps, statusResync, , epicRollup, local] = await Promise.all([
404
+ step(
419
405
  () =>
420
- stepBaseFastForward({
406
+ stepFollowUps({
407
+ storyId,
408
+ provider,
409
+ config,
421
410
  cwd,
422
- baseBranch,
423
411
  progress,
424
- planFastForwardFn,
425
- executeFastForwardFn,
412
+ captureStoryFollowUpsFn,
426
413
  }),
427
- { name: 'base fast-forward', progress },
428
- );
429
- } finally {
430
- if (lock.acquired) lock.release();
431
- }
414
+ { name: 'follow-up capture', progress },
415
+ ),
416
+ step(
417
+ () =>
418
+ stepStatusResync({
419
+ storyId,
420
+ provider,
421
+ config,
422
+ progress,
423
+ reassertStatusColumnFn,
424
+ }),
425
+ { name: 'status-column resync', progress },
426
+ ),
427
+ step(
428
+ () =>
429
+ stepPlanRunLabelReap({
430
+ storyId,
431
+ provider,
432
+ progress,
433
+ reapPlanRunLabelsForStoryFn,
434
+ }),
435
+ { name: 'plan-run label reap', progress },
436
+ ),
437
+ step(
438
+ () =>
439
+ stepEpicRollup({
440
+ storyId,
441
+ provider,
442
+ config,
443
+ progress,
444
+ rollUpEpicForStoryFn,
445
+ }),
446
+ { name: 'epic rollup', progress },
447
+ ),
448
+ runLockedLocalSteps({
449
+ storyId,
450
+ storyBranch,
451
+ baseBranch,
452
+ cwd,
453
+ config,
454
+ progress,
455
+ gitSpawnFn,
456
+ planFastForwardFn,
457
+ executeFastForwardFn,
458
+ acquireLockWithWaitFn,
459
+ }),
460
+ ]);
461
+ const { refCleanup, baseFastForward } = local;
432
462
 
433
463
  // After every step that reads the temp artifacts; `signals.ndjson` survives.
434
464
  const tempPurge = await step(
@@ -33,6 +33,7 @@ import { runConfirmMergePhase } from './phases/confirm-merge.js';
33
33
  import { runGraphqlPreflight } from './phases/graphql-preflight.js';
34
34
  import { lockWaitPending } from './phases/lock-wait-pending.js';
35
35
  import { parseCloseOptions, resolveWaitForMerge } from './phases/options.js';
36
+ import { runPostLandTail } from './phases/post-land.js';
36
37
  import { ensurePullRequestWith } from './phases/pull-request.js';
37
38
  import { pushStoryBranch } from './phases/push.js';
38
39
  import { handleCriticalReviewBlock } from './phases/review-block.js';
@@ -42,12 +43,54 @@ import { runWrongTreeGuardPhase } from './phases/wrong-tree-guard.js';
42
43
 
43
44
  const progress = Logger.createProgress('single-story-close', { stderr: true });
44
45
 
46
+ /**
47
+ * Wall-clock seconds per named phase; each transition logs the phase it ends.
48
+ *
49
+ * @param {() => number} [nowMs]
50
+ */
51
+ function createPhaseTimer(nowMs = Date.now) {
52
+ const durations = {};
53
+ let current = null;
54
+ let since = 0;
55
+ const end = () => {
56
+ if (current === null) return;
57
+ const seconds = Math.round((nowMs() - since) / 100) / 10;
58
+ durations[current] = (durations[current] ?? 0) + seconds;
59
+ progress('TIMING', `⏱ ${current}: ${seconds}s`);
60
+ current = null;
61
+ };
62
+ return {
63
+ enter(phase) {
64
+ end();
65
+ if (phase === 'init') return;
66
+ current = phase;
67
+ since = nowMs();
68
+ },
69
+ finish() {
70
+ end();
71
+ return Object.keys(durations).length > 0 ? { ...durations } : null;
72
+ },
73
+ stamp(terminal) {
74
+ const phaseDurations = this.finish();
75
+ if (phaseDurations) terminal.phaseDurations = phaseDurations;
76
+ },
77
+ };
78
+ }
79
+
80
+ const UNTIMED = Object.freeze({ stamp() {} });
81
+
45
82
  /**
46
83
  * The single terminal writer: the result summary, the envelope callers parse,
47
84
  * and terminal friction — so no ending can forget one. Must be awaited: the
48
85
  * CLI `process.exit`s as soon as `main` resolves.
49
86
  */
50
- async function emitTerminal({ terminal, result, config }) {
87
+ async function emitTerminal({
88
+ terminal,
89
+ result,
90
+ config,
91
+ phaseTimer = UNTIMED,
92
+ }) {
93
+ phaseTimer.stamp(terminal);
51
94
  if (result) {
52
95
  emitTerseResult({
53
96
  label: 'STORY CLOSE RESULT',
@@ -516,8 +559,10 @@ export async function runSingleStoryClose({
516
559
  // builds the `failed` envelope from those tags.
517
560
  let phase = 'init';
518
561
  let observedGates = null;
562
+ const phaseTimer = createPhaseTimer();
519
563
  const setPhase = (next) => {
520
564
  phase = next;
565
+ phaseTimer.enter(next);
521
566
  };
522
567
  const setObservedGates = (gates) => {
523
568
  observedGates = gates;
@@ -527,6 +572,7 @@ export async function runSingleStoryClose({
527
572
  options,
528
573
  setPhase,
529
574
  setObservedGates,
575
+ phaseTimer,
530
576
  injectedProvider,
531
577
  injectedConfig,
532
578
  injectedNotify,
@@ -539,6 +585,7 @@ export async function runSingleStoryClose({
539
585
  });
540
586
  } catch (err) {
541
587
  if (err && typeof err === 'object') {
588
+ err.closePhaseDurations = phaseTimer.finish();
542
589
  if (!err.closePhase) err.closePhase = phase;
543
590
  if (!err.closeGates && observedGates) err.closeGates = observedGates;
544
591
  }
@@ -617,6 +664,10 @@ async function finishWithMergeWait(prCtx, deps) {
617
664
  progress,
618
665
  injectedGh: deps.injectedGh,
619
666
  injectedNotify: deps.injectedNotify,
667
+ runPostLandTailFn: (args) => {
668
+ deps.setPhase('post-land');
669
+ return runPostLandTail(args);
670
+ },
620
671
  });
621
672
  const terminal = terminalFromWaitOutcome({
622
673
  waitOutcome,
@@ -650,7 +701,12 @@ async function finishWithMergeWait(prCtx, deps) {
650
701
  }),
651
702
  landCompleted: waitOutcome.confirmed === true,
652
703
  });
653
- await emitTerminal({ terminal, result, config: prCtx.config });
704
+ await emitTerminal({
705
+ terminal,
706
+ result,
707
+ config: prCtx.config,
708
+ phaseTimer: prCtx.phaseTimer,
709
+ });
654
710
  reportWaitTerminal(terminal, { storyId: prCtx.storyId, prUrl: prCtx.prUrl });
655
711
  return { success: terminal.status === 'landed', result, terminal };
656
712
  }
@@ -698,7 +754,12 @@ async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
698
754
  nextCommand: NEXT_COMMANDS.confirmMerge(prCtx.storyId),
699
755
  elapsedSeconds: elapsedSecondsSince(prCtx.startedAtMs),
700
756
  });
701
- await emitTerminal({ terminal, result, config: prCtx.config });
757
+ await emitTerminal({
758
+ terminal,
759
+ result,
760
+ config: prCtx.config,
761
+ phaseTimer: prCtx.phaseTimer,
762
+ });
702
763
  progress(
703
764
  'DONE',
704
765
  `✅ Story #${prCtx.storyId}: PR ready → ${prCtx.prUrl} (${waitForMergeReason})`,
@@ -714,13 +775,16 @@ async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
714
775
  * config: object, startedAtMs: number }} ctx
715
776
  * @returns {Promise<{ success: false, result: object, terminal: object }>}
716
777
  */
717
- async function finishDeferred(lockWait, { config, startedAtMs, ...ids }) {
778
+ async function finishDeferred(
779
+ lockWait,
780
+ { config, startedAtMs, phaseTimer, ...ids },
781
+ ) {
718
782
  const { result, terminal, note } = lockWaitPending({
719
783
  ...ids,
720
784
  lockWait,
721
785
  elapsedSeconds: elapsedSecondsSince(startedAtMs),
722
786
  });
723
- await emitTerminal({ terminal, result, config });
787
+ await emitTerminal({ terminal, result, config, phaseTimer });
724
788
  progress('PENDING', note);
725
789
  return { success: false, result, terminal };
726
790
  }
@@ -759,6 +823,7 @@ async function runClosePipeline({
759
823
  options,
760
824
  setPhase,
761
825
  setObservedGates,
826
+ phaseTimer,
762
827
  injectedProvider,
763
828
  injectedConfig,
764
829
  injectedNotify,
@@ -848,6 +913,7 @@ async function runClosePipeline({
848
913
  baseBranch,
849
914
  config,
850
915
  startedAtMs,
916
+ phaseTimer,
851
917
  });
852
918
  }
853
919
 
@@ -943,6 +1009,7 @@ async function runClosePipeline({
943
1009
  directMerged,
944
1010
  config,
945
1011
  startedAtMs,
1012
+ phaseTimer,
946
1013
  lockWait: prePush.lockWait,
947
1014
  gates: closeEnvelopeGates(options, prePush.validationGates, reviewOverride),
948
1015
  };
@@ -3,23 +3,20 @@
3
3
  * maker-blind `runCodeReview` invocation out of any phase file.
4
4
  */
5
5
 
6
- import { countChangedLines } from '../../../audit-suite/index.js';
7
6
  import { gitSpawn } from '../../../git-utils.js';
8
- import { appendFindingsYield } from '../../../observability/metrics-ledger.js';
9
7
  import { computeChangeSet } from '../../change-set.js';
10
8
  import { runCodeReview } from '../../code-review.js';
11
- import { runLocalLensReview } from './local-lens-review.js';
12
9
 
13
10
  /**
14
- * Run the local-lens pass and `runCodeReview` over one change set and return
15
- * the review result. Throws propagate; the caller picks the advisory posture.
16
- * Review depth is derived by `runCodeReview` from the changed files and is
17
- * input-only — it never alters the output envelope.
11
+ * Run `runCodeReview` over one change set and return the review result.
12
+ * Throws propagate; the caller picks the advisory posture. Review depth is
13
+ * derived by `runCodeReview` from the changed files and is input-only — it
14
+ * never alters the output envelope.
18
15
  *
19
- * The diff is enumerated exactly once here and injected into both consumers,
20
- * so lens roster and review depth agree on what changed even if a commit
21
- * lands in between. An unenumerable diff injects `null` ("already tried"),
22
- * which both consumers honour without re-spawning git.
16
+ * The diff is enumerated exactly once here and injected into the review, so
17
+ * review depth scores the same file set the change set names even if a
18
+ * commit lands in between. An unenumerable diff injects `null` ("already
19
+ * tried"), which the review honours without re-spawning git.
23
20
  *
24
21
  * @param {{
25
22
  * storyId: number|string,
@@ -32,12 +29,9 @@ import { runLocalLensReview } from './local-lens-review.js';
32
29
  * gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
33
30
  * computeChangeSetFn?: typeof computeChangeSet,
34
31
  * runCodeReviewFn?: typeof runCodeReview,
35
- * runLocalLensReviewFn?: typeof runLocalLensReview,
36
- * countChangedLinesFn?: typeof countChangedLines,
37
- * appendFindingsYieldFn?: typeof appendFindingsYield,
38
32
  * }} args
39
- * @returns {Promise<object>} The `runCodeReview` result plus
40
- * `localLensReview` and the computed `changeSet`.
33
+ * @returns {Promise<object>} The `runCodeReview` result plus the computed
34
+ * `changeSet`.
41
35
  */
42
36
  export async function runStoryReviewCore({
43
37
  storyId,
@@ -50,24 +44,12 @@ export async function runStoryReviewCore({
50
44
  gitSpawnFn = gitSpawn,
51
45
  computeChangeSetFn = computeChangeSet,
52
46
  runCodeReviewFn = runCodeReview,
53
- runLocalLensReviewFn = runLocalLensReview,
54
- countChangedLinesFn = countChangedLines,
55
- appendFindingsYieldFn = appendFindingsYield,
56
47
  }) {
57
- const storyIdNum = Number(storyId);
58
-
59
48
  const changeSet = computeChangeSetFn({ baseRef, headRef, gitSpawnFn });
60
49
 
61
- // Line count for the lens diff-floor, probed only for a non-empty file set;
62
- // `null` = unknown, and the floor fails open.
63
- const changedLineCount =
64
- Array.isArray(changeSet.files) && changeSet.files.length > 0
65
- ? countChangedLinesFn({ baseRef, headRef, gitSpawnFn })
66
- : null;
67
-
68
50
  const opts = {
69
51
  scope: 'story',
70
- ticketId: storyIdNum,
52
+ ticketId: Number(storyId),
71
53
  baseRef,
72
54
  headRef,
73
55
  provider,
@@ -82,63 +64,6 @@ export async function runStoryReviewCore({
82
64
  opts.commentTargetId = commentTargetId;
83
65
  }
84
66
 
85
- const localLensReview = await runLocalLensReviewFn({
86
- baseRef,
87
- headRef,
88
- changedFiles: changeSet.files,
89
- changedLineCount,
90
- storyId: storyIdNum,
91
- progress,
92
- progressTag,
93
- gitSpawnFn,
94
- });
95
-
96
67
  const result = await runCodeReviewFn(opts);
97
-
98
- // Best-effort findings-yield ledger, for tuning the roster on measurement.
99
- try {
100
- const yieldEntries = buildLensYieldEntries(localLensReview);
101
- if (yieldEntries !== null) {
102
- await appendFindingsYieldFn({
103
- storyId: storyIdNum,
104
- cli: 'story-close-review',
105
- lenses: yieldEntries,
106
- diffFloor: localLensReview?.floorSkip ?? null,
107
- });
108
- }
109
- } catch (err) {
110
- progress(
111
- progressTag,
112
- `⚠️ findings-yield ledger append failed (continuing): ${err?.message ?? err}`,
113
- );
114
- }
115
-
116
- return { ...result, localLensReview, changeSet };
117
- }
118
-
119
- /**
120
- * One findings-yield entry per matched lens; `null` for an empty roster.
121
- *
122
- * @param {object|null|undefined} localLensReview
123
- * @returns {Array<{ lens: string, findings: number, skippedByFloor: boolean }>|null}
124
- */
125
- function buildLensYieldEntries(localLensReview) {
126
- const lenses = Array.isArray(localLensReview?.lenses)
127
- ? localLensReview.lenses.filter((l) => typeof l === 'string' && l.length)
128
- : [];
129
- if (lenses.length === 0) return null;
130
- const skippedByFloor = localLensReview?.floorSkip?.skip === true;
131
- const findingsByLens = new Map();
132
- for (const finding of localLensReview?.materialized?.findings ?? []) {
133
- if (typeof finding?.audit !== 'string') continue;
134
- findingsByLens.set(
135
- finding.audit,
136
- (findingsByLens.get(finding.audit) ?? 0) + 1,
137
- );
138
- }
139
- return lenses.map((lens) => ({
140
- lens,
141
- findings: skippedByFloor ? 0 : (findingsByLens.get(lens) ?? 0),
142
- skippedByFloor,
143
- }));
68
+ return { ...result, changeSet };
144
69
  }
@@ -116,6 +116,7 @@ function compact(obj) {
116
116
  * @param {object|null} [args.waitBudget]
117
117
  * @param {{ waitedSeconds: number, expired: boolean }|null} [args.lockWait]
118
118
  * Full-suite lock wait; `waitBudget` is merge-wait only.
119
+ * @param {Record<string, number>|null} [args.phaseDurations] Seconds per phase.
119
120
  * @param {string} [args.timestamp]
120
121
  * @param {{ schema: object|null, error: string|null }} [args.schemaSource]
121
122
  * Test seam.
@@ -137,6 +138,7 @@ export function buildTerminalEnvelope({
137
138
  elapsedSeconds = 0,
138
139
  waitBudget,
139
140
  lockWait,
141
+ phaseDurations,
140
142
  timestamp = new Date().toISOString(),
141
143
  schemaSource,
142
144
  }) {
@@ -158,6 +160,7 @@ export function buildTerminalEnvelope({
158
160
  elapsedSeconds: Math.max(0, Number(elapsedSeconds) || 0),
159
161
  waitBudget: waitBudget ?? null,
160
162
  lockWait: lockWait ?? null,
163
+ phaseDurations,
161
164
  timestamp,
162
165
  });
163
166
 
@@ -31,13 +31,11 @@ export function renderStoryAuthorCore() {
31
31
  (lint) =>
32
32
  `- **${lint.id}** — ${lint.summary} Example: \`${lint.goodExample}\``,
33
33
  ).join('\n');
34
- return `You are an expert Senior Project Manager and Orchestrator.
35
- Your job is to turn a plan seed / Tech Spec into a Story ticket array for an AI Agent to execute.
34
+ return `Turn a plan seed / Tech Spec into Story tickets for an AI agent to execute. The emitted stories template (see STORY BODY SCHEMA) is the ticket shape.
36
35
 
37
36
  ### HIERARCHY RULES (v2 default-single):
38
37
  1. **Emit exactly one Story by default.** Split into N>1 only when pieces have near-zero overlap or sit across an architectural seam. Coupled work stays one Story — put intra-session checkpoints in \`## Slicing\` and fold the Tech Spec into \`## Spec\`.
39
38
  2. **Stories**: Specific user-facing or architectural capabilities (e.g., "Implement JWT Token Exchange").
40
- - There is NO Epic parent ticket, NO Feature tier, and NO Task layer.
41
39
  - **Story-Level Execution**: Each Story is executed end-to-end on a single branch by a single agent. Acceptance criteria and verification commands live as top-level \`acceptance[]\` / \`verify[]\` arrays on the Story ticket (see STORY BODY SCHEMA below).
42
40
  - Thematic grouping is prose in the Story's folded \`## Spec\` / \`## Slicing\`, never sibling tickets for coupled work.
43
41
 
@@ -46,27 +44,10 @@ Your job is to turn a plan seed / Tech Spec into a Story ticket array for an AI
46
44
  - \`labels[]\` is **optional**. Emit it only to request an *additional* label; persist sanitizes the list before applying it.
47
45
  - Do **not** emit \`agent::*\` labels — lifecycle state is runtime-owned, and persist applies \`agent::ready\` itself once every checkpoint is on the ticket.
48
46
 
49
- ### OUTPUT FORMAT:
50
- You MUST respond ONLY with a valid JSON array of objects. No prose, no markdown blocks.
51
-
52
- ### JSON SCHEMA:
53
- [
54
- {
55
- "slug": "hyphen-case-id",
56
- "type": "story",
57
- "title": "Short descriptive title",
58
- "body": <string — see STORY BODY SCHEMA below>,
59
- "acceptance": ["<outcome a PR reviewer can confirm>", ...],
60
- "verify": ["<exact command or test path>", ...],
61
- "labels": ["<extra-label>"] (optional — type::story is applied automatically; omit this field unless you need an additional label),
62
- "depends_on": ["slug-of-blocking-dependency"] (optional array of Story slugs that block execution)
63
- }
64
- ]
65
-
66
47
  **Slug format**: \`^[a-z0-9][a-z0-9-]*$\` — hyphen-case only. Underscores are rejected by the validator.
67
48
 
68
49
  ### STORY BODY SCHEMA (REQUIRED FOR EVERY STORY):
69
- \`body\` is either the serialized markdown **string** (the section format below) or a **structured object** carrying the same fields (\`goal\`, optional \`slicing\` / \`spec\`, \`changes\`, optional \`non_goals\`) — persist parses either shape and serializes the canonical markdown itself, so you never need to read \`story-body.js\` or hand-assemble the markdown (the \`stories.template.json\` file emitted next to the plan-context envelope is a ready-to-fill structured-object skeleton). Stories are consumed by non-interactive sub-agents that must self-verify from the Story ticket alone — so the ticket must carry everything an agent needs to execute and self-verify.
50
+ \`body\` is either the serialized markdown **string** (the section format below) or a **structured object** carrying the same fields (\`goal\`, optional \`slicing\` / \`spec\`, \`changes\`, optional \`non_goals\`) — persist parses either shape and serializes the canonical markdown itself, so you never need to read \`story-body.js\` or hand-assemble the markdown (the \`stories.template.json\` file emitted next to the plan-context envelope is a ready-to-fill structured-object skeleton). The executing sub-agent is non-interactive and self-verifies from the ticket alone, so the ticket carries everything it needs.
70
51
 
71
52
  The \`acceptance[]\` and \`verify[]\` arrays live at the **top level** of the Story ticket object — that is the machine contract the validator reads. Author each list **once, at top level**, and **omit** the \`## Acceptance\` / \`## Verify\` sections from the authored \`body\` string: persist syncs the top-level arrays into those sections so the GitHub issue stays a complete executable document. The validator resolves both fields from the top level, so an omitted section is the expected shape, not a violation.
72
53
 
@@ -153,9 +134,9 @@ ${envelopeFloor}
153
134
  - A Story touching UI (\`*.tsx\`, \`*.astro\`, \`*.svelte\`, \`*.vue\`, a components folder) states the \`data-testid\` contract in \`acceptance[]\` per the testid contract in \`.agents/skills/stack/qa/playwright/SKILL.md\`.
154
135
  - A Story touching user-visible copy, brand assets or visual style cites the relevant section of \`docs/style-guide.md\` in \`acceptance[]\` when that file exists.
155
136
 
156
- CRITICAL: Dependencies should follow execution blockers. There is no parent ticket — never emit a 'parent_slug' field.
157
- IMPORTANT DEPENDENCY RULE: Story-to-Story dependencies are expressed via \`depends_on\` (one Story depends_on another Story's slug). Use this to express execution ordering across the plan.
158
- **Never stop mid-array.** Always emit complete JSON — partial arrays are rejected by the validator.`;
137
+ #### ORDERING:
138
+
139
+ Express execution ordering between Stories with \`depends_on\` — the slugs of the Stories that must land first. Never emit a parent field.`;
159
140
  }
160
141
 
161
142
  /**
@@ -20,6 +20,7 @@ import { runAsCli } from './lib/cli-utils.js';
20
20
  import { resolveConfig } from './lib/config-resolver.js';
21
21
  import { gh as defaultGh } from './lib/gh-exec.js';
22
22
  import { Logger } from './lib/Logger.js';
23
+ import { recordRequiredRed } from './lib/orchestration/ci-red-handling.js';
23
24
  import {
24
25
  blockStoryDelivery,
25
26
  classifyFailure,
@@ -229,25 +230,18 @@ async function handleRedWatch({
229
230
  blockFn,
230
231
  logger,
231
232
  }) {
232
- const disarm = await disarmFn({ prRef });
233
- const scope = resolveDigestScope({ storyId });
234
- const headSha = scope ? headShaFn({ prRef, cwd }) : null;
235
- let digestPaths = null;
236
- try {
237
- digestPaths = writeDigestFn({
238
- storyId,
239
- prNumber,
240
- headSha,
241
- failures,
242
- tempRoot,
243
- cwd,
244
- prRef,
245
- });
246
- } catch (err) {
247
- logger.warn?.(
248
- `[pr-watch] failed to write CI digest (non-fatal): ${err?.message ?? err}`,
249
- );
250
- }
233
+ const { headSha, disarm, digestPaths } = await recordRequiredRed({
234
+ storyId,
235
+ prNumber,
236
+ prRef,
237
+ failures,
238
+ tempRoot,
239
+ cwd,
240
+ disarmFn,
241
+ headShaFn,
242
+ writeDigestFn,
243
+ logger,
244
+ });
251
245
  let blocked = false;
252
246
  if (!disarm.disarmed) {
253
247
  logger.error?.(