mandrel 2.65.0 → 2.67.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 (82) hide show
  1. package/.agents/agents/acceptance-critic.md +7 -7
  2. package/.agents/agents/auditor.md +17 -18
  3. package/.agents/agents/plan-critic.md +5 -5
  4. package/.agents/agents/story-worker.md +5 -5
  5. package/.agents/docs/agentrc-reference.json +2 -1
  6. package/.agents/docs/configuration.md +2 -1
  7. package/.agents/docs/execution-reference.md +27 -5
  8. package/.agents/docs/workflows.md +4 -2
  9. package/.agents/instructions.md +12 -13
  10. package/.agents/rules/ci-remediation.md +3 -3
  11. package/.agents/rules/gherkin-standards.md +3 -2
  12. package/.agents/rules/git-conventions-reference.md +17 -8
  13. package/.agents/rules/git-conventions.md +10 -8
  14. package/.agents/rules/testing-standards.md +8 -7
  15. package/.agents/runtime-deps.json +1 -1
  16. package/.agents/schemas/agentrc.schema.json +6 -1
  17. package/.agents/scripts/boot-sweep.js +97 -9
  18. package/.agents/scripts/bootstrap.js +94 -89
  19. package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
  20. package/.agents/scripts/clean-temp.js +54 -0
  21. package/.agents/scripts/clean-worktrees.js +593 -0
  22. package/.agents/scripts/drain-pending-cleanup.js +5 -4
  23. package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
  24. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +78 -78
  25. package/.agents/scripts/lib/clean-temp.js +440 -0
  26. package/.agents/scripts/lib/cli/standard-args.js +60 -76
  27. package/.agents/scripts/lib/cli-args.js +26 -0
  28. package/.agents/scripts/lib/config/gates/shared.js +3 -3
  29. package/.agents/scripts/lib/config-settings-schema-delivery.js +11 -2
  30. package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
  31. package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
  32. package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
  33. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  34. package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
  35. package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
  36. package/.agents/scripts/lib/observability/signal-validator.js +17 -5
  37. package/.agents/scripts/lib/observability/source-classifier.js +3 -1
  38. package/.agents/scripts/lib/orchestration/code-review.js +22 -0
  39. package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
  40. package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
  41. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
  42. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +23 -0
  43. package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
  44. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -0
  45. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +349 -263
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
  48. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +327 -314
  49. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
  50. package/.agents/scripts/lib/signals/detectors/common.js +63 -51
  51. package/.agents/scripts/lib/single-story-sweep.js +2 -2
  52. package/.agents/scripts/lib/temp-removal.js +110 -0
  53. package/.agents/scripts/lib/temp-retention.js +122 -73
  54. package/.agents/scripts/lib/transpile.js +28 -3
  55. package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
  56. package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
  57. package/.agents/scripts/single-story-close.js +10 -2
  58. package/.agents/scripts/single-story-confirm-merge.js +267 -238
  59. package/.agents/scripts/single-story-init.js +120 -17
  60. package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
  61. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
  62. package/.agents/workflows/audit-architecture.md +5 -4
  63. package/.agents/workflows/audit-documentation.md +5 -5
  64. package/.agents/workflows/audit-performance.md +10 -10
  65. package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
  66. package/.agents/workflows/clean-temp.md +67 -0
  67. package/.agents/workflows/clean-worktrees.md +63 -0
  68. package/.agents/workflows/git-deliver.md +1 -1
  69. package/.agents/workflows/helpers/acceptance-self-eval.md +11 -10
  70. package/.agents/workflows/helpers/audit-lens-core.md +30 -57
  71. package/.agents/workflows/helpers/deliver-digest.md +2 -2
  72. package/.agents/workflows/helpers/deliver-reference.md +3 -1
  73. package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
  74. package/.agents/workflows/helpers/deliver-story.md +6 -1
  75. package/.agents/workflows/helpers/parallel-tooling.md +16 -18
  76. package/.agents/workflows/mandrel-deliver.md +1 -1
  77. package/.agents/workflows/mandrel-plan.md +6 -5
  78. package/docs/CHANGELOG.md +39 -0
  79. package/lib/cli/guarded-sync.js +87 -0
  80. package/lib/cli/sync-agents.js +9 -92
  81. package/lib/cli/sync-commands.js +9 -101
  82. package/package.json +2 -2
