@awak-app/simy-cli 0.6.18 → 0.6.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -107,6 +107,27 @@ run retains the legacy metadata endpoint
107
107
  no longer impose local limits and changing them never resets usage or resumes work. Both routes require the original authorized
108
108
  account, organization and device throughout the request.
109
109
 
110
+ ## Provider reconnection and saved consent
111
+
112
+ Disconnecting an AI provider revokes its conversation monitoring and automatic
113
+ continuation with a durable connection epoch. Reconnecting restores discovery;
114
+ previous work remains paused until explicitly selected again. Old or damaged
115
+ conversation locks do not block this operation and are never deleted by it.
116
+ Individual locked conversations may still require support before they can be
117
+ edited or continued. Provider login is separate: for a signed-out Codex CLI,
118
+ run `codex login` in Terminal and refresh connections.
119
+
120
+ After the first connection change, the saved settings deliberately appear
121
+ **disconnected to older CLI/plugin versions** that cannot enforce the consent
122
+ epoch. Update the runtime and installed SIMY plugins together and start a fresh
123
+ provider session before using their hooks. Do not edit the saved `connected`
124
+ flag to bypass this fence. A rollback to older code leaves those integrations
125
+ disconnected; pause work and use a compatible version to restore the connection.
126
+ Connection failures write only provider, operation, and a bounded error code to
127
+ the private `logs/desktop-host.log`; they do not include credentials or conversation
128
+ content. Verify reconnect, fresh explicit consent, and that previous work remains
129
+ paused when validating a rollout. This does not prove provider model access.
130
+
110
131
  ## Apple Silicon Mac app
111
132
 
112
133
  The macOS installer includes a native status window and menu bar app. Open
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.6.18",
3
+ "version": "0.6.20",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.6.18";
16
+ export const CLI_VERSION = "0.6.20";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -8,6 +8,7 @@ import { execFile } from 'node:child_process';
8
8
  import { promisify } from 'node:util';
9
9
  import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
10
10
  import { associationContract, validateAssociationResult } from './action-association.mjs';
11
+ import { minimalSufficientChangeInstruction } from './minimal-sufficient.mjs';
11
12
  import { continuationMode } from './continuation-policy.mjs';
12
13
  import { evaluateOutcomeProgress, restartOutcomeProgressWindow } from './outcome-progress.mjs';
13
14
 
@@ -36,11 +37,29 @@ export async function providerLinkState(root, provider) {
36
37
  const valid = settings?.schemaVersion === 1 && ['codex', 'claude'].every(key => {
37
38
  const value = settings.providers?.[key];
38
39
  return value && typeof value.connected === 'boolean' && typeof value.requiresConsent === 'boolean' &&
39
- (value.epoch === null || typeof value.epoch === 'string' && value.epoch.length > 0 && value.epoch.length <= 128);
40
+ (value.epoch === null || typeof value.epoch === 'string' && value.epoch.length > 0 && value.epoch.length <= 128) &&
41
+ (value.consentEpoch === undefined || value.consentEpoch === value.epoch && Boolean(value.consentEpoch) && value.connected === false && typeof value.enabled === 'boolean');
40
42
  });
41
- return valid ? settings.providers[provider] ?? { connected: false, epoch: null } : { connected: false, epoch: null };
43
+ const value = valid ? settings.providers[provider] : null;
44
+ return value ? { ...value, connected: value.consentEpoch ? value.enabled : value.connected } : { connected: false, epoch: null };
42
45
  } catch (error) { return { connected: error.code === 'ENOENT', epoch: null }; }
43
46
  }
47
+ // Revocation is durable before touching any conversation lock. Project it on every
48
+ // read (including cached records) and under the write lock, so stale consent never
49
+ // returns after reconnect, restart, or a late writer from the previous epoch.
50
+ function applyProviderConsent(record, link) {
51
+ if (!record || !link.consentEpoch || record.providerConsentEpoch === link.consentEpoch) return record;
52
+ return { ...record, providerConsentEpoch: link.consentEpoch,
53
+ autoContinue: false, autoContinueEpoch: null, tracked: false, monitor: false,
54
+ ...(record.run && !TERMINAL.has(record.run.state) ? { run: { ...record.run,
55
+ state: 'paused', reason: 'SIMY connection changed. Select this conversation again before continuing.',
56
+ revision: (record.run.revision || 0) + 1,
57
+ ...(record.run.managed ? { managed: { ...record.run.managed, autoRecovery: false } } : {}) } } : {}) };
58
+ }
59
+ async function currentProviderConsent(root, records) {
60
+ const links = Object.fromEntries(await Promise.all(['codex', 'claude'].map(async provider => [provider, await providerLinkState(root, provider)])));
61
+ return records.map(record => applyProviderConsent(record, links[record.provider] || {}));
62
+ }
44
63
  export async function linkedProvider(root, provider) {
45
64
  return (await providerLinkState(root, provider)).connected === true;
46
65
  }
