mandrel 2.38.0 → 2.39.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.
@@ -26,11 +26,25 @@
26
26
  * harness invocation — by exact name or by raw-URL origin match against each
27
27
  * environment's `baseUrl` — and throws loudly (naming the known environments)
28
28
  * on an unknown name or unmatched URL.
29
+ *
30
+ * It also **resolves the selected environment's `signInSeam`** (Story #5135).
31
+ * A `{ skill }` seam naming an id that resolves to no readable `SKILL.md`
32
+ * under either skills root used to fail silently: the contract validated, the
33
+ * seam was returned unread, and the dangling pointer only surfaced much later
34
+ * when a sweep reached its sign-in step — after the harness had already
35
+ * driven a browser. Resolution now happens here, at config-resolution time,
36
+ * so the failure lands where the operator can fix `.agentrc.json`. A seam
37
+ * that is absent entirely is a legitimate, declarable state (the workflows
38
+ * drive the unauthenticated surface and record the gap), not an error.
29
39
  */
30
40
 
31
41
  import Ajv from 'ajv';
32
-
33
42
  import { QA_SCHEMA } from '../config-settings-schema.js';
43
+ import { PROJECT_ROOT } from '../project-root.js';
44
+ import {
45
+ resolveSkillFile,
46
+ SKILL_SEARCH_ROOTS,
47
+ } from '../skills/walk-skill-files.js';
34
48
 
