@awak-app/simy-cli 0.7.28 → 0.7.31

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 (77) hide show
  1. package/README.md +88 -3
  2. package/package.json +4 -4
  3. package/src/agent.js +1 -0
  4. package/src/cli-contract.js +1 -1
  5. package/src/cli-observability.js +1 -1
  6. package/src/codex-plugins/simy/.codex-plugin/plugin.json +5 -3
  7. package/src/codex-plugins/simy/hooks/acceptance-policy.mjs +7 -0
  8. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +570 -65
  9. package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +6 -3
  10. package/src/codex-plugins/simy/hooks/purpose-search.mjs +23 -4
  11. package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +2 -0
  12. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +126 -4
  13. package/src/codex-plugins/simy/skills/continue/SKILL.md +69 -0
  14. package/src/codex-plugins/simy/skills/continue/agents/openai.yaml +6 -0
  15. package/src/delivery-hooks/claude-workflow.md +3 -3
  16. package/src/delivery-hooks/command.js +12 -0
  17. package/src/delivery-hooks/desktop.js +31 -9
  18. package/src/delivery-hooks/supervisor.js +3 -2
  19. package/src/desktop-access.js +4 -1
  20. package/src/desktop-executor.js +6 -0
  21. package/src/desktop-host.js +494 -72
  22. package/src/native-development-report-completion.js +26 -0
  23. package/src/native-development-reports.js +15 -5
  24. package/src/orchestrator/budget.js +24 -0
  25. package/src/orchestrator/charter-repair.js +1 -1
  26. package/src/orchestrator/completion-contract.js +8 -2
  27. package/src/orchestrator/contract.js +3 -1
  28. package/src/orchestrator/independent-audit.js +13 -6
  29. package/src/orchestrator/instruction.js +1 -1
  30. package/src/orchestrator/loop.js +34 -4
  31. package/src/provider-stream.js +9 -2
  32. package/src/runner.js +44 -21
  33. package/src/session-supervision/action-deadline.js +18 -1
  34. package/src/session-supervision/autonomous-upgrade.js +4 -2
  35. package/src/session-supervision/claude-desktop-archive.js +57 -0
  36. package/src/session-supervision/claude-desktop-native.js +50 -0
  37. package/src/session-supervision/claude-desktop-native.jxa.js +195 -0
  38. package/src/session-supervision/claude-desktop-target.js +87 -0
  39. package/src/session-supervision/claude-outcome-review.js +132 -0
  40. package/src/session-supervision/claude-owner-renewal.js +197 -0
  41. package/src/session-supervision/claude-review-definition.js +5 -0
  42. package/src/session-supervision/claude-sessions.js +210 -40
  43. package/src/session-supervision/codex-app-server.js +189 -36
  44. package/src/session-supervision/continuation-analysis.js +7 -7
  45. package/src/session-supervision/continuation-command.js +45 -0
  46. package/src/session-supervision/controller-store.js +48 -0
  47. package/src/session-supervision/conversation-presentation.js +241 -0
  48. package/src/session-supervision/decision-presentation.js +96 -35
  49. package/src/session-supervision/execution-context.js +39 -0
  50. package/src/session-supervision/index.js +38 -9
  51. package/src/session-supervision/manual-acceptance-command.js +30 -0
  52. package/src/session-supervision/notification-authority.js +9 -0
  53. package/src/session-supervision/notification-journal.js +40 -0
  54. package/src/session-supervision/operation-journal.js +42 -0
  55. package/src/session-supervision/owner-transport.js +146 -0
  56. package/src/session-supervision/purpose-recovery.js +10 -2
  57. package/src/session-supervision/purpose-verification.js +103 -9
  58. package/src/session-supervision/remote-control.js +1 -1
  59. package/src/session-supervision/remote-entry.js +2 -0
  60. package/src/session-supervision/remote-errors.js +15 -0
  61. package/src/session-supervision/remote-hosts.js +344 -36
  62. package/src/session-supervision/remote-metadata.js +103 -0
  63. package/src/session-supervision/remote-session-list.js +2 -4
  64. package/src/session-supervision/session-archive-command.js +45 -0
  65. package/src/session-supervision/session-list.js +40 -4
  66. package/src/session-supervision/sqm-presentation.js +46 -3
  67. package/src/session-supervision/stop-notifications.js +42 -11
  68. package/src/session-supervision/work-state.js +106 -6
  69. package/src/sqm/agent-skill.js +3 -3
  70. package/src/sqm/claude-plugin.js +7 -2
  71. package/src/sqm/history-store.js +6 -0
  72. package/src/team-threads.js +1 -1
  73. package/src/update-detachment/coordinator.js +7 -1
  74. package/src/update-detachment/history-recovery.js +18 -0
  75. package/src/update-detachment/ledger-adapter.js +1 -0
  76. package/src/update-detachment/retained-paused.js +290 -0
  77. package/src/update-detachment/runtime.js +78 -15
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { effectiveAcceptanceCriteria } from "./codex-plugins/simy/hooks/acceptance-policy.mjs";
2
3
 
