@awak-app/simy-cli 0.5.72 → 0.5.74

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 (51) hide show
  1. package/README.md +97 -0
  2. package/package.json +2 -2
  3. package/src/agent.js +191 -11
  4. package/src/backend-executable.js +21 -1
  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 +179 -34
  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 +14 -10
  16. package/src/desktop-host.js +9 -6
  17. package/src/desktop-sqm-settings.js +24 -7
  18. package/src/device-signing-identity.js +65 -0
  19. package/src/diagnostic-log.js +72 -0
  20. package/src/doctor.js +55 -0
  21. package/src/index.js +7 -0
  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/independent-audit.js +9 -0
  29. package/src/orchestrator/instruction.js +6 -1
  30. package/src/orchestrator/loop.js +38 -41
  31. package/src/orchestrator/outcome-monitor.js +58 -38
  32. package/src/orchestrator/outcome-progress.js +2 -0
  33. package/src/purpose-local-proof.js +380 -0
  34. package/src/runner.js +224 -36
  35. package/src/session-supervision/analysis-manager.js +45 -0
  36. package/src/session-supervision/codex-app-server.js +3 -2
  37. package/src/session-supervision/continuation-analysis.js +1 -1
  38. package/src/session-supervision/index.js +16 -4
  39. package/src/sqm/bundle-store.js +11 -7
  40. package/src/sqm/claude-plugin.js +2 -0
  41. package/src/sqm/command.js +36 -4
  42. package/src/sqm/diagnostics.js +1 -1
  43. package/src/sqm/evidence-client.js +23 -7
  44. package/src/sqm/history-store.js +9 -0
  45. package/src/sqm/index.js +104 -14
  46. package/src/sqm/proof.js +3 -23
  47. package/src/sqm/provider-diagnostics.js +35 -0
  48. package/src/sqm/request-recovery.js +82 -0
  49. package/src/sqm/review-worker.js +218 -112
  50. package/src/sqm/session.js +33 -3
  51. package/src/startup-preflight.js +44 -0
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
@@ -108,6 +108,7 @@ export function buildIndependentAuditInstruction(charter, attempt) {
108
108
  "Required keys: passed, summary, findings, criterion_results, visual_review, coverage_review.",
109
109
  "coverage_review must contain requirements_complete (boolean), unmapped_requirements (array), observation, and user_outcome with passed, verification_method, evidence_refs, and observed_result. Independently exercise or inspect proof of the original user goal even when supplied acceptance criteria mention only tests, code, or a PR. Use passed=false if the goal is not demonstrated. Explain the comparison of the original request, frozen experience, stable criterion-ID list, implementation and raw user/system proof.",
110
110
  "Each finding candidate must contain passed, code, severity, target, explanation, and repairability.",
111
+ "For a failed finding, optionally identify blocker_kind as machine, human, or external. Use human when a specific user decision or credential interaction is required, external for an unavailable outside dependency, and machine when local repair or verification can proceed. One blocked criterion does not imply all remaining work is blocked.",
111
112
  "criterion_results must contain one entry per stable criterion ID with criterion_id, passed, verification_method, evidence_refs, and observed_result.",
112
113
  "visual_review must contain inspected, artifacts, and acceptance_observations.",
113
114
  "Only passed=false entries are findings. Omit passing checks, and never report passed=true evidence as a finding.",
@@ -121,6 +122,10 @@ export function buildIndependentAudit(execution, charter = null, attempt = null)
121
122
  const result = execution?.result && typeof execution.result === "object" ? execution.result : {};
122
123
  const completionContractV2 =
123
124
  charter?.completion_contract?.version === COMPLETION_CONTRACT_VERSION;
125
+ const objectiveBinding = {
126
+ objective_digest: charter?.thread_state?.objective_contract?.current_digest || null,
127
+ objective_version: charter?.thread_state?.objective_contract?.version || null,
128
+ };
124
129
  const valid =
125
130
  execution?.exitCode === 0 &&
126
131
  !execution?.error &&
@@ -132,6 +137,7 @@ export function buildIndependentAudit(execution, charter = null, attempt = null)
132
137
  typeof result.visual_review === "object"));
133
138
  if (!valid) {
134
139
  return {
140
+ ...objectiveBinding,
135
141
  passed: false,
136
142
  summary: cleanString(execution?.error || result.summary) || "Independent audit did not return valid evidence.",
137
143
  findings: [
@@ -159,6 +165,8 @@ export function buildIndependentAudit(execution, charter = null, attempt = null)
159
165
  target: cleanString(item.target) || "pull_request",
160
166
  explanation: cleanString(item.explanation) || "Independent audit finding.",
161
167
  repairability: item.repairability === "manual" ? "manual" : "auto",
168
+ blocker_kind: ["machine", "human", "external"].includes(item.blocker_kind)
169
+ ? item.blocker_kind : item.repairability === "manual" ? "human" : "machine",
162
170
  auto_fix_hint: cleanString(item.auto_fix_hint) || null,
163
171
  }));
