@aipper/aiws 0.0.23 → 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 +45 -2
- 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 +436 -1291
- package/src/commands/dashboard.js +38 -12
- package/src/commands/hooks-install.js +1 -2
- package/src/commands/hooks-status.js +9 -1
- package/src/commands/opencode-status.js +61 -0
- package/src/commands/validate.js +1 -1
- package/src/dashboard/app.js +205 -2
- package/src/dashboard/index.html +5 -2
- package/src/dashboard/style.css +86 -0
- package/src/governance.js +157 -0
- package/src/json-schema-lite.js +164 -0
- package/src/opencode-env.js +76 -0
- package/src/spec.js +131 -0
package/src/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
|
*/
|
|
@@ -109,63 +163,7 @@ async function checkGitClean(gitRoot) {
|
|
|
109
163
|
|
|
110
164
|
/**
|
|
111
165
|
* @param {string} gitRoot
|
|
112
|
-
* @returns {Promise<{
|
|
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
|
-
/**
|
|
167
|
-
* @param {string} gitRoot
|
|
168
|
-
* @returns {Promise<Array<{ worktree: string, head: string, branch: string }>>}
|
|
166
|
+
* @returns {Promise<Array<{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string }>>}
|
|
169
167
|
*/
|
|
170
168
|
async function listGitWorktrees(gitRoot) {
|
|
171
169
|
const res = await runCommand("git", ["worktree", "list", "--porcelain"], { cwd: gitRoot });
|
|
@@ -176,16 +174,16 @@ async function listGitWorktrees(gitRoot) {
|
|
|
176
174
|
.split(/\r?\n/)
|
|
177
175
|
.map((l) => l.trimEnd());
|
|
178
176
|
|
|
179
|
-
/** @type {Array<{ worktree: string, head: string, branch: string }>} */
|
|
177
|
+
/** @type {Array<{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string }>} */
|
|
180
178
|
const out = [];
|
|
181
|
-
/** @type {{ worktree: string, head: string, branch: string } | null} */
|
|
179
|
+
/** @type {{ worktree: string, head: string, branch: string, locked: boolean, lockReason: string } | null} */
|
|
182
180
|
let cur = null;
|
|
183
181
|
|
|
184
182
|
for (const line of lines) {
|
|
185
183
|
if (!line) continue;
|
|
186
184
|
if (line.startsWith("worktree ")) {
|
|
187
185
|
if (cur) out.push(cur);
|
|
188
|
-
cur = { worktree: path.resolve(line.slice("worktree ".length).trim()), head: "", branch: "" };
|
|
186
|
+
cur = { worktree: path.resolve(line.slice("worktree ".length).trim()), head: "", branch: "", locked: false, lockReason: "" };
|
|
189
187
|
continue;
|
|
190
188
|
}
|
|
191
189
|
if (!cur) continue;
|
|
@@ -197,26 +195,16 @@ async function listGitWorktrees(gitRoot) {
|
|
|
197
195
|
cur.branch = line.slice("branch ".length).trim();
|
|
198
196
|
continue;
|
|
199
197
|
}
|
|
198
|
+
if (line === "locked" || line.startsWith("locked ")) {
|
|
199
|
+
cur.locked = true;
|
|
200
|
+
cur.lockReason = line === "locked" ? "" : line.slice("locked ".length).trim();
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
200
203
|
}
|
|
201
204
|
if (cur) out.push(cur);
|
|
202
205
|
return out;
|
|
203
206
|
}
|
|
204
207
|
|
|
205
|
-
/**
|
|
206
|
-
* @param {string} gitRoot
|
|
207
|
-
* @param {string | undefined} worktreeDir
|
|
208
|
-
* @param {string} changeId
|
|
209
|
-
*/
|
|
210
|
-
function resolveDefaultWorktreeDir(gitRoot, worktreeDir, changeId) {
|
|
211
|
-
const parent = path.dirname(gitRoot);
|
|
212
|
-
const raw = String(worktreeDir || "").trim();
|
|
213
|
-
if (raw) {
|
|
214
|
-
return path.isAbsolute(raw) ? raw : path.resolve(parent, raw);
|
|
215
|
-
}
|
|
216
|
-
const repoName = path.basename(gitRoot);
|
|
217
|
-
return path.resolve(parent, `${repoName}-${changeId}`);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
208
|
/**
|
|
221
209
|
* @param {string} branch
|
|
222
210
|
* @returns {string}
|
|
@@ -320,13 +308,6 @@ function splitDeclaredValues(s) {
|
|
|
320
308
|
.filter(Boolean);
|
|
321
309
|
}
|
|
322
310
|
|
|
323
|
-
/**
|
|
324
|
-
* @param {string} p
|
|
325
|
-
*/
|
|
326
|
-
function normalizeSlashes(p) {
|
|
327
|
-
return String(p || "").replaceAll("\\", "/");
|
|
328
|
-
}
|
|
329
|
-
|
|
330
311
|
/**
|
|
331
312
|
* Extract bullet lines under a markdown "## ..." heading.
|
|
332
313
|
*
|
|
@@ -477,6 +458,7 @@ async function generateHandoffContent(gitRoot, changeDir, changeId) {
|
|
|
477
458
|
let baseBranch = "main";
|
|
478
459
|
/** @type {string[]} */
|
|
479
460
|
const decisions = [];
|
|
461
|
+
const collaboration = await collectCollaborationArtifacts(gitRoot, changeDir);
|
|
480
462
|
|
|
481
463
|
if (await pathExists(proposalPath)) {
|
|
482
464
|
const text = await readText(proposalPath);
|
|
@@ -568,6 +550,21 @@ async function generateHandoffContent(gitRoot, changeDir, changeId) {
|
|
|
568
550
|
lines.push("- (missing) add decisions to changes/<id>/design.md#Decisions");
|
|
569
551
|
}
|
|
570
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
|
+
|
|
571
568
|
lines.push("");
|
|
572
569
|
lines.push("## 下一步建议");
|
|
573
570
|
lines.push("");
|
|
@@ -590,149 +587,6 @@ async function generateHandoffContent(gitRoot, changeDir, changeId) {
|
|
|
590
587
|
return lines.join("\n") + "\n";
|
|
591
588
|
}
|
|
592
589
|
|
|
593
|
-
/**
|
|
594
|
-
* Evidence_Path is user-declared; we treat it as best-effort hints, not hard gates.
|
|
595
|
-
*
|
|
596
|
-
* @param {string} gitRoot
|
|
597
|
-
* @param {string} changeId
|
|
598
|
-
* @param {string[]} evidencePaths
|
|
599
|
-
* @returns {Promise<{
|
|
600
|
-
* entries: Array<{rel: string, abs: string, exists: boolean, kind: string, isAbsolute: boolean}>,
|
|
601
|
-
* counts: { total: number, exists: number, missing: number, tmp: number, persistent: number, other: number, absolute: number },
|
|
602
|
-
* missing: string[]
|
|
603
|
-
* }>}
|
|
604
|
-
*/
|
|
605
|
-
async function analyzeEvidencePaths(gitRoot, changeId, evidencePaths) {
|
|
606
|
-
const changePrefix = `changes/${changeId}/`;
|
|
607
|
-
const evidencePrefix = `changes/${changeId}/evidence/`;
|
|
608
|
-
const reviewPrefix = `changes/${changeId}/review/`;
|
|
609
|
-
|
|
610
|
-
const entries = await Promise.all(
|
|
611
|
-
(evidencePaths || []).map(async (raw) => {
|
|
612
|
-
const rel = String(raw || "").trim();
|
|
613
|
-
const isAbsolute = path.isAbsolute(rel);
|
|
614
|
-
const abs = isAbsolute ? rel : path.join(gitRoot, rel);
|
|
615
|
-
const exists = rel ? await pathExists(abs) : false;
|
|
616
|
-
|
|
617
|
-
const relNorm = normalizeSlashes(rel);
|
|
618
|
-
let kind = "other";
|
|
619
|
-
if (relNorm.startsWith(".agentdocs/tmp/") || relNorm.startsWith(".agentdocs/")) kind = "tmp";
|
|
620
|
-
else if (relNorm.startsWith(evidencePrefix) || relNorm.startsWith(reviewPrefix) || relNorm.startsWith(changePrefix)) kind = "persistent";
|
|
621
|
-
|
|
622
|
-
return { rel, abs: path.resolve(abs), exists, kind, isAbsolute };
|
|
623
|
-
}),
|
|
624
|
-
);
|
|
625
|
-
|
|
626
|
-
const missing = entries.filter((e) => e.rel && !e.exists).map((e) => e.rel);
|
|
627
|
-
const counts = {
|
|
628
|
-
total: entries.filter((e) => e.rel).length,
|
|
629
|
-
exists: entries.filter((e) => e.rel && e.exists).length,
|
|
630
|
-
missing: missing.length,
|
|
631
|
-
tmp: entries.filter((e) => e.rel && e.kind === "tmp").length,
|
|
632
|
-
persistent: entries.filter((e) => e.rel && e.kind === "persistent").length,
|
|
633
|
-
other: entries.filter((e) => e.rel && e.kind === "other").length,
|
|
634
|
-
absolute: entries.filter((e) => e.rel && e.isAbsolute).length,
|
|
635
|
-
};
|
|
636
|
-
|
|
637
|
-
return { entries: entries.filter((e) => e.rel), counts, missing };
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/**
|
|
641
|
-
* @param {string[]} a
|
|
642
|
-
* @param {string[]} b
|
|
643
|
-
*/
|
|
644
|
-
function mergeDeclaredValues(a, b) {
|
|
645
|
-
const seen = new Set();
|
|
646
|
-
/** @type {string[]} */
|
|
647
|
-
const out = [];
|
|
648
|
-
for (const raw of [...(a || []), ...(b || [])]) {
|
|
649
|
-
const v = String(raw || "").trim().replace(/^`+/, "").replace(/`+$/, "").trim();
|
|
650
|
-
if (!v) continue;
|
|
651
|
-
if (seen.has(v)) continue;
|
|
652
|
-
seen.add(v);
|
|
653
|
-
out.push(v);
|
|
654
|
-
}
|
|
655
|
-
return out;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
/**
|
|
659
|
-
* @param {string[]} values
|
|
660
|
-
*/
|
|
661
|
-
function formatDeclaredValues(values) {
|
|
662
|
-
return (values || []).join(", ");
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/**
|
|
666
|
-
* Replace or insert a simple "ID: value" style line in Markdown.
|
|
667
|
-
*
|
|
668
|
-
* @param {string} text
|
|
669
|
-
* @param {string[]} labelCandidates
|
|
670
|
-
* @param {string} value
|
|
671
|
-
* @param {{ insertAfterLabels?: string[] }} options
|
|
672
|
-
*/
|
|
673
|
-
function upsertIdLine(text, labelCandidates, value, options) {
|
|
674
|
-
const src = String(text || "");
|
|
675
|
-
const labels = Array.isArray(labelCandidates) ? labelCandidates : [];
|
|
676
|
-
for (const label of labels) {
|
|
677
|
-
const re = new RegExp(`^(.*${escapeRegExp(label)}.*?[:=][ \\t]*)(.*)$`, "m");
|
|
678
|
-
const m = re.exec(src);
|
|
679
|
-
if (!m) continue;
|
|
680
|
-
const prefix = m[1] || "";
|
|
681
|
-
const replaced = src.replace(re, `${prefix}${value}`);
|
|
682
|
-
return { ok: true, changed: replaced !== src, text: replaced, mode: "replaced", label };
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
// Insert near the top (best-effort), after a nearby label if present.
|
|
686
|
-
const insertAfter = Array.isArray(options?.insertAfterLabels) ? options.insertAfterLabels : [];
|
|
687
|
-
const lines = src.split(/\r?\n/);
|
|
688
|
-
let idx = 0;
|
|
689
|
-
for (let i = 0; i < Math.min(lines.length, 80); i++) {
|
|
690
|
-
const line = lines[i] || "";
|
|
691
|
-
if (insertAfter.some((l) => line.includes(l))) idx = i + 1;
|
|
692
|
-
}
|
|
693
|
-
const insertedLabel = labels[0] || "Evidence_Path";
|
|
694
|
-
const newLine = `- ${insertedLabel}: ${value}`;
|
|
695
|
-
lines.splice(idx, 0, newLine);
|
|
696
|
-
const inserted = lines.join("\n");
|
|
697
|
-
return { ok: true, changed: inserted !== src, text: inserted, mode: "inserted", label: labels[0] || "" };
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
/**
|
|
701
|
-
* Special-case insertion for proposal.md template style.
|
|
702
|
-
*
|
|
703
|
-
* @param {string} proposalText
|
|
704
|
-
* @param {string} value
|
|
705
|
-
*/
|
|
706
|
-
function upsertProposalEvidencePath(proposalText, value) {
|
|
707
|
-
const src = String(proposalText || "");
|
|
708
|
-
const re = new RegExp(`^(.*${escapeRegExp("Evidence_Path")}.*?[:=][ \\t]*)(.*)$`, "m");
|
|
709
|
-
if (re.test(src)) {
|
|
710
|
-
const m = re.exec(src);
|
|
711
|
-
const prefix = m?.[1] || "";
|
|
712
|
-
const out = src.replace(re, `${prefix}${value}`);
|
|
713
|
-
return { changed: out !== src, text: out, mode: "replaced" };
|
|
714
|
-
}
|
|
715
|
-
// Insert right after Plan_File binding if possible.
|
|
716
|
-
const lines = src.split(/\r?\n/);
|
|
717
|
-
let idx = 0;
|
|
718
|
-
for (let i = 0; i < Math.min(lines.length, 120); i++) {
|
|
719
|
-
if ((lines[i] || "").includes("Plan_File")) {
|
|
720
|
-
idx = i + 1;
|
|
721
|
-
break;
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
lines.splice(idx, 0, `- \`Evidence_Path\` = ${value}`);
|
|
725
|
-
const inserted = lines.join("\n");
|
|
726
|
-
return { changed: inserted !== src, text: inserted, mode: "inserted" };
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
/**
|
|
730
|
-
* @param {string} changeId
|
|
731
|
-
*/
|
|
732
|
-
function planVerifyHint(changeId) {
|
|
733
|
-
return `执行前质量门(优先):\`aiws change validate ${changeId} --strict\`(AI 工具中等价于 \`$ws-plan-verify\`)`;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
590
|
/**
|
|
737
591
|
* @param {string[]} blockersStrict
|
|
738
592
|
* @param {{ unchecked: number }} tasks
|
|
@@ -755,10 +609,23 @@ export async function computeChangeStatus(gitRoot, changeId) {
|
|
|
755
609
|
|
|
756
610
|
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
757
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
|
+
});
|
|
758
619
|
|
|
759
620
|
const proposal = await fileState(changeDir, "proposal.md");
|
|
760
621
|
const tasks = await fileState(changeDir, "tasks.md");
|
|
761
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 });
|
|
762
629
|
|
|
763
630
|
const metaPath = path.join(changeDir, ".ws-change.json");
|
|
764
631
|
let metaState = "missing";
|
|
@@ -839,12 +706,15 @@ export async function computeChangeStatus(gitRoot, changeId) {
|
|
|
839
706
|
blockersArchive.push(...blockersStrict);
|
|
840
707
|
if (taskProgress.unchecked > 0) blockersArchive.push(`tasks.md still has unchecked tasks (${taskProgress.unchecked} items)`);
|
|
841
708
|
const phase = inferChangePhase(blockersStrict, taskProgress);
|
|
709
|
+
const lifecycle = summarizeLifecycleSignals(metrics);
|
|
710
|
+
const scopeGate = computeScopeGate(lifecycle);
|
|
842
711
|
|
|
843
|
-
|
|
712
|
+
const baseStatus = {
|
|
844
713
|
ok: true,
|
|
845
714
|
changeId,
|
|
846
715
|
dir: path.relative(gitRoot, changeDir),
|
|
847
716
|
phase,
|
|
717
|
+
context,
|
|
848
718
|
metaState,
|
|
849
719
|
reqId,
|
|
850
720
|
probId,
|
|
@@ -853,7 +723,16 @@ export async function computeChangeStatus(gitRoot, changeId) {
|
|
|
853
723
|
planFile,
|
|
854
724
|
evidencePaths,
|
|
855
725
|
},
|
|
726
|
+
scopeGate,
|
|
856
727
|
evidence,
|
|
728
|
+
collaboration,
|
|
729
|
+
reviewSignals,
|
|
730
|
+
reviewGates,
|
|
731
|
+
git,
|
|
732
|
+
repo: {
|
|
733
|
+
submodules: submodules.length,
|
|
734
|
+
},
|
|
735
|
+
lifecycle,
|
|
857
736
|
tasks: taskProgress,
|
|
858
737
|
baselineLabel,
|
|
859
738
|
baselineAt,
|
|
@@ -865,54 +744,14 @@ export async function computeChangeStatus(gitRoot, changeId) {
|
|
|
865
744
|
dev: "质量门通过后再进入编码:在 AI 工具中运行 `$ws-dev`",
|
|
866
745
|
},
|
|
867
746
|
};
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
const warnings = [];
|
|
876
|
-
for (const raw of text.split("\n")) {
|
|
877
|
-
const line = raw.trim();
|
|
878
|
-
if (!line) continue;
|
|
879
|
-
if (line.startsWith("error: ")) errors.push(line.replace(/^error:\s*/, ""));
|
|
880
|
-
else if (line.startsWith("warn: ")) warnings.push(line.replace(/^warn:\s*/, ""));
|
|
881
|
-
}
|
|
882
|
-
return { errors, warnings, raw: text.trim() };
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
function classifyCheckMessage(msg) {
|
|
886
|
-
const s = String(msg || "");
|
|
887
|
-
if (s.includes("truth file") || s.includes("truth drift") || s.includes("allow-truth-drift")) return "truth_drift";
|
|
888
|
-
if (s.includes("WS:TODO") || s.includes("unrendered template placeholders")) return "placeholders";
|
|
889
|
-
if (s.includes("missing:") || s.includes("empty:") || s.includes("Missing change dir")) return "missing_files";
|
|
890
|
-
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";
|
|
891
|
-
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")) {
|
|
892
|
-
return "plan_quality";
|
|
893
|
-
}
|
|
894
|
-
return "other";
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
function groupCheckMessages(errors, warnings) {
|
|
898
|
-
const groups = {
|
|
899
|
-
truth_drift: { errors: [], warnings: [] },
|
|
900
|
-
missing_files: { errors: [], warnings: [] },
|
|
901
|
-
placeholders: { errors: [], warnings: [] },
|
|
902
|
-
bindings: { errors: [], warnings: [] },
|
|
903
|
-
plan_quality: { errors: [], warnings: [] },
|
|
904
|
-
other: { errors: [], warnings: [] },
|
|
747
|
+
const governance = await inferChangeGovernance(baseStatus);
|
|
748
|
+
return {
|
|
749
|
+
...baseStatus,
|
|
750
|
+
governance: {
|
|
751
|
+
...governance,
|
|
752
|
+
warning: context.warning || "",
|
|
753
|
+
},
|
|
905
754
|
};
|
|
906
|
-
|
|
907
|
-
for (const m of errors || []) {
|
|
908
|
-
const k = classifyCheckMessage(m);
|
|
909
|
-
groups[k].errors.push(m);
|
|
910
|
-
}
|
|
911
|
-
for (const m of warnings || []) {
|
|
912
|
-
const k = classifyCheckMessage(m);
|
|
913
|
-
groups[k].warnings.push(m);
|
|
914
|
-
}
|
|
915
|
-
return groups;
|
|
916
755
|
}
|
|
917
756
|
|
|
918
757
|
/**
|
|
@@ -923,29 +762,14 @@ function groupCheckMessages(errors, warnings) {
|
|
|
923
762
|
* @param {{ strict: boolean, allowTruthDrift: boolean, checkEvidence?: boolean, checkScope?: boolean }} options
|
|
924
763
|
*/
|
|
925
764
|
export async function validateChangeArtifacts(gitRoot, changeId, options) {
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
if (options.checkScope) args.push("--check-scope");
|
|
935
|
-
|
|
936
|
-
const res = await runPython(gitRoot, ["-u", ...args]);
|
|
937
|
-
const parsed = parseChangeCheckerOutput(res.stdout, res.stderr);
|
|
938
|
-
return {
|
|
939
|
-
ok: res.code === 0,
|
|
940
|
-
changeId,
|
|
941
|
-
strict: options.strict === true,
|
|
942
|
-
allowTruthDrift: options.allowTruthDrift === true,
|
|
943
|
-
exitCode: res.code,
|
|
944
|
-
errors: parsed.errors,
|
|
945
|
-
warnings: parsed.warnings,
|
|
946
|
-
groups: groupCheckMessages(parsed.errors, parsed.warnings),
|
|
947
|
-
raw: parsed.raw,
|
|
948
|
-
};
|
|
765
|
+
return validateChangeArtifactsModel(gitRoot, changeId, options, {
|
|
766
|
+
assertValidChangeId,
|
|
767
|
+
collectFinishGateErrors,
|
|
768
|
+
computeChangeStatus,
|
|
769
|
+
ensureTruthFiles,
|
|
770
|
+
resolveWsChangeChecker,
|
|
771
|
+
runPython,
|
|
772
|
+
});
|
|
949
773
|
}
|
|
950
774
|
|
|
951
775
|
/**
|
|
@@ -1015,39 +839,6 @@ async function appendMetricsEvent(gitRoot, changeId, type, payload) {
|
|
|
1015
839
|
await writeText(metricsAbs, JSON.stringify(cur, null, 2) + "\n");
|
|
1016
840
|
}
|
|
1017
841
|
|
|
1018
|
-
/**
|
|
1019
|
-
* @param {string} absDir
|
|
1020
|
-
* @param {(name: string) => boolean} predicate
|
|
1021
|
-
*/
|
|
1022
|
-
async function findLatestFileByMtime(absDir, predicate) {
|
|
1023
|
-
if (!(await pathExists(absDir))) return "";
|
|
1024
|
-
const entries = await fs.readdir(absDir, { withFileTypes: true });
|
|
1025
|
-
/** @type {{ abs: string, mtimeMs: number } | null} */
|
|
1026
|
-
let best = null;
|
|
1027
|
-
for (const e of entries) {
|
|
1028
|
-
if (!e.isFile()) continue;
|
|
1029
|
-
if (!predicate(e.name)) continue;
|
|
1030
|
-
const abs = path.join(absDir, e.name);
|
|
1031
|
-
let st;
|
|
1032
|
-
try {
|
|
1033
|
-
st = await fs.stat(abs);
|
|
1034
|
-
} catch {
|
|
1035
|
-
continue;
|
|
1036
|
-
}
|
|
1037
|
-
const mtimeMs = Number(st.mtimeMs || 0);
|
|
1038
|
-
if (!best || mtimeMs > best.mtimeMs) best = { abs, mtimeMs };
|
|
1039
|
-
}
|
|
1040
|
-
return best ? best.abs : "";
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
/**
|
|
1044
|
-
* @param {string} gitRoot
|
|
1045
|
-
* @param {string} absPath
|
|
1046
|
-
*/
|
|
1047
|
-
function relFromRoot(gitRoot, absPath) {
|
|
1048
|
-
return normalizeSlashes(path.relative(gitRoot, absPath));
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
842
|
/**
|
|
1052
843
|
* @param {string} gitRoot
|
|
1053
844
|
*/
|
|
@@ -1141,46 +932,6 @@ async function resolveChangeId(gitRoot, changeId, options) {
|
|
|
1141
932
|
throw new UserError(`usage: aiws change ${options.command} <change-id> (or switch to branch change/<change-id>)`);
|
|
1142
933
|
}
|
|
1143
934
|
|
|
1144
|
-
/**
|
|
1145
|
-
* @param {any} meta
|
|
1146
|
-
*/
|
|
1147
|
-
function baselineFromMeta(meta) {
|
|
1148
|
-
const createdTruth = meta?.base_truth_files && typeof meta.base_truth_files === "object" ? meta.base_truth_files : {};
|
|
1149
|
-
const syncedTruth = meta?.synced_truth_files && typeof meta.synced_truth_files === "object" ? meta.synced_truth_files : {};
|
|
1150
|
-
if (Object.keys(syncedTruth).length > 0) {
|
|
1151
|
-
return { baseline: syncedTruth, label: "last sync", at: String(meta?.synced_at || "") };
|
|
1152
|
-
}
|
|
1153
|
-
return { baseline: createdTruth, label: "creation", at: String(meta?.created_at || "") };
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
/**
|
|
1157
|
-
* @param {Record<string, string | null>} curTruth
|
|
1158
|
-
* @param {any} baseline
|
|
1159
|
-
* @returns {{ baselineLabel: string, baselineAt: string, driftFiles: string[] }}
|
|
1160
|
-
*/
|
|
1161
|
-
function truthDrift(curTruth, baseline) {
|
|
1162
|
-
/** @type {string[]} */
|
|
1163
|
-
const driftFiles = [];
|
|
1164
|
-
|
|
1165
|
-
const createdTruth = baseline?.base_truth_files && typeof baseline.base_truth_files === "object" ? baseline.base_truth_files : {};
|
|
1166
|
-
const syncedTruth = baseline?.synced_truth_files && typeof baseline.synced_truth_files === "object" ? baseline.synced_truth_files : {};
|
|
1167
|
-
const effective = Object.keys(syncedTruth).length > 0 ? syncedTruth : createdTruth;
|
|
1168
|
-
const baselineLabel = Object.keys(syncedTruth).length > 0 ? "sync" : "creation";
|
|
1169
|
-
const baselineAt = Object.keys(syncedTruth).length > 0 ? String(baseline?.synced_at || "") : String(baseline?.created_at || "");
|
|
1170
|
-
|
|
1171
|
-
for (const [rel, info] of Object.entries(effective || {})) {
|
|
1172
|
-
const baseSha = info && typeof info === "object" ? String(info.sha256 || "") : "";
|
|
1173
|
-
const curSha = curTruth[rel] ?? null;
|
|
1174
|
-
if (curSha == null) {
|
|
1175
|
-
driftFiles.push(rel);
|
|
1176
|
-
continue;
|
|
1177
|
-
}
|
|
1178
|
-
if (baseSha && curSha !== baseSha) driftFiles.push(rel);
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
return { baselineLabel, baselineAt, driftFiles };
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
935
|
/**
|
|
1185
936
|
* @param {string} gitRoot
|
|
1186
937
|
* @param {string} changeId
|
|
@@ -1356,6 +1107,10 @@ async function changeNewAtGitRoot(gitRoot, options) {
|
|
|
1356
1107
|
const changeDir = path.join(changeRoot, changeId);
|
|
1357
1108
|
if (await pathExists(changeDir)) throw new UserError(`Change already exists: ${changeDir}`);
|
|
1358
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"));
|
|
1359
1114
|
|
|
1360
1115
|
const templates = await resolveChangeTemplatesDir(gitRoot);
|
|
1361
1116
|
const proposalTpl = await readText(path.join(templates.templateDir, "proposal.md"));
|
|
@@ -1401,6 +1156,9 @@ async function changeNewAtGitRoot(gitRoot, options) {
|
|
|
1401
1156
|
console.log(` - edit: changes/${changeId}/proposal.md`);
|
|
1402
1157
|
console.log(` - edit: changes/${changeId}/tasks.md`);
|
|
1403
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/ (多审查结果)`);
|
|
1404
1162
|
console.log(` - validate: aiws change validate ${changeId}`);
|
|
1405
1163
|
}
|
|
1406
1164
|
|
|
@@ -1448,7 +1206,7 @@ export async function changeStartCommand(options) {
|
|
|
1448
1206
|
let effectiveWorktree = options.worktree === true;
|
|
1449
1207
|
|
|
1450
1208
|
if (hasGitmodules && !effectiveNoSwitch && !effectiveWorktree && !effectiveForceSwitch) {
|
|
1451
|
-
const prereq = await checkWorktreePrereqs(gitRoot);
|
|
1209
|
+
const prereq = await checkWorktreePrereqs(gitRoot, { hasHeadCommit, runCommand });
|
|
1452
1210
|
if (prereq.ok) {
|
|
1453
1211
|
effectiveWorktree = true;
|
|
1454
1212
|
console.error("warn: .gitmodules detected (git submodules). defaulting to --worktree to avoid switching the superproject branch.");
|
|
@@ -1488,7 +1246,7 @@ export async function changeStartCommand(options) {
|
|
|
1488
1246
|
}
|
|
1489
1247
|
|
|
1490
1248
|
if (effectiveWorktree) {
|
|
1491
|
-
const prereq = await checkWorktreePrereqs(gitRoot);
|
|
1249
|
+
const prereq = await checkWorktreePrereqs(gitRoot, { hasHeadCommit, runCommand });
|
|
1492
1250
|
if (!prereq.ok) {
|
|
1493
1251
|
if (prereq.reason === "no_commit") {
|
|
1494
1252
|
throw new UserError("change start --worktree requires a repo with at least one commit.", { details: "Hint: make an initial commit, then retry." });
|
|
@@ -1561,7 +1319,7 @@ export async function changeStartCommand(options) {
|
|
|
1561
1319
|
baseBranch: startFromBranch && startFromBranch !== branch ? startFromBranch : undefined,
|
|
1562
1320
|
});
|
|
1563
1321
|
} else {
|
|
1564
|
-
await maybeRecordBaseBranch(changeDir, startFromBranch, branch);
|
|
1322
|
+
await maybeRecordBaseBranch(changeDir, startFromBranch, branch, { nowIsoUtc, pathExists, readText, writeText });
|
|
1565
1323
|
}
|
|
1566
1324
|
|
|
1567
1325
|
// Worktree mode: if this repo declares submodules, always initialize them in the new worktree.
|
|
@@ -1624,48 +1382,9 @@ export async function changeStartCommand(options) {
|
|
|
1624
1382
|
let branchReady = hasBranch;
|
|
1625
1383
|
|
|
1626
1384
|
if (effectiveNoSwitch) {
|
|
1627
|
-
|
|
1628
|
-
if (!headReady) {
|
|
1629
|
-
// On an unborn branch, `git branch <name>` fails because HEAD doesn't point to a commit yet.
|
|
1630
|
-
// We still allow initializing change artifacts without switching branches.
|
|
1631
|
-
console.error(`warn: repo has no commits; cannot create branch "${branch}" without switching (skipped)`);
|
|
1632
|
-
branchReady = false;
|
|
1633
|
-
} else {
|
|
1634
|
-
const br = await runCommand("git", ["branch", branch], { cwd: gitRoot });
|
|
1635
|
-
if (br.code !== 0) throw new UserError("Failed to create branch.", { details: br.stderr || br.stdout });
|
|
1636
|
-
branchReady = true;
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1385
|
+
branchReady = await prepareNoSwitchBranch(gitRoot, { branch, hasBranch, headReady }, { runCommand, UserError });
|
|
1639
1386
|
} else {
|
|
1640
|
-
|
|
1641
|
-
let sw = await runCommand("git", ["switch", ...(hasGitmodules ? ["--recurse-submodules"] : []), branch], { cwd: gitRoot });
|
|
1642
|
-
if (sw.code !== 0 && hasGitmodules && outputMentionsRecurseSubmodules(sw)) {
|
|
1643
|
-
console.error("warn: git switch does not support --recurse-submodules; switching without it (submodules may be out of sync).");
|
|
1644
|
-
sw = await runCommand("git", ["switch", branch], { cwd: gitRoot });
|
|
1645
|
-
}
|
|
1646
|
-
if (sw.code !== 0) {
|
|
1647
|
-
let co = await runCommand("git", ["checkout", ...(hasGitmodules ? ["--recurse-submodules"] : []), branch], { cwd: gitRoot });
|
|
1648
|
-
if (co.code !== 0 && hasGitmodules && outputMentionsRecurseSubmodules(co)) {
|
|
1649
|
-
console.error("warn: git checkout does not support --recurse-submodules; checking out without it (submodules may be out of sync).");
|
|
1650
|
-
co = await runCommand("git", ["checkout", branch], { cwd: gitRoot });
|
|
1651
|
-
}
|
|
1652
|
-
if (co.code !== 0) throw new UserError("Failed to switch branch.", { details: co.stderr || co.stdout });
|
|
1653
|
-
}
|
|
1654
|
-
} else {
|
|
1655
|
-
let sw = await runCommand("git", ["switch", ...(hasGitmodules ? ["--recurse-submodules"] : []), "-c", branch], { cwd: gitRoot });
|
|
1656
|
-
if (sw.code !== 0 && hasGitmodules && outputMentionsRecurseSubmodules(sw)) {
|
|
1657
|
-
console.error("warn: git switch does not support --recurse-submodules; switching without it (submodules may be out of sync).");
|
|
1658
|
-
sw = await runCommand("git", ["switch", "-c", branch], { cwd: gitRoot });
|
|
1659
|
-
}
|
|
1660
|
-
if (sw.code !== 0) {
|
|
1661
|
-
let co = await runCommand("git", ["checkout", ...(hasGitmodules ? ["--recurse-submodules"] : []), "-b", branch], { cwd: gitRoot });
|
|
1662
|
-
if (co.code !== 0 && hasGitmodules && outputMentionsRecurseSubmodules(co)) {
|
|
1663
|
-
console.error("warn: git checkout does not support --recurse-submodules; checking out without it (submodules may be out of sync).");
|
|
1664
|
-
co = await runCommand("git", ["checkout", "-b", branch], { cwd: gitRoot });
|
|
1665
|
-
}
|
|
1666
|
-
if (co.code !== 0) throw new UserError("Failed to create branch.", { details: co.stderr || co.stdout });
|
|
1667
|
-
}
|
|
1668
|
-
}
|
|
1387
|
+
await switchOrCreateStartBranch(gitRoot, { branch, hasBranch, hasGitmodules }, { runCommand, UserError });
|
|
1669
1388
|
branchReady = true;
|
|
1670
1389
|
}
|
|
1671
1390
|
|
|
@@ -1678,7 +1397,7 @@ export async function changeStartCommand(options) {
|
|
|
1678
1397
|
baseBranch: startFromBranch && startFromBranch !== branch ? startFromBranch : undefined,
|
|
1679
1398
|
});
|
|
1680
1399
|
} else {
|
|
1681
|
-
await maybeRecordBaseBranch(changeDir, startFromBranch, branch);
|
|
1400
|
+
await maybeRecordBaseBranch(changeDir, startFromBranch, branch, { nowIsoUtc, pathExists, readText, writeText });
|
|
1682
1401
|
}
|
|
1683
1402
|
|
|
1684
1403
|
// Check Depends_On dependencies (non-worktree mode)
|
|
@@ -1726,98 +1445,47 @@ export async function changeStartCommand(options) {
|
|
|
1726
1445
|
/**
|
|
1727
1446
|
* aiws change finish
|
|
1728
1447
|
*
|
|
1729
|
-
* @param {{ changeId?: string, into?: string, base?: string }} options
|
|
1448
|
+
* @param {{ changeId?: string, into?: string, base?: string, push?: boolean, remote?: string }} options
|
|
1730
1449
|
*/
|
|
1731
1450
|
export async function changeFinishCommand(options) {
|
|
1732
1451
|
const gitRoot = await resolveGitRoot(process.cwd());
|
|
1733
1452
|
await ensureTruthFiles(gitRoot);
|
|
1734
1453
|
|
|
1735
|
-
const
|
|
1736
|
-
assertValidChangeId(
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
const hasChangeBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", changeBranchRef], { cwd: gitRoot }).then((r) => r.code === 0);
|
|
1742
|
-
if (!hasChangeBranch) {
|
|
1743
|
-
throw new UserError(`Missing change branch: ${changeBranch}`, {
|
|
1744
|
-
details: "Hint: create it with `aiws change start <change-id>` (or `git switch -c change/<id>`).",
|
|
1745
|
-
});
|
|
1746
|
-
}
|
|
1747
|
-
|
|
1748
|
-
const clean = await checkGitClean(gitRoot);
|
|
1749
|
-
if (!clean.clean) {
|
|
1750
|
-
throw new UserError("Refusing to finish with a dirty working tree.", {
|
|
1751
|
-
details: `${clean.details}\n\nHint: commit or stash your changes, then retry.`,
|
|
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\``,
|
|
1752
1460
|
});
|
|
1753
1461
|
}
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
try {
|
|
1779
|
-
meta = JSON.parse(await readText(metaPath));
|
|
1780
|
-
} catch {
|
|
1781
|
-
meta = null;
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
if (!meta) {
|
|
1785
|
-
const show = await runCommand("git", ["show", `${changeBranch}:changes/${changeId}/.ws-change.json`], { cwd: gitRoot });
|
|
1786
|
-
if (show.code === 0) {
|
|
1787
|
-
try {
|
|
1788
|
-
meta = JSON.parse(String(show.stdout || ""));
|
|
1789
|
-
} catch {
|
|
1790
|
-
meta = null;
|
|
1791
|
-
}
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1794
|
-
const inferred = meta && typeof meta === "object" ? String(meta.base_branch || "").trim() : "";
|
|
1795
|
-
if (!inferred) {
|
|
1796
|
-
throw new UserError("Cannot infer base branch for finish.", {
|
|
1797
|
-
details: "Hint: run from the target branch (e.g. main), or pass: `aiws change finish <id> --into <branch>`.",
|
|
1798
|
-
});
|
|
1799
|
-
}
|
|
1800
|
-
into = inferred;
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
if (into === changeBranch) {
|
|
1805
|
-
throw new UserError("change finish: target branch cannot be the change branch.", { details: `target=${into}` });
|
|
1806
|
-
}
|
|
1807
|
-
|
|
1808
|
-
const worktrees = await listGitWorktrees(gitRoot);
|
|
1809
|
-
const intoRef = `refs/heads/${into}`;
|
|
1810
|
-
const intoWt = worktrees.find((w) => String(w.branch || "") === intoRef);
|
|
1811
|
-
if (intoWt && path.resolve(intoWt.worktree) !== path.resolve(gitRoot)) {
|
|
1812
|
-
throw new UserError("Target branch is checked out in another worktree.", {
|
|
1813
|
-
details: `branch: ${into}\nworktree: ${intoWt.worktree}\n\nHint: run finish in that worktree:\n cd ${intoWt.worktree}\n aiws change finish ${changeId}`,
|
|
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`,
|
|
1814
1486
|
});
|
|
1815
1487
|
}
|
|
1816
|
-
|
|
1817
|
-
const hasIntoBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", intoRef], { cwd: gitRoot }).then((r) => r.code === 0);
|
|
1818
|
-
if (!hasIntoBranch) {
|
|
1819
|
-
throw new UserError("Target branch does not exist.", { details: `branch: ${into}` });
|
|
1820
|
-
}
|
|
1488
|
+
const finishBasePayload = { into, from: changeBranch, push: options.push === true };
|
|
1821
1489
|
|
|
1822
1490
|
if (cur && cur !== into) {
|
|
1823
1491
|
const sw = await runCommand("git", ["switch", into], { cwd: gitRoot });
|
|
@@ -1829,8 +1497,6 @@ export async function changeFinishCommand(options) {
|
|
|
1829
1497
|
|
|
1830
1498
|
const merge = await runCommand("git", ["merge", "--ff-only", changeBranch], { cwd: gitRoot });
|
|
1831
1499
|
if (merge.code !== 0) {
|
|
1832
|
-
const changeRef = changeBranchRef;
|
|
1833
|
-
const changeWt = worktrees.find((w) => String(w.branch || "") === changeRef);
|
|
1834
1500
|
const extra =
|
|
1835
1501
|
changeWt && changeWt.worktree
|
|
1836
1502
|
? `\n\nIf not fast-forward, rebase the change branch then retry:\n cd ${changeWt.worktree}\n git rebase ${into}\n cd ${gitRoot}\n aiws change finish ${changeId}`
|
|
@@ -1838,14 +1504,105 @@ export async function changeFinishCommand(options) {
|
|
|
1838
1504
|
throw new UserError("Failed to fast-forward merge change branch into target.", { details: `${merge.stderr || merge.stdout}${extra}` });
|
|
1839
1505
|
}
|
|
1840
1506
|
|
|
1841
|
-
await appendMetricsEvent(gitRoot, changeId, "
|
|
1507
|
+
await appendMetricsEvent(gitRoot, changeId, "finish_local", finishBasePayload);
|
|
1842
1508
|
|
|
1843
|
-
|
|
1509
|
+
let cleanupNote = "not_requested";
|
|
1510
|
+
let finishCompleted = false;
|
|
1511
|
+
if (options.push === true) {
|
|
1512
|
+
try {
|
|
1513
|
+
const submodulePush = await pushSubmodulesForFinish(gitRoot, changeId, into, changeWt?.worktree, {
|
|
1514
|
+
pathExists,
|
|
1515
|
+
readText,
|
|
1516
|
+
runCommand,
|
|
1517
|
+
UserError,
|
|
1518
|
+
});
|
|
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}` : ""}`);
|
|
1537
|
+
|
|
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
|
+
}
|
|
1564
|
+
} else {
|
|
1565
|
+
cleanupNote = "skipped:no_separate_change_worktree";
|
|
1566
|
+
finishCompleted = true;
|
|
1567
|
+
console.log("cleanup: skipped (no separate change worktree)");
|
|
1568
|
+
}
|
|
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;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
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}`);
|
|
1844
1600
|
console.log(`into: ${into}`);
|
|
1845
1601
|
console.log(`from: ${changeBranch}`);
|
|
1846
|
-
|
|
1602
|
+
|
|
1603
|
+
if (options.push === true && (await listSubmodulesFromGitmodules(gitRoot, { pathExists, runCommand })).length > 0) {
|
|
1847
1604
|
console.log("next:");
|
|
1848
|
-
console.log(" -
|
|
1605
|
+
console.log(" - submodules already synced for finish --push; rerun `git submodule update --init --recursive` only if another worktree changed them later");
|
|
1849
1606
|
}
|
|
1850
1607
|
}
|
|
1851
1608
|
|
|
@@ -1861,70 +1618,21 @@ export async function changeStatusCommand(options) {
|
|
|
1861
1618
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "status" });
|
|
1862
1619
|
const st = await computeChangeStatus(gitRoot, changeId);
|
|
1863
1620
|
const branch = await currentBranch(gitRoot);
|
|
1864
|
-
|
|
1865
|
-
const
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
console.log(`branch: ${branch || "(detached HEAD)"}`);
|
|
1880
|
-
if (wtHint.changeWorktree) console.log(`note: change/<id> checked out in another worktree: ${wtHint.changeWorktree}`);
|
|
1881
|
-
console.log(`meta: ${st.metaState}`);
|
|
1882
|
-
if (st.reqId) console.log(`Req_ID: ${st.reqId}`);
|
|
1883
|
-
if (st.probId) console.log(`Problem_ID: ${st.probId}`);
|
|
1884
|
-
console.log(`tasks: ${st.tasks.done}/${st.tasks.total} (unchecked=${st.tasks.unchecked})`);
|
|
1885
|
-
console.log(`baseline: ${st.baselineLabel}${st.baselineAt ? ` (at=${st.baselineAt})` : ""}`);
|
|
1886
|
-
console.log(`drift: ${st.driftFiles.length > 0 ? st.driftFiles.join(", ") : "(none)"}`);
|
|
1887
|
-
if (st.evidence?.counts) {
|
|
1888
|
-
const c = st.evidence.counts;
|
|
1889
|
-
console.log(`evidence: declared=${c.total} (exists=${c.exists}, missing=${c.missing}, persistent=${c.persistent}, tmp=${c.tmp})`);
|
|
1890
|
-
}
|
|
1891
|
-
|
|
1892
|
-
console.log("");
|
|
1893
|
-
console.log("Blockers (strict):");
|
|
1894
|
-
if (st.blockersStrict.length === 0) console.log("- (none)");
|
|
1895
|
-
else for (const b of st.blockersStrict) console.log(`- ${b}`);
|
|
1896
|
-
|
|
1897
|
-
console.log("");
|
|
1898
|
-
console.log("Blockers (archive):");
|
|
1899
|
-
if (st.blockersArchive.length === 0) console.log("- (none)");
|
|
1900
|
-
else for (const b of st.blockersArchive) console.log(`- ${b}`);
|
|
1901
|
-
|
|
1902
|
-
console.log("");
|
|
1903
|
-
console.log("Next (recommended):");
|
|
1904
|
-
console.log(`- ${planVerifyHint(st.changeId)}`);
|
|
1905
|
-
if (st.blockersStrict.length > 0) {
|
|
1906
|
-
console.log("- 先修复 Blockers (strict) 后复跑质量门,再进入 `$ws-dev`");
|
|
1907
|
-
if (wtHint.changeWorktree) console.log(`- 若需要写代码:先切到 change worktree 再改动:\`cd ${wtHint.changeWorktree}\``);
|
|
1908
|
-
return;
|
|
1909
|
-
}
|
|
1910
|
-
|
|
1911
|
-
if (st.tasks.unchecked > 0) {
|
|
1912
|
-
console.log("- 继续开发:在 AI 工具中运行 `$ws-dev`(小步实现 + 可复现验证)");
|
|
1913
|
-
console.log("- 提交/交付前强制门禁:`aiws validate .`(可选落盘:`aiws validate . --stamp`)");
|
|
1914
|
-
console.log(`- (可选)状态快照:\`aiws change state ${st.changeId} --write\``);
|
|
1915
|
-
return;
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
// deliver-ready hints
|
|
1919
|
-
if (st.evidence?.counts && st.evidence.counts.persistent === 0) {
|
|
1920
|
-
console.log("- 交付前建议生成持久证据:`changes/<id>/evidence/...`(并把路径回填到 proposal.md 的 Evidence_Path)");
|
|
1921
|
-
}
|
|
1922
|
-
if (st.evidence?.missing && st.evidence.missing.length > 0) {
|
|
1923
|
-
const show = st.evidence.missing.slice(0, 5);
|
|
1924
|
-
console.log(`- 交付前建议补齐缺失证据文件(missing=${st.evidence.missing.length}):${show.join(", ")}${st.evidence.missing.length > show.length ? ", ..." : ""}`);
|
|
1925
|
-
}
|
|
1926
|
-
console.log(`- (可选)状态快照:\`aiws change state ${st.changeId} --write\``);
|
|
1927
|
-
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);
|
|
1928
1636
|
}
|
|
1929
1637
|
|
|
1930
1638
|
/**
|
|
@@ -1938,216 +1646,46 @@ export async function changeNextCommand(options) {
|
|
|
1938
1646
|
|
|
1939
1647
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "next" });
|
|
1940
1648
|
assertValidChangeId(changeId);
|
|
1941
|
-
const
|
|
1649
|
+
const st = await computeChangeStatus(gitRoot, changeId);
|
|
1942
1650
|
|
|
1943
1651
|
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
1944
1652
|
if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
/** @type {string[]} */
|
|
1965
|
-
const actions = [];
|
|
1966
|
-
|
|
1967
|
-
const inferred = inferChangeIdFromBranch(branch);
|
|
1968
|
-
if (!branch) {
|
|
1969
|
-
actions.push("当前为 detached HEAD:建议切到 `change/<change-id>` 分支后再继续(或显式传入 `<change-id>`)");
|
|
1970
|
-
} else if (inferred && inferred !== changeId) {
|
|
1971
|
-
actions.push(`当前分支为 \`${branch}\`(推断 change=${inferred}),但你在查看 change=${changeId}:建议先切换到 \`change/${changeId}\` 再执行开发/交付`);
|
|
1972
|
-
} else if (!inferred) {
|
|
1973
|
-
actions.push(`当前分支为 \`${branch}\`(非 change 分支):建议 \`aiws change start ${changeId} --hooks --switch\` 或 \`git switch change/${changeId}\``);
|
|
1974
|
-
}
|
|
1975
|
-
|
|
1976
|
-
try {
|
|
1977
|
-
const worktrees = await listGitWorktrees(gitRoot);
|
|
1978
|
-
const changeRef = `refs/heads/change/${changeId}`;
|
|
1979
|
-
const changeWt = worktrees.find((w) => String(w.branch || "") === changeRef);
|
|
1980
|
-
if (changeWt?.worktree && path.resolve(changeWt.worktree) !== path.resolve(gitRoot)) {
|
|
1981
|
-
actions.push(`change/${changeId} 分支在另一个 worktree:\`cd ${changeWt.worktree}\`(建议在该目录执行开发/交付相关命令)`);
|
|
1982
|
-
}
|
|
1983
|
-
} catch {
|
|
1984
|
-
// best-effort; ignore
|
|
1985
|
-
}
|
|
1986
|
-
|
|
1987
|
-
if (!meta) {
|
|
1988
|
-
actions.push(`补齐元信息并建立真值基线:\`aiws change sync ${changeId}\``);
|
|
1989
|
-
}
|
|
1990
|
-
|
|
1991
|
-
if (meta && drift.length > 0) {
|
|
1992
|
-
actions.push("真值/合同已变化:先对齐 `AI_PROJECT.md` / `AI_WORKSPACE.md` / `REQUIREMENTS.md` 与 proposal/tasks");
|
|
1993
|
-
actions.push(`确认后同步基线:\`aiws change sync ${changeId}\``);
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
if (proposal.state !== "ok" || proposal.wsTodo > 0 || proposal.placeholders > 0) {
|
|
1997
|
-
actions.push(`完善 proposal:\`$EDITOR ${path.join(changeDir, "proposal.md")}\``);
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
if (design.state === "ok" && (design.wsTodo > 0 || design.placeholders > 0)) {
|
|
2001
|
-
actions.push(`(可选) 完善 design:\`$EDITOR ${path.join(changeDir, "design.md")}\``);
|
|
2002
|
-
}
|
|
2003
|
-
|
|
2004
|
-
if (tasks.state !== "ok" || tasks.wsTodo > 0 || tasks.placeholders > 0) {
|
|
2005
|
-
actions.push(`完善 tasks:\`$EDITOR ${path.join(changeDir, "tasks.md")}\``);
|
|
2006
|
-
}
|
|
2007
|
-
|
|
2008
|
-
if (proposal.state === "ok") {
|
|
2009
|
-
const reqId = extractId("Req_ID", proposal.text);
|
|
2010
|
-
const probId = extractId("Problem_ID", proposal.text);
|
|
2011
|
-
if (!(reqId || probId)) {
|
|
2012
|
-
actions.push("补齐归因:在 proposal.md 填写非空 `Req_ID` 或 `Problem_ID`(严格校验需要)");
|
|
2013
|
-
}
|
|
2014
|
-
|
|
2015
|
-
const contractRow = extractId("Contract_Row", proposal.text) || extractId("Contract_Row(s)", proposal.text);
|
|
2016
|
-
if (!contractRow) actions.push("补齐绑定:在 proposal.md 填写非空 `Contract_Row`(严格校验需要)");
|
|
2017
|
-
|
|
2018
|
-
const planFile = extractId("Plan_File", proposal.text) || extractId("Plan file", proposal.text);
|
|
2019
|
-
const planFiles = splitDeclaredValues(planFile);
|
|
2020
|
-
if (planFiles.length !== 1) actions.push("补齐绑定:proposal.md `Plan_File` 必须且只能包含 1 个 plan 路径(严格校验需要)");
|
|
2021
|
-
else {
|
|
2022
|
-
const planRel = String(planFiles[0] || "").replace(/^\.\//, "");
|
|
2023
|
-
if (path.isAbsolute(planRel)) actions.push("修正绑定:proposal.md `Plan_File` 必须是工作区相对路径(不要绝对路径)");
|
|
2024
|
-
else if (!(await pathExists(path.join(gitRoot, planRel)))) actions.push(`补齐计划文件:缺少 ${planRel}(或修正 proposal.md 的 Plan_File)`);
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
|
-
const evidenceDecl = extractId("Evidence_Path", proposal.text) || extractId("Evidence_Path(s)", proposal.text);
|
|
2028
|
-
const evidencePaths = splitDeclaredValues(evidenceDecl);
|
|
2029
|
-
if (evidencePaths.length === 0) {
|
|
2030
|
-
actions.push("补齐绑定:在 proposal.md 填写非空 `Evidence_Path`(严格校验需要;建议优先写 `changes/<id>/evidence/...`)");
|
|
2031
|
-
} else {
|
|
2032
|
-
const evidence = await analyzeEvidencePaths(gitRoot, changeId, evidencePaths);
|
|
2033
|
-
if (evidence.counts.persistent === 0) {
|
|
2034
|
-
actions.push("交付前建议生成持久证据:`changes/<id>/evidence/...`(并把路径回填到 Evidence_Path;`.agentdocs/tmp/...` 可作为原始证据引用)");
|
|
2035
|
-
}
|
|
2036
|
-
if (evidence.missing.length > 0) {
|
|
2037
|
-
const show = evidence.missing.slice(0, 5);
|
|
2038
|
-
actions.push(`补齐缺失证据文件(missing=${evidence.missing.length}):${show.join(", ")}${evidence.missing.length > show.length ? ", ..." : ""}`);
|
|
2039
|
-
}
|
|
2040
|
-
}
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
|
-
const prog = tasks.state === "ok" ? checkboxStats(tasks.text) : { total: 0, done: 0, unchecked: 0, hasCheckboxes: false };
|
|
2044
|
-
if (!prog.hasCheckboxes) {
|
|
2045
|
-
actions.push("tasks.md 需要至少一条 checkbox 任务(`- [ ]` / `- [x]`)");
|
|
2046
|
-
} else if (prog.unchecked > 0) {
|
|
2047
|
-
actions.push(`完成未勾选任务(${prog.unchecked} 项)`);
|
|
2048
|
-
}
|
|
2049
|
-
|
|
2050
|
-
if (actions.length > 0) {
|
|
2051
|
-
console.log(`- ${planVerifyHint(changeId)}`);
|
|
2052
|
-
for (const a of actions) console.log(`- ${a}`);
|
|
2053
|
-
console.log(`- 修复后复跑质量门:\`aiws change validate ${changeId} --strict\``);
|
|
2054
|
-
console.log("- 质量门通过后再进入编码:在 AI 工具中运行 `$ws-dev`");
|
|
2055
|
-
return;
|
|
2056
|
-
}
|
|
2057
|
-
|
|
2058
|
-
console.log(`- ${planVerifyHint(changeId)}`);
|
|
2059
|
-
if (prog.unchecked > 0) {
|
|
2060
|
-
console.log("- 若仍需继续编码,先通过质量门,再在 AI 工具中运行 `$ws-dev`");
|
|
2061
|
-
} else {
|
|
2062
|
-
console.log("- 进入交付:在 AI 工具中运行 `$ws-deliver`(门禁 + submodule 感知提交 + 最终 `$ws-finish`)");
|
|
2063
|
-
console.log("- 交付前门禁(推荐落盘):`aiws validate . --stamp`");
|
|
2064
|
-
}
|
|
2065
|
-
console.log("- 审计与证据(推荐):在 AI 工具内运行 `/ws-review`(或按 AI_PROJECT.md 手工审计)");
|
|
2066
|
-
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);
|
|
2067
1671
|
}
|
|
2068
1672
|
|
|
2069
1673
|
/**
|
|
2070
1674
|
* @param {string} gitRoot
|
|
2071
1675
|
* @param {string} changeId
|
|
2072
1676
|
*/
|
|
2073
|
-
async function computeChangeNextAdvice(gitRoot, changeId) {
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
prohibitions: [],
|
|
2086
|
-
recommended: [],
|
|
2087
|
-
};
|
|
2088
|
-
|
|
2089
|
-
if (!branch) {
|
|
2090
|
-
advice.actions.push("当前为 detached HEAD:建议切到 `change/<change-id>` 分支后再继续(或显式传入 `<change-id>`)");
|
|
2091
|
-
} else if (inferred && inferred !== changeId) {
|
|
2092
|
-
advice.actions.push(`当前分支为 \`${branch}\`(推断 change=${inferred}),但你在查看 change=${changeId}:建议先切换到 \`change/${changeId}\` 再执行开发/交付`);
|
|
2093
|
-
} else if (!inferred) {
|
|
2094
|
-
advice.actions.push(`当前分支为 \`${branch}\`(非 change 分支):建议 \`aiws change start ${changeId} --hooks --switch\` 或 \`git switch change/${changeId}\``);
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
try {
|
|
2098
|
-
const worktrees = await listGitWorktrees(gitRoot);
|
|
2099
|
-
const changeRef = `refs/heads/change/${changeId}`;
|
|
2100
|
-
const changeWt = worktrees.find((w) => String(w.branch || "") === changeRef);
|
|
2101
|
-
if (changeWt?.worktree && path.resolve(changeWt.worktree) !== path.resolve(gitRoot)) {
|
|
2102
|
-
advice.actions.push(`change/${changeId} 分支在另一个 worktree:\`cd ${changeWt.worktree}\`(建议在该目录执行开发/交付相关命令)`);
|
|
2103
|
-
advice.prohibitions.push("不要在当前 worktree 继续写代码(很可能不在 change 分支上)");
|
|
2104
|
-
}
|
|
2105
|
-
} catch {
|
|
2106
|
-
// ignore
|
|
2107
|
-
}
|
|
2108
|
-
|
|
2109
|
-
if (st.metaState !== "ok") advice.actions.push(`补齐元信息并建立真值基线:\`aiws change sync ${changeId}\``);
|
|
2110
|
-
if (st.driftFiles.length > 0) {
|
|
2111
|
-
advice.actions.push("真值/合同已变化:先对齐 `AI_PROJECT.md` / `AI_WORKSPACE.md` / `REQUIREMENTS.md` 与 proposal/tasks");
|
|
2112
|
-
advice.actions.push(`确认后同步基线:\`aiws change sync ${changeId}\``);
|
|
2113
|
-
}
|
|
2114
|
-
|
|
2115
|
-
if (st.blockersStrict.some((b) => b.includes("proposal.md") && b.includes("WS:TODO"))) {
|
|
2116
|
-
advice.actions.push(`完善 proposal:\`$EDITOR ${path.join(changeDirAbs(gitRoot, changeId), "proposal.md")}\``);
|
|
2117
|
-
}
|
|
2118
|
-
if (st.blockersStrict.some((b) => b.includes("tasks.md") && b.includes("WS:TODO"))) {
|
|
2119
|
-
advice.actions.push(`完善 tasks:\`$EDITOR ${path.join(changeDirAbs(gitRoot, changeId), "tasks.md")}\``);
|
|
2120
|
-
}
|
|
2121
|
-
if (st.blockersStrict.some((b) => b.includes("proposal.md missing attribution"))) {
|
|
2122
|
-
advice.actions.push("补齐归因:在 proposal.md 填写非空 `Req_ID` 或 `Problem_ID`(严格校验需要)");
|
|
2123
|
-
}
|
|
2124
|
-
if (st.blockersStrict.some((b) => b.includes("missing Contract_Row"))) {
|
|
2125
|
-
advice.actions.push("补齐绑定:在 proposal.md 填写非空 `Contract_Row`(严格校验需要)");
|
|
2126
|
-
}
|
|
2127
|
-
if (st.blockersStrict.some((b) => b.includes("Plan_File"))) {
|
|
2128
|
-
advice.actions.push("补齐绑定:修正 proposal.md 的 `Plan_File`(严格校验需要;必须存在且仅 1 个)");
|
|
2129
|
-
}
|
|
2130
|
-
if (st.blockersStrict.some((b) => b.includes("missing Evidence_Path"))) {
|
|
2131
|
-
advice.actions.push("补齐绑定:在 proposal.md 填写非空 `Evidence_Path`(严格校验需要;建议优先写 `changes/<id>/evidence/...`)");
|
|
2132
|
-
}
|
|
2133
|
-
if (st.tasks.unchecked > 0) advice.actions.push(`完成未勾选任务(${st.tasks.unchecked} 项)`);
|
|
2134
|
-
|
|
2135
|
-
if (st.blockersStrict.length > 0 && advice.actions.length === 0) {
|
|
2136
|
-
advice.actions.push("先修复 Blockers (strict) 后复跑质量门,再进入 `$ws-dev`");
|
|
2137
|
-
}
|
|
2138
|
-
|
|
2139
|
-
if (advice.actions.length === 0) {
|
|
2140
|
-
if (st.tasks.unchecked > 0) {
|
|
2141
|
-
advice.recommended.push("继续开发:在 AI 工具中运行 `$ws-dev`(小步实现 + 可复现验证)");
|
|
2142
|
-
} else {
|
|
2143
|
-
advice.recommended.push("进入交付:在 AI 工具中运行 `$ws-deliver`(门禁 + submodule 感知提交 + 最终 `$ws-finish`)");
|
|
2144
|
-
if (advice.evidence && advice.evidence.persistent === 0) {
|
|
2145
|
-
advice.recommended.push("交付前建议生成持久证据并回填:`aiws change evidence <change-id>`");
|
|
2146
|
-
}
|
|
2147
|
-
}
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
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
|
+
});
|
|
2151
1689
|
}
|
|
2152
1690
|
|
|
2153
1691
|
/**
|
|
@@ -2161,62 +1699,24 @@ export async function changeStateCommand(options) {
|
|
|
2161
1699
|
|
|
2162
1700
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "state" });
|
|
2163
1701
|
assertValidChangeId(changeId);
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
lines.push("");
|
|
2183
|
-
lines.push("## Bindings");
|
|
2184
|
-
if (st.reqId) lines.push(`- Req_ID: \`${st.reqId}\``);
|
|
2185
|
-
if (st.probId) lines.push(`- Problem_ID: \`${st.probId}\``);
|
|
2186
|
-
if (st.bindings?.contractRow) lines.push(`- Contract_Row: \`${st.bindings.contractRow}\``);
|
|
2187
|
-
if (st.bindings?.planFile) lines.push(`- Plan_File: \`${st.bindings.planFile}\``);
|
|
2188
|
-
lines.push("");
|
|
2189
|
-
lines.push("## Evidence");
|
|
2190
|
-
if (st.evidence?.counts) {
|
|
2191
|
-
const c = st.evidence.counts;
|
|
2192
|
-
lines.push(`- Declared: \`${c.total}\` (exists=\`${c.exists}\`, missing=\`${c.missing}\`, persistent=\`${c.persistent}\`, tmp=\`${c.tmp}\`)`);
|
|
2193
|
-
} else {
|
|
2194
|
-
lines.push("- (unknown)");
|
|
2195
|
-
}
|
|
2196
|
-
lines.push("");
|
|
2197
|
-
lines.push("## Blockers (strict)");
|
|
2198
|
-
if (st.blockersStrict.length === 0) lines.push("- (none)");
|
|
2199
|
-
else for (const b of st.blockersStrict) lines.push(`- ${b}`);
|
|
2200
|
-
lines.push("");
|
|
2201
|
-
lines.push("## Next");
|
|
2202
|
-
lines.push(`- ${planVerifyHint(changeId)}`);
|
|
2203
|
-
for (const a of advice.actions) lines.push(`- ${a}`);
|
|
2204
|
-
for (const r of advice.recommended) lines.push(`- ${r}`);
|
|
2205
|
-
if (advice.prohibitions.length > 0) {
|
|
2206
|
-
lines.push("");
|
|
2207
|
-
lines.push("## Do Not");
|
|
2208
|
-
for (const p of advice.prohibitions) lines.push(`- ${p}`);
|
|
2209
|
-
}
|
|
2210
|
-
|
|
2211
|
-
const out = lines.join("\n") + "\n";
|
|
2212
|
-
const dest = path.join(changeDirAbs(gitRoot, changeId), "STATE.md");
|
|
2213
|
-
if (options.write === true) {
|
|
2214
|
-
await writeText(dest, out);
|
|
2215
|
-
await appendMetricsEvent(gitRoot, changeId, "state_written", { path: path.relative(gitRoot, dest) });
|
|
2216
|
-
console.log(`ok: state written: ${path.relative(gitRoot, dest)}`);
|
|
2217
|
-
return;
|
|
2218
|
-
}
|
|
2219
|
-
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);
|
|
2220
1720
|
}
|
|
2221
1721
|
|
|
2222
1722
|
/**
|
|
@@ -2231,98 +1731,18 @@ export async function metricsSummaryCommand(options) {
|
|
|
2231
1731
|
const sinceRaw = String(options.since || "").trim();
|
|
2232
1732
|
const since = sinceRaw ? new Date(`${sinceRaw}T00:00:00Z`).getTime() : 0;
|
|
2233
1733
|
if (sinceRaw && Number.isNaN(since)) throw new UserError("Invalid --since (expected YYYY-MM-DD).", { details: `since=${sinceRaw}` });
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
const entries = await fs.readdir(base, { withFileTypes: true });
|
|
2247
|
-
for (const e of entries) {
|
|
2248
|
-
if (!e.isDirectory()) continue;
|
|
2249
|
-
const dir = path.join(base, e.name);
|
|
2250
|
-
const mAbs = path.join(dir, "metrics.json");
|
|
2251
|
-
if (!(await pathExists(mAbs))) continue;
|
|
2252
|
-
try {
|
|
2253
|
-
const m = JSON.parse(await readText(mAbs));
|
|
2254
|
-
const cid = String(m?.change_id || "").trim() || e.name;
|
|
2255
|
-
all.push({ changeId: cid, path: path.relative(gitRoot, mAbs), metrics: m });
|
|
2256
|
-
} catch {
|
|
2257
|
-
// ignore invalid
|
|
2258
|
-
}
|
|
2259
|
-
}
|
|
2260
|
-
}
|
|
2261
|
-
|
|
2262
|
-
if (all.length === 0) {
|
|
2263
|
-
console.log("(no metrics.json found)");
|
|
2264
|
-
return;
|
|
2265
|
-
}
|
|
2266
|
-
|
|
2267
|
-
/** @type {Record<string, number>} */
|
|
2268
|
-
const eventCounts = {};
|
|
2269
|
-
let strictValidations = 0;
|
|
2270
|
-
let strictPass = 0;
|
|
2271
|
-
let evidenceRuns = 0;
|
|
2272
|
-
let finishRuns = 0;
|
|
2273
|
-
/** @type {number[]} */
|
|
2274
|
-
const durations = [];
|
|
2275
|
-
|
|
2276
|
-
for (const row of all) {
|
|
2277
|
-
const events = Array.isArray(row.metrics?.events) ? row.metrics.events : [];
|
|
2278
|
-
const filtered = since
|
|
2279
|
-
? events.filter((ev) => {
|
|
2280
|
-
const t = Date.parse(String(ev?.timestamp || ""));
|
|
2281
|
-
return Number.isFinite(t) && t >= since;
|
|
2282
|
-
})
|
|
2283
|
-
: events;
|
|
2284
|
-
|
|
2285
|
-
for (const ev of filtered) {
|
|
2286
|
-
const type = String(ev?.type || "unknown");
|
|
2287
|
-
eventCounts[type] = (eventCounts[type] || 0) + 1;
|
|
2288
|
-
if (type === "validate") {
|
|
2289
|
-
const p = ev?.payload || {};
|
|
2290
|
-
if (p.strict === true) {
|
|
2291
|
-
strictValidations += 1;
|
|
2292
|
-
if (p.ok === true) strictPass += 1;
|
|
2293
|
-
}
|
|
2294
|
-
}
|
|
2295
|
-
if (type === "evidence") evidenceRuns += 1;
|
|
2296
|
-
if (type === "finish") finishRuns += 1;
|
|
2297
|
-
}
|
|
2298
|
-
|
|
2299
|
-
// Cycle time (best-effort): change_new -> finish
|
|
2300
|
-
const tNew = events.find((e) => e?.type === "change_new")?.timestamp;
|
|
2301
|
-
const tFin = events.find((e) => e?.type === "finish")?.timestamp;
|
|
2302
|
-
if (tNew && tFin) {
|
|
2303
|
-
const a = Date.parse(String(tNew));
|
|
2304
|
-
const b = Date.parse(String(tFin));
|
|
2305
|
-
if (Number.isFinite(a) && Number.isFinite(b) && b >= a) durations.push(Math.floor((b - a) / 1000));
|
|
2306
|
-
}
|
|
2307
|
-
}
|
|
2308
|
-
|
|
2309
|
-
const avgSec = durations.length > 0 ? Math.floor(durations.reduce((x, y) => x + y, 0) / durations.length) : 0;
|
|
2310
|
-
const avgMin = avgSec ? Math.floor(avgSec / 60) : 0;
|
|
2311
|
-
|
|
2312
|
-
console.log(`AIWS Metrics Summary${sinceRaw ? ` (since ${sinceRaw})` : ""}`);
|
|
2313
|
-
console.log("================================================");
|
|
2314
|
-
console.log(`Total metric files: ${all.length}`);
|
|
2315
|
-
console.log("");
|
|
2316
|
-
console.log("Event counts:");
|
|
2317
|
-
for (const k of Object.keys(eventCounts).sort()) console.log(`- ${k}: ${eventCounts[k]}`);
|
|
2318
|
-
console.log("");
|
|
2319
|
-
console.log("Quality:");
|
|
2320
|
-
console.log(`- Strict validate pass rate: ${strictValidations > 0 ? `${strictPass}/${strictValidations}` : "(no data)"}`);
|
|
2321
|
-
console.log(`- Evidence runs: ${evidenceRuns}`);
|
|
2322
|
-
console.log(`- Finish runs: ${finishRuns}`);
|
|
2323
|
-
console.log("");
|
|
2324
|
-
console.log("Cycle time (best-effort):");
|
|
2325
|
-
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);
|
|
2326
1746
|
}
|
|
2327
1747
|
|
|
2328
1748
|
/**
|
|
@@ -2336,201 +1756,38 @@ export async function changeEvidenceCommand(options) {
|
|
|
2336
1756
|
|
|
2337
1757
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "evidence" });
|
|
2338
1758
|
assertValidChangeId(changeId);
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
if (proposal.state !== "ok") throw new UserError("proposal.md is required for evidence generation.", { details: path.relative(gitRoot, proposal.abs) });
|
|
2345
|
-
|
|
2346
|
-
const planDecl = extractId("Plan_File", proposal.text) || extractId("Plan file", proposal.text);
|
|
2347
|
-
const planFiles = splitDeclaredValues(planDecl).map((p) => String(p || "").replace(/^\.\//, ""));
|
|
2348
|
-
const planRel = planFiles.length === 1 ? planFiles[0] : "";
|
|
2349
|
-
const planAbs = planRel ? path.join(gitRoot, planRel) : "";
|
|
2350
|
-
const planText = planAbs && (await pathExists(planAbs)) ? await readText(planAbs) : "";
|
|
2351
|
-
|
|
2352
|
-
const now = nowStampUtc();
|
|
2353
|
-
const evidenceDir = path.join(changeDir, "evidence");
|
|
2354
|
-
const reviewDir = path.join(changeDir, "review");
|
|
2355
|
-
await ensureDir(evidenceDir);
|
|
2356
|
-
await ensureDir(reviewDir);
|
|
2357
|
-
|
|
2358
|
-
/** @type {string[]} */
|
|
2359
|
-
const created = [];
|
|
2360
|
-
|
|
2361
|
-
const branch = await currentBranch(gitRoot);
|
|
2362
|
-
|
|
2363
|
-
const status = await computeChangeStatus(gitRoot, changeId);
|
|
2364
|
-
const statusOut = {
|
|
2365
|
-
generated_at: nowIsoUtc(),
|
|
2366
|
-
ws_root: gitRoot,
|
|
2367
|
-
change_id: changeId,
|
|
2368
|
-
branch: branch || "",
|
|
2369
|
-
phase: status.phase,
|
|
2370
|
-
tasks: status.tasks,
|
|
2371
|
-
bindings: status.bindings,
|
|
2372
|
-
truth: {
|
|
2373
|
-
baseline: { label: status.baselineLabel, at: status.baselineAt },
|
|
2374
|
-
drift_files: status.driftFiles,
|
|
1759
|
+
const result = await runChangeEvidenceCommand(
|
|
1760
|
+
{
|
|
1761
|
+
gitRoot,
|
|
1762
|
+
changeId,
|
|
1763
|
+
options,
|
|
2375
1764
|
},
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
created.push(relFromRoot(gitRoot, dst));
|
|
2403
|
-
}
|
|
2404
|
-
|
|
2405
|
-
const syncStampDir = path.join(gitRoot, ".agentdocs", "tmp", "change-sync");
|
|
2406
|
-
const latestSyncStamp = await findLatestFileByMtime(syncStampDir, (n) => n.endsWith(`-${changeId}.json`));
|
|
2407
|
-
if (latestSyncStamp) {
|
|
2408
|
-
const dst = path.join(evidenceDir, `change-sync-stamp-${now}.json`);
|
|
2409
|
-
await fs.copyFile(latestSyncStamp, dst);
|
|
2410
|
-
created.push(relFromRoot(gitRoot, dst));
|
|
2411
|
-
}
|
|
2412
|
-
|
|
2413
|
-
const reviewAbs = path.join(reviewDir, "codex-review.md");
|
|
2414
|
-
if (!(await pathExists(reviewAbs))) {
|
|
2415
|
-
const tmpReview = path.join(gitRoot, ".agentdocs", "tmp", "review", "codex-review.md");
|
|
2416
|
-
if (await pathExists(tmpReview)) {
|
|
2417
|
-
await fs.copyFile(tmpReview, reviewAbs);
|
|
2418
|
-
}
|
|
2419
|
-
}
|
|
2420
|
-
if (await pathExists(reviewAbs)) created.push(relFromRoot(gitRoot, reviewAbs));
|
|
2421
|
-
|
|
2422
|
-
// Collect all review files (support multiple reviewers)
|
|
2423
|
-
if (await pathExists(reviewDir)) {
|
|
2424
|
-
try {
|
|
2425
|
-
const reviewEntries = await fs.readdir(reviewDir, { withFileTypes: true });
|
|
2426
|
-
for (const e of reviewEntries) {
|
|
2427
|
-
if (!e.isFile()) continue;
|
|
2428
|
-
if (e.name === "codex-review.md") continue; // already added
|
|
2429
|
-
if (e.name.endsWith(".md")) {
|
|
2430
|
-
created.push(relFromRoot(gitRoot, path.join(reviewDir, e.name)));
|
|
2431
|
-
}
|
|
2432
|
-
}
|
|
2433
|
-
} catch {
|
|
2434
|
-
// ignore
|
|
2435
|
-
}
|
|
2436
|
-
}
|
|
2437
|
-
|
|
2438
|
-
// Delivery summary (persistent, human-readable).
|
|
2439
|
-
const summaryAbs = path.join(evidenceDir, `delivery-summary-${now}.md`);
|
|
2440
|
-
/** @type {string[]} */
|
|
2441
|
-
const summaryLines = [];
|
|
2442
|
-
summaryLines.push(`# Delivery Summary: ${changeId}`);
|
|
2443
|
-
summaryLines.push("");
|
|
2444
|
-
summaryLines.push(`Generated: ${nowIsoUtc()}`);
|
|
2445
|
-
summaryLines.push("");
|
|
2446
|
-
summaryLines.push("## Context");
|
|
2447
|
-
summaryLines.push(`- Worktree: \`${gitRoot}\``);
|
|
2448
|
-
summaryLines.push(`- Branch: \`${branch || "(detached)"}\``);
|
|
2449
|
-
summaryLines.push("");
|
|
2450
|
-
summaryLines.push("## Status");
|
|
2451
|
-
summaryLines.push(`- Phase: \`${status.phase}\``);
|
|
2452
|
-
summaryLines.push(`- Tasks: ${status.tasks.done}/${status.tasks.total} (unchecked=${status.tasks.unchecked})`);
|
|
2453
|
-
summaryLines.push(`- Truth drift: ${status.driftFiles.length > 0 ? status.driftFiles.join(", ") : "(none)"}`);
|
|
2454
|
-
summaryLines.push("");
|
|
2455
|
-
summaryLines.push("## Bindings");
|
|
2456
|
-
if (status.reqId) summaryLines.push(`- Req_ID: \`${status.reqId}\``);
|
|
2457
|
-
if (status.probId) summaryLines.push(`- Problem_ID: \`${status.probId}\``);
|
|
2458
|
-
if (status.bindings?.contractRow) summaryLines.push(`- Contract_Row: \`${status.bindings.contractRow}\``);
|
|
2459
|
-
if (status.bindings?.planFile) summaryLines.push(`- Plan_File: \`${status.bindings.planFile}\``);
|
|
2460
|
-
summaryLines.push("");
|
|
2461
|
-
summaryLines.push("## Evidence (Created/Collected)");
|
|
2462
|
-
if (created.length === 0) summaryLines.push("- (none)");
|
|
2463
|
-
else for (const p of created) summaryLines.push(`- \`${p}\``);
|
|
2464
|
-
summaryLines.push("");
|
|
2465
|
-
summaryLines.push("## Quality Gate");
|
|
2466
|
-
if (options.noValidate) {
|
|
2467
|
-
summaryLines.push("- Strict validation: (skipped via --no-validate)");
|
|
2468
|
-
} else if (validateRes) {
|
|
2469
|
-
summaryLines.push(`- Strict validation ok: \`${validateRes.ok === true}\``);
|
|
2470
|
-
summaryLines.push(`- Errors: \`${Array.isArray(validateRes.errors) ? validateRes.errors.length : 0}\``);
|
|
2471
|
-
summaryLines.push(`- Warnings: \`${Array.isArray(validateRes.warnings) ? validateRes.warnings.length : 0}\``);
|
|
2472
|
-
} else {
|
|
2473
|
-
summaryLines.push("- Strict validation: (unknown)");
|
|
2474
|
-
}
|
|
2475
|
-
summaryLines.push("");
|
|
2476
|
-
summaryLines.push("## Next");
|
|
2477
|
-
summaryLines.push(`- ${planVerifyHint(changeId)}`);
|
|
2478
|
-
if (validateFailed) summaryLines.push("- Fix strict validation errors, then re-run `aiws change evidence`");
|
|
2479
|
-
else summaryLines.push(`- Verify evidence gate: \`aiws change validate ${changeId} --strict --check-evidence\``);
|
|
2480
|
-
await writeText(summaryAbs, summaryLines.join("\n") + "\n");
|
|
2481
|
-
created.push(relFromRoot(gitRoot, summaryAbs));
|
|
2482
|
-
|
|
2483
|
-
const declaredEvidence = extractId("Evidence_Path", proposal.text) || extractId("Evidence_Path(s)", proposal.text);
|
|
2484
|
-
const existingProposalEvidence = splitDeclaredValues(declaredEvidence).map((p) => String(p || "").replace(/^\.\//, ""));
|
|
2485
|
-
const mergedEvidence = mergeDeclaredValues(existingProposalEvidence, created);
|
|
2486
|
-
const mergedEvidenceText = formatDeclaredValues(mergedEvidence);
|
|
2487
|
-
|
|
2488
|
-
const updatedProposal = upsertProposalEvidencePath(proposal.text, mergedEvidenceText);
|
|
2489
|
-
if (updatedProposal.changed) {
|
|
2490
|
-
await writeText(path.join(changeDir, "proposal.md"), updatedProposal.text);
|
|
2491
|
-
}
|
|
2492
|
-
|
|
2493
|
-
if (planAbs && planText) {
|
|
2494
|
-
const existingPlanEvidence = splitDeclaredValues(extractId("Evidence_Path", planText) || extractId("Evidence_Path(s)", planText)).map((p) =>
|
|
2495
|
-
String(p || "").replace(/^\.\//, ""),
|
|
2496
|
-
);
|
|
2497
|
-
const mergedPlanEvidence = mergeDeclaredValues(existingPlanEvidence, mergedEvidence);
|
|
2498
|
-
const mergedPlanEvidenceText = formatDeclaredValues(mergedPlanEvidence);
|
|
2499
|
-
const updatedPlan = upsertIdLine(planText, ["Evidence_Path", "Evidence_Path(s)"], mergedPlanEvidenceText, { insertAfterLabels: ["Plan_File", "Contract_Row"] });
|
|
2500
|
-
if (updatedPlan.changed) await writeText(planAbs, updatedPlan.text);
|
|
2501
|
-
}
|
|
2502
|
-
|
|
2503
|
-
await appendMetricsEvent(gitRoot, changeId, "evidence", {
|
|
2504
|
-
generated_at: nowIsoUtc(),
|
|
2505
|
-
created: created.length,
|
|
2506
|
-
strict_validate: options.noValidate ? "skipped" : validateRes && validateRes.ok === true ? "ok" : "failed",
|
|
2507
|
-
allow_fail: options.allowFail === true,
|
|
2508
|
-
});
|
|
2509
|
-
|
|
2510
|
-
console.log(`✓ aiws change evidence: ${changeId}`);
|
|
2511
|
-
console.log(`dir: ${path.relative(gitRoot, changeDir)}`);
|
|
2512
|
-
if (planRel) console.log(`Plan_File: ${planRel}`);
|
|
2513
|
-
if (created.length > 0) {
|
|
2514
|
-
console.log("created:");
|
|
2515
|
-
for (const p of created) console.log(`- ${p}`);
|
|
2516
|
-
} else {
|
|
2517
|
-
console.log("created:");
|
|
2518
|
-
console.log("- (none)");
|
|
2519
|
-
}
|
|
2520
|
-
console.log("updated:");
|
|
2521
|
-
console.log(`- ${path.relative(gitRoot, path.join(changeDir, "proposal.md"))} (Evidence_Path)`);
|
|
2522
|
-
if (planRel && planText) console.log(`- ${planRel} (Evidence_Path)`);
|
|
2523
|
-
console.log("");
|
|
2524
|
-
console.log("next:");
|
|
2525
|
-
console.log(`- ${planVerifyHint(changeId)}`);
|
|
2526
|
-
if (validateFailed) console.log("- fix validation errors, then re-run evidence (or run with --allow-fail to proceed)");
|
|
2527
|
-
else console.log(`- verify evidence gate: \`aiws change validate ${changeId} --strict --check-evidence\``);
|
|
2528
|
-
|
|
2529
|
-
if (validateFailed && !options.allowFail) {
|
|
2530
|
-
const details = (validateRes && validateRes.raw ? String(validateRes.raw) : "").trim() || "Validation failed (see evidence JSON for details).";
|
|
2531
|
-
const hint = validatePathForError ? `\n\nevidence:\n- ${path.relative(gitRoot, validatePathForError)}` : "";
|
|
2532
|
-
throw new UserError("Failed to generate evidence due to strict validation errors.", { details: `${details}${hint}` });
|
|
2533
|
-
}
|
|
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;
|
|
2534
1791
|
}
|
|
2535
1792
|
|
|
2536
1793
|
/**
|
|
@@ -2544,26 +1801,21 @@ export async function changeValidateCommand(options) {
|
|
|
2544
1801
|
|
|
2545
1802
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "validate" });
|
|
2546
1803
|
assertValidChangeId(changeId);
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
exit_code: res.exitCode,
|
|
2563
|
-
});
|
|
2564
|
-
if (res.raw) process.stderr.write(res.raw + "\n");
|
|
2565
|
-
if (!res.ok) throw new UserError("");
|
|
2566
|
-
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);
|
|
2567
1819
|
}
|
|
2568
1820
|
|
|
2569
1821
|
/**
|
|
@@ -2577,100 +1829,30 @@ export async function changeSyncCommand(options) {
|
|
|
2577
1829
|
|
|
2578
1830
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "sync" });
|
|
2579
1831
|
assertValidChangeId(changeId);
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
const syncedTruth = meta.synced_truth_files && typeof meta.synced_truth_files === "object" ? meta.synced_truth_files : {};
|
|
2605
|
-
const baseline = Object.keys(syncedTruth).length > 0 ? syncedTruth : createdTruth;
|
|
2606
|
-
const baselineLabel = Object.keys(syncedTruth).length > 0 ? "last sync" : "creation";
|
|
2607
|
-
const baselineAt = Object.keys(syncedTruth).length > 0 ? String(meta.synced_at || "") : String(meta.created_at || "");
|
|
2608
|
-
|
|
2609
|
-
/** @type {Array<{file: string, from: string | null, to: string | null}>} */
|
|
2610
|
-
const changed = [];
|
|
2611
|
-
|
|
2612
|
-
for (const [rel, info] of Object.entries(truth)) {
|
|
2613
|
-
const curSha = info && typeof info === "object" ? String(info.sha256 || "") : "";
|
|
2614
|
-
const baseSha =
|
|
2615
|
-
baseline && typeof baseline === "object" && baseline[rel] && typeof baseline[rel] === "object" ? String(baseline[rel].sha256 || "") : "";
|
|
2616
|
-
if (baseSha && curSha && baseSha !== curSha) {
|
|
2617
|
-
changed.push({ file: rel, from: baseSha, to: curSha });
|
|
2618
|
-
} else if (!baseSha && curSha) {
|
|
2619
|
-
changed.push({ file: rel, from: null, to: curSha });
|
|
2620
|
-
}
|
|
2621
|
-
}
|
|
2622
|
-
|
|
2623
|
-
if (baseline && typeof baseline === "object") {
|
|
2624
|
-
for (const rel of Object.keys(baseline)) {
|
|
2625
|
-
if (!truth[rel]) {
|
|
2626
|
-
const fromSha = baseline[rel] && typeof baseline[rel] === "object" ? String(baseline[rel].sha256 || "") : null;
|
|
2627
|
-
changed.push({ file: rel, from: fromSha || null, to: null });
|
|
2628
|
-
}
|
|
2629
|
-
}
|
|
2630
|
-
}
|
|
2631
|
-
|
|
2632
|
-
meta.synced_at = nowIso;
|
|
2633
|
-
meta.synced_truth_files = truth;
|
|
2634
|
-
let events = meta.sync_events;
|
|
2635
|
-
if (!Array.isArray(events)) events = [];
|
|
2636
|
-
events.push({ synced_at: nowIso, baseline_from: baselineLabel, baseline_at: baselineAt, changed });
|
|
2637
|
-
meta.sync_events = events.slice(-20);
|
|
2638
|
-
|
|
2639
|
-
await writeText(metaPath, JSON.stringify(meta, null, 2) + "\n");
|
|
2640
|
-
|
|
2641
|
-
const stampDir = path.join(gitRoot, ".agentdocs", "tmp", "change-sync");
|
|
2642
|
-
await ensureDir(stampDir);
|
|
2643
|
-
const stampPath = path.join(stampDir, `${nowStampUtc()}-${changeId}.json`);
|
|
2644
|
-
const stamp = {
|
|
2645
|
-
timestamp: nowTs,
|
|
2646
|
-
ws_root: gitRoot,
|
|
2647
|
-
change_id: changeId,
|
|
2648
|
-
change_dir: changeDir,
|
|
2649
|
-
synced_at: nowIso,
|
|
2650
|
-
previous_baseline: { label: baselineLabel, at: baselineAt, truth_files: baseline },
|
|
2651
|
-
new_baseline: truth,
|
|
2652
|
-
changed,
|
|
2653
|
-
note: "aiws change sync stamp; does not contain secrets.",
|
|
2654
|
-
};
|
|
2655
|
-
await writeText(stampPath, JSON.stringify(stamp, null, 2) + "\n");
|
|
2656
|
-
await appendMetricsEvent(gitRoot, changeId, "truth_sync", {
|
|
2657
|
-
synced_at: nowIso,
|
|
2658
|
-
baseline_from: baselineLabel,
|
|
2659
|
-
baseline_at: baselineAt,
|
|
2660
|
-
changed_files: changed.map((c) => c.file),
|
|
2661
|
-
});
|
|
2662
|
-
|
|
2663
|
-
console.log(`✓ aiws change sync: ${changeId}`);
|
|
2664
|
-
console.log(`meta: ${path.relative(gitRoot, metaPath)}`);
|
|
2665
|
-
console.log(`stamp: ${path.relative(gitRoot, stampPath)}`);
|
|
2666
|
-
if (changed.length > 0) {
|
|
2667
|
-
console.log("");
|
|
2668
|
-
console.log("Changed files:");
|
|
2669
|
-
for (const c of changed) console.log(`- ${c.file}`);
|
|
2670
|
-
} else {
|
|
2671
|
-
console.log("");
|
|
2672
|
-
console.log("No changes detected vs baseline.");
|
|
2673
|
-
}
|
|
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);
|
|
2674
1856
|
}
|
|
2675
1857
|
|
|
2676
1858
|
/**
|
|
@@ -2684,70 +1866,33 @@ export async function changeArchiveCommand(options) {
|
|
|
2684
1866
|
|
|
2685
1867
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "archive" });
|
|
2686
1868
|
assertValidChangeId(changeId);
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
console.log(`✓ aiws change archive: ${changeId}`);
|
|
2717
|
-
console.log(`archived_to: ${path.relative(gitRoot, dest)}`);
|
|
2718
|
-
|
|
2719
|
-
// Generate handoff.md
|
|
2720
|
-
const handoffPath = path.join(dest, "handoff.md");
|
|
2721
|
-
const handoffContent = await generateHandoffContent(gitRoot, dest, changeId);
|
|
2722
|
-
await writeText(handoffPath, handoffContent);
|
|
2723
|
-
console.log(`handoff: ${path.relative(gitRoot, handoffPath)}`);
|
|
2724
|
-
|
|
2725
|
-
const metaPath = path.join(dest, ".ws-change.json");
|
|
2726
|
-
if (await pathExists(metaPath)) {
|
|
2727
|
-
try {
|
|
2728
|
-
const meta = JSON.parse(await readText(metaPath));
|
|
2729
|
-
const truth = await snapshotTruthFiles(gitRoot);
|
|
2730
|
-
meta.archived_at = nowIsoUtc();
|
|
2731
|
-
meta.archived_to = dest;
|
|
2732
|
-
meta.archived_truth_files = truth;
|
|
2733
|
-
await writeText(metaPath, JSON.stringify(meta, null, 2) + "\n");
|
|
2734
|
-
console.log(`meta_updated: ${path.relative(gitRoot, metaPath)}`);
|
|
2735
|
-
} catch {
|
|
2736
|
-
// ignore invalid meta
|
|
2737
|
-
}
|
|
2738
|
-
}
|
|
2739
|
-
|
|
2740
|
-
const stampDir = path.join(gitRoot, ".agentdocs", "tmp", "change-archive");
|
|
2741
|
-
await ensureDir(stampDir);
|
|
2742
|
-
const stampPath = path.join(stampDir, `${nowStampUtc()}-${changeId}.json`);
|
|
2743
|
-
const truth = await snapshotTruthFiles(gitRoot);
|
|
2744
|
-
const stamp = {
|
|
2745
|
-
timestamp: nowUnixSeconds(),
|
|
2746
|
-
ws_root: gitRoot,
|
|
2747
|
-
archived_to: dest,
|
|
2748
|
-
truth_files: truth,
|
|
2749
|
-
note: "aiws change archive stamp; does not contain secrets.",
|
|
2750
|
-
};
|
|
2751
|
-
await writeText(stampPath, JSON.stringify(stamp, null, 2) + "\n");
|
|
2752
|
-
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);
|
|
2753
1898
|
}
|