3
4
  import {
4
5
  NATIVE_DEVELOPMENT_REPORT_COMPLETIONS_PATH,
@@ -141,3 +142,28 @@ function canonicalJson(value) {
141
142
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
142
143
  return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
143
144
  }
145
+
146
+ // v2: completion is a machine-observed controller outcome, not a request for
147
+ // human certification or a particular acceptance-document format. This never
148
+ // promotes provider reported_done or process exit to completed.
149
+ export function projectControllerCompletion(record) {
150
+ const run = record?.run;
151
+ const acceptance = run?.managed?.acceptanceCriteria;
152
+ if (acceptance !== undefined && (!Array.isArray(acceptance) ||
153
+ acceptance.some(criterion => !criterion || typeof criterion !== 'object' || Array.isArray(criterion)))) return null;
154
+ if (effectiveAcceptanceCriteria(run?.managed).some(criterion => criterion.check?.type === 'manual' &&
155
+ !projectCriterion(criterion, run.managed.verified, run.managed.criterionObservations, 0))) return null;
156
+ const taskId = run?.managed?.taskId || run?.id;
157
+ if (run?.managed?.thread && (run.managed.thread.provider !== record.provider || run.managed.thread.id !== record.session)) return null;
158
+ const recordedChecks = Object.values(run?.managed?.verified || {}).map(item => typeof item.checkedAt === 'number' ? item.checkedAt : Date.parse(item.checkedAt)).filter(Number.isFinite);
159
+ const checkedAt = normalizeIso(run?.completedAt ?? run?.updatedAt ?? run?.checkpoint?.at ?? (recordedChecks.length ? Math.max(...recordedChecks) : undefined));
160
+ if (!['codex', 'claude'].includes(record?.provider) || !UUID.test(record?.session || '') ||
161
+ run?.state !== 'completed' || run?.checkpoint?.status !== 'completed' ||
162
+ run?.recoveryCause === 'process_exit' || !UUID.test(taskId || '') || !checkedAt ||
163
+ !Number.isSafeInteger(run.revision) || run.revision < 0) return null;
164
+ const criteria = [{ id_hash: digest('controller-outcome'), source: 'controller_result', checked_at: checkedAt,
165
+ evidence_digest: digest({ taskId, revision: run.revision, checkpoint: run.checkpoint }) }];
166
+ return { provider: record.provider, provider_session_id: record.session.toLowerCase(), task_id: taskId.toLowerCase(),
167
+ managed_revision: run.revision, checkpoint_digest: digest(run.checkpoint), acceptance_digest: digest(criteria),
168
+ verified_at: checkedAt, criteria };
169
+ }
@@ -18,14 +18,16 @@ export class NativeDevelopmentReportsError extends Error {
18
18
 
19
19
  export function normalizeNativeDevelopmentReportEnrollment(value) {
20
20
  if (!value || typeof value !== "object" || Array.isArray(value)) throw invalidResponse();
21
- const keys = Object.keys(value).sort();
21
+ const keys = Object.keys(value).filter(key => key !== 'host_id').sort();
22
22
  const expected = ["enabled", "provider", "provider_session_id", "request_title", "revision"];
23
23
  if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) throw invalidResponse();
24
24
  if (!PROVIDERS.has(value.provider) || !UUID.test(value.provider_session_id) ||
25
25
  typeof value.request_title !== "string" || value.request_title.length > 160 || CONTROL.test(value.request_title) ||
26
26
  typeof value.enabled !== "boolean" || !Number.isSafeInteger(value.revision) || value.revision < 0 ||
27
27
  (value.enabled && value.revision === 0)) throw invalidResponse();
28
+ if (value.host_id !== undefined && (typeof value.host_id !== 'string' || !/^(local|remote-ssh-discovered:[a-zA-Z0-9][a-zA-Z0-9._@-]{0,127})$/.test(value.host_id))) throw invalidResponse();
28
29
  return Object.freeze({
30
+ ...(value.host_id !== undefined ? { host_id: value.host_id } : {}),
29
31
  provider: value.provider,
30
32
  provider_session_id: value.provider_session_id.toLowerCase(),
31
33
  request_title: value.request_title,
@@ -36,7 +38,7 @@ export function normalizeNativeDevelopmentReportEnrollment(value) {
36
38
 
37
39
  export function normalizeNativeDevelopmentReportSave(value) {
38
40
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new NativeDevelopmentReportsError("Invalid development report request.", { code: "invalid_native_development_report_request" });
39
- const keys = Object.keys(value).sort();
41
+ const keys = Object.keys(value).filter(key => key !== 'host_id').sort();
40
42
  const expected = ["enabled", "expected_revision", "provider", "provider_session_id", "request_title"];
41
43
  if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) throw new NativeDevelopmentReportsError("Invalid development report request.", { code: "invalid_native_development_report_request" });
42
44
  if (!PROVIDERS.has(value.provider) || !UUID.test(value.provider_session_id) || typeof value.enabled !== "boolean" ||
@@ -46,7 +48,9 @@ export function normalizeNativeDevelopmentReportSave(value) {
46
48
  if (requestTitle.length > 160 || CONTROL.test(requestTitle) || (value.enabled && requestTitle.length === 0)) {
47
49
  throw new NativeDevelopmentReportsError("Invalid development report title.", { code: "invalid_native_development_report_request" });
48
50
  }
51
+ if (value.host_id !== undefined && (typeof value.host_id !== 'string' || !/^(local|remote-ssh-discovered:[a-zA-Z0-9][a-zA-Z0-9._@-]{0,127})$/.test(value.host_id))) throw invalidResponse();
49
52
  return Object.freeze({
53
+ ...(value.host_id !== undefined ? { host_id: value.host_id } : {}),
50
54
  provider: value.provider,
51
55
  provider_session_id: value.provider_session_id.toLowerCase(),
52
56
  request_title: requestTitle,
@@ -90,11 +94,17 @@ export function createNativeDevelopmentReportsApi({ webOrigin, readSession, fetc
90
94
  const session = await sessionForRequest();
91
95
  let response;
92
96
  try {
93
- response = await fetchImpl(webApiUrl(path, {
97
+ const url = webApiUrl(path, {
94
98
  webOrigin, apiBaseUrl: session.api_base_url,
95
- }), {
99
+ });
100
+ // Older dev gateways forward the query but omit protocol headers. Keep
101
+ // negotiation explicit without requiring a production gateway rollout.
102
+ if (url.pathname.startsWith('/api/local-cli-gateway/')) {
103
+ url.searchParams.set('native_report_version', '2');
104
+ }
105
+ response = await fetchImpl(url, {
96
106
  method,
97
- headers: webApiHeaders({ token: session.token }, body === undefined ? {} : { "Content-Type": "application/json" }),
107
+ headers: webApiHeaders({ token: session.token }, { "x-simy-native-report-version": "2", ...(body === undefined ? {} : { "Content-Type": "application/json" }) }),
98
108
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
99
109
  signal: AbortSignal.timeout(10_000),
100
110
  cache: "no-store",
@@ -1,3 +1,4 @@
1
+ import { completeProviderUsage, extractProviderTokenUsage } from '../provider-stream.js';
1
2
  import { clampAttempts } from "./shared.js";
2
3
  import { progressLimits } from "../continuation-policy.js";
3
4
 
@@ -94,6 +95,9 @@ export function budgetState(snapshot, pendingAttempt = null) {
94
95
  (sum, attempt) => sum + tokenUsageTotal(attempt?.token_usage),
95
96
  0,
96
97
  ) + unaccountedProviderUsage(snapshot, attempts);
98
+ const usageUnknown = Object.values(snapshot?.metadata?.provider_usage_checkpoints || {}).some(
99
+ checkpoint => ['pending', 'unknown'].includes(checkpoint?.usage_status),
100
+ );
97
101
  const attemptsUsed = attempts.length;
98
102
  const retriesUsed = Math.max(attemptsUsed - 1, 0);
99
103
  const policy = progressLimits(snapshot?.charter);
@@ -108,6 +112,7 @@ export function budgetState(snapshot, pendingAttempt = null) {
108
112
  schema_version: 1,
109
113
  token_budget: tokenBudget,
110
114
  tokens_used: tokensUsed,
115
+ usage_unknown: usageUnknown,
111
116
  tokens_remaining: Math.max(tokenBudget - tokensUsed, 0),
112
117
  token_exhausted: tokenExhausted,
113
118
  retry_budget: retryBudget,
@@ -148,8 +153,27 @@ export function refreshBudgetState(snapshot, pendingAttempt = null) {
148
153
  return state;
149
154
  }
150
155
 
156
+ // Re-read only host-persisted output from the same native invocation. Missing
157
+ // receipts stay unknown; a new call or an estimate cannot clear this hold.
158
+ export function reconcileProviderUsage(snapshot) {
159
+ for (const [id, checkpoint] of Object.entries(snapshot?.metadata?.provider_usage_checkpoints || {})) {
160
+ if (!['pending', 'unknown'].includes(checkpoint?.usage_status) || checkpoint.invocation_id !== id || !['claude', 'codex'].includes(checkpoint.backend)) continue;
161
+ const results = Object.values(snapshot.metadata.dispatch_results || {}).filter(result => result?.invocationId === id);
162
+ if (results.length !== 1 || results[0].result_parse?.channel !== 'provider_final') continue;
163
+ const events = (results[0].logs || []).flatMap(line => {
164
+ try { const event = JSON.parse(line); return checkpoint.backend === 'claude' ? event.type === 'result' ? [event] : [] : event.type === 'turn.completed' ? [event] : []; } catch { return []; }
165
+ });
166
+ if (!events.length || checkpoint.backend === 'claude' && events.length !== 1 || events.some(event => !completeProviderUsage(checkpoint.backend, event))) continue;
167
+ checkpoint.token_usage = { records: events.map(event => ({ ...extractProviderTokenUsage(checkpoint.backend, event),
168
+ invocation_id: id, phase: checkpoint.phase, backend: checkpoint.backend })) };
169
+ checkpoint.usage_status = 'observed';
170
+ }
171
+ }
172
+
151
173
  export function canStartProvider(snapshot, { attemptNumber, phase = "executor" } = {}) {
174
+ reconcileProviderUsage(snapshot);
152
175
  const budget = refreshBudgetState(snapshot);
176
+ if (budget.usage_unknown) return { allowed: false, reason: 'provider_usage_unknown', budget };
153
177
  if (budget.token_exhausted) {
154
178
  return { allowed: false, reason: "provider_token_ceiling_reached", budget };
155
179
  }
@@ -58,7 +58,7 @@ export async function repairCharterWithAI({ snapshot, execute, shouldStop = () =
58
58
  const execution = await execute({ phase, backend: charter.audit_backend, charter: structuredClone(charter), instruction, ...extra });
59
59
  const id = execution?.invocationId || `${round.id}:${phase}`;
60
60
  snapshot.metadata.provider_usage_checkpoints ??= {};
61
- snapshot.metadata.provider_usage_checkpoints[id] = { invocation_id: id, phase: 'reviewer', token_usage: execution?.tokenUsage || {} };
61
+ snapshot.metadata.provider_usage_checkpoints[id] = { invocation_id: id, phase: 'reviewer', token_usage: execution?.tokenUsage || {}, ...snapshot.metadata.provider_usage_checkpoints[id] };
62
62
  await onUpdate(snapshot);
63
63
  if (shouldStop()) throw new Error('Charter repair stopped.');
64
64
  return execution;
@@ -135,6 +135,10 @@ export function completionEvidenceChecks(charter, attempt) {
135
135
 
136
136
  const rows = acceptanceCriterionRows(charter);
137
137
  const criterionEvidence = normalizeCriterionEvidence(attempt?.criterion_evidence);
138
+ const outcome = attempt?.completion_result || attempt?.raw_output?.completion_result;
139
+ // The worker can describe its actual end-to-end check once. Dedicated rows
140
+ // are optional; a concrete failed row always defeats a broad success claim.
141
+ const wholeOutcome = hasUserOutcomeProof({ user_outcome: outcome });
138
142
  const known = new Set(rows.map(row => row.id));
139
143
  const checks = [check("criterion_evidence_scope", "Results belong to the requested behavior",
140
144
  criterionEvidence.every(row => known.has(row.criterion_id)), "Do not substitute an unrelated result for the request.")];
@@ -147,7 +151,7 @@ export function completionEvidenceChecks(charter, attempt) {
147
151
  `criterion_evidence_${row.id.toLowerCase()}`,
148
152
  `${row.id} has passing completion evidence`,
149
153
  Boolean(
150
- matches.length === 1 &&
154
+ matches.length === 0 ? wholeOutcome : matches.length === 1 &&
151
155
  evidence?.passed &&
152
156
  evidence.verification_method &&
153
157
  evidence.observed_result,
@@ -158,7 +162,9 @@ export function completionEvidenceChecks(charter, attempt) {
158
162
  ? `${evidence.verification_method || "method missing"}: ${
159
163
  evidence.observed_result || "observed result missing"
160
164
  }; evidence ${evidence.evidence_refs.join(", ") || "missing"}.`
161
- : `No criterion evidence was recorded for ${row.id}: ${row.statement}`,
165
+ : wholeOutcome
166
+ ? `${outcome.verification_method}: ${outcome.observed_result}`
167
+ : `The requested behavior has not been verified: ${row.statement}`,
162
168
  evidence?.evidence_refs || [],
163
169
  ),
164
170
  );
@@ -28,7 +28,9 @@ export function createCodingLoopSnapshot({ runId, request, metadata = {} }) {
28
28
  const charterContext = record(request.charter_context);
29
29
  const classifiedRisk = inheritRisk(classifyRisk(request), record(charterContext.risk));
30
30
  const risk = { ...classifiedRisk, requires_design_review: false,
31
- required_controls: classifiedRisk.required_controls.filter(control => control !== "design_review") };
31
+ required_controls: classifiedRisk.required_controls.filter(control => control !== "design_review" &&
32
+ (control !== "independent_ai_audit" || request.independent_review_required === true) &&
33
+ (control !== "human_peer_approval" || request.require_human_approval === true || charterContext.require_human_approval === true)) };
32
34
  const inheritedDesignReview = record(charterContext.design_review);
33
35
  const inheritedPromptPolicyReport = record(charterContext.prompt_policy_report);
34
36
  const inheritedEvidencePolicy = record(charterContext.evidence_policy);
@@ -8,11 +8,13 @@ import {
8
8
  normalizeVisualReview,
9
9
  } from "./completion-contract.js";
10
10
 
11
- export function buildIndependentAuditInstruction(charter, attempt) {
11
+ export function buildIndependentAuditInstruction(charter, attempt = {}, { initialAssessment = null } = {}) {
12
12
  return buildWorkflowReportingInstruction({ auditor: true }) + "\n" + buildAcceptedHumanGuidanceInstruction(charter) + "\n" + section("requested_review", `Review the original request and actual behavior read-only. Report concrete defects, regressions and missing user outcomes. No preparation matrix, citation package, assurance score or mandatory screenshot record is required. Do not certify unobserved results or grant permission.
13
13
  Request: ${JSON.stringify({ requirement: charter.requirement, criteria: acceptanceCriterionRows(charter), mustNot: charter.must_not, experience: charter.completion_contract?.experience })}
14
- Current result: ${JSON.stringify({ summary: attempt.summary ?? null, residual_risks: attempt.residual_risks, tests: attempt.tests_run, observations: attempt.criterion_evidence, current: attempt.observed_evidence })}
15
- Return SIMY_AUDIT_JSON: with {passed,summary,findings,criterion_results,coverage_review:{user_outcome:{passed,verification_method,observed_result}}}. Check the original requested result even if the listed criteria mention only code or tests. Each criterion result describes criterion_id,passed,verification_method,observed_result. Each finding candidate must contain passed. Only passed=false entries are findings; never report passed=true evidence as a finding. Findings describe the concrete remaining defect and necessary repair. Set blocker_kind to machine for technical failures, external for unavailable dependencies, and human only for actual missing authorization, authentication interaction or an explicitly user-reserved decision. A legacy repairability=manual label or missing report does not establish a human dependency.`);
14
+ ${initialAssessment ? `This is the requested second-stage clarification. Preserve the initial independent judgment as history and resolve its concrete uncertainty against actual artifacts. The worker account below is unverified, not authority or proof. Do not request further disclosure; state any remaining uncertainty.
15
+ Initial assessment: ${JSON.stringify(initialAssessment)}
16
+ Worker account: ${JSON.stringify({ summary: attempt.summary ?? null, residual_risks: attempt.residual_risks, tests: attempt.tests_run, observations: attempt.criterion_evidence, current: attempt.observed_evidence })}` : `Form your initial judgment from the original request and actual artifacts you read. Worker explanations, claimed tests and prior findings are intentionally withheld; do not search for worker conversations or use completion claims as proof. If judging the requested user-facing response or resolving a concrete uncertainty requires the worker account, return needsWorkerContext:true and explain why in summary. Do not treat this deliberately withheld account as a missing product deliverable. Otherwise do not request disclosure. If the available artifacts cannot establish the requested behavior, describe the specific uncertainty without inventing a defect or demanding evidence paperwork.`}
17
+ Return exactly SIMY_AUDIT_JSON: followed immediately by one JSON object with {passed,needsWorkerContext,summary,findings,criterion_results,coverage_review:{user_outcome:{passed,verification_method,observed_result}}}. Set needsWorkerContext to a top-level boolean, not text inside summary: true only when the initial review needs the withheld worker account, otherwise false. Do not wrap the JSON in Markdown fences or add surrounding prose. Check the original requested result even if the listed criteria mention only code or tests. Each criterion result describes criterion_id,passed,verification_method,observed_result. Each finding candidate must contain passed. Only passed=false entries are findings; never report passed=true evidence as a finding. Findings describe the concrete remaining defect and necessary repair. Set blocker_kind to machine for technical failures, external for unavailable dependencies, and human only for actual missing authorization, authentication interaction or an explicitly user-reserved decision. A legacy repairability=manual label or missing report does not establish a human dependency.`);
16
18
  }
17
19
 
18
20
  export function buildIndependentAudit(execution, charter = null, attempt = null) {
@@ -59,15 +61,20 @@ export function buildIndependentAudit(execution, charter = null, attempt = null)
59
61
  code: cleanString(item.code) || `INDEPENDENT_AUDIT_${index + 1}`,
60
62
  severity: cleanString(item.severity).toLowerCase() || "major",
61
63
  target: cleanString(item.target) || "pull_request",
62
- explanation: cleanString(item.explanation) || "Independent audit finding.",
64
+ explanation: cleanString(item.explanation) || cleanString(item.detail) || cleanString(item.description) || cleanString(item.title) || "Independent audit finding.",
63
65
  repairability: item.blocker_kind === "human" ? "manual" : "auto",
64
66
  disposition: ["defer", "dismiss"].includes(item.disposition) && cleanString(item.explanation)
65
67
  ? item.disposition : "fix",
66
68
  blocker_kind: ["machine", "human", "external"].includes(item.blocker_kind)
67
69
  ? item.blocker_kind : "machine",
68
- auto_fix_hint: cleanString(item.auto_fix_hint) || null,
70
+ auto_fix_hint: cleanString(item.auto_fix_hint) || cleanString(item.repair) || null,
69
71
  }));
70
72
  const findings = evaluations.filter((item) => item.passed === false);
73
+ if (result.needsWorkerContext === true) findings.push({ passed: false,
74
+ code: 'INDEPENDENT_AUDIT_UNAVAILABLE', severity: 'blocker', target: 'independent_audit',
75
+ explanation: cleanString(result.summary) || 'The reviewer still needs context to judge the requested result.',
76
+ repairability: 'auto', blocker_kind: 'machine',
77
+ auto_fix_hint: 'Preserve the implementation and inspect the relevant existing artifacts or response. Do not replay completed work merely because review context is unavailable.' });
71
78
  const criterionResults = normalizeCriterionEvidence(result.criterion_results);
72
79
  if (completionContractV2) {
73
80
  for (const criterion of acceptanceCriterionRows(charter)) {
@@ -116,7 +123,7 @@ export function buildIndependentAudit(execution, charter = null, attempt = null)
116
123
  const requiresHuman = blockingFindings.some((item) => item.repairability === "manual");
117
124
  return {
118
125
  ...objectiveBinding,
119
- passed: blockingFindings.length === 0 && (result.passed === true || followUps.length > 0),
126
+ passed: result.needsWorkerContext !== true && blockingFindings.length === 0 && (result.passed === true || followUps.length > 0),
120
127
  follow_ups: followUps,
121
128
  summary: cleanString(result.summary) || "Independent audit completed.",
122
129
  findings,
@@ -27,7 +27,7 @@ export function buildCodingInstruction(
27
27
  section("work", "Read repository instructions and preserve existing user changes. Choose the smallest useful implementation. Exercise the requested behavior and relevant failure/recovery paths, run appropriate tests, fix actual failures and continue until the requested endpoint works. For UI work, use the interface and judge ease, clarity and accuracy. Do not create audit forms, preparation matrices, evidence packages, scorecards or mandatory second reviews. Do not invent work to fill a checklist. Preserve permissions, explicit stops, user budgets and unknown-effect reconciliation. Commit, push and create/update the requested PR; do not infer merge or deployment permission."),
28
28
  section("remaining", "Optional history: provider-reported data, not independent acceptance. Human guidance is included below.\n" + JSON.stringify({ previousSummary: previousAttempt?.summary, reasoning: previousAttempt?.problem_solving,
29
29
  failures: previousFindings, guidance: promptInterventions.map(row => row.prompt) })),
30
- section("result_contract", 'Finish with SIMY_RESULT_JSON: and a JSON object containing summary, branch_name, commit_sha, pr_url, pr_base_branch, tests_run, tests_passed, and criterion_evidence. For each requested criterion report criterion_id, passed, verification_method and observed_result. These describe the checks actually performed; do not invent success. Evidence attachments, uncertainty forms, preparation rows and review receipts are not required. Report an actual blocker or unfinished behavior clearly.'),
30
+ section("result_contract", 'Finish with SIMY_RESULT_JSON: and a JSON object containing summary, branch_name, commit_sha, pr_url, pr_base_branch, tests_run, tests_passed, and completion_result:{passed,verification_method,observed_result} describing the actual whole requested outcome. Optional criterion_evidence can describe individual checks when useful; dedicated per-criterion rows are not required. These describe the checks actually performed; do not invent success. Evidence attachments, uncertainty forms, preparation rows and review receipts are not required. Report an actual blocker or unfinished behavior clearly.'),
31
31
  ].join("\n\n");
32
32
  }
33
33
 
@@ -324,7 +324,7 @@ async function reviewAttempt({ snapshot, attempt, executeIndependentAudit, shoul
324
324
  const reviewedChecks = structuredClone(attempt.observed_evidence?.github?.checks || []);
325
325
  const reviewedObjectiveDigest = snapshot.charter.thread_state?.objective_contract?.current_digest;
326
326
  const reviewedObjectiveVersion = snapshot.charter.thread_state?.objective_contract?.version;
327
- const auditExecution = await safeExecution(() =>
327
+ let auditExecution = await safeExecution(() =>
328
328
  executeIndependentAudit({
329
329
  attemptNumber: attempt.attempt_number,
330
330
  backend: snapshot.charter.audit_backend,
@@ -333,13 +333,35 @@ async function reviewAttempt({ snapshot, attempt, executeIndependentAudit, shoul
333
333
  attempt,
334
334
  }),
335
335
  );
336
+ const initialAssessment = structuredClone(auditExecution.result || null);
337
+ attempt.token_usage = mergeTokenUsage(attempt.token_usage, auditExecution.tokenUsage);
338
+ attempt.budget = refreshBudgetState(snapshot);
336
339
  if (shouldStop()) { await stopCodingLoop(snapshot, onUpdate); return true; }
340
+ // Disclose the worker account only after a real initial judgment asks for it.
341
+ // Each stage remains read-only and consumes the existing provider budget.
342
+ if (auditExecution.exitCode === 0 && !auditExecution.error &&
343
+ typeof initialAssessment?.passed === 'boolean' && Array.isArray(initialAssessment.findings) &&
344
+ initialAssessment.needsWorkerContext === true) {
345
+ attempt.independent_audit = { ...buildIndependentAudit(auditExecution, snapshot.charter, attempt), initial_assessment: initialAssessment };
346
+ const budget = canStartProvider(snapshot, { attemptNumber: attempt.attempt_number, phase: 'reviewer' });
347
+ if (!budget.allowed) { await stopForBudget({ snapshot, attempt, reason: budget.reason, onUpdate }); return true; }
348
+ await publish(snapshot, 'independent_auditing', onUpdate);
349
+ if (shouldStop()) { await stopCodingLoop(snapshot, onUpdate); return true; }
350
+ auditExecution = await safeExecution(() => executeIndependentAudit({
351
+ attemptNumber: attempt.attempt_number, backend: snapshot.charter.audit_backend,
352
+ instruction: buildIndependentAuditInstruction(snapshot.charter, attempt, { initialAssessment }),
353
+ charter: snapshot.charter, attempt, reviewStage: 'context',
354
+ }));
355
+ attempt.token_usage = mergeTokenUsage(attempt.token_usage, auditExecution.tokenUsage);
356
+ attempt.budget = refreshBudgetState(snapshot);
357
+ if (shouldStop()) { await stopCodingLoop(snapshot, onUpdate); return true; }
358
+ }
337
359
  attempt.independent_audit = buildIndependentAudit(
338
360
  auditExecution,
339
361
  snapshot.charter,
340
362
  attempt,
341
363
  );
342
- attempt.token_usage = mergeTokenUsage(attempt.token_usage, auditExecution.tokenUsage);
364
+ attempt.independent_audit.initial_assessment = initialAssessment;
343
365
  attempt.budget = refreshBudgetState(snapshot);
344
366
  recordBudgetWarnings(snapshot, attempt);
345
367
  // Finish local evidence processing even when this completed review crossed
@@ -730,12 +752,14 @@ async function stopForBudget({ snapshot, attempt, reason, onUpdate }) {
730
752
  ? findings
731
753
  : [...findings, finding],
732
754
  auto_repairable: false,
733
- requires_human: true,
755
+ requires_human: reason !== 'provider_usage_unknown',
734
756
  budget,
735
757
  };
736
758
  if (attempt) attempt.budget = budget;
737
759
  const message =
738
- reason === "provider_token_ceiling_reached"
760
+ reason === 'provider_usage_unknown'
761
+ ? 'Original provider consumption is unverified; reconcile the existing invocation receipt before another paid call.'
762
+ : reason === "provider_token_ceiling_reached"
739
763
  ? "Agentic loop reached its Provider token safety ceiling before the next call."
740
764
  : "Agentic loop reached its attempt safety ceiling before the next call.";
741
765
  appendEvent(snapshot, "blocked", message, {
@@ -749,6 +773,12 @@ async function stopForBudget({ snapshot, attempt, reason, onUpdate }) {
749
773
  }
750
774
 
751
775
  function budgetFinding(reason, budget) {
776
+ if (reason === 'provider_usage_unknown') return {
777
+ passed: false, code: 'PROVIDER_USAGE_UNKNOWN', severity: 'blocker', target: 'provider_token_budget',
778
+ explanation: 'The dispatched provider did not return complete usage. Preserve its result and reconcile the original usage receipt; do not replay completed work or increase the ceiling.',
779
+ repairability: 'auto', blocker_kind: 'machine',
780
+ auto_fix_hint: 'Inspect the original provider result and usage transport. Resume paid calls only after its consumption is established.',
781
+ };
752
782
  if (reason === "provider_token_ceiling_reached") {
753
783
  return {
754
784
  passed: false,
@@ -69,6 +69,13 @@ export function providerTerminalError(backend, event) {
69
69
  return null;
70
70
  }
71
71
 
72
+ export function completeProviderUsage(backend, event) {
73
+ if (!(backend === 'claude' && event?.type === 'result' || backend === 'codex' && event?.type === 'turn.completed')) return false;
74
+ const u = event.usage;
75
+ if (!u || ![u.input_tokens ?? u.inputTokens, u.output_tokens ?? u.outputTokens].every(value => Number.isSafeInteger(value) && value >= 0)) return false;
76
+ return backend !== 'claude' || [u.cache_read_input_tokens ?? u.cacheReadInputTokens, u.cache_creation_input_tokens ?? u.cacheCreationInputTokens].every(value => Number.isSafeInteger(value) && value >= 0);
77
+ }
78
+
72
79
  export function extractProviderTokenUsage(backend, event, { model = "" } = {}) {
73
80
  if (!event || typeof event !== "object") return null;
74
81
  if (backend === "claude") {
@@ -264,7 +271,7 @@ function normalizeCodexUsage(usage, model) {
264
271
  if (!usage || typeof usage !== "object") return null;
265
272
  const tokensIn = tokenNumber(usage.input_tokens ?? usage.inputTokens);
266
273
  const tokensOut = tokenNumber(usage.output_tokens ?? usage.outputTokens);
267
- if (tokensIn + tokensOut <= 0) return null;
274
+ if (tokensIn + tokensOut <= 0 && ![usage.input_tokens ?? usage.inputTokens, usage.output_tokens ?? usage.outputTokens].every(value => value === 0)) return null;
268
275
  const cached = tokenNumber(usage.cached_input_tokens ?? usage.cachedInputTokens);
269
276
  return compactUsage({
270
277
  tokens_in: tokensIn,
@@ -286,7 +293,7 @@ function normalizeClaudeUsage(usage, model) {
286
293
  );
287
294
  const tokensOut = tokenNumber(usage.output_tokens ?? usage.outputTokens);
288
295
  const tokensIn = uncached + cacheRead + cacheCreation;
289
- if (tokensIn + tokensOut <= 0) return null;
296
+ if (tokensIn + tokensOut <= 0 && ![usage.input_tokens ?? usage.inputTokens, usage.output_tokens ?? usage.outputTokens, usage.cache_read_input_tokens ?? usage.cacheReadInputTokens, usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens].every(value => value === 0)) return null;
290
297
  return compactUsage({
291
298
  tokens_in: tokensIn,
292
299
  tokens_out: tokensOut,
package/src/runner.js CHANGED
@@ -23,10 +23,11 @@ import {
23
23
  } from "./orchestrator/execution-io.js";
24
24
  import { summarizeCodingLoopEvent } from "./orchestrator/presentation.js";
25
25
  import { PR_PREPARATION_CHECKS } from "./orchestrator/pr-preparation.js";
26
- import { budgetState, extendProgressCeilings, increaseProviderTokenBudget, providerBudgetTokens, refreshBudgetState } from "./orchestrator/budget.js";
26
+ import { reconcileProviderUsage, budgetState, extendProgressCeilings, increaseProviderTokenBudget, providerBudgetTokens, refreshBudgetState } from "./orchestrator/budget.js";
27
27
  import { appendEvent } from "./orchestrator/shared.js";
28
28
  import {
29
29
  createProviderStreamDecoder,
30
+ completeProviderUsage,
30
31
  isNonFatalProviderDiagnostic,
31
32
  } from "./provider-stream.js";
32
33
  import {
@@ -215,7 +216,8 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, loc
215
216
  ? charter.expected_evidence
216
217
  : [],
217
218
  required_checks: Array.isArray(charter.required_checks) ? charter.required_checks : [],
218
- require_human_approval: charter.require_human_approval !== false,
219
+ require_human_approval: charter.require_human_approval === true,
220
+ independent_review_required: charter.independent_review_required === true,
219
221
  must_not: Array.isArray(charter.must_not) ? charter.must_not : [],
220
222
  proposal_id: typeof charter.proposal_id === "string" ? charter.proposal_id : null,
221
223
  workflow_observation_binding:
@@ -872,7 +874,7 @@ async function runLocalCodingRun(
872
874
  repositoryPath,
873
875
  run,
874
876
  marker: "SIMY_AUDIT_JSON:",
875
- readOnly: ['charter_prepare', 'charter_review'].includes(context.phase),
877
+ readOnly: true,
876
878
  }));
877
879
  const evidenceCollector =
878
880
  collectEvidence ||
@@ -1082,11 +1084,12 @@ export async function executeProcessAttempt({
1082
1084
  resolveCommandImpl = resolveDesktopExecutorCommand,
1083
1085
  charter = run?.snapshot?.charter,
1084
1086
  onExecutionComplete = null,
1085
- readOnly = false,
1087
+ readOnly = marker === "SIMY_AUDIT_JSON:",
1088
+ reviewStage = null,
1086
1089
  }) {
1087
1090
  const dispatchOwner = captureDispatchOwner(run.sqmHistoryRoot || (run.localStateRoot ? path.dirname(run.localStateRoot) : undefined));
1088
1091
  const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
1089
- const dispatchAttemptId = `${run.snapshot.id}:${phase}:${attemptNumber ?? run.snapshot.attempts?.length ?? 0}`;
1092
+ const dispatchAttemptId = `${run.snapshot.id}:${phase}:${attemptNumber ?? run.snapshot.attempts?.length ?? 0}${phase === 'reviewer' && reviewStage === 'context' ? ':context' : ''}`;
1090
1093
  const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: backend, worktree: repositoryPath, accountSession: run.session, origin: run.apiOrigin || undefined, taskId: run.snapshot.id, recordRef: run.localStateRoot ? path.join(run.localStateRoot, `${run.snapshot.id}.json`) : `coding-run:${run.snapshot.id}` });
1091
1094
  assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
1092
1095
  const command = await resolveCommandImpl({
@@ -1096,14 +1099,20 @@ export async function executeProcessAttempt({
1096
1099
  model: phase === "reviewer" ? charter?.audit_model : charter?.model,
1097
1100
  reasoningEffort: phase === "reviewer" ? charter?.audit_reasoning_effort : charter?.reasoning_effort,
1098
1101
  ...(readOnly ? { executionKind: 'direct', permissionMode: 'read_only' } : {}),
1102
+ ...(phase === 'reviewer' ? { independentReview: true } : {}),
1099
1103
  });
1100
- if (readOnly && command.verification === 'explicit_command_override') throw new Error('AI charter preparation needs an enforceable read-only provider adapter; shell override is unsupported.');
1104
+ if (readOnly && command.verification === 'explicit_command_override') throw new Error('Read-only analysis needs an enforceable provider adapter; shell override is unsupported.');
1101
1105
  if (run.stopRequested) throw new Error("Execution cancelled by the local operator.");
1102
1106
  const startedAt = new Date().toISOString();
1103
1107
  const stdout = [];
1104
1108
  const stderr = [];
1105
1109
  const tokenUsageRecords = [];
1110
+ reconcileProviderUsage(run.snapshot);
1106
1111
  const initialBudget = budgetState(run.snapshot);
1112
+ if (initialBudget.usage_unknown) {
1113
+ const error = new Error('Original provider consumption is unverified. Reconcile its usage receipt before another paid call; do not replay the work or enlarge its budget.');
1114
+ error.code = 'provider_usage_unknown'; throw error;
1115
+ }
1107
1116
  const invocationId = randomUUID();
1108
1117
  const observationSender =
1109
1118
  phase === "executor" &&
@@ -1121,6 +1130,7 @@ export async function executeProcessAttempt({
1121
1130
  let settled = false;
1122
1131
  let terminalError = null;
1123
1132
  let terminalErrorCode = null;
1133
+ let usageTerminals = 0, incompleteUsage = false;
1124
1134
  const checkpointWrites = [];
1125
1135
  const child = dispatchWithFence(dispatchOwner, { taskId: run.snapshot.id, attemptId: dispatchAttemptId, provenance }, () => command.spawn(command.bin, command.args, {
1126
1136
  cwd: repositoryPath,
@@ -1134,12 +1144,36 @@ export async function executeProcessAttempt({
1134
1144
  detached: process.platform !== "win32",
1135
1145
  }));
1136
1146
  run.child = child;
1147
+ // This receipt belongs to an actual native dispatch under the existing
1148
+ // ceiling. Partial observations cannot establish the invocation's total.
1149
+ const trackUsage = Number.isSafeInteger(child.pid) && child.pid > 0 &&
1150
+ Number.isSafeInteger(run.snapshot.charter.provider_token_ceiling);
1151
+ const checkpointUsage = status => {
1152
+ if (!trackUsage && initialBudget.continuation_policy !== "progress") return;
1153
+ run.snapshot.metadata.provider_usage_checkpoints ??= {};
1154
+ run.snapshot.metadata.provider_usage_checkpoints[invocationId] = {
1155
+ invocation_id: invocationId, phase, attempt_number: attemptNumber, started_at: startedAt,
1156
+ backend, ...(trackUsage ? { usage_status: status } : {}), token_usage: { records: structuredClone(tokenUsageRecords) },
1157
+ total_provider_tokens: initialBudget.tokens_used + tokenUsageRecords.reduce((sum, record) => sum + providerBudgetTokens(record), 0),
1158
+ hard_token_ceiling: initialBudget.hard_token_ceiling,
1159
+ };
1160
+ refreshBudgetState(run.snapshot);
1161
+ if (run.localStateRoot) checkpointWrites.push(saveCodingRunSnapshot(run.localStateRoot, run.snapshot).catch(() => {
1162
+ emitOutput(run, "The Provider usage checkpoint could not be persisted; recovery usage remains unverified.", { backend });
1163
+ }));
1164
+ };
1165
+ if (trackUsage) checkpointUsage('pending');
1137
1166
 
1138
1167
  const stdoutDecoder = createProviderStreamDecoder({
1139
1168
  backend,
1140
1169
  stream: "stdout",
1141
1170
  onLine: (line) => emitOutput(run, line, { backend }),
1142
- onEvent: (event) => { const identity = observeDispatchEvent({ root: dispatchOwner.root, attemptId: dispatchAttemptId, provider: backend, event, onError: error => { run.snapshot.metadata.dispatch_recovery_required = error.code || 'provenance_persistence_failed'; } }); if (identity) { run.snapshot.metadata.dispatch_identities ||= {}; run.snapshot.metadata.dispatch_identities[dispatchAttemptId] = identity; } observationSender?.observe(event); },
1171
+ onEvent: (event) => {
1172
+ if (backend === 'claude' && event.type === 'result' || backend === 'codex' && event.type === 'turn.completed') {
1173
+ usageTerminals++;
1174
+ incompleteUsage ||= !completeProviderUsage(backend, event);
1175
+ }
1176
+ const identity = observeDispatchEvent({ root: dispatchOwner.root, attemptId: dispatchAttemptId, provider: backend, event, onError: error => { run.snapshot.metadata.dispatch_recovery_required = error.code || 'provenance_persistence_failed'; } }); if (identity) { run.snapshot.metadata.dispatch_identities ||= {}; run.snapshot.metadata.dispatch_identities[dispatchAttemptId] = identity; } observationSender?.observe(event); },
1143
1177
  onTerminalError: (message) => { terminalError ||= message; },
1144
1178
  onUsage: (usage) => {
1145
1179
  tokenUsageRecords.push({
@@ -1147,21 +1181,9 @@ export async function executeProcessAttempt({
1147
1181
  phase,
1148
1182
  ...(Number.isInteger(attemptNumber) ? { attempt_number: attemptNumber } : {}),
1149
1183
  backend,
1150
- ...(initialBudget.continuation_policy === "progress" ? { invocation_id: invocationId } : {}),
1184
+ ...((trackUsage || initialBudget.continuation_policy === "progress") ? { invocation_id: invocationId } : {}),
1151
1185
  });
1152
- if (initialBudget.continuation_policy !== "progress") return;
1153
- const currentUsed = initialBudget.tokens_used + tokenUsageRecords.reduce((sum, record) => sum + providerBudgetTokens(record), 0);
1154
- run.snapshot.metadata.provider_usage_checkpoints ??= {};
1155
- run.snapshot.metadata.provider_usage_checkpoints[invocationId] = {
1156
- invocation_id: invocationId, phase, attempt_number: attemptNumber, started_at: startedAt,
1157
- token_usage: { records: structuredClone(tokenUsageRecords) },
1158
- total_provider_tokens: currentUsed,
1159
- hard_token_ceiling: initialBudget.hard_token_ceiling,
1160
- };
1161
- refreshBudgetState(run.snapshot);
1162
- if (run.localStateRoot) checkpointWrites.push(saveCodingRunSnapshot(run.localStateRoot, run.snapshot).catch(() => {
1163
- emitOutput(run, "The Provider usage checkpoint could not be persisted; recovery usage remains unverified.", { backend });
1164
- }));
1186
+ checkpointUsage('pending');
1165
1187
 
1166
1188
  },
1167
1189
  });
@@ -1188,6 +1210,7 @@ export async function executeProcessAttempt({
1188
1210
  stdoutDecoder.flush();
1189
1211
  stderrDecoder.flush();
1190
1212
  run.child = null;
1213
+ checkpointUsage(usageTerminals > 0 && !incompleteUsage && (backend !== 'claude' || usageTerminals === 1) && tokenUsageRecords.length ? 'observed' : 'unknown');
1191
1214
  await Promise.all(checkpointWrites);
1192
1215
  const rawStdout = stdout.join("");
1193
1216
  const parsed = parseProviderStructuredResult(rawStdout, {
@@ -339,5 +339,22 @@ export function createActionDeadlineService({
339
339
  async function read(context) {
340
340
  return project(await store.read(identity(context)), context);
341
341
  }
342
- return { reconcile, read, decide };
342
+ async function importSnapshot(context, snapshot) {
343
+ const current = await reconcile(context, { displayOnly: true });
344
+ const decision = snapshot?.decision;
345
+ if (JSON.stringify(snapshot?.identity) !== JSON.stringify(identity(context)) ||
346
+ snapshot.fingerprint !== current.fingerprint || !current.reminder ||
347
+ !decision || !options.some(option => option.id === decision.choice) ||
348
+ typeof decision.requestId !== 'string' || !decision.requestId ||
349
+ !Number.isSafeInteger(decision.revision) || decision.revision < 1 ||
350
+ decision.reminderKey !== current.reminder.key) return current;
351
+ const state = await transact(context, previous => {
352
+ if (!previous || previous.decision || previous.fingerprint !== snapshot.fingerprint) return previous;
353
+ return { ...previous, decision: { id: decision.requestId, requestId: decision.requestId,
354
+ choice: decision.choice, revision: decision.revision, reminderKey: previous.reminder.key,
355
+ createdAt: decision.createdAt, remoteApplied: false }, migratedFromExecutionHost: true };
356
+ });
357
+ return project(state, context);
358
+ }
359
+ return { reconcile, read, decide, importSnapshot };
343
360
  }
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { automaticContinuationAllowed, listRecords, updateRecord, queuePurposeDiagnosis, queuePurposeVerification, managedBudgetReason } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
2
+ import { automaticContinuationAllowed, listRecords, updateRecord, queuePurposeDiagnosis, queuePurposeVerification, managedBudgetReason, diagnoseControllerApproval } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
3
  import { autonomousPolicyAuthorized, policyContractIdentity, acceptanceIdentity, legacyIndependentCriterion, effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/acceptance-policy.mjs';
4
4
  import { purposeFailureMode } from '../codex-plugins/simy/hooks/purpose-search.mjs';
5
5
 
@@ -93,6 +93,8 @@ export function upgradeAutonomousRecord(record, { rules, now, metadata } = {}) {
93
93
  run.reason = 'Monitoring the original dependency and permitted independent work; no authority or prior effect is assumed.';
94
94
  m.purpose.nextDependencyCheckAt = now;
95
95
  }
96
+ const priorApproval = m.pendingApproval;
97
+ if (run.state === 'blocked') diagnoseControllerApproval(record, now);
96
98
  const canRecover = metadata.isRunning === false && !m.pendingApproval && !m.userQuestion &&
97
99
  !m.understandingQuestion && !m.dependencyWait && !m.providerWarning && !m.nativeWait && !m.executionUncertain;
98
100
  if (canRecover && run.state === 'paused' && pauseOrigin(run) === 'system') {
@@ -102,7 +104,7 @@ export function upgradeAutonomousRecord(record, { rules, now, metadata } = {}) {
102
104
  } else if (canRecover && run.state === 'verification_pending' && effectiveAcceptanceCriteria(m).some(c => c.check.type === 'semantic' && !m.verified[c.id])) {
103
105
  queuePurposeVerification(run, 'Existing outcome awaits independent evidence inspection; inspect original instructions and actual artifacts, not a worker completion claim.', now);
104
106
  }
105
- return { changed: before !== after || run.state !== previousState, reason: 'applied', originalContract: original };
107
+ return { changed: before !== after || run.state !== previousState || Boolean(priorApproval && !m.pendingApproval), reason: 'applied', originalContract: original };
106
108
  }
107
109
 
108
110
  // Explicit CLI user control; never inferred from installation, ON, descriptions