@llman-sdd/core 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llman-sdd/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Pure domain logic for llman-sdd (config, gherkin specs, validation, templates)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -147,6 +147,8 @@ export async function runList(io: FreezeIo, sz: SevenZipPort, rootAbs: string):
147
147
  if (io.exists(archiveAbs)) {
148
148
  // Legacy/unexpected entries present only inside the 7z (no card on disk).
149
149
  const entries = (await sz.listEntries(archiveAbs)).map((n) => n.replace(/\/$/u, ''));
150
+ // 7z entry names always use '/' per archive contract — POSIX by contract, not
151
+ // platform-native; do not switch to node:path here.
150
152
  const top = (n: string): string => n.split('/')[0] ?? n;
151
153
  for (const e of entries.map(top).filter((n) => DATED_RE.test(n))) {
152
154
  if (!names.has(e)) names.add(e);
@@ -84,6 +84,8 @@ function resolveWorktreePath(
84
84
  naming: 'id' | 'hash' | undefined,
85
85
  ): string {
86
86
  const toplevel = git.run(['rev-parse', '--show-toplevel']);
87
+ // git rev-parse --show-toplevel emits forward-slash paths even on win32
88
+ // (git-for-Windows normalizes) — POSIX by contract; keep '/'-splitting.
87
89
  const cut = toplevel.lastIndexOf('/');
88
90
  const basename = toplevel.slice(cut + 1);
89
91
  const name =
@@ -26,7 +26,7 @@ export const specsSchema = z.object({
26
26
  .string()
27
27
  .nullish()
28
28
  .describe(
29
- 'Spec verification command executed by validate for spec targets (skip with --no-check). Placeholders: {feature_path}, {feature_dir}, {feature_name}; without placeholders it runs once per validate invocation (batch-once). Legacy `bdd.run_command` is elevated onto this key at load time.',
29
+ 'Spec verification command executed by validate for spec targets (opt-in via --check; default skips). Placeholders: {feature_path}, {feature_dir}, {feature_name}; without placeholders it runs once per validate invocation (batch-once). Legacy `bdd.run_command` is elevated onto this key at load time.',
30
30
  ),
31
31
  verify_prompt: z.string().nullish().describe('Extra prompt text injected during verify phase.'),
32
32
  });
package/src/index.ts CHANGED
@@ -1,5 +1,3 @@
1
- export * from './ports.ts';
2
-
3
1
  export {
4
2
  EXTRA_SKILLS,
5
3
  archiveSchema,
@@ -107,6 +105,16 @@ export {
107
105
  type StageGate,
108
106
  } from './validation/changeCheck.ts';
109
107
  export { discoverSpecs, type DiscoveryIo } from './validation/discover.ts';
108
+ export {
109
+ discoverRoots,
110
+ isValidRoot,
111
+ resolveInstanceRoot,
112
+ scopeCrossings,
113
+ ROOT_EXCLUDED_DIRS,
114
+ type RootEntry,
115
+ type RootsIo,
116
+ type ScopeCrossing,
117
+ } from './validation/roots.ts';
110
118
  export {
111
119
  expandRunCommand,
112
120
  runHarnessForSpecs,
@@ -187,6 +195,7 @@ export {
187
195
  } from './templates/embedded.ts';
188
196
  export {
189
197
  TEMPLATES_ROOT,
198
+ refreshSubRootBlocks,
190
199
  runInit,
191
200
  updateFileWithMarkers,
192
201
  type InitIo,
package/src/init/init.ts CHANGED
@@ -80,7 +80,7 @@ function writeDefaultConfig(io: InitIo, locale: string): void {
80
80
  }
81
81
 
82
82
  export interface InitResult {
83
- /** Skill dirs written, in render order. */
83
+ /** Skill dirs written, in render order ([] when skills injection is off). */
84
84
  skills: string[];
85
85
  /** llman-sdd-* directories removed by --update namespace cleanup. */
86
86
  removed: string[];
@@ -89,10 +89,31 @@ export interface InitResult {
89
89
 
90
90
  const SKILLS_BASE = '.agents/skills';
91
91
 
92
+ /** Managed AGENTS.md marker blocks (root + llmanspec), content-preserving. */
93
+ function writeManagedBlocks(
94
+ io: InitIo,
95
+ templates: TemplateIo,
96
+ config: ReturnType<typeof loadConfig>,
97
+ version: string,
98
+ ): void {
99
+ const vars = buildTemplateVars(config, version);
100
+ const locales = localeFallbacks(config.locale);
101
+ for (const [stubPath, agentsPath] of [
102
+ ['agents-root-stub.md', 'AGENTS.md'],
103
+ ['llmanspec-agents-stub.md', 'llmanspec/AGENTS.md'],
104
+ ] as const) {
105
+ const stubRaw = loadLocaleResource(templates, TEMPLATES_ROOT, locales, stubPath);
106
+ if (stubRaw === null) continue;
107
+ const existing = io.exists(agentsPath) ? io.readText(agentsPath) : '';
108
+ const body = renderTemplate(stubRaw, new Map(), vars);
109
+ io.writeText(agentsPath, updateFileWithMarkers(existing, body));
110
+ }
111
+ }
112
+
92
113
  export function runInit(
93
114
  io: InitIo,
94
115
  templates: TemplateIo,
95
- opts: { update: boolean; locale?: string; version: string },
116
+ opts: { update: boolean; locale?: string; version: string; skills: boolean },
96
117
  ): InitResult {
97
118
  io.mkdirp('llmanspec');
98
119
 
@@ -110,26 +131,15 @@ export function runInit(
110
131
  }
111
132
 
112
133
  // 3) AGENTS.md managed blocks (root + llmanspec), preserving content.
134
+ writeManagedBlocks(io, templates, config, opts.version);
113
135
  const vars = buildTemplateVars(config, opts.version);
114
- const locales = localeFallbacks(config.locale);
115
136
  const skillTemplates = loadSkillTemplates(templates, TEMPLATES_ROOT, config, vars);
116
137
  enforceEthicsGovernance(skillTemplates);
117
138
 
118
- for (const [stubPath, agentsPath] of [
119
- ['agents-root-stub.md', 'AGENTS.md'],
120
- ['llmanspec-agents-stub.md', 'llmanspec/AGENTS.md'],
121
- ] as const) {
122
- const stubRaw = loadLocaleResource(templates, TEMPLATES_ROOT, locales, stubPath);
123
- if (stubRaw === null) continue;
124
- const existing = io.exists(agentsPath) ? io.readText(agentsPath) : '';
125
- const body = renderTemplate(stubRaw, new Map(), vars);
126
- io.writeText(agentsPath, updateFileWithMarkers(existing, body));
127
- }
128
-
129
139
  // 4) skills namespace cleanup (--update only): remove llman-sdd-* dirs
130
140
  // outside the candidate set; un-prefixed custom skills stay untouched.
131
141
  const removed: string[] = [];
132
- if (opts.update && io.exists(SKILLS_BASE)) {
142
+ if (opts.skills && opts.update && io.exists(SKILLS_BASE)) {
133
143
  const candidates = new Set(skillCandidates(config).map((f) => f.replace(/\.md$/u, '')));
134
144
  for (const entry of io.listDir(SKILLS_BASE)) {
135
145
  if (entry.startsWith('llman-sdd-') && !candidates.has(entry)) {
@@ -140,6 +150,12 @@ export function runInit(
140
150
  }
141
151
 
142
152
  // 5) write candidates: rendered product trimmed + single trailing newline.
153
+ // Sub-root instances default to no skills injection (r93): the agent skill
154
+ // surface stays at the repo root and sub-root navigation lives in the
155
+ // managed blocks; --skills opts a sub-root in.
156
+ if (!opts.skills) {
157
+ return { skills: [], removed, configPath };
158
+ }
143
159
  for (const t of skillTemplates) {
144
160
  const dirName = t.name.replace(/\.md$/u, '');
145
161
  io.mkdirp(`${SKILLS_BASE}/${dirName}`);
@@ -148,3 +164,16 @@ export function runInit(
148
164
 
149
165
  return { skills: skillTemplates.map((t) => t.name.replace(/\.md$/u, '')), removed, configPath };
150
166
  }
167
+
168
+ /**
169
+ * r93 --update sweep: refresh the managed blocks of one discovered sub-root
170
+ * (blocks only — no scaffold, no config touch, no skills). Returns false for
171
+ * a missing config (discovery guarantees validity; a race is not fatal).
172
+ */
173
+ export function refreshSubRootBlocks(io: InitIo, templates: TemplateIo, version: string): boolean {
174
+ const configPath = 'llmanspec/config.yaml';
175
+ if (!io.exists(configPath)) return false;
176
+ const config = loadConfig(io.readText(configPath));
177
+ writeManagedBlocks(io, templates, config, version);
178
+ return true;
179
+ }
@@ -39,20 +39,19 @@ function collectSpecEntries(io: SpecHelperIo, specsDir: string): ParsedEntry[] {
39
39
  }
40
40
 
41
41
  /**
42
- * predecessor parity (`req_registry.rs::next_req_id_from_index`): smallest free
43
- * rN over the requirement handles (`@req` on `规则:` headers) only. The id set
44
- * comes from the global req registry fed with the parsed specs.
42
+ * Max+1 over the requirement handles (`@req` on `规则:` headers) only — the id
43
+ * set comes from the global req registry fed with the parsed specs. Deliberately
44
+ * divergent from the predecessor's smallest-free semantics
45
+ * (`req_registry.rs::next_req_id_from_index`): handing out freed ids aliases
46
+ * references archived in past changes (issue #5) — retired ranges are never
47
+ * reused. See change `align-next-req-id-max-plus-one` design for the trade-off.
45
48
  */
46
49
  export function nextReqId(io: SpecHelperIo, specsDir: string): string {
47
50
  const entries = collectSpecEntries(io, specsDir);
48
- const used = new Set(
49
- [...buildReqRegistry(entries).byId.keys()].map((reqId) =>
50
- Math.trunc(Number(reqId.replace(/^r/u, ''))),
51
- ),
51
+ const used = [...buildReqRegistry(entries).byId.keys()].map((reqId) =>
52
+ Math.trunc(Number(reqId.replace(/^r/u, ''))),
52
53
  );
53
- let n = 1;
54
- while (used.has(n)) n += 1;
55
- return `r${n}`;
54
+ return `r${Math.max(0, ...used) + 1}`;
56
55
  }
57
56
 
58
57
  export function skeletonContent(capability: string, reqId: string, locale: string): string {
@@ -141,8 +141,7 @@ export function migrateNativeSource(source: string): MigrateResult {
141
141
  for (const b of blocks) {
142
142
  if (!b.isRule) continue;
143
143
  rules++;
144
- out.push(` @req:${b.reqIds[0] ?? ''}`);
145
- out.push(` ${kw.rule}: ${b.title}`);
144
+ out.push(` @req:${b.reqIds[0] ?? ''}`, ` ${kw.rule}: ${b.title}`);
146
145
  for (const line of b.descriptionLines) {
147
146
  const text = stripBullet(line);
148
147
  if (text !== '') out.push(` ${text}`);
@@ -96,7 +96,8 @@ function extractHeader(source: string): CapabilityHeader {
96
96
  }
97
97
 
98
98
  interface RawTags {
99
- names: string[]; // without leading '@'
99
+ // Tag names are stored here without the leading '@'.
100
+ names: string[];
100
101
  reqId: string;
101
102
  }
102
103
 
@@ -57,6 +57,8 @@ export interface HarnessRunOutcome {
57
57
 
58
58
  /** Plain-text placeholder expansion — capability ids are kebab-constrained. */
59
59
  export function expandRunCommand(command: string, target: HarnessTarget): string {
60
+ // featurePath is a repo-relative spec path (llmanspec/specs/x.feature), POSIX by
61
+ // contract on every host — keep '/'-splitting, do not switch to node:path here.
60
62
  const dirEnd = target.featurePath.lastIndexOf('/');
61
63
  const featureDir = dirEnd === -1 ? target.featurePath : target.featurePath.slice(0, dirEnd);
62
64
  return command
@@ -74,7 +76,7 @@ interface CacheEntry {
74
76
  const OUTPUT_TAIL = 200;
75
77
 
76
78
  /**
77
- * Trigger matrix (r13): --no-check skips silently; a nested invocation skips
79
+ * Trigger matrix (r13): default skips; a nested invocation skips
78
80
  * with a per-spec INFO; an explicit --check without a configured check_command
79
81
  * yields a single INFO on the first spec; otherwise every expanded command
80
82
  * executes at most once (cache keyed by the expanded string).
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Instance-root discovery (subproject-llmanspec-discovery, r91-r92): the
3
+ * unified-root axiom — every llman-sdd operation targets one llmanspec/
4
+ * directory; the repo root is just the default instance and a workspace
5
+ * subpackage carrying its own llmanspec/ is another instance of the same
6
+ * shape. Pure: filesystem access only through the injected IO.
7
+ */
8
+
9
+ export interface RootsIo {
10
+ exists(path: string): boolean;
11
+ isDirectory(path: string): boolean;
12
+ listDir(path: string): string[];
13
+ }
14
+
15
+ export interface RootEntry {
16
+ /** Directory containing the llmanspec/ instance (the instance root). */
17
+ rootDir: string;
18
+ /** The llmanspec directory itself. */
19
+ llmanspecDir: string;
20
+ }
21
+
22
+ /** Directory names never descended into during discovery. */
23
+ export const ROOT_EXCLUDED_DIRS: ReadonlySet<string> = new Set(['node_modules', 'target', '.git']);
24
+
25
+ const joinPath = (dir: string, name: string): string =>
26
+ dir.endsWith('/') ? `${dir}${name}` : `${dir}/${name}`;
27
+
28
+ /**
29
+ * A directory counts as an llmanspec root when it carries `config.yaml` or a
30
+ * `specs/` subdir — bare `llmanspec/` scaffolds in flight are not instances.
31
+ */
32
+ export function isValidRoot(llmanspecDir: string, io: RootsIo): boolean {
33
+ if (!io.isDirectory(llmanspecDir)) return false;
34
+ return io.exists(`${llmanspecDir}/config.yaml`) || io.isDirectory(`${llmanspecDir}/specs`);
35
+ }
36
+
37
+ /**
38
+ * Discover instance roots under `startDir` (convention scan). The start dir
39
+ * itself is checked first (depth 0 — the git-root instance), then subdirs
40
+ * depth-first in stable sort order; `llmanspec/` contents are never descended
41
+ * into and excluded names are pruned. `maxDepth` mirrors the global
42
+ * --max-scan-depth knob (default 8).
43
+ */
44
+ export function discoverRoots(
45
+ startDir: string,
46
+ io: RootsIo,
47
+ opts: { maxDepth?: number } = {},
48
+ ): RootEntry[] {
49
+ const maxDepth = opts.maxDepth ?? 8;
50
+ const roots: RootEntry[] = [];
51
+ const walk = (dir: string, depth: number): void => {
52
+ if (depth > maxDepth) return;
53
+ const llmanspecDir = joinPath(dir, 'llmanspec');
54
+ if (isValidRoot(llmanspecDir, io)) roots.push({ rootDir: dir, llmanspecDir });
55
+ let names: string[];
56
+ try {
57
+ names = io.listDir(dir);
58
+ } catch {
59
+ return;
60
+ }
61
+ for (const name of names.toSorted()) {
62
+ if (name.startsWith('.') || ROOT_EXCLUDED_DIRS.has(name)) continue;
63
+ const full = joinPath(dir, name);
64
+ if (!io.isDirectory(full)) continue;
65
+ if (name === 'llmanspec') continue;
66
+ walk(full, depth + 1);
67
+ }
68
+ };
69
+ walk(startDir, 0);
70
+ return roots;
71
+ }
72
+
73
+ export interface ScopeCrossing {
74
+ scope: string;
75
+ rootDir: string;
76
+ }
77
+
78
+ /**
79
+ * Single-ownership rule (r92): a file path belongs to exactly one llmanspec
80
+ * root. Scope entries are resolved against the owning instance root; a scope
81
+ * that reaches into another root's instance directory crosses the boundary
82
+ * and is reported with the offending scope and the other root. Ancestor roots
83
+ * are exempt: a sub-root scoping its own subtree always resolves under the
84
+ * ancestor's directory, but ownership there belongs to the descendant.
85
+ */
86
+ export function scopeCrossings(
87
+ scopePaths: readonly string[],
88
+ instanceRootDir: string,
89
+ otherRoots: readonly RootEntry[],
90
+ ): ScopeCrossing[] {
91
+ const crossings: ScopeCrossing[] = [];
92
+ for (const raw of scopePaths) {
93
+ const scope = raw.trim().replace(/^\.\//u, '').replace(/\/+$/u, '');
94
+ if (scope === '') continue;
95
+ const abs = joinPath(instanceRootDir, scope);
96
+ for (const other of otherRoots) {
97
+ if (other.rootDir === instanceRootDir) continue;
98
+ // Ancestor exemption: our own subtree necessarily sits inside the
99
+ // ancestor's directory — the ancestor does not own it.
100
+ if (instanceRootDir.startsWith(`${other.rootDir}/`)) continue;
101
+ if (abs === other.rootDir || abs.startsWith(`${other.rootDir}/`)) {
102
+ crossings.push({ scope: raw.trim(), rootDir: other.rootDir });
103
+ break;
104
+ }
105
+ }
106
+ }
107
+ return crossings;
108
+ }
109
+
110
+ /**
111
+ * Nearest instance root at or above `startDir` (inclusive) — the cwd
112
+ * resolution behind "cd into the subpackage and run" (r94). Returns null when
113
+ * no ancestor carries a valid llmanspec root.
114
+ */
115
+ export function resolveInstanceRoot(startDir: string, io: RootsIo): string | null {
116
+ let dir = startDir;
117
+ for (;;) {
118
+ if (isValidRoot(joinPath(dir, 'llmanspec'), io)) return dir;
119
+ const parent = dir.replace(/\/+$/u, '').replace(/\/[^/]+$/u, '');
120
+ if (parent === '' || parent === dir) return null;
121
+ dir = parent;
122
+ }
123
+ }
@@ -75,9 +75,16 @@ Run the project gates as appropriate:
75
75
  - Edit `llmanspec/specs/<capability>.feature` on the branch as needed (flat or directory main file; canonical native layout: `@req:<id>` on the `规则:` block header, nested `场景:` as executable examples); run `llman-sdd validate --specs` after spec edits; commit on the branch freely.
76
76
  - SDD validation: `llman-sdd validate <id> --strict`
77
77
 
78
+ **Verification ladder (opt-in discipline, escalate by cost)**:
79
+ - L1 minimal unit: `bun test tests/unit/<relevant>` (<1s, direct import), covers only the current change point.
80
+ - L2 targeted behavior: `bun test tests/bdd -t "<scenario/rule title pattern>"` — exercises only the relevant real-CLI path.
81
+ - L3 explicit full harness: `llman-sdd validate --check` (with `specs.check_command` configured; plain `validate` no longer runs the harness).
82
+ - L4 close-out backstop: `change finalize` runs the real harness pre-merge — at least one full run per close-out.
83
+ - Self-heal loops default to L1/L2 for fast reproduction; use L3 only when full-acceptance evidence is needed; avoid paying full-suite cost needlessly.
84
+
78
85
  **Gate evidence**:
79
- - Close-out runs the configured `specs.check_command`, so do not run that command again just before close-out; the skip line printed by `--no-check` is not a pass.
80
- - Gate verdicts MUST come from the real harness: MUST NOT obtain a "pass" via `--no-check`; on harness failure, find the root cause first (leaked env vars, nested-invocation guards, wrong cwd …) — MUST NOT label it an "inherent/self-referential property" and bypass it.
86
+ - Plain `validate` is structure/state-only and claims no harness evidence; harness evidence MUST come from explicit `--check` or close-out acceptance.
87
+ - Gate verdicts MUST come from the real harness: claiming a harness pass requires `--check` (or close-out); the default structural gate is not a pass; on harness failure, find the root cause first (leaked env vars, nested-invocation guards, wrong cwd …) — MUST NOT label it an "inherent/self-referential property" and bypass it.
81
88
  - Before/after criteria (counts, baselines) MUST be measured on the change branch (against the freshly computed merge-base); a value measured on the default branch is usually trivially the baseline and proves nothing.
82
89
  - Refactors and bulk replacements: MUST compare the test count before and after; all-green gates with fewer tests is a failure.
83
90
 
@@ -46,7 +46,7 @@ flowchart LR
46
46
  - Write decisions back: resolved decisions go into the change's `proposal.md` "Open Questions" section.
47
47
  - Completion criterion: every pending decision is resolved or explicitly deferred. When not triggered, the default (ask 1–3 questions) behavior is unchanged.
48
48
  4. If a change id is relevant, read its artifacts under `llmanspec/changes/<id>/`.
49
- - When diagnosing validation errors, run `llman-sdd validate <spec> --strict` first for the structural gates (Gherkin / `@req` linkage / dual-write / req_id uniqueness); when `specs.check_command` is configured, validate executes that harness by default (`--no-check` skips it). Failing items are pinned down in the default TOON output's `items[].issues[]`; `--output human` prints `FAIL <item_type>/<id>` lines.
49
+ - When diagnosing validation errors, run `llman-sdd validate <spec> --strict` first for the structural gates (Gherkin / `@req` linkage / dual-write / req_id uniqueness); when `specs.check_command` is configured, validate only executes that harness with explicit `--check` (default skips). Failing items are pinned down in the default TOON output's `items[].issues[]`; `--output human` prints `FAIL <item_type>/<id>` lines.
50
50
  5. Explore options and tradeoffs (2–3 options).
51
51
  6. Assess change scale to determine if full SDD is needed.
52
52
  7. When something crystallizes, offer to capture it (don't auto-write):
@@ -85,7 +85,7 @@ This MUST pass before proceeding; failing items are listed one by one in the val
85
85
 
86
86
  ### 4a) Optional BDD runner (`specs:` block)
87
87
  - Read `llmanspec/config.yaml`. Is there a `specs:` block?
88
- - **Yes**: `specs.check_command` declares the project's BDD execution entry; validate executes it by default when its target set includes specs (`--no-check` skips). Authoring follows 4b regardless.
88
+ - **Yes**: `specs.check_command` declares the project's BDD execution entry; validate runs it only via explicit `--check` (default skips — structure-only). Authoring follows 4b regardless.
89
89
  - **No**: if this change involves executable behavior scenarios (Given/When/Then the user will want to run), ask **once, up front** whether to enable a `specs:` verification runner block (adds a `specs:` block to `config.yaml` — runner only, does not change the lifecycle). If **yes**: show the exact `specs:` block to add (pick a `check_command` matching the project's test framework — `cargo test --features bdd` for rstest-bdd, `pytest {feature_dir} -k {feature_name} -v` for pytest-bdd), let the user confirm or edit, write it to `config.yaml`, then proceed with 4b. If **no**: features still validate structurally; BDD execution responsibility stays with the project test suite.
90
90
  - **Do NOT silently add the `specs:` block** — always ask first. Adding it declares the project-wide BDD execution entry.
91
91
 
@@ -16,7 +16,7 @@ Validate change/spec format and staleness.
16
16
  3. **BDD checks**:
17
17
  - Validate `.feature` Gherkin and `@req` / dual-write gates on the **bound branch**; `.feature` is the harness authority — executable GWT lives only there.
18
18
  - Lifecycle gates: `change start` / `attach` (bind branch), `finalize` (close-out; auto commit `archive(sdd): <id>`, `--no-commit` to skip) / `diff` (read-only).
19
- - `llman-sdd validate --specs` enforces structural and contract gates; when `specs.check_command` is configured it also executes that harness by default (`--no-check` skips it, `--check` is a compat alias); a placeholder-free command runs at most once per invocation.
19
+ - `llman-sdd validate --specs` enforces structural and contract gates; when `specs.check_command` is configured the full harness runs only with explicit `--check` (default skips — structure-only); a placeholder-free command runs at most once per invocation.
20
20
  - `list --specs --json` shows `morphology` (requirementCount / requirementBoundCount / requirementUnboundCount / acceptanceCount / featureScenarioCount).
21
21
  - Change JSON status fields: `stage` (draft/designed/planned/full) / `specsLanded` / `needsSpecsChange` / `readyToImplement` (`show --output json`).
22
22
  {% endif %}
@@ -25,8 +25,8 @@ flowchart LR
25
25
 
26
26
  - **Apply must be all-green first**: don't verify unimplemented changes.
27
27
  - **CRITICAL must be fixed**: zero CRITICAL before archive.
28
- - **Rerun the gates yourself**: MUST rerun `llman-sdd validate <id> --strict` (real harness) and the project gates; MUST NOT trust gate verdicts in the implementer's report — a mismatch is CRITICAL.
29
- - **`--no-check` is not evidence**: gate evidence obtained with `--no-check` → CRITICAL. Close-out runs the configured `specs.check_command`, so do not run that command again just before close-out; the skip line printed by `--no-check` is not a pass.
28
+ - **Rerun the gates yourself**: MUST rerun `llman-sdd validate <id> --check --strict` (real harness evidence) and the project gates; MUST NOT trust gate verdicts in the implementer's report — a mismatch is CRITICAL.
29
+ - **Harness evidence requires `--check`**: plain `validate` does not run the harness; claiming full-harness evidence MUST use explicit `--check` — the default structural gate is not a pass. Close-out runs the configured `specs.check_command`, so do not run it again just before close-out.
30
30
  - **Don't ask "should I continue?"**: run the full verification flow and output a complete report.
31
31
 
32
32
  {{ unit("skills/stage-guard") }}
@@ -34,7 +34,7 @@ flowchart LR
34
34
  ## Steps
35
35
  1. Select the change id (or ask the user to pick from `llman-sdd list --json`).
36
36
  2. Fast validation gate: `llman-sdd validate <id> --strict`.
37
- - When diagnosing structural issues (Gherkin parse / `@req` linkage / dual-write / req_id uniqueness), run the structural validation first (when `specs.check_command` is configured, validate executes that harness by default — `--no-check` skips it; a harness failure lands as an ERROR on its spec item). Failing items are listed one by one in the default TOON output's `items[].issues[]` (`--output human` prints `FAIL <item_type>/<id>` lines above the `Totals` line).
37
+ - When diagnosing structural issues (Gherkin parse / `@req` linkage / dual-write / req_id uniqueness), run the structural validation first (the full harness only runs via explicit `--check`; plain `validate` no longer runs it by default; a harness failure lands as an ERROR on its spec item). Failing items are listed one by one in the default TOON output's `items[].issues[]` (`--output human` prints `FAIL <item_type>/<id>` lines above the `Totals` line).
38
38
  3. Read: `llmanspec/specs/**` (`<capability>.feature`, the single source of truth) on the branch, `proposal.md` and `design.md` (if present), `tasks.md`; ignore residual old docs under `changes/<id>/specs/`.
39
39
  4. **Dual-axis review (kept separate so neither masks the other)** — diff against `git diff <merge-base>...HEAD` (merge-base is COMPUTED via `git merge-base <local-default> HEAD`; the stored base_sha is audit-only and MUST NOT feed range math):
40
40
  - **Spec axis**: does the implementation satisfy the `规则:` block requirement statement (free-text description, judged by its semantics) and the nested `场景:` GWT steps? Missing/partial behaviors, wrong implementations, and scope creep not asked for by the spec → suggest minimal fixes or artifact updates. Check where before/after evidence (counts, baselines) was taken: it MUST be measured on the change branch (against the freshly computed merge-base); a value measured on the default branch is usually trivially the baseline and proves nothing.
@@ -57,7 +57,7 @@ flowchart LR
57
57
  - The two axes may be reviewed in parallel (sub-agents); the report MUST present them separately, MUST NOT merge or cross-rerank (one axis passing must not mask the other failing).
58
58
  5. **BDD verification** — only when `config.yaml` has a `specs:` block:
59
59
  - Confirm the change is branch-bound and you are on that branch.
60
- - `llman-sdd validate --specs`: Gherkin + `@req`/dual-write gates; when `specs.check_command` is configured the harness runs by default (`--no-check` skips it) and a failure maps to an ERROR on the matching spec item.
60
+ - `llman-sdd validate --specs`: Gherkin + `@req`/dual-write gates; the full harness only runs via explicit `--check` (default skips) and a failure maps to an ERROR on the matching spec item.
61
61
  - Optional read-only review: `llman-sdd change diff <id>` (or `--export-patch <path>`) — review/export only, never an apply step.
62
62
  - Next step after verify passes: `llman-sdd-archive` (not inline finalize here).
63
63
  {% if specs_verify_prompt %}
@@ -75,9 +75,16 @@ flowchart LR
75
75
  - 分支上按需编辑 `llmanspec/specs/<capability>.feature`(扁平或目录主文件;统一原生分层:`@req:<id>` 挂 `规则:` 块头、嵌套 `场景:` 为可执行示例),spec 改动后跑 `llman-sdd validate --specs`;分支上可自由提交。
76
76
  - SDD 校验:`llman-sdd validate <id> --strict`
77
77
 
78
+ **验证阶梯(opt-in 纪律,按成本逐级扩大)**:
79
+ - L1 最小单元:`bun test tests/unit/<相关>`(<1s,直接导入),仅覆盖当前改动点。
80
+ - L2 定向行为:`bun test tests/bdd -t "<场景/规则标题模式>"`——只跑相关场景的真实 CLI 链路。
81
+ - L3 显式全量:`llman-sdd validate --check`(配置 `specs.check_command` 时执行全量 harness;缺省 validate 不再执行 harness)。
82
+ - L4 收口兜底:`change finalize` 预合并强制真实 harness——每次收口至少 1 次全量。
83
+ - 自修复循环默认用 L1/L2 快速复现;只有需要全量验收时才 L3;避免无谓白付全量成本。
84
+
78
85
  **门禁证据**:
79
- - 收口会执行已配置的 `specs.check_command`,收口前不必再跑一遍;`--no-check` 打出的跳过说明不是通过。
80
- - 门禁结论 MUST 来自真实 harness:MUST NOT 以 `--no-check` 取得「通过」;harness 失败 MUST 先查根因(环境变量泄漏、嵌套调用守卫、工作目录错误等),MUST NOT 以「固有/自指属性」定性后绕过。
86
+ - 缺省 `validate` 只做结构/状态门,不声明 harness 证据;harness 证据 MUST 经显式 `--check` 或收口验收取得。
87
+ - 门禁结论 MUST 来自真实 harness:声称 harness 合格时 MUST 已用 `--check`(或收口);缺省结构门的通过不是通过;harness 失败 MUST 先查根因(环境变量泄漏、嵌套调用守卫、工作目录错误等),MUST NOT 以「固有/自指属性」定性后绕过。
81
88
  - 前后对比类判据(计数、基线)MUST 在 change 分支上测量(相对现算 merge-base);默认分支测得的值通常恒为基线,不构成证据。
82
89
  - 重构或批量替换类 task:MUST 对比改动前后测试用例数;门禁全绿但用例数下降视为失败。
83
90
 
@@ -46,7 +46,7 @@ flowchart LR
46
46
  - 决策回写:已解决的决策写进该 change 的 `proposal.md`「Open Questions」段。
47
47
  - 完成判据:每个待定决策都已解决或显式推迟。未触发时保持默认(问 1–3 个问题)。
48
48
  4. 涉及某个 change id 时,读 `llmanspec/changes/<id>/` 下的工件。
49
- - 诊断校验错误先跑 `llman-sdd validate <spec> --strict` 过结构门禁(Gherkin / `@req` 链接 / 双写 / req_id 唯一性);配置了 `specs.check_command` 时 validate 缺省执行该 harness(`--no-check` 跳过)。失败项在缺省 TOON 输出的 `items[].issues[]` 逐条指明;`--output human` 输出人读 `FAIL <item_type>/<id>` 行。
49
+ - 诊断校验错误先跑 `llman-sdd validate <spec> --strict` 过结构门禁(Gherkin / `@req` 链接 / 双写 / req_id 唯一性);配置了 `specs.check_command` 时 validate 仅经显式 `--check` 执行该 harness(缺省跳过)。失败项在缺省 TOON 输出的 `items[].issues[]` 逐条指明;`--output human` 输出人读 `FAIL <item_type>/<id>` 行。
50
50
  5. 探索 2–3 个选项与权衡。
51
51
  6. 判断变更规模,确定是否走完整 SDD。
52
52
  7. 结论清晰时建议用户记录(勿自动写):
@@ -85,7 +85,7 @@ MUST 通过才能继续;失败项在 validate 输出的 `items[].issues[]` 逐
85
85
 
86
86
  ### 4a) 可选 BDD runner(`specs:` 段)
87
87
  - 读 `llmanspec/config.yaml` 是否含 `specs:` 段:
88
- - **有**:`specs.check_command` 是项目的 BDD 执行入口;validate 在目标集含 spec 时缺省执行它(`--no-check` 跳过)。撰写仍按 4b。
88
+ - **有**:`specs.check_command` 是项目的 BDD 执行入口;validate 仅在显式 `--check` 时执行它(缺省跳过,仅结构/状态门)。撰写仍按 4b。
89
89
  - **无**:若本次 change 含可执行行为场景(用户会想运行的 Given/When/Then),**一次性前置**询问是否启用 `specs:` 验证 runner 段(会向 `config.yaml` 加一个 `specs:` 段——仅 runner,不改生命周期)。**是**:展示要加的精确 `specs:` 段(`check_command` 选匹配项目测试框架的——rstest-bdd 用 `cargo test --features bdd`,pytest-bdd 用 `pytest {feature_dir} -k {feature_name} -v`),用户确认或修改后写入 `config.yaml`,再按 4b 继续。**否**:feature 仍做结构校验;BDD 执行责任始终在项目测试套件。
90
90
  - **MUST NOT 静默添加 `specs:` 段**——总是先问。添加它会向全项目声明 BDD 执行入口。
91
91
 
@@ -16,7 +16,7 @@ metadata:
16
16
  3. **Spec 校验**:
17
17
  - 在**绑定分支**上验证 `.feature` Gherkin 与 `@req` / 双写门禁;`.feature` 是 harness 权威——可执行 GWT 只在其中维护。
18
18
  - 生命周期门禁:`change start` / `attach`(绑定分支)、`finalize`(收口;自动提交 `archive(sdd): <id>`,`--no-commit` 跳过)/ `diff`(只读)。
19
- - `llman-sdd validate --specs` 做结构与合约门禁;配置 `specs.check_command` 时缺省执行该 harness(`--no-check` 跳过,`--check` 为兼容别名),无占位符的命令每次调用至多执行一次。
19
+ - `llman-sdd validate --specs` 做结构与合约门禁;配置 `specs.check_command` 时全量 harness 仅经显式 `--check` 执行(缺省跳过,仅结构/状态门),无占位符的命令每次调用至多执行一次。
20
20
  - `list --specs --json` 查看 `morphology`(requirementCount / requirementBoundCount / requirementUnboundCount / acceptanceCount / featureScenarioCount)。
21
21
  - change JSON 状态字段:`stage`(draft/designed/planned/full)/ `specsLanded` / `needsSpecsChange` / `readyToImplement`(`show --output json`)。
22
22
  {% endif %}
@@ -25,8 +25,8 @@ flowchart LR
25
25
 
26
26
  - **必须先 apply 全绿**:未完成实现的 change 跳过验证。
27
27
  - **CRITICAL 必须修复**:归档前清零。
28
- - **亲自复跑门禁**:MUST 亲自重跑 `llman-sdd validate <id> --strict`(真实 harness)与项目门禁,MUST NOT 采信实现者报告的门禁结论;复跑结果与报告不符 → CRITICAL。
29
- - **`--no-check` 不是证据**:以 `--no-check` 取得的门禁证据 → CRITICAL。收口会执行已配置的 `specs.check_command`,收口前不必再跑一遍;`--no-check` 打出的跳过说明不是通过。
28
+ - **亲自复跑门禁**:MUST 亲自重跑 `llman-sdd validate <id> --check --strict`(真实 harness 证据)与项目门禁,MUST NOT 采信实现者报告的门禁结论;复跑结果与报告不符 → CRITICAL。
29
+ - **harness 证据必须 `--check`**:缺省 `validate` 不执行 harness,声称全量 harness 证据 MUST 经显式 `--check`(缺省结构门的通过不是通过);收口会执行已配置的 `specs.check_command`,收口前不必再跑一遍。
30
30
  - **不要问「要不要继续」**:跑完整验证流程,输出完整报告。
31
31
 
32
32
  {{ unit("skills/stage-guard") }}
@@ -34,7 +34,7 @@ flowchart LR
34
34
  ## 步骤
35
35
  1. 确定 change id(不明确时让用户从 `llman-sdd list --json` 选)。
36
36
  2. 快速校验门禁:`llman-sdd validate <id> --strict`。
37
- - 诊断结构问题(Gherkin 解析 / `@req` 链接 / 双写 / req_id 唯一性)先跑结构校验(配置 `specs.check_command` 时 validate 缺省执行该 harness,`--no-check` 跳过;harness 失败以 ERROR 落在对应 spec 条目)。失败项在缺省 TOON 输出的 `items[].issues[]` 逐条列出(`--output human` 输出 `FAIL <item_type>/<id>` 行,位于 `Totals` 上方)。
37
+ - 诊断结构问题(Gherkin 解析 / `@req` 链接 / 双写 / req_id 唯一性)先跑结构校验(全量 harness 仅经显式 `--check` 执行,缺省不再自动执行;harness 失败以 ERROR 落在对应 spec 条目)。失败项在缺省 TOON 输出的 `items[].issues[]` 逐条列出(`--output human` 输出 `FAIL <item_type>/<id>` 行,位于 `Totals` 上方)。
38
38
  3. 阅读:分支上的 `llmanspec/specs/**`(`<capability>.feature`,唯一事实来源)、`proposal.md` 与 `design.md`(如有)、`tasks.md`;`changes/<id>/specs/` 若有残留旧文档可忽略。
39
39
  4. **双轴审查(两轴分离,互不掩盖)**——对比 diff(`git diff <merge-base>...HEAD`,merge-base 现算 `git merge-base <本地默认分支> HEAD`;存储的 base_sha 仅审计、MUST NOT 参与范围计算):
40
40
  - **合约轴**:实现是否满足 `规则:` 块的需求表述(描述为自由文本,以其语义为准)与嵌套 `场景:` 的 GWT 步骤?缺失/部分实现、错误实现、spec 未要求的超范围改动 → 给最小修复建议或建议更新工件。前后对比类证据(计数、基线)核对测量位置:MUST 在 change 分支上测量(相对现算 merge-base);默认分支测得的值通常恒为基线,不构成证据。
@@ -57,7 +57,7 @@ flowchart LR
57
57
  - 两轴可并行(sub-agent)审查;报告 MUST 分离呈现,MUST NOT 合并或交叉重排(一轴通过不能掩盖另一轴失败)。
58
58
  5. **Spec 验证**——仅当 `config.yaml` 含 `specs:` 段:
59
59
  - 确认 change 已绑定分支且当前在该分支上。
60
- - `llman-sdd validate --specs`:Gherkin + `@req`/双写门禁;配置 `specs.check_command` 时缺省执行该 harness(`--no-check` 跳过),失败映射为对应 spec 条目的 ERROR。
60
+ - `llman-sdd validate --specs`:Gherkin + `@req`/双写门禁;全量 harness 仅经 `--check` 显式执行(缺省跳过),失败映射为对应 spec 条目的 ERROR。
61
61
  - 可选只读审查:`llman-sdd change diff <id>`(或 `--export-patch <path>`)——仅审查/导出,绝不当作 apply 步骤。
62
62
  - verify 通过后下一步 `llman-sdd-archive`(勿在此 inline finalize)。
63
63
  {% if specs_verify_prompt %}
package/src/ports.ts DELETED
@@ -1,14 +0,0 @@
1
- /**
2
- * Ports (spec monorepo-structure r3): domain logic must not touch the
3
- * filesystem, git subprocesses, or the terminal directly — side effects are
4
- * injected through these interfaces. Nothing under packages/core may import
5
- * `node:fs`, `Bun.$`, or prompt libraries; runtimes wire adapters in.
6
- */
7
-
8
- /** Interaction port. predecessor ships an @inquirer/prompts adapter; a future ink TUI
9
- * ships its own adapter (the two must never run in the same process). */
10
- export interface PromptDriver {
11
- select<T extends string>(message: string, choices: readonly T[]): Promise<T>;
12
- multiselect<T extends string>(message: string, choices: readonly T[]): Promise<T[]>;
13
- confirm(message: string): Promise<boolean>;
14
- }