mandrel 2.38.0 → 2.40.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/README.md +51 -11
- package/.agents/agents/auditor.md +5 -0
- package/.agents/docs/SDLC.md +21 -12
- package/.agents/docs/agentrc-reference.json +1 -4
- package/.agents/docs/configuration.md +2 -2
- package/.agents/instructions.md +17 -16
- package/.agents/schemas/agentrc.schema.json +6 -7
- package/.agents/scripts/audit-to-stories.js +510 -66
- package/.agents/scripts/generate-skills-index.js +158 -75
- package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +39 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
- package/.agents/scripts/lib/changed-files.js +100 -9
- package/.agents/scripts/lib/config-settings-schema.js +25 -7
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/label-constants.js +18 -0
- package/.agents/scripts/lib/label-taxonomy.js +18 -5
- package/.agents/scripts/lib/orchestration/epic-container.js +186 -0
- package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +320 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +18 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +58 -6
- package/.agents/scripts/lib/skills/skills-index.js +168 -0
- package/.agents/scripts/lib/skills/walk-skill-files.js +133 -9
- package/.agents/scripts/plan-persist.js +39 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
- package/.agents/scripts/quality-preview.js +50 -9
- package/.agents/scripts/resolve-stories.js +42 -2
- package/.agents/scripts/validate-skills.js +53 -66
- package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
- package/.agents/workflows/audit-to-stories.md +85 -7
- package/.agents/workflows/helpers/audit-lens-core.md +24 -4
- package/.agents/workflows/helpers/deliver-reference.md +8 -0
- package/.agents/workflows/helpers/plan-reference.md +28 -0
- package/.agents/workflows/mandrel-deliver.md +47 -43
- package/.agents/workflows/mandrel-plan.md +44 -38
- package/.agents/workflows/qa-run.md +13 -5
- package/docs/CHANGELOG.md +28 -0
- package/package.json +1 -1
|
@@ -12,9 +12,18 @@
|
|
|
12
12
|
* 2. `npm run quality:watch` — chokidar wrapper re-emits on save.
|
|
13
13
|
* 3. `.husky/pre-commit` — block the commit on threshold violations.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* pre-commit
|
|
15
|
+
* The pre-commit hook passes `--staged` and nothing else — the index is
|
|
16
|
+
* already the exact delta the operator is about to commit, and
|
|
17
|
+
* `tests/pre-commit-hook.test.js` pins that `--changed-since` stays off it.
|
|
18
|
+
* (This docblock previously claimed the hook passed `--changed-since HEAD`,
|
|
19
|
+
* describing a wiring the hook has not used for some time; the stale prose
|
|
20
|
+
* sent at least one bug report at the wrong flag — Story #5131.)
|
|
21
|
+
*
|
|
22
|
+
* `--staged` is merge-aware: while a merge is in progress the index is read
|
|
23
|
+
* against `MERGE_HEAD` rather than `HEAD`, so a base-sync merge commit is
|
|
24
|
+
* scored for the merging branch's own work and its conflict resolutions, not
|
|
25
|
+
* for everything the base branch landed. See `resolveMergeHead` in
|
|
26
|
+
* `lib/changed-files.js`.
|
|
18
27
|
*
|
|
19
28
|
* The CLI exits 0 when both envelopes report zero violations and the script
|
|
20
29
|
* could not surface a regression. Any violation in either envelope, or any
|
|
@@ -29,6 +38,7 @@ import {
|
|
|
29
38
|
runCrapPreview,
|
|
30
39
|
runMaintainabilityPreview,
|
|
31
40
|
} from './lib/baselines/preview-gates.js';
|
|
41
|
+
import { resolveMergeHead } from './lib/changed-files.js';
|
|
32
42
|
import { respondToHelp } from './lib/cli-usage.js';
|
|
33
43
|
import { getQuality, resolveConfig } from './lib/config-resolver.js';
|
|
34
44
|
import { resolveCyclomaticPolicy } from './lib/cyclomatic-ceiling.js';
|
|
@@ -39,7 +49,10 @@ const USAGE = {
|
|
|
39
49
|
summary:
|
|
40
50
|
'Preview the per-file maintainability and CRAP deltas for the change set, and exit non-zero on any threshold violation.',
|
|
41
51
|
flags: [
|
|
42
|
-
[
|
|
52
|
+
[
|
|
53
|
+
'--staged',
|
|
54
|
+
'Score the git index only (the pre-commit-hook scope). During a merge the index is read against MERGE_HEAD.',
|
|
55
|
+
],
|
|
43
56
|
[
|
|
44
57
|
'--changed-since <ref>',
|
|
45
58
|
'Score the diff against <ref> (default: HEAD). Last occurrence wins.',
|
|
@@ -388,6 +401,35 @@ function runGateSafely(runner, args, label, stderr) {
|
|
|
388
401
|
});
|
|
389
402
|
}
|
|
390
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Render the scope header line.
|
|
406
|
+
*
|
|
407
|
+
* Story #5131 — when `--staged` runs during a merge the scope is re-based to
|
|
408
|
+
* `MERGE_HEAD`, and the header says so. Without that line the operator sees a
|
|
409
|
+
* table whose row count does not match `git diff --cached` with no way to tell
|
|
410
|
+
* the narrowing was deliberate.
|
|
411
|
+
*
|
|
412
|
+
* The merge state is resolved here rather than read back off a gate envelope's
|
|
413
|
+
* `summary.diffRef`: that field means "the ref this scope was resolved
|
|
414
|
+
* against" for every scope kind, so anything that populates it — a future
|
|
415
|
+
* scope mode, a test stub — would render a merge banner over a repo that is
|
|
416
|
+
* not merging. Resolving it after the `!staged` early return also keeps the
|
|
417
|
+
* probe off the `--changed-since` path, which has no use for it.
|
|
418
|
+
*
|
|
419
|
+
* @param {{ staged: boolean, ref: string|null, cwd: string }} args
|
|
420
|
+
* @returns {string}
|
|
421
|
+
*/
|
|
422
|
+
function stagedScopeLine({ staged, ref, cwd }) {
|
|
423
|
+
if (!staged) return `scope=diff ref=${ref}\n\n`;
|
|
424
|
+
const mergeHead = resolveMergeHead({ cwd });
|
|
425
|
+
if (!mergeHead) return 'scope=staged (git diff --cached)\n\n';
|
|
426
|
+
return (
|
|
427
|
+
`scope=staged (git diff --cached ${mergeHead.slice(0, 12)}) — merge in ` +
|
|
428
|
+
"progress: scored against MERGE_HEAD, not HEAD, so the base branch's " +
|
|
429
|
+
'incoming files are excluded\n\n'
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
391
433
|
/**
|
|
392
434
|
* Write the run's report — the `--json` envelope, or the human-readable
|
|
393
435
|
* table plus any gate diagnostics and the non-zero-exit summary.
|
|
@@ -401,6 +443,7 @@ function runGateSafely(runner, args, label, stderr) {
|
|
|
401
443
|
* json: boolean,
|
|
402
444
|
* staged: boolean,
|
|
403
445
|
* ref: string|null,
|
|
446
|
+
* cwd: string,
|
|
404
447
|
* miResult: {exitCode: number, envelope: object|null},
|
|
405
448
|
* crapResult: {exitCode: number, envelope: object|null},
|
|
406
449
|
* merged: ReturnType<typeof mergeEnvelopes>,
|
|
@@ -413,6 +456,7 @@ function emitReport({
|
|
|
413
456
|
json,
|
|
414
457
|
staged,
|
|
415
458
|
ref,
|
|
459
|
+
cwd,
|
|
416
460
|
miResult,
|
|
417
461
|
crapResult,
|
|
418
462
|
merged,
|
|
@@ -438,11 +482,7 @@ function emitReport({
|
|
|
438
482
|
return;
|
|
439
483
|
}
|
|
440
484
|
stdout.write('\n--- quality:preview ---\n');
|
|
441
|
-
stdout.write(
|
|
442
|
-
staged
|
|
443
|
-
? 'scope=staged (git diff --cached)\n\n'
|
|
444
|
-
: `scope=diff ref=${ref}\n\n`,
|
|
445
|
-
);
|
|
485
|
+
stdout.write(stagedScopeLine({ staged, ref, cwd }));
|
|
446
486
|
stdout.write(`${renderTable(merged)}\n`);
|
|
447
487
|
const diagnostics = renderDiagnostics([miResult, crapResult]);
|
|
448
488
|
if (diagnostics) stdout.write(`\n${diagnostics}\n`);
|
|
@@ -518,6 +558,7 @@ export async function runCli({
|
|
|
518
558
|
json,
|
|
519
559
|
staged,
|
|
520
560
|
ref,
|
|
561
|
+
cwd,
|
|
521
562
|
miResult,
|
|
522
563
|
crapResult,
|
|
523
564
|
merged,
|
|
@@ -39,6 +39,7 @@ import { parseArgs } from 'node:util';
|
|
|
39
39
|
import { runAsCli } from './lib/cli-utils.js';
|
|
40
40
|
import { resolveConfig } from './lib/config-resolver.js';
|
|
41
41
|
import { Logger, routeAllOutputToStderr } from './lib/Logger.js';
|
|
42
|
+
import { expandEpicIds } from './lib/orchestration/epic-expansion.js';
|
|
42
43
|
import {
|
|
43
44
|
buildStoriesEnvelope,
|
|
44
45
|
isSatisfiedBlocker,
|
|
@@ -71,7 +72,9 @@ real issue state.
|
|
|
71
72
|
Options:
|
|
72
73
|
--ids <csv> Comma-separated Story issue numbers. Required. A token may be
|
|
73
74
|
a single id (4922) or an inclusive dash range (4922-4926);
|
|
74
|
-
ranges expand in place and dedupe against the rest.
|
|
75
|
+
ranges expand in place and dedupe against the rest. A
|
|
76
|
+
container Epic id expands to its open child Stories, and may
|
|
77
|
+
be mixed with Story ids.
|
|
75
78
|
--pretty Pretty-print the JSON envelope.
|
|
76
79
|
--no-native Skip the native blocked_by read (body edges only).
|
|
77
80
|
--help Show this help.
|
|
@@ -89,17 +92,54 @@ export function resolveStoriesProvider({
|
|
|
89
92
|
return { provider: createProviderFn(config), config };
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Read an Epic's native sub-issue children as issue numbers.
|
|
97
|
+
*
|
|
98
|
+
* Injected into `expandEpicIds` so the lib layer stays provider-agnostic,
|
|
99
|
+
* exactly as `paginate` is injected into `readNativeBlockedBy`. A provider
|
|
100
|
+
* without the GraphQL surface yields `[]`, and the Epic body's checklist
|
|
101
|
+
* carries the children on its own.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} provider
|
|
104
|
+
* @returns {(epic: object) => Promise<number[]>}
|
|
105
|
+
*/
|
|
106
|
+
export function nativeChildReader(provider) {
|
|
107
|
+
return async (epic) => {
|
|
108
|
+
if (typeof provider?._getNativeSubIssues !== 'function') return [];
|
|
109
|
+
return provider._getNativeSubIssues(epic?.nodeId, epic?.number ?? epic?.id);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
92
113
|
/**
|
|
93
114
|
* Fetch every requested id and map it to a Story record, failing on the first
|
|
94
115
|
* id that is not a deliverable Story.
|
|
95
116
|
*
|
|
117
|
+
* Container Epics are expanded to their open child Stories **first**, so
|
|
118
|
+
* everything downstream sees a plain Story-id list (Story #5139). The
|
|
119
|
+
* expansion walk is sequential because it is id-by-id conditional; the Story
|
|
120
|
+
* fetch that follows stays under the bounded concurrency.
|
|
121
|
+
*
|
|
96
122
|
* @param {object} provider
|
|
97
123
|
* @param {number[]} ids
|
|
98
124
|
* @returns {Promise<object[]>}
|
|
99
125
|
*/
|
|
100
126
|
export async function fetchStories(provider, ids) {
|
|
101
|
-
|
|
127
|
+
const { ids: resolvedIds, expansions } = await expandEpicIds({
|
|
102
128
|
ids,
|
|
129
|
+
getTicket: (id) => provider.getTicket(id),
|
|
130
|
+
readNativeChildIds: nativeChildReader(provider),
|
|
131
|
+
warn: (m) => Logger.warn(m),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
for (const { epicId, childIds } of expansions) {
|
|
135
|
+
Logger.info(
|
|
136
|
+
`[resolve-stories] Epic #${epicId} → ${childIds.length} open Story(ies): ` +
|
|
137
|
+
childIds.map((c) => `#${c}`).join(', '),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return concurrentMap(
|
|
142
|
+
resolvedIds,
|
|
103
143
|
async (id) => {
|
|
104
144
|
const issue = await provider.getTicket(id);
|
|
105
145
|
if (!issue) {
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// .agents/scripts/validate-skills.js
|
|
3
3
|
//
|
|
4
|
-
// Walk
|
|
4
|
+
// Walk `SKILL.md` under both skills roots — the package payload
|
|
5
|
+
// (`.agents/skills/{core,stack}/`) and the consumer-writable local zone
|
|
6
|
+
// (`.agents/local/skills/{core,stack}/`, Story #5135) — via the shared parser
|
|
5
7
|
// helper, validate each frontmatter block against
|
|
6
8
|
// `.agents/schemas/skill.schema.json`, enforce Policy Capsule presence
|
|
7
|
-
// (5–12 bullets), and verify membership in
|
|
8
|
-
//
|
|
9
|
+
// (5–12 bullets), and verify membership in each root's own manifest when it
|
|
10
|
+
// exists. A consumer-authored skill is held to exactly the same bar as a
|
|
11
|
+
// shipped one; the roots are validated separately because each carries its
|
|
12
|
+
// own index (the shipped manifest is a payload file and must stay
|
|
13
|
+
// payload-only — see generate-skills-index.js). All findings are batched into a single
|
|
9
14
|
// human-readable report; the process exits non-zero when any finding is
|
|
10
15
|
// surfaced.
|
|
11
16
|
//
|
|
@@ -30,7 +35,17 @@ import { parseStandardCliArgs } from './lib/cli/standard-args.js';
|
|
|
30
35
|
import { runAsCli } from './lib/cli-utils.js';
|
|
31
36
|
import { Logger } from './lib/Logger.js';
|
|
32
37
|
import { parseSkill } from './lib/skills/parse-skill.js';
|
|
33
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
auditIndex,
|
|
40
|
+
indexPathFor,
|
|
41
|
+
readIndexPaths,
|
|
42
|
+
} from './lib/skills/skills-index.js';
|
|
43
|
+
import {
|
|
44
|
+
collectLocalSkillFiles,
|
|
45
|
+
collectSkillFiles,
|
|
46
|
+
LOCAL_SKILLS_SEGMENTS,
|
|
47
|
+
PAYLOAD_SKILLS_SEGMENTS,
|
|
48
|
+
} from './lib/skills/walk-skill-files.js';
|
|
34
49
|
|
|
35
50
|
const MIN_CAPSULE_BULLETS = 5;
|
|
36
51
|
const MAX_CAPSULE_BULLETS = 12;
|
|
@@ -118,56 +133,11 @@ function buildManifestValidator(repoRoot) {
|
|
|
118
133
|
}
|
|
119
134
|
|
|
120
135
|
/**
|
|
121
|
-
* Read
|
|
122
|
-
*
|
|
123
|
-
* is present and parseable, or null otherwise.
|
|
136
|
+
* Read one root's manifest into the `{ exists, paths, manifest, indexPath }`
|
|
137
|
+
* shape the findings pass consumes.
|
|
124
138
|
*/
|
|
125
|
-
function readIndex(repoRoot) {
|
|
126
|
-
|
|
127
|
-
repoRoot,
|
|
128
|
-
'.agents',
|
|
129
|
-
'skills',
|
|
130
|
-
'skills.index.json',
|
|
131
|
-
);
|
|
132
|
-
if (!fs.existsSync(indexPath)) {
|
|
133
|
-
return { exists: false, paths: null, manifest: null, indexPath };
|
|
134
|
-
}
|
|
135
|
-
try {
|
|
136
|
-
const manifest = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
|
137
|
-
const paths = new Set(
|
|
138
|
-
Array.isArray(manifest.skills)
|
|
139
|
-
? manifest.skills
|
|
140
|
-
.map((s) => s.path)
|
|
141
|
-
.filter((p) => typeof p === 'string')
|
|
142
|
-
: [],
|
|
143
|
-
);
|
|
144
|
-
return { exists: true, paths, manifest, indexPath };
|
|
145
|
-
} catch (err) {
|
|
146
|
-
return {
|
|
147
|
-
exists: true,
|
|
148
|
-
paths: null,
|
|
149
|
-
manifest: null,
|
|
150
|
-
indexPath,
|
|
151
|
-
parseError: err.message,
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Validate a parsed manifest against skills-index.schema.json. Returns
|
|
158
|
-
* finding strings tagged with the `manifest-schema` pillar.
|
|
159
|
-
*/
|
|
160
|
-
function validateManifestSchema(manifest, indexRelPath, validateManifest) {
|
|
161
|
-
const findings = [];
|
|
162
|
-
if (!validateManifest(manifest)) {
|
|
163
|
-
for (const err of validateManifest.errors ?? []) {
|
|
164
|
-
const where = err.instancePath || '(root)';
|
|
165
|
-
findings.push(
|
|
166
|
-
`${indexRelPath}: manifest-schema: schema violation at ${where}: ${err.message}`,
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
return findings;
|
|
139
|
+
function readIndex(repoRoot, rootSegments = PAYLOAD_SKILLS_SEGMENTS) {
|
|
140
|
+
return readIndexPaths(indexPathFor(repoRoot, rootSegments));
|
|
171
141
|
}
|
|
172
142
|
|
|
173
143
|
/**
|
|
@@ -216,6 +186,7 @@ function rel(absPath, repoRoot) {
|
|
|
216
186
|
/**
|
|
217
187
|
* Pure entry point used by tests. Returns `{ status, output, findings }`.
|
|
218
188
|
*/
|
|
189
|
+
|
|
219
190
|
export function run({ argv = [], repoRoot } = {}) {
|
|
220
191
|
const parsed = parseArgs(argv);
|
|
221
192
|
if (parsed.help) {
|
|
@@ -234,25 +205,41 @@ export function run({ argv = [], repoRoot } = {}) {
|
|
|
234
205
|
const indexRel = rel(indexInfo.indexPath, root);
|
|
235
206
|
|
|
236
207
|
const findings = [];
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
);
|
|
241
|
-
} else if (indexInfo.paths === null) {
|
|
242
|
-
findings.push(`index unparseable: ${indexRel} — ${indexInfo.parseError}`);
|
|
243
|
-
} else if (indexInfo.manifest !== null) {
|
|
244
|
-
findings.push(
|
|
245
|
-
...validateManifestSchema(indexInfo.manifest, indexRel, validateManifest),
|
|
246
|
-
);
|
|
247
|
-
}
|
|
208
|
+
findings.push(
|
|
209
|
+
...auditIndex(indexInfo, indexRel, validateManifest, { required: true }),
|
|
210
|
+
);
|
|
248
211
|
|
|
249
|
-
const
|
|
212
|
+
const payloadFiles = collectSkillFiles(root);
|
|
250
213
|
const indexPaths =
|
|
251
214
|
indexInfo.exists && indexInfo.paths !== null ? indexInfo.paths : null;
|
|
252
|
-
for (const file of
|
|
215
|
+
for (const file of payloadFiles) {
|
|
253
216
|
findings.push(...validateOne(file, root, validateFrontmatter, indexPaths));
|
|
254
217
|
}
|
|
255
218
|
|
|
219
|
+
// The local zone is optional: a repo with no consumer-authored skills has
|
|
220
|
+
// no local root and no local index, and that is a clean run, not a finding.
|
|
221
|
+
const localFiles = collectLocalSkillFiles(root);
|
|
222
|
+
if (localFiles.length > 0) {
|
|
223
|
+
const localIndexInfo = readIndex(root, LOCAL_SKILLS_SEGMENTS);
|
|
224
|
+
const localIndexRel = rel(localIndexInfo.indexPath, root);
|
|
225
|
+
findings.push(
|
|
226
|
+
...auditIndex(localIndexInfo, localIndexRel, validateManifest, {
|
|
227
|
+
required: true,
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
const localIndexPaths =
|
|
231
|
+
localIndexInfo.exists && localIndexInfo.paths !== null
|
|
232
|
+
? localIndexInfo.paths
|
|
233
|
+
: null;
|
|
234
|
+
for (const file of localFiles) {
|
|
235
|
+
findings.push(
|
|
236
|
+
...validateOne(file, root, validateFrontmatter, localIndexPaths),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const skillFiles = [...payloadFiles, ...localFiles];
|
|
242
|
+
|
|
256
243
|
if (findings.length === 0) {
|
|
257
244
|
Logger.info(`validate-skills: ${skillFiles.length} skill(s) passed`);
|
|
258
245
|
return { status: 0, output: '', findings };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Audit sweep runbook
|
|
2
|
+
|
|
3
|
+
> **Template generated by Mandrel.** Copy it into your own docs tree (e.g.
|
|
4
|
+
> `docs/audit-sweep-runbook.md`), then localise every bracketed placeholder:
|
|
5
|
+
> the cadence, the lens list, who reviews the ledger PR, and the label
|
|
6
|
+
> conventions your repository actually uses. The steps themselves are the
|
|
7
|
+
> contract — the values around them are yours.
|
|
8
|
+
>
|
|
9
|
+
> The workflow this runbook drives is
|
|
10
|
+
> [`/audit-to-stories`](../../workflows/audit-to-stories.md); the CLI it calls
|
|
11
|
+
> is [`audit-to-stories.js`](../../scripts/audit-to-stories.js). Run the CLI
|
|
12
|
+
> with `--help` for the authoritative flag list.
|
|
13
|
+
|
|
14
|
+
## What this runbook is for
|
|
15
|
+
|
|
16
|
+
A maintenance **sweep** runs the `audit-*` lenses full-scope, folds their
|
|
17
|
+
findings onto the cross-run ledger, and turns what is genuinely new into
|
|
18
|
+
Stories. It is the unattended sibling of an interactive `/audit-to-stories`
|
|
19
|
+
run: no HITL gates, so every judgement call the interactive path asks a human
|
|
20
|
+
has to be settled here instead.
|
|
21
|
+
|
|
22
|
+
| Setting | Value for this repository |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| Cadence | _e.g. weekly, Sunday 02:00_ |
|
|
25
|
+
| Lenses in scope | _e.g. security, clean-code, quality, dependencies_ |
|
|
26
|
+
| Severity floor | _`delivery.auditToStories.severityFloor`, default `high`_ |
|
|
27
|
+
| Ledger reviewer | _e.g. @your-handle_ |
|
|
28
|
+
| Story triage owner | _e.g. the on-call maintainer_ |
|
|
29
|
+
|
|
30
|
+
## Step 1 — Run the lenses full-scope
|
|
31
|
+
|
|
32
|
+
Run each `audit-*` workflow with **no** `--paths` and no change-set filter, so
|
|
33
|
+
the whole target-set union is audited rather than whatever a recent branch
|
|
34
|
+
happened to touch. Each lens writes its report to
|
|
35
|
+
`temp/audits/audit-<lens>-results.md`.
|
|
36
|
+
|
|
37
|
+
A sweep scoped to a change set is not a sweep: it re-reports the same recent
|
|
38
|
+
files every cycle and never reaches the code nobody has touched in a year,
|
|
39
|
+
which is exactly where audit findings accumulate.
|
|
40
|
+
|
|
41
|
+
## Step 2 — Cross-check the severity tally
|
|
42
|
+
|
|
43
|
+
Every lens report ends its executive summary with a machine-readable line:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
Severity tally: Critical 0 / High 3 / Medium 7 / Low 2
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The CLI re-counts the `### Finding` blocks it actually parsed and compares them
|
|
50
|
+
to that declared tally. A mismatch — or a missing tally line — means the report
|
|
51
|
+
is not trustworthy: a finding was malformed, a severity did not resolve onto the
|
|
52
|
+
canonical scale, or the lens truncated its own output.
|
|
53
|
+
|
|
54
|
+
`--auto` **fails closed** on any such failure. It exits non-zero having opened
|
|
55
|
+
no Issue and written no ledger, and names the offending report in
|
|
56
|
+
`summary.reportFailures[]`. `--allow-missing-tally` is a `--scan` affordance
|
|
57
|
+
only; `--auto` ignores it by design, because an unattended run has no operator
|
|
58
|
+
to read a warning.
|
|
59
|
+
|
|
60
|
+
**When the sweep goes red here, re-run the lens.** Do not reach for
|
|
61
|
+
`--allow-missing-tally` and do not hand-edit the report to make the numbers
|
|
62
|
+
agree — the tally is the only signal that the parse saw what the lens wrote.
|
|
63
|
+
|
|
64
|
+
## Step 3 — Dry-run the first cycles
|
|
65
|
+
|
|
66
|
+
Start every new sweep in report-only mode, and stay there until the tallies
|
|
67
|
+
stop surprising you:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
node .agents/scripts/audit-to-stories.js --auto --dry-run
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`--dry-run` performs zero GitHub writes and skips the ledger write, printing
|
|
74
|
+
only the run summary. Read `totals.create` before you let the sweep file
|
|
75
|
+
anything: a first full-scope run over an un-audited repository can propose more
|
|
76
|
+
Stories than your team can triage in a quarter. Raise `--severity` (or
|
|
77
|
+
`delivery.auditToStories.severityFloor`) until the create count is a batch you
|
|
78
|
+
would actually take on, then go live.
|
|
79
|
+
|
|
80
|
+
## Step 4 — Go live, and persist the ledger
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
node .agents/scripts/audit-to-stories.js --auto --ledger-commit
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The cross-run ledger (`baselines/audit-ledger.json`) is **consumer state, not
|
|
87
|
+
scratch output**. It is what lets the next sweep tell a re-detection from a
|
|
88
|
+
fresh finding, and a deliberately-rejected finding from an unseen one. A
|
|
89
|
+
scheduled job normally runs on an ephemeral checkout, so unless the ledger is
|
|
90
|
+
committed back it dies with the clone and every later sweep starts amnesiac —
|
|
91
|
+
re-proposing findings already filed and re-surfacing findings a human already
|
|
92
|
+
rejected.
|
|
93
|
+
|
|
94
|
+
`--ledger-commit` closes that loop. After the run summary has printed, and only
|
|
95
|
+
when the ledger actually changed, it:
|
|
96
|
+
|
|
97
|
+
1. creates `chore/audit-ledger-<YYYY-MM-DD>` from the current HEAD,
|
|
98
|
+
2. commits **only** the ledger file, subject
|
|
99
|
+
`chore(audit): reconcile audit ledger <date>`,
|
|
100
|
+
3. pushes the branch, and
|
|
101
|
+
4. opens a PR against your base branch.
|
|
102
|
+
|
|
103
|
+
**Auto-merge is never requested.** The ledger records machine-derived lifecycle
|
|
104
|
+
state, so a human glance before it lands is the point — nominate that reviewer
|
|
105
|
+
in the table above. Review the PR for entries flipping to `accepted-risk` or
|
|
106
|
+
`regressed`; those two are the ledger telling you something about your backlog,
|
|
107
|
+
not about itself.
|
|
108
|
+
|
|
109
|
+
Any git or `gh` failure in that sequence is fatal and names the step it broke
|
|
110
|
+
on — but it happens _after_ the summary is printed, so a broken remote never
|
|
111
|
+
costs you the sweep's findings.
|
|
112
|
+
|
|
113
|
+
Without the flag, a run whose ledger changed on a checkout that cannot persist
|
|
114
|
+
it — no `origin` remote, or HEAD parked off the base branch — sets
|
|
115
|
+
`ledger.unpersisted: true` in the summary and warns on stderr naming the file.
|
|
116
|
+
Treat that warning as a red sweep: the findings are fine, but the memory is
|
|
117
|
+
about to be thrown away.
|
|
118
|
+
|
|
119
|
+
## Step 5 — Enrich before you deliver
|
|
120
|
+
|
|
121
|
+
`--emit-stories` renders `{ title, body, labels }` payloads from audit findings.
|
|
122
|
+
Those bodies are **audit prose**, not delivery-ready Specs: they describe a
|
|
123
|
+
symptom and a recommendation, not a scoped change with acceptance criteria a
|
|
124
|
+
worker can verify against.
|
|
125
|
+
|
|
126
|
+
Do not point `/mandrel-deliver` at a freshly-filed audit Story. Route it through
|
|
127
|
+
`/mandrel-plan` first — the planning pass is where the finding becomes a
|
|
128
|
+
capability slice with a `## Spec`, real `acceptance[]` items and runnable
|
|
129
|
+
`verify[]` lines. Planning is deliberately not automated here: deciding what a
|
|
130
|
+
finding is worth, and how far the fix should reach, is the judgement the sweep
|
|
131
|
+
exists to surface rather than to make.
|
|
132
|
+
|
|
133
|
+
The wiring pass is the exception — it _is_ mechanical and it is **required**.
|
|
134
|
+
After opening the Issues, replay their numbers through `--wire-edges` so the
|
|
135
|
+
cohort's declared ordering exists as `blocked by #N` footers and native
|
|
136
|
+
`blocked_by` relations. An unwired cohort is genuinely unordered, and
|
|
137
|
+
`/mandrel-deliver` will co-dispatch Stories the edges say must follow one
|
|
138
|
+
another.
|
|
139
|
+
|
|
140
|
+
## Step 6 — Label convention
|
|
141
|
+
|
|
142
|
+
Audit-sourced Stories carry a closed label set, so they can be filtered out of
|
|
143
|
+
(or into) ordinary planning at a glance:
|
|
144
|
+
|
|
145
|
+
| Label | Meaning |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| `type::story` | Every emitted Story — the sweep never opens Epics or tasks. |
|
|
148
|
+
| `agent::ready` | Filed and available for pickup. |
|
|
149
|
+
| `audit::<lens>` | One per lens represented in the group; a cross-audit merge carries several. |
|
|
150
|
+
| `risk::high` | Added when any finding in the group is Critical. |
|
|
151
|
+
|
|
152
|
+
The lens labels are a **closed taxonomy**: only the canonical `audit::<lens>`
|
|
153
|
+
names are valid, and the filer refuses to emit a label the repository has never
|
|
154
|
+
created. Create them once with `audit-labels-bootstrap.js` before the first live
|
|
155
|
+
sweep — a generated label that does not exist makes every Issue create fail.
|
|
156
|
+
|
|
157
|
+
Do not invent per-finding labels. If you need another axis (a team, a
|
|
158
|
+
component), add it in triage on top of this set rather than teaching the sweep
|
|
159
|
+
to mint labels no taxonomy defines.
|
|
160
|
+
|
|
161
|
+
## Failure triage
|
|
162
|
+
|
|
163
|
+
| Symptom | Cause | Action |
|
|
164
|
+
| --- | --- | --- |
|
|
165
|
+
| Non-zero exit, `summary.reportFailures[]` populated | A lens report's tally is missing or disagrees with the parse | Re-run that lens; never downgrade with `--allow-missing-tally` |
|
|
166
|
+
| `ledger.unpersisted: true` in the summary | No `origin`, or HEAD off the base branch | Re-run with `--ledger-commit`, or commit the ledger by hand |
|
|
167
|
+
| `--ledger-commit failed at step "..."` | git or `gh` failed at the named step | Fix the remote/auth and re-run; the summary above it is still valid |
|
|
168
|
+
| Same findings re-proposed every cycle | The ledger is not being committed | Adopt Step 4 |
|
|
169
|
+
| `totals.create` far larger than the team can absorb | Severity floor too low for a first full-scope run | Raise `--severity` and re-dry-run |
|
|
@@ -44,10 +44,12 @@ They remain read-only emitters of audit reports.
|
|
|
44
44
|
## Phase 1 — Discover & parse
|
|
45
45
|
|
|
46
46
|
Run the CLI in `--scan` mode against the resolved glob. It parses every
|
|
47
|
-
|
|
47
|
+
finding block, normalises the fields (`Severity` / `Impact` are
|
|
48
48
|
both recognised; `Dimension` / `Category` likewise), and extracts file
|
|
49
|
-
paths mentioned in the body.
|
|
50
|
-
|
|
49
|
+
paths mentioned in the body. A `###` heading that carries no severity axis and
|
|
50
|
+
holds `####` blocks is read as a **grouping header**: its `####` children are
|
|
51
|
+
the findings, and the header itself never becomes one. It then stamps each
|
|
52
|
+
finding with a stable sha1 fingerprint via the shared
|
|
51
53
|
[`lib/findings/route-finding.js`](../scripts/lib/findings/route-finding.js)
|
|
52
54
|
helper (`fingerprintFinding`) — the single dedup/route implementation
|
|
53
55
|
shared with `qa-explore`. The workflow carries **no** separate inline
|
|
@@ -64,6 +66,19 @@ The emitted plan envelope carries `findings`, `groups`, `edges`,
|
|
|
64
66
|
`classifications`, and `summary`. Subsequent phases consume the file
|
|
65
67
|
rather than re-parsing the reports.
|
|
66
68
|
|
|
69
|
+
**The tally cross-check is automatic.** Every report declares
|
|
70
|
+
`Severity tally: Critical <n> / High <n> / Medium <n> / Low <n>` in its
|
|
71
|
+
Executive Summary; the scan compares that line with what it parsed and carries
|
|
72
|
+
each disagreement on `summary.reportFailures[]` as
|
|
73
|
+
`{ sourceReport, kind, reported, parsed }`. The kinds are `missing-tally` (no
|
|
74
|
+
line), `tally-mismatch` (line and parse disagree), and `unresolved-severity` (a
|
|
75
|
+
finding whose severity did not resolve — dropped from grouping, never filed as
|
|
76
|
+
an `unknown` group). They print to stderr before `--scan` returns its plan, so
|
|
77
|
+
a mis-parsed report is never read as a clean audit: re-run the lens rather than
|
|
78
|
+
file from it. Over older reports predating the mandate,
|
|
79
|
+
`--scan --allow-missing-tally` downgrades **only** `missing-tally` to a
|
|
80
|
+
warning.
|
|
81
|
+
|
|
67
82
|
## Phase 2 — HITL: severity gate
|
|
68
83
|
|
|
69
84
|
Read the plan envelope's `summary.tally`. Present the operator with the
|
|
@@ -117,6 +132,11 @@ Ask:
|
|
|
117
132
|
> default-single policy.
|
|
118
133
|
> - **Individual standalone Stories** — opens one GitHub Issue per
|
|
119
134
|
> group directly (no plan ceremony).
|
|
135
|
+
>
|
|
136
|
+
> Either way, if the sweep proposes **more than 2** Stories they are grouped
|
|
137
|
+
> under a **container Epic** by default — a title, a one-paragraph goal and a
|
|
138
|
+
> child checklist, carrying nothing a child does not already carry. Say so if
|
|
139
|
+
> you would rather file them flat.
|
|
120
140
|
|
|
121
141
|
**STOP** until the operator picks.
|
|
122
142
|
|
|
@@ -133,7 +153,14 @@ node .agents/scripts/audit-to-stories.js --emit-plan-seed \
|
|
|
133
153
|
The seed renders the canonical one-pager sections — Problem Statement,
|
|
134
154
|
Recommended Direction, Key Assumptions (with links to every source
|
|
135
155
|
report), MVP Scope (the M proposed Stories), Key Files (so `/mandrel-plan`'s
|
|
136
|
-
authoring step has concrete anchors), Not Doing.
|
|
156
|
+
authoring step has concrete anchors), Grouping, Not Doing.
|
|
157
|
+
|
|
158
|
+
**Grouping is the container-Epic directive.** Above 2 proposed Stories the
|
|
159
|
+
seed instructs `/mandrel-plan` to group them under one Epic — a sweep is the
|
|
160
|
+
clearest case for a container, since every Story shares a provenance and an
|
|
161
|
+
operator usually delivers them together. It is a directive in the text, not an
|
|
162
|
+
automatic write: Phase 4 above is where an operator declines it. Below the
|
|
163
|
+
threshold the section says so and asks for nothing.
|
|
137
164
|
|
|
138
165
|
Chain into the existing planning entrypoint:
|
|
139
166
|
|
|
@@ -215,6 +242,14 @@ its footprint guard ignores the shared provenance footers, so an unwired cohort
|
|
|
215
242
|
is genuinely unordered and `/mandrel-deliver` will co-dispatch Stories the edges say
|
|
216
243
|
must follow one another.
|
|
217
244
|
|
|
245
|
+
**Preconditions.** The pass writes through the configured provider, so it needs
|
|
246
|
+
`github.owner` **and** `github.repo` in `.agentrc.json` plus working `gh` auth
|
|
247
|
+
(`GH_TOKEN`/`gh auth status`) — the same two things Phase 1's dedup needs. When
|
|
248
|
+
either is missing the command refuses and names which one; fix that and re-run
|
|
249
|
+
the exact command above. Do not transcribe the footers by hand: `/mandrel-deliver`
|
|
250
|
+
reads them, but the native `blocked_by` relations only exist if this pass wrote
|
|
251
|
+
them.
|
|
252
|
+
|
|
218
253
|
## Phase 6 — Idempotency (folded into Phase 1 scan)
|
|
219
254
|
|
|
220
255
|
The `--scan` step routes each group's findings through the shared
|
|
@@ -289,6 +324,8 @@ summarising the run:
|
|
|
289
324
|
|
|
290
325
|
When the single-plan path ran, link the Story (or plan-run) the chained
|
|
291
326
|
`/mandrel-plan` opened. When the Standalone-Stories path ran, list every Issue URL.
|
|
327
|
+
Either way, name the container Epic if one was created — it is the single id
|
|
328
|
+
that delivers the whole sweep (`/mandrel-deliver <epicId>`).
|
|
292
329
|
|
|
293
330
|
## Constraints
|
|
294
331
|
|
|
@@ -330,18 +367,59 @@ writing their `temp/audits/audit-*-results.md` reports, then (2) invokes the
|
|
|
330
367
|
CLI's **`--auto` mode** over those results:
|
|
331
368
|
|
|
332
369
|
```bash
|
|
333
|
-
node .agents/scripts/audit-to-stories.js --auto [--dry-run] \
|
|
370
|
+
node .agents/scripts/audit-to-stories.js --auto [--dry-run] [--ledger-commit] \
|
|
334
371
|
[--glob "temp/audits/audit-*-results.md"] [--severity <floor>]
|
|
335
372
|
```
|
|
336
373
|
|
|
374
|
+
The routine shape is **lenses full-scope → dry-run → live with a ledger PR**:
|
|
375
|
+
|
|
376
|
+
1. Run the `audit-*` lenses with no `--paths` and no change-set filter. A
|
|
377
|
+
sweep scoped to a change set re-reports the same recent files every cycle
|
|
378
|
+
and never reaches the untouched code where findings accumulate.
|
|
379
|
+
2. `--auto --dry-run` for the first cycles — zero writes, summary only. Read
|
|
380
|
+
`totals.create` and raise the severity floor until it is a batch the team
|
|
381
|
+
would actually take on.
|
|
382
|
+
3. `--auto --ledger-commit` once the tallies stop surprising you.
|
|
383
|
+
|
|
337
384
|
`--auto` runs with **no interactive gates**: it resolves the severity floor
|
|
338
385
|
from `delivery.auditToStories.severityFloor` (default `high`, overridable with
|
|
339
386
|
`--severity`), applies the two-stage dedup, reconciles the cross-run ledger,
|
|
340
387
|
and prints a run-summary JSON (create / skip-open / skip-reoccurring /
|
|
341
388
|
suppressed-by-ledger tallies, plus the re-detected open Issue numbers an
|
|
342
389
|
operator may want a "re-detected" comment on). `--dry-run` performs zero GitHub
|
|
343
|
-
writes and skips the ledger write, emitting only the summary.
|
|
344
|
-
|
|
390
|
+
writes and skips the ledger write, emitting only the summary.
|
|
391
|
+
|
|
392
|
+
`--auto` **fails closed on any `summary.reportFailures[]` entry** (Phase 1): an
|
|
393
|
+
unattended sweep has no operator to read a warning, so a missing or mismatched
|
|
394
|
+
`Severity tally:` line — or a finding whose severity did not resolve — exits
|
|
395
|
+
non-zero having opened no Issue and written no ledger. `--allow-missing-tally`
|
|
396
|
+
is a `--scan` affordance that `--auto` ignores. A red sweep means the report is
|
|
397
|
+
untrustworthy: re-run the lens. The host scheduler owns the cadence; this
|
|
398
|
+
workflow owns the routing.
|
|
399
|
+
|
|
400
|
+
### The ledger is consumer state — commit it
|
|
401
|
+
|
|
402
|
+
`baselines/audit-ledger.json` is **committed consumer state, not scratch
|
|
403
|
+
output**. A scheduled sweep normally runs on an ephemeral checkout, so unless
|
|
404
|
+
the reconciled ledger is committed back it dies with the clone: every later
|
|
405
|
+
sweep starts amnesiac, re-proposing findings already filed and re-surfacing
|
|
406
|
+
findings a human already rejected.
|
|
407
|
+
|
|
408
|
+
`--ledger-commit` closes that loop. After the summary prints — and only when
|
|
409
|
+
the ledger changed — it creates `chore/audit-ledger-<YYYY-MM-DD>` from HEAD,
|
|
410
|
+
commits **only** the ledger file, pushes it, and opens a PR against
|
|
411
|
+
`project.baseBranch`. **Auto-merge is never requested**: a human glance at the
|
|
412
|
+
`accepted-risk` / `regressed` flips before it lands is the point. A git or `gh`
|
|
413
|
+
failure is fatal and names its step, but only after the summary is printed, so
|
|
414
|
+
a broken remote never costs the operator the run's findings. `--dry-run` skips
|
|
415
|
+
the tail. Without the flag, a changed ledger on a checkout that cannot persist
|
|
416
|
+
it — no `origin`, or HEAD off the base branch — sets `ledger.unpersisted: true`
|
|
417
|
+
in the summary and warns on stderr naming the file.
|
|
418
|
+
|
|
419
|
+
The full sweep procedure — tally cross-check, the ledger PR, the
|
|
420
|
+
enrich-before-deliver step and the label convention — ships as a
|
|
421
|
+
consumer-copyable template at
|
|
422
|
+
[`templates/docs/audit-sweep-runbook.md`](../templates/docs/audit-sweep-runbook.md).
|
|
345
423
|
|
|
346
424
|
## See also
|
|
347
425
|
|