@aipper/aiws 0.0.21 → 0.0.24

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
@@ -47,7 +47,7 @@ aiws codex <install-skills|status-skills|uninstall-skills|install-prompts|status
47
47
 
48
48
  ## Codex(repo skills 优先)
49
49
 
50
- - `aiws init .` 会生成 `.agents/skills/`(随仓库共享),在 Codex 中可显式调用(示例):`$ws-preflight` / `$ws-plan` / `$ws-dev` / `$ws-review` / `$ws-commit`
50
+ - `aiws init .` 会生成 `.agents/skills/`(随仓库共享),在 Codex 中可显式调用(示例):`$ws-preflight` / `$ws-plan` / `$p-tasks-plan` / `$ws-handoff` / `$ws-dev` / `$ws-review` / `$ws-commit`
51
51
  - 交付收尾(submodules+superproject 分步提交 + 安全合并):`$ws-deliver`
52
52
  - 收尾(安全合并回目标分支):`$ws-finish`(底层调用 `aiws change finish`,默认 fast-forward)
53
53
  - 可选:安装全局 skills 到 `~/.codex/skills/`(或 `$CODEX_HOME/skills`):
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@aipper/aiws",
3
- "version": "0.0.21",
3
+ "version": "0.0.24",
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.21"
10
+ "@aipper/aiws-spec": "0.0.24"
11
11
  },
