@aipper/aiws 0.0.23 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +30 -2
  2. package/package.json +2 -2
  3. package/src/cli.js +45 -2
  4. package/src/codex-skills.js +4 -4
  5. package/src/commands/change-advice.js +188 -0
  6. package/src/commands/change-evidence-command.js +242 -0
  7. package/src/commands/change-evidence-entry.js +40 -0
  8. package/src/commands/change-evidence.js +338 -0
  9. package/src/commands/change-finish.js +484 -0
  10. package/src/commands/change-git-status.js +53 -0
  11. package/src/commands/change-lifecycle-entry.js +60 -0
  12. package/src/commands/change-lifecycle.js +219 -0
  13. package/src/commands/change-metrics-command.js +110 -0
  14. package/src/commands/change-next-command.js +127 -0
  15. package/src/commands/change-review-gates.js +315 -0
  16. package/src/commands/change-scope-gate.js +34 -0
  17. package/src/commands/change-start.js +138 -0
  18. package/src/commands/change-state-command.js +40 -0
  19. package/src/commands/change-status-command.js +126 -0
  20. package/src/commands/change-status-context.js +202 -0
  21. package/src/commands/change-validate-entry.js +45 -0
  22. package/src/commands/change-validate.js +127 -0
  23. package/src/commands/change.js +436 -1291
  24. package/src/commands/dashboard.js +38 -12
  25. package/src/commands/hooks-install.js +1 -2
  26. package/src/commands/hooks-status.js +9 -1
  27. package/src/commands/opencode-status.js +61 -0
  28. package/src/commands/validate.js +1 -1
  29. package/src/dashboard/app.js +205 -2
  30. package/src/dashboard/index.html +5 -2
  31. package/src/dashboard/style.css +86 -0
  32. package/src/governance.js +157 -0
  33. package/src/json-schema-lite.js +164 -0
  34. package/src/opencode-env.js +76 -0
  35. package/src/spec.js +131 -0
