cyber-sdd 0.4.2 → 0.5.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "homepage": "https://github.com/cyberuni/cyber-sdd",
9
9
  "repository": "https://github.com/cyberuni/cyber-sdd",
10
- "version": "0.4.2",
10
+ "version": "0.5.0",
11
11
  "skills": "./skills",
12
12
  "agents": [
13
13
  "./agents/sdd-automaton.md",
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "homepage": "https://github.com/cyberuni/cyber-sdd",
9
9
  "repository": "https://github.com/cyberuni/cyber-sdd",
10
- "version": "0.4.2",
10
+ "version": "0.5.0",
11
11
  "skills": "./skills",
12
12
  "agents": [
13
13
  "./agents/sdd-automaton.md",
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "homepage": "https://github.com/cyberuni/cyber-sdd",
9
9
  "repository": "https://github.com/cyberuni/cyber-sdd",
10
- "version": "0.4.2",
10
+ "version": "0.5.0",
11
11
  "skills": "./skills",
12
12
  "agents": [
13
13
  "./agents/sdd-automaton.md",
@@ -44,7 +44,7 @@ when you grade against that bar. The **impl-gate lens set is {builder, architect
44
44
  ## Input
45
45
 
46
46
  ```
47
- ARTIFACT_TYPE, NODE_PATH(s), SPEC_PATH, FEATURE_PATH
47
+ ARTIFACT_TYPE, NODE_PATH(s), SPEC_PATH, FEATURE_PATH: the touched artifact-type, the node folder(s), the spec.md, and the .feature
48
48
  IMPLEMENTATION_PATHS: impl-layer paths from the ## Artifacts table
49
49
  VERIFICATION_PATHS: the verification the impl-producer authored (or discoverable across IMPLEMENTATION_PATHS)
50
50
  ```
@@ -44,7 +44,7 @@ them):
44
44
  ## Input
45
45
 
46
46
  ```
47
- ARTIFACT_TYPE, NODE_PATH(s), SPEC_PATH, FEATURE_PATH
47
+ ARTIFACT_TYPE, NODE_PATH(s), SPEC_PATH, FEATURE_PATH: the touched artifact-type, the node folder(s), the spec.md, and the .feature
48
48
  PRODUCER_GOVERNANCES_DECLARED: [ the spec-producer's declared governances_loaded, relayed by the conductor — or [] ]
49
49
  ```
50
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cyber-sdd",
3
- "version": "0.4.2",
3
+ "version": "0.5.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",
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: check-field-mandates
3
+ description: "Partial Skill: invoke by name only"
4
+ user-invocable: false
5
+ metadata:
6
+ internal: true
7
+ ---
8
+
9
+ # Check Field Mandates
10
+
11
+ The concrete engine for the rule that **a definition's declared fields and its prose agree**. Skill
12
+ and agent definitions ship structured blocks — an `Input`, an `Output`, a dispatch payload — whose
13
+ fields the surrounding prose also mandates. A field the prose mandates and the block omits does not
14
+ fail loudly: the stage that depends on it silently never fires. Nothing else in the repo diffs the
15
+ two token sets.
16
+
17
+ ## What it checks
18
+
19
+ Every `plugins/<plugin>/skills/**/SKILL.md` and `plugins/<plugin>/agents/*.md`, both directions:
20
+
21
+ - **unexplained** — a block declares a field that carries no gloss and that the prose never names.
22
+ - **undeclared** — the prose names a known field in a code span, and none of the file's blocks
23
+ declare it.
24
+
25
+ **A structured block** is a fenced code block with no info string (or `text`). A **declaration** is a
26
+ block line opening with field tokens then a colon (`STATUS: complete | blocked`), a comma list of two
27
+ or more tokens (`SPEC_PATH, FEATURE_PATH`), or a list item opening with bold tokens
28
+ (`- **SUBJECT** — …`). A **gloss** is the text after the colon or the bold lead, through to the next
29
+ declaration.
30
+
31
+ **A field is explained by a gloss or by the prose.** A glossed declaration already says what it
32
+ carries; the rule catches the **bare** one nothing explains.
33
+
34
+ **A mandate is a code span naming a known field** — a token some definition in the tree declares.
35
+ That vocabulary separates a field from an uppercase literal such as `TODO`. A definition that
36
+ declares no field at all is a consumer, and its prose is not held to a block.
37
+
38
+ **Naming another agent's field on purpose** — a conductor describing what it passes a judge — is
39
+ marked beside the line, with its reason:
40
+
41
+ ```markdown
42
+ Pass the zero-based `ROW` to the case judge. <!-- field-mandate-ignore: the case judge's input, not ours -->
43
+ ```
44
+
45
+ A marker with no reason excuses nothing. There is no allow-list file: a real mismatch is fixed.
46
+
47
+ ## Run it
48
+
49
+ ```bash
50
+ node "<skill>/scripts/check-field-mandates.mts" [--root <dir>]
51
+ ```
52
+
53
+ `--root` defaults to the working directory. A finding always exits non-zero, as does a root that is
54
+ not a readable directory and an unrecognized flag — there is deliberately no report-only mode.
55
+
56
+ It joins the root check chain as **`check:fields`**, so it runs on every `pnpm verify` and in CI.
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env node
2
+ // check-field-mandates — a definition's declared fields and its prose must agree.
3
+ //
4
+ // Skill and agent definitions ship structured blocks (an Input, an Output, a dispatch payload)
5
+ // whose fields the surrounding prose also mandates. Nothing else compares the two, so a field can be
6
+ // mandated and never carried, or carried and never explained, and both read as complete. This
7
+ // engine diffs the token sets (spec: .agents/specs/sdd/plugin/check-field-mandates/):
8
+ // unexplained — a block declares a field that carries no gloss and the prose never names
9
+ // undeclared — the prose mandates a known field (a code span) that none of the file's blocks declare
10
+ //
11
+ // A field is explained by a gloss OR by the prose — requiring the prose to repeat a glossed
12
+ // declaration would demand filler in every definition. A mandate is a code span naming a KNOWN field
13
+ // (declared somewhere in the tree); that vocabulary is what separates a field from `TODO`.
14
+ //
15
+ // Pure functions are exported for node:test; running the file directly drives the CLI. No
16
+ // dependencies (the repo's node-≥23.6 / no-deps convention).
17
+
18
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
19
+ import { join, relative, sep } from 'node:path'
20
+ import { pathToFileURL } from 'node:url'
21
+
22
+ export type FindingKind = 'unexplained' | 'undeclared'
23
+
24
+ export interface Finding {
25
+ kind: FindingKind
26
+ /** Root-relative path of the definition, `/`-separated. */
27
+ file: string
28
+ /** 1-based line: the declaration for `unexplained`, the prose line for `undeclared`. */
29
+ line: number
30
+ token: string
31
+ }
32
+
33
+ interface Declaration {
34
+ token: string
35
+ line: number
36
+ glossed: boolean
37
+ }
38
+
39
+ interface Mandate {
40
+ token: string
41
+ line: number
42
+ }
43
+
44
+ export interface ParsedDefinition {
45
+ declarations: Declaration[]
46
+ mandates: Mandate[]
47
+ /** Every field-shaped word the prose names, code span or not. */
48
+ proseWords: Set<string>
49
+ }
50
+
51
+ const TOKEN = '[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*'
52
+ const TOKEN_ITEM = `${TOKEN}(?:\\(s\\))?`
53
+ /** A block line opening with a comma list of tokens, then a colon, a trailing comment, or nothing. */
54
+ const BLOCK_DECLARATION_RE = new RegExp(`^\\s*(${TOKEN_ITEM}(?:\\s*,\\s*${TOKEN_ITEM})*)\\s*(:.*|#.*)?$`)
55
+ /** A list item opening with one or more bold tokens joined by `+`. */
56
+ const LIST_DECLARATION_RE = new RegExp(`^\\s*[-*+]\\s+(\\*\\*${TOKEN}\\*\\*(?:\\s*\\+\\s*\\*\\*${TOKEN}\\*\\*)*)(.*)$`)
57
+ const BOLD_TOKEN_RE = new RegExp(`\\*\\*(${TOKEN})\\*\\*`, 'g')
58
+ const WORD_RE = new RegExp(`(?<![A-Za-z0-9_])${TOKEN}(?![A-Za-z0-9_])`, 'g')
59
+ const CODE_SPAN_RE = /`([^`\n]+)`/g
60
+ const MANDATE_LEAD_RE = new RegExp(`^<?(${TOKEN})(?=$|[\\s.:\\[\\]>(,=])`)
61
+ const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/
62
+ /** The marker must carry a reason — a bare marker excuses nothing. */
63
+ const IGNORE_MARKER_RE = /<!--\s*field-mandate-ignore\s*:\s*[^\s>-][^>]*-->/
64
+
65
+ /** A field token is two or more characters: a lone capital is a word, not a field. */
66
+ function isField(token: string): boolean {
67
+ return token.length >= 2
68
+ }
69
+
70
+ /** The forms a token may take and still be the same field — equal, or one trailing `S` apart. */
71
+ function sameFieldForms(token: string): string[] {
72
+ const forms = [token, `${token}S`]
73
+ if (token.endsWith('S') && token.length > 2) forms.push(token.slice(0, -1))
74
+ return forms
75
+ }
76
+
77
+ function hasSameField(set: Set<string>, token: string): boolean {
78
+ return sameFieldForms(token).some((f) => set.has(f))
79
+ }
80
+
81
+ // ── Parse ──
82
+
83
+ export function parseDefinition(text: string): ParsedDefinition {
84
+ const lines = text.split(/\r?\n/)
85
+ const declarations: Declaration[] = []
86
+ const mandates: Mandate[] = []
87
+ const proseWords = new Set<string>()
88
+
89
+ let i = 0
90
+ // YAML frontmatter is metadata, not prose.
91
+ if (lines[0]?.trim() === '---') {
92
+ const end = lines.findIndex((l, n) => n > 0 && l.trim() === '---')
93
+ if (end > 0) i = end + 1
94
+ }
95
+
96
+ let fence: { char: string; length: number; structured: boolean } | null = null
97
+ // The declarations a continuation line glosses: the most recent group in the open block.
98
+ let group: Declaration[] = []
99
+
100
+ /** Record a span of text as prose: its field-shaped words, and any code-span mandate. */
101
+ function scanProse(text: string, lineNo: number): void {
102
+ for (const m of text.matchAll(WORD_RE)) if (isField(m[0])) proseWords.add(m[0])
103
+ if (IGNORE_MARKER_RE.test(text)) return
104
+ for (const m of text.matchAll(CODE_SPAN_RE)) {
105
+ const lead = (m[1] ?? '').trim().match(MANDATE_LEAD_RE)
106
+ const token = lead?.[1]
107
+ if (token && isField(token)) mandates.push({ token, line: lineNo })
108
+ }
109
+ }
110
+
111
+ for (; i < lines.length; i++) {
112
+ const line = lines[i] ?? ''
113
+ const lineNo = i + 1
114
+ const fenceMatch = line.match(FENCE_RE)
115
+
116
+ if (fence) {
117
+ if (
118
+ fenceMatch &&
119
+ fenceMatch[1]?.[0] === fence.char &&
120
+ (fenceMatch[1]?.length ?? 0) >= fence.length &&
121
+ fenceMatch[2]?.trim() === ''
122
+ ) {
123
+ fence = null
124
+ group = []
125
+ continue
126
+ }
127
+ if (!fence.structured) continue
128
+ const decl = line.match(BLOCK_DECLARATION_RE)
129
+ const tokens = decl ? (decl[1] ?? '').split(',').map((t) => t.trim().replace(/\(s\)$/, '')) : []
130
+ const rest = decl?.[2]
131
+ const isDeclaration = decl !== null && (rest?.startsWith(':') || tokens.length >= 2)
132
+ if (isDeclaration) {
133
+ const glossed = rest?.startsWith(':') === true && rest.slice(1).trim() !== ''
134
+ group = tokens.filter(isField).map((token) => ({ token, line: lineNo, glossed }))
135
+ declarations.push(...group)
136
+ } else if (line.trim() !== '') {
137
+ for (const d of group) d.glossed = true
138
+ }
139
+ continue
140
+ }
141
+
142
+ if (fenceMatch) {
143
+ const info = fenceMatch[2]?.trim() ?? ''
144
+ fence = {
145
+ char: fenceMatch[1]?.[0] ?? '`',
146
+ length: fenceMatch[1]?.length ?? 3,
147
+ structured: info === '' || info === 'text',
148
+ }
149
+ group = []
150
+ continue
151
+ }
152
+
153
+ const listDecl = line.match(LIST_DECLARATION_RE)
154
+ if (listDecl) {
155
+ const rest = listDecl[2] ?? ''
156
+ const glossed = rest.trim() !== ''
157
+ for (const m of (listDecl[1] ?? '').matchAll(BOLD_TOKEN_RE)) {
158
+ const token = m[1] ?? ''
159
+ if (isField(token)) declarations.push({ token, line: lineNo, glossed })
160
+ }
161
+ // The text after a list item's bold lead is that declaration's gloss AND prose: a code
162
+ // span in it mandates like any other (it is scanned the same as a full prose line).
163
+ scanProse(rest, lineNo)
164
+ continue
165
+ }
166
+
167
+ // Prose.
168
+ scanProse(line, lineNo)
169
+ }
170
+
171
+ return { declarations, mandates, proseWords }
172
+ }
173
+
174
+ // ── Discovery ──
175
+
176
+ function walk(dir: string, out: string[]): void {
177
+ let entries: import('node:fs').Dirent[]
178
+ try {
179
+ entries = readdirSync(dir, { withFileTypes: true })
180
+ } catch {
181
+ return
182
+ }
183
+ for (const e of entries) {
184
+ const full = join(dir, e.name)
185
+ if (e.isDirectory()) walk(full, out)
186
+ else if (e.isFile() && e.name.endsWith('.md')) out.push(full)
187
+ }
188
+ }
189
+
190
+ /** Is this root-relative path a skill (`plugins/<p>/skills/**\/SKILL.md`) or agent (`plugins/<p>/agents/*.md`)? */
191
+ export function isDefinitionPath(rel: string): boolean {
192
+ const parts = rel.split('/')
193
+ if (parts[0] !== 'plugins' || parts.length < 4) return false
194
+ if (parts[2] === 'agents') return parts.length === 4 && (parts[3] ?? '').endsWith('.md')
195
+ if (parts[2] === 'skills') return parts.length >= 5 && parts[parts.length - 1] === 'SKILL.md'
196
+ return false
197
+ }
198
+
199
+ /** Every definition under `root`, root-relative, `/`-separated, path-sorted. */
200
+ export function discoverDefinitions(root: string): string[] {
201
+ const pluginsDir = join(root, 'plugins')
202
+ if (!existsSync(pluginsDir)) return []
203
+ const files: string[] = []
204
+ walk(pluginsDir, files)
205
+ return files
206
+ .map((f) => relative(root, f).split(sep).join('/'))
207
+ .filter(isDefinitionPath)
208
+ .sort()
209
+ }
210
+
211
+ // ── The check ──
212
+
213
+ export function check(parsed: Map<string, ParsedDefinition>): Finding[] {
214
+ const known = new Set<string>()
215
+ for (const p of parsed.values()) for (const d of p.declarations) known.add(d.token)
216
+
217
+ const findings: Finding[] = []
218
+ for (const [file, p] of parsed) {
219
+ if (p.declarations.length === 0) continue
220
+ const declared = new Set(p.declarations.map((d) => d.token))
221
+
222
+ const seen = new Set<string>()
223
+ for (const d of p.declarations) {
224
+ if (seen.has(d.token)) continue
225
+ seen.add(d.token)
226
+ const glossed = p.declarations.some((o) => o.token === d.token && o.glossed)
227
+ if (!glossed && !hasSameField(p.proseWords, d.token)) {
228
+ findings.push({ kind: 'unexplained', file, line: d.line, token: d.token })
229
+ }
230
+ }
231
+
232
+ const reported = new Set<string>()
233
+ for (const m of p.mandates) {
234
+ if (!hasSameField(known, m.token) || hasSameField(declared, m.token)) continue
235
+ const key = `${m.line}:${m.token}`
236
+ if (reported.has(key)) continue
237
+ reported.add(key)
238
+ findings.push({ kind: 'undeclared', file, line: m.line, token: m.token })
239
+ }
240
+ }
241
+ return findings.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1))
242
+ }
243
+
244
+ // ── Report ──
245
+
246
+ const DETAIL: Record<FindingKind, string> = {
247
+ unexplained: 'declared in a block, but no gloss and no prose explains it',
248
+ undeclared: 'mandated in the prose, but no block in this file declares it',
249
+ }
250
+
251
+ export function formatReport(definitions: string[], findings: Finding[]): string {
252
+ if (definitions.length === 0) return 'check-field-mandates: no skill or agent definition found\n'
253
+ if (findings.length === 0) return `check-field-mandates: ${definitions.length} definition(s) OK\n`
254
+ const rows = findings
255
+ .map((f) => ` ${f.kind.padEnd(11)} ${f.file}:${f.line} ${f.token} — ${DETAIL[f.kind]}`)
256
+ .join('\n')
257
+ return `${rows}\ncheck-field-mandates: ${findings.length} finding(s) across ${definitions.length} definition(s)\n`
258
+ }
259
+
260
+ // ── CLI ──
261
+
262
+ function isReadableDirectory(path: string): boolean {
263
+ try {
264
+ if (!statSync(path).isDirectory()) return false
265
+ readdirSync(path)
266
+ return true
267
+ } catch {
268
+ return false
269
+ }
270
+ }
271
+
272
+ export function main(argv: string[]): number {
273
+ let root = '.'
274
+ for (let i = 0; i < argv.length; i++) {
275
+ const a = argv[i]
276
+ if (a === '--root') {
277
+ const next = argv[++i]
278
+ if (next === undefined) {
279
+ process.stderr.write('check-field-mandates: --root needs a directory\n')
280
+ return 1
281
+ }
282
+ root = next
283
+ continue
284
+ }
285
+ process.stderr.write(`check-field-mandates: unrecognized flag ${a}\n`)
286
+ return 1
287
+ }
288
+ if (!isReadableDirectory(root)) {
289
+ process.stderr.write(`check-field-mandates: root ${root} is not a readable directory\n`)
290
+ return 1
291
+ }
292
+ const definitions = discoverDefinitions(root)
293
+ const parsed = new Map<string, ParsedDefinition>()
294
+ for (const rel of definitions) parsed.set(rel, parseDefinition(readFileSync(join(root, rel), 'utf8')))
295
+ const findings = check(parsed)
296
+ process.stdout.write(formatReport(definitions, findings))
297
+ return findings.length === 0 ? 0 : 1
298
+ }
299
+
300
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
301
+ process.exit(main(process.argv.slice(2)))
302
+ }
@@ -14,14 +14,14 @@ and is copied into every generated vendor manifest — it fails only on an insta
14
14
  publish, as a component the host runtime cannot load. Nothing else in the repo compares a manifest's
