@aipper/aiws 0.0.28 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/README.md +18 -2
  2. package/bin/aiws.js +2 -2
  3. package/dist/aiws-package.d.ts +4 -0
  4. package/dist/aiws-package.js +11 -0
  5. package/dist/aiws-package.js.map +1 -0
  6. package/dist/aiws-paths.d.ts +48 -0
  7. package/dist/aiws-paths.js +129 -0
  8. package/dist/aiws-paths.js.map +1 -0
  9. package/dist/backup.d.ts +27 -0
  10. package/dist/backup.js +125 -0
  11. package/dist/backup.js.map +1 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +773 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/codex-prompts.d.ts +17 -0
  16. package/dist/codex-prompts.js +49 -0
  17. package/dist/codex-prompts.js.map +1 -0
  18. package/dist/codex-skills.d.ts +17 -0
  19. package/dist/codex-skills.js +75 -0
  20. package/dist/codex-skills.js.map +1 -0
  21. package/dist/commands/change-advice.d.ts +25 -0
  22. package/dist/commands/change-advice.js +197 -0
  23. package/dist/commands/change-advice.js.map +1 -0
  24. package/dist/commands/change-evidence-command.d.ts +29 -0
  25. package/dist/commands/change-evidence-command.js +213 -0
  26. package/dist/commands/change-evidence-command.js.map +1 -0
  27. package/dist/commands/change-evidence-entry.d.ts +16 -0
  28. package/dist/commands/change-evidence-entry.js +35 -0
  29. package/dist/commands/change-evidence-entry.js.map +1 -0
  30. package/dist/commands/change-evidence.d.ts +108 -0
  31. package/dist/commands/change-evidence.js +341 -0
  32. package/dist/commands/change-evidence.js.map +1 -0
  33. package/dist/commands/change-finish.d.ts +71 -0
  34. package/dist/commands/change-finish.js +387 -0
  35. package/dist/commands/change-finish.js.map +1 -0
  36. package/dist/commands/change-git-status.d.ts +22 -0
  37. package/dist/commands/change-git-status.js +50 -0
  38. package/dist/commands/change-git-status.js.map +1 -0
  39. package/dist/commands/change-lifecycle-entry.d.ts +29 -0
  40. package/{src → dist}/commands/change-lifecycle-entry.js +21 -28
  41. package/dist/commands/change-lifecycle-entry.js.map +1 -0
  42. package/dist/commands/change-lifecycle.d.ts +56 -0
  43. package/dist/commands/change-lifecycle.js +211 -0
  44. package/dist/commands/change-lifecycle.js.map +1 -0
  45. package/dist/commands/change-metrics-command.d.ts +12 -0
  46. package/dist/commands/change-metrics-command.js +110 -0
  47. package/dist/commands/change-metrics-command.js.map +1 -0
  48. package/dist/commands/change-next-command.d.ts +18 -0
  49. package/dist/commands/change-next-command.js +138 -0
  50. package/dist/commands/change-next-command.js.map +1 -0
  51. package/dist/commands/change-review-gates.d.ts +107 -0
  52. package/dist/commands/change-review-gates.js +329 -0
  53. package/dist/commands/change-review-gates.js.map +1 -0
  54. package/dist/commands/change-scope-gate.d.ts +21 -0
  55. package/dist/commands/change-scope-gate.js +32 -0
  56. package/dist/commands/change-scope-gate.js.map +1 -0
  57. package/dist/commands/change-start.d.ts +30 -0
  58. package/dist/commands/change-start.js +91 -0
  59. package/dist/commands/change-start.js.map +1 -0
  60. package/dist/commands/change-state-command.d.ts +3 -0
  61. package/dist/commands/change-state-command.js +32 -0
  62. package/dist/commands/change-state-command.js.map +1 -0
  63. package/dist/commands/change-status-command.d.ts +13 -0
  64. package/dist/commands/change-status-command.js +126 -0
  65. package/dist/commands/change-status-command.js.map +1 -0
  66. package/dist/commands/change-status-context.d.ts +58 -0
  67. package/dist/commands/change-status-context.js +166 -0
  68. package/dist/commands/change-status-context.js.map +1 -0
  69. package/dist/commands/change-task-stats.d.ts +24 -0
  70. package/dist/commands/change-task-stats.js +66 -0
  71. package/dist/commands/change-task-stats.js.map +1 -0
  72. package/dist/commands/change-validate-entry.d.ts +17 -0
  73. package/dist/commands/change-validate-entry.js +44 -0
  74. package/dist/commands/change-validate-entry.js.map +1 -0
  75. package/dist/commands/change-validate.d.ts +32 -0
  76. package/dist/commands/change-validate.js +128 -0
  77. package/dist/commands/change-validate.js.map +1 -0
  78. package/dist/commands/change.d.ts +322 -0
  79. package/dist/commands/change.js +1731 -0
  80. package/dist/commands/change.js.map +1 -0
  81. package/dist/commands/codex-install-prompts.d.ts +4 -0
  82. package/dist/commands/codex-install-prompts.js +68 -0
  83. package/dist/commands/codex-install-prompts.js.map +1 -0
  84. package/dist/commands/codex-install-skills.d.ts +4 -0
  85. package/dist/commands/codex-install-skills.js +74 -0
  86. package/dist/commands/codex-install-skills.js.map +1 -0
  87. package/dist/commands/codex-status-prompts.d.ts +4 -0
  88. package/dist/commands/codex-status-prompts.js +44 -0
  89. package/dist/commands/codex-status-prompts.js.map +1 -0
  90. package/dist/commands/codex-status-skills.d.ts +4 -0
  91. package/dist/commands/codex-status-skills.js +54 -0
  92. package/dist/commands/codex-status-skills.js.map +1 -0
  93. package/dist/commands/codex-uninstall-prompts.d.ts +4 -0
  94. package/dist/commands/codex-uninstall-prompts.js +51 -0
  95. package/dist/commands/codex-uninstall-prompts.js.map +1 -0
  96. package/dist/commands/codex-uninstall-skills.d.ts +4 -0
  97. package/dist/commands/codex-uninstall-skills.js +59 -0
  98. package/dist/commands/codex-uninstall-skills.js.map +1 -0
  99. package/dist/commands/dashboard.d.ts +222 -0
  100. package/dist/commands/dashboard.js +193 -0
  101. package/dist/commands/dashboard.js.map +1 -0
  102. package/dist/commands/hooks-install.d.ts +4 -0
  103. package/dist/commands/hooks-install.js +90 -0
  104. package/dist/commands/hooks-install.js.map +1 -0
  105. package/dist/commands/hooks-status.d.ts +4 -0
  106. package/dist/commands/hooks-status.js +89 -0
  107. package/dist/commands/hooks-status.js.map +1 -0
  108. package/dist/commands/init.d.ts +4 -0
  109. package/dist/commands/init.js +100 -0
  110. package/dist/commands/init.js.map +1 -0
  111. package/dist/commands/opencode-auto.d.ts +11 -0
  112. package/dist/commands/opencode-auto.js +158 -0
  113. package/dist/commands/opencode-auto.js.map +1 -0
  114. package/dist/commands/opencode-status.d.ts +4 -0
  115. package/dist/commands/opencode-status.js +75 -0
  116. package/dist/commands/opencode-status.js.map +1 -0
  117. package/dist/commands/opencode-supervise.d.ts +24 -0
  118. package/dist/commands/opencode-supervise.js +188 -0
  119. package/dist/commands/opencode-supervise.js.map +1 -0
  120. package/dist/commands/rollback.d.ts +4 -0
  121. package/dist/commands/rollback.js +12 -0
  122. package/dist/commands/rollback.js.map +1 -0
  123. package/dist/commands/update.d.ts +4 -0
  124. package/dist/commands/update.js +212 -0
  125. package/dist/commands/update.js.map +1 -0
  126. package/dist/commands/validate.d.ts +4 -0
  127. package/dist/commands/validate.js +222 -0
  128. package/dist/commands/validate.js.map +1 -0
  129. package/dist/commands/ws-commit.d.ts +5 -0
  130. package/dist/commands/ws-commit.js +39 -0
  131. package/dist/commands/ws-commit.js.map +1 -0
  132. package/dist/commands/ws-deliver.d.ts +5 -0
  133. package/dist/commands/ws-deliver.js +59 -0
  134. package/dist/commands/ws-deliver.js.map +1 -0
  135. package/dist/commands/ws-finish.d.ts +5 -0
  136. package/dist/commands/ws-finish.js +142 -0
  137. package/dist/commands/ws-finish.js.map +1 -0
  138. package/dist/commands/ws-handoff.d.ts +5 -0
  139. package/dist/commands/ws-handoff.js +35 -0
  140. package/dist/commands/ws-handoff.js.map +1 -0
  141. package/dist/commands/ws-migrate.d.ts +5 -0
  142. package/dist/commands/ws-migrate.js +41 -0
  143. package/dist/commands/ws-migrate.js.map +1 -0
  144. package/dist/commands/ws-plan-verify.d.ts +5 -0
  145. package/dist/commands/ws-plan-verify.js +38 -0
  146. package/dist/commands/ws-plan-verify.js.map +1 -0
  147. package/dist/commands/ws-pull.d.ts +5 -0
  148. package/dist/commands/ws-pull.js +26 -0
  149. package/dist/commands/ws-pull.js.map +1 -0
  150. package/dist/commands/ws-push.d.ts +5 -0
  151. package/dist/commands/ws-push.js +68 -0
  152. package/dist/commands/ws-push.js.map +1 -0
  153. package/dist/commands/ws-submodule-setup.d.ts +5 -0
  154. package/dist/commands/ws-submodule-setup.js +55 -0
  155. package/dist/commands/ws-submodule-setup.js.map +1 -0
  156. package/dist/commands/ws-verify-before-complete.d.ts +5 -0
  157. package/dist/commands/ws-verify-before-complete.js +35 -0
  158. package/dist/commands/ws-verify-before-complete.js.map +1 -0
  159. package/dist/dashboard/app.d.ts +1 -0
  160. package/dist/dashboard/app.js +370 -0
  161. package/dist/dashboard/app.js.map +1 -0
  162. package/dist/delegation/cli.d.ts +6 -0
  163. package/dist/delegation/cli.js +14 -0
  164. package/dist/delegation/cli.js.map +1 -0
  165. package/dist/delegation/index.d.ts +35 -0
  166. package/dist/delegation/index.js +118 -0
  167. package/dist/delegation/index.js.map +1 -0
  168. package/dist/errors.d.ts +8 -0
  169. package/dist/errors.js +11 -0
  170. package/dist/errors.js.map +1 -0
  171. package/dist/exec.d.ts +9 -0
  172. package/dist/exec.js +22 -0
  173. package/dist/exec.js.map +1 -0
  174. package/dist/fs.d.ts +9 -0
  175. package/dist/fs.js +62 -0
  176. package/dist/fs.js.map +1 -0
  177. package/dist/governance.d.ts +10 -0
  178. package/dist/governance.js +135 -0
  179. package/dist/governance.js.map +1 -0
  180. package/dist/hash.d.ts +3 -0
  181. package/dist/hash.js +13 -0
  182. package/dist/hash.js.map +1 -0
  183. package/dist/hooks/deploy-claude.d.ts +13 -0
  184. package/dist/hooks/deploy-claude.js +67 -0
  185. package/dist/hooks/deploy-claude.js.map +1 -0
  186. package/dist/journal.d.ts +22 -0
  187. package/dist/journal.js +112 -0
  188. package/dist/journal.js.map +1 -0
  189. package/dist/json-schema-lite.d.ts +22 -0
  190. package/dist/json-schema-lite.js +148 -0
  191. package/dist/json-schema-lite.js.map +1 -0
  192. package/dist/managed-blocks.d.ts +24 -0
  193. package/dist/managed-blocks.js +84 -0
  194. package/dist/managed-blocks.js.map +1 -0
  195. package/dist/manifest.d.ts +24 -0
  196. package/dist/manifest.js +145 -0
  197. package/dist/manifest.js.map +1 -0
  198. package/dist/opencode-env.d.ts +62 -0
  199. package/dist/opencode-env.js +178 -0
  200. package/dist/opencode-env.js.map +1 -0
  201. package/dist/path-utils.d.ts +2 -0
  202. package/dist/path-utils.js +8 -0
  203. package/dist/path-utils.js.map +1 -0
  204. package/dist/spec.d.ts +17 -0
  205. package/dist/spec.js +174 -0
  206. package/dist/spec.js.map +1 -0
  207. package/dist/template.d.ts +20 -0
  208. package/dist/template.js +123 -0
  209. package/dist/template.js.map +1 -0
  210. package/dist/workspace.d.ts +4 -0
  211. package/dist/workspace.js +19 -0
  212. package/dist/workspace.js.map +1 -0
  213. package/package.json +11 -3
  214. package/src/aiws-package.js +0 -15
  215. package/src/backup.js +0 -149
  216. package/src/cli.js +0 -647
  217. package/src/codex-prompts.js +0 -74
  218. package/src/codex-skills.js +0 -111
  219. package/src/commands/change-advice.js +0 -200
  220. package/src/commands/change-evidence-command.js +0 -242
  221. package/src/commands/change-evidence-entry.js +0 -40
  222. package/src/commands/change-evidence.js +0 -340
  223. package/src/commands/change-finish.js +0 -484
  224. package/src/commands/change-git-status.js +0 -53
  225. package/src/commands/change-lifecycle.js +0 -236
  226. package/src/commands/change-metrics-command.js +0 -110
  227. package/src/commands/change-next-command.js +0 -129
  228. package/src/commands/change-review-gates.js +0 -315
  229. package/src/commands/change-scope-gate.js +0 -34
  230. package/src/commands/change-start.js +0 -120
  231. package/src/commands/change-state-command.js +0 -40
  232. package/src/commands/change-status-command.js +0 -134
  233. package/src/commands/change-status-context.js +0 -202
  234. package/src/commands/change-task-stats.js +0 -64
  235. package/src/commands/change-validate-entry.js +0 -45
  236. package/src/commands/change-validate.js +0 -127
  237. package/src/commands/change.js +0 -2087
  238. package/src/commands/codex-install-prompts.js +0 -68
  239. package/src/commands/codex-install-skills.js +0 -74
  240. package/src/commands/codex-status-prompts.js +0 -55
  241. package/src/commands/codex-status-skills.js +0 -63
  242. package/src/commands/codex-uninstall-prompts.js +0 -55
  243. package/src/commands/codex-uninstall-skills.js +0 -62
  244. package/src/commands/dashboard.js +0 -205
  245. package/src/commands/hooks-install.js +0 -92
  246. package/src/commands/hooks-status.js +0 -95
  247. package/src/commands/init.js +0 -93
  248. package/src/commands/opencode-status.js +0 -61
  249. package/src/commands/rollback.js +0 -13
  250. package/src/commands/update.js +0 -133
  251. package/src/commands/validate.js +0 -222
  252. package/src/dashboard/app.js +0 -371
  253. package/src/errors.js +0 -15
  254. package/src/exec.js +0 -34
  255. package/src/fs.js +0 -91
  256. package/src/governance.js +0 -159
  257. package/src/hash.js +0 -25
  258. package/src/json-schema-lite.js +0 -164
  259. package/src/managed-blocks.js +0 -131
  260. package/src/manifest.js +0 -153
  261. package/src/opencode-env.js +0 -76
  262. package/src/path-utils.js +0 -20
  263. package/src/spec.js +0 -195
  264. package/src/template.js +0 -187
  265. package/src/workspace.js +0 -23
  266. /package/{src → dist}/dashboard/index.html +0 -0
  267. /package/{src → dist}/dashboard/style.css +0 -0
