mandrel 2.40.0 → 2.42.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 (90) hide show
  1. package/.agents/README.md +2 -2
  2. package/.agents/agents/story-worker.md +24 -14
  3. package/.agents/docs/agentrc-reference.json +7 -2
  4. package/.agents/docs/configuration.md +5 -2
  5. package/.agents/instructions.md +5 -6
  6. package/.agents/rules/api-conventions.md +43 -7
  7. package/.agents/rules/ci-remediation.md +3 -14
  8. package/.agents/rules/gherkin-standards.md +21 -6
  9. package/.agents/rules/git-conventions.md +6 -5
  10. package/.agents/rules/security-baseline.md +6 -7
  11. package/.agents/rules/testing-standards.md +75 -198
  12. package/.agents/schemas/agentrc.schema.json +17 -2
  13. package/.agents/schemas/validation-evidence.schema.json +3 -1
  14. package/.agents/scripts/acceptance-eval.js +68 -3
  15. package/.agents/scripts/coverage-capture.js +25 -8
  16. package/.agents/scripts/install-matrix-assert.js +2 -2
  17. package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +6 -0
  18. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +7 -2
  19. package/.agents/scripts/lib/baselines/git-base.js +74 -38
  20. package/.agents/scripts/lib/close-validation/gates.js +153 -25
  21. package/.agents/scripts/lib/close-validation/process.js +30 -1
  22. package/.agents/scripts/lib/close-validation/runner.js +5 -0
  23. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +33 -12
  24. package/.agents/scripts/lib/config/quality.js +36 -21
  25. package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
  26. package/.agents/scripts/lib/coverage-capture-incremental.js +12 -6
  27. package/.agents/scripts/lib/crap-baseline-join.js +11 -7
  28. package/.agents/scripts/lib/full-suite-lock.js +311 -0
  29. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  30. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +11 -104
  31. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +320 -0
  32. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  33. package/.agents/scripts/lib/orchestration/dependency-candidates.js +139 -0
  34. package/.agents/scripts/lib/orchestration/epic-candidates.js +159 -0
  35. package/.agents/scripts/lib/orchestration/epic-checklist.js +103 -0
  36. package/.agents/scripts/lib/orchestration/epic-container.js +18 -2
  37. package/.agents/scripts/lib/orchestration/plan-context.js +97 -36
  38. package/.agents/scripts/lib/orchestration/plan-persist/cross-plan-links.js +80 -0
  39. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +192 -0
  40. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +5 -1
  41. package/.agents/scripts/lib/orchestration/plan-persist/external-deps.js +164 -0
  42. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +14 -2
  43. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +26 -5
  44. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +83 -4
  45. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +39 -7
  46. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +70 -18
  47. package/.agents/scripts/lib/orchestration/ticket-validator.js +11 -1
  48. package/.agents/scripts/lib/orchestration/verify-credit.js +207 -0
  49. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +24 -0
  50. package/.agents/scripts/plan-persist.js +60 -1
  51. package/.agents/skills/core/browser-testing-with-devtools/SKILL.md +5 -10
  52. package/.agents/skills/core/browser-testing-with-devtools/reference.md +7 -46
  53. package/.agents/skills/core/code-review-and-quality/SKILL.md +0 -5
  54. package/.agents/skills/core/documentation-and-adrs/SKILL.md +0 -3
  55. package/.agents/skills/core/gates-and-baselines/SKILL.md +10 -137
  56. package/.agents/skills/core/gates-and-baselines/reference.md +103 -0
  57. package/.agents/skills/core/idea-refinement/SKILL.md +2 -18
  58. package/.agents/skills/core/security-and-hardening/SKILL.md +2 -4
  59. package/.agents/skills/core/security-and-hardening/reference.md +0 -70
  60. package/.agents/skills/skills.index.json +10 -70
  61. package/.agents/skills/stack/qa/gherkin-authoring/SKILL.md +4 -10
  62. package/.agents/skills/stack/qa/gherkin-authoring/reference.md +9 -113
  63. package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +29 -154
  64. package/.agents/skills/stack/qa/qa-harness/SKILL.md +157 -98
  65. package/.agents/workflows/git-cleanup.md +3 -2
  66. package/.agents/workflows/git-deliver.md +3 -2
  67. package/.agents/workflows/helpers/acceptance-self-eval.md +12 -0
  68. package/.agents/workflows/helpers/deliver-digest.md +31 -10
  69. package/.agents/workflows/helpers/deliver-story-reference.md +50 -30
  70. package/.agents/workflows/helpers/deliver-story.md +23 -21
  71. package/.agents/workflows/helpers/plan-reference.md +82 -2
  72. package/.agents/workflows/mandrel-plan.md +45 -45
  73. package/.agents/workflows/qa-assist.md +20 -17
  74. package/.agents/workflows/qa-explore.md +30 -29
  75. package/.agents/workflows/qa-run.md +2 -1
  76. package/docs/CHANGELOG.md +32 -0
  77. package/package.json +1 -1
  78. package/.agents/rules/changelog-style.md +0 -180
  79. package/.agents/rules/shell-conventions.md +0 -61
  80. package/.agents/scripts/lib/qa/coverage-verdict.js +0 -214
  81. package/.agents/skills/core/api-and-interface-design/SKILL.md +0 -55
  82. package/.agents/skills/core/api-and-interface-design/reference.md +0 -76
  83. package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +0 -45
  84. package/.agents/skills/core/debugging-and-error-recovery/reference.md +0 -56
  85. package/.agents/skills/core/git-workflow-and-versioning/SKILL.md +0 -54
  86. package/.agents/skills/core/idea-refinement/refinement-criteria.md +0 -155
  87. package/.agents/skills/core/idea-refinement/scripts/idea-refine.sh +0 -15
  88. package/.agents/skills/core/qa-coverage-mapping/SKILL.md +0 -105
  89. package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +0 -152
  90. package/.agents/skills/stack/qa/vitest/SKILL.md +0 -22
