cyber-sdd 0.2.0 → 0.3.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.
@@ -2,9 +2,11 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "sdd",
4
4
  "description": "Spec-Driven Development. Scaffold, validate, and maintain behavioral specs (spec.md + .feature files) for software features.",
5
- "author": { "name": "unional" },
6
- "homepage": "https://github.com/cyberuni/cyberplace",
7
- "repository": "https://github.com/cyberuni/cyberplace",
5
+ "author": {
6
+ "name": "unional"
7
+ },
8
+ "homepage": "https://github.com/cyberuni/cyber-sdd",
9
+ "repository": "https://github.com/cyberuni/cyber-sdd",
8
10
  "version": "0.0.0",
9
11
  "skills": "./skills",
10
12
  "agents": [
@@ -2,9 +2,11 @@
2
2
  "$schema": "https://raw.githubusercontent.com/cyberuni/marketplace/main/schema/claude/plugin.json",
3
3
  "name": "sdd",
4
4
  "description": "Spec-Driven Development. Scaffold, validate, and maintain behavioral specs (spec.md + .feature files) for software features.",
5
- "author": { "name": "unional" },
6
- "homepage": "https://github.com/cyberuni/cyberplace",
7
- "repository": "https://github.com/cyberuni/cyberplace",
5
+ "author": {
6
+ "name": "unional"
7
+ },
8
+ "homepage": "https://github.com/cyberuni/cyber-sdd",
9
+ "repository": "https://github.com/cyberuni/cyber-sdd",
8
10
  "version": "0.0.0",
9
11
  "skills": "./skills",
10
12
  "agents": [
package/.plugin/pins.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "cyberlegion": "0.3.0"
2
+ "cyberlegion": "0.3.1"
3
3
  }
@@ -2,9 +2,11 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "sdd",
4
4
  "description": "Spec-Driven Development. Scaffold, validate, and maintain behavioral specs (spec.md + .feature files) for software features.",
5
- "author": { "name": "unional" },
6
- "homepage": "https://github.com/cyberuni/cyberplace",
7
- "repository": "https://github.com/cyberuni/cyberplace",
5
+ "author": {
6
+ "name": "unional"
7
+ },
8
+ "homepage": "https://github.com/cyberuni/cyber-sdd",
9
+ "repository": "https://github.com/cyberuni/cyber-sdd",
8
10
  "version": "0.0.0",
9
11
  "skills": "./skills",
10
12
  "agents": [
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "cyber-sdd",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Spec-Driven Development. Scaffold, validate, and maintain behavioral specs (spec.md + .feature files) for software features.",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "git+https://github.com/cyberuni/cyberplace.git",
7
+ "url": "git+https://github.com/cyberuni/cyber-sdd.git",
8
8
  "directory": "plugins/sdd"
9
9
  },
10
10
  "license": "MIT",
@@ -23,8 +23,9 @@
23
23
  "dependencies": {
24
24
  "gherkin-cli": "0.0.2"
25
25
  },
26
+ "homepage": "https://github.com/cyberuni/cyber-sdd",
26
27
  "scripts": {
27
- "check:spec": "sdd-check-specs",
28
+ "check:spec": "node ./skills/check-project-specs/scripts/check-project-specs.mts",
28
29
  "test": "node --test \"skills/*/scripts/*.test.mts\"",
29
30
  "typecheck": "tsc -p tsconfig.json"
30
31
  }
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: check-plugin-manifests
3
+ description: "Partial Skill: invoke by name only — plugin/check-plugin-manifests' guard engine against a manifest declaring a component the package does not ship — the CI guard, not triggered by users directly."
4
+ user-invocable: false
5
+ metadata:
6
+ internal: true
7
+ ---
8
+
9
+ # Check Plugin Manifests
10
+
11
+ The concrete engine for the rule that **a plugin manifest declares only components the package
12
+ ships**. A pointer naming something the package does not carry is valid JSON, satisfies the schema,
13
+ and is copied into every generated vendor manifest — it fails only on an installer's machine, after
14
+ publish, as a component the host runtime cannot load. Nothing else in the repo compares a manifest's
15
+ pointers against what ships.
16
+
17
+ Spec: [`.agents/specs/sdd/plugin/check-plugin-manifests/`](../../../../.agents/specs/sdd/plugin/check-plugin-manifests/README.md).
18
+
19
+ ## What it checks
20
+
21
+ Two sub-checks, neither subsuming the other:
22
+
23
+ - **the disk check** — does the pointer resolve to a path that exists?
24
+ - **the publish check** — for a package that publishes, is the pointer inside its `files` allowlist?
25
+
26
+ A directory can exist and be excluded from the tarball; a `files` entry can name a directory nobody
27
+ created. The disk check **short-circuits**: a pointer dead on disk is reported once, as unresolved,
28
+ and is not also asked about `files`.
29
+
30
+ **A pointer is any `./`-prefixed string value**, at any depth, under any key — not a fixed key list.
31
+ The manifest format grows new component keys, and a guard hardcoding today's set fails open on the
32
+ next one added.
33
+
34
+ **A pointer resolves against the plugin root, not the manifest's own directory.** A vendor manifest
35
+ sits one level down; a guard resolving relative to the file it just read reports every vendor
36
+ manifest as entirely broken.
37
+
38
+ **A package that declares no `files` at all ships everything**, so there is no allowlist to be
39
+ outside of. Reading an absent allowlist as an empty one inverts npm's semantic and manufactures a
40
+ finding against every pointer in the package.
41
+
42
+ ## Run it
43
+
44
+ ```bash
45
+ node "<skill>/scripts/check-plugin-manifests.mts" [--root <dir>]
46
+ ```
47
+
48
+ `--root` defaults to the working directory. A finding always exits non-zero — there is deliberately
49
+ no report-only mode, because a chain that forgets the strict flag reports green over a defect, and
50
+ green is what the repo treats as clearance to commit. An unrecognized flag is an error, never an
51
+ input to ignore.
52
+
53
+ It joins the root check chain as **`check:plugins`**, so it runs on every `pnpm verify` and in CI.
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ // check-plugin-manifests — a plugin manifest must declare only components the package ships.
3
+ //
4
+ // A manifest pointer naming something the package does not carry is valid JSON, satisfies the
5
+ // schema, and is copied into every generated vendor manifest. It fails only on an installer's
6
+ // machine, after publish, as a component the host runtime cannot load. Nothing else in the repo
7
+ // compares a manifest's pointers against what ships, so this engine does.
8
+ //
9
+ // Two sub-checks, neither subsuming the other (spec: .agents/specs/sdd/plugin/check-plugin-manifests/):
10
+ // disk — does the pointer resolve to a path that exists?
11
+ // publish — for a package that publishes, is the pointer inside its `files` allowlist?
12
+ // A directory can exist and be excluded from the tarball; a `files` entry can name a directory
13
+ // nobody created. The disk check short-circuits: a pointer dead on disk is reported once, as
14
+ // unresolved, and is not also asked about `files`.
15
+ //
16
+ // A pointer is any `./`-prefixed string value, at any depth, under any key — NOT a fixed key list.
17
+ // The manifest format grows new component keys, and a guard hardcoding today's set fails open on
18
+ // the next one added.
19
+ //
20
+ // Pure functions are exported for node:test; running the file directly drives the CLI. No
21
+ // dependencies (the repo's node-≥23.6 / no-deps convention).
22
+
23
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
24
+ import { dirname, join, relative, sep } from 'node:path'
25
+ import { pathToFileURL } from 'node:url'
26
+
27
+ /**
28
+ * Directories that hold a manifest one level below the plugin root.
29
+ *
30
+ * This is a path-shape list, not a provenance one: `.plugin/` holds the **canonical** manifest and
31
+ * the other two hold its generated vendor copies, but all three sit one level down, so a pointer
32
+ * inside any of them resolves against the parent. Naming it for vendors would contradict the
33
+ * spec's own vocabulary and has already misled one reader.
34
+ */
35
+ const MANIFEST_DIRS = ['.plugin', '.claude-plugin', '.codex-plugin']
36
+ const MANIFEST_NAME = 'plugin.json'
37
+ const SKIP_DIRS = new Set(['node_modules', '.git'])
38
+
39
+ export type FindingKind = 'unreadable' | 'unresolved' | 'unpublished'
40
+
41
+ export interface Finding {
42
+ kind: FindingKind
43
+ /** Repo-relative path of the manifest carrying the defect. */
44
+ manifest: string
45
+ /** The manifest key the pointer sits under. Absent for an unreadable manifest. */
46
+ key?: string
47
+ /** The pointer value. Absent for an unreadable manifest. */
48
+ pointer?: string
49
+ detail: string
50
+ }
51
+
52
+ // ── Discovery ──
53
+
54
+ /**
55
+ * Every manifest at a conventional location under `root`, repo-relative and path-sorted.
56
+ *
57
+ * A manifest is a `plugin.json` either inside a vendor directory, or sitting beside a
58
+ * `package.json` (a package-root manifest). Sorted so a sweep's report order is deterministic.
59
+ * A symlink is included: it resolves to a real manifest, and a broken one must surface as
60
+ * unreadable rather than vanish from the sweep.
61
+ */
62
+ /** Directory entries, or none where the directory cannot be read. */
63
+ function safeReaddir(dir: string) {
64
+ try {
65
+ return readdirSync(dir, { withFileTypes: true })
66
+ } catch {
67
+ return []
68
+ }
69
+ }
70
+
71
+ export function discoverManifests(root: string): string[] {
72
+ const found: string[] = []
73
+ const walk = (dir: string): void => {
74
+ for (const e of safeReaddir(dir)) {
75
+ const full = join(dir, e.name)
76
+ if (e.isDirectory()) {
77
+ if (!SKIP_DIRS.has(e.name)) walk(full)
78
+ continue
79
+ }
80
+ if (e.name !== MANIFEST_NAME) continue
81
+ const parent = dirname(full)
82
+ const inVendorDir = MANIFEST_DIRS.includes(parent.split(sep).pop() ?? '')
83
+ if (inVendorDir || existsSync(join(parent, 'package.json'))) found.push(full)
84
+ }
85
+ }
86
+ walk(root)
87
+ return found.map((f) => relative(root, f).split(sep).join('/')).sort()
88
+ }
89
+
90
+ /**
91
+ * The plugin root a manifest's pointers resolve against — NOT the manifest's own directory.
92
+ *
93
+ * A vendor manifest sits one level down, and its `./skills` still names the plugin's skills
94
+ * directory. A guard resolving relative to the file it just read reports every vendor manifest as
95
+ * entirely broken.
96
+ */
97
+ export function pluginRootOf(manifestRelPath: string): string {
98
+ const parts = manifestRelPath.split('/')
99
+ const parentName = parts[parts.length - 2]
100
+ const drop = parentName !== undefined && MANIFEST_DIRS.includes(parentName) ? 2 : 1
101
+ return parts.slice(0, parts.length - drop).join('/')
102
+ }
103
+
104
+ // ── Pointer extraction ──
105
+
106
+ /** Every `./`-prefixed string value in the manifest, paired with the key it sits under. */
107
+ export function extractPointers(manifest: unknown): Array<{ key: string; pointer: string }> {
108
+ const out: Array<{ key: string; pointer: string }> = []
109
+ const visit = (value: unknown, key: string): void => {
110
+ if (typeof value === 'string') {
111
+ if (value.startsWith('./')) out.push({ key, pointer: value })
112
+ return
113
+ }
114
+ if (Array.isArray(value)) {
115
+ for (const item of value) visit(item, key)
116
+ return
117
+ }
118
+ if (value !== null && typeof value === 'object') {
119
+ for (const [k, v] of Object.entries(value)) visit(v, k)
120
+ }
121
+ }
122
+ visit(manifest, '')
123
+ return out
124
+ }
125
+
126
+ // ── The publish sub-check ──
127
+
128
+ export interface OwningPackage {
129
+ path: string
130
+ publishes: boolean
131
+ files: string[]
132
+ }
133
+
134
+ /**
135
+ * The nearest `package.json` at or above the plugin root, and whether it publishes.
136
+ *
137
+ * A package publishes when it is not `private` AND declares a `files` allowlist. A `private`
138
+ * package ships no tarball, so its `files` constrains nothing. A package declaring no `files` at
139
+ * all ships everything, so there is no allowlist to be outside of — npm's own semantic, and
140
+ * inverting it manufactures a finding against every pointer in the package.
141
+ */
142
+ export function owningPackage(root: string, pluginRoot: string): OwningPackage | null {
143
+ let dir = pluginRoot
144
+ for (;;) {
145
+ const candidate = dir === '' ? 'package.json' : `${dir}/package.json`
146
+ const abs = join(root, candidate)
147
+ if (existsSync(abs)) {
148
+ try {
149
+ const pkg = JSON.parse(readFileSync(abs, 'utf8')) as { private?: unknown; files?: unknown }
150
+ const files = Array.isArray(pkg.files) ? pkg.files.filter((f): f is string => typeof f === 'string') : null
151
+ return { path: candidate, publishes: pkg.private !== true && files !== null, files: files ?? [] }
152
+ } catch {
153
+ return { path: candidate, publishes: false, files: [] }
154
+ }
155
+ }
156
+ if (dir === '') return null
157
+ const up = dir.split('/').slice(0, -1).join('/')
158
+ dir = up
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Whether a `files` allowlist covers a pointer, compared on the leading path segment.
164
+ *
165
+ * Both sides are normalized past a leading `./`, because an allowlist may legitimately spell an
166
+ * entry either way and a pointer always carries the prefix — comparing them raw reports a shipped
167
+ * component as unpublished.
168
+ *
169
+ * A `!`-prefixed entry is an exclusion and needs no special case: its leading `!` is part of its
170
+ * first segment, so it can never equal a real segment and is never read as an inclusion.
171
+ */
172
+ export function filesCovers(files: string[], pointer: string): boolean {
173
+ const head = (path: string): string | undefined => path.replace(/^\.\//, '').split('/')[0]
174
+ const segment = head(pointer)
175
+ if (segment === undefined || segment === '') return true
176
+ return files.some((entry) => {
177
+ const h = head(entry)
178
+ // A wildcard head ships everything below it, so it covers any segment.
179
+ return h === '*' || h === '**' || h === segment
180
+ })
181
+ }
182
+
183
+ // ── The sweep ──
184
+
185
+ export function sweep(root: string, manifests: string[]): Finding[] {
186
+ const findings: Finding[] = []
187
+ for (const rel of manifests) {
188
+ let parsed: unknown
189
+ try {
190
+ parsed = JSON.parse(readFileSync(join(root, rel), 'utf8'))
191
+ } catch {
192
+ findings.push({ kind: 'unreadable', manifest: rel, detail: 'not readable as JSON' })
193
+ continue
194
+ }
195
+ const pluginRoot = pluginRootOf(rel)
196
+ const pkg = owningPackage(root, pluginRoot)
197
+ for (const { key, pointer } of extractPointers(parsed)) {
198
+ const target = join(root, pluginRoot, pointer)
199
+ if (!existsSync(target)) {
200
+ findings.push({ kind: 'unresolved', manifest: rel, key, pointer, detail: 'names no path on disk' })
201
+ continue
202
+ }
203
+ if (pkg?.publishes && !filesCovers(pkg.files, pointer)) {
204
+ findings.push({
205
+ kind: 'unpublished',
206
+ manifest: rel,
207
+ key,
208
+ pointer,
209
+ detail: `declared but not published — ${pkg.path} files omits it`,
210
+ })
211
+ }
212
+ }
213
+ }
214
+ return findings
215
+ }
216
+
217
+ // ── Report ──
218
+
219
+ export function formatReport(manifests: string[], findings: Finding[]): string {
220
+ if (manifests.length === 0) return 'check-plugin-manifests: no plugin manifest found\n'
221
+ if (findings.length === 0) {
222
+ return `check-plugin-manifests: ${manifests.length} manifest(s) OK\n`
223
+ }
224
+ const rows = findings
225
+ .map((f) => ` ${f.kind.padEnd(11)} ${f.manifest}${f.key ? ` [${f.key}] ${f.pointer}` : ''} — ${f.detail}`)
226
+ .join('\n')
227
+ return `${rows}\ncheck-plugin-manifests: ${findings.length} finding(s) across ${manifests.length} manifest(s)\n`
228
+ }
229
+
230
+ // ── CLI ──
231
+
232
+ export function main(argv: string[]): number {
233
+ let root = '.'
234
+ for (let i = 0; i < argv.length; i++) {
235
+ const a = argv[i]
236
+ if (a === '--root') {
237
+ const next = argv[++i]
238
+ if (next === undefined) {
239
+ process.stderr.write('check-plugin-manifests: --root needs a directory\n')
240
+ return 1
241
+ }
242
+ root = next
243
+ continue
244
+ }
245
+ process.stderr.write(`check-plugin-manifests: unrecognized flag ${a}\n`)
246
+ return 1
247
+ }
248
+ const manifests = discoverManifests(root)
249
+ const findings = sweep(root, manifests)
250
+ process.stdout.write(formatReport(manifests, findings))
251
+ return findings.length === 0 ? 0 : 1
252
+ }
253
+
254
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
255
+ process.exit(main(process.argv.slice(2)))
256
+ }
@@ -1,14 +1,20 @@
1
1
  # check-project-specs
2
2
 
3
- Runs every project-spec check against the one spec governing the invoking package.
3
+ Runs every project-spec check — over the whole corpus, or over one project's spec.
4
4
 
5
5
  ```bash
6
- node scripts/check-project-specs.mts # resolve from cwd
7
- node scripts/check-project-specs.mts --project <dir> # resolve an explicit project dir
6
+ node scripts/check-project-specs.mts --corpus # every project-spec + the coverage guard
7
+ node scripts/check-project-specs.mts # one project, resolved from cwd
8
+ node scripts/check-project-specs.mts --project <dir> # one project, explicit dir
8
9
  ```
9
10
 
10
- Wired as each project's `check:spec` script through the `sdd-check-specs` bin, so every project —
11
- `plugins/*` and `packages/*` alike — runs the identical, path-free command.
11
+ The **corpus** scope is the repo's commit floor, wired as the root `check:specs`; it is total by
12
+ construction — the engine sweep and the coverage guard together, because neither alone sees a spec
13
+ the other misses. The **project** scope is each project's `check:spec` script through the
14
+ `sdd-check-specs` bin, so every project — `plugins/*` and `packages/*` alike — runs the identical,
15
+ path-free command.
16
+
17
+ A flag the harness does not define is an error, never a default scope.
12
18
 
13
19
  Resolution is spec-first: the spec's own `project-path` names the project dir, and
14
20
  `check-project-specs` inverts that map. The reverse map cannot be derived by name
@@ -16,4 +22,5 @@ Resolution is spec-first: the spec's own `project-path` names the project dir, a
16
22
 
17
23
  A project no spec governs prints a skip and exits zero. Two specs claiming one project is an error.
18
24
 
19
- See `SKILL.md` for the engine set and the cwd contract.
25
+ See `SKILL.md` for the engine set and the cwd contract, and
26
+ `.agents/specs/sdd/corpus/spec-floor/` for the spec.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: check-project-specs
3
- description: "Partial Skill: invoke by name only — project-spec/check-project-specs' engine that runs every project-spec check against the one spec governing the invoking package — the per-project CI entrypoint, not triggered by users directly."
3
+ description: "Partial Skill: invoke by name only — corpus/spec-floor's engine that runs every project-spec check, over the whole corpus or over one project — the commit-floor and CI entrypoint, not triggered by users directly."
4
4
  user-invocable: false
5
5
  metadata:
6
6
  internal: true
@@ -8,10 +8,27 @@ metadata:
8
8
 
9
9
  # Check Project Specs
10
10
 
11
- The **per-project entrypoint** for the project-spec checks. It resolves the one spec that governs
12
- the invoking package and runs each project-spec engine against it, so a project's spec checks are a
13
- task **the project owns** rather than a path some root script hardcodes. It carries a self-contained
14
- `.mts` script (the repo's node-≥23.6 / no-deps convention).
11
+ The **entrypoint** for the project-spec checks — the harness behind `corpus/spec-floor`. It resolves
12
+ which spec governs which project and runs each project-spec engine against it. It carries a
13
+ self-contained `.mts` script (the repo's node-≥23.6 / no-deps convention).
14
+
15
+ Two scopes:
16
+
17
+ | Scope | Flag | Who runs it |
18
+ |---|---|---|
19
+ | **corpus** — every project-spec, plus the coverage guard | `--corpus` | the repo's `check:specs` chain, so every commit and every CI run |
20
+ | **project** — one project-spec | `--project <dir>`, or no flag (the cwd) | a maintainer inside one project; each project's own `check:spec` |
21
+
22
+ **The corpus scope is total, and that is the point.** It runs *both* the engine sweep and the
23
+ coverage guard, because neither subsumes the other: the sweep only visits the specs discovery
24
+ **recognizes**, so a spec whose lifecycle `status` is a typo is invisible to it and the sweep would
25
+ report clean; the coverage guard sees that file on disk and escalates it, but says nothing about
26
+ whether the engines pass. Run either alone and a whole project-spec leaves the floor silently.
27
+
28
+ **An unrecognized flag is an error.** The scope is chosen by a flag, so a flag that falls through to
29
+ a default silently downgrades the run — and project scope, which is what a fall-through reaches,
30
+ resolves no governing spec at a repo root and exits **0**. That is exactly how a coverage-only flag
31
+ guarded this repo's commits and CI while running no engine at all (issue #5).
15
32
 
16
33
  ## Resolution — spec-first, never by name
17
34
 
@@ -31,13 +48,26 @@ mapping and no path is ever written into a package's scripts.
31
48
  ## Run it
32
49
 
33
50
  ```bash
34
- node "<skill>/scripts/check-project-specs.mts" [--project <dir>]
51
+ node "<skill>/scripts/check-project-specs.mts" --corpus # the whole corpus — the commit floor
52
+ node "<skill>/scripts/check-project-specs.mts" [--project <dir>] # one project-spec
35
53
  ```
36
54
 
37
- Wired as each project's `check:spec` script, via the `sdd-check-specs` bin.
55
+ The corpus scope is wired as the repo's root `check:specs`; the project scope is wired as each
56
+ project's `check:spec` script, via the `sdd-check-specs` bin. `--corpus` and `--project` name
57
+ contradictory scopes and are refused together.
38
58
 
39
59
  ## Outcomes
40
60
 
61
+ **Corpus scope**
62
+
63
+ - **Clean** — every recognized project-spec passed every engine and every spec file is covered; exits zero.
64
+ - **A coverage gap** — reports each gap with its reason, **then sweeps anyway**, and exits non-zero.
65
+ - **A failing engine** — names the engine and the project-spec, **continues to the next**, exits non-zero.
66
+ - **An empty corpus** — reports that plainly and exits zero. A repo with no project-spec is not a defect.
67
+ - **No repo root** — names the missing workspace marker and exits non-zero; it never falls back to the cwd.
68
+
69
+ **Project scope**
70
+
41
71
  - **Resolved** — runs every engine against the spec dir, reports `ok` / `FAIL` per engine, and exits
42
72
  non-zero if any failed.
43
73
  - **No spec governs this project** — prints that and exits **zero**. The script is uniform across
@@ -48,13 +78,14 @@ Wired as each project's `check:spec` script, via the `sdd-check-specs` bin.
48
78
  ## The engines it runs
49
79
 
50
80
  `check-spec-state` and `check-suite` (each `--root <specDir>`), then `concept-index`,
51
- `check-spec-structure`, and `align-spec` (each `--spec-dir <specDir> --check`).
81
+ `check-spec-structure`, `align-spec`, and `check-scenario-overlap` (each `--spec-dir <specDir>
82
+ --check`), and `check-spec-references` (`--spec-dir <specDir>`).
52
83
 
53
- **`check-scenario-overlap` is not in this set yet.** Per-project it reports pre-existing
54
- exact-duplicate scenarios that are `@trigger` sibling-deference rows; resolving one deletes a frozen
55
- scenario from its non-owning node, which is a **narrowing** and Clearance-bound — not a call this
56
- engine may force. It still runs corpus-wide at the root, so no coverage is lost, and it joins this
57
- set in the CR that resolves those duplicates under a granted clearance.
84
+ **`check-spec-references` takes no `--check`.** Every other engine here has a second mode a `--check`
85
+ selects between — write-vs-verify, or audit-vs-gate. This one does not: it is read-only and
86
+ single-severity, so a report mode would differ from the guard by exit code alone. A path that is
87
+ correct but not resolvable from the file carrying it (prose quoting a symlink target, say) is
88
+ excused inline by the engine's own marker, not by a severity.
58
89
 
59
90
  Every engine is spawned with **cwd = the repo root**, never the project dir — they resolve
60
91
  repo-root-relative references against the cwd.
@@ -62,6 +93,9 @@ repo-root-relative references against the cwd.
62
93
  The two `--root` engines are corpus-shaped (they read the first path segment under root as a project
63
94
  slug), but a single project-spec dir is a legal root: the slug is only a message tag.
64
95
 
96
+ A failing engine never stops the ones after it, at either scope: a floor that returns at the first
97
+ defect reports one defect per invocation, so an author fixes the tree one run at a time.
98
+
65
99
  ## Boundaries
66
100
 
67
101
  It owns no checks of its own — it resolves and delegates. It writes nothing, and it never decides