@gajae-code/coding-agent 0.12.2 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +15 -2
  2. package/dist/types/async/job-manager.d.ts +9 -1
  3. package/dist/types/gjc-runtime/ultragoal-evidence.d.ts +5 -1
  4. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +2 -2
  5. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -2
  6. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +5 -3
  7. package/dist/types/session/internal/managed-session-storage.d.ts +3 -22
  8. package/dist/types/session/session-manager.d.ts +1 -1
  9. package/dist/types/task/executor.d.ts +1 -1
  10. package/dist/types/task/index.d.ts +2 -1
  11. package/dist/types/task/receipt.d.ts +1 -0
  12. package/dist/types/task/types.d.ts +16 -0
  13. package/dist/types/task/worktree.d.ts +13 -6
  14. package/dist/types/tools/subagent.d.ts +2 -0
  15. package/dist/types/utils/git.d.ts +1 -0
  16. package/package.json +8 -8
  17. package/src/async/job-manager.ts +13 -5
  18. package/src/config/provider-ranking.ts +1 -0
  19. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -5
  20. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +2 -1
  21. package/src/gjc-runtime/ultragoal-evidence.ts +160 -31
  22. package/src/gjc-runtime/ultragoal-runtime.ts +231 -19
  23. package/src/internal-urls/docs-index.generated.ts +2 -2
  24. package/src/sdk/broker/lifecycle.ts +13 -18
  25. package/src/sdk/bus/chat-daemon-control.ts +6 -4
  26. package/src/sdk/bus/telegram-daemon-contract.ts +5 -3
  27. package/src/sdk/session.ts +1 -0
  28. package/src/session/internal/managed-session-scope.ts +107 -158
  29. package/src/session/internal/managed-session-storage.ts +57 -445
  30. package/src/session/session-manager.ts +12 -7
  31. package/src/session/session-storage.ts +44 -29
  32. package/src/task/executor.ts +5 -2
  33. package/src/task/index.ts +232 -64
  34. package/src/task/receipt.ts +22 -2
  35. package/src/task/render.ts +12 -1
  36. package/src/task/types.ts +18 -0
  37. package/src/task/worktree.ts +122 -43
  38. package/src/tools/subagent-render.ts +5 -2
  39. package/src/tools/subagent.ts +8 -0
  40. package/src/utils/git.ts +2 -0
@@ -1,5 +1,8 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
1
3
  import * as path from "node:path";
2
4
  import { inflateSync } from "node:zlib";
