@aipper/aiws 0.0.10 → 0.0.12
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/package.json +2 -2
- package/src/cli.js +43 -0
- package/src/commands/change.js +234 -91
- package/src/commands/codex-install-skills.js +8 -2
- package/src/commands/codex-status-skills.js +11 -2
- package/src/commands/dashboard.js +179 -0
- package/src/dashboard/app.js +168 -0
- package/src/dashboard/index.html +60 -0
- package/src/dashboard/style.css +215 -0
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aipper/aiws",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"description": "AI Workspace CLI (init/update/validate) for Claude Code / OpenCode / Codex / iFlow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"aiws": "./bin/aiws.js"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@aipper/aiws-spec": "0.0.
|
|
10
|
+
"@aipper/aiws-spec": "0.0.12"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"bin",
|
package/src/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { codexStatusSkillsCommand } from "./commands/codex-status-skills.js";
|
|
|
14
14
|
import { codexUninstallSkillsCommand } from "./commands/codex-uninstall-skills.js";
|
|
15
15
|
import { hooksInstallCommand } from "./commands/hooks-install.js";
|
|
16
16
|
import { hooksStatusCommand } from "./commands/hooks-status.js";
|
|
17
|
+
import { dashboardServeCommand } from "./commands/dashboard.js";
|
|
17
18
|
import {
|
|
18
19
|
changeArchiveCommand,
|
|
19
20
|
changeFinishCommand,
|
|
@@ -42,6 +43,10 @@ export async function cliMain(argv) {
|
|
|
42
43
|
printHooksHelp();
|
|
43
44
|
return 0;
|
|
44
45
|
}
|
|
46
|
+
if (args[0] === "dashboard" && (args.includes("-h") || args.includes("--help"))) {
|
|
47
|
+
printDashboardHelp();
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
45
50
|
if (args[0] === "change" && (args.includes("-h") || args.includes("--help"))) {
|
|
46
51
|
printChangeHelp();
|
|
47
52
|
return 0;
|
|
@@ -209,6 +214,27 @@ export async function cliMain(argv) {
|
|
|
209
214
|
throw new UserError(`Unknown hooks subcommand: ${sub}`, { details: "Use `aiws hooks --help` to see available subcommands." });
|
|
210
215
|
}
|
|
211
216
|
}
|
|
217
|
+
case "dashboard": {
|
|
218
|
+
const sub = args.shift();
|
|
219
|
+
if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
|
|
220
|
+
printDashboardHelp();
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
switch (sub) {
|
|
224
|
+
case "serve": {
|
|
225
|
+
const { options } = parseArgs(args, {
|
|
226
|
+
host: { type: "string" },
|
|
227
|
+
port: { type: "string" },
|
|
228
|
+
});
|
|
229
|
+
const host = options.host ?? "127.0.0.1";
|
|
230
|
+
const port = options.port ? Number.parseInt(String(options.port), 10) : 3456;
|
|
231
|
+
await dashboardServeCommand({ host, port });
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
default:
|
|
235
|
+
throw new UserError(`Unknown dashboard subcommand: ${sub}`, { details: "Use `aiws dashboard --help` to see available subcommands." });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
212
238
|
case "change": {
|
|
213
239
|
const sub = args.shift();
|
|
214
240
|
if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
|
|
@@ -356,6 +382,7 @@ Usage:
|
|
|
356
382
|
aiws validate [path] [--stamp]
|
|
357
383
|
aiws rollback [path] <timestamp|latest>
|
|
358
384
|
aiws change <subcommand>
|
|
385
|
+
aiws dashboard <subcommand>
|
|
359
386
|
aiws codex <subcommand>
|
|
360
387
|
aiws hooks <subcommand>
|
|
361
388
|
|
|
@@ -401,6 +428,19 @@ Notes:
|
|
|
401
428
|
`);
|
|
402
429
|
}
|
|
403
430
|
|
|
431
|
+
function printDashboardHelp() {
|
|
432
|
+
console.log(`aiws dashboard
|
|
433
|
+
|
|
434
|
+
Usage:
|
|
435
|
+
aiws dashboard serve [--host 127.0.0.1] [--port 3456]
|
|
436
|
+
|
|
437
|
+
Notes:
|
|
438
|
+
- Local-only dashboard, no external dependencies.
|
|
439
|
+
- Recommended: run change quality gate first (AI tools: $ws-plan-verify):
|
|
440
|
+
aiws change validate <change-id> --strict
|
|
441
|
+
`);
|
|
442
|
+
}
|
|
443
|
+
|
|
404
444
|
function printChangeHelp() {
|
|
405
445
|
console.log(`aiws change
|
|
406
446
|
|
|
@@ -421,6 +461,9 @@ Notes:
|
|
|
421
461
|
- change-id must be kebab-case: ^[a-z0-9]+(-[a-z0-9]+)*$
|
|
422
462
|
- If your git branch matches change/<change-id> (or changes/ws/ws-change prefixes),
|
|
423
463
|
you can omit <change-id> for status/next/validate/sync/archive/finish.
|
|
464
|
+
- Execution quality gate first: aiws change validate [change-id] --strict
|
|
465
|
+
(equivalent to running $ws-plan-verify in AI tools).
|
|
466
|
+
- status/next now prioritize quality-gate guidance before coding ($ws-dev).
|
|
424
467
|
- If .gitmodules exists and you didn't specify --switch/--no-switch/--worktree,
|
|
425
468
|
start will prefer --worktree (fallback: --no-switch) to avoid switching the superproject branch.
|
|
426
469
|
- archive runs strict validation and (by default) requires all tasks checked.
|
package/src/commands/change.js
CHANGED
|
@@ -14,7 +14,7 @@ const CHANGE_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
|
14
14
|
/**
|
|
15
15
|
* @param {string} changeId
|
|
16
16
|
*/
|
|
17
|
-
function assertValidChangeId(changeId) {
|
|
17
|
+
export function assertValidChangeId(changeId) {
|
|
18
18
|
if (!changeId || !CHANGE_ID_RE.test(changeId)) {
|
|
19
19
|
throw new UserError(`Invalid change id (use kebab-case): ${changeId}`);
|
|
20
20
|
}
|
|
@@ -301,7 +301,7 @@ function escapeRegExp(s) {
|
|
|
301
301
|
* @param {string} text
|
|
302
302
|
*/
|
|
303
303
|
function extractId(label, text) {
|
|
304
|
-
const re = new RegExp(`^.*${escapeRegExp(label)}.*?[:=]\\
|
|
304
|
+
const re = new RegExp(`^.*${escapeRegExp(label)}.*?[:=][ \\t]*(.*)$`, "m");
|
|
305
305
|
const m = re.exec(text);
|
|
306
306
|
if (!m) return "";
|
|
307
307
|
let v = String(m[1] || "").trim();
|
|
@@ -310,6 +310,205 @@ function extractId(label, text) {
|
|
|
310
310
|
return v;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/**
|
|
314
|
+
* @param {string} s
|
|
315
|
+
*/
|
|
316
|
+
function splitDeclaredValues(s) {
|
|
317
|
+
return String(s || "")
|
|
318
|
+
.split(/[,;\n]+/g)
|
|
319
|
+
.map((x) => x.trim().replace(/^`+/, "").replace(/`+$/, "").trim())
|
|
320
|
+
.filter(Boolean);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* @param {string} changeId
|
|
325
|
+
*/
|
|
326
|
+
function planVerifyHint(changeId) {
|
|
327
|
+
return `执行前质量门(优先):\`aiws change validate ${changeId} --strict\`(AI 工具中等价于 \`$ws-plan-verify\`)`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Compute change status as JSON-friendly object (used by dashboard and CLI).
|
|
332
|
+
*
|
|
333
|
+
* @param {string} gitRoot
|
|
334
|
+
* @param {string} changeId
|
|
335
|
+
*/
|
|
336
|
+
export async function computeChangeStatus(gitRoot, changeId) {
|
|
337
|
+
assertValidChangeId(changeId);
|
|
338
|
+
await ensureTruthFiles(gitRoot);
|
|
339
|
+
|
|
340
|
+
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
341
|
+
if (!(await pathExists(changeDir))) throw new UserError(`Missing change dir: ${path.relative(gitRoot, changeDir)}`);
|
|
342
|
+
|
|
343
|
+
const proposal = await fileState(changeDir, "proposal.md");
|
|
344
|
+
const tasks = await fileState(changeDir, "tasks.md");
|
|
345
|
+
const design = await fileState(changeDir, "design.md");
|
|
346
|
+
|
|
347
|
+
const metaPath = path.join(changeDir, ".ws-change.json");
|
|
348
|
+
let metaState = "missing";
|
|
349
|
+
/** @type {any} */
|
|
350
|
+
let meta = null;
|
|
351
|
+
if (await pathExists(metaPath)) {
|
|
352
|
+
metaState = "ok";
|
|
353
|
+
try {
|
|
354
|
+
meta = JSON.parse(await readText(metaPath));
|
|
355
|
+
} catch {
|
|
356
|
+
metaState = "invalid";
|
|
357
|
+
meta = null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const reqId = proposal.state === "ok" ? extractId("Req_ID", proposal.text) : "";
|
|
362
|
+
const probId = proposal.state === "ok" ? extractId("Problem_ID", proposal.text) : "";
|
|
363
|
+
const contractRow = proposal.state === "ok" ? extractId("Contract_Row", proposal.text) || extractId("Contract_Row(s)", proposal.text) : "";
|
|
364
|
+
const planFile = proposal.state === "ok" ? extractId("Plan_File", proposal.text) || extractId("Plan file", proposal.text) : "";
|
|
365
|
+
const evidencePath = proposal.state === "ok" ? extractId("Evidence_Path", proposal.text) || extractId("Evidence_Path(s)", proposal.text) : "";
|
|
366
|
+
const evidencePaths = splitDeclaredValues(evidencePath);
|
|
367
|
+
const taskProgress = tasks.state === "ok" ? checkboxStats(tasks.text) : { total: 0, done: 0, unchecked: 0, hasCheckboxes: false };
|
|
368
|
+
|
|
369
|
+
const curTruth = await snapshotTruthShaOnly(gitRoot);
|
|
370
|
+
const { baselineLabel, baselineAt, driftFiles } = meta ? truthDrift(curTruth, meta) : { baselineLabel: "-", baselineAt: "", driftFiles: [] };
|
|
371
|
+
|
|
372
|
+
/** @type {string[]} */
|
|
373
|
+
const blockersStrict = [];
|
|
374
|
+
/** @type {string[]} */
|
|
375
|
+
const blockersArchive = [];
|
|
376
|
+
|
|
377
|
+
if (metaState !== "ok") {
|
|
378
|
+
blockersStrict.push("missing/invalid .ws-change.json (run `aiws change sync <id>` to regenerate)");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
for (const [rel, st] of [
|
|
382
|
+
["proposal.md", proposal],
|
|
383
|
+
["tasks.md", tasks],
|
|
384
|
+
]) {
|
|
385
|
+
if (st.state !== "ok") {
|
|
386
|
+
blockersStrict.push(`missing/empty ${rel}`);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (st.placeholders > 0) blockersStrict.push(`unrendered template placeholders in ${rel}`);
|
|
390
|
+
if (st.wsTodo > 0) blockersStrict.push(`WS:TODO markers remain in ${rel}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (design.state === "ok") {
|
|
394
|
+
if (design.placeholders > 0) blockersStrict.push("unrendered template placeholders in design.md");
|
|
395
|
+
if (design.wsTodo > 0) blockersStrict.push("WS:TODO markers remain in design.md");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (proposal.state === "ok" && !(reqId || probId)) {
|
|
399
|
+
blockersStrict.push("proposal.md missing attribution (Req_ID or Problem_ID)");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (!taskProgress.hasCheckboxes) blockersStrict.push("tasks.md has no checkbox tasks ('- [ ]' or '- [x]')");
|
|
403
|
+
if (driftFiles.length > 0) blockersStrict.push(`truth drift vs ${baselineLabel} baseline (run \`aiws change sync ${changeId}\`)`);
|
|
404
|
+
|
|
405
|
+
blockersArchive.push(...blockersStrict);
|
|
406
|
+
if (taskProgress.unchecked > 0) blockersArchive.push(`tasks.md still has unchecked tasks (${taskProgress.unchecked} items)`);
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
ok: true,
|
|
410
|
+
changeId,
|
|
411
|
+
dir: path.relative(gitRoot, changeDir),
|
|
412
|
+
metaState,
|
|
413
|
+
reqId,
|
|
414
|
+
probId,
|
|
415
|
+
bindings: {
|
|
416
|
+
contractRow,
|
|
417
|
+
planFile,
|
|
418
|
+
evidencePaths,
|
|
419
|
+
},
|
|
420
|
+
tasks: taskProgress,
|
|
421
|
+
baselineLabel,
|
|
422
|
+
baselineAt,
|
|
423
|
+
driftFiles,
|
|
424
|
+
blockersStrict,
|
|
425
|
+
blockersArchive,
|
|
426
|
+
hints: {
|
|
427
|
+
planVerify: planVerifyHint(changeId),
|
|
428
|
+
dev: "质量门通过后再进入编码:在 AI 工具中运行 `$ws-dev`",
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function parseChangeCheckerOutput(stdout, stderr) {
|
|
434
|
+
const text = [String(stdout || ""), String(stderr || "")].filter(Boolean).join("\n");
|
|
435
|
+
/** @type {string[]} */
|
|
436
|
+
const errors = [];
|
|
437
|
+
/** @type {string[]} */
|
|
438
|
+
const warnings = [];
|
|
439
|
+
for (const raw of text.split("\n")) {
|
|
440
|
+
const line = raw.trim();
|
|
441
|
+
if (!line) continue;
|
|
442
|
+
if (line.startsWith("error: ")) errors.push(line.replace(/^error:\s*/, ""));
|
|
443
|
+
else if (line.startsWith("warn: ")) warnings.push(line.replace(/^warn:\s*/, ""));
|
|
444
|
+
}
|
|
445
|
+
return { errors, warnings, raw: text.trim() };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function classifyCheckMessage(msg) {
|
|
449
|
+
const s = String(msg || "");
|
|
450
|
+
if (s.includes("truth file") || s.includes("truth drift") || s.includes("allow-truth-drift")) return "truth_drift";
|
|
451
|
+
if (s.includes("WS:TODO") || s.includes("unrendered template placeholders")) return "placeholders";
|
|
452
|
+
if (s.includes("missing:") || s.includes("empty:") || s.includes("Missing change dir")) return "missing_files";
|
|
453
|
+
if (s.includes("Change_ID") || s.includes("Req_ID") || s.includes("Problem_ID") || s.includes("Contract_Row") || s.includes("Plan_File") || s.includes("Evidence_Path")) return "bindings";
|
|
454
|
+
if (s.includes("missing required sections") || s.includes("has empty sections") || s.includes("Plan section") || s.includes("Verify section") || s.includes("scope is too broad") || s.includes("too abstract")) {
|
|
455
|
+
return "plan_quality";
|
|
456
|
+
}
|
|
457
|
+
return "other";
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function groupCheckMessages(errors, warnings) {
|
|
461
|
+
const groups = {
|
|
462
|
+
truth_drift: { errors: [], warnings: [] },
|
|
463
|
+
missing_files: { errors: [], warnings: [] },
|
|
464
|
+
placeholders: { errors: [], warnings: [] },
|
|
465
|
+
bindings: { errors: [], warnings: [] },
|
|
466
|
+
plan_quality: { errors: [], warnings: [] },
|
|
467
|
+
other: { errors: [], warnings: [] },
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
for (const m of errors || []) {
|
|
471
|
+
const k = classifyCheckMessage(m);
|
|
472
|
+
groups[k].errors.push(m);
|
|
473
|
+
}
|
|
474
|
+
for (const m of warnings || []) {
|
|
475
|
+
const k = classifyCheckMessage(m);
|
|
476
|
+
groups[k].warnings.push(m);
|
|
477
|
+
}
|
|
478
|
+
return groups;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Run ws_change_check.py and return parsed results for dashboards/tools.
|
|
483
|
+
*
|
|
484
|
+
* @param {string} gitRoot
|
|
485
|
+
* @param {string} changeId
|
|
486
|
+
* @param {{ strict: boolean, allowTruthDrift: boolean }} options
|
|
487
|
+
*/
|
|
488
|
+
export async function validateChangeArtifacts(gitRoot, changeId, options) {
|
|
489
|
+
assertValidChangeId(changeId);
|
|
490
|
+
await ensureTruthFiles(gitRoot);
|
|
491
|
+
|
|
492
|
+
const checker = await resolveWsChangeChecker(gitRoot);
|
|
493
|
+
const args = [...checker.args, "--workspace-root", gitRoot, "--change-id", changeId];
|
|
494
|
+
if (options.strict) args.push("--strict");
|
|
495
|
+
if (options.allowTruthDrift) args.push("--allow-truth-drift");
|
|
496
|
+
|
|
497
|
+
const res = await runPython(gitRoot, ["-u", ...args]);
|
|
498
|
+
const parsed = parseChangeCheckerOutput(res.stdout, res.stderr);
|
|
499
|
+
return {
|
|
500
|
+
ok: res.code === 0,
|
|
501
|
+
changeId,
|
|
502
|
+
strict: options.strict === true,
|
|
503
|
+
allowTruthDrift: options.allowTruthDrift === true,
|
|
504
|
+
exitCode: res.code,
|
|
505
|
+
errors: parsed.errors,
|
|
506
|
+
warnings: parsed.warnings,
|
|
507
|
+
groups: groupCheckMessages(parsed.errors, parsed.warnings),
|
|
508
|
+
raw: parsed.raw,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
313
512
|
/**
|
|
314
513
|
* @param {string} text
|
|
315
514
|
*/
|
|
@@ -1089,90 +1288,35 @@ export async function changeStatusCommand(options) {
|
|
|
1089
1288
|
await ensureTruthFiles(gitRoot);
|
|
1090
1289
|
|
|
1091
1290
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "status" });
|
|
1092
|
-
|
|
1291
|
+
const st = await computeChangeStatus(gitRoot, changeId);
|
|
1093
1292
|
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
let metaState = "missing";
|
|
1103
|
-
/** @type {any} */
|
|
1104
|
-
let meta = null;
|
|
1105
|
-
if (await pathExists(metaPath)) {
|
|
1106
|
-
metaState = "ok";
|
|
1107
|
-
try {
|
|
1108
|
-
meta = JSON.parse(await readText(metaPath));
|
|
1109
|
-
} catch {
|
|
1110
|
-
metaState = "invalid";
|
|
1111
|
-
meta = null;
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
const reqId = proposal.state === "ok" ? extractId("Req_ID", proposal.text) : "";
|
|
1116
|
-
const probId = proposal.state === "ok" ? extractId("Problem_ID", proposal.text) : "";
|
|
1117
|
-
const taskProgress = tasks.state === "ok" ? checkboxStats(tasks.text) : { total: 0, done: 0, unchecked: 0, hasCheckboxes: false };
|
|
1118
|
-
|
|
1119
|
-
const curTruth = await snapshotTruthShaOnly(gitRoot);
|
|
1120
|
-
const { baselineLabel, baselineAt, driftFiles } = meta ? truthDrift(curTruth, meta) : { baselineLabel: "-", baselineAt: "", driftFiles: [] };
|
|
1121
|
-
|
|
1122
|
-
/** @type {string[]} */
|
|
1123
|
-
const blockersStrict = [];
|
|
1124
|
-
/** @type {string[]} */
|
|
1125
|
-
const blockersArchive = [];
|
|
1126
|
-
|
|
1127
|
-
if (metaState !== "ok") {
|
|
1128
|
-
blockersStrict.push("missing/invalid .ws-change.json (run `aiws change sync <id>` to regenerate)");
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
for (const [rel, st] of [
|
|
1132
|
-
["proposal.md", proposal],
|
|
1133
|
-
["tasks.md", tasks],
|
|
1134
|
-
]) {
|
|
1135
|
-
if (st.state !== "ok") {
|
|
1136
|
-
blockersStrict.push(`missing/empty ${rel}`);
|
|
1137
|
-
continue;
|
|
1138
|
-
}
|
|
1139
|
-
if (st.placeholders > 0) blockersStrict.push(`unrendered template placeholders in ${rel}`);
|
|
1140
|
-
if (st.wsTodo > 0) blockersStrict.push(`WS:TODO markers remain in ${rel}`);
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
if (design.state === "ok") {
|
|
1144
|
-
if (design.placeholders > 0) blockersStrict.push("unrendered template placeholders in design.md");
|
|
1145
|
-
if (design.wsTodo > 0) blockersStrict.push("WS:TODO markers remain in design.md");
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
if (proposal.state === "ok" && !(reqId || probId)) {
|
|
1149
|
-
blockersStrict.push("proposal.md missing attribution (Req_ID or Problem_ID)");
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
if (!taskProgress.hasCheckboxes) blockersStrict.push("tasks.md has no checkbox tasks ('- [ ]' or '- [x]')");
|
|
1153
|
-
if (driftFiles.length > 0) blockersStrict.push(`truth drift vs ${baselineLabel} baseline (run \`aiws change sync ${changeId}\`)`);
|
|
1154
|
-
|
|
1155
|
-
blockersArchive.push(...blockersStrict);
|
|
1156
|
-
if (taskProgress.unchecked > 0) blockersArchive.push(`tasks.md still has unchecked tasks (${taskProgress.unchecked} items)`);
|
|
1157
|
-
|
|
1158
|
-
console.log(`✓ aiws change status: ${changeId}`);
|
|
1159
|
-
console.log(`dir: ${path.relative(gitRoot, changeDir)}`);
|
|
1160
|
-
console.log(`meta: ${metaState}`);
|
|
1161
|
-
if (reqId) console.log(`Req_ID: ${reqId}`);
|
|
1162
|
-
if (probId) console.log(`Problem_ID: ${probId}`);
|
|
1163
|
-
console.log(`tasks: ${taskProgress.done}/${taskProgress.total} (unchecked=${taskProgress.unchecked})`);
|
|
1164
|
-
console.log(`baseline: ${baselineLabel}${baselineAt ? ` (at=${baselineAt})` : ""}`);
|
|
1165
|
-
console.log(`drift: ${driftFiles.length > 0 ? driftFiles.join(", ") : "(none)"}`);
|
|
1293
|
+
console.log(`✓ aiws change status: ${st.changeId}`);
|
|
1294
|
+
console.log(`dir: ${st.dir}`);
|
|
1295
|
+
console.log(`meta: ${st.metaState}`);
|
|
1296
|
+
if (st.reqId) console.log(`Req_ID: ${st.reqId}`);
|
|
1297
|
+
if (st.probId) console.log(`Problem_ID: ${st.probId}`);
|
|
1298
|
+
console.log(`tasks: ${st.tasks.done}/${st.tasks.total} (unchecked=${st.tasks.unchecked})`);
|
|
1299
|
+
console.log(`baseline: ${st.baselineLabel}${st.baselineAt ? ` (at=${st.baselineAt})` : ""}`);
|
|
1300
|
+
console.log(`drift: ${st.driftFiles.length > 0 ? st.driftFiles.join(", ") : "(none)"}`);
|
|
1166
1301
|
|
|
1167
1302
|
console.log("");
|
|
1168
1303
|
console.log("Blockers (strict):");
|
|
1169
|
-
if (blockersStrict.length === 0) console.log("- (none)");
|
|
1170
|
-
else for (const b of blockersStrict) console.log(`- ${b}`);
|
|
1304
|
+
if (st.blockersStrict.length === 0) console.log("- (none)");
|
|
1305
|
+
else for (const b of st.blockersStrict) console.log(`- ${b}`);
|
|
1171
1306
|
|
|
1172
1307
|
console.log("");
|
|
1173
1308
|
console.log("Blockers (archive):");
|
|
1174
|
-
if (blockersArchive.length === 0) console.log("- (none)");
|
|
1175
|
-
else for (const b of blockersArchive) console.log(`- ${b}`);
|
|
1309
|
+
if (st.blockersArchive.length === 0) console.log("- (none)");
|
|
1310
|
+
else for (const b of st.blockersArchive) console.log(`- ${b}`);
|
|
1311
|
+
|
|
1312
|
+
console.log("");
|
|
1313
|
+
console.log("Next (recommended):");
|
|
1314
|
+
console.log(`- ${planVerifyHint(st.changeId)}`);
|
|
1315
|
+
if (st.blockersStrict.length === 0) {
|
|
1316
|
+
console.log("- 质量门通过后再进入编码:在 AI 工具中运行 `$ws-dev`(小步实现 + 可复现验证)");
|
|
1317
|
+
} else {
|
|
1318
|
+
console.log("- 先修复 Blockers (strict) 后复跑质量门,再进入 `$ws-dev`");
|
|
1319
|
+
}
|
|
1176
1320
|
}
|
|
1177
1321
|
|
|
1178
1322
|
/**
|
|
@@ -1248,11 +1392,15 @@ export async function changeNextCommand(options) {
|
|
|
1248
1392
|
}
|
|
1249
1393
|
|
|
1250
1394
|
if (actions.length > 0) {
|
|
1251
|
-
|
|
1395
|
+
console.log(`- ${planVerifyHint(changeId)}`);
|
|
1252
1396
|
for (const a of actions) console.log(`- ${a}`);
|
|
1397
|
+
console.log(`- 修复后复跑质量门:\`aiws change validate ${changeId} --strict\``);
|
|
1398
|
+
console.log("- 质量门通过后再进入编码:在 AI 工具中运行 `$ws-dev`");
|
|
1253
1399
|
return;
|
|
1254
1400
|
}
|
|
1255
1401
|
|
|
1402
|
+
console.log(`- ${planVerifyHint(changeId)}`);
|
|
1403
|
+
console.log("- 若仍需继续编码,先通过质量门,再在 AI 工具中运行 `$ws-dev`");
|
|
1256
1404
|
console.log("- 生成交叉审计报告:在 AI 工具内运行 `/ws-review`(或按 AI_PROJECT.md 手工审计)");
|
|
1257
1405
|
console.log(`- 归档:\`aiws change archive ${changeId}\``);
|
|
1258
1406
|
}
|
|
@@ -1269,17 +1417,12 @@ export async function changeValidateCommand(options) {
|
|
|
1269
1417
|
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "validate" });
|
|
1270
1418
|
assertValidChangeId(changeId);
|
|
1271
1419
|
|
|
1272
|
-
const
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
if (res.stdout) process.stdout.write(res.stdout);
|
|
1279
|
-
if (res.stderr) process.stderr.write(res.stderr);
|
|
1280
|
-
if (res.code !== 0) {
|
|
1281
|
-
throw new UserError("");
|
|
1282
|
-
}
|
|
1420
|
+
const res = await validateChangeArtifacts(gitRoot, changeId, {
|
|
1421
|
+
strict: options.strict === true,
|
|
1422
|
+
allowTruthDrift: options.allowTruthDrift === true,
|
|
1423
|
+
});
|
|
1424
|
+
if (res.raw) process.stderr.write(res.raw + "\n");
|
|
1425
|
+
if (!res.ok) throw new UserError("");
|
|
1283
1426
|
console.log(`ok: change validated (${changeId})`);
|
|
1284
1427
|
}
|
|
1285
1428
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { loadTemplate } from "../spec.js";
|
|
3
4
|
import { UserError } from "../errors.js";
|
|
@@ -16,6 +17,11 @@ export async function codexInstallSkillsCommand(options) {
|
|
|
16
17
|
const skillsDir = resolveCodexSkillsDir(options.skillsDir);
|
|
17
18
|
const dryRun = options.dryRun === true;
|
|
18
19
|
if (!dryRun) await ensureDir(skillsDir);
|
|
20
|
+
const skillsDirIsSymlink = await fs
|
|
21
|
+
.lstat(skillsDir)
|
|
22
|
+
.then((st) => st.isSymbolicLink())
|
|
23
|
+
.catch(() => false);
|
|
24
|
+
const skillsDirReal = skillsDirIsSymlink ? await fs.realpath(skillsDir).catch(() => skillsDir) : skillsDir;
|
|
19
25
|
|
|
20
26
|
const skillFiles = await listTemplateCodexSkills(tpl);
|
|
21
27
|
|
|
@@ -60,9 +66,9 @@ export async function codexInstallSkillsCommand(options) {
|
|
|
60
66
|
}
|
|
61
67
|
}
|
|
62
68
|
|
|
63
|
-
|
|
69
|
+
const header = skillsDirIsSymlink && skillsDirReal !== skillsDir ? `${skillsDir} -> ${skillsDirReal}` : skillsDir;
|
|
70
|
+
console.log(`${dryRun ? "✓ (dry-run)" : "✓"} aiws codex install-skills: ${header}`);
|
|
64
71
|
if (created.length > 0) console.log(`created: ${created.join(", ")}`);
|
|
65
72
|
if (updated.length > 0) console.log(`updated: ${updated.join(", ")}`);
|
|
66
73
|
if (overwritten.length > 0) console.log(`overwritten: ${overwritten.join(", ")}`);
|
|
67
74
|
}
|
|
68
|
-
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { loadTemplate } from "../spec.js";
|
|
3
4
|
import { normalizeNewlines } from "../hash.js";
|
|
@@ -13,6 +14,11 @@ export async function codexStatusSkillsCommand(options) {
|
|
|
13
14
|
const tpl = await loadTemplate(templateId);
|
|
14
15
|
|
|
15
16
|
const skillsDir = resolveCodexSkillsDir(options.skillsDir);
|
|
17
|
+
const skillsDirIsSymlink = await fs
|
|
18
|
+
.lstat(skillsDir)
|
|
19
|
+
.then((st) => st.isSymbolicLink())
|
|
20
|
+
.catch(() => false);
|
|
21
|
+
const skillsDirReal = skillsDirIsSymlink ? await fs.realpath(skillsDir).catch(() => skillsDir) : skillsDir;
|
|
16
22
|
const skillFiles = await listTemplateCodexSkills(tpl);
|
|
17
23
|
|
|
18
24
|
/** @type {Array<{ name: string, status: "ok" | "missing" | "unmanaged" | "outdated" }>} */
|
|
@@ -42,13 +48,16 @@ export async function codexStatusSkillsCommand(options) {
|
|
|
42
48
|
/** @type {{ ok: number, missing: number, unmanaged: number, outdated: number }} */ ({ ok: 0, missing: 0, unmanaged: 0, outdated: 0 }),
|
|
43
49
|
);
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
const header = skillsDirIsSymlink && skillsDirReal !== skillsDir ? `${skillsDir} -> ${skillsDirReal}` : skillsDir;
|
|
52
|
+
console.log(`✓ aiws codex status-skills: ${header}`);
|
|
46
53
|
console.log(`ok=${counts.ok} missing=${counts.missing} unmanaged=${counts.unmanaged} outdated=${counts.outdated}`);
|
|
47
54
|
for (const r of rows) {
|
|
48
55
|
console.log(`${r.status}\t${r.name}`);
|
|
49
56
|
}
|
|
57
|
+
if (counts.ok === 0 && counts.missing === rows.length && skillsDirIsSymlink) {
|
|
58
|
+
console.log("Note: skills dir is a symlink; if you installed to a different dir, pass --dir (or set CODEX_HOME).");
|
|
59
|
+
}
|
|
50
60
|
if (counts.missing > 0 || counts.outdated > 0) {
|
|
51
61
|
console.log("Next: aiws codex install-skills");
|
|
52
62
|
}
|
|
53
63
|
}
|
|
54
|
-
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { UserError } from "../errors.js";
|
|
6
|
+
import { runCommand } from "../exec.js";
|
|
7
|
+
import { pathExists } from "../fs.js";
|
|
8
|
+
import { computeChangeStatus, validateChangeArtifacts } from "./change.js";
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
|
|
13
|
+
function send(res, status, headers, body) {
|
|
14
|
+
res.writeHead(status, headers);
|
|
15
|
+
res.end(body);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sendJson(res, status, obj) {
|
|
19
|
+
send(res, status, { "content-type": "application/json; charset=utf-8" }, JSON.stringify(obj, null, 2));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function guessContentType(p) {
|
|
23
|
+
const ext = path.extname(p).toLowerCase();
|
|
24
|
+
if (ext === ".html") return "text/html; charset=utf-8";
|
|
25
|
+
if (ext === ".css") return "text/css; charset=utf-8";
|
|
26
|
+
if (ext === ".js") return "text/javascript; charset=utf-8";
|
|
27
|
+
if (ext === ".json") return "application/json; charset=utf-8";
|
|
28
|
+
return "application/octet-stream";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeJoin(baseDir, rel) {
|
|
32
|
+
const abs = path.resolve(baseDir, rel);
|
|
33
|
+
const baseAbs = path.resolve(baseDir);
|
|
34
|
+
if (!abs.startsWith(baseAbs + path.sep) && abs !== baseAbs) {
|
|
35
|
+
throw new UserError("Invalid path.", { exitCode: 1 });
|
|
36
|
+
}
|
|
37
|
+
return abs;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} absPath
|
|
42
|
+
*/
|
|
43
|
+
async function resolveGitRoot(absPath) {
|
|
44
|
+
let res;
|
|
45
|
+
try {
|
|
46
|
+
res = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd: absPath });
|
|
47
|
+
} catch (e) {
|
|
48
|
+
throw new UserError("git is required for aiws dashboard commands.", { details: e instanceof Error ? e.message : String(e) });
|
|
49
|
+
}
|
|
50
|
+
if (res.code !== 0) {
|
|
51
|
+
throw new UserError("Not a git repository.", { details: res.stderr || res.stdout });
|
|
52
|
+
}
|
|
53
|
+
const root = String(res.stdout || "").trim();
|
|
54
|
+
if (!root) throw new UserError("Failed to resolve git repository root.");
|
|
55
|
+
return root;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} gitRoot
|
|
60
|
+
*/
|
|
61
|
+
async function ensureTruthFiles(gitRoot) {
|
|
62
|
+
const required = ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"];
|
|
63
|
+
const missing = [];
|
|
64
|
+
for (const f of required) {
|
|
65
|
+
if (!(await pathExists(path.join(gitRoot, f)))) missing.push(f);
|
|
66
|
+
}
|
|
67
|
+
if (missing.length > 0) {
|
|
68
|
+
throw new UserError("AI Workspace truth files missing.", {
|
|
69
|
+
details: `Root: ${gitRoot}\nMissing:\n${missing.map((m) => `- ${m}`).join("\n")}\n\nHint: run \`aiws init .\` (new) or \`aiws update .\` (migrate).`,
|
|
70
|
+
exitCode: 1,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function listChangeIds(gitRoot) {
|
|
76
|
+
const dir = path.join(gitRoot, "changes");
|
|
77
|
+
try {
|
|
78
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
79
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseUrl(reqUrl) {
|
|
86
|
+
const u = new URL(reqUrl || "/", "http://127.0.0.1");
|
|
87
|
+
return { pathname: u.pathname, searchParams: u.searchParams };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function dashboardServeCommand(options) {
|
|
91
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
92
|
+
await ensureTruthFiles(gitRoot);
|
|
93
|
+
|
|
94
|
+
const host = String(options.host || "127.0.0.1").trim() || "127.0.0.1";
|
|
95
|
+
const port = Number.isFinite(options.port) ? options.port : Number.parseInt(String(options.port || "3456"), 10);
|
|
96
|
+
if (!Number.isFinite(port) || port < 0 || port > 65535) {
|
|
97
|
+
throw new UserError(`Invalid --port: ${options.port}`, { exitCode: 1 });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const dashboardDir = path.join(__dirname, "..", "dashboard");
|
|
101
|
+
const assetsDir = path.join(dashboardDir);
|
|
102
|
+
|
|
103
|
+
const server = http.createServer(async (req, res) => {
|
|
104
|
+
try {
|
|
105
|
+
const { pathname, searchParams } = parseUrl(req.url || "/");
|
|
106
|
+
if ((req.method || "GET") !== "GET") {
|
|
107
|
+
sendJson(res, 405, { ok: false, error: "method not allowed" });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (pathname === "/api/changes") {
|
|
112
|
+
const ids = await listChangeIds(gitRoot);
|
|
113
|
+
const changes = [];
|
|
114
|
+
for (const id of ids) {
|
|
115
|
+
try {
|
|
116
|
+
const st = await computeChangeStatus(gitRoot, id);
|
|
117
|
+
changes.push(st);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
changes.push({ changeId: id, ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
sendJson(res, 200, { ok: true, changes });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const m = pathname.match(/^\/api\/change\/([^/]+)\/validate$/);
|
|
127
|
+
if (m) {
|
|
128
|
+
const changeId = decodeURIComponent(m[1] || "");
|
|
129
|
+
const strict = searchParams.get("strict") === "1" || searchParams.get("strict") === "true";
|
|
130
|
+
const allowTruthDrift = searchParams.get("allow_truth_drift") === "1" || searchParams.get("allow_truth_drift") === "true";
|
|
131
|
+
const result = await validateChangeArtifacts(gitRoot, changeId, { strict, allowTruthDrift });
|
|
132
|
+
sendJson(res, 200, result);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
137
|
+
const p = path.join(dashboardDir, "index.html");
|
|
138
|
+
const text = await fs.readFile(p);
|
|
139
|
+
send(res, 200, { "content-type": "text/html; charset=utf-8" }, text);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (pathname.startsWith("/assets/")) {
|
|
144
|
+
const rel = pathname.replace(/^\/assets\//, "");
|
|
145
|
+
const p = safeJoin(assetsDir, rel);
|
|
146
|
+
const buf = await fs.readFile(p);
|
|
147
|
+
send(res, 200, { "content-type": guessContentType(p) }, buf);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
sendJson(res, 404, { ok: false, error: "not found" });
|
|
152
|
+
} catch (e) {
|
|
153
|
+
sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await new Promise((resolve, reject) => {
|
|
158
|
+
server.once("error", reject);
|
|
159
|
+
server.listen({ host, port }, () => resolve());
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const addr = server.address();
|
|
163
|
+
const realPort = addr && typeof addr === "object" ? addr.port : port;
|
|
164
|
+
console.log(`✓ aiws dashboard serve`);
|
|
165
|
+
console.log(`root: ${gitRoot}`);
|
|
166
|
+
console.log(`url: http://${host}:${realPort}/`);
|
|
167
|
+
console.log("note: press Ctrl+C to stop");
|
|
168
|
+
|
|
169
|
+
await new Promise((resolve) => {
|
|
170
|
+
let stopping = false;
|
|
171
|
+
const stop = () => {
|
|
172
|
+
if (stopping) return;
|
|
173
|
+
stopping = true;
|
|
174
|
+
server.close(() => resolve());
|
|
175
|
+
};
|
|
176
|
+
process.once("SIGINT", stop);
|
|
177
|
+
process.once("SIGTERM", stop);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
async function fetchJson(url) {
|
|
2
|
+
const res = await fetch(url, { headers: { "Accept": "application/json" } });
|
|
3
|
+
const text = await res.text();
|
|
4
|
+
let data = null;
|
|
5
|
+
try {
|
|
6
|
+
data = JSON.parse(text);
|
|
7
|
+
} catch {
|
|
8
|
+
data = { ok: false, error: "invalid json", raw: text };
|
|
9
|
+
}
|
|
10
|
+
if (!res.ok) {
|
|
11
|
+
return { ok: false, status: res.status, ...data };
|
|
12
|
+
}
|
|
13
|
+
return data;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function escapeHtml(s) {
|
|
17
|
+
return String(s || "")
|
|
18
|
+
.replaceAll("&", "&")
|
|
19
|
+
.replaceAll("<", "<")
|
|
20
|
+
.replaceAll(">", ">")
|
|
21
|
+
.replaceAll("\"", """)
|
|
22
|
+
.replaceAll("'", "'");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function blockersBadge(n) {
|
|
26
|
+
const cls = n === 0 ? "good" : n <= 2 ? "warn" : "bad";
|
|
27
|
+
return `<span class="badge"><span class="dot ${cls}"></span>${n === 0 ? "none" : `${n} blockers`}</span>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function driftBadge(n) {
|
|
31
|
+
const cls = n === 0 ? "good" : "warn";
|
|
32
|
+
return `<span class="badge"><span class="dot ${cls}"></span>${n === 0 ? "clean" : `${n} drift`}</span>`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function tasksBadge(done, total, unchecked) {
|
|
36
|
+
const cls = unchecked === 0 && total > 0 ? "good" : unchecked === 0 ? "warn" : "warn";
|
|
37
|
+
const label = total > 0 ? `${done}/${total} (unchecked=${unchecked})` : "no tasks";
|
|
38
|
+
return `<span class="badge"><span class="dot ${cls}"></span>${label}</span>`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function renderDetails(obj) {
|
|
42
|
+
return JSON.stringify(obj, null, 2);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderValidateGroups(groups) {
|
|
46
|
+
const order = [
|
|
47
|
+
["truth_drift", "Truth drift"],
|
|
48
|
+
["missing_files", "Missing files"],
|
|
49
|
+
["placeholders", "Placeholders / WS:TODO"],
|
|
50
|
+
["bindings", "Bindings"],
|
|
51
|
+
["plan_quality", "Plan quality"],
|
|
52
|
+
["other", "Other"],
|
|
53
|
+
];
|
|
54
|
+
const g = groups && typeof groups === "object" ? groups : {};
|
|
55
|
+
const lines = [];
|
|
56
|
+
for (const [key, label] of order) {
|
|
57
|
+
const it = g[key] || { errors: [], warnings: [] };
|
|
58
|
+
const errs = Array.isArray(it.errors) ? it.errors : [];
|
|
59
|
+
const warns = Array.isArray(it.warnings) ? it.warnings : [];
|
|
60
|
+
if (errs.length === 0 && warns.length === 0) continue;
|
|
61
|
+
lines.push(`${label}:`);
|
|
62
|
+
for (const e of errs) lines.push(` - error: ${e}`);
|
|
63
|
+
for (const w of warns) lines.push(` - warn: ${w}`);
|
|
64
|
+
lines.push("");
|
|
65
|
+
}
|
|
66
|
+
return lines.join("\n").trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function runStrictValidate(changeId) {
|
|
70
|
+
return fetchJson(`/api/change/${encodeURIComponent(changeId)}/validate?strict=1`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function refresh() {
|
|
74
|
+
const summaryEl = document.getElementById("summary");
|
|
75
|
+
const bodyEl = document.getElementById("changes-body");
|
|
76
|
+
const detailsTitleEl = document.getElementById("details-title");
|
|
77
|
+
const detailsEl = document.getElementById("details");
|
|
78
|
+
|
|
79
|
+
summaryEl.textContent = "Loading…";
|
|
80
|
+
bodyEl.innerHTML = "";
|
|
81
|
+
|
|
82
|
+
const data = await fetchJson("/api/changes");
|
|
83
|
+
if (!data || data.ok === false) {
|
|
84
|
+
summaryEl.textContent = "Failed";
|
|
85
|
+
detailsTitleEl.textContent = "Error";
|
|
86
|
+
detailsEl.textContent = renderDetails(data);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const items = Array.isArray(data.changes) ? data.changes : [];
|
|
91
|
+
summaryEl.textContent = `${items.length} change(s)`;
|
|
92
|
+
|
|
93
|
+
bodyEl.innerHTML = items
|
|
94
|
+
.map((c) => {
|
|
95
|
+
const changeId = c.changeId || c.id || "";
|
|
96
|
+
const tasks = c.tasks || {};
|
|
97
|
+
const drift = Array.isArray(c.driftFiles) ? c.driftFiles.length : 0;
|
|
98
|
+
const blockers = Array.isArray(c.blockersStrict) ? c.blockersStrict.length : 0;
|
|
99
|
+
const bindings = c.bindings || {};
|
|
100
|
+
const planFile = bindings.planFile || "";
|
|
101
|
+
const evidencePaths = Array.isArray(bindings.evidencePaths) ? bindings.evidencePaths : [];
|
|
102
|
+
const pe = `${planFile ? `<div><code>${escapeHtml(planFile)}</code></div>` : `<div class="muted">(no Plan_File)</div>`}
|
|
103
|
+
<div class="muted">${evidencePaths.length} evidence path(s)</div>`;
|
|
104
|
+
return `<tr data-change="${escapeHtml(changeId)}">
|
|
105
|
+
<td><a class="link" href="#" data-open="${escapeHtml(changeId)}">${escapeHtml(changeId)}</a></td>
|
|
106
|
+
<td>${tasksBadge(tasks.done || 0, tasks.total || 0, tasks.unchecked || 0)}</td>
|
|
107
|
+
<td>${driftBadge(drift)}</td>
|
|
108
|
+
<td>${blockersBadge(blockers)}</td>
|
|
109
|
+
<td>${pe}</td>
|
|
110
|
+
<td>
|
|
111
|
+
<button class="btn secondary" data-validate="${escapeHtml(changeId)}">Validate --strict</button>
|
|
112
|
+
</td>
|
|
113
|
+
</tr>`;
|
|
114
|
+
})
|
|
115
|
+
.join("");
|
|
116
|
+
|
|
117
|
+
bodyEl.querySelectorAll("[data-open]").forEach((a) => {
|
|
118
|
+
a.addEventListener("click", (ev) => {
|
|
119
|
+
ev.preventDefault();
|
|
120
|
+
const changeId = a.getAttribute("data-open") || "";
|
|
121
|
+
const selected = items.find((x) => (x.changeId || x.id) === changeId) || null;
|
|
122
|
+
detailsTitleEl.textContent = changeId ? `change: ${changeId}` : "Details";
|
|
123
|
+
if (!selected) {
|
|
124
|
+
detailsEl.textContent = renderDetails(selected);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const b = selected.bindings || {};
|
|
128
|
+
const evid = Array.isArray(b.evidencePaths) ? b.evidencePaths : [];
|
|
129
|
+
const lines = [];
|
|
130
|
+
lines.push(`changeId: ${selected.changeId || ""}`);
|
|
131
|
+
if (selected.reqId) lines.push(`Req_ID: ${selected.reqId}`);
|
|
132
|
+
if (selected.probId) lines.push(`Problem_ID: ${selected.probId}`);
|
|
133
|
+
if (b.planFile) lines.push(`Plan_File: ${b.planFile}`);
|
|
134
|
+
if (b.contractRow) lines.push(`Contract_Row: ${b.contractRow}`);
|
|
135
|
+
if (evid.length > 0) {
|
|
136
|
+
lines.push("Evidence_Path:");
|
|
137
|
+
for (const p of evid) lines.push(`- ${p}`);
|
|
138
|
+
}
|
|
139
|
+
if (Array.isArray(selected.blockersStrict) && selected.blockersStrict.length > 0) {
|
|
140
|
+
lines.push("");
|
|
141
|
+
lines.push("Blockers (strict):");
|
|
142
|
+
for (const x of selected.blockersStrict) lines.push(`- ${x}`);
|
|
143
|
+
}
|
|
144
|
+
lines.push("");
|
|
145
|
+
lines.push("Raw JSON:");
|
|
146
|
+
lines.push(renderDetails(selected));
|
|
147
|
+
detailsEl.textContent = lines.join("\n");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
bodyEl.querySelectorAll("[data-validate]").forEach((btn) => {
|
|
152
|
+
btn.addEventListener("click", async () => {
|
|
153
|
+
const changeId = btn.getAttribute("data-validate") || "";
|
|
154
|
+
if (!changeId) return;
|
|
155
|
+
btn.disabled = true;
|
|
156
|
+
btn.textContent = "Running…";
|
|
157
|
+
detailsTitleEl.textContent = `validate --strict: ${changeId}`;
|
|
158
|
+
const res = await runStrictValidate(changeId);
|
|
159
|
+
const grouped = renderValidateGroups(res && res.groups ? res.groups : null);
|
|
160
|
+
detailsEl.textContent = grouped ? `${grouped}\n\nRaw JSON:\n${renderDetails(res)}` : renderDetails(res);
|
|
161
|
+
btn.disabled = false;
|
|
162
|
+
btn.textContent = "Validate --strict";
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
document.getElementById("refresh")?.addEventListener("click", refresh);
|
|
168
|
+
refresh();
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
6
|
+
<title>AIWS Dashboard</title>
|
|
7
|
+
<link rel="stylesheet" href="/assets/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header class="header">
|
|
11
|
+
<div class="brand">
|
|
12
|
+
<div class="logo">AIWS</div>
|
|
13
|
+
<div class="title">
|
|
14
|
+
<div class="h1">AIWS Dashboard</div>
|
|
15
|
+
<div class="sub">Local status for changes, quality gate, and evidence</div>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="actions">
|
|
19
|
+
<button id="refresh" class="btn">Refresh</button>
|
|
20
|
+
</div>
|
|
21
|
+
</header>
|
|
22
|
+
|
|
23
|
+
<main class="main">
|
|
24
|
+
<section class="panel">
|
|
25
|
+
<div class="panel-head">
|
|
26
|
+
<div class="panel-title">Changes</div>
|
|
27
|
+
<div class="panel-meta" id="summary"></div>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="table-wrap">
|
|
30
|
+
<table class="table" id="changes">
|
|
31
|
+
<thead>
|
|
32
|
+
<tr>
|
|
33
|
+
<th>Change</th>
|
|
34
|
+
<th>Tasks</th>
|
|
35
|
+
<th>Truth drift</th>
|
|
36
|
+
<th>Blockers (strict)</th>
|
|
37
|
+
<th>Plan & Evidence</th>
|
|
38
|
+
<th>Actions</th>
|
|
39
|
+
</tr>
|
|
40
|
+
</thead>
|
|
41
|
+
<tbody id="changes-body"></tbody>
|
|
42
|
+
</table>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="hint">
|
|
45
|
+
建议:先运行执行前质量门 <code>aiws change validate <id> --strict</code>(等价 <code>$ws-plan-verify</code>),通过后再进入 <code>$ws-dev</code>。
|
|
46
|
+
</div>
|
|
47
|
+
</section>
|
|
48
|
+
|
|
49
|
+
<section class="panel" id="details-panel">
|
|
50
|
+
<div class="panel-head">
|
|
51
|
+
<div class="panel-title">Details</div>
|
|
52
|
+
<div class="panel-meta" id="details-title">Select a change</div>
|
|
53
|
+
</div>
|
|
54
|
+
<pre class="log" id="details"></pre>
|
|
55
|
+
</section>
|
|
56
|
+
</main>
|
|
57
|
+
|
|
58
|
+
<script type="module" src="/assets/app.js"></script>
|
|
59
|
+
</body>
|
|
60
|
+
</html>
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #0b1020;
|
|
3
|
+
--panel: rgba(255, 255, 255, 0.06);
|
|
4
|
+
--panel2: rgba(255, 255, 255, 0.03);
|
|
5
|
+
--text: rgba(255, 255, 255, 0.92);
|
|
6
|
+
--muted: rgba(255, 255, 255, 0.62);
|
|
7
|
+
--border: rgba(255, 255, 255, 0.12);
|
|
8
|
+
--good: #3ddc97;
|
|
9
|
+
--warn: #ffcc00;
|
|
10
|
+
--bad: #ff5c7a;
|
|
11
|
+
--link: #8ab4ff;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
* {
|
|
15
|
+
box-sizing: border-box;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
body {
|
|
19
|
+
margin: 0;
|
|
20
|
+
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif;
|
|
21
|
+
color: var(--text);
|
|
22
|
+
background: radial-gradient(1200px 800px at 20% 0%, rgba(138, 180, 255, 0.12), transparent 60%),
|
|
23
|
+
radial-gradient(900px 700px at 80% 10%, rgba(61, 220, 151, 0.10), transparent 55%),
|
|
24
|
+
var(--bg);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
code {
|
|
28
|
+
background: rgba(255, 255, 255, 0.08);
|
|
29
|
+
padding: 0.15em 0.35em;
|
|
30
|
+
border-radius: 6px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.header {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
justify-content: space-between;
|
|
37
|
+
padding: 18px 22px;
|
|
38
|
+
border-bottom: 1px solid var(--border);
|
|
39
|
+
background: rgba(0, 0, 0, 0.2);
|
|
40
|
+
backdrop-filter: blur(10px);
|
|
41
|
+
position: sticky;
|
|
42
|
+
top: 0;
|
|
43
|
+
z-index: 10;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.brand {
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
gap: 14px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.logo {
|
|
53
|
+
width: 38px;
|
|
54
|
+
height: 38px;
|
|
55
|
+
border-radius: 10px;
|
|
56
|
+
display: grid;
|
|
57
|
+
place-items: center;
|
|
58
|
+
background: rgba(138, 180, 255, 0.16);
|
|
59
|
+
border: 1px solid rgba(138, 180, 255, 0.30);
|
|
60
|
+
font-weight: 700;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.h1 {
|
|
64
|
+
font-size: 16px;
|
|
65
|
+
font-weight: 700;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.sub {
|
|
69
|
+
font-size: 12px;
|
|
70
|
+
color: var(--muted);
|
|
71
|
+
margin-top: 2px;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.btn {
|
|
75
|
+
padding: 8px 12px;
|
|
76
|
+
border-radius: 10px;
|
|
77
|
+
border: 1px solid var(--border);
|
|
78
|
+
background: rgba(255, 255, 255, 0.06);
|
|
79
|
+
color: var(--text);
|
|
80
|
+
cursor: pointer;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.btn:hover {
|
|
84
|
+
background: rgba(255, 255, 255, 0.09);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.btn:disabled {
|
|
88
|
+
opacity: 0.55;
|
|
89
|
+
cursor: not-allowed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.btn.secondary {
|
|
93
|
+
background: rgba(255, 255, 255, 0.03);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.btn.danger {
|
|
97
|
+
border-color: rgba(255, 92, 122, 0.4);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.main {
|
|
101
|
+
display: grid;
|
|
102
|
+
grid-template-columns: 1.6fr 1fr;
|
|
103
|
+
gap: 18px;
|
|
104
|
+
padding: 18px 22px;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@media (max-width: 1100px) {
|
|
108
|
+
.main {
|
|
109
|
+
grid-template-columns: 1fr;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.panel {
|
|
114
|
+
background: var(--panel);
|
|
115
|
+
border: 1px solid var(--border);
|
|
116
|
+
border-radius: 14px;
|
|
117
|
+
overflow: hidden;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.panel-head {
|
|
121
|
+
display: flex;
|
|
122
|
+
align-items: center;
|
|
123
|
+
justify-content: space-between;
|
|
124
|
+
padding: 14px 14px;
|
|
125
|
+
background: var(--panel2);
|
|
126
|
+
border-bottom: 1px solid var(--border);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.panel-title {
|
|
130
|
+
font-weight: 700;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.panel-meta {
|
|
134
|
+
color: var(--muted);
|
|
135
|
+
font-size: 12px;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.muted {
|
|
139
|
+
color: var(--muted);
|
|
140
|
+
font-size: 12px;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.table-wrap {
|
|
144
|
+
overflow: auto;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.table {
|
|
148
|
+
width: 100%;
|
|
149
|
+
border-collapse: collapse;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.table th,
|
|
153
|
+
.table td {
|
|
154
|
+
padding: 10px 12px;
|
|
155
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
156
|
+
vertical-align: top;
|
|
157
|
+
font-size: 13px;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.table th {
|
|
161
|
+
text-align: left;
|
|
162
|
+
color: rgba(255, 255, 255, 0.78);
|
|
163
|
+
font-weight: 600;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.badge {
|
|
167
|
+
display: inline-flex;
|
|
168
|
+
align-items: center;
|
|
169
|
+
gap: 6px;
|
|
170
|
+
padding: 2px 8px;
|
|
171
|
+
border-radius: 999px;
|
|
172
|
+
border: 1px solid var(--border);
|
|
173
|
+
font-size: 12px;
|
|
174
|
+
color: var(--muted);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.dot {
|
|
178
|
+
width: 8px;
|
|
179
|
+
height: 8px;
|
|
180
|
+
border-radius: 50%;
|
|
181
|
+
background: var(--muted);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.dot.good {
|
|
185
|
+
background: var(--good);
|
|
186
|
+
}
|
|
187
|
+
.dot.warn {
|
|
188
|
+
background: var(--warn);
|
|
189
|
+
}
|
|
190
|
+
.dot.bad {
|
|
191
|
+
background: var(--bad);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.link {
|
|
195
|
+
color: var(--link);
|
|
196
|
+
text-decoration: none;
|
|
197
|
+
}
|
|
198
|
+
.link:hover {
|
|
199
|
+
text-decoration: underline;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.hint {
|
|
203
|
+
padding: 12px 14px;
|
|
204
|
+
color: var(--muted);
|
|
205
|
+
font-size: 12px;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.log {
|
|
209
|
+
margin: 0;
|
|
210
|
+
padding: 12px 14px;
|
|
211
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
212
|
+
font-size: 12px;
|
|
213
|
+
line-height: 1.45;
|
|
214
|
+
white-space: pre-wrap;
|
|
215
|
+
}
|