@aipper/aiws 0.0.24 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,219 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+
4
+ /**
5
+ * @param {{
6
+ * gitRoot: string,
7
+ * changeId: string,
8
+ * changeDir: string
9
+ * }} input
10
+ * @param {{
11
+ * appendMetricsEvent: (gitRoot: string, changeId: string, type: string, payload: any) => Promise<void>,
12
+ * ensureDir: (path: string) => Promise<void>,
13
+ * nowIsoUtc: () => string,
14
+ * nowStampUtc: () => string,
15
+ * nowUnixSeconds: () => number,
16
+ * pathExists: (path: string) => Promise<boolean>,
17
+ * readText: (path: string) => Promise<string>,
18
+ * snapshotTruthFiles: (gitRoot: string) => Promise<any>,
19
+ * writeText: (path: string, content: string) => Promise<void>
20
+ * }} deps
21
+ */
22
+ export async function runChangeSyncWorkflow(input, deps) {
23
+ const { gitRoot, changeId, changeDir } = input;
24
+ const metaPath = path.join(changeDir, ".ws-change.json");
25
+ /** @type {any} */
26
+ let meta = null;
27
+ if (await deps.pathExists(metaPath)) {
28
+ try {
29
+ meta = JSON.parse(await deps.readText(metaPath));
30
+ } catch {
31
+ meta = null;
32
+ }
33
+ }
34
+
35
+ const truth = await deps.snapshotTruthFiles(gitRoot);
36
+ const nowIso = deps.nowIsoUtc();
37
+ const nowTs = deps.nowUnixSeconds();
38
+
39
+ if (!meta) {
40
+ meta = { ws_change_version: 1, id: changeId, title: changeId, created_at: nowIso, base_truth_files: truth };
41
+ }
42
+
43
+ const createdTruth = meta.base_truth_files && typeof meta.base_truth_files === "object" ? meta.base_truth_files : {};
44
+ const syncedTruth = meta.synced_truth_files && typeof meta.synced_truth_files === "object" ? meta.synced_truth_files : {};
45
+ const baseline = Object.keys(syncedTruth).length > 0 ? syncedTruth : createdTruth;
46
+ const baselineLabel = Object.keys(syncedTruth).length > 0 ? "last sync" : "creation";
47
+ const baselineAt = Object.keys(syncedTruth).length > 0 ? String(meta.synced_at || "") : String(meta.created_at || "");
48
+
49
+ /** @type {Array<{file: string, from: string | null, to: string | null}>} */
50
+ const changed = [];
51
+ for (const [rel, info] of Object.entries(truth)) {
52
+ const curSha = info && typeof info === "object" ? String(info.sha256 || "") : "";
53
+ const baseSha =
54
+ baseline && typeof baseline === "object" && baseline[rel] && typeof baseline[rel] === "object" ? String(baseline[rel].sha256 || "") : "";
55
+ if (baseSha && curSha && baseSha !== curSha) {
56
+ changed.push({ file: rel, from: baseSha, to: curSha });
57
+ } else if (!baseSha && curSha) {
58
+ changed.push({ file: rel, from: null, to: curSha });
59
+ }
60
+ }
61
+ if (baseline && typeof baseline === "object") {
62
+ for (const rel of Object.keys(baseline)) {
63
+ if (!truth[rel]) {
64
+ const fromSha = baseline[rel] && typeof baseline[rel] === "object" ? String(baseline[rel].sha256 || "") : null;
65
+ changed.push({ file: rel, from: fromSha || null, to: null });
66
+ }
67
+ }
68
+ }
69
+
70
+ meta.synced_at = nowIso;
71
+ meta.synced_truth_files = truth;
72
+ let events = meta.sync_events;
73
+ if (!Array.isArray(events)) events = [];
74
+ events.push({ synced_at: nowIso, baseline_from: baselineLabel, baseline_at: baselineAt, changed });
75
+ meta.sync_events = events.slice(-20);
76
+ await deps.writeText(metaPath, JSON.stringify(meta, null, 2) + "\n");
77
+
78
+ const stampDir = path.join(gitRoot, ".agentdocs", "tmp", "change-sync");
79
+ await deps.ensureDir(stampDir);
80
+ const stampPath = path.join(stampDir, `${deps.nowStampUtc()}-${changeId}.json`);
81
+ await deps.writeText(
82
+ stampPath,
83
+ JSON.stringify(
84
+ {
85
+ timestamp: nowTs,
86
+ ws_root: gitRoot,
87
+ change_id: changeId,
88
+ change_dir: changeDir,
89
+ synced_at: nowIso,
90
+ previous_baseline: { label: baselineLabel, at: baselineAt, truth_files: baseline },
91
+ new_baseline: truth,
92
+ changed,
93
+ note: "aiws change sync stamp; does not contain secrets.",
94
+ },
95
+ null,
96
+ 2,
97
+ ) + "\n",
98
+ );
99
+ await deps.appendMetricsEvent(gitRoot, changeId, "truth_sync", {
100
+ synced_at: nowIso,
101
+ baseline_from: baselineLabel,
102
+ baseline_at: baselineAt,
103
+ changed_files: changed.map((item) => item.file),
104
+ });
105
+
106
+ /** @type {string[]} */
107
+ const outputLines = [];
108
+ outputLines.push(`✓ aiws change sync: ${changeId}`);
109
+ outputLines.push(`meta: ${path.relative(gitRoot, metaPath)}`);
110
+ outputLines.push(`stamp: ${path.relative(gitRoot, stampPath)}`);
111
+ outputLines.push("");
112
+ if (changed.length > 0) {
113
+ outputLines.push("Changed files:");
114
+ for (const item of changed) outputLines.push(`- ${item.file}`);
115
+ } else {
116
+ outputLines.push("No changes detected vs baseline.");
117
+ }
118
+ return { output: outputLines.join("\n") + "\n" };
119
+ }
120
+
121
+ /**
122
+ * @param {{
123
+ * gitRoot: string,
124
+ * changeId: string,
125
+ * changeDir: string,
126
+ * datePrefix?: string,
127
+ * force: boolean
128
+ * }} input
129
+ * @param {{
130
+ * appendMetricsEvent: (gitRoot: string, changeId: string, type: string, payload: any) => Promise<void>,
131
+ * changeValidateCommand: (options: { changeId: string, strict: boolean, allowTruthDrift: boolean }) => Promise<void>,
132
+ * ensureDir: (path: string) => Promise<void>,
133
+ * generateHandoffContent: (gitRoot: string, changeDir: string, changeId: string) => Promise<string>,
134
+ * nowIsoUtc: () => string,
135
+ * nowStampUtc: () => string,
136
+ * nowUnixSeconds: () => number,
137
+ * pathExists: (path: string) => Promise<boolean>,
138
+ * readText: (path: string) => Promise<string>,
139
+ * snapshotTruthFiles: (gitRoot: string) => Promise<any>,
140
+ * todayLocal: () => string,
141
+ * writeText: (path: string, content: string) => Promise<void>,
142
+ * UserError: typeof import("../errors.js").UserError
143
+ * }} deps
144
+ */
145
+ export async function runChangeArchiveWorkflow(input, deps) {
146
+ const { gitRoot, changeId, changeDir } = input;
147
+ await deps.changeValidateCommand({ changeId, strict: true, allowTruthDrift: input.force });
148
+
149
+ const tasksAbs = path.join(changeDir, "tasks.md");
150
+ if (await deps.pathExists(tasksAbs)) {
151
+ const tasksText = await deps.readText(tasksAbs);
152
+ if (/- \[ \]/.test(tasksText)) {
153
+ if (!input.force) {
154
+ throw new deps.UserError("tasks.md still has unchecked tasks; complete them or pass --force");
155
+ }
156
+ console.error("warn: tasks.md still has unchecked tasks; continuing due to --force");
157
+ }
158
+ }
159
+
160
+ const archiveRoot = path.join(gitRoot, "changes", "archive");
161
+ await deps.ensureDir(archiveRoot);
162
+
163
+ const prefix = (input.datePrefix ? String(input.datePrefix) : deps.todayLocal()).trim() || deps.todayLocal();
164
+ let dest = path.join(archiveRoot, `${prefix}-${changeId}`);
165
+ if (await deps.pathExists(dest)) {
166
+ dest = path.join(archiveRoot, `${prefix}-${changeId}-${deps.nowStampUtc()}`);
167
+ }
168
+
169
+ await deps.appendMetricsEvent(gitRoot, changeId, "archive", { archived_to: path.relative(gitRoot, dest) });
170
+ await fs.rename(changeDir, dest);
171
+
172
+ const handoffPath = path.join(dest, "handoff.md");
173
+ const handoffContent = await deps.generateHandoffContent(gitRoot, dest, changeId);
174
+ await deps.writeText(handoffPath, handoffContent);
175
+
176
+ const metaPath = path.join(dest, ".ws-change.json");
177
+ let metaUpdated = false;
178
+ if (await deps.pathExists(metaPath)) {
179
+ try {
180
+ const meta = JSON.parse(await deps.readText(metaPath));
181
+ const truth = await deps.snapshotTruthFiles(gitRoot);
182
+ meta.archived_at = deps.nowIsoUtc();
183
+ meta.archived_to = dest;
184
+ meta.archived_truth_files = truth;
185
+ await deps.writeText(metaPath, JSON.stringify(meta, null, 2) + "\n");
186
+ metaUpdated = true;
187
+ } catch {
188
+ metaUpdated = false;
189
+ }
190
+ }
191
+
192
+ const stampDir = path.join(gitRoot, ".agentdocs", "tmp", "change-archive");
193
+ await deps.ensureDir(stampDir);
194
+ const stampPath = path.join(stampDir, `${deps.nowStampUtc()}-${changeId}.json`);
195
+ const truth = await deps.snapshotTruthFiles(gitRoot);
196
+ await deps.writeText(
197
+ stampPath,
198
+ JSON.stringify(
199
+ {
200
+ timestamp: deps.nowUnixSeconds(),
201
+ ws_root: gitRoot,
202
+ archived_to: dest,
203
+ truth_files: truth,
204
+ note: "aiws change archive stamp; does not contain secrets.",
205
+ },
206
+ null,
207
+ 2,
208
+ ) + "\n",
209
+ );
210
+
211
+ /** @type {string[]} */
212
+ const outputLines = [];
213
+ outputLines.push(`✓ aiws change archive: ${changeId}`);
214
+ outputLines.push(`archived_to: ${path.relative(gitRoot, dest)}`);
215
+ outputLines.push(`handoff: ${path.relative(gitRoot, handoffPath)}`);
216
+ if (metaUpdated) outputLines.push(`meta_updated: ${path.relative(gitRoot, metaPath)}`);
217
+ outputLines.push(`stamp: ${path.relative(gitRoot, stampPath)}`);
218
+ return { output: outputLines.join("\n") + "\n" };
219
+ }
@@ -0,0 +1,110 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+
4
+ /**
5
+ * @param {{
6
+ * gitRoot: string,
7
+ * sinceRaw: string,
8
+ * since: number
9
+ * }} input
10
+ * @param {{
11
+ * pathExists: (path: string) => Promise<boolean>,
12
+ * readText: (path: string) => Promise<string>
13
+ * }} deps
14
+ */
15
+ export async function renderMetricsSummaryOutput(input, deps) {
16
+ const { gitRoot, sinceRaw, since } = input;
17
+ const changesRoot = path.join(gitRoot, "changes");
18
+ if (!(await deps.pathExists(changesRoot))) {
19
+ return "(no changes dir)\n";
20
+ }
21
+
22
+ /** @type {Array<{ changeId: string, path: string, metrics: any }>} */
23
+ const all = [];
24
+ const scanDirs = [changesRoot, path.join(changesRoot, "archive")];
25
+ for (const base of scanDirs) {
26
+ if (!(await deps.pathExists(base))) continue;
27
+ const entries = await fs.readdir(base, { withFileTypes: true });
28
+ for (const entry of entries) {
29
+ if (!entry.isDirectory()) continue;
30
+ const dir = path.join(base, entry.name);
31
+ const metricsAbs = path.join(dir, "metrics.json");
32
+ if (!(await deps.pathExists(metricsAbs))) continue;
33
+ try {
34
+ const metrics = JSON.parse(await deps.readText(metricsAbs));
35
+ const changeId = String(metrics?.change_id || "").trim() || entry.name;
36
+ all.push({ changeId, path: path.relative(gitRoot, metricsAbs), metrics });
37
+ } catch {
38
+ // ignore invalid
39
+ }
40
+ }
41
+ }
42
+
43
+ if (all.length === 0) {
44
+ return "(no metrics.json found)\n";
45
+ }
46
+
47
+ /** @type {Record<string, number>} */
48
+ const eventCounts = {};
49
+ let strictValidations = 0;
50
+ let strictPass = 0;
51
+ let evidenceRuns = 0;
52
+ let finishRuns = 0;
53
+ /** @type {number[]} */
54
+ const durations = [];
55
+
56
+ for (const row of all) {
57
+ const events = Array.isArray(row.metrics?.events) ? row.metrics.events : [];
58
+ const filtered = since
59
+ ? events.filter((event) => {
60
+ const timestamp = Date.parse(String(event?.timestamp || ""));
61
+ return Number.isFinite(timestamp) && timestamp >= since;
62
+ })
63
+ : events;
64
+
65
+ for (const event of filtered) {
66
+ const type = String(event?.type || "unknown");
67
+ eventCounts[type] = (eventCounts[type] || 0) + 1;
68
+ if (type === "validate") {
69
+ const payload = event?.payload || {};
70
+ if (payload.strict === true) {
71
+ strictValidations += 1;
72
+ if (payload.ok === true) strictPass += 1;
73
+ }
74
+ }
75
+ if (type === "evidence") evidenceRuns += 1;
76
+ if (type === "finish" || type === "finish_done") finishRuns += 1;
77
+ }
78
+
79
+ const startedAt = events.find((event) => event?.type === "change_new")?.timestamp;
80
+ const finishedAt = events.find((event) => event?.type === "finish_done")?.timestamp || events.find((event) => event?.type === "finish")?.timestamp;
81
+ if (startedAt && finishedAt) {
82
+ const startTime = Date.parse(String(startedAt));
83
+ const finishTime = Date.parse(String(finishedAt));
84
+ if (Number.isFinite(startTime) && Number.isFinite(finishTime) && finishTime >= startTime) {
85
+ durations.push(Math.floor((finishTime - startTime) / 1000));
86
+ }
87
+ }
88
+ }
89
+
90
+ const avgSec = durations.length > 0 ? Math.floor(durations.reduce((left, right) => left + right, 0) / durations.length) : 0;
91
+ const avgMin = avgSec ? Math.floor(avgSec / 60) : 0;
92
+
93
+ /** @type {string[]} */
94
+ const lines = [];
95
+ lines.push(`AIWS Metrics Summary${sinceRaw ? ` (since ${sinceRaw})` : ""}`);
96
+ lines.push("================================================");
97
+ lines.push(`Total metric files: ${all.length}`);
98
+ lines.push("");
99
+ lines.push("Event counts:");
100
+ for (const key of Object.keys(eventCounts).sort()) lines.push(`- ${key}: ${eventCounts[key]}`);
101
+ lines.push("");
102
+ lines.push("Quality:");
103
+ lines.push(`- Strict validate pass rate: ${strictValidations > 0 ? `${strictPass}/${strictValidations}` : "(no data)"}`);
104
+ lines.push(`- Evidence runs: ${evidenceRuns}`);
105
+ lines.push(`- Finish runs: ${finishRuns}`);
106
+ lines.push("");
107
+ lines.push("Cycle time (best-effort):");
108
+ lines.push(`- Avg change_new -> finish: ${durations.length > 0 ? `${avgMin} min` : "(no data)"}`);
109
+ return lines.join("\n") + "\n";
110
+ }
@@ -0,0 +1,127 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * @param {{
5
+ * gitRoot: string,
6
+ * changeId: string,
7
+ * changeDir: string,
8
+ * status: any
9
+ * }} input
10
+ * @param {{
11
+ * analyzeEvidencePaths: (gitRoot: string, changeId: string, evidencePaths: string[]) => Promise<any>,
12
+ * computeChangeNextAdvice: (gitRoot: string, changeId: string) => Promise<any>,
13
+ * extractId: (label: string, text: string) => string,
14
+ * fileState: (changeDir: string, rel: string) => Promise<any>,
15
+ * pathExists: (path: string) => Promise<boolean>,
16
+ * planVerifyHint: (changeId: string) => string,
17
+ * splitDeclaredValues: (value: string) => string[]
18
+ * }} deps
19
+ */
20
+ export async function renderChangeNextOutput(input, deps) {
21
+ const { gitRoot, changeId, changeDir, status } = input;
22
+ const proposal = await deps.fileState(changeDir, "proposal.md");
23
+ const tasks = await deps.fileState(changeDir, "tasks.md");
24
+ const design = await deps.fileState(changeDir, "design.md");
25
+ const advice = await deps.computeChangeNextAdvice(gitRoot, changeId);
26
+
27
+ const actionSet = new Set(advice.actions);
28
+ const pushAction = (line) => {
29
+ const value = String(line || "").trim();
30
+ if (!value || actionSet.has(value)) return;
31
+ actionSet.add(value);
32
+ advice.actions.push(value);
33
+ };
34
+
35
+ if (proposal.state !== "ok" || proposal.wsTodo > 0 || proposal.placeholders > 0) {
36
+ pushAction(`完善 proposal:\`$EDITOR ${path.join(changeDir, "proposal.md")}\``);
37
+ }
38
+
39
+ if (design.state === "ok" && (design.wsTodo > 0 || design.placeholders > 0)) {
40
+ pushAction(`(可选) 完善 design:\`$EDITOR ${path.join(changeDir, "design.md")}\``);
41
+ }
42
+
43
+ if (tasks.state !== "ok" || tasks.wsTodo > 0 || tasks.placeholders > 0) {
44
+ pushAction(`完善 tasks:\`$EDITOR ${path.join(changeDir, "tasks.md")}\``);
45
+ }
46
+
47
+ if (proposal.state === "ok") {
48
+ const reqId = deps.extractId("Req_ID", proposal.text);
49
+ const probId = deps.extractId("Problem_ID", proposal.text);
50
+ if (!(reqId || probId)) {
51
+ pushAction("补齐归因:在 proposal.md 填写非空 `Req_ID` 或 `Problem_ID`(严格校验需要)");
52
+ }
53
+
54
+ const contractRow = deps.extractId("Contract_Row", proposal.text) || deps.extractId("Contract_Row(s)", proposal.text);
55
+ if (!contractRow) pushAction("补齐绑定:在 proposal.md 填写非空 `Contract_Row`(严格校验需要)");
56
+
57
+ const planFile = deps.extractId("Plan_File", proposal.text) || deps.extractId("Plan file", proposal.text);
58
+ const planFiles = deps.splitDeclaredValues(planFile);
59
+ if (planFiles.length !== 1) {
60
+ pushAction("补齐绑定:proposal.md `Plan_File` 必须且只能包含 1 个 plan 路径(严格校验需要)");
61
+ } else {
62
+ const planRel = String(planFiles[0] || "").replace(/^\.\//, "");
63
+ if (path.isAbsolute(planRel)) {
64
+ pushAction("修正绑定:proposal.md `Plan_File` 必须是工作区相对路径(不要绝对路径)");
65
+ } else if (!(await deps.pathExists(path.join(gitRoot, planRel)))) {
66
+ pushAction(`补齐计划文件:缺少 ${planRel}(或修正 proposal.md 的 Plan_File)`);
67
+ }
68
+ }
69
+
70
+ const evidenceDecl = deps.extractId("Evidence_Path", proposal.text) || deps.extractId("Evidence_Path(s)", proposal.text);
71
+ const evidencePaths = deps.splitDeclaredValues(evidenceDecl);
72
+ if (evidencePaths.length === 0) {
73
+ pushAction("补齐绑定:在 proposal.md 填写非空 `Evidence_Path`(严格校验需要;建议优先写 `changes/<id>/evidence/...`)");
74
+ } else {
75
+ const evidence = await deps.analyzeEvidencePaths(gitRoot, changeId, evidencePaths);
76
+ if (evidence.counts.persistent === 0) {
77
+ pushAction("交付前建议生成持久证据:`changes/<id>/evidence/...`(并把路径回填到 Evidence_Path;`.agentdocs/tmp/...` 可作为原始证据引用)");
78
+ }
79
+ if (evidence.missing.length > 0) {
80
+ const show = evidence.missing.slice(0, 5);
81
+ pushAction(`补齐缺失证据文件(missing=${evidence.missing.length}):${show.join(", ")}${evidence.missing.length > show.length ? ", ..." : ""}`);
82
+ }
83
+ }
84
+ }
85
+
86
+ const prog = tasks.state === "ok" ? checkboxStats(tasks.text) : { total: 0, done: 0, unchecked: 0, hasCheckboxes: false };
87
+ if (!prog.hasCheckboxes) {
88
+ pushAction("tasks.md 需要至少一条 checkbox 任务(`- [ ]` / `- [x]`)");
89
+ }
90
+
91
+ /** @type {string[]} */
92
+ const lines = [];
93
+ if (advice.actions.length > 0) {
94
+ lines.push(`- ${deps.planVerifyHint(changeId)}`);
95
+ for (const action of advice.actions) lines.push(`- ${action}`);
96
+ if (status.blockersStrict.length > 0) {
97
+ lines.push(`- 修复后复跑质量门:\`aiws change validate ${changeId} --strict\``);
98
+ lines.push("- 质量门通过后再进入编码:在 AI 工具中运行 `$ws-dev`");
99
+ } else if (status.tasks.unchecked > 0) {
100
+ lines.push(`- 处理完上述事项后复跑质量门:\`aiws change validate ${changeId} --strict\``);
101
+ lines.push("- 质量门通过后继续开发:在 AI 工具中运行 `$ws-dev`");
102
+ } else {
103
+ for (const recommendation of advice.recommended) lines.push(`- ${recommendation}`);
104
+ }
105
+ return lines.join("\n") + "\n";
106
+ }
107
+
108
+ lines.push(`- ${deps.planVerifyHint(changeId)}`);
109
+ if (prog.unchecked > 0) {
110
+ lines.push("- 若仍需继续编码,先通过质量门,再在 AI 工具中运行 `$ws-dev`");
111
+ } else {
112
+ for (const recommendation of advice.recommended) lines.push(`- ${recommendation}`);
113
+ }
114
+ lines.push("- 审计与证据(推荐):在 AI 工具内运行 `/ws-review`(或按 AI_PROJECT.md 手工审计)");
115
+ lines.push(`- 归档:\`aiws change archive ${changeId}\``);
116
+ return lines.join("\n") + "\n";
117
+ }
118
+
119
+ /**
120
+ * @param {string} text
121
+ */
122
+ function checkboxStats(text) {
123
+ const total = (text.match(/^- \[[ xX]\]/gm) || []).length;
124
+ const done = (text.match(/^- \[[xX]\]/gm) || []).length;
125
+ const unchecked = (text.match(/^- \[ \]/gm) || []).length;
126
+ return { total, done, unchecked, hasCheckboxes: total > 0 };
127
+ }