mandrel 1.87.0 → 1.89.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 (140) hide show
  1. package/.agents/README.md +18 -13
  2. package/.agents/audit-checklists/architecture.md +24 -0
  3. package/.agents/audit-checklists/clean-code.md +24 -0
  4. package/.agents/audit-checklists/dependencies.md +14 -0
  5. package/.agents/audit-checklists/devops.md +17 -0
  6. package/.agents/audit-checklists/documentation.md +22 -0
  7. package/.agents/audit-checklists/lighthouse.md +15 -0
  8. package/.agents/audit-checklists/navigability.md +14 -0
  9. package/.agents/audit-checklists/performance.md +22 -0
  10. package/.agents/audit-checklists/privacy.md +21 -0
  11. package/.agents/audit-checklists/quality.md +18 -0
  12. package/.agents/audit-checklists/security.md +22 -0
  13. package/.agents/audit-checklists/seo.md +16 -0
  14. package/.agents/audit-checklists/sre.md +24 -0
  15. package/.agents/audit-checklists/ux-ui.md +21 -0
  16. package/.agents/docs/SDLC.md +63 -16
  17. package/.agents/docs/configuration.md +5 -3
  18. package/.agents/instructions.md +51 -21
  19. package/.agents/personas/architect.md +10 -7
  20. package/.agents/personas/engineer.md +4 -3
  21. package/.agents/personas/project-manager.md +5 -2
  22. package/.agents/personas/refactorer.md +5 -3
  23. package/.agents/rules/git-conventions.md +77 -0
  24. package/.agents/schemas/agentrc.schema.json +16 -4
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
  28. package/.agents/schemas/signal-event.schema.json +28 -13
  29. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  30. package/.agents/scripts/check-context-budget.js +320 -0
  31. package/.agents/scripts/diagnose-friction.js +4 -4
  32. package/.agents/scripts/epic-audit-prepare.js +30 -2
  33. package/.agents/scripts/epic-audit-recheck.js +46 -13
  34. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  35. package/.agents/scripts/epic-plan-spec.js +4 -8
  36. package/.agents/scripts/generate-lens-checklists.js +180 -0
  37. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  38. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  39. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  40. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  41. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  42. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  43. package/.agents/scripts/lib/cli-args.js +8 -0
  44. package/.agents/scripts/lib/config/explain.js +4 -0
  45. package/.agents/scripts/lib/config/runners.js +21 -2
  46. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  47. package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
  48. package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
  49. package/.agents/scripts/lib/doc-tiers.js +291 -0
  50. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  51. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  52. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  53. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
  54. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  55. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  56. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  57. package/.agents/scripts/lib/gates/friction.js +15 -5
  58. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  59. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  60. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  61. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  62. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  63. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  64. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  65. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  66. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  67. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  68. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  69. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  70. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
  71. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  72. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
  73. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  79. package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
  80. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  81. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  82. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  83. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  84. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  85. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  86. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  91. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  92. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  93. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  94. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  95. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  96. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  97. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  98. package/.agents/scripts/lib/planning-corpus.js +306 -0
  99. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  100. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  101. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  102. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  103. package/.agents/scripts/lib/signals/schema.js +56 -81
  104. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  105. package/.agents/scripts/lib/story-plan.js +3 -0
  106. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  107. package/.agents/scripts/lifecycle-emit.js +39 -8
  108. package/.agents/scripts/providers/github/issues.js +12 -1
  109. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  110. package/.agents/scripts/retro-run.js +51 -0
  111. package/.agents/scripts/signals-view.js +1 -1
  112. package/.agents/scripts/single-story-close.js +20 -1
  113. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  114. package/.agents/scripts/story-close.js +48 -0
  115. package/.agents/scripts/story-plan.js +51 -12
  116. package/.agents/scripts/validate-docs-freshness.js +69 -15
  117. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  118. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  119. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  120. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  121. package/.agents/skills/skills.index.json +3 -3
  122. package/.agents/workflows/audit-documentation.md +82 -2
  123. package/.agents/workflows/helpers/code-review.md +193 -44
  124. package/.agents/workflows/helpers/deliver-epic.md +128 -39
  125. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  126. package/.agents/workflows/helpers/epic-audit.md +116 -283
  127. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  128. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  129. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  130. package/.agents/workflows/helpers/plan-epic.md +141 -105
  131. package/.agents/workflows/helpers/plan-story.md +32 -0
  132. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  133. package/.agents/workflows/loops/nightly-audit.md +9 -7
  134. package/docs/CHANGELOG.md +29 -0
  135. package/lib/cli/doctor.js +44 -0
  136. package/package.json +4 -3
  137. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  138. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
  139. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  140. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -1,5 +1,5 @@
