@aipper/aiws 0.0.29 → 0.0.31

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 (278) hide show
  1. package/bin/aiws.js +2 -2
  2. package/dist/aiws-package.d.ts +4 -0
  3. package/dist/aiws-package.js +11 -0
  4. package/dist/aiws-package.js.map +1 -0
  5. package/dist/aiws-paths.d.ts +48 -0
  6. package/dist/aiws-paths.js +129 -0
  7. package/dist/aiws-paths.js.map +1 -0
  8. package/dist/backup.d.ts +27 -0
  9. package/dist/backup.js +125 -0
  10. package/dist/backup.js.map +1 -0
  11. package/dist/cli.d.ts +1 -0
  12. package/dist/cli.js +773 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/codex-prompts.d.ts +17 -0
  15. package/dist/codex-prompts.js +49 -0
  16. package/dist/codex-prompts.js.map +1 -0
  17. package/dist/codex-skills.d.ts +17 -0
  18. package/dist/codex-skills.js +75 -0
  19. package/dist/codex-skills.js.map +1 -0
  20. package/dist/commands/change-advice.d.ts +25 -0
  21. package/dist/commands/change-advice.js +197 -0
  22. package/dist/commands/change-advice.js.map +1 -0
  23. package/dist/commands/change-evidence-command.d.ts +29 -0
  24. package/dist/commands/change-evidence-command.js +213 -0
  25. package/dist/commands/change-evidence-command.js.map +1 -0
  26. package/dist/commands/change-evidence-entry.d.ts +16 -0
  27. package/dist/commands/change-evidence-entry.js +35 -0
  28. package/dist/commands/change-evidence-entry.js.map +1 -0
  29. package/dist/commands/change-evidence.d.ts +108 -0
  30. package/dist/commands/change-evidence.js +341 -0
  31. package/dist/commands/change-evidence.js.map +1 -0
  32. package/dist/commands/change-finish.d.ts +71 -0
  33. package/dist/commands/change-finish.js +387 -0
  34. package/dist/commands/change-finish.js.map +1 -0
  35. package/dist/commands/change-git-status.d.ts +22 -0
  36. package/dist/commands/change-git-status.js +50 -0
  37. package/dist/commands/change-git-status.js.map +1 -0
  38. package/dist/commands/change-lifecycle-entry.d.ts +29 -0
  39. package/{src → dist}/commands/change-lifecycle-entry.js +21 -28
  40. package/dist/commands/change-lifecycle-entry.js.map +1 -0
  41. package/dist/commands/change-lifecycle.d.ts +56 -0
  42. package/dist/commands/change-lifecycle.js +211 -0
  43. package/dist/commands/change-lifecycle.js.map +1 -0
  44. package/dist/commands/change-metrics-command.d.ts +12 -0
  45. package/dist/commands/change-metrics-command.js +110 -0
  46. package/dist/commands/change-metrics-command.js.map +1 -0
  47. package/dist/commands/change-next-command.d.ts +18 -0
  48. package/dist/commands/change-next-command.js +138 -0
  49. package/dist/commands/change-next-command.js.map +1 -0
  50. package/dist/commands/change-review-gates.d.ts +107 -0
  51. package/dist/commands/change-review-gates.js +329 -0
  52. package/dist/commands/change-review-gates.js.map +1 -0
  53. package/dist/commands/change-scope-gate.d.ts +21 -0
  54. package/dist/commands/change-scope-gate.js +32 -0
  55. package/dist/commands/change-scope-gate.js.map +1 -0
  56. package/dist/commands/change-start.d.ts +30 -0
  57. package/dist/commands/change-start.js +91 -0
  58. package/dist/commands/change-start.js.map +1 -0
  59. package/dist/commands/change-state-command.d.ts +3 -0
  60. package/dist/commands/change-state-command.js +32 -0
  61. package/dist/commands/change-state-command.js.map +1 -0
  62. package/dist/commands/change-status-command.d.ts +13 -0
  63. package/dist/commands/change-status-command.js +126 -0
  64. package/dist/commands/change-status-command.js.map +1 -0
  65. package/dist/commands/change-status-context.d.ts +58 -0
  66. package/dist/commands/change-status-context.js +166 -0
  67. package/dist/commands/change-status-context.js.map +1 -0
  68. package/dist/commands/change-task-stats.d.ts +24 -0
  69. package/dist/commands/change-task-stats.js +66 -0
  70. package/dist/commands/change-task-stats.js.map +1 -0
  71. package/dist/commands/change-validate-entry.d.ts +17 -0
  72. package/dist/commands/change-validate-entry.js +44 -0
  73. package/dist/commands/change-validate-entry.js.map +1 -0
  74. package/dist/commands/change-validate.d.ts +32 -0
  75. package/dist/commands/change-validate.js +128 -0
  76. package/dist/commands/change-validate.js.map +1 -0
  77. package/dist/commands/change.d.ts +322 -0
  78. package/dist/commands/change.js +1731 -0
  79. package/dist/commands/change.js.map +1 -0
  80. package/dist/commands/codex-install-prompts.d.ts +4 -0
  81. package/dist/commands/codex-install-prompts.js +68 -0
  82. package/dist/commands/codex-install-prompts.js.map +1 -0
  83. package/dist/commands/codex-install-skills.d.ts +4 -0
  84. package/dist/commands/codex-install-skills.js +74 -0
  85. package/dist/commands/codex-install-skills.js.map +1 -0
  86. package/dist/commands/codex-status-prompts.d.ts +4 -0
  87. package/dist/commands/codex-status-prompts.js +44 -0
  88. package/dist/commands/codex-status-prompts.js.map +1 -0
  89. package/dist/commands/codex-status-skills.d.ts +4 -0
  90. package/dist/commands/codex-status-skills.js +54 -0
  91. package/dist/commands/codex-status-skills.js.map +1 -0
  92. package/dist/commands/codex-uninstall-prompts.d.ts +4 -0
  93. package/dist/commands/codex-uninstall-prompts.js +51 -0
  94. package/dist/commands/codex-uninstall-prompts.js.map +1 -0
  95. package/dist/commands/codex-uninstall-skills.d.ts +4 -0
  96. package/dist/commands/codex-uninstall-skills.js +59 -0
  97. package/dist/commands/codex-uninstall-skills.js.map +1 -0
  98. package/dist/commands/dashboard.d.ts +222 -0
  99. package/dist/commands/dashboard.js +193 -0
  100. package/dist/commands/dashboard.js.map +1 -0
  101. package/dist/commands/hooks-install.d.ts +4 -0
  102. package/dist/commands/hooks-install.js +90 -0
  103. package/dist/commands/hooks-install.js.map +1 -0
  104. package/dist/commands/hooks-status.d.ts +4 -0
  105. package/dist/commands/hooks-status.js +89 -0
  106. package/dist/commands/hooks-status.js.map +1 -0
  107. package/dist/commands/init.d.ts +4 -0
  108. package/dist/commands/init.js +100 -0
  109. package/dist/commands/init.js.map +1 -0
  110. package/dist/commands/opencode-auto.d.ts +11 -0
  111. package/dist/commands/opencode-auto.js +158 -0
  112. package/dist/commands/opencode-auto.js.map +1 -0
  113. package/dist/commands/opencode-status.d.ts +4 -0
  114. package/dist/commands/opencode-status.js +75 -0
  115. package/dist/commands/opencode-status.js.map +1 -0
  116. package/dist/commands/opencode-supervise.d.ts +24 -0
  117. package/dist/commands/opencode-supervise.js +188 -0
  118. package/dist/commands/opencode-supervise.js.map +1 -0
  119. package/dist/commands/rollback.d.ts +4 -0
  120. package/dist/commands/rollback.js +12 -0
  121. package/dist/commands/rollback.js.map +1 -0
  122. package/dist/commands/update.d.ts +4 -0
  123. package/dist/commands/update.js +212 -0
  124. package/dist/commands/update.js.map +1 -0
  125. package/dist/commands/validate.d.ts +4 -0
  126. package/dist/commands/validate.js +222 -0
  127. package/dist/commands/validate.js.map +1 -0
  128. package/dist/commands/ws-commit.d.ts +5 -0
  129. package/dist/commands/ws-commit.js +39 -0
  130. package/dist/commands/ws-commit.js.map +1 -0
  131. package/dist/commands/ws-deliver.d.ts +5 -0
  132. package/dist/commands/ws-deliver.js +59 -0
  133. package/dist/commands/ws-deliver.js.map +1 -0
  134. package/dist/commands/ws-finish.d.ts +5 -0
  135. package/dist/commands/ws-finish.js +142 -0
  136. package/dist/commands/ws-finish.js.map +1 -0
  137. package/dist/commands/ws-handoff.d.ts +5 -0
  138. package/dist/commands/ws-handoff.js +35 -0
  139. package/dist/commands/ws-handoff.js.map +1 -0
  140. package/dist/commands/ws-migrate.d.ts +5 -0
  141. package/dist/commands/ws-migrate.js +41 -0
  142. package/dist/commands/ws-migrate.js.map +1 -0
  143. package/dist/commands/ws-plan-verify.d.ts +5 -0
  144. package/dist/commands/ws-plan-verify.js +38 -0
  145. package/dist/commands/ws-plan-verify.js.map +1 -0
  146. package/dist/commands/ws-pull.d.ts +5 -0
  147. package/dist/commands/ws-pull.js +26 -0
  148. package/dist/commands/ws-pull.js.map +1 -0
  149. package/dist/commands/ws-push.d.ts +5 -0
  150. package/dist/commands/ws-push.js +68 -0
  151. package/dist/commands/ws-push.js.map +1 -0
  152. package/dist/commands/ws-submodule-setup.d.ts +5 -0
  153. package/dist/commands/ws-submodule-setup.js +55 -0
  154. package/dist/commands/ws-submodule-setup.js.map +1 -0
  155. package/dist/commands/ws-verify-before-complete.d.ts +5 -0
  156. package/dist/commands/ws-verify-before-complete.js +35 -0
  157. package/dist/commands/ws-verify-before-complete.js.map +1 -0
  158. package/dist/dashboard/app.d.ts +1 -0
  159. package/dist/dashboard/app.js +370 -0
  160. package/dist/dashboard/app.js.map +1 -0
  161. package/dist/delegation/cli.d.ts +6 -0
  162. package/dist/delegation/cli.js +14 -0
  163. package/dist/delegation/cli.js.map +1 -0
  164. package/dist/delegation/index.d.ts +35 -0
  165. package/dist/delegation/index.js +118 -0
  166. package/dist/delegation/index.js.map +1 -0
  167. package/dist/errors.d.ts +8 -0
  168. package/dist/errors.js +11 -0
  169. package/dist/errors.js.map +1 -0
  170. package/dist/exec.d.ts +9 -0
  171. package/dist/exec.js +22 -0
  172. package/dist/exec.js.map +1 -0
  173. package/dist/fs.d.ts +9 -0
  174. package/dist/fs.js +62 -0
  175. package/dist/fs.js.map +1 -0
  176. package/dist/governance.d.ts +10 -0
  177. package/dist/governance.js +135 -0
  178. package/dist/governance.js.map +1 -0
  179. package/dist/hash.d.ts +3 -0
  180. package/dist/hash.js +13 -0
  181. package/dist/hash.js.map +1 -0
  182. package/dist/hooks/deploy-claude.d.ts +13 -0
  183. package/dist/hooks/deploy-claude.js +67 -0
  184. package/dist/hooks/deploy-claude.js.map +1 -0
  185. package/dist/journal.d.ts +22 -0
  186. package/dist/journal.js +112 -0
  187. package/dist/journal.js.map +1 -0
  188. package/dist/json-schema-lite.d.ts +22 -0
  189. package/dist/json-schema-lite.js +148 -0
  190. package/dist/json-schema-lite.js.map +1 -0
  191. package/dist/managed-blocks.d.ts +24 -0
  192. package/dist/managed-blocks.js +84 -0
  193. package/dist/managed-blocks.js.map +1 -0
  194. package/dist/manifest.d.ts +24 -0
  195. package/dist/manifest.js +145 -0
  196. package/dist/manifest.js.map +1 -0
  197. package/dist/opencode-env.d.ts +62 -0
  198. package/dist/opencode-env.js +178 -0
  199. package/dist/opencode-env.js.map +1 -0
  200. package/dist/path-utils.d.ts +2 -0
  201. package/dist/path-utils.js +8 -0
  202. package/dist/path-utils.js.map +1 -0
  203. package/dist/spec.d.ts +17 -0
  204. package/dist/spec.js +174 -0
  205. package/dist/spec.js.map +1 -0
  206. package/dist/template.d.ts +20 -0
  207. package/dist/template.js +123 -0
  208. package/dist/template.js.map +1 -0
  209. package/dist/workspace.d.ts +4 -0
  210. package/dist/workspace.js +19 -0
  211. package/dist/workspace.js.map +1 -0
  212. package/package.json +11 -3
  213. package/src/aiws-package.js +0 -15
  214. package/src/backup.js +0 -149
  215. package/src/cli.js +0 -760
  216. package/src/codex-prompts.js +0 -74
  217. package/src/codex-skills.js +0 -111
  218. package/src/commands/change-advice.js +0 -181
  219. package/src/commands/change-evidence-command.js +0 -242
  220. package/src/commands/change-evidence-entry.js +0 -40
  221. package/src/commands/change-evidence.js +0 -340
  222. package/src/commands/change-finish.js +0 -413
  223. package/src/commands/change-git-status.js +0 -53
  224. package/src/commands/change-lifecycle.js +0 -236
  225. package/src/commands/change-metrics-command.js +0 -110
  226. package/src/commands/change-next-command.js +0 -133
  227. package/src/commands/change-review-gates.js +0 -331
  228. package/src/commands/change-scope-gate.js +0 -34
  229. package/src/commands/change-start.js +0 -85
  230. package/src/commands/change-state-command.js +0 -40
  231. package/src/commands/change-status-command.js +0 -125
  232. package/src/commands/change-status-context.js +0 -173
  233. package/src/commands/change-task-stats.js +0 -64
  234. package/src/commands/change-validate-entry.js +0 -45
  235. package/src/commands/change-validate.js +0 -127
  236. package/src/commands/change.js +0 -1829
  237. package/src/commands/codex-install-prompts.js +0 -68
  238. package/src/commands/codex-install-skills.js +0 -74
  239. package/src/commands/codex-status-prompts.js +0 -55
  240. package/src/commands/codex-status-skills.js +0 -63
  241. package/src/commands/codex-uninstall-prompts.js +0 -55
  242. package/src/commands/codex-uninstall-skills.js +0 -62
  243. package/src/commands/dashboard.js +0 -205
  244. package/src/commands/hooks-install.js +0 -92
  245. package/src/commands/hooks-status.js +0 -95
  246. package/src/commands/init.js +0 -101
  247. package/src/commands/opencode-auto.js +0 -167
  248. package/src/commands/opencode-status.js +0 -103
  249. package/src/commands/opencode-supervise.js +0 -200
  250. package/src/commands/rollback.js +0 -13
  251. package/src/commands/update.js +0 -145
  252. package/src/commands/validate.js +0 -228
  253. package/src/commands/ws-commit.js +0 -44
  254. package/src/commands/ws-deliver.js +0 -67
  255. package/src/commands/ws-finish.js +0 -83
  256. package/src/commands/ws-handoff.js +0 -40
  257. package/src/commands/ws-migrate.js +0 -41
  258. package/src/commands/ws-plan-verify.js +0 -44
  259. package/src/commands/ws-pull.js +0 -30
  260. package/src/commands/ws-push.js +0 -71
  261. package/src/commands/ws-submodule-setup.js +0 -65
  262. package/src/commands/ws-verify-before-complete.js +0 -39
  263. package/src/dashboard/app.js +0 -371
  264. package/src/errors.js +0 -15
  265. package/src/exec.js +0 -34
  266. package/src/fs.js +0 -91
  267. package/src/governance.js +0 -159
  268. package/src/hash.js +0 -25
  269. package/src/json-schema-lite.js +0 -164
  270. package/src/managed-blocks.js +0 -131
  271. package/src/manifest.js +0 -177
  272. package/src/opencode-env.js +0 -208
  273. package/src/path-utils.js +0 -20
  274. package/src/spec.js +0 -195
  275. package/src/template.js +0 -187
  276. package/src/workspace.js +0 -23
  277. /package/{src → dist}/dashboard/index.html +0 -0
  278. /package/{src → dist}/dashboard/style.css +0 -0
