@llman-sdd/core 0.1.3 → 0.2.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.3",
3
+ "version": "0.2.0",
4
4
  "description": "Pure domain logic for llman-sdd (config, gherkin specs, validation, templates)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -88,12 +88,14 @@ 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
- const entries = (await sz.listEntries(archiveAbs))
94
- .map((n) => n.replace(/\/$/u, ''))
95
- .filter((n) => DATED_RE.test(n) && n.split('/').length === 1);
96
- const unique = [...new Set(entries)].toSorted();
93
+ // Real 7z lists file paths under their directory (`<dir>/proposal.md`);
94
+ // directory entries themselves are skipped by parseListNames. Derive the
95
+ // archived change names from the leading path segment.
96
+ const entries = (await sz.listEntries(archiveAbs)).map((n) => n.replace(/\/$/u, ''));
97
+ const top = (n: string): string => n.split('/')[0] ?? n;
98
+ const unique = [...new Set(entries.map(top).filter((n) => DATED_RE.test(n)))].toSorted();
97
99
  if (unique.length === 0) {
98
100
  return [
99
101
  `Freeze archive ${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME} contains no archived changes`,
@@ -115,10 +117,13 @@ export async function runThaw(
115
117
  sz: SevenZipPort,
116
118
  rootAbs: string,
117
119
  names: string[],
120
+ opts: { dest?: string } = {},
118
121
  ): Promise<ThawResult> {
122
+ // r56: restore target override (created on demand); default = changes/archive
123
+ const destRel = opts.dest ?? ARCHIVE_DIR_REL;
119
124
  const archiveAbs = join(rootAbs, ARCHIVE_DIR_REL, FREEZE_ARCHIVE_NAME);
120
125
  if (!io.exists(`${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`)) {
121
- 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}`);
122
127
  }
123
128
  const tmpRel = 'llmanspec/.thaw-tmp';
124
129
  const tmpAbs = join(rootAbs, tmpRel);
@@ -134,15 +139,15 @@ export async function runThaw(
134
139
  }
135
140
  const restored: string[] = [];
136
141
  for (const name of names.toSorted()) {
137
- if (io.exists(`${ARCHIVE_DIR_REL}/${name}`)) {
142
+ if (io.exists(`${destRel}/${name}`)) {
138
143
  throw new Error(`target already exists: ${name}`);
139
144
  }
140
- io.moveDir(`${tmpRel}/${name}`, `${ARCHIVE_DIR_REL}/${name}`);
145
+ io.moveDir(`${tmpRel}/${name}`, `${destRel}/${name}`);
141
146
  restored.push(name);
142
147
  }
143
148
  return {
144
149
  restored,
145
- lines: [`Thawed ${restored.length} selected archived changes to ${ARCHIVE_DIR_REL}`],
150
+ lines: [`Thawed ${restored.length} selected archived changes to ${destRel}`],
146
151
  };
147
152
  } finally {
148
153
  io.removeDir(tmpRel);
@@ -1,3 +1,4 @@
1
+ import { Buffer } from 'node:buffer';
1
2
  import { mkdirSync } from 'node:fs';
2
3
  import { basename, join } from 'node:path';
3
4
 
@@ -7,6 +8,13 @@ import { basename, join } from 'node:path';
7
8
  * instance per operation; extraction target must be a fresh directory
8
9
  * (WASM 7z refuses existing -o dirs). Spike-proven on Bun: compress / list /
9
10
  * extract keep directory structure.
11
+ *
12
+ * Compiled single-file binaries have no on-disk 7zz.wasm (Emscripten would
13
+ * probe $bunfs and abort), so build-binary.ts additionally injects the wasm
14
+ * base64 via the literal define `process.env.LLMAN_SDD_EMBEDDED_7ZZ_WASM_B64`
15
+ * (define only rewrites literal member access — do not route through a
16
+ * variable). Unset in source/npm/Node runs → glue loads the .wasm from disk
17
+ * as before.
10
18
  */
11
19
  import SevenZip from '7z-wasm';
12
20
 
@@ -35,14 +43,34 @@ type ModuleFactory = (opts: {
35
43
  noInitialRun: boolean;
36
44
  print?: (text: string) => void;
37
45
  printErr?: (text: string) => void;
46
+ wasmBinary?: Uint8Array;
38
47
  }) => Promise<EmscriptenModule>;
39
48
 
40
- async function initModule(print?: (text: string) => void): Promise<EmscriptenModule> {
49
+ const WASM_MAGIC = [0x00, 0x61, 0x73, 0x6d] as const;
50
+
51
+ /** Decode a base64 wasm blob; undefined when absent or not a WASM binary.
52
+ * Base64 decoding itself never throws, so the magic bytes are the guard. */
53
+ export function resolveEmbeddedWasmB64(value: unknown): Uint8Array | undefined {
54
+ if (typeof value !== 'string' || value === '') return undefined;
55
+ const bytes = Buffer.from(value, 'base64');
56
+ if (bytes.length < 4 || WASM_MAGIC.some((b, i) => bytes[i] !== b)) return undefined;
57
+ return new Uint8Array(bytes);
58
+ }
59
+
60
+ /** Read the build-injected wasm; undefined when absent or malformed. */
61
+ export function embeddedWasmBinary(): Uint8Array | undefined {
62
+ return resolveEmbeddedWasmB64(process.env.LLMAN_SDD_EMBEDDED_7ZZ_WASM_B64);
63
+ }
64
+
65
+ async function initModule(
66
+ print?: (text: string) => void,
67
+ wasmBinary?: Uint8Array,
68
+ ): Promise<EmscriptenModule> {
41
69
  const sink = (text: string): void => {
42
70
  if (print) print(text);
43
71
  };
44
72
  const factory = SevenZip as unknown as ModuleFactory;
45
- return factory({ noInitialRun: true, print: sink, printErr: sink });
73
+ return factory({ noInitialRun: true, print: sink, printErr: sink, wasmBinary });
46
74
  }
47
75
 
48
76
  function mount(mod: EmscriptenModule, seq: number, hostDir: string): string {
@@ -78,9 +106,10 @@ function parseListNames(lines: string[]): string[] {
78
106
  }
79
107
 
80
108
  export async function makeWasmSevenZip(): Promise<SevenZipPort> {
109
+ const wasmBinary = embeddedWasmBinary();
81
110
  return {
82
111
  async add(archivePath: string, baseDir: string, entries: string[]): Promise<void> {
83
- const mod = await initModule();
112
+ const mod = await initModule(undefined, wasmBinary);
84
113
  const archMount = mount(mod, 1, join(archivePath, '..'));
85
114
  const baseMount = mount(mod, 2, baseDir);
86
115
  mod.FS.chdir(baseMount);
@@ -90,7 +119,7 @@ export async function makeWasmSevenZip(): Promise<SevenZipPort> {
90
119
 
91
120
  async listEntries(archivePath: string): Promise<string[]> {
92
121
  const lines: string[] = [];
93
- const mod = await initModule((text) => lines.push(text));
122
+ const mod = await initModule((text) => lines.push(text), wasmBinary);
94
123
  const archMount = mount(mod, 1, join(archivePath, '..'));
95
124
  const rc = mod.callMain(['l', `${archMount}/${basename(archivePath)}`]);
96
125
  if (rc !== 0) throw new SevenZipError(`7z list failed (rc=${rc})`);
@@ -98,7 +127,7 @@ export async function makeWasmSevenZip(): Promise<SevenZipPort> {
98
127
  },
99
128
 
100
129
  async extractAll(archivePath: string, destDir: string): Promise<void> {
101
- const mod = await initModule();
130
+ const mod = await initModule(undefined, wasmBinary);
102
131
  const archMount = mount(mod, 1, join(archivePath, '..'));
103
132
  mkdirSync(destDir, { recursive: true });
104
133
  const destMount = mount(mod, 2, destDir);
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,12 +136,48 @@ 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));
@@ -106,32 +186,91 @@ export function finalizeChange(
106
186
  }
107
187
  const method = opts.method ?? 'squash';
108
188
  const target = opts.into ?? binding.baseBranch ?? defaultBranch(git);
109
- const warnings: string[] = [];
189
+ return mergeRenameCommit(git, io, id, binding.branch, target, method, opts.today, opts.noCommit);
190
+ }
110
191
 
111
- // Read the binding while still on the feature branch (its proposal.md may
112
- // carry uncommitted binding edits), then switch and merge.
192
+ /** Shared close-out: merge → archive rename → single archive(sdd) commit. */
193
+ function mergeRenameCommit(
194
+ git: GitLike,
195
+ io: FsIo,
196
+ id: string,
197
+ featureBranch: string,
198
+ target: string,
199
+ method: 'squash' | 'ff',
200
+ today?: string,
201
+ noCommit?: boolean,
202
+ ): FinalizeResult {
203
+ const warnings: string[] = [];
113
204
  git.run(['switch', target]);
114
205
  const mergeArgs =
115
- method === 'ff'
116
- ? ['merge', '--ff-only', binding.branch]
117
- : ['merge', '--squash', binding.branch];
206
+ method === 'ff' ? ['merge', '--ff-only', featureBranch] : ['merge', '--squash', featureBranch];
118
207
  if (git.runOpt(mergeArgs) === null) {
119
208
  git.runOpt(['merge', '--abort']);
120
209
  warnings.push(
121
- `merge ${method} failed — resolve manually, e.g. \`git merge ${method === 'ff' ? '--ff-only' : '--squash'} ${binding.branch}\``,
210
+ `merge ${method} failed — resolve manually, e.g. \`git merge ${method === 'ff' ? '--ff-only' : '--squash'} ${featureBranch}\``,
122
211
  );
123
212
  }
124
213
 
125
- const date = opts.today ?? new Date().toISOString().slice(0, 10);
214
+ const date = today ?? new Date().toISOString().slice(0, 10);
126
215
  const archiveDir = `${CHANGES_DIR}/archive/${date}-${id}`;
127
216
  io.rename(`${CHANGES_DIR}/${id}`, archiveDir);
128
217
 
218
+ if (noCommit) return { target, archiveDir, warnings, commitSubject: '' };
129
219
  git.run(['add', '-A']);
130
220
  const commitSubject = `archive(sdd): ${id}`;
131
221
  git.run(['commit', '-m', commitSubject]);
132
222
  return { target, archiveDir, warnings, commitSubject };
133
223
  }
