mandrel 2.53.0 → 2.55.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/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +4 -2
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +15 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +158 -7
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +19 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/findings/route-finding.js +4 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +40 -16
- package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +8 -1
- package/.agents/workflows/mandrel-plan.md +4 -7
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +34 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- package/package.json +7 -4
|
@@ -15,6 +15,7 @@ import { createProvider } from '../../provider-factory.js';
|
|
|
15
15
|
import { flipLabelAndNotify } from '../../single-story/story-merged-notify.js';
|
|
16
16
|
import { WorktreeManager } from '../../worktree-manager.js';
|
|
17
17
|
import { runCodeReview as runCodeReviewDefault } from '../code-review.js';
|
|
18
|
+
import { MERGED_FLIP_FAILED_BLOCK_CLASS } from '../lifecycle/emit-merge-flip-failed.js';
|
|
18
19
|
import { resolveRunScopedConfig } from '../run-scoped-config.js';
|
|
19
20
|
import { releaseStoryLease } from '../single-story-lease-guard.js';
|
|
20
21
|
import {
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
NEXT_COMMANDS,
|
|
24
25
|
terminalFromWaitOutcome,
|
|
25
26
|
} from '../story-deliver-terminal.js';
|
|
27
|
+
import { deriveCloseNote } from './close-note.js';
|
|
26
28
|
import { runAutoMergePhase } from './phases/auto-merge.js';
|
|
27
29
|
import { runBaseSyncPhase } from './phases/base-sync.js';
|
|
28
30
|
import { runCloseValidationPhase } from './phases/close-validation.js';
|
|
@@ -203,6 +205,7 @@ async function runPrePushPhases({
|
|
|
203
205
|
injectedSync,
|
|
204
206
|
injectedGitSpawn,
|
|
205
207
|
setPhase = () => {},
|
|
208
|
+
setObservedGates = () => {},
|
|
206
209
|
}) {
|
|
207
210
|
setPhase('wrong-tree-guard');
|
|
208
211
|
await runWrongTreeGuardPhase({
|
|
@@ -235,18 +238,34 @@ async function runPrePushPhases({
|
|
|
235
238
|
return { validationGates: null };
|
|
236
239
|
}
|
|
237
240
|
setPhase('close-validation');
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
241
|
+
let validation;
|
|
242
|
+
try {
|
|
243
|
+
validation = await runCloseValidationPhase({
|
|
244
|
+
cwd,
|
|
245
|
+
worktreePath,
|
|
246
|
+
config,
|
|
247
|
+
baseBranch,
|
|
248
|
+
storyBranch,
|
|
249
|
+
storyId,
|
|
250
|
+
progress,
|
|
251
|
+
runCloseValidation,
|
|
252
|
+
buildDefaultGates,
|
|
253
|
+
});
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// Story #5279 — the failed terminal REPORTS gates rather than
|
|
256
|
+
// reconstructing them, so hand it what this run observed. The phase tags
|
|
257
|
+
// `closeGate` with the entry that died; that one name is the whole of
|
|
258
|
+
// what the run observed about gate outcomes, and therefore the whole of
|
|
259
|
+
// what the envelope may claim. Anything else it might have named is a
|
|
260
|
+
// gate the run never proved ran at all.
|
|
261
|
+
if (typeof err?.closeGate === 'string') {
|
|
262
|
+
setObservedGates({ [err.closeGate]: 'failed' });
|
|
263
|
+
}
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
const gates = validation?.gates ?? null;
|
|
267
|
+
setObservedGates(gates);
|
|
268
|
+
return { validationGates: gates };
|
|
250
269
|
}
|
|
251
270
|
|
|
252
271
|
async function openAndReviewPr({
|
|
@@ -402,6 +421,38 @@ async function releaseLeaseOnBlock(run, leaseArgs) {
|
|
|
402
421
|
}
|
|
403
422
|
}
|
|
404
423
|
|
|
424
|
+
/**
|
|
425
|
+
* Did this run OBSERVE the PR merge? (Story #5279)
|
|
426
|
+
*
|
|
427
|
+
* `merged` used to be whatever the caller happened to pass, and the two
|
|
428
|
+
* finishers passed different halves of the truth. Three observations say a
|
|
429
|
+
* merge happened, and each of them reported `merged: false` on at least one
|
|
430
|
+
* path:
|
|
431
|
+
*
|
|
432
|
+
* - the confirm phase watched it land (`waitOutcome.confirmed`);
|
|
433
|
+
* - it landed and only the `agent::done` label write failed
|
|
434
|
+
* (`merged-flip-failed`) — the terminal envelope for that same run
|
|
435
|
+
* already reports `pr.state: MERGED` from the probe, so a result denying
|
|
436
|
+
* the merge made the two halves of one run contradict each other;
|
|
437
|
+
* - the arm phase direct-squash-merged it synchronously because the
|
|
438
|
+
* repository has no native auto-merge (`directMerged`), which the
|
|
439
|
+
* no-wait finisher never consulted at all.
|
|
440
|
+
*
|
|
441
|
+
* Deliberately observation-only: it reads what the run saw, never what the
|
|
442
|
+
* run intended. A `null`/absent `waitOutcome` is the no-wait finisher, whose
|
|
443
|
+
* sole observation is the direct merge.
|
|
444
|
+
*
|
|
445
|
+
* @param {{ waitOutcome?: object|null, directMerged?: boolean }} args
|
|
446
|
+
* @returns {boolean}
|
|
447
|
+
*/
|
|
448
|
+
function deriveObservedMerge({ waitOutcome = null, directMerged = false }) {
|
|
449
|
+
return (
|
|
450
|
+
waitOutcome?.confirmed === true ||
|
|
451
|
+
waitOutcome?.blockClass === MERGED_FLIP_FAILED_BLOCK_CLASS ||
|
|
452
|
+
directMerged === true
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
405
456
|
function closeResult({
|
|
406
457
|
storyId,
|
|
407
458
|
storyBranch,
|
|
@@ -416,6 +467,7 @@ function closeResult({
|
|
|
416
467
|
directMerged = false,
|
|
417
468
|
waitedForMerge = false,
|
|
418
469
|
merged = false,
|
|
470
|
+
landCompleted = merged,
|
|
419
471
|
}) {
|
|
420
472
|
return {
|
|
421
473
|
storyId,
|
|
@@ -440,11 +492,20 @@ function closeResult({
|
|
|
440
492
|
directMerged,
|
|
441
493
|
waitedForMerge,
|
|
442
494
|
merged,
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
495
|
+
// Story #5266 — derived from `merged` / `directMerged` / `autoMergeEnabled`,
|
|
496
|
+
// never from `waitedForMerge`. A wait that expired unmerged used to write
|
|
497
|
+
// "PR merge confirmed" beside `merged: false`, and this log is what the
|
|
498
|
+
// operator reads first. See close-note.js for the invariant.
|
|
499
|
+
// Story #5279 — `landCompleted` keeps that invariant intact now that
|
|
500
|
+
// `merged: true` no longer implies the flip and the tail ran: a direct
|
|
501
|
+
// merge under `--no-wait-merge`, and a `merged-flip-failed` block, are
|
|
502
|
+
// both merged WITHOUT a completed land.
|
|
503
|
+
note: deriveCloseNote({
|
|
504
|
+
merged,
|
|
505
|
+
directMerged,
|
|
506
|
+
autoMergeEnabled,
|
|
507
|
+
landCompleted,
|
|
508
|
+
}),
|
|
448
509
|
};
|
|
449
510
|
}
|
|
450
511
|
|
|
@@ -458,6 +519,7 @@ export async function runSingleStoryClose({
|
|
|
458
519
|
noWaitForMerge: noWaitForMergeParam,
|
|
459
520
|
maxWaitSeconds: maxWaitSecondsParam,
|
|
460
521
|
mergeWatchMode: mergeWatchModeParam,
|
|
522
|
+
rerunAdvisory: rerunAdvisoryParam,
|
|
461
523
|
overrideReviewBlock: overrideReviewBlockParam,
|
|
462
524
|
injectedProvider,
|
|
463
525
|
injectedConfig,
|
|
@@ -478,11 +540,12 @@ export async function runSingleStoryClose({
|
|
|
478
540
|
noWaitForMergeParam,
|
|
479
541
|
maxWaitSecondsParam,
|
|
480
542
|
mergeWatchModeParam,
|
|
543
|
+
rerunAdvisoryParam,
|
|
481
544
|
overrideReviewBlockParam,
|
|
482
545
|
});
|
|
483
546
|
if (!options.storyId) {
|
|
484
547
|
throw new Error(
|
|
485
|
-
'Usage: node single-story-close.js --story <STORY_ID> [--cwd <main-repo>] [--skip-validation] [--skip-sync] [--no-auto-merge] [--wait-merge|--no-wait-merge] [--max-wait-seconds <n>] [--merge-watch-mode <sync|async>] [--override-review-block <reason>]',
|
|
548
|
+
'Usage: node single-story-close.js --story <STORY_ID> [--cwd <main-repo>] [--skip-validation] [--skip-sync] [--no-auto-merge] [--wait-merge|--no-wait-merge] [--max-wait-seconds <n>] [--merge-watch-mode <sync|async>] [--rerun-advisory <n>] [--override-review-block <reason>]',
|
|
486
549
|
);
|
|
487
550
|
}
|
|
488
551
|
|
|
@@ -493,13 +556,23 @@ export async function runSingleStoryClose({
|
|
|
493
556
|
// the `failed` terminal envelope. Tagging rather than swallowing is what
|
|
494
557
|
// lets `failed` name its phase without inventing a second success path.
|
|
495
558
|
let phase = 'init';
|
|
559
|
+
// Story #5279 — the per-gate outcomes this run OBSERVED, tagged onto a
|
|
560
|
+
// throwing error alongside the phase so `failedTerminalFor` reports gates
|
|
561
|
+
// instead of reconstructing which ones "must have" run. Stays null until
|
|
562
|
+
// close-validation reports, so a run that died before it claims no gates at
|
|
563
|
+
// all rather than inventing names for gates that were never registered.
|
|
564
|
+
let observedGates = null;
|
|
496
565
|
const setPhase = (next) => {
|
|
497
566
|
phase = next;
|
|
498
567
|
};
|
|
568
|
+
const setObservedGates = (gates) => {
|
|
569
|
+
observedGates = gates;
|
|
570
|
+
};
|
|
499
571
|
try {
|
|
500
572
|
return await runClosePipeline({
|
|
501
573
|
options,
|
|
502
574
|
setPhase,
|
|
575
|
+
setObservedGates,
|
|
503
576
|
injectedProvider,
|
|
504
577
|
injectedConfig,
|
|
505
578
|
injectedNotify,
|
|
@@ -510,8 +583,9 @@ export async function runSingleStoryClose({
|
|
|
510
583
|
injectedReleaseLease,
|
|
511
584
|
});
|
|
512
585
|
} catch (err) {
|
|
513
|
-
if (err && typeof err === 'object'
|
|
514
|
-
err.closePhase = phase;
|
|
586
|
+
if (err && typeof err === 'object') {
|
|
587
|
+
if (!err.closePhase) err.closePhase = phase;
|
|
588
|
+
if (!err.closeGates && observedGates) err.closeGates = observedGates;
|
|
515
589
|
}
|
|
516
590
|
throw err;
|
|
517
591
|
}
|
|
@@ -596,6 +670,7 @@ async function finishWithMergeWait(prCtx, deps) {
|
|
|
596
670
|
config: prCtx.config,
|
|
597
671
|
maxWaitSeconds: deps.maxWaitSeconds,
|
|
598
672
|
mergeWatchMode: deps.mergeWatchMode,
|
|
673
|
+
rerunAdvisory: deps.rerunAdvisory,
|
|
599
674
|
progress,
|
|
600
675
|
injectedGh: deps.injectedGh,
|
|
601
676
|
injectedNotify: deps.injectedNotify,
|
|
@@ -628,7 +703,15 @@ async function finishWithMergeWait(prCtx, deps) {
|
|
|
628
703
|
localCleanupDeferred: prCtx.localCleanupDeferred,
|
|
629
704
|
directMerged: prCtx.directMerged,
|
|
630
705
|
waitedForMerge: true,
|
|
631
|
-
|
|
706
|
+
// Story #5279 — `confirmed` alone denied the merge behind a
|
|
707
|
+
// `merged-flip-failed` block, whose own envelope (above) reports
|
|
708
|
+
// `pr.state: MERGED` off the probe.
|
|
709
|
+
merged: deriveObservedMerge({
|
|
710
|
+
waitOutcome,
|
|
711
|
+
directMerged: prCtx.directMerged,
|
|
712
|
+
}),
|
|
713
|
+
// Only the confirmed ending runs the flip, the issue close and the tail.
|
|
714
|
+
landCompleted: waitOutcome.confirmed === true,
|
|
632
715
|
});
|
|
633
716
|
await emitTerminal({ terminal, result, config: prCtx.config });
|
|
634
717
|
reportWaitTerminal(terminal, { storyId: prCtx.storyId, prUrl: prCtx.prUrl });
|
|
@@ -646,6 +729,11 @@ async function finishWithMergeWait(prCtx, deps) {
|
|
|
646
729
|
* @returns {Promise<{ success: boolean, result: object, terminal: object }>}
|
|
647
730
|
*/
|
|
648
731
|
async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
|
|
732
|
+
// Story #5279 — a repository with no native auto-merge is direct
|
|
733
|
+
// squash-merged synchronously by the arm phase, so this ending can and does
|
|
734
|
+
// hold an OBSERVED merge. It used to report `merged: false` and hard-code
|
|
735
|
+
// `pr.state: 'OPEN'` for a PR the same run had just merged.
|
|
736
|
+
const merged = deriveObservedMerge({ directMerged: prCtx.directMerged });
|
|
649
737
|
const result = closeResult({
|
|
650
738
|
storyId: prCtx.storyId,
|
|
651
739
|
storyBranch: prCtx.storyBranch,
|
|
@@ -661,6 +749,10 @@ async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
|
|
|
661
749
|
leaseReleased: false,
|
|
662
750
|
localCleanupDeferred: prCtx.localCleanupDeferred,
|
|
663
751
|
directMerged: prCtx.directMerged,
|
|
752
|
+
merged,
|
|
753
|
+
// Whatever the merge state, this ending never flips `agent::done`, never
|
|
754
|
+
// closes the issue and never runs the tail — `nextCommand` does.
|
|
755
|
+
landCompleted: false,
|
|
664
756
|
});
|
|
665
757
|
const terminal = buildTerminalEnvelope({
|
|
666
758
|
storyId: prCtx.storyId,
|
|
@@ -671,7 +763,7 @@ async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
|
|
|
671
763
|
pr: {
|
|
672
764
|
number: prCtx.prNumber,
|
|
673
765
|
url: prCtx.prUrl ?? null,
|
|
674
|
-
state: 'OPEN',
|
|
766
|
+
state: merged ? 'MERGED' : 'OPEN',
|
|
675
767
|
autoMergeEnabled: Boolean(prCtx.autoMergeEnabled),
|
|
676
768
|
},
|
|
677
769
|
gates: prCtx.gates,
|
|
@@ -731,6 +823,7 @@ function reportOperatorMergeSkip({
|
|
|
731
823
|
async function runClosePipeline({
|
|
732
824
|
options,
|
|
733
825
|
setPhase,
|
|
826
|
+
setObservedGates,
|
|
734
827
|
injectedProvider,
|
|
735
828
|
injectedConfig,
|
|
736
829
|
injectedNotify,
|
|
@@ -805,6 +898,7 @@ async function runClosePipeline({
|
|
|
805
898
|
injectedSync,
|
|
806
899
|
injectedGitSpawn,
|
|
807
900
|
setPhase,
|
|
901
|
+
setObservedGates,
|
|
808
902
|
}),
|
|
809
903
|
leaseArgs,
|
|
810
904
|
);
|
|
@@ -947,6 +1041,7 @@ async function runClosePipeline({
|
|
|
947
1041
|
provider,
|
|
948
1042
|
maxWaitSeconds: options.maxWaitSeconds,
|
|
949
1043
|
mergeWatchMode: options.mergeWatchMode,
|
|
1044
|
+
rerunAdvisory: options.rerunAdvisory,
|
|
950
1045
|
setPhase,
|
|
951
1046
|
injectedGh,
|
|
952
1047
|
injectedNotify,
|
|
@@ -64,7 +64,11 @@ import {
|
|
|
64
64
|
import { getQuality } from '../../config-resolver.js';
|
|
65
65
|
import { gitSync as defaultGitSync } from '../../git-utils.js';
|
|
66
66
|
import { Logger as DefaultLogger } from '../../Logger.js';
|
|
67
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
currentBranch,
|
|
69
|
+
listChangedFiles,
|
|
70
|
+
listDirtyPaths,
|
|
71
|
+
} from './format-autofix.js';
|
|
68
72
|
|
|
69
73
|
const TAG = '[baseline-writeback]';
|
|
70
74
|
|
|
@@ -92,6 +96,7 @@ const GUARD_REASONS = new Set([
|
|
|
92
96
|
'gate-disabled',
|
|
93
97
|
'no-changed-files',
|
|
94
98
|
'wrong-branch',
|
|
99
|
+
'dirty-tree',
|
|
95
100
|
'no-baseline',
|
|
96
101
|
'no-scorer',
|
|
97
102
|
]);
|
|
@@ -267,7 +272,14 @@ function commitBaseline({ cwd, git, relPath, subject, body }) {
|
|
|
267
272
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
268
273
|
});
|
|
269
274
|
} catch (err) {
|
|
270
|
-
git
|
|
275
|
+
// `git checkout -- <path>` restores the WORKTREE from the index — and the
|
|
276
|
+
// index is exactly what the `git add` above just overwrote, so on a
|
|
277
|
+
// rejected commit it restored the file to the value it was meant to be
|
|
278
|
+
// rolled back FROM. The rollback was a no-op that looked like one, and the
|
|
279
|
+
// rewritten row survived as a staged edit into whatever the gates scored
|
|
280
|
+
// next. `restore --staged --worktree` resets both to HEAD, which is what
|
|
281
|
+
// "leave the tree as the close found it" actually means.
|
|
282
|
+
git(['restore', '--staged', '--worktree', '--', relPath], {
|
|
271
283
|
cwd,
|
|
272
284
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
273
285
|
});
|
|
@@ -289,24 +301,84 @@ function commitBaseline({ cwd, git, relPath, subject, body }) {
|
|
|
289
301
|
* so a mis-wired `worktreePath` can never leave a modified baseline in a tree
|
|
290
302
|
* whose history we then refuse to touch.
|
|
291
303
|
*
|
|
292
|
-
*
|
|
304
|
+
* The dirty-tree guard is the same refusal `runScopedFormatAutofix` makes and
|
|
305
|
+
* for the same reason, sharpened to one path: this step's only write target is
|
|
306
|
+
* the baseline file, and it commits that file by name. An uncommitted edit
|
|
307
|
+
* sitting on it — a hand-run `maintainability:reanchor`, a half-resolved merge,
|
|
308
|
+
* an operator mid-edit — would be swept into a `baseline-refresh:` commit
|
|
309
|
+
* authored by close and attributed to rows this branch improved. That commit is
|
|
310
|
+
* the one `refresh-ack.js` VOUCHES for, so an absorbed edit is not merely
|
|
311
|
+
* unrelated: it arrives pre-acknowledged, which is the precise laundering this
|
|
312
|
+
* whole Story exists to close.
|
|
313
|
+
*
|
|
314
|
+
* @param {{ gate: object|undefined, workTree: string, storyBranch: string, git: Function, changed: string[], relPath: string }} ctx
|
|
293
315
|
* @returns {string|null}
|
|
294
316
|
*/
|
|
295
|
-
function precheck({ gate, workTree, storyBranch, git, changed }) {
|
|
317
|
+
function precheck({ gate, workTree, storyBranch, git, changed, relPath }) {
|
|
296
318
|
if (gate?.enabled === false) return 'gate-disabled';
|
|
297
319
|
if (changed.length === 0) return 'no-changed-files';
|
|
298
320
|
const onBranch = currentBranch(workTree, git);
|
|
299
321
|
if (onBranch !== storyBranch) return 'wrong-branch';
|
|
322
|
+
if (isDirty({ workTree, git, relPath })) return 'dirty-tree';
|
|
300
323
|
return null;
|
|
301
324
|
}
|
|
302
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Is the baseline file already modified in the worktree or the index?
|
|
328
|
+
*
|
|
329
|
+
* Fails CLOSED on an unreadable status: a step that cannot tell whether it is
|
|
330
|
+
* about to absorb someone else's edit must not proceed, and skipping costs
|
|
331
|
+
* only a stale upward row that the nightly full-scope re-score still catches.
|
|
332
|
+
*
|
|
333
|
+
* @param {{ workTree: string, git: Function, relPath: string }} ctx
|
|
334
|
+
* @returns {boolean}
|
|
335
|
+
*/
|
|
336
|
+
function isDirty({ workTree, git, relPath }) {
|
|
337
|
+
try {
|
|
338
|
+
return listDirtyPaths(workTree, git).includes(relPath);
|
|
339
|
+
} catch {
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The ref the branch's changed-file set is measured against.
|
|
346
|
+
*
|
|
347
|
+
* `origin/<baseBranch>` when the remote-tracking ref exists, the local branch
|
|
348
|
+
* otherwise. A local `main` in a long-lived checkout — and in every Story
|
|
349
|
+
* worktree, which is seeded once and never pulled again — drifts behind the
|
|
350
|
+
* remote, and a stale base widens the three-dot range to include commits that
|
|
351
|
+
* landed on the base after the branch forked. Every file in that widening is
|
|
352
|
+
* then scored and written back by whichever Story happens to close next, which
|
|
353
|
+
* is precisely the "absorb unrelated drift into the next PR" failure
|
|
354
|
+
* constraint 3 in the preamble forbids.
|
|
355
|
+
*
|
|
356
|
+
* @param {{ workTree: string, git: Function, baseBranch: string }} ctx
|
|
357
|
+
* @returns {string}
|
|
358
|
+
*/
|
|
359
|
+
function resolveScopeBase({ workTree, git, baseBranch }) {
|
|
360
|
+
try {
|
|
361
|
+
git(
|
|
362
|
+
['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${baseBranch}`],
|
|
363
|
+
{
|
|
364
|
+
cwd: workTree,
|
|
365
|
+
encoding: 'utf8',
|
|
366
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
367
|
+
},
|
|
368
|
+
);
|
|
369
|
+
return `origin/${baseBranch}`;
|
|
370
|
+
} catch {
|
|
371
|
+
return baseBranch;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
303
375
|
/**
|
|
304
376
|
* Persist improved maintainability rows for the files this branch changed, and
|
|
305
377
|
* fold them into one `baseline-refresh:` commit on the Story branch.
|
|
306
378
|
*
|
|
307
379
|
* Every no-op is reported by name rather than silently: `gate-disabled`,
|
|
308
|
-
* `no-changed-files`, `wrong-branch`, `
|
|
309
|
-
* `no-improvements`, `unchanged`. The caller logs the reason and proceeds —
|
|
380
|
+
* `no-changed-files`, `wrong-branch`, `dirty-tree`, `no-baseline`,
|
|
381
|
+
* `no-scored-rows`, `no-improvements`, `unchanged`. The caller logs the reason and proceeds —
|
|
310
382
|
* this step is never allowed to fail a close, because `check-baselines` is
|
|
311
383
|
* still the gate and this is only the refresh half of the loop.
|
|
312
384
|
*
|
|
@@ -364,12 +436,22 @@ export async function runBaselineUpwardWriteback({
|
|
|
364
436
|
|
|
365
437
|
const changed = listChangedFiles({
|
|
366
438
|
cwd: workTree,
|
|
367
|
-
baseBranch,
|
|
439
|
+
baseBranch: resolveScopeBase({ workTree, git, baseBranch }),
|
|
368
440
|
storyBranch,
|
|
369
441
|
git,
|
|
370
442
|
}).filter((file) => SCORABLE.test(file));
|
|
371
443
|
|
|
372
|
-
const
|
|
444
|
+
const writePath = resolveWritePath({ cwd: workTree });
|
|
445
|
+
const relPath = path.relative(workTree, writePath).split(path.sep).join('/');
|
|
446
|
+
|
|
447
|
+
const blocked = precheck({
|
|
448
|
+
gate,
|
|
449
|
+
workTree,
|
|
450
|
+
storyBranch,
|
|
451
|
+
git,
|
|
452
|
+
changed,
|
|
453
|
+
relPath,
|
|
454
|
+
});
|
|
373
455
|
if (blocked) return skip(logger, blocked);
|
|
374
456
|
|
|
375
457
|
const baselineRows = loadBaselineRows({ cwd: workTree });
|
|
@@ -399,7 +481,8 @@ export async function runBaselineUpwardWriteback({
|
|
|
399
481
|
storyId,
|
|
400
482
|
logger,
|
|
401
483
|
refreshBaseline,
|
|
402
|
-
|
|
484
|
+
writePath,
|
|
485
|
+
relPath,
|
|
403
486
|
});
|
|
404
487
|
}
|
|
405
488
|
|
|
@@ -420,10 +503,10 @@ async function persist({
|
|
|
420
503
|
storyId,
|
|
421
504
|
logger,
|
|
422
505
|
refreshBaseline,
|
|
423
|
-
|
|
506
|
+
writePath,
|
|
507
|
+
relPath,
|
|
424
508
|
}) {
|
|
425
509
|
const improvedPaths = improved.map((row) => row.path);
|
|
426
|
-
const writePath = resolveWritePath({ cwd: workTree });
|
|
427
510
|
const { wrote } = await refreshBaseline({
|
|
428
511
|
kind: KIND,
|
|
429
512
|
cwd: workTree,
|
|
@@ -436,7 +519,6 @@ async function persist({
|
|
|
436
519
|
// nothing worth a log line above debug.
|
|
437
520
|
if (!wrote) return skip(logger, 'unchanged');
|
|
438
521
|
|
|
439
|
-
const relPath = path.relative(workTree, writePath).split(path.sep).join('/');
|
|
440
522
|
const { sha } = commitBaseline({
|
|
441
523
|
cwd: workTree,
|
|
442
524
|
git,
|
|
@@ -59,11 +59,16 @@ export const FORMAT_AUTOFIX_TIMEOUT_EXIT_CODE = 124;
|
|
|
59
59
|
* (e.g. ` M file` for unstaged-modified) so we slice a fixed 3 chars off
|
|
60
60
|
* the front rather than trimming.
|
|
61
61
|
*
|
|
62
|
+
* Exported since Story #5277: `baseline-upward-writeback.js` needs the same
|
|
63
|
+
* "is this path already dirty?" test before it rewrites a baseline row, and a
|
|
64
|
+
* second porcelain parser is exactly the near-duplicate the duplication gate
|
|
65
|
+
* exists to refuse.
|
|
66
|
+
*
|
|
62
67
|
* @param {string} cwd
|
|
63
68
|
* @param {(args: string[], opts: object) => string} git
|
|
64
69
|
* @returns {string[]}
|
|
65
70
|
*/
|
|
66
|
-
function listDirtyPaths(cwd, git) {
|
|
71
|
+
export function listDirtyPaths(cwd, git) {
|
|
67
72
|
const out = git(['status', '--porcelain'], {
|
|
68
73
|
cwd,
|
|
69
74
|
encoding: 'utf8',
|
|
@@ -601,19 +601,28 @@ function assertAcyclic(slugAdjacency) {
|
|
|
601
601
|
}
|
|
602
602
|
}
|
|
603
603
|
|
|
604
|
-
function attachFindingsAndErrors(
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
604
|
+
function attachFindingsAndErrors(
|
|
605
|
+
tickets,
|
|
606
|
+
findings,
|
|
607
|
+
errors,
|
|
608
|
+
normalizations = [],
|
|
609
|
+
) {
|
|
610
|
+
for (const [key, value] of [
|
|
611
|
+
['findings', findings],
|
|
612
|
+
['errors', errors],
|
|
613
|
+
// Story #5265: the auto-normalizations the assumption gate applied. They
|
|
614
|
+
// used to end at a `Logger.warn` and die with the process, so persist's
|
|
615
|
+
// emitted result reported a plan whose declarations it had silently
|
|
616
|
+
// rewritten as if nothing had been rewritten.
|
|
617
|
+
['normalizations', normalizations],
|
|
618
|
+
]) {
|
|
619
|
+
Object.defineProperty(tickets, key, {
|
|
620
|
+
value,
|
|
621
|
+
enumerable: false,
|
|
622
|
+
configurable: true,
|
|
623
|
+
writable: true,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
617
626
|
}
|
|
618
627
|
|
|
619
628
|
export function validateAndNormalizeTickets(tickets, opts = {}) {
|
|
@@ -671,6 +680,7 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
|
|
|
671
680
|
// unit tests keep their semantics; production call-sites always pass
|
|
672
681
|
// it.
|
|
673
682
|
let assumptionErrors = [];
|
|
683
|
+
let assumptionNormalizations = [];
|
|
674
684
|
if (opts.baseBranchRef) {
|
|
675
685
|
const assumptionReport = validateStoryFileAssumptions({
|
|
676
686
|
tickets,
|
|
@@ -699,6 +709,7 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
|
|
|
699
709
|
);
|
|
700
710
|
}
|
|
701
711
|
assumptionErrors = assumptionReport.errors;
|
|
712
|
+
assumptionNormalizations = assumptionReport.normalizations ?? [];
|
|
702
713
|
}
|
|
703
714
|
|
|
704
715
|
const sizingFindings = computeSizingFindings({
|
|
@@ -741,7 +752,7 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
|
|
|
741
752
|
errors.push(`File assumption mismatch: ${e}`);
|
|
742
753
|
}
|
|
743
754
|
|
|
744
|
-
attachFindingsAndErrors(tickets, findings, errors);
|
|
755
|
+
attachFindingsAndErrors(tickets, findings, errors, assumptionNormalizations);
|
|
745
756
|
return tickets;
|
|
746
757
|
}
|
|
747
758
|
|
|
@@ -401,25 +401,59 @@ async function cascadeCompletion(provider, ticketId, opts = {}) {
|
|
|
401
401
|
* Derive the parent `agent::*` state from the composition of its children.
|
|
402
402
|
*
|
|
403
403
|
* Rules (Story #2676):
|
|
404
|
-
* - Any child carrying `agent::blocked` → parent should be
|
|
404
|
+
* - Any **open** child carrying `agent::blocked` → parent should be
|
|
405
|
+
* `agent::blocked`.
|
|
405
406
|
* - Otherwise, every child is `agent::done` (or closed) → parent should be
|
|
406
407
|
* `agent::done`.
|
|
407
|
-
* - Otherwise, any child carrying `agent::executing` or
|
|
408
|
-
* parent should be `agent::executing`.
|
|
408
|
+
* - Otherwise, any **open** child carrying `agent::executing` or
|
|
409
|
+
* `agent::closing` → parent should be `agent::executing`.
|
|
409
410
|
* - Otherwise (e.g. all children still `agent::ready`) → return `null` to
|
|
410
411
|
* signal "leave the parent unchanged". A parent already partway through
|
|
411
412
|
* the lifecycle MUST NOT be downgraded just because one child reverted.
|
|
412
413
|
*
|
|
414
|
+
* **A closed child contributes no `agent::*` state** (Story #5255). Its label
|
|
415
|
+
* records the state it was in when it stopped, not outstanding work, and
|
|
416
|
+
* nothing clears it on the way out: a Story re-planned out of `agent::blocked`
|
|
417
|
+
* is closed as superseded still wearing that label, and the blocked rule then
|
|
418
|
+
* pinned its container Epic open forever — `epic-rollup.js` bails before its
|
|
419
|
+
* close path on any derived state other than `agent::done`, so the Epic
|
|
420
|
+
* reported `pending` every run with every child long since closed. Filtering
|
|
421
|
+
* here rather than at that one call site is what also covers a child closed by
|
|
422
|
+
* hand with a stale state label attached. The all-done branch already counted
|
|
423
|
+
* `state === 'closed'` as done, so a closed child keeps exactly that meaning
|
|
424
|
+
* and loses only its vote on the other two.
|
|
425
|
+
*
|
|
413
426
|
* The function is pure and exported so the rule can be exercised in
|
|
414
427
|
* isolation by unit tests without dragging the cascade I/O surface in.
|
|
415
428
|
*
|
|
416
429
|
* @param {Array<{ labels?: string[], state?: string }>} siblings
|
|
417
430
|
* @returns {string|null} A `STATE_LABELS.*` value, or `null` for no-op.
|
|
418
431
|
*/
|
|
432
|
+
/**
|
|
433
|
+
* The labels that still describe **live** work on this child.
|
|
434
|
+
*
|
|
435
|
+
* Empty for a closed child: its `agent::*` label records the state it stopped
|
|
436
|
+
* in, not outstanding work, and the two live-state rules in
|
|
437
|
+
* {@link deriveParentState} must not read it. The all-done rule reads the
|
|
438
|
+
* child's labels directly, so a closed child keeps counting as done.
|
|
439
|
+
*
|
|
440
|
+
* Module-level rather than another local arrow inside `deriveParentState`:
|
|
441
|
+
* the CRAP baseline keys anonymous functions positionally within their
|
|
442
|
+
* enclosing scope, so adding or removing one there renumbers every later
|
|
443
|
+
* arrow and reports the shift as drift on code that did not change.
|
|
444
|
+
*
|
|
445
|
+
* @param {{ labels?: string[], state?: string }} sibling
|
|
446
|
+
* @returns {string[]}
|
|
447
|
+
*/
|
|
448
|
+
function liveChildLabels(sibling) {
|
|
449
|
+
if (sibling?.state === 'closed') return [];
|
|
450
|
+
return Array.isArray(sibling?.labels) ? sibling.labels : [];
|
|
451
|
+
}
|
|
452
|
+
|
|
419
453
|
export function deriveParentState(siblings) {
|
|
420
454
|
if (!Array.isArray(siblings) || siblings.length === 0) return null;
|
|
421
455
|
const labelsOf = (s) => (Array.isArray(s?.labels) ? s.labels : []);
|
|
422
|
-
if (siblings.some((s) =>
|
|
456
|
+
if (siblings.some((s) => liveChildLabels(s).includes(STATE_LABELS.BLOCKED))) {
|
|
423
457
|
return STATE_LABELS.BLOCKED;
|
|
424
458
|
}
|
|
425
459
|
const allDone = siblings.every(
|
|
@@ -428,13 +462,43 @@ export function deriveParentState(siblings) {
|
|
|
428
462
|
if (allDone) return STATE_LABELS.DONE;
|
|
429
463
|
const anyActive = siblings.some(
|
|
430
464
|
(s) =>
|
|
431
|
-
|
|
432
|
-
|
|
465
|
+
liveChildLabels(s).includes(STATE_LABELS.EXECUTING) ||
|
|
466
|
+
liveChildLabels(s).includes(STATE_LABELS.CLOSING),
|
|
433
467
|
);
|
|
434
468
|
if (anyActive) return STATE_LABELS.EXECUTING;
|
|
435
469
|
return null;
|
|
436
470
|
}
|
|
437
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Did any of these children actually **land**?
|
|
474
|
+
*
|
|
475
|
+
* `deriveParentState` answers `agent::done` for a child set in which every
|
|
476
|
+
* child is done *or closed*, and it is right to: a closed child is finished
|
|
477
|
+
* work as far as the parent's lifecycle goes. But "finished" and "landed" are
|
|
478
|
+
* different claims, and the close path spends the difference. A cohort
|
|
479
|
+
* re-planned out of existence closes every child as superseded, carrying no
|
|
480
|
+
* `agent::done` and having merged nothing — and the container above it then
|
|
481
|
+
* closed as `completed`, over a log line claiming every child Story landed.
|
|
482
|
+
* Both the state reason and the sentence were false.
|
|
483
|
+
*
|
|
484
|
+
* So the two questions are asked separately: `deriveParentState` decides
|
|
485
|
+
* *whether* the parent is finished, this decides *how* it finished. A single
|
|
486
|
+
* landed child is enough — a container that delivered some of its work and
|
|
487
|
+
* superseded the rest completed, partially, and `not_planned` is reserved for
|
|
488
|
+
* the case where nothing was delivered at all.
|
|
489
|
+
*
|
|
490
|
+
* @param {Array<{ labels?: string[] }>} children
|
|
491
|
+
* @returns {boolean} True when at least one child carries `agent::done`.
|
|
492
|
+
*/
|
|
493
|
+
export function anyChildLanded(children) {
|
|
494
|
+
if (!Array.isArray(children)) return false;
|
|
495
|
+
return children.some((child) =>
|
|
496
|
+
(Array.isArray(child?.labels) ? child.labels : []).includes(
|
|
497
|
+
STATE_LABELS.DONE,
|
|
498
|
+
),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
438
502
|
/**
|
|
439
503
|
* Parent-state cascade for non-terminal transitions. Story #2676.
|
|
440
504
|
*
|