mandrel 2.55.0 → 2.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/.agents/agents/plan-critic.md +13 -18
  2. package/.agents/agents/story-worker.md +25 -34
  3. package/.agents/docs/agentrc-reference.json +4 -30
  4. package/.agents/docs/configuration.md +11 -28
  5. package/.agents/docs/execution-reference.md +5 -5
  6. package/.agents/docs/quality-gates.md +8 -7
  7. package/.agents/instructions.md +9 -10
  8. package/.agents/rules/ci-remediation.md +39 -21
  9. package/.agents/schemas/agentrc.schema.json +28 -185
  10. package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
  11. package/.agents/scripts/acceptance-eval.js +107 -17
  12. package/.agents/scripts/audit-to-stories.js +222 -75
  13. package/.agents/scripts/ceremony-derive.js +191 -0
  14. package/.agents/scripts/check-context-budget.js +28 -33
  15. package/.agents/scripts/check-cyclomatic.js +4 -3
  16. package/.agents/scripts/deliver-light.js +31 -94
  17. package/.agents/scripts/file-ci-gap.js +306 -0
  18. package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
  19. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  20. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +40 -52
  21. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  22. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  23. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  24. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
  25. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  26. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  27. package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
  28. package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
  29. package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
  30. package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
  31. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
  32. package/.agents/scripts/lib/close-validation/gates.js +52 -1
  33. package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
  34. package/.agents/scripts/lib/config/delivery-routing.js +7 -33
  35. package/.agents/scripts/lib/config/explain.js +0 -19
  36. package/.agents/scripts/lib/config/limits.js +18 -78
  37. package/.agents/scripts/lib/config/quality.js +6 -3
  38. package/.agents/scripts/lib/config/runners.js +3 -2
  39. package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
  40. package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
  41. package/.agents/scripts/lib/config-settings-schema.js +49 -143
  42. package/.agents/scripts/lib/crap-engine.js +35 -4
  43. package/.agents/scripts/lib/crap-utils.js +17 -1
  44. package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
  45. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  46. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  47. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  48. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  49. package/.agents/scripts/lib/findings/route-finding.js +38 -0
  50. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  51. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  52. package/.agents/scripts/lib/label-constants.js +6 -1
  53. package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
  54. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  55. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
  56. package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
  57. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  58. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  59. package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
  60. package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
  61. package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
  62. package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
  63. package/.agents/scripts/lib/orchestration/plan-context.js +181 -387
  64. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
  65. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
  66. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  67. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +300 -0
  68. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +131 -168
  69. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +133 -299
  70. package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
  71. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
  72. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
  73. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +30 -139
  74. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
  75. package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
  78. package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
  79. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  80. package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
  82. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
  83. package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
  84. package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
  85. package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
  86. package/.agents/scripts/lib/story-body/story-body.js +17 -237
  87. package/.agents/scripts/lib/templates/decomposer-prompts.js +84 -121
  88. package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
  89. package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
  90. package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
  91. package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
  92. package/.agents/scripts/lib/test-run-credit.js +266 -0
  93. package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
  94. package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
  95. package/.agents/scripts/lib/workers/crap-worker.js +32 -41
  96. package/.agents/scripts/plan-context.js +7 -9
  97. package/.agents/scripts/plan-critics.js +28 -54
  98. package/.agents/scripts/plan-persist.js +25 -68
  99. package/.agents/scripts/pr-watch-with-update.js +3 -2
  100. package/.agents/scripts/quality-preview.js +51 -0
  101. package/.agents/scripts/run-tests.js +12 -0
  102. package/.agents/scripts/stories-wave-tick.js +23 -45
  103. package/.agents/scripts/test-isolate.js +13 -180
  104. package/.agents/scripts/update-coverage-baseline.js +25 -70
  105. package/.agents/scripts/update-crap-baseline.js +19 -123
  106. package/.agents/skills/core/scope-triage/SKILL.md +3 -3
  107. package/.agents/workflows/audit-clean-code.md +4 -3
  108. package/.agents/workflows/audit-to-stories.md +63 -27
  109. package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
  110. package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
  111. package/.agents/workflows/helpers/code-review.md +2 -3
  112. package/.agents/workflows/helpers/deliver-digest.md +41 -57
  113. package/.agents/workflows/helpers/deliver-light.md +40 -105
  114. package/.agents/workflows/helpers/deliver-reference.md +1 -1
  115. package/.agents/workflows/helpers/deliver-story-reference.md +56 -62
  116. package/.agents/workflows/helpers/deliver-story.md +9 -13
  117. package/.agents/workflows/helpers/plan-reference.md +132 -196
  118. package/.agents/workflows/mandrel-plan.md +28 -41
  119. package/.agents/workflows/memory-consolidate.md +9 -13
  120. package/docs/CHANGELOG.md +33 -0
  121. package/lib/migrations/index.js +4 -0
  122. package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
  123. package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
  124. package/package.json +1 -1
  125. package/.agents/scripts/lib/framework-version.js +0 -39
  126. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
  127. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
  128. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
  129. package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
  130. package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
  131. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +0 -107
