@abelxiaoxing/cadence 1.0.2 → 1.0.3

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