@gajae-code/coding-agent 0.11.6 → 0.11.7

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 (131) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/commands/daemon.d.ts +2 -2
  5. package/dist/types/commands/sdk.d.ts +20 -2
  6. package/dist/types/config/keybindings.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +56 -6
  8. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  9. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +5 -5
  10. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +19 -5
  12. package/dist/types/main.d.ts +24 -1
  13. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  14. package/dist/types/modes/components/model-selector.d.ts +2 -0
  15. package/dist/types/modes/components/tool-status-header.d.ts +1 -0
  16. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  17. package/dist/types/modes/interactive-mode.d.ts +2 -0
  18. package/dist/types/modes/types.d.ts +1 -0
  19. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  20. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  21. package/dist/types/sdk/bus/index.d.ts +1 -1
  22. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  23. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -3
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +61 -3
  26. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  27. package/dist/types/sdk/client/discovery.d.ts +2 -0
  28. package/dist/types/sdk/client/liveness.d.ts +7 -0
  29. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  30. package/dist/types/sdk/transport/index.d.ts +12 -0
  31. package/dist/types/sdk/transport/relay.d.ts +43 -0
  32. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  33. package/dist/types/sdk/transport/socket.d.ts +7 -0
  34. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  35. package/dist/types/session/agent-session.d.ts +28 -5
  36. package/dist/types/session/btw-contract.d.ts +16 -0
  37. package/dist/types/session/internal/managed-session-scope.d.ts +21 -7
  38. package/dist/types/session/internal/managed-session-storage.d.ts +10 -8
  39. package/dist/types/session/session-manager.d.ts +25 -4
  40. package/dist/types/setup/credential-import.d.ts +5 -1
  41. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  42. package/dist/types/ssh/utils.d.ts +1 -0
  43. package/dist/types/task/discovery.d.ts +3 -1
  44. package/dist/types/tools/fetch.d.ts +5 -8
  45. package/dist/types/tools/image-gen.d.ts +16 -0
  46. package/dist/types/tools/skill-discovery.d.ts +7 -0
  47. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  48. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  49. package/dist/types/web/insane/bridge.d.ts +1 -0
  50. package/dist/types/web/insane/url-guard.d.ts +17 -0
  51. package/package.json +8 -8
  52. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  53. package/src/capability/index.ts +50 -42
  54. package/src/capability/types.ts +3 -0
  55. package/src/cli/setup-cli.ts +10 -4
  56. package/src/cli.ts +34 -9
  57. package/src/commands/daemon.ts +22 -5
  58. package/src/commands/sdk.ts +42 -9
  59. package/src/config/keybindings.ts +1 -1
  60. package/src/config/model-profiles.ts +18 -0
  61. package/src/config/settings-schema.ts +57 -6
  62. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  63. package/src/discovery/ssh.ts +12 -1
  64. package/src/eval/py/kernel.ts +1 -21
  65. package/src/eval/py/runner-artifact.ts +84 -0
  66. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  67. package/src/gjc-runtime/ultragoal-guard.ts +88 -10
  68. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  69. package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
  70. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  71. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  72. package/src/internal-urls/docs-index.generated.ts +8 -7
  73. package/src/internal-urls/local-protocol.ts +32 -6
  74. package/src/main.ts +86 -27
  75. package/src/modes/action-registry.ts +1 -1
  76. package/src/modes/components/btw-panel.ts +82 -16
  77. package/src/modes/components/model-selector.ts +31 -3
  78. package/src/modes/components/tool-status-header.ts +6 -1
  79. package/src/modes/controllers/btw-controller.ts +119 -36
  80. package/src/modes/controllers/input-controller.ts +31 -0
  81. package/src/modes/controllers/selector-controller.ts +106 -12
  82. package/src/modes/interactive-mode.ts +16 -7
  83. package/src/modes/types.ts +1 -0
  84. package/src/prompts/system/btw-user.md +3 -8
  85. package/src/prompts/tools/skill-discovery.md +1 -0
  86. package/src/runtime-mcp/client.ts +41 -55
  87. package/src/runtime-mcp/content-limits.ts +71 -0
  88. package/src/runtime-mcp/json-rpc.ts +23 -9
  89. package/src/runtime-mcp/transports/http.ts +50 -16
  90. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  91. package/src/sdk/bus/index.ts +4 -5
  92. package/src/sdk/bus/lifecycle-commands.ts +16 -7
  93. package/src/sdk/bus/notification-service.ts +41 -18
  94. package/src/sdk/bus/telegram-daemon-contract.ts +8 -3
  95. package/src/sdk/bus/telegram-daemon-control.ts +2 -2
  96. package/src/sdk/bus/telegram-daemon.ts +1663 -375
  97. package/src/sdk/bus/topic-registry.ts +451 -12
  98. package/src/sdk/client/discovery.ts +19 -3
  99. package/src/sdk/client/liveness.ts +49 -0
  100. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  101. package/src/sdk/session.ts +29 -5
  102. package/src/sdk/transport/auth-preface.ts +82 -0
  103. package/src/sdk/transport/index.ts +18 -0
  104. package/src/sdk/transport/relay.ts +252 -0
  105. package/src/sdk/transport/serve-cli.ts +105 -0
  106. package/src/sdk/transport/socket.ts +197 -0
  107. package/src/sdk/transport/stdio.ts +17 -0
  108. package/src/session/agent-session.ts +240 -149
  109. package/src/session/blob-store.ts +19 -6
  110. package/src/session/btw-contract.ts +59 -0
  111. package/src/session/internal/managed-session-scope.ts +313 -57
  112. package/src/session/internal/managed-session-storage.ts +146 -35
  113. package/src/session/session-manager.ts +319 -85
  114. package/src/setup/credential-import.ts +28 -3
  115. package/src/setup/provider-onboarding.ts +30 -1
  116. package/src/setup/provider-presets.json +2 -1
  117. package/src/slash-commands/builtin-registry.ts +1 -1
  118. package/src/ssh/utils.ts +14 -0
  119. package/src/task/discovery.ts +8 -2
  120. package/src/tools/ask.ts +1 -1
  121. package/src/tools/fetch.ts +13 -131
  122. package/src/tools/image-gen.ts +301 -20
  123. package/src/tools/read.ts +2 -0
  124. package/src/tools/skill-discovery.ts +39 -3
  125. package/src/tools/sqlite-reader.ts +63 -5
  126. package/src/utils/pasted-image-path.ts +15 -6
  127. package/src/utils/shell-snapshot.ts +173 -63
  128. package/src/web/insane/bridge.ts +5 -1
  129. package/src/web/insane/url-guard.ts +138 -27
  130. package/src/web/scrapers/types.ts +19 -49
  131. package/src/web/scrapers/utils.ts +12 -26
