mandrel 2.54.0 → 2.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +8 -2
- package/.agents/docs/configuration.md +5 -0
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +34 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +374 -76
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/file-ci-gap.js +306 -0
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +52 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
- package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
- package/.agents/scripts/lib/findings/route-finding.js +42 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/github/framework-repo.js +148 -2
- package/.agents/scripts/lib/label-constants.js +6 -1
- package/.agents/scripts/lib/observability/source-classifier.js +2 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/pr-watch-with-update.js +3 -2
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/audit-to-stories.md +63 -27
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +35 -14
- package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +30 -0
- package/.agents/workflows/mandrel-plan.md +10 -13
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +37 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- package/package.json +7 -4
|
@@ -34,13 +34,31 @@
|
|
|
34
34
|
* simply stays silent and only the age arm can speak, until the next pass
|
|
35
35
|
* writes a baseline.
|
|
36
36
|
*
|
|
37
|
+
* **The index byte arm (Story #5285).** Age and growth both measure the
|
|
38
|
+
* *pool*; neither measures the one artifact a session actually loads. The
|
|
39
|
+
* harness reads `MEMORY.md` into every session under a hard byte cap and
|
|
40
|
+
* **truncates** past it, so an index over that cap loses its tail entries
|
|
41
|
+
* silently — the pointers are on disk, indexed, and unreachable. That is a
|
|
42
|
+
* loss in progress, not a hygiene forecast, so this arm is independent of the
|
|
43
|
+
* other two: it fires on a fresh, zero-growth pool whose index has simply
|
|
44
|
+
* outgrown the cap. It measures the index file's size, never the pool's, and
|
|
45
|
+
* a pass that rewrites long index lines short clears it without pruning a
|
|
46
|
+
* single entry.
|
|
47
|
+
*
|
|
48
|
+
* **A future-dated stamp is no stamp.** `lastConsolidatedAt` ahead of `now`
|
|
49
|
+
* cannot describe a pass that happened — it is a clock skew, a hand-edit, or
|
|
50
|
+
* a timezone bug. Scored as-is it yields a negative age that silences the age
|
|
51
|
+
* arm *forever*, which is the loudest possible failure for an advisory whose
|
|
52
|
+
* only job is to speak up. It reads as unstamped instead, so the
|
|
53
|
+
* never-consolidated reason fires and the next real pass overwrites it.
|
|
54
|
+
*
|
|
37
55
|
* Detection is filesystem-only — no child processes, no `gh` probes, no
|
|
38
56
|
* network. Every failure path fails soft to "no pool, no recommendation": the
|
|
39
57
|
* advisory can degrade the nudge, never a plan.
|
|
40
58
|
*
|
|
41
59
|
* Test seams: `cwd`, `env`, `fsImpl` (node:fs-compatible `statSync` /
|
|
42
|
-
* `readdirSync` / `readFileSync`), `now`, and the
|
|
43
|
-
* (`staleAfterDays`, `growthDelta`).
|
|
60
|
+
* `readdirSync` / `readFileSync`), `now`, and the three thresholds
|
|
61
|
+
* (`staleAfterDays`, `growthDelta`, `indexByteCeiling`).
|
|
44
62
|
*
|
|
45
63
|
* `buildMemoryPoolAdvisory` is the **only** export: the helpers below have no
|
|
46
64
|
* caller outside this module, and exporting one solely for a test would add a
|
|
@@ -59,6 +77,16 @@ const STALE_AFTER_DAYS = 30;
|
|
|
59
77
|
/** Recommend a pass once this many entries were written since the last one. */
|
|
60
78
|
const GROWTH_DELTA = 25;
|
|
61
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Recommend a pass once `MEMORY.md` exceeds this many bytes.
|
|
82
|
+
*
|
|
83
|
+
* 24576 (24 KiB) is the harness's own index cap — the point past which it
|
|
84
|
+
* truncates the file it loads into a session, making every entry after the
|
|
85
|
+
* cut unreachable. The default is the cap itself rather than a margin under
|
|
86
|
+
* it: the arm reports a loss that has already started, not one approaching.
|
|
87
|
+
*/
|
|
88
|
+
const INDEX_BYTE_CEILING = 24_576;
|
|
89
|
+
|
|
62
90
|
/** Stamp file written by `/memory-consolidate` after its operator gate. */
|
|
63
91
|
const STAMP_FILENAME = '.consolidation-stamp.json';
|
|
64
92
|
|
|
@@ -109,6 +137,28 @@ function resolveMemoryPoolDir({ cwd, env = process.env, homedir } = {}) {
|
|
|
109
137
|
);
|
|
110
138
|
}
|
|
111
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Run one filesystem probe, falling back on any failure.
|
|
142
|
+
*
|
|
143
|
+
* Every read here is fail-soft by design — the advisory may degrade its nudge
|
|
144
|
+
* but never a plan — so all four probes had the same try/catch shape wrapped
|
|
145
|
+
* around one expression. One helper states the rule once; a new probe cannot
|
|
146
|
+
* forget it, and a `catch` that ever needs to do more than fall back would
|
|
147
|
+
* have to be written out, which is the signal it deserves.
|
|
148
|
+
*
|
|
149
|
+
* @template T
|
|
150
|
+
* @param {() => T} read
|
|
151
|
+
* @param {T|null} [fallback]
|
|
152
|
+
* @returns {T|null}
|
|
153
|
+
*/
|
|
154
|
+
function probe(read, fallback = null) {
|
|
155
|
+
try {
|
|
156
|
+
return read();
|
|
157
|
+
} catch {
|
|
158
|
+
return fallback;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
112
162
|
/**
|
|
113
163
|
* The growth baseline a stamp records: its entry count, or `null` when it
|
|
114
164
|
* records none. `null` is *unmeasured*, never zero — a zero baseline would
|
|
@@ -125,8 +175,11 @@ function readBaseline(count) {
|
|
|
125
175
|
* Read the consolidation stamp.
|
|
126
176
|
*
|
|
127
177
|
* `at` is the ISO timestamp of the last pass, or `null` when there was none:
|
|
128
|
-
* a missing, unreadable, unparseable
|
|
129
|
-
* from "never consolidated" — all
|
|
178
|
+
* a missing, unreadable, unparseable, date-less or **future-dated** stamp is
|
|
179
|
+
* indistinguishable from "never consolidated" — all five mean the same thing
|
|
180
|
+
* to the advisory. A future date is the one that has to be caught here rather
|
|
181
|
+
* than downstream: it is arithmetically valid, so the age arm would score it
|
|
182
|
+
* as a negative age and stay silent for as long as the clock stays behind it.
|
|
130
183
|
* A stamp whose date is unusable carries no baseline either, so `baseline`
|
|
131
184
|
* follows it to `null` rather than describing a pass that cannot be dated.
|
|
132
185
|
*
|
|
@@ -135,23 +188,40 @@ function readBaseline(count) {
|
|
|
135
188
|
* only) and on a malformed count, which reads as *unmeasured growth*, never
|
|
136
189
|
* as zero growth: a `0` baseline would score the whole pool as new.
|
|
137
190
|
*
|
|
191
|
+
* @param {{ poolDir: string, fsImpl: object, now: Date|string|number }} args
|
|
138
192
|
* @returns {{ at: string|null, baseline: number|null }}
|
|
139
193
|
*/
|
|
140
|
-
function readStamp({ poolDir, fsImpl }) {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
return { at, baseline: readBaseline(parsed.entryCount) };
|
|
152
|
-
} catch {
|
|
153
|
-
return unstamped;
|
|
194
|
+
function readStamp({ poolDir, fsImpl, now }) {
|
|
195
|
+
const parsed = probe(() =>
|
|
196
|
+
JSON.parse(fsImpl.readFileSync(path.join(poolDir, STAMP_FILENAME), 'utf8')),
|
|
197
|
+
);
|
|
198
|
+
const at = parsed?.lastConsolidatedAt;
|
|
199
|
+
// `Date.parse` rejects the empty string as NaN, so one test covers both an
|
|
200
|
+
// absent date and an unusable one; `> now` covers the future-dated stamp.
|
|
201
|
+
// Equality is not the future, so a stamp written this instant still counts.
|
|
202
|
+
const at_ms = typeof at === 'string' ? Date.parse(at) : Number.NaN;
|
|
203
|
+
if (Number.isNaN(at_ms) || at_ms > new Date(now).getTime()) {
|
|
204
|
+
return { at: null, baseline: null };
|
|
154
205
|
}
|
|
206
|
+
return { at, baseline: readBaseline(parsed.entryCount) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The index file's size in bytes.
|
|
211
|
+
*
|
|
212
|
+
* `null` when it cannot be stat'd — an absent or unreadable `MEMORY.md`
|
|
213
|
+
* leaves the byte arm silent rather than guessing a size, on the same
|
|
214
|
+
* fail-soft rule every other probe here follows. Stat'd rather than read:
|
|
215
|
+
* the arm needs the length, never the content, and this module deliberately
|
|
216
|
+
* never reads a memory's text.
|
|
217
|
+
*
|
|
218
|
+
* @returns {number|null}
|
|
219
|
+
*/
|
|
220
|
+
function readIndexBytes({ poolDir, fsImpl }) {
|
|
221
|
+
const size = probe(
|
|
222
|
+
() => fsImpl.statSync(path.join(poolDir, INDEX_FILENAME)).size,
|
|
223
|
+
);
|
|
224
|
+
return Number.isFinite(size) ? size : null;
|
|
155
225
|
}
|
|
156
226
|
|
|
157
227
|
/**
|
|
@@ -160,13 +230,13 @@ function readStamp({ poolDir, fsImpl }) {
|
|
|
160
230
|
* @returns {number|null} `null` when the directory cannot be listed
|
|
161
231
|
*/
|
|
162
232
|
function countEntries({ poolDir, fsImpl }) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
233
|
+
return probe(
|
|
234
|
+
() =>
|
|
235
|
+
fsImpl
|
|
236
|
+
.readdirSync(poolDir)
|
|
237
|
+
.filter((name) => name.endsWith('.md') && name !== INDEX_FILENAME)
|
|
238
|
+
.length,
|
|
239
|
+
);
|
|
170
240
|
}
|
|
171
241
|
|
|
172
242
|
/**
|
|
@@ -176,7 +246,8 @@ function countEntries({ poolDir, fsImpl }) {
|
|
|
176
246
|
* and not others, which is the failure mode a per-branch object literal has.
|
|
177
247
|
*
|
|
178
248
|
* @param {object} fields
|
|
179
|
-
* @returns {{ present: boolean, entryCount: number,
|
|
249
|
+
* @returns {{ present: boolean, entryCount: number, indexBytes: number|null,
|
|
250
|
+
* lastConsolidatedAt: string|null,
|
|
180
251
|
* entriesSinceConsolidation: number|null, recommend: boolean,
|
|
181
252
|
* reasons: string[] }}
|
|
182
253
|
*/
|
|
@@ -184,6 +255,7 @@ function envelope(fields) {
|
|
|
184
255
|
return {
|
|
185
256
|
present: false,
|
|
186
257
|
entryCount: 0,
|
|
258
|
+
indexBytes: null,
|
|
187
259
|
lastConsolidatedAt: null,
|
|
188
260
|
entriesSinceConsolidation: null,
|
|
189
261
|
recommend: false,
|
|
@@ -197,14 +269,23 @@ function envelope(fields) {
|
|
|
197
269
|
* the quiet verdict; the caller turns it into `recommend` and supplies the
|
|
198
270
|
* standing-down sentence, so every arm lives in one place.
|
|
199
271
|
*
|
|
200
|
-
* The
|
|
272
|
+
* The three arms are independent and every one that fires is reported.
|
|
201
273
|
*
|
|
202
274
|
* @param {{ stamp: { at: string|null, baseline: number|null },
|
|
203
|
-
* growth: number|null,
|
|
204
|
-
*
|
|
275
|
+
* growth: number|null, indexBytes: number|null,
|
|
276
|
+
* now: Date|string|number, staleAfterDays: number,
|
|
277
|
+
* growthDelta: number, indexByteCeiling: number }} args
|
|
205
278
|
* @returns {string[]}
|
|
206
279
|
*/
|
|
207
|
-
function collectReasons({
|
|
280
|
+
function collectReasons({
|
|
281
|
+
stamp,
|
|
282
|
+
growth,
|
|
283
|
+
indexBytes,
|
|
284
|
+
now,
|
|
285
|
+
staleAfterDays,
|
|
286
|
+
growthDelta,
|
|
287
|
+
indexByteCeiling,
|
|
288
|
+
}) {
|
|
208
289
|
const reasons = [];
|
|
209
290
|
|
|
210
291
|
if (stamp.at === null) {
|
|
@@ -229,6 +310,13 @@ function collectReasons({ stamp, growth, now, staleAfterDays, growthDelta }) {
|
|
|
229
310
|
);
|
|
230
311
|
}
|
|
231
312
|
|
|
313
|
+
// `indexBytes === null` is an unreadable index, not a small one.
|
|
314
|
+
if (indexBytes !== null && indexBytes > indexByteCeiling) {
|
|
315
|
+
reasons.push(
|
|
316
|
+
`${INDEX_FILENAME} is ${indexBytes} bytes, ${indexBytes - indexByteCeiling} over the ${indexByteCeiling}-byte index ceiling — the index is truncated at the cap, so every entry listed after the cut is invisible to every session`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
232
320
|
return reasons;
|
|
233
321
|
}
|
|
234
322
|
|
|
@@ -241,9 +329,9 @@ function collectReasons({ stamp, growth, now, staleAfterDays, growthDelta }) {
|
|
|
241
329
|
*/
|
|
242
330
|
function quietReason({ growth, growthDelta }) {
|
|
243
331
|
if (growth === null) {
|
|
244
|
-
return 'memory pool is within the freshness
|
|
332
|
+
return 'memory pool is within the freshness and index-size thresholds; growth is unmeasured until the next /memory-consolidate stamps an entry count';
|
|
245
333
|
}
|
|
246
|
-
return `memory pool is within
|
|
334
|
+
return `memory pool is within every threshold — ${growth} entries written since the last consolidation (under the ${growthDelta}-entry growth delta)`;
|
|
247
335
|
}
|
|
248
336
|
|
|
249
337
|
/**
|
|
@@ -261,7 +349,9 @@ function quietReason({ growth, growthDelta }) {
|
|
|
261
349
|
* @param {Date|string|number} [opts.now]
|
|
262
350
|
* @param {number} [opts.staleAfterDays]
|
|
263
351
|
* @param {number} [opts.growthDelta]
|
|
264
|
-
* @
|
|
352
|
+
* @param {number} [opts.indexByteCeiling]
|
|
353
|
+
* @returns {{ present: boolean, entryCount: number, indexBytes: number|null,
|
|
354
|
+
* lastConsolidatedAt: string|null,
|
|
265
355
|
* entriesSinceConsolidation: number|null, recommend: boolean,
|
|
266
356
|
* reasons: string[] }}
|
|
267
357
|
*/
|
|
@@ -273,6 +363,7 @@ export function buildMemoryPoolAdvisory({
|
|
|
273
363
|
now = new Date(),
|
|
274
364
|
staleAfterDays = STALE_AFTER_DAYS,
|
|
275
365
|
growthDelta = GROWTH_DELTA,
|
|
366
|
+
indexByteCeiling = INDEX_BYTE_CEILING,
|
|
276
367
|
} = {}) {
|
|
277
368
|
const absent = (reason) => envelope({ reasons: [reason] });
|
|
278
369
|
|
|
@@ -283,12 +374,7 @@ export function buildMemoryPoolAdvisory({
|
|
|
283
374
|
);
|
|
284
375
|
}
|
|
285
376
|
|
|
286
|
-
|
|
287
|
-
try {
|
|
288
|
-
isDir = fsImpl.statSync(poolDir).isDirectory();
|
|
289
|
-
} catch {
|
|
290
|
-
isDir = false;
|
|
291
|
-
}
|
|
377
|
+
const isDir = probe(() => fsImpl.statSync(poolDir).isDirectory(), false);
|
|
292
378
|
if (!isDir) {
|
|
293
379
|
return absent(`no memory pool at ${poolDir} — nothing to consolidate`);
|
|
294
380
|
}
|
|
@@ -298,14 +384,16 @@ export function buildMemoryPoolAdvisory({
|
|
|
298
384
|
return absent(`memory pool at ${poolDir} could not be listed`);
|
|
299
385
|
}
|
|
300
386
|
|
|
301
|
-
const stamp = readStamp({ poolDir, fsImpl });
|
|
387
|
+
const stamp = readStamp({ poolDir, fsImpl, now });
|
|
302
388
|
// Reported raw: a pruning pass can leave this negative, and saying the pool
|
|
303
389
|
// shrank by 7 is more use to the operator than clamping it to zero.
|
|
304
390
|
const growth = stamp.baseline === null ? null : entryCount - stamp.baseline;
|
|
391
|
+
const indexBytes = readIndexBytes({ poolDir, fsImpl });
|
|
305
392
|
|
|
306
393
|
const found = {
|
|
307
394
|
present: true,
|
|
308
395
|
entryCount,
|
|
396
|
+
indexBytes,
|
|
309
397
|
lastConsolidatedAt: stamp.at,
|
|
310
398
|
entriesSinceConsolidation: growth,
|
|
311
399
|
};
|
|
@@ -321,9 +409,11 @@ export function buildMemoryPoolAdvisory({
|
|
|
321
409
|
const reasons = collectReasons({
|
|
322
410
|
stamp,
|
|
323
411
|
growth,
|
|
412
|
+
indexBytes,
|
|
324
413
|
now,
|
|
325
414
|
staleAfterDays,
|
|
326
415
|
growthDelta,
|
|
416
|
+
indexByteCeiling,
|
|
327
417
|
});
|
|
328
418
|
|
|
329
419
|
return envelope({
|
|
@@ -40,6 +40,13 @@ import { resolveStoryDispatchMode } from './complexity-gate.js';
|
|
|
40
40
|
/** Labels/state that mean a blocker no longer gates its dependents. */
|
|
41
41
|
const DONE_LABEL = 'agent::done';
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The lifecycle-label prefix a deliverable Story carries. Any `agent::*` label
|
|
45
|
+
* will do — the resolver is not a state machine and does not care WHICH state a
|
|
46
|
+
* Story is in, only that it has been through the step that assigns one.
|
|
47
|
+
*/
|
|
48
|
+
const AGENT_LABEL_PREFIX = 'agent::';
|
|
49
|
+
|
|
43
50
|
/**
|
|
44
51
|
* Module-private: `toStoryRecord` and `isSatisfiedBlocker` are its only
|
|
45
52
|
* callers. The ancestor exported it with no external consumer, which is how
|
|
@@ -64,9 +71,12 @@ function normalizeIssueLabels(issue) {
|
|
|
64
71
|
*
|
|
65
72
|
* @param {object} issue
|
|
66
73
|
* @param {number} [requestedId] The id the operator asked for, for error text.
|
|
74
|
+
* @param {{ allowUnlabelled?: boolean }} [options] `allowUnlabelled` waives the
|
|
75
|
+
* `agent::*` guard below — the deliberate escape hatch for delivering a Story
|
|
76
|
+
* whose state label is absent for a reason the operator knows about.
|
|
67
77
|
* @returns {{ id, title, body, url, labels, state, assignees }}
|
|
68
78
|
*/
|
|
69
|
-
export function toStoryRecord(issue, requestedId) {
|
|
79
|
+
export function toStoryRecord(issue, requestedId, { allowUnlabelled } = {}) {
|
|
70
80
|
const id = Number(issue?.number ?? issue?.id ?? requestedId);
|
|
71
81
|
if (!Number.isInteger(id) || id <= 0) {
|
|
72
82
|
throw new Error(
|
|
@@ -88,6 +98,9 @@ export function toStoryRecord(issue, requestedId) {
|
|
|
88
98
|
`v2 is Story-only — re-plan it as a v2 Story or finish it on a pre-v2 checkout.`,
|
|
89
99
|
);
|
|
90
100
|
}
|
|
101
|
+
// Last, so the two shape refusals above — not a Story at all, and a v1 body —
|
|
102
|
+
// keep naming their own remedy rather than being masked by a missing label.
|
|
103
|
+
assertDispatchable(id, labels, allowUnlabelled);
|
|
91
104
|
return {
|
|
92
105
|
id,
|
|
93
106
|
title: String(issue?.title ?? ''),
|
|
@@ -106,6 +119,36 @@ export function toStoryRecord(issue, requestedId) {
|
|
|
106
119
|
};
|
|
107
120
|
}
|
|
108
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Refuse a Story that has never been through planning.
|
|
124
|
+
*
|
|
125
|
+
* The audit sweep files Stories deliberately WITHOUT an `agent::*` label: their
|
|
126
|
+
* bodies are audit prose — a symptom and a recommendation — not a scoped change
|
|
127
|
+
* with acceptance criteria a worker can verify against, and the sweep's runbook
|
|
128
|
+
* says so. But `/mandrel-deliver` takes ids, and nothing downstream re-checked the
|
|
129
|
+
* label, so naming a freshly-filed audit Story dispatched a worker at an
|
|
130
|
+
* unenriched body: the run then either invented its own acceptance criteria or
|
|
131
|
+
* blocked several minutes in, having taken the Story's lease and flipped it to
|
|
132
|
+
* `agent::executing` on the way.
|
|
133
|
+
*
|
|
134
|
+
* The label is the cheap, honest signal that the enrich step ran — no state
|
|
135
|
+
* machine is consulted, only that SOME `agent::*` label exists.
|
|
136
|
+
*
|
|
137
|
+
* @param {number} id
|
|
138
|
+
* @param {string[]} labels
|
|
139
|
+
* @param {boolean} [allowUnlabelled]
|
|
140
|
+
*/
|
|
141
|
+
function assertDispatchable(id, labels, allowUnlabelled) {
|
|
142
|
+
if (allowUnlabelled) return;
|
|
143
|
+
if (labels.some((l) => l.startsWith(AGENT_LABEL_PREFIX))) return;
|
|
144
|
+
throw new Error(
|
|
145
|
+
`[resolve-stories] Issue #${id} carries no "${AGENT_LABEL_PREFIX}*" label, so it has not been ` +
|
|
146
|
+
`through planning — an audit sweep files Stories without one on purpose (its runbook's ` +
|
|
147
|
+
`"Enrich before you deliver" step). Route it through /mandrel-plan first, which applies ` +
|
|
148
|
+
`agent::ready once the finding is a scoped slice. Pass --allow-unlabelled to deliver it as-is.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
109
152
|
/**
|
|
110
153
|
* A blocker stops gating once its issue is closed or carries `agent::done`.
|
|
111
154
|
*
|
|
@@ -372,11 +372,25 @@ export async function analyzeChangedFiles(
|
|
|
372
372
|
/**
|
|
373
373
|
* Pure: turn a lint summary into Finding(s). Lint errors collapse into a
|
|
374
374
|
* single high-risk finding (the structured comment shows the count); lint
|
|
375
|
-
* warnings collapse into a single suggestion.
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
375
|
+
* warnings collapse into a single suggestion.
|
|
376
|
+
*
|
|
377
|
+
* **Findings come from the parsed counts, never from the execution flag**
|
|
378
|
+
* (Story #5282). `executionFailed` is the OR across the biome and markdownlint
|
|
379
|
+
* surfaces, so gating findings on it let *one* absent runner discard the
|
|
380
|
+
* *other* surface's real errors — and since the code surface's disk probe
|
|
381
|
+
* (#5193) degrades in every checkout without `node_modules/.bin/biome`, that
|
|
382
|
+
* was the default state of a consumer checkout: markdownlint errors reached
|
|
383
|
+
* neither the findings nor the severity tally while the outcome read clean
|
|
384
|
+
* apart from a degradation line.
|
|
385
|
+
*
|
|
386
|
+
* Story #4699's intent is preserved exactly, because it was never about the
|
|
387
|
+
* flag: a degradation is still not a `Finding` — it has no counts to report,
|
|
388
|
+
* so a surface that could not execute contributes `parsed: false` and zero
|
|
389
|
+
* counts and produces nothing here, while travelling on the degradation and
|
|
390
|
+
* friction-telemetry channels under its own name. A summary that explicitly
|
|
391
|
+
* reports `parsed: false` therefore yields no findings whatever its counts
|
|
392
|
+
* claim; a summary omitting `parsed` (an injected or pre-#4839 shape) is
|
|
393
|
+
* scored on its counts as before.
|
|
380
394
|
*
|
|
381
395
|
* @param {{ errors: number, warnings: number, parsed?: boolean, skipped?: boolean, mode?: string, executionFailed?: boolean, evidenceSkipped?: boolean }} lintSummary
|
|
382
396
|
* @returns {Finding[]}
|
|
@@ -385,7 +399,7 @@ export function buildLintFindings(lintSummary) {
|
|
|
385
399
|
if (lintSummary.mode === 'off') return [];
|
|
386
400
|
if (lintSummary.evidenceSkipped) return [];
|
|
387
401
|
if (lintSummary.skipped) return [];
|
|
388
|
-
if (lintSummary.
|
|
402
|
+
if (lintSummary.parsed === false) return [];
|
|
389
403
|
const findings = [];
|
|
390
404
|
if (lintSummary.errors > 0) {
|
|
391
405
|
findings.push({
|
|
@@ -428,6 +442,7 @@ async function runLintPhase({
|
|
|
428
442
|
mode: 'off',
|
|
429
443
|
executionFailed: false,
|
|
430
444
|
degradations: [],
|
|
445
|
+
surfaces: [],
|
|
431
446
|
};
|
|
432
447
|
}
|
|
433
448
|
logger?.info?.(
|
|
@@ -592,15 +607,19 @@ export function createNativeProvider(deps = {}) {
|
|
|
592
607
|
// Story #4839 — telemetry alone left the review's own verdict unable to
|
|
593
608
|
// distinguish "lint ran and found nothing" from "lint never ran", so
|
|
594
609
|
// the same degradation is also recorded on the outcome channel. It is
|
|
595
|
-
// still never a `Finding`: the friction emission below is unchanged
|
|
596
|
-
//
|
|
610
|
+
// still never a `Finding`: the friction emission below is unchanged.
|
|
611
|
+
//
|
|
612
|
+
// Story #5282 — this branch is about the degraded surface only. A
|
|
613
|
+
// sibling surface that *did* run still contributes its parsed counts
|
|
614
|
+
// to `buildLintFindings` below, so a degradation here no longer
|
|
615
|
+
// suppresses the other surface's errors.
|
|
597
616
|
recordedDegradations = buildLintDegradations(lintSummary);
|
|
598
617
|
logger?.warn?.(
|
|
599
618
|
`[native-review] Lint runner could not execute (${recordedDegradations
|
|
600
619
|
.map((d) => `${d.surface}: ${d.reason}`)
|
|
601
620
|
.join(
|
|
602
621
|
'; ',
|
|
603
|
-
)}) — reported as a degraded gate on the review outcome and recorded as friction telemetry;
|
|
622
|
+
)}) — reported as a degraded gate on the review outcome and recorded as friction telemetry; the degradation itself is never a finding, and any surface that did run still reports its own errors. Verify with the canonical \`npm run lint\` before merging.`,
|
|
604
623
|
);
|
|
605
624
|
try {
|
|
606
625
|
await emitToolDegradationFn({
|
|
@@ -622,8 +641,9 @@ export function createNativeProvider(deps = {}) {
|
|
|
622
641
|
|
|
623
642
|
// Canonical ordering: critical (maintainability) first, then high
|
|
624
643
|
// (lint errors), then medium (size/volume warnings), then suggestion
|
|
625
|
-
// (lint warnings). An execution failure contributes
|
|
626
|
-
// tiers — it travels on the degradation channel
|
|
644
|
+
// (lint warnings). An execution failure contributes no counts of its
|
|
645
|
+
// own to these tiers — it travels on the degradation channel — but it
|
|
646
|
+
// no longer suppresses a sibling surface's. The renderer
|
|
627
647
|
// re-bucketizes by severity tier, so this order only matters for
|
|
628
648
|
// stability of fixture outputs.
|
|
629
649
|
return [
|
|
@@ -289,36 +289,38 @@ export function parseLintOutput(result) {
|
|
|
289
289
|
* one degradation record naming itself. Merging *summaries* rather than raw
|
|
290
290
|
* output is what stops one runner's failure from becoming the other's verdict.
|
|
291
291
|
*
|
|
292
|
+
* The OR is deliberately lossy — it answers "did any surface fail?", which is
|
|
293
|
+
* the only question the degradation channel asks. Story #5282 added the
|
|
294
|
+
* `surfaces[]` rows so a consumer can ask the *other* questions the OR cannot
|
|
295
|
+
* answer: which surface the merged counts came from, and — via each row's
|
|
296
|
+
* `parsed` and `executionFailed` — whether an absent biome or a biome run that
|
|
297
|
+
* simply reported nothing is behind a zero. Consumers that read the flat
|
|
298
|
+
* counts are unaffected; the rows are additive.
|
|
299
|
+
*
|
|
292
300
|
* @param {Array<{ surface: string, summary: ReturnType<typeof parseLintOutput> }>} surfaces
|
|
293
301
|
*/
|
|
294
302
|
function mergeSurfaceSummaries(surfaces) {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
if (summary.parsed) parsed = true;
|
|
305
|
-
if (summary.executionFailed) {
|
|
306
|
-
executionFailed = true;
|
|
307
|
-
degradations.push({
|
|
308
|
-
surface,
|
|
309
|
-
reason: summary.reason ?? DEGRADATION_REASONS.UNPARSEABLE_OUTPUT,
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
}
|
|
303
|
+
const rows = surfaces.map(({ surface, summary }) => ({
|
|
304
|
+
surface,
|
|
305
|
+
parsed: summary.parsed,
|
|
306
|
+
errors: summary.errors,
|
|
307
|
+
warnings: summary.warnings,
|
|
308
|
+
executionFailed: summary.executionFailed,
|
|
309
|
+
reason: summary.reason ?? DEGRADATION_REASONS.UNPARSEABLE_OUTPUT,
|
|
310
|
+
}));
|
|
311
|
+
const total = (field) => rows.reduce((sum, row) => sum + row[field], 0);
|
|
313
312
|
|
|
314
313
|
return {
|
|
315
|
-
errors,
|
|
316
|
-
warnings,
|
|
317
|
-
parsed,
|
|
318
|
-
executionFailed,
|
|
314
|
+
errors: total('errors'),
|
|
315
|
+
warnings: total('warnings'),
|
|
316
|
+
parsed: rows.some((row) => row.parsed),
|
|
317
|
+
executionFailed: rows.some((row) => row.executionFailed),
|
|
319
318
|
skipped: false,
|
|
320
319
|
mode: 'changed-only',
|
|
321
|
-
degradations
|
|
320
|
+
degradations: rows
|
|
321
|
+
.filter((row) => row.executionFailed)
|
|
322
|
+
.map(({ surface, reason }) => ({ surface, reason })),
|
|
323
|
+
surfaces: rows.map(({ reason, ...row }) => row),
|
|
322
324
|
};
|
|
323
325
|
}
|
|
324
326
|
|
|
@@ -329,7 +331,7 @@ function mergeSurfaceSummaries(surfaces) {
|
|
|
329
331
|
* @param {string} cwd
|
|
330
332
|
* @param {typeof spawnLintRunner} [runnerFn]
|
|
331
333
|
* @param {{ existsFn?: (p: string) => boolean }} [deps] Test seam for runner resolution.
|
|
332
|
-
* @returns {{ errors: number, warnings: number, parsed: boolean, skipped: boolean, mode: 'changed-only'|'off', executionFailed: boolean, degradations: Array<{ surface: string, reason: string }> }}
|
|
334
|
+
* @returns {{ errors: number, warnings: number, parsed: boolean, skipped: boolean, mode: 'changed-only'|'off', executionFailed: boolean, degradations: Array<{ surface: string, reason: string }>, surfaces: Array<{ surface: string, parsed: boolean, errors: number, warnings: number, executionFailed: boolean }> }}
|
|
333
335
|
*/
|
|
334
336
|
export function runScopedLint(
|
|
335
337
|
changedFiles,
|
|
@@ -348,6 +350,7 @@ export function runScopedLint(
|
|
|
348
350
|
mode: 'changed-only',
|
|
349
351
|
executionFailed: false,
|
|
350
352
|
degradations: [],
|
|
353
|
+
surfaces: [],
|
|
351
354
|
};
|
|
352
355
|
}
|
|
353
356
|
|