@apexops/cli 0.10.0-next.3 → 0.10.0-next.5

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.
Files changed (33) hide show
  1. package/README.md +40 -0
  2. package/assets/client-projection-metadata/copilot-cli-agent-tools.json +12 -0
  3. package/assets/client-projections/github-copilot-cli/.github/agents/apex-architect.agent.md +9 -8
  4. package/assets/client-projections/github-copilot-cli/.github/agents/apex-planner.agent.md +11 -8
  5. package/assets/client-projections/github-copilot-cli/.github/agents/apex-requirements.agent.md +23 -12
  6. package/assets/client-projections/github-copilot-cli/.github/agents/apex.agent.md +4 -0
  7. package/assets/client-projections/github-copilot-cli/.github/mcp.json +7 -0
  8. package/assets/client-projections/github-copilot-cli/.github/skills/apex-requirements/SKILL.md +7 -4
  9. package/assets/client-projections/github-copilot-vscode/.github/agents/apex-architect.agent.md +9 -8
  10. package/assets/client-projections/github-copilot-vscode/.github/agents/apex-planner.agent.md +11 -8
  11. package/assets/client-projections/github-copilot-vscode/.github/agents/apex-requirements.agent.md +23 -12
  12. package/assets/client-projections/github-copilot-vscode/.github/agents/apex-reviewer.agent.md +9 -5
  13. package/assets/client-projections/github-copilot-vscode/.github/agents/apex.agent.md +4 -0
  14. package/assets/client-projections/github-copilot-vscode/.github/skills/apex-requirements/SKILL.md +7 -4
  15. package/assets/config/runtime-bundle.v1.json +7 -7
  16. package/assets/customizations/.github/agents/apex-architect.agent.md +9 -8
  17. package/assets/customizations/.github/agents/apex-planner.agent.md +11 -8
  18. package/assets/customizations/.github/agents/apex-requirements.agent.md +23 -12
  19. package/assets/customizations/.github/agents/apex-reviewer.agent.md +9 -5
  20. package/assets/customizations/.github/agents/apex.agent.md +4 -0
  21. package/assets/customizations/.github/mcp.json +7 -0
  22. package/assets/customizations/.github/skills/apex-requirements/SKILL.md +7 -4
  23. package/assets/customizations/manifest.json +1 -1
  24. package/assets/manifest.json +58 -58
  25. package/dist/cli.d.ts +1 -0
  26. package/dist/cli.js +42 -2
  27. package/dist/mcp.js +89 -16
  28. package/dist/service.d.ts +46 -1
  29. package/dist/service.js +466 -41
  30. package/dist/state-transfer.js +23 -6
  31. package/dist/version.d.ts +1 -1
  32. package/dist/version.js +1 -1
  33. package/package.json +5 -5
package/dist/service.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { ApprovalEvidenceV1Schema, ArchitectureAvailabilityV1Schema, ArchitectureV1Schema, CONTRACT_VERSION, CostEstimateV1Schema, DeploymentPreviewV1Schema, DiagnosisV1Schema, EnvironmentInputsV1Schema, EvidenceManifestV1Schema, ExecutionPlanAttestationV1Schema, GovernanceConstraintsV1Schema, IacBindingV1Schema, IacHandoffV1Schema, InputRequestV1Schema, InputValueV1Schema, InputSubmissionV1Schema, ImplementationIntentV1Schema, LogicalResourceManifestV1Schema, OperationRecordV1Schema, PolicyPropertyMapV1Schema, QualityMeasurementsV1Schema, QualityReportV1Schema, RequirementsV1Schema, ResourceInventoryV1Schema, ReviewFindingsV1Schema, RuntimeBundleLockV1Schema, WorkloadDecisionManifestV1Schema, hasOnlyTypedSecretReferences, hasValidInputRequestQuestions, hasValidCostArithmetic, hasValidLogicalResourceReferences, } from "@apexops/contracts";
2
2
  import { Value } from "@sinclair/typebox/value";
3
3
  import { CapabilityPackManager, FakeIaCProvider, ProcessRunner, generateBicepTree, generateTerraformTree, } from "@apexops/capabilities";
4
- import { ContentCache, EvidencePolicy, EvidenceStore, EventJournal, ImprovementStore, ObjectStore, ProjectStore, RunRepository, ValidatorRegistry, WriterTransferStore, WorkflowEngine, assertTaskCurrent, atomicWriteBytes, atomicWriteJson, createTaskEnvelope, decideGate, inheritGate, openGate, sha256Bytes, sha256Json, validateInputAnswers, workflowValidatorOwnership, } from "@apexops/kernel";
4
+ import { ContentCache, EvidencePolicy, EvidenceStore, EventJournal, ImprovementStore, ObjectStore, ProjectStore, RunRepository, ValidatorRegistry, WriterTransferStore, WorkflowEngine, assertTaskCurrent, atomicWriteBytes, atomicWriteJson, createTaskEnvelope, decideGate, inheritGate, invalidateGate, openGate, sha256Bytes, sha256Json, validateInputAnswers, workflowValidatorOwnership, } from "@apexops/kernel";
5
5
  import { DOCUMENT_REGISTRY, renderApprovalEvidence, renderDeploymentPreview, renderRequirementsDocument, renderResourceInventory, renderRunStatus, } from "@apexops/renderers";
6
6
  import { constants } from "node:fs";
7
7
  import { access, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat } from "node:fs/promises";
