mandrel 2.54.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.
Files changed (114) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +4 -2
  5. package/.agents/docs/configuration.md +2 -0
  6. package/.agents/schemas/agentrc.schema.json +15 -1
  7. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  8. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  9. package/.agents/scripts/audit-to-stories.js +158 -7
  10. package/.agents/scripts/check-audit-attribution.js +119 -62
  11. package/.agents/scripts/check-test-portability.js +512 -0
  12. package/.agents/scripts/coverage-capture.js +17 -10
  13. package/.agents/scripts/evidence-gate.js +31 -4
  14. package/.agents/scripts/generate-workflows-doc.js +65 -14
  15. package/.agents/scripts/git-cleanup.js +4 -0
  16. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  17. package/.agents/scripts/lib/audit-advisories.js +195 -0
  18. package/.agents/scripts/lib/audit-attribution.js +22 -0
  19. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
  20. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  21. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
  22. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  23. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  24. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  25. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  26. package/.agents/scripts/lib/cli-args.js +26 -0
  27. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  28. package/.agents/scripts/lib/close-validation/process.js +7 -3
  29. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  30. package/.agents/scripts/lib/config/ci.js +28 -9
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  32. package/.agents/scripts/lib/config-settings-schema.js +19 -1
  33. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  34. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  35. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  36. package/.agents/scripts/lib/coverage-capture.js +77 -3
  37. package/.agents/scripts/lib/findings/route-finding.js +4 -2
  38. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  39. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  40. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  41. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  42. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  43. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  44. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  45. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  46. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  47. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  48. package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
  49. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  50. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  51. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  52. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  53. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  54. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  55. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  56. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  57. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  58. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  64. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  65. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  67. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  68. package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
  69. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  70. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  73. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  75. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  77. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  78. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  79. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
  80. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  81. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  82. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  83. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  84. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  85. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  86. package/.agents/scripts/lib/test-temp.js +167 -30
  87. package/.agents/scripts/lib/validation-evidence.js +37 -0
  88. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  91. package/.agents/scripts/merge-baseline.js +175 -21
  92. package/.agents/scripts/providers/github/errors.js +22 -1
  93. package/.agents/scripts/providers/github/issues.js +106 -1
  94. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  95. package/.agents/scripts/providers/github.js +6 -0
  96. package/.agents/scripts/resolve-stories.js +44 -34
  97. package/.agents/scripts/single-story-close.js +5 -0
  98. package/.agents/scripts/stories-wave-tick.js +37 -13
  99. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  100. package/.agents/workflows/audit-accessibility.md +16 -31
  101. package/.agents/workflows/audit-mobile.md +20 -37
  102. package/.agents/workflows/git-cleanup.md +17 -3
  103. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  104. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  105. package/.agents/workflows/helpers/deliver-reference.md +35 -14
  106. package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
  107. package/.agents/workflows/helpers/deliver-story.md +15 -12
  108. package/.agents/workflows/helpers/plan-reference.md +7 -0
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +27 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. package/package.json +7 -4
@@ -48,20 +48,19 @@ the step-by-step. This shared core binds every role:
48
48
  You are a **Story delivery worker**: you take one Story from init through
49
49
  implementation to a **pushed branch**, then return. You do **not** close it —
50
50
  your caller owns the close-and-land tail. Follow the `helpers/deliver-story`
51
- prose your caller hands you; this delta states the non-negotiable
52
- MUSTs. Treat a blocking tool-permission prompt as a harness condition —
53
- flip to `agent::blocked` rather than waiting on an approval that cannot
54
- come.
51
+ prose your caller hands you; this delta states the MUSTs. Treat a
52
+ blocking tool-permission prompt as a harness condition — flip to
53
+ `agent::blocked` rather than waiting on an approval that cannot come.
55
54
 
56
55
  ## Worktree discipline (MUST)
57
56
 
58
57
  1. Initialize with
59
58
  `node .agents/scripts/single-story-init.js --story <storyId>` from the
