@llman-sdd/core 0.1.4 → 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.
@@ -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`;
@@ -41,8 +45,16 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
41
45
  ? countTasks(io.readText(`${dir}/tasks.md`))
42
46
  : { completed: 0, total: 0 };
43
47
 
44
- const cleanTree = isCleanTree(git);
45
- const onBoundBranch = binding !== null && currentBranch(git) === binding.branch;
48
+ // v1 parity: show works (gracefully degraded gates) outside a git repo.
49
+ let cleanTree = false;
50
+ let current = null;
51
+ try {
52
+ cleanTree = isCleanTree(git);
53
+ current = currentBranch(git);
54
+ } catch {
55
+ current = null;
56
+ }
57
+ const onBoundBranch = binding !== null && current === binding.branch;
46
58
 
47
59
  // specs landing: bound changes must have touched llmanspec/specs/ since base
48
60
  let specsLanded = false;
@@ -60,7 +72,17 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
60
72
  const specReport = validateAllSpecs(discoverSpecs(deps.specsDir, io), io);
61
73
  const validateOk = !specReport.failed && (total === 0 || tasksDone);
62
74
 
63
- const stageComplete = stage === 'full';
75
+ const stageHint = !hasDesign
76
+ ? 'add design.md (current: draft → designed)'
77
+ : !hasTasks
78
+ ? 'add tasks.md (current: designed → planned)'
79
+ : stage === 'planned'
80
+ ? 'bind via `llman sdd change start <id>` (planned → full)'
81
+ : '';
82
+ const tasksHint =
83
+ total > 0 && completed < total
84
+ ? `${total - completed} unchecked tasks`
85
+ : 'complete or check off remaining tasks';
64
86
 
65
87
  const gateChecks = [
66
88
  {
@@ -71,29 +93,30 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
71
93
  {
72
94
  name: 'on-bound-branch',
73
95
  pass: onBoundBranch,
74
- hint: onBoundBranch ? '' : 'change is not attached; run `llman-sdd change start <id>`',
96
+ hint: onBoundBranch ? '' : 'change is not attached; run `llman sdd change start <id>`',
75
97
  },
76
98
  {
77
99
  name: 'stage-complete',
78
- pass: stageComplete,
79
- hint: stageComplete ? '' : `add ${!hasDesign ? 'design.md' : 'tasks.md'} (current: ${stage})`,
100
+ pass: hasDesign && hasTasks,
101
+ hint: hasDesign && hasTasks ? '' : (stageHint as string),
80
102
  },
81
103
  {
82
104
  name: 'specs-landed',
83
- pass: specsLanded,
84
- hint: specsLanded
85
- ? ''
86
- : 'edit live specs on the bound branch and commit (or needs_specs_change: false)',
105
+ pass: specsLanded || !needsSpecsChange,
106
+ hint:
107
+ specsLanded || !needsSpecsChange
108
+ ? ''
109
+ : 'edit live specs on the bound branch and commit (or needs_specs_change: false)',
87
110
  },
88
111
  {
89
112
  name: 'tasks-done',
90
- pass: tasksDone,
91
- hint: tasksDone ? '' : `${total - completed} unchecked tasks`,
113
+ pass: tasksDone && total > 0,
114
+ hint: tasksDone && total > 0 ? '' : tasksHint,
92
115
  },
93
116
  {
94
117
  name: 'validate',
95
118
  pass: validateOk,
96
- hint: validateOk ? '' : 'fix issues reported by `llman-sdd validate <id> --strict`',
119
+ hint: validateOk ? '' : 'fix issues reported by `llman sdd validate <id> --strict`',
97
120
  },
98
121
  ];
99
122
 
@@ -110,6 +133,6 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
110
133
  deltaCount: 0,
111
134
  deltas: [] as string[],
112
135
  gateChecks,
113
- matchedViaPrefix: false,
136
+ matchedViaPrefix: opts.matchedViaPrefix === true,
114
137
  };
115
138
  }
@@ -14,9 +14,12 @@ export interface SpecHelperIo {
14
14
  listDir(path: string): string[];
15
15
  }
16
16
 
17
- /** Scan all specs under `specsDir` for @req:rN TAGS (not step text) and return the next free id. */
17
+ /**
18
+ * v1 parity (`req_registry.rs::next_req_id_from_index`): smallest free rN over
19
+ * the RULE (@human) req ids only; acceptance-only req tags do not occupy ids.
20
+ */
18
21
  export function nextReqId(io: SpecHelperIo, specsDir: string): string {
19
- let max = 0;
22
+ const used = new Set<number>();
20
23
  const walk = (dir: string): void => {
21
24
  if (!io.exists(dir)) return;
22
25
  for (const name of io.listDir(dir)) {
@@ -24,8 +27,10 @@ export function nextReqId(io: SpecHelperIo, specsDir: string): string {
24
27
  if (name.endsWith('.feature')) {
25
28
  const doc = parseCapability(io.readText(full), full);
26
29
  for (const scenario of doc.scenarios) {
30
+ if (scenario.classification !== 'human') continue;
27
31
  for (const reqId of scenario.reqIds) {
28
- max = Math.max(max, Math.trunc(Number(reqId.replace(/^r/u, ''))));
32
+ const n = Math.trunc(Number(reqId.replace(/^r/u, '')));
33
+ if (Number.isFinite(n)) used.add(n);
29
34
  }
30
35
  }
31
36
  } else if (io.isDirectory(full)) {
@@ -34,7 +39,9 @@ export function nextReqId(io: SpecHelperIo, specsDir: string): string {
34
39
  }
35
40
  };
36
41
  walk(specsDir);
37
- return `r${max + 1}`;
42
+ let n = 1;
43
+ while (used.has(n)) n += 1;
44
+ return `r${n}`;
38
45
  }
39
46
 
40
47
  export function skeletonContent(capability: string, reqId: string, locale: string): string {
@@ -53,10 +60,11 @@ export function scaffoldSpec(
53
60
  specsDir: string,
54
61
  capability: string,
55
62
  locale: string,
63
+ opts: { force?: boolean } = {},
56
64
  ): string {
57
65
  const reqId = nextReqId(io, specsDir);
58
66
  const path = `${specsDir}/${capability}.feature`;
59
- if (io.exists(path)) throw new Error(`spec already exists: ${path}`);
67
+ if (!opts.force && io.exists(path)) throw new Error(`spec already exists: ${path}`);
60
68
  io.mkdirp('src/');
61
69
  io.writeText(path, skeletonContent(capability, reqId, locale));
62
70
  return path;
@@ -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;
@@ -1,10 +1,12 @@
1
+ import type { GitLike } from '../git/spawnGit.ts';
2
+ import { evaluateStaleness, notApplicableStaleness } from '../validation/staleness.ts';
1
3
  /**
2
4
  * Review aggregation (review-freeze capability, r23): five-signal review over
3
5
  * spec IR + validate sweep. Port of v1 sdd/review.rs observable contract.
4
6
  */
5
7
  import { validateAllSpecs, type SpecEntry, type SpecIo } from '../validation/validate.ts';
6
8
 
7
- export type ReviewKind = 'pending' | 'manual' | 'unbound' | 'stale' | 'locked' | 'validate';
9
+ export type ReviewKind = 'pending' | 'unbound' | 'stale' | 'locked' | 'validate';
8
10
 
9
11
  export interface ReviewSignal {
10
12
  kind: ReviewKind;
@@ -26,6 +28,12 @@ export interface ReviewInput {
26
28
  boundChangeCount: number;
27
29
  /** Active change summaries for the strict sweep (pending tasks → FAIL). */
28
30
  activeChanges?: readonly { name: string; completedTasks: number; totalTasks: number }[];
31
+ /** Restrict per-capability signals (pending/unbound/stale) to this capability. */
32
+ capability?: string;
33
+ /** git + root for real staleness evaluation (v1 parity). */
34
+ git?: GitLike;
35
+ root?: string;
36
+ specsDir?: string;
29
37
  }
30
38
 
31
39
  export interface ReviewResult {
@@ -35,17 +43,8 @@ export interface ReviewResult {
35
43
  exitCode: number;
36
44
  }
37
45
 
38
- function executableBound(
39
- scenarioTags: readonly string[],
40
- bindings: readonly TagBinding[] | null,
41
- ): boolean {
42
- if (!bindings || bindings.length === 0) return false;
43
- const has = (t: string): boolean => scenarioTags.includes(t);
44
- return bindings.some((b) => (b.kind === 'tags' ? b.tags.every((t) => has(t)) : false));
45
- }
46
-
47
46
  export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
48
- const { entries, bindings, boundChangeCount } = input;
47
+ const { entries, boundChangeCount } = input;
49
48
  const strictChangeFails = (input.activeChanges ?? []).filter(
50
49
  (c) => c.totalTasks > 0 && c.completedTasks < c.totalTasks,
51
50
  );
@@ -59,18 +58,36 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
59
58
 
60
59
  for (const entry of sorted) {
61
60
  const cap = entry.doc.header.capability ?? entry.fileName;
61
+ // r33: per-capability signals honor the --capability filter; locked and
62
+ // validate stay global regardless.
63
+ if (input.capability !== undefined && cap !== input.capability) continue;
62
64
  const rules = entry.doc.scenarios.filter((s) => s.classification === 'human');
63
65
  const acceptance = entry.doc.scenarios.filter((s) => s.classification === 'executable');
64
66
  const acceptanceReqIds = new Set(acceptance.flatMap((s) => s.reqIds));
65
67
  const pending = rules.filter((r) => !r.reqIds.some((id) => acceptanceReqIds.has(id)));
66
- const manual = rules.filter((r) => r.manual);
67
- const unbound = acceptance.filter((s) => !executableBound(s.tags, bindings));
68
+ // v1 r5: unbound = orphan acceptance scenarios (no @req link).
69
+ const unbound = acceptance.filter((s) => s.reqIds.length === 0);
68
70
 
69
71
  push('pending', cap, pending.length);
70
- push('manual', cap, manual.length);
71
72
  push('unbound', cap, unbound.length);
72
- // staleness is deferred in v2 — placeholder signal (golden normalizes it)
73
- push('stale', cap, 0, 'DEFERRED');
73
+
74
+ // staleness (v1 evaluate): real base-ref/scope evaluation.
75
+ let staleInfo = notApplicableStaleness();
76
+ let staleCount = 0;
77
+ if (input.git !== undefined && input.root !== undefined) {
78
+ const specRel =
79
+ (entry.fileName.startsWith('llmanspec/') ? '' : 'llmanspec/specs/') + entry.fileName;
80
+ const evalResult = evaluateStaleness({
81
+ git: input.git,
82
+ root: input.root,
83
+ specRel,
84
+ scope: entry.doc.header.scope?.split(',').map((x) => x.trim()) ?? [],
85
+ baseRefEnv: process.env.LLMANSPEC_BASE_REF,
86
+ });
87
+ staleInfo = evalResult.info;
88
+ staleCount = staleInfo.status === 'OK' || staleInfo.status === 'NOTAPPLICABLE' ? 0 : 1;
89
+ }
90
+ push('stale', cap, staleCount, staleInfo.status === 'NOTAPPLICABLE' ? '' : staleInfo.status);
74
91
  }
75
92
 
76
93
  const failed = sweep.verdicts.filter((v) => !v.ok);
@@ -96,13 +113,13 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
96
113
  'locked',
97
114
  '-',
98
115
  0,
99
- `${boundChangeCount} bound change(s); inspect with \`llman-sdd change diff <id>\``,
116
+ `${boundChangeCount} bound change(s); inspect with \`llman sdd change diff <id>\``,
100
117
  );