5
+ import { isCompiledBinary } from "@gajae-code/utils/env";
3
6
  import {
4
7
  evidenceKindMatches,
5
8
  hasExistingNonEmptyArtifact,
@@ -382,6 +385,25 @@ function clampCliReplayTimeout(value: unknown): number {
382
385
  function basenameCommand(value: string): string {
383
386
  return path.basename(value).toLowerCase();
384
387
  }
388
+
389
+ function isDeterministicConsoleLogReplay(code: string): boolean {
390
+ let remaining = code.trim();
391
+ if (remaining.length === 0) return false;
392
+ let matched = false;
393
+ while (remaining.length > 0) {
394
+ const match =
395
+ /^console\.log\(\s*("(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\$])*`)\s*\)\s*;?\s*/.exec(
396
+ remaining,
397
+ );
398
+ if (!match) return false;
399
+ const statement = match[0]!;
400
+ const literal = match[1]!;
401
+ if (literal.startsWith("`") && literal.includes("${")) return false;
402
+ matched = true;
403
+ remaining = remaining.slice(statement.length);
404
+ }
405
+ return matched;
406
+ }
385
407
  function hasShellRedirectionToken(value: string): boolean {
386
408
  return /^(?:[<>]|\d?[<>]|\d?>&\d|\|\|?|&&|;)$/.test(value) || /(?:^|[^\w])-?>/.test(value);
387
409
  }
@@ -413,19 +435,73 @@ export function isAllowedGitReplayCommand(args: readonly string[]): boolean {
413
435
  return true;
414
436
  }
415
437
 
416
- function resolveCliReplayCommand(command: string[]): string[] {
417
- if (basenameCommand(command[0]!) === "bun") return [process.execPath, ...command.slice(1)];
418
- return command;
438
+ function isBareExecutableName(value: string): boolean {
439
+ return (
440
+ value.length > 0 &&
441
+ !value.includes("/") &&
442
+ !value.includes("\\") &&
443
+ value === path.basename(value) &&
444
+ value === value.toLowerCase()
445
+ );
446
+ }
447
+
448
+ function isAllowedCliReplayCommand(command: readonly string[]): boolean {
449
+ if (
450
+ command.length === 0 ||
451
+ command.some(arg => arg.trim() !== arg || arg.length === 0 || hasShellRedirectionToken(arg))
452
+ )
453
+ return false;
454
+ if (!isBareExecutableName(command[0]!) || command[0] !== "bun") return false;
455
+ const args = command.slice(1);
456
+ if (args.length === 1 && args[0] === "--version") return true;
457
+ return args.length === 2 && args[0] === "-e" && isDeterministicConsoleLogReplay(args[1]!);
419
458
  }
420
459
 
421
- function resolveUnderCwd(cwd: string, replayCwd: unknown, fieldName: string): string {
460
+ function summarizeBlockedCliReplayCommand(command: readonly string[]): string {
461
+ const executable = command[0] ? basenameCommand(command[0]) : "<missing>";
462
+ const argCount = Math.max(0, command.length - 1);
463
+ return `${JSON.stringify(executable)} with ${argCount} arg${argCount === 1 ? "" : "s"}`;
464
+ }
465
+
466
+ function cliReplayAllowlistDescription(): string {
467
+ return '`bun --version` or deterministic `bun -e "console.log(...)"`; focused bun test execution is blocked and replayExempt still requires an existing screenshot, automation, or PTY structural fallback';
468
+ }
469
+
470
+ export function resolveCliReplayCommand(command: string[], options?: { compiled?: boolean }): string[] {
471
+ if (options?.compiled ?? isCompiledBinary()) {
472
+ throw new Error(
473
+ "CLI replay execution is unavailable in the compiled GJC runtime because process.execPath is the GJC application, not a Bun CLI executable",
474
+ );
475
+ }
476
+ return [process.execPath, ...command.slice(1)];
477
+ }
478
+
479
+ async function resolveUnderCwd(cwd: string, replayCwd: unknown, fieldName: string): Promise<string> {
422
480
  const relative = replayCwd === undefined ? "." : nonEmptyString(replayCwd);
423
481
  if (!relative) throw new Error(`qualityGate ${fieldName}.cwd must be a non-empty string when provided`);
424
- const root = path.resolve(cwd);
425
- const resolved = path.resolve(root, relative);
482
+ const lexicalRoot = path.resolve(cwd);
483
+ const lexical = path.resolve(lexicalRoot, relative);
484
+ const relativeToLexicalRoot = path.relative(lexicalRoot, lexical);
485
+ if (
486
+ relativeToLexicalRoot === ".." ||
487
+ relativeToLexicalRoot.startsWith(`..${path.sep}`) ||
488
+ path.isAbsolute(relativeToLexicalRoot)
489
+ ) {
490
+ throw new Error(`qualityGate ${fieldName}.cwd must resolve under the repository cwd`);
491
+ }
492
+ let root: string;
493
+ let resolved: string;
494
+ try {
495
+ [root, resolved] = await Promise.all([fs.realpath(lexicalRoot), fs.realpath(lexical)]);
496
+ } catch {
497
+ throw new Error(`qualityGate ${fieldName}.cwd must reference an existing directory under the repository cwd`);
498
+ }
426
499
  const relativeToRoot = path.relative(root, resolved);
427
500
  if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)) {
428
- throw new Error(`qualityGate ${fieldName}.cwd must resolve under the repository cwd`);
501
+ throw new Error(`qualityGate ${fieldName}.cwd must resolve under the repository cwd without symlink escape`);
502
+ }
503
+ if (!(await fs.stat(resolved)).isDirectory()) {
504
+ throw new Error(`qualityGate ${fieldName}.cwd must reference a directory`);
429
505
  }
430
506
  return resolved;
431
507
  }
@@ -467,16 +543,40 @@ function normalizeCliReplayOutput(value: string, cwd: string): string {
467
543
  }
468
544
 
469
545
  export async function readCliReplayRecord(cwd: string, row: JsonObject, fieldName: string): Promise<JsonObject | null> {
470
- // A row is an inline replay record only when it actually carries replay fields.
471
- // `kind: "cli-replay"` alone is the natural kind for an artifactRef that *points*
472
- // at a replay file, so treating that as inline meant the referenced `path` was
473
- // never read and surfaced a confusing `schemaVersion must be 1` error on the ref.
474
- const inline =
475
- qualityGateObject(row.replay) ?? (row.kind === "cli-replay" && row.command !== undefined ? row : null);
476
- if (inline) return inline;
546
+ const nestedReplay = row.replay === undefined ? null : qualityGateObject(row.replay);
547
+ if (row.replay !== undefined && !nestedReplay) {
548
+ throw new Error(`qualityGate ${fieldName}.replay must be an object when provided`);
549
+ }
550
+ const inlineFieldNames = [
551
+ "schemaVersion",
552
+ "replaySafe",
553
+ "command",
554
+ "cwd",
555
+ "env",
556
+ "timeoutMs",
557
+ "expectedExitCode",
558
+ "recordedStdout",
559
+ "recordedStderr",
560
+ "normalization",
561
+ "invariants",
562
+ "replayExempt",
563
+ ];
564
+ const hasTopLevelInlineFields = inlineFieldNames.some(name => row[name] !== undefined);
565
+ const hasPath = row.path !== undefined;
566
+ if (nestedReplay && (hasPath || hasTopLevelInlineFields)) {
567
+ throw new Error(`qualityGate ${fieldName} must not mix nested replay, artifact path, or top-level replay fields`);
568
+ }
569
+ if (hasTopLevelInlineFields && hasPath) {
570
+ throw new Error(`qualityGate ${fieldName} must not mix inline replay fields with an artifact path`);
571
+ }
572
+ if (nestedReplay) return nestedReplay;
573
+ if (row.kind === "cli-replay" && hasTopLevelInlineFields) return row;
477
574
  if (!evidenceKindMatches(normalizedEvidenceKind(row), ["cli-replay", "command-replay"])) return null;
575
+ if (!hasPath) {
576
+ throw new Error(`qualityGate ${fieldName} CLI replay artifact must provide either replay fields or path`);
577
+ }
478
578
  const bytes = await readArtifactBytes(cwd, row, fieldName);
479
- if (!bytes) return null;
579
+ if (!bytes) throw new Error(`qualityGate ${fieldName} CLI replay artifact path must reference a readable file`);
480
580
  try {
481
581
  return requireQualityGateObject(JSON.parse(bytes.toString("utf8")), `${fieldName}.replay`);
482
582
  } catch (error) {
@@ -494,6 +594,7 @@ function parseCliReplayRecord(
494
594
  timeoutMs: number;
495
595
  expectedExitCode: number;
496
596
  recordedStdout: string;
597
+ recordedStderr: string;
497
598
  invariants: JsonObject[];
498
599
  } {
499
600
  if (record.schemaVersion !== 1) throw new Error(`qualityGate ${fieldName}.schemaVersion must be 1`);
@@ -505,6 +606,11 @@ function parseCliReplayRecord(
505
606
  if (!command) throw new Error(`qualityGate ${fieldName}.command must be a non-empty string array`);
506
607
  if (record.replaySafe !== true)
507
608
  throw new Error(`qualityGate ${fieldName}.replaySafe must be true before CLI replay executes`);
609
+ if (!isAllowedCliReplayCommand(command)) {
610
+ throw new Error(
611
+ `qualityGate ${fieldName}.command is not in the deterministic CLI replay allowlist; command ${summarizeBlockedCliReplayCommand(command)} is blocked. Allowed replay commands: ${cliReplayAllowlistDescription()}. For other commands, provide audited replayExempt metadata with reasonCode, reason, approvedBy, and fallbackArtifactRefs that point to a structurally valid fallback artifact.`,
612
+ );
613
+ }
508
614
  if (record.normalization !== undefined && record.normalization !== "default") {
509
615
  throw new Error(`qualityGate ${fieldName}.normalization must be default when provided`);
510
616
  }
@@ -526,6 +632,7 @@ function parseCliReplayRecord(
526
632
  timeoutMs: clampCliReplayTimeout(record.timeoutMs),
527
633
  expectedExitCode,
528
634
  recordedStdout: record.recordedStdout,
635
+ recordedStderr: typeof record.recordedStderr === "string" ? record.recordedStderr : "",
529
636
  invariants,
530
637
  };
531
638
  }
@@ -597,11 +704,24 @@ async function collectCliReplayOutput(
597
704
 
598
705
  export interface ReplayProcessHandle {
599
706
  readonly exited: Promise<number>;
707
+ readonly pid?: number;
600
708
  kill(signal?: number | NodeJS.Signals): void;
601
709
  }
602
710
 
711
+ function signalReplayProcessTree(handle: ReplayProcessHandle, signal: NodeJS.Signals): void {
712
+ if (process.platform !== "win32" && Number.isInteger(handle.pid) && (handle.pid ?? 0) > 0) {
713
+ try {
714
+ process.kill(-handle.pid!, signal);
715
+ return;
716
+ } catch (error) {
717
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") return;
718
+ }
719
+ }
720
+ handle.kill(signal);
721
+ }
722
+
603
723
  export async function waitForReplayProcessWithTimeout(
604
- process: ReplayProcessHandle,
724
+ handle: ReplayProcessHandle,
605
725
  timeoutMs: number,
606
726
  graceMs = 2000,
607
727
  ): Promise<number> {
@@ -611,21 +731,21 @@ export async function waitForReplayProcessWithTimeout(
611
731
  const timeout = new Promise<typeof timedOut>(resolve => {
612
732
  timeoutTimer = setTimeout(() => resolve(timedOut), timeoutMs);
613
733
  });
614
- const first = await Promise.race([process.exited, timeout]);
734
+ const first = await Promise.race([handle.exited, timeout]);
615
735
  if (first !== timedOut) {
616
736
  if (timeoutTimer) clearTimeout(timeoutTimer);
617
737
  return first;
618
738
  }
619
- process.kill("SIGTERM");
739
+ signalReplayProcessTree(handle, "SIGTERM");
620
740
  const killed = Symbol("killed");
621
741
  const grace = new Promise<typeof killed>(resolve => {
622
742
  graceTimer = setTimeout(() => {
623
- process.kill("SIGKILL");
743
+ signalReplayProcessTree(handle, "SIGKILL");
624
744
  resolve(killed);
625
745
  }, graceMs);
626
746
  });
627
- await Promise.race([process.exited, grace]);
628
- await process.exited.catch(() => undefined);
747
+ await Promise.race([handle.exited, grace]);
748
+ await handle.exited.catch(() => undefined);
629
749
  if (timeoutTimer) clearTimeout(timeoutTimer);
630
750
  if (graceTimer) clearTimeout(graceTimer);
631
751
  throw new Error("timeout");
@@ -684,18 +804,20 @@ export async function validateCliReplay(
684
804
  }
685
805
  void options.live;
686
806
  const replay = parseCliReplayRecord(record, fieldName);
687
- const replayCwd = resolveUnderCwd(cwd, replay.replayCwd, fieldName);
688
- const process = Bun.spawn(resolveCliReplayCommand(replay.command), {
689
- cwd: replayCwd,
690
- env: replay.env,
691
- stdout: "pipe",
692
- stderr: "pipe",
693
- });
807
+ await resolveUnderCwd(cwd, replay.replayCwd, fieldName);
808
+ const executionCwd = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-cli-replay-"));
694
809
  try {
810
+ const subprocess = Bun.spawn(resolveCliReplayCommand(replay.command), {
811
+ cwd: executionCwd,
812
+ env: { ...replay.env, HOME: executionCwd, TMPDIR: executionCwd },
813
+ stdout: "pipe",
814
+ stderr: "pipe",
815
+ detached: process.platform !== "win32",
816
+ });
695
817
  const [stdout, stderr, exitCode] = await Promise.all([
696
- collectCliReplayOutput(process.stdout),
697
- collectCliReplayOutput(process.stderr),
698
- waitForReplayProcessWithTimeout(process, replay.timeoutMs),
818
+ collectCliReplayOutput(subprocess.stdout),
819
+ collectCliReplayOutput(subprocess.stderr),
820
+ waitForReplayProcessWithTimeout(subprocess, replay.timeoutMs),
699
821
  ]);
700
822
  if (stdout.truncated || stderr.truncated)
701
823
  throw new Error(`qualityGate ${fieldName} CLI replay output exceeded 1 MiB buffer cap`);
@@ -706,6 +828,11 @@ export async function validateCliReplay(
706
828
  }
707
829
  const actualStdout = normalizeCliReplayOutput(stdout.text, cwd);
708
830
  const recordedStdout = normalizeCliReplayOutput(replay.recordedStdout, cwd);
831
+ const actualStderr = normalizeCliReplayOutput(stderr.text, cwd);
832
+ const recordedStderr = normalizeCliReplayOutput(replay.recordedStderr, cwd);
833
+ if (actualStderr !== recordedStderr) {
834
+ throw new Error(`qualityGate ${fieldName} CLI replay stderr did not match recordedStderr after normalization`);
835
+ }
709
836
  if (!replay.invariants.length || !validateCliReplayInvariants(replay.invariants, actualStdout, fieldName)) {
710
837
  if (actualStdout !== recordedStdout) {
711
838
  throw new Error(
@@ -719,6 +846,8 @@ export async function validateCliReplay(
719
846
  throw new Error(`qualityGate ${fieldName} CLI replay timed out after ${replay.timeoutMs}ms`);
720
847
  }
721
848
  throw error;
849
+ } finally {
850
+ await fs.rm(executionCwd, { recursive: true, force: true }).catch(() => undefined);
722
851
  }
723
852
  }
724
853
 
@@ -1,4 +1,5 @@
1
1
  import * as crypto from "node:crypto";
2
+ import * as fs from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import { getConfigRootDir } from "@gajae-code/utils";
4
5
  import type { WorkflowHudSummary } from "../skill-state/active-state";
@@ -1794,27 +1795,46 @@ export function hasTypedVerifiedReceipt(value: unknown): boolean {
1794
1795
  return Boolean(type && id && (status === "verified" || status === "passed"));
1795
1796
  }
1796
1797
 
1797
- export async function hasExistingNonEmptyArtifact(cwd: string, value: unknown): Promise<boolean> {
1798
+ async function resolveExistingArtifactPathUnderCwd(
1799
+ cwd: string,
1800
+ value: unknown,
1801
+ fieldName: string,
1802
+ ): Promise<string | null> {
1798
1803
  const artifactPath = nonEmptyString(value);
1799
- if (!artifactPath) return false;
1800
- const resolved = path.resolve(cwd, artifactPath);
1804
+ if (!artifactPath) return null;
1805
+ const lexicalRoot = path.resolve(cwd);
1806
+ const lexical = path.resolve(lexicalRoot, artifactPath);
1807
+ const lexicalRelative = path.relative(lexicalRoot, lexical);
1808
+ if (lexicalRelative === ".." || lexicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(lexicalRelative)) {
1809
+ throw new Error(`qualityGate ${fieldName} artifact path must resolve under the repository cwd`);
1810
+ }
1801
1811
  try {
1802
- const file = Bun.file(resolved);
1803
- return (await file.exists()) && file.size > 0;
1812
+ const [root, resolved] = await Promise.all([fs.realpath(lexicalRoot), fs.realpath(lexical)]);
1813
+ const relative = path.relative(root, resolved);
1814
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
1815
+ throw new Error(`qualityGate ${fieldName} artifact path must not escape the repository cwd through symlinks`);
1816
+ }
1817
+ if (!(await fs.stat(resolved)).isFile()) {
1818
+ throw new Error(`qualityGate ${fieldName} artifact path must reference a regular file`);
1819
+ }
1820
+ return resolved;
1804
1821
  } catch (error) {
1805
- if (isEnoent(error)) return false;
1822
+ if (isEnoent(error)) return null;
1806
1823
  throw error;
1807
1824
  }
1808
1825
  }
1809
1826
 
1827
+ export async function hasExistingNonEmptyArtifact(cwd: string, value: unknown): Promise<boolean> {
1828
+ const resolved = await resolveExistingArtifactPathUnderCwd(cwd, value, "artifact");
1829
+ if (!resolved) return false;
1830
+ return (await fs.stat(resolved)).size > 0;
1831
+ }
1832
+
1810
1833
  export async function readArtifactBytes(cwd: string, row: JsonObject, fieldName: string): Promise<Buffer | null> {
1811
- const artifactPath = nonEmptyString(row.path);
1812
- if (!artifactPath) return null;
1813
- const resolved = path.resolve(cwd, artifactPath);
1834
+ const resolved = await resolveExistingArtifactPathUnderCwd(cwd, row.path, fieldName);
1835
+ if (!resolved) return null;
1814
1836
  try {
1815
- const file = Bun.file(resolved);
1816
- if (!(await file.exists())) return null;
1817
- return Buffer.from(await file.arrayBuffer());
1837
+ return Buffer.from(await Bun.file(resolved).arrayBuffer());
1818
1838
  } catch (error) {
1819
1839
  if (isEnoent(error)) return null;
1820
1840
  throw new Error(`qualityGate ${fieldName} artifact could not be read: ${String(error)}`);
@@ -1823,6 +1843,7 @@ export async function readArtifactBytes(cwd: string, row: JsonObject, fieldName:
1823
1843
 
1824
1844
  import {
1825
1845
  readCliReplayRecord,
1846
+ resolveCliReplayCommand,
1826
1847
  validateArtifactProof,
1827
1848
  validateCliReplay,
1828
1849
  validateLiveSurfaceProofPresence,
@@ -1834,6 +1855,7 @@ import {
1834
1855
 
1835
1856
  export type { ReplayProcessHandle } from "./ultragoal-evidence";
1836
1857
  export {
1858
+ resolveCliReplayCommand,
1837
1859
  validateArtifactProof,
1838
1860
  validateCliReplay,
1839
1861
  validateLiveSurfaceProofPresence,
@@ -2230,6 +2252,169 @@ function validateDeferredLaneDeclaration(deferred: JsonObject, fieldName: string
2230
2252
  }
2231
2253
  }
2232
2254
 
2255
+ /**
2256
+ * Agent-friendly deferred-gate hydration: the runtime already stores the durable
2257
+ * batch tuple and computes the cumulative change set itself, so a deferred gate
2258
+ * only has to prove what the runtime cannot know — that targeted verification ran.
2259
+ * Every mechanical field (`kind`, the batch tuple, `deferredLanes`, and the whole
2260
+ * `changeSet` block including `paths` and `changeSetHash`) is auto-filled when
2261
+ * omitted. Explicitly supplied values are never overwritten, so a wrong
2262
+ * declaration still fails closed.
2263
+ */
2264
+ function hydrateDeferredGateDefaults(
2265
+ gate: JsonObject,
2266
+ goal: UltragoalGoal,
2267
+ changeSet: UltragoalChangeSet | undefined,
2268
+ ): JsonObject {
2269
+ const deferred = qualityGateObject(gate.deferredToBatch);
2270
+ if (!deferred) return gate;
2271
+ const metadata = goal.validationBatch;
2272
+ // The final member must carry the full strict gate; never help it defer.
2273
+ if (metadata && goal.id === metadata.finalGoalId) return gate;
2274
+ const hydrated: JsonObject = { ...deferred };
2275
+ if (hydrated.kind === undefined) hydrated.kind = "validation-batch-deferred";
2276
+ if (metadata) {
2277
+ if (hydrated.schemaVersion === undefined) hydrated.schemaVersion = metadata.schemaVersion;
2278
+ if (hydrated.batchId === undefined) hydrated.batchId = metadata.batchId;
2279
+ if (hydrated.memberIds === undefined) hydrated.memberIds = [...metadata.memberIds];
2280
+ if (hydrated.finalGoalId === undefined) hydrated.finalGoalId = metadata.finalGoalId;
2281
+ if (hydrated.metadataHash === undefined) hydrated.metadataHash = metadata.metadataHash;
2282
+ }
2283
+ if (hydrated.deferredLanes === undefined) hydrated.deferredLanes = ["architectReview", "executorQa"];
2284
+ const computedRows = (changeSet?.paths ?? []).map(row => ({
2285
+ path: row.path,
2286
+ status: row.status,
2287
+ ...(row.oldPath ? { oldPath: row.oldPath } : {}),
2288
+ }));
2289
+ const computedByPath = new Map(computedRows.map(row => [row.path, row]));
2290
+ const declared = qualityGateObject(hydrated.changeSet);
2291
+ const changeSetRecord: JsonObject = declared ? { ...declared } : {};
2292
+ if (changeSetRecord.memberGoalId === undefined) changeSetRecord.memberGoalId = goal.id;
2293
+ if (changeSetRecord.cumulativeFromBase === undefined) changeSetRecord.cumulativeFromBase = true;
2294
+ if (changeSetRecord.paths === undefined) {
2295
+ changeSetRecord.paths = computedRows;
2296
+ } else if (Array.isArray(changeSetRecord.paths)) {
2297
+ // Accept plain-string rows and rows without a status; resolve the status
2298
+ // from the computed change set instead of demanding git trivia.
2299
+ changeSetRecord.paths = changeSetRecord.paths.map(row => {
2300
+ const record = typeof row === "string" ? { path: row } : qualityGateObject(row);
2301
+ if (!record) return row;
2302
+ const pathValue = nonEmptyString(record.path);
2303
+ if (!pathValue || nonEmptyString(record.status)) return record;
2304
+ const computed = computedByPath.get(normalizeRepoPath(pathValue));
2305
+ return {
2306
+ ...record,
2307
+ status: computed?.status ?? "unknown",
2308
+ ...(computed?.oldPath && record.oldPath === undefined ? { oldPath: computed.oldPath } : {}),
2309
+ };
2310
+ });
2311
+ }
2312
+ if (changeSetRecord.changeSetHash === undefined) {
2313
+ try {
2314
+ changeSetRecord.changeSetHash = changeSetHashForPaths(
2315
+ canonicalChangeSetRows(changeSetRecord.paths, "deferredToBatch.changeSet.paths"),
2316
+ );
2317
+ } catch {
2318
+ // Malformed rows: leave the hash unset so validation reports the row defect.
2319
+ }
2320
+ }
2321
+ hydrated.changeSet = changeSetRecord;
2322
+ return { ...gate, deferredToBatch: hydrated };
2323
+ }
2324
+
2325
+ /**
2326
+ * Agent-friendly batch-close hydration, mirroring `hydrateDeferredGateDefaults`:
2327
+ * every `validationBatchClose` field except `coverageEvidence` is derivable from
2328
+ * durable state (batch metadata, member receipts) and the computed cumulative
2329
+ * change set, so it is auto-filled when omitted. Supplied values are never
2330
+ * overwritten and still fail closed when wrong. The minimal close is the full
2331
+ * strict gate plus `{"validationBatchClose":{"coverageEvidence":"..."}}`.
2332
+ */
2333
+ function hydrateBatchCloseDefaults(input: {
2334
+ gate: JsonObject;
2335
+ plan: UltragoalPlan;
2336
+ goal: UltragoalGoal;
2337
+ ledger: readonly UltragoalLedgerEvent[];
2338
+ changeSet?: UltragoalChangeSet;
2339
+ }): JsonObject {
2340
+ const metadata = input.goal.validationBatch;
2341
+ if (!metadata || input.goal.id !== metadata.finalGoalId) return input.gate;
2342
+ const requested = qualityGateObject(input.gate.validationBatchClose);
2343
+ if (!requested) return input.gate;
2344
+ // The replacement-close kind has its own dedicated hydrator.
2345
+ if (requested.kind === "review-blocker-replacement-close") return input.gate;
2346
+ const close: JsonObject = { ...requested };
2347
+ if (close.schemaVersion === undefined) close.schemaVersion = 1;
2348
+ if (close.kind === undefined) close.kind = "validation-batch-close";
2349
+ if (close.batchId === undefined) close.batchId = metadata.batchId;
2350
+ if (close.finalGoalId === undefined) close.finalGoalId = metadata.finalGoalId;
2351
+ if (close.memberIds === undefined) close.memberIds = [...metadata.memberIds];
2352
+ const derivedMetadataHashes: Record<string, string> = {};
2353
+ const derivedChangeSetHashes: Record<string, string> = {};
2354
+ const derivedReceipts: JsonObject[] = [];
2355
+ for (const memberId of metadata.memberIds) {
2356
+ const member = input.plan.goals.find(goal => goal.id === memberId);
2357
+ if (member?.validationBatch) derivedMetadataHashes[memberId] = member.validationBatch.metadataHash;
2358
+ if (!member || memberId === input.goal.id) continue;
2359
+ try {
2360
+ const receipt = requireDeferredMemberReceiptFresh(
2361
+ input.plan,
2362
+ input.ledger,
2363
+ member,
2364
+ "validationBatchClose hydration",
2365
+ );
2366
+ derivedChangeSetHashes[memberId] = receipt.validationBatch.changeSetHash;
2367
+ derivedReceipts.push({
2368
+ goalId: memberId,
2369
+ receiptId: receipt.receiptId,
2370
+ checkpointLedgerEventId: receipt.checkpointLedgerEventId,
2371
+ qualityGateHash: receipt.qualityGateHash,
2372
+ changeSetHash: receipt.validationBatch.changeSetHash,
2373
+ role: "deferred-member",
2374
+ });
2375
+ } catch {
2376
+ // Member not fresh/complete: omit it so validation reports the real defect.
2377
+ }
2378
+ }
2379
+ const suppliedMetadataHashes = qualityGateObject(close.memberMetadataHashes);
2380
+ if (close.memberMetadataHashes === undefined) close.memberMetadataHashes = derivedMetadataHashes;
2381
+ else if (suppliedMetadataHashes)
2382
+ close.memberMetadataHashes = { ...derivedMetadataHashes, ...suppliedMetadataHashes };
2383
+ if (close.memberReceipts === undefined) close.memberReceipts = derivedReceipts;
2384
+ const requestedUnion = qualityGateObject(close.unionChangeSet);
2385
+ const union: JsonObject = requestedUnion ? { ...requestedUnion } : {};
2386
+ if (union.source === undefined) union.source = "validation-batch";
2387
+ if (union.paths === undefined) {
2388
+ union.paths = (input.changeSet?.paths ?? []).map(row => ({
2389
+ path: row.path,
2390
+ status: row.status,
2391
+ ...(row.oldPath ? { oldPath: row.oldPath } : {}),
2392
+ }));
2393
+ }
2394
+ try {
2395
+ const unionRows = canonicalChangeSetRows(union.paths, "validationBatchClose.unionChangeSet.paths");
2396
+ const suppliedChangeSetHashes = qualityGateObject(union.memberChangeSetHashes);
2397
+ const memberChangeSetHashes: Record<string, unknown> = {
2398
+ ...derivedChangeSetHashes,
2399
+ [metadata.finalGoalId]: changeSetHashForPaths(unionRows),
2400
+ ...suppliedChangeSetHashes,
2401
+ };
2402
+ if (union.memberChangeSetHashes === undefined || suppliedChangeSetHashes) {
2403
+ union.memberChangeSetHashes = memberChangeSetHashes;
2404
+ }
2405
+ if (union.unionHash === undefined) {
2406
+ union.unionHash = hashStructuredValue({
2407
+ memberChangeSetHashes: qualityGateObject(union.memberChangeSetHashes) ?? memberChangeSetHashes,
2408
+ paths: unionRows.map(row => ({ path: row.path, status: row.status, oldPath: row.oldPath })),
2409
+ });
2410
+ }
2411
+ } catch {
2412
+ // Malformed rows: leave derived hashes unset so validation reports the row defect.
2413
+ }
2414
+ close.unionChangeSet = union;
2415
+ return { ...input.gate, validationBatchClose: close };
2416
+ }
2417
+
2233
2418
  function validateDeferredCompletionQualityGate(
2234
2419
  gate: JsonObject,
2235
2420
  goal: UltragoalGoal,
@@ -2251,7 +2436,9 @@ function validateDeferredCompletionQualityGate(
2251
2436
  }
2252
2437
  const deferredLanes = stringArray(deferred.deferredLanes)?.filter(Boolean).sort();
2253
2438
  if (deferredLanes?.join(",") !== "architectReview,executorQa")
2254
- throw new Error("deferredToBatch.deferredLanes must be architectReview and executorQa");
2439
+ throw new Error(
2440
+ "deferredToBatch.deferredLanes must be architectReview and executorQa (or omitted; the runtime fills it)",
2441
+ );
2255
2442
  const targeted = qualityGateObject(deferred.targetedVerification);
2256
2443
  if (!targeted || targeted.status !== PASSED_STATUS || !nonEmptyStringArray(targeted.commands))
2257
2444
  throw new Error("deferredToBatch.targetedVerification must pass with non-empty commands");
@@ -2276,13 +2463,17 @@ function validateDeferredCompletionQualityGate(
2276
2463
  const declaredChangeSet = qualityGateObject(deferred.changeSet);
2277
2464
  if (!declaredChangeSet) throw new Error("deferredToBatch.changeSet is required");
2278
2465
  if (declaredChangeSet.memberGoalId !== goal.id)
2279
- throw new Error("deferredToBatch.changeSet.memberGoalId must label the checkpointed goal");
2466
+ throw new Error(
2467
+ `deferredToBatch.changeSet.memberGoalId must label the checkpointed goal ${goal.id} (or be omitted; the runtime fills it)`,
2468
+ );
2280
2469
  if (declaredChangeSet.cumulativeFromBase !== true)
2281
- throw new Error("deferredToBatch.changeSet.cumulativeFromBase must be true");
2470
+ throw new Error("deferredToBatch.changeSet.cumulativeFromBase must be true (or omitted; the runtime fills it)");
2282
2471
  const paths = canonicalChangeSetRows(declaredChangeSet.paths, "deferredToBatch.changeSet.paths");
2283
2472
  requireChangeSetCoverage(changeSet, paths, "deferredToBatch.changeSet.paths");
2284
2473
  if (declaredChangeSet.changeSetHash !== changeSetHashForPaths(paths))
2285
- throw new Error("deferredToBatch.changeSet.changeSetHash does not match declared paths");
2474
+ throw new Error(
2475
+ "deferredToBatch.changeSet.changeSetHash does not match declared paths; omit changeSetHash and the runtime computes it",
2476
+ );
2286
2477
  }
2287
2478
  const COHORT_LANE_KEYS = ["cleaner", "architect", "qa"] as const;
2288
2479
 
@@ -2983,8 +3174,17 @@ export async function validateUltragoalQualityGateReadOnly(input: {
2983
3174
  }
2984
3175
  const ledger = plan ? await readUltragoalLedger(input.cwd, sessionId) : undefined;
2985
3176
  const changeSet = await computeCheckpointChangeSet(input.cwd);
3177
+ let hydratedGate = goal ? hydrateDeferredGateDefaults(gate, goal, changeSet) : gate;
3178
+ if (goal && plan && ledger) {
3179
+ hydratedGate = hydrateBatchCloseDefaults({ gate: hydratedGate, plan, goal, ledger, changeSet });
3180
+ }
2986
3181
  try {
2987
- await validateCompletionQualityGate(input.cwd, gate, { changeSet, plan: plan ?? undefined, goal, ledger });
3182
+ await validateCompletionQualityGate(input.cwd, hydratedGate, {
3183
+ changeSet,
3184
+ plan: plan ?? undefined,
3185
+ goal,
3186
+ ledger,
3187
+ });
2988
3188
  return { valid: true, errors: [] };
2989
3189
  } catch (error) {
2990
3190
  if (error instanceof UltragoalQualityGateError) return { valid: false, errors: error.diagnostics };
@@ -3031,13 +3231,25 @@ async function readRequiredCompletionQualityGate(
3031
3231
  changeSet: options.changeSet,
3032
3232
  })
3033
3233
  : gateObject;
3034
- await validateCompletionQualityGate(cwd, hydratedGate, {
3234
+ let completionGate = options.goal
3235
+ ? hydrateDeferredGateDefaults(hydratedGate, options.goal, options.changeSet)
3236
+ : hydratedGate;
3237
+ if (options.goal && options.plan && options.ledger) {
3238
+ completionGate = hydrateBatchCloseDefaults({
3239
+ gate: completionGate,
3240
+ plan: options.plan,
3241
+ goal: options.goal,
3242
+ ledger: options.ledger,
3243
+ changeSet: options.changeSet,
3244
+ });
3245
+ }
3246
+ await validateCompletionQualityGate(cwd, completionGate, {
3035
3247
  changeSet: options.changeSet,
3036
3248
  plan: options.plan,
3037
3249
  goal: options.goal,
3038
3250
  ledger: options.ledger,
3039
3251
  });
3040
- return hydratedGate;
3252
+ return completionGate;
3041
3253
  }
3042
3254
 
3043
3255
  function validateCompleteCheckpointTargetGoal(goal: UltragoalGoal): void {