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.
package/.agents/README.md CHANGED
@@ -775,7 +775,16 @@ time by `resolveQaContract`. Copy the reference shape from
775
775
  "qa": {
776
776
  "featureRoot": "tests/features", // root the selector resolves .feature files against
777
777
  "fixturesManifest": "tests/fixtures/personas.json", // persona → seed-data manifest
778
- "signInSeam": { "urlTemplate": "/dev/sign-in-as/{persona}" }, // dev seam (see step 3)
778
+ "environments": { // one entry per deployment target
779
+ "local": {
780
+ "baseUrl": "http://localhost:3000",
781
+ "signInSeam": { "urlTemplate": "/dev/sign-in-as/{persona}" } // dev seam (see step 3)
782
+ },
783
+ "staging": {
784
+ "baseUrl": "https://staging.example.test",
785
+ "allowWrites": false // no signInSeam — an honestly seamless target
786
+ }
787
+ },
779
788
  "personas": ["admin", "member"], // name-only array — the honest shape for a url-template seam
780
789
  "consoleAllowlist": ["[HMR]"], // optional benign-noise filter (default [])
781
790
  "designTokens": "src/styles/tokens.css" // optional visual-check pointer (default null)
@@ -783,8 +792,9 @@ time by `resolveQaContract`. Copy the reference shape from
783
792
  }
784
793
  ```
785
794
 
786
- `featureRoot`, `fixturesManifest`, `signInSeam`, and `personas` are mandatory;
787
- omitting any one makes the resolver throw a field-named error.
795
+ `featureRoot`, `fixturesManifest`, `environments`, and `personas` are mandatory;
796
+ omitting any one makes the resolver throw a field-named error. Within an
797
+ environment only `baseUrl` is required — `signInSeam` is optional (see step 3).
788
798
  `consoleAllowlist` and `designTokens` default to `[]` and `null`.
789
799
 
790
800
  `personas` accepts **two shapes** (the resolver normalizes both to one
@@ -800,13 +810,16 @@ canonical internal map keyed by persona name):
800
810
  (or credential) seam where that material is genuinely consulted:
801
811
 
802
812
  ```jsonc
803
- "signInSeam": { "skill": "stack/qa/sign-in" },
813
+ "signInSeam": { "skill": "stack/qa/acme-sso" }, // a skill YOU author (see step 3)
804
814
  "personas": {
805
815
  "admin": { "credentialRef": "QA_ADMIN_CREDENTIAL" }, // stored-credential reference, never an inline secret
806
- "member": { "signInSkill": "stack/qa/sign-in-member" } // or a per-persona sign-in skill
816
+ "member": { "signInSkill": "stack/qa/acme-sso-member" } // or a per-persona sign-in skill
807
817
  }
