@apexops/cli 0.10.0-next.4 → 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 (30) hide show
  1. package/README.md +40 -0
  2. package/assets/client-projection-metadata/copilot-cli-agent-tools.json +8 -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 +7 -5
  5. package/assets/client-projections/github-copilot-cli/.github/agents/apex-requirements.agent.md +18 -11
  6. package/assets/client-projections/github-copilot-cli/.github/mcp.json +5 -0
  7. package/assets/client-projections/github-copilot-cli/.github/skills/apex-requirements/SKILL.md +7 -4
  8. package/assets/client-projections/github-copilot-vscode/.github/agents/apex-architect.agent.md +9 -8
  9. package/assets/client-projections/github-copilot-vscode/.github/agents/apex-planner.agent.md +7 -5
  10. package/assets/client-projections/github-copilot-vscode/.github/agents/apex-requirements.agent.md +18 -11
  11. package/assets/client-projections/github-copilot-vscode/.github/agents/apex-reviewer.agent.md +9 -5
  12. package/assets/client-projections/github-copilot-vscode/.github/skills/apex-requirements/SKILL.md +7 -4
  13. package/assets/config/runtime-bundle.v1.json +7 -7
  14. package/assets/customizations/.github/agents/apex-architect.agent.md +9 -8
  15. package/assets/customizations/.github/agents/apex-planner.agent.md +7 -5
  16. package/assets/customizations/.github/agents/apex-requirements.agent.md +18 -11
  17. package/assets/customizations/.github/agents/apex-reviewer.agent.md +9 -5
  18. package/assets/customizations/.github/mcp.json +5 -0
  19. package/assets/customizations/.github/skills/apex-requirements/SKILL.md +7 -4
  20. package/assets/customizations/manifest.json +1 -1
  21. package/assets/manifest.json +50 -50
  22. package/dist/cli.d.ts +1 -0
  23. package/dist/cli.js +42 -2
  24. package/dist/mcp.js +58 -16
  25. package/dist/service.d.ts +41 -1
  26. package/dist/service.js +448 -40
  27. package/dist/state-transfer.js +23 -6
  28. package/dist/version.d.ts +1 -1
  29. package/dist/version.js +1 -1
  30. package/package.json +5 -5
package/dist/service.js CHANGED
@@ -1,7 +1,7 @@
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";
@@ -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,40 @@ 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
+ }
1019
1138
  async completePlan(taskId, intent, binding, environmentInputs) {
1020
1139
  return this.completeTaskOutputs(taskId, [
1021
1140
  { kind: "implementation-intent", value: intent },
@@ -1023,6 +1142,13 @@ export class ApexService {
1023
1142
  { kind: "environment-inputs", value: environmentInputs },
1024
1143
  ]);
1025
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
+ }
1026
1152
  async acceptTaskOutputs(taskId, outputs, legacy) {
1027
1153
  const run = await this.currentRun();
1028
1154
  const task = await this.readTask(run, taskId);
@@ -1396,6 +1522,114 @@ export class ApexService {
1396
1522
  }
1397
1523
  }
1398
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
+ }
1399
1633
  assertReviewResolution(value) {
1400
1634
  const hashes = [value.reviewHash, value.subjectHash, value.dependencyHash, ...value.evidenceRefs];
1401
1635
  if (!value.findingId ||
@@ -2454,17 +2688,22 @@ export class ApexService {
2454
2688
  typeof event.payload.requestId === "string"
2455
2689
  ? [event.payload.requestId]
2456
2690
  : []));
2691
+ const catalog = this.requirementsIntakeCatalog(requests);
2457
2692
  const latest = requests.at(-1);
2458
2693
  if (latest !== undefined && !recordedRequestIds.has(latest.payload.requestId)) {
2459
2694
  const intake = latest.payload.intake;
2460
2695
  const ordinal = intake.ordinal;
2461
- const round = typeof ordinal === "number" && Number.isInteger(ordinal) ? REQUIREMENTS_INTAKE[ordinal - 1] : undefined;
2462
- 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) {
2463
2698
  throw new ApexError("APEX_CONFLICT", "Requirements intake is incompatible; start a new run", EXIT_CODES.conflict);
2464
2699
  }
2465
2700
  return round;
2466
2701
  }
2467
- 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;
2468
2707
  }
2469
2708
  async issueRequirementsInput(run, round) {
2470
2709
  const events = await this.journal(run).replay();
@@ -2480,36 +2719,129 @@ export class ApexService {
2480
2719
  return this.inputRequest(latest, run.ownerEpoch);
2481
2720
  }
2482
2721
  }
