mandrel 2.63.0 → 2.65.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.
- package/.agents/agents/acceptance-critic.md +3 -2
- package/.agents/agents/auditor.md +3 -2
- package/.agents/agents/plan-critic.md +3 -2
- package/.agents/agents/story-worker.md +2 -2
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +1 -9
- package/.agents/docs/configuration.md +8 -7
- package/.agents/schemas/agentrc.schema.json +6 -13
- package/.agents/schemas/audit-rules.schema.json +1 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +5 -0
- package/.agents/scripts/bootstrap.js +8 -2
- package/.agents/scripts/check-context-budget.js +1 -1
- package/.agents/scripts/lib/ITicketingProvider.js +1 -3
- package/.agents/scripts/lib/audit-suite/findings.js +1 -17
- package/.agents/scripts/lib/audit-suite/frontmatter.js +0 -28
- package/.agents/scripts/lib/audit-suite/index.js +0 -6
- package/.agents/scripts/lib/audit-suite/selector.js +0 -31
- package/.agents/scripts/lib/bootstrap/agents-md-fold.js +156 -0
- package/.agents/scripts/lib/bootstrap/commit-push.js +1 -1
- package/.agents/scripts/lib/bootstrap/manifest.js +2 -2
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +13 -29
- package/.agents/scripts/lib/config/review-chain-default.js +13 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +2 -2
- package/.agents/scripts/lib/config-settings-schema-quality.js +11 -13
- package/.agents/scripts/lib/doc-tiers.js +25 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/metrics-ledger.js +0 -72
- package/.agents/scripts/lib/orchestration/ci-red-handling.js +73 -0
- package/.agents/scripts/lib/orchestration/code-review.js +11 -6
- package/.agents/scripts/lib/orchestration/deliver-recover.js +56 -11
- package/.agents/scripts/lib/orchestration/epic-rollup.js +29 -12
- package/.agents/scripts/lib/orchestration/merge-block-class.js +20 -4
- package/.agents/scripts/lib/orchestration/merge-poll.js +41 -22
- package/.agents/scripts/lib/orchestration/required-checks.js +147 -0
- package/.agents/scripts/lib/orchestration/review-providers/code-review.js +203 -0
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +6 -4
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -2
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +1 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +0 -12
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +135 -20
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +112 -82
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +72 -5
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +12 -87
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +3 -0
- package/.agents/scripts/lib/templates/decomposer-prompts.js +5 -24
- package/.agents/scripts/pr-watch-with-update.js +13 -19
- package/.agents/scripts/providers/github/issues.js +14 -23
- package/.agents/scripts/sync-claude-agents.js +1 -1
- package/.agents/workflows/audit-quality.md +42 -7
- package/.agents/workflows/helpers/acceptance-self-eval.md +1 -1
- package/.agents/workflows/helpers/code-review.md +15 -38
- package/.agents/workflows/helpers/deliver-reference.md +4 -2
- package/.agents/workflows/helpers/deliver-story.md +3 -0
- package/.agents/workflows/helpers/plan-reference.md +9 -8
- package/.agents/workflows/mandrel-deliver.md +2 -1
- package/.agents/workflows/mandrel-plan.md +10 -7
- package/.agents/workflows/mandrel-update.md +5 -3
- package/docs/CHANGELOG.md +38 -0
- package/lib/cli/claude-code-version.js +73 -0
- package/lib/cli/doctor.js +2 -2
- package/lib/cli/registry.js +9 -0
- package/lib/cli/uninstall.js +37 -9
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.65.0-fold-claude-md-into-agents-md.js +38 -0
- package/package.json +2 -1
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +0 -99
- package/.agents/scripts/lib/audit-suite/runner.js +0 -205
- package/.agents/scripts/lib/audit-suite/substitutions.js +0 -96
- package/.agents/scripts/lib/audit-suite/workflow-loader.js +0 -37
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +0 -234
|
@@ -282,11 +282,61 @@ async function stepPlanRunLabelReap({
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
/**
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
|
|
289
|
-
|
|
285
|
+
* The local-checkout mutations (ref reap first, then fast-forward) under a
|
|
286
|
+
* cross-process lock on the main checkout, since concurrent closes race on
|
|
287
|
+
* the base ref and worktree registry. Best-effort: on timeout they run anyway.
|
|
288
|
+
*/
|
|
289
|
+
async function runLockedLocalSteps({
|
|
290
|
+
storyId,
|
|
291
|
+
storyBranch,
|
|
292
|
+
baseBranch,
|
|
293
|
+
cwd,
|
|
294
|
+
config,
|
|
295
|
+
progress,
|
|
296
|
+
gitSpawnFn,
|
|
297
|
+
planFastForwardFn,
|
|
298
|
+
executeFastForwardFn,
|
|
299
|
+
acquireLockWithWaitFn,
|
|
300
|
+
}) {
|
|
301
|
+
const lockCfg = config?.delivery?.postLandLock ?? {};
|
|
302
|
+
const lock = await acquireLockWithWaitFn({
|
|
303
|
+
lockPath: postLandLockPath(cwd),
|
|
304
|
+
waitMs: lockCfg.waitMs,
|
|
305
|
+
pollMs: lockCfg.pollMs,
|
|
306
|
+
timeoutMs: lockCfg.timeoutMs,
|
|
307
|
+
ownerId: `post-land-${storyId}`,
|
|
308
|
+
});
|
|
309
|
+
if (!lock.acquired) {
|
|
310
|
+
progress?.(
|
|
311
|
+
'POST-LAND',
|
|
312
|
+
`⚠️ post-land lock not acquired (${lock.reason}); proceeding unserialized.`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
const refCleanup = await step(
|
|
317
|
+
() => stepRefCleanup({ cwd, storyBranch, progress, gitSpawnFn }),
|
|
318
|
+
{ name: 'local ref cleanup', progress },
|
|
319
|
+
);
|
|
320
|
+
const baseFastForward = await step(
|
|
321
|
+
() =>
|
|
322
|
+
stepBaseFastForward({
|
|
323
|
+
cwd,
|
|
324
|
+
baseBranch,
|
|
325
|
+
progress,
|
|
326
|
+
planFastForwardFn,
|
|
327
|
+
executeFastForwardFn,
|
|
328
|
+
}),
|
|
329
|
+
{ name: 'base fast-forward', progress },
|
|
330
|
+
);
|
|
331
|
+
return { refCleanup, baseFastForward };
|
|
332
|
+
} finally {
|
|
333
|
+
if (lock.acquired) lock.release();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Run the post-land tail: friction markers, then the GitHub steps and the
|
|
339
|
+
* locked local steps concurrently, then temp purge, then lease release.
|
|
290
340
|
*
|
|
291
341
|
* @param {object} args
|
|
292
342
|
* @param {number} args.storyId
|
|
@@ -348,87 +398,67 @@ export async function runPostLandTail({
|
|
|
348
398
|
config,
|
|
349
399
|
});
|
|
350
400
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
provider,
|
|
356
|
-
config,
|
|
357
|
-
cwd,
|
|
358
|
-
progress,
|
|
359
|
-
captureStoryFollowUpsFn,
|
|
360
|
-
}),
|
|
361
|
-
{ name: 'follow-up capture', progress },
|
|
362
|
-
);
|
|
363
|
-
const statusResync = await step(
|
|
364
|
-
() =>
|
|
365
|
-
stepStatusResync({
|
|
366
|
-
storyId,
|
|
367
|
-
provider,
|
|
368
|
-
config,
|
|
369
|
-
progress,
|
|
370
|
-
reassertStatusColumnFn,
|
|
371
|
-
}),
|
|
372
|
-
{ name: 'status-column resync', progress },
|
|
373
|
-
);
|
|
374
|
-
await step(
|
|
375
|
-
() =>
|
|
376
|
-
stepPlanRunLabelReap({
|
|
377
|
-
storyId,
|
|
378
|
-
provider,
|
|
379
|
-
progress,
|
|
380
|
-
reapPlanRunLabelsForStoryFn,
|
|
381
|
-
}),
|
|
382
|
-
{ name: 'plan-run label reap', progress },
|
|
383
|
-
);
|
|
384
|
-
|
|
385
|
-
const epicRollup = await step(
|
|
386
|
-
() =>
|
|
387
|
-
stepEpicRollup({
|
|
388
|
-
storyId,
|
|
389
|
-
provider,
|
|
390
|
-
config,
|
|
391
|
-
progress,
|
|
392
|
-
rollUpEpicForStoryFn,
|
|
393
|
-
}),
|
|
394
|
-
{ name: 'epic rollup', progress },
|
|
395
|
-
);
|
|
396
|
-
|
|
397
|
-
const lockCfg = config?.delivery?.postLandLock ?? {};
|
|
398
|
-
const lock = await acquireLockWithWaitFn({
|
|
399
|
-
lockPath: postLandLockPath(cwd),
|
|
400
|
-
waitMs: lockCfg.waitMs,
|
|
401
|
-
pollMs: lockCfg.pollMs,
|
|
402
|
-
timeoutMs: lockCfg.timeoutMs,
|
|
403
|
-
ownerId: `post-land-${storyId}`,
|
|
404
|
-
});
|
|
405
|
-
if (!lock.acquired) {
|
|
406
|
-
progress?.(
|
|
407
|
-
'POST-LAND',
|
|
408
|
-
`⚠️ post-land lock not acquired (${lock.reason}); proceeding unserialized.`,
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
|
-
let refCleanup;
|
|
412
|
-
let baseFastForward;
|
|
413
|
-
try {
|
|
414
|
-
refCleanup = await step(
|
|
415
|
-
() => stepRefCleanup({ cwd, storyBranch, progress, gitSpawnFn }),
|
|
416
|
-
{ name: 'local ref cleanup', progress },
|
|
417
|
-
);
|
|
418
|
-
baseFastForward = await step(
|
|
401
|
+
// The GitHub steps are independent of each other and of the local
|
|
402
|
+
// lock-held mutations, so all of them run concurrently.
|
|
403
|
+
const [followUps, statusResync, , epicRollup, local] = await Promise.all([
|
|
404
|
+
step(
|
|
419
405
|
() =>
|
|
420
|
-
|
|
406
|
+
stepFollowUps({
|
|
407
|
+
storyId,
|
|
408
|
+
provider,
|
|
409
|
+
config,
|
|
421
410
|
cwd,
|
|
422
|
-
baseBranch,
|
|
423
411
|
progress,
|
|
424
|
-
|
|
425
|
-
executeFastForwardFn,
|
|
412
|
+
captureStoryFollowUpsFn,
|
|
426
413
|
}),
|
|
427
|
-
{ name: '
|
|
428
|
-
)
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
414
|
+
{ name: 'follow-up capture', progress },
|
|
415
|
+
),
|
|
416
|
+
step(
|
|
417
|
+
() =>
|
|
418
|
+
stepStatusResync({
|
|
419
|
+
storyId,
|
|
420
|
+
provider,
|
|
421
|
+
config,
|
|
422
|
+
progress,
|
|
423
|
+
reassertStatusColumnFn,
|
|
424
|
+
}),
|
|
425
|
+
{ name: 'status-column resync', progress },
|
|
426
|
+
),
|
|
427
|
+
step(
|
|
428
|
+
() =>
|
|
429
|
+
stepPlanRunLabelReap({
|
|
430
|
+
storyId,
|
|
431
|
+
provider,
|
|
432
|
+
progress,
|
|
433
|
+
reapPlanRunLabelsForStoryFn,
|
|
434
|
+
}),
|
|
435
|
+
{ name: 'plan-run label reap', progress },
|
|
436
|
+
),
|
|
437
|
+
step(
|
|
438
|
+
() =>
|
|
439
|
+
stepEpicRollup({
|
|
440
|
+
storyId,
|
|
441
|
+
provider,
|
|
442
|
+
config,
|
|
443
|
+
progress,
|
|
444
|
+
rollUpEpicForStoryFn,
|
|
445
|
+
}),
|
|
446
|
+
{ name: 'epic rollup', progress },
|
|
447
|
+
),
|
|
448
|
+
runLockedLocalSteps({
|
|
449
|
+
storyId,
|
|
450
|
+
storyBranch,
|
|
451
|
+
baseBranch,
|
|
452
|
+
cwd,
|
|
453
|
+
config,
|
|
454
|
+
progress,
|
|
455
|
+
gitSpawnFn,
|
|
456
|
+
planFastForwardFn,
|
|
457
|
+
executeFastForwardFn,
|
|
458
|
+
acquireLockWithWaitFn,
|
|
459
|
+
}),
|
|
460
|
+
]);
|
|
461
|
+
const { refCleanup, baseFastForward } = local;
|
|
432
462
|
|
|
433
463
|
// After every step that reads the temp artifacts; `signals.ndjson` survives.
|
|
434
464
|
const tempPurge = await step(
|
|
@@ -33,6 +33,7 @@ import { runConfirmMergePhase } from './phases/confirm-merge.js';
|
|
|
33
33
|
import { runGraphqlPreflight } from './phases/graphql-preflight.js';
|
|
34
34
|
import { lockWaitPending } from './phases/lock-wait-pending.js';
|
|
35
35
|
import { parseCloseOptions, resolveWaitForMerge } from './phases/options.js';
|
|
36
|
+
import { runPostLandTail } from './phases/post-land.js';
|
|
36
37
|
import { ensurePullRequestWith } from './phases/pull-request.js';
|
|
37
38
|
import { pushStoryBranch } from './phases/push.js';
|
|
38
39
|
import { handleCriticalReviewBlock } from './phases/review-block.js';
|
|
@@ -42,12 +43,54 @@ import { runWrongTreeGuardPhase } from './phases/wrong-tree-guard.js';
|
|
|
42
43
|
|
|
43
44
|
const progress = Logger.createProgress('single-story-close', { stderr: true });
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Wall-clock seconds per named phase; each transition logs the phase it ends.
|
|
48
|
+
*
|
|
49
|
+
* @param {() => number} [nowMs]
|
|
50
|
+
*/
|
|
51
|
+
function createPhaseTimer(nowMs = Date.now) {
|
|
52
|
+
const durations = {};
|
|
53
|
+
let current = null;
|
|
54
|
+
let since = 0;
|
|
55
|
+
const end = () => {
|
|
56
|
+
if (current === null) return;
|
|
57
|
+
const seconds = Math.round((nowMs() - since) / 100) / 10;
|
|
58
|
+
durations[current] = (durations[current] ?? 0) + seconds;
|
|
59
|
+
progress('TIMING', `⏱ ${current}: ${seconds}s`);
|
|
60
|
+
current = null;
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
enter(phase) {
|
|
64
|
+
end();
|
|
65
|
+
if (phase === 'init') return;
|
|
66
|
+
current = phase;
|
|
67
|
+
since = nowMs();
|
|
68
|
+
},
|
|
69
|
+
finish() {
|
|
70
|
+
end();
|
|
71
|
+
return Object.keys(durations).length > 0 ? { ...durations } : null;
|
|
72
|
+
},
|
|
73
|
+
stamp(terminal) {
|
|
74
|
+
const phaseDurations = this.finish();
|
|
75
|
+
if (phaseDurations) terminal.phaseDurations = phaseDurations;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const UNTIMED = Object.freeze({ stamp() {} });
|
|
81
|
+
|
|
45
82
|
/**
|
|
46
83
|
* The single terminal writer: the result summary, the envelope callers parse,
|
|
47
84
|
* and terminal friction — so no ending can forget one. Must be awaited: the
|
|
48
85
|
* CLI `process.exit`s as soon as `main` resolves.
|
|
49
86
|
*/
|
|
50
|
-
async function emitTerminal({
|
|
87
|
+
async function emitTerminal({
|
|
88
|
+
terminal,
|
|
89
|
+
result,
|
|
90
|
+
config,
|
|
91
|
+
phaseTimer = UNTIMED,
|
|
92
|
+
}) {
|
|
93
|
+
phaseTimer.stamp(terminal);
|
|
51
94
|
if (result) {
|
|
52
95
|
emitTerseResult({
|
|
53
96
|
label: 'STORY CLOSE RESULT',
|
|
@@ -516,8 +559,10 @@ export async function runSingleStoryClose({
|
|
|
516
559
|
// builds the `failed` envelope from those tags.
|
|
517
560
|
let phase = 'init';
|
|
518
561
|
let observedGates = null;
|
|
562
|
+
const phaseTimer = createPhaseTimer();
|
|
519
563
|
const setPhase = (next) => {
|
|
520
564
|
phase = next;
|
|
565
|
+
phaseTimer.enter(next);
|
|
521
566
|
};
|
|
522
567
|
const setObservedGates = (gates) => {
|
|
523
568
|
observedGates = gates;
|
|
@@ -527,6 +572,7 @@ export async function runSingleStoryClose({
|
|
|
527
572
|
options,
|
|
528
573
|
setPhase,
|
|
529
574
|
setObservedGates,
|
|
575
|
+
phaseTimer,
|
|
530
576
|
injectedProvider,
|
|
531
577
|
injectedConfig,
|
|
532
578
|
injectedNotify,
|
|
@@ -539,6 +585,7 @@ export async function runSingleStoryClose({
|
|
|
539
585
|
});
|
|
540
586
|
} catch (err) {
|
|
541
587
|
if (err && typeof err === 'object') {
|
|
588
|
+
err.closePhaseDurations = phaseTimer.finish();
|
|
542
589
|
if (!err.closePhase) err.closePhase = phase;
|
|
543
590
|
if (!err.closeGates && observedGates) err.closeGates = observedGates;
|
|
544
591
|
}
|
|
@@ -617,6 +664,10 @@ async function finishWithMergeWait(prCtx, deps) {
|
|
|
617
664
|
progress,
|
|
618
665
|
injectedGh: deps.injectedGh,
|
|
619
666
|
injectedNotify: deps.injectedNotify,
|
|
667
|
+
runPostLandTailFn: (args) => {
|
|
668
|
+
deps.setPhase('post-land');
|
|
669
|
+
return runPostLandTail(args);
|
|
670
|
+
},
|
|
620
671
|
});
|
|
621
672
|
const terminal = terminalFromWaitOutcome({
|
|
622
673
|
waitOutcome,
|
|
@@ -650,7 +701,12 @@ async function finishWithMergeWait(prCtx, deps) {
|
|
|
650
701
|
}),
|
|
651
702
|
landCompleted: waitOutcome.confirmed === true,
|
|
652
703
|
});
|
|
653
|
-
await emitTerminal({
|
|
704
|
+
await emitTerminal({
|
|
705
|
+
terminal,
|
|
706
|
+
result,
|
|
707
|
+
config: prCtx.config,
|
|
708
|
+
phaseTimer: prCtx.phaseTimer,
|
|
709
|
+
});
|
|
654
710
|
reportWaitTerminal(terminal, { storyId: prCtx.storyId, prUrl: prCtx.prUrl });
|
|
655
711
|
return { success: terminal.status === 'landed', result, terminal };
|
|
656
712
|
}
|
|
@@ -698,7 +754,12 @@ async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
|
|
|
698
754
|
nextCommand: NEXT_COMMANDS.confirmMerge(prCtx.storyId),
|
|
699
755
|
elapsedSeconds: elapsedSecondsSince(prCtx.startedAtMs),
|
|
700
756
|
});
|
|
701
|
-
await emitTerminal({
|
|
757
|
+
await emitTerminal({
|
|
758
|
+
terminal,
|
|
759
|
+
result,
|
|
760
|
+
config: prCtx.config,
|
|
761
|
+
phaseTimer: prCtx.phaseTimer,
|
|
762
|
+
});
|
|
702
763
|
progress(
|
|
703
764
|
'DONE',
|
|
704
765
|
`✅ Story #${prCtx.storyId}: PR ready → ${prCtx.prUrl} (${waitForMergeReason})`,
|
|
@@ -714,13 +775,16 @@ async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
|
|
|
714
775
|
* config: object, startedAtMs: number }} ctx
|
|
715
776
|
* @returns {Promise<{ success: false, result: object, terminal: object }>}
|
|
716
777
|
*/
|
|
717
|
-
async function finishDeferred(
|
|
778
|
+
async function finishDeferred(
|
|
779
|
+
lockWait,
|
|
780
|
+
{ config, startedAtMs, phaseTimer, ...ids },
|
|
781
|
+
) {
|
|
718
782
|
const { result, terminal, note } = lockWaitPending({
|
|
719
783
|
...ids,
|
|
720
784
|
lockWait,
|
|
721
785
|
elapsedSeconds: elapsedSecondsSince(startedAtMs),
|
|
722
786
|
});
|
|
723
|
-
await emitTerminal({ terminal, result, config });
|
|
787
|
+
await emitTerminal({ terminal, result, config, phaseTimer });
|
|
724
788
|
progress('PENDING', note);
|
|
725
789
|
return { success: false, result, terminal };
|
|
726
790
|
}
|
|
@@ -759,6 +823,7 @@ async function runClosePipeline({
|
|
|
759
823
|
options,
|
|
760
824
|
setPhase,
|
|
761
825
|
setObservedGates,
|
|
826
|
+
phaseTimer,
|
|
762
827
|
injectedProvider,
|
|
763
828
|
injectedConfig,
|
|
764
829
|
injectedNotify,
|
|
@@ -848,6 +913,7 @@ async function runClosePipeline({
|
|
|
848
913
|
baseBranch,
|
|
849
914
|
config,
|
|
850
915
|
startedAtMs,
|
|
916
|
+
phaseTimer,
|
|
851
917
|
});
|
|
852
918
|
}
|
|
853
919
|
|
|
@@ -943,6 +1009,7 @@ async function runClosePipeline({
|
|
|
943
1009
|
directMerged,
|
|
944
1010
|
config,
|
|
945
1011
|
startedAtMs,
|
|
1012
|
+
phaseTimer,
|
|
946
1013
|
lockWait: prePush.lockWait,
|
|
947
1014
|
gates: closeEnvelopeGates(options, prePush.validationGates, reviewOverride),
|
|
948
1015
|
};
|
|
@@ -3,23 +3,20 @@
|
|
|
3
3
|
* maker-blind `runCodeReview` invocation out of any phase file.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { countChangedLines } from '../../../audit-suite/index.js';
|
|
7
6
|
import { gitSpawn } from '../../../git-utils.js';
|
|
8
|
-
import { appendFindingsYield } from '../../../observability/metrics-ledger.js';
|
|
9
7
|
import { computeChangeSet } from '../../change-set.js';
|
|
10
8
|
import { runCodeReview } from '../../code-review.js';
|
|
11
|
-
import { runLocalLensReview } from './local-lens-review.js';
|
|
12
9
|
|
|
13
10
|
/**
|
|
14
|
-
* Run
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
11
|
+
* Run `runCodeReview` over one change set and return the review result.
|
|
12
|
+
* Throws propagate; the caller picks the advisory posture. Review depth is
|
|
13
|
+
* derived by `runCodeReview` from the changed files and is input-only — it
|
|
14
|
+
* never alters the output envelope.
|
|
18
15
|
*
|
|
19
|
-
* The diff is enumerated exactly once here and injected into
|
|
20
|
-
*
|
|
21
|
-
* lands in between. An unenumerable diff injects `null` ("already
|
|
22
|
-
* which
|
|
16
|
+
* The diff is enumerated exactly once here and injected into the review, so
|
|
17
|
+
* review depth scores the same file set the change set names even if a
|
|
18
|
+
* commit lands in between. An unenumerable diff injects `null` ("already
|
|
19
|
+
* tried"), which the review honours without re-spawning git.
|
|
23
20
|
*
|
|
24
21
|
* @param {{
|
|
25
22
|
* storyId: number|string,
|
|
@@ -32,12 +29,9 @@ import { runLocalLensReview } from './local-lens-review.js';
|
|
|
32
29
|
* gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
|
|
33
30
|
* computeChangeSetFn?: typeof computeChangeSet,
|
|
34
31
|
* runCodeReviewFn?: typeof runCodeReview,
|
|
35
|
-
* runLocalLensReviewFn?: typeof runLocalLensReview,
|
|
36
|
-
* countChangedLinesFn?: typeof countChangedLines,
|
|
37
|
-
* appendFindingsYieldFn?: typeof appendFindingsYield,
|
|
38
32
|
* }} args
|
|
39
|
-
* @returns {Promise<object>} The `runCodeReview` result plus
|
|
40
|
-
* `
|
|
33
|
+
* @returns {Promise<object>} The `runCodeReview` result plus the computed
|
|
34
|
+
* `changeSet`.
|
|
41
35
|
*/
|
|
42
36
|
export async function runStoryReviewCore({
|
|
43
37
|
storyId,
|
|
@@ -50,24 +44,12 @@ export async function runStoryReviewCore({
|
|
|
50
44
|
gitSpawnFn = gitSpawn,
|
|
51
45
|
computeChangeSetFn = computeChangeSet,
|
|
52
46
|
runCodeReviewFn = runCodeReview,
|
|
53
|
-
runLocalLensReviewFn = runLocalLensReview,
|
|
54
|
-
countChangedLinesFn = countChangedLines,
|
|
55
|
-
appendFindingsYieldFn = appendFindingsYield,
|
|
56
47
|
}) {
|
|
57
|
-
const storyIdNum = Number(storyId);
|
|
58
|
-
|
|
59
48
|
const changeSet = computeChangeSetFn({ baseRef, headRef, gitSpawnFn });
|
|
60
49
|
|
|
61
|
-
// Line count for the lens diff-floor, probed only for a non-empty file set;
|
|
62
|
-
// `null` = unknown, and the floor fails open.
|
|
63
|
-
const changedLineCount =
|
|
64
|
-
Array.isArray(changeSet.files) && changeSet.files.length > 0
|
|
65
|
-
? countChangedLinesFn({ baseRef, headRef, gitSpawnFn })
|
|
66
|
-
: null;
|
|
67
|
-
|
|
68
50
|
const opts = {
|
|
69
51
|
scope: 'story',
|
|
70
|
-
ticketId:
|
|
52
|
+
ticketId: Number(storyId),
|
|
71
53
|
baseRef,
|
|
72
54
|
headRef,
|
|
73
55
|
provider,
|
|
@@ -82,63 +64,6 @@ export async function runStoryReviewCore({
|
|
|
82
64
|
opts.commentTargetId = commentTargetId;
|
|
83
65
|
}
|
|
84
66
|
|
|
85
|
-
const localLensReview = await runLocalLensReviewFn({
|
|
86
|
-
baseRef,
|
|
87
|
-
headRef,
|
|
88
|
-
changedFiles: changeSet.files,
|
|
89
|
-
changedLineCount,
|
|
90
|
-
storyId: storyIdNum,
|
|
91
|
-
progress,
|
|
92
|
-
progressTag,
|
|
93
|
-
gitSpawnFn,
|
|
94
|
-
});
|
|
95
|
-
|
|
96
67
|
const result = await runCodeReviewFn(opts);
|
|
97
|
-
|
|
98
|
-
// Best-effort findings-yield ledger, for tuning the roster on measurement.
|
|
99
|
-
try {
|
|
100
|
-
const yieldEntries = buildLensYieldEntries(localLensReview);
|
|
101
|
-
if (yieldEntries !== null) {
|
|
102
|
-
await appendFindingsYieldFn({
|
|
103
|
-
storyId: storyIdNum,
|
|
104
|
-
cli: 'story-close-review',
|
|
105
|
-
lenses: yieldEntries,
|
|
106
|
-
diffFloor: localLensReview?.floorSkip ?? null,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
} catch (err) {
|
|
110
|
-
progress(
|
|
111
|
-
progressTag,
|
|
112
|
-
`⚠️ findings-yield ledger append failed (continuing): ${err?.message ?? err}`,
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return { ...result, localLensReview, changeSet };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* One findings-yield entry per matched lens; `null` for an empty roster.
|
|
121
|
-
*
|
|
122
|
-
* @param {object|null|undefined} localLensReview
|
|
123
|
-
* @returns {Array<{ lens: string, findings: number, skippedByFloor: boolean }>|null}
|
|
124
|
-
*/
|
|
125
|
-
function buildLensYieldEntries(localLensReview) {
|
|
126
|
-
const lenses = Array.isArray(localLensReview?.lenses)
|
|
127
|
-
? localLensReview.lenses.filter((l) => typeof l === 'string' && l.length)
|
|
128
|
-
: [];
|
|
129
|
-
if (lenses.length === 0) return null;
|
|
130
|
-
const skippedByFloor = localLensReview?.floorSkip?.skip === true;
|
|
131
|
-
const findingsByLens = new Map();
|
|
132
|
-
for (const finding of localLensReview?.materialized?.findings ?? []) {
|
|
133
|
-
if (typeof finding?.audit !== 'string') continue;
|
|
134
|
-
findingsByLens.set(
|
|
135
|
-
finding.audit,
|
|
136
|
-
(findingsByLens.get(finding.audit) ?? 0) + 1,
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
return lenses.map((lens) => ({
|
|
140
|
-
lens,
|
|
141
|
-
findings: skippedByFloor ? 0 : (findingsByLens.get(lens) ?? 0),
|
|
142
|
-
skippedByFloor,
|
|
143
|
-
}));
|
|
68
|
+
return { ...result, changeSet };
|
|
144
69
|
}
|
|
@@ -116,6 +116,7 @@ function compact(obj) {
|
|
|
116
116
|
* @param {object|null} [args.waitBudget]
|
|
117
117
|
* @param {{ waitedSeconds: number, expired: boolean }|null} [args.lockWait]
|
|
118
118
|
* Full-suite lock wait; `waitBudget` is merge-wait only.
|
|
119
|
+
* @param {Record<string, number>|null} [args.phaseDurations] Seconds per phase.
|
|
119
120
|
* @param {string} [args.timestamp]
|
|
120
121
|
* @param {{ schema: object|null, error: string|null }} [args.schemaSource]
|
|
121
122
|
* Test seam.
|
|
@@ -137,6 +138,7 @@ export function buildTerminalEnvelope({
|
|
|
137
138
|
elapsedSeconds = 0,
|
|
138
139
|
waitBudget,
|
|
139
140
|
lockWait,
|
|
141
|
+
phaseDurations,
|
|
140
142
|
timestamp = new Date().toISOString(),
|
|
141
143
|
schemaSource,
|
|
142
144
|
}) {
|
|
@@ -158,6 +160,7 @@ export function buildTerminalEnvelope({
|
|
|
158
160
|
elapsedSeconds: Math.max(0, Number(elapsedSeconds) || 0),
|
|
159
161
|
waitBudget: waitBudget ?? null,
|
|
160
162
|
lockWait: lockWait ?? null,
|
|
163
|
+
phaseDurations,
|
|
161
164
|
timestamp,
|
|
162
165
|
});
|
|
163
166
|
|
|
@@ -31,13 +31,11 @@ export function renderStoryAuthorCore() {
|
|
|
31
31
|
(lint) =>
|
|
32
32
|
`- **${lint.id}** — ${lint.summary} Example: \`${lint.goodExample}\``,
|
|
33
33
|
).join('\n');
|
|
34
|
-
return `
|
|
35
|
-
Your job is to turn a plan seed / Tech Spec into a Story ticket array for an AI Agent to execute.
|
|
34
|
+
return `Turn a plan seed / Tech Spec into Story tickets for an AI agent to execute. The emitted stories template (see STORY BODY SCHEMA) is the ticket shape.
|
|
36
35
|
|
|
37
36
|
### HIERARCHY RULES (v2 default-single):
|
|
38
37
|
1. **Emit exactly one Story by default.** Split into N>1 only when pieces have near-zero overlap or sit across an architectural seam. Coupled work stays one Story — put intra-session checkpoints in \`## Slicing\` and fold the Tech Spec into \`## Spec\`.
|
|
39
38
|
2. **Stories**: Specific user-facing or architectural capabilities (e.g., "Implement JWT Token Exchange").
|
|
40
|
-
- There is NO Epic parent ticket, NO Feature tier, and NO Task layer.
|
|
41
39
|
- **Story-Level Execution**: Each Story is executed end-to-end on a single branch by a single agent. Acceptance criteria and verification commands live as top-level \`acceptance[]\` / \`verify[]\` arrays on the Story ticket (see STORY BODY SCHEMA below).
|
|
42
40
|
- Thematic grouping is prose in the Story's folded \`## Spec\` / \`## Slicing\`, never sibling tickets for coupled work.
|
|
43
41
|
|
|
@@ -46,27 +44,10 @@ Your job is to turn a plan seed / Tech Spec into a Story ticket array for an AI
|
|
|
46
44
|
- \`labels[]\` is **optional**. Emit it only to request an *additional* label; persist sanitizes the list before applying it.
|
|
47
45
|
- Do **not** emit \`agent::*\` labels — lifecycle state is runtime-owned, and persist applies \`agent::ready\` itself once every checkpoint is on the ticket.
|
|
48
46
|
|
|
49
|
-
### OUTPUT FORMAT:
|
|
50
|
-
You MUST respond ONLY with a valid JSON array of objects. No prose, no markdown blocks.
|
|
51
|
-
|
|
52
|
-
### JSON SCHEMA:
|
|
53
|
-
[
|
|
54
|
-
{
|
|
55
|
-
"slug": "hyphen-case-id",
|
|
56
|
-
"type": "story",
|
|
57
|
-
"title": "Short descriptive title",
|
|
58
|
-
"body": <string — see STORY BODY SCHEMA below>,
|
|
59
|
-
"acceptance": ["<outcome a PR reviewer can confirm>", ...],
|
|
60
|
-
"verify": ["<exact command or test path>", ...],
|
|
61
|
-
"labels": ["<extra-label>"] (optional — type::story is applied automatically; omit this field unless you need an additional label),
|
|
62
|
-
"depends_on": ["slug-of-blocking-dependency"] (optional array of Story slugs that block execution)
|
|
63
|
-
}
|
|
64
|
-
]
|
|
65
|
-
|
|
66
47
|
**Slug format**: \`^[a-z0-9][a-z0-9-]*$\` — hyphen-case only. Underscores are rejected by the validator.
|
|
67
48
|
|
|
68
49
|
### STORY BODY SCHEMA (REQUIRED FOR EVERY STORY):
|
|
69
|
-
\`body\` is either the serialized markdown **string** (the section format below) or a **structured object** carrying the same fields (\`goal\`, optional \`slicing\` / \`spec\`, \`changes\`, optional \`non_goals\`) — persist parses either shape and serializes the canonical markdown itself, so you never need to read \`story-body.js\` or hand-assemble the markdown (the \`stories.template.json\` file emitted next to the plan-context envelope is a ready-to-fill structured-object skeleton).
|
|
50
|
+
\`body\` is either the serialized markdown **string** (the section format below) or a **structured object** carrying the same fields (\`goal\`, optional \`slicing\` / \`spec\`, \`changes\`, optional \`non_goals\`) — persist parses either shape and serializes the canonical markdown itself, so you never need to read \`story-body.js\` or hand-assemble the markdown (the \`stories.template.json\` file emitted next to the plan-context envelope is a ready-to-fill structured-object skeleton). The executing sub-agent is non-interactive and self-verifies from the ticket alone, so the ticket carries everything it needs.
|
|
70
51
|
|
|
71
52
|
The \`acceptance[]\` and \`verify[]\` arrays live at the **top level** of the Story ticket object — that is the machine contract the validator reads. Author each list **once, at top level**, and **omit** the \`## Acceptance\` / \`## Verify\` sections from the authored \`body\` string: persist syncs the top-level arrays into those sections so the GitHub issue stays a complete executable document. The validator resolves both fields from the top level, so an omitted section is the expected shape, not a violation.
|
|
72
53
|
|
|
@@ -153,9 +134,9 @@ ${envelopeFloor}
|
|
|
153
134
|
- A Story touching UI (\`*.tsx\`, \`*.astro\`, \`*.svelte\`, \`*.vue\`, a components folder) states the \`data-testid\` contract in \`acceptance[]\` per the testid contract in \`.agents/skills/stack/qa/playwright/SKILL.md\`.
|
|
154
135
|
- A Story touching user-visible copy, brand assets or visual style cites the relevant section of \`docs/style-guide.md\` in \`acceptance[]\` when that file exists.
|
|
155
136
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
137
|
+
#### ORDERING:
|
|
138
|
+
|
|
139
|
+
Express execution ordering between Stories with \`depends_on\` — the slugs of the Stories that must land first. Never emit a parent field.`;
|
|
159
140
|
}
|
|
160
141
|
|
|
161
142
|
/**
|
|
@@ -20,6 +20,7 @@ import { runAsCli } from './lib/cli-utils.js';
|
|
|
20
20
|
import { resolveConfig } from './lib/config-resolver.js';
|
|
21
21
|
import { gh as defaultGh } from './lib/gh-exec.js';
|
|
22
22
|
import { Logger } from './lib/Logger.js';
|
|
23
|
+
import { recordRequiredRed } from './lib/orchestration/ci-red-handling.js';
|
|
23
24
|
import {
|
|
24
25
|
blockStoryDelivery,
|
|
25
26
|
classifyFailure,
|
|
@@ -229,25 +230,18 @@ async function handleRedWatch({
|
|
|
229
230
|
blockFn,
|
|
230
231
|
logger,
|
|
231
232
|
}) {
|
|
232
|
-
const disarm = await
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
prRef,
|
|
245
|
-
});
|
|
246
|
-
} catch (err) {
|
|
247
|
-
logger.warn?.(
|
|
248
|
-
`[pr-watch] failed to write CI digest (non-fatal): ${err?.message ?? err}`,
|
|
249
|
-
);
|
|
250
|
-
}
|
|
233
|
+
const { headSha, disarm, digestPaths } = await recordRequiredRed({
|
|
234
|
+
storyId,
|
|
235
|
+
prNumber,
|
|
236
|
+
prRef,
|
|
237
|
+
failures,
|
|
238
|
+
tempRoot,
|
|
239
|
+
cwd,
|
|
240
|
+
disarmFn,
|
|
241
|
+
headShaFn,
|
|
242
|
+
writeDigestFn,
|
|
243
|
+
logger,
|
|
244
|
+
});
|
|
251
245
|
let blocked = false;
|
|
252
246
|
if (!disarm.disarmed) {
|
|
253
247
|
logger.error?.(
|