@williambeto/ai-workflow 2.3.6 → 2.4.0
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/AGENTS.md +5 -0
- package/CHANGELOG.md +43 -0
- package/dist-assets/skills/frontend-design-system/SKILL.md +83 -0
- package/package.json +2 -1
- package/src/cli.js +29 -3
- package/src/commands/collect-evidence.js +2 -2
- package/src/commands/execute.js +11 -2
- package/src/commands/validate.js +81 -0
- package/src/core/evidence/evidence-ledger.js +45 -0
- package/src/core/finalization/finalizer.js +56 -7
- package/src/core/validation/artifact-fidelity-gate.js +364 -3
- package/src/core/validation/delivery-decision-engine.js +283 -8
- package/src/core/validation/evidence-collector.js +112 -3
- package/src/core/validation/visual-verifier.js +159 -0
|
@@ -2,6 +2,29 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { StackDetector } from "./stack-detector.js";
|
|
4
4
|
|
|
5
|
+
function parseRequest(request = "") {
|
|
6
|
+
const req = request.toLowerCase();
|
|
7
|
+
|
|
8
|
+
let targetStack = null;
|
|
9
|
+
if (/\b(vue|nuxt|quasar)\b/.test(req)) targetStack = "vue";
|
|
10
|
+
else if (/\b(react|next)\b/.test(req)) targetStack = "react";
|
|
11
|
+
else if (/\b(angular|svelte)\b/.test(req)) targetStack = "svelte-or-angular";
|
|
12
|
+
else if (/\b(laravel|django|rails|express|nest|fastify)\b/.test(req)) {
|
|
13
|
+
if (/\blaravel\b/.test(req)) targetStack = "laravel";
|
|
14
|
+
else targetStack = "backend";
|
|
15
|
+
}
|
|
16
|
+
else if (/\b(wordpress|wp)\b/.test(req)) targetStack = "wordpress";
|
|
17
|
+
else if (/\b(cli|command|bin)\b/.test(req)) targetStack = "cli";
|
|
18
|
+
|
|
19
|
+
let intent = "implementation";
|
|
20
|
+
if (/\b(document|doc|readme)\b/.test(req)) intent = "docs";
|
|
21
|
+
else if (/\b(audit|security|inspect|dependencies)\b/.test(req)) intent = "audit";
|
|
22
|
+
else if (/\b(release|publish|deploy)\b/.test(req)) intent = "release";
|
|
23
|
+
else if (/\b(prototype|standalone html)\b/.test(req)) intent = "prototype";
|
|
24
|
+
|
|
25
|
+
return { targetStack, intent };
|
|
26
|
+
}
|
|
27
|
+
|
|
5
28
|
export class DeliveryDecisionEngine {
|
|
6
29
|
constructor({ cwd = process.cwd() } = {}) {
|
|
7
30
|
this.cwd = cwd;
|
|
@@ -13,7 +36,6 @@ export class DeliveryDecisionEngine {
|
|
|
13
36
|
return "empty";
|
|
14
37
|
}
|
|
15
38
|
|
|
16
|
-
// 1. Docs check
|
|
17
39
|
const isDocs = changedFiles.every(file =>
|
|
18
40
|
file.endsWith(".md") || file.endsWith(".txt") || file.startsWith("docs/")
|
|
19
41
|
);
|
|
@@ -21,10 +43,8 @@ export class DeliveryDecisionEngine {
|
|
|
21
43
|
return "docs-only";
|
|
22
44
|
}
|
|
23
45
|
|
|
24
|
-
// 2. Detect project stacks
|
|
25
46
|
const stacks = await this.stackDetector.detect();
|
|
26
47
|
|
|
27
|
-
// Check package.json for frontend frameworks
|
|
28
48
|
let usesJsFramework = false;
|
|
29
49
|
try {
|
|
30
50
|
const pkgPath = path.join(this.cwd, "package.json");
|
|
@@ -35,14 +55,11 @@ export class DeliveryDecisionEngine {
|
|
|
35
55
|
const frameworks = ["react", "next", "vue", "nuxt", "svelte", "solid-js", "preact", "quasar", "@angular/core"];
|
|
36
56
|
usesJsFramework = frameworks.some(fw => deps[fw] !== undefined);
|
|
37
57
|
} catch {
|
|
38
|
-
//
|
|
58
|
+
// ignore
|
|
39
59
|
}
|
|
40
60
|
|
|
41
|
-
|
|
42
|
-
const isWordPress = await fs.access(path.join(this.cwd, "wp-config.php")).then(() => true).catch(() => false) ||
|
|
43
|
-
await fs.access(path.join(this.cwd, "wp-content")).then(() => true).catch(() => false);
|
|
61
|
+
const isWordPress = await checkWordPressDirectRoot(this.cwd);
|
|
44
62
|
|
|
45
|
-
// Analyze changed files for UI/frontend assets
|
|
46
63
|
const hasFrontendFiles = changedFiles.some(file =>
|
|
47
64
|
/\.(html?|css|scss|sass|less|jsx|tsx|vue|svelte)$/i.test(file) ||
|
|
48
65
|
(/\.js$/i.test(file) && !file.includes("config") && !file.includes("test"))
|
|
@@ -66,4 +83,262 @@ export class DeliveryDecisionEngine {
|
|
|
66
83
|
|
|
67
84
|
return "generic-code";
|
|
68
85
|
}
|
|
86
|
+
|
|
87
|
+
async detectContext() {
|
|
88
|
+
const stacks = await this.stackDetector.detect();
|
|
89
|
+
|
|
90
|
+
let usesJsFramework = null;
|
|
91
|
+
let hasPackageJson = false;
|
|
92
|
+
let isCli = false;
|
|
93
|
+
try {
|
|
94
|
+
const pkgPath = path.join(this.cwd, "package.json");
|
|
95
|
+
const pkgContent = await fs.readFile(pkgPath, "utf8");
|
|
96
|
+
hasPackageJson = true;
|
|
97
|
+
const pkg = JSON.parse(pkgContent);
|
|
98
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
99
|
+
if (pkg.bin || pkg.name === "@williambeto/ai-workflow") {
|
|
100
|
+
isCli = true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const frameworks = ["react", "next", "vue", "nuxt", "svelte", "solid-js", "preact", "quasar", "@angular/core"];
|
|
104
|
+
for (const fw of frameworks) {
|
|
105
|
+
if (deps[fw] !== undefined) {
|
|
106
|
+
usesJsFramework = fw;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch {}
|
|
111
|
+
|
|
112
|
+
let isLaravel = false;
|
|
113
|
+
let hasComposerJson = false;
|
|
114
|
+
try {
|
|
115
|
+
const composerPath = path.join(this.cwd, "composer.json");
|
|
116
|
+
const composerContent = await fs.readFile(composerPath, "utf8");
|
|
117
|
+
hasComposerJson = true;
|
|
118
|
+
const composer = JSON.parse(composerContent);
|
|
119
|
+
const reqs = { ...(composer.require || {}), ...(composer.requireDev || {}) };
|
|
120
|
+
if (reqs["laravel/framework"]) {
|
|
121
|
+
isLaravel = true;
|
|
122
|
+
}
|
|
123
|
+
} catch {}
|
|
124
|
+
|
|
125
|
+
const isWordPress = await checkWordPressDirectRoot(this.cwd);
|
|
126
|
+
|
|
127
|
+
let isEmpty = true;
|
|
128
|
+
try {
|
|
129
|
+
const entries = await fs.readdir(this.cwd);
|
|
130
|
+
const meaningful = entries.filter(e => !e.startsWith(".") && !["node_modules", "EVIDENCE.json"].includes(e));
|
|
131
|
+
if (meaningful.length > 0) {
|
|
132
|
+
if (meaningful.length === 1 && meaningful[0] === "package.json") {
|
|
133
|
+
try {
|
|
134
|
+
const content = await fs.readFile(path.join(this.cwd, "package.json"), "utf8");
|
|
135
|
+
const pkg = JSON.parse(content);
|
|
136
|
+
if (!pkg.dependencies && !pkg.devDependencies) {
|
|
137
|
+
isEmpty = true;
|
|
138
|
+
} else {
|
|
139
|
+
isEmpty = false;
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
isEmpty = true;
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
isEmpty = false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
isEmpty = true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let projectState = isEmpty ? "empty" : "active";
|
|
153
|
+
let detectedStackType = "unknown";
|
|
154
|
+
let detectedStacks = [];
|
|
155
|
+
|
|
156
|
+
if (isWordPress) {
|
|
157
|
+
detectedStackType = "WordPress context";
|
|
158
|
+
detectedStacks.push("wordpress");
|
|
159
|
+
} else if (isLaravel) {
|
|
160
|
+
detectedStackType = "backend context";
|
|
161
|
+
detectedStacks.push("laravel");
|
|
162
|
+
} else if (usesJsFramework) {
|
|
163
|
+
detectedStackType = "existing matching stack";
|
|
164
|
+
detectedStacks.push(usesJsFramework);
|
|
165
|
+
} else if (isCli) {
|
|
166
|
+
detectedStackType = "CLI package context";
|
|
167
|
+
detectedStacks.push("node");
|
|
168
|
+
} else if (stacks.includes("node")) {
|
|
169
|
+
detectedStackType = "existing matching stack";
|
|
170
|
+
detectedStacks.push("node");
|
|
171
|
+
} else if (stacks.includes("python") || stacks.includes("php")) {
|
|
172
|
+
detectedStackType = "backend context";
|
|
173
|
+
detectedStacks.push(...stacks);
|
|
174
|
+
} else if (projectState === "empty") {
|
|
175
|
+
detectedStackType = "empty project";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
projectState,
|
|
180
|
+
detectedStackType,
|
|
181
|
+
detectedStacks,
|
|
182
|
+
isWordPress,
|
|
183
|
+
isLaravel,
|
|
184
|
+
usesJsFramework,
|
|
185
|
+
isCli
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async determineDecision(input = {}) {
|
|
190
|
+
const userRequest = input.userRequest || "";
|
|
191
|
+
const explicitApprovals = input.explicitApprovals || [];
|
|
192
|
+
|
|
193
|
+
const ctx = input.projectContext || await this.detectContext();
|
|
194
|
+
const projectState = ctx.projectState || "active";
|
|
195
|
+
const detectedStacks = ctx.detectedStacks || [];
|
|
196
|
+
const isWordPress = ctx.isWordPress || false;
|
|
197
|
+
const isLaravel = ctx.isLaravel || false;
|
|
198
|
+
const isCli = ctx.isCli || false;
|
|
199
|
+
const usesJsFramework = ctx.usesJsFramework || null;
|
|
200
|
+
|
|
201
|
+
const parsed = parseRequest(userRequest);
|
|
202
|
+
const targetStack = parsed.targetStack;
|
|
203
|
+
const intent = parsed.intent;
|
|
204
|
+
|
|
205
|
+
let decision = "IMPLEMENT_IN_EXISTING_CONTEXT";
|
|
206
|
+
let allowedArtifactShape = "generic-code";
|
|
207
|
+
let forbiddenSubstitutes = ["success-without-validation"];
|
|
208
|
+
let validationRequired = ["build-or-equivalent"];
|
|
209
|
+
let approvalRequired = false;
|
|
210
|
+
|
|
211
|
+
if (intent === "audit") {
|
|
212
|
+
decision = "READ_ONLY_AUDIT";
|
|
213
|
+
allowedArtifactShape = "audit-report";
|
|
214
|
+
forbiddenSubstitutes.push("any-code-mutation");
|
|
215
|
+
validationRequired = [];
|
|
216
|
+
}
|
|
217
|
+
else if (intent === "docs") {
|
|
218
|
+
decision = "DOCS_ONLY";
|
|
219
|
+
allowedArtifactShape = "markdown";
|
|
220
|
+
forbiddenSubstitutes.push("unjustified-code-mutation");
|
|
221
|
+
validationRequired = [];
|
|
222
|
+
}
|
|
223
|
+
else if (intent === "release") {
|
|
224
|
+
decision = "RELEASE_OR_DEPLOY_REQUIRES_APPROVAL";
|
|
225
|
+
allowedArtifactShape = "package-tarball";
|
|
226
|
+
forbiddenSubstitutes.push("publish-without-approval");
|
|
227
|
+
validationRequired = ["release-readiness-or-equivalent"];
|
|
228
|
+
|
|
229
|
+
const hasApproval = explicitApprovals.includes("npm-publish") || explicitApprovals.includes("deploy-production");
|
|
230
|
+
if (hasApproval) {
|
|
231
|
+
decision = "RELEASE_OR_DEPLOY_APPROVED";
|
|
232
|
+
approvalRequired = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else if (intent === "prototype") {
|
|
236
|
+
decision = "CREATE_STANDALONE_PROTOTYPE";
|
|
237
|
+
allowedArtifactShape = "standalone-html";
|
|
238
|
+
validationRequired = [];
|
|
239
|
+
}
|
|
240
|
+
else if (projectState === "empty") {
|
|
241
|
+
decision = "SCAFFOLD_AND_IMPLEMENT";
|
|
242
|
+
if (targetStack === "vue" || targetStack === "react") {
|
|
243
|
+
allowedArtifactShape = "runnable-framework-app";
|
|
244
|
+
forbiddenSubstitutes.push("standalone-html-as-framework-delivery", "loose-framework-files-without-runtime");
|
|
245
|
+
validationRequired.push("dependency-install-or-verification", "runtime-smoke-or-equivalent");
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
const hasConflict = (targetStack === "vue" && detectedStacks.includes("react")) ||
|
|
249
|
+
(targetStack === "react" && detectedStacks.includes("vue")) ||
|
|
250
|
+
(targetStack === "laravel" && !detectedStacks.includes("laravel") && detectedStacks.length > 0) ||
|
|
251
|
+
(targetStack === "wordpress" && !detectedStacks.includes("wordpress") && detectedStacks.length > 0);
|
|
252
|
+
|
|
253
|
+
if (hasConflict) {
|
|
254
|
+
decision = "BLOCK_STACK_CONFLICT";
|
|
255
|
+
} else if (targetStack === "laravel" && !detectedStacks.includes("laravel")) {
|
|
256
|
+
decision = "BLOCK_UNSUPPORTED_CONTEXT";
|
|
257
|
+
} else if (targetStack === "wordpress" && !detectedStacks.includes("wordpress")) {
|
|
258
|
+
decision = "BLOCK_UNSUPPORTED_CONTEXT";
|
|
259
|
+
} else {
|
|
260
|
+
if (targetStack === "vue" || targetStack === "react") {
|
|
261
|
+
decision = "IMPLEMENT_IN_EXISTING_CONTEXT";
|
|
262
|
+
allowedArtifactShape = "runnable-framework-app";
|
|
263
|
+
forbiddenSubstitutes.push("standalone-html-as-framework-delivery", "loose-framework-files-without-runtime");
|
|
264
|
+
validationRequired.push("dependency-install-or-verification", "runtime-smoke-or-equivalent");
|
|
265
|
+
} else if (targetStack === "cli" || isCli) {
|
|
266
|
+
decision = "CLI_COMMAND_INTEGRATION";
|
|
267
|
+
allowedArtifactShape = "bin-entry";
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (userRequest && userRequest.trim().length < 3) {
|
|
273
|
+
decision = "BLOCK_UNCLEAR_SCOPE";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
intent: intent === "implementation" ? "implementation" : intent,
|
|
278
|
+
requestedOutcome: targetStack === "vue" || targetStack === "react" ? "page" : (targetStack === "wordpress" ? "plugin-or-theme" : null),
|
|
279
|
+
requestedStack: targetStack,
|
|
280
|
+
requestedPlatform: targetStack === "wordpress" ? "wordpress" : null,
|
|
281
|
+
projectState,
|
|
282
|
+
detectedStacks,
|
|
283
|
+
decision,
|
|
284
|
+
allowedArtifactShape,
|
|
285
|
+
forbiddenSubstitutes,
|
|
286
|
+
validationRequired,
|
|
287
|
+
approvalRequired: explicitApprovals.length > 0
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function checkWordPressDirectRoot(cwd) {
|
|
293
|
+
// Check standard paths
|
|
294
|
+
const hasWpConfig = await fs.access(path.join(cwd, "wp-config.php")).then(() => true).catch(() => false);
|
|
295
|
+
const hasWpContent = await fs.access(path.join(cwd, "wp-content")).then(() => true).catch(() => false);
|
|
296
|
+
if (hasWpConfig || hasWpContent) {
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Check direct Theme root
|
|
301
|
+
const hasStyleCss = await fs.access(path.join(cwd, "style.css")).then(() => true).catch(() => false);
|
|
302
|
+
if (hasStyleCss) {
|
|
303
|
+
try {
|
|
304
|
+
const content = await fs.readFile(path.join(cwd, "style.css"), "utf8");
|
|
305
|
+
if (/\bTheme Name\s*:/i.test(content) || /\bTheme URI\s*:/i.test(content)) {
|
|
306
|
+
const otherIndicators = [
|
|
307
|
+
"functions.php",
|
|
308
|
+
"theme.json",
|
|
309
|
+
"index.php",
|
|
310
|
+
"front-page.php",
|
|
311
|
+
"single.php",
|
|
312
|
+
"page.php",
|
|
313
|
+
"archive.php",
|
|
314
|
+
"template-parts"
|
|
315
|
+
];
|
|
316
|
+
for (const ind of otherIndicators) {
|
|
317
|
+
const exists = await fs.access(path.join(cwd, ind)).then(() => true).catch(() => false);
|
|
318
|
+
if (exists) {
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
// ignore
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Check direct Plugin root
|
|
329
|
+
try {
|
|
330
|
+
const files = await fs.readdir(cwd);
|
|
331
|
+
for (const file of files) {
|
|
332
|
+
if (file.endsWith(".php")) {
|
|
333
|
+
const content = await fs.readFile(path.join(cwd, file), "utf8");
|
|
334
|
+
if (/\bPlugin Name\s*:/i.test(content)) {
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
} catch {
|
|
340
|
+
// ignore
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return false;
|
|
69
344
|
}
|
|
@@ -2,6 +2,9 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { QualityGuard } from "./quality-guard.js";
|
|
5
|
+
import { DeliveryDecisionEngine } from "./delivery-decision-engine.js";
|
|
6
|
+
import { ArtifactFidelityGate } from "./artifact-fidelity-gate.js";
|
|
7
|
+
import { VisualVerifier } from "./visual-verifier.js";
|
|
5
8
|
|
|
6
9
|
const VALIDATION_KINDS = new Set(["test", "build", "typecheck", "lint", "security", "smoke", "validate", "other"]);
|
|
7
10
|
|
|
@@ -27,7 +30,7 @@ function inferTaskKind(task = {}) {
|
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export class EvidenceCollector {
|
|
30
|
-
constructor({ cwd, maxLogLength = 2000, timeout = 60000, taskSlug = null, mode = null, profile = "generic", branchRecovery = "NOT_RECORDED" } = {}) {
|
|
33
|
+
constructor({ cwd, maxLogLength = 2000, timeout = 60000, taskSlug = null, mode = null, profile = "generic", branchRecovery = "NOT_RECORDED", userRequest = null, explicitApprovals = [], visualDist = null, port = 8080 } = {}) {
|
|
31
34
|
this.cwd = cwd;
|
|
32
35
|
this.maxLogLength = maxLogLength;
|
|
33
36
|
this.timeout = timeout;
|
|
@@ -35,6 +38,55 @@ export class EvidenceCollector {
|
|
|
35
38
|
this.mode = mode;
|
|
36
39
|
this.profile = profile;
|
|
37
40
|
this.branchRecovery = branchRecovery;
|
|
41
|
+
this.userRequest = userRequest;
|
|
42
|
+
this.explicitApprovals = explicitApprovals;
|
|
43
|
+
this.visualDist = visualDist;
|
|
44
|
+
this.port = Number(port || 8080);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async findUserRequest() {
|
|
48
|
+
if (this.userRequest) return this.userRequest;
|
|
49
|
+
|
|
50
|
+
if (this.taskSlug) {
|
|
51
|
+
const ledgerPath = path.join(this.cwd, `.ai-workflow/history/${this.taskSlug}-ledger.json`);
|
|
52
|
+
try {
|
|
53
|
+
const content = await fs.readFile(ledgerPath, "utf8");
|
|
54
|
+
const events = JSON.parse(content);
|
|
55
|
+
const routeEvent = events.find(e => e.eventType === "routing");
|
|
56
|
+
if (routeEvent && routeEvent.data?.request) {
|
|
57
|
+
return routeEvent.data.request;
|
|
58
|
+
}
|
|
59
|
+
const startEvent = events.find(e => e.eventType === "implementation_start");
|
|
60
|
+
if (startEvent && startEvent.data?.prompt) {
|
|
61
|
+
return startEvent.data.prompt;
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const dir = path.join(this.cwd, ".ai-workflow/history");
|
|
68
|
+
const files = await fs.readdir(dir);
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
if (file.endsWith("-ledger.json")) {
|
|
71
|
+
const content = await fs.readFile(path.join(dir, file), "utf8");
|
|
72
|
+
const events = JSON.parse(content);
|
|
73
|
+
const routeEvent = events.find(e => e.eventType === "routing");
|
|
74
|
+
if (routeEvent && routeEvent.data?.request) {
|
|
75
|
+
return routeEvent.data.request;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch {}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const { execSync } = await import("node:child_process");
|
|
83
|
+
const branch = execSync("git branch --show-current", { cwd: this.cwd, encoding: "utf8" }).trim();
|
|
84
|
+
if (branch && branch !== "main" && branch !== "master" && branch.startsWith("feat/")) {
|
|
85
|
+
return branch.replace("feat/", "").replaceAll("-", " ");
|
|
86
|
+
}
|
|
87
|
+
} catch {}
|
|
88
|
+
|
|
89
|
+
return "";
|
|
38
90
|
}
|
|
39
91
|
|
|
40
92
|
runTask(task) {
|
|
@@ -95,6 +147,30 @@ export class EvidenceCollector {
|
|
|
95
147
|
const behaviorTests = results.filter((result) => result.kind === "test");
|
|
96
148
|
const passingBehaviorTest = behaviorTests.some((result) => result.status === "PASS");
|
|
97
149
|
|
|
150
|
+
const userRequest = await this.findUserRequest();
|
|
151
|
+
const engine = new DeliveryDecisionEngine({ cwd: this.cwd });
|
|
152
|
+
const projectContext = await engine.detectContext();
|
|
153
|
+
const deliveryDecision = await engine.determineDecision({
|
|
154
|
+
userRequest,
|
|
155
|
+
projectContext,
|
|
156
|
+
explicitApprovals: this.explicitApprovals || []
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const fidelityGate = new ArtifactFidelityGate({
|
|
160
|
+
cwd: this.cwd,
|
|
161
|
+
deliveryClass: deliveryDecision.allowedArtifactShape === "runnable-framework-app" ? "framework-native-frontend" : (deliveryDecision.allowedArtifactShape === "wordpress" ? "wordpress-php" : "generic-code"),
|
|
162
|
+
userRequest,
|
|
163
|
+
explicitApprovals: this.explicitApprovals || []
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const changedFiles = policyValidation.git?.changedFiles || [];
|
|
167
|
+
const artifactFidelityCheck = await fidelityGate.verifyRequestFidelity({
|
|
168
|
+
userRequest,
|
|
169
|
+
deliveryDecision,
|
|
170
|
+
projectContext,
|
|
171
|
+
changedFiles
|
|
172
|
+
});
|
|
173
|
+
|
|
98
174
|
let overallStatus = "PASS";
|
|
99
175
|
if (implementation && executableBehavior && tasks.length === 0) overallStatus = "BLOCKED";
|
|
100
176
|
else if (results.some((result) => ["FAIL", "BLOCKED", "FAIL_QUALITY_GATE"].includes(result.status))) overallStatus = "FAIL_QUALITY_GATE";
|
|
@@ -102,6 +178,16 @@ export class EvidenceCollector {
|
|
|
102
178
|
else if (["FAIL", "BLOCKED", "FAIL_QUALITY_GATE"].includes(policyValidation.overallStatus)) overallStatus = "FAIL_QUALITY_GATE";
|
|
103
179
|
else if (results.some((result) => result.status === "PASS_WITH_NOTES") || policyValidation.overallStatus === "PASS_WITH_NOTES") overallStatus = "PASS_WITH_NOTES";
|
|
104
180
|
|
|
181
|
+
if (!artifactFidelityCheck || !artifactFidelityCheck.passed) {
|
|
182
|
+
overallStatus = "BLOCKED";
|
|
183
|
+
} else if (implementation) {
|
|
184
|
+
if (!deliveryDecision || !deliveryDecision.decision || deliveryDecision.decision.startsWith("BLOCK") || deliveryDecision.decision.includes("BLOCKED") || deliveryDecision.decision === "REQUIRE_EXPLICIT_AUTHORIZATION" || deliveryDecision.decision === "RELEASE_OR_DEPLOY_REQUIRES_APPROVAL") {
|
|
185
|
+
overallStatus = "BLOCKED";
|
|
186
|
+
} else if (results.length === 0 && executableBehavior && deliveryDecision.decision !== "DOCUMENTATION_ONLY" && deliveryDecision.decision !== "DOCS_ONLY" && deliveryDecision.decision !== "READ_ONLY_AUDIT") {
|
|
187
|
+
overallStatus = "BLOCKED";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
105
191
|
const limitations = [];
|
|
106
192
|
if (implementation && executableBehavior && tasks.length === 0) limitations.push("No meaningful validation command was available for executable implementation work.");
|
|
107
193
|
if (executableBehavior && behaviorTests.length === 0) {
|
|
@@ -111,6 +197,26 @@ export class EvidenceCollector {
|
|
|
111
197
|
if (check.status === "PASS_WITH_NOTES" && check.reason) limitations.push(`${name}: ${check.reason}`);
|
|
112
198
|
}
|
|
113
199
|
|
|
200
|
+
let visualEvidence = null;
|
|
201
|
+
if (this.visualDist) {
|
|
202
|
+
console.log(`\n--- Capturing Visual Evidence ---`);
|
|
203
|
+
console.log(`Serving ${this.visualDist} on port ${this.port}...`);
|
|
204
|
+
try {
|
|
205
|
+
const verifier = new VisualVerifier({ cwd: this.cwd, visualDist: this.visualDist, port: this.port });
|
|
206
|
+
const screenshots = await verifier.captureScreenshots();
|
|
207
|
+
visualEvidence = {
|
|
208
|
+
desktop: path.relative(this.cwd, screenshots[0]),
|
|
209
|
+
mobile: path.relative(this.cwd, screenshots[1])
|
|
210
|
+
};
|
|
211
|
+
console.log(`Visual evidence captured successfully:`);
|
|
212
|
+
console.log(`- Desktop: ${visualEvidence.desktop}`);
|
|
213
|
+
console.log(`- Mobile: ${visualEvidence.mobile}`);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.error(`Error capturing visual evidence: ${err.message}`);
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
114
220
|
const evidence = {
|
|
115
221
|
timestamp: new Date().toISOString(),
|
|
116
222
|
taskSlug: this.taskSlug,
|
|
@@ -118,12 +224,15 @@ export class EvidenceCollector {
|
|
|
118
224
|
workflowProfile: this.profile,
|
|
119
225
|
branchRecovery: this.branchRecovery,
|
|
120
226
|
branch: policyValidation.git?.branch || "unknown",
|
|
121
|
-
changedFiles
|
|
227
|
+
changedFiles,
|
|
122
228
|
status: publicStatus(overallStatus),
|
|
123
229
|
internalStatus: overallStatus,
|
|
124
230
|
commands: results,
|
|
125
231
|
checks: policyValidation.checks,
|
|
126
|
-
limitations
|
|
232
|
+
limitations,
|
|
233
|
+
deliveryDecision,
|
|
234
|
+
artifactFidelityCheck,
|
|
235
|
+
visualEvidence
|
|
127
236
|
};
|
|
128
237
|
|
|
129
238
|
if (writeArtifact) {
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export class VisualVerifier {
|
|
6
|
+
constructor({ cwd = process.cwd(), visualDist = null, port = 8080 } = {}) {
|
|
7
|
+
this.cwd = cwd;
|
|
8
|
+
this.visualDist = visualDist;
|
|
9
|
+
this.port = Number(port || 8080);
|
|
10
|
+
this.server = null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async startServer() {
|
|
14
|
+
if (!this.visualDist) {
|
|
15
|
+
throw new Error("No --visual-dist directory specified. Cannot start visual server.");
|
|
16
|
+
}
|
|
17
|
+
const distPath = path.resolve(this.cwd, this.visualDist);
|
|
18
|
+
this.server = http.createServer(async (req, res) => {
|
|
19
|
+
try {
|
|
20
|
+
const urlPath = req.url.split("?")[0];
|
|
21
|
+
const filePath = path.join(distPath, urlPath === "/" ? "index.html" : urlPath);
|
|
22
|
+
const content = await fs.readFile(filePath);
|
|
23
|
+
|
|
24
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
25
|
+
let contentType = "text/html";
|
|
26
|
+
if (ext === ".js" || ext === ".mjs") contentType = "application/javascript";
|
|
27
|
+
else if (ext === ".css") contentType = "text/css";
|
|
28
|
+
else if (ext === ".png") contentType = "image/png";
|
|
29
|
+
else if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg";
|
|
30
|
+
else if (ext === ".svg") contentType = "image/svg+xml";
|
|
31
|
+
|
|
32
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
33
|
+
res.end(content);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
36
|
+
res.end("Not Found");
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await new Promise((resolve, reject) => {
|
|
41
|
+
this.server.listen(this.port, "127.0.0.1", (err) => {
|
|
42
|
+
if (err) reject(err);
|
|
43
|
+
else resolve();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async closeServer() {
|
|
49
|
+
if (this.server) {
|
|
50
|
+
await new Promise((resolve) => this.server.close(resolve));
|
|
51
|
+
this.server = null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async captureScreenshots() {
|
|
56
|
+
if (!this.visualDist) return [];
|
|
57
|
+
|
|
58
|
+
let playwright;
|
|
59
|
+
try {
|
|
60
|
+
playwright = await import("playwright");
|
|
61
|
+
} catch {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"Playwright is required to capture visual evidence. Please install it using 'npm install -D playwright' and initialize browsers using 'npx playwright install'."
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await this.startServer();
|
|
68
|
+
const browser = await playwright.chromium.launch();
|
|
69
|
+
const page = await browser.newPage();
|
|
70
|
+
|
|
71
|
+
const outputDir = path.join(this.cwd, ".evidence/visual");
|
|
72
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
const desktopPath = path.join(outputDir, "desktop.png");
|
|
75
|
+
const mobilePath = path.join(outputDir, "mobile.png");
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await page.goto(`http://127.0.0.1:${this.port}`);
|
|
79
|
+
|
|
80
|
+
// Desktop
|
|
81
|
+
await page.setViewportSize({ width: 1440, height: 900 });
|
|
82
|
+
await page.screenshot({ path: desktopPath, fullPage: true });
|
|
83
|
+
|
|
84
|
+
// Mobile
|
|
85
|
+
await page.setViewportSize({ width: 390, height: 844 });
|
|
86
|
+
await page.screenshot({ path: mobilePath, fullPage: true });
|
|
87
|
+
} finally {
|
|
88
|
+
await browser.close();
|
|
89
|
+
await this.closeServer();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return [desktopPath, mobilePath];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async runAccessibilityCheck() {
|
|
96
|
+
if (!this.visualDist) {
|
|
97
|
+
throw new Error("Accessibility check requires a target build directory specified via --visual-dist.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let playwright;
|
|
101
|
+
try {
|
|
102
|
+
playwright = await import("playwright");
|
|
103
|
+
} catch {
|
|
104
|
+
throw new Error(
|
|
105
|
+
"Playwright is required to run accessibility checks. Please install it using 'npm install -D playwright' and initialize browsers using 'npx playwright install'."
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await this.startServer();
|
|
110
|
+
const browser = await playwright.chromium.launch();
|
|
111
|
+
const page = await browser.newPage();
|
|
112
|
+
|
|
113
|
+
const outputDir = path.join(this.cwd, ".evidence/a11y");
|
|
114
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await page.goto(`http://127.0.0.1:${this.port}`);
|
|
118
|
+
|
|
119
|
+
// Read axe-core bundle
|
|
120
|
+
let axeScript = "";
|
|
121
|
+
try {
|
|
122
|
+
const axePath = path.join(this.cwd, "node_modules/axe-core/axe.min.js");
|
|
123
|
+
axeScript = await fs.readFile(axePath, "utf8");
|
|
124
|
+
} catch {
|
|
125
|
+
// Fallback: look in dev-kit node_modules
|
|
126
|
+
try {
|
|
127
|
+
// Resolve relative to current module path
|
|
128
|
+
const mainAxePath = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../../node_modules/axe-core/axe.min.js");
|
|
129
|
+
axeScript = await fs.readFile(mainAxePath, "utf8");
|
|
130
|
+
} catch {
|
|
131
|
+
throw new Error("axe-core package is not installed. Please install axe-core using 'npm install -D axe-core'.");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await page.evaluate(axeScript);
|
|
136
|
+
|
|
137
|
+
const axeResults = await page.evaluate(() => {
|
|
138
|
+
return window.axe.run();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const reportPath = path.join(outputDir, "report.json");
|
|
142
|
+
await fs.writeFile(reportPath, JSON.stringify(axeResults, null, 2));
|
|
143
|
+
|
|
144
|
+
const criticalViolations = axeResults.violations.filter(v => v.impact === "critical");
|
|
145
|
+
const seriousViolations = axeResults.violations.filter(v => v.impact === "serious");
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
passed: criticalViolations.length === 0,
|
|
149
|
+
reportPath,
|
|
150
|
+
violations: axeResults.violations,
|
|
151
|
+
critical: criticalViolations,
|
|
152
|
+
serious: seriousViolations
|
|
153
|
+
};
|
|
154
|
+
} finally {
|
|
155
|
+
await browser.close();
|
|
156
|
+
await this.closeServer();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|