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
@@ -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
  */
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * standalone-feedback-rollup.js — end-of-run feedback rollup for the
5
+ * `/deliver` standalone multi-Story path (Epic #4406 / Story #4416).
6
+ *
7
+ * The standalone delivery path (`helpers/deliver-stories`) drives one or
8
+ * more Epic-free Stories to green, and each per-Story sub-agent appends
9
+ * `friction` records to its **standalone** signals stream at
10
+ * `temp/standalone/stories/story-<sid>/signals.ndjson` (written via
11
+ * `appendSignal({ epicId: null, storyId })`). Before this rollup that
12
+ * tree was write-only — nothing ever read it back. This CLI closes the
13
+ * gap: it scans the standalone streams for the delivered Story set and
14
+ * emits a per-category friction summary that `deliver-stories.md`
15
+ * surfaces in its Phase 3 run summary.
16
+ *
17
+ * Usage:
18
+ * node standalone-feedback-rollup.js --stories <id,...>
19
+ *
20
+ * Contract:
21
+ * - Stream paths resolve **exclusively** through the `temp-paths`
22
+ * helpers via `forEachLine(null, storyId, cb, config)` — the `null`
23
+ * Epic sentinel routes to the standalone tree. No hand-built path
24
+ * strings.
25
+ * - Aggregates `friction` records by their **top-level** `category`
26
+ * (Epic #4406 canonical envelope shape); a record with no category
27
+ * buckets under `Unknown`.
28
+ * - **Never fails a run.** A missing or empty stream degrades to an
29
+ * empty contribution; the CLI always prints a JSON summary and
30
+ * exits 0. Observability MUST NOT take down the delivering flow.
31
+ *
32
+ * Output (stdout, one JSON object):
33
+ * {
34
+ * "kind": "standalone-feedback-rollup",
35
+ * "stories": [<id>, ...],
36
+ * "totalFriction": <int>,
37
+ * "byCategory": { "<category>": <count>, ... },
38
+ * "perStory": { "<id>": { "friction": <int>, "missing": <bool> }, ... }
39
+ * }
40
+ *
41
+ * @see .agents/scripts/lib/observability/signals-writer.js (forEachLine, appendSignal)
42
+ * @see .agents/scripts/lib/config/temp-paths.js (signalsFile — null Epic sentinel)
43
+ * @see .agents/workflows/helpers/deliver-stories.md (Phase 3 summary wiring)
44
+ */
45
+
46
+ import { runAsCli } from './lib/cli-utils.js';
47
+ import { resolveConfig } from './lib/config-resolver.js';
48
+ import { forEachLine } from './lib/observability/signals-writer.js';
49
+
50
+ const FRICTION_KIND = 'friction';
51
+ const UNKNOWN_CATEGORY = 'Unknown';
52
+
53
+ /**
54
+ * Parse `--stories <id,...>` into an ordered, de-duplicated array of
55
+ * positive-integer Story IDs. Accepts a single comma-separated value or
56
+ * repeated `--stories` flags. Non-integer / non-positive tokens are
57
+ * rejected so a typo surfaces as an input error rather than a silently
58
+ * empty rollup.
59
+ *
60
+ * @param {string[]} args
61
+ * @returns {{ stories: number[] }}
62
+ */
63
+ export function parseArguments(args) {
64
+ const raw = [];
65
+ for (let i = 0; i < args.length; i++) {
66
+ if (args[i] === '--stories') {
67
+ const value = args[++i];
68
+ if (typeof value === 'string') {
69
+ raw.push(...value.split(','));
70
+ }
71
+ }
72
+ }
73
+
74
+ const seen = new Set();
75
+ const stories = [];
76
+ for (const token of raw) {
77
+ const trimmed = token.trim();
78
+ if (trimmed.length === 0) continue;
79
+ const id = Number(trimmed);
80
+ if (!Number.isInteger(id) || id <= 0) {
81
+ throw new Error(
82
+ `--stories expects comma-separated positive integers; got ${JSON.stringify(trimmed)}`,
83
+ );
84
+ }
85
+ if (seen.has(id)) continue;
86
+ seen.add(id);
87
+ stories.push(id);
88
+ }
89
+
90
+ if (stories.length === 0) {
91
+ throw new Error(
92
+ 'Usage: node standalone-feedback-rollup.js --stories <id,...>',
93
+ );
94
+ }
95
+
96
+ return { stories };
97
+ }
98
+
99
+ /**
100
+ * Read one standalone Story's signals stream and aggregate its `friction`
101
+ * records by top-level `category`, mutating the shared `byCategory` tally.
102
+ *
103
+ * Degrades to a zero contribution on a missing / empty / unreadable
104
+ * stream — `forEachLine` already swallows fs + JSON faults and reports a
105
+ * missing file via `{ missing: true }` rather than throwing.
106
+ *
107
+ * @param {number} storyId
108
+ * @param {Record<string, number>} byCategory Mutated in place.
109
+ * @param {object} config
110
+ * @returns {Promise<{ friction: number, missing: boolean }>}
111
+ */
112
+ async function rollupStory(storyId, byCategory, config) {
113
+ let friction = 0;
114
+ // `epicId: null` routes forEachLine → signalsFile(null, storyId, config)
115
+ // → the standalone tree. Never a hand-built path string.
116
+ const result = await forEachLine(
117
+ null,
118
+ storyId,
119
+ (record) => {
120
+ if (
121
+ record === null ||
122
+ typeof record !== 'object' ||
123
+ record.kind !== FRICTION_KIND
124
+ ) {
125
+ return;
126
+ }
127
+ const category =
128
+ typeof record.category === 'string' && record.category.length > 0
129
+ ? record.category
130
+ : UNKNOWN_CATEGORY;
131
+ byCategory[category] = (byCategory[category] ?? 0) + 1;
132
+ friction += 1;
133
+ },
134
+ config,
135
+ );
136
+
137
+ return { friction, missing: Boolean(result?.missing) };
138
+ }
139
+
140
+ /**
141
+ * Aggregate friction across every named standalone Story stream.
142
+ *
143
+ * @param {number[]} stories
144
+ * @param {object} [config]
145
+ * @returns {Promise<{
146
+ * kind: string,
147
+ * stories: number[],
148
+ * totalFriction: number,
149
+ * byCategory: Record<string, number>,
150
+ * perStory: Record<string, { friction: number, missing: boolean }>,
151
+ * }>}
152
+ */
153
+ export async function buildRollup(stories, config) {
154
+ const byCategory = {};
155
+ const perStory = {};
156
+ let totalFriction = 0;
157
+
158
+ for (const storyId of stories) {
159
+ const { friction, missing } = await rollupStory(
160
+ storyId,
161
+ byCategory,
162
+ config,
163
+ );
164
+ perStory[storyId] = { friction, missing };
165
+ totalFriction += friction;
166
+ }
167
+
168
+ return {
169
+ kind: 'standalone-feedback-rollup',
170
+ stories,
171
+ totalFriction,
172
+ byCategory,
173
+ perStory,
174
+ };
175
+ }
176
+
177
+ export async function main(args = process.argv.slice(2)) {
178
+ const { stories } = parseArguments(args);
179
+ const config = resolveConfig();
180
+ const summary = await buildRollup(stories, config);
181
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
182
+ return 0;
183
+ }
184
+
185
+ runAsCli(import.meta.url, main, {
186
+ source: 'StandaloneFeedbackRollup',
187
+ propagateExitCode: true,
188
+ });
@@ -10,6 +10,9 @@
10
10
  * envelope. Pipeline shape:
11
11
  *
12
12
  * 1. parse + resolveCloseInputs (lib/orchestration/story-close/close-inputs.js)
13
+ * 1.5. already-done no-op guard (inline below) — a Story already at
14
+ * `agent::done` AND closed short-circuits here so no phase below it
15
+ * ever flips the label away from `agent::done` or reopens the issue.
13
16
  * 2. preflight (phases/preflight.js)
14
17
  * 3. state-flip → closing (inline helper below)
15
18
  * 4. capture starting branch (phases/branch-restore.js)
@@ -152,6 +155,51 @@ export async function runStoryClose({
152
155
  storyBranch,
153
156
  } = resolved;
154
157
 
158
+ // Phase 0 — idempotency guard. A re-run against a Story that is already
159
+ // fully closed must be a safe no-op. Without this guard, Phase 3 below
160
+ // (`transitionToClosing`) unconditionally flips the label to
161
+ // `agent::closing` — and `transitionTicketState`
162
+ // (lib/orchestration/ticketing/transition.js) unconditionally sends
163
+ // `state: 'open'` for any non-`agent::done` target — so re-running close
164
+ // against an already-closed Story reopens the GitHub issue and strips
165
+ // `agent::done` *before* the post-merge pipeline even starts. If the
166
+ // pipeline then throws or a phase silently no-ops (the merge is already
167
+ // fully reaped, so there is nothing left to redo), the Story is left
168
+ // regressed at `agent::closing`/OPEN instead of a safe no-op.
169
+ //
170
+ // The "already fully closed" test mirrors the deepest-level guard in the
171
+ // post-merge `ticketClosurePhase` (post-merge/phases/ticket-closure.js):
172
+ // label `agent::done` AND issue `state === 'closed'`, an AND — never a
173
+ // state-alone check. The `state === 'closed'` disjunct is deliberately
174
+ // excluded per the hard-won reasoning in
175
+ // `lib/single-story/confirm-merge.js`: a Story can arrive here closed at
176
+ // the GitHub layer (a `Closes #<id>` PR footer, or a prior close that set
177
+ // `state: closed` but was killed before the label flip landed) while its
178
+ // label is legitimately still `agent::closing`. Treating that as
179
+ // already-done would skip the very `closing → done` re-assertion the
180
+ // re-run exists to perform, stranding the label. Requiring BOTH signals
181
+ // no-ops only a genuinely finished Story.
182
+ const alreadyDone =
183
+ Array.isArray(story?.labels) &&
184
+ story.labels.includes(STATE_LABELS.DONE) &&
185
+ story?.state === 'closed';
186
+ if (alreadyDone) {
187
+ progress(
188
+ 'SKIP',
189
+ `Story #${storyId} is already ${STATE_LABELS.DONE} and closed — no-op re-run.`,
190
+ );
191
+ return {
192
+ success: true,
193
+ result: {
194
+ storyId,
195
+ epicId,
196
+ action: 'noop',
197
+ reason: 'already-done',
198
+ merged: true,
199
+ },
200
+ };
201
+ }
202
+
155
203
  const notifyFn = (ticketId, payload, opts = {}) =>
156
204
  notify(ticketId, payload, { config, provider, ...opts });
157
205