cyber-sdd 0.4.2 → 0.6.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.6.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.6.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.6.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.6.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,61 @@
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
+ - **miscased** — the prose names, in a code span, a field this file's block declares, but spelled
25
+ in the other case (`governances_loaded` against `GOVERNANCES_LOADED`).
26
+
27
+ **A field token** is `UPPER_CASE` or `snake_case`; the two spellings name the same field. A lowercase
28
+ word needs an underscore to be a token, so ordinary prose (`owner`) is never a field.
29
+
30
+ **A structured block** is a fenced code block with no info string (or `text`). A **declaration** is a
31
+ block line opening with field tokens then a colon (`STATUS: complete | blocked`), a comma list of two
32
+ or more tokens (`SPEC_PATH, FEATURE_PATH`), or a list item opening with bold tokens
33
+ (`- **SUBJECT** — …`). A **gloss** is the text after the colon or the bold lead, through to the next
34
+ declaration.
35
+
36
+ **A field is explained by a gloss or by the prose.** A glossed declaration already says what it
37
+ carries; the rule catches the **bare** one nothing explains.
38
+
39
+ **A mandate is a code span naming a known field** — a token some definition in the tree declares.
40
+ That vocabulary separates a field from an uppercase literal such as `TODO`. A definition that
41
+ declares no field at all is a consumer, and its prose is not held to a block.
42
+
43
+ **Naming another agent's field on purpose** — a conductor describing what it passes a judge — is
44
+ marked beside the line, with its reason:
45
+
46
+ ```markdown
47
+ Pass the zero-based `ROW` to the case judge. <!-- field-mandate-ignore: the case judge's input, not ours -->
48
+ ```
49
+
50
+ A marker with no reason excuses nothing. There is no allow-list file: a real mismatch is fixed.
51
+
52
+ ## Run it
53
+
54
+ ```bash
55
+ node "<skill>/scripts/check-field-mandates.mts" [--root <dir>]
56
+ ```
57
+
58
+ `--root` defaults to the working directory. A finding always exits non-zero, as does a root that is
59
+ not a readable directory and an unrecognized flag — there is deliberately no report-only mode.
60
+
61
+ It joins the root check chain as **`check:fields`**, so it runs on every `pnpm verify` and in CI.
@@ -0,0 +1,337 @@
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
+ // miscased — the prose mandates a field this file declares, but spelled in the other case
11
+ //
12
+ // A field is explained by a gloss OR by the prose — requiring the prose to repeat a glossed
13
+ // declaration would demand filler in every definition. A mandate is a code span naming a KNOWN field
14
+ // (declared somewhere in the tree); that vocabulary is what separates a field from `TODO`.
15
+ //
16
+ // A field token is UPPER_CASE or snake_case. A lowercase word needs an underscore to be a token, so
17
+ // ordinary prose words (`owner`, `summary`) never become fields. Case decides whether two tokens are the
18
+ // same field only for the miscased finding: `governances_loaded` and `GOVERNANCES_LOADED` are one
19
+ // field, spelled two ways, and a file that does both is reported.
20
+ //
21
+ // Pure functions are exported for node:test; running the file directly drives the CLI. No
22
+ // dependencies (the repo's node-≥23.6 / no-deps convention).
23
+
24
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
25
+ import { join, relative, sep } from 'node:path'
26
+ import { pathToFileURL } from 'node:url'
27
+
28
+ export type FindingKind = 'unexplained' | 'undeclared' | 'miscased'
29
+
30
+ export interface Finding {
31
+ kind: FindingKind
32
+ /** Root-relative path of the definition, `/`-separated. */
33
+ file: string
34
+ /** 1-based line: the declaration for `unexplained`, the prose line for `undeclared`. */
35
+ line: number
36
+ token: string
37
+ /** For `miscased`: the spelling this file's declaration uses. */
38
+ declared?: string
39
+ }
40
+
41
+ interface Declaration {
42
+ token: string
43
+ line: number
44
+ glossed: boolean
45
+ }
46
+
47
+ interface Mandate {
48
+ token: string
49
+ line: number
50
+ }
51
+
52
+ export interface ParsedDefinition {
53
+ declarations: Declaration[]
54
+ mandates: Mandate[]
55
+ /** Every field-shaped word the prose names, code span or not. */
56
+ proseWords: Set<string>
57
+ }
58
+
59
+ /** UPPER_CASE, or snake_case with at least one underscore — a bare lowercase word is prose. */
60
+ const TOKEN = '(?:[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*|[a-z][a-z0-9]*(?:_[a-z0-9]+)+)'
61
+ const TOKEN_ITEM = `${TOKEN}(?:\\(s\\))?`
62
+ /** A block line opening with a comma list of tokens, then a colon, a trailing comment, or nothing. */
63
+ const BLOCK_DECLARATION_RE = new RegExp(`^\\s*(${TOKEN_ITEM}(?:\\s*,\\s*${TOKEN_ITEM})*)\\s*(:.*|#.*)?$`)
64
+ /** A list item opening with one or more bold tokens joined by `+`. */
65
+ const LIST_DECLARATION_RE = new RegExp(`^\\s*[-*+]\\s+(\\*\\*${TOKEN}\\*\\*(?:\\s*\\+\\s*\\*\\*${TOKEN}\\*\\*)*)(.*)$`)
66
+ const BOLD_TOKEN_RE = new RegExp(`\\*\\*(${TOKEN})\\*\\*`, 'g')
67
+ const WORD_RE = new RegExp(`(?<![A-Za-z0-9_])${TOKEN}(?![A-Za-z0-9_])`, 'g')
68
+ const CODE_SPAN_RE = /`([^`\n]+)`/g
69
+ const MANDATE_LEAD_RE = new RegExp(`^<?(${TOKEN})(?=$|[\\s.:\\[\\]>(,=])`)
70
+ const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/
71
+ /** The marker must carry a reason — a bare marker excuses nothing. */
72
+ const IGNORE_MARKER_RE = /<!--\s*field-mandate-ignore\s*:\s*[^\s>-][^>]*-->/
73
+
74
+ /** A field token is two or more characters: a lone capital is a word, not a field. */
75
+ function isField(token: string): boolean {
76
+ return token.length >= 2
77
+ }
78
+
79
+ /** A token's case-folded form: the spelling-independent name of the field. */
80
+ function fold(token: string): string {
81
+ return token.toUpperCase()
82
+ }
83
+
84
+ /** The folded forms a token may take and still be the same field — equal, or one trailing `S` apart. */
85
+ function sameFieldForms(token: string): string[] {
86
+ const t = fold(token)
87
+ const forms = [t, `${t}S`]
88
+ if (t.endsWith('S') && t.length > 2) forms.push(t.slice(0, -1))
89
+ return forms
90
+ }
91
+
92
+ /** Does the set of folded tokens hold the same field as `token`? */
93
+ function hasSameField(folded: Set<string>, token: string): boolean {
94
+ return sameFieldForms(token).some((f) => folded.has(f))
95
+ }
96
+
97
+ /** Is the token spelled lowercase (snake_case) rather than uppercase? */
98
+ function isLower(token: string): boolean {
99
+ return token !== fold(token)
100
+ }
101
+
102
+ // ── Parse ──
103
+
104
+ export function parseDefinition(text: string): ParsedDefinition {
105
+ const lines = text.split(/\r?\n/)
106
+ const declarations: Declaration[] = []
107
+ const mandates: Mandate[] = []
108
+ const proseWords = new Set<string>()
109
+
110
+ let i = 0
111
+ // YAML frontmatter is metadata, not prose.
112
+ if (lines[0]?.trim() === '---') {
113
+ const end = lines.findIndex((l, n) => n > 0 && l.trim() === '---')
114
+ if (end > 0) i = end + 1
115
+ }
116
+
117
+ let fence: { char: string; length: number; structured: boolean } | null = null
118
+ // The declarations a continuation line glosses: the most recent group in the open block.
119
+ let group: Declaration[] = []
120
+
121
+ /** Record a span of text as prose: its field-shaped words, and any code-span mandate. */
122
+ function scanProse(text: string, lineNo: number): void {
123
+ for (const m of text.matchAll(WORD_RE)) if (isField(m[0])) proseWords.add(m[0])
124
+ if (IGNORE_MARKER_RE.test(text)) return
125
+ for (const m of text.matchAll(CODE_SPAN_RE)) {
126
+ const lead = (m[1] ?? '').trim().match(MANDATE_LEAD_RE)
127
+ const token = lead?.[1]
128
+ if (token && isField(token)) mandates.push({ token, line: lineNo })
129
+ }
130
+ }
131
+
132
+ for (; i < lines.length; i++) {
133
+ const line = lines[i] ?? ''
134
+ const lineNo = i + 1
135
+ const fenceMatch = line.match(FENCE_RE)
136
+
137
+ if (fence) {
138
+ if (
139
+ fenceMatch &&
140
+ fenceMatch[1]?.[0] === fence.char &&
141
+ (fenceMatch[1]?.length ?? 0) >= fence.length &&
142
+ fenceMatch[2]?.trim() === ''
143
+ ) {
144
+ fence = null
145
+ group = []
146
+ continue
147
+ }
148
+ if (!fence.structured) continue
149
+ const decl = line.match(BLOCK_DECLARATION_RE)
150
+ const tokens = decl ? (decl[1] ?? '').split(',').map((t) => t.trim().replace(/\(s\)$/, '')) : []
151
+ const rest = decl?.[2]
152
+ const isDeclaration = decl !== null && (rest?.startsWith(':') || tokens.length >= 2)
153
+ if (isDeclaration) {
154
+ const glossed = rest?.startsWith(':') === true && rest.slice(1).trim() !== ''
155
+ group = tokens.filter(isField).map((token) => ({ token, line: lineNo, glossed }))
156
+ declarations.push(...group)
157
+ } else if (line.trim() !== '') {
158
+ for (const d of group) d.glossed = true
159
+ }
160
+ continue
161
+ }
162
+
163
+ if (fenceMatch) {
164
+ const info = fenceMatch[2]?.trim() ?? ''
165
+ fence = {
166
+ char: fenceMatch[1]?.[0] ?? '`',
167
+ length: fenceMatch[1]?.length ?? 3,
168
+ structured: info === '' || info === 'text',
169
+ }
170
+ group = []
171
+ continue
172
+ }
173
+
174
+ const listDecl = line.match(LIST_DECLARATION_RE)
175
+ if (listDecl) {
176
+ const rest = listDecl[2] ?? ''
177
+ const glossed = rest.trim() !== ''
178
+ for (const m of (listDecl[1] ?? '').matchAll(BOLD_TOKEN_RE)) {
179
+ const token = m[1] ?? ''
180
+ if (isField(token)) declarations.push({ token, line: lineNo, glossed })
181
+ }
182
+ // The text after a list item's bold lead is that declaration's gloss AND prose: a code
183
+ // span in it mandates like any other (it is scanned the same as a full prose line).
184
+ scanProse(rest, lineNo)
185
+ continue
186
+ }
187
+
188
+ // Prose.
189
+ scanProse(line, lineNo)
190
+ }
191
+
192
+ return { declarations, mandates, proseWords }
193
+ }
194
+
195
+ // ── Discovery ──
196
+
197
+ function walk(dir: string, out: string[]): void {
198
+ let entries: import('node:fs').Dirent[]
199
+ try {
200
+ entries = readdirSync(dir, { withFileTypes: true })
201
+ } catch {
202
+ return
203
+ }
204
+ for (const e of entries) {
205
+ const full = join(dir, e.name)
206
+ if (e.isDirectory()) walk(full, out)
207
+ else if (e.isFile() && e.name.endsWith('.md')) out.push(full)
208
+ }
209
+ }
210
+
211
+ /** Is this root-relative path a skill (`plugins/<p>/skills/**\/SKILL.md`) or agent (`plugins/<p>/agents/*.md`)? */
212
+ export function isDefinitionPath(rel: string): boolean {
213
+ const parts = rel.split('/')
214
+ if (parts[0] !== 'plugins' || parts.length < 4) return false
215
+ if (parts[2] === 'agents') return parts.length === 4 && (parts[3] ?? '').endsWith('.md')
216
+ if (parts[2] === 'skills') return parts.length >= 5 && parts[parts.length - 1] === 'SKILL.md'
217
+ return false
218
+ }
219
+
220
+ /** Every definition under `root`, root-relative, `/`-separated, path-sorted. */
221
+ export function discoverDefinitions(root: string): string[] {
222
+ const pluginsDir = join(root, 'plugins')
223
+ if (!existsSync(pluginsDir)) return []
224
+ const files: string[] = []
225
+ walk(pluginsDir, files)
226
+ return files
227
+ .map((f) => relative(root, f).split(sep).join('/'))
228
+ .filter(isDefinitionPath)
229
+ .sort()
230
+ }
231
+
232
+ // ── The check ──
233
+
234
+ export function check(parsed: Map<string, ParsedDefinition>): Finding[] {
235
+ const known = new Set<string>()
236
+ for (const p of parsed.values()) for (const d of p.declarations) known.add(fold(d.token))
237
+
238
+ const findings: Finding[] = []
239
+ for (const [file, p] of parsed) {
240
+ if (p.declarations.length === 0) continue
241
+ const declared = new Set(p.declarations.map((d) => fold(d.token)))
242
+ const proseWords = new Set([...p.proseWords].map(fold))
243
+
244
+ const seen = new Set<string>()
245
+ for (const d of p.declarations) {
246
+ if (seen.has(d.token)) continue
247
+ seen.add(d.token)
248
+ const glossed = p.declarations.some((o) => o.token === d.token && o.glossed)
249
+ if (!glossed && !hasSameField(proseWords, d.token)) {
250
+ findings.push({ kind: 'unexplained', file, line: d.line, token: d.token })
251
+ }
252
+ }
253
+
254
+ const reported = new Set<string>()
255
+ for (const m of p.mandates) {
256
+ if (!hasSameField(known, m.token)) continue
257
+ const key = `${m.line}:${m.token}`
258
+ if (reported.has(key)) continue
259
+ if (!hasSameField(declared, m.token)) {
260
+ reported.add(key)
261
+ findings.push({ kind: 'undeclared', file, line: m.line, token: m.token })
262
+ continue
263
+ }
264
+ // Declared here — but is any same-field declaration spelled in the mandate's case?
265
+ const forms = sameFieldForms(m.token)
266
+ const same = p.declarations.filter((d) => forms.includes(fold(d.token)))
267
+ if (same.some((d) => isLower(d.token) === isLower(m.token))) continue
268
+ reported.add(key)
269
+ findings.push({ kind: 'miscased', file, line: m.line, token: m.token, declared: same[0]?.token })
270
+ }
271
+ }
272
+ return findings.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1))
273
+ }
274
+
275
+ // ── Report ──
276
+
277
+ const DETAIL: Record<FindingKind, string> = {
278
+ unexplained: 'declared in a block, but no gloss and no prose explains it',
279
+ undeclared: 'mandated in the prose, but no block in this file declares it',
280
+ miscased: 'mandated in the prose, but this file declares it in the other case',
281
+ }
282
+
283
+ export function formatReport(definitions: string[], findings: Finding[]): string {
284
+ if (definitions.length === 0) return 'check-field-mandates: no skill or agent definition found\n'
285
+ if (findings.length === 0) return `check-field-mandates: ${definitions.length} definition(s) OK\n`
286
+ const rows = findings
287
+ .map(
288
+ (f) =>
289
+ ` ${f.kind.padEnd(11)} ${f.file}:${f.line} ${f.token} — ${DETAIL[f.kind]}${f.declared ? ` (${f.declared})` : ''}`,
290
+ )
291
+ .join('\n')
292
+ return `${rows}\ncheck-field-mandates: ${findings.length} finding(s) across ${definitions.length} definition(s)\n`
293
+ }
294
+
295
+ // ── CLI ──
296
+
297
+ function isReadableDirectory(path: string): boolean {
298
+ try {
299
+ if (!statSync(path).isDirectory()) return false
300
+ readdirSync(path)
301
+ return true
302
+ } catch {
303
+ return false
304
+ }
305
+ }
306
+
307
+ export function main(argv: string[]): number {
308
+ let root = '.'
309
+ for (let i = 0; i < argv.length; i++) {
310
+ const a = argv[i]
311
+ if (a === '--root') {
312
+ const next = argv[++i]
313
+ if (next === undefined) {
314
+ process.stderr.write('check-field-mandates: --root needs a directory\n')
315
+ return 1
316
+ }
317
+ root = next
318
+ continue
319
+ }
320
+ process.stderr.write(`check-field-mandates: unrecognized flag ${a}\n`)
321
+ return 1
322
+ }
323
+ if (!isReadableDirectory(root)) {
324
+ process.stderr.write(`check-field-mandates: root ${root} is not a readable directory\n`)
325
+ return 1
326
+ }
327
+ const definitions = discoverDefinitions(root)
328
+ const parsed = new Map<string, ParsedDefinition>()
329
+ for (const rel of definitions) parsed.set(rel, parseDefinition(readFileSync(join(root, rel), 'utf8')))
330
+ const findings = check(parsed)
331
+ process.stdout.write(formatReport(definitions, findings))
332
+ return findings.length === 0 ? 0 : 1
333
+ }
334
+
335
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
336
+ process.exit(main(process.argv.slice(2)))
337
+ }
@@ -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>
@@ -83,7 +83,7 @@ REMEDIATION: <per finding answered: verdict, rule, swept, ruled-out, prove
83
83
  STATUS: complete | needs-input | blocked
84
84
  SCENARIOS_WRITTEN: <count>
85
85
  NOTES: <what was written / revised>
86
- GOVERNANCES_LOADED: [ every governance name loaded before writing — required, [] when none, never written into spec.md or the .feature ]
86
+ governances_loaded: [ every governance name loaded before writing — required, [] when none, never written into spec.md or the .feature ]
87
87
  QUESTIONS: [ batched, when needs-input ]
88
88
  CONTENT_GAPS: [ { artifact, location, gap } ] # become <!-- open: --> markers
89
89
  OBSERVATIONS: [ { owner: architect | strategist, note, evidence } ]