60
- **main checkout**, synchronously at max Bash timeout — a per-worktree
61
- install can take minutes; do not background it.
59
+ **main checkout**, synchronously at max Bash timeout — the install
60
+ can take minutes; never background it.
62
61
  2. Capture `workCwd` and `dependenciesInstalled` from the envelope.
63
- Work only inside the absolute `workCwd`; never move the main checkout's
64
- HEAD. cwd may reset between calls, so anchor every path at `workCwd`.
62
+ Anchor every path at the absolute `workCwd` and work only there; never
63
+ move the main checkout HEAD.
65
64
 
66
65
  ## Verify branch before every commit (MUST)
67
66
 
@@ -81,8 +80,8 @@ follow-up commit, never amend.
81
80
  ## Docs context — digest first
82
81
 
83
82
  Do **not** re-read every file in `project.docsContextFiles`. Read the
84
- `docsDigestPath` digest your caller passes, then pull files on demand at
85
- the lines it names. A null `docsDigestPath` means no mandate.
83
+ digest your caller passes, then pull files on demand at the lines it
84
+ names. A null digest path means no docs mandate.
86
85
 
87
86
  ## Close gates — one credited run
88
87
 
@@ -90,7 +89,7 @@ the lines it names. A null `docsDigestPath` means no mandate.
90
89
  (**typecheck, lint, test, format, maintainability, coverage, crap**) and is
91
90
  the authoritative gate — do not pre-run it. The **one** exception is
92
91
  the full suite: run it exactly once, after the self-eval loop's last fix
93
- commit and immediately before the push, in the shape close credits. A bare
92
+ commit and immediately after the push, in the shape close credits. A bare
94
93
  `npm test` / `pnpm run test` deposits **no** credit:
95
94
 
96
95
  ```bash
@@ -102,8 +101,9 @@ node <main-repo>/.agents/scripts/evidence-gate.js --standalone \
102
101
  ```
103
102
 
104
103
  Dispatch it in the **background**: it routinely outruns the host's sync
105
- Bash ceiling, and its completion re-invokes you — that is the signal. Never spawn a task to poll or `sleep`-loop
106
- against it; a waiter whose condition is wrong outlives the agent. Share
104
+ Bash ceiling, and its completion re-invokes you — which is why the push
105
+ comes first. Never spawn a task to poll or `sleep`-loop
106
+ against it; a waiter with a wrong condition outlives the agent. Share
107
107
  `lint` / `typecheck` evidence with close via `evidence-gate.js`; never
108
108
  stamp coverage / CRAP fresh any other way.
109
109
 
@@ -117,13 +117,12 @@ Waiter traps: [`parallel-tooling.md`](../workflows/helpers/parallel-tooling.md)
117
117
 
118
118
  ## Acceptance self-eval before close (MUST)
119
119
 
120
- After the implementation commits land and **before** flipping to `closing`,
121
- run the bounded acceptance self-eval loop
120
+ **Before** flipping to `closing`, run the bounded self-eval loop
122
121
  ([`acceptance-self-eval.md`](../workflows/helpers/acceptance-self-eval.md)).
123
122
  It scores the change set you computed **once** and injected into the critic
124
123
  — never one it re-derives — against each `acceptance[]` item,
125
124
  consuming `verify[]` output as evidence. **proceed** → flip to `closing`,
126
- push, hand off; **redraft** → fix the flagged criteria, commit, re-eval;
125
+ push, capture, hand off; **redraft** → fix the criteria, commit, re-eval;
127
126
  **block** → take the blocked path below. Never hand off an unscored
128
127
  branch.
129
128
 
@@ -141,20 +140,22 @@ branch.
141
140
 
142
141
  The init envelope carries `remoteVerified` + `remoteProbe`. When
143
142
  `remoteVerified` is `false`, flip to `agent::blocked` quoting
144
- `remoteProbe.detail` and stop. A PR opened by
145
- `single-story-close.js` is the only sanctioned landing.
143
+ `remoteProbe.detail` and stop. A PR opened by `single-story-close.js` is
144
+ the only sanctioned landing.
146
145
 