1
1
  /**
2
- * spec-section-validator.js — Phase 7.5 Tech Spec post-authoring section gate.
2
+ * spec-section-validator.js — Tech Spec post-authoring section gate.
3
3
  *
4
4
  * `/plan` Phase 7 authors a Tech Spec from documentation and the Epic body.
5
5
  * Phase 8.3 (Holistic Consolidation) then reconciles the draft ticket array
@@ -12,19 +12,29 @@
12
12
  *
13
13
  * `validateSpecSections` parses a Tech Spec body for the required
14
14
  * `## Delivery Slicing` heading (accepting the casing/wording variants
15
- * below) and returns a deterministic `{ ok, missing[] }` envelope. The
16
- * caller (`epic-plan-spec-validate.js`) maps a non-empty `missing[]` to a
17
- * non-zero exit so Phase 8 decomposition cannot proceed against an
18
- * un-anchored spec.
15
+ * below) and returns a deterministic `{ ok, missing[] }` envelope.
19
16
  *
20
- * This is the Phase 8-side counterpart to the Phase 6 Epic Clarity Gate
17
+ * Story #4403 (Finding 3): this gate used to be run by a standalone
18
+ * `epic-plan-spec-validate.js` CLI as a separate Phase 7.5 workflow step —
19
+ * but by the time the documented ordering ran it, the Phase 7 persist path's
20
+ * `cleanupPhaseTempFiles` had already deleted the temp `techspec.md` file the
21
+ * gate read, so the "blocking gate" could never actually block. The CLI is
22
+ * retired; `runSpecPhase` (`phases/run-spec-phase.js`) now calls
23
+ * `validateSpecSections` directly against the in-memory authored content as
24
+ * part of its input validation, before any GitHub mutation. This is the
25
+ * Phase 8-side counterpart to the Phase 6 Epic Clarity Gate
21
26
  * ({@link ../epic-plan-clarity.js#scoreEpicBody}) — same detect-then-prompt
22
- * pattern, one phase later, but a hard gate (exit non-zero) rather than an
27
+ * pattern, one phase later, but a hard gate (fail closed) rather than an
23
28
  * advisory rubric.
24
29
  *
25
- * Pure ESM, no I/O.
30
+ * `validateSpecSections` and `formatMissingSectionMessage` are pure, I/O-free
31
+ * helpers. `validateSpecFile` is a thin disk-reading convenience for callers
32
+ * (tests, ad-hoc tooling) that hold a path rather than in-memory content.
26
33
  */
27
34
 
35
+ import { readFile } from 'node:fs/promises';
36
+ import { DELIVERY_SLICING_RE } from '../epic-body-sections.js';
37
+
28
38
  /**
29
39
  * The single required Tech Spec section, with the heading variants the
30
40
  * Architect persona may emit. Matched case-insensitively against any
@@ -39,7 +49,7 @@ const REQUIRED_SECTIONS = Object.freeze([
39
49
  {
40
50
  name: 'Delivery Slicing',
41
51
  /** Level-2 heading, allowing the `Delivery ` qualifier to be optional. */
42
- re: /^##\s+(?:Delivery\s+)?Slicing\s*$/im,
52
+ re: DELIVERY_SLICING_RE,
43
53
  },
44
54
  ]);
45
55
 
@@ -78,3 +88,44 @@ export function validateSpecSections({ body } = {}) {
78
88
 
79
89
  return { ok: missing.length === 0, missing, present };
80
90
  }
