mandrel 1.88.0 → 1.90.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 (145) 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 +62 -27
  17. package/.agents/docs/configuration.md +5 -4
  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 +10 -6
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
  28. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +39 -0
  29. package/.agents/schemas/signal-event.schema.json +28 -13
  30. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  31. package/.agents/scripts/check-context-budget.js +320 -0
  32. package/.agents/scripts/coverage-capture.js +17 -0
  33. package/.agents/scripts/diagnose-friction.js +4 -4
  34. package/.agents/scripts/epic-audit-prepare.js +30 -2
  35. package/.agents/scripts/epic-audit-recheck.js +46 -13
  36. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  37. package/.agents/scripts/epic-plan-spec.js +4 -8
  38. package/.agents/scripts/generate-lens-checklists.js +180 -0
  39. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  40. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  41. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  42. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  43. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  44. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  45. package/.agents/scripts/lib/cli-args.js +8 -0
  46. package/.agents/scripts/lib/close-validation/gates.js +64 -24
  47. package/.agents/scripts/lib/config/ci.js +12 -1
  48. package/.agents/scripts/lib/config/runners.js +13 -5
  49. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  50. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -8
  51. package/.agents/scripts/lib/doc-tiers.js +291 -0
  52. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  53. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  54. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  55. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +47 -15
  56. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  57. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  58. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  59. package/.agents/scripts/lib/gates/friction.js +15 -5
  60. package/.agents/scripts/lib/npm-scripts.js +55 -0
  61. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  62. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  63. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  64. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  65. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  66. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  67. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  68. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  69. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  70. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  71. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  72. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  73. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +193 -0
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +248 -13
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +109 -12
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +46 -4
  79. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  80. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  81. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  82. package/.agents/scripts/lib/orchestration/merge-block-class.js +246 -0
  83. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  84. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  85. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  86. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  87. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  88. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  89. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  92. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  93. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  94. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  95. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  96. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  97. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  98. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  99. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  100. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
  101. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  102. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  103. package/.agents/scripts/lib/planning-corpus.js +306 -0
  104. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  105. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  106. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  107. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  108. package/.agents/scripts/lib/signals/schema.js +56 -81
  109. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  110. package/.agents/scripts/lib/story-plan.js +3 -0
  111. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  112. package/.agents/scripts/lifecycle-emit.js +39 -8
  113. package/.agents/scripts/providers/github/issues.js +12 -1
  114. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  115. package/.agents/scripts/retro-run.js +51 -0
  116. package/.agents/scripts/signals-view.js +1 -1
  117. package/.agents/scripts/single-story-close.js +20 -1
  118. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  119. package/.agents/scripts/story-close.js +48 -0
  120. package/.agents/scripts/story-plan.js +51 -12
  121. package/.agents/scripts/validate-docs-freshness.js +69 -15
  122. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  123. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  124. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  125. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  126. package/.agents/skills/skills.index.json +3 -3
  127. package/.agents/workflows/audit-documentation.md +82 -2
  128. package/.agents/workflows/helpers/code-review.md +116 -43
  129. package/.agents/workflows/helpers/deliver-epic.md +123 -54
  130. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  131. package/.agents/workflows/helpers/epic-audit.md +116 -366
  132. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  133. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  134. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  135. package/.agents/workflows/helpers/plan-epic.md +141 -105
  136. package/.agents/workflows/helpers/plan-story.md +32 -0
  137. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  138. package/.agents/workflows/loops/nightly-audit.md +9 -7
  139. package/docs/CHANGELOG.md +29 -0
  140. package/lib/cli/doctor.js +44 -0
  141. package/package.json +4 -3
  142. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  143. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -224
  144. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  145. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -18,48 +18,50 @@
18
18
  * the codebase as of Epic #1181 (audit-snapshot 2026-05-11):
19
19
  *
20
20
  * Signals-writer `appendSignal` call sites:
