@aipper/aiws 0.0.24 → 0.0.26

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 (35) hide show
  1. package/README.md +89 -33
  2. package/package.json +3 -3
  3. package/src/cli.js +40 -2
  4. package/src/codex-skills.js +4 -4
  5. package/src/commands/change-advice.js +191 -0
  6. package/src/commands/change-evidence-command.js +242 -0
  7. package/src/commands/change-evidence-entry.js +40 -0
  8. package/src/commands/change-evidence.js +338 -0
  9. package/src/commands/change-finish.js +484 -0
  10. package/src/commands/change-git-status.js +53 -0
  11. package/src/commands/change-lifecycle-entry.js +60 -0
  12. package/src/commands/change-lifecycle.js +234 -0
  13. package/src/commands/change-metrics-command.js +110 -0
  14. package/src/commands/change-next-command.js +132 -0
  15. package/src/commands/change-review-gates.js +315 -0
  16. package/src/commands/change-scope-gate.js +34 -0
  17. package/src/commands/change-start.js +138 -0
  18. package/src/commands/change-state-command.js +40 -0
  19. package/src/commands/change-status-command.js +126 -0
  20. package/src/commands/change-status-context.js +202 -0
  21. package/src/commands/change-validate-entry.js +45 -0
  22. package/src/commands/change-validate.js +127 -0
  23. package/src/commands/change.js +540 -1692
  24. package/src/commands/dashboard.js +38 -12
  25. package/src/commands/init.js +1 -1
  26. package/src/commands/opencode-status.js +61 -0
  27. package/src/commands/update.js +5 -1
  28. package/src/dashboard/app.js +205 -2
  29. package/src/dashboard/index.html +5 -2
  30. package/src/dashboard/style.css +86 -0
  31. package/src/governance.js +157 -0
  32. package/src/json-schema-lite.js +164 -0
  33. package/src/opencode-env.js +76 -0
  34. package/src/spec.js +131 -0
  35. package/src/template.js +42 -2
@@ -5,12 +5,66 @@ import { runCommand } from "../exec.js";
5
5
  import { pathExists, readText, writeText, ensureDir } from "../fs.js";
6
6
  import { UserError } from "../errors.js";
7
7
  import { loadTemplate } from "../spec.js";
8
+ import { effectiveReviewCount, governanceGuidanceLines, inferChangeGovernance } from "../governance.js";
8
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 { runChangeValidateCommand } from "./change-validate-entry.js";
25
+ import {
26
+ computeChangeNextAdvice as computeChangeNextAdviceModel,
27
+ renderChangeStateMarkdown,
28
+ resolveChangeWorktreeHint,
29
+ } from "./change-advice.js";
30
+ import {
31
+ cleanupWorktreePath,
32
+ isGitRepository,
33
+ listSubmodulesFromGitmodules,
34
+ pushSubmodulesForFinish,
35
+ resolveFinishContext,
36
+ resolvePushTarget,
37
+ } from "./change-finish.js";
38
+ import {
39
+ checkWorktreePrereqs,
40
+ maybeRecordBaseBranch,
41
+ prepareNoSwitchBranch,
42
+ resolveDefaultWorktreeDir,
43
+ switchOrCreateStartBranch,
44
+ } from "./change-start.js";
45
+ import {
46
+ collectFinishGateErrors,
47
+ collectReviewGates,
48
+ collectReviewSignals,
49
+ } from "./change-review-gates.js";
50
+ import {
51
+ computeScopeGate,
52
+ scopeGateFailureAction,
53
+ scopeGatePassedRecommendation,
54
+ scopeGatePendingRecommendation,
55
+ } from "./change-scope-gate.js";
56
+ import {
57
+ readChangeMetrics,
58
+ resolveChangeStatusContext,
59
+ summarizeLifecycleSignals,
60
+ truthDrift,
61
+ } from "./change-status-context.js";
62
+ import { renderChangeStatusOutput } from "./change-status-command.js";
63
+ import { validateChangeArtifacts as validateChangeArtifactsModel } from "./change-validate.js";
9
64
  import { hooksInstallCommand } from "./hooks-install.js";
10
65
 
11
66
  const CHANGE_BRANCH_RE = /^(change|changes|ws|ws-change)\/([a-z0-9]+(?:-[a-z0-9]+)*)$/;
12
67
  const CHANGE_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
-
14
68
  /**
15
69
  * @param {string} changeId
16
70
  */
@@ -107,62 +161,6 @@ async function checkGitClean(gitRoot) {
107
161
  return { clean: false, details: truncated };
108
162
  }
109
163
 