15
15
  pointers against what ships.
16
16
 
17
- Spec: [`.agents/specs/sdd/plugin/check-plugin-manifests/`](../../../../.agents/specs/sdd/plugin/check-plugin-manifests/README.md).
18
-
19
17
  ## What it checks
20
18
 
21
- Two sub-checks, neither subsuming the other:
19
+ Three sub-checks:
22
20
 
23
21
  - **the disk check** — does the pointer resolve to a path that exists?
24
22
  - **the publish check** — for a package that publishes, is the pointer inside its `files` allowlist?
23
+ - **the pack check** — for a package that is not `private`, does the manifest or a component it
24
+ declares ship as (or hold) a symbolic link? The npm registry rejects such a tarball outright.
25
25
 
26
26
  A directory can exist and be excluded from the tarball; a `files` entry can name a directory nobody
27
27
  created. The disk check **short-circuits**: a pointer dead on disk is reported once, as unresolved,
@@ -9,6 +9,8 @@
9
9
  // Two sub-checks, neither subsuming the other (spec: .agents/specs/sdd/plugin/check-plugin-manifests/):
10
10
  // disk — does the pointer resolve to a path that exists?
11
11
  // publish — for a package that publishes, is the pointer inside its `files` allowlist?
12
+ // pack — for a package that packs a tarball, does it ship a symbolic link? npm's registry
13
+ // rejects the whole package ("Symbolic link is not allowed"), so it never arrives.
12
14
  // A directory can exist and be excluded from the tarball; a `files` entry can name a directory