@@ -24,13 +24,26 @@ const SUPPORTED_FLAGS = Object.freeze({
24
24
 
25
25
  const FLAG_NAMES = Object.keys(SUPPORTED_FLAGS);
26
26
 
27
- const SUPPORTED_EXTRAS_TYPES = new Set([
28
- 'string',
29
- 'boolean',
30
- 'ticket',
31
- 'integer',
32
- 'string-multi',
33
- ]);
27
+ const isNullish = (v) => v === null || v === undefined;
28
+
29
+ /** Per-type rules shared by built-in and extra flags. */
30
+ const FLAG_TYPES = Object.freeze({
31
+ string: {
32
+ normalise: (v, orDefault) =>
33
+ typeof v === 'string' && v.length > 0 ? v : orDefault(null),
34
+ isAbsent: (v) => isNullish(v) || v === '',
35
+ },
36
+ boolean: { normalise: (v) => v === true, isAbsent: (v) => !v },
37
+ ticket: { normalise: (v) => parseTicketId(v), isAbsent: isNullish },
38
+ integer: {
39
+ normalise: (v, orDefault) => (v === undefined ? orDefault(undefined) : v),
40
+ isAbsent: (v) => v === undefined || Number.isNaN(v),
41
+ },
42
+ 'string-multi': {
43
+ normalise: (v, orDefault) => (Array.isArray(v) ? v : orDefault([])),
44
+ isAbsent: (v) => !Array.isArray(v) || v.length === 0,
45
+ },
46
+ });
34
47
 
35
48
  function camelCase(name) {
36
49
  return name.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
@@ -50,16 +63,33 @@ function normaliseCallSignature(opts) {
50
63
  return { argv: argv ?? [], schema, extras };
51
64
  }
52
65
 
53
- function buildDefineFlagsSpec(extras) {
66
+ function flagEntries(schema, extras) {
67
+ const entries = Object.entries(SUPPORTED_FLAGS).map(
68
+ ([flag, { key, type }]) => ({
69
+ flag,
70
+ key,
71
+ type,
72
+ def: {},
73
+ required: schema?.[flag]?.required === true,
74
+ }),
75
+ );
76
+ for (const [flag, def] of Object.entries(extras ?? {})) {
77
+ entries.push({
78
+ flag,
79
+ key: def.alias ?? camelCase(flag),
80
+ type: def.type,
81
+ def,
82
+ required: def.required === true,
83
+ });
84
+ }
85
+ return entries;
86
+ }
87
+
88
+ function buildDefineFlagsSpec(entries) {
54
89
  const spec = {};
55
- for (const [flag, { key, type }] of Object.entries(SUPPORTED_FLAGS)) {
90
+ for (const { flag, key, type, def } of entries) {
56
91
  spec[flag] = { type, alias: key };
57
- }
58
- if (!extras) return spec;
59
- for (const [flag, def] of Object.entries(extras)) {
60
- const entry = { type: def.type, alias: def.alias ?? camelCase(flag) };
61
- if ('default' in def) entry.default = def.default;
62
- spec[flag] = entry;
92
+ if ('default' in def) spec[flag].default = def.default;
63
93
  }
64
94
  return spec;
65
95
  }
@@ -119,10 +149,10 @@ function validateExtras(extras) {
119
149
  `parseStandardCliArgs: extras["${flag}"] must be an object`,
120
150
  );
121
151
  }
122
- if (!SUPPORTED_EXTRAS_TYPES.has(def.type)) {
152
+ if (!Object.hasOwn(FLAG_TYPES, def.type)) {
123
153
  const err = new Error(
124
154
  `parseStandardCliArgs: extras["${flag}"].type "${def.type}" is unsupported. ` +
125
- `Supported: ${[...SUPPORTED_EXTRAS_TYPES].join(', ')}.`,
155
+ `Supported: ${Object.keys(FLAG_TYPES).join(', ')}.`,
126
156
  );
127
157
  err.code = 'UNKNOWN_EXTRAS_TYPE';
128
158
  throw err;
@@ -130,15 +160,6 @@ function validateExtras(extras) {
130
160
  }
131
161
  }
132
162
 
133
- function isAbsent(type, cur) {
134
- if (type === 'ticket') return cur === null || cur === undefined;
135
- if (type === 'string') return cur === null || cur === undefined || cur === '';
136
- if (type === 'boolean') return !cur;
137
- if (type === 'integer') return cur === undefined || Number.isNaN(cur);
138
- if (type === 'string-multi') return !Array.isArray(cur) || cur.length === 0;
139
- return cur === undefined || cur === null;
140
- }
141
-
142
163
  function throwMissing(flag) {
143
164
  const err = new Error(
144
165
  `parseStandardCliArgs: missing required flag --${flag}`,
@@ -148,56 +169,19 @@ function throwMissing(flag) {
148
169
  throw err;
149
170
  }
150
171
 
151
- function enforceRequired(values, schema) {
152
- if (!schema) return;
153
- for (const [flag, rule] of Object.entries(schema)) {
154
- if (!rule || rule.required !== true) continue;
155
- const meta = SUPPORTED_FLAGS[flag];
156
- if (isAbsent(meta.type, values[meta.key])) throwMissing(flag);
157
- }
158
- }
159
-
160
- function enforceExtrasRequired(values, extras) {
161
- if (!extras) return;
162
- for (const [flag, def] of Object.entries(extras)) {
163
- if (!def || def.required !== true) continue;
164
- const key = def.alias ?? camelCase(flag);
165
- if (isAbsent(def.type, values[key])) throwMissing(flag);
172
+ function normaliseValues(raw, entries) {
173
+ const out = {};
174
+ for (const { key, type, def } of entries) {
175
+ const orDefault = (empty) => ('default' in def ? def.default : empty);
176
+ out[key] = FLAG_TYPES[type].normalise(raw[key], orDefault);
166
177
  }
178
+ return out;
167
179
  }
168
180
 
169
- /** Absent strings become `null` and booleans strict `false`. */
170
- function normaliseValues(raw, extras) {
171
- const out = {};
172
- for (const [, { key, type }] of Object.entries(SUPPORTED_FLAGS)) {
173
- let v = raw[key];
174
- if (type === 'ticket') {
175
- v = parseTicketId(v);
176
- } else if (type === 'string') {
177
- v = typeof v === 'string' && v.length > 0 ? v : null;
178
- } else if (type === 'boolean') {
179
- v = v === true;
180
- }
181
- out[key] = v;
181
+ function enforceRequired(values, entries) {
182
+ for (const { flag, key, type, required } of entries) {
183
+ if (required && FLAG_TYPES[type].isAbsent(values[key])) throwMissing(flag);
182
184
  }
183
- if (!extras) return out;
184
- for (const [flag, def] of Object.entries(extras)) {
185
- const key = def.alias ?? camelCase(flag);
186
- let v = raw[key];
187
- if (def.type === 'boolean') {
188
- v = v === true;
189
- } else if (def.type === 'string') {
190
- if (v === undefined) v = 'default' in def ? def.default : null;
191
- } else if (def.type === 'ticket') {
192
- v = parseTicketId(v);
193
- } else if (def.type === 'string-multi') {
194
- if (!Array.isArray(v)) v = 'default' in def ? def.default : [];
195
- } else if (def.type === 'integer') {
196
- if (v === undefined && 'default' in def) v = def.default;
197
- }
198
- out[key] = v;
199
- }
200
- return out;
201
185
  }
202
186
 
203
187
  /**
@@ -222,13 +206,13 @@ export function parseStandardCliArgs(opts = {}) {
222
206
  err.flag = unknown;
223
207
  throw err;
224
208
  }
209
+ const entries = flagEntries(schema, extras);
225
210
  const { values: raw, positionals } = defineFlags(
226
- buildDefineFlagsSpec(extras),
211
+ buildDefineFlagsSpec(entries),
227
212
  argv,
228
213
  );
229
- const values = normaliseValues(raw, extras);
230
- enforceRequired(values, schema);
231
- enforceExtrasRequired(values, extras);
214
+ const values = normaliseValues(raw, entries);
215
+ enforceRequired(values, entries);
232
216
  return { values, positionals };
233
217
  }
234
218
 
@@ -133,6 +133,30 @@ function tolerantOverrideReviewBlock(value) {
133
133
  }
134
134
  }
135
135
 
136
+ /**
137
+ * Best-effort: absent or junk is `null` plus a warning, never a throw.
138
+ *
139
+ * @param {unknown} value
140
+ * @returns {{ tokens: number|null, warning: string|null }}
141
+ */
142
+ export function parseWorkerTokens(value) {
143
+ if (value == null) {
144
+ return {
145
+ tokens: null,
146
+ warning:
147
+ '--worker-tokens not supplied; telemetry.workerTokens records null.',
148
+ };
149
+ }
150
+ const raw = String(value).trim();
151
+ if (/^\d+$/.test(raw) && Number.isSafeInteger(Number(raw))) {
152
+ return { tokens: Number(raw), warning: null };
153
+ }
154
+ return {
155
+ tokens: null,
156
+ warning: `--worker-tokens must be a non-negative integer (got ${JSON.stringify(value)}); telemetry.workerTokens records null.`,
157
+ };
158
+ }
159
+
136
160
  /**
137
161
  * Throws when a validating flag parser rejects a value; error handlers use
138
162
  * {@link parseSprintArgsTolerant} instead of re-calling this.
@@ -164,6 +188,7 @@ export function parseSprintArgs(
164
188
  'merge-watch-mode': { type: 'string' },
165
189
  'rerun-advisory': { type: 'string' },
166
190
  'override-review-block': { type: 'string' },
191
+ 'worker-tokens': { type: 'string' },
167
192
  executor: { type: 'string' },
168
193
  cwd: { type: 'string' },
169
194
  'recut-of': { type: 'string' },
@@ -194,6 +219,7 @@ export function parseSprintArgs(
194
219
  overrideReviewBlock: tolerant
195
220
  ? tolerantOverrideReviewBlock(values['override-review-block'])
196
221
  : parseOverrideReviewBlock(values['override-review-block']),
222
+ workerTokens: values['worker-tokens'],
197
223
  executor: values.executor ?? null,
198
224
  cwd:
199
225
  (typeof values.cwd === 'string' && values.cwd.trim()) ||
@@ -23,7 +23,7 @@ export const LIST_OR_EXTENDER_OF_STRINGS = {
23
23
  ],
24
24
  };
25
25
 
26
- export const TOLERANCE_SCHEMA = {
26
+ const TOLERANCE_SCHEMA = {
27
27
  type: 'object',
28
28
  description:
29
29
  'How much a rollup may drift from the committed baseline before the gate reports a regression.',
@@ -47,7 +47,7 @@ export const TOLERANCE_SCHEMA = {
47
47
  /**
48
48
  * `"*"` is optional: the resolver injects the framework default when absent.
49
49
  */
50
- export const FLOORS_SCHEMA = {
50
+ const FLOORS_SCHEMA = {
51
51
  type: 'object',
52
52
  description:
53
53
  'Workspace-keyed absolute floors: `{ "<workspace>": { "<metric>": number } }`. `"*"` is the project-wide catch-all; the metric keyset is open so per-rollup keys flow through without each gate enumerating them. Floors are absolute — unlike `tolerance`, they are enforced regardless of the baseline.',
@@ -57,7 +57,7 @@ export const FLOORS_SCHEMA = {
57
57
  },
58
58
  };
59
59
 
60
- export const COMPONENTS_SCHEMA = {
60
+ const COMPONENTS_SCHEMA = {
61
61
  type: 'object',
62
62
  description:
63
63
  'Per-gate component map — component name to the glob list whose files roll up under it. Defaults to `{ "*": ["**"] }` at the resolver layer.',
@@ -283,8 +283,9 @@ const TEMP_RETENTION_SCHEMA = {
283
283
  description:
284
284
  'Story #4794. Auto-purge of spent temp artifacts once their Story lands. ' +
285
285
  'Classification is an allowlist: only the declared classes below are ever ' +
286
- 'deleted, so operator scratch files under tempRoot are reported with their ' +
287
- 'size and left alone. signals.ndjson is never purged by any path.',
286
+ 'deleted, so unrecognized files under tempRoot are reported with their ' +
287
+ 'size and left alone (`/clean-temp` is the operator path for them). ' +
288
+ 'signals.ndjson is never purged by any path.',
288
289
  properties: {
289
290
  enabled: {
290
291
  type: 'boolean',
@@ -326,6 +327,14 @@ const TEMP_RETENTION_SCHEMA = {
326
327
  'Age-floored only; the current run is always excluded.',
327
328
  default: true,
328
329
  },
330
+ scratch: {
331
+ type: 'boolean',
332
+ description:
333
+ '<tempRoot>/scratch/ — agent-authored scratch. ' +
334
+ '`scratch/story-<id>/` is purged when that Story lands; any ' +
335
+ 'other `scratch/` entry is age-floored.',
336
+ default: true,
337
+ },
329
338
  },
330
339
  additionalProperties: false,
331
340
  },
@@ -0,0 +1,205 @@
1
+ /**
2
+ * One finding's walk through the graduator, as route → dedup → file steps.
3
+ * Every step receives the gh context as the single `gh` value.
4
+ */
5
+
6
+ import { routeOwnership } from '../github/framework-repo.js';
7
+ import {
8
+ createFollowUpIssue,
9
+ ensureIssueLabels,
10
+ findExistingFollowUp,
11
+ probePathStatus,
12
+ searchFollowUpByMarker,
13
+ updateFollowUpIssue,
14
+ } from './graduator-gh.js';
15
+
16
+ /** @returns {Promise<string|null>} A skip reason, or `null` to continue. */
17
+ export async function probeFindingPath({ finding, gitRef, gh }) {
18
+ // A path-less finding would misclassify as `file-removed`; skip the probe.
19
+ if (typeof finding.path !== 'string' || finding.path.trim().length === 0) {
20
+ return null;
21
+ }
22
+ const { exists, probeError } = await probePathStatus({
23
+ ref: gitRef,
24
+ path: finding.path,
25
+ ...gh,
26
+ });
27
+ if (probeError) return 'probe-error';
28
+ return exists ? null : 'file-removed';
29
+ }
30
+
31
+ /**
32
+ * Unroutable findings are deferred and named, never re-pointed at the
33
+ * consumer's repo.
34
+ *
35
+ * @returns {{ source: string, routedRepo: object|null, deferred: object|null, skipReason: string|null }}
36
+ */
37
+ export function routeFinding({
38
+ finding,
39
+ classifier,
40
+ repos,
41
+ currentRepo,
42
+ logger,
43
+ spec,
44
+ }) {
45
+ const source = classifier(finding.path, null);
46
+ const routing = routeOwnership({ bucket: source, repos, currentRepo });
47
+ if (!routing.routable) {
48
+ const logLine = `[${spec.fnName}] unroutable ${source} finding (${routing.missingKey} is unset) — not filed: ${finding.title ?? finding.path ?? `finding ${finding.index}`}`;
49
+ logger?.warn?.(logLine);
50
+ const deferred = {
51
+ finding,
52
+ routedRepo: null,
53
+ source,
54
+ logLine,
55
+ missingKey: routing.missingKey,
56
+ };
57
+ return { source, routedRepo: null, deferred, skipReason: 'unroutable' };
58
+ }
59
+ const routedRepo = routing.routedRepo;
60
+ if (routing.crossRepo) {
61
+ const logLine = spec.buildCrossRepoLog({ finding, routedRepo, source });
62
+ logger?.info?.(logLine);
63
+ const deferred = { finding, routedRepo, source, logLine };
64
+ return { source, routedRepo, deferred, skipReason: 'cross-repo-deferred' };
65
+ }
66
+ return { source, routedRepo, deferred: null, skipReason: null };
67
+ }
68
+
69
+ /**
70
+ * Checks the content-hash marker, then the legacy ordinal marker so
71
+ * pre-fingerprint filings are not re-filed.
72
+ *
73
+ * @returns {Promise<{ number: number|null, state: string, url: string }|null>}
74
+ */
75
+ export async function dedupFinding({
76
+ finding,
77
+ epicId,
78
+ routedRepo,
79
+ contentMarker,
80
+ gh,
81
+ spec,
82
+ }) {
83
+ const probe = (marker) =>
84
+ searchFollowUpByMarker({
85
+ marker,
86
+ owner: routedRepo.owner,
87
+ repo: routedRepo.repo,
88
+ ...gh,
89
+ });
90
+ const hit = await probe(contentMarker);
91
+ if (hit) return hit;
92
+ const legacyMarker =
93
+ typeof spec.buildLegacyMarker === 'function'
94
+ ? spec.buildLegacyMarker(epicId, finding.index)
95
+ : null;
96
+ return legacyMarker ? probe(legacyMarker) : null;
97
+ }
98
+
99
+ /**
100
+ * An open, numbered match is refreshed and recorded on `filed` as
101
+ * `action: 'updated'` (a live write, counted like a creation). Anything else
102
+ * is `already-filed` and untouched: a closed follow-up is a human's decision.
103
+ */
104
+ export async function recordRecurrence(walk, existing, body) {
105
+ const { finding, source, routedRepo, envelope, decorate, skip, gh } = walk;
106
+ walk.filedMarkers?.add(walk.contentMarker);
107
+ if (existing.state !== 'open' || existing.number === null) {
108
+ skip('already-filed');
109
+ return;
110
+ }
111
+ const updated = await updateFollowUpIssue({
112
+ owner: routedRepo.owner,
113
+ repo: routedRepo.repo,
114
+ number: existing.number,
115
+ body,
116
+ ...gh,
117
+ });
118
+ if (updated.error) {
119
+ envelope.errors.push(
120
+ `finding ${finding.index} (${finding.path}): ${updated.error}`,
121
+ );
122
+ return;
123
+ }
124
+ envelope.filed.push(
125
+ decorate(
126
+ {
127
+ index: finding.index,
128
+ action: 'updated',
129
+ issueNumber: existing.number,
130
+ severity: finding.severity,
131
+ path: finding.path,
132
+ source,
133
+ repo: `${routedRepo.owner}/${routedRepo.repo}`,
134
+ url: updated.url || existing.url || null,
135
+ },
136
+ finding,
137
+ ),
138
+ );
139
+ }
140
+
141
+ function strongReadExisting(walk, labels) {
142
+ const { epicId, finding, contentMarker, routedRepo, spec } = walk;
143
+ return findExistingFollowUp({
144
+ markers:
145
+ typeof spec.buildMatchTokens === 'function'
146
+ ? spec.buildMatchTokens({ epicId, finding, contentMarker })
147
+ : [contentMarker],
148
+ owner: routedRepo.owner,
149
+ repo: routedRepo.repo,
150
+ labels,
151
+ ...walk.gh,
152
+ });
153
+ }
154
+
155
+ /** Cap → label mint → strong-read confirm → create. */
156
+ export async function fileFinding(walk, { title, body, labels }) {
157
+ const { finding, routedRepo, envelope, decorate, skip, gh } = walk;
158
+ if (envelope.filed.length >= walk.maxFilingsPerRun) {
159
+ return skip('cap-reached');
160
+ }
161
+ // Before the strong read too: `gh issue list --label <absent>` exits 0
162
+ // with `[]`, silently defeating the dedup confirm.
163
+ const ensured = await ensureIssueLabels({
164
+ owner: routedRepo.owner,
165
+ repo: routedRepo.repo,
166
+ labels,
167
+ labelCache: walk.labelCache,
168
+ ...gh,
169
+ });
170
+ envelope.errors.push(...ensured.errors);
171
+ if (ensured.missing.length > 0) return skip('label-ensure-failed');
172
+
173
+ const confirmed = await strongReadExisting(walk, labels);
174
+ if (confirmed) return recordRecurrence(walk, confirmed, body);
175
+
176
+ const created = await createFollowUpIssue({
177
+ owner: routedRepo.owner,
178
+ repo: routedRepo.repo,
179
+ title,
180
+ body,
181
+ labels,
182
+ ...gh,
183
+ });
184
+ if (created.error) {
185
+ envelope.errors.push(
186
+ `finding ${finding.index} (${finding.path}): ${created.error}`,
187
+ );
188
+ return;
189
+ }
190
+ walk.filedMarkers?.add(walk.contentMarker);
191
+ envelope.filed.push(
192
+ decorate(
193
+ {
194
+ index: finding.index,
195
+ action: 'created',
196
+ severity: finding.severity,
197
+ path: finding.path,
198
+ source: walk.source,
199
+ repo: `${routedRepo.owner}/${routedRepo.repo}`,
200
+ url: created.url,
201
+ },
202
+ finding,
203
+ ),
204
+ );
205
+ }