@awak-app/simy-cli 0.5.71 → 0.5.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +101 -3
  2. package/package.json +2 -2
  3. package/src/agent.js +205 -12
  4. package/src/backend-executable.js +45 -2
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/README.md +22 -0
  7. package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
  8. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +180 -31
  9. package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
  10. package/src/coding-run-recovery.js +88 -0
  11. package/src/coding-run-store.js +2 -2
  12. package/src/continuation-policy.js +2 -0
  13. package/src/delivery-hooks/command.js +11 -5
  14. package/src/delivery-hooks/desktop.js +12 -3
  15. package/src/delivery-hooks/supervisor.js +17 -12
  16. package/src/desktop-host.js +19 -8
  17. package/src/desktop-sqm-settings.js +24 -7
  18. package/src/device-signing-identity.js +65 -0
  19. package/src/diagnostic-log.js +68 -0
  20. package/src/doctor.js +55 -0
  21. package/src/index.js +18 -1
  22. package/src/local-task.js +68 -3
  23. package/src/orchestrator/budget.js +55 -4
  24. package/src/orchestrator/charter-validation.js +33 -0
  25. package/src/orchestrator/completion-contract.js +1 -1
  26. package/src/orchestrator/contract.js +2 -0
  27. package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
  28. package/src/orchestrator/evidence.js +20 -19
  29. package/src/orchestrator/independent-audit.js +9 -0
  30. package/src/orchestrator/instruction.js +6 -1
  31. package/src/orchestrator/loop.js +66 -41
  32. package/src/orchestrator/outcome-monitor.js +95 -44
  33. package/src/orchestrator/outcome-progress.js +2 -0
  34. package/src/orchestrator/recovery.js +3 -0
  35. package/src/purpose-local-proof.js +380 -0
  36. package/src/runner.js +227 -19
  37. package/src/runtime-diagnostics.js +77 -0
  38. package/src/session-supervision/codex-app-server.js +3 -2
  39. package/src/session-supervision/index.js +16 -4
  40. package/src/sqm/bundle-store.js +14 -7
  41. package/src/sqm/claude-plugin.js +2 -0
  42. package/src/sqm/command.js +42 -4
  43. package/src/sqm/diagnostics.js +23 -0
  44. package/src/sqm/evidence-client.js +24 -7
  45. package/src/sqm/history-store.js +21 -5
  46. package/src/sqm/index.js +116 -17
  47. package/src/sqm/proof.js +4 -23
  48. package/src/sqm/report.js +9 -2
  49. package/src/sqm/request-recovery.js +82 -0
  50. package/src/sqm/review-worker.js +97 -15
  51. package/src/sqm/session.js +33 -3
  52. package/src/startup-preflight.js +44 -0