@@ -36,18 +36,20 @@ import { parseArgs } from 'node:util';
36
36
  import { buildStoryBody } from './lib/audit-to-stories/build-story-body.js';
37
37
  import { classifyGroupsAgainstGitHub } from './lib/audit-to-stories/dedupe-against-github.js';
38
38
  import { formatEpicGrouping } from './lib/audit-to-stories/epic-grouping-directive.js';
39
- import { withFingerprints } from './lib/audit-to-stories/finding-adapter.js';
39
+ import {
40
+ toCanonicalFinding,
41
+ withFingerprints,
42
+ } from './lib/audit-to-stories/finding-adapter.js';
40
43
  import { groupFindings } from './lib/audit-to-stories/group-findings.js';
41
44
  import {
42
- DEFAULT_LEDGER_PATH,
43
- readLedger,
44
- reconcileLedger,
45
- writeLedger,
46
- } from './lib/audit-to-stories/ledger.js';
45
+ loadIssuesFile,
46
+ normaliseIssueHit,
47
+ } from './lib/audit-to-stories/issues-file.js';
47
48
  import {
48
49
  resolveLedgerSummary,
49
50
  runLedgerCommit,
50
51
  } from './lib/audit-to-stories/ledger-commit.js';
52
+ import { recordFiledIssues } from './lib/audit-to-stories/ledger-record.js';
51
53
  import {
52
54
  parseAuditReports,
53
55
  readSeverityTally,
@@ -55,6 +57,12 @@ import {
55
57
  import { buildPlanSeedMarkdown } from './lib/audit-to-stories/seed-from-findings.js';
56
58
  import { wireAuditStoryEdges } from './lib/audit-to-stories/wire-dependencies.js';
57
59
  import { runAsCli } from './lib/cli-utils.js';
60
+ import {
61
+ DEFAULT_LEDGER_PATH,
62
+ readLedger,
63
+ reconcileLedger,
64
+ writeLedger,
65
+ } from './lib/findings/audit-ledger.js';
58
66
  import { searchSemanticCandidates } from './lib/findings/semantic-issue-search.js';
59
67
  import {
60
68
  normalizeSeverity,
@@ -366,28 +374,6 @@ class ProviderUnavailableError extends Error {
366
374
  }
367
375
  }
368
376
 
369
- /**
370
- * Flatten one raw `searchIssues` hit onto the `{ number, state, title, body }`
371
- * shape the dedupe module reads, collapsing every closed-ish state spelling
372
- * (`CLOSED`, `state_reason: not_planned`, …) onto `'closed'`.
373
- *
374
- * @param {object} hit
375
- * @returns {{ number: number, state: 'open'|'closed', title: string, body: string }}
376
- */
377
- function normaliseIssueHit(hit) {
378
- return {
379
- number: hit.number,
380
- state: (hit.state ?? hit.state_reason ?? 'open')
381
- .toString()
382
- .toLowerCase()
383
- .includes('closed')
384
- ? 'closed'
385
- : 'open',
386
- title: hit.title ?? '',
387
- body: hit.body ?? '',
388
- };
389
- }
390
-
391
377
  /**
392
378
  * Walk the list endpoint once per label and merge the pages into one
393
379
  * deduplicated, normalised issue list.
@@ -649,6 +635,136 @@ function dedupDegradedWarning(entries) {
649
635
  );
650
636
  }
651
637
 
638
+ /**
639
+ * Render the operator-visible line naming a host-supplied index and its size.
640
+ *
641
+ * Always emitted for a `--issues-file` run, because "how many issues did you
642
+ * actually check against" is the one number that separates a real dedup from a
643
+ * plan that merely looks checked. At zero it is the load-bearing case: an empty
644
+ * corpus is a legitimate first sweep AND exactly what a broken fetch writes, so
645
+ * the operator — not the run — decides which this was. Deliberately distinct in
646
+ * wording from both `dedupSkippedWarning` and `dedupDegradedWarning` so the
647
+ * three are never confused in a scrollback.
648
+ *
649
+ * @param {{ source?: string, size?: number }} dedupIndex
650
+ * @returns {string}
651
+ */
652
+ function dedupIndexWarning({ size = 0 } = {}) {
653
+ if (size === 0) {
654
+ return (
655
+ 'dedup index: 0 issues supplied via --issues-file. Dedup DID run and ' +
656
+ 'every group is correctly "create" — but that is also what a fetch that ' +
657
+ 'returned nothing looks like. If audit issues already exist, the fetch ' +
658
+ 'that wrote this file is broken and this run will re-file them.'
659
+ );
660
+ }
661
+ return `dedup index: ${size} issue(s) supplied via --issues-file; every exact-fingerprint lookup was answered from it.`;
662
+ }
663
+
664
+ /**
665
+ * Render the warning for a pre-fetch of the issue index that could not
666
+ * complete. The run still dedups — it falls back to a per-finding search — but
667
+ * it loses the one-list saving, and until Story #5301 this failure was
668
+ * swallowed whole: the operator saw only the downstream per-group degradation
669
+ * and could not tell that the pre-fetch itself was the cause.
670
+ *
671
+ * @param {string} reason
672
+ * @returns {string}
673
+ */
674
+ function dedupIndexDegradedWarning(reason) {
675
+ return (
676
+ `dedup index unavailable: ${reason}. Dedup fell back to a per-finding ` +
677
+ 'search, which is slower and rate-limited — if those searches also fail, ' +
678
+ 'every affected group is classified "create" WITHOUT a dedup check.'
679
+ );
680
+ }
681
+
682
+ /**
683
+ * Phase 6: classify every group against GitHub, and say loudly whichever way
684
+ * it went.
685
+ *
686
+ * Extracted from `buildPlan` because the gate has three outcomes, not two, and
687
+ * inlining them pushed the caller past its complexity ceiling. The three:
688
+ *
689
+ * - **deduped** — a provider resolved, or the host supplied a corpus, or
690
+ * both. A host-supplied corpus is a dedup source in its own right, which is
691
+ * the whole point: the gate asks "can we dedup at all", not "did a provider
692
+ * resolve". While it asked the latter, `--no-provider --issues-file` — the
693
+ * one invocation a `gh`-less host can run — short-circuited to the seeded
694
+ * all-`create` classifications however well the dedupe module worked.
695
+ * - **skipped, no port** — a provider was wanted but could not be adapted.
696
+ * - **skipped, disabled** — `--no-provider` with no corpus to fall back on.
697
+ *
698
+ * Every outcome warns on stderr, so the `--scan` JSON on stdout stays clean and
699
+ * a create-only plan is never read as "checked, found nothing".
700
+ *
701
+ * @param {{ groups: Array<object>, useProvider?: boolean,
702
+ * issues?: Array<object>|null }} params
703
+ * @param {{ loadProviderImpl: Function, classifyGroupsImpl: Function,
704
+ * logger: { warn: Function } }} deps
705
+ * @returns {Promise<{ classifications: Array<object>, summary: object,
706
+ * dedupApplied: boolean }>}
707
+ */
708
+ async function runDedupPhase(
709
+ { groups, useProvider, issues },
710
+ { loadProviderImpl, classifyGroupsImpl, logger },
711
+ ) {
712
+ const provider = useProvider ? await loadProviderImpl() : null;
713
+ if (!provider && !issues) {
714
+ logger.warn(
715
+ dedupSkippedWarning(useProvider ? 'no-provider-port' : 'disabled'),
716
+ );
717
+ return {
718
+ classifications: groups.map((group) => ({
719
+ group,
720
+ action: 'create',
721
+ matchedIssues: [],
722
+ matchedFingerprints: [],
723
+ })),
724
+ summary: { create: groups.length, skipOpen: 0, skipReoccurring: 0 },
725
+ dedupApplied: false,
726
+ };
727
+ }
728
+
729
+ const { classifications, summary } = await classifyGroupsImpl({
730
+ groups,
731
+ provider,
732
+ searchCandidates: provider?.searchCandidates,
733
+ listAuditIssues: provider?.listAuditIssues,
734
+ issues,
735
+ });
736
+ for (const warning of dedupPhaseWarnings({ issues, summary })) {
737
+ logger.warn(warning);
738
+ }
739
+ return { classifications, summary, dedupApplied: true };
740
+ }
741
+
742
+ /**
743
+ * Every warning a completed dedup pass owes the operator, in order. Pure, so
744
+ * the wording stays unit-testable and `runDedupPhase` keeps one write site.
745
+ *
746
+ * @param {{ issues?: Array<object>|null, summary: object }} params
747
+ * @returns {string[]}
748
+ */
749
+ function dedupPhaseWarnings({ issues, summary }) {
750
+ const warnings = [];
751
+ if (issues) warnings.push(dedupIndexWarning(summary.dedupIndex));
752
+ // The pre-fetch failing is a distinct fact from any group's lookup failing,
753
+ // and used to be invisible: the operator saw only the downstream per-group
754
+ // degradation and could not tell what had caused it.
755
+ if (summary.dedupDegraded?.indexPrefetch) {
756
+ warnings.push(
757
+ dedupIndexDegradedWarning(summary.dedupDegraded.indexPrefetch),
758
+ );
759
+ }
760
+ // A partially-checked plan is a useful result — name the groups that degraded
761
+ // to create because their lookup could not complete (Story #4678).
762
+ if (summary.dedupDegraded?.count > 0) {
763
+ warnings.push(dedupDegradedWarning(summary.dedupDegraded.groups));
764
+ }
765
+ return warnings;
766
+ }
767
+
652
768
  /**
653
769
  * Scan → group → dedup → (optionally) reconcile the cross-run ledger, and
654
770
  * return the plan envelope.
@@ -657,12 +773,14 @@ function dedupDegradedWarning(entries) {
657
773
  * implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main`,
658
774
  * `runAuto`, and every production caller are unchanged.
659
775
  *
660
- * @param {{ glob?: string, severity?: string, useProvider?: boolean, ledger?: object }} params
776
+ * @param {{ glob?: string, severity?: string, useProvider?: boolean,
777
+ * issuesFile?: string, ledger?: object }} params
661
778
  * @param {{
662
779
  * collectReportPathsImpl?: typeof collectReportPaths,
663
780
  * readReportsImpl?: typeof readReports,
664
781
  * loadProviderImpl?: typeof loadProviderOrNull,
665
782
  * classifyGroupsImpl?: typeof classifyGroupsAgainstGitHub,
783
+ * loadIssuesFileImpl?: typeof loadIssuesFile,
666
784
  * reconcileScanLedgerImpl?: typeof reconcileScanLedger,
667
785
  * logger?: { warn: Function },
668
786
  * }} [deps]
@@ -673,6 +791,7 @@ async function buildPlan(
673
791
  glob: pattern,
674
792
  severity,
675
793
  useProvider,
794
+ issuesFile,
676
795
  ledger,
677
796
  allowMissingTally,
678
797
  failOnReportFailures,
@@ -684,9 +803,14 @@ async function buildPlan(
684
803
  readReportsImpl = readReports,
685
804
  loadProviderImpl = loadProviderOrNull,
686
805
  classifyGroupsImpl = classifyGroupsAgainstGitHub,
806
+ loadIssuesFileImpl = loadIssuesFile,
687
807
  reconcileScanLedgerImpl = reconcileScanLedger,
688
808
  logger = Logger,
689
809
  } = deps;
810
+ // Deliberately BEFORE the reports are read: an unusable corpus is a usage
811
+ // error, and failing fast costs the operator nothing, where failing late
812
+ // would tempt a fallback that silently dedups nothing.
813
+ const issues = issuesFile ? loadIssuesFileImpl(issuesFile) : null;
690
814
  const reportPaths = await collectReportPathsImpl(pattern ?? DEFAULT_GLOB);
691
815
  if (reportPaths.length === 0) {
692
816
  return {
@@ -724,45 +848,10 @@ async function buildPlan(
724
848
  const stamped = withFingerprints(filtered.filter((f) => Boolean(f.severity)));
725
849
  const { groups, edges } = groupFindings(stamped);
726
850
 
727
- let classifications = groups.map((g) => ({
728
- group: g,
729
- action: 'create',
730
- matchedIssues: [],
731
- matchedFingerprints: [],
732
- }));
733
- let summary = { create: groups.length, skipOpen: 0, skipReoccurring: 0 };
734
- let dedupApplied = false;
735
-
736
- if (useProvider) {
737
- const provider = await loadProviderImpl();
738
- if (provider) {
739
- const result = await classifyGroupsImpl({
740
- groups,
741
- provider,
742
- searchCandidates: provider.searchCandidates,
743
- listAuditIssues: provider.listAuditIssues,
744
- });
745
- classifications = result.classifications;
746
- summary = result.summary;
747
- dedupApplied = true;
748
- // A partially-checked plan is a useful result — warn loudly (stderr, so
749
- // the --scan JSON on stdout stays clean) naming the groups that degraded
750
- // to create because their lookup could not complete (Story #4678).
751
- if (summary.dedupDegraded?.count > 0) {
752
- logger.warn(dedupDegradedWarning(summary.dedupDegraded.groups));
753
- }
754
- } else {
755
- // The provider could not resolve a searchIssues port — the dedup gate
756
- // is silently a no-op without this. Surface it loudly (stderr, so the
757
- // --scan JSON on stdout stays clean) so the operator does not read a
758
- // create-only plan as "no duplicates found".
759
- logger.warn(dedupSkippedWarning('no-provider-port'));
760
- }
761
- } else {
762
- // Operator explicitly opted out via --no-provider. Still warn so a
763
- // duplicate-opening re-run is never a surprise.
764
- logger.warn(dedupSkippedWarning('disabled'));
765
- }
851
+ const { classifications, summary, dedupApplied } = await runDedupPhase(
852
+ { groups, useProvider, issues },
853
+ { loadProviderImpl, classifyGroupsImpl, logger },
854
+ );
766
855
 
767
856
  // Cross-run ledger (Story #4626): fold this scan onto the committed memory,
768
857
  // suppress findings a prior run recorded as accepted-risk, and (unless the
@@ -832,6 +921,9 @@ function reconcileScanLedger({ ledgerPath, findings, classifications, write }) {
832
921
  ledger: prior,
833
922
  findings,
834
923
  issueStates,
924
+ // The ledger lives in the shared findings layer and cannot import the
925
+ // audit adapter without closing a cycle, so the projection is ours to pass.
926
+ toCanonical: toCanonicalFinding,
835
927
  });
836
928
  if (write !== false) writeLedger(ledgerPath, next);
837
929
  return new Set(
@@ -918,6 +1010,7 @@ async function runAuto({
918
1010
  severity,
919
1011
  dryRun,
920
1012
  useProvider,
1013
+ issuesFile,
921
1014
  ledgerPath,
922
1015
  ledgerCommit,
923
1016
  git,
@@ -930,6 +1023,7 @@ async function runAuto({
930
1023
  glob,
931
1024
  severity: floor,
932
1025
  useProvider,
1026
+ issuesFile,
933
1027
  ledger: { path: resolvedLedgerPath, write: !dryRun },
934
1028
  // `--auto` never accepts `--allow-missing-tally`: an unattended sweep has
935
1029
  // no operator to read a warning, so every report failure is fatal here.
@@ -967,6 +1061,11 @@ async function runAuto({
967
1061
  skipReoccurring: byAction.skipReoccurring.length,
968
1062
  suppressedByLedger: byAction.suppressed.length,
969
1063
  },
1064
+ // `--auto` opens no Issues itself — the caller does, from the `--emit-stories`
1065
+ // drafts — so these keys are the only thing standing between its summary and
1066
+ // the `--wire-edges --ids` map. Without them an unattended sweep cannot
1067
+ // record what it filed, and the ledger stays empty however well it works.
1068
+ createGroupKeys: eligible.map((g) => g?.groupKey).filter(Boolean),
970
1069
  // Re-detected open Issues the operator may want a "re-detected" comment on.
971
1070
  reDetected: byAction.skipOpen
972
1071
  .flatMap((c) => c.matchedIssues ?? [])
@@ -1076,20 +1175,50 @@ function wireEdgesPreconditionError(reason, detail) {
1076
1175
  * re-rendered with a canonical `blocked by #N` footer and the same edges are
1077
1176
  * mirrored as native `blocked_by` relations.
1078
1177
  *
1178
+ * The same map is what the cross-run ledger needs to record what this run
1179
+ * filed, so the record rides along here rather than arriving as a second
1180
+ * command an operator must remember (Story #5305).
1181
+ *
1079
1182
  * @param {object} params
1080
1183
  * @param {object} params.plan A `--scan` plan envelope.
1081
1184
  * @param {Record<string, number>} params.issueByGroupKey
1185
+ * @param {string} [params.ledgerPath] — ledger to record into; defaults to
1186
+ * `DEFAULT_LEDGER_PATH` inside the record.
1187
+ * @param {boolean} [params.write] — `false` computes the record without
1188
+ * persisting it (what `--dry-run` passes).
1082
1189
  * @param {object} [deps]
1083
1190
  * @param {Function} [deps.loadProviderImpl]
1084
1191
  * @param {Function} [deps.wireImpl]
1085
- * @returns {Promise<object>} the wiring summary.
1192
+ * @param {Function} [deps.recordFiledIssuesImpl]
1193
+ * @returns {Promise<object>} the wiring summary, with the ledger record on
1194
+ * `ledger`.
1086
1195
  */
1087
- async function wireEdges({ plan, issueByGroupKey }, deps = {}) {
1088
- const { loadProviderImpl = loadProvider, wireImpl = wireAuditStoryEdges } =
1089
- deps;
1196
+ async function wireEdges(
1197
+ { plan, issueByGroupKey, ledgerPath, write },
1198
+ deps = {},
1199
+ ) {
1200
+ const {
1201
+ loadProviderImpl = loadProvider,
1202
+ wireImpl = wireAuditStoryEdges,
1203
+ recordFiledIssuesImpl = recordFiledIssues,
1204
+ } = deps;
1090
1205
  const groups = (plan.classifications ?? [])
1091
1206
  .filter((c) => c.action === 'create')
1092
1207
  .map((c) => c.group);
1208
+
1209
+ // Record BEFORE the provider is loaded. The record is pure local filesystem
1210
+ // work, while the wiring below needs a provider exposing `updateTicket` — and
1211
+ // the host most likely to lack one is the `gh`-less host where the ledger is
1212
+ // the only duplicate protection there is. Recording first means such a run
1213
+ // still remembers what it filed, and the precondition error below still
1214
+ // surfaces unchanged afterwards.
1215
+ const ledger = recordFiledIssuesImpl({
1216
+ ledgerPath,
1217
+ groups,
1218
+ issueByGroupKey,
1219
+ write,
1220
+ });
1221
+
1093
1222
  let provider;
1094
1223
  try {
1095
1224
  provider = await loadProviderImpl();
@@ -1099,7 +1228,7 @@ async function wireEdges({ plan, issueByGroupKey }, deps = {}) {
1099
1228
  if (typeof provider?.updateTicket !== 'function') {
1100
1229
  throw wireEdgesPreconditionError('fixture-no-write-port');
1101
1230
  }
1102
- return wireImpl({
1231
+ const wired = await wireImpl({
1103
1232
  groups,
1104
1233
  edges: plan.edges ?? [],
1105
1234
  issueByGroupKey,
@@ -1107,6 +1236,7 @@ async function wireEdges({ plan, issueByGroupKey }, deps = {}) {
1107
1236
  updateBody: (issueNumber, body) =>
1108
1237
  provider.updateTicket(issueNumber, { body }),
1109
1238
  });
1239
+ return { ...wired, ledger };
1110
1240
  }
1111
1241
 
1112
1242
  /**
@@ -1161,6 +1291,8 @@ export const __testing = {
1161
1291
  loadProviderOrNull,
1162
1292
  dedupSkippedWarning,
1163
1293
  dedupDegradedWarning,
1294
+ dedupIndexWarning,
1295
+ dedupIndexDegradedWarning,
1164
1296
  buildAndGateStories,
1165
1297
  runAuto,
1166
1298
  resolveSeverityFloor,
@@ -1291,6 +1423,7 @@ export async function runAuditToStories(
1291
1423
  plan: { type: 'string' },
1292
1424
  out: { type: 'string' },
1293
1425
  'no-provider': { type: 'boolean' },
1426
+ 'issues-file': { type: 'string' },
1294
1427
  'allow-missing-tally': { type: 'boolean' },
1295
1428
  json: { type: 'boolean' },
1296
1429
  },
@@ -1308,6 +1441,7 @@ export async function runAuditToStories(
1308
1441
  severity: values.severity,
1309
1442
  dryRun: values['dry-run'],
1310
1443
  useProvider: !values['no-provider'],
1444
+ issuesFile: values['issues-file'],
1311
1445
  ledgerPath: values.ledger,
1312
1446
  ledgerCommit: values['ledger-commit'],
1313
1447
  })
@@ -1334,6 +1468,7 @@ export async function runAuditToStories(
1334
1468
  glob: values.glob,
1335
1469
  severity: values.severity,
1336
1470
  useProvider: !values['no-provider'],
1471
+ issuesFile: values['issues-file'],
1337
1472
  allowMissingTally: values['allow-missing-tally'],
1338
1473
  });
1339
1474
 
@@ -1359,6 +1494,11 @@ export async function runAuditToStories(
1359
1494
  wireEdgesImpl({
1360
1495
  plan: loadPlanImpl(values.plan),
1361
1496
  issueByGroupKey: parseIssueMapImpl(values.ids),
1497
+ ledgerPath: values.ledger,
1498
+ // `--scan` still never writes the ledger; `--wire-edges` runs only after
1499
+ // the Issues were really opened, where recording is never wrong — so the
1500
+ // record is on by default here and `--dry-run` is what suppresses it.
1501
+ write: !values['dry-run'],
1362
1502
  });
1363
1503
 
1364
1504
  // One table, not a chain of `if (values.X) { …; return; }`. Each entry
@@ -1435,7 +1575,7 @@ runAsCli(import.meta.url, main, {
1435
1575
  ['--emit-stories', 'Emit the Story drafts as JSON.'],
1436
1576
  [
1437
1577
  '--wire-edges',
1438
- 'Second pass: resolve the detected group edges to blocked by #N footers plus native blocked_by relations. Needs --plan and --ids.',
1578
+ 'Second pass: resolve the detected group edges to blocked by #N footers plus native blocked_by relations, and record the mapped issues in the cross-run ledger as filed. Needs --plan and --ids; --dry-run suppresses the ledger write.',
1439
1579
  ],
1440
1580
  [
1441
1581
  '--ids <json|path>',
@@ -1443,7 +1583,10 @@ runAsCli(import.meta.url, main, {
1443
1583
  ],
1444
1584
  ['--glob <pattern>', 'Override the audit-results glob.'],
1445
1585
  ['--severity <level>', 'Lowest severity to include (high|medium|low).'],
1446
- ['--ledger <path>', 'Path to the dedup ledger.'],
1586
+ [
1587
+ '--ledger <path>',
1588
+ `Path to the cross-run dedup ledger (default ${DEFAULT_LEDGER_PATH}).`,
1589
+ ],
1447
1590
  [
1448
1591
  '--ledger-commit',
1449
1592
  'After the --auto summary prints, commit a changed ledger onto chore/audit-ledger-<date>, push it, and open a PR against the base branch (never auto-merged). Ignored under --dry-run.',
@@ -1454,6 +1597,10 @@ runAsCli(import.meta.url, main, {
1454
1597
  ],
1455
1598
  ['--out <path>', 'Write output to a file instead of stdout.'],
1456
1599
  ['--no-provider', 'Skip live GitHub dedup lookups (offline).'],
1600
+ [
1601
+ '--issues-file <path>',
1602
+ 'Dedup against a JSON array of issues the host already fetched (every issue labelled audit::*, state all) instead of listing them through the provider. Lets dedup run where there is no gh CLI; composes with --no-provider.',
1603
+ ],
1457
1604
  [
1458
1605
  '--allow-missing-tally',
1459
1606
  'Downgrade a missing "Severity tally:" line to a warning (--scan only; --auto ignores it).',
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ceremony-derive.js — derive a Story's acceptance ceremony from ground truth
5
+ * (Story #5313).
6
+ *
7
+ * The deliver digest used to hand the worker a three-module import block to
8
+ * paste into `node --input-type=module -e` — compute the change set, derive
9
+ * the level, resolve the ceremony — and every hand-carried incantation is a
10
+ * transcription risk (the object-vs-string `derivedLevel` slip alone routed a
11
+ * whole class of Stories to the null fail-safe). This CLI is that block,
12
+ * scripted: one invocation, one JSON object, computed from the branch rather
13
+ * than recalled.
14
+ *
15
+ * 1. Compute the change set ONCE for `<base>...story-<id>`
16
+ * (`lib/orchestration/change-set.js`).
17
+ * 2. Derive the change level and the sensitive-path classes from it
18
+ * (`lib/orchestration/review-depth.js#deriveChangeLevel`).
19
+ * 3. Resolve the ceremony for the configured profile
20
+ * (`lib/orchestration/ceremony-routing.js#resolveCeremonyForRisk`).
21
+ *
22
+ * Stdout: a single JSON object —
23
+ * { storyId, baseRef, headRef, files, enumerated, level, classes, profile,
24
+ * mode, reason, verdictOwner }
25
+ *
26
+ * `files` is the one change set every acceptance critic must be handed; the
27
+ * caller never lets a critic re-enumerate it. `files: null` means the diff
28
+ * could not be enumerated, which routes to the fail-safe fresh critic.
29
+ *
30
+ * Exit codes: 0 on a derived decision (including the `null` fail-safe — an
31
+ * unenumerable diff is a decision, not an error), 1 on a usage error.
32
+ */
33
+
34
+ import { parseArgs } from 'node:util';
35
+
36
+ import { runAsCli } from './lib/cli-utils.js';
37
+ import { getDeliveryRouting } from './lib/config/delivery-routing.js';
38
+ import { resolveConfig } from './lib/config-resolver.js';
39
+ import { resolveCeremonyForRisk } from './lib/orchestration/ceremony-routing.js';
40
+ import { computeChangeSet } from './lib/orchestration/change-set.js';
41
+ import { deriveChangeLevel } from './lib/orchestration/review-depth.js';
42
+
43
+ const USAGE = {
44
+ invocation:
45
+ 'node .agents/scripts/ceremony-derive.js --story <id> [--base <ref>] [--cwd <path>]',
46
+ summary:
47
+ 'Compute the Story change set once, derive its change level and sensitive-path classes, resolve the acceptance ceremony, and print one JSON object.',
48
+ flags: [
49
+ ['--story <id>', 'Story issue number; the head ref is story-<id>.'],
50
+ [
51
+ '--base <ref>',
52
+ 'Base ref for the three-dot diff (default: project.baseBranch, normally main).',
53
+ ],
54
+ [
55
+ '--cwd <path>',
56
+ 'Checkout to run the diff in (default: the current directory).',
57
+ ],
58
+ ],
59
+ };
60
+
61
+ /**
62
+ * Parse the CLI argv. Exported for tests.
63
+ *
64
+ * @param {string[]} argv
65
+ * @returns {{ storyId: number|null, base: string|null, cwd: string|null }}
66
+ */
67
+ export function parseArgv(argv) {
68
+ const { values } = parseArgs({
69
+ args: argv,
70
+ options: {
71
+ story: { type: 'string' },
72
+ base: { type: 'string' },
73
+ cwd: { type: 'string' },
74
+ },
75
+ strict: false,
76
+ });
77
+ const storyId = Number.parseInt(values.story ?? '', 10);
78
+ return {
79
+ storyId: Number.isInteger(storyId) && storyId > 0 ? storyId : null,
80
+ base: values.base ?? null,
81
+ cwd: values.cwd ?? null,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * The derivation itself — pure over its injected collaborators, so tests can
87
+ * pin the envelope without spawning git or reading a config.
88
+ *
89
+ * @param {{ storyId: number, baseRef: string, cwd: string, ceremonyProfile: string }} input
90
+ * @param {{
91
+ * computeChangeSetImpl?: typeof computeChangeSet,
92
+ * deriveChangeLevelImpl?: typeof deriveChangeLevel,
93
+ * resolveCeremonyImpl?: typeof resolveCeremonyForRisk,
94
+ * }} [deps]
95
+ * @returns {{
96
+ * storyId: number, baseRef: string, headRef: string,
97
+ * files: string[]|null, enumerated: boolean,
98
+ * level: 'low'|'high'|null, classes: string[],
99
+ * profile: string, mode: 'fresh'|'inline', reason: string,
100
+ * verdictOwner: 'fresh-critic'|'inline-self-eval',
101
+ * }}
102
+ */
103
+ export function deriveCeremony(
104
+ { storyId, baseRef, cwd, ceremonyProfile },
105
+ deps = {},
106
+ ) {
107
+ const {
108
+ computeChangeSetImpl = computeChangeSet,
109
+ deriveChangeLevelImpl = deriveChangeLevel,
110
+ resolveCeremonyImpl = resolveCeremonyForRisk,
111
+ } = deps;
112
+ const headRef = `story-${storyId}`;
113
+ const changeSet = computeChangeSetImpl({ baseRef, headRef, cwd });
114
+ const { level, classes } = deriveChangeLevelImpl({
115
+ changedFiles: changeSet.files,
116
+ });
117
+ // `derivedLevel` is the level STRING — handing the `{ level, classes }`
118
+ // object here was the transcription slip this CLI exists to retire.
119
+ const ceremony = resolveCeremonyImpl({
120
+ derivedLevel: level,
121
+ clusterIndex: 0,
122
+ ceremonyProfile,
123
+ });
124
+ return {
125
+ storyId,
126
+ baseRef,
127
+ headRef,
128
+ files: changeSet.files,
129
+ enumerated: changeSet.enumerated,
130
+ level,
131
+ classes,
132
+ profile: ceremony.profile,
133
+ mode: ceremony.mode,
134
+ reason: ceremony.reason,
135
+ verdictOwner: ceremony.verdictOwner,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * CLI shell: resolve the config-backed defaults, derive, print.
141
+ *
142
+ * @param {string[]} [argv]
143
+ * @param {{
144
+ * resolveConfigImpl?: typeof resolveConfig,
145
+ * stdout?: { write: (s: string) => void },
146
+ * cwd?: string,
147
+ * } & Parameters<typeof deriveCeremony>[1]} [deps]
148
+ * @returns {Promise<object>} the printed envelope
149
+ */
150
+ export async function runCeremonyDeriveCli(
151
+ argv = process.argv.slice(2),
152
+ deps = {},
153
+ ) {
154
+ const {
155
+ resolveConfigImpl = resolveConfig,
156
+ stdout = process.stdout,
157
+ cwd: defaultCwd = process.cwd(),
158
+ ...derivationDeps
159
+ } = deps;
160
+ const { storyId, base, cwd } = parseArgv(argv);
161
+ if (!storyId) {
162
+ throw new Error(
163
+ 'ceremony-derive: --story <id> is required (a positive integer).',
164
+ );
165
+ }
166
+ const workCwd = cwd ?? defaultCwd;
167
+ const config = resolveConfigImpl({ cwd: workCwd });
168
+ const envelope = deriveCeremony(
169
+ {
170
+ storyId,
171
+ baseRef: base ?? config?.project?.baseBranch ?? 'main',
172
+ cwd: workCwd,
173
+ ceremonyProfile: getDeliveryRouting(config).ceremonyProfile,
174
+ },
175
+ derivationDeps,
176
+ );
177
+ stdout.write(`${JSON.stringify(envelope)}\n`);
178
+ return envelope;
179
+ }
180
+
181
+ async function main() {
182
+ await runCeremonyDeriveCli();
183
+ return 0;
184
+ }
185
+
186
+ runAsCli(import.meta.url, main, {
187
+ source: 'ceremony-derive',
188
+ propagateExitCode: true,
189
+ errorPrefix: '[ceremony-derive] ❌ Fatal error',
190
+ usage: USAGE,
191
+ });