147
146
  ## Your turn ends at a pushed branch (MUST)
148
147
 
149
148
  You do **not** run close. Push `story-<storyId>` to `origin` — confirming
150
- the remote ref moved — and return. The dispatching orchestrator runs
149
+ the remote ref moved — **before** the credited capture, then return: the
150
+ capture is backgrounded, so its completion ends your turn, and a turn that
151
+ ends unpushed reads as unfinished work. The orchestrator runs
151
152
  `single-story-close.js` in its own session, serialized against your
152
- siblings. Do not open the PR, flip `agent::done`, or spawn a child to close
153
- on your behalf. If the push fails, take the blocked path above.
153
+ siblings. Do not open the PR, flip `agent::done`, or spawn a child to
154
+ close for you. If the push fails, take the blocked path above.
154
155
 
155
156
  ## Return contract — the hand-off report
156
157
 
157
158
  A short, literal hand-off your caller can act on: Story id, `workCwd`,
158
- branch, pushed head SHA, self-eval verdict, `verify[]` evidence. Say plainly
159
- the branch is pushed and unclosed. Never hand-compose a terminal envelope —
159
+ branch, pushed head SHA, self-eval verdict, `verify[]` evidence. Say the
160
+ branch is pushed and unclosed. Never hand-compose a terminal envelope —
160
161
  inventing one makes an unlanded Story look landed.
@@ -24,7 +24,4 @@ Self-check your change against this lens's concerns before you ship:
24
24
  - [ ] Media alternatives
25
25
  - [ ] Contrast where statically derivable
26
26
  - [ ] Raw-element census
27
- - [ ] Resolve the target from config — never a hardcoded URL.
28
- - [ ] Sample routes from the navigability SSOT.
29
27
  - [ ] Run an accessibility engine per sampled route.
30
- - [ ] Median-of-3 or provisional.
@@ -28,8 +28,4 @@ Self-check your change against this lens's concerns before you ship:
28
28
  - [ ] Typography and spacing
29
29
  - [ ] Coverage — is anything exercised at a non-desktop viewport?
30
30
  - [ ] Effectiveness — does that exercise assert anything mobile-specific?
31
- - [ ] Resolve the target from config — never a hardcoded URL.
32
- - [ ] Sample routes from the navigability SSOT.
33
31
  - [ ] Drive two form factors per route.
34
- - [ ] Median-of-3 or provisional.
35
- - [ ] Leave the viewport as you found it.
@@ -75,7 +75,8 @@
75
75
  ],
76
76
  "memoryPool": {
77
77
  "staleAfterDays": 30,
78
- "growthDelta": 25
78
+ "growthDelta": 25,
79
+ "indexByteCeiling": 24576
79
80
  },
80
81
  "failOnSharedEditors": false,
81
82
  "requireExplicitCrossStoryDeps": false,
@@ -353,7 +354,8 @@
353
354
  },
354
355
  "autoMerge": "trust-ci",
355
356
  "blockOnAdvisoryFailure": true,
356
- "advisoryAllowlist": []
357
+ "advisoryAllowlist": [],
358
+ "rerunAdvisory": 0
357
359
  },
