mandrel 2.55.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.
Files changed (37) hide show
  1. package/.agents/docs/agentrc-reference.json +4 -0
  2. package/.agents/docs/configuration.md +3 -0
  3. package/.agents/rules/ci-remediation.md +39 -21
  4. package/.agents/schemas/agentrc.schema.json +19 -0
  5. package/.agents/scripts/audit-to-stories.js +222 -75
  6. package/.agents/scripts/file-ci-gap.js +306 -0
  7. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  8. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +40 -52
  9. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  10. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  11. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  12. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
  13. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  14. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  15. package/.agents/scripts/lib/config-settings-schema.js +33 -0
  16. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  17. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  18. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  19. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  20. package/.agents/scripts/lib/findings/route-finding.js +38 -0
  21. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  22. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  23. package/.agents/scripts/lib/label-constants.js +6 -1
  24. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  25. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  26. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  27. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  28. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +15 -2
  29. package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
  30. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  31. package/.agents/scripts/pr-watch-with-update.js +3 -2
  32. package/.agents/workflows/audit-to-stories.md +63 -27
  33. package/.agents/workflows/helpers/deliver-story-reference.md +19 -4
  34. package/.agents/workflows/helpers/plan-reference.md +23 -0
  35. package/.agents/workflows/mandrel-plan.md +6 -6
  36. package/docs/CHANGELOG.md +10 -0
  37. package/package.json +1 -1
@@ -22,7 +22,10 @@
22
22
  * Routing correctness: a routed item already knows its source
23
23
  * (`framework` / `consumer`), so we file each source bucket with its own
24
24
  * constant classifier and thread the graduator's per-run filing cap across
