@abelxiaoxing/cadence 1.0.1 → 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,6 +21,23 @@ export const PHASES = [
21
21
  "review",
22
22
  ] as const;
23
23
 
24
+ export const AGENTS_IMPACTS = [
25
+ "none",
26
+ "update-existing",
27
+ "create-index",
28
+ "remove-index",
29
+ ] as const;
30
+ export type AgentsImpact = (typeof AGENTS_IMPACTS)[number];
31
+
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];
40
+
24
41
  export const LIMITS = {
25
42
  maxActiveChildSessions: 4,
26
43
  maxRequestsPerBatch: 8,
@@ -29,11 +46,214 @@ export const LIMITS = {
29
46
  maxCompleteResultBytes: 64 * 1024,
30
47
  } as const;
31
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
+
32
179
  const NONCANONICAL = /(^|\/)\.\.(\/|$)|(^|\/)\/|^\//;
180
+ const SNAPSHOT_SHA256 = /^[a-f0-9]{64}$/;
181
+
182
+ export interface VerificationContract {
183
+ id: string;
184
+ argv: string[];
185
+ classification: "expected-red" | "expected-green" | "expected-refactor";
186
+ expectedFailure?: string;
187
+ minTests: number;
188
+ }
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
+ };
33
252
 
34
253
  export interface RequestEnvelope {
35
254
  stage: string;
36
255
  role: string;
256
+ taskId?: string;
37
257
  id: string;
38
258
  phase: string;
39
259
  objective: string;
@@ -47,9 +267,45 @@ export interface RequestEnvelope {
47
267
  verificationLock?: string;
48
268
  };
49
269
  output: string;
270
+ agentsImpact?: AgentsImpact;
271
+ agentsTarget?: string;
272
+ agentsManagedOnly?: true;
273
+ approvedDependencies?: string[];
274
+ impactClosure?: ImpactClosureContract;
275
+ verification?: VerificationContract;
50
276
  snapshot?: unknown;
51
277
  }
