@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.
@@ -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
- return requireSections(content, "Verification Report", [
43
- "Requirements Checked",
44
- "Checks Passed",
45
- "Missing Evidence",
46
- "Residual Risks",
47
- "Decision",
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 result = syncSelectedResources(fs, root, aiw, profile, selected);
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
- const approvedPermissions = parseApprovedPermissions(args);
759
- if (!approvedPermissions.includes("network:external")) {
760
- messages.push("Remote skill pending: network:external was not approved. Bundled resources were installed; rerun sync with --allow=network:external only if you approve external access.");
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
- else {
763
- try {
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
- return { exitCode: 0, output: manifest };
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
- const requirements = {
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: "Usage: aiw gate <brainstorming|specification|plan|verification>",
1212
+ error: `Usage: aiw gate <${APPROVAL_STAGES.join("|")}>`,
1055
1213
  };
1056
- const gateStage = stage;
1057
- const artifact = requirements[gateStage];
1058
- if (!fs.exists(join(aiw, artifact)))
1059
- return { exitCode: 1, error: `Quality gate blocked: missing ${artifact}` };
1060
- const issues = evaluateQualityGate(gateStage, fs.read(join(aiw, artifact)));
1061
- return issues.length
1062
- ? { exitCode: 1, error: `Quality gate blocked: ${issues.join(" ")}` }
1063
- : { exitCode: 0, output: `Quality gate passed for ${stage}.` };
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
- if (type === "missing")
1901
+ const relativePath = relative(root, path).split(sep).join("/");
1902
+ if (type === "missing") {
1643
1903
  changes.push({ path, content: Buffer.from(content, "utf8") });
1644
- else if (type !== "file" || fs.read(path) !== content)
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) {