@llman-sdd/core 0.3.0 → 0.5.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 +2 -1
- package/src/archive/freeze.ts +86 -18
- package/src/archive/frozenCard.ts +105 -0
- package/src/archive/sevenzip.ts +15 -13
- package/src/change/closeOutHarness.ts +29 -0
- package/src/change/collect.ts +140 -0
- package/src/change/frontmatter.ts +48 -6
- package/src/change/id.ts +2 -6
- package/src/change/lifecycle.ts +285 -86
- package/src/change/nextId.ts +63 -2
- package/src/change/resolve.ts +2 -2
- package/src/change/tasks.ts +59 -0
- package/src/config/changeId.ts +14 -12
- package/src/config/load.ts +14 -0
- package/src/config/schema.ts +4 -41
- package/src/config/surface.ts +6 -36
- package/src/context/indexStore.ts +7 -3
- package/src/context/retrieve.ts +8 -10
- package/src/context/tree.ts +28 -24
- package/src/git/spawnGit.ts +90 -2
- package/src/index.ts +67 -59
- package/src/init/defaultConfig.ts +1 -5
- package/src/init/init.ts +19 -4
- package/src/ports.ts +1 -7
- package/src/project/migrateNotes.ts +104 -0
- package/src/render/machine.ts +30 -0
- package/src/report/collect.ts +11 -127
- package/src/report/graph/analysis.ts +152 -0
- package/src/report/graph/deps.ts +30 -0
- package/src/report/graph/graphData.ts +53 -0
- package/src/report/graph/nodes.ts +130 -0
- package/src/report/graph/render.ts +83 -0
- package/src/report/graph/types.ts +47 -0
- package/src/report/graph.ts +9 -381
- package/src/report/show.ts +20 -22
- package/src/report/specHelpers.ts +42 -21
- package/src/report/specs.ts +23 -25
- package/src/review/review.ts +45 -30
- package/src/spec/authoring.ts +91 -63
- package/src/spec/ir.ts +43 -15
- package/src/spec/migrateNative.ts +167 -0
- package/src/spec/parser.ts +73 -77
- package/src/spec/reqRegistry.ts +8 -9
- package/src/templates/embedded.ts +10 -16
- package/src/templates/engine.ts +10 -5
- package/src/templates/locale.ts +1 -1
- package/src/templates/skills.ts +4 -5
- package/src/validation/changeCheck.ts +128 -105
- package/src/validation/harness.ts +161 -0
- package/src/validation/staleness.ts +9 -5
- package/src/validation/validate.ts +60 -88
- package/templates/en/skills/llman-sdd-apply-cycle.md +20 -28
- package/templates/en/skills/llman-sdd-apply.md +58 -76
- package/templates/en/skills/llman-sdd-arch-review.md +12 -19
- package/templates/en/skills/llman-sdd-archive.md +27 -42
- package/templates/en/skills/llman-sdd-continue.md +17 -24
- package/templates/en/skills/llman-sdd-draft.md +17 -28
- package/templates/en/skills/llman-sdd-explore.md +29 -43
- package/templates/en/skills/llman-sdd-ff.md +12 -17
- package/templates/en/skills/llman-sdd-graph.md +14 -32
- package/templates/en/skills/llman-sdd-propose.md +48 -63
- package/templates/en/skills/llman-sdd-quick.md +12 -27
- package/templates/en/skills/llman-sdd-research.md +13 -24
- package/templates/en/skills/llman-sdd-specs-compact.md +14 -39
- package/templates/en/skills/llman-sdd-validate.md +11 -15
- package/templates/en/skills/llman-sdd-verify.md +23 -44
- package/templates/en/skills/llman-sdd-wayfinder.md +18 -22
- package/templates/en/units/skills/cli-footer.md +2 -0
- package/templates/en/units/skills/git-native-flow-brief.md +7 -6
- package/templates/en/units/skills/git-native-flow.md +21 -11
- package/templates/en/units/skills/human-readable-summary.md +2 -3
- package/templates/en/units/skills/stage-guard.md +7 -7
- package/templates/en/units/skills/structured-protocol.md +5 -8
- package/templates/en/units/skills/validation-hints.md +10 -14
- package/templates/en/units/spec/feature-contract.md +27 -16
- package/templates/en/units/workflow/archive-freeze-guidance.md +6 -3
- package/templates/zh-Hans/skills/llman-sdd-apply-cycle.md +23 -31
- package/templates/zh-Hans/skills/llman-sdd-apply.md +63 -81
- package/templates/zh-Hans/skills/llman-sdd-arch-review.md +21 -28
- package/templates/zh-Hans/skills/llman-sdd-archive.md +29 -44
- package/templates/zh-Hans/skills/llman-sdd-continue.md +17 -24
- package/templates/zh-Hans/skills/llman-sdd-draft.md +18 -29
- package/templates/zh-Hans/skills/llman-sdd-explore.md +34 -48
- package/templates/zh-Hans/skills/llman-sdd-ff.md +13 -18
- package/templates/zh-Hans/skills/llman-sdd-graph.md +16 -34
- package/templates/zh-Hans/skills/llman-sdd-propose.md +51 -65
- package/templates/zh-Hans/skills/llman-sdd-quick.md +15 -30
- package/templates/zh-Hans/skills/llman-sdd-research.md +17 -28
- package/templates/zh-Hans/skills/llman-sdd-specs-compact.md +15 -40
- package/templates/zh-Hans/skills/llman-sdd-validate.md +11 -15
- package/templates/zh-Hans/skills/llman-sdd-verify.md +26 -47
- package/templates/zh-Hans/skills/llman-sdd-wayfinder.md +25 -29
- package/templates/zh-Hans/units/skills/cli-footer.md +2 -0
- package/templates/zh-Hans/units/skills/git-native-flow-brief.md +7 -6
- package/templates/zh-Hans/units/skills/git-native-flow.md +22 -12
- package/templates/zh-Hans/units/skills/human-readable-summary.md +4 -5
- package/templates/zh-Hans/units/skills/stage-guard.md +9 -9
- package/templates/zh-Hans/units/skills/structured-protocol.md +5 -8
- package/templates/zh-Hans/units/skills/validation-hints.md +10 -14
- package/templates/zh-Hans/units/spec/feature-contract.md +25 -16
- package/templates/zh-Hans/units/workflow/archive-freeze-guidance.md +6 -2
- package/templates/en/skills/llman-sdd-onboard.md +0 -34
- package/templates/en/skills/llman-sdd-show.md +0 -24
- package/templates/en/units/migrate-prompt.md +0 -28
- package/templates/zh-Hans/skills/llman-sdd-onboard.md +0 -34
- package/templates/zh-Hans/skills/llman-sdd-show.md +0 -24
- package/templates/zh-Hans/units/migrate-prompt.md +0 -28
package/src/change/lifecycle.ts
CHANGED
|
@@ -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
|
|
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: {
|
|
60
|
-
|
|
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
|
|
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('
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
268
|
+
/** Trimmed unchecked-task lines (empty when not blocked). */
|
|
269
|
+
pendingLines: string[];
|
|
142
270
|
}
|
|
143
271
|
|
|
144
|
-
/**
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
362
|
+
today: string,
|
|
209
363
|
noCommit?: boolean,
|
|
210
364
|
): FinalizeResult {
|
|
211
365
|
const warnings: string[] = [];
|
|
212
|
-
|
|
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 (
|
|
216
|
-
|
|
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
|
|
400
|
+
const date = today;
|
|
223
401
|
const archiveDir = `${CHANGES_DIR}/archive/${date}-${id}`;
|
|
224
|
-
|
|
402
|
+
execIo.rename(`${CHANGES_DIR}/${id}`, archiveDir);
|
|
225
403
|
|
|
226
|
-
if (noCommit) return { target, archiveDir, warnings, commitSubject: '' };
|
|
227
|
-
|
|
404
|
+
if (noCommit) return { target, archiveDir, warnings, commitSubject: '', executedIn };
|
|
405
|
+
execGit.run(['add', '-A']);
|
|
228
406
|
const commitSubject = `archive(sdd): ${id}`;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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('
|
|
456
|
+
throw new LifecycleError(detachedHead('change archive'));
|
|
265
457
|
if (current !== binding?.branch) {
|
|
266
458
|
throw new LifecycleError(
|
|
267
|
-
|
|
459
|
+
notOnBoundBranch('change archive', (binding?.branch ?? '') as string, current),
|
|
268
460
|
);
|
|
269
461
|
}
|
|
270
462
|
if (current === defaultBranch(git)) {
|
|
271
|
-
throw new LifecycleError('archive
|
|
463
|
+
throw new LifecycleError(onDefaultBranch('change archive', current));
|
|
272
464
|
}
|
|
273
|
-
if (!isCleanTree(git))
|
|
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
|
-
/**
|
|
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
|
|
302
|
-
|
|
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
|
}
|
package/src/change/nextId.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Whole-tree change-id number harvest (r35,
|
|
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
|
-
|
|
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
|
+
}
|
package/src/change/resolve.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Change-id prefix resolution (peripheral-commands r61,
|
|
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 '
|
|
7
|
+
import { collectChanges, type ChangeFsIo } from './collect.ts';
|
|
8
8
|
|
|
9
9
|
export interface ResolvedChangeId {
|
|
10
10
|
id: string;
|