@aipper/aiws 0.0.20 → 0.0.23
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 +1 -1
- package/package.json +2 -2
- package/src/cli.js +63 -3
- package/src/commands/change.js +1167 -14
- package/src/commands/update.js +20 -0
package/src/commands/change.js
CHANGED
|
@@ -320,6 +320,412 @@ function splitDeclaredValues(s) {
|
|
|
320
320
|
.filter(Boolean);
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
/**
|
|
324
|
+
* @param {string} p
|
|
325
|
+
*/
|
|
326
|
+
function normalizeSlashes(p) {
|
|
327
|
+
return String(p || "").replaceAll("\\", "/");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Extract bullet lines under a markdown "## ..." heading.
|
|
332
|
+
*
|
|
333
|
+
* @param {string} text
|
|
334
|
+
* @param {RegExp} headingRe
|
|
335
|
+
* @param {{ max?: number }} [options]
|
|
336
|
+
* @returns {string[]} trimmed bullet lines like "- foo"
|
|
337
|
+
*/
|
|
338
|
+
function extractBulletsUnderHeading(text, headingRe, options) {
|
|
339
|
+
const max = typeof options?.max === "number" ? options.max : 5;
|
|
340
|
+
const lines = String(text || "").split("\n");
|
|
341
|
+
let inSection = false;
|
|
342
|
+
/** @type {string[]} */
|
|
343
|
+
const out = [];
|
|
344
|
+
for (const raw of lines) {
|
|
345
|
+
const line = String(raw || "");
|
|
346
|
+
const trimmed = line.trim();
|
|
347
|
+
if (!inSection) {
|
|
348
|
+
if (headingRe.test(trimmed)) inSection = true;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (/^##\s+/.test(trimmed)) break;
|
|
352
|
+
if (trimmed.startsWith("-")) {
|
|
353
|
+
out.push(trimmed);
|
|
354
|
+
if (out.length >= max) break;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {string} bulletLine
|
|
362
|
+
*/
|
|
363
|
+
function isMeaningfulBullet(bulletLine) {
|
|
364
|
+
const s = String(bulletLine || "").trim();
|
|
365
|
+
if (!s.startsWith("-")) return false;
|
|
366
|
+
if (s.includes("WS:TODO")) return false;
|
|
367
|
+
if (s.includes("<!--")) return false;
|
|
368
|
+
if (s === "-" || s === "- (none)" || s === "- (missing)") return false;
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Find an archived change by ID in the archive directory.
|
|
374
|
+
* @param {string} archiveDir
|
|
375
|
+
* @param {string} changeId
|
|
376
|
+
* @returns {Promise<string | null>} Full path to archived change dir, or null if not found.
|
|
377
|
+
*/
|
|
378
|
+
async function findArchivedChange(archiveDir, changeId) {
|
|
379
|
+
if (!(await pathExists(archiveDir))) return null;
|
|
380
|
+
try {
|
|
381
|
+
const entries = await fs.readdir(archiveDir, { withFileTypes: true });
|
|
382
|
+
for (const e of entries) {
|
|
383
|
+
if (!e.isDirectory()) continue;
|
|
384
|
+
// Archive dirs are named like: 2026-03-12-<change-id> or 2026-03-12-<change-id>-<timestamp>
|
|
385
|
+
if (e.name.endsWith(`-${changeId}`) || e.name.includes(`-${changeId}-`)) {
|
|
386
|
+
return path.join(archiveDir, e.name);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
} catch {
|
|
390
|
+
// ignore
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Extract a brief summary from handoff.md content.
|
|
397
|
+
* @param {string} text
|
|
398
|
+
* @returns {string}
|
|
399
|
+
*/
|
|
400
|
+
function extractHandoffSummary(text) {
|
|
401
|
+
// Try to extract "本次完成" or "What Changed" section
|
|
402
|
+
const lines = String(text || "").split("\n");
|
|
403
|
+
let inSection = false;
|
|
404
|
+
const summary = [];
|
|
405
|
+
for (const line of lines) {
|
|
406
|
+
if (/^##\s*(本次完成|What Changed|Completed)/i.test(line)) {
|
|
407
|
+
inSection = true;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (inSection && /^##/.test(line)) break;
|
|
411
|
+
if (inSection && line.trim().startsWith("-")) {
|
|
412
|
+
summary.push(line.trim());
|
|
413
|
+
if (summary.length >= 3) break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return summary.join(" ");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Check Depends_On dependencies and print handoff summaries.
|
|
421
|
+
* @param {string} gitRoot - The git root directory (main or worktree)
|
|
422
|
+
* @param {string} changeId
|
|
423
|
+
* @param {string} changeDir - The change directory (may be in worktree)
|
|
424
|
+
*/
|
|
425
|
+
async function checkDependenciesAndPrintHandoff(gitRoot, changeId, changeDir) {
|
|
426
|
+
const proposalPath = path.join(changeDir, "proposal.md");
|
|
427
|
+
if (!(await pathExists(proposalPath))) return;
|
|
428
|
+
|
|
429
|
+
const proposalText = await readText(proposalPath);
|
|
430
|
+
const dependsOn = extractId("Depends_On", proposalText);
|
|
431
|
+
if (!dependsOn) return;
|
|
432
|
+
|
|
433
|
+
const deps = splitDeclaredValues(dependsOn).filter((d) => d && d !== changeId);
|
|
434
|
+
if (deps.length === 0) return;
|
|
435
|
+
const archiveDir = path.join(gitRoot, "changes", "archive");
|
|
436
|
+
|
|
437
|
+
for (const dep of deps) {
|
|
438
|
+
const depDir = changeDirAbs(gitRoot, dep);
|
|
439
|
+
const depArchived = await findArchivedChange(archiveDir, dep);
|
|
440
|
+
if (!depArchived && !(await pathExists(depDir))) {
|
|
441
|
+
console.error(`warn: Depends_On "${dep}" not found (neither active nor archived)`);
|
|
442
|
+
console.error(`hint: if you are using --worktree, ensure the dependency is archived under changes/archive/ and committed to HEAD in this worktree.`);
|
|
443
|
+
} else if (!depArchived) {
|
|
444
|
+
console.error(`warn: Depends_On "${dep}" is not yet archived (still active at changes/${dep})`);
|
|
445
|
+
} else {
|
|
446
|
+
// Dependency is archived, check for handoff.md
|
|
447
|
+
const handoffPath = path.join(depArchived, "handoff.md");
|
|
448
|
+
if (await pathExists(handoffPath)) {
|
|
449
|
+
console.log(`info: reading handoff from dependency "${dep}"`);
|
|
450
|
+
const handoffText = await readText(handoffPath);
|
|
451
|
+
const summary = extractHandoffSummary(handoffText);
|
|
452
|
+
if (summary) {
|
|
453
|
+
console.log(` ${summary}`);
|
|
454
|
+
}
|
|
455
|
+
} else {
|
|
456
|
+
console.error(`warn: dependency "${dep}" is archived but missing handoff.md: ${path.relative(gitRoot, handoffPath)}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Generate handoff.md content for a completed change.
|
|
464
|
+
* @param {string} gitRoot
|
|
465
|
+
* @param {string} changeDir - The archived change directory
|
|
466
|
+
* @param {string} changeId
|
|
467
|
+
* @returns {Promise<string>}
|
|
468
|
+
*/
|
|
469
|
+
async function generateHandoffContent(gitRoot, changeDir, changeId) {
|
|
470
|
+
const proposalPath = path.join(changeDir, "proposal.md");
|
|
471
|
+
const designPath = path.join(changeDir, "design.md");
|
|
472
|
+
|
|
473
|
+
let reqId = "";
|
|
474
|
+
let problemId = "";
|
|
475
|
+
let blocks = "";
|
|
476
|
+
let goal = "";
|
|
477
|
+
let baseBranch = "main";
|
|
478
|
+
/** @type {string[]} */
|
|
479
|
+
const decisions = [];
|
|
480
|
+
|
|
481
|
+
if (await pathExists(proposalPath)) {
|
|
482
|
+
const text = await readText(proposalPath);
|
|
483
|
+
reqId = extractId("Req_ID", text);
|
|
484
|
+
problemId = extractId("Problem_ID", text);
|
|
485
|
+
blocks = extractId("Blocks", text);
|
|
486
|
+
const declaredBase = extractId("Base_Branch", text);
|
|
487
|
+
if (declaredBase) baseBranch = declaredBase;
|
|
488
|
+
// Extract goal from 目标 section
|
|
489
|
+
const goalMatch = text.match(/\*\*目标[::]\*\*\s*\n([\s\S]*?)(?=\n\*\*非目标|$)/);
|
|
490
|
+
if (goalMatch) {
|
|
491
|
+
const goalLines = goalMatch[1].split("\n").filter((l) => l.trim().startsWith("-")).slice(0, 3);
|
|
492
|
+
goal = goalLines.map((l) => l.trim()).join("\n");
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Get changed files from git diff if possible
|
|
497
|
+
let changedFiles = [];
|
|
498
|
+
try {
|
|
499
|
+
const metaPath = path.join(changeDir, ".ws-change.json");
|
|
500
|
+
if (await pathExists(metaPath)) {
|
|
501
|
+
const meta = JSON.parse(await readText(metaPath));
|
|
502
|
+
if (meta && typeof meta === "object" && meta.base_branch) baseBranch = String(meta.base_branch || "").trim() || baseBranch;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const changeBranch = `change/${changeId}`;
|
|
506
|
+
const hasChangeBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${changeBranch}`], { cwd: gitRoot }).then((r) => r.code === 0);
|
|
507
|
+
const cur = await currentBranch(gitRoot);
|
|
508
|
+
const diffTarget = hasChangeBranch ? changeBranch : cur === changeBranch ? "HEAD" : "";
|
|
509
|
+
if (diffTarget) {
|
|
510
|
+
const diff = await runCommand("git", ["diff", "--name-only", `${baseBranch}...${diffTarget}`], { cwd: gitRoot });
|
|
511
|
+
if (diff.code === 0 && diff.stdout) {
|
|
512
|
+
changedFiles = diff.stdout.split("\n").filter(Boolean).slice(0, 20);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
} catch {
|
|
516
|
+
// ignore
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
if (await pathExists(designPath)) {
|
|
521
|
+
const designText = await readText(designPath);
|
|
522
|
+
decisions.push(...extractBulletsUnderHeading(designText, /^##\s*Decisions\s*$/i, { max: 12 }).filter(isMeaningfulBullet).slice(0, 8));
|
|
523
|
+
decisions.push(...extractBulletsUnderHeading(designText, /^##\s*关键决策\s*$/i, { max: 12 }).filter(isMeaningfulBullet).slice(0, 8));
|
|
524
|
+
} else if (await pathExists(proposalPath)) {
|
|
525
|
+
const proposalText = await readText(proposalPath);
|
|
526
|
+
const fromProposal = extractBulletsUnderHeading(proposalText, /^##\s*方案概述\b.*$/i, { max: 10 }).filter(isMeaningfulBullet).slice(0, 6);
|
|
527
|
+
for (const b of fromProposal) decisions.push(`- (from proposal) ${b.replace(/^-+\s*/, "")}`);
|
|
528
|
+
}
|
|
529
|
+
} catch {
|
|
530
|
+
// ignore
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const lines = [
|
|
534
|
+
`# Handoff: ${changeId}`,
|
|
535
|
+
"",
|
|
536
|
+
`> Archived: ${nowIsoUtc()}`,
|
|
537
|
+
"",
|
|
538
|
+
"## 本次完成",
|
|
539
|
+
"",
|
|
540
|
+
];
|
|
541
|
+
|
|
542
|
+
if (goal) {
|
|
543
|
+
lines.push(goal);
|
|
544
|
+
} else {
|
|
545
|
+
lines.push("- (see proposal.md for details)");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
lines.push("");
|
|
549
|
+
lines.push("## 改动文件");
|
|
550
|
+
lines.push("");
|
|
551
|
+
if (changedFiles.length > 0) {
|
|
552
|
+
for (const f of changedFiles) {
|
|
553
|
+
lines.push(`- ${f}`);
|
|
554
|
+
}
|
|
555
|
+
if (changedFiles.length === 20) {
|
|
556
|
+
lines.push("- ...(truncated)");
|
|
557
|
+
}
|
|
558
|
+
} else {
|
|
559
|
+
lines.push("- (see git log for details)");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
lines.push("");
|
|
563
|
+
lines.push("## 关键决策");
|
|
564
|
+
lines.push("");
|
|
565
|
+
if (decisions.length > 0) {
|
|
566
|
+
for (const d of decisions) lines.push(d);
|
|
567
|
+
} else {
|
|
568
|
+
lines.push("- (missing) add decisions to changes/<id>/design.md#Decisions");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
lines.push("");
|
|
572
|
+
lines.push("## 下一步建议");
|
|
573
|
+
lines.push("");
|
|
574
|
+
if (blocks) {
|
|
575
|
+
const blockIds = splitDeclaredValues(blocks);
|
|
576
|
+
for (const b of blockIds) {
|
|
577
|
+
lines.push(`- 可以开始: ${b}`);
|
|
578
|
+
}
|
|
579
|
+
} else {
|
|
580
|
+
lines.push("- (no Blocks declared)");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
lines.push("");
|
|
584
|
+
lines.push("## 绑定");
|
|
585
|
+
lines.push("");
|
|
586
|
+
lines.push(`- Change_ID: ${changeId}`);
|
|
587
|
+
if (reqId) lines.push(`- Req_ID: ${reqId}`);
|
|
588
|
+
if (problemId) lines.push(`- Problem_ID: ${problemId}`);
|
|
589
|
+
|
|
590
|
+
return lines.join("\n") + "\n";
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Evidence_Path is user-declared; we treat it as best-effort hints, not hard gates.
|
|
595
|
+
*
|
|
596
|
+
* @param {string} gitRoot
|
|
597
|
+
* @param {string} changeId
|
|
598
|
+
* @param {string[]} evidencePaths
|
|
599
|
+
* @returns {Promise<{
|
|
600
|
+
* entries: Array<{rel: string, abs: string, exists: boolean, kind: string, isAbsolute: boolean}>,
|
|
601
|
+
* counts: { total: number, exists: number, missing: number, tmp: number, persistent: number, other: number, absolute: number },
|
|
602
|
+
* missing: string[]
|
|
603
|
+
* }>}
|
|
604
|
+
*/
|
|
605
|
+
async function analyzeEvidencePaths(gitRoot, changeId, evidencePaths) {
|
|
606
|
+
const changePrefix = `changes/${changeId}/`;
|
|
607
|
+
const evidencePrefix = `changes/${changeId}/evidence/`;
|
|
608
|
+
const reviewPrefix = `changes/${changeId}/review/`;
|
|
609
|
+
|
|
610
|
+
const entries = await Promise.all(
|
|
611
|
+
(evidencePaths || []).map(async (raw) => {
|
|
612
|
+
const rel = String(raw || "").trim();
|
|
613
|
+
const isAbsolute = path.isAbsolute(rel);
|
|
614
|
+
const abs = isAbsolute ? rel : path.join(gitRoot, rel);
|
|
615
|
+
const exists = rel ? await pathExists(abs) : false;
|
|
616
|
+
|
|
617
|
+
const relNorm = normalizeSlashes(rel);
|
|
618
|
+
let kind = "other";
|
|
619
|
+
if (relNorm.startsWith(".agentdocs/tmp/") || relNorm.startsWith(".agentdocs/")) kind = "tmp";
|
|
620
|
+
else if (relNorm.startsWith(evidencePrefix) || relNorm.startsWith(reviewPrefix) || relNorm.startsWith(changePrefix)) kind = "persistent";
|
|
621
|
+
|
|
622
|
+
return { rel, abs: path.resolve(abs), exists, kind, isAbsolute };
|
|
623
|
+
}),
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
const missing = entries.filter((e) => e.rel && !e.exists).map((e) => e.rel);
|
|
627
|
+
const counts = {
|
|
628
|
+
total: entries.filter((e) => e.rel).length,
|
|
629
|
+
exists: entries.filter((e) => e.rel && e.exists).length,
|
|
630
|
+
missing: missing.length,
|
|
631
|
+
tmp: entries.filter((e) => e.rel && e.kind === "tmp").length,
|
|
632
|
+
persistent: entries.filter((e) => e.rel && e.kind === "persistent").length,
|
|
633
|
+
other: entries.filter((e) => e.rel && e.kind === "other").length,
|
|
634
|
+
absolute: entries.filter((e) => e.rel && e.isAbsolute).length,
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
return { entries: entries.filter((e) => e.rel), counts, missing };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* @param {string[]} a
|
|
642
|
+
* @param {string[]} b
|
|
643
|
+
*/
|
|
644
|
+
function mergeDeclaredValues(a, b) {
|
|
645
|
+
const seen = new Set();
|
|
646
|
+
/** @type {string[]} */
|
|
647
|
+
const out = [];
|
|
648
|
+
for (const raw of [...(a || []), ...(b || [])]) {
|
|
649
|
+
const v = String(raw || "").trim().replace(/^`+/, "").replace(/`+$/, "").trim();
|
|
650
|
+
if (!v) continue;
|
|
651
|
+
if (seen.has(v)) continue;
|
|
652
|
+
seen.add(v);
|
|
653
|
+
out.push(v);
|
|
654
|
+
}
|
|
655
|
+
return out;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* @param {string[]} values
|
|
660
|
+
*/
|
|
661
|
+
function formatDeclaredValues(values) {
|
|
662
|
+
return (values || []).join(", ");
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Replace or insert a simple "ID: value" style line in Markdown.
|
|
667
|
+
*
|
|
668
|
+
* @param {string} text
|
|
669
|
+
* @param {string[]} labelCandidates
|
|
670
|
+
* @param {string} value
|
|
671
|
+
* @param {{ insertAfterLabels?: string[] }} options
|
|
672
|
+
*/
|
|
673
|
+
function upsertIdLine(text, labelCandidates, value, options) {
|
|
674
|
+
const src = String(text || "");
|
|
675
|
+
const labels = Array.isArray(labelCandidates) ? labelCandidates : [];
|
|
676
|
+
for (const label of labels) {
|
|
677
|
+
const re = new RegExp(`^(.*${escapeRegExp(label)}.*?[:=][ \\t]*)(.*)$`, "m");
|
|
678
|
+
const m = re.exec(src);
|
|
679
|
+
if (!m) continue;
|
|
680
|
+
const prefix = m[1] || "";
|
|
681
|
+
const replaced = src.replace(re, `${prefix}${value}`);
|
|
682
|
+
return { ok: true, changed: replaced !== src, text: replaced, mode: "replaced", label };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Insert near the top (best-effort), after a nearby label if present.
|
|
686
|
+
const insertAfter = Array.isArray(options?.insertAfterLabels) ? options.insertAfterLabels : [];
|
|
687
|
+
const lines = src.split(/\r?\n/);
|
|
688
|
+
let idx = 0;
|
|
689
|
+
for (let i = 0; i < Math.min(lines.length, 80); i++) {
|
|
690
|
+
const line = lines[i] || "";
|
|
691
|
+
if (insertAfter.some((l) => line.includes(l))) idx = i + 1;
|
|
692
|
+
}
|
|
693
|
+
const insertedLabel = labels[0] || "Evidence_Path";
|
|
694
|
+
const newLine = `- ${insertedLabel}: ${value}`;
|
|
695
|
+
lines.splice(idx, 0, newLine);
|
|
696
|
+
const inserted = lines.join("\n");
|
|
697
|
+
return { ok: true, changed: inserted !== src, text: inserted, mode: "inserted", label: labels[0] || "" };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Special-case insertion for proposal.md template style.
|
|
702
|
+
*
|
|
703
|
+
* @param {string} proposalText
|
|
704
|
+
* @param {string} value
|
|
705
|
+
*/
|
|
706
|
+
function upsertProposalEvidencePath(proposalText, value) {
|
|
707
|
+
const src = String(proposalText || "");
|
|
708
|
+
const re = new RegExp(`^(.*${escapeRegExp("Evidence_Path")}.*?[:=][ \\t]*)(.*)$`, "m");
|
|
709
|
+
if (re.test(src)) {
|
|
710
|
+
const m = re.exec(src);
|
|
711
|
+
const prefix = m?.[1] || "";
|
|
712
|
+
const out = src.replace(re, `${prefix}${value}`);
|
|
713
|
+
return { changed: out !== src, text: out, mode: "replaced" };
|
|
714
|
+
}
|
|
715
|
+
// Insert right after Plan_File binding if possible.
|
|
716
|
+
const lines = src.split(/\r?\n/);
|
|
717
|
+
let idx = 0;
|
|
718
|
+
for (let i = 0; i < Math.min(lines.length, 120); i++) {
|
|
719
|
+
if ((lines[i] || "").includes("Plan_File")) {
|
|
720
|
+
idx = i + 1;
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
lines.splice(idx, 0, `- \`Evidence_Path\` = ${value}`);
|
|
725
|
+
const inserted = lines.join("\n");
|
|
726
|
+
return { changed: inserted !== src, text: inserted, mode: "inserted" };
|
|
727
|
+
}
|
|
728
|
+
|
|
323
729
|
/**
|
|
324
730
|
* @param {string} changeId
|
|
325
731
|
*/
|
|
@@ -327,6 +733,16 @@ function planVerifyHint(changeId) {
|
|
|
327
733
|
return `执行前质量门(优先):\`aiws change validate ${changeId} --strict\`(AI 工具中等价于 \`$ws-plan-verify\`)`;
|
|
328
734
|
}
|
|
329
735
|
|
|
736
|
+
/**
|
|
737
|
+
* @param {string[]} blockersStrict
|
|
738
|
+
* @param {{ unchecked: number }} tasks
|
|
739
|
+
*/
|
|
740
|
+
function inferChangePhase(blockersStrict, tasks) {
|
|
741
|
+
if ((blockersStrict || []).length > 0) return "planning";
|
|
742
|
+
if ((tasks?.unchecked || 0) > 0) return "dev";
|
|
743
|
+
return "deliver";
|
|
744
|
+
}
|
|
745
|
+
|
|
330
746
|
/**
|
|
331
747
|
* Compute change status as JSON-friendly object (used by dashboard and CLI).
|
|
332
748
|
*
|
|
@@ -364,6 +780,7 @@ export async function computeChangeStatus(gitRoot, changeId) {
|
|
|
364
780
|
const planFile = proposal.state === "ok" ? extractId("Plan_File", proposal.text) || extractId("Plan file", proposal.text) : "";
|
|
365
781
|
const evidencePath = proposal.state === "ok" ? extractId("Evidence_Path", proposal.text) || extractId("Evidence_Path(s)", proposal.text) : "";
|
|
366
782
|
const evidencePaths = splitDeclaredValues(evidencePath);
|
|
783
|
+
const evidence = await analyzeEvidencePaths(gitRoot, changeId, evidencePaths);
|
|
367
784
|
const taskProgress = tasks.state === "ok" ? checkboxStats(tasks.text) : { total: 0, done: 0, unchecked: 0, hasCheckboxes: false };
|
|
368
785
|
|
|
369
786
|
const curTruth = await snapshotTruthShaOnly(gitRoot);
|
|
@@ -399,16 +816,35 @@ export async function computeChangeStatus(gitRoot, changeId) {
|
|
|
399
816
|
blockersStrict.push("proposal.md missing attribution (Req_ID or Problem_ID)");
|
|
400
817
|
}
|
|
401
818
|
|
|
819
|
+
if (proposal.state === "ok" && !contractRow) blockersStrict.push("proposal.md missing Contract_Row binding");
|
|
820
|
+
|
|
821
|
+
if (proposal.state === "ok") {
|
|
822
|
+
const planFiles = splitDeclaredValues(planFile);
|
|
823
|
+
if (planFiles.length !== 1) blockersStrict.push("proposal.md Plan_File must contain exactly one plan path");
|
|
824
|
+
else {
|
|
825
|
+
const planRel = String(planFiles[0] || "").replace(/^\.\//, "");
|
|
826
|
+
if (path.isAbsolute(planRel)) blockersStrict.push("proposal.md Plan_File must be workspace-relative (not absolute)");
|
|
827
|
+
else {
|
|
828
|
+
if (!planRel.startsWith("plan/")) blockersStrict.push("proposal.md Plan_File should be under plan/ (recommended)");
|
|
829
|
+
if (!(await pathExists(path.join(gitRoot, planRel)))) blockersStrict.push(`proposal.md Plan_File missing: ${planRel}`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (proposal.state === "ok" && evidencePaths.length === 0) blockersStrict.push("proposal.md missing Evidence_Path binding");
|
|
835
|
+
|
|
402
836
|
if (!taskProgress.hasCheckboxes) blockersStrict.push("tasks.md has no checkbox tasks ('- [ ]' or '- [x]')");
|
|
403
837
|
if (driftFiles.length > 0) blockersStrict.push(`truth drift vs ${baselineLabel} baseline (run \`aiws change sync ${changeId}\`)`);
|
|
404
838
|
|
|
405
839
|
blockersArchive.push(...blockersStrict);
|
|
406
840
|
if (taskProgress.unchecked > 0) blockersArchive.push(`tasks.md still has unchecked tasks (${taskProgress.unchecked} items)`);
|
|
841
|
+
const phase = inferChangePhase(blockersStrict, taskProgress);
|
|
407
842
|
|
|
408
843
|
return {
|
|
409
844
|
ok: true,
|
|
410
845
|
changeId,
|
|
411
846
|
dir: path.relative(gitRoot, changeDir),
|
|
847
|
+
phase,
|
|
412
848
|
metaState,
|
|
413
849
|
reqId,
|
|
414
850
|
probId,
|
|
@@ -417,6 +853,7 @@ export async function computeChangeStatus(gitRoot, changeId) {
|
|
|
417
853
|
planFile,
|
|
418
854
|
evidencePaths,
|
|
419
855
|
},
|
|
856
|
+
evidence,
|
|
420
857
|
tasks: taskProgress,
|
|
421
858
|
baselineLabel,
|
|
422
859
|
baselineAt,
|
|
@@ -483,7 +920,7 @@ function groupCheckMessages(errors, warnings) {
|
|
|
483
920
|
*
|
|
484
921
|
* @param {string} gitRoot
|
|
485
922
|
* @param {string} changeId
|
|
486
|
-
* @param {{ strict: boolean, allowTruthDrift: boolean }} options
|
|
923
|
+
* @param {{ strict: boolean, allowTruthDrift: boolean, checkEvidence?: boolean, checkScope?: boolean }} options
|
|
487
924
|
*/
|
|
488
925
|
export async function validateChangeArtifacts(gitRoot, changeId, options) {
|
|
489
926
|
assertValidChangeId(changeId);
|
|
@@ -493,6 +930,8 @@ export async function validateChangeArtifacts(gitRoot, changeId, options) {
|
|
|
493
930
|
const args = [...checker.args, "--workspace-root", gitRoot, "--change-id", changeId];
|
|
494
931
|
if (options.strict) args.push("--strict");
|
|
495
932
|
if (options.allowTruthDrift) args.push("--allow-truth-drift");
|
|
933
|
+
if (options.checkEvidence) args.push("--check-evidence");
|
|
934
|
+
if (options.checkScope) args.push("--check-scope");
|
|
496
935
|
|
|
497
936
|
const res = await runPython(gitRoot, ["-u", ...args]);
|
|
498
937
|
const parsed = parseChangeCheckerOutput(res.stdout, res.stderr);
|
|
@@ -546,6 +985,69 @@ async function fileState(changeDir, rel) {
|
|
|
546
985
|
return { state: "ok", wsTodo: countWsTodo(text), placeholders: countPlaceholders(text), abs, text };
|
|
547
986
|
}
|
|
548
987
|
|
|
988
|
+
/**
|
|
989
|
+
* Append a change lifecycle event into changes/<id>/metrics.json.
|
|
990
|
+
*
|
|
991
|
+
* @param {string} gitRoot
|
|
992
|
+
* @param {string} changeId
|
|
993
|
+
* @param {string} type
|
|
994
|
+
* @param {any} payload
|
|
995
|
+
*/
|
|
996
|
+
async function appendMetricsEvent(gitRoot, changeId, type, payload) {
|
|
997
|
+
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
998
|
+
const metricsAbs = path.join(changeDir, "metrics.json");
|
|
999
|
+
/** @type {{ version: number, change_id: string, events: any[], updated_at?: string } | null} */
|
|
1000
|
+
let cur = null;
|
|
1001
|
+
if (await pathExists(metricsAbs)) {
|
|
1002
|
+
try {
|
|
1003
|
+
cur = JSON.parse(await readText(metricsAbs));
|
|
1004
|
+
} catch {
|
|
1005
|
+
cur = null;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
if (!cur || typeof cur !== "object") {
|
|
1009
|
+
cur = { version: 1, change_id: changeId, events: [] };
|
|
1010
|
+
}
|
|
1011
|
+
if (!Array.isArray(cur.events)) cur.events = [];
|
|
1012
|
+
cur.events.push({ type, timestamp: nowIsoUtc(), payload: payload ?? null });
|
|
1013
|
+
cur.events = cur.events.slice(-200);
|
|
1014
|
+
cur.updated_at = nowIsoUtc();
|
|
1015
|
+
await writeText(metricsAbs, JSON.stringify(cur, null, 2) + "\n");
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* @param {string} absDir
|
|
1020
|
+
* @param {(name: string) => boolean} predicate
|
|
1021
|
+
*/
|
|
1022
|
+
async function findLatestFileByMtime(absDir, predicate) {
|
|
1023
|
+
if (!(await pathExists(absDir))) return "";
|
|
1024
|
+
const entries = await fs.readdir(absDir, { withFileTypes: true });
|
|
1025
|
+
/** @type {{ abs: string, mtimeMs: number } | null} */
|
|
1026
|
+
let best = null;
|
|
1027
|
+
for (const e of entries) {
|
|
1028
|
+
if (!e.isFile()) continue;
|
|
1029
|
+
if (!predicate(e.name)) continue;
|
|
1030
|
+
const abs = path.join(absDir, e.name);
|
|
1031
|
+
let st;
|
|
1032
|
+
try {
|
|
1033
|
+
st = await fs.stat(abs);
|
|
1034
|
+
} catch {
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
const mtimeMs = Number(st.mtimeMs || 0);
|
|
1038
|
+
if (!best || mtimeMs > best.mtimeMs) best = { abs, mtimeMs };
|
|
1039
|
+
}
|
|
1040
|
+
return best ? best.abs : "";
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* @param {string} gitRoot
|
|
1045
|
+
* @param {string} absPath
|
|
1046
|
+
*/
|
|
1047
|
+
function relFromRoot(gitRoot, absPath) {
|
|
1048
|
+
return normalizeSlashes(path.relative(gitRoot, absPath));
|
|
1049
|
+
}
|
|
1050
|
+
|
|
549
1051
|
/**
|
|
550
1052
|
* @param {string} gitRoot
|
|
551
1053
|
*/
|
|
@@ -884,6 +1386,13 @@ async function changeNewAtGitRoot(gitRoot, options) {
|
|
|
884
1386
|
meta.base_branch_set_at = createdAt;
|
|
885
1387
|
}
|
|
886
1388
|
await writeText(path.join(changeDir, ".ws-change.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
1389
|
+
await appendMetricsEvent(gitRoot, changeId, "change_new", {
|
|
1390
|
+
title,
|
|
1391
|
+
created_at: createdAt,
|
|
1392
|
+
base_branch: baseBranch || "",
|
|
1393
|
+
templates: templates.resolvedFrom,
|
|
1394
|
+
no_design: options.noDesign === true,
|
|
1395
|
+
});
|
|
887
1396
|
|
|
888
1397
|
console.log(`✓ aiws change new: ${changeId}`);
|
|
889
1398
|
console.log(`dir: ${path.relative(gitRoot, changeDir)}`);
|
|
@@ -908,7 +1417,7 @@ export async function changeNewCommand(options) {
|
|
|
908
1417
|
/**
|
|
909
1418
|
* aiws change start
|
|
910
1419
|
*
|
|
911
|
-
* @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean, noSwitch: boolean, forceSwitch: boolean, worktree: boolean, worktreeDir?: string, submodules: boolean }} options
|
|
1420
|
+
* @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean, noSwitch: boolean, forceSwitch: boolean, allowDirty?: boolean, worktree: boolean, worktreeDir?: string, submodules: boolean }} options
|
|
912
1421
|
*/
|
|
913
1422
|
export async function changeStartCommand(options) {
|
|
914
1423
|
const gitRoot = await resolveGitRoot(process.cwd());
|
|
@@ -920,6 +1429,7 @@ export async function changeStartCommand(options) {
|
|
|
920
1429
|
const startFromBranch = await currentBranch(gitRoot);
|
|
921
1430
|
const branch = `change/${changeId}`;
|
|
922
1431
|
const branchRef = `refs/heads/${branch}`;
|
|
1432
|
+
const allowDirty = options.allowDirty === true;
|
|
923
1433
|
|
|
924
1434
|
if (options.worktree === true && options.noSwitch === true) {
|
|
925
1435
|
throw new UserError("change start: cannot combine --worktree with --no-switch");
|
|
@@ -959,6 +1469,24 @@ export async function changeStartCommand(options) {
|
|
|
959
1469
|
console.error("warn: recommended for submodules: aiws change start <change-id> --worktree");
|
|
960
1470
|
}
|
|
961
1471
|
|
|
1472
|
+
if (!allowDirty && (effectiveWorktree || !effectiveNoSwitch)) {
|
|
1473
|
+
const clean = await checkGitClean(gitRoot);
|
|
1474
|
+
if (!clean.clean) {
|
|
1475
|
+
const mode = effectiveWorktree ? "worktree" : "switch";
|
|
1476
|
+
throw new UserError(`Refusing to start change with a dirty working tree (${mode} mode).`, {
|
|
1477
|
+
details:
|
|
1478
|
+
`${clean.details}\n\n` +
|
|
1479
|
+
`Why: starting in ${mode} mode changes your checkout context; uncommitted changes may not appear where you expect.\n` +
|
|
1480
|
+
"Fix: commit first, then retry:\n" +
|
|
1481
|
+
" git add -A && git commit -m \"wip: save before change start\"\n" +
|
|
1482
|
+
"Fix: or stash:\n" +
|
|
1483
|
+
" git stash\n" +
|
|
1484
|
+
`Alt: prepare artifacts without switching: aiws change start ${changeId} --no-switch\n` +
|
|
1485
|
+
"Alt: proceed anyway (not recommended): add --allow-dirty",
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
962
1490
|
if (effectiveWorktree) {
|
|
963
1491
|
const prereq = await checkWorktreePrereqs(gitRoot);
|
|
964
1492
|
if (!prereq.ok) {
|
|
@@ -1036,18 +1564,48 @@ export async function changeStartCommand(options) {
|
|
|
1036
1564
|
await maybeRecordBaseBranch(changeDir, startFromBranch, branch);
|
|
1037
1565
|
}
|
|
1038
1566
|
|
|
1039
|
-
if
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1567
|
+
// Worktree mode: if this repo declares submodules, always initialize them in the new worktree.
|
|
1568
|
+
// --submodules is now implicit (kept for backwards compat but has no effect).
|
|
1569
|
+
if (hasGitmodules) {
|
|
1570
|
+
const hasEntries = await runCommand("git", ["config", "--file", ".gitmodules", "--get-regexp", "^submodule\\..*\\.path$"], { cwd: destKey }).then(
|
|
1571
|
+
(r) => r.code === 0 && String(r.stdout || "").trim().length > 0
|
|
1572
|
+
);
|
|
1573
|
+
if (hasEntries) {
|
|
1574
|
+
console.error("info: .gitmodules detected; initializing submodules in the new worktree.");
|
|
1575
|
+
const sm = await runCommand("git", ["submodule", "update", "--init", "--recursive"], { cwd: destKey });
|
|
1576
|
+
if (sm.code !== 0) throw new UserError("Failed to init/update submodules in worktree.", { details: sm.stderr || sm.stdout });
|
|
1577
|
+
|
|
1578
|
+
// Sanity check: fail fast if anything remains uninitialized or conflicted.
|
|
1579
|
+
const st = await runCommand("git", ["submodule", "status", "--recursive"], { cwd: destKey });
|
|
1580
|
+
const lines = String(st.stdout || "")
|
|
1581
|
+
.split("\n")
|
|
1582
|
+
.map((l) => l.trimEnd())
|
|
1583
|
+
.filter(Boolean);
|
|
1584
|
+
const bad = lines.filter((l) => l.startsWith("-") || l.startsWith("U"));
|
|
1585
|
+
if (bad.length > 0) {
|
|
1586
|
+
throw new UserError("Submodules not fully initialized in worktree.", {
|
|
1587
|
+
details: `${bad.slice(0, 20).join("\n")}${bad.length > 20 ? "\n..." : ""}\n\nHint: run in the worktree:\n git submodule update --init --recursive`,
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1045
1591
|
}
|
|
1046
1592
|
|
|
1047
1593
|
if (options.enableHooks) {
|
|
1048
1594
|
await hooksInstallCommand({ targetPath: gitRoot });
|
|
1049
1595
|
}
|
|
1050
1596
|
|
|
1597
|
+
await appendMetricsEvent(destKey, changeId, "change_start", {
|
|
1598
|
+
mode: "worktree",
|
|
1599
|
+
worktree: destKey,
|
|
1600
|
+
branch,
|
|
1601
|
+
from_branch: startFromBranch || "",
|
|
1602
|
+
submodules: true,
|
|
1603
|
+
hooks: options.enableHooks === true,
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
// Check Depends_On dependencies (worktree mode)
|
|
1607
|
+
await checkDependenciesAndPrintHandoff(destKey, changeId, changeDir);
|
|
1608
|
+
|
|
1051
1609
|
console.log(`ok: change worktree ready: ${changeId}`);
|
|
1052
1610
|
console.log(`worktree: ${destKey}`);
|
|
1053
1611
|
console.log(`branch: ${branch}`);
|
|
@@ -1123,12 +1681,23 @@ export async function changeStartCommand(options) {
|
|
|
1123
1681
|
await maybeRecordBaseBranch(changeDir, startFromBranch, branch);
|
|
1124
1682
|
}
|
|
1125
1683
|
|
|
1684
|
+
// Check Depends_On dependencies (non-worktree mode)
|
|
1685
|
+
await checkDependenciesAndPrintHandoff(gitRoot, changeId, changeDir);
|
|
1686
|
+
|
|
1126
1687
|
if (options.enableHooks) {
|
|
1127
1688
|
await hooksInstallCommand({ targetPath: gitRoot });
|
|
1128
1689
|
}
|
|
1129
1690
|
|
|
1130
1691
|
const cur = await currentBranch(gitRoot);
|
|
1131
1692
|
const curLabel = cur || "(detached)";
|
|
1693
|
+
await appendMetricsEvent(gitRoot, changeId, "change_start", {
|
|
1694
|
+
mode: effectiveNoSwitch ? "no-switch" : "switch",
|
|
1695
|
+
worktree: gitRoot,
|
|
1696
|
+
branch,
|
|
1697
|
+
from_branch: startFromBranch || "",
|
|
1698
|
+
current_branch: curLabel,
|
|
1699
|
+
hooks: options.enableHooks === true,
|
|
1700
|
+
});
|
|
1132
1701
|
if (effectiveNoSwitch && cur !== branch) {
|
|
1133
1702
|
console.log(`ok: prepared change: ${changeId} (branch: ${branch}${branchReady ? "" : " (pending)"}; current: ${curLabel})`);
|
|
1134
1703
|
console.log("next:");
|
|
@@ -1269,6 +1838,8 @@ export async function changeFinishCommand(options) {
|
|
|
1269
1838
|
throw new UserError("Failed to fast-forward merge change branch into target.", { details: `${merge.stderr || merge.stdout}${extra}` });
|
|
1270
1839
|
}
|
|
1271
1840
|
|
|
1841
|
+
await appendMetricsEvent(gitRoot, changeId, "finish", { into, from: changeBranch });
|
|
1842
|
+
|
|
1272
1843
|
console.log(`ok: finished change: ${changeId}`);
|
|
1273
1844
|
console.log(`into: ${into}`);
|
|
1274
1845
|
console.log(`from: ${changeBranch}`);
|
|
@@ -1289,15 +1860,34 @@ export async function changeStatusCommand(options) {
|
|
|
1289
1860
|
|
|
1290
1861
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "status" });
|
|
1291
1862
|
const st = await computeChangeStatus(gitRoot, changeId);
|
|
1863
|
+
const branch = await currentBranch(gitRoot);
|
|
1864
|
+
/** @type {{ changeWorktree?: string, targetWorktree?: string }} */
|
|
1865
|
+
const wtHint = {};
|
|
1866
|
+
try {
|
|
1867
|
+
const worktrees = await listGitWorktrees(gitRoot);
|
|
1868
|
+
const changeRef = `refs/heads/change/${changeId}`;
|
|
1869
|
+
const changeWt = worktrees.find((w) => String(w.branch || "") === changeRef);
|
|
1870
|
+
if (changeWt?.worktree && path.resolve(changeWt.worktree) !== path.resolve(gitRoot)) wtHint.changeWorktree = changeWt.worktree;
|
|
1871
|
+
} catch {
|
|
1872
|
+
// best-effort; ignore
|
|
1873
|
+
}
|
|
1292
1874
|
|
|
1293
1875
|
console.log(`✓ aiws change status: ${st.changeId}`);
|
|
1294
1876
|
console.log(`dir: ${st.dir}`);
|
|
1877
|
+
console.log(`phase: ${st.phase}`);
|
|
1878
|
+
console.log(`worktree: ${gitRoot}`);
|
|
1879
|
+
console.log(`branch: ${branch || "(detached HEAD)"}`);
|
|
1880
|
+
if (wtHint.changeWorktree) console.log(`note: change/<id> checked out in another worktree: ${wtHint.changeWorktree}`);
|
|
1295
1881
|
console.log(`meta: ${st.metaState}`);
|
|
1296
1882
|
if (st.reqId) console.log(`Req_ID: ${st.reqId}`);
|
|
1297
1883
|
if (st.probId) console.log(`Problem_ID: ${st.probId}`);
|
|
1298
1884
|
console.log(`tasks: ${st.tasks.done}/${st.tasks.total} (unchecked=${st.tasks.unchecked})`);
|
|
1299
1885
|
console.log(`baseline: ${st.baselineLabel}${st.baselineAt ? ` (at=${st.baselineAt})` : ""}`);
|
|
1300
1886
|
console.log(`drift: ${st.driftFiles.length > 0 ? st.driftFiles.join(", ") : "(none)"}`);
|
|
1887
|
+
if (st.evidence?.counts) {
|
|
1888
|
+
const c = st.evidence.counts;
|
|
1889
|
+
console.log(`evidence: declared=${c.total} (exists=${c.exists}, missing=${c.missing}, persistent=${c.persistent}, tmp=${c.tmp})`);
|
|
1890
|
+
}
|
|
1301
1891
|
|
|
1302
1892
|
console.log("");
|
|
1303
1893
|
console.log("Blockers (strict):");
|
|
@@ -1312,11 +1902,29 @@ export async function changeStatusCommand(options) {
|
|
|
1312
1902
|
console.log("");
|
|
1313
1903
|
console.log("Next (recommended):");
|
|
1314
1904
|
console.log(`- ${planVerifyHint(st.changeId)}`);
|
|
1315
|
-
if (st.blockersStrict.length
|
|
1316
|
-
console.log("- 质量门通过后再进入编码:在 AI 工具中运行 `$ws-dev`(小步实现 + 可复现验证)");
|
|
1317
|
-
} else {
|
|
1905
|
+
if (st.blockersStrict.length > 0) {
|
|
1318
1906
|
console.log("- 先修复 Blockers (strict) 后复跑质量门,再进入 `$ws-dev`");
|
|
1907
|
+
if (wtHint.changeWorktree) console.log(`- 若需要写代码:先切到 change worktree 再改动:\`cd ${wtHint.changeWorktree}\``);
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
if (st.tasks.unchecked > 0) {
|
|
1912
|
+
console.log("- 继续开发:在 AI 工具中运行 `$ws-dev`(小步实现 + 可复现验证)");
|
|
1913
|
+
console.log("- 提交/交付前强制门禁:`aiws validate .`(可选落盘:`aiws validate . --stamp`)");
|
|
1914
|
+
console.log(`- (可选)状态快照:\`aiws change state ${st.changeId} --write\``);
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
// deliver-ready hints
|
|
1919
|
+
if (st.evidence?.counts && st.evidence.counts.persistent === 0) {
|
|
1920
|
+
console.log("- 交付前建议生成持久证据:`changes/<id>/evidence/...`(并把路径回填到 proposal.md 的 Evidence_Path)");
|
|
1319
1921
|
}
|
|
1922
|
+
if (st.evidence?.missing && st.evidence.missing.length > 0) {
|
|
1923
|
+
const show = st.evidence.missing.slice(0, 5);
|
|
1924
|
+
console.log(`- 交付前建议补齐缺失证据文件(missing=${st.evidence.missing.length}):${show.join(", ")}${st.evidence.missing.length > show.length ? ", ..." : ""}`);
|
|
1925
|
+
}
|
|
1926
|
+
console.log(`- (可选)状态快照:\`aiws change state ${st.changeId} --write\``);
|
|
1927
|
+
console.log("- 进入交付:在 AI 工具中运行 `$ws-deliver`(submodule 感知提交 + 门禁 + 最终 `$ws-finish`)");
|
|
1320
1928
|
}
|
|
1321
1929
|
|
|
1322
1930
|
/**
|
|
@@ -1330,6 +1938,7 @@ export async function changeNextCommand(options) {
|
|
|
1330
1938
|
|
|
1331
1939
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "next" });
|
|
1332
1940
|
assertValidChangeId(changeId);
|
|
1941
|
+
const branch = await currentBranch(gitRoot);
|
|
1333
1942
|
|
|
1334
1943
|
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
1335
1944
|
if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
|
|
@@ -1355,6 +1964,26 @@ export async function changeNextCommand(options) {
|
|
|
1355
1964
|
/** @type {string[]} */
|
|
1356
1965
|
const actions = [];
|
|
1357
1966
|
|
|
1967
|
+
const inferred = inferChangeIdFromBranch(branch);
|
|
1968
|
+
if (!branch) {
|
|
1969
|
+
actions.push("当前为 detached HEAD:建议切到 `change/<change-id>` 分支后再继续(或显式传入 `<change-id>`)");
|
|
1970
|
+
} else if (inferred && inferred !== changeId) {
|
|
1971
|
+
actions.push(`当前分支为 \`${branch}\`(推断 change=${inferred}),但你在查看 change=${changeId}:建议先切换到 \`change/${changeId}\` 再执行开发/交付`);
|
|
1972
|
+
} else if (!inferred) {
|
|
1973
|
+
actions.push(`当前分支为 \`${branch}\`(非 change 分支):建议 \`aiws change start ${changeId} --hooks --switch\` 或 \`git switch change/${changeId}\``);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
try {
|
|
1977
|
+
const worktrees = await listGitWorktrees(gitRoot);
|
|
1978
|
+
const changeRef = `refs/heads/change/${changeId}`;
|
|
1979
|
+
const changeWt = worktrees.find((w) => String(w.branch || "") === changeRef);
|
|
1980
|
+
if (changeWt?.worktree && path.resolve(changeWt.worktree) !== path.resolve(gitRoot)) {
|
|
1981
|
+
actions.push(`change/${changeId} 分支在另一个 worktree:\`cd ${changeWt.worktree}\`(建议在该目录执行开发/交付相关命令)`);
|
|
1982
|
+
}
|
|
1983
|
+
} catch {
|
|
1984
|
+
// best-effort; ignore
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1358
1987
|
if (!meta) {
|
|
1359
1988
|
actions.push(`补齐元信息并建立真值基线:\`aiws change sync ${changeId}\``);
|
|
1360
1989
|
}
|
|
@@ -1382,6 +2011,33 @@ export async function changeNextCommand(options) {
|
|
|
1382
2011
|
if (!(reqId || probId)) {
|
|
1383
2012
|
actions.push("补齐归因:在 proposal.md 填写非空 `Req_ID` 或 `Problem_ID`(严格校验需要)");
|
|
1384
2013
|
}
|
|
2014
|
+
|
|
2015
|
+
const contractRow = extractId("Contract_Row", proposal.text) || extractId("Contract_Row(s)", proposal.text);
|
|
2016
|
+
if (!contractRow) actions.push("补齐绑定:在 proposal.md 填写非空 `Contract_Row`(严格校验需要)");
|
|
2017
|
+
|
|
2018
|
+
const planFile = extractId("Plan_File", proposal.text) || extractId("Plan file", proposal.text);
|
|
2019
|
+
const planFiles = splitDeclaredValues(planFile);
|
|
2020
|
+
if (planFiles.length !== 1) actions.push("补齐绑定:proposal.md `Plan_File` 必须且只能包含 1 个 plan 路径(严格校验需要)");
|
|
2021
|
+
else {
|
|
2022
|
+
const planRel = String(planFiles[0] || "").replace(/^\.\//, "");
|
|
2023
|
+
if (path.isAbsolute(planRel)) actions.push("修正绑定:proposal.md `Plan_File` 必须是工作区相对路径(不要绝对路径)");
|
|
2024
|
+
else if (!(await pathExists(path.join(gitRoot, planRel)))) actions.push(`补齐计划文件:缺少 ${planRel}(或修正 proposal.md 的 Plan_File)`);
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
const evidenceDecl = extractId("Evidence_Path", proposal.text) || extractId("Evidence_Path(s)", proposal.text);
|
|
2028
|
+
const evidencePaths = splitDeclaredValues(evidenceDecl);
|
|
2029
|
+
if (evidencePaths.length === 0) {
|
|
2030
|
+
actions.push("补齐绑定:在 proposal.md 填写非空 `Evidence_Path`(严格校验需要;建议优先写 `changes/<id>/evidence/...`)");
|
|
2031
|
+
} else {
|
|
2032
|
+
const evidence = await analyzeEvidencePaths(gitRoot, changeId, evidencePaths);
|
|
2033
|
+
if (evidence.counts.persistent === 0) {
|
|
2034
|
+
actions.push("交付前建议生成持久证据:`changes/<id>/evidence/...`(并把路径回填到 Evidence_Path;`.agentdocs/tmp/...` 可作为原始证据引用)");
|
|
2035
|
+
}
|
|
2036
|
+
if (evidence.missing.length > 0) {
|
|
2037
|
+
const show = evidence.missing.slice(0, 5);
|
|
2038
|
+
actions.push(`补齐缺失证据文件(missing=${evidence.missing.length}):${show.join(", ")}${evidence.missing.length > show.length ? ", ..." : ""}`);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
1385
2041
|
}
|
|
1386
2042
|
|
|
1387
2043
|
const prog = tasks.state === "ok" ? checkboxStats(tasks.text) : { total: 0, done: 0, unchecked: 0, hasCheckboxes: false };
|
|
@@ -1400,15 +2056,487 @@ export async function changeNextCommand(options) {
|
|
|
1400
2056
|
}
|
|
1401
2057
|
|
|
1402
2058
|
console.log(`- ${planVerifyHint(changeId)}`);
|
|
1403
|
-
|
|
1404
|
-
|
|
2059
|
+
if (prog.unchecked > 0) {
|
|
2060
|
+
console.log("- 若仍需继续编码,先通过质量门,再在 AI 工具中运行 `$ws-dev`");
|
|
2061
|
+
} else {
|
|
2062
|
+
console.log("- 进入交付:在 AI 工具中运行 `$ws-deliver`(门禁 + submodule 感知提交 + 最终 `$ws-finish`)");
|
|
2063
|
+
console.log("- 交付前门禁(推荐落盘):`aiws validate . --stamp`");
|
|
2064
|
+
}
|
|
2065
|
+
console.log("- 审计与证据(推荐):在 AI 工具内运行 `/ws-review`(或按 AI_PROJECT.md 手工审计)");
|
|
1405
2066
|
console.log(`- 归档:\`aiws change archive ${changeId}\``);
|
|
1406
2067
|
}
|
|
1407
2068
|
|
|
2069
|
+
/**
|
|
2070
|
+
* @param {string} gitRoot
|
|
2071
|
+
* @param {string} changeId
|
|
2072
|
+
*/
|
|
2073
|
+
async function computeChangeNextAdvice(gitRoot, changeId) {
|
|
2074
|
+
const st = await computeChangeStatus(gitRoot, changeId);
|
|
2075
|
+
const branch = await currentBranch(gitRoot);
|
|
2076
|
+
const inferred = inferChangeIdFromBranch(branch);
|
|
2077
|
+
const advice = {
|
|
2078
|
+
changeId,
|
|
2079
|
+
branch: branch || "",
|
|
2080
|
+
inferredChangeId: inferred || "",
|
|
2081
|
+
phase: st.phase,
|
|
2082
|
+
blockersStrict: st.blockersStrict,
|
|
2083
|
+
evidence: st.evidence?.counts || null,
|
|
2084
|
+
actions: [],
|
|
2085
|
+
prohibitions: [],
|
|
2086
|
+
recommended: [],
|
|
2087
|
+
};
|
|
2088
|
+
|
|
2089
|
+
if (!branch) {
|
|
2090
|
+
advice.actions.push("当前为 detached HEAD:建议切到 `change/<change-id>` 分支后再继续(或显式传入 `<change-id>`)");
|
|
2091
|
+
} else if (inferred && inferred !== changeId) {
|
|
2092
|
+
advice.actions.push(`当前分支为 \`${branch}\`(推断 change=${inferred}),但你在查看 change=${changeId}:建议先切换到 \`change/${changeId}\` 再执行开发/交付`);
|
|
2093
|
+
} else if (!inferred) {
|
|
2094
|
+
advice.actions.push(`当前分支为 \`${branch}\`(非 change 分支):建议 \`aiws change start ${changeId} --hooks --switch\` 或 \`git switch change/${changeId}\``);
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
try {
|
|
2098
|
+
const worktrees = await listGitWorktrees(gitRoot);
|
|
2099
|
+
const changeRef = `refs/heads/change/${changeId}`;
|
|
2100
|
+
const changeWt = worktrees.find((w) => String(w.branch || "") === changeRef);
|
|
2101
|
+
if (changeWt?.worktree && path.resolve(changeWt.worktree) !== path.resolve(gitRoot)) {
|
|
2102
|
+
advice.actions.push(`change/${changeId} 分支在另一个 worktree:\`cd ${changeWt.worktree}\`(建议在该目录执行开发/交付相关命令)`);
|
|
2103
|
+
advice.prohibitions.push("不要在当前 worktree 继续写代码(很可能不在 change 分支上)");
|
|
2104
|
+
}
|
|
2105
|
+
} catch {
|
|
2106
|
+
// ignore
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
if (st.metaState !== "ok") advice.actions.push(`补齐元信息并建立真值基线:\`aiws change sync ${changeId}\``);
|
|
2110
|
+
if (st.driftFiles.length > 0) {
|
|
2111
|
+
advice.actions.push("真值/合同已变化:先对齐 `AI_PROJECT.md` / `AI_WORKSPACE.md` / `REQUIREMENTS.md` 与 proposal/tasks");
|
|
2112
|
+
advice.actions.push(`确认后同步基线:\`aiws change sync ${changeId}\``);
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
if (st.blockersStrict.some((b) => b.includes("proposal.md") && b.includes("WS:TODO"))) {
|
|
2116
|
+
advice.actions.push(`完善 proposal:\`$EDITOR ${path.join(changeDirAbs(gitRoot, changeId), "proposal.md")}\``);
|
|
2117
|
+
}
|
|
2118
|
+
if (st.blockersStrict.some((b) => b.includes("tasks.md") && b.includes("WS:TODO"))) {
|
|
2119
|
+
advice.actions.push(`完善 tasks:\`$EDITOR ${path.join(changeDirAbs(gitRoot, changeId), "tasks.md")}\``);
|
|
2120
|
+
}
|
|
2121
|
+
if (st.blockersStrict.some((b) => b.includes("proposal.md missing attribution"))) {
|
|
2122
|
+
advice.actions.push("补齐归因:在 proposal.md 填写非空 `Req_ID` 或 `Problem_ID`(严格校验需要)");
|
|
2123
|
+
}
|
|
2124
|
+
if (st.blockersStrict.some((b) => b.includes("missing Contract_Row"))) {
|
|
2125
|
+
advice.actions.push("补齐绑定:在 proposal.md 填写非空 `Contract_Row`(严格校验需要)");
|
|
2126
|
+
}
|
|
2127
|
+
if (st.blockersStrict.some((b) => b.includes("Plan_File"))) {
|
|
2128
|
+
advice.actions.push("补齐绑定:修正 proposal.md 的 `Plan_File`(严格校验需要;必须存在且仅 1 个)");
|
|
2129
|
+
}
|
|
2130
|
+
if (st.blockersStrict.some((b) => b.includes("missing Evidence_Path"))) {
|
|
2131
|
+
advice.actions.push("补齐绑定:在 proposal.md 填写非空 `Evidence_Path`(严格校验需要;建议优先写 `changes/<id>/evidence/...`)");
|
|
2132
|
+
}
|
|
2133
|
+
if (st.tasks.unchecked > 0) advice.actions.push(`完成未勾选任务(${st.tasks.unchecked} 项)`);
|
|
2134
|
+
|
|
2135
|
+
if (st.blockersStrict.length > 0 && advice.actions.length === 0) {
|
|
2136
|
+
advice.actions.push("先修复 Blockers (strict) 后复跑质量门,再进入 `$ws-dev`");
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
if (advice.actions.length === 0) {
|
|
2140
|
+
if (st.tasks.unchecked > 0) {
|
|
2141
|
+
advice.recommended.push("继续开发:在 AI 工具中运行 `$ws-dev`(小步实现 + 可复现验证)");
|
|
2142
|
+
} else {
|
|
2143
|
+
advice.recommended.push("进入交付:在 AI 工具中运行 `$ws-deliver`(门禁 + submodule 感知提交 + 最终 `$ws-finish`)");
|
|
2144
|
+
if (advice.evidence && advice.evidence.persistent === 0) {
|
|
2145
|
+
advice.recommended.push("交付前建议生成持久证据并回填:`aiws change evidence <change-id>`");
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
return advice;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/**
|
|
2154
|
+
* aiws change state
|
|
2155
|
+
*
|
|
2156
|
+
* @param {{ changeId?: string, write: boolean }} options
|
|
2157
|
+
*/
|
|
2158
|
+
export async function changeStateCommand(options) {
|
|
2159
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
2160
|
+
await ensureTruthFiles(gitRoot);
|
|
2161
|
+
|
|
2162
|
+
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "state" });
|
|
2163
|
+
assertValidChangeId(changeId);
|
|
2164
|
+
|
|
2165
|
+
const st = await computeChangeStatus(gitRoot, changeId);
|
|
2166
|
+
const advice = await computeChangeNextAdvice(gitRoot, changeId);
|
|
2167
|
+
|
|
2168
|
+
const now = nowIsoUtc();
|
|
2169
|
+
/** @type {string[]} */
|
|
2170
|
+
const lines = [];
|
|
2171
|
+
lines.push(`# AIWS STATE: ${changeId}`);
|
|
2172
|
+
lines.push("");
|
|
2173
|
+
lines.push(`Generated: ${now}`);
|
|
2174
|
+
lines.push("");
|
|
2175
|
+
lines.push("## Context");
|
|
2176
|
+
lines.push(`- Worktree: \`${gitRoot}\``);
|
|
2177
|
+
lines.push(`- Branch: \`${advice.branch || "(detached)"}\``);
|
|
2178
|
+
lines.push("");
|
|
2179
|
+
lines.push("## Phase");
|
|
2180
|
+
lines.push(`- Phase: \`${st.phase}\``);
|
|
2181
|
+
lines.push(`- Tasks: ${st.tasks.done}/${st.tasks.total} (unchecked=${st.tasks.unchecked})`);
|
|
2182
|
+
lines.push("");
|
|
2183
|
+
lines.push("## Bindings");
|
|
2184
|
+
if (st.reqId) lines.push(`- Req_ID: \`${st.reqId}\``);
|
|
2185
|
+
if (st.probId) lines.push(`- Problem_ID: \`${st.probId}\``);
|
|
2186
|
+
if (st.bindings?.contractRow) lines.push(`- Contract_Row: \`${st.bindings.contractRow}\``);
|
|
2187
|
+
if (st.bindings?.planFile) lines.push(`- Plan_File: \`${st.bindings.planFile}\``);
|
|
2188
|
+
lines.push("");
|
|
2189
|
+
lines.push("## Evidence");
|
|
2190
|
+
if (st.evidence?.counts) {
|
|
2191
|
+
const c = st.evidence.counts;
|
|
2192
|
+
lines.push(`- Declared: \`${c.total}\` (exists=\`${c.exists}\`, missing=\`${c.missing}\`, persistent=\`${c.persistent}\`, tmp=\`${c.tmp}\`)`);
|
|
2193
|
+
} else {
|
|
2194
|
+
lines.push("- (unknown)");
|
|
2195
|
+
}
|
|
2196
|
+
lines.push("");
|
|
2197
|
+
lines.push("## Blockers (strict)");
|
|
2198
|
+
if (st.blockersStrict.length === 0) lines.push("- (none)");
|
|
2199
|
+
else for (const b of st.blockersStrict) lines.push(`- ${b}`);
|
|
2200
|
+
lines.push("");
|
|
2201
|
+
lines.push("## Next");
|
|
2202
|
+
lines.push(`- ${planVerifyHint(changeId)}`);
|
|
2203
|
+
for (const a of advice.actions) lines.push(`- ${a}`);
|
|
2204
|
+
for (const r of advice.recommended) lines.push(`- ${r}`);
|
|
2205
|
+
if (advice.prohibitions.length > 0) {
|
|
2206
|
+
lines.push("");
|
|
2207
|
+
lines.push("## Do Not");
|
|
2208
|
+
for (const p of advice.prohibitions) lines.push(`- ${p}`);
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
const out = lines.join("\n") + "\n";
|
|
2212
|
+
const dest = path.join(changeDirAbs(gitRoot, changeId), "STATE.md");
|
|
2213
|
+
if (options.write === true) {
|
|
2214
|
+
await writeText(dest, out);
|
|
2215
|
+
await appendMetricsEvent(gitRoot, changeId, "state_written", { path: path.relative(gitRoot, dest) });
|
|
2216
|
+
console.log(`ok: state written: ${path.relative(gitRoot, dest)}`);
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
process.stdout.write(out);
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/**
|
|
2223
|
+
* aiws metrics summary
|
|
2224
|
+
*
|
|
2225
|
+
* @param {{ since?: string }} options
|
|
2226
|
+
*/
|
|
2227
|
+
export async function metricsSummaryCommand(options) {
|
|
2228
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
2229
|
+
await ensureTruthFiles(gitRoot);
|
|
2230
|
+
|
|
2231
|
+
const sinceRaw = String(options.since || "").trim();
|
|
2232
|
+
const since = sinceRaw ? new Date(`${sinceRaw}T00:00:00Z`).getTime() : 0;
|
|
2233
|
+
if (sinceRaw && Number.isNaN(since)) throw new UserError("Invalid --since (expected YYYY-MM-DD).", { details: `since=${sinceRaw}` });
|
|
2234
|
+
|
|
2235
|
+
const changesRoot = path.join(gitRoot, "changes");
|
|
2236
|
+
if (!(await pathExists(changesRoot))) {
|
|
2237
|
+
console.log("(no changes dir)");
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
/** @type {Array<{ changeId: string, path: string, metrics: any }>} */
|
|
2242
|
+
const all = [];
|
|
2243
|
+
const scanDirs = [changesRoot, path.join(changesRoot, "archive")];
|
|
2244
|
+
for (const base of scanDirs) {
|
|
2245
|
+
if (!(await pathExists(base))) continue;
|
|
2246
|
+
const entries = await fs.readdir(base, { withFileTypes: true });
|
|
2247
|
+
for (const e of entries) {
|
|
2248
|
+
if (!e.isDirectory()) continue;
|
|
2249
|
+
const dir = path.join(base, e.name);
|
|
2250
|
+
const mAbs = path.join(dir, "metrics.json");
|
|
2251
|
+
if (!(await pathExists(mAbs))) continue;
|
|
2252
|
+
try {
|
|
2253
|
+
const m = JSON.parse(await readText(mAbs));
|
|
2254
|
+
const cid = String(m?.change_id || "").trim() || e.name;
|
|
2255
|
+
all.push({ changeId: cid, path: path.relative(gitRoot, mAbs), metrics: m });
|
|
2256
|
+
} catch {
|
|
2257
|
+
// ignore invalid
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
if (all.length === 0) {
|
|
2263
|
+
console.log("(no metrics.json found)");
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
/** @type {Record<string, number>} */
|
|
2268
|
+
const eventCounts = {};
|
|
2269
|
+
let strictValidations = 0;
|
|
2270
|
+
let strictPass = 0;
|
|
2271
|
+
let evidenceRuns = 0;
|
|
2272
|
+
let finishRuns = 0;
|
|
2273
|
+
/** @type {number[]} */
|
|
2274
|
+
const durations = [];
|
|
2275
|
+
|
|
2276
|
+
for (const row of all) {
|
|
2277
|
+
const events = Array.isArray(row.metrics?.events) ? row.metrics.events : [];
|
|
2278
|
+
const filtered = since
|
|
2279
|
+
? events.filter((ev) => {
|
|
2280
|
+
const t = Date.parse(String(ev?.timestamp || ""));
|
|
2281
|
+
return Number.isFinite(t) && t >= since;
|
|
2282
|
+
})
|
|
2283
|
+
: events;
|
|
2284
|
+
|
|
2285
|
+
for (const ev of filtered) {
|
|
2286
|
+
const type = String(ev?.type || "unknown");
|
|
2287
|
+
eventCounts[type] = (eventCounts[type] || 0) + 1;
|
|
2288
|
+
if (type === "validate") {
|
|
2289
|
+
const p = ev?.payload || {};
|
|
2290
|
+
if (p.strict === true) {
|
|
2291
|
+
strictValidations += 1;
|
|
2292
|
+
if (p.ok === true) strictPass += 1;
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
if (type === "evidence") evidenceRuns += 1;
|
|
2296
|
+
if (type === "finish") finishRuns += 1;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// Cycle time (best-effort): change_new -> finish
|
|
2300
|
+
const tNew = events.find((e) => e?.type === "change_new")?.timestamp;
|
|
2301
|
+
const tFin = events.find((e) => e?.type === "finish")?.timestamp;
|
|
2302
|
+
if (tNew && tFin) {
|
|
2303
|
+
const a = Date.parse(String(tNew));
|
|
2304
|
+
const b = Date.parse(String(tFin));
|
|
2305
|
+
if (Number.isFinite(a) && Number.isFinite(b) && b >= a) durations.push(Math.floor((b - a) / 1000));
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
const avgSec = durations.length > 0 ? Math.floor(durations.reduce((x, y) => x + y, 0) / durations.length) : 0;
|
|
2310
|
+
const avgMin = avgSec ? Math.floor(avgSec / 60) : 0;
|
|
2311
|
+
|
|
2312
|
+
console.log(`AIWS Metrics Summary${sinceRaw ? ` (since ${sinceRaw})` : ""}`);
|
|
2313
|
+
console.log("================================================");
|
|
2314
|
+
console.log(`Total metric files: ${all.length}`);
|
|
2315
|
+
console.log("");
|
|
2316
|
+
console.log("Event counts:");
|
|
2317
|
+
for (const k of Object.keys(eventCounts).sort()) console.log(`- ${k}: ${eventCounts[k]}`);
|
|
2318
|
+
console.log("");
|
|
2319
|
+
console.log("Quality:");
|
|
2320
|
+
console.log(`- Strict validate pass rate: ${strictValidations > 0 ? `${strictPass}/${strictValidations}` : "(no data)"}`);
|
|
2321
|
+
console.log(`- Evidence runs: ${evidenceRuns}`);
|
|
2322
|
+
console.log(`- Finish runs: ${finishRuns}`);
|
|
2323
|
+
console.log("");
|
|
2324
|
+
console.log("Cycle time (best-effort):");
|
|
2325
|
+
console.log(`- Avg change_new -> finish: ${durations.length > 0 ? `${avgMin} min` : "(no data)"}`);
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
/**
|
|
2329
|
+
* aiws change evidence
|
|
2330
|
+
*
|
|
2331
|
+
* @param {{ changeId?: string, noValidate: boolean, allowFail: boolean }} options
|
|
2332
|
+
*/
|
|
2333
|
+
export async function changeEvidenceCommand(options) {
|
|
2334
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
2335
|
+
await ensureTruthFiles(gitRoot);
|
|
2336
|
+
|
|
2337
|
+
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "evidence" });
|
|
2338
|
+
assertValidChangeId(changeId);
|
|
2339
|
+
|
|
2340
|
+
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
2341
|
+
if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
|
|
2342
|
+
|
|
2343
|
+
const proposal = await fileState(changeDir, "proposal.md");
|
|
2344
|
+
if (proposal.state !== "ok") throw new UserError("proposal.md is required for evidence generation.", { details: path.relative(gitRoot, proposal.abs) });
|
|
2345
|
+
|
|
2346
|
+
const planDecl = extractId("Plan_File", proposal.text) || extractId("Plan file", proposal.text);
|
|
2347
|
+
const planFiles = splitDeclaredValues(planDecl).map((p) => String(p || "").replace(/^\.\//, ""));
|
|
2348
|
+
const planRel = planFiles.length === 1 ? planFiles[0] : "";
|
|
2349
|
+
const planAbs = planRel ? path.join(gitRoot, planRel) : "";
|
|
2350
|
+
const planText = planAbs && (await pathExists(planAbs)) ? await readText(planAbs) : "";
|
|
2351
|
+
|
|
2352
|
+
const now = nowStampUtc();
|
|
2353
|
+
const evidenceDir = path.join(changeDir, "evidence");
|
|
2354
|
+
const reviewDir = path.join(changeDir, "review");
|
|
2355
|
+
await ensureDir(evidenceDir);
|
|
2356
|
+
await ensureDir(reviewDir);
|
|
2357
|
+
|
|
2358
|
+
/** @type {string[]} */
|
|
2359
|
+
const created = [];
|
|
2360
|
+
|
|
2361
|
+
const branch = await currentBranch(gitRoot);
|
|
2362
|
+
|
|
2363
|
+
const status = await computeChangeStatus(gitRoot, changeId);
|
|
2364
|
+
const statusOut = {
|
|
2365
|
+
generated_at: nowIsoUtc(),
|
|
2366
|
+
ws_root: gitRoot,
|
|
2367
|
+
change_id: changeId,
|
|
2368
|
+
branch: branch || "",
|
|
2369
|
+
phase: status.phase,
|
|
2370
|
+
tasks: status.tasks,
|
|
2371
|
+
bindings: status.bindings,
|
|
2372
|
+
truth: {
|
|
2373
|
+
baseline: { label: status.baselineLabel, at: status.baselineAt },
|
|
2374
|
+
drift_files: status.driftFiles,
|
|
2375
|
+
},
|
|
2376
|
+
evidence: status.evidence?.counts || null,
|
|
2377
|
+
note: "aiws change evidence status snapshot; does not contain secrets.",
|
|
2378
|
+
};
|
|
2379
|
+
const statusPath = path.join(evidenceDir, `change-status-${now}.json`);
|
|
2380
|
+
await writeText(statusPath, JSON.stringify(statusOut, null, 2) + "\n");
|
|
2381
|
+
created.push(relFromRoot(gitRoot, statusPath));
|
|
2382
|
+
|
|
2383
|
+
/** @type {any | null} */
|
|
2384
|
+
let validateRes = null;
|
|
2385
|
+
let validateFailed = false;
|
|
2386
|
+
/** @type {string} */
|
|
2387
|
+
let validatePathForError = "";
|
|
2388
|
+
if (!options.noValidate) {
|
|
2389
|
+
validateRes = await validateChangeArtifacts(gitRoot, changeId, { strict: true, allowTruthDrift: false });
|
|
2390
|
+
const validatePath = path.join(evidenceDir, `change-validate-strict-${now}.json`);
|
|
2391
|
+
await writeText(validatePath, JSON.stringify(validateRes, null, 2) + "\n");
|
|
2392
|
+
created.push(relFromRoot(gitRoot, validatePath));
|
|
2393
|
+
validateFailed = validateRes.ok !== true;
|
|
2394
|
+
validatePathForError = validatePath;
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
const validateStampDir = path.join(gitRoot, ".agentdocs", "tmp", "aiws-validate");
|
|
2398
|
+
const latestValidateStamp = await findLatestFileByMtime(validateStampDir, (n) => n.endsWith(".json"));
|
|
2399
|
+
if (latestValidateStamp) {
|
|
2400
|
+
const dst = path.join(evidenceDir, `aiws-validate-stamp-${now}.json`);
|
|
2401
|
+
await fs.copyFile(latestValidateStamp, dst);
|
|
2402
|
+
created.push(relFromRoot(gitRoot, dst));
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
const syncStampDir = path.join(gitRoot, ".agentdocs", "tmp", "change-sync");
|
|
2406
|
+
const latestSyncStamp = await findLatestFileByMtime(syncStampDir, (n) => n.endsWith(`-${changeId}.json`));
|
|
2407
|
+
if (latestSyncStamp) {
|
|
2408
|
+
const dst = path.join(evidenceDir, `change-sync-stamp-${now}.json`);
|
|
2409
|
+
await fs.copyFile(latestSyncStamp, dst);
|
|
2410
|
+
created.push(relFromRoot(gitRoot, dst));
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
const reviewAbs = path.join(reviewDir, "codex-review.md");
|
|
2414
|
+
if (!(await pathExists(reviewAbs))) {
|
|
2415
|
+
const tmpReview = path.join(gitRoot, ".agentdocs", "tmp", "review", "codex-review.md");
|
|
2416
|
+
if (await pathExists(tmpReview)) {
|
|
2417
|
+
await fs.copyFile(tmpReview, reviewAbs);
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
if (await pathExists(reviewAbs)) created.push(relFromRoot(gitRoot, reviewAbs));
|
|
2421
|
+
|
|
2422
|
+
// Collect all review files (support multiple reviewers)
|
|
2423
|
+
if (await pathExists(reviewDir)) {
|
|
2424
|
+
try {
|
|
2425
|
+
const reviewEntries = await fs.readdir(reviewDir, { withFileTypes: true });
|
|
2426
|
+
for (const e of reviewEntries) {
|
|
2427
|
+
if (!e.isFile()) continue;
|
|
2428
|
+
if (e.name === "codex-review.md") continue; // already added
|
|
2429
|
+
if (e.name.endsWith(".md")) {
|
|
2430
|
+
created.push(relFromRoot(gitRoot, path.join(reviewDir, e.name)));
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
} catch {
|
|
2434
|
+
// ignore
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
// Delivery summary (persistent, human-readable).
|
|
2439
|
+
const summaryAbs = path.join(evidenceDir, `delivery-summary-${now}.md`);
|
|
2440
|
+
/** @type {string[]} */
|
|
2441
|
+
const summaryLines = [];
|
|
2442
|
+
summaryLines.push(`# Delivery Summary: ${changeId}`);
|
|
2443
|
+
summaryLines.push("");
|
|
2444
|
+
summaryLines.push(`Generated: ${nowIsoUtc()}`);
|
|
2445
|
+
summaryLines.push("");
|
|
2446
|
+
summaryLines.push("## Context");
|
|
2447
|
+
summaryLines.push(`- Worktree: \`${gitRoot}\``);
|
|
2448
|
+
summaryLines.push(`- Branch: \`${branch || "(detached)"}\``);
|
|
2449
|
+
summaryLines.push("");
|
|
2450
|
+
summaryLines.push("## Status");
|
|
2451
|
+
summaryLines.push(`- Phase: \`${status.phase}\``);
|
|
2452
|
+
summaryLines.push(`- Tasks: ${status.tasks.done}/${status.tasks.total} (unchecked=${status.tasks.unchecked})`);
|
|
2453
|
+
summaryLines.push(`- Truth drift: ${status.driftFiles.length > 0 ? status.driftFiles.join(", ") : "(none)"}`);
|
|
2454
|
+
summaryLines.push("");
|
|
2455
|
+
summaryLines.push("## Bindings");
|
|
2456
|
+
if (status.reqId) summaryLines.push(`- Req_ID: \`${status.reqId}\``);
|
|
2457
|
+
if (status.probId) summaryLines.push(`- Problem_ID: \`${status.probId}\``);
|
|
2458
|
+
if (status.bindings?.contractRow) summaryLines.push(`- Contract_Row: \`${status.bindings.contractRow}\``);
|
|
2459
|
+
if (status.bindings?.planFile) summaryLines.push(`- Plan_File: \`${status.bindings.planFile}\``);
|
|
2460
|
+
summaryLines.push("");
|
|
2461
|
+
summaryLines.push("## Evidence (Created/Collected)");
|
|
2462
|
+
if (created.length === 0) summaryLines.push("- (none)");
|
|
2463
|
+
else for (const p of created) summaryLines.push(`- \`${p}\``);
|
|
2464
|
+
summaryLines.push("");
|
|
2465
|
+
summaryLines.push("## Quality Gate");
|
|
2466
|
+
if (options.noValidate) {
|
|
2467
|
+
summaryLines.push("- Strict validation: (skipped via --no-validate)");
|
|
2468
|
+
} else if (validateRes) {
|
|
2469
|
+
summaryLines.push(`- Strict validation ok: \`${validateRes.ok === true}\``);
|
|
2470
|
+
summaryLines.push(`- Errors: \`${Array.isArray(validateRes.errors) ? validateRes.errors.length : 0}\``);
|
|
2471
|
+
summaryLines.push(`- Warnings: \`${Array.isArray(validateRes.warnings) ? validateRes.warnings.length : 0}\``);
|
|
2472
|
+
} else {
|
|
2473
|
+
summaryLines.push("- Strict validation: (unknown)");
|
|
2474
|
+
}
|
|
2475
|
+
summaryLines.push("");
|
|
2476
|
+
summaryLines.push("## Next");
|
|
2477
|
+
summaryLines.push(`- ${planVerifyHint(changeId)}`);
|
|
2478
|
+
if (validateFailed) summaryLines.push("- Fix strict validation errors, then re-run `aiws change evidence`");
|
|
2479
|
+
else summaryLines.push(`- Verify evidence gate: \`aiws change validate ${changeId} --strict --check-evidence\``);
|
|
2480
|
+
await writeText(summaryAbs, summaryLines.join("\n") + "\n");
|
|
2481
|
+
created.push(relFromRoot(gitRoot, summaryAbs));
|
|
2482
|
+
|
|
2483
|
+
const declaredEvidence = extractId("Evidence_Path", proposal.text) || extractId("Evidence_Path(s)", proposal.text);
|
|
2484
|
+
const existingProposalEvidence = splitDeclaredValues(declaredEvidence).map((p) => String(p || "").replace(/^\.\//, ""));
|
|
2485
|
+
const mergedEvidence = mergeDeclaredValues(existingProposalEvidence, created);
|
|
2486
|
+
const mergedEvidenceText = formatDeclaredValues(mergedEvidence);
|
|
2487
|
+
|
|
2488
|
+
const updatedProposal = upsertProposalEvidencePath(proposal.text, mergedEvidenceText);
|
|
2489
|
+
if (updatedProposal.changed) {
|
|
2490
|
+
await writeText(path.join(changeDir, "proposal.md"), updatedProposal.text);
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
if (planAbs && planText) {
|
|
2494
|
+
const existingPlanEvidence = splitDeclaredValues(extractId("Evidence_Path", planText) || extractId("Evidence_Path(s)", planText)).map((p) =>
|
|
2495
|
+
String(p || "").replace(/^\.\//, ""),
|
|
2496
|
+
);
|
|
2497
|
+
const mergedPlanEvidence = mergeDeclaredValues(existingPlanEvidence, mergedEvidence);
|
|
2498
|
+
const mergedPlanEvidenceText = formatDeclaredValues(mergedPlanEvidence);
|
|
2499
|
+
const updatedPlan = upsertIdLine(planText, ["Evidence_Path", "Evidence_Path(s)"], mergedPlanEvidenceText, { insertAfterLabels: ["Plan_File", "Contract_Row"] });
|
|
2500
|
+
if (updatedPlan.changed) await writeText(planAbs, updatedPlan.text);
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
await appendMetricsEvent(gitRoot, changeId, "evidence", {
|
|
2504
|
+
generated_at: nowIsoUtc(),
|
|
2505
|
+
created: created.length,
|
|
2506
|
+
strict_validate: options.noValidate ? "skipped" : validateRes && validateRes.ok === true ? "ok" : "failed",
|
|
2507
|
+
allow_fail: options.allowFail === true,
|
|
2508
|
+
});
|
|
2509
|
+
|
|
2510
|
+
console.log(`✓ aiws change evidence: ${changeId}`);
|
|
2511
|
+
console.log(`dir: ${path.relative(gitRoot, changeDir)}`);
|
|
2512
|
+
if (planRel) console.log(`Plan_File: ${planRel}`);
|
|
2513
|
+
if (created.length > 0) {
|
|
2514
|
+
console.log("created:");
|
|
2515
|
+
for (const p of created) console.log(`- ${p}`);
|
|
2516
|
+
} else {
|
|
2517
|
+
console.log("created:");
|
|
2518
|
+
console.log("- (none)");
|
|
2519
|
+
}
|
|
2520
|
+
console.log("updated:");
|
|
2521
|
+
console.log(`- ${path.relative(gitRoot, path.join(changeDir, "proposal.md"))} (Evidence_Path)`);
|
|
2522
|
+
if (planRel && planText) console.log(`- ${planRel} (Evidence_Path)`);
|
|
2523
|
+
console.log("");
|
|
2524
|
+
console.log("next:");
|
|
2525
|
+
console.log(`- ${planVerifyHint(changeId)}`);
|
|
2526
|
+
if (validateFailed) console.log("- fix validation errors, then re-run evidence (or run with --allow-fail to proceed)");
|
|
2527
|
+
else console.log(`- verify evidence gate: \`aiws change validate ${changeId} --strict --check-evidence\``);
|
|
2528
|
+
|
|
2529
|
+
if (validateFailed && !options.allowFail) {
|
|
2530
|
+
const details = (validateRes && validateRes.raw ? String(validateRes.raw) : "").trim() || "Validation failed (see evidence JSON for details).";
|
|
2531
|
+
const hint = validatePathForError ? `\n\nevidence:\n- ${path.relative(gitRoot, validatePathForError)}` : "";
|
|
2532
|
+
throw new UserError("Failed to generate evidence due to strict validation errors.", { details: `${details}${hint}` });
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
|
|
1408
2536
|
/**
|
|
1409
2537
|
* aiws change validate
|
|
1410
2538
|
*
|
|
1411
|
-
* @param {{ changeId?: string, strict: boolean, allowTruthDrift: boolean }} options
|
|
2539
|
+
* @param {{ changeId?: string, strict: boolean, allowTruthDrift: boolean, checkEvidence: boolean, checkScope: boolean }} options
|
|
1412
2540
|
*/
|
|
1413
2541
|
export async function changeValidateCommand(options) {
|
|
1414
2542
|
const gitRoot = await resolveGitRoot(process.cwd());
|
|
@@ -1420,6 +2548,18 @@ export async function changeValidateCommand(options) {
|
|
|
1420
2548
|
const res = await validateChangeArtifacts(gitRoot, changeId, {
|
|
1421
2549
|
strict: options.strict === true,
|
|
1422
2550
|
allowTruthDrift: options.allowTruthDrift === true,
|
|
2551
|
+
checkEvidence: options.checkEvidence === true,
|
|
2552
|
+
checkScope: options.checkScope === true,
|
|
2553
|
+
});
|
|
2554
|
+
await appendMetricsEvent(gitRoot, changeId, "validate", {
|
|
2555
|
+
ok: res.ok === true,
|
|
2556
|
+
strict: options.strict === true,
|
|
2557
|
+
allow_truth_drift: options.allowTruthDrift === true,
|
|
2558
|
+
check_evidence: options.checkEvidence === true,
|
|
2559
|
+
check_scope: options.checkScope === true,
|
|
2560
|
+
errors: Array.isArray(res.errors) ? res.errors.length : 0,
|
|
2561
|
+
warnings: Array.isArray(res.warnings) ? res.warnings.length : 0,
|
|
2562
|
+
exit_code: res.exitCode,
|
|
1423
2563
|
});
|
|
1424
2564
|
if (res.raw) process.stderr.write(res.raw + "\n");
|
|
1425
2565
|
if (!res.ok) throw new UserError("");
|
|
@@ -1513,6 +2653,12 @@ export async function changeSyncCommand(options) {
|
|
|
1513
2653
|
note: "aiws change sync stamp; does not contain secrets.",
|
|
1514
2654
|
};
|
|
1515
2655
|
await writeText(stampPath, JSON.stringify(stamp, null, 2) + "\n");
|
|
2656
|
+
await appendMetricsEvent(gitRoot, changeId, "truth_sync", {
|
|
2657
|
+
synced_at: nowIso,
|
|
2658
|
+
baseline_from: baselineLabel,
|
|
2659
|
+
baseline_at: baselineAt,
|
|
2660
|
+
changed_files: changed.map((c) => c.file),
|
|
2661
|
+
});
|
|
1516
2662
|
|
|
1517
2663
|
console.log(`✓ aiws change sync: ${changeId}`);
|
|
1518
2664
|
console.log(`meta: ${path.relative(gitRoot, metaPath)}`);
|
|
@@ -1565,10 +2711,17 @@ export async function changeArchiveCommand(options) {
|
|
|
1565
2711
|
dest = path.join(archiveRoot, `${prefix}-${changeId}-${nowStampUtc()}`);
|
|
1566
2712
|
}
|
|
1567
2713
|
|
|
2714
|
+
await appendMetricsEvent(gitRoot, changeId, "archive", { archived_to: path.relative(gitRoot, dest) });
|
|
1568
2715
|
await fs.rename(changeDir, dest);
|
|
1569
2716
|
console.log(`✓ aiws change archive: ${changeId}`);
|
|
1570
2717
|
console.log(`archived_to: ${path.relative(gitRoot, dest)}`);
|
|
1571
2718
|
|
|
2719
|
+
// Generate handoff.md
|
|
2720
|
+
const handoffPath = path.join(dest, "handoff.md");
|
|
2721
|
+
const handoffContent = await generateHandoffContent(gitRoot, dest, changeId);
|
|
2722
|
+
await writeText(handoffPath, handoffContent);
|
|
2723
|
+
console.log(`handoff: ${path.relative(gitRoot, handoffPath)}`);
|
|
2724
|
+
|
|
1572
2725
|
const metaPath = path.join(dest, ".ws-change.json");
|
|
1573
2726
|
if (await pathExists(metaPath)) {
|
|
1574
2727
|
try {
|