@aipper/aiws 0.0.24 → 0.0.25
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 +30 -2
- package/package.json +2 -2
- package/src/cli.js +39 -1
- package/src/codex-skills.js +4 -4
- package/src/commands/change-advice.js +188 -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 +219 -0
- package/src/commands/change-metrics-command.js +110 -0
- package/src/commands/change-next-command.js +127 -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 +423 -1673
- package/src/commands/dashboard.js +38 -12
- package/src/commands/opencode-status.js +61 -0
- 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/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
|
*
|
|
@@ -917,6 +458,7 @@ async function generateHandoffContent(gitRoot, changeDir, changeId) {
|
|
|
917
458
|
let baseBranch = "main";
|
|
918
459
|
/** @type {string[]} */
|
|
919
460
|
const decisions = [];
|
|
461
|
+
const collaboration = await collectCollaborationArtifacts(gitRoot, changeDir);
|
|
920
462
|
|
|
921
463
|
if (await pathExists(proposalPath)) {
|
|
922
464
|
const text = await readText(proposalPath);
|
|
@@ -1008,6 +550,21 @@ async function generateHandoffContent(gitRoot, changeDir, changeId) {
|
|
|
1008
550
|
lines.push("- (missing) add decisions to changes/<id>/design.md#Decisions");
|
|
1009
551
|
}
|
|
1010
552
|
|
|
553
|
+
lines.push("");
|
|
554
|
+
lines.push("## 协同记录");
|
|
555
|
+
lines.push("");
|
|
556
|
+
if ((collaboration.counts.total || 0) === 0) {
|
|
557
|
+
lines.push("- (none)");
|
|
558
|
+
} else {
|
|
559
|
+
for (const key of ["analysis", "patches", "review", "evidence"]) {
|
|
560
|
+
const group = collaboration.dirs[key];
|
|
561
|
+
if (!group) continue;
|
|
562
|
+
lines.push(`- ${group.label}: ${group.count} file(s)`);
|
|
563
|
+
for (const rel of group.files) lines.push(` - ${rel}`);
|
|
564
|
+
if (group.truncated) lines.push(" - ...(truncated)");
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
1011
568
|
lines.push("");
|
|
1012
569
|
lines.push("## 下一步建议");
|
|
1013
570
|
lines.push("");
|
|
@@ -1030,149 +587,6 @@ async function generateHandoffContent(gitRoot, changeDir, changeId) {
|
|
|
1030
587
|
return lines.join("\n") + "\n";
|
|
1031
588
|
}
|
|
1032
589
|
|
|
1033
|
-
/**
|
|
1034
|
-
* Evidence_Path is user-declared; we treat it as best-effort hints, not hard gates.
|
|
1035
|
-
*
|
|
1036
|
-
* @param {string} gitRoot
|
|
1037
|
-
* @param {string} changeId
|
|
1038
|
-
* @param {string[]} evidencePaths
|
|
1039
|
-
* @returns {Promise<{
|
|
1040
|
-
* entries: Array<{rel: string, abs: string, exists: boolean, kind: string, isAbsolute: boolean}>,
|
|
1041
|
-
* counts: { total: number, exists: number, missing: number, tmp: number, persistent: number, other: number, absolute: number },
|
|
1042
|
-
* missing: string[]
|
|
1043
|
-
* }>}
|
|
1044
|
-
*/
|
|
1045
|
-
async function analyzeEvidencePaths(gitRoot, changeId, evidencePaths) {
|
|
1046
|
-
const changePrefix = `changes/${changeId}/`;
|
|
1047
|
-
const evidencePrefix = `changes/${changeId}/evidence/`;
|
|
1048
|
-
const reviewPrefix = `changes/${changeId}/review/`;
|
|
1049
|
-
|
|
1050
|
-
const entries = await Promise.all(
|
|
1051
|
-
(evidencePaths || []).map(async (raw) => {
|
|
1052
|
-
const rel = String(raw || "").trim();
|
|
1053
|
-
const isAbsolute = path.isAbsolute(rel);
|
|
1054
|
-
const abs = isAbsolute ? rel : path.join(gitRoot, rel);
|
|
1055
|
-
const exists = rel ? await pathExists(abs) : false;
|
|
1056
|
-
|
|
1057
|
-
const relNorm = normalizeSlashes(rel);
|
|
1058
|
-
let kind = "other";
|
|
1059
|
-
if (relNorm.startsWith(".agentdocs/tmp/") || relNorm.startsWith(".agentdocs/")) kind = "tmp";
|
|
1060
|
-
else if (relNorm.startsWith(evidencePrefix) || relNorm.startsWith(reviewPrefix) || relNorm.startsWith(changePrefix)) kind = "persistent";
|
|
1061
|
-
|
|
1062
|
-
return { rel, abs: path.resolve(abs), exists, kind, isAbsolute };
|
|
1063
|
-
}),
|
|
1064
|
-
);
|
|
1065
|
-
|
|
1066
|
-
const missing = entries.filter((e) => e.rel && !e.exists).map((e) => e.rel);
|
|
1067
|
-
const counts = {
|
|
1068
|
-
total: entries.filter((e) => e.rel).length,
|
|
1069
|
-
exists: entries.filter((e) => e.rel && e.exists).length,
|
|
1070
|
-
missing: missing.length,
|
|
1071
|
-
tmp: entries.filter((e) => e.rel && e.kind === "tmp").length,
|
|
1072
|
-
persistent: entries.filter((e) => e.rel && e.kind === "persistent").length,
|
|
1073
|
-
other: entries.filter((e) => e.rel && e.kind === "other").length,
|
|
1074
|
-
absolute: entries.filter((e) => e.rel && e.isAbsolute).length,
|
|
1075
|
-
};
|
|
1076
|
-
|
|
1077
|
-
return { entries: entries.filter((e) => e.rel), counts, missing };
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
/**
|
|
1081
|
-
* @param {string[]} a
|
|
1082
|
-
* @param {string[]} b
|
|
1083
|
-
*/
|
|
1084
|
-
function mergeDeclaredValues(a, b) {
|
|
1085
|
-
const seen = new Set();
|
|
1086
|
-
/** @type {string[]} */
|
|
1087
|
-
const out = [];
|
|
1088
|
-
for (const raw of [...(a || []), ...(b || [])]) {
|
|
1089
|
-
const v = String(raw || "").trim().replace(/^`+/, "").replace(/`+$/, "").trim();
|
|
1090
|
-
if (!v) continue;
|
|
1091
|
-
if (seen.has(v)) continue;
|
|
1092
|
-
seen.add(v);
|
|
1093
|
-
out.push(v);
|
|
1094
|
-
}
|
|
1095
|
-
return out;
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
/**
|
|
1099
|
-
* @param {string[]} values
|
|
1100
|
-
*/
|
|
1101
|
-
function formatDeclaredValues(values) {
|
|
1102
|
-
return (values || []).join(", ");
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
/**
|
|
1106
|
-
* Replace or insert a simple "ID: value" style line in Markdown.
|
|
1107
|
-
*
|
|
1108
|
-
* @param {string} text
|
|
1109
|
-
* @param {string[]} labelCandidates
|
|
1110
|
-
* @param {string} value
|
|
1111
|
-
* @param {{ insertAfterLabels?: string[] }} options
|
|
1112
|
-
*/
|
|
1113
|
-
function upsertIdLine(text, labelCandidates, value, options) {
|
|
1114
|
-
const src = String(text || "");
|
|
1115
|
-
const labels = Array.isArray(labelCandidates) ? labelCandidates : [];
|
|
1116
|
-
for (const label of labels) {
|
|
1117
|
-
const re = new RegExp(`^(.*${escapeRegExp(label)}.*?[:=][ \\t]*)(.*)$`, "m");
|
|
1118
|
-
const m = re.exec(src);
|
|
1119
|
-
if (!m) continue;
|
|
1120
|
-
const prefix = m[1] || "";
|
|
1121
|
-
const replaced = src.replace(re, `${prefix}${value}`);
|
|
1122
|
-
return { ok: true, changed: replaced !== src, text: replaced, mode: "replaced", label };
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
// Insert near the top (best-effort), after a nearby label if present.
|
|
1126
|
-
const insertAfter = Array.isArray(options?.insertAfterLabels) ? options.insertAfterLabels : [];
|
|
1127
|
-
const lines = src.split(/\r?\n/);
|
|
1128
|
-
let idx = 0;
|
|
1129
|
-
for (let i = 0; i < Math.min(lines.length, 80); i++) {
|
|
1130
|
-
const line = lines[i] || "";
|
|
1131
|
-
if (insertAfter.some((l) => line.includes(l))) idx = i + 1;
|
|
1132
|
-
}
|
|
1133
|
-
const insertedLabel = labels[0] || "Evidence_Path";
|
|
1134
|
-
const newLine = `- ${insertedLabel}: ${value}`;
|
|
1135
|
-
lines.splice(idx, 0, newLine);
|
|
1136
|
-
const inserted = lines.join("\n");
|
|
1137
|
-
return { ok: true, changed: inserted !== src, text: inserted, mode: "inserted", label: labels[0] || "" };
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
/**
|
|
1141
|
-
* Special-case insertion for proposal.md template style.
|
|
1142
|
-
*
|
|
1143
|
-
* @param {string} proposalText
|
|
1144
|
-
* @param {string} value
|
|
1145
|
-
*/
|
|
1146
|
-
function upsertProposalEvidencePath(proposalText, value) {
|
|
1147
|
-
const src = String(proposalText || "");
|
|
1148
|
-
const re = new RegExp(`^(.*${escapeRegExp("Evidence_Path")}.*?[:=][ \\t]*)(.*)$`, "m");
|
|
1149
|
-
if (re.test(src)) {
|
|
1150
|
-
const m = re.exec(src);
|
|
1151
|
-
const prefix = m?.[1] || "";
|
|
1152
|
-
const out = src.replace(re, `${prefix}${value}`);
|
|
1153
|
-
return { changed: out !== src, text: out, mode: "replaced" };
|
|
1154
|
-
}
|
|
1155
|
-
// Insert right after Plan_File binding if possible.
|
|
1156
|
-
const lines = src.split(/\r?\n/);
|
|
1157
|
-
let idx = 0;
|
|
1158
|
-
for (let i = 0; i < Math.min(lines.length, 120); i++) {
|
|
1159
|
-
if ((lines[i] || "").includes("Plan_File")) {
|
|
1160
|
-
idx = i + 1;
|
|
1161
|
-
break;
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
lines.splice(idx, 0, `- \`Evidence_Path\` = ${value}`);
|
|
1165
|
-
const inserted = lines.join("\n");
|
|
1166
|
-
return { changed: inserted !== src, text: inserted, mode: "inserted" };
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
/**
|
|
1170
|
-
* @param {string} changeId
|
|
1171
|
-
*/
|
|
1172
|
-
function planVerifyHint(changeId) {
|
|
1173
|
-
return `执行前质量门(优先):\`aiws change validate ${changeId} --strict\`(AI 工具中等价于 \`$ws-plan-verify\`)`;
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
590
|
/**
|
|
1177
591
|
* @param {string[]} blockersStrict
|
|
1178
592
|
* @param {{ unchecked: number }} tasks
|
|
@@ -1195,10 +609,23 @@ export async function computeChangeStatus(gitRoot, changeId) {
|
|
|
1195
609
|
|
|
1196
610
|
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
1197
611
|
if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
|
|
612
|
+
const context = await resolveChangeStatusContext(gitRoot, changeId, {
|
|
613
|
+
currentBranch,
|
|
614
|
+
listGitWorktrees,
|
|
615
|
+
pathExists,
|
|
616
|
+
isGitRepository: (repoPath) => isGitRepository(repoPath, { pathExists, runCommand }),
|
|
617
|
+
inferChangeIdFromBranch,
|
|
618
|
+
});
|
|
1198
619
|
|
|
1199
620
|
const proposal = await fileState(changeDir, "proposal.md");
|
|
1200
621
|
const tasks = await fileState(changeDir, "tasks.md");
|
|
1201
622
|
const design = await fileState(changeDir, "design.md");
|
|
623
|
+
const collaboration = await collectCollaborationArtifacts(gitRoot, changeDir);
|
|
624
|
+
const reviewSignals = await collectReviewSignals(gitRoot, changeId, context, collaboration);
|
|
625
|
+
const reviewGates = await collectReviewGates(gitRoot, changeId, context);
|
|
626
|
+
const git = await collectGitStatus(context.worktree);
|
|
627
|
+
const submodules = await listSubmodulesFromGitmodules(gitRoot, { pathExists, runCommand });
|
|
628
|
+
const metrics = await readChangeMetrics(changeDir, { pathExists, readText });
|
|
1202
629
|
|
|
1203
630
|
const metaPath = path.join(changeDir, ".ws-change.json");
|
|
1204
631
|
let metaState = "missing";
|
|
@@ -1279,12 +706,15 @@ export async function computeChangeStatus(gitRoot, changeId) {
|
|
|
1279
706
|
blockersArchive.push(...blockersStrict);
|
|
1280
707
|
if (taskProgress.unchecked > 0) blockersArchive.push(`tasks.md still has unchecked tasks (${taskProgress.unchecked} items)`);
|
|
1281
708
|
const phase = inferChangePhase(blockersStrict, taskProgress);
|
|
709
|
+
const lifecycle = summarizeLifecycleSignals(metrics);
|
|
710
|
+
const scopeGate = computeScopeGate(lifecycle);
|
|
1282
711
|
|
|
1283
|
-
|
|
712
|
+
const baseStatus = {
|
|
1284
713
|
ok: true,
|
|
1285
714
|
changeId,
|
|
1286
715
|
dir: path.relative(gitRoot, changeDir),
|
|
1287
716
|
phase,
|
|
717
|
+
context,
|
|
1288
718
|
metaState,
|
|
1289
719
|
reqId,
|
|
1290
720
|
probId,
|
|
@@ -1293,7 +723,16 @@ export async function computeChangeStatus(gitRoot, changeId) {
|
|
|
1293
723
|
planFile,
|
|
1294
724
|
evidencePaths,
|
|
1295
725
|
},
|
|
726
|
+
scopeGate,
|
|
1296
727
|
evidence,
|
|
728
|
+
collaboration,
|
|
729
|
+
reviewSignals,
|
|
730
|
+
reviewGates,
|
|
731
|
+
git,
|
|
732
|
+
repo: {
|
|
733
|
+
submodules: submodules.length,
|
|
734
|
+
},
|
|
735
|
+
lifecycle,
|
|
1297
736
|
tasks: taskProgress,
|
|
1298
737
|
baselineLabel,
|
|
1299
738
|
baselineAt,
|
|
@@ -1305,54 +744,14 @@ export async function computeChangeStatus(gitRoot, changeId) {
|
|
|
1305
744
|
dev: "质量门通过后再进入编码:在 AI 工具中运行 `$ws-dev`",
|
|
1306
745
|
},
|
|
1307
746
|
};
|
|
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: [] },
|
|
747
|
+
const governance = await inferChangeGovernance(baseStatus);
|
|
748
|
+
return {
|
|
749
|
+
...baseStatus,
|
|
750
|
+
governance: {
|
|
751
|
+
...governance,
|
|
752
|
+
warning: context.warning || "",
|
|
753
|
+
},
|
|
1345
754
|
};
|
|
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
755
|
}
|
|
1357
756
|
|
|
1358
757
|
/**
|
|
@@ -1363,29 +762,14 @@ function groupCheckMessages(errors, warnings) {
|
|
|
1363
762
|
* @param {{ strict: boolean, allowTruthDrift: boolean, checkEvidence?: boolean, checkScope?: boolean }} options
|
|
1364
763
|
*/
|
|
1365
764
|
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
|
-
};
|
|
765
|
+
return validateChangeArtifactsModel(gitRoot, changeId, options, {
|
|
766
|
+
assertValidChangeId,
|
|
767
|
+
collectFinishGateErrors,
|
|
768
|
+
computeChangeStatus,
|
|
769
|
+
ensureTruthFiles,
|
|
770
|
+
resolveWsChangeChecker,
|
|
771
|
+
runPython,
|
|
772
|
+
});
|
|
1389
773
|
}
|
|
1390
774
|
|
|
1391
775
|
/**
|
|
@@ -1455,39 +839,6 @@ async function appendMetricsEvent(gitRoot, changeId, type, payload) {
|
|
|
1455
839
|
await writeText(metricsAbs, JSON.stringify(cur, null, 2) + "\n");
|
|
1456
840
|
}
|
|
1457
841
|
|
|
1458
|
-
/**
|
|
1459
|
-
* @param {string} absDir
|
|
1460
|
-
* @param {(name: string) => boolean} predicate
|
|
1461
|
-
*/
|
|
1462
|
-
async function findLatestFileByMtime(absDir, predicate) {
|
|
1463
|
-
if (!(await pathExists(absDir))) return "";
|
|
1464
|
-
const entries = await fs.readdir(absDir, { withFileTypes: true });
|
|
1465
|
-
/** @type {{ abs: string, mtimeMs: number } | null} */
|
|
1466
|
-
let best = null;
|
|
1467
|
-
for (const e of entries) {
|
|
1468
|
-
if (!e.isFile()) continue;
|
|
1469
|
-
if (!predicate(e.name)) continue;
|
|
1470
|
-
const abs = path.join(absDir, e.name);
|
|
1471
|
-
let st;
|
|
1472
|
-
try {
|
|
1473
|
-
st = await fs.stat(abs);
|
|
1474
|
-
} catch {
|
|
1475
|
-
continue;
|
|
1476
|
-
}
|
|
1477
|
-
const mtimeMs = Number(st.mtimeMs || 0);
|
|
1478
|
-
if (!best || mtimeMs > best.mtimeMs) best = { abs, mtimeMs };
|
|
1479
|
-
}
|
|
1480
|
-
return best ? best.abs : "";
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
/**
|
|
1484
|
-
* @param {string} gitRoot
|
|
1485
|
-
* @param {string} absPath
|
|
1486
|
-
*/
|
|
1487
|
-
function relFromRoot(gitRoot, absPath) {
|
|
1488
|
-
return normalizeSlashes(path.relative(gitRoot, absPath));
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
842
|
/**
|
|
1492
843
|
* @param {string} gitRoot
|
|
1493
844
|
*/
|
|
@@ -1581,46 +932,6 @@ async function resolveChangeId(gitRoot, changeId, options) {
|
|
|
1581
932
|
throw new UserError(`usage: aiws change ${options.command} <change-id> (or switch to branch change/<change-id>)`);
|
|
1582
933
|
}
|
|
1583
934
|
|
|
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
935
|
/**
|
|
1625
936
|
* @param {string} gitRoot
|
|
1626
937
|
* @param {string} changeId
|
|
@@ -1796,6 +1107,10 @@ async function changeNewAtGitRoot(gitRoot, options) {
|
|
|
1796
1107
|
const changeDir = path.join(changeRoot, changeId);
|
|
1797
1108
|
if (await pathExists(changeDir)) throw new UserError(`Change already exists: ${changeDir}`);
|
|
1798
1109
|
await ensureDir(changeDir);
|
|
1110
|
+
await ensureDir(path.join(changeDir, "analysis"));
|
|
1111
|
+
await ensureDir(path.join(changeDir, "patches"));
|
|
1112
|
+
await ensureDir(path.join(changeDir, "review"));
|
|
1113
|
+
await ensureDir(path.join(changeDir, "evidence"));
|
|
1799
1114
|
|
|
1800
1115
|
const templates = await resolveChangeTemplatesDir(gitRoot);
|
|
1801
1116
|
const proposalTpl = await readText(path.join(templates.templateDir, "proposal.md"));
|
|
@@ -1841,6 +1156,9 @@ async function changeNewAtGitRoot(gitRoot, options) {
|
|
|
1841
1156
|
console.log(` - edit: changes/${changeId}/proposal.md`);
|
|
1842
1157
|
console.log(` - edit: changes/${changeId}/tasks.md`);
|
|
1843
1158
|
if (!options.noDesign) console.log(` - optional: changes/${changeId}/design.md`);
|
|
1159
|
+
console.log(` - optional: changes/${changeId}/analysis/ (委托分析)`);
|
|
1160
|
+
console.log(` - optional: changes/${changeId}/patches/ (patch 草案)`);
|
|
1161
|
+
console.log(` - optional: changes/${changeId}/review/ (多审查结果)`);
|
|
1844
1162
|
console.log(` - validate: aiws change validate ${changeId}`);
|
|
1845
1163
|
}
|
|
1846
1164
|
|
|
@@ -1888,7 +1206,7 @@ export async function changeStartCommand(options) {
|
|
|
1888
1206
|
let effectiveWorktree = options.worktree === true;
|
|
1889
1207
|
|
|
1890
1208
|
if (hasGitmodules && !effectiveNoSwitch && !effectiveWorktree && !effectiveForceSwitch) {
|
|
1891
|
-
const prereq = await checkWorktreePrereqs(gitRoot);
|
|
1209
|
+
const prereq = await checkWorktreePrereqs(gitRoot, { hasHeadCommit, runCommand });
|
|
1892
1210
|
if (prereq.ok) {
|
|
1893
1211
|
effectiveWorktree = true;
|
|
1894
1212
|
console.error("warn: .gitmodules detected (git submodules). defaulting to --worktree to avoid switching the superproject branch.");
|
|
@@ -1928,7 +1246,7 @@ export async function changeStartCommand(options) {
|
|
|
1928
1246
|
}
|
|
1929
1247
|
|
|
1930
1248
|
if (effectiveWorktree) {
|
|
1931
|
-
const prereq = await checkWorktreePrereqs(gitRoot);
|
|
1249
|
+
const prereq = await checkWorktreePrereqs(gitRoot, { hasHeadCommit, runCommand });
|
|
1932
1250
|
if (!prereq.ok) {
|
|
1933
1251
|
if (prereq.reason === "no_commit") {
|
|
1934
1252
|
throw new UserError("change start --worktree requires a repo with at least one commit.", { details: "Hint: make an initial commit, then retry." });
|
|
@@ -2001,7 +1319,7 @@ export async function changeStartCommand(options) {
|
|
|
2001
1319
|
baseBranch: startFromBranch && startFromBranch !== branch ? startFromBranch : undefined,
|
|
2002
1320
|
});
|
|
2003
1321
|
} else {
|
|
2004
|
-
await maybeRecordBaseBranch(changeDir, startFromBranch, branch);
|
|
1322
|
+
await maybeRecordBaseBranch(changeDir, startFromBranch, branch, { nowIsoUtc, pathExists, readText, writeText });
|
|
2005
1323
|
}
|
|
2006
1324
|
|
|
2007
1325
|
// Worktree mode: if this repo declares submodules, always initialize them in the new worktree.
|
|
@@ -2064,48 +1382,9 @@ export async function changeStartCommand(options) {
|
|
|
2064
1382
|
let branchReady = hasBranch;
|
|
2065
1383
|
|
|
2066
1384
|
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
|
-
}
|
|
1385
|
+
branchReady = await prepareNoSwitchBranch(gitRoot, { branch, hasBranch, headReady }, { runCommand, UserError });
|
|
2079
1386
|
} 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
|
-
}
|
|
1387
|
+
await switchOrCreateStartBranch(gitRoot, { branch, hasBranch, hasGitmodules }, { runCommand, UserError });
|
|
2109
1388
|
branchReady = true;
|
|
2110
1389
|
}
|
|
2111
1390
|
|
|
@@ -2118,7 +1397,7 @@ export async function changeStartCommand(options) {
|
|
|
2118
1397
|
baseBranch: startFromBranch && startFromBranch !== branch ? startFromBranch : undefined,
|
|
2119
1398
|
});
|
|
2120
1399
|
} else {
|
|
2121
|
-
await maybeRecordBaseBranch(changeDir, startFromBranch, branch);
|
|
1400
|
+
await maybeRecordBaseBranch(changeDir, startFromBranch, branch, { nowIsoUtc, pathExists, readText, writeText });
|
|
2122
1401
|
}
|
|
2123
1402
|
|
|
2124
1403
|
// Check Depends_On dependencies (non-worktree mode)
|
|
@@ -2172,7 +1451,41 @@ export async function changeFinishCommand(options) {
|
|
|
2172
1451
|
const gitRoot = await resolveGitRoot(process.cwd());
|
|
2173
1452
|
await ensureTruthFiles(gitRoot);
|
|
2174
1453
|
|
|
2175
|
-
const
|
|
1454
|
+
const resolvedChangeId = await resolveChangeId(gitRoot, options.changeId, { command: "finish" });
|
|
1455
|
+
assertValidChangeId(resolvedChangeId);
|
|
1456
|
+
const existingLifecycle = summarizeLifecycleSignals(await readChangeMetrics(changeDirAbs(gitRoot, resolvedChangeId), { pathExists, readText }));
|
|
1457
|
+
if (existingLifecycle.finishState === "done") {
|
|
1458
|
+
throw new UserError("Change finish already completed.", {
|
|
1459
|
+
details: `change: ${resolvedChangeId}\nnext: run \`aiws change archive ${resolvedChangeId}\` or continue with \`$ws-handoff\``,
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
const { changeId, changeBranch, into, cur, changeWt } = await resolveFinishContext(gitRoot, { ...options, changeId: resolvedChangeId }, {
|
|
1463
|
+
assertValidChangeId,
|
|
1464
|
+
checkGitClean,
|
|
1465
|
+
currentBranch,
|
|
1466
|
+
listGitWorktrees,
|
|
1467
|
+
pathExists,
|
|
1468
|
+
readText,
|
|
1469
|
+
resolveChangeId,
|
|
1470
|
+
runCommand,
|
|
1471
|
+
UserError,
|
|
1472
|
+
});
|
|
1473
|
+
const finishStatusRoot = changeWt?.worktree ? path.resolve(changeWt.worktree) : gitRoot;
|
|
1474
|
+
const finishStatus = await computeChangeStatus(finishStatusRoot, changeId);
|
|
1475
|
+
const finishGateErrors = collectFinishGateErrors(changeId, finishStatus.reviewGates);
|
|
1476
|
+
if (finishGateErrors.length > 0) {
|
|
1477
|
+
throw new UserError("Refusing to finish without complete review gates.", {
|
|
1478
|
+
details:
|
|
1479
|
+
`change: ${changeId}\n` +
|
|
1480
|
+
"missing:\n" +
|
|
1481
|
+
finishGateErrors.map((line) => `- ${line}`).join("\n") +
|
|
1482
|
+
`\n\nnext:\n` +
|
|
1483
|
+
`- aiws change validate ${changeId} --strict --check-evidence\n` +
|
|
1484
|
+
`- aiws change evidence ${changeId}\n` +
|
|
1485
|
+
`- re-run finish after all finish gates are green`,
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
const finishBasePayload = { into, from: changeBranch, push: options.push === true };
|
|
2176
1489
|
|
|
2177
1490
|
if (cur && cur !== into) {
|
|
2178
1491
|
const sw = await runCommand("git", ["switch", into], { cwd: gitRoot });
|
|
@@ -2191,54 +1504,103 @@ export async function changeFinishCommand(options) {
|
|
|
2191
1504
|
throw new UserError("Failed to fast-forward merge change branch into target.", { details: `${merge.stderr || merge.stdout}${extra}` });
|
|
2192
1505
|
}
|
|
2193
1506
|
|
|
2194
|
-
await appendMetricsEvent(gitRoot, changeId, "
|
|
2195
|
-
|
|
2196
|
-
console.log(`ok: finished change: ${changeId}`);
|
|
2197
|
-
console.log(`into: ${into}`);
|
|
2198
|
-
console.log(`from: ${changeBranch}`);
|
|
1507
|
+
await appendMetricsEvent(gitRoot, changeId, "finish_local", finishBasePayload);
|
|
2199
1508
|
|
|
1509
|
+
let cleanupNote = "not_requested";
|
|
1510
|
+
let finishCompleted = false;
|
|
2200
1511
|
if (options.push === true) {
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
const push = await runCommand("git", ["push", pushTarget.remote, pushTarget.refspec], { cwd: gitRoot });
|
|
2208
|
-
if (push.code !== 0) {
|
|
2209
|
-
throw new UserError("Finished locally, but failed to push target branch.", {
|
|
2210
|
-
details:
|
|
2211
|
-
`remote: ${pushTarget.remote}\nlocal_branch: ${into}\nremote_branch: ${pushTarget.remoteBranch}\n` +
|
|
2212
|
-
(pushTarget.trackedMergeRef ? `tracked_merge: ${pushTarget.trackedMergeRef}\n` : "") +
|
|
2213
|
-
`\n${push.stderr || push.stdout}`,
|
|
1512
|
+
try {
|
|
1513
|
+
const submodulePush = await pushSubmodulesForFinish(gitRoot, changeId, into, changeWt?.worktree, {
|
|
1514
|
+
pathExists,
|
|
1515
|
+
readText,
|
|
1516
|
+
runCommand,
|
|
1517
|
+
UserError,
|
|
2214
1518
|
});
|
|
2215
|
-
|
|
1519
|
+
if (submodulePush.processed > 0) {
|
|
1520
|
+
console.log(`submodule push: ok (${submodulePush.processed} repos)`);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
const pushTarget = await resolvePushTarget(gitRoot, into, options.remote, { runCommand });
|
|
1524
|
+
const push = await runCommand("git", ["push", pushTarget.remote, pushTarget.refspec], { cwd: gitRoot });
|
|
1525
|
+
if (push.code !== 0) {
|
|
1526
|
+
throw new UserError("Finished locally, but failed to push target branch.", {
|
|
1527
|
+
details:
|
|
1528
|
+
`remote: ${pushTarget.remote}\nlocal_branch: ${into}\nremote_branch: ${pushTarget.remoteBranch}\n` +
|
|
1529
|
+
(pushTarget.trackedMergeRef ? `tracked_merge: ${pushTarget.trackedMergeRef}\n` : "") +
|
|
1530
|
+
`\n${push.stderr || push.stdout}`,
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
console.log(`push: ok (${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""})`);
|
|
1535
|
+
|
|
1536
|
+
console.log(`push: target ${pushTarget.remote} ${into}${pushTarget.remoteBranch !== into ? ` -> ${pushTarget.remoteBranch}` : ""}`);
|
|
2216
1537
|
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
1538
|
+
if (changeWt?.worktree) {
|
|
1539
|
+
const cleanup = await cleanupWorktreePath(gitRoot, changeWt.worktree, {
|
|
1540
|
+
checkGitClean,
|
|
1541
|
+
listGitWorktrees,
|
|
1542
|
+
pathExists,
|
|
1543
|
+
runCommand,
|
|
1544
|
+
UserError,
|
|
1545
|
+
});
|
|
1546
|
+
cleanupNote = cleanup.status === "skipped" ? `skipped:${cleanup.reason || "unknown"}` : cleanup.status;
|
|
1547
|
+
finishCompleted =
|
|
1548
|
+
cleanup.status === "removed" ||
|
|
1549
|
+
cleanup.status === "pruned-missing" ||
|
|
1550
|
+
(cleanup.status === "skipped" && cleanup.reason === "current_worktree");
|
|
1551
|
+
if (cleanup.status === "removed") {
|
|
1552
|
+
console.log(`cleanup: removed worktree ${cleanup.worktree}`);
|
|
1553
|
+
} else if (cleanup.status === "pruned-missing") {
|
|
1554
|
+
console.log(`cleanup: pruned stale worktree metadata for ${cleanup.worktree}`);
|
|
1555
|
+
} else if (cleanup.reason === "current_worktree") {
|
|
1556
|
+
console.log("cleanup: skipped (change worktree is current worktree)");
|
|
1557
|
+
} else if (cleanup.reason === "locked") {
|
|
1558
|
+
console.log(`cleanup: skipped locked worktree ${cleanup.worktree}`);
|
|
1559
|
+
} else if (cleanup.reason === "dirty") {
|
|
1560
|
+
console.log(`cleanup: skipped dirty worktree ${cleanup.worktree}`);
|
|
1561
|
+
} else {
|
|
1562
|
+
console.log("cleanup: skipped");
|
|
1563
|
+
}
|
|
2233
1564
|
} else {
|
|
2234
|
-
|
|
1565
|
+
cleanupNote = "skipped:no_separate_change_worktree";
|
|
1566
|
+
finishCompleted = true;
|
|
1567
|
+
console.log("cleanup: skipped (no separate change worktree)");
|
|
2235
1568
|
}
|
|
2236
|
-
}
|
|
2237
|
-
|
|
1569
|
+
} catch (error) {
|
|
1570
|
+
await appendMetricsEvent(gitRoot, changeId, "finish_failed", {
|
|
1571
|
+
...finishBasePayload,
|
|
1572
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1573
|
+
});
|
|
1574
|
+
throw error;
|
|
2238
1575
|
}
|
|
2239
1576
|
}
|
|
2240
1577
|
|
|
2241
|
-
if (options.push === true &&
|
|
1578
|
+
if (options.push === true && !finishCompleted) {
|
|
1579
|
+
const cleanupReason = cleanupNote.startsWith("skipped:") ? cleanupNote.slice("skipped:".length) : cleanupNote;
|
|
1580
|
+
await appendMetricsEvent(gitRoot, changeId, "finish_cleanup_pending", {
|
|
1581
|
+
...finishBasePayload,
|
|
1582
|
+
cleanup: cleanupNote,
|
|
1583
|
+
reason: cleanupReason,
|
|
1584
|
+
});
|
|
1585
|
+
throw new UserError("Change finish incomplete: cleanup pending.", {
|
|
1586
|
+
details:
|
|
1587
|
+
`change: ${changeId}\n` +
|
|
1588
|
+
`next: cleanup pending (${cleanupReason || "unknown"})\n` +
|
|
1589
|
+
` - resolve the change worktree state, then rerun: aiws change finish ${changeId} --push${options.remote ? ` --remote ${options.remote}` : ""}`,
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
if (finishCompleted) {
|
|
1594
|
+
await appendMetricsEvent(gitRoot, changeId, "finish_done", { ...finishBasePayload, cleanup: cleanupNote });
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
const outcomeLabel =
|
|
1598
|
+
options.push === true ? (finishCompleted ? "finished change" : "pushed change; cleanup pending") : "finished locally";
|
|
1599
|
+
console.log(`ok: ${outcomeLabel}: ${changeId}`);
|
|
1600
|
+
console.log(`into: ${into}`);
|
|
1601
|
+
console.log(`from: ${changeBranch}`);
|
|
1602
|
+
|
|
1603
|
+
if (options.push === true && (await listSubmodulesFromGitmodules(gitRoot, { pathExists, runCommand })).length > 0) {
|
|
2242
1604
|
console.log("next:");
|
|
2243
1605
|
console.log(" - submodules already synced for finish --push; rerun `git submodule update --init --recursive` only if another worktree changed them later");
|
|
2244
1606
|
}
|
|
@@ -2256,70 +1618,21 @@ export async function changeStatusCommand(options) {
|
|
|
2256
1618
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "status" });
|
|
2257
1619
|
const st = await computeChangeStatus(gitRoot, changeId);
|
|
2258
1620
|
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`)");
|
|
1621
|
+
const changeWorktreeHint = await resolveChangeWorktreeHint(gitRoot, changeId, { listGitWorktrees });
|
|
1622
|
+
const output = await renderChangeStatusOutput(
|
|
1623
|
+
{
|
|
1624
|
+
gitRoot,
|
|
1625
|
+
branch,
|
|
1626
|
+
changeWorktreeHint,
|
|
1627
|
+
status: st,
|
|
1628
|
+
},
|
|
1629
|
+
{
|
|
1630
|
+
effectiveReviewCount,
|
|
1631
|
+
governanceGuidanceLines,
|
|
1632
|
+
planVerifyHint,
|
|
1633
|
+
},
|
|
1634
|
+
);
|
|
1635
|
+
process.stdout.write(output);
|
|
2323
1636
|
}
|
|
2324
1637
|
|
|
2325
1638
|
/**
|
|
@@ -2333,216 +1646,46 @@ export async function changeNextCommand(options) {
|
|
|
2333
1646
|
|
|
2334
1647
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "next" });
|
|
2335
1648
|
assertValidChangeId(changeId);
|
|
2336
|
-
const
|
|
1649
|
+
const st = await computeChangeStatus(gitRoot, changeId);
|
|
2337
1650
|
|
|
2338
1651
|
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
2339
1652
|
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}\``);
|
|
1653
|
+
const output = await renderChangeNextOutput(
|
|
1654
|
+
{
|
|
1655
|
+
gitRoot,
|
|
1656
|
+
changeId,
|
|
1657
|
+
changeDir,
|
|
1658
|
+
status: st,
|
|
1659
|
+
},
|
|
1660
|
+
{
|
|
1661
|
+
analyzeEvidencePaths,
|
|
1662
|
+
computeChangeNextAdvice,
|
|
1663
|
+
extractId,
|
|
1664
|
+
fileState,
|
|
1665
|
+
pathExists,
|
|
1666
|
+
planVerifyHint,
|
|
1667
|
+
splitDeclaredValues,
|
|
1668
|
+
},
|
|
1669
|
+
);
|
|
1670
|
+
process.stdout.write(output);
|
|
2462
1671
|
}
|
|
2463
1672
|
|
|
2464
1673
|
/**
|
|
2465
1674
|
* @param {string} gitRoot
|
|
2466
1675
|
* @param {string} changeId
|
|
2467
1676
|
*/
|
|
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;
|
|
1677
|
+
export async function computeChangeNextAdvice(gitRoot, changeId) {
|
|
1678
|
+
return computeChangeNextAdviceModel(gitRoot, changeId, {
|
|
1679
|
+
computeChangeStatus,
|
|
1680
|
+
currentBranch,
|
|
1681
|
+
inferChangeIdFromBranch,
|
|
1682
|
+
listGitWorktrees,
|
|
1683
|
+
changeDirAbs,
|
|
1684
|
+
governanceGuidanceLines,
|
|
1685
|
+
scopeGateFailureAction,
|
|
1686
|
+
scopeGatePendingRecommendation: (id) => scopeGatePendingRecommendation(id),
|
|
1687
|
+
scopeGatePassedRecommendation,
|
|
1688
|
+
});
|
|
2546
1689
|
}
|
|
2547
1690
|
|
|
2548
1691
|
/**
|
|
@@ -2556,62 +1699,24 @@ export async function changeStateCommand(options) {
|
|
|
2556
1699
|
|
|
2557
1700
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "state" });
|
|
2558
1701
|
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);
|
|
1702
|
+
const result = await runChangeStateCommand(
|
|
1703
|
+
{
|
|
1704
|
+
gitRoot,
|
|
1705
|
+
changeId,
|
|
1706
|
+
options,
|
|
1707
|
+
},
|
|
1708
|
+
{
|
|
1709
|
+
appendMetricsEvent,
|
|
1710
|
+
changeDirAbs,
|
|
1711
|
+
computeChangeNextAdvice,
|
|
1712
|
+
computeChangeStatus,
|
|
1713
|
+
nowIsoUtc,
|
|
1714
|
+
planVerifyHint,
|
|
1715
|
+
renderChangeStateMarkdown,
|
|
1716
|
+
writeText,
|
|
1717
|
+
},
|
|
1718
|
+
);
|
|
1719
|
+
process.stdout.write(result.output);
|
|
2615
1720
|
}
|
|
2616
1721
|
|
|
2617
1722
|
/**
|
|
@@ -2626,98 +1731,18 @@ export async function metricsSummaryCommand(options) {
|
|
|
2626
1731
|
const sinceRaw = String(options.since || "").trim();
|
|
2627
1732
|
const since = sinceRaw ? new Date(`${sinceRaw}T00:00:00Z`).getTime() : 0;
|
|
2628
1733
|
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)"}`);
|
|
1734
|
+
const output = await renderMetricsSummaryOutput(
|
|
1735
|
+
{
|
|
1736
|
+
gitRoot,
|
|
1737
|
+
sinceRaw,
|
|
1738
|
+
since,
|
|
1739
|
+
},
|
|
1740
|
+
{
|
|
1741
|
+
pathExists,
|
|
1742
|
+
readText,
|
|
1743
|
+
},
|
|
1744
|
+
);
|
|
1745
|
+
process.stdout.write(output);
|
|
2721
1746
|
}
|
|
2722
1747
|
|
|
2723
1748
|
/**
|
|
@@ -2731,201 +1756,38 @@ export async function changeEvidenceCommand(options) {
|
|
|
2731
1756
|
|
|
2732
1757
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "evidence" });
|
|
2733
1758
|
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,
|
|
1759
|
+
const result = await runChangeEvidenceCommand(
|
|
1760
|
+
{
|
|
1761
|
+
gitRoot,
|
|
1762
|
+
changeId,
|
|
1763
|
+
options,
|
|
2770
1764
|
},
|
|
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
|
-
}
|
|
1765
|
+
{
|
|
1766
|
+
changeDirAbs,
|
|
1767
|
+
fileState,
|
|
1768
|
+
pathExists,
|
|
1769
|
+
runChangeEvidenceWorkflow,
|
|
1770
|
+
workflowDeps: {
|
|
1771
|
+
appendMetricsEvent,
|
|
1772
|
+
computeChangeStatus,
|
|
1773
|
+
currentBranch,
|
|
1774
|
+
ensureDir,
|
|
1775
|
+
extractId,
|
|
1776
|
+
nowIsoUtc,
|
|
1777
|
+
nowStampUtc,
|
|
1778
|
+
pathExists,
|
|
1779
|
+
planVerifyHint,
|
|
1780
|
+
readText,
|
|
1781
|
+
splitDeclaredValues,
|
|
1782
|
+
validateChangeArtifacts,
|
|
1783
|
+
writeText,
|
|
1784
|
+
UserError,
|
|
1785
|
+
},
|
|
1786
|
+
UserError,
|
|
1787
|
+
},
|
|
1788
|
+
);
|
|
1789
|
+
process.stdout.write(result.output);
|
|
1790
|
+
if (result.errorToThrow) throw result.errorToThrow;
|
|
2929
1791
|
}
|
|
2930
1792
|
|
|
2931
1793
|
/**
|
|
@@ -2939,26 +1801,21 @@ export async function changeValidateCommand(options) {
|
|
|
2939
1801
|
|
|
2940
1802
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "validate" });
|
|
2941
1803
|
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})`);
|
|
1804
|
+
const result = await runChangeValidateCommand(
|
|
1805
|
+
{
|
|
1806
|
+
gitRoot,
|
|
1807
|
+
changeId,
|
|
1808
|
+
options,
|
|
1809
|
+
},
|
|
1810
|
+
{
|
|
1811
|
+
appendMetricsEvent,
|
|
1812
|
+
validateChangeArtifacts,
|
|
1813
|
+
UserError,
|
|
1814
|
+
},
|
|
1815
|
+
);
|
|
1816
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
1817
|
+
if (result.errorToThrow) throw result.errorToThrow;
|
|
1818
|
+
process.stdout.write(result.output);
|
|
2962
1819
|
}
|
|
2963
1820
|
|
|
2964
1821
|
/**
|
|
@@ -2972,100 +1829,30 @@ export async function changeSyncCommand(options) {
|
|
|
2972
1829
|
|
|
2973
1830
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "sync" });
|
|
2974
1831
|
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
|
-
}
|
|
1832
|
+
const result = await runChangeSyncCommand(
|
|
1833
|
+
{
|
|
1834
|
+
gitRoot,
|
|
1835
|
+
changeId,
|
|
1836
|
+
},
|
|
1837
|
+
{
|
|
1838
|
+
changeDirAbs,
|
|
1839
|
+
pathExists,
|
|
1840
|
+
runChangeSyncWorkflow,
|
|
1841
|
+
workflowDeps: {
|
|
1842
|
+
appendMetricsEvent,
|
|
1843
|
+
ensureDir,
|
|
1844
|
+
nowIsoUtc,
|
|
1845
|
+
nowStampUtc,
|
|
1846
|
+
nowUnixSeconds,
|
|
1847
|
+
pathExists,
|
|
1848
|
+
readText,
|
|
1849
|
+
snapshotTruthFiles,
|
|
1850
|
+
writeText,
|
|
1851
|
+
},
|
|
1852
|
+
UserError,
|
|
1853
|
+
},
|
|
1854
|
+
);
|
|
1855
|
+
process.stdout.write(result.output);
|
|
3069
1856
|
}
|
|
3070
1857
|
|
|
3071
1858
|
/**
|
|
@@ -3079,70 +1866,33 @@ export async function changeArchiveCommand(options) {
|
|
|
3079
1866
|
|
|
3080
1867
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "archive" });
|
|
3081
1868
|
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)}`);
|
|
1869
|
+
const result = await runChangeArchiveCommand(
|
|
1870
|
+
{
|
|
1871
|
+
gitRoot,
|
|
1872
|
+
changeId,
|
|
1873
|
+
options,
|
|
1874
|
+
},
|
|
1875
|
+
{
|
|
1876
|
+
changeDirAbs,
|
|
1877
|
+
pathExists,
|
|
1878
|
+
runChangeArchiveWorkflow,
|
|
1879
|
+
workflowDeps: {
|
|
1880
|
+
appendMetricsEvent,
|
|
1881
|
+
changeValidateCommand,
|
|
1882
|
+
ensureDir,
|
|
1883
|
+
generateHandoffContent,
|
|
1884
|
+
nowIsoUtc,
|
|
1885
|
+
nowStampUtc,
|
|
1886
|
+
nowUnixSeconds,
|
|
1887
|
+
pathExists,
|
|
1888
|
+
readText,
|
|
1889
|
+
snapshotTruthFiles,
|
|
1890
|
+
todayLocal,
|
|
1891
|
+
writeText,
|
|
1892
|
+
UserError,
|
|
1893
|
+
},
|
|
1894
|
+
UserError,
|
|
1895
|
+
},
|
|
1896
|
+
);
|
|
1897
|
+
process.stdout.write(result.output);
|
|
3148
1898
|
}
|