cyber-sdd 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,422 @@
1
+ #!/usr/bin/env node
2
+ // check-spec-references — resolve every explicitly-relative reference a project spec's .md files
3
+ // carry, and report the ones that resolve to nothing.
4
+ //
5
+ // The recurrence this closes was not a typo but a CONSISTENT OFF-BY-ONE: every `../../../src/…`
6
+ // reference in a spec corpus was one directory level short, and every one of them read as entirely
7
+ // plausible — right filename, right-looking depth, wrong level. Re-reading a reference by eye is
8
+ // not a check on it; only resolving it is. So the finding names BOTH the reference as written and
9
+ // the path it actually resolved to — the second is the half review cannot supply.
10
+ //
11
+ // Scope is deliberately narrow. Only an EXPLICITLY-RELATIVE path (`./` or `../`) is a reference; a
12
+ // bare path in inline code is prose. A spec corpus is full of bare paths that are illustrative or
13
+ // relative to somewhere other than the repo (`cli/`, `skills/doctor/`, `.claude/skills`,
14
+ // `~/.codex/config.toml`), and resolving those would reject nearly all of them. The prefix rule is
15
+ // what makes the repo-root-relative case (`.research/agentic-configuration-standards/`) pass by
16
+ // construction rather than by exception.
17
+ //
18
+ // There is exactly ONE escape hatch, deliberately. A second, implicit one was built and then cut:
19
+ // excluding whatever a doubled-backtick span holds, on the theory that such a span exhibits markup
20
+ // rather than citing it. It was unconditional, so a genuinely broken reference written that way
21
+ // escaped silently — reproducing, inside the exclusion, the exact "looks anchored, isn't" failure
22
+ // this engine exists to close. An escape must be explicit, reasoned, and visible where it applies.
23
+ //
24
+ // One false-positive class is genuine and recurs: prose QUOTING a path relative to something other
25
+ // than the file it sits in — the text held inside a bridge file, a symlink target relative to
26
+ // `.cursor/`. That is structurally indistinguishable from a real anchor, so an inline
27
+ // `<!-- spec-ref-ignore: why -->` marker suppresses its own line, keeping the justification beside
28
+ // the prose it excuses instead of in a registry that drifts away from what it covers.
29
+ //
30
+ // Pure functions are exported for node:test; running the file directly drives the CLI. No
31
+ // dependencies (the repo's node-≥23.6 / no-deps convention).
32
+
33
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
34
+ import { dirname, join, relative, resolve } from 'node:path'
35
+ import { pathToFileURL } from 'node:url'
36
+
37
+ // ── Extraction ──
38
+
39
+ /** A reference as written, and the 1-based line it sits on. */
40
+ export interface Reference {
41
+ line: number
42
+ ref: string
43
+ }
44
+
45
+ /** Suppresses every reference on the line it appears on. Matched as a COMPLETE html comment, not
46
+ * as a substring: a marker whose name merely starts with this one's (a typo, a future sibling)
47
+ * must not silently inherit its power to hide a reference. The optional `: reason` is convention,
48
+ * not syntax — the marker is recognized with or without one. */
49
+ const IGNORE_MARKER_RE = /<!--\s*spec-ref-ignore\s*(?::[^>]*)?-->/
50
+
51
+ /** A markdown inline link whose target is explicitly relative — covering the plain form, an
52
+ * angle-bracket-wrapped target, and an optional title in any of markdown's three quotings. Run only
53
+ * over the parts of a line that are NOT inside a code span: inside one, markup is literal text on
54
+ * display, not a link. */
55
+ const LINK_RE = /\]\(\s*(?:<([^>\n]*)>|(\.{1,2}\/[^)\s]*))(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g
56
+
57
+ /** A reference-style link definition (`[label]: ../path`) whose target is explicitly relative. It
58
+ * is a link target like any other — the label form changes where the path is written, not what it
59
+ * points at. */
60
+ const LINK_DEF_RE = /^\s{0,3}\[.+\]:\s*(?:<([^>\n]*)>|(\S+))/
61
+
62
+ /** An explicitly-relative path, and nothing else. The trailing class excludes whitespace and
63
+ * backticks so that "the span's WHOLE content is the path" means what it says: a span holding
64
+ * `../a` plus commentary is prose that starts with a path, not a citation. */
65
+ const RELATIVE_RE = /^\.{1,2}\/[^\s`]*$/
66
+
67
+ /** The same, minus the no-whitespace rule — for an ANGLE-BRACKET link target, whose whole reason
68
+ * for existing is to carry a path with a space in it. */
69
+ const RELATIVE_IN_ANGLES_RE = /^\.{1,2}\/[^`]*$/
70
+
71
+ /** An open fence, remembered so the close can be matched against it. */
72
+ interface Fence {
73
+ char: '`' | '~'
74
+ length: number
75
+ }
76
+
77
+ /**
78
+ * The fence a line opens, or `undefined`. CommonMark: three or more backticks or tildes, indented
79
+ * fewer than four spaces.
80
+ */
81
+ function fenceOpenedBy(line: string): Fence | undefined {
82
+ const m = /^ {0,3}(`{3,}|~{3,})/.exec(line)
83
+ if (m === null) return undefined
84
+ const run = m[1] as string
85
+ return { char: run[0] as '`' | '~', length: run.length }
86
+ }
87
+
88
+ /**
89
+ * Whether `line` CLOSES `fence`: the same character, at least as long, and nothing but whitespace
90
+ * after it.
91
+ *
92
+ * Matching the delimiter rather than toggling a boolean on any fence-shaped line is what keeps the
93
+ * exclusion honest. A bare toggle flips on a `~~~` line inside a backtick fence — so a reference
94
+ * genuinely inside a fence gets extracted, and, worse, a fence-shaped line inside another fence
95
+ * leaves the parity inverted and every genuinely broken reference AFTER the block is silently
96
+ * swallowed. That is this engine's own failure class, reached through the one rule meant to
97
+ * suppress noise.
98
+ */
99
+ function fenceClosedBy(line: string, fence: Fence): boolean {
100
+ const m = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line)
101
+ if (m === null) return false
102
+ const run = m[1] as string
103
+ return run[0] === fence.char && run.length >= fence.length
104
+ }
105
+
106
+ /**
107
+ * A line that starts a new block: blank, an ATX heading, a blockquote, a list item, or a thematic
108
+ * break. Inline parsing happens WITHIN a block, so a code span cannot reach across one of these —
109
+ * and the boundary matters in the under-reporting direction. An unclosed backtick left by a typo
110
+ * would otherwise pair with the first backtick of the next block, swallowing the span that was
111
+ * about to open and every reference after it in the flow.
112
+ */
113
+ export function startsNewBlock(line: string): boolean {
114
+ if (/^[ \t]*$/.test(line)) return true
115
+ if (/^ {0,3}#{1,6}(\s|$)/.test(line)) return true // ATX heading
116
+ if (/^ {0,3}>/.test(line)) return true // blockquote
117
+ if (/^ {0,3}([-+*]|\d{1,9}[.)])(\s|$)/.test(line)) return true // list item
118
+ if (/^ {0,3}((\*[ \t]*){3,}|(-[ \t]*){3,}|(_[ \t]*){3,})$/.test(line)) return true // thematic break
119
+ return false
120
+ }
121
+
122
+ interface CodeSpan {
123
+ /** The span's content, line endings folded to spaces and CommonMark's one-space padding stripped. */
124
+ content: string
125
+ /** Offset into the scanned text where the opening run begins. */
126
+ start: number
127
+ end: number
128
+ }
129
+
130
+ /** Same length, same line breaks, no content — so blanking a region shifts no offset and moves no
131
+ * finding to another line. */
132
+ function blankOut(region: string): string {
133
+ return region.replace(/[^\n]/g, ' ')
134
+ }
135
+
136
+ /**
137
+ * The inline-code spans on one line, scanned the way CommonMark delimits them: a run of N backticks
138
+ * opens, the next run of EXACTLY N closes, and one leading + trailing space is stripped when both
139
+ * are present.
140
+ *
141
+ * Scanning properly rather than matching a single-backtick pair is what makes the reference rule —
142
+ * "a code span is a reference when its WHOLE content is the path" — mean one thing everywhere. A
143
+ * doubled span written around another span (`` `x` ``) has content that still carries backticks, so
144
+ * it is not a path and not a reference; a doubled span written around a bare path has the path as
145
+ * its content, so it IS one. Neither is a special case, and neither leaves a broken reference
146
+ * anywhere to hide.
147
+ */
148
+ export function scanCodeSpans(text: string, blockStarts: readonly number[] = []): CodeSpan[] {
149
+ const out: CodeSpan[] = []
150
+ let i = 0
151
+ while (i < text.length) {
152
+ if (text[i] !== '`') {
153
+ i++
154
+ continue
155
+ }
156
+ const open = i
157
+ while (text[i] === '`') i++
158
+ const runLength = i - open
159
+ // find the next run of exactly runLength backticks, stopping at the next block boundary — a
160
+ // code span lives inside one block and cannot reach past it
161
+ const limit = blockStarts.find((b) => b > open) ?? text.length
162
+ let j = i
163
+ let closeStart = -1
164
+ while (j < limit) {
165
+ if (text[j] !== '`') {
166
+ j++
167
+ continue
168
+ }
169
+ const runStart = j
170
+ while (text[j] === '`') j++
171
+ if (j - runStart === runLength) {
172
+ closeStart = runStart
173
+ break
174
+ }
175
+ }
176
+ if (closeStart === -1) {
177
+ // An unmatched run is literal text, and the scan RESUMES after it — CommonMark's own
178
+ // recovery. Abandoning the rest of the text instead would let one stray backtick
179
+ // silently swallow every reference after it, which is this engine's own failure class
180
+ // wearing a different hat.
181
+ i = open + runLength
182
+ continue
183
+ }
184
+ // Line endings inside a span are spaces, per CommonMark — which is what lets a span that
185
+ // WRAPS still be one span. Prose here hard-wraps, so a long path in backticks landing
186
+ // across a line break is ordinary, not exotic; scanning line by line would have left every
187
+ // wrapped reference unread.
188
+ let content = text.slice(open + runLength, closeStart).replace(/\r?\n/g, ' ')
189
+ if (content.length > 1 && content.startsWith(' ') && content.endsWith(' ') && content.trim() !== '') {
190
+ content = content.slice(1, -1)
191
+ }
192
+ out.push({ content, start: open, end: j })
193
+ i = j
194
+ }
195
+ return out
196
+ }
197
+
198
+ /**
199
+ * Every explicitly-relative reference in `text`, in document order.
200
+ *
201
+ * Skipped: lines inside a fenced code block, and lines carrying the ignore marker. Extraction is
202
+ * line-scoped on purpose — it is what gives each finding a line number, and what lets the marker's
203
+ * scope be exactly the line it appears on rather than the whole file.
204
+ */
205
+ export function extractReferences(text: string): Reference[] {
206
+ const lines = text.split('\n')
207
+
208
+ // 1. Blank the fenced blocks, keeping every line's length and every line break, so offsets and
209
+ // line numbers below still mean what they say.
210
+ let fence: Fence | undefined
211
+ const unfenced = lines.map((line) => {
212
+ if (fence !== undefined) {
213
+ if (fenceClosedBy(line, fence)) fence = undefined
214
+ return blankOut(line)
215
+ }
216
+ const opened = fenceOpenedBy(line)
217
+ if (opened !== undefined) {
218
+ fence = opened
219
+ return blankOut(line)
220
+ }
221
+ return line
222
+ })
223
+
224
+ // 2. Scan code spans across the WHOLE text, not line by line: a span that wraps is still one
225
+ // span, and reading it as two would leave a wrapped reference unread. Bounded at block
226
+ // starts, so widening the window past a line break does not let a stray backtick reach into
227
+ // the next block.
228
+ const scannable = unfenced.join('\n')
229
+ const lineStarts: number[] = []
230
+ let acc = 0
231
+ for (const line of lines) {
232
+ lineStarts.push(acc)
233
+ acc += line.length + 1
234
+ }
235
+ // A blanked fence line is all spaces, so it reads as a block start here too — the fence bounds
236
+ // a span by the same rule as everything else rather than by a happy accident.
237
+ const blockStarts = lineStarts.filter((_start, i) => i > 0 && startsNewBlock(unfenced[i] as string))
238
+ const lineOf = (offset: number): number => {
239
+ let lo = 0
240
+ let hi = lineStarts.length - 1
241
+ while (lo < hi) {
242
+ const mid = (lo + hi + 1) >> 1
243
+ if ((lineStarts[mid] as number) <= offset) lo = mid
244
+ else hi = mid - 1
245
+ }
246
+ return lo
247
+ }
248
+
249
+ const spans = scanCodeSpans(scannable, blockStarts)
250
+ let outsideSpans = scannable
251
+ for (const span of spans) {
252
+ outsideSpans =
253
+ outsideSpans.slice(0, span.start) +
254
+ blankOut(outsideSpans.slice(span.start, span.end)) +
255
+ outsideSpans.slice(span.end)
256
+ }
257
+ const outsideLines = outsideSpans.split('\n')
258
+
259
+ // 3. The marker is read from OUTSIDE the code spans — the same rule, not an exception for the
260
+ // escape hatch. Read from the raw line it would fire on a line that merely QUOTES it, which
261
+ // is how this very node documents it, and that line's real references would vanish: an
262
+ // escape hatch a description of the escape hatch can trigger hides exactly what this engine
263
+ // exists to find. A wrapped span is judged by the line it OPENS on — the line a reader
264
+ // would put the marker beside.
265
+ const marked = outsideLines.map((line) => IGNORE_MARKER_RE.test(line))
266
+
267
+ const perLine = new Map<number, Set<string>>()
268
+ const add = (line: number, ref: string) => {
269
+ if (marked[line] === true) return
270
+ const set = perLine.get(line) ?? new Set<string>()
271
+ set.add(ref)
272
+ perLine.set(line, set)
273
+ }
274
+
275
+ for (const span of spans) {
276
+ const content = span.content.trim()
277
+ if (RELATIVE_RE.test(content)) add(lineOf(span.start), content)
278
+ }
279
+
280
+ outsideLines.forEach((line, i) => {
281
+ const addTarget = (angled: string | undefined, bare: string | undefined) => {
282
+ if (angled !== undefined) {
283
+ if (RELATIVE_IN_ANGLES_RE.test(angled)) add(i, angled)
284
+ return
285
+ }
286
+ if (bare !== undefined && RELATIVE_RE.test(bare)) add(i, bare)
287
+ }
288
+ for (const m of line.matchAll(LINK_RE)) addTarget(m[1], m[2])
289
+ const def = LINK_DEF_RE.exec(line)
290
+ if (def) addTarget(def[1], def[2])
291
+ })
292
+
293
+ const out: Reference[] = []
294
+ for (const line of [...perLine.keys()].sort((a, b) => a - b)) {
295
+ for (const ref of perLine.get(line) as Set<string>) out.push({ line: line + 1, ref })
296
+ }
297
+ return out
298
+ }
299
+
300
+ // ── Resolution ──
301
+
302
+ /**
303
+ * Where `ref` points, resolved against `fileDir` — the directory of the file that CARRIES it,
304
+ * never the spec root and never the repo root. A trailing `#fragment` is stripped first; a
305
+ * trailing slash is immaterial (`resolve` drops it), so a directory reference resolves either way.
306
+ */
307
+ export function resolveReference(fileDir: string, ref: string): string {
308
+ const withoutFragment = ref.replace(/#.*$/, '')
309
+ return resolve(fileDir, withoutFragment)
310
+ }
311
+
312
+ // ── The walk ──
313
+
314
+ /** Every `.md` file under `dir`, at any depth, absolute and sorted so the report is stable. */
315
+ export function listMarkdownFiles(dir: string): string[] {
316
+ const out: string[] = []
317
+ const walk = (d: string) => {
318
+ for (const e of readdirSync(d, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
319
+ const p = join(d, e.name)
320
+ if (e.isDirectory()) walk(p)
321
+ else if (e.name.endsWith('.md')) out.push(p)
322
+ }
323
+ }
324
+ walk(dir)
325
+ return out
326
+ }
327
+
328
+ // ── Audit ──
329
+
330
+ export interface Finding {
331
+ /** Absolute path of the file carrying the reference. */
332
+ file: string
333
+ line: number
334
+ /** The reference exactly as written. */
335
+ ref: string
336
+ /** The absolute path it resolved to — the half a reader cannot supply by eye. */
337
+ resolved: string
338
+ }
339
+
340
+ export interface AuditOptions {
341
+ /** Substitute the markdown walk (tests assert which files are visited). */
342
+ listMarkdownFiles?: (dir: string) => string[]
343
+ /** Substitute the file reader (tests assert nothing outside the walk is read). */
344
+ readFile?: (path: string) => string
345
+ /** Substitute the existence probe. */
346
+ exists?: (path: string) => boolean
347
+ }
348
+
349
+ /**
350
+ * Every unresolved reference under `specDir`, ordered by file, then line, then reference — so two
351
+ * runs over an unchanged tree render byte-identically.
352
+ *
353
+ * EVERY finding, never the first: a single off-by-one lands as a whole family of broken
354
+ * references, and reporting one at a time would take as many runs to clear as there are levels
355
+ * wrong.
356
+ */
357
+ export function audit(specDir: string, options: AuditOptions = {}): Finding[] {
358
+ const list = options.listMarkdownFiles ?? listMarkdownFiles
359
+ const read = options.readFile ?? ((p: string) => readFileSync(p, 'utf8'))
360
+ const has = options.exists ?? existsSync
361
+
362
+ const findings: Finding[] = []
363
+ for (const file of list(specDir)) {
364
+ const fileDir = dirname(file)
365
+ for (const { line, ref } of extractReferences(read(file))) {
366
+ const resolved = resolveReference(fileDir, ref)
367
+ if (!has(resolved)) findings.push({ file, line, ref, resolved })
368
+ }
369
+ }
370
+ return findings.sort((a, b) =>
371
+ a.file !== b.file ? (a.file < b.file ? -1 : 1) : a.line !== b.line ? a.line - b.line : a.ref < b.ref ? -1 : 1,
372
+ )
373
+ }
374
+
375
+ // ── Report ──
376
+
377
+ /** Renders each finding as `file:line: <ref> -> <resolved>`, both paths relative to `base` so the
378
+ * report reads the same from any checkout. */
379
+ export function formatFindings(findings: Finding[], base: string): string {
380
+ if (findings.length === 0) return 'check-spec-references: every relative reference resolves\n'
381
+ const lines = findings.map(
382
+ (f) =>
383
+ ` ${relative(base, f.file)}:${f.line}: \`${f.ref}\` -> ${relative(base, f.resolved)} — no file or directory there\n`,
384
+ )
385
+ lines.push(`check-spec-references: ${findings.length} unresolved reference(s)\n`)
386
+ return lines.join('')
387
+ }
388
+
389
+ // ── CLI ──
390
+
391
+ // One mode, deliberately. A report-only mode would differ from the guard by exit code alone —
392
+ // the same findings, printed the same way — and no actor wants the list without wanting it fixed.
393
+ // Every finding is a defect, so every finding fails the run.
394
+ export function main(argv: string[], cwd: string = process.cwd()): number {
395
+ const i = argv.indexOf('--spec-dir')
396
+ const specDir = i === -1 ? '' : (argv[i + 1] ?? '')
397
+ if (specDir === '') {
398
+ process.stderr.write('check-spec-references: --spec-dir <dir> is required\n')
399
+ return 1
400
+ }
401
+
402
+ const dir = resolve(cwd, specDir)
403
+ // A spec dir that is not there is refused by name, never as a raw stack trace: a mistyped path
404
+ // must not read like a corpus that has no markdown in it.
405
+ if (!existsSync(dir)) {
406
+ process.stderr.write(`check-spec-references: no directory at ${relative(cwd, dir)}\n`)
407
+ return 1
408
+ }
409
+
410
+ const findings = audit(dir)
411
+ const report = formatFindings(findings, cwd)
412
+ if (findings.length === 0) {
413
+ process.stdout.write(report)
414
+ return 0
415
+ }
416
+ process.stderr.write(report)
417
+ return 1
418
+ }
419
+
420
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
421
+ process.exit(main(process.argv.slice(2)))
422
+ }
@@ -38,6 +38,22 @@ const ROOT_PROJECT_NAME = 'repo'
38
38
  // Dirs the scan never descends into (keep `.agents` — specs live under it).
39
39
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
40
40
 
41
+ /**
42
+ * A directory that is itself a checkout is a DIFFERENT repository's tree, not part of this corpus.
43
+ *
44
+ * A name blocklist cannot express this: it can only say "this is called X", never "this is a
45
+ * boundary". `SKIP_DIRS` already holds `.git`, so the walk skipped the METADATA directory while
46
+ * descending straight into the checkout that directory marks — one level off from where the guard
47
+ * was needed. Agent-harness worktree isolation checks this repo out inside itself, and the scan then
48
+ * found the whole corpus once per worktree: 38 spec files where 10 exist, and every corpus-wide
49
+ * guard silently auditing a tree nobody has.
50
+ *
51
+ * `.git` is a DIRECTORY in a clone and a FILE in a worktree or submodule, so both forms count.
52
+ */
53
+ function isNestedCheckout(abs: string): boolean {
54
+ return existsSync(join(abs, '.git'))
55
+ }
56
+
41
57
  // The opt-in extra-anchor registry (ADR-0019). Scanned IN ADDITION TO the three fixed conventions;
42
58
  // absent ⇒ only the fixed conventions are scanned (today's behavior, unchanged).
43
59
  const ANCHORS_CONFIG = '.agents/sdd/spec-anchors.toml'
@@ -168,6 +184,7 @@ export function discoverSpecFiles(root: string): string[] {
168
184
  for (const e of entries) {
169
185
  if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue
170
186
  const childRel = relDir ? `${relDir}/${e.name}` : e.name
187
+ if (isNestedCheckout(join(root, childRel))) continue
171
188
  if (e.name === '.agents') {
172
189
  probeAgents(root, childRel, found)
173
190
  continue // spec locations live directly under .agents, no deeper walk needed
@@ -237,7 +254,9 @@ function collectDescendants(root: string, startDir: string): string[] {
237
254
  }
238
255
  for (const e of entries) {
239
256
  if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue
240
- out.push(...collectDescendants(root, startDir ? `${startDir}/${e.name}` : e.name))
257
+ const childRel = startDir ? `${startDir}/${e.name}` : e.name
258
+ if (isNestedCheckout(join(root, childRel))) continue
259
+ out.push(...collectDescendants(root, childRel))
241
260
  }
242
261
  return out
243
262
  }
@@ -273,8 +292,10 @@ export function expandAnchor(root: string, pattern: string): { rel: string; capt
273
292
  }
274
293
  for (const e of entries) {
275
294
  if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue
295
+ const childRel = node.dir ? `${node.dir}/${e.name}` : e.name
296
+ if (isNestedCheckout(join(root, childRel))) continue
276
297
  next.push({
277
- dir: node.dir ? `${node.dir}/${e.name}` : e.name,
298
+ dir: childRel,
278
299
  capturedName: seg === '<project>' ? e.name : node.capturedName,
279
300
  })
280
301
  }
@@ -41,7 +41,7 @@ For each unit the CR touches:
41
41
  - **Scaffold the skeleton** per `sdd:spec-format-governance` (sections per type; `.feature` form per `sdd:suite-format-governance`). Write **no** control frontmatter (`status` / `project-path` / `approval` / `produced-by`) — those live on the root `spec.md` and belong to the conductor and the gate.
42
42
  - **Collect seed intent.** For a **new** feature, ask 3–5 targeted questions — **lead with the actors** (who reaches this capability, and who is affected by its outcome without invoking it), then their goals, then the core problem, observable behavior, edge cases / non-goals, and reviewers who must be heard. Ask for the **public interface last, and never first**: an interface offered up front becomes the anchor the use cases get read off, which is the enumeration failure `sdd:spec-format-governance` exists to prevent. For **backfill** (behavior already in code), skip — the producer reads source, tests, history. For a **revise**, collect what changes and why and the parts it touches.
43
43
 
44
- **The grill loop (the user loop).** You are the conductor. Run the spec-producer **inline** (load `sdd:spec-producer-governance`, or persona-load a plugin specialist for the `artifact-types`), **dispatch the cold spec-judge** each round — through the dispatch capability's intent seam when one is available (preferring its **warm** unit, context-cleared fresh via `npx cyberlegion@0.3.0 unit clear <ref>` before each round's judgment), else a portable cold subagent — and for build-to-learn **dispatch the impl-producer builder** the same way (its warm unit **keeps** its context across spikes; no reset) in `explore` mode against the **non-frozen** suite — spikes are thrown away; their learnings feed the live grill to steer the spec + suite. Set an **iteration cap** (default **3**; honor a user-named cap), then loop:
44
+ **The grill loop (the user loop).** You are the conductor. Run the spec-producer **inline** (load `sdd:spec-producer-governance`, or persona-load a plugin specialist for the `artifact-types`), **dispatch the cold spec-judge** each round — through the dispatch capability's intent seam when one is available (preferring its **warm** unit, context-cleared fresh via `npx cyberlegion@0.3.1 unit clear <ref>` before each round's judgment), else a portable cold subagent — and for build-to-learn **dispatch the impl-producer builder** the same way (its warm unit **keeps** its context across spikes; no reset) in `explore` mode against the **non-frozen** suite — spikes are thrown away; their learnings feed the live grill to steer the spec + suite. Set an **iteration cap** (default **3**; honor a user-named cap), then loop:
45
45
 
46
46
  **Governance provenance relay.** When you dispatch the cold spec-judge, forward the inline spec-producer's declared `governances_loaded` (`sdd:spec-producer-governance`) verbatim through the same dispatch channel, keyed **`producer_governances_declared`** — a brief field when the judge is a cold subagent, a mail envelope field when it runs through an agent pool. Forward it **as-is, including an empty set** — you render **no opinion** on which governances were actually required; that check is the spec-judge's own pre-flight (`sdd:sdd-spec-judge`).
47
47
 
@@ -79,7 +79,7 @@ Build-to-keep against the **frozen** suite. The deliver **read-set is scoped** (
79
79
 
80
80
  **Rebase onto the target — the last deliver act, before the gate.** Before running the impl gate, **rebase the CR branch onto the current tip of the declared target** (for a commit-to-main project, the equivalent `pull --rebase` onto the latest `main`), so the impl gate judges the **merged tree that will actually land** — keeping history linear and leaving handoff a pure consumer that never re-verifies. A **textual conflict** is resolved as **deliver code work** against the frozen `.feature` (never a `.feature` edit); the gate then runs on the resolved tree. A conflict you **cannot resolve confidently is never guess-resolved** — the frozen suite covers *this CR's* behavior, not the incoming change's, so a wrong resolution could still pass the gate and land broken; **stop and escalate** (in-session ask the user; headless return `needs-input` up the relay) and record a `halt`, never land a low-confidence resolution. Rebasing an *unmerged* CR branch is git-reversible (reflog), so it raises **no new hard floor** — but a conflict resolution that would **narrow** a frozen scenario still fires the existing **Clearance** floor, a semver class over the ceiling **Compatibility**, and a genuine contradiction **Conflict** (autonomy bar, below). The rebase-then-gate is **optimistic**: if the target **advances again** between the passing gate and the push (another CR merged in the window), **re-rebase onto the new tip and re-run the impl gate — do not push until the gate passes on the re-rebased tree**, looping until the push wins, so what lands is always a tree the gate saw green. **The loop is bounded, not forced** — if the target keeps advancing past a small cap of attempts, **stop and escalate** (record a `halt`) rather than spinning forever (a liveness stop, same as the unconfident-conflict halt).
81
81
 
82
- **The impl gate (Approved → Implemented, internal).** On entering the gate, overwrite the statusline file with `impl gate`. Dispatch the **cold impl-judge** (`sdd:sdd-impl-judge` or the covering plugin's judge) — same seam-when-available wiring, preferring its warm unit context-cleared fresh via `npx cyberlegion@0.3.0 unit clear <ref>` for this judgment, else a portable cold subagent — to run the verification per frozen scenario plus an orthogonal structural/scope read. Advance to **`status: implemented`** **only when every impl-judge passes** (a frozen scenario with no verification blocks the advance — impl-sync is this suite run, not a stored flag). The three actions: **approve** → `implemented`; **change** → fix the **code** (never the frozen `.feature`), under the same evidence-not-a-work-order remediation the spec gate uses (`sdd:remediation-governance`) — including the **provenance** account that stops a regressing loop; **reject** → redo, or a **Oracle-lens revert** (a frozen scenario proved fatal → unfreeze the `.feature`, return to `draft` — the only place a frozen `.feature` reopens).
82
+ **The impl gate (Approved → Implemented, internal).** On entering the gate, overwrite the statusline file with `impl gate`. Dispatch the **cold impl-judge** (`sdd:sdd-impl-judge` or the covering plugin's judge) — same seam-when-available wiring, preferring its warm unit context-cleared fresh via `npx cyberlegion@0.3.1 unit clear <ref>` for this judgment, else a portable cold subagent — to run the verification per frozen scenario plus an orthogonal structural/scope read. Advance to **`status: implemented`** **only when every impl-judge passes** (a frozen scenario with no verification blocks the advance — impl-sync is this suite run, not a stored flag). The three actions: **approve** → `implemented`; **change** → fix the **code** (never the frozen `.feature`), under the same evidence-not-a-work-order remediation the spec gate uses (`sdd:remediation-governance`) — including the **provenance** account that stops a regressing loop; **reject** → redo, or a **Oracle-lens revert** (a frozen scenario proved fatal → unfreeze the `.feature`, return to `draft` — the only place a frozen `.feature` reopens).
83
83
 
84
84
  ## Step 4 — handoff
85
85
 
@@ -97,13 +97,13 @@ Before you close out, run the **correction-line finalize backstop** (autonomy ba
97
97
 
98
98
  Also run the **plan-brief finalize backstop** (autonomy bar, below): reconcile the plan brief's `todos` and its `## NEXT` anchor to the landed state, **in this same change** — so the delivery never ships a landed mission described as in-progress.
99
99
 
100
- Before closing out, **reset the mission's warm units**: `npx cyberlegion@0.3.0 unit clear <ref>` (context-clear, pane stays warm) or tear down every warm unit this mission dispatched — none carries this mission's context into the next.
100
+ Before closing out, **reset the mission's warm units**: `npx cyberlegion@0.3.1 unit clear <ref>` (context-clear, pane stays warm) or tear down every warm unit this mission dispatched — none carries this mission's context into the next.
101
101
 
102
102
  Once landed, **do not spawn** the formation Warden. Surface a **one-line nudge** that a corpus-wide formation pass is due, pointing to `sdd:manage` ("audit the corpus structure" → `formation-loop`). The pass is **on-demand** — run deliberately, not auto-spawned on every landing; `sdd:manage` owns the trigger. Gate nothing on it.
103
103
 
104
104
  ## Autonomy, provenance, and the hard floor (baked in)
105
105
 
106
- - **Dispatch transport.** Every spawn beyond this session states a **dispatch intent** — role, brief, expected verdict schema — never a pinned command. When a harness-agnostic dispatch capability is available (detected at runtime; the concrete case is the Legate's `dispatch-governance` composing `cyberlegion` primitives — `agent resolve` + `unit spawn` + `mail await` — with **no** `dispatch` CLI verb, the seam named in the SDD project spec's `design/harness-spawning` node, repo-only), route through its intent seam and let it pick `subagent | channel | run-inline`, **preferring a warm unit** over a cold one-shot spawn; with no capability present, fall back to the portable cold subagent (depth-1) default — grader independence intact either way. **Warmth is a property of the unit/process; coldness of the context**: a judge's fresh-context guarantee (ADR-0016) is transport-agnostic — satisfied by a newly spawned cold subagent **or** a warm unit **context-cleared** to a fresh context before **each** judgment (re-deriving its oracle, carrying none of a prior round's context). Clear a warm unit with **`npx cyberlegion@0.3.0 unit clear <ref>`** (`<ref>` = unit id / handle / worktree branch or CR ref) — it injects the harness's own fresh-context command (`/clear` on Claude/Codex/Copilot, `/new-chat` on Cursor; fail-loud on a harness with no honest reset) so the **pane stays warm** while the **context goes cold**; it tears nothing down. The **impl-producer builder** instead stays warm and **keeps** its context across the explore spikes and the deliver build (never cleared between those uses). Warm units stay warm for **one mission** — reused within it, then **`unit clear`**'d or torn down at **handoff**, never carrying this mission's context into the next.
106
+ - **Dispatch transport.** Every spawn beyond this session states a **dispatch intent** — role, brief, expected verdict schema — never a pinned command. When a harness-agnostic dispatch capability is available (detected at runtime; the concrete case is the Legate's `dispatch-governance` composing `cyberlegion` primitives — `agent resolve` + `unit spawn` + `mail await` — with **no** `dispatch` CLI verb, the seam named in the SDD project spec's `design/harness-spawning` node, repo-only), route through its intent seam and let it pick `subagent | channel | run-inline`, **preferring a warm unit** over a cold one-shot spawn; with no capability present, fall back to the portable cold subagent (depth-1) default — grader independence intact either way. **Warmth is a property of the unit/process; coldness of the context**: a judge's fresh-context guarantee (ADR-0016) is transport-agnostic — satisfied by a newly spawned cold subagent **or** a warm unit **context-cleared** to a fresh context before **each** judgment (re-deriving its oracle, carrying none of a prior round's context). Clear a warm unit with **`npx cyberlegion@0.3.1 unit clear <ref>`** (`<ref>` = unit id / handle / worktree branch or CR ref) — it injects the harness's own fresh-context command (`/clear` on Claude/Codex/Copilot, `/new-chat` on Cursor; fail-loud on a harness with no honest reset) so the **pane stays warm** while the **context goes cold**; it tears nothing down. The **impl-producer builder** instead stays warm and **keeps** its context across the explore spikes and the deliver build (never cleared between those uses). Warm units stay warm for **one mission** — reused within it, then **`unit clear`**'d or torn down at **handoff**, never carrying this mission's context into the next.
107
107
  - **Initial strategy** (run start): assess blast radius + the other dimensions and emit a run-level `kind: leash` block to **your own ledger shard** (`ledger/<cr-ref>.<hash>.jsonl` — mint `<hash>` as 6 random hex **once per session** and reuse it for every line you append; `sdd:combat-log-governance`) — `leash` (`auto-none | auto-spec | auto-all`), `by: derived | user`, `approach[]`. It may be user-specified. This block is `kind: leash`, **not** `strategy` — `strategy` is the doctrine Scanner's alone. Ledger lines carry **no `ts`**.
108
108
  - **Per-gate verdict.** At each gate, derive the leash against discovered state and either **self-assert within leash** (write `approval.<gate>: { verdict: approve, by: agent, why }`; the spec lands in the async review queue) or **stop** with a verdict packet for the human. **Never advance** when any judge fails, any open marker remains, or (at the impl gate) any frozen scenario's verification does not pass. Human ratification (`by: <name>`, advance `status`) is reserved to the in-session position holding the user channel — by default you, in-session; a headless `automaton` emits the verdict packet and stops, **even when a coordinator relays "the user approved."**
109
109
  - **Combat log.** Append `report` / `correction` lines (and the halt that stopped you) to the plan's `*.log.jsonl` (these carry a UTC `ts`); your run-start `leash` block, self-asserted `gate` lines, and the handoff `followup` records go to **your own shard** in the durable `ledger/` directory sibling to `spec.md` — never another writer's shard, never a shared file (`strategy` there is the Scanner's alone). Free text is commit-message-grade — never code, prompts, secrets, or literal values.
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 unional
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.