358
360
  "routing": {
359
361
  "roleScopedAgents": true,
@@ -130,6 +130,7 @@ Inputs to `/mandrel-plan`: risk escalation heuristics, ceremony-lite routing, th
130
130
  | `memoryPool` | No | `object` | — | Thresholds for the memory-hygiene advisory `/mandrel-plan` surfaces at Gate #1. Advisory only: it recommends `/memory-consolidate` and never gates, reroutes, or mutates the memory pool. |
131
131
  | `memoryPool.staleAfterDays` | No | `integer` | `30` | Recommend a consolidation pass once the pool's stamp is older than this many days. Default 30. |
132
132
  | `memoryPool.growthDelta` | No | `integer` | `25` | Recommend a consolidation pass once this many entries have been written since the last one. Measured against the entry count the last pass stamped, so a stamp predating that field leaves growth unmeasured and only the age threshold applies. Default 25. |
133
+ | `memoryPool.indexByteCeiling` | No | `integer` | `24576` | Recommend a consolidation pass once the pool's `MEMORY.md` index exceeds this many bytes. Independent of the age and growth thresholds: the harness truncates the index it loads into each session at its own byte cap, so an oversized index is a loss already happening — every entry listed after the cut is invisible — rather than a hygiene forecast. Default 24576, the harness cap itself. |
133
134
  | `failOnSharedEditors` | No | `boolean` | `false` | When true, upgrade shared-editor conflict findings to hard errors (default false — advisory soft findings only). |
134
135
  | `requireExplicitCrossStoryDeps` | No | `boolean` | `false` | When true, upgrade implicit cross-Story dependency findings to hard errors (default false — advisory soft findings only). |
135
136
  | `crossCuttingRegistries` | No | `string[]` or `{ append?, prepend? }` | `["lib/orchestration/lifecycle/listeners/index.js","**/listeners/index.js","**/handlers/index.js"]` | Registry path patterns whose concurrent edits across Stories are flagged as conflicts. Defaults to the framework listener/handler index patterns when omitted. |
@@ -330,6 +331,7 @@ Everything `/mandrel-deliver` and `single-story-close` consume: execution timeou
330
331
  | `ci.autoMerge` | No | `"trust-ci"` \| `"strict"` | `"trust-ci"` | Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate. |
331
332
  | `ci.blockOnAdvisoryFailure` | No | `boolean` | `true` | Story #5096. When true (default), delivery refuses to arm — and disarms — GitHub native auto-merge while a non-required (advisory) check is genuinely red on the PR head and GitHub reports the PR mergeable anyway (mergeStateStatus=UNSTABLE). `--auto` waits on REQUIRED contexts only, so without this a red advisory quality gate merges unattended. Set false to restore the pre-#5096 behaviour verbatim. |
332
333
  | `ci.advisoryAllowlist` | No | `array<string>` | `[]` | Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks. |
334
+ | `ci.rerunAdvisory` | No | `integer` | `0` | Story #5266. How many times close may re-run a failed advisory workflow run before blocking on it, per close invocation. Default 0: close spends no CI minutes and issues no GitHub mutation on an advisory red unless asked. At n > 0 the failed run(s) are re-run within that allowance and the merge wait re-polls inside its existing budget, landing or blocking on the re-run verdict. Overridden per invocation by --rerun-advisory <n>. |
333
335
  | `routing` | No | `object` | — | v2 delivery-spawn routing: role-scoped boot contexts and maker-checker sampling. The v1 singleDelivery epic-route kill-switch was removed in Stage 6. |
334
336
  | `routing.roleScopedAgents` | No | `boolean` | `true` | Epic #4478 (M7-B). Kill-switch for the role-scoped boot contexts. When true (default), a converted delivery spawn (`story-worker`, `acceptance-critic`) boots on its own `.claude/agents/<role>.md` system prompt instead of re-paying the full CLAUDE.md @-import closure. When false, every converted spawn falls back to `subagent_type: general-purpose` — the instant, code-rollback-free per-consumer revert, and the universal escape for hosts that ignore `.claude/agents/`. The fallback is the full-closure agent that ran before M7-B, so flipping it off never drops a gate. |
335
337
  | `routing.freshCriticSampleRate` | No | `number` | `0.2` | Epic #4478 (M7-B, Part 2). Maker-checker sampling floor. Under the standard profile, a change set touching no sensitive path routes its acceptance clusters down the contract-identical inline critic path, but this fraction of them is still forced through a fresh-context critic so a low derived level never means zero independent checking. Clamped to [0, 1]; 0 disables the floor, 1 forces every cluster fresh. Consumed by resolveCeremonyForRisk (lib/orchestration/ceremony-routing.js). |
@@ -377,6 +377,12 @@
377
377
  "minimum": 1,
378
378
  "description": "Recommend a consolidation pass once this many entries have been written since the last one. Measured against the entry count the last pass stamped, so a stamp predating that field leaves growth unmeasured and only the age threshold applies. Default 25.",
379
379
  "default": 25
380
+ },
381
+ "indexByteCeiling": {
382
+ "type": "integer",
383
+ "minimum": 1,
384
+ "description": "Recommend a consolidation pass once the pool's `MEMORY.md` index exceeds this many bytes. Independent of the age and growth thresholds: the harness truncates the index it loads into each session at its own byte cap, so an oversized index is a loss already happening — every entry listed after the cut is invisible — rather than a hygiene forecast. Default 24576, the harness cap itself.",
385
+ "default": 24576
380
386
  }
