@abelxiaoxing/cadence 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +172 -13
  2. package/agents/design-explorer.md +17 -11
  3. package/agents/diagnosis-worker.md +9 -1
  4. package/agents/implementation-worker.md +3 -1
  5. package/config/plan-draft.example.json +122 -0
  6. package/config/plan-draft.multiple-tasks.example.json +345 -0
  7. package/package.json +9 -5
  8. package/prompts/abel-design.md +141 -38
  9. package/prompts/abel-diagnose.md +10 -1
  10. package/prompts/abel-implement.md +118 -20
  11. package/prompts/abel-init.md +9 -2
  12. package/src/activity-contracts.ts +82 -0
  13. package/src/apply-transaction.ts +16 -14
  14. package/src/artifact-store.ts +17 -0
  15. package/src/candidate-artifact.ts +143 -0
  16. package/src/candidate-patch.ts +33 -12
  17. package/src/candidate-workspace.ts +74 -0
  18. package/src/canonical.ts +16 -0
  19. package/src/change-contract-schema.ts +191 -0
  20. package/src/change-contract.ts +273 -0
  21. package/src/change-verification.ts +896 -0
  22. package/src/child-model.ts +129 -0
  23. package/src/child-session.ts +296 -328
  24. package/src/contracts.ts +325 -109
  25. package/src/control-contracts.ts +51 -1
  26. package/src/delivery-compiler.ts +241 -141
  27. package/src/delivery-revision.ts +42 -0
  28. package/src/design-control.ts +182 -15
  29. package/src/design-diagnostics.ts +150 -0
  30. package/src/design-journal.ts +216 -72
  31. package/src/durable-contracts.ts +210 -0
  32. package/src/durable-workflow.ts +963 -0
  33. package/src/evidence-draft.ts +122 -0
  34. package/src/implement-graph.ts +14 -16
  35. package/src/implement-plan.ts +70 -0
  36. package/src/index.ts +341 -1405
  37. package/src/isolation-backend.ts +84 -23
  38. package/src/model-source.ts +22 -0
  39. package/src/package-candidate.ts +224 -0
  40. package/src/package-delivery.ts +486 -0
  41. package/src/package-verification.ts +509 -0
  42. package/src/package-workflow.ts +253 -0
  43. package/src/packet-runtime.ts +27 -48
  44. package/src/parent-provider.ts +86 -198
  45. package/src/phase-execution.ts +2248 -0
  46. package/src/pi-adapter.ts +19 -0
  47. package/src/plan-draft-summary.ts +146 -0
  48. package/src/plan-draft.ts +493 -0
  49. package/src/route-policy.ts +1 -11
  50. package/src/run-state.ts +4 -16
  51. package/src/run-store.ts +5 -144
  52. package/src/scoped-grep-worker.mjs +23 -0
  53. package/src/scoped-tools.ts +312 -116
  54. package/src/sqlite-schema.ts +171 -0
  55. package/src/storage-schema.ts +352 -0
  56. package/src/subagent-activity.ts +46 -67
  57. package/src/submit-tool.ts +40 -61
  58. package/src/task-ledger.ts +14 -55
  59. package/src/transport-budget.ts +32 -0
  60. package/src/verification-capability.ts +298 -102
  61. package/src/verification-environment.ts +213 -0
  62. package/src/verification-identity.ts +77 -0
  63. package/src/worker-broker.ts +134 -91
  64. package/src/workflow-engine.ts +28 -8285
  65. package/src/workflow-policy.ts +1388 -0
  66. package/src/workflow-recovery-policy.ts +95 -0
  67. package/src/workflow-scheduling.ts +105 -0
  68. package/src/workflow-state-machine.ts +4086 -0
  69. package/src/workflow-status.ts +384 -0
  70. package/src/workspace-io-entry.mjs +58 -0
  71. package/src/workspace-io-worker.mjs +991 -0
  72. package/src/workspace-io.ts +84 -0
  73. package/src/workspace-store.ts +125 -19
  74. package/skills/abel-workflow/SKILL.md +0 -205
  75. package/src/empty-resource-loader.ts +0 -38
  76. package/src/parent-payload-bridge.ts +0 -523
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { createHash, randomUUID } from "node:crypto";
2
3
  import {
3
4
  lstatSync,
@@ -8,9 +9,15 @@ import {
8
9
  writeFileSync,
9
10
  } from "node:fs";
10
11
  import path from "node:path";
12
+ import {
13
+ type ChangeContract,
14
+ normalizeChangeContract,
15
+ } from "./change-contract.ts";
16
+ import { changeContractDiagnostics } from "./change-contract-schema.ts";
11
17
  import { isValidRelativePath } from "./contracts.ts";
12
18
  import {
13
19
  assessDeliveryTraceability,
20
+ bindTaskVerifications,
14
21
  compileGateAReceipt,
15
22
  compileImplementPlan,
16
23
  compileReadyReceipt,
@@ -21,6 +28,7 @@ import {
21
28
  parseImplementPlan,
22
29
  parseReadyReceipt,
23
30
  } from "./delivery-compiler.ts";
31
+ import type { DesignPlanDiagnostic } from "./design-diagnostics.ts";
24
32
  import {
25
33
  type DesignArtifactOperationFacts,
26
34
  type DesignArtifactOperationOutcome,
@@ -28,6 +36,8 @@ import {
28
36
  type DesignStatusProjection,
29
37
  } from "./design-journal.ts";
30
38
  import { OpenSpecCliError, type OpenSpecDiagnostic } from "./openspec-cli.ts";
39
+ import { planAuthoringSummary } from "./plan-draft-summary.ts";
40
+ import { canonicalJson } from "./run-state.ts";
31
41
  import { RunStore } from "./run-store.ts";
32
42
  import { observeSafePath } from "./safe-path.ts";
33
43
  import type { ResolvedStateRoot } from "./state-root.ts";
@@ -39,6 +49,20 @@ const MAX_DESIGN_FILE_BYTES = 16 * 1024 * 1024;
39
49
  const MAX_DESIGN_REQUIREMENT_BYTES = 64 * 1024;
40
50
  const MAX_DESIGN_CONTRACT_BYTES = 256 * 1024;
41
51
 
52
+ function validGateContract(value: unknown): boolean {
53
+ if (typeof value === "string")
54
+ return (
55
+ value.trim().length > 0 &&
56
+ Buffer.byteLength(value, "utf8") <= MAX_DESIGN_CONTRACT_BYTES
57
+ );
58
+ try {
59
+ normalizeChangeContract(value);
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
42
66
  export interface DesignOpenSpecInspection {
43
67
  change: string;
44
68
  schema: string;
@@ -82,7 +106,7 @@ export type DesignControlRequest =
82
106
  runId: string;
83
107
  operationId: string;
84
108
  gate: "gate-a";
85
- contract: string;
109
+ contract: string | ChangeContract;
86
110
  }
87
111
  | {
88
112
  operation: "approve-gate";
@@ -179,7 +203,53 @@ function lifecycleOperationId(kind: string, runId: string): string {
179
203
  .slice(0, 32)}`;
180
204
  }
181
205
 
182
- export function validateDesignControlRequest(
206
+ export class DesignControlValidationError extends DesignPlanValidationError {
207
+ constructor(diagnostics: readonly DesignPlanDiagnostic[]) {
208
+ super("invalid-design-control-request", diagnostics);
209
+ }
210
+ }
211
+ export function validateDesignControlRequest(value: unknown):
212
+ | { ok: true; value: DesignControlRequest }
213
+ | {
214
+ ok: false;
215
+ code: "invalid-design-control-request";
216
+ diagnostics: DesignPlanDiagnostic[];
217
+ } {
218
+ const parsed = parseDesignControlRequest(value);
219
+ if (parsed.ok) return parsed;
220
+ let diagnostics: DesignPlanDiagnostic[] = [];
221
+ if (
222
+ isRecord(value) &&
223
+ value.operation === "approve-gate" &&
224
+ value.gate === "gate-a" &&
225
+ typeof value.contract !== "string"
226
+ )
227
+ diagnostics = changeContractDiagnostics(value.contract);
228
+ if (!diagnostics.length) {
229
+ const field = !isRecord(value)
230
+ ? "request"
231
+ : typeof value.operation !== "string"
232
+ ? "operation"
233
+ : value.operation !== "start" &&
234
+ (typeof value.runId !== "string" || !IDENTIFIER.test(value.runId))
235
+ ? "runId"
236
+ : !["status", "validate-plan-draft"].includes(
237
+ String(value.operation),
238
+ ) &&
239
+ (typeof value.operationId !== "string" ||
240
+ !IDENTIFIER.test(value.operationId))
241
+ ? "operationId"
242
+ : value.operation === "approve-gate"
243
+ ? value.gate === "gate-a"
244
+ ? "contract"
245
+ : "gate"
246
+ : "request";
247
+ diagnostics = [{ code: "design-control-field-invalid", field }];
248
+ }
249
+ return { ...parsed, diagnostics };
250
+ }
251
+
252
+ function parseDesignControlRequest(
183
253
  value: unknown,
184
254
  ):
185
255
  | { ok: true; value: DesignControlRequest }
@@ -275,9 +345,7 @@ export function validateDesignControlRequest(
275
345
  "gate",
276
346
  "contract",
277
347
  ]) &&
278
- typeof value.contract === "string" &&
279
- value.contract.trim().length > 0 &&
280
- Buffer.byteLength(value.contract, "utf8") <= MAX_DESIGN_CONTRACT_BYTES
348
+ validGateContract(value.contract)
281
349
  ? { ok: true, value: structuredClone(value) as DesignControlRequest }
282
350
  : { ok: false, code: "invalid-design-control-request" };
283
351
  }
@@ -459,6 +527,7 @@ export class DesignController {
459
527
  readonly #runs: RunStore;
460
528
  readonly #inspectOpenSpec: DesignControllerOptions["inspectOpenSpec"];
461
529
  #closed = false;
530
+ readonly #authority = new AsyncLocalStorage<() => void>();
462
531
 
463
532
  private constructor(options: DesignControllerOptions) {
464
533
  this.consumerRoot = path.resolve(options.consumerRoot);
@@ -485,7 +554,22 @@ export class DesignController {
485
554
  return new DesignController(options);
486
555
  }
487
556
 
557
+ executeWithAuthority(
558
+ value: unknown,
559
+ assertAuthority: () => void,
560
+ ): Promise<Record<string, unknown>> {
561
+ return this.#authority.run(assertAuthority, () => this.execute(value));
562
+ }
563
+
564
+ #assertFinalizationLease(
565
+ lease: Parameters<DesignJournal["assertFinalizationLease"]>[0],
566
+ ): void {
567
+ this.#authority.getStore()?.();
568
+ this.#journal.assertFinalizationLease(lease);
569
+ }
570
+
488
571
  #assertOpen(): void {
572
+ this.#authority.getStore()?.();
489
573
  if (this.#closed) throw new Error("design-controller-closed");
490
574
  }
491
575
 
@@ -519,7 +603,8 @@ export class DesignController {
519
603
  async execute(value: unknown): Promise<Record<string, unknown>> {
520
604
  this.#assertOpen();
521
605
  const validation = validateDesignControlRequest(value);
522
- if (!validation.ok) throw new Error(validation.code);
606
+ if (!validation.ok)
607
+ throw new DesignControlValidationError(validation.diagnostics);
523
608
  const request = validation.value;
524
609
  switch (request.operation) {
525
610
  case "start":
@@ -597,6 +682,30 @@ export class DesignController {
597
682
  completed: run.state === "completed",
598
683
  ...(run.pauseCode ? { pause: { code: run.pauseCode } } : {}),
599
684
  legalOperations,
685
+ nextStep: terminal
686
+ ? null
687
+ : !design.gates.gateA.current
688
+ ? {
689
+ operation: "approve-gate",
690
+ instruction:
691
+ "Read applicable instructions, the affected source and existing tests, and the verification manifest together. Resolve only remaining substantive choices; use existing same-task approval. Submit the accepted structured ChangeContract, not a prose placeholder.",
692
+ }
693
+ : !design.change
694
+ ? {
695
+ operation: "bind-change",
696
+ instruction: "Bind the approved change name to this runId.",
697
+ }
698
+ : !design.plan || !design.gates.gateB.current
699
+ ? {
700
+ operation: "write-artifact",
701
+ instruction:
702
+ "Write proposal, design, specs, author task checkboxes with exact Scenario references, and plan-draft.json. Use the installed example named in the tool description; omit derived identities. Preflight then compile; compilation supplies verification bindings and Gate B.",
703
+ }
704
+ : {
705
+ operation: "finalize-delivery",
706
+ instruction:
707
+ "Finalize the prepared artifacts. If tasks or the draft changed since compilation, preflight and compile again first. No separate Gate B approval is needed.",
708
+ },
600
709
  packetActions: terminal ? [] : ["finish"],
601
710
  design,
602
711
  };
@@ -631,6 +740,7 @@ export class DesignController {
631
740
  throw error;
632
741
  }
633
742
  if (run.state === "created") {
743
+ this.#journal.inheritFinalizedAuthority(run.runId);
634
744
  this.#runs.transition({
635
745
  runId: run.runId,
636
746
  to: "paused",
@@ -831,7 +941,22 @@ export class DesignController {
831
941
  { code: "design-plan-draft-invalid", field: "plan-draft.json" },
832
942
  ]);
833
943
  }
944
+ const authoredDraft = structuredClone(draft);
945
+ if (status.changeContract) {
946
+ if (!draft || typeof draft !== "object" || Array.isArray(draft))
947
+ throw new Error("change-contract-invalid");
948
+ const record = draft as Record<string, unknown>;
949
+ if (
950
+ record.changeContract !== undefined &&
951
+ canonicalJson(normalizeChangeContract(record.changeContract)) !==
952
+ canonicalJson(status.changeContract)
953
+ )
954
+ throw new Error("change-contract-mismatch");
955
+ record.changeContract = status.changeContract;
956
+ } else if (draft && typeof draft === "object" && "changeContract" in draft)
957
+ throw new Error("change-contract-gate-a-required");
834
958
  const compiled = compileImplementPlan(draft, {
959
+ bindExecutionInputs: true,
835
960
  consumerRoot: this.consumerRoot,
836
961
  });
837
962
  if (compiled.plan.changeId !== status.change) {
@@ -839,7 +964,7 @@ export class DesignController {
839
964
  { code: "design-plan-change-mismatch", field: "changeId" },
840
965
  ]);
841
966
  }
842
- return { status, compiled };
967
+ return { status, compiled, authoredDraft };
843
968
  }
844
969
 
845
970
  #validatePlanDraft(
@@ -848,11 +973,12 @@ export class DesignController {
848
973
  { operation: "validate-plan-draft" }
849
974
  >,
850
975
  ) {
851
- const { compiled } = this.#readCompiledDraft(request.runId);
976
+ const { compiled, authoredDraft } = this.#readCompiledDraft(request.runId);
852
977
  return {
853
978
  operation: request.operation,
854
979
  runId: request.runId,
855
980
  valid: true,
981
+ summary: planAuthoringSummary(compiled.plan, authoredDraft),
856
982
  plan: {
857
983
  taskCount: compiled.plan.tasks.length,
858
984
  outputCount: compiled.plan.outputs.length,
@@ -882,9 +1008,47 @@ export class DesignController {
882
1008
  if (!change) throw new Error("design-change-required");
883
1009
  const readyPath = safeRelative(change, "ready.yaml");
884
1010
  const gateAPath = safeRelative(change, "gate-a.yaml");
885
- removeSafeFile(this.consumerRoot, readyPath);
886
- removeSafeFile(this.consumerRoot, gateAPath);
887
- this.#journal.assertFinalizationLease(lease);
1011
+ const tasksPath = safeRelative(change, "tasks.md");
1012
+ if (observeSafePath(this.consumerRoot, tasksPath).kind === "absent") {
1013
+ throw new DesignPlanValidationError(
1014
+ "design-traceability-input-unavailable",
1015
+ [{ code: "design-traceability-input-unavailable", path: "tasks.md" }],
1016
+ );
1017
+ }
1018
+ const priorTasks = boundedArtifactBytes(this.consumerRoot, tasksPath);
1019
+ const tasksBytes = Buffer.from(
1020
+ bindTaskVerifications(
1021
+ new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
1022
+ priorTasks,
1023
+ ),
1024
+ compiled.plan,
1025
+ ),
1026
+ "utf8",
1027
+ );
1028
+ if (tasksBytes.length > MAX_DESIGN_FILE_BYTES) {
1029
+ throw new Error("design-artifact-content-too-large");
1030
+ }
1031
+ const tasksUnchanged = tasksBytes.equals(priorTasks);
1032
+ const unchanged =
1033
+ tasksUnchanged &&
1034
+ status.gates.gateB.current &&
1035
+ status.plan?.rawSha256 === compiled.rawSha256 &&
1036
+ status.plan.canonicalHash === compiled.planHash;
1037
+ if (!unchanged) {
1038
+ removeSafeFile(this.consumerRoot, readyPath);
1039
+ removeSafeFile(this.consumerRoot, gateAPath);
1040
+ }
1041
+ this.#assertFinalizationLease(lease);
1042
+ if (
1043
+ !boundedArtifactBytes(this.consumerRoot, tasksPath).equals(priorTasks)
1044
+ ) {
1045
+ throw new Error("design-artifact-currentness-mismatch");
1046
+ }
1047
+ // The compilation journal commits only after both files are installed.
1048
+ // A crash before that point can replay this deterministic projection;
1049
+ // finalization always rechecks the plan and task bytes together.
1050
+ if (!tasksUnchanged)
1051
+ atomicWrite(this.consumerRoot, tasksPath, tasksBytes);
888
1052
  atomicWrite(
889
1053
  this.consumerRoot,
890
1054
  safeRelative(change, "implement-plan.json"),
@@ -897,6 +1061,7 @@ export class DesignController {
897
1061
  rawSha256: compiled.rawSha256,
898
1062
  canonicalHash: compiled.planHash,
899
1063
  lease,
1064
+ certify: true,
900
1065
  });
901
1066
  } finally {
902
1067
  this.#journal.releaseFinalizationLease(lease);
@@ -970,7 +1135,9 @@ export class DesignController {
970
1135
  }
971
1136
  let compiled: ReturnType<typeof compileImplementPlan> | undefined;
972
1137
  try {
973
- const plan = parseImplementPlan(installedPlanBytes);
1138
+ const plan = parseImplementPlan(installedPlanBytes, {
1139
+ allowLegacyOrder: true,
1140
+ });
974
1141
  compiled = compileImplementPlan(plan, {
975
1142
  consumerRoot: this.consumerRoot,
976
1143
  });
@@ -1067,14 +1234,14 @@ export class DesignController {
1067
1234
  traceability: traceability.value,
1068
1235
  });
1069
1236
  readyHash = readyReceipt.rawSha256;
1070
- this.#journal.assertFinalizationLease(lease);
1237
+ this.#assertFinalizationLease(lease);
1071
1238
  atomicWrite(this.consumerRoot, gateAPath, gateAReceipt.bytes);
1072
1239
  atomicWrite(this.consumerRoot, readyPath, readyReceipt.bytes);
1073
1240
  readyInstalled = true;
1074
1241
  const installedGateA = readSafeFile(this.consumerRoot, gateAPath);
1075
1242
  const installedReady = readSafeFile(this.consumerRoot, readyPath);
1076
- parseGateAReceipt(installedGateA);
1077
- parseReadyReceipt(installedReady);
1243
+ parseGateAReceipt(installedGateA, { allowLegacyOrder: true });
1244
+ parseReadyReceipt(installedReady, { allowLegacyOrder: true });
1078
1245
  if (
1079
1246
  !Buffer.from(installedGateA).equals(Buffer.from(gateAReceipt.bytes)) ||
1080
1247
  !Buffer.from(installedReady).equals(Buffer.from(readyReceipt.bytes))
@@ -0,0 +1,150 @@
1
+ import { canonicalJson, compareCanonicalStrings } from "./canonical.ts";
2
+ import { isValidRelativePath } from "./contracts.ts";
3
+
4
+ export type SafeDesignDiagnostic = Record<string, string | boolean | string[]>;
5
+ const CODE = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
6
+ const IDENTITIES = new Set([
7
+ "code",
8
+ "taskId",
9
+ "phase",
10
+ "field",
11
+ "category",
12
+ "owner",
13
+ "verificationId",
14
+ "outputId",
15
+ "dependencyTaskId",
16
+ "producerTaskId",
17
+ "producerPhase",
18
+ "command",
19
+ "reason",
20
+ "systemCode",
21
+ "exitCode",
22
+ ]);
23
+ const HINTS = new Map<string, string>(
24
+ Object.entries({
25
+ "change-contract-acceptance-missing":
26
+ "Read the current approved ChangeContract from Design status and include each accepted verification obligation in the plan's phase, affected, full-suite or post-apply verification. Command, arguments, inputs and classification must match; copying only its ID is insufficient. Keep accepted IDs, statements and scope unchanged.",
27
+ "script-command-mismatch":
28
+ "command must equal the literal package.json scripts[script] value, not an invocation such as npm run test. In PlanDraft, omit command to bind it from the manifest. Explicit commands, including approved Gate acceptance verifiers, remain exact and must be corrected rather than silently replaced.",
29
+ "design-traceability-input-unavailable":
30
+ "Write tasks.md with author task checkboxes and exact owned Scenario references, plus the referenced spec artifacts, before compilation and finalization.",
31
+ "traceability-managed-region-invalid":
32
+ "Repair the paired ABEL:VERIFICATION-BINDINGS markers in tasks.md without removing author evidence, then compile again. Code replaces only one complete managed region.",
33
+ "traceability-managed-region-stale":
34
+ "Compile the current draft again to regenerate phase verification bindings; do not copy verifier identities by hand.",
35
+ "traceability-task-unmapped":
36
+ "Put each planned task ID in backticks on its author checkbox outside the generated bindings region.",
37
+ "traceability-verification-unmapped":
38
+ "Compile after writing tasks.md so code installs phase verification bindings while preserving the author evidence.",
39
+ "traceability-reference-empty":
40
+ "Add exact backticked specs/<capability>/spec.md#Requirement title/Scenario title references under the owning author tasks. Code does not infer Scenario ownership.",
41
+ "change-contract-field-invalid":
42
+ "Correct the indicated ChangeContract field using the tool schema. Keep accepted scope and evidence unchanged.",
43
+ "design-control-field-invalid":
44
+ "Use the exact operation envelope and returned runId; inspect the indicated field in the tool schema.",
45
+ "verification-reference-unavailable":
46
+ "Use a declared verificationDefinitions name or an explicit verification contract.",
47
+ "verification-reference-invalid":
48
+ "A verification reference contains only use and optional Red expectedFailure. Put executable fields in its definition.",
49
+ "verification-definition-invalid":
50
+ "Define one existing atomic verifier without id, classification, executionBindings or references.",
51
+ "current-task-outside-write-set":
52
+ "Omit disposition to derive the test owner from task writes. Keep path and evidence; do not widen writes to satisfy this label.",
53
+ "related-test-owner-ambiguous":
54
+ "Several other tasks edit this test. Choose the intended regression-task owner explicitly, or resolve overlapping task ownership.",
55
+ "verification-input-binding-mismatch":
56
+ "Omit verificationInputs to derive exact bindings from the verification contract and declared outputs. Keep supporting source files in read, not in verificationInputs.",
57
+ "workspace-input-has-producer":
58
+ "Omit verificationInputs or bind this path to its declared outputId instead of workspace.",
59
+ "multiple-output-producers":
60
+ "Declare one producer per output path before deriving input bindings. Do not choose a producer by list order.",
61
+ "producer-not-dependency":
62
+ "The producing task must be an explicit dependency of this task. Input inference does not add dependency authority.",
63
+ "producer-phase-after-consumer":
64
+ "This verification needs an output from a later phase. Correct the producer or phase order; input inference cannot make that output available earlier.",
65
+ "verification-input-not-declared":
66
+ "The verification contract requires a path outside this phase's read/write declarations. Correct the contract or explicitly declare the approved input path.",
67
+ "workspace-input-unavailable":
68
+ "The required workspace input is missing or not a safe regular file. Correct its path or declare its planned producer.",
69
+ }),
70
+ );
71
+ function safePath(value: unknown): value is string {
72
+ return isValidRelativePath(value) && !/[\p{Cc}\p{Cf}]/u.test(value);
73
+ }
74
+
75
+ /** One bounded projection for tool feedback and TUI; guidance is code-owned. */
76
+ export function projectDesignDiagnostic(
77
+ value: unknown,
78
+ ): SafeDesignDiagnostic | undefined {
79
+ if (typeof value === "string") value = { code: value.split(":", 1)[0] };
80
+ if (!value || typeof value !== "object" || Array.isArray(value))
81
+ return undefined;
82
+ const record = value as Record<string, unknown>;
83
+ if (typeof record.code !== "string" || !CODE.test(record.code))
84
+ return undefined;
85
+ const result: SafeDesignDiagnostic = {};
86
+ for (const [key, entry] of Object.entries(record)) {
87
+ if (IDENTITIES.has(key) && typeof entry === "string" && CODE.test(entry))
88
+ result[key] = entry;
89
+ else if (
90
+ (key === "retryable" || key === "pathsTruncated") &&
91
+ typeof entry === "boolean"
92
+ )
93
+ result[key] =
94
+ key === "pathsTruncated"
95
+ ? result.pathsTruncated === true || entry
96
+ : entry;
97
+ else if (key === "path" && safePath(entry)) result.path = entry;
98
+ else if (
99
+ (key === "expectedPaths" || key === "actualPaths") &&
100
+ Array.isArray(entry)
101
+ ) {
102
+ const paths = [...new Set(entry.filter(safePath))].sort(
103
+ compareCanonicalStrings,
104
+ );
105
+ result[key] = paths.slice(0, 32);
106
+ if (entry.some((path) => !safePath(path)) || paths.length > 32)
107
+ result.pathsTruncated = true;
108
+ }
109
+ }
110
+ const hint = HINTS.get(String(result.category)) ?? HINTS.get(record.code);
111
+ if (hint) result.hint = hint;
112
+ return result;
113
+ }
114
+
115
+ export interface DesignPlanDiagnostic {
116
+ code: string;
117
+ taskId?: string;
118
+ phase?: string;
119
+ field?: string;
120
+ category?: string;
121
+ owner?: string;
122
+ verificationId?: string;
123
+ outputId?: string;
124
+ dependencyTaskId?: string;
125
+ producerTaskId?: string;
126
+ producerPhase?: string;
127
+ path?: string;
128
+ expectedPaths?: string[];
129
+ actualPaths?: string[];
130
+ }
131
+
132
+ export class DesignPlanValidationError extends Error {
133
+ readonly diagnostics: readonly DesignPlanDiagnostic[];
134
+
135
+ constructor(message: string, diagnostics: readonly DesignPlanDiagnostic[]) {
136
+ const normalized = [
137
+ ...new Map(
138
+ diagnostics
139
+ .map((diagnostic) => structuredClone(diagnostic))
140
+ .sort((left, right) =>
141
+ compareCanonicalStrings(canonicalJson(left), canonicalJson(right)),
142
+ )
143
+ .map((diagnostic) => [canonicalJson(diagnostic), diagnostic]),
144
+ ).values(),
145
+ ];
146
+ super(message);
147
+ this.name = "DesignPlanValidationError";
148
+ this.diagnostics = Object.freeze(normalized);
149
+ }
150
+ }