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
@@ -0,0 +1,449 @@
1
+ /**
2
+ * The graduator's gh/git primitives. Each takes one gh context value —
3
+ * `{ ghPath, spawnImpl, cwd, timeoutMs }` — spread flat into its options.
4
+ */
5
+
6
+ import { spawn as defaultSpawn } from 'node:child_process';
7
+
8
+ import { LABEL_COLORS } from '../label-constants.js';
9
+
10
+ export const DEFAULT_RUN_CHILD_TIMEOUT_MS = 30000;
11
+
12
+ /**
13
+ * The feedback loop's single spawn helper. Never throws: spawn errors land in
14
+ * `spawnError`; an overrun is SIGKILL'd and resolves `timedOut: true`.
15
+ * `timeoutMs` of `0`/`Infinity` disables the watchdog.
16
+ *
17
+ * @param {object} opts
18
+ * @param {string} opts.cmd
19
+ * @param {string[]} opts.args
20
+ * @param {Function} [opts.spawnImpl]
21
+ * @param {string} [opts.cwd]
22
+ * @param {number} [opts.timeoutMs]
23
+ * @returns {Promise<{ code: number|null, stdout: string, stderr: string, spawnError: Error|null, timedOut: boolean }>}
24
+ */
25
+ export function runChild({
26
+ cmd,
27
+ args,
28
+ spawnImpl = defaultSpawn,
29
+ cwd,
30
+ timeoutMs = DEFAULT_RUN_CHILD_TIMEOUT_MS,
31
+ }) {
32
+ return new Promise((resolve) => {
33
+ let child;
34
+ try {
35
+ child = spawnImpl(cmd, args, {
36
+ stdio: ['ignore', 'pipe', 'pipe'],
37
+ cwd,
38
+ });
39
+ } catch (err) {
40
+ resolve({
41
+ code: null,
42
+ stdout: '',
43
+ stderr: '',
44
+ spawnError: err,
45
+ timedOut: false,
46
+ });
47
+ return;
48
+ }
49
+ let stdout = '';
50
+ let stderr = '';
51
+ let spawnError = null;
52
+ let settled = false;
53
+ let timer = null;
54
+ const finish = (result) => {
55
+ if (settled) return;
56
+ settled = true;
57
+ if (timer) clearTimeout(timer);
58
+ resolve(result);
59
+ };
60
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
61
+ timer = setTimeout(() => {
62
+ try {
63
+ child.kill?.('SIGKILL');
64
+ } catch {
65
+ // already dead / stub child
66
+ }
67
+ finish({
68
+ code: null,
69
+ stdout,
70
+ stderr,
71
+ spawnError: Object.assign(
72
+ new Error(
73
+ `child process '${cmd}' exceeded ${timeoutMs}ms and was killed`,
74
+ ),
75
+ { code: 'ETIMEDOUT' },
76
+ ),
77
+ timedOut: true,
78
+ });
79
+ }, timeoutMs);
80
+ // Deliberately NOT unref'd: a child whose handles close early (or a
81
+ // stub) leaves the loop idle, and an unref'd watchdog would never fire.
82
+ // `finish()` always clears it.
83
+ }
84
+ child.stdout?.on('data', (chunk) => {
85
+ stdout += chunk.toString();
86
+ });
87
+ child.stderr?.on('data', (chunk) => {
88
+ stderr += chunk.toString();
89
+ });
90
+ child.on('error', (err) => {
91
+ spawnError = err;
92
+ });
93
+ child.on('close', (code) => {
94
+ finish({ code, stdout, stderr, spawnError, timedOut: false });
95
+ });
96
+ });
97
+ }
98
+
99
+ function runWith(gh, cmd, args) {
100
+ return runChild({
101
+ cmd,
102
+ args,
103
+ spawnImpl: gh.spawnImpl,
104
+ cwd: gh.cwd,
105
+ timeoutMs: gh.timeoutMs,
106
+ });
107
+ }
108
+
109
+ function runGh(gh, args) {
110
+ return runWith(gh, gh.ghPath, args);
111
+ }
112
+
113
+ function childFailed(res) {
114
+ return (
115
+ Boolean(res.spawnError) || (typeof res.code === 'number' && res.code !== 0)
116
+ );
117
+ }
118
+
119
+ /**
120
+ * `git cat-file -e <ref>:<path>`; a spawn failure/timeout is `probeError`,
121
+ * distinct from a confirmed-missing file.
122
+ *
123
+ * @param {object} opts
124
+ * @param {string} opts.ref
125
+ * @param {string} opts.path
126
+ * @param {Function} [opts.spawnImpl]
127
+ * @param {string} [opts.cwd]
128
+ * @param {number} [opts.timeoutMs]
129
+ * @returns {Promise<{ exists: boolean, probeError: boolean }>}
130
+ */
131
+ export async function probePathStatus({ ref, path, ...gh }) {
132
+ const res = await runWith(gh, 'git', ['cat-file', '-e', `${ref}:${path}`]);
133
+ if (res.spawnError || res.timedOut) {
134
+ return { exists: false, probeError: true };
135
+ }
136
+ return { exists: res.code === 0, probeError: false };
137
+ }
138
+
139
+ /**
140
+ * GitHub search indexes the text inside an HTML-comment marker, but a query
141
+ * carrying the `<!--`/`-->` delimiters never matches it; strip them.
142
+ *
143
+ * @param {string} marker
144
+ * @returns {string}
145
+ */
146
+ function normalizeMarkerQuery(marker) {
147
+ if (typeof marker !== 'string') return '';
148
+ return marker.replaceAll('<!--', '').replaceAll('-->', '').trim();
149
+ }
150
+
151
+ /**
152
+ * `state` defaults to `''`, never `'open'`: an unknown state must not
153
+ * authorize editing an issue.
154
+ *
155
+ * @param {object} row
156
+ * @returns {{ number: number|null, state: string, url: string }}
157
+ */
158
+ function toFollowUpRef(row) {
159
+ const number = Number(row?.number);
160
+ return {
161
+ number: Number.isInteger(number) && number > 0 ? number : null,
162
+ state: String(row?.state ?? '').toLowerCase(),
163
+ url: typeof row?.url === 'string' ? row.url : '',
164
+ };
165
+ }
166
+
167
+ /**
168
+ * `null` on no match OR an undecidable probe — degrade toward filing: a
169
+ * duplicate beats a swallowed finding.
170
+ *
171
+ * @returns {Promise<{ number: number|null, state: string, url: string }|null>}
172
+ */
173
+ export async function searchFollowUpByMarker({ marker, owner, repo, ...gh }) {
174
+ const res = await runGh(gh, [
175
+ 'search',
176
+ 'issues',
177
+ normalizeMarkerQuery(marker),
178
+ '--repo',
179
+ `${owner}/${repo}`,
180
+ '--json',
181
+ 'number,state,url',
182
+ '--limit',
183
+ '1',
184
+ ]);
185
+ if (childFailed(res)) return null;
186
+ try {
187
+ const parsed = JSON.parse(res.stdout || '[]');
188
+ if (!Array.isArray(parsed) || parsed.length === 0) return null;
189
+ return toFollowUpRef(parsed[0]);
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Strongly-consistent last gate before creating: the search index can lag
197
+ * long enough to miss a duplicate filed seconds earlier, while a
198
+ * label-scoped `gh issue list --state all` cannot. Any of `markers` matching
199
+ * counts, so a marker-format change does not re-file the backlog. `null` on
200
+ * no match or error (degrade toward filing).
201
+ *
202
+ * @param {object} opts
203
+ * @param {string[]} opts.markers
204
+ * @param {string} opts.owner
205
+ * @param {string} opts.repo
206
+ * @param {string[]} [opts.labels]
207
+ * @returns {Promise<{ number: number|null, state: string, url: string }|null>}
208
+ */
209
+ export async function findExistingFollowUp({
210
+ markers,
211
+ owner,
212
+ repo,
213
+ labels,
214
+ ...gh
215
+ }) {
216
+ const tokens = (Array.isArray(markers) ? markers : []).filter(
217
+ (m) => typeof m === 'string' && m.length > 0,
218
+ );
219
+ if (tokens.length === 0) return null;
220
+ const args = [
221
+ 'issue',
222
+ 'list',
223
+ '--repo',
224
+ `${owner}/${repo}`,
225
+ '--state',
226
+ 'all',
227
+ '--json',
228
+ 'number,body,state,url',
229
+ ];
230
+ for (const label of Array.isArray(labels) ? labels : []) {
231
+ args.push('--label', label);
232
+ }
233
+ const res = await runGh(gh, args);
234
+ if (childFailed(res)) return null;
235
+ let parsed;
236
+ try {
237
+ parsed = JSON.parse(res.stdout || '[]');
238
+ } catch {
239
+ return null;
240
+ }
241
+ if (!Array.isArray(parsed)) return null;
242
+ const matches = parsed.filter(
243
+ (issue) =>
244
+ typeof issue?.body === 'string' &&
245
+ tokens.some((token) => issue.body.includes(token)),
246
+ );
247
+ if (matches.length === 0) return null;
248
+ // Prefer an open match: a closed one is a decided follow-up.
249
+ const open = matches.find(
250
+ (issue) => String(issue?.state ?? '').toLowerCase() === 'open',
251
+ );
252
+ return toFollowUpRef(open ?? matches[0]);
253
+ }
254
+
255
+ function issueWriteError(verb, res) {
256
+ return res.spawnError
257
+ ? `gh issue ${verb} spawn failed: ${res.spawnError.message}`
258
+ : `gh issue ${verb} exited ${res.code}: ${(res.stderr || '').trim()}`;
259
+ }
260
+
261
+ /**
262
+ * Recurrence path: refresh the body only, leaving human-curated labels,
263
+ * title, assignees and state untouched.
264
+ *
265
+ * @returns {Promise<{ url: string|null, error: string|null }>}
266
+ */
267
+ export async function updateFollowUpIssue({
268
+ owner,
269
+ repo,
270
+ number,
271
+ body,
272
+ ...gh
273
+ }) {
274
+ const res = await runGh(gh, [
275
+ 'issue',
276
+ 'edit',
277
+ String(number),
278
+ '--repo',
279
+ `${owner}/${repo}`,
280
+ '--body',
281
+ body,
282
+ ]);
283
+ if (childFailed(res))
284
+ return { url: null, error: issueWriteError('edit', res) };
285
+ return { url: (res.stdout || '').trim(), error: null };
286
+ }
287
+
288
+ const FRICTION_LABEL_PREFIX = 'friction::';
289
+
290
+ /**
291
+ * Only `meta::*` and `friction::<category>` labels reach this path.
292
+ *
293
+ * @param {string} name
294
+ * @returns {{ color: string, description: string }}
295
+ */
296
+ function describeMintedLabel(name) {
297
+ if (name.startsWith(FRICTION_LABEL_PREFIX)) {
298
+ return {
299
+ color: LABEL_COLORS.FRICTION,
300
+ description: `Recurring friction category "${name.slice(FRICTION_LABEL_PREFIX.length)}" (minted by the feedback loop)`,
301
+ };
302
+ }
303
+ return {
304
+ color: LABEL_COLORS.META,
305
+ description: 'Feedback-loop routing axis (minted by the feedback loop)',
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Memoized per repo. `known: null` means unverifiable, not "no labels".
311
+ *
312
+ * @returns {Promise<{ known: Set<string>|null, error: string|null }>}
313
+ */
314
+ async function readLiveLabelNames({ owner, repo, labelCache, gh }) {
315
+ const key = `${owner}/${repo}`;
316
+ const cached = labelCache?.get(key);
317
+ if (cached) return { known: cached, error: null };
318
+ const res = await runGh(gh, [
319
+ 'label',
320
+ 'list',
321
+ '--repo',
322
+ key,
323
+ '--limit',
324
+ '500',
325
+ '--json',
326
+ 'name',
327
+ ]);
328
+ if (childFailed(res)) {
329
+ return {
330
+ known: null,
331
+ error: `gh label list ${key} failed: ${res.spawnError?.message ?? (res.stderr || '').trim()}`,
332
+ };
333
+ }
334
+ let parsed;
335
+ try {
336
+ parsed = JSON.parse(res.stdout || '[]');
337
+ } catch {
338
+ return {
339
+ known: null,
340
+ error: `gh label list ${key} returned unparseable JSON`,
341
+ };
342
+ }
343
+ if (!Array.isArray(parsed)) {
344
+ return { known: null, error: `gh label list ${key} returned a non-array` };
345
+ }
346
+ const known = new Set();
347
+ for (const row of parsed) {
348
+ if (row && typeof row.name === 'string') known.add(row.name);
349
+ }
350
+ labelCache?.set(key, known);
351
+ return { known, error: null };
352
+ }
353
+
354
+ /**
355
+ * Mint absent labels: `gh issue create` fails the whole call on any unknown
356
+ * `--label`, and `friction::<category>` names come from live telemetry so no
357
+ * bootstrap can pre-create them. An unreadable live set reports nothing
358
+ * `missing`, so the caller still attempts the filing.
359
+ *
360
+ * @param {object} opts
361
+ * @param {string} opts.owner
362
+ * @param {string} opts.repo
363
+ * @param {string[]} opts.labels
364
+ * @param {Map<string, Set<string>>} [opts.labelCache]
365
+ * @returns {Promise<{ created: string[], missing: string[], errors: string[] }>}
366
+ */
367
+ export async function ensureIssueLabels({
368
+ owner,
369
+ repo,
370
+ labels,
371
+ labelCache,
372
+ ...ghOpts
373
+ }) {
374
+ const gh = { ...ghOpts, ghPath: ghOpts.ghPath ?? 'gh' };
375
+ const wanted = (Array.isArray(labels) ? labels : []).filter(
376
+ (name) => typeof name === 'string' && name.trim().length > 0,
377
+ );
378
+ if (wanted.length === 0) return { created: [], missing: [], errors: [] };
379
+
380
+ const { known, error } = await readLiveLabelNames({
381
+ owner,
382
+ repo,
383
+ labelCache,
384
+ gh,
385
+ });
386
+ if (!known) return { created: [], missing: [], errors: error ? [error] : [] };
387
+
388
+ const created = [];
389
+ const missing = [];
390
+ const errors = [];
391
+ for (const name of wanted) {
392
+ if (known.has(name)) continue;
393
+ const { color, description } = describeMintedLabel(name);
394
+ const res = await runGh(gh, [
395
+ 'label',
396
+ 'create',
397
+ name,
398
+ '--repo',
399
+ `${owner}/${repo}`,
400
+ '--color',
401
+ color.replace(/^#/, ''),
402
+ '--description',
403
+ description,
404
+ ]);
405
+ // A concurrent mint between list and create is success.
406
+ const raced = /label\b[\s\S]*?already exists/i.test(
407
+ `${res.stderr ?? ''}${res.spawnError?.message ?? ''}`,
408
+ );
409
+ if (!childFailed(res) || raced) {
410
+ known.add(name);
411
+ if (!raced) created.push(name);
412
+ continue;
413
+ }
414
+ missing.push(name);
415
+ errors.push(
416
+ `gh label create "${name}" in ${owner}/${repo} failed: ${res.spawnError?.message ?? (res.stderr || '').trim()}`,
417
+ );
418
+ }
419
+ return { created, missing, errors };
420
+ }
421
+
422
+ /** Resolves `{ url, error }`; exactly one is non-null. */
423
+ export async function createFollowUpIssue({
424
+ owner,
425
+ repo,
426
+ title,
427
+ body,
428
+ labels,
429
+ ...gh
430
+ }) {
431
+ const args = [
432
+ 'issue',
433
+ 'create',
434
+ '--repo',
435
+ `${owner}/${repo}`,
436
+ '--title',
437
+ title,
438
+ '--body',
439
+ body,
440
+ ];
441
+ for (const label of labels) {
442
+ args.push('--label', label);
443
+ }
444
+ const res = await runGh(gh, args);
445
+ if (childFailed(res)) {
446
+ return { url: null, error: issueWriteError('create', res) };
447
+ }
448
+ return { url: (res.stdout || '').trim(), error: null };
449
+ }