@abelxiaoxing/cadence 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/contracts.ts CHANGED
@@ -21,19 +21,22 @@ export const PHASES = [
21
21
  "review",
22
22
  ] as const;
23
23
 
24
- export const RECOVERY_CODES = [
25
- "mechanical-redispatch-exhausted",
26
- "implementation-artifact-delivery-blocked",
27
- "environment-blocked",
28
- "design-required",
24
+ export const AGENTS_IMPACTS = [
25
+ "none",
26
+ "update-existing",
27
+ "create-index",
28
+ "remove-index",
29
29
  ] as const;
30
+ export type AgentsImpact = (typeof AGENTS_IMPACTS)[number];
30
31
 
31
- export type RecoveryCode = (typeof RECOVERY_CODES)[number];
32
- export type RecoveryNext =
33
- | "finish-unaffected"
34
- | "correct-artifact"
35
- | "repair-environment"
36
- | "return-to-design";
32
+ export const IMPACT_SURFACES = [
33
+ "none",
34
+ "route-authorization",
35
+ "page-state",
36
+ "api-response",
37
+ "public-html",
38
+ ] as const;
39
+ export type ImpactSurface = (typeof IMPACT_SURFACES)[number];
37
40
 
38
41
  export const LIMITS = {
39
42
  maxActiveChildSessions: 4,
@@ -43,6 +46,143 @@ export const LIMITS = {
43
46
  maxCompleteResultBytes: 64 * 1024,
44
47
  } as const;
45
48
 
49
+ export const ARTIFACT_FAILURE_CODES = [
50
+ "agents-impact-mismatch",
51
+ "agents-target-mismatch",
52
+ "baseline-bound-mismatch",
53
+ "baseline-deletion-kind",
54
+ "baseline-directory-kind",
55
+ "baseline-directory-mismatch",
56
+ "baseline-directory-special-file",
57
+ "baseline-directory-symlink",
58
+ "baseline-file-kind",
59
+ "baseline-shape",
60
+ "candidate-check-failed",
61
+ "checkout-special-file",
62
+ "checkout-symlink",
63
+ "dependency-bound-mismatch",
64
+ "escaping-path",
65
+ "git-apply-check-failed",
66
+ "git-apply-failed",
67
+ "git-numstat-rejected",
68
+ "git-summary-rejected",
69
+ "ignored-baseline-input",
70
+ "incomplete-baseline",
71
+ "invalid-approved-dependencies",
72
+ "invalid-checkpoint-contract",
73
+ "invalid-diff",
74
+ "invalid-diff-bytes",
75
+ "invalid-snapshot",
76
+ "invalid-structural-result",
77
+ "invalid-verification-contract",
78
+ "lockfile-bound-mismatch",
79
+ "noncanonical-path",
80
+ "nonregular-mode",
81
+ "outside-managed-region",
82
+ "package-bound-mismatch",
83
+ "package-manifest-invalid",
84
+ "parent-review-rejected",
85
+ "red-not-witnessed",
86
+ "submodule",
87
+ "target-baseline-missing",
88
+ "verification-baseline-missing",
89
+ "verification-input-unavailable",
90
+ "verification-rejected",
91
+ "verification-report-invalid",
92
+ "verification-report-missing",
93
+ "write-set-mismatch",
94
+ ] as const;
95
+ export const STALE_FAILURE_CODES = [
96
+ "baseline-deletion-drift",
97
+ "baseline-directory-drift",
98
+ "baseline-file-drift",
99
+ "git-apply-check-failed",
100
+ "stale-snapshot",
101
+ ] as const;
102
+ export const ENVIRONMENT_FAILURE_CODES = [
103
+ "bubblewrap-launch-failed",
104
+ "bubblewrap-or-dependency-unavailable",
105
+ "bun-executable-unavailable",
106
+ "checkpoint-unavailable",
107
+ "checkout-cleanup-failed",
108
+ "checkout-failed",
109
+ "clone-alternates",
110
+ "clone-failed",
111
+ "dependency-path-unsafe",
112
+ "git-apply-check-unavailable",
113
+ "git-apply-failed",
114
+ "git-apply-unavailable",
115
+ "git-ignore-check-failed",
116
+ "git-index-unavailable",
117
+ "invalid-subagent-endpoint",
118
+ "root-unavailable",
119
+ "sandbox-runtime-unavailable",
120
+ ] as const;
121
+ export const APPROVAL_BOUNDARY_CODES = [
122
+ "unapproved-dependency-change",
123
+ "behavior-contract-insufficient",
124
+ "architecture-contract-insufficient",
125
+ "task-scope-insufficient",
126
+ "verification-contract-insufficient",
127
+ "agents-contract-insufficient",
128
+ ] as const;
129
+ export const CHILD_TRANSPORT_CODES = ["timeout", "transport-failure"] as const;
130
+
131
+ export type ArtifactFailureCode = (typeof ARTIFACT_FAILURE_CODES)[number];
132
+ export type StaleFailureCode = (typeof STALE_FAILURE_CODES)[number];
133
+ export type EnvironmentFailureCode = (typeof ENVIRONMENT_FAILURE_CODES)[number];
134
+ export type ApprovalBoundaryCode = (typeof APPROVAL_BOUNDARY_CODES)[number];
135
+ export type ChildTransportCode = (typeof CHILD_TRANSPORT_CODES)[number];
136
+
137
+ export type EnvironmentFailure = {
138
+ kind: "environment";
139
+ code: EnvironmentFailureCode;
140
+ message?: string;
141
+ };
142
+
143
+ export type CandidateFailure =
144
+ | { kind: "artifact"; code: ArtifactFailureCode; evidence?: string[] }
145
+ | { kind: "stale"; code: StaleFailureCode }
146
+ | EnvironmentFailure
147
+ | { kind: "approval-boundary"; code: ApprovalBoundaryCode }
148
+ | { kind: "cancelled"; code: "cancelled" }
149
+ | { kind: "result-limit"; limitBytes: number };
150
+
151
+ export type ChildFailure =
152
+ | CandidateFailure
153
+ | { kind: "transport"; code: ChildTransportCode };
154
+
155
+ export type CandidateRejection =
156
+ | {
157
+ kind: "artifact";
158
+ code: "parent-review-rejected";
159
+ evidence?: string[];
160
+ }
161
+ | { kind: "approval-boundary"; code: ApprovalBoundaryCode };
162
+
163
+ export type TaskFailure =
164
+ | { kind: "approval-boundary"; code: ApprovalBoundaryCode }
165
+ | {
166
+ kind: "attempts-exhausted";
167
+ cause: "artifact" | "stale" | "transport";
168
+ }
169
+ | {
170
+ kind: "checkpoint-attempts-exhausted";
171
+ cause: "artifact" | "stale";
172
+ }
173
+ | EnvironmentFailure
174
+ | { kind: "result-limit"; limitBytes: number };
175
+
176
+ export interface CandidateApplyEvidence {
177
+ targets: string[];
178
+ checkExitCode: 0;
179
+ applyExitCode: 0;
180
+ }
181
+
182
+ export type ApplyCandidateResult =
183
+ | { ok: true; result: CandidateApplyEvidence }
184
+ | { ok: false; failure: CandidateFailure };
185
+
46
186
  const NONCANONICAL = /(^|\/)\.\.(\/|$)|(^|\/)\/|^\//;
47
187
  const SNAPSHOT_SHA256 = /^[a-f0-9]{64}$/;
48
188
 
@@ -54,6 +194,69 @@ export interface VerificationContract {
54
194
  minTests: number;
55
195
  }
56
196
 
197
+ export interface ImpactClosureContract {
198
+ changedSurfaces: ImpactSurface[];
199
+ searchEvidence: string[];
200
+ relatedTests: Array<{
201
+ path: string;
202
+ disposition: "current-task" | "regression-task" | "unaffected";
203
+ evidence: string;
204
+ regressionTaskId?: string;
205
+ }>;
206
+ affectedSuite: string[];
207
+ }
208
+
209
+ export type ImplementationPhase = "red" | "green" | "refactor";
210
+
211
+ export interface PhaseBoundary {
212
+ read: string[];
213
+ write: string[];
214
+ verification: VerificationContract;
215
+ verificationLock?: string;
216
+ }
217
+
218
+ export interface TaskBoundary {
219
+ changeId: string;
220
+ taskId: string;
221
+ objective: string;
222
+ context: { agents: string; contract: string };
223
+ roots: string[];
224
+ phases: {
225
+ red: PhaseBoundary;
226
+ green: PhaseBoundary;
227
+ refactor?: PhaseBoundary;
228
+ };
229
+ scheduling: { conflicts: string[]; resources: string[] };
230
+ agents: {
231
+ impact: AgentsImpact;
232
+ target?: string;
233
+ managedOnly: true;
234
+ };
235
+ approvedDependencies: string[];
236
+ impactClosure: ImpactClosureContract;
237
+ }
238
+
239
+ export interface PhaseAttempt {
240
+ changeId: string;
241
+ taskId: string;
242
+ requestId: string;
243
+ phase: ImplementationPhase;
244
+ snapshot: unknown;
245
+ }
246
+
247
+ export type ImplementRunRequest =
248
+ | {
249
+ stage: "abel-implement";
250
+ kind: "open-task";
251
+ boundary: TaskBoundary;
252
+ attempt: PhaseAttempt;
253
+ }
254
+ | {
255
+ stage: "abel-implement";
256
+ kind: "phase-attempt";
257
+ attempt: PhaseAttempt;
258
+ };
259
+
57
260
  export interface RequestEnvelope {
58
261
  stage: string;
59
262
  role: string;
@@ -71,21 +274,43 @@ export interface RequestEnvelope {
71
274
  verificationLock?: string;
72
275
  };
73
276
  output: string;
277
+ agentsImpact?: AgentsImpact;
278
+ agentsTarget?: string;
279
+ agentsManagedOnly?: true;
280
+ approvedDependencies?: string[];
281
+ impactClosure?: ImpactClosureContract;
74
282
  verification?: VerificationContract;
75
283
  snapshot?: unknown;
76
284
  }
77
285
 
78
- export interface RecoveryRecord {
79
- code: RecoveryCode;
286
+ export type RunRequest = RequestEnvelope | ImplementRunRequest;
287
+
288
+ export interface AgentsCheckpointRequest {
289
+ stage: "abel-implement";
290
+ taskId: string;
291
+ agentsImpact: Exclude<AgentsImpact, "none">;
292
+ agentsTarget: string;
293
+ agentsManagedOnly: true;
294
+ stableCheckpoint: true;
295
+ snapshot: unknown;
296
+ diff: string;
297
+ }
298
+
299
+ export interface AgentsCheckpointAttempt {
300
+ changeId: string;
80
301
  taskId: string;
81
302
  requestId: string;
82
- phase: string;
83
- launchIndex: 0 | 1;
84
- branchBlocked: true;
85
- dependentsBlocked: true;
86
- partialResultUsable: false;
87
- independentResultsPreserved: true;
88
- next: RecoveryNext;
303
+ snapshot: unknown;
304
+ diff: string;
305
+ }
306
+
307
+ export interface ImplementApplyOperation {
308
+ resultId: string;
309
+ requestId: string;
310
+ }
311
+
312
+ export interface ImplementDiscardOperation extends ImplementApplyOperation {
313
+ rejection: CandidateRejection;
89
314
  }
90
315
 
91
316
  export function isValidRelativePath(p: unknown): p is string {
@@ -95,12 +320,20 @@ export function isValidRelativePath(p: unknown): p is string {
95
320
  p.length <= 512 &&
96
321
  !NONCANONICAL.test(p) &&
97
322
  !p.startsWith("/") &&
323
+ (p === "." ||
324
+ (!p.startsWith("./") &&
325
+ !p.endsWith("/") &&
326
+ !p.split("/").includes("."))) &&
98
327
  !p.includes("..") &&
99
328
  !p.includes("\\") &&
100
329
  !p.includes("\u0000")
101
330
  );
102
331
  }
103
332
 
333
+ export function isAgentsPath(p: unknown): p is string {
334
+ return isValidRelativePath(p) && /(?:^|\/)AGENTS\.md$/u.test(p);
335
+ }
336
+
104
337
  export function validateVerificationContract(
105
338
  value: unknown,
106
339
  ): { ok: true; value: VerificationContract } | { ok: false; reason: string } {
@@ -179,6 +412,7 @@ function validateImplementationSnapshot(
179
412
  const entry = value as Record<string, unknown>;
180
413
  if (entry.kind === "file") {
181
414
  if (
415
+ !hasExactKeys(entry, ["kind", "sha256", "bytes"]) ||
182
416
  typeof entry.sha256 !== "string" ||
183
417
  !SNAPSHOT_SHA256.test(entry.sha256) ||
184
418
  typeof entry.bytes !== "number" ||
@@ -189,29 +423,610 @@ function validateImplementationSnapshot(
189
423
  }
190
424
  } else if (entry.kind === "dir") {
191
425
  if (
426
+ !hasExactKeys(entry, ["kind", "manifest"]) ||
192
427
  typeof entry.manifest !== "string" ||
193
428
  !SNAPSHOT_SHA256.test(entry.manifest)
194
429
  ) {
195
430
  return "invalid snapshot";
196
431
  }
197
432
  } else if (entry.kind === "absent") {
198
- if (entry.absent !== true) return "invalid snapshot";
433
+ if (!hasExactKeys(entry, ["kind", "absent"]) || entry.absent !== true) {
434
+ return "invalid snapshot";
435
+ }
199
436
  } else {
200
437
  return "invalid snapshot";
201
438
  }
202
439
  }
440
+ if ([...new Set(paths)].some((path) => !Object.hasOwn(bounds, path))) {
441
+ return "snapshot does not cover declared paths";
442
+ }
443
+ return null;
444
+ }
445
+
446
+ function validateAgentsContract(env: Record<string, unknown>): string | null {
447
+ if (!(AGENTS_IMPACTS as readonly unknown[]).includes(env.agentsImpact)) {
448
+ return "invalid AGENTS impact";
449
+ }
450
+ if (env.agentsManagedOnly !== true) return "AGENTS must be managed-only";
451
+ if (env.agentsImpact === "none") {
452
+ return env.agentsTarget === undefined
453
+ ? null
454
+ : "none AGENTS impact cannot declare a target";
455
+ }
456
+ return isAgentsPath(env.agentsTarget)
457
+ ? null
458
+ : "AGENTS impact requires an explicit AGENTS target";
459
+ }
460
+
461
+ function validateApprovedDependencies(value: unknown): string | null {
462
+ if (
463
+ !Array.isArray(value) ||
464
+ value.some(
465
+ (dependency) =>
466
+ typeof dependency !== "string" ||
467
+ !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/iu.test(
468
+ dependency,
469
+ ),
470
+ ) ||
471
+ new Set(value).size !== value.length
472
+ ) {
473
+ return "invalid approved dependencies";
474
+ }
475
+ return null;
476
+ }
477
+
478
+ function validateImpactClosure(
479
+ value: unknown,
480
+ declared: Record<string, unknown>,
481
+ snapshot: unknown,
482
+ ): string | null {
483
+ if (
484
+ !hasExactKeys(value, [
485
+ "changedSurfaces",
486
+ "searchEvidence",
487
+ "relatedTests",
488
+ "affectedSuite",
489
+ ])
490
+ ) {
491
+ return "invalid impact closure";
492
+ }
493
+ const closure = value as Record<string, unknown>;
494
+ const surfaces = closure.changedSurfaces;
495
+ const searchEvidence = closure.searchEvidence;
496
+ const relatedTests = closure.relatedTests;
497
+ const affectedSuite = closure.affectedSuite;
498
+ if (
499
+ !Array.isArray(surfaces) ||
500
+ surfaces.length === 0 ||
501
+ surfaces.some(
502
+ (surface) => !(IMPACT_SURFACES as readonly unknown[]).includes(surface),
503
+ ) ||
504
+ new Set(surfaces).size !== surfaces.length ||
505
+ (surfaces.includes("none") && surfaces.length !== 1) ||
506
+ !Array.isArray(searchEvidence) ||
507
+ searchEvidence.some(
508
+ (evidence) => typeof evidence !== "string" || evidence.length === 0,
509
+ ) ||
510
+ !Array.isArray(relatedTests) ||
511
+ !Array.isArray(affectedSuite) ||
512
+ affectedSuite.some(
513
+ (test) => !isValidRelativePath(test) || !isTestPath(test),
514
+ ) ||
515
+ new Set(affectedSuite).size !== affectedSuite.length
516
+ ) {
517
+ return "invalid impact closure";
518
+ }
519
+
520
+ const scope = new Set([
521
+ ...((declared.read as string[]) ?? []),
522
+ ...((declared.write as string[]) ?? []),
523
+ ]);
524
+ const writes = new Set((declared.write as string[]) ?? []);
525
+ const relatedPaths = new Set<string>();
526
+ for (const item of relatedTests) {
527
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
528
+ return "invalid impact closure related test";
529
+ }
530
+ const related = item as Record<string, unknown>;
531
+ const relatedKeys =
532
+ related.disposition === "regression-task"
533
+ ? ["path", "disposition", "evidence", "regressionTaskId"]
534
+ : ["path", "disposition", "evidence"];
535
+ if (
536
+ !hasExactKeys(related, relatedKeys) ||
537
+ !isValidRelativePath(related.path) ||
538
+ !isTestPath(related.path) ||
539
+ relatedPaths.has(related.path) ||
540
+ !scope.has(related.path) ||
541
+ !["current-task", "regression-task", "unaffected"].includes(
542
+ String(related.disposition),
543
+ ) ||
544
+ typeof related.evidence !== "string" ||
545
+ related.evidence.length === 0
546
+ ) {
547
+ return "invalid impact closure related test";
548
+ }
549
+ relatedPaths.add(related.path);
550
+ if (related.disposition === "current-task" && !writes.has(related.path)) {
551
+ return "current-task related test is outside the write set";
552
+ }
553
+ if (
554
+ related.disposition === "regression-task" &&
555
+ (typeof related.regressionTaskId !== "string" ||
556
+ related.regressionTaskId.length === 0)
557
+ ) {
558
+ return "regression task identity is required";
559
+ }
560
+ if (
561
+ related.disposition !== "regression-task" &&
562
+ related.regressionTaskId !== undefined
563
+ ) {
564
+ return "unexpected regression task identity";
565
+ }
566
+ }
567
+
568
+ if (surfaces[0] === "none") return null;
569
+ if (
570
+ searchEvidence.length === 0 ||
571
+ relatedTests.length === 0 ||
572
+ affectedSuite.length === 0 ||
573
+ affectedSuite.some((test) => !relatedPaths.has(test))
574
+ ) {
575
+ return "public impact closure is incomplete";
576
+ }
577
+ const bounds = snapshot as Record<string, { kind?: unknown }>;
578
+ if (!affectedSuite.some((test) => bounds?.[test]?.kind === "file")) {
579
+ return "affected suite contains no existing test evidence";
580
+ }
581
+ return null;
582
+ }
583
+
584
+ function isTestPath(path: string): boolean {
585
+ return /^(?:test|tests)\//u.test(path);
586
+ }
587
+
588
+ export function validateAgentsCheckpointRequest(
589
+ value: unknown,
590
+ ):
591
+ | { ok: true; value: AgentsCheckpointRequest }
592
+ | { ok: false; reason: string } {
593
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
594
+ return { ok: false, reason: "missing AGENTS checkpoint request" };
595
+ }
596
+ const request = value as Record<string, unknown>;
597
+ if (
598
+ request.stage !== "abel-implement" ||
599
+ typeof request.taskId !== "string" ||
600
+ request.taskId.length === 0 ||
601
+ request.taskId.length > 128 ||
602
+ request.agentsImpact === "none" ||
603
+ !(AGENTS_IMPACTS as readonly unknown[]).includes(request.agentsImpact) ||
604
+ !isAgentsPath(request.agentsTarget) ||
605
+ request.agentsManagedOnly !== true ||
606
+ request.stableCheckpoint !== true ||
607
+ typeof request.diff !== "string" ||
608
+ request.diff.length === 0 ||
609
+ request.diff.length > LIMITS.maxCompleteResultBytes
610
+ ) {
611
+ return { ok: false, reason: "invalid AGENTS checkpoint contract" };
612
+ }
613
+ const snapshotReason = validateImplementationSnapshot(request.snapshot, [
614
+ request.agentsTarget,
615
+ ] as string[]);
616
+ if (snapshotReason !== null) {
617
+ return { ok: false, reason: snapshotReason };
618
+ }
619
+ return {
620
+ ok: true,
621
+ value: request as unknown as AgentsCheckpointRequest,
622
+ };
623
+ }
624
+
625
+ export function validateAgentsCheckpointAttempt(
626
+ value: unknown,
627
+ ):
628
+ | { ok: true; value: AgentsCheckpointAttempt }
629
+ | { ok: false; reason: string } {
630
+ if (
631
+ !hasExactKeys(value, [
632
+ "changeId",
633
+ "taskId",
634
+ "requestId",
635
+ "snapshot",
636
+ "diff",
637
+ ]) ||
638
+ !validIdentifier(value.changeId) ||
639
+ !validIdentifier(value.taskId) ||
640
+ !validIdentifier(value.requestId) ||
641
+ typeof value.diff !== "string" ||
642
+ value.diff.length === 0 ||
643
+ value.diff.length > LIMITS.maxCompleteResultBytes ||
644
+ !value.snapshot ||
645
+ typeof value.snapshot !== "object" ||
646
+ Array.isArray(value.snapshot) ||
647
+ Object.keys(value.snapshot).length === 0 ||
648
+ validateImplementationSnapshot(
649
+ value.snapshot,
650
+ Object.keys(value.snapshot as Record<string, unknown>),
651
+ ) !== null
652
+ ) {
653
+ return { ok: false, reason: "invalid AGENTS checkpoint attempt" };
654
+ }
655
+ return {
656
+ ok: true,
657
+ value: structuredClone(value) as unknown as AgentsCheckpointAttempt,
658
+ };
659
+ }
660
+
661
+ export function validateImplementApplyOperation(
662
+ value: unknown,
663
+ ):
664
+ | { ok: true; value: ImplementApplyOperation }
665
+ | { ok: false; reason: string } {
666
+ if (
667
+ !hasExactKeys(value, ["resultId", "requestId"]) ||
668
+ !validIdentifier(value.resultId) ||
669
+ !validIdentifier(value.requestId)
670
+ ) {
671
+ return { ok: false, reason: "invalid Implement apply operation" };
672
+ }
673
+ return {
674
+ ok: true,
675
+ value: { resultId: value.resultId, requestId: value.requestId },
676
+ };
677
+ }
678
+
679
+ function validateCandidateRejection(
680
+ value: unknown,
681
+ ): value is CandidateRejection {
682
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
683
+ return false;
684
+ }
685
+ const rejection = value as Record<string, unknown>;
686
+ if (rejection.kind === "approval-boundary") {
687
+ return (
688
+ hasExactKeys(rejection, ["kind", "code"]) &&
689
+ (APPROVAL_BOUNDARY_CODES as readonly unknown[]).includes(rejection.code)
690
+ );
691
+ }
692
+ if (
693
+ rejection.kind !== "artifact" ||
694
+ rejection.code !== "parent-review-rejected" ||
695
+ !hasExactKeys(rejection, ["kind", "code"], ["evidence"])
696
+ ) {
697
+ return false;
698
+ }
699
+ return (
700
+ rejection.evidence === undefined ||
701
+ (Array.isArray(rejection.evidence) &&
702
+ rejection.evidence.length <= 8 &&
703
+ rejection.evidence.every(
704
+ (item) =>
705
+ typeof item === "string" && item.length > 0 && item.length <= 512,
706
+ ))
707
+ );
708
+ }
709
+
710
+ export function validateImplementDiscardOperation(
711
+ value: unknown,
712
+ ):
713
+ | { ok: true; value: ImplementDiscardOperation }
714
+ | { ok: false; reason: string } {
715
+ if (
716
+ !hasExactKeys(value, ["resultId", "requestId", "rejection"]) ||
717
+ !validIdentifier(value.resultId) ||
718
+ !validIdentifier(value.requestId) ||
719
+ !validateCandidateRejection(value.rejection)
720
+ ) {
721
+ return { ok: false, reason: "invalid Implement discard operation" };
722
+ }
723
+ return {
724
+ ok: true,
725
+ value: structuredClone(value) as unknown as ImplementDiscardOperation,
726
+ };
727
+ }
728
+
729
+ function hasExactKeys(
730
+ value: unknown,
731
+ required: readonly string[],
732
+ optional: readonly string[] = [],
733
+ ): value is Record<string, unknown> {
734
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
735
+ const record = value as Record<string, unknown>;
736
+ const allowed = new Set([...required, ...optional]);
737
+ return (
738
+ required.every((key) => Object.hasOwn(record, key)) &&
739
+ Object.keys(record).every((key) => allowed.has(key))
740
+ );
741
+ }
742
+
743
+ function validIdentifier(value: unknown): value is string {
744
+ return typeof value === "string" && value.length > 0 && value.length <= 128;
745
+ }
746
+
747
+ function validatePathSet(value: unknown): value is string[] {
748
+ return (
749
+ Array.isArray(value) &&
750
+ value.every(isValidRelativePath) &&
751
+ new Set(value).size === value.length
752
+ );
753
+ }
754
+
755
+ function validateRoots(value: unknown): value is string[] {
756
+ if (!validatePathSet(value) || value.length === 0) return false;
757
+ if (
758
+ value.some(
759
+ (root) => (root !== "." && root.startsWith("./")) || root.endsWith("/"),
760
+ )
761
+ ) {
762
+ return false;
763
+ }
764
+ return !value.some((root, index) =>
765
+ value.some(
766
+ (other, otherIndex) =>
767
+ index !== otherIndex && (root === "." || other.startsWith(`${root}/`)),
768
+ ),
769
+ );
770
+ }
771
+
772
+ function isWithinRoots(path: string, roots: string[]): boolean {
773
+ return roots.some(
774
+ (root) => root === "." || path === root || path.startsWith(`${root}/`),
775
+ );
776
+ }
777
+
778
+ function validatePhaseBoundary(
779
+ value: unknown,
780
+ phase: ImplementationPhase,
781
+ ): value is PhaseBoundary {
203
782
  if (
204
- [...new Set(paths)].some(
205
- (path) => !Object.prototype.hasOwnProperty.call(bounds, path),
783
+ !hasExactKeys(
784
+ value,
785
+ ["read", "write", "verification"],
786
+ ["verificationLock"],
206
787
  )
207
788
  ) {
208
- return "snapshot does not cover declared paths";
789
+ return false;
790
+ }
791
+ if (
792
+ !validatePathSet(value.read) ||
793
+ !validatePathSet(value.write) ||
794
+ value.write.some(isAgentsPath) ||
795
+ (value.verificationLock !== undefined &&
796
+ !isValidRelativePath(value.verificationLock))
797
+ ) {
798
+ return false;
799
+ }
800
+ const verificationKeys =
801
+ phase === "red"
802
+ ? ["id", "argv", "classification", "expectedFailure", "minTests"]
803
+ : ["id", "argv", "classification", "minTests"];
804
+ if (!hasExactKeys(value.verification, verificationKeys)) return false;
805
+ const verification = validateVerificationContract(value.verification);
806
+ if (!verification.ok) return false;
807
+ const classification = {
808
+ red: "expected-red",
809
+ green: "expected-green",
810
+ refactor: "expected-refactor",
811
+ } as const;
812
+ return verification.value.classification === classification[phase];
813
+ }
814
+
815
+ function validateTaskBoundary(
816
+ value: unknown,
817
+ snapshot: unknown,
818
+ ): value is TaskBoundary {
819
+ if (
820
+ !hasExactKeys(value, [
821
+ "changeId",
822
+ "taskId",
823
+ "objective",
824
+ "context",
825
+ "roots",
826
+ "phases",
827
+ "scheduling",
828
+ "agents",
829
+ "approvedDependencies",
830
+ "impactClosure",
831
+ ]) ||
832
+ !validIdentifier(value.changeId) ||
833
+ !validIdentifier(value.taskId) ||
834
+ typeof value.objective !== "string" ||
835
+ value.objective.length === 0 ||
836
+ value.objective.length > 4096 ||
837
+ !hasExactKeys(value.context, ["agents", "contract"]) ||
838
+ typeof value.context.agents !== "string" ||
839
+ typeof value.context.contract !== "string" ||
840
+ !validateRoots(value.roots) ||
841
+ !hasExactKeys(value.phases, ["red", "green"], ["refactor"]) ||
842
+ !validatePhaseBoundary(value.phases.red, "red") ||
843
+ !validatePhaseBoundary(value.phases.green, "green") ||
844
+ (value.phases.refactor !== undefined &&
845
+ !validatePhaseBoundary(value.phases.refactor, "refactor")) ||
846
+ !hasExactKeys(value.scheduling, ["conflicts", "resources"]) ||
847
+ !validatePathSet(value.scheduling.conflicts) ||
848
+ !validatePathSet(value.scheduling.resources) ||
849
+ !hasExactKeys(value.agents, ["impact", "managedOnly"], ["target"]) ||
850
+ !(AGENTS_IMPACTS as readonly unknown[]).includes(value.agents.impact) ||
851
+ value.agents.managedOnly !== true
852
+ ) {
853
+ return false;
854
+ }
855
+ if (
856
+ value.agents.impact === "none"
857
+ ? value.agents.target !== undefined
858
+ : !isAgentsPath(value.agents.target)
859
+ ) {
860
+ return false;
861
+ }
862
+ if (validateApprovedDependencies(value.approvedDependencies) !== null) {
863
+ return false;
864
+ }
865
+ const phases = [
866
+ value.phases.red,
867
+ value.phases.green,
868
+ ...(value.phases.refactor ? [value.phases.refactor] : []),
869
+ ];
870
+ const roots = value.roots as string[];
871
+ if (
872
+ phases.some((phase) =>
873
+ [...phase.read, ...phase.write].some(
874
+ (path) => !isWithinRoots(path, roots),
875
+ ),
876
+ )
877
+ ) {
878
+ return false;
879
+ }
880
+ const declared = {
881
+ read: [...new Set(phases.flatMap((entry) => entry.read))],
882
+ write: [...new Set(phases.flatMap((entry) => entry.write))],
883
+ };
884
+ return (
885
+ validateImpactClosure(value.impactClosure, declared, snapshot) === null
886
+ );
887
+ }
888
+
889
+ function validatePhaseAttemptShape(value: unknown): value is PhaseAttempt {
890
+ if (
891
+ !hasExactKeys(value, [
892
+ "changeId",
893
+ "taskId",
894
+ "requestId",
895
+ "phase",
896
+ "snapshot",
897
+ ]) ||
898
+ !validIdentifier(value.changeId) ||
899
+ !validIdentifier(value.taskId) ||
900
+ !validIdentifier(value.requestId) ||
901
+ !["red", "green", "refactor"].includes(String(value.phase))
902
+ ) {
903
+ return false;
904
+ }
905
+ if (
906
+ !value.snapshot ||
907
+ typeof value.snapshot !== "object" ||
908
+ Array.isArray(value.snapshot)
909
+ ) {
910
+ return false;
911
+ }
912
+ return (
913
+ validateImplementationSnapshot(
914
+ value.snapshot,
915
+ Object.keys(value.snapshot as Record<string, unknown>),
916
+ ) === null
917
+ );
918
+ }
919
+
920
+ export function validatePhaseAttemptAgainstBoundary(
921
+ boundary: TaskBoundary,
922
+ attempt: PhaseAttempt,
923
+ ): string | null {
924
+ if (
925
+ attempt.changeId !== boundary.changeId ||
926
+ attempt.taskId !== boundary.taskId
927
+ ) {
928
+ return "task attempt identity mismatch";
929
+ }
930
+ const phase = boundary.phases[attempt.phase];
931
+ if (!phase) return "task attempt phase is not declared";
932
+ const snapshotReason = validateImplementationSnapshot(attempt.snapshot, [
933
+ ...phase.read,
934
+ ...phase.write,
935
+ ]);
936
+ if (snapshotReason !== null) return snapshotReason;
937
+ const bounds = attempt.snapshot as Record<string, { kind?: unknown }>;
938
+ if (
939
+ phase.write.some(
940
+ (path) =>
941
+ bounds[path]?.kind !== "file" && bounds[path]?.kind !== "absent",
942
+ )
943
+ ) {
944
+ return "write snapshot must bind a regular file or absent path";
209
945
  }
210
946
  return null;
211
947
  }
212
948
 
949
+ function validateImplementRunRequest(
950
+ value: unknown,
951
+ ): { ok: true; value: ImplementRunRequest } | { ok: false; reason: string } {
952
+ const serialized = JSON.stringify(value);
953
+ if (serialized.length > LIMITS.maxEnvelopeBytes) {
954
+ return {
955
+ ok: false,
956
+ reason: `request envelope exceeds ${LIMITS.maxEnvelopeBytes / 1024} KiB`,
957
+ };
958
+ }
959
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
960
+ return { ok: false, reason: "missing request envelope" };
961
+ }
962
+ const request = value as Record<string, unknown>;
963
+ if (request.kind === "open-task") {
964
+ if (
965
+ request.attempt &&
966
+ typeof request.attempt === "object" &&
967
+ !Array.isArray(request.attempt) &&
968
+ !Object.hasOwn(request.attempt, "snapshot")
969
+ ) {
970
+ return { ok: false, reason: "invalid snapshot" };
971
+ }
972
+ if (
973
+ !hasExactKeys(request, ["stage", "kind", "boundary", "attempt"]) ||
974
+ !validatePhaseAttemptShape(request.attempt) ||
975
+ !validateTaskBoundary(request.boundary, request.attempt.snapshot)
976
+ ) {
977
+ return { ok: false, reason: "invalid task open contract" };
978
+ }
979
+ const boundary = request.boundary as TaskBoundary;
980
+ const attempt = request.attempt as PhaseAttempt;
981
+ const attemptReason = validatePhaseAttemptAgainstBoundary(
982
+ boundary,
983
+ attempt,
984
+ );
985
+ if (attempt.phase !== "red" || attemptReason !== null) {
986
+ return {
987
+ ok: false,
988
+ reason:
989
+ attempt.phase !== "red"
990
+ ? "task open must begin with Red"
991
+ : (attemptReason ?? "invalid phase attempt"),
992
+ };
993
+ }
994
+ return {
995
+ ok: true,
996
+ value: structuredClone(request) as ImplementRunRequest,
997
+ };
998
+ }
999
+ if (request.kind === "phase-attempt") {
1000
+ if (
1001
+ !hasExactKeys(request, ["stage", "kind", "attempt"]) ||
1002
+ !validatePhaseAttemptShape(request.attempt)
1003
+ ) {
1004
+ return { ok: false, reason: "invalid phase attempt contract" };
1005
+ }
1006
+ return {
1007
+ ok: true,
1008
+ value: structuredClone(request) as ImplementRunRequest,
1009
+ };
1010
+ }
1011
+ return { ok: false, reason: "invalid Implement run kind" };
1012
+ }
1013
+
213
1014
  export function validateRequestEnvelope(
214
1015
  value: unknown,
1016
+ ): { ok: true; value: RunRequest } | { ok: false; reason: string } {
1017
+ if (
1018
+ value &&
1019
+ typeof value === "object" &&
1020
+ !Array.isArray(value) &&
1021
+ (value as Record<string, unknown>).stage === "abel-implement"
1022
+ ) {
1023
+ return validateImplementRunRequest(value);
1024
+ }
1025
+ return validateLegacyRequestEnvelope(value);
1026
+ }
1027
+
1028
+ function validateLegacyRequestEnvelope(
1029
+ value: unknown,
215
1030
  ): { ok: true; value: RequestEnvelope } | { ok: false; reason: string } {
216
1031
  if (value === null || typeof value !== "object") {
217
1032
  return { ok: false, reason: "missing request envelope" };
@@ -310,6 +1125,9 @@ export function validateRequestEnvelope(
310
1125
  return { ok: false, reason: `invalid declared ${key} set` };
311
1126
  }
312
1127
  }
1128
+ if ((decl.write as unknown[]).some(isAgentsPath)) {
1129
+ return { ok: false, reason: "subagents cannot declare AGENTS writes" };
1130
+ }
313
1131
  if (
314
1132
  decl.verificationLock !== undefined &&
315
1133
  !isValidRelativePath(decl.verificationLock)
@@ -329,6 +1147,20 @@ export function validateRequestEnvelope(
329
1147
  }
330
1148
  }
331
1149
  if (env.stage === "abel-implement" && env.output === "diff") {
1150
+ const agentsReason = validateAgentsContract(env);
1151
+ if (agentsReason !== null) return { ok: false, reason: agentsReason };
1152
+ const dependenciesReason = validateApprovedDependencies(
1153
+ env.approvedDependencies,
1154
+ );
1155
+ if (dependenciesReason !== null) {
1156
+ return { ok: false, reason: dependenciesReason };
1157
+ }
1158
+ const closureReason = validateImpactClosure(
1159
+ env.impactClosure,
1160
+ decl,
1161
+ env.snapshot,
1162
+ );
1163
+ if (closureReason !== null) return { ok: false, reason: closureReason };
332
1164
  if (env.verification === undefined) {
333
1165
  return { ok: false, reason: "missing required field: verification" };
334
1166
  }
@@ -592,37 +1424,154 @@ export interface DiffResult {
592
1424
  diff: string;
593
1425
  expectedVerification: string;
594
1426
  risks: string[];
595
- nextStep: string;
596
1427
  contractCompliant: boolean;
597
1428
  }
598
1429
 
1430
+ export interface ApplyResult {
1431
+ targets: string[];
1432
+ checkExitCode: 0;
1433
+ applyExitCode: 0;
1434
+ sequence?: number;
1435
+ }
1436
+
1437
+ export interface AgentsCheckpointResult {
1438
+ target: string;
1439
+ agentsImpact: Exclude<TaskBoundary["agents"]["impact"], "none">;
1440
+ checkExitCode: 0;
1441
+ applyExitCode: 0;
1442
+ sequence?: number;
1443
+ }
1444
+
1445
+ export type ImplementOutcome =
1446
+ | {
1447
+ kind: "deferred";
1448
+ requestId: string;
1449
+ taskId: string;
1450
+ reason: "task-conflict";
1451
+ }
1452
+ | {
1453
+ kind: "candidate";
1454
+ requestId: string;
1455
+ taskId: string;
1456
+ phase: ImplementationPhase;
1457
+ resultId: string;
1458
+ result: DiffResult;
1459
+ }
1460
+ | {
1461
+ kind: "applied";
1462
+ requestId: string;
1463
+ taskId: string;
1464
+ phase: "red" | "green";
1465
+ readyPhase: "green" | "refactor";
1466
+ result: ApplyResult;
1467
+ }
1468
+ | {
1469
+ kind: "checkpoint-required";
1470
+ requestId: string;
1471
+ taskId: string;
1472
+ finalPhase: "green" | "refactor";
1473
+ result: ApplyResult;
1474
+ }
1475
+ | {
1476
+ kind: "retry";
1477
+ requestId: string;
1478
+ taskId: string;
1479
+ scope: "worker" | "checkpoint";
1480
+ phase: ImplementationPhase;
1481
+ cause: "artifact" | "stale";
1482
+ remainingAttempts: 1;
1483
+ }
1484
+ | {
1485
+ kind: "completed";
1486
+ requestId: string;
1487
+ taskId: string;
1488
+ finalPhase: "green" | "refactor";
1489
+ result?: ApplyResult | AgentsCheckpointResult;
1490
+ }
1491
+ | {
1492
+ kind: "blocked";
1493
+ requestId: string;
1494
+ taskId: string;
1495
+ phase: ImplementationPhase;
1496
+ failure: TaskFailure;
1497
+ }
1498
+ | {
1499
+ kind: "cancelled";
1500
+ requestId: string;
1501
+ taskId: string;
1502
+ phase: ImplementationPhase;
1503
+ };
1504
+
599
1505
  export function validateEvidenceResult(value: unknown): {
600
1506
  ok: boolean;
601
1507
  reason?: string;
1508
+ failure?: CandidateFailure;
602
1509
  } {
603
1510
  if (value === null || typeof value !== "object")
604
1511
  return { ok: false, reason: "missing result" };
605
1512
  const r = value as Record<string, unknown>;
606
1513
  if (r.kind !== "evidence") return { ok: false, reason: "wrong result kind" };
607
- for (const field of [
1514
+ const fields = [
608
1515
  "id",
609
1516
  "role",
1517
+ "kind",
610
1518
  "conclusions",
611
1519
  "citations",
1520
+ "constraints",
1521
+ "dependencies",
1522
+ "risks",
612
1523
  "blockingQuestions",
613
- ]) {
614
- if (r[field] === undefined)
615
- return { ok: false, reason: `missing field: ${field}` };
1524
+ "hints",
1525
+ ] as const;
1526
+ if (!hasExactKeys(r, fields)) {
1527
+ return { ok: false, reason: "invalid evidence result fields" };
616
1528
  }
617
- if (typeof r.id !== "string" || typeof r.role !== "string")
1529
+ if (!validIdentifier(r.id) || typeof r.role !== "string")
618
1530
  return { ok: false, reason: "invalid identity" };
619
1531
  if (!(ROLES as readonly string[]).includes(r.role))
620
1532
  return { ok: false, reason: "unknown role" };
1533
+ for (const field of [
1534
+ "conclusions",
1535
+ "constraints",
1536
+ "dependencies",
1537
+ "risks",
1538
+ "blockingQuestions",
1539
+ ] as const) {
1540
+ if (
1541
+ !Array.isArray(r[field]) ||
1542
+ !r[field].every((entry) => typeof entry === "string")
1543
+ ) {
1544
+ return { ok: false, reason: `invalid evidence field: ${field}` };
1545
+ }
1546
+ }
1547
+ if (
1548
+ !Array.isArray(r.citations) ||
1549
+ r.citations.some(
1550
+ (citation) =>
1551
+ !hasExactKeys(citation, ["path", "lines"]) ||
1552
+ !isValidRelativePath(citation.path) ||
1553
+ typeof citation.lines !== "string",
1554
+ )
1555
+ ) {
1556
+ return { ok: false, reason: "invalid evidence citations" };
1557
+ }
1558
+ if (
1559
+ !hasExactKeys(r.hints, ["writeSet", "verification", "agentsImpact"]) ||
1560
+ !validatePathSet(r.hints.writeSet) ||
1561
+ typeof r.hints.verification !== "string" ||
1562
+ !(AGENTS_IMPACTS as readonly unknown[]).includes(r.hints.agentsImpact)
1563
+ ) {
1564
+ return { ok: false, reason: "invalid evidence hints" };
1565
+ }
621
1566
  const serialized = JSON.stringify(r);
622
- if (serialized.length > LIMITS.maxCompleteResultBytes) {
1567
+ if (Buffer.byteLength(serialized, "utf8") > LIMITS.maxCompleteResultBytes) {
623
1568
  return {
624
1569
  ok: false,
625
1570
  reason: `result exceeds ${LIMITS.maxCompleteResultBytes} bytes`,
1571
+ failure: {
1572
+ kind: "result-limit",
1573
+ limitBytes: LIMITS.maxCompleteResultBytes,
1574
+ },
626
1575
  };
627
1576
  }
628
1577
  return { ok: true };
@@ -632,23 +1581,28 @@ export function validateDiffResult(value: unknown): {
632
1581
  ok: boolean;
633
1582
  reason?: string;
634
1583
  paths?: string[];
1584
+ failure?: CandidateFailure;
635
1585
  } {
636
1586
  if (value === null || typeof value !== "object")
637
1587
  return { ok: false, reason: "missing result" };
638
1588
  const r = value as Record<string, unknown>;
639
1589
  if (r.kind !== "diff") return { ok: false, reason: "wrong result kind" };
640
- for (const field of [
1590
+ const fields = [
641
1591
  "id",
642
1592
  "role",
1593
+ "kind",
643
1594
  "taskId",
644
1595
  "phase",
645
1596
  "summary",
646
1597
  "diff",
647
1598
  "expectedVerification",
648
1599
  "risks",
649
- "nextStep",
650
1600
  "contractCompliant",
651
- ]) {
1601
+ ] as const;
1602
+ if (!hasExactKeys(r, fields)) {
1603
+ return { ok: false, reason: "invalid diff result fields" };
1604
+ }
1605
+ for (const field of fields) {
652
1606
  if (r[field] === undefined)
653
1607
  return { ok: false, reason: `missing field: ${field}` };
654
1608
  }
@@ -675,9 +1629,7 @@ export function validateDiffResult(value: unknown): {
675
1629
  typeof r.summary !== "string" ||
676
1630
  r.summary.length === 0 ||
677
1631
  typeof r.expectedVerification !== "string" ||
678
- r.expectedVerification.length === 0 ||
679
- typeof r.nextStep !== "string" ||
680
- r.nextStep.length === 0
1632
+ r.expectedVerification.length === 0
681
1633
  ) {
682
1634
  return { ok: false, reason: "invalid diff result text" };
683
1635
  }
@@ -696,10 +1648,14 @@ export function validateDiffResult(value: unknown): {
696
1648
  return { ok: false, reason: `invalid diff: ${(err as Error).message}` };
697
1649
  }
698
1650
  const serialized = JSON.stringify(r);
699
- if (serialized.length > LIMITS.maxCompleteResultBytes) {
1651
+ if (Buffer.byteLength(serialized, "utf8") > LIMITS.maxCompleteResultBytes) {
700
1652
  return {
701
1653
  ok: false,
702
1654
  reason: `result exceeds ${LIMITS.maxCompleteResultBytes} bytes`,
1655
+ failure: {
1656
+ kind: "result-limit",
1657
+ limitBytes: LIMITS.maxCompleteResultBytes,
1658
+ },
703
1659
  };
704
1660
  }
705
1661
  return { ok: true, paths };