91
+
92
+ /**
93
+ * Validate an authored Tech Spec file for the required post-authoring
94
+ * sections. Thin wrapper around `validateSpecSections` that owns the file
95
+ * read so callers holding a path (rather than in-memory content) do not each
96
+ * re-implement the read-then-validate sequence.
97
+ *
98
+ * @param {{ techspecPath: string }} args
99
+ * @returns {Promise<{ ok: boolean, missing: string[], present: string[] }>}
100
+ */
101
+ export async function validateSpecFile({ techspecPath }) {
102
+ const body = await readFile(techspecPath, 'utf8');
103
+ return validateSpecSections({ body });
104
+ }
105
+
106
+ /**
107
+ * Build the operator-facing failure message for a missing-section result.
108
+ * Names each missing section and tells the operator whether to re-author the
109
+ * spec or add the section by hand before continuing.
110
+ *
111
+ * @param {{ techspecPath: string, missing: string[] }} args
112
+ * @returns {string}
113
+ */
114
+ export function formatMissingSectionMessage({ techspecPath, missing }) {
115
+ const list = missing.map((name) => `## ${name}`).join(', ');
116
+ return [
117
+ `[spec-section-validator] Tech Spec is missing required section(s): ${list}`,
118
+ ` Spec source: ${techspecPath}`,
119
+ '',
120
+ ` Phase 8 (decomposition) reconciles the draft ticket array against the`,
121
+ ` Tech Spec's "## Delivery Slicing" section — without it, the Phase 8.3`,
122
+ ` consolidation pass has no capability-boundary anchor and groups by`,
123
+ ` technical shape instead.`,
124
+ '',
125
+ ' To continue, do ONE of the following and re-run the Phase 7 persist step:',
126
+ ` 1. Re-author the Tech Spec (re-run the Phase 7 spec-author step) so it`,
127
+ ` emits a "## Delivery Slicing" section, OR`,
128
+ ` 2. Add a "## Delivery Slicing" section to the Tech Spec by hand,`,
129
+ ` describing the capability boundaries the work should be sliced along.`,
130
+ ].join('\n');
131
+ }
@@ -376,7 +376,7 @@ function filterToStoryDiff({ miRows, crapRows, storyDiffPaths }) {
376
376
 
377
377
  /**
378
378
  * Check whether a `baseline-refresh-regression` signal tagged with the
379
- * runner's `source.tool === 'auto-refresh-runner'` already exists in the
379
+ * runner's `emitter.tool === 'auto-refresh-runner'` already exists in the
380
380
  * Story's signals stream. Backs the AC3 idempotent-re-run contract.
381
381
  */
382
382
  async function priorRefusalSignalExists({
@@ -391,7 +391,7 @@ async function priorRefusalSignalExists({
391
391
  typeof record === 'object' &&
392
392
  record.kind === 'friction' &&
393
393
  record.category === FRICTION_CATEGORY &&
394
- record?.source?.tool === RUNNER_SOURCE_TOOL
394
+ record?.emitter?.tool === RUNNER_SOURCE_TOOL
395
395
  ) {
396
396
  found = true;
397
397
  }
@@ -413,12 +413,14 @@ function buildRefusalSignal({
413
413
  }) {
414
414
  return {
415
415
  kind: 'friction',
416
- timestamp: new Date().toISOString(),
416
+ ts: new Date().toISOString(),
417
417
  epicId,
418
418
  storyId,
419
419
  category: FRICTION_CATEGORY,
420
- source: { tool: RUNNER_SOURCE_TOOL },
421
- details: `Auto-refresh refused: ${refusalReasons.length} row(s) breach configured caps (miDropCap=${caps.miDropCap}, crapJumpCap=${caps.crapJumpCap}).`,
420
+ emitter: { tool: RUNNER_SOURCE_TOOL },
421
+ details: {
422
+ message: `Auto-refresh refused: ${refusalReasons.length} row(s) breach configured caps (miDropCap=${caps.miDropCap}, crapJumpCap=${caps.crapJumpCap}).`,
423
+ },
422
424
  refusalReasons,
423
425
  miOverCap,
424
426
  crapOverCap,
@@ -55,6 +55,10 @@ import {
55
55
  buildMergeMessageWithCap,
56
56
  loadHeaderMaxLength,
57
57
  } from './merge-subject.js';
58
+ import {
59
+ assertNoForeignEpicLock as defaultAssertNoForeignEpicLock,
60
+ assertSharedCheckoutAvailable as defaultAssertSharedCheckoutAvailable,
61
+ } from './shared-checkout-guard.js';
58
62
 
59
63
  /**
60
64
  * Render the lock-file path for a given main-repo `cwd` + `epicId`. Pure;
@@ -424,7 +428,7 @@ export async function runFinalizeMerge({
424
428
  storyBranch,
425
429
  storyTitle,
426
430
  storyId,
427
- epicId: _epicId,
431
+ epicId,
428
432
  cwd,
429
433
  config,
430
434
  bus = null,
@@ -432,6 +436,7 @@ export async function runFinalizeMerge({
432
436
  logger = DefaultLogger,
433
437
  gitSync = defaultGitSync,
434
438
  gitSpawn = defaultGitSpawn,
439
+ assertSharedCheckoutAvailable = defaultAssertSharedCheckoutAvailable,
435
440
  }) {
436
441
  rebaseStoryOnEpic({
437
442
  config,
@@ -443,6 +448,15 @@ export async function runFinalizeMerge({
443
448
  gitSpawn,
444
449
  });
445
450
 
451
+ // Story #4460 — cross-epic shared-checkout guard. Runs AFTER the
452
+ // per-Epic merge lock is already held (acquired around the whole close
453
+ // flow in story-close.js) so it composes with, rather than replaces,
454
+ // that same-epic serialization. Fails fast with an actionable
455
+ // diagnostic instead of letting a raw `git checkout` error surface
456
+ // when another epic's merge phase (or unrelated dirt) holds the
457
+ // shared checkout.
458
+ assertSharedCheckoutAvailable({ cwd, epicId, gitSpawn });
459
+
446
460
  log('GIT', `Checking out ${epicBranch}...`);
447
461
  gitSync(cwd, 'checkout', epicBranch);
448
462
  gitSpawn(cwd, 'pull', '--rebase', 'origin', epicBranch);
@@ -582,13 +596,21 @@ export async function runResumeMerge({
582
596
  storyBranch,
583
597
  storyTitle,
584
598
  storyId,
585
- epicId: _epicId,
599
+ epicId,
586
600
  config,
587
601
  bus = null,
588
602
  logger = DefaultLogger,
589
603
  log = () => {},
590
604
  gitSpawn = defaultGitSpawn,
605
+ assertNoForeignEpicLockFn = defaultAssertNoForeignEpicLock,
591
606
  }) {
607
+ // Story #4460 follow-up: the resume path re-enters the shared checkout
608
+ // just like the finalize path, so another epic's live merge phase is
609
+ // the same hazard here. Only the foreign-lock half of the guard runs —
610
+ // a resume's own partial merge legitimately leaves the tree dirty, so
611
+ // the dirty-tree probe would false-positive against our own state.
612
+ assertNoForeignEpicLockFn({ cwd, epicId });
613
+
592
614
  const resumeMergeMessage = await buildMergeMessage(storyTitle, storyId, {
593
615
  cwd,
594
616
  logger,
@@ -5,9 +5,9 @@
5
5
  * Sits between the close-validation gate chain and the merge into
6
6
  * `epic/<id>` inside `runStoryCloseLocked` (locked-pipeline.js). The
7
7
  * configured ReviewProvider runs against the
8
- * `epic/<id>`…`story-<id>` diff. The structured `code-review` comment
9
- * is posted to the Story issue (default `commentTargetId === ticketId`
10
- * inside `runCodeReview`). Outcomes:
8
+ * `epic/<id>`…`story-<id>` diff. The unified `verification-results`
9
+ * structured comment is posted to the Story issue (default
10
+ * `commentTargetId === ticketId` inside `runCodeReview`). Outcomes:
11
11
  *
12
12
  * - clean / non-critical findings → `{ blocked: null }`; the pipeline
13
13
  * proceeds to merge.
@@ -32,10 +32,141 @@
32
32
  * invocation pattern (Story #3653).
33
33
  */
34
34
 
35
+ import {
36
+ runAuditSuite,
37
+ selectLocalLenses,
38
+ } from '../../../audit-suite/index.js';
39
+ import { gitSpawn } from '../../../git-utils.js';
35
40
  import { Logger } from '../../../Logger.js';
36
41
  import { runCodeReview } from '../../code-review.js';
37
42
  import { emitBlockedCloseResult } from '../merge-runner.js';
38
43
 
44
+ /**
45
+ * The review depth the Story-scope local-lens pass runs at. Shift-left
46
+ * (Epic #4405): local concerns are cheap to decide on a single Story's diff, so
47
+ * the maker-blind Story-scope review runs its matched local lenses at `light`
48
+ * depth here rather than paying a deeper pass at Epic close. Fixed for this
49
+ * tier — it is not risk-scaled like the code-review pillar depth.
50
+ */
51
+ export const STORY_SCOPE_LENS_DEPTH = 'light';
52
+
53
+ /**
54
+ * Enumerate the files changed in the `baseRef...headRef` diff via
55
+ * `git diff --name-only`. Best-effort: returns `[]` when the diff cannot be
56
+ * enumerated (git failure, missing ref) and never throws, mirroring the
57
+ * advisory posture of the surrounding review phase. Synchronous `gitSpawn`
58
+ * (returns `{ status, stdout }`) is the same seam `code-review.js#countChangedFiles`
59
+ * uses.
60
+ *
61
+ * @param {{ baseRef: string, headRef: string, gitSpawnFn?: typeof gitSpawn }} args
62
+ * @returns {string[]} Changed file paths, or `[]` on any failure.
63
+ */
64
+ export function enumerateChangedFiles({
65
+ baseRef,
66
+ headRef,
67
+ gitSpawnFn = gitSpawn,
68
+ }) {
69
+ try {
70
+ const result = gitSpawnFn(
71
+ process.cwd(),
72
+ 'diff',
73
+ '--name-only',
74
+ `${baseRef}...${headRef}`,
75
+ );
76
+ if (!result || result.status !== 0 || typeof result.stdout !== 'string') {
77
+ return [];
78
+ }
79
+ return result.stdout
80
+ .split('\n')
81
+ .map((f) => f.trim())
82
+ .filter(Boolean);
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Run the Story-scope local-lens pass: select the LOCAL-tier lenses whose
90
+ * `filePatterns` match the actual Story diff (`baseRef...headRef`) and
91
+ * materialize their lens-prompt bodies at `light` depth. This is the
92
+ * shift-left tier from Epic #4405 — it runs **inside** the story-close
93
+ * subprocess spine (called only from {@link runStoryReviewCore}), never in the
94
+ * delivering child's (maker's) context, so a maker never grades its own work.
95
+ *
96
+ * A diff that matches no local lens adds **no** lens work: the roster is empty
97
+ * and `runAuditSuite` is never invoked. Best-effort and total — a git or
98
+ * materialization failure degrades to `{ skipped: true, lenses: [] }` and is
99
+ * logged via `progress`, matching the advisory posture the review phase already
100
+ * takes for provider/transport failures.
101
+ *
102
+ * @param {{
103
+ * baseRef: string,
104
+ * headRef: string,
105
+ * progress: (tag: string, msg: string) => void,
106
+ * progressTag?: string,
107
+ * gitSpawnFn?: typeof gitSpawn,
108
+ * selectLocalLensesFn?: typeof selectLocalLenses,
109
+ * runAuditSuiteFn?: typeof runAuditSuite,
110
+ * }} args
111
+ * @returns {Promise<{
112
+ * depth: 'light',
113
+ * lenses: string[],
114
+ * skipped: boolean,
115
+ * materialized: object|null,
116
+ * }>}
117
+ */
118
+ export async function runLocalLensReview({
119
+ baseRef,
120
+ headRef,
121
+ progress,
122
+ progressTag = 'CODE-REVIEW',
123
+ gitSpawnFn = gitSpawn,
124
+ selectLocalLensesFn = selectLocalLenses,
125
+ runAuditSuiteFn = runAuditSuite,
126
+ }) {
127
+ const empty = {
128
+ depth: STORY_SCOPE_LENS_DEPTH,
129
+ lenses: [],
130
+ skipped: true,
131
+ materialized: null,
132
+ };
133
+ let lenses;
134
+ try {
135
+ const changedFiles = enumerateChangedFiles({
136
+ baseRef,
137
+ headRef,
138
+ gitSpawnFn,
139
+ });
140
+ lenses = selectLocalLensesFn({ changedFiles });
141
+ if (lenses.length === 0) {
142
+ progress(
143
+ progressTag,
144
+ 'No local lens matched the Story diff — skipping the lens pass.',
145
+ );
146
+ return empty;
147
+ }
148
+ const materialized = await runAuditSuiteFn({ auditWorkflows: lenses });
149
+ progress(
150
+ progressTag,
151
+ `Ran ${lenses.length} local lens(es) at ${STORY_SCOPE_LENS_DEPTH} depth: ${lenses.join(', ')}.`,
152
+ );
153
+ return {
154
+ depth: STORY_SCOPE_LENS_DEPTH,
155
+ lenses,
156
+ skipped: false,
157
+ materialized,
158
+ };
159
+ } catch (err) {
160
+ // The lens pass is advisory: a git or materialization failure must not
161
+ // fail the close. Log and degrade to a skipped envelope.
162
+ progress(
163
+ progressTag,
164
+ `⚠️ local lens pass failed (continuing without it): ${err?.message ?? err}`,
165
+ );
166
+ return empty;
167
+ }
168
+ }
169
+
39
170
  /**
40
171
  * Collect the extra fields for the code-review-critical blocked envelope.
41
172
  * Pure; used by `runStoryCodeReview` to populate the `extra` argument of
@@ -92,8 +223,13 @@ function buildCodeReviewBlockedExtra({ storyId, reviewResult }) {
92
223
  * progressTag?: string,
93
224
  * planningRisk?: { overallLevel?: ('low'|'medium'|'high'), axes?: Array<{ axis?: string, level?: string }> }|null,
94
225
  * runCodeReviewFn?: typeof runCodeReview,
226
+ * runLocalLensReviewFn?: typeof runLocalLensReview,
95
227
  * }} args
96
- * @returns {Promise<object>} Raw result envelope from `runCodeReview`.
228
+ * @returns {Promise<object>} Raw result envelope from `runCodeReview`, augmented
229
+ * with a `localLensReview` field carrying the Story-scope local-lens pass
230
+ * outcome (Epic #4405, Story #4409). Both close entry points reach the lens
231
+ * pass through this single spine, so it runs on the Epic-attached and
232
+ * standalone paths alike and always inside the close subprocess.
97
233
  */
98
234
  export async function runStoryReviewCore({
99
235
  storyId,
@@ -105,6 +241,7 @@ export async function runStoryReviewCore({
105
241
  progressTag = 'CODE-REVIEW',
106
242
  planningRisk = null,
107
243
  runCodeReviewFn = runCodeReview,
244
+ runLocalLensReviewFn = runLocalLensReview,
108
245
  }) {
109
246
  const storyIdNum = Number(storyId);
110
247
  const opts = {
@@ -127,7 +264,21 @@ export async function runStoryReviewCore({
127
264
  if (planningRisk != null) {
128
265
  opts.planningRisk = planningRisk;
129
266
  }
130
- return runCodeReviewFn(opts);
267
+
268
+ // Shift-left local-lens pass (Epic #4405). Runs matched local lenses at
269
+ // `light` depth against the actual Story diff, inside this close-subprocess
270
+ // spine so the maker never grades its own work. Advisory — it never blocks
271
+ // the close and its outcome rides on the returned envelope for downstream
272
+ // consumers.
273
+ const localLensReview = await runLocalLensReviewFn({
274
+ baseRef,
275
+ headRef,
276
+ progress,
277
+ progressTag,
278
+ });
279
+
280
+ const result = await runCodeReviewFn(opts);
281
+ return { ...result, localLensReview };
131
282
  }
132
283
 
133
284
  /**
@@ -155,8 +306,12 @@ export async function runStoryReviewCore({
155
306
  * progress: (tag: string, msg: string) => void,
156
307
  * planningRisk?: { overallLevel?: ('low'|'medium'|'high'), axes?: Array<{ axis?: string, level?: string }> }|null,
157
308
  * runCodeReviewFn?: typeof runCodeReview,
309
+ * runLocalLensReviewFn?: typeof runLocalLensReview,
158
310
  * }} args
159
- * @returns {Promise<{ blocked: object|null }>}
311
+ * @returns {Promise<{ blocked: object|null, localLensReview?: object }>}
312
+ * `localLensReview` carries the Story-scope local-lens pass outcome
313
+ * (Epic #4405, Story #4409) when the review completed; it is absent only when
314
+ * the whole review phase threw (advisory failure).
160
315
  */
161
316
  export async function runStoryCodeReview(args) {
162
317
  const {
@@ -168,6 +323,7 @@ export async function runStoryCodeReview(args) {
168
323
  progress,
169
324
  planningRisk = null,
170
325
  runCodeReviewFn = runCodeReview,
326
+ runLocalLensReviewFn = runLocalLensReview,
171
327
  } = args;
172
328
 
173
329
  const storyIdNum = Number(storyId);
@@ -186,6 +342,7 @@ export async function runStoryCodeReview(args) {
186
342
  progress,
187
343
  planningRisk,
188
344
  runCodeReviewFn,
345
+ runLocalLensReviewFn,
189
346
  });
190
347
  } catch (err) {
191
348
  // Adapter / wiring failure — log and proceed. The review is advisory
@@ -197,6 +354,8 @@ export async function runStoryCodeReview(args) {
197
354
  return { blocked: null };
198
355
  }
199
356
 
357
+ const localLensReview = reviewResult?.localLensReview;
358
+
200
359
  if (reviewResult?.halted) {
201
360
  const blocked = await emitBlockedCloseResult({
202
361
  storyId: storyIdNum,
@@ -208,7 +367,7 @@ export async function runStoryCodeReview(args) {
208
367
  blockedMessage: `Story #${storyIdNum} blocked: code-review reported ${reviewResult.severity.critical} critical blocker(s).`,
209
368
  logger: Logger,
210
369
  });
211
- return { blocked };
370
+ return { blocked, localLensReview };
212
371
  }
213
372
 
214
373
  const counts = reviewResult?.severity ?? {};
@@ -216,5 +375,5 @@ export async function runStoryCodeReview(args) {
216
375
  'CODE-REVIEW',
217
376
  `Review complete — high=${counts.high ?? 0} medium=${counts.medium ?? 0} suggestion=${counts.suggestion ?? 0} (posted=${reviewResult?.posted ?? false}).`,
218
377
  );
219
- return { blocked: null };
378
+ return { blocked: null, localLensReview };
220
379
  }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * shared-checkout-guard.js — cross-epic contention guard for the merge
3
+ * phase's `git checkout <epicBranch>` in the shared main checkout
4
+ * (Story #4460).
5
+ *
6
+ * `story-close.js`'s merge phase (`runFinalizeMerge` in `merge-runner.js`)
7
+ * runs `git checkout <epicBranch>` directly in the shared main repo
8
+ * checkout — `close-inputs.js` resolves `cwd` to `PROJECT_ROOT`, not an
9
+ * isolated worktree. The only exclusivity guard around that shared
10
+ * checkout is the per-Epic `epic-merge-lock.js` mutex, which only
11
+ * serializes concurrent runs for the SAME epic. Nothing stops a
12
+ * DIFFERENT epic's concurrently-running `story-close.js` from treating
13
+ * the same shared checkout as scratch space at the same time.
14
+ *
15
+ * This was observed live: while delivering Epic #4425 Stories #4427/#4428,
16
+ * the shared checkout repeatedly carried uncommitted stray changes
17
+ * belonging to a concurrently-running Epic #4405 delivery (parked on
18
+ * `epic/4405` with dirty edits), which blocked the `git checkout epic/4425`
19
+ * merge step with a raw `error: Your local changes ... would be
20
+ * overwritten by checkout`.
21
+ *
22
+ * `assertSharedCheckoutAvailable` runs immediately before that checkout
23
+ * and fails fast with an actionable, story-close-specific diagnostic
24
+ * instead of letting the raw git error surface. It COMPOSES with (does
25
+ * not replace) the per-Epic lock: by the time this guard runs, the
26
+ * caller's own epic lock is already held (acquired around the whole
27
+ * close flow in `story-close.js`), so this guard only inspects OTHER
28
+ * epics' lock files plus the tree's overall dirty state — it never
29
+ * contends with same-epic concurrent runs, which continue to serialize
30
+ * solely via `withEpicMergeLock` before this guard ever executes.
31
+ *
32
+ * Breadth trade-off (deliberate): the foreign-lock probe keys on the
33
+ * OTHER epic's per-epic merge lock, which that run holds for its WHOLE
34
+ * close flow — so any overlapping story-close of another epic trips this
35
+ * guard even when that run never actually touches the shared checkout
36
+ * during the overlap. The refusal is deterministic and loud (throws with
37
+ * a diagnostic naming the holder pid), never a deadlock (no waiting),
38
+ * and stale/dead-pid foreign locks are ignored via the pid-liveness
39
+ * probe. Narrowing the window (a dedicated checkout-phase lock) was
40
+ * considered and rejected: the coarse refusal is rare, cheap to retry,
41
+ * and far simpler than a second lock tier.
42
+ */
43
+
44
+ import { findForeignActiveEpicLock as defaultFindForeignActiveEpicLock } from '../../epic-merge-lock.js';
45
+ import { gitSpawn as defaultGitSpawn } from '../../git-utils.js';
46
+
47
+ const MAX_LISTED_DIRTY_FILES = 20;
48
+
49
+ function describeForeignLock(foreign) {
50
+ const acquired = Number.isFinite(foreign.acquiredAt)
51
+ ? new Date(foreign.acquiredAt).toISOString()
52
+ : 'an unknown time';
53
+ return (
54
+ `[story-close] shared-checkout guard: refusing to touch the shared main checkout — ` +
55
+ `it is currently held by epic #${foreign.epicId}'s story-close merge phase ` +
56
+ `(lock ${foreign.filePath}, pid ${foreign.pid}, acquired ${acquired}). ` +
57
+ `Wait for that epic's story-close run to finish before retrying this merge. ` +
58
+ `If you have independently confirmed that process is no longer running, remove ` +
59
+ `the lock file by hand — never force a checkout past a live foreign lock. ` +
60
+ `See .agents/rules/git-conventions.md § Shared-checkout contention (Story #4460).`
61
+ );
62
+ }
63
+
64
+ function listDirtyFiles(porcelainOutput) {
65
+ // `git status --porcelain` lines are `XY <path>` — a fixed 2-char status
66
+ // column, a space, then the path. Strip only a trailing `\r` (Windows)
67
+ // before slicing off that 3-char prefix; trimming the whole line first
68
+ // would shift the slice offset whenever the status column starts with a
69
+ // space (the common "unstaged modification" case), truncating the path.
70
+ const lines = porcelainOutput
71
+ .split('\n')
72
+ .map((line) => line.replace(/\r$/, ''))
73
+ .filter((line) => line.length > 0);
74
+ const shown = lines
75
+ .slice(0, MAX_LISTED_DIRTY_FILES)
76
+ .map((line) => line.slice(3).trim() || line);
77
+ const overflow = lines.length - shown.length;
78
+ return overflow > 0
79
+ ? `${shown.join(', ')}, … (+${overflow} more)`
80
+ : shown.join(', ');
81
+ }
82
+
83
+ function describeDirtyCheckout({ cwd, epicId, currentBranch, dirtyFiles }) {
84
+ return (
85
+ `[story-close] shared-checkout guard: refusing to check out the epic branch for ` +
86
+ `epic #${epicId} — the shared main checkout at ${cwd} is dirty (currently on ` +
87
+ `\`${currentBranch}\`). Dirty files: ${dirtyFiles}. This usually means another ` +
88
+ `epic's story-close run left uncommitted work in the shared checkout, or a prior ` +
89
+ `run crashed mid-merge. Resolve manually (stash/commit/reset in ${cwd}) before ` +
90
+ `retrying. See .agents/rules/git-conventions.md § Shared-checkout contention ` +
91
+ `(Story #4460).`
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Fail fast when the shared main checkout is not safely available for this
97
+ * epic's merge-phase `git checkout <epicBranch>` — either because another
98
+ * epic's story-close merge phase currently holds it (a live foreign lock),
99
+ * or because it is simply dirty (regardless of whose branch is checked
100
+ * out). Silent no-op when the checkout is clean and uncontended.
101
+ *
102
+ * @param {{
103
+ * cwd: string,
104
+ * epicId: number|string,
105
+ * gitSpawn?: typeof defaultGitSpawn,
106
+ * findForeignActiveEpicLock?: typeof defaultFindForeignActiveEpicLock,
107
+ * }} opts
108
+ * @throws {Error} with an actionable, story-close-specific diagnostic.
109
+ */
110
+ /**
111
+ * Foreign-lock-only variant for the RESUME merge path (Story #4460
112
+ * follow-up): a resume legitimately re-enters a shared checkout that is
113
+ * dirty with THIS story's own in-progress merge, so the dirty-tree half
114
+ * of `assertSharedCheckoutAvailable` would false-positive there. The
115
+ * cross-epic hazard — another epic's live merge phase holding the
116
+ * checkout — still applies and is the only probe this variant runs.
117
+ */
118
+ export function assertNoForeignEpicLock({
119
+ cwd,
120
+ epicId,
121
+ findForeignActiveEpicLock = defaultFindForeignActiveEpicLock,
122
+ }) {
123
+ const foreign = findForeignActiveEpicLock(epicId, { repoRoot: cwd });
124
+ if (foreign) {
125
+ throw new Error(describeForeignLock(foreign));
126
+ }
127
+ }
128
+
129
+ export function assertSharedCheckoutAvailable({
130
+ cwd,
131
+ epicId,
132
+ gitSpawn = defaultGitSpawn,
133
+ findForeignActiveEpicLock = defaultFindForeignActiveEpicLock,
134
+ }) {
135
+ const foreign = findForeignActiveEpicLock(epicId, { repoRoot: cwd });
136
+ if (foreign) {
137
+ throw new Error(describeForeignLock(foreign));
138
+ }
139
+
140
+ const statusRes = gitSpawn(cwd, 'status', '--porcelain');
141
+ if (statusRes.status !== 0) {
142
+ // Can't determine dirtiness from here — let the downstream checkout
143
+ // surface whatever git itself reports rather than guessing.
144
+ return;
145
+ }
146
+ const porcelain = statusRes.stdout || '';
147
+ if (porcelain.trim().length === 0) return;
148
+
149
+ const branchRes = gitSpawn(cwd, 'rev-parse', '--abbrev-ref', 'HEAD');
150
+ const currentBranch =
151
+ branchRes.status === 0
152
+ ? (branchRes.stdout || '').trim() || 'unknown'
153
+ : 'unknown';
154
+
155
+ throw new Error(
156
+ describeDirtyCheckout({
157
+ cwd,
158
+ epicId,
159
+ currentBranch,
160
+ dirtyFiles: listDirtyFiles(porcelain),
161
+ }),
162
+ );
163
+ }
@@ -59,7 +59,17 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
59
59
  'friction',
60
60
  'notification',
61
61
  // Extended set (Story #449 — retro follow-ons)
62
- 'code-review',
62
+ // Story #4411 (Epic #4405) — the former `code-review` structured comment
63
+ // is unified with the former `audit-results` comment into the single
64
+ // `verification-results` findings contract. `runCodeReview` (the sole code
65
+ // producer) upserts `verification-results`; the feedback-loop graduators and
66
+ // the auto-merge integration gate read it. Both the `code-review` and (as of
67
+ // Story #4412's slim-Epic-close cutover) the `audit-results` markers are
68
+ // retired here — the Phase 4 standalone lens walk folded into the Phase 5
69
+ // code-review pass, whose single `verification-results` comment now carries
70
+ // the Epic-close lens findings. Hard cutover, no dual-shape reader per
71
+ // `git-conventions.md`.
72
+ 'verification-results',
63
73
  'retro',
64
74
  'retro-partial',
65
75
  'epic-run-state',
@@ -98,14 +108,6 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
98
108
  // operator can correct drift before Phase 8 decomposes from a stale
99
109
  // spec. Advisory: the run continues regardless of the report contents.
100
110
  'spec-freshness',
101
- // Story #2681 — `/deliver` Phase 4 epic-audit helper upserts an
102
- // `audit-results` comment on the Epic listing the per-lens findings
103
- // returned by the change-set audit pass. The marker was prescribed by
104
- // `helpers/epic-audit.md` Step 4 long before it was added to this
105
- // registry; without the entry the helper's `post-structured-comment.js`
106
- // invocation always failed with "Invalid structured-comment type". One
107
- // entry per Epic; re-runs replace prior content.
108
- 'audit-results',
109
111
  // Story #2813 — the per-Task progress writer (since retired under
110
112
  // #3157) upserted a `model-attribution` comment on a Task ticket at
111
113
  // the moment it transitioned to `agent::executing`, recording which
@@ -160,6 +162,15 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
160
162
  // refuses with the claim age. One entry per Epic; re-acquires upsert
161
163
  // in place.
162
164
  'plan-lease',
165
+ // Story #4415 (Epic #4406) — the feedback-loop graduators
166
+ // (`audit-results-graduator.js` / `retro-proposals-graduator.js`) upsert a
167
+ // `cross-repo-deferred` comment on the Epic listing findings that route
168
+ // to a different repository and were therefore not filed here. Replaces
169
+ // the prior log-line-only trace so the deferral survives the finalize
170
+ // run as a durable, operator-visible record. Discriminated by a
171
+ // `graduator="audit-results|code-review"` attr so the two graduators
172
+ // upsert independent comments; re-runs upsert in place.
173
+ 'cross-repo-deferred',
163
174
  ]);
164
175
 
165
176
  export const WAVE_TYPE_PATTERN = WAVE_MARKER_RE;