2483
- 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;
2484
2724
  const event = await this.append(run, "requirements.input-requested", {
2485
2725
  requestId: this.idSource(),
2486
- intake: { round: round.round, ordinal, total: 4 },
2726
+ intake: { round: round.round, ordinal, total: catalog.length },
2487
2727
  questions,
2488
2728
  });
2489
2729
  return this.inputRequest(event, run.ownerEpoch);
2490
2730
  }
2491
2731
  requirementsIntakeQuestions(round, events) {
2492
- if (round.round === "service-preferences") {
2493
- const scenario = this.recordedRequirementsInput(events)?.["delivery-scenario"];
2494
- if (scenario !== "migration" && scenario !== "modernization" && scenario !== "extension")
2495
- return round.questions;
2496
- return [
2497
- { id: "retained-services", prompt: "List Azure services or integrations that must be retained." },
2498
- ...round.questions,
2499
- ];
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];
2500
2777
  }
2501
2778
  if (round.round !== "workload-pattern")
2502
2779
  return round.questions;
2503
- 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);
2504
2796
  if (scenario !== "migration" && scenario !== "modernization")
2505
- return round.questions;
2797
+ return questions;
2506
2798
  return [
2507
- ...round.questions,
2799
+ ...questions,
2508
2800
  { id: "current-platform", prompt: "Describe the current platform and hosting model." },
2509
2801
  { id: "migration-pain-points", prompt: "Describe the problems the migration or modernization must address." },
2510
2802
  { id: "preserve-components", prompt: "List components, integrations, or data that must be preserved." },
2511
2803
  ];
2512
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
+ }
2513
2845
  async append(run, type, payload, expectedHead) {
2514
2846
  const journal = this.journal(run);
2515
2847
  return await journal.append({
@@ -2564,11 +2896,12 @@ export class ApexService {
2564
2896
  throw new ApexError("APEX_CONFLICT", "Requirements intake is incompatible; start a new run", EXIT_CODES.conflict);
2565
2897
  }
2566
2898
  const intake = request.intake;
2567
- 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];
2568
2901
  if (round === undefined ||
2569
2902
  intake === undefined ||
2570
2903
  round.round !== intake.round ||
2571
- intake.total !== REQUIREMENTS_INTAKE.length) {
2904
+ intake.total !== catalog.length) {
2572
2905
  throw new ApexError("APEX_CONFLICT", "Requirements intake is incompatible; start a new run", EXIT_CODES.conflict);
2573
2906
  }
2574
2907
  }
@@ -2595,6 +2928,10 @@ export class ApexService {
2595
2928
  }
2596
2929
  artifactHash(events, kind) {
2597
2930
  for (const event of [...events].reverse()) {
2931
+ if (event.type === "workflow.invalidated" &&
2932
+ event.payload.artifactKinds?.includes?.(kind)) {
2933
+ return undefined;
2934
+ }
2598
2935
  if (event.type !== "task.completed")
2599
2936
  continue;
2600
2937
  const hashes = event.payload.artifactHashes;
@@ -2615,6 +2952,14 @@ export class ApexService {
2615
2952
  "implementation-intent": "intentHash",
2616
2953
  };
2617
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
+ }
2618
2963
  if (event.type !== "task.completed")
2619
2964
  continue;
2620
2965
  const payload = event.payload;
@@ -2680,7 +3025,7 @@ export class ApexService {
2680
3025
  }
2681
3026
  return recorded ? result : null;
2682
3027
  }
2683
- outputTemplate(kind, run, events) {
3028
+ outputTemplate(kind, run, events, taskType) {
2684
3029
  if (kind === "requirements") {
2685
3030
  const input = this.recordedRequirementsInput(events);
2686
3031
  return {
@@ -2688,7 +3033,34 @@ export class ApexService {
2688
3033
  projectId: run.projectId,
2689
3034
  workload: typeof input?.workload === "string" ? input.workload : "",
2690
3035
  environment: run.environment,
2691
- ...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
+ ],
2692
3064
  };
2693
3065
  }
2694
3066
  if (kind === "implementation-intent") {
@@ -2850,17 +3222,28 @@ export class ApexService {
2850
3222
  }
2851
3223
  }
2852
3224
  completedNodeIds(events) {
2853
- 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
+ }
2854
3235
  if (event.type !== "task.completed")
2855
- return [];
3236
+ continue;
2856
3237
  const nodeId = event.payload.nodeId;
2857
- return typeof nodeId === "string" ? [nodeId] : [];
2858
- }));
3238
+ if (typeof nodeId === "string")
3239
+ completed.add(nodeId);
3240
+ }
3241
+ return completed;
2859
3242
  }