35
49
  /**
36
50
  * The harness-required fields. The AJV `QA_SCHEMA` keeps these optional so
@@ -242,6 +256,40 @@ function toOrigin(value) {
242
256
  }
243
257
  }
244
258
 
259
+ /**
260
+ * Normalize and verify one environment's `signInSeam`.
261
+ *
262
+ * An absent seam normalizes to `null` — a declarable state, not an error.
263
+ * A `{ skill }` seam is resolved against both skills roots and throws when
264
+ * it resolves under neither, so a dangling pointer is caught here rather
265
+ * than mid-sweep. The resolved `SKILL.md` path is attached as
266
+ * `skillPath` so the harness reads the file the check actually found.
267
+ *
268
+ * @param {object | undefined} seam
269
+ * @param {string} envName Environment name, for the error message.
270
+ * @param {{ repoRoot?: string }} options
271
+ * @returns {object | null}
272
+ */
273
+ function resolveSignInSeam(seam, envName, options) {
274
+ if (seam == null) return null;
275
+ if (typeof seam.skill !== 'string') return seam;
276
+
277
+ const repoRoot = options.repoRoot ?? PROJECT_ROOT;
278
+ const found = resolveSkillFile(repoRoot, seam.skill);
279
+ if (found === null) {
280
+ throw new Error(
281
+ `qa: environment \`${envName}\` declares signInSeam.skill ` +
282
+ `\`${seam.skill}\`, which resolves to no readable SKILL.md. ` +
283
+ `Searched ${SKILL_SEARCH_ROOTS.map((r) => `\`${r}/<skill>/SKILL.md\``).join(' and ')}. ` +
284
+ 'Author the skill under the consumer-writable `.agents/local/skills/` ' +
285
+ 'zone (it is never pruned by `mandrel sync` and never flagged as ' +
286
+ 'payload drift), correct the id, or omit `signInSeam` entirely if ' +
287
+ 'this target genuinely has no sign-in seam.',
288
+ );
289
+ }
290
+ return { ...seam, skillPath: found.path };
291
+ }
292
+
245
293
  /**
246
294
  * Resolve a single QA environment for one harness invocation.
247
295
  *
@@ -262,13 +310,17 @@ function toOrigin(value) {
262
310
  * Fails **loudly**: an unknown name or an unmatched URL throws an error that
263
311
  * names the known environments so the operator can correct the invocation.
264
312
  *
265
- * @param {{ environments: Record<string, { baseUrl: string, signInSeam: object, allowWrites?: boolean }>, defaultEnvironment: string }} contract
313
+ * @param {{ environments: Record<string, { baseUrl: string, signInSeam?: object, allowWrites?: boolean }>, defaultEnvironment: string }} contract
266
314
  * A contract returned by `resolveQaContract`.
267
315
  * @param {string} [target] Environment name or raw URL. Omit for the default.
268
- * @returns {{ name: string, baseUrl: string, signInSeam: object, allowWrites: boolean }}
269
- * @throws {Error} on an unknown name or unmatched URL.
316
+ * @param {{ repoRoot?: string }} [options] `repoRoot` roots skill-seam
317
+ * resolution; defaults to the project root. Injected by tests.
318
+ * @returns {{ name: string, baseUrl: string, signInSeam: object | null, allowWrites: boolean }}
319
+ * `signInSeam` is `null` when the environment declares none.
320
+ * @throws {Error} on an unknown name, an unmatched URL, or a `{ skill }` seam
321
+ * that resolves under no skills root.
270
322
  */
271
- export function resolveQaEnvironment(contract, target) {
323
+ export function resolveQaEnvironment(contract, target, options = {}) {
272
324
  const environments = contract?.environments;
273
325
  if (
274
326
  environments == null ||
@@ -320,7 +372,7 @@ export function resolveQaEnvironment(contract, target) {
320
372
  return {
321
373
  name: resolvedName,
322
374
  baseUrl: env.baseUrl,
323
- signInSeam: env.signInSeam,
375
+ signInSeam: resolveSignInSeam(env.signInSeam, resolvedName, options),
324
376
  allowWrites,
325
377
  };
326
378
  }
@@ -0,0 +1,168 @@
1
+ // .agents/scripts/lib/skills/skills-index.js
2
+ //
3
+ // Shared I/O for the two skills manifests (Story #5135).
4
+ //
5
+ // Each skills root carries its own `skills.index.json`: the package payload's
6
+ // at `.agents/skills/`, and the consumer-writable zone's at
7
+ // `.agents/local/skills/`. The shipped one is a committed payload file that
8
+ // `mandrel doctor` / `mandrel sync-agents` compare byte-for-byte against the
9
+ // installed package, so the two manifests must never be merged — but they are
10
+ // read, compared and reported identically, and both CLIs need that logic.
11
+ // Before this module `generate-skills-index.js` and `validate-skills.js`
12
+ // each carried their own near-identical reader.
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ /** Manifest filename, shared by both roots. */
18
+ export const INDEX_FILENAME = 'skills.index.json';
19
+
20
+ /**
21
+ * Absolute path of the manifest for one skills root.
22
+ *
23
+ * @param {string} repoRoot
24
+ * @param {readonly string[]} rootSegments From `walk-skill-files.js`.
25
+ * @returns {string}
26
+ */
27
+ export function indexPathFor(repoRoot, rootSegments) {
28
+ return path.join(repoRoot, ...rootSegments, INDEX_FILENAME);
29
+ }
30
+
31
+ /**
32
+ * Read a manifest from disk. Distinguishes "missing" from "unparseable" via
33
+ * the `reason` channel so callers can report which drift they hit rather than
34
+ * collapsing both into "not fresh".
35
+ *
36
+ * @param {string} indexPath
37
+ * @returns {{ manifest: object | null, reason: string | null }}
38
+ */
39
+ export function readManifest(indexPath) {
40
+ if (!fs.existsSync(indexPath)) {
41
+ return { manifest: null, reason: 'missing' };
42
+ }
43
+ let src;
44
+ try {
45
+ src = fs.readFileSync(indexPath, 'utf8');
46
+ } catch (err) {
47
+ return { manifest: null, reason: `read-error: ${err.message}` };
48
+ }
49
+ try {
50
+ return { manifest: JSON.parse(src), reason: null };
51
+ } catch (err) {
52
+ return { manifest: null, reason: `parse-error: ${err.message}` };
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Read a manifest and project its entry paths into a Set, the shape the
58
+ * validator's membership check consumes.
59
+ *
60
+ * @param {string} indexPath
61
+ * @returns {{ exists: boolean, paths: Set<string> | null, manifest: object | null, indexPath: string, parseError?: string }}
62
+ */
63
+ export function readIndexPaths(indexPath) {
64
+ const { manifest, reason } = readManifest(indexPath);
65
+ if (reason === 'missing') {
66
+ return { exists: false, paths: null, manifest: null, indexPath };
67
+ }
68
+ if (manifest === null) {
69
+ return {
70
+ exists: true,
71
+ paths: null,
72
+ manifest: null,
73
+ indexPath,
74
+ parseError: reason,
75
+ };
76
+ }
77
+ const paths = new Set(
78
+ Array.isArray(manifest.skills)
79
+ ? manifest.skills.map((s) => s.path).filter((p) => typeof p === 'string')
80
+ : [],
81
+ );
82
+ return { exists: true, paths, manifest, indexPath };
83
+ }
84
+
85
+ /**
86
+ * Compare two manifests ignoring `generatedAt` — the one volatile field, which
87
+ * changes on every write and is not content. Returns null when they match, or
88
+ * a diff-style message naming the entry counts.
89
+ *
90
+ * @param {object | null} diskManifest
91
+ * @param {object} freshManifest
92
+ * @param {string} label Manifest name for the message.
93
+ * @returns {string | null}
94
+ */
95
+ export function diffManifests(diskManifest, freshManifest, label) {
96
+ if (diskManifest === null) {
97
+ return `${label}: on-disk manifest is missing or unreadable`;
98
+ }
99
+ const a = { ...diskManifest };
100
+ const b = { ...freshManifest };
101
+ a.generatedAt = undefined;
102
+ b.generatedAt = undefined;
103
+ if (JSON.stringify(a) === JSON.stringify(b)) return null;
104
+ const count = (m) => (Array.isArray(m.skills) ? m.skills.length : 'n/a');
105
+ return [
106
+ `${label} drift detected:`,
107
+ ` on-disk entries: ${count(diskManifest)}`,
108
+ ` generated entries: ${count(freshManifest)}`,
109
+ " run 'node .agents/scripts/generate-skills-index.js' to refresh",
110
+ ].join('\n');
111
+ }
112
+
113
+ /**
114
+ * Render a manifest's schema violations as field-named findings. The compiled
115
+ * AJV validator is passed in so this module stays free of the schema-loading
116
+ * side effects the validator CLI owns.
117
+ *
118
+ * @param {object} manifest
119
+ * @param {string} indexRelPath Repo-relative manifest path, for the message.
120
+ * @param {(m: object) => boolean} validateManifest Compiled AJV validator.
121
+ * @returns {string[]}
122
+ */
123
+ function validateManifestSchema(manifest, indexRelPath, validateManifest) {
124
+ const findings = [];
125
+ if (validateManifest(manifest)) return findings;
126
+ for (const err of validateManifest.errors ?? []) {
127
+ const where = err.instancePath || '(root)';
128
+ findings.push(
129
+ `${indexRelPath}: manifest-schema: schema violation at ${where}: ${err.message}`,
130
+ );
131
+ }
132
+ return findings;
133
+ }
134
+
135
+ /**
136
+ * Audit one root's manifest: present, parseable, and schema-valid. Shared by
137
+ * both skills roots so a consumer-authored index is held to the same bar as
138
+ * the shipped one.
139
+ *
140
+ * @param {{ exists: boolean, paths: Set<string> | null, manifest: object | null, parseError?: string }} indexInfo
141
+ * @param {string} indexRelPath
142
+ * @param {(m: object) => boolean} validateManifest
143
+ * @param {{ required: boolean }} options
144
+ * @returns {string[]}
145
+ */
146
+ export function auditIndex(
147
+ indexInfo,
148
+ indexRelPath,
149
+ validateManifest,
150
+ { required },
151
+ ) {
152
+ if (!indexInfo.exists) {
153
+ return required
154
+ ? [
155
+ `index missing: ${indexRelPath} not found — run 'node .agents/scripts/generate-skills-index.js'`,
156
+ ]
157
+ : [];
158
+ }
159
+ if (indexInfo.paths === null) {
160
+ return [`index unparseable: ${indexRelPath} — ${indexInfo.parseError}`];
161
+ }
162
+ if (indexInfo.manifest === null) return [];
163
+ return validateManifestSchema(
164
+ indexInfo.manifest,
165
+ indexRelPath,
166
+ validateManifest,
167
+ );
168
+ }
@@ -1,12 +1,56 @@
1
1
  // .agents/scripts/lib/skills/walk-skill-files.js
2
2
  //
3
- // Shared traversal for SKILL.md files under `.agents/skills/{core,stack}/`.
3
+ // Shared traversal for SKILL.md files across the two skills roots:
4
+ // the package payload (`.agents/skills/{core,stack}/`) and the
5
+ // consumer-writable local zone (`.agents/local/skills/{core,stack}/`).
4
6
  // Used by validate-skills.js and generate-skills-index.js so both CLIs
5
7
  // enumerate the same paths in the same deterministic order.
8
+ //
9
+ // The two roots stay **separately enumerable** on purpose (Story #5135).
10
+ // `.agents/skills/skills.index.json` is a committed payload file that
11
+ // `mandrel doctor` / `mandrel sync-agents` compare byte-for-byte against
12
+ // the installed package; folding a consumer's local skills into it would
13
+ // make every consumer's regenerated index read as payload drift and cause
14
+ // those commands to refuse. The local zone therefore carries its own
15
+ // index artifact, and the two roots are unified only at *lookup* time, by
16
+ // `resolveSkillFile` — never for the shipped manifest.
6
17
 
7
18
  import fs from 'node:fs';
8
19
  import path from 'node:path';
9
20
 
21
+ /** Tier directories a skills root is enumerated under. */
22
+ const TIERS = Object.freeze(['core', 'stack']);
23
+
24
+ /**
25
+ * Path segments (from the repo root) of the package-payload skills root.
26
+ * Materialized by `mandrel sync`; every file under it is payload.
27
+ */
28
+ export const PAYLOAD_SKILLS_SEGMENTS = Object.freeze(['.agents', 'skills']);
29
+
30
+ /**
31
+ * Path segments (from the repo root) of the consumer-writable skills root.
32
+ * It sits inside the `.agents/local/` zone (Story #3498), which sync never
33
+ * copies into and never prunes, and which the agents-drift check cannot
34
+ * flag because that check only walks files present in the package payload.
35
+ */
36
+ export const LOCAL_SKILLS_SEGMENTS = Object.freeze([
37
+ '.agents',
38
+ 'local',
39
+ 'skills',
40
+ ]);
41
+
42
+ /**
43
+ * A skill id is the tier-relative path naming a skill — e.g.
44
+ * `core/scope-triage` or `stack/qa/playwright`. It is the value that
45
+ * appears in `skills.index.json` minus the root prefix, and the value a
46
+ * `qa.environments.*.signInSeam.skill` seam carries.
47
+ *
48
+ * The pattern is deliberately strict: ids resolve to filesystem paths, so
49
+ * anything that could escape a root (`..`, absolute paths, backslashes) or
50
+ * smuggle a shell metacharacter is rejected rather than normalized.
51
+ */
52
+ const SKILL_ID_RE = /^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)+$/;
53
+
10
54
  /**
11
55
  * Recursively enumerate `SKILL.md` paths under a directory.
12
56
  *
@@ -38,19 +82,99 @@ function walkSkillFiles(rootDir) {
38
82
  }
39
83
 
40
84
  /**
41
- * Build the list of SKILL.md files under `<repoRoot>/.agents/skills/{core,
42
- * stack}/`, sorted by POSIX repo-relative path for deterministic output.
85
+ * Sort absolute paths by their POSIX repo-relative form so output order is
86
+ * deterministic across platforms.
43
87
  *
88
+ * @param {string[]} files
44
89
  * @param {string} repoRoot
45
- * @returns {string[]} absolute paths
90
+ * @returns {string[]}
46
91
  */
47
- export function collectSkillFiles(repoRoot) {
48
- const skillsRoot = path.join(repoRoot, '.agents', 'skills');
49
- const coreFiles = walkSkillFiles(path.join(skillsRoot, 'core'));
50
- const stackFiles = walkSkillFiles(path.join(skillsRoot, 'stack'));
51
- return [...coreFiles, ...stackFiles].sort((a, b) => {
92
+ function sortByRepoRelative(files, repoRoot) {
93
+ return [...files].sort((a, b) => {
52
94
  const ra = path.relative(repoRoot, a).split(path.sep).join('/');
53
95
  const rb = path.relative(repoRoot, b).split(path.sep).join('/');
54
96
  return ra < rb ? -1 : ra > rb ? 1 : 0;
55
97
  });
56
98
  }
99
+
100
+ /**
101
+ * Enumerate the `SKILL.md` files under one skills root, sorted by POSIX
102
+ * repo-relative path.
103
+ *
104
+ * @param {string} repoRoot
105
+ * @param {readonly string[]} rootSegments One of the exported segment lists.
106
+ * @returns {string[]} absolute paths
107
+ */
108
+ function collectUnderRoot(repoRoot, rootSegments) {
109
+ const skillsRoot = path.join(repoRoot, ...rootSegments);
110
+ const files = TIERS.flatMap((tier) =>
111
+ walkSkillFiles(path.join(skillsRoot, tier)),
112
+ );
113
+ return sortByRepoRelative(files, repoRoot);
114
+ }
115
+
116
+ /**
117
+ * Build the list of payload SKILL.md files under
118
+ * `<repoRoot>/.agents/skills/{core,stack}/`.
119
+ *
120
+ * This is the set the **shipped** `skills.index.json` is generated from —
121
+ * it must never include local-zone skills (see the module header).
122
+ *
123
+ * @param {string} repoRoot
124
+ * @returns {string[]} absolute paths
125
+ */
126
+ export function collectSkillFiles(repoRoot) {
127
+ return collectUnderRoot(repoRoot, PAYLOAD_SKILLS_SEGMENTS);
128
+ }
129
+
130
+ /**
131
+ * Build the list of consumer-authored SKILL.md files under
132
+ * `<repoRoot>/.agents/local/skills/{core,stack}/`. Empty when the
133
+ * consumer has authored none — the common case, and the case in this
134
+ * repository itself.
135
+ *
136
+ * @param {string} repoRoot
137
+ * @returns {string[]} absolute paths
138
+ */
139
+ export function collectLocalSkillFiles(repoRoot) {
140
+ return collectUnderRoot(repoRoot, LOCAL_SKILLS_SEGMENTS);
141
+ }
142
+
143
+ /**
144
+ * Resolve a skill id to a readable `SKILL.md`, searching the payload root
145
+ * first and the local zone second (payload-wins, matching
146
+ * {@link collectAllSkillFiles}).
147
+ *
148
+ * Returns `null` rather than throwing so callers own the error message —
149
+ * a config resolver wants to name the offending config key, a workflow
150
+ * wants to name the seam.
151
+ *
152
+ * @param {string} repoRoot
153
+ * @param {string} skillId Tier-relative id, e.g. `stack/qa/acme-sso`.
154
+ * @returns {{ path: string, root: string } | null} absolute `SKILL.md`
155
+ * path and the POSIX repo-relative root it resolved under.
156
+ */
157
+ export function resolveSkillFile(repoRoot, skillId) {
158
+ if (typeof skillId !== 'string' || !SKILL_ID_RE.test(skillId)) return null;
159
+ for (const segments of [PAYLOAD_SKILLS_SEGMENTS, LOCAL_SKILLS_SEGMENTS]) {
160
+ const candidate = path.join(repoRoot, ...segments, skillId, 'SKILL.md');
161
+ try {
162
+ if (fs.statSync(candidate).isFile()) {
163
+ return { path: candidate, root: segments.join('/') };
164
+ }
165
+ } catch {
166
+ // Unreadable or absent — try the next root.
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /**
173
+ * The POSIX repo-relative skills roots, in search order. Exported so error
174
+ * messages can name exactly what was searched rather than restating the
175
+ * paths as literals.
176
+ */
177
+ export const SKILL_SEARCH_ROOTS = Object.freeze([
178
+ PAYLOAD_SKILLS_SEGMENTS.join('/'),
179
+ LOCAL_SKILLS_SEGMENTS.join('/'),
180
+ ]);
@@ -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
- * Story #1394 (Epic #1386) flipped the default scope of both gates to
16
- * diff-against-`main`, so passing `--changed-since HEAD` here mirrors what the
17
- * pre-commit hook actually wants: the delta the operator is about to 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
- ['--staged', 'Score the git index only (the pre-commit-hook scope).'],
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,