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
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * file-ci-gap.js — the CI-remediation Option-2 filing command.
4
+ *
5
+ * `rules/ci-remediation.md` sends three verdicts here — `pre-existing`,
6
+ * `capacity`, `unreproducible-tier` — each meaning "this red check is real,
7
+ * and fixing it is not this delivery's job". The rule used to say "file a
8
+ * `meta::framework-gap` issue" and stop, leaving the agent to hand-run
9
+ * `gh issue create` wherever it was standing. This is the mechanism behind
10
+ * that sentence: evidence from the CI digest, ownership routing, fingerprint
11
+ * dedup, the `friction` comment, and the `agent::blocked` flip in one call.
12
+ *
13
+ * It files an **intake** issue, never a Story: `/mandrel-plan <id>` graduates
14
+ * it on the next planning pass. Delivery never blocks on planning — see
15
+ * `lib/orchestration/ci-gap-intake.js` for why that split is load-bearing.
16
+ */
17
+
18
+ import { parseArgs } from 'node:util';
19
+
20
+ import { runAsCli } from './lib/cli-utils.js';
21
+ import { resolveConfig } from './lib/config-resolver.js';
22
+ import {
23
+ createFollowUpIssue,
24
+ ensureIssueLabels,
25
+ updateFollowUpIssue,
26
+ } from './lib/feedback-loop/graduator-core.js';
27
+ import { issueNumberFromUrl } from './lib/feedback-loop/retro-proposals-graduator.js';
28
+ import {
29
+ resolveOwnershipRepos,
30
+ routeOwnership,
31
+ } from './lib/github/framework-repo.js';
32
+ import { Logger } from './lib/Logger.js';
33
+ import {
34
+ fileCiGapIntake,
35
+ INTAKE_VERDICTS,
36
+ REFUSED_VERDICT,
37
+ } from './lib/orchestration/ci-gap-intake.js';
38
+ import { readCiDigest } from './lib/orchestration/ci-rerun-guard.js';
39
+ import {
40
+ STATE_LABELS,
41
+ transitionTicketState,
42
+ upsertStructuredComment,
43
+ } from './lib/orchestration/ticketing.js';
44
+ import { createProvider } from './lib/provider-factory.js';
45
+
46
+ const USAGE = {
47
+ invocation:
48
+ 'node .agents/scripts/file-ci-gap.js --story <id> --verdict <verdict> --owner <bucket> [--evidence "<proof reading>"] [--pr <n>] [--block] [--dry-run]',
49
+ summary:
50
+ 'File (or update) the CI-gap intake issue for an Option-2 verdict in .agents/rules/ci-remediation.md, routed to the repository that owns the fault and deduped by failure signature.',
51
+ flags: [
52
+ [
53
+ '--story <id>',
54
+ 'Story the red check blocked (required — keys the CI digest).',
55
+ ],
56
+ [
57
+ '--verdict <verdict>',
58
+ `One of ${INTAKE_VERDICTS.join(' | ')}. "${REFUSED_VERDICT}" is refused: it routes to Option 1, fix at source.`,
59
+ ],
60
+ [
61
+ '--owner <bucket>',
62
+ 'Who owns the fault: consumer | framework | platform. Resolves through github.followUpRepos.*.',
63
+ ],
64
+ [
65
+ '--evidence <text>',
66
+ "The verdict's proof reading (the exhausted-limit log line, the failed attach). Recorded in the body.",
67
+ ],
68
+ ['--pr <n>', 'PR number the red check ran on; recorded as an occurrence.'],
69
+ [
70
+ '--block',
71
+ 'Also flip the Story to agent::blocked. Without it the friction comment is posted but the Story is left where it is.',
72
+ ],
73
+ ['--dry-run', 'Compose the filing and print it; write nothing.'],
74
+ ],
75
+ notes: [
76
+ 'Requires a CI digest at temp/story-<id>-ci-digest.json — pr-watch-with-update.js --story <id> writes it on the first red.',
77
+ 'A repeat occurrence of a known signature UPDATES the existing intake issue rather than opening a second one.',
78
+ 'The issue it files is intake, not an executable Story: graduate it with /mandrel-plan <issue number>.',
79
+ ],
80
+ };
81
+
82
+ /**
83
+ * Wire the live GitHub ports the intake filer writes through.
84
+ *
85
+ * `gh` is the transport rather than the provider facade because a filing can
86
+ * target a repository other than the configured one, and `gh issue create
87
+ * --repo` is the surface that already does that (the graduators file
88
+ * cross-repo the same way).
89
+ *
90
+ * @param {object} opts
91
+ * @returns {object} ports for `fileCiGapIntake`
92
+ */
93
+ function liveIntakePorts({ provider, searchRepo, cwd, logger }) {
94
+ const labelCache = new Map();
95
+ return {
96
+ searchIssues: (query) =>
97
+ provider.searchIssues({
98
+ query,
99
+ owner: searchRepo.owner,
100
+ repo: searchRepo.repo,
101
+ }),
102
+ createIssue: async ({ owner, repo, title, body, labels }) => {
103
+ // `gh issue create --label <absent>` fails outright, so a brand-new
104
+ // routing label (meta::platform-gap, friction::unreproducible-tier)
105
+ // has to exist before the create — not after it errors.
106
+ const ensured = await ensureIssueLabels({
107
+ owner,
108
+ repo,
109
+ labels,
110
+ labelCache,
111
+ cwd,
112
+ });
113
+ for (const err of ensured.errors) logger?.warn?.(`[file-ci-gap] ${err}`);
114
+ const created = await createFollowUpIssue({
115
+ owner,
116
+ repo,
117
+ title,
118
+ body,
119
+ labels,
120
+ ghPath: 'gh',
121
+ cwd,
122
+ });
123
+ return {
124
+ url: created.url,
125
+ number: created.url ? issueNumberFromUrl(created.url) : null,
126
+ error: created.error,
127
+ };
128
+ },
129
+ updateIssue: ({ owner, repo, number, body }) =>
130
+ updateFollowUpIssue({ owner, repo, number, body, ghPath: 'gh', cwd }),
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Render the `friction` comment the Story carries so the blocker is legible
136
+ * on the ticket itself, not only in the intake issue.
137
+ *
138
+ * @param {object} opts
139
+ * @returns {string}
140
+ */
141
+ export function renderFrictionComment({ verdict, result, digest }) {
142
+ const target = result.issue?.url ?? result.issue?.number ?? '(not filed)';
143
+ const lines = [
144
+ `### CI gap filed — verdict \`${verdict}\``,
145
+ '',
146
+ `- **Failing check:** \`${digest?.failingCheck ?? 'unknown'}\``,
147
+ `- **Run:** ${digest?.runUrl ?? `run id ${digest?.runId ?? 'unresolved'}`}`,
148
+ `- **Intake issue:** ${target} (${result.decision})`,
149
+ `- **Owner:** \`${result.routing.bucket}\` → \`${result.routing.routedRepo.owner}/${result.routing.routedRepo.repo}\``,
150
+ ];
151
+ if (!result.routing.routable) {
152
+ lines.push(
153
+ `- **Routing:** \`unroutable\` — \`${result.routing.missingKey}\` is unset, so the intake issue was filed locally.`,
154
+ );
155
+ }
156
+ if (result.routing.deferredFrom) {
157
+ lines.push(
158
+ `- **Routing:** deferred from \`${result.routing.deferredFrom}\` (${result.routing.deferralReason}) — filed locally instead.`,
159
+ );
160
+ }
161
+ lines.push(
162
+ '',
163
+ 'This verdict does **not** license a re-run of the failed job. Graduate the',
164
+ 'intake issue with `/mandrel-plan <issue number>` to turn it into a Story.',
165
+ );
166
+ return lines.join('\n');
167
+ }
168
+
169
+ /**
170
+ * File the CI-gap intake issue for one Story, post the `friction` comment,
171
+ * and optionally flip the Story to `agent::blocked`.
172
+ *
173
+ * Every port is injectable so the unit tests exercise the whole command with
174
+ * no network and no live tracker.
175
+ *
176
+ * @param {object} opts
177
+ * @returns {Promise<object>} the intake result, plus what the command did.
178
+ */
179
+ export async function runFileCiGap({
180
+ storyId,
181
+ verdict,
182
+ owner: bucket,
183
+ evidence = '',
184
+ prNumber = null,
185
+ dryRun = false,
186
+ block = false,
187
+ config,
188
+ provider,
189
+ ports,
190
+ digest,
191
+ tempRoot,
192
+ cwd = process.cwd(),
193
+ logger = Logger,
194
+ now,
195
+ } = {}) {
196
+ const sid = Number(storyId);
197
+ if (!Number.isInteger(sid) || sid <= 0) {
198
+ throw new Error('--story <id> is required (a positive issue number).');
199
+ }
200
+ const resolved = config ?? resolveConfig();
201
+ const ciDigest = digest ?? readCiDigest({ storyId: sid, tempRoot, cwd });
202
+ if (!ciDigest) {
203
+ throw new Error(
204
+ `no CI digest for Story #${sid}. The digest is written by \`pr-watch-with-update.js --story ${sid}\` on the first red; without it there is no run link or failure signature to file.`,
205
+ );
206
+ }
207
+
208
+ const repos = resolveOwnershipRepos(resolved);
209
+ const currentRepo = repos.consumer ?? { owner: 'unknown', repo: 'unknown' };
210
+ // Dedup searches the repository the filing will land in — routing is a pure
211
+ // function, so computing it here and inside the filer cannot disagree.
212
+ const routed = routeOwnership({ bucket, repos, currentRepo });
213
+ const searchRepo = routed.routable ? routed.routedRepo : currentRepo;
214
+
215
+ const ticketing =
216
+ provider ?? (dryRun ? null : (createProvider(resolved) ?? null));
217
+ const livePorts =
218
+ ports ??
219
+ liveIntakePorts({
220
+ provider: ticketing ?? createProvider(resolved),
221
+ searchRepo,
222
+ cwd,
223
+ logger,
224
+ });
225
+
226
+ const result = await fileCiGapIntake({
227
+ digest: ciDigest,
228
+ verdict,
229
+ bucket,
230
+ evidence,
231
+ repos,
232
+ currentRepo,
233
+ prNumber,
234
+ dryRun,
235
+ ports: livePorts,
236
+ logger,
237
+ now,
238
+ });
239
+
240
+ const actions = { commented: false, blocked: false };
241
+ if (!dryRun && ticketing) {
242
+ const body = renderFrictionComment({ verdict, result, digest: ciDigest });
243
+ try {
244
+ await upsertStructuredComment(ticketing, sid, 'friction', body);
245
+ actions.commented = true;
246
+ } catch (err) {
247
+ result.errors.push(`friction comment failed: ${err?.message ?? err}`);
248
+ }
249
+ if (block) {
250
+ try {
251
+ await transitionTicketState(ticketing, sid, STATE_LABELS.BLOCKED, {});
252
+ actions.blocked = true;
253
+ } catch (err) {
254
+ result.errors.push(
255
+ `agent::blocked transition failed: ${err?.message ?? err}`,
256
+ );
257
+ }
258
+ }
259
+ }
260
+
261
+ return { storyId: sid, verdict, ...result, actions };
262
+ }
263
+
264
+ /**
265
+ * CLI entrypoint.
266
+ *
267
+ * @returns {Promise<void>}
268
+ */
269
+ async function main() {
270
+ const { values } = parseArgs({
271
+ args: process.argv.slice(2),
272
+ options: {
273
+ story: { type: 'string' },
274
+ verdict: { type: 'string' },
275
+ owner: { type: 'string' },
276
+ evidence: { type: 'string' },
277
+ pr: { type: 'string' },
278
+ block: { type: 'boolean', default: false },
279
+ 'dry-run': { type: 'boolean', default: false },
280
+ },
281
+ });
282
+
283
+ const result = await runFileCiGap({
284
+ storyId: values.story,
285
+ verdict: values.verdict,
286
+ owner: values.owner,
287
+ evidence: values.evidence ?? '',
288
+ prNumber: values.pr ? Number(values.pr) : null,
289
+ dryRun: values['dry-run'],
290
+ block: values.block,
291
+ });
292
+
293
+ // Single-line JSON per the script-output contract — an orchestrator parses
294
+ // this, and a pretty dump is noise in a delivery transcript.
295
+ process.stdout.write(`${JSON.stringify(result)}\n`);
296
+ for (const err of result.errors) {
297
+ Logger.error(`[file-ci-gap] ${err}`);
298
+ }
299
+ if (result.errors.length > 0) process.exitCode = 1;
300
+ }
301
+
302
+ runAsCli(import.meta.url, main, {
303
+ source: 'file-ci-gap',
304
+ errorPrefix: '[file-ci-gap]',
305
+ usage: USAGE,
306
+ });
@@ -22,6 +22,8 @@
22
22
  * current workflow set.
23
23
  * --check — exits 0 when the on-disk file matches the freshly generated
24
24
  * content, throws (→ exit 1) with a regeneration hint otherwise.
25
+ * --root — read and write under another checkout's `.agents/` tree.
26
+ * See {@link resolveTargets} for why this seam exists.
25
27
  *
26
28
  * Per `.agents/rules/orchestration-error-handling.md`, unrecoverable failures
27
29
  * surface via `throw new Error(...)` so `runAsCli` maps the throw to
@@ -39,8 +41,43 @@ import { buildCatalog, buildLoopCatalog } from './lib/mandrel-catalog.js';
39
41
  const __filename = fileURLToPath(import.meta.url);
40
42
  const __dirname = path.dirname(__filename);
41
43
  const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
42
- const WORKFLOWS_DIR = path.join(PROJECT_ROOT, '.agents', 'workflows');
43
- const DOC_PATH = path.join(PROJECT_ROOT, '.agents', 'docs', 'workflows.md');
44
+
45
+ /**
46
+ * Resolve the workflow source directory and the generated doc for one
47
+ * repository root.
48
+ *
49
+ * The `--root` seam this backs exists for the drift gate's own test. That
50
+ * test used to prove the gate by editing the *real*
51
+ * `.agents/workflows/mandrel-deliver.md`, running `--check`, and restoring
52
+ * the file in `afterEach`. The proof was sound; the blast radius was not.
53
+ * `node --test` runs test files in parallel against one shared checkout, so
54
+ * for the ~1s the real file sat mutated, every other test file observed a
55
+ * dirty tree — and `tests/enforcement/workflow-script-help.test.js`, whose
56
+ * final assertion is a repo-wide `git status --porcelain`, reported it as
57
+ * "`--help` mutated the working tree" on the Windows Smoke job, where the
58
+ * wider process-spawn cost stretches that window far enough to collide.
59
+ *
60
+ * A generator that can only ever be pointed at its own checkout forces that
61
+ * choice. Pointing it at a fixture root removes it.
62
+ *
63
+ * The `--root` default is applied here rather than at the call site so the
64
+ * one branch it costs lives with the resolution it belongs to.
65
+ *
66
+ * @param {string} [root] Repository root to render against; defaults to this
67
+ * checkout. A relative path is resolved against the process cwd.
68
+ * @returns {{ root: string, workflowsDir: string, docPath: string }}
69
+ */
70
+ export function resolveTargets(root) {
71
+ const resolved = root ? path.resolve(root) : PROJECT_ROOT;
72
+ return {
73
+ root: resolved,
74
+ workflowsDir: path.join(resolved, '.agents', 'workflows'),
75
+ docPath: path.join(resolved, '.agents', 'docs', 'workflows.md'),
76
+ };
77
+ }
78
+
79
+ /** This checkout's own targets — the default when `--root` is absent. */
80
+ const { workflowsDir: WORKFLOWS_DIR, docPath: DOC_PATH } = resolveTargets();
44
81
 
45
82
  /**
46
83
  * Collapse a catalog description to a single Markdown table-cell-safe line.
@@ -149,16 +186,24 @@ export function renderWorkflowsDoc(catalog, loopCatalog = []) {
149
186
  /**
150
187
  * Build the canonical generated content and read the on-disk file (if any).
151
188
  *
152
- * @returns {{ generated: string, original: string | null }}
189
+ * @param {string} [root] Repository root to render against; see
190
+ * {@link resolveTargets}.
191
+ * @returns {{
192
+ * generated: string,
193
+ * original: string | null,
194
+ * root: string,
195
+ * docPath: string,
196
+ * }}
153
197
  */
154
- export function buildExpected() {
155
- const catalog = buildCatalog(WORKFLOWS_DIR);
156
- const loopCatalog = buildLoopCatalog(WORKFLOWS_DIR);
198
+ export function buildExpected(root) {
199
+ const { root: resolvedRoot, workflowsDir, docPath } = resolveTargets(root);
200
+ const catalog = buildCatalog(workflowsDir);
201
+ const loopCatalog = buildLoopCatalog(workflowsDir);
157
202
  const generated = renderWorkflowsDoc(catalog, loopCatalog);
158
- const original = fs.existsSync(DOC_PATH)
159
- ? fs.readFileSync(DOC_PATH, 'utf8')
203
+ const original = fs.existsSync(docPath)
204
+ ? fs.readFileSync(docPath, 'utf8')
160
205
  : null;
161
- return { generated, original };
206
+ return { generated, original, root: resolvedRoot, docPath };
162
207
  }
163
208
 
164
209
  /**
@@ -169,12 +214,13 @@ async function main(argv = process.argv.slice(2)) {
169
214
  args: argv,
170
215
  options: {
171
216
  check: { type: 'boolean', default: false },
217
+ root: { type: 'string' },
172
218
  },
173
219
  allowPositionals: false,
174
220
  });
175
221
 
176
- const { generated, original } = buildExpected();
177
- const rel = path.relative(PROJECT_ROOT, DOC_PATH).split(path.sep).join('/');
222
+ const { generated, original, root, docPath } = buildExpected(values.root);
223
+ const rel = path.relative(root, docPath).split(path.sep).join('/');
178
224
 
179
225
  if (values.check) {
180
226
  if (original === generated) {
@@ -191,8 +237,8 @@ async function main(argv = process.argv.slice(2)) {
191
237
  Logger.info(`generate-workflows-doc: ${rel} already current — no write.`);
192
238
  return;
193
239
  }
194
- fs.mkdirSync(path.dirname(DOC_PATH), { recursive: true });
195
- fs.writeFileSync(DOC_PATH, generated, 'utf8');
240
+ fs.mkdirSync(path.dirname(docPath), { recursive: true });
241
+ fs.writeFileSync(docPath, generated, 'utf8');
196
242
  Logger.info(`generate-workflows-doc: wrote ${rel}.`);
197
243
  }
198
244
 
@@ -201,7 +247,8 @@ export { DOC_PATH, WORKFLOWS_DIR };
201
247
  runAsCli(import.meta.url, main, {
202
248
  source: 'generate-workflows-doc',
203
249
  usage: {
204
- invocation: 'node .agents/scripts/generate-workflows-doc.js [--check]',
250
+ invocation:
251
+ 'node .agents/scripts/generate-workflows-doc.js [--check] [--root <dir>]',
205
252
  summary:
206
253
  'Regenerate the workflow catalog from .agents/workflows/. Writes only when the generated content differs.',
207
254
  flags: [
@@ -209,6 +256,10 @@ runAsCli(import.meta.url, main, {
209
256
  '--check',
210
257
  'Verify the doc is current and fail if stale; write nothing.',
211
258
  ],
259
+ [
260
+ '--root <dir>',
261
+ "Render against another checkout's .agents/ tree instead of this one (test seam).",
262
+ ],
212
263
  ],
213
264
  },
214
265
  });
@@ -157,6 +157,10 @@ runAsCli(import.meta.url, main, {
157
157
  'Never consider branches matching the glob (repeatable).',
158
158
  ],
159
159
  ['--drop-stashes <ref>', 'Stash ref approved for dropping (repeatable).'],
160
+ [
161
+ '--include-content-merged',
162
+ 'Under --yes, also delete remote refs detected only by content-equivalence.',
163
+ ],
160
164
  ['--base <branch>', 'Base branch (default: project.baseBranch).'],
161
165
  ['--cwd <path>', 'Repository root (default: process cwd).'],
162
166
  ],
@@ -98,6 +98,84 @@ export class ITicketingProvider {
98
98
  // Intentional no-op. Concrete providers that maintain a cache override.
99
99
  }
100
100
 
101
+ /**
102
+ * List every ticket carrying `labels`, in the **mapped** ticket shape.
103
+ *
104
+ * This is the declared read for a label scan, and the only one callers
105
+ * should reach for. Implementations MUST map every issue the way every
106
+ * other read on this interface does — in particular `id` is the **issue
107
+ * number**, not the backend's internal database id.
108
+ *
109
+ * That single rule is the whole reason the method exists. The raw REST
110
+ * payload names the issue number `number` and the database id `id`, so a
111
+ * consumer handed either shape wrote `number ?? id` and appeared to cope —
112
+ * while silently addressing issues by database id on the mapped shape,
113
+ * because there `id` is already the number and the fallback never fires.
114
+ * A declared shape removes the choice rather than documenting it.
115
+ *
116
+ * `state` selects `open` (default), `closed` or `all`. Implementations MUST
117
+ * honour it: a caller asking for `all` is asking a question — "did this
118
+ * child reopen?" — that an open-only listing answers wrongly rather than
119
+ * partially.
120
+ *
121
+ * @param {{ state?: 'open'|'closed'|'all', labels?: string }} [_opts]
122
+ * @returns {Promise<Array<{
123
+ * id: number,
124
+ * title: string,
125
+ * body: string,
126
+ * labels: string[],
127
+ * assignees: string[],
128
+ * state: string,
129
+ * url?: string|null,
130
+ * }>>}
131
+ */
132
+ async listTicketsByLabel(_opts = {}) {
133
+ throw new Error('Not implemented: listTicketsByLabel');
134
+ }
135
+
136
+ /**
137
+ * Read a parent's native sub-issue children as issue numbers.
138
+ *
139
+ * Takes **both** identifiers because they address different things: the
140
+ * backend's child edge is keyed by the parent's opaque node id, while
141
+ * `number` exists only so a degraded read can name the parent it failed on.
142
+ * Passing the number where the node id belongs is not a type error — it is
143
+ * a successful call about the wrong issue — which is why the parameter
144
+ * order is fixed here rather than left to each call site.
145
+ *
146
+ * Implementations MUST return `[]` rather than throw when the sub-issue
147
+ * feature is unavailable on the backend: absence of the feature is not a
148
+ * failed read, and callers union this with a body checklist that still
149
+ * answers the question.
150
+ *
151
+ * @param {string} _nodeId Opaque node id of the parent.
152
+ * @param {number} _number Parent's issue number, for diagnostics only.
153
+ * @returns {Promise<number[]>}
154
+ */
155
+ async getNativeSubIssues(_nodeId, _number) {
156
+ throw new Error('Not implemented: getNativeSubIssues');
157
+ }
158
+
159
+ /**
160
+ * Resolve a ticket's container parent in **one** call.
161
+ *
162
+ * Exists so a child→parent lookup is a read, not a search. Without it the
163
+ * only way to find a container was to list every candidate parent and read
164
+ * each one's children — O(containers) requests to answer what the backend
165
+ * knows directly.
166
+ *
167
+ * Returns `null` when the ticket has no parent, and `null` rather than
168
+ * throwing when the backend cannot answer. Callers treat a null as "no
169
+ * parent resolved *here*" and may fall back to a body-declared link; an
170
+ * exception would turn a degraded lookup into a failed lifecycle edge.
171
+ *
172
+ * @param {number} _number Issue number whose parent to resolve.
173
+ * @returns {Promise<object|null>} Mapped parent ticket, or null.
174
+ */
175
+ async getParentIssue(_number) {
176
+ throw new Error('Not implemented: getParentIssue');
177
+ }
178
+
101
179
  /**
102
180
  * Return the dependency graph edges for a ticket.
103
181
  * Parses `blocked by #NNN` patterns from the ticket body.