@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.
@@ -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
+ }