@@ -157,8 +176,9 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
157
176
  const lockToken = await acquireStateLock(lock, inspectProcess);
158
177
  let temporary;
159
178
  try {
160
- const previous = await read(file);
161
- const previousBytes = previous === null ? null : JSON.stringify(previous);
179
+ const stored = await read(file);
180
+ const previous = applyProviderConsent(stored, await providerLinkState(root, provider));
181
+ const previousBytes = stored === null ? null : JSON.stringify(stored);
162
182
  const next = await mutate(previous);
163
183
  if (next) {
164
184
  // Older file_equals drafts used `path`, which an older verifier could
@@ -183,7 +203,7 @@ export async function readRecord(root = rootDirectory(), provider, session) {
183
203
  const path = recordPath(root, provider, session);
184
204
  try {
185
205
  const record = await read(path);
186
- return record?.schemaVersion === 1 && record.provider === provider && record.session === session ? record : null;
206
+ return record?.schemaVersion === 1 && record.provider === provider && record.session === session ? applyProviderConsent(record, await providerLinkState(root, provider)) : null;
187
207
  } catch { return null; } // Match listRecords' treatment of unreadable records.
188
208
  }
189
209
  const RECORD_NAME = /^[a-f0-9]{64}\.json$/;
@@ -208,7 +228,7 @@ async function readNamedRecord(root, name) {
208
228
  } catch { return null; }
209
229
  }
210
230
  export async function listRecords(root = rootDirectory()) {
211
- return mapRecordNames(await recordNames(root), name => readNamedRecord(root, name));
231
+ return currentProviderConsent(root, await mapRecordNames(await recordNames(root), name => readNamedRecord(root, name)));
212
232
  }
213
233
 
214
234
  // A shared candidate/display index, never mutation authority. Every list checks
@@ -243,7 +263,7 @@ export function createRecordReader(root = rootDirectory()) {
243
263
  if (!inFlight) inFlight = scan().finally(() => { inFlight = null; });
244
264
  // Consumers can decorate/mutate their own snapshots without corrupting the
245
265
  // cached records or a simultaneous caller's view.
246
- return structuredClone(await inFlight);
266
+ return currentProviderConsent(root, structuredClone(await inFlight));
247
267
  },
248
268
  async close() { closed = true; await inFlight?.catch(() => {}); cache.clear(); },
249
269
  };
