@williambeto/ai-workflow 2.3.5 → 2.3.7
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/dist-assets/docs/full-documentation.md +1 -1
- package/dist-assets/prompts/03-create-tech-plan.md +2 -2
- package/package.json +1 -1
- package/src/commands/collect-evidence.js +2 -2
- package/src/commands/execute.js +11 -2
- 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 +278 -3
- package/src/core/validation/delivery-decision-engine.js +283 -8
- package/src/core/validation/evidence-collector.js +88 -3
package/AGENTS.md
CHANGED
|
@@ -25,3 +25,8 @@ Atlas routes. Nexus owns discovery, requirements, and spec authoring. Orion owns
|
|
|
25
25
|
## Evidence
|
|
26
26
|
|
|
27
27
|
Quick and standard work require only branch, changed areas, commands/results, and limitations in the handoff. Full/release/audit/security work may persist machine-generated evidence.
|
|
28
|
+
|
|
29
|
+
## Core Concept
|
|
30
|
+
|
|
31
|
+
AI Workflow is an npm CLI that installs agents, commands, skills, policies and templates into your repository.
|
|
32
|
+
It guides coding agents from request to implementation, validation and evidence. AI Workflow Kit preserves workflow integrity so coding agents cannot skip from request to code to success without an explicit implementation path, proportional validation, and final evidence.
|
|
@@ -49,7 +49,7 @@ In an initialized consumer, use:
|
|
|
49
49
|
- `.ai-workflow/opencode/agents/*.md`
|
|
50
50
|
- `.ai-workflow/opencode/commands/*.md`
|
|
51
51
|
- `.ai-workflow/opencode/skills/*/SKILL.md`
|
|
52
|
-
- `.ai-workflow/docs/policies/*.md`
|
|
52
|
+
- `.ai-workflow/opencode/docs/policies/*.md`
|
|
53
53
|
- `.ai-workflow/QUICKSTART.md`
|
|
54
54
|
|
|
55
55
|
Repository maintainers edit the packaged sources under `dist-assets/**`.
|
|
@@ -94,8 +94,8 @@ Follow these rules:
|
|
|
94
94
|
2. Preserve the agreed scope.
|
|
95
95
|
3. Inspect or reason from the current project structure.
|
|
96
96
|
4. Follow existing project patterns first.
|
|
97
|
-
5. Apply `docs/architecture-policy.md` before proposing structure (classify as `simple-site`, `feature-app`, `domain-app`, `package-tooling`, or `wordpress`).
|
|
98
|
-
6. Apply `docs/design-patterns-policy.md` before proposing formal design patterns.
|
|
97
|
+
5. Apply `opencode/docs/architecture-policy.md` before proposing structure (classify as `simple-site`, `feature-app`, `domain-app`, `package-tooling`, or `wordpress`).
|
|
98
|
+
6. Apply `opencode/docs/design-patterns-policy.md` before proposing formal design patterns.
|
|
99
99
|
7. Do not invent new architecture unless justified.
|
|
100
100
|
8. Identify files likely to change.
|
|
101
101
|
9. Identify files that should not change.
|
package/package.json
CHANGED
|
@@ -5,13 +5,13 @@ import { buildDeliverySummary, formatDeliverySummary } from "../core/validation/
|
|
|
5
5
|
import fs from "node:fs/promises";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
|
|
8
|
-
export async function runCollectEvidence({ cwd, exitOnError = true, taskSlug = null, mode = null, dryRun = false, profile = "generic", branchRecovery = "NOT_RECORDED" }) {
|
|
8
|
+
export async function runCollectEvidence({ cwd, exitOnError = true, taskSlug = null, mode = null, dryRun = false, profile = "generic", branchRecovery = "NOT_RECORDED", userRequest = null, explicitApprovals = [] }) {
|
|
9
9
|
const qualityGuard = new QualityGuard({ cwd, taskSlug, mode });
|
|
10
10
|
const planner = new ValidationPlanner({ cwd, qualityGuard });
|
|
11
11
|
const tasks = await planner.plan(profile);
|
|
12
12
|
|
|
13
13
|
const persist = !dryRun && mode === "full";
|
|
14
|
-
const collector = new EvidenceCollector({ cwd, timeout: 60000, taskSlug, mode, profile, branchRecovery });
|
|
14
|
+
const collector = new EvidenceCollector({ cwd, timeout: 60000, taskSlug, mode, profile, branchRecovery, userRequest, explicitApprovals });
|
|
15
15
|
const evidence = await collector.collect(tasks, { writeArtifact: persist });
|
|
16
16
|
const summary = buildDeliverySummary({ evidence });
|
|
17
17
|
|
package/src/commands/execute.js
CHANGED
|
@@ -257,7 +257,9 @@ export async function runExecute({ cwd, naturalRequest, taskSlug: taskSlugOverri
|
|
|
257
257
|
taskSlug,
|
|
258
258
|
mode: plan.mode,
|
|
259
259
|
profile: plan.profile,
|
|
260
|
-
branchRecovery: gateResult.recovered ? `${gateResult.branchBefore} -> ${gateResult.branch}` : "NOT_REQUIRED"
|
|
260
|
+
branchRecovery: gateResult.recovered ? `${gateResult.branchBefore} -> ${gateResult.branch}` : "NOT_REQUIRED",
|
|
261
|
+
userRequest: naturalRequest,
|
|
262
|
+
explicitApprovals: process.env.AI_WORKFLOW_APPROVALS ? process.env.AI_WORKFLOW_APPROVALS.split(",").map(a => a.trim()) : []
|
|
261
263
|
});
|
|
262
264
|
const quality = await new QualityGuard({ cwd, taskSlug, mode: plan.mode }).verify();
|
|
263
265
|
return combineValidation(evidence, quality);
|
|
@@ -329,7 +331,14 @@ export async function runExecute({ cwd, naturalRequest, taskSlug: taskSlugOverri
|
|
|
329
331
|
finalStatus = "FAIL_QUALITY_GATE";
|
|
330
332
|
} else {
|
|
331
333
|
// Workspace stabilized, now run Artifact Fidelity Gate
|
|
332
|
-
const fidelityResult = await finalizer.verifyFidelity(
|
|
334
|
+
const fidelityResult = await finalizer.verifyFidelity({
|
|
335
|
+
userRequest: naturalRequest,
|
|
336
|
+
projectContext: result.evidence?.deliveryDecision?.projectContext || null,
|
|
337
|
+
deliveryDecision: result.evidence?.deliveryDecision || null,
|
|
338
|
+
changedFiles: result.evidence?.changedFiles || [],
|
|
339
|
+
evidence: result.evidence || null,
|
|
340
|
+
explicitApprovals: process.env.AI_WORKFLOW_APPROVALS ? process.env.AI_WORKFLOW_APPROVALS.split(",").map(a => a.trim()) : []
|
|
341
|
+
});
|
|
333
342
|
if (!fidelityResult.passed) {
|
|
334
343
|
console.log(`\n[FINALIZER BLOCKED] BLOCKED: Artifact fidelity check failed!`);
|
|
335
344
|
console.log(`Reason: ${fidelityResult.reason}`);
|
|
@@ -50,4 +50,49 @@ export class EvidenceLedger {
|
|
|
50
50
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
51
51
|
await fs.writeFile(targetPath, JSON.stringify(this.events, null, 2), "utf8");
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
async verifyRoleConfinement() {
|
|
55
|
+
const violations = [];
|
|
56
|
+
const events = this.events;
|
|
57
|
+
|
|
58
|
+
const atlasImplements = events.some(e => e.actor === "Atlas" && (e.eventType === "implementation_start" || e.eventType === "implementation_complete"));
|
|
59
|
+
if (atlasImplements) {
|
|
60
|
+
violations.push("Atlas cannot implement directly");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const nexusMutates = events.some(e => e.actor === "Nexus" && e.eventType === "file_mutate");
|
|
64
|
+
if (nexusMutates) {
|
|
65
|
+
violations.push("Nexus cannot mutate files during discovery");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sageValidates = events.some(e => e.actor === "Sage" && (e.eventType === "validation_start" || e.eventType === "validation_complete"));
|
|
69
|
+
const hasFidelity = events.some(e => e.eventType === "fidelity_check" || e.eventType === "fidelity_verification");
|
|
70
|
+
if (sageValidates && !hasFidelity) {
|
|
71
|
+
violations.push("Sage cannot validate without fidelity evidence");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const phoenixRemediates = events.find(e => e.actor === "Phoenix" && e.eventType === "remediation_start");
|
|
75
|
+
if (phoenixRemediates) {
|
|
76
|
+
const findings = phoenixRemediates.data?.findings;
|
|
77
|
+
if (!findings || findings.length === 0) {
|
|
78
|
+
violations.push("Phoenix cannot perform remediation without findings");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const orionReleases = events.find(e => e.actor === "Orion" && e.eventType === "release_start");
|
|
83
|
+
if (orionReleases) {
|
|
84
|
+
const approvals = orionReleases.data?.approvals;
|
|
85
|
+
if (!approvals || approvals.length === 0) {
|
|
86
|
+
violations.push("Orion cannot release without explicit approval");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const passed = violations.length === 0;
|
|
91
|
+
return {
|
|
92
|
+
passed,
|
|
93
|
+
status: passed ? "PASS" : "BLOCKED",
|
|
94
|
+
reason: violations.length > 0 ? `Role violations: ${violations.join("; ")}` : "Verification passed",
|
|
95
|
+
violations
|
|
96
|
+
};
|
|
97
|
+
}
|
|
53
98
|
}
|
|
@@ -32,16 +32,65 @@ export class Finalizer {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
async verifyFidelity(
|
|
35
|
+
async verifyFidelity(arg) {
|
|
36
|
+
let userRequest = null;
|
|
37
|
+
let projectContext = null;
|
|
38
|
+
let deliveryDecision = null;
|
|
39
|
+
let changedFiles = null;
|
|
40
|
+
let evidence = null;
|
|
41
|
+
let explicitApprovals = [];
|
|
42
|
+
|
|
43
|
+
if (Array.isArray(arg)) {
|
|
44
|
+
changedFiles = arg;
|
|
45
|
+
} else if (arg && typeof arg === "object") {
|
|
46
|
+
userRequest = arg.userRequest;
|
|
47
|
+
projectContext = arg.projectContext;
|
|
48
|
+
deliveryDecision = arg.deliveryDecision;
|
|
49
|
+
changedFiles = arg.changedFiles;
|
|
50
|
+
evidence = arg.evidence;
|
|
51
|
+
explicitApprovals = arg.explicitApprovals || [];
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
const filesToCheck = changedFiles || this.getChangedFilesFromGit();
|
|
37
55
|
const decisionEngine = new DeliveryDecisionEngine({ cwd: this.cwd });
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
56
|
+
const actualProjectContext = projectContext || await decisionEngine.detectContext();
|
|
57
|
+
const actualUserRequest = userRequest || "";
|
|
58
|
+
|
|
59
|
+
const actualDeliveryDecision = deliveryDecision || await decisionEngine.determineDecision({
|
|
60
|
+
userRequest: actualUserRequest,
|
|
61
|
+
projectContext: actualProjectContext,
|
|
62
|
+
explicitApprovals
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const actualDeliveryClass = await decisionEngine.determineClass(filesToCheck);
|
|
66
|
+
|
|
67
|
+
const fidelityGate = new ArtifactFidelityGate({
|
|
68
|
+
cwd: this.cwd,
|
|
69
|
+
deliveryClass: actualDeliveryClass,
|
|
70
|
+
userRequest: actualUserRequest,
|
|
71
|
+
explicitApprovals
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const requestFidelity = await fidelityGate.verifyRequestFidelity({
|
|
75
|
+
userRequest: actualUserRequest,
|
|
76
|
+
deliveryDecision: actualDeliveryDecision,
|
|
77
|
+
projectContext: actualProjectContext,
|
|
78
|
+
changedFiles: filesToCheck,
|
|
79
|
+
evidence,
|
|
80
|
+
explicitApprovals
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const verifyResult = await fidelityGate.verify(filesToCheck);
|
|
84
|
+
|
|
85
|
+
const passed = requestFidelity.passed && verifyResult.passed;
|
|
86
|
+
const reason = !requestFidelity.passed ? requestFidelity.reason : verifyResult.reason;
|
|
87
|
+
|
|
41
88
|
return {
|
|
42
|
-
passed
|
|
43
|
-
|
|
44
|
-
|
|
89
|
+
passed,
|
|
90
|
+
status: requestFidelity.status || (passed ? "PASS" : "BLOCKED"),
|
|
91
|
+
reason,
|
|
92
|
+
deliveryClass: actualDeliveryClass,
|
|
93
|
+
requestFidelity
|
|
45
94
|
};
|
|
46
95
|
}
|
|
47
96
|
|
|
@@ -2,9 +2,11 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
export class ArtifactFidelityGate {
|
|
5
|
-
constructor({ cwd = process.cwd(), deliveryClass } = {}) {
|
|
5
|
+
constructor({ cwd = process.cwd(), deliveryClass, userRequest, explicitApprovals } = {}) {
|
|
6
6
|
this.cwd = cwd;
|
|
7
7
|
this.deliveryClass = deliveryClass;
|
|
8
|
+
this.userRequest = userRequest;
|
|
9
|
+
this.explicitApprovals = explicitApprovals;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
async verify(changedFiles = []) {
|
|
@@ -16,7 +18,6 @@ export class ArtifactFidelityGate {
|
|
|
16
18
|
for (const file of changedFiles) {
|
|
17
19
|
const ext = path.extname(file).toLowerCase();
|
|
18
20
|
|
|
19
|
-
// 1. Forbid adding/modifying HTML files that are not SPA entry points
|
|
20
21
|
if (ext === ".html" || ext === ".htm") {
|
|
21
22
|
const basename = path.basename(file).toLowerCase();
|
|
22
23
|
const isSpaEntry = basename === "index.html" &&
|
|
@@ -28,7 +29,6 @@ export class ArtifactFidelityGate {
|
|
|
28
29
|
};
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
// Check if SPA entry imports framework libraries via CDN script tags
|
|
32
32
|
try {
|
|
33
33
|
const content = await fs.readFile(path.join(this.cwd, file), "utf8");
|
|
34
34
|
if (content.includes("unpkg.com") || content.includes("cdnjs.cloudflare.com") || content.includes("cdn.jsdelivr.net")) {
|
|
@@ -60,4 +60,279 @@ export class ArtifactFidelityGate {
|
|
|
60
60
|
|
|
61
61
|
return { passed: true };
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
async verifyRequestFidelity(arg) {
|
|
65
|
+
let userRequest = this.userRequest || "";
|
|
66
|
+
let deliveryDecision = this.deliveryDecision || {};
|
|
67
|
+
let projectContext = this.projectContext || {};
|
|
68
|
+
let changedFiles = [];
|
|
69
|
+
let explicitApprovals = this.explicitApprovals || [];
|
|
70
|
+
|
|
71
|
+
if (Array.isArray(arg)) {
|
|
72
|
+
changedFiles = arg;
|
|
73
|
+
} else if (arg && typeof arg === "object") {
|
|
74
|
+
userRequest = arg.userRequest || userRequest;
|
|
75
|
+
deliveryDecision = arg.deliveryDecision || deliveryDecision;
|
|
76
|
+
projectContext = arg.projectContext || projectContext;
|
|
77
|
+
changedFiles = arg.changedFiles || changedFiles;
|
|
78
|
+
explicitApprovals = arg.explicitApprovals || explicitApprovals;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const blockingDecisions = new Set([
|
|
82
|
+
"BLOCK_STACK_CONFLICT",
|
|
83
|
+
"BLOCK_UNCLEAR_SCOPE",
|
|
84
|
+
"BLOCK_UNSUPPORTED_CONTEXT",
|
|
85
|
+
"REQUIRE_EXPLICIT_AUTHORIZATION",
|
|
86
|
+
"RELEASE_OR_DEPLOY_REQUIRES_APPROVAL"
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
if (blockingDecisions.has(deliveryDecision?.decision)) {
|
|
90
|
+
const filtered = changedFiles.filter(file =>
|
|
91
|
+
!file.startsWith(".ai-workflow/") &&
|
|
92
|
+
!file.startsWith(".git/") &&
|
|
93
|
+
file !== "EVIDENCE.json" &&
|
|
94
|
+
file !== "EVIDENCE.temp.json"
|
|
95
|
+
);
|
|
96
|
+
return {
|
|
97
|
+
status: "BLOCKED",
|
|
98
|
+
passed: false,
|
|
99
|
+
reason: `Delivery decision is blocking: ${deliveryDecision.decision}`,
|
|
100
|
+
decision: deliveryDecision.decision,
|
|
101
|
+
checkedFiles: filtered,
|
|
102
|
+
violations: [`Blocking delivery decision: ${deliveryDecision.decision}`]
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const filteredFiles = changedFiles.filter(file =>
|
|
107
|
+
!file.startsWith(".ai-workflow/") &&
|
|
108
|
+
!file.startsWith(".git/") &&
|
|
109
|
+
file !== "EVIDENCE.json" &&
|
|
110
|
+
file !== "EVIDENCE.temp.json"
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const req = userRequest.toLowerCase();
|
|
114
|
+
const isReadOnly = /(inspect|audit|read-only|dependencies)/.test(req);
|
|
115
|
+
const isDocsOnly = /(document|doc|readme)/.test(req) && !req.includes("code");
|
|
116
|
+
const isRelease = /(release|publish|deploy)/.test(req);
|
|
117
|
+
|
|
118
|
+
const violations = [];
|
|
119
|
+
|
|
120
|
+
if (isReadOnly && filteredFiles.length > 0) {
|
|
121
|
+
violations.push("Read-only audit task mutated repository files mismatch.");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (isDocsOnly) {
|
|
125
|
+
const codeChanged = filteredFiles.some(file =>
|
|
126
|
+
!file.endsWith(".md") && !file.endsWith(".txt") && !file.startsWith("docs/")
|
|
127
|
+
);
|
|
128
|
+
if (codeChanged) {
|
|
129
|
+
violations.push("Documentation-only task mutated codebase files without justification mismatch.");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (isRelease) {
|
|
134
|
+
const approved = explicitApprovals.includes("npm-publish") || explicitApprovals.includes("deploy-production");
|
|
135
|
+
if (!approved) {
|
|
136
|
+
violations.push("Release/deploy task proceeds without explicit approval token mismatch.");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const reqReact = /(react|next)/.test(req);
|
|
141
|
+
const reqVue = /(vue|nuxt)/.test(req);
|
|
142
|
+
const reqBackend = /(laravel|django|rails|express|nest|fastify|flask)/.test(req);
|
|
143
|
+
const reqWp = /(wordpress|wp)/.test(req);
|
|
144
|
+
const reqCli = /(cli|command)/.test(req);
|
|
145
|
+
|
|
146
|
+
if (reqReact && this.deliveryClass === "standalone-web" && !/prototype/.test(req)) {
|
|
147
|
+
violations.push("React framework page requested, but a standalone HTML/CSS substitute mismatch was delivered instead.");
|
|
148
|
+
}
|
|
149
|
+
if (reqVue && this.deliveryClass === "standalone-web" && !/prototype/.test(req)) {
|
|
150
|
+
violations.push("Vue framework page requested, but a standalone HTML/CSS substitute mismatch was delivered instead.");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (reqBackend && filteredFiles.length > 0) {
|
|
154
|
+
const isLaravel = /\blaravel\b/i.test(req);
|
|
155
|
+
const isDjango = /\bdjango\b/i.test(req);
|
|
156
|
+
const isExpressNestFastify = /\b(express|nest|fastify)\b/i.test(req);
|
|
157
|
+
const isRails = /\brails\b/i.test(req);
|
|
158
|
+
|
|
159
|
+
if (isLaravel) {
|
|
160
|
+
const isLaravelArtifact = filteredFiles.some(file =>
|
|
161
|
+
file === "composer.json" ||
|
|
162
|
+
file === "artisan" ||
|
|
163
|
+
file.startsWith("routes/") ||
|
|
164
|
+
file.startsWith("app/Http/Controllers/") ||
|
|
165
|
+
file.startsWith("app/Models/") ||
|
|
166
|
+
file.startsWith("app/Services/") ||
|
|
167
|
+
file.startsWith("tests/Feature/") ||
|
|
168
|
+
file.startsWith("tests/Unit/") ||
|
|
169
|
+
/(^|\/)(routes\/.*\.php|app\/Http\/Controllers\/.*\.php|app\/Models\/.*\.php|app\/Services\/.*\.php|tests\/Feature\/.*\.php|tests\/Unit\/.*\.php|artisan|composer\.json)$/.test(file)
|
|
170
|
+
);
|
|
171
|
+
if (!isLaravelArtifact) {
|
|
172
|
+
violations.push("Laravel endpoint/scaffold requested, but no valid Laravel files/structure mismatch was delivered.");
|
|
173
|
+
}
|
|
174
|
+
} else if (isDjango) {
|
|
175
|
+
const isDjangoArtifact = filteredFiles.some(file =>
|
|
176
|
+
file === "manage.py" ||
|
|
177
|
+
file === "requirements.txt" ||
|
|
178
|
+
file === "pyproject.toml" ||
|
|
179
|
+
file.endsWith("settings.py") ||
|
|
180
|
+
file.endsWith("urls.py") ||
|
|
181
|
+
file.endsWith("views.py") ||
|
|
182
|
+
file.endsWith("models.py") ||
|
|
183
|
+
file.endsWith("tests.py") ||
|
|
184
|
+
/(^|\/)(manage\.py|settings\.py|urls\.py|views\.py|models\.py|tests\.py|requirements\.txt|pyproject\.toml)$/.test(file)
|
|
185
|
+
);
|
|
186
|
+
if (!isDjangoArtifact) {
|
|
187
|
+
violations.push("Django backend requested, but no valid Django files/structure mismatch was delivered.");
|
|
188
|
+
}
|
|
189
|
+
} else if (isExpressNestFastify) {
|
|
190
|
+
const isExpressNestFastifyArtifact = filteredFiles.some(file =>
|
|
191
|
+
file === "package.json" ||
|
|
192
|
+
file.startsWith("src/routes/") ||
|
|
193
|
+
file.startsWith("src/controllers/") ||
|
|
194
|
+
file.startsWith("src/services/") ||
|
|
195
|
+
file.startsWith("tests/") ||
|
|
196
|
+
/(^|\/)(package\.json|src\/routes\/.*|src\/controllers\/.*|src\/services\/.*|tests\/.*|server\.[a-z]+|app\.[a-z]+|main\.[a-z]+)$/.test(file)
|
|
197
|
+
);
|
|
198
|
+
if (!isExpressNestFastifyArtifact) {
|
|
199
|
+
violations.push("Express/Fastify/Nest backend requested, but no valid Node.js server files/structure mismatch was delivered.");
|
|
200
|
+
}
|
|
201
|
+
} else if (isRails) {
|
|
202
|
+
const isRailsArtifact = filteredFiles.some(file =>
|
|
203
|
+
file === "Gemfile" ||
|
|
204
|
+
file === "config/routes.rb" ||
|
|
205
|
+
file.startsWith("app/controllers/") ||
|
|
206
|
+
file.startsWith("app/models/") ||
|
|
207
|
+
file.startsWith("app/services/") ||
|
|
208
|
+
file.startsWith("test/") ||
|
|
209
|
+
file.startsWith("spec/") ||
|
|
210
|
+
/(^|\/)(Gemfile|config\/routes\.rb|app\/controllers\/.*|app\/models\/.*|app\/services\/.*|test\/.*|spec\/.*)$/.test(file)
|
|
211
|
+
);
|
|
212
|
+
if (!isRailsArtifact) {
|
|
213
|
+
violations.push("Ruby on Rails backend requested, but no valid Rails files/structure mismatch was delivered.");
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
const isGenericBackendArtifact = filteredFiles.some(file =>
|
|
217
|
+
!file.includes("plain_script.py") &&
|
|
218
|
+
(
|
|
219
|
+
file.startsWith("app/") ||
|
|
220
|
+
file.startsWith("routes/") ||
|
|
221
|
+
file.startsWith("config/") ||
|
|
222
|
+
file.startsWith("src/controllers/") ||
|
|
223
|
+
file.startsWith("src/routes/") ||
|
|
224
|
+
file.startsWith("src/services/") ||
|
|
225
|
+
file.startsWith("backend/") ||
|
|
226
|
+
file.startsWith("server/") ||
|
|
227
|
+
file.startsWith("api/") ||
|
|
228
|
+
file.endsWith(".py") ||
|
|
229
|
+
file.endsWith(".php") ||
|
|
230
|
+
file.endsWith(".rb") ||
|
|
231
|
+
file.endsWith(".go")
|
|
232
|
+
)
|
|
233
|
+
);
|
|
234
|
+
if (!isGenericBackendArtifact) {
|
|
235
|
+
violations.push("Backend endpoint requested, but wrong backend artifact structure mismatch was delivered.");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (reqCli && filteredFiles.length > 0) {
|
|
241
|
+
const isCliArtifact = filteredFiles.some(file =>
|
|
242
|
+
file.startsWith("bin/") || file.startsWith("src/commands/")
|
|
243
|
+
);
|
|
244
|
+
if (!isCliArtifact) {
|
|
245
|
+
violations.push("CLI command requested, but a loose script outside the executable bin/commands configuration mismatch was delivered.");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (reqWp && filteredFiles.length > 0) {
|
|
250
|
+
let isWpArtifact = false;
|
|
251
|
+
for (const file of filteredFiles) {
|
|
252
|
+
const basename = path.basename(file);
|
|
253
|
+
const ext = path.extname(file).toLowerCase();
|
|
254
|
+
|
|
255
|
+
if (file.startsWith("wp-content/") || file === "wp-config.php") {
|
|
256
|
+
isWpArtifact = true;
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (basename === "functions.php" ||
|
|
261
|
+
basename === "front-page.php" ||
|
|
262
|
+
basename === "single.php" ||
|
|
263
|
+
basename === "page.php" ||
|
|
264
|
+
basename === "archive.php" ||
|
|
265
|
+
basename === "theme.json" ||
|
|
266
|
+
file.startsWith("template-parts/") ||
|
|
267
|
+
file.includes("/template-parts/")) {
|
|
268
|
+
isWpArtifact = true;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
if (basename === "style.css") {
|
|
272
|
+
const hasHeader = await hasWpThemeHeader(this.cwd, file);
|
|
273
|
+
if (hasHeader) {
|
|
274
|
+
isWpArtifact = true;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (basename === "index.php") {
|
|
279
|
+
isWpArtifact = true;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (basename === "uninstall.php" ||
|
|
284
|
+
basename === "composer.json" ||
|
|
285
|
+
file.startsWith("includes/") || file.includes("/includes/") ||
|
|
286
|
+
file.startsWith("admin/") || file.includes("/admin/") ||
|
|
287
|
+
file.startsWith("public/") || file.includes("/public/") ||
|
|
288
|
+
file.startsWith("src/") || file.includes("/src/")) {
|
|
289
|
+
isWpArtifact = true;
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
if (ext === ".php") {
|
|
293
|
+
const hasHeader = await hasWpPluginHeader(this.cwd, file);
|
|
294
|
+
if (hasHeader) {
|
|
295
|
+
isWpArtifact = true;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const hasHtml = filteredFiles.some(file => path.extname(file).toLowerCase() === ".html" || path.extname(file).toLowerCase() === ".htm");
|
|
302
|
+
|
|
303
|
+
if (!isWpArtifact || hasHtml) {
|
|
304
|
+
violations.push("WordPress plugin or theme requested, but files were delivered outside the WordPress structure or contained standalone HTML.");
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const status = violations.length > 0 ? "BLOCKED" : "PASS";
|
|
309
|
+
return {
|
|
310
|
+
status,
|
|
311
|
+
passed: status === "PASS",
|
|
312
|
+
reason: violations.length > 0 ? `Fidelity violations: ${violations.join("; ")}` : "Verification passed",
|
|
313
|
+
decision: deliveryDecision.decision || this.deliveryClass,
|
|
314
|
+
checkedFiles: filteredFiles,
|
|
315
|
+
violations
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function hasWpThemeHeader(cwd, file) {
|
|
321
|
+
try {
|
|
322
|
+
const fullPath = path.join(cwd, file);
|
|
323
|
+
const content = await fs.readFile(fullPath, "utf8");
|
|
324
|
+
return /\bTheme Name\s*:/i.test(content) || /\bTheme URI\s*:/i.test(content);
|
|
325
|
+
} catch {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function hasWpPluginHeader(cwd, file) {
|
|
331
|
+
try {
|
|
332
|
+
const fullPath = path.join(cwd, file);
|
|
333
|
+
const content = await fs.readFile(fullPath, "utf8");
|
|
334
|
+
return /\bPlugin Name\s*:/i.test(content) || /\bDescription\s*:/i.test(content);
|
|
335
|
+
} catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
63
338
|
}
|
|
@@ -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,8 @@ 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";
|
|
5
7
|
|
|
6
8
|
const VALIDATION_KINDS = new Set(["test", "build", "typecheck", "lint", "security", "smoke", "validate", "other"]);
|
|
7
9
|
|
|
@@ -27,7 +29,7 @@ function inferTaskKind(task = {}) {
|
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export class EvidenceCollector {
|
|
30
|
-
constructor({ cwd, maxLogLength = 2000, timeout = 60000, taskSlug = null, mode = null, profile = "generic", branchRecovery = "NOT_RECORDED" } = {}) {
|
|
32
|
+
constructor({ cwd, maxLogLength = 2000, timeout = 60000, taskSlug = null, mode = null, profile = "generic", branchRecovery = "NOT_RECORDED", userRequest = null, explicitApprovals = [] } = {}) {
|
|
31
33
|
this.cwd = cwd;
|
|
32
34
|
this.maxLogLength = maxLogLength;
|
|
33
35
|
this.timeout = timeout;
|
|
@@ -35,6 +37,53 @@ export class EvidenceCollector {
|
|
|
35
37
|
this.mode = mode;
|
|
36
38
|
this.profile = profile;
|
|
37
39
|
this.branchRecovery = branchRecovery;
|
|
40
|
+
this.userRequest = userRequest;
|
|
41
|
+
this.explicitApprovals = explicitApprovals;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async findUserRequest() {
|
|
45
|
+
if (this.userRequest) return this.userRequest;
|
|
46
|
+
|
|
47
|
+
if (this.taskSlug) {
|
|
48
|
+
const ledgerPath = path.join(this.cwd, `.ai-workflow/history/${this.taskSlug}-ledger.json`);
|
|
49
|
+
try {
|
|
50
|
+
const content = await fs.readFile(ledgerPath, "utf8");
|
|
51
|
+
const events = JSON.parse(content);
|
|
52
|
+
const routeEvent = events.find(e => e.eventType === "routing");
|
|
53
|
+
if (routeEvent && routeEvent.data?.request) {
|
|
54
|
+
return routeEvent.data.request;
|
|
55
|
+
}
|
|
56
|
+
const startEvent = events.find(e => e.eventType === "implementation_start");
|
|
57
|
+
if (startEvent && startEvent.data?.prompt) {
|
|
58
|
+
return startEvent.data.prompt;
|
|
59
|
+
}
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const dir = path.join(this.cwd, ".ai-workflow/history");
|
|
65
|
+
const files = await fs.readdir(dir);
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
if (file.endsWith("-ledger.json")) {
|
|
68
|
+
const content = await fs.readFile(path.join(dir, file), "utf8");
|
|
69
|
+
const events = JSON.parse(content);
|
|
70
|
+
const routeEvent = events.find(e => e.eventType === "routing");
|
|
71
|
+
if (routeEvent && routeEvent.data?.request) {
|
|
72
|
+
return routeEvent.data.request;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const { execSync } = await import("node:child_process");
|
|
80
|
+
const branch = execSync("git branch --show-current", { cwd: this.cwd, encoding: "utf8" }).trim();
|
|
81
|
+
if (branch && branch !== "main" && branch !== "master" && branch.startsWith("feat/")) {
|
|
82
|
+
return branch.replace("feat/", "").replaceAll("-", " ");
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
|
|
86
|
+
return "";
|
|
38
87
|
}
|
|
39
88
|
|
|
40
89
|
runTask(task) {
|
|
@@ -95,6 +144,30 @@ export class EvidenceCollector {
|
|
|
95
144
|
const behaviorTests = results.filter((result) => result.kind === "test");
|
|
96
145
|
const passingBehaviorTest = behaviorTests.some((result) => result.status === "PASS");
|
|
97
146
|
|
|
147
|
+
const userRequest = await this.findUserRequest();
|
|
148
|
+
const engine = new DeliveryDecisionEngine({ cwd: this.cwd });
|
|
149
|
+
const projectContext = await engine.detectContext();
|
|
150
|
+
const deliveryDecision = await engine.determineDecision({
|
|
151
|
+
userRequest,
|
|
152
|
+
projectContext,
|
|
153
|
+
explicitApprovals: this.explicitApprovals || []
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const fidelityGate = new ArtifactFidelityGate({
|
|
157
|
+
cwd: this.cwd,
|
|
158
|
+
deliveryClass: deliveryDecision.allowedArtifactShape === "runnable-framework-app" ? "framework-native-frontend" : (deliveryDecision.allowedArtifactShape === "wordpress" ? "wordpress-php" : "generic-code"),
|
|
159
|
+
userRequest,
|
|
160
|
+
explicitApprovals: this.explicitApprovals || []
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const changedFiles = policyValidation.git?.changedFiles || [];
|
|
164
|
+
const artifactFidelityCheck = await fidelityGate.verifyRequestFidelity({
|
|
165
|
+
userRequest,
|
|
166
|
+
deliveryDecision,
|
|
167
|
+
projectContext,
|
|
168
|
+
changedFiles
|
|
169
|
+
});
|
|
170
|
+
|
|
98
171
|
let overallStatus = "PASS";
|
|
99
172
|
if (implementation && executableBehavior && tasks.length === 0) overallStatus = "BLOCKED";
|
|
100
173
|
else if (results.some((result) => ["FAIL", "BLOCKED", "FAIL_QUALITY_GATE"].includes(result.status))) overallStatus = "FAIL_QUALITY_GATE";
|
|
@@ -102,6 +175,16 @@ export class EvidenceCollector {
|
|
|
102
175
|
else if (["FAIL", "BLOCKED", "FAIL_QUALITY_GATE"].includes(policyValidation.overallStatus)) overallStatus = "FAIL_QUALITY_GATE";
|
|
103
176
|
else if (results.some((result) => result.status === "PASS_WITH_NOTES") || policyValidation.overallStatus === "PASS_WITH_NOTES") overallStatus = "PASS_WITH_NOTES";
|
|
104
177
|
|
|
178
|
+
if (!artifactFidelityCheck || !artifactFidelityCheck.passed) {
|
|
179
|
+
overallStatus = "BLOCKED";
|
|
180
|
+
} else if (implementation) {
|
|
181
|
+
if (!deliveryDecision || !deliveryDecision.decision || deliveryDecision.decision.startsWith("BLOCK") || deliveryDecision.decision.includes("BLOCKED") || deliveryDecision.decision === "REQUIRE_EXPLICIT_AUTHORIZATION" || deliveryDecision.decision === "RELEASE_OR_DEPLOY_REQUIRES_APPROVAL") {
|
|
182
|
+
overallStatus = "BLOCKED";
|
|
183
|
+
} else if (results.length === 0 && executableBehavior && deliveryDecision.decision !== "DOCUMENTATION_ONLY" && deliveryDecision.decision !== "DOCS_ONLY" && deliveryDecision.decision !== "READ_ONLY_AUDIT") {
|
|
184
|
+
overallStatus = "BLOCKED";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
105
188
|
const limitations = [];
|
|
106
189
|
if (implementation && executableBehavior && tasks.length === 0) limitations.push("No meaningful validation command was available for executable implementation work.");
|
|
107
190
|
if (executableBehavior && behaviorTests.length === 0) {
|
|
@@ -118,12 +201,14 @@ export class EvidenceCollector {
|
|
|
118
201
|
workflowProfile: this.profile,
|
|
119
202
|
branchRecovery: this.branchRecovery,
|
|
120
203
|
branch: policyValidation.git?.branch || "unknown",
|
|
121
|
-
changedFiles
|
|
204
|
+
changedFiles,
|
|
122
205
|
status: publicStatus(overallStatus),
|
|
123
206
|
internalStatus: overallStatus,
|
|
124
207
|
commands: results,
|
|
125
208
|
checks: policyValidation.checks,
|
|
126
|
-
limitations
|
|
209
|
+
limitations,
|
|
210
|
+
deliveryDecision,
|
|
211
|
+
artifactFidelityCheck
|
|
127
212
|
};
|
|
128
213
|
|
|
129
214
|
if (writeArtifact) {
|