134
224
 
225
+ export interface ArchiveChangeResult {
226
+ result: FinalizeResult;
227
+ }
228
+
229
+ /** `change archive`: independent seal-off with task + strict git gates (r39/r40). */
230
+ export function archiveChange(
231
+ git: GitLike,
232
+ io: FsIo,
233
+ id: string,
234
+ opts: {
235
+ into?: string;
236
+ method?: 'squash' | 'ff';
237
+ force?: boolean;
238
+ minCompletionRatio?: number;
239
+ today?: string;
240
+ } = {},
241
+ ): FinalizeResult {
242
+ const path = proposalPath(id);
243
+ const binding = readBinding(io.readText(path));
244
+ if (!opts.force && binding === null) {
245
+ throw new LifecycleError(`change \`${id}\` has no branch binding — run start/attach first`);
246
+ }
247
+ if (!opts.force) {
248
+ const tasksPath = `${CHANGES_DIR}/${id}/tasks.md`;
249
+ const gate = archiveTaskGate(
250
+ io.exists(tasksPath) ? io.readText(tasksPath) : null,
251
+ opts.minCompletionRatio,
252
+ );
253
+ if (gate.blocked) throw new LifecycleError(gate.reasons.join('\n'));
254
+ const current = currentBranch(git);
255
+ if (current === null || current === '')
256
+ throw new LifecycleError('detached HEAD — cannot archive');
257
+ if (current !== binding?.branch) {
258
+ throw new LifecycleError(
259
+ `archive must run on attached branch \`${binding?.branch}\` (current: \`${current}\`)`,
260
+ );
261
+ }
262
+ if (current === defaultBranch(git)) {
263
+ throw new LifecycleError('archive must not run on the default branch');
264
+ }
265
+ if (!isCleanTree(git)) throw new LifecycleError('working tree must be clean to archive');
266
+ } else if (binding === null) {
267
+ throw new LifecycleError(`change \`${id}\` has no branch binding — cannot merge`);
268
+ }
269
+ const method = opts.method ?? 'squash';
270
+ const target = opts.into ?? binding?.baseBranch ?? defaultBranch(git);
271
+ return mergeRenameCommit(git, io, id, binding?.branch as string, target, method, opts.today);
272
+ }
273
+
135
274
  /** `change diff <id>`: full diff of the bound branch vs base. */
136
275
  export function changeDiff(git: GitLike, io: FsIo, id: string): string {
137
276
  const binding = readBinding(io.readText(proposalPath(id)));
@@ -139,3 +278,19 @@ export function changeDiff(git: GitLike, io: FsIo, id: string): string {
139
278
  const base = binding.baseBranch ?? defaultBranch(git);
140
279
  return git.run(['diff', `${base}...${binding.branch}`]);
141
280
  }
281
+
282
+ export interface ChangeDiffInfo {
283
+ change: string;
284
+ branch: string;
285
+ base: string;
286
+ commitCount: number;
287
+ }
288
+
289
+ /** `change diff --json` (r46): structured bound-branch summary. */
290
+ export function changeDiffInfo(git: GitLike, io: FsIo, id: string): ChangeDiffInfo {
291
+ const binding = readBinding(io.readText(proposalPath(id)));
292
+ if (binding === null) throw new LifecycleError(`change \`${id}\` has no branch binding`);
293
+ const count =
294
+ git.runOpt(['rev-list', '--count', `${binding.baseSha}...${binding.branch}`]) ?? '0';
295
+ return { change: id, branch: binding.branch, base: binding.baseSha, commitCount: Number(count) };
296
+ }
@@ -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,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
+ }