@gajae-code/coding-agent 0.11.7 → 0.11.8

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 (180) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/types/cli/args.d.ts +0 -2
  3. package/dist/types/config/keybindings.d.ts +33 -3
  4. package/dist/types/config/model-profile-activation.d.ts +4 -3
  5. package/dist/types/config/model-profile-contract.d.ts +38 -0
  6. package/dist/types/config/settings-schema.d.ts +15 -2
  7. package/dist/types/edit/modes/patch.d.ts +13 -0
  8. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  9. package/dist/types/eval/py/executor.d.ts +1 -1
  10. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  11. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  12. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +1 -0
  13. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
  14. package/dist/types/main.d.ts +8 -0
  15. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  16. package/dist/types/modes/components/tool-status-header.d.ts +3 -2
  17. package/dist/types/modes/components/welcome.d.ts +2 -0
  18. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  19. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  20. package/dist/types/modes/interactive-mode.d.ts +5 -2
  21. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  22. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  23. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  24. package/dist/types/modes/types.d.ts +3 -0
  25. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  26. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  27. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  28. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  29. package/dist/types/sdk/broker/broker.d.ts +2 -0
  30. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  31. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -1
  32. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  33. package/dist/types/sdk/bus/config.d.ts +2 -0
  34. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  35. package/dist/types/sdk/bus/index.d.ts +25 -0
  36. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  37. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  38. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -3
  39. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  40. package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
  41. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  42. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  43. package/dist/types/sdk/host/host.d.ts +1 -1
  44. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  45. package/dist/types/sdk/index.d.ts +2 -0
  46. package/dist/types/sdk/prompt-status.d.ts +88 -0
  47. package/dist/types/sdk/session-directory.d.ts +1 -1
  48. package/dist/types/sdk/startup-capability.d.ts +3 -0
  49. package/dist/types/session/agent-session.d.ts +2 -0
  50. package/dist/types/session/internal/managed-session-scope.d.ts +11 -2
  51. package/dist/types/session/internal/managed-session-storage.d.ts +17 -2
  52. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  53. package/dist/types/session/session-storage.d.ts +18 -0
  54. package/dist/types/task/index.d.ts +3 -1
  55. package/dist/types/task/receipt.d.ts +2 -0
  56. package/dist/types/task/types.d.ts +94 -0
  57. package/dist/types/tools/ask.d.ts +454 -174
  58. package/dist/types/tools/image-gen.d.ts +47 -1
  59. package/dist/types/tools/index.d.ts +4 -0
  60. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  61. package/package.json +8 -8
  62. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  63. package/scripts/dogfood-repository-binding.ts +209 -0
  64. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  65. package/scripts/run-test-manifest.ts +4 -4
  66. package/src/cli/args.ts +2 -92
  67. package/src/cli/notify-cli.ts +5 -38
  68. package/src/cli.ts +18 -0
  69. package/src/commands/sdk.ts +2 -2
  70. package/src/config/keybindings.ts +157 -24
  71. package/src/config/model-profile-activation.ts +7 -18
  72. package/src/config/model-profile-contract.ts +161 -0
  73. package/src/config/model-registry.ts +11 -1
  74. package/src/config/settings-schema.ts +17 -2
  75. package/src/coordinator-mcp/model-preset.ts +21 -74
  76. package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
  77. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  78. package/src/edit/index.ts +3 -6
  79. package/src/edit/modes/patch.ts +38 -1
  80. package/src/edit/modes/replace.ts +42 -0
  81. package/src/edit/path-mutation-lock.ts +112 -0
  82. package/src/eval/py/executor.ts +30 -11
  83. package/src/eval/py/prelude.py +2 -2
  84. package/src/eval/py/tool-bridge.ts +34 -18
  85. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  86. package/src/gjc-runtime/launch-tmux.ts +1 -1
  87. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  88. package/src/gjc-runtime/ralplan-runtime.ts +75 -3
  89. package/src/gjc-runtime/repository-binding.ts +267 -0
  90. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  91. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  92. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  93. package/src/gjc-runtime/ultragoal-guard.ts +5 -5
  94. package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
  95. package/src/internal-urls/docs-index.generated.ts +6 -5
  96. package/src/internal-urls/local-protocol.ts +33 -16
  97. package/src/internal-urls/mcp-protocol.ts +73 -11
  98. package/src/main.ts +25 -10
  99. package/src/modes/DESIGN.md +5 -0
  100. package/src/modes/acp/acp-agent.ts +50 -16
  101. package/src/modes/components/model-selector.ts +42 -8
  102. package/src/modes/components/plan-preview-overlay.ts +1 -1
  103. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  104. package/src/modes/components/tool-status-header.ts +20 -4
  105. package/src/modes/components/welcome.ts +22 -18
  106. package/src/modes/controllers/command-controller.ts +97 -54
  107. package/src/modes/controllers/event-controller.ts +10 -0
  108. package/src/modes/controllers/input-controller.ts +7 -2
  109. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  110. package/src/modes/controllers/selector-controller.ts +46 -54
  111. package/src/modes/interactive-mode.ts +41 -27
  112. package/src/modes/prompt-action-autocomplete.ts +32 -13
  113. package/src/modes/prompt-suggestion-controller.ts +96 -0
  114. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  115. package/src/modes/types.ts +3 -0
  116. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  117. package/src/modes/utils/ui-helpers.ts +5 -3
  118. package/src/prompts/agents/architect.md +0 -1
  119. package/src/prompts/agents/critic.md +0 -1
  120. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  121. package/src/prompts/system/system-prompt.md +6 -4
  122. package/src/prompts/tools/cron.md +3 -1
  123. package/src/runtime-mcp/manager.ts +51 -53
  124. package/src/runtime-mcp/oauth-flow.ts +0 -72
  125. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  126. package/src/runtime-mcp/transports/http.ts +13 -6
  127. package/src/sdk/broker/broker.ts +34 -20
  128. package/src/sdk/broker/lifecycle.ts +185 -36
  129. package/src/sdk/bus/chat-daemon-control.ts +18 -5
  130. package/src/sdk/bus/config-commands.ts +2 -2
  131. package/src/sdk/bus/config.ts +9 -1
  132. package/src/sdk/bus/conversation-store.ts +40 -6
  133. package/src/sdk/bus/index.ts +240 -65
  134. package/src/sdk/bus/lifecycle-commands.ts +47 -3
  135. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  136. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  137. package/src/sdk/bus/notification-orchestration.ts +12 -0
  138. package/src/sdk/bus/notification-service.ts +27 -2
  139. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  140. package/src/sdk/bus/recent-activity.ts +90 -26
  141. package/src/sdk/bus/telegram-daemon-contract.ts +13 -3
  142. package/src/sdk/bus/telegram-daemon-control.ts +145 -33
  143. package/src/sdk/bus/telegram-daemon.ts +835 -110
  144. package/src/sdk/bus/telegram-reference.ts +20 -2
  145. package/src/sdk/cli/session-cli.ts +15 -2
  146. package/src/sdk/host/control/dispatch.ts +1 -1
  147. package/src/sdk/host/control/operations.ts +1 -1
  148. package/src/sdk/host/host.ts +2 -6
  149. package/src/sdk/host/query/handlers.ts +70 -5
  150. package/src/sdk/index.ts +12 -0
  151. package/src/sdk/prompt-status.ts +97 -0
  152. package/src/sdk/protocol/operation-inventory.generated.json +47 -0
  153. package/src/sdk/protocol/operation-registry.ts +18 -4
  154. package/src/sdk/session-directory.ts +5 -1
  155. package/src/sdk/session.ts +2 -0
  156. package/src/sdk/startup-capability.ts +9 -1
  157. package/src/session/agent-session.ts +52 -5
  158. package/src/session/blob-store.ts +7 -2
  159. package/src/session/internal/managed-session-scope.ts +854 -84
  160. package/src/session/internal/managed-session-storage.ts +251 -199
  161. package/src/session/internal/native-publish-outcome.ts +266 -0
  162. package/src/session/session-manager.ts +310 -327
  163. package/src/session/session-storage.ts +128 -11
  164. package/src/task/gjc-command.ts +2 -2
  165. package/src/task/index.ts +121 -15
  166. package/src/task/receipt.ts +3 -0
  167. package/src/task/types.ts +34 -3
  168. package/src/tools/ask.ts +198 -88
  169. package/src/tools/ast-edit.ts +2 -2
  170. package/src/tools/bash-pty-selection.ts +2 -2
  171. package/src/tools/browser/screenshot-format.ts +6 -1
  172. package/src/tools/cron.ts +1 -1
  173. package/src/tools/image-gen.ts +178 -6
  174. package/src/tools/index.ts +4 -0
  175. package/src/tools/render-utils.ts +7 -1
  176. package/src/utils/edit-mode.ts +2 -2
  177. package/src/utils/prompt-suggestion.ts +265 -0
  178. package/src/utils/sixel.ts +3 -3
  179. package/src/web/insane/url-guard.ts +3 -1
  180. package/src/web/scrapers/docs-rs.ts +30 -5
