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 +45 -8
- package/.agents/docs/agentrc-reference.json +1 -4
- package/.agents/docs/configuration.md +2 -2
- package/.agents/schemas/agentrc.schema.json +6 -7
- package/.agents/scripts/generate-skills-index.js +158 -75
- package/.agents/scripts/lib/changed-files.js +100 -9
- package/.agents/scripts/lib/config-settings-schema.js +25 -7
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +58 -6
- package/.agents/scripts/lib/skills/skills-index.js +168 -0
- package/.agents/scripts/lib/skills/walk-skill-files.js +133 -9
- package/.agents/scripts/quality-preview.js +50 -9
- package/.agents/scripts/validate-skills.js +53 -66
- package/.agents/workflows/qa-run.md +13 -5
- package/docs/CHANGELOG.md +12 -0
- package/package.json +1 -1
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
|
-
"
|
|
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`, `
|
|
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/
|
|
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/
|
|
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,
|
|
828
|
-
|
|
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
|
-
"
|
|
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","
|
|
340
|
-
| `personas` | No | one of: `array`, `object` | `{"admin":{"credentialRef":"QA_ADMIN_CREDENTIAL"},"member":{"
|
|
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": {
|
|
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`,
|
|
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"
|
|
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
|
-
"
|
|
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 {
|
|
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 =
|
|
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
|
-
*
|
|
147
|
-
*
|
|
148
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
return
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
*
|
|
169
|
-
*
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
*
|
|
200
|
-
*
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
176
|
-
|
|
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 {
|