808
818
  ```
809
819
 
820
+ Both skill ids above are **illustrative names for skills you write** — the
821
+ framework ships no sign-in skill. Step 3 says where they go.
822
+
810
823
  ### 2. Author the fixtures manifest
811
824
 
812
825
  Create the file referenced by `fixturesManifest`. It binds each persona to the
@@ -824,9 +837,33 @@ credentials are never entered. Expose one of two shapes:
824
837
  - **`{ urlTemplate }`** — a dev sign-in route where `{persona}` is substituted
825
838
  (e.g. `/dev/sign-in-as/{persona}` → `/dev/sign-in-as/admin`); gate it to
826
839
  non-production builds. Pair with the name-only `personas` array.
827
- - **`{ skill }`** — when sign-in is multi-step or non-URL, point at a consumer
828
- skill whose `SKILL.md` the harness reads. Pair with the object-map
829
- `personas` form (per step 1).
840
+ - **`{ skill }`** — when sign-in is multi-step or non-URL, name a skill by its
841
+ tier-relative id (e.g. `stack/qa/acme-sso`) whose `SKILL.md` the harness
842
+ reads. Pair with the object-map `personas` form (per step 1).
843
+ - **Omit it entirely** — the honest shape for a target with no sign-in seam at
844
+ all, such as a deployed build whose dev bypass is tree-shaken out. The
845
+ workflows then drive the unauthenticated surface and record the gap rather
846
+ than fabricating a session.
847
+
848
+ **Where a `{ skill }` seam resolves.** The id is looked up under
849
+ `.agents/skills/` (the package payload) and then `.agents/local/skills/` —
850
+ the consumer-writable zone. Author your own sign-in skill in the local zone:
851
+
852
+ ```text
853
+ .agents/local/skills/stack/qa/acme-sso/SKILL.md → id: stack/qa/acme-sso
854
+ ```
855
+
856
+ `.agents/local/` is never copied into by `mandrel sync`, never pruned, and
857
+ never reported as payload drift by `mandrel doctor`, so a skill you write
858
+ there survives every upgrade. It is held to the same bar as a shipped skill —
859
+ `validate-skills.js` checks its frontmatter and Policy Capsule, and
860
+ `generate-skills-index.js` writes it into its own
861
+ `.agents/local/skills/skills.index.json` (never into the shipped manifest,
862
+ which must stay byte-identical to the package payload).
863
+
864
+ A seam naming an id that resolves under neither root is rejected by
865
+ `resolveQaEnvironment` when the contract is resolved — not silently carried
866
+ until a sweep reaches its sign-in step.
830
867
 
831
868
  Once these three `qa.*` keys are in place, `/qa-explore <surface>`, `/qa-assist`,
832
869
  and `/qa-run <selector>` all resolve the contract and operate against the bound
@@ -363,9 +363,6 @@
363
363
  },
364
364
  "staging": {
365
365
  "baseUrl": "https://staging.example.test",
366
- "signInSeam": {
367
- "skill": "stack/qa/sign-in"
368
- },
369
366
  "allowWrites": false
370
367
  }
371
368
  },
@@ -374,7 +371,7 @@
374
371
  "credentialRef": "QA_ADMIN_CREDENTIAL"
375
372
  },
376
373
  "member": {
377
- "signInSkill": "stack/qa/sign-in-member"
374
+ "credentialRef": "QA_MEMBER_CREDENTIAL"
378
375
  }
379
376
  },
380
377
  "gherkinLint": {
@@ -336,8 +336,8 @@ Agent-driven QA harness contract (Epic #3214; environment-keyed by Epic #4326).
336
336
  | --- | --- | --- | --- | --- |
337
337
  | `featureRoot` | No | `string` | `"tests/features"` | Directory holding the Gherkin feature files the QA sweep drives. |
338
338
  | `fixturesManifest` | No | `string` | `"tests/fixtures/personas.json"` | Path to the persona/fixture manifest the harness seeds from. |
339
- | `environments` | No | `object<map>` | `{"local":{"baseUrl":"http://localhost:3000","signInSeam":{"urlTemplate":"/dev/sign-in-as/{persona}"}},"staging":{"baseUrl":"https://staging.example.test","signInSeam":{"skill":"stack/qa/sign-in"},"allowWrites":false}}` | 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`. |
340
- | `personas` | No | one of: `array`, `object` | `{"admin":{"credentialRef":"QA_ADMIN_CREDENTIAL"},"member":{"signInSkill":"stack/qa/sign-in-member"}}` | 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. |
339
+ | `environments` | No | `object<map>` | `{"local":{"baseUrl":"http://localhost:3000","signInSeam":{"urlTemplate":"/dev/sign-in-as/{persona}"}},"staging":{"baseUrl":"https://staging.example.test","allowWrites":false}}` | 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`. |
340
+ | `personas` | No | one of: `array`, `object` | `{"admin":{"credentialRef":"QA_ADMIN_CREDENTIAL"},"member":{"credentialRef":"QA_MEMBER_CREDENTIAL"}}` | 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. |
341
341
  | `gherkinLint` | No | `object` | `{"scopes":{"web":{"featureRoots":["apps/web/tests/features"],"stepRoots":["apps/web/tests/steps"]}},"exemptionTags":["@skip"],"stepWaivers":[]}` | Static Gherkin corpus gate (Story #5013). Optional; the gate runs only when this block is present, so an upgrade never reddens the lint of a consumer that never asked the framework to police its `.feature` files. Inside the opt-in it fails closed: an unresolvable `@cucumber/gherkin` parser, or a scope resolving zero step definitions, exits 1 rather than reporting a clean run. |
342
342
  | `gherkinLint.scopes` | Yes | `object<map>` | — | Binding scopes, keyed by name. Each scope resolves its own features against its own step definitions only — pooling every step root into one matcher list is what makes a cross-app false bind possible, where a step defined solely in app B silently vouches for app A. The scope name appears verbatim in every unbound finding. |
343
343
  | `gherkinLint.exemptionTags` | No | `array<string>` | `["@skip"]` | Tags marking a scenario as intentionally non-binding, so must-bind skips it. Never an escape from must-compile: a parse error in the file still fails the run. Default: ["@skip"]. |
@@ -1921,7 +1921,9 @@
1921
1921
  },
1922
1922
  "advisoryAllowlist": {
1923
1923
  "type": "array",
1924
- "items": { "type": "string" },
1924
+ "items": {
1925
+ "type": "string"
1926
+ },
1925
1927
  "description": "Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks.",
1926
1928
  "default": []
1927
1929
  }
@@ -1985,7 +1987,7 @@
1985
1987
  },
1986
1988
  "environments": {
1987
1989
  "type": "object",
1988
- "description": "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`.",
1990
+ "description": "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`.",
1989
1991
  "default": {
1990
1992
  "local": {
1991
1993
  "baseUrl": "http://localhost:3000",
@@ -1995,9 +1997,6 @@
1995
1997
  },
1996
1998
  "staging": {
1997
1999
  "baseUrl": "https://staging.example.test",
1998
- "signInSeam": {
1999
- "skill": "stack/qa/sign-in"
2000
- },
2001
2000
  "allowWrites": false
2002
2001
  }
2003
2002
  },