52
278
 
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;
294
+ taskId: string;
295
+ requestId: string;
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;
307
+ }
308
+
53
309
  export function isValidRelativePath(p: unknown): p is string {
54
310
  return (
55
311
  typeof p === "string" &&
@@ -57,14 +313,713 @@ export function isValidRelativePath(p: unknown): p is string {
57
313
  p.length <= 512 &&
58
314
  !NONCANONICAL.test(p) &&
59
315
  !p.startsWith("/") &&
316
+ (p === "." ||
317
+ (!p.startsWith("./") &&
318
+ !p.endsWith("/") &&
319
+ !p.split("/").includes("."))) &&
60
320
  !p.includes("..") &&
61
321
  !p.includes("\\") &&
62
322
  !p.includes("\u0000")
63
323
  );
64
324
  }
65
325
 
326
+ export function isAgentsPath(p: unknown): p is string {
327
+ return isValidRelativePath(p) && /(?:^|\/)AGENTS\.md$/u.test(p);
328
+ }
329
+
330
+ export function validateVerificationContract(
331
+ value: unknown,
332
+ ): { ok: true; value: VerificationContract } | { ok: false; reason: string } {
333
+ if (value === null || typeof value !== "object" || Array.isArray(value))
334
+ return { ok: false, reason: "invalid verification contract" };
335
+ const contract = value as Record<string, unknown>;
336
+ if (
337
+ typeof contract.id !== "string" ||
338
+ contract.id.length === 0 ||
339
+ contract.id.length > 128 ||
340
+ !Array.isArray(contract.argv) ||
341
+ contract.argv.some(
342
+ (part) =>
343
+ typeof part !== "string" ||
344
+ part.length === 0 ||
345
+ /[;&|`$<>\n\r]/u.test(part) ||
346
+ part.includes("\0"),
347
+ ) ||
348
+ !["expected-red", "expected-green", "expected-refactor"].includes(
349
+ String(contract.classification),
350
+ ) ||
351
+ !Number.isSafeInteger(contract.minTests) ||
352
+ (contract.minTests as number) < 1
353
+ ) {
354
+ return { ok: false, reason: "invalid verification contract" };
355
+ }
356
+ const argv = contract.argv as string[];
357
+ const check =
358
+ argv.length === 3 &&
359
+ argv[0] === "bun" &&
360
+ argv[1] === "run" &&
361
+ argv[2] === "check";
362
+ const target =
363
+ argv.length >= 4 &&
364
+ argv[0] === "bun" &&
365
+ argv[1] === "run" &&
366
+ argv[2] === "test:target" &&
367
+ argv
368
+ .slice(3)
369
+ .every((part) => part.startsWith("test/") && isValidRelativePath(part));
370
+ if (!check && !target)
371
+ return { ok: false, reason: "verification argv is not approved" };
372
+ if (
373
+ contract.classification === "expected-red"
374
+ ? typeof contract.expectedFailure !== "string" ||
375
+ contract.expectedFailure.length === 0
376
+ : contract.expectedFailure !== undefined
377
+ ) {
378
+ return { ok: false, reason: "invalid expected failure identity" };
379
+ }
380
+ return { ok: true, value: contract as unknown as VerificationContract };
381
+ }
382
+
383
+ function validateImplementationSnapshot(
384
+ snapshot: unknown,
385
+ paths: string[],
386
+ ): string | null {
387
+ if (
388
+ snapshot === undefined ||
389
+ snapshot === null ||
390
+ typeof snapshot !== "object" ||
391
+ Array.isArray(snapshot)
392
+ ) {
393
+ return "invalid snapshot";
394
+ }
395
+ const bounds = snapshot as Record<string, unknown>;
396
+ for (const [path, value] of Object.entries(bounds)) {
397
+ if (
398
+ !isValidRelativePath(path) ||
399
+ value === null ||
400
+ typeof value !== "object" ||
401
+ Array.isArray(value)
402
+ ) {
403
+ return "invalid snapshot";
404
+ }
405
+ const entry = value as Record<string, unknown>;
406
+ if (entry.kind === "file") {
407
+ if (
408
+ !hasExactKeys(entry, ["kind", "sha256", "bytes"]) ||
409
+ typeof entry.sha256 !== "string" ||
410
+ !SNAPSHOT_SHA256.test(entry.sha256) ||
411
+ typeof entry.bytes !== "number" ||
412
+ !Number.isSafeInteger(entry.bytes) ||
413
+ entry.bytes < 0
414
+ ) {
415
+ return "invalid snapshot";
416
+ }
417
+ } else if (entry.kind === "dir") {
418
+ if (
419
+ !hasExactKeys(entry, ["kind", "manifest"]) ||
420
+ typeof entry.manifest !== "string" ||
421
+ !SNAPSHOT_SHA256.test(entry.manifest)
422
+ ) {
423
+ return "invalid snapshot";
424
+ }
425
+ } else if (entry.kind === "absent") {
426
+ if (!hasExactKeys(entry, ["kind", "absent"]) || entry.absent !== true) {
427
+ return "invalid snapshot";
428
+ }
429
+ } else {
430
+ return "invalid snapshot";
431
+ }
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 } {
708
+ if (
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("/"),
753
+ )
754
+ ) {
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";
938
+ }
939
+ return null;
940
+ }
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
+
66
1007
  export function validateRequestEnvelope(
67
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,
68
1023
  ): { ok: true; value: RequestEnvelope } | { ok: false; reason: string } {
69
1024
  if (value === null || typeof value !== "object") {
70
1025
  return { ok: false, reason: "missing request envelope" };
@@ -98,6 +1053,23 @@ export function validateRequestEnvelope(
98
1053
  ) {
99
1054
  return { ok: false, reason: "invalid request id" };
100
1055
  }
1056
+ if (
1057
+ env.taskId !== undefined &&
1058
+ (typeof env.taskId !== "string" ||
1059
+ env.taskId.length === 0 ||
1060
+ env.taskId.length > 128)
1061
+ ) {
1062
+ return { ok: false, reason: "invalid task id" };
1063
+ }
1064
+ // Implementation phases use a phase-local request id and carry their stable
1065
+ // task identity explicitly. Other packets keep id as task id.
1066
+ if (
1067
+ env.stage === "abel-implement" &&
1068
+ env.output === "diff" &&
1069
+ env.taskId === undefined
1070
+ ) {
1071
+ return { ok: false, reason: "missing required field: taskId" };
1072
+ }
101
1073
  if (
102
1074
  typeof env.phase !== "string" ||
103
1075
  !(PHASES as readonly string[]).includes(env.phase)
@@ -146,6 +1118,9 @@ export function validateRequestEnvelope(
146
1118
  return { ok: false, reason: `invalid declared ${key} set` };
147
1119
  }
148
1120
  }
1121
+ if ((decl.write as unknown[]).some(isAgentsPath)) {
1122
+ return { ok: false, reason: "subagents cannot declare AGENTS writes" };
1123
+ }
149
1124
  if (
150
1125
  decl.verificationLock !== undefined &&
151
1126
  !isValidRelativePath(decl.verificationLock)
@@ -155,6 +1130,59 @@ export function validateRequestEnvelope(
155
1130
  if (!(OUTPUT_KINDS as readonly string[]).includes(env.output as string)) {
156
1131
  return { ok: false, reason: `invalid output kind: ${String(env.output)}` };
157
1132
  }
1133
+ if (env.verification !== undefined) {
1134
+ const verification = validateVerificationContract(env.verification);
1135
+ if (!verification.ok) {
1136
+ return {
1137
+ ok: false,
1138
+ reason: verification.reason,
1139
+ };
1140
+ }
1141
+ }
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 };
1157
+ if (env.verification === undefined) {
1158
+ return { ok: false, reason: "missing required field: verification" };
1159
+ }
1160
+ const expectedClassification = {
1161
+ red: "expected-red",
1162
+ green: "expected-green",
1163
+ refactor: "expected-refactor",
1164
+ }[String(env.phase)];
1165
+ if (
1166
+ expectedClassification === undefined ||
1167
+ (env.verification as VerificationContract).classification !==
1168
+ expectedClassification
1169
+ ) {
1170
+ return {
1171
+ ok: false,
1172
+ reason:
1173
+ "verification classification does not match implementation phase",
1174
+ };
1175
+ }
1176
+ }
1177
+ if (env.stage === "abel-implement" && env.output === "diff") {
1178
+ const snapshotReason = validateImplementationSnapshot(env.snapshot, [
1179
+ ...(decl.read as string[]),
1180
+ ...(decl.write as string[]),
1181
+ ]);
1182
+ if (snapshotReason !== null) {
1183
+ return { ok: false, reason: snapshotReason };
1184
+ }
1185
+ }
158
1186
  if (
159
1187
  env.snapshot !== undefined &&
160
1188
  (env.snapshot === null || typeof env.snapshot !== "object")
@@ -172,68 +1200,196 @@ export function diffWritePaths(diffText: string): {
172
1200
  if (typeof diffText !== "string" || diffText.length === 0) {
173
1201
  throw new Error("empty diff");
174
1202
  }
175
- if (/^GIT binary patch|^Binary files /.test(diffText)) {
176
- throw new Error("binary diff is not supported");
177
- }
178
- const lines = diffText.split("\n");
179
- if (lines.some((line) => /^copy (from|to) /.test(line))) {
180
- throw new Error("copy records are not supported");
181
- }
182
- if (lines.some((line) => /^rename (from|to) /.test(line))) {
183
- throw new Error("rename records are not supported");
184
- }
185
- const paths: string[] = [];
186
- let sawHeader = false;
187
- let pendingOld: string | null = null;
1203
+ const hasFinalLf = diffText.endsWith("\n");
1204
+ const lines = (hasFinalLf ? diffText.slice(0, -1) : diffText).split("\n");
1205
+
188
1206
  for (const line of lines) {
189
- if (/^old mode /.test(line) || /^new mode /.test(line)) {
1207
+ if (/^(?:GIT binary patch|Binary files )/.test(line)) {
1208
+ throw new Error("binary diff is not supported");
1209
+ }
1210
+ if (/^(?:copy|rename) (?:from|to) /.test(line)) {
1211
+ throw new Error(`${line.split(" ", 1)[0]} records are not supported`);
1212
+ }
1213
+ if (/^(?:old|new) mode /.test(line)) {
190
1214
  throw new Error("mode transitions are not supported");
191
1215
  }
192
- if (/^(new|deleted) file mode /.test(line)) {
193
- const mode = line.split(" ").pop();
194
- if (mode === "160000") {
195
- throw new Error("submodule mode is not supported");
1216
+ const mode = /^(?:new file mode|deleted file mode) (.+)$/.exec(line);
1217
+ if (mode) requireRegularFileMode(mode[1]);
1218
+ }
1219
+
1220
+ const paths: string[] = [];
1221
+ let i = 0;
1222
+ while (i < lines.length) {
1223
+ if (!isFileSectionStart(lines[i])) {
1224
+ if (paths.length === 0) {
1225
+ throw new Error("diff has no ordinary text headers");
196
1226
  }
197
- if (mode !== "100644" && mode !== "100755") {
198
- throw new Error(`non-regular file mode is not supported: ${mode}`);
1227
+ throw new Error(`unconsumed diff content: ${lines[i]}`);
1228
+ }
1229
+
1230
+ let gitTarget: string | null = null;
1231
+ if (lines[i].startsWith("diff --git ")) {
1232
+ gitTarget = parseGitTarget(lines[i]);
1233
+ i++;
1234
+ }
1235
+
1236
+ let operation: "add" | "delete" | null = null;
1237
+ const mode = /^(new file mode|deleted file mode) (.+)$/.exec(
1238
+ lines[i] ?? "",
1239
+ );
1240
+ if (mode) {
1241
+ operation = mode[1] === "new file mode" ? "add" : "delete";
1242
+ i++;
1243
+ }
1244
+ if (lines[i]?.startsWith("index ")) {
1245
+ parseIndexMetadata(lines[i]);
1246
+ i++;
1247
+ }
1248
+
1249
+ if (!lines[i]?.startsWith("--- ")) {
1250
+ throw new Error("file section is missing an ordinary --- header");
1251
+ }
1252
+ const oldPath = parseHeaderPath(lines[i].slice(4), "a");
1253
+ i++;
1254
+ if (!lines[i]?.startsWith("+++ ")) {
1255
+ throw new Error("diff header pair is malformed");
1256
+ }
1257
+ const newPath = parseHeaderPath(lines[i].slice(4), "b");
1258
+ i++;
1259
+
1260
+ if (oldPath === null && newPath === null) {
1261
+ throw new Error("diff header pair has no target");
1262
+ }
1263
+ if (oldPath !== null && newPath !== null && oldPath !== newPath) {
1264
+ throw new Error("mismatched old/new diff targets");
1265
+ }
1266
+ const target = oldPath ?? newPath;
1267
+ if (target === null) throw new Error("diff header pair has no target");
1268
+ if (gitTarget !== null && gitTarget !== target) {
1269
+ throw new Error("mismatched diff --git and text targets");
1270
+ }
1271
+ if (
1272
+ (operation === "add" && oldPath !== null) ||
1273
+ (operation === "delete" && newPath !== null)
1274
+ ) {
1275
+ throw new Error("create/delete metadata does not match text headers");
1276
+ }
1277
+ if (paths.includes(target)) throw new Error("duplicate diff targets");
1278
+ paths.push(target);
1279
+
1280
+ let sawHunk = false;
1281
+ while (lines[i]?.startsWith("@@")) {
1282
+ const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$/.exec(
1283
+ lines[i],
1284
+ );
1285
+ if (!hunk) throw new Error(`invalid hunk header: ${lines[i]}`);
1286
+ const range = [
1287
+ Number(hunk[1]),
1288
+ Number(hunk[2] ?? "1"),
1289
+ Number(hunk[3]),
1290
+ Number(hunk[4] ?? "1"),
1291
+ ];
1292
+ if (!range.every(Number.isSafeInteger)) {
1293
+ throw new Error("hunk range exceeds supported integer bounds");
199
1294
  }
200
- continue;
201
- }
202
- if (line.startsWith("--- ") && !line.startsWith("--- /dev/null")) {
203
- pendingOld = parseHeaderPath(line.slice(4));
204
- continue;
205
- }
206
- if (line.startsWith("+++ ")) {
207
- const newPath = parseHeaderPath(line.slice(4));
208
- let target: string;
209
- if (newPath === null) {
210
- if (pendingOld === null) {
211
- throw new Error("diff header pair is malformed");
212
- }
213
- target = pendingOld;
214
- } else {
215
- target = newPath;
216
- if (pendingOld !== null && pendingOld !== newPath) {
217
- throw new Error("mismatched old/new diff targets");
1295
+ let oldRemaining = range[1];
1296
+ let newRemaining = range[3];
1297
+ sawHunk = true;
1298
+ i++;
1299
+
1300
+ while (oldRemaining > 0 || newRemaining > 0) {
1301
+ if (i >= lines.length) throw new Error("hunk body underflow");
1302
+ const body = lines[i];
1303
+ switch (body[0]) {
1304
+ case " ":
1305
+ if (oldRemaining === 0 || newRemaining === 0) {
1306
+ throw new Error("hunk body overflow");
1307
+ }
1308
+ oldRemaining--;
1309
+ newRemaining--;
1310
+ break;
1311
+ case "-":
1312
+ if (oldRemaining === 0) throw new Error("hunk body overflow");
1313
+ oldRemaining--;
1314
+ break;
1315
+ case "+":
1316
+ if (newRemaining === 0) throw new Error("hunk body overflow");
1317
+ newRemaining--;
1318
+ break;
1319
+ default:
1320
+ throw new Error("hunk body underflow");
218
1321
  }
1322
+ i++;
1323
+ if (lines[i] === "\") i++;
219
1324
  }
220
- pendingOld = null;
221
- if (paths.includes(target)) throw new Error("duplicate diff targets");
222
- paths.push(target);
223
- sawHeader = true;
1325
+ }
1326
+ if (!sawHunk) {
1327
+ throw new Error("file section has no valid unified-diff hunk");
224
1328
  }
225
1329
  }
226
- if (!sawHeader || paths.length === 0) {
227
- throw new Error("diff has no ordinary text headers");
228
- }
1330
+
1331
+ if (!hasFinalLf) throw new Error("diff must end with LF");
229
1332
  return { paths, kind: "text" };
230
1333
  }
231
1334
 
232
- function parseHeaderPath(header: string): string | null {
1335
+ function isFileSectionStart(line: string | undefined): boolean {
1336
+ return (
1337
+ line?.startsWith("diff --git ") === true ||
1338
+ line?.startsWith("new file mode ") === true ||
1339
+ line?.startsWith("deleted file mode ") === true ||
1340
+ line?.startsWith("index ") === true ||
1341
+ line?.startsWith("--- ") === true
1342
+ );
1343
+ }
1344
+
1345
+ function parseGitTarget(line: string): string {
1346
+ const prefix = "diff --git a/";
1347
+ if (!line.startsWith(prefix)) throw new Error("malformed diff --git header");
1348
+ const rest = line.slice(prefix.length);
1349
+ for (
1350
+ let separator = rest.indexOf(" b/");
1351
+ separator >= 0;
1352
+ separator = rest.indexOf(" b/", separator + 1)
1353
+ ) {
1354
+ const oldPath = rest.slice(0, separator);
1355
+ const newPath = rest.slice(separator + 3);
1356
+ if (oldPath === newPath) return requireCanonicalDiffPath(oldPath);
1357
+ }
1358
+ throw new Error("mismatched diff --git targets");
1359
+ }
1360
+
1361
+ function parseIndexMetadata(line: string): void {
1362
+ const index = /^index [0-9a-f]+\.\.[0-9a-f]+(?: ([0-7]{6}))?$/.exec(line);
1363
+ if (!index) throw new Error("malformed index metadata");
1364
+ if (index[1]) requireRegularFileMode(index[1]);
1365
+ }
1366
+
1367
+ function requireRegularFileMode(mode: string): void {
1368
+ if (mode === "160000") {
1369
+ throw new Error("submodule mode is not supported: 160000");
1370
+ }
1371
+ if (mode !== "100644" && mode !== "100755") {
1372
+ throw new Error(`non-regular file mode is not supported: ${mode}`);
1373
+ }
1374
+ }
1375
+
1376
+ function parseHeaderPath(header: string, prefix: "a" | "b"): string | null {
233
1377
  if (header === "/dev/null") return null;
234
- const path = header.replace(/^\s*(a|b)\//, "");
235
- if (!isValidRelativePath(path)) {
236
- throw new Error(`noncanonical or escaping diff path: ${header}`);
1378
+ const other = prefix === "a" ? "b" : "a";
1379
+ if (header.startsWith(`${other}/`)) {
1380
+ throw new Error("mismatched old/new diff header prefixes");
1381
+ }
1382
+ const path = header.startsWith(`${prefix}/`) ? header.slice(2) : header;
1383
+ return requireCanonicalDiffPath(path);
1384
+ }
1385
+
1386
+ function requireCanonicalDiffPath(path: string): string {
1387
+ if (
1388
+ !isValidRelativePath(path) ||
1389
+ path.trim() !== path ||
1390
+ /[\r\n\t]/.test(path)
1391
+ ) {
1392
+ throw new Error(`noncanonical or escaping diff path: ${path}`);
237
1393
  }
238
1394
  return path;
239
1395
  }
@@ -261,37 +1417,154 @@ export interface DiffResult {
261
1417
  diff: string;
262
1418
  expectedVerification: string;
263
1419
  risks: string[];
264
- nextStep: string;
265
1420
  contractCompliant: boolean;
266
1421
  }
267
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
+
268
1498
  export function validateEvidenceResult(value: unknown): {
269
1499
  ok: boolean;
270
1500
  reason?: string;
1501
+ failure?: CandidateFailure;
271
1502
  } {
272
1503
  if (value === null || typeof value !== "object")
273
1504
  return { ok: false, reason: "missing result" };
274
1505
  const r = value as Record<string, unknown>;
275
1506
  if (r.kind !== "evidence") return { ok: false, reason: "wrong result kind" };
276
- for (const field of [
1507
+ const fields = [
277
1508
  "id",
278
1509
  "role",
1510
+ "kind",
279
1511
  "conclusions",
280
1512
  "citations",
1513
+ "constraints",
1514
+ "dependencies",
1515
+ "risks",
281
1516
  "blockingQuestions",
282
- ]) {
283
- if (r[field] === undefined)
284
- 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" };
285
1521
  }
286
- if (typeof r.id !== "string" || typeof r.role !== "string")
1522
+ if (!validIdentifier(r.id) || typeof r.role !== "string")
287
1523
  return { ok: false, reason: "invalid identity" };
288
1524
  if (!(ROLES as readonly string[]).includes(r.role))
289
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
+ }
290
1559
  const serialized = JSON.stringify(r);
291
- if (serialized.length > LIMITS.maxCompleteResultBytes) {
1560
+ if (Buffer.byteLength(serialized, "utf8") > LIMITS.maxCompleteResultBytes) {
292
1561
  return {
293
1562
  ok: false,
294
1563
  reason: `result exceeds ${LIMITS.maxCompleteResultBytes} bytes`,
1564
+ failure: {
1565
+ kind: "result-limit",
1566
+ limitBytes: LIMITS.maxCompleteResultBytes,
1567
+ },
295
1568
  };
296
1569
  }
297
1570
  return { ok: true };
@@ -301,23 +1574,28 @@ export function validateDiffResult(value: unknown): {
301
1574
  ok: boolean;
302
1575
  reason?: string;
303
1576
  paths?: string[];
1577
+ failure?: CandidateFailure;
304
1578
  } {
305
1579
  if (value === null || typeof value !== "object")
306
1580
  return { ok: false, reason: "missing result" };
307
1581
  const r = value as Record<string, unknown>;
308
1582
  if (r.kind !== "diff") return { ok: false, reason: "wrong result kind" };
309
- for (const field of [
1583
+ const fields = [
310
1584
  "id",
311
1585
  "role",
1586
+ "kind",
312
1587
  "taskId",
313
1588
  "phase",
314
1589
  "summary",
315
1590
  "diff",
316
1591
  "expectedVerification",
317
1592
  "risks",
318
- "nextStep",
319
1593
  "contractCompliant",
320
- ]) {
1594
+ ] as const;
1595
+ if (!hasExactKeys(r, fields)) {
1596
+ return { ok: false, reason: "invalid diff result fields" };
1597
+ }
1598
+ for (const field of fields) {
321
1599
  if (r[field] === undefined)
322
1600
  return { ok: false, reason: `missing field: ${field}` };
323
1601
  }
@@ -344,9 +1622,7 @@ export function validateDiffResult(value: unknown): {
344
1622
  typeof r.summary !== "string" ||
345
1623
  r.summary.length === 0 ||
346
1624
  typeof r.expectedVerification !== "string" ||
347
- r.expectedVerification.length === 0 ||
348
- typeof r.nextStep !== "string" ||
349
- r.nextStep.length === 0
1625
+ r.expectedVerification.length === 0
350
1626
  ) {
351
1627
  return { ok: false, reason: "invalid diff result text" };
352
1628
  }
@@ -365,10 +1641,14 @@ export function validateDiffResult(value: unknown): {
365
1641
  return { ok: false, reason: `invalid diff: ${(err as Error).message}` };
366
1642
  }
367
1643
  const serialized = JSON.stringify(r);
368
- if (serialized.length > LIMITS.maxCompleteResultBytes) {
1644
+ if (Buffer.byteLength(serialized, "utf8") > LIMITS.maxCompleteResultBytes) {
369
1645
  return {
370
1646
  ok: false,
371
1647
  reason: `result exceeds ${LIMITS.maxCompleteResultBytes} bytes`,
1648
+ failure: {
1649
+ kind: "result-limit",
1650
+ limitBytes: LIMITS.maxCompleteResultBytes,
1651
+ },
372
1652
  };
373
1653
  }
374
1654
  return { ok: true, paths };