110
- /**
111
- * @param {string} gitRoot
112
- * @returns {Promise<{ ok: true } | { ok: false, reason: "no_commit" | "missing_truth", missing?: string[] }>}
113
- */
114
- async function checkWorktreePrereqs(gitRoot) {
115
- if (!(await hasHeadCommit(gitRoot))) return { ok: false, reason: "no_commit" };
116
- /** @type {string[]} */
117
- const missingInHead = [];
118
- for (const rel of ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"]) {
119
- const ok = await runCommand("git", ["cat-file", "-e", `HEAD:${rel}`], { cwd: gitRoot }).then((r) => r.code === 0);
120
- if (!ok) missingInHead.push(rel);
121
- }
122
- if (missingInHead.length > 0) return { ok: false, reason: "missing_truth", missing: missingInHead };
123
- return { ok: true };
124
- }
125
-
126
- /**
127
- * Best-effort detection for "unknown option" errors related to --recurse-submodules.
128
- *
129
- * We only fall back when the option itself appears in output, to avoid silently
130
- * bypassing submodule safety checks when switching fails for real reasons
131
- * (e.g. dirty submodules).
132
- *
133
- * @param {{ stdout: string, stderr: string }} res
134
- */
135
- function outputMentionsRecurseSubmodules(res) {
136
- const out = `${res.stderr || ""}\n${res.stdout || ""}`;
137
- return out.includes("recurse-submodules");
138
- }
139
-
140
- /**
141
- * @param {string} changeDir
142
- * @param {string} baseBranch
143
- * @param {string} changeBranch
144
- */
145
- async function maybeRecordBaseBranch(changeDir, baseBranch, changeBranch) {
146
- const b = String(baseBranch || "").trim();
147
- if (!b) return;
148
- if (b === String(changeBranch || "").trim()) return;
149
- const metaPath = path.join(changeDir, ".ws-change.json");
150
- if (!(await pathExists(metaPath))) return;
151
- /** @type {any} */
152
- let meta = null;
153
- try {
154
- meta = JSON.parse(await readText(metaPath));
155
- } catch {
156
- meta = null;
157
- }
158
- if (!meta || typeof meta !== "object") return;
159
- const cur = String(meta.base_branch || "").trim();
160
- if (cur) return;
161
- meta.base_branch = b;
162
- meta.base_branch_set_at = nowIsoUtc();
163
- await writeText(metaPath, JSON.stringify(meta, null, 2) + "\n");
164
- }
165
-
166
164
  /**
167
165
  * @param {string} gitRoot
168
166
  * @returns {Promise<Array<{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string }>>}
@@ -207,456 +205,6 @@ async function listGitWorktrees(gitRoot) {
207
205
  return out;
208
206
  }
209
207
 
210
- /**
211
- * @param {string} gitRoot
212
- * @param {string} branch
213
- * @param {string | undefined} preferredRemote
214
- */
215
- async function resolvePushTarget(gitRoot, branch, preferredRemote) {
216
- const remoteRaw = String(preferredRemote || "").trim();
217
- const branchRemoteRes = await runCommand("git", ["config", "--get", `branch.${branch}.remote`], { cwd: gitRoot });
218
- const trackedRemote = String(branchRemoteRes.stdout || "").trim();
219
-
220
- const branchMergeRes = await runCommand("git", ["config", "--get", `branch.${branch}.merge`], { cwd: gitRoot });
221
- const trackedMergeRef = String(branchMergeRes.stdout || "").trim();
222
- const trackedBranch = trackedMergeRef.startsWith("refs/heads/") ? trackedMergeRef.slice("refs/heads/".length) : "";
223
-
224
- const remote = remoteRaw || trackedRemote || "origin";
225
- const remoteBranch = trackedBranch || branch;
226
- const refspec = remoteBranch === branch ? branch : `${branch}:${remoteBranch}`;
227
-
228
- return { remote, remoteBranch, refspec, trackedRemote, trackedMergeRef };
229
- }
230
-
231
- /**
232
- * @param {string} gitRoot
233
- * @param {string} worktreePath
234
- */
235
- async function cleanupWorktreePath(gitRoot, worktreePath) {
236
- const abs = path.resolve(worktreePath);
237
- if (abs === path.resolve(gitRoot)) {
238
- return { status: "skipped", reason: "current_worktree", worktree: abs };
239
- }
240
-
241
- const worktrees = await listGitWorktrees(gitRoot);
242
- const worktreeEntry = worktrees.find((w) => path.resolve(w.worktree) === abs);
243
- if (worktreeEntry?.locked) {
244
- return { status: "skipped", reason: "locked", worktree: abs, details: worktreeEntry.lockReason };
245
- }
246
-
247
- if (!(await pathExists(abs))) {
248
- const prune = await runCommand("git", ["worktree", "prune"], { cwd: gitRoot });
249
- if (prune.code !== 0) {
250
- throw new UserError("Failed to prune stale git worktree metadata.", { details: prune.stderr || prune.stdout });
251
- }
252
- return { status: "pruned-missing", worktree: abs };
253
- }
254
-
255
- const clean = await checkGitClean(abs);
256
- if (!clean.clean) {
257
- return { status: "skipped", reason: "dirty", worktree: abs, details: clean.details };
258
- }
259
-
260
- let rm = await runCommand("git", ["worktree", "remove", abs], { cwd: gitRoot });
261
- if (rm.code !== 0) {
262
- const out = `${rm.stderr || ""}\n${rm.stdout || ""}`;
263
- if (out.includes("working trees containing submodules cannot be moved or removed")) {
264
- rm = await runCommand("git", ["worktree", "remove", "--force", abs], { cwd: gitRoot });
265
- }
266
- }
267
- if (rm.code !== 0) {
268
- throw new UserError("Failed to remove change worktree.", { details: rm.stderr || rm.stdout });
269
- }
270
-
271
- const prune = await runCommand("git", ["worktree", "prune"], { cwd: gitRoot });
272
- if (prune.code !== 0) {
273
- throw new UserError("Failed to prune git worktree metadata after remove.", { details: prune.stderr || prune.stdout });
274
- }
275
-
276
- return { status: "removed", worktree: abs };
277
- }
278
-
279
- /**
280
- * @param {string} gitRoot
281
- * @returns {Promise<Array<{ name: string, path: string }>>}
282
- */
283
- async function listSubmodulesFromGitmodules(gitRoot) {
284
- const gitmodules = path.join(gitRoot, ".gitmodules");
285
- if (!(await pathExists(gitmodules))) return [];
286
-
287
- const list = await runCommand("git", ["config", "--file", ".gitmodules", "--get-regexp", "^submodule\\..*\\.path$"], { cwd: gitRoot });
288
- if (list.code !== 0) return [];
289
-
290
- /** @type {Array<{ name: string, path: string }>} */
291
- const subs = [];
292
- for (const raw of String(list.stdout || "").split("\n")) {
293
- const line = raw.trim();
294
- if (!line) continue;
295
- const idx = line.indexOf(" ");
296
- if (idx <= 0) continue;
297
- const key = line.slice(0, idx).trim();
298
- const subPath = line.slice(idx + 1).trim();
299
- const m = key.match(/^submodule\.(.+)\.path$/);
300
- if (!m) continue;
301
- const name = String(m[1] || "").trim();
302
- if (!name || !subPath) continue;
303
- subs.push({ name, path: subPath });
304
- }
305
- return subs;
306
- }
307
-
308
- /**
309
- * @param {string} targetsPath
310
- */
311
- async function parseSubmoduleTargetsFile(targetsPath) {
312
- /** @type {Map<string, { branch: string, remote: string }>} */
313
- const parsed = new Map();
314
- /** @type {string[]} */
315
- const errors = [];
316
-
317
- if (!(await pathExists(targetsPath))) {
318
- return { parsed, errors };
319
- }
320
-
321
- const text = await readText(targetsPath);
322
- const lines = String(text || "").split(/\r?\n/);
323
- for (let i = 0; i < lines.length; i += 1) {
324
- const raw = lines[i] || "";
325
- const line = raw.trim();
326
- if (!line || line.startsWith("#")) continue;
327
- const parts = line.split(/\s+/).filter(Boolean);
328
- if (parts.length < 2) {
329
- errors.push(`${path.basename(targetsPath)}:${i + 1} invalid line (expected: <path> <target_branch> [remote])`);
330
- continue;
331
- }
332
- const subPath = String(parts[0] || "").trim();
333
- const targetBranch = String(parts[1] || "").trim();
334
- const remote = String(parts[2] || "origin").trim() || "origin";
335
- if (!subPath) {
336
- errors.push(`${path.basename(targetsPath)}:${i + 1} missing submodule path`);
337
- continue;
338
- }
339
- if (parsed.has(subPath)) {
340
- errors.push(`${path.basename(targetsPath)}:${i + 1} duplicate submodule path: ${subPath}`);
341
- continue;
342
- }
343
- if (!targetBranch || ["<fill-me>", "<fill_me>", "tbd", "TBD"].includes(targetBranch)) {
344
- errors.push(`${path.basename(targetsPath)}:${i + 1} missing/placeholder target_branch for ${subPath}`);
345
- continue;
346
- }
347
- parsed.set(subPath, { branch: targetBranch, remote });
348
- }
349
-
350
- return { parsed, errors };
351
- }
352
-
353
- /**
354
- * @param {string} repoPath
355
- * @param {string} rev
356
- */
357
- async function gitHasRevision(repoPath, rev) {
358
- if (!(await pathExists(repoPath))) return false;
359
- const res = await runCommand("git", ["-C", repoPath, "cat-file", "-e", `${rev}^{commit}`], { cwd: repoPath });
360
- return res.code === 0;
361
- }
362
-
363
- /**
364
- * @param {string} repoPath
365
- * @returns {Promise<boolean>}
366
- */
367
- async function isGitRepository(repoPath) {
368
- if (!(await pathExists(repoPath))) return false;
369
- const res = await runCommand("git", ["-C", repoPath, "rev-parse", "--git-dir"], { cwd: repoPath });
370
- return res.code === 0;
371
- }
372
-
373
- /**
374
- * @param {string} targetRepoPath
375
- * @param {string} sha
376
- * @param {string | undefined} fallbackRepoPath
377
- */
378
- async function ensureRevisionAvailable(targetRepoPath, sha, fallbackRepoPath) {
379
- if (await gitHasRevision(targetRepoPath, sha)) return;
380
- const fallback = String(fallbackRepoPath || "").trim();
381
- if (!fallback) {
382
- throw new UserError("Required submodule commit is not available locally.", {
383
- details: `repo: ${targetRepoPath}\ncommit: ${sha}`,
384
- });
385
- }
386
- if (!(await gitHasRevision(fallback, sha))) {
387
- throw new UserError("Required submodule commit is missing in both target and change worktrees.", {
388
- details: `target_repo: ${targetRepoPath}\nchange_repo: ${fallback}\ncommit: ${sha}`,
389
- });
390
- }
391
- const fetch = await runCommand("git", ["-C", targetRepoPath, "fetch", fallback, sha], { cwd: targetRepoPath });
392
- if (fetch.code !== 0 || !(await gitHasRevision(targetRepoPath, sha))) {
393
- throw new UserError("Failed to import submodule commit from change worktree.", {
394
- details: `target_repo: ${targetRepoPath}\nchange_repo: ${fallback}\ncommit: ${sha}\n\n${fetch.stderr || fetch.stdout}`,
395
- });
396
- }
397
- }
398
-
399
- /**
400
- * @param {string} repoPath
401
- * @param {string} remote
402
- * @param {string} branch
403
- * @param {string} sha
404
- */
405
- async function pushSubmoduleBranch(repoPath, remote, branch, sha) {
406
- const fetch = await runCommand("git", ["-C", repoPath, "fetch", remote, "--prune"], { cwd: repoPath });
407
- if (fetch.code !== 0) {
408
- throw new UserError("Failed to fetch submodule remote before push.", {
409
- details: `repo: ${repoPath}\nremote: ${remote}\n\n${fetch.stderr || fetch.stdout}`,
410
- });
411
- }
412
-
413
- const remoteRef = `refs/remotes/${remote}/${branch}`;
414
- const hasRemoteBranch = await runCommand("git", ["-C", repoPath, "show-ref", "--verify", "--quiet", remoteRef], { cwd: repoPath }).then((r) => r.code === 0);
415
- if (!hasRemoteBranch) {
416
- throw new UserError("Submodule target branch does not exist on remote.", {
417
- details: `repo: ${repoPath}\nremote: ${remote}\nbranch: ${branch}`,
418
- });
419
- }
420
-
421
- const remoteAncestor = await runCommand("git", ["-C", repoPath, "merge-base", "--is-ancestor", remoteRef, sha], { cwd: repoPath }).then((r) => r.code === 0);
422
- if (remoteAncestor) {
423
- const push = await runCommand("git", ["-C", repoPath, "push", remote, `${sha}:refs/heads/${branch}`], { cwd: repoPath });
424
- if (push.code !== 0) {
425
- throw new UserError("Failed to fast-forward push submodule target branch.", {
426
- details: `repo: ${repoPath}\nremote: ${remote}\nbranch: ${branch}\ncommit: ${sha}\n\n${push.stderr || push.stdout}`,
427
- });
428
- }
429
- return { status: "pushed", remoteRef };
430
- }
431
-
432
- const shaContained = await runCommand("git", ["-C", repoPath, "merge-base", "--is-ancestor", sha, remoteRef], { cwd: repoPath }).then((r) => r.code === 0);
433
- if (shaContained) {
434
- return { status: "already-contained", remoteRef };
435
- }
436
-
437
- throw new UserError("Submodule target branch diverged from gitlink commit.", {
438
- details: `repo: ${repoPath}\nremote: ${remote}\nbranch: ${branch}\ncommit: ${sha}\n\nHint: reconcile the submodule branch manually, then retry.`,
439
- });
440
- }
441
-
442
- /**
443
- * @param {string} gitRoot
444
- * @param {string} changeId
445
- * @param {string} baseBranch
446
- * @param {string | undefined} changeWorktreePath
447
- */
448
- async function pushSubmodulesForFinish(gitRoot, changeId, baseBranch, changeWorktreePath) {
449
- const subs = await listSubmodulesFromGitmodules(gitRoot);
450
- if (subs.length === 0) return { processed: 0 };
451
-
452
- const targetsPath = path.join(gitRoot, "changes", changeId, "submodules.targets");
453
- if (!(await pathExists(targetsPath))) {
454
- throw new UserError("Missing submodules.targets for submodule-aware finish.", {
455
- details: `required: ${path.relative(gitRoot, targetsPath)}\n\nHint: add one entry per submodule path before running \`aiws change finish --push\`.`,
456
- });
457
- }
458
-
459
- const parsedTargets = await parseSubmoduleTargetsFile(targetsPath);
460
- if (parsedTargets.errors.length > 0) {
461
- throw new UserError("Invalid submodules.targets.", { details: parsedTargets.errors.join("\n") });
462
- }
463
-
464
- const missingPaths = subs.filter((s) => !parsedTargets.parsed.has(s.path)).map((s) => s.path);
465
- if (missingPaths.length > 0) {
466
- throw new UserError("submodules.targets missing declared submodule paths.", {
467
- details: `file: ${path.relative(gitRoot, targetsPath)}\nmissing: ${missingPaths.join(", ")}`,
468
- });
469
- }
470
-
471
- /** @type {Array<{ name: string, path: string, sha: string, branch: string, remote: string, pinBranch: string }>} */
472
- const specs = [];
473
- for (const sub of subs) {
474
- const target = parsedTargets.parsed.get(sub.path);
475
- if (!target) continue;
476
- const targetBranch = target.branch === "." ? baseBranch : target.branch;
477
- const gitlink = await runCommand("git", ["rev-parse", `HEAD:${sub.path}`], { cwd: gitRoot });
478
- if (gitlink.code !== 0) {
479
- throw new UserError("Failed to resolve submodule gitlink commit from merged superproject.", {
480
- details: `path: ${sub.path}\n\n${gitlink.stderr || gitlink.stdout}`,
481
- });
482
- }
483
- const sha = String(gitlink.stdout || "").trim();
484
- specs.push({
485
- name: sub.name,
486
- path: sub.path,
487
- sha,
488
- branch: targetBranch,
489
- remote: target.remote,
490
- pinBranch: `aiws/pin/${targetBranch}`,
491
- });
492
- }
493
-
494
- for (const spec of specs) {
495
- const targetRepoPath = path.join(gitRoot, spec.path);
496
- const changeRepoPath = changeWorktreePath ? path.join(changeWorktreePath, spec.path) : "";
497
- const targetRepoReady = await isGitRepository(targetRepoPath);
498
- const changeRepoReady = changeRepoPath ? await isGitRepository(changeRepoPath) : false;
499
- if (!targetRepoReady && !changeRepoReady) {
500
- throw new UserError("Submodule repository is not initialized in either target or change worktree.", {
501
- details: `path: ${spec.path}\nHint: initialize submodules before finish, or keep using worktree mode for changes involving submodules.`,
502
- });
503
- }
504
-
505
- const sourceRepoPath = (targetRepoReady && (await gitHasRevision(targetRepoPath, spec.sha))) ? targetRepoPath : changeRepoReady ? changeRepoPath : targetRepoPath;
506
- if (!(await gitHasRevision(sourceRepoPath, spec.sha))) {
507
- throw new UserError("Merged submodule gitlink commit is not available locally.", {
508
- details: `path: ${spec.path}\ncommit: ${spec.sha}\nsource_repo: ${sourceRepoPath}`,
509
- });
510
- }
511
-
512
- const pushResult = await pushSubmoduleBranch(sourceRepoPath, spec.remote, spec.branch, spec.sha);
513
- console.log(
514
- `submodule push: ${spec.path} (${spec.remote} ${spec.branch}${pushResult.status === "pushed" ? ` <= ${spec.sha}` : ", already contained"})`,
515
- );
516
- }
517
-
518
- const sync = await runCommand("git", ["submodule", "update", "--init", "--recursive"], { cwd: gitRoot });
519
- if (sync.code !== 0) {
520
- throw new UserError("Submodule push succeeded, but failed to sync target worktree submodules.", {
521
- details: sync.stderr || sync.stdout,
522
- });
523
- }
524
-
525
- for (const spec of specs) {
526
- const targetRepoPath = path.join(gitRoot, spec.path);
527
- if (!(await isGitRepository(targetRepoPath))) {
528
- throw new UserError("Submodule repo missing after sync.", { details: `path: ${spec.path}` });
529
- }
530
- await ensureRevisionAvailable(targetRepoPath, spec.sha, changeWorktreePath ? path.join(changeWorktreePath, spec.path) : "");
531
- const fetch = await runCommand("git", ["-C", targetRepoPath, "fetch", spec.remote, "--prune"], { cwd: targetRepoPath });
532
- if (fetch.code !== 0) {
533
- throw new UserError("Failed to refresh target worktree submodule remote.", {
534
- details: `path: ${spec.path}\nremote: ${spec.remote}\n\n${fetch.stderr || fetch.stdout}`,
535
- });
536
- }
537
- const checkout = await runCommand("git", ["-C", targetRepoPath, "checkout", "-B", spec.pinBranch, spec.sha], { cwd: targetRepoPath });
538
- if (checkout.code !== 0) {
539
- throw new UserError("Failed to attach target worktree submodule to pin branch.", {
540
- details: `path: ${spec.path}\npin_branch: ${spec.pinBranch}\ncommit: ${spec.sha}\n\n${checkout.stderr || checkout.stdout}`,
541
- });
542
- }
543
- await runCommand("git", ["-C", targetRepoPath, "branch", "--set-upstream-to", `${spec.remote}/${spec.branch}`, spec.pinBranch], { cwd: targetRepoPath });
544
- }
545
-
546
- return { processed: specs.length };
547
- }
548
-
549
- /**
550
- * @param {string} gitRoot
551
- * @param {{ changeId?: string, into?: string, base?: string }} options
552
- */
553
- async function resolveFinishContext(gitRoot, options) {
554
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "finish" });
555
- assertValidChangeId(changeId);
556
-
557
- const changeBranch = `change/${changeId}`;
558
- const changeBranchRef = `refs/heads/${changeBranch}`;
559
-
560
- const hasChangeBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", changeBranchRef], { cwd: gitRoot }).then((r) => r.code === 0);
561
- if (!hasChangeBranch) {
562
- throw new UserError(`Missing change branch: ${changeBranch}`, {
563
- details: "Hint: create it with `aiws change start <change-id>` (or `git switch -c change/<id>`).",
564
- });
565
- }
566
-
567
- const clean = await checkGitClean(gitRoot);
568
- if (!clean.clean) {
569
- throw new UserError("Refusing to finish with a dirty working tree.", {
570
- details: `${clean.details}\n\nHint: commit or stash your changes, then retry.`,
571
- });
572
- }
573
-
574
- const cur = await currentBranch(gitRoot);
575
- const intoRaw = String(options.into || "").trim();
576
- const baseRaw = String(options.base || "").trim();
577
- if (intoRaw && baseRaw && intoRaw !== baseRaw) {
578
- throw new UserError("change finish: cannot combine --into with --base (values differ).", { details: `--into=${intoRaw}\n--base=${baseRaw}` });
579
- }
580
-
581
- /** @type {string} */
582
- let into = intoRaw || baseRaw;
583
-
584
- if (!into) {
585
- if (!cur) {
586
- throw new UserError("Detached HEAD: cannot infer target branch for finish.", {
587
- details: "Hint: switch to the target branch (e.g. main) and retry, or pass `aiws change finish <id> --into <branch>`.",
588
- });
589
- }
590
- if (cur !== changeBranch) {
591
- into = cur;
592
- } else {
593
- const metaPath = path.join(gitRoot, "changes", changeId, ".ws-change.json");
594
- /** @type {any} */
595
- let meta = null;
596
- if (await pathExists(metaPath)) {
597
- try {
598
- meta = JSON.parse(await readText(metaPath));
599
- } catch {
600
- meta = null;
601
- }
602
- }
603
- if (!meta) {
604
- const show = await runCommand("git", ["show", `${changeBranch}:changes/${changeId}/.ws-change.json`], { cwd: gitRoot });
605
- if (show.code === 0) {
606
- try {
607
- meta = JSON.parse(String(show.stdout || ""));
608
- } catch {
609
- meta = null;
610
- }
611
- }
612
- }
613
- const inferred = meta && typeof meta === "object" ? String(meta.base_branch || "").trim() : "";
614
- if (!inferred) {
615
- throw new UserError("Cannot infer base branch for finish.", {
616
- details: "Hint: run from the target branch (e.g. main), or pass: `aiws change finish <id> --into <branch>`.",
617
- });
618
- }
619
- into = inferred;
620
- }
621
- }
622
-
623
- if (into === changeBranch) {
624
- throw new UserError("change finish: target branch cannot be the change branch.", { details: `target=${into}` });
625
- }
626
-
627
- const worktrees = await listGitWorktrees(gitRoot);
628
- const intoRef = `refs/heads/${into}`;
629
- const intoWt = worktrees.find((w) => String(w.branch || "") === intoRef);
630
- if (intoWt && path.resolve(intoWt.worktree) !== path.resolve(gitRoot)) {
631
- throw new UserError("Target branch is checked out in another worktree.", {
632
- details: `branch: ${into}\nworktree: ${intoWt.worktree}\n\nHint: run finish in that worktree:\n cd ${intoWt.worktree}\n aiws change finish ${changeId}`,
633
- });
634
- }
635
-
636
- const hasIntoBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", intoRef], { cwd: gitRoot }).then((r) => r.code === 0);
637
- if (!hasIntoBranch) {
638
- throw new UserError("Target branch does not exist.", { details: `branch: ${into}` });
639
- }
640
-
641
- const changeWt = worktrees.find((w) => String(w.branch || "") === changeBranchRef);
642
- return { changeId, changeBranch, changeBranchRef, into, cur, worktrees, changeWt };
643
- }
644
-
645
- /**
646
- * @param {string} gitRoot
647
- * @param {string | undefined} worktreeDir
648
- * @param {string} changeId
649
- */
650
- function resolveDefaultWorktreeDir(gitRoot, worktreeDir, changeId) {
651
- const parent = path.dirname(gitRoot);
652
- const raw = String(worktreeDir || "").trim();
653
- if (raw) {
654
- return path.isAbsolute(raw) ? raw : path.resolve(parent, raw);
655
- }
656
- const repoName = path.basename(gitRoot);
657
- return path.resolve(parent, `${repoName}-${changeId}`);
658
- }
659
-
660
208
  /**
661
209
  * @param {string} branch
662
210
  * @returns {string}
@@ -760,13 +308,6 @@ function splitDeclaredValues(s) {
760
308
  .filter(Boolean);
761
309
  }
762
310
 
763
- /**
764
- * @param {string} p
765
- */
766
- function normalizeSlashes(p) {
767
- return String(p || "").replaceAll("\\", "/");
768
- }
769
-
770
311
  /**
771
312
  * Extract bullet lines under a markdown "## ..." heading.
772
313
  *
@@ -809,6 +350,22 @@ function isMeaningfulBullet(bulletLine) {
809
350
  return true;
810
351
  }
811
352
 
353
+ /**
354
+ * @param {string} entryName
355
+ * @returns {{ datePrefix: string, changeId: string, stamp: string } | null}
356
+ */
357
+ function parseArchivedChangeDirName(entryName) {
358
+ const m = /^(\d{4}-\d{2}-\d{2})-(.+?)(?:-(\d{8}-\d{6}Z))?$/.exec(String(entryName || ""));
359
+ if (!m) return null;
360
+ const changeId = m[2] || "";
361
+ if (!CHANGE_ID_RE.test(changeId)) return null;
362
+ return {
363
+ datePrefix: m[1] || "",
364
+ changeId,
365
+ stamp: m[3] || "",
366
+ };
367
+ }
368
+
812
369
  /**
813
370
  * Find an archived change by ID in the archive directory.
814
371
  * @param {string} archiveDir
@@ -819,13 +376,12 @@ async function findArchivedChange(archiveDir, changeId) {
819
376
  if (!(await pathExists(archiveDir))) return null;
820
377
  try {
821
378
  const entries = await fs.readdir(archiveDir, { withFileTypes: true });
822
- for (const e of entries) {
823
- if (!e.isDirectory()) continue;
824
- // Archive dirs are named like: 2026-03-12-<change-id> or 2026-03-12-<change-id>-<timestamp>
825
- if (e.name.endsWith(`-${changeId}`) || e.name.includes(`-${changeId}-`)) {
826
- return path.join(archiveDir, e.name);
827
- }
828
- }
379
+ const matches = entries
380
+ .filter((e) => e.isDirectory())
381
+ .map((e) => ({ entry: e, parsed: parseArchivedChangeDirName(e.name) }))
382
+ .filter((item) => item.parsed?.changeId === changeId)
383
+ .sort((a, b) => a.entry.name.localeCompare(b.entry.name));
384
+ if (matches.length > 0) return path.join(archiveDir, matches.at(-1)?.entry.name || "");
829
385
  } catch {
830
386
  // ignore
831
387
  }
@@ -917,6 +473,7 @@ async function generateHandoffContent(gitRoot, changeDir, changeId) {
917
473
  let baseBranch = "main";
918
474
  /** @type {string[]} */
919
475
  const decisions = [];
476
+ const collaboration = await collectCollaborationArtifacts(gitRoot, changeDir);
920
477
 
