@llman-sdd/core 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llman-sdd/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Pure domain logic for llman-sdd (config, gherkin specs, validation, templates)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -184,6 +184,14 @@ export function finalizeChange(
184
184
  if (binding === null) {
185
185
  throw new LifecycleError(`change \`${id}\` has no branch binding — run start/attach first`);
186
186
  }
187
+ // r15 (v1 r94): finalize runs on the bound branch — any other branch must
188
+ // fail before any write (no switch, no merge, no rename).
189
+ const current = currentBranch(git);
190
+ if (current !== binding.branch) {
191
+ throw new LifecycleError(
192
+ `finalize must run on the bound branch \`${binding.branch}\` (current: ${current ?? 'detached HEAD'})`,
193
+ );
194
+ }
187
195
  const method = opts.method ?? 'squash';
188
196
  const target = opts.into ?? binding.baseBranch ?? defaultBranch(git);
189
197
  return mergeRenameCommit(git, io, id, binding.branch, target, method, opts.today, opts.noCommit);
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Change-id prefix resolution (peripheral-commands r61, v1 cli spec r112):
3
+ * exact match > unique prefix > multiple candidates > no match, all
4
+ * case-sensitive. Candidates are the active change ids discovered via
5
+ * collectChanges (depth-limited, archive-skipping). Pure — IO is injected.
6
+ */
7
+ import { collectChanges, type ChangeFsIo } from '../report/collect.ts';
8
+
9
+ export interface ResolvedChangeId {
10
+ id: string;
11
+ viaPrefix: boolean;
12
+ }
13
+
14
+ export class ChangeIdResolveError extends Error {}
15
+
16
+ export function resolveChangeId(
17
+ io: ChangeFsIo,
18
+ root: string,
19
+ input: string,
20
+ opts: { maxScanDepth?: number } = {},
21
+ ): ResolvedChangeId {
22
+ const ids = collectChanges(io, root, new Date(0), opts).map((c) => c.name);
23
+ if (ids.includes(input)) return { id: input, viaPrefix: false };
24
+ const matches = ids.filter((id) => id.startsWith(input));
25
+ if (matches.length === 1) return { id: matches[0] as string, viaPrefix: true };
26
+ if (matches.length > 1) {
27
+ throw new ChangeIdResolveError(
28
+ `change '${input}' matches multiple active changes:\n${matches
29
+ .map((m) => ` - ${m}`)
30
+ .join('\n')}\nDid you mean one of these?`,
31
+ );
32
+ }
33
+ throw new ChangeIdResolveError(`change not found: ${input}`);
34
+ }
@@ -124,3 +124,44 @@ export function loadTree(io: IndexIo): SerializedTreeIndex | null {
124
124
  return null;
125
125
  }
126
126
  }
127
+
128
+ export interface AutoRefreshResult {
129
+ tree: SerializedTreeIndex | null;
130
+ rebuilt: boolean;
131
+ error: string | null;
132
+ }
133
+
134
+ /**
135
+ * r62: retrieval-side lazy refresh — a missing/corrupted/stale index is
136
+ * rebuilt once (zero-LLM) before retrieval; a failed rebuild surfaces
137
+ * `index_rebuild_failed` instead of a missing/stale quality note.
138
+ */
139
+ export function loadTreeWithAutoRebuild(
140
+ io: IndexIo,
141
+ specsDir: string,
142
+ entries: readonly SpecEntry[],
143
+ opts: RebuildOpts,
144
+ ): AutoRefreshResult {
145
+ const initial = loadTree(io);
146
+ if (initial !== null && checkIndexFreshness(io, specsDir).fresh) {
147
+ return { tree: initial, rebuilt: false, error: null };
148
+ }
149
+ try {
150
+ rebuildIndex(io, specsDir, entries, opts);
151
+ } catch (error) {
152
+ return {
153
+ tree: null,
154
+ rebuilt: false,
155
+ error: `auto-rebuild failed: ${(error as Error).message} — run \`llman-sdd index rebuild\``,
156
+ };
157
+ }
158
+ const tree = loadTree(io);
159
+ if (tree === null) {
160
+ return {
161
+ tree: null,
162
+ rebuilt: true,
163
+ error: 'auto-rebuild produced an unreadable tree.json — run `llman-sdd index rebuild`',
164
+ };
165
+ }
166
+ return { tree, rebuilt: true, error: null };
167
+ }
package/src/index.ts CHANGED
@@ -112,6 +112,7 @@ export {
112
112
  } from './git/spawnGit.ts';
