@aipper/aiws 0.0.28 → 0.0.29

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.
@@ -26,23 +26,12 @@ import { runChangeValidateCommand } from "./change-validate-entry.js";
26
26
  import {
27
27
  computeChangeNextAdvice as computeChangeNextAdviceModel,
28
28
  renderChangeStateMarkdown,
29
- resolveChangeWorktreeHint,
30
29
  } from "./change-advice.js";
31
30
  import {
32
- cleanupWorktreePath,
33
31
  isGitRepository,
34
32
  listSubmodulesFromGitmodules,
35
- pushSubmodulesForFinish,
36
33
  resolveFinishContext,
37
- resolvePushTarget,
38
34
  } from "./change-finish.js";
39
- import {
40
- checkWorktreePrereqs,
41
- maybeRecordBaseBranch,
42
- prepareNoSwitchBranch,
43
- resolveDefaultWorktreeDir,
44
- switchOrCreateStartBranch,
45
- } from "./change-start.js";
46
35
  import {
47
36
  collectFinishGateErrors,
48
37
  collectReviewGates,
@@ -50,9 +39,6 @@ import {
50
39
  } from "./change-review-gates.js";
51
40
  import {
52
41
  computeScopeGate,
53
- scopeGateFailureAction,
54
- scopeGatePassedRecommendation,
55
- scopeGatePendingRecommendation,
56
42
  } from "./change-scope-gate.js";
57
43
  import {
58
44
  readChangeMetrics,
@@ -62,10 +48,176 @@ import {
62
48
  } from "./change-status-context.js";
63
49
  import { renderChangeStatusOutput } from "./change-status-command.js";
64
50
  import { validateChangeArtifacts as validateChangeArtifactsModel } from "./change-validate.js";
65
- import { hooksInstallCommand } from "./hooks-install.js";
51
+
52
+ /**
53
+ * @param {string} text
54
+ */
55
+ function countWsTodo(text) {
56
+ return (text.match(/WS:TODO/g) || []).length;
57
+ }
58
+
59
+ /**
60
+ * @param {string} text
61
+ */
62
+ function countPlaceholders(text) {
63
+ return (text.match(/{{CHANGE_ID}}|{{TITLE}}|{{CREATED_AT}}/g) || []).length;
64
+ }
65
+
66
+ /**
67
+ * @param {string} changeDir
68
+ * @param {string} rel
69
+ */
70
+ async function fileState(changeDir, rel) {
71
+ const abs = path.join(changeDir, rel);
72
+ if (!(await pathExists(abs))) return { state: "missing", wsTodo: 0, placeholders: 0, abs, text: "" };
73
+ const st = await fs.stat(abs);
74
+ if (st.size === 0) return { state: "empty", wsTodo: 0, placeholders: 0, abs, text: "" };
75
+ const text = await readText(abs);
76
+ return { state: "ok", wsTodo: countWsTodo(text), placeholders: countPlaceholders(text), abs, text };
77
+ }
78
+
79
+ /**
80
+ * Append a change lifecycle event into changes/<id>/metrics.json.
81
+ *
82
+ * @param {string} gitRoot
83
+ * @param {string} changeId
84
+ * @param {string} type
85
+ * @param {any} payload
86
+ */
87
+ async function appendMetricsEventAtDir(changeDir, changeId, type, payload) {
88
+ const metricsAbs = path.join(changeDir, "metrics.json");
89
+ /** @type {{ version: number, change_id: string, events: any[], updated_at?: string } | null} */
90
+ let cur = null;
91
+ if (await pathExists(metricsAbs)) {
92
+ try {
93
+ cur = JSON.parse(await readText(metricsAbs));
94
+ } catch {
95
+ cur = null;
96
+ }
97
+ }
98
+ if (!cur || typeof cur !== "object") {
99
+ cur = { version: 1, change_id: changeId, events: [] };
100
+ }
101
+ if (!Array.isArray(cur.events)) cur.events = [];
102
+ cur.events.push({ type, timestamp: nowIsoUtc(), payload: payload ?? null });
103
+ cur.events = cur.events.slice(-200);
104
+ cur.updated_at = nowIsoUtc();
105
+ await writeText(metricsAbs, JSON.stringify(cur, null, 2) + "\n");
106
+ }
107
+
108
+ async function appendMetricsEvent(gitRoot, changeId, type, payload) {
109
+ const changeDir = changeDirAbs(gitRoot, changeId);
110
+ await appendMetricsEventAtDir(changeDir, changeId, type, payload);
111
+ }
112
+
113
+ /**
114
+ * @param {string} gitRoot
115
+ */
116
+ async function resolveTemplateIdForRepo(gitRoot) {
117
+ const manifestPath = path.join(gitRoot, ".aiws", "manifest.json");
118
+ if (!(await pathExists(manifestPath))) return "workspace";
119
+ try {
120
+ const stored = JSON.parse(await readText(manifestPath));
121
+ const templateId = String(stored.template_id || "").trim();
122
+ return templateId || "workspace";
123
+ } catch {
124
+ return "workspace";
125
+ }
126
+ }
127
+
128
+ /**
129
+ * @param {string} gitRoot
130
+ * @returns {Promise<{ templateDir: string, resolvedFrom: string }>}
131
+ */
132
+ async function resolveChangeTemplatesDir(gitRoot) {
133
+ const candidates = [
134
+ { dir: path.join(gitRoot, "changes", "templates"), label: `${gitRoot}/changes/templates` },
135
+ { dir: path.join(gitRoot, "workflow", "changes", "templates"), label: `${gitRoot}/workflow/changes/templates` },
136
+ ];
137
+
138
+ for (const c of candidates) {
139
+ if (!(await pathExists(path.join(c.dir, "proposal.md")))) continue;
140
+ if (!(await pathExists(path.join(c.dir, "tasks.md")))) continue;
141
+ return { templateDir: c.dir, resolvedFrom: c.label };
142
+ }
143
+
144
+ const templateId = await resolveTemplateIdForRepo(gitRoot);
145
+ const tpl = await loadTemplate(templateId);
146
+ const fallback = path.join(tpl.templateDir, "changes", "templates");
147
+
148
+ if (!(await pathExists(path.join(fallback, "proposal.md"))) || !(await pathExists(path.join(fallback, "tasks.md")))) {
149
+ throw new UserError("Missing change templates.", { details: `Expected proposal.md and tasks.md under: ${fallback}` });
150
+ }
151
+ return { templateDir: fallback, resolvedFrom: `@aipper/aiws-spec:${tpl.templateId}` };
152
+ }
153
+
154
+ /**
155
+ * @param {string} templateText
156
+ * @param {{ changeId: string, title: string, createdAt: string }} vars
157
+ */
158
+ function renderTemplate(templateText, vars) {
159
+ return templateText.replaceAll("{{CHANGE_ID}}", vars.changeId).replaceAll("{{TITLE}}", vars.title).replaceAll("{{CREATED_AT}}", vars.createdAt);
160
+ }
161
+
162
+ /**
163
+ * @param {string} gitRoot
164
+ * @returns {Promise<{ path: string, args: string[] }>}
165
+ */
166
+ async function resolveWsChangeChecker(gitRoot) {
167
+ const local = path.join(gitRoot, "tools", "ws_change_check.py");
168
+ if (await pathExists(local)) return { path: local, args: [local] };
169
+
170
+ const templateId = await resolveTemplateIdForRepo(gitRoot);
171
+ const tpl = await loadTemplate(templateId);
172
+ const fallback = path.join(tpl.templateDir, "tools", "ws_change_check.py");
173
+ if (!(await pathExists(fallback))) {
174
+ throw new UserError("Missing ws_change_check.py.", { details: "Hint: run `aiws init .` to install tools/ws_change_check.py in your repo." });
175
+ }
176
+ return { path: fallback, args: [fallback] };
177
+ }
178
+
179
+ /**
180
+ * @param {string} gitRoot
181
+ * @param {string[]} args
182
+ */
183
+ async function runPython(gitRoot, args) {
184
+ let res;
185
+ try {
186
+ res = await runCommand("python3", args, { cwd: gitRoot });
187
+ } catch (e) {
188
+ throw new UserError("python3 is required.", { details: e instanceof Error ? e.message : String(e) });
189
+ }
190
+ return res;
191
+ }
192
+
193
+ /**
194
+ * @param {string} gitRoot
195
+ * @param {string | undefined} changeId
196
+ * @param {{ command: string, allowFinishedUnarchived?: boolean }} options
197
+ */
198
+ async function resolveChangeId(gitRoot, changeId, options) {
199
+ const resolved = changeId || inferChangeIdFromBranch(await currentBranch(gitRoot));
200
+ if (resolved) {
201
+ const terminated = await detectTerminatedChange(gitRoot, resolved);
202
+ if (!isTerminatedAllowed(options.command, terminated, { allowFinishedUnarchived: options.allowFinishedUnarchived === true })) {
203
+ throw terminatedChangeError(gitRoot, resolved, terminated, `aiws change ${options.command}`);
204
+ }
205
+ return resolved;
206
+ }
207
+ throw new UserError(`usage: aiws change ${options.command} <change-id> (or switch to branch change/<change-id>)`);
208
+ }
209
+
210
+ /**
211
+ * @param {string} gitRoot
212
+ * @param {string} changeId
213
+ */
214
+ function changeDirAbs(gitRoot, changeId) {
215
+ return path.join(gitRoot, "changes", changeId);
216
+ }
66
217
 
67
218
  const CHANGE_BRANCH_RE = /^(change|changes|ws|ws-change)\/([a-z0-9]+(?:-[a-z0-9]+)*)$/;
68
219
  const CHANGE_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
220
+
69
221
  /**
70
222
  * @param {string} changeId
71
223
  */
@@ -162,53 +314,8 @@ async function checkGitClean(gitRoot) {
162
314
  return { clean: false, details: truncated };
163
315
  }
164
316
 
165
- /**
166
- * @param {string} gitRoot
167
- * @returns {Promise<Array<{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string }>>}
168
- */
169
- async function listGitWorktrees(gitRoot) {
170
- const res = await runCommand("git", ["worktree", "list", "--porcelain"], { cwd: gitRoot });
171
- if (res.code !== 0) {
172
- throw new UserError("Failed to list git worktrees.", { details: res.stderr || res.stdout });
173
- }
174
- const lines = String(res.stdout || "")
175
- .split(/\r?\n/)
176
- .map((l) => l.trimEnd());
177
-
178
- /** @type {Array<{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string }>} */
179
- const out = [];
180
- /** @type {{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string } | null} */
181
- let cur = null;
182
-
183
- for (const line of lines) {
184
- if (!line) continue;
185
- if (line.startsWith("worktree ")) {
186
- if (cur) out.push(cur);
187
- cur = { worktree: path.resolve(line.slice("worktree ".length).trim()), head: "", branch: "", locked: false, lockReason: "" };
188
- continue;
189
- }
190
- if (!cur) continue;
191
- if (line.startsWith("HEAD ")) {
192
- cur.head = line.slice("HEAD ".length).trim();
193
- continue;
194
- }
195
- if (line.startsWith("branch ")) {
196
- cur.branch = line.slice("branch ".length).trim();
197
- continue;
198
- }
199
- if (line === "locked" || line.startsWith("locked ")) {
200
- cur.locked = true;
201
- cur.lockReason = line === "locked" ? "" : line.slice("locked ".length).trim();
202
- continue;
203
- }
204
- }
205
- if (cur) out.push(cur);
206
- return out;
207
- }
208
-
209
317
  /**
210
318
  * @param {string} branch
211
- * @returns {string}
212
319
  */
213
320
  function inferChangeIdFromBranch(branch) {
214
321
  const m = CHANGE_BRANCH_RE.exec(branch || "");
@@ -681,12 +788,6 @@ function inferChangePhase(blockersStrict, tasks) {
681
788
  return "deliver";
682
789
  }
683
790
 
684
- /**
685
- * Compute change status as JSON-friendly object (used by dashboard and CLI).
686
- *
687
- * @param {string} gitRoot
688
- * @param {string} changeId
689
- */
690
791
  export async function computeChangeStatus(gitRoot, changeId) {
691
792
  assertValidChangeId(changeId);
692
793
  await ensureTruthFiles(gitRoot);
@@ -695,7 +796,6 @@ export async function computeChangeStatus(gitRoot, changeId) {
695
796
  if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
696
797
  const context = await resolveChangeStatusContext(gitRoot, changeId, {
697
798
  currentBranch,
698
- listGitWorktrees,
699
799
  pathExists,
700
800
  isGitRepository: (repoPath) => isGitRepository(repoPath, { pathExists, runCommand }),
701
801
  inferChangeIdFromBranch,
@@ -868,190 +968,21 @@ export async function computeChangeStatus(gitRoot, changeId) {
868
968
  };
869
969
  }
870
970
 
871
- /**
872
- * Run ws_change_check.py and return parsed results for dashboards/tools.
873
- *
874
- * @param {string} gitRoot
875
- * @param {string} changeId
876
- * @param {{ strict: boolean, allowTruthDrift: boolean, checkEvidence?: boolean, checkScope?: boolean }} options
877
- */
878
971
  export async function validateChangeArtifacts(gitRoot, changeId, options) {
879
972
  return validateChangeArtifactsModel(gitRoot, changeId, options, {
880
973
  assertValidChangeId,
881
974
  collectFinishGateErrors,
882
975
  computeChangeStatus,
883
976
  ensureTruthFiles,
977
+ pathExists,
978
+ readText,
884
979
  resolveWsChangeChecker,
980
+ runCommand,
885
981
  runPython,
982
+ UserError,
886
983
  });
887
984
  }
888
985
 
889
- /**
890
- * @param {string} text
891
- */
892
- function countWsTodo(text) {
893
- return (text.match(/WS:TODO/g) || []).length;
894
- }
895
-
896
- /**
897
- * @param {string} text
898
- */
899
- function countPlaceholders(text) {
900
- return (text.match(/{{CHANGE_ID}}|{{TITLE}}|{{CREATED_AT}}/g) || []).length;
901
- }
902
-
903
- /**
904
- * @param {string} changeDir
905
- * @param {string} rel
906
- */
907
- async function fileState(changeDir, rel) {
908
- const abs = path.join(changeDir, rel);
909
- if (!(await pathExists(abs))) return { state: "missing", wsTodo: 0, placeholders: 0, abs, text: "" };
910
- const st = await fs.stat(abs);
911
- if (st.size === 0) return { state: "empty", wsTodo: 0, placeholders: 0, abs, text: "" };
912
- const text = await readText(abs);
913
- return { state: "ok", wsTodo: countWsTodo(text), placeholders: countPlaceholders(text), abs, text };
914
- }
915
-
916
- /**
917
- * Append a change lifecycle event into changes/<id>/metrics.json.
918
- *
919
- * @param {string} gitRoot
920
- * @param {string} changeId
921
- * @param {string} type
922
- * @param {any} payload
923
- */
924
- async function appendMetricsEventAtDir(changeDir, changeId, type, payload) {
925
- const metricsAbs = path.join(changeDir, "metrics.json");
926
- /** @type {{ version: number, change_id: string, events: any[], updated_at?: string } | null} */
927
- let cur = null;
928
- if (await pathExists(metricsAbs)) {
929
- try {
930
- cur = JSON.parse(await readText(metricsAbs));
931
- } catch {
932
- cur = null;
933
- }
934
- }
935
- if (!cur || typeof cur !== "object") {
936
- cur = { version: 1, change_id: changeId, events: [] };
937
- }
938
- if (!Array.isArray(cur.events)) cur.events = [];
939
- cur.events.push({ type, timestamp: nowIsoUtc(), payload: payload ?? null });
940
- cur.events = cur.events.slice(-200);
941
- cur.updated_at = nowIsoUtc();
942
- await writeText(metricsAbs, JSON.stringify(cur, null, 2) + "\n");
943
- }
944
-
945
- async function appendMetricsEvent(gitRoot, changeId, type, payload) {
946
- const changeDir = changeDirAbs(gitRoot, changeId);
947
- await appendMetricsEventAtDir(changeDir, changeId, type, payload);
948
- }
949
-
950
- /**
951
- * @param {string} gitRoot
952
- */
953
- async function resolveTemplateIdForRepo(gitRoot) {
954
- const manifestPath = path.join(gitRoot, ".aiws", "manifest.json");
955
- if (!(await pathExists(manifestPath))) return "workspace";
956
- try {
957
- const stored = JSON.parse(await readText(manifestPath));
958
- const templateId = String(stored.template_id || "").trim();
959
- return templateId || "workspace";
960
- } catch {
961
- return "workspace";
962
- }
963
- }
964
-
965
- /**
966
- * @param {string} gitRoot
967
- * @returns {Promise<{ templateDir: string, resolvedFrom: string }>}
968
- */
969
- async function resolveChangeTemplatesDir(gitRoot) {
970
- const candidates = [
971
- { dir: path.join(gitRoot, "changes", "templates"), label: `${gitRoot}/changes/templates` },
972
- { dir: path.join(gitRoot, "workflow", "changes", "templates"), label: `${gitRoot}/workflow/changes/templates` },
973
- ];
974
-
975
- for (const c of candidates) {
976
- if (!(await pathExists(path.join(c.dir, "proposal.md")))) continue;
977
- if (!(await pathExists(path.join(c.dir, "tasks.md")))) continue;
978
- return { templateDir: c.dir, resolvedFrom: c.label };
979
- }
980
-
981
- const templateId = await resolveTemplateIdForRepo(gitRoot);
982
- const tpl = await loadTemplate(templateId);
983
- const fallback = path.join(tpl.templateDir, "changes", "templates");
984
-
985
- if (!(await pathExists(path.join(fallback, "proposal.md"))) || !(await pathExists(path.join(fallback, "tasks.md")))) {
986
- throw new UserError("Missing change templates.", { details: `Expected proposal.md and tasks.md under: ${fallback}` });
987
- }
988
- return { templateDir: fallback, resolvedFrom: `@aipper/aiws-spec:${tpl.templateId}` };
989
- }
990
-
991
- /**
992
- * @param {string} templateText
993
- * @param {{ changeId: string, title: string, createdAt: string }} vars
994
- */
995
- function renderTemplate(templateText, vars) {
996
- return templateText.replaceAll("{{CHANGE_ID}}", vars.changeId).replaceAll("{{TITLE}}", vars.title).replaceAll("{{CREATED_AT}}", vars.createdAt);
997
- }
998
-
999
- /**
1000
- * @param {string} gitRoot
1001
- * @returns {Promise<{ path: string, args: string[] }>}
1002
- */
1003
- async function resolveWsChangeChecker(gitRoot) {
1004
- const local = path.join(gitRoot, "tools", "ws_change_check.py");
1005
- if (await pathExists(local)) return { path: local, args: [local] };
1006
-
1007
- const templateId = await resolveTemplateIdForRepo(gitRoot);
1008
- const tpl = await loadTemplate(templateId);
1009
- const fallback = path.join(tpl.templateDir, "tools", "ws_change_check.py");
1010
- if (!(await pathExists(fallback))) {
1011
- throw new UserError("Missing ws_change_check.py.", { details: "Hint: run `aiws init .` to install tools/ws_change_check.py in your repo." });
1012
- }
1013
- return { path: fallback, args: [fallback] };
1014
- }
1015
-
1016
- /**
1017
- * @param {string} gitRoot
1018
- * @param {string[]} args
1019
- */
1020
- async function runPython(gitRoot, args) {
1021
- let res;
1022
- try {
1023
- res = await runCommand("python3", args, { cwd: gitRoot });
1024
- } catch (e) {
1025
- throw new UserError("python3 is required.", { details: e instanceof Error ? e.message : String(e) });
1026
- }
1027
- return res;
1028
- }
1029
-
1030
- /**
1031
- * @param {string} gitRoot
1032
- * @param {string | undefined} changeId
1033
- * @param {{ command: string, allowFinishedUnarchived?: boolean }} options
1034
- */
1035
- async function resolveChangeId(gitRoot, changeId, options) {
1036
- const resolved = changeId || inferChangeIdFromBranch(await currentBranch(gitRoot));
1037
- if (resolved) {
1038
- const terminated = await detectTerminatedChange(gitRoot, resolved);
1039
- if (!isTerminatedAllowed(options.command, terminated, { allowFinishedUnarchived: options.allowFinishedUnarchived === true })) {
1040
- throw terminatedChangeError(gitRoot, resolved, terminated, `aiws change ${options.command}`);
1041
- }
1042
- return resolved;
1043
- }
1044
- throw new UserError(`usage: aiws change ${options.command} <change-id> (or switch to branch change/<change-id>)`);
1045
- }
1046
-
1047
- /**
1048
- * @param {string} gitRoot
1049
- * @param {string} changeId
1050
- */
1051
- function changeDirAbs(gitRoot, changeId) {
1052
- return path.join(gitRoot, "changes", changeId);
1053
- }
1054
-
1055
986
  /**
1056
987
  * aiws change list
1057
988
  */
@@ -1289,7 +1220,7 @@ export async function changeNewCommand(options) {
1289
1220
  /**
1290
1221
  * aiws change start
1291
1222
  *
1292
- * @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean, noSwitch: boolean, forceSwitch: boolean, allowDirty?: boolean, worktree: boolean, worktreeDir?: string, submodules: boolean }} options
1223
+ * @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean, noSwitch: boolean, forceSwitch: boolean, allowDirty?: boolean }} options
1293
1224
  */
1294
1225
  export async function changeStartCommand(options) {
1295
1226
  const gitRoot = await resolveGitRoot(process.cwd());
@@ -1302,55 +1233,32 @@ export async function changeStartCommand(options) {
1302
1233
 
1303
1234
  const startFromBranch = await currentBranch(gitRoot);
1304
1235
  const branch = `change/${changeId}`;
1305
- const branchRef = `refs/heads/${branch}`;
1306
1236
  const allowDirty = options.allowDirty === true;
1307
1237
 
1308
- if (options.worktree === true && options.noSwitch === true) {
1309
- throw new UserError("change start: cannot combine --worktree with --no-switch");
1310
- }
1311
1238
  if (options.forceSwitch === true && options.noSwitch === true) {
1312
1239
  throw new UserError("change start: cannot combine --switch with --no-switch");
1313
1240
  }
1314
- if (options.forceSwitch === true && options.worktree === true) {
1315
- throw new UserError("change start: cannot combine --switch with --worktree");
1316
- }
1317
1241
 
1318
1242
  const hasBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: gitRoot }).then((r) => r.code === 0);
1319
1243
  const hasGitmodules = await pathExists(path.join(gitRoot, ".gitmodules"));
1320
1244
  let effectiveNoSwitch = options.noSwitch === true;
1321
1245
  const effectiveForceSwitch = options.forceSwitch === true;
1322
- let effectiveWorktree = options.worktree === true;
1323
-
1324
- if (hasGitmodules && !effectiveNoSwitch && !effectiveWorktree && !effectiveForceSwitch) {
1325
- const prereq = await checkWorktreePrereqs(gitRoot, { hasHeadCommit, runCommand });
1326
- if (prereq.ok) {
1327
- effectiveWorktree = true;
1328
- console.error("warn: .gitmodules detected (git submodules). defaulting to --worktree to avoid switching the superproject branch.");
1329
- console.error("warn: to avoid worktree and just prepare artifacts: aiws change start <change-id> --no-switch");
1330
- console.error("warn: to switch anyway: aiws change start <change-id> --switch");
1331
- } else {
1332
- effectiveNoSwitch = true;
1333
- if (prereq.reason === "no_commit") {
1334
- console.error("warn: .gitmodules detected (git submodules). cannot use --worktree on a repo with no commits; defaulting to --no-switch.");
1335
- } else {
1336
- console.error("warn: .gitmodules detected (git submodules). cannot use --worktree without truth files committed in HEAD; defaulting to --no-switch.");
1337
- }
1338
- console.error("warn: to switch anyway: aiws change start <change-id> --switch");
1339
- console.error("warn: recommended for submodules: aiws change start <change-id> --worktree (after committing truth files)");
1340
- }
1246
+
1247
+ if (hasGitmodules && !effectiveNoSwitch && !effectiveForceSwitch) {
1248
+ effectiveNoSwitch = true;
1249
+ console.error("warn: .gitmodules detected (git submodules). defaulting to --no-switch (prepare artifacts only).");
1250
+ console.error("warn: to switch anyway: aiws change start <change-id> --switch");
1341
1251
  } else if (hasGitmodules && effectiveForceSwitch) {
1342
1252
  console.error("warn: .gitmodules detected (git submodules). switching the superproject branch due to --switch.");
1343
- console.error("warn: recommended for submodules: aiws change start <change-id> --worktree");
1253
+ console.error("warn: recommended for submodules: aiws change start <change-id> --no-switch");
1344
1254
  }
1345
1255
 
1346
- if (!allowDirty && (effectiveWorktree || !effectiveNoSwitch)) {
1256
+ if (!allowDirty && !effectiveNoSwitch) {
1347
1257
  const clean = await checkGitClean(gitRoot);
1348
1258
  if (!clean.clean) {
1349
- const mode = effectiveWorktree ? "worktree" : "switch";
1350
- throw new UserError(`Refusing to start change with a dirty working tree (${mode} mode).`, {
1259
+ throw new UserError("Refusing to start change with a dirty working tree.", {
1351
1260
  details:
1352
1261
  `${clean.details}\n\n` +
1353
- `Why: starting in ${mode} mode changes your checkout context; uncommitted changes may not appear where you expect.\n` +
1354
1262
  "Fix: commit first, then retry:\n" +
1355
1263
  " git add -A && git commit -m \"wip: save before change start\"\n" +
1356
1264
  "Fix: or stash:\n" +
@@ -1361,139 +1269,6 @@ export async function changeStartCommand(options) {
1361
1269
  }
1362
1270
  }
1363
1271
 
1364
- if (effectiveWorktree) {
1365
- const prereq = await checkWorktreePrereqs(gitRoot, { hasHeadCommit, runCommand });
1366
- if (!prereq.ok) {
1367
- if (prereq.reason === "no_commit") {
1368
- throw new UserError("change start --worktree requires a repo with at least one commit.", { details: "Hint: make an initial commit, then retry." });
1369
- }
1370
- const missingInHead = Array.isArray(prereq.missing) ? prereq.missing : [];
1371
- throw new UserError("change start --worktree requires truth files committed in HEAD.", {
1372
- details:
1373
- `Missing in HEAD:\n${missingInHead.map((f) => `- ${f}`).join("\n")}\n\n` +
1374
- "Why: git worktree checks out from HEAD, so uncommitted files won't be present in the new worktree.\n" +
1375
- "Hint: commit the truth files first, or use `aiws change start --no-switch`.",
1376
- });
1377
- }
1378
- const worktreeDir = resolveDefaultWorktreeDir(gitRoot, options.worktreeDir, changeId);
1379
-
1380
- const rel = path.relative(gitRoot, worktreeDir);
1381
- if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
1382
- throw new UserError("worktree dir must be outside the git root.", {
1383
- details: `git_root: ${gitRoot}\nworktree_dir: ${worktreeDir}\nHint: use a sibling dir like ../<repo>-<change-id> (default) or pass --worktree-dir <path>.`,
1384
- });
1385
- }
1386
-
1387
- const worktrees = await listGitWorktrees(gitRoot);
1388
- /** @type {Map<string, { worktree: string, head: string, branch: string }>} */
1389
- const byPath = new Map();
1390
- /** @type {Map<string, string>} */
1391
- const byBranch = new Map();
1392
- for (const w of worktrees) {
1393
- const k = path.resolve(w.worktree);
1394
- byPath.set(k, w);
1395
- if (w.branch) byBranch.set(w.branch, k);
1396
- }
1397
-
1398
- const destKey = path.resolve(worktreeDir);
1399
- const destExisting = byPath.get(destKey);
1400
-
1401
- if (!destExisting) {
1402
- if (await pathExists(destKey)) {
1403
- throw new UserError("worktree dir already exists and is not a registered worktree; refusing to overwrite.", {
1404
- details: `worktree_dir: ${destKey}`,
1405
- });
1406
- }
1407
- const used = byBranch.get(branchRef);
1408
- if (used) {
1409
- throw new UserError("change branch is already checked out in another worktree.", {
1410
- details: `branch: ${branch}\nworktree: ${used}\nHint: use that worktree, or pick another change-id.`,
1411
- });
1412
- }
1413
-
1414
- const args = hasBranch ? ["worktree", "add", destKey, branch] : ["worktree", "add", "-b", branch, destKey];
1415
- const add = await runCommand("git", args, { cwd: gitRoot });
1416
- if (add.code !== 0) {
1417
- throw new UserError("Failed to create git worktree.", { details: add.stderr || add.stdout });
1418
- }
1419
- } else if (destExisting.branch && destExisting.branch !== branchRef) {
1420
- throw new UserError("worktree dir already exists but is on a different branch.", {
1421
- details: `worktree_dir: ${destKey}\nexpected_branch: ${branchRef}\nactual_branch: ${destExisting.branch}`,
1422
- });
1423
- } else if (!destExisting.branch) {
1424
- throw new UserError("worktree dir already exists but is detached; refusing to proceed.", {
1425
- details: `worktree_dir: ${destKey}\nHint: inspect with: git -C ${destKey} status`,
1426
- });
1427
- }
1428
-
1429
- const changeDir = changeDirAbs(destKey, changeId);
1430
- if (!(await pathExists(changeDir))) {
1431
- await changeNewAtGitRoot(destKey, {
1432
- changeId,
1433
- title: options.title,
1434
- noDesign: options.noDesign,
1435
- baseBranch: startFromBranch && startFromBranch !== branch ? startFromBranch : undefined,
1436
- });
1437
- } else {
1438
- await maybeRecordBaseBranch(changeDir, startFromBranch, branch, { nowIsoUtc, pathExists, readText, writeText });
1439
- }
1440
-
1441
- // Worktree mode: if this repo declares submodules, always initialize them in the new worktree.
1442
- // --submodules is now implicit (kept for backwards compat but has no effect).
1443
- if (hasGitmodules) {
1444
- const hasEntries = await runCommand("git", ["config", "--file", ".gitmodules", "--get-regexp", "^submodule\\..*\\.path$"], { cwd: destKey }).then(
1445
- (r) => r.code === 0 && String(r.stdout || "").trim().length > 0
1446
- );
1447
- if (hasEntries) {
1448
- console.error("info: .gitmodules detected; initializing submodules in the new worktree.");
1449
- const sm = await runCommand("git", ["submodule", "update", "--init", "--recursive"], { cwd: destKey });
1450
- if (sm.code !== 0) throw new UserError("Failed to init/update submodules in worktree.", { details: sm.stderr || sm.stdout });
1451
-
1452
- // Sanity check: fail fast if anything remains uninitialized or conflicted.
1453
- const st = await runCommand("git", ["submodule", "status", "--recursive"], { cwd: destKey });
1454
- const lines = String(st.stdout || "")
1455
- .split("\n")
1456
- .map((l) => l.trimEnd())
1457
- .filter(Boolean);
1458
- const bad = lines.filter((l) => l.startsWith("-") || l.startsWith("U"));
1459
- if (bad.length > 0) {
1460
- throw new UserError("Submodules not fully initialized in worktree.", {
1461
- details: `${bad.slice(0, 20).join("\n")}${bad.length > 20 ? "\n..." : ""}\n\nHint: run in the worktree:\n git submodule update --init --recursive`,
1462
- });
1463
- }
1464
- }
1465
- }
1466
-
1467
- if (options.enableHooks) {
1468
- await hooksInstallCommand({ targetPath: gitRoot });
1469
- }
1470
-
1471
- await appendMetricsEvent(destKey, changeId, "change_start", {
1472
- mode: "worktree",
1473
- worktree: destKey,
1474
- branch,
1475
- from_branch: startFromBranch || "",
1476
- submodules: true,
1477
- hooks: options.enableHooks === true,
1478
- });
1479
-
1480
- // Check Depends_On dependencies (worktree mode)
1481
- await checkDependenciesAndPrintHandoff(destKey, changeId, changeDir);
1482
-
1483
- console.log(`ok: change worktree ready: ${changeId}`);
1484
- console.log(`worktree: ${destKey}`);
1485
- console.log(`branch: ${branch}`);
1486
- console.log("next:");
1487
- console.log(` - cd: cd ${destKey}`);
1488
- console.log(" - status: aiws change status");
1489
- console.log(" - next: aiws change next");
1490
- console.log(" - validate: aiws change validate --strict");
1491
- if (!options.enableHooks) console.log(" - (optional) enable hooks: aiws hooks install .");
1492
- console.log("finish (safe merge):");
1493
- console.log(` - from main worktree: git merge --ff-only ${branch}`);
1494
- return;
1495
- }
1496
-
1497
1272
  const headReady = await hasHeadCommit(gitRoot);
1498
1273
  let branchReady = hasBranch;
1499
1274
 
@@ -1516,7 +1291,6 @@ export async function changeStartCommand(options) {
1516
1291
  await maybeRecordBaseBranch(changeDir, startFromBranch, branch, { nowIsoUtc, pathExists, readText, writeText });
1517
1292
  }
1518
1293
 
1519
- // Check Depends_On dependencies (non-worktree mode)
1520
1294
  await checkDependenciesAndPrintHandoff(gitRoot, changeId, changeDir);
1521
1295
 
1522
1296
  if (options.enableHooks) {
@@ -1527,7 +1301,6 @@ export async function changeStartCommand(options) {
1527
1301
  const curLabel = cur || "(detached)";
1528
1302
  await appendMetricsEvent(gitRoot, changeId, "change_start", {
1529
1303
  mode: effectiveNoSwitch ? "no-switch" : "switch",
1530
- worktree: gitRoot,
1531
1304
  branch,
1532
1305
  from_branch: startFromBranch || "",
1533
1306
  current_branch: curLabel,
@@ -1580,18 +1353,17 @@ export async function changeFinishCommand(options) {
1580
1353
  `fallback: if you intentionally only need local archive recovery, run \`aiws change archive ${resolvedChangeId}\``,
1581
1354
  });
1582
1355
  }
1583
- const { changeId, changeBranch, into, cur, changeWt } = await resolveFinishContext(gitRoot, { ...options, changeId: resolvedChangeId }, {
1356
+ const { changeId, changeBranch, into, cur } = await resolveFinishContext(gitRoot, { ...options, changeId: resolvedChangeId }, {
1584
1357
  assertValidChangeId,
1585
1358
  checkGitClean,
1586
1359
  currentBranch,
1587
- listGitWorktrees,
1588
1360
  pathExists,
1589
1361
  readText,
1590
1362
  resolveChangeId,
1591
1363
  runCommand,
1592
1364
  UserError,
1593
1365
  });
1594
- const finishStatusRoot = changeWt?.worktree ? path.resolve(changeWt.worktree) : gitRoot;
1366
+ const finishStatusRoot = gitRoot;
1595
1367
  const finishStatus = await computeChangeStatus(finishStatusRoot, changeId);
1596
1368
  const finishGateErrors = collectFinishGateErrors(changeId, finishStatus.reviewGates);
1597
1369
  if (finishGateErrors.length > 0) {
@@ -1633,11 +1405,12 @@ export async function changeFinishCommand(options) {
1633
1405
 
1634
1406
  const merge = await runCommand("git", ["merge", "--ff-only", changeBranch], { cwd: gitRoot });
1635
1407
  if (merge.code !== 0) {
1636
- const extra =
1637
- changeWt && changeWt.worktree
1638
- ? `\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}`
1639
- : `\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}`;
1640
- throw new UserError("Failed to fast-forward merge change branch into target.", { details: `${merge.stderr || merge.stdout}${extra}` });
1408
+ throw new UserError("Failed to fast-forward merge change branch into target.", {
1409
+ details:
1410
+ `${merge.stderr || merge.stdout}` +
1411
+ `\n\nIf not fast-forward, rebase the change branch then retry:\n` +
1412
+ ` git switch ${changeBranch}\n git rebase ${into}\n git switch ${into}\n aiws change finish ${changeId}`,
1413
+ });
1641
1414
  }
1642
1415
 
1643
1416
  await appendMetricsEvent(gitRoot, changeId, "finish_local", finishBasePayload);
@@ -1651,7 +1424,7 @@ export async function changeFinishCommand(options) {
1651
1424
  let handoffRel = "";
1652
1425
  if (options.push === true) {
1653
1426
  try {
1654
- const submodulePush = await pushSubmodulesForFinish(gitRoot, changeId, into, changeWt?.worktree, {
1427
+ const submodulePush = await pushSubmodulesForFinish(gitRoot, changeId, into, {
1655
1428
  pathExists,
1656
1429
  readText,
1657
1430
  runCommand,
@@ -1676,37 +1449,9 @@ export async function changeFinishCommand(options) {
1676
1449
 
1677
1450
  console.log(`push: target ${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""}`);
1678
1451
 
1679
- if (changeWt?.worktree) {
1680
- const cleanup = await cleanupWorktreePath(gitRoot, changeWt.worktree, {
1681
- checkGitClean,
1682
- listGitWorktrees,
1683
- pathExists,
1684
- runCommand,
1685
- UserError,
1686
- });
1687
- cleanupNote = cleanup.status === "skipped" ? `skipped:${cleanup.reason || "unknown"}` : cleanup.status;
1688
- finishCompleted =
1689
- cleanup.status === "removed" ||
1690
- cleanup.status === "pruned-missing" ||
1691
- (cleanup.status === "skipped" && cleanup.reason === "current_worktree");
1692
- if (cleanup.status === "removed") {
1693
- console.log(`cleanup: removed worktree ${cleanup.worktree}`);
1694
- } else if (cleanup.status === "pruned-missing") {
1695
- console.log(`cleanup: pruned stale worktree metadata for ${cleanup.worktree}`);
1696
- } else if (cleanup.reason === "current_worktree") {
1697
- console.log("cleanup: skipped (change worktree is current worktree)");
1698
- } else if (cleanup.reason === "locked") {
1699
- console.log(`cleanup: skipped locked worktree ${cleanup.worktree}`);
1700
- } else if (cleanup.reason === "dirty") {
1701
- console.log(`cleanup: skipped dirty worktree ${cleanup.worktree}`);
1702
- } else {
1703
- console.log("cleanup: skipped");
1704
- }
1705
- } else {
1706
- cleanupNote = "skipped:no_separate_change_worktree";
1707
- finishCompleted = true;
1708
- console.log("cleanup: skipped (no separate change worktree)");
1709
- }
1452
+ cleanupNote = "skipped:current_worktree";
1453
+ finishCompleted = true;
1454
+ console.log("cleanup: skipped (no separate worktree)");
1710
1455
  } catch (error) {
1711
1456
  await appendMetricsEvent(gitRoot, changeId, "finish_failed", {
1712
1457
  ...finishBasePayload,
@@ -1727,7 +1472,7 @@ export async function changeFinishCommand(options) {
1727
1472
  details:
1728
1473
  `change: ${changeId}\n` +
1729
1474
  `next: cleanup pending (${cleanupReason || "unknown"})\n` +
1730
- ` - resolve the change worktree state, then rerun: aiws change finish ${changeId} --push${options.remote ? ` --remote ${options.remote}` : ""}`,
1475
+ ` - then rerun: aiws change finish ${changeId} --push${options.remote ? ` --remote ${options.remote}` : ""}`,
1731
1476
  });
1732
1477
  }
1733
1478
 
@@ -1788,7 +1533,7 @@ export async function changeFinishCommand(options) {
1788
1533
 
1789
1534
  if (options.push === true && (await listSubmodulesFromGitmodules(gitRoot, { pathExists, runCommand })).length > 0) {
1790
1535
  console.log("next:");
1791
- console.log(" - submodules already synced for finish --push; rerun `git submodule update --init --recursive` only if another worktree changed them later");
1536
+ console.log(" - submodules already synced for finish --push; rerun `git submodule update --init --recursive` only if needed when submodule state changes");
1792
1537
  }
1793
1538
  }
1794
1539
 
@@ -1804,12 +1549,10 @@ export async function changeStatusCommand(options) {
1804
1549
  const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "status" });
1805
1550
  const st = await computeChangeStatus(gitRoot, changeId);
1806
1551
  const branch = await currentBranch(gitRoot);
1807
- const changeWorktreeHint = await resolveChangeWorktreeHint(gitRoot, changeId, { listGitWorktrees });
1808
1552
  const output = await renderChangeStatusOutput(
1809
1553
  {
1810
1554
  gitRoot,
1811
1555
  branch,
1812
- changeWorktreeHint,
1813
1556
  status: st,
1814
1557
  },
1815
1558
  {
@@ -1865,7 +1608,6 @@ export async function computeChangeNextAdvice(gitRoot, changeId) {
1865
1608
  computeChangeStatus,
1866
1609
  currentBranch,
1867
1610
  inferChangeIdFromBranch,
1868
- listGitWorktrees,
1869
1611
  changeDirAbs,
1870
1612
  governanceGuidanceLines,
1871
1613
  scopeGateFailureAction,