@@ -1,2087 +0,0 @@
1
- import crypto from "node:crypto";
2
- import path from "node:path";
3
- import fs from "node:fs/promises";
4
- import { runCommand } from "../exec.js";
5
- import { pathExists, readText, writeText, ensureDir } from "../fs.js";
6
- import { UserError } from "../errors.js";
7
- import { loadTemplate } from "../spec.js";
8
- import { effectiveReviewCount, governanceGuidanceLines, inferChangeGovernance } from "../governance.js";
9
- import { copyTemplateFileToWorkspace } from "../template.js";
10
- import { collectGitStatus } from "./change-git-status.js";
11
- import {
12
- analyzeEvidencePaths,
13
- collectCollaborationArtifacts,
14
- planVerifyHint,
15
- relFromRoot,
16
- } from "./change-evidence.js";
17
- import { runChangeEvidenceCommand } from "./change-evidence-entry.js";
18
- import { runChangeEvidenceWorkflow } from "./change-evidence-command.js";
19
- import { runChangeArchiveCommand, runChangeSyncCommand } from "./change-lifecycle-entry.js";
20
- import { runChangeArchiveWorkflow, runChangeSyncWorkflow } from "./change-lifecycle.js";
21
- import { renderMetricsSummaryOutput } from "./change-metrics-command.js";
22
- import { renderChangeNextOutput } from "./change-next-command.js";
23
- import { runChangeStateCommand } from "./change-state-command.js";
24
- import { checkboxStats } from "./change-task-stats.js";
25
- import { runChangeValidateCommand } from "./change-validate-entry.js";
26
- import {
27
- computeChangeNextAdvice as computeChangeNextAdviceModel,
28
- renderChangeStateMarkdown,
29
- resolveChangeWorktreeHint,
30
- } from "./change-advice.js";
31
- import {
32
- cleanupWorktreePath,
33
- isGitRepository,
34
- listSubmodulesFromGitmodules,
35
- pushSubmodulesForFinish,
36
- resolveFinishContext,
37
- resolvePushTarget,
38
- } from "./change-finish.js";
39
- import {
40
- checkWorktreePrereqs,
41
- maybeRecordBaseBranch,
42
- prepareNoSwitchBranch,
43
- resolveDefaultWorktreeDir,
44
- switchOrCreateStartBranch,
45
- } from "./change-start.js";
46
- import {
47
- collectFinishGateErrors,
48
- collectReviewGates,
49
- collectReviewSignals,
50
- } from "./change-review-gates.js";
51
- import {
52
- computeScopeGate,
53
- scopeGateFailureAction,
54
- scopeGatePassedRecommendation,
55
- scopeGatePendingRecommendation,
56
- } from "./change-scope-gate.js";
57
- import {
58
- readChangeMetrics,
59
- resolveChangeStatusContext,
60
- summarizeLifecycleSignals,
61
- truthDrift,
62
- } from "./change-status-context.js";
63
- import { renderChangeStatusOutput } from "./change-status-command.js";
64
- import { validateChangeArtifacts as validateChangeArtifactsModel } from "./change-validate.js";
65
- import { hooksInstallCommand } from "./hooks-install.js";
66
-
67
- const CHANGE_BRANCH_RE = /^(change|changes|ws|ws-change)\/([a-z0-9]+(?:-[a-z0-9]+)*)$/;
68
- const CHANGE_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
69
- /**
70
- * @param {string} changeId
71
- */
72
- export function assertValidChangeId(changeId) {
73
- if (!changeId || !CHANGE_ID_RE.test(changeId)) {
74
- throw new UserError(`Invalid change id (use kebab-case): ${changeId}`);
75
- }
76
- }
77
-
78
- /**
79
- * @returns {string} e.g. 2026-01-28T14:00:26Z
80
- */
81
- function nowIsoUtc() {
82
- return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
83
- }
84
-
85
- /**
86
- * @returns {number} unix seconds
87
- */
88
- function nowUnixSeconds() {
89
- return Math.floor(Date.now() / 1000);
90
- }
91
-
92
- /**
93
- * @returns {string} e.g. 20260128-140026Z
94
- */
95
- function nowStampUtc() {
96
- const d = new Date();
97
- const y = String(d.getUTCFullYear());
98
- const m = String(d.getUTCMonth() + 1).padStart(2, "0");
99
- const day = String(d.getUTCDate()).padStart(2, "0");
100
- const hh = String(d.getUTCHours()).padStart(2, "0");
101
- const mm = String(d.getUTCMinutes()).padStart(2, "0");
102
- const ss = String(d.getUTCSeconds()).padStart(2, "0");
103
- return `${y}${m}${day}-${hh}${mm}${ss}Z`;
104
- }
105
-
106
- /**
107
- * @returns {string} e.g. 2026-01-28 (local date)
108
- */
109
- function todayLocal() {
110
- const d = new Date();
111
- const y = String(d.getFullYear());
112
- const m = String(d.getMonth() + 1).padStart(2, "0");
113
- const day = String(d.getDate()).padStart(2, "0");
114
- return `${y}-${m}-${day}`;
115
- }
116
-
117
- /**
118
- * @param {string} absPath
119
- */
120
- async function resolveGitRoot(absPath) {
121
- let res;
122
- try {
123
- res = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd: absPath });
124
- } catch (e) {
125
- throw new UserError("git is required for aiws change commands.", { details: e instanceof Error ? e.message : String(e) });
126
- }
127
- if (res.code !== 0) {
128
- throw new UserError("Not a git repository.", { details: res.stderr || res.stdout });
129
- }
130
- const root = String(res.stdout || "").trim();
131
- if (!root) throw new UserError("Failed to resolve git repository root.");
132
- return root;
133
- }
134
-
135
- /**
136
- * @param {string} gitRoot
137
- * @returns {Promise<string>} branch or empty
138
- */
139
- async function currentBranch(gitRoot) {
140
- const res = await runCommand("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: gitRoot });
141
- if (res.code !== 0) return "";
142
- return String(res.stdout || "").trim();
143
- }
144
-
145
- /**
146
- * @param {string} gitRoot
147
- */
148
- async function hasHeadCommit(gitRoot) {
149
- return runCommand("git", ["rev-parse", "--verify", "HEAD"], { cwd: gitRoot }).then((r) => r.code === 0);
150
- }
151
-
152
- /**
153
- * @param {string} gitRoot
154
- * @returns {Promise<{ clean: true } | { clean: false, details: string }>}
155
- */
156
- async function checkGitClean(gitRoot) {
157
- const res = await runCommand("git", ["status", "--porcelain"], { cwd: gitRoot });
158
- const out = String(res.stdout || "").trim();
159
- if (!out) return { clean: true };
160
- const lines = out.split("\n");
161
- const truncated = lines.length > 20 ? `${lines.slice(0, 20).join("\n")}\n... (truncated)` : out;
162
- return { clean: false, details: truncated };
163
- }
164
-
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
- /**
210
- * @param {string} branch
211
- * @returns {string}
212
- */
213
- function inferChangeIdFromBranch(branch) {
214
- const m = CHANGE_BRANCH_RE.exec(branch || "");
215
- return m?.[2] || "";
216
- }
217
-
218
- /**
219
- * @param {string} gitRoot
220
- */
221
- async function ensureTruthFiles(gitRoot) {
222
- const required = ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"];
223
- const missing = [];
224
- for (const f of required) {
225
- if (!(await pathExists(path.join(gitRoot, f)))) missing.push(f);
226
- }
227
- if (missing.length > 0) {
228
- throw new UserError("AI Workspace truth files missing.", {
229
- details: `Root: ${gitRoot}\nMissing:\n${missing.map((m) => `- ${m}`).join("\n")}\n\nHint: run \`aiws init .\` (new) or \`aiws update .\` (migrate).`,
230
- });
231
- }
232
- }
233
-
234
- /**
235
- * @param {string} filePath
236
- * @returns {Promise<string>}
237
- */
238
- async function sha256FileBytes(filePath) {
239
- const buf = await fs.readFile(filePath);
240
- return crypto.createHash("sha256").update(buf).digest("hex");
241
- }
242
-
243
- /**
244
- * @param {string} gitRoot
245
- * @returns {Promise<Record<string, {mtime: number, sha256: string}>>}
246
- */
247
- async function snapshotTruthFiles(gitRoot) {
248
- /** @type {Record<string, {mtime: number, sha256: string}>} */
249
- const truth = {};
250
- for (const rel of ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"]) {
251
- const abs = path.join(gitRoot, rel);
252
- if (!(await pathExists(abs))) continue;
253
- const st = await fs.stat(abs);
254
- truth[rel] = { mtime: Math.floor(st.mtimeMs / 1000), sha256: await sha256FileBytes(abs) };
255
- }
256
- return truth;
257
- }
258
-
259
- /**
260
- * @param {string} gitRoot
261
- * @returns {Promise<Record<string, string | null>>}
262
- */
263
- async function snapshotTruthShaOnly(gitRoot) {
264
- /** @type {Record<string, string | null>} */
265
- const out = {};
266
- for (const rel of ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"]) {
267
- const abs = path.join(gitRoot, rel);
268
- if (!(await pathExists(abs))) {
269
- out[rel] = null;
270
- continue;
271
- }
272
- try {
273
- out[rel] = await sha256FileBytes(abs);
274
- } catch {
275
- out[rel] = null;
276
- }
277
- }
278
- return out;
279
- }
280
-
281
- /**
282
- * @param {string} s
283
- */
284
- function escapeRegExp(s) {
285
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
286
- }
287
-
288
- /**
289
- * @param {string} label
290
- * @param {string} text
291
- */
292
- function extractId(label, text) {
293
- const re = new RegExp(`^.*${escapeRegExp(label)}.*?[:=][ \\t]*(.*)$`, "m");
294
- const m = re.exec(text);
295
- if (!m) return "";
296
- let v = String(m[1] || "").trim();
297
- v = v.replace(/<!--.*?-->/g, "").trim();
298
- v = v.replace(/^`+/, "").replace(/`+$/, "").trim();
299
- return v;
300
- }
301
-
302
- /**
303
- * @param {string} s
304
- */
305
- function splitDeclaredValues(s) {
306
- return String(s || "")
307
- .split(/[,;\n]+/g)
308
- .map((x) => x.trim().replace(/^`+/, "").replace(/`+$/, "").trim())
309
- .filter(Boolean);
310
- }
311
-
312
- /**
313
- * Extract bullet lines under a markdown "## ..." heading.
314
- *
315
- * @param {string} text
316
- * @param {RegExp} headingRe
317
- * @param {{ max?: number }} [options]
318
- * @returns {string[]} trimmed bullet lines like "- foo"
319
- */
320
- function extractBulletsUnderHeading(text, headingRe, options) {
321
- const max = typeof options?.max === "number" ? options.max : 5;
322
- const lines = String(text || "").split("\n");
323
- let inSection = false;
324
- /** @type {string[]} */
325
- const out = [];
326
- for (const raw of lines) {
327
- const line = String(raw || "");
328
- const trimmed = line.trim();
329
- if (!inSection) {
330
- if (headingRe.test(trimmed)) inSection = true;
331
- continue;
332
- }
333
- if (/^##\s+/.test(trimmed)) break;
334
- if (trimmed.startsWith("-")) {
335
- out.push(trimmed);
336
- if (out.length >= max) break;
337
- }
338
- }
339
- return out;
340
- }
341
-
342
- /**
343
- * @param {string} bulletLine
344
- */
345
- function isMeaningfulBullet(bulletLine) {
346
- const s = String(bulletLine || "").trim();
347
- if (!s.startsWith("-")) return false;
348
- if (s.includes("WS:TODO")) return false;
349
- if (s.includes("<!--")) return false;
350
- if (s === "-" || s === "- (none)" || s === "- (missing)") return false;
351
- return true;
352
- }
353
-
354
- /**
355
- * @param {string} entryName
356
- * @returns {{ datePrefix: string, changeId: string, stamp: string } | null}
357
- */
358
- function parseArchivedChangeDirName(entryName) {
359
- const m = /^(\d{4}-\d{2}-\d{2})-(.+?)(?:-(\d{8}-\d{6}Z))?$/.exec(String(entryName || ""));
360
- if (!m) return null;
361
- const changeId = m[2] || "";
362
- if (!CHANGE_ID_RE.test(changeId)) return null;
363
- return {
364
- datePrefix: m[1] || "",
365
- changeId,
366
- stamp: m[3] || "",
367
- };
368
- }
369
-
370
- /**
371
- * Find an archived change by ID in the archive directory.
372
- * @param {string} archiveDir
373
- * @param {string} changeId
374
- * @returns {Promise<string | null>} Full path to archived change dir, or null if not found.
375
- */
376
- async function findArchivedChange(archiveDir, changeId) {
377
- if (!(await pathExists(archiveDir))) return null;
378
- try {
379
- const entries = await fs.readdir(archiveDir, { withFileTypes: true });
380
- const matches = entries
381
- .filter((e) => e.isDirectory())
382
- .map((e) => ({ entry: e, parsed: parseArchivedChangeDirName(e.name) }))
383
- .filter((item) => item.parsed?.changeId === changeId)
384
- .sort((a, b) => a.entry.name.localeCompare(b.entry.name));
385
- if (matches.length > 0) return path.join(archiveDir, matches.at(-1)?.entry.name || "");
386
- } catch {
387
- // ignore
388
- }
389
- return null;
390
- }
391
-
392
- /**
393
- * @param {string} gitRoot
394
- * @param {string} changeId
395
- * @returns {Promise<null | { kind: "archived" | "finished_unarchived", archivedChangeDir?: string, archivedHandoffRel?: string, lifecycle?: any }>}
396
- */
397
- async function detectTerminatedChange(gitRoot, changeId) {
398
- const changeDir = changeDirAbs(gitRoot, changeId);
399
- const archiveRoot = path.join(gitRoot, "changes", "archive");
400
- const activeExists = await pathExists(changeDir);
401
- if (!activeExists) {
402
- const archivedChangeDir = await findArchivedChange(archiveRoot, changeId);
403
- if (!archivedChangeDir) return null;
404
- const archivedHandoffPath = path.join(archivedChangeDir, "handoff.md");
405
- return {
406
- kind: "archived",
407
- archivedChangeDir,
408
- archivedHandoffRel: (await pathExists(archivedHandoffPath)) ? path.relative(gitRoot, archivedHandoffPath) : "",
409
- };
410
- }
411
-
412
- const lifecycle = summarizeLifecycleSignals(await readChangeMetrics(changeDir, { pathExists, readText }));
413
- if (lifecycle.finishState !== "done") return null;
414
- return { kind: "finished_unarchived", lifecycle };
415
- }
416
-
417
- /**
418
- * @param {string} gitRoot
419
- * @param {string} changeId
420
- * @param {Awaited<ReturnType<typeof detectTerminatedChange>>} terminated
421
- * @param {string} action
422
- */
423
- function terminatedChangeError(gitRoot, changeId, terminated, action) {
424
- if (terminated?.kind === "archived") {
425
- return new UserError(`Change ${changeId} is already archived.`, {
426
- details:
427
- `change: ${changeId}\n` +
428
- `archived: ${path.relative(gitRoot, terminated.archivedChangeDir || "")}\n` +
429
- (terminated.archivedHandoffRel ? `handoff: ${terminated.archivedHandoffRel}\n` : "") +
430
- `next: create a new follow-up change instead of reusing change/${changeId}` +
431
- (action ? `\nblocked: ${action}` : ""),
432
- });
433
- }
434
- return new UserError(`Change ${changeId} has unfinished finish closeout; resume finish before any follow-up work.`, {
435
- details:
436
- `change: ${changeId}\n` +
437
- `branch: change/${changeId}\n` +
438
- `next: rerun \`aiws change finish ${changeId} --push\` to complete push/archive closeout` +
439
- `\nfallback: if you intentionally only need local archive recovery, run \`aiws change archive ${changeId}\`` +
440
- (action ? `\nblocked: ${action}` : ""),
441
- });
442
- }
443
-
444
- /**
445
- * @param {string} command
446
- * @param {Awaited<ReturnType<typeof detectTerminatedChange>>} terminated
447
- * @param {{ allowFinishedUnarchived?: boolean }} [options]
448
- */
449
- function isTerminatedAllowed(command, terminated, options = {}) {
450
- if (!terminated) return true;
451
- if (terminated.kind === "finished_unarchived") {
452
- if (command === "archive") return true;
453
- if (command === "finish") return true;
454
- if (command === "status" || command === "next") return true;
455
- if (options.allowFinishedUnarchived === true) return true;
456
- }
457
- return false;
458
- }
459
-
460
- /**
461
- * Extract a brief summary from handoff.md content.
462
- * @param {string} text
463
- * @returns {string}
464
- */
465
- function extractHandoffSummary(text) {
466
- // Try to extract "本次完成" or "What Changed" section
467
- const lines = String(text || "").split("\n");
468
- let inSection = false;
469
- const summary = [];
470
- for (const line of lines) {
471
- if (/^##\s*(本次完成|What Changed|Completed)/i.test(line)) {
472
- inSection = true;
473
- continue;
474
- }
475
- if (inSection && /^##/.test(line)) break;
476
- if (inSection && line.trim().startsWith("-")) {
477
- summary.push(line.trim());
478
- if (summary.length >= 3) break;
479
- }
480
- }
481
- return summary.join(" ");
482
- }
483
-
484
- /**
485
- * Check Depends_On dependencies and print handoff summaries.
486
- * @param {string} gitRoot - The git root directory (main or worktree)
487
- * @param {string} changeId
488
- * @param {string} changeDir - The change directory (may be in worktree)
489
- */
490
- async function checkDependenciesAndPrintHandoff(gitRoot, changeId, changeDir) {
491
- const proposalPath = path.join(changeDir, "proposal.md");
492
- if (!(await pathExists(proposalPath))) return;
493
-
494
- const proposalText = await readText(proposalPath);
495
- const dependsOn = extractId("Depends_On", proposalText);
496
- if (!dependsOn) return;
497
-
498
- const deps = splitDeclaredValues(dependsOn).filter((d) => d && d !== changeId);
499
- if (deps.length === 0) return;
500
- const archiveDir = path.join(gitRoot, "changes", "archive");
501
-
502
- for (const dep of deps) {
503
- const depDir = changeDirAbs(gitRoot, dep);
504
- const depArchived = await findArchivedChange(archiveDir, dep);
505
- if (!depArchived && !(await pathExists(depDir))) {
506
- console.error(`warn: Depends_On "${dep}" not found (neither active nor archived)`);
507
- console.error(`hint: if you are using --worktree, ensure the dependency is archived under changes/archive/ and committed to HEAD in this worktree.`);
508
- } else if (!depArchived) {
509
- console.error(`warn: Depends_On "${dep}" is not yet archived (still active at changes/${dep})`);
510
- } else {
511
- // Dependency is archived, check for handoff.md
512
- const handoffPath = path.join(depArchived, "handoff.md");
513
- if (await pathExists(handoffPath)) {
514
- console.log(`info: reading handoff from dependency "${dep}"`);
515
- const handoffText = await readText(handoffPath);
516
- const summary = extractHandoffSummary(handoffText);
517
- if (summary) {
518
- console.log(` ${summary}`);
519
- }
520
- } else {
521
- console.error(`warn: dependency "${dep}" is archived but missing handoff.md: ${path.relative(gitRoot, handoffPath)}`);
522
- }
523
- }
524
- }
525
- }
526
-
527
- /**
528
- * Generate handoff.md content for a completed change.
529
- * @param {string} gitRoot
530
- * @param {string} changeDir - The archived change directory
531
- * @param {string} changeId
532
- * @returns {Promise<string>}
533
- */
534
- async function generateHandoffContent(gitRoot, changeDir, changeId) {
535
- const proposalPath = path.join(changeDir, "proposal.md");
536
- const designPath = path.join(changeDir, "design.md");
537
-
538
- let reqId = "";
539
- let problemId = "";
540
- let blocks = "";
541
- let goal = "";
542
- let baseBranch = "main";
543
- /** @type {string[]} */
544
- const decisions = [];
545
- const collaboration = await collectCollaborationArtifacts(gitRoot, changeDir);
546
-
547
- if (await pathExists(proposalPath)) {
548
- const text = await readText(proposalPath);
549
- reqId = extractId("Req_ID", text);
550
- problemId = extractId("Problem_ID", text);
551
- blocks = extractId("Blocks", text);
552
- const declaredBase = extractId("Base_Branch", text);
553
- if (declaredBase) baseBranch = declaredBase;
554
- // Extract goal from 目标 section
555
- const goalMatch = text.match(/\*\*目标[::]\*\*\s*\n([\s\S]*?)(?=\n\*\*非目标|$)/);
556
- if (goalMatch) {
557
- const goalLines = goalMatch[1].split("\n").filter((l) => l.trim().startsWith("-")).slice(0, 3);
558
- goal = goalLines.map((l) => l.trim()).join("\n");
559
- }
560
- }
561
-
562
- // Get changed files from git diff if possible
563
- let changedFiles = [];
564
- try {
565
- const metaPath = path.join(changeDir, ".ws-change.json");
566
- if (await pathExists(metaPath)) {
567
- const meta = JSON.parse(await readText(metaPath));
568
- if (meta && typeof meta === "object" && meta.base_branch) baseBranch = String(meta.base_branch || "").trim() || baseBranch;
569
- }
570
-
571
- const changeBranch = `change/${changeId}`;
572
- const hasChangeBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${changeBranch}`], { cwd: gitRoot }).then((r) => r.code === 0);
573
- const cur = await currentBranch(gitRoot);
574
- const diffTarget = hasChangeBranch ? changeBranch : cur === changeBranch ? "HEAD" : "";
575
- if (diffTarget) {
576
- const diff = await runCommand("git", ["diff", "--name-only", `${baseBranch}...${diffTarget}`], { cwd: gitRoot });
577
- if (diff.code === 0 && diff.stdout) {
578
- changedFiles = diff.stdout.split("\n").filter(Boolean).slice(0, 20);
579
- }
580
- }
581
- } catch {
582
- // ignore
583
- }
584
-
585
- try {
586
- if (await pathExists(designPath)) {
587
- const designText = await readText(designPath);
588
- decisions.push(...extractBulletsUnderHeading(designText, /^##\s*Decisions\s*$/i, { max: 12 }).filter(isMeaningfulBullet).slice(0, 8));
589
- decisions.push(...extractBulletsUnderHeading(designText, /^##\s*关键决策\s*$/i, { max: 12 }).filter(isMeaningfulBullet).slice(0, 8));
590
- } else if (await pathExists(proposalPath)) {
591
- const proposalText = await readText(proposalPath);
592
- const fromProposal = extractBulletsUnderHeading(proposalText, /^##\s*方案概述\b.*$/i, { max: 10 }).filter(isMeaningfulBullet).slice(0, 6);
593
- for (const b of fromProposal) decisions.push(`- (from proposal) ${b.replace(/^-+\s*/, "")}`);
594
- }
595
- } catch {
596
- // ignore
597
- }
598
-
599
- const lines = [
600
- `# Handoff: ${changeId}`,
601
- "",
602
- `> Archived: ${nowIsoUtc()}`,
603
- "",
604
- "## 本次完成",
605
- "",
606
- ];
607
-
608
- if (goal) {
609
- lines.push(goal);
610
- } else {
611
- lines.push("- (see proposal.md for details)");
612
- }
613
-
614
- lines.push("");
615
- lines.push("## 改动文件");
616
- lines.push("");
617
- if (changedFiles.length > 0) {
618
- for (const f of changedFiles) {
619
- lines.push(`- ${f}`);
620
- }
621
- if (changedFiles.length === 20) {
622
- lines.push("- ...(truncated)");
623
- }
624
- } else {
625
- lines.push("- (see git log for details)");
626
- }
627
-
628
- lines.push("");
629
- lines.push("## 关键决策");
630
- lines.push("");
631
- if (decisions.length > 0) {
632
- for (const d of decisions) lines.push(d);
633
- } else {
634
- lines.push("- (missing) add decisions to changes/<id>/design.md#Decisions");
635
- }
636
-
637
- lines.push("");
638
- lines.push("## 协同记录");
639
- lines.push("");
640
- if ((collaboration.counts.total || 0) === 0) {
641
- lines.push("- (none)");
642
- } else {
643
- for (const key of ["analysis", "patches", "review", "evidence"]) {
644
- const group = collaboration.dirs[key];
645
- if (!group) continue;
646
- lines.push(`- ${group.label}: ${group.count} file(s)`);
647
- for (const rel of group.files) lines.push(` - ${rel}`);
648
- if (group.truncated) lines.push(" - ...(truncated)");
649
- }
650
- }
651
-
652
- lines.push("");
653
- lines.push("## 下一步建议");
654
- lines.push("");
655
- if (blocks) {
656
- const blockIds = splitDeclaredValues(blocks);
657
- for (const b of blockIds) {
658
- lines.push(`- 可以开始: ${b}`);
659
- }
660
- } else {
661
- lines.push("- (no Blocks declared)");
662
- }
663
-
664
- lines.push("");
665
- lines.push("## 绑定");
666
- lines.push("");
667
- lines.push(`- Change_ID: ${changeId}`);
668
- if (reqId) lines.push(`- Req_ID: ${reqId}`);
669
- if (problemId) lines.push(`- Problem_ID: ${problemId}`);
670
-
671
- return lines.join("\n") + "\n";
672
- }
673
-
674
- /**
675
- * @param {string[]} blockersStrict
676
- * @param {{ unchecked: number }} tasks
677
- */
678
- function inferChangePhase(blockersStrict, tasks) {
679
- if ((blockersStrict || []).length > 0) return "planning";
680
- if ((tasks?.unchecked || 0) > 0) return "dev";
681
- return "deliver";
682
- }
683
-
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
- export async function computeChangeStatus(gitRoot, changeId) {
691
- assertValidChangeId(changeId);
692
- await ensureTruthFiles(gitRoot);
693
-
694
- const changeDir = changeDirAbs(gitRoot, changeId);
695
- if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
696
- const context = await resolveChangeStatusContext(gitRoot, changeId, {
697
- currentBranch,
698
- listGitWorktrees,
699
- pathExists,
700
- isGitRepository: (repoPath) => isGitRepository(repoPath, { pathExists, runCommand }),
701
- inferChangeIdFromBranch,
702
- });
703
-
704
- const proposal = await fileState(changeDir, "proposal.md");
705
- const tasks = await fileState(changeDir, "tasks.md");
706
- const design = await fileState(changeDir, "design.md");
707
- const collaboration = await collectCollaborationArtifacts(gitRoot, changeDir);
708
- const reviewSignals = await collectReviewSignals(gitRoot, changeId, context, collaboration);
709
- const reviewGates = await collectReviewGates(gitRoot, changeId, context);
710
- const git = await collectGitStatus(context.worktree);
711
- const submodules = await listSubmodulesFromGitmodules(gitRoot, { pathExists, runCommand });
712
- const metrics = await readChangeMetrics(changeDir, { pathExists, readText });
713
-
714
- const metaPath = path.join(changeDir, ".ws-change.json");
715
- let metaState = "missing";
716
- /** @type {any} */
717
- let meta = null;
718
- if (await pathExists(metaPath)) {
719
- metaState = "ok";
720
- try {
721
- meta = JSON.parse(await readText(metaPath));
722
- } catch {
723
- metaState = "invalid";
724
- meta = null;
725
- }
726
- }
727
-
728
- const reqId = proposal.state === "ok" ? extractId("Req_ID", proposal.text) : "";
729
- const probId = proposal.state === "ok" ? extractId("Problem_ID", proposal.text) : "";
730
- const contractRow = proposal.state === "ok" ? extractId("Contract_Row", proposal.text) || extractId("Contract_Row(s)", proposal.text) : "";
731
- const planFile = proposal.state === "ok" ? extractId("Plan_File", proposal.text) || extractId("Plan file", proposal.text) : "";
732
- const evidencePath = proposal.state === "ok" ? extractId("Evidence_Path", proposal.text) || extractId("Evidence_Path(s)", proposal.text) : "";
733
- const evidencePaths = splitDeclaredValues(evidencePath);
734
- const evidence = await analyzeEvidencePaths(gitRoot, changeId, evidencePaths);
735
- const taskProgress =
736
- tasks.state === "ok"
737
- ? checkboxStats(tasks.text)
738
- : {
739
- total: 0,
740
- done: 0,
741
- unchecked: 0,
742
- hasCheckboxes: false,
743
- totalAll: 0,
744
- doneAll: 0,
745
- uncheckedAll: 0,
746
- optionalTotal: 0,
747
- optionalUnchecked: 0,
748
- naCount: 0,
749
- };
750
-
751
- const curTruth = await snapshotTruthShaOnly(gitRoot);
752
- const { baselineLabel, baselineAt, driftFiles } = meta ? truthDrift(curTruth, meta) : { baselineLabel: "-", baselineAt: "", driftFiles: [] };
753
-
754
- /** @type {string[]} */
755
- const blockersStrict = [];
756
- /** @type {string[]} */
757
- const blockersArchive = [];
758
-
759
- if (metaState !== "ok") {
760
- blockersStrict.push("missing/invalid .ws-change.json (run `aiws change sync <id>` to regenerate)");
761
- }
762
-
763
- for (const [rel, st] of [
764
- ["proposal.md", proposal],
765
- ["tasks.md", tasks],
766
- ]) {
767
- if (st.state !== "ok") {
768
- blockersStrict.push(`missing/empty ${rel}`);
769
- continue;
770
- }
771
- if (st.placeholders > 0) blockersStrict.push(`unrendered template placeholders in ${rel}`);
772
- if (st.wsTodo > 0) blockersStrict.push(`WS:TODO markers remain in ${rel}`);
773
- }
774
-
775
- if (design.state === "ok") {
776
- if (design.placeholders > 0) blockersStrict.push("unrendered template placeholders in design.md");
777
- if (design.wsTodo > 0) blockersStrict.push("WS:TODO markers remain in design.md");
778
- }
779
-
780
- if (proposal.state === "ok" && !(reqId || probId)) {
781
- blockersStrict.push("proposal.md missing attribution (Req_ID or Problem_ID)");
782
- }
783
-
784
- if (proposal.state === "ok" && !contractRow) blockersStrict.push("proposal.md missing Contract_Row binding");
785
-
786
- if (proposal.state === "ok") {
787
- const planFiles = splitDeclaredValues(planFile);
788
- if (planFiles.length !== 1) blockersStrict.push("proposal.md Plan_File must contain exactly one plan path");
789
- else {
790
- const planRel = String(planFiles[0] || "").replace(/^\.\//, "");
791
- if (path.isAbsolute(planRel)) blockersStrict.push("proposal.md Plan_File must be workspace-relative (not absolute)");
792
- else {
793
- if (!planRel.startsWith("plan/")) blockersStrict.push("proposal.md Plan_File should be under plan/ (recommended)");
794
- if (!(await pathExists(path.join(gitRoot, planRel)))) blockersStrict.push(`proposal.md Plan_File missing: ${planRel}`);
795
- }
796
- }
797
- }
798
-
799
- if (proposal.state === "ok" && evidencePaths.length === 0) blockersStrict.push("proposal.md missing Evidence_Path binding");
800
-
801
- if (!taskProgress.hasCheckboxes) blockersStrict.push("tasks.md has no checkbox tasks ('- [ ]' or '- [x]')");
802
- if (driftFiles.length > 0) blockersStrict.push(`truth drift vs ${baselineLabel} baseline (run \`aiws change sync ${changeId}\`)`);
803
-
804
- const lifecycle = summarizeLifecycleSignals(metrics);
805
- const terminatedReuse =
806
- lifecycle.finishState === "done"
807
- ? {
808
- kind: "finished_unarchived",
809
- message: `change/${changeId} has unfinished finish closeout; rerun \`aiws change finish ${changeId} --push\` to complete archive/push`,
810
- }
811
- : null;
812
- if (terminatedReuse) {
813
- blockersStrict.push(terminatedReuse.message);
814
- }
815
-
816
- blockersArchive.push(...blockersStrict);
817
- if (taskProgress.unchecked > 0) blockersArchive.push(`tasks.md still has unchecked required tasks (${taskProgress.unchecked} items)`);
818
- const phase = inferChangePhase(blockersStrict, taskProgress);
819
- if (terminatedReuse) {
820
- lifecycle.finishState = "resume_required";
821
- lifecycle.finishStateReason = "archive_pending";
822
- }
823
- const scopeGate = computeScopeGate(lifecycle);
824
-
825
- const baseStatus = {
826
- ok: true,
827
- changeId,
828
- dir: path.relative(gitRoot, changeDir),
829
- phase,
830
- context,
831
- metaState,
832
- reqId,
833
- probId,
834
- bindings: {
835
- contractRow,
836
- planFile,
837
- evidencePaths,
838
- },
839
- scopeGate,
840
- evidence,
841
- collaboration,
842
- reviewSignals,
843
- reviewGates,
844
- git,
845
- repo: {
846
- submodules: submodules.length,
847
- },
848
- lifecycle,
849
- tasks: taskProgress,
850
- baselineLabel,
851
- baselineAt,
852
- driftFiles,
853
- blockersStrict,
854
- blockersArchive,
855
- terminatedReuse,
856
- hints: {
857
- planVerify: planVerifyHint(changeId),
858
- dev: "质量门通过后再进入编码:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`",
859
- },
860
- };
861
- const governance = await inferChangeGovernance(baseStatus);
862
- return {
863
- ...baseStatus,
864
- governance: {
865
- ...governance,
866
- warning: context.warning || "",
867
- },
868
- };
869
- }
870
-
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
- export async function validateChangeArtifacts(gitRoot, changeId, options) {
879
- return validateChangeArtifactsModel(gitRoot, changeId, options, {
880
- assertValidChangeId,
881
- collectFinishGateErrors,
882
- computeChangeStatus,
883
- ensureTruthFiles,
884
- resolveWsChangeChecker,
885
- runPython,
886
- });
887
- }
888
-
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
- /**
1056
- * aiws change list
1057
- */
1058
- export async function changeListCommand() {
1059
- const gitRoot = await resolveGitRoot(process.cwd());
1060
- await ensureTruthFiles(gitRoot);
1061
-
1062
- const changesRoot = path.join(gitRoot, "changes");
1063
- if (!(await pathExists(changesRoot))) {
1064
- console.log("(no changes dir)");
1065
- return;
1066
- }
1067
-
1068
- const curTruth = await snapshotTruthShaOnly(gitRoot);
1069
-
1070
- const entries = await fs.readdir(changesRoot, { withFileTypes: true });
1071
- const dirs = entries
1072
- .filter((e) => e.isDirectory())
1073
- .map((e) => e.name)
1074
- .filter((name) => name && !name.startsWith(".") && name !== "archive" && name !== "templates")
1075
- .sort();
1076
-
1077
- if (dirs.length === 0) {
1078
- console.log("(no active changes)");
1079
- return;
1080
- }
1081
-
1082
- /** @type {Array<[string, string, string, string, number, string, string]>} */
1083
- const rows = [];
1084
-
1085
- for (const name of dirs) {
1086
- const dir = path.join(changesRoot, name);
1087
- const proposalPath = path.join(dir, "proposal.md");
1088
- const tasksPath = path.join(dir, "tasks.md");
1089
- const metaPath = path.join(dir, ".ws-change.json");
1090
-
1091
- const metaState = !(await pathExists(metaPath)) ? "missing" : "ok";
1092
- /** @type {any} */
1093
- let meta = null;
1094
- if (metaState === "ok") {
1095
- try {
1096
- meta = JSON.parse(await readText(metaPath));
1097
- } catch {
1098
- meta = null;
1099
- }
1100
- }
1101
- const finalMetaState = meta ? "ok" : metaState === "missing" ? "missing" : "invalid";
1102
-
1103
- let proposalWsTodo = 0;
1104
- if (await pathExists(proposalPath)) {
1105
- try {
1106
- proposalWsTodo = countWsTodo(await readText(proposalPath));
1107
- } catch {
1108
- proposalWsTodo = 0;
1109
- }
1110
- }
1111
-
1112
- let tasksTotal = 0;
1113
- let tasksDone = 0;
1114
- let tasksTodo = 0;
1115
- let tasksWsTodo = 0;
1116
- if (await pathExists(tasksPath)) {
1117
- try {
1118
- const t = await readText(tasksPath);
1119
- const stats = checkboxStats(t);
1120
- tasksTotal = stats.total;
1121
- tasksDone = stats.done;
1122
- tasksTodo = stats.unchecked;
1123
- tasksWsTodo = countWsTodo(t);
1124
- } catch {
1125
- // ignore
1126
- }
1127
- }
1128
-
1129
- const { baselineLabel, driftFiles } = meta ? truthDrift(curTruth, meta) : { baselineLabel: "-", driftFiles: [] };
1130
-
1131
- let nextStep = "archive";
1132
- if (finalMetaState !== "ok") nextStep = "sync";
1133
- else if (driftFiles.length > 0) nextStep = "sync";
1134
- else if (proposalWsTodo + tasksWsTodo > 0) nextStep = "edit";
1135
- else if (tasksTodo > 0) nextStep = "do";
1136
-
1137
- rows.push([
1138
- name,
1139
- nextStep,
1140
- finalMetaState,
1141
- baselineLabel,
1142
- driftFiles.length,
1143
- `${tasksDone}/${tasksTotal} (unchecked=${tasksTodo})`,
1144
- `proposal=${proposalWsTodo} tasks=${tasksWsTodo}`,
1145
- ]);
1146
- }
1147
-
1148
- console.log("change_id\tnext\tmeta\tbaseline\tdrift\ttasks\tWS:TODO");
1149
- for (const r of rows) {
1150
- console.log(r.join("\t"));
1151
- }
1152
- }
1153
-
1154
- /**
1155
- * aiws change templates which
1156
- */
1157
- export async function changeTemplatesWhichCommand() {
1158
- const gitRoot = await resolveGitRoot(process.cwd());
1159
- await ensureTruthFiles(gitRoot);
1160
-
1161
- const templateId = await resolveTemplateIdForRepo(gitRoot);
1162
- const tpl = await loadTemplate(templateId);
1163
- const resolved = await resolveChangeTemplatesDir(gitRoot);
1164
-
1165
- console.log(`templates: ${resolved.templateDir}`);
1166
- console.log(`resolved_from: ${resolved.resolvedFrom}`);
1167
- console.log("precedence:");
1168
- console.log(` 1) ${path.join(gitRoot, "changes", "templates")}`);
1169
- console.log(` 2) ${path.join(gitRoot, "workflow", "changes", "templates")}`);
1170
- console.log(` 3) ${path.join(tpl.templateDir, "changes", "templates")}`);
1171
- }
1172
-
1173
- /**
1174
- * aiws change templates init
1175
- */
1176
- export async function changeTemplatesInitCommand() {
1177
- const gitRoot = await resolveGitRoot(process.cwd());
1178
- await ensureTruthFiles(gitRoot);
1179
-
1180
- const templateId = await resolveTemplateIdForRepo(gitRoot);
1181
- const tpl = await loadTemplate(templateId);
1182
-
1183
- const rels = ["changes/templates/proposal.md", "changes/templates/tasks.md", "changes/templates/design.md"];
1184
- /** @type {string[]} */
1185
- const installed = [];
1186
- /** @type {string[]} */
1187
- const skipped = [];
1188
-
1189
- for (const rel of rels) {
1190
- const destAbs = path.join(gitRoot, ...rel.split("/"));
1191
- if (await pathExists(destAbs)) {
1192
- skipped.push(rel);
1193
- continue;
1194
- }
1195
- await copyTemplateFileToWorkspace({ templateDir: tpl.templateDir, workspaceRoot: gitRoot, relPosix: rel });
1196
- installed.push(rel);
1197
- }
1198
-
1199
- console.log(`✓ aiws change templates init: ${gitRoot}`);
1200
- if (installed.length > 0) console.log(`installed: ${installed.join(", ")}`);
1201
- if (skipped.length > 0) console.log(`skip: ${skipped.join(", ")}`);
1202
- }
1203
-
1204
- /**
1205
- * @param {string} gitRoot
1206
- * @param {{ changeId: string, title?: string, noDesign: boolean, baseBranch?: string }} options
1207
- */
1208
- async function changeNewAtGitRoot(gitRoot, options) {
1209
- await ensureTruthFiles(gitRoot);
1210
-
1211
- const changeId = String(options.changeId || "").trim();
1212
- assertValidChangeId(changeId);
1213
- const terminated = await detectTerminatedChange(gitRoot, changeId);
1214
- if (terminated) throw terminatedChangeError(gitRoot, changeId, terminated, "aiws change new");
1215
-
1216
- const title = (options.title ? String(options.title) : changeId).trim() || changeId;
1217
- const createdAt = nowIsoUtc();
1218
-
1219
- const changeRoot = path.join(gitRoot, "changes");
1220
- await ensureDir(changeRoot);
1221
- const changeDir = path.join(changeRoot, changeId);
1222
- if (await pathExists(changeDir)) throw new UserError(`Change already exists: ${changeDir}`);
1223
- await ensureDir(changeDir);
1224
- await ensureDir(path.join(changeDir, "analysis"));
1225
- await ensureDir(path.join(changeDir, "patches"));
1226
- await ensureDir(path.join(changeDir, "review"));
1227
- await ensureDir(path.join(changeDir, "evidence"));
1228
-
1229
- const templates = await resolveChangeTemplatesDir(gitRoot);
1230
- const proposalTpl = await readText(path.join(templates.templateDir, "proposal.md"));
1231
- const tasksTpl = await readText(path.join(templates.templateDir, "tasks.md"));
1232
- await writeText(path.join(changeDir, "proposal.md"), renderTemplate(proposalTpl, { changeId, title, createdAt }));
1233
- await writeText(path.join(changeDir, "tasks.md"), renderTemplate(tasksTpl, { changeId, title, createdAt }));
1234
-
1235
- if (!options.noDesign) {
1236
- const designTplPath = path.join(templates.templateDir, "design.md");
1237
- if (!(await pathExists(designTplPath))) {
1238
- throw new UserError("Missing design.md in templates dir; pass --no-design to skip.", { details: `templates: ${templates.templateDir}` });
1239
- }
1240
- const designTpl = await readText(designTplPath);
1241
- await writeText(path.join(changeDir, "design.md"), renderTemplate(designTpl, { changeId, title, createdAt }));
1242
- }
1243
-
1244
- const truth = await snapshotTruthFiles(gitRoot);
1245
- const meta = {
1246
- ws_change_version: 1,
1247
- id: changeId,
1248
- title,
1249
- created_at: createdAt,
1250
- base_truth_files: truth,
1251
- };
1252
- const baseBranch = String(options.baseBranch || "").trim();
1253
- if (baseBranch) {
1254
- meta.base_branch = baseBranch;
1255
- meta.base_branch_set_at = createdAt;
1256
- }
1257
- await writeText(path.join(changeDir, ".ws-change.json"), JSON.stringify(meta, null, 2) + "\n");
1258
- await appendMetricsEvent(gitRoot, changeId, "change_new", {
1259
- title,
1260
- created_at: createdAt,
1261
- base_branch: baseBranch || "",
1262
- templates: templates.resolvedFrom,
1263
- no_design: options.noDesign === true,
1264
- });
1265
-
1266
- console.log(`✓ aiws change new: ${changeId}`);
1267
- console.log(`dir: ${path.relative(gitRoot, changeDir)}`);
1268
- console.log(`templates: ${templates.resolvedFrom}`);
1269
- console.log("next:");
1270
- console.log(` - edit: changes/${changeId}/proposal.md`);
1271
- console.log(` - edit: changes/${changeId}/tasks.md`);
1272
- if (!options.noDesign) console.log(` - optional: changes/${changeId}/design.md`);
1273
- console.log(` - optional: changes/${changeId}/analysis/ (委托分析)`);
1274
- console.log(` - optional: changes/${changeId}/patches/ (patch 草案)`);
1275
- console.log(` - optional: changes/${changeId}/review/ (多审查结果)`);
1276
- console.log(` - validate: aiws change validate ${changeId}`);
1277
- }
1278
-
1279
- /**
1280
- * aiws change new
1281
- *
1282
- * @param {{ changeId: string, title?: string, noDesign: boolean }} options
1283
- */
1284
- export async function changeNewCommand(options) {
1285
- const gitRoot = await resolveGitRoot(process.cwd());
1286
- await changeNewAtGitRoot(gitRoot, options);
1287
- }
1288
-
1289
- /**
1290
- * aiws change start
1291
- *
1292
- * @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean, noSwitch: boolean, forceSwitch: boolean, allowDirty?: boolean, worktree: boolean, worktreeDir?: string, submodules: boolean }} options
1293
- */
1294
- export async function changeStartCommand(options) {
1295
- const gitRoot = await resolveGitRoot(process.cwd());
1296
- await ensureTruthFiles(gitRoot);
1297
-
1298
- const changeId = String(options.changeId || "").trim();
1299
- assertValidChangeId(changeId);
1300
- const terminated = await detectTerminatedChange(gitRoot, changeId);
1301
- if (terminated) throw terminatedChangeError(gitRoot, changeId, terminated, "aiws change start");
1302
-
1303
- const startFromBranch = await currentBranch(gitRoot);
1304
- const branch = `change/${changeId}`;
1305
- const branchRef = `refs/heads/${branch}`;
1306
- const allowDirty = options.allowDirty === true;
1307
-
1308
- if (options.worktree === true && options.noSwitch === true) {
1309
- throw new UserError("change start: cannot combine --worktree with --no-switch");
1310
- }
1311
- if (options.forceSwitch === true && options.noSwitch === true) {
1312
- throw new UserError("change start: cannot combine --switch with --no-switch");
1313
- }
1314
- if (options.forceSwitch === true && options.worktree === true) {
1315
- throw new UserError("change start: cannot combine --switch with --worktree");
1316
- }
1317
-
1318
- const hasBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: gitRoot }).then((r) => r.code === 0);
1319
- const hasGitmodules = await pathExists(path.join(gitRoot, ".gitmodules"));
1320
- let effectiveNoSwitch = options.noSwitch === true;
1321
- 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
- }
1341
- } else if (hasGitmodules && effectiveForceSwitch) {
1342
- 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");
1344
- }
1345
-
1346
- if (!allowDirty && (effectiveWorktree || !effectiveNoSwitch)) {
1347
- const clean = await checkGitClean(gitRoot);
1348
- if (!clean.clean) {
1349
- const mode = effectiveWorktree ? "worktree" : "switch";
1350
- throw new UserError(`Refusing to start change with a dirty working tree (${mode} mode).`, {
1351
- details:
1352
- `${clean.details}\n\n` +
1353
- `Why: starting in ${mode} mode changes your checkout context; uncommitted changes may not appear where you expect.\n` +
1354
- "Fix: commit first, then retry:\n" +
1355
- " git add -A && git commit -m \"wip: save before change start\"\n" +
1356
- "Fix: or stash:\n" +
1357
- " git stash\n" +
1358
- `Alt: prepare artifacts without switching: aiws change start ${changeId} --no-switch\n` +
1359
- "Alt: proceed anyway (not recommended): add --allow-dirty",
1360
- });
1361
- }
1362
- }
1363
-
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
- const headReady = await hasHeadCommit(gitRoot);
1498
- let branchReady = hasBranch;
1499
-
1500
- if (effectiveNoSwitch) {
1501
- branchReady = await prepareNoSwitchBranch(gitRoot, { branch, hasBranch, headReady }, { runCommand, UserError });
1502
- } else {
1503
- await switchOrCreateStartBranch(gitRoot, { branch, hasBranch, hasGitmodules }, { runCommand, UserError });
1504
- branchReady = true;
1505
- }
1506
-
1507
- const changeDir = changeDirAbs(gitRoot, changeId);
1508
- if (!(await pathExists(changeDir))) {
1509
- await changeNewAtGitRoot(gitRoot, {
1510
- changeId,
1511
- title: options.title,
1512
- noDesign: options.noDesign,
1513
- baseBranch: startFromBranch && startFromBranch !== branch ? startFromBranch : undefined,
1514
- });
1515
- } else {
1516
- await maybeRecordBaseBranch(changeDir, startFromBranch, branch, { nowIsoUtc, pathExists, readText, writeText });
1517
- }
1518
-
1519
- // Check Depends_On dependencies (non-worktree mode)
1520
- await checkDependenciesAndPrintHandoff(gitRoot, changeId, changeDir);
1521
-
1522
- if (options.enableHooks) {
1523
- await hooksInstallCommand({ targetPath: gitRoot });
1524
- }
1525
-
1526
- const cur = await currentBranch(gitRoot);
1527
- const curLabel = cur || "(detached)";
1528
- await appendMetricsEvent(gitRoot, changeId, "change_start", {
1529
- mode: effectiveNoSwitch ? "no-switch" : "switch",
1530
- worktree: gitRoot,
1531
- branch,
1532
- from_branch: startFromBranch || "",
1533
- current_branch: curLabel,
1534
- hooks: options.enableHooks === true,
1535
- });
1536
- if (effectiveNoSwitch && cur !== branch) {
1537
- console.log(`ok: prepared change: ${changeId} (branch: ${branch}${branchReady ? "" : " (pending)"}; current: ${curLabel})`);
1538
- console.log("next:");
1539
- if (branchReady) {
1540
- console.log(` - switch: git switch ${branch}`);
1541
- } else {
1542
- console.log(" - note: repo has no commits; create the branch after your first commit");
1543
- console.log(` - create: git branch ${branch}`);
1544
- console.log(` - switch: git switch ${branch}`);
1545
- }
1546
- console.log(` - status: aiws change status ${changeId}`);
1547
- console.log(` - next: aiws change next ${changeId}`);
1548
- console.log(` - validate: aiws change validate ${changeId} --strict`);
1549
- if (!options.enableHooks) console.log(" - (optional) enable hooks: aiws hooks install .");
1550
- return;
1551
- }
1552
-
1553
- console.log(`ok: active change: ${changeId} (branch: ${branch}; current: ${curLabel})`);
1554
- console.log("next:");
1555
- console.log(" - status: aiws change status");
1556
- console.log(" - next: aiws change next");
1557
- console.log(" - validate: aiws change validate --strict");
1558
- if (!options.enableHooks) console.log(" - (optional) enable hooks: aiws hooks install .");
1559
- }
1560
-
1561
- /**
1562
- * aiws change finish
1563
- *
1564
- * @param {{ changeId?: string, into?: string, base?: string, push?: boolean, remote?: string }} options
1565
- */
1566
- export async function changeFinishCommand(options) {
1567
- const gitRoot = await resolveGitRoot(process.cwd());
1568
- await ensureTruthFiles(gitRoot);
1569
-
1570
- const resolvedChangeId = await resolveChangeId(gitRoot, options.changeId, { command: "finish" });
1571
- assertValidChangeId(resolvedChangeId);
1572
- const terminated = await detectTerminatedChange(gitRoot, resolvedChangeId);
1573
- if (terminated?.kind === "archived") throw terminatedChangeError(gitRoot, resolvedChangeId, terminated, "aiws change finish");
1574
- const resumeFinishedUnarchived = terminated?.kind === "finished_unarchived";
1575
- if (resumeFinishedUnarchived && options.push !== true) {
1576
- throw new UserError("Change finish closeout is pending; rerun with --push.", {
1577
- details:
1578
- `change: ${resolvedChangeId}\n` +
1579
- `next: rerun \`aiws change finish ${resolvedChangeId} --push\` to complete archive/push closeout\n` +
1580
- `fallback: if you intentionally only need local archive recovery, run \`aiws change archive ${resolvedChangeId}\``,
1581
- });
1582
- }
1583
- const { changeId, changeBranch, into, cur, changeWt } = await resolveFinishContext(gitRoot, { ...options, changeId: resolvedChangeId }, {
1584
- assertValidChangeId,
1585
- checkGitClean,
1586
- currentBranch,
1587
- listGitWorktrees,
1588
- pathExists,
1589
- readText,
1590
- resolveChangeId,
1591
- runCommand,
1592
- UserError,
1593
- });
1594
- const finishStatusRoot = changeWt?.worktree ? path.resolve(changeWt.worktree) : gitRoot;
1595
- const finishStatus = await computeChangeStatus(finishStatusRoot, changeId);
1596
- const finishGateErrors = collectFinishGateErrors(changeId, finishStatus.reviewGates);
1597
- if (finishGateErrors.length > 0) {
1598
- throw new UserError("Refusing to finish without complete review gates.", {
1599
- details:
1600
- `change: ${changeId}\n` +
1601
- "missing:\n" +
1602
- finishGateErrors.map((line) => `- ${line}`).join("\n") +
1603
- `\n\nnext:\n` +
1604
- `- aiws change validate ${changeId} --strict --check-evidence\n` +
1605
- `- aiws change evidence ${changeId}\n` +
1606
- `- re-run finish after all finish gates are green`,
1607
- });
1608
- }
1609
- const finishBasePayload = { into, from: changeBranch, push: options.push === true };
1610
- const archiveWorkflowDeps = {
1611
- appendMetricsEvent,
1612
- changeValidateCommand,
1613
- ensureDir,
1614
- generateHandoffContent,
1615
- nowIsoUtc,
1616
- nowStampUtc,
1617
- nowUnixSeconds,
1618
- pathExists,
1619
- readText,
1620
- snapshotTruthFiles,
1621
- todayLocal,
1622
- writeText,
1623
- UserError,
1624
- };
1625
-
1626
- if (cur && cur !== into) {
1627
- const sw = await runCommand("git", ["switch", into], { cwd: gitRoot });
1628
- if (sw.code !== 0) {
1629
- const co = await runCommand("git", ["checkout", into], { cwd: gitRoot });
1630
- if (co.code !== 0) throw new UserError("Failed to switch target branch.", { details: co.stderr || co.stdout });
1631
- }
1632
- }
1633
-
1634
- const merge = await runCommand("git", ["merge", "--ff-only", changeBranch], { cwd: gitRoot });
1635
- 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}` });
1641
- }
1642
-
1643
- await appendMetricsEvent(gitRoot, changeId, "finish_local", finishBasePayload);
1644
- if (resumeFinishedUnarchived) {
1645
- console.log(`resume: continuing previously finished change ${changeId} to complete remaining finish steps`);
1646
- }
1647
-
1648
- let cleanupNote = "not_requested";
1649
- let finishCompleted = false;
1650
- let archivedToRel = "";
1651
- let handoffRel = "";
1652
- if (options.push === true) {
1653
- try {
1654
- const submodulePush = await pushSubmodulesForFinish(gitRoot, changeId, into, changeWt?.worktree, {
1655
- pathExists,
1656
- readText,
1657
- runCommand,
1658
- UserError,
1659
- });
1660
- if (submodulePush.processed > 0) {
1661
- console.log(`submodule push: ok (${submodulePush.processed} repos)`);
1662
- }
1663
-
1664
- const pushTarget = await resolvePushTarget(gitRoot, into, options.remote, { runCommand });
1665
- const push = await runCommand("git", ["push", pushTarget.remote, pushTarget.refspec], { cwd: gitRoot });
1666
- if (push.code !== 0) {
1667
- throw new UserError("Finished locally, but failed to push target branch.", {
1668
- details:
1669
- `remote: ${pushTarget.remote}\nlocal_branch: ${into}\nremote_branch: ${pushTarget.remoteBranch}\n` +
1670
- (pushTarget.trackedMergeRef ? `tracked_merge: ${pushTarget.trackedMergeRef}\n` : "") +
1671
- `\n${push.stderr || push.stdout}`,
1672
- });
1673
- }
1674
-
1675
- console.log(`push: ok (${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""})`);
1676
-
1677
- console.log(`push: target ${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""}`);
1678
-
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
- }
1710
- } catch (error) {
1711
- await appendMetricsEvent(gitRoot, changeId, "finish_failed", {
1712
- ...finishBasePayload,
1713
- error: error instanceof Error ? error.message : String(error),
1714
- });
1715
- throw error;
1716
- }
1717
- }
1718
-
1719
- if (options.push === true && !finishCompleted) {
1720
- const cleanupReason = cleanupNote.startsWith("skipped:") ? cleanupNote.slice("skipped:".length) : cleanupNote;
1721
- await appendMetricsEvent(gitRoot, changeId, "finish_cleanup_pending", {
1722
- ...finishBasePayload,
1723
- cleanup: cleanupNote,
1724
- reason: cleanupReason,
1725
- });
1726
- throw new UserError("Change finish incomplete: cleanup pending.", {
1727
- details:
1728
- `change: ${changeId}\n` +
1729
- `next: cleanup pending (${cleanupReason || "unknown"})\n` +
1730
- ` - resolve the change worktree state, then rerun: aiws change finish ${changeId} --push${options.remote ? ` --remote ${options.remote}` : ""}`,
1731
- });
1732
- }
1733
-
1734
- if (finishCompleted) {
1735
- const archiveResult = await runChangeArchiveWorkflow(
1736
- {
1737
- gitRoot,
1738
- changeId,
1739
- changeDir: changeDirAbs(gitRoot, changeId),
1740
- force: false,
1741
- },
1742
- archiveWorkflowDeps,
1743
- );
1744
- archivedToRel = archiveResult.archivedToRel;
1745
- handoffRel = archiveResult.handoffRel;
1746
- await appendMetricsEventAtDir(archiveResult.archivedTo, changeId, "finish_done", { ...finishBasePayload, cleanup: cleanupNote });
1747
-
1748
- if (options.push === true) {
1749
- const stageArchive = await runCommand("git", ["add", "-A", "--", "changes"], { cwd: gitRoot });
1750
- if (stageArchive.code !== 0) {
1751
- throw new UserError("Finish completed, but failed to stage archived change artifacts.", {
1752
- details: stageArchive.stderr || stageArchive.stdout,
1753
- });
1754
- }
1755
-
1756
- const stagedArchive = await runCommand("git", ["diff", "--cached", "--quiet", "--", "changes"], { cwd: gitRoot });
1757
- if (stagedArchive.code !== 0) {
1758
- const archiveCommit = await runCommand("git", ["commit", "-m", `归档: ${changeId}`], { cwd: gitRoot });
1759
- if (archiveCommit.code !== 0) {
1760
- throw new UserError("Finish completed, but failed to commit archived change artifacts.", {
1761
- details: `archived_to: ${archivedToRel}\nhandoff: ${handoffRel}\n\n${archiveCommit.stderr || archiveCommit.stdout}`,
1762
- });
1763
- }
1764
-
1765
- const archivePushTarget = await resolvePushTarget(gitRoot, into, options.remote, { runCommand });
1766
- const archivePush = await runCommand("git", ["push", archivePushTarget.remote, archivePushTarget.refspec], { cwd: gitRoot });
1767
- if (archivePush.code !== 0) {
1768
- throw new UserError("Finish completed, but failed to push archived change artifacts.", {
1769
- details:
1770
- `archived_to: ${archivedToRel}\n` +
1771
- `handoff: ${handoffRel}\n` +
1772
- `remote: ${archivePushTarget.remote}\nlocal_branch: ${into}\nremote_branch: ${archivePushTarget.remoteBranch}\n` +
1773
- (archivePushTarget.trackedMergeRef ? `tracked_merge: ${archivePushTarget.trackedMergeRef}\n` : "") +
1774
- `\n${archivePush.stderr || archivePush.stdout}`,
1775
- });
1776
- }
1777
- }
1778
- }
1779
- }
1780
-
1781
- const outcomeLabel =
1782
- options.push === true ? (finishCompleted ? "finished change" : "pushed change; cleanup pending") : "finished locally";
1783
- console.log(`ok: ${outcomeLabel}: ${changeId}`);
1784
- console.log(`into: ${into}`);
1785
- console.log(`from: ${changeBranch}`);
1786
- if (archivedToRel) console.log(`archived_to: ${archivedToRel}`);
1787
- if (handoffRel) console.log(`handoff: ${handoffRel}`);
1788
-
1789
- if (options.push === true && (await listSubmodulesFromGitmodules(gitRoot, { pathExists, runCommand })).length > 0) {
1790
- 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");
1792
- }
1793
- }
1794
-
1795
- /**
1796
- * aiws change status
1797
- *
1798
- * @param {{ changeId?: string }} options
1799
- */
1800
- export async function changeStatusCommand(options) {
1801
- const gitRoot = await resolveGitRoot(process.cwd());
1802
- await ensureTruthFiles(gitRoot);
1803
-
1804
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "status" });
1805
- const st = await computeChangeStatus(gitRoot, changeId);
1806
- const branch = await currentBranch(gitRoot);
1807
- const changeWorktreeHint = await resolveChangeWorktreeHint(gitRoot, changeId, { listGitWorktrees });
1808
- const output = await renderChangeStatusOutput(
1809
- {
1810
- gitRoot,
1811
- branch,
1812
- changeWorktreeHint,
1813
- status: st,
1814
- },
1815
- {
1816
- effectiveReviewCount,
1817
- governanceGuidanceLines,
1818
- planVerifyHint,
1819
- },
1820
- );
1821
- process.stdout.write(output);
1822
- }
1823
-
1824
- /**
1825
- * aiws change next
1826
- *
1827
- * @param {{ changeId?: string }} options
1828
- */
1829
- export async function changeNextCommand(options) {
1830
- const gitRoot = await resolveGitRoot(process.cwd());
1831
- await ensureTruthFiles(gitRoot);
1832
-
1833
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "next" });
1834
- assertValidChangeId(changeId);
1835
- const st = await computeChangeStatus(gitRoot, changeId);
1836
-
1837
- const changeDir = changeDirAbs(gitRoot, changeId);
1838
- if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
1839
- const output = await renderChangeNextOutput(
1840
- {
1841
- gitRoot,
1842
- changeId,
1843
- changeDir,
1844
- status: st,
1845
- },
1846
- {
1847
- analyzeEvidencePaths,
1848
- computeChangeNextAdvice,
1849
- extractId,
1850
- fileState,
1851
- pathExists,
1852
- planVerifyHint,
1853
- splitDeclaredValues,
1854
- },
1855
- );
1856
- process.stdout.write(output);
1857
- }
1858
-
1859
- /**
1860
- * @param {string} gitRoot
1861
- * @param {string} changeId
1862
- */
1863
- export async function computeChangeNextAdvice(gitRoot, changeId) {
1864
- return computeChangeNextAdviceModel(gitRoot, changeId, {
1865
- computeChangeStatus,
1866
- currentBranch,
1867
- inferChangeIdFromBranch,
1868
- listGitWorktrees,
1869
- changeDirAbs,
1870
- governanceGuidanceLines,
1871
- scopeGateFailureAction,
1872
- scopeGatePendingRecommendation: (id) => scopeGatePendingRecommendation(id),
1873
- scopeGatePassedRecommendation,
1874
- });
1875
- }
1876
-
1877
- /**
1878
- * aiws change state
1879
- *
1880
- * @param {{ changeId?: string, write: boolean }} options
1881
- */
1882
- export async function changeStateCommand(options) {
1883
- const gitRoot = await resolveGitRoot(process.cwd());
1884
- await ensureTruthFiles(gitRoot);
1885
-
1886
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "state" });
1887
- assertValidChangeId(changeId);
1888
- const result = await runChangeStateCommand(
1889
- {
1890
- gitRoot,
1891
- changeId,
1892
- options,
1893
- },
1894
- {
1895
- appendMetricsEvent,
1896
- changeDirAbs,
1897
- computeChangeNextAdvice,
1898
- computeChangeStatus,
1899
- nowIsoUtc,
1900
- planVerifyHint,
1901
- renderChangeStateMarkdown,
1902
- writeText,
1903
- },
1904
- );
1905
- process.stdout.write(result.output);
1906
- }
1907
-
1908
- /**
1909
- * aiws metrics summary
1910
- *
1911
- * @param {{ since?: string }} options
1912
- */
1913
- export async function metricsSummaryCommand(options) {
1914
- const gitRoot = await resolveGitRoot(process.cwd());
1915
- await ensureTruthFiles(gitRoot);
1916
-
1917
- const sinceRaw = String(options.since || "").trim();
1918
- const since = sinceRaw ? new Date(`${sinceRaw}T00:00:00Z`).getTime() : 0;
1919
- if (sinceRaw && Number.isNaN(since)) throw new UserError("Invalid --since (expected YYYY-MM-DD).", { details: `since=${sinceRaw}` });
1920
- const output = await renderMetricsSummaryOutput(
1921
- {
1922
- gitRoot,
1923
- sinceRaw,
1924
- since,
1925
- },
1926
- {
1927
- pathExists,
1928
- readText,
1929
- },
1930
- );
1931
- process.stdout.write(output);
1932
- }
1933
-
1934
- /**
1935
- * aiws change evidence
1936
- *
1937
- * @param {{ changeId?: string, noValidate: boolean, allowFail: boolean }} options
1938
- */
1939
- export async function changeEvidenceCommand(options) {
1940
- const gitRoot = await resolveGitRoot(process.cwd());
1941
- await ensureTruthFiles(gitRoot);
1942
-
1943
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "evidence" });
1944
- assertValidChangeId(changeId);
1945
- const result = await runChangeEvidenceCommand(
1946
- {
1947
- gitRoot,
1948
- changeId,
1949
- options,
1950
- },
1951
- {
1952
- changeDirAbs,
1953
- fileState,
1954
- pathExists,
1955
- runChangeEvidenceWorkflow,
1956
- workflowDeps: {
1957
- appendMetricsEvent,
1958
- computeChangeStatus,
1959
- currentBranch,
1960
- ensureDir,
1961
- extractId,
1962
- nowIsoUtc,
1963
- nowStampUtc,
1964
- pathExists,
1965
- planVerifyHint,
1966
- readText,
1967
- splitDeclaredValues,
1968
- validateChangeArtifacts,
1969
- writeText,
1970
- UserError,
1971
- },
1972
- UserError,
1973
- },
1974
- );
1975
- process.stdout.write(result.output);
1976
- if (result.errorToThrow) throw result.errorToThrow;
1977
- }
1978
-
1979
- /**
1980
- * aiws change validate
1981
- *
1982
- * @param {{ changeId?: string, strict: boolean, allowTruthDrift: boolean, checkEvidence: boolean, checkScope: boolean, allowFinishedUnarchived?: boolean }} options
1983
- */
1984
- export async function changeValidateCommand(options) {
1985
- const gitRoot = await resolveGitRoot(process.cwd());
1986
- await ensureTruthFiles(gitRoot);
1987
-
1988
- const changeId = await resolveChangeId(gitRoot, options.changeId, {
1989
- command: "validate",
1990
- allowFinishedUnarchived: options.allowFinishedUnarchived === true,
1991
- });
1992
- assertValidChangeId(changeId);
1993
- const result = await runChangeValidateCommand(
1994
- {
1995
- gitRoot,
1996
- changeId,
1997
- options,
1998
- },
1999
- {
2000
- appendMetricsEvent,
2001
- validateChangeArtifacts,
2002
- UserError,
2003
- },
2004
- );
2005
- if (result.stderr) process.stderr.write(result.stderr);
2006
- if (result.errorToThrow) throw result.errorToThrow;
2007
- process.stdout.write(result.output);
2008
- }
2009
-
2010
- /**
2011
- * aiws change sync
2012
- *
2013
- * @param {{ changeId?: string }} options
2014
- */
2015
- export async function changeSyncCommand(options) {
2016
- const gitRoot = await resolveGitRoot(process.cwd());
2017
- await ensureTruthFiles(gitRoot);
2018
-
2019
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "sync" });
2020
- assertValidChangeId(changeId);
2021
- const result = await runChangeSyncCommand(
2022
- {
2023
- gitRoot,
2024
- changeId,
2025
- },
2026
- {
2027
- changeDirAbs,
2028
- pathExists,
2029
- runChangeSyncWorkflow,
2030
- workflowDeps: {
2031
- appendMetricsEvent,
2032
- ensureDir,
2033
- nowIsoUtc,
2034
- nowStampUtc,
2035
- nowUnixSeconds,
2036
- pathExists,
2037
- readText,
2038
- snapshotTruthFiles,
2039
- writeText,
2040
- },
2041
- UserError,
2042
- },
2043
- );
2044
- process.stdout.write(result.output);
2045
- }
2046
-
2047
- /**
2048
- * aiws change archive
2049
- *
2050
- * @param {{ changeId?: string, datePrefix?: string, force: boolean }} options
2051
- */
2052
- export async function changeArchiveCommand(options) {
2053
- const gitRoot = await resolveGitRoot(process.cwd());
2054
- await ensureTruthFiles(gitRoot);
2055
-
2056
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "archive" });
2057
- assertValidChangeId(changeId);
2058
- const result = await runChangeArchiveCommand(
2059
- {
2060
- gitRoot,
2061
- changeId,
2062
- options,
2063
- },
2064
- {
2065
- changeDirAbs,
2066
- pathExists,
2067
- runChangeArchiveWorkflow,
2068
- workflowDeps: {
2069
- appendMetricsEvent,
2070
- changeValidateCommand,
2071
- ensureDir,
2072
- generateHandoffContent,
2073
- nowIsoUtc,
2074
- nowStampUtc,
2075
- nowUnixSeconds,
2076
- pathExists,
2077
- readText,
2078
- snapshotTruthFiles,
2079
- todayLocal,
2080
- writeText,
2081
- UserError,
2082
- },
2083
- UserError,
2084
- },
2085
- );
2086
- process.stdout.write(result.output);
2087
- }