@awak-app/simy-cli 0.5.71 → 0.5.73

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 (52) hide show
  1. package/README.md +101 -3
  2. package/package.json +2 -2
  3. package/src/agent.js +205 -12
  4. package/src/backend-executable.js +45 -2
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/README.md +22 -0
  7. package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
  8. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +180 -31
  9. package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
  10. package/src/coding-run-recovery.js +88 -0
  11. package/src/coding-run-store.js +2 -2
  12. package/src/continuation-policy.js +2 -0
  13. package/src/delivery-hooks/command.js +11 -5
  14. package/src/delivery-hooks/desktop.js +12 -3
  15. package/src/delivery-hooks/supervisor.js +17 -12
  16. package/src/desktop-host.js +19 -8
  17. package/src/desktop-sqm-settings.js +24 -7
  18. package/src/device-signing-identity.js +65 -0
  19. package/src/diagnostic-log.js +68 -0
  20. package/src/doctor.js +55 -0
  21. package/src/index.js +18 -1
  22. package/src/local-task.js +68 -3
  23. package/src/orchestrator/budget.js +55 -4
  24. package/src/orchestrator/charter-validation.js +33 -0
  25. package/src/orchestrator/completion-contract.js +1 -1
  26. package/src/orchestrator/contract.js +2 -0
  27. package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
  28. package/src/orchestrator/evidence.js +20 -19
  29. package/src/orchestrator/independent-audit.js +9 -0
  30. package/src/orchestrator/instruction.js +6 -1
  31. package/src/orchestrator/loop.js +66 -41
  32. package/src/orchestrator/outcome-monitor.js +95 -44
  33. package/src/orchestrator/outcome-progress.js +2 -0
  34. package/src/orchestrator/recovery.js +3 -0
  35. package/src/purpose-local-proof.js +380 -0
  36. package/src/runner.js +227 -19
  37. package/src/runtime-diagnostics.js +77 -0
  38. package/src/session-supervision/codex-app-server.js +3 -2
  39. package/src/session-supervision/index.js +16 -4
  40. package/src/sqm/bundle-store.js +14 -7
  41. package/src/sqm/claude-plugin.js +2 -0
  42. package/src/sqm/command.js +42 -4
  43. package/src/sqm/diagnostics.js +23 -0
  44. package/src/sqm/evidence-client.js +24 -7
  45. package/src/sqm/history-store.js +21 -5
  46. package/src/sqm/index.js +116 -17
  47. package/src/sqm/proof.js +4 -23
  48. package/src/sqm/report.js +9 -2
  49. package/src/sqm/request-recovery.js +82 -0
  50. package/src/sqm/review-worker.js +97 -15
  51. package/src/sqm/session.js +33 -3
  52. package/src/startup-preflight.js +44 -0