@@ -5,10 +5,14 @@
5
5
  */
6
6
 
7
7
  import * as fs from "node:fs/promises";
8
+ import * as http from "node:http";
9
+ import * as https from "node:https";
10
+ import * as net from "node:net";
8
11
  import * as os from "node:os";
9
12
  import * as path from "node:path";
10
13
  import { isEnoent, logger } from "@gajae-code/utils";
11
14
  import * as git from "../../../utils/git";
15
+ import { isPrivateOrSpecialAddress, validatePublicHttpUrl } from "../../../web/insane/url-guard";
12
16
 
13
17
  import type { MarketplaceCatalog, MarketplaceSourceType } from "./types";
14
18
  import { isValidNameSegment } from "./types";
@@ -194,6 +198,192 @@ export function parseMarketplaceCatalog(content: string, filePath: string): Mark
194
198
 
195
199
  /** Relative path from a marketplace root to its catalog file. */
196
200
  const CATALOG_RELATIVE_PATH = path.join(".claude-plugin", "marketplace.json");
201
+ const URL_FETCH_TIMEOUT_MS = 60_000;
202
+ const URL_FETCH_MAX_REDIRECTS = 5;
203
+ const URL_FETCH_MAX_HEADER_BYTES = 16 * 1024;
204
+ /** Direct JSON catalogs are metadata; 2 MiB leaves ample room without permitting unbounded buffering. */
205
+ const URL_FETCH_MAX_BYTES = 2 * 1024 * 1024;
206
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
207
+
208
+ function withAbortSignal<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
209
+ if (signal.aborted) return Promise.reject(signal.reason);
210
+ const deferred = Promise.withResolvers<T>();
211
+ const abort = () => deferred.reject(signal.reason);
212
+ signal.addEventListener("abort", abort, { once: true });
213
+ operation.then(deferred.resolve, deferred.reject).finally(() => signal.removeEventListener("abort", abort));
214
+ return deferred.promise;
215
+ }
216
+
217
+ function normalizePeerAddress(address: string): string {
218
+ const normalized = address.toLowerCase();
219
+ return normalized.startsWith("::ffff:") ? normalized.slice(7) : normalized;
220
+ }
221
+
222
+ function normalizeUrlHostname(hostname: string): string {
223
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
224
+ }
225
+
226
+ async function validateCatalogUrl(rawUrl: string, signal: AbortSignal) {
227
+ const url = new URL(rawUrl);
228
+ const hostname = normalizeUrlHostname(url.hostname);
229
+ // URL.hostname retains IPv6 brackets in Node/Bun, while net.isIP expects the bare literal.
230
+ if (net.isIP(hostname) === 6) {
231
+ if (url.protocol !== "http:" && url.protocol !== "https:")
232
+ return { ok: false as const, reason: `unsupported scheme ${url.protocol}` };
233
+ if (url.username || url.password) return { ok: false as const, reason: "URL credentials are not allowed" };
234
+ if (isPrivateOrSpecialAddress(hostname))
235
+ return { ok: false as const, reason: "private, loopback, link-local, or reserved IP literal" };
236
+ return { ok: true as const, url, addresses: [hostname] };
237
+ }
238
+ return withAbortSignal(validatePublicHttpUrl(rawUrl), signal);
239
+ }
240
+
241
+ function openCatalogResponse(url: URL, addresses: string[], signal: AbortSignal): Promise<http.IncomingMessage> {
242
+ const hostname = normalizeUrlHostname(url.hostname);
243
+ const approved = addresses.map(address => ({ address, family: net.isIP(address) }));
244
+ const approvedPeers = new Set(approved.map(record => normalizePeerAddress(record.address)));
245
+ const lookup: net.LookupFunction = (requestedHostname, options, callback) => {
246
+ const requestedFamily = options.family === "IPv4" ? 4 : options.family === "IPv6" ? 6 : (options.family ?? 0);
247
+ const matching = approved.filter(record => requestedFamily === 0 || record.family === requestedFamily);
248
+ if (normalizeUrlHostname(requestedHostname) !== hostname || matching.length === 0) {
249
+ const error = Object.assign(new Error("No approved address for marketplace host"), { code: "ENOTFOUND" });
250
+ callback(error, options.all ? [] : "", 0);
251
+ return;
252
+ }
253
+ if (options.all) callback(null, matching);
254
+ else callback(null, matching[0].address, matching[0].family);
255
+ };
256
+ const deferred = Promise.withResolvers<http.IncomingMessage>();
257
+ const options: https.RequestOptions = {
258
+ protocol: url.protocol,
259
+ hostname,
260
+ port: url.port || undefined,
261
+ path: `${url.pathname}${url.search}`,
262
+ method: "GET",
263
+ headers: { Accept: "application/json", "Accept-Encoding": "identity", Connection: "close", Host: url.host },
264
+ agent: false,
265
+ insecureHTTPParser: false,
266
+ lookup,
267
+ maxHeaderSize: URL_FETCH_MAX_HEADER_BYTES,
268
+ signal,
269
+ ...(url.protocol === "https:"
270
+ ? { rejectUnauthorized: true, servername: net.isIP(hostname) === 0 ? hostname : undefined }
271
+ : {}),
272
+ };
273
+ const requestFn = url.protocol === "https:" ? https.request : http.request;
274
+ const request = requestFn(options, response => {
275
+ const peer = response.socket.remoteAddress;
276
+ if (!peer || isPrivateOrSpecialAddress(peer) || !approvedPeers.has(normalizePeerAddress(peer))) {
277
+ response.destroy();
278
+ deferred.reject(
279
+ new Error(`Refusing marketplace catalog response from unapproved connected peer ${peer ?? "unknown"}`),
280
+ );
281
+ return;
282
+ }
283
+ deferred.resolve(response);
284
+ });
285
+ request.once("error", deferred.reject);
286
+ const abort = () => {
287
+ request.destroy(signal.reason);
288
+ deferred.reject(signal.reason);
289
+ };
290
+ if (signal.aborted) abort();
291
+ else signal.addEventListener("abort", abort, { once: true });
292
+ request.once("close", () => signal.removeEventListener("abort", abort));
293
+ request.end();
294
+ return deferred.promise;
295
+ }
296
+
297
+ async function readCatalogResponse(
298
+ response: http.IncomingMessage,
299
+ source: string,
300
+ signal: AbortSignal,
301
+ ): Promise<string> {
302
+ const rawContentLengths: string[] = [];
303
+ let hasTransferEncoding = false;
304
+ for (let index = 0; index < response.rawHeaders.length; index += 2) {
305
+ const name = response.rawHeaders[index]?.toLowerCase();
306
+ if (name === "content-length") rawContentLengths.push(response.rawHeaders[index + 1] ?? "");
307
+ if (name === "transfer-encoding") hasTransferEncoding = true;
308
+ }
309
+ if (rawContentLengths.length > 1 || (rawContentLengths.length > 0 && hasTransferEncoding)) {
310
+ response.destroy();
311
+ throw new Error(`Marketplace catalog from ${source} has ambiguous response framing`);
312
+ }
313
+ const contentLength = response.headers["content-length"];
314
+ if (contentLength !== undefined) {
315
+ const declaredBytes =
316
+ typeof contentLength === "string" && /^\d+$/.test(contentLength) ? Number(contentLength) : NaN;
317
+ if (!Number.isSafeInteger(declaredBytes)) {
318
+ response.destroy();
319
+ throw new Error(`Marketplace catalog from ${source} has an invalid Content-Length`);
320
+ }
321
+ if (declaredBytes > URL_FETCH_MAX_BYTES) {
322
+ response.destroy();
323
+ throw new Error(`Marketplace catalog from ${source} exceeds the maximum size of 2 MiB`);
324
+ }
325
+ }
326
+
327
+ const abort = () => response.destroy(signal.reason);
328
+ if (signal.aborted) {
329
+ abort();
330
+ throw signal.reason;
331
+ }
332
+ signal.addEventListener("abort", abort, { once: true });
333
+ const chunks: Buffer[] = [];
334
+ let receivedBytes = 0;
335
+ try {
336
+ for await (const chunk of response) {
337
+ const bytes = Buffer.from(chunk);
338
+ receivedBytes += bytes.byteLength;
339
+ if (receivedBytes > URL_FETCH_MAX_BYTES) {
340
+ response.destroy();
341
+ throw new Error(`Marketplace catalog from ${source} exceeds the maximum size of 2 MiB`);
342
+ }
343
+ chunks.push(bytes);
344
+ }
345
+ return Buffer.concat(chunks, receivedBytes).toString("utf8");
346
+ } finally {
347
+ signal.removeEventListener("abort", abort);
348
+ }
349
+ }
350
+
351
+ async function fetchMarketplaceCatalogUrl(source: string): Promise<string> {
352
+ const deadline = AbortSignal.timeout(URL_FETCH_TIMEOUT_MS);
353
+ let currentUrl = source;
354
+ try {
355
+ for (let redirectCount = 0; ; redirectCount++) {
356
+ const guard = await validateCatalogUrl(currentUrl, deadline);
357
+ if (!guard.ok) {
358
+ throw new Error(`Refusing marketplace catalog URL: target URL is not public HTTP(S): ${guard.reason}`);
359
+ }
360
+ const response = await openCatalogResponse(guard.url, guard.addresses, deadline);
361
+ const status = response.statusCode ?? 0;
362
+ if (REDIRECT_STATUSES.has(status)) {
363
+ if (redirectCount >= URL_FETCH_MAX_REDIRECTS) {
364
+ response.destroy();
365
+ throw new Error(`Too many redirects fetching marketplace catalog from ${source}`);
366
+ }
367
+ const location = response.headers.location;
368
+ response.destroy();
369
+ if (!location)
370
+ throw new Error(`Marketplace catalog redirect from ${currentUrl} is missing a Location header`);
371
+ currentUrl = new URL(location, guard.url).toString();
372
+ continue;
373
+ }
374
+ if (status < 200 || status >= 300) {
375
+ response.destroy();
376
+ throw new Error(
377
+ `Failed to fetch marketplace catalog from ${currentUrl}: HTTP ${status} ${response.statusMessage ?? ""}`,
378
+ );
379
+ }
380
+ return await readCatalogResponse(response, currentUrl, deadline);
381
+ }
382
+ } catch (err) {
383
+ if (deadline.aborted) throw new Error(`Timed out fetching marketplace catalog from ${source}`);
384
+ throw err;
385
+ }
386
+ }
197
387
 
