@gajae-code/coding-agent 0.1.3 → 0.2.0

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.
@@ -1,6 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+ import type { WorkflowHudSummary } from "../skill-state/active-state";
5
+ import { buildTeamHudSummary as buildWorkflowTeamHudSummary } from "../skill-state/workflow-hud";
6
+ import { applyGjcTmuxProfile } from "./launch-tmux";
4
7
 
5
8
  export type GjcTeamPhase = "starting" | "running" | "complete" | "failed" | "cancelled";
6
9
  export type GjcTeamTaskStatus = "pending" | "blocked" | "in_progress" | "completed" | "failed";
@@ -235,7 +238,7 @@ interface GjcTmuxLeaderContext {
235
238
  leaderPaneId: string;
236
239
  target: string;
237
240
  }
238
- interface GjcTeamEvent {
241
+ export interface GjcTeamEvent {
239
242
  event_id: string;
240
243
  ts: string;
241
244
  type: string;
@@ -263,7 +266,12 @@ interface GitResult {
263
266
  }
264
267
  interface GjcTeamCommitHygieneEntry {
265
268
  recorded_at: string;
266
- operation: "auto_checkpoint" | "integration_merge" | "integration_cherry_pick" | "cross_rebase";
269
+ operation:
270
+ | "auto_checkpoint"
271
+ | "leader_integration_attempt"
272
+ | "integration_merge"
273
+ | "integration_cherry_pick"
274
+ | "cross_rebase";
267
275
  worker_name: string;
268
276
  task_id?: string;
269
277
  status: "applied" | "skipped" | "conflict" | "failed";
@@ -277,6 +285,23 @@ interface GjcTeamCommitHygieneEntry {
277
285
  detail: string;
278
286
  }
279
287
 
288
+ interface GjcWorkerIntegrationDedupeState {
289
+ last_requested_fingerprint?: string;
290
+ last_requested_head?: string | null;
291
+ last_requested_status?: GjcWorkerCheckpointClassification["kind"];
292
+ last_requested_at?: string;
293
+ }
294
+
295
+ export interface GjcWorkerIntegrationAttemptRequestResult {
296
+ requested: boolean;
297
+ reason: "requested" | "not_worker" | "missing_worktree" | "no_changes" | "deduped" | "git_error";
298
+ worker?: string;
299
+ team_name?: string;
300
+ fingerprint?: string;
301
+ head?: string | null;
302
+ status?: GjcWorkerCheckpointClassification["kind"];
303
+ }
304
+
280
305
  function isGjcTeamTaskStatus(value: string): value is GjcTeamTaskStatus {
281
306
  return ["pending", "blocked", "in_progress", "completed", "failed"].includes(value);
282
307
  }
@@ -364,6 +389,9 @@ function mailboxPath(dir: string, worker: string): string {
364
389
  function workerDir(dir: string, worker: string): string {
365
390
  return path.join(dir, "workers", worker);
366
391
  }
392
+ function workerIntegrationDedupePath(dir: string, worker: string): string {
393
+ return path.join(workerDir(dir, worker), "posttooluse-dedupe.json");
394
+ }
367
395
 
368
396
  export function resolveGjcTeamStateRoot(cwd = process.cwd(), env: NodeJS.ProcessEnv = process.env): string {
369
397
  const explicit = env.GJC_TEAM_STATE_ROOT?.trim();
@@ -679,7 +707,12 @@ function buildInitialTasks(task: string, workers: GjcTeamWorker[]): GjcTeamTask[
679
707
  }));
680
708
  }
681
709
 
682
- async function startTmuxSession(config: GjcTeamConfig, dir: string, dryRun: boolean): Promise<GjcTeamWorker[]> {
710
+ async function startTmuxSession(
711
+ config: GjcTeamConfig,
712
+ dir: string,
713
+ dryRun: boolean,
714
+ env: NodeJS.ProcessEnv = process.env,
715
+ ): Promise<GjcTeamWorker[]> {
683
716
  if (dryRun) return config.workers.map(worker => ({ ...worker, pane_id: `%dry-run-${worker.id}` }));
684
717
  const rollbackPaneIds: string[] = [];
685
718
  try {
@@ -740,6 +773,23 @@ async function startTmuxSession(config: GjcTeamConfig, dir: string, dryRun: bool
740
773
  stderr: "ignore",
741
774
  });
742
775
  }
776
+ const profileResult = applyGjcTmuxProfile({
777
+ tmuxCommand: config.tmux_command,
778
+ target: config.tmux_target,
779
+ cwd: config.leader.cwd,
780
+ env,
781
+ });
782
+ await appendTelemetry(dir, {
783
+ type: "tmux_profile_applied",
784
+ message: profileResult.skipped
785
+ ? "Skipped GJC scoped tmux profile"
786
+ : "Applied GJC scoped tmux profile to team tmux target",
787
+ data: {
788
+ tmux_target: config.tmux_target,
789
+ command_count: profileResult.commands.length,
790
+ failure_count: profileResult.failures.length,
791
+ },
792
+ });
743
793
  await appendTelemetry(dir, {
744
794
  type: "tmux_started",
745
795
  message: "Started gjc team worker panes in current tmux window",
@@ -843,6 +893,72 @@ function listConflictFiles(cwd: string): string[] {
843
893
  .map(line => line.trim())
844
894
  .filter(Boolean);
845
895
  }
896
+
897
+ export type GjcWorkerCheckpointClassification =
898
+ | { kind: "clean"; files: string[] }
899
+ | { kind: "eligible"; files: string[] }
900
+ | { kind: "protected_only"; files: string[] }
901
+ | { kind: "conflicted"; files: string[] }
902
+ | { kind: "git_error"; files: string[]; detail: string };
903
+
904
+ const UNMERGED_GIT_STATUS_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
905
+ const PROTECTED_WORKER_CHECKPOINT_PREFIXES = [
906
+ ".gjc/state/",
907
+ ".gjc/logs/",
908
+ ".gjc/reports/",
909
+ ".gjc/tmp/",
910
+ ".gjc/ultragoal/",
911
+ ];
912
+
913
+ function parsePorcelainStatusFiles(stdout: string): string[] {
914
+ return stdout
915
+ .split(/\r?\n/)
916
+ .map(line => line.trimEnd())
917
+ .filter(Boolean)
918
+ .map(line => line.slice(3).trim())
919
+ .filter(Boolean);
920
+ }
921
+
922
+ function normalizeGitStatusPath(filePath: string): string {
923
+ return (filePath.split(" -> ").at(-1) ?? filePath).replace(/\\/g, "/").replace(/^\.\//, "");
924
+ }
925
+
926
+ export function classifyGjcTeamCheckpointFiles(files: string[]): { eligible: string[]; protected: string[] } {
927
+ const eligible: string[] = [];
928
+ const protectedFiles: string[] = [];
929
+ for (const file of files) {
930
+ const normalized = normalizeGitStatusPath(file);
931
+ if (
932
+ PROTECTED_WORKER_CHECKPOINT_PREFIXES.some(
933
+ prefix => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix),
934
+ )
935
+ )
936
+ protectedFiles.push(file);
937
+ else eligible.push(file);
938
+ }
939
+ return { eligible, protected: protectedFiles };
940
+ }
941
+
942
+ export function classifyWorkerCheckpointStatus(cwd: string): GjcWorkerCheckpointClassification {
943
+ const status = runGitResult(cwd, ["status", "--porcelain", "-uall"]);
944
+ if (!status.ok) {
945
+ return { kind: "git_error", files: [], detail: status.stderr || status.stdout || "git status failed" };
946
+ }
947
+ if (!status.stdout.trim()) return { kind: "clean", files: [] };
948
+ const files = parsePorcelainStatusFiles(status.stdout);
949
+ const hasUnmergedStatus = status.stdout
950
+ .split(/\r?\n/)
951
+ .filter(Boolean)
952
+ .some(line => UNMERGED_GIT_STATUS_CODES.has(line.slice(0, 2)));
953
+ const conflictFiles = listConflictFiles(cwd);
954
+ if (hasUnmergedStatus || conflictFiles.length > 0) {
955
+ return { kind: "conflicted", files: conflictFiles.length > 0 ? conflictFiles : files };
956
+ }
957
+ const classified = classifyGjcTeamCheckpointFiles(files);
958
+ if (classified.eligible.length === 0 && classified.protected.length > 0)
959
+ return { kind: "protected_only", files: classified.protected };
960
+ return { kind: "eligible", files: classified.eligible };
961
+ }
846
962
  async function appendIntegrationEvent(
847
963
  dir: string,
848
964
  type: string,
@@ -866,15 +982,39 @@ async function notifyLeader(
866
982
  ): Promise<void> {
867
983
  await sendGjcTeamMessage(config.team_name, worker.id, "leader-fixed", body, cwd, env).catch(() => undefined);
868
984
  }
869
- function autoCommitDirtyWorker(worker: GjcTeamWorker): { committed: boolean; commit: string | null } {
870
- if (!worker.worktree_path) return { committed: false, commit: null };
871
- const status = runGitResult(worker.worktree_path, ["status", "--porcelain"]);
872
- if (!status.ok || !status.stdout.trim()) return { committed: false, commit: null };
873
- if (!runGitResult(worker.worktree_path, ["add", "-A"]).ok) return { committed: false, commit: null };
985
+ async function notifyWorker(
986
+ config: GjcTeamConfig,
987
+ worker: GjcTeamWorker,
988
+ body: string,
989
+ cwd: string,
990
+ env: NodeJS.ProcessEnv,
991
+ ): Promise<void> {
992
+ await sendGjcTeamMessage(config.team_name, "leader-fixed", worker.id, body, cwd, env).catch(() => undefined);
993
+ }
994
+ async function notifyIntegrationConflict(
995
+ config: GjcTeamConfig,
996
+ worker: GjcTeamWorker,
997
+ body: string,
998
+ cwd: string,
999
+ env: NodeJS.ProcessEnv,
1000
+ ): Promise<void> {
1001
+ await Promise.all([notifyLeader(config, worker, body, cwd, env), notifyWorker(config, worker, body, cwd, env)]);
1002
+ }
1003
+ function autoCommitDirtyWorker(worker: GjcTeamWorker): {
1004
+ committed: boolean;
1005
+ commit: string | null;
1006
+ classification: GjcWorkerCheckpointClassification | null;
1007
+ } {
1008
+ const empty = { committed: false, commit: null, classification: null };
1009
+ if (!worker.worktree_path) return empty;
1010
+ const classification = classifyWorkerCheckpointStatus(worker.worktree_path);
1011
+ if (classification.kind !== "eligible") return { ...empty, classification };
1012
+ if (!runGitResult(worker.worktree_path, ["add", "--", ...classification.files]).ok)
1013
+ return { ...empty, classification };
874
1014
  const message = `gjc(team): auto-checkpoint ${worker.id} [${worker.assigned_tasks[0] ?? "unknown"}]`;
875
1015
  if (!runGitResult(worker.worktree_path, ["commit", "--no-verify", "-m", message]).ok)
876
- return { committed: false, commit: null };
877
- return { committed: true, commit: resolveHead(worker.worktree_path) };
1016
+ return { ...empty, classification };
1017
+ return { committed: true, commit: resolveHead(worker.worktree_path), classification };
878
1018
  }
879
1019
  function workerMergeRef(worker: GjcTeamWorker, workerHead: string): string {
880
1020
  if (!worker.worktree_path) return workerHead;
@@ -939,15 +1079,7 @@ async function integrateGjcWorkerCommits(
939
1079
  }
940
1080
  if (isAncestor(worker.worktree_path, leaderHead, workerHead)) {
941
1081
  const mergeRef = workerMergeRef(worker, workerHead);
942
- const merge = runGitResult(leaderCwd, [
943
- "merge",
944
- "--no-ff",
945
- "-X",
946
- "theirs",
947
- "-m",
948
- `gjc(team): merge ${worker.id}`,
949
- mergeRef,
950
- ]);
1082
+ const merge = runGitResult(leaderCwd, ["merge", "--no-ff", "-m", `gjc(team): merge ${worker.id}`, mergeRef]);
951
1083
  if (merge.ok) {
952
1084
  const newLeaderHead = resolveHead(leaderCwd);
953
1085
  if (newLeaderHead && newLeaderHead !== leaderHead && isAncestor(leaderCwd, workerHead, "HEAD")) {
@@ -1029,12 +1161,12 @@ async function integrateGjcWorkerCommits(
1029
1161
  worker: worker.id,
1030
1162
  operation: "merge",
1031
1163
  files: conflictFiles,
1032
- detail: `merge --no-ff -X theirs failed and was aborted: ${(merge.stderr || merge.stdout).slice(0, 200)}`,
1164
+ detail: `merge --no-ff failed and was aborted: ${(merge.stderr || merge.stdout).slice(0, 200)}`,
1033
1165
  });
1034
- await notifyLeader(
1166
+ await notifyIntegrationConflict(
1035
1167
  config,
1036
1168
  worker,
1037
- `CONFLICT: merge failed for ${worker.id}; files: ${conflictFiles.join(",") || "unknown"}.`,
1169
+ `CONFLICT: merge failed for ${worker.id}; files: ${conflictFiles.join(",") || "unknown"}. Manual resolution required; runtime aborted the merge and did not auto-resolve.`,
1038
1170
  cwd,
1039
1171
  env,
1040
1172
  );
@@ -1061,7 +1193,7 @@ async function integrateGjcWorkerCommits(
1061
1193
  : leaderHead;
1062
1194
  const commits = listCommitRange(worker.worktree_path, baseline, workerHead);
1063
1195
  for (const commit of commits) {
1064
- const pick = runGitResult(leaderCwd, ["cherry-pick", "--allow-empty", "-X", "theirs", commit]);
1196
+ const pick = runGitResult(leaderCwd, ["cherry-pick", "--allow-empty", commit]);
1065
1197
  if (!pick.ok) {
1066
1198
  const conflictFiles = listConflictFiles(leaderCwd);
1067
1199
  runGitResult(leaderCwd, ["cherry-pick", "--abort"]);
@@ -1082,12 +1214,12 @@ async function integrateGjcWorkerCommits(
1082
1214
  worker: worker.id,
1083
1215
  operation: "cherry-pick",
1084
1216
  files: conflictFiles,
1085
- detail: `cherry-pick -X theirs failed and was aborted: ${(pick.stderr || pick.stdout).slice(0, 200)}`,
1217
+ detail: `cherry-pick failed and was aborted: ${(pick.stderr || pick.stdout).slice(0, 200)}`,
1086
1218
  });
1087
- await notifyLeader(
1219
+ await notifyIntegrationConflict(
1088
1220
  config,
1089
1221
  worker,
1090
- `CONFLICT: cherry-pick failed for ${worker.id}; files: ${conflictFiles.join(",") || "unknown"}.`,
1222
+ `CONFLICT: cherry-pick failed for ${worker.id}; files: ${conflictFiles.join(",") || "unknown"}. Manual resolution required; runtime aborted the cherry-pick and did not auto-resolve.`,
1091
1223
  cwd,
1092
1224
  env,
1093
1225
  );
@@ -1199,7 +1331,7 @@ async function integrateGjcWorkerCommits(
1199
1331
  continue;
1200
1332
  }
1201
1333
  const before = resolveHead(worker.worktree_path);
1202
- const rebase = runGitResult(worker.worktree_path, ["rebase", "-X", "ours", newLeaderHead]);
1334
+ const rebase = runGitResult(worker.worktree_path, ["rebase", newLeaderHead]);
1203
1335
  if (rebase.ok) {
1204
1336
  const after = resolveHead(worker.worktree_path);
1205
1337
  integrationByWorker[worker.id] = {
@@ -1248,12 +1380,12 @@ async function integrateGjcWorkerCommits(
1248
1380
  worker: worker.id,
1249
1381
  operation: "rebase",
1250
1382
  files: conflictFiles,
1251
- detail: `rebase -X ours failed and was aborted: ${(rebase.stderr || rebase.stdout).slice(0, 200)}`,
1383
+ detail: `rebase failed and was aborted: ${(rebase.stderr || rebase.stdout).slice(0, 200)}`,
1252
1384
  });
1253
- await notifyLeader(
1385
+ await notifyIntegrationConflict(
1254
1386
  config,
1255
1387
  worker,
1256
- `CONFLICT: cross-rebase failed for ${worker.id}; files: ${conflictFiles.join(",") || "unknown"}.`,
1388
+ `CONFLICT: cross-rebase failed for ${worker.id}; files: ${conflictFiles.join(",") || "unknown"}. Manual resolution required; runtime aborted the rebase and did not auto-resolve.`,
1257
1389
  cwd,
1258
1390
  env,
1259
1391
  );
@@ -1379,7 +1511,7 @@ export async function startGjcTeam(options: GjcTeamStartOptions): Promise<GjcTea
1379
1511
  });
1380
1512
  let tmuxWorkers: GjcTeamWorker[];
1381
1513
  try {
1382
- tmuxWorkers = await startTmuxSession(config, dir, options.dryRun ?? false);
1514
+ tmuxWorkers = await startTmuxSession(config, dir, options.dryRun ?? false, env);
1383
1515
  } catch (error) {
1384
1516
  await writePhase(dir, "failed");
1385
1517
  await appendEvent(dir, {
@@ -1433,6 +1565,108 @@ export async function readGjcTeamSnapshot(
1433
1565
  updated_at: config.updated_at,
1434
1566
  };
1435
1567
  }
1568
+ function workerIntegrationFingerprint(head: string | null, classification: GjcWorkerCheckpointClassification): string {
1569
+ return `${head ?? "no-head"}:${classification.kind}:${classification.files.join("\0")}`;
1570
+ }
1571
+
1572
+ export async function requestGjcWorkerIntegrationAttempt(
1573
+ cwd = process.cwd(),
1574
+ env: NodeJS.ProcessEnv = process.env,
1575
+ ): Promise<GjcWorkerIntegrationAttemptRequestResult> {
1576
+ const teamName = env.GJC_TEAM_NAME?.trim();
1577
+ const worker = env.GJC_TEAM_WORKER_ID?.trim() || env.GJC_TEAM_INTERNAL_WORKER?.split("/").pop()?.trim();
1578
+ if (!teamName || !worker) return { requested: false, reason: "not_worker" };
1579
+ const dir = await findTeamDir(teamName, cwd, env);
1580
+ const config = await readConfig(dir);
1581
+ const configuredWorker = config.workers.find(candidate => candidate.id === worker);
1582
+ const worktreePath = env.GJC_TEAM_WORKTREE_PATH?.trim() || configuredWorker?.worktree_path;
1583
+ if (!worktreePath || !(await pathExists(worktreePath)))
1584
+ return { requested: false, reason: "missing_worktree", worker, team_name: teamName };
1585
+ const classification = classifyWorkerCheckpointStatus(worktreePath);
1586
+ const head = resolveHead(worktreePath);
1587
+ if (classification.kind === "git_error") {
1588
+ return { requested: false, reason: "git_error", worker, team_name: teamName, head, status: classification.kind };
1589
+ }
1590
+ if (classification.kind === "protected_only") {
1591
+ return { requested: false, reason: "no_changes", worker, team_name: teamName, head, status: classification.kind };
1592
+ }
1593
+ if (classification.kind === "clean" && configuredWorker?.worktree_base_ref === head) {
1594
+ return { requested: false, reason: "no_changes", worker, team_name: teamName, head, status: classification.kind };
1595
+ }
1596
+ const fingerprint = workerIntegrationFingerprint(head, classification);
1597
+ const dedupePath = workerIntegrationDedupePath(dir, worker);
1598
+ const dedupe = (await readJsonFile<GjcWorkerIntegrationDedupeState>(dedupePath)) ?? {};
1599
+ if (dedupe.last_requested_fingerprint === fingerprint) {
1600
+ return {
1601
+ requested: false,
1602
+ reason: "deduped",
1603
+ worker,
1604
+ team_name: teamName,
1605
+ fingerprint,
1606
+ head,
1607
+ status: classification.kind,
1608
+ };
1609
+ }
1610
+ await writeJsonFile(dedupePath, {
1611
+ last_requested_fingerprint: fingerprint,
1612
+ last_requested_head: head,
1613
+ last_requested_status: classification.kind,
1614
+ last_requested_at: now(),
1615
+ } satisfies GjcWorkerIntegrationDedupeState);
1616
+ await appendEvent(dir, {
1617
+ type: "worker_integration_attempt_requested",
1618
+ worker,
1619
+ message: `Worker ${worker} requested leader integration attempt`,
1620
+ data: { worker_name: worker, worker_head: head, status: classification.kind, files: classification.files },
1621
+ });
1622
+ await sendGjcTeamMessage(
1623
+ teamName,
1624
+ worker,
1625
+ "leader-fixed",
1626
+ `INTEGRATION REQUESTED: ${worker} has ${classification.kind} git changes at ${head?.slice(0, 12) ?? "unknown-head"}.`,
1627
+ cwd,
1628
+ env,
1629
+ ).catch(() => undefined);
1630
+ await appendCommitHygieneEntries(config, [
1631
+ {
1632
+ recorded_at: now(),
1633
+ operation: "leader_integration_attempt",
1634
+ worker_name: worker,
1635
+ task_id: configuredWorker?.assigned_tasks[0],
1636
+ status: "applied",
1637
+ source_commit: head ?? undefined,
1638
+ worker_head_after: head,
1639
+ worktree_path: worktreePath,
1640
+ detail: "Worker turn-end requested a leader integration attempt for semantic git changes.",
1641
+ },
1642
+ ]);
1643
+ return {
1644
+ requested: true,
1645
+ reason: "requested",
1646
+ worker,
1647
+ team_name: teamName,
1648
+ fingerprint,
1649
+ head,
1650
+ status: classification.kind,
1651
+ };
1652
+ }
1653
+
1654
+ export async function buildTeamHudSummary(
1655
+ snapshot: GjcTeamSnapshot,
1656
+ latestEvent?: GjcTeamEvent,
1657
+ latestMessage?: GjcTeamMailboxMessage,
1658
+ ): Promise<WorkflowHudSummary> {
1659
+ return buildWorkflowTeamHudSummary({
1660
+ phase: snapshot.phase,
1661
+ task_total: snapshot.task_total,
1662
+ task_counts: snapshot.task_counts,
1663
+ workers: snapshot.workers,
1664
+ updated_at: snapshot.updated_at,
1665
+ latestEvent,
1666
+ latestMessage,
1667
+ });
1668
+ }
1669
+
1436
1670
  export async function monitorGjcTeam(
1437
1671
  teamName: string,
1438
1672
  cwd = process.cwd(),
@@ -1470,6 +1704,13 @@ export async function shutdownGjcTeam(
1470
1704
  ): Promise<GjcTeamSnapshot> {
1471
1705
  const dir = await findTeamDir(teamName, cwd, env);
1472
1706
  const config = await readConfig(dir);
1707
+ const tasks = await readTasks(dir);
1708
+ const shutdownPhase: GjcTeamPhase =
1709
+ tasks.length === 0 || tasks.every(task => task.status === "completed")
1710
+ ? "complete"
1711
+ : tasks.some(task => task.status === "failed" || task.status === "blocked")
1712
+ ? "failed"
1713
+ : "cancelled";
1473
1714
  killWorkerPanes(config);
1474
1715
  await removeCleanCreatedWorktrees(config.workers);
1475
1716
  const stopped = {
@@ -1478,9 +1719,19 @@ export async function shutdownGjcTeam(
1478
1719
  updated_at: now(),
1479
1720
  };
1480
1721
  await writeJsonFile(path.join(dir, "config.json"), stopped);
1481
- await writePhase(dir, "complete");
1482
- await appendEvent(dir, { type: "team_shutdown", message: "Shut down native gjc team runtime" });
1483
- await appendTelemetry(dir, { type: "team_shutdown", message: "Native gjc team runtime stopped" });
1722
+ await writePhase(dir, shutdownPhase);
1723
+ await appendEvent(dir, {
1724
+ type: "team_shutdown",
1725
+ message:
1726
+ shutdownPhase === "complete"
1727
+ ? "Shut down native gjc team runtime after completed tasks"
1728
+ : "Shut down native gjc team runtime with incomplete tasks",
1729
+ data: { phase: shutdownPhase },
1730
+ });
1731
+ await appendTelemetry(dir, {
1732
+ type: "team_shutdown",
1733
+ message: `Native gjc team runtime stopped with phase ${shutdownPhase}`,
1734
+ });
1484
1735
  return readGjcTeamSnapshot(config.team_name, cwd, env);
1485
1736
  }
1486
1737
 
@@ -1,6 +1,8 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import { DEFAULT_ULTRAGOAL_OBJECTIVE } from "./goal-mode-request";
2
3
  import {
3
4
  computeUltragoalPlanGeneration,
5
+ getUltragoalPaths,
4
6
  hashStructuredValue,
5
7
  readUltragoalLedger,
6
8
  readUltragoalPlan,
@@ -42,6 +44,31 @@ function objectiveMatches(currentObjective: string, plan: UltragoalPlan): boolea
42
44
  return plan.goals.some(goal => goal.objective === normalized);
43
45
  }
44
46
 
47
+ function isKnownUltragoalObjective(currentObjective: string): boolean {
48
+ const normalized = currentObjective.trim();
49
+ return (
50
+ normalized === DEFAULT_ULTRAGOAL_OBJECTIVE ||
51
+ (normalized.includes(".gjc/ultragoal/goals.json") && normalized.includes(".gjc/ultragoal/ledger.jsonl"))
52
+ );
53
+ }
54
+
55
+ async function hasDurableUltragoalState(cwd: string): Promise<boolean> {
56
+ try {
57
+ await fs.stat(getUltragoalPaths(cwd).dir);
58
+ return true;
59
+ } catch (error) {
60
+ if (
61
+ typeof error === "object" &&
62
+ error !== null &&
63
+ "code" in error &&
64
+ (error as { code?: unknown }).code === "ENOENT"
65
+ ) {
66
+ return false;
67
+ }
68
+ throw error;
69
+ }
70
+ }
71
+
45
72
  function requiredGoals(plan: UltragoalPlan): UltragoalGoal[] {
46
73
  return plan.goals.filter(goal => goal.status !== "superseded");
47
74
  }
@@ -141,6 +168,13 @@ export function validateCompletionReceipt(input: {
141
168
  goalId: input.goal.id,
142
169
  };
143
170
  }
171
+ if (hashStructuredValue(event.gjcGoalJson) !== receipt.gjcGoalSnapshotHash) {
172
+ return {
173
+ state: "active_stale_receipt",
174
+ message: `Ultragoal ${input.goal.id} receipt get_goal snapshot hash does not match ledger.`,
175
+ goalId: input.goal.id,
176
+ };
177
+ }
144
178
  if (input.goal.updatedAt !== receipt.verifiedAt) {
145
179
  return {
146
180
  state: "active_stale_receipt",
@@ -195,7 +229,15 @@ export async function readUltragoalVerificationState(input: {
195
229
  }
196
230
  return { state: "unrelated_goal", message: "Current goal is not an active Ultragoal objective." };
197
231
  }
198
- if (!plan) return { state: "inactive", message: "No Ultragoal plan exists." };
232
+ if (!plan) {
233
+ if (isKnownUltragoalObjective(currentObjective) || (await hasDurableUltragoalState(input.cwd))) {
234
+ return {
235
+ state: "unreadable_fail_closed",
236
+ message: "Active Ultragoal objective is missing durable .gjc/ultragoal/goals.json state.",
237
+ };
238
+ }
239
+ return { state: "inactive", message: "No Ultragoal plan exists." };
240
+ }
199
241
  if (!objectiveMatches(currentObjective, plan))
200
242
  return { state: "unrelated_goal", message: "Current goal is not an active Ultragoal objective." };
201
243
  if (plan.goals.some(goal => goal.status === "review_blocked")) {