8
- import { homedir } from "node:os";
8
+ import { homedir, userInfo } from "node:os";
9
9
  import { basename, delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
10
10
  import { resolveBundledAssets } from "./assets.js";
11
11
  import { dependencyRevision as calculateDependencyRevision } from "./dependency-revision.js";
@@ -106,7 +106,7 @@ const TASKS = [
106
106
  { id: "diagnosis", role: "diagnostic-operator", outputs: ["diagnosis"] },
107
107
  { id: "quality", role: "quality-evaluator", outputs: ["quality-report"] },
108
108
  ];
109
- const REQUIREMENTS_INTAKE = [
109
+ const LEGACY_REQUIREMENTS_INTAKE = [
110
110
  {
111
111
  round: "business-discovery",
112
112
  questions: [
@@ -129,6 +129,11 @@ const REQUIREMENTS_INTAKE = [
129
129
  id: "delivery-scenario",
130
130
  prompt: "Is this a greenfield build, migration, modernization, or extension?",
131
131
  options: ["greenfield", "migration", "modernization", "extension"],
132
+ recommendation: {
133
+ value: "greenfield",
134
+ source: "default",
135
+ rationale: "Use greenfield unless an existing workload must be migrated, modernized, or extended.",
136
+ },
132
137
  },
133
138
  {
134
139
  id: "target-environments",
@@ -136,6 +141,11 @@ const REQUIREMENTS_INTAKE = [
136
141
  options: ["dev", "test", "staging", "prod"],
137
142
  multiSelect: true,
138
143
  valueType: "environment-set",
144
+ recommendation: {
145
+ value: ["dev", "prod"],
146
+ source: "default",
147
+ rationale: "Plan development and production intent; each environment remains a separate governed run.",
148
+ },
139
149
  },
140
150
  ],
141
151
  },
@@ -154,6 +164,11 @@ const REQUIREMENTS_INTAKE = [
154
164
  prompt: "Choose the data classification or explicitly defer it.",
155
165
  options: ["public", "internal", "confidential", "restricted"],
156
166
  valueType: "data-classification",
167
+ recommendation: {
168
+ value: "internal",
169
+ source: "default",
170
+ rationale: "Use internal as the baseline unless the workload handles public-only or more sensitive data.",
171
+ },
157
172
  },
158
173
  { id: "iac-preference", prompt: "Choose the preferred infrastructure tool.", options: ["bicep", "terraform"] },
159
174
  ],
@@ -161,7 +176,10 @@ const REQUIREMENTS_INTAKE = [
161
176
  {
162
177
  round: "service-preferences",
163
178
  questions: [
164
- { id: "prohibited-services", prompt: "List prohibited services, or explicitly defer the constraint." },
179
+ {
180
+ id: "prohibited-services",
181
+ prompt: "List prohibited services, use 'none', or explicitly defer the constraint.",
182
+ },
165
183
  {
166
184
  id: "service-preferences",
167
185
  prompt: "Select preferred Azure services. Select all that apply; architecture chooses the exact combination.",
@@ -209,17 +227,32 @@ const REQUIREMENTS_INTAKE = [
209
227
  "diagnostic-logging",
210
228
  ],
211
229
  multiSelect: true,
230
+ recommendation: {
231
+ value: ["managed-identity", "key-vault", "platform-managed-encryption", "diagnostic-logging"],
232
+ source: "default",
233
+ rationale: "APEX security baseline; confirm additions or exceptions for this workload.",
234
+ },
212
235
  },
213
236
  {
214
237
  id: "authentication",
215
238
  prompt: "Select the required authentication and authorization mechanisms.",
216
239
  options: ["microsoft-entra-id", "managed-identity", "external-oidc", "api-key", "anonymous"],
217
240
  multiSelect: true,
241
+ recommendation: {
242
+ value: ["microsoft-entra-id", "managed-identity"],
243
+ source: "default",
244
+ rationale: "Use Entra ID for people and managed identity for workload-to-service access.",
245
+ },
218
246
  },
219
247
  {
220
248
  id: "region",
221
249
  prompt: "Choose the Azure region, or explicitly defer the decision.",
222
250
  options: ["swedencentral", "westeurope", "germanywestcentral"],
251
+ recommendation: {
252
+ value: "swedencentral",
253
+ source: "default",
254
+ rationale: "Repository default for EU data residency and service deployment.",
255
+ },
223
256
  },
224
257
  {
225
258
  id: "availability-recovery",
@@ -248,6 +281,17 @@ const REQUIREMENTS_INTAKE = [
248
281
  ],
249
282
  },
250
283
  ];
284
+ const REQUIREMENTS_INTAKE = [
285
+ LEGACY_REQUIREMENTS_INTAKE[0],
286
+ {
287
+ round: "workload-pattern",
288
+ questions: [
289
+ ...LEGACY_REQUIREMENTS_INTAKE[1].questions.filter(({ id }) => id !== "iac-preference"),
290
+ ...LEGACY_REQUIREMENTS_INTAKE[2].questions,
291
+ ],
292
+ },
293
+ LEGACY_REQUIREMENTS_INTAKE[3],
294
+ ];
251
295
  const ARCHITECTURE_DECISIONS = [
252
296
  {
253
297
  id: "network-exposure",
@@ -379,6 +423,8 @@ export class ApexService {
379
423
  await this.installCapabilityAssets(assets);
380
424
  const runtimeLock = await this.createRuntimeLock(assets);
381
425
  await atomicWriteJson(join(this.root, ".apex", "apex.lock.json"), runtimeLock);
426
+ await atomicWriteJson(join(this.root, ".apex", "runtime", "apex.lock.json"), runtimeLock);
427
+ await this.installRuntimeGeneration(runtimeLock);
382
428
  return this.createProject(input);
383
429
  }
384
430
  catch (error) {
@@ -486,6 +532,8 @@ export class ApexService {
486
532
  const selection = await this.selection();
487
533
  await this.ensureLocalGitBoundary();
488
534
  const assets = await resolveBundledAssets();
535
+ const previousRuntimeLock = JSON.parse(await readFile(join(this.root, ".apex", "apex.lock.json"), "utf8"));
536
+ await this.installRuntimeGeneration(previousRuntimeLock);
489
537
  const lock = JSON.parse(await readFile(join(this.root, ".apex", "customizations.lock.json"), "utf8"));
490
538
  const persisted = await this.customizationSelection(lock.clientId);
491
539
  const clientId = persisted.clientId;
@@ -494,6 +542,11 @@ export class ApexService {
494
542
  }
495
543
  const source = customizationsSource ?? assets.clientProjections[clientId];
496
544
  const updated = await this.installCustomizations(source, true, assets.config, false, clientId);
545
+ await this.installCapabilityAssets(assets);
546
+ const runtimeLock = await this.createRuntimeLock(assets);
547
+ await atomicWriteJson(join(this.root, ".apex", "apex.lock.json"), runtimeLock);
548
+ await atomicWriteJson(join(this.root, ".apex", "runtime", "apex.lock.json"), runtimeLock);
549
+ await this.installRuntimeGeneration(runtimeLock);
497
550
  await this.append(await this.run(selection), "customizations.updated", { source: resolve(source), updated });
498
551
  return { updated };
499
552
  }
@@ -730,6 +783,9 @@ export class ApexService {
730
783
  return { status: "task", task: await this.issueTask(run, TASKS[0], []) };
731
784
  }
732
785
  const route = await this.route(run, events);
786
+ if (route.blockers.length > 0 && route.reviewGate !== undefined) {
787
+ return { status: "needs_review", review: await this.pendingReview(events, route.reviewGate) };
788
+ }
733
789
  if (route.blockers.length > 0)
734
790
  throw new ApexError("APEX_AUTHORIZATION", route.blockers.join("; "), EXIT_CODES.authorization, route.blockers);
735
791
  if (route.task === undefined)
@@ -847,12 +903,12 @@ export class ApexService {
847
903
  const events = await this.journal(run).replay();
848
904
  const route = await this.route(run, events);
849
905
  const outputTemplates = {};
850
- if (task.taskType === "requirements" || task.taskType === "plan") {
906
+ if (task.taskType === "requirements" || task.taskType === "plan" || task.taskType.endsWith("-review")) {
851
907
  for (const kind of task.allowedOutputKinds) {
852
908
  if (!SUPPORTED_ARTIFACT_KINDS.includes(kind)) {
853
909
  throw new ApexError("APEX_INTERNAL", `Unsupported task output kind: ${kind}`, EXIT_CODES.internal);
854
910
  }
855
- outputTemplates[kind] = this.outputTemplate(kind, run, events);
911
+ outputTemplates[kind] = this.outputTemplate(kind, run, events, task.taskType);
856
912
  }
857
913
  }
858
914
  const artifactHashes = this.acceptedArtifactHashes(events);
@@ -868,6 +924,35 @@ export class ApexService {
868
924
  blockers: route.blockers,
869
925
  };
870
926
  }
927
+ async readTaskInput(taskId, offset = 0, limit = 6_000) {
928
+ const run = await this.currentRun();
929
+ const task = await this.readTask(run, taskId);
930
+ const descriptor = TASKS.find(({ id }) => id === task.taskType);
931
+ if (descriptor?.reviewSubject === undefined)
932
+ throw new ApexError("APEX_AUTHORIZATION", "Only review tasks may read their accepted input", EXIT_CODES.authorization);
933
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 6_000) {
934
+ throw new ApexError("APEX_VALIDATION", "Input read range is invalid", EXIT_CODES.validation);
935
+ }
936
+ const subjectKind = descriptor.reviewSubject === "governance-reconciliation" ? "policy-property-map" : descriptor.reviewSubject;
937
+ const events = await this.journal(run).replay();
938
+ const subjectHash = this.artifactHash(events, subjectKind);
939
+ if (subjectHash === undefined || !task.inputRefs.includes(subjectHash)) {
940
+ throw new ApexError("APEX_STALE", "Review task input is unavailable or stale", EXIT_CODES.stale);
941
+ }
942
+ const serialized = JSON.stringify(await this.objects.getJson(subjectHash));
943
+ if (offset >= serialized.length) {
944
+ throw new ApexError("APEX_VALIDATION", "Input read offset is outside the review subject", EXIT_CODES.validation);
945
+ }
946
+ const content = serialized.slice(offset, offset + limit);
947
+ const nextOffset = offset + content.length;
948
+ return {
949
+ subjectHash,
950
+ content,
951
+ offset,
952
+ ...(offset === 0 ? { outputTemplate: this.outputTemplate("review-findings", run, events, task.taskType) } : {}),
953
+ ...(nextOffset < serialized.length ? { nextOffset } : {}),
954
+ };
955
+ }
871
956
  async stageArtifact(taskId, output) {
872
957
  const run = await this.currentRun();
873
958
  const task = await this.readTask(run, taskId);
@@ -1016,6 +1101,54 @@ export class ApexService {
1016
1101
  async completeTaskOutputs(taskId, outputs) {
1017
1102
  return this.acceptTaskOutputs(taskId, outputs, false);
1018
1103
  }
1104
+ async completeRequirements(taskId, requirements) {
1105
+ await this.assertTaskType(taskId, "requirements");
1106
+ return this.completeTaskOutputs(taskId, [{ kind: "requirements", value: requirements }]);
1107
+ }
1108
+ async completeArchitecture(taskId, architecture, costEstimate, decisionManifest) {
1109
+ await this.assertTaskType(taskId, "architecture");
1110
+ return this.completeTaskOutputs(taskId, [
1111
+ { kind: "architecture", value: architecture },
1112
+ { kind: "cost-estimate", value: costEstimate },
1113
+ { kind: "workload-decision-manifest", value: decisionManifest },
1114
+ ]);
1115
+ }
1116
+ async completeReview(taskId, findings) {
1117
+ const context = await this.taskContext(taskId);
1118
+ if (!context.task.taskType.endsWith("-review")) {
1119
+ throw new ApexError("APEX_AUTHORIZATION", "Task is not a review task", EXIT_CODES.authorization);
1120
+ }
1121
+ const template = context.outputTemplates["review-findings"];
1122
+ if (template === undefined)
1123
+ throw new ApexError("APEX_STALE", "Review template is unavailable", EXIT_CODES.stale);
1124
+ return this.completeTaskOutputs(taskId, [
1125
+ {
1126
+ kind: "review-findings",
1127
+ value: {
1128
+ ...template,
1129
+ findings: findings.map((finding) => ({
1130
+ ...finding,
1131
+ disposition: "open",
1132
+ evidenceRefs: [template.subjectHash],
1133
+ })),
1134
+ },
1135
+ },
1136
+ ]);
1137
+ }
1138
+ async completePlan(taskId, intent, binding, environmentInputs) {
1139
+ return this.completeTaskOutputs(taskId, [
1140
+ { kind: "implementation-intent", value: intent },
1141
+ { kind: "iac-binding", value: { ...binding, intentHash: sha256Json(intent) } },
1142
+ { kind: "environment-inputs", value: environmentInputs },
1143
+ ]);
1144
+ }
1145
+ async assertTaskType(taskId, expected) {
1146
+ const run = await this.currentRun();
1147
+ const task = await this.readTask(run, taskId);
1148
+ if (task.taskType !== expected) {
1149
+ throw new ApexError("APEX_AUTHORIZATION", `Task is not the ${expected} task`, EXIT_CODES.authorization);
1150
+ }
1151
+ }
1019
1152
  async acceptTaskOutputs(taskId, outputs, legacy) {
1020
1153
  const run = await this.currentRun();
1021
1154
  const task = await this.readTask(run, taskId);
@@ -1389,6 +1522,114 @@ export class ApexService {
1389
1522
  }
1390
1523
  }
1391
1524
  }
1525
+ async decideReview(reviewHash, decisions) {
1526
+ if (decisions.length === 0 || new Set(decisions.map(({ findingId }) => findingId)).size !== decisions.length) {
1527
+ throw new ApexError("APEX_VALIDATION", "Review decisions must be nonempty and unique", EXIT_CODES.validation);
1528
+ }
1529
+ const run = await this.currentRun();
1530
+ const events = await this.journal(run).replay();
1531
+ const reviewEvent = [...events]
1532
+ .reverse()
1533
+ .find((event) => event.type === "task.completed" && event.payload.reviewHash === reviewHash);
1534
+ if (reviewEvent === undefined)
1535
+ throw new ApexError("APEX_STALE", "Review is not current", EXIT_CODES.stale);
1536
+ const payload = reviewEvent.payload;
1537
+ const openIds = this.reviewBlockers(events, String(payload.nodeId)).map((blocker) => blocker.split(" ")[2]);
1538
+ if (decisions.some(({ findingId }) => !openIds.includes(findingId))) {
1539
+ throw new ApexError("APEX_STALE", "Review decisions do not match current open findings", EXIT_CODES.stale);
1540
+ }
1541
+ const revisions = decisions.filter(({ action }) => action === "revise");
1542
+ if (revisions.length > 0) {
1543
+ await this.reviseReview(reviewHash, revisions.map(({ findingId }) => findingId), revisions.map(({ rationale }) => rationale).join("; "));
1544
+ return { status: "revision_requested" };
1545
+ }
1546
+ if (decisions.length !== openIds.length) {
1547
+ throw new ApexError("APEX_VALIDATION", "Every open finding requires a decision", EXIT_CODES.validation);
1548
+ }
1549
+ for (const decision of decisions) {
1550
+ await this.resolveReview({
1551
+ findingId: decision.findingId,
1552
+ reviewHash,
1553
+ subjectHash: String(payload.subjectHash),
1554
+ dependencyHash: String(payload.dependencyHash),
1555
+ disposition: "accepted-risk",
1556
+ actor: userInfo().username,
1557
+ rationale: decision.rationale,
1558
+ evidenceRefs: [],
1559
+ ...(decision.expiresAt === undefined ? {} : { expiresAt: decision.expiresAt }),
1560
+ });
1561
+ }
1562
+ return { status: "resolved" };
1563
+ }
1564
+ async pendingReview(events, gate) {
1565
+ const reviewEvent = [...events]
1566
+ .reverse()
1567
+ .find((event) => event.type === "task.completed" &&
1568
+ typeof event.payload.reviewHash === "string" &&
1569
+ this.reviewBlockers(events, String(event.payload.nodeId)).length > 0);
1570
+ const reviewHash = reviewEvent?.payload?.reviewHash;
1571
+ if (typeof reviewHash !== "string")
1572
+ throw new ApexError("APEX_STALE", "Open review is unavailable", EXIT_CODES.stale);
1573
+ const review = await this.objects.getJson(reviewHash);
1574
+ const unresolved = new Set(this.reviewBlockers(events, String(reviewEvent.payload.nodeId)).map((blocker) => blocker.split(" ")[2]));
1575
+ return {
1576
+ gate,
1577
+ reviewHash,
1578
+ findings: review.findings
1579
+ .filter(({ id }) => unresolved.has(id))
1580
+ .map(({ id, severity, title, detail }) => ({
1581
+ id,
1582
+ severity,
1583
+ title,
1584
+ detail,
1585
+ actions: ["revise", ...(["critical", "high"].includes(severity) ? [] : ["accept-risk"])],
1586
+ })),
1587
+ };
1588
+ }
1589
+ async reviseReview(reviewHash, findingIds, correction) {
1590
+ if (!/^[0-9a-f]{64}$/u.test(reviewHash) || findingIds.length === 0 || correction.trim().length === 0) {
1591
+ throw new ApexError("APEX_VALIDATION", "Review revision request is incomplete", EXIT_CODES.validation);
1592
+ }
1593
+ const run = await this.currentRun();
1594
+ const events = await this.journal(run).replay();
1595
+ const reviewEvent = [...events]
1596
+ .reverse()
1597
+ .find((event) => event.type === "task.completed" && event.payload.reviewHash === reviewHash);
1598
+ if (reviewEvent === undefined) {
1599
+ throw new ApexError("APEX_STALE", "Review is not current", EXIT_CODES.stale);
1600
+ }
1601
+ const reviewNode = String(reviewEvent.payload.nodeId);
1602
+ const descriptor = TASKS.find(({ id }) => id === reviewNode);
1603
+ const subjectNode = descriptor?.reviewSubject;
1604
+ if (subjectNode === undefined)
1605
+ throw new ApexError("APEX_STALE", "Review subject is unavailable", EXIT_CODES.stale);
1606
+ const openIds = new Set(this.reviewBlockers(events, reviewNode).map((blocker) => blocker.split(" ")[2]));
1607
+ if (findingIds.some((id) => !openIds.has(id))) {
1608
+ throw new ApexError("APEX_STALE", "Revision does not match current open findings", EXIT_CODES.stale);
1609
+ }
1610
+ const workflow = await this.lockedWorkflowEngine(run);
1611
+ const invalidatedNodes = [
1612
+ subjectNode,
1613
+ reviewNode,
1614
+ ...workflow.invalidationPlan(subjectNode, correction.trim()).map(({ nodeId }) => nodeId),
1615
+ ];
1616
+ const uniqueNodes = [...new Set(invalidatedNodes)];
1617
+ const artifactKinds = TASKS.filter(({ id }) => uniqueNodes.includes(id)).flatMap(({ outputs }) => outputs);
1618
+ await this.append(run, "workflow.invalidated", {
1619
+ reason: correction.trim(),
1620
+ findingIds,
1621
+ reviewHash,
1622
+ nodeIds: uniqueNodes,
1623
+ artifactKinds: [...new Set(artifactKinds)],
1624
+ });
1625
+ const dependencyHash = this.dependencyRevision(run, await this.journal(run).replay());
1626
+ await atomicWriteJson(this.runPath(run), {
1627
+ ...run,
1628
+ gates: run.gates.map((gate) => uniqueNodes.includes(`gate-${gate.gate}`)
1629
+ ? invalidateGate(gate, dependencyHash, `${subjectNode} revision requested`)
1630
+ : gate),
1631
+ });
1632
+ }
1392
1633
  assertReviewResolution(value) {
1393
1634
  const hashes = [value.reviewHash, value.subjectHash, value.dependencyHash, ...value.evidenceRefs];
1394
1635
  if (!value.findingId ||
@@ -1489,6 +1730,16 @@ export class ApexService {
1489
1730
  await this.materializeOperationsApprovalPackage(run, approval, approvalHash);
1490
1731
  return approval;
1491
1732
  }
1733
+ async decideInteractiveGate(gateNumber, decision) {
1734
+ let actor = "local-user";
1735
+ try {
1736
+ actor = userInfo().username.trim() || actor;
1737
+ }
1738
+ catch {
1739
+ // Some minimal container identities do not have an OS user entry.
1740
+ }
1741
+ return this.decideGateNumber(gateNumber, decision, actor);
1742
+ }
1492
1743
  async preview(options) {
1493
1744
  const run = await this.currentRun();
1494
1745
  await this.assertCurrentWriterAuthority(run, new WriterTransferStore(this.projects.runDirectory(run.projectId, run.runId), this.clock));
@@ -2437,17 +2688,22 @@ export class ApexService {
2437
2688
  typeof event.payload.requestId === "string"
2438
2689
  ? [event.payload.requestId]
2439
2690
  : []));
2691
+ const catalog = this.requirementsIntakeCatalog(requests);
2440
2692
  const latest = requests.at(-1);
2441
2693
  if (latest !== undefined && !recordedRequestIds.has(latest.payload.requestId)) {
2442
2694
  const intake = latest.payload.intake;
2443
2695
  const ordinal = intake.ordinal;
2444
- const round = typeof ordinal === "number" && Number.isInteger(ordinal) ? REQUIREMENTS_INTAKE[ordinal - 1] : undefined;
2445
- if (round === undefined || intake.round !== round.round || intake.total !== REQUIREMENTS_INTAKE.length) {
2696
+ const round = typeof ordinal === "number" && Number.isInteger(ordinal) ? catalog[ordinal - 1] : undefined;
2697
+ if (round === undefined || intake.round !== round.round || intake.total !== catalog.length) {
2446
2698
  throw new ApexError("APEX_CONFLICT", "Requirements intake is incompatible; start a new run", EXIT_CODES.conflict);
2447
2699
  }
2448
2700
  return round;
2449
2701
  }
2450
- return REQUIREMENTS_INTAKE[recordedRequestIds.size];
2702
+ return catalog[recordedRequestIds.size];
2703
+ }
2704
+ requirementsIntakeCatalog(requests) {
2705
+ const total = requests[0]?.payload?.intake?.total;
2706
+ return total === LEGACY_REQUIREMENTS_INTAKE.length ? LEGACY_REQUIREMENTS_INTAKE : REQUIREMENTS_INTAKE;
2451
2707
  }
2452
2708
  async issueRequirementsInput(run, round) {
2453
2709
  const events = await this.journal(run).replay();
@@ -2463,36 +2719,129 @@ export class ApexService {
2463
2719
  return this.inputRequest(latest, run.ownerEpoch);
2464
2720
  }
2465
2721
  }
2466
- const ordinal = REQUIREMENTS_INTAKE.findIndex(({ round: id }) => id === round.round) + 1;
2722
+ const catalog = this.requirementsIntakeCatalog(events.filter((event) => event.type === "requirements.input-requested"));
2723
+ const ordinal = catalog.findIndex((candidate) => candidate === round) + 1;
2467
2724
  const event = await this.append(run, "requirements.input-requested", {
2468
2725
  requestId: this.idSource(),
2469
- intake: { round: round.round, ordinal, total: 4 },
2726
+ intake: { round: round.round, ordinal, total: catalog.length },
2470
2727
  questions,
2471
2728
  });
2472
2729
  return this.inputRequest(event, run.ownerEpoch);
2473
2730
  }
2474
2731
  requirementsIntakeQuestions(round, events) {
2475
- if (round.round === "service-preferences") {
2476
- const scenario = this.recordedRequirementsInput(events)?.["delivery-scenario"];
2477
- if (scenario !== "migration" && scenario !== "modernization" && scenario !== "extension")
2478
- return round.questions;
2479
- return [
2480
- { id: "retained-services", prompt: "List Azure services or integrations that must be retained." },
2481
- ...round.questions,
2482
- ];
2732
+ const hasServicePreferences = round.questions.some(({ id }) => id === "service-preferences");
2733
+ if (round.round === "service-preferences" || (round.round === "workload-pattern" && hasServicePreferences)) {
2734
+ const input = this.recordedRequirementsInput(events);
2735
+ const scenario = input?.["delivery-scenario"];
2736
+ const workload = typeof input?.workload === "string" ? input.workload : "";
2737
+ const pattern = this.recommendedWorkloadPattern(workload);
2738
+ const recommendationInput = { ...(input ?? {}), "workload-pattern": pattern };
2739
+ const questions = round.questions.map((question) => question.id === "service-preferences"
2740
+ ? {
2741
+ ...question,
2742
+ prompt: "Confirm the recommended Azure service candidates or select alternatives; Architecture makes the final choice.",
2743
+ recommendation: {
2744
+ value: this.recommendedServiceCandidates(recommendationInput),
2745
+ source: "prior-answer",
2746
+ rationale: "Derived from the confirmed workload description as non-binding Architecture candidates.",
2747
+ },
2748
+ }
2749
+ : question.id === "workload-pattern"
2750
+ ? {
2751
+ ...question,
2752
+ recommendation: {
2753
+ value: pattern,
2754
+ source: "derived",
2755
+ rationale: "Derived from the confirmed workload description; confirm or choose another pattern.",
2756
+ },
2757
+ }
2758
+ : question.id === "scale"
2759
+ ? { ...question, prompt: this.workloadScalePrompt(pattern) }
2760
+ : question);
2761
+ const retained = scenario === "migration" || scenario === "modernization" || scenario === "extension"
2762
+ ? [{ id: "retained-services", prompt: "List Azure services or integrations that must be retained." }]
2763
+ : [];
2764
+ if (round.round === "service-preferences")
2765
+ return [...retained, ...questions];
2766
+ const migration = scenario === "migration" || scenario === "modernization"
2767
+ ? [
2768
+ { id: "current-platform", prompt: "Describe the current platform and hosting model." },
2769
+ {
2770
+ id: "migration-pain-points",
2771
+ prompt: "Describe the problems the migration or modernization must address.",
2772
+ },
2773
+ { id: "preserve-components", prompt: "List components, integrations, or data that must be preserved." },
2774
+ ]
2775
+ : [];
2776
+ return [...questions, ...retained, ...migration];
2483
2777
  }
2484
2778
  if (round.round !== "workload-pattern")
2485
2779
  return round.questions;
2486
- const scenario = this.recordedRequirementsInput(events)?.["delivery-scenario"];
2780
+ const input = this.recordedRequirementsInput(events);
2781
+ const scenario = input?.["delivery-scenario"];
2782
+ const workload = typeof input?.workload === "string" ? input.workload : "";
2783
+ const pattern = this.recommendedWorkloadPattern(workload);
2784
+ const questions = round.questions.map((question) => question.id === "workload-pattern"
2785
+ ? {
2786
+ ...question,
2787
+ recommendation: {
2788
+ value: pattern,
2789
+ source: "derived",
2790
+ rationale: "Derived from the confirmed workload description; confirm or choose another pattern.",
2791
+ },
2792
+ }
2793
+ : question.id === "scale"
2794
+ ? { ...question, prompt: this.workloadScalePrompt(pattern) }
2795
+ : question);
2487
2796
  if (scenario !== "migration" && scenario !== "modernization")
2488
- return round.questions;
2797
+ return questions;
2489
2798
  return [
2490
- ...round.questions,
2799
+ ...questions,
2491
2800
  { id: "current-platform", prompt: "Describe the current platform and hosting model." },
2492
2801
  { id: "migration-pain-points", prompt: "Describe the problems the migration or modernization must address." },
2493
2802
  { id: "preserve-components", prompt: "List components, integrations, or data that must be preserved." },
2494
2803
  ];
2495
2804
  }
2805
+ recommendedWorkloadPattern(workload) {
2806
+ const normalized = workload.toLowerCase();
2807
+ if (/\b(iot|device|sensor|telemetry)\b/u.test(normalized))
2808
+ return "iot";
2809
+ if (/\b(analytics|warehouse|lakehouse|reporting|etl)\b/u.test(normalized))
2810
+ return "data-analytics";
2811
+ if (/\b(batch|scheduled|nightly|cron)\b/u.test(normalized))
2812
+ return "batch";
2813
+ if (/\b(event|queue|stream|message|asynchronous)\b/u.test(normalized))
2814
+ return "event-driven";
2815
+ return "web-api";
2816
+ }
2817
+ workloadScalePrompt(pattern) {
2818
+ const prompts = {
2819
+ "web-api": "Describe normal and peak concurrency, request or order rate, and latency expectations.",
2820
+ "event-driven": "Describe normal and peak event rates, ordering requirements, and delivery guarantees.",
2821
+ "data-analytics": "Describe ingest rate, retained volume, growth, and data-freshness expectations.",
2822
+ iot: "Describe device count, message rate, payload size, and offline behavior.",
2823
+ batch: "Describe schedule, data volume, expected duration, and completion window.",
2824
+ };
2825
+ return prompts[pattern];
2826
+ }
2827
+ recommendedServiceCandidates(input) {
2828
+ const pattern = input?.["workload-pattern"];
2829
+ const budget = input?.budget;
2830
+ const lowBudget = typeof budget === "object" && budget !== null && !Array.isArray(budget) && budget.kind === "budget"
2831
+ ? budget.amount < 1_000
2832
+ : false;
2833
+ const candidates = {
2834
+ "web-api": [lowBudget ? "azure-functions" : "app-service", "azure-sql", "storage", "service-bus"],
2835
+ "event-driven": ["azure-functions", "service-bus", "storage"],
2836
+ "data-analytics": ["azure-functions", "storage", "event-hubs"],
2837
+ iot: ["azure-functions", "event-hubs", "storage"],
2838
+ batch: ["azure-functions", "storage", "service-bus"],
2839
+ };
2840
+ const selected = typeof pattern === "string" && pattern in candidates
2841
+ ? candidates[pattern]
2842
+ : candidates["web-api"];
2843
+ return [...selected, "azure-monitor", "application-insights"];
2844
+ }
2496
2845
  async append(run, type, payload, expectedHead) {
2497
2846
  const journal = this.journal(run);
2498
2847
  return await journal.append({
@@ -2547,11 +2896,12 @@ export class ApexService {
2547
2896
  throw new ApexError("APEX_CONFLICT", "Requirements intake is incompatible; start a new run", EXIT_CODES.conflict);
2548
2897
  }
2549
2898
  const intake = request.intake;
2550
- const round = intake === undefined ? undefined : REQUIREMENTS_INTAKE[intake.ordinal - 1];
2899
+ const catalog = intake?.total === LEGACY_REQUIREMENTS_INTAKE.length ? LEGACY_REQUIREMENTS_INTAKE : REQUIREMENTS_INTAKE;
2900
+ const round = intake === undefined ? undefined : catalog[intake.ordinal - 1];
2551
2901
  if (round === undefined ||
2552
2902
  intake === undefined ||
2553
2903
  round.round !== intake.round ||
2554
- intake.total !== REQUIREMENTS_INTAKE.length) {
2904
+ intake.total !== catalog.length) {
2555
2905
  throw new ApexError("APEX_CONFLICT", "Requirements intake is incompatible; start a new run", EXIT_CODES.conflict);
2556
2906
  }
2557
2907
  }
@@ -2578,6 +2928,10 @@ export class ApexService {
2578
2928
  }
2579
2929
  artifactHash(events, kind) {
2580
2930
  for (const event of [...events].reverse()) {
2931
+ if (event.type === "workflow.invalidated" &&
2932
+ event.payload.artifactKinds?.includes?.(kind)) {
2933
+ return undefined;
2934
+ }
2581
2935
  if (event.type !== "task.completed")
2582
2936
  continue;
2583
2937
  const hashes = event.payload.artifactHashes;
@@ -2598,6 +2952,14 @@ export class ApexService {
2598
2952
  "implementation-intent": "intentHash",
2599
2953
  };
2600
2954
  for (const event of events) {
2955
+ if (event.type === "workflow.invalidated") {
2956
+ const kinds = event.payload.artifactKinds;
2957
+ if (Array.isArray(kinds))
2958
+ for (const kind of kinds)
2959
+ if (typeof kind === "string")
2960
+ delete hashes[kind];
2961
+ continue;
2962
+ }
2601
2963
  if (event.type !== "task.completed")
2602
2964
  continue;
2603
2965
  const payload = event.payload;
@@ -2663,7 +3025,7 @@ export class ApexService {
2663
3025
  }
2664
3026
  return recorded ? result : null;
2665
3027
  }
2666
- outputTemplate(kind, run, events) {
3028
+ outputTemplate(kind, run, events, taskType) {
2667
3029
  if (kind === "requirements") {
2668
3030
  const input = this.recordedRequirementsInput(events);
2669
3031
  return {
@@ -2671,7 +3033,34 @@ export class ApexService {
2671
3033
  projectId: run.projectId,
2672
3034
  workload: typeof input?.workload === "string" ? input.workload : "",
2673
3035
  environment: run.environment,
2674
- ...this.requirementsTemplateFromIntake(input),
3036
+ ...this.requirementsTemplateFromIntake({ ...(input ?? {}), "iac-preference": run.iacTool }),
3037
+ };
3038
+ }
3039
+ if (kind === "review-findings") {
3040
+ const review = TASKS.find(({ id }) => id === taskType);
3041
+ const subjectKind = review?.reviewSubject === "governance-reconciliation" ? "policy-property-map" : review?.reviewSubject;
3042
+ const artifactKind = subjectKind === "plan" ? "implementation-intent" : subjectKind;
3043
+ const subjectHash = artifactKind === undefined ? undefined : this.artifactHash(events, artifactKind);
3044
+ if (subjectKind === undefined || subjectHash === undefined) {
3045
+ throw new ApexError("APEX_STALE", "Review subject is unavailable", EXIT_CODES.stale);
3046
+ }
3047
+ return {
3048
+ schemaVersion: CONTRACT_VERSION,
3049
+ projectId: run.projectId,
3050
+ runId: run.runId,
3051
+ subjectKind,
3052
+ subjectHash,
3053
+ reviewedAt: this.clock().toISOString(),
3054
+ findings: [
3055
+ {
3056
+ id: "FINDING-001",
3057
+ severity: "medium",
3058
+ disposition: "open",
3059
+ title: "Concise finding title",
3060
+ detail: "Evidence, impact, and concrete remediation.",
3061
+ evidenceRefs: [subjectHash],
3062
+ },
3063
+ ],
2675
3064
  };
2676
3065
  }
2677
3066
  if (kind === "implementation-intent") {
@@ -2833,17 +3222,28 @@ export class ApexService {
2833
3222
  }
2834
3223
  }
2835
3224
  completedNodeIds(events) {
2836
- return new Set(events.flatMap((event) => {
3225
+ const completed = new Set();
3226
+ for (const event of events) {
3227
+ if (event.type === "workflow.invalidated") {
3228
+ const nodeIds = event.payload.nodeIds;
3229
+ if (Array.isArray(nodeIds))
3230
+ for (const nodeId of nodeIds)
3231
+ if (typeof nodeId === "string")
3232
+ completed.delete(nodeId);
3233
+ continue;
3234
+ }
2837
3235
  if (event.type !== "task.completed")
2838
- return [];
3236
+ continue;
2839
3237
  const nodeId = event.payload.nodeId;
2840
- return typeof nodeId === "string" ? [nodeId] : [];
2841
- }));
3238
+ if (typeof nodeId === "string")
3239
+ completed.add(nodeId);
3240
+ }
3241
+ return completed;
2842
3242
  }
2843
3243
  async lockedWorkflowEngine(run) {
2844
- const runtimeRoot = join(this.root, ".apex", "runtime");
3244
+ const runtimeRoot = await this.runtimeRootForRun(run);
2845
3245
  const workflowBytes = await readFile(join(runtimeRoot, "workflow.v1.json"));
2846
- const lock = JSON.parse(await readFile(join(this.root, ".apex", "apex.lock.json"), "utf8"));
3246
+ const lock = JSON.parse(await readFile(join(runtimeRoot, "apex.lock.json"), "utf8"));
2847
3247
  this.assertValid("runtime-lock", lock);
2848
3248
  if (sha256Json(lock) !== run.runtimeLockHash || sha256Bytes(workflowBytes) !== lock.workflowHash) {
2849
3249
  throw new ApexError("APEX_STALE", "Runtime workflow lock is not current", EXIT_CODES.stale);
@@ -3162,7 +3562,7 @@ export class ApexService {
3162
3562
  ...(availabilityEvidence === undefined ? {} : { availabilityEvidence }),
3163
3563
  ...(deploymentOperation === undefined ? {} : { deploymentOperation }),
3164
3564
  ...(deploymentInventory === undefined ? {} : { deploymentInventory }),
3165
- ...(nodeId === "quality" ? await this.qualityValidatorContext() : {}),
3565
+ ...(nodeId === "quality" ? await this.qualityValidatorContext(run) : {}),
3166
3566
  };
3167
3567
  for (const id of validatorIds) {
3168
3568
  this.assertValid(id, taskWorkflowValidatorInput(id, context));
@@ -3173,7 +3573,7 @@ export class ApexService {
3173
3573
  evidenceModes: availabilityEvidence === undefined ? {} : { "business:availability-current": availabilityEvidence.mode },
3174
3574
  };
3175
3575
  }
3176
- async qualityValidatorContext() {
3576
+ async qualityValidatorContext(run) {
3177
3577
  let qualityMeasurements;
3178
3578
  try {
3179
3579
  qualityMeasurements = JSON.parse(await readFile(join(this.root, ".apex", "quality", "measurements.json"), "utf8"));
@@ -3185,8 +3585,9 @@ export class ApexService {
3185
3585
  throw error;
3186
3586
  }
3187
3587
  this.assertValid("quality-measurements", qualityMeasurements);
3588
+ const runtimeRoot = await this.runtimeRootForRun(run);
3188
3589
  return {
3189
- scorecard: JSON.parse(await readFile(join(this.root, ".apex", "runtime", "quality-scorecard.v1.json"), "utf8")),
3590
+ scorecard: JSON.parse(await readFile(join(runtimeRoot, "quality-scorecard.v1.json"), "utf8")),
3190
3591
  qualityMeasurements,
3191
3592
  };
3192
3593
  }
@@ -3577,9 +3978,10 @@ export class ApexService {
3577
3978
  this.improvementRuntime ??= new ImprovementStore(this.root, this.improvementPolicy, this.clock);
3578
3979
  return this.improvementRuntime;
3579
3980
  }
3981
+ const runtimeRoot = await this.runtimeRootForRun(run);
3580
3982
  const [policyBytes, lockBytes] = await Promise.all([
3581
- readFile(join(this.root, ".apex", "runtime", "improvement-policy.v1.json")),
3582
- readFile(join(this.root, ".apex", "apex.lock.json")),
3983
+ readFile(join(runtimeRoot, "improvement-policy.v1.json")),
3984
+ readFile(join(runtimeRoot, "apex.lock.json")),
3583
3985
  ]);
3584
3986
  const lock = JSON.parse(lockBytes.toString("utf8"));
3585
3987
  this.assertValid("runtime-lock", lock);
@@ -3644,6 +4046,28 @@ export class ApexService {
3644
4046
  this.assertValid("runtime-lock", lock);
3645
4047
  return lock;
3646
4048
  }
4049
+ async installRuntimeGeneration(lock) {
4050
+ const hash = sha256Json(lock);
4051
+ const generationsRoot = join(this.root, ".apex", "runtime-generations");
4052
+ const destination = join(generationsRoot, hash);
4053
+ if (!(await this.exists(destination))) {
4054
+ await mkdir(generationsRoot, { recursive: true });
4055
+ const staging = join(generationsRoot, `.incoming-${this.idSource()}`);
4056
+ await cp(join(this.root, ".apex", "runtime"), staging, {
4057
+ recursive: true,
4058
+ dereference: false,
4059
+ errorOnExist: true,
4060
+ force: false,
4061
+ });
4062
+ await atomicWriteJson(join(staging, "apex.lock.json"), lock);
4063
+ await rename(staging, destination);
4064
+ }
4065
+ return destination;
4066
+ }
4067
+ async runtimeRootForRun(run) {
4068
+ const generation = join(this.root, ".apex", "runtime-generations", run.runtimeLockHash);
4069
+ return (await this.exists(join(generation, "apex.lock.json"))) ? generation : join(this.root, ".apex", "runtime");
4070
+ }
3647
4071
  capabilityPacks(manifestPath) {
3648
4072
  return new CapabilityPackManager({
3649
4073
  root: this.root,
@@ -3652,11 +4076,11 @@ export class ApexService {
3652
4076
  });
3653
4077
  }
3654
4078
  async runtimeLockChecks(run) {
3655
- const path = join(this.root, ".apex", "apex.lock.json");
3656
4079
  try {
3657
- const lock = JSON.parse(await readFile(path, "utf8"));
4080
+ const runtimeRoot = await this.runtimeRootForRun(run);
4081
+ const lockPath = join(runtimeRoot, "apex.lock.json");
4082
+ const lock = JSON.parse(await readFile(lockPath, "utf8"));
3658
4083
  this.assertValid("runtime-lock", lock);
3659
- const runtimeRoot = join(this.root, ".apex", "runtime");
3660
4084
  const validatorPath = (await this.exists(join(runtimeRoot, "runtime-bundle.v1.json")))
3661
4085
  ? join(runtimeRoot, "runtime-bundle.v1.json")
3662
4086
  : join(runtimeRoot, "toolchain.v1.json");
@@ -3884,7 +4308,8 @@ export class ApexService {
3884
4308
  }
3885
4309
  }
3886
4310
  async evidenceStore(kind, contentType) {
3887
- const defaults = JSON.parse(await readFile(join(this.root, ".apex", "runtime", "defaults.v1.json"), "utf8"));
4311
+ const runtimeRoot = await this.runtimeRootForRun(await this.currentRun());
4312
+ const defaults = JSON.parse(await readFile(join(runtimeRoot, "defaults.v1.json"), "utf8"));
3888
4313
  const maxBytes = defaults.evidence?.budgets?.perEntryBytes;
3889
4314
  if (!Number.isInteger(maxBytes) || maxBytes === undefined || maxBytes < 1)
3890
4315
  throw new ApexError("APEX_VALIDATION", "Runtime evidence byte budget is invalid", EXIT_CODES.validation);