13
15
  // nobody created. The disk check short-circuits: a pointer dead on disk is reported once, as
14
16
  // unresolved, and is not also asked about `files`.
@@ -20,7 +22,7 @@
20
22
  // Pure functions are exported for node:test; running the file directly drives the CLI. No
21
23
  // dependencies (the repo's node-≥23.6 / no-deps convention).
22
24
 
23
- import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
25
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
24
26
  import { dirname, join, relative, sep } from 'node:path'
25
27
  import { pathToFileURL } from 'node:url'
26
28
 
@@ -36,7 +38,7 @@ const MANIFEST_DIRS = ['.plugin', '.claude-plugin', '.codex-plugin']
36
38
  const MANIFEST_NAME = 'plugin.json'
37
39
  const SKIP_DIRS = new Set(['node_modules', '.git'])
38
40
 
39
- export type FindingKind = 'unreadable' | 'unresolved' | 'unpublished'
41
+ export type FindingKind = 'unreadable' | 'unresolved' | 'unpublished' | 'symlink'
40
42
 
41
43
  export interface Finding {
42
44
  kind: FindingKind
@@ -127,6 +129,8 @@ export function extractPointers(manifest: unknown): Array<{ key: string; pointer
127
129
 
128
130
  export interface OwningPackage {
129
131
  path: string
132
+ /** Not `private` — the package packs a tarball, whether or not it declares `files`. */
133
+ packs: boolean
130
134
  publishes: boolean
131
135
  files: string[]
132
136
  }
@@ -148,9 +152,10 @@ export function owningPackage(root: string, pluginRoot: string): OwningPackage |
148
152
  try {
149
153
  const pkg = JSON.parse(readFileSync(abs, 'utf8')) as { private?: unknown; files?: unknown }
150
154
  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 ?? [] }
155
+ const packs = pkg.private !== true
156
+ return { path: candidate, packs, publishes: packs && files !== null, files: files ?? [] }
152
157
  } catch {
153
- return { path: candidate, publishes: false, files: [] }
158
+ return { path: candidate, packs: false, publishes: false, files: [] }
154
159
  }
155
160
  }
156
161
  if (dir === '') return null
@@ -180,10 +185,47 @@ export function filesCovers(files: string[], pointer: string): boolean {
180
185
  })
181
186
  }
182
187
 
188
+ // ── The pack sub-check ──
189
+
190
+ /** Whether a package that packs a tarball ships the package-relative path `./<rel>`. */
191
+ function ships(pkg: OwningPackage | null, rel: string): pkg is OwningPackage {
192
+ return pkg?.packs === true && (!pkg.publishes || filesCovers(pkg.files, `./${rel}`))
193
+ }
194
+
195
+ /** Every symbolic link at or below `abs` — the path itself included, links never followed. */
196
+ export function findSymlinks(abs: string): string[] {
197
+ let stat: ReturnType<typeof lstatSync>
198
+ try {
199
+ stat = lstatSync(abs)
200
+ } catch {
201
+ return []
202
+ }
203
+ if (stat.isSymbolicLink()) return [abs]
204
+ if (!stat.isDirectory()) return []
205
+ return safeReaddir(abs)
206
+ .filter((e) => !SKIP_DIRS.has(e.name))
207
+ .flatMap((e) => findSymlinks(join(abs, e.name)))
208
+ }
209
+
183
210
  // ── The sweep ──
184
211
 
185
212
  export function sweep(root: string, manifests: string[]): Finding[] {
186
213
  const findings: Finding[] = []
214
+ // One link reported once, however many manifests ship it.
215
+ const linksSeen = new Set<string>()
216
+ const reportLinks = (rel: string, abs: string, key?: string, pointer?: string): void => {
217
+ for (const link of findSymlinks(abs)) {
218
+ const linkRel = relative(root, link).split(sep).join('/')
219
+ if (linksSeen.has(linkRel)) continue
220
+ linksSeen.add(linkRel)
221
+ findings.push({
222
+ kind: 'symlink',
223
+ manifest: rel,
224
+ ...(key === undefined ? {} : { key, pointer }),
225
+ detail: `${linkRel} is a symbolic link — the registry rejects a package that ships one`,
226
+ })
227
+ }
228
+ }
187
229
  for (const rel of manifests) {
188
230
  let parsed: unknown
189
231
  try {
@@ -194,6 +236,9 @@ export function sweep(root: string, manifests: string[]): Finding[] {
194
236
  }
195
237
  const pluginRoot = pluginRootOf(rel)
196
238
  const pkg = owningPackage(root, pluginRoot)
239
+ const pkgDir = pkg ? dirname(pkg.path) : ''
240
+ const fromPkg = (path: string): string => relative(join(root, pkgDir), join(root, path)).split(sep).join('/')
241
+ if (ships(pkg, fromPkg(rel))) reportLinks(rel, join(root, rel))
197
242
  for (const { key, pointer } of extractPointers(parsed)) {
198
243
  const target = join(root, pluginRoot, pointer)
199
244
  if (!existsSync(target)) {
@@ -208,7 +253,9 @@ export function sweep(root: string, manifests: string[]): Finding[] {
208
253
  pointer,
209
254
  detail: `declared but not published — ${pkg.path} files omits it`,
210
255
  })
256
+ continue
211
257
  }
258
+ if (ships(pkg, fromPkg(join(pluginRoot, pointer)))) reportLinks(rel, target, key, pointer)
212
259
  }
213
260
  }
214
261
  return findings
@@ -16,7 +16,7 @@ declares its own pass.
16
16
  ## Inputs (folded in by the conductor)
17
17
 
18
18
  ```
19
- DOMAIN, DOMAIN_PATH, SPEC_PATH, FEATURE_PATH, SOLUTION_PATH
19
+ DOMAIN, DOMAIN_PATH, SPEC_PATH, FEATURE_PATH, SOLUTION_PATH: the domain under work, its spec folder, its spec.md, its .feature, and its <unit>.solution.md
20
20
  MODE: explore | implement
21
21
  ```
22
22
 
@@ -71,6 +71,7 @@ rule governing the artifact · account for provenance, where a regression stops
71
71
  ```
72
72
  REMEDIATION: <per finding answered: verdict, rule, swept, ruled-out, provenance — `sdd:remediation-governance`; omit when no verdict was answered>
73
73
  STATUS: complete | needs-input | blocked
74
+ BLOCKER: <what blocked it — the behavior the frozen contract omits — when STATUS is blocked, else null>
74
75
  ARTIFACTS_WRITTEN: [ paths ]
75
76
  VERIFICATION_WRITTEN: [ paths ] # one per frozen scenario, each with its level + why
76
77
  CHANGES_MADE: <what was built>
@@ -15,7 +15,7 @@ Load alongside this governance: the resolved **architect** actor bar (structural
15
15
  ## Inputs (folded in by the conductor)
16
16
 
17
17
  ```
18
- DOMAIN, DOMAIN_PATH, SPEC_PATH, FEATURE_PATH, SOLUTION_PATH
18
+ DOMAIN, DOMAIN_PATH, SPEC_PATH, FEATURE_PATH, SOLUTION_PATH: the domain under work, its spec folder, its spec.md, its .feature, and its <unit>.solution.md
19
19
  MODE: explore | implement
20
20
  EXISTING_SOLUTION: <the current <unit>.solution.md, on a revise — or null>
21
21
  ```
@@ -15,7 +15,7 @@ Load alongside this governance: `sdd:spec-format-governance` (the required `## U
15
15
  ## Inputs (folded in by the conductor)
16
16
 
17
17
  ```
18
- DOMAIN, DOMAIN_PATH, SPEC_PATH
18
+ DOMAIN, DOMAIN_PATH, SPEC_PATH: the domain under work, its spec folder, and its spec.md
19
19
  COMMAND_SURFACE: <command syntax / signatures / events — or null>
20
20
  DESIGN_DECISIONS: <known choices — or null>
21
21
  USER_INPUT: <What / Why / command surface for a new feature — or null>