@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.
- package/README.md +101 -3
- package/package.json +2 -2
- package/src/agent.js +205 -12
- package/src/backend-executable.js +45 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/README.md +22 -0
- package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +180 -31
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
- package/src/coding-run-recovery.js +88 -0
- package/src/coding-run-store.js +2 -2
- package/src/continuation-policy.js +2 -0
- package/src/delivery-hooks/command.js +11 -5
- package/src/delivery-hooks/desktop.js +12 -3
- package/src/delivery-hooks/supervisor.js +17 -12
- package/src/desktop-host.js +19 -8
- package/src/desktop-sqm-settings.js +24 -7
- package/src/device-signing-identity.js +65 -0
- package/src/diagnostic-log.js +68 -0
- package/src/doctor.js +55 -0
- package/src/index.js +18 -1
- package/src/local-task.js +68 -3
- package/src/orchestrator/budget.js +55 -4
- package/src/orchestrator/charter-validation.js +33 -0
- package/src/orchestrator/completion-contract.js +1 -1
- package/src/orchestrator/contract.js +2 -0
- package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
- package/src/orchestrator/evidence.js +20 -19
- package/src/orchestrator/independent-audit.js +9 -0
- package/src/orchestrator/instruction.js +6 -1
- package/src/orchestrator/loop.js +66 -41
- package/src/orchestrator/outcome-monitor.js +95 -44
- package/src/orchestrator/outcome-progress.js +2 -0
- package/src/orchestrator/recovery.js +3 -0
- package/src/purpose-local-proof.js +380 -0
- package/src/runner.js +227 -19
- package/src/runtime-diagnostics.js +77 -0
- package/src/session-supervision/codex-app-server.js +3 -2
- package/src/session-supervision/index.js +16 -4
- package/src/sqm/bundle-store.js +14 -7
- package/src/sqm/claude-plugin.js +2 -0
- package/src/sqm/command.js +42 -4
- package/src/sqm/diagnostics.js +23 -0
- package/src/sqm/evidence-client.js +24 -7
- package/src/sqm/history-store.js +21 -5
- package/src/sqm/index.js +116 -17
- package/src/sqm/proof.js +4 -23
- package/src/sqm/report.js +9 -2
- package/src/sqm/request-recovery.js +82 -0
- package/src/sqm/review-worker.js +97 -15
- package/src/sqm/session.js +33 -3
- 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
|
|
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
|
|
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
|
|
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
|
|
74
|
-
const
|
|
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.
|
|
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.
|
|
103
|
-
|
|
104
|
-
|
|
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
|