113
113
  export { DRAFT_PROPOSAL_TEMPLATE, deriveChangeId, isLegalChangeId } from './change/id.ts';
114
114
  export { extractUniqueNumber, harvestUniqueNumbers } from './change/nextId.ts';
115
+ export { ChangeIdResolveError, resolveChangeId, type ResolvedChangeId } from './change/resolve.ts';
115
116
  export {
116
117
  CHANGES_DIR,
117
118
  LifecycleError,
@@ -184,6 +185,7 @@ export {
184
185
  } from './report/specHelpers.ts';
185
186
  export {
186
187
  collectSpecs,
188
+ morphologyOfScenarios,
187
189
  renderSpecsJson,
188
190
  renderSpecsList,
189
191
  type SpecMorphology,
@@ -235,8 +237,10 @@ export {
235
237
  TREE_JSON_REL,
236
238
  checkIndexFreshness,
237
239
  loadTree,
240
+ loadTreeWithAutoRebuild,
238
241
  parseLock,
239
242
  rebuildIndex,
243
+ type AutoRefreshResult,
240
244
  type FreshnessResult,
241
245
  type IndexIo,
242
246
  type RebuildOpts,
@@ -22,7 +22,11 @@ export interface ShowDeps {
22
22
  specsDir: string;
23
23
  }
24
24
 
25
- export function showChangeJson(deps: ShowDeps, id: string): Record<string, unknown> {
25
+ export function showChangeJson(
26
+ deps: ShowDeps,
27
+ id: string,
28
+ opts: { matchedViaPrefix?: boolean } = {},
29
+ ): Record<string, unknown> {
26
30
  const { io, git, root } = deps;
27
31
  const dir = `${root}/${CHANGES_DIR}/${id}`;
28
32
  const proposalPath = `${dir}/proposal.md`;
@@ -129,6 +133,6 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
129
133
  deltaCount: 0,
130
134
  deltas: [] as string[],
131
135
  gateChecks,
132
- matchedViaPrefix: false,
136
+ matchedViaPrefix: opts.matchedViaPrefix === true,
133
137
  };
134
138
  }
@@ -10,7 +10,6 @@ import { pad } from './collect.ts';
10
10
  export interface SpecMorphology {
11
11
  ruleCount: number;
12
12
  ruleEnforcedCount: number;
13
- ruleManualCount: number;
14
13
  rulePendingCount: number;
15
14
  acceptanceCount: number;
16
15
  orphanAcceptanceCount: number;
@@ -27,22 +26,30 @@ export interface SpecSummary {
27
26
  morphology: SpecMorphology;
28
27
  }
29
28
 
30
- function morphologyOf(doc: CapabilityDoc): SpecMorphology {
31
- const rules = doc.scenarios.filter((s) => s.classification === 'human');
32
- const acceptance = doc.scenarios.filter((s) => s.classification === 'executable');
33
- const acceptanceReqIds = new Set(acceptance.flatMap((s) => s.reqIds));
29
+ /** Morphology counts shared by `list --specs`, `show <spec> --json`, and the
30
+ * CLI text render — the single source of the enforced/pending two-state rule
31
+ * (rules = @human; enforced = @req-linked acceptance coverage; pending = rest). */
32
+ export function morphologyOfScenarios(
33
+ scenarios: readonly { classification: string; reqIds: readonly string[] }[],
34
+ ): SpecMorphology {
35
+ const rules = scenarios.filter((s) => s.classification === 'human');
36
+ const acceptance = scenarios.filter((s) => s.classification === 'executable');
37
+ const acceptanceReqIds = new Set(acceptance.flatMap((s) => [...s.reqIds]));
34
38
  const enforced = rules.filter((r) => r.reqIds.some((id) => acceptanceReqIds.has(id)));
35
39
  const orphan = acceptance.filter((s) => s.reqIds.length === 0);
36
40
  return {
37
41
  ruleCount: rules.length,
38
42
  ruleEnforcedCount: enforced.length,
39
- ruleManualCount: rules.filter((r) => r.manual).length,
40
43
  rulePendingCount: rules.length - enforced.length,
41
44
  acceptanceCount: acceptance.length,
42
45
  orphanAcceptanceCount: orphan.length,
43
46
  };
44
47
  }
45
48
 
49
+ function morphologyOf(doc: CapabilityDoc): SpecMorphology {
50
+ return morphologyOfScenarios(doc.scenarios);
51
+ }
52
+
46
53
  export function collectSpecs(entries: readonly SpecEntry[]): SpecSummary[] {
47
54
  return entries.map((e) => {
48
55
  const morphology = morphologyOf(e.doc);
@@ -68,7 +75,7 @@ export function renderSpecsList(summaries: readonly SpecSummary[]): string[] {
68
75
  for (const s of summaries) {
69
76
  const m = s.morphology;
70
77
  lines.push(
71
- ` ${pad(s.id, idWidth)}rules ${m.ruleCount} enforced ${m.ruleEnforcedCount} manual ${m.ruleManualCount} pending ${m.rulePendingCount} acceptance ${m.acceptanceCount}`,
78
+ ` ${pad(s.id, idWidth)}rules ${m.ruleCount} enforced ${m.ruleEnforcedCount} pending ${m.rulePendingCount} acceptance ${m.acceptanceCount}`,
72
79
  );
73
80
  }
74
81
  return lines;
@@ -6,7 +6,7 @@ import { evaluateStaleness, notApplicableStaleness } from '../validation/stalene
6
6
  */
7
7
  import { validateAllSpecs, type SpecEntry, type SpecIo } from '../validation/validate.ts';
8
8
 
9
- export type ReviewKind = 'pending' | 'manual' | 'unbound' | 'stale' | 'locked' | 'validate';
9
+ export type ReviewKind = 'pending' | 'unbound' | 'stale' | 'locked' | 'validate';
10
10
 
11
11
  export interface ReviewSignal {
12
12
  kind: ReviewKind;
@@ -28,7 +28,7 @@ export interface ReviewInput {
28
28
  boundChangeCount: number;
29
29
  /** Active change summaries for the strict sweep (pending tasks → FAIL). */
30
30
  activeChanges?: readonly { name: string; completedTasks: number; totalTasks: number }[];
31
- /** Restrict per-capability signals (pending/manual/unbound/stale) to this capability. */
31
+ /** Restrict per-capability signals (pending/unbound/stale) to this capability. */
32
32
  capability?: string;
33
33
  /** git + root for real staleness evaluation (v1 parity). */
34
34
  git?: GitLike;
@@ -65,12 +65,10 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
65
65
  const acceptance = entry.doc.scenarios.filter((s) => s.classification === 'executable');
66
66
  const acceptanceReqIds = new Set(acceptance.flatMap((s) => s.reqIds));
67
67
  const pending = rules.filter((r) => !r.reqIds.some((id) => acceptanceReqIds.has(id)));
68
- const manual = rules.filter((r) => r.manual);
69
68
  // v1 r5: unbound = orphan acceptance scenarios (no @req link).
70
69
  const unbound = acceptance.filter((s) => s.reqIds.length === 0);
71
70
 
72
71
  push('pending', cap, pending.length);
73
- push('manual', cap, manual.length);
74
72
  push('unbound', cap, unbound.length);
75
73
 
76
74
  // staleness (v1 evaluate): real base-ref/scope evaluation.
@@ -131,7 +129,7 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
131
129
 
132
130
  const lines: string[] = [`Review: critical=${criticalCount} warning=${warningCount}`];
133
131
  for (const cap of sorted.map((e) => e.doc.header.capability ?? e.fileName)) {
134
- for (const kind of ['pending', 'manual', 'unbound', 'stale'] as const) {
132
+ for (const kind of ['pending', 'unbound', 'stale'] as const) {
135
133
  const s = signals.find((x) => x.kind === kind && x.capability === cap);
136
134
  if (!s) continue;
137
135
  lines.push(`${kind}: ${cap} (${s.count})`);
package/src/spec/ir.ts CHANGED
@@ -19,7 +19,6 @@ export interface ScenarioIR {
19
19
  /** `@req:rN` links, normalized to `rN`. */
20
20
  reqIds: string[];
21
21
  classification: ScenarioClassification;
22
- manual: boolean;
23
22
  /** Rule statement (description lines, trimmed) + step texts for executables. */
24
23
  statement: string;
25
24
  stepCount: number;
@@ -77,20 +77,18 @@ function extractHeader(source: string): CapabilityHeader {
77
77
 
78
78
  function classify(tags: string[]): {
79
79
  classification: ScenarioIR['classification'];
80
- manual: boolean;
81
80
  errors: SpecStructuralError[];
82
81
  } {
83
82
  const errors: SpecStructuralError[] = [];
84
83
  const has = (t: string): boolean => tags.includes(t);
85
84
  const human = has('human');
86
85
  const executable = has('executable');
87
- const manual = has('manual');
88
86
  const label = tags.join(',');
89
87
 
90
- if (manual && !human) {
88
+ if (has('manual')) {
91
89
  errors.push({
92
- code: 'tag:manual-orphan',
93
- message: `@manual 必须与 @human 同用(tags: ${label})`,
90
+ code: 'tag:manual-removed',
91
+ message: `@manual was removed in 0.3.0 — drop the tag (@human already carries the human-judgement semantics) (tags: ${label})`,
94
92
  });
95
93
  }
96
94
  if (human && executable) {
@@ -104,7 +102,7 @@ function classify(tags: string[]): {
104
102
  : executable
105
103
  ? 'executable'
106
104
  : 'unclassified';
107
- return { classification, manual, errors };
105
+ return { classification, errors };
108
106
  }
109
107
 
110
108
  /** Parse one capability .feature source into the single-track IR. */
@@ -165,7 +163,6 @@ export function parseCapability(source: string, fileName = '<inline>'): Capabili
165
163
  tags,
166
164
  reqIds,
167
165
  classification: kind.classification,
168
- manual: kind.manual,
169
166
  statement,
170
167
  stepCount: stepTexts.length,
171
168
  steps,
@@ -1,8 +1,10 @@
1
+ import { readBinding } from '../change/frontmatter.ts';
1
2
  /**
2
3
  * Change-domain validation (v1 `commands/validate.rs` change path parity):
3
4
  * frontmatter/depends_on gates, design/tasks constraints, completeness stage
4
5
  * INFO, pattern gate and task gates. IO + git injected (pure).
5
6
  */
7
+ import type { GitLike } from '../git/spawnGit.ts';
6
8
 
7
9
  export type ChangeIssueLevel = 'ERROR' | 'WARNING' | 'INFO';
8
10
 
@@ -111,18 +113,21 @@ const COMPLETENESS: Record<string, string> = {
111
113
  "Change is in 'designed' stage (next: add tasks.md to reach 'planned', then `llman sdd change start` to enter feature branch and reach 'full')",
112
114
  planned:
113
115
  "Change is in 'planned' stage (next: `llman sdd change start` to enter feature branch and reach 'full')",
116
+ full: "Change is bound and stage is 'full' (verify readiness via `llman sdd show <id> --json` readyToImplement)",
114
117
  };
115
118
 
116
119
  /**
117
120
  * File-aware change validation with v1 messages/paths (used by the validate
118
121
  * command). `strict` escalates WARNING issues to ERROR (v1 build_report).
122
+ * `git` (optional) enables the r63 completeness WARNINGs — Full-not-ready
123
+ * with skill guidance, per change.
119
124
  */
120
125
  export function validateChange(
121
126
  io: ChangeFsIoLite,
122
127
  root: string,
123
128
  id: string,
124
129
  config: ChangeValidationConfig,
125
- opts: { stage?: StageGate; strict?: boolean } = {},
130
+ opts: { stage?: StageGate; strict?: boolean; git?: GitLike } = {},
126
131
  ): ChangeCheckResult {
127
132
  const issues: ChangeIssue[] = [];
128
133
  const push = (level: ChangeIssueLevel, path: string, message: string): void => {
@@ -206,7 +211,15 @@ export function validateChange(
206
211
 
207
212
  const hasDesign = io.exists(`${dir}design.md`);
208
213
  const hasTasks = io.exists(`${dir}tasks.md`);
209
- const stage = hasDesign && hasTasks ? 'planned' : hasDesign ? 'designed' : 'draft';
214
+ const binding = readBinding(text);
215
+ const stage =
216
+ hasDesign && hasTasks
217
+ ? binding !== null
218
+ ? 'full'
219
+ : 'planned'
220
+ : hasDesign
221
+ ? 'designed'
222
+ : 'draft';
210
223
 
211
224
  if (hasTasks && !hasDesign) {
212
225
  push(
@@ -239,6 +252,24 @@ export function validateChange(
239
252
 
240
253
  // completeness INFO (v1 surface).
241
254
  push('INFO', 'completeness', COMPLETENESS[stage] ?? '');
255
+
256
+ // r63: Full-but-not-ready WARNING with skill guidance (v1 r1 surface).
257
+ if (opts.git !== undefined && binding !== null && stage === 'full') {
258
+ const needs =
259
+ text.match(/^needs_specs_change:\s*(true|false)\s*$/mu)?.[1] !== undefined
260
+ ? text.match(/^needs_specs_change:\s*(true|false)\s*$/mu)?.[1] === 'true'
261
+ : true;
262
+ const touched =
263
+ opts.git.runOpt(['diff', '--name-only', `${binding.baseBranch}...${binding.branch}`]) ?? '';
264
+ const landed = touched.includes('llmanspec/specs/');
265
+ if (!landed && needs) {
266
+ push(
267
+ 'WARNING',
268
+ 'proposal.md',
269
+ `specs not landed: change bound to \`${binding.branch}\` but no changes under \`llmanspec/specs/\` on its bound branch. Edit live specs there and commit (or set \`needs_specs_change: false\` if this change has no live contract edits). Skill: llman-sdd-propose — do NOT re-run change start when already attached; apply only when \`llman-sdd show <id> --json\` reports readyToImplement=true (llman-sdd-apply).`,
270
+ );
271
+ }
272
+ }
242
273
  }
243
274
 
244
275
  // pattern gate (v1 change-id path).
@@ -112,8 +112,8 @@ export function validateCapability(
112
112
  push('ERROR', `${cap}/feature`, 'Feature line must carry a title');
113
113
  }
114
114
 
115
- // Parser-level structural errors (mutual exclusion, manual orphan, nested
116
- // rule scenarios). Header gates and the MUST-word gate are owned here
115
+ // Parser-level structural errors (mutual exclusion, removed-tag migration,
116
+ // nested rule scenarios). Header gates and the MUST-word gate are owned here
117
117
  // (mapped below), so the parser's duplicate findings are skipped.
118
118
  const OWNED_BY_THIS_LAYER = ['missing-header:', 'rule:missing-must-word'];
119
119
  for (const err of doc.errors) {
@@ -161,6 +161,14 @@ export function validateCapability(
161
161
  // Dangling acceptance @req links (v1 order: acceptance/@req then coverage).
162
162
  const ruleReqIds = new Set(human.flatMap((s) => s.reqIds));
163
163
  for (const sc of acceptance) {
164
+ // r65: orphan acceptance scenario — no @req link at all (v1 r132 WARNING).
165
+ if (sc.reqIds.length === 0) {
166
+ push(
167
+ 'WARNING',
168
+ `${cap}/acceptance/${sc.name}`,
169
+ `orphan acceptance scenario \`${sc.name}\` has no @req:<req_id> link`,
170
+ );
171
+ }
164
172
  for (const rid of sc.reqIds) {
165
173
  if (!ruleReqIds.has(rid)) {
166
174
  push(
@@ -180,7 +188,7 @@ export function validateCapability(
180
188
  push(
181
189
  'INFO',
182
190
  coveragePath(cap),
183
- `rule ${rid} is pending: no @executable acceptance scenario and no @manual waiver`,
191
+ `rule ${rid} is pending: no @executable acceptance scenario`,
184
192
  );
185
193
  }
186
194
  }
@@ -12,7 +12,7 @@ Every capability `.feature` (`llmanspec/specs/<capability>.feature` or `llmanspe
12
12
  2) Tag grammar (`@human constraint scenario must carry an @req:<req_id> tag` / `orphan acceptance scenario`):
13
13
  - Rules: `@req:<id> @human` — statement in the scenario description (MUST/SHALL required).
14
14
  - Acceptance: `@executable` + at least one `@req:<id>` linking a rule.
15
- - `@manual` requires `@human`. Never combine `@human` with `@executable`.
15
+ Never combine `@human` with `@executable`. (`@manual` was removed in 0.3.0 — drop it; `@human` already carries the human-judgement semantics.)
16
16
 
17
17
  3) Legacy `spec.toon` present (`legacy spec.toon found ... run ... toon2features`):
18
18
  Run `llman-sdd project migrate --kind toon2features --yes`, review the diff, commit.
@@ -25,5 +25,5 @@ It is the only spec artifact — there is no `spec.toon`.
25
25
  - Header comments (`# capability:` / `# purpose:` / `# scope:`) are REQUIRED; `scope` drives staleness.
26
26
  - `@human` scenarios are human-owned constraints; their description carries the normative statement verbatim. Editing/removing them yields a WARNING only (report-only, never blocks a gate) — compare via git branch diff; the legacy lock-ack metadata `rules_touched` / `agent_acked` / `@agent` is removed with no aliases and no compat layer (locked rules are report-only: a warning, never a block).
27
27
  - `@executable` scenarios are runner-bound acceptance; they link rules via `@req:<req_id>`.
28
- - Coverage tiers: enforced (has acceptance) / manual (`@manual`) / pending. `list --specs` reports all three.
28
+ - Coverage tiers: enforced (has acceptance) / pending. `list --specs` reports both.
29
29
  - Scenarios MUST stay top-level: `Rule:` blocks are rejected (the runner skips them silently).
@@ -12,7 +12,7 @@
12
12
  2)tag 语法(`@human constraint scenario must carry an @req:<req_id> tag` / `orphan acceptance scenario`):
13
13
  - 规则:`@req:<id> @human` —— statement 放场景描述(须含 MUST/SHALL)。
14
14
  - 验收:`@executable` 且至少一个 `@req:<id>` 挂到规则。
15
- - `@manual` 须与 `@human` 同用;禁止 `@human` 与 `@executable` 同场景。
15
+ - 禁止 `@human` 与 `@executable` 同场景;`@manual` 已在 0.3.0 移除——残留会被报迁移 ERROR,删掉该 tag 即可(`@human` 本身已承载人工判定语义)。
16
16
 
17
17
  3)遗留 `spec.toon`(`legacy spec.toon found ... run ... toon2features`):
18
18
  运行 `llman-sdd project migrate --kind toon2features --yes`,审阅 diff 后提交。
@@ -25,5 +25,5 @@
25
25
  - 头注释(`# capability:` / `# purpose:` / `# scope:`)必填;`scope` 驱动 staleness 检查。
26
26
  - `@human` 场景是人拥有的约束场景;规则 statement 全文放在场景描述里。改/删它只出 WARNING(报告制,不阻断门禁),用 git 分支对比审视;旧的锁定确认元数据 `rules_touched` / `agent_acked` / `@agent` 已删除,无别名也无兼容层。
27
27
  - `@executable` 场景是 runner 绑定的验收场景;用 `@req:<req_id>` 挂回规则。
28
- - 覆盖三态分级:enforced(有验收)/ manual(`@manual`)/ pending——`list --specs` 逐项输出。
28
+ - 覆盖两态分级:enforced(有验收)/ pending——`list --specs` 逐项输出。
29
29
  - 场景 MUST 保持顶层:`Rule:` 块会被拒绝(runner 会静默跳过其中场景)。