101
118
  push(
102
119
  'validate',
103
120
  '-',
104
121
  failed.length,
105
- failed.length > 0 ? 'validate --all failed; run `llman-sdd validate --all` for details' : 'ok',
122
+ failed.length > 0 ? 'validate --all failed; run `llman sdd validate --all` for details' : 'ok',
106
123
  );
107
124
 
108
125
  const warningCount = signals
@@ -112,7 +129,7 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
112
129
 
113
130
  const lines: string[] = [`Review: critical=${criticalCount} warning=${warningCount}`];
114
131
  for (const cap of sorted.map((e) => e.doc.header.capability ?? e.fileName)) {
115
- for (const kind of ['pending', 'manual', 'unbound', 'stale'] as const) {
132
+ for (const kind of ['pending', 'unbound', 'stale'] as const) {
116
133
  const s = signals.find((x) => x.kind === kind && x.capability === cap);
117
134
  if (!s) continue;
118
135
  lines.push(`${kind}: ${cap} (${s.count})`);
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Programming-style spec authoring helpers (r41-r43, v1 parity):
3
+ * append rules / acceptance scenarios, resolve req ids, dedupe conflicts.
4
+ * Appends are text-level so existing file content (formatting, comments)
5
+ * stays untouched.
6
+ */
7
+
8
+ import type { CapabilityDoc } from './ir.ts';
9
+
10
+ const RULE_KEYWORDS = ['MUST', 'SHALL', '必须', '不得', '禁止'] as const;
11
+
12
+ export class AuthoringError extends Error {}
13
+
14
+ export interface SpecEntryLike {
15
+ fileName: string;
16
+ doc: CapabilityDoc;
17
+ }
18
+
19
+ export interface WriteIo {
20
+ exists(path: string): boolean;
21
+ readText(path: string): string;
22
+ writeText(path: string, content: string): void;
23
+ }
24
+
25
+ function keywordsOf(content: string): {
26
+ scenario: string;
27
+ given: string;
28
+ when: string;
29
+ thenText: string;
30
+ } {
31
+ return content.includes('功能:')
32
+ ? { scenario: '场景', given: '假如', when: '当', thenText: '那么' }
33
+ : { scenario: 'Scenario', given: 'Given', when: 'When', thenText: 'Then' };
34
+ }
35
+
36
+ function assertRuleWording(statement: string): void {
37
+ if (!RULE_KEYWORDS.some((k) => statement.includes(k))) {
38
+ throw new AuthoringError(
39
+ `statement must contain a rule keyword (${RULE_KEYWORDS.join('/')}): ${statement}`,
40
+ );
41
+ }
42
+ }
43
+
44
+ function findReq(
45
+ entries: readonly SpecEntryLike[],
46
+ reqId: string,
47
+ ): { entry: SpecEntryLike; scenarioName: string; statement: string } | null {
48
+ for (const entry of entries) {
49
+ for (const scenario of entry.doc.scenarios) {
50
+ if (scenario.reqIds.includes(reqId) && scenario.classification === 'human') {
51
+ return { entry, scenarioName: scenario.name, statement: scenario.statement };
52
+ }
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /** v1 parity: only @human (rule) req ids participate in the dedupe registry. */
59
+ export function ruleReqIds(entries: readonly SpecEntryLike[]): Set<string> {
60
+ const ids = new Set<string>();
61
+ for (const entry of entries) {
62
+ for (const scenario of entry.doc.scenarios) {
63
+ if (scenario.classification === 'human') {
64
+ for (const id of scenario.reqIds) ids.add(id);
65
+ }
66
+ }
67
+ }
68
+ return ids;
69
+ }
70
+
71
+ export function allReqIds(entries: readonly SpecEntryLike[]): Set<string> {
72
+ const ids = new Set<string>();
73
+ for (const entry of entries) {
74
+ for (const scenario of entry.doc.scenarios) {
75
+ for (const id of scenario.reqIds) ids.add(id);
76
+ }
77
+ }
78
+ return ids;
79
+ }
80
+
81
+ export interface AddReqOpts {
82
+ capability: string;
83
+ reqId: string;
84
+ title: string;
85
+ statement: string;
86
+ }
87
+
88
+ /** Append `@req:<id> @human` rule scenario to `<capability>.feature` (r41). */
89
+ export function addReq(
90
+ io: WriteIo,
91
+ specsRoot: string,
92
+ entries: readonly SpecEntryLike[],
93
+ opts: AddReqOpts,
94
+ ): string {
95
+ const path = `${specsRoot}/${opts.capability}.feature`;
96
+ if (!io.exists(path)) throw new AuthoringError(`spec not found: ${path}`);
97
+ if (allReqIds(entries).has(opts.reqId)) {
98
+ throw new AuthoringError(`req id already in use: ${opts.reqId}`);
99
+ }
100
+ assertRuleWording(opts.statement);
101
+ const kw = keywordsOf(io.readText(path));
102
+ const block = `\n @req:${opts.reqId} @human\n ${kw.scenario}: ${opts.title}\n ${opts.statement}\n`;
103
+ io.writeText(path, `${io.readText(path).replace(/\n+$/u, '')}${block}`);
104
+ return path;
105
+ }
106
+
107
+ export interface AddScenarioOpts {
108
+ capability: string;
109
+ reqId: string;
110
+ scenarioId: string;
111
+ given?: string;
112
+ when: string;
113
+ thenText: string;
114
+ }
115
+
116
+ /** Append `@req:<id> @executable` acceptance scenario (r42). */
117
+ export function addScenario(
118
+ io: WriteIo,
119
+ specsRoot: string,
120
+ entries: readonly SpecEntryLike[],
121
+ opts: AddScenarioOpts,
122
+ ): string {
123
+ const path = `${specsRoot}/${opts.capability}.feature`;
124
+ if (!io.exists(path)) throw new AuthoringError(`spec not found: ${path}`);
125
+ if (findReq(entries, opts.reqId) === null) {
126
+ throw new AuthoringError(`req id not found: ${opts.reqId}`);
127
+ }
128
+ const kw = keywordsOf(io.readText(path));
129
+ const givenLine =
130
+ opts.given !== undefined && opts.given !== '' ? ` ${kw.given} ${opts.given}\n` : '';
131
+ const block =
132
+ `\n @req:${opts.reqId} @executable\n ${kw.scenario}: ${opts.scenarioId}\n` +
133
+ `${givenLine} ${kw.when} ${opts.when}\n ${kw.thenText} ${opts.thenText}\n`;
134
+ io.writeText(path, `${io.readText(path).replace(/\n+$/u, '')}${block}`);
135
+ return path;
136
+ }
137
+
138
+ export interface ResolvedReq {
139
+ reqId: string;
140
+ capability: string;
141
+ title: string;
142
+ statement: string;
143
+ harness: string[];
144
+ }
145
+
146
+ /** Resolve an rN to capability/statement plus bound harness scenarios (r43). */
147
+ export function resolveReq(entries: readonly SpecEntryLike[], reqId: string): ResolvedReq | null {
148
+ const rule = findReq(entries, reqId);
149
+ if (rule === null) return null;
150
+ const capability =
151
+ rule.entry.doc.header.capability ?? rule.entry.fileName.replace(/\.feature$/u, '');
152
+ const harness: string[] = [];
153
+ for (const entry of entries) {
154
+ for (const scenario of entry.doc.scenarios) {
155
+ if (scenario.classification === 'executable' && scenario.reqIds.includes(reqId)) {
156
+ harness.push(`${entry.fileName}:${scenario.name}`);
157
+ }
158
+ }
159
+ }
160
+ return { reqId, capability, title: rule.scenarioName, statement: rule.statement, harness };
161
+ }
162
+
163
+ export interface DedupePlanItem {
164
+ reqId: string;
165
+ keepFile: string;
166
+ remapFile: string;
167
+ newReqId: string;
168
+ }
169
+
170
+ /**
171
+ * Plan (and optionally apply) a re-map of globally duplicated rN ids: the
172
+ * first file keeps the id, later files get the next free id (r43).
173
+ */
174
+ export function planDedupe(
175
+ entries: readonly SpecEntryLike[],
176
+ io: WriteIo,
177
+ specsRoot: string,
178
+ duplicates: readonly { reqId: string; files: string[] }[],
179
+ ): DedupePlanItem[] {
180
+ const used = ruleReqIds(entries);
181
+ let next = 1;
182
+ const fresh = (): string => {
183
+ while (used.has(`r${String(next)}`)) next += 1;
184
+ used.add(`r${String(next)}`);
185
+ return `r${String(next)}`;
186
+ };
187
+ const plan: DedupePlanItem[] = [];
188
+ for (const dup of duplicates) {
189
+ const [keep, ...rest] = dup.files;
190
+ for (const remapFile of rest) {
191
+ const newReqId = fresh();
192
+ if (keep !== undefined) {
193
+ plan.push({ reqId: dup.reqId, keepFile: keep, remapFile, newReqId });
194
+ }
195
+ }
196
+ }
197
+ for (const item of plan) {
198
+ // registry duplicates carry bare file names; accept already-rooted paths too
199
+ const path = item.remapFile.startsWith(`${specsRoot}/`)
200
+ ? item.remapFile
201
+ : `${specsRoot}/${item.remapFile}`;
202
+ const content = io.readText(path);
203
+ io.writeText(path, content.replaceAll(`@req:${item.reqId}`, `@req:${item.newReqId}`));
204
+ }
205
+ return plan;
206
+ }
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,