@@ -26,7 +26,7 @@ export async function collectPrEvidence({
26
26
  const baseRefresh = await command(
27
27
  runCommand,
28
28
  "git",
29
- ["fetch", "--no-tags", "origin", charter.base_branch],
29
+ ["fetch", "--no-tags", "origin", charter.base_branch.replace(/^(?:refs\/remotes\/)?origin\//, "")],
30
30
  repositoryPath,
31
31
  );
32
32
  const local = await collectLocalGitEvidence({
@@ -42,7 +42,8 @@ export async function collectPrEvidence({
42
42
  const latest = await collectLocalGitEvidence({ baseBranch: charter.base_branch, repositoryPath, runCommand });
43
43
  latest.base_ref_refreshed = baseRefresh.ok;
44
44
  latest.base_ref_error = baseRefresh.ok ? null : baseRefresh.error;
45
- if (!latest.available || latest.head_sha !== local.head_sha || !latest.working_tree_clean) {
45
+ if (!latest.available || latest.head_sha !== local.head_sha || latest.base_sha !== local.base_sha ||
46
+ latest.base_ref !== local.base_ref || !latest.working_tree_clean) {
46
47
  minimumCompletion.status = "evidence_incomplete";
47
48
  minimumCompletion.errors = ["Checkout changed while collecting minimum evidence; verify again."];
48
49
  }
@@ -90,16 +91,17 @@ function observedEvidenceRef(ref, local, github) {
90
91
  }
91
92
 
92
93
  async function collectLocalGitEvidence({ baseBranch, repositoryPath, runCommand }) {
93
- const baseRef = `origin/${baseBranch}`;
94
- const [head, branch, headline, status, changed, base, mergeBase, baseAncestor] = await Promise.all([
94
+ const base = await resolveBaseRef(runCommand, baseBranch, repositoryPath);
95
+ // Pin all comparisons to the same commit, even if another process moves the ref.
96
+ const baseCommit = base.ok ? base.stdout : null;
97
+ const [head, branch, headline, status, changed, mergeBase, baseAncestor] = await Promise.all([
95
98
  command(runCommand, "git", ["rev-parse", "HEAD"], repositoryPath),
96
99
  command(runCommand, "git", ["branch", "--show-current"], repositoryPath),
97
100
  command(runCommand, "git", ["log", "-1", "--pretty=%s"], repositoryPath),
98
101
  command(runCommand, "git", ["status", "--porcelain"], repositoryPath),
99
- changedFiles(runCommand, baseBranch, repositoryPath),
100
- command(runCommand, "git", ["rev-parse", baseRef], repositoryPath),
101
- command(runCommand, "git", ["merge-base", baseRef, "HEAD"], repositoryPath),
102
- command(runCommand, "git", ["merge-base", "--is-ancestor", baseRef, "HEAD"], repositoryPath),
102
+ base.ok ? command(runCommand, "git", ["diff", "--name-only", "--diff-filter=ACMR", `${baseCommit}...HEAD`], repositoryPath) : base,
103
+ base.ok ? command(runCommand, "git", ["merge-base", baseCommit, "HEAD"], repositoryPath) : base,
104
+ base.ok ? command(runCommand, "git", ["merge-base", "--is-ancestor", baseCommit, "HEAD"], repositoryPath) : base,
103
105
  ]);
104
106
  const statusLines = lines(status.stdout);
105
107
  return {
@@ -110,7 +112,8 @@ async function collectLocalGitEvidence({ baseBranch, repositoryPath, runCommand
110
112
  working_tree_clean: status.ok && statusLines.length === 0,
111
113
  status_lines: statusLines,
112
114
  changed_files: lines(changed.stdout),
113
- base_sha: cleanString(base.stdout) || null,
115
+ base_ref: base.ref || null,
116
+ base_sha: baseCommit,
114
117
  merge_base_sha: cleanString(mergeBase.stdout) || null,
115
118
  base_is_ancestor: baseAncestor.ok,
116
119
  errors: [head, branch, headline, status, changed, base, mergeBase]
@@ -119,18 +122,16 @@ async function collectLocalGitEvidence({ baseBranch, repositoryPath, runCommand
119
122
  };
120
123
  }
121
124
 
122
- async function changedFiles(runCommand, baseBranch, repositoryPath) {
123
- const refs = [`origin/${baseBranch}`, baseBranch];
125
+ async function resolveBaseRef(runCommand, baseBranch, repositoryPath) {
126
+ const refs = baseBranch.startsWith("refs/") ? [baseBranch] :
127
+ baseBranch.startsWith("origin/") ? [`refs/remotes/${baseBranch}`] :
128
+ [`refs/remotes/origin/${baseBranch}`, `refs/heads/${baseBranch}`];
124
129
  for (const ref of refs) {
125
- const result = await command(
126
- runCommand,
127
- "git",
128
- ["diff", "--name-only", "--diff-filter=ACMR", `${ref}...HEAD`],
129
- repositoryPath,
130
- );
131
- if (result.ok) return result;
130
+ const result = await command(runCommand, "git",
131
+ ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], repositoryPath);
132
+ if (result.ok) return { ...result, ref };
132
133
  }
133
- return { ok: false, stdout: "", error: `Could not diff against ${baseBranch}.` };
134
+ return { ok: false, stdout: "", error: `Could not resolve base branch ${baseBranch}.` };
134
135
  }
135
136
 
136
137
  async function collectGitHubEvidence({ charter, attempt, repositoryPath, runCommand }) {
@@ -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);
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);
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,
@@ -555,6 +540,34 @@ async function handleFailedAudit({
555
540
  });
556
541
  return true;
557
542
  }
543
+ if (attempt.outcome_progress?.assessment_unavailable) {
544
+ const progress = attempt.outcome_progress;
545
+ snapshot.final_audit = {
546
+ ...(snapshot.final_audit || {}),
547
+ passed: false,
548
+ summary: "Outcome progress could not be assessed across consecutive attempts.",
549
+ findings: [...(snapshot.final_audit?.findings || []), {
550
+ passed: false,
551
+ code: "OUTCOME_ASSESSMENT_UNAVAILABLE",
552
+ severity: "blocker",
553
+ target: "outcome_monitor",
554
+ explanation: "Two consecutive attempts could not establish an independent outcome assessment. Resolve the recorded verification failures and continue this run; this does not prove the work has stalled.",
555
+ repairability: "manual",
556
+ }],
557
+ auto_repairable: false,
558
+ requires_human: true,
559
+ };
560
+ appendEvent(snapshot, "waiting_human", "Outcome assessment needs recovery before further automatic work.", {
561
+ code: "outcome_assessment_unavailable",
562
+ finding_codes: ["OUTCOME_ASSESSMENT_UNAVAILABLE"],
563
+ attempt_number: attemptNumber,
564
+ consecutive_unassessed_attempts: progress.consecutive_unassessed_attempts,
565
+ unavailable_reason: progress.unavailable_reason,
566
+ unproven_ids: progress.unproven_ids,
567
+ });
568
+ await publish(snapshot, "waiting_human", onUpdate);
569
+ return true;
570
+ }
558
571
  if (requiresHuman) {
559
572
  appendEvent(snapshot, "waiting_human", "PR audit requires human input.", {
560
573
  finding_codes: findings.map((finding) => finding.code),
@@ -562,7 +575,7 @@ async function handleFailedAudit({
562
575
  await publish(snapshot, "waiting_human", onUpdate);
563
576
  return true;
564
577
  }
565
- if (attemptNumber < snapshot.charter.max_attempts) {
578
+ if (attemptNumber < codingAttemptCeiling(snapshot)) {
566
579
  appendEvent(snapshot, "re_instructing", "PR audit produced a repair instruction.", {
567
580
  attempt_number: attemptNumber,
568
581
  finding_codes: findings.map((finding) => finding.code),
@@ -573,6 +586,18 @@ async function handleFailedAudit({
573
586
  return false;
574
587
  }
575
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
+
576
601
  async function stopForBudget({ snapshot, attempt, reason, onUpdate }) {
577
602
  const budget = refreshBudgetState(snapshot);
578
603
  const finding = budgetFinding(reason, budget);
@@ -611,7 +636,7 @@ function budgetFinding(reason, budget) {
611
636
  code: "TOKEN_BUDGET_EXHAUSTED",
612
637
  severity: "blocker",
613
638
  target: "provider_token_budget",
614
- 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.`,
615
640
  repairability: "manual",
616
641
  auto_fix_hint: null,
617
642
  };
@@ -621,7 +646,7 @@ function budgetFinding(reason, budget) {
621
646
  code: "RETRY_BUDGET_EXHAUSTED",
622
647
  severity: "blocker",
623
648
  target: "retry_budget",
624
- 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.`,
625
650
  repairability: "manual",
626
651
  auto_fix_hint: null,
627
652
  };
@@ -1,6 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
- import { acceptanceCriterionRows, hasUserOutcomeProof } from "./completion-contract.js";
3
+ import { acceptanceCriterionRows } from "./completion-contract.js";
4
+ import { evaluateOutcomeProgress } from "./outcome-progress.js";
5
+ import { independentAuditEvidenceIdentity } from "./pr-preparation.js";
4
6
 
5
7
  function objective(charter) {
6
8
  return {
@@ -17,17 +19,31 @@ function objective(charter) {
17
19
  };
18
20
  }
19
21
 
20
- function digest(value) {
21
- return createHash("sha256").update(JSON.stringify(value)).digest("hex");
22
+ const CANONICAL_DIGEST = "sha256-canonical-json-v1";
23
+
24
+ function canonicalValue(value) {
25
+ if (Array.isArray(value)) return value.map(canonicalValue);
26
+ if (value && typeof value === "object") {
27
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]));
28
+ }
29
+ return value;
22
30
  }
23
31
 
32
+ export function objectiveValueDigest(value, algorithm) {
33
+ return createHash("sha256").update(JSON.stringify(
34
+ algorithm === CANONICAL_DIGEST ? canonicalValue(value) : value,
35
+ )).digest("hex");
36
+ }
37
+ const digest = objectiveValueDigest;
38
+
24
39
  export function freezeObjective(charter) {
25
40
  const value = objective(charter);
26
41
  charter.thread_state.objective_contract = {
27
42
  version: 1,
43
+ digest_algorithm: CANONICAL_DIGEST,
28
44
  baseline: structuredClone(value),
29
45
  current: structuredClone(value),
30
- current_digest: digest(value),
46
+ current_digest: digest(value, CANONICAL_DIGEST),
31
47
  revisions: [],
32
48
  };
33
49
  }
@@ -36,7 +52,7 @@ export function reviseObjective(charter, source) {
36
52
  const contract = charter.thread_state.objective_contract;
37
53
  if (!contract) return;
38
54
  const next = objective(charter);
39
- const nextDigest = digest(next);
55
+ const nextDigest = digest(next, contract.digest_algorithm);
40
56
  if (nextDigest === contract.current_digest) return;
41
57
  contract.revisions.push({
42
58
  version: contract.version + 1,
@@ -55,6 +71,10 @@ export function verifyObjective(charter) {
55
71
  const contract = charter.thread_state?.objective_contract;
56
72
  if (!contract) return { valid: true, legacy: true };
57
73
  try {
74
+ if (contract.digest_algorithm && contract.digest_algorithm !== CANONICAL_DIGEST) {
75
+ return { valid: false, legacy: false, digest: null };
76
+ }
77
+ const contractDigest = (value) => digest(value, contract.digest_algorithm);
58
78
  const current = objective(charter);
59
79
  const revisions = contract.revisions;
60
80
  const valid = Boolean(
@@ -62,15 +82,15 @@ export function verifyObjective(charter) {
62
82
  contract.baseline &&
63
83
  Array.isArray(revisions) &&
64
84
  revisions.length === contract.version - 1 &&
65
- digest(contract.current) === contract.current_digest &&
66
- digest(current) === contract.current_digest &&
85
+ contractDigest(contract.current) === contract.current_digest &&
86
+ contractDigest(current) === contract.current_digest &&
67
87
  revisions.every((revision, index) =>
68
88
  revision.version === index + 2 &&
69
- revision.previous_digest === digest(revision.previous) &&
70
- revision.next_digest === digest(revision.next) &&
71
- (index === 0 ? revision.previous_digest === digest(contract.baseline) :
89
+ revision.previous_digest === contractDigest(revision.previous) &&
90
+ revision.next_digest === contractDigest(revision.next) &&
91
+ (index === 0 ? revision.previous_digest === contractDigest(contract.baseline) :
72
92
  revision.previous_digest === revisions[index - 1].next_digest)) &&
73
- (revisions.length === 0 ? digest(contract.baseline) === contract.current_digest :
93
+ (revisions.length === 0 ? contractDigest(contract.baseline) === contract.current_digest :
74
94
  revisions.at(-1).next_digest === contract.current_digest)
75
95
  );
76
96
  return { valid, legacy: false, digest: contract.current_digest };
@@ -81,43 +101,74 @@ export function verifyObjective(charter) {
81
101
 
82
102
  // The monitor reads independent criterion assessments, not executor test counts.
83
103
  // It records insufficient evidence when the independent review was not reached.
84
- export function assessOutcomeProgress(charter, attempt, previousAttempt = null) {
85
- const criteria = acceptanceCriterionRows(charter);
104
+ export function assessOutcomeProgress(charter, attempt, previousAttempt = null, options = {}) {
86
105
  const assessment = attempt.independent_audit;
87
- const results = Array.isArray(assessment?.criterion_results)
88
- ? assessment.criterion_results : [];
89
- const completeAssessment = Boolean(attempt.audit?.passed === true && assessment &&
90
- criteria.every((criterion) => results.filter((row) => row.criterion_id === criterion.id).length === 1) &&
91
- results.length === criteria.length &&
92
- !(assessment.findings || []).some((finding) => finding.code === "INDEPENDENT_AUDIT_UNAVAILABLE"));
93
- const reviewed = new Set();
94
- for (const criterion of criteria) {
95
- const matches = results.filter((row) => row.criterion_id === criterion.id);
96
- const row = matches[0];
97
- if (matches.length === 1 && row.passed === true && row.verification_method &&
98
- row.observed_result && Array.isArray(row.evidence_refs) && row.evidence_refs.length > 0) {
99
- reviewed.add(criterion.id);
100
- }
106
+ const objectiveState = verifyObjective(charter);
107
+ const contract = charter.thread_state?.objective_contract;
108
+ const objectiveDigest = contract?.current_digest || null;
109
+ const boundObjective = assessment?.objective_digest || attempt.outcome_progress?.objective_digest;
110
+ let assessmentReason = null;
111
+ if (!objectiveState.valid) assessmentReason = "objective_contract_invalid";
112
+ else if (boundObjective && boundObjective !== objectiveDigest ||
113
+ assessment?.objective_version != null && assessment.objective_version !== contract?.version) {
114
+ assessmentReason = "independent_objective_stale";
115
+ } else if (assessment?.objective_digest && !assessment.evidence_identity_sha256) {
116
+ assessmentReason = "independent_candidate_unbound";
117
+ } else if (assessment?.evidence_identity_sha256 &&
118
+ assessment.evidence_identity_sha256 !== independentAuditEvidenceIdentity(attempt)) {
119
+ assessmentReason = "independent_candidate_stale";
120
+ } else if (!assessment || (assessment.findings || []).some((finding) =>
121
+ finding.code === "INDEPENDENT_AUDIT_UNAVAILABLE")) {
122
+ assessmentReason = "independent_assessment_unavailable";
101
123
  }
102
- if (hasUserOutcomeProof(assessment?.coverage_review)) {
103
- reviewed.add("USER-GOAL");
124
+ const result = evaluateOutcomeProgress({
125
+ criteria: acceptanceCriterionRows(charter),
126
+ objectiveDigest,
127
+ objectiveVersion: contract?.version || null,
128
+ criterionResults: assessment?.criterion_results,
129
+ userOutcome: assessment?.coverage_review?.user_outcome,
130
+ assessmentAvailable: assessmentReason === null,
131
+ assessmentReason,
132
+ findings: [...(attempt.audit?.findings || []), ...(assessment?.findings || [])],
133
+ previous: previousAttempt?.outcome_progress || null,
134
+ maxNoProgressAttempts: options.maxNoProgressAttempts ?? 3,
135
+ });
136
+ // Existing runs keep their original stopping policy until the controller opts
137
+ // into progress continuation. The richer measurement is persisted in both.
138
+ if (options.policy !== "progress") {
139
+ const criteria = acceptanceCriterionRows(charter);
140
+ const rows = Array.isArray(assessment?.criterion_results) ? assessment.criterion_results : [];
141
+ const completeAssessment = assessmentReason === null && attempt.audit?.passed === true &&
142
+ rows.length === criteria.length && criteria.every((criterion) =>
143
+ rows.filter((row) => row.criterion_id === criterion.id).length === 1);
144
+ const previous = previousAttempt?.outcome_progress;
145
+ result.status = completeAssessment ? "assessed" : "insufficient_evidence";
146
+ result.stalled = Boolean(completeAssessment && previous?.status === "assessed" &&
147
+ previous.objective_digest === result.objective_digest && result.unproven_ids.length > 0 &&
148
+ result.assessed_ids.every((id) => previous.assessed_ids.includes(id)) &&
149
+ result.unproven_ids.every((id) => previous.unproven_ids.includes(id)));
150
+ result.stall_reason = result.stalled ? "no_new_verified_outcome" : null;
151
+ result.next_actions = result.next_actions.filter((action) => action !== "reassess_approach");
152
+ if (result.stalled) result.next_actions.push("reassess_approach");
153
+ result.next_action = result.stalled ? "reassess_approach" : result.next_actions[0] || "repair_or_verify";
104
154
  }
105
- const result = {
106
- objective_digest: charter.thread_state?.objective_contract?.current_digest || null,
107
- status: completeAssessment ? "assessed" : "insufficient_evidence",
108
- assessed_ids: [...reviewed].sort(),
109
- unproven_ids: ["USER-GOAL", ...criteria.map((row) => row.id)].filter((id) => !reviewed.has(id)),
110
- evidence_identity: assessment?.evidence_identity_sha256 || null,
111
- stalled: false,
112
- };
155
+ result.evidence_identity = assessment?.evidence_identity_sha256 || null;
156
+ const completeAssessment = result.status === "assessed";
113
157
  const previous = previousAttempt?.outcome_progress;
114
- result.stalled = Boolean(
115
- completeAssessment && previous?.status === "assessed" &&
116
- previous.objective_digest === result.objective_digest &&
117
- result.unproven_ids.length > 0 &&
118
- result.assessed_ids.every((id) => previous.assessed_ids.includes(id)) &&
119
- result.unproven_ids.every((id) => previous.unproven_ids.includes(id))
120
- );
158
+ const sameObjective = previous?.objective_digest === result.objective_digest;
159
+ const previousUnavailableCount = !options.resetUnavailableCount && sameObjective && previous?.status === "insufficient_evidence"
160
+ ? previous.consecutive_unassessed_attempts || 1 : 0;
161
+ result.consecutive_unassessed_attempts = completeAssessment ? 0 : previousUnavailableCount + 1;
162
+ result.unavailable_reason = completeAssessment ? null : attempt.audit?.passed !== true
163
+ ? "local_verification_incomplete" : "independent_assessment_incomplete";
164
+ const previousMissingAssessments = !options.resetUnavailableCount && sameObjective &&
165
+ previous?.unavailable_reason === "independent_assessment_incomplete"
166
+ ? previous.consecutive_unavailable_assessments || 1 : 0;
167
+ result.consecutive_unavailable_assessments = result.unavailable_reason === "independent_assessment_incomplete"
168
+ ? previousMissingAssessments + 1 : 0;
169
+ // Local repair can improve without reaching independent review. Only a repeatedly
170
+ // missing independent assessment after local readiness opens this recovery gate.
171
+ result.assessment_unavailable = options.policy !== "progress" && result.consecutive_unavailable_assessments >= 2;
121
172
  attempt.outcome_progress = result;
122
173
  return result;
123
174
  }
@@ -0,0 +1,2 @@
1
+ // The installed provider plugins use the same dependency-free implementation.
2
+ export { evaluateOutcomeProgress } from "../codex-plugins/simy/hooks/outcome-progress.mjs";
@@ -114,6 +114,7 @@ function recoveryReasonCode(state, message, detail) {
114
114
  if (code === "retry_circuit_open" || detail.reason === "identical_commit_and_findings") {
115
115
  return "retry_strategy_unchanged";
116
116
  }
117
+ if (code === "outcome_assessment_unavailable") return "outcome_assessment_unavailable";
117
118
  if (code === "token_budget_exhausted") return "token_budget_exhausted";
118
119
  if (code === "retry_budget_exhausted") return "retry_budget_exhausted";
119
120
  const findings = strings(detail.finding_codes);
@@ -148,6 +149,8 @@ function recoveryMessage(reasonCode) {
148
149
  requirements_incomplete:
149
150
  "SIMY needs the missing requirement or approval before implementation can start.",
150
151
  human_input_required: "SIMY needs your decision or clarification before it can continue.",
152
+ outcome_assessment_unavailable:
153
+ "SIMY could not assess outcome progress across consecutive attempts. Repair the recorded verification failure and continue this run.",
151
154
  token_budget_exhausted:
152
155
  "This run reached its Provider token safety limit. Provider tokens are not billed by SIMY. Increase the limit to continue this run.",
153
156
  retry_budget_exhausted: