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.
Files changed (41) hide show
  1. package/.agents/README.md +51 -11
  2. package/.agents/agents/auditor.md +5 -0
  3. package/.agents/docs/SDLC.md +21 -12
  4. package/.agents/docs/agentrc-reference.json +1 -4
  5. package/.agents/docs/configuration.md +2 -2
  6. package/.agents/instructions.md +17 -16
  7. package/.agents/schemas/agentrc.schema.json +6 -7
  8. package/.agents/scripts/audit-to-stories.js +510 -66
  9. package/.agents/scripts/generate-skills-index.js +158 -75
  10. package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +39 -0
  11. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
  12. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
  13. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
  14. package/.agents/scripts/lib/changed-files.js +100 -9
  15. package/.agents/scripts/lib/config-settings-schema.js +25 -7
  16. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  17. package/.agents/scripts/lib/label-constants.js +18 -0
  18. package/.agents/scripts/lib/label-taxonomy.js +18 -5
  19. package/.agents/scripts/lib/orchestration/epic-container.js +186 -0
  20. package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
  21. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +320 -0
  22. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +18 -0
  23. package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
  24. package/.agents/scripts/lib/qa/resolve-qa-contract.js +58 -6
  25. package/.agents/scripts/lib/skills/skills-index.js +168 -0
  26. package/.agents/scripts/lib/skills/walk-skill-files.js +133 -9
  27. package/.agents/scripts/plan-persist.js +39 -1
  28. package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
  29. package/.agents/scripts/quality-preview.js +50 -9
  30. package/.agents/scripts/resolve-stories.js +42 -2
  31. package/.agents/scripts/validate-skills.js +53 -66
  32. package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
  33. package/.agents/workflows/audit-to-stories.md +85 -7
  34. package/.agents/workflows/helpers/audit-lens-core.md +24 -4
  35. package/.agents/workflows/helpers/deliver-reference.md +8 -0
  36. package/.agents/workflows/helpers/plan-reference.md +28 -0
  37. package/.agents/workflows/mandrel-deliver.md +47 -43
  38. package/.agents/workflows/mandrel-plan.md +44 -38
  39. package/.agents/workflows/qa-run.md +13 -5
  40. package/docs/CHANGELOG.md +28 -0
  41. package/package.json +1 -1