21
- * - `friction` — quality-gate / runtime friction (check-crap.js,
22
- * check-maintainability.js, diagnose-friction.js,
23
- * post-merge-pipeline.js, progress-reporter.js,
24
- * auto-refresh-runner.js)
25
- * - `dispatched` — (no live emitter; retained pending Story #3908 sweep)
21
+ * - `friction` — quality-gate / runtime friction (diagnose-friction.js,
22
+ * lib/gates/friction.js, auto-refresh-runner.js,
23
+ * worktree-reap.js, lifecycle-emit.js)
24
+ * - `acceptance-eval` — acceptance-eval.js per-criterion terminus signal.
26
25
  * - `wave-start` / `wave-complete` — `lib/wave-runner/tick.js` (read by
27
26
  * perf-aggregator's `waveParallelism` report;
28
27
  * `wave-start` also anchors span-tree Story spans)
29
- * - `wave-end` — span-tree pairing anchor (no live emitter after the
30
- * Epic #2646 listener deletion; retained for span-tree)
31
- * - `state-transition` — orchestration/ticketing.js
28
+ * - `wave-end` — span-tree pairing anchor (retained for span-tree)
29
+ * - `state-transition` notification-derived window anchor read by the
30
+ * waveParallelism bucketer.
32
31
  *
33
- * Story #3909 retired the write-only wave kinds with no consumer (`wave-tick`,
34
- * `epic-complete`)they duplicated the checkpoint + `epic-run-progress`
35
- * rollup and nothing read them back.
32
+ * The `dispatched` kind was deleted in the Epic #4406 signal-contract
33
+ * cutoverit had no live emitter and no consumer.
36
34
  *
37
35
  * Signals-writer `appendTrace` call sites (traces.ndjson sibling, but
38
36
  * sharing the same envelope shape — `tool-trace-hook.js`):
39
37
  * - `trace` — per-tool-call timing record
40
38
  *
41
- * Aggregator-consumed kinds (perf-aggregator.js scans these — emitters
42
- * for `hotspot`, `rework`, `churn`, `idle`, `retry` are future Epic #1030
43
- * detector Stories that the aggregator was built to receive; the schema
44
- * pins the names so emitters land on a known shape):
39
+ * Detector-emitted / aggregator-consumed kinds (`rework`, `retry` are
40
+ * emitted by `lib/signals/detectors/*`; `hotspot`, `churn`, `idle` remain
41
+ * pinned in the enum for the aggregator's kind-count rollup even though no
42
+ * live detector emits them):
45
43
  * - `hotspot`, `rework`, `churn`, `idle`, `retry`
46
44
  *
47
- * ## Common envelope
45
+ * ## Common envelope (canonical — Epic #4406 / Story #4413)
48
46
  *
49
47
  * Every signal MUST carry at minimum:
50
- * - `ts` — ISO-8601 timestamp string (writers historically used
51
- * `timestamp:` instead; both keys are accepted by the
52
- * guards below for backward-compat the migration to
53
- * `ts:` lands in a follow-on Story).
54
- * - `epic` — integer Epic ID (writers historically used `epicId:`;
55
- * same backward-compat note).
48
+ * - `ts` — ISO-8601 timestamp string. The single canonical
49
+ * timestamp key; the legacy `timestamp:` alias was deleted
50
+ * from every writer and this guard in the same PR.
56
51
  * - `kind` — one of `EVENT_KINDS`.
57
52
  *
53
+ * Scoped signals additionally carry:
54
+ * - `epicId` — integer Epic ID (or `null` for standalone-Story
55
+ * friction). The single canonical epic-id key; the legacy
56
+ * `epic:` alias was deleted.
57
+ *
58
58
  * Optional but commonly carried:
59
- * - `story` / `storyId` — integer Story ID
60
- * - `task` / `taskId` — integer Task ID (nullable)
61
- * - `source` — `{ tool: string }`
62
- * - `details` kind-specific payload (object or string)
59
+ * - `storyId` — integer Story ID
60
+ * - `taskId` — integer Task ID (nullable)
61
+ * - `emitter` — `{ tool: string, command?: string }` provenance
62
+ * - `source` `"framework" | "consumer"` classifier tag
63
+ * - `category` — top-level friction category string
64
+ * - `details` — kind-specific payload (always an object)
63
65
  *
64
66
  * @module lib/signals/schema
65
67
  */
@@ -77,7 +79,6 @@ import { isPositiveInt } from './detectors/common.js';
77
79
  export const EVENT_KINDS = Object.freeze({
78
80
  FRICTION: 'friction',
79
81
  TRACE: 'trace',
80
- DISPATCHED: 'dispatched',
81
82
  // Wave-window forensics signals: `wave-start` / `wave-end` anchor the
82
83
  // span-tree's Story spans, and the perf-aggregator brackets each wave's
83
84
  // wall-clock from `wave-start` → `wave-complete` (the `waveParallelism`
@@ -101,6 +102,13 @@ export const EVENT_KINDS = Object.freeze({
101
102
  // the retro and /plan Phase 0 feedback fetch can see acceptance
102
103
  // churn alongside friction/hotspot data.
103
104
  ACCEPTANCE_EVAL: 'acceptance-eval',
105
+ // Forensic breadcrumb appended to the per-Epic stream by the notify
106
+ // dispatcher (lib/orchestration/lifecycle/listeners/notify-dispatcher.js)
107
+ // when a lifecycle event maps to a webhook notification — the resume
108
+ // suite reads it back to prove a dispatch survived a crash window without
109
+ // duplicating. Enumerated so the write-time validator (Story #4413) does
110
+ // not drop it; it is a deliberate write, not malformed data.
111
+ NOTIFICATION_EMITTED: 'notification.emitted',
104
112
  });
105
113
 
106
114
  /**
@@ -117,20 +125,17 @@ export const EVENT_KIND_VALUES = Object.freeze(
117
125
  * spread string literals across the module graph.
118
126
  */
119
127
  export const FIELDS = Object.freeze({
120
- // Envelope (canonical namesmigration to these is in flight)
128
+ // Canonical envelope keysthere is exactly one key per concept. The
129
+ // legacy `timestamp` / `epic` / `story` / `task` aliases were deleted in
130
+ // the Epic #4406 signal-contract cutover; no reader tolerates them.
121
131
  TS: 'ts',
122
- EPIC: 'epic',
123
- STORY: 'story',
124
- TASK: 'task',
125
- KIND: 'kind',
126
-
127
- // Legacy envelope aliases still emitted by some writers
128
- TIMESTAMP: 'timestamp',
129
132
  EPIC_ID: 'epicId',
130
133
  STORY_ID: 'storyId',
131
134
  TASK_ID: 'taskId',
135
+ KIND: 'kind',
132
136
 
133
137
  // Common payload fields
138
+ EMITTER: 'emitter',
134
139
  SOURCE: 'source',
135
140
  DETAILS: 'details',
136
141
  CATEGORY: 'category',
@@ -150,8 +155,9 @@ function isTimestamp(v) {
150
155
  }
151
156
 
152
157
  /**
153
- * Common envelope guard: every signal MUST carry `ts` (or legacy
154
- * `timestamp`), `epic` (or legacy `epicId`), and a recognised `kind`.
158
+ * Common envelope guard: every signal MUST carry `ts`, `epicId`, and a
159
+ * recognised `kind`. Canonical keys only — the legacy `timestamp` / `epic`
160
+ * aliases were deleted in the Epic #4406 cutover.
155
161
  *
156
162
  * Returns true when the envelope is well-formed. Used by `lib/signals/read`
157
163
  * to discard malformed lines before yielding them to the consumer.
@@ -164,16 +170,24 @@ export function hasCommonEnvelope(evt) {
164
170
  if (typeof evt.kind !== 'string' || !EVENT_KIND_VALUES.has(evt.kind)) {
165
171
  return false;
166
172
  }
167
- const ts = evt.ts ?? evt.timestamp;
168
- if (!isTimestamp(ts)) return false;
169
- const epic = evt.epic ?? evt.epicId;
170
- if (!isPositiveInt(epic)) return false;
173
+ if (!isTimestamp(evt.ts)) return false;
174
+ // The canonical `epicId` key MUST be present — a record carrying only the
175
+ // legacy `epic` alias is rejected (the cutover deleted that alias). Its
176
+ // value is nullable by contract: standalone-Story friction carries
177
+ // `epicId: null` (see signal-event.schema.json, where epicId is
178
+ // `["integer","null"]`). So accept an explicit null, reject a missing key
179
+ // or a present-but-non-positive-int value.
180
+ if (!Object.hasOwn(evt, 'epicId')) return false;
181
+ if (evt.epicId !== null && !isPositiveInt(evt.epicId)) return false;
171
182
  return true;
172
183
  }
173
184
 
174
185
  /**
175
186
  * Generic per-kind guard. Returns true when the envelope is well-formed
176
- * AND (when `kind` is supplied) the event's `kind` matches.
187
+ * AND (when `kind` is supplied) the event's `kind` matches. The full
188
+ * canonical-shape check lives in the AJV validator compiled from
189
+ * `signal-event.schema.json` (see `lib/observability/signal-validator.js`);
190
+ * this predicate is the cheap envelope gate the streaming reader uses.
177
191
  *
178
192
  * @param {unknown} evt
179
193
  * @param {string} [kind] — optional kind to match (one of `EVENT_KINDS`).
@@ -184,42 +198,3 @@ export function isValidSignal(evt, kind) {
184
198
  if (kind != null && evt.kind !== kind) return false;
185
199
  return true;
186
200
  }
187
-
188
- /**
189
- * Per-kind shape guards. Each entry asserts the envelope plus any
190
- * required per-kind fields. Unknown kinds fall back to the envelope
191
- * check.
192
- *
193
- * The guards are intentionally lax — they reject records that are
194
- * obviously malformed (missing `kind`, missing `ts`, missing `epic`),
195
- * not records with extra fields or future schema extensions. The
196
- * aggregator (perf-aggregator.js) carries its own per-kind narrowing.
197
- *
198
- * @type {Readonly<Record<string, (evt: unknown) => boolean>>}
199
- */
200
- export const GUARDS = Object.freeze({
201
- [EVENT_KINDS.FRICTION]: (evt) => {
202
- if (!isValidSignal(evt, EVENT_KINDS.FRICTION)) return false;
203
- // friction signals commonly carry a `category` field, but some
204
- // writers (early in the migration) omit it. We accept both.
205
- return true;
206
- },
207
- [EVENT_KINDS.TRACE]: (evt) => isValidSignal(evt, EVENT_KINDS.TRACE),
208
- [EVENT_KINDS.DISPATCHED]: (evt) => isValidSignal(evt, EVENT_KINDS.DISPATCHED),
209
- [EVENT_KINDS.WAVE_START]: (evt) => isValidSignal(evt, EVENT_KINDS.WAVE_START),
210
- [EVENT_KINDS.WAVE_END]: (evt) => isValidSignal(evt, EVENT_KINDS.WAVE_END),
211
- [EVENT_KINDS.WAVE_TICK]: (evt) => isValidSignal(evt, EVENT_KINDS.WAVE_TICK),
212
- [EVENT_KINDS.WAVE_COMPLETE]: (evt) =>
213
- isValidSignal(evt, EVENT_KINDS.WAVE_COMPLETE),
214
- [EVENT_KINDS.EPIC_COMPLETE]: (evt) =>
215
- isValidSignal(evt, EVENT_KINDS.EPIC_COMPLETE),
216
- [EVENT_KINDS.STATE_TRANSITION]: (evt) =>
217
- isValidSignal(evt, EVENT_KINDS.STATE_TRANSITION),
218
- [EVENT_KINDS.HOTSPOT]: (evt) => isValidSignal(evt, EVENT_KINDS.HOTSPOT),
219
- [EVENT_KINDS.REWORK]: (evt) => isValidSignal(evt, EVENT_KINDS.REWORK),
220
- [EVENT_KINDS.CHURN]: (evt) => isValidSignal(evt, EVENT_KINDS.CHURN),
221
- [EVENT_KINDS.IDLE]: (evt) => isValidSignal(evt, EVENT_KINDS.IDLE),
222
- [EVENT_KINDS.RETRY]: (evt) => isValidSignal(evt, EVENT_KINDS.RETRY),
223
- [EVENT_KINDS.ACCEPTANCE_EVAL]: (evt) =>
224
- isValidSignal(evt, EVENT_KINDS.ACCEPTANCE_EVAL),
225
- });
@@ -51,20 +51,21 @@
51
51
  */
52
52
 
53
53
  function tsOf(evt) {
54
- // Schema accepts both `ts` (canonical) and `timestamp` (legacy).
55
- return evt?.ts ?? evt?.timestamp ?? null;
54
+ // Canonical envelope only (Epic #4406) the legacy `timestamp` alias
55
+ // was deleted from every writer in the same PR.
56
+ return evt?.ts ?? null;
56
57
  }
57
58
 
58
59
  function epicOf(evt) {
59
- return evt?.epic ?? evt?.epicId ?? null;
60
+ return evt?.epicId ?? null;
60
61
  }
61
62
 
62
63
  function storyOf(evt) {
63
- return evt?.story ?? evt?.storyId ?? null;
64
+ return evt?.storyId ?? null;
64
65
  }
65
66
 
66
67
  function taskOf(evt) {
67
- return evt?.task ?? evt?.taskId ?? null;
68
+ return evt?.taskId ?? null;
68
69
  }
69
70
 
70
71
  /**
@@ -193,6 +193,7 @@ export async function loadBodyTemplate(projectRoot) {
193
193
  * bodyTemplate: string,
194
194
  * duplicateCandidates: Array<object>,
195
195
  * techStack?: string|null,
196
+ * corpusContext?: { docsDigest: string|null, relevantSections: Array<object> }|null,
196
197
  * maxResults?: number,
197
198
  * }} opts
198
199
  */
@@ -203,6 +204,7 @@ export function buildContextEnvelope({
203
204
  bodyTemplate,
204
205
  duplicateCandidates,
205
206
  techStack = null,
207
+ corpusContext = null,
206
208
  maxResults = DEFAULT_DUPLICATE_MAX_RESULTS,
207
209
  }) {
208
210
  return {
@@ -218,6 +220,7 @@ export function buildContextEnvelope({
218
220
  candidates: duplicateCandidates,
219
221
  },
220
222
  techStack,
223
+ corpusContext,
221
224
  deliverContract: {
222
225
  workflow: '.agents/workflows/helpers/single-story-deliver.md',
223
226
  requiredLabels: ['type::story', `persona::${persona}`],
@@ -425,7 +425,13 @@ export function planTick(state, records, inFlight) {
425
425
  inFlight.length === 0
426
426
  ) {
427
427
  signals.push({
428
+ // `index` is what the perf-aggregator's `bucketWaves` keys on to
429
+ // bracket a wave's wall-clock (Epic #4406 / Story #4413). Without
430
+ // it every wave-start collapsed to NaN and the waveParallelism
431
+ // table rendered structurally empty. The continuous ready-set
432
+ // scheduler runs a single logical wave, so `index: 0`.
428
433
  kind: 'wave-start',
434
+ index: 0,
429
435
  stories: records.map((s) => ({ id: s.id, title: s.title })),
430
436
  });
431
437
  }
@@ -443,7 +449,7 @@ export function planTick(state, records, inFlight) {
443
449
  nextAction = { kind: 'observe', waitingOn };
444
450
  } else if (allDone) {
445
451
  // Every Story is done and nothing is in flight: the run is complete.
446
- signals.push({ kind: 'wave-complete' });
452
+ signals.push({ kind: 'wave-complete', index: 0 });
447
453
  nextAction = { kind: 'epic-complete' };
448
454
  } else {
449
455
  // Ready set empty, nothing in flight, but not all Stories are done — a
@@ -739,7 +745,9 @@ function defaultSignalEmit(epicId, ctx) {
739
745
  return async (signal) => {
740
746
  await appendEpicSignal({
741
747
  epicId,
742
- signal: { ts: new Date().toISOString(), epic: epicId, ...signal },
748
+ // Canonical envelope (Epic #4406 / Story #4413): the single
749
+ // `epicId` key — the legacy `epic` alias is gone.
750
+ signal: { ts: new Date().toISOString(), epicId, ...signal },
743
751
  config: ctx?.config,
744
752
  });
745
753
  };
@@ -130,14 +130,27 @@ export function parseArgv(argv) {
130
130
  }
131
131
 
132
132
  /**
133
- * Build the bus emit payload from parsed argv. `event` is consumed.
134
- * `--epic` is mapped to `epicId` (integer). All other flags are
135
- * mapped from kebab-case to camelCase with light value coercion.
133
+ * Argv keys that are runtime OPTIONS consumed by `main()` /
134
+ * `runLifecycleEmit()` directly rather than forwarded into the emitted
135
+ * bus payload. `--headless` (Story #4427) is the first member: every
136
+ * lifecycle event schema declares `additionalProperties: false`, so a
137
+ * flag that selects delivery-run behavior (rather than describing the
138
+ * event itself) would fail schema validation if it leaked into the
139
+ * payload.
140
+ */
141
+ const RUNTIME_OPTION_KEYS = Object.freeze(['event', 'headless']);
142
+
143
+ /**
144
+ * Build the bus emit payload from parsed argv. `event` and `headless`
145
+ * (see `RUNTIME_OPTION_KEYS`) are consumed as runtime options, not
146
+ * payload fields. `--epic` is mapped to `epicId` (integer). All other
147
+ * flags are mapped from kebab-case to camelCase with light value
148
+ * coercion.
136
149
  */
137
150
  export function buildPayload(parsed) {
138
151
  const payload = {};
139
152
  for (const [key, raw] of Object.entries(parsed)) {
140
- if (key === 'event') continue;
153
+ if (RUNTIME_OPTION_KEYS.includes(key)) continue;
141
154
  if (key === 'epic') {
142
155
  const n = Number.parseInt(raw, 10);
143
156
  if (!Number.isInteger(n) || n < 1) {
@@ -261,11 +274,16 @@ export async function emitBlockedSignal({
261
274
  config,
262
275
  signal: {
263
276
  kind: 'friction',
277
+ ts: new Date().toISOString(),
278
+ epicId,
279
+ category: 'lifecycle-listener-failure',
280
+ emitter: { tool: 'lifecycle-emit.js' },
264
281
  severity: 'high',
265
282
  event,
266
- message: `lifecycle-emit: ${event} produced failed listener classification(s): ${reasons}`,
267
- outcomes: failedOutcomes,
268
- timestamp: new Date().toISOString(),
283
+ details: {
284
+ message: `lifecycle-emit: ${event} produced failed listener classification(s): ${reasons}`,
285
+ outcomes: failedOutcomes,
286
+ },
269
287
  },
270
288
  });
271
289
  } catch (err) {
@@ -321,6 +339,11 @@ export async function emitBlockedSignal({
321
339
  * alongside an injected `bus` (when the caller owns listener wiring) —
322
340
  * lets a test inject a bus + a chain carrying a `failed`-classifying
323
341
  * listener to exercise the non-zero-exit path without the real roster.
342
+ * @param {boolean} [opts.headless] Explicit must-land signal (Story
343
+ * #4427), forwarded to `buildDefaultListenerChain({ headless })` (and
344
+ * from there to `MergeWatcher`) when this call constructs the default
345
+ * chain. Defaults to `false` — attended-mode behavior is unchanged.
346
+ * Ignored when the caller injects its own `bus`/`chain`.
324
347
  *
325
348
  * @returns {Promise<{ event: string, payload: object, seqId: number,
326
349
  * outcomes: Array<object>, failed: boolean }>} The `outcomes[]` array
@@ -340,6 +363,7 @@ export async function runLifecycleEmit({
340
363
  config,
341
364
  emitBlockedSignalFn = emitBlockedSignal,
342
365
  chain: injectedChain,
366
+ headless = false,
343
367
  } = {}) {
344
368
  if (typeof event !== 'string' || event.length === 0) {
345
369
  throw new Error('lifecycle-emit: --event is required');
@@ -404,6 +428,7 @@ export async function runLifecycleEmit({
404
428
  checkpointer: resolvedCheckpointer,
405
429
  config: resolvedConfig,
406
430
  logger,
431
+ headless,
407
432
  });
408
433
  }
409
434
  const { seqId } = await targetBus.emit(event, payload ?? {});
@@ -464,7 +489,13 @@ async function main() {
464
489
  }
465
490
  const event = parsed.event;
466
491
  const payload = buildPayload(parsed);
467
- const out = await runLifecycleEmit({ event, payload });
492
+ // `--headless true` is a runtime option (Story #4427), not a payload
493
+ // field — see RUNTIME_OPTION_KEYS. Threaded explicitly through
494
+ // runLifecycleEmit → buildDefaultListenerChain → MergeWatcher so the
495
+ // epic-path must-land terminal step reads a caller-supplied input
496
+ // rather than an ambient global.
497
+ const headless = parsed.headless === 'true';
498
+ const out = await runLifecycleEmit({ event, payload, headless });
468
499
  process.stdout.write(`${JSON.stringify(out)}\n`);
469
500
  // Exit non-zero when any listener classification came back `failed` so
470
501
  // the workflow's "re-run on non-zero" loop closes the partial-finalize
@@ -150,6 +150,14 @@ export class IssuesGateway {
150
150
  /**
151
151
  * List Epic-typed issues. Filter shape preserved from the old code.
152
152
  *
153
+ * `filters.pageCap` / `filters.perPage` pass through to `paginateRest`
154
+ * so a caller that only needs a bounded shortlist (e.g. the
155
+ * standalone-Story corpus lookup in `planning-corpus.js`, which ranks
156
+ * candidates down to a top-5 shortlist) can cap the scan instead of
157
+ * inheriting the default 50-page / 5000-item ceiling. Omitted, both
158
+ * fall back to `paginateRest`'s own defaults — unchanged behavior for
159
+ * existing callers (e.g. `duplicate-search.js`).
160
+ *
153
161
  * @field-manifest /repos/{owner}/{repo}/issues?labels=type::epic: number,
154
162
  * title, labels, state, state_reason, pull_request
155
163
  */
@@ -160,7 +168,10 @@ export class IssuesGateway {
160
168
  labels: TYPE_LABELS.EPIC,
161
169
  });
162
170
  const endpoint = `/repos/${this.owner}/${this.repo}/issues?${params}`;
163
- const issues = await paginateRest(this._gh, endpoint);
171
+ const issues = await paginateRest(this._gh, endpoint, {
172
+ pageCap: filters.pageCap,
173
+ perPage: filters.perPage,
174
+ });
164
175
  return issues
165
176
  .filter((issue) => !issue.pull_request)
166
177
  .map(issueToEpicListItem);
@@ -0,0 +1,83 @@
1
+ /**
2
+ * CLI: resolve the repository's documentation read-tiers (Story #4438).
3
+ *
4
+ * Thin wrapper over `lib/doc-tiers.js#resolveDocTiers` that prints the tier
5
+ * map — `{ tiers: { alwaysLoaded, mandatoryRead, digestVisible, onDemand } }`,
6
+ * every entry `{ path, bytes }` — as JSON. Consumed by the `audit-documentation`
7
+ * lens (read-tier severity weighting), the `check-context-budget.js` ratchet,
8
+ * and operators inspecting the always-loaded closure.
9
+ *
10
+ * Flags:
11
+ * --json emit the tier map as JSON to stdout (default rendering is also
12
+ * JSON; the flag is accepted for parity with the sibling ratchets
13
+ * and future non-JSON renderings).
14
+ * --root <path> resolve tiers against an explicit repo root (default: the
15
+ * resolved PROJECT_ROOT).
16
+ *
17
+ * Exit code is always 0 on success — this is a reporter, not a gate.
18
+ */
19
+
20
+ import process from 'node:process';
21
+ import { runAsCli } from './lib/cli-utils.js';
22
+ import { PROJECT_ROOT, resolveConfig } from './lib/config-resolver.js';
23
+ import { resolveDocTiers } from './lib/doc-tiers.js';
24
+
25
+ /**
26
+ * Parse argv for `--root <path>` and `--json`.
27
+ *
28
+ * @param {string[]} argv
29
+ * @returns {{ rootPath: string | null, json: boolean }}
30
+ */
31
+ export function parseArgv(argv = []) {
32
+ let rootPath = null;
33
+ let json = false;
34
+ for (let i = 0; i < argv.length; i += 1) {
35
+ const a = argv[i];
36
+ if (a === '--root') {
37
+ const next = argv[i + 1];
38
+ if (next && !next.startsWith('--')) {
39
+ rootPath = next;
40
+ i += 1;
41
+ }
42
+ } else if (a === '--json') {
43
+ json = true;
44
+ }
45
+ }
46
+ return { rootPath, json };
47
+ }
48
+
49
+ /**
50
+ * Top-level CLI entry. Exported so tests can drive it against a fixture root
51
+ * with an injected sink and config.
52
+ *
53
+ * @param {{
54
+ * argv?: string[],
55
+ * config?: object,
56
+ * root?: string,
57
+ * stdout?: { write: (s: string) => void },
58
+ * }} [opts]
59
+ * @returns {Promise<number>} always 0
60
+ */
61
+ export async function runCli({
62
+ argv = process.argv.slice(2),
63
+ config,
64
+ root,
65
+ stdout = process.stdout,
66
+ } = {}) {
67
+ const { rootPath } = parseArgv(argv);
68
+ const resolvedConfig = config ?? resolveConfig();
69
+ const resolvedRoot = root ?? rootPath ?? PROJECT_ROOT;
70
+ const result = resolveDocTiers(resolvedConfig, { root: resolvedRoot });
71
+ stdout.write(`${JSON.stringify(result, null, 2)}\n`);
72
+ return 0;
73
+ }
74
+
75
+ async function main() {
76
+ return runCli();
77
+ }
78
+
79
+ runAsCli(import.meta.url, main, {
80
+ source: 'resolve-doc-tiers',
81
+ propagateExitCode: true,
82
+ errorPrefix: '[resolve-doc-tiers] ❌ Fatal error',
83
+ });
@@ -37,6 +37,46 @@ import { createLedgerWriter } from './lib/orchestration/lifecycle/ledger-writer.
37
37
  import { runRetro } from './lib/orchestration/retro-runner.js';
38
38
  import { createProvider } from './lib/provider-factory.js';
39
39
 
40
+ /**
41
+ * Pure: resolve the framework/consumer repo slugs (`"<owner>/<repo>"`)
42
+ * from a resolved config's `github` block, for threading into the retro's
43
+ * routed-proposal composer.
44
+ *
45
+ * - `consumerRepo` is the project's OWN repo (`github.owner/github.repo`).
46
+ * It has **no** silent fallback: when `github.owner` / `github.repo` are
47
+ * absent the slug is `''`, which `gatherRetroSignals` treats as "disable
48
+ * the consumer pane loudly" rather than routing consumer-tagged friction
49
+ * at the framework mirror (Story #4417).
50
+ * - `frameworkRepo` is the distinct framework mirror
51
+ * (`github.frameworkRepo.{owner,repo}`) when configured, otherwise `''`
52
+ * — `gatherRetroSignals` then falls back to its `DEFAULT_FRAMEWORK_REPO`
53
+ * constant (a stable, known default is legitimate for the framework).
54
+ *
55
+ * Exported for tests.
56
+ *
57
+ * @param {object} [config]
58
+ * @returns {{ frameworkRepo: string, consumerRepo: string }}
59
+ */
60
+ export function resolveRetroRepos(config) {
61
+ const gh = config?.github ?? {};
62
+ const consumerRepo =
63
+ typeof gh.owner === 'string' &&
64
+ gh.owner.length > 0 &&
65
+ typeof gh.repo === 'string' &&
66
+ gh.repo.length > 0
67
+ ? `${gh.owner}/${gh.repo}`
68
+ : '';
69
+ const fw = gh.frameworkRepo ?? {};
70
+ const frameworkRepo =
71
+ typeof fw.owner === 'string' &&
72
+ fw.owner.length > 0 &&
73
+ typeof fw.repo === 'string' &&
74
+ fw.repo.length > 0
75
+ ? `${fw.owner}/${fw.repo}`
76
+ : '';
77
+ return { frameworkRepo, consumerRepo };
78
+ }
79
+
40
80
  const HELP = `Usage: node .agents/scripts/retro-run.js --epic <epicId> [--full-retro]
41
81
 
42
82
  Composes and posts the Epic retro structured comment for Epic #<epicId>,
@@ -125,11 +165,22 @@ export async function runRetroCli({
125
165
  : createLedgerWriter({ epicId, tempRoot });
126
166
  ledger.register(bus);
127
167
 
168
+ // Story #4417 — thread the framework/consumer repo slugs resolved from
169
+ // `config.github` into the retro so the routed-proposal composer files
170
+ // consumer-tagged friction at the project's own repo (not the framework
171
+ // mirror), and disables the consumer pane loudly when unresolved.
172
+ const { frameworkRepo, consumerRepo } = resolveRetroRepos(config);
173
+
128
174
  const result = await runRetroFn({
129
175
  epicId,
130
176
  provider,
131
177
  bus,
132
178
  forceFull: fullRetro,
179
+ frameworkRepo,
180
+ consumerRepo,
181
+ // Story #4418 — thread the resolved config so the retro auto-filer can
182
+ // read the `delivery.feedbackLoop.retroProposals` toggle.
183
+ config,
133
184
  logger,
134
185
  });
135
186
 
@@ -161,7 +161,7 @@ function formatDuration(ms) {
161
161
  }
162
162
 
163
163
  function describeEvent(evt) {
164
- const ts = evt?.ts ?? evt?.timestamp ?? '(no ts)';
164
+ const ts = evt?.ts ?? '(no ts)';
165
165
  const kind = evt?.kind ?? '(no kind)';
166
166
  const phase = evt?.phase ? ` phase=${evt.phase}` : '';
167
167
  const category = evt?.category ? ` category=${evt.category}` : '';
@@ -23,6 +23,14 @@
23
23
  * the post-merge confirmation step,
24
24
  * `single-story-confirm-merge.js`)
25
25
  * 8. worktree-reap — drop the per-Story worktree
26
+ * 9. confirm-merge — Story #4428, headless-only (`--wait-merge`):
27
+ * poll the just-armed PR to merge confirmation
28
+ * (reusing `confirmStoryMerged`) or terminate
29
+ * `agent::blocked` with a classified
30
+ * `merge.unlanded` lifecycle event. Attended runs
31
+ * (the default, no `--wait-merge`) skip this
32
+ * phase entirely and keep resting at
33
+ * `agent::closing`, exactly as before.
26
34
  *
27
35
  * Existing tests import the re-exported helpers
28
36
  * (`runSingleStoryClose`, `ensurePullRequest`, `parsePrNumber`,
@@ -33,8 +41,19 @@
33
41
  * node single-story-close.js --story <STORY_ID> [--cwd <main-repo>]
34
42
  * [--skip-validation] [--skip-sync]
35
43
  * [--no-auto-merge] [--no-full-scope-crap]
44
+ * [--wait-merge | --no-wait-merge]
36
45
  *
37
- * Exit codes: 0 ok, 1 error.
46
+ * `--wait-merge` is the headless must-land signal (Story #4428, Epic
47
+ * #4425): the invoking surface (a headless `/single-story-deliver` run, a
48
+ * CI-driven wrapper, or `/deliver`'s standalone multi-Story fan-out) opts
49
+ * in explicitly — attended runs never pass it, so the default exit shape
50
+ * (rest at `agent::closing`, issue OPEN) is unchanged. `--no-wait-merge` is
51
+ * the explicit opt-out that always wins over `--wait-merge`, for a caller
52
+ * that wants to manage merge confirmation externally even in an otherwise
53
+ * headless context.
54
+ *
55
+ * Exit codes: 0 ok, 1 error (including a headless `--wait-merge` run that
56
+ * gave up without a confirmed merge — see phase 9 above).
38
57
  *
39
58
  * @see .agents/workflows/helpers/single-story-deliver.md
40
59
  */