198
388
  /**
199
389
  * Expand a `~/...` path to an absolute path using os.homedir().
@@ -249,13 +439,7 @@ export async function fetchMarketplace(source: string, cacheDir: string): Promis
249
439
  }
250
440
 
251
441
  // type === "url"
252
- const response = await fetch(source, { signal: AbortSignal.timeout(60_000) });
253
- if (!response.ok) {
254
- throw new Error(
255
- `Failed to fetch marketplace catalog from ${source}: HTTP ${response.status} ${response.statusText}`,
256
- );
257
- }
258
- const text = await response.text();
442
+ const text = await fetchMarketplaceCatalogUrl(source);
259
443
  const catalog = parseMarketplaceCatalog(text, source);
260
444
 
261
445
  const catalogDir = path.join(cacheDir, catalog.name);
@@ -2,6 +2,10 @@ import * as fs from "node:fs/promises";
2
2
  import { DEFAULT_ULTRAGOAL_OBJECTIVE } from "./goal-mode-request";
3
3
  import { resolveGjcSessionForRead, SessionResolutionError } from "./session-resolution";
4
4
  import {
5
+ findCleanPauseCriticVerdict,
6
+ findLedgerReceiptEvent,
7
+ terminalCriticCeilingReached,
8
+ terminalCriticGateOverridden,
5
9
  validateDeferredMemberReceiptFresh,
6
10
  validateReceiptFreshBase,
7
11
  validateSupersededFinalAggregateReceipt,
@@ -32,6 +36,7 @@ export type UltragoalGuardState =
32
36
  | "active_dirty_quality_gate"
33
37
  | "active_review_blocked_unrecorded"
34
38
  | "active_review_blocked_recorded"
39
+ | "active_missing_critic_verdict"
35
40
  | "unreadable_fail_closed";
36
41
 
37
42
  export interface UltragoalGuardDiagnostic {
@@ -256,6 +261,31 @@ export function validateCompletionReceipt(input: {
256
261
  goalId: input.goal.id,
257
262
  };
258
263
  }
264
+ if (input.receiptKind === "final-aggregate") {
265
+ const checkpointEvent = findLedgerReceiptEvent(input.ledger, receipt);
266
+ if (checkpointEvent) {
267
+ const qualityGate =
268
+ typeof checkpointEvent.qualityGateJson === "object" &&
269
+ checkpointEvent.qualityGateJson !== null &&
270
+ !Array.isArray(checkpointEvent.qualityGateJson)
271
+ ? (checkpointEvent.qualityGateJson as Record<string, unknown>)
272
+ : undefined;
273
+ const criticReview =
274
+ qualityGate &&
275
+ typeof qualityGate.criticReview === "object" &&
276
+ qualityGate.criticReview !== null &&
277
+ !Array.isArray(qualityGate.criticReview)
278
+ ? (qualityGate.criticReview as Record<string, unknown>)
279
+ : undefined;
280
+ if (criticReview?.verdict !== "OKAY") {
281
+ return {
282
+ state: "active_missing_critic_verdict",
283
+ message: `Ultragoal ${input.goal.id} final aggregate receipt checkpoint requires criticReview with verdict OKAY.`,
284
+ goalId: input.goal.id,
285
+ };
286
+ }
287
+ }
288
+ }
259
289
  if (receipt.validationBatch?.role === "deferred-member") {
260
290
  return validateDeferredMemberReceiptFresh({
261
291
  plan: input.plan,
@@ -274,6 +304,15 @@ export function validateCompletionReceipt(input: {
274
304
  receiptKind: input.receiptKind,
275
305
  });
276
306
  if (baseDiagnostic) return baseDiagnostic;
307
+ if (input.receiptKind === "final-aggregate") {
308
+ if (terminalCriticCeilingReached(input.ledger) && !terminalCriticGateOverridden(input.ledger)) {
309
+ return {
310
+ state: "active_stale_receipt",
311
+ message: `Ultragoal ${input.goal.id} final aggregate receipt is stale because the terminal-critic ceiling is currently reached.`,
312
+ goalId: input.goal.id,
313
+ };
314
+ }
315
+ }
277
316
  if (receipt.validationBatch?.role === "batch-close") {
278
317
  for (const memberId of receipt.validationBatch.memberIds) {
279
318
  const member = input.plan.goals.find(goal => goal.id === memberId);
@@ -856,10 +895,10 @@ export interface UltragoalPauseBlockDiagnostic {
856
895
 
857
896
  /**
858
897
  * While an Ultragoal run is active, `goal({"op":"pause"})` is only allowed when the
859
- * current durable Ultragoal state is readable and the latest durable ledger event
860
- * classifies the current blocker as `human_blocked`. Resolvable blockers must be
861
- * worked, not parked. Reads fail closed so unreadable durable state or ledger data
862
- * blocks pause rather than silently allowing a give-up.
898
+ * current durable Ultragoal state is readable, the latest `blocker_classified`
899
+ * event is `human_blocked`, and a later fresh clean pause terminal critic verdict is bound to
900
+ * that exact classification. Reads fail closed so unreadable durable state or
901
+ * ledger data blocks pause rather than silently allowing a give-up.
863
902
  */