@@ -318,10 +338,10 @@ export function evaluateAssessedPlan(plan, policy, scopes, now = Date.now()) {
318
338
  // Shared by live Stop hooks and cold exact-session recovery. Keep the result
319
339
  // contract identical across transports; the host still verifies acceptance.
320
340
  export function assessedCheckpointContract(decision) {
321
- return `Return 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",evidence:["concrete observation"]} for THIS selected action. This is provider-reported, not independent verification. 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. 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. Missing facts are unknown. Completed requires evidence, and SIMY independently checks frozen acceptance. 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. missing_information requires a specific question. external_dependency requires waitCondition:{condition,owner,nextCheck} with the observable resumption condition, dependency owner, and bounded recheck instruction, plus nextStep.`;
341
+ 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",evidence:["concrete observation"]} for THIS selected action. This is provider-reported, not independent verification. 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. 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. Missing facts are unknown. Completed requires evidence, and SIMY independently checks frozen acceptance. 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. missing_information requires a specific question. external_dependency requires waitCondition:{condition,owner,nextCheck} with the observable resumption condition, dependency owner, and bounded recheck instruction, plus nextStep.`;
322
342
  }
323
343
  export function understandingCheckpointContract(decision) {
324
- return `This is an UNDERSTANDING turn, not a work turn. Read the existing conversation context and available read-only evidence; 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|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 a read-only single-file action with no effects, use reversible, none, single_file when those facts are known; unknown facts require review. 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. For semantic conditions use manual. If the complete expected file contents are not yet known from the original user request or observed evidence in this understanding turn, use manual for that criterion and choose a read as the next action to learn them. Never emit file_equals with null, omitted, guessed, or placeholder text; an exact file check requires a bounded concrete UTF-8 string now. Do not perform the proposed next action merely to fill the check, and do not claim that a later read or model report has independently verified a manual criterion. 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. If material context is missing, set exactly one ambiguity and one focused question, nextAction:null, and omit plan. Provider statements about past work and semantic success are not 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.`;
344
+ return `${minimalSufficientChangeInstruction({ turn: 'understanding' })}\nThis is an UNDERSTANDING turn, not a work turn. Read the existing conversation context and available read-only evidence; 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|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 a read-only single-file action with no effects, use reversible, none, single_file when those facts are known; unknown facts require review. 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. For semantic conditions use manual. If the complete expected file contents are not yet known from the original user request or observed evidence in this understanding turn, use manual for that criterion and choose a read as the next action to learn them. Never emit file_equals with null, omitted, guessed, or placeholder text; an exact file check requires a bounded concrete UTF-8 string now. Do not perform the proposed next action merely to fill the check, and do not claim that a later read or model report has independently verified a manual criterion. 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. If material context is missing, set exactly one ambiguity and one focused question, nextAction:null, and omit plan. Provider statements about past work and semantic success are not 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.`;
325
345
  }
326
346
  function boundedContextList(value, maxItems = 8, maxLength = 300) {
327
347
  if (!Array.isArray(value) || value.length > maxItems) throw new Error('Session understanding has too many items.');
@@ -1030,7 +1050,7 @@ export function decideStop(run, now = Date.now()) {
1030
1050
  }
1031
1051
  next.reason = fresh ? 'continue_pending_work' : 'checkpoint_missing';
1032
1052
  const selected = next.managed?.autonomyPolicy ? next.managed.decision : null;
1033
- const instruction = 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)} 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 ? next.nextStep : `Check the actual delivery status first. Record an honest checkpoint with simy delivery checkpoint before stopping; do not claim completion without verification. Pending step: ${next.nextStep}`;
1053
+ const instruction = 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)} 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${next.nextStep}` : `${minimalSufficientChangeInstruction()}\nCheck the actual delivery status first. Record an honest checkpoint with simy delivery checkpoint before stopping; do not claim completion without verification. Pending step: ${next.nextStep}`;
1034
1054
  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}\nStay within the original request and permissions. If authorization or information is missing, record a blocked checkpoint and stop. Resume this same task; do not start a replacement loop.` } };
1035
1055
  }
1036
1056
  // A process signature protects against PID reuse. Never treat a stale timer alone
@@ -0,0 +1,15 @@
1
+ export function minimalSufficientChangeInstruction({ auditor = false, turn = 'implementation' } = {}) {
2
+ return [
3
+ turn === 'understanding'
4
+ ? "For planning only, propose the smallest change sufficient for the original purpose and acceptance criteria. Explain which criterion or observed failure each proposed behavior, abstraction, configuration, compatibility branch, document or test would serve."
5
+ : "Choose the smallest change sufficient for the original purpose and acceptance criteria. Explain which criterion or observed failure each substantive behavior, abstraction, configuration, compatibility branch, document or test serves.",
6
+ "Prefer existing conventions and a simpler equivalent. Avoid speculative features, generalization, duplicate implementations and repetitive prose or tests. Do not optimize line counts or remove required behavior, authorization, data integrity, concurrency, failure recovery, regression coverage or evidence to make a change shorter.",
7
+ auditor
8
+ ? "Independently inspect the final diff for unnecessary scope and complexity. Report concrete findings with file/line, harm or maintenance burden, a smaller alternative, preserved criteria and required verification; do not invent findings when the change is already sufficient."
9
+ : turn === 'understanding'
10
+ ? "Use read-only evidence to plan; do not inspect the final diff as a completion step or perform cleanup in this understanding turn. Record any cleanup proposal for a next assessed action, without executing it."
11
+ : turn === 'assessed'
12
+ ? "Execute only the selected decision.action. Cleanup is allowed only when explicitly included in that selected action and within its scope, expected verification and rollback. Defer any additional diff inspection or cleanup: report it in the existing checkpoint or propose it as the next assessed action, without executing it in this turn. Summarize the selected action's sufficiency in the existing result; a plan or reduced diff is not proof of acceptance."
13
+ : "Before completion, inspect the final diff against the original purpose and remove unnecessary scope or complexity only when required behavior and verification remain intact. Summarize that judgment in the existing result; a plan or reduced diff is not proof of acceptance.",
14
+ ].join("\n");
15
+ }
@@ -1,3 +1,4 @@
1
+ import { minimalSufficientChangeInstruction } from '../codex-plugins/simy/hooks/minimal-sufficient.mjs';
1
2
  import { requireProviderLink } from '../provider-links.js';
2
3
  import { spawn } from 'node:child_process';
3
4
  import { randomUUID } from 'node:crypto';
@@ -83,7 +84,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
83
84
  remainingWork: run.managed.remainingWork, acceptanceCriteria: run.managed.acceptanceCriteria, proposedAction: run.managed.proposedAction,
84
85
  currentDecision: run.managed.autonomyPolicy ? run.managed.decision : undefined, diagnosis: run.managed.plan?.diagnosis,
85
86
  progress: deliveryProgressContext(run) };
86
- const prompt = run.managed.autonomyPolicy ? `Continue the existing SIMY managed task in this exact session. Context: ${JSON.stringify(context)}\nExecute ONLY currentDecision.action. Its expectedVerification and rollback apply to this turn. Stay within provider permissions. End with ONLY one JSON checkpoint. ${assessedCheckpointContract(run.managed.decision)} Do not write SIMY state directly.` : `Continue the existing SIMY managed task in this exact session. Context: ${JSON.stringify(context)}\nStay within the original task and provider permissions. End with ONLY one JSON object: {"status":"in_progress|completed|blocked|error","summary":"...","nextStep":"...","remainingWork":"...","proposedAction":{"kind":"read|local_edit|local_test|external|destructive|scope_change|unknown","scope":"...","summary":"..."},"evidence":[],"acceptanceMet":false}. For blocked include blocker (authorization, missing_information, external_dependency), and for authorization include approval {kind,scope,summary}. Do not claim completion without checking frozen acceptance. SIMY independently verifies supported criteria. Do not write SIMY state directly. If approval or input is needed, return blocked and stop.`;
87
+ const prompt = run.managed.autonomyPolicy ? `Continue the existing SIMY managed task in this exact session. Context: ${JSON.stringify(context)}\nExecute ONLY currentDecision.action. Its expectedVerification and rollback apply to this turn. Stay within provider permissions. End with ONLY one JSON checkpoint. ${assessedCheckpointContract(run.managed.decision)} Do not write SIMY state directly.` : `Continue the existing SIMY managed task in this exact session. Context: ${JSON.stringify(context)}\n${minimalSufficientChangeInstruction()}\nStay within the original task and provider permissions. End with ONLY one JSON object: {"status":"in_progress|completed|blocked|error","summary":"...","nextStep":"...","remainingWork":"...","proposedAction":{"kind":"read|local_edit|local_test|external|destructive|scope_change|unknown","scope":"...","summary":"..."},"evidence":[],"acceptanceMet":false}. For blocked include blocker (authorization, missing_information, external_dependency), and for authorization include approval {kind,scope,summary}. Do not claim completion without checking frozen acceptance. SIMY independently verifies supported criteria. Do not write SIMY state directly. If approval or input is needed, return blocked and stop.`;
87
88
  const sandbox = run.managed.autonomyPolicy && run.managed.decision?.action.kind === 'read' ? 'read-only' : run.managed.autoRecoverySandbox;
88
89
  const args = ['exec', '--json', '--skip-git-repo-check', '-s', sandbox, '-m', run.managed.resumeModel, 'resume', record.session, prompt];