921
478
  if (await pathExists(proposalPath)) {
922
479
  const text = await readText(proposalPath);
@@ -1008,6 +565,21 @@ async function generateHandoffContent(gitRoot, changeDir, changeId) {
1008
565
  lines.push("- (missing) add decisions to changes/<id>/design.md#Decisions");
1009
566
  }
1010
567
 
568
+ lines.push("");
569
+ lines.push("## 协同记录");
570
+ lines.push("");
571
+ if ((collaboration.counts.total || 0) === 0) {
572
+ lines.push("- (none)");
573
+ } else {
574
+ for (const key of ["analysis", "patches", "review", "evidence"]) {
575
+ const group = collaboration.dirs[key];
576
+ if (!group) continue;
577
+ lines.push(`- ${group.label}: ${group.count} file(s)`);
578
+ for (const rel of group.files) lines.push(` - ${rel}`);
579
+ if (group.truncated) lines.push(" - ...(truncated)");
580
+ }
581
+ }
582
+
1011
583
  lines.push("");
1012
584
  lines.push("## 下一步建议");
1013
585
  lines.push("");
@@ -1031,174 +603,44 @@ async function generateHandoffContent(gitRoot, changeDir, changeId) {
1031
603
  }
1032
604
 
1033
605
  /**
1034
- * Evidence_Path is user-declared; we treat it as best-effort hints, not hard gates.
1035
- *
1036
- * @param {string} gitRoot
1037
- * @param {string} changeId
1038
- * @param {string[]} evidencePaths
1039
- * @returns {Promise<{
1040
- * entries: Array<{rel: string, abs: string, exists: boolean, kind: string, isAbsolute: boolean}>,
1041
- * counts: { total: number, exists: number, missing: number, tmp: number, persistent: number, other: number, absolute: number },
1042
- * missing: string[]
1043
- * }>}
1044
- */
1045
- async function analyzeEvidencePaths(gitRoot, changeId, evidencePaths) {
1046
- const changePrefix = `changes/${changeId}/`;
1047
- const evidencePrefix = `changes/${changeId}/evidence/`;
1048
- const reviewPrefix = `changes/${changeId}/review/`;
1049
-
1050
- const entries = await Promise.all(
1051
- (evidencePaths || []).map(async (raw) => {
1052
- const rel = String(raw || "").trim();
1053
- const isAbsolute = path.isAbsolute(rel);
1054
- const abs = isAbsolute ? rel : path.join(gitRoot, rel);
1055
- const exists = rel ? await pathExists(abs) : false;
1056
-
1057
- const relNorm = normalizeSlashes(rel);
1058
- let kind = "other";
1059
- if (relNorm.startsWith(".agentdocs/tmp/") || relNorm.startsWith(".agentdocs/")) kind = "tmp";
1060
- else if (relNorm.startsWith(evidencePrefix) || relNorm.startsWith(reviewPrefix) || relNorm.startsWith(changePrefix)) kind = "persistent";
1061
-
1062
- return { rel, abs: path.resolve(abs), exists, kind, isAbsolute };
1063
- }),
1064
- );
1065
-
1066
- const missing = entries.filter((e) => e.rel && !e.exists).map((e) => e.rel);
1067
- const counts = {
1068
- total: entries.filter((e) => e.rel).length,
1069
- exists: entries.filter((e) => e.rel && e.exists).length,
1070
- missing: missing.length,
1071
- tmp: entries.filter((e) => e.rel && e.kind === "tmp").length,
1072
- persistent: entries.filter((e) => e.rel && e.kind === "persistent").length,
1073
- other: entries.filter((e) => e.rel && e.kind === "other").length,
1074
- absolute: entries.filter((e) => e.rel && e.isAbsolute).length,
1075
- };
1076
-
1077
- return { entries: entries.filter((e) => e.rel), counts, missing };
1078
- }
1079
-
1080
- /**
1081
- * @param {string[]} a
1082
- * @param {string[]} b
1083
- */
1084
- function mergeDeclaredValues(a, b) {
1085
- const seen = new Set();
1086
- /** @type {string[]} */
1087
- const out = [];
1088
- for (const raw of [...(a || []), ...(b || [])]) {
1089
- const v = String(raw || "").trim().replace(/^`+/, "").replace(/`+$/, "").trim();
1090
- if (!v) continue;
1091
- if (seen.has(v)) continue;
1092
- seen.add(v);
1093
- out.push(v);
1094
- }
1095
- return out;
1096
- }
1097
-
1098
- /**
1099
- * @param {string[]} values
606
+ * @param {string[]} blockersStrict
607
+ * @param {{ unchecked: number }} tasks
1100
608
  */
1101
- function formatDeclaredValues(values) {
1102
- return (values || []).join(", ");
609
+ function inferChangePhase(blockersStrict, tasks) {
610
+ if ((blockersStrict || []).length > 0) return "planning";
611
+ if ((tasks?.unchecked || 0) > 0) return "dev";
612
+ return "deliver";
1103
613
  }
1104
614
 
1105
615
  /**
1106
- * Replace or insert a simple "ID: value" style line in Markdown.
616
+ * Compute change status as JSON-friendly object (used by dashboard and CLI).
1107
617
  *
1108
- * @param {string} text
1109
- * @param {string[]} labelCandidates
1110
- * @param {string} value
1111
- * @param {{ insertAfterLabels?: string[] }} options
618
+ * @param {string} gitRoot
619
+ * @param {string} changeId
1112
620
  */
1113
- function upsertIdLine(text, labelCandidates, value, options) {
1114
- const src = String(text || "");
1115
- const labels = Array.isArray(labelCandidates) ? labelCandidates : [];
1116
- for (const label of labels) {
1117
- const re = new RegExp(`^(.*${escapeRegExp(label)}.*?[:=][ \\t]*)(.*)$`, "m");
1118
- const m = re.exec(src);
1119
- if (!m) continue;
1120
- const prefix = m[1] || "";
1121
- const replaced = src.replace(re, `${prefix}${value}`);
1122
- return { ok: true, changed: replaced !== src, text: replaced, mode: "replaced", label };
1123
- }
1124
-
1125
- // Insert near the top (best-effort), after a nearby label if present.
1126
- const insertAfter = Array.isArray(options?.insertAfterLabels) ? options.insertAfterLabels : [];
1127
- const lines = src.split(/\r?\n/);
1128
- let idx = 0;
1129
- for (let i = 0; i < Math.min(lines.length, 80); i++) {
1130
- const line = lines[i] || "";
1131
- if (insertAfter.some((l) => line.includes(l))) idx = i + 1;
1132
- }
1133
- const insertedLabel = labels[0] || "Evidence_Path";
1134
- const newLine = `- ${insertedLabel}: ${value}`;
1135
- lines.splice(idx, 0, newLine);
1136
- const inserted = lines.join("\n");
1137
- return { ok: true, changed: inserted !== src, text: inserted, mode: "inserted", label: labels[0] || "" };
1138
- }
1139
-
1140
- /**
1141
- * Special-case insertion for proposal.md template style.
1142
- *
1143
- * @param {string} proposalText
1144
- * @param {string} value
1145
- */
1146
- function upsertProposalEvidencePath(proposalText, value) {
1147
- const src = String(proposalText || "");
1148
- const re = new RegExp(`^(.*${escapeRegExp("Evidence_Path")}.*?[:=][ \\t]*)(.*)$`, "m");
1149
- if (re.test(src)) {
1150
- const m = re.exec(src);
1151
- const prefix = m?.[1] || "";
1152
- const out = src.replace(re, `${prefix}${value}`);
1153
- return { changed: out !== src, text: out, mode: "replaced" };
1154
- }
1155
- // Insert right after Plan_File binding if possible.
1156
- const lines = src.split(/\r?\n/);
1157
- let idx = 0;
1158
- for (let i = 0; i < Math.min(lines.length, 120); i++) {
1159
- if ((lines[i] || "").includes("Plan_File")) {
1160
- idx = i + 1;
1161
- break;
1162
- }
1163
- }
1164
- lines.splice(idx, 0, `- \`Evidence_Path\` = ${value}`);
1165
- const inserted = lines.join("\n");
1166
- return { changed: inserted !== src, text: inserted, mode: "inserted" };
1167
- }
1168
-
1169
- /**
1170
- * @param {string} changeId
1171
- */
1172
- function planVerifyHint(changeId) {
1173
- return `执行前质量门(优先):\`aiws change validate ${changeId} --strict\`(AI 工具中等价于 \`$ws-plan-verify\`)`;
1174
- }
1175
-
1176
- /**
1177
- * @param {string[]} blockersStrict
1178
- * @param {{ unchecked: number }} tasks
1179
- */
1180
- function inferChangePhase(blockersStrict, tasks) {
1181
- if ((blockersStrict || []).length > 0) return "planning";
1182
- if ((tasks?.unchecked || 0) > 0) return "dev";
1183
- return "deliver";
1184
- }
1185
-
1186
- /**
1187
- * Compute change status as JSON-friendly object (used by dashboard and CLI).
1188
- *
1189
- * @param {string} gitRoot
1190
- * @param {string} changeId
1191
- */
1192
- export async function computeChangeStatus(gitRoot, changeId) {
1193
- assertValidChangeId(changeId);
1194
- await ensureTruthFiles(gitRoot);
621
+ export async function computeChangeStatus(gitRoot, changeId) {
622
+ assertValidChangeId(changeId);
623
+ await ensureTruthFiles(gitRoot);
1195
624
 
1196
625
  const changeDir = changeDirAbs(gitRoot, changeId);
1197
626
  if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
627
+ const context = await resolveChangeStatusContext(gitRoot, changeId, {
628
+ currentBranch,
629
+ listGitWorktrees,
630
+ pathExists,
631
+ isGitRepository: (repoPath) => isGitRepository(repoPath, { pathExists, runCommand }),
632
+ inferChangeIdFromBranch,
633
+ });
1198
634
 
1199
635
  const proposal = await fileState(changeDir, "proposal.md");
1200
636
  const tasks = await fileState(changeDir, "tasks.md");
1201
637
  const design = await fileState(changeDir, "design.md");
638
+ const collaboration = await collectCollaborationArtifacts(gitRoot, changeDir);
639
+ const reviewSignals = await collectReviewSignals(gitRoot, changeId, context, collaboration);
640
+ const reviewGates = await collectReviewGates(gitRoot, changeId, context);
641
+ const git = await collectGitStatus(context.worktree);
642
+ const submodules = await listSubmodulesFromGitmodules(gitRoot, { pathExists, runCommand });
643
+ const metrics = await readChangeMetrics(changeDir, { pathExists, readText });
1202
644
 
1203
645
  const metaPath = path.join(changeDir, ".ws-change.json");
1204
646
  let metaState = "missing";
@@ -1279,12 +721,15 @@ export async function computeChangeStatus(gitRoot, changeId) {
1279
721
  blockersArchive.push(...blockersStrict);
1280
722
  if (taskProgress.unchecked > 0) blockersArchive.push(`tasks.md still has unchecked tasks (${taskProgress.unchecked} items)`);
1281
723
  const phase = inferChangePhase(blockersStrict, taskProgress);
724
+ const lifecycle = summarizeLifecycleSignals(metrics);
725
+ const scopeGate = computeScopeGate(lifecycle);
1282
726
 
1283
- return {
727
+ const baseStatus = {
1284
728
  ok: true,
1285
729
  changeId,
1286
730
  dir: path.relative(gitRoot, changeDir),
1287
731
  phase,
732
+ context,
1288
733
  metaState,
1289
734
  reqId,
1290
735
  probId,
@@ -1293,7 +738,16 @@ export async function computeChangeStatus(gitRoot, changeId) {
1293
738
  planFile,
1294
739
  evidencePaths,
1295
740
  },
741
+ scopeGate,
1296
742
  evidence,
743
+ collaboration,
744
+ reviewSignals,
745
+ reviewGates,
746
+ git,
747
+ repo: {
748
+ submodules: submodules.length,
749
+ },
750
+ lifecycle,
1297
751
  tasks: taskProgress,
1298
752
  baselineLabel,
1299
753
  baselineAt,
@@ -1302,57 +756,17 @@ export async function computeChangeStatus(gitRoot, changeId) {
1302
756
  blockersArchive,
1303
757
  hints: {
1304
758
  planVerify: planVerifyHint(changeId),
1305
- dev: "质量门通过后再进入编码:在 AI 工具中运行 `$ws-dev`",
759
+ dev: "质量门通过后再进入编码:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`",
1306
760
  },
1307
761
  };
1308
- }
1309
-
1310
- function parseChangeCheckerOutput(stdout, stderr) {
1311
- const text = [String(stdout || ""), String(stderr || "")].filter(Boolean).join("\n");
1312
- /** @type {string[]} */
1313
- const errors = [];
1314
- /** @type {string[]} */
1315
- const warnings = [];
1316
- for (const raw of text.split("\n")) {
1317
- const line = raw.trim();
1318
- if (!line) continue;
1319
- if (line.startsWith("error: ")) errors.push(line.replace(/^error:\s*/, ""));
1320
- else if (line.startsWith("warn: ")) warnings.push(line.replace(/^warn:\s*/, ""));
1321
- }
1322
- return { errors, warnings, raw: text.trim() };
1323
- }
1324
-
1325
- function classifyCheckMessage(msg) {
1326
- const s = String(msg || "");
1327
- if (s.includes("truth file") || s.includes("truth drift") || s.includes("allow-truth-drift")) return "truth_drift";
1328
- if (s.includes("WS:TODO") || s.includes("unrendered template placeholders")) return "placeholders";
1329
- if (s.includes("missing:") || s.includes("empty:") || s.includes("Missing change dir")) return "missing_files";
1330
- if (s.includes("Change_ID") || s.includes("Req_ID") || s.includes("Problem_ID") || s.includes("Contract_Row") || s.includes("Plan_File") || s.includes("Evidence_Path")) return "bindings";
1331
- if (s.includes("missing required sections") || s.includes("has empty sections") || s.includes("Plan section") || s.includes("Verify section") || s.includes("scope is too broad") || s.includes("too abstract")) {
1332
- return "plan_quality";
1333
- }
1334
- return "other";
1335
- }
1336
-
1337
- function groupCheckMessages(errors, warnings) {
1338
- const groups = {
1339
- truth_drift: { errors: [], warnings: [] },
1340
- missing_files: { errors: [], warnings: [] },
1341
- placeholders: { errors: [], warnings: [] },
1342
- bindings: { errors: [], warnings: [] },
1343
- plan_quality: { errors: [], warnings: [] },
1344
- other: { errors: [], warnings: [] },
762
+ const governance = await inferChangeGovernance(baseStatus);
763
+ return {
764
+ ...baseStatus,
765
+ governance: {
766
+ ...governance,
767
+ warning: context.warning || "",
768
+ },
1345
769
  };
1346
-
1347
- for (const m of errors || []) {
1348
- const k = classifyCheckMessage(m);
1349
- groups[k].errors.push(m);
1350
- }
1351
- for (const m of warnings || []) {
1352
- const k = classifyCheckMessage(m);
1353
- groups[k].warnings.push(m);
1354
- }
1355
- return groups;
1356
770
  }
1357
771
 
1358
772
  /**
@@ -1363,29 +777,14 @@ function groupCheckMessages(errors, warnings) {
1363
777
  * @param {{ strict: boolean, allowTruthDrift: boolean, checkEvidence?: boolean, checkScope?: boolean }} options
1364
778
  */
1365
779
  export async function validateChangeArtifacts(gitRoot, changeId, options) {
1366
- assertValidChangeId(changeId);
1367
- await ensureTruthFiles(gitRoot);
1368
-
1369
- const checker = await resolveWsChangeChecker(gitRoot);
1370
- const args = [...checker.args, "--workspace-root", gitRoot, "--change-id", changeId];
1371
- if (options.strict) args.push("--strict");
1372
- if (options.allowTruthDrift) args.push("--allow-truth-drift");
1373
- if (options.checkEvidence) args.push("--check-evidence");
1374
- if (options.checkScope) args.push("--check-scope");
1375
-
1376
- const res = await runPython(gitRoot, ["-u", ...args]);
1377
- const parsed = parseChangeCheckerOutput(res.stdout, res.stderr);
1378
- return {
1379
- ok: res.code === 0,
1380
- changeId,
1381
- strict: options.strict === true,
1382
- allowTruthDrift: options.allowTruthDrift === true,
1383
- exitCode: res.code,
1384
- errors: parsed.errors,
1385
- warnings: parsed.warnings,
1386
- groups: groupCheckMessages(parsed.errors, parsed.warnings),
1387
- raw: parsed.raw,
1388
- };
780
+ return validateChangeArtifactsModel(gitRoot, changeId, options, {
781
+ assertValidChangeId,
782
+ collectFinishGateErrors,
783
+ computeChangeStatus,
784
+ ensureTruthFiles,
785
+ resolveWsChangeChecker,
786
+ runPython,
787
+ });
1389
788
  }
1390
789
 
1391
790
  /**
@@ -1433,8 +832,7 @@ async function fileState(changeDir, rel) {
1433
832
  * @param {string} type
1434
833
  * @param {any} payload
1435
834
  */
1436
- async function appendMetricsEvent(gitRoot, changeId, type, payload) {
1437
- const changeDir = changeDirAbs(gitRoot, changeId);
835
+ async function appendMetricsEventAtDir(changeDir, changeId, type, payload) {
1438
836
  const metricsAbs = path.join(changeDir, "metrics.json");
1439
837
  /** @type {{ version: number, change_id: string, events: any[], updated_at?: string } | null} */
1440
838
  let cur = null;
@@ -1455,37 +853,9 @@ async function appendMetricsEvent(gitRoot, changeId, type, payload) {
1455
853
  await writeText(metricsAbs, JSON.stringify(cur, null, 2) + "\n");
1456
854
  }
1457
855
 
1458
- /**
1459
- * @param {string} absDir
1460
- * @param {(name: string) => boolean} predicate
1461
- */
1462
- async function findLatestFileByMtime(absDir, predicate) {
1463
- if (!(await pathExists(absDir))) return "";
1464
- const entries = await fs.readdir(absDir, { withFileTypes: true });
1465
- /** @type {{ abs: string, mtimeMs: number } | null} */
1466
- let best = null;
1467
- for (const e of entries) {
1468
- if (!e.isFile()) continue;
1469
- if (!predicate(e.name)) continue;
1470
- const abs = path.join(absDir, e.name);
1471
- let st;
1472
- try {
1473
- st = await fs.stat(abs);
1474
- } catch {
1475
- continue;
1476
- }
1477
- const mtimeMs = Number(st.mtimeMs || 0);
1478
- if (!best || mtimeMs > best.mtimeMs) best = { abs, mtimeMs };
1479
- }
1480
- return best ? best.abs : "";
1481
- }
1482
-
1483
- /**
1484
- * @param {string} gitRoot
1485
- * @param {string} absPath
1486
- */
1487
- function relFromRoot(gitRoot, absPath) {
1488
- return normalizeSlashes(path.relative(gitRoot, absPath));
856
+ async function appendMetricsEvent(gitRoot, changeId, type, payload) {
857
+ const changeDir = changeDirAbs(gitRoot, changeId);
858
+ await appendMetricsEventAtDir(changeDir, changeId, type, payload);
1489
859
  }
1490
860
 
1491
861
  /**
@@ -1581,46 +951,6 @@ async function resolveChangeId(gitRoot, changeId, options) {
1581
951
  throw new UserError(`usage: aiws change ${options.command} <change-id> (or switch to branch change/<change-id>)`);
1582
952
  }
1583
953
 
1584
- /**
1585
- * @param {any} meta
1586
- */
1587
- function baselineFromMeta(meta) {
1588
- const createdTruth = meta?.base_truth_files && typeof meta.base_truth_files === "object" ? meta.base_truth_files : {};
1589
- const syncedTruth = meta?.synced_truth_files && typeof meta.synced_truth_files === "object" ? meta.synced_truth_files : {};
1590
- if (Object.keys(syncedTruth).length > 0) {
1591
- return { baseline: syncedTruth, label: "last sync", at: String(meta?.synced_at || "") };
1592
- }
1593
- return { baseline: createdTruth, label: "creation", at: String(meta?.created_at || "") };
1594
- }
1595
-
1596
- /**
1597
- * @param {Record<string, string | null>} curTruth
1598
- * @param {any} baseline
1599
- * @returns {{ baselineLabel: string, baselineAt: string, driftFiles: string[] }}
1600
- */
1601
- function truthDrift(curTruth, baseline) {
1602
- /** @type {string[]} */
1603
- const driftFiles = [];
1604
-
1605
- const createdTruth = baseline?.base_truth_files && typeof baseline.base_truth_files === "object" ? baseline.base_truth_files : {};
1606
- const syncedTruth = baseline?.synced_truth_files && typeof baseline.synced_truth_files === "object" ? baseline.synced_truth_files : {};
1607
- const effective = Object.keys(syncedTruth).length > 0 ? syncedTruth : createdTruth;
1608
- const baselineLabel = Object.keys(syncedTruth).length > 0 ? "sync" : "creation";
1609
- const baselineAt = Object.keys(syncedTruth).length > 0 ? String(baseline?.synced_at || "") : String(baseline?.created_at || "");
1610
-
1611
- for (const [rel, info] of Object.entries(effective || {})) {
1612
- const baseSha = info && typeof info === "object" ? String(info.sha256 || "") : "";
1613
- const curSha = curTruth[rel] ?? null;
1614
- if (curSha == null) {
1615
- driftFiles.push(rel);
1616
- continue;
1617
- }
1618
- if (baseSha && curSha !== baseSha) driftFiles.push(rel);
1619
- }
1620
-
1621
- return { baselineLabel, baselineAt, driftFiles };
1622
- }
1623
-
1624
954
  /**
1625
955
  * @param {string} gitRoot
1626
956
  * @param {string} changeId
@@ -1796,6 +1126,10 @@ async function changeNewAtGitRoot(gitRoot, options) {
1796
1126
  const changeDir = path.join(changeRoot, changeId);
1797
1127
  if (await pathExists(changeDir)) throw new UserError(`Change already exists: ${changeDir}`);
1798
1128
  await ensureDir(changeDir);
1129
+ await ensureDir(path.join(changeDir, "analysis"));
1130
+ await ensureDir(path.join(changeDir, "patches"));
1131
+ await ensureDir(path.join(changeDir, "review"));
1132
+ await ensureDir(path.join(changeDir, "evidence"));
1799
1133
 
1800
1134
  const templates = await resolveChangeTemplatesDir(gitRoot);
1801
1135
  const proposalTpl = await readText(path.join(templates.templateDir, "proposal.md"));
@@ -1841,6 +1175,9 @@ async function changeNewAtGitRoot(gitRoot, options) {
1841
1175
  console.log(` - edit: changes/${changeId}/proposal.md`);
1842
1176
  console.log(` - edit: changes/${changeId}/tasks.md`);
1843
1177
  if (!options.noDesign) console.log(` - optional: changes/${changeId}/design.md`);
1178
+ console.log(` - optional: changes/${changeId}/analysis/ (委托分析)`);
1179
+ console.log(` - optional: changes/${changeId}/patches/ (patch 草案)`);
1180
+ console.log(` - optional: changes/${changeId}/review/ (多审查结果)`);
1844
1181
  console.log(` - validate: aiws change validate ${changeId}`);
1845
1182
  }
1846
1183
 
@@ -1888,7 +1225,7 @@ export async function changeStartCommand(options) {
1888
1225
  let effectiveWorktree = options.worktree === true;
1889
1226
 
1890
1227
  if (hasGitmodules && !effectiveNoSwitch && !effectiveWorktree && !effectiveForceSwitch) {
1891
- const prereq = await checkWorktreePrereqs(gitRoot);
1228
+ const prereq = await checkWorktreePrereqs(gitRoot, { hasHeadCommit, runCommand });
1892
1229
  if (prereq.ok) {
1893
1230
  effectiveWorktree = true;
1894
1231
  console.error("warn: .gitmodules detected (git submodules). defaulting to --worktree to avoid switching the superproject branch.");
@@ -1928,7 +1265,7 @@ export async function changeStartCommand(options) {
1928
1265
  }
1929
1266
 
1930
1267
  if (effectiveWorktree) {
1931
- const prereq = await checkWorktreePrereqs(gitRoot);
1268
+ const prereq = await checkWorktreePrereqs(gitRoot, { hasHeadCommit, runCommand });
1932
1269
  if (!prereq.ok) {
1933
1270
  if (prereq.reason === "no_commit") {
1934
1271
  throw new UserError("change start --worktree requires a repo with at least one commit.", { details: "Hint: make an initial commit, then retry." });
@@ -2001,7 +1338,7 @@ export async function changeStartCommand(options) {
2001
1338
  baseBranch: startFromBranch && startFromBranch !== branch ? startFromBranch : undefined,
2002
1339
  });
2003
1340
  } else {
2004
- await maybeRecordBaseBranch(changeDir, startFromBranch, branch);
1341
+ await maybeRecordBaseBranch(changeDir, startFromBranch, branch, { nowIsoUtc, pathExists, readText, writeText });
2005
1342
  }
2006
1343
 
2007
1344
  // Worktree mode: if this repo declares submodules, always initialize them in the new worktree.
@@ -2064,48 +1401,9 @@ export async function changeStartCommand(options) {
2064
1401
  let branchReady = hasBranch;
2065
1402
 
2066
1403
  if (effectiveNoSwitch) {
2067
- if (!hasBranch) {
2068
- if (!headReady) {
2069
- // On an unborn branch, `git branch <name>` fails because HEAD doesn't point to a commit yet.
2070
- // We still allow initializing change artifacts without switching branches.
2071
- console.error(`warn: repo has no commits; cannot create branch "${branch}" without switching (skipped)`);
2072
- branchReady = false;
2073
- } else {
2074
- const br = await runCommand("git", ["branch", branch], { cwd: gitRoot });
2075
- if (br.code !== 0) throw new UserError("Failed to create branch.", { details: br.stderr || br.stdout });
2076
- branchReady = true;
2077
- }
2078
- }
1404
+ branchReady = await prepareNoSwitchBranch(gitRoot, { branch, hasBranch, headReady }, { runCommand, UserError });
2079
1405
  } else {
2080
- if (hasBranch) {
2081
- let sw = await runCommand("git", ["switch", ...(hasGitmodules ? ["--recurse-submodules"] : []), branch], { cwd: gitRoot });
2082
- if (sw.code !== 0 && hasGitmodules && outputMentionsRecurseSubmodules(sw)) {
2083
- console.error("warn: git switch does not support --recurse-submodules; switching without it (submodules may be out of sync).");
2084
- sw = await runCommand("git", ["switch", branch], { cwd: gitRoot });
2085
- }
2086
- if (sw.code !== 0) {
2087
- let co = await runCommand("git", ["checkout", ...(hasGitmodules ? ["--recurse-submodules"] : []), branch], { cwd: gitRoot });
2088
- if (co.code !== 0 && hasGitmodules && outputMentionsRecurseSubmodules(co)) {
2089
- console.error("warn: git checkout does not support --recurse-submodules; checking out without it (submodules may be out of sync).");
2090
- co = await runCommand("git", ["checkout", branch], { cwd: gitRoot });
2091
- }
2092
- if (co.code !== 0) throw new UserError("Failed to switch branch.", { details: co.stderr || co.stdout });
2093
- }
2094
- } else {
2095
- let sw = await runCommand("git", ["switch", ...(hasGitmodules ? ["--recurse-submodules"] : []), "-c", branch], { cwd: gitRoot });
2096
- if (sw.code !== 0 && hasGitmodules && outputMentionsRecurseSubmodules(sw)) {
2097
- console.error("warn: git switch does not support --recurse-submodules; switching without it (submodules may be out of sync).");
2098
- sw = await runCommand("git", ["switch", "-c", branch], { cwd: gitRoot });
2099
- }
2100
- if (sw.code !== 0) {
2101
- let co = await runCommand("git", ["checkout", ...(hasGitmodules ? ["--recurse-submodules"] : []), "-b", branch], { cwd: gitRoot });
2102
- if (co.code !== 0 && hasGitmodules && outputMentionsRecurseSubmodules(co)) {
2103
- console.error("warn: git checkout does not support --recurse-submodules; checking out without it (submodules may be out of sync).");
2104
- co = await runCommand("git", ["checkout", "-b", branch], { cwd: gitRoot });
2105
- }
2106
- if (co.code !== 0) throw new UserError("Failed to create branch.", { details: co.stderr || co.stdout });
2107
- }
2108
- }
1406
+ await switchOrCreateStartBranch(gitRoot, { branch, hasBranch, hasGitmodules }, { runCommand, UserError });
2109
1407
  branchReady = true;
2110
1408
  }
2111
1409
 
@@ -2118,7 +1416,7 @@ export async function changeStartCommand(options) {
2118
1416
  baseBranch: startFromBranch && startFromBranch !== branch ? startFromBranch : undefined,
2119
1417
  });
2120
1418
  } else {
2121
- await maybeRecordBaseBranch(changeDir, startFromBranch, branch);
1419
+ await maybeRecordBaseBranch(changeDir, startFromBranch, branch, { nowIsoUtc, pathExists, readText, writeText });
2122
1420
  }
2123
1421
 
2124
1422
  // Check Depends_On dependencies (non-worktree mode)
@@ -2172,7 +1470,73 @@ export async function changeFinishCommand(options) {
2172
1470
  const gitRoot = await resolveGitRoot(process.cwd());
2173
1471
  await ensureTruthFiles(gitRoot);
2174
1472
 
2175
- const { changeId, changeBranch, into, cur, changeWt } = await resolveFinishContext(gitRoot, options);
1473
+ const resolvedChangeId = await resolveChangeId(gitRoot, options.changeId, { command: "finish" });
1474
+ assertValidChangeId(resolvedChangeId);
1475
+ const activeChangeDir = changeDirAbs(gitRoot, resolvedChangeId);
1476
+ const activeChangeExists = await pathExists(activeChangeDir);
1477
+ const archiveRoot = path.join(gitRoot, "changes", "archive");
1478
+ const archivedChangeDir = activeChangeExists ? null : await findArchivedChange(archiveRoot, resolvedChangeId);
1479
+ const existingLifecycle = summarizeLifecycleSignals(await readChangeMetrics(activeChangeDir, { pathExists, readText }));
1480
+ const archivedHandoffPath = archivedChangeDir ? path.join(archivedChangeDir, "handoff.md") : "";
1481
+ const archivedHandoffRel = archivedHandoffPath && (await pathExists(archivedHandoffPath)) ? path.relative(gitRoot, archivedHandoffPath) : "";
1482
+ if (archivedChangeDir) {
1483
+ throw new UserError("No finish work remaining for this change.", {
1484
+ details:
1485
+ `change: ${resolvedChangeId}\n` +
1486
+ `archived: ${path.relative(gitRoot, archivedChangeDir)}\n` +
1487
+ (archivedHandoffRel ? `handoff: ${archivedHandoffRel}\n` : "") +
1488
+ "next: continue with `$ws-handoff`",
1489
+ });
1490
+ }
1491
+ if (existingLifecycle.finishState === "done") {
1492
+ throw new UserError("Change finish already completed.", {
1493
+ details:
1494
+ `change: ${resolvedChangeId}\n` +
1495
+ `next: legacy finish is already done; run \`aiws change archive ${resolvedChangeId}\` once, then continue with \`$ws-handoff\``,
1496
+ });
1497
+ }
1498
+ const { changeId, changeBranch, into, cur, changeWt } = await resolveFinishContext(gitRoot, { ...options, changeId: resolvedChangeId }, {
1499
+ assertValidChangeId,
1500
+ checkGitClean,
1501
+ currentBranch,
1502
+ listGitWorktrees,
1503
+ pathExists,
1504
+ readText,
1505
+ resolveChangeId,
1506
+ runCommand,
1507
+ UserError,
1508
+ });
1509
+ const finishStatusRoot = changeWt?.worktree ? path.resolve(changeWt.worktree) : gitRoot;
1510
+ const finishStatus = await computeChangeStatus(finishStatusRoot, changeId);
1511
+ const finishGateErrors = collectFinishGateErrors(changeId, finishStatus.reviewGates);
1512
+ if (finishGateErrors.length > 0) {
1513
+ throw new UserError("Refusing to finish without complete review gates.", {
1514
+ details:
1515
+ `change: ${changeId}\n` +
1516
+ "missing:\n" +
1517
+ finishGateErrors.map((line) => `- ${line}`).join("\n") +
1518
+ `\n\nnext:\n` +
1519
+ `- aiws change validate ${changeId} --strict --check-evidence\n` +
1520
+ `- aiws change evidence ${changeId}\n` +
1521
+ `- re-run finish after all finish gates are green`,
1522
+ });
1523
+ }
1524
+ const finishBasePayload = { into, from: changeBranch, push: options.push === true };
1525
+ const archiveWorkflowDeps = {
1526
+ appendMetricsEvent,
1527
+ changeValidateCommand,
1528
+ ensureDir,
1529
+ generateHandoffContent,
1530
+ nowIsoUtc,
1531
+ nowStampUtc,
1532
+ nowUnixSeconds,
1533
+ pathExists,
1534
+ readText,
1535
+ snapshotTruthFiles,
1536
+ todayLocal,
1537
+ writeText,
1538
+ UserError,
1539
+ };
2176
1540
 
2177
1541
  if (cur && cur !== into) {
2178
1542
  const sw = await runCommand("git", ["switch", into], { cwd: gitRoot });
@@ -2191,54 +1555,150 @@ export async function changeFinishCommand(options) {
2191
1555
  throw new UserError("Failed to fast-forward merge change branch into target.", { details: `${merge.stderr || merge.stdout}${extra}` });
2192
1556
  }
2193
1557
 
2194
- await appendMetricsEvent(gitRoot, changeId, "finish", { into, from: changeBranch });
2195
-
2196
- console.log(`ok: finished change: ${changeId}`);
2197
- console.log(`into: ${into}`);
2198
- console.log(`from: ${changeBranch}`);
1558
+ await appendMetricsEvent(gitRoot, changeId, "finish_local", finishBasePayload);
2199
1559
 
1560
+ let cleanupNote = "not_requested";
1561
+ let finishCompleted = false;
1562
+ let archivedToRel = "";
1563
+ let handoffRel = "";
2200
1564
  if (options.push === true) {
2201
- const submodulePush = await pushSubmodulesForFinish(gitRoot, changeId, into, changeWt?.worktree);
2202
- if (submodulePush.processed > 0) {
2203
- console.log(`submodule push: ok (${submodulePush.processed} repos)`);
2204
- }
1565
+ try {
1566
+ const submodulePush = await pushSubmodulesForFinish(gitRoot, changeId, into, changeWt?.worktree, {
1567
+ pathExists,
1568
+ readText,
1569
+ runCommand,
1570
+ UserError,
1571
+ });
1572
+ if (submodulePush.processed > 0) {
1573
+ console.log(`submodule push: ok (${submodulePush.processed} repos)`);
1574
+ }
2205
1575
 
2206
- const pushTarget = await resolvePushTarget(gitRoot, into, options.remote);
2207
- const push = await runCommand("git", ["push", pushTarget.remote, pushTarget.refspec], { cwd: gitRoot });
2208
- if (push.code !== 0) {
2209
- throw new UserError("Finished locally, but failed to push target branch.", {
2210
- details:
2211
- `remote: ${pushTarget.remote}\nlocal_branch: ${into}\nremote_branch: ${pushTarget.remoteBranch}\n` +
2212
- (pushTarget.trackedMergeRef ? `tracked_merge: ${pushTarget.trackedMergeRef}\n` : "") +
2213
- `\n${push.stderr || push.stdout}`,
1576
+ const pushTarget = await resolvePushTarget(gitRoot, into, options.remote, { runCommand });
1577
+ const push = await runCommand("git", ["push", pushTarget.remote, pushTarget.refspec], { cwd: gitRoot });
1578
+ if (push.code !== 0) {
1579
+ throw new UserError("Finished locally, but failed to push target branch.", {
1580
+ details:
1581
+ `remote: ${pushTarget.remote}\nlocal_branch: ${into}\nremote_branch: ${pushTarget.remoteBranch}\n` +
1582
+ (pushTarget.trackedMergeRef ? `tracked_merge: ${pushTarget.trackedMergeRef}\n` : "") +
1583
+ `\n${push.stderr || push.stdout}`,
1584
+ });
1585
+ }
1586
+
1587
+ console.log(`push: ok (${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""})`);
1588
+
1589
+ console.log(`push: target ${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""}`);
1590
+
1591
+ if (changeWt?.worktree) {
1592
+ const cleanup = await cleanupWorktreePath(gitRoot, changeWt.worktree, {
1593
+ checkGitClean,
1594
+ listGitWorktrees,
1595
+ pathExists,
1596
+ runCommand,
1597
+ UserError,
1598
+ });
1599
+ cleanupNote = cleanup.status === "skipped" ? `skipped:${cleanup.reason || "unknown"}` : cleanup.status;
1600
+ finishCompleted =
1601
+ cleanup.status === "removed" ||
1602
+ cleanup.status === "pruned-missing" ||
1603
+ (cleanup.status === "skipped" && cleanup.reason === "current_worktree");
1604
+ if (cleanup.status === "removed") {
1605
+ console.log(`cleanup: removed worktree ${cleanup.worktree}`);
1606
+ } else if (cleanup.status === "pruned-missing") {
1607
+ console.log(`cleanup: pruned stale worktree metadata for ${cleanup.worktree}`);
1608
+ } else if (cleanup.reason === "current_worktree") {
1609
+ console.log("cleanup: skipped (change worktree is current worktree)");
1610
+ } else if (cleanup.reason === "locked") {
1611
+ console.log(`cleanup: skipped locked worktree ${cleanup.worktree}`);
1612
+ } else if (cleanup.reason === "dirty") {
1613
+ console.log(`cleanup: skipped dirty worktree ${cleanup.worktree}`);
1614
+ } else {
1615
+ console.log("cleanup: skipped");
1616
+ }
1617
+ } else {
1618
+ cleanupNote = "skipped:no_separate_change_worktree";
1619
+ finishCompleted = true;
1620
+ console.log("cleanup: skipped (no separate change worktree)");
1621
+ }
1622
+ } catch (error) {
1623
+ await appendMetricsEvent(gitRoot, changeId, "finish_failed", {
1624
+ ...finishBasePayload,
1625
+ error: error instanceof Error ? error.message : String(error),
2214
1626
  });
1627
+ throw error;
2215
1628
  }
1629
+ }
2216
1630
 
2217
- console.log(`push: ok (${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""})`);
2218
-
2219
- console.log(`push: target ${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""}`);
2220
-
2221
- if (changeWt?.worktree) {
2222
- const cleanup = await cleanupWorktreePath(gitRoot, changeWt.worktree);
2223
- if (cleanup.status === "removed") {
2224
- console.log(`cleanup: removed worktree ${cleanup.worktree}`);
2225
- } else if (cleanup.status === "pruned-missing") {
2226
- console.log(`cleanup: pruned stale worktree metadata for ${cleanup.worktree}`);
2227
- } else if (cleanup.reason === "current_worktree") {
2228
- console.log("cleanup: skipped (change worktree is current worktree)");
2229
- } else if (cleanup.reason === "locked") {
2230
- console.log(`cleanup: skipped locked worktree ${cleanup.worktree}`);
2231
- } else if (cleanup.reason === "dirty") {
2232
- console.log(`cleanup: skipped dirty worktree ${cleanup.worktree}`);
2233
- } else {
2234
- console.log("cleanup: skipped");
1631
+ if (options.push === true && !finishCompleted) {
1632
+ const cleanupReason = cleanupNote.startsWith("skipped:") ? cleanupNote.slice("skipped:".length) : cleanupNote;
1633
+ await appendMetricsEvent(gitRoot, changeId, "finish_cleanup_pending", {
1634
+ ...finishBasePayload,
1635
+ cleanup: cleanupNote,
1636
+ reason: cleanupReason,
1637
+ });
1638
+ throw new UserError("Change finish incomplete: cleanup pending.", {
1639
+ details:
1640
+ `change: ${changeId}\n` +
1641
+ `next: cleanup pending (${cleanupReason || "unknown"})\n` +
1642
+ ` - resolve the change worktree state, then rerun: aiws change finish ${changeId} --push${options.remote ? ` --remote ${options.remote}` : ""}`,
1643
+ });
1644
+ }
1645
+
1646
+ if (finishCompleted) {
1647
+ const archiveResult = await runChangeArchiveWorkflow(
1648
+ {
1649
+ gitRoot,
1650
+ changeId,
1651
+ changeDir: changeDirAbs(gitRoot, changeId),
1652
+ force: false,
1653
+ },
1654
+ archiveWorkflowDeps,
1655
+ );
1656
+ archivedToRel = archiveResult.archivedToRel;
1657
+ handoffRel = archiveResult.handoffRel;
1658
+ await appendMetricsEventAtDir(archiveResult.archivedTo, changeId, "finish_done", { ...finishBasePayload, cleanup: cleanupNote });
1659
+
1660
+ if (options.push === true) {
1661
+ const stageArchive = await runCommand("git", ["add", "-A", "--", "changes"], { cwd: gitRoot });
1662
+ if (stageArchive.code !== 0) {
1663
+ throw new UserError("Finish completed, but failed to stage archived change artifacts.", {
1664
+ details: stageArchive.stderr || stageArchive.stdout,
1665
+ });
1666
+ }
1667
+
1668
+ const stagedArchive = await runCommand("git", ["diff", "--cached", "--quiet", "--", "changes"], { cwd: gitRoot });
1669
+ if (stagedArchive.code !== 0) {
1670
+ const archiveCommit = await runCommand("git", ["commit", "-m", `归档: ${changeId}`], { cwd: gitRoot });
1671
+ if (archiveCommit.code !== 0) {
1672
+ throw new UserError("Finish completed, but failed to commit archived change artifacts.", {
1673
+ details: `archived_to: ${archivedToRel}\nhandoff: ${handoffRel}\n\n${archiveCommit.stderr || archiveCommit.stdout}`,
1674
+ });
1675
+ }
1676
+
1677
+ const archivePushTarget = await resolvePushTarget(gitRoot, into, options.remote, { runCommand });
1678
+ const archivePush = await runCommand("git", ["push", archivePushTarget.remote, archivePushTarget.refspec], { cwd: gitRoot });
1679
+ if (archivePush.code !== 0) {
1680
+ throw new UserError("Finish completed, but failed to push archived change artifacts.", {
1681
+ details:
1682
+ `archived_to: ${archivedToRel}\n` +
1683
+ `handoff: ${handoffRel}\n` +
1684
+ `remote: ${archivePushTarget.remote}\nlocal_branch: ${into}\nremote_branch: ${archivePushTarget.remoteBranch}\n` +
1685
+ (archivePushTarget.trackedMergeRef ? `tracked_merge: ${archivePushTarget.trackedMergeRef}\n` : "") +
1686
+ `\n${archivePush.stderr || archivePush.stdout}`,
1687
+ });
1688
+ }
2235
1689
  }
2236
- } else {
2237
- console.log("cleanup: skipped (no separate change worktree)");
2238
1690
  }
2239
1691
  }
2240
1692
 
2241
- if (options.push === true && (await listSubmodulesFromGitmodules(gitRoot)).length > 0) {
1693
+ const outcomeLabel =
1694
+ options.push === true ? (finishCompleted ? "finished change" : "pushed change; cleanup pending") : "finished locally";
1695
+ console.log(`ok: ${outcomeLabel}: ${changeId}`);
1696
+ console.log(`into: ${into}`);
1697
+ console.log(`from: ${changeBranch}`);
1698
+ if (archivedToRel) console.log(`archived_to: ${archivedToRel}`);
1699
+ if (handoffRel) console.log(`handoff: ${handoffRel}`);
1700
+
1701
+ if (options.push === true && (await listSubmodulesFromGitmodules(gitRoot, { pathExists, runCommand })).length > 0) {
2242
1702
  console.log("next:");
2243
1703
  console.log(" - submodules already synced for finish --push; rerun `git submodule update --init --recursive` only if another worktree changed them later");
2244
1704
  }
@@ -2256,70 +1716,21 @@ export async function changeStatusCommand(options) {
2256
1716
  const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "status" });
2257
1717
  const st = await computeChangeStatus(gitRoot, changeId);
2258
1718
  const branch = await currentBranch(gitRoot);
2259
- /** @type {{ changeWorktree?: string, targetWorktree?: string }} */
2260
- const wtHint = {};
2261
- try {
2262
- const worktrees = await listGitWorktrees(gitRoot);
2263
- const changeRef = `refs/heads/change/${changeId}`;
2264
- const changeWt = worktrees.find((w) => String(w.branch || "") === changeRef);
2265
- if (changeWt?.worktree && path.resolve(changeWt.worktree) !== path.resolve(gitRoot)) wtHint.changeWorktree = changeWt.worktree;
2266
- } catch {
2267
- // best-effort; ignore
2268
- }
2269
-
2270
- console.log(`✓ aiws change status: ${st.changeId}`);
2271
- console.log(`dir: ${st.dir}`);
2272
- console.log(`phase: ${st.phase}`);
2273
- console.log(`worktree: ${gitRoot}`);
2274
- console.log(`branch: ${branch || "(detached HEAD)"}`);
2275
- if (wtHint.changeWorktree) console.log(`note: change/<id> checked out in another worktree: ${wtHint.changeWorktree}`);
2276
- console.log(`meta: ${st.metaState}`);
2277
- if (st.reqId) console.log(`Req_ID: ${st.reqId}`);
2278
- if (st.probId) console.log(`Problem_ID: ${st.probId}`);
2279
- console.log(`tasks: ${st.tasks.done}/${st.tasks.total} (unchecked=${st.tasks.unchecked})`);
2280
- console.log(`baseline: ${st.baselineLabel}${st.baselineAt ? ` (at=${st.baselineAt})` : ""}`);
2281
- console.log(`drift: ${st.driftFiles.length > 0 ? st.driftFiles.join(", ") : "(none)"}`);
2282
- if (st.evidence?.counts) {
2283
- const c = st.evidence.counts;
2284
- console.log(`evidence: declared=${c.total} (exists=${c.exists}, missing=${c.missing}, persistent=${c.persistent}, tmp=${c.tmp})`);
2285
- }
2286
-
2287
- console.log("");
2288
- console.log("Blockers (strict):");
2289
- if (st.blockersStrict.length === 0) console.log("- (none)");
2290
- else for (const b of st.blockersStrict) console.log(`- ${b}`);
2291
-
2292
- console.log("");
2293
- console.log("Blockers (archive):");
2294
- if (st.blockersArchive.length === 0) console.log("- (none)");
2295
- else for (const b of st.blockersArchive) console.log(`- ${b}`);
2296
-
2297
- console.log("");
2298
- console.log("Next (recommended):");
2299
- console.log(`- ${planVerifyHint(st.changeId)}`);
2300
- if (st.blockersStrict.length > 0) {
2301
- console.log("- 先修复 Blockers (strict) 后复跑质量门,再进入 `$ws-dev`");
2302
- if (wtHint.changeWorktree) console.log(`- 若需要写代码:先切到 change worktree 再改动:\`cd ${wtHint.changeWorktree}\``);
2303
- return;
2304
- }
2305
-
2306
- if (st.tasks.unchecked > 0) {
2307
- console.log("- 继续开发:在 AI 工具中运行 `$ws-dev`(小步实现 + 可复现验证)");
2308
- console.log("- 提交/交付前强制门禁:`aiws validate .`(可选落盘:`aiws validate . --stamp`)");
2309
- console.log(`- (可选)状态快照:\`aiws change state ${st.changeId} --write\``);
2310
- return;
2311
- }
2312
-
2313
- // deliver-ready hints
2314
- if (st.evidence?.counts && st.evidence.counts.persistent === 0) {
2315
- console.log("- 交付前建议生成持久证据:`changes/<id>/evidence/...`(并把路径回填到 proposal.md 的 Evidence_Path)");
2316
- }
2317
- if (st.evidence?.missing && st.evidence.missing.length > 0) {
2318
- const show = st.evidence.missing.slice(0, 5);
2319
- console.log(`- 交付前建议补齐缺失证据文件(missing=${st.evidence.missing.length}):${show.join(", ")}${st.evidence.missing.length > show.length ? ", ..." : ""}`);
2320
- }
2321
- console.log(`- (可选)状态快照:\`aiws change state ${st.changeId} --write\``);
2322
- console.log("- 进入交付:在 AI 工具中运行 `$ws-deliver`(submodule 感知提交 + 门禁 + 最终 `$ws-finish`)");
1719
+ const changeWorktreeHint = await resolveChangeWorktreeHint(gitRoot, changeId, { listGitWorktrees });
1720
+ const output = await renderChangeStatusOutput(
1721
+ {
1722
+ gitRoot,
1723
+ branch,
1724
+ changeWorktreeHint,
1725
+ status: st,
1726
+ },
1727
+ {
1728
+ effectiveReviewCount,
1729
+ governanceGuidanceLines,
1730
+ planVerifyHint,
1731
+ },
1732
+ );
1733
+ process.stdout.write(output);
2323
1734
  }
2324
1735
 
2325
1736
  /**
@@ -2333,216 +1744,46 @@ export async function changeNextCommand(options) {
2333
1744
 
2334
1745
  const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "next" });
2335
1746
  assertValidChangeId(changeId);
2336
- const branch = await currentBranch(gitRoot);
1747
+ const st = await computeChangeStatus(gitRoot, changeId);
2337
1748
 
2338
1749
  const changeDir = changeDirAbs(gitRoot, changeId);
2339
1750
  if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
2340
-
2341
- const proposal = await fileState(changeDir, "proposal.md");
2342
- const tasks = await fileState(changeDir, "tasks.md");
2343
- const design = await fileState(changeDir, "design.md");
2344
-
2345
- const metaPath = path.join(changeDir, ".ws-change.json");
2346
- /** @type {any} */
2347
- let meta = null;
2348
- if (await pathExists(metaPath)) {
2349
- try {
2350
- meta = JSON.parse(await readText(metaPath));
2351
- } catch {
2352
- meta = null;
2353
- }
2354
- }
2355
-
2356
- const curTruth = await snapshotTruthShaOnly(gitRoot);
2357
- const drift = meta ? truthDrift(curTruth, meta).driftFiles : [];
2358
-
2359
- /** @type {string[]} */
2360
- const actions = [];
2361
-
2362
- const inferred = inferChangeIdFromBranch(branch);
2363
- if (!branch) {
2364
- actions.push("当前为 detached HEAD:建议切到 `change/<change-id>` 分支后再继续(或显式传入 `<change-id>`)");
2365
- } else if (inferred && inferred !== changeId) {
2366
- actions.push(`当前分支为 \`${branch}\`(推断 change=${inferred}),但你在查看 change=${changeId}:建议先切换到 \`change/${changeId}\` 再执行开发/交付`);
2367
- } else if (!inferred) {
2368
- actions.push(`当前分支为 \`${branch}\`(非 change 分支):建议 \`aiws change start ${changeId} --hooks --switch\` 或 \`git switch change/${changeId}\``);
2369
- }
2370
-
2371
- try {
2372
- const worktrees = await listGitWorktrees(gitRoot);
2373
- const changeRef = `refs/heads/change/${changeId}`;
2374
- const changeWt = worktrees.find((w) => String(w.branch || "") === changeRef);
2375
- if (changeWt?.worktree && path.resolve(changeWt.worktree) !== path.resolve(gitRoot)) {
2376
- actions.push(`change/${changeId} 分支在另一个 worktree:\`cd ${changeWt.worktree}\`(建议在该目录执行开发/交付相关命令)`);
2377
- }
2378
- } catch {
2379
- // best-effort; ignore
2380
- }
2381
-
2382
- if (!meta) {
2383
- actions.push(`补齐元信息并建立真值基线:\`aiws change sync ${changeId}\``);
2384
- }
2385
-
2386
- if (meta && drift.length > 0) {
2387
- actions.push("真值/合同已变化:先对齐 `AI_PROJECT.md` / `AI_WORKSPACE.md` / `REQUIREMENTS.md` 与 proposal/tasks");
2388
- actions.push(`确认后同步基线:\`aiws change sync ${changeId}\``);
2389
- }
2390
-
2391
- if (proposal.state !== "ok" || proposal.wsTodo > 0 || proposal.placeholders > 0) {
2392
- actions.push(`完善 proposal:\`$EDITOR ${path.join(changeDir, "proposal.md")}\``);
2393
- }
2394
-
2395
- if (design.state === "ok" && (design.wsTodo > 0 || design.placeholders > 0)) {
2396
- actions.push(`(可选) 完善 design:\`$EDITOR ${path.join(changeDir, "design.md")}\``);
2397
- }
2398
-
2399
- if (tasks.state !== "ok" || tasks.wsTodo > 0 || tasks.placeholders > 0) {
2400
- actions.push(`完善 tasks:\`$EDITOR ${path.join(changeDir, "tasks.md")}\``);
2401
- }
2402
-
2403
- if (proposal.state === "ok") {
2404
- const reqId = extractId("Req_ID", proposal.text);
2405
- const probId = extractId("Problem_ID", proposal.text);
2406
- if (!(reqId || probId)) {
2407
- actions.push("补齐归因:在 proposal.md 填写非空 `Req_ID` 或 `Problem_ID`(严格校验需要)");
2408
- }
2409
-
2410
- const contractRow = extractId("Contract_Row", proposal.text) || extractId("Contract_Row(s)", proposal.text);
2411
- if (!contractRow) actions.push("补齐绑定:在 proposal.md 填写非空 `Contract_Row`(严格校验需要)");
2412
-
2413
- const planFile = extractId("Plan_File", proposal.text) || extractId("Plan file", proposal.text);
2414
- const planFiles = splitDeclaredValues(planFile);
2415
- if (planFiles.length !== 1) actions.push("补齐绑定:proposal.md `Plan_File` 必须且只能包含 1 个 plan 路径(严格校验需要)");
2416
- else {
2417
- const planRel = String(planFiles[0] || "").replace(/^\.\//, "");
2418
- if (path.isAbsolute(planRel)) actions.push("修正绑定:proposal.md `Plan_File` 必须是工作区相对路径(不要绝对路径)");
2419
- else if (!(await pathExists(path.join(gitRoot, planRel)))) actions.push(`补齐计划文件:缺少 ${planRel}(或修正 proposal.md 的 Plan_File)`);
2420
- }
2421
-
2422
- const evidenceDecl = extractId("Evidence_Path", proposal.text) || extractId("Evidence_Path(s)", proposal.text);
2423
- const evidencePaths = splitDeclaredValues(evidenceDecl);
2424
- if (evidencePaths.length === 0) {
2425
- actions.push("补齐绑定:在 proposal.md 填写非空 `Evidence_Path`(严格校验需要;建议优先写 `changes/<id>/evidence/...`)");
2426
- } else {
2427
- const evidence = await analyzeEvidencePaths(gitRoot, changeId, evidencePaths);
2428
- if (evidence.counts.persistent === 0) {
2429
- actions.push("交付前建议生成持久证据:`changes/<id>/evidence/...`(并把路径回填到 Evidence_Path;`.agentdocs/tmp/...` 可作为原始证据引用)");
2430
- }
2431
- if (evidence.missing.length > 0) {
2432
- const show = evidence.missing.slice(0, 5);
2433
- actions.push(`补齐缺失证据文件(missing=${evidence.missing.length}):${show.join(", ")}${evidence.missing.length > show.length ? ", ..." : ""}`);
2434
- }
2435
- }
2436
- }
2437
-
2438
- const prog = tasks.state === "ok" ? checkboxStats(tasks.text) : { total: 0, done: 0, unchecked: 0, hasCheckboxes: false };
2439
- if (!prog.hasCheckboxes) {
2440
- actions.push("tasks.md 需要至少一条 checkbox 任务(`- [ ]` / `- [x]`)");
2441
- } else if (prog.unchecked > 0) {
2442
- actions.push(`完成未勾选任务(${prog.unchecked} 项)`);
2443
- }
2444
-
2445
- if (actions.length > 0) {
2446
- console.log(`- ${planVerifyHint(changeId)}`);
2447
- for (const a of actions) console.log(`- ${a}`);
2448
- console.log(`- 修复后复跑质量门:\`aiws change validate ${changeId} --strict\``);
2449
- console.log("- 质量门通过后再进入编码:在 AI 工具中运行 `$ws-dev`");
2450
- return;
2451
- }
2452
-
2453
- console.log(`- ${planVerifyHint(changeId)}`);
2454
- if (prog.unchecked > 0) {
2455
- console.log("- 若仍需继续编码,先通过质量门,再在 AI 工具中运行 `$ws-dev`");
2456
- } else {
2457
- console.log("- 进入交付:在 AI 工具中运行 `$ws-deliver`(门禁 + submodule 感知提交 + 最终 `$ws-finish`)");
2458
- console.log("- 交付前门禁(推荐落盘):`aiws validate . --stamp`");
2459
- }
2460
- console.log("- 审计与证据(推荐):在 AI 工具内运行 `/ws-review`(或按 AI_PROJECT.md 手工审计)");
2461
- console.log(`- 归档:\`aiws change archive ${changeId}\``);
1751
+ const output = await renderChangeNextOutput(
1752
+ {
1753
+ gitRoot,
1754
+ changeId,
1755
+ changeDir,
1756
+ status: st,
1757
+ },
1758
+ {
1759
+ analyzeEvidencePaths,
1760
+ computeChangeNextAdvice,
1761
+ extractId,
1762
+ fileState,
1763
+ pathExists,
1764
+ planVerifyHint,
1765
+ splitDeclaredValues,
1766
+ },
1767
+ );
1768
+ process.stdout.write(output);
2462
1769
  }
2463
1770
 
2464
1771
  /**
2465
1772
  * @param {string} gitRoot
2466
1773
  * @param {string} changeId
2467
1774
  */
2468
- async function computeChangeNextAdvice(gitRoot, changeId) {
2469
- const st = await computeChangeStatus(gitRoot, changeId);
2470
- const branch = await currentBranch(gitRoot);
2471
- const inferred = inferChangeIdFromBranch(branch);
2472
- const advice = {
2473
- changeId,
2474
- branch: branch || "",
2475
- inferredChangeId: inferred || "",
2476
- phase: st.phase,
2477
- blockersStrict: st.blockersStrict,
2478
- evidence: st.evidence?.counts || null,
2479
- actions: [],
2480
- prohibitions: [],
2481
- recommended: [],
2482
- };
2483
-
2484
- if (!branch) {
2485
- advice.actions.push("当前为 detached HEAD:建议切到 `change/<change-id>` 分支后再继续(或显式传入 `<change-id>`)");
2486
- } else if (inferred && inferred !== changeId) {
2487
- advice.actions.push(`当前分支为 \`${branch}\`(推断 change=${inferred}),但你在查看 change=${changeId}:建议先切换到 \`change/${changeId}\` 再执行开发/交付`);
2488
- } else if (!inferred) {
2489
- advice.actions.push(`当前分支为 \`${branch}\`(非 change 分支):建议 \`aiws change start ${changeId} --hooks --switch\` 或 \`git switch change/${changeId}\``);
2490
- }
2491
-
2492
- try {
2493
- const worktrees = await listGitWorktrees(gitRoot);
2494
- const changeRef = `refs/heads/change/${changeId}`;
2495
- const changeWt = worktrees.find((w) => String(w.branch || "") === changeRef);
2496
- if (changeWt?.worktree && path.resolve(changeWt.worktree) !== path.resolve(gitRoot)) {
2497
- advice.actions.push(`change/${changeId} 分支在另一个 worktree:\`cd ${changeWt.worktree}\`(建议在该目录执行开发/交付相关命令)`);
2498
- advice.prohibitions.push("不要在当前 worktree 继续写代码(很可能不在 change 分支上)");
2499
- }
2500
- } catch {
2501
- // ignore
2502
- }
2503
-
2504
- if (st.metaState !== "ok") advice.actions.push(`补齐元信息并建立真值基线:\`aiws change sync ${changeId}\``);
2505
- if (st.driftFiles.length > 0) {
2506
- advice.actions.push("真值/合同已变化:先对齐 `AI_PROJECT.md` / `AI_WORKSPACE.md` / `REQUIREMENTS.md` 与 proposal/tasks");
2507
- advice.actions.push(`确认后同步基线:\`aiws change sync ${changeId}\``);
2508
- }
2509
-
2510
- if (st.blockersStrict.some((b) => b.includes("proposal.md") && b.includes("WS:TODO"))) {
2511
- advice.actions.push(`完善 proposal:\`$EDITOR ${path.join(changeDirAbs(gitRoot, changeId), "proposal.md")}\``);
2512
- }
2513
- if (st.blockersStrict.some((b) => b.includes("tasks.md") && b.includes("WS:TODO"))) {
2514
- advice.actions.push(`完善 tasks:\`$EDITOR ${path.join(changeDirAbs(gitRoot, changeId), "tasks.md")}\``);
2515
- }
2516
- if (st.blockersStrict.some((b) => b.includes("proposal.md missing attribution"))) {
2517
- advice.actions.push("补齐归因:在 proposal.md 填写非空 `Req_ID` 或 `Problem_ID`(严格校验需要)");
2518
- }
2519
- if (st.blockersStrict.some((b) => b.includes("missing Contract_Row"))) {
2520
- advice.actions.push("补齐绑定:在 proposal.md 填写非空 `Contract_Row`(严格校验需要)");
2521
- }
2522
- if (st.blockersStrict.some((b) => b.includes("Plan_File"))) {
2523
- advice.actions.push("补齐绑定:修正 proposal.md 的 `Plan_File`(严格校验需要;必须存在且仅 1 个)");
2524
- }
2525
- if (st.blockersStrict.some((b) => b.includes("missing Evidence_Path"))) {
2526
- advice.actions.push("补齐绑定:在 proposal.md 填写非空 `Evidence_Path`(严格校验需要;建议优先写 `changes/<id>/evidence/...`)");
2527
- }
2528
- if (st.tasks.unchecked > 0) advice.actions.push(`完成未勾选任务(${st.tasks.unchecked} 项)`);
2529
-
2530
- if (st.blockersStrict.length > 0 && advice.actions.length === 0) {
2531
- advice.actions.push("先修复 Blockers (strict) 后复跑质量门,再进入 `$ws-dev`");
2532
- }
2533
-
2534
- if (advice.actions.length === 0) {
2535
- if (st.tasks.unchecked > 0) {
2536
- advice.recommended.push("继续开发:在 AI 工具中运行 `$ws-dev`(小步实现 + 可复现验证)");
2537
- } else {
2538
- advice.recommended.push("进入交付:在 AI 工具中运行 `$ws-deliver`(门禁 + submodule 感知提交 + 最终 `$ws-finish`)");
2539
- if (advice.evidence && advice.evidence.persistent === 0) {
2540
- advice.recommended.push("交付前建议生成持久证据并回填:`aiws change evidence <change-id>`");
2541
- }
2542
- }
2543
- }
2544
-
2545
- return advice;
1775
+ export async function computeChangeNextAdvice(gitRoot, changeId) {
1776
+ return computeChangeNextAdviceModel(gitRoot, changeId, {
1777
+ computeChangeStatus,
1778
+ currentBranch,
1779
+ inferChangeIdFromBranch,
1780
+ listGitWorktrees,
1781
+ changeDirAbs,
1782
+ governanceGuidanceLines,
1783
+ scopeGateFailureAction,
1784
+ scopeGatePendingRecommendation: (id) => scopeGatePendingRecommendation(id),
1785
+ scopeGatePassedRecommendation,
1786
+ });
2546
1787
  }
2547
1788
 
2548
1789
  /**
@@ -2556,62 +1797,24 @@ export async function changeStateCommand(options) {
2556
1797
 
2557
1798
  const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "state" });
2558
1799
  assertValidChangeId(changeId);
2559
-
2560
- const st = await computeChangeStatus(gitRoot, changeId);
2561
- const advice = await computeChangeNextAdvice(gitRoot, changeId);
2562
-
2563
- const now = nowIsoUtc();
2564
- /** @type {string[]} */
2565
- const lines = [];
2566
- lines.push(`# AIWS STATE: ${changeId}`);
2567
- lines.push("");
2568
- lines.push(`Generated: ${now}`);
2569
- lines.push("");
2570
- lines.push("## Context");
2571
- lines.push(`- Worktree: \`${gitRoot}\``);
2572
- lines.push(`- Branch: \`${advice.branch || "(detached)"}\``);
2573
- lines.push("");
2574
- lines.push("## Phase");
2575
- lines.push(`- Phase: \`${st.phase}\``);
2576
- lines.push(`- Tasks: ${st.tasks.done}/${st.tasks.total} (unchecked=${st.tasks.unchecked})`);
2577
- lines.push("");
2578
- lines.push("## Bindings");
2579
- if (st.reqId) lines.push(`- Req_ID: \`${st.reqId}\``);
2580
- if (st.probId) lines.push(`- Problem_ID: \`${st.probId}\``);
2581
- if (st.bindings?.contractRow) lines.push(`- Contract_Row: \`${st.bindings.contractRow}\``);
2582
- if (st.bindings?.planFile) lines.push(`- Plan_File: \`${st.bindings.planFile}\``);
2583
- lines.push("");
2584
- lines.push("## Evidence");
2585
- if (st.evidence?.counts) {
2586
- const c = st.evidence.counts;
2587
- lines.push(`- Declared: \`${c.total}\` (exists=\`${c.exists}\`, missing=\`${c.missing}\`, persistent=\`${c.persistent}\`, tmp=\`${c.tmp}\`)`);
2588
- } else {
2589
- lines.push("- (unknown)");
2590
- }
2591
- lines.push("");
2592
- lines.push("## Blockers (strict)");
2593
- if (st.blockersStrict.length === 0) lines.push("- (none)");
2594
- else for (const b of st.blockersStrict) lines.push(`- ${b}`);
2595
- lines.push("");
2596
- lines.push("## Next");
2597
- lines.push(`- ${planVerifyHint(changeId)}`);
2598
- for (const a of advice.actions) lines.push(`- ${a}`);
2599
- for (const r of advice.recommended) lines.push(`- ${r}`);
2600
- if (advice.prohibitions.length > 0) {
2601
- lines.push("");
2602
- lines.push("## Do Not");
2603
- for (const p of advice.prohibitions) lines.push(`- ${p}`);
2604
- }
2605
-
2606
- const out = lines.join("\n") + "\n";
2607
- const dest = path.join(changeDirAbs(gitRoot, changeId), "STATE.md");
2608
- if (options.write === true) {
2609
- await writeText(dest, out);
2610
- await appendMetricsEvent(gitRoot, changeId, "state_written", { path: path.relative(gitRoot, dest) });
2611
- console.log(`ok: state written: ${path.relative(gitRoot, dest)}`);
2612
- return;
2613
- }
2614
- process.stdout.write(out);
1800
+ const result = await runChangeStateCommand(
1801
+ {
1802
+ gitRoot,
1803
+ changeId,
1804
+ options,
1805
+ },
1806
+ {
1807
+ appendMetricsEvent,
1808
+ changeDirAbs,
1809
+ computeChangeNextAdvice,
1810
+ computeChangeStatus,
1811
+ nowIsoUtc,
1812
+ planVerifyHint,
1813
+ renderChangeStateMarkdown,
1814
+ writeText,
1815
+ },
1816
+ );
1817
+ process.stdout.write(result.output);
2615
1818
  }
2616
1819
 
2617
1820
  /**
@@ -2626,98 +1829,18 @@ export async function metricsSummaryCommand(options) {
2626
1829
  const sinceRaw = String(options.since || "").trim();
2627
1830
  const since = sinceRaw ? new Date(`${sinceRaw}T00:00:00Z`).getTime() : 0;
2628
1831
  if (sinceRaw && Number.isNaN(since)) throw new UserError("Invalid --since (expected YYYY-MM-DD).", { details: `since=${sinceRaw}` });
2629
-
2630
- const changesRoot = path.join(gitRoot, "changes");
2631
- if (!(await pathExists(changesRoot))) {
2632
- console.log("(no changes dir)");
2633
- return;
2634
- }
2635
-
2636
- /** @type {Array<{ changeId: string, path: string, metrics: any }>} */
2637
- const all = [];
2638
- const scanDirs = [changesRoot, path.join(changesRoot, "archive")];
2639
- for (const base of scanDirs) {
2640
- if (!(await pathExists(base))) continue;
2641
- const entries = await fs.readdir(base, { withFileTypes: true });
2642
- for (const e of entries) {
2643
- if (!e.isDirectory()) continue;
2644
- const dir = path.join(base, e.name);
2645
- const mAbs = path.join(dir, "metrics.json");
2646
- if (!(await pathExists(mAbs))) continue;
2647
- try {
2648
- const m = JSON.parse(await readText(mAbs));
2649
- const cid = String(m?.change_id || "").trim() || e.name;
2650
- all.push({ changeId: cid, path: path.relative(gitRoot, mAbs), metrics: m });
2651
- } catch {
2652
- // ignore invalid
2653
- }
2654
- }
2655
- }
2656
-
2657
- if (all.length === 0) {
2658
- console.log("(no metrics.json found)");
2659
- return;
2660
- }
2661
-
2662
- /** @type {Record<string, number>} */
2663
- const eventCounts = {};
2664
- let strictValidations = 0;
2665
- let strictPass = 0;
2666
- let evidenceRuns = 0;
2667
- let finishRuns = 0;
2668
- /** @type {number[]} */
2669
- const durations = [];
2670
-
2671
- for (const row of all) {
2672
- const events = Array.isArray(row.metrics?.events) ? row.metrics.events : [];
2673
- const filtered = since
2674
- ? events.filter((ev) => {
2675
- const t = Date.parse(String(ev?.timestamp || ""));
2676
- return Number.isFinite(t) && t >= since;
2677
- })
2678
- : events;
2679
-
2680
- for (const ev of filtered) {
2681
- const type = String(ev?.type || "unknown");
2682
- eventCounts[type] = (eventCounts[type] || 0) + 1;
2683
- if (type === "validate") {
2684
- const p = ev?.payload || {};
2685
- if (p.strict === true) {
2686
- strictValidations += 1;
2687
- if (p.ok === true) strictPass += 1;
2688
- }
2689
- }
2690
- if (type === "evidence") evidenceRuns += 1;
2691
- if (type === "finish") finishRuns += 1;
2692
- }
2693
-
2694
- // Cycle time (best-effort): change_new -> finish
2695
- const tNew = events.find((e) => e?.type === "change_new")?.timestamp;
2696
- const tFin = events.find((e) => e?.type === "finish")?.timestamp;
2697
- if (tNew && tFin) {
2698
- const a = Date.parse(String(tNew));
2699
- const b = Date.parse(String(tFin));
2700
- if (Number.isFinite(a) && Number.isFinite(b) && b >= a) durations.push(Math.floor((b - a) / 1000));
2701
- }
2702
- }
2703
-
2704
- const avgSec = durations.length > 0 ? Math.floor(durations.reduce((x, y) => x + y, 0) / durations.length) : 0;
2705
- const avgMin = avgSec ? Math.floor(avgSec / 60) : 0;
2706
-
2707
- console.log(`AIWS Metrics Summary${sinceRaw ? ` (since ${sinceRaw})` : ""}`);
2708
- console.log("================================================");
2709
- console.log(`Total metric files: ${all.length}`);
2710
- console.log("");
2711
- console.log("Event counts:");
2712
- for (const k of Object.keys(eventCounts).sort()) console.log(`- ${k}: ${eventCounts[k]}`);
2713
- console.log("");
2714
- console.log("Quality:");
2715
- console.log(`- Strict validate pass rate: ${strictValidations > 0 ? `${strictPass}/${strictValidations}` : "(no data)"}`);
2716
- console.log(`- Evidence runs: ${evidenceRuns}`);
2717
- console.log(`- Finish runs: ${finishRuns}`);
2718
- console.log("");
2719
- console.log("Cycle time (best-effort):");
2720
- console.log(`- Avg change_new -> finish: ${durations.length > 0 ? `${avgMin} min` : "(no data)"}`);
1832
+ const output = await renderMetricsSummaryOutput(
1833
+ {
1834
+ gitRoot,
1835
+ sinceRaw,
1836
+ since,
1837
+ },
1838
+ {
1839
+ pathExists,
1840
+ readText,
1841
+ },
1842
+ );
1843
+ process.stdout.write(output);
2721
1844
  }
2722
1845
 
2723
1846
  /**
@@ -2731,201 +1854,38 @@ export async function changeEvidenceCommand(options) {
2731
1854
 
2732
1855
  const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "evidence" });
2733
1856
  assertValidChangeId(changeId);
2734
-
2735
- const changeDir = changeDirAbs(gitRoot, changeId);
2736
- if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
2737
-
2738
- const proposal = await fileState(changeDir, "proposal.md");
2739
- if (proposal.state !== "ok") throw new UserError("proposal.md is required for evidence generation.", { details: path.relative(gitRoot, proposal.abs) });
2740
-
2741
- const planDecl = extractId("Plan_File", proposal.text) || extractId("Plan file", proposal.text);
2742
- const planFiles = splitDeclaredValues(planDecl).map((p) => String(p || "").replace(/^\.\//, ""));
2743
- const planRel = planFiles.length === 1 ? planFiles[0] : "";
2744
- const planAbs = planRel ? path.join(gitRoot, planRel) : "";
2745
- const planText = planAbs && (await pathExists(planAbs)) ? await readText(planAbs) : "";
2746
-
2747
- const now = nowStampUtc();
2748
- const evidenceDir = path.join(changeDir, "evidence");
2749
- const reviewDir = path.join(changeDir, "review");
2750
- await ensureDir(evidenceDir);
2751
- await ensureDir(reviewDir);
2752
-
2753
- /** @type {string[]} */
2754
- const created = [];
2755
-
2756
- const branch = await currentBranch(gitRoot);
2757
-
2758
- const status = await computeChangeStatus(gitRoot, changeId);
2759
- const statusOut = {
2760
- generated_at: nowIsoUtc(),
2761
- ws_root: gitRoot,
2762
- change_id: changeId,
2763
- branch: branch || "",
2764
- phase: status.phase,
2765
- tasks: status.tasks,
2766
- bindings: status.bindings,
2767
- truth: {
2768
- baseline: { label: status.baselineLabel, at: status.baselineAt },
2769
- drift_files: status.driftFiles,
1857
+ const result = await runChangeEvidenceCommand(
1858
+ {
1859
+ gitRoot,
1860
+ changeId,
1861
+ options,
2770
1862
  },
2771
- evidence: status.evidence?.counts || null,
2772
- note: "aiws change evidence status snapshot; does not contain secrets.",
2773
- };
2774
- const statusPath = path.join(evidenceDir, `change-status-${now}.json`);
2775
- await writeText(statusPath, JSON.stringify(statusOut, null, 2) + "\n");
2776
- created.push(relFromRoot(gitRoot, statusPath));
2777
-
2778
- /** @type {any | null} */
2779
- let validateRes = null;
2780
- let validateFailed = false;
2781
- /** @type {string} */
2782
- let validatePathForError = "";
2783
- if (!options.noValidate) {
2784
- validateRes = await validateChangeArtifacts(gitRoot, changeId, { strict: true, allowTruthDrift: false });
2785
- const validatePath = path.join(evidenceDir, `change-validate-strict-${now}.json`);
2786
- await writeText(validatePath, JSON.stringify(validateRes, null, 2) + "\n");
2787
- created.push(relFromRoot(gitRoot, validatePath));
2788
- validateFailed = validateRes.ok !== true;
2789
- validatePathForError = validatePath;
2790
- }
2791
-
2792
- const validateStampDir = path.join(gitRoot, ".agentdocs", "tmp", "aiws-validate");
2793
- const latestValidateStamp = await findLatestFileByMtime(validateStampDir, (n) => n.endsWith(".json"));
2794
- if (latestValidateStamp) {
2795
- const dst = path.join(evidenceDir, `aiws-validate-stamp-${now}.json`);
2796
- await fs.copyFile(latestValidateStamp, dst);
2797
- created.push(relFromRoot(gitRoot, dst));
2798
- }
2799
-
2800
- const syncStampDir = path.join(gitRoot, ".agentdocs", "tmp", "change-sync");
2801
- const latestSyncStamp = await findLatestFileByMtime(syncStampDir, (n) => n.endsWith(`-${changeId}.json`));
2802
- if (latestSyncStamp) {
2803
- const dst = path.join(evidenceDir, `change-sync-stamp-${now}.json`);
2804
- await fs.copyFile(latestSyncStamp, dst);
2805
- created.push(relFromRoot(gitRoot, dst));
2806
- }
2807
-
2808
- const reviewAbs = path.join(reviewDir, "codex-review.md");
2809
- if (!(await pathExists(reviewAbs))) {
2810
- const tmpReview = path.join(gitRoot, ".agentdocs", "tmp", "review", "codex-review.md");
2811
- if (await pathExists(tmpReview)) {
2812
- await fs.copyFile(tmpReview, reviewAbs);
2813
- }
2814
- }
2815
- if (await pathExists(reviewAbs)) created.push(relFromRoot(gitRoot, reviewAbs));
2816
-
2817
- // Collect all review files (support multiple reviewers)
2818
- if (await pathExists(reviewDir)) {
2819
- try {
2820
- const reviewEntries = await fs.readdir(reviewDir, { withFileTypes: true });
2821
- for (const e of reviewEntries) {
2822
- if (!e.isFile()) continue;
2823
- if (e.name === "codex-review.md") continue; // already added
2824
- if (e.name.endsWith(".md")) {
2825
- created.push(relFromRoot(gitRoot, path.join(reviewDir, e.name)));
2826
- }
2827
- }
2828
- } catch {
2829
- // ignore
2830
- }
2831
- }
2832
-
2833
- // Delivery summary (persistent, human-readable).
2834
- const summaryAbs = path.join(evidenceDir, `delivery-summary-${now}.md`);
2835
- /** @type {string[]} */
2836
- const summaryLines = [];
2837
- summaryLines.push(`# Delivery Summary: ${changeId}`);
2838
- summaryLines.push("");
2839
- summaryLines.push(`Generated: ${nowIsoUtc()}`);
2840
- summaryLines.push("");
2841
- summaryLines.push("## Context");
2842
- summaryLines.push(`- Worktree: \`${gitRoot}\``);
2843
- summaryLines.push(`- Branch: \`${branch || "(detached)"}\``);
2844
- summaryLines.push("");
2845
- summaryLines.push("## Status");
2846
- summaryLines.push(`- Phase: \`${status.phase}\``);
2847
- summaryLines.push(`- Tasks: ${status.tasks.done}/${status.tasks.total} (unchecked=${status.tasks.unchecked})`);
2848
- summaryLines.push(`- Truth drift: ${status.driftFiles.length > 0 ? status.driftFiles.join(", ") : "(none)"}`);
2849
- summaryLines.push("");
2850
- summaryLines.push("## Bindings");
2851
- if (status.reqId) summaryLines.push(`- Req_ID: \`${status.reqId}\``);
2852
- if (status.probId) summaryLines.push(`- Problem_ID: \`${status.probId}\``);
2853
- if (status.bindings?.contractRow) summaryLines.push(`- Contract_Row: \`${status.bindings.contractRow}\``);
2854
- if (status.bindings?.planFile) summaryLines.push(`- Plan_File: \`${status.bindings.planFile}\``);
2855
- summaryLines.push("");
2856
- summaryLines.push("## Evidence (Created/Collected)");
2857
- if (created.length === 0) summaryLines.push("- (none)");
2858
- else for (const p of created) summaryLines.push(`- \`${p}\``);
2859
- summaryLines.push("");
2860
- summaryLines.push("## Quality Gate");
2861
- if (options.noValidate) {
2862
- summaryLines.push("- Strict validation: (skipped via --no-validate)");
2863
- } else if (validateRes) {
2864
- summaryLines.push(`- Strict validation ok: \`${validateRes.ok === true}\``);
2865
- summaryLines.push(`- Errors: \`${Array.isArray(validateRes.errors) ? validateRes.errors.length : 0}\``);
2866
- summaryLines.push(`- Warnings: \`${Array.isArray(validateRes.warnings) ? validateRes.warnings.length : 0}\``);
2867
- } else {
2868
- summaryLines.push("- Strict validation: (unknown)");
2869
- }
2870
- summaryLines.push("");
2871
- summaryLines.push("## Next");
2872
- summaryLines.push(`- ${planVerifyHint(changeId)}`);
2873
- if (validateFailed) summaryLines.push("- Fix strict validation errors, then re-run `aiws change evidence`");
2874
- else summaryLines.push(`- Verify evidence gate: \`aiws change validate ${changeId} --strict --check-evidence\``);
2875
- await writeText(summaryAbs, summaryLines.join("\n") + "\n");
2876
- created.push(relFromRoot(gitRoot, summaryAbs));
2877
-
2878
- const declaredEvidence = extractId("Evidence_Path", proposal.text) || extractId("Evidence_Path(s)", proposal.text);
2879
- const existingProposalEvidence = splitDeclaredValues(declaredEvidence).map((p) => String(p || "").replace(/^\.\//, ""));
2880
- const mergedEvidence = mergeDeclaredValues(existingProposalEvidence, created);
2881
- const mergedEvidenceText = formatDeclaredValues(mergedEvidence);
2882
-
2883
- const updatedProposal = upsertProposalEvidencePath(proposal.text, mergedEvidenceText);
2884
- if (updatedProposal.changed) {
2885
- await writeText(path.join(changeDir, "proposal.md"), updatedProposal.text);
2886
- }
2887
-
2888
- if (planAbs && planText) {
2889
- const existingPlanEvidence = splitDeclaredValues(extractId("Evidence_Path", planText) || extractId("Evidence_Path(s)", planText)).map((p) =>
2890
- String(p || "").replace(/^\.\//, ""),
2891
- );
2892
- const mergedPlanEvidence = mergeDeclaredValues(existingPlanEvidence, mergedEvidence);
2893
- const mergedPlanEvidenceText = formatDeclaredValues(mergedPlanEvidence);
2894
- const updatedPlan = upsertIdLine(planText, ["Evidence_Path", "Evidence_Path(s)"], mergedPlanEvidenceText, { insertAfterLabels: ["Plan_File", "Contract_Row"] });
2895
- if (updatedPlan.changed) await writeText(planAbs, updatedPlan.text);
2896
- }
2897
-
2898
- await appendMetricsEvent(gitRoot, changeId, "evidence", {
2899
- generated_at: nowIsoUtc(),
2900
- created: created.length,
2901
- strict_validate: options.noValidate ? "skipped" : validateRes && validateRes.ok === true ? "ok" : "failed",
2902
- allow_fail: options.allowFail === true,
2903
- });
2904
-
2905
- console.log(`✓ aiws change evidence: ${changeId}`);
2906
- console.log(`dir: ${path.relative(gitRoot, changeDir)}`);
2907
- if (planRel) console.log(`Plan_File: ${planRel}`);
2908
- if (created.length > 0) {
2909
- console.log("created:");
2910
- for (const p of created) console.log(`- ${p}`);
2911
- } else {
2912
- console.log("created:");
2913
- console.log("- (none)");
2914
- }
2915
- console.log("updated:");
2916
- console.log(`- ${path.relative(gitRoot, path.join(changeDir, "proposal.md"))} (Evidence_Path)`);
2917
- if (planRel && planText) console.log(`- ${planRel} (Evidence_Path)`);
2918
- console.log("");
2919
- console.log("next:");
2920
- console.log(`- ${planVerifyHint(changeId)}`);
2921
- if (validateFailed) console.log("- fix validation errors, then re-run evidence (or run with --allow-fail to proceed)");
2922
- else console.log(`- verify evidence gate: \`aiws change validate ${changeId} --strict --check-evidence\``);
2923
-
2924
- if (validateFailed && !options.allowFail) {
2925
- const details = (validateRes && validateRes.raw ? String(validateRes.raw) : "").trim() || "Validation failed (see evidence JSON for details).";
2926
- const hint = validatePathForError ? `\n\nevidence:\n- ${path.relative(gitRoot, validatePathForError)}` : "";
2927
- throw new UserError("Failed to generate evidence due to strict validation errors.", { details: `${details}${hint}` });
2928
- }
1863
+ {
1864
+ changeDirAbs,
1865
+ fileState,
1866
+ pathExists,
1867
+ runChangeEvidenceWorkflow,
1868
+ workflowDeps: {
1869
+ appendMetricsEvent,
1870
+ computeChangeStatus,
1871
+ currentBranch,
1872
+ ensureDir,
1873
+ extractId,
1874
+ nowIsoUtc,
1875
+ nowStampUtc,
1876
+ pathExists,
1877
+ planVerifyHint,
1878
+ readText,
1879
+ splitDeclaredValues,
1880
+ validateChangeArtifacts,
1881
+ writeText,
1882
+ UserError,
1883
+ },
1884
+ UserError,
1885
+ },
1886
+ );
1887
+ process.stdout.write(result.output);
1888
+ if (result.errorToThrow) throw result.errorToThrow;
2929
1889
  }
2930
1890
 
2931
1891
  /**
@@ -2939,26 +1899,21 @@ export async function changeValidateCommand(options) {
2939
1899
 
2940
1900
  const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "validate" });
2941
1901
  assertValidChangeId(changeId);
2942
-
2943
- const res = await validateChangeArtifacts(gitRoot, changeId, {
2944
- strict: options.strict === true,
2945
- allowTruthDrift: options.allowTruthDrift === true,
2946
- checkEvidence: options.checkEvidence === true,
2947
- checkScope: options.checkScope === true,
2948
- });
2949
- await appendMetricsEvent(gitRoot, changeId, "validate", {
2950
- ok: res.ok === true,
2951
- strict: options.strict === true,
2952
- allow_truth_drift: options.allowTruthDrift === true,
2953
- check_evidence: options.checkEvidence === true,
2954
- check_scope: options.checkScope === true,
2955
- errors: Array.isArray(res.errors) ? res.errors.length : 0,
2956
- warnings: Array.isArray(res.warnings) ? res.warnings.length : 0,
2957
- exit_code: res.exitCode,
2958
- });
2959
- if (res.raw) process.stderr.write(res.raw + "\n");
2960
- if (!res.ok) throw new UserError("");
2961
- console.log(`ok: change validated (${changeId})`);
1902
+ const result = await runChangeValidateCommand(
1903
+ {
1904
+ gitRoot,
1905
+ changeId,
1906
+ options,
1907
+ },
1908
+ {
1909
+ appendMetricsEvent,
1910
+ validateChangeArtifacts,
1911
+ UserError,
1912
+ },
1913
+ );
1914
+ if (result.stderr) process.stderr.write(result.stderr);
1915
+ if (result.errorToThrow) throw result.errorToThrow;
1916
+ process.stdout.write(result.output);
2962
1917
  }
2963
1918
 
2964
1919
  /**
@@ -2972,100 +1927,30 @@ export async function changeSyncCommand(options) {
2972
1927
 
2973
1928
  const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "sync" });
2974
1929
  assertValidChangeId(changeId);
2975
-
2976
- const changeDir = changeDirAbs(gitRoot, changeId);
2977
- if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
2978
-
2979
- const metaPath = path.join(changeDir, ".ws-change.json");
2980
- /** @type {any} */
2981
- let meta = null;
2982
- if (await pathExists(metaPath)) {
2983
- try {
2984
- meta = JSON.parse(await readText(metaPath));
2985
- } catch {
2986
- meta = null;
2987
- }
2988
- }
2989
-
2990
- const truth = await snapshotTruthFiles(gitRoot);
2991
- const nowIso = nowIsoUtc();
2992
- const nowTs = nowUnixSeconds();
2993
-
2994
- if (!meta) {
2995
- meta = { ws_change_version: 1, id: changeId, title: changeId, created_at: nowIso, base_truth_files: truth };
2996
- }
2997
-
2998
- const createdTruth = meta.base_truth_files && typeof meta.base_truth_files === "object" ? meta.base_truth_files : {};
2999
- const syncedTruth = meta.synced_truth_files && typeof meta.synced_truth_files === "object" ? meta.synced_truth_files : {};
3000
- const baseline = Object.keys(syncedTruth).length > 0 ? syncedTruth : createdTruth;
3001
- const baselineLabel = Object.keys(syncedTruth).length > 0 ? "last sync" : "creation";
3002
- const baselineAt = Object.keys(syncedTruth).length > 0 ? String(meta.synced_at || "") : String(meta.created_at || "");
3003
-
3004
- /** @type {Array<{file: string, from: string | null, to: string | null}>} */
3005
- const changed = [];
3006
-
3007
- for (const [rel, info] of Object.entries(truth)) {
3008
- const curSha = info && typeof info === "object" ? String(info.sha256 || "") : "";
3009
- const baseSha =
3010
- baseline && typeof baseline === "object" && baseline[rel] && typeof baseline[rel] === "object" ? String(baseline[rel].sha256 || "") : "";
3011
- if (baseSha && curSha && baseSha !== curSha) {
3012
- changed.push({ file: rel, from: baseSha, to: curSha });
3013
- } else if (!baseSha && curSha) {
3014
- changed.push({ file: rel, from: null, to: curSha });
3015
- }
3016
- }
3017
-
3018
- if (baseline && typeof baseline === "object") {
3019
- for (const rel of Object.keys(baseline)) {
3020
- if (!truth[rel]) {
3021
- const fromSha = baseline[rel] && typeof baseline[rel] === "object" ? String(baseline[rel].sha256 || "") : null;
3022
- changed.push({ file: rel, from: fromSha || null, to: null });
3023
- }
3024
- }
3025
- }
3026
-
3027
- meta.synced_at = nowIso;
3028
- meta.synced_truth_files = truth;
3029
- let events = meta.sync_events;
3030
- if (!Array.isArray(events)) events = [];
3031
- events.push({ synced_at: nowIso, baseline_from: baselineLabel, baseline_at: baselineAt, changed });
3032
- meta.sync_events = events.slice(-20);
3033
-
3034
- await writeText(metaPath, JSON.stringify(meta, null, 2) + "\n");
3035
-
3036
- const stampDir = path.join(gitRoot, ".agentdocs", "tmp", "change-sync");
3037
- await ensureDir(stampDir);
3038
- const stampPath = path.join(stampDir, `${nowStampUtc()}-${changeId}.json`);
3039
- const stamp = {
3040
- timestamp: nowTs,
3041
- ws_root: gitRoot,
3042
- change_id: changeId,
3043
- change_dir: changeDir,
3044
- synced_at: nowIso,
3045
- previous_baseline: { label: baselineLabel, at: baselineAt, truth_files: baseline },
3046
- new_baseline: truth,
3047
- changed,
3048
- note: "aiws change sync stamp; does not contain secrets.",
3049
- };
3050
- await writeText(stampPath, JSON.stringify(stamp, null, 2) + "\n");
3051
- await appendMetricsEvent(gitRoot, changeId, "truth_sync", {
3052
- synced_at: nowIso,
3053
- baseline_from: baselineLabel,
3054
- baseline_at: baselineAt,
3055
- changed_files: changed.map((c) => c.file),
3056
- });
3057
-
3058
- console.log(`✓ aiws change sync: ${changeId}`);
3059
- console.log(`meta: ${path.relative(gitRoot, metaPath)}`);
3060
- console.log(`stamp: ${path.relative(gitRoot, stampPath)}`);
3061
- if (changed.length > 0) {
3062
- console.log("");
3063
- console.log("Changed files:");
3064
- for (const c of changed) console.log(`- ${c.file}`);
3065
- } else {
3066
- console.log("");
3067
- console.log("No changes detected vs baseline.");
3068
- }
1930
+ const result = await runChangeSyncCommand(
1931
+ {
1932
+ gitRoot,
1933
+ changeId,
1934
+ },
1935
+ {
1936
+ changeDirAbs,
1937
+ pathExists,
1938
+ runChangeSyncWorkflow,
1939
+ workflowDeps: {
1940
+ appendMetricsEvent,
1941
+ ensureDir,
1942
+ nowIsoUtc,
1943
+ nowStampUtc,
1944
+ nowUnixSeconds,
1945
+ pathExists,
1946
+ readText,
1947
+ snapshotTruthFiles,
1948
+ writeText,
1949
+ },
1950
+ UserError,
1951
+ },
1952
+ );
1953
+ process.stdout.write(result.output);
3069
1954
  }
3070
1955
 
3071
1956
  /**
@@ -3079,70 +1964,33 @@ export async function changeArchiveCommand(options) {
3079
1964
 
3080
1965
  const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "archive" });
3081
1966
  assertValidChangeId(changeId);
3082
-
3083
- const changeDir = changeDirAbs(gitRoot, changeId);
3084
- if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
3085
-
3086
- // Strict validate before archiving.
3087
- await changeValidateCommand({ changeId, strict: true, allowTruthDrift: options.force });
3088
-
3089
- const tasksAbs = path.join(changeDir, "tasks.md");
3090
- if (await pathExists(tasksAbs)) {
3091
- const t = await readText(tasksAbs);
3092
- if (/- \[ \]/.test(t)) {
3093
- if (!options.force) {
3094
- throw new UserError("tasks.md still has unchecked tasks; complete them or pass --force");
3095
- }
3096
- console.error("warn: tasks.md still has unchecked tasks; continuing due to --force");
3097
- }
3098
- }
3099
-
3100
- const archiveRoot = path.join(gitRoot, "changes", "archive");
3101
- await ensureDir(archiveRoot);
3102
-
3103
- const prefix = (options.datePrefix ? String(options.datePrefix) : todayLocal()).trim() || todayLocal();
3104
- let dest = path.join(archiveRoot, `${prefix}-${changeId}`);
3105
- if (await pathExists(dest)) {
3106
- dest = path.join(archiveRoot, `${prefix}-${changeId}-${nowStampUtc()}`);
3107
- }
3108
-
3109
- await appendMetricsEvent(gitRoot, changeId, "archive", { archived_to: path.relative(gitRoot, dest) });
3110
- await fs.rename(changeDir, dest);
3111
- console.log(`✓ aiws change archive: ${changeId}`);
3112
- console.log(`archived_to: ${path.relative(gitRoot, dest)}`);
3113
-
3114
- // Generate handoff.md
3115
- const handoffPath = path.join(dest, "handoff.md");
3116
- const handoffContent = await generateHandoffContent(gitRoot, dest, changeId);
3117
- await writeText(handoffPath, handoffContent);
3118
- console.log(`handoff: ${path.relative(gitRoot, handoffPath)}`);
3119
-
3120
- const metaPath = path.join(dest, ".ws-change.json");
3121
- if (await pathExists(metaPath)) {
3122
- try {
3123
- const meta = JSON.parse(await readText(metaPath));
3124
- const truth = await snapshotTruthFiles(gitRoot);
3125
- meta.archived_at = nowIsoUtc();
3126
- meta.archived_to = dest;
3127
- meta.archived_truth_files = truth;
3128
- await writeText(metaPath, JSON.stringify(meta, null, 2) + "\n");
3129
- console.log(`meta_updated: ${path.relative(gitRoot, metaPath)}`);
3130
- } catch {
3131
- // ignore invalid meta
3132
- }
3133
- }
3134
-
3135
- const stampDir = path.join(gitRoot, ".agentdocs", "tmp", "change-archive");
3136
- await ensureDir(stampDir);
3137
- const stampPath = path.join(stampDir, `${nowStampUtc()}-${changeId}.json`);
3138
- const truth = await snapshotTruthFiles(gitRoot);
3139
- const stamp = {
3140
- timestamp: nowUnixSeconds(),
3141
- ws_root: gitRoot,
3142
- archived_to: dest,
3143
- truth_files: truth,
3144
- note: "aiws change archive stamp; does not contain secrets.",
3145
- };
3146
- await writeText(stampPath, JSON.stringify(stamp, null, 2) + "\n");
3147
- console.log(`stamp: ${path.relative(gitRoot, stampPath)}`);
1967
+ const result = await runChangeArchiveCommand(
1968
+ {
1969
+ gitRoot,
1970
+ changeId,
1971
+ options,
1972
+ },
1973
+ {
1974
+ changeDirAbs,
1975
+ pathExists,
1976
+ runChangeArchiveWorkflow,
1977
+ workflowDeps: {
1978
+ appendMetricsEvent,
1979
+ changeValidateCommand,
1980
+ ensureDir,
1981
+ generateHandoffContent,
1982
+ nowIsoUtc,
1983
+ nowStampUtc,
1984
+ nowUnixSeconds,
1985
+ pathExists,
1986
+ readText,
1987
+ snapshotTruthFiles,
1988
+ todayLocal,
1989
+ writeText,
1990
+ UserError,
1991
+ },
1992
+ UserError,
1993
+ },
1994
+ );
1995
+ process.stdout.write(result.output);
3148
1996
  }