@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.
- package/README.md +89 -33
- package/package.json +3 -3
- package/src/cli.js +40 -2
- package/src/codex-skills.js +4 -4
- package/src/commands/change-advice.js +191 -0
- package/src/commands/change-evidence-command.js +242 -0
- package/src/commands/change-evidence-entry.js +40 -0
- package/src/commands/change-evidence.js +338 -0
- package/src/commands/change-finish.js +484 -0
- package/src/commands/change-git-status.js +53 -0
- package/src/commands/change-lifecycle-entry.js +60 -0
- package/src/commands/change-lifecycle.js +234 -0
- package/src/commands/change-metrics-command.js +110 -0
- package/src/commands/change-next-command.js +132 -0
- package/src/commands/change-review-gates.js +315 -0
- package/src/commands/change-scope-gate.js +34 -0
- package/src/commands/change-start.js +138 -0
- package/src/commands/change-state-command.js +40 -0
- package/src/commands/change-status-command.js +126 -0
- package/src/commands/change-status-context.js +202 -0
- package/src/commands/change-validate-entry.js +45 -0
- package/src/commands/change-validate.js +127 -0
- package/src/commands/change.js +540 -1692
- package/src/commands/dashboard.js +38 -12
- package/src/commands/init.js +1 -1
- package/src/commands/opencode-status.js +61 -0
- package/src/commands/update.js +5 -1
- package/src/dashboard/app.js +205 -2
- package/src/dashboard/index.html +5 -2
- package/src/dashboard/style.css +86 -0
- package/src/governance.js +157 -0
- package/src/json-schema-lite.js +164 -0
- package/src/opencode-env.js +76 -0
- package/src/spec.js +131 -0
- package/src/template.js +42 -2
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { pathExists } from "../fs.js";
|
|
4
|
+
import { loadWorkflowReviewGates } from "../spec.js";
|
|
5
|
+
|
|
6
|
+
export const TMP_REVIEW_FALLBACKS = [
|
|
7
|
+
{ fileName: "codex-review.md", persistentKind: "review" },
|
|
8
|
+
{ fileName: "spec-review.md", persistentKind: "review" },
|
|
9
|
+
{ fileName: "quality-review.md", persistentKind: "review" },
|
|
10
|
+
{ fileName: "verify-before-complete.md", persistentKind: "evidence" },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} input
|
|
15
|
+
*/
|
|
16
|
+
function normalizeSlashes(input) {
|
|
17
|
+
return String(input || "").replaceAll("\\", "/");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} gitRoot
|
|
22
|
+
* @param {string} absPath
|
|
23
|
+
*/
|
|
24
|
+
function relFromRoot(gitRoot, absPath) {
|
|
25
|
+
return normalizeSlashes(path.relative(gitRoot, absPath));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} gitRoot
|
|
30
|
+
* @param {string} fileName
|
|
31
|
+
*/
|
|
32
|
+
export function tmpReviewFallbackPath(gitRoot, fileName) {
|
|
33
|
+
return path.join(gitRoot, ".agentdocs", "tmp", "review", fileName);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} gitRoot
|
|
38
|
+
* @param {string} changeId
|
|
39
|
+
* @param {string} fileName
|
|
40
|
+
*/
|
|
41
|
+
function persistentReviewPath(gitRoot, changeId, fileName) {
|
|
42
|
+
return path.join(gitRoot, "changes", changeId, "review", fileName);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} gitRoot
|
|
47
|
+
* @param {string} changeId
|
|
48
|
+
* @param {string} fileName
|
|
49
|
+
*/
|
|
50
|
+
function persistentEvidencePath(gitRoot, changeId, fileName) {
|
|
51
|
+
return path.join(gitRoot, "changes", changeId, "evidence", fileName);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} gitRoot
|
|
56
|
+
* @param {string} changeId
|
|
57
|
+
* @param {string} gateId
|
|
58
|
+
*/
|
|
59
|
+
function reviewGateArtifactPaths(gitRoot, changeId, gateId) {
|
|
60
|
+
switch (gateId) {
|
|
61
|
+
case "spec_review":
|
|
62
|
+
return {
|
|
63
|
+
primaryAbs: persistentReviewPath(gitRoot, changeId, "spec-review.md"),
|
|
64
|
+
fallbackAbs: tmpReviewFallbackPath(gitRoot, "spec-review.md"),
|
|
65
|
+
};
|
|
66
|
+
case "quality_review":
|
|
67
|
+
return {
|
|
68
|
+
primaryAbs: persistentReviewPath(gitRoot, changeId, "quality-review.md"),
|
|
69
|
+
fallbackAbs: tmpReviewFallbackPath(gitRoot, "quality-review.md"),
|
|
70
|
+
};
|
|
71
|
+
case "verify_before_complete":
|
|
72
|
+
return {
|
|
73
|
+
primaryAbs: persistentEvidencePath(gitRoot, changeId, "verify-before-complete.md"),
|
|
74
|
+
fallbackAbs: tmpReviewFallbackPath(gitRoot, "verify-before-complete.md"),
|
|
75
|
+
};
|
|
76
|
+
default:
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} absDir
|
|
83
|
+
* @param {(fileName: string) => boolean} predicate
|
|
84
|
+
*/
|
|
85
|
+
async function findLatestFileByMtime(absDir, predicate) {
|
|
86
|
+
/** @type {Array<{ abs: string, mtimeMs: number }>} */
|
|
87
|
+
const matches = [];
|
|
88
|
+
if (!(await pathExists(absDir))) return "";
|
|
89
|
+
try {
|
|
90
|
+
const entries = await fs.readdir(absDir, { withFileTypes: true });
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (!entry.isFile()) continue;
|
|
93
|
+
if (!predicate(entry.name)) continue;
|
|
94
|
+
const abs = path.join(absDir, entry.name);
|
|
95
|
+
const st = await fs.stat(abs);
|
|
96
|
+
matches.push({ abs, mtimeMs: st.mtimeMs });
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
matches.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
102
|
+
return matches[0]?.abs || "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {string} gitRoot
|
|
107
|
+
*/
|
|
108
|
+
async function listActiveChangeIds(gitRoot) {
|
|
109
|
+
const changesRoot = path.join(gitRoot, "changes");
|
|
110
|
+
try {
|
|
111
|
+
const entries = await fs.readdir(changesRoot, { withFileTypes: true });
|
|
112
|
+
return entries
|
|
113
|
+
.filter((entry) => entry.isDirectory())
|
|
114
|
+
.map((entry) => entry.name)
|
|
115
|
+
.filter((name) => name && !name.startsWith(".") && name !== "archive" && name !== "templates")
|
|
116
|
+
.sort();
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string} gitRoot
|
|
124
|
+
* @param {string} changeId
|
|
125
|
+
* @param {{ branchMatchesChange?: boolean }} [context]
|
|
126
|
+
*/
|
|
127
|
+
async function canAttributeTmpReviewFallback(gitRoot, changeId, context) {
|
|
128
|
+
if (context?.branchMatchesChange === true) return true;
|
|
129
|
+
const activeChangeIds = await listActiveChangeIds(gitRoot);
|
|
130
|
+
return activeChangeIds.length === 1 && activeChangeIds[0] === changeId;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {string} gitRoot
|
|
135
|
+
* @param {string} changeId
|
|
136
|
+
* @param {{ branchMatchesChange?: boolean }} [context]
|
|
137
|
+
* @param {{ counts?: { review?: number } }} [collaboration]
|
|
138
|
+
*/
|
|
139
|
+
export async function collectReviewSignals(gitRoot, changeId, context, collaboration) {
|
|
140
|
+
const persistentCount = Number(collaboration?.counts?.review || 0);
|
|
141
|
+
const tmpFallbackPaths = [];
|
|
142
|
+
const canAttributeTmpFallback = await canAttributeTmpReviewFallback(gitRoot, changeId, context);
|
|
143
|
+
|
|
144
|
+
for (const { fileName, persistentKind } of TMP_REVIEW_FALLBACKS) {
|
|
145
|
+
if (persistentKind !== "review") continue;
|
|
146
|
+
const tmpReviewPath = tmpReviewFallbackPath(gitRoot, fileName);
|
|
147
|
+
if (!(await pathExists(tmpReviewPath))) continue;
|
|
148
|
+
const persistentPath = persistentReviewPath(gitRoot, changeId, fileName);
|
|
149
|
+
if (await pathExists(persistentPath)) continue;
|
|
150
|
+
if (!canAttributeTmpFallback) continue;
|
|
151
|
+
tmpFallbackPaths.push(relFromRoot(gitRoot, tmpReviewPath));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const tmpFallbackExists = tmpFallbackPaths.length > 0;
|
|
155
|
+
const effectiveCount = persistentCount + tmpFallbackPaths.length;
|
|
156
|
+
let source = "none";
|
|
157
|
+
if (persistentCount > 0 && tmpFallbackExists) source = "persistent+tmp_fallback";
|
|
158
|
+
else if (persistentCount > 0) source = "persistent";
|
|
159
|
+
else if (tmpFallbackExists) source = "tmp_fallback";
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
persistentCount,
|
|
163
|
+
tmpFallbackExists,
|
|
164
|
+
tmpFallbackCount: tmpFallbackPaths.length,
|
|
165
|
+
tmpFallbackPath: tmpFallbackPaths[0] || "",
|
|
166
|
+
tmpFallbackPaths,
|
|
167
|
+
effectiveCount,
|
|
168
|
+
source,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @param {string} gitRoot
|
|
174
|
+
* @param {string} changeId
|
|
175
|
+
* @param {{ branchMatchesChange?: boolean }} [context]
|
|
176
|
+
*/
|
|
177
|
+
export async function collectReviewGates(gitRoot, changeId, context) {
|
|
178
|
+
const spec = await loadWorkflowReviewGates();
|
|
179
|
+
const canAttributeTmpFallback = await canAttributeTmpReviewFallback(gitRoot, changeId, context);
|
|
180
|
+
|
|
181
|
+
/** @type {Array<any>} */
|
|
182
|
+
const gates = [];
|
|
183
|
+
for (const gate of spec.gates) {
|
|
184
|
+
const paths = reviewGateArtifactPaths(gitRoot, changeId, gate.id);
|
|
185
|
+
if (!paths) continue;
|
|
186
|
+
const primaryExists = await pathExists(paths.primaryAbs);
|
|
187
|
+
const fallbackExists = await pathExists(paths.fallbackAbs);
|
|
188
|
+
const primaryPath = relFromRoot(gitRoot, paths.primaryAbs);
|
|
189
|
+
const fallbackPath = relFromRoot(gitRoot, paths.fallbackAbs);
|
|
190
|
+
|
|
191
|
+
let ready = false;
|
|
192
|
+
let status = "missing";
|
|
193
|
+
let foundPath = "";
|
|
194
|
+
if (primaryExists) {
|
|
195
|
+
ready = true;
|
|
196
|
+
status = "persistent";
|
|
197
|
+
foundPath = primaryPath;
|
|
198
|
+
} else if (fallbackExists && canAttributeTmpFallback) {
|
|
199
|
+
ready = true;
|
|
200
|
+
status = "tmp_fallback";
|
|
201
|
+
foundPath = fallbackPath;
|
|
202
|
+
} else if (fallbackExists) {
|
|
203
|
+
ready = false;
|
|
204
|
+
status = "tmp_unattributed";
|
|
205
|
+
foundPath = fallbackPath;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
gates.push({
|
|
209
|
+
id: gate.id,
|
|
210
|
+
name: gate.name,
|
|
211
|
+
stage: gate.stage,
|
|
212
|
+
focus: gate.focus,
|
|
213
|
+
next: gate.next,
|
|
214
|
+
ready,
|
|
215
|
+
status,
|
|
216
|
+
primaryPath,
|
|
217
|
+
fallbackPath,
|
|
218
|
+
foundPath,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const specReview = gates.find((gate) => gate.id === "spec_review") || null;
|
|
223
|
+
const qualityReview = gates.find((gate) => gate.id === "quality_review") || null;
|
|
224
|
+
const verifyBeforeComplete = gates.find((gate) => gate.id === "verify_before_complete") || null;
|
|
225
|
+
const validateStampDir = path.join(gitRoot, ".agentdocs", "tmp", "aiws-validate");
|
|
226
|
+
const latestValidateStamp = await findLatestFileByMtime(validateStampDir, (name) => name.endsWith(".json"));
|
|
227
|
+
const validateStamp = {
|
|
228
|
+
id: "validate_stamp",
|
|
229
|
+
name: "validate-stamp",
|
|
230
|
+
ready: Boolean(latestValidateStamp),
|
|
231
|
+
status: latestValidateStamp ? "latest" : "missing",
|
|
232
|
+
dir: relFromRoot(gitRoot, validateStampDir),
|
|
233
|
+
foundPath: latestValidateStamp ? relFromRoot(gitRoot, latestValidateStamp) : "",
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const dualReviewMissingIds = [specReview, qualityReview].filter((gate) => !(gate && gate.ready)).map((gate) => gate?.id || "");
|
|
237
|
+
const finishGateMissingIds = [];
|
|
238
|
+
if (!specReview?.ready) finishGateMissingIds.push("spec_review");
|
|
239
|
+
if (!qualityReview?.ready) finishGateMissingIds.push("quality_review");
|
|
240
|
+
if (!validateStamp.ready) finishGateMissingIds.push("validate_stamp");
|
|
241
|
+
if (!verifyBeforeComplete?.ready) finishGateMissingIds.push("verify_before_complete");
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
source: spec.source,
|
|
245
|
+
specVersion: spec.specVersion,
|
|
246
|
+
version: spec.version,
|
|
247
|
+
gates,
|
|
248
|
+
specReview,
|
|
249
|
+
qualityReview,
|
|
250
|
+
verifyBeforeComplete,
|
|
251
|
+
validateStamp,
|
|
252
|
+
summary: {
|
|
253
|
+
gateCount: gates.length,
|
|
254
|
+
gateReadyCount: gates.filter((gate) => gate.ready).length,
|
|
255
|
+
dualReviewReady: dualReviewMissingIds.length === 0,
|
|
256
|
+
dualReviewMissingIds,
|
|
257
|
+
dualReviewMissingCount: dualReviewMissingIds.length,
|
|
258
|
+
finishGateReady: finishGateMissingIds.length === 0,
|
|
259
|
+
finishGateMissingIds,
|
|
260
|
+
finishGateMissingCount: finishGateMissingIds.length,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* @param {string} changeId
|
|
267
|
+
* @param {any} reviewGates
|
|
268
|
+
*/
|
|
269
|
+
export function collectFinishGateErrors(changeId, reviewGates) {
|
|
270
|
+
const summary = reviewGates?.summary && typeof reviewGates.summary === "object" ? reviewGates.summary : {};
|
|
271
|
+
const missingIds = Array.isArray(summary.finishGateMissingIds) ? summary.finishGateMissingIds : [];
|
|
272
|
+
if (missingIds.length === 0) return [];
|
|
273
|
+
|
|
274
|
+
/** @type {string[]} */
|
|
275
|
+
const errors = [];
|
|
276
|
+
for (const missingId of missingIds) {
|
|
277
|
+
if (missingId === "spec_review") {
|
|
278
|
+
const gate = reviewGates?.specReview || {};
|
|
279
|
+
if (gate.status === "tmp_unattributed") {
|
|
280
|
+
errors.push(`finish gate has unattributed tmp spec review (persist it to changes/${changeId}/review/spec-review.md or rerun in the owning change context)`);
|
|
281
|
+
} else {
|
|
282
|
+
errors.push(`finish gate missing spec review (run \`$ws-spec-review\` or write changes/${changeId}/review/spec-review.md)`);
|
|
283
|
+
}
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (missingId === "quality_review") {
|
|
287
|
+
const gate = reviewGates?.qualityReview || {};
|
|
288
|
+
if (gate.status === "tmp_unattributed") {
|
|
289
|
+
errors.push(`finish gate has unattributed tmp quality review (persist it to changes/${changeId}/review/quality-review.md or rerun in the owning change context)`);
|
|
290
|
+
} else {
|
|
291
|
+
errors.push(`finish gate missing quality review (run \`$ws-quality-review\` or write changes/${changeId}/review/quality-review.md)`);
|
|
292
|
+
}
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (missingId === "validate_stamp") {
|
|
296
|
+
errors.push("finish gate missing validate stamp (run `aiws validate . --stamp`)");
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (missingId === "verify_before_complete") {
|
|
300
|
+
const gate = reviewGates?.verifyBeforeComplete || {};
|
|
301
|
+
if (gate.status === "tmp_unattributed") {
|
|
302
|
+
errors.push(
|
|
303
|
+
`finish gate has unattributed tmp verify-before-complete evidence (persist it to changes/${changeId}/evidence/verify-before-complete.md or rerun in the owning change context)`,
|
|
304
|
+
);
|
|
305
|
+
} else {
|
|
306
|
+
errors.push(
|
|
307
|
+
`finish gate missing verify-before-complete evidence (run \`$ws-verify-before-complete\` or write changes/${changeId}/evidence/verify-before-complete.md)`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
errors.push(`finish gate missing required artifact: ${missingId}`);
|
|
313
|
+
}
|
|
314
|
+
return errors;
|
|
315
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {any} lifecycle
|
|
3
|
+
*/
|
|
4
|
+
export function computeScopeGate(lifecycle) {
|
|
5
|
+
const runs = Number(lifecycle?.strictScopeValidateRuns || 0);
|
|
6
|
+
const pass = lifecycle?.strictScopeValidatePass === true;
|
|
7
|
+
return {
|
|
8
|
+
checked: runs > 0,
|
|
9
|
+
ready: runs > 0 && pass,
|
|
10
|
+
status: runs === 0 ? "not_run" : pass ? "passed" : "failed",
|
|
11
|
+
source: runs > 0 ? "metrics.validate.check_scope" : "",
|
|
12
|
+
lastRunAt: String(lifecycle?.strictScopeValidateAt || ""),
|
|
13
|
+
errors: Number(lifecycle?.strictScopeValidateErrors || 0),
|
|
14
|
+
warnings: Number(lifecycle?.strictScopeValidateWarnings || 0),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} changeId
|
|
20
|
+
*/
|
|
21
|
+
export function scopeGateFailureAction(changeId) {
|
|
22
|
+
return `scope gate 未通过:更新 plan 的 \`## Scope\` / \`### In Scope\` 或收敛改动后复跑 \`aiws change validate ${changeId} --strict --check-scope\``;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} changeId
|
|
27
|
+
*/
|
|
28
|
+
export function scopeGatePendingRecommendation(changeId) {
|
|
29
|
+
return `范围门禁:\`aiws change validate ${changeId} --strict --check-scope\``;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function scopeGatePassedRecommendation() {
|
|
33
|
+
return "范围门禁:最近一次 `--check-scope` 已通过";
|
|
34
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string} gitRoot
|
|
5
|
+
* @param {{
|
|
6
|
+
* hasHeadCommit: (gitRoot: string) => Promise<boolean>,
|
|
7
|
+
* runCommand: typeof import("../exec.js").runCommand
|
|
8
|
+
* }} deps
|
|
9
|
+
* @returns {Promise<{ ok: true } | { ok: false, reason: "no_commit" | "missing_truth", missing?: string[] }>}
|
|
10
|
+
*/
|
|
11
|
+
export async function checkWorktreePrereqs(gitRoot, deps) {
|
|
12
|
+
if (!(await deps.hasHeadCommit(gitRoot))) return { ok: false, reason: "no_commit" };
|
|
13
|
+
/** @type {string[]} */
|
|
14
|
+
const missingInHead = [];
|
|
15
|
+
for (const rel of ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"]) {
|
|
16
|
+
const ok = await deps.runCommand("git", ["cat-file", "-e", `HEAD:${rel}`], { cwd: gitRoot }).then((result) => result.code === 0);
|
|
17
|
+
if (!ok) missingInHead.push(rel);
|
|
18
|
+
}
|
|
19
|
+
if (missingInHead.length > 0) return { ok: false, reason: "missing_truth", missing: missingInHead };
|
|
20
|
+
return { ok: true };
|
|
21
|
+
}
|
|
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
|
+
/**
|
|
32
|
+
* @param {string} changeDir
|
|
33
|
+
* @param {string} baseBranch
|
|
34
|
+
* @param {string} changeBranch
|
|
35
|
+
* @param {{
|
|
36
|
+
* nowIsoUtc: () => string,
|
|
37
|
+
* pathExists: (path: string) => Promise<boolean>,
|
|
38
|
+
* readText: (path: string) => Promise<string>,
|
|
39
|
+
* writeText: (path: string, content: string) => Promise<void>
|
|
40
|
+
* }} deps
|
|
41
|
+
*/
|
|
42
|
+
export async function maybeRecordBaseBranch(changeDir, baseBranch, changeBranch, deps) {
|
|
43
|
+
const base = String(baseBranch || "").trim();
|
|
44
|
+
if (!base) return;
|
|
45
|
+
if (base === String(changeBranch || "").trim()) return;
|
|
46
|
+
const metaPath = path.join(changeDir, ".ws-change.json");
|
|
47
|
+
if (!(await deps.pathExists(metaPath))) return;
|
|
48
|
+
/** @type {any} */
|
|
49
|
+
let meta = null;
|
|
50
|
+
try {
|
|
51
|
+
meta = JSON.parse(await deps.readText(metaPath));
|
|
52
|
+
} catch {
|
|
53
|
+
meta = null;
|
|
54
|
+
}
|
|
55
|
+
if (!meta || typeof meta !== "object") return;
|
|
56
|
+
const current = String(meta.base_branch || "").trim();
|
|
57
|
+
if (current) return;
|
|
58
|
+
meta.base_branch = base;
|
|
59
|
+
meta.base_branch_set_at = deps.nowIsoUtc();
|
|
60
|
+
await deps.writeText(metaPath, JSON.stringify(meta, null, 2) + "\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} gitRoot
|
|
65
|
+
* @param {string | undefined} worktreeDir
|
|
66
|
+
* @param {string} changeId
|
|
67
|
+
*/
|
|
68
|
+
export function resolveDefaultWorktreeDir(gitRoot, worktreeDir, changeId) {
|
|
69
|
+
const parent = path.dirname(gitRoot);
|
|
70
|
+
const raw = String(worktreeDir || "").trim();
|
|
71
|
+
if (raw) {
|
|
72
|
+
return path.isAbsolute(raw) ? raw : path.resolve(parent, raw);
|
|
73
|
+
}
|
|
74
|
+
const repoName = path.basename(gitRoot);
|
|
75
|
+
return path.resolve(parent, `${repoName}-${changeId}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {string} gitRoot
|
|
80
|
+
* @param {{ branch: string, hasBranch: boolean, hasGitmodules: boolean }} options
|
|
81
|
+
* @param {{
|
|
82
|
+
* runCommand: typeof import("../exec.js").runCommand,
|
|
83
|
+
* UserError: typeof import("../errors.js").UserError
|
|
84
|
+
* }} deps
|
|
85
|
+
*/
|
|
86
|
+
export async function switchOrCreateStartBranch(gitRoot, options, deps) {
|
|
87
|
+
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 });
|
|
92
|
+
}
|
|
93
|
+
if (sw.code === 0) return;
|
|
94
|
+
|
|
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
|
+
}
|
|
100
|
+
if (co.code !== 0) throw new deps.UserError("Failed to switch branch.", { details: co.stderr || co.stdout });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
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 });
|
|
108
|
+
}
|
|
109
|
+
if (sw.code === 0) return;
|
|
110
|
+
|
|
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
|
+
}
|
|
116
|
+
if (co.code !== 0) throw new deps.UserError("Failed to create branch.", { details: co.stderr || co.stdout });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {string} gitRoot
|
|
121
|
+
* @param {{ branch: string, hasBranch: boolean, headReady: boolean }} options
|
|
122
|
+
* @param {{
|
|
123
|
+
* runCommand: typeof import("../exec.js").runCommand,
|
|
124
|
+
* UserError: typeof import("../errors.js").UserError
|
|
125
|
+
* }} deps
|
|
126
|
+
*/
|
|
127
|
+
export async function prepareNoSwitchBranch(gitRoot, options, deps) {
|
|
128
|
+
if (options.hasBranch) return true;
|
|
129
|
+
if (!options.headReady) {
|
|
130
|
+
console.error(`warn: repo has no commits; cannot create branch "${options.branch}" without switching (skipped)`);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
const branchResult = await deps.runCommand("git", ["branch", options.branch], { cwd: gitRoot });
|
|
134
|
+
if (branchResult.code !== 0) {
|
|
135
|
+
throw new deps.UserError("Failed to create branch.", { details: branchResult.stderr || branchResult.stdout });
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {{
|
|
5
|
+
* gitRoot: string,
|
|
6
|
+
* changeId: string,
|
|
7
|
+
* options: { write: boolean }
|
|
8
|
+
* }} input
|
|
9
|
+
* @param {{
|
|
10
|
+
* appendMetricsEvent: (gitRoot: string, changeId: string, type: string, payload: any) => Promise<void>,
|
|
11
|
+
* changeDirAbs: (gitRoot: string, changeId: string) => string,
|
|
12
|
+
* computeChangeNextAdvice: (gitRoot: string, changeId: string) => Promise<any>,
|
|
13
|
+
* computeChangeStatus: (gitRoot: string, changeId: string) => Promise<any>,
|
|
14
|
+
* nowIsoUtc: () => string,
|
|
15
|
+
* planVerifyHint: (changeId: string) => string,
|
|
16
|
+
* renderChangeStateMarkdown: (input: any) => string,
|
|
17
|
+
* writeText: (path: string, content: string) => Promise<void>
|
|
18
|
+
* }} deps
|
|
19
|
+
*/
|
|
20
|
+
export async function runChangeStateCommand(input, deps) {
|
|
21
|
+
const { gitRoot, changeId, options } = input;
|
|
22
|
+
const status = await deps.computeChangeStatus(gitRoot, changeId);
|
|
23
|
+
const advice = await deps.computeChangeNextAdvice(gitRoot, changeId);
|
|
24
|
+
|
|
25
|
+
const output = deps.renderChangeStateMarkdown({
|
|
26
|
+
changeId,
|
|
27
|
+
gitRoot,
|
|
28
|
+
now: deps.nowIsoUtc(),
|
|
29
|
+
status,
|
|
30
|
+
advice,
|
|
31
|
+
planVerifyLine: deps.planVerifyHint(changeId),
|
|
32
|
+
});
|
|
33
|
+
const dest = path.join(deps.changeDirAbs(gitRoot, changeId), "STATE.md");
|
|
34
|
+
if (options.write === true) {
|
|
35
|
+
await deps.writeText(dest, output);
|
|
36
|
+
await deps.appendMetricsEvent(gitRoot, changeId, "state_written", { path: path.relative(gitRoot, dest) });
|
|
37
|
+
return { output: `ok: state written: ${path.relative(gitRoot, dest)}\n` };
|
|
38
|
+
}
|
|
39
|
+
return { output };
|
|
40
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {{
|
|
5
|
+
* gitRoot: string,
|
|
6
|
+
* branch: string,
|
|
7
|
+
* changeWorktreeHint: string,
|
|
8
|
+
* status: any
|
|
9
|
+
* }} input
|
|
10
|
+
* @param {{
|
|
11
|
+
* effectiveReviewCount: (status: any) => number,
|
|
12
|
+
* governanceGuidanceLines: (status: any) => Promise<string[]>,
|
|
13
|
+
* planVerifyHint: (changeId: string) => string
|
|
14
|
+
* }} deps
|
|
15
|
+
*/
|
|
16
|
+
export async function renderChangeStatusOutput(input, deps) {
|
|
17
|
+
const { gitRoot, branch, changeWorktreeHint, status } = input;
|
|
18
|
+
/** @type {string[]} */
|
|
19
|
+
const lines = [];
|
|
20
|
+
|
|
21
|
+
lines.push(`✓ aiws change status: ${status.changeId}`);
|
|
22
|
+
lines.push(`dir: ${status.dir}`);
|
|
23
|
+
lines.push(`phase: ${status.phase}`);
|
|
24
|
+
lines.push(`worktree: ${gitRoot}`);
|
|
25
|
+
lines.push(`branch: ${branch || "(detached HEAD)"}`);
|
|
26
|
+
if (changeWorktreeHint) lines.push(`note: change/<id> checked out in another worktree: ${changeWorktreeHint}`);
|
|
27
|
+
if (status.context?.worktree && path.resolve(status.context.worktree) !== path.resolve(gitRoot)) {
|
|
28
|
+
lines.push(`status_context: ${status.context.worktree}`);
|
|
29
|
+
lines.push(`status_branch: ${status.context.branch || "(detached HEAD)"}`);
|
|
30
|
+
}
|
|
31
|
+
if (status.context?.warning) lines.push(`warning: ${status.context.warning}`);
|
|
32
|
+
lines.push(`meta: ${status.metaState}`);
|
|
33
|
+
if (status.reqId) lines.push(`Req_ID: ${status.reqId}`);
|
|
34
|
+
if (status.probId) lines.push(`Problem_ID: ${status.probId}`);
|
|
35
|
+
lines.push(`tasks: ${status.tasks.done}/${status.tasks.total} (unchecked=${status.tasks.unchecked})`);
|
|
36
|
+
if (status.reviewSignals) {
|
|
37
|
+
lines.push(`review_signal: effective=${status.reviewSignals.effectiveCount} source=${status.reviewSignals.source}`);
|
|
38
|
+
}
|
|
39
|
+
if (status.reviewGates?.summary) {
|
|
40
|
+
const summary = status.reviewGates.summary;
|
|
41
|
+
const missing = summary.finishGateMissingIds.length > 0 ? summary.finishGateMissingIds.join(", ") : "(none)";
|
|
42
|
+
lines.push(`review_gates: dual_ready=${summary.dualReviewReady} finish_ready=${summary.finishGateReady} missing=${missing}`);
|
|
43
|
+
for (const gate of status.reviewGates.gates || []) {
|
|
44
|
+
lines.push(`review_gate:${gate.id} ready=${gate.ready} status=${gate.status}${gate.foundPath ? ` path=${gate.foundPath}` : ""}`);
|
|
45
|
+
}
|
|
46
|
+
if (status.reviewGates.validateStamp) {
|
|
47
|
+
const validateStamp = status.reviewGates.validateStamp;
|
|
48
|
+
lines.push(
|
|
49
|
+
`review_gate:${validateStamp.id} ready=${validateStamp.ready} status=${validateStamp.status}${validateStamp.foundPath ? ` path=${validateStamp.foundPath}` : ""}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (status.scopeGate) {
|
|
54
|
+
lines.push(
|
|
55
|
+
`scope_gate: status=${status.scopeGate.status} ready=${status.scopeGate.ready} checked=${status.scopeGate.checked} errors=${status.scopeGate.errors} warnings=${status.scopeGate.warnings}${status.scopeGate.lastRunAt ? ` at=${status.scopeGate.lastRunAt}` : ""}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (status.git) {
|
|
59
|
+
lines.push(
|
|
60
|
+
`git: clean=${status.git.clean} staged=${status.git.staged} unstaged=${status.git.unstaged} untracked=${status.git.untracked} conflicted=${status.git.conflicted}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (status.governance) {
|
|
64
|
+
lines.push(`governance: current=${status.governance.currentStage} next=${status.governance.recommendedStage}`);
|
|
65
|
+
if (status.governance.rationale) lines.push(`governance_reason: ${status.governance.rationale}`);
|
|
66
|
+
if (status.governance.ruleId) lines.push(`governance_rule: ${status.governance.ruleId}`);
|
|
67
|
+
if (status.governance.source) lines.push(`governance_source: ${status.governance.source}`);
|
|
68
|
+
}
|
|
69
|
+
lines.push(`baseline: ${status.baselineLabel}${status.baselineAt ? ` (at=${status.baselineAt})` : ""}`);
|
|
70
|
+
lines.push(`drift: ${status.driftFiles.length > 0 ? status.driftFiles.join(", ") : "(none)"}`);
|
|
71
|
+
if (status.evidence?.counts) {
|
|
72
|
+
const counts = status.evidence.counts;
|
|
73
|
+
lines.push(`evidence: declared=${counts.total} (exists=${counts.exists}, missing=${counts.missing}, persistent=${counts.persistent}, tmp=${counts.tmp})`);
|
|
74
|
+
}
|
|
75
|
+
if (status.collaboration?.counts) {
|
|
76
|
+
const counts = status.collaboration.counts;
|
|
77
|
+
lines.push(`collaboration: analysis=${counts.analysis}, patches=${counts.patches}, review=${counts.review}, evidence_dir=${counts.evidence}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
lines.push("");
|
|
81
|
+
lines.push("Blockers (strict):");
|
|
82
|
+
if (status.blockersStrict.length === 0) lines.push("- (none)");
|
|
83
|
+
else for (const blocker of status.blockersStrict) lines.push(`- ${blocker}`);
|
|
84
|
+
|
|
85
|
+
lines.push("");
|
|
86
|
+
lines.push("Blockers (archive):");
|
|
87
|
+
if (status.blockersArchive.length === 0) lines.push("- (none)");
|
|
88
|
+
else for (const blocker of status.blockersArchive) lines.push(`- ${blocker}`);
|
|
89
|
+
|
|
90
|
+
lines.push("");
|
|
91
|
+
lines.push("Next (recommended):");
|
|
92
|
+
lines.push(`- ${deps.planVerifyHint(status.changeId)}`);
|
|
93
|
+
if (status.blockersStrict.length > 0) {
|
|
94
|
+
lines.push("- 先修复 Blockers (strict) 后复跑质量门,再进入编码:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`");
|
|
95
|
+
if (changeWorktreeHint) lines.push(`- 若需要写代码:先切到 change worktree 再改动:\`cd ${changeWorktreeHint}\``);
|
|
96
|
+
return lines.join("\n") + "\n";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (status.tasks.unchecked > 0) {
|
|
100
|
+
lines.push("- 继续开发:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`(小步实现 + 可复现验证)");
|
|
101
|
+
if ((status.collaboration?.counts?.analysis || 0) > 0) lines.push("- 已有委托分析:在编码前先收敛 `changes/<id>/analysis/` 中的结论");
|
|
102
|
+
if ((status.collaboration?.counts?.patches || 0) > 0) lines.push("- 已有 patch 草案:应用前先人工审查 `changes/<id>/patches/`");
|
|
103
|
+
lines.push("- 提交/交付前强制门禁:`aiws validate .`(可选落盘:`aiws validate . --stamp`)");
|
|
104
|
+
lines.push(`- (可选)状态快照:\`aiws change state ${status.changeId} --write\``);
|
|
105
|
+
return lines.join("\n") + "\n";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (status.evidence?.counts && status.evidence.counts.persistent === 0) {
|
|
109
|
+
lines.push("- 交付前建议生成持久证据:`changes/<id>/evidence/...`(并把路径回填到 proposal.md 的 Evidence_Path)");
|
|
110
|
+
}
|
|
111
|
+
if (status.evidence?.missing && status.evidence.missing.length > 0) {
|
|
112
|
+
const show = status.evidence.missing.slice(0, 5);
|
|
113
|
+
lines.push(`- 交付前建议补齐缺失证据文件(missing=${status.evidence.missing.length}):${show.join(", ")}${status.evidence.missing.length > show.length ? ", ..." : ""}`);
|
|
114
|
+
}
|
|
115
|
+
if ((status.collaboration?.counts?.patches || 0) > 0) {
|
|
116
|
+
lines.push("- 存在委托 patch 草案:交付前确认其已被人工审查,并在 review/handoff 中记录结论");
|
|
117
|
+
}
|
|
118
|
+
if ((status.collaboration?.counts?.analysis || 0) > 0 && deps.effectiveReviewCount(status) === 0) {
|
|
119
|
+
lines.push("- 存在委托分析但尚无 review 汇总:建议先在 `changes/<id>/review/` 形成结论后再交付");
|
|
120
|
+
}
|
|
121
|
+
lines.push(`- (可选)状态快照:\`aiws change state ${status.changeId} --write\``);
|
|
122
|
+
for (const line of await deps.governanceGuidanceLines(status)) lines.push(`- ${line}`);
|
|
123
|
+
if (status.governance?.rationale) lines.push(`- 阶段判定:${status.governance.rationale}`);
|
|
124
|
+
if (status.governance?.warning) lines.push(`- 注意:${status.governance.warning}`);
|
|
125
|
+
return lines.join("\n") + "\n";
|
|
126
|
+
}
|