cyber-sdd 0.0.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 +17 -0
- package/.codex-plugin/plugin.json +17 -0
- package/.plugin/plugin.json +17 -0
- package/README.md +159 -0
- package/agents/sdd-automaton.md +97 -0
- package/agents/sdd-impl-judge.md +214 -0
- package/agents/sdd-scanner.md +120 -0
- package/agents/sdd-spec-judge.md +224 -0
- package/agents/sdd-warden.md +101 -0
- package/package.json +24 -0
- package/skills/align-spec/README.md +20 -0
- package/skills/align-spec/SKILL.md +111 -0
- package/skills/align-spec/scripts/align-spec.mts +187 -0
- package/skills/architect-impl-governance/README.md +46 -0
- package/skills/architect-impl-governance/SKILL.md +45 -0
- package/skills/architect-spec-governance/README.md +48 -0
- package/skills/architect-spec-governance/SKILL.md +59 -0
- package/skills/blast-estimate/README.md +47 -0
- package/skills/blast-estimate/SKILL.md +133 -0
- package/skills/blast-estimate/scripts/blast-estimate.mts +583 -0
- package/skills/builder-impl-governance/README.md +47 -0
- package/skills/builder-impl-governance/SKILL.md +47 -0
- package/skills/builder-spec-governance/README.md +49 -0
- package/skills/builder-spec-governance/SKILL.md +36 -0
- package/skills/check-partition-quality/README.md +22 -0
- package/skills/check-partition-quality/SKILL.md +51 -0
- package/skills/check-partition-quality/scripts/check-partition-quality.mts +336 -0
- package/skills/check-plan-safety/README.md +17 -0
- package/skills/check-plan-safety/SKILL.md +60 -0
- package/skills/check-plan-safety/scripts/check-plan-safety.mts +145 -0
- package/skills/check-project-specs/README.md +19 -0
- package/skills/check-project-specs/SKILL.md +69 -0
- package/skills/check-project-specs/scripts/check-project-specs.mts +217 -0
- package/skills/check-scenario-overlap/README.md +19 -0
- package/skills/check-scenario-overlap/SKILL.md +74 -0
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +249 -0
- package/skills/check-spec-structure/README.md +17 -0
- package/skills/check-spec-structure/SKILL.md +66 -0
- package/skills/check-spec-structure/scripts/check-spec-structure.mts +346 -0
- package/skills/collision-ladder/README.md +18 -0
- package/skills/collision-ladder/SKILL.md +83 -0
- package/skills/collision-ladder/scripts/collision-ladder.mts +657 -0
- package/skills/combat-log-governance/README.md +13 -0
- package/skills/combat-log-governance/SKILL.md +257 -0
- package/skills/concept-index/README.md +13 -0
- package/skills/concept-index/SKILL.md +38 -0
- package/skills/concept-index/scripts/concept-index.mts +245 -0
- package/skills/discover-plans/README.md +16 -0
- package/skills/discover-plans/SKILL.md +74 -0
- package/skills/discover-plans/scripts/discover-plans.mts +212 -0
- package/skills/discover-specs/README.md +15 -0
- package/skills/discover-specs/SKILL.md +76 -0
- package/skills/discover-specs/scripts/discover-specs.mts +396 -0
- package/skills/doctrine-loop/README.md +15 -0
- package/skills/doctrine-loop/SKILL.md +97 -0
- package/skills/formation-loop/README.md +17 -0
- package/skills/formation-loop/SKILL.md +140 -0
- package/skills/gate-validation-governance/README.md +12 -0
- package/skills/gate-validation-governance/SKILL.md +87 -0
- package/skills/impl-producer-governance/README.md +48 -0
- package/skills/impl-producer-governance/SKILL.md +85 -0
- package/skills/init/README.md +27 -0
- package/skills/init/SKILL.md +68 -0
- package/skills/init/scripts/wire-statusline.mts +276 -0
- package/skills/lifecycle-governance/README.md +11 -0
- package/skills/lifecycle-governance/SKILL.md +168 -0
- package/skills/manage/README.md +9 -0
- package/skills/manage/SKILL.md +62 -0
- package/skills/manage-ignore/README.md +19 -0
- package/skills/manage-ignore/SKILL.md +52 -0
- package/skills/manage-ignore/scripts/manage-ignore.mts +294 -0
- package/skills/manage-scenario-bridge/README.md +20 -0
- package/skills/manage-scenario-bridge/SKILL.md +60 -0
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +156 -0
- package/skills/manage-spec-anchors/README.md +18 -0
- package/skills/manage-spec-anchors/SKILL.md +56 -0
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +328 -0
- package/skills/mission-graph/README.md +15 -0
- package/skills/mission-graph/SKILL.md +67 -0
- package/skills/mission-graph/scripts/mission-graph.mts +844 -0
- package/skills/oracle-spec-governance/README.md +45 -0
- package/skills/oracle-spec-governance/SKILL.md +45 -0
- package/skills/ownership-governance/README.md +65 -0
- package/skills/ownership-governance/SKILL.md +104 -0
- package/skills/pause-mission/README.md +18 -0
- package/skills/pause-mission/SKILL.md +112 -0
- package/skills/place-node/README.md +12 -0
- package/skills/place-node/SKILL.md +47 -0
- package/skills/place-node/scripts/place-node.mts +157 -0
- package/skills/plan-retirement/README.md +32 -0
- package/skills/plan-retirement/SKILL.md +90 -0
- package/skills/plan-retirement/scripts/retire-plans.mts +196 -0
- package/skills/plugin-contract-governance/README.md +12 -0
- package/skills/plugin-contract-governance/SKILL.md +112 -0
- package/skills/remediation-governance/README.md +46 -0
- package/skills/remediation-governance/SKILL.md +78 -0
- package/skills/resolve-governances/README.md +18 -0
- package/skills/resolve-governances/SKILL.md +50 -0
- package/skills/resolve-governances/scripts/resolve-governances.mts +515 -0
- package/skills/resolve-tracking/SKILL.md +64 -0
- package/skills/resolve-tracking/scripts/resolve-tracking.mts +213 -0
- package/skills/resume-mission/README.md +12 -0
- package/skills/resume-mission/SKILL.md +53 -0
- package/skills/scaffold-project-spec/README.md +7 -0
- package/skills/scaffold-project-spec/SKILL.md +192 -0
- package/skills/sdd/README.md +7 -0
- package/skills/sdd/SKILL.md +92 -0
- package/skills/solution-producer-governance/README.md +9 -0
- package/skills/solution-producer-governance/SKILL.md +44 -0
- package/skills/spec-format-governance/README.md +73 -0
- package/skills/spec-format-governance/SKILL.md +114 -0
- package/skills/spec-gate/README.md +26 -0
- package/skills/spec-gate/SKILL.md +201 -0
- package/skills/spec-gate/scripts/check-spec-state.mts +601 -0
- package/skills/spec-gate/scripts/check-suite.mts +501 -0
- package/skills/spec-gate/scripts/classify-edit-class.mts +411 -0
- package/skills/spec-producer-governance/README.md +7 -0
- package/skills/spec-producer-governance/SKILL.md +86 -0
- package/skills/spec-structure-governance/README.md +40 -0
- package/skills/spec-structure-governance/SKILL.md +169 -0
- package/skills/ssa-lowering/README.md +26 -0
- package/skills/ssa-lowering/SKILL.md +181 -0
- package/skills/start-mission/README.md +7 -0
- package/skills/start-mission/SKILL.md +115 -0
- package/skills/suite-format-governance/README.md +75 -0
- package/skills/suite-format-governance/SKILL.md +299 -0
- package/skills/suite-format-governance/references/rubric.md +313 -0
- package/skills/touch-set-correction/README.md +16 -0
- package/skills/touch-set-correction/SKILL.md +67 -0
- package/skills/touch-set-correction/scripts/touch-set-correction.mts +418 -0
- package/skills/verify-scenarios/README.md +17 -0
- package/skills/verify-scenarios/SKILL.md +109 -0
- package/skills/verify-scenarios/scripts/verify-scenarios.mts +386 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// check-plan-safety — the concrete guard engine for the plan brief's safe-to-publish floor.
|
|
3
|
+
// Scans the SDD plan directory (.agents/plans) for the *.md handoff artifacts a mission commits
|
|
4
|
+
// — the `<cr-ref>.plan.md` brief and its sibling design docs — and flags any machine-local
|
|
5
|
+
// reference that must never enter git history in a tracked, portable artifact:
|
|
6
|
+
//
|
|
7
|
+
// - a home-directory absolute path (`/home/<user>/…`, `/Users/<user>/…`, `C:\Users\<user>\…`)
|
|
8
|
+
// — carries the OS username (privacy) and resolves on no other checkout (portability), and
|
|
9
|
+
// - a shell expansion of the user's home / identity (`$HOME`, `${HOME}`, `$USER`, `%USERPROFILE%`).
|
|
10
|
+
//
|
|
11
|
+
// A bare `~/` is deliberately NOT flagged: it carries no username and legitimately appears in
|
|
12
|
+
// design prose describing home-rooted feature paths (e.g. a tool's own `~/.<tool>/` data root).
|
|
13
|
+
// The floor mirrors the combat-log's "never committed: … absolute paths, OS usernames" rule
|
|
14
|
+
// (combat-log-governance), extended from the ledger to the plan brief.
|
|
15
|
+
//
|
|
16
|
+
// Pure functions are exported for node:test; running the file directly drives the CLI. No
|
|
17
|
+
// dependencies (the repo's node-≥23.6 / no-deps convention). Read-only: it writes nothing.
|
|
18
|
+
// Default output is TOON (the token-efficient tabular form); --format json for a flat array.
|
|
19
|
+
// --check is the CI guard: exit non-zero iff any leak is found.
|
|
20
|
+
|
|
21
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
|
|
24
|
+
export interface Leak {
|
|
25
|
+
/** Repo-relative path of the file the leak sits in. */
|
|
26
|
+
file: string
|
|
27
|
+
/** 1-indexed line number. */
|
|
28
|
+
line: number
|
|
29
|
+
/** The leak class — `home-abs-path`, `env-home`, or `env-user`. */
|
|
30
|
+
kind: string
|
|
31
|
+
/** The matched fragment (capped), so a reader can locate and scrub it. */
|
|
32
|
+
token: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The machine-local reference patterns. Each carries the leak `kind` it detects. Global flag so a
|
|
36
|
+
// single line with several leaks reports each. USER/HOME env forms use a negative lookahead so
|
|
37
|
+
// `$HOMEBREW` / `$USERDATA` (a different variable that merely starts with the name) is not flagged.
|
|
38
|
+
export const LEAK_PATTERNS: { kind: string; re: RegExp }[] = [
|
|
39
|
+
// /home/<user>/… and /Users/<user>/… — the home-abs path the leak class is named for.
|
|
40
|
+
// Also catches a forward-slash Windows profile (`C:/Users/…` contains `/Users/…`).
|
|
41
|
+
{ kind: 'home-abs-path', re: /\/(?:home|Users)\/[^\s'"`)\]]+/g },
|
|
42
|
+
// Backslash Windows user profile — C:\Users\<user>\… (the forward-slash form is caught above).
|
|
43
|
+
{ kind: 'home-abs-path', re: /[A-Za-z]:\\Users\\[^\s'"`)\]]+/g },
|
|
44
|
+
{ kind: 'env-home', re: /\$\{?HOME\}?(?![A-Za-z])|%USERPROFILE%|%HOMEPATH%/g },
|
|
45
|
+
{ kind: 'env-user', re: /\$\{?USER\}?(?![A-Za-z])|%USERNAME%/g },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
/** Cap a matched token so a pathological long path does not blow up the report. */
|
|
49
|
+
function capToken(t: string): string {
|
|
50
|
+
return t.length > 80 ? `${t.slice(0, 77)}...` : t
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Scan one file's text ──
|
|
54
|
+
// Every machine-local reference in `text`, tagged with `file` (the repo-relative path the caller
|
|
55
|
+
// supplies) and its 1-indexed line. Lines are scanned independently; a line with N leaks yields N.
|
|
56
|
+
export function scanText(file: string, text: string): Leak[] {
|
|
57
|
+
const out: Leak[] = []
|
|
58
|
+
const lines = text.split('\n')
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
const line = lines[i].replace(/\r$/, '')
|
|
61
|
+
for (const { kind, re } of LEAK_PATTERNS) {
|
|
62
|
+
re.lastIndex = 0
|
|
63
|
+
let m = re.exec(line)
|
|
64
|
+
while (m !== null) {
|
|
65
|
+
out.push({ file, line: i + 1, kind, token: capToken(m[0]) })
|
|
66
|
+
if (m.index === re.lastIndex) re.lastIndex++ // guard against a zero-width match
|
|
67
|
+
m = re.exec(line)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Scan the plan directory ──
|
|
75
|
+
// The handoff artifacts a mission commits: every `*.md` sitting directly under <root>/.agents/plans
|
|
76
|
+
// — the `<cr-ref>.plan.md` brief plus any sibling design doc. A missing plans dir yields no leaks.
|
|
77
|
+
function planFiles(root: string): string[] {
|
|
78
|
+
const dir = join(root, '.agents', 'plans')
|
|
79
|
+
if (!existsSync(dir)) return []
|
|
80
|
+
let entries: import('node:fs').Dirent[]
|
|
81
|
+
try {
|
|
82
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
83
|
+
} catch {
|
|
84
|
+
return []
|
|
85
|
+
}
|
|
86
|
+
return entries
|
|
87
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md'))
|
|
88
|
+
.map((e) => e.name)
|
|
89
|
+
.sort()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Collect leaks across the plan directory (default) or a caller-named file set (`--path`). File
|
|
93
|
+
// paths in the returned leaks are repo-relative when scanning the plan dir, and as-passed for
|
|
94
|
+
// explicit `--path` targets. Sorted by file then line for stable output.
|
|
95
|
+
export function collectLeaks(root: string, paths?: string[]): Leak[] {
|
|
96
|
+
const targets: { rel: string; abs: string }[] = paths
|
|
97
|
+
? paths.map((p) => ({ rel: p, abs: p }))
|
|
98
|
+
: planFiles(root).map((name) => ({
|
|
99
|
+
rel: join('.agents', 'plans', name),
|
|
100
|
+
abs: join(root, '.agents', 'plans', name),
|
|
101
|
+
}))
|
|
102
|
+
const out: Leak[] = []
|
|
103
|
+
for (const { rel, abs } of targets) {
|
|
104
|
+
let text: string
|
|
105
|
+
try {
|
|
106
|
+
text = readFileSync(abs, 'utf8')
|
|
107
|
+
} catch {
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
out.push(...scanText(rel, text))
|
|
111
|
+
}
|
|
112
|
+
return out.sort((a, b) => (a.file !== b.file ? (a.file < b.file ? -1 : 1) : a.line - b.line))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Output ──
|
|
116
|
+
const COLUMNS = ['file', 'line', 'kind', 'token'] as const
|
|
117
|
+
|
|
118
|
+
function toonField(v: string): string {
|
|
119
|
+
if (v === '' || /[",]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, '""')}"`
|
|
120
|
+
return v
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function toToon(leaks: Leak[]): string {
|
|
124
|
+
const header = `leaks[${leaks.length}]{${COLUMNS.join(',')}}:`
|
|
125
|
+
const rows = leaks.map((l) => ` ${COLUMNS.map((c) => toonField(String(l[c]))).join(',')}`)
|
|
126
|
+
return [header, ...rows].join('\n')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function main(argv: string[]): number {
|
|
130
|
+
const root = argv.includes('--root') ? (argv[argv.indexOf('--root') + 1] ?? '.') : '.'
|
|
131
|
+
const format = argv.includes('--format') ? argv[argv.indexOf('--format') + 1] : 'toon'
|
|
132
|
+
const check = argv.includes('--check')
|
|
133
|
+
// `--path <file>` may repeat to scan an explicit file set (e.g. the one brief a checkpoint
|
|
134
|
+
// is about to commit) instead of the whole plan directory.
|
|
135
|
+
const paths: string[] = []
|
|
136
|
+
for (let i = 0; i < argv.length; i++) if (argv[i] === '--path' && argv[i + 1]) paths.push(argv[++i])
|
|
137
|
+
|
|
138
|
+
const leaks = collectLeaks(root, paths.length ? paths : undefined)
|
|
139
|
+
const out = format === 'json' ? JSON.stringify(leaks, null, 2) : toToon(leaks)
|
|
140
|
+
process.stdout.write(`${out}\n`)
|
|
141
|
+
// --check is the CI guard: any leak fails. Audit mode (default) always exits 0.
|
|
142
|
+
return check && leaks.length > 0 ? 1 : 0
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (import.meta.main) process.exit(main(process.argv.slice(2)))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# check-project-specs
|
|
2
|
+
|
|
3
|
+
Runs every project-spec check against the one spec governing the invoking package.
|
|
4
|
+
|
|
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
|
|
8
|
+
```
|
|
9
|
+
|
|
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.
|
|
12
|
+
|
|
13
|
+
Resolution is spec-first: the spec's own `project-path` names the project dir, and
|
|
14
|
+
`check-project-specs` inverts that map. The reverse map cannot be derived by name
|
|
15
|
+
(`plugins/cyberfleet` → `.agents/specs/cyberfleet-plugin`).
|
|
16
|
+
|
|
17
|
+
A project no spec governs prints a skip and exits zero. Two specs claiming one project is an error.
|
|
18
|
+
|
|
19
|
+
See `SKILL.md` for the engine set and the cwd contract.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
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."
|
|
4
|
+
user-invocable: false
|
|
5
|
+
metadata:
|
|
6
|
+
internal: true
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Check Project Specs
|
|
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).
|
|
15
|
+
|
|
16
|
+
## Resolution — spec-first, never by name
|
|
17
|
+
|
|
18
|
+
A package knows its own directory; exactly one spec declares `project-path` pointing at it. The
|
|
19
|
+
engine inverts that map via `discover-specs`' `collectSpecs`:
|
|
20
|
+
|
|
21
|
+
1. The project dir is the **cwd** (`--project <dir>` overrides — the cwd is what a package-manager
|
|
22
|
+
script gives you for free).
|
|
23
|
+
2. Walk up for `pnpm-workspace.yaml` → the repo root.
|
|
24
|
+
3. Match the repo-relative project dir against each spec's `project-path`.
|
|
25
|
+
|
|
26
|
+
The reverse map is **irregular and not derivable by name** — `plugins/cyberfleet` is governed by
|
|
27
|
+
`.agents/specs/cyberfleet-plugin`, and two different projects both own a skill named `init`. Only
|
|
28
|
+
`project-path` inverts reliably, which is why the spec stays the single source of truth for the
|
|
29
|
+
mapping and no path is ever written into a package's scripts.
|
|
30
|
+
|
|
31
|
+
## Run it
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
node "<skill>/scripts/check-project-specs.mts" [--project <dir>]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Wired as each project's `check:spec` script, via the `sdd-check-specs` bin.
|
|
38
|
+
|
|
39
|
+
## Outcomes
|
|
40
|
+
|
|
41
|
+
- **Resolved** — runs every engine against the spec dir, reports `ok` / `FAIL` per engine, and exits
|
|
42
|
+
non-zero if any failed.
|
|
43
|
+
- **No spec governs this project** — prints that and exits **zero**. The script is uniform across
|
|
44
|
+
every workspace member, and some members are governed by no spec; a project without one is not a
|
|
45
|
+
failure.
|
|
46
|
+
- **Two specs claim the project** — exits non-zero. One project is one spec.
|
|
47
|
+
|
|
48
|
+
## The engines it runs
|
|
49
|
+
|
|
50
|
+
`check-spec-state` and `check-suite` (each `--root <specDir>`), then `concept-index`,
|
|
51
|
+
`check-spec-structure`, and `align-spec` (each `--spec-dir <specDir> --check`).
|
|
52
|
+
|
|
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.
|
|
58
|
+
|
|
59
|
+
Every engine is spawned with **cwd = the repo root**, never the project dir — they resolve
|
|
60
|
+
repo-root-relative references against the cwd.
|
|
61
|
+
|
|
62
|
+
The two `--root` engines are corpus-shaped (they read the first path segment under root as a project
|
|
63
|
+
slug), but a single project-spec dir is a legal root: the slug is only a message tag.
|
|
64
|
+
|
|
65
|
+
## Boundaries
|
|
66
|
+
|
|
67
|
+
It owns no checks of its own — it resolves and delegates. It writes nothing, and it never decides
|
|
68
|
+
what a finding means. Adding a project-spec engine means adding it here, which is what keeps every
|
|
69
|
+
project's coverage identical.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runs every project-spec engine against the ONE project spec that governs the
|
|
3
|
+
// invoking package — resolved from the spec's own `project-path`, never from a
|
|
4
|
+
// hardcoded path. Pure functions are exported for node:test; running the file
|
|
5
|
+
// directly drives the CLI. No dependencies — plain node strips the types.
|
|
6
|
+
//
|
|
7
|
+
// The resolution is deliberately spec-first: a package knows its own directory,
|
|
8
|
+
// and exactly one spec declares `project-path` pointing at it. The reverse map
|
|
9
|
+
// (project dir -> spec dir) is irregular and cannot be derived by name —
|
|
10
|
+
// `plugins/cyberfleet` is governed by `.agents/specs/cyberfleet-plugin`.
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from 'node:child_process'
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { collectSpecs, discoverSpecFiles, type SpecRecord } from '../../discover-specs/scripts/discover-specs.mts'
|
|
17
|
+
|
|
18
|
+
const SKILLS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
|
19
|
+
|
|
20
|
+
// ─── the engine set ───────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
// Each engine is handed the resolved spec dir. The two `--root` engines are
|
|
23
|
+
// corpus-shaped (they treat the first path segment under root as a project
|
|
24
|
+
// slug) but accept a single project-spec dir as root — the slug is only a
|
|
25
|
+
// message tag, so a one-project root reports paths relative to that project.
|
|
26
|
+
interface Engine {
|
|
27
|
+
name: string
|
|
28
|
+
script: string
|
|
29
|
+
args: (specDir: string) => string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const ENGINES: Engine[] = [
|
|
33
|
+
{ name: 'check-spec-state', script: 'spec-gate/scripts/check-spec-state.mts', args: (d) => ['--root', d] },
|
|
34
|
+
{ name: 'check-suite', script: 'spec-gate/scripts/check-suite.mts', args: (d) => ['--root', d] },
|
|
35
|
+
{
|
|
36
|
+
name: 'concept-index',
|
|
37
|
+
script: 'concept-index/scripts/concept-index.mts',
|
|
38
|
+
args: (d) => ['--spec-dir', d, '--check'],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'check-spec-structure',
|
|
42
|
+
script: 'check-spec-structure/scripts/check-spec-structure.mts',
|
|
43
|
+
args: (d) => ['--spec-dir', d, '--check'],
|
|
44
|
+
},
|
|
45
|
+
{ name: 'align-spec', script: 'align-spec/scripts/align-spec.mts', args: (d) => ['--spec-dir', d, '--check'] },
|
|
46
|
+
{
|
|
47
|
+
name: 'check-scenario-overlap',
|
|
48
|
+
script: 'check-scenario-overlap/scripts/check-scenario-overlap.mts',
|
|
49
|
+
args: (d) => ['--spec-dir', d, '--check'],
|
|
50
|
+
},
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
// ─── repo root ────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/** Walk up from `start` for the workspace marker. Returns '' when not found. */
|
|
56
|
+
export function findRepoRoot(start: string): string {
|
|
57
|
+
let dir = resolve(start)
|
|
58
|
+
for (;;) {
|
|
59
|
+
if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return dir
|
|
60
|
+
const up = dirname(dir)
|
|
61
|
+
if (up === dir) return ''
|
|
62
|
+
dir = up
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── resolution ───────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export type Resolution =
|
|
69
|
+
| { kind: 'resolved'; spec: SpecRecord }
|
|
70
|
+
| { kind: 'none' }
|
|
71
|
+
| { kind: 'ambiguous'; specs: SpecRecord[] }
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Find the spec whose `project-path` names `projectRel` (a repo-relative dir).
|
|
75
|
+
* A project with no spec is `none` — not an error: the script is uniform across
|
|
76
|
+
* every workspace member, and some members are governed by no spec.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveSpecFor(specs: SpecRecord[], projectRel: string): Resolution {
|
|
79
|
+
const hits = specs.filter((s) => s.projectPath !== '' && s.projectPath === projectRel)
|
|
80
|
+
if (hits.length === 0) return { kind: 'none' }
|
|
81
|
+
if (hits.length > 1) return { kind: 'ambiguous', specs: hits }
|
|
82
|
+
return { kind: 'resolved', spec: hits[0] as SpecRecord }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── coverage ─────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export interface CoverageGap {
|
|
88
|
+
spec: string
|
|
89
|
+
projectPath: string
|
|
90
|
+
reason: 'unrecognized' | 'no-project-path' | 'no-manifest' | 'no-check-script'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Every spec must be reachable from a project that actually checks it. Without
|
|
95
|
+
* this, a spec silently goes unchecked the moment its project drops the script —
|
|
96
|
+
* which is exactly how the corpus ended up with one audited project out of ten.
|
|
97
|
+
*
|
|
98
|
+
* `specFiles` are the spec.md paths found at the recognized locations *before*
|
|
99
|
+
* the lifecycle-status filter; `specs` are the ones that survived it. A file in
|
|
100
|
+
* the first set and not the second is a spec whose status is not in the enum:
|
|
101
|
+
* discovery drops it, so every engine silently skips it and it is checked by
|
|
102
|
+
* nothing. That is a gap to escalate, not to exempt.
|
|
103
|
+
*/
|
|
104
|
+
export function findCoverageGaps(
|
|
105
|
+
root: string,
|
|
106
|
+
specFiles: string[],
|
|
107
|
+
specs: SpecRecord[],
|
|
108
|
+
readPkg: (p: string) => unknown,
|
|
109
|
+
): CoverageGap[] {
|
|
110
|
+
const gaps: CoverageGap[] = []
|
|
111
|
+
const recognized = new Set(specs.map((s) => (s.path === '' ? 'spec.md' : `${s.path}/spec.md`)))
|
|
112
|
+
for (const f of specFiles) {
|
|
113
|
+
if (!recognized.has(f)) gaps.push({ spec: f, projectPath: '', reason: 'unrecognized' })
|
|
114
|
+
}
|
|
115
|
+
for (const s of specs) {
|
|
116
|
+
if (s.projectPath === '') {
|
|
117
|
+
gaps.push({ spec: s.path, projectPath: '', reason: 'no-project-path' })
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
const pkg = readPkg(join(root, s.projectPath, 'package.json')) as { scripts?: Record<string, string> } | null
|
|
121
|
+
if (!pkg) {
|
|
122
|
+
gaps.push({ spec: s.path, projectPath: s.projectPath, reason: 'no-manifest' })
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (!pkg.scripts?.['check:spec']) {
|
|
126
|
+
gaps.push({ spec: s.path, projectPath: s.projectPath, reason: 'no-check-script' })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return gaps
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const REASON_TEXT: Record<CoverageGap['reason'], string> = {
|
|
133
|
+
unrecognized:
|
|
134
|
+
'sits at a spec location but its status is not in the lifecycle enum, so discovery drops it and nothing checks it',
|
|
135
|
+
'no-project-path': 'declares no project-path, so no project can be resolved to check it',
|
|
136
|
+
'no-manifest': 'names a project with no package.json, so it is not a workspace member',
|
|
137
|
+
'no-check-script': 'names a project that defines no `check:spec` script',
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function checkCoverage(root: string): number {
|
|
141
|
+
const gaps = findCoverageGaps(root, discoverSpecFiles(root), collectSpecs(root), (p) => {
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(readFileSync(p, 'utf8'))
|
|
144
|
+
} catch {
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
if (gaps.length === 0) {
|
|
149
|
+
process.stdout.write('check-project-specs: every spec is checked by its project\n')
|
|
150
|
+
return 0
|
|
151
|
+
}
|
|
152
|
+
for (const g of gaps) process.stderr.write(` ${g.spec} — ${REASON_TEXT[g.reason]}\n`)
|
|
153
|
+
process.stderr.write(`check-project-specs: ${gaps.length} spec(s) no project checks\n`)
|
|
154
|
+
return 1
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── run ──────────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export function main(argv: string[]): number {
|
|
160
|
+
if (argv.includes('--check-coverage')) {
|
|
161
|
+
const root = findRepoRoot(process.cwd())
|
|
162
|
+
if (!root) {
|
|
163
|
+
process.stderr.write('check-project-specs: no pnpm-workspace.yaml found above the cwd\n')
|
|
164
|
+
return 1
|
|
165
|
+
}
|
|
166
|
+
return checkCoverage(root)
|
|
167
|
+
}
|
|
168
|
+
return checkProject(argv)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function checkProject(argv: string[]): number {
|
|
172
|
+
const projectArg = argv.includes('--project') ? argv[argv.indexOf('--project') + 1] : undefined
|
|
173
|
+
const projectDir = resolve(projectArg ?? process.cwd())
|
|
174
|
+
|
|
175
|
+
const repoRoot = findRepoRoot(projectDir)
|
|
176
|
+
if (!repoRoot) {
|
|
177
|
+
process.stderr.write(`check-project-specs: no pnpm-workspace.yaml found above ${projectDir}\n`)
|
|
178
|
+
return 1
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const projectRel = relative(repoRoot, projectDir) || '.'
|
|
182
|
+
const res = resolveSpecFor(collectSpecs(repoRoot), projectRel)
|
|
183
|
+
|
|
184
|
+
if (res.kind === 'none') {
|
|
185
|
+
process.stdout.write(`check-project-specs: no spec governs \`${projectRel}\` — skipped\n`)
|
|
186
|
+
return 0
|
|
187
|
+
}
|
|
188
|
+
if (res.kind === 'ambiguous') {
|
|
189
|
+
process.stderr.write(
|
|
190
|
+
`check-project-specs: \`${projectRel}\` is claimed by ${res.specs.length} specs ` +
|
|
191
|
+
`(${res.specs.map((s) => s.path).join(', ')}) — a project has exactly one spec\n`,
|
|
192
|
+
)
|
|
193
|
+
return 1
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const specDir = join(repoRoot, res.spec.path)
|
|
197
|
+
process.stdout.write(`check-project-specs: ${projectRel} -> ${res.spec.path}\n`)
|
|
198
|
+
|
|
199
|
+
let failed = 0
|
|
200
|
+
for (const e of ENGINES) {
|
|
201
|
+
// cwd is the repo root, not the project dir: the engines resolve
|
|
202
|
+
// repo-root-relative references against process.cwd().
|
|
203
|
+
try {
|
|
204
|
+
execFileSync('node', [join(SKILLS_DIR, e.script), ...e.args(specDir)], {
|
|
205
|
+
cwd: repoRoot,
|
|
206
|
+
stdio: 'inherit',
|
|
207
|
+
})
|
|
208
|
+
process.stdout.write(` ok ${e.name}\n`)
|
|
209
|
+
} catch {
|
|
210
|
+
process.stderr.write(` FAIL ${e.name}\n`)
|
|
211
|
+
failed++
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return failed === 0 ? 0 : 1
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (import.meta.main) process.exit(main(process.argv.slice(2)))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# check-scenario-overlap
|
|
2
|
+
|
|
3
|
+
Internal SDD skill — the concrete engine for **cross-node scenario-overlap detection**. Audits across
|
|
4
|
+
the nodes of one project spec and emits the candidates where the **same behavior lives in more than
|
|
5
|
+
one node's `.feature`**, for the formation Warden: the intra-project **spec-level SSA** partner of the
|
|
6
|
+
collision ladder, and the cross-node sibling of `check-spec-structure` (intra-node node-shape).
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
node scripts/check-scenario-overlap.mts --spec-dir <spec> # audit (TOON candidate set)
|
|
10
|
+
node scripts/check-scenario-overlap.mts --spec-dir <spec> --check # CI guard (fails on exact-duplicate)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Two deterministic candidate kinds — **exact-duplicate** (blocking: two nodes share an identical
|
|
14
|
+
normalized fingerprint) and **title-overlap** (advisory: two nodes share a scenario title, differing
|
|
15
|
+
fingerprints) — plus a Warden `@rubric` arm that confirms real overlap and assigns a single owning
|
|
16
|
+
node. The fingerprint is step-bodies-only for a plain `Scenario`; a `Scenario Outline`'s fingerprint
|
|
17
|
+
also folds in its normalized `Examples` table (header + rows), since its steps are a template shared
|
|
18
|
+
by every canonical outline. Detection is cross-node only. Read-only, writes nothing.
|
|
19
|
+
See [`SKILL.md`](./SKILL.md) for the full contract. Not user-invocable.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: check-scenario-overlap
|
|
3
|
+
description: "Partial Skill: invoke by name only — project-spec/scenario-overlap's engine that detects the same behavior specified in two nodes' suites across one project spec — feeds the formation Warden, not triggered by users directly."
|
|
4
|
+
user-invocable: false
|
|
5
|
+
metadata:
|
|
6
|
+
internal: true
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Check Scenario Overlap
|
|
10
|
+
|
|
11
|
+
The concrete engine for **cross-node scenario-overlap detection** — the intra-project **spec-level
|
|
12
|
+
SSA** partner of the code-side collision ladder. It audits **across the nodes** of one project spec
|
|
13
|
+
and returns the candidates where the **same behavior lives in more than one node's `.feature`**, for
|
|
14
|
+
the formation **Warden**. It is the **cross-node** sibling of `check-spec-structure` (intra-node
|
|
15
|
+
node-shape), whose per-node scope leaves this cross-node axis uncovered. Self-contained `.mts` (the
|
|
16
|
+
repo's node-≥23.6 / no-deps convention).
|
|
17
|
+
|
|
18
|
+
## Why this rung exists
|
|
19
|
+
|
|
20
|
+
The scheduler treats two missions touching different `.feature` files as **file-disjoint** — but if
|
|
21
|
+
the **same behavior** is specified in **two** files, a change to that behavior must touch both, so
|
|
22
|
+
what looked disjoint is a **hard collision the scenario rung cannot see** (it diffs changed scenarios
|
|
23
|
+
per file, never across files). One behavior = **one scenario in one owning node** keeps the scenario
|
|
24
|
+
rung honest. Cross-*project* dedup (`dedupe-specs`) was retired when one project became one spec;
|
|
25
|
+
cross-*node* overlap **inside** a project had no detector until this one.
|
|
26
|
+
|
|
27
|
+
## The two deterministic candidate kinds (and one judgment arm)
|
|
28
|
+
|
|
29
|
+
- **exact-duplicate** (blocking) — two **distinct** nodes whose suites each carry a scenario with an
|
|
30
|
+
**identical normalized fingerprint** (the ordered Given/When/Then step bodies, whitespace- and
|
|
31
|
+
case-normalized; for a `Scenario Outline`, its normalized `Examples` table too). A near-certain
|
|
32
|
+
one-behavior-two-nodes violation; `--check` fails on it.
|
|
33
|
+
- **title-overlap** (advisory) — two distinct nodes sharing a **normalized scenario title** but with
|
|
34
|
+
**differing** fingerprints. A weaker hint (same words may name different behavior); **never** fails
|
|
35
|
+
`--check`.
|
|
36
|
+
- **real-overlap + owning-node** is a **Warden judgment** (the spec's `@rubric` scenario) — the
|
|
37
|
+
engine ships no verdict. The Warden confirms the candidate is the same behavior (not a coincidental
|
|
38
|
+
text match) and **assigns a single owning node** for the dedup.
|
|
39
|
+
|
|
40
|
+
The fingerprint is computed from **step bodies only** for a plain `Scenario` — its title, tags,
|
|
41
|
+
comments, and the `.feature` prose never reach it, so the signal is behavior-shaped, not cosmetic. A
|
|
42
|
+
`Scenario Outline`'s steps are a **template**, not its content: every canonical `@trigger` outline
|
|
43
|
+
shares byte-identical steps by construction, so its fingerprint also folds in its **`Examples` table**
|
|
44
|
+
(header + rows, normalized cell-by-cell) — two outlines are an exact-duplicate only when their steps
|
|
45
|
+
**and** their rows match. Title and tags stay excluded from both shapes. Detection is **cross-node
|
|
46
|
+
only**: a scenario duplicated **within one node** raises no candidate, and a scenario appearing
|
|
47
|
+
**once** corpus-wide raises nothing.
|
|
48
|
+
|
|
49
|
+
## Run the scan
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
node "<skill>/scripts/check-scenario-overlap.mts" [--spec-dir <spec>] [--check] [--format toon|json]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- Default `--spec-dir` is the current directory; default `--format` is **TOON** (the token-efficient
|
|
56
|
+
form the Warden scans).
|
|
57
|
+
- **Audit mode** (default) emits the candidate set — a `blocking[]` group then an `advisory[]` group,
|
|
58
|
+
each candidate naming **both** nodes and the overlapping scenario. `--format json` emits the same
|
|
59
|
+
candidates as a flat JSON array.
|
|
60
|
+
- **`--check`** (CI guard) exits **non-zero** iff an **exact-duplicate** candidate exists and **writes
|
|
61
|
+
nothing**; title-overlap advisories still exit zero. Wire it after `check-spec-structure --check` in
|
|
62
|
+
`verify:specs` so the project-spec holds the one-behavior-one-node partition.
|
|
63
|
+
|
|
64
|
+
When `node` is absent, an agent performs the same derivation by hand: parse each node's `.feature`
|
|
65
|
+
into scenarios, normalize each scenario's ordered steps into a fingerprint, and flag any fingerprint
|
|
66
|
+
(or, more weakly, any title) shared across two distinct node folders.
|
|
67
|
+
|
|
68
|
+
## Boundaries
|
|
69
|
+
|
|
70
|
+
Steps + titles only — it never reads a scenario's prose into the fingerprint, owns no lifecycle
|
|
71
|
+
state, and writes nothing. It **never acts** on a candidate (a dedup, a relocation) — that is the
|
|
72
|
+
Warden's (`sdd:formation-loop`) under its own self-clear-vs-escalate verdict. It does **not** audit
|
|
73
|
+
node-shape (`check-spec-structure`), flag within-node duplicate scenarios, render the by-concept view
|
|
74
|
+
(`concept-index`), or advise a new node's home (`place-node`).
|