@@ -0,0 +1,65 @@
1
+ import { constants } from "node:fs";
2
+ import { mkdir, open, lstat } from "node:fs/promises";
3
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
4
+ import path from "node:path";
5
+
6
+ // Shared SQM/Purpose identity. Inspection never creates or repairs a key.
7
+ export async function loadDeviceSigningIdentity(location, { create = false } = {}) {
8
+ const parent = path.dirname(location);
9
+ if (create) await mkdir(parent, { recursive: true, mode: 0o700 });
10
+ try {
11
+ const directory = await lstat(parent);
12
+ if (!directory.isDirectory() || directory.isSymbolicLink() || (process.platform !== "win32" && (directory.mode & 0o022) !== 0)) throw new Error("Unsafe signing key directory.");
13
+ const details = await lstat(location);
14
+ assertPrivateFile(details);
15
+ const handle = await open(location, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
16
+ try {
17
+ const opened = await handle.stat();
18
+ assertPrivateFile(opened);
19
+ if (opened.ino !== details.ino || opened.dev !== details.dev || opened.size > 4096) throw new Error("Signing key changed while opening.");
20
+ const raw = await handle.readFile("utf8");
21
+ const after = await handle.stat();
22
+ if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) throw new Error("Signing key changed while reading.");
23
+ const parsed = JSON.parse(raw);
24
+ const privateKey = createPrivateKey({ key: decode(parsed.private_key), format: "der", type: "pkcs8" });
25
+ const publicKeyDer = createPublicKey(privateKey).export({ format: "der", type: "spki" });
26
+ const keyId = `sha256:${createHash("sha256").update(publicKeyDer).digest("hex")}`;
27
+ if (privateKey.asymmetricKeyType !== "ed25519" || parsed.algorithm !== "ed25519" ||
28
+ parsed.key_id !== keyId || !decode(parsed.public_key).equals(publicKeyDer)) throw new Error("Signing key pair does not match.");
29
+ return { privateKey, publicKeyDer, keyId };
30
+ } finally { await handle.close(); }
31
+ } catch (error) {
32
+ if (error.code !== "ENOENT") throw error;
33
+ if (!create) return null;
34
+ }
35
+ const pair = generateKeyPairSync("ed25519");
36
+ const publicKeyDer = pair.publicKey.export({ format: "der", type: "spki" });
37
+ const keyId = `sha256:${createHash("sha256").update(publicKeyDer).digest("hex")}`;
38
+ let handle;
39
+ try {
40
+ handle = await open(location, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
41
+ } catch (error) {
42
+ if (error.code === "EEXIST") return loadDeviceSigningIdentity(location);
43
+ throw error;
44
+ }
45
+ try {
46
+ await handle.writeFile(`${JSON.stringify({ algorithm: "ed25519", key_id: keyId,
47
+ private_key: pair.privateKey.export({ format: "der", type: "pkcs8" }).toString("base64"),
48
+ public_key: publicKeyDer.toString("base64"), created_at: new Date().toISOString() })}\n`);
49
+ await handle.sync();
50
+ } finally { await handle.close(); }
51
+ return { privateKey: pair.privateKey, publicKeyDer, keyId };
52
+ }
53
+
54
+ function assertPrivateFile(details) {
55
+ if (!details.isFile() || details.isSymbolicLink() || details.nlink !== 1 ||
56
+ (process.platform !== "win32" && ((details.mode & 0o077) !== 0 ||
57
+ (typeof process.getuid === "function" && details.uid !== process.getuid())))) throw new Error("Signing key must be a private regular file.");
58
+ }
59
+
60
+ function decode(value) {
61
+ if (typeof value !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) throw new Error("Invalid signing key encoding.");
62
+ const bytes = Buffer.from(value, "base64");
63
+ if (bytes.toString("base64") !== value) throw new Error("Invalid signing key encoding.");
64
+ return bytes;
65
+ }
@@ -0,0 +1,68 @@
1
+ import { randomUUID, createHash } from "node:crypto";
2
+ import { mkdir, appendFile, rename, writeFile, readdir, readFile, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { defaultSessionRoot } from "./session-store.js";
5
+
6
+ const writers = new Map();
7
+ const scopeDir = (root, scope) => join(root, "diagnostics", createHash("sha256").update(JSON.stringify([
8
+ scope?.webOrigin, scope?.accountId, scope?.organizationId,
9
+ ])).digest("hex"));
10
+ const safeIdentifier = value => typeof value === "string" && /^[a-z][a-z0-9_.-]{0,79}$/i.test(value) ? value : null;
11
+
12
+ // Only structured diagnostics are durable. Never retain prompts, arbitrary error
13
+ // messages, tokens, source paths, or provider stdout/stderr.
14
+ export function safeDiagnosticEvent(value, now = () => Date.now()) {
15
+ return { at: Number.isFinite(Date.parse(value?.at)) ? value.at : new Date(now()).toISOString(),
16
+ kind: ["analysis", "check", "preflight"].includes(value?.kind) ? value.kind : "check",
17
+ stage: safeIdentifier(value?.stage) || "unknown",
18
+ code: safeIdentifier(value?.code) || "failure_unclassified",
19
+ provider: ["codex", "claude"].includes(value?.provider) ? value.provider : null,
20
+ http_status: Number.isInteger(value?.http_status) && value.http_status >= 400 && value.http_status <= 599 ? value.http_status : null };
21
+ }
22
+
23
+ export function diagnosticLogger({ root = defaultSessionRoot(), scope, maxBytes = 64 * 1024, segments = 4, now } = {}) {
24
+ if (!scope?.webOrigin || !scope?.accountId || !scope?.organizationId) return { record: async () => false };
25
+ const dir = scopeDir(root, scope), cacheKey = `${dir}:${maxBytes}:${segments}`;
26
+ if (writers.has(cacheKey)) return writers.get(cacheKey);
27
+ const id = randomUUID();
28
+ let queue = Promise.resolve(), total = 0, bytes = 0, segment = 0, firstAt = null;
29
+ const writer = { record(value) {
30
+ const task = queue.catch(() => {}).then(async () => {
31
+ const event = safeDiagnosticEvent(value, now), line = `${JSON.stringify(event)}\n`;
32
+ await mkdir(dir, { recursive: true, mode: 0o700 });
33
+ if (bytes + Buffer.byteLength(line) > maxBytes && bytes > 0) {
34
+ segment += 1; bytes = 0;
35
+ if (segment >= segments) await rm(join(dir, `${id}.${segment - segments}.jsonl`), { force: true });
36
+ }
37
+ await appendFile(join(dir, `${id}.${segment}.jsonl`), line, { mode: 0o600 });
38
+ bytes += Buffer.byteLength(line); total += 1; firstAt ||= event.at;
39
+ const meta = join(dir, `${id}.json`), temp = `${meta}.tmp`;
40
+ await writeFile(temp, JSON.stringify({ schema_version: 1, total_failures: total, first_failure_at: firstAt, last_failure_at: event.at }), { mode: 0o600 });
41
+ await rename(temp, meta);
42
+ return true;
43
+ });
44
+ queue = task; return task;
45
+ } };
46
+ writers.set(cacheKey, writer); return writer;
47
+ }
48
+
49
+ export async function readDiagnosticLog({ root = defaultSessionRoot(), scope, limit = 20 } = {}) {
50
+ if (!scope?.webOrigin || !scope?.accountId || !scope?.organizationId) return { total_failures: 0, first_failure_at: null, recent: [] };
51
+ const dir = scopeDir(root, scope);
52
+ let names;
53
+ try { names = await readdir(dir); } catch (error) { if (error.code === "ENOENT") return { total_failures: 0, first_failure_at: null, recent: [] }; throw error; }
54
+ let total = 0, first = null; const events = [];
55
+ for (const name of names.filter(name => /^[a-f0-9-]{36}(?:\.\d+\.jsonl|\.json)$/.test(name))) {
56
+ const content = await readFile(join(dir, name), "utf8").catch(() => "");
57
+ if (name.endsWith(".json")) {
58
+ try { const meta = JSON.parse(content); if (Number.isSafeInteger(meta.total_failures) && meta.total_failures >= 0) total += meta.total_failures;
59
+ if (Number.isFinite(Date.parse(meta.first_failure_at)) && (!first || meta.first_failure_at < first)) first = meta.first_failure_at; } catch {}
60
+ } else {
61
+ for (const line of content.slice(0, 128 * 1024).split("\n")) {
62
+ try { const event = JSON.parse(line); if (Number.isFinite(Date.parse(event.at))) events.push(safeDiagnosticEvent(event)); } catch {}
63
+ }
64
+ }
65
+ }
66
+ return { total_failures: total, first_failure_at: first,
67
+ recent: events.sort((a, b) => b.at.localeCompare(a.at)).slice(0, Math.max(0, Math.min(100, limit))) };
68
+ }
package/src/doctor.js ADDED
@@ -0,0 +1,55 @@
1
+ import { purposeSigningKeyPath, purposeVerificationCapability } from "./purpose-local-proof.js";
2
+ import { readFile } from "node:fs/promises";
3
+ import { connectionDiscoveryPath } from "./connection-discovery.js";
4
+ import { readSession, isSessionValid, defaultSessionRoot } from "./session-store.js";
5
+ import { inspectProviderStatus } from "./desktop-status.js";
6
+ import { readDiagnosticLog } from "./diagnostic-log.js";
7
+
8
+ export async function collectDiagnostics({ webOrigin, cliVersion, root = defaultSessionRoot(),
9
+ readSessionImpl = readSession, inspectProvider = inspectProviderStatus, fetchImpl = fetch,
10
+ readFileImpl = readFile, alive = pid => { try { process.kill(pid, 0); return true; } catch { return false; } }, now = Date.now() } = {}) {
11
+ const session = await readSessionImpl(webOrigin, root).catch(() => null);
12
+ const valid = isSessionValid(session, now, webOrigin);
13
+ let daemon = { state: "not_found", cli_version: null, version_matches: null };
14
+ try {
15
+ const record = JSON.parse(await readFileImpl(connectionDiscoveryPath(webOrigin, root), "utf8"));
16
+ if (record.web_origin !== webOrigin || !Number.isInteger(record.port) || record.port < 1 || record.port > 65535 ||
17
+ !Number.isInteger(record.pid) || record.pid <= 0) throw new Error("Invalid discovery");
18
+ if (!alive(record.pid)) daemon.state = "stale_record";
19
+ else {
20
+ const response = await fetchImpl(`http://127.0.0.1:${record.port}/v1/health`, {
21
+ headers: { Origin: webOrigin }, signal: AbortSignal.timeout(1500), redirect: "error" });
22
+ const health = response.ok ? await response.json() : null;
23
+ if (health?.web_origin !== webOrigin || health?.ok !== true) throw new Error("Unverified daemon");
24
+ const version = typeof health.cli_version === "string" && /^\d+\.\d+\.\d+$/.test(health.cli_version) ? health.cli_version : null;
25
+ daemon = { state: "reachable", cli_version: version, version_matches: version ? version === cliVersion : null,
26
+ background: health.daemon === true };
27
+ }
28
+ } catch (error) { if (error.code !== "ENOENT") daemon.state = "unverified"; }
29
+ const providers = Object.fromEntries(await Promise.all(["codex", "claude"].map(async provider => {
30
+ const result = await inspectProvider(provider).catch(() => ({ state: "unknown" }));
31
+ return [provider, { state: ["ready", "signed_out", "missing", "outdated", "unknown"].includes(result?.state) ? result.state : "unknown",
32
+ version: typeof result?.version === "string" && /^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/i.test(result.version) ? result.version : null,
33
+ model_access: "unknown" }];
34
+ })));
35
+ const scope = session?.account_id && session?.organization_id ? { webOrigin, accountId: session.account_id, organizationId: session.organization_id } : null;
36
+ const failures = await readDiagnosticLog({ root, scope }).catch(() => ({ status: "unavailable", recent: [] }));
37
+ return { schema_version: 1, cli_version: cliVersion, web_origin: webOrigin, daemon,
38
+ session: { state: valid ? "signed_in" : session ? "expired" : "signed_out",
39
+ expires_at: Number.isFinite(Date.parse(session?.expires_at)) ? session.expires_at : null },
40
+ providers, failures,
41
+ purpose_verification: await purposeVerificationCapability(purposeSigningKeyPath(root)).then(value => ({ state: value ? "available" : "not_initialized", key_id: value?.key_id || null, execution: "explicit_frozen_recipes_only" })).catch(() => ({ state: "unavailable", key_id: null, execution: "explicit_frozen_recipes_only" })),
42
+ actions: [
43
+ ...(!valid ? [{ id: "reconnect_cli", command: "simy" }] : []),
44
+ ...(["stale_record", "not_found", "unverified"].includes(daemon.state) ? [{ id: "inspect_daemon", command: "simy doctor" }] : []),
45
+ ...(daemon.version_matches === false ? [{ id: "update_when_idle", command: "simy update --check" }] : []),
46
+ ...Object.entries(providers).filter(([, provider]) => provider.state === "signed_out")
47
+ .map(([provider]) => ({ id: `login_${provider}`, command: provider === "codex" ? "codex login" : "claude auth login" })),
48
+ ] };
49
+ }
50
+
51
+ export async function runDoctor(options) {
52
+ const diagnostics = await collectDiagnostics(options);
53
+ console.log(JSON.stringify(diagnostics, null, 2));
54
+ return 0;
55
+ }
package/src/index.js CHANGED
@@ -7,6 +7,7 @@ import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
8
 
9
9
  import { startAgent } from "./agent.js";
10
+ import { createRuntimeDiagnostics, captureRuntimeOutput } from "./runtime-diagnostics.js";
10
11
  import {
11
12
  autoUpdateHelpText,
12
13
  createCliAutoUpdater,
@@ -34,8 +35,16 @@ const entryPath = fileURLToPath(import.meta.url);
34
35
  const packageInfo = await readCliPackageInfo();
35
36
  const updatesDisabled = args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0";
36
37
  let configuredWebOrigin;
38
+ const versionOnly = dispatchArgv.length === 1 && ["--version", "-v"].includes(dispatchArgv[0]);
39
+ if (versionOnly) {
40
+ console.log(packageInfo.version);
41
+ } else if (process.env.SIMY_DAEMON_CHILD === "1" && (args.has("--daemon") || args.has("--deamon"))) {
42
+ const diagnostics = createRuntimeDiagnostics(process.env.SIMY_HOME?.trim() || join(homedir(), ".simy"));
43
+ captureRuntimeOutput(diagnostics);
44
+ diagnostics.write(`daemon starting version=${packageInfo.version} pid=${process.pid}`);
45
+ }
37
46
  try {
38
- configuredWebOrigin = resolveWebOrigin(readOption(argv, "--host"));
47
+ if (!versionOnly) configuredWebOrigin = resolveWebOrigin(readOption(argv, "--host"));
39
48
  } catch (error) {
40
49
  console.error(error instanceof Error ? error.message : String(error));
41
50
  process.exitCode = 2;
@@ -43,6 +52,11 @@ try {
43
52
 
44
53
  if (!configuredWebOrigin) {
45
54
  // Invalid host input is reported above before update discovery or startup.
55
+ } else if (args.has("--version") || args.has("-v")) {
56
+ console.log(packageInfo.version);
57
+ } else if (dispatchArgv[0] === "doctor") {
58
+ const { runDoctor } = await import("./doctor.js");
59
+ process.exitCode = await runDoctor({ webOrigin: configuredWebOrigin, cliVersion: packageInfo.version });
46
60
  } else if (dispatchArgv[0] === "update") {
47
61
  const checkOnly = args.has("--check");
48
62
  const result = await runCliUpdateGate({
@@ -111,6 +125,8 @@ async function runCommand(installMode) {
111
125
  if (args.has("--help") || args.has("-h")) {
112
126
  console.log(`Usage:
113
127
  simy Start the local SIMY agent
128
+ simy --version Print the installed version without starting or updating
129
+ simy doctor Read-only version, connection, provider and failure diagnostics
114
130
  simy --daemon Start it in the background
115
131
  simy --no-tui Start the foreground HTTP agent without the interactive console
116
132
  simy sqm min-check Run the fast deterministic SQM diff preflight
@@ -121,6 +137,7 @@ async function runCommand(installMode) {
121
137
  simy update ${installMode === "macos_installer" ? "Show Apple Silicon update status and installer action" : "Install an eligible stable CLI update"}
122
138
 
123
139
  Options:
140
+ --version, -v Print the installed CLI version and exit
124
141
  --daemon, --deamon Run detached in the background
125
142
  --no-tui Disable the interactive Agentic Loop console
126
143
  --no-open Do not open the browser when authorization is required
package/src/local-task.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { normalizePurposeVerification } from "./purpose-local-proof.js";
1
2
  import { buildWorkflowReportingInstruction } from "./workflow-reporting.js";
2
3
  import { execFile } from "node:child_process";
3
4
  import { createHash, randomUUID } from "node:crypto";
@@ -20,6 +21,7 @@ import { promisify } from "node:util";
20
21
 
21
22
  import { resolveDesktopExecutorCommand } from "./desktop-executor.js";
22
23
  import { providerBudgetTokens } from "./orchestrator/budget.js";
24
+ import { continuationMode } from "./continuation-policy.js";
23
25
  import {
24
26
  buildLocalTaskArtifactRefs,
25
27
  MAX_LOCAL_TASK_ARTIFACT_REFS,
@@ -257,15 +259,21 @@ export function createLocalTask({
257
259
  expectedOutputs = [],
258
260
  scenarioPack = null,
259
261
  providerTokenBudget = DEFAULT_LOCAL_TASK_TOKEN_BUDGET,
262
+ continuationPolicy = "bounded",
263
+ providerTokenCeiling = MAX_LOCAL_TASK_TOKEN_BUDGET,
260
264
  timeoutMs = DEFAULT_LOCAL_TASK_TIMEOUT_MS,
261
265
  idempotencyKeyHash = null,
262
266
  requestFingerprint = null,
263
267
  workflowObservationBinding = null,
264
268
  workflowStartIntent = null,
269
+ purposeVerification = null,
265
270
  }) {
266
271
  validateTaskId(id);
267
272
  const now = new Date().toISOString();
268
273
  const tokenBudget = normalizeTokenBudget(providerTokenBudget);
274
+ const policy = continuationMode(continuationPolicy);
275
+ const tokenCeiling = normalizeTokenBudget(providerTokenCeiling);
276
+ if (policy === "progress" && tokenCeiling < tokenBudget) throw new Error("The hard Provider token ceiling cannot be below the notification threshold.");
269
277
  const normalizedTimeout = normalizeTimeoutMs(timeoutMs);
270
278
  const normalizedScenarioPack = normalizeLocalTaskScenarioPack(scenarioPack);
271
279
  const scenarioExpectedOutputs =
@@ -284,6 +292,7 @@ export function createLocalTask({
284
292
  return {
285
293
  id,
286
294
  kind: "local_task",
295
+ ...(purposeVerification ? { purpose_verification: normalizePurposeVerification(purposeVerification) } : {}),
287
296
  idempotency_key_hash: idempotencyKeyHash,
288
297
  request_fingerprint: requestFingerprint,
289
298
  status: "queued",
@@ -316,6 +325,8 @@ export function createLocalTask({
316
325
  error: null,
317
326
  reason_code: null,
318
327
  provider_token_budget: tokenBudget,
328
+ continuation_policy: policy,
329
+ provider_token_ceiling: policy === "progress" ? tokenCeiling : tokenBudget,
319
330
  provider_tokens_used: 0,
320
331
  token_usage: { records: [] },
321
332
  ...(normalizedWorkflowObservationBinding
@@ -368,11 +379,14 @@ export function createLocalTaskRequestFingerprint({
368
379
  repository,
369
380
  expectedOutputs,
370
381
  providerTokenBudget,
382
+ continuationPolicy = "bounded",
383
+ providerTokenCeiling = MAX_LOCAL_TASK_TOKEN_BUDGET,
371
384
  timeoutMs,
372
385
  attachmentManifest,
373
386
  scenarioPack,
374
387
  workflowObservationBinding = null,
375
388
  workflowStartIntent = null,
389
+ purposeVerification = null,
376
390
  }) {
377
391
  const normalizedModel = normalizeLocalTaskModel(model);
378
392
  const manifest = Array.isArray(attachmentManifest)
@@ -398,6 +412,7 @@ export function createLocalTaskRequestFingerprint({
398
412
  }
399
413
  const payload = {
400
414
  instruction: String(instruction || "").trim(),
415
+ ...(purposeVerification ? { purpose_verification: normalizePurposeVerification(purposeVerification) } : {}),
401
416
  backend: String(backend || ""),
402
417
  ...(normalizedModel ? { model: normalizedModel } : {}),
403
418
  operation: String(operation || ""),
@@ -406,6 +421,10 @@ export function createLocalTaskRequestFingerprint({
406
421
  repository: String(repository || "").trim() || null,
407
422
  expected_outputs: normalizeExpectedOutputs(expectedOutputs),
408
423
  provider_token_budget: normalizeTokenBudget(providerTokenBudget),
424
+ ...(continuationMode(continuationPolicy) === "progress" ? {
425
+ continuation_policy: "progress",
426
+ provider_token_ceiling: normalizeTokenBudget(providerTokenCeiling),
427
+ } : {}),
409
428
  timeout_ms: normalizeTimeoutMs(timeoutMs),
410
429
  attachment_manifest: manifest,
411
430
  scenario_pack: normalizeLocalTaskScenarioPack(scenarioPack),
@@ -426,6 +445,7 @@ export async function startLocalTask(
426
445
  environment = process.env,
427
446
  persist = persistLocalTask,
428
447
  beforeProviderInvocation = null,
448
+ beforeSpawn = null,
429
449
  session = null,
430
450
  apiOrigin = null,
431
451
  observationFetch = globalThis.fetch,
@@ -509,6 +529,7 @@ export async function startLocalTask(
509
529
  // synchronous spawn boundary so a stopped task can never start Provider work.
510
530
  if (task.stop_requested || task.status !== "running") return task;
511
531
 
532
+
512
533
  const observationSender = createWorkflowObservationSender({
513
534
  binding: task.workflow_observation_binding,
514
535
  startIntent: task.workflow_start_intent,
@@ -523,6 +544,27 @@ export async function startLocalTask(
523
544
  path.join(runtimeTempBase, `simy-local-task-${task.id}-`),
524
545
  );
525
546
  allowCodexRuntimeTemp(command, runtimeTempRoot);
547
+ // Temp preparation is asynchronous too: the final authority check belongs
548
+ // after it, immediately adjacent to the synchronous process spawn boundary.
549
+ if (task.stop_requested || task.status !== "running") {
550
+ await rm(runtimeTempRoot, { recursive: true, force: true }); return task;
551
+ }
552
+ if (beforeSpawn) {
553
+ try { await beforeSpawn(task); }
554
+ catch (error) {
555
+ try {
556
+ if (!task.stop_requested && task.status === "running") {
557
+ updateTask(task, { status: "failed", control_state: "complete", error: errorMessage(error),
558
+ reason_code: error.code || "local_task_invocation_authorization_failed", completed_at: new Date().toISOString() });
559
+ await persist(task);
560
+ }
561
+ } finally { await rm(runtimeTempRoot, { recursive: true, force: true }); }
562
+ return task;
563
+ }
564
+ }
565
+ if (task.stop_requested || task.status !== "running") {
566
+ await rm(runtimeTempRoot, { recursive: true, force: true }); return task;
567
+ }
526
568
 
527
569
  return new Promise((resolve, reject) => {
528
570
  let settled = false;
@@ -572,13 +614,20 @@ export async function startLocalTask(
572
614
  emitTaskEvent(task, "usage", {
573
615
  provider_tokens_used: task.provider_tokens_used,
574
616
  provider_token_budget: task.provider_token_budget,
617
+ continuation_policy: task.continuation_policy || "bounded",
618
+ provider_token_ceiling: localTaskTokenCeiling(task),
575
619
  });
620
+ if (task.continuation_policy === "progress" && task.provider_tokens_used >= task.provider_token_budget && !task.token_warning_emitted) {
621
+ task.token_warning_emitted = true;
622
+ task.warnings.push("Provider token notification threshold reached. Work continues within the approved task and hard safety ceiling.");
623
+ emitTaskEvent(task, "budget_warning", { provider_tokens_used: task.provider_tokens_used, provider_token_budget: task.provider_token_budget, provider_token_ceiling: localTaskTokenCeiling(task) });
624
+ }
576
625
  if (
577
- task.provider_tokens_used > task.provider_token_budget &&
626
+ localTaskTokensExhausted(task) &&
578
627
  !task.stop_requested
579
628
  ) {
580
629
  task.reason_code = "provider_token_budget_exhausted";
581
- task.error = `Provider token budget exceeded (${task.provider_tokens_used} / ${task.provider_token_budget}).`;
630
+ task.error = `Provider token ${task.continuation_policy === "progress" ? "hard ceiling" : "budget"} exceeded (${task.provider_tokens_used} / ${localTaskTokenCeiling(task)}).`;
582
631
  task.stop_requested = true;
583
632
  task.control_state = "stopping";
584
633
  signalExecutorProcess(child, "SIGINT");
@@ -692,7 +741,7 @@ export async function startLocalTask(
692
741
  Boolean(error) ||
693
742
  exitCode !== 0 ||
694
743
  Boolean(task.error) ||
695
- task.provider_tokens_used > task.provider_token_budget;
744
+ localTaskTokensExhausted(task);
696
745
  task.status = manuallyStopped ? "stopped" : failed ? "failed" : "succeeded";
697
746
  task.control_state = task.status === "stopped" ? "stopped" : "complete";
698
747
  task.error ||= error || (exitCode !== 0 ? `Provider exited with code ${exitCode}.` : null);
@@ -823,6 +872,7 @@ export function localTaskSnapshot(task, { includeEvents = true, includeLocalPath
823
872
  output: [...task.output],
824
873
  ...(includeEvents ? { events: structuredClone(task.events) } : {}),
825
874
  result: structuredClone(task.result),
875
+ ...(task.host_verification ? { host_verification: structuredClone(task.host_verification) } : {}),
826
876
  artifacts: task.artifacts.map(publicArtifact),
827
877
  attachments: task.attachments.map(publicAttachment),
828
878
  expected_outputs: structuredClone(task.expected_outputs),
@@ -836,6 +886,9 @@ export function localTaskSnapshot(task, { includeEvents = true, includeLocalPath
836
886
  error: task.error,
837
887
  reason_code: task.reason_code,
838
888
  provider_token_budget: task.provider_token_budget,
889
+ continuation_policy: task.continuation_policy || "bounded",
890
+ provider_token_ceiling: localTaskTokenCeiling(task),
891
+ token_warning_emitted: task.token_warning_emitted === true,
839
892
  provider_tokens_used: task.provider_tokens_used,
840
893
  token_usage: structuredClone(task.token_usage),
841
894
  ...(task.workflow_observation
@@ -849,6 +902,8 @@ export function localTaskSnapshot(task, { includeEvents = true, includeLocalPath
849
902
  completed_at: task.completed_at,
850
903
  ...(includeLocalPaths
851
904
  ? {
905
+ ...(task.purpose_verification ? { purpose_verification: structuredClone(task.purpose_verification),
906
+ purpose_verification_state: structuredClone(task.purpose_verification_state || null) } : {}),
852
907
  workspace_path: task.workspace_path,
853
908
  task_root: task.task_root,
854
909
  inputs_root: task.inputs_root,
@@ -1057,6 +1112,7 @@ function restoreLocalTask(snapshot) {
1057
1112
  );
1058
1113
  return {
1059
1114
  ...snapshot,
1115
+ ...(snapshot.purpose_verification ? { purpose_verification: normalizePurposeVerification(snapshot.purpose_verification) } : {}),
1060
1116
  outputs_root_expected:
1061
1117
  snapshot.outputs_root_expected || null,
1062
1118
  output: Array.isArray(snapshot.output) ? snapshot.output : [],
@@ -1243,6 +1299,15 @@ function normalizeExpectedOutputs(value) {
1243
1299
  }));
1244
1300
  }
1245
1301
 
1302
+ function localTaskTokenCeiling(task) {
1303
+ return task.continuation_policy === "progress" ? normalizeTokenBudget(task.provider_token_ceiling ?? MAX_LOCAL_TASK_TOKEN_BUDGET) : task.provider_token_budget;
1304
+ }
1305
+
1306
+ function localTaskTokensExhausted(task) {
1307
+ const ceiling = localTaskTokenCeiling(task);
1308
+ return task.continuation_policy === "progress" ? task.provider_tokens_used >= ceiling : task.provider_tokens_used > ceiling;
1309
+ }
1310
+
1246
1311
  function normalizeTokenBudget(value) {
1247
1312
  const budget = Number(value ?? DEFAULT_LOCAL_TASK_TOKEN_BUDGET);
1248
1313
  if (!Number.isInteger(budget) || budget < 1 || budget > MAX_LOCAL_TASK_TOKEN_BUDGET) {
@@ -1,4 +1,5 @@
1
1
  import { clampAttempts } from "./shared.js";
2
+ import { progressLimits } from "../continuation-policy.js";
2
3
 
3
4
  export const DEFAULT_TOKEN_BUDGET = 250_000;
4
5
  export const DEFAULT_RETRY_BUDGET = 2;
@@ -54,6 +55,31 @@ export function increaseProviderTokenBudget(snapshot, value) {
54
55
  return refreshBudgetState(snapshot);
55
56
  }
56
57
 
58
+ export function extendProgressCeilings(snapshot, changes = {}) {
59
+ const current = budgetState(snapshot);
60
+ if (current.continuation_policy !== "progress") {
61
+ throw new Error("Hard ceilings can be extended only for an explicitly approved progress policy.");
62
+ }
63
+ if (!changes || typeof changes !== "object" || Array.isArray(changes) ||
64
+ Object.keys(changes).some(key => !["provider_token_ceiling", "attempt_ceiling"].includes(key))) {
65
+ throw new Error("Specify only the Provider token ceiling or attempt ceiling.");
66
+ }
67
+ const provided = Object.entries(changes).filter(([, value]) => value !== undefined);
68
+ if (!provided.length) throw new Error("Explicitly increase at least one hard ceiling.");
69
+ for (const [field, value] of provided) {
70
+ const previous = field === "provider_token_ceiling" ? current.hard_token_ceiling : current.hard_attempt_ceiling;
71
+ const used = field === "provider_token_ceiling" ? current.tokens_used : (snapshot.attempts || []).length;
72
+ if (!Number.isSafeInteger(value) || value <= previous || value <= used) {
73
+ throw new Error("Each requested hard ceiling must be an integer higher than its current limit and recorded usage.");
74
+ }
75
+ }
76
+ // Validate every field before changing either ceiling; partial rejection must
77
+ // never leave a larger authorization behind.
78
+ const limits = progressLimits({ ...snapshot.charter, ...Object.fromEntries(provided) });
79
+ for (const [field] of provided) snapshot.charter[field] = limits[field];
80
+ return refreshBudgetState(snapshot);
81
+ }
82
+
57
83
  export function budgetState(snapshot, pendingAttempt = null) {
58
84
  const attempts = [
59
85
  ...(Array.isArray(snapshot?.attempts) ? snapshot.attempts : []),
@@ -67,11 +93,15 @@ export function budgetState(snapshot, pendingAttempt = null) {
67
93
  const tokensUsed = attempts.reduce(
68
94
  (sum, attempt) => sum + tokenUsageTotal(attempt?.token_usage),
69
95
  0,
70
- );
96
+ ) + unaccountedProviderUsage(snapshot, attempts);
71
97
  const attemptsUsed = attempts.length;
72
98
  const retriesUsed = Math.max(attemptsUsed - 1, 0);
73
- const tokenExhausted = tokensUsed >= tokenBudget;
74
- const retryExhausted = attemptsUsed > 0 && retriesUsed >= retryBudget;
99
+ const policy = progressLimits(snapshot?.charter);
100
+ const progress = policy.continuation_policy === "progress";
101
+ const hardTokenCeiling = progress ? policy.provider_token_ceiling : tokenBudget;
102
+ const hardAttemptCeiling = progress ? policy.attempt_ceiling : retryBudget + 1;
103
+ const tokenExhausted = tokensUsed >= hardTokenCeiling;
104
+ const retryExhausted = attemptsUsed >= hardAttemptCeiling;
75
105
  return {
76
106
  schema_version: 1,
77
107
  token_budget: tokenBudget,
@@ -88,9 +118,26 @@ export function budgetState(snapshot, pendingAttempt = null) {
88
118
  : retryExhausted
89
119
  ? "retry_budget_exhausted"
90
120
  : null,
121
+ continuation_policy: policy.continuation_policy,
122
+ token_warning: progress && tokensUsed >= tokenBudget,
123
+ retry_warning: progress && attemptsUsed >= retryBudget + 1,
124
+ hard_token_ceiling: hardTokenCeiling,
125
+ hard_attempt_ceiling: hardAttemptCeiling,
126
+ hard_tokens_remaining: Math.max(hardTokenCeiling - tokensUsed, 0),
91
127
  };
92
128
  }
93
129
 
130
+ function unaccountedProviderUsage(snapshot, attempts) {
131
+ const checkpoints = snapshot?.metadata?.provider_usage_checkpoints || {};
132
+ return Object.entries(checkpoints).reduce((sum, [id, checkpoint]) => {
133
+ const reported = tokenUsageTotal(checkpoint?.token_usage);
134
+ const accounted = attempts.reduce((total, attempt) => total + tokenUsageTotal({
135
+ records: (Array.isArray(attempt?.token_usage?.records) ? attempt.token_usage.records : []).filter(record => record?.invocation_id === id),
136
+ }), 0);
137
+ return sum + Math.max(0, reported - accounted);
138
+ }, 0);
139
+ }
140
+
94
141
  export function refreshBudgetState(snapshot, pendingAttempt = null) {
95
142
  const state = budgetState(snapshot, pendingAttempt);
96
143
  snapshot.budget = state;
@@ -102,12 +149,16 @@ export function canStartProvider(snapshot, { attemptNumber, phase }) {
102
149
  if (state.token_exhausted) {
103
150
  return { allowed: false, reason: "token_budget_exhausted", budget: state };
104
151
  }
105
- if (phase === "executor" && attemptNumber > state.retry_budget + 1) {
152
+ if (phase === "executor" && attemptNumber > state.hard_attempt_ceiling) {
106
153
  return { allowed: false, reason: "retry_budget_exhausted", budget: state };
107
154
  }
108
155
  return { allowed: true, reason: null, budget: state };
109
156
  }
110
157
 
158
+ export function codingAttemptCeiling(snapshot) {
159
+ return snapshot?.charter?.continuation_policy === "progress" ? budgetState(snapshot).hard_attempt_ceiling : snapshot?.charter?.max_attempts;
160
+ }
161
+
111
162
  export function tokenUsageTotal(value) {
112
163
  if (!value || typeof value !== "object" || Array.isArray(value)) return 0;
113
164
  if (Array.isArray(value.records)) {
@@ -0,0 +1,33 @@
1
+ export function validateCharter(charter) {
2
+ const findings = [];
3
+ if (charter.risk.requires_design_review && charter.acceptance_criteria_source !== "explicit") {
4
+ findings.push({
5
+ code: "EXPLICIT_ACCEPTANCE_CRITERIA_REQUIRED",
6
+ severity: "blocker",
7
+ target: "acceptance_criteria",
8
+ explanation: "High-risk work requires explicit acceptance criteria.",
9
+ repairability: "manual",
10
+ });
11
+ }
12
+ if (charter.design_review.required && charter.design_review.status !== "approved") {
13
+ findings.push({
14
+ code: "DESIGN_REVIEW_REQUIRED",
15
+ severity: "blocker",
16
+ target: "design_review",
17
+ explanation:
18
+ "High-risk work requires a design summary, approval owner, and review evidence before implementation.",
19
+ repairability: "manual",
20
+ });
21
+ }
22
+ if (charter.risk.requires_design_review && charter.required_checks.length === 0) {
23
+ findings.push({
24
+ code: "REQUIRED_CI_CHECKS_MISSING",
25
+ severity: "blocker",
26
+ target: "required_checks",
27
+ explanation: "High-risk work requires explicit CI and security check names.",
28
+ repairability: "manual",
29
+ });
30
+ }
31
+ return findings;
32
+ }
33
+
@@ -65,7 +65,7 @@ function criterionIds(value, count) {
65
65
  if (
66
66
  ids.length !== count ||
67
67
  new Set(ids).size !== ids.length ||
68
- ids.some((id) => !criterionIdPattern.test(id))
68
+ ids.some((id) => id === "USER-GOAL" || !criterionIdPattern.test(id))
69
69
  ) {
70
70
  throw new Error("Invalid acceptance criterion IDs");
71
71
  }
@@ -8,6 +8,7 @@ import {
8
8
  visualEvidenceSurface,
9
9
  } from "./shared.js";
10
10
  import { budgetState, resolveRunBudgets } from "./budget.js";
11
+ import { progressLimits } from "../continuation-policy.js";
11
12
  import { classifyRisk } from "./risk.js";
12
13
  import { buildCompletionContract } from "./completion-contract.js";
13
14
  import { freezeObjective } from "./outcome-monitor.js";
@@ -117,6 +118,7 @@ export function createCodingLoopSnapshot({ runId, request, metadata = {} }) {
117
118
  max_attempts: budgets.max_attempts,
118
119
  retry_budget: budgets.retry_budget,
119
120
  token_budget: budgets.token_budget,
121
+ ...progressLimits(request),
120
122
  ui_evidence_root: cleanString(request.ui_evidence_root),
121
123
  acceptance_criteria: finalAcceptanceCriteria,
122
124
  acceptance_criteria_source: acceptanceCriteriaSource,
@@ -99,9 +99,27 @@ recommendation, silence, or an implementation pattern.
99
99
  The controller persists `thread_state.objective_contract` with the baseline,
100
100
  current version, digest and explicit human revision lineage. Each attempt stores
101
101
  `outcome_progress` with its objective digest, independently supported criterion
102
- IDs, unproven IDs and assessment availability. A repeated unproven set without
103
- new independently supported IDs opens `OUTCOME_PROGRESS_STALLED`; this prevents
104
- automatic retries across changing commits. An old review cannot settle a newer
102
+ IDs, unproven IDs and assessment availability. `evaluateOutcomeProgress` separates
103
+ current `verified_ids`, `failed_ids`, and `unassessed_ids`; executor test counts,
104
+ commits, evidence hashes and reference paths cannot create progress. A new
105
+ independently verified criterion resets `no_progress_attempts`. A prior proof
106
+ missing from the current review remains unassessed, rather than silently passing.
107
+ The historical verified-ID set is used only for comparison, so repeating an old
108
+ proof after a regression cannot manufacture new progress.
109
+
110
+ Progress continuation uses `{ policy: "progress", maxNoProgressAttempts: 3 }`
111
+ with `assessOutcomeProgress`. Three attempts without new verified outcomes,
112
+ including attempts that never reach independent review, mark `stalled` with a
113
+ specific `stall_reason`. The pure evaluator accepts the same threshold for
114
+ Delivery. Legacy runs retain their original stopping policy until the controller
115
+ selects progress continuation. Partial independent proof can show progress even
116
+ when the overall audit fails; a complete passing audit is still required for
117
+ completion. Human and external blockers remain visible alongside machine repairs
118
+ in `blockers` and `next_actions`; they do not imply every criterion is blocked.
119
+
120
+ Audits bound to a different objective or current candidate evidence identity
121
+ cannot supply progress. Revisions reset the comparison baseline and require a
122
+ fresh independent review. An old review cannot settle a newer
105
123
  objective version. Legacy snapshots without an objective contract remain
106
124
  readable but lose completion status on restore; their original baseline cannot
107
125
  be reconstructed retrospectively. `USER-GOAL` is tracked separately from