mandrel 2.54.0 → 2.56.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 (134) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +8 -2
  5. package/.agents/docs/configuration.md +5 -0
  6. package/.agents/rules/ci-remediation.md +39 -21
  7. package/.agents/schemas/agentrc.schema.json +34 -1
  8. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  10. package/.agents/scripts/audit-to-stories.js +374 -76
  11. package/.agents/scripts/check-audit-attribution.js +119 -62
  12. package/.agents/scripts/check-test-portability.js +512 -0
  13. package/.agents/scripts/coverage-capture.js +17 -10
  14. package/.agents/scripts/evidence-gate.js +31 -4
  15. package/.agents/scripts/file-ci-gap.js +306 -0
  16. package/.agents/scripts/generate-workflows-doc.js +65 -14
  17. package/.agents/scripts/git-cleanup.js +4 -0
  18. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  19. package/.agents/scripts/lib/audit-advisories.js +195 -0
  20. package/.agents/scripts/lib/audit-attribution.js +22 -0
  21. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
  23. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  24. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  25. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  26. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  27. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
  28. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  29. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  30. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  31. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  32. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  33. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  34. package/.agents/scripts/lib/cli-args.js +26 -0
  35. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  36. package/.agents/scripts/lib/close-validation/process.js +7 -3
  37. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  38. package/.agents/scripts/lib/config/ci.js +28 -9
  39. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  40. package/.agents/scripts/lib/config-settings-schema.js +52 -1
  41. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  42. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  43. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  44. package/.agents/scripts/lib/coverage-capture.js +77 -3
  45. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  46. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  47. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  48. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  49. package/.agents/scripts/lib/findings/route-finding.js +42 -2
  50. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  51. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  52. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  53. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  54. package/.agents/scripts/lib/label-constants.js +6 -1
  55. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  56. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  57. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  58. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  59. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  60. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  61. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  62. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  63. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  64. package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
  65. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  66. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  67. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  68. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  69. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  70. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  71. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  72. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  73. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  74. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  75. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  76. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
  77. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  78. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  79. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
  80. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  81. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  82. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  83. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  84. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  85. package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
  86. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  92. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  93. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  94. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  95. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  96. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  97. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
  98. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  99. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  100. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  101. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  102. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  103. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  104. package/.agents/scripts/lib/test-temp.js +167 -30
  105. package/.agents/scripts/lib/validation-evidence.js +37 -0
  106. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  107. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  108. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  109. package/.agents/scripts/merge-baseline.js +175 -21
  110. package/.agents/scripts/pr-watch-with-update.js +3 -2
  111. package/.agents/scripts/providers/github/errors.js +22 -1
  112. package/.agents/scripts/providers/github/issues.js +106 -1
  113. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  114. package/.agents/scripts/providers/github.js +6 -0
  115. package/.agents/scripts/resolve-stories.js +44 -34
  116. package/.agents/scripts/single-story-close.js +5 -0
  117. package/.agents/scripts/stories-wave-tick.js +37 -13
  118. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  119. package/.agents/workflows/audit-accessibility.md +16 -31
  120. package/.agents/workflows/audit-mobile.md +20 -37
  121. package/.agents/workflows/audit-to-stories.md +63 -27
  122. package/.agents/workflows/git-cleanup.md +17 -3
  123. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  124. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  125. package/.agents/workflows/helpers/deliver-reference.md +35 -14
  126. package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
  127. package/.agents/workflows/helpers/deliver-story.md +15 -12
  128. package/.agents/workflows/helpers/plan-reference.md +30 -0
  129. package/.agents/workflows/mandrel-plan.md +10 -13
  130. package/.agents/workflows/memory-consolidate.md +14 -9
  131. package/docs/CHANGELOG.md +37 -0
  132. package/lib/cli/registry.js +64 -21
  133. package/lib/cli/sync.js +27 -2
  134. package/package.json +7 -4
@@ -45,13 +45,20 @@
45
45
 
46
46
  import fs from 'node:fs';
47
47
  import path from 'node:path';
48
-
49
48
  import { assertEnvelope } from './lib/baselines/envelope.js';
50
49
  import {
50
+ baselineRegenerateRemedy,
51
51
  kindFromEnvelope,
52
52
  mergeEnvelopes,
53
+ mergePlainBaseline,
54
+ plainKindFromEnvelope,
55
+ renderStampConflict,
53
56
  } from './lib/baselines/merge-envelopes.js';
54
57
  import { writeFile as writeEnvelopeFile } from './lib/baselines/writer.js';
58
+ import {
59
+ BASELINE_MERGE_DRIVER_REMEDY,
60
+ ensureBaselineMergeDriver,
61
+ } from './lib/bootstrap/baseline-merge-driver.js';
55
62
  import { spawnChild } from './lib/child-exec.js';
