mandrel 2.24.0 → 2.26.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 +19 -21
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
- package/.agents/schemas/baselines/crap.schema.json +13 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +124 -15
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +121 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +3 -4
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -7
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +124 -27
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +86 -13
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
- package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
- package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
- package/.agents/scripts/lib/workers/crap-worker.js +8 -13
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +137 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +36 -36
- package/.agents/scripts/single-story-close.js +34 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +308 -47
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +92 -97
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +95 -4
- package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +49 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +62 -75
- package/README.md +13 -6
- package/docs/CHANGELOG.md +93 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
|
@@ -38,11 +38,14 @@
|
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
40
|
import { rm } from 'node:fs/promises';
|
|
41
|
-
import path from 'node:path';
|
|
42
41
|
import { getLimits, PROJECT_ROOT } from '../../config-resolver.js';
|
|
43
42
|
import { gitSpawn } from '../../git-utils.js';
|
|
44
43
|
import { Logger } from '../../Logger.js';
|
|
45
44
|
import { sweepTempRetention } from '../../temp-retention.js';
|
|
45
|
+
import {
|
|
46
|
+
concurrentMap,
|
|
47
|
+
FANOUT_CONCURRENCY,
|
|
48
|
+
} from '../../util/concurrent-map.js';
|
|
46
49
|
import {
|
|
47
50
|
deriveStoryShape,
|
|
48
51
|
LITE_ROUTE_LABEL,
|
|
@@ -410,6 +413,198 @@ export async function reapStalePlanDirs({
|
|
|
410
413
|
return { reaped: result.purged.map((entry) => entry.path) };
|
|
411
414
|
}
|
|
412
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Fail closed on a payload that cannot be persisted, and warn on an
|
|
418
|
+
* explicitly-authorized over-budget one. Extracted from `runPlanPersist`
|
|
419
|
+
* (Story #4926) so the entry point carries the flow, not the guards.
|
|
420
|
+
*
|
|
421
|
+
* @param {unknown} rawStories
|
|
422
|
+
* @param {{ maxTickets: number, allowOverBudget: boolean }} limits
|
|
423
|
+
* @returns {void}
|
|
424
|
+
* @throws {Error} On an empty payload or an unauthorized over-budget one.
|
|
425
|
+
*/
|
|
426
|
+
function assertPersistablePlan(rawStories, { maxTickets, allowOverBudget }) {
|
|
427
|
+
if (!Array.isArray(rawStories) || rawStories.length === 0) {
|
|
428
|
+
throw new Error(
|
|
429
|
+
'[plan-persist] stories payload must be a non-empty array ' +
|
|
430
|
+
'(--stories <file>). Default is one Story.',
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (rawStories.length <= maxTickets) return;
|
|
434
|
+
if (!allowOverBudget) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
`[plan-persist] Stories (${rawStories.length}) exceed the reviewability ` +
|
|
437
|
+
`budget (${maxTickets}). Re-scope, or rerun with --allow-over-budget.`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
Logger.warn(
|
|
441
|
+
`[plan-persist] Persisting an over-budget plan: ${rawStories.length} ` +
|
|
442
|
+
`Stories vs. budget ${maxTickets} (--allow-over-budget).`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Enforce the draft-reachability critic: orphans throw, a skip is ledgered.
|
|
448
|
+
*
|
|
449
|
+
* @param {{ status: string, reasons: string[], orphans?: object[] }} reachability
|
|
450
|
+
* @param {object} config
|
|
451
|
+
* @returns {Promise<void>}
|
|
452
|
+
* @throws {Error} `PLAN_REACHABILITY_ORPHANS` when the draft orphans a Story.
|
|
453
|
+
*/
|
|
454
|
+
async function enforceReachability(reachability, config) {
|
|
455
|
+
if (reachability.status === 'orphans') {
|
|
456
|
+
const err = new Error(renderReachabilityOrphans(reachability));
|
|
457
|
+
err.code = 'PLAN_REACHABILITY_ORPHANS';
|
|
458
|
+
err.orphans = reachability.orphans;
|
|
459
|
+
throw err;
|
|
460
|
+
}
|
|
461
|
+
Logger.info(`[plan-persist] reachability: ${reachability.reasons[0]}`);
|
|
462
|
+
if (reachability.status === 'skipped') {
|
|
463
|
+
await appendCriticSkip(
|
|
464
|
+
{
|
|
465
|
+
critic: 'reachability',
|
|
466
|
+
reasons: reachability.reasons,
|
|
467
|
+
cli: 'plan-persist',
|
|
468
|
+
},
|
|
469
|
+
config,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Write the per-Story checkpoint, upsert the plan-summary comment, and flip
|
|
476
|
+
* every created Story to `agent::ready`. Terminal ordering is load-bearing:
|
|
477
|
+
* `agent::ready` lands last so it can honestly mean "fully persisted"
|
|
478
|
+
* (Story #4541). A dry run performs none of it.
|
|
479
|
+
*
|
|
480
|
+
* **The checkpoints fan out; the phase boundary does not** (Story #4952). The
|
|
481
|
+
* per-Story upserts are independent of one another and run under bounded
|
|
482
|
+
* concurrency, but the `await` on that whole fan-out is what keeps the
|
|
483
|
+
* Story #4541 invariant intact: *every* checkpoint is on its ticket before the
|
|
484
|
+
* first `agent::ready` flip is issued, so `ready` still means "fully
|
|
485
|
+
* persisted" and a `/deliver` that picks a Story up cannot read a null
|
|
486
|
+
* checkpoint. Concurrency inside the phase is safe; overlapping the phases is
|
|
487
|
+
* the race this ordering exists to close.
|
|
488
|
+
*
|
|
489
|
+
* @param {object} args
|
|
490
|
+
* @returns {Promise<void>}
|
|
491
|
+
*/
|
|
492
|
+
async function persistStoryArtifacts({
|
|
493
|
+
provider,
|
|
494
|
+
created,
|
|
495
|
+
primary,
|
|
496
|
+
route,
|
|
497
|
+
summaryBody,
|
|
498
|
+
}) {
|
|
499
|
+
const cohort = created.map((createdStory) => ({
|
|
500
|
+
slug: createdStory.slug,
|
|
501
|
+
id: createdStory.id,
|
|
502
|
+
}));
|
|
503
|
+
await concurrentMap(
|
|
504
|
+
created,
|
|
505
|
+
(story) =>
|
|
506
|
+
writeCheckpointV2(provider, story.id, {
|
|
507
|
+
persist: {
|
|
508
|
+
completedAt: new Date().toISOString(),
|
|
509
|
+
storyCount: created.length,
|
|
510
|
+
primaryStoryId: primary.id,
|
|
511
|
+
stories: cohort,
|
|
512
|
+
},
|
|
513
|
+
// Ledger the authored route verdict — the recorded reason and the
|
|
514
|
+
// per-Story shape evidence, including a shape-refused claim — on plan
|
|
515
|
+
// state (Story #4722). No authored verdict writes no block: absence
|
|
516
|
+
// is the standard full path.
|
|
517
|
+
...(route ? { route } : {}),
|
|
518
|
+
}),
|
|
519
|
+
// The per-Story checkpoint upserts (Story #4952): each targets a
|
|
520
|
+
// different issue and reads nothing another writes, so this loop was
|
|
521
|
+
// serial only by construction — but see {@link persistStoryArtifacts}
|
|
522
|
+
// for the phase ordering that is *not* incidental.
|
|
523
|
+
{ concurrency: FANOUT_CONCURRENCY },
|
|
524
|
+
);
|
|
525
|
+
await upsertStructuredComment(
|
|
526
|
+
provider,
|
|
527
|
+
primary.id,
|
|
528
|
+
PLAN_SUMMARY_COMMENT_TYPE,
|
|
529
|
+
summaryBody,
|
|
530
|
+
);
|
|
531
|
+
await markStoriesReady({ provider, created });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Remove this run's plan directory on terminal success, then sweep the
|
|
536
|
+
* abandoned ones terminal-success cleanup can never reach (Story #4541).
|
|
537
|
+
*
|
|
538
|
+
* @param {{ config: object, planDir: string|null, skipCleanup: boolean }} args
|
|
539
|
+
* @returns {Promise<void>}
|
|
540
|
+
*/
|
|
541
|
+
async function cleanupPlanDirs({ config, planDir, skipCleanup }) {
|
|
542
|
+
if (!skipCleanup && planDir) {
|
|
543
|
+
try {
|
|
544
|
+
await rm(planDir, { recursive: true, force: true });
|
|
545
|
+
} catch (err) {
|
|
546
|
+
Logger.warn(`[plan-persist] temp cleanup skipped: ${err.message}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
await reapStalePlanDirs({ config, keepDir: skipCleanup ? planDir : null });
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Log the effective complexity route (Story #4722). Lite is upheld by the
|
|
554
|
+
* shape backstop; anything else reports why it fell back to full.
|
|
555
|
+
*
|
|
556
|
+
* @param {object|null} route
|
|
557
|
+
* @param {boolean} isLiteRoute
|
|
558
|
+
* @returns {void}
|
|
559
|
+
*/
|
|
560
|
+
function logEffectiveRoute(route, isLiteRoute) {
|
|
561
|
+
if (isLiteRoute) {
|
|
562
|
+
Logger.info(
|
|
563
|
+
`[plan-persist] ceremony-lite route upheld by the shape backstop: ` +
|
|
564
|
+
`created Stories carry the ${LITE_ROUTE_LABEL} hint ` +
|
|
565
|
+
`(recorded reason: ${route.authored.reason}). /deliver re-derives ` +
|
|
566
|
+
'the route from each Story body — the label is never the control signal.',
|
|
567
|
+
);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (route) {
|
|
571
|
+
Logger.warn(
|
|
572
|
+
`[plan-persist] ${route.reasons.join('; ')} — persisting as full ` +
|
|
573
|
+
'(no route hint label).',
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Log the operator-facing persist epilogue: adoption, the ready primary, the
|
|
580
|
+
* deliver command, and the cohort grouping label.
|
|
581
|
+
*
|
|
582
|
+
* @param {{ created: object[], primary: object, planRunLabel: string }} args
|
|
583
|
+
* @returns {void}
|
|
584
|
+
*/
|
|
585
|
+
function logPersistEpilogue({ created, primary, planRunLabel }) {
|
|
586
|
+
const adopted = created.filter((story) => story.adopted);
|
|
587
|
+
if (adopted.length > 0) {
|
|
588
|
+
Logger.info(
|
|
589
|
+
`[plan-persist] resumed ${adopted.length} of ${created.length} Story(ies) ` +
|
|
590
|
+
`from a previous persist: ${adopted.map((s2) => `#${s2.id}`).join(', ')}.`,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
Logger.info(
|
|
594
|
+
`[plan-persist] Persisted ${created.length} Story(ies)` +
|
|
595
|
+
`; primary #${primary.id} is agent::ready.`,
|
|
596
|
+
);
|
|
597
|
+
Logger.info(
|
|
598
|
+
`[plan-persist] Deliver with: /deliver ${created.map((s2) => s2.id).join(' ')}`,
|
|
599
|
+
);
|
|
600
|
+
// Metadata only — a GitHub filter for the cohort this run authored, never
|
|
601
|
+
// a delivery-resolution input (/deliver stays ids-only, Story #4540).
|
|
602
|
+
Logger.info(
|
|
603
|
+
`[plan-persist] Cohort grouping label: ${planRunLabel} — filter with ` +
|
|
604
|
+
`label:${planRunLabel}`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
413
608
|
/**
|
|
414
609
|
* Execute the flat Story persist end to end.
|
|
415
610
|
*
|
|
@@ -451,6 +646,7 @@ export async function runPlanPersist({
|
|
|
451
646
|
stories: rawStories = null,
|
|
452
647
|
techSpecContent = null,
|
|
453
648
|
planAcceptance = null,
|
|
649
|
+
planContextEnvelope = null,
|
|
454
650
|
} = artifacts ?? {};
|
|
455
651
|
const {
|
|
456
652
|
forceReview = false,
|
|
@@ -474,26 +670,10 @@ export async function runPlanPersist({
|
|
|
474
670
|
// through the shared standalone ledger (Story #4541).
|
|
475
671
|
const runStartedAt = opts.metricsSince ?? new Date().toISOString();
|
|
476
672
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
const maxTickets = getLimits(config).maxTickets;
|
|
485
|
-
if (rawStories.length > maxTickets && !allowOverBudget) {
|
|
486
|
-
throw new Error(
|
|
487
|
-
`[plan-persist] Stories (${rawStories.length}) exceed the reviewability ` +
|
|
488
|
-
`budget (${maxTickets}). Re-scope, or rerun with --allow-over-budget.`,
|
|
489
|
-
);
|
|
490
|
-
}
|
|
491
|
-
if (rawStories.length > maxTickets && allowOverBudget) {
|
|
492
|
-
Logger.warn(
|
|
493
|
-
`[plan-persist] Persisting an over-budget plan: ${rawStories.length} ` +
|
|
494
|
-
`Stories vs. budget ${maxTickets} (--allow-over-budget).`,
|
|
495
|
-
);
|
|
496
|
-
}
|
|
673
|
+
assertPersistablePlan(rawStories, {
|
|
674
|
+
maxTickets: getLimits(config).maxTickets,
|
|
675
|
+
allowOverBudget,
|
|
676
|
+
});
|
|
497
677
|
|
|
498
678
|
Logger.info(
|
|
499
679
|
`[plan-persist] Running cross-validation on ${rawStories.length} Story ticket(s)...`,
|
|
@@ -515,29 +695,19 @@ export async function runPlanPersist({
|
|
|
515
695
|
tickets: rawStories,
|
|
516
696
|
config,
|
|
517
697
|
});
|
|
518
|
-
|
|
519
|
-
const err = new Error(renderReachabilityOrphans(reachability));
|
|
520
|
-
err.code = 'PLAN_REACHABILITY_ORPHANS';
|
|
521
|
-
err.orphans = reachability.orphans;
|
|
522
|
-
throw err;
|
|
523
|
-
}
|
|
524
|
-
Logger.info(`[plan-persist] reachability: ${reachability.reasons[0]}`);
|
|
525
|
-
if (reachability.status === 'skipped') {
|
|
526
|
-
await appendCriticSkip(
|
|
527
|
-
{
|
|
528
|
-
critic: 'reachability',
|
|
529
|
-
reasons: reachability.reasons,
|
|
530
|
-
cli: 'plan-persist',
|
|
531
|
-
},
|
|
532
|
-
config,
|
|
533
|
-
);
|
|
534
|
-
}
|
|
698
|
+
await enforceReachability(reachability, config);
|
|
535
699
|
|
|
536
700
|
// Split policy + inline Spec fold (over-budget Specs fail closed — no docs/).
|
|
537
701
|
const { stories } = assemblePlanStories(rawStories, {
|
|
538
702
|
sharedSpec: techSpecContent,
|
|
539
703
|
planAcceptance: planAcceptance ?? undefined,
|
|
540
704
|
sourceTicketIds,
|
|
705
|
+
// The seed this plan was authored from is the provenance source: an audit
|
|
706
|
+
// sweep's Single-plan seed carries the `audit-fingerprints` /
|
|
707
|
+
// `audit-semantic-keys` footers, which assembly copies into every persisted
|
|
708
|
+
// Story body so the next sweep recognises what it already planned
|
|
709
|
+
// (Story #4877). Empty for a `--tickets` run, which is a no-op.
|
|
710
|
+
provenanceSource: planContextEnvelope?.seed?.content ?? '',
|
|
541
711
|
});
|
|
542
712
|
|
|
543
713
|
// Effective complexity route (Story #4722): the planner's authored lite
|
|
@@ -552,19 +722,7 @@ export async function runPlanPersist({
|
|
|
552
722
|
injectedRules,
|
|
553
723
|
});
|
|
554
724
|
const isLiteRoute = route?.route === 'lite';
|
|
555
|
-
|
|
556
|
-
Logger.info(
|
|
557
|
-
`[plan-persist] ceremony-lite route upheld by the shape backstop: ` +
|
|
558
|
-
`created Stories carry the ${LITE_ROUTE_LABEL} hint ` +
|
|
559
|
-
`(recorded reason: ${route.authored.reason}). /deliver re-derives ` +
|
|
560
|
-
'the route from each Story body — the label is never the control signal.',
|
|
561
|
-
);
|
|
562
|
-
} else if (route) {
|
|
563
|
-
Logger.warn(
|
|
564
|
-
`[plan-persist] ${route.reasons.join('; ')} — persisting as full ` +
|
|
565
|
-
'(no route hint label).',
|
|
566
|
-
);
|
|
567
|
-
}
|
|
725
|
+
logEffectiveRoute(route, isLiteRoute);
|
|
568
726
|
|
|
569
727
|
const { created, planRunLabel } = await createStoryIssues({
|
|
570
728
|
provider,
|
|
@@ -609,35 +767,13 @@ export async function runPlanPersist({
|
|
|
609
767
|
});
|
|
610
768
|
|
|
611
769
|
if (!dryRun) {
|
|
612
|
-
|
|
613
|
-
await writeCheckpointV2(provider, story.id, {
|
|
614
|
-
persist: {
|
|
615
|
-
completedAt: new Date().toISOString(),
|
|
616
|
-
storyCount: created.length,
|
|
617
|
-
primaryStoryId: primary.id,
|
|
618
|
-
stories: created.map((createdStory) => ({
|
|
619
|
-
slug: createdStory.slug,
|
|
620
|
-
id: createdStory.id,
|
|
621
|
-
})),
|
|
622
|
-
},
|
|
623
|
-
// Ledger the authored route verdict — the recorded reason and the
|
|
624
|
-
// per-Story shape evidence, including a shape-refused claim — on plan
|
|
625
|
-
// state (Story #4722). No authored verdict writes no block: absence
|
|
626
|
-
// is the standard full path.
|
|
627
|
-
...(route ? { route } : {}),
|
|
628
|
-
});
|
|
629
|
-
}
|
|
630
|
-
await upsertStructuredComment(
|
|
770
|
+
await persistStoryArtifacts({
|
|
631
771
|
provider,
|
|
632
|
-
|
|
633
|
-
|
|
772
|
+
created,
|
|
773
|
+
primary,
|
|
774
|
+
route,
|
|
634
775
|
summaryBody,
|
|
635
|
-
);
|
|
636
|
-
|
|
637
|
-
// Terminal step: every checkpoint above is now on every Story, so
|
|
638
|
-
// `agent::ready` can honestly mean "fully persisted" (Story #4541).
|
|
639
|
-
// Anything that picks a Story up from here reads a real checkpoint.
|
|
640
|
-
await markStoriesReady({ provider, created });
|
|
776
|
+
});
|
|
641
777
|
}
|
|
642
778
|
|
|
643
779
|
const supersede = await runSupersedePhase({
|
|
@@ -653,37 +789,8 @@ export async function runPlanPersist({
|
|
|
653
789
|
// any) rather than reading as a clean no-op — Story #4554.
|
|
654
790
|
supersede.sourceTicketOrigin = sourceTicketOrigin;
|
|
655
791
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
await rm(planDir, { recursive: true, force: true });
|
|
659
|
-
} catch (err) {
|
|
660
|
-
Logger.warn(`[plan-persist] temp cleanup skipped: ${err.message}`);
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
// Terminal-success cleanup only ever removes *this* run's planDir, so
|
|
664
|
-
// abandoned ones accumulated forever. Sweep them (Story #4541).
|
|
665
|
-
await reapStalePlanDirs({ config, keepDir: skipCleanup ? planDir : null });
|
|
666
|
-
|
|
667
|
-
const adopted = created.filter((story) => story.adopted);
|
|
668
|
-
if (adopted.length > 0) {
|
|
669
|
-
Logger.info(
|
|
670
|
-
`[plan-persist] resumed ${adopted.length} of ${created.length} Story(ies) ` +
|
|
671
|
-
`from a previous persist: ${adopted.map((s2) => `#${s2.id}`).join(', ')}.`,
|
|
672
|
-
);
|
|
673
|
-
}
|
|
674
|
-
Logger.info(
|
|
675
|
-
`[plan-persist] Persisted ${created.length} Story(ies)` +
|
|
676
|
-
`; primary #${primary.id} is agent::ready.`,
|
|
677
|
-
);
|
|
678
|
-
Logger.info(
|
|
679
|
-
`[plan-persist] Deliver with: /deliver ${created.map((s2) => s2.id).join(' ')}`,
|
|
680
|
-
);
|
|
681
|
-
// Metadata only — a GitHub filter for the cohort this run authored, never
|
|
682
|
-
// a delivery-resolution input (/deliver stays ids-only, Story #4540).
|
|
683
|
-
Logger.info(
|
|
684
|
-
`[plan-persist] Cohort grouping label: ${planRunLabel} — filter with ` +
|
|
685
|
-
`label:${planRunLabel}`,
|
|
686
|
-
);
|
|
792
|
+
await cleanupPlanDirs({ config, planDir, skipCleanup });
|
|
793
|
+
logPersistEpilogue({ created, primary, planRunLabel });
|
|
687
794
|
|
|
688
795
|
return {
|
|
689
796
|
stories: created,
|
|
@@ -17,12 +17,17 @@
|
|
|
17
17
|
|
|
18
18
|
import { createHash } from 'node:crypto';
|
|
19
19
|
import { applyBlockedByDependencies } from '../../../providers/github/blocked-by-add.js';
|
|
20
|
+
import { carryProvenanceFooters } from '../../findings/route-finding.js';
|
|
20
21
|
import { Logger } from '../../Logger.js';
|
|
21
22
|
import { AGENT_LABELS, TYPE_LABELS } from '../../label-constants.js';
|
|
22
23
|
import {
|
|
23
24
|
parse as parseStoryBody,
|
|
24
25
|
serialize as serializeStoryBody,
|
|
25
26
|
} from '../../story-body/story-body.js';
|
|
27
|
+
import {
|
|
28
|
+
concurrentMap,
|
|
29
|
+
FANOUT_CONCURRENCY,
|
|
30
|
+
} from '../../util/concurrent-map.js';
|
|
26
31
|
import { assertSpecWithinBudget } from '../spec-spill.js';
|
|
27
32
|
import { assertAcceptancePartition } from '../split-policy-validator.js';
|
|
28
33
|
import {
|
|
@@ -392,7 +397,18 @@ function assembleOnePlanStory(ticket, opts) {
|
|
|
392
397
|
});
|
|
393
398
|
// Body first: the fingerprint is an identity over the *assembled* content,
|
|
394
399
|
// so it cannot be computed until that content exists.
|
|
395
|
-
const
|
|
400
|
+
const serialized = serializeStoryBody({ ...folded, depends_on });
|
|
401
|
+
// Carry audit dedup provenance out of the seed this plan was authored from
|
|
402
|
+
// (Story #4877). The audit sweep's Single-plan path stamps the
|
|
403
|
+
// `audit-fingerprints` / `audit-semantic-keys` footers into the seed it hands
|
|
404
|
+
// `/plan`; without this the persisted Story carries no provenance and the
|
|
405
|
+
// next sweep re-files work it already planned. Mechanical on purpose — the
|
|
406
|
+
// authoring agent is not asked to notice HTML comments in a one-pager. A
|
|
407
|
+
// non-audit seed carries no footers, so this is a no-op there.
|
|
408
|
+
const { body } = carryProvenanceFooters({
|
|
409
|
+
from: opts.provenanceSource ?? '',
|
|
410
|
+
into: serialized,
|
|
411
|
+
});
|
|
396
412
|
const fingerprint = planStoryFingerprint({ slug, title, body });
|
|
397
413
|
return {
|
|
398
414
|
story: {
|
|
@@ -783,6 +799,14 @@ async function ensurePersistLabel({
|
|
|
783
799
|
* (see `ensureCohortLabel`). `/deliver` never reads it — delivery stays
|
|
784
800
|
* ids-only over live state (Story #4540's actual point).
|
|
785
801
|
*
|
|
802
|
+
* **The create loop stays serial and dependency-ordered** — deliberately, and
|
|
803
|
+
* unlike every other per-Story loop on this path (Story #4952). It is not an
|
|
804
|
+
* independent fan-out: `renderStoryBodyForCreate(story, idBySlug)` resolves a
|
|
805
|
+
* Story's `depends_on` slugs to the real issue ids its siblings were just
|
|
806
|
+
* minted with, and `idBySlug` is filled *in loop order* by the POSTs
|
|
807
|
+
* themselves. Running it concurrently would render `#undefined` dependency
|
|
808
|
+
* refs for any Story whose dependency had not yet returned an id.
|
|
809
|
+
*
|
|
786
810
|
* **Sibling order is mirrored into native GitHub `blocked_by` edges** once
|
|
787
811
|
* every id is known (Story #4544), so plan-created order stops depending on
|
|
788
812
|
* prose. That pass is non-fatal — see `mirrorNativeDependencyEdges`.
|
|
@@ -948,6 +972,16 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
|
|
|
948
972
|
* Fails closed: an un-flipped Story is invisible to `/deliver`, which is the
|
|
949
973
|
* safe direction — the operator is told exactly which ids need the label.
|
|
950
974
|
*
|
|
975
|
+
* **Collect failures, never fast-fail** (preserved verbatim under the
|
|
976
|
+
* Story #4952 concurrency conversion). Every Story is attempted even when an
|
|
977
|
+
* earlier one's PATCH rejects, because the whole value of the closing error is
|
|
978
|
+
* naming the *complete* set of ids that still need the label by hand. The
|
|
979
|
+
* mapper below therefore absorbs its own rejection into a per-Story outcome
|
|
980
|
+
* rather than letting `concurrentMap`'s first-rejection-wins policy abandon
|
|
981
|
+
* the remaining flips. `concurrentMap` preserves input order, so `readied[]`
|
|
982
|
+
* and the reported failures come back in `created[]` order exactly as the
|
|
983
|
+
* serial loop produced them.
|
|
984
|
+
*
|
|
951
985
|
* @param {object} args
|
|
952
986
|
* @param {object} args.provider
|
|
953
987
|
* @param {Array<{ id: number, slug: string }>} args.created
|
|
@@ -960,18 +994,32 @@ export async function markStoriesReady({ provider, created }) {
|
|
|
960
994
|
'Stories to agent::ready.',
|
|
961
995
|
);
|
|
962
996
|
}
|
|
963
|
-
const
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
997
|
+
const outcomes = await concurrentMap(
|
|
998
|
+
created,
|
|
999
|
+
async (story) => {
|
|
1000
|
+
try {
|
|
1001
|
+
await provider.updateTicket(story.id, {
|
|
1002
|
+
labels: { add: [AGENT_LABELS.READY] },
|
|
1003
|
+
});
|
|
1004
|
+
return { id: story.id, failure: null };
|
|
1005
|
+
} catch (err) {
|
|
1006
|
+
return {
|
|
1007
|
+
id: story.id,
|
|
1008
|
+
failure: `#${story.id} (${story.slug}): ${err.message}`,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
// The terminal per-Story `agent::ready` PATCHes (Story #4952): each flip
|
|
1013
|
+
// is an independent single-issue write, so the loop was serial only by
|
|
1014
|
+
// construction. A latency fix, not a throughput one.
|
|
1015
|
+
{ concurrency: FANOUT_CONCURRENCY },
|
|
1016
|
+
);
|
|
1017
|
+
const readied = outcomes
|
|
1018
|
+
.filter((outcome) => outcome.failure === null)
|
|
1019
|
+
.map((outcome) => outcome.id);
|
|
1020
|
+
const failed = outcomes
|
|
1021
|
+
.filter((outcome) => outcome.failure !== null)
|
|
1022
|
+
.map((outcome) => outcome.failure);
|
|
975
1023
|
if (failed.length > 0) {
|
|
976
1024
|
throw new Error(
|
|
977
1025
|
`[plan-persist] ${failed.length} Story(ies) were created with their ` +
|
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
import { Logger } from '../../Logger.js';
|
|
39
|
+
import {
|
|
40
|
+
concurrentMap,
|
|
41
|
+
FANOUT_CONCURRENCY,
|
|
42
|
+
} from '../../util/concurrent-map.js';
|
|
39
43
|
import { upsertStructuredComment } from '../ticketing.js';
|
|
40
44
|
|
|
41
45
|
/** Structured-comment type marking a source issue as superseded. */
|
|
@@ -440,41 +444,94 @@ export async function closeSupersededTickets({
|
|
|
440
444
|
const createdBySlug = new Map(
|
|
441
445
|
(created ?? []).map((story) => [story.slug, story]),
|
|
442
446
|
);
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
447
|
+
const units = collectSupersedeUnits(stories, createdBySlug);
|
|
448
|
+
|
|
449
|
+
// Story #4952 — one bounded fan-out across distinct source tickets. The
|
|
450
|
+
// mapper inherits `closeOneSupersededTicket`'s never-throw contract (and
|
|
451
|
+
// resolves the two pre-write outcomes itself), so `concurrentMap`'s
|
|
452
|
+
// first-rejection-wins policy can never fire and no unit is abandoned
|
|
453
|
+
// because a sibling failed. Input order is preserved, so the report arrays
|
|
454
|
+
// below read exactly as the serial nested loop wrote them.
|
|
455
|
+
const outcomes = await concurrentMap(
|
|
456
|
+
units,
|
|
457
|
+
({ id, note, createdStory }) => {
|
|
449
458
|
if (!createdStory) {
|
|
450
|
-
|
|
451
|
-
continue;
|
|
452
|
-
}
|
|
453
|
-
if (dryRun) {
|
|
454
|
-
report.planned.push({ ticket: id, storySlug: createdStory.slug });
|
|
455
|
-
continue;
|
|
459
|
+
return { outcome: 'skipped', reason: 'story-not-created' };
|
|
456
460
|
}
|
|
457
|
-
|
|
461
|
+
if (dryRun) return { outcome: 'planned' };
|
|
462
|
+
return closeOneSupersededTicket({
|
|
458
463
|
provider,
|
|
459
464
|
id,
|
|
460
465
|
note,
|
|
461
466
|
story: createdStory,
|
|
462
467
|
sourceTicketIds: sources,
|
|
463
468
|
});
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
469
|
+
},
|
|
470
|
+
// The per-source-ticket close (Story #4952) fans out across **distinct**
|
|
471
|
+
// tickets — `assertSupersedePartition` has already failed the run closed
|
|
472
|
+
// if two Stories claim the same id, so no two units in flight can touch
|
|
473
|
+
// the same issue. Within one unit the probe → comment → close sequence
|
|
474
|
+
// stays strictly ordered: commenting on an issue the probe reported
|
|
475
|
+
// closed, or closing one the comment never landed on, is the whole
|
|
476
|
+
// failure mode this phase is careful about.
|
|
477
|
+
{ concurrency: FANOUT_CONCURRENCY },
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
const report = emptyReport({ enabled: true, dryRun });
|
|
481
|
+
outcomes.forEach((result, index) => {
|
|
482
|
+
recordSupersedeOutcome(report, units[index], result);
|
|
483
|
+
});
|
|
473
484
|
|
|
474
485
|
logSupersedeReport(report);
|
|
475
486
|
return report;
|
|
476
487
|
}
|
|
477
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Flatten the per-Story `supersedes[]` maps into one list of close units, in
|
|
491
|
+
* the nested iteration order the report arrays are expected to follow.
|
|
492
|
+
*
|
|
493
|
+
* @param {Array<{ slug: string, supersedes?: Array<{ id: number, note: string|null }> }>|undefined} stories
|
|
494
|
+
* @param {Map<string, { slug: string, id: number, title: string }>} createdBySlug
|
|
495
|
+
* @returns {Array<{ id: number, note: string|null, createdStory: object|undefined }>}
|
|
496
|
+
*/
|
|
497
|
+
function collectSupersedeUnits(stories, createdBySlug) {
|
|
498
|
+
const units = [];
|
|
499
|
+
for (const story of stories ?? []) {
|
|
500
|
+
const createdStory = createdBySlug.get(story.slug);
|
|
501
|
+
for (const { id, note } of story.supersedes ?? []) {
|
|
502
|
+
units.push({ id, note, createdStory });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return units;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* File one unit's outcome onto the report.
|
|
510
|
+
*
|
|
511
|
+
* @param {SupersedeReport} report
|
|
512
|
+
* @param {{ id: number, createdStory: object|undefined }} unit
|
|
513
|
+
* @param {{ outcome: string, reason?: string }} result
|
|
514
|
+
* @returns {void}
|
|
515
|
+
*/
|
|
516
|
+
function recordSupersedeOutcome(report, unit, result) {
|
|
517
|
+
if (result.outcome === 'closed') {
|
|
518
|
+
report.closed.push(unit.id);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (result.outcome === 'planned') {
|
|
522
|
+
report.planned.push({
|
|
523
|
+
ticket: unit.id,
|
|
524
|
+
storySlug: unit.createdStory.slug,
|
|
525
|
+
});
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (result.outcome === 'skipped') {
|
|
529
|
+
report.skipped.push({ ticket: unit.id, reason: result.reason });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
report.failed.push({ ticket: unit.id, reason: result.reason });
|
|
533
|
+
}
|
|
534
|
+
|
|
478
535
|
/**
|
|
479
536
|
* Surface the supersede outcome on the console so a partial failure is
|
|
480
537
|
* visible without reading the JSON envelope.
|