@aipper/aiws 0.0.2 → 0.0.3

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/README.md CHANGED
@@ -1,54 +1,67 @@
1
1
  # @aipper/aiws
2
2
 
3
- AI Workspace CLI:`aiws init/update/validate/rollback`。
4
-
5
- 真值来源(SSOT):`@aipper/aiws-spec`(模板与契约在 `packages/spec/`)。
6
-
7
- 需求真值:仓库根 `REQUIREMENTS.md`(TOOLING-001B)。
8
-
9
- 开发期最小验证(本仓库内):
10
- - `node packages/aiws/bin/aiws.js --help`
11
-
12
- Codex repo skills(推荐):
13
- - `aiws init .` 会生成 `.agents/skills/`(随仓库共享)
14
- - 在 Codex 中可显式调用(示例):`$ws-preflight` / `$ws-plan` / `$ws-dev` / `$ws-review` / `$ws-commit`
15
-
16
- Codex 全局 skills(推荐;可选):
17
- - 安装到 `~/.codex/skills/`(或 `$CODEX_HOME/skills`):`aiws codex install-skills`
18
- - 预演(不写入):`aiws codex install-skills --dry-run`
19
- - 状态检查:`aiws codex status-skills`
20
- - 卸载(仅移除 AIWS 托管 skills;会备份):`aiws codex uninstall-skills`
21
- - 本仓库内可复现验证(不写入 home):`CODEX_HOME="$(mktemp -d)" node packages/aiws/bin/aiws.js codex install-skills`
22
-
23
- Codex 全局 prompts(遗留;deprecated):
24
- - 安装到 `~/.codex/prompts/`(或 `$CODEX_HOME/prompts`):`aiws codex install-prompts`
25
- - 预演(不写入):`aiws codex install-prompts --dry-run`
26
- - 状态检查:`aiws codex status`
27
- - 卸载(仅移除 AIWS 托管 prompts;会备份):`aiws codex uninstall-prompts`
28
- - 本仓库内可复现验证(不写入 home):`CODEX_HOME="$(mktemp -d)" node packages/aiws/bin/aiws.js codex install-prompts`
29
-
30
- Git hooks 门禁(推荐):
31
- - 启用(会自动补齐 `.githooks/*`,并设置 `core.hooksPath=.githooks`):`aiws hooks install .`
32
- - 查看状态:`aiws hooks status .`
33
- - 证据落盘(可选):`AIWS_VALIDATE_STAMP=1 aiws validate .` 会写入 `.agentdocs/tmp/aiws-validate/*.json`(`.agentdocs/` 被 `.gitignore` 忽略)
34
-
35
- Change 工作流(脱离 dotfiles):
36
- - 创建工件:`aiws change new <change-id>`(生成 `changes/<change-id>/{proposal,tasks,design}.md` 与 `.ws-change.json`)
37
- - 切分支并初始化:`aiws change start <change-id> [--hooks]`
38
- - 进度/建议:`aiws change list` / `aiws change status <change-id>` / `aiws change next <change-id>`
39
- - 同步真值基线:`aiws change sync <change-id>`
40
- - 严格校验:`aiws change validate <change-id> --strict`(在你把 `WS:TODO` 与归因补齐前,预期会失败)
41
- - 归档:`aiws change archive <change-id>`
42
-
43
- 本仓库内可复现验证(不污染本仓库):
3
+ AI Workspace CLI:把 Claude Code / OpenCode / Codex / iFlow 对齐到同一套“真值文件 + 可审计工作流”,降低规则漂移。
4
+
5
+ 核心能力:
6
+ - 初始化/更新模板:`aiws init` / `aiws update`
7
+ - 强门禁校验:`aiws validate`(可选证据落盘 `--stamp`)
8
+ - 回滚:`aiws rollback`(从 `.aiws/backups/` 恢复)
9
+ - 变更工件工作流(脱离 dotfiles):`aiws change ...`
10
+ - Git hooks:`aiws hooks install/status`
11
+ - Codex:repo skills(推荐)+ 可选全局 skills 安装
12
+
13
+ 真值来源(SSOT):`@aipper/aiws-spec`(模板与契约)。
14
+
15
+ ## 安装
16
+
17
+ 二选一:
18
+ - 全局安装(推荐,适合长期使用):`npm i -g @aipper/aiws`
19
+ - 临时运行(不污染全局版本):`npx @aipper/aiws <command>`
20
+
21
+ ## 快速开始
22
+
23
+ 在任意 git 仓库根目录:
44
24
  ```bash
45
- repo_root="$(pwd)"
46
- tmpdir="$(mktemp -d)"
47
- cd "$tmpdir"
48
- git init
49
- node "$repo_root/packages/aiws/bin/aiws.js" init .
50
- node "$repo_root/packages/aiws/bin/aiws.js" change new demo-change --no-design
51
- node "$repo_root/packages/aiws/bin/aiws.js" change list
52
- node "$repo_root/packages/aiws/bin/aiws.js" change sync demo-change
53
- node "$repo_root/packages/aiws/bin/aiws.js" change validate demo-change --strict
25
+ aiws init .
26
+ aiws hooks install .
27
+
28
+ # 建议:用 change 工作流生成可审计工件并切分支
29
+ aiws change start demo-change --no-design --hooks
30
+
31
+ # 提交前门禁(并落盘证据)
32
+ aiws validate . --stamp
54
33
  ```