56
63
  import { runAsCli } from './lib/cli-utils.js';
57
64
 
@@ -144,11 +151,86 @@ function delegateToGit(basePath, oursPath, theirsPath) {
144
151
  return result.status ?? 1;
145
152
  }
146
153
 
154
+ /**
155
+ * Which merge does this file get?
156
+ *
157
+ * Two families answer, and neither is "all of `baselines/*.json`":
158
+ *
159
+ * - **Envelope kinds** — the eight kernel kinds, merged by the kind
160
+ * module's own `rowIdentity` with the rollup RE-DERIVED.
161
+ * - **Plain row baselines** — `cyclomatic`, `dead-exports` and
162
+ * `dead-exports-production`, which are row sets with a real identity but
163
+ * no kernel protocol. Until Story #5277 these fell through to
164
+ * `git merge-file` even though `.gitattributes` routes them here, so the
165
+ * attribute promised a row merge the driver never performed.
166
+ *
167
+ * Anything else — arch-cycles, audit-ledger, context-budget,
168
+ * workflow-citations — resolves `null` and is handed back to git unchanged.
169
+ *
170
+ * @param {unknown} ours
171
+ * @param {unknown} theirs
172
+ * @returns {{ kind: string, envelopeKind: boolean }|null}
173
+ */
174
+ function resolveMergeTarget(ours, theirs) {
175
+ const envelopeKind = kindFromEnvelope(ours) ?? kindFromEnvelope(theirs);
176
+ if (envelopeKind) return { kind: envelopeKind, envelopeKind: true };
177
+ const plain = plainKindFromEnvelope(ours) ?? plainKindFromEnvelope(theirs);
178
+ if (plain) return { kind: plain, envelopeKind: false };
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * Serialize the merged result into `%A`. Envelope kinds go through the shared
184
+ * writer (which validates against the per-kind schema); plain baselines are
185
+ * written with the same `JSON.stringify(…, null, 2)` + trailing newline their
186
+ * own generators use, which is what keeps a clean merge byte-identical to a
187
+ * regeneration.
188
+ *
189
+ * @param {string} oursPath
190
+ * @param {{ envelope: object }} merged
191
+ * @param {boolean} isEnvelopeKind
192
+ */
193
+ function writeMerged(oursPath, merged, isEnvelopeKind) {
194
+ if (isEnvelopeKind) {
195
+ writeEnvelopeFile(oursPath, merged.envelope);
196
+ return;
197
+ }
198
+ fs.writeFileSync(oursPath, `${JSON.stringify(merged.envelope, null, 2)}\n`);
199
+ }
200
+
201
+ /**
202
+ * Register the driver in this clone (`--install`).
203
+ *
204
+ * The driver script installs its own registration because the registration is
205
+ * the half that cannot travel: `.gitattributes` is tracked and ships with the
206
+ * repo, `merge.mandrel-baseline.driver` is per-clone git config and is absent
207
+ * in every fresh clone — silently, with git falling back to a text merge and
208
+ * reporting nothing. Wired into this repo's `prepare` script so `npm install`
209
+ * completes the registration, and idempotent so every later `prepare` is a
210
+ * no-op.
211
+ *
212
+ * @returns {number} Process exit code.
213
+ */
214
+ function runInstall() {
215
+ const result = ensureBaselineMergeDriver({ projectRoot: process.cwd() });
216
+ if (result.config === 'failed') {
217
+ process.stderr.write(
218
+ `merge-baseline: could not register the merge driver.\n → ${BASELINE_MERGE_DRIVER_REMEDY}\n`,
219
+ );
220
+ return 1;
221
+ }
222
+ process.stdout.write(
223
+ `merge-baseline: driver ${result.action} (attributes=${result.attributes}, config=${result.config}) → ${result.command}\n`,
224
+ );
225
+ return 0;
226
+ }
227
+
147
228
  /**
148
229
  * @param {string[]} argv Positional arguments: %O %A %B [%P].
149
230
  * @returns {number} Process exit code.
150
231
  */
151
232
  export function runMergeBaseline(argv) {
233
+ if (argv.includes('--install')) return runInstall();
152
234
  const [baseArg, oursArg, theirsArg, mergedPath] = argv;
153
235
  if (!baseArg || !oursArg || !theirsArg) {
154
236
  process.stderr.write(
@@ -168,37 +250,106 @@ export function runMergeBaseline(argv) {
168
250
 
169
251
  const ours = readSide(oursPath);
170
252
  const theirs = readSide(theirsPath);
171
- const base = readSide(basePath);
253
+ const target =
254
+ ours === undefined || theirs === undefined
255
+ ? null
256
+ : resolveMergeTarget(ours, theirs);
257
+ if (!target) return delegateToGit(basePath, oursPath, theirsPath);
172
258
 
173
- const kind = kindFromEnvelope(ours) ?? kindFromEnvelope(theirs);
174
- if (!kind || ours === undefined || theirs === undefined) {
175
- return delegateToGit(basePath, oursPath, theirsPath);
176
- }
259
+ return mergeResolved({
260
+ target,
261
+ base: readSide(basePath),
262
+ ours,
263
+ theirs,
264
+ basePath,
265
+ oursPath,
266
+ theirsPath,
267
+ label: mergedPath || oursPath,
268
+ });
269
+ }
177
270
 
271
+ /**
272
+ * Merge a file whose kind the driver DOES understand, and leave the result in
273
+ * `%A`. A merge that throws is handed back to git rather than half-written:
274
+ * the driver must never invent a result for a baseline it could not model.
275
+ *
276
+ * @param {object} ctx
277
+ * @returns {number} Process exit code.
278
+ */
279
+ function mergeResolved({
280
+ target,
281
+ base,
282
+ ours,
283
+ theirs,
284
+ basePath,
285
+ oursPath,
286
+ theirsPath,
287
+ label,
288
+ }) {
289
+ const { kind, envelopeKind: isEnvelopeKind } = target;
178
290
  let merged;
179
291
  try {
180
- merged = mergeEnvelopes({ base, ours, theirs, kind });
292
+ merged = isEnvelopeKind
293
+ ? mergeEnvelopes({ base, ours, theirs, kind })
294
+ : mergePlainBaseline({ base, ours, theirs, kind });
181
295
  } catch (err) {
182
296
  process.stderr.write(`merge-baseline: ${kind}: ${err.message}\n`);
183
297
  return delegateToGit(basePath, oursPath, theirsPath);
184
298
  }
185
299
 
186
- const rowConflicts = merged.conflicts.filter((c) => c.scope === 'row');
187
- const envelopeConflicts = merged.conflicts.filter(
188
- (c) => c.scope === 'envelope',
189
- );
190
-
191
300
  // Write the canonical projection first even when conflicted: the marker
192
301
  // rendering operates on exactly the bytes a clean merge would have left,
193
302
  // so the merged remainder of a conflicted file is identical to it.
194
- writeEnvelopeFile(oursPath, merged.envelope);
303
+ writeMerged(oursPath, merged, isEnvelopeKind);
195
304
 
196
305
  if (merged.conflicts.length === 0) {
197
- assertEnvelope(merged.envelope);
306
+ if (isEnvelopeKind) assertEnvelope(merged.envelope);
198
307
  return 0;
199
308
  }
309
+ return markConflicts({ kind, merged, oursPath, label });
310
+ }
311
+
312
+ /**
313
+ * Report a conflicted merge and render its markers into `%A`.
314
+ *
315
+ * Both scopes get markers. An envelope-level conflict used to be reported on
316
+ * stderr alone, leaving a file that looked cleanly merged while git held the
317
+ * path unmerged — the operator had to reconstruct from scrollback which stamp
318
+ * disagreed.
319
+ *
320
+ * @param {{ kind: string, merged: object, oursPath: string, label: string }} ctx
321
+ * @returns {number} Always 1 — a conflicted merge.
322
+ */
323
+ function markConflicts({ kind, merged, oursPath, label }) {
324
+ const rowConflicts = merged.conflicts.filter((c) => c.scope === 'row');
325
+ const envelopeConflicts = merged.conflicts.filter(
326
+ (c) => c.scope === 'envelope',
327
+ );
328
+ reportConflicts({ kind, label, rowConflicts, envelopeConflicts });
329
+
330
+ let text = fs.readFileSync(oursPath, 'utf8');
331
+ if (envelopeConflicts.length > 0) {
332
+ text = renderStampConflict(text, envelopeConflicts);
333
+ }
334
+ if (rowConflicts.length > 0) {
335
+ text = renderConflictMarkers(text, rowConflicts);
336
+ }
337
+ fs.writeFileSync(oursPath, text);
338
+ return 1;
339
+ }
200
340
 
201
- const label = mergedPath || oursPath;
341
+ /**
342
+ * Report every conflict on stderr, then name the regeneration command.
343
+ *
344
+ * The regenerate line is not decoration. The merged file's rollup was derived
345
+ * from a row set that still carries conflict markers, so resolving the markers
346
+ * by hand leaves the rollup describing a tree nobody scored — the same silent
347
+ * wrong number the driver exists to prevent, arrived at from the other
348
+ * direction. Saying it here is the only place an operator sees it.
349
+ *
350
+ * @param {{ kind: string, label: string, rowConflicts: Array<object>, envelopeConflicts: Array<object> }} args
351
+ */
352
+ function reportConflicts({ kind, label, rowConflicts, envelopeConflicts }) {
202
353
  for (const conflict of envelopeConflicts) {
203
354
  process.stderr.write(
204
355
  `merge-baseline: conflict ${kind} envelope key "${conflict.identity}" in ${label} — ours ${JSON.stringify(conflict.ours)}, theirs ${JSON.stringify(conflict.theirs)}\n`,
@@ -209,12 +360,11 @@ export function runMergeBaseline(argv) {
209
360
  `merge-baseline: conflict ${kind} row "${conflict.identity}" in ${label}\n`,
210
361
  );
211
362
  }
212
-
213
- if (rowConflicts.length > 0) {
214
- const text = fs.readFileSync(oursPath, 'utf8');
215
- fs.writeFileSync(oursPath, renderConflictMarkers(text, rowConflicts));
216
- }
217
- return 1;
363
+ process.stderr.write(
364
+ `merge-baseline: ${label} is conflicted — the rollup in it was derived from ` +
365
+ `unresolved rows and must not be trusted. After resolving the markers, ` +
366
+ `regenerate it: ${baselineRegenerateRemedy(kind)}\n`,
367
+ );
218
368
  }
219
369
 
220
370
  function main() {
@@ -233,6 +383,10 @@ runAsCli(import.meta.url, main, {
233
383
  ['%A', 'Our version — the driver writes its result here.'],
234
384
  ['%B', 'Their version.'],
235
385
  ['%P', 'Real pathname being merged; used in conflict messages.'],
386
+ [
387
+ '--install',
388
+ 'Register the driver in this clone (.gitattributes line + the per-clone merge.mandrel-baseline.driver config) and exit. Idempotent.',
389
+ ],
236
390
  ],
237
391
  },
238
392
  });
@@ -66,7 +66,8 @@
66
66
  * green can already have merged by the time it is observed. On red the
67
67
  * watcher disarms auto-merge and records the head SHA; on green it reads
68
68
  * the digest and adjudicates: a green on the SAME head SHA is a forbidden
69
- * re-run (exit 1, `agent::blocked`, `meta::framework-gap` required), while
69
+ * re-run (exit 1, `agent::blocked`, a `file-ci-gap.js` intake filing
70
+ * required), while
70
71
  * a green on a NEW head SHA is a fix at source — the digest is retired,
71
72
  * auto-merge is re-armed, and the delivery proceeds. A delivery that never
72
73
  * went red has no digest and is untouched. Mechanism:
@@ -475,7 +476,7 @@ async function evaluateGreenWatch({
475
476
  `[pr-watch] run link: ${digest.runUrl ?? `run id ${digest.runId ?? 'unresolved'}`} — classification: ${digest.classification ?? 'unknown'}`,
476
477
  );
477
478
  logger.error?.(
478
- '[pr-watch] fix the root cause and push a new commit, or file a `meta::framework-gap` issue carrying the run link and failure signature.',
479
+ '[pr-watch] fix the root cause and push a new commit, or — when the root cause is outside this delivery — run `node .agents/scripts/file-ci-gap.js --story <id> --verdict <verdict> --owner <bucket> --block` to file the routed, deduped intake issue.',
479
480
  );
480
481
  const outcome = await blockFn({ storyId, body });
481
482
  return {
@@ -15,13 +15,34 @@
15
15
  * Extracted from `../github.js` in Story #1846 / Task #1857.
16
16
  */
17
17
 
18
+ /**
19
+ * Needles that mean "this API surface does not exist here", not "this call
20
+ * failed". Every one names a *schema* fact — an absent GraphQL field, a
21
+ * disabled feature — so a match is a settled answer no retry can improve.
22
+ *
23
+ * The bare `sub-issues` needle used to sit in this list and had to go: it
24
+ * matches the endpoint's own name, so any error that merely *mentions* the
25
+ * surface classified as `feature-disabled`. A secondary rate limit delivered
26
+ * as `HTTP 403: API rate limit exceeded while fetching sub-issues` is the live
27
+ * case — genuinely transient, but bucketed here it bypassed
28
+ * `withTransientRetry` entirely and the Epic rollup read the burst as "this
29
+ * repo has no sub-issues", degrading to the body checklist on a read that
30
+ * would have succeeded a second later. The narrower `subissues` spellings
31
+ * below still catch the real schema errors, because GraphQL names the field
32
+ * without the hyphen.
33
+ */
18
34
  const FEATURE_DISABLED_MESSAGES = [
19
35
  'feature not available',
20
36
  'feature is not enabled',
21
37
  "field 'subissues'",
22
38
  'field "subissues"',
23
39
  'subissues is not available',
24
- 'sub-issues',
40
+ // The field, never the endpoint. `<x> field` cannot appear in "while
41
+ // fetching sub-issues", which is what makes these safe to keep after the
42
+ // bare needle's removal.
43
+ 'subissues field',
44
+ 'sub_issues field',
45
+ 'sub-issues field',
25
46
  "doesn't exist on type",
26
47
  'does not exist on type',
27
48
  'unknown field',
@@ -20,7 +20,7 @@ import { Logger } from '../../lib/Logger.js';
20
20
  import { concurrentMap } from '../../lib/util/concurrent-map.js';
21
21
  import { isNotFoundError } from './branch-protection.js';
22
22
  import { classifyGithubError, withTransientRetry } from './errors.js';
23
- import { issueToEpic } from './mappers.js';
23
+ import { issueToEpic, issueToTicket, subIssueNodeToTicket } from './mappers.js';
24
24
  import {
25
25
  defaultRetryWarn,
26
26
  paginateRest,
@@ -54,6 +54,32 @@ function classifySearchRetry(err) {
54
54
  */
55
55
  export const SUBTICKET_HYDRATION_CONCURRENCY = 8;
56
56
 
57
+ /**
58
+ * The `Issue.parent` read backing {@link IssuesGateway#getParentIssue}.
59
+ *
60
+ * Node selection is deliberately identical to `SUB_ISSUES_QUERY`'s, so the
61
+ * parent and the children a caller holds come back in one shape and
62
+ * `subIssueNodeToTicket` maps both. Addressed by `owner/repo/number` rather
63
+ * than by node id because every caller starts from an issue number and would
64
+ * otherwise pay a round-trip just to learn the node id.
65
+ */
66
+ const PARENT_ISSUE_QUERY = `query($owner: String!, $repo: String!, $number: Int!) {
67
+ repository(owner: $owner, name: $repo) {
68
+ issue(number: $number) {
69
+ parent {
70
+ number
71
+ databaseId
72
+ id
73
+ title
74
+ body
75
+ state
76
+ labels(first: 30) { nodes { name } }
77
+ assignees(first: 20) { nodes { login } }
78
+ }
79
+ }
80
+ }
81
+ }`;
82
+
57
83
  // Re-export so existing test consumers that previously imported
58
84
  // `paginateRest` from this module continue to work without an extra
59
85
  // migration step.
@@ -124,6 +150,85 @@ export class IssuesGateway {
124
150
  return issues.filter((issue) => !issue?.pull_request);
125
151
  }
126
152
 
153
+ /**
154
+ * The same scan as {@link listIssuesByLabel}, mapped through
155
+ * `issueToTicket` — one **declared** shape instead of a raw REST payload.
156
+ *
157
+ * The two differ in exactly the field that keeps biting: the REST payload
158
+ * calls the issue number `number` and the database id `id`, while every
159
+ * mapped read calls the issue number `id`. Consumers that could be handed
160
+ * either wrote `number ?? id` to cope, and that fallback is not a
161
+ * defensive nicety — it is a live bug, because on a *mapped* ticket `id`
162
+ * is the number and on a *raw* one it is the database id. A caller that
163
+ * ever receives the raw shape silently addresses issues by database id.
164
+ *
165
+ * `url` is carried alongside the mapped fields because two consumers
166
+ * (`epic-candidates`, `dependency-candidates`) render a link and
167
+ * `issueToTicket` drops `html_url`. It is the only addition; everything
168
+ * else is exactly what every other single-issue read returns.
169
+ *
170
+ * @param {{ state?: 'open'|'closed'|'all', labels?: string }} [opts]
171
+ * @returns {Promise<Array<object>>} Mapped tickets (`id` is the issue number).
172
+ * @field-manifest /repos/{owner}/{repo}/issues: number, id, node_id, title,
173
+ * body, labels, state, state_reason, assignees, html_url,
174
+ * pull_request
175
+ */
176
+ async listTicketsByLabel(opts = {}) {
177
+ const issues = await this.listIssuesByLabel(opts);
178
+ return issues.map((issue) => ({
179
+ ...issueToTicket(issue),
180
+ url: issue.html_url ?? null,
181
+ }));
182
+ }
183
+
184
+ /**
185
+ * Resolve an issue's container parent in **one** request.
186
+ *
187
+ * The rollup's child→parent lookup used to scan every open `type::epic`
188
+ * issue and read each one's children looking for the Story it was handed:
189
+ * O(open Epics) requests to answer a question the API answers directly.
190
+ * `Issue.parent` is the native sub-issue edge read backwards, so a Story
191
+ * with a container costs one call and a Story without one costs the same.
192
+ *
193
+ * Returns `null` — never throws — when the issue has no parent, when the
194
+ * response is shaped unexpectedly, or when the sub-issues feature is
195
+ * unavailable on this repo. A null is "no parent resolved here", which is
196
+ * exactly what the caller's body-checklist fallback exists for; turning a
197
+ * disabled feature into an exception would convert a degraded lookup into a
198
+ * failed lifecycle edge.
199
+ *
200
+ * @param {number} number Issue number whose parent to resolve.
201
+ * @returns {Promise<object|null>} Mapped parent ticket, or null.
202
+ * @field-manifest GraphQL Issue.parent: number, id, title, body, state,
203
+ * labels.nodes.name, assignees.nodes.login
204
+ */
205
+ async getParentIssue(number) {
206
+ const issueNumber = Number(number);
207
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0) return null;
208
+ let data;
209
+ try {
210
+ data = await withTransientRetry(
211
+ () =>
212
+ this.ghGraphql(
213
+ PARENT_ISSUE_QUERY,
214
+ { owner: this.owner, repo: this.repo, number: issueNumber },
215
+ { headers: { 'GraphQL-Features': 'sub_issues' } },
216
+ ),
217
+ {
218
+ label: `getParentIssue #${issueNumber}`,
219
+ onRetry: defaultRetryWarn,
220
+ },
221
+ );
222
+ } catch (err) {
223
+ Logger.warn(
224
+ `[GitHubProvider] parent lookup for #${issueNumber} degraded to none ` +
225
+ `(${err?.message ?? err}).`,
226
+ );
227
+ return null;
228
+ }
229
+ return subIssueNodeToTicket(data?.repository?.issue?.parent ?? null);
230
+ }
231
+
127
232
  /**
128
233
  * Search issues by a free-text query via the REST search API
129
234
  * (`GET /search/issues`). Deliberately REST, **not** GraphQL: transient
@@ -150,18 +150,28 @@ export async function addSubIssueEdges({
150
150
 
151
151
  /**
152
152
  * Link child Stories to a container Epic, resolving each child's **database
153
- * id** from its issue number via the injected `getTicket` hook.
153
+ * id** from its issue number.
154
154
  *
155
155
  * Callers hold issue numbers (that is what `plan-persist` creates and what
156
156
  * an operator types); the API wants database ids. Doing the translation here
157
157
  * keeps that trap in one place instead of at every call site.
158
158
  *
159
+ * `knownInternalIds` short-circuits the translation for children whose
160
+ * database id the caller already has. `createIssue` returns `internalId` in
161
+ * its response, so a cohort this run just created needs no lookup at all —
162
+ * the `getTicket` fan-out that used to run over every child was re-reading
163
+ * issues the same process had created seconds earlier, one round-trip per
164
+ * Story, to recover a field it had already been handed and thrown away.
165
+ * `getTicket` stays for the children a *resumed* run adopted, whose ids came
166
+ * from a listing rather than a create.
167
+ *
159
168
  * Never throws: a child whose id cannot be resolved is counted as failed and
160
169
  * the remaining edges still go out.
161
170
  *
162
171
  * @param {{
163
172
  * epicNumber: number,
164
173
  * childIssueNumbers: number[],
174
+ * knownInternalIds?: Map<number, number>|null,
165
175
  * getTicket: (issueNumber: number) => Promise<{ internalId: number }>,
166
176
  * owner: string,
167
177
  * repo: string,
@@ -173,6 +183,7 @@ export async function addSubIssueEdges({
173
183
  export async function linkStoriesToEpic({
174
184
  epicNumber,
175
185
  childIssueNumbers,
186
+ knownInternalIds = null,
176
187
  getTicket,
177
188
  owner,
178
189
  repo,
@@ -182,10 +193,16 @@ export async function linkStoriesToEpic({
182
193
  const numbers = Array.isArray(childIssueNumbers) ? childIssueNumbers : [];
183
194
  if (numbers.length === 0) return { added: 0, skipped: 0, failed: 0 };
184
195
 
196
+ const known = knownInternalIds instanceof Map ? knownInternalIds : new Map();
185
197
  let failed = 0;
186
198
  const childInternalIds = [];
187
199
 
188
200
  for (const childNumber of numbers) {
201
+ const alreadyKnown = known.get(Number(childNumber));
202
+ if (typeof alreadyKnown === 'number') {
203
+ childInternalIds.push(alreadyKnown);
204
+ continue;
205
+ }
189
206
  try {
190
207
  const ticket = await getTicket(childNumber);
191
208
  const internalId = ticket?.internalId;
@@ -116,6 +116,8 @@ const DELEGATIONS = [
116
116
  ['graphql', 'issues.ghGraphql'],
117
117
  ['searchIssues', 'issues.searchIssues'],
118
118
  ['listIssuesByLabel', 'issues.listIssuesByLabel'],
119
+ ['listTicketsByLabel', 'issues.listTicketsByLabel'],
120
+ ['getParentIssue', 'issues.getParentIssue'],
119
121
  ['getEpic', 'issues.getEpic'],
120
122
  ['branchExists', 'issues.branchExists'],
121
123
  ['getSubTickets', 'issues.getSubTickets'],
@@ -126,6 +128,10 @@ const DELEGATIONS = [
126
128
  ['createIssue', 'tickets.createIssue'],
127
129
  ['updateTicket', 'tickets.updateTicket'],
128
130
  ['_applyLabelMutations', 'tickets._applyLabelMutations'],
131
+ ['getNativeSubIssues', 'subIssues.getNativeSubIssues'],
132
+ // The private alias predates the declared `getNativeSubIssues` port above
133
+ // and still has call sites; both forward to the same gateway method, so the
134
+ // two can never answer differently while the older name is retired.
129
135
  ['_getNativeSubIssues', 'subIssues.getNativeSubIssues'],
130
136
  ['getTicketComments', 'comments.getTicketComments'],
131
137
  ['deleteComment', 'comments.deleteComment'],
@@ -30,6 +30,7 @@
30
30
  * node .agents/scripts/resolve-stories.js --ids 101-104 # inclusive range
31
31
  * node .agents/scripts/resolve-stories.js --ids 101,102 --pretty
32
32
  * node .agents/scripts/resolve-stories.js --ids 101 --no-native # skip the dependencies API
33
+ * node .agents/scripts/resolve-stories.js --ids 101 --allow-unlabelled
33
34
  *
34
35
  * Exit codes: 0 ok, 1 usage/resolution error.
35
36
  */
@@ -39,7 +40,7 @@ import { parseArgs } from 'node:util';
39
40
  import { runAsCli } from './lib/cli-utils.js';
40
41
  import { resolveConfig } from './lib/config-resolver.js';
41
42
  import { Logger, routeAllOutputToStderr } from './lib/Logger.js';
42
- import { resolveEpicNodeId } from './lib/orchestration/epic-container.js';
43
+ import { nativeChildReader } from './lib/orchestration/epic-container.js';
43
44
  import { expandEpicIds } from './lib/orchestration/epic-expansion.js';
44
45
  import {
45
46
  buildStoriesEnvelope,
@@ -52,7 +53,18 @@ import { createProvider } from './lib/provider-factory.js';
52
53
  import { concurrentMap } from './lib/util/concurrent-map.js';
53
54
  import { paginateRest } from './providers/github/request-helpers.js';
54
55
 
55
- export { buildStoriesEnvelope, parseIds, readNativeBlockedBy, toStoryRecord };
56
+ export {
57
+ buildStoriesEnvelope,
58
+ // Re-exported, not redefined (Story #5280). The reader now lives in
59
+ // `epic-container.js` beside the shape it reads, so the expansion path and
60
+ // the rollup share one definition — the pair whose divergence makes an Epic
61
+ // expandable but unclosable. The name stays exported here because two test
62
+ // modules import it from this entrypoint.
63
+ nativeChildReader,
64
+ parseIds,
65
+ readNativeBlockedBy,
66
+ toStoryRecord,
67
+ };
56
68
 
57
69
  /**
58
70
  * Bounded concurrency for the per-issue round-trips. Matches the edge-writer's
@@ -78,6 +90,11 @@ Options:
78
90
  be mixed with Story ids.
79
91
  --pretty Pretty-print the JSON envelope.
80
92
  --no-native Skip the native blocked_by read (body edges only).
93
+ --allow-unlabelled
94
+ Resolve a Story carrying no agent::* label. Without it such a
95
+ Story is refused: the audit sweep files Stories without one on
96
+ purpose, and delivering one means dispatching a worker at
97
+ unenriched audit prose. Route it through /mandrel-plan first.
81
98
  --help Show this help.
82
99
  `;
83
100
 
@@ -93,29 +110,6 @@ export function resolveStoriesProvider({
93
110
  return { provider: createProviderFn(config), config };
94
111
  }
95
112
 
96
- /**
97
- * Read an Epic's native sub-issue children as issue numbers.
98
- *
99
- * Injected into `expandEpicIds` so the lib layer stays provider-agnostic,
100
- * exactly as `paginate` is injected into `readNativeBlockedBy`. A provider
101
- * without the GraphQL surface yields `[]`, and the Epic body's checklist
102
- * carries the children on its own — as does an Epic carrying no resolvable
103
- * node id, which `resolveEpicNodeId` reports rather than letting an
104
- * `undefined` reach the API as a rejected `ID!` variable.
105
- *
106
- * @param {object} provider
107
- * @returns {(epic: object) => Promise<number[]>}
108
- */
109
- export function nativeChildReader(provider) {
110
- return async (epic) => {
111
- const nodeId = resolveEpicNodeId(epic);
112
- if (nodeId === null) return [];
113
- return (
114
- provider?._getNativeSubIssues?.(nodeId, epic?.number ?? epic?.id) ?? []
115
- );
116
- };
117
- }
118
-
119
113
  /**
120
114
  * Fetch every requested id and map it to a Story record, failing on the first
121
115
  * id that is not a deliverable Story.
@@ -127,9 +121,10 @@ export function nativeChildReader(provider) {
127
121
  *
128
122
  * @param {object} provider
129
123
  * @param {number[]} ids
124
+ * @param {{ allowUnlabelled?: boolean }} [options]
130
125
  * @returns {Promise<object[]>}
131
126
  */
132
- export async function fetchStories(provider, ids) {
127
+ export async function fetchStories(provider, ids, { allowUnlabelled } = {}) {
133
128
  const { ids: resolvedIds, expansions } = await expandEpicIds({
134
129
  ids,
135
130
  getTicket: (id) => provider.getTicket(id),
@@ -151,7 +146,7 @@ export async function fetchStories(provider, ids) {
151
146
  if (!issue) {
152
147
  throw new Error(`[resolve-stories] Issue #${id} was not found.`);
153
148
  }
154
- return toStoryRecord(issue, id);
149
+ return toStoryRecord(issue, id, { allowUnlabelled });
155
150
  },
156
151
  { concurrency: FETCH_CONCURRENCY },
157
152
  );
@@ -237,19 +232,20 @@ export async function resolveForeignDone({ provider, dag, inSetIds }) {
237
232
  * stdout are injected so the whole path is unit-testable without a live
238
233
  * GitHub round-trip. Exported for testing.
239
234
  *
240
- * @param {{ ids: string, native?: boolean, pretty?: boolean }} args
235
+ * @param {{ ids: string, native?: boolean, pretty?: boolean,
236
+ * allowUnlabelled?: boolean }} args
241
237
  * @param {{ provider: object, config: object, stdout?: { write(s: string): void } }} deps
242
238
  * @returns {Promise<number>}
243
239
  */
244
240
  export async function runResolveStories(
245
- { ids: rawIds, native = true, pretty = false },
241
+ { ids: rawIds, native = true, pretty = false, allowUnlabelled = false },
246
242
  { provider, config, stdout = process.stdout },
247
243
  ) {
248
244
  const ids = parseIds(rawIds);
249
245
  const owner = config.github?.owner;
250
246
  const repo = config.github?.repo;
251
247
 
252
- const stories = await fetchStories(provider, ids);
248
+ const stories = await fetchStories(provider, ids, { allowUnlabelled });
253
249
  const nativeEdges = native
254
250
  ? await readNativeEdges({ provider, stories, owner, repo })
255
251
  : new Map();
@@ -280,12 +276,29 @@ export async function runResolveStories(
280
276
  return 0;
281
277
  }
282
278
 
279
+ /**
280
+ * Project the parsed flags onto the flow core's options object, so `main` reads
281
+ * as parse → help → run and the flag-name spellings live in one place.
282
+ *
283
+ * @param {Record<string, unknown>} values
284
+ * @returns {{ ids: string, native: boolean, pretty: boolean, allowUnlabelled: boolean }}
285
+ */
286
+ function toRunOptions(values) {
287
+ return {
288
+ ids: values.ids,
289
+ native: values.native,
290
+ pretty: values.pretty,
291
+ allowUnlabelled: values['allow-unlabelled'],
292
+ };
293
+ }
294
+
283
295
  async function main() {
284
296
  const { values } = parseArgs({
285
297
  options: {
286
298
  ids: { type: 'string' },
287
299
  pretty: { type: 'boolean', default: false },
288
300
  native: { type: 'boolean', default: true },
301
+ 'allow-unlabelled': { type: 'boolean', default: false },
289
302
  help: { type: 'boolean', default: false },
290
303
  },
291
304
  // The documented opt-out is `--no-native`; without allowNegative,
@@ -308,10 +321,7 @@ async function main() {
308
321
  // headless caller can pipe this straight into stories-wave-tick.js.
309
322
  routeAllOutputToStderr();
310
323
 
311
- return runResolveStories(
312
- { ids: values.ids, native: values.native, pretty: values.pretty },
313
- resolveStoriesProvider(),
314
- );
324
+ return runResolveStories(toRunOptions(values), resolveStoriesProvider());
315
325
  }
316
326
 
317
327
  runAsCli(import.meta.url, main, {