@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
package/src/commands/change.js
CHANGED
|
@@ -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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
*
|
|
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
|
|
1102
|
-
|
|
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
|
-
*
|
|
616
|
+
* Compute change status as JSON-friendly object (used by dashboard and CLI).
|
|
1107
617
|
*
|
|
1108
|
-
* @param {string}
|
|
1109
|
-
* @param {string
|
|
1110
|
-
* @param {string} value
|
|
1111
|
-
* @param {{ insertAfterLabels?: string[] }} options
|
|
618
|
+
* @param {string} gitRoot
|
|
619
|
+
* @param {string} changeId
|
|
1112
620
|
*/
|
|
1113
|
-
function
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
-
|
|
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: "
|
|
759
|
+
dev: "质量门通过后再进入编码:simple/local 单点修复优先 `$ws-dev-lite`;否则 `$ws-dev`",
|
|
1306
760
|
},
|
|
1307
761
|
};
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
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
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
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
|
|
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
|
-
|
|
1460
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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, "
|
|
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
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
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
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
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
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2260
|
-
const
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
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
|
|
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
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
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
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
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
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
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
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
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
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
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
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
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
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
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
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
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
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
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
|
}
|