864
903
  export async function isUltragoalPauseBlocked(cwd: string): Promise<UltragoalPauseBlockDiagnostic> {
865
904
  if (!cwd) return { blocked: false, reason: "No cwd to resolve durable Ultragoal state." };
@@ -880,14 +919,53 @@ export async function isUltragoalPauseBlocked(cwd: string): Promise<UltragoalPau
880
919
  reason: `Unable to read durable Ultragoal ledger: ${error instanceof Error ? error.message : String(error)}`,
881
920
  };
882
921
  }
883
- const latest = ledger.at(-1);
884
- if (latest?.event === "blocker_classified" && latest.classification === "human_blocked") {
885
- return { blocked: false, reason: "Latest Ultragoal ledger event classifies the blocker as human_blocked." };
922
+ if (terminalCriticCeilingReached(ledger) && !terminalCriticGateOverridden(ledger)) {
923
+ return {
924
+ blocked: true,
925
+ reason:
926
+ "The Ultragoal run hit the terminal-critic ceiling; requires human/leader `gjc ultragoal record-critic-gate-override` before further terminal attempts.",
927
+ };
928
+ }
929
+
930
+ const classification = [...ledger].reverse().find(event => event.event === "blocker_classified");
931
+ if (classification?.classification !== "human_blocked") {
932
+ return {
933
+ blocked: true,
934
+ reason:
935
+ "An Ultragoal run is active. Pausing requires the latest blocker_classified event to be human_blocked, followed by a bound clean pause terminal critic verdict.",
936
+ };
937
+ }
938
+ if (typeof classification.eventId !== "string" || !classification.eventId.trim()) {
939
+ return {
940
+ blocked: true,
941
+ reason:
942
+ "Pausing requires a later fresh clean pause terminal critic OKAY verdict bound to the latest human_blocked blocker_classified event; a REJECT/ITERATE/stale/missing verdict blocks the pause and the run must keep executing.",
943
+ };
944
+ }
945
+ let plan: UltragoalPlan | null;
946
+ try {
947
+ plan = await readUltragoalPlan(cwd);
948
+ } catch (error) {
949
+ return {
950
+ blocked: true,
951
+ reason: `Unable to read durable Ultragoal plan for pause critic verdict: ${error instanceof Error ? error.message : String(error)}`,
952
+ };
953
+ }
954
+ if (!plan) {
955
+ return { blocked: true, reason: "Unable to read durable Ultragoal plan for pause critic verdict." };
956
+ }
957
+ const criticVerdict = findCleanPauseCriticVerdict(plan, ledger, classification.eventId);
958
+ if (!criticVerdict) {
959
+ return {
960
+ blocked: true,
961
+ reason:
962
+ "Pausing requires a later fresh clean pause terminal critic OKAY verdict bound to the latest human_blocked blocker_classified event; a REJECT/ITERATE/stale/missing verdict blocks the pause and the run must keep executing.",
963
+ };
886
964
  }
887
965
  return {
888
- blocked: true,
966
+ blocked: false,
889
967
  reason:
890
- "An Ultragoal run is active. Pausing requires the current blocker to be classified human_blocked as the latest ledger event.",
968
+ "Latest blocker_classified event is human_blocked with a later fresh clean bound pause terminal critic verdict.",
891
969
  };
892
970
  }