25
- * the two buckets. The `meta::<framework-gap|consumer-improvement>` +
25
+ * the two buckets. Which repository a source routes to is decided once, in
26
+ * `github/framework-repo.js` — the silent consumer-repo fallback that used
27
+ * to live here (and mis-filed framework work into the consumer's tracker)
28
+ * is gone from the whole path, not just from this module. The `meta::<framework-gap|consumer-improvement>` +
26
29
  * `friction::<category>` labels are lifted verbatim from the routed item.
27
30
  *
28
31
  * Behind the `delivery.feedbackLoop.retroProposals` toggle (default ON,
@@ -30,7 +33,10 @@
30
33
  * failure path is captured in `errors[]`.
31
34
  */
32
35
 
33
- import { DEFAULT_FRAMEWORK_REPO } from '../github/framework-repo.js';
36
+ import {
37
+ DEFAULT_FRAMEWORK_REPO,
38
+ parseRepoSlug,
39
+ } from '../github/framework-repo.js';
34
40
  import { META_LABELS } from '../label-constants.js';
35
41
  import {
36
42
  contentFingerprint,
@@ -218,6 +224,8 @@ function toFinding(item, source, index) {
218
224
  * @param {{owner: string, repo: string}} opts.currentRepo — the repo the
219
225
  * retro is running inside (the consumer's own repo); the cross-repo guard's
220
226
  * anchor.
227
+ * @param {{owner: string, repo: string}} [opts.platformRepo] — the shared
228
+ * platform/infra bucket, forwarded to the walk's routing SSOT.
221
229
  * @param {{owner: string, repo: string}} [opts.frameworkRepo] — where
222
230
  * framework-tagged proposals route.
223
231
  * @param {{ framework?: object[], consumer?: object[] }} [opts.routedProposals]
@@ -239,6 +247,7 @@ export async function graduateRetroProposals({
239
247
  config,
240
248
  currentRepo,
241
249
  frameworkRepo,
250
+ platformRepo,
242
251
  routedProposals,
243
252
  ghPath,
244
253
  spawnImpl,
@@ -294,6 +303,7 @@ export async function graduateRetroProposals({
294
303
  config,
295
304
  currentRepo,
296
305
  frameworkRepo,
306
+ platformRepo,
297
307
  // Each bucket's source is known — a constant classifier routes the
298
308
  // whole bucket to the correct repo and stamps the correct label.
299
309
  classifier: () => source,
@@ -380,22 +390,6 @@ export function enrichRoutedProposalsWithFilings(routedProposals, filed) {
380
390
  };
381
391
  }
382
392
 
383
- /**
384
- * Parse an `"<owner>/<repo>"` slug into `{ owner, repo }`, or `null` when
385
- * the slug is empty / malformed.
386
- *
387
- * @param {string|null|undefined} slug
388
- * @returns {{ owner: string, repo: string } | null}
389
- */
390
- function parseRepoSlug(slug) {
391
- if (typeof slug !== 'string') return null;
392
- const parts = slug.split('/');
393
- if (parts.length !== 2) return null;
394
- const [owner, repo] = parts;
395
- if (!owner || !repo) return null;
396
- return { owner, repo };
397
- }
398
-
399
393
  /**
400
394
  * Orchestrating seam invoked by the retro post-and-mirror phase: gate the
401
395
  * toggle, file the routed proposals, and return the routed proposals
@@ -452,13 +446,12 @@ export async function fileRetroProposals({
452
446
  );
453
447
  return passthrough('no-current-repo');
454
448
  }
455
- // Framework-repo fallback parity with `gatherRetroSignals`
456
- // (gather-signals.js): an unconfigured `github.frameworkRepo` falls
457
- // back to the Mandrel mirror constant, NEVER to the consumer's own
458
- // repo — the prior `?? currentRepo` fallback silently auto-filed
459
- // framework-tagged proposals into the consumer's repo while the retro
460
- // body rendered them under "framework repo" (masked in this repo only
461
- // because consumer === framework here).
449
+ // An unconfigured framework slug falls back to the Mandrel mirror
450
+ // constant, NEVER to the consumer's own repo: the retired consumer-repo
451
+ // fallback silently auto-filed framework-tagged proposals into the
452
+ // consumer's tracker while the retro body rendered them under "framework
453
+ // repo" (masked in this repo only because consumer === framework here).
454
+ // `github/framework-repo.js` is the SSOT for that rule now.
462
455
  const frameworkRepoObj =
463
456
  parseRepoSlug(frameworkRepo) ?? parseRepoSlug(DEFAULT_FRAMEWORK_REPO);
464
457
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * lib/audit-to-stories/ledger.js — Cross-run audit findings ledger.
2
+ * lib/findings/audit-ledger.js — Cross-run audit findings ledger.
3
3
  *
4
4
  * Without a committed memory of what a prior sweep already saw, every
5
5
  * `/audit-to-stories` run re-litigates the whole backlog from zero: it cannot
@@ -30,10 +30,7 @@
30
30
 
31
31
  import nodeFs from 'node:fs';
32
32
  import nodePath from 'node:path';
33
- import {
34
- fingerprintAuditFinding,
35
- semanticKeyForAuditFinding,
36
- } from './finding-adapter.js';
33
+ import { fingerprintFinding, semanticKeyFor } from './route-finding.js';
37
34
 
38
35
  export const DEFAULT_LEDGER_PATH = 'baselines/audit-ledger.json';
39
36
  const LEDGER_SCHEMA_URL =
@@ -54,13 +51,24 @@ function createEmptyLedger(now = new Date().toISOString()) {
54
51
  /**
55
52
  * Compute a finding's stable identity: its fingerprint (title-sensitive) and
56
53
  * its location-based semantic key (title-insensitive).
54
+ *
55
+ * The projection onto the canonical identity is the **caller's**, injected as
56
+ * `toCanonical`. That is what lets this module live beside `route-finding.js`
57
+ * in the shared findings layer: the audit pipeline's own adapter
58
+ * (`lib/audit-to-stories/finding-adapter.js`) imports *from* here, so importing
59
+ * it back would close a `findings → audit-to-stories → findings` cycle that
60
+ * `check-arch-cycles` rightly refuses. A caller that already holds canonical
61
+ * findings passes nothing.
62
+ *
57
63
  * @param {object} finding — a parsed/stamped audit finding.
64
+ * @param {(finding: object) => object} [toCanonical]
58
65
  * @returns {{ fingerprint: string, semanticKey: string }}
59
66
  */
60
- function findingIdentity(finding) {
67
+ function findingIdentity(finding, toCanonical) {
68
+ const canonical = toCanonical ? toCanonical(finding) : finding;
61
69
  return {
62
- fingerprint: fingerprintAuditFinding(finding).full,
63
- semanticKey: semanticKeyForAuditFinding(finding),
70
+ fingerprint: fingerprintFinding(canonical).full,
71
+ semanticKey: semanticKeyFor(canonical),
64
72
  };
65
73
  }
66
74
 
@@ -132,6 +140,19 @@ function resolveIssueState(id, existing, issueStates) {
132
140
  };
133
141
  }
134
142
 
143
+ /**
144
+ * The four verdicts the policy can reach, spelled once. Deduping them keeps
145
+ * `decideStatus` readable as the decision table it is, rather than eight
146
+ * near-identical object literals.
147
+ * @type {Record<string, { status: string, action: string }>}
148
+ */
149
+ const VERDICT = Object.freeze({
150
+ filed: Object.freeze({ status: 'filed', action: 'known' }),
151
+ propose: Object.freeze({ status: 'new', action: 'propose' }),
152
+ suppress: Object.freeze({ status: 'accepted-risk', action: 'suppress' }),
153
+ regressed: Object.freeze({ status: 'regressed', action: 'regressed' }),
154
+ });
155
+
135
156
  /**
136
157
  * Decide the finding's next status + action from its prior ledger state and
137
158
  * the live Issue state. This is the whole reconciliation policy in one place.
@@ -140,31 +161,41 @@ function resolveIssueState(id, existing, issueStates) {
140
161
  * @returns {{ status: string, action: 'propose'|'known'|'suppress'|'regressed' }}
141
162
  */
142
163
  function decideStatus(existing, issue) {
143
- // A closed Issue is the strongest signal — its close reason drives the verdict.
164
+ // A closed Issue is the strongest signal — its close reason drives the
165
+ // verdict, and it is read FIRST so a recorded `filed` can never outrank it.
144
166
  if (issue && issue.state === 'closed') {
145
- if (issue.stateReason === 'not_planned') {
146
- return { status: 'accepted-risk', action: 'suppress' };
147
- }
148
- // Closed as completed (or unspecified) but the finding is in this scan →
149
- // it came back. That is a regression, not a fresh proposal.
150
- return { status: 'regressed', action: 'regressed' };
167
+ return issue.stateReason === 'not_planned'
168
+ ? VERDICT.suppress
169
+ : // Closed as completed (or unspecified) but the finding is in this scan
170
+ // → it came back. That is a regression, not a fresh proposal.
171
+ VERDICT.regressed;
151
172
  }
152
173
 
153
- if (!existing) return { status: 'new', action: 'propose' };
174
+ // An OPEN tracking Issue means the finding has been filed, whatever the prior
175
+ // entry said — including when there is no prior entry at all. Until Story
176
+ // #5305 nothing in the package ever assigned `filed`, so this fell through to
177
+ // `new`/`propose` on every run and the `filed` arm below was unreachable in
178
+ // production: the ledger suppressed nothing, and only the GitHub-search dedup
179
+ // stopped a sweep re-filing what it had already filed. Reading it before the
180
+ // `!existing` guard is what makes a record pass correct on its FIRST run
181
+ // rather than its second.
182
+ const unseen = issue?.state === 'open' ? VERDICT.filed : VERDICT.propose;
183
+ if (!existing) return unseen;
154
184
 
155
185
  switch (existing.status) {
156
186
  case 'accepted-risk':
157
- return { status: 'accepted-risk', action: 'suppress' };
187
+ return VERDICT.suppress;
158
188
  case 'filed':
159
- return { status: 'filed', action: 'known' };
189
+ return VERDICT.filed;
190
+ // Recorded fixed, yet detected again with no closed-Issue evidence → treat
191
+ // as a regression the operator should look at. An open Issue does not
192
+ // soften that: the finding came back either way.
160
193
  case 'fixed':
161
- // Recorded fixed, yet detected again with no closed-Issue evidence →
162
- // treat as a regression the operator should look at.
163
- return { status: 'regressed', action: 'regressed' };
194
+ return VERDICT.regressed;
164
195
  case 'regressed':
165
- return { status: 'regressed', action: 'regressed' };
196
+ return VERDICT.regressed;
166
197
  default:
167
- return { status: 'new', action: 'propose' };
198
+ return unseen;
168
199
  }
169
200
  }
170
201
 
@@ -178,6 +209,8 @@ function decideStatus(existing, issue) {
178
209
  * Live Issue state keyed by fingerprint (or semanticKey). Optional — when a
179
210
  * prior entry already records the Issue, that is used.
180
211
  * @param {string} [params.now] — ISO timestamp for firstSeen/lastSeen stamping.
212
+ * @param {(finding: object) => object} [params.toCanonical] — projection onto
213
+ * the canonical identity; omit when `findings` are already canonical.
181
214
  * @returns {{
182
215
  * ledger: { $schema: string, generatedAt: string, entries: object[] },
183
216
  * classifications: Array<{ fingerprint: string, semanticKey: string, status: string, action: string, issue: object|null }>,
@@ -188,6 +221,7 @@ export function reconcileLedger({
188
221
  findings,
189
222
  issueStates = {},
190
223
  now = new Date().toISOString(),
224
+ toCanonical,
191
225
  } = {}) {
192
226
  if (!Array.isArray(findings)) {
193
227
  throw new Error('reconcileLedger: findings must be an array');
@@ -199,7 +233,7 @@ export function reconcileLedger({
199
233
  const classifications = [];
200
234
 
201
235
  for (const finding of findings) {
202
- const id = findingIdentity(finding);
236
+ const id = findingIdentity(finding, toCanonical);
203
237
  const existing =
204
238
  byFingerprint.get(id.fingerprint) ??
205
239
  (id.semanticKey ? bySemanticKey.get(id.semanticKey) : undefined) ??
@@ -254,3 +288,76 @@ export function reconcileLedger({
254
288
  classifications,
255
289
  };
256
290
  }
291
+
292
+ /**
293
+ * Record a set of already-known identities as filed against one Issue.
294
+ *
295
+ * The finding-shaped {@link reconcileLedger} cannot serve this caller:
296
+ * `plan-persist` never sees findings. It holds the provenance identities it
297
+ * stamped on a Story body — fingerprints and semantic keys, as strings — plus
298
+ * the issue number it just created. That is enough to record the filing, and
299
+ * demanding a finding it does not have would be the reason the recommended
300
+ * planning path never reached this ledger at all.
301
+ *
302
+ * An identity already carrying a **closed** Issue is left exactly as it is: a
303
+ * finding whose tracking Issue was closed `not_planned` is `accepted-risk` and
304
+ * must stay suppressed, and one closed as completed is a `regressed` the
305
+ * operator still needs to see. A fresh filing never overwrites either verdict.
306
+ *
307
+ * @param {object} params
308
+ * @param {{ entries?: object[] }} [params.ledger] — prior ledger (default empty).
309
+ * @param {Array<{ fingerprint: string, semanticKey?: string, title?: string, dimension?: string, primaryFile?: string }>} params.identities
310
+ * @param {{ number: number }} params.issue — the Issue these identities were filed as.
311
+ * @param {string} [params.now]
312
+ * @returns {{ ledger: object, recorded: number, skipped: number }}
313
+ */
314
+ export function recordFiledIdentities({
315
+ ledger = createEmptyLedger(),
316
+ identities,
317
+ issue,
318
+ now = new Date().toISOString(),
319
+ } = {}) {
320
+ if (!Array.isArray(identities)) {
321
+ throw new Error('recordFiledIdentities: identities must be an array');
322
+ }
323
+ if (!issue || typeof issue.number !== 'number') {
324
+ throw new Error('recordFiledIdentities: issue.number must be a number');
325
+ }
326
+
327
+ const { byFingerprint } = indexLedger(ledger);
328
+ const next = new Map(byFingerprint);
329
+ let recorded = 0;
330
+ let skipped = 0;
331
+
332
+ for (const identity of identities) {
333
+ const fingerprint = identity?.fingerprint;
334
+ if (typeof fingerprint !== 'string' || fingerprint.length === 0) continue;
335
+ const existing = byFingerprint.get(fingerprint) ?? null;
336
+ if (existing?.issue && existing.issue.state === 'closed') {
337
+ skipped += 1;
338
+ continue;
339
+ }
340
+ next.set(fingerprint, {
341
+ fingerprint,
342
+ semanticKey: identity.semanticKey ?? existing?.semanticKey ?? '',
343
+ title: identity.title ?? existing?.title ?? '',
344
+ dimension: identity.dimension ?? existing?.dimension ?? '',
345
+ primaryFile: identity.primaryFile ?? existing?.primaryFile ?? '',
346
+ status: 'filed',
347
+ issue: { number: issue.number, state: 'open', stateReason: null },
348
+ firstSeen: existing?.firstSeen ?? now,
349
+ lastSeen: now,
350
+ });
351
+ recorded += 1;
352
+ }
353
+
354
+ return {
355
+ ledger: {
356
+ $schema: ledger?.$schema ?? LEDGER_SCHEMA_URL,
357
+ generatedAt: now,
358
+ entries: [...next.values()],
359
+ },
360
+ recorded,
361
+ skipped,
362
+ };
363
+ }
@@ -36,6 +36,7 @@ import { fingerprintSeverity } from './severity.js';
36
36
  const SEP = '␟'; // unit separator — keeps fingerprint fields unambiguous
37
37
  const MARKER = 'audit-fingerprints:';
38
38
  const SEMANTIC_MARKER = 'audit-semantic-keys:';
39
+ const LABEL_MARKER = 'audit-labels:';
39
40
  export const SHA1_RE = /^[0-9a-f]{40}$/;
40
41
  // A semantic key round-trips through a comma-joined footer, so it must not
41
42
  // carry a comma or a `>` (which would truncate the HTML comment). Both are
@@ -175,6 +176,43 @@ export function parseSemanticKeyFooter(body) {
175
176
  );
176
177
  }
177
178
 
179
+ /**
180
+ * Render the machine-readable audit-label footer
181
+ * (`<!-- audit-labels: audit::x,audit::y -->`).
182
+ *
183
+ * The dedup corpus is listed by `audit::*` label, so a Story carrying none is
184
+ * absent from the pool an indexed run matches against — and with an index in
185
+ * play the exact lookup is answered locally and never reaches the provider, so
186
+ * a fingerprint footer alone cannot rescue it. Carrying the labels through the
187
+ * seed is what lets the planning path stamp them without the authoring agent
188
+ * being asked to notice them (Story #5307).
189
+ *
190
+ * @param {string | string[]} labels
191
+ * @returns {string}
192
+ */
193
+ export function auditLabelFooter(labels) {
194
+ const list = (Array.isArray(labels) ? labels : [labels])
195
+ .filter((l) => typeof l === 'string' && l.startsWith('audit::'))
196
+ .map((l) => l.replace(/[,>]/g, ' ').trim())
197
+ .filter((l) => l.length > 0);
198
+ if (list.length === 0) return '';
199
+ return `<!-- ${LABEL_MARKER} ${[...new Set(list)].sort().join(',')} -->`;
200
+ }
201
+
202
+ /**
203
+ * Read every `audit-labels` footer out of a body, de-duplicated.
204
+ *
205
+ * @param {string} body
206
+ * @returns {string[]}
207
+ */
208
+ export function parseAuditLabelFooter(body) {
209
+ return parseAllFooterValues(
210
+ body,
211
+ /<!--\s*audit-labels:\s*([^>]*?)\s*-->/g,
212
+ (s) => s.startsWith('audit::'),
213
+ );
214
+ }
215
+
178
216
  /**
179
217
  * Render the machine-readable fingerprint footer for one or more shas.
180
218
  *