@@ -911,15 +911,11 @@ async function operatorDispatchIdForOwner(
911
911
  }
912
912
  }
913
913
 
914
- async function persistCoordinatorRuntimeStateFromOwnerTerminalPostmortem(
914
+ async function observeOwnerTerminalPostmortem(
915
915
  reason: postmortem.Reason,
916
- context: RuntimeStateContext,
917
- stateFile: string,
916
+ owner: OwnerTerminalContext,
918
917
  sessionId: string,
919
- previous: Record<string, unknown>,
920
- ): Promise<void> {
921
- const owner = context.ownerTerminal;
922
- if (!owner) return;
918
+ ): Promise<OwnerVerdict | null> {
923
919
  try {
924
920
  const now = new Date().toISOString();
925
921
  const observation: Omit<ObserveTerminalRequest, "operator_dispatch_id"> = {
@@ -937,10 +933,27 @@ async function persistCoordinatorRuntimeStateFromOwnerTerminalPostmortem(
937
933
  reason: "process_postmortem",
938
934
  };
939
935
  const operatorDispatchId = await operatorDispatchIdForOwner(owner, observation);
940
- const verdict = await observeOwnerTerminal({
936
+ return await observeOwnerTerminal({
941
937
  ...observation,
942
938
  ...(operatorDispatchId ? { operator_dispatch_id: operatorDispatchId } : {}),
943
939
  });
940
+ } catch {
941
+ return null;
942
+ }
943
+ }
944
+
945
+ async function persistCoordinatorRuntimeStateFromOwnerTerminalPostmortem(
946
+ context: RuntimeStateContext,
947
+ stateFile: string,
948
+ sessionId: string,
949
+ previous: Record<string, unknown>,
950
+ verdict: OwnerVerdict | null,
951
+ ): Promise<void> {
952
+ const owner = context.ownerTerminal;
953
+ if (!owner) return;
954
+ try {
955
+ if (!verdict) throw new Error("owner terminal verdict unavailable");
956
+ const now = new Date().toISOString();
944
957
  const expected = verdict.classification === "expected_operator_shutdown";
945
958
  const state: RuntimeState = expected ? "completed" : "errored";
946
959
  const payload = {
@@ -1009,6 +1022,9 @@ export async function persistCoordinatorRuntimeStateFromPostmortem(
1009
1022
  const stateFile = runtimeStateFileForContext(context);
1010
1023
  if (!stateFile) return;
1011
1024
  const identity = normalizedIdentity(context);
1025
+ const ownerTerminalVerdict = context.ownerTerminal
1026
+ ? await observeOwnerTerminalPostmortem(reason, context.ownerTerminal, identity.sessionId)
1027
+ : null;
1012
1028
  await serializeStateFileWrite(
1013
1029
  stateFile,
1014
1030
  async () =>
@@ -1033,11 +1049,11 @@ export async function persistCoordinatorRuntimeStateFromPostmortem(
1033
1049
  }
1034
1050
  if (context.ownerTerminal) {
1035
1051
  await persistCoordinatorRuntimeStateFromOwnerTerminalPostmortem(
1036
- reason,
1037
1052
  context,
1038
1053
  stateFile,
1039
1054
  identity.sessionId,
1040
1055
  previous,
1056
+ ownerTerminalVerdict,
1041
1057
  );
1042
1058
  return;
1043
1059
  }
@@ -1646,7 +1646,8 @@ export function isValidOwnerIntent(intent: unknown, request?: ObserveTerminalReq
1646
1646
  intent.generation === request.owner_generation &&
1647
1647
  intent.session_id === request.session_id &&
1648
1648
  intent.server_key === request.socket_key &&
1649
- (request.operator_dispatch_id === undefined || intent.dispatch_id === request.operator_dispatch_id) &&
1649
+ request.operator_dispatch_id !== undefined &&
1650
+ intent.dispatch_id === request.operator_dispatch_id &&
1650
1651
  request.signal === "SIGTERM"
1651
1652
  );
1652
1653
  }
@@ -3,6 +3,7 @@ import * as crypto from "node:crypto";
3
3
  import * as fsSync from "node:fs";
4
4
  import * as fs from "node:fs/promises";
5
5
  import * as path from "node:path";
6
+ import { Process } from "@gajae-code/natives";
6
7
  import { readLinuxProcStartTime, readLinuxProcStartTimeSync } from "./linux-proc";
7
8
  import { resolveGjcTmuxBinary } from "./psmux-detect";
8
9
  import { tmuxRuntimeSessionPath } from "./session-layout";
@@ -43,6 +44,7 @@ import {
43
44
  lifecyclePaths,
44
45
  type OwnerIsolationProbeSync,
45
46
  type OwnerVerdict,
47
+ observeOwnerTerminal,
46
48
  type PlanResponse,
47
49
  planTmuxOwnerIsolationSync,
48
50
  replaceOwnerGenerationSync,
@@ -963,6 +965,14 @@ async function readProcessStartTime(pid: number): Promise<string | null> {
963
965
  return readLinuxProcStartTime(pid);
964
966
  }
965
967
 
968
+ function exactManagedOwnerSupervisor(supervisorPid: number, supervisorStartTime: string): Process {
969
+ const supervisor = Process.fromPid(supervisorPid);
970
+ if (!supervisor) throw new Error("managed_owner_supervisor_unverifiable");
971
+ if (process.platform === "linux" && supervisor.incarnation !== `linux:${supervisorStartTime}`)
972
+ throw new Error("managed_owner_supervisor_incarnation_mismatch");
973
+ return supervisor;
974
+ }
975
+
966
976
  async function readCurrentGeneration(stateDir: string, sessionId: string): Promise<string | null> {
967
977
  try {
968
978
  const value: unknown = JSON.parse(
@@ -1016,12 +1026,12 @@ async function requireUnchangedOwnerForCompatibilityCleanup(
1016
1026
  initialServer: { pid: number; startTime: string },
1017
1027
  listPanePids: (sessionName: string, env: NodeJS.ProcessEnv) => number[],
1018
1028
  readStartTime: (pid: number) => Promise<string | null>,
1019
- ): Promise<void> {
1029
+ ): Promise<boolean> {
1020
1030
  try {
1021
1031
  const currentNativeSessionId = readNativeTmuxSessionId(nativeSessionId, env);
1022
1032
  if (!currentNativeSessionId) {
1023
1033
  if (readNativeTmuxSessionId(sessionName, env)) throw new Error(`gjc_tmux_owner_changed:${sessionName}`);
1024
- return;
1034
+ return false;
1025
1035
  }
1026
1036
  const currentServer = requireSafeTmuxServerForMutation(resolveGjcTmuxCommand(env), env);
1027
1037
  const panePids = listPanePids(nativeSessionId, env);
@@ -1054,10 +1064,11 @@ async function requireUnchangedOwnerForCompatibilityCleanup(
1054
1064
  : null,
1055
1065
  ].filter((value): value is string => value !== null);
1056
1066
  if (mismatches.length > 0) throw new Error(`gjc_tmux_owner_changed:${sessionName}:${mismatches.join(",")}`);
1067
+ return true;
1057
1068
  } catch (error) {
1058
1069
  if (!readNativeTmuxSessionId(nativeSessionId, env)) {
1059
1070
  if (readNativeTmuxSessionId(sessionName, env)) throw new Error(`gjc_tmux_owner_changed:${sessionName}`);
1060
- return;
1071
+ return false;
1061
1072
  }
1062
1073
  if (error instanceof Error && error.message.startsWith(`gjc_tmux_owner_changed:${sessionName}`))
1063
1074
  throw new Error(`gjc_tmux_owner_changed:${sessionName}`);
@@ -1152,6 +1163,7 @@ export async function forceCloseGjcTmuxSession(
1152
1163
  const now = deps.now ?? (() => new Date());
1153
1164
  const sleep = deps.sleep ?? (ms => Bun.sleep(ms));
1154
1165
  const dispatchId = crypto.randomUUID();
1166
+ let operatorVerdict: Promise<OwnerVerdict> | null = null;
1155
1167
  await closeExactTmuxOwner(
1156
1168
  {
1157
1169
  stateDir: identity.stateDir,
@@ -1169,12 +1181,37 @@ export async function forceCloseGjcTmuxSession(
1169
1181
  sendSigterm: async pid => {
1170
1182
  if ((await (deps.readProcessStartTime ?? readProcessStartTime)(pid)) !== identity.startTime)
1171
1183
  throw new Error("owner_pid_identity_mismatch");
1172
- (deps.signalTerm ?? (target => process.kill(target, "SIGTERM")))(pid);
1184
+ if (deps.signalTerm) {
1185
+ deps.signalTerm(pid);
1186
+ } else {
1187
+ const supervisor = exactManagedOwnerSupervisor(pid, identity.startTime);
1188
+ if (!supervisor.signalRoot(15)) throw new Error("managed_owner_supervisor_signal_failed");
1189
+ operatorVerdict = supervisor
1190
+ .waitForExit({ timeoutMs: FORCE_CLOSE_VERDICT_TIMEOUT_MS - 500 })
1191
+ .then(async exited => {
1192
+ if (!exited) throw new Error("managed_owner_supervisor_exit_timeout");
1193
+ return await observeOwnerTerminal({
1194
+ schema_version: 1,
1195
+ op: "observe_terminal",
1196
+ session_id: identity.sessionId,
1197
+ owner_generation: identity.generation,
1198
+ state_dir: identity.stateDir,
1199
+ socket_key: identity.socketKey,
1200
+ observer: "raw_monitor",
1201
+ observed_at: now().toISOString(),
1202
+ signal: "SIGTERM",
1203
+ exit_code: null,
1204
+ exit_kind: "exact_owner_exit_observed",
1205
+ reason: "operator_observed_owner_exit",
1206
+ operator_dispatch_id: dispatchId,
1207
+ });
1208
+ });
1209
+ }
1173
1210
  },
1174
1211
 
1175
- waitForVerdict: () => waitForExpectedVerdict(identity, sleep, now),
1212
+ waitForVerdict: () => operatorVerdict ?? waitForExpectedVerdict(identity, sleep, now),
1176
1213
  cleanupSession: async () => {
1177
- await requireUnchangedOwnerForCompatibilityCleanup(
1214
+ const cleanupRequired = await requireUnchangedOwnerForCompatibilityCleanup(
1178
1215
  session.name,
1179
1216
  nativeSessionId,
1180
1217
  env,
@@ -1184,6 +1221,7 @@ export async function forceCloseGjcTmuxSession(
1184
1221
  deps.listPanePids ?? readExactSessionPanePids,
1185
1222
  deps.readProcessStartTime ?? readProcessStartTime,
1186
1223
  );
1224
+ if (!cleanupRequired) return;
1187
1225
  if (deps.cleanupSession) deps.cleanupSession(nativeSessionId, env);
1188
1226
  else
1189
1227
  runGuardedTmuxSessionCommand(
@@ -1192,6 +1230,7 @@ export async function forceCloseGjcTmuxSession(
1192
1230
  initialServer.pid,
1193
1231
  env,
1194
1232
  `kill-session -t '${nativeSessionId}'`,
1233
+ identity.generation,
1195
1234
  );
1196
1235
  },
1197
1236
  },
@@ -76,12 +76,12 @@ function objectiveMatches(currentGoal: CurrentGoalLike, plan: UltragoalPlan, ses
76
76
  return plan.goals.some(goal => goal.objective === normalized);
77
77
  }
78
78
 
79
- function isKnownUltragoalObjective(currentObjective: string): boolean {
79
+ export function isKnownUltragoalObjective(currentObjective: string): boolean {
80
80
  const normalized = currentObjective.trim();
81
- return (
82
- normalized === DEFAULT_ULTRAGOAL_OBJECTIVE ||
83
- (normalized.includes(".gjc/ultragoal/goals.json") && normalized.includes(".gjc/ultragoal/ledger.jsonl"))
84
- );
81
+ // Exact default aggregate objective only. Substring path sniffing previously
82
+ // treated arbitrary objectives that merely mentioned goals.json/ledger paths
83
+ // as Ultragoal-owned, which could mis-arm guards. Prefer fail-closed.
84
+ return normalized === DEFAULT_ULTRAGOAL_OBJECTIVE;
85
85
  }
86
86
 
87
87
  async function ultragoalReadPaths(
@@ -5,6 +5,12 @@ import type { WorkflowHudSummary } from "../skill-state/active-state";
5
5
  import { buildUltragoalHudSummary as buildWorkflowUltragoalHudSummary } from "../skill-state/workflow-hud";
6
6
  import { renderCliWriteReceipt } from "./cli-write-receipt";
7
7
  import { DEFAULT_ULTRAGOAL_OBJECTIVE } from "./goal-mode-request";
8
+ import {
9
+ assertCwdMatchesRepositoryBinding,
10
+ captureRepositoryBinding,
11
+ parseRepositoryBinding,
12
+ type RepositoryBinding,
13
+ } from "./repository-binding";
8
14
  import {
9
15
  CRITIC_GATE_HARD_STOP_EVENT,
10
16
  CRITIC_GATE_OVERRIDE_EVENT,
@@ -192,6 +198,8 @@ export interface UltragoalPlan {
192
198
  gjcObjective: string;
193
199
  gjcObjectiveAliases?: string[];
194
200
  goals: UltragoalGoal[];
201
+ /** Authoritative repository identity for multi-repo fail-closed spawn (#2901). */
202
+ repositoryBinding?: RepositoryBinding;
195
203
  createdAt: string;
196
204
  updatedAt: string;
197
205
  [key: string]: unknown;
@@ -1349,6 +1357,10 @@ function normalizePlan(raw: unknown): UltragoalPlan {
1349
1357
  (value): value is string => typeof value === "string" && value.trim().length > 0,
1350
1358
  )
1351
1359
  : undefined;
1360
+ let repositoryBinding: RepositoryBinding | undefined;
1361
+ if (record.repositoryBinding !== undefined) {
1362
+ repositoryBinding = parseRepositoryBinding(record.repositoryBinding);
1363
+ }
1352
1364
  return {
1353
1365
  version: 1,
1354
1366
  brief,
@@ -1358,6 +1370,7 @@ function normalizePlan(raw: unknown): UltragoalPlan {
1358
1370
  goals,
1359
1371
  createdAt,
1360
1372
  updatedAt,
1373
+ ...(repositoryBinding ? { repositoryBinding } : {}),
1361
1374
  ...(typeof record.state_revision === "number" && Number.isFinite(record.state_revision)
1362
1375
  ? { state_revision: record.state_revision }
1363
1376
  : {}),
@@ -1558,12 +1571,16 @@ export async function createUltragoalPlan(input: {
1558
1571
  goal.validationBatch = validationBatchByGoalId.get(goal.id);
1559
1572
  validateValidationBatchPipelineExclusion(goal);
1560
1573
  }
1574
+ const repositoryBinding = await captureRepositoryBinding(input.cwd, {
1575
+ displayPath: input.cwd,
1576
+ });
1561
1577
  const plan: UltragoalPlan = {
1562
1578
  version: 1,
1563
1579
  brief,
1564
1580
  gjcGoalMode: input.gjcGoalMode ?? "aggregate",
1565
1581
  gjcObjective: DEFAULT_ULTRAGOAL_OBJECTIVE,
1566
1582
  goals,
1583
+ repositoryBinding,
1567
1584
  createdAt: now,
1568
1585
  updatedAt: now,
1569
1586
  };
@@ -1639,6 +1656,50 @@ export function getUltragoalRunCompletionState(
1639
1656
  };
1640
1657
  }
1641
1658
 
1659
+ /**
1660
+ * Discriminated next-action for `complete-goals` handoff (#2903).
1661
+ * `none` is reserved for genuine completion; `execute-goal` always carries a goal.
1662
+ */
1663
+ export type UltragoalCompleteNextActionKind =
1664
+ | "none"
1665
+ | "execute-goal"
1666
+ | "retry-failed"
1667
+ | "resolve-blockers"
1668
+ | "final-aggregate-receipt";
1669
+
1670
+ export type UltragoalCompleteNextAction = {
1671
+ kind: UltragoalCompleteNextActionKind;
1672
+ goal?: UltragoalGoal;
1673
+ blockedGoals?: UltragoalGoal[];
1674
+ failedGoals?: UltragoalGoal[];
1675
+ };
1676
+
1677
+ /**
1678
+ * Resolve the actionable next step after scheduling / complete-goals.
1679
+ * Blocked and review_blocked goals remain unschedulable; they surface as
1680
+ * `resolve-blockers` instead of a contradictory `execute-goal` without goal_id.
1681
+ */
1682
+ export function resolveUltragoalCompleteNextAction(
1683
+ plan: UltragoalPlan,
1684
+ options: { retryFailed?: boolean; selectedGoal?: UltragoalGoal } = {},
1685
+ ): UltragoalCompleteNextAction {
1686
+ const state = getUltragoalRunCompletionState(plan, { retryFailed: options.retryFailed });
1687
+ // Genuine completion keeps next_action=`none` (historical complete-goals contract).
1688
+ // final-aggregate-receipt is reserved for a future dedicated handoff; do not remap
1689
+ // allComplete here so aggregate runs still finish with `none` / complete text.
1690
+ if (state.allComplete) return { kind: "none" };
1691
+ const goal = options.selectedGoal ?? state.nextGoal;
1692
+ if (goal) return { kind: "execute-goal", goal };
1693
+ const blockedGoals = state.incompleteGoals.filter(
1694
+ item => item.status === "blocked" || item.status === "review_blocked",
1695
+ );
1696
+ if (blockedGoals.length > 0) return { kind: "resolve-blockers", blockedGoals };
1697
+ const failedGoals = state.incompleteGoals.filter(item => item.status === "failed");
1698
+ if (failedGoals.length > 0) return { kind: "retry-failed", failedGoals };
1699
+ // Incomplete but not schedulable (unexpected statuses): still actionable, not "none".
1700
+ return { kind: "resolve-blockers", blockedGoals: state.incompleteGoals };
1701
+ }
1702
+
1642
1703
  export async function startNextUltragoalGoal(input: {
1643
1704
  cwd: string;
1644
1705
  retryFailed?: boolean;
@@ -1647,11 +1708,27 @@ export async function startNextUltragoalGoal(input: {
1647
1708
  plan: UltragoalPlan;
1648
1709
  goal?: UltragoalGoal;
1649
1710
  allComplete: boolean;
1711
+ nextAction: UltragoalCompleteNextAction;
1650
1712
  }> {
1651
1713
  const plan = await readUltragoalPlan(input.cwd, input.sessionId);
1652
1714
  if (!plan) throw new Error("No ultragoal plan found. Run `gjc ultragoal create-goals --brief ...` first.");
1653
- const goal = chooseNextGoal(plan, input.retryFailed === true);
1654
- if (!goal) return { plan, allComplete: getUltragoalRunCompletionState(plan).allComplete };
1715
+ // Fail closed: delegated execution requires stamped repository authority (#2901).
1716
+ if (!plan.repositoryBinding) {
1717
+ throw new Error(
1718
+ "Ultragoal plan is missing repositoryBinding; recreate goals so the plan is bound to an authoritative repository identity.",
1719
+ );
1720
+ }
1721
+ await assertCwdMatchesRepositoryBinding(input.cwd, plan.repositoryBinding);
1722
+ const retryFailed = input.retryFailed === true;
1723
+ const goal = chooseNextGoal(plan, retryFailed);
1724
+ if (!goal) {
1725
+ const state = getUltragoalRunCompletionState(plan, { retryFailed });
1726
+ return {
1727
+ plan,
1728
+ allComplete: state.allComplete,
1729
+ nextAction: resolveUltragoalCompleteNextAction(plan, { retryFailed }),
1730
+ };
1731
+ }
1655
1732
  if (goal.status !== "active") {
1656
1733
  const now = new Date().toISOString();
1657
1734
  goal.status = "active";
@@ -1661,7 +1738,12 @@ export async function startNextUltragoalGoal(input: {
1661
1738
  await writePlan(input.cwd, plan, input.sessionId);
1662
1739
  await appendLedger(input.cwd, { event: "goal_started", goalId: goal.id }, input.sessionId);
1663
1740
  }
1664
- return { plan, goal, allComplete: false };
1741
+ return {
1742
+ plan,
1743
+ goal,
1744
+ allComplete: false,
1745
+ nextAction: { kind: "execute-goal", goal },
1746
+ };
1665
1747
  }
1666
1748
 
1667
1749
  async function readStructuredValue(cwd: string, value: string): Promise<unknown> {
@@ -4237,31 +4319,111 @@ function renderStatus(summary: UltragoalStatusSummary, json: boolean): string {
4237
4319
  return renderUltragoalStatusMarkdown(summary);
4238
4320
  }
4239
4321
 
4322
+ function summarizeBlockedGoalForHandoff(goal: UltragoalGoal): {
4323
+ id: string;
4324
+ status: UltragoalGoalStatus;
4325
+ evidence?: string;
4326
+ } {
4327
+ const evidence = typeof goal.evidence === "string" && goal.evidence.trim() ? goal.evidence.trim() : undefined;
4328
+ return {
4329
+ id: goal.id,
4330
+ status: goal.status,
4331
+ ...(evidence ? { evidence } : {}),
4332
+ };
4333
+ }
4334
+
4240
4335
  function renderCompleteHandoff(
4241
- result: { plan: UltragoalPlan; goal?: UltragoalGoal; allComplete: boolean },
4336
+ result: {
4337
+ plan: UltragoalPlan;
4338
+ goal?: UltragoalGoal;
4339
+ allComplete: boolean;
4340
+ nextAction?: UltragoalCompleteNextAction;
4341
+ },
4242
4342
  json: boolean,
4243
4343
  cwd: string,
4244
4344
  ): string {
4345
+ const nextAction =
4346
+ result.nextAction ??
4347
+ resolveUltragoalCompleteNextAction(result.plan, {
4348
+ selectedGoal: result.goal,
4349
+ });
4350
+ const goalsPath = getUltragoalPaths(cwd, currentUltragoalSessionId(cwd)).goalsPath;
4351
+
4245
4352
  if (json) {
4246
- return renderCliWriteReceipt({
4353
+ const receipt: Record<string, unknown> = {
4247
4354
  ok: true,
4248
4355
  all_complete: result.allComplete,
4249
- next_action: result.allComplete ? "none" : "execute-goal",
4250
- goal_id: result.goal?.id,
4251
- goal_status: result.goal?.status,
4356
+ next_action: nextAction.kind,
4252
4357
  gjc_objective: result.plan.gjcObjective,
4253
- goals_path: getUltragoalPaths(cwd, currentUltragoalSessionId(cwd)).goalsPath,
4254
- });
4358
+ goals_path: goalsPath,
4359
+ };
4360
+ if (nextAction.kind === "execute-goal" && nextAction.goal) {
4361
+ receipt.goal_id = nextAction.goal.id;
4362
+ receipt.goal_status = nextAction.goal.status;
4363
+ }
4364
+ if (nextAction.kind === "resolve-blockers" && nextAction.blockedGoals) {
4365
+ receipt.blocked_goals = nextAction.blockedGoals.map(summarizeBlockedGoalForHandoff);
4366
+ receipt.blocked_goal_ids = nextAction.blockedGoals.map(goal => goal.id);
4367
+ receipt.recovery_hints = [
4368
+ "gjc ultragoal classify-blocker --help",
4369
+ "gjc ultragoal record-review-blockers --help",
4370
+ "gjc ultragoal steer --kind add_subgoal --help",
4371
+ "gjc ultragoal steer --kind mark_blocked_superseded --help",
4372
+ ];
4373
+ }
4374
+ if (nextAction.kind === "retry-failed" && nextAction.failedGoals) {
4375
+ receipt.failed_goal_ids = nextAction.failedGoals.map(goal => goal.id);
4376
+ receipt.recovery_hints = ["gjc ultragoal complete-goals --retry-failed"];
4377
+ }
4378
+ if (nextAction.kind === "final-aggregate-receipt") {
4379
+ receipt.recovery_hints = [
4380
+ "Finalize the aggregate completion receipt before treating the ultragoal run as closed.",
4381
+ ];
4382
+ }
4383
+ return renderCliWriteReceipt(receipt);
4255
4384
  }
4256
- if (result.allComplete) return "ultragoal complete all=true\n";
4257
- if (!result.goal) return "ultragoal next-action=none\n";
4258
- return [
4259
- `ultragoal next-action=execute-goal goal-id=${result.goal.id}`,
4260
- `objective=${result.goal.objective}`,
4261
- `gjc-objective=${result.plan.gjcObjective}`,
4262
- "checkpoint requires=architectReview:CLEAR+APPROVE,executorQa:passed",
4263
- "",
4264
- ].join("\n");
4385
+
4386
+ if (nextAction.kind === "none" || (result.allComplete && nextAction.kind !== "final-aggregate-receipt")) {
4387
+ return "ultragoal complete all=true\n";
4388
+ }
4389
+ if (nextAction.kind === "final-aggregate-receipt") {
4390
+ return [
4391
+ "ultragoal next-action=final-aggregate-receipt",
4392
+ "hint=finalize the aggregate completion receipt before treating the run as closed",
4393
+ "",
4394
+ ].join("\n");
4395
+ }
4396
+ if (nextAction.kind === "execute-goal" && nextAction.goal) {
4397
+ return [
4398
+ `ultragoal next-action=execute-goal goal-id=${nextAction.goal.id}`,
4399
+ `objective=${nextAction.goal.objective}`,
4400
+ `gjc-objective=${result.plan.gjcObjective}`,
4401
+ "checkpoint requires=architectReview:CLEAR+APPROVE,executorQa:passed",
4402
+ "",
4403
+ ].join("\n");
4404
+ }
4405
+ if (nextAction.kind === "resolve-blockers" && nextAction.blockedGoals && nextAction.blockedGoals.length > 0) {
4406
+ const ids = nextAction.blockedGoals.map(goal => goal.id).join(",");
4407
+ const statuses = nextAction.blockedGoals.map(goal => `${goal.id}:${goal.status}`).join(",");
4408
+ return [
4409
+ "ultragoal next-action=resolve-blockers",
4410
+ `blocked-goal-ids=${ids}`,
4411
+ `blocked-statuses=${statuses}`,
4412
+ "hint=resolve blockers via classify-blocker / record-review-blockers / steer --kind add_subgoal (or audited mark_blocked_superseded); blocked goals stay unschedulable",
4413
+ "",
4414
+ ].join("\n");
4415
+ }
4416
+ if (nextAction.kind === "retry-failed" && nextAction.failedGoals && nextAction.failedGoals.length > 0) {
4417
+ const ids = nextAction.failedGoals.map(goal => goal.id).join(",");
4418
+ return [
4419
+ "ultragoal next-action=retry-failed",
4420
+ `failed-goal-ids=${ids}`,
4421
+ "hint=run `gjc ultragoal complete-goals --retry-failed` after the failure is addressed",
4422
+ "",
4423
+ ].join("\n");
4424
+ }
4425
+ // Fail closed: never claim complete or execute-goal without a goal id.
4426
+ return "ultragoal next-action=resolve-blockers\nhint=no schedulable goal; inspect goals.json and ledger\n";
4265
4427
  }
4266
4428
  function renderCheckpointContinuation(
4267
4429
  result: UltragoalCheckpointContinuation,