@@ -1,1829 +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
- } from "./change-advice.js";
30
- import {
31
- isGitRepository,
32
- listSubmodulesFromGitmodules,
33
- resolveFinishContext,
34
- } from "./change-finish.js";
35
- import {
36
- collectFinishGateErrors,
37
- collectReviewGates,
38
- collectReviewSignals,
39
- } from "./change-review-gates.js";
40
- import {
41
- computeScopeGate,
42
- } from "./change-scope-gate.js";
43
- import {
44
- readChangeMetrics,
45
- resolveChangeStatusContext,
46
- summarizeLifecycleSignals,
47
- truthDrift,
48
- } from "./change-status-context.js";
49
- import { renderChangeStatusOutput } from "./change-status-command.js";
50
- import { validateChangeArtifacts as validateChangeArtifactsModel } from "./change-validate.js";
51
-
52
- /**
53
- * @param {string} text
54
- */
55
- function countWsTodo(text) {
56
- return (text.match(/WS:TODO/g) || []).length;
57
- }
58
-
59
- /**
60
- * @param {string} text
61
- */
62
- function countPlaceholders(text) {
63
- return (text.match(/{{CHANGE_ID}}|{{TITLE}}|{{CREATED_AT}}/g) || []).length;
64
- }
65
-
66
- /**
67
- * @param {string} changeDir
68
- * @param {string} rel
69
- */
70
- async function fileState(changeDir, rel) {
71
- const abs = path.join(changeDir, rel);
72
- if (!(await pathExists(abs))) return { state: "missing", wsTodo: 0, placeholders: 0, abs, text: "" };
73
- const st = await fs.stat(abs);
74
- if (st.size === 0) return { state: "empty", wsTodo: 0, placeholders: 0, abs, text: "" };
75
- const text = await readText(abs);
76
- return { state: "ok", wsTodo: countWsTodo(text), placeholders: countPlaceholders(text), abs, text };
77
- }
78
-
79
- /**
80
- * Append a change lifecycle event into changes/<id>/metrics.json.
81
- *
82
- * @param {string} gitRoot
83
- * @param {string} changeId
84
- * @param {string} type
85
- * @param {any} payload
86
- */
87
- async function appendMetricsEventAtDir(changeDir, changeId, type, payload) {
88
- const metricsAbs = path.join(changeDir, "metrics.json");
89
- /** @type {{ version: number, change_id: string, events: any[], updated_at?: string } | null} */
90
- let cur = null;
91
- if (await pathExists(metricsAbs)) {
92
- try {
93
- cur = JSON.parse(await readText(metricsAbs));
94
- } catch {
95
- cur = null;
96
- }
97
- }
98
- if (!cur || typeof cur !== "object") {
99
- cur = { version: 1, change_id: changeId, events: [] };
100
- }
101
- if (!Array.isArray(cur.events)) cur.events = [];
102
- cur.events.push({ type, timestamp: nowIsoUtc(), payload: payload ?? null });
103
- cur.events = cur.events.slice(-200);
104
- cur.updated_at = nowIsoUtc();
105
- await writeText(metricsAbs, JSON.stringify(cur, null, 2) + "\n");
106
- }
107
-
108
- async function appendMetricsEvent(gitRoot, changeId, type, payload) {
109
- const changeDir = changeDirAbs(gitRoot, changeId);
110
- await appendMetricsEventAtDir(changeDir, changeId, type, payload);
111
- }
112
-
113
- /**
114
- * @param {string} gitRoot
115
- */
116
- async function resolveTemplateIdForRepo(gitRoot) {
117
- const manifestPath = path.join(gitRoot, ".aiws", "manifest.json");
118
- if (!(await pathExists(manifestPath))) return "workspace";
119
- try {
120
- const stored = JSON.parse(await readText(manifestPath));
121
- const templateId = String(stored.template_id || "").trim();
122
- return templateId || "workspace";
123
- } catch {
124
- return "workspace";
125
- }
126
- }
127
-
128
- /**
129
- * @param {string} gitRoot
130
- * @returns {Promise<{ templateDir: string, resolvedFrom: string }>}
131
- */
132
- async function resolveChangeTemplatesDir(gitRoot) {
133
- const candidates = [
134
- { dir: path.join(gitRoot, "changes", "templates"), label: `${gitRoot}/changes/templates` },
135
- { dir: path.join(gitRoot, "workflow", "changes", "templates"), label: `${gitRoot}/workflow/changes/templates` },
136
- ];
137
-
138
- for (const c of candidates) {
139
- if (!(await pathExists(path.join(c.dir, "proposal.md")))) continue;
140
- if (!(await pathExists(path.join(c.dir, "tasks.md")))) continue;
141
- return { templateDir: c.dir, resolvedFrom: c.label };
142
- }
143
-
144
- const templateId = await resolveTemplateIdForRepo(gitRoot);
145
- const tpl = await loadTemplate(templateId);
146
- const fallback = path.join(tpl.templateDir, "changes", "templates");
147
-
148
- if (!(await pathExists(path.join(fallback, "proposal.md"))) || !(await pathExists(path.join(fallback, "tasks.md")))) {
149
- throw new UserError("Missing change templates.", { details: `Expected proposal.md and tasks.md under: ${fallback}` });
150
- }
151
- return { templateDir: fallback, resolvedFrom: `@aipper/aiws-spec:${tpl.templateId}` };
152
- }
153
-
154
- /**
155
- * @param {string} templateText
156
- * @param {{ changeId: string, title: string, createdAt: string }} vars
157
- */
158
- function renderTemplate(templateText, vars) {
159
- return templateText.replaceAll("{{CHANGE_ID}}", vars.changeId).replaceAll("{{TITLE}}", vars.title).replaceAll("{{CREATED_AT}}", vars.createdAt);
160
- }
161
-
162
- /**
163
- * @param {string} gitRoot
164
- * @returns {Promise<{ path: string, args: string[] }>}
165
- */
166
- async function resolveWsChangeChecker(gitRoot) {
167
- const local = path.join(gitRoot, "tools", "ws_change_check.py");
168
- if (await pathExists(local)) return { path: local, args: [local] };
169
-
170
- const templateId = await resolveTemplateIdForRepo(gitRoot);
171
- const tpl = await loadTemplate(templateId);
172
- const fallback = path.join(tpl.templateDir, "tools", "ws_change_check.py");
173
- if (!(await pathExists(fallback))) {
174
- throw new UserError("Missing ws_change_check.py.", { details: "Hint: run `aiws init .` to install tools/ws_change_check.py in your repo." });
175
- }
176
- return { path: fallback, args: [fallback] };
177
- }
178
-
179
- /**
180
- * @param {string} gitRoot
181
- * @param {string[]} args
182
- */
183
- async function runPython(gitRoot, args) {
184
- let res;
185
- try {
186
- res = await runCommand("python3", args, { cwd: gitRoot });
187
- } catch (e) {
188
- throw new UserError("python3 is required.", { details: e instanceof Error ? e.message : String(e) });
189
- }
190
- return res;
191
- }
192
-
193
- /**
194
- * @param {string} gitRoot
195
- * @param {string | undefined} changeId
196
- * @param {{ command: string, allowFinishedUnarchived?: boolean }} options
197
- */
198
- async function resolveChangeId(gitRoot, changeId, options) {
199
- const resolved = changeId || inferChangeIdFromBranch(await currentBranch(gitRoot));
200
- if (resolved) {
201
- const terminated = await detectTerminatedChange(gitRoot, resolved);
202
- if (!isTerminatedAllowed(options.command, terminated, { allowFinishedUnarchived: options.allowFinishedUnarchived === true })) {
203
- throw terminatedChangeError(gitRoot, resolved, terminated, `aiws change ${options.command}`);
204
- }
205
- return resolved;
206
- }
207
- throw new UserError(`usage: aiws change ${options.command} <change-id> (or switch to branch change/<change-id>)`);
208
- }
209
-
210
- /**
211
- * @param {string} gitRoot
212
- * @param {string} changeId
213
- */
214
- function changeDirAbs(gitRoot, changeId) {
215
- return path.join(gitRoot, "changes", changeId);
216
- }
217
-
218
- const CHANGE_BRANCH_RE = /^(change|changes|ws|ws-change)\/([a-z0-9]+(?:-[a-z0-9]+)*)$/;
219
- const CHANGE_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
220
-
221
- /**
222
- * @param {string} changeId
223
- */
224
- export function assertValidChangeId(changeId) {
225
- if (!changeId || !CHANGE_ID_RE.test(changeId)) {
226
- throw new UserError(`Invalid change id (use kebab-case): ${changeId}`);
227
- }
228
- }
229
-
230
- /**
231
- * @returns {string} e.g. 2026-01-28T14:00:26Z
232
- */
233
- function nowIsoUtc() {
234
- return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
235
- }
236
-
237
- /**
238
- * @returns {number} unix seconds
239
- */
240
- function nowUnixSeconds() {
241
- return Math.floor(Date.now() / 1000);
242
- }
243
-
244
- /**
245
- * @returns {string} e.g. 20260128-140026Z
246
- */
247
- function nowStampUtc() {
248
- const d = new Date();
249
- const y = String(d.getUTCFullYear());
250
- const m = String(d.getUTCMonth() + 1).padStart(2, "0");
251
- const day = String(d.getUTCDate()).padStart(2, "0");
252
- const hh = String(d.getUTCHours()).padStart(2, "0");
253
- const mm = String(d.getUTCMinutes()).padStart(2, "0");
254
- const ss = String(d.getUTCSeconds()).padStart(2, "0");
255
- return `${y}${m}${day}-${hh}${mm}${ss}Z`;
256
- }
257
-
258
- /**
259
- * @returns {string} e.g. 2026-01-28 (local date)
260
- */
261
- function todayLocal() {
262
- const d = new Date();
263
- const y = String(d.getFullYear());
264
- const m = String(d.getMonth() + 1).padStart(2, "0");
265
- const day = String(d.getDate()).padStart(2, "0");
266
- return `${y}-${m}-${day}`;
267
- }
268
-
269
- /**
270
- * @param {string} absPath
271
- */
272
- async function resolveGitRoot(absPath) {
273
- let res;
274
- try {
275
- res = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd: absPath });
276
- } catch (e) {
277
- throw new UserError("git is required for aiws change commands.", { details: e instanceof Error ? e.message : String(e) });
278
- }
279
- if (res.code !== 0) {
280
- throw new UserError("Not a git repository.", { details: res.stderr || res.stdout });
281
- }
282
- const root = String(res.stdout || "").trim();
283
- if (!root) throw new UserError("Failed to resolve git repository root.");
284
- return root;
285
- }
286
-
287
- /**
288
- * @param {string} gitRoot
289
- * @returns {Promise<string>} branch or empty
290
- */
291
- async function currentBranch(gitRoot) {
292
- const res = await runCommand("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: gitRoot });
293
- if (res.code !== 0) return "";
294
- return String(res.stdout || "").trim();
295
- }
296
-
297
- /**
298
- * @param {string} gitRoot
299
- */
300
- async function hasHeadCommit(gitRoot) {
301
- return runCommand("git", ["rev-parse", "--verify", "HEAD"], { cwd: gitRoot }).then((r) => r.code === 0);
302
- }
303
-
304
- /**
305
- * @param {string} gitRoot
306
- * @returns {Promise<{ clean: true } | { clean: false, details: string }>}
307
- */
308
- async function checkGitClean(gitRoot) {
309
- const res = await runCommand("git", ["status", "--porcelain"], { cwd: gitRoot });
310
- const out = String(res.stdout || "").trim();
311
- if (!out) return { clean: true };
312
- const lines = out.split("\n");
313
- const truncated = lines.length > 20 ? `${lines.slice(0, 20).join("\n")}\n... (truncated)` : out;
314
- return { clean: false, details: truncated };
315
- }
316
-
317
- /**
318
- * @param {string} branch
319
- */
320
- function inferChangeIdFromBranch(branch) {
321
- const m = CHANGE_BRANCH_RE.exec(branch || "");
322
- return m?.[2] || "";
323
- }
324
-
325
- /**
326
- * @param {string} gitRoot
327
- */
328
- async function ensureTruthFiles(gitRoot) {
329
- const required = ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"];
330
- const missing = [];
331
- for (const f of required) {
332
- if (!(await pathExists(path.join(gitRoot, f)))) missing.push(f);
333
- }
334
- if (missing.length > 0) {
335
- throw new UserError("AI Workspace truth files missing.", {
336
- details: `Root: ${gitRoot}\nMissing:\n${missing.map((m) => `- ${m}`).join("\n")}\n\nHint: run \`aiws init .\` (new) or \`aiws update .\` (migrate).`,
337
- });
338
- }
339
- }
340
-
341
- /**
342
- * @param {string} filePath
343
- * @returns {Promise<string>}
344
- */
345
- async function sha256FileBytes(filePath) {
346
- const buf = await fs.readFile(filePath);
347
- return crypto.createHash("sha256").update(buf).digest("hex");
348
- }
349
-
350
- /**
351
- * @param {string} gitRoot
352
- * @returns {Promise<Record<string, {mtime: number, sha256: string}>>}
353
- */
354
- async function snapshotTruthFiles(gitRoot) {
355
- /** @type {Record<string, {mtime: number, sha256: string}>} */
356
- const truth = {};
357
- for (const rel of ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"]) {
358
- const abs = path.join(gitRoot, rel);
359
- if (!(await pathExists(abs))) continue;
360
- const st = await fs.stat(abs);
361
- truth[rel] = { mtime: Math.floor(st.mtimeMs / 1000), sha256: await sha256FileBytes(abs) };
362
- }
363
- return truth;
364
- }
365
-
366
- /**
367
- * @param {string} gitRoot
368
- * @returns {Promise<Record<string, string | null>>}
369
- */
370
- async function snapshotTruthShaOnly(gitRoot) {
371
- /** @type {Record<string, string | null>} */
372
- const out = {};
373
- for (const rel of ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"]) {
374
- const abs = path.join(gitRoot, rel);
375
- if (!(await pathExists(abs))) {
376
- out[rel] = null;
377
- continue;
378
- }
379
- try {
380
- out[rel] = await sha256FileBytes(abs);
381
- } catch {
382
- out[rel] = null;
383
- }
384
- }
385
- return out;
386
- }
387
-
388
- /**
389
- * @param {string} s
390
- */
391
- function escapeRegExp(s) {
392
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
393
- }
394
-
395
- /**
396
- * @param {string} label
397
- * @param {string} text
398
- */
399
- function extractId(label, text) {
400
- const re = new RegExp(`^.*${escapeRegExp(label)}.*?[:=][ \\t]*(.*)$`, "m");
401
- const m = re.exec(text);
402
- if (!m) return "";
403
- let v = String(m[1] || "").trim();
404
- v = v.replace(/<!--.*?-->/g, "").trim();
405
- v = v.replace(/^`+/, "").replace(/`+$/, "").trim();
406
- return v;
407
- }
408
-
409
- /**
410
- * @param {string} s
411
- */
412
- function splitDeclaredValues(s) {
413
- return String(s || "")
414
- .split(/[,;\n]+/g)
415
- .map((x) => x.trim().replace(/^`+/, "").replace(/`+$/, "").trim())
416
- .filter(Boolean);
417
- }
418
-
419
- /**
420
- * Extract bullet lines under a markdown "## ..." heading.
421
- *
422
- * @param {string} text
423
- * @param {RegExp} headingRe
424
- * @param {{ max?: number }} [options]
425
- * @returns {string[]} trimmed bullet lines like "- foo"
426
- */
427
- function extractBulletsUnderHeading(text, headingRe, options) {
428
- const max = typeof options?.max === "number" ? options.max : 5;
429
- const lines = String(text || "").split("\n");
430
- let inSection = false;
431
- /** @type {string[]} */
432
- const out = [];
433
- for (const raw of lines) {
434
- const line = String(raw || "");
435
- const trimmed = line.trim();
436
- if (!inSection) {
437
- if (headingRe.test(trimmed)) inSection = true;
438
- continue;
439
- }
440
- if (/^##\s+/.test(trimmed)) break;
441
- if (trimmed.startsWith("-")) {
442
- out.push(trimmed);
443
- if (out.length >= max) break;
444
- }
445
- }
446
- return out;
447
- }
448
-
449
- /**
450
- * @param {string} bulletLine
451
- */
452
- function isMeaningfulBullet(bulletLine) {
453
- const s = String(bulletLine || "").trim();
454
- if (!s.startsWith("-")) return false;
455
- if (s.includes("WS:TODO")) return false;
456
- if (s.includes("<!--")) return false;
457
- if (s === "-" || s === "- (none)" || s === "- (missing)") return false;
458
- return true;
459
- }
460
-
461
- /**
462
- * @param {string} entryName
463
- * @returns {{ datePrefix: string, changeId: string, stamp: string } | null}
464
- */
465
- function parseArchivedChangeDirName(entryName) {
466
- const m = /^(\d{4}-\d{2}-\d{2})-(.+?)(?:-(\d{8}-\d{6}Z))?$/.exec(String(entryName || ""));
467
- if (!m) return null;
468
- const changeId = m[2] || "";
469
- if (!CHANGE_ID_RE.test(changeId)) return null;
470
- return {
471
- datePrefix: m[1] || "",
472
- changeId,
473
- stamp: m[3] || "",
474
- };
475
- }
476
-
477
- /**
478
- * Find an archived change by ID in the archive directory.
479
- * @param {string} archiveDir
480
- * @param {string} changeId
481
- * @returns {Promise<string | null>} Full path to archived change dir, or null if not found.
482
- */
483
- async function findArchivedChange(archiveDir, changeId) {
484
- if (!(await pathExists(archiveDir))) return null;
485
- try {
486
- const entries = await fs.readdir(archiveDir, { withFileTypes: true });
487
- const matches = entries
488
- .filter((e) => e.isDirectory())
489
- .map((e) => ({ entry: e, parsed: parseArchivedChangeDirName(e.name) }))
490
- .filter((item) => item.parsed?.changeId === changeId)
491
- .sort((a, b) => a.entry.name.localeCompare(b.entry.name));
492
- if (matches.length > 0) return path.join(archiveDir, matches.at(-1)?.entry.name || "");
493
- } catch {
494
- // ignore
495
- }
496
- return null;
497
- }
498
-
499
- /**
500
- * @param {string} gitRoot
501
- * @param {string} changeId
502
- * @returns {Promise<null | { kind: "archived" | "finished_unarchived", archivedChangeDir?: string, archivedHandoffRel?: string, lifecycle?: any }>}
503
- */
504
- async function detectTerminatedChange(gitRoot, changeId) {
505
- const changeDir = changeDirAbs(gitRoot, changeId);
506
- const archiveRoot = path.join(gitRoot, "changes", "archive");
507
- const activeExists = await pathExists(changeDir);
508
- if (!activeExists) {
509
- const archivedChangeDir = await findArchivedChange(archiveRoot, changeId);
510
- if (!archivedChangeDir) return null;
511
- const archivedHandoffPath = path.join(archivedChangeDir, "handoff.md");
512
- return {
513
- kind: "archived",
514
- archivedChangeDir,
515
- archivedHandoffRel: (await pathExists(archivedHandoffPath)) ? path.relative(gitRoot, archivedHandoffPath) : "",
516
- };
517
- }
518
-
519
- const lifecycle = summarizeLifecycleSignals(await readChangeMetrics(changeDir, { pathExists, readText }));
520
- if (lifecycle.finishState !== "done") return null;
521
- return { kind: "finished_unarchived", lifecycle };
522
- }
523
-
524
- /**
525
- * @param {string} gitRoot
526
- * @param {string} changeId
527
- * @param {Awaited<ReturnType<typeof detectTerminatedChange>>} terminated
528
- * @param {string} action
529
- */
530
- function terminatedChangeError(gitRoot, changeId, terminated, action) {
531
- if (terminated?.kind === "archived") {
532
- return new UserError(`Change ${changeId} is already archived.`, {
533
- details:
534
- `change: ${changeId}\n` +
535
- `archived: ${path.relative(gitRoot, terminated.archivedChangeDir || "")}\n` +
536
- (terminated.archivedHandoffRel ? `handoff: ${terminated.archivedHandoffRel}\n` : "") +
537
- `next: create a new follow-up change instead of reusing change/${changeId}` +
538
- (action ? `\nblocked: ${action}` : ""),
539
- });
540
- }
541
- return new UserError(`Change ${changeId} has unfinished finish closeout; resume finish before any follow-up work.`, {
542
- details:
543
- `change: ${changeId}\n` +
544
- `branch: change/${changeId}\n` +
545
- `next: rerun \`aiws change finish ${changeId} --push\` to complete push/archive closeout` +
546
- `\nfallback: if you intentionally only need local archive recovery, run \`aiws change archive ${changeId}\`` +
547
- (action ? `\nblocked: ${action}` : ""),
548
- });
549
- }
550
-
551
- /**
552
- * @param {string} command
553
- * @param {Awaited<ReturnType<typeof detectTerminatedChange>>} terminated
554
- * @param {{ allowFinishedUnarchived?: boolean }} [options]
555
- */
556
- function isTerminatedAllowed(command, terminated, options = {}) {
557
- if (!terminated) return true;
558
- if (terminated.kind === "finished_unarchived") {
559
- if (command === "archive") return true;
560
- if (command === "finish") return true;
561
- if (command === "status" || command === "next") return true;
562
- if (options.allowFinishedUnarchived === true) return true;
563
- }
564
- return false;
565
- }
566
-
567
- /**
568
- * Extract a brief summary from handoff.md content.
569
- * @param {string} text
570
- * @returns {string}
571
- */
572
- function extractHandoffSummary(text) {
573
- // Try to extract "本次完成" or "What Changed" section
574
- const lines = String(text || "").split("\n");
575
- let inSection = false;
576
- const summary = [];
577
- for (const line of lines) {
578
- if (/^##\s*(本次完成|What Changed|Completed)/i.test(line)) {
579
- inSection = true;
580
- continue;
581
- }
582
- if (inSection && /^##/.test(line)) break;
583
- if (inSection && line.trim().startsWith("-")) {
584
- summary.push(line.trim());
585
- if (summary.length >= 3) break;
586
- }
587
- }
588
- return summary.join(" ");
589
- }
590
-
591
- /**
592
- * Check Depends_On dependencies and print handoff summaries.
593
- * @param {string} gitRoot - The git root directory (main or worktree)
594
- * @param {string} changeId
595
- * @param {string} changeDir - The change directory (may be in worktree)
596
- */
597
- async function checkDependenciesAndPrintHandoff(gitRoot, changeId, changeDir) {
598
- const proposalPath = path.join(changeDir, "proposal.md");
599
- if (!(await pathExists(proposalPath))) return;
600
-
601
- const proposalText = await readText(proposalPath);
602
- const dependsOn = extractId("Depends_On", proposalText);
603
- if (!dependsOn) return;
604
-
605
- const deps = splitDeclaredValues(dependsOn).filter((d) => d && d !== changeId);
606
- if (deps.length === 0) return;
607
- const archiveDir = path.join(gitRoot, "changes", "archive");
608
-
609
- for (const dep of deps) {
610
- const depDir = changeDirAbs(gitRoot, dep);
611
- const depArchived = await findArchivedChange(archiveDir, dep);
612
- if (!depArchived && !(await pathExists(depDir))) {
613
- console.error(`warn: Depends_On "${dep}" not found (neither active nor archived)`);
614
- console.error(`hint: if you are using --worktree, ensure the dependency is archived under changes/archive/ and committed to HEAD in this worktree.`);
615
- } else if (!depArchived) {
616
- console.error(`warn: Depends_On "${dep}" is not yet archived (still active at changes/${dep})`);
617
- } else {
618
- // Dependency is archived, check for handoff.md
619
- const handoffPath = path.join(depArchived, "handoff.md");
620
- if (await pathExists(handoffPath)) {
621
- console.log(`info: reading handoff from dependency "${dep}"`);
622
- const handoffText = await readText(handoffPath);
623
- const summary = extractHandoffSummary(handoffText);
624
- if (summary) {
625
- console.log(` ${summary}`);
626
- }
627
- } else {
628
- console.error(`warn: dependency "${dep}" is archived but missing handoff.md: ${path.relative(gitRoot, handoffPath)}`);
629
- }
630
- }
631
- }
632
- }
633
-
634
- /**
635
- * Generate handoff.md content for a completed change.
636
- * @param {string} gitRoot
637
- * @param {string} changeDir - The archived change directory
638
- * @param {string} changeId
639
- * @returns {Promise<string>}
640
- */
641
- async function generateHandoffContent(gitRoot, changeDir, changeId) {
642
- const proposalPath = path.join(changeDir, "proposal.md");
643
- const designPath = path.join(changeDir, "design.md");
644
-
645
- let reqId = "";
646
- let problemId = "";
647
- let blocks = "";
648
- let goal = "";
649
- let baseBranch = "main";
650
- /** @type {string[]} */
651
- const decisions = [];
652
- const collaboration = await collectCollaborationArtifacts(gitRoot, changeDir);
653
-
654
- if (await pathExists(proposalPath)) {
655
- const text = await readText(proposalPath);
656
- reqId = extractId("Req_ID", text);
657
- problemId = extractId("Problem_ID", text);
658
- blocks = extractId("Blocks", text);
659
- const declaredBase = extractId("Base_Branch", text);
660
- if (declaredBase) baseBranch = declaredBase;
661
- // Extract goal from 目标 section
662
- const goalMatch = text.match(/\*\*目标[::]\*\*\s*\n([\s\S]*?)(?=\n\*\*非目标|$)/);
663
- if (goalMatch) {
664
- const goalLines = goalMatch[1].split("\n").filter((l) => l.trim().startsWith("-")).slice(0, 3);
665
- goal = goalLines.map((l) => l.trim()).join("\n");
666
- }
667
- }
668
-
669
- // Get changed files from git diff if possible
670
- let changedFiles = [];
671
- try {
672
- const metaPath = path.join(changeDir, ".ws-change.json");
673
- if (await pathExists(metaPath)) {
674
- const meta = JSON.parse(await readText(metaPath));
675
- if (meta && typeof meta === "object" && meta.base_branch) baseBranch = String(meta.base_branch || "").trim() || baseBranch;
676
- }
677
-
678
- const changeBranch = `change/${changeId}`;
679
- const hasChangeBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${changeBranch}`], { cwd: gitRoot }).then((r) => r.code === 0);
680
- const cur = await currentBranch(gitRoot);
681
- const diffTarget = hasChangeBranch ? changeBranch : cur === changeBranch ? "HEAD" : "";
682
- if (diffTarget) {
683
- const diff = await runCommand("git", ["diff", "--name-only", `${baseBranch}...${diffTarget}`], { cwd: gitRoot });
684
- if (diff.code === 0 && diff.stdout) {
685
- changedFiles = diff.stdout.split("\n").filter(Boolean).slice(0, 20);
686
- }
687
- }
688
- } catch {
689
- // ignore
690
- }
691
-
692
- try {
693
- if (await pathExists(designPath)) {
694
- const designText = await readText(designPath);
695
- decisions.push(...extractBulletsUnderHeading(designText, /^##\s*Decisions\s*$/i, { max: 12 }).filter(isMeaningfulBullet).slice(0, 8));
696
- decisions.push(...extractBulletsUnderHeading(designText, /^##\s*关键决策\s*$/i, { max: 12 }).filter(isMeaningfulBullet).slice(0, 8));
697
- } else if (await pathExists(proposalPath)) {
698
- const proposalText = await readText(proposalPath);
699
- const fromProposal = extractBulletsUnderHeading(proposalText, /^##\s*方案概述\b.*$/i, { max: 10 }).filter(isMeaningfulBullet).slice(0, 6);
700
- for (const b of fromProposal) decisions.push(`- (from proposal) ${b.replace(/^-+\s*/, "")}`);
701
- }
702
- } catch {
703
- // ignore
704
- }
705
-
706
- const lines = [
707
- `# Handoff: ${changeId}`,
708
- "",
709
- `> Archived: ${nowIsoUtc()}`,
710
- "",
711
- "## 本次完成",
712
- "",
713
- ];
714
-
715
- if (goal) {
716
- lines.push(goal);
717
- } else {
718
- lines.push("- (see proposal.md for details)");
719
- }
720
-
721
- lines.push("");
722
- lines.push("## 改动文件");
723
- lines.push("");
724
- if (changedFiles.length > 0) {
725
- for (const f of changedFiles) {
726
- lines.push(`- ${f}`);
727
- }
728
- if (changedFiles.length === 20) {
729
- lines.push("- ...(truncated)");
730
- }
731
- } else {
732
- lines.push("- (see git log for details)");
733
- }
734
-
735
- lines.push("");
736
- lines.push("## 关键决策");
737
- lines.push("");
738
- if (decisions.length > 0) {
739
- for (const d of decisions) lines.push(d);
740
- } else {
741
- lines.push("- (missing) add decisions to changes/<id>/design.md#Decisions");
742
- }
743
-
744
- lines.push("");
745
- lines.push("## 协同记录");
746
- lines.push("");
747
- if ((collaboration.counts.total || 0) === 0) {
748
- lines.push("- (none)");
749
- } else {
750
- for (const key of ["analysis", "patches", "review", "evidence"]) {
751
- const group = collaboration.dirs[key];
752
- if (!group) continue;
753
- lines.push(`- ${group.label}: ${group.count} file(s)`);
754
- for (const rel of group.files) lines.push(` - ${rel}`);
755
- if (group.truncated) lines.push(" - ...(truncated)");
756
- }
757
- }
758
-
759
- lines.push("");
760
- lines.push("## 下一步建议");
761
- lines.push("");
762
- if (blocks) {
763
- const blockIds = splitDeclaredValues(blocks);
764
- for (const b of blockIds) {
765
- lines.push(`- 可以开始: ${b}`);
766
- }
767
- } else {
768
- lines.push("- (no Blocks declared)");
769
- }
770
-
771
- lines.push("");
772
- lines.push("## 绑定");
773
- lines.push("");
774
- lines.push(`- Change_ID: ${changeId}`);
775
- if (reqId) lines.push(`- Req_ID: ${reqId}`);
776
- if (problemId) lines.push(`- Problem_ID: ${problemId}`);
777
-
778
- return lines.join("\n") + "\n";
779
- }
780
-
781
- /**
782
- * @param {string[]} blockersStrict
783
- * @param {{ unchecked: number }} tasks
784
- */
785
- function inferChangePhase(blockersStrict, tasks) {
786
- if ((blockersStrict || []).length > 0) return "planning";
787
- if ((tasks?.unchecked || 0) > 0) return "dev";
788
- return "deliver";
789
- }
790
-
791
- export async function computeChangeStatus(gitRoot, changeId) {
792
- assertValidChangeId(changeId);
793
- await ensureTruthFiles(gitRoot);
794
-
795
- const changeDir = changeDirAbs(gitRoot, changeId);
796
- if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
797
- const context = await resolveChangeStatusContext(gitRoot, changeId, {
798
- currentBranch,
799
- pathExists,
800
- isGitRepository: (repoPath) => isGitRepository(repoPath, { pathExists, runCommand }),
801
- inferChangeIdFromBranch,
802
- });
803
-
804
- const proposal = await fileState(changeDir, "proposal.md");
805
- const tasks = await fileState(changeDir, "tasks.md");
806
- const design = await fileState(changeDir, "design.md");
807
- const collaboration = await collectCollaborationArtifacts(gitRoot, changeDir);
808
- const reviewSignals = await collectReviewSignals(gitRoot, changeId, context, collaboration);
809
- const reviewGates = await collectReviewGates(gitRoot, changeId, context);
810
- const git = await collectGitStatus(context.worktree);
811
- const submodules = await listSubmodulesFromGitmodules(gitRoot, { pathExists, runCommand });
812
- const metrics = await readChangeMetrics(changeDir, { pathExists, readText });
813
-
814
- const metaPath = path.join(changeDir, ".ws-change.json");
815
- let metaState = "missing";
816
- /** @type {any} */
817
- let meta = null;
818
- if (await pathExists(metaPath)) {
819
- metaState = "ok";
820
- try {
821
- meta = JSON.parse(await readText(metaPath));
822
- } catch {
823
- metaState = "invalid";
824
- meta = null;
825
- }
826
- }
827
-
828
- const reqId = proposal.state === "ok" ? extractId("Req_ID", proposal.text) : "";
829
- const probId = proposal.state === "ok" ? extractId("Problem_ID", proposal.text) : "";
830
- const contractRow = proposal.state === "ok" ? extractId("Contract_Row", proposal.text) || extractId("Contract_Row(s)", proposal.text) : "";
831
- const planFile = proposal.state === "ok" ? extractId("Plan_File", proposal.text) || extractId("Plan file", proposal.text) : "";
832
- const evidencePath = proposal.state === "ok" ? extractId("Evidence_Path", proposal.text) || extractId("Evidence_Path(s)", proposal.text) : "";
833
- const evidencePaths = splitDeclaredValues(evidencePath);
834
- const evidence = await analyzeEvidencePaths(gitRoot, changeId, evidencePaths);
835
- const taskProgress =
836
- tasks.state === "ok"
837
- ? checkboxStats(tasks.text)
838
- : {
839
- total: 0,
840
- done: 0,
841
- unchecked: 0,
842
- hasCheckboxes: false,
843
- totalAll: 0,
844
- doneAll: 0,
845
- uncheckedAll: 0,
846
- optionalTotal: 0,
847
- optionalUnchecked: 0,
848
- naCount: 0,
849
- };
850
-
851
- const curTruth = await snapshotTruthShaOnly(gitRoot);
852
- const { baselineLabel, baselineAt, driftFiles } = meta ? truthDrift(curTruth, meta) : { baselineLabel: "-", baselineAt: "", driftFiles: [] };
853
-
854
- /** @type {string[]} */
855
- const blockersStrict = [];
856
- /** @type {string[]} */
857
- const blockersArchive = [];
858
-
859
- if (metaState !== "ok") {
860
- blockersStrict.push("missing/invalid .ws-change.json (run `aiws change sync <id>` to regenerate)");
861
- }
862
-
863
- for (const [rel, st] of [
864
- ["proposal.md", proposal],
865
- ["tasks.md", tasks],
866
- ]) {
867
- if (st.state !== "ok") {
868
- blockersStrict.push(`missing/empty ${rel}`);
869
- continue;
870
- }
871
- if (st.placeholders > 0) blockersStrict.push(`unrendered template placeholders in ${rel}`);
872
- if (st.wsTodo > 0) blockersStrict.push(`WS:TODO markers remain in ${rel}`);
873
- }
874
-
875
- if (design.state === "ok") {
876
- if (design.placeholders > 0) blockersStrict.push("unrendered template placeholders in design.md");
877
- if (design.wsTodo > 0) blockersStrict.push("WS:TODO markers remain in design.md");
878
- }
879
-
880
- if (proposal.state === "ok" && !(reqId || probId)) {
881
- blockersStrict.push("proposal.md missing attribution (Req_ID or Problem_ID)");
882
- }
883
-
884
- if (proposal.state === "ok" && !contractRow) blockersStrict.push("proposal.md missing Contract_Row binding");
885
-
886
- if (proposal.state === "ok") {
887
- const planFiles = splitDeclaredValues(planFile);
888
- if (planFiles.length !== 1) blockersStrict.push("proposal.md Plan_File must contain exactly one plan path");
889
- else {
890
- const planRel = String(planFiles[0] || "").replace(/^\.\//, "");
891
- if (path.isAbsolute(planRel)) blockersStrict.push("proposal.md Plan_File must be workspace-relative (not absolute)");
892
- else {
893
- if (!planRel.startsWith("plan/")) blockersStrict.push("proposal.md Plan_File should be under plan/ (recommended)");
894
- if (!(await pathExists(path.join(gitRoot, planRel)))) blockersStrict.push(`proposal.md Plan_File missing: ${planRel}`);
895
- }
896
- }
897
- }
898
-
899
- if (proposal.state === "ok" && evidencePaths.length === 0) blockersStrict.push("proposal.md missing Evidence_Path binding");
900
-
901
- if (!taskProgress.hasCheckboxes) blockersStrict.push("tasks.md has no checkbox tasks ('- [ ]' or '- [x]')");
902
- if (driftFiles.length > 0) blockersStrict.push(`truth drift vs ${baselineLabel} baseline (run \`aiws change sync ${changeId}\`)`);
903
-
904
- const lifecycle = summarizeLifecycleSignals(metrics);
905
- const terminatedReuse =
906
- lifecycle.finishState === "done"
907
- ? {
908
- kind: "finished_unarchived",
909
- message: `change/${changeId} has unfinished finish closeout; rerun \`aiws change finish ${changeId} --push\` to complete archive/push`,
910
- }
911
- : null;
912
- if (terminatedReuse) {
913
- blockersStrict.push(terminatedReuse.message);
914
- }
915
-
916
- blockersArchive.push(...blockersStrict);
917
- if (taskProgress.unchecked > 0) blockersArchive.push(`tasks.md still has unchecked required tasks (${taskProgress.unchecked} items)`);
918
- const phase = inferChangePhase(blockersStrict, taskProgress);
919
- if (terminatedReuse) {
920
- lifecycle.finishState = "resume_required";
921
- lifecycle.finishStateReason = "archive_pending";
922
- }
923
- const scopeGate = computeScopeGate(lifecycle);
924
-
925
- const baseStatus = {
926
- ok: true,
927
- changeId,
928
- dir: path.relative(gitRoot, changeDir),
929
- phase,
930
- context,
931
- metaState,
932
- reqId,
933
- probId,
934
- bindings: {
935
- contractRow,
936
- planFile,
937
- evidencePaths,
938
- },
939
- scopeGate,
940
- evidence,
941
- collaboration,
942
- reviewSignals,
943
- reviewGates,
944
- git,
945
- repo: {
946
- submodules: submodules.length,
947
- },
948
- lifecycle,
949
- tasks: taskProgress,
950
- baselineLabel,
951
- baselineAt,
952
- driftFiles,
953
- blockersStrict,
954
- blockersArchive,
955
- terminatedReuse,
956
- hints: {
957
- planVerify: planVerifyHint(changeId),
958
- dev: "质量门通过后再进入编码:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`",
959
- },
960
- };
961
- const governance = await inferChangeGovernance(baseStatus);
962
- return {
963
- ...baseStatus,
964
- governance: {
965
- ...governance,
966
- warning: context.warning || "",
967
- },
968
- };
969
- }
970
-
971
- export async function validateChangeArtifacts(gitRoot, changeId, options) {
972
- return validateChangeArtifactsModel(gitRoot, changeId, options, {
973
- assertValidChangeId,
974
- collectFinishGateErrors,
975
- computeChangeStatus,
976
- ensureTruthFiles,
977
- pathExists,
978
- readText,
979
- resolveWsChangeChecker,
980
- runCommand,
981
- runPython,
982
- UserError,
983
- });
984
- }
985
-
986
- /**
987
- * aiws change list
988
- */
989
- export async function changeListCommand() {
990
- const gitRoot = await resolveGitRoot(process.cwd());
991
- await ensureTruthFiles(gitRoot);
992
-
993
- const changesRoot = path.join(gitRoot, "changes");
994
- if (!(await pathExists(changesRoot))) {
995
- console.log("(no changes dir)");
996
- return;
997
- }
998
-
999
- const curTruth = await snapshotTruthShaOnly(gitRoot);
1000
-
1001
- const entries = await fs.readdir(changesRoot, { withFileTypes: true });
1002
- const dirs = entries
1003
- .filter((e) => e.isDirectory())
1004
- .map((e) => e.name)
1005
- .filter((name) => name && !name.startsWith(".") && name !== "archive" && name !== "templates")
1006
- .sort();
1007
-
1008
- if (dirs.length === 0) {
1009
- console.log("(no active changes)");
1010
- return;
1011
- }
1012
-
1013
- /** @type {Array<[string, string, string, string, number, string, string]>} */
1014
- const rows = [];
1015
-
1016
- for (const name of dirs) {
1017
- const dir = path.join(changesRoot, name);
1018
- const proposalPath = path.join(dir, "proposal.md");
1019
- const tasksPath = path.join(dir, "tasks.md");
1020
- const metaPath = path.join(dir, ".ws-change.json");
1021
-
1022
- const metaState = !(await pathExists(metaPath)) ? "missing" : "ok";
1023
- /** @type {any} */
1024
- let meta = null;
1025
- if (metaState === "ok") {
1026
- try {
1027
- meta = JSON.parse(await readText(metaPath));
1028
- } catch {
1029
- meta = null;
1030
- }
1031
- }
1032
- const finalMetaState = meta ? "ok" : metaState === "missing" ? "missing" : "invalid";
1033
-
1034
- let proposalWsTodo = 0;
1035
- if (await pathExists(proposalPath)) {
1036
- try {
1037
- proposalWsTodo = countWsTodo(await readText(proposalPath));
1038
- } catch {
1039
- proposalWsTodo = 0;
1040
- }
1041
- }
1042
-
1043
- let tasksTotal = 0;
1044
- let tasksDone = 0;
1045
- let tasksTodo = 0;
1046
- let tasksWsTodo = 0;
1047
- if (await pathExists(tasksPath)) {
1048
- try {
1049
- const t = await readText(tasksPath);
1050
- const stats = checkboxStats(t);
1051
- tasksTotal = stats.total;
1052
- tasksDone = stats.done;
1053
- tasksTodo = stats.unchecked;
1054
- tasksWsTodo = countWsTodo(t);
1055
- } catch {
1056
- // ignore
1057
- }
1058
- }
1059
-
1060
- const { baselineLabel, driftFiles } = meta ? truthDrift(curTruth, meta) : { baselineLabel: "-", driftFiles: [] };
1061
-
1062
- let nextStep = "archive";
1063
- if (finalMetaState !== "ok") nextStep = "sync";
1064
- else if (driftFiles.length > 0) nextStep = "sync";
1065
- else if (proposalWsTodo + tasksWsTodo > 0) nextStep = "edit";
1066
- else if (tasksTodo > 0) nextStep = "do";
1067
-
1068
- rows.push([
1069
- name,
1070
- nextStep,
1071
- finalMetaState,
1072
- baselineLabel,
1073
- driftFiles.length,
1074
- `${tasksDone}/${tasksTotal} (unchecked=${tasksTodo})`,
1075
- `proposal=${proposalWsTodo} tasks=${tasksWsTodo}`,
1076
- ]);
1077
- }
1078
-
1079
- console.log("change_id\tnext\tmeta\tbaseline\tdrift\ttasks\tWS:TODO");
1080
- for (const r of rows) {
1081
- console.log(r.join("\t"));
1082
- }
1083
- }
1084
-
1085
- /**
1086
- * aiws change templates which
1087
- */
1088
- export async function changeTemplatesWhichCommand() {
1089
- const gitRoot = await resolveGitRoot(process.cwd());
1090
- await ensureTruthFiles(gitRoot);
1091
-
1092
- const templateId = await resolveTemplateIdForRepo(gitRoot);
1093
- const tpl = await loadTemplate(templateId);
1094
- const resolved = await resolveChangeTemplatesDir(gitRoot);
1095
-
1096
- console.log(`templates: ${resolved.templateDir}`);
1097
- console.log(`resolved_from: ${resolved.resolvedFrom}`);
1098
- console.log("precedence:");
1099
- console.log(` 1) ${path.join(gitRoot, "changes", "templates")}`);
1100
- console.log(` 2) ${path.join(gitRoot, "workflow", "changes", "templates")}`);
1101
- console.log(` 3) ${path.join(tpl.templateDir, "changes", "templates")}`);
1102
- }
1103
-
1104
- /**
1105
- * aiws change templates init
1106
- */
1107
- export async function changeTemplatesInitCommand() {
1108
- const gitRoot = await resolveGitRoot(process.cwd());
1109
- await ensureTruthFiles(gitRoot);
1110
-
1111
- const templateId = await resolveTemplateIdForRepo(gitRoot);
1112
- const tpl = await loadTemplate(templateId);
1113
-
1114
- const rels = ["changes/templates/proposal.md", "changes/templates/tasks.md", "changes/templates/design.md"];
1115
- /** @type {string[]} */
1116
- const installed = [];
1117
- /** @type {string[]} */
1118
- const skipped = [];
1119
-
1120
- for (const rel of rels) {
1121
- const destAbs = path.join(gitRoot, ...rel.split("/"));
1122
- if (await pathExists(destAbs)) {
1123
- skipped.push(rel);
1124
- continue;
1125
- }
1126
- await copyTemplateFileToWorkspace({ templateDir: tpl.templateDir, workspaceRoot: gitRoot, relPosix: rel });
1127
- installed.push(rel);
1128
- }
1129
-
1130
- console.log(`✓ aiws change templates init: ${gitRoot}`);
1131
- if (installed.length > 0) console.log(`installed: ${installed.join(", ")}`);
1132
- if (skipped.length > 0) console.log(`skip: ${skipped.join(", ")}`);
1133
- }
1134
-
1135
- /**
1136
- * @param {string} gitRoot
1137
- * @param {{ changeId: string, title?: string, noDesign: boolean, baseBranch?: string }} options
1138
- */
1139
- async function changeNewAtGitRoot(gitRoot, options) {
1140
- await ensureTruthFiles(gitRoot);
1141
-
1142
- const changeId = String(options.changeId || "").trim();
1143
- assertValidChangeId(changeId);
1144
- const terminated = await detectTerminatedChange(gitRoot, changeId);
1145
- if (terminated) throw terminatedChangeError(gitRoot, changeId, terminated, "aiws change new");
1146
-
1147
- const title = (options.title ? String(options.title) : changeId).trim() || changeId;
1148
- const createdAt = nowIsoUtc();
1149
-
1150
- const changeRoot = path.join(gitRoot, "changes");
1151
- await ensureDir(changeRoot);
1152
- const changeDir = path.join(changeRoot, changeId);
1153
- if (await pathExists(changeDir)) throw new UserError(`Change already exists: ${changeDir}`);
1154
- await ensureDir(changeDir);
1155
- await ensureDir(path.join(changeDir, "analysis"));
1156
- await ensureDir(path.join(changeDir, "patches"));
1157
- await ensureDir(path.join(changeDir, "review"));
1158
- await ensureDir(path.join(changeDir, "evidence"));
1159
-
1160
- const templates = await resolveChangeTemplatesDir(gitRoot);
1161
- const proposalTpl = await readText(path.join(templates.templateDir, "proposal.md"));
1162
- const tasksTpl = await readText(path.join(templates.templateDir, "tasks.md"));
1163
- await writeText(path.join(changeDir, "proposal.md"), renderTemplate(proposalTpl, { changeId, title, createdAt }));
1164
- await writeText(path.join(changeDir, "tasks.md"), renderTemplate(tasksTpl, { changeId, title, createdAt }));
1165
-
1166
- if (!options.noDesign) {
1167
- const designTplPath = path.join(templates.templateDir, "design.md");
1168
- if (!(await pathExists(designTplPath))) {
1169
- throw new UserError("Missing design.md in templates dir; pass --no-design to skip.", { details: `templates: ${templates.templateDir}` });
1170
- }
1171
- const designTpl = await readText(designTplPath);
1172
- await writeText(path.join(changeDir, "design.md"), renderTemplate(designTpl, { changeId, title, createdAt }));
1173
- }
1174
-
1175
- const truth = await snapshotTruthFiles(gitRoot);
1176
- const meta = {
1177
- ws_change_version: 1,
1178
- id: changeId,
1179
- title,
1180
- created_at: createdAt,
1181
- base_truth_files: truth,
1182
- };
1183
- const baseBranch = String(options.baseBranch || "").trim();
1184
- if (baseBranch) {
1185
- meta.base_branch = baseBranch;
1186
- meta.base_branch_set_at = createdAt;
1187
- }
1188
- await writeText(path.join(changeDir, ".ws-change.json"), JSON.stringify(meta, null, 2) + "\n");
1189
- await appendMetricsEvent(gitRoot, changeId, "change_new", {
1190
- title,
1191
- created_at: createdAt,
1192
- base_branch: baseBranch || "",
1193
- templates: templates.resolvedFrom,
1194
- no_design: options.noDesign === true,
1195
- });
1196
-
1197
- console.log(`✓ aiws change new: ${changeId}`);
1198
- console.log(`dir: ${path.relative(gitRoot, changeDir)}`);
1199
- console.log(`templates: ${templates.resolvedFrom}`);
1200
- console.log("next:");
1201
- console.log(` - edit: changes/${changeId}/proposal.md`);
1202
- console.log(` - edit: changes/${changeId}/tasks.md`);
1203
- if (!options.noDesign) console.log(` - optional: changes/${changeId}/design.md`);
1204
- console.log(` - optional: changes/${changeId}/analysis/ (委托分析)`);
1205
- console.log(` - optional: changes/${changeId}/patches/ (patch 草案)`);
1206
- console.log(` - optional: changes/${changeId}/review/ (多审查结果)`);
1207
- console.log(` - validate: aiws change validate ${changeId}`);
1208
- }
1209
-
1210
- /**
1211
- * aiws change new
1212
- *
1213
- * @param {{ changeId: string, title?: string, noDesign: boolean }} options
1214
- */
1215
- export async function changeNewCommand(options) {
1216
- const gitRoot = await resolveGitRoot(process.cwd());
1217
- await changeNewAtGitRoot(gitRoot, options);
1218
- }
1219
-
1220
- /**
1221
- * aiws change start
1222
- *
1223
- * @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean, noSwitch: boolean, forceSwitch: boolean, allowDirty?: boolean }} options
1224
- */
1225
- export async function changeStartCommand(options) {
1226
- const gitRoot = await resolveGitRoot(process.cwd());
1227
- await ensureTruthFiles(gitRoot);
1228
-
1229
- const changeId = String(options.changeId || "").trim();
1230
- assertValidChangeId(changeId);
1231
- const terminated = await detectTerminatedChange(gitRoot, changeId);
1232
- if (terminated) throw terminatedChangeError(gitRoot, changeId, terminated, "aiws change start");
1233
-
1234
- const startFromBranch = await currentBranch(gitRoot);
1235
- const branch = `change/${changeId}`;
1236
- const allowDirty = options.allowDirty === true;
1237
-
1238
- if (options.forceSwitch === true && options.noSwitch === true) {
1239
- throw new UserError("change start: cannot combine --switch with --no-switch");
1240
- }
1241
-
1242
- const hasBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: gitRoot }).then((r) => r.code === 0);
1243
- const hasGitmodules = await pathExists(path.join(gitRoot, ".gitmodules"));
1244
- let effectiveNoSwitch = options.noSwitch === true;
1245
- const effectiveForceSwitch = options.forceSwitch === true;
1246
-
1247
- if (hasGitmodules && !effectiveNoSwitch && !effectiveForceSwitch) {
1248
- effectiveNoSwitch = true;
1249
- console.error("warn: .gitmodules detected (git submodules). defaulting to --no-switch (prepare artifacts only).");
1250
- console.error("warn: to switch anyway: aiws change start <change-id> --switch");
1251
- } else if (hasGitmodules && effectiveForceSwitch) {
1252
- console.error("warn: .gitmodules detected (git submodules). switching the superproject branch due to --switch.");
1253
- console.error("warn: recommended for submodules: aiws change start <change-id> --no-switch");
1254
- }
1255
-
1256
- if (!allowDirty && !effectiveNoSwitch) {
1257
- const clean = await checkGitClean(gitRoot);
1258
- if (!clean.clean) {
1259
- throw new UserError("Refusing to start change with a dirty working tree.", {
1260
- details:
1261
- `${clean.details}\n\n` +
1262
- "Fix: commit first, then retry:\n" +
1263
- " git add -A && git commit -m \"wip: save before change start\"\n" +
1264
- "Fix: or stash:\n" +
1265
- " git stash\n" +
1266
- `Alt: prepare artifacts without switching: aiws change start ${changeId} --no-switch\n` +
1267
- "Alt: proceed anyway (not recommended): add --allow-dirty",
1268
- });
1269
- }
1270
- }
1271
-
1272
- const headReady = await hasHeadCommit(gitRoot);
1273
- let branchReady = hasBranch;
1274
-
1275
- if (effectiveNoSwitch) {
1276
- branchReady = await prepareNoSwitchBranch(gitRoot, { branch, hasBranch, headReady }, { runCommand, UserError });
1277
- } else {
1278
- await switchOrCreateStartBranch(gitRoot, { branch, hasBranch, hasGitmodules }, { runCommand, UserError });
1279
- branchReady = true;
1280
- }
1281
-
1282
- const changeDir = changeDirAbs(gitRoot, changeId);
1283
- if (!(await pathExists(changeDir))) {
1284
- await changeNewAtGitRoot(gitRoot, {
1285
- changeId,
1286
- title: options.title,
1287
- noDesign: options.noDesign,
1288
- baseBranch: startFromBranch && startFromBranch !== branch ? startFromBranch : undefined,
1289
- });
1290
- } else {
1291
- await maybeRecordBaseBranch(changeDir, startFromBranch, branch, { nowIsoUtc, pathExists, readText, writeText });
1292
- }
1293
-
1294
- await checkDependenciesAndPrintHandoff(gitRoot, changeId, changeDir);
1295
-
1296
- if (options.enableHooks) {
1297
- await hooksInstallCommand({ targetPath: gitRoot });
1298
- }
1299
-
1300
- const cur = await currentBranch(gitRoot);
1301
- const curLabel = cur || "(detached)";
1302
- await appendMetricsEvent(gitRoot, changeId, "change_start", {
1303
- mode: effectiveNoSwitch ? "no-switch" : "switch",
1304
- branch,
1305
- from_branch: startFromBranch || "",
1306
- current_branch: curLabel,
1307
- hooks: options.enableHooks === true,
1308
- });
1309
- if (effectiveNoSwitch && cur !== branch) {
1310
- console.log(`ok: prepared change: ${changeId} (branch: ${branch}${branchReady ? "" : " (pending)"}; current: ${curLabel})`);
1311
- console.log("next:");
1312
- if (branchReady) {
1313
- console.log(` - switch: git switch ${branch}`);
1314
- } else {
1315
- console.log(" - note: repo has no commits; create the branch after your first commit");
1316
- console.log(` - create: git branch ${branch}`);
1317
- console.log(` - switch: git switch ${branch}`);
1318
- }
1319
- console.log(` - status: aiws change status ${changeId}`);
1320
- console.log(` - next: aiws change next ${changeId}`);
1321
- console.log(` - validate: aiws change validate ${changeId} --strict`);
1322
- if (!options.enableHooks) console.log(" - (optional) enable hooks: aiws hooks install .");
1323
- return;
1324
- }
1325
-
1326
- console.log(`ok: active change: ${changeId} (branch: ${branch}; current: ${curLabel})`);
1327
- console.log("next:");
1328
- console.log(" - status: aiws change status");
1329
- console.log(" - next: aiws change next");
1330
- console.log(" - validate: aiws change validate --strict");
1331
- if (!options.enableHooks) console.log(" - (optional) enable hooks: aiws hooks install .");
1332
- }
1333
-
1334
- /**
1335
- * aiws change finish
1336
- *
1337
- * @param {{ changeId?: string, into?: string, base?: string, push?: boolean, remote?: string }} options
1338
- */
1339
- export async function changeFinishCommand(options) {
1340
- const gitRoot = await resolveGitRoot(process.cwd());
1341
- await ensureTruthFiles(gitRoot);
1342
-
1343
- const resolvedChangeId = await resolveChangeId(gitRoot, options.changeId, { command: "finish" });
1344
- assertValidChangeId(resolvedChangeId);
1345
- const terminated = await detectTerminatedChange(gitRoot, resolvedChangeId);
1346
- if (terminated?.kind === "archived") throw terminatedChangeError(gitRoot, resolvedChangeId, terminated, "aiws change finish");
1347
- const resumeFinishedUnarchived = terminated?.kind === "finished_unarchived";
1348
- if (resumeFinishedUnarchived && options.push !== true) {
1349
- throw new UserError("Change finish closeout is pending; rerun with --push.", {
1350
- details:
1351
- `change: ${resolvedChangeId}\n` +
1352
- `next: rerun \`aiws change finish ${resolvedChangeId} --push\` to complete archive/push closeout\n` +
1353
- `fallback: if you intentionally only need local archive recovery, run \`aiws change archive ${resolvedChangeId}\``,
1354
- });
1355
- }
1356
- const { changeId, changeBranch, into, cur } = await resolveFinishContext(gitRoot, { ...options, changeId: resolvedChangeId }, {
1357
- assertValidChangeId,
1358
- checkGitClean,
1359
- currentBranch,
1360
- pathExists,
1361
- readText,
1362
- resolveChangeId,
1363
- runCommand,
1364
- UserError,
1365
- });
1366
- const finishStatusRoot = gitRoot;
1367
- const finishStatus = await computeChangeStatus(finishStatusRoot, changeId);
1368
- const finishGateErrors = collectFinishGateErrors(changeId, finishStatus.reviewGates);
1369
- if (finishGateErrors.length > 0) {
1370
- throw new UserError("Refusing to finish without complete review gates.", {
1371
- details:
1372
- `change: ${changeId}\n` +
1373
- "missing:\n" +
1374
- finishGateErrors.map((line) => `- ${line}`).join("\n") +
1375
- `\n\nnext:\n` +
1376
- `- aiws change validate ${changeId} --strict --check-evidence\n` +
1377
- `- aiws change evidence ${changeId}\n` +
1378
- `- re-run finish after all finish gates are green`,
1379
- });
1380
- }
1381
- const finishBasePayload = { into, from: changeBranch, push: options.push === true };
1382
- const archiveWorkflowDeps = {
1383
- appendMetricsEvent,
1384
- changeValidateCommand,
1385
- ensureDir,
1386
- generateHandoffContent,
1387
- nowIsoUtc,
1388
- nowStampUtc,
1389
- nowUnixSeconds,
1390
- pathExists,
1391
- readText,
1392
- snapshotTruthFiles,
1393
- todayLocal,
1394
- writeText,
1395
- UserError,
1396
- };
1397
-
1398
- if (cur && cur !== into) {
1399
- const sw = await runCommand("git", ["switch", into], { cwd: gitRoot });
1400
- if (sw.code !== 0) {
1401
- const co = await runCommand("git", ["checkout", into], { cwd: gitRoot });
1402
- if (co.code !== 0) throw new UserError("Failed to switch target branch.", { details: co.stderr || co.stdout });
1403
- }
1404
- }
1405
-
1406
- const merge = await runCommand("git", ["merge", "--ff-only", changeBranch], { cwd: gitRoot });
1407
- if (merge.code !== 0) {
1408
- throw new UserError("Failed to fast-forward merge change branch into target.", {
1409
- details:
1410
- `${merge.stderr || merge.stdout}` +
1411
- `\n\nIf not fast-forward, rebase the change branch then retry:\n` +
1412
- ` git switch ${changeBranch}\n git rebase ${into}\n git switch ${into}\n aiws change finish ${changeId}`,
1413
- });
1414
- }
1415
-
1416
- await appendMetricsEvent(gitRoot, changeId, "finish_local", finishBasePayload);
1417
- if (resumeFinishedUnarchived) {
1418
- console.log(`resume: continuing previously finished change ${changeId} to complete remaining finish steps`);
1419
- }
1420
-
1421
- let cleanupNote = "not_requested";
1422
- let finishCompleted = false;
1423
- let archivedToRel = "";
1424
- let handoffRel = "";
1425
- if (options.push === true) {
1426
- try {
1427
- const submodulePush = await pushSubmodulesForFinish(gitRoot, changeId, into, {
1428
- pathExists,
1429
- readText,
1430
- runCommand,
1431
- UserError,
1432
- });
1433
- if (submodulePush.processed > 0) {
1434
- console.log(`submodule push: ok (${submodulePush.processed} repos)`);
1435
- }
1436
-
1437
- const pushTarget = await resolvePushTarget(gitRoot, into, options.remote, { runCommand });
1438
- const push = await runCommand("git", ["push", pushTarget.remote, pushTarget.refspec], { cwd: gitRoot });
1439
- if (push.code !== 0) {
1440
- throw new UserError("Finished locally, but failed to push target branch.", {
1441
- details:
1442
- `remote: ${pushTarget.remote}\nlocal_branch: ${into}\nremote_branch: ${pushTarget.remoteBranch}\n` +
1443
- (pushTarget.trackedMergeRef ? `tracked_merge: ${pushTarget.trackedMergeRef}\n` : "") +
1444
- `\n${push.stderr || push.stdout}`,
1445
- });
1446
- }
1447
-
1448
- console.log(`push: ok (${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""})`);
1449
-
1450
- console.log(`push: target ${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""}`);
1451
-
1452
- cleanupNote = "skipped:current_worktree";
1453
- finishCompleted = true;
1454
- console.log("cleanup: skipped (no separate worktree)");
1455
- } catch (error) {
1456
- await appendMetricsEvent(gitRoot, changeId, "finish_failed", {
1457
- ...finishBasePayload,
1458
- error: error instanceof Error ? error.message : String(error),
1459
- });
1460
- throw error;
1461
- }
1462
- }
1463
-
1464
- if (options.push === true && !finishCompleted) {
1465
- const cleanupReason = cleanupNote.startsWith("skipped:") ? cleanupNote.slice("skipped:".length) : cleanupNote;
1466
- await appendMetricsEvent(gitRoot, changeId, "finish_cleanup_pending", {
1467
- ...finishBasePayload,
1468
- cleanup: cleanupNote,
1469
- reason: cleanupReason,
1470
- });
1471
- throw new UserError("Change finish incomplete: cleanup pending.", {
1472
- details:
1473
- `change: ${changeId}\n` +
1474
- `next: cleanup pending (${cleanupReason || "unknown"})\n` +
1475
- ` - then rerun: aiws change finish ${changeId} --push${options.remote ? ` --remote ${options.remote}` : ""}`,
1476
- });
1477
- }
1478
-
1479
- if (finishCompleted) {
1480
- const archiveResult = await runChangeArchiveWorkflow(
1481
- {
1482
- gitRoot,
1483
- changeId,
1484
- changeDir: changeDirAbs(gitRoot, changeId),
1485
- force: false,
1486
- },
1487
- archiveWorkflowDeps,
1488
- );
1489
- archivedToRel = archiveResult.archivedToRel;
1490
- handoffRel = archiveResult.handoffRel;
1491
- await appendMetricsEventAtDir(archiveResult.archivedTo, changeId, "finish_done", { ...finishBasePayload, cleanup: cleanupNote });
1492
-
1493
- if (options.push === true) {
1494
- const stageArchive = await runCommand("git", ["add", "-A", "--", "changes"], { cwd: gitRoot });
1495
- if (stageArchive.code !== 0) {
1496
- throw new UserError("Finish completed, but failed to stage archived change artifacts.", {
1497
- details: stageArchive.stderr || stageArchive.stdout,
1498
- });
1499
- }
1500
-
1501
- const stagedArchive = await runCommand("git", ["diff", "--cached", "--quiet", "--", "changes"], { cwd: gitRoot });
1502
- if (stagedArchive.code !== 0) {
1503
- const archiveCommit = await runCommand("git", ["commit", "-m", `归档: ${changeId}`], { cwd: gitRoot });
1504
- if (archiveCommit.code !== 0) {
1505
- throw new UserError("Finish completed, but failed to commit archived change artifacts.", {
1506
- details: `archived_to: ${archivedToRel}\nhandoff: ${handoffRel}\n\n${archiveCommit.stderr || archiveCommit.stdout}`,
1507
- });
1508
- }
1509
-
1510
- const archivePushTarget = await resolvePushTarget(gitRoot, into, options.remote, { runCommand });
1511
- const archivePush = await runCommand("git", ["push", archivePushTarget.remote, archivePushTarget.refspec], { cwd: gitRoot });
1512
- if (archivePush.code !== 0) {
1513
- throw new UserError("Finish completed, but failed to push archived change artifacts.", {
1514
- details:
1515
- `archived_to: ${archivedToRel}\n` +
1516
- `handoff: ${handoffRel}\n` +
1517
- `remote: ${archivePushTarget.remote}\nlocal_branch: ${into}\nremote_branch: ${archivePushTarget.remoteBranch}\n` +
1518
- (archivePushTarget.trackedMergeRef ? `tracked_merge: ${archivePushTarget.trackedMergeRef}\n` : "") +
1519
- `\n${archivePush.stderr || archivePush.stdout}`,
1520
- });
1521
- }
1522
- }
1523
- }
1524
- }
1525
-
1526
- const outcomeLabel =
1527
- options.push === true ? (finishCompleted ? "finished change" : "pushed change; cleanup pending") : "finished locally";
1528
- console.log(`ok: ${outcomeLabel}: ${changeId}`);
1529
- console.log(`into: ${into}`);
1530
- console.log(`from: ${changeBranch}`);
1531
- if (archivedToRel) console.log(`archived_to: ${archivedToRel}`);
1532
- if (handoffRel) console.log(`handoff: ${handoffRel}`);
1533
-
1534
- if (options.push === true && (await listSubmodulesFromGitmodules(gitRoot, { pathExists, runCommand })).length > 0) {
1535
- console.log("next:");
1536
- console.log(" - submodules already synced for finish --push; rerun `git submodule update --init --recursive` only if needed when submodule state changes");
1537
- }
1538
- }
1539
-
1540
- /**
1541
- * aiws change status
1542
- *
1543
- * @param {{ changeId?: string }} options
1544
- */
1545
- export async function changeStatusCommand(options) {
1546
- const gitRoot = await resolveGitRoot(process.cwd());
1547
- await ensureTruthFiles(gitRoot);
1548
-
1549
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "status" });
1550
- const st = await computeChangeStatus(gitRoot, changeId);
1551
- const branch = await currentBranch(gitRoot);
1552
- const output = await renderChangeStatusOutput(
1553
- {
1554
- gitRoot,
1555
- branch,
1556
- status: st,
1557
- },
1558
- {
1559
- effectiveReviewCount,
1560
- governanceGuidanceLines,
1561
- planVerifyHint,
1562
- },
1563
- );
1564
- process.stdout.write(output);
1565
- }
1566
-
1567
- /**
1568
- * aiws change next
1569
- *
1570
- * @param {{ changeId?: string }} options
1571
- */
1572
- export async function changeNextCommand(options) {
1573
- const gitRoot = await resolveGitRoot(process.cwd());
1574
- await ensureTruthFiles(gitRoot);
1575
-
1576
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "next" });
1577
- assertValidChangeId(changeId);
1578
- const st = await computeChangeStatus(gitRoot, changeId);
1579
-
1580
- const changeDir = changeDirAbs(gitRoot, changeId);
1581
- if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
1582
- const output = await renderChangeNextOutput(
1583
- {
1584
- gitRoot,
1585
- changeId,
1586
- changeDir,
1587
- status: st,
1588
- },
1589
- {
1590
- analyzeEvidencePaths,
1591
- computeChangeNextAdvice,
1592
- extractId,
1593
- fileState,
1594
- pathExists,
1595
- planVerifyHint,
1596
- splitDeclaredValues,
1597
- },
1598
- );
1599
- process.stdout.write(output);
1600
- }
1601
-
1602
- /**
1603
- * @param {string} gitRoot
1604
- * @param {string} changeId
1605
- */
1606
- export async function computeChangeNextAdvice(gitRoot, changeId) {
1607
- return computeChangeNextAdviceModel(gitRoot, changeId, {
1608
- computeChangeStatus,
1609
- currentBranch,
1610
- inferChangeIdFromBranch,
1611
- changeDirAbs,
1612
- governanceGuidanceLines,
1613
- scopeGateFailureAction,
1614
- scopeGatePendingRecommendation: (id) => scopeGatePendingRecommendation(id),
1615
- scopeGatePassedRecommendation,
1616
- });
1617
- }
1618
-
1619
- /**
1620
- * aiws change state
1621
- *
1622
- * @param {{ changeId?: string, write: boolean }} options
1623
- */
1624
- export async function changeStateCommand(options) {
1625
- const gitRoot = await resolveGitRoot(process.cwd());
1626
- await ensureTruthFiles(gitRoot);
1627
-
1628
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "state" });
1629
- assertValidChangeId(changeId);
1630
- const result = await runChangeStateCommand(
1631
- {
1632
- gitRoot,
1633
- changeId,
1634
- options,
1635
- },
1636
- {
1637
- appendMetricsEvent,
1638
- changeDirAbs,
1639
- computeChangeNextAdvice,
1640
- computeChangeStatus,
1641
- nowIsoUtc,
1642
- planVerifyHint,
1643
- renderChangeStateMarkdown,
1644
- writeText,
1645
- },
1646
- );
1647
- process.stdout.write(result.output);
1648
- }
1649
-
1650
- /**
1651
- * aiws metrics summary
1652
- *
1653
- * @param {{ since?: string }} options
1654
- */
1655
- export async function metricsSummaryCommand(options) {
1656
- const gitRoot = await resolveGitRoot(process.cwd());
1657
- await ensureTruthFiles(gitRoot);
1658
-
1659
- const sinceRaw = String(options.since || "").trim();
1660
- const since = sinceRaw ? new Date(`${sinceRaw}T00:00:00Z`).getTime() : 0;
1661
- if (sinceRaw && Number.isNaN(since)) throw new UserError("Invalid --since (expected YYYY-MM-DD).", { details: `since=${sinceRaw}` });
1662
- const output = await renderMetricsSummaryOutput(
1663
- {
1664
- gitRoot,
1665
- sinceRaw,
1666
- since,
1667
- },
1668
- {
1669
- pathExists,
1670
- readText,
1671
- },
1672
- );
1673
- process.stdout.write(output);
1674
- }
1675
-
1676
- /**
1677
- * aiws change evidence
1678
- *
1679
- * @param {{ changeId?: string, noValidate: boolean, allowFail: boolean }} options
1680
- */
1681
- export async function changeEvidenceCommand(options) {
1682
- const gitRoot = await resolveGitRoot(process.cwd());
1683
- await ensureTruthFiles(gitRoot);
1684
-
1685
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "evidence" });
1686
- assertValidChangeId(changeId);
1687
- const result = await runChangeEvidenceCommand(
1688
- {
1689
- gitRoot,
1690
- changeId,
1691
- options,
1692
- },
1693
- {
1694
- changeDirAbs,
1695
- fileState,
1696
- pathExists,
1697
- runChangeEvidenceWorkflow,
1698
- workflowDeps: {
1699
- appendMetricsEvent,
1700
- computeChangeStatus,
1701
- currentBranch,
1702
- ensureDir,
1703
- extractId,
1704
- nowIsoUtc,
1705
- nowStampUtc,
1706
- pathExists,
1707
- planVerifyHint,
1708
- readText,
1709
- splitDeclaredValues,
1710
- validateChangeArtifacts,
1711
- writeText,
1712
- UserError,
1713
- },
1714
- UserError,
1715
- },
1716
- );
1717
- process.stdout.write(result.output);
1718
- if (result.errorToThrow) throw result.errorToThrow;
1719
- }
1720
-
1721
- /**
1722
- * aiws change validate
1723
- *
1724
- * @param {{ changeId?: string, strict: boolean, allowTruthDrift: boolean, checkEvidence: boolean, checkScope: boolean, allowFinishedUnarchived?: boolean }} options
1725
- */
1726
- export async function changeValidateCommand(options) {
1727
- const gitRoot = await resolveGitRoot(process.cwd());
1728
- await ensureTruthFiles(gitRoot);
1729
-
1730
- const changeId = await resolveChangeId(gitRoot, options.changeId, {
1731
- command: "validate",
1732
- allowFinishedUnarchived: options.allowFinishedUnarchived === true,
1733
- });
1734
- assertValidChangeId(changeId);
1735
- const result = await runChangeValidateCommand(
1736
- {
1737
- gitRoot,
1738
- changeId,
1739
- options,
1740
- },
1741
- {
1742
- appendMetricsEvent,
1743
- validateChangeArtifacts,
1744
- UserError,
1745
- },
1746
- );
1747
- if (result.stderr) process.stderr.write(result.stderr);
1748
- if (result.errorToThrow) throw result.errorToThrow;
1749
- process.stdout.write(result.output);
1750
- }
1751
-
1752
- /**
1753
- * aiws change sync
1754
- *
1755
- * @param {{ changeId?: string }} options
1756
- */
1757
- export async function changeSyncCommand(options) {
1758
- const gitRoot = await resolveGitRoot(process.cwd());
1759
- await ensureTruthFiles(gitRoot);
1760
-
1761
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "sync" });
1762
- assertValidChangeId(changeId);
1763
- const result = await runChangeSyncCommand(
1764
- {
1765
- gitRoot,
1766
- changeId,
1767
- },
1768
- {
1769
- changeDirAbs,
1770
- pathExists,
1771
- runChangeSyncWorkflow,
1772
- workflowDeps: {
1773
- appendMetricsEvent,
1774
- ensureDir,
1775
- nowIsoUtc,
1776
- nowStampUtc,
1777
- nowUnixSeconds,
1778
- pathExists,
1779
- readText,
1780
- snapshotTruthFiles,
1781
- writeText,
1782
- },
1783
- UserError,
1784
- },
1785
- );
1786
- process.stdout.write(result.output);
1787
- }
1788
-
1789
- /**
1790
- * aiws change archive
1791
- *
1792
- * @param {{ changeId?: string, datePrefix?: string, force: boolean }} options
1793
- */
1794
- export async function changeArchiveCommand(options) {
1795
- const gitRoot = await resolveGitRoot(process.cwd());
1796
- await ensureTruthFiles(gitRoot);
1797
-
1798
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "archive" });
1799
- assertValidChangeId(changeId);
1800
- const result = await runChangeArchiveCommand(
1801
- {
1802
- gitRoot,
1803
- changeId,
1804
- options,
1805
- },
1806
- {
1807
- changeDirAbs,
1808
- pathExists,
1809
- runChangeArchiveWorkflow,
1810
- workflowDeps: {
1811
- appendMetricsEvent,
1812
- changeValidateCommand,
1813
- ensureDir,
1814
- generateHandoffContent,
1815
- nowIsoUtc,
1816
- nowStampUtc,
1817
- nowUnixSeconds,
1818
- pathExists,
1819
- readText,
1820
- snapshotTruthFiles,
1821
- todayLocal,
1822
- writeText,
1823
- UserError,
1824
- },
1825
- UserError,
1826
- },
1827
- );
1828
- process.stdout.write(result.output);
1829
- }