893
971
 
@@ -902,7 +980,7 @@ export async function assertUltragoalPauseAllowed(cwd: string): Promise<void> {
902
980
  [
903
981
  diagnostic.reason,
904
982
  "Resolvable blockers must be worked, not paused: investigate, `gjc ultragoal steer --kind add_subgoal`, delegate an executor, or `gjc ultragoal record-review-blockers`.",
905
- 'If the blocker is genuinely human-only, record `gjc ultragoal classify-blocker --classification human_blocked --evidence "<human-only dependency>"` immediately before pausing.',
983
+ 'If the blocker is genuinely human-only, record `gjc ultragoal classify-blocker --classification human_blocked --evidence "<human-only dependency>"`, then record a clean bound `gjc ultragoal record-critic-verdict --terminus pause --classification-event-id <eventId> --verdict OKAY --evidence "<critic evidence>"` before pausing.',
906
984
  ].join("\n"),
907
985
  );
908
986
  }
@@ -8,6 +8,12 @@ import type {
8
8
  UltragoalReceiptKind,
9
9
  } from "./ultragoal-runtime";
10
10
 
11
+ export const CRITIC_VERDICT_EVENT = "critic_verdict";
12
+ export const CRITIC_GATE_HARD_STOP_EVENT = "critic_gate_hard_stop";
13
+ export const CRITIC_GATE_OVERRIDE_EVENT = "critic_gate_override";
14
+ export const TERMINAL_CRITIC_CEILING = 5;
15
+ export type CriticVerdict = "OKAY" | "ITERATE" | "REJECT";
16
+
11
17
  export type UltragoalReceiptFreshnessDiagnostic = {
12
18
  state:
13
19
  | "inactive"
@@ -46,6 +52,103 @@ export function requiredUltragoalGoals(plan: UltragoalPlan): UltragoalGoal[] {
46
52
  return plan.goals.filter(goal => goal.status !== "superseded");
47
53
  }
48
54
 
55
+ /** Hash the current required-goal set for terminal critic verdict freshness. */
56
+ export function computeCriticVerdictPlanGeneration(plan: UltragoalPlan): string {
57
+ return hashStructuredValue(
58
+ requiredUltragoalGoals(plan).map(goal => ({
59
+ id: goal.id,
60
+ status: goal.status,
61
+ updatedAt: goal.updatedAt,
62
+ })),
63
+ );
64
+ }
65
+
66
+ export function isCleanPauseCriticVerdictShape(
67
+ candidate: UltragoalLedgerEvent,
68
+ planGeneration: string,
69
+ classificationEventId: string,
70
+ ): boolean {
71
+ return (
72
+ candidate.event === CRITIC_VERDICT_EVENT &&
73
+ candidate.terminus === "pause" &&
74
+ candidate.verdict === "OKAY" &&
75
+ typeof candidate.evidence === "string" &&
76
+ candidate.evidence.trim().length > 0 &&
77
+ Array.isArray(candidate.blockers) &&
78
+ candidate.blockers.length === 0 &&
79
+ candidate.planGeneration === planGeneration &&
80
+ candidate.classificationEventId === classificationEventId
81
+ );
82
+ }
83
+
84
+ export function isCleanPauseCriticVerdict(
85
+ event: UltragoalLedgerEvent,
86
+ opts: { planGeneration: string; classificationEventId: string },
87
+ ): boolean {
88
+ return isCleanPauseCriticVerdictShape(event, opts.planGeneration, opts.classificationEventId);
89
+ }
90
+
91
+ export function findCleanPauseCriticVerdict(
92
+ plan: UltragoalPlan,
93
+ ledger: readonly UltragoalLedgerEvent[],
94
+ classificationEventId: string,
95
+ ): UltragoalLedgerEvent | null {
96
+ const classificationIndex = ledger.findIndex(event => event.eventId === classificationEventId);
97
+ if (classificationIndex < 0) return null;
98
+ const planGeneration = computeCriticVerdictPlanGeneration(plan);
99
+ for (let index = ledger.length - 1; index > classificationIndex; index--) {
100
+ const event = ledger[index];
101
+ if (
102
+ event.event !== CRITIC_VERDICT_EVENT ||
103
+ event.terminus !== "pause" ||
104
+ event.planGeneration !== planGeneration ||
105
+ event.classificationEventId !== classificationEventId
106
+ )
107
+ continue;
108
+ return isCleanPauseCriticVerdict(event, { planGeneration, classificationEventId }) ? event : null;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ /** Pure: count all ledger `critic_verdict` rows for an exact plan generation. */
114
+ export function countTerminalCriticVerdicts(ledger: readonly UltragoalLedgerEvent[], planGeneration: string): number {
115
+ return ledger.filter(event => event.event === CRITIC_VERDICT_EVENT && event.planGeneration === planGeneration)
116
+ .length;
117
+ }
118
+ /** Pure: count every non-OKAY terminal critic verdict recorded for this run. */
119
+ export function countNonOkayTerminalCriticVerdicts(
120
+ ledger: readonly UltragoalLedgerEvent[],
121
+ _legacyPlanGeneration?: string,
122
+ ): number {
123
+ return ledger.filter(event => event.event === CRITIC_VERDICT_EVENT && event.verdict !== "OKAY").length;
124
+ }
125
+
126
+ export function terminalCriticHardStopReached(
127
+ ledger: readonly UltragoalLedgerEvent[],
128
+ _legacyPlanGeneration?: string,
129
+ ): boolean {
130
+ return ledger.some(event => event.event === CRITIC_GATE_HARD_STOP_EVENT);
131
+ }
132
+
133
+ export function terminalCriticGateOverridden(ledger: readonly UltragoalLedgerEvent[]): boolean {
134
+ let overrideAfterLatestHardStop = false;
135
+ for (let index = ledger.length - 1; index >= 0; index--) {
136
+ const event = ledger[index];
137
+ if (event.event === CRITIC_GATE_OVERRIDE_EVENT) overrideAfterLatestHardStop = true;
138
+ if (event.event === CRITIC_GATE_HARD_STOP_EVENT) return overrideAfterLatestHardStop;
139
+ }
140
+ return false;
141
+ }
142
+
143
+ export function terminalCriticCeilingReached(
144
+ ledger: readonly UltragoalLedgerEvent[],
145
+ _legacyPlanGeneration?: string,
146
+ ): boolean {
147
+ return (
148
+ countNonOkayTerminalCriticVerdicts(ledger) >= TERMINAL_CRITIC_CEILING || terminalCriticHardStopReached(ledger)
149
+ );
150
+ }
151
+
49
152
  export function receiptRelevantGoals(
50
153
  plan: UltragoalPlan,
51
154
  goal: UltragoalGoal,