@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
@@ -0,0 +1,82 @@
1
+ export type PacketActivityState =
2
+ | "queued"
3
+ | "preparing"
4
+ | "connecting"
5
+ | "waiting-first-response"
6
+ | "running"
7
+ | "retrying"
8
+ | "completed"
9
+ | "failed"
10
+ | "cancelled"
11
+ | "timed-out";
12
+
13
+ export type PacketFailureReason =
14
+ | "subagent failed"
15
+ | "subagent cancelled"
16
+ | "phase timed out";
17
+
18
+ export interface PacketActivityEvent {
19
+ state: PacketActivityState;
20
+ requestId: string;
21
+ role: string;
22
+ phase: string;
23
+ objective: string;
24
+ sequence: number;
25
+ attempt?: number;
26
+ maxAttempts?: number;
27
+ code?: string;
28
+ wait?: string;
29
+ failureReason?: PacketFailureReason;
30
+ }
31
+
32
+ export type PacketActivityObserver = (
33
+ event: PacketActivityEvent,
34
+ ) => void | Promise<void>;
35
+
36
+ export const WORKFLOW_ACTIVITY_STATES = [
37
+ "queued",
38
+ "preparing",
39
+ "connecting",
40
+ "waiting-first-response",
41
+ "running",
42
+ "validating",
43
+ "retrying",
44
+ "verifying",
45
+ "paused",
46
+ "approval-needed",
47
+ "applying",
48
+ "recovering",
49
+ "operation-cancelled",
50
+ "discarded",
51
+ "rejected",
52
+ "completed",
53
+ ] as const;
54
+
55
+ export type WorkflowActivityState = (typeof WORKFLOW_ACTIVITY_STATES)[number];
56
+ export type ActivityState = PacketActivityState | WorkflowActivityState;
57
+ export type TerminalActivityState =
58
+ | Exclude<
59
+ PacketActivityState,
60
+ | "queued"
61
+ | "preparing"
62
+ | "connecting"
63
+ | "waiting-first-response"
64
+ | "running"
65
+ | "retrying"
66
+ >
67
+ | WorkflowActivityState;
68
+
69
+ export interface WorkflowActivityUpdate {
70
+ state: WorkflowActivityState;
71
+ stage?: "abel-design" | "abel-implement";
72
+ runId?: string;
73
+ change?: string;
74
+ taskId?: string;
75
+ phase?: string;
76
+ objective?: string;
77
+ code?: string;
78
+ attempt?: number;
79
+ maxAttempts?: number;
80
+ wait?: string;
81
+ legalCommands?: string[];
82
+ }
@@ -20,10 +20,12 @@ import {
20
20
  import { tmpdir } from "node:os";
21
21
  import path from "node:path";
22
22
  import { DatabaseSync } from "node:sqlite";
23
-
24
23
  import type { ArtifactStore } from "./artifact-store.ts";
24
+ import { compareCanonicalStrings } from "./canonical.ts";
25
25
  import { isValidRelativePath } from "./contracts.ts";
26
26
  import { observeSafePath } from "./safe-path.ts";
27
+ import { configureSqlite, ensureSqliteSchema } from "./sqlite-schema.ts";
28
+ import { APPLY_SCHEMA } from "./storage-schema.ts";
27
29
  import type {
28
30
  WorkspaceEntry,
29
31
  WorkspaceRevision,
@@ -548,7 +550,11 @@ export async function verifyCumulativeRevision(
548
550
  );
549
551
  chmodSync(temporary, 0o700);
550
552
  try {
551
- input.workspaceStore.materialize(revision.revisionId, temporary);
553
+ await input.workspaceStore.materializeAsync(
554
+ revision.revisionId,
555
+ temporary,
556
+ input.signal,
557
+ );
552
558
  if (input.signal?.aborted) {
553
559
  return {
554
560
  ok: false,
@@ -629,16 +635,8 @@ export class ApplyTransaction {
629
635
  throw new Error("transaction-database-path-invalid");
630
636
  }
631
637
  this.#database = new DatabaseSync(databasePath);
632
- this.#database.exec(
633
- "PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;",
634
- );
635
- this.#database.exec(`
636
- CREATE TABLE IF NOT EXISTS apply_transaction_journal (
637
- transaction_id TEXT PRIMARY KEY,
638
- journal_json TEXT NOT NULL,
639
- journal_hash TEXT NOT NULL
640
- ) STRICT;
641
- `);
638
+ configureSqlite(this.#database);
639
+ ensureSqliteSchema(this.#database, APPLY_SCHEMA);
642
640
  chmodSync(databasePath, 0o600);
643
641
  }
644
642
 
@@ -986,7 +984,7 @@ export class ApplyTransaction {
986
984
  const deepestFirst = [...journal.createdDirectories].sort(
987
985
  (left, right) =>
988
986
  right.split("/").length - left.split("/").length ||
989
- right.localeCompare(left),
987
+ compareCanonicalStrings(right, left),
990
988
  );
991
989
  for (const relative of deepestFirst) {
992
990
  const observation = observeSafePath(journal.consumerRoot, relative);
@@ -1244,7 +1242,11 @@ export class ApplyTransaction {
1244
1242
  path.join(this.root, "post-apply-verification-"),
1245
1243
  );
1246
1244
  try {
1247
- this.#workspaces.materialize(journal.finalRevisionId, isolatedRoot);
1245
+ await this.#workspaces.materializeAsync(
1246
+ journal.finalRevisionId,
1247
+ isolatedRoot,
1248
+ signal,
1249
+ );
1248
1250
  const execution = Promise.resolve(
1249
1251
  this.#hooks.postApply({
1250
1252
  transactionId,
@@ -3,6 +3,7 @@ import {
3
3
  chmodSync,
4
4
  closeSync,
5
5
  constants,
6
+ copyFileSync,
6
7
  fsyncSync,
7
8
  lstatSync,
8
9
  mkdirSync,
@@ -160,6 +161,22 @@ export class ArtifactStore {
160
161
  return Buffer.from(bytes);
161
162
  }
162
163
 
164
+ /** Clone when supported; always use a distinct inode and verify the copied bytes. */
165
+ copyVerified(hash: string, destination: string): number {
166
+ const source = this.#blobPath(hash);
167
+ const stat = lstatSync(source, { throwIfNoEntry: false });
168
+ if (!stat?.isFile() || stat.isSymbolicLink())
169
+ throw new Error("artifact-unavailable");
170
+ copyFileSync(
171
+ source,
172
+ destination,
173
+ constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE,
174
+ );
175
+ const bytes = readFileSync(destination);
176
+ if (digest(bytes) !== hash) throw new Error("artifact-integrity-invalid");
177
+ return bytes.byteLength;
178
+ }
179
+
163
180
  referenceCount(hash: string): number {
164
181
  requireHash(hash);
165
182
  const referencePath = this.#referencePath(hash);
@@ -0,0 +1,143 @@
1
+ import { createHash } from "node:crypto";
2
+ import { diffWritePaths } from "./contracts.ts";
3
+ import {
4
+ type BeginCandidateInput,
5
+ TASK_LEDGER_LIMITS,
6
+ type TaskLedger,
7
+ } from "./task-ledger.ts";
8
+ import type { DurableCandidateProposal } from "./workflow-policy.ts";
9
+
10
+ type ArtifactProposal = Extract<
11
+ DurableCandidateProposal,
12
+ { kind: "candidate" | "sealed-candidate" }
13
+ >;
14
+ interface CandidateArtifactInput {
15
+ ledger: TaskLedger;
16
+ identity: BeginCandidateInput;
17
+ proposal: ArtifactProposal;
18
+ }
19
+
20
+ const sha256 = (bytes: Uint8Array) =>
21
+ createHash("sha256").update(bytes).digest("hex");
22
+
23
+ /** Inspect the same sealed bytes for normal phases and repairs. Callers own recovery policy. */
24
+ export function inspectCandidateArtifact({
25
+ ledger,
26
+ identity,
27
+ proposal,
28
+ approvedPaths,
29
+ }: CandidateArtifactInput & { approvedPaths: readonly string[] }):
30
+ | { ok: true; bytes: Buffer; paths: string[] }
31
+ | {
32
+ ok: false;
33
+ code: "candidate-diff-invalid" | "write-set-mismatch";
34
+ stage: string;
35
+ } {
36
+ const invalid = (stage: string) => ({
37
+ ok: false as const,
38
+ code: "candidate-diff-invalid" as const,
39
+ stage,
40
+ });
41
+ ledger.beginCandidate(identity);
42
+ let bytes: Buffer;
43
+ if (proposal.kind === "sealed-candidate") {
44
+ if (
45
+ proposal.candidateId !== identity.candidateId ||
46
+ !/^[a-f0-9]{64}$/u.test(proposal.artifactHash) ||
47
+ !Number.isSafeInteger(proposal.bytes) ||
48
+ proposal.bytes < 1 ||
49
+ !Array.isArray(proposal.paths)
50
+ )
51
+ return invalid("candidate-seal");
52
+ bytes = Buffer.from(ledger.readSealedCandidate(identity.candidateId));
53
+ if (
54
+ bytes.length !== proposal.bytes ||
55
+ sha256(bytes) !== proposal.artifactHash
56
+ ) {
57
+ throw new Error("candidate-seal-integrity-invalid");
58
+ }
59
+ } else {
60
+ if (
61
+ !(proposal.bytes instanceof Uint8Array) ||
62
+ proposal.bytes.length === 0
63
+ ) {
64
+ return invalid("candidate-submit");
65
+ }
66
+ bytes = Buffer.from(proposal.bytes);
67
+ }
68
+ let paths: string[];
69
+ try {
70
+ paths = diffWritePaths(
71
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes),
72
+ ).paths;
73
+ } catch {
74
+ return invalid("candidate-diff");
75
+ }
76
+ if (paths.some((relative) => !approvedPaths.includes(relative))) {
77
+ return {
78
+ ok: false,
79
+ code: "write-set-mismatch",
80
+ stage: "candidate-boundary",
81
+ };
82
+ }
83
+ if (
84
+ proposal.kind === "sealed-candidate" &&
85
+ JSON.stringify([...proposal.paths].sort()) !==
86
+ JSON.stringify([...paths].sort())
87
+ ) {
88
+ return invalid("candidate-seal");
89
+ }
90
+ return { ok: true, bytes, paths };
91
+ }
92
+
93
+ /** Seal after isolated patch/dependency checks, before any phase verification. */
94
+ export function sealCandidateArtifact({
95
+ ledger,
96
+ identity,
97
+ proposal,
98
+ bytes,
99
+ }: CandidateArtifactInput & { bytes: Buffer }):
100
+ | { ok: true; artifactHash: string }
101
+ | { ok: false; kind: "paused" | "retryable"; code: string; stage: string } {
102
+ if (proposal.kind === "sealed-candidate")
103
+ return { ok: true, artifactHash: proposal.artifactHash };
104
+ let sequence = 0;
105
+ for (
106
+ let offset = 0;
107
+ offset < bytes.length;
108
+ offset += TASK_LEDGER_LIMITS.maxSegmentBytes
109
+ ) {
110
+ const segment = bytes.subarray(
111
+ offset,
112
+ Math.min(offset + TASK_LEDGER_LIMITS.maxSegmentBytes, bytes.length),
113
+ );
114
+ const accepted = ledger.appendCandidateSegment({
115
+ ...identity,
116
+ sequence,
117
+ bytes: segment,
118
+ segmentHash: sha256(segment),
119
+ });
120
+ if (!accepted.ok)
121
+ return {
122
+ ok: false,
123
+ kind: "paused",
124
+ code: accepted.code,
125
+ stage: "candidate-segment",
126
+ };
127
+ sequence++;
128
+ }
129
+ const sealed = ledger.sealCandidate({
130
+ ...identity,
131
+ segmentCount: sequence,
132
+ totalBytes: bytes.length,
133
+ candidateHash: sha256(bytes),
134
+ });
135
+ return sealed.ok
136
+ ? { ok: true, artifactHash: sealed.artifactHash }
137
+ : {
138
+ ok: false,
139
+ kind: "retryable",
140
+ code: sealed.code,
141
+ stage: "candidate-seal",
142
+ };
143
+ }
@@ -1,6 +1,7 @@
1
1
  import { lstatSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
4
+ import { compareCanonicalStrings } from "./canonical.ts";
4
5
 
5
6
  import { isValidRelativePath } from "./contracts.ts";
6
7
  import { observeSafePath } from "./safe-path.ts";
@@ -146,7 +147,7 @@ export function compileCandidatePatch(input: {
146
147
  return state;
147
148
  };
148
149
 
149
- for (const operation of input.operations) {
150
+ for (const [operationIndex, operation] of input.operations.entries()) {
150
151
  if (
151
152
  !operation ||
152
153
  typeof operation !== "object" ||
@@ -165,20 +166,40 @@ export function compileCandidatePatch(input: {
165
166
  throw new Error("candidate-patch-replace-invalid");
166
167
  }
167
168
  const relative = requirePath(operation.path);
169
+ const reject = (reason: string, hint: string): never => {
170
+ throw new Error(
171
+ JSON.stringify({
172
+ code: "candidate-patch-replace-invalid",
173
+ operationIndex,
174
+ ...(relative.length <= 512 && !/[\p{Cc}\p{Cf}]/u.test(relative)
175
+ ? { path: relative }
176
+ : {}),
177
+ reason,
178
+ hint,
179
+ }),
180
+ );
181
+ };
168
182
  if (!writePaths.has(relative)) {
169
- throw new Error("candidate-patch-replace-invalid");
183
+ reject(
184
+ "path-not-authorized",
185
+ "Use only approved write paths; request context instead of widening authority.",
186
+ );
170
187
  }
171
188
  const state = load(relative);
172
- if (
173
- state.current === null ||
174
- occurrenceCount(state.current, operation.oldText) !== 1
175
- ) {
176
- throw new Error("candidate-patch-replace-invalid");
189
+ const current =
190
+ state.current ??
191
+ reject(
192
+ "file-not-found",
193
+ "Use create for a new approved file, or correct the path.",
194
+ );
195
+ const occurrences = occurrenceCount(current, operation.oldText);
196
+ if (occurrences !== 1) {
197
+ reject(
198
+ occurrences === 0 ? "old-text-not-found" : "old-text-ambiguous",
199
+ "Read the approved file and supply exact unique oldText, including enough surrounding context. Operations are applied in order.",
200
+ );
177
201
  }
178
- state.current = state.current.replace(
179
- operation.oldText,
180
- operation.newText,
181
- );
202
+ state.current = current.replace(operation.oldText, operation.newText);
182
203
  break;
183
204
  }
184
205
  case "rewrite": {
@@ -242,7 +263,7 @@ export function compileCandidatePatch(input: {
242
263
 
243
264
  const diff = [...states.values()]
244
265
  .filter((state) => state.base !== state.current)
245
- .sort((left, right) => left.path.localeCompare(right.path))
266
+ .sort((left, right) => compareCanonicalStrings(left.path, right.path))
246
267
  .map(renderFileDiff)
247
268
  .join("");
248
269
  if (diff.length === 0) throw new Error("candidate-patch-empty");
@@ -0,0 +1,74 @@
1
+ import { spawn } from "node:child_process";
2
+ import { lstatSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { observeSafePath } from "./safe-path.ts";
5
+
6
+ export function runGitApply(
7
+ root: string,
8
+ diff: Uint8Array,
9
+ checkOnly: boolean,
10
+ signal: AbortSignal,
11
+ ): Promise<"ok" | "failed" | "cancelled"> {
12
+ if (signal.aborted) return Promise.resolve("cancelled");
13
+ return new Promise((resolve) => {
14
+ let settled = false;
15
+ const settle = (value: "ok" | "failed" | "cancelled") => {
16
+ if (settled) return;
17
+ settled = true;
18
+ signal.removeEventListener("abort", abort);
19
+ resolve(value);
20
+ };
21
+ const child = spawn(
22
+ "git",
23
+ [
24
+ "apply",
25
+ ...(checkOnly ? ["--check"] : []),
26
+ "--recount",
27
+ "--whitespace=nowarn",
28
+ "-",
29
+ ],
30
+ { cwd: root, shell: false, stdio: ["pipe", "ignore", "ignore"] },
31
+ );
32
+ const abort = () => {
33
+ child.kill("SIGTERM");
34
+ settle("cancelled");
35
+ };
36
+ signal.addEventListener("abort", abort, { once: true });
37
+ child.once("error", () => settle(signal.aborted ? "cancelled" : "failed"));
38
+ child.once("close", (code) =>
39
+ settle(signal.aborted ? "cancelled" : code === 0 ? "ok" : "failed"),
40
+ );
41
+ child.stdin.on("error", () => undefined);
42
+ child.stdin.end(Buffer.from(diff));
43
+ });
44
+ }
45
+ export function revisionChanges(
46
+ root: string,
47
+ paths: readonly string[],
48
+ ): Record<
49
+ string,
50
+ { kind: "absent" } | { kind: "file"; bytes: Uint8Array; mode: number }
51
+ > {
52
+ const changes: Record<
53
+ string,
54
+ { kind: "absent" } | { kind: "file"; bytes: Uint8Array; mode: number }
55
+ > = {};
56
+ for (const relative of [...new Set(paths)].sort()) {
57
+ const observation = observeSafePath(root, relative);
58
+ if (observation.kind === "absent") {
59
+ changes[relative] = { kind: "absent" };
60
+ continue;
61
+ }
62
+ if (observation.kind !== "file") {
63
+ throw new Error("workflow-candidate-path-unsafe");
64
+ }
65
+ const target = path.join(root, ...relative.split("/"));
66
+ const stat = lstatSync(target);
67
+ changes[relative] = {
68
+ kind: "file",
69
+ bytes: readFileSync(target),
70
+ mode: stat.mode & 0o777,
71
+ };
72
+ }
73
+ return changes;
74
+ }
@@ -0,0 +1,16 @@
1
+ /** Canonical identity ordering is UTF-16 code-unit order, never host collation. */
2
+ export function compareCanonicalStrings(left: string, right: string): number {
3
+ return left < right ? -1 : left > right ? 1 : 0;
4
+ }
5
+
6
+ export function canonicalJson(value: unknown): string {
7
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
8
+ if (value && typeof value === "object") {
9
+ return `{${Object.entries(value as Record<string, unknown>)
10
+ .filter(([, entry]) => entry !== undefined)
11
+ .sort(([left], [right]) => compareCanonicalStrings(left, right))
12
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
13
+ .join(",")}}`;
14
+ }
15
+ return JSON.stringify(value) ?? "null";
16
+ }
@@ -0,0 +1,191 @@
1
+ import { Type } from "typebox";
2
+ import { Value } from "typebox/value";
3
+ import {
4
+ isValidRelativePath,
5
+ VERIFICATION_LIMITS,
6
+ validateVerificationContract,
7
+ } from "./contracts.ts";
8
+ import type { DesignPlanDiagnostic } from "./design-diagnostics.ts";
9
+
10
+ const text = Type.String({ minLength: 1, maxLength: 8192, pattern: "\\S" });
11
+ const id = Type.String({ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$" });
12
+ const object = <T extends Parameters<typeof Type.Object>[0]>(properties: T) =>
13
+ Type.Object(properties, { additionalProperties: false });
14
+ const managers = Type.Union([
15
+ Type.Literal("bun"),
16
+ Type.Literal("npm"),
17
+ Type.Literal("pnpm"),
18
+ Type.Literal("yarn"),
19
+ ]);
20
+ const packageRunner = {
21
+ kind: Type.Literal("package-script"),
22
+ packageManager: managers,
23
+ script: text,
24
+ command: text,
25
+ };
26
+ const executable = Type.Union([
27
+ object(packageRunner),
28
+ object({ kind: Type.Literal("local-binary"), executable: text }),
29
+ object({
30
+ kind: Type.Literal("npx"),
31
+ executable: text,
32
+ noInstall: Type.Literal(true),
33
+ }),
34
+ ]);
35
+ const classification = Type.Union([
36
+ Type.Literal("expected-red"),
37
+ Type.Literal("expected-green"),
38
+ Type.Literal("expected-refactor"),
39
+ ]);
40
+ const base = {
41
+ id,
42
+ classification,
43
+ expectedFailure: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })),
44
+ executionBindings: Type.Optional(
45
+ Type.Record(Type.String(), Type.Union([Type.String(), Type.Null()])),
46
+ ),
47
+ };
48
+ const args = Type.Array(Type.String(), { maxItems: 128 });
49
+ export const ATOMIC_VERIFICATION_SCHEMAS = {
50
+ vitest: object({
51
+ ...base,
52
+ kind: Type.Literal("vitest"),
53
+ runner: executable,
54
+ testFiles: Type.Array(text, {
55
+ minItems: VERIFICATION_LIMITS.minTestFiles,
56
+ maxItems: VERIFICATION_LIMITS.maxTestFiles,
57
+ uniqueItems: true,
58
+ }),
59
+ args,
60
+ minTests: Type.Integer({ minimum: 1 }),
61
+ }),
62
+ "package-script": object({ ...base, ...packageRunner, args }),
63
+ "static-check": object({
64
+ ...base,
65
+ kind: Type.Literal("static-check"),
66
+ runner: Type.Union([
67
+ executable,
68
+ object({ kind: Type.Literal("node"), script: text }),
69
+ ]),
70
+ args,
71
+ }),
72
+ };
73
+ const atomic = Type.Union(Object.values(ATOMIC_VERIFICATION_SCHEMAS));
74
+ const steps = object({
75
+ kind: Type.Literal("steps"),
76
+ id,
77
+ classification,
78
+ steps: Type.Array(atomic, {
79
+ minItems: VERIFICATION_LIMITS.minSteps,
80
+ maxItems: VERIFICATION_LIMITS.maxSteps,
81
+ }),
82
+ });
83
+ export const VERIFICATION_SCHEMA = Type.Union([atomic, steps]);
84
+ export const CHANGE_CONTRACT_SCHEMA = object({
85
+ goal: text,
86
+ acceptance: Type.Array(
87
+ object({ id, statement: text, verification: VERIFICATION_SCHEMA }),
88
+ { minItems: 1, maxItems: 256 },
89
+ ),
90
+ constraints: Type.Array(object({ id, statement: text }), { maxItems: 256 }),
91
+ policy: object({
92
+ writeRoots: Type.Array(text, {
93
+ minItems: 1,
94
+ maxItems: 256,
95
+ uniqueItems: true,
96
+ }),
97
+ dependencies: Type.Array(text, { maxItems: 256, uniqueItems: true }),
98
+ verificationModes: Type.Array(
99
+ Type.Union([
100
+ Type.Literal("behavior"),
101
+ Type.Literal("mechanical"),
102
+ Type.Literal("refactor"),
103
+ ]),
104
+ { minItems: 1, maxItems: 256, uniqueItems: true },
105
+ ),
106
+ }),
107
+ });
108
+ function record(value: unknown): value is Record<string, unknown> {
109
+ return !!value && typeof value === "object" && !Array.isArray(value);
110
+ }
111
+
112
+ const schemaFields = new Set([
113
+ "goal",
114
+ "acceptance",
115
+ "id",
116
+ "statement",
117
+ "verification",
118
+ "constraints",
119
+ "policy",
120
+ "writeRoots",
121
+ "dependencies",
122
+ "verificationModes",
123
+ "classification",
124
+ "expectedFailure",
125
+ "executionBindings",
126
+ "kind",
127
+ "runner",
128
+ "packageManager",
129
+ "script",
130
+ "command",
131
+ "executable",
132
+ "noInstall",
133
+ "testFiles",
134
+ "args",
135
+ "minTests",
136
+ "steps",
137
+ ]);
138
+ function diagnosticField(pointer: string): string {
139
+ const fields = ["contract"];
140
+ for (const part of pointer.split("/").slice(1)) {
141
+ if (!schemaFields.has(part) && !/^(0|[1-9][0-9]{0,5})$/u.test(part)) break;
142
+ fields.push(part);
143
+ // Binding keys are author data, even when they happen to look like schema fields.
144
+ if (part === "executionBindings") break;
145
+ }
146
+ return fields.join(".");
147
+ }
148
+
149
+ /** Shared shape, bounded field-only diagnostics; never return submitted values or parser messages. */
150
+ export function changeContractDiagnostics(
151
+ value: unknown,
152
+ ): DesignPlanDiagnostic[] {
153
+ const diagnostics: DesignPlanDiagnostic[] = Value.Errors(
154
+ CHANGE_CONTRACT_SCHEMA,
155
+ value,
156
+ )
157
+ .slice(0, 32)
158
+ .map((error) => ({
159
+ code: "change-contract-field-invalid",
160
+ field: diagnosticField(error.instancePath),
161
+ category: error.keyword,
162
+ }));
163
+ if (!record(value)) return diagnostics;
164
+ if (Array.isArray(value.acceptance))
165
+ value.acceptance.slice(0, 256).forEach((item, index) => {
166
+ if (!record(item)) return;
167
+ const verification = item.verification;
168
+ if (!validateVerificationContract(verification).ok) {
169
+ const field = `contract.acceptance.${index}.verification`;
170
+ if (
171
+ record(verification) &&
172
+ verification.classification === "expected-red" &&
173
+ typeof verification.expectedFailure !== "string"
174
+ )
175
+ diagnostics.push({
176
+ code: "change-contract-field-invalid",
177
+ field: `${field}.expectedFailure`,
178
+ });
179
+ else diagnostics.push({ code: "change-contract-field-invalid", field });
180
+ }
181
+ });
182
+ if (record(value.policy) && Array.isArray(value.policy.writeRoots))
183
+ value.policy.writeRoots.slice(0, 256).forEach((root, index) => {
184
+ if (root !== "." && !isValidRelativePath(root))
185
+ diagnostics.push({
186
+ code: "change-contract-field-invalid",
187
+ field: `contract.policy.writeRoots.${index}`,
188
+ });
189
+ });
190
+ return diagnostics.slice(0, 32);
191
+ }