@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 +21 -0
- package/package.json +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +30 -10
- package/src/codex-plugins/simy/hooks/minimal-sufficient.mjs +15 -0
- package/src/delivery-hooks/supervisor.js +2 -1
- package/src/desktop-host.js +24 -22
- package/src/orchestrator/delivery/WORKFLOW.md +8 -0
- package/src/orchestrator/independent-audit.js +2 -0
- package/src/orchestrator/instruction.js +2 -0
- package/src/provider-links.js +10 -4
- package/src/session-supervision/analysis-manager.js +1 -1
- package/src/session-supervision/index.js +5 -3
- package/src/sqm/claude-plugin.js +1 -0
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
package/src/cli-contract.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
161
|
-
const
|
|
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
|
|
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
|
|
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 :
|
|
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.');
|
package/src/desktop-host.js
CHANGED
|
@@ -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))
|
|
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
|
-
//
|
|
443
|
-
//
|
|
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(),
|
package/src/provider-links.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 }),
|
|
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 }),
|
|
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;
|
package/src/sqm/claude-plugin.js
CHANGED
|
@@ -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"));
|