@aipper/aiws 0.0.26 → 0.0.28
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 +2 -2
- package/src/commands/change-advice.js +10 -1
- package/src/commands/change-evidence.js +3 -1
- package/src/commands/change-lifecycle.js +6 -4
- package/src/commands/change-next-command.js +7 -10
- package/src/commands/change-start.js +8 -26
- package/src/commands/change-status-command.js +9 -1
- package/src/commands/change-task-stats.js +64 -0
- package/src/commands/change.js +130 -39
- package/src/governance.js +2 -0
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aipper/aiws",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.28",
|
|
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.
|
|
10
|
+
"@aipper/aiws-spec": "0.0.28"
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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",
|
|
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
|
-
|
|
105
|
-
|
|
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",
|
|
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(
|
|
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
|
+
}
|
package/src/commands/change.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
|
1476
|
-
|
|
1477
|
-
const
|
|
1478
|
-
|
|
1479
|
-
|
|
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
|
-
`
|
|
1487
|
-
|
|
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, {
|
|
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),
|