@awak-app/simy-cli 0.7.37 → 0.7.38

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 (29) hide show
  1. package/package.json +1 -1
  2. package/src/auto-update.js +25 -13
  3. package/src/cli-contract.js +1 -1
  4. package/src/cli-observability.js +2 -2
  5. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +240 -24
  6. package/src/codex-plugins/simy/hooks/purpose-workspace.mjs +4 -1
  7. package/src/codex-plugins/simy/hooks/registered-requirement-checker.mjs +201 -0
  8. package/src/codex-plugins/simy/hooks/requirement-acceptance.mjs +69 -0
  9. package/src/delivery-hooks/supervisor-watchdog.js +3 -2
  10. package/src/desktop-host.js +48 -18
  11. package/src/session-supervision/acceptance-reads.js +11 -2
  12. package/src/session-supervision/analysis-manager.js +9 -2
  13. package/src/session-supervision/catalog-refresh.js +16 -0
  14. package/src/session-supervision/claude-native-agent-contract.js +62 -0
  15. package/src/session-supervision/claude-outcome-review.js +7 -6
  16. package/src/session-supervision/claude-sessions.js +137 -76
  17. package/src/session-supervision/codex-app-server.js +3 -3
  18. package/src/session-supervision/codex-children.js +2 -2
  19. package/src/session-supervision/conversation-context.js +58 -26
  20. package/src/session-supervision/discovery-window.js +8 -2
  21. package/src/session-supervision/index.js +25 -12
  22. package/src/session-supervision/purpose-verification.js +12 -3
  23. package/src/sqm/advisory-guidance.js +1 -0
  24. package/src/sqm/checkers.js +35 -1
  25. package/src/sqm/claude-plugin.js +7 -1
  26. package/src/sqm/hypothesis-knowledge.js +3 -3
  27. package/src/sqm/knowledge-recall.js +34 -12
  28. package/src/sqm/reasoned-assessment.js +22 -9
  29. package/src/update-detachment/runtime.js +2 -1
@@ -12,6 +12,8 @@ import { recordHandoffStage, compactConsistencyOutbox } from './consistency-hand
12
12
  import { retainApplicableRules } from './purpose-rule-policy.mjs';
13
13
  import { reconcileRequirements, retainControllerEvidence, reconcileFindings, findingText, evidenceLedger, currentPurposeObservation } from './purpose-evidence.mjs';
14
14
  import { workspaceIdentity } from './purpose-workspace.mjs';
15
+ import { normalizeRequirementContract, pendingRequirements } from './requirement-acceptance.mjs';
16
+ import { bindRequirementChecker, registeredCheckerCurrent, registeredRequirementObserver, isRegisteredRequirementObserver } from './registered-requirement-checker.mjs';
15
17
  import { actionReversibilityInstruction, searchEnabled, failureLimitReached, searchState, recordSearchExperiment, rememberSearchWait, searchNeedsPivot, markSearchPivot, repeatedSearchAction } from './purpose-search.mjs';
16
18
  // Standalone by design: this exact module is shipped to both provider plugins.
17
19
  import { createHash, randomUUID } from 'node:crypto';
@@ -40,7 +42,7 @@ const RETRYABLE = new Set(['overloaded', 'server_error', 'rate_limit']);
40
42
  const APPROVAL_DECISIONS = new Set(['automatic', 'proceed_and_report', 'wait']);
41
43
  const LOCAL_ACTIONS = new Set(['read', 'local_edit', 'local_test']);
42
44
  const ACTIONS = new Set([...LOCAL_ACTIONS, 'external', 'destructive', 'scope_change', 'unknown']);
43
- const AUTONOMY_POLICIES = new Set(['strict', 'balanced', 'autonomous']);
45
+ const AUTONOMY_POLICIES = new Set(['strict', 'balanced', 'autonomous', 'local_single_file']);
44
46
  const ASSESSMENT_FIELDS = { risk: ['low', 'medium', 'high', 'unknown'], reversibility: ['reversible', 'irreversible', 'unknown'], externalEffect: ['none', 'local', 'external', 'unknown'], blastRadius: ['single_file', 'repository', 'multi_system', 'unknown'], uncertainty: ['low', 'medium', 'high', 'unknown'] };
45
47
  export const rootDirectory = (env = process.env) => join(env.SIMY_HOME?.trim() || join(homedir(), '.simy'), 'delivery-hooks');
46
48
  // Provider identity remains bound to record.cwd. File acceptance and writer
