@aipper/aiws 0.0.1
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 +54 -0
- package/bin/aiws.js +18 -0
- package/package.json +24 -0
- package/src/aiws-package.js +15 -0
- package/src/backup.js +149 -0
- package/src/cli.js +470 -0
- package/src/codex-prompts.js +74 -0
- package/src/codex-skills.js +111 -0
- package/src/commands/change.js +987 -0
- package/src/commands/codex-install-prompts.js +68 -0
- package/src/commands/codex-install-skills.js +68 -0
- package/src/commands/codex-status-prompts.js +55 -0
- package/src/commands/codex-status-skills.js +54 -0
- package/src/commands/codex-uninstall-prompts.js +55 -0
- package/src/commands/codex-uninstall-skills.js +62 -0
- package/src/commands/hooks-install.js +93 -0
- package/src/commands/hooks-status.js +87 -0
- package/src/commands/init.js +93 -0
- package/src/commands/rollback.js +13 -0
- package/src/commands/update.js +98 -0
- package/src/commands/validate.js +155 -0
- package/src/errors.js +15 -0
- package/src/exec.js +34 -0
- package/src/fs.js +91 -0
- package/src/hash.js +25 -0
- package/src/managed-blocks.js +131 -0
- package/src/manifest.js +153 -0
- package/src/path-utils.js +20 -0
- package/src/spec.js +64 -0
- package/src/template.js +107 -0
- package/src/workspace.js +23 -0
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import { runCommand } from "../exec.js";
|
|
5
|
+
import { pathExists, readText, writeText, ensureDir } from "../fs.js";
|
|
6
|
+
import { UserError } from "../errors.js";
|
|
7
|
+
import { loadTemplate } from "../spec.js";
|
|
8
|
+
import { copyTemplateFileToWorkspace } from "../template.js";
|
|
9
|
+
import { hooksInstallCommand } from "./hooks-install.js";
|
|
10
|
+
|
|
11
|
+
const CHANGE_BRANCH_RE = /^(change|changes|ws|ws-change)\/([a-z0-9]+(?:-[a-z0-9]+)*)$/;
|
|
12
|
+
const CHANGE_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} changeId
|
|
16
|
+
*/
|
|
17
|
+
function assertValidChangeId(changeId) {
|
|
18
|
+
if (!changeId || !CHANGE_ID_RE.test(changeId)) {
|
|
19
|
+
throw new UserError(`Invalid change id (use kebab-case): ${changeId}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @returns {string} e.g. 2026-01-28T14:00:26Z
|
|
25
|
+
*/
|
|
26
|
+
function nowIsoUtc() {
|
|
27
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @returns {number} unix seconds
|
|
32
|
+
*/
|
|
33
|
+
function nowUnixSeconds() {
|
|
34
|
+
return Math.floor(Date.now() / 1000);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @returns {string} e.g. 20260128-140026Z
|
|
39
|
+
*/
|
|
40
|
+
function nowStampUtc() {
|
|
41
|
+
const d = new Date();
|
|
42
|
+
const y = String(d.getUTCFullYear());
|
|
43
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
44
|
+
const day = String(d.getUTCDate()).padStart(2, "0");
|
|
45
|
+
const hh = String(d.getUTCHours()).padStart(2, "0");
|
|
46
|
+
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
|
47
|
+
const ss = String(d.getUTCSeconds()).padStart(2, "0");
|
|
48
|
+
return `${y}${m}${day}-${hh}${mm}${ss}Z`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @returns {string} e.g. 2026-01-28 (local date)
|
|
53
|
+
*/
|
|
54
|
+
function todayLocal() {
|
|
55
|
+
const d = new Date();
|
|
56
|
+
const y = String(d.getFullYear());
|
|
57
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
58
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
59
|
+
return `${y}-${m}-${day}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} absPath
|
|
64
|
+
*/
|
|
65
|
+
async function resolveGitRoot(absPath) {
|
|
66
|
+
let res;
|
|
67
|
+
try {
|
|
68
|
+
res = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd: absPath });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
throw new UserError("git is required for aiws change commands.", { details: e instanceof Error ? e.message : String(e) });
|
|
71
|
+
}
|
|
72
|
+
if (res.code !== 0) {
|
|
73
|
+
throw new UserError("Not a git repository.", { details: res.stderr || res.stdout });
|
|
74
|
+
}
|
|
75
|
+
const root = String(res.stdout || "").trim();
|
|
76
|
+
if (!root) throw new UserError("Failed to resolve git repository root.");
|
|
77
|
+
return root;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} gitRoot
|
|
82
|
+
* @returns {Promise<string>} branch or empty
|
|
83
|
+
*/
|
|
84
|
+
async function currentBranch(gitRoot) {
|
|
85
|
+
const res = await runCommand("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: gitRoot });
|
|
86
|
+
if (res.code !== 0) return "";
|
|
87
|
+
return String(res.stdout || "").trim();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} branch
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
function inferChangeIdFromBranch(branch) {
|
|
95
|
+
const m = CHANGE_BRANCH_RE.exec(branch || "");
|
|
96
|
+
return m?.[2] || "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} gitRoot
|
|
101
|
+
*/
|
|
102
|
+
async function ensureTruthFiles(gitRoot) {
|
|
103
|
+
const required = ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"];
|
|
104
|
+
const missing = [];
|
|
105
|
+
for (const f of required) {
|
|
106
|
+
if (!(await pathExists(path.join(gitRoot, f)))) missing.push(f);
|
|
107
|
+
}
|
|
108
|
+
if (missing.length > 0) {
|
|
109
|
+
throw new UserError("AI Workspace truth files missing.", {
|
|
110
|
+
details: `Root: ${gitRoot}\nMissing:\n${missing.map((m) => `- ${m}`).join("\n")}\n\nHint: run \`aiws init .\` (new) or \`aiws update .\` (migrate).`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {string} filePath
|
|
117
|
+
* @returns {Promise<string>}
|
|
118
|
+
*/
|
|
119
|
+
async function sha256FileBytes(filePath) {
|
|
120
|
+
const buf = await fs.readFile(filePath);
|
|
121
|
+
return crypto.createHash("sha256").update(buf).digest("hex");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {string} gitRoot
|
|
126
|
+
* @returns {Promise<Record<string, {mtime: number, sha256: string}>>}
|
|
127
|
+
*/
|
|
128
|
+
async function snapshotTruthFiles(gitRoot) {
|
|
129
|
+
/** @type {Record<string, {mtime: number, sha256: string}>} */
|
|
130
|
+
const truth = {};
|
|
131
|
+
for (const rel of ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"]) {
|
|
132
|
+
const abs = path.join(gitRoot, rel);
|
|
133
|
+
if (!(await pathExists(abs))) continue;
|
|
134
|
+
const st = await fs.stat(abs);
|
|
135
|
+
truth[rel] = { mtime: Math.floor(st.mtimeMs / 1000), sha256: await sha256FileBytes(abs) };
|
|
136
|
+
}
|
|
137
|
+
return truth;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {string} gitRoot
|
|
142
|
+
* @returns {Promise<Record<string, string | null>>}
|
|
143
|
+
*/
|
|
144
|
+
async function snapshotTruthShaOnly(gitRoot) {
|
|
145
|
+
/** @type {Record<string, string | null>} */
|
|
146
|
+
const out = {};
|
|
147
|
+
for (const rel of ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"]) {
|
|
148
|
+
const abs = path.join(gitRoot, rel);
|
|
149
|
+
if (!(await pathExists(abs))) {
|
|
150
|
+
out[rel] = null;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
out[rel] = await sha256FileBytes(abs);
|
|
155
|
+
} catch {
|
|
156
|
+
out[rel] = null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @param {string} s
|
|
164
|
+
*/
|
|
165
|
+
function escapeRegExp(s) {
|
|
166
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @param {string} label
|
|
171
|
+
* @param {string} text
|
|
172
|
+
*/
|
|
173
|
+
function extractId(label, text) {
|
|
174
|
+
const re = new RegExp(`^.*${escapeRegExp(label)}.*?[:=]\\s*(.+)$`, "m");
|
|
175
|
+
const m = re.exec(text);
|
|
176
|
+
if (!m) return "";
|
|
177
|
+
let v = String(m[1] || "").trim();
|
|
178
|
+
v = v.replace(/<!--.*?-->/g, "").trim();
|
|
179
|
+
v = v.replace(/^`+/, "").replace(/`+$/, "").trim();
|
|
180
|
+
return v;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @param {string} text
|
|
185
|
+
*/
|
|
186
|
+
function checkboxStats(text) {
|
|
187
|
+
const total = (text.match(/^- \[[ xX]\]/gm) || []).length;
|
|
188
|
+
const done = (text.match(/^- \[[xX]\]/gm) || []).length;
|
|
189
|
+
const unchecked = (text.match(/^- \[ \]/gm) || []).length;
|
|
190
|
+
return { total, done, unchecked, hasCheckboxes: total > 0 };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {string} text
|
|
195
|
+
*/
|
|
196
|
+
function countWsTodo(text) {
|
|
197
|
+
return (text.match(/WS:TODO/g) || []).length;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {string} text
|
|
202
|
+
*/
|
|
203
|
+
function countPlaceholders(text) {
|
|
204
|
+
return (text.match(/{{CHANGE_ID}}|{{TITLE}}|{{CREATED_AT}}/g) || []).length;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* @param {string} changeDir
|
|
209
|
+
* @param {string} rel
|
|
210
|
+
*/
|
|
211
|
+
async function fileState(changeDir, rel) {
|
|
212
|
+
const abs = path.join(changeDir, rel);
|
|
213
|
+
if (!(await pathExists(abs))) return { state: "missing", wsTodo: 0, placeholders: 0, abs, text: "" };
|
|
214
|
+
const st = await fs.stat(abs);
|
|
215
|
+
if (st.size === 0) return { state: "empty", wsTodo: 0, placeholders: 0, abs, text: "" };
|
|
216
|
+
const text = await readText(abs);
|
|
217
|
+
return { state: "ok", wsTodo: countWsTodo(text), placeholders: countPlaceholders(text), abs, text };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* @param {string} gitRoot
|
|
222
|
+
*/
|
|
223
|
+
async function resolveTemplateIdForRepo(gitRoot) {
|
|
224
|
+
const manifestPath = path.join(gitRoot, ".aiws", "manifest.json");
|
|
225
|
+
if (!(await pathExists(manifestPath))) return "workspace";
|
|
226
|
+
try {
|
|
227
|
+
const stored = JSON.parse(await readText(manifestPath));
|
|
228
|
+
const templateId = String(stored.template_id || "").trim();
|
|
229
|
+
return templateId || "workspace";
|
|
230
|
+
} catch {
|
|
231
|
+
return "workspace";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* @param {string} gitRoot
|
|
237
|
+
* @returns {Promise<{ templateDir: string, resolvedFrom: string }>}
|
|
238
|
+
*/
|
|
239
|
+
async function resolveChangeTemplatesDir(gitRoot) {
|
|
240
|
+
const candidates = [
|
|
241
|
+
{ dir: path.join(gitRoot, "changes", "templates"), label: `${gitRoot}/changes/templates` },
|
|
242
|
+
{ dir: path.join(gitRoot, "workflow", "changes", "templates"), label: `${gitRoot}/workflow/changes/templates` },
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
for (const c of candidates) {
|
|
246
|
+
if (!(await pathExists(path.join(c.dir, "proposal.md")))) continue;
|
|
247
|
+
if (!(await pathExists(path.join(c.dir, "tasks.md")))) continue;
|
|
248
|
+
return { templateDir: c.dir, resolvedFrom: c.label };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const templateId = await resolveTemplateIdForRepo(gitRoot);
|
|
252
|
+
const tpl = await loadTemplate(templateId);
|
|
253
|
+
const fallback = path.join(tpl.templateDir, "changes", "templates");
|
|
254
|
+
|
|
255
|
+
if (!(await pathExists(path.join(fallback, "proposal.md"))) || !(await pathExists(path.join(fallback, "tasks.md")))) {
|
|
256
|
+
throw new UserError("Missing change templates.", { details: `Expected proposal.md and tasks.md under: ${fallback}` });
|
|
257
|
+
}
|
|
258
|
+
return { templateDir: fallback, resolvedFrom: `@aipper/aiws-spec:${tpl.templateId}` };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @param {string} templateText
|
|
263
|
+
* @param {{ changeId: string, title: string, createdAt: string }} vars
|
|
264
|
+
*/
|
|
265
|
+
function renderTemplate(templateText, vars) {
|
|
266
|
+
return templateText.replaceAll("{{CHANGE_ID}}", vars.changeId).replaceAll("{{TITLE}}", vars.title).replaceAll("{{CREATED_AT}}", vars.createdAt);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @param {string} gitRoot
|
|
271
|
+
* @returns {Promise<{ path: string, args: string[] }>}
|
|
272
|
+
*/
|
|
273
|
+
async function resolveWsChangeChecker(gitRoot) {
|
|
274
|
+
const local = path.join(gitRoot, "tools", "ws_change_check.py");
|
|
275
|
+
if (await pathExists(local)) return { path: local, args: [local] };
|
|
276
|
+
|
|
277
|
+
const templateId = await resolveTemplateIdForRepo(gitRoot);
|
|
278
|
+
const tpl = await loadTemplate(templateId);
|
|
279
|
+
const fallback = path.join(tpl.templateDir, "tools", "ws_change_check.py");
|
|
280
|
+
if (!(await pathExists(fallback))) {
|
|
281
|
+
throw new UserError("Missing ws_change_check.py.", { details: "Hint: run `aiws init .` to install tools/ws_change_check.py in your repo." });
|
|
282
|
+
}
|
|
283
|
+
return { path: fallback, args: [fallback] };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {string} gitRoot
|
|
288
|
+
* @param {string[]} args
|
|
289
|
+
*/
|
|
290
|
+
async function runPython(gitRoot, args) {
|
|
291
|
+
let res;
|
|
292
|
+
try {
|
|
293
|
+
res = await runCommand("python3", args, { cwd: gitRoot });
|
|
294
|
+
} catch (e) {
|
|
295
|
+
throw new UserError("python3 is required.", { details: e instanceof Error ? e.message : String(e) });
|
|
296
|
+
}
|
|
297
|
+
return res;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @param {string} gitRoot
|
|
302
|
+
* @param {string | undefined} changeId
|
|
303
|
+
* @param {{ command: string }} options
|
|
304
|
+
*/
|
|
305
|
+
async function resolveChangeId(gitRoot, changeId, options) {
|
|
306
|
+
if (changeId) return changeId;
|
|
307
|
+
const branch = await currentBranch(gitRoot);
|
|
308
|
+
const inferred = inferChangeIdFromBranch(branch);
|
|
309
|
+
if (inferred) return inferred;
|
|
310
|
+
throw new UserError(`usage: aiws change ${options.command} <change-id> (or switch to branch change/<change-id>)`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* @param {any} meta
|
|
315
|
+
*/
|
|
316
|
+
function baselineFromMeta(meta) {
|
|
317
|
+
const createdTruth = meta?.base_truth_files && typeof meta.base_truth_files === "object" ? meta.base_truth_files : {};
|
|
318
|
+
const syncedTruth = meta?.synced_truth_files && typeof meta.synced_truth_files === "object" ? meta.synced_truth_files : {};
|
|
319
|
+
if (Object.keys(syncedTruth).length > 0) {
|
|
320
|
+
return { baseline: syncedTruth, label: "last sync", at: String(meta?.synced_at || "") };
|
|
321
|
+
}
|
|
322
|
+
return { baseline: createdTruth, label: "creation", at: String(meta?.created_at || "") };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* @param {Record<string, string | null>} curTruth
|
|
327
|
+
* @param {any} baseline
|
|
328
|
+
* @returns {{ baselineLabel: string, baselineAt: string, driftFiles: string[] }}
|
|
329
|
+
*/
|
|
330
|
+
function truthDrift(curTruth, baseline) {
|
|
331
|
+
/** @type {string[]} */
|
|
332
|
+
const driftFiles = [];
|
|
333
|
+
|
|
334
|
+
const createdTruth = baseline?.base_truth_files && typeof baseline.base_truth_files === "object" ? baseline.base_truth_files : {};
|
|
335
|
+
const syncedTruth = baseline?.synced_truth_files && typeof baseline.synced_truth_files === "object" ? baseline.synced_truth_files : {};
|
|
336
|
+
const effective = Object.keys(syncedTruth).length > 0 ? syncedTruth : createdTruth;
|
|
337
|
+
const baselineLabel = Object.keys(syncedTruth).length > 0 ? "sync" : "creation";
|
|
338
|
+
const baselineAt = Object.keys(syncedTruth).length > 0 ? String(baseline?.synced_at || "") : String(baseline?.created_at || "");
|
|
339
|
+
|
|
340
|
+
for (const [rel, info] of Object.entries(effective || {})) {
|
|
341
|
+
const baseSha = info && typeof info === "object" ? String(info.sha256 || "") : "";
|
|
342
|
+
const curSha = curTruth[rel] ?? null;
|
|
343
|
+
if (curSha == null) {
|
|
344
|
+
driftFiles.push(rel);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (baseSha && curSha !== baseSha) driftFiles.push(rel);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return { baselineLabel, baselineAt, driftFiles };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* @param {string} gitRoot
|
|
355
|
+
* @param {string} changeId
|
|
356
|
+
*/
|
|
357
|
+
function changeDirAbs(gitRoot, changeId) {
|
|
358
|
+
return path.join(gitRoot, "changes", changeId);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* aiws change list
|
|
363
|
+
*/
|
|
364
|
+
export async function changeListCommand() {
|
|
365
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
366
|
+
await ensureTruthFiles(gitRoot);
|
|
367
|
+
|
|
368
|
+
const changesRoot = path.join(gitRoot, "changes");
|
|
369
|
+
if (!(await pathExists(changesRoot))) {
|
|
370
|
+
console.log("(no changes dir)");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const curTruth = await snapshotTruthShaOnly(gitRoot);
|
|
375
|
+
|
|
376
|
+
const entries = await fs.readdir(changesRoot, { withFileTypes: true });
|
|
377
|
+
const dirs = entries
|
|
378
|
+
.filter((e) => e.isDirectory())
|
|
379
|
+
.map((e) => e.name)
|
|
380
|
+
.filter((name) => name && !name.startsWith(".") && name !== "archive" && name !== "templates")
|
|
381
|
+
.sort();
|
|
382
|
+
|
|
383
|
+
if (dirs.length === 0) {
|
|
384
|
+
console.log("(no active changes)");
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** @type {Array<[string, string, string, string, number, string, string]>} */
|
|
389
|
+
const rows = [];
|
|
390
|
+
|
|
391
|
+
for (const name of dirs) {
|
|
392
|
+
const dir = path.join(changesRoot, name);
|
|
393
|
+
const proposalPath = path.join(dir, "proposal.md");
|
|
394
|
+
const tasksPath = path.join(dir, "tasks.md");
|
|
395
|
+
const metaPath = path.join(dir, ".ws-change.json");
|
|
396
|
+
|
|
397
|
+
const metaState = !(await pathExists(metaPath)) ? "missing" : "ok";
|
|
398
|
+
/** @type {any} */
|
|
399
|
+
let meta = null;
|
|
400
|
+
if (metaState === "ok") {
|
|
401
|
+
try {
|
|
402
|
+
meta = JSON.parse(await readText(metaPath));
|
|
403
|
+
} catch {
|
|
404
|
+
meta = null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const finalMetaState = meta ? "ok" : metaState === "missing" ? "missing" : "invalid";
|
|
408
|
+
|
|
409
|
+
let proposalWsTodo = 0;
|
|
410
|
+
if (await pathExists(proposalPath)) {
|
|
411
|
+
try {
|
|
412
|
+
proposalWsTodo = countWsTodo(await readText(proposalPath));
|
|
413
|
+
} catch {
|
|
414
|
+
proposalWsTodo = 0;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let tasksTotal = 0;
|
|
419
|
+
let tasksDone = 0;
|
|
420
|
+
let tasksTodo = 0;
|
|
421
|
+
let tasksWsTodo = 0;
|
|
422
|
+
if (await pathExists(tasksPath)) {
|
|
423
|
+
try {
|
|
424
|
+
const t = await readText(tasksPath);
|
|
425
|
+
const stats = checkboxStats(t);
|
|
426
|
+
tasksTotal = stats.total;
|
|
427
|
+
tasksDone = stats.done;
|
|
428
|
+
tasksTodo = stats.unchecked;
|
|
429
|
+
tasksWsTodo = countWsTodo(t);
|
|
430
|
+
} catch {
|
|
431
|
+
// ignore
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const { baselineLabel, driftFiles } = meta ? truthDrift(curTruth, meta) : { baselineLabel: "-", driftFiles: [] };
|
|
436
|
+
|
|
437
|
+
let nextStep = "archive";
|
|
438
|
+
if (finalMetaState !== "ok") nextStep = "sync";
|
|
439
|
+
else if (driftFiles.length > 0) nextStep = "sync";
|
|
440
|
+
else if (proposalWsTodo + tasksWsTodo > 0) nextStep = "edit";
|
|
441
|
+
else if (tasksTodo > 0) nextStep = "do";
|
|
442
|
+
|
|
443
|
+
rows.push([
|
|
444
|
+
name,
|
|
445
|
+
nextStep,
|
|
446
|
+
finalMetaState,
|
|
447
|
+
baselineLabel,
|
|
448
|
+
driftFiles.length,
|
|
449
|
+
`${tasksDone}/${tasksTotal} (unchecked=${tasksTodo})`,
|
|
450
|
+
`proposal=${proposalWsTodo} tasks=${tasksWsTodo}`,
|
|
451
|
+
]);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
console.log("change_id\tnext\tmeta\tbaseline\tdrift\ttasks\tWS:TODO");
|
|
455
|
+
for (const r of rows) {
|
|
456
|
+
console.log(r.join("\t"));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* aiws change templates which
|
|
462
|
+
*/
|
|
463
|
+
export async function changeTemplatesWhichCommand() {
|
|
464
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
465
|
+
await ensureTruthFiles(gitRoot);
|
|
466
|
+
|
|
467
|
+
const templateId = await resolveTemplateIdForRepo(gitRoot);
|
|
468
|
+
const tpl = await loadTemplate(templateId);
|
|
469
|
+
const resolved = await resolveChangeTemplatesDir(gitRoot);
|
|
470
|
+
|
|
471
|
+
console.log(`templates: ${resolved.templateDir}`);
|
|
472
|
+
console.log(`resolved_from: ${resolved.resolvedFrom}`);
|
|
473
|
+
console.log("precedence:");
|
|
474
|
+
console.log(` 1) ${path.join(gitRoot, "changes", "templates")}`);
|
|
475
|
+
console.log(` 2) ${path.join(gitRoot, "workflow", "changes", "templates")}`);
|
|
476
|
+
console.log(` 3) ${path.join(tpl.templateDir, "changes", "templates")}`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* aiws change templates init
|
|
481
|
+
*/
|
|
482
|
+
export async function changeTemplatesInitCommand() {
|
|
483
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
484
|
+
await ensureTruthFiles(gitRoot);
|
|
485
|
+
|
|
486
|
+
const templateId = await resolveTemplateIdForRepo(gitRoot);
|
|
487
|
+
const tpl = await loadTemplate(templateId);
|
|
488
|
+
|
|
489
|
+
const rels = ["changes/templates/proposal.md", "changes/templates/tasks.md", "changes/templates/design.md"];
|
|
490
|
+
/** @type {string[]} */
|
|
491
|
+
const installed = [];
|
|
492
|
+
/** @type {string[]} */
|
|
493
|
+
const skipped = [];
|
|
494
|
+
|
|
495
|
+
for (const rel of rels) {
|
|
496
|
+
const destAbs = path.join(gitRoot, ...rel.split("/"));
|
|
497
|
+
if (await pathExists(destAbs)) {
|
|
498
|
+
skipped.push(rel);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
await copyTemplateFileToWorkspace({ templateDir: tpl.templateDir, workspaceRoot: gitRoot, relPosix: rel });
|
|
502
|
+
installed.push(rel);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
console.log(`✓ aiws change templates init: ${gitRoot}`);
|
|
506
|
+
if (installed.length > 0) console.log(`installed: ${installed.join(", ")}`);
|
|
507
|
+
if (skipped.length > 0) console.log(`skip: ${skipped.join(", ")}`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* aiws change new
|
|
512
|
+
*
|
|
513
|
+
* @param {{ changeId: string, title?: string, noDesign: boolean }} options
|
|
514
|
+
*/
|
|
515
|
+
export async function changeNewCommand(options) {
|
|
516
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
517
|
+
await ensureTruthFiles(gitRoot);
|
|
518
|
+
|
|
519
|
+
const changeId = String(options.changeId || "").trim();
|
|
520
|
+
assertValidChangeId(changeId);
|
|
521
|
+
|
|
522
|
+
const title = (options.title ? String(options.title) : changeId).trim() || changeId;
|
|
523
|
+
const createdAt = nowIsoUtc();
|
|
524
|
+
|
|
525
|
+
const changeRoot = path.join(gitRoot, "changes");
|
|
526
|
+
await ensureDir(changeRoot);
|
|
527
|
+
const changeDir = path.join(changeRoot, changeId);
|
|
528
|
+
if (await pathExists(changeDir)) throw new UserError(`Change already exists: ${changeDir}`);
|
|
529
|
+
await ensureDir(changeDir);
|
|
530
|
+
|
|
531
|
+
const templates = await resolveChangeTemplatesDir(gitRoot);
|
|
532
|
+
const proposalTpl = await readText(path.join(templates.templateDir, "proposal.md"));
|
|
533
|
+
const tasksTpl = await readText(path.join(templates.templateDir, "tasks.md"));
|
|
534
|
+
await writeText(path.join(changeDir, "proposal.md"), renderTemplate(proposalTpl, { changeId, title, createdAt }));
|
|
535
|
+
await writeText(path.join(changeDir, "tasks.md"), renderTemplate(tasksTpl, { changeId, title, createdAt }));
|
|
536
|
+
|
|
537
|
+
if (!options.noDesign) {
|
|
538
|
+
const designTplPath = path.join(templates.templateDir, "design.md");
|
|
539
|
+
if (!(await pathExists(designTplPath))) {
|
|
540
|
+
throw new UserError("Missing design.md in templates dir; pass --no-design to skip.", { details: `templates: ${templates.templateDir}` });
|
|
541
|
+
}
|
|
542
|
+
const designTpl = await readText(designTplPath);
|
|
543
|
+
await writeText(path.join(changeDir, "design.md"), renderTemplate(designTpl, { changeId, title, createdAt }));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const truth = await snapshotTruthFiles(gitRoot);
|
|
547
|
+
const meta = {
|
|
548
|
+
ws_change_version: 1,
|
|
549
|
+
id: changeId,
|
|
550
|
+
title,
|
|
551
|
+
created_at: createdAt,
|
|
552
|
+
base_truth_files: truth,
|
|
553
|
+
};
|
|
554
|
+
await writeText(path.join(changeDir, ".ws-change.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
555
|
+
|
|
556
|
+
console.log(`✓ aiws change new: ${changeId}`);
|
|
557
|
+
console.log(`dir: ${path.relative(gitRoot, changeDir)}`);
|
|
558
|
+
console.log(`templates: ${templates.resolvedFrom}`);
|
|
559
|
+
console.log("next:");
|
|
560
|
+
console.log(` - edit: changes/${changeId}/proposal.md`);
|
|
561
|
+
console.log(` - edit: changes/${changeId}/tasks.md`);
|
|
562
|
+
if (!options.noDesign) console.log(` - optional: changes/${changeId}/design.md`);
|
|
563
|
+
console.log(` - validate: aiws change validate ${changeId}`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* aiws change start
|
|
568
|
+
*
|
|
569
|
+
* @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean }} options
|
|
570
|
+
*/
|
|
571
|
+
export async function changeStartCommand(options) {
|
|
572
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
573
|
+
await ensureTruthFiles(gitRoot);
|
|
574
|
+
|
|
575
|
+
const changeId = String(options.changeId || "").trim();
|
|
576
|
+
assertValidChangeId(changeId);
|
|
577
|
+
|
|
578
|
+
const branch = `change/${changeId}`;
|
|
579
|
+
|
|
580
|
+
const hasBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: gitRoot }).then((r) => r.code === 0);
|
|
581
|
+
if (hasBranch) {
|
|
582
|
+
const sw = await runCommand("git", ["switch", branch], { cwd: gitRoot });
|
|
583
|
+
if (sw.code !== 0) {
|
|
584
|
+
const co = await runCommand("git", ["checkout", branch], { cwd: gitRoot });
|
|
585
|
+
if (co.code !== 0) throw new UserError("Failed to switch branch.", { details: co.stderr || co.stdout });
|
|
586
|
+
}
|
|
587
|
+
} else {
|
|
588
|
+
const sw = await runCommand("git", ["switch", "-c", branch], { cwd: gitRoot });
|
|
589
|
+
if (sw.code !== 0) {
|
|
590
|
+
const co = await runCommand("git", ["checkout", "-b", branch], { cwd: gitRoot });
|
|
591
|
+
if (co.code !== 0) throw new UserError("Failed to create branch.", { details: co.stderr || co.stdout });
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
596
|
+
if (!(await pathExists(changeDir))) {
|
|
597
|
+
await changeNewCommand({ changeId, title: options.title, noDesign: options.noDesign });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (options.enableHooks) {
|
|
601
|
+
await hooksInstallCommand({ targetPath: gitRoot });
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
console.log(`ok: active change: ${changeId} (branch: ${branch})`);
|
|
605
|
+
console.log("next:");
|
|
606
|
+
console.log(" - status: aiws change status");
|
|
607
|
+
console.log(" - next: aiws change next");
|
|
608
|
+
console.log(" - validate: aiws change validate --strict");
|
|
609
|
+
if (!options.enableHooks) console.log(" - (optional) enable hooks: aiws hooks install .");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* aiws change status
|
|
614
|
+
*
|
|
615
|
+
* @param {{ changeId?: string }} options
|
|
616
|
+
*/
|
|
617
|
+
export async function changeStatusCommand(options) {
|
|
618
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
619
|
+
await ensureTruthFiles(gitRoot);
|
|
620
|
+
|
|
621
|
+
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "status" });
|
|
622
|
+
assertValidChangeId(changeId);
|
|
623
|
+
|
|
624
|
+
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
625
|
+
if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
|
|
626
|
+
|
|
627
|
+
const proposal = await fileState(changeDir, "proposal.md");
|
|
628
|
+
const tasks = await fileState(changeDir, "tasks.md");
|
|
629
|
+
const design = await fileState(changeDir, "design.md");
|
|
630
|
+
|
|
631
|
+
const metaPath = path.join(changeDir, ".ws-change.json");
|
|
632
|
+
let metaState = "missing";
|
|
633
|
+
/** @type {any} */
|
|
634
|
+
let meta = null;
|
|
635
|
+
if (await pathExists(metaPath)) {
|
|
636
|
+
metaState = "ok";
|
|
637
|
+
try {
|
|
638
|
+
meta = JSON.parse(await readText(metaPath));
|
|
639
|
+
} catch {
|
|
640
|
+
metaState = "invalid";
|
|
641
|
+
meta = null;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const reqId = proposal.state === "ok" ? extractId("Req_ID", proposal.text) : "";
|
|
646
|
+
const probId = proposal.state === "ok" ? extractId("Problem_ID", proposal.text) : "";
|
|
647
|
+
const taskProgress = tasks.state === "ok" ? checkboxStats(tasks.text) : { total: 0, done: 0, unchecked: 0, hasCheckboxes: false };
|
|
648
|
+
|
|
649
|
+
const curTruth = await snapshotTruthShaOnly(gitRoot);
|
|
650
|
+
const { baselineLabel, baselineAt, driftFiles } = meta ? truthDrift(curTruth, meta) : { baselineLabel: "-", baselineAt: "", driftFiles: [] };
|
|
651
|
+
|
|
652
|
+
/** @type {string[]} */
|
|
653
|
+
const blockersStrict = [];
|
|
654
|
+
/** @type {string[]} */
|
|
655
|
+
const blockersArchive = [];
|
|
656
|
+
|
|
657
|
+
if (metaState !== "ok") {
|
|
658
|
+
blockersStrict.push("missing/invalid .ws-change.json (run `aiws change sync <id>` to regenerate)");
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
for (const [rel, st] of [
|
|
662
|
+
["proposal.md", proposal],
|
|
663
|
+
["tasks.md", tasks],
|
|
664
|
+
]) {
|
|
665
|
+
if (st.state !== "ok") {
|
|
666
|
+
blockersStrict.push(`missing/empty ${rel}`);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (st.placeholders > 0) blockersStrict.push(`unrendered template placeholders in ${rel}`);
|
|
670
|
+
if (st.wsTodo > 0) blockersStrict.push(`WS:TODO markers remain in ${rel}`);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (design.state === "ok") {
|
|
674
|
+
if (design.placeholders > 0) blockersStrict.push("unrendered template placeholders in design.md");
|
|
675
|
+
if (design.wsTodo > 0) blockersStrict.push("WS:TODO markers remain in design.md");
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (proposal.state === "ok" && !(reqId || probId)) {
|
|
679
|
+
blockersStrict.push("proposal.md missing attribution (Req_ID or Problem_ID)");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (!taskProgress.hasCheckboxes) blockersStrict.push("tasks.md has no checkbox tasks ('- [ ]' or '- [x]')");
|
|
683
|
+
if (driftFiles.length > 0) blockersStrict.push(`truth drift vs ${baselineLabel} baseline (run \`aiws change sync ${changeId}\`)`);
|
|
684
|
+
|
|
685
|
+
blockersArchive.push(...blockersStrict);
|
|
686
|
+
if (taskProgress.unchecked > 0) blockersArchive.push(`tasks.md still has unchecked tasks (${taskProgress.unchecked} items)`);
|
|
687
|
+
|
|
688
|
+
console.log(`✓ aiws change status: ${changeId}`);
|
|
689
|
+
console.log(`dir: ${path.relative(gitRoot, changeDir)}`);
|
|
690
|
+
console.log(`meta: ${metaState}`);
|
|
691
|
+
if (reqId) console.log(`Req_ID: ${reqId}`);
|
|
692
|
+
if (probId) console.log(`Problem_ID: ${probId}`);
|
|
693
|
+
console.log(`tasks: ${taskProgress.done}/${taskProgress.total} (unchecked=${taskProgress.unchecked})`);
|
|
694
|
+
console.log(`baseline: ${baselineLabel}${baselineAt ? ` (at=${baselineAt})` : ""}`);
|
|
695
|
+
console.log(`drift: ${driftFiles.length > 0 ? driftFiles.join(", ") : "(none)"}`);
|
|
696
|
+
|
|
697
|
+
console.log("");
|
|
698
|
+
console.log("Blockers (strict):");
|
|
699
|
+
if (blockersStrict.length === 0) console.log("- (none)");
|
|
700
|
+
else for (const b of blockersStrict) console.log(`- ${b}`);
|
|
701
|
+
|
|
702
|
+
console.log("");
|
|
703
|
+
console.log("Blockers (archive):");
|
|
704
|
+
if (blockersArchive.length === 0) console.log("- (none)");
|
|
705
|
+
else for (const b of blockersArchive) console.log(`- ${b}`);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* aiws change next
|
|
710
|
+
*
|
|
711
|
+
* @param {{ changeId?: string }} options
|
|
712
|
+
*/
|
|
713
|
+
export async function changeNextCommand(options) {
|
|
714
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
715
|
+
await ensureTruthFiles(gitRoot);
|
|
716
|
+
|
|
717
|
+
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "next" });
|
|
718
|
+
assertValidChangeId(changeId);
|
|
719
|
+
|
|
720
|
+
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
721
|
+
if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
|
|
722
|
+
|
|
723
|
+
const proposal = await fileState(changeDir, "proposal.md");
|
|
724
|
+
const tasks = await fileState(changeDir, "tasks.md");
|
|
725
|
+
const design = await fileState(changeDir, "design.md");
|
|
726
|
+
|
|
727
|
+
const metaPath = path.join(changeDir, ".ws-change.json");
|
|
728
|
+
/** @type {any} */
|
|
729
|
+
let meta = null;
|
|
730
|
+
if (await pathExists(metaPath)) {
|
|
731
|
+
try {
|
|
732
|
+
meta = JSON.parse(await readText(metaPath));
|
|
733
|
+
} catch {
|
|
734
|
+
meta = null;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const curTruth = await snapshotTruthShaOnly(gitRoot);
|
|
739
|
+
const drift = meta ? truthDrift(curTruth, meta).driftFiles : [];
|
|
740
|
+
|
|
741
|
+
/** @type {string[]} */
|
|
742
|
+
const actions = [];
|
|
743
|
+
|
|
744
|
+
if (!meta) {
|
|
745
|
+
actions.push(`补齐元信息并建立真值基线:\`aiws change sync ${changeId}\``);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (meta && drift.length > 0) {
|
|
749
|
+
actions.push("真值/合同已变化:先对齐 `AI_PROJECT.md` / `AI_WORKSPACE.md` / `REQUIREMENTS.md` 与 proposal/tasks");
|
|
750
|
+
actions.push(`确认后同步基线:\`aiws change sync ${changeId}\``);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (proposal.state !== "ok" || proposal.wsTodo > 0 || proposal.placeholders > 0) {
|
|
754
|
+
actions.push(`完善 proposal:\`$EDITOR ${path.join(changeDir, "proposal.md")}\``);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (design.state === "ok" && (design.wsTodo > 0 || design.placeholders > 0)) {
|
|
758
|
+
actions.push(`(可选) 完善 design:\`$EDITOR ${path.join(changeDir, "design.md")}\``);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (tasks.state !== "ok" || tasks.wsTodo > 0 || tasks.placeholders > 0) {
|
|
762
|
+
actions.push(`完善 tasks:\`$EDITOR ${path.join(changeDir, "tasks.md")}\``);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (proposal.state === "ok") {
|
|
766
|
+
const reqId = extractId("Req_ID", proposal.text);
|
|
767
|
+
const probId = extractId("Problem_ID", proposal.text);
|
|
768
|
+
if (!(reqId || probId)) {
|
|
769
|
+
actions.push("补齐归因:在 proposal.md 填写非空 `Req_ID` 或 `Problem_ID`(严格校验需要)");
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const prog = tasks.state === "ok" ? checkboxStats(tasks.text) : { total: 0, done: 0, unchecked: 0, hasCheckboxes: false };
|
|
774
|
+
if (!prog.hasCheckboxes) {
|
|
775
|
+
actions.push("tasks.md 需要至少一条 checkbox 任务(`- [ ]` / `- [x]`)");
|
|
776
|
+
} else if (prog.unchecked > 0) {
|
|
777
|
+
actions.push(`完成未勾选任务(${prog.unchecked} 项)`);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (actions.length > 0) {
|
|
781
|
+
actions.push(`严格校验:\`aiws change validate ${changeId} --strict\``);
|
|
782
|
+
for (const a of actions) console.log(`- ${a}`);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
console.log("- 生成交叉审计报告:在 AI 工具内运行 `/ws-review`(或按 AI_PROJECT.md 手工审计)");
|
|
787
|
+
console.log(`- 归档:\`aiws change archive ${changeId}\``);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* aiws change validate
|
|
792
|
+
*
|
|
793
|
+
* @param {{ changeId?: string, strict: boolean, allowTruthDrift: boolean }} options
|
|
794
|
+
*/
|
|
795
|
+
export async function changeValidateCommand(options) {
|
|
796
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
797
|
+
await ensureTruthFiles(gitRoot);
|
|
798
|
+
|
|
799
|
+
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "validate" });
|
|
800
|
+
assertValidChangeId(changeId);
|
|
801
|
+
|
|
802
|
+
const checker = await resolveWsChangeChecker(gitRoot);
|
|
803
|
+
const args = [...checker.args, "--workspace-root", gitRoot, "--change-id", changeId];
|
|
804
|
+
if (options.strict) args.push("--strict");
|
|
805
|
+
if (options.allowTruthDrift) args.push("--allow-truth-drift");
|
|
806
|
+
|
|
807
|
+
const res = await runPython(gitRoot, ["-u", ...args]);
|
|
808
|
+
if (res.stdout) process.stdout.write(res.stdout);
|
|
809
|
+
if (res.stderr) process.stderr.write(res.stderr);
|
|
810
|
+
if (res.code !== 0) {
|
|
811
|
+
throw new UserError("");
|
|
812
|
+
}
|
|
813
|
+
console.log(`ok: change validated (${changeId})`);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* aiws change sync
|
|
818
|
+
*
|
|
819
|
+
* @param {{ changeId?: string }} options
|
|
820
|
+
*/
|
|
821
|
+
export async function changeSyncCommand(options) {
|
|
822
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
823
|
+
await ensureTruthFiles(gitRoot);
|
|
824
|
+
|
|
825
|
+
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "sync" });
|
|
826
|
+
assertValidChangeId(changeId);
|
|
827
|
+
|
|
828
|
+
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
829
|
+
if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
|
|
830
|
+
|
|
831
|
+
const metaPath = path.join(changeDir, ".ws-change.json");
|
|
832
|
+
/** @type {any} */
|
|
833
|
+
let meta = null;
|
|
834
|
+
if (await pathExists(metaPath)) {
|
|
835
|
+
try {
|
|
836
|
+
meta = JSON.parse(await readText(metaPath));
|
|
837
|
+
} catch {
|
|
838
|
+
meta = null;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const truth = await snapshotTruthFiles(gitRoot);
|
|
843
|
+
const nowIso = nowIsoUtc();
|
|
844
|
+
const nowTs = nowUnixSeconds();
|
|
845
|
+
|
|
846
|
+
if (!meta) {
|
|
847
|
+
meta = { ws_change_version: 1, id: changeId, title: changeId, created_at: nowIso, base_truth_files: truth };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const createdTruth = meta.base_truth_files && typeof meta.base_truth_files === "object" ? meta.base_truth_files : {};
|
|
851
|
+
const syncedTruth = meta.synced_truth_files && typeof meta.synced_truth_files === "object" ? meta.synced_truth_files : {};
|
|
852
|
+
const baseline = Object.keys(syncedTruth).length > 0 ? syncedTruth : createdTruth;
|
|
853
|
+
const baselineLabel = Object.keys(syncedTruth).length > 0 ? "last sync" : "creation";
|
|
854
|
+
const baselineAt = Object.keys(syncedTruth).length > 0 ? String(meta.synced_at || "") : String(meta.created_at || "");
|
|
855
|
+
|
|
856
|
+
/** @type {Array<{file: string, from: string | null, to: string | null}>} */
|
|
857
|
+
const changed = [];
|
|
858
|
+
|
|
859
|
+
for (const [rel, info] of Object.entries(truth)) {
|
|
860
|
+
const curSha = info && typeof info === "object" ? String(info.sha256 || "") : "";
|
|
861
|
+
const baseSha =
|
|
862
|
+
baseline && typeof baseline === "object" && baseline[rel] && typeof baseline[rel] === "object" ? String(baseline[rel].sha256 || "") : "";
|
|
863
|
+
if (baseSha && curSha && baseSha !== curSha) {
|
|
864
|
+
changed.push({ file: rel, from: baseSha, to: curSha });
|
|
865
|
+
} else if (!baseSha && curSha) {
|
|
866
|
+
changed.push({ file: rel, from: null, to: curSha });
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (baseline && typeof baseline === "object") {
|
|
871
|
+
for (const rel of Object.keys(baseline)) {
|
|
872
|
+
if (!truth[rel]) {
|
|
873
|
+
const fromSha = baseline[rel] && typeof baseline[rel] === "object" ? String(baseline[rel].sha256 || "") : null;
|
|
874
|
+
changed.push({ file: rel, from: fromSha || null, to: null });
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
meta.synced_at = nowIso;
|
|
880
|
+
meta.synced_truth_files = truth;
|
|
881
|
+
let events = meta.sync_events;
|
|
882
|
+
if (!Array.isArray(events)) events = [];
|
|
883
|
+
events.push({ synced_at: nowIso, baseline_from: baselineLabel, baseline_at: baselineAt, changed });
|
|
884
|
+
meta.sync_events = events.slice(-20);
|
|
885
|
+
|
|
886
|
+
await writeText(metaPath, JSON.stringify(meta, null, 2) + "\n");
|
|
887
|
+
|
|
888
|
+
const stampDir = path.join(gitRoot, ".agentdocs", "tmp", "change-sync");
|
|
889
|
+
await ensureDir(stampDir);
|
|
890
|
+
const stampPath = path.join(stampDir, `${nowStampUtc()}-${changeId}.json`);
|
|
891
|
+
const stamp = {
|
|
892
|
+
timestamp: nowTs,
|
|
893
|
+
ws_root: gitRoot,
|
|
894
|
+
change_id: changeId,
|
|
895
|
+
change_dir: changeDir,
|
|
896
|
+
synced_at: nowIso,
|
|
897
|
+
previous_baseline: { label: baselineLabel, at: baselineAt, truth_files: baseline },
|
|
898
|
+
new_baseline: truth,
|
|
899
|
+
changed,
|
|
900
|
+
note: "aiws change sync stamp; does not contain secrets.",
|
|
901
|
+
};
|
|
902
|
+
await writeText(stampPath, JSON.stringify(stamp, null, 2) + "\n");
|
|
903
|
+
|
|
904
|
+
console.log(`✓ aiws change sync: ${changeId}`);
|
|
905
|
+
console.log(`meta: ${path.relative(gitRoot, metaPath)}`);
|
|
906
|
+
console.log(`stamp: ${path.relative(gitRoot, stampPath)}`);
|
|
907
|
+
if (changed.length > 0) {
|
|
908
|
+
console.log("");
|
|
909
|
+
console.log("Changed files:");
|
|
910
|
+
for (const c of changed) console.log(`- ${c.file}`);
|
|
911
|
+
} else {
|
|
912
|
+
console.log("");
|
|
913
|
+
console.log("No changes detected vs baseline.");
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* aiws change archive
|
|
919
|
+
*
|
|
920
|
+
* @param {{ changeId?: string, datePrefix?: string, force: boolean }} options
|
|
921
|
+
*/
|
|
922
|
+
export async function changeArchiveCommand(options) {
|
|
923
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
924
|
+
await ensureTruthFiles(gitRoot);
|
|
925
|
+
|
|
926
|
+
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "archive" });
|
|
927
|
+
assertValidChangeId(changeId);
|
|
928
|
+
|
|
929
|
+
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
930
|
+
if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
|
|
931
|
+
|
|
932
|
+
// Strict validate before archiving.
|
|
933
|
+
await changeValidateCommand({ changeId, strict: true, allowTruthDrift: options.force });
|
|
934
|
+
|
|
935
|
+
const tasksAbs = path.join(changeDir, "tasks.md");
|
|
936
|
+
if (await pathExists(tasksAbs)) {
|
|
937
|
+
const t = await readText(tasksAbs);
|
|
938
|
+
if (/- \[ \]/.test(t)) {
|
|
939
|
+
if (!options.force) {
|
|
940
|
+
throw new UserError("tasks.md still has unchecked tasks; complete them or pass --force");
|
|
941
|
+
}
|
|
942
|
+
console.error("warn: tasks.md still has unchecked tasks; continuing due to --force");
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const archiveRoot = path.join(gitRoot, "changes", "archive");
|
|
947
|
+
await ensureDir(archiveRoot);
|
|
948
|
+
|
|
949
|
+
const prefix = (options.datePrefix ? String(options.datePrefix) : todayLocal()).trim() || todayLocal();
|
|
950
|
+
let dest = path.join(archiveRoot, `${prefix}-${changeId}`);
|
|
951
|
+
if (await pathExists(dest)) {
|
|
952
|
+
dest = path.join(archiveRoot, `${prefix}-${changeId}-${nowStampUtc()}`);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
await fs.rename(changeDir, dest);
|
|
956
|
+
console.log(`✓ aiws change archive: ${changeId}`);
|
|
957
|
+
console.log(`archived_to: ${path.relative(gitRoot, dest)}`);
|
|
958
|
+
|
|
959
|
+
const metaPath = path.join(dest, ".ws-change.json");
|
|
960
|
+
if (await pathExists(metaPath)) {
|
|
961
|
+
try {
|
|
962
|
+
const meta = JSON.parse(await readText(metaPath));
|
|
963
|
+
const truth = await snapshotTruthFiles(gitRoot);
|
|
964
|
+
meta.archived_at = nowIsoUtc();
|
|
965
|
+
meta.archived_to = dest;
|
|
966
|
+
meta.archived_truth_files = truth;
|
|
967
|
+
await writeText(metaPath, JSON.stringify(meta, null, 2) + "\n");
|
|
968
|
+
console.log(`meta_updated: ${path.relative(gitRoot, metaPath)}`);
|
|
969
|
+
} catch {
|
|
970
|
+
// ignore invalid meta
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const stampDir = path.join(gitRoot, ".agentdocs", "tmp", "change-archive");
|
|
975
|
+
await ensureDir(stampDir);
|
|
976
|
+
const stampPath = path.join(stampDir, `${nowStampUtc()}-${changeId}.json`);
|
|
977
|
+
const truth = await snapshotTruthFiles(gitRoot);
|
|
978
|
+
const stamp = {
|
|
979
|
+
timestamp: nowUnixSeconds(),
|
|
980
|
+
ws_root: gitRoot,
|
|
981
|
+
archived_to: dest,
|
|
982
|
+
truth_files: truth,
|
|
983
|
+
note: "aiws change archive stamp; does not contain secrets.",
|
|
984
|
+
};
|
|
985
|
+
await writeText(stampPath, JSON.stringify(stamp, null, 2) + "\n");
|
|
986
|
+
console.log(`stamp: ${path.relative(gitRoot, stampPath)}`);
|
|
987
|
+
}
|