164
172
  const findings = evaluations.filter((item) => item.passed === false);
@@ -243,6 +251,7 @@ export function buildIndependentAudit(execution, charter = null, attempt = null)
243
251
  }
244
252
  const requiresHuman = findings.some((item) => item.repairability === "manual");
245
253
  return {
254
+ ...objectiveBinding,
246
255
  passed: result.passed === true && findings.length === 0,
247
256
  summary: cleanString(result.summary) || "Independent audit completed.",
248
257
  findings,
@@ -39,9 +39,14 @@ export function buildCodingInstruction(
39
39
  `Risk level: ${charter.risk.level}`,
40
40
  `Design summary: ${charter.design_review.summary || "not required"}`,
41
41
  `Design approved by: ${charter.design_review.approved_by || "not required"}`,
42
- `Attempt: ${attemptNumber}/${charter.max_attempts}`,
42
+ `Attempt: ${attemptNumber}/${budget?.hard_attempt_ceiling ?? charter.max_attempts}`,
43
43
  `Retry budget: ${Math.max(attemptNumber - 1, 0)}/${charter.retry_budget}`,
44
44
  `Token budget remaining: ${budget?.tokens_remaining ?? charter.token_budget}/${charter.token_budget}`,
45
+ ...(charter.continuation_policy === "progress" ? [
46
+ "Continuation policy: verified acceptance progress. Token/retry thresholds notify; they do not relax acceptance or authorize new work.",
47
+ `Hard Provider token ceiling: ${budget?.hard_token_ceiling}; remaining: ${budget?.hard_tokens_remaining}. Hard attempt ceiling: ${budget?.hard_attempt_ceiling}.`,
48
+ "Report human/external blockers as soon as observed; continue independent work within the approved objective. Changed metadata alone is not progress.",
49
+ ] : []),
45
50
  `Local evidence root: ${charter.ui_evidence_root || "not required"}`,
46
51
  ].join("\n"),
47
52
  ),
@@ -1,3 +1,4 @@
1
+ import { validateCharter } from "./charter-validation.js";
1
2
  import { redactExecutionText } from "./execution-io.js";
2
3
  import { minimumCharter } from "./delivery/scripts/check-minimum-completion.mjs";
3
4
  import { auditAttempt, evaluatePrReadiness } from "./audit.js";
@@ -6,7 +7,7 @@ import {
6
7
  buildIndependentAuditInstruction,
7
8
  } from "./independent-audit.js";
8
9
  import { buildCodingInstruction, buildPromptInterventions } from "./instruction.js";
9
- import { canStartProvider, refreshBudgetState } from "./budget.js";
10
+ import { canStartProvider, codingAttemptCeiling, refreshBudgetState } from "./budget.js";
10
11
  import { buildAttempt, mergeTokenUsage } from "./result.js";
11
12
  import { evaluateRetryCircuit } from "./retry.js";
12
13
  import { assessOutcomeProgress, verifyObjective } from "./outcome-monitor.js";