@@ -449,7 +451,9 @@ export function missingAcceptanceDiagnosis(managed, missing) {
449
451
  const names = missing.map(item => item.id).join(', ');
450
452
  const scope = managed.decision.action.scope;
451
453
  const action = { kind: 'read', scope, summary: `Inspect missing acceptance checks: ${names}` };
452
- return assessedPlan({ diagnosis: `Worker claimed completion, but independent acceptance checks still fail: ${names}. Diagnose the discrepancy before any repair.`,
454
+ return assessedPlan({ diagnosis: missing.some(item => item.check === 'behavior')
455
+ ? `Behavior acceptance is incomplete or unresolved: ${names}. Read the observations and distinguish a failed behavior from unavailable verification before any repair.`
456
+ : `Worker claimed completion, but independent acceptance checks still fail: ${names}. Diagnose the discrepancy before any repair.`,
453
457
  alternatives: [{ id: 'inspect_missing_acceptance', action,
454
458
  assessment: { risk: 'low', reversibility: 'reversible', externalEffect: 'none', blastRadius: 'single_file', uncertainty: 'low', inObjective: true, userChoice: false },
455
459
  expectedVerification: `Report actual outcome and unmet conditions for ${names}`, rollback: 'No changes to undo' }],
@@ -535,7 +539,7 @@ export function evaluateAssessedPlan(plan, policy, scopes, now = Date.now(), { a
535
539
  const localWrite = action.kind === 'local_edit' || allowLocalTestWrites && action.kind === 'local_test' && facts.externalEffect === 'local';
536
540
  const autonomous = policy === 'autonomous' && llmDelegated;
537
541
  const external = autonomous && allowReversibleExternal && action.kind === 'external' && facts.externalEffect === 'external';
538
- let reason = null, waitKind = autonomous ? 'research' : 'human';
542
+ let reason = null, assessmentError = null, waitKind = autonomous ? 'research' : 'human';
539
543
  if (!scopes.includes(action.scope)) { reason = 'Action scope is outside the task enrollment.'; waitKind = 'authority'; }
540
544
  else if (facts.inObjective !== true) reason = 'Establish objective fit or choose work within the original task.';
541
545
  else if (facts.reversibility === 'irreversible') { reason = 'Irreversible consequences require an exact user decision.'; waitKind = 'human'; }
@@ -545,13 +549,16 @@ export function evaluateAssessedPlan(plan, policy, scopes, now = Date.now(), { a
545
549
  else if (autonomous && allowReversibleExternal && action.kind === 'external' && facts.externalEffect !== 'external') reason = 'External action and declared effects disagree. Inspect the target and correct the assessment without executing it; API/service mutations have external effects even for a loopback service, while local filesystem work uses a local action.';
546
550
  else if ((!LOCAL_ACTIONS.has(action.kind) || facts.externalEffect === 'external') && !external) { reason = 'This action needs existing external authority or a reversible supported alternative.'; waitKind = 'authority'; }
547
551
  else if ((localWrite || external) && (!option.rollback || external && /^(?:no changes|none|n\/a)/i.test(option.rollback.trim()))) reason = 'Effectful work needs a concrete restoration plan.';
548
- else if (localWrite && facts.externalEffect !== 'local') reason = 'Local write has inconsistent effect boundaries.';
552
+ else if (localWrite && facts.externalEffect !== 'local') { reason = 'Local write has inconsistent effect boundaries.'; assessmentError = 'local_write_effect_mismatch'; }
549
553
  else if (!localWrite && !external && facts.externalEffect !== 'none') reason = 'Read or test action has unexpected side effects.';
550
554
  else if (!autonomous && (facts.blastRadius === 'multi_system' || facts.risk === 'high' || facts.uncertainty === 'high')) reason = 'Selected policy requires a narrower or lower-risk action.';
551
555
  else if (policy === 'strict' && (localWrite || facts.risk !== 'low' || facts.uncertainty !== 'low')) reason = 'Strict policy requires low-risk read or local test.';
552
556
  else if (policy === 'balanced' && (facts.risk !== 'low' || facts.uncertainty !== 'low' || localWrite && facts.blastRadius !== 'single_file')) reason = 'Balanced policy requires a low-risk, narrow action.';
553
- const report = external || autonomous && (facts.risk !== 'low' || facts.uncertainty !== 'low' || facts.userChoice !== false || facts.blastRadius === 'multi_system') || policy === 'autonomous' && facts.risk === 'medium';
554
- return { id: digest(JSON.stringify({ plan, policy, llmDelegated, allowReversibleExternal })), outcome: reason ? 'wait' : report ? 'report' : 'proceed', ...(reason ? { waitKind } : {}), ...(external && !reason ? { autonomousExternal: true } : {}), reason: reason || 'AI selected reversible work within the enrolled objective and provider authority.', selectedOptionId: option.id, action, assessment: facts, expectedVerification: option.expectedVerification, rollback: option.rollback, policy, at: now };
557
+ else if (policy === 'local_single_file' && (action.scope !== 'task' || facts.uncertainty !== 'low' ||
558
+ (action.kind === 'local_edit' ? !['low','medium'].includes(facts.risk) || facts.blastRadius !== 'single_file' || /^(?:no changes|none|n\/a)\b/i.test(option.rollback.trim()) :
559
+ localWrite || facts.risk !== 'low'))) reason = 'Local single-file policy requires known low uncertainty, reversible task-local single-file edits at low or medium risk, or low-risk read-only work.';
560
+ const report = external || autonomous && (facts.risk !== 'low' || facts.uncertainty !== 'low' || facts.userChoice !== false || facts.blastRadius === 'multi_system') || ['autonomous','local_single_file'].includes(policy) && facts.risk === 'medium';
561
+ return { id: digest(JSON.stringify({ plan, policy, llmDelegated, allowReversibleExternal })), outcome: reason ? 'wait' : report ? 'report' : 'proceed', ...(reason ? { waitKind } : {}), ...(assessmentError ? { assessmentError } : {}), ...(external && !reason ? { autonomousExternal: true } : {}), reason: reason || 'AI selected reversible work within the enrolled objective and provider authority.', selectedOptionId: option.id, action, assessment: facts, expectedVerification: option.expectedVerification, rollback: option.rollback, policy, at: now };
555
562
  }
556
563
  // Recompute from the frozen plan, not an arbitrary checkpoint flag. Provider
557
564
  // permissions are checked independently by the adapter at actual dispatch.
@@ -566,15 +573,19 @@ export function canExecuteAutonomousExternal(managed) {
566
573
  }
567
574
  // Shared by live Stop hooks and cold exact-session recovery. Keep the result
568
575
  // contract identical across transports; the host still verifies acceptance.
569
- export function assessedCheckpointContract(decision, managed = null) {
576
+ function actionAssessmentInstruction(decision, managed = null) {
570
577
  const scopes = managed?.approvalScopes ?? (decision.action?.scope ? [decision.action.scope] : []);
571
- const actionFields = `Enrolled action.scope labels: ${JSON.stringify(scopes)}. scope is an exact enrollment label, not a filename or target description. Reuse an enrolled label for work within that scope; do not invent labels. Put concrete file names and affected targets in action.summary. These labels do not expand the original objective or provider permissions. Describe actual effects consistently: read uses externalEffect:"none"; local_edit uses externalEffect:"local". Do not relabel an outside-scope action to bypass approval.`;
578
+ const policy=managed?.autonomyPolicy || decision.policy;
579
+ return `${policy==='local_single_file' ? 'The host-enrolled local_single_file policy permits only reversible task-local edits to one file at low or medium risk with known low uncertainty. Read-only work stays low risk; external/high/unknown/user-choice effects remain held. Declare exactly one task-relative writeResources target, retain a concrete rollback, and checkpoint the actual result before the next effect. ' : ''}Enrolled action.scope labels: ${JSON.stringify(scopes)}. scope is an exact enrollment label, not a filename or target description. Reuse an enrolled label for work within that scope; do not invent labels. Put concrete file names and affected targets in action.summary. These labels do not expand the original objective or provider permissions. externalEffect classifies all effects, not just network effects: read-only work uses "none"; local file writes use "local"; outside-service effects use "external"; retain "unknown" when facts are missing. local_edit therefore requires externalEffect:"local", even when it makes no network call. Never relabel an external or unknown effect to bypass a hold. ${policy==='balanced' ? 'Balanced policy requires known low risk and uncertainty; a local write must be single_file, reversible and have a concrete rollback. Keep the entire original goal in remaining work, but choose one bounded original-task action rather than collapsing a multi-file goal into one write.' : policy==='strict' ? 'Strict policy permits low-risk read or local test; it does not authorize local edits.' : 'The enrolled policy and actual permission boundaries remain authoritative.'}`;
580
+ }
581
+ export function assessedCheckpointContract(decision, managed = null) {
582
+ const actionFields=actionAssessmentInstruction(decision,managed);
572
583
  const observationField = flexibleReasoning(managed) ? 'Plan diagnosis, recommendation and its reason are optional when the selected action is clear. Optional observations may be omitted; do not create evidence files, citations or extra reports. Actual tool results are captured by the controller.' : 'Return the observed result; evidence attachments and extra reports are optional.';
573
584
  return `${minimalSufficientChangeInstruction({ turn: 'assessed' })}\nReturn ONLY one JSON object with top-level status:"in_progress|completed|blocked|error" and summary:"what happened". Use the key status, NEVER kind, for an ordinary work checkpoint. Record actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"what actually happened"} for THIS selected action. ${observationField} ${actionReversibilityInstruction} This is provider-reported, not independent verification. When the requested outcome is a conversational answer or correction, put the actual answer text in summary. Saying it was prepared or delivered is insufficient; do not place the answer only in an undeclared JSON field. For an answer-only request, returning that text here is the conversational result: do not invent a separate message-delivery action unless the original user requested a separate destination. This does not verify implementation or external effects. For in_progress/error include nextStep, remainingWork, and plan:{diagnosis,alternatives:[{id,action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification,rollback}],selectedOptionId,recommendation:{optionId,reason},userQuestion?} for the NEXT action; select one assessed option but do not execute it in this turn. Valid action.kind values are exactly read, local_edit, local_test, external, destructive, scope_change, unknown; use local_edit for writing local files. ${actionFields} Risk is low|medium|high|unknown; reversibility reversible|irreversible|unknown; externalEffect none|local|external|unknown; blastRadius single_file|repository|multi_system|unknown; uncertainty low|medium|high|unknown. For blastRadius, single_file and repository describe filesystem scope; multi_system is the conservative category for a known non-filesystem boundary, including an API/service or conversation. State the actual affected targets in the plan; this category grants no additional target or permission. Use unknown for missing scope facts, not merely because a known target is not a file. Missing facts are unknown. Successful diagnosis or repair is only an action result, not completion of the original user task. If original work remains, return in_progress with remainingWork and the next assessed plan, preserving the original objective, corrections and acceptance criteria. Do not repeat effects already performed. Report completed only when the original requested outcome is achieved; keep unfinished checks as remaining work. Do not add audit reports, certification, or evidence-submission tasks. Blocked requires blocker set to exactly authorization, missing_information, or external_dependency, plus nextStep. For authorization, include an assessed plan and set approval to an exact copy of the selected plan alternative action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. ${informationWaitInstruction} external_dependency requires waitCondition:{condition,owner,nextCheck} with the observable resumption condition, dependency owner, and bounded recheck instruction, plus nextStep.`;
574
585
 
575
586
  }
576
- export function understandingCheckpointContract(decision) {
577
- return `${minimalSufficientChangeInstruction({ turn: 'understanding' })}\n${correctiveFollowupInstruction}\nThis is an UNDERSTANDING turn, not a work turn. Read the existing conversation context and available read-only evidence; ${decision.purpose ? "Authorized read-only API and web research is allowed within existing provider permissions; do not send private material to external services. Do not edit files, write to external services, or start the proposed next action." : "do not edit files, call external services, or start the proposed next action."} Return ONLY JSON {kind:"session_understanding",summary:"...",actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"...",evidence:["..."]},sessionUnderstanding:{state:"clear|ambiguous|context_missing|already_done",objective:"current user goal",constraints:["..."],done:["..."],remaining:["..."],nextAction:{kind:"read|local_edit|local_test|external|destructive|scope_change|unknown",scope:"task",summary:"..."},criteria:[{description:"...",source:"user_grounded|proposed",basisIndex:0,check:{type:"manual|semantic|file_exists|file_contains|file_equals",path:"relative/path",text:"expected text"}}],ambiguities:["..."],question:"one focused user question if unclear",basis:[{source:"user|assistant|artifact",turnRef:"if available",snippet:"short relevant excerpt"}]},plan:{diagnosis:"...",alternatives:[{id:"...",action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification:"...",rollback:"..."}],selectedOptionId:"...",recommendation:{optionId:"...",reason:"..."}}}. For clear, choose one NEXT action but do not execute it. Set sessionUnderstanding.nextAction to an exact copy of the selected plan alternative's action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. Complete every known assessment field: risk low|medium|high|unknown; reversibility reversible|irreversible|unknown; externalEffect none|local|external|unknown; blastRadius single_file|repository|multi_system|unknown; uncertainty low|medium|high|unknown; inObjective and userChoice booleans. For blastRadius, single_file and repository describe filesystem scope; multi_system is the conservative category for a known non-filesystem boundary, including an API/service or conversation. State the actual affected targets in the plan; this category grants no additional target or permission. Use unknown for missing scope facts, not merely because a known target is not a file. For a read-only single-file action with no effects, use reversible, none, single_file when those facts are known; unknown facts require AI investigation; reversible preferences are delegated under autonomous policy. Write objective and criteria in the original user's language. Lead objective with the intended user experience (who can do what in which situation), when grounded in the request. Criteria must describe observable outcomes for that experience, not merely code written or tests passing. Keep technical-only requests technical; do not invent new UX obligations. Preserve done and remaining as the recorded stages of the whole requested work, distinguishing reported implementation from verified outcomes. Use only the original user's goal; constraints[] must contain only constraints from the original user, not these SIMY turn instructions. Do not invent new work or acceptance obligations. Include a user-grounded criterion only with a basisIndex pointing to a user message. Use file_equals for a user-requested file whose complete UTF-8 contents must be exact (including whitespace/newlines), file_contains only for a requested substring, and file_exists only for existence. ${decision.search ? "For semantic outcomes use check.type semantic; reserve manual for an explicit requirement for a human decision. Missing verification tooling is work to do, not a manual condition." : "For semantic conditions use semantic; do not create human verification requirements."} Never emit file_equals with null, omitted, guessed, or placeholder text. If exact expected contents are unknown, choose read to learn them and use semantic and obtain the needed observation automatically. Do not invent a file check from repository hints alone. If the original work is already done, choose already_done with remaining:[], nextAction:null, no plan, and no new goal. First recover missing context with authorized reads and infer reversible defaults from the original request. Select read investigation when facts remain unknown. Only if the original goal cannot be grounded at all or irreversible personal consequences require a decision, set one ambiguity and one focused question, nextAction:null, and omit plan. Provider statements about past work and semantic success are not independent verification. If and only if the original user requests ONLY a conversational answer or design (no implementation, files, tests, deployment or external effects), no independent review/audit or human acceptance is requested anywhere in the user context, and the existing answer already satisfies every requested point, you may include sessionUnderstanding.conversationOutcome:{kind:"answer_only",independentReviewRequested:false,requestTurnRefs:["exact user turn refs"],answerTurnRefs:["exact assistant answer turn refs"]}. Include the latest user and latest answer references, and exact user excerpts in the criteria basis. Read and judge the actual answer, including factual errors and unsupported claims; its mere existence or a worker saying done is insufficient. If the answer is wrong or incomplete, use clear with the next read action to formulate the missing/corrected answer, not already_done. Omit conversationOutcome for uncertain scope, missing context or requested effects; a design that describes unimplemented work is complete only when the user asked for design alone. This optional classification ends only an initially uncontracted conversational task as reported_done; it does not waive a frozen acceptance contract or assert independent verification. Optionally include sessionUnderstanding.matchingContext:{deliverables:["concrete requested output"],projectHints:["project name"],repositoryUrls:[],relatedUrls:[],actionIds:[],sourceRefs:[{platform:"slack",externalThreadId:"exact thread",channelExternalId:"exact channel"}]}. Extract only explicit conversation evidence; IDs and sources are retrieval signals, never authenticated identity. Do not infer URLs or identifiers.`;
587
+ export function understandingCheckpointContract(decision, managed = null) {
588
+ return `${minimalSufficientChangeInstruction({ turn: 'understanding' })}\n${correctiveFollowupInstruction}\n${actionAssessmentInstruction(decision,managed)}\nThis is an UNDERSTANDING turn, not a work turn. Read the existing conversation context and available read-only evidence; ${decision.purpose ? "Authorized read-only API and web research is allowed within existing provider permissions; do not send private material to external services. Do not edit files, write to external services, or start the proposed next action." : "do not edit files, call external services, or start the proposed next action."} Return ONLY JSON {kind:"session_understanding",summary:"...",actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"...",evidence:["..."]},sessionUnderstanding:{state:"clear|ambiguous|context_missing|already_done",objective:"current user goal",constraints:["..."],done:["..."],remaining:["..."],nextAction:{kind:"read|local_edit|local_test|external|destructive|scope_change|unknown",scope:"task",summary:"..."},criteria:[{description:"...",source:"user_grounded|proposed",basisIndex:0,check:{type:"manual|semantic|file_exists|file_contains|file_equals",path:"relative/path",text:"expected text"}}],ambiguities:["..."],question:"one focused user question if unclear",basis:[{source:"user|assistant|artifact",turnRef:"if available",snippet:"short relevant excerpt"}]},plan:{diagnosis:"...",alternatives:[{id:"...",action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification:"...",rollback:"..."}],selectedOptionId:"...",recommendation:{optionId:"...",reason:"..."}}}. For clear, choose one NEXT action but do not execute it. Set sessionUnderstanding.nextAction to an exact copy of the selected plan alternative's action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. Complete every known assessment field: risk low|medium|high|unknown; reversibility reversible|irreversible|unknown; externalEffect none|local|external|unknown; blastRadius single_file|repository|multi_system|unknown; uncertainty low|medium|high|unknown; inObjective and userChoice booleans. For blastRadius, single_file and repository describe filesystem scope; multi_system is the conservative category for a known non-filesystem boundary, including an API/service or conversation. State the actual affected targets in the plan; this category grants no additional target or permission. Use unknown for missing scope facts, not merely because a known target is not a file. For a read-only single-file action with no effects, use reversible, none, single_file when those facts are known; unknown facts require AI investigation; reversible preferences are delegated under autonomous policy. Write objective and criteria in the original user's language. Lead objective with the intended user experience (who can do what in which situation), when grounded in the request. Criteria must describe observable outcomes for that experience, not merely code written or tests passing. Keep technical-only requests technical; do not invent new UX obligations. Preserve done and remaining as the recorded stages of the whole requested work, distinguishing reported implementation from verified outcomes. Use only the original user's goal; constraints[] must contain only constraints from the original user, not these SIMY turn instructions. Do not invent new work or acceptance obligations. Include a user-grounded criterion only with a basisIndex pointing to a user message. Use file_equals for a user-requested file whose complete UTF-8 contents must be exact (including whitespace/newlines), file_contains only for a requested substring, and file_exists only for existence. ${decision.search ? "For semantic outcomes use check.type semantic; reserve manual for an explicit requirement for a human decision. Missing verification tooling is work to do, not a manual condition." : "For semantic conditions use semantic; do not create human verification requirements."} Never emit file_equals with null, omitted, guessed, or placeholder text. If exact expected contents are unknown, choose read to learn them and use semantic and obtain the needed observation automatically. Do not invent a file check from repository hints alone. If the original work is already done, choose already_done with remaining:[], nextAction:null, no plan, and no new goal. First recover missing context with authorized reads and infer reversible defaults from the original request. Select read investigation when facts remain unknown. Only if the original goal cannot be grounded at all or irreversible personal consequences require a decision, set one ambiguity and one focused question, nextAction:null, and omit plan. Provider statements about past work and semantic success are not independent verification. If and only if the original user requests ONLY a conversational answer or design (no implementation, files, tests, deployment or external effects), no independent review/audit or human acceptance is requested anywhere in the user context, and the existing answer already satisfies every requested point, you may include sessionUnderstanding.conversationOutcome:{kind:"answer_only",independentReviewRequested:false,requestTurnRefs:["exact user turn refs"],answerTurnRefs:["exact assistant answer turn refs"]}. Include the latest user and latest answer references, and exact user excerpts in the criteria basis. Read and judge the actual answer, including factual errors and unsupported claims; its mere existence or a worker saying done is insufficient. If the answer is wrong or incomplete, use clear with the next read action to formulate the missing/corrected answer, not already_done. Omit conversationOutcome for uncertain scope, missing context or requested effects; a design that describes unimplemented work is complete only when the user asked for design alone. This optional classification ends only an initially uncontracted conversational task as reported_done; it does not waive a frozen acceptance contract or assert independent verification. Optionally include sessionUnderstanding.matchingContext:{deliverables:["concrete requested output"],projectHints:["project name"],repositoryUrls:[],relatedUrls:[],actionIds:[],sourceRefs:[{platform:"slack",externalThreadId:"exact thread",channelExternalId:"exact channel"}]}. Extract only explicit conversation evidence; IDs and sources are retrieval signals, never authenticated identity. Do not infer URLs or identifiers.`;
578
589
  }
579
590
  function boundedContextList(value, maxItems = 8, maxLength = 300) {
580
591
  if (!Array.isArray(value) || value.length > maxItems) throw new Error('Session understanding has too many items.');
@@ -690,11 +701,14 @@ function recordPurposeProgress(run, now, countAttempt = true) {
690
701
  if (fresh.length) purpose.progressCapacityReached = true;
691
702
  }
692
703
  }
704
+ function managedAssessmentOptions(managed) {
705
+ return {allowLocalTestWrites:true,llmDelegated:searchEnabled(managed),
706
+ allowReversibleExternal:searchEnabled(managed) && managed.resumeChannel==='codex_app_server'};
707
+ }
693
708
  function recordDecision(managed, plan, now) {
694
709
  // Classify effectful local tests as writes, with the same scope, policy and
695
710
  // rollback checks as local_edit. Native write permissions remain independent.
696
- const assessmentOptions = { allowLocalTestWrites: true, llmDelegated: searchEnabled(managed),
697
- allowReversibleExternal: searchEnabled(managed) && managed.resumeChannel === 'codex_app_server' };
711
+ const assessmentOptions = managedAssessmentOptions(managed);
698
712
  let decision = evaluateAssessedPlan(plan, managed.autonomyPolicy, managed.approvalScopes, now, assessmentOptions);
699
713
  const dependencyPending = option => (option?.dependsOn || []).some(id =>
700
714
  !managed.purpose?.evidence?.units?.some(unit => unit.id === id && unit.status === 'verified' && unit.purposeRevision === managed.purpose.evidence.purposeRevision));
@@ -773,6 +787,7 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
773
787
  if (!spec) return null;
774
788
  if (spec !== true && (typeof spec !== 'object' || Array.isArray(spec))) throw new Error('Invalid managed task specification.');
775
789
  const config = spec === true ? {} : spec;
790
+ if (Object.hasOwn(config, 'requirementChecker') || Object.hasOwn(config, 'hostRequirementChecker')) throw new Error('Checker registration must come from the owning host, not a managed specification.');
776
791
  if (!Array.isArray(config.acceptanceCriteria) || (!config.acceptanceCriteria.length && config.contextBootstrap !== true) || config.acceptanceCriteria.length > 20) throw new Error('Managed tasks require acceptanceCriteria.');
777
792
  const criteria = config.acceptanceCriteria.map((item, index) => {
778
793
  if (typeof item !== 'object' || !item || !text(item.description, 500)) throw new Error('Each acceptance criterion needs a description.');
@@ -814,6 +829,7 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
814
829
  deliveryLevel = { requested: value.requested, effective: value.effective, numberingVersion: value.numberingVersion, selectionRef: text(value.selectionRef, 200) };
815
830
  }
816
831
  const managed = { ...(config.purpose ? { purpose: structuredClone(config.purpose) } : {}), taskId: randomUUID(), deliveryLevel, thread: { provider, id: session },
832
+ ...(config.requirementContract ? { requirementContract: normalizeRequirementContract(config.requirementContract) } : {}),
817
833
  independentReviewRequired: config.independentReviewRequired === true,
818
834
  actionId: text(config.actionId, 200) || null, actionDueAt, acceptanceCriteria: criteria, verified: {}, remainingWork: text(config.remainingWork, 4000) || null,
819
835
  initialAction, proposedAction: initialAction, approvalPolicy, approvalScopes: [...new Set(scopes)].slice(0, 20),
@@ -842,12 +858,16 @@ function freezeEnrollment(run, now) {
842
858
  ledger.charter ||= { objective: run.objective, criteria: structuredClone(run.managed.acceptanceCriteria), approvalScopes: [...run.managed.approvalScopes], frozenAt: now };
843
859
  }
844
860
  run.managed.enrollment = { objective: run.objective, acceptanceDigest: digest(JSON.stringify(run.managed.acceptanceCriteria)),
861
+ ...(run.managed.requirementContract ? { requirementContractHash: run.managed.requirementContract.contractHash } : {}),
862
+ ...(run.managed.requirementChecker ? { requirementCheckerDigest: run.managed.requirementChecker.digest } : {}),
845
863
  ...(run.managed.providerSandboxSource ? {nativeAuthorityDigest:digest(JSON.stringify([run.managed.providerSandbox,run.managed.providerSandboxSource]))} : {}),
846
864
  deliveryLevel: structuredClone(run.managed.deliveryLevel || null), frozenAt: now };
847
865
  }
848
866
  export function pendingAcceptance(run) {
849
867
  const enrollment = run.managed?.enrollment;
850
868
  if (enrollment && (enrollment.objective !== run.objective || enrollment.acceptanceDigest !== digest(JSON.stringify(run.managed.acceptanceCriteria)) ||
869
+ enrollment.requirementContractHash !== run.managed.requirementContract?.contractHash ||
870
+ enrollment.requirementCheckerDigest !== run.managed.requirementChecker?.digest ||
851
871
  JSON.stringify(enrollment.deliveryLevel) !== JSON.stringify(run.managed.deliveryLevel || null))) return [{ id: 'enrollment', description: 'Original objective, level and acceptance contract',
852
872
  check: 'manual', detail: 'Enrollment changed unexpectedly; stop and inspect before enrolling a revised task' }];
853
873
  const feedback = new Map(pendingAcceptanceFeedback(run).map(item => [item.criterionId, item]));
@@ -860,7 +880,7 @@ export function pendingAcceptance(run) {
860
880
  if (run.managed?.independentReviewRequired && !searchEnabled(run.managed) && run.managed.liveOutcomeReview?.passed !== true) pending.push({
861
881
  id: 'independent-review', description: 'Required independent outcome review', check: 'semantic',
862
882
  detail: 'Independent outcome review has not passed; file checks alone cannot complete this task' });
863
- return pending;
883
+ return [...pending, ...pendingRequirements(run.managed)];
864
884
  }
865
885
  function acceptancePendingReason(run) {
866
886
  return `Acceptance verification pending: ${pendingAcceptance(run).map(item => `${item.id} (${item.description}): ${item.detail}`).join('; ')}`;
@@ -929,7 +949,7 @@ export function liveOutcomeReviewEligible(record) {
929
949
  }
930
950
  // Checkpoints are rejected while only manual acceptance is pending, so the
931
951
  // review instruction applies only where an independent review can follow.
932
- function reviewInstruction(run) {
952
+ export function reviewInstruction(run) {
933
953
  const m = run.managed;
934
954
  if (m?.capability !== 'live_hook' || m?.thread?.provider !== 'claude') return '';
935
955
  if (run.state === 'verification_pending' && !m.independentReviewRequired && !effectiveAcceptanceCriteria(m).some(c => c.check.type === 'semantic'))
@@ -937,7 +957,7 @@ function reviewInstruction(run) {
937
957
  ? 'Manual acceptance is pending. The completion report records remaining work, but no additional executable next plan has been accepted. '
938
958
  : 'Only manual acceptance remains and the user verifies it. ') +
939
959
  'Checkpoints are not accepted until the user verifies or resumes this task; do not resubmit one. ';
940
- return 'When the requested artifact is ready or repaired, submit a completed work checkpoint to request independent review. This does not grant acceptance. If nativeOutcomeReview.prompt is absent, submit that checkpoint first to obtain a fresh review prompt; never reuse a pre-repair prompt. ';
960
+ return 'When the requested artifact is ready or repaired, submit a completed work checkpoint to request independent review. Use status=completed with the actual actionResult even while acceptance is pending; this is not acceptance. If nativeOutcomeReview.prompt is absent, submit that checkpoint first to obtain a fresh review prompt; never reuse a pre-repair prompt. ';
941
961
  }
942
962
  export function deliveryAcceptanceContext(run) {
943
963
  const feedback = manualAcceptanceFeedback(run);
@@ -991,6 +1011,9 @@ function initialDeliveryRun(objective, nextStep, now, maxFailures) {
991
1011
  maxFailuresSource: maxFailures === undefined ? 'default' : 'explicit', continuations: 0, failures: 0,
992
1012
  revision: 0, consumedRevision: -1, checkpoint: null, reason: 'started', managed: null };
993
1013
  }
1014
+ function understandingTurnInstruction(managed, decision) {
1015
+ return `Understand the current user request in this ORIGINAL conversation. ${managed.understandingAnswer ? `The user answered question ${managed.understandingAnswer.questionId}: ${managed.understandingAnswer.text}. ` : ''}${understandingCheckpointContract(decision,managed)}`;
1016
+ }
994
1017
  function hookDeliveryContext(record, automatic = false) {
995
1018
  const { provider, session, run } = record;
996
1019
  const identity = `SIMY connected to this ${provider} conversation: ${session}.`;
@@ -1000,9 +1023,14 @@ function hookDeliveryContext(record, automatic = false) {
1000
1023
  (['active', 'reported_done', 'completed'].includes(run.state) ? `${correctiveFollowupInstruction} ` : '') +
1001
1024
  (provider === 'codex' && ['completed', 'reported_done'].includes(run.state) ? `Finished run ID: ${run.id}. Its completion does not supervise later user requests. If the user explicitly requests supervision of the current goal, use simy delivery followup --provider codex --session ${session} --previous-run-id ${run.id} --objective CURRENT_GOAL. A different goal archives the old run; the same ordinary reported_done goal retains identity and budgets for read-only reconciliation. A new prompt alone is not consent to run this command. ` : '') +
1002
1025
  (run.state === 'active' && !run.managed ? `Before ending the turn, submit an honest JSON checkpoint with simy delivery checkpoint --provider ${provider} --session ${session} --file -. Use status in_progress with summary, remainingWork and nextStep for remaining work; completed with summary, acceptanceMet:true and evidence for finished work; or blocked with summary and blocker missing_information or authorization. The first completed checkpoint requests an outcome check in this conversation; inspect the actual result and submit a fresh checkpoint. If requested work remains, perform the first authorized step instead of ending with self-evaluation or a plan. Completion is provider-reported, not independently verified. ${informationWaitInstruction} ` : '') +
1026
+ // Publish the current read/understanding contract at the real user boundary,
1027
+ // before any work or Stop. Rendering it neither dispatches nor consumes a
1028
+ // decision, and terminal/paused runs never receive an execution instruction.
1029
+ (run.state === 'active' && run.managed?.phase === 'understanding' && run.managed.decision ?
1030
+ `${understandingTurnInstruction(run.managed, run.managed.decision)} Submit this checkpoint through standard input using simy delivery checkpoint --provider ${provider} --session ${session} --file -. A chat JSON answer does not submit a checkpoint. ` : '') +
1003
1031
  `Preserve the current task and its permissions, pauses and stops; never restart or reset it automatically.`;
1004
1032
  }
1005
- export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), executionCwd, upgradeRunId, previousRunId, reconcileRunId, objective, nextStep, maxMinutes, maxContinuations, maxFailures, continuationPolicy, managed, admission,
1033
+ export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), executionCwd, hostRequirementChecker, upgradeRunId, previousRunId, reconcileRunId, objective, nextStep, maxMinutes, maxContinuations, maxFailures, continuationPolicy, managed, admission,
1006
1034
  liveHookAdmission, autoContinueEpoch, followupAutoContinue = false, providerLinkEpoch, inspectProcess = processIdentity, now = Date.now() }) {
1007
1035
  const link = await providerLinkState(root, provider);
1008
1036
  if (providerLinkEpoch !== undefined && providerLinkEpoch !== link.epoch) throw Object.assign(new Error("SIMY provider connection changed before enrollment."), { code: "provider_link_changed" });
@@ -1013,6 +1041,11 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
1013
1041
  if (!(await stat(executionCwd)).isDirectory()) throw new Error('Task execution directory must be a directory.');
1014
1042
  }
1015
1043
  if (!text(objective) || !text(nextStep)) throw new Error('Objective and concrete next step are required.');
1044
+ const hostRequirements = hostRequirementChecker === undefined ? null : await bindRequirementChecker(hostRequirementChecker,
1045
+ { root, provider, session, cwd: executionCwd || cwd, objective: text(objective) });
1046
+ if (hostRequirements && (!managed || managed.resumeChannel && managed.resumeChannel !== 'live_hook')) throw new Error('Registered checker requires managed Claude live-hook enrollment.');
1047
+ if (hostRequirements && managed.requirementContract && normalizeRequirementContract(managed.requirementContract).contractHash !== hostRequirements.requirementContract.contractHash)
1048
+ throw new Error('Managed contract differs from the host registration.');
1016
1049
  return updateRecord(root, provider, session, async previous => {
1017
1050
  if (previous?.providerArchived === true) throw new Error('Restore the original provider conversation before starting Delivery Loop.');
1018
1051
  if (autoContinueEpoch && (previous?.autoContinue !== true || previous.autoContinueEpoch !== autoContinueEpoch)) throw new Error('Automatic continuation was disabled before enrollment.');
@@ -1048,6 +1081,7 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
1048
1081
  const managedConfig = managed && { ...(managed === true ? {} : managed),
1049
1082
  ...(continuationPolicy === undefined ? {} : { continuationPolicy }) };
1050
1083
  const managedState = managedSpec(managedConfig, session, provider, previous.process, admitted ? admission : null, now);
1084
+ if (hostRequirements) Object.assign(managedState, structuredClone(hostRequirements));
1051
1085
  if ((following || reconciling) && (provider !== 'codex' || !admitted || !managedState?.autoRecovery || managedState.capability !== 'codex_app_server' || managedState.initialAction.kind !== 'read' || managedState.contextBootstrap)) throw new Error('Follow-up requires exact-session managed supervision, a concrete goal and read-only reconciliation.');
1052
1086
  if (upgrading && (!managedState?.autonomyPolicy || managedState.initialAction.kind !== 'read' || managedState.contextBootstrap)) throw new Error('Upgrade requires read-only reconciliation and frozen acceptance of the original objective.');
1053
1087
  if (!managedState && continuationMode(continuationPolicy) === 'progress') throw new Error('Progress continuation requires frozen managed acceptance criteria.');
@@ -1242,7 +1276,8 @@ export function diagnoseControllerApproval(record, now) {
1242
1276
  return queued;
1243
1277
  }
1244
1278
 
1245
- export async function checkpointDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), checkpoint, expectedLease, expectedNativeHandoff, toolEvidence = [], sourceContext, controllerEvidence, now = Date.now() }) {
1279
+ export async function checkpointDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), checkpoint, expectedLease, expectedNativeHandoff, toolEvidence = [], sourceContext, controllerEvidence, signal, now = Date.now() }) {
1280
+ signal?.throwIfAborted();
1246
1281
  if (expectedNativeHandoff && checkpoint?.kind) throw new Error('Native handoff requires an ordinary action checkpoint.');
1247
1282
  if (checkpoint?.kind === 'action_association') return checkpointActionAssociation({ root, provider, session, cwd, checkpoint, expectedLease, now });
1248
1283
  if (checkpoint?.kind === 'session_understanding') return checkpointUnderstanding({ root, provider, session, cwd, checkpoint, expectedLease, sourceContext, controllerEvidence, now });
@@ -1253,7 +1288,23 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1253
1288
  if (checkpoint.status === 'error' && !text(checkpoint.nextStep)) throw new Error('A concrete nextStep is required.');
1254
1289
  if (checkpoint.status === 'blocked' && !['authorization', 'missing_information', 'external_dependency'].includes(checkpoint.blocker)) throw new Error('Specify authorization, missing_information, or external_dependency.');
1255
1290
  const evidence = Array.isArray(checkpoint.evidence) ? checkpoint.evidence.map(v => text(v, 1000)).filter(Boolean).slice(0, 20) : [];
1291
+ // Registered read-only verification runs outside updateRecord's lock. The
1292
+ // ordinary checkpoint still applies the existing stop/authority/action rules.
1293
+ if (checkpoint.status === 'completed') {
1294
+ const snapshot = await readRecord(root, provider, session);
1295
+ const binding = snapshot?.run?.managed?.requirementChecker;
1296
+ const decision = snapshot?.run?.managed?.decision;
1297
+ const result = checkpoint.actionResult;
1298
+ const actionBound = !snapshot?.run?.managed?.autonomyPolicy || result?.decisionId === decision?.id &&
1299
+ ['observed', 'failed', 'not_run'].includes(result?.status) && Boolean(text(result.summary, 500));
1300
+ if (binding && actionBound && checkpointDirectoryMatches(snapshot, cwd) && requirementObservationEligible(root, snapshot)) {
1301
+ const observer = registeredRequirementObserver({ root, binding, provider, session, runId: snapshot.run.id });
1302
+ observer.checkVersion = snapshot.run.managed.requirementContract.checkVersion;
1303
+ await observeRequirementAcceptance({ root, provider, session, observer, signal, now });
1304
+ }
1305
+ }
1256
1306
  const settled = await updateRecord(root, provider, session, async record => {
1307
+ signal?.throwIfAborted();
1257
1308
  const run = record?.run;
1258
1309
  let effectiveStatus = checkpoint.status;
1259
1310
  let nextStep = text(checkpoint.nextStep);
@@ -1363,7 +1414,8 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1363
1414
  rememberPerspectives(run, run.managed.purpose.lastReview, actionResult, now, perspectiveScope(record));
1364
1415
  }
1365
1416
  if (run.managed.autonomyPolicy && checkpoint.status === 'completed') {
1366
- const missing = run.managed.acceptanceCriteria.filter(item => !['manual', 'semantic'].includes(item.check.type) && !Object.hasOwn(run.managed.verified, item.id));
1417
+ const missing = [...run.managed.acceptanceCriteria.filter(item => !['manual', 'semantic'].includes(item.check.type) && !Object.hasOwn(run.managed.verified, item.id)),
1418
+ ...pendingRequirements(run.managed)];
1367
1419
  if (missing.length) {
1368
1420
  effectiveStatus = 'in_progress';
1369
1421
  repairPlan = checkpoint.plan && nextStep ? assessedPlan(checkpoint.plan, run.managed) : missingAcceptanceDiagnosis(run.managed, missing);
@@ -1467,9 +1519,10 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1467
1519
  run.blockerSource = effectiveStatus === 'blocked' ? 'checkpoint' : null;
1468
1520
  run.providerRequest = null;
1469
1521
  run.checkpoint = { status: effectiveStatus, summary: text(checkpoint.summary), nextStep,
1522
+ ...(effectiveStatus !== checkpoint.status || incompleteResult ? { reportedStatus: checkpoint.status } : {}),
1470
1523
  blocker: unsupportedWait ? undefined : checkpoint.blocker,
1471
1524
  ...(unsupportedWait ? { reportedBlocker: checkpoint.blocker } : checkpoint.blocker === 'missing_information' ? { question: text(checkpoint.question, 1000) } : {}),
1472
- ...(remainingWork ? { remainingWork } : {}), ...(incompleteResult ? { reportedStatus: checkpoint.status } : {}),
1525
+ ...(remainingWork ? { remainingWork } : {}),
1473
1526
  evidence, acceptanceMet: !unsupportedWait && !incompleteResult && checkpoint.acceptanceMet === true };
1474
1527
  if (!run.managed && effectiveStatus !== 'completed') delete run.completionCheck;
1475
1528
  if (run.managed?.liveOutcomeReview) {
@@ -1483,6 +1536,9 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1483
1536
  const semanticReviewRequired = effectiveAcceptanceCriteria(run.managed).some(c => c.check.type === 'semantic');
1484
1537
  if (searchEnabled(run.managed) && effectiveStatus === 'completed' && (run.managed.independentReviewRequired || semanticReviewRequired || pendingAcceptance(run).length || purposeCompletionBarrier(run))) {
1485
1538
  queuePurposeVerification(run, checkpoint.summary, now);
1539
+ } else if (searchEnabled(run.managed) && effectiveStatus === 'in_progress' && semanticReviewStalled(run) &&
1540
+ !(await reviewedCurrentWorkspace(record, root, searchState(run.managed).review))) {
1541
+ queuePurposeVerification(run, `Repeated read-only checks report no remaining change; independently review the semantic acceptance. Latest result: ${text(checkpoint.summary, 600)}`, now);
1486
1542
  } else if (run.managed && effectiveStatus === 'completed') {
1487
1543
  const pending = pendingAcceptance(run);
1488
1544
  if (pending.length) { run.state = 'verification_pending'; run.reason = acceptancePendingReason(run); }
@@ -1730,6 +1786,16 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
1730
1786
  if (['clear', 'already_done'].includes(understanding.state) && result.status !== 'observed') throw new Error('Clear understanding requires an observed read result.');
1731
1787
  const plan = understanding.state === 'clear' ? assessedPlan(checkpoint.plan, run.managed) : null;
1732
1788
  if (plan && JSON.stringify(plan.alternatives.find(item => item.id === plan.selectedOptionId).action) !== JSON.stringify(understanding.nextAction)) throw new Error('Understood next action differs from selected assessed option.');
1789
+ // A deterministic contradictory declaration is invalid input, not evidence
1790
+ // that the human needs to authorize an effect. Reject before committing
1791
+ // understanding; never rewrite none to local or dispatch the write.
1792
+ if (plan && run.managed.contextBootstrap && !searchEnabled(run.managed) &&
1793
+ !run.managed.pendingApproval && !run.managed.executionUncertain && !run.managed.rejectedActionOverflow &&
1794
+ !run.managed.rejectedActionDigests?.includes(digest(JSON.stringify(understanding.nextAction)))) {
1795
+ const assessed=evaluateAssessedPlan(plan,run.managed.autonomyPolicy,run.managed.approvalScopes,now,managedAssessmentOptions(run.managed));
1796
+ if (assessed.assessmentError==='local_write_effect_mismatch')
1797
+ throw new Error('Inconsistent understanding assessment: local_edit requires externalEffect:"local" for actual local writes, not "none". Correct the declaration honestly or choose authorized read investigation; do not execute or relabel external/unknown effects.');
1798
+ }
1733
1799
  if (purposeEnabled(run)) recordPurposeReview(run.managed,
1734
1800
  validatePurposeReview(checkpoint.purposeReview, plan, run.managed.purpose.ruleIds, searchEnabled(run.managed) ? run.managed.purpose.lastReview?.rules || [] : [], run.managed), now);
1735
1801
  if (searchEnabled(run.managed) && expectedLease && sourceContext) run.managed.purpose.originalContext ||= sourceContext;
@@ -1779,7 +1845,25 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
1779
1845
  run.managed.phase = 'active';
1780
1846
  run.state = 'reported_done';
1781
1847
  run.reason = 'Original conversation reports no remaining work; independent completion was not verified.';
1782
- if (conversationalCompletion) {
1848
+ if (run.managed.requirementContract) {
1849
+ // An already-enrolled behavior contract is independent of a worker's
1850
+ // interpretation that nothing remains. Acquire its observation through
1851
+ // the existing read/checkpoint path; reported_done would end that path.
1852
+ if (!run.managed.acceptanceCriteria.length) run.managed.acceptanceCriteria = understanding.criteria
1853
+ .filter(item => item.source === 'user_grounded').map(item => ({ id: item.id, description: item.description,
1854
+ check: item.check.type === 'file_equals' ? { type: 'file_equals', exactPath: item.check.path, text: item.check.text } : item.check }));
1855
+ freezeEnrollment(run, now);
1856
+ await verifyAutomaticCriteria(record, now);
1857
+ const missing = pendingRequirements(run.managed);
1858
+ if (missing.length) {
1859
+ const diagnosis = missingAcceptanceDiagnosis(run.managed, missing);
1860
+ run.state = 'active'; recordDecision(run.managed, diagnosis, now);
1861
+ run.nextStep = run.managed.decision.action.summary;
1862
+ run.reason = 'Reported completion still needs the enrolled independent behavior observations.';
1863
+ } else {
1864
+ run.state = 'verification_pending'; run.reason = acceptancePendingReason(run);
1865
+ }
1866
+ } else if (conversationalCompletion) {
1783
1867
  // These are inferred expectations, not a user-enrolled verification
1784
1868
  // contract. Preserve them in sessionUnderstanding without promoting
1785
1869
  // them into new audit obligations or claiming independent acceptance.
@@ -1820,8 +1904,98 @@ function newVerifiedCriterion(run, id, now) {
1820
1904
  if (!Object.hasOwn(managed.verified, id)) managed.lastOutcomeAt = now;
1821
1905
  managed.lastVerifiedProgressAt = Math.max(managed.lastVerifiedProgressAt ?? run.startedAt, now);
1822
1906
  }
1907
+ // Host adapter API, deliberately absent from the worker checkpoint JSON/CLI.
1908
+ // The caller provides a registered read-only observer, not a worker verdict or
1909
+ // a command discovered from task files. This API grants no execution permission.
1910
+ function requirementObservationEligible(root, record) {
1911
+ const managed = record?.run?.managed;
1912
+ return managed?.requirementContract && ['active', 'verification_pending'].includes(record.run.state) &&
1913
+ !record.run.stopReason && !hasPendingUserStop(root, record) && automaticContinuationAllowed(record) &&
1914
+ !managed.pendingApproval && !managed.nativeWait && !managed.executionUncertain && !managed.purpose?.nativeBoundary &&
1915
+ !acceptanceAuthorityHeld(record.run) && !managed.liveWorkspaceLease && !managed.resumeLease && !managed.outcomeAudit?.lease &&
1916
+ !['pending', 'pending_received', 'transferred'].includes(managed.handoff?.status) &&
1917
+ !['understanding', 'needs_clarification'].includes(managed.phase);
1918
+ }
1919
+ export async function observeRequirementAcceptance({ root, provider, session, observer, signal, now = Date.now() }) {
1920
+ signal?.throwIfAborted();
1921
+ if (typeof observer !== 'function') throw new Error('A host behavior observer is required.');
1922
+ const snapshot = await readRecord(root, provider, session);
1923
+ const eligible = record => requirementObservationEligible(root, record);
1924
+ if (!eligible(snapshot)) throw new Error('Behavior observation cannot override a task hold.');
1925
+ if (snapshot.run.managed.requirementChecker && !isRegisteredRequirementObserver(observer)) throw new Error('Enrolled checker requires its registered host execution path.');
1926
+ const contract = normalizeRequirementContract(snapshot.run.managed.requirementContract);
1927
+ if (observer.checkVersion !== contract.checkVersion) throw new Error('Host observer version does not match the enrolled contract.');
1928
+ const link = await providerLinkState(root, provider);
1929
+ if (!link.connected) throw new Error('Provider connection is unavailable.');
1930
+ const controller = new AbortController();
1931
+ const abort = () => controller.abort(signal.reason);
1932
+ signal?.addEventListener('abort', abort, { once: true });
1933
+ if (signal?.aborted) abort();
1934
+ let checking = false;
1935
+ const stillCurrent = async () => {
1936
+ if (checking || controller.signal.aborted) return;
1937
+ checking = true;
1938
+ try {
1939
+ const record = await readRecord(root, provider, session), currentLink = await providerLinkState(root, provider);
1940
+ if (!eligible(record) || record.run.id !== snapshot.run.id || record.run.revision !== snapshot.run.revision ||
1941
+ record.run.controlRevision !== snapshot.run.controlRevision || !currentLink.connected || currentLink.epoch !== link.epoch)
1942
+ controller.abort(new Error('Task authority changed during behavior verification.'));
1943
+ } catch { controller.abort(new Error('Task authority is unavailable.')); }
1944
+ finally { checking = false; }
1945
+ };
1946
+ const monitor = setInterval(stillCurrent, 100);
1947
+ let candidateHash, receipt;
1948
+ try {
1949
+ await stillCurrent();
1950
+ controller.signal.throwIfAborted();
1951
+ candidateHash = await workspaceIdentity(taskDirectory(snapshot), root, undefined, { signal: controller.signal });
1952
+ await stillCurrent();
1953
+ controller.signal.throwIfAborted();
1954
+ receipt = await observer({ cwd: taskDirectory(snapshot), contract: structuredClone(contract), candidateHash, signal: controller.signal });
1955
+ }
1956
+ catch { receipt = { schemaVersion: 1, contractHash: contract.contractHash, checkVersion: contract.checkVersion,
1957
+ candidateHash, execution: { status: 'infrastructure_error', assertionCount: 0 }, observations: [] }; }
1958
+ finally { clearInterval(monitor); signal?.removeEventListener('abort', abort); }
1959
+ // Bound storage before retaining output from a verifier transport.
1960
+ try {
1961
+ const serialized = JSON.stringify(receipt);
1962
+ if (!serialized || Buffer.byteLength(serialized) > 48 * 1024) throw new Error('Unusable observation.');
1963
+ receipt = JSON.parse(serialized);
1964
+ } catch { receipt = null; }
1965
+ return updateRecord(root, provider, session, async record => {
1966
+ signal?.throwIfAborted();
1967
+ const currentLink = await providerLinkState(root, provider);
1968
+ if (!eligible(record) || record.run.id !== snapshot.run.id || record.run.revision !== snapshot.run.revision ||
1969
+ record.run.controlRevision !== snapshot.run.controlRevision || !currentLink.connected || currentLink.epoch !== link.epoch ||
1970
+ JSON.stringify(record.run.managed.enrollment) !== JSON.stringify(snapshot.run.managed.enrollment) ||
1971
+ record.run.managed.requirementContract.contractHash !== contract.contractHash) return record;
1972
+ const owner = record.run.managed.liveHookOwner;
1973
+ if (owner && await processIdentity(owner.pid) !== owner.identity) return record;
1974
+ record.run.managed.requirementObservation = { id: randomUUID(), source: 'host_requirement_observer',
1975
+ ...(snapshot.run.managed.requirementChecker ? { checkerDigest: snapshot.run.managed.requirementChecker.digest } : {}),
1976
+ candidateHash, stateRoot: root, checkedAt: now, current: false, receipt };
1977
+ await verifyAutomaticCriteria(record, now);
1978
+ if (!pendingAcceptance(record.run).length && record.run.state === 'verification_pending') {
1979
+ record.run.state = 'active'; record.run.reason = 'Current behavior observation passed; awaiting the original Stop boundary.';
1980
+ }
1981
+ return record;
1982
+ });
1983
+ }
1984
+
1823
1985
  export async function verifyAutomaticCriteria(record, now) {
1824
1986
  const managed = record.run.managed;
1987
+ if (managed.requirementObservation) {
1988
+ const observation = managed.requirementObservation;
1989
+ // Refresh against the actual current workspace on every checkpoint/Stop.
1990
+ // A cached worker PASS or a previously matching candidate cannot waive it.
1991
+ observation.current = observation.source === 'host_requirement_observer' &&
1992
+ (!managed.requirementChecker || observation.checkerDigest === managed.requirementChecker.digest &&
1993
+ await registeredCheckerCurrent(observation.stateRoot, managed.requirementChecker)) &&
1994
+ observation.receipt?.candidateHash === observation.candidateHash &&
1995
+ observation.receipt?.contractHash === managed.requirementContract?.contractHash &&
1996
+ observation.receipt?.checkVersion === managed.requirementContract?.checkVersion &&
1997
+ observation.candidateHash === await workspaceIdentity(taskDirectory(record), observation.stateRoot).catch(() => null);
1998
+ }
1825
1999
  if (managed.liveOutcomeReview?.passed && managed.liveOutcomeReview.workspaceDigest !== await workspaceIdentity(taskDirectory(record)).catch(() => null)) {
1826
2000
  managed.liveOutcomeReview = { ...managed.liveOutcomeReview, passed: false, summary: 'Workspace changed after independent review; inspect the current result.' };
1827
2001
  delete managed.nativeOutcomeReview;
@@ -1834,6 +2008,23 @@ export async function verifyAutomaticCriteria(record, now) {
1834
2008
  evidence_refs: [`host-check:${managed.taskId}:${criterion.id}:${sha256 || 'failed'}`] };
1835
2009
  };
1836
2010
  const failed = (criterion, detail) => { delete managed.verified[criterion.id]; observe(criterion, false, detail); };
2011
+ if (managed.requirementContract) {
2012
+ const pending = new Map(pendingRequirements(managed).map(row => [row.id, row]));
2013
+ let requirements = [];
2014
+ try { requirements = normalizeRequirementContract(managed.requirementContract).requirements; }
2015
+ catch { for (const id of Object.keys(managed.verified)) if (id.startsWith('behavior:')) delete managed.verified[id]; }
2016
+ for (const requirement of requirements) {
2017
+ const id = `behavior:${requirement.id}`, row = pending.get(id);
2018
+ if (row) delete managed.verified[id];
2019
+ else {
2020
+ newVerifiedCriterion(record.run, id, now);
2021
+ managed.verified[id] = { source: 'behavior', checkedAt: now, sha256: managed.requirementObservation.candidateHash };
2022
+ }
2023
+ managed.criterionObservations[id] = { criterion_id: id, passed: row ? row.status === 'failed' ? false : null : true,
2024
+ verification_method: 'host_behavior', observed_result: row?.detail || 'All required assertions independently observed.',
2025
+ evidence_refs: managed.requirementObservation ? [`host-behavior:${managed.requirementObservation.id}`] : [] };
2026
+ }
2027
+ }
1837
2028
  const feedbackPending = new Set(pendingAcceptanceFeedback(record.run).map(item => item.criterionId));
1838
2029
  for (const criterion of effectiveAcceptanceCriteria(managed)) {
1839
2030
  if (criterion.check.type === 'manual') {
@@ -1864,6 +2055,29 @@ export async function verifyAutomaticCriteria(record, now) {
1864
2055
  } catch { failed(criterion, 'Acceptance file could not be independently read'); }
1865
2056
  }
1866
2057
  }
2058
+ // A semantic criterion is verified only by the independent review, and the
2059
+ // review is queued when the worker reports completed. The worker sees the
2060
+ // criterion as unverified until then, so it can keep re-reading an already
2061
+ // correct result and never report completed: the review never runs and the
2062
+ // task spends its whole budget. Two consecutive observed read-only results
2063
+ // (reads cannot change the workspace) with nothing pending except semantic
2064
+ // criteria mean there is nothing left for the worker to do, so the controller
2065
+ // schedules the review instead. The review still decides, and its findings
2066
+ // become the next repair cycle. A workspace the last review already judged is
2067
+ // not reviewed again; after a repair it is.
2068
+ function semanticReviewStalled(run) {
2069
+ const m = run.managed;
2070
+ if (m.executionUncertain || m.pendingApproval || m.userQuestion || m.dependencyWait || searchState(m).phase === 'verifying') return false;
2071
+ const pending = pendingAcceptance(run);
2072
+ if (!pending.length || pending.some(item => item.check !== 'semantic')) return false;
2073
+ const recent = (m.workHistory || []).filter(item => item.status !== 'session_understanding').slice(-2);
2074
+ return recent.length === 2 && recent.every(item => item.action?.kind === 'read' && item.result?.status === 'observed');
2075
+ }
2076
+ async function reviewedCurrentWorkspace(record, root, review) {
2077
+ if (!review?.workspaceDigest) return false;
2078
+ const current = await workspaceIdentity(taskDirectory(record), root, undefined, { excludedPaths: review.manifest?.source === 'controller_certified_independent_proposal' ? review.manifest.exclusions.map(row => row.path) : [] }).catch(() => null);
2079
+ return current === review.workspaceDigest;
2080
+ }
1867
2081
  function purposeCompletionBarrier(run) {
1868
2082
  const managed=run.managed;
1869
2083
  if(!searchEnabled(managed)) return null;
@@ -2137,8 +2351,10 @@ const pausesWithoutProgress = managed => managed.continuationPolicy === 'progres
2137
2351
  function recordManagedOutcomeProgress(run, now, assessmentAvailable = true) {
2138
2352
  const managed = run.managed;
2139
2353
  if (managed?.continuationPolicy !== 'progress') return;
2140
- const current = managed.acceptanceCriteria.map(criterion => ({ id: criterion.id, statement: criterion.description }));
2141
- const objectiveDigest = digest(JSON.stringify({ objective: run.objective, criteria: managed.acceptanceCriteria }));
2354
+ const current = [...managed.acceptanceCriteria.map(criterion => ({ id: criterion.id, statement: criterion.description })),
2355
+ ...(managed.requirementContract?.requirements || []).map(row => ({ id: `behavior:${row.id}`, statement: row.description }))];
2356
+ const objectiveDigest = digest(JSON.stringify({ objective: run.objective, criteria: managed.acceptanceCriteria,
2357
+ ...(managed.requirementContract ? { requirementContract: managed.requirementContract } : {}) }));
2142
2358
  const results = current.flatMap(criterion => {
2143
2359
  const observation = managed.verified[criterion.id];
2144
2360
  return observation ? [{ criterion_id: criterion.id, passed: true,
@@ -2286,7 +2502,7 @@ export function decideStop(run, now = Date.now()) {
2286
2502
  next.reason = 'check_reported_outcome';
2287
2503
  }
2288
2504
  const selected = next.managed?.autonomyPolicy ? next.managed.decision : null;
2289
- const instruction = inspectCompletion ? resultFollowThroughInstruction({ completion: true }) : selected && next.managed.phase === 'action_association' ? `${associationContract(next.managed.actionAssociation.job)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping.` : selected && next.managed.phase === 'understanding' ? `Understand the current user request in this ORIGINAL conversation. ${next.managed.understandingAnswer ? `The user answered question ${next.managed.understandingAnswer.questionId}: ${next.managed.understandingAnswer.text}. ` : ''}${understandingCheckpointContract(selected)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable. This updates SIMY metadata only; do not create a checkpoint file in the task workspace.` : selected ? `${initialHook ? `Managed objective: ${next.objective}. Frozen acceptance criteria: ${JSON.stringify(next.managed.acceptanceCriteria)}. ` : ''}${replanHook ? `Replan context: ${next.managed.plan.diagnosis}. ` : ''}Execute only selected ${selected.action.kind} action in scope ${selected.action.scope}: ${selected.action.summary}. Expected verification: ${selected.expectedVerification}. Rollback: ${selected.rollback || 'none needed'}. ${assessedCheckpointContract(selected, next.managed)} Submit that JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable; do not create a task-workspace checkpoint file.` : fresh ? `${minimalSufficientChangeInstruction()}\n${informationWaitInstruction}\n${next.nextStep}` : `${minimalSufficientChangeInstruction()}\n${informationWaitInstruction}\n${resultFollowThroughInstruction()}`;
2505
+ const instruction = inspectCompletion ? resultFollowThroughInstruction({ completion: true }) : selected && next.managed.phase === 'action_association' ? `${associationContract(next.managed.actionAssociation.job)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping.` : selected && next.managed.phase === 'understanding' ? `${understandingTurnInstruction(next.managed, selected)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable. This updates SIMY metadata only; do not create a checkpoint file in the task workspace.` : selected ? `${initialHook ? `Managed objective: ${next.objective}. Frozen acceptance criteria: ${JSON.stringify(next.managed.acceptanceCriteria)}. ` : ''}${replanHook ? `Replan context: ${next.managed.plan.diagnosis}. ` : ''}Execute only selected ${selected.action.kind} action in scope ${selected.action.scope}: ${selected.action.summary}. Expected verification: ${selected.expectedVerification}. Rollback: ${selected.rollback || 'none needed'}. ${assessedCheckpointContract(selected, next.managed)} Submit that JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable; do not create a task-workspace checkpoint file.` : fresh ? `${minimalSufficientChangeInstruction()}\n${informationWaitInstruction}\n${next.nextStep}` : `${minimalSufficientChangeInstruction()}\n${informationWaitInstruction}\n${resultFollowThroughInstruction()}`;
2290
2506
  return { run: next, output: { decision: 'block', reason: `SIMY Delivery Loop (${next.continuations} continuations). ${next.executionCwd ? `Use task execution directory ${JSON.stringify(next.executionCwd)} for task work and acceptance. ` : ''}${deliveryAcceptanceContext(next)} ${instruction}\n${purposeContract(next.managed)}\nStay within the original request and permissions. If one action lacks authorization or information, preserve that dependency and select independent authorized work. Resume this same task; do not start a replacement loop.` } };
2291
2507
  }
2292
2508
  // A process signature protects against PID reuse. Never treat a stale timer alone
@@ -2695,7 +2911,7 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
2695
2911
  if (run.managed && run.checkpoint?.status === 'completed' && run.revision > run.consumedRevision) {
2696
2912
  await verifyAutomaticCriteria(record, now);
2697
2913
  const pending = pendingAcceptance(run);
2698
- const missingFiles = pending.filter(item => ['file_exists', 'file_contains', 'file_equals'].includes(item.check) || run.managed.liveOutcomeReview?.passed === false && item.check === 'semantic');
2914
+ const missingFiles = pending.filter(item => ['file_exists', 'file_contains', 'file_equals', 'behavior'].includes(item.check) || run.managed.liveOutcomeReview?.passed === false && item.check === 'semantic');
2699
2915
  if (missingFiles.length && run.managed.autonomyPolicy && !run.managed.pendingApproval &&
2700
2916
  !run.managed.nativeWait && !run.managed.purpose?.nativeBoundary && !run.managed.executionUncertain) {
2701
2917
  // Completion reads at checkpoint time can become stale before Stop.
@@ -5,7 +5,7 @@ import { resolve, relative, isAbsolute, join } from 'node:path';
5
5
 
6
6
  // Completion reviews bind to the actual local state. No resets, stashes, writes,
7
7
  // shell commands from a worker, or special verifier registration are needed.
8
- export async function workspaceIdentity(cwd, stateRoot, inventory, { signal, excludedPaths = [], inventoryLimit = 256,
8
+ export async function workspaceIdentity(cwd, stateRoot, inventory, { signal, excludedPaths = [], inventoryLimit = 256, inventoryPaths,
9
9
  maxEntries = Infinity, maxBytes = Infinity, deadline = Infinity } = {}) {
10
10
  signal?.throwIfAborted();
11
11
  let entries = 0, bytes = 0;
@@ -18,6 +18,9 @@ export async function workspaceIdentity(cwd, stateRoot, inventory, { signal, exc
18
18
  const paths = [];
19
19
  const capture = entry => {
20
20
  if (!inventory) return;
21
+ // A trusted read-only consumer may request only its relevant inventory
22
+ // rows. Every workspace byte/mode is still hashed and race-checked below.
23
+ if (inventoryPaths && !inventoryPaths.includes(entry.path)) return;
21
24
  if (inventory.length < inventoryLimit) inventory.push(entry);
22
25
  else inventory.truncated = true;
23
26
  };