12
12
  "files": [
13
13
  "bin",
package/src/cli.js CHANGED
@@ -260,6 +260,7 @@ export async function cliMain(argv) {
260
260
  hooks: { type: "boolean" },
261
261
  "no-switch": { type: "boolean" },
262
262
  switch: { type: "boolean" },
263
+ "allow-dirty": { type: "boolean" },
263
264
  worktree: { type: "boolean" },
264
265
  "worktree-dir": { type: "string" },
265
266
  submodules: { type: "boolean" },
@@ -268,7 +269,7 @@ export async function cliMain(argv) {
268
269
  if (!changeId)
269
270
  throw new UserError("change start requires <change-id>", {
270
271
  details:
271
- "Usage: aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--worktree] [--worktree-dir <path>] [--submodules]",
272
+ "Usage: aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--allow-dirty] [--worktree] [--worktree-dir <path>] [--submodules]",
272
273
  });
273
274
  await changeStartCommand({
274
275
  changeId,
@@ -277,6 +278,7 @@ export async function cliMain(argv) {
277
278
  enableHooks: options.hooks === true,
278
279
  noSwitch: options["no-switch"] === true,
279
280
  forceSwitch: options.switch === true,
281
+ allowDirty: options["allow-dirty"] === true,
280
282
  worktree: options.worktree === true,
281
283
  worktreeDir: options["worktree-dir"],
282
284
  submodules: options.submodules === true,
@@ -368,12 +370,16 @@ export async function cliMain(argv) {
368
370
  const { positionals, options } = parseArgs(args, {
369
371
  into: { type: "string" },
370
372
  base: { type: "string" },
373
+ push: { type: "boolean" },
374
+ remote: { type: "string" },
371
375
  });
372
376
  const changeId = positionals[0];
373
377
  await changeFinishCommand({
374
378
  changeId,
375
379
  into: options.into,
376
380
  base: options.base,
381
+ push: options.push === true,
382
+ remote: options.remote,
377
383
  });
378
384
  return 0;
379
385
  }
@@ -490,8 +496,8 @@ function printChangeHelp() {
490
496
 
491
497
  Usage:
492
498
  aiws change list
493
- aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--worktree] [--worktree-dir <path>] [--submodules]
494
- aiws change finish [change-id] [--into <branch> | --base <branch>]
499
+ aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--allow-dirty] [--worktree] [--worktree-dir <path>]
500
+ aiws change finish [change-id] [--into <branch> | --base <branch>] [--push] [--remote <name>]
495
501
  aiws change new <change-id> [--title <title>] [--no-design]
496
502
  aiws change status [change-id]
497
503
  aiws change next [change-id]
@@ -510,6 +516,8 @@ Notes:
510
516
  - Execution quality gate first: aiws change validate [change-id] --strict
511
517
  (equivalent to running $ws-plan-verify in AI tools).
512
518
  - status/next now prioritize quality-gate guidance before coding ($ws-dev).
519
+ - start refuses to switch/create worktree with a dirty working tree (unless --allow-dirty).
520
+ - finish --push is submodule-aware: when .gitmodules exists it pushes submodules first, then the target branch, then safely cleans the separate change worktree if possible.
513
521
  - If .gitmodules exists and you didn't specify --switch/--no-switch/--worktree,
514
522
  start will prefer --worktree (fallback: --no-switch) to avoid switching the superproject branch.
515
523
  - archive runs strict validation and (by default) requires all tasks checked.
@@ -165,7 +165,7 @@ async function maybeRecordBaseBranch(changeDir, baseBranch, changeBranch) {
165
165
 
166
166
  /**
167
167
  * @param {string} gitRoot
168
- * @returns {Promise<Array<{ worktree: string, head: string, branch: string }>>}
168
+ * @returns {Promise<Array<{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string }>>}
169
169
  */
170
170
  async function listGitWorktrees(gitRoot) {
171
171
  const res = await runCommand("git", ["worktree", "list", "--porcelain"], { cwd: gitRoot });
@@ -176,16 +176,16 @@ async function listGitWorktrees(gitRoot) {
176
176
  .split(/\r?\n/)
177
177
  .map((l) => l.trimEnd());
178
178
 
179
- /** @type {Array<{ worktree: string, head: string, branch: string }>} */
179
+ /** @type {Array<{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string }>} */
180
180
  const out = [];
181
- /** @type {{ worktree: string, head: string, branch: string } | null} */
181
+ /** @type {{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string } | null} */
182
182
  let cur = null;
183
183
 
184
184
  for (const line of lines) {
185
185
  if (!line) continue;
186
186
  if (line.startsWith("worktree ")) {
187
187
  if (cur) out.push(cur);
188
- cur = { worktree: path.resolve(line.slice("worktree ".length).trim()), head: "", branch: "" };
188
+ cur = { worktree: path.resolve(line.slice("worktree ".length).trim()), head: "", branch: "", locked: false, lockReason: "" };
189
189
  continue;
190
190
  }
191
191
  if (!cur) continue;
@@ -197,11 +197,451 @@ async function listGitWorktrees(gitRoot) {
197
197
  cur.branch = line.slice("branch ".length).trim();
198
198
  continue;
199
199
  }
200
+ if (line === "locked" || line.startsWith("locked ")) {
201
+ cur.locked = true;
202
+ cur.lockReason = line === "locked" ? "" : line.slice("locked ".length).trim();
203
+ continue;
204
+ }
200
205
  }
201
206
  if (cur) out.push(cur);
202
207
  return out;
203
208
  }
204
209
 
210
+ /**
211
+ * @param {string} gitRoot
212
+ * @param {string} branch
213
+ * @param {string | undefined} preferredRemote
214
+ */
215
+ async function resolvePushTarget(gitRoot, branch, preferredRemote) {
216
+ const remoteRaw = String(preferredRemote || "").trim();
217
+ const branchRemoteRes = await runCommand("git", ["config", "--get", `branch.${branch}.remote`], { cwd: gitRoot });
218
+ const trackedRemote = String(branchRemoteRes.stdout || "").trim();
219
+
220
+ const branchMergeRes = await runCommand("git", ["config", "--get", `branch.${branch}.merge`], { cwd: gitRoot });
221
+ const trackedMergeRef = String(branchMergeRes.stdout || "").trim();
222
+ const trackedBranch = trackedMergeRef.startsWith("refs/heads/") ? trackedMergeRef.slice("refs/heads/".length) : "";
223
+
224
+ const remote = remoteRaw || trackedRemote || "origin";
225
+ const remoteBranch = trackedBranch || branch;
226
+ const refspec = remoteBranch === branch ? branch : `${branch}:${remoteBranch}`;
227
+
228
+ return { remote, remoteBranch, refspec, trackedRemote, trackedMergeRef };
229
+ }
230
+
231
+ /**
232
+ * @param {string} gitRoot
233
+ * @param {string} worktreePath
234
+ */
235
+ async function cleanupWorktreePath(gitRoot, worktreePath) {
236
+ const abs = path.resolve(worktreePath);
237
+ if (abs === path.resolve(gitRoot)) {
238
+ return { status: "skipped", reason: "current_worktree", worktree: abs };
239
+ }
240
+
241
+ const worktrees = await listGitWorktrees(gitRoot);
242
+ const worktreeEntry = worktrees.find((w) => path.resolve(w.worktree) === abs);
243
+ if (worktreeEntry?.locked) {
244
+ return { status: "skipped", reason: "locked", worktree: abs, details: worktreeEntry.lockReason };
245
+ }
246
+
247
+ if (!(await pathExists(abs))) {
248
+ const prune = await runCommand("git", ["worktree", "prune"], { cwd: gitRoot });
249
+ if (prune.code !== 0) {
250
+ throw new UserError("Failed to prune stale git worktree metadata.", { details: prune.stderr || prune.stdout });
251
+ }
252
+ return { status: "pruned-missing", worktree: abs };
253
+ }
254
+
255
+ const clean = await checkGitClean(abs);
256
+ if (!clean.clean) {
257
+ return { status: "skipped", reason: "dirty", worktree: abs, details: clean.details };
258
+ }
259
+
260
+ let rm = await runCommand("git", ["worktree", "remove", abs], { cwd: gitRoot });
261
+ if (rm.code !== 0) {
262
+ const out = `${rm.stderr || ""}\n${rm.stdout || ""}`;
263
+ if (out.includes("working trees containing submodules cannot be moved or removed")) {
264
+ rm = await runCommand("git", ["worktree", "remove", "--force", abs], { cwd: gitRoot });
265
+ }
266
+ }
267
+ if (rm.code !== 0) {
268
+ throw new UserError("Failed to remove change worktree.", { details: rm.stderr || rm.stdout });
269
+ }
270
+
271
+ const prune = await runCommand("git", ["worktree", "prune"], { cwd: gitRoot });
272
+ if (prune.code !== 0) {
273
+ throw new UserError("Failed to prune git worktree metadata after remove.", { details: prune.stderr || prune.stdout });
274
+ }
275
+
276
+ return { status: "removed", worktree: abs };
277
+ }
278
+
279
+ /**
280
+ * @param {string} gitRoot
281
+ * @returns {Promise<Array<{ name: string, path: string }>>}
282
+ */
283
+ async function listSubmodulesFromGitmodules(gitRoot) {
284
+ const gitmodules = path.join(gitRoot, ".gitmodules");
285
+ if (!(await pathExists(gitmodules))) return [];
286
+
287
+ const list = await runCommand("git", ["config", "--file", ".gitmodules", "--get-regexp", "^submodule\\..*\\.path$"], { cwd: gitRoot });
288
+ if (list.code !== 0) return [];
289
+
290
+ /** @type {Array<{ name: string, path: string }>} */
291
+ const subs = [];
292
+ for (const raw of String(list.stdout || "").split("\n")) {
293
+ const line = raw.trim();
294
+ if (!line) continue;
295
+ const idx = line.indexOf(" ");
296
+ if (idx <= 0) continue;
297
+ const key = line.slice(0, idx).trim();
298
+ const subPath = line.slice(idx + 1).trim();
299
+ const m = key.match(/^submodule\.(.+)\.path$/);
300
+ if (!m) continue;
301
+ const name = String(m[1] || "").trim();
302
+ if (!name || !subPath) continue;
303
+ subs.push({ name, path: subPath });
304
+ }
305
+ return subs;
306
+ }
307
+
308
+ /**
309
+ * @param {string} targetsPath
310
+ */
311
+ async function parseSubmoduleTargetsFile(targetsPath) {
312
+ /** @type {Map<string, { branch: string, remote: string }>} */
313
+ const parsed = new Map();
314
+ /** @type {string[]} */
315
+ const errors = [];
316
+
317
+ if (!(await pathExists(targetsPath))) {
318
+ return { parsed, errors };
319
+ }
320
+
321
+ const text = await readText(targetsPath);
322
+ const lines = String(text || "").split(/\r?\n/);
323
+ for (let i = 0; i < lines.length; i += 1) {
324
+ const raw = lines[i] || "";
325
+ const line = raw.trim();
326
+ if (!line || line.startsWith("#")) continue;
327
+ const parts = line.split(/\s+/).filter(Boolean);
328
+ if (parts.length < 2) {
329
+ errors.push(`${path.basename(targetsPath)}:${i + 1} invalid line (expected: <path> <target_branch> [remote])`);
330
+ continue;
331
+ }
332
+ const subPath = String(parts[0] || "").trim();
333
+ const targetBranch = String(parts[1] || "").trim();
334
+ const remote = String(parts[2] || "origin").trim() || "origin";
335
+ if (!subPath) {
336
+ errors.push(`${path.basename(targetsPath)}:${i + 1} missing submodule path`);
337
+ continue;
338
+ }
339
+ if (parsed.has(subPath)) {
340
+ errors.push(`${path.basename(targetsPath)}:${i + 1} duplicate submodule path: ${subPath}`);
341
+ continue;
342
+ }
343
+ if (!targetBranch || ["<fill-me>", "<fill_me>", "tbd", "TBD"].includes(targetBranch)) {
344
+ errors.push(`${path.basename(targetsPath)}:${i + 1} missing/placeholder target_branch for ${subPath}`);
345
+ continue;
346
+ }
347
+ parsed.set(subPath, { branch: targetBranch, remote });
348
+ }
349
+
350
+ return { parsed, errors };
351
+ }
352
+
353
+ /**
354
+ * @param {string} repoPath
355
+ * @param {string} rev
356
+ */
357
+ async function gitHasRevision(repoPath, rev) {
358
+ if (!(await pathExists(repoPath))) return false;
359
+ const res = await runCommand("git", ["-C", repoPath, "cat-file", "-e", `${rev}^{commit}`], { cwd: repoPath });
360
+ return res.code === 0;
361
+ }
362
+
363
+ /**
364
+ * @param {string} repoPath
365
+ * @returns {Promise<boolean>}
366
+ */
367
+ async function isGitRepository(repoPath) {
368
+ if (!(await pathExists(repoPath))) return false;
369
+ const res = await runCommand("git", ["-C", repoPath, "rev-parse", "--git-dir"], { cwd: repoPath });
370
+ return res.code === 0;
371
+ }
372
+
373
+ /**
374
+ * @param {string} targetRepoPath
375
+ * @param {string} sha
376
+ * @param {string | undefined} fallbackRepoPath
377
+ */
378
+ async function ensureRevisionAvailable(targetRepoPath, sha, fallbackRepoPath) {
379
+ if (await gitHasRevision(targetRepoPath, sha)) return;
380
+ const fallback = String(fallbackRepoPath || "").trim();
381
+ if (!fallback) {
382
+ throw new UserError("Required submodule commit is not available locally.", {
383
+ details: `repo: ${targetRepoPath}\ncommit: ${sha}`,
384
+ });
385
+ }
386
+ if (!(await gitHasRevision(fallback, sha))) {
387
+ throw new UserError("Required submodule commit is missing in both target and change worktrees.", {
388
+ details: `target_repo: ${targetRepoPath}\nchange_repo: ${fallback}\ncommit: ${sha}`,
389
+ });
390
+ }
391
+ const fetch = await runCommand("git", ["-C", targetRepoPath, "fetch", fallback, sha], { cwd: targetRepoPath });
392
+ if (fetch.code !== 0 || !(await gitHasRevision(targetRepoPath, sha))) {
393
+ throw new UserError("Failed to import submodule commit from change worktree.", {
394
+ details: `target_repo: ${targetRepoPath}\nchange_repo: ${fallback}\ncommit: ${sha}\n\n${fetch.stderr || fetch.stdout}`,
395
+ });
396
+ }
397
+ }
398
+
399
+ /**
400
+ * @param {string} repoPath
401
+ * @param {string} remote
402
+ * @param {string} branch
403
+ * @param {string} sha
404
+ */
405
+ async function pushSubmoduleBranch(repoPath, remote, branch, sha) {
406
+ const fetch = await runCommand("git", ["-C", repoPath, "fetch", remote, "--prune"], { cwd: repoPath });
407
+ if (fetch.code !== 0) {
408
+ throw new UserError("Failed to fetch submodule remote before push.", {
409
+ details: `repo: ${repoPath}\nremote: ${remote}\n\n${fetch.stderr || fetch.stdout}`,
410
+ });
411
+ }
412
+
413
+ const remoteRef = `refs/remotes/${remote}/${branch}`;
414
+ const hasRemoteBranch = await runCommand("git", ["-C", repoPath, "show-ref", "--verify", "--quiet", remoteRef], { cwd: repoPath }).then((r) => r.code === 0);
415
+ if (!hasRemoteBranch) {
416
+ throw new UserError("Submodule target branch does not exist on remote.", {
417
+ details: `repo: ${repoPath}\nremote: ${remote}\nbranch: ${branch}`,
418
+ });
419
+ }
420
+
421
+ const remoteAncestor = await runCommand("git", ["-C", repoPath, "merge-base", "--is-ancestor", remoteRef, sha], { cwd: repoPath }).then((r) => r.code === 0);
422
+ if (remoteAncestor) {
423
+ const push = await runCommand("git", ["-C", repoPath, "push", remote, `${sha}:refs/heads/${branch}`], { cwd: repoPath });
424
+ if (push.code !== 0) {
425
+ throw new UserError("Failed to fast-forward push submodule target branch.", {
426
+ details: `repo: ${repoPath}\nremote: ${remote}\nbranch: ${branch}\ncommit: ${sha}\n\n${push.stderr || push.stdout}`,
427
+ });
428
+ }
429
+ return { status: "pushed", remoteRef };
430
+ }
431
+
432
+ const shaContained = await runCommand("git", ["-C", repoPath, "merge-base", "--is-ancestor", sha, remoteRef], { cwd: repoPath }).then((r) => r.code === 0);
433
+ if (shaContained) {
434
+ return { status: "already-contained", remoteRef };
435
+ }
436
+
437
+ throw new UserError("Submodule target branch diverged from gitlink commit.", {
438
+ details: `repo: ${repoPath}\nremote: ${remote}\nbranch: ${branch}\ncommit: ${sha}\n\nHint: reconcile the submodule branch manually, then retry.`,
439
+ });
440
+ }
441
+
442
+ /**
443
+ * @param {string} gitRoot
444
+ * @param {string} changeId
445
+ * @param {string} baseBranch
446
+ * @param {string | undefined} changeWorktreePath
447
+ */
448
+ async function pushSubmodulesForFinish(gitRoot, changeId, baseBranch, changeWorktreePath) {
449
+ const subs = await listSubmodulesFromGitmodules(gitRoot);
450
+ if (subs.length === 0) return { processed: 0 };
451
+
452
+ const targetsPath = path.join(gitRoot, "changes", changeId, "submodules.targets");
453
+ if (!(await pathExists(targetsPath))) {
454
+ throw new UserError("Missing submodules.targets for submodule-aware finish.", {
455
+ details: `required: ${path.relative(gitRoot, targetsPath)}\n\nHint: add one entry per submodule path before running \`aiws change finish --push\`.`,
456
+ });
457
+ }
458
+
459
+ const parsedTargets = await parseSubmoduleTargetsFile(targetsPath);
460
+ if (parsedTargets.errors.length > 0) {
461
+ throw new UserError("Invalid submodules.targets.", { details: parsedTargets.errors.join("\n") });
462
+ }
463
+
464
+ const missingPaths = subs.filter((s) => !parsedTargets.parsed.has(s.path)).map((s) => s.path);
465
+ if (missingPaths.length > 0) {
466
+ throw new UserError("submodules.targets missing declared submodule paths.", {
467
+ details: `file: ${path.relative(gitRoot, targetsPath)}\nmissing: ${missingPaths.join(", ")}`,
468
+ });
469
+ }
470
+
471
+ /** @type {Array<{ name: string, path: string, sha: string, branch: string, remote: string, pinBranch: string }>} */
472
+ const specs = [];
473
+ for (const sub of subs) {
474
+ const target = parsedTargets.parsed.get(sub.path);
475
+ if (!target) continue;
476
+ const targetBranch = target.branch === "." ? baseBranch : target.branch;
477
+ const gitlink = await runCommand("git", ["rev-parse", `HEAD:${sub.path}`], { cwd: gitRoot });
478
+ if (gitlink.code !== 0) {
479
+ throw new UserError("Failed to resolve submodule gitlink commit from merged superproject.", {
480
+ details: `path: ${sub.path}\n\n${gitlink.stderr || gitlink.stdout}`,
481
+ });
482
+ }
483
+ const sha = String(gitlink.stdout || "").trim();
484
+ specs.push({
485
+ name: sub.name,
486
+ path: sub.path,
487
+ sha,
488
+ branch: targetBranch,
489
+ remote: target.remote,
490
+ pinBranch: `aiws/pin/${targetBranch}`,
491
+ });
492
+ }
493
+
494
+ for (const spec of specs) {
495
+ const targetRepoPath = path.join(gitRoot, spec.path);
496
+ const changeRepoPath = changeWorktreePath ? path.join(changeWorktreePath, spec.path) : "";
497
+ const targetRepoReady = await isGitRepository(targetRepoPath);
498
+ const changeRepoReady = changeRepoPath ? await isGitRepository(changeRepoPath) : false;
499
+ if (!targetRepoReady && !changeRepoReady) {
500
+ throw new UserError("Submodule repository is not initialized in either target or change worktree.", {
501
+ details: `path: ${spec.path}\nHint: initialize submodules before finish, or keep using worktree mode for changes involving submodules.`,
502
+ });
503
+ }
504
+
505
+ const sourceRepoPath = (targetRepoReady && (await gitHasRevision(targetRepoPath, spec.sha))) ? targetRepoPath : changeRepoReady ? changeRepoPath : targetRepoPath;
506
+ if (!(await gitHasRevision(sourceRepoPath, spec.sha))) {
507
+ throw new UserError("Merged submodule gitlink commit is not available locally.", {
508
+ details: `path: ${spec.path}\ncommit: ${spec.sha}\nsource_repo: ${sourceRepoPath}`,
509
+ });
510
+ }
511
+
512
+ const pushResult = await pushSubmoduleBranch(sourceRepoPath, spec.remote, spec.branch, spec.sha);
513
+ console.log(
514
+ `submodule push: ${spec.path} (${spec.remote} ${spec.branch}${pushResult.status === "pushed" ? ` <= ${spec.sha}` : ", already contained"})`,
515
+ );
516
+ }
517
+
518
+ const sync = await runCommand("git", ["submodule", "update", "--init", "--recursive"], { cwd: gitRoot });
519
+ if (sync.code !== 0) {
520
+ throw new UserError("Submodule push succeeded, but failed to sync target worktree submodules.", {
521
+ details: sync.stderr || sync.stdout,
522
+ });
523
+ }
524
+
525
+ for (const spec of specs) {
526
+ const targetRepoPath = path.join(gitRoot, spec.path);
527
+ if (!(await isGitRepository(targetRepoPath))) {
528
+ throw new UserError("Submodule repo missing after sync.", { details: `path: ${spec.path}` });
529
+ }
530
+ await ensureRevisionAvailable(targetRepoPath, spec.sha, changeWorktreePath ? path.join(changeWorktreePath, spec.path) : "");
531
+ const fetch = await runCommand("git", ["-C", targetRepoPath, "fetch", spec.remote, "--prune"], { cwd: targetRepoPath });
532
+ if (fetch.code !== 0) {
533
+ throw new UserError("Failed to refresh target worktree submodule remote.", {
534
+ details: `path: ${spec.path}\nremote: ${spec.remote}\n\n${fetch.stderr || fetch.stdout}`,
535
+ });
536
+ }
537
+ const checkout = await runCommand("git", ["-C", targetRepoPath, "checkout", "-B", spec.pinBranch, spec.sha], { cwd: targetRepoPath });
538
+ if (checkout.code !== 0) {
539
+ throw new UserError("Failed to attach target worktree submodule to pin branch.", {
540
+ details: `path: ${spec.path}\npin_branch: ${spec.pinBranch}\ncommit: ${spec.sha}\n\n${checkout.stderr || checkout.stdout}`,
541
+ });
542
+ }
543
+ await runCommand("git", ["-C", targetRepoPath, "branch", "--set-upstream-to", `${spec.remote}/${spec.branch}`, spec.pinBranch], { cwd: targetRepoPath });
544
+ }
545
+
546
+ return { processed: specs.length };
547
+ }
548
+
549
+ /**
550
+ * @param {string} gitRoot
551
+ * @param {{ changeId?: string, into?: string, base?: string }} options
552
+ */
553
+ async function resolveFinishContext(gitRoot, options) {
554
+ const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "finish" });
555
+ assertValidChangeId(changeId);
556
+
557
+ const changeBranch = `change/${changeId}`;
558
+ const changeBranchRef = `refs/heads/${changeBranch}`;
559
+
560
+ const hasChangeBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", changeBranchRef], { cwd: gitRoot }).then((r) => r.code === 0);
561
+ if (!hasChangeBranch) {
562
+ throw new UserError(`Missing change branch: ${changeBranch}`, {
563
+ details: "Hint: create it with `aiws change start <change-id>` (or `git switch -c change/<id>`).",
564
+ });
565
+ }
566
+
567
+ const clean = await checkGitClean(gitRoot);
568
+ if (!clean.clean) {
569
+ throw new UserError("Refusing to finish with a dirty working tree.", {
570
+ details: `${clean.details}\n\nHint: commit or stash your changes, then retry.`,
571
+ });
572
+ }
573
+
574
+ const cur = await currentBranch(gitRoot);
575
+ const intoRaw = String(options.into || "").trim();
576
+ const baseRaw = String(options.base || "").trim();
577
+ if (intoRaw && baseRaw && intoRaw !== baseRaw) {
578
+ throw new UserError("change finish: cannot combine --into with --base (values differ).", { details: `--into=${intoRaw}\n--base=${baseRaw}` });
579
+ }
580
+
581
+ /** @type {string} */
582
+ let into = intoRaw || baseRaw;
583
+
584
+ if (!into) {
585
+ if (!cur) {
586
+ throw new UserError("Detached HEAD: cannot infer target branch for finish.", {
587
+ details: "Hint: switch to the target branch (e.g. main) and retry, or pass `aiws change finish <id> --into <branch>`.",
588
+ });
589
+ }
590
+ if (cur !== changeBranch) {
591
+ into = cur;
592
+ } else {
593
+ const metaPath = path.join(gitRoot, "changes", changeId, ".ws-change.json");
594
+ /** @type {any} */
595
+ let meta = null;
596
+ if (await pathExists(metaPath)) {
597
+ try {
598
+ meta = JSON.parse(await readText(metaPath));
599
+ } catch {
600
+ meta = null;
601
+ }
602
+ }
603
+ if (!meta) {
604
+ const show = await runCommand("git", ["show", `${changeBranch}:changes/${changeId}/.ws-change.json`], { cwd: gitRoot });
605
+ if (show.code === 0) {
606
+ try {
607
+ meta = JSON.parse(String(show.stdout || ""));
608
+ } catch {
609
+ meta = null;
610
+ }
611
+ }
612
+ }
613
+ const inferred = meta && typeof meta === "object" ? String(meta.base_branch || "").trim() : "";
614
+ if (!inferred) {
615
+ throw new UserError("Cannot infer base branch for finish.", {
616
+ details: "Hint: run from the target branch (e.g. main), or pass: `aiws change finish <id> --into <branch>`.",
617
+ });
618
+ }
619
+ into = inferred;
620
+ }
621
+ }
622
+
623
+ if (into === changeBranch) {
624
+ throw new UserError("change finish: target branch cannot be the change branch.", { details: `target=${into}` });
625
+ }
626
+
627
+ const worktrees = await listGitWorktrees(gitRoot);
628
+ const intoRef = `refs/heads/${into}`;
629
+ const intoWt = worktrees.find((w) => String(w.branch || "") === intoRef);
630
+ if (intoWt && path.resolve(intoWt.worktree) !== path.resolve(gitRoot)) {
631
+ throw new UserError("Target branch is checked out in another worktree.", {
632
+ details: `branch: ${into}\nworktree: ${intoWt.worktree}\n\nHint: run finish in that worktree:\n cd ${intoWt.worktree}\n aiws change finish ${changeId}`,
633
+ });
634
+ }
635
+
636
+ const hasIntoBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", intoRef], { cwd: gitRoot }).then((r) => r.code === 0);
637
+ if (!hasIntoBranch) {
638
+ throw new UserError("Target branch does not exist.", { details: `branch: ${into}` });
639
+ }
640
+
641
+ const changeWt = worktrees.find((w) => String(w.branch || "") === changeBranchRef);
642
+ return { changeId, changeBranch, changeBranchRef, into, cur, worktrees, changeWt };
643
+ }
644
+
205
645
  /**
206
646
  * @param {string} gitRoot
207
647
  * @param {string | undefined} worktreeDir
@@ -1417,7 +1857,7 @@ export async function changeNewCommand(options) {
1417
1857
  /**
1418
1858
  * aiws change start
1419
1859
  *
1420
- * @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean, noSwitch: boolean, forceSwitch: boolean, worktree: boolean, worktreeDir?: string, submodules: boolean }} options
1860
+ * @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean, noSwitch: boolean, forceSwitch: boolean, allowDirty?: boolean, worktree: boolean, worktreeDir?: string, submodules: boolean }} options
1421
1861
  */
1422
1862
  export async function changeStartCommand(options) {
1423
1863
  const gitRoot = await resolveGitRoot(process.cwd());
@@ -1429,6 +1869,7 @@ export async function changeStartCommand(options) {
1429
1869
  const startFromBranch = await currentBranch(gitRoot);
1430
1870
  const branch = `change/${changeId}`;
1431
1871
  const branchRef = `refs/heads/${branch}`;
1872
+ const allowDirty = options.allowDirty === true;
1432
1873
 
1433
1874
  if (options.worktree === true && options.noSwitch === true) {
1434
1875
  throw new UserError("change start: cannot combine --worktree with --no-switch");
@@ -1468,6 +1909,24 @@ export async function changeStartCommand(options) {
1468
1909
  console.error("warn: recommended for submodules: aiws change start <change-id> --worktree");
1469
1910
  }
1470
1911
 
1912
+ if (!allowDirty && (effectiveWorktree || !effectiveNoSwitch)) {
1913
+ const clean = await checkGitClean(gitRoot);
1914
+ if (!clean.clean) {
1915
+ const mode = effectiveWorktree ? "worktree" : "switch";
1916
+ throw new UserError(`Refusing to start change with a dirty working tree (${mode} mode).`, {
1917
+ details:
1918
+ `${clean.details}\n\n` +
1919
+ `Why: starting in ${mode} mode changes your checkout context; uncommitted changes may not appear where you expect.\n` +
1920
+ "Fix: commit first, then retry:\n" +
1921
+ " git add -A && git commit -m \"wip: save before change start\"\n" +
1922
+ "Fix: or stash:\n" +
1923
+ " git stash\n" +
1924
+ `Alt: prepare artifacts without switching: aiws change start ${changeId} --no-switch\n` +
1925
+ "Alt: proceed anyway (not recommended): add --allow-dirty",
1926
+ });
1927
+ }
1928
+ }
1929
+
1471
1930
  if (effectiveWorktree) {
1472
1931
  const prereq = await checkWorktreePrereqs(gitRoot);
1473
1932
  if (!prereq.ok) {
@@ -1545,12 +2004,30 @@ export async function changeStartCommand(options) {
1545
2004
  await maybeRecordBaseBranch(changeDir, startFromBranch, branch);
1546
2005
  }
1547
2006
 
1548
- if (options.submodules === true) {
1549
- const sm = await runCommand("git", ["submodule", "update", "--init", "--recursive"], { cwd: destKey });
1550
- if (sm.code !== 0) throw new UserError("Failed to init/update submodules in worktree.", { details: sm.stderr || sm.stdout });
1551
- } else if (hasGitmodules) {
1552
- console.error("warn: .gitmodules detected; new worktree may have empty submodule dirs.");
1553
- console.error("warn: consider running in the worktree: git submodule update --init --recursive (or re-run with --submodules)");
2007
+ // Worktree mode: if this repo declares submodules, always initialize them in the new worktree.
2008
+ // --submodules is now implicit (kept for backwards compat but has no effect).
2009
+ if (hasGitmodules) {
2010
+ const hasEntries = await runCommand("git", ["config", "--file", ".gitmodules", "--get-regexp", "^submodule\\..*\\.path$"], { cwd: destKey }).then(
2011
+ (r) => r.code === 0 && String(r.stdout || "").trim().length > 0
2012
+ );
2013
+ if (hasEntries) {
2014
+ console.error("info: .gitmodules detected; initializing submodules in the new worktree.");
2015
+ const sm = await runCommand("git", ["submodule", "update", "--init", "--recursive"], { cwd: destKey });
2016
+ if (sm.code !== 0) throw new UserError("Failed to init/update submodules in worktree.", { details: sm.stderr || sm.stdout });
2017
+
2018
+ // Sanity check: fail fast if anything remains uninitialized or conflicted.
2019
+ const st = await runCommand("git", ["submodule", "status", "--recursive"], { cwd: destKey });
2020
+ const lines = String(st.stdout || "")
2021
+ .split("\n")
2022
+ .map((l) => l.trimEnd())
2023
+ .filter(Boolean);
2024
+ const bad = lines.filter((l) => l.startsWith("-") || l.startsWith("U"));
2025
+ if (bad.length > 0) {
2026
+ throw new UserError("Submodules not fully initialized in worktree.", {
2027
+ details: `${bad.slice(0, 20).join("\n")}${bad.length > 20 ? "\n..." : ""}\n\nHint: run in the worktree:\n git submodule update --init --recursive`,
2028
+ });
2029
+ }
2030
+ }
1554
2031
  }
1555
2032
 
1556
2033
  if (options.enableHooks) {
@@ -1562,7 +2039,7 @@ export async function changeStartCommand(options) {
1562
2039
  worktree: destKey,
1563
2040
  branch,
1564
2041
  from_branch: startFromBranch || "",
1565
- submodules: options.submodules === true,
2042
+ submodules: true,
1566
2043
  hooks: options.enableHooks === true,
1567
2044
  });
1568
2045
 
@@ -1689,98 +2166,13 @@ export async function changeStartCommand(options) {
1689
2166
  /**
1690
2167
  * aiws change finish
1691
2168
  *
1692
- * @param {{ changeId?: string, into?: string, base?: string }} options
2169
+ * @param {{ changeId?: string, into?: string, base?: string, push?: boolean, remote?: string }} options
1693
2170
  */
1694
2171
  export async function changeFinishCommand(options) {
1695
2172
  const gitRoot = await resolveGitRoot(process.cwd());
1696
2173
  await ensureTruthFiles(gitRoot);
1697
2174
 
1698
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "finish" });
1699
- assertValidChangeId(changeId);
1700
-
1701
- const changeBranch = `change/${changeId}`;
1702
- const changeBranchRef = `refs/heads/${changeBranch}`;
1703
-
1704
- const hasChangeBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", changeBranchRef], { cwd: gitRoot }).then((r) => r.code === 0);
1705
- if (!hasChangeBranch) {
1706
- throw new UserError(`Missing change branch: ${changeBranch}`, {
1707
- details: "Hint: create it with `aiws change start <change-id>` (or `git switch -c change/<id>`).",
1708
- });
1709
- }
1710
-
1711
- const clean = await checkGitClean(gitRoot);
1712
- if (!clean.clean) {
1713
- throw new UserError("Refusing to finish with a dirty working tree.", {
1714
- details: `${clean.details}\n\nHint: commit or stash your changes, then retry.`,
1715
- });
1716
- }
1717
-
1718
- const cur = await currentBranch(gitRoot);
1719
- const intoRaw = String(options.into || "").trim();
1720
- const baseRaw = String(options.base || "").trim();
1721
- if (intoRaw && baseRaw && intoRaw !== baseRaw) {
1722
- throw new UserError("change finish: cannot combine --into with --base (values differ).", { details: `--into=${intoRaw}\n--base=${baseRaw}` });
1723
- }
1724
-
1725
- /** @type {string} */
1726
- let into = intoRaw || baseRaw;
1727
-
1728
- if (!into) {
1729
- if (!cur) {
1730
- throw new UserError("Detached HEAD: cannot infer target branch for finish.", {
1731
- details: "Hint: switch to the target branch (e.g. main) and retry, or pass `aiws change finish <id> --into <branch>`.",
1732
- });
1733
- }
1734
- if (cur !== changeBranch) {
1735
- into = cur;
1736
- } else {
1737
- const metaPath = path.join(gitRoot, "changes", changeId, ".ws-change.json");
1738
- /** @type {any} */
1739
- let meta = null;
1740
- if (await pathExists(metaPath)) {
1741
- try {
1742
- meta = JSON.parse(await readText(metaPath));
1743
- } catch {
1744
- meta = null;
1745
- }
1746
- }
1747
- if (!meta) {
1748
- const show = await runCommand("git", ["show", `${changeBranch}:changes/${changeId}/.ws-change.json`], { cwd: gitRoot });
1749
- if (show.code === 0) {
1750
- try {
1751
- meta = JSON.parse(String(show.stdout || ""));
1752
- } catch {
1753
- meta = null;
1754
- }
1755
- }
1756
- }
1757
- const inferred = meta && typeof meta === "object" ? String(meta.base_branch || "").trim() : "";
1758
- if (!inferred) {
1759
- throw new UserError("Cannot infer base branch for finish.", {
1760
- details: "Hint: run from the target branch (e.g. main), or pass: `aiws change finish <id> --into <branch>`.",
1761
- });
1762
- }
1763
- into = inferred;
1764
- }
1765
- }
1766
-
1767
- if (into === changeBranch) {
1768
- throw new UserError("change finish: target branch cannot be the change branch.", { details: `target=${into}` });
1769
- }
1770
-
1771
- const worktrees = await listGitWorktrees(gitRoot);
1772
- const intoRef = `refs/heads/${into}`;
1773
- const intoWt = worktrees.find((w) => String(w.branch || "") === intoRef);
1774
- if (intoWt && path.resolve(intoWt.worktree) !== path.resolve(gitRoot)) {
1775
- throw new UserError("Target branch is checked out in another worktree.", {
1776
- details: `branch: ${into}\nworktree: ${intoWt.worktree}\n\nHint: run finish in that worktree:\n cd ${intoWt.worktree}\n aiws change finish ${changeId}`,
1777
- });
1778
- }
1779
-
1780
- const hasIntoBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", intoRef], { cwd: gitRoot }).then((r) => r.code === 0);
1781
- if (!hasIntoBranch) {
1782
- throw new UserError("Target branch does not exist.", { details: `branch: ${into}` });
1783
- }
2175
+ const { changeId, changeBranch, into, cur, changeWt } = await resolveFinishContext(gitRoot, options);
1784
2176
 
1785
2177
  if (cur && cur !== into) {
1786
2178
  const sw = await runCommand("git", ["switch", into], { cwd: gitRoot });
@@ -1792,8 +2184,6 @@ export async function changeFinishCommand(options) {
1792
2184
 
1793
2185
  const merge = await runCommand("git", ["merge", "--ff-only", changeBranch], { cwd: gitRoot });
1794
2186
  if (merge.code !== 0) {
1795
- const changeRef = changeBranchRef;
1796
- const changeWt = worktrees.find((w) => String(w.branch || "") === changeRef);
1797
2187
  const extra =
1798
2188
  changeWt && changeWt.worktree
1799
2189
  ? `\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}`
@@ -1806,9 +2196,51 @@ export async function changeFinishCommand(options) {
1806
2196
  console.log(`ok: finished change: ${changeId}`);
1807
2197
  console.log(`into: ${into}`);
1808
2198
  console.log(`from: ${changeBranch}`);
1809
- if (await pathExists(path.join(gitRoot, ".gitmodules"))) {
2199
+
2200
+ if (options.push === true) {
2201
+ const submodulePush = await pushSubmodulesForFinish(gitRoot, changeId, into, changeWt?.worktree);
2202
+ if (submodulePush.processed > 0) {
2203
+ console.log(`submodule push: ok (${submodulePush.processed} repos)`);
2204
+ }
2205
+
2206
+ const pushTarget = await resolvePushTarget(gitRoot, into, options.remote);
2207
+ const push = await runCommand("git", ["push", pushTarget.remote, pushTarget.refspec], { cwd: gitRoot });
2208
+ if (push.code !== 0) {
2209
+ throw new UserError("Finished locally, but failed to push target branch.", {
2210
+ details:
2211
+ `remote: ${pushTarget.remote}\nlocal_branch: ${into}\nremote_branch: ${pushTarget.remoteBranch}\n` +
2212
+ (pushTarget.trackedMergeRef ? `tracked_merge: ${pushTarget.trackedMergeRef}\n` : "") +
2213
+ `\n${push.stderr || push.stdout}`,
2214
+ });
2215
+ }
2216
+
2217
+ console.log(`push: ok (${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""})`);
2218
+
2219
+ console.log(`push: target ${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""}`);
2220
+
2221
+ if (changeWt?.worktree) {
2222
+ const cleanup = await cleanupWorktreePath(gitRoot, changeWt.worktree);
2223
+ if (cleanup.status === "removed") {
2224
+ console.log(`cleanup: removed worktree ${cleanup.worktree}`);
2225
+ } else if (cleanup.status === "pruned-missing") {
2226
+ console.log(`cleanup: pruned stale worktree metadata for ${cleanup.worktree}`);
2227
+ } else if (cleanup.reason === "current_worktree") {
2228
+ console.log("cleanup: skipped (change worktree is current worktree)");
2229
+ } else if (cleanup.reason === "locked") {
2230
+ console.log(`cleanup: skipped locked worktree ${cleanup.worktree}`);
2231
+ } else if (cleanup.reason === "dirty") {
2232
+ console.log(`cleanup: skipped dirty worktree ${cleanup.worktree}`);
2233
+ } else {
2234
+ console.log("cleanup: skipped");
2235
+ }
2236
+ } else {
2237
+ console.log("cleanup: skipped (no separate change worktree)");
2238
+ }
2239
+ }
2240
+
2241
+ if (options.push === true && (await listSubmodulesFromGitmodules(gitRoot)).length > 0) {
1810
2242
  console.log("next:");
1811
- console.log(" - (optional) update submodules: git submodule update --init --recursive");
2243
+ console.log(" - submodules already synced for finish --push; rerun `git submodule update --init --recursive` only if another worktree changed them later");
1812
2244
  }
1813
2245
  }
1814
2246
 
@@ -61,7 +61,7 @@ export async function hooksInstallCommand(options) {
61
61
  const templateId = await resolveTemplateIdForRepo(gitRoot);
62
62
  const tpl = await loadTemplate(templateId);
63
63
 
64
- const hookFiles = [".githooks/pre-commit", ".githooks/pre-push"];
64
+ const hookFiles = [".githooks/commit-msg", ".githooks/pre-commit", ".githooks/pre-push"];
65
65
  /** @type {string[]} */
66
66
  const created = [];
67
67
  /** @type {string[]} */
@@ -90,4 +90,3 @@ export async function hooksInstallCommand(options) {
90
90
  if (created.length > 0) console.log(`created: ${created.join(", ")}`);
91
91
  if (chmodded.length > 0) console.log(`chmod: ${chmodded.join(", ")}`);
92
92
  }
93
-
@@ -51,15 +51,22 @@ export async function hooksStatusCommand(options) {
51
51
  const normalizedHooksPath = hooksPath.replace(/[\\/]+$/, "");
52
52
 
53
53
  const githooksDir = path.join(gitRoot, ".githooks");
54
+ const commitMsg = path.join(githooksDir, "commit-msg");
54
55
  const preCommit = path.join(githooksDir, "pre-commit");
55
56
  const prePush = path.join(githooksDir, "pre-push");
56
57
 
57
58
  const githooksExists = await pathExists(githooksDir);
59
+ const commitMsgExists = await pathExists(commitMsg);
58
60
  const preCommitExists = await pathExists(preCommit);
59
61
  const prePushExists = await pathExists(prePush);
60
62
 
63
+ let commitMsgExec = false;
61
64
  let preCommitExec = false;
62
65
  let prePushExec = false;
66
+ if (commitMsgExists) {
67
+ const st = await fs.stat(commitMsg);
68
+ commitMsgExec = isExecutable(st);
69
+ }
63
70
  if (preCommitExists) {
64
71
  const st = await fs.stat(preCommit);
65
72
  preCommitExec = isExecutable(st);
@@ -72,6 +79,7 @@ export async function hooksStatusCommand(options) {
72
79
  console.log(`✓ aiws hooks status: ${gitRoot}`);
73
80
  console.log(`core.hooksPath: ${hooksPath ? hooksPath : "(default: .git/hooks)"}`);
74
81
  console.log(`.githooks/: ${githooksExists ? "ok" : "missing"}`);
82
+ console.log(`.githooks/commit-msg: ${commitMsgExists ? (commitMsgExec ? "ok" : "not-executable") : "missing"}`);
75
83
  console.log(`.githooks/pre-commit: ${preCommitExists ? (preCommitExec ? "ok" : "not-executable") : "missing"}`);
76
84
  console.log(`.githooks/pre-push: ${prePushExists ? (prePushExec ? "ok" : "not-executable") : "missing"}`);
77
85
 
@@ -80,7 +88,7 @@ export async function hooksStatusCommand(options) {
80
88
  console.log("Next: aiws hooks install .");
81
89
  return;
82
90
  }
83
- if (!githooksExists || !preCommitExists || !prePushExists || !preCommitExec || !prePushExec) {
91
+ if (!githooksExists || !commitMsgExists || !preCommitExists || !prePushExists || !commitMsgExec || !preCommitExec || !prePushExec) {
84
92
  console.log("Next: aiws hooks install .");
85
93
  return;
86
94
  }
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs/promises";
1
2
  import path from "node:path";
2
3
  import { loadTemplate } from "../spec.js";
3
4
  import { loadAiwsPackage } from "../aiws-package.js";
@@ -59,13 +60,32 @@ export async function updateCommand(options) {
59
60
  }
60
61
  }
61
62
 
63
+ const removeFiles = (update.remove || []).map(normalizeRel);
64
+
62
65
  const backup = new BackupSession({ workspaceRoot, operation: "update" });
63
66
  // Backup every file we might touch (including missing, for rollback deletions).
64
67
  await backup.recordFile(".aiws/manifest.json", { recordMissing: true });
65
68
  for (const f of replaceFiles) await backup.recordFile(f, { recordMissing: true });
66
69
  for (const f of Object.keys(managedBlocks)) await backup.recordFile(f, { recordMissing: true });
70
+ for (const f of removeFiles) await backup.recordFile(f);
67
71
  await backup.finalize({ extra: { template_id: tpl.templateId } });
68
72
 
73
+ // Remove deprecated files (backed up above; restorable via `aiws rollback`).
74
+ let removedCount = 0;
75
+ for (const rel of removeFiles) {
76
+ if (!rel) continue;
77
+ const abs = joinRel(workspaceRoot, rel);
78
+ if (await pathExists(abs)) {
79
+ await fs.rm(abs, { force: true });
80
+ removedCount++;
81
+ // Remove empty parent directory (safe: rmdir fails on non-empty).
82
+ try { await fs.rmdir(path.dirname(abs)); } catch { /* non-empty or gone */ }
83
+ }
84
+ }
85
+ if (removedCount > 0) {
86
+ console.log(` removed ${removedCount} deprecated file(s)`);
87
+ }
88
+
69
89
  // Replace files (except manifest, generated later).
70
90
  for (const rel of replaceFiles) {
71
91
  if (!rel || rel === ".aiws/manifest.json") continue;
@@ -36,7 +36,7 @@ async function validateSubmoduleBranchPolicy(workspaceRoot) {
36
36
  if (idx <= 0) continue;
37
37
  const key = t.slice(0, idx).trim();
38
38
  const subPath = t.slice(idx + 1).trim();
39
- const m = key.match(/^submodule\.([^.]+)\.path$/);
39
+ const m = key.match(/^submodule\.(.+)\.path$/);
40
40
  if (!m) continue;
41
41
  const name = m[1] || "";
42
42
  if (!name || !subPath) continue;