89
90
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
@@ -66,7 +66,12 @@ async function withReadCancellation(signal, work) {
66
66
  function desktopActionError(action, error) {
67
67
  if (error?.code === "provider_link_changed") return "SIMY connection changed. Refresh and select the conversation again.";
68
68
  if (error?.code === "provider_link_disconnected") return "Reconnect this provider to SIMY before using it.";
69
- if (["provider_connect", "provider_disconnect"].includes(action)) return "Could not finish changing the SIMY connection. Refresh its status and try again.";
69
+ if (["provider_connect", "provider_disconnect"].includes(action)) {
70
+ if (error?.code === 'desktop_access_required') return "Sign in to your SIMY workspace, then try connecting again.";
71
+ if (['EACCES', 'EPERM', 'EROFS'].includes(error?.code)) return "SIMY could not save the connection settings. Check access to its local settings folder, then retry.";
72
+ if (error?.code === 'ENOSPC') return "SIMY could not save the connection settings. Free disk space, then retry.";
73
+ return "SIMY could not change the connection. Reopen SIMY and retry. If this continues, share its diagnostic log with support.";
74
+ }
70
75
  if (action === "session_set_auto_continue") {
71
76
  const messages = {
72
77
  session_workspace_changed: "The conversation workspace changed. Refresh and select it again.",
@@ -297,10 +302,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
297
302
  let organizationAnalysisDrain = Promise.resolve();
298
303
  let organizationAnalysisEpoch = 0;
299
304
  const authorizeWork = async expectedScope => {
300
- if (closed || stoppingForUpdate || updater?.acceptsNewWork?.() === false || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
305
+ if (closed || stoppingForUpdate || updater?.acceptsNewWork?.() === false || organizationAnalysesSuspended) throw Object.assign(new Error("SIMY is stopping. Reopen SIMY before starting new work."), { code: "desktop_stopping" });
301
306
  if (agent?.requireDesktopAccess) await agent.requireDesktopAccess();
302
307
  const access = await accessSnapshot();
303
- if (closed || stoppingForUpdate || updater?.acceptsNewWork?.() === false || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
308
+ if (closed || stoppingForUpdate || updater?.acceptsNewWork?.() === false || organizationAnalysesSuspended) throw Object.assign(new Error("SIMY is stopping. Reopen SIMY before starting new work."), { code: "desktop_stopping" });
304
309
  requireDesktopAccess(access);
305
310
  const scope = accessScope(access);
306
311
  if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
@@ -406,29 +411,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
406
411
  selectedLinkEpochs.set(selected, link.epoch);
407
412
  return selected;
408
413
  };
409
- const revokeProviderConsent = async provider => {
410
- const records = (await recordReader.list()).filter(record => record.provider === provider);
411
- for (const record of records) {
412
- automaticContinuation?.revoke(provider, record.session);
413
- await updateRecord(deliveryRoot, provider, record.session, previous => {
414
- if (!previous) return previous;
415
- const next = { ...previous, autoContinue: false, autoContinueEpoch: randomUUID(), tracked: false, monitor: false };
416
- if (previous.run && !TERMINAL.has(previous.run.state)) {
417
- next.run = { ...previous.run, state: "paused", reason: "Provider disconnected from SIMY.",
418
- revision: (previous.run.revision || 0) + 1, updatedAt: Date.now(),
419
- ...(previous.run.managed ? { managed: { ...previous.run.managed, autoRecovery: false } } : {}) };
420
- }
421
- return next;
422
- });
423
- }
424
- };
425
414
  const changeProviderLink = async (provider, connected) => {
426
415
  if (!PROVIDER_LINKS.includes(provider)) throw new Error("Unsupported SIMY provider connection.");
427
416
  if (providerLinkBusy.has(provider)) throw new Error("SIMY connection is already changing.");
428
417
  providerLinkBusy.add(provider);
429
418
  providerLinkFeedback[provider] = { state: connected ? "connecting" : "disconnecting" };
419
+ let phase = "read_settings";
430
420
  try {
431
421
  providerLinks = await readProviderLinks(root);
422
+ phase = "save_disconnect";
432
423
  if (!connected) providerLinks = await writeProviderLink(root, provider, false);
433
424
  if (activeStatisticsRead?.key.startsWith(`${provider}:`)) activeStatisticsRead.controller.abort();
434
425
  discoveryController?.abort();
@@ -437,13 +428,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
437
428
  for (const key of explicitAnalyses) if (key.startsWith(`${provider}:`)) explicitAnalyses.delete(key);
438
429
  statisticsCache.clear();
439
430
  if (!connected) sessionStatus = { ...sessionStatus, sessions: linkedSessions(sessionStatus.sessions) };
431
+ phase = "cancel_analysis";
440
432
  await analysisManager.update(analysisSessions(sessionStatus.sessions));
433
+ phase = "publish_status";
441
434
  await publish();
442
- // A crash after saving disconnected must not restore old session consent
443
- // when the user reconnects. Revoke before persisting connected again.
444
- if (!connected || !providerLinks[provider].connected) await revokeProviderConsent(provider);
435
+ // The provider epoch revokes old consent atomically, independent of stale
436
+ // conversation locks. Record readers and writers enforce that boundary.
445
437
  if (connected && !providerLinks[provider].connected) {
438
+ phase = "authorize_connect";
446
439
  await authorizeWork();
440
+ phase = "save_connect";
447
441
  providerLinks = await writeProviderLink(root, provider, true);
448
442
  }
449
443
  delete providerLinkFeedback[provider];
@@ -451,6 +445,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
451
445
  catch { providerLinkFeedback[provider] = { message: "SIMY connection saved. Provider status could not be verified; refresh connections to check it." }; }
452
446
  refreshSessions(true);
453
447
  } catch (error) {
448
+ diagnostics.write(`provider connection phase provider=${provider} phase=${phase}`);
454
449
  providerLinkFeedback[provider] = { state: "error", message: desktopActionError(connected ? "provider_connect" : "provider_disconnect", error) };
455
450
  throw error;
456
451
  } finally { providerLinkBusy.delete(provider); await publish(); }
@@ -1327,6 +1322,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
1327
1322
  emit({ type: "result", id: command.id, ok: true });
1328
1323
  await publish();
1329
1324
  } catch (error) {
1325
+ if (["provider_connect", "provider_disconnect"].includes(command.action)) {
1326
+ const code = ['desktop_access_required', 'desktop_stopping', 'EACCES', 'EPERM', 'ENOSPC', 'EROFS', 'EBUSY', 'ENOENT', 'ERR_INVALID_ARG_TYPE'].includes(error?.code) ? error.code : 'provider_connection_failed';
1327
+ const provider = PROVIDER_LINKS.includes(command.provider) ? command.provider : 'unsupported';
1328
+ const kind = ['Error', 'TypeError', 'RangeError', 'SyntaxError'].includes(error?.name) ? error.name : 'Error';
1329
+ const locations = [...String(error?.stack || '').matchAll(/(?:desktop-host|agent|provider-links|delivery-loop|analysis-manager)\.m?js:\d{1,6}:\d{1,6}/g)].slice(0, 3).map(match => match[0]).join(',') || 'unknown';
1330
+ diagnostics.write(`provider connection failed provider=${provider} operation=${command.action} code=${code} kind=${kind} locations=${locations}`);
1331
+ }
1330
1332
  emit({ type: "result", id: command.id, ok: false, ...(error?.code === 'cancelled' || statisticsRequest?.controller.signal.aborted ? { code: 'cancelled' } : {}), message: ["update", "prepare_update_restart"].includes(command.action) && updateProgress ? updateProgress.message : desktopActionError(command.action, error) });
1331
1333
  } finally {
1332
1334
  // Access rejection can happen before the shared reader is constructed.
@@ -521,6 +521,14 @@ boundary from a simplified unit fixture.
521
521
 
522
522
  ### 2. Isolate and implement
523
523
 
524
+ Choose the smallest change sufficient for the original purpose and acceptance.
525
+ Tie substantive behavior, abstractions, configuration, compatibility, docs and
526
+ tests to a criterion or observed failure. Independent review must identify
527
+ unnecessary scope or complexity with file/line, burden, a smaller alternative,
528
+ preserved criteria and required verification. Do not optimize line counts or
529
+ remove required behavior, authorization, data integrity, concurrency, recovery,
530
+ regression coverage or evidence to shorten the diff.
531
+
524
532
  Start from the latest required base in a dedicated worktree. Preserve user
525
533
  changes. Build the smallest behaviorally correct patch and add a regression
526
534
  that fails against the pre-fix behavior at the escaped boundary. Derive its
@@ -1,3 +1,4 @@
1
+ import { minimalSufficientChangeInstruction } from "../codex-plugins/simy/hooks/minimal-sufficient.mjs";
1
2
  import { buildAcceptedHumanGuidanceInstruction, buildWorkflowReportingInstruction } from "../workflow-reporting.js";
2
3
  import { bullets, cleanString, section } from "./shared.js";
3
4
  import { buildPrClosureInstruction } from "./pr-closure.js";
@@ -26,6 +27,7 @@ export function buildIndependentAuditInstruction(charter, attempt) {
26
27
 
27
28
  return [
28
29
  buildUxSpecificationInstruction({ auditor: true }),
30
+ section("minimal_sufficient_change", minimalSufficientChangeInstruction({ auditor: true })),
29
31
  buildDeliveryLevelInstruction({ auditor: true }),
30
32
  buildChecklistContinuationInstruction({ auditor: true }),
31
33
  buildWorkflowReportingInstruction({ auditor: true }),
@@ -1,3 +1,4 @@
1
+ import { minimalSufficientChangeInstruction } from "../codex-plugins/simy/hooks/minimal-sufficient.mjs";
1
2
  import { buildAcceptedHumanGuidanceInstruction, buildWorkflowReportingInstruction } from "../workflow-reporting.js";
2
3
  import { minimumCharter } from "./delivery/scripts/check-minimum-completion.mjs";
3
4
  import { bullets, looksLikeUiTask, section } from "./shared.js";
@@ -23,6 +24,7 @@ export function buildCodingInstruction(
23
24
  const sections = [
24
25
  section("role", "You are the local coding executor for a SIMY agentic loop."),
25
26
  buildUxSpecificationInstruction(),
27
+ section("minimal_sufficient_change", minimalSufficientChangeInstruction()),
26
28
  buildDeliveryLevelInstruction(),
27
29
  buildChecklistContinuationInstruction(),
28
30
  buildWorkflowReportingInstruction(),
@@ -17,10 +17,12 @@ async function readSettings(root) {
17
17
  if (payload?.schemaVersion !== 1 || !payload.providers || PROVIDER_LINKS.some(provider => {
18
18
  const value = payload.providers[provider];
19
19
  return !value || typeof value.connected !== 'boolean' || typeof value.requiresConsent !== 'boolean' ||
20
- value.epoch !== null && (typeof value.epoch !== 'string' || !value.epoch || value.epoch.length > 128);
20
+ value.epoch !== null && (typeof value.epoch !== 'string' || !value.epoch || value.epoch.length > 128) ||
21
+ value.consentEpoch !== undefined && (value.consentEpoch !== value.epoch || !value.consentEpoch || value.connected !== false || typeof value.enabled !== 'boolean');
21
22
  })) throw new Error('Invalid SIMY provider connection settings.');
22
23
  return Object.fromEntries(PROVIDER_LINKS.map(provider => [provider, {
23
- connected: payload.providers[provider].connected,
24
+ connected: payload.providers[provider].consentEpoch ? payload.providers[provider].enabled : payload.providers[provider].connected,
25
+ ...(payload.providers[provider].consentEpoch ? { consentEpoch: payload.providers[provider].consentEpoch } : {}),
24
26
  epoch: payload.providers[provider].epoch,
25
27
  requiresConsent: payload.providers[provider].requiresConsent,
26
28
  }]));
@@ -59,11 +61,15 @@ export function writeProviderLink(root = defaultRoot(), provider, connected) {
59
61
  const providers = await readSettings(root);
60
62
  // Reconnection restores discovery only. Session execution consent is cleared
61
63
  // separately and must be selected again, including after a host restart.
62
- providers[provider] = { connected, epoch: randomUUID(), requiresConsent: true };
64
+ const epoch = randomUUID();
65
+ providers[provider] = { connected, epoch, consentEpoch: epoch, requiresConsent: true };
66
+ // Old plugins must remain disconnected: they cannot enforce epoch revocation.
67
+ const saved = Object.fromEntries(Object.entries(providers).map(([key, value]) => [key,
68
+ value.consentEpoch ? { ...value, connected: false, enabled: value.connected } : value]));
63
69
  await mkdir(root, { recursive: true, mode: 0o700 });
64
70
  const target = join(root, 'provider-links.json'), temporary = `${target}.${randomUUID()}.tmp`;
65
71
  try {
66
- await writeFile(temporary, `${JSON.stringify({ schemaVersion: 1, providers })}\n`, { mode: 0o600 });
72
+ await writeFile(temporary, `${JSON.stringify({ schemaVersion: 1, providers: saved })}\n`, { mode: 0o600 });
67
73
  await rename(temporary, target);
68
74
  } finally { await rm(temporary, { force: true }); }
69
75
  return providers;
@@ -307,7 +307,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
307
307
  candidate = observedCandidate(candidate);
308
308
  const revision = continuationAnalysisRevision(candidate), entry = entries.get(keyOf(candidate));
309
309
  let analysis = entry?.revision === revision ? { ...entry.result, revision, requestId: entry.requestId } : { state: 'unanalysed', revision };
310
- const previous = entry?.cwd === candidate.cwd ? entry.settledResult : null;
310
+ const previous = entry && entry.cwd === candidate.cwd ? entry.settledResult : null;
311
311
  if (candidate.isRunning === true || analysis.awaitingTurn === true) {
312
312
  analysis = { state: 'waiting', awaitingTurn: true, revision,
313
313
  reason: 'This turn is still running. SIMY analyzes only finished turns and will update automatically when it ends.',
@@ -316,7 +316,7 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
316
316
  ...(continuationPolicy ?? savedPolicy) === undefined ? {} : { continuationPolicy: continuationPolicy ?? savedPolicy } };
317
317
  if (settings.continuationPolicy !== undefined && !['bounded', 'progress'].includes(settings.continuationPolicy)) throw new Error('Choose a continuation policy.');
318
318
  if (previous.run && continuationPolicy !== undefined) throw new Error('Change an existing task through its explicit continuation controls.');
319
- const next = { ...previous, autoContinue: enabled, autoContinueEpoch: epoch,
319
+ const next = { ...previous, ...(enabled && link.consentEpoch ? { providerConsentEpoch: link.consentEpoch } : {}), autoContinue: enabled, autoContinueEpoch: epoch,
320
320
  ...(enabled ? { tracked: true, monitor: true, autoContinueSettings: settings } : { tracked: false, monitor: false }) };
321
321
  // Adopt existing supervision without restarting it or renewing its budgets.
322
322
  if (next.run?.managed) next.run.managed.autoContinueEpoch = epoch;
@@ -357,7 +357,8 @@ export async function trackSession({ root, provider, session, tracked = true, op
357
357
  await requireProviderLink({ root, provider, expectedEpoch: link.epoch });
358
358
  if (previous?.providerArchived === true) throw Object.assign(new Error('Restore the original provider conversation before enabling automatic continuation or tracking.'), { code: 'session_archived' });
359
359
  if (previous && previous.cwd !== cwd) throw new Error('Session workspace changed; refresh its metadata.');
360
- return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }), tracked: true, monitor: true,
360
+ return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }),
361
+ ...(link.consentEpoch ? { providerConsentEpoch: link.consentEpoch } : {}), tracked: true, monitor: true,
361
362
  admission: { ...(previous?.admission || {}), source: metadata.observation.source, provider, session, cwd, model: metadata.model, validatedAt: now } };
362
363
  });
363
364
  }
@@ -380,7 +381,8 @@ export async function monitorSession({ root, provider, session, monitor = true,
380
381
  await requireProviderLink({ root, provider, expectedEpoch: link.epoch });
381
382
  if (previous?.providerArchived === true) throw new Error('Restore the original provider conversation before enabling supervision.');
382
383
  if (previous && previous.cwd !== cwd) throw new Error('Session workspace changed; refresh its metadata.');
383
- return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }), monitor,
384
+ return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }),
385
+ ...(link.consentEpoch ? { providerConsentEpoch: link.consentEpoch } : {}), monitor,
384
386
  admission: { source: metadata.observation.source, provider, session, cwd, model: metadata.model, validatedAt: now } };
385
387
  });
386
388
  return metadata;
@@ -22,6 +22,7 @@ export async function claudePluginFiles() {
22
22
  files.set(relative, content.replaceAll("current Codex thread", "current Claude Code conversation"));
23
23
  }
24
24
  files.set("hooks/delivery-loop.mjs", await readFile(path.join(SOURCE, "hooks/delivery-loop.mjs"), "utf8"));
25
+ files.set("hooks/minimal-sufficient.mjs", await readFile(path.join(SOURCE, "hooks/minimal-sufficient.mjs"), "utf8"));
25
26
  files.set("hooks/outcome-progress.mjs", await readFile(path.join(SOURCE, "hooks/outcome-progress.mjs"), "utf8"));
26
27
  files.set("hooks/continuation-policy.mjs", await readFile(path.join(SOURCE, "hooks/continuation-policy.mjs"), "utf8"));
27
28
  files.set("hooks/action-association.mjs", await readFile(path.join(SOURCE, "hooks/action-association.mjs"), "utf8"));