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.
- package/.agents/docs/agentrc-reference.json +4 -0
- package/.agents/docs/configuration.md +3 -0
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +19 -0
- package/.agents/scripts/audit-to-stories.js +222 -75
- package/.agents/scripts/file-ci-gap.js +306 -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 +40 -52
- 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/issues-file.js +121 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
- package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
- package/.agents/scripts/lib/config-settings-schema.js +33 -0
- 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 +38 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- 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 +1 -0
- 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/plan-persist/audit-provenance.js +197 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +15 -2
- package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
- package/.agents/scripts/pr-watch-with-update.js +3 -2
- package/.agents/workflows/audit-to-stories.md +63 -27
- package/.agents/workflows/helpers/deliver-story-reference.md +19 -4
- package/.agents/workflows/helpers/plan-reference.md +23 -0
- package/.agents/workflows/mandrel-plan.md +6 -6
- package/docs/CHANGELOG.md +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/plan-persist/audit-provenance.js — what an audit-seeded
|
|
3
|
+
* plan leaves behind for the next sweep.
|
|
4
|
+
*
|
|
5
|
+
* The chained `/mandrel-plan` path files Stories from an audit seed, and until
|
|
6
|
+
* Story #5307 those Stories were invisible to the sweep that proposed them:
|
|
7
|
+
* they carried no `audit::*` label, so they were absent from the label-listed
|
|
8
|
+
* corpus dedup indexes, and nothing recorded them in the cross-run ledger. The
|
|
9
|
+
* provenance footers `carryProvenanceFooters` stamps could not cover for
|
|
10
|
+
* either — with an index in play the exact lookup is answered from that pool
|
|
11
|
+
* and never reaches the provider.
|
|
12
|
+
*
|
|
13
|
+
* Both halves ride the create loop in `story-ops.js` because that is the only
|
|
14
|
+
* seam on this path that cannot be forgotten: there is no second required pass
|
|
15
|
+
* here the way `--wire-edges` is one for the standalone filer.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_LEDGER_PATH,
|
|
20
|
+
readLedger,
|
|
21
|
+
recordFiledIdentities,
|
|
22
|
+
writeLedger,
|
|
23
|
+
} from '../../findings/audit-ledger.js';
|
|
24
|
+
import {
|
|
25
|
+
parseAuditLabelFooter,
|
|
26
|
+
parseFingerprintFooter,
|
|
27
|
+
parseSemanticKeyFooter,
|
|
28
|
+
} from '../../findings/route-finding.js';
|
|
29
|
+
import { Logger } from '../../Logger.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A fresh accumulator for one persist run's audit filings.
|
|
33
|
+
*
|
|
34
|
+
* The run collects into this and flushes once, so a plan that files N Stories
|
|
35
|
+
* leaves one reviewable ledger diff rather than N.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} [ledgerPath]
|
|
38
|
+
* @returns {{ path: string, ledger: object|null, recorded: number, ambiguous: number }}
|
|
39
|
+
*/
|
|
40
|
+
function newAuditLedgerRecord(ledgerPath) {
|
|
41
|
+
return {
|
|
42
|
+
path: ledgerPath ?? DEFAULT_LEDGER_PATH,
|
|
43
|
+
ledger: null,
|
|
44
|
+
recorded: 0,
|
|
45
|
+
ambiguous: 0,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Merge the seed's `audit::*` labels into an audit-seeded Story's labels.
|
|
51
|
+
*
|
|
52
|
+
* Taken from the **seed union**, not from the attributed provenance source: a
|
|
53
|
+
* label only scopes which issues the next sweep's corpus fetch returns, and
|
|
54
|
+
* matching inside that corpus is by fingerprint and semantic key. A superset
|
|
55
|
+
* corpus can therefore only ever find more, never less — so erring wide is the
|
|
56
|
+
* safe direction, and it keeps lens-to-label knowledge in the audit filer that
|
|
57
|
+
* owns it rather than re-deriving a dimension here (the junk-derivation trap
|
|
58
|
+
* Story #4195 closed).
|
|
59
|
+
*
|
|
60
|
+
* A non-audit seed carries no such footer, so this is a no-op there.
|
|
61
|
+
*
|
|
62
|
+
* @param {Array<object>} stories — the assembled Stories.
|
|
63
|
+
* @param {string} [provenanceSource] — the seed this plan was authored from.
|
|
64
|
+
* @returns {Array<object>}
|
|
65
|
+
*/
|
|
66
|
+
export function withAuditLabels(stories, provenanceSource) {
|
|
67
|
+
const fromSeed = parseAuditLabelFooter(provenanceSource ?? '');
|
|
68
|
+
if (fromSeed.length === 0) return stories;
|
|
69
|
+
return stories.map((story) => ({
|
|
70
|
+
...story,
|
|
71
|
+
labels: [...new Set([...story.labels, ...fromSeed])],
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Record one just-created Story in the cross-run audit ledger.
|
|
77
|
+
*
|
|
78
|
+
* The identities are read back off the body this run assembled, so what is
|
|
79
|
+
* recorded is exactly what was stamped — attribution already applied. A Story
|
|
80
|
+
* from a non-audit plan carries no footers and records nothing, which is why
|
|
81
|
+
* a `--seed` or `--tickets` run never touches the ledger file at all.
|
|
82
|
+
*
|
|
83
|
+
* **The union fallback is deliberately NOT recorded.** When the seed carried
|
|
84
|
+
* footers but the plan attributed none per-Story, every sibling carries every
|
|
85
|
+
* fingerprint; binding a finding to one of them would be a coin flip, and a
|
|
86
|
+
* wrong binding is worse than none — the finding would be suppressed against an
|
|
87
|
+
* Issue that never tracked it, or resurrected when an unrelated Story closed.
|
|
88
|
+
* The audit path stamps `provenance` mechanically, so this is the exception.
|
|
89
|
+
*
|
|
90
|
+
* @param {{ story: object, id: number, ledgerRecord: object, attributed: boolean }} args
|
|
91
|
+
*/
|
|
92
|
+
function recordAuditFiling({ story, id, ledgerRecord, attributed }) {
|
|
93
|
+
const fingerprints = parseFingerprintFooter(story.body);
|
|
94
|
+
if (fingerprints.length === 0) return;
|
|
95
|
+
if (!attributed) {
|
|
96
|
+
ledgerRecord.ambiguous += fingerprints.length;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const semanticKeys = parseSemanticKeyFooter(story.body);
|
|
100
|
+
const identities = fingerprints.map((fingerprint, i) => ({
|
|
101
|
+
fingerprint,
|
|
102
|
+
semanticKey: semanticKeys[i] ?? '',
|
|
103
|
+
title: story.title,
|
|
104
|
+
}));
|
|
105
|
+
const { ledger, recorded } = recordFiledIdentities({
|
|
106
|
+
ledger: ledgerRecord.ledger ?? readLedger(ledgerRecord.path),
|
|
107
|
+
identities,
|
|
108
|
+
issue: { number: id },
|
|
109
|
+
});
|
|
110
|
+
ledgerRecord.ledger = ledger;
|
|
111
|
+
ledgerRecord.recorded += recorded;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Persist the run's audit filings, once, after every Story exists.
|
|
116
|
+
*
|
|
117
|
+
* Writing per-Story would rewrite a committed baseline N times for one plan;
|
|
118
|
+
* writing once keeps the diff to a single reviewable change. A run that
|
|
119
|
+
* recorded nothing writes nothing.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} ledgerRecord
|
|
122
|
+
* @param {{ warn: Function }} logger
|
|
123
|
+
*/
|
|
124
|
+
function flushAuditLedger(ledgerRecord, logger) {
|
|
125
|
+
if (ledgerRecord.ambiguous > 0) {
|
|
126
|
+
logger.warn(
|
|
127
|
+
`[plan-persist] audit ledger: ${ledgerRecord.ambiguous} identity(ies) not recorded — ` +
|
|
128
|
+
'the seed carried provenance footers but no Story attributed them, so ownership is ' +
|
|
129
|
+
'ambiguous. Author per-Story `provenance` to record them.',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (!ledgerRecord.ledger || ledgerRecord.recorded === 0) return;
|
|
133
|
+
writeLedger(ledgerRecord.path, ledgerRecord.ledger);
|
|
134
|
+
logger.warn(
|
|
135
|
+
`[plan-persist] audit ledger: recorded ${ledgerRecord.recorded} filed finding(s) ` +
|
|
136
|
+
`to ${ledgerRecord.path}.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Record everything one persist run filed, once, after every Story exists.
|
|
142
|
+
*
|
|
143
|
+
* Driven from the persist orchestrator rather than from inside the create loop:
|
|
144
|
+
* the loop's job is to create issues, and this is the only other place that
|
|
145
|
+
* always runs after it. Either seam is unforgettable — there is no second
|
|
146
|
+
* required pass on this path the way `--wire-edges` is one for the standalone
|
|
147
|
+
* filer — and keeping the side effect at the orchestration layer leaves the
|
|
148
|
+
* create loop doing one thing.
|
|
149
|
+
*
|
|
150
|
+
* One write per plan, not per Story: the ledger is committed state, and N
|
|
151
|
+
* rewrites of it for one plan is a diff nobody can review. A run that recorded
|
|
152
|
+
* nothing — a non-audit plan, or one whose identities were all union-carried —
|
|
153
|
+
* writes nothing at all.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} params
|
|
156
|
+
* @param {Array<object>} params.stories — the assembled Stories.
|
|
157
|
+
* @param {Array<{ slug: string, id: number }>} params.created — persist receipts.
|
|
158
|
+
* @param {Array<object>} params.tickets — the raw authored tickets.
|
|
159
|
+
* @param {string} [params.ledgerPath]
|
|
160
|
+
* @param {{ warn: Function }} [params.logger]
|
|
161
|
+
* @param {boolean} [params.dryRun] — a dry run records nothing at all.
|
|
162
|
+
* @returns {{ recorded: number, ambiguous: number }}
|
|
163
|
+
*/
|
|
164
|
+
export function recordAuditFilings({
|
|
165
|
+
stories,
|
|
166
|
+
created,
|
|
167
|
+
tickets,
|
|
168
|
+
ledgerPath,
|
|
169
|
+
logger,
|
|
170
|
+
dryRun = false,
|
|
171
|
+
}) {
|
|
172
|
+
// A dry run created no issue to record against, and must not touch committed
|
|
173
|
+
// state. Owned here rather than at the call site so the contract is testable.
|
|
174
|
+
if (dryRun) return { recorded: 0, ambiguous: 0 };
|
|
175
|
+
const record = newAuditLedgerRecord(ledgerPath);
|
|
176
|
+
const idBySlug = new Map((created ?? []).map((c) => [c.slug, c.id]));
|
|
177
|
+
// Attribution is a property of what the plan AUTHORED, so it is read off the
|
|
178
|
+
// raw tickets rather than threaded through assembly: a Story that declared no
|
|
179
|
+
// `provenance` inherited the seed union, where ownership is a coin flip.
|
|
180
|
+
const attributedSlugs = new Set(
|
|
181
|
+
(tickets ?? [])
|
|
182
|
+
.filter((t) => t?.provenance !== undefined && t?.provenance !== null)
|
|
183
|
+
.map((t) => t?.slug),
|
|
184
|
+
);
|
|
185
|
+
for (const story of stories ?? []) {
|
|
186
|
+
const id = idBySlug.get(story.slug);
|
|
187
|
+
if (typeof id !== 'number') continue;
|
|
188
|
+
recordAuditFiling({
|
|
189
|
+
story,
|
|
190
|
+
id,
|
|
191
|
+
ledgerRecord: record,
|
|
192
|
+
attributed: attributedSlugs.has(story.slug),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
flushAuditLedger(record, logger ?? Logger);
|
|
196
|
+
return { recorded: record.recorded, ambiguous: record.ambiguous };
|
|
197
|
+
}
|
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
renderHardConflictError,
|
|
69
69
|
} from '../ticket-validator-conflicts.js';
|
|
70
70
|
import { upsertStructuredComment } from '../ticketing.js';
|
|
71
|
+
import { recordAuditFilings, withAuditLabels } from './audit-provenance.js';
|
|
71
72
|
import {
|
|
72
73
|
resolveContainerEpic,
|
|
73
74
|
resolveCrossPlanLinks,
|
|
@@ -780,7 +781,8 @@ export async function runPlanPersist({
|
|
|
780
781
|
});
|
|
781
782
|
|
|
782
783
|
// Split policy + inline Spec fold (over-budget Specs fail closed — no docs/).
|
|
783
|
-
const
|
|
784
|
+
const seedContent = planContextEnvelope?.seed?.content ?? '';
|
|
785
|
+
const { stories: assembled } = assemblePlanStories(rawStories, {
|
|
784
786
|
sharedSpec: techSpecContent,
|
|
785
787
|
planAcceptance: planAcceptance ?? undefined,
|
|
786
788
|
sourceTicketIds,
|
|
@@ -791,9 +793,16 @@ export async function runPlanPersist({
|
|
|
791
793
|
// is the **fallback** — it is carried onto every Story that did not
|
|
792
794
|
// attribute its own `provenance`, which keeps an un-attributed plan exactly
|
|
793
795
|
// as recall-safe as it was. Empty for a `--tickets` run, a no-op there.
|
|
794
|
-
provenanceSource:
|
|
796
|
+
provenanceSource: seedContent,
|
|
795
797
|
});
|
|
796
798
|
|
|
799
|
+
// Stamp the `audit::*` labels the dedup corpus is listed by. Without them a
|
|
800
|
+
// Story this path files is absent from the pool an indexed sweep matches
|
|
801
|
+
// against, and an indexed run answers exact lookups from that pool without
|
|
802
|
+
// ever reaching the provider — so the provenance footers alone leave it
|
|
803
|
+
// invisible (Story #5307). A non-audit seed carries none: a no-op there.
|
|
804
|
+
const stories = withAuditLabels(assembled, seedContent);
|
|
805
|
+
|
|
797
806
|
// Story #5045: the cross-Story conflict passes re-run over the assembled,
|
|
798
807
|
// footer-stamped bodies — the artifact persist actually writes — before any
|
|
799
808
|
// GitHub call, so a policy upgrade still refuses the plan pre-creation.
|
|
@@ -824,6 +833,10 @@ export async function runPlanPersist({
|
|
|
824
833
|
opts: { dryRun, routeLabel: isLiteRoute ? LITE_ROUTE_LABEL : null },
|
|
825
834
|
});
|
|
826
835
|
|
|
836
|
+
// What this run filed, recorded where the next audit sweep reads it
|
|
837
|
+
// (Story #5307). A dry run created nothing, and the call knows it.
|
|
838
|
+
recordAuditFilings({ stories, created, tickets: rawStories, dryRun });
|
|
839
|
+
|
|
827
840
|
const primary = created[0];
|
|
828
841
|
const waveTable = buildWaveTable(
|
|
829
842
|
stories.map((s) => ({
|
|
@@ -683,10 +683,10 @@ async function executeFollowUpRollup({
|
|
|
683
683
|
provider,
|
|
684
684
|
config,
|
|
685
685
|
currentRepo: repos.currentRepo,
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
686
|
+
// The resolved bucket object, not a re-split of the slug: routing is
|
|
687
|
+
// decided once in `github/framework-repo.js`.
|
|
688
|
+
frameworkRepo: repos.repos.framework,
|
|
689
|
+
platformRepo: repos.repos.platform,
|
|
690
690
|
routedProposals: proposals,
|
|
691
691
|
cwd,
|
|
692
692
|
});
|
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
|
|
12
12
|
import { signalsFile } from '../config/temp-paths.js';
|
|
13
13
|
import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_FRAMEWORK_REPO,
|
|
16
|
+
formatRepoSlug,
|
|
17
|
+
resolveOwnershipRepos,
|
|
18
|
+
} from '../github/framework-repo.js';
|
|
15
19
|
import { Logger } from '../Logger.js';
|
|
16
20
|
import { normalizeGatheredSignal } from '../observability/runtime-friction.js';
|
|
17
21
|
import {
|
|
@@ -54,28 +58,36 @@ function resolveFrictionWindowDays(config) {
|
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
/**
|
|
61
|
+
* Resolve the follow-up ownership buckets for the retro composer and the
|
|
62
|
+
* graduator walk.
|
|
63
|
+
*
|
|
64
|
+
* Routing itself lives in `github/framework-repo.js` — this is the slug-shaped
|
|
65
|
+
* adapter the composer wants (`composeRoutedProposals` renders slugs in prose)
|
|
66
|
+
* plus the `{owner, repo}` map `graduate()` routes on. The `platform` bucket
|
|
67
|
+
* has **no default**: an unconfigured shared-infra repo stays `null` so a
|
|
68
|
+
* caller reports it rather than filing platform-owned work somewhere plausible.
|
|
69
|
+
*
|
|
57
70
|
* @param {object} [config]
|
|
58
|
-
* @returns {{ frameworkRepo: string, consumerRepo: string, currentRepo: { owner: string, repo: string } }}
|
|
71
|
+
* @returns {{ frameworkRepo: string, consumerRepo: string, platform: (string|null), currentRepo: { owner: string, repo: string }, repos: { consumer: object|null, framework: object|null, platform: object|null } }}
|
|
59
72
|
*/
|
|
60
73
|
export function resolveFollowUpRepos(config) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const frameworkRepo =
|
|
68
|
-
typeof config?.github?.frameworkRepo === 'string' &&
|
|
69
|
-
config.github.frameworkRepo.trim()
|
|
70
|
-
? config.github.frameworkRepo.trim()
|
|
71
|
-
: DEFAULT_FRAMEWORK_REPO;
|
|
74
|
+
const repos = resolveOwnershipRepos(config);
|
|
75
|
+
const consumerRepo = formatRepoSlug(repos.consumer) ?? DEFAULT_FRAMEWORK_REPO;
|
|
76
|
+
// `resolveOwnershipRepos` always resolves the framework bucket (it defaults
|
|
77
|
+
// to the mirror constant), and both slugs above are well-formed by
|
|
78
|
+
// construction — so neither the render nor the split can yield a blank half.
|
|
79
|
+
const frameworkRepo = formatRepoSlug(repos.framework);
|
|
72
80
|
const [cOwner, cRepo] = consumerRepo.split('/');
|
|
81
|
+
const currentRepo = { owner: cOwner, repo: cRepo };
|
|
73
82
|
return {
|
|
74
83
|
frameworkRepo,
|
|
75
84
|
consumerRepo,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
platform: formatRepoSlug(repos.platform),
|
|
86
|
+
currentRepo,
|
|
87
|
+
repos: {
|
|
88
|
+
consumer: currentRepo,
|
|
89
|
+
framework: repos.framework,
|
|
90
|
+
platform: repos.platform,
|
|
79
91
|
},
|
|
80
92
|
};
|
|
81
93
|
}
|
|
@@ -749,10 +761,10 @@ export async function captureStoryFollowUps({
|
|
|
749
761
|
provider,
|
|
750
762
|
config,
|
|
751
763
|
currentRepo: repos.currentRepo,
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
764
|
+
// The resolved bucket object, not a re-split of the slug: routing is
|
|
765
|
+
// decided once in `github/framework-repo.js`.
|
|
766
|
+
frameworkRepo: repos.repos.framework,
|
|
767
|
+
platformRepo: repos.repos.platform,
|
|
756
768
|
routedProposals: proposals,
|
|
757
769
|
cwd,
|
|
758
770
|
});
|
|
@@ -66,7 +66,8 @@
|
|
|
66
66
|
* green can already have merged by the time it is observed. On red the
|
|
67
67
|
* watcher disarms auto-merge and records the head SHA; on green it reads
|
|
68
68
|
* the digest and adjudicates: a green on the SAME head SHA is a forbidden
|
|
69
|
-
* re-run (exit 1, `agent::blocked`, `
|
|
69
|
+
* re-run (exit 1, `agent::blocked`, a `file-ci-gap.js` intake filing
|
|
70
|
+
* required), while
|
|
70
71
|
* a green on a NEW head SHA is a fix at source — the digest is retired,
|
|
71
72
|
* auto-merge is re-armed, and the delivery proceeds. A delivery that never
|
|
72
73
|
* went red has no digest and is untouched. Mechanism:
|
|
@@ -475,7 +476,7 @@ async function evaluateGreenWatch({
|
|
|
475
476
|
`[pr-watch] run link: ${digest.runUrl ?? `run id ${digest.runId ?? 'unresolved'}`} — classification: ${digest.classification ?? 'unknown'}`,
|
|
476
477
|
);
|
|
477
478
|
logger.error?.(
|
|
478
|
-
'[pr-watch] fix the root cause and push a new commit, or
|
|
479
|
+
'[pr-watch] fix the root cause and push a new commit, or — when the root cause is outside this delivery — run `node .agents/scripts/file-ci-gap.js --story <id> --verdict <verdict> --owner <bucket> --block` to file the routed, deduped intake issue.',
|
|
479
480
|
);
|
|
480
481
|
const outcome = await blockFn({ storyId, body });
|
|
481
482
|
return {
|
|
@@ -181,6 +181,14 @@ persists, via `carryProvenanceFooters`
|
|
|
181
181
|
The carry is additive, union-preserving and idempotent, so a resumed persist
|
|
182
182
|
cannot stack footers and a hand-authored fingerprint is never dropped.
|
|
183
183
|
|
|
184
|
+
**Persist records the ledger and the labels too.** It stamps the seed's
|
|
185
|
+
`audit::<dimension>` labels — the dedup corpus is listed by them, and an indexed
|
|
186
|
+
run answers lookups from that pool without reaching the provider, so a footer
|
|
187
|
+
alone leaves a plan-path Story invisible — and records each Story's
|
|
188
|
+
**attributed** identities. Union-only identities are not recorded: every sibling
|
|
189
|
+
carries every fingerprint, so an owner would be a coin flip; persist says so on
|
|
190
|
+
stderr. Author per-Story `provenance` to record them.
|
|
191
|
+
|
|
184
192
|
This is deliberately not an authoring step. It used to be: the footers reached
|
|
185
193
|
the seed and stopped there, leaving the authoring agent to notice HTML comments
|
|
186
194
|
in a one-pager and copy them forward — a remembered step, which is to say no
|
|
@@ -243,6 +251,14 @@ every Story that has a resolvable blocker with a canonical
|
|
|
243
251
|
GitHub `blocked_by` relations. An edge whose target was never opened (deduped,
|
|
244
252
|
ledger-suppressed) drops rather than becoming a `blocked by #undefined`.
|
|
245
253
|
|
|
254
|
+
**This pass also records the ledger.** The `--ids` map is the only artifact
|
|
255
|
+
carrying the numbers just opened, so `--wire-edges` folds in the cross-run
|
|
256
|
+
record: each mapped group's findings are written `filed` against its Issue
|
|
257
|
+
(`--ledger <path>`, default `baselines/audit-ledger.json`; `--dry-run`
|
|
258
|
+
suppresses the write). The record runs **before** the provider loads, so a host
|
|
259
|
+
with no `gh` still remembers what it filed. Without it nothing ever writes
|
|
260
|
+
`filed` and the ledger suppresses nothing.
|
|
261
|
+
|
|
246
262
|
**Do not skip this.** `/mandrel-deliver` has no other source for this cohort's order:
|
|
247
263
|
its footprint guard ignores the shared provenance footers, so an unwired cohort
|
|
248
264
|
is genuinely unordered and `/mandrel-deliver` will co-dispatch Stories the edges say
|
|
@@ -277,31 +293,45 @@ the `classifications` array:
|
|
|
277
293
|
is skipped by default; flag in the Phase 7 summary so the operator
|
|
278
294
|
can decide whether to reopen.
|
|
279
295
|
|
|
280
|
-
`routeFinding`
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
Dedup runs in **two stages** when a provider resolves: a
|
|
290
|
-
meaning-first **semantic candidate** pass (`searchCandidates`, wired to
|
|
291
|
-
[`lib/findings/semantic-issue-search.js`](../scripts/lib/findings/semantic-issue-search.js))
|
|
292
|
-
runs FIRST and widens the net across open + closed issues; the exact
|
|
293
|
-
**fingerprint / semantic-key** confirmation runs SECOND. A finding whose title
|
|
294
|
-
was reworded but whose *location* is unchanged still confirms against the Issue
|
|
295
|
-
that already tracks that location, because the audit filers stamp a
|
|
296
|
-
location-based `audit-semantic-keys` footer alongside the `audit-fingerprints`
|
|
297
|
-
footer. Filings from the
|
|
296
|
+
`routeFinding` reads open + closed issues through two ports, which **both run
|
|
297
|
+
and union their pools** — the exact `searchIssues` lookup, and the meaning-first
|
|
298
|
+
`searchCandidates` pass wired to
|
|
299
|
+
[`lib/findings/semantic-issue-search.js`](../scripts/lib/findings/semantic-issue-search.js).
|
|
300
|
+
Confirmation then filters that union by footer, dropping hits whose body
|
|
301
|
+
mentions a sha in prose without the canonical marker. A finding reworded but
|
|
302
|
+
unmoved still confirms, because the filers stamp a location-based
|
|
303
|
+
`audit-semantic-keys` footer
|
|
304
|
+
beside `audit-fingerprints`; so do
|
|
298
305
|
[`retro-proposals-graduator`](../scripts/lib/feedback-loop/retro-proposals-graduator.js)
|
|
299
|
-
|
|
300
|
-
|
|
306
|
+
filings, which a sweep therefore never re-files.
|
|
307
|
+
|
|
308
|
+
Where the corpus is pre-fetched — either off the list endpoint or from
|
|
309
|
+
`--issues-file` — the exact lookup is answered from that local index and the
|
|
310
|
+
search API is spent only on findings with no exact hit. The workflow owns **no**
|
|
311
|
+
parallel dedup or footer-parsing code: fingerprint, footer round-trip, and
|
|
312
|
+
routing all live in that one shared module.
|
|
313
|
+
|
|
314
|
+
### When there is no `gh` CLI
|
|
315
|
+
|
|
316
|
+
**No GitHub access** (air-gapped): pass `--no-provider` to `--scan`. Every
|
|
317
|
+
group is classified `create` and the operator is told dedupe was skipped — a
|
|
318
|
+
re-run opens duplicates.
|
|
319
|
+
|
|
320
|
+
**Reachable, but not through `gh`** (a cloud sandbox: no `gh`, no direct API,
|
|
321
|
+
MCP fine): fetch the corpus yourself. List every issue labelled `audit::*` at
|
|
322
|
+
state `all` — `mcp__github__list_issues` or any other path — write the raw
|
|
323
|
+
result as a JSON array, and pass it:
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
node .agents/scripts/audit-to-stories.js --scan --no-provider \
|
|
327
|
+
--issues-file temp/audits/issues.json --glob "temp/audits/audit-*-results.md"
|
|
328
|
+
```
|
|
301
329
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
330
|
+
Real `skip-open` / `skip-reoccurring` classifications come back. The corpus is
|
|
331
|
+
normalised on load, so a raw list result works as-is; only `number` and `body`
|
|
332
|
+
are read. An unreadable file is a hard error, never a silent fall-back to an
|
|
333
|
+
unchecked run; an **empty** array — a valid first sweep — is reported with its
|
|
334
|
+
count so it cannot pass for a failed fetch.
|
|
305
335
|
|
|
306
336
|
### Cross-run ledger
|
|
307
337
|
|
|
@@ -314,8 +344,12 @@ shape). Each entry is keyed by the finding's fingerprint plus a location-based
|
|
|
314
344
|
(`new | filed | fixed | accepted-risk | regressed`). A finding whose tracking
|
|
315
345
|
Issue was closed as `not_planned` becomes `accepted-risk` and is **suppressed**
|
|
316
346
|
on every later scan; a `fixed` finding that re-appears becomes `regressed`. The
|
|
317
|
-
ledger is written by the unattended `--auto` sweep
|
|
318
|
-
run
|
|
347
|
+
ledger is written by the unattended `--auto` sweep, by any `--scan --ledger`
|
|
348
|
+
run, by the Phase 5c `--wire-edges` pass, and by `plan-persist` on the Phase 5a
|
|
349
|
+
chained path — the two filing paths both record what they filed; the
|
|
350
|
+
plain `--scan` path leaves it untouched. A finding whose resolved Issue is open
|
|
351
|
+
is recorded `filed` and is known on re-detection; a closed Issue still outranks
|
|
352
|
+
that.
|
|
319
353
|
|
|
320
354
|
## Phase 7 — Summary & cleanup
|
|
321
355
|
|
|
@@ -391,8 +425,10 @@ The routine shape is **lenses full-scope → dry-run → live with a ledger PR**
|
|
|
391
425
|
from `delivery.auditToStories.severityFloor` (default `high`, overridable with
|
|
392
426
|
`--severity`), applies the two-stage dedup, reconciles the cross-run ledger,
|
|
393
427
|
and prints a run-summary JSON (create / skip-open / skip-reoccurring /
|
|
394
|
-
suppressed-by-ledger tallies,
|
|
395
|
-
|
|
428
|
+
suppressed-by-ledger tallies, the `create`-classified group keys the `--ids`
|
|
429
|
+
map is built from, plus the re-detected open Issue numbers an operator may want
|
|
430
|
+
a "re-detected" comment on). It opens no Issues itself, so run Phase 5c after
|
|
431
|
+
filing or the sweep's memory stays empty. `--dry-run` performs zero GitHub
|
|
396
432
|
writes and skips the ledger write, emitting only the summary.
|
|
397
433
|
|
|
398
434
|
`--auto` **fails closed on any `summary.reportFailures[]` entry** (Phase 1): an
|
|
@@ -682,13 +682,28 @@ When the watch exits, branch on the exit code:
|
|
|
682
682
|
|
|
683
683
|
**Triage authority.** How to classify and remediate a red (or repeatedly slow)
|
|
684
684
|
check — the root-cause-only decision tree for infra/transient and flaky failures
|
|
685
|
-
(reproduce → check `main` → bisect env vs code → fix in-scope or
|
|
686
|
-
|
|
687
|
-
and the escalation criteria (three-strikes, the 30-minute
|
|
688
|
-
and the clearly-environmental fast path) — is defined once in
|
|
685
|
+
(reproduce → check `main` → bisect env vs code → fix in-scope, or reach an
|
|
686
|
+
Option-2 verdict and file the intake issue), the never-rerun / never-quarantine
|
|
687
|
+
prohibitions, and the escalation criteria (three-strikes, the 30-minute
|
|
688
|
+
wall-clock timebox, and the clearly-environmental fast path) — is defined once in
|
|
689
689
|
[`.agents/rules/ci-remediation.md`](../../rules/ci-remediation.md). Read it
|
|
690
690
|
before remediating a red check.
|
|
691
691
|
|
|
692
|
+
**Filing an out-of-scope root cause is one command, never a hand-run `gh issue
|
|
693
|
+
create`:**
|
|
694
|
+
|
|
695
|
+
```bash
|
|
696
|
+
node <agentRoot>/scripts/file-ci-gap.js --story <storyId> \
|
|
697
|
+
--verdict <pre-existing|capacity|unreproducible-tier> \
|
|
698
|
+
--owner <consumer|framework|platform> --evidence "<proof reading>" [--block]
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
It reads the digest, routes the filing to the repo that owns the fault, updates
|
|
702
|
+
the existing ticket when the signature is a repeat, posts the `friction`
|
|
703
|
+
comment, and with `--block` flips the Story. What it files is an **intake**
|
|
704
|
+
issue, not a Story — `/mandrel-plan <issue number>` graduates it on the next
|
|
705
|
+
planning pass, so the delivery never waits on planning.
|
|
706
|
+
|
|
692
707
|
### The auto-merge wait is an internally-blocking step
|
|
693
708
|
|
|
694
709
|
This is the single most important contract of this workflow, and the seam
|
|
@@ -99,6 +99,29 @@ On a truthy `memoryPoolAdvisory.recommend`, name
|
|
|
99
99
|
`reasons[]`. Purely advisory: a stale pool degrades recall, it does not make
|
|
100
100
|
the plan wrong, so it never blocks and never reroutes.
|
|
101
101
|
|
|
102
|
+
## Gate #1 → graduating a CI-gap intake filing (`intake`)
|
|
103
|
+
|
|
104
|
+
The envelope's `priorFeedback` arrays carry the open `meta::*` feedback issues.
|
|
105
|
+
A row flagged `intake: true` is a **CI-gap intake filing** — written by
|
|
106
|
+
[`file-ci-gap.js`](../../scripts/file-ci-gap.js) when a delivery reached an
|
|
107
|
+
Option-2 verdict in [`ci-remediation.md`](../../rules/ci-remediation.md) and the
|
|
108
|
+
root cause was outside its scope. It carries evidence (failure signature, run
|
|
109
|
+
link, occurrence history, ownership routing) but **no `## Spec`, no
|
|
110
|
+
`acceptance[]` / `verify[]` and no `agent::*` label**, so `/mandrel-deliver`
|
|
111
|
+
cannot take it: it is intake awaiting graduation, by design. Delivery files it
|
|
112
|
+
and moves on rather than blocking on a planning pass nobody is present for.
|
|
113
|
+
|
|
114
|
+
Graduating one is exactly **tickets mode**: `/mandrel-plan <issue number>`
|
|
115
|
+
rewrites it into a Story, `supersedes[]` claims it, and persist closes it.
|
|
116
|
+
|
|
117
|
+
At Gate #1, in **ask** and **seed** mode, name any open intake rows and offer
|
|
118
|
+
that instead of the seed in front of you — a filing that keeps recurring
|
|
119
|
+
(its `## Occurrences` table is the count) is usually the better next Story than
|
|
120
|
+
whatever prompted this run. It is **advisory**: never reroute automatically, and
|
|
121
|
+
skip the offer entirely under `--yes`, where nobody is at the keyboard to take
|
|
122
|
+
it. A `platformGaps[]` row is the same shape with a different owner — the
|
|
123
|
+
Story it graduates into may well be a config or runbook change rather than code.
|
|
124
|
+
|
|
102
125
|
## Gate #1 → the light path (in-session handoff)
|
|
103
126
|
|
|
104
127
|
On a confirmed `deliverLightSuggestion`, `/mandrel-plan` routes into
|
|
@@ -58,10 +58,9 @@ and derives source ids from its `sourceTickets[]`; it also writes
|
|
|
58
58
|
|
|
59
59
|
The envelope carries docs context, the story-author prompt, `sourceTickets[]`,
|
|
60
60
|
`duplicates[]` (open **Stories**, never Epics), `epicCandidates[]` +
|
|
61
|
-
`dependencyCandidates[]` (Gate #3; path collisions) and
|
|
62
|
-
`complexitySignals` (**no routing authority**). A trivial scope
|
|
63
|
-
lite route at persist
|
|
64
|
-
([ref](helpers/plan-reference.md)).
|
|
61
|
+
`dependencyCandidates[]` (Gate #3; path collisions), `priorFeedback` and
|
|
62
|
+
advisory `complexitySignals` (**no routing authority**). A trivial scope claims
|
|
63
|
+
the lite route at persist, failing closed to `full`.
|
|
65
64
|
|
|
66
65
|
**Triage each unknown by resolver** ([ref](helpers/plan-reference.md)): an
|
|
67
66
|
**AFK** unknown (research settles it) is resolved before authoring, never
|
|
@@ -69,8 +68,9 @@ assumed; a **HITL** unknown goes to Gate #1. Under `--yes` do not ask free-form
|
|
|
69
68
|
operator questions — AFK unknowns are still researched; only HITL unknowns land
|
|
70
69
|
in Key Assumptions, each a decision-made-by-default.
|
|
71
70
|
|
|
72
|
-
**Gate #1** — STOP to confirm the sharpened plan intent
|
|
73
|
-
duplicate-candidate review
|
|
71
|
+
**Gate #1** — STOP to confirm the sharpened plan intent, any
|
|
72
|
+
duplicate-candidate review, and any `intake` row worth graduating
|
|
73
|
+
([ref](helpers/plan-reference.md)). Under `--yes`, auto-proceed.
|
|
74
74
|
|
|
75
75
|
On a truthy `deliverLightSuggestion.suggested`, offer — advisory, never an
|
|
76
76
|
automatic reroute — to deliver the seed instead; on confirm route **in this
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,16 @@ All notable changes to this project will be documented in this file.
|
|
|
15
15
|
-->
|
|
16
16
|
<!-- markdownlint-disable-file MD004 MD012 MD037 -->
|
|
17
17
|
|
|
18
|
+
## [2.56.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.55.0...mandrel-v2.56.0) (2026-09-11)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
* audit-to-stories: dedup against a host-supplied issue index, so a sweep on a gh-less host stops re-filing what it already filed ([#5301](https://github.com/dsj1984/mandrel/issues/5301)) ([#5304](https://github.com/dsj1984/mandrel/issues/5304)) ([1b8abf0](https://github.com/dsj1984/mandrel/commit/1b8abf072a05db65e54a63cb8ec6cf6f06bc80ab))
|
|
24
|
+
* audit-to-stories: make Phase 5a filings visible to the next sweep — record the ledger from plan-persist and stamp the audit labels the corpus is listed by ([#5307](https://github.com/dsj1984/mandrel/issues/5307)) ([#5308](https://github.com/dsj1984/mandrel/issues/5308)) ([094ba8a](https://github.com/dsj1984/mandrel/commit/094ba8ae6e2640ba3ac7cb5a4aeb9309aa72e42d))
|
|
25
|
+
* audit-to-stories: record filed Issues in the cross-run ledger, so its suppression branch stops being unreachable ([#5305](https://github.com/dsj1984/mandrel/issues/5305)) ([#5306](https://github.com/dsj1984/mandrel/issues/5306)) ([d5e36ab](https://github.com/dsj1984/mandrel/commit/d5e36ab796f6946bf93d7161efa1470cad8a0a2b))
|
|
26
|
+
* cI-gap intake: script-backed, repo-routed, deduped meta filings that /mandrel-plan can graduate ([#5300](https://github.com/dsj1984/mandrel/issues/5300)) ([#5302](https://github.com/dsj1984/mandrel/issues/5302)) ([37bc287](https://github.com/dsj1984/mandrel/commit/37bc2875ed8c98298afd90c72ece4f318e2c233d))
|
|
27
|
+
|
|
18
28
|
## [2.55.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.54.0...mandrel-v2.55.0) (2026-09-11)
|
|
19
29
|
|
|
20
30
|
|
package/package.json
CHANGED