381
387
  },
382
388
  "additionalProperties": false
@@ -1975,6 +1981,12 @@
1975
1981
  },
1976
1982
  "description": "Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks.",
1977
1983
  "default": []
1984
+ },
1985
+ "rerunAdvisory": {
1986
+ "type": "integer",
1987
+ "minimum": 0,
1988
+ "description": "Story #5266. How many times close may re-run a failed advisory workflow run before blocking on it, per close invocation. Default 0: close spends no CI minutes and issues no GitHub mutation on an advisory red unless asked. At n > 0 the failed run(s) are re-run within that allowance and the merge wait re-polls inside its existing budget, landing or blocking on the re-run verdict. Overridden per invocation by --rerun-advisory <n>.",
1989
+ "default": 0
1978
1990
  }
1979
1991
  },
1980
1992
  "additionalProperties": false
@@ -2084,7 +2096,9 @@
2084
2096
  "not": {
2085
2097
  "pattern": "([;&|`]|\\$\\()"
2086
2098
  },
2087
- "minLength": 1
2099
+ "minLength": 1,
2100
+ "pattern": "^[a-z0-9][a-z0-9._-]*(?:\\/[a-z0-9][a-z0-9._-]*)+$",
2101
+ "description": "Tier-relative skill id, e.g. `stack/qa/acme-sso`: lowercase segments of letters, digits, `.`, `_` or `-`, at least two of them, separated by `/`. A traversal (`../..`), an absolute path, a backslash or an uppercase segment is rejected here rather than normalized."
2088
2102
  }
2089
2103
  },
2090
2104
  "required": ["skill"],
@@ -30,7 +30,8 @@
30
30
  "arm-failure",
31
31
  "api-race-other",
32
32
  "predicate-refused",
33
- "advisory-gate-red"
33
+ "advisory-gate-red",
34
+ "advisory-gate-inconclusive"
34
35
  ]
35
36
  },
36
37
  "reason": { "type": "string", "minLength": 1 },
@@ -131,6 +131,7 @@
131
131
  "api-race-other",
132
132
  "predicate-refused",
133
133
  "advisory-gate-red",
134
+ "advisory-gate-inconclusive",
134
135
  "merged-flip-failed"
135
136
  ]
136
137
  },
@@ -50,13 +50,17 @@ import {
50
50
  } from './lib/audit-to-stories/ledger-commit.js';
51
51
  import {
52
52
  parseAuditReports,
53
- parseSeverityTally,
53
+ readSeverityTally,
54
54
  } from './lib/audit-to-stories/parse-audit-md.js';
55
55
  import { buildPlanSeedMarkdown } from './lib/audit-to-stories/seed-from-findings.js';
56
56
  import { wireAuditStoryEdges } from './lib/audit-to-stories/wire-dependencies.js';
57
57
  import { runAsCli } from './lib/cli-utils.js';
58
58
  import { searchSemanticCandidates } from './lib/findings/semantic-issue-search.js';
59
- import { SEVERITIES, SEVERITY_RANK } from './lib/findings/severity.js';
59
+ import {
60
+ normalizeSeverity,
61
+ SEVERITIES,
62
+ SEVERITY_RANK,
63
+ } from './lib/findings/severity.js';
60
64
  import { Logger } from './lib/Logger.js';