@@ -7,22 +7,16 @@
7
7
  * @module lib/orchestration/check-baselines/phases/evaluate
8
8
  */
9
9
 
10
- import { resolveKindRefreshOverrides } from '../../../baselines/env-overrides.js';
11
- import { readRangeSubjectsTouchingFile } from '../../../baselines/git-base.js';
12
10
  import {
13
11
  checkBaselineSemantics,
14
12
  checkKernelVersion,
15
13
  getKindModule,
16
14
  } from '../../../baselines/kernel.js';
17
15
  import * as reader from '../../../baselines/reader.js';
18
- import { Logger } from '../../../Logger.js';
19
16
  import { isIgnoredByGlobs } from '../../../maintainability-utils.js';
20
17
  import { applyTolerance, evaluateCompare, runCompareStage } from './compare.js';
21
18
  import { applyFloors, flattenBreaches } from './floors.js';
22
- import { DEFAULT_BASELINE_PATHS } from './parse-args.js';
23
-
24
- /** Default refresh-tag substring when the gate omits `refreshTag`. */
25
- const DEFAULT_REFRESH_TAG = 'baseline-refresh:';
19
+ import { applyRefreshAcknowledgment } from './refresh-ack.js';
26
20
 
27
21
  /**
28
22
  * Defense-in-depth against an `ignoreGlobs`-poisoned baseline (Epic #4326
@@ -87,97 +81,6 @@ function loadHeadBaseline(kind, cwd, configPath) {
87
81
  }
88
82
  }
89
83
 
90
- /**
91
- * Resolve the one-shot refresh trigger for any ratcheted kind (Story #4802,
92
- * generalizing Story #151's bundle-size env flag and Story #4731's
93
- * maintainability env-or-commit-tag pair). Two paths, either of which
94
- * acknowledges:
95
- *
96
- * 1. Env parity: `<KIND>_REFRESH=1` (the manual override) — upper-snaked,
97
- * so the two pre-existing names (`BUNDLE_SIZE_REFRESH`,
98
- * `MAINTAINABILITY_REFRESH`) keep working unchanged.
99
- * 2. Commit tag: a commit in the compared range `<baseRef>..HEAD` whose
100
- * subject contains the configured `refreshTag` AND whose diff touches
101
- * that kind's baseline file. One-shot by construction — once merged, the
102
- * refreshed baseline becomes the base and the tag leaves the range.
103
- *
104
- * The tag is matched as a plain substring of a conventional commit subject, so
105
- * commitlint stays satisfied (e.g. `chore(baselines): baseline-refresh: …`).
106
- *
107
- * Fails closed: a kind whose baseline path is neither configured nor present
108
- * in `DEFAULT_BASELINE_PATHS` simply skips the commit-tag path rather than
109
- * throwing, leaving the run un-acknowledged.
110
- *
111
- * @returns {{ triggered: boolean, reasons: string[] }}
112
- */
113
- function resolveRefreshTrigger({ kind, gateBlock, cmp, cwd, env }) {
114
- const reasons = [];
115
- const { acknowledged: envAck, overrides } = resolveKindRefreshOverrides(
116
- kind,
117
- env,
118
- );
119
- if (envAck) reasons.push(...overrides);
120
-
121
- const baseRef = cmp?.baseRef ?? null;
122
- if (baseRef) {
123
- const refreshTag =
124
- typeof gateBlock?.refreshTag === 'string' && gateBlock.refreshTag.length
125
- ? gateBlock.refreshTag
126
- : DEFAULT_REFRESH_TAG;
127
- const baselinePath =
128
- typeof gateBlock?.baselinePath === 'string' &&
129
- gateBlock.baselinePath.length
130
- ? gateBlock.baselinePath
131
- : DEFAULT_BASELINE_PATHS[kind];
132
- if (typeof baselinePath === 'string' && baselinePath.length) {
133
- const subjects = readRangeSubjectsTouchingFile(baseRef, baselinePath, {
134
- cwd,
135
- });
136
- const match = subjects.find((s) => s.includes(refreshTag));
137
- if (match) {
138
- reasons.push(
139
- `refresh commit "${match}" (subject contains ${JSON.stringify(refreshTag)}, touches ${baselinePath})`,
140
- );
141
- }
142
- }
143
- }
144
-
145
- return { triggered: reasons.length > 0, reasons };
146
- }
147
-
148
- /**
149
- * One-shot baseline refresh/acknowledge for any ratcheted kind (Story #4802).
150
- * When triggered (env flag OR a `baseline-refresh:`-tagged range commit
151
- * touching that kind's baseline), demote every head-vs-base regression to
152
- * `unchanged` for this run only — floors still apply, so a row below its floor
153
- * still breaches. The trigger is read fresh every run and never persisted:
154
- * post-merge the refreshed baseline is the new base and the tag leaves the
155
- * range, so the ratchet returns to full strength automatically.
156
- *
157
- * A no-op absent a trigger, so an unacknowledged run of any kind reports its
158
- * regressions exactly as before.
159
- */
160
- function applyRefreshAcknowledgment(kind, compareOutput, ctx) {
161
- const { triggered, reasons } = resolveRefreshTrigger({ ...ctx, kind });
162
- if (!triggered || compareOutput.regressions.length === 0) {
163
- return { compareOutput, acknowledged: false };
164
- }
165
- Logger.warn(
166
- `[${kind}] ⚠ ${reasons.join('; ')} — ` +
167
- `${compareOutput.regressions.length} regression(s) acknowledged for this run only; ` +
168
- 'floors still enforced. This does not persist: once the refresh is the ' +
169
- 'new base the ratchet re-enforces at full strength.',
170
- );
171
- return {
172
- acknowledged: true,
173
- compareOutput: {
174
- ...compareOutput,
175
- regressions: [],
176
- unchanged: [...compareOutput.unchanged, ...compareOutput.regressions],
177
- },
178
- };
179
- }
180
-
181
84
  function buildGateReport({
182
85
  kind,
183
86
  gateBlock,
@@ -186,7 +89,7 @@ function buildGateReport({
186
89
  breaches,
187
90
  compareOutput,
188
91
  cmp,
189
- acknowledged,
92
+ ack,
190
93
  }) {
191
94
  const kernel = checkKernelVersion(kind, baseline.kernelVersion);
192
95
  return {
@@ -212,7 +115,12 @@ function buildGateReport({
212
115
  // the JSON report alone.
213
116
  baseRead: cmp.baseRead === true,
214
117
  generatedAt: baseline.generatedAt,
215
- acknowledged,
118
+ acknowledged: ack.acknowledged,
119
+ // Story #5179 — `acknowledged` is now a PARTIAL statement: a refresh commit
120
+ // clears only the rows it actually refreshed, so a run can acknowledge some
121
+ // regressions and still fail on others. Naming the acknowledged keys makes
122
+ // which-half-was-which readable from the JSON report without re-walking git.
123
+ acknowledgedKeys: ack.acknowledgedKeys,
216
124
  };
217
125
  }
218
126
 
@@ -258,17 +166,16 @@ export async function evaluateKind({
258
166
  cmp,
259
167
  cwd,
260
168
  env,
169
+ headBaseline: baseline,
261
170
  });
262
- const compareOutput = ack.compareOutput;
263
- const acknowledged = ack.acknowledged;
264
171
  return buildGateReport({
265
172
  kind,
266
173
  gateBlock,
267
174
  baseline,
268
175
  findings,
269
176
  breaches,
270
- compareOutput,
177
+ compareOutput: ack.compareOutput,
271
178
  cmp,
272
- acknowledged,
179
+ ack,
273
180
  });
274
181
  }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * refresh-ack.js — the one-shot baseline refresh acknowledgment (Story #5179).
3
+ *
4
+ * Extracted from `evaluate.js`, where it grew from a bundle-size env flag
5
+ * (Story #151) through a maintainability env-or-commit-tag pair (Story #4731)
6
+ * into the kind-generic trigger (Story #4802). Story #5179 narrowed the
7
+ * commit-tag arm from a whole-run blanket to the rows the tagged commit
8
+ * actually refreshed, which is enough logic to own its own module.
9
+ *
10
+ * @module lib/orchestration/check-baselines/phases/refresh-ack
11
+ */
12
+
13
+ import { resolveKindRefreshOverrides } from '../../../baselines/env-overrides.js';
14
+ import {
15
+ readBaseFromGit,
16
+ readRangeCommitsTouchingFile,
17
+ } from '../../../baselines/git-base.js';
18
+ import { getKindModule } from '../../../baselines/kernel.js';
19
+ import { Logger } from '../../../Logger.js';
20
+ import { applyTolerance } from './compare.js';
21
+ import { DEFAULT_BASELINE_PATHS } from './parse-args.js';
22
+
23
+ /** Default refresh-tag substring when the gate omits `refreshTag`. */
24
+ const DEFAULT_REFRESH_TAG = 'baseline-refresh:';
25
+
26
+ function resolveBaselinePath(kind, gateBlock) {
27
+ const configured =
28
+ typeof gateBlock?.baselinePath === 'string' && gateBlock.baselinePath.length
29
+ ? gateBlock.baselinePath
30
+ : null;
31
+ return configured ?? DEFAULT_BASELINE_PATHS[kind] ?? null;
32
+ }
33
+
34
+ function resolveRefreshTag(gateBlock) {
35
+ return typeof gateBlock?.refreshTag === 'string' &&
36
+ gateBlock.refreshTag.length
37
+ ? gateBlock.refreshTag
38
+ : DEFAULT_REFRESH_TAG;
39
+ }
40
+
41
+ /**
42
+ * Resolve the one-shot refresh trigger for any ratcheted kind. Two paths, either
43
+ * of which acknowledges:
44
+ *
45
+ * 1. Env parity: `<KIND>_REFRESH=1` (the manual override) — upper-snaked, so
46
+ * the two pre-existing names (`BUNDLE_SIZE_REFRESH`,
47
+ * `MAINTAINABILITY_REFRESH`) keep working unchanged.
48
+ * 2. Commit tag: a commit in the compared range `<baseRef>..HEAD` whose
49
+ * subject contains the configured `refreshTag` AND whose diff touches that
50
+ * kind's baseline file. One-shot by construction — once merged, the
51
+ * refreshed baseline becomes the base and the tag leaves the range.
52
+ *
53
+ * The tag is matched as a plain substring of a conventional commit subject, so
54
+ * commitlint stays satisfied (e.g. `chore(baselines): baseline-refresh: …`).
55
+ *
56
+ * The two arms are reported separately because Story #5179 scopes them
57
+ * differently: see `applyRefreshAcknowledgment`.
58
+ *
59
+ * Fails closed: a kind whose baseline path is neither configured nor present in
60
+ * `DEFAULT_BASELINE_PATHS` simply skips the commit-tag path rather than
61
+ * throwing, leaving that arm un-acknowledged.
62
+ *
63
+ * @returns {{ triggered: boolean, reasons: string[], envAcknowledged: boolean,
64
+ * refreshCommits: { sha: string, subject: string }[], baselinePath: string | null }}
65
+ */
66
+ function resolveRefreshTrigger({ kind, gateBlock, cmp, cwd, env }) {
67
+ const reasons = [];
68
+ const { acknowledged: envAcknowledged, overrides } =
69
+ resolveKindRefreshOverrides(kind, env);
70
+ if (envAcknowledged) reasons.push(...overrides);
71
+
72
+ const baselinePath = resolveBaselinePath(kind, gateBlock);
73
+ const refreshCommits = [];
74
+ const baseRef = cmp?.baseRef ?? null;
75
+ if (baseRef && typeof baselinePath === 'string' && baselinePath.length) {
76
+ const refreshTag = resolveRefreshTag(gateBlock);
77
+ const commits = readRangeCommitsTouchingFile(baseRef, baselinePath, {
78
+ cwd,
79
+ });
80
+ for (const commit of commits) {
81
+ if (!commit.subject.includes(refreshTag)) continue;
82
+ refreshCommits.push(commit);
83
+ reasons.push(
84
+ `refresh commit "${commit.subject}" (subject contains ${JSON.stringify(refreshTag)}, touches ${baselinePath})`,
85
+ );
86
+ }
87
+ }
88
+
89
+ return {
90
+ triggered: reasons.length > 0,
91
+ reasons,
92
+ envAcknowledged,
93
+ refreshCommits,
94
+ baselinePath,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Read the baseline rows as of one refresh commit. Returns `null` — never
100
+ * throws and never a partial row set — when the blob is absent, unreadable or
101
+ * unparseable at that SHA. The caller treats `null` as "this commit
102
+ * acknowledges nothing", which keeps the ratchet at full strength rather than
103
+ * acknowledging on a guess.
104
+ */
105
+ function readRowsAtCommit(sha, baselinePath, cwd) {
106
+ let raw;
107
+ try {
108
+ raw = readBaseFromGit(sha, baselinePath, { cwd });
109
+ } catch {
110
+ return null;
111
+ }
112
+ if (raw === null) return null;
113
+ try {
114
+ const payload = JSON.parse(raw);
115
+ return Array.isArray(payload?.rows) ? payload.rows : null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Classify the head rows against one refresh commit's rows, using the kind's
123
+ * own classifier and the gate's own tolerance.
124
+ *
125
+ * Row identity is deliberately never derived here. A kind's `keyField` names
126
+ * the row property the kind is *about*, which is not always its compare key:
127
+ * CRAP declares `keyField: 'path'` but keys rows as `path::method@startLine`,
128
+ * because one file holds many methods. Reading `row[keyField]` would produce a
129
+ * key matching no regression, silently acknowledging nothing for that kind.
130
+ * `compare()` is the one thing that knows a kind's key, so every key here comes
131
+ * back out of it — the same reason direction and tolerance are delegated rather
132
+ * than reimplemented.
133
+ *
134
+ * That also collapses both tests into one classification:
135
+ *
136
+ * - `regressions` — present in both, worse at head: post-refresh drift.
137
+ * - `improvements` / `unchanged` — present in both, no worse: the rows this
138
+ * commit vouches for.
139
+ * - `additions` — present at head but absent from the refresh blob, i.e.
140
+ * never refreshed by this commit, so deliberately in neither set.
141
+ *
142
+ * @returns {{ ok: string[], drifted: string[] } | null} null when the
143
+ * classifier is unusable, which acknowledges nothing.
144
+ */
145
+ function classifyAgainstRefresh({ mod, headRows, refreshRows, tolerance }) {
146
+ try {
147
+ const result = mod.compare({ rows: headRows }, { rows: refreshRows });
148
+ const tolerated = applyTolerance(
149
+ {
150
+ regressions: result?.regressions ?? [],
151
+ improvements: result?.improvements ?? [],
152
+ unchanged: result?.unchanged ?? [],
153
+ additions: result?.additions ?? [],
154
+ },
155
+ tolerance ?? null,
156
+ );
157
+ return {
158
+ ok: [...tolerated.improvements, ...tolerated.unchanged].map((r) => r.key),
159
+ drifted: tolerated.regressions.map((r) => r.key),
160
+ };
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Which regression keys are acknowledgeable by the tagged commits (Story #5179)?
168
+ *
169
+ * The acknowledgment is a statement about what a refresh commit re-scored, so a
170
+ * key is acknowledgeable only when the commit both covered it and recorded a
171
+ * value the head has not since fallen below. Both tests come out of
172
+ * `classifyAgainstRefresh` above:
173
+ *
174
+ * 1. **Row membership** — a key absent from the refresh blob lands in
175
+ * `additions`, never in `ok`. This was the larger half of the leak: a
176
+ * single tagged commit cleared regressions on rows in directories it never
177
+ * touched.
178
+ * 2. **No post-refresh drift** — a key worse at head than the commit recorded
179
+ * lands in `drifted`. Drift from commits landing AFTER the refresh is what
180
+ * "the baseline commit must be the branch's last score-moving commit" asks
181
+ * for by convention and nothing enforced.
182
+ *
183
+ * Fails closed at every step: an unreadable blob, a missing `compare`, or a
184
+ * classifier that throws contributes nothing, so those regressions stand.
185
+ *
186
+ * @returns {Set<string>}
187
+ */
188
+ function acknowledgeableKeys({
189
+ kind,
190
+ headBaseline,
191
+ refreshCommits,
192
+ baselinePath,
193
+ cwd,
194
+ tolerance,
195
+ }) {
196
+ const acknowledgeable = new Set();
197
+ if (refreshCommits.length === 0) return acknowledgeable;
198
+ if (typeof baselinePath !== 'string' || baselinePath.length === 0)
199
+ return acknowledgeable;
200
+
201
+ let mod;
202
+ try {
203
+ mod = getKindModule(kind);
204
+ } catch {
205
+ return acknowledgeable;
206
+ }
207
+ if (typeof mod?.compare !== 'function') return acknowledgeable;
208
+
209
+ const headRows = Array.isArray(headBaseline?.rows) ? headBaseline.rows : [];
210
+ // `readRangeCommitsTouchingFile` returns newest-first, and a key the newest
211
+ // refresh already ruled on is not reopened by an older one: the newest is the
212
+ // state the branch is asking to be held to.
213
+ const decided = new Set();
214
+ for (const { sha } of refreshCommits) {
215
+ const refreshRows = readRowsAtCommit(sha, baselinePath, cwd);
216
+ if (refreshRows === null) continue;
217
+ const verdict = classifyAgainstRefresh({
218
+ mod,
219
+ headRows,
220
+ refreshRows,
221
+ tolerance,
222
+ });
223
+ if (verdict === null) continue;
224
+ for (const key of verdict.ok) {
225
+ if (decided.has(key)) continue;
226
+ decided.add(key);
227
+ acknowledgeable.add(key);
228
+ }
229
+ for (const key of verdict.drifted) decided.add(key);
230
+ }
231
+ return acknowledgeable;
232
+ }
233
+
234
+ function partitionRegressions(regressions, isAcknowledgeable) {
235
+ const acknowledged = [];
236
+ const kept = [];
237
+ for (const reg of regressions) {
238
+ if (isAcknowledgeable(reg)) acknowledged.push(reg);
239
+ else kept.push(reg);
240
+ }
241
+ return { acknowledged, kept };
242
+ }
243
+
244
+ function logAcknowledgment({ kind, reasons, acknowledged, kept }) {
245
+ const held =
246
+ kept.length > 0
247
+ ? `${kept.length} regression(s) NOT acknowledged (outside the refreshed rows, or drifted further after the refresh) and still fail the gate; `
248
+ : '';
249
+ Logger.warn(
250
+ `[${kind}] ⚠ ${reasons.join('; ')} — ` +
251
+ `${acknowledged.length} regression(s) acknowledged for this run only; ` +
252
+ `${held}floors still enforced. This does not persist: once the refresh is ` +
253
+ 'the new base the ratchet re-enforces at full strength.',
254
+ );
255
+ }
256
+
257
+ /**
258
+ * One-shot baseline refresh/acknowledge for any ratcheted kind. When triggered,
259
+ * demote acknowledged head-vs-base regressions to `unchanged` for this run only
260
+ * — floors still apply, so a row below its floor still breaches. The trigger is
261
+ * read fresh every run and never persisted: post-merge the refreshed baseline is
262
+ * the new base and the tag leaves the range, so the ratchet returns to full
263
+ * strength automatically.
264
+ *
265
+ * The two trigger arms are scoped differently, deliberately:
266
+ *
267
+ * - **Commit tag** — scoped to the rows the tagged commits actually refreshed,
268
+ * per `acknowledgeableKeys`. Before Story #5179 this cleared every regression
269
+ * in the range, so a branch merged carrying a stale row and the ratchet ran
270
+ * loose on that file; the failure recurred six times.
271
+ * - **Env parity** (`<KIND>_REFRESH=1`) — stays whole-run. There is no commit to
272
+ * anchor row-scoping to, and setting the variable is an explicit, deliberate
273
+ * operator act rather than a signal inferred from history.
274
+ *
275
+ * A no-op absent a trigger, so an unacknowledged run of any kind reports its
276
+ * regressions exactly as before.
277
+ *
278
+ * @returns {{ compareOutput: object, acknowledged: boolean, acknowledgedKeys: string[] }}
279
+ */
280
+ export function applyRefreshAcknowledgment(kind, compareOutput, ctx) {
281
+ const trigger = resolveRefreshTrigger({ ...ctx, kind });
282
+ if (!trigger.triggered || compareOutput.regressions.length === 0) {
283
+ return { compareOutput, acknowledged: false, acknowledgedKeys: [] };
284
+ }
285
+
286
+ let isAcknowledgeable;
287
+ if (trigger.envAcknowledged) {
288
+ isAcknowledgeable = () => true;
289
+ } else {
290
+ const keys = acknowledgeableKeys({
291
+ kind,
292
+ headBaseline: ctx.headBaseline,
293
+ refreshCommits: trigger.refreshCommits,
294
+ baselinePath: trigger.baselinePath,
295
+ cwd: ctx.cwd,
296
+ tolerance: ctx.gateBlock?.tolerance ?? null,
297
+ });
298
+ isAcknowledgeable = (reg) => keys.has(reg.key);
299
+ }
300
+
301
+ const { acknowledged, kept } = partitionRegressions(
302
+ compareOutput.regressions,
303
+ isAcknowledgeable,
304
+ );
305
+ if (acknowledged.length === 0) {
306
+ return { compareOutput, acknowledged: false, acknowledgedKeys: [] };
307
+ }
308
+
309
+ logAcknowledgment({ kind, reasons: trigger.reasons, acknowledged, kept });
310
+
311
+ return {
312
+ acknowledged: true,
313
+ acknowledgedKeys: acknowledged.map((reg) => reg.key),
314
+ compareOutput: {
315
+ ...compareOutput,
316
+ regressions: kept,
317
+ unchanged: [...compareOutput.unchanged, ...acknowledged],
318
+ },
319
+ };
320
+ }
@@ -34,7 +34,14 @@ function formatGateLine(g) {
34
34
  g.baseRef && g.baseRead === false
35
35
  ? ' [baseRead=false — compare skipped]'
36
36
  : '';
37
- const ack = g.acknowledged ? ' [ACKNOWLEDGED this run only]' : '';
37
+ // Story #5179 a refresh commit acknowledges only the rows it refreshed, so
38
+ // the count says how much was cleared and the status ahead of it still shows
39
+ // FAIL when regressions outside those rows survived. `buildGateReport` always
40
+ // sets `acknowledgedKeys` alongside `acknowledged` from the same result
41
+ // object, so the array needs no guard of its own here.
42
+ const ack = g.acknowledged
43
+ ? ` [ACKNOWLEDGED ${g.acknowledgedKeys.length} row(s) — this run only]`
44
+ : '';
38
45
  return ` - ${g.kind}: ${status}${drift}${baseRef}${baseRead}${ack}`;
39
46
  }
40
47
 
@@ -0,0 +1,139 @@
1
+ /**
2
+ * dependency-candidates.js — open Stories a newly planned Story may need to
3
+ * wait for.
4
+ *
5
+ * Story #5155. `depends_on[]` has always ordered *siblings within one plan*.
6
+ * Nothing surfaced the other ordering that actually bites: a Story authored
7
+ * today that edits a file an already-open Story from an earlier plan is going
8
+ * to rewrite. Delivered concurrently, the second lands on a base the first
9
+ * just changed — and the planner had no way to see it coming, because the
10
+ * duplicate search asks "is this the same Story?" (title/body similarity),
11
+ * never "does this Story touch what I am about to touch?".
12
+ *
13
+ * Overlap here is therefore computed on **declared footprints**, not prose:
14
+ * the seed's `predictedPaths` against each open Story's parsed `changes[]`,
15
+ * via the same `storyFootprint` the wave runner uses to withhold colliding
16
+ * Stories at dispatch. That is deliberate — the planner sees the collision the
17
+ * runtime would later enforce, one layer earlier and while it is still cheap
18
+ * to order around.
19
+ *
20
+ * The result is **advisory**: an overlap is a prompt to consider an edge, not
21
+ * proof one is needed. Two Stories can touch a shared barrel file with no real
22
+ * ordering between them; only the operator knows.
23
+ *
24
+ * @module lib/orchestration/dependency-candidates
25
+ * @see Story #5155
26
+ */
27
+
28
+ import { Logger } from '../Logger.js';
29
+ import { TYPE_LABELS } from '../label-constants.js';
30
+ import { parse as parseStoryBody } from '../story-body/story-body.js';
31
+ import { storyFootprint } from '../wave-runner/footprint.js';
32
+
33
+ /**
34
+ * Build an issue URL for a Story the provider returned without one.
35
+ *
36
+ * @param {number} id
37
+ * @param {{ owner?: string, repo?: string }} [opts]
38
+ * @returns {string}
39
+ */
40
+ function buildStoryUrl(id, { owner, repo } = {}) {
41
+ if (owner && repo) return `https://github.com/${owner}/${repo}/issues/${id}`;
42
+ return `#${id}`;
43
+ }
44
+
45
+ /**
46
+ * Read one open Story's declared footprint.
47
+ *
48
+ * Total by construction: an unparseable body yields an empty footprint, which
49
+ * intersects with nothing and drops the Story from the candidate list. A
50
+ * hand-written Story with no `## Changes` section is exactly that case, and it
51
+ * is the right outcome — there is no declared footprint to collide with.
52
+ *
53
+ * @param {object} issue
54
+ * @returns {Set<string>}
55
+ */
56
+ function footprintOf(issue) {
57
+ const body = typeof issue?.body === 'string' ? issue.body : '';
58
+ if (body === '') return new Set();
59
+ try {
60
+ return storyFootprint(parseStoryBody(body).body);
61
+ } catch {
62
+ return new Set();
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Find open Stories whose declared footprint intersects the seed's predicted
68
+ * paths.
69
+ *
70
+ * Returns `[]` **without contacting the provider** when the seed named no
71
+ * paths: with nothing to intersect, every candidate would score empty, and the
72
+ * round-trip would buy nothing. That short-circuit is load-bearing for the
73
+ * common one-line seed, which mentions no file at all.
74
+ *
75
+ * @param {{
76
+ * predictedPaths: string[],
77
+ * provider: object,
78
+ * owner?: string,
79
+ * repo?: string,
80
+ * excludeIds?: Iterable<number|string>,
81
+ * }} args
82
+ * @returns {Promise<Array<{ id: number, title: string, url: string, state: string, overlappingPaths: string[] }>>}
83
+ */
84
+ export async function findDependencyCandidates({
85
+ predictedPaths,
86
+ provider,
87
+ owner,
88
+ repo,
89
+ excludeIds = [],
90
+ }) {
91
+ const wanted = (Array.isArray(predictedPaths) ? predictedPaths : []).filter(
92
+ (p) => typeof p === 'string' && p.trim() !== '',
93
+ );
94
+ if (wanted.length === 0) return [];
95
+ if (typeof provider?.listIssuesByLabel !== 'function') return [];
96
+
97
+ const excluded = new Set(
98
+ [...excludeIds].map((id) => Number(id)).filter((n) => Number.isFinite(n)),
99
+ );
100
+
101
+ let issues;
102
+ try {
103
+ issues = await provider.listIssuesByLabel({
104
+ state: 'open',
105
+ labels: TYPE_LABELS.STORY,
106
+ });
107
+ } catch (err) {
108
+ Logger.warn(
109
+ `[dependency-candidates] open-Story listing degraded to no candidates: ${err?.message ?? err}`,
110
+ );
111
+ return [];
112
+ }
113
+
114
+ const out = [];
115
+ for (const issue of Array.isArray(issues) ? issues : []) {
116
+ const id = Number(issue?.number ?? issue?.id);
117
+ if (!Number.isInteger(id) || id <= 0 || excluded.has(id)) continue;
118
+
119
+ const footprint = footprintOf(issue);
120
+ if (footprint.size === 0) continue;
121
+
122
+ const overlappingPaths = wanted.filter((p) => footprint.has(p));
123
+ if (overlappingPaths.length === 0) continue;
124
+
125
+ out.push({
126
+ id,
127
+ title: typeof issue?.title === 'string' ? issue.title : '',
128
+ url: issue?.html_url ?? issue?.url ?? buildStoryUrl(id, { owner, repo }),
129
+ state: typeof issue?.state === 'string' ? issue.state : 'open',
130
+ overlappingPaths,
131
+ });
132
+ }
133
+
134
+ // Most-entangled first, then ascending id for a stable render.
135
+ return out.sort(
136
+ (a, b) =>
137
+ b.overlappingPaths.length - a.overlappingPaths.length || a.id - b.id,
138
+ );
139
+ }