34
+
35
+ ## CLI 速查
36
+
37
+ ```bash
38
+ aiws init [path] [--template <id>]
39
+ aiws update [path]
40
+ aiws validate [path] [--stamp]
41
+ aiws rollback [path] <timestamp|latest>
42
+
43
+ aiws change <list|new|start|status|next|sync|validate|archive|templates>
44
+ aiws hooks <install|status>
45
+ aiws codex <install-skills|status-skills|uninstall-skills|install-prompts|status|uninstall-prompts>
46
+ ```
47
+
48
+ ## Codex(repo skills 优先)
49
+
50
+ - `aiws init .` 会生成 `.agents/skills/`(随仓库共享),在 Codex 中可显式调用(示例):`$ws-preflight` / `$ws-plan` / `$ws-dev` / `$ws-review` / `$ws-commit`
51
+ - 收尾(安全合并回目标分支):`$ws-finish`(底层调用 `aiws change finish`,默认 fast-forward)
52
+ - 可选:安装全局 skills 到 `~/.codex/skills/`(或 `$CODEX_HOME/skills`):
53
+ - `aiws codex install-skills`
54
+ - `aiws codex status-skills`
55
+ - `aiws codex uninstall-skills`
56
+ - legacy prompts(deprecated,仅兼容):`aiws codex install-prompts`
57
+
58
+ ## 证据落盘与协作
59
+
60
+ - `aiws validate --stamp` 会写入:`.agentdocs/tmp/aiws-validate/*.json`(默认被 `.gitignore` 忽略)
61
+ - `$ws-review` 会落盘:`.agentdocs/tmp/review/codex-review.md`(默认被 `.gitignore` 忽略)
62
+ - 建议把“可协作/可审计”的内容放进 `changes/<change-id>/`(proposal/tasks/design)并提交;`.agentdocs/tmp/` 作为本地缓存更稳
63
+
64
+ ## 运行要求
65
+
66
+ - Node.js:>= 20
67
+ - `aiws validate`:需要 `python3`(用于执行 `tools/ws_change_check.py` 与 `tools/requirements_contract.py`)
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@aipper/aiws",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "AI Workspace CLI (init/update/validate) for Claude Code / OpenCode / Codex / iFlow.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "aiws": "./bin/aiws.js"
8
8
  },
9
9
  "dependencies": {
10
- "@aipper/aiws-spec": "0.0.2"
10
+ "@aipper/aiws-spec": "0.0.3"
11
11
  },
12
12
  "files": [
13
13
  "bin",
package/src/cli.js CHANGED
@@ -16,6 +16,7 @@ import { hooksInstallCommand } from "./commands/hooks-install.js";
16
16
  import { hooksStatusCommand } from "./commands/hooks-status.js";
17
17
  import {
18
18
  changeArchiveCommand,
19
+ changeFinishCommand,
19
20
  changeListCommand,
20
21
  changeNewCommand,
21
22
  changeNextCommand,
@@ -224,14 +225,28 @@ export async function cliMain(argv) {
224
225
  title: { type: "string" },
225
226
  "no-design": { type: "boolean" },
226
227
  hooks: { type: "boolean" },
228
+ "no-switch": { type: "boolean" },
229
+ switch: { type: "boolean" },
230
+ worktree: { type: "boolean" },
231
+ "worktree-dir": { type: "string" },
232
+ submodules: { type: "boolean" },
227
233
  });
228
234
  const changeId = positionals[0] ?? "";
229
- if (!changeId) throw new UserError("change start requires <change-id>", { details: "Usage: aiws change start <change-id> [--title <title>] [--no-design] [--hooks]" });
235
+ if (!changeId)
236
+ throw new UserError("change start requires <change-id>", {
237
+ details:
238
+ "Usage: aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--worktree] [--worktree-dir <path>] [--submodules]",
239
+ });
230
240
  await changeStartCommand({
231
241
  changeId,
232
242
  title: options.title,
233
243
  noDesign: options["no-design"] === true,
234
244
  enableHooks: options.hooks === true,
245
+ noSwitch: options["no-switch"] === true,
246
+ forceSwitch: options.switch === true,
247
+ worktree: options.worktree === true,
248
+ worktreeDir: options["worktree-dir"],
249
+ submodules: options.submodules === true,
235
250
  });
236
251
  return 0;
237
252
  }