@@ -2048,7 +2047,7 @@
2048
2047
  "type": "boolean"
2049
2048
  }
2050
2049
  },
2051
- "required": ["baseUrl", "signInSeam"],
2050
+ "required": ["baseUrl"],
2052
2051
  "additionalProperties": false
2053
2052
  }
2054
2053
  },
@@ -2059,7 +2058,7 @@
2059
2058
  "credentialRef": "QA_ADMIN_CREDENTIAL"
2060
2059
  },
2061
2060
  "member": {
2062
- "signInSkill": "stack/qa/sign-in-member"
2061
+ "credentialRef": "QA_MEMBER_CREDENTIAL"
2063
2062
  }
2064
2063
  },
2065
2064
  "oneOf": [
@@ -8,6 +8,15 @@
8
8
  // generator output (ignoring the volatile `generatedAt` field) and exits
9
9
  // non-zero with a diff-style message if they diverge.
10
10
  //
11
+ // Two indexes, never one (Story #5135). The shipped manifest above is a
12
+ // committed payload file that `mandrel doctor` / `mandrel sync-agents`
13
+ // compare byte-for-byte against the installed package, so consumer-authored
14
+ // skills under the `.agents/local/skills/` zone MUST NOT be folded into it —
15
+ // a merged index would read as payload drift in every consumer that authored
16
+ // a skill, and those commands would refuse. Local skills are therefore
17
+ // indexed into their own `.agents/local/skills/skills.index.json`, inside
18
+ // the zone sync never prunes and drift never walks.
19
+ //
11
20
  // CLI surface:
12
21
  //
13
22
  // node generate-skills-index.js [--check] [--root <dir>] [--out <file>]
@@ -38,7 +47,18 @@ import { runAsCli } from './lib/cli-utils.js';
38
47
  import { formatGeneratedJson } from './lib/format-generated-json.js';
39
48
  import { Logger } from './lib/Logger.js';
40
49
  import { parseSkill } from './lib/skills/parse-skill.js';
41
- import { collectSkillFiles } from './lib/skills/walk-skill-files.js';
50
+ import {
51
+ diffManifests,
52
+ INDEX_FILENAME,
53
+ indexPathFor,
54
+ readManifest,
55
+ } from './lib/skills/skills-index.js';
56
+ import {
57
+ collectLocalSkillFiles,
58
+ collectSkillFiles,
59
+ LOCAL_SKILLS_SEGMENTS,
60
+ PAYLOAD_SKILLS_SEGMENTS,
61
+ } from './lib/skills/walk-skill-files.js';
42
62
 
43
63
  const GENERATOR_ID = 'generate-skills-index.js@1';
44
64
 
@@ -103,8 +123,8 @@ function projectEntry(parsed) {
103
123
  * Build the manifest object (without `generatedAt`) by walking the tree
104
124
  * and projecting each parsed SKILL.md into an index entry.
105
125
  */
106
- export function buildManifestBody(repoRoot) {
107
- const skillFiles = collectSkillFiles(repoRoot);
126
+ export function buildManifestBody(repoRoot, collect = collectSkillFiles) {
127
+ const skillFiles = collect(repoRoot);
108
128
  const skills = skillFiles.map((absPath) =>
109
129
  projectEntry(parseSkill(absPath, { repoRoot })),
110
130
  );
@@ -118,8 +138,8 @@ export function buildManifestBody(repoRoot) {
118
138
  * Build the full manifest with `generatedAt`. `nowIso` is injected so
119
139
  * tests can pin the timestamp deterministically.
120
140
  */
121
- export function buildManifest(repoRoot, { nowIso } = {}) {
122
- const body = buildManifestBody(repoRoot);
141
+ export function buildManifest(repoRoot, { nowIso, collect } = {}) {
142
+ const body = buildManifestBody(repoRoot, collect);
123
143
  return {
124
144
  generatedAt: nowIso ?? new Date().toISOString(),
125
145
  generator: body.generator,
@@ -143,66 +163,147 @@ export function serializeManifest(manifest) {
143
163
  }
144
164
 
145
165
  /**
146
- * Read the on-disk manifest as a parsed object, or null when missing /
147
- * unparseable. The --check pipeline distinguishes "missing" (drift) from
148
- * "unparseable" (drift) via the returned `reason` channel.
166
+ * Resolve the manifest output path given (root, optional explicit
167
+ * override).
168
+ */
169
+ function resolveOutPath(root, override) {
170
+ return override
171
+ ? path.resolve(override)
172
+ : indexPathFor(root, PAYLOAD_SKILLS_SEGMENTS);
173
+ }
174
+
175
+ /**
176
+ * Resolve the local-zone manifest path. Deliberately NOT overridable by
177
+ * `--out`: that flag redirects the payload manifest (tests stage fixture
178
+ * trees with it), and letting it also move the local manifest would let one
179
+ * invocation write both indexes to the same file.
180
+ */
181
+ function resolveLocalOutPath(root) {
182
+ return indexPathFor(root, LOCAL_SKILLS_SEGMENTS);
183
+ }
184
+
185
+ /**
186
+ * Write one manifest through the project formatter so a regeneration on a
187
+ * clean tree leaves no format drift behind.
149
188
  */
150
- function readOnDiskManifest(outPath) {
151
- if (!fs.existsSync(outPath)) {
152
- return { manifest: null, reason: 'missing' };
189
+ function writeManifest(manifest, outPath, root) {
190
+ const serialized = serializeManifest(manifest);
191
+ const opts = { cwd: root, filename: INDEX_FILENAME };
192
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
193
+ fs.writeFileSync(
194
+ outPath,
195
+ formatGeneratedJson(serialized, opts) ?? serialized,
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Write (or reap) the local-zone manifest. A consumer who deletes their last
201
+ * local skill would otherwise be left with a stale index reporting skills
202
+ * that no longer exist, so an emptied zone removes the artifact rather than
203
+ * leaving it behind.
204
+ */
205
+ function writeLocalManifest(localFresh, localOutPath, root) {
206
+ const rel = path.relative(root, localOutPath).split(path.sep).join('/');
207
+ if (localFresh === null) {
208
+ if (fs.existsSync(localOutPath)) {
209
+ fs.rmSync(localOutPath);
210
+ Logger.info(`removed ${rel} (no local skills remain)`);
211
+ }
212
+ return;
153
213
  }
154
- let src;
155
- try {
156
- src = fs.readFileSync(outPath, 'utf8');
157
- } catch (err) {
158
- return { manifest: null, reason: `read-error: ${err.message}` };
214
+ writeManifest(localFresh, localOutPath, root);
215
+ Logger.info(`wrote ${rel} (${localFresh.skills.length} entries)`);
216
+ }
217
+
218
+ /**
219
+ * Compare the local-zone manifest against fresh generator output. Returns
220
+ * null when in sync (including the common case of no local skills and no
221
+ * artifact), or a diff-style message.
222
+ */
223
+ function checkLocalManifest(localFresh, localOutPath) {
224
+ const exists = fs.existsSync(localOutPath);
225
+ if (localFresh === null) {
226
+ return exists
227
+ ? 'local skills.index.json drift detected: the local skills zone is ' +
228
+ 'empty but .agents/local/skills/skills.index.json still exists — ' +
229
+ "run 'node .agents/scripts/generate-skills-index.js' to reap it"
230
+ : null;
159
231
  }
160
- try {
161
- return { manifest: JSON.parse(src), reason: null };
162
- } catch (err) {
163
- return { manifest: null, reason: `parse-error: ${err.message}` };
232
+ if (!exists) {
233
+ return (
234
+ 'local skills.index.json drift detected: missing — run ' +
235
+ "'node .agents/scripts/generate-skills-index.js' to write it"
236
+ );
164
237
  }
238
+ const { manifest: disk } = readManifest(localOutPath);
239
+ return diffManifests(disk, localFresh, 'local skills.index.json');
165
240
  }
166
241
 
167
242
  /**
168
- * Compare two manifests ignoring `generatedAt`. Returns null when they
169
- * match, or a short diff-style message when they diverge.
243
+ * Build the local zone's manifest plan for this invocation: its output path,
244
+ * and a fresh manifest when the consumer has authored any local skill (null
245
+ * otherwise, which is the signal to reap a stale artifact).
246
+ *
247
+ * Split out of `run` so the payload path and the local path each read as one
248
+ * step there rather than interleaving.
249
+ *
250
+ * @param {string} root
251
+ * @param {Date} now
252
+ * @returns {{ localFresh: object | null, localOutPath: string }}
170
253
  */
171
- function diffManifestsIgnoringTimestamp(diskManifest, freshManifest) {
172
- if (diskManifest === null) {
173
- return 'on-disk manifest is missing or unreadable';
174
- }
175
- const a = { ...diskManifest };
176
- const b = { ...freshManifest };
177
- delete a.generatedAt;
178
- delete b.generatedAt;
179
- const sa = JSON.stringify(a);
180
- const sb = JSON.stringify(b);
181
- if (sa === sb) return null;
182
- // Surface a structural summary rather than a full JSON dump.
183
- const diskCount = Array.isArray(diskManifest.skills)
184
- ? diskManifest.skills.length
185
- : 'n/a';
186
- const freshCount = Array.isArray(freshManifest.skills)
187
- ? freshManifest.skills.length
188
- : 'n/a';
189
- const summary = [
190
- 'skills.index.json drift detected:',
191
- ` on-disk entries: ${diskCount}`,
192
- ` generated entries: ${freshCount}`,
193
- " run 'node .agents/scripts/generate-skills-index.js' to refresh",
194
- ].join('\n');
195
- return summary;
254
+ function buildLocalPlan(root, now) {
255
+ const localOutPath = resolveLocalOutPath(root);
256
+ const localFresh =
257
+ collectLocalSkillFiles(root).length > 0
258
+ ? buildManifest(root, {
259
+ nowIso: now.toISOString(),
260
+ collect: collectLocalSkillFiles,
261
+ })
262
+ : null;
263
+ return { localFresh, localOutPath };
196
264
  }
197
265
 
198
266
  /**
199
- * Resolve the manifest output path given (root, optional explicit
200
- * override).
267
+ * Render the freshness line's entry counts, naming the local zone only when
268
+ * one exists.
269
+ *
270
+ * @param {object} fresh
271
+ * @param {object | null} localFresh
272
+ * @returns {string}
201
273
  */
202
- function resolveOutPath(root, override) {
203
- return override
204
- ? path.resolve(override)
205
- : path.join(root, '.agents', 'skills', 'skills.index.json');
274
+ function describeCounts(fresh, localFresh) {
275
+ const base = `${fresh.skills.length} entries`;
276
+ return localFresh === null
277
+ ? base
278
+ : `${base}, ${localFresh.skills.length} local`;
279
+ }
280
+
281
+ /**
282
+ * `--check` mode: compare both manifests against fresh generator output and
283
+ * report the first drift found, payload first.
284
+ *
285
+ * Lives outside `run` so the entry point reads as "resolve inputs, then check
286
+ * or write" — and so the check path's branches are not charged to a function
287
+ * that also owns argument resolution.
288
+ *
289
+ * @param {{ outPath: string, fresh: object, localOutPath: string, localFresh: object | null }} plan
290
+ * @returns {{ status: number, output: string }}
291
+ */
292
+ function checkBothManifests({ outPath, fresh, localOutPath, localFresh }) {
293
+ const { manifest: disk, reason } = readManifest(outPath);
294
+ if (disk === null) {
295
+ return { status: 1, output: `${INDEX_FILENAME} drift detected: ${reason}` };
296
+ }
297
+ const drift =
298
+ diffManifests(disk, fresh, INDEX_FILENAME) ??
299
+ checkLocalManifest(localFresh, localOutPath);
300
+ if (drift !== null) {
301
+ return { status: 1, output: drift };
302
+ }
303
+ Logger.info(
304
+ `${INDEX_FILENAME} is fresh (${describeCounts(fresh, localFresh)})`,
305
+ );
306
+ return { status: 0, output: '' };
206
307
  }
207
308
 
208
309
  /**
@@ -225,35 +326,17 @@ export function run({ argv = [], now = new Date(), repoRoot } = {}) {
225
326
  : (repoRoot ?? defaultRepoRoot());
226
327
  const outPath = resolveOutPath(root, parsed.out);
227
328
  const fresh = buildManifest(root, { nowIso: now.toISOString() });
329
+ const { localFresh, localOutPath } = buildLocalPlan(root, now);
228
330
 
229
331
  if (parsed.check) {
230
- const { manifest: disk, reason } = readOnDiskManifest(outPath);
231
- if (disk === null) {
232
- return {
233
- status: 1,
234
- output: `skills.index.json drift detected: ${reason}`,
235
- };
236
- }
237
- const diff = diffManifestsIgnoringTimestamp(disk, fresh);
238
- if (diff === null) {
239
- Logger.info(
240
- `skills.index.json is fresh (${fresh.skills.length} entries)`,
241
- );
242
- return { status: 0, output: '' };
243
- }
244
- return { status: 1, output: diff };
332
+ return checkBothManifests({ outPath, fresh, localOutPath, localFresh });
245
333
  }
246
334
 
247
- const serialized = serializeManifest(fresh);
248
- const opts = { cwd: root, filename: 'skills.index.json' };
249
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
250
- fs.writeFileSync(
251
- outPath,
252
- formatGeneratedJson(serialized, opts) ?? serialized,
253
- );
335
+ writeManifest(fresh, outPath, root);
254
336
  Logger.info(
255
337
  `wrote ${path.relative(root, outPath).split(path.sep).join('/')} (${fresh.skills.length} entries)`,
256
338
  );
339
+ writeLocalManifest(localFresh, localOutPath, root);
257
340
  return { status: 0, output: '' };
258
341
  }
259
342
 
@@ -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 {