@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.
- package/CHANGELOG.md +37 -0
- package/dist/types/capability/index.d.ts +10 -20
- package/dist/types/capability/types.d.ts +3 -0
- package/dist/types/commands/daemon.d.ts +2 -2
- package/dist/types/commands/sdk.d.ts +20 -2
- package/dist/types/config/keybindings.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +56 -6
- package/dist/types/eval/py/runner-artifact.d.ts +8 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +5 -5
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +19 -5
- package/dist/types/main.d.ts +24 -1
- package/dist/types/modes/components/btw-panel.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-status-header.d.ts +1 -0
- package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
- package/dist/types/sdk/bus/index.d.ts +1 -1
- package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
- package/dist/types/sdk/bus/notification-service.d.ts +34 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -3
- package/dist/types/sdk/bus/telegram-daemon.d.ts +61 -3
- package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
- package/dist/types/sdk/client/discovery.d.ts +2 -0
- package/dist/types/sdk/client/liveness.d.ts +7 -0
- package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
- package/dist/types/sdk/transport/index.d.ts +12 -0
- package/dist/types/sdk/transport/relay.d.ts +43 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
- package/dist/types/sdk/transport/socket.d.ts +7 -0
- package/dist/types/sdk/transport/stdio.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +28 -5
- package/dist/types/session/btw-contract.d.ts +16 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +21 -7
- package/dist/types/session/internal/managed-session-storage.d.ts +10 -8
- package/dist/types/session/session-manager.d.ts +25 -4
- package/dist/types/setup/credential-import.d.ts +5 -1
- package/dist/types/setup/provider-onboarding.d.ts +2 -0
- package/dist/types/ssh/utils.d.ts +1 -0
- package/dist/types/task/discovery.d.ts +3 -1
- package/dist/types/tools/fetch.d.ts +5 -8
- package/dist/types/tools/image-gen.d.ts +16 -0
- package/dist/types/tools/skill-discovery.d.ts +7 -0
- package/dist/types/tools/sqlite-reader.d.ts +7 -0
- package/dist/types/utils/shell-snapshot.d.ts +12 -0
- package/dist/types/web/insane/bridge.d.ts +1 -0
- package/dist/types/web/insane/url-guard.d.ts +17 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/src/capability/index.ts +50 -42
- package/src/capability/types.ts +3 -0
- package/src/cli/setup-cli.ts +10 -4
- package/src/cli.ts +34 -9
- package/src/commands/daemon.ts +22 -5
- package/src/commands/sdk.ts +42 -9
- package/src/config/keybindings.ts +1 -1
- package/src/config/model-profiles.ts +18 -0
- package/src/config/settings-schema.ts +57 -6
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
- package/src/discovery/ssh.ts +12 -1
- package/src/eval/py/kernel.ts +1 -21
- package/src/eval/py/runner-artifact.ts +84 -0
- package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
- package/src/gjc-runtime/ultragoal-guard.ts +88 -10
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
- package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
- package/src/gjc-runtime/workflow-manifest.ts +23 -0
- package/src/internal-urls/docs-index.generated.ts +8 -7
- package/src/internal-urls/local-protocol.ts +32 -6
- package/src/main.ts +86 -27
- package/src/modes/action-registry.ts +1 -1
- package/src/modes/components/btw-panel.ts +82 -16
- package/src/modes/components/model-selector.ts +31 -3
- package/src/modes/components/tool-status-header.ts +6 -1
- package/src/modes/controllers/btw-controller.ts +119 -36
- package/src/modes/controllers/input-controller.ts +31 -0
- package/src/modes/controllers/selector-controller.ts +106 -12
- package/src/modes/interactive-mode.ts +16 -7
- package/src/modes/types.ts +1 -0
- package/src/prompts/system/btw-user.md +3 -8
- package/src/prompts/tools/skill-discovery.md +1 -0
- package/src/runtime-mcp/client.ts +41 -55
- package/src/runtime-mcp/content-limits.ts +71 -0
- package/src/runtime-mcp/json-rpc.ts +23 -9
- package/src/runtime-mcp/transports/http.ts +50 -16
- package/src/sdk/bus/chat-daemon-control.ts +4 -3
- package/src/sdk/bus/index.ts +4 -5
- package/src/sdk/bus/lifecycle-commands.ts +16 -7
- package/src/sdk/bus/notification-service.ts +41 -18
- package/src/sdk/bus/telegram-daemon-contract.ts +8 -3
- package/src/sdk/bus/telegram-daemon-control.ts +2 -2
- package/src/sdk/bus/telegram-daemon.ts +1663 -375
- package/src/sdk/bus/topic-registry.ts +451 -12
- package/src/sdk/client/discovery.ts +19 -3
- package/src/sdk/client/liveness.ts +49 -0
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/session.ts +29 -5
- package/src/sdk/transport/auth-preface.ts +82 -0
- package/src/sdk/transport/index.ts +18 -0
- package/src/sdk/transport/relay.ts +252 -0
- package/src/sdk/transport/serve-cli.ts +105 -0
- package/src/sdk/transport/socket.ts +197 -0
- package/src/sdk/transport/stdio.ts +17 -0
- package/src/session/agent-session.ts +240 -149
- package/src/session/blob-store.ts +19 -6
- package/src/session/btw-contract.ts +59 -0
- package/src/session/internal/managed-session-scope.ts +313 -57
- package/src/session/internal/managed-session-storage.ts +146 -35
- package/src/session/session-manager.ts +319 -85
- package/src/setup/credential-import.ts +28 -3
- package/src/setup/provider-onboarding.ts +30 -1
- package/src/setup/provider-presets.json +2 -1
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/ssh/utils.ts +14 -0
- package/src/task/discovery.ts +8 -2
- package/src/tools/ask.ts +1 -1
- package/src/tools/fetch.ts +13 -131
- package/src/tools/image-gen.ts +301 -20
- package/src/tools/read.ts +2 -0
- package/src/tools/skill-discovery.ts +39 -3
- package/src/tools/sqlite-reader.ts +63 -5
- package/src/utils/pasted-image-path.ts +15 -6
- package/src/utils/shell-snapshot.ts +173 -63
- package/src/web/insane/bridge.ts +5 -1
- package/src/web/insane/url-guard.ts +138 -27
- package/src/web/scrapers/types.ts +19 -49
- 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
|
|
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
|
|
860
|
-
*
|
|
861
|
-
*
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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:
|
|
966
|
+
blocked: false,
|
|
889
967
|
reason:
|
|
890
|
-
"
|
|
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>"`
|
|
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,
|