@multileaf/ai-workflow 0.1.5 → 0.2.1
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/CHANGELOG.md +20 -0
- package/README.md +52 -24
- package/dist/adapter.js +21 -9
- package/dist/human-approval.d.ts +51 -0
- package/dist/human-approval.js +286 -0
- package/dist/quality-gates.d.ts +1 -1
- package/dist/quality-gates.js +59 -7
- package/dist/types.d.ts +2 -0
- package/dist/types.js +10 -0
- package/dist/workflow.js +347 -47
- package/docs/ai-init-workflow-spec.md +10 -5
- package/docs/architecture.md +8 -0
- package/package.json +1 -1
- package/resources/package.yaml +2 -0
- package/resources/skills/brainstorming.md +3 -1
- package/resources/skills/code-review.md +3 -1
- package/resources/skills/implementation-planning.md +3 -1
- package/resources/skills/project-init.md +2 -0
- package/resources/skills/requirements-specification.md +3 -1
- package/resources/skills/tdd-development.md +3 -1
- package/resources/skills/technical-design.md +3 -1
- package/resources/skills/verification.md +3 -1
- package/resources/templates/code-review.md +21 -0
- package/resources/templates/implementation-evidence.md +17 -0
package/dist/quality-gates.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseDocument } from "yaml";
|
|
1
2
|
function section(content, heading) {
|
|
2
3
|
const match = content.match(new RegExp(`^## ${heading}\\s*$([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, "im"));
|
|
3
4
|
return match?.[1].trim();
|
|
@@ -29,6 +30,15 @@ export function evaluateQualityGate(stage, content) {
|
|
|
29
30
|
issues.push("Requirement acceptance criteria must use Given, When, and Then.");
|
|
30
31
|
return issues;
|
|
31
32
|
}
|
|
33
|
+
if (stage === "technical-design")
|
|
34
|
+
return requireSections(content, "Technical Design", [
|
|
35
|
+
"Context",
|
|
36
|
+
"Proposed Design",
|
|
37
|
+
"Alternatives",
|
|
38
|
+
"Interfaces",
|
|
39
|
+
"Risks",
|
|
40
|
+
"Validation",
|
|
41
|
+
]);
|
|
32
42
|
if (stage === "plan") {
|
|
33
43
|
const fields = ["Requirement", "Code", "Tests", "Validation", "Evidence"];
|
|
34
44
|
const issues = !/TASK-\d+/.test(content)
|
|
@@ -39,11 +49,53 @@ export function evaluateQualityGate(stage, content) {
|
|
|
39
49
|
issues.push(`Implementation plan task must define ${field}.`);
|
|
40
50
|
return issues;
|
|
41
51
|
}
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
if (stage === "implementation")
|
|
53
|
+
return requireSections(content, "Implementation Evidence", [
|
|
54
|
+
"Changed Files",
|
|
55
|
+
"Tests",
|
|
56
|
+
"Validation",
|
|
57
|
+
"Deviations",
|
|
58
|
+
]);
|
|
59
|
+
if (stage === "verification")
|
|
60
|
+
return requireSections(content, "Verification Report", [
|
|
61
|
+
"Requirements Checked",
|
|
62
|
+
"Checks Passed",
|
|
63
|
+
"Missing Evidence",
|
|
64
|
+
"Residual Risks",
|
|
65
|
+
"Decision",
|
|
66
|
+
]);
|
|
67
|
+
if (stage === "review")
|
|
68
|
+
return requireSections(content, "Code Review", [
|
|
69
|
+
"Scope",
|
|
70
|
+
"Findings",
|
|
71
|
+
"Checks",
|
|
72
|
+
"Residual Risks",
|
|
73
|
+
"Decision",
|
|
74
|
+
]);
|
|
75
|
+
const document = parseDocument(content, { uniqueKeys: true, strict: true });
|
|
76
|
+
if (document.errors.length)
|
|
77
|
+
return ["Traceability evidence must be valid YAML."];
|
|
78
|
+
const value = document.toJS();
|
|
79
|
+
if (!isRecord(value) || value.schema !== 1 || !Array.isArray(value.links))
|
|
80
|
+
return ["Traceability evidence must define schema: 1 and a links list."];
|
|
81
|
+
if (value.links.length === 0)
|
|
82
|
+
return ["Traceability evidence must link at least one requirement."];
|
|
83
|
+
const fields = ["requirement", "tasks", "code", "tests", "evidence"];
|
|
84
|
+
return value.links.flatMap((link, index) => {
|
|
85
|
+
if (!isRecord(link))
|
|
86
|
+
return [`Traceability link ${index + 1} must be a mapping.`];
|
|
87
|
+
return fields.flatMap((field) => {
|
|
88
|
+
const fieldValue = link[field];
|
|
89
|
+
return field === "requirement"
|
|
90
|
+
? typeof fieldValue === "string" && /^REQ-\d+$/.test(fieldValue)
|
|
91
|
+
? []
|
|
92
|
+
: [`Traceability link ${index + 1} requires a valid requirement ID.`]
|
|
93
|
+
: Array.isArray(fieldValue) && fieldValue.length > 0
|
|
94
|
+
? []
|
|
95
|
+
: [`Traceability link ${index + 1} requires non-empty ${field} evidence.`];
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function isRecord(value) {
|
|
100
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
101
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export declare const TARGETS: readonly ["codex", "claude", "cursor", "gemini", "copilot", "universal"];
|
|
2
2
|
export type Target = (typeof TARGETS)[number];
|
|
3
|
+
export declare const HUMAN_APPROVAL_STAGES: readonly ["brainstorming", "specification", "technical-design", "plan", "implementation", "verification", "review", "traceability"];
|
|
4
|
+
export type HumanApprovalStage = (typeof HUMAN_APPROVAL_STAGES)[number];
|
|
3
5
|
export type CommandResult = {
|
|
4
6
|
exitCode: number;
|
|
5
7
|
output?: string;
|
package/dist/types.js
CHANGED
|
@@ -1 +1,11 @@
|
|
|
1
1
|
export const TARGETS = ["codex", "claude", "cursor", "gemini", "copilot", "universal"];
|
|
2
|
+
export const HUMAN_APPROVAL_STAGES = [
|
|
3
|
+
"brainstorming",
|
|
4
|
+
"specification",
|
|
5
|
+
"technical-design",
|
|
6
|
+
"plan",
|
|
7
|
+
"implementation",
|
|
8
|
+
"verification",
|
|
9
|
+
"review",
|
|
10
|
+
"traceability",
|
|
11
|
+
];
|
package/dist/workflow.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { BASE_DIRECTORIES, WORKFLOW_DIRECTORY } from "./constants.js";
|
|
3
4
|
import { detectTarget, isTarget, renderAiInit, renderAiInitPath, renderResourcePath, } from "./adapter.js";
|
|
4
5
|
import { loadBundledResources } from "./bundled-resources.js";
|
|
@@ -31,6 +32,7 @@ import { composeContext, CONTEXT_LAYERS, readContextStore, writeContextLayer, }
|
|
|
31
32
|
import { loadContextDelta, retrieveTaskContext } from "./context-retrieval.js";
|
|
32
33
|
import { createSummaryCache, isSummaryFresh, parseSummaryCache, serializeSummaryCache, } from "./context-cache.js";
|
|
33
34
|
import { parseTokenUsage, recordTokenUsage, serializeTokenUsage } from "./token-usage.js";
|
|
35
|
+
import { APPROVAL_STAGES, approveStage, beginStageApproval, createApprovalLedger, getUnresolvedPrerequisites, invalidateStageAndDescendants, parseApprovalLedger, parseWorkflowSession, rejectStage, serializeApprovalLedger, serializeWorkflowSession, skipOptionalStage, stageDefinition, stageFingerprint, } from "./human-approval.js";
|
|
34
36
|
import { measureContextQuality, serializeContextQuality } from "./context-quality.js";
|
|
35
37
|
import { serializeAdapterCapabilities } from "./adapter-contract.js";
|
|
36
38
|
import { buildTraceabilityGraph, serializeTraceabilityGraph } from "./traceability.js";
|
|
@@ -46,6 +48,139 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
46
48
|
try {
|
|
47
49
|
fs = withSafeProjectWrites(fs, root);
|
|
48
50
|
const command = args[0] ?? "help";
|
|
51
|
+
if (command === "workflow") {
|
|
52
|
+
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
53
|
+
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
54
|
+
const action = args[1];
|
|
55
|
+
const sessionPath = join(aiw, "workflow.yml");
|
|
56
|
+
const ledgerPath = join(aiw, "approvals.yml");
|
|
57
|
+
if (action === "start") {
|
|
58
|
+
const options = parseNamedOptions(args.slice(2), ["id"]);
|
|
59
|
+
if (!options)
|
|
60
|
+
return { exitCode: 1, error: "Usage: aiw workflow start [--id=workflow-id]" };
|
|
61
|
+
if (fs.exists(sessionPath)) {
|
|
62
|
+
const previous = parseWorkflowSession(fs.read(sessionPath));
|
|
63
|
+
if (previous.status === "active")
|
|
64
|
+
return {
|
|
65
|
+
exitCode: 1,
|
|
66
|
+
error: `Workflow ${previous.workflowId} is still active. Complete it before starting another.`,
|
|
67
|
+
};
|
|
68
|
+
if (!fs.exists(ledgerPath))
|
|
69
|
+
return { exitCode: 1, error: "Completed workflow is missing its approval ledger." };
|
|
70
|
+
const archive = join(aiw, `checkpoints/workflow-${previous.workflowId}-approvals.yml`);
|
|
71
|
+
if (fs.exists(archive))
|
|
72
|
+
return { exitCode: 1, error: `Workflow approval archive already exists: ${archive}` };
|
|
73
|
+
fs.mkdir(join(aiw, "checkpoints"));
|
|
74
|
+
fs.write(archive, fs.read(ledgerPath));
|
|
75
|
+
}
|
|
76
|
+
else if (fs.exists(ledgerPath)) {
|
|
77
|
+
return {
|
|
78
|
+
exitCode: 1,
|
|
79
|
+
error: "Approval ledger exists without a workflow session; run doctor.",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const workflowId = options.id ?? randomUUID();
|
|
83
|
+
const priorRun = join(aiw, `checkpoints/workflow-${workflowId}-approvals.yml`);
|
|
84
|
+
if (fs.exists(priorRun))
|
|
85
|
+
return {
|
|
86
|
+
exitCode: 1,
|
|
87
|
+
error: `Workflow ID has already been used: ${workflowId}. Choose a new ID.`,
|
|
88
|
+
};
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
const session = {
|
|
91
|
+
schema: 1,
|
|
92
|
+
workflowId,
|
|
93
|
+
status: "active",
|
|
94
|
+
startedAt: now,
|
|
95
|
+
updatedAt: now,
|
|
96
|
+
};
|
|
97
|
+
const ledger = createApprovalLedger(workflowId);
|
|
98
|
+
fs.write(ledgerPath, serializeApprovalLedger(ledger));
|
|
99
|
+
fs.write(sessionPath, serializeWorkflowSession(session));
|
|
100
|
+
return { exitCode: 0, output: `Workflow started: ${workflowId}.` };
|
|
101
|
+
}
|
|
102
|
+
if (action === "status") {
|
|
103
|
+
if (!fs.exists(sessionPath))
|
|
104
|
+
return { exitCode: 0, output: "No SDD workflow is active." };
|
|
105
|
+
const { session, ledger } = readWorkflowState(aiw, fs, false);
|
|
106
|
+
const stages = APPROVAL_STAGES.map((stage) => `- ${stage}: ${ledger.stages[stage]?.status ?? "not-started"}`);
|
|
107
|
+
return {
|
|
108
|
+
exitCode: 0,
|
|
109
|
+
output: `Workflow ${session.workflowId} (${session.status})\n${stages.join("\n")}\n${workflowNextAction(ledger)}`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (action === "complete") {
|
|
113
|
+
const { session, ledger } = readActiveWorkflow(aiw, fs);
|
|
114
|
+
const unresolved = APPROVAL_STAGES.filter((stage) => ledger.stages[stage]?.status !== "approved" &&
|
|
115
|
+
ledger.stages[stage]?.status !== "skipped");
|
|
116
|
+
if (unresolved.length)
|
|
117
|
+
return {
|
|
118
|
+
exitCode: 1,
|
|
119
|
+
error: `Workflow cannot complete before every stage is approved or explicitly skipped: ${unresolved.join(", ")}.`,
|
|
120
|
+
};
|
|
121
|
+
fs.write(sessionPath, serializeWorkflowSession({
|
|
122
|
+
...session,
|
|
123
|
+
status: "complete",
|
|
124
|
+
updatedAt: new Date().toISOString(),
|
|
125
|
+
}));
|
|
126
|
+
return { exitCode: 0, output: `Workflow completed: ${session.workflowId}.` };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
exitCode: 1,
|
|
130
|
+
error: "Usage: aiw workflow <start|status|complete> [options]",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (command === "approve" || command === "reject" || command === "skip") {
|
|
134
|
+
const stage = args[1];
|
|
135
|
+
if (!stage || !APPROVAL_STAGES.includes(stage))
|
|
136
|
+
return { exitCode: 1, error: `Usage: aiw ${command} <workflow-stage> [options]` };
|
|
137
|
+
const { ledger } = readActiveWorkflow(aiw, fs);
|
|
138
|
+
const workflowStage = stage;
|
|
139
|
+
if (command === "skip") {
|
|
140
|
+
const options = parseNamedOptions(args.slice(2), ["reason"]);
|
|
141
|
+
if (!options?.reason)
|
|
142
|
+
return {
|
|
143
|
+
exitCode: 1,
|
|
144
|
+
error: "Usage: aiw skip <optional-stage> --reason=human-approved-reason",
|
|
145
|
+
};
|
|
146
|
+
const updated = skipOptionalStage(ledger, workflowStage, {
|
|
147
|
+
reason: options.reason,
|
|
148
|
+
humanAccepted: true,
|
|
149
|
+
updatedAt: new Date().toISOString(),
|
|
150
|
+
});
|
|
151
|
+
fs.write(join(aiw, "approvals.yml"), serializeApprovalLedger(updated));
|
|
152
|
+
return { exitCode: 0, output: `Human-approved skip recorded for ${workflowStage}.` };
|
|
153
|
+
}
|
|
154
|
+
const artifact = approvalArtifactPath(aiw, workflowStage);
|
|
155
|
+
if (!artifact || !fs.exists(artifact))
|
|
156
|
+
return { exitCode: 1, error: `Approval evidence is missing for ${workflowStage}.` };
|
|
157
|
+
const artifactHash = stageFingerprint(fs.read(artifact));
|
|
158
|
+
if (command === "approve") {
|
|
159
|
+
if (ledger.stages[workflowStage]?.artifactHash !== artifactHash) {
|
|
160
|
+
const invalidated = invalidateStageAndDescendants(ledger, workflowStage, new Date().toISOString());
|
|
161
|
+
fs.write(join(aiw, "approvals.yml"), serializeApprovalLedger(invalidated));
|
|
162
|
+
return {
|
|
163
|
+
exitCode: 1,
|
|
164
|
+
error: `The ${workflowStage} artifact changed after review; its approval is stale. Rerun the quality gate and request approval again.`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const updated = approveStage(ledger, workflowStage, {
|
|
168
|
+
artifactHash,
|
|
169
|
+
updatedAt: new Date().toISOString(),
|
|
170
|
+
});
|
|
171
|
+
fs.write(join(aiw, "approvals.yml"), serializeApprovalLedger(updated));
|
|
172
|
+
return { exitCode: 0, output: `Human approval recorded for ${workflowStage}.` };
|
|
173
|
+
}
|
|
174
|
+
const options = parseNamedOptions(args.slice(2), ["reason"]);
|
|
175
|
+
if (!options?.reason)
|
|
176
|
+
return { exitCode: 1, error: "Usage: aiw reject <stage> --reason=human-feedback" };
|
|
177
|
+
const updated = rejectStage(ledger, workflowStage, options.reason, new Date().toISOString());
|
|
178
|
+
fs.write(join(aiw, "approvals.yml"), serializeApprovalLedger(updated));
|
|
179
|
+
return {
|
|
180
|
+
exitCode: 0,
|
|
181
|
+
output: `Human rejection recorded for ${workflowStage}. Revise this stage.`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
49
184
|
if (command === "ui") {
|
|
50
185
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
51
186
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
@@ -750,32 +885,48 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
750
885
|
const selectedArg = args.find((arg) => arg.startsWith("--select="))?.split("=", 2)[1];
|
|
751
886
|
const selected = selectedArg?.split(",") ?? (command === "sync" ? readSelectedRecommendations(fs, aiw) : []);
|
|
752
887
|
if (command === "sync") {
|
|
753
|
-
const
|
|
888
|
+
const previewOnly = args.includes("--preview");
|
|
889
|
+
const planApprovals = args.filter((arg) => arg.startsWith("--approve-plan="));
|
|
890
|
+
if (planApprovals.length > 1 || (previewOnly && planApprovals.length > 0))
|
|
891
|
+
return {
|
|
892
|
+
exitCode: 1,
|
|
893
|
+
error: "Usage: aiw sync [--preview | --approve-plan=sha256-fingerprint]",
|
|
894
|
+
};
|
|
895
|
+
const approval = planApprovals[0]?.slice("--approve-plan=".length);
|
|
896
|
+
const result = syncSelectedResources(fs, root, aiw, profile, selected, !previewOnly, approval);
|
|
754
897
|
if (result.error)
|
|
755
898
|
return { exitCode: 1, error: result.error };
|
|
899
|
+
if (previewOnly)
|
|
900
|
+
return { exitCode: 0, output: result.preview };
|
|
901
|
+
if (!approval)
|
|
902
|
+
return {
|
|
903
|
+
exitCode: 1,
|
|
904
|
+
error: `Sync is read-only until the exact preview is approved. Review the plan, then rerun with --approve-plan=${result.fingerprint}.`,
|
|
905
|
+
};
|
|
906
|
+
if (approval !== result.fingerprint)
|
|
907
|
+
return {
|
|
908
|
+
exitCode: 1,
|
|
909
|
+
error: `The sync plan changed after review. Run aiw sync --preview again and approve the new fingerprint (${result.fingerprint}).`,
|
|
910
|
+
};
|
|
911
|
+
if (result.conflicts?.length)
|
|
912
|
+
return {
|
|
913
|
+
exitCode: 1,
|
|
914
|
+
error: `Sync plan has conflicts and made no changes: ${result.conflicts.join(", ")}`,
|
|
915
|
+
};
|
|
756
916
|
const messages = [result.output ?? "Selected bundled resources synchronized."];
|
|
757
917
|
if (selected.includes("react-best-practices")) {
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
918
|
+
let pendingReason = "network:external was not approved";
|
|
919
|
+
try {
|
|
920
|
+
enforceConfiguredOrganizationPolicy(fs, aiw, {
|
|
921
|
+
provider: "vercel-skills",
|
|
922
|
+
source: "vercel-labs/agent-skills",
|
|
923
|
+
permissions: ["network:external"],
|
|
924
|
+
});
|
|
761
925
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
enforceConfiguredOrganizationPolicy(fs, aiw, {
|
|
765
|
-
provider: "vercel-skills",
|
|
766
|
-
source: "vercel-labs/agent-skills",
|
|
767
|
-
permissions: ["network:external"],
|
|
768
|
-
});
|
|
769
|
-
const target = parseManifest(fs.read(join(aiw, "manifest.yml"))).target.active;
|
|
770
|
-
await installVercelSkill(services.externalSkills ?? nodeCommandExecutor, "vercel-labs/agent-skills", "vercel-react-best-practices", target);
|
|
771
|
-
writeVercelSkillLock(fs, root, aiw, "vercel-labs/agent-skills", "vercel-react-best-practices");
|
|
772
|
-
messages.push("Remote React best-practices skill installed.");
|
|
773
|
-
}
|
|
774
|
-
catch (error) {
|
|
775
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
776
|
-
messages.push(`Remote skill pending: ${reason}`);
|
|
777
|
-
}
|
|
926
|
+
catch (error) {
|
|
927
|
+
pendingReason = error instanceof Error ? error.message : String(error);
|
|
778
928
|
}
|
|
929
|
+
messages.push(`Remote skill pending: ${pendingReason}. Install it separately with aiw skills install.`);
|
|
779
930
|
}
|
|
780
931
|
return { exitCode: 0, output: messages.join(" ") };
|
|
781
932
|
}
|
|
@@ -818,7 +969,14 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
818
969
|
try {
|
|
819
970
|
const manifest = fs.read(join(aiw, "manifest.yml"));
|
|
820
971
|
parseManifest(manifest);
|
|
821
|
-
|
|
972
|
+
const workflowPath = join(aiw, "workflow.yml");
|
|
973
|
+
if (!fs.exists(workflowPath))
|
|
974
|
+
return { exitCode: 0, output: manifest };
|
|
975
|
+
const { session, ledger } = readWorkflowState(aiw, fs, false);
|
|
976
|
+
return {
|
|
977
|
+
exitCode: 0,
|
|
978
|
+
output: `${manifest}\nSDD workflow ${session.workflowId} (${session.status})\n${workflowNextAction(ledger)}`,
|
|
979
|
+
};
|
|
822
980
|
}
|
|
823
981
|
catch (error) {
|
|
824
982
|
return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
|
|
@@ -946,6 +1104,7 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
946
1104
|
if (command === "brainstorm") {
|
|
947
1105
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
948
1106
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
1107
|
+
assertStageReady(aiw, fs, "brainstorming");
|
|
949
1108
|
const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Brainstorm";
|
|
950
1109
|
const path = join(aiw, "generated/specs/brainstorm.md");
|
|
951
1110
|
fs.write(path, `# ${title}\n\n## Goal\n\n## Users\n\n## Facts\n\n## Assumptions\n\n## Hypotheses\n\n## Constraints\n\n## Risks\n\n## Non-goals\n\n## Open Questions\n\n`);
|
|
@@ -954,6 +1113,7 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
954
1113
|
if (command === "spec") {
|
|
955
1114
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
956
1115
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
1116
|
+
assertStageReady(aiw, fs, "specification");
|
|
957
1117
|
const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Specification";
|
|
958
1118
|
const path = join(aiw, "generated/specs/specification.md");
|
|
959
1119
|
fs.write(path, `# ${title}\n\n## Context\n\n## Requirements\n\n### REQ-001: Requirement title\n\n- **Description:**\n- **Acceptance criteria:**\n - Given\n - When\n - Then\n\n## Non-functional Requirements\n\n## Constraints\n\n## Open Questions\n\n`);
|
|
@@ -962,6 +1122,7 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
962
1122
|
if (command === "adr") {
|
|
963
1123
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
964
1124
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
1125
|
+
assertStageReady(aiw, fs, "technical-design");
|
|
965
1126
|
const id = args.find((arg) => arg.startsWith("--id="))?.slice(5) || "001";
|
|
966
1127
|
const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Decision";
|
|
967
1128
|
const path = join(root, ".context/adrs", `ADR-${id}-${title.toLowerCase().replaceAll(" ", "-")}.md`);
|
|
@@ -971,6 +1132,7 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
971
1132
|
if (command === "plan") {
|
|
972
1133
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
973
1134
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
1135
|
+
assertStageReady(aiw, fs, "plan");
|
|
974
1136
|
const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Implementation Plan";
|
|
975
1137
|
const path = join(aiw, "generated/plans/implementation-plan.md");
|
|
976
1138
|
fs.write(path, `# ${title}\n\n## Linked Requirements\n\n- REQ-001: Link to the specification requirement.\n\n## Tasks\n\n- [ ] TASK-001: Implement the first increment.\n - Requirement: REQ-001\n - Code: src/\n - Tests: src/**/*.test.ts\n - Validation: npm test\n - Evidence: Record the passing command output and changed files.\n\n## Validation Commands\n\n- npm run check\n\n## Risks and Dependencies\n\n- Dependencies: List prerequisite tasks, packages, or decisions.\n- Risks: Record delivery, compatibility, and validation risks with mitigations.\n\n## Completion Evidence\n\n- TASK-001: Link code changes, test results, and validation output.\n\n`);
|
|
@@ -979,6 +1141,7 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
979
1141
|
if (command === "verify") {
|
|
980
1142
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
981
1143
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
1144
|
+
assertStageReady(aiw, fs, "verification");
|
|
982
1145
|
const specPath = join(aiw, "generated/specs/specification.md");
|
|
983
1146
|
const planPath = join(aiw, "generated/plans/implementation-plan.md");
|
|
984
1147
|
const issues = [];
|
|
@@ -1011,6 +1174,7 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
1011
1174
|
if (command === "trace") {
|
|
1012
1175
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
1013
1176
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
1177
|
+
assertStageReady(aiw, fs, "traceability");
|
|
1014
1178
|
const specPath = join(aiw, "generated/specs/specification.md");
|
|
1015
1179
|
const planPath = join(aiw, "generated/plans/implementation-plan.md");
|
|
1016
1180
|
if (!fs.exists(specPath) || !fs.exists(planPath))
|
|
@@ -1042,25 +1206,36 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
1042
1206
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
1043
1207
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
1044
1208
|
const stage = args[1];
|
|
1045
|
-
|
|
1046
|
-
brainstorming: "generated/specs/brainstorm.md",
|
|
1047
|
-
specification: "generated/specs/specification.md",
|
|
1048
|
-
plan: "generated/plans/implementation-plan.md",
|
|
1049
|
-
verification: "generated/reports/verification-report.md",
|
|
1050
|
-
};
|
|
1051
|
-
if (!stage || !Object.hasOwn(requirements, stage))
|
|
1209
|
+
if (!stage || !APPROVAL_STAGES.includes(stage))
|
|
1052
1210
|
return {
|
|
1053
1211
|
exitCode: 1,
|
|
1054
|
-
error:
|
|
1212
|
+
error: `Usage: aiw gate <${APPROVAL_STAGES.join("|")}>`,
|
|
1055
1213
|
};
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1214
|
+
const workflowStage = stage;
|
|
1215
|
+
assertStageReady(aiw, fs, workflowStage);
|
|
1216
|
+
const artifactPath = approvalArtifactPath(aiw, workflowStage);
|
|
1217
|
+
if (!artifactPath || !fs.exists(artifactPath))
|
|
1218
|
+
return {
|
|
1219
|
+
exitCode: 1,
|
|
1220
|
+
error: `Quality gate blocked: required evidence is missing for ${stage}.`,
|
|
1221
|
+
};
|
|
1222
|
+
const hasQualityGate = stageDefinition(workflowStage).requiresQualityGate;
|
|
1223
|
+
if (hasQualityGate) {
|
|
1224
|
+
const issues = evaluateQualityGate(stage, fs.read(artifactPath));
|
|
1225
|
+
if (issues.length)
|
|
1226
|
+
return { exitCode: 1, error: `Quality gate blocked: ${issues.join(" ")}` };
|
|
1227
|
+
}
|
|
1228
|
+
const { ledger } = readActiveWorkflow(aiw, fs);
|
|
1229
|
+
const updated = beginStageApproval(ledger, workflowStage, {
|
|
1230
|
+
artifactHash: stageFingerprint(fs.read(artifactPath)),
|
|
1231
|
+
qualityGate: hasQualityGate ? "passed" : "not-applicable",
|
|
1232
|
+
updatedAt: new Date().toISOString(),
|
|
1233
|
+
});
|
|
1234
|
+
fs.write(join(aiw, "approvals.yml"), serializeApprovalLedger(updated));
|
|
1235
|
+
return {
|
|
1236
|
+
exitCode: 0,
|
|
1237
|
+
output: `${hasQualityGate ? `Quality gate passed for ${stage}.` : `Evidence recorded for ${stage}; no automated quality gate applies.`} Human approval is still required: run aiw approve ${stage}.`,
|
|
1238
|
+
};
|
|
1064
1239
|
}
|
|
1065
1240
|
if (command === "validate") {
|
|
1066
1241
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
@@ -1411,13 +1586,81 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
1411
1586
|
}
|
|
1412
1587
|
return {
|
|
1413
1588
|
exitCode: 0,
|
|
1414
|
-
output: "aiw install [--target target] | ui [--port=number] | orchestrate --plan=path [--execute] [--max-parallel=number] | telemetry <status|enable|disable> [privacy options] | plugin <create|validate> [options] | scan | self-validate --ticket=TYPE-000 | organization-policy --file=path [--replace] | policy-check --package=path [--package=path] | preset --file=path [--replace] | context | context-summary | context-quality | capabilities [--target=target] | token-usage [--stage=name --budget=number --used=number] | registry [--search=query] [--private=name] | registry configure --file=path [--replace] | skills <search|inspect|install|check|update> [options] | audit-package --package=path | verify-package --package=path --checksum=sha256 | brainstorm [--title=title] | spec [--title=title] | adr [--id=id --title=title] | plan [--title=title] | verify | trace | gate <stage> | recommend [--select=id,id] | status | doctor | repair | uninstall [--dry-run] | target <target> | rollback | confirm | resolve --package=path | update --package=path --target=target | validate [--package=path]",
|
|
1589
|
+
output: "aiw install [--target target] | workflow <start|status|complete> [--id=id] | approve <stage> | reject <stage> --reason=text | skip <optional-stage> --reason=text | ui [--port=number] | orchestrate --plan=path [--execute] [--max-parallel=number] | telemetry <status|enable|disable> [privacy options] | plugin <create|validate> [options] | scan | self-validate --ticket=TYPE-000 | organization-policy --file=path [--replace] | policy-check --package=path [--package=path] | preset --file=path [--replace] | context | context-summary | context-quality | capabilities [--target=target] | token-usage [--stage=name --budget=number --used=number] | registry [--search=query] [--private=name] | registry configure --file=path [--replace] | skills <search|inspect|install|check|update> [options] | audit-package --package=path | verify-package --package=path --checksum=sha256 | brainstorm [--title=title] | spec [--title=title] | adr [--id=id --title=title] | plan [--title=title] | verify | trace | gate <stage> | recommend [--select=id,id] | status | doctor | repair | uninstall [--dry-run] | target <target> | rollback | confirm | resolve --package=path | update --package=path --target=target | validate [--package=path]",
|
|
1415
1590
|
};
|
|
1416
1591
|
}
|
|
1417
1592
|
catch (error) {
|
|
1418
1593
|
return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
|
|
1419
1594
|
}
|
|
1420
1595
|
}
|
|
1596
|
+
function readWorkflowState(aiw, fs, requireActive = true) {
|
|
1597
|
+
const sessionPath = join(aiw, "workflow.yml");
|
|
1598
|
+
const ledgerPath = join(aiw, "approvals.yml");
|
|
1599
|
+
if (!fs.exists(sessionPath) || !fs.exists(ledgerPath))
|
|
1600
|
+
throw new Error("No SDD workflow is active. Start one with `aiw workflow start`.");
|
|
1601
|
+
const session = parseWorkflowSession(fs.read(sessionPath));
|
|
1602
|
+
let ledger = parseApprovalLedger(fs.read(ledgerPath));
|
|
1603
|
+
if (session.workflowId !== ledger.workflowId)
|
|
1604
|
+
throw new Error("Workflow session and approval ledger IDs do not match; run doctor.");
|
|
1605
|
+
if (requireActive && session.status !== "active")
|
|
1606
|
+
throw new Error(`Workflow ${session.workflowId} is complete; start a new workflow first.`);
|
|
1607
|
+
const changed = APPROVAL_STAGES.find((stage) => {
|
|
1608
|
+
const record = ledger.stages[stage];
|
|
1609
|
+
if (!record?.artifactHash || !["pending-human", "approved"].includes(record.status))
|
|
1610
|
+
return false;
|
|
1611
|
+
const artifact = approvalArtifactPath(aiw, stage);
|
|
1612
|
+
return (!artifact ||
|
|
1613
|
+
!fs.exists(artifact) ||
|
|
1614
|
+
stageFingerprint(fs.read(artifact)) !== record.artifactHash);
|
|
1615
|
+
});
|
|
1616
|
+
if (changed) {
|
|
1617
|
+
ledger = invalidateStageAndDescendants(ledger, changed, new Date().toISOString());
|
|
1618
|
+
fs.write(ledgerPath, serializeApprovalLedger(ledger));
|
|
1619
|
+
}
|
|
1620
|
+
return { session, ledger };
|
|
1621
|
+
}
|
|
1622
|
+
function readActiveWorkflow(aiw, fs) {
|
|
1623
|
+
return readWorkflowState(aiw, fs, true);
|
|
1624
|
+
}
|
|
1625
|
+
function workflowNextAction(ledger) {
|
|
1626
|
+
const next = APPROVAL_STAGES.find((stage) => ledger.stages[stage]?.status !== "approved" && ledger.stages[stage]?.status !== "skipped");
|
|
1627
|
+
if (!next)
|
|
1628
|
+
return "Next action: all stages are resolved; run aiw workflow complete.";
|
|
1629
|
+
if (ledger.stages[next]?.status === "pending-human")
|
|
1630
|
+
return `Next action: present ${next} evidence and wait for the human; then run aiw approve ${next} (or reject it).`;
|
|
1631
|
+
if (ledger.stages[next]?.status === "stale" || ledger.stages[next]?.status === "rejected")
|
|
1632
|
+
return `Next action: revise ${next}, rerun its gate, and request human approval.`;
|
|
1633
|
+
return `Next action: prepare ${next} evidence.`;
|
|
1634
|
+
}
|
|
1635
|
+
function assertStageReady(aiw, fs, stage) {
|
|
1636
|
+
const { ledger } = readActiveWorkflow(aiw, fs);
|
|
1637
|
+
const unresolved = getUnresolvedPrerequisites(ledger, stage);
|
|
1638
|
+
if (unresolved.length) {
|
|
1639
|
+
const pending = unresolved.filter((prerequisite) => ledger.stages[prerequisite]?.status === "pending-human");
|
|
1640
|
+
if (pending.length)
|
|
1641
|
+
throw new Error(`Cannot enter ${stage}; previous stage(s) are awaiting human approval: ${pending.join(", ")}.`);
|
|
1642
|
+
throw new Error(`Cannot enter ${stage}; previous stages require human approval or an accepted optional skip: ${unresolved.join(", ")}.`);
|
|
1643
|
+
}
|
|
1644
|
+
const current = ledger.stages[stage];
|
|
1645
|
+
if (current?.status === "pending-human")
|
|
1646
|
+
throw new Error(`${stage} is awaiting human approval. Approve or reject it before continuing.`);
|
|
1647
|
+
if (current?.status === "approved")
|
|
1648
|
+
throw new Error(`${stage} is already approved. Evidence changes require rerunning its gate.`);
|
|
1649
|
+
}
|
|
1650
|
+
function approvalArtifactPath(aiw, stage) {
|
|
1651
|
+
if (stage === "technical-design")
|
|
1652
|
+
return join(aiw, "generated/reports/technical-design.md");
|
|
1653
|
+
const paths = {
|
|
1654
|
+
brainstorming: "generated/specs/brainstorm.md",
|
|
1655
|
+
specification: "generated/specs/specification.md",
|
|
1656
|
+
plan: "generated/plans/implementation-plan.md",
|
|
1657
|
+
implementation: "generated/reports/implementation-report.md",
|
|
1658
|
+
verification: "generated/reports/verification-report.md",
|
|
1659
|
+
review: "generated/reports/code-review.md",
|
|
1660
|
+
traceability: "generated/artifacts/traceability.yml",
|
|
1661
|
+
};
|
|
1662
|
+
return resolve(aiw, paths[stage]);
|
|
1663
|
+
}
|
|
1421
1664
|
function parseNamedOptions(args, allowed) {
|
|
1422
1665
|
const entries = args.map((arg) => arg.match(/^--([a-z-]+)=(.+)$/)?.slice(1));
|
|
1423
1666
|
if (args.some((arg) => arg.indexOf("=") !== arg.lastIndexOf("=")) ||
|
|
@@ -1561,7 +1804,7 @@ function containsCredential(value, credential, visited = new Set()) {
|
|
|
1561
1804
|
visited.add(value);
|
|
1562
1805
|
return Object.values(value).some((entry) => containsCredential(entry, credential, visited));
|
|
1563
1806
|
}
|
|
1564
|
-
function syncSelectedResources(fs, root, aiw, profile, selected) {
|
|
1807
|
+
function syncSelectedResources(fs, root, aiw, profile, selected, apply = false, approvedFingerprint) {
|
|
1565
1808
|
const target = parseManifest(fs.read(join(aiw, "manifest.yml"))).target.active;
|
|
1566
1809
|
const resourceSelections = resourcesForCapabilities(selected);
|
|
1567
1810
|
const bundled = loadBundledResources(target);
|
|
@@ -1612,7 +1855,16 @@ function syncSelectedResources(fs, root, aiw, profile, selected) {
|
|
|
1612
1855
|
const inventory = parseOwnership(fs.read(ownershipPath));
|
|
1613
1856
|
const nextInventory = new Map(inventory.map((entry) => [entry.path, entry]));
|
|
1614
1857
|
const changes = [];
|
|
1858
|
+
const changeActions = new Map();
|
|
1859
|
+
const preserved = new Map();
|
|
1615
1860
|
const conflicts = [];
|
|
1861
|
+
const preserve = (path, type, content) => {
|
|
1862
|
+
preserved.set(path, {
|
|
1863
|
+
path,
|
|
1864
|
+
state: type ?? "unknown",
|
|
1865
|
+
...(content === undefined ? {} : { contentFingerprint: stageFingerprint(content) }),
|
|
1866
|
+
});
|
|
1867
|
+
};
|
|
1616
1868
|
for (const [path, { content, metadata }] of desired) {
|
|
1617
1869
|
assertSafeProjectPath(fs, root, path);
|
|
1618
1870
|
const relativePath = relative(root, path).split(sep).join("/");
|
|
@@ -1620,41 +1872,89 @@ function syncSelectedResources(fs, root, aiw, profile, selected) {
|
|
|
1620
1872
|
const owned = nextInventory.get(relativePath);
|
|
1621
1873
|
if (type === "missing") {
|
|
1622
1874
|
changes.push({ path, content: Buffer.from(content, "utf8") });
|
|
1875
|
+
changeActions.set(relativePath, "add");
|
|
1623
1876
|
nextInventory.set(relativePath, createOwnedFile(relativePath, content, metadata));
|
|
1624
1877
|
continue;
|
|
1625
1878
|
}
|
|
1626
1879
|
if (type !== "file" || !owned) {
|
|
1627
1880
|
conflicts.push(relativePath);
|
|
1881
|
+
preserve(relativePath, type);
|
|
1628
1882
|
continue;
|
|
1629
1883
|
}
|
|
1630
1884
|
const actual = fs.read(path);
|
|
1631
1885
|
if (createOwnedFile(relativePath, actual).checksum !== owned.checksum) {
|
|
1632
1886
|
conflicts.push(relativePath);
|
|
1887
|
+
preserve(relativePath, type, actual);
|
|
1633
1888
|
continue;
|
|
1634
1889
|
}
|
|
1635
|
-
if (actual !== content)
|
|
1890
|
+
if (actual !== content) {
|
|
1636
1891
|
changes.push({ path, content: Buffer.from(content, "utf8") });
|
|
1892
|
+
changeActions.set(relativePath, "update");
|
|
1893
|
+
}
|
|
1894
|
+
else
|
|
1895
|
+
preserve(relativePath, type, actual);
|
|
1637
1896
|
nextInventory.set(relativePath, createOwnedFile(relativePath, content, metadata));
|
|
1638
1897
|
}
|
|
1639
1898
|
for (const [path, content] of generatedFiles) {
|
|
1640
1899
|
assertSafeProjectPath(fs, root, path);
|
|
1641
1900
|
const type = fs.pathType?.(path);
|
|
1642
|
-
|
|
1901
|
+
const relativePath = relative(root, path).split(sep).join("/");
|
|
1902
|
+
if (type === "missing") {
|
|
1643
1903
|
changes.push({ path, content: Buffer.from(content, "utf8") });
|
|
1644
|
-
|
|
1904
|
+
changeActions.set(relativePath, "add");
|
|
1905
|
+
}
|
|
1906
|
+
else if (type !== "file" || fs.read(path) !== content) {
|
|
1645
1907
|
conflicts.push(relative(root, path).split(sep).join("/"));
|
|
1908
|
+
preserve(relativePath, type, type === "file" ? fs.read(path) : undefined);
|
|
1909
|
+
}
|
|
1910
|
+
else
|
|
1911
|
+
preserve(relativePath, type, fs.read(path));
|
|
1646
1912
|
}
|
|
1647
|
-
if (conflicts.length)
|
|
1648
|
-
return {
|
|
1649
|
-
error: `Sync preserved existing or modified project resources: ${[...new Set(conflicts)].sort().join(", ")}`,
|
|
1650
|
-
};
|
|
1651
1913
|
const nextOwnership = serializeOwnership([...nextInventory.values()]);
|
|
1652
|
-
if (fs.read(ownershipPath) !== nextOwnership)
|
|
1914
|
+
if (fs.read(ownershipPath) !== nextOwnership) {
|
|
1653
1915
|
changes.push({ path: ownershipPath, content: Buffer.from(nextOwnership, "utf8") });
|
|
1916
|
+
changeActions.set(relative(root, ownershipPath).split(sep).join("/"), "update");
|
|
1917
|
+
}
|
|
1918
|
+
const plan = {
|
|
1919
|
+
target,
|
|
1920
|
+
selected: [...new Set(selected)].sort(),
|
|
1921
|
+
changes: changes
|
|
1922
|
+
.map(({ path, content }) => ({
|
|
1923
|
+
path: relative(root, path).split(sep).join("/"),
|
|
1924
|
+
action: changeActions.get(relative(root, path).split(sep).join("/")) ?? "update",
|
|
1925
|
+
contentFingerprint: stageFingerprint(content ?? new Uint8Array()),
|
|
1926
|
+
}))
|
|
1927
|
+
.sort((a, b) => a.path.localeCompare(b.path)),
|
|
1928
|
+
preserved: [...preserved.values()].sort((a, b) => a.path.localeCompare(b.path)),
|
|
1929
|
+
conflicts: [...new Set(conflicts)].sort(),
|
|
1930
|
+
externalPending: selected.includes("react-best-practices")
|
|
1931
|
+
? [
|
|
1932
|
+
{
|
|
1933
|
+
id: "vercel-react-best-practices",
|
|
1934
|
+
source: "vercel-labs/agent-skills",
|
|
1935
|
+
permissions: ["network:external"],
|
|
1936
|
+
approval: "separate human decision required",
|
|
1937
|
+
},
|
|
1938
|
+
]
|
|
1939
|
+
: [],
|
|
1940
|
+
};
|
|
1941
|
+
const fingerprint = stageFingerprint(JSON.stringify(plan));
|
|
1942
|
+
const preview = `${JSON.stringify({ plan, fingerprint }, null, 2)}\n`;
|
|
1943
|
+
if (!apply)
|
|
1944
|
+
return { preview, fingerprint, conflicts: plan.conflicts };
|
|
1945
|
+
if (!approvedFingerprint)
|
|
1946
|
+
return { preview, fingerprint, conflicts: plan.conflicts };
|
|
1947
|
+
if (approvedFingerprint !== fingerprint)
|
|
1948
|
+
return { error: `The sync plan changed after review; current fingerprint is ${fingerprint}.` };
|
|
1949
|
+
if (plan.conflicts.length)
|
|
1950
|
+
return { error: `Sync plan has conflicts and made no changes: ${plan.conflicts.join(", ")}` };
|
|
1654
1951
|
if (changes.length)
|
|
1655
1952
|
applyProjectFileChanges(fs, root, changes);
|
|
1656
1953
|
return {
|
|
1657
1954
|
output: `Synchronized ${resourceSelections.length} selected bundled resources for ${target} and generated ${generatedFiles.size} project-specific files.`,
|
|
1955
|
+
preview,
|
|
1956
|
+
fingerprint,
|
|
1957
|
+
conflicts: plan.conflicts,
|
|
1658
1958
|
};
|
|
1659
1959
|
}
|
|
1660
1960
|
function personalizeBundledResource(type, id, content, profile) {
|