@llman-sdd/core 0.3.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/package.json +2 -1
  2. package/src/archive/freeze.ts +86 -18
  3. package/src/archive/frozenCard.ts +105 -0
  4. package/src/archive/sevenzip.ts +15 -13
  5. package/src/change/closeOutHarness.ts +29 -0
  6. package/src/change/collect.ts +140 -0
  7. package/src/change/frontmatter.ts +48 -6
  8. package/src/change/id.ts +2 -6
  9. package/src/change/lifecycle.ts +285 -86
  10. package/src/change/nextId.ts +63 -2
  11. package/src/change/resolve.ts +2 -2
  12. package/src/change/tasks.ts +59 -0
  13. package/src/config/changeId.ts +14 -12
  14. package/src/config/load.ts +14 -0
  15. package/src/config/schema.ts +4 -41
  16. package/src/config/surface.ts +6 -36
  17. package/src/context/indexStore.ts +7 -3
  18. package/src/context/retrieve.ts +8 -10
  19. package/src/context/tree.ts +28 -24
  20. package/src/git/spawnGit.ts +90 -2
  21. package/src/index.ts +81 -54
  22. package/src/init/defaultConfig.ts +1 -5
  23. package/src/init/init.ts +19 -4
  24. package/src/ports.ts +1 -7
  25. package/src/project/migrateNotes.ts +104 -0
  26. package/src/render/machine.ts +30 -0
  27. package/src/report/collect.ts +11 -127
  28. package/src/report/graph/analysis.ts +152 -0
  29. package/src/report/graph/deps.ts +30 -0
  30. package/src/report/graph/graphData.ts +53 -0
  31. package/src/report/graph/nodes.ts +130 -0
  32. package/src/report/graph/render.ts +83 -0
  33. package/src/report/graph/types.ts +47 -0
  34. package/src/report/graph.ts +9 -381
  35. package/src/report/show.ts +20 -22
  36. package/src/report/specHelpers.ts +45 -22
  37. package/src/report/specs.ts +23 -25
  38. package/src/review/review.ts +45 -30
  39. package/src/spec/authoring.ts +147 -71
  40. package/src/spec/ir.ts +43 -15
  41. package/src/spec/keywords.ts +147 -0
  42. package/src/spec/migrateNative.ts +201 -0
  43. package/src/spec/parser.ts +95 -83
  44. package/src/spec/reqRegistry.ts +31 -15
  45. package/src/templates/embedded.ts +10 -16
  46. package/src/templates/engine.ts +10 -5
  47. package/src/templates/locale.ts +1 -1
  48. package/src/templates/skills.ts +4 -5
  49. package/src/validation/changeCheck.ts +128 -105
  50. package/src/validation/harness.ts +161 -0
  51. package/src/validation/staleness.ts +9 -5
  52. package/src/validation/validate.ts +60 -88
  53. package/templates/en/skills/llman-sdd-apply-cycle.md +20 -28
  54. package/templates/en/skills/llman-sdd-apply.md +58 -76
  55. package/templates/en/skills/llman-sdd-arch-review.md +12 -19
  56. package/templates/en/skills/llman-sdd-archive.md +27 -42
  57. package/templates/en/skills/llman-sdd-continue.md +17 -24
  58. package/templates/en/skills/llman-sdd-draft.md +17 -28
  59. package/templates/en/skills/llman-sdd-explore.md +29 -43
  60. package/templates/en/skills/llman-sdd-ff.md +12 -17
  61. package/templates/en/skills/llman-sdd-graph.md +14 -32
  62. package/templates/en/skills/llman-sdd-propose.md +48 -63
  63. package/templates/en/skills/llman-sdd-quick.md +12 -27
  64. package/templates/en/skills/llman-sdd-research.md +13 -24
  65. package/templates/en/skills/llman-sdd-specs-compact.md +14 -39
  66. package/templates/en/skills/llman-sdd-validate.md +11 -15
  67. package/templates/en/skills/llman-sdd-verify.md +23 -44
  68. package/templates/en/skills/llman-sdd-wayfinder.md +18 -22
  69. package/templates/en/units/skills/cli-footer.md +2 -0
  70. package/templates/en/units/skills/git-native-flow-brief.md +7 -6
  71. package/templates/en/units/skills/git-native-flow.md +21 -11
  72. package/templates/en/units/skills/human-readable-summary.md +2 -3
  73. package/templates/en/units/skills/stage-guard.md +7 -7
  74. package/templates/en/units/skills/structured-protocol.md +5 -8
  75. package/templates/en/units/skills/validation-hints.md +10 -14
  76. package/templates/en/units/spec/feature-contract.md +27 -16
  77. package/templates/en/units/workflow/archive-freeze-guidance.md +6 -3
  78. package/templates/zh-Hans/skills/llman-sdd-apply-cycle.md +23 -31
  79. package/templates/zh-Hans/skills/llman-sdd-apply.md +63 -81
  80. package/templates/zh-Hans/skills/llman-sdd-arch-review.md +21 -28
  81. package/templates/zh-Hans/skills/llman-sdd-archive.md +29 -44
  82. package/templates/zh-Hans/skills/llman-sdd-continue.md +17 -24
  83. package/templates/zh-Hans/skills/llman-sdd-draft.md +18 -29
  84. package/templates/zh-Hans/skills/llman-sdd-explore.md +34 -48
  85. package/templates/zh-Hans/skills/llman-sdd-ff.md +13 -18
  86. package/templates/zh-Hans/skills/llman-sdd-graph.md +16 -34
  87. package/templates/zh-Hans/skills/llman-sdd-propose.md +51 -65
  88. package/templates/zh-Hans/skills/llman-sdd-quick.md +15 -30
  89. package/templates/zh-Hans/skills/llman-sdd-research.md +17 -28
  90. package/templates/zh-Hans/skills/llman-sdd-specs-compact.md +15 -40
  91. package/templates/zh-Hans/skills/llman-sdd-validate.md +11 -15
  92. package/templates/zh-Hans/skills/llman-sdd-verify.md +26 -47
  93. package/templates/zh-Hans/skills/llman-sdd-wayfinder.md +25 -29
  94. package/templates/zh-Hans/units/skills/cli-footer.md +2 -0
  95. package/templates/zh-Hans/units/skills/git-native-flow-brief.md +7 -6
  96. package/templates/zh-Hans/units/skills/git-native-flow.md +22 -12
  97. package/templates/zh-Hans/units/skills/human-readable-summary.md +4 -5
  98. package/templates/zh-Hans/units/skills/stage-guard.md +9 -9
  99. package/templates/zh-Hans/units/skills/structured-protocol.md +5 -8
  100. package/templates/zh-Hans/units/skills/validation-hints.md +10 -14
  101. package/templates/zh-Hans/units/spec/feature-contract.md +25 -16
  102. package/templates/zh-Hans/units/workflow/archive-freeze-guidance.md +6 -2
  103. package/templates/en/skills/llman-sdd-onboard.md +0 -34
  104. package/templates/en/skills/llman-sdd-show.md +0 -24
  105. package/templates/en/units/migrate-prompt.md +0 -28
  106. package/templates/zh-Hans/skills/llman-sdd-onboard.md +0 -34
  107. package/templates/zh-Hans/skills/llman-sdd-show.md +0 -24
  108. package/templates/zh-Hans/units/migrate-prompt.md +0 -28
