@multileaf/ai-workflow 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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) {
@@ -6,13 +6,13 @@ Make `/ai-init` a guided, evidence-backed onboarding flow. The initial `npx` ins
6
6
 
7
7
  ## User flow
8
8
 
9
- 1. **Model and consent.** Before repository analysis, recommend switching to a lower-cost model. Do not change model settings. Ask whether the user switched or wants to continue with the current model. Explain that repository analysis is read-only until resource selection.
9
+ 1. **Model and consent.** Before repository analysis, recommend switching to a lower-cost model. Do not change model settings. Ask whether the user switched or explicitly wants to continue with the current model. End the agent turn after asking and wait for a reply before scanning, launching subagents, or calling tools. Silence, ambiguity, or a preselected widget value is not consent.
10
10
  2. **Parallel project analysis.** Use the host's native subagent mechanism to start independent read-only scans in parallel. Assign separate work to (a) technologies and package/workspace topology, (b) architecture, modules, and code patterns, and (c) tests, quality tools, and CI. The initiating agent is the orchestrator: it consolidates findings, resolves conflicts using repository evidence, and records paths for every material conclusion. If the host cannot start subagents, do the same scans directly and say so.
11
11
  3. **Monorepo coverage.** Discover root and nested project manifests, workspace definitions, package boundaries, framework configs, tests, and module entry points. Analyze each workspace separately, then summarize shared tooling and cross-workspace relationships. Never infer that root dependencies apply to every workspace.
12
12
  4. **Scan record.** Run the deterministic AI Workflow scan and combine it with the parallel analysis. Present technologies, architecture, patterns, modules, tests, quality commands, workspace topology, confidence, and source evidence. Do not include secret values. Ask for confirmation where conflicting or uncertain findings would affect installed guidance.
13
13
  5. **Recommendations.** Match catalog capabilities to the consolidated project profile. Show concise tables grouped by resource category (skills, rules, agents, hooks, templates, and MCPs), with one row per item and a short, project-specific reason plus supporting evidence. Include resource types, required permissions, and project-specific configuration steps where relevant. Recommend an MCP only when repository evidence supports its use; disclose its publisher/source, capabilities, access, transport, and credential needs. Never invent credentials or silently enable an MCP.
14
- 6. **Selection.** After the recommendation tables, use the coding host's native selection widget when it supports one. First offer: install all recommendations, customize, or install none. For customization, show selectable blocks grouped by resource type, then the individual recommended items in each selected block; retain each item's concise reason and evidence in the selection details. Include MCPs as a separate block. Keep dependencies visible and select required dependencies together. If the host has no selection widget, use an equivalent numbered prompt and accept all/custom/none explicitly.
15
- 7. **Install and personalize.** Install only the confirmed selection. For “all”, install every recommendation that does not require an unapproved external permission. A missing external permission must not block independent bundled resources; report the external item as pending and give the user the exact retry command. For custom selection, install only selected items and their declared dependencies. Personalize resources only when project evidence materially changes their instructions (for example, test commands, workspace/module layout, framework patterns, or architectural boundaries). Preserve existing files and secrets; report conflicts rather than overwriting. MCP setup must use the selected host's supported project-level configuration, preserve existing configuration, use environment-variable references for secrets, and request separate approval for permissions or external execution.
14
+ 6. **Selection.** After the recommendation tables, use the coding host's native selection widget when it supports one. First offer: install all recommendations, customize, or install none. No option may be preselected or inferred. Wait for an explicit submission; a dismissed widget, blank response, silence, or ambiguity must stop progress without selecting or installing anything. For customization, show selectable blocks grouped by resource type, then the individual recommended items in each selected block; retain each item's concise reason and evidence in the selection details. Include MCPs as a separate block. Keep dependencies visible and select required dependencies together. If the host has no selection widget, ask an equivalent numbered prompt, end the turn, and wait for an explicit all/custom/none reply.
15
+ 7. **Install and personalize.** After an explicit non-empty selection, show the exact resources and prerequisite capabilities that will be installed, ask for confirmation, end the turn, and wait. Install only after that confirmation arrives in a later turn. For “all”, install every recommendation that does not require an unapproved external permission. A missing external permission must not block independent bundled resources; report the external item as pending and give the user the exact retry command. For custom selection, install only selected items and their declared dependencies. Personalize resources only when project evidence materially changes their instructions (for example, test commands, workspace/module layout, framework patterns, or architectural boundaries). Preserve existing files and secrets; report conflicts rather than overwriting. MCP setup must use the selected host's supported project-level configuration, preserve existing configuration, use environment-variable references for secrets, and request separate approval for permissions or external execution.
16
16
  8. **Completion.** Report installed resources by block, project-specific changes, skipped recommendations, conflicts, MCP authentication/trust steps, and validation performed. Recommend restarting/reloading the coding-agent session so newly installed resources are discovered. Do not claim a restart is required when the host loads changes immediately; distinguish host behavior where known.
17
17
 
18
18
  ## Safety and compatibility
@@ -38,7 +38,10 @@ Make `/ai-init` a guided, evidence-backed onboarding flow. The initial `npx` ins
38
38
  - **AIINIT-009:** Completion lists changes and recommends a session restart/reload to discover installed resources.
39
39
  - **AIINIT-010:** CLI tests cover mixed-stack monorepos, all/custom/none selection, permission gating, MCP config merging, personalization, conflicts, and completion output.
40
40
  - **AIINIT-011:** Custom selections can choose individual resource references by block, while `--select=all` selects every recommended capability and `--select=` selects none.
41
- - **AIINIT-013:** A selected remote resource without approved network access does not block independent bundled resource sync; sync reports the remote item as pending and preserves unrelated project files.
41
+ - **AIINIT-013:** Sync previews exact additions, updates, preserved files, conflicts, and external permissions without writing; apply is bound to the approved plan fingerprint and is transactional. External skills and MCP actions require separate approvals, so network denial cannot partially apply local resources.
42
+ - **AIINIT-014:** The bootstrap pauses the agent turn at the model choice, recommendation selection, and final install confirmation; no scan or install proceeds from silence, ambiguity, cancellation, or a default/preselected choice.
43
+ - **AIINIT-015:** Every applicable SDD stage has a mandatory human approval checkpoint before the next stage; automated quality gates are explicitly distinct from human approval, and the agent must stop the turn until an explicit decision arrives.
44
+ - **AIINIT-016:** A versioned per-workflow approval ledger binds each human decision to the exact evidence fingerprint, blocks out-of-order commands, invalidates downstream approvals when evidence changes, and explains the next action in workflow status.
42
45
 
43
46
  ## Delivery slices
44
47
 
@@ -43,6 +43,14 @@ Target files such as `.cursor/rules`, `.claude/skills`, or Codex skill directori
43
43
 
44
44
  Project analysis is hybrid: deterministic detectors establish observable facts, while a scoped AI interpreter explains conventions and produces recommendations. AI output is never confirmed project state without user approval.
45
45
 
46
+ ## Human workflow approvals
47
+
48
+ The SDD approval contract models the ordered stages brainstorming, specification, technical design, plan, implementation, verification, review, and traceability. Brainstorming and technical design are optional; skipping either requires a human-accepted reason. Every other stage must be approved. Automated quality-gate results are stored separately from human decisions and can only move an artifact to pending review, never directly to approved.
49
+
50
+ Each approval is scoped to one workflow ID and bound to a SHA-256 fingerprint of the exact artifact reviewed, so a later feature cannot reuse earlier approvals. A content change or rejection makes that stage and its descendants stale. The CLI persists the versioned ledger in `.aiw/approvals.yml`, blocks stage commands when prerequisites are unresolved, and keeps passing quality gates distinct from human approval. Approval records provide an audit trail but cannot prove the identity of a person who responded in a host conversation.
51
+
52
+ Every stage has a stable evidence path and an automated completeness gate. Design, implementation, review, and traceability use explicit report templates; a passing completeness check still leaves the stage pending until a human accepts the exact evidence.
53
+
46
54
  ## Modularity
47
55
 
48
56
  The CLI is an entry point, not the business layer. Filesystem, package providers, scanners, recommendation engines, generators, validators, and adapters must be replaceable through interfaces and dependency injection.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multileaf/ai-workflow",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Portable spec-driven development workflow for AI coding agents",
5
5
  "license": "MIT",
6
6
  "repository": {