@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llman-sdd/core",
3
- "version": "0.1.4",
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": {
@@ -88,7 +88,7 @@ export async function runFreeze(
88
88
  export async function runList(io: FreezeIo, sz: SevenZipPort, rootAbs: string): Promise<string[]> {
89
89
  const archiveAbs = join(rootAbs, ARCHIVE_DIR_REL, FREEZE_ARCHIVE_NAME);
90
90
  if (!io.exists(archiveAbs)) {
91
- return [`No freeze archive found at ./${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`];
91
+ return [`freeze archive not found: ./${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`];
92
92
  }
93
93
  // Real 7z lists file paths under their directory (`<dir>/proposal.md`);
94
94
  // directory entries themselves are skipped by parseListNames. Derive the
@@ -117,10 +117,13 @@ export async function runThaw(
117
117
  sz: SevenZipPort,
118
118
  rootAbs: string,
119
119
  names: string[],
120
+ opts: { dest?: string } = {},
120
121
  ): Promise<ThawResult> {
122
+ // r56: restore target override (created on demand); default = changes/archive
123
+ const destRel = opts.dest ?? ARCHIVE_DIR_REL;
121
124
  const archiveAbs = join(rootAbs, ARCHIVE_DIR_REL, FREEZE_ARCHIVE_NAME);
122
125
  if (!io.exists(`${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`)) {
123
- throw new Error(`No freeze archive found at ./${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`);
126
+ throw new Error(`freeze archive not found: ./${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`);
124
127
  }
125
128
  const tmpRel = 'llmanspec/.thaw-tmp';
126
129
  const tmpAbs = join(rootAbs, tmpRel);
@@ -136,15 +139,15 @@ export async function runThaw(
136
139
  }
137
140
  const restored: string[] = [];
138
141
  for (const name of names.toSorted()) {
139
- if (io.exists(`${ARCHIVE_DIR_REL}/${name}`)) {
142
+ if (io.exists(`${destRel}/${name}`)) {
140
143
  throw new Error(`target already exists: ${name}`);
141
144
  }
142
- io.moveDir(`${tmpRel}/${name}`, `${ARCHIVE_DIR_REL}/${name}`);
145
+ io.moveDir(`${tmpRel}/${name}`, `${destRel}/${name}`);
143
146
  restored.push(name);
144
147
  }
145
148
  return {
146
149
  restored,
147
- lines: [`Thawed ${restored.length} selected archived changes to ${ARCHIVE_DIR_REL}`],
150
+ lines: [`Thawed ${restored.length} selected archived changes to ${destRel}`],
148
151
  };
149
152
  } finally {
150
153
  io.removeDir(tmpRel);
package/src/change/id.ts CHANGED
@@ -1,43 +1,63 @@
1
1
  /**
2
- * Change id derivation (change-lifecycle capability): legal ids are
3
- * verb-prefixed kebab-case ASCII, ≤60 chars (CLI-checked convention).
2
+ * Change id derivation (change-lifecycle capability): v1 `change/new.rs`
3
+ * `derive_change_id` parity — pure kebab sanitization (lowercase alnum,
4
+ * separators), no verb requirement, empty/oversized handling.
4
5
  */
5
- const VERBS = [
6
- 'bootstrap',
7
- 'port',
8
- 'add',
9
- 'update',
10
- 'remove',
11
- 'refactor',
12
- 'fix',
13
- 'release',
14
- 'migrate',
15
- 'init',
16
- 'draft',
17
- 'archive',
18
- ] as const;
19
6
 
20
7
  const ID_CAP = 60;
21
8
 
9
+ export class ChangeIdError extends Error {}
10
+
22
11
  export function isLegalChangeId(id: string): boolean {
23
- return (
24
- VERBS.some((v) => id === v || id.startsWith(`${v}-`)) &&
25
- /^[a-z0-9-]+$/u.test(id) &&
26
- id.length <= ID_CAP
27
- );
12
+ return /^[a-z0-9-]+$/u.test(id) && id.length > 0 && id.length <= ID_CAP;
28
13
  }
29
14
 
15
+ /** v1 parity: sanitize to lowercase kebab (ASCII alnum only; CJK/punct dropped). */
30
16
  export function deriveChangeId(description: string): string {
31
- let id = description
32
- .toLowerCase()
33
- .normalize('NFKD')
34
- .replaceAll(/[^a-z0-9]+/gu, '-')
35
- .replaceAll(/^-+|-+$/gu, '')
36
- .slice(0, ID_CAP)
37
- .replaceAll(/-+$/gu, '');
38
- if (id === '') id = 'change';
39
- if (isLegalChangeId(id)) return id;
40
- return `add-${id}`.slice(0, ID_CAP).replaceAll(/-+$/gu, '');
17
+ const trimmed = description.trim();
18
+ if (trimmed === '') {
19
+ throw new ChangeIdError('--from <DESCRIPTION> must be non-empty');
20
+ }
21
+ let id = '';
22
+ let prevDash = true;
23
+ for (const ch of trimmed) {
24
+ if (/[a-zA-Z0-9]/u.test(ch)) {
25
+ id += ch.toLowerCase();
26
+ prevDash = false;
27
+ } else if (
28
+ /\s/u.test(ch) ||
29
+ ch === '_' ||
30
+ ch === '.' ||
31
+ ch === '-' ||
32
+ ch === '/' ||
33
+ ch === '\\'
34
+ ) {
35
+ if (!prevDash) {
36
+ id += '-';
37
+ prevDash = true;
38
+ }
39
+ } else {
40
+ // Punctuation / CJK etc.: drop (CJK intentionally dropped to keep ids
41
+ // ASCII-friendly); a dropped char at a word boundary yields a `-`.
42
+ if (!prevDash) {
43
+ id += '-';
44
+ prevDash = true;
45
+ }
46
+ }
47
+ }
48
+ while (id.endsWith('-')) id = id.slice(0, -1);
49
+ if (id === '') {
50
+ throw new ChangeIdError(
51
+ '--from <DESCRIPTION> yielded an empty id after sanitizing; provide a description with at least one alphanumeric character',
52
+ );
53
+ }
54
+ if (id.length > ID_CAP) {
55
+ const cutoff = id.slice(0, ID_CAP).lastIndexOf('-');
56
+ const at = cutoff === -1 ? ID_CAP : cutoff;
57
+ id = id.slice(0, at);
58
+ while (id.endsWith('-')) id = id.slice(0, -1);
59
+ }
60
+ return id;
41
61
  }
42
62
 
43
63
  export const DRAFT_PROPOSAL_TEMPLATE = `## Why
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  currentBranch,
3
3
  defaultBranch,
4
+ dirtyCount,
4
5
  isCleanTree,
6
+ mergeBase,
5
7
  revParseHead,
6
8
  type GitLike,
7
9
  } from '../git/spawnGit.ts';
@@ -33,14 +35,18 @@ export function changeExists(io: FsIo, id: string): boolean {
33
35
  return io.exists(proposalPath(id));
34
36
  }
35
37
 
36
- /** `change new [id] --from DESC`: derive a legal id and write the draft shell. */
38
+ /** `change new [id] --from DESC --force`: derive a legal id and write the draft shell. */
37
39
  export function newChange(
38
40
  io: FsIo,
39
- opts: { id?: string; from?: string },
41
+ opts: { id?: string; from?: string; force?: boolean },
40
42
  ): { id: string; path: string } {
41
43
  const id = opts.id ?? deriveChangeId(opts.from ?? '');
42
44
  const path = proposalPath(id);
43
- if (io.exists(path)) throw new LifecycleError(`change \`${id}\` already exists: ${path}`);
45
+ if (io.exists(path) && !opts.force) {
46
+ throw new LifecycleError(
47
+ `change proposal already exists: ./${path} (pass --force to overwrite)`,
48
+ );
49
+ }
44
50
  io.writeText(path, `---\ndepends_on: []\n---\n\n${DRAFT_PROPOSAL_TEMPLATE}`);
45
51
  return { id, path };
46
52
  }
@@ -54,35 +60,73 @@ export function startChange(
54
60
  ): { branch: string; baseBranch: string; baseSha: string } {
55
61
  const path = proposalPath(id);
56
62
  if (!io.exists(path)) throw new LifecycleError(`proposal not found: ${path}`);
57
- if (!isCleanTree(git))
58
- throw new LifecycleError('working tree is not clean — commit or stash first');
63
+ const dirty = dirtyCount(git);
64
+ if (dirty > 0)
65
+ throw new LifecycleError(
66
+ `dirty tree: ${dirty} uncommitted files; commit/stash before \`change start\``,
67
+ );
59
68
  const baseBranch = defaultBranch(git);
60
69
  const here = currentBranch(git);
61
- if (here !== baseBranch) {
70
+ if (here !== null && here !== baseBranch) {
62
71
  throw new LifecycleError(
63
- `must run from the default branch (${baseBranch}), currently on: ${here ?? 'detached'}`,
72
+ `already on non-default branch \`${here}\`; use \`change attach\` to bind it, or switch to the default branch before \`change start\``,
64
73
  );
65
74
  }
75
+ if (here === null) throw new LifecycleError('detached HEAD is not allowed for change binding');
66
76
  const branchPrefix = opts.branchPrefix ?? 'sdd/';
67
77
  const branch = `${branchPrefix}${id}`;
68
78
  git.run(['switch', '-c', branch]);
69
- const baseSha = revParseHead(git);
79
+ const baseSha =
80
+ currentBranch(git) !== null ? mergeBase(git, 'HEAD', baseBranch) : revParseHead(git);
70
81
  io.writeText(path, writeBinding(io.readText(path), { branch, baseBranch, baseSha }));
71
82
  return { branch, baseBranch, baseSha };
72
83
  }
73
84
 
74
- /** `change attach`: bind the current branch without gates. */
75
- export function attachChange(git: GitLike, io: FsIo, id: string): { branch: string } {
85
+ /** `change attach`: bind the current branch — same branch gate family as start (r31). */
86
+ export function attachChange(
87
+ git: GitLike,
88
+ io: FsIo,
89
+ id: string,
90
+ opts: { force?: boolean; base?: string } = {},
91
+ ): { branch: string; baseBranch: string; baseSha: string } {
76
92
  const path = proposalPath(id);
77
93
  if (!io.exists(path)) throw new LifecycleError(`proposal not found: ${path}`);
94
+ const existing = readBinding(io.readText(path));
95
+ if (!opts.force && existing !== null) {
96
+ throw new LifecycleError(
97
+ `change \`${id}\` already attached to branch \`${existing.branch}\` (base ${existing.baseSha}); pass --force to rebind`,
98
+ );
99
+ }
100
+ const configuredBase = opts.base ?? defaultBranch(git);
78
101
  const branch = currentBranch(git);
79
- if (branch === null) throw new LifecycleError('detached HEAD — cannot attach');
80
- const baseSha = revParseHead(git);
102
+ if (branch === null || branch === '') {
103
+ throw new LifecycleError('detached HEAD is not allowed for change binding');
104
+ }
105
+ if (opts.base !== undefined) {
106
+ if (
107
+ git.runOpt(['show-ref', '--verify', '--quiet', `refs/heads/${opts.base}`]) === null &&
108
+ git.runOpt(['show-ref', '--verify', '--quiet', `refs/remotes/${opts.base}`]) === null
109
+ ) {
110
+ throw new LifecycleError(
111
+ `base branch \`${opts.base}\` does not exist; --base records the fork source branch for merge-target resolution (r111)`,
112
+ );
113
+ }
114
+ if (opts.base === branch) {
115
+ throw new LifecycleError(`--base must differ from the bound branch \`${branch}\``);
116
+ }
117
+ }
118
+ if (branch === configuredBase) {
119
+ throw new LifecycleError(
120
+ `changes must not attach on the default branch (\`${branch}\`); ` +
121
+ 'create/switch to a feature branch first (or use `change start`)',
122
+ );
123
+ }
124
+ const baseSha = mergeBase(git, branch, configuredBase);
81
125
  io.writeText(
82
126
  path,
83
- writeBinding(io.readText(path), { branch, baseBranch: defaultBranch(git), baseSha }),
127
+ writeBinding(io.readText(path), { branch, baseBranch: configuredBase, baseSha }),
84
128
  );
85
- return { branch };
129
+ return { branch, baseBranch: configuredBase, baseSha };
86
130
  }
87
131
 
88
132
  export interface FinalizeResult {
@@ -92,46 +136,149 @@ export interface FinalizeResult {
92
136
  commitSubject: string;
93
137
  }
94
138
 
139
+ export interface ArchiveTaskGate {
140
+ blocked: boolean;
141
+ reasons: string[];
142
+ }
143
+
144
+ /** r40 task gate: unchecked tasks always block; ratio gate when configured. */
145
+ export function archiveTaskGate(
146
+ tasksMd: string | null,
147
+ minCompletionRatio: number | undefined,
148
+ ): ArchiveTaskGate {
149
+ const reasons: string[] = [];
150
+ if (tasksMd !== null) {
151
+ let completed = 0;
152
+ let total = 0;
153
+ for (const line of tasksMd.split('\n')) {
154
+ const m = line.match(/^\s*-\s+\[( |x|X)\]/u);
155
+ if (m) {
156
+ total += 1;
157
+ if (m[1] !== ' ') completed += 1;
158
+ }
159
+ }
160
+ if (total > 0 && completed < total) {
161
+ reasons.push(`archive blocked by unchecked tasks (${total - completed}/${total} pending)`);
162
+ for (const line of tasksMd.split('\n')) {
163
+ if (/^\s*-\s+\[ \]/u.test(line)) reasons.push(line.trim());
164
+ }
165
+ }
166
+ if (minCompletionRatio !== undefined && total > 0 && completed / total < minCompletionRatio) {
167
+ reasons.push(
168
+ `completion ${((completed / total) * 100).toFixed(0)}% below archive.min_completion_ratio ${(minCompletionRatio * 100).toFixed(0)}%`,
169
+ );
170
+ }
171
+ }
172
+ return { blocked: reasons.length > 0, reasons };
173
+ }
174
+
95
175
  /** `change finalize`: merge (squash default) + archive rename + close-out commit. */
96
176
  export function finalizeChange(
97
177
  git: GitLike,
98
178
  io: FsIo,
99
179
  id: string,
100
- opts: { into?: string; method?: 'squash' | 'ff'; today?: string } = {},
180
+ opts: { into?: string; method?: 'squash' | 'ff'; today?: string; noCommit?: boolean } = {},
101
181
  ): FinalizeResult {
102
182
  const path = proposalPath(id);
103
183
  const binding = readBinding(io.readText(path));
104
184
  if (binding === null) {
105
185
  throw new LifecycleError(`change \`${id}\` has no branch binding — run start/attach first`);
106
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
+ }
107
195
  const method = opts.method ?? 'squash';
108
196
  const target = opts.into ?? binding.baseBranch ?? defaultBranch(git);
109
- const warnings: string[] = [];
197
+ return mergeRenameCommit(git, io, id, binding.branch, target, method, opts.today, opts.noCommit);
198
+ }
110
199
 
111
- // Read the binding while still on the feature branch (its proposal.md may
112
- // carry uncommitted binding edits), then switch and merge.
200
+ /** Shared close-out: merge → archive rename → single archive(sdd) commit. */
201
+ function mergeRenameCommit(
202
+ git: GitLike,
203
+ io: FsIo,
204
+ id: string,
205
+ featureBranch: string,
206
+ target: string,
207
+ method: 'squash' | 'ff',
208
+ today?: string,
209
+ noCommit?: boolean,
210
+ ): FinalizeResult {
211
+ const warnings: string[] = [];
113
212
  git.run(['switch', target]);
114
213
  const mergeArgs =
115
- method === 'ff'
116
- ? ['merge', '--ff-only', binding.branch]
117
- : ['merge', '--squash', binding.branch];
214
+ method === 'ff' ? ['merge', '--ff-only', featureBranch] : ['merge', '--squash', featureBranch];
118
215
  if (git.runOpt(mergeArgs) === null) {
119
216
  git.runOpt(['merge', '--abort']);
120
217
  warnings.push(
121
- `merge ${method} failed — resolve manually, e.g. \`git merge ${method === 'ff' ? '--ff-only' : '--squash'} ${binding.branch}\``,
218
+ `merge ${method} failed — resolve manually, e.g. \`git merge ${method === 'ff' ? '--ff-only' : '--squash'} ${featureBranch}\``,
122
219
  );
123
220
  }
124
221
 
125
- const date = opts.today ?? new Date().toISOString().slice(0, 10);
222
+ const date = today ?? new Date().toISOString().slice(0, 10);
126
223
  const archiveDir = `${CHANGES_DIR}/archive/${date}-${id}`;
127
224
  io.rename(`${CHANGES_DIR}/${id}`, archiveDir);
128
225
 
226
+ if (noCommit) return { target, archiveDir, warnings, commitSubject: '' };
129
227
  git.run(['add', '-A']);
130
228
  const commitSubject = `archive(sdd): ${id}`;
131
229
  git.run(['commit', '-m', commitSubject]);
132
230
  return { target, archiveDir, warnings, commitSubject };
133
231
  }
134
232
 
233
+ export interface ArchiveChangeResult {
234
+ result: FinalizeResult;
235
+ }
236
+
237
+ /** `change archive`: independent seal-off with task + strict git gates (r39/r40). */
238
+ export function archiveChange(
239
+ git: GitLike,
240
+ io: FsIo,
241
+ id: string,
242
+ opts: {
243
+ into?: string;
244
+ method?: 'squash' | 'ff';
245
+ force?: boolean;
246
+ minCompletionRatio?: number;
247
+ today?: string;
248
+ } = {},
249
+ ): FinalizeResult {
250
+ const path = proposalPath(id);
251
+ const binding = readBinding(io.readText(path));
252
+ if (!opts.force && binding === null) {
253
+ throw new LifecycleError(`change \`${id}\` has no branch binding — run start/attach first`);
254
+ }
255
+ if (!opts.force) {
256
+ const tasksPath = `${CHANGES_DIR}/${id}/tasks.md`;
257
+ const gate = archiveTaskGate(
258
+ io.exists(tasksPath) ? io.readText(tasksPath) : null,
259
+ opts.minCompletionRatio,
260
+ );
261
+ if (gate.blocked) throw new LifecycleError(gate.reasons.join('\n'));
262
+ const current = currentBranch(git);
263
+ if (current === null || current === '')
264
+ throw new LifecycleError('detached HEAD — cannot archive');
265
+ if (current !== binding?.branch) {
266
+ throw new LifecycleError(
267
+ `archive must run on attached branch \`${binding?.branch}\` (current: \`${current}\`)`,
268
+ );
269
+ }
270
+ if (current === defaultBranch(git)) {
271
+ throw new LifecycleError('archive must not run on the default branch');
272
+ }
273
+ if (!isCleanTree(git)) throw new LifecycleError('working tree must be clean to archive');
274
+ } else if (binding === null) {
275
+ throw new LifecycleError(`change \`${id}\` has no branch binding — cannot merge`);
276
+ }
277
+ const method = opts.method ?? 'squash';
278
+ const target = opts.into ?? binding?.baseBranch ?? defaultBranch(git);
279
+ return mergeRenameCommit(git, io, id, binding?.branch as string, target, method, opts.today);
280
+ }
281
+
135
282
  /** `change diff <id>`: full diff of the bound branch vs base. */
136
283
  export function changeDiff(git: GitLike, io: FsIo, id: string): string {
137
284
  const binding = readBinding(io.readText(proposalPath(id)));
@@ -139,3 +286,19 @@ export function changeDiff(git: GitLike, io: FsIo, id: string): string {
139
286
  const base = binding.baseBranch ?? defaultBranch(git);
140
287
  return git.run(['diff', `${base}...${binding.branch}`]);
141
288
  }
289
+
290
+ export interface ChangeDiffInfo {
291
+ change: string;
292
+ branch: string;
293
+ base: string;
294
+ commitCount: number;
295
+ }
296
+
297
+ /** `change diff --json` (r46): structured bound-branch summary. */
298
+ export function changeDiffInfo(git: GitLike, io: FsIo, id: string): ChangeDiffInfo {
299
+ const binding = readBinding(io.readText(proposalPath(id)));
300
+ if (binding === null) throw new LifecycleError(`change \`${id}\` has no branch binding`);
301
+ const count =
302
+ git.runOpt(['rev-list', '--count', `${binding.baseSha}...${binding.branch}`]) ?? '0';
303
+ return { change: id, branch: binding.branch, base: binding.baseSha, commitCount: Number(count) };
304
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Whole-tree change-id number harvest (r35, v1 `change next-id` parity).
3
+ * Read-only: walks directory names at any depth under `llmanspec/` and
4
+ * extracts `c<digits>` tokens at token boundaries — the same value
5
+ * `change new --from` used to inject as `llman_sdd_unique_id`.
6
+ */
7
+
8
+ export interface NextIdIo {
9
+ listDir(path: string): string[];
10
+ isDirectory(path: string): boolean;
11
+ }
12
+
13
+ export interface IdHarvest {
14
+ maxNumber: number | null;
15
+ nextNumber: number;
16
+ warnings: string[];
17
+ }
18
+
19
+ const C_TOKEN_RE = /(?:^|[^a-z0-9])c([0-9]+)(?:$|[^a-z0-9])/iu;
20
+
21
+ /**
22
+ * Extract the number carried by a change-id-shaped directory name: the first
23
+ * `c<digits>` run at a token boundary (matches `c2790`, `c10-active`, and the
24
+ * `2026-01-01-c20-slug` date-prefixed archive shape; glued names like `cab12`
25
+ * match no position). Leading digits (`3-third`) do not count.
26
+ */
27
+ export function extractUniqueNumber(name: string): number | null {
28
+ const m = C_TOKEN_RE.exec(name);
29
+ return m ? Number(m[1]) : null;
30
+ }
31
+
32
+ export function harvestUniqueNumbers(io: NextIdIo, root: string): IdHarvest {
33
+ const numbers: number[] = [];
34
+ const warnings: string[] = [];
35
+ const walk = (dir: string): void => {
36
+ let names: string[];
37
+ try {
38
+ names = io.listDir(dir);
39
+ } catch (error) {
40
+ warnings.push(`cannot list ${dir}: ${(error as Error).message}`);
41
+ return;
42
+ }
43
+ for (const name of names.toSorted()) {
44
+ if (name.startsWith('.')) continue;
45
+ const full = `${dir}/${name}`;
46
+ if (!io.isDirectory(full)) continue;
47
+ const n = extractUniqueNumber(name);
48
+ if (n !== null) numbers.push(n);
49
+ walk(full);
50
+ }
51
+ };
52
+ walk(root);
53
+ const maxNumber = numbers.length > 0 ? Math.max(...numbers) : null;
54
+ return { maxNumber, nextNumber: maxNumber === null ? 1 : maxNumber + 1, warnings };
55
+ }
@@ -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
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * change_id contract (r59/r60): pattern compile-check at load time, validate
3
+ * ERROR for active changes violating the pattern, and `change new --from`
4
+ * template rendering (nunjucks Strict) with the v1 preset variables.
5
+ */
6
+
7
+ import nunjucks from 'nunjucks';
8
+
9
+ import { harvestUniqueNumbers, type NextIdIo } from '../change/nextId.ts';
10
+
11
+ export class ChangeIdError extends Error {}
12
+
13
+ /** Compile-check a configured change_id.pattern (r59). */
14
+ export function compileChangeIdPattern(pattern: string | null | undefined): RegExp | null {
15
+ if (pattern === null || pattern === undefined || pattern === '') return null;
16
+ try {
17
+ return new RegExp(pattern, 'u');
18
+ } catch (error) {
19
+ throw new ChangeIdError(`change_id.pattern is not a valid regex: ${(error as Error).message}`);
20
+ }
21
+ }
22
+
23
+ export interface ChangeIdVars {
24
+ /** whole-tree next free number (v1 llman_sdd_unique_id) */
25
+ llman_sdd_unique_id: number;
26
+ /** explicit --verb value; undefined when not provided (Strict render errors) */
27
+ verb?: string;
28
+ /** slugified description subject */
29
+ subject: string;
30
+ /** YYYY-MM-DD */
31
+ date: string;
32
+ }
33
+
34
+ /**
35
+ * Render a change_id.template with v1 preset vars (r60). Strict semantics:
36
+ * referencing a variable that was not provided errors out (v1 parity —
37
+ * `{{ verb }}` without --verb fails).
38
+ */
39
+ export function renderChangeIdTemplate(template: string, vars: ChangeIdVars): string {
40
+ // Named pre-check (v1 parity): referencing an unprovided variable errors with
41
+ // the variable name and the preset list, instead of a generic render error.
42
+ const provided = new Set(
43
+ Object.keys(vars).filter((k) => vars[k as keyof ChangeIdVars] !== undefined),
44
+ );
45
+ for (const m of template.matchAll(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/gu)) {
46
+ const name = m[1] as string;
47
+ if (!provided.has(name)) {
48
+ throw new ChangeIdError(
49
+ `change_id.template references unprovided variable(s): ${name} — preset vars are llman_sdd_unique_id, verb, subject, date (pass --verb when it uses {{ verb }})`,
50
+ );
51
+ }
52
+ }
53
+ const env = new nunjucks.Environment(undefined, { throwOnUndefined: true });
54
+ const rendered = env.renderString(template, vars as unknown as Record<string, unknown>).trim();
55
+ if (rendered === '') throw new ChangeIdError('change_id.template rendered to an empty id');
56
+ return rendered;
57
+ }
58
+
59
+ /** Whole-tree next free number for the llman_sdd_unique_id preset var. */
60
+ export function nextUniqueNumber(io: NextIdIo, llmanspecRoot: string): number {
61
+ return harvestUniqueNumbers(io, llmanspecRoot).nextNumber;
62
+ }
63
+
64
+ const VERB_TABLE = ['add', 'update', 'remove', 'refactor', 'fix'] as const;
65
+
66
+ /**
67
+ * v1 `new.rs::split_verb` parity: the subject is the derived id minus a
68
+ * detected table-verb prefix; the verb is the explicit override when given,
69
+ * otherwise the auto-detected one (v1 renders `{{ verb }}` without --verb for
70
+ * descriptions that carry a verb token).
71
+ */
72
+ export function splitVerb(
73
+ derived: string,
74
+ verbOverride: string | undefined,
75
+ ): { verb?: string; subject: string } {
76
+ let subject = derived;
77
+ let detected: string | undefined;
78
+ for (const verb of VERB_TABLE) {
79
+ if (derived.startsWith(`${verb}-`)) {
80
+ detected = verb;
81
+ subject = derived.slice(verb.length + 1);
82
+ break;
83
+ }
84
+ }
85
+ const verb = verbOverride !== undefined ? verbOverride : detected;
86
+ return { verb, subject };
87
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `config` command surface (r37/r38): read-only overview rendering and
3
+ * non-interactive extra_skills management with comment-preserving writeback.
4
+ */
5
+
6
+ import { parseDocument } from 'yaml';
7
+
8
+ import { loadConfig } from './load.ts';
9
+ import { EXTRA_SKILLS } from './schema.ts';
10
+
11
+ /** Render the five-element overview (v1 parity wording). */
12
+ export function renderConfigOverview(source: string): string[] {
13
+ const config = loadConfig(source);
14
+ const enabled = (config.extra_skills ?? []).length;
15
+ const archive = config.archive;
16
+ const archiveConfigured =
17
+ archive !== null &&
18
+ archive !== undefined &&
19
+ (archive.strict_defer || archive.min_completion_ratio !== undefined);
20
+ return [
21
+ `schema: ${config.schema}`,
22
+ `locale: ${config.locale}`,
23
+ `extra_skills (enabled/total): ${enabled} / ${EXTRA_SKILLS.length}`,
24
+ `bdd: ${config.bdd ? 'on' : 'off'}`,
25
+ `archive: ${archiveConfigured ? 'configured' : 'default'}`,
26
+ ];
27
+ }
28
+
29
+ export class ExtraSkillsError extends Error {}
30
+
31
+ /**
32
+ * Add/remove extra_skills entries with full comment preservation: only the
33
+ * `extra_skills` list node is touched, everything else (including the
34
+ * `$schema` header comment) stays byte-identical.
35
+ */
36
+ export function setExtraSkills(
37
+ source: string,
38
+ change: { set?: readonly string[]; unset?: readonly string[] },
39
+ ): string {
40
+ for (const name of [...(change.set ?? []), ...(change.unset ?? [])]) {
41
+ if (!(EXTRA_SKILLS as readonly string[]).includes(name)) {
42
+ throw new ExtraSkillsError(`unknown extra skill: ${name}`);
43
+ }
44
+ }
45
+ const wanted = new Set<string>(loadConfig(source).extra_skills ?? []);
46
+ for (const name of change.set ?? []) wanted.add(name);
47
+ for (const name of change.unset ?? []) wanted.delete(name);
48
+
49
+ const doc = parseDocument(source);
50
+ const list = [...wanted].toSorted();
51
+ if (list.length > 0) doc.set('extra_skills', list);
52
+ else doc.delete('extra_skills');
53
+ return doc.toString();
54
+ }
55
+
56
+ /** Shape of `config skills --json` (v1 parity). */
57
+ export function skillsJson(source: string): { enabled: string[]; available: readonly string[] } {
58
+ return { enabled: [...(loadConfig(source).extra_skills ?? [])], available: EXTRA_SKILLS };
59
+ }