@@ -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 = /^###\s+(.+?)\s*$/;
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 = { title: findingMatch[1].trim(), bodyLines: [] };
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.',
@@ -122,13 +122,98 @@ export function getChangedFiles({
122
122
  return parseNameOnlyStdout(res.stdout);
123
123
  }
124
124
 
125
+ /**
126
+ * A full-length hex object id, as `git rev-parse` prints it. Used to reject
127
+ * anything that is not a resolved commit — a stubbed git interface in a test
128
+ * answers every `gitSpawn` with the same canned stdout, and a file list must
129
+ * never be mistaken for a merge head.
130
+ */
131
+ const OBJECT_ID_RE = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
132
+
133
+ /**
134
+ * Resolve the commit an in-progress merge is merging **in**, or `null` when no
135
+ * merge is in progress.
136
+ *
137
+ * Story #5131. `git diff --cached` with no commit argument diffs the index
138
+ * against `HEAD`, and during a merge `HEAD` is still the pre-merge tip — so a
139
+ * base-sync merge commit (`git merge --no-edit origin/<base>`, which
140
+ * `single-story-close`'s base-sync phase tells the operator to run by hand)
141
+ * put every file the base branch had landed into the staged scope. The
142
+ * pre-commit MI/CRAP gate then blocked the resolution commit for deltas
143
+ * belonging to already-landed, already-gated work, with no remedy: the preview
144
+ * is a delta against the baseline, not a baseline comparison, so no baseline
145
+ * refresh could silence it.
146
+ *
147
+ * Two details are load-bearing:
148
+ *
149
+ * - **Ask git, never the filesystem.** `.git` is a *file*, not a directory,
150
+ * in the linked worktrees this repo delivers from, so an
151
+ * `existsSync('.git/MERGE_HEAD')` probe would be silently inert exactly
152
+ * where deliveries happen. `rev-parse --verify` resolves the ref through
153
+ * git's own worktree-aware lookup.
154
+ * - **`--verify` fails closed on an octopus merge.** It refuses a
155
+ * `MERGE_HEAD` naming more than one head, which lands here as `null` — the
156
+ * pre-#5131 behaviour. Narrowing the scope wrongly would hide a real
157
+ * regression; widening it only restores the status quo.
158
+ *
159
+ * Never throws: a merge is either detectable or it is not, and an
160
+ * undetectable one must degrade to the plain cached diff rather than fail the
161
+ * gate.
162
+ *
163
+ * @param {object} [params]
164
+ * @param {string} [params.cwd=process.cwd()]
165
+ * @param {ReturnType<typeof createGitInterface>} [params.git]
166
+ * @returns {string | null} The merge head's object id, or `null`.
167
+ */
168
+ export function resolveMergeHead({ cwd = process.cwd(), git } = {}) {
169
+ const gitIface = git ?? createGitInterface({});
170
+ let res;
171
+ try {
172
+ res = gitIface.gitSpawn(cwd, 'rev-parse', '-q', '--verify', 'MERGE_HEAD');
173
+ } catch {
174
+ return null;
175
+ }
176
+ if (res?.status !== 0) return null;
177
+ const sha = (res.stdout ?? '').trim();
178
+ return OBJECT_ID_RE.test(sha) ? sha : null;
179
+ }
180
+
181
+ /**
182
+ * Read the index file list against an explicit base, shared by
183
+ * `getStagedFiles` and `resolvePreviewScope` so the merge head is resolved
184
+ * once per scope resolution rather than once per caller.
185
+ *
186
+ * @param {object} params
187
+ * @param {string} params.cwd
188
+ * @param {ReturnType<typeof createGitInterface>} params.git
189
+ * @param {string | null} params.mergeHead
190
+ * @returns {string[]}
191
+ */
192
+ function stagedFilesAgainst({ cwd, git, mergeHead }) {
193
+ const args = ['diff', '--name-only', '--cached'];
194
+ if (mergeHead) args.push(mergeHead);
195
+ const res = git.gitSpawn(cwd, ...args);
196
+ if (res.status !== 0) {
197
+ const detail = res.stderr || res.stdout || `exit ${res.status}`;
198
+ throw new Error(`[staged] unable to read cached diff: ${detail}`);
199
+ }
200
+ return parseNameOnlyStdout(res.stdout);
201
+ }
202
+
125
203
  /**
126
204
  * Resolve paths in the index (staged for commit). Used by `quality-preview
127
205
  * --staged` so pre-commit gates score only the commit payload, not unstaged
128
206
  * working-tree edits.
129
207
  *
130
208
  * Semantics:
131
- * - Runs `git diff --name-only --cached`.
209
+ * - Runs `git diff --name-only --cached`, which diffs the index against
210
+ * `HEAD`.
211
+ * - **During a merge**, diffs the index against `MERGE_HEAD` instead
212
+ * (Story #5131), so the scope is the merging branch's own contribution
213
+ * plus its conflict resolutions — not the base branch's incoming work.
214
+ * `git merge-base HEAD MERGE_HEAD` would *not* do: diffing the index
215
+ * against the fork point re-admits everything the base branch landed since
216
+ * it, which is the whole defect.
132
217
  * - Returns forward-slash-normalized repo-relative paths.
133
218
  * - Non-zero git exit throws — staged mode must not silently widen scope.
134
219
  *
@@ -139,12 +224,11 @@ export function getChangedFiles({
139
224
  */
140
225
  export function getStagedFiles({ cwd = process.cwd(), git } = {}) {
141
226
  const gitIface = git ?? createGitInterface({});
142
- const res = gitIface.gitSpawn(cwd, 'diff', '--name-only', '--cached');
143
- if (res.status !== 0) {
144
- const detail = res.stderr || res.stdout || `exit ${res.status}`;
145
- throw new Error(`[staged] unable to read cached diff: ${detail}`);
146
- }
147
- return parseNameOnlyStdout(res.stdout);
227
+ return stagedFilesAgainst({
228
+ cwd,
229
+ git: gitIface,
230
+ mergeHead: resolveMergeHead({ cwd, git: gitIface }),
231
+ });
148
232
  }
149
233
 
150
234
  /**
@@ -154,6 +238,11 @@ export function getStagedFiles({ cwd = process.cwd(), git } = {}) {
154
238
  * is ignored. Otherwise a `changedSinceRef` limits to that three-dot diff;
155
239
  * when both are absent the caller runs in full-repo mode (`scopeSet: null`).
156
240
  *
241
+ * In `staged` scope, `diffRef` carries the in-progress merge head when there
242
+ * is one (Story #5131) and `null` otherwise, so a caller can tell the operator
243
+ * *why* the scope narrowed. `scope` stays `'staged'` either way — the merge is
244
+ * a property of the base the index is read against, not a different mode.
245
+ *
157
246
  * @param {object} [params]
158
247
  * @param {boolean} [params.staged=false]
159
248
  * @param {string | null} [params.changedSinceRef=null]
@@ -172,8 +261,10 @@ export function resolvePreviewScope({
172
261
  git,
173
262
  } = {}) {
174
263
  if (staged) {
175
- const files = getStagedFiles({ cwd, git });
176
- return { scopeSet: new Set(files), scope: 'staged', diffRef: null };
264
+ const gitIface = git ?? createGitInterface({});
265
+ const mergeHead = resolveMergeHead({ cwd, git: gitIface });
266
+ const files = stagedFilesAgainst({ cwd, git: gitIface, mergeHead });
267
+ return { scopeSet: new Set(files), scope: 'staged', diffRef: mergeHead };
177
268
  }
178
269
  if (changedSinceRef) {
179
270
  try {
@@ -622,6 +622,17 @@ const PLANNING_SCHEMA = {
622
622
  // qa.* — Agent-driven QA harness contract (Epic #3214)
623
623
  // ---------------------------------------------------------------------------
624
624
 
625
+ // The per-environment sign-in seam. `{ urlTemplate }` is a dev
626
+ // impersonation route; `{ skill }` names a skill by its tier-relative id
627
+ // (e.g. `stack/qa/acme-sso`), resolved against the payload skills root and
628
+ // then the consumer-writable `.agents/local/skills/` zone (Story #5135).
629
+ //
630
+ // No default anywhere in this file may name a skill id: the framework ships
631
+ // no sign-in skill, so any id baked into an inventory value would be a
632
+ // dangling pointer a consumer copies verbatim — the exact defect #5134
633
+ // reported. The `{ skill }` arm is taught in prose (`.agents/README.md`
634
+ // § "Expose a `signInSeam`"), and `resolveQaEnvironment` fails loudly on an
635
+ // id that resolves under neither root.
625
636
  const QA_SIGN_IN_SEAM_SCHEMA = {
626
637
  oneOf: [
627
638
  {
@@ -654,9 +665,13 @@ const QA_PERSONAS_SCHEMA = {
654
665
  description:
655
666
  'Personas the QA-harness sign-in seam accepts. Two accepted shapes: (1) a plain array of persona names — the honest shape for a `urlTemplate` dev-impersonation seam, where the persona name is the sole input the workflow consumes; (2) the object-map form keyed by persona name, where each entry carries per-persona auth material (`credentialRef` or `signInSkill`) consulted only under a skill-based or credential-based seam.',
656
667
  // Inventory value: an illustrative map showing both per-persona shapes.
668
+ // Inventory value: illustrative credential references, not resolvable
669
+ // ones. It deliberately does NOT illustrate the `signInSkill` arm — a
670
+ // skill id in a shipped default is a dangling pointer (see
671
+ // QA_SIGN_IN_SEAM_SCHEMA above); that arm is taught in prose instead.
657
672
  default: {
658
673
  admin: { credentialRef: 'QA_ADMIN_CREDENTIAL' },
659
- member: { signInSkill: 'stack/qa/sign-in-member' },
674
+ member: { credentialRef: 'QA_MEMBER_CREDENTIAL' },
660
675
  },
661
676
  oneOf: [
662
677
  {
@@ -703,11 +718,11 @@ const QA_PERSONAS_SCHEMA = {
703
718
  const QA_ENVIRONMENTS_SCHEMA = {
704
719
  type: 'object',
705
720
  description:
706
- 'Deployment targets the QA harness can run against (Epic #4326). A map keyed by environment name (e.g. `local`, `staging`), each carrying its own `baseUrl`, its own per-environment sign-in seam (the same url-template/skill union as the top-level seam), and an optional `allowWrites` gate. resolveQaEnvironment selects one environment per invocation by name or by raw-URL origin match against `baseUrl`; `allowWrites` defaults to true only for the `local` environment. Replaces the retired top-level single `signInSeam`.',
721
+ 'Deployment targets the QA harness can run against (Epic #4326). A map keyed by environment name (e.g. `local`, `staging`), each carrying its own `baseUrl`, an optional per-environment sign-in seam, and an optional `allowWrites` gate. `signInSeam` is the union `{ urlTemplate }` (a dev impersonation route) or `{ skill }` (a skill id such as `stack/qa/acme-sso`, resolved against `.agents/skills/` then the consumer-writable `.agents/local/skills/` zone, and rejected loudly by resolveQaEnvironment when it resolves under neither); omit it entirely for a target with no sign-in seam. resolveQaEnvironment selects one environment per invocation by name or by raw-URL origin match against `baseUrl`; `allowWrites` defaults to true only for the `local` environment. Replaces the retired top-level single `signInSeam`.',
707
722
  // Inventory value: an illustrative two-environment map, not a resolvable
708
- // default. The QA harness is opt-in and every value here is
709
- // project-specific; the entry exists so `mandrel explain` can show the
710
- // expected shape.
723
+ // default. `staging` deliberately carries NO `signInSeam` that is the
724
+ // honest shape for a deployed target with no dev sign-in seam, and it
725
+ // shows the field is optional (Story #5135).
711
726
  default: {
712
727
  local: {
713
728
  baseUrl: 'http://localhost:3000',
@@ -715,7 +730,6 @@ const QA_ENVIRONMENTS_SCHEMA = {
715
730
  },
716
731
  staging: {
717
732
  baseUrl: 'https://staging.example.test',
718
- signInSeam: { skill: 'stack/qa/sign-in' },
719
733
  allowWrites: false,
720
734
  },
721
735
  },
@@ -727,7 +741,11 @@ const QA_ENVIRONMENTS_SCHEMA = {
727
741
  signInSeam: QA_SIGN_IN_SEAM_SCHEMA,
728
742
  allowWrites: { type: 'boolean' },
729
743
  },
730
- required: ['baseUrl', 'signInSeam'],
744
+ // `signInSeam` is OPTIONAL (Story #5135). An environment that resolves no
745
+ // seam is a state the QA workflows already branch on — they drive the
746
+ // unauthenticated surface and record the gap — so requiring it made an
747
+ // honestly seamless remote target undeclarable.
748
+ required: ['baseUrl'],
731
749
  additionalProperties: false,
732
750
  },
733
751
  };