2860
3243
  async lockedWorkflowEngine(run) {
2861
- const runtimeRoot = join(this.root, ".apex", "runtime");
3244
+ const runtimeRoot = await this.runtimeRootForRun(run);
2862
3245
  const workflowBytes = await readFile(join(runtimeRoot, "workflow.v1.json"));
2863
- 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"));
2864
3247
  this.assertValid("runtime-lock", lock);
2865
3248
  if (sha256Json(lock) !== run.runtimeLockHash || sha256Bytes(workflowBytes) !== lock.workflowHash) {
2866
3249
  throw new ApexError("APEX_STALE", "Runtime workflow lock is not current", EXIT_CODES.stale);
@@ -3179,7 +3562,7 @@ export class ApexService {
3179
3562
  ...(availabilityEvidence === undefined ? {} : { availabilityEvidence }),
3180
3563
  ...(deploymentOperation === undefined ? {} : { deploymentOperation }),
3181
3564
  ...(deploymentInventory === undefined ? {} : { deploymentInventory }),
3182
- ...(nodeId === "quality" ? await this.qualityValidatorContext() : {}),
3565
+ ...(nodeId === "quality" ? await this.qualityValidatorContext(run) : {}),
3183
3566
  };
3184
3567
  for (const id of validatorIds) {
3185
3568
  this.assertValid(id, taskWorkflowValidatorInput(id, context));
@@ -3190,7 +3573,7 @@ export class ApexService {
3190
3573
  evidenceModes: availabilityEvidence === undefined ? {} : { "business:availability-current": availabilityEvidence.mode },
3191
3574
  };
3192
3575
  }
3193
- async qualityValidatorContext() {
3576
+ async qualityValidatorContext(run) {
3194
3577
  let qualityMeasurements;
3195
3578
  try {
3196
3579
  qualityMeasurements = JSON.parse(await readFile(join(this.root, ".apex", "quality", "measurements.json"), "utf8"));
@@ -3202,8 +3585,9 @@ export class ApexService {
3202
3585
  throw error;
3203
3586
  }
3204
3587
  this.assertValid("quality-measurements", qualityMeasurements);
3588
+ const runtimeRoot = await this.runtimeRootForRun(run);
3205
3589
  return {
3206
- 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")),
3207
3591
  qualityMeasurements,
3208
3592
  };
3209
3593
  }
@@ -3594,9 +3978,10 @@ export class ApexService {
3594
3978
  this.improvementRuntime ??= new ImprovementStore(this.root, this.improvementPolicy, this.clock);
3595
3979
  return this.improvementRuntime;
3596
3980
  }
3981
+ const runtimeRoot = await this.runtimeRootForRun(run);
3597
3982
  const [policyBytes, lockBytes] = await Promise.all([
3598
- readFile(join(this.root, ".apex", "runtime", "improvement-policy.v1.json")),
3599
- readFile(join(this.root, ".apex", "apex.lock.json")),
3983
+ readFile(join(runtimeRoot, "improvement-policy.v1.json")),
3984
+ readFile(join(runtimeRoot, "apex.lock.json")),
3600
3985
  ]);
3601
3986
  const lock = JSON.parse(lockBytes.toString("utf8"));
3602
3987
  this.assertValid("runtime-lock", lock);
@@ -3661,6 +4046,28 @@ export class ApexService {
3661
4046
  this.assertValid("runtime-lock", lock);
3662
4047
  return lock;
3663
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
+ }
3664
4071
  capabilityPacks(manifestPath) {
3665
4072
  return new CapabilityPackManager({
3666
4073
  root: this.root,
@@ -3669,11 +4076,11 @@ export class ApexService {
3669
4076
  });
3670
4077
  }
3671
4078
  async runtimeLockChecks(run) {
3672
- const path = join(this.root, ".apex", "apex.lock.json");
3673
4079
  try {
3674
- 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"));
3675
4083
  this.assertValid("runtime-lock", lock);
3676
- const runtimeRoot = join(this.root, ".apex", "runtime");
3677
4084
  const validatorPath = (await this.exists(join(runtimeRoot, "runtime-bundle.v1.json")))
3678
4085
  ? join(runtimeRoot, "runtime-bundle.v1.json")
3679
4086
  : join(runtimeRoot, "toolchain.v1.json");
@@ -3901,7 +4308,8 @@ export class ApexService {
3901
4308
  }
3902
4309
  }
3903
4310
  async evidenceStore(kind, contentType) {
3904
- 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"));
3905
4313
  const maxBytes = defaults.evidence?.budgets?.perEntryBytes;
3906
4314
  if (!Number.isInteger(maxBytes) || maxBytes === undefined || maxBytes < 1)
3907
4315
  throw new ApexError("APEX_VALIDATION", "Runtime evidence byte budget is invalid", EXIT_CODES.validation);