@@ -76,7 +77,8 @@ async function runCodingLoopOwned({
76
77
 
77
78
  let previousFindings = resume ? snapshot.final_audit?.findings || [] : [];
78
79
  const firstAttempt = resume ? snapshot.attempts.length + 1 : 1;
79
- const lastAttempt = resume ? firstAttempt : snapshot.charter.max_attempts;
80
+ const progressPolicy = snapshot.charter.continuation_policy === "progress";
81
+ const lastAttempt = progressPolicy ? codingAttemptCeiling(snapshot) : resume ? firstAttempt : snapshot.charter.max_attempts;
80
82
  for (
81
83
  let attemptNumber = firstAttempt;
82
84
  attemptNumber <= lastAttempt;
@@ -183,8 +185,9 @@ async function runCodingLoopOwned({
183
185
  if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
184
186
  attempt.implementation_gate = attempt.audit.implementation_gate;
185
187
  snapshot.attempts.push(attempt);
186
- assessOutcomeProgress(snapshot.charter, attempt, previousAttempt, { resetUnavailableCount: resume && attemptNumber === firstAttempt });
188
+ assessOutcomeProgress(snapshot.charter, attempt, previousAttempt, { ...outcomeOptions(snapshot), resetUnavailableCount: resume && attemptNumber === firstAttempt });
187
189
  attempt.budget = refreshBudgetState(snapshot);
190
+ recordBudgetWarnings(snapshot, attempt);
188
191
 
189
192
  if (snapshot.budget.token_exhausted) {
190
193
  await stopForBudget({
@@ -234,6 +237,8 @@ async function runCodingLoopOwned({
234
237
  const auditInstruction = buildIndependentAuditInstruction(snapshot.charter, attempt);
235
238
  const reviewedMatrix = attempt.observed_evidence?.minimum_completion?.matrix_sha256;
236
239
  const reviewedEvidenceIdentity = independentAuditEvidenceIdentity(attempt);
240
+ const reviewedObjectiveDigest = snapshot.charter.thread_state?.objective_contract?.current_digest;
241
+ const reviewedObjectiveVersion = snapshot.charter.thread_state?.objective_contract?.version;
237
242
  const auditExecution = await safeExecution(() =>
238
243
  executeIndependentAudit({
239
244
  attemptNumber,
@@ -251,9 +256,16 @@ async function runCodingLoopOwned({
251
256
  );
252
257
  attempt.token_usage = mergeTokenUsage(attempt.token_usage, auditExecution.tokenUsage);
253
258
  attempt.budget = refreshBudgetState(snapshot);
259
+ recordBudgetWarnings(snapshot, attempt);
260
+ if (attempt.budget.token_exhausted) {
261
+ await stopForBudget({ snapshot, attempt, reason: "token_budget_exhausted", onUpdate });
262
+ return snapshot;
263
+ }
254
264
 
255
265
  attempt.independent_audit.minimum_matrix_sha256 = reviewedMatrix;
256
266
  attempt.independent_audit.evidence_identity_sha256 = reviewedEvidenceIdentity;
267
+ attempt.independent_audit.objective_digest = reviewedObjectiveDigest;
268
+ attempt.independent_audit.objective_version = reviewedObjectiveVersion;
257
269
 
258
270
  // Re-collect after the read-only auditor to catch any mutated HEAD or working tree.
259
271
  attempt.observed_evidence = await safeEvidence(collectEvidence, attempt);
@@ -265,7 +277,7 @@ async function runCodingLoopOwned({
265
277
  });
266
278
  if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
267
279
  attempt.implementation_gate = attempt.audit.implementation_gate;
268
- assessOutcomeProgress(snapshot.charter, attempt, previousAttempt, { resetUnavailableCount: resume && attemptNumber === firstAttempt });
280
+ assessOutcomeProgress(snapshot.charter, attempt, previousAttempt, { ...outcomeOptions(snapshot), resetUnavailableCount: resume && attemptNumber === firstAttempt });
269
281
 
270
282
  const independentFindings = attempt.independent_audit.findings || [];
271
283
  if (!attempt.audit.passed || !attempt.independent_audit.passed) {
@@ -308,6 +320,10 @@ async function runCodingLoopOwned({
308
320
  return snapshot;
309
321
  }
310
322
 
323
+ function outcomeOptions(snapshot) {
324
+ return { policy: snapshot.charter.continuation_policy || "bounded", maxNoProgressAttempts: snapshot.charter.max_no_progress_attempts || 3 };
325
+ }
326
+
311
327
  async function stopCodingLoop(snapshot, onUpdate) {
312
328
  if (snapshot.state === "stopped") return snapshot;
313
329
  appendEvent(snapshot, "stopped", "Agentic loop stopped by the local human operator.", {
@@ -354,6 +370,7 @@ async function recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm = n
354
370
  });
355
371
  attempt.outcome_progress = assessOutcomeProgress(
356
372
  snapshot.charter, attempt, snapshot.attempts.at(-2) ?? null,
373
+ outcomeOptions(snapshot),
357
374
  );
358
375
  snapshot.implementation_gate_passed = attempt.audit.passed;
359
376
  attempt.pr_readiness = evaluatePrReadiness(snapshot.charter, attempt);
@@ -372,38 +389,6 @@ async function recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm = n
372
389
  return snapshot;
373
390
  }
374
391
 
375
- function validateCharter(charter) {
376
- const findings = [];
377
- if (charter.risk.requires_design_review && charter.acceptance_criteria_source !== "explicit") {
378
- findings.push({
379
- code: "EXPLICIT_ACCEPTANCE_CRITERIA_REQUIRED",
380
- severity: "blocker",
381
- target: "acceptance_criteria",
382
- explanation: "High-risk work requires explicit acceptance criteria.",
383
- repairability: "manual",
384
- });
385
- }
386
- if (charter.design_review.required && charter.design_review.status !== "approved") {
387
- findings.push({
388
- code: "DESIGN_REVIEW_REQUIRED",
389
- severity: "blocker",
390
- target: "design_review",
391
- explanation:
392
- "High-risk work requires a design summary, approval owner, and review evidence before implementation.",
393
- repairability: "manual",
394
- });
395
- }
396
- if (charter.risk.requires_design_review && charter.required_checks.length === 0) {
397
- findings.push({
398
- code: "REQUIRED_CI_CHECKS_MISSING",
399
- severity: "blocker",
400
- target: "required_checks",
401
- explanation: "High-risk work requires explicit CI and security check names.",
402
- repairability: "manual",
403
- });
404
- }
405
- return findings;
406
- }
407
392
 
408
393
  async function safeExecution(callback) {
409
394
  try {
@@ -515,7 +500,7 @@ async function handleFailedAudit({
515
500
  previousAttempt: snapshot.attempts.at(-2) ?? null,
516
501
  findings,
517
502
  });
518
- if (retryCircuit.open) {
503
+ if (retryCircuit.open && !attempt.outcome_progress?.has_progress) {
519
504
  const avoidedAttempts = Math.max(snapshot.charter.max_attempts - attemptNumber, 0);
520
505
  const circuitState = {
521
506
  state: "open",
@@ -546,7 +531,7 @@ async function handleFailedAudit({
546
531
  await publish(snapshot, "blocked", onUpdate);
547
532
  return true;
548
533
  }
549
- if (attemptNumber >= snapshot.charter.max_attempts) {
534
+ if (attemptNumber >= codingAttemptCeiling(snapshot)) {
550
535
  await stopForBudget({
551
536
  snapshot,
552
537
  attempt,
@@ -590,7 +575,7 @@ async function handleFailedAudit({
590
575
  await publish(snapshot, "waiting_human", onUpdate);
591
576
  return true;
592
577
  }
593
- if (attemptNumber < snapshot.charter.max_attempts) {
578
+ if (attemptNumber < codingAttemptCeiling(snapshot)) {
594
579
  appendEvent(snapshot, "re_instructing", "PR audit produced a repair instruction.", {
595
580
  attempt_number: attemptNumber,
596
581
  finding_codes: findings.map((finding) => finding.code),
@@ -601,6 +586,18 @@ async function handleFailedAudit({
601
586
  return false;
602
587
  }
603
588
 
589
+ function recordBudgetWarnings(snapshot, attempt) {
590
+ if (snapshot.charter.continuation_policy !== "progress") return;
591
+ snapshot.budget_notifications ??= {};
592
+ for (const kind of ["token", "retry"]) {
593
+ if (!snapshot.budget[`${kind}_warning`] || snapshot.budget_notifications[kind]) continue;
594
+ snapshot.budget_notifications[kind] = { attempt_number: attempt.attempt_number, at: new Date().toISOString() };
595
+ appendEvent(snapshot, "budget_warning", "The continuation notification threshold was reached; acceptance progress and hard ceilings still govern further work.", {
596
+ kind, attempt_number: attempt.attempt_number, budget: snapshot.budget,
597
+ });
598
+ }
599
+ }
600
+
604
601
  async function stopForBudget({ snapshot, attempt, reason, onUpdate }) {
605
602
  const budget = refreshBudgetState(snapshot);
606
603
  const finding = budgetFinding(reason, budget);
@@ -639,7 +636,7 @@ function budgetFinding(reason, budget) {
639
636
  code: "TOKEN_BUDGET_EXHAUSTED",
640
637
  severity: "blocker",
641
638
  target: "provider_token_budget",
642
- explanation: `The run used ${budget.tokens_used} of ${budget.token_budget} Provider tokens, so no additional executor or reviewer call can start until the Provider token budget is increased. Provider tokens are not billed as SIMY tokens.`,
639
+ explanation: `The run used ${budget.tokens_used} of ${budget.hard_token_ceiling} Provider tokens. The ${budget.continuation_policy === "progress" ? "hard safety ceiling" : "budget"} prevents another provider call. Preserve this run and review its remaining acceptance conditions before resuming. Provider tokens are not billed as SIMY tokens.`,
643
640
  repairability: "manual",
644
641
  auto_fix_hint: null,
645
642
  };
@@ -649,7 +646,7 @@ function budgetFinding(reason, budget) {
649
646
  code: "RETRY_BUDGET_EXHAUSTED",
650
647
  severity: "blocker",
651
648
  target: "retry_budget",
652
- explanation: `The run used ${budget.retries_used} of ${budget.retry_budget} automatic retries.`,
649
+ explanation: budget.continuation_policy === "progress" ? `The run reached its hard ceiling of ${budget.hard_attempt_ceiling} attempts. Preserve the checkpoint and review remaining acceptance conditions before resuming.` : `The run used ${budget.retries_used} of ${budget.retry_budget} automatic retries.`,
653
650
  repairability: "manual",
654
651
  auto_fix_hint: null,
655
652
  };