mandrel 1.62.0 → 1.63.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 (27) hide show
  1. package/.agents/scripts/check-action-pinning.js +260 -0
  2. package/.agents/scripts/check-arch-cycles.js +38 -14
  3. package/.agents/scripts/epic-deliver-prepare.js +149 -104
  4. package/.agents/scripts/lib/baseline-snapshot.js +245 -141
  5. package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
  6. package/.agents/scripts/lib/orchestration/code-review.js +206 -168
  7. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
  8. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
  9. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
  10. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
  11. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
  12. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
  13. package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
  14. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
  15. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
  16. package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
  17. package/.agents/scripts/lib/signals/detectors/common.js +107 -0
  18. package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
  19. package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
  20. package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
  21. package/.agents/scripts/lib/story-body/story-body.js +102 -76
  22. package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
  23. package/.agents/scripts/single-story-init.js +16 -3
  24. package/.agents/workflows/audit-architecture.md +9 -0
  25. package/README.md +1 -1
  26. package/docs/CHANGELOG.md +28 -0
  27. package/package.json +1 -1
@@ -238,183 +238,217 @@ export async function createFollowUpIssue({
238
238
  * @param {(record: object, finding: object) => object} [opts.spec.decorateRecord]
239
239
  * @returns {Promise<{ filed: object[], skipped: object[], errors: string[] }>}
240
240
  */
241
- export async function graduate({
241
+ /**
242
+ * Validate the `graduate` preconditions (toggle, epicId, provider shape,
243
+ * currentRepo shape). Returns `null` when all preconditions pass, or a
244
+ * `{ skipped?, errors? }` partial-envelope the caller short-circuits on.
245
+ * Story #4075 — extracted from `graduate` so the orchestrating body holds
246
+ * no guard-chain branching.
247
+ */
248
+ function checkGraduatePreconditions({
242
249
  epicId,
243
250
  provider,
244
- config,
245
251
  currentRepo,
246
- frameworkRepo,
247
- gitRef = 'HEAD',
248
- classifier = defaultClassifier,
249
- ghPath = 'gh',
250
- spawnImpl,
251
- cwd,
252
- logger,
252
+ config,
253
253
  spec,
254
254
  }) {
255
- const envelope = { filed: [], skipped: [], errors: [] };
256
- const decorate =
257
- typeof spec.decorateRecord === 'function'
258
- ? spec.decorateRecord
259
- : (record) => record;
260
-
261
255
  if (!spec.isAutoFileEnabled(config)) {
262
- envelope.skipped.push({ reason: 'toggle-disabled' });
263
- return envelope;
256
+ return { skipped: [{ reason: 'toggle-disabled' }] };
264
257
  }
265
-
266
258
  if (!Number.isInteger(epicId) || epicId < 1) {
267
- envelope.errors.push(`${spec.fnName}: missing or invalid epicId`);
268
- return envelope;
259
+ return { errors: [`${spec.fnName}: missing or invalid epicId`] };
269
260
  }
270
261
  if (!provider || typeof provider.getTicketComments !== 'function') {
271
- envelope.errors.push(`${spec.fnName}: provider lacks getTicketComments`);
272
- return envelope;
262
+ return { errors: [`${spec.fnName}: provider lacks getTicketComments`] };
273
263
  }
274
264
  if (
275
265
  !currentRepo ||
276
266
  typeof currentRepo.owner !== 'string' ||
277
267
  typeof currentRepo.repo !== 'string'
278
268
  ) {
279
- envelope.errors.push(`${spec.fnName}: missing currentRepo {owner,repo}`);
280
- return envelope;
269
+ return { errors: [`${spec.fnName}: missing currentRepo {owner,repo}`] };
281
270
  }
271
+ return null;
272
+ }
282
273
 
283
- // 1. Read the structured comment off the Epic.
274
+ /**
275
+ * Read the source structured comment off the Epic and parse its findings.
276
+ * Returns `{ findings }` on success, or `{ skipped?, errors? }` for the
277
+ * no-comment / parse-empty / fetch-error short-circuits. Story #4075 —
278
+ * extracted from `graduate`.
279
+ */
280
+ async function loadGraduateFindings({ epicId, provider, spec }) {
284
281
  let comments;
285
282
  try {
286
283
  comments = await provider.getTicketComments(epicId);
287
284
  } catch (err) {
288
- envelope.errors.push(
289
- `getTicketComments failed for epic #${epicId}: ${err?.message ?? err}`,
290
- );
291
- return envelope;
292
- }
293
- if (!Array.isArray(comments) || comments.length === 0) {
294
- envelope.skipped.push({ reason: spec.noCommentReason });
295
- return envelope;
285
+ return {
286
+ errors: [
287
+ `getTicketComments failed for epic #${epicId}: ${err?.message ?? err}`,
288
+ ],
289
+ };
296
290
  }
297
- const matched = comments.filter(
291
+ const matched = (Array.isArray(comments) ? comments : []).filter(
298
292
  (c) => typeof c?.body === 'string' && c.body.includes(spec.commentMarker),
299
293
  );
300
294
  if (matched.length === 0) {
301
- envelope.skipped.push({ reason: spec.noCommentReason });
302
- return envelope;
295
+ return { skipped: [{ reason: spec.noCommentReason }] };
303
296
  }
304
- const sourceComment = matched[matched.length - 1];
305
-
306
- // 2. Parse findings.
307
- const findings = spec.parseFindings(sourceComment.body);
297
+ const findings = spec.parseFindings(matched[matched.length - 1].body);
308
298
  if (findings.length === 0) {
309
- envelope.skipped.push({ reason: 'no-non-blocking-findings' });
310
- return envelope;
299
+ return { skipped: [{ reason: 'no-non-blocking-findings' }] };
311
300
  }
301
+ return { findings };
302
+ }
312
303
 
313
- // 3. For each finding, route → idempotency probe → file.
314
- for (const finding of findings) {
315
- const exists = await probePathExists({
316
- ref: gitRef,
317
- path: finding.path,
318
- spawnImpl,
319
- cwd,
320
- });
321
- if (!exists) {
322
- envelope.skipped.push(
323
- decorate(
324
- {
325
- index: finding.index,
326
- reason: 'file-removed',
327
- path: finding.path,
328
- severity: finding.severity,
329
- },
330
- finding,
331
- ),
332
- );
333
- continue;
334
- }
304
+ /**
305
+ * Route a single finding (path-exists probe → repo routing → idempotency
306
+ * probe file) and fold the outcome into the running envelope. Story #4075
307
+ * — extracted from `graduate`'s per-finding loop body.
308
+ */
309
+ async function processGraduateFinding({
310
+ finding,
311
+ envelope,
312
+ decorate,
313
+ epicId,
314
+ currentRepo,
315
+ frameworkRepo,
316
+ classifier,
317
+ gitRef,
318
+ ghPath,
319
+ spawnImpl,
320
+ cwd,
321
+ logger,
322
+ spec,
323
+ }) {
324
+ const skip = (reason) =>
325
+ envelope.skipped.push(
326
+ decorate(
327
+ {
328
+ index: finding.index,
329
+ reason,
330
+ path: finding.path,
331
+ severity: finding.severity,
332
+ },
333
+ finding,
334
+ ),
335
+ );
335
336
 
336
- const source = classifier(finding.path, null);
337
- const routedRepo =
338
- source === 'framework' && frameworkRepo ? frameworkRepo : currentRepo;
337
+ const exists = await probePathExists({
338
+ ref: gitRef,
339
+ path: finding.path,
340
+ spawnImpl,
341
+ cwd,
342
+ });
343
+ if (!exists) return skip('file-removed');
339
344
 
340
- const isCrossRepo =
341
- routedRepo.owner !== currentRepo.owner ||
342
- routedRepo.repo !== currentRepo.repo;
343
- if (isCrossRepo) {
344
- logger?.info?.(spec.buildCrossRepoLog({ finding, routedRepo, source }));
345
- envelope.skipped.push(
346
- decorate(
347
- {
348
- index: finding.index,
349
- reason: 'cross-repo-deferred',
350
- path: finding.path,
351
- severity: finding.severity,
352
- },
353
- finding,
354
- ),
355
- );
356
- continue;
357
- }
345
+ const source = classifier(finding.path, null);
346
+ const routedRepo =
347
+ source === 'framework' && frameworkRepo ? frameworkRepo : currentRepo;
348
+ const isCrossRepo =
349
+ routedRepo.owner !== currentRepo.owner ||
350
+ routedRepo.repo !== currentRepo.repo;
351
+ if (isCrossRepo) {
352
+ logger?.info?.(spec.buildCrossRepoLog({ finding, routedRepo, source }));
353
+ return skip('cross-repo-deferred');
354
+ }
358
355
 
359
- const idMarker = spec.buildIdempotencyMarker(epicId, finding.index);
360
- const alreadyFiled = await probeMarkerExists({
361
- marker: idMarker,
362
- owner: routedRepo.owner,
363
- repo: routedRepo.repo,
364
- ghPath,
365
- spawnImpl,
366
- cwd,
367
- });
368
- if (alreadyFiled) {
369
- envelope.skipped.push(
370
- decorate(
371
- {
372
- index: finding.index,
373
- reason: 'already-filed',
374
- path: finding.path,
375
- severity: finding.severity,
376
- },
377
- finding,
378
- ),
379
- );
380
- continue;
381
- }
356
+ const idMarker = spec.buildIdempotencyMarker(epicId, finding.index);
357
+ const alreadyFiled = await probeMarkerExists({
358
+ marker: idMarker,
359
+ owner: routedRepo.owner,
360
+ repo: routedRepo.repo,
361
+ ghPath,
362
+ spawnImpl,
363
+ cwd,
364
+ });
365
+ if (alreadyFiled) return skip('already-filed');
366
+
367
+ const { title, body, labels } = spec.buildFollowUp({
368
+ finding,
369
+ source,
370
+ epicId,
371
+ idMarker,
372
+ });
373
+ const created = await createFollowUpIssue({
374
+ owner: routedRepo.owner,
375
+ repo: routedRepo.repo,
376
+ title,
377
+ body,
378
+ labels,
379
+ ghPath,
380
+ spawnImpl,
381
+ cwd,
382
+ });
383
+ if (created.error) {
384
+ envelope.errors.push(
385
+ `finding ${finding.index} (${finding.path}): ${created.error}`,
386
+ );
387
+ return;
388
+ }
389
+ envelope.filed.push(
390
+ decorate(
391
+ {
392
+ index: finding.index,
393
+ severity: finding.severity,
394
+ path: finding.path,
395
+ source,
396
+ repo: `${routedRepo.owner}/${routedRepo.repo}`,
397
+ url: created.url,
398
+ },
399
+ finding,
400
+ ),
401
+ );
402
+ }
403
+
404
+ export async function graduate({
405
+ epicId,
406
+ provider,
407
+ config,
408
+ currentRepo,
409
+ frameworkRepo,
410
+ gitRef = 'HEAD',
411
+ classifier = defaultClassifier,
412
+ ghPath = 'gh',
413
+ spawnImpl,
414
+ cwd,
415
+ logger,
416
+ spec,
417
+ }) {
418
+ const envelope = { filed: [], skipped: [], errors: [] };
419
+ const decorate =
420
+ typeof spec.decorateRecord === 'function'
421
+ ? spec.decorateRecord
422
+ : (record) => record;
382
423
 
383
- const { title, body, labels } = spec.buildFollowUp({
424
+ const precondition = checkGraduatePreconditions({
425
+ epicId,
426
+ provider,
427
+ currentRepo,
428
+ config,
429
+ spec,
430
+ });
431
+ if (precondition) return { ...envelope, ...precondition };
432
+
433
+ const loaded = await loadGraduateFindings({ epicId, provider, spec });
434
+ if (!loaded.findings) return { ...envelope, ...loaded };
435
+
436
+ for (const finding of loaded.findings) {
437
+ await processGraduateFinding({
384
438
  finding,
385
- source,
439
+ envelope,
440
+ decorate,
386
441
  epicId,
387
- idMarker,
388
- });
389
- const created = await createFollowUpIssue({
390
- owner: routedRepo.owner,
391
- repo: routedRepo.repo,
392
- title,
393
- body,
394
- labels,
442
+ currentRepo,
443
+ frameworkRepo,
444
+ classifier,
445
+ gitRef,
395
446
  ghPath,
396
447
  spawnImpl,
397
448
  cwd,
449
+ logger,
450
+ spec,
398
451
  });
399
- if (created.error) {
400
- envelope.errors.push(
401
- `finding ${finding.index} (${finding.path}): ${created.error}`,
402
- );
403
- continue;
404
- }
405
- envelope.filed.push(
406
- decorate(
407
- {
408
- index: finding.index,
409
- severity: finding.severity,
410
- path: finding.path,
411
- source,
412
- repo: `${routedRepo.owner}/${routedRepo.repo}`,
413
- url: created.url,
414
- },
415
- finding,
416
- ),
417
- );
418
452
  }
419
453
 
420
454
  return envelope;