@@ -0,0 +1,202 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * @param {string} gitRoot
5
+ * @param {string} changeId
6
+ * @param {{
7
+ * currentBranch: (gitRoot: string) => Promise<string>,
8
+ * listGitWorktrees: (gitRoot: string) => Promise<Array<{ worktree: string, branch: string }>>,
9
+ * pathExists: (path: string) => Promise<boolean>,
10
+ * isGitRepository: (path: string) => Promise<boolean>,
11
+ * inferChangeIdFromBranch: (branch: string) => string
12
+ * }} deps
13
+ */
14
+ export async function resolveChangeStatusContext(gitRoot, changeId, deps) {
15
+ const rootBranch = await deps.currentBranch(gitRoot);
16
+ /** @type {Array<{ worktree: string, branch: string }>} */
17
+ let worktrees = [];
18
+ try {
19
+ worktrees = await deps.listGitWorktrees(gitRoot);
20
+ } catch {
21
+ worktrees = [];
22
+ }
23
+
24
+ const changeRef = `refs/heads/change/${changeId}`;
25
+ const changeWorktree = worktrees.find((worktree) => String(worktree.branch || "") === changeRef)?.worktree || "";
26
+ const rootWorktree = path.resolve(gitRoot);
27
+ let contextWorktree = rootWorktree;
28
+ let source = "current_worktree";
29
+ let warning = "";
30
+ if (changeWorktree && path.resolve(changeWorktree) !== rootWorktree) {
31
+ const candidate = path.resolve(changeWorktree);
32
+ const candidateExists = await deps.pathExists(candidate);
33
+ const candidateReady = candidateExists ? await deps.isGitRepository(candidate) : false;
34
+ if (candidateReady) {
35
+ contextWorktree = candidate;
36
+ source = "change_worktree";
37
+ } else {
38
+ warning = `stale change worktree metadata: ${candidate} (run \`git worktree prune\`)`;
39
+ }
40
+ }
41
+ const contextBranch = await deps.currentBranch(contextWorktree);
42
+ const inferred = deps.inferChangeIdFromBranch(contextBranch);
43
+
44
+ return {
45
+ worktree: contextWorktree,
46
+ branch: contextBranch,
47
+ branchMatchesChange: inferred === changeId,
48
+ source,
49
+ warning,
50
+ currentWorktree: rootWorktree,
51
+ currentBranch: rootBranch,
52
+ changeWorktree: changeWorktree ? path.resolve(changeWorktree) : "",
53
+ };
54
+ }
55
+
56
+ /**
57
+ * @param {string} changeDir
58
+ * @param {{ pathExists: (path: string) => Promise<boolean>, readText: (path: string) => Promise<string> }} deps
59
+ */
60
+ export async function readChangeMetrics(changeDir, deps) {
61
+ const metricsAbs = path.join(changeDir, "metrics.json");
62
+ if (!(await deps.pathExists(metricsAbs))) return { state: "missing", abs: metricsAbs, events: [] };
63
+ try {
64
+ const parsed = JSON.parse(await deps.readText(metricsAbs));
65
+ const events = Array.isArray(parsed?.events) ? parsed.events : [];
66
+ return { state: "ok", abs: metricsAbs, events };
67
+ } catch {
68
+ return { state: "invalid", abs: metricsAbs, events: [] };
69
+ }
70
+ }
71
+
72
+ const FINISH_DONE_COMPLETED_CLEANUPS = new Set([
73
+ "removed",
74
+ "pruned-missing",
75
+ "skipped:no_separate_change_worktree",
76
+ "skipped:current_worktree",
77
+ ]);
78
+
79
+ /**
80
+ * @param {any} ev
81
+ * @returns {{ state: string, reason: string, eventType: string } | null}
82
+ */
83
+ function classifyFinishLifecycleEvent(ev) {
84
+ const type = String(ev?.type || "").trim();
85
+ if (!type) return null;
86
+ if (type === "finish_local") {
87
+ return { state: "local", reason: "", eventType: type };
88
+ }
89
+ if (type === "finish_failed") {
90
+ return { state: "failed", reason: String(ev?.payload?.error || ""), eventType: type };
91
+ }
92
+ if (type === "finish_cleanup_pending") {
93
+ return {
94
+ state: "cleanup_pending",
95
+ reason: String(ev?.payload?.reason || ev?.payload?.cleanup || ""),
96
+ eventType: type,
97
+ };
98
+ }
99
+ if (type === "finish") {
100
+ return { state: "done", reason: "", eventType: type };
101
+ }
102
+ if (type !== "finish_done") return null;
103
+
104
+ const payload = ev?.payload && typeof ev.payload === "object" ? ev.payload : null;
105
+ const hasPush = payload ? Object.prototype.hasOwnProperty.call(payload, "push") : false;
106
+ const pushCompleted = hasPush ? payload?.push === true : null;
107
+ const cleanup = String(payload?.cleanup || "").trim();
108
+
109
+ if (pushCompleted === false || cleanup === "not_requested") {
110
+ return { state: "local", reason: "", eventType: type };
111
+ }
112
+ if (!cleanup) {
113
+ return { state: "done", reason: "", eventType: type };
114
+ }
115
+ if (FINISH_DONE_COMPLETED_CLEANUPS.has(cleanup)) {
116
+ return { state: "done", reason: "", eventType: type };
117
+ }
118
+
119
+ return {
120
+ state: "cleanup_pending",
121
+ reason: cleanup.startsWith("skipped:") ? cleanup.slice("skipped:".length) : cleanup,
122
+ eventType: type,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * @param {{ state: string, events: any[] }} metrics
128
+ */
129
+ export function summarizeLifecycleSignals(metrics) {
130
+ const events = Array.isArray(metrics?.events) ? metrics.events : [];
131
+ /** @type {Record<string, number>} */
132
+ const eventCounts = {};
133
+ let finishState = "";
134
+ let finishStateReason = "";
135
+ let finishStateEventType = "";
136
+ for (const ev of events) {
137
+ const type = String(ev?.type || "").trim();
138
+ if (!type) continue;
139
+ eventCounts[type] = (eventCounts[type] || 0) + 1;
140
+ const finishIsTerminal = finishState === "done";
141
+ if (finishIsTerminal && type !== "finish_done" && type !== "finish") continue;
142
+ const classified = classifyFinishLifecycleEvent(ev);
143
+ if (!classified) continue;
144
+ finishState = classified.state;
145
+ finishStateReason = classified.reason;
146
+ finishStateEventType = classified.eventType;
147
+ }
148
+
149
+ const latest = events.length > 0 ? events[events.length - 1] : null;
150
+ const validateEvents = events.filter((ev) => ev?.type === "validate");
151
+ const latestStrictValidate = [...validateEvents].reverse().find((ev) => ev?.payload?.strict === true) || null;
152
+ const latestStrictScopeValidate =
153
+ [...validateEvents].reverse().find((ev) => ev?.payload?.strict === true && ev?.payload?.check_scope === true) || null;
154
+ const strictScopeValidateRuns = validateEvents.filter((ev) => ev?.payload?.strict === true && ev?.payload?.check_scope === true).length;
155
+
156
+ return {
157
+ metricsState: String(metrics?.state || "missing"),
158
+ latestEventType: String(latest?.type || ""),
159
+ eventCounts,
160
+ evidenceRuns: eventCounts.evidence || 0,
161
+ finishRuns: (eventCounts.finish || 0) + (eventCounts.finish_done || 0),
162
+ finishCleanupPendingReason: finishState === "cleanup_pending" ? finishStateReason : "",
163
+ finishState,
164
+ finishStateReason,
165
+ finishStateEventType,
166
+ archiveRuns: eventCounts.archive || 0,
167
+ strictValidatePass: latestStrictValidate ? latestStrictValidate?.payload?.ok === true : false,
168
+ strictScopeValidateRuns,
169
+ strictScopeValidatePass: latestStrictScopeValidate ? latestStrictScopeValidate?.payload?.ok === true : false,
170
+ strictScopeValidateAt: String(latestStrictScopeValidate?.timestamp || ""),
171
+ strictScopeValidateErrors: Number(latestStrictScopeValidate?.payload?.errors || 0),
172
+ strictScopeValidateWarnings: Number(latestStrictScopeValidate?.payload?.warnings || 0),
173
+ };
174
+ }
175
+
176
+ /**
177
+ * @param {Record<string, string | null>} curTruth
178
+ * @param {any} baseline
179
+ * @returns {{ baselineLabel: string, baselineAt: string, driftFiles: string[] }}
180
+ */
181
+ export function truthDrift(curTruth, baseline) {
182
+ /** @type {string[]} */
183
+ const driftFiles = [];
184
+
185
+ const createdTruth = baseline?.base_truth_files && typeof baseline.base_truth_files === "object" ? baseline.base_truth_files : {};
186
+ const syncedTruth = baseline?.synced_truth_files && typeof baseline.synced_truth_files === "object" ? baseline.synced_truth_files : {};
187
+ const effective = Object.keys(syncedTruth).length > 0 ? syncedTruth : createdTruth;
188
+ const baselineLabel = Object.keys(syncedTruth).length > 0 ? "sync" : "creation";
189
+ const baselineAt = Object.keys(syncedTruth).length > 0 ? String(baseline?.synced_at || "") : String(baseline?.created_at || "");
190
+
191
+ for (const [rel, info] of Object.entries(effective || {})) {
192
+ const baseSha = info && typeof info === "object" ? String(info.sha256 || "") : "";
193
+ const curSha = curTruth[rel] ?? null;
194
+ if (curSha == null) {
195
+ driftFiles.push(rel);
196
+ continue;
197
+ }
198
+ if (baseSha && curSha !== baseSha) driftFiles.push(rel);
199
+ }
200
+
201
+ return { baselineLabel, baselineAt, driftFiles };
202
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @param {{
3
+ * gitRoot: string,
4
+ * changeId: string,
5
+ * options: { strict: boolean, allowTruthDrift: boolean, checkEvidence: boolean, checkScope: boolean }
6
+ * }} input
7
+ * @param {{
8
+ * appendMetricsEvent: (gitRoot: string, changeId: string, type: string, payload: any) => Promise<void>,
9
+ * validateChangeArtifacts: (gitRoot: string, changeId: string, options: any) => Promise<any>,
10
+ * UserError: typeof import("../errors.js").UserError
11
+ * }} deps
12
+ */
13
+ export async function runChangeValidateCommand(input, deps) {
14
+ const { gitRoot, changeId, options } = input;
15
+ const result = await deps.validateChangeArtifacts(gitRoot, changeId, {
16
+ strict: options.strict === true,
17
+ allowTruthDrift: options.allowTruthDrift === true,
18
+ checkEvidence: options.checkEvidence === true,
19
+ checkScope: options.checkScope === true,
20
+ });
21
+
22
+ await deps.appendMetricsEvent(gitRoot, changeId, "validate", {
23
+ ok: result.ok === true,
24
+ strict: options.strict === true,
25
+ allow_truth_drift: options.allowTruthDrift === true,
26
+ check_evidence: options.checkEvidence === true,
27
+ check_scope: options.checkScope === true,
28
+ errors: Array.isArray(result.errors) ? result.errors.length : 0,
29
+ warnings: Array.isArray(result.warnings) ? result.warnings.length : 0,
30
+ exit_code: result.exitCode,
31
+ });
32
+
33
+ if (result.raw) {
34
+ return {
35
+ output: result.ok ? `ok: change validated (${changeId})\n` : "",
36
+ stderr: result.raw + "\n",
37
+ errorToThrow: result.ok ? null : new deps.UserError(""),
38
+ };
39
+ }
40
+ return {
41
+ output: result.ok ? `ok: change validated (${changeId})\n` : "",
42
+ stderr: "",
43
+ errorToThrow: result.ok ? null : new deps.UserError(""),
44
+ };
45
+ }
@@ -0,0 +1,127 @@
1
+ function parseChangeCheckerOutput(stdout, stderr) {
2
+ const text = [String(stdout || ""), String(stderr || "")].filter(Boolean).join("\n");
3
+ /** @type {string[]} */
4
+ const errors = [];
5
+ /** @type {string[]} */
6
+ const warnings = [];
7
+ for (const raw of text.split("\n")) {
8
+ const line = raw.trim();
9
+ if (!line) continue;
10
+ if (line.startsWith("error: ")) errors.push(line.replace(/^error:\s*/, ""));
11
+ else if (line.startsWith("warn: ")) warnings.push(line.replace(/^warn:\s*/, ""));
12
+ }
13
+ return { errors, warnings, raw: text.trim() };
14
+ }
15
+
16
+ function classifyCheckMessage(msg) {
17
+ const s = String(msg || "");
18
+ if (s.includes("truth file") || s.includes("truth drift") || s.includes("allow-truth-drift")) return "truth_drift";
19
+ if (s.includes("WS:TODO") || s.includes("unrendered template placeholders")) return "placeholders";
20
+ if (s.includes("missing:") || s.includes("empty:") || s.includes("Missing change dir")) return "missing_files";
21
+ if (
22
+ s.includes("Change_ID") ||
23
+ s.includes("Req_ID") ||
24
+ s.includes("Problem_ID") ||
25
+ s.includes("Contract_Row") ||
26
+ s.includes("Plan_File") ||
27
+ s.includes("Evidence_Path")
28
+ ) {
29
+ return "bindings";
30
+ }
31
+ if (s.includes("scope check") || s.includes("out-of-scope")) return "scope_gate";
32
+ if (
33
+ s.includes("finish gate") ||
34
+ s.includes("validate stamp") ||
35
+ s.includes("verify-before-complete") ||
36
+ s.includes("spec review") ||
37
+ s.includes("quality review")
38
+ ) {
39
+ return "finish_gate";
40
+ }
41
+ if (
42
+ s.includes("missing required sections") ||
43
+ s.includes("has empty sections") ||
44
+ s.includes("Plan section") ||
45
+ s.includes("Verify section") ||
46
+ s.includes("scope is too broad") ||
47
+ s.includes("too abstract")
48
+ ) {
49
+ return "plan_quality";
50
+ }
51
+ return "other";
52
+ }
53
+
54
+ function groupCheckMessages(errors, warnings) {
55
+ const groups = {
56
+ truth_drift: { errors: [], warnings: [] },
57
+ missing_files: { errors: [], warnings: [] },
58
+ placeholders: { errors: [], warnings: [] },
59
+ bindings: { errors: [], warnings: [] },
60
+ scope_gate: { errors: [], warnings: [] },
61
+ finish_gate: { errors: [], warnings: [] },
62
+ plan_quality: { errors: [], warnings: [] },
63
+ other: { errors: [], warnings: [] },
64
+ };
65
+
66
+ for (const message of errors || []) {
67
+ const group = classifyCheckMessage(message);
68
+ groups[group].errors.push(message);
69
+ }
70
+ for (const message of warnings || []) {
71
+ const group = classifyCheckMessage(message);
72
+ groups[group].warnings.push(message);
73
+ }
74
+ return groups;
75
+ }
76
+
77
+ /**
78
+ * @param {string} gitRoot
79
+ * @param {string} changeId
80
+ * @param {{ strict: boolean, allowTruthDrift: boolean, checkEvidence?: boolean, checkScope?: boolean }} options
81
+ * @param {{
82
+ * assertValidChangeId: (changeId: string) => void,
83
+ * collectFinishGateErrors: (changeId: string, reviewGates: any) => string[],
84
+ * computeChangeStatus: (gitRoot: string, changeId: string) => Promise<any>,
85
+ * ensureTruthFiles: (gitRoot: string) => Promise<void>,
86
+ * resolveWsChangeChecker: (gitRoot: string) => Promise<{ args: string[] }>,
87
+ * runPython: (cwd: string, args: string[]) => Promise<{ code: number, stdout: string, stderr: string }>
88
+ * }} deps
89
+ */
90
+ export async function validateChangeArtifacts(gitRoot, changeId, options, deps) {
91
+ deps.assertValidChangeId(changeId);
92
+ await deps.ensureTruthFiles(gitRoot);
93
+
94
+ const checker = await deps.resolveWsChangeChecker(gitRoot);
95
+ const args = [...checker.args, "--workspace-root", gitRoot, "--change-id", changeId];
96
+ if (options.strict) args.push("--strict");
97
+ if (options.allowTruthDrift) args.push("--allow-truth-drift");
98
+ if (options.checkEvidence) args.push("--check-evidence");
99
+ if (options.checkScope) args.push("--check-scope");
100
+
101
+ const res = await deps.runPython(gitRoot, ["-u", ...args]);
102
+ const parsed = parseChangeCheckerOutput(res.stdout, res.stderr);
103
+ const errors = [...parsed.errors];
104
+ const warnings = [...parsed.warnings];
105
+
106
+ if (options.checkEvidence === true) {
107
+ const status = await deps.computeChangeStatus(gitRoot, changeId);
108
+ errors.push(...deps.collectFinishGateErrors(changeId, status.reviewGates));
109
+ }
110
+
111
+ const rawLines = [];
112
+ if (parsed.raw) rawLines.push(parsed.raw);
113
+ for (const message of errors.slice(parsed.errors.length)) rawLines.push(`error: ${message}`);
114
+ for (const message of warnings.slice(parsed.warnings.length)) rawLines.push(`warn: ${message}`);
115
+ const exitCode = res.code === 0 && errors.length > 0 ? 1 : res.code;
116
+ return {
117
+ ok: exitCode === 0,
118
+ changeId,
119
+ strict: options.strict === true,
120
+ allowTruthDrift: options.allowTruthDrift === true,
121
+ exitCode,
122
+ errors,
123
+ warnings,
124
+ groups: groupCheckMessages(errors, warnings),
125
+ raw: rawLines.filter(Boolean).join("\n").trim(),
126
+ };
127
+ }