61
65
  import { parse as parseStoryBody } from './lib/story-body/story-body.js';
62
66
 
@@ -209,6 +213,11 @@ function auditReportFailures({
209
213
  * hand-written report). `allowMissingTally` downgrades ONLY this kind to a
210
214
  * warning, for an interactive `--scan` over legacy reports.
211
215
  * - `tally-mismatch` — the report says one thing and the parse says another.
216
+ * - `duplicate-tally` — the report declares the tally more than once, so there
217
+ * is no single number to check against. Adopting whichever line the scan
218
+ * reached first would compare the parse against an arbitrary one of two
219
+ * declarations, which is a cross-check in name only. `allowMissingTally`
220
+ * does NOT downgrade it: the report is contradictory, not merely old.
212
221
  * - `unresolved-severity` — a finding parsed with no resolvable severity. It
213
222
  * is a report defect, never an `unknown` group.
214
223
  *
@@ -232,7 +241,11 @@ function crossCheckReports({ reports, findings, allowMissingTally }) {
232
241
  for (const report of reports) {
233
242
  const own = byReport.get(report.sourceReport) ?? [];
234
243
  const parsed = comparableTally(own);
235
- const reported = parseSeverityTally(report.markdown);
244
+ const {
245
+ tally: reported,
246
+ matches: tallyLines,
247
+ duplicate,
248
+ } = readSeverityTally(report.markdown);
236
249
  const sourceReport = report.sourceReport;
237
250
  const unresolved = own.filter((f) => !f.severity);
238
251
  if (unresolved.length > 0) {
@@ -244,6 +257,16 @@ function crossCheckReports({ reports, findings, allowMissingTally }) {
244
257
  titles: unresolved.map((f) => f.title),
245
258
  });
246
259
  }
260
+ if (duplicate) {
261
+ failures.push({
262
+ sourceReport,
263
+ kind: 'duplicate-tally',
264
+ reported: null,
265
+ parsed,
266
+ tallyLines,
267
+ });
268
+ continue;
269
+ }
247
270
  if (!reported) {
248
271
  const failure = {
249
272
  sourceReport,
@@ -282,7 +305,7 @@ function missingTallyWarning(failure) {
282
305
  *
283
306
  * Pure: returns the message string so the caller owns the single `Logger.warn`.
284
307
  *
285
- * @param {Array<{ sourceReport: string, kind: string, reported: object|null, parsed: object, titles?: string[] }>} failures
308
+ * @param {Array<{ sourceReport: string, kind: string, reported: object|null, parsed: object, titles?: string[], tallyLines?: string[] }>} failures
286
309
  * @returns {string}
287
310
  */
288
311
  function reportFailureWarning(failures) {
@@ -290,7 +313,13 @@ function reportFailureWarning(failures) {
290
313
  const titles = f.titles?.length
291
314
  ? ` findings=${f.titles.map((t) => `"${t}"`).join(', ')}`
292
315
  : '';
293
- return ` - ${f.sourceReport} [${f.kind}] reported=${formatTally(f.reported)} parsed=${formatTally(f.parsed)}${titles}`;
316
+ // A duplicate names the competing lines rather than a tally: there is no
317
+ // single `reported` number to print, and "which two lines" is the whole
318
+ // remedy.
319
+ const declared = f.tallyLines?.length
320
+ ? ` declared=${f.tallyLines.map((t) => `"${t}"`).join(' | ')}`
321
+ : '';
322
+ return ` - ${f.sourceReport} [${f.kind}] reported=${formatTally(f.reported)} parsed=${formatTally(f.parsed)}${titles}${declared}`;
294
323
  });
295
324
  return [
296
325
  `audit report cross-check FAILED for ${failures.length} report(s) — the declared severity tally does not match the parsed findings:`,
@@ -359,6 +388,34 @@ function normaliseIssueHit(hit) {
359
388
  };
360
389
  }
361
390
 
391
+ /**
392
+ * Walk the list endpoint once per label and merge the pages into one
393
+ * deduplicated, normalised issue list.
394
+ *
395
+ * `labels` is an OR across the run's lenses, which the REST list endpoint
396
+ * cannot express in one query (its `labels` parameter is an AND), so one call
397
+ * per label is the narrowest honest read. Each is a paginated **list**, not a
398
+ * search — a different, far larger rate-limit budget.
399
+ *
400
+ * @param {object} provider
401
+ * @param {string[]} labels
402
+ * @returns {Promise<Array<object>>}
403
+ */
404
+ async function listIssuesForLabels(provider, labels) {
405
+ const seen = new Map();
406
+ for (const label of labels) {
407
+ const issues = await provider.listIssuesByLabel({
408
+ state: 'all',
409
+ labels: label,
410
+ });
411
+ for (const raw of issues ?? []) {
412
+ const hit = normaliseIssueHit(raw);
413
+ if (!seen.has(hit.number)) seen.set(hit.number, hit);
414
+ }
415
+ }
416
+ return [...seen.values()];
417
+ }
418
+
362
419
  /**
363
420
  * The two read ports the dedupe module consumes, adapted off the provider's
364
421
  * one full-text `searchIssues` call: `findIssuesByFingerprint(sha)` for the
@@ -368,7 +425,8 @@ function normaliseIssueHit(hit) {
368
425
  *
369
426
  * @param {object} provider
370
427
  * @param {{ owner: string, repo: string }} coords
371
- * @returns {{ findIssuesByFingerprint: Function, searchCandidates: Function }}
428
+ * @returns {{ findIssuesByFingerprint: Function, listAuditIssues: Function,
429
+ * searchCandidates: Function }}
372
430
  */
373
431
  function buildDedupPorts(provider, { owner, repo }) {
374
432
  return {
@@ -376,6 +434,21 @@ function buildDedupPorts(provider, { owner, repo }) {
376
434
  const hits = await provider.searchIssues({ query: sha, owner, repo });
377
435
  return (hits ?? []).map(normaliseIssueHit);
378
436
  },
437
+ /**
438
+ * List every Issue carrying one of the run's `audit::*` labels, once, off
439
+ * the REST list endpoint. The dedup module indexes the result and answers
440
+ * every exact-fingerprint lookup from it, so the rate-limited search API is
441
+ * spent only on findings it has never seen. A provider without the list
442
+ * port yields `null`, which returns dedup to the per-finding search path
443
+ * rather than silently skipping it.
444
+ *
445
+ * @param {string[]} labels
446
+ * @returns {Promise<Array<object>|null>}
447
+ */
448
+ async listAuditIssues(labels) {
449
+ if (typeof provider.listIssuesByLabel !== 'function') return null;
450
+ return listIssuesForLabels(provider, labels);
451
+ },
379
452
  async searchCandidates(finding) {
380
453
  // Wire the shared semantic search onto the provider's full-text
381
454
  // issue search (open + closed) so route-finding's Stage-1 pass runs.
@@ -667,6 +740,7 @@ async function buildPlan(
667
740
  groups,
668
741
  provider,
669
742
  searchCandidates: provider.searchCandidates,
743
+ listAuditIssues: provider.listAuditIssues,
670
744
  });
671
745
  classifications = result.classifications;
672
746
  summary = result.summary;
@@ -1117,6 +1191,73 @@ export const __testing = {
1117
1191
  * }} [deps]
1118
1192
  * @returns {Promise<void>}
1119
1193
  */
1194
+ /**
1195
+ * The one-line `--ledger-commit` outcome, for stderr.
1196
+ *
1197
+ * Names the branch and the PR on success — the two things an operator needs to
1198
+ * go look at it — and the skip reason otherwise, because "nothing happened" and
1199
+ * "the ledger was already clean" are different facts and only one of them is
1200
+ * fine.
1201
+ *
1202
+ * @param {{ committed?: boolean, reason?: string, branch?: string,
1203
+ * prUrl?: string|null, resumed?: boolean, ledgerPath?: string }} [result]
1204
+ * @returns {string}
1205
+ */
1206
+ function describeLedgerCommit(result) {
1207
+ return result?.committed
1208
+ ? ledgerCommittedLine(result)
1209
+ : ledgerSkippedLine(result);
1210
+ }
1211
+
1212
+ /**
1213
+ * The success half: the branch, whether it resumed a half-finished one, and
1214
+ * the PR to go look at.
1215
+ * @param {object} result
1216
+ * @returns {string}
1217
+ */
1218
+ function ledgerCommittedLine(result) {
1219
+ const resumed = result.resumed ? ' (resumed an unpushed ledger branch)' : '';
1220
+ const pr = result.prUrl ?? '(no URL reported by gh)';
1221
+ return `--ledger-commit: pushed ${result.branch}${resumed} and opened ${pr}.`;
1222
+ }
1223
+
1224
+ /**
1225
+ * The skip half. It names the ledger file, because the fact that matters is
1226
+ * which state is still only in the working tree.
1227
+ * @param {object} [result]
1228
+ * @returns {string}
1229
+ */
1230
+ function ledgerSkippedLine(result) {
1231
+ const reason = result?.reason ?? 'no result';
1232
+ const ledgerPath = result?.ledgerPath ?? 'the ledger';
1233
+ return `--ledger-commit: skipped (${reason}) — ${ledgerPath} was not committed.`;
1234
+ }
1235
+
1236
+ /**
1237
+ * Validate `--severity` against the canonical scale, failing on a value the
1238
+ * filter would silently ignore.
1239
+ *
1240
+ * `meetsSeverity` reads an unknown threshold as rank `0`, so a typo — the
1241
+ * classic being `--severity Hgh` — quietly widened the run to every finding
1242
+ * instead of narrowing it. On an unattended sweep that is the difference
1243
+ * between filing a batch and filing the backlog, with nothing on stderr to say
1244
+ * so. An absent flag stays absent: the floor then resolves from config.
1245
+ *
1246
+ * @param {string|undefined} raw
1247
+ * @returns {string|undefined} the canonical level.
1248
+ */
1249
+ function validateSeverityFlag(raw) {
1250
+ if (raw === undefined || raw === null || raw === '') return undefined;
1251
+ if (String(raw).toLowerCase() === 'all') return 'all';
1252
+ const level = normalizeSeverity(String(raw), null);
1253
+ if (!level) {
1254
+ throw new Error(
1255
+ `[audit-to-stories] --severity "${raw}" is not a severity. Accepted: ${SEVERITIES.join(', ')} (or "all").`,
1256
+ );
1257
+ }
1258
+ return level;
1259
+ }
1260
+
1120
1261
  export async function runAuditToStories(
1121
1262
  argv = process.argv.slice(2),
1122
1263
  deps = {},
@@ -1156,6 +1297,8 @@ export async function runAuditToStories(
1156
1297
  strict: false,
1157
1298
  });
1158
1299
 
1300
+ values.severity = validateSeverityFlag(values.severity);
1301
+
1159
1302
  const json = (value) => JSON.stringify(value, null, 2);
1160
1303
 
1161
1304
  const runAutoSummary = async () =>
@@ -1175,7 +1318,15 @@ export async function runAuditToStories(
1175
1318
  // findings, so the PR attempt is the last thing the run does (Story #5145).
1176
1319
  const commitLedger = async () => {
1177
1320
  if (!values['ledger-commit'] || values['dry-run']) return;
1178
- await runLedgerCommitImpl({ ledgerPath: values.ledger });
1321
+ // Say what happened. The tail used to run silently, so an operator could
1322
+ // not tell a ledger PR from a skip without going to look for the branch —
1323
+ // and a skip is the outcome that matters, because it means the sweep's
1324
+ // memory is still only in the working tree.
1325
+ Logger.warn(
1326
+ describeLedgerCommit(
1327
+ await runLedgerCommitImpl({ ledgerPath: values.ledger }),
1328
+ ),
1329
+ );
1179
1330
  };
1180
1331
 
1181
1332
  const scanPlan = () =>