@aipper/aiws 0.0.26 → 0.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@aipper/aiws",
3
- "version": "0.0.26",
3
+ "version": "0.0.27",
4
4
  "description": "AI Workspace CLI (init/update/validate) for Claude Code / OpenCode / Codex.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "aiws": "./bin/aiws.js"
8
8
  },
9
9
  "dependencies": {
10
- "@aipper/aiws-spec": "0.0.26"
10
+ "@aipper/aiws-spec": "0.0.27"
11
11
  },
12
12
  "files": [
13
13
  "bin",
@@ -71,6 +71,13 @@ export async function computeChangeNextAdvice(gitRoot, changeId, deps) {
71
71
  advice.actions.push("真值/合同已变化:先对齐 `AI_PROJECT.md` / `AI_WORKSPACE.md` / `REQUIREMENTS.md` 与 proposal/tasks");
72
72
  advice.actions.push(`确认后同步基线:\`aiws change sync ${changeId}\``);
73
73
  }
74
+ if (status.terminatedReuse) {
75
+ advice.actions.push(status.terminatedReuse.message);
76
+ advice.prohibitions.push(`不要继续在 change/${changeId} 上开发或提交新改动`);
77
+ advice.recommended.push(`优先续跑收尾:\`aiws change finish ${changeId} --push\``);
78
+ advice.recommended.push(`仅在你明确只需要本地归档恢复时,才手工运行 \`aiws change archive ${changeId}\``);
79
+ return advice;
80
+ }
74
81
 
75
82
  const proposalPath = path.join(deps.changeDirAbs(gitRoot, changeId), "proposal.md");
76
83
  const tasksPath = path.join(deps.changeDirAbs(gitRoot, changeId), "tasks.md");
@@ -156,7 +163,9 @@ export function renderChangeStateMarkdown(input) {
156
163
  lines.push("");
157
164
  lines.push("## Phase");
158
165
  lines.push(`- Phase: \`${input.status.phase}\``);
159
- lines.push(`- Tasks: ${input.status.tasks.done}/${input.status.tasks.total} (unchecked=${input.status.tasks.unchecked})`);
166
+ lines.push(
167
+ `- Tasks: ${input.status.tasks.done}/${input.status.tasks.total} (unchecked=${input.status.tasks.unchecked}, optional_open=${input.status.tasks.optionalUnchecked || 0}, n_a=${input.status.tasks.naCount || 0})`,
168
+ );
160
169
  lines.push("");
161
170
  lines.push("## Bindings");
162
171
  if (input.status.reqId) lines.push(`- Req_ID: \`${input.status.reqId}\``);
@@ -293,7 +293,9 @@ export function buildDeliverySummary(options) {
293
293
  lines.push("");
294
294
  lines.push("## Status");
295
295
  lines.push(`- Phase: \`${status.phase}\``);
296
- lines.push(`- Tasks: ${status.tasks.done}/${status.tasks.total} (unchecked=${status.tasks.unchecked})`);
296
+ lines.push(
297
+ `- Tasks: ${status.tasks.done}/${status.tasks.total} (unchecked=${status.tasks.unchecked}, optional_open=${status.tasks.optionalUnchecked || 0}, n_a=${status.tasks.naCount || 0})`,
298
+ );
297
299
  lines.push(`- Truth drift: ${status.driftFiles.length > 0 ? status.driftFiles.join(", ") : "(none)"}`);
298
300
  lines.push("");
299
301
  lines.push("## Collaboration");
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs/promises";
3
+ import { checkboxStats } from "./change-task-stats.js";
3
4
 
4
5
  /**
5
6
  * @param {{
@@ -144,16 +145,17 @@ export async function runChangeSyncWorkflow(input, deps) {
144
145
  */
145
146
  export async function runChangeArchiveWorkflow(input, deps) {
146
147
  const { gitRoot, changeId, changeDir } = input;
147
- await deps.changeValidateCommand({ changeId, strict: true, allowTruthDrift: input.force });
148
+ await deps.changeValidateCommand({ changeId, strict: true, allowTruthDrift: input.force, allowFinishedUnarchived: true });
148
149
 
149
150
  const tasksAbs = path.join(changeDir, "tasks.md");
150
151
  if (await deps.pathExists(tasksAbs)) {
151
152
  const tasksText = await deps.readText(tasksAbs);
152
- if (/- \[ \]/.test(tasksText)) {
153
+ const taskStats = checkboxStats(tasksText);
154
+ if (taskStats.unchecked > 0) {
153
155
  if (!input.force) {
154
- throw new deps.UserError("tasks.md still has unchecked tasks; complete them or pass --force");
156
+ throw new deps.UserError("tasks.md still has unchecked required tasks; complete them or pass --force");
155
157
  }
156
- console.error("warn: tasks.md still has unchecked tasks; continuing due to --force");
158
+ console.error("warn: tasks.md still has unchecked required tasks; continuing due to --force");
157
159
  }
158
160
  }
159
161
 
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { checkboxStats } from "./change-task-stats.js";
2
3
 
3
4
  /**
4
5
  * @param {{
@@ -90,6 +91,12 @@ export async function renderChangeNextOutput(input, deps) {
90
91
 
91
92
  /** @type {string[]} */
92
93
  const lines = [];
94
+ if (status.terminatedReuse) {
95
+ for (const action of advice.actions) lines.push(`- ${action}`);
96
+ for (const recommendation of advice.recommended) lines.push(`- ${recommendation}`);
97
+ for (const prohibition of advice.prohibitions) lines.push(`- ${prohibition}`);
98
+ return lines.join("\n") + "\n";
99
+ }
93
100
  if (advice.actions.length > 0) {
94
101
  lines.push(`- ${deps.planVerifyHint(changeId)}`);
95
102
  for (const action of advice.actions) lines.push(`- ${action}`);
@@ -120,13 +127,3 @@ export async function renderChangeNextOutput(input, deps) {
120
127
  }
121
128
  return lines.join("\n") + "\n";
122
129
  }
123
-
124
- /**
125
- * @param {string} text
126
- */
127
- function checkboxStats(text) {
128
- const total = (text.match(/^- \[[ xX]\]/gm) || []).length;
129
- const done = (text.match(/^- \[[xX]\]/gm) || []).length;
130
- const unchecked = (text.match(/^- \[ \]/gm) || []).length;
131
- return { total, done, unchecked, hasCheckboxes: total > 0 };
132
- }
@@ -20,14 +20,6 @@ export async function checkWorktreePrereqs(gitRoot, deps) {
20
20
  return { ok: true };
21
21
  }
22
22
 
23
- /**
24
- * @param {{ stdout: string, stderr: string }} res
25
- */
26
- export function outputMentionsRecurseSubmodules(res) {
27
- const out = `${res.stderr || ""}\n${res.stdout || ""}`;
28
- return out.includes("recurse-submodules");
29
- }
30
-
31
23
  /**
32
24
  * @param {string} changeDir
33
25
  * @param {string} baseBranch
@@ -85,34 +77,24 @@ export function resolveDefaultWorktreeDir(gitRoot, worktreeDir, changeId) {
85
77
  */
86
78
  export async function switchOrCreateStartBranch(gitRoot, options, deps) {
87
79
  if (options.hasBranch) {
88
- let sw = await deps.runCommand("git", ["switch", ...(options.hasGitmodules ? ["--recurse-submodules"] : []), options.branch], { cwd: gitRoot });
89
- if (sw.code !== 0 && options.hasGitmodules && outputMentionsRecurseSubmodules(sw)) {
90
- console.error("warn: git switch does not support --recurse-submodules; switching without it (submodules may be out of sync).");
91
- sw = await deps.runCommand("git", ["switch", options.branch], { cwd: gitRoot });
80
+ if (options.hasGitmodules) {
81
+ console.error("warn: .gitmodules detected; switching only the superproject branch (submodules stay on their current/pin branches).");
92
82
  }
83
+ let sw = await deps.runCommand("git", ["switch", options.branch], { cwd: gitRoot });
93
84
  if (sw.code === 0) return;
94
85
 
95
- let co = await deps.runCommand("git", ["checkout", ...(options.hasGitmodules ? ["--recurse-submodules"] : []), options.branch], { cwd: gitRoot });
96
- if (co.code !== 0 && options.hasGitmodules && outputMentionsRecurseSubmodules(co)) {
97
- console.error("warn: git checkout does not support --recurse-submodules; checking out without it (submodules may be out of sync).");
98
- co = await deps.runCommand("git", ["checkout", options.branch], { cwd: gitRoot });
99
- }
86
+ let co = await deps.runCommand("git", ["checkout", options.branch], { cwd: gitRoot });
100
87
  if (co.code !== 0) throw new deps.UserError("Failed to switch branch.", { details: co.stderr || co.stdout });
101
88
  return;
102
89
  }
103
90
 
104
- let sw = await deps.runCommand("git", ["switch", ...(options.hasGitmodules ? ["--recurse-submodules"] : []), "-c", options.branch], { cwd: gitRoot });
105
- if (sw.code !== 0 && options.hasGitmodules && outputMentionsRecurseSubmodules(sw)) {
106
- console.error("warn: git switch does not support --recurse-submodules; switching without it (submodules may be out of sync).");
107
- sw = await deps.runCommand("git", ["switch", "-c", options.branch], { cwd: gitRoot });
91
+ if (options.hasGitmodules) {
92
+ console.error("warn: .gitmodules detected; creating and switching only the superproject branch (submodules stay on their current/pin branches).");
108
93
  }
94
+ let sw = await deps.runCommand("git", ["switch", "-c", options.branch], { cwd: gitRoot });
109
95
  if (sw.code === 0) return;
110
96
 
111
- let co = await deps.runCommand("git", ["checkout", ...(options.hasGitmodules ? ["--recurse-submodules"] : []), "-b", options.branch], { cwd: gitRoot });
112
- if (co.code !== 0 && options.hasGitmodules && outputMentionsRecurseSubmodules(co)) {
113
- console.error("warn: git checkout does not support --recurse-submodules; checking out without it (submodules may be out of sync).");
114
- co = await deps.runCommand("git", ["checkout", "-b", options.branch], { cwd: gitRoot });
115
- }
97
+ let co = await deps.runCommand("git", ["checkout", "-b", options.branch], { cwd: gitRoot });
116
98
  if (co.code !== 0) throw new deps.UserError("Failed to create branch.", { details: co.stderr || co.stdout });
117
99
  }
118
100
 
@@ -32,7 +32,9 @@ export async function renderChangeStatusOutput(input, deps) {
32
32
  lines.push(`meta: ${status.metaState}`);
33
33
  if (status.reqId) lines.push(`Req_ID: ${status.reqId}`);
34
34
  if (status.probId) lines.push(`Problem_ID: ${status.probId}`);
35
- lines.push(`tasks: ${status.tasks.done}/${status.tasks.total} (unchecked=${status.tasks.unchecked})`);
35
+ lines.push(
36
+ `tasks: ${status.tasks.done}/${status.tasks.total} (unchecked=${status.tasks.unchecked}, optional_open=${status.tasks.optionalUnchecked || 0}, n_a=${status.tasks.naCount || 0})`,
37
+ );
36
38
  if (status.reviewSignals) {
37
39
  lines.push(`review_signal: effective=${status.reviewSignals.effectiveCount} source=${status.reviewSignals.source}`);
38
40
  }
@@ -89,6 +91,12 @@ export async function renderChangeStatusOutput(input, deps) {
89
91
 
90
92
  lines.push("");
91
93
  lines.push("Next (recommended):");
94
+ if (status.terminatedReuse) {
95
+ lines.push(`- ${status.terminatedReuse.message}`);
96
+ lines.push(`- rerun \`aiws change finish ${status.changeId} --push\` to resume the unfinished finish/archive flow`);
97
+ lines.push(`- do not continue development or commit on \`change/${status.changeId}\``);
98
+ return lines.join("\n") + "\n";
99
+ }
92
100
  lines.push(`- ${deps.planVerifyHint(status.changeId)}`);
93
101
  if (status.blockersStrict.length > 0) {
94
102
  lines.push("- 先修复 Blockers (strict) 后复跑质量门,再进入编码:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`");
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Parse checkbox tasks with AIWS-specific control markers.
3
+ *
4
+ * Supported markers:
5
+ * - `[optional]`: unchecked optional tasks do not block archive/finish.
6
+ * - `[n/a: ...]` / `[na: ...]` / `[not-applicable: ...]`: task is treated as closed.
7
+ *
8
+ * Returned `total/done/unchecked` are the effective gate counts. Optional open
9
+ * tasks are reported separately via `optionalUnchecked`.
10
+ *
11
+ * @param {string} text
12
+ */
13
+ export function checkboxStats(text) {
14
+ const lines = String(text || "").split(/\r?\n/);
15
+ let total = 0;
16
+ let done = 0;
17
+ let unchecked = 0;
18
+ let totalAll = 0;
19
+ let doneAll = 0;
20
+ let uncheckedAll = 0;
21
+ let optionalTotal = 0;
22
+ let optionalUnchecked = 0;
23
+ let naCount = 0;
24
+
25
+ for (const rawLine of lines) {
26
+ const match = rawLine.match(/^\s*-\s+\[([ xX])\]\s+(.*)$/);
27
+ if (!match) continue;
28
+ totalAll += 1;
29
+ const checked = String(match[1] || "").toLowerCase() === "x";
30
+ const body = String(match[2] || "");
31
+ const isOptional = /\[(?:optional|Optional)\]/.test(body);
32
+ const isNa = /\[(?:n\/a|na|not-applicable)\s*:[^\]]+\]/i.test(body);
33
+
34
+ if (checked) doneAll += 1;
35
+ else uncheckedAll += 1;
36
+
37
+ if (isOptional) {
38
+ optionalTotal += 1;
39
+ if (!checked && !isNa) optionalUnchecked += 1;
40
+ continue;
41
+ }
42
+
43
+ total += 1;
44
+ if (checked || isNa) {
45
+ done += 1;
46
+ if (isNa) naCount += 1;
47
+ } else {
48
+ unchecked += 1;
49
+ }
50
+ }
51
+
52
+ return {
53
+ total,
54
+ done,
55
+ unchecked,
56
+ hasCheckboxes: totalAll > 0,
57
+ totalAll,
58
+ doneAll,
59
+ uncheckedAll,
60
+ optionalTotal,
61
+ optionalUnchecked,
62
+ naCount,
63
+ };
64
+ }
@@ -21,6 +21,7 @@ import { runChangeArchiveWorkflow, runChangeSyncWorkflow } from "./change-lifecy
21
21
  import { renderMetricsSummaryOutput } from "./change-metrics-command.js";
22
22
  import { renderChangeNextOutput } from "./change-next-command.js";
23
23
  import { runChangeStateCommand } from "./change-state-command.js";
24
+ import { checkboxStats } from "./change-task-stats.js";
24
25
  import { runChangeValidateCommand } from "./change-validate-entry.js";
25
26
  import {
26
27
  computeChangeNextAdvice as computeChangeNextAdviceModel,
@@ -388,6 +389,74 @@ async function findArchivedChange(archiveDir, changeId) {
388
389
  return null;
389
390
  }
390
391
 
392
+ /**
393
+ * @param {string} gitRoot
394
+ * @param {string} changeId
395
+ * @returns {Promise<null | { kind: "archived" | "finished_unarchived", archivedChangeDir?: string, archivedHandoffRel?: string, lifecycle?: any }>}
396
+ */
397
+ async function detectTerminatedChange(gitRoot, changeId) {
398
+ const changeDir = changeDirAbs(gitRoot, changeId);
399
+ const archiveRoot = path.join(gitRoot, "changes", "archive");
400
+ const activeExists = await pathExists(changeDir);
401
+ if (!activeExists) {
402
+ const archivedChangeDir = await findArchivedChange(archiveRoot, changeId);
403
+ if (!archivedChangeDir) return null;
404
+ const archivedHandoffPath = path.join(archivedChangeDir, "handoff.md");
405
+ return {
406
+ kind: "archived",
407
+ archivedChangeDir,
408
+ archivedHandoffRel: (await pathExists(archivedHandoffPath)) ? path.relative(gitRoot, archivedHandoffPath) : "",
409
+ };
410
+ }
411
+
412
+ const lifecycle = summarizeLifecycleSignals(await readChangeMetrics(changeDir, { pathExists, readText }));
413
+ if (lifecycle.finishState !== "done") return null;
414
+ return { kind: "finished_unarchived", lifecycle };
415
+ }
416
+
417
+ /**
418
+ * @param {string} gitRoot
419
+ * @param {string} changeId
420
+ * @param {Awaited<ReturnType<typeof detectTerminatedChange>>} terminated
421
+ * @param {string} action
422
+ */
423
+ function terminatedChangeError(gitRoot, changeId, terminated, action) {
424
+ if (terminated?.kind === "archived") {
425
+ return new UserError(`Change ${changeId} is already archived.`, {
426
+ details:
427
+ `change: ${changeId}\n` +
428
+ `archived: ${path.relative(gitRoot, terminated.archivedChangeDir || "")}\n` +
429
+ (terminated.archivedHandoffRel ? `handoff: ${terminated.archivedHandoffRel}\n` : "") +
430
+ `next: create a new follow-up change instead of reusing change/${changeId}` +
431
+ (action ? `\nblocked: ${action}` : ""),
432
+ });
433
+ }
434
+ return new UserError(`Change ${changeId} has unfinished finish closeout; resume finish before any follow-up work.`, {
435
+ details:
436
+ `change: ${changeId}\n` +
437
+ `branch: change/${changeId}\n` +
438
+ `next: rerun \`aiws change finish ${changeId} --push\` to complete push/archive closeout` +
439
+ `\nfallback: if you intentionally only need local archive recovery, run \`aiws change archive ${changeId}\`` +
440
+ (action ? `\nblocked: ${action}` : ""),
441
+ });
442
+ }
443
+
444
+ /**
445
+ * @param {string} command
446
+ * @param {Awaited<ReturnType<typeof detectTerminatedChange>>} terminated
447
+ * @param {{ allowFinishedUnarchived?: boolean }} [options]
448
+ */
449
+ function isTerminatedAllowed(command, terminated, options = {}) {
450
+ if (!terminated) return true;
451
+ if (terminated.kind === "finished_unarchived") {
452
+ if (command === "archive") return true;
453
+ if (command === "finish") return true;
454
+ if (command === "status" || command === "next") return true;
455
+ if (options.allowFinishedUnarchived === true) return true;
456
+ }
457
+ return false;
458
+ }
459
+
391
460
  /**
392
461
  * Extract a brief summary from handoff.md content.
393
462
  * @param {string} text
@@ -663,7 +732,21 @@ export async function computeChangeStatus(gitRoot, changeId) {
663
732
  const evidencePath = proposal.state === "ok" ? extractId("Evidence_Path", proposal.text) || extractId("Evidence_Path(s)", proposal.text) : "";
664
733
  const evidencePaths = splitDeclaredValues(evidencePath);
665
734
  const evidence = await analyzeEvidencePaths(gitRoot, changeId, evidencePaths);
666
- const taskProgress = tasks.state === "ok" ? checkboxStats(tasks.text) : { total: 0, done: 0, unchecked: 0, hasCheckboxes: false };
735
+ const taskProgress =
736
+ tasks.state === "ok"
737
+ ? checkboxStats(tasks.text)
738
+ : {
739
+ total: 0,
740
+ done: 0,
741
+ unchecked: 0,
742
+ hasCheckboxes: false,
743
+ totalAll: 0,
744
+ doneAll: 0,
745
+ uncheckedAll: 0,
746
+ optionalTotal: 0,
747
+ optionalUnchecked: 0,
748
+ naCount: 0,
749
+ };
667
750
 
668
751
  const curTruth = await snapshotTruthShaOnly(gitRoot);
669
752
  const { baselineLabel, baselineAt, driftFiles } = meta ? truthDrift(curTruth, meta) : { baselineLabel: "-", baselineAt: "", driftFiles: [] };
@@ -718,10 +801,25 @@ export async function computeChangeStatus(gitRoot, changeId) {
718
801
  if (!taskProgress.hasCheckboxes) blockersStrict.push("tasks.md has no checkbox tasks ('- [ ]' or '- [x]')");
719
802
  if (driftFiles.length > 0) blockersStrict.push(`truth drift vs ${baselineLabel} baseline (run \`aiws change sync ${changeId}\`)`);
720
803
 
804
+ const lifecycle = summarizeLifecycleSignals(metrics);
805
+ const terminatedReuse =
806
+ lifecycle.finishState === "done"
807
+ ? {
808
+ kind: "finished_unarchived",
809
+ message: `change/${changeId} has unfinished finish closeout; rerun \`aiws change finish ${changeId} --push\` to complete archive/push`,
810
+ }
811
+ : null;
812
+ if (terminatedReuse) {
813
+ blockersStrict.push(terminatedReuse.message);
814
+ }
815
+
721
816
  blockersArchive.push(...blockersStrict);
722
- if (taskProgress.unchecked > 0) blockersArchive.push(`tasks.md still has unchecked tasks (${taskProgress.unchecked} items)`);
817
+ if (taskProgress.unchecked > 0) blockersArchive.push(`tasks.md still has unchecked required tasks (${taskProgress.unchecked} items)`);
723
818
  const phase = inferChangePhase(blockersStrict, taskProgress);
724
- const lifecycle = summarizeLifecycleSignals(metrics);
819
+ if (terminatedReuse) {
820
+ lifecycle.finishState = "resume_required";
821
+ lifecycle.finishStateReason = "archive_pending";
822
+ }
725
823
  const scopeGate = computeScopeGate(lifecycle);
726
824
 
727
825
  const baseStatus = {
@@ -754,6 +852,7 @@ export async function computeChangeStatus(gitRoot, changeId) {
754
852
  driftFiles,
755
853
  blockersStrict,
756
854
  blockersArchive,
855
+ terminatedReuse,
757
856
  hints: {
758
857
  planVerify: planVerifyHint(changeId),
759
858
  dev: "质量门通过后再进入编码:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`",
@@ -787,16 +886,6 @@ export async function validateChangeArtifacts(gitRoot, changeId, options) {
787
886
  });
788
887
  }
789
888
 
790
- /**
791
- * @param {string} text
792
- */
793
- function checkboxStats(text) {
794
- const total = (text.match(/^- \[[ xX]\]/gm) || []).length;
795
- const done = (text.match(/^- \[[xX]\]/gm) || []).length;
796
- const unchecked = (text.match(/^- \[ \]/gm) || []).length;
797
- return { total, done, unchecked, hasCheckboxes: total > 0 };
798
- }
799
-
800
889
  /**
801
890
  * @param {string} text
802
891
  */
@@ -941,13 +1030,17 @@ async function runPython(gitRoot, args) {
941
1030
  /**
942
1031
  * @param {string} gitRoot
943
1032
  * @param {string | undefined} changeId
944
- * @param {{ command: string }} options
1033
+ * @param {{ command: string, allowFinishedUnarchived?: boolean }} options
945
1034
  */
946
1035
  async function resolveChangeId(gitRoot, changeId, options) {
947
- if (changeId) return changeId;
948
- const branch = await currentBranch(gitRoot);
949
- const inferred = inferChangeIdFromBranch(branch);
950
- if (inferred) return inferred;
1036
+ const resolved = changeId || inferChangeIdFromBranch(await currentBranch(gitRoot));
1037
+ if (resolved) {
1038
+ const terminated = await detectTerminatedChange(gitRoot, resolved);
1039
+ if (!isTerminatedAllowed(options.command, terminated, { allowFinishedUnarchived: options.allowFinishedUnarchived === true })) {
1040
+ throw terminatedChangeError(gitRoot, resolved, terminated, `aiws change ${options.command}`);
1041
+ }
1042
+ return resolved;
1043
+ }
951
1044
  throw new UserError(`usage: aiws change ${options.command} <change-id> (or switch to branch change/<change-id>)`);
952
1045
  }
953
1046
 
@@ -1117,6 +1210,8 @@ async function changeNewAtGitRoot(gitRoot, options) {
1117
1210
 
1118
1211
  const changeId = String(options.changeId || "").trim();
1119
1212
  assertValidChangeId(changeId);
1213
+ const terminated = await detectTerminatedChange(gitRoot, changeId);
1214
+ if (terminated) throw terminatedChangeError(gitRoot, changeId, terminated, "aiws change new");
1120
1215
 
1121
1216
  const title = (options.title ? String(options.title) : changeId).trim() || changeId;
1122
1217
  const createdAt = nowIsoUtc();
@@ -1202,6 +1297,8 @@ export async function changeStartCommand(options) {
1202
1297
 
1203
1298
  const changeId = String(options.changeId || "").trim();
1204
1299
  assertValidChangeId(changeId);
1300
+ const terminated = await detectTerminatedChange(gitRoot, changeId);
1301
+ if (terminated) throw terminatedChangeError(gitRoot, changeId, terminated, "aiws change start");
1205
1302
 
1206
1303
  const startFromBranch = await currentBranch(gitRoot);
1207
1304
  const branch = `change/${changeId}`;
@@ -1472,27 +1569,15 @@ export async function changeFinishCommand(options) {
1472
1569
 
1473
1570
  const resolvedChangeId = await resolveChangeId(gitRoot, options.changeId, { command: "finish" });
1474
1571
  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.", {
1572
+ const terminated = await detectTerminatedChange(gitRoot, resolvedChangeId);
1573
+ if (terminated?.kind === "archived") throw terminatedChangeError(gitRoot, resolvedChangeId, terminated, "aiws change finish");
1574
+ const resumeFinishedUnarchived = terminated?.kind === "finished_unarchived";
1575
+ if (resumeFinishedUnarchived && options.push !== true) {
1576
+ throw new UserError("Change finish closeout is pending; rerun with --push.", {
1484
1577
  details:
1485
1578
  `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\``,
1579
+ `next: rerun \`aiws change finish ${resolvedChangeId} --push\` to complete archive/push closeout\n` +
1580
+ `fallback: if you intentionally only need local archive recovery, run \`aiws change archive ${resolvedChangeId}\``,
1496
1581
  });
1497
1582
  }
1498
1583
  const { changeId, changeBranch, into, cur, changeWt } = await resolveFinishContext(gitRoot, { ...options, changeId: resolvedChangeId }, {
@@ -1556,6 +1641,9 @@ export async function changeFinishCommand(options) {
1556
1641
  }
1557
1642
 
1558
1643
  await appendMetricsEvent(gitRoot, changeId, "finish_local", finishBasePayload);
1644
+ if (resumeFinishedUnarchived) {
1645
+ console.log(`resume: continuing previously finished change ${changeId} to complete remaining finish steps`);
1646
+ }
1559
1647
 
1560
1648
  let cleanupNote = "not_requested";
1561
1649
  let finishCompleted = false;
@@ -1891,13 +1979,16 @@ export async function changeEvidenceCommand(options) {
1891
1979
  /**
1892
1980
  * aiws change validate
1893
1981
  *
1894
- * @param {{ changeId?: string, strict: boolean, allowTruthDrift: boolean, checkEvidence: boolean, checkScope: boolean }} options
1982
+ * @param {{ changeId?: string, strict: boolean, allowTruthDrift: boolean, checkEvidence: boolean, checkScope: boolean, allowFinishedUnarchived?: boolean }} options
1895
1983
  */
1896
1984
  export async function changeValidateCommand(options) {
1897
1985
  const gitRoot = await resolveGitRoot(process.cwd());
1898
1986
  await ensureTruthFiles(gitRoot);
1899
1987
 
1900
- const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "validate" });
1988
+ const changeId = await resolveChangeId(gitRoot, options.changeId, {
1989
+ command: "validate",
1990
+ allowFinishedUnarchived: options.allowFinishedUnarchived === true,
1991
+ });
1901
1992
  assertValidChangeId(changeId);
1902
1993
  const result = await runChangeValidateCommand(
1903
1994
  {
package/src/governance.js CHANGED
@@ -26,7 +26,9 @@ function collectGovernanceSignals(status, governance) {
26
26
  const validateStampReady = reviewGates?.validateStamp?.ready === true;
27
27
 
28
28
  return {
29
+ change_id: String(status?.changeId || ""),
29
30
  blockers_strict_count: Array.isArray(status?.blockersStrict) ? status.blockersStrict.length : 0,
31
+ finish_resume_required: status?.terminatedReuse?.kind === "finished_unarchived",
30
32
  tasks_unchecked: Number(status?.tasks?.unchecked || 0),
31
33
  review_effective_count: reviewEffectiveCount,
32
34
  evidence_persistent_count: Number(status?.evidence?.counts?.persistent || 0),