mandrel 2.39.0 → 2.41.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 +8 -5
- package/.agents/agents/auditor.md +5 -0
- package/.agents/docs/SDLC.md +21 -12
- package/.agents/instructions.md +22 -22
- package/.agents/rules/api-conventions.md +43 -7
- package/.agents/rules/ci-remediation.md +3 -14
- package/.agents/rules/gherkin-standards.md +21 -6
- package/.agents/rules/git-conventions.md +6 -5
- package/.agents/rules/security-baseline.md +6 -7
- package/.agents/rules/testing-standards.md +75 -198
- package/.agents/scripts/audit-to-stories.js +510 -66
- package/.agents/scripts/install-matrix-assert.js +2 -2
- package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +45 -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/label-constants.js +18 -0
- package/.agents/scripts/lib/label-taxonomy.js +18 -5
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +139 -0
- package/.agents/scripts/lib/orchestration/epic-candidates.js +159 -0
- package/.agents/scripts/lib/orchestration/epic-checklist.js +103 -0
- package/.agents/scripts/lib/orchestration/epic-container.js +202 -0
- package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +97 -36
- package/.agents/scripts/lib/orchestration/plan-persist/cross-plan-links.js +80 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +192 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +324 -0
- package/.agents/scripts/lib/orchestration/plan-persist/external-deps.js +164 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +30 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +26 -5
- package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +11 -1
- package/.agents/scripts/plan-persist.js +98 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
- package/.agents/scripts/resolve-stories.js +42 -2
- package/.agents/skills/core/browser-testing-with-devtools/SKILL.md +5 -10
- package/.agents/skills/core/browser-testing-with-devtools/reference.md +7 -46
- package/.agents/skills/core/code-review-and-quality/SKILL.md +0 -5
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +0 -3
- package/.agents/skills/core/gates-and-baselines/SKILL.md +10 -137
- package/.agents/skills/core/gates-and-baselines/reference.md +103 -0
- package/.agents/skills/core/idea-refinement/SKILL.md +2 -18
- package/.agents/skills/core/security-and-hardening/SKILL.md +2 -4
- package/.agents/skills/core/security-and-hardening/reference.md +0 -70
- package/.agents/skills/skills.index.json +10 -70
- package/.agents/skills/stack/qa/gherkin-authoring/SKILL.md +4 -10
- package/.agents/skills/stack/qa/gherkin-authoring/reference.md +9 -113
- package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +29 -154
- package/.agents/skills/stack/qa/qa-harness/SKILL.md +157 -98
- package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
- package/.agents/workflows/audit-to-stories.md +85 -7
- package/.agents/workflows/git-cleanup.md +3 -2
- package/.agents/workflows/git-deliver.md +3 -2
- 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 +109 -1
- package/.agents/workflows/mandrel-deliver.md +47 -43
- package/.agents/workflows/mandrel-plan.md +61 -55
- package/.agents/workflows/qa-assist.md +20 -17
- package/.agents/workflows/qa-explore.md +30 -29
- package/.agents/workflows/qa-run.md +2 -1
- package/docs/CHANGELOG.md +30 -0
- package/package.json +1 -1
- package/.agents/rules/changelog-style.md +0 -180
- package/.agents/rules/shell-conventions.md +0 -61
- package/.agents/scripts/lib/qa/coverage-verdict.js +0 -214
- package/.agents/skills/core/api-and-interface-design/SKILL.md +0 -55
- package/.agents/skills/core/api-and-interface-design/reference.md +0 -76
- package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +0 -45
- package/.agents/skills/core/debugging-and-error-recovery/reference.md +0 -56
- package/.agents/skills/core/git-workflow-and-versioning/SKILL.md +0 -54
- package/.agents/skills/core/idea-refinement/refinement-criteria.md +0 -155
- package/.agents/skills/core/idea-refinement/scripts/idea-refine.sh +0 -15
- package/.agents/skills/core/qa-coverage-mapping/SKILL.md +0 -105
- package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +0 -152
- package/.agents/skills/stack/qa/vitest/SKILL.md +0 -22
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* `mandrel doctor` — across {npm, pnpm, yarn} x {ubuntu-latest,
|
|
9
9
|
* windows-latest}. Rather than spread the per-leg invariants across
|
|
10
10
|
* PowerShell-vs-bash shell snippets (which diverge on quoting, `$?`, and
|
|
11
|
-
* separators
|
|
12
|
-
*
|
|
11
|
+
* path separators), every leg shells out to this single Node script so the
|
|
12
|
+
* checks run identically on every OS.
|
|
13
13
|
*
|
|
14
14
|
* It asserts three invariants against a consumer project directory:
|
|
15
15
|
*
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epic-grouping-directive.js — the container-Epic directive an audit sweep
|
|
3
|
+
* emits (Story #5139).
|
|
4
|
+
*
|
|
5
|
+
* Lives on its own because **both** `/audit-to-stories` output paths carry it:
|
|
6
|
+
* the `/mandrel-plan` seed one-pager (`seed-from-findings.js`) and the
|
|
7
|
+
* standalone Story-draft transcript (`audit-to-stories.js`). A sweep is the
|
|
8
|
+
* clearest case for a container — every Story shares a provenance and the
|
|
9
|
+
* operator almost always wants them delivered together — so the Epic is the
|
|
10
|
+
* **default** here, unlike the offer `/mandrel-plan` makes on an ad-hoc plan.
|
|
11
|
+
*
|
|
12
|
+
* It stays a directive in the text rather than an automatic write: the
|
|
13
|
+
* workflow's Phase 4 HITL stop is where an operator declines it.
|
|
14
|
+
*
|
|
15
|
+
* Since Story #5155 the directive names adoption first: a repeat sweep over the
|
|
16
|
+
* same area is the case most likely to already have a container, and opening a
|
|
17
|
+
* second one beside it is the failure this ordering exists to prevent.
|
|
18
|
+
*
|
|
19
|
+
* @module lib/audit-to-stories/epic-grouping-directive
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { EPIC_SUGGESTION_THRESHOLD } from '../orchestration/plan-persist/epic-ops.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render the grouping directive for a proposed Story set.
|
|
26
|
+
*
|
|
27
|
+
* @param {unknown[]} groups The proposed Stories (only the count is read).
|
|
28
|
+
* @returns {string} Markdown paragraph(s).
|
|
29
|
+
*/
|
|
30
|
+
export function formatEpicGrouping(groups) {
|
|
31
|
+
const count = Array.isArray(groups) ? groups.length : 0;
|
|
32
|
+
if (count < EPIC_SUGGESTION_THRESHOLD) {
|
|
33
|
+
const noun = count === 1 ? 'Story' : 'Stories';
|
|
34
|
+
return `This sweep proposes ${count} ${noun} — below the ${EPIC_SUGGESTION_THRESHOLD}-Story threshold, so no container Epic is needed.`;
|
|
35
|
+
}
|
|
36
|
+
return [
|
|
37
|
+
`**Group these under a container Epic.** This sweep proposes ${count} Stories from one audit pass, which is exactly the case a container earns: they share a provenance and an operator will want to deliver them as a unit.`,
|
|
38
|
+
'',
|
|
39
|
+
'**Check `epicCandidates[]` first.** A repeat sweep over the same area usually belongs under the Epic the previous sweep opened, not beside it — adopt that one with `--epic <id>` (any Story count) rather than opening a second container for one body of work. Only when no open Epic fits does this directive mean *create*.',
|
|
40
|
+
'',
|
|
41
|
+
'The Epic is a **pure container** — a title, a one-paragraph goal, and the child checklist. It must carry no finding, no path and no rationale that is not already in a child Story, or that information ends up somewhere no delivering agent reads.',
|
|
42
|
+
'',
|
|
43
|
+
'Decline it and file the Stories flat if the operator prefers.',
|
|
44
|
+
].join('\n');
|
|
45
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit-to-stories/ledger-commit.js — persist the cross-run audit ledger.
|
|
3
|
+
*
|
|
4
|
+
* The `--auto` sweep's whole value is memory: `baselines/audit-ledger.json`
|
|
5
|
+
* is what lets the next run tell a re-detection from a fresh finding and an
|
|
6
|
+
* accepted risk from an unseen one. A scheduled sweep, though, typically runs
|
|
7
|
+
* on an ephemeral checkout — a fresh clone that is deleted when the job ends —
|
|
8
|
+
* so the ledger `--auto` writes is discarded and every later sweep starts from
|
|
9
|
+
* an empty memory. The sweep is then permanently amnesiac, and the ledger's
|
|
10
|
+
* suppression and regression signals never fire.
|
|
11
|
+
*
|
|
12
|
+
* This module closes that hole from both ends:
|
|
13
|
+
*
|
|
14
|
+
* - {@link runLedgerCommit} (`--auto --ledger-commit`) commits the changed
|
|
15
|
+
* ledger onto a dated `chore/audit-ledger-<YYYY-MM-DD>` branch, pushes it,
|
|
16
|
+
* and opens a PR against `project.baseBranch` through the `gh` wrapper.
|
|
17
|
+
* Auto-merge is never requested: a ledger PR records machine-derived state
|
|
18
|
+
* a human should glance at, so landing it stays an operator decision.
|
|
19
|
+
* - {@link resolveLedgerSummary} answers the question the *unflagged* sweep
|
|
20
|
+
* needs — "would this ledger survive?" — so a run that cannot persist (no
|
|
21
|
+
* `origin`, or HEAD parked off the base branch) says so in its summary,
|
|
22
|
+
* and on stderr, instead of silently discarding the state.
|
|
23
|
+
*
|
|
24
|
+
* Both take injectable `git` / `gh` seams (`.agents/rules/test-seams.md`) so
|
|
25
|
+
* the branch/commit/push/PR argv shape is assertable without a live remote.
|
|
26
|
+
* The logic lives here rather than in `audit-to-stories.js` so the CLI file's
|
|
27
|
+
* complexity budget does not absorb a git driver.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { gh as defaultGh } from '../gh-exec.js';
|
|
31
|
+
import { gitSync } from '../git-utils.js';
|
|
32
|
+
import { DEFAULT_LEDGER_PATH } from './ledger.js';
|
|
33
|
+
|
|
34
|
+
/** Fallback base branch when config carries no `project.baseBranch`. */
|
|
35
|
+
const DEFAULT_BASE_BRANCH = 'main';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Render the `YYYY-MM-DD` stamp both the branch name and the commit subject
|
|
39
|
+
* carry, so one sweep produces one identifiable ledger branch per day.
|
|
40
|
+
* @param {Date|string|number} [now]
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function isoDate(now) {
|
|
44
|
+
const date = now instanceof Date ? now : new Date(now ?? Date.now());
|
|
45
|
+
return date.toISOString().slice(0, 10);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve `project.baseBranch` defensively: an explicit value wins, then
|
|
50
|
+
* config, then `main`. A failed config resolve must never break a sweep that
|
|
51
|
+
* has already done its real work.
|
|
52
|
+
* @param {string} [explicit]
|
|
53
|
+
* @returns {Promise<string>}
|
|
54
|
+
*/
|
|
55
|
+
async function resolveBaseBranch(explicit) {
|
|
56
|
+
if (typeof explicit === 'string' && explicit.length > 0) return explicit;
|
|
57
|
+
try {
|
|
58
|
+
const { resolveConfig } = await import('../config-resolver.js');
|
|
59
|
+
const branch = resolveConfig()?.project?.baseBranch;
|
|
60
|
+
if (typeof branch === 'string' && branch.length > 0) return branch;
|
|
61
|
+
} catch (_) {
|
|
62
|
+
// fall through to the default
|
|
63
|
+
}
|
|
64
|
+
return DEFAULT_BASE_BRANCH;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run a read-only git probe that must never throw: a checkout with no commits
|
|
69
|
+
* (or no repository at all) is a legitimate answer of "nothing to report",
|
|
70
|
+
* not a crash. The write path below uses {@link runStep} instead, where a
|
|
71
|
+
* failure IS fatal.
|
|
72
|
+
* @param {(cwd: string, ...args: string[]) => string} git
|
|
73
|
+
* @param {string} cwd
|
|
74
|
+
* @param {string[]} args
|
|
75
|
+
* @returns {string} trimmed stdout, or `''` when git failed.
|
|
76
|
+
*/
|
|
77
|
+
function probeGit(git, cwd, args) {
|
|
78
|
+
try {
|
|
79
|
+
const out = git(cwd, ...args);
|
|
80
|
+
return typeof out === 'string' ? out.trim() : '';
|
|
81
|
+
} catch (_) {
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Wrap one write step so a git or `gh` failure surfaces as a fatal error that
|
|
88
|
+
* names the step that broke. Accepts sync and async steps alike.
|
|
89
|
+
* @param {string} name
|
|
90
|
+
* @param {() => unknown} fn
|
|
91
|
+
* @returns {Promise<unknown>}
|
|
92
|
+
*/
|
|
93
|
+
async function runStep(name, fn) {
|
|
94
|
+
try {
|
|
95
|
+
return await fn();
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`--ledger-commit failed at step "${name}": ${error?.message ?? error}`,
|
|
99
|
+
{ cause: error },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Inspect whether the ledger changed and whether this checkout could persist
|
|
106
|
+
* it at all. Module-local: the two exported entry points below are the whole
|
|
107
|
+
* public surface, so a probe helper never becomes a second way in.
|
|
108
|
+
*
|
|
109
|
+
* `unpersisted` is the signal the unflagged `--auto` summary carries: the
|
|
110
|
+
* sweep produced new memory, and this checkout has nowhere to put it — either
|
|
111
|
+
* there is no `origin` to push to or HEAD is not on the base branch, so a
|
|
112
|
+
* commit here would not reach the repository's shared state.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} [params]
|
|
115
|
+
* @param {string} [params.ledgerPath] — defaults to `baselines/audit-ledger.json`.
|
|
116
|
+
* @param {string} [params.baseBranch] — defaults to resolved `project.baseBranch`.
|
|
117
|
+
* @param {string} [params.cwd]
|
|
118
|
+
* @param {(cwd: string, ...args: string[]) => string} [params.git]
|
|
119
|
+
* @returns {Promise<{ ledgerPath: string, baseBranch: string, changed: boolean,
|
|
120
|
+
* hasOrigin: boolean, headBranch: string, onBaseBranch: boolean,
|
|
121
|
+
* unpersisted: boolean }>}
|
|
122
|
+
*/
|
|
123
|
+
async function assessLedgerPersistence({
|
|
124
|
+
ledgerPath = DEFAULT_LEDGER_PATH,
|
|
125
|
+
baseBranch,
|
|
126
|
+
cwd = process.cwd(),
|
|
127
|
+
git = gitSync,
|
|
128
|
+
} = {}) {
|
|
129
|
+
const base = await resolveBaseBranch(baseBranch);
|
|
130
|
+
const changed =
|
|
131
|
+
probeGit(git, cwd, ['status', '--porcelain', '--', ledgerPath]).length > 0;
|
|
132
|
+
const hasOrigin = probeGit(git, cwd, ['remote'])
|
|
133
|
+
.split('\n')
|
|
134
|
+
.map((line) => line.trim())
|
|
135
|
+
.includes('origin');
|
|
136
|
+
const headBranch = probeGit(git, cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
137
|
+
const onBaseBranch = headBranch === base;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
ledgerPath,
|
|
141
|
+
baseBranch: base,
|
|
142
|
+
changed,
|
|
143
|
+
hasOrigin,
|
|
144
|
+
headBranch,
|
|
145
|
+
onBaseBranch,
|
|
146
|
+
unpersisted: changed && (!hasOrigin || !onBaseBranch),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Warn that the reconciled ledger has nowhere to go. Names the file, because
|
|
152
|
+
* "state will be lost" is unactionable without knowing which state.
|
|
153
|
+
* @param {{ ledgerPath: string, baseBranch: string, hasOrigin: boolean, headBranch: string }} state
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
function unpersistedWarning(state) {
|
|
157
|
+
const cause = state.hasOrigin
|
|
158
|
+
? `HEAD is on "${state.headBranch || '(detached)'}", not the base branch "${state.baseBranch}"`
|
|
159
|
+
: 'this checkout has no "origin" remote';
|
|
160
|
+
return `ledger not persisted: ${state.ledgerPath} changed but ${cause}, so this sweep's memory will be lost when the checkout goes away. Re-run with --ledger-commit to open a PR for it, or commit ${state.ledgerPath} by hand.`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolve the `--auto` summary's `ledger` field, annotating it with
|
|
165
|
+
* `unpersisted: true` (and warning on stderr) when the sweep produced memory
|
|
166
|
+
* this checkout cannot keep.
|
|
167
|
+
*
|
|
168
|
+
* The whole decision lives here rather than in the CLI so `runAuto` stays a
|
|
169
|
+
* straight-line assembly of its summary: `dryRun` and `ledgerCommit` are
|
|
170
|
+
* passed through raw and branched on once, in one place.
|
|
171
|
+
*
|
|
172
|
+
* @param {object} [params]
|
|
173
|
+
* @param {object|null} [params.ledger] — the plan's ledger summary, or null.
|
|
174
|
+
* @param {string} [params.ledgerPath]
|
|
175
|
+
* @param {boolean} [params.dryRun] — nothing was written, so nothing is at risk.
|
|
176
|
+
* @param {boolean} [params.ledgerCommit] — a PR is about to persist it.
|
|
177
|
+
* @param {string} [params.cwd]
|
|
178
|
+
* @param {(cwd: string, ...args: string[]) => string} [params.git]
|
|
179
|
+
* @param {{ warn: Function }} [params.logger]
|
|
180
|
+
* @returns {Promise<object|null>} the (possibly annotated) ledger summary.
|
|
181
|
+
*/
|
|
182
|
+
export async function resolveLedgerSummary({
|
|
183
|
+
ledger = null,
|
|
184
|
+
ledgerPath = DEFAULT_LEDGER_PATH,
|
|
185
|
+
dryRun,
|
|
186
|
+
ledgerCommit,
|
|
187
|
+
cwd,
|
|
188
|
+
git,
|
|
189
|
+
logger,
|
|
190
|
+
} = {}) {
|
|
191
|
+
if (dryRun || ledgerCommit) return ledger;
|
|
192
|
+
const state = await assessLedgerPersistence({ ledgerPath, cwd, git });
|
|
193
|
+
if (!state.unpersisted) return ledger;
|
|
194
|
+
logger?.warn?.(unpersistedWarning(state));
|
|
195
|
+
return { ...(ledger ?? { path: ledgerPath }), unpersisted: true };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Compose the ledger PR body. Kept separate so the step sequence below reads
|
|
200
|
+
* as a sequence and not as a string-building exercise.
|
|
201
|
+
* @param {string} ledgerPath
|
|
202
|
+
* @param {string} date
|
|
203
|
+
* @returns {string}
|
|
204
|
+
*/
|
|
205
|
+
function pullRequestBody(ledgerPath, date) {
|
|
206
|
+
return [
|
|
207
|
+
`Reconciles the cross-run audit ledger (\`${ledgerPath}\`) written by the`,
|
|
208
|
+
`unattended \`audit-to-stories --auto\` sweep on ${date}.`,
|
|
209
|
+
'',
|
|
210
|
+
'Ledger-only change — no source, workflow or documentation file is touched.',
|
|
211
|
+
'Merging it is what gives the next sweep a memory: without it the ledger',
|
|
212
|
+
'dies with the checkout and every later run re-proposes findings this one',
|
|
213
|
+
'already filed, and re-surfaces findings a human already rejected.',
|
|
214
|
+
'',
|
|
215
|
+
'Auto-merge is deliberately not requested: the ledger records machine-derived',
|
|
216
|
+
'lifecycle state, and a human glance before it lands is the point.',
|
|
217
|
+
].join('\n');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Commit the changed ledger onto a dated branch and open a PR for it.
|
|
222
|
+
*
|
|
223
|
+
* Skipped — returning `{ committed: false }` with a `reason` — when the ledger
|
|
224
|
+
* did not change. Every git/`gh` failure is fatal and names its step; the
|
|
225
|
+
* caller runs this *after* printing the run summary, so a broken remote never
|
|
226
|
+
* costs the operator the sweep's findings.
|
|
227
|
+
*
|
|
228
|
+
* @param {object} [params]
|
|
229
|
+
* @param {string} [params.ledgerPath]
|
|
230
|
+
* @param {string} [params.baseBranch]
|
|
231
|
+
* @param {string} [params.cwd]
|
|
232
|
+
* @param {(cwd: string, ...args: string[]) => string} [params.git]
|
|
233
|
+
* @param {{ pr: { create: (flags: string[]) => Promise<unknown> } }} [params.gh]
|
|
234
|
+
* @param {Date|string|number} [params.now]
|
|
235
|
+
* @returns {Promise<{ committed: boolean, reason?: string, branch?: string,
|
|
236
|
+
* subject?: string, baseBranch?: string, ledgerPath: string }>}
|
|
237
|
+
*/
|
|
238
|
+
export async function runLedgerCommit({
|
|
239
|
+
ledgerPath = DEFAULT_LEDGER_PATH,
|
|
240
|
+
baseBranch,
|
|
241
|
+
cwd = process.cwd(),
|
|
242
|
+
git = gitSync,
|
|
243
|
+
gh = defaultGh,
|
|
244
|
+
now,
|
|
245
|
+
} = {}) {
|
|
246
|
+
const state = await assessLedgerPersistence({
|
|
247
|
+
ledgerPath,
|
|
248
|
+
baseBranch,
|
|
249
|
+
cwd,
|
|
250
|
+
git,
|
|
251
|
+
});
|
|
252
|
+
if (!state.changed) {
|
|
253
|
+
return { committed: false, reason: 'ledger-unchanged', ledgerPath };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const date = isoDate(now);
|
|
257
|
+
const branch = `chore/audit-ledger-${date}`;
|
|
258
|
+
const subject = `chore(audit): reconcile audit ledger ${date}`;
|
|
259
|
+
|
|
260
|
+
await runStep('create-branch', () => git(cwd, 'checkout', '-b', branch));
|
|
261
|
+
await runStep('stage-ledger', () => git(cwd, 'add', '--', ledgerPath));
|
|
262
|
+
// The `-- <path>` pathspec is what keeps the commit ledger-only even when
|
|
263
|
+
// the sweep's checkout carries unrelated dirt.
|
|
264
|
+
await runStep('commit-ledger', () =>
|
|
265
|
+
git(cwd, 'commit', '-m', subject, '--', ledgerPath),
|
|
266
|
+
);
|
|
267
|
+
await runStep('push-branch', () =>
|
|
268
|
+
git(cwd, 'push', '--set-upstream', 'origin', branch),
|
|
269
|
+
);
|
|
270
|
+
await runStep('open-pull-request', () =>
|
|
271
|
+
gh.pr.create([
|
|
272
|
+
'--base',
|
|
273
|
+
state.baseBranch,
|
|
274
|
+
'--head',
|
|
275
|
+
branch,
|
|
276
|
+
'--title',
|
|
277
|
+
subject,
|
|
278
|
+
'--body',
|
|
279
|
+
pullRequestBody(ledgerPath, date),
|
|
280
|
+
]),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
committed: true,
|
|
285
|
+
branch,
|
|
286
|
+
subject,
|
|
287
|
+
baseBranch: state.baseBranch,
|
|
288
|
+
ledgerPath,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -20,7 +20,10 @@ import path from 'node:path';
|
|
|
20
20
|
import { normalizeSeverity } from '../findings/severity.js';
|
|
21
21
|
|
|
22
22
|
const KEY_LINE = /^\s*-\s*\*\*([^:*]+):\*\*\s*(.*)$/;
|
|
23
|
-
const HEADING_FINDING =
|
|
23
|
+
const HEADING_FINDING = /^(#{3,4})\s+(.+?)\s*$/;
|
|
24
|
+
const SEVERITY_KEY_LINE = /^\s*-\s*\*\*(?:severity|impact)\s*:\*\*/i;
|
|
25
|
+
const TALLY_LINE =
|
|
26
|
+
/severity\s+tally\s*:?\**\s*critical\s+(\d+)\s*\/\s*high\s+(\d+)\s*\/\s*medium\s+(\d+)\s*\/\s*low\s+(\d+)/i;
|
|
24
27
|
const HEADING_SECTION = /^##\s+(.+?)\s*$/;
|
|
25
28
|
const PATH_HINT =
|
|
26
29
|
/(?<![\w/])([A-Za-z0-9_./\\@-]+\.(?:js|ts|tsx|jsx|mjs|cjs|md|json|yaml|yml|css|scss|html|py|go|rs|java|kt|rb|sh|ps1|tf|env))(?![\w])/g;
|
|
@@ -238,7 +241,11 @@ function splitFindingBlocks(reportText) {
|
|
|
238
241
|
const findingMatch = HEADING_FINDING.exec(line);
|
|
239
242
|
if (findingMatch) {
|
|
240
243
|
if (current) blocks.push(current);
|
|
241
|
-
current = {
|
|
244
|
+
current = {
|
|
245
|
+
level: findingMatch[1].length,
|
|
246
|
+
title: findingMatch[2].trim(),
|
|
247
|
+
bodyLines: [],
|
|
248
|
+
};
|
|
242
249
|
continue;
|
|
243
250
|
}
|
|
244
251
|
|
|
@@ -249,6 +256,88 @@ function splitFindingBlocks(reportText) {
|
|
|
249
256
|
return blocks;
|
|
250
257
|
}
|
|
251
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Does this block carry the axis line that makes it a finding rather than a
|
|
261
|
+
* section header? The skeleton mandates `Severity` (or its `Impact` alias), so
|
|
262
|
+
* its absence is the signal that a heading is organisational.
|
|
263
|
+
*
|
|
264
|
+
* @param {{ bodyLines: string[] }} block
|
|
265
|
+
* @returns {boolean}
|
|
266
|
+
*/
|
|
267
|
+
function carriesSeverity(block) {
|
|
268
|
+
return block.bodyLines.some((line) => SEVERITY_KEY_LINE.test(line));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Resolve `###` headings that are **grouping headers** rather than findings.
|
|
273
|
+
*
|
|
274
|
+
* Several lenses nest their findings one level deeper — a `###` per dimension
|
|
275
|
+
* (`### Perceivable`), each holding `####` finding blocks. Read flat, that
|
|
276
|
+
* report parsed as one severity-less finding per dimension with no files and
|
|
277
|
+
* no recommendation, and `--auto` filed those empties (Story #5144). The rule
|
|
278
|
+
* this applies: a `###` heading that carries no `Severity:`/`Impact:` line and
|
|
279
|
+
* is followed by `####` headings is a grouping header — its `####` children
|
|
280
|
+
* are emitted as findings and the header itself never is.
|
|
281
|
+
*
|
|
282
|
+
* A `###` heading that DOES carry the axis line keeps the previous behaviour:
|
|
283
|
+
* its `####` sub-sections fold back into its own body rather than splitting
|
|
284
|
+
* into phantom findings, so existing flat reports parse exactly as before.
|
|
285
|
+
*
|
|
286
|
+
* @param {Array<{ level: number, title: string, bodyLines: string[] }>} blocks
|
|
287
|
+
* @returns {Array<{ level: number, title: string, bodyLines: string[] }>}
|
|
288
|
+
*/
|
|
289
|
+
function foldGroupingHeaders(blocks) {
|
|
290
|
+
const out = [];
|
|
291
|
+
let parent = null;
|
|
292
|
+
for (const block of blocks) {
|
|
293
|
+
if (block.level <= 3) {
|
|
294
|
+
parent = block;
|
|
295
|
+
out.push(block);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (!parent) {
|
|
299
|
+
out.push(block);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (carriesSeverity(parent)) {
|
|
303
|
+
parent.bodyLines.push(`#### ${block.title}`, ...block.bodyLines);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
parent.isGroupingHeader = true;
|
|
307
|
+
out.push(block);
|
|
308
|
+
}
|
|
309
|
+
return out.filter((block) => !block.isGroupingHeader);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Read the machine-readable severity tally the report envelope mandates in its
|
|
314
|
+
* `## Executive Summary`:
|
|
315
|
+
*
|
|
316
|
+
* ```text
|
|
317
|
+
* Severity tally: Critical 0 / High 2 / Medium 1 / Low 0
|
|
318
|
+
* ```
|
|
319
|
+
*
|
|
320
|
+
* The line is what lets a consumer cross-check what the lens says it found
|
|
321
|
+
* against what the parser actually extracted — a parse that silently drops
|
|
322
|
+
* findings is otherwise indistinguishable from a clean report. `Info` is never
|
|
323
|
+
* counted (the severity scale already excludes it from scheduled work).
|
|
324
|
+
*
|
|
325
|
+
* @param {string} markdown — full report text.
|
|
326
|
+
* @returns {{ critical: number, high: number, medium: number, low: number }|null}
|
|
327
|
+
* `null` when the report declares no tally at all.
|
|
328
|
+
*/
|
|
329
|
+
export function parseSeverityTally(markdown) {
|
|
330
|
+
if (typeof markdown !== 'string') return null;
|
|
331
|
+
const match = TALLY_LINE.exec(markdown);
|
|
332
|
+
if (!match) return null;
|
|
333
|
+
return {
|
|
334
|
+
critical: Number(match[1]),
|
|
335
|
+
high: Number(match[2]),
|
|
336
|
+
medium: Number(match[3]),
|
|
337
|
+
low: Number(match[4]),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
252
341
|
function parseBlockFields(bodyLines) {
|
|
253
342
|
const fields = {};
|
|
254
343
|
let activeKey = null;
|
|
@@ -302,7 +391,7 @@ export function parseAuditReport({ markdown, sourceReport, repoRoot }) {
|
|
|
302
391
|
}
|
|
303
392
|
|
|
304
393
|
const fallbackDimension = inferDimensionFromReportName(sourceReport);
|
|
305
|
-
const blocks = splitFindingBlocks(markdown);
|
|
394
|
+
const blocks = foldGroupingHeaders(splitFindingBlocks(markdown));
|
|
306
395
|
|
|
307
396
|
return blocks.map((block) => {
|
|
308
397
|
const fields = parseBlockFields(block.bodyLines);
|
|
@@ -357,6 +446,8 @@ export function parseAuditReports(reports, { repoRoot } = {}) {
|
|
|
357
446
|
}
|
|
358
447
|
|
|
359
448
|
export const __testing = {
|
|
449
|
+
carriesSeverity,
|
|
450
|
+
foldGroupingHeaders,
|
|
360
451
|
normaliseSeverity,
|
|
361
452
|
extractFilePaths,
|
|
362
453
|
normaliseTitle,
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { SEVERITIES } from '../findings/severity.js';
|
|
21
|
+
import { formatEpicGrouping } from './epic-grouping-directive.js';
|
|
21
22
|
import {
|
|
22
23
|
renderFingerprintFooter,
|
|
23
24
|
renderSemanticKeyFooter,
|
|
@@ -137,6 +138,10 @@ function formatKeyAssumptions(sourceReports) {
|
|
|
137
138
|
* @param {string[]} params.sourceReports — list of source report paths.
|
|
138
139
|
* @returns {string}
|
|
139
140
|
*/
|
|
141
|
+
/**
|
|
142
|
+
* @param {{ groups: object[], findings: object[], sourceReports: object[] }} opts
|
|
143
|
+
* @returns {string} The `/mandrel-plan` seed one-pager.
|
|
144
|
+
*/
|
|
140
145
|
export function buildPlanSeedMarkdown({ groups, findings, sourceReports }) {
|
|
141
146
|
if (
|
|
142
147
|
!Array.isArray(groups) ||
|
|
@@ -152,6 +157,7 @@ export function buildPlanSeedMarkdown({ groups, findings, sourceReports }) {
|
|
|
152
157
|
const scope = formatMVPScope(groups);
|
|
153
158
|
const files = formatKeyFiles(groups);
|
|
154
159
|
const assumptions = formatKeyAssumptions(sourceReports);
|
|
160
|
+
const grouping = formatEpicGrouping(groups);
|
|
155
161
|
|
|
156
162
|
return [
|
|
157
163
|
'# Idea Seed: Audit Remediation',
|
|
@@ -176,6 +182,10 @@ export function buildPlanSeedMarkdown({ groups, findings, sourceReports }) {
|
|
|
176
182
|
'',
|
|
177
183
|
files,
|
|
178
184
|
'',
|
|
185
|
+
'## Grouping',
|
|
186
|
+
'',
|
|
187
|
+
grouping,
|
|
188
|
+
'',
|
|
179
189
|
'## Not Doing',
|
|
180
190
|
'',
|
|
181
191
|
'- Findings with severity below the operator-selected threshold.',
|
|
@@ -74,8 +74,26 @@ export function isValidTransition(fromState, toState) {
|
|
|
74
74
|
return allowed.includes(toState);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Ticket-type axis.
|
|
79
|
+
*
|
|
80
|
+
* `STORY` is the only type carrying an execution payload — it is what
|
|
81
|
+
* `/mandrel-deliver` branches, implements and lands.
|
|
82
|
+
*
|
|
83
|
+
* `EPIC` (Story #5139) is a **pure container**: a grouping ticket that holds
|
|
84
|
+
* a `## Goal` paragraph and a child checklist and nothing else. It is never
|
|
85
|
+
* branched, never implemented, and never carries an `agent::*` label — that
|
|
86
|
+
* absence is what keeps it out of the bare `/mandrel-deliver` ready list and
|
|
87
|
+
* outside `lint-issue-body.js`, which is `type::story`-scoped.
|
|
88
|
+
*
|
|
89
|
+
* This is deliberately NOT a revival of the v1 Epic tier. Linkage runs
|
|
90
|
+
* parent→child only (the Epic body's checklist plus native sub-issue edges),
|
|
91
|
+
* so Story bodies stay untouched and the `Epic: #N` footer stays retired and
|
|
92
|
+
* refused. See ADR `20260905-container-epic` in `docs/decisions.md`.
|
|
93
|
+
*/
|
|
77
94
|
export const TYPE_LABELS = {
|
|
78
95
|
STORY: 'type::story',
|
|
96
|
+
EPIC: 'type::epic',
|
|
79
97
|
};
|
|
80
98
|
|
|
81
99
|
export const STATUS_LABELS = {
|
|
@@ -20,14 +20,27 @@ import {
|
|
|
20
20
|
TYPE_LABELS,
|
|
21
21
|
} from './label-constants.js';
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The ticket-type axis. Both rows share one colour, so they are derived from
|
|
25
|
+
* `[name, description]` pairs rather than restated as full literals — Story
|
|
26
|
+
* #5139 added `type::epic` here, and the derived form absorbs it without
|
|
27
|
+
* growing the file's structural weight.
|
|
28
|
+
*
|
|
29
|
+
* @type {Array<{ name: string, color: string, description: string }>}
|
|
30
|
+
*/
|
|
31
|
+
const TYPE_LABEL_ROWS = [
|
|
32
|
+
[TYPE_LABELS.STORY, 'Story work item'],
|
|
33
|
+
[TYPE_LABELS.EPIC, 'Container-only grouping ticket for child Stories'],
|
|
34
|
+
].map(([name, description]) => ({
|
|
35
|
+
name,
|
|
36
|
+
color: LABEL_COLORS.TYPE,
|
|
37
|
+
description,
|
|
38
|
+
}));
|
|
39
|
+
|
|
23
40
|
/** @type {Array<{ name: string, color: string, description: string }>} */
|
|
24
41
|
export const LABEL_TAXONOMY = [
|
|
25
42
|
// Type
|
|
26
|
-
|
|
27
|
-
name: TYPE_LABELS.STORY,
|
|
28
|
-
color: LABEL_COLORS.TYPE,
|
|
29
|
-
description: 'Story work item',
|
|
30
|
-
},
|
|
43
|
+
...TYPE_LABEL_ROWS,
|
|
31
44
|
|
|
32
45
|
// Agent State
|
|
33
46
|
{
|