@@ -293,6 +308,19 @@ export async function cliMain(argv) {
293
308
  });
294
309
  return 0;
295
310
  }
311
+ case "finish": {
312
+ const { positionals, options } = parseArgs(args, {
313
+ into: { type: "string" },
314
+ base: { type: "string" },
315
+ });
316
+ const changeId = positionals[0];
317
+ await changeFinishCommand({
318
+ changeId,
319
+ into: options.into,
320
+ base: options.base,
321
+ });
322
+ return 0;
323
+ }
296
324
  case "templates": {
297
325
  const templatesSub = args.shift();
298
326
  if (!templatesSub) throw new UserError("change templates requires <which|init>", { details: "Usage: aiws change templates which|init" });
@@ -378,7 +406,8 @@ function printChangeHelp() {
378
406
 
379
407
  Usage:
380
408
  aiws change list
381
- aiws change start <change-id> [--title <title>] [--no-design] [--hooks]
409
+ aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--worktree] [--worktree-dir <path>] [--submodules]
410
+ aiws change finish [change-id] [--into <branch> | --base <branch>]
382
411
  aiws change new <change-id> [--title <title>] [--no-design]
383
412
  aiws change status [change-id]
384
413
  aiws change next [change-id]
@@ -391,7 +420,9 @@ Usage:
391
420
  Notes:
392
421
  - change-id must be kebab-case: ^[a-z0-9]+(-[a-z0-9]+)*$
393
422
  - If your git branch matches change/<change-id> (or changes/ws/ws-change prefixes),
394
- you can omit <change-id> for status/next/validate/sync/archive.
423
+ you can omit <change-id> for status/next/validate/sync/archive/finish.
424
+ - If .gitmodules exists and you didn't specify --switch/--no-switch/--worktree,
425
+ start will prefer --worktree (fallback: --no-switch) to avoid switching the superproject branch.
395
426
  - archive runs strict validation and (by default) requires all tasks checked.
396
427
  - archive --force also bypasses truth drift gating (not recommended).
397
428
  `);
@@ -87,6 +87,122 @@ async function currentBranch(gitRoot) {
87
87
  return String(res.stdout || "").trim();
88
88
  }
89
89
 
90
+ /**
91
+ * @param {string} gitRoot
92
+ */
93
+ async function hasHeadCommit(gitRoot) {
94
+ return runCommand("git", ["rev-parse", "--verify", "HEAD"], { cwd: gitRoot }).then((r) => r.code === 0);
95
+ }
96
+
97
+ /**
98
+ * @param {string} gitRoot
99
+ * @returns {Promise<{ clean: true } | { clean: false, details: string }>}
100
+ */
101
+ async function checkGitClean(gitRoot) {
102
+ const res = await runCommand("git", ["status", "--porcelain"], { cwd: gitRoot });
103
+ const out = String(res.stdout || "").trim();
104
+ if (!out) return { clean: true };
105
+ const lines = out.split("\n");
106
+ const truncated = lines.length > 20 ? `${lines.slice(0, 20).join("\n")}\n... (truncated)` : out;
107
+ return { clean: false, details: truncated };
108
+ }
109
+
110
+ /**
111
+ * @param {string} gitRoot
112
+ * @returns {Promise<{ ok: true } | { ok: false, reason: "no_commit" | "missing_truth", missing?: string[] }>}
113
+ */
114
+ async function checkWorktreePrereqs(gitRoot) {
115
+ if (!(await hasHeadCommit(gitRoot))) return { ok: false, reason: "no_commit" };
116
+ /** @type {string[]} */
117
+ const missingInHead = [];
118
+ for (const rel of ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"]) {
119
+ const ok = await runCommand("git", ["cat-file", "-e", `HEAD:${rel}`], { cwd: gitRoot }).then((r) => r.code === 0);
120
+ if (!ok) missingInHead.push(rel);
121
+ }
122
+ if (missingInHead.length > 0) return { ok: false, reason: "missing_truth", missing: missingInHead };
123
+ return { ok: true };
124
+ }
125
+
126
+ /**
127
+ * @param {string} changeDir
128
+ * @param {string} baseBranch
129
+ * @param {string} changeBranch
130
+ */
131
+ async function maybeRecordBaseBranch(changeDir, baseBranch, changeBranch) {
132
+ const b = String(baseBranch || "").trim();
133
+ if (!b) return;
134
+ if (b === String(changeBranch || "").trim()) return;
135
+ const metaPath = path.join(changeDir, ".ws-change.json");
136
+ if (!(await pathExists(metaPath))) return;
137
+ /** @type {any} */
138
+ let meta = null;
139
+ try {
140
+ meta = JSON.parse(await readText(metaPath));
141
+ } catch {
142
+ meta = null;
143
+ }
144
+ if (!meta || typeof meta !== "object") return;
145
+ const cur = String(meta.base_branch || "").trim();
146
+ if (cur) return;
147
+ meta.base_branch = b;
148
+ meta.base_branch_set_at = nowIsoUtc();
149
+ await writeText(metaPath, JSON.stringify(meta, null, 2) + "\n");
150
+ }
151
+
152
+ /**
153
+ * @param {string} gitRoot
154
+ * @returns {Promise<Array<{ worktree: string, head: string, branch: string }>>}
155
+ */
156
+ async function listGitWorktrees(gitRoot) {
157
+ const res = await runCommand("git", ["worktree", "list", "--porcelain"], { cwd: gitRoot });
158
+ if (res.code !== 0) {
159
+ throw new UserError("Failed to list git worktrees.", { details: res.stderr || res.stdout });
160
+ }
161
+ const lines = String(res.stdout || "")
162
+ .split(/\r?\n/)
163
+ .map((l) => l.trimEnd());
164
+
165
+ /** @type {Array<{ worktree: string, head: string, branch: string }>} */
166
+ const out = [];
167
+ /** @type {{ worktree: string, head: string, branch: string } | null} */
168
+ let cur = null;
169
+
170
+ for (const line of lines) {
171
+ if (!line) continue;
172
+ if (line.startsWith("worktree ")) {
173
+ if (cur) out.push(cur);
174
+ cur = { worktree: path.resolve(line.slice("worktree ".length).trim()), head: "", branch: "" };
175
+ continue;
176
+ }
177
+ if (!cur) continue;
178
+ if (line.startsWith("HEAD ")) {
179
+ cur.head = line.slice("HEAD ".length).trim();
180
+ continue;
181
+ }
182
+ if (line.startsWith("branch ")) {
183
+ cur.branch = line.slice("branch ".length).trim();
184
+ continue;
185
+ }
186
+ }
187
+ if (cur) out.push(cur);
188
+ return out;
189
+ }
190
+
191
+ /**
192
+ * @param {string} gitRoot
193
+ * @param {string | undefined} worktreeDir
194
+ * @param {string} changeId
195
+ */
196
+ function resolveDefaultWorktreeDir(gitRoot, worktreeDir, changeId) {
197
+ const parent = path.dirname(gitRoot);
198
+ const raw = String(worktreeDir || "").trim();
199
+ if (raw) {
200
+ return path.isAbsolute(raw) ? raw : path.resolve(parent, raw);
201
+ }
202
+ const repoName = path.basename(gitRoot);
203
+ return path.resolve(parent, `${repoName}-${changeId}`);
204
+ }
205
+
90
206
  /**
91
207
  * @param {string} branch
92
208
  * @returns {string}
@@ -508,12 +624,10 @@ export async function changeTemplatesInitCommand() {
508
624
  }
509
625
 
510
626
  /**
511
- * aiws change new
512
- *
513
- * @param {{ changeId: string, title?: string, noDesign: boolean }} options
627
+ * @param {string} gitRoot
628
+ * @param {{ changeId: string, title?: string, noDesign: boolean, baseBranch?: string }} options
514
629
  */
515
- export async function changeNewCommand(options) {
516
- const gitRoot = await resolveGitRoot(process.cwd());
630
+ async function changeNewAtGitRoot(gitRoot, options) {
517
631
  await ensureTruthFiles(gitRoot);
518
632
 
519
633
  const changeId = String(options.changeId || "").trim();
@@ -551,6 +665,11 @@ export async function changeNewCommand(options) {
551
665
  created_at: createdAt,
552
666
  base_truth_files: truth,
553
667
  };
668
+ const baseBranch = String(options.baseBranch || "").trim();
669
+ if (baseBranch) {
670
+ meta.base_branch = baseBranch;
671
+ meta.base_branch_set_at = createdAt;
672
+ }
554
673
  await writeText(path.join(changeDir, ".ws-change.json"), JSON.stringify(meta, null, 2) + "\n");
555
674
 
556
675
  console.log(`✓ aiws change new: ${changeId}`);
@@ -563,10 +682,20 @@ export async function changeNewCommand(options) {
563
682
  console.log(` - validate: aiws change validate ${changeId}`);
564
683
  }
565
684
 
685
+ /**
686
+ * aiws change new
687
+ *
688
+ * @param {{ changeId: string, title?: string, noDesign: boolean }} options
689
+ */
690
+ export async function changeNewCommand(options) {
691
+ const gitRoot = await resolveGitRoot(process.cwd());
692
+ await changeNewAtGitRoot(gitRoot, options);
693
+ }
694
+
566
695
  /**
567
696
  * aiws change start
568
697
  *
569
- * @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean }} options
698
+ * @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean, noSwitch: boolean, forceSwitch: boolean, worktree: boolean, worktreeDir?: string, submodules: boolean }} options
570
699
  */
571
700
  export async function changeStartCommand(options) {
572
701
  const gitRoot = await resolveGitRoot(process.cwd());
@@ -575,33 +704,220 @@ export async function changeStartCommand(options) {
575
704
  const changeId = String(options.changeId || "").trim();
576
705
  assertValidChangeId(changeId);
577
706
 
707
+ const startFromBranch = await currentBranch(gitRoot);
578
708
  const branch = `change/${changeId}`;
709
+ const branchRef = `refs/heads/${branch}`;
710
+
711
+ if (options.worktree === true && options.noSwitch === true) {
712
+ throw new UserError("change start: cannot combine --worktree with --no-switch");
713
+ }
714
+ if (options.forceSwitch === true && options.noSwitch === true) {
715
+ throw new UserError("change start: cannot combine --switch with --no-switch");
716
+ }
717
+ if (options.forceSwitch === true && options.worktree === true) {
718
+ throw new UserError("change start: cannot combine --switch with --worktree");
719
+ }
579
720
 
580
721
  const hasBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: gitRoot }).then((r) => r.code === 0);
581
- if (hasBranch) {
582
- const sw = await runCommand("git", ["switch", branch], { cwd: gitRoot });
583
- if (sw.code !== 0) {
584
- const co = await runCommand("git", ["checkout", branch], { cwd: gitRoot });
585
- if (co.code !== 0) throw new UserError("Failed to switch branch.", { details: co.stderr || co.stdout });
722
+ const hasGitmodules = await pathExists(path.join(gitRoot, ".gitmodules"));
723
+ let effectiveNoSwitch = options.noSwitch === true;
724
+ const effectiveForceSwitch = options.forceSwitch === true;
725
+ let effectiveWorktree = options.worktree === true;
726
+
727
+ if (hasGitmodules && !effectiveNoSwitch && !effectiveWorktree && !effectiveForceSwitch) {
728
+ const prereq = await checkWorktreePrereqs(gitRoot);
729
+ if (prereq.ok) {
730
+ effectiveWorktree = true;
731
+ console.error("warn: .gitmodules detected (git submodules). defaulting to --worktree to avoid switching the superproject branch.");
732
+ console.error("warn: to avoid worktree and just prepare artifacts: aiws change start <change-id> --no-switch");
733
+ console.error("warn: to switch anyway: aiws change start <change-id> --switch");
734
+ } else {
735
+ effectiveNoSwitch = true;
736
+ if (prereq.reason === "no_commit") {
737
+ console.error("warn: .gitmodules detected (git submodules). cannot use --worktree on a repo with no commits; defaulting to --no-switch.");
738
+ } else {
739
+ console.error("warn: .gitmodules detected (git submodules). cannot use --worktree without truth files committed in HEAD; defaulting to --no-switch.");
740
+ }
741
+ console.error("warn: to switch anyway: aiws change start <change-id> --switch");
742
+ console.error("warn: recommended for submodules: aiws change start <change-id> --worktree (after committing truth files)");
743
+ }
744
+ } else if (hasGitmodules && effectiveForceSwitch) {
745
+ console.error("warn: .gitmodules detected (git submodules). switching the superproject branch due to --switch.");
746
+ console.error("warn: recommended for submodules: aiws change start <change-id> --worktree");
747
+ }
748
+
749
+ if (effectiveWorktree) {
750
+ const prereq = await checkWorktreePrereqs(gitRoot);
751
+ if (!prereq.ok) {
752
+ if (prereq.reason === "no_commit") {
753
+ throw new UserError("change start --worktree requires a repo with at least one commit.", { details: "Hint: make an initial commit, then retry." });
754
+ }
755
+ const missingInHead = Array.isArray(prereq.missing) ? prereq.missing : [];
756
+ throw new UserError("change start --worktree requires truth files committed in HEAD.", {
757
+ details:
758
+ `Missing in HEAD:\n${missingInHead.map((f) => `- ${f}`).join("\n")}\n\n` +
759
+ "Why: git worktree checks out from HEAD, so uncommitted files won't be present in the new worktree.\n" +
760
+ "Hint: commit the truth files first, or use `aiws change start --no-switch`.",
761
+ });
762
+ }
763
+ const worktreeDir = resolveDefaultWorktreeDir(gitRoot, options.worktreeDir, changeId);
764
+
765
+ const rel = path.relative(gitRoot, worktreeDir);
766
+ if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
767
+ throw new UserError("worktree dir must be outside the git root.", {
768
+ details: `git_root: ${gitRoot}\nworktree_dir: ${worktreeDir}\nHint: use a sibling dir like ../<repo>-<change-id> (default) or pass --worktree-dir <path>.`,
769
+ });
770
+ }
771
+
772
+ const worktrees = await listGitWorktrees(gitRoot);
773
+ /** @type {Map<string, { worktree: string, head: string, branch: string }>} */
774
+ const byPath = new Map();
775
+ /** @type {Map<string, string>} */
776
+ const byBranch = new Map();
777
+ for (const w of worktrees) {
778
+ const k = path.resolve(w.worktree);
779
+ byPath.set(k, w);
780
+ if (w.branch) byBranch.set(w.branch, k);
781
+ }
782
+
783
+ const destKey = path.resolve(worktreeDir);
784
+ const destExisting = byPath.get(destKey);
785
+
786
+ if (!destExisting) {
787
+ if (await pathExists(destKey)) {
788
+ throw new UserError("worktree dir already exists and is not a registered worktree; refusing to overwrite.", {
789
+ details: `worktree_dir: ${destKey}`,
790
+ });
791
+ }
792
+ const used = byBranch.get(branchRef);
793
+ if (used) {
794
+ throw new UserError("change branch is already checked out in another worktree.", {
795
+ details: `branch: ${branch}\nworktree: ${used}\nHint: use that worktree, or pick another change-id.`,
796
+ });
797
+ }
798
+
799
+ const args = hasBranch ? ["worktree", "add", destKey, branch] : ["worktree", "add", "-b", branch, destKey];
800
+ const add = await runCommand("git", args, { cwd: gitRoot });
801
+ if (add.code !== 0) {
802
+ throw new UserError("Failed to create git worktree.", { details: add.stderr || add.stdout });
803
+ }
804
+ } else if (destExisting.branch && destExisting.branch !== branchRef) {
805
+ throw new UserError("worktree dir already exists but is on a different branch.", {
806
+ details: `worktree_dir: ${destKey}\nexpected_branch: ${branchRef}\nactual_branch: ${destExisting.branch}`,
807
+ });
808
+ } else if (!destExisting.branch) {
809
+ throw new UserError("worktree dir already exists but is detached; refusing to proceed.", {
810
+ details: `worktree_dir: ${destKey}\nHint: inspect with: git -C ${destKey} status`,
811
+ });
812
+ }
813
+
814
+ const changeDir = changeDirAbs(destKey, changeId);
815
+ if (!(await pathExists(changeDir))) {
816
+ await changeNewAtGitRoot(destKey, {
817
+ changeId,
818
+ title: options.title,
819
+ noDesign: options.noDesign,
820
+ baseBranch: startFromBranch && startFromBranch !== branch ? startFromBranch : undefined,
821
+ });
822
+ } else {
823
+ await maybeRecordBaseBranch(changeDir, startFromBranch, branch);
824
+ }
825
+
826
+ if (options.submodules === true) {
827
+ const sm = await runCommand("git", ["submodule", "update", "--init", "--recursive"], { cwd: destKey });
828
+ if (sm.code !== 0) throw new UserError("Failed to init/update submodules in worktree.", { details: sm.stderr || sm.stdout });
829
+ } else if (hasGitmodules) {
830
+ console.error("warn: .gitmodules detected; new worktree may have empty submodule dirs.");
831
+ console.error("warn: consider running in the worktree: git submodule update --init --recursive (or re-run with --submodules)");
832
+ }
833
+
834
+ if (options.enableHooks) {
835
+ await hooksInstallCommand({ targetPath: gitRoot });
836
+ }
837
+
838
+ console.log(`ok: change worktree ready: ${changeId}`);
839
+ console.log(`worktree: ${destKey}`);
840
+ console.log(`branch: ${branch}`);
841
+ console.log("next:");
842
+ console.log(` - cd: cd ${destKey}`);
843
+ console.log(" - status: aiws change status");
844
+ console.log(" - next: aiws change next");
845
+ console.log(" - validate: aiws change validate --strict");
846
+ if (!options.enableHooks) console.log(" - (optional) enable hooks: aiws hooks install .");
847
+ console.log("finish (safe merge):");
848
+ console.log(` - from main worktree: git merge --ff-only ${branch}`);
849
+ return;
850
+ }
851
+
852
+ const headReady = await hasHeadCommit(gitRoot);
853
+ let branchReady = hasBranch;
854
+
855
+ if (effectiveNoSwitch) {
856
+ if (!hasBranch) {
857
+ if (!headReady) {
858
+ // On an unborn branch, `git branch <name>` fails because HEAD doesn't point to a commit yet.
859
+ // We still allow initializing change artifacts without switching branches.
860
+ console.error(`warn: repo has no commits; cannot create branch "${branch}" without switching (skipped)`);
861
+ branchReady = false;
862
+ } else {
863
+ const br = await runCommand("git", ["branch", branch], { cwd: gitRoot });
864
+ if (br.code !== 0) throw new UserError("Failed to create branch.", { details: br.stderr || br.stdout });
865
+ branchReady = true;
866
+ }
586
867
  }
587
868
  } else {
588
- const sw = await runCommand("git", ["switch", "-c", branch], { cwd: gitRoot });
589
- if (sw.code !== 0) {
590
- const co = await runCommand("git", ["checkout", "-b", branch], { cwd: gitRoot });
591
- if (co.code !== 0) throw new UserError("Failed to create branch.", { details: co.stderr || co.stdout });
869
+ if (hasBranch) {
870
+ const sw = await runCommand("git", ["switch", branch], { cwd: gitRoot });
871
+ if (sw.code !== 0) {
872
+ const co = await runCommand("git", ["checkout", branch], { cwd: gitRoot });
873
+ if (co.code !== 0) throw new UserError("Failed to switch branch.", { details: co.stderr || co.stdout });
874
+ }
875
+ } else {
876
+ const sw = await runCommand("git", ["switch", "-c", branch], { cwd: gitRoot });
877
+ if (sw.code !== 0) {
878
+ const co = await runCommand("git", ["checkout", "-b", branch], { cwd: gitRoot });
879
+ if (co.code !== 0) throw new UserError("Failed to create branch.", { details: co.stderr || co.stdout });
880
+ }
592
881
  }
882
+ branchReady = true;
593
883
  }
594
884
 
595
885
  const changeDir = changeDirAbs(gitRoot, changeId);
596
886
  if (!(await pathExists(changeDir))) {
597
- await changeNewCommand({ changeId, title: options.title, noDesign: options.noDesign });
887
+ await changeNewAtGitRoot(gitRoot, {
888
+ changeId,
889
+ title: options.title,
890
+ noDesign: options.noDesign,
891
+ baseBranch: startFromBranch && startFromBranch !== branch ? startFromBranch : undefined,
892
+ });
893
+ } else {
894
+ await maybeRecordBaseBranch(changeDir, startFromBranch, branch);
598
895
  }
599
896
 
600
897
  if (options.enableHooks) {
601
898
  await hooksInstallCommand({ targetPath: gitRoot });
602
899
  }
603
900
 
604
- console.log(`ok: active change: ${changeId} (branch: ${branch})`);
901
+ const cur = await currentBranch(gitRoot);
902
+ const curLabel = cur || "(detached)";
903
+ if (effectiveNoSwitch && cur !== branch) {
904
+ console.log(`ok: prepared change: ${changeId} (branch: ${branch}${branchReady ? "" : " (pending)"}; current: ${curLabel})`);
905
+ console.log("next:");
906
+ if (branchReady) {
907
+ console.log(` - switch: git switch ${branch}`);
908
+ } else {
909
+ console.log(" - note: repo has no commits; create the branch after your first commit");
910
+ console.log(` - create: git branch ${branch}`);
911
+ console.log(` - switch: git switch ${branch}`);
912
+ }
913
+ console.log(` - status: aiws change status ${changeId}`);
914
+ console.log(` - next: aiws change next ${changeId}`);
915
+ console.log(` - validate: aiws change validate ${changeId} --strict`);
916
+ if (!options.enableHooks) console.log(" - (optional) enable hooks: aiws hooks install .");
917
+ return;
918
+ }
919
+
920
+ console.log(`ok: active change: ${changeId} (branch: ${branch}; current: ${curLabel})`);
605
921
  console.log("next:");
606
922
  console.log(" - status: aiws change status");
607
923
  console.log(" - next: aiws change next");
@@ -609,6 +925,130 @@ export async function changeStartCommand(options) {
609
925
  if (!options.enableHooks) console.log(" - (optional) enable hooks: aiws hooks install .");
610
926
  }
611
927
 
928
+ /**
929
+ * aiws change finish
930
+ *
931
+ * @param {{ changeId?: string, into?: string, base?: string }} options
932
+ */
933
+ export async function changeFinishCommand(options) {
934
+ const gitRoot = await resolveGitRoot(process.cwd());
935
+ await ensureTruthFiles(gitRoot);
936
+
937
+ const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "finish" });
938
+ assertValidChangeId(changeId);
939
+
940
+ const changeBranch = `change/${changeId}`;
941
+ const changeBranchRef = `refs/heads/${changeBranch}`;
942
+
943
+ const hasChangeBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", changeBranchRef], { cwd: gitRoot }).then((r) => r.code === 0);
944
+ if (!hasChangeBranch) {
945
+ throw new UserError(`Missing change branch: ${changeBranch}`, {
946
+ details: "Hint: create it with `aiws change start <change-id>` (or `git switch -c change/<id>`).",
947
+ });
948
+ }
949
+
950
+ const clean = await checkGitClean(gitRoot);
951
+ if (!clean.clean) {
952
+ throw new UserError("Refusing to finish with a dirty working tree.", {
953
+ details: `${clean.details}\n\nHint: commit or stash your changes, then retry.`,
954
+ });
955
+ }
956
+
957
+ const cur = await currentBranch(gitRoot);
958
+ const intoRaw = String(options.into || "").trim();
959
+ const baseRaw = String(options.base || "").trim();
960
+ if (intoRaw && baseRaw && intoRaw !== baseRaw) {
961
+ throw new UserError("change finish: cannot combine --into with --base (values differ).", { details: `--into=${intoRaw}\n--base=${baseRaw}` });
962
+ }
963
+
964
+ /** @type {string} */
965
+ let into = intoRaw || baseRaw;
966
+
967
+ if (!into) {
968
+ if (!cur) {
969
+ throw new UserError("Detached HEAD: cannot infer target branch for finish.", {
970
+ details: "Hint: switch to the target branch (e.g. main) and retry, or pass `aiws change finish <id> --into <branch>`.",
971
+ });
972
+ }
973
+ if (cur !== changeBranch) {
974
+ into = cur;
975
+ } else {
976
+ const metaPath = path.join(gitRoot, "changes", changeId, ".ws-change.json");
977
+ /** @type {any} */
978
+ let meta = null;
979
+ if (await pathExists(metaPath)) {
980
+ try {
981
+ meta = JSON.parse(await readText(metaPath));
982
+ } catch {
983
+ meta = null;
984
+ }
985
+ }
986
+ if (!meta) {
987
+ const show = await runCommand("git", ["show", `${changeBranch}:changes/${changeId}/.ws-change.json`], { cwd: gitRoot });
988
+ if (show.code === 0) {
989
+ try {
990
+ meta = JSON.parse(String(show.stdout || ""));
991
+ } catch {
992
+ meta = null;
993
+ }
994
+ }
995
+ }
996
+ const inferred = meta && typeof meta === "object" ? String(meta.base_branch || "").trim() : "";
997
+ if (!inferred) {
998
+ throw new UserError("Cannot infer base branch for finish.", {
999
+ details: "Hint: run from the target branch (e.g. main), or pass: `aiws change finish <id> --into <branch>`.",
1000
+ });
1001
+ }
1002
+ into = inferred;
1003
+ }
1004
+ }
1005
+
1006
+ if (into === changeBranch) {
1007
+ throw new UserError("change finish: target branch cannot be the change branch.", { details: `target=${into}` });
1008
+ }
1009
+
1010
+ const worktrees = await listGitWorktrees(gitRoot);
1011
+ const intoRef = `refs/heads/${into}`;
1012
+ const intoWt = worktrees.find((w) => String(w.branch || "") === intoRef);
1013
+ if (intoWt && path.resolve(intoWt.worktree) !== path.resolve(gitRoot)) {
1014
+ throw new UserError("Target branch is checked out in another worktree.", {
1015
+ details: `branch: ${into}\nworktree: ${intoWt.worktree}\n\nHint: run finish in that worktree:\n cd ${intoWt.worktree}\n aiws change finish ${changeId}`,
1016
+ });
1017
+ }
1018
+
1019
+ const hasIntoBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", intoRef], { cwd: gitRoot }).then((r) => r.code === 0);
1020
+ if (!hasIntoBranch) {
1021
+ throw new UserError("Target branch does not exist.", { details: `branch: ${into}` });
1022
+ }
1023
+
1024
+ if (cur && cur !== into) {
1025
+ const sw = await runCommand("git", ["switch", into], { cwd: gitRoot });
1026
+ if (sw.code !== 0) {
1027
+ const co = await runCommand("git", ["checkout", into], { cwd: gitRoot });
1028
+ if (co.code !== 0) throw new UserError("Failed to switch target branch.", { details: co.stderr || co.stdout });
1029
+ }
1030
+ }
1031
+
1032
+ const merge = await runCommand("git", ["merge", "--ff-only", changeBranch], { cwd: gitRoot });
1033
+ if (merge.code !== 0) {
1034
+ const changeRef = changeBranchRef;
1035
+ const changeWt = worktrees.find((w) => String(w.branch || "") === changeRef);
1036
+ const extra =
1037
+ changeWt && changeWt.worktree
1038
+ ? `\n\nIf not fast-forward, rebase the change branch then retry:\n cd ${changeWt.worktree}\n git rebase ${into}\n cd ${gitRoot}\n aiws change finish ${changeId}`
1039
+ : `\n\nIf not fast-forward, rebase the change branch then retry:\n git switch ${changeBranch}\n git rebase ${into}\n git switch ${into}\n aiws change finish ${changeId}`;
1040
+ throw new UserError("Failed to fast-forward merge change branch into target.", { details: `${merge.stderr || merge.stdout}${extra}` });
1041
+ }
1042
+
1043
+ console.log(`ok: finished change: ${changeId}`);
1044
+ console.log(`into: ${into}`);
1045
+ console.log(`from: ${changeBranch}`);
1046
+ if (await pathExists(path.join(gitRoot, ".gitmodules"))) {
1047
+ console.log("next:");
1048
+ console.log(" - (optional) update submodules: git submodule update --init --recursive");
1049
+ }
1050
+ }
1051
+
612
1052
  /**
613
1053
  * aiws change status
614
1054
  *