@@ -1,3 +1,5 @@
1
+ import { createHash } from 'node:crypto';
2
+
1
3
  import {
2
4
  currentBranch,
3
5
  defaultBranch,
@@ -5,17 +7,19 @@ import {
5
7
  isCleanTree,
6
8
  mergeBase,
7
9
  revParseHead,
10
+ worktreeList,
8
11
  type GitLike,
9
12
  } from '../git/spawnGit.ts';
10
13
  import { readBinding, writeBinding } from './frontmatter.ts';
11
14
  /**
12
15
  * Change lifecycle (change-lifecycle capability): new / start / attach /
13
- * finalize with the v1 git-native contract (r14-r16). All effects flow
16
+ * finalize with the predecessor git-native contract (r14-r16). All effects flow
14
17
  * through the injected GitLike and FsIo ports — this module stays pure.
15
18
  * FsIo paths are ROOT-RELATIVE (e.g. `llmanspec/changes/<id>/proposal.md`);
16
19
  * the git cwd binding lives in the GitLike adapter.
17
20
  */
18
21
  import { DRAFT_PROPOSAL_TEMPLATE, deriveChangeId } from './id.ts';
22
+ import { CLOSE_OUT_TASK_HINT, closeOutTaskLines, parseTaskCheckboxes } from './tasks.ts';
19
23
 
20
24
  export interface FsIo {
21
25
  exists(path: string): boolean;
@@ -28,13 +32,19 @@ export interface FsIo {
28
32
 
29
33
  export class LifecycleError extends Error {}
30
34
 
35
+ // r74: single git-gate message family shared by start/attach/finalize/archive
36
+ // (stable substrings for matching; no internal requirement ids in output).
37
+ const detachedHead = (cmd: string): string =>
38
+ `\`${cmd}\` refuses a detached HEAD; check out a branch first`;
39
+ const notOnBoundBranch = (cmd: string, bound: string, current: string | null): string =>
40
+ `\`${cmd}\` must run on the bound branch \`${bound}\` (current: \`${current ?? 'detached HEAD'}\`)`;
41
+ const onDefaultBranch = (cmd: string, def: string): string =>
42
+ `\`${cmd}\` must not run on the default branch \`${def}\``;
43
+ const dirtyTree = (cmd: string): string => `\`${cmd}\` requires a clean working tree`;
44
+
31
45
  export const CHANGES_DIR = 'llmanspec/changes';
32
46
  const proposalPath = (id: string): string => `${CHANGES_DIR}/${id}/proposal.md`;
33
47
 
34
- export function changeExists(io: FsIo, id: string): boolean {
35
- return io.exists(proposalPath(id));
36
- }
37
-
38
48
  /** `change new [id] --from DESC --force`: derive a legal id and write the draft shell. */
39
49
  export function newChange(
40
50
  io: FsIo,
@@ -51,30 +61,146 @@ export function newChange(
51
61
  return { id, path };
52
62
  }
53
63
 
64
+ export interface StartResult {
65
+ branch: string;
66
+ baseBranch: string;
67
+ baseSha: string;
68
+ /** Absolute worktree path when started with --worktree (r68); undefined on the classic path. */
69
+ worktreePath?: string;
70
+ }
71
+
72
+ /**
73
+ * r68 worktree path (design D2): `<root>/<repo-basename>-<name>` where root is
74
+ * `sdd.worktree_root` (absolute, or repo-root-relative; default = the repo
75
+ * root's parent, the wt-style sibling layout) and name is the branch with `/`
76
+ * folded to `-` (naming=id, default) or base32(sha256(change_id))[:8]
77
+ * (naming=hash).
78
+ */
79
+ function resolveWorktreePath(
80
+ git: GitLike,
81
+ id: string,
82
+ branch: string,
83
+ worktreeRoot: string | undefined,
84
+ naming: 'id' | 'hash' | undefined,
85
+ ): string {
86
+ const toplevel = git.run(['rev-parse', '--show-toplevel']);
87
+ const cut = toplevel.lastIndexOf('/');
88
+ const basename = toplevel.slice(cut + 1);
89
+ const name =
90
+ naming === 'hash'
91
+ ? `${basename}-${base32Sha8(id)}`
92
+ : `${basename}-${branch.replaceAll('/', '-')}`;
93
+ let root = cut <= 0 ? '/' : toplevel.slice(0, cut);
94
+ if (worktreeRoot !== undefined && worktreeRoot !== '') {
95
+ const configured = worktreeRoot.replace(/\/+$/u, '');
96
+ root = configured.startsWith('/') ? configured : `${toplevel}/${configured}`;
97
+ }
98
+ return `${root}/${name}`;
99
+ }
100
+
101
+ const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567';
102
+
103
+ /** First 8 chars of base32(sha256(change_id)), lowercase RFC-4648 alphabet. */
104
+ function base32Sha8(input: string): string {
105
+ const bytes = createHash('sha256').update(input, 'utf8').digest();
106
+ let out = '';
107
+ let buffer = 0;
108
+ let bitsLeft = 0;
109
+ for (const byte of bytes) {
110
+ buffer = (buffer << 8) | byte;
111
+ bitsLeft += 8;
112
+ while (bitsLeft >= 5 && out.length < 8) {
113
+ out += BASE32[(buffer >>> (bitsLeft - 5)) & 31];
114
+ bitsLeft -= 5;
115
+ }
116
+ if (out.length >= 8) break;
117
+ }
118
+ return out;
119
+ }
120
+
54
121
  /** `change start`: clean-tree + default-branch gates, then branch + binding. */
55
122
  export function startChange(
56
123
  git: GitLike,
57
124
  io: FsIo,
58
125
  id: string,
59
- opts: { branchPrefix?: string } = {},
60
- ): { branch: string; baseBranch: string; baseSha: string } {
126
+ opts: {
127
+ branchPrefix?: string;
128
+ /** r68: explicit fork source; exempts the default-branch gate (clean-tree gate stays). */
129
+ base?: string;
130
+ /** r68: create the branch in a dedicated worktree instead of switching this checkout. */
131
+ worktree?: boolean;
132
+ /** Config sdd.worktree_root (unresolved). */
133
+ worktreeRoot?: string;
134
+ /** Config sdd.worktree_naming. */
135
+ worktreeNaming?: 'id' | 'hash';
136
+ } = {},
137
+ ): StartResult {
61
138
  const path = proposalPath(id);
62
139
  if (!io.exists(path)) throw new LifecycleError(`proposal not found: ${path}`);
63
140
  const dirty = dirtyCount(git);
64
- if (dirty > 0)
65
- throw new LifecycleError(
66
- `dirty tree: ${dirty} uncommitted files; commit/stash before \`change start\``,
67
- );
68
- const baseBranch = defaultBranch(git);
141
+ if (dirty > 0) throw new LifecycleError(dirtyTree('change start'));
69
142
  const here = currentBranch(git);
70
- if (here !== null && here !== baseBranch) {
71
- throw new LifecycleError(
72
- `already on non-default branch \`${here}\`; use \`change attach\` to bind it, or switch to the default branch before \`change start\``,
73
- );
74
- }
75
- if (here === null) throw new LifecycleError('detached HEAD is not allowed for change binding');
143
+ if (here === null) throw new LifecycleError(detachedHead('change start'));
76
144
  const branchPrefix = opts.branchPrefix ?? 'sdd/';
77
145
  const branch = `${branchPrefix}${id}`;
146
+
147
+ if (opts.base !== undefined) {
148
+ if (
149
+ git.runOpt(['show-ref', '--verify', '--quiet', `refs/heads/${opts.base}`]) === null &&
150
+ git.runOpt(['show-ref', '--verify', '--quiet', `refs/remotes/${opts.base}`]) === null
151
+ ) {
152
+ throw new LifecycleError(
153
+ `base branch \`${opts.base}\` does not exist; --base records the fork source branch for merge-target resolution`,
154
+ );
155
+ }
156
+ if (opts.base === branch) {
157
+ throw new LifecycleError(`--base must differ from the new branch \`${branch}\``);
158
+ }
159
+ }
160
+
161
+ // r68 fork-source resolution: --base explicit > current branch (worktree
162
+ // mode records the actual source, which may be a non-default branch) >
163
+ // default branch. The classic path keeps the r14 default-branch gate.
164
+ let baseBranch: string;
165
+ if (opts.base !== undefined) {
166
+ baseBranch = opts.base;
167
+ } else if (opts.worktree) {
168
+ baseBranch = here;
169
+ } else {
170
+ baseBranch = defaultBranch(git);
171
+ if (here !== baseBranch) {
172
+ throw new LifecycleError(
173
+ `already on non-default branch \`${here}\`; use \`change attach\` to bind it, or switch to the default branch before \`change start\``,
174
+ );
175
+ }
176
+ }
177
+
178
+ let worktreePath: string | undefined;
179
+ if (opts.worktree) {
180
+ if (git.runOpt(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]) !== null) {
181
+ throw new LifecycleError(`branch \`${branch}\` already exists; choose another change id`);
182
+ }
183
+ worktreePath = resolveWorktreePath(git, id, branch, opts.worktreeRoot, opts.worktreeNaming);
184
+ if (io.exists(worktreePath)) {
185
+ throw new LifecycleError(`worktree path already exists: ${worktreePath}`);
186
+ }
187
+ git.run(['worktree', 'add', '-b', branch, worktreePath]);
188
+ // r68: the binding lands in the NEW worktree's proposal — the initiating
189
+ // checkout stays byte-identical (zero writes through the original io).
190
+ // The clean-tree gate guaranteed the proposal is committed at HEAD, so it
191
+ // is present in the fresh worktree; a missing one rolls everything back.
192
+ const wtIo = ioAt(io, worktreePath);
193
+ if (!wtIo.exists(path)) {
194
+ git.run(['worktree', 'remove', '--force', worktreePath]);
195
+ git.run(['branch', '-D', branch]);
196
+ throw new LifecycleError(
197
+ `proposal not found in the new worktree: ${worktreePath}/${path} — commit the change docs before \`change start --worktree\``,
198
+ );
199
+ }
200
+ const baseSha = mergeBase(gitAt(git, worktreePath), 'HEAD', baseBranch);
201
+ wtIo.writeText(path, writeBinding(wtIo.readText(path), { branch, baseBranch, baseSha }));
202
+ return { branch, baseBranch, baseSha, worktreePath };
203
+ }
78
204
  git.run(['switch', '-c', branch]);
79
205
  const baseSha =
80
206
  currentBranch(git) !== null ? mergeBase(git, 'HEAD', baseBranch) : revParseHead(git);
@@ -100,7 +226,7 @@ export function attachChange(
100
226
  const configuredBase = opts.base ?? defaultBranch(git);
101
227
  const branch = currentBranch(git);
102
228
  if (branch === null || branch === '') {
103
- throw new LifecycleError('detached HEAD is not allowed for change binding');
229
+ throw new LifecycleError(detachedHead('change attach'));
104
230
  }
105
231
  if (opts.base !== undefined) {
106
232
  if (
@@ -108,7 +234,7 @@ export function attachChange(
108
234
  git.runOpt(['show-ref', '--verify', '--quiet', `refs/remotes/${opts.base}`]) === null
109
235
  ) {
110
236
  throw new LifecycleError(
111
- `base branch \`${opts.base}\` does not exist; --base records the fork source branch for merge-target resolution (r111)`,
237
+ `base branch \`${opts.base}\` does not exist; --base records the fork source branch for merge-target resolution`,
112
238
  );
113
239
  }
114
240
  if (opts.base === branch) {
@@ -117,8 +243,7 @@ export function attachChange(
117
243
  }
118
244
  if (branch === configuredBase) {
119
245
  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`)',
246
+ `${onDefaultBranch('change attach', branch)}; create or switch to a feature branch, or use \`change start\``,
122
247
  );
123
248
  }
124
249
  const baseSha = mergeBase(git, branch, configuredBase);
@@ -134,42 +259,27 @@ export interface FinalizeResult {
134
259
  archiveDir: string;
135
260
  warnings: string[];
136
261
  commitSubject: string;
262
+ /** r69: absolute worktree path the close-out executed in; null on the classic path. */
263
+ executedIn: string | null;
137
264
  }
138
265
 
139
266
  export interface ArchiveTaskGate {
140
267
  blocked: boolean;
141
- reasons: string[];
268
+ /** Trimmed unchecked-task lines (empty when not blocked). */
269
+ pendingLines: string[];
142
270
  }
143
271
 
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 };
272
+ /**
273
+ * r40 task gate (single implementation): unchecked tasks block
274
+ * unconditionally — a completion ratio below 1 implies unchecked tasks, so a
275
+ * separate ratio threshold is unreachable and stays out of the contract. The
276
+ * CLI renders the list; core never formats output.
277
+ */
278
+ export function archiveTaskGate(tasksMd: string | null): ArchiveTaskGate {
279
+ if (tasksMd === null) return { blocked: false, pendingLines: [] };
280
+ const { completed, total, pendingLines } = parseTaskCheckboxes(tasksMd);
281
+ if (total > 0 && completed < total) return { blocked: true, pendingLines };
282
+ return { blocked: false, pendingLines: [] };
173
283
  }
174
284
 
175
285
  /** `change finalize`: merge (squash default) + archive rename + close-out commit. */
@@ -177,26 +287,70 @@ export function finalizeChange(
177
287
  git: GitLike,
178
288
  io: FsIo,
179
289
  id: string,
180
- opts: { into?: string; method?: 'squash' | 'ff'; today?: string; noCommit?: boolean } = {},
290
+ opts: { into?: string; method?: 'squash' | 'ff'; today: string; noCommit?: boolean },
181
291
  ): FinalizeResult {
182
292
  const path = proposalPath(id);
183
293
  const binding = readBinding(io.readText(path));
184
294
  if (binding === null) {
185
295
  throw new LifecycleError(`change \`${id}\` has no branch binding — run start/attach first`);
186
296
  }
187
- // r15 (v1 r94): finalize runs on the bound branch — any other branch must
297
+ // r40 task gate before any write (B23: finalize shares the archive task gate).
298
+ const tasksPath = `${CHANGES_DIR}/${id}/tasks.md`;
299
+ const gate = archiveTaskGate(io.exists(tasksPath) ? io.readText(tasksPath) : null);
300
+ if (gate.blocked) {
301
+ // D9: point at a close-out pseudo-task with the single shared hint.
302
+ const closeOut = closeOutTaskLines(gate.pendingLines);
303
+ throw new LifecycleError(
304
+ [
305
+ `finalize blocked by unchecked tasks (${gate.pendingLines.length} pending)`,
306
+ ...gate.pendingLines,
307
+ ...(closeOut.length > 0
308
+ ? [
309
+ `task "${closeOut[0]?.replace(/^-\s+\[ \]\s*/u, '').trim() ?? ''}" looks like a close-out step; ${CLOSE_OUT_TASK_HINT}`,
310
+ ]
311
+ : []),
312
+ ].join('\n'),
313
+ );
314
+ }
315
+ // r15 (predecessor r94): finalize runs on the bound branch — any other branch must
188
316
  // fail before any write (no switch, no merge, no rename).
189
317
  const current = currentBranch(git);
190
318
  if (current !== binding.branch) {
191
- throw new LifecycleError(
192
- `finalize must run on the bound branch \`${binding.branch}\` (current: ${current ?? 'detached HEAD'})`,
193
- );
319
+ throw new LifecycleError(notOnBoundBranch('change finalize', binding.branch, current));
194
320
  }
195
321
  const method = opts.method ?? 'squash';
196
322
  const target = opts.into ?? binding.baseBranch ?? defaultBranch(git);
197
323
  return mergeRenameCommit(git, io, id, binding.branch, target, method, opts.today, opts.noCommit);
198
324
  }
199
325
 
326
+ /** r69: GitLike view bound to another worktree via `git -C <path>` prefixing. */
327
+ function gitAt(git: GitLike, cwd: string): GitLike {
328
+ return {
329
+ run: (args) => git.run(['-C', cwd, ...args]),
330
+ runOpt: (args) => git.runOpt(['-C', cwd, ...args]),
331
+ };
332
+ }
333
+
334
+ /** r69: FsIo view re-rooted at another worktree (root-relative paths join). */
335
+ function ioAt(io: FsIo, root: string): FsIo {
336
+ const at = (p: string): string => (p.startsWith('/') ? p : `${root}/${p}`);
337
+ return {
338
+ exists: (p) => io.exists(at(p)),
339
+ readText: (p) => io.readText(at(p)),
340
+ writeText: (p, content) => io.writeText(at(p), content),
341
+ rename: (from, to) => io.rename(at(from), at(to)),
342
+ listDir: (p) => io.listDir(at(p)),
343
+ };
344
+ }
345
+
346
+ /** r69: worktree path currently holding <branch> checked out, or null. */
347
+ function holderOfWorktree(git: GitLike, branch: string): string | null {
348
+ for (const entry of worktreeList(git)) {
349
+ if (entry.branch === branch) return entry.path;
350
+ }
351
+ return null;
352
+ }
353
+
200
354
  /** Shared close-out: merge → archive rename → single archive(sdd) commit. */
201
355
  function mergeRenameCommit(
202
356
  git: GitLike,
@@ -205,33 +359,58 @@ function mergeRenameCommit(
205
359
  featureBranch: string,
206
360
  target: string,
207
361
  method: 'squash' | 'ff',
208
- today?: string,
362
+ today: string,
209
363
  noCommit?: boolean,
210
364
  ): FinalizeResult {
211
365
  const warnings: string[] = [];
212
- git.run(['switch', target]);
366
+ // r69: when the target branch is checked out in another worktree, `git
367
+ // switch` there would fail outright — run every write (switch / merge /
368
+ // rename / commit) inside the holding worktree instead.
369
+ const here = git.runOpt(['rev-parse', '--show-toplevel']);
370
+ const holder = holderOfWorktree(git, target);
371
+ let execGit = git;
372
+ let execIo = io;
373
+ let executedIn: string | null = null;
374
+ if (
375
+ here !== null &&
376
+ holder !== null &&
377
+ holder.replace(/\/+$/u, '') !== here.replace(/\/+$/u, '')
378
+ ) {
379
+ if (!isCleanTree(gitAt(git, holder))) {
380
+ throw new LifecycleError(
381
+ `target branch \`${target}\` is held by a dirty worktree at \`${holder}\` — nothing written; ` +
382
+ `either clean that worktree (commit/stash, or \`git worktree remove ${holder}\`) ` +
383
+ `or merge manually: git -C ${holder} merge ${method === 'ff' ? '--ff-only' : '--squash'} ${featureBranch} && git -C ${holder} add -A && git -C ${holder} commit -m "archive(sdd): ${id}"`,
384
+ );
385
+ }
386
+ execGit = gitAt(git, holder);
387
+ execIo = ioAt(io, holder);
388
+ executedIn = holder;
389
+ }
390
+ execGit.run(['switch', target]);
213
391
  const mergeArgs =
214
392
  method === 'ff' ? ['merge', '--ff-only', featureBranch] : ['merge', '--squash', featureBranch];
215
- if (git.runOpt(mergeArgs) === null) {
216
- git.runOpt(['merge', '--abort']);
393
+ if (execGit.runOpt(mergeArgs) === null) {
394
+ execGit.runOpt(['merge', '--abort']);
217
395
  warnings.push(
218
396
  `merge ${method} failed — resolve manually, e.g. \`git merge ${method === 'ff' ? '--ff-only' : '--squash'} ${featureBranch}\``,
219
397
  );
220
398
  }
221
399
 
222
- const date = today ?? new Date().toISOString().slice(0, 10);
400
+ const date = today;
223
401
  const archiveDir = `${CHANGES_DIR}/archive/${date}-${id}`;
224
- io.rename(`${CHANGES_DIR}/${id}`, archiveDir);
402
+ execIo.rename(`${CHANGES_DIR}/${id}`, archiveDir);
225
403
 
226
- if (noCommit) return { target, archiveDir, warnings, commitSubject: '' };
227
- git.run(['add', '-A']);
404
+ if (noCommit) return { target, archiveDir, warnings, commitSubject: '', executedIn };
405
+ execGit.run(['add', '-A']);
228
406
  const commitSubject = `archive(sdd): ${id}`;
229
- git.run(['commit', '-m', commitSubject]);
230
- return { target, archiveDir, warnings, commitSubject };
231
- }
232
-
233
- export interface ArchiveChangeResult {
234
- result: FinalizeResult;
407
+ execGit.run(['commit', '-m', commitSubject]);
408
+ if (executedIn !== null) {
409
+ warnings.push(
410
+ `this worktree still shows the pre-archive \`llmanspec/changes/${id}\` checkout (expected) — clean up with \`git worktree remove\` / \`wt remove\` when done`,
411
+ );
412
+ }
413
+ return { target, archiveDir, warnings, commitSubject, executedIn };
235
414
  }
236
415
 
237
416
  /** `change archive`: independent seal-off with task + strict git gates (r39/r40). */
@@ -243,9 +422,10 @@ export function archiveChange(
243
422
  into?: string;
244
423
  method?: 'squash' | 'ff';
245
424
  force?: boolean;
246
- minCompletionRatio?: number;
247
- today?: string;
248
- } = {},
425
+ /** CLI already enforced a clean tree before the acceptance command ran. */
426
+ skipCleanTree?: boolean;
427
+ today: string;
428
+ },
249
429
  ): FinalizeResult {
250
430
  const path = proposalPath(id);
251
431
  const binding = readBinding(io.readText(path));
@@ -254,23 +434,36 @@ export function archiveChange(
254
434
  }
255
435
  if (!opts.force) {
256
436
  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'));
437
+ const gate = archiveTaskGate(io.exists(tasksPath) ? io.readText(tasksPath) : null);
438
+ if (gate.blocked) {
439
+ // D9: when a pending unchecked task starts with a close-out verb, append
440
+ // the same remediation hint as the validate WARNING (single constant).
441
+ const closeOut = closeOutTaskLines(gate.pendingLines);
442
+ throw new LifecycleError(
443
+ [
444
+ `archive blocked by unchecked tasks (${gate.pendingLines.length} pending)`,
445
+ ...gate.pendingLines,
446
+ ...(closeOut.length > 0
447
+ ? [
448
+ `task "${closeOut[0]?.replace(/^-\s+\[ \]\s*/u, '').trim() ?? ''}" looks like a close-out step; ${CLOSE_OUT_TASK_HINT}`,
449
+ ]
450
+ : []),
451
+ ].join('\n'),
452
+ );
453
+ }
262
454
  const current = currentBranch(git);
263
455
  if (current === null || current === '')
264
- throw new LifecycleError('detached HEAD — cannot archive');
456
+ throw new LifecycleError(detachedHead('change archive'));
265
457
  if (current !== binding?.branch) {
266
458
  throw new LifecycleError(
267
- `archive must run on attached branch \`${binding?.branch}\` (current: \`${current}\`)`,
459
+ notOnBoundBranch('change archive', (binding?.branch ?? '') as string, current),
268
460
  );
269
461
  }
270
462
  if (current === defaultBranch(git)) {
271
- throw new LifecycleError('archive must not run on the default branch');
463
+ throw new LifecycleError(onDefaultBranch('change archive', current));
272
464
  }
273
- if (!isCleanTree(git)) throw new LifecycleError('working tree must be clean to archive');
465
+ if (!opts.skipCleanTree && !isCleanTree(git))
466
+ throw new LifecycleError(dirtyTree('change archive'));
274
467
  } else if (binding === null) {
275
468
  throw new LifecycleError(`change \`${id}\` has no branch binding — cannot merge`);
276
469
  }
@@ -294,11 +487,17 @@ export interface ChangeDiffInfo {
294
487
  commitCount: number;
295
488
  }
296
489
 
297
- /** `change diff --json` (r46): structured bound-branch summary. */
490
+ /**
491
+ * `change diff --json` (r46): structured bound-branch summary. commitCount =
492
+ * `merge-base(base_branch, branch)..branch` commit count — the stored
493
+ * `base_sha` is audit-only and never participates in the range; the `base`
494
+ * field echoes it for the predecessor JSON shape.
495
+ */
298
496
  export function changeDiffInfo(git: GitLike, io: FsIo, id: string): ChangeDiffInfo {
299
497
  const binding = readBinding(io.readText(proposalPath(id)));
300
498
  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';
499
+ const baseBranch = binding.baseBranch ?? defaultBranch(git);
500
+ const mb = mergeBase(git, binding.branch, baseBranch);
501
+ const count = git.runOpt(['rev-list', '--count', `${mb}..${binding.branch}`]) ?? '0';
303
502
  return { change: id, branch: binding.branch, base: binding.baseSha, commitCount: Number(count) };
304
503
  }
@@ -1,10 +1,14 @@
1
1
  /**
2
- * Whole-tree change-id number harvest (r35, v1 `change next-id` parity).
2
+ * Whole-tree change-id number harvest (r35, predecessor `change next-id` parity).
3
3
  * Read-only: walks directory names at any depth under `llmanspec/` and
4
4
  * extracts `c<digits>` tokens at token boundaries — the same value
5
5
  * `change new --from` used to inject as `llman_sdd_unique_id`.
6
+ * r35 extension: the scan covers the current tree plus every linked git
7
+ * worktree's own `llmanspec/` tree (see harvestAcrossWorktrees).
6
8
  */
7
9
 
10
+ import { worktreeList, type GitLike } from '../git/spawnGit.ts';
11
+
8
12
  export interface NextIdIo {
9
13
  listDir(path: string): string[];
10
14
  isDirectory(path: string): boolean;
@@ -29,7 +33,11 @@ export function extractUniqueNumber(name: string): number | null {
29
33
  return m ? Number(m[1]) : null;
30
34
  }
31
35
 
32
- export function harvestUniqueNumbers(io: NextIdIo, root: string): IdHarvest {
36
+ /** Numbers found under one tree root plus per-walk listing warnings. */
37
+ export function collectNumbers(
38
+ io: NextIdIo,
39
+ root: string,
40
+ ): { numbers: number[]; warnings: string[] } {
33
41
  const numbers: number[] = [];
34
42
  const warnings: string[] = [];
35
43
  const walk = (dir: string): void => {
@@ -50,6 +58,59 @@ export function harvestUniqueNumbers(io: NextIdIo, root: string): IdHarvest {
50
58
  }
51
59
  };
52
60
  walk(root);
61
+ return { numbers, warnings };
62
+ }
63
+
64
+ export function harvestUniqueNumbers(io: NextIdIo, root: string): IdHarvest {
65
+ const { numbers, warnings } = collectNumbers(io, root);
53
66
  const maxNumber = numbers.length > 0 ? Math.max(...numbers) : null;
54
67
  return { maxNumber, nextNumber: maxNumber === null ? 1 : maxNumber + 1, warnings };
55
68
  }
69
+
70
+ /**
71
+ * r35 extension: harvest across the current tree AND every linked git
72
+ * worktree's own `llmanspec/` tree (worktree paths deduped). Worktree listing
73
+ * failure degrades to the current tree only (best-effort, same grade as the
74
+ * archive sweep) with the cause recorded in warnings; the same number visible
75
+ * in more than one worktree raises a warning naming it and the worktrees.
76
+ */
77
+ export function harvestAcrossWorktrees(git: GitLike, io: NextIdIo, root: string): IdHarvest {
78
+ const warnings: string[] = [];
79
+ let paths: string[] | null;
80
+ try {
81
+ const seen = new Set<string>();
82
+ paths = [];
83
+ for (const entry of worktreeList(git)) {
84
+ const path = entry.path.replace(/\/+$/u, '');
85
+ if (!seen.has(path)) {
86
+ seen.add(path);
87
+ paths.push(path);
88
+ }
89
+ }
90
+ } catch (error) {
91
+ paths = null;
92
+ warnings.push(
93
+ `worktree list failed: ${(error as Error).message} — scanned the current tree only`,
94
+ );
95
+ }
96
+ const trees = paths === null ? [root] : paths.map((p) => `${p}/llmanspec`);
97
+ const perTree = trees.map((at) => ({ at, ...collectNumbers(io, at) }));
98
+ for (const tree of perTree) warnings.push(...tree.warnings);
99
+ const where = new Map<number, Set<string>>();
100
+ for (const tree of perTree) {
101
+ for (const n of new Set(tree.numbers)) {
102
+ const labels = where.get(n) ?? new Set<string>();
103
+ if (labels.size === 0) where.set(n, labels);
104
+ labels.add(tree.at);
105
+ }
106
+ }
107
+ for (const n of [...where.keys()].toSorted((a, b) => a - b)) {
108
+ const labels = [...(where.get(n) as Set<string>)].toSorted();
109
+ if (labels.length > 1) {
110
+ warnings.push(`number ${n} appears in multiple worktrees: ${labels.join(', ')}`);
111
+ }
112
+ }
113
+ const all = perTree.flatMap((tree) => tree.numbers);
114
+ const maxNumber = all.length > 0 ? Math.max(...all) : null;
115
+ return { maxNumber, nextNumber: maxNumber === null ? 1 : maxNumber + 1, warnings };
116
+ }
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Change-id prefix resolution (peripheral-commands r61, v1 cli spec r112):
2
+ * Change-id prefix resolution (peripheral-commands r61, predecessor cli spec r112):
3
3
  * exact match > unique prefix > multiple candidates > no match, all
4
4
  * case-sensitive. Candidates are the active change ids discovered via
5
5
  * collectChanges (depth-limited, archive-skipping). Pure — IO is injected.
6
6
  */
7
- import { collectChanges, type ChangeFsIo } from '../report/collect.ts';
7
+ import { collectChanges, type ChangeFsIo } from './collect.ts';
8
8
 
9
9
  export interface ResolvedChangeId {
10
10
  id: string;