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.
- package/.claude-plugin/plugin.json +5 -3
- package/.codex-plugin/plugin.json +5 -3
- package/.plugin/pins.json +1 -1
- package/.plugin/plugin.json +5 -3
- package/package.json +4 -3
- package/skills/check-plugin-manifests/SKILL.md +53 -0
- package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +256 -0
- package/skills/check-project-specs/README.md +13 -6
- package/skills/check-project-specs/SKILL.md +47 -13
- package/skills/check-project-specs/scripts/check-project-specs.mts +103 -24
- package/skills/check-spec-references/README.md +22 -0
- package/skills/check-spec-references/SKILL.md +96 -0
- package/skills/check-spec-references/scripts/check-spec-references.mts +422 -0
- package/skills/discover-specs/scripts/discover-specs.mts +23 -2
- package/skills/start-mission/SKILL.md +4 -4
- package/LICENSE +0 -21
|
@@ -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": {
|
|
6
|
-
|
|
7
|
-
|
|
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": {
|
|
6
|
-
|
|
7
|
-
|
|
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
package/.plugin/plugin.json
CHANGED
|
@@ -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": {
|
|
6
|
-
|
|
7
|
-
|
|
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.
|
|
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/
|
|
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": "
|
|
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
|
|
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
|
|
7
|
-
node scripts/check-project-specs.mts
|
|
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
|
-
|
|
11
|
-
|
|
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 —
|
|
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 **
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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"
|
|
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
|
-
|
|
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`,
|
|
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-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|