@awak-app/simy-cli 0.7.8 → 0.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +16 -9
  2. package/package.json +4 -3
  3. package/src/cli-contract.js +1 -1
  4. package/src/codex-plugins/simy/.codex-plugin/plugin.json +5 -7
  5. package/src/codex-plugins/simy/hooks/README.md +3 -1
  6. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +12 -2
  7. package/src/codex-plugins/simy/hooks/hypothesis-sheet.mjs +1 -1
  8. package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +6 -5
  9. package/src/codex-plugins/simy/hooks/recovery-incident.mjs +191 -0
  10. package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +22 -0
  11. package/src/delivery-hooks/command.js +34 -1
  12. package/src/delivery-hooks/diagnosis-sheet.js +2 -2
  13. package/src/delivery-hooks/diagnosis.js +5 -3
  14. package/src/delivery-hooks/local-supervisor.js +7 -1
  15. package/src/delivery-hooks/recovery.js +33 -0
  16. package/src/delivery-hooks/supervisor-owner.js +74 -0
  17. package/src/delivery-hooks/supervisor-service.js +65 -0
  18. package/src/delivery-hooks/supervisor-watchdog.js +151 -0
  19. package/src/delivery-hooks/supervisor.js +26 -0
  20. package/src/direct-executor.js +2 -1
  21. package/src/orchestrator/instruction.js +4 -4
  22. package/src/orchestrator/loop.js +1 -1
  23. package/src/orchestrator/problem-solving.js +2 -2
  24. package/src/session-supervision/codex-app-server.js +9 -5
  25. package/src/session-supervision/continuation-analysis.js +3 -2
  26. package/src/session-supervision/purpose-instruction.js +10 -2
  27. package/src/session-supervision/purpose-recovery.js +7 -4
  28. package/src/sqm/claude-plugin.js +6 -0
  29. package/src/sqm/local-tests.js +1 -0
  30. package/src/sqm/review-worker.js +3 -0
  31. package/src/sqm/workflow-onboarding.js +1 -1
  32. /package/src/{codex-plugins/simy/skills/delivery-loop/SKILL.md → delivery-hooks/claude-workflow.md} +0 -0
@@ -23,6 +23,7 @@ export function diagnosisIssues(record, requested) {
23
23
  }
24
24
 
25
25
  export function readDiagnosisSheet(value, issues) {
26
+ if (value == null) return { sheet: null, diagnostic: null };
26
27
  // Preserve a useful diagnosis if advisory worksheet formatting is incomplete.
27
28
  try {
28
29
  const concise = (v, required = true) => typeof v === 'string' && v.length <= 600 && (!required || v.trim());
@@ -31,7 +32,6 @@ export function readDiagnosisSheet(value, issues) {
31
32
  const requirements = value.requirements.map(row => {
32
33
  if (!issues.some(i => i.id === row?.requirementId) || seen.has(row.requirementId) || !Array.isArray(row.hypotheses) || !row.hypotheses.length || row.hypotheses.length > 4 || !concise(row.breadthReason, false)) throw Error();
33
34
  seen.add(row.requirementId);
34
- if (row.hypotheses.length < 3 && !row.breadthReason.trim()) throw Error();
35
35
  const ids = new Set(), statements = new Set();
36
36
  const hypotheses = row.hypotheses.map((h, index) => {
37
37
  const canonical = typeof h?.statement === 'string' ? h.statement.trim().toLowerCase().replace(/\s+/g, ' ') : '';
@@ -48,4 +48,4 @@ export function readDiagnosisSheet(value, issues) {
48
48
  } catch { return { sheet: null, diagnostic: 'incomplete_advisory_sheet' }; }
49
49
  }
50
50
 
51
- export const diagnosisSheetInstruction = `ISSUE-FIRST WORKSHEET: Use the supplied issues as the rows, keeping their IDs. For EACH issue normally compare 3-4 distinct causes, ranked by likelihood, impact and smallest useful check, with a brief priorityReason. Never give just 3-4 causes for the entire task when several issues exist. Reuse relevant prior hypotheses and actual results from task.managed.purpose.hypothesisLedger; keep IDs stable, retain refuted causes as refuted, and do not repeat a rejected cause or unchanged test without new observations. When a simple cause is already established, fewer causes with breadthReason are better than invented alternatives. Record only the issue, ranked hypotheses, next check, short observed result (unverified if not run), and nextDecision. Describe what outcome would change the decision within verification when useful. No mandatory evidence attachments, proof documents, additional audit agent, exhaustive checklist or repeated full validation. Recheck only changed or still-uncertain aspects; never weaken user stops or real native permissions. This is advisory planning, not a new stop/approval/completion gate. Do not execute the checks. Return hypothesisSheet.requirements for every supplied issue. Keep the entire sheet under 24 KiB. Knowledge remains a concise reference with applicability reasons, not extra tests.`;
51
+ export const diagnosisSheetInstruction = `ISSUE-FIRST WORKSHEET: Use the supplied issues as the rows, keeping their IDs. For EACH issue apply the shared scene reasoning cycle; select the few useful interpretations and causes, with a brief priorityReason. Do not impose a fixed hypothesis count. Reuse relevant prior hypotheses and actual results from task.managed.purpose.hypothesisLedger; keep IDs stable, retain refuted causes as refuted, and do not repeat a rejected cause or unchanged test without new observations. When a simple cause is already established, one cause is enough; breadthReason is optional context. Record only the issue, ranked hypotheses, next check, short observed result (unverified if not run), and nextDecision. Describe what outcome would change the decision within verification when useful. No mandatory evidence attachments, proof documents, additional audit agent, exhaustive checklist or repeated full validation. Recheck only changed or still-uncertain aspects; never weaken user stops or real native permissions. This is advisory planning, not a new stop/approval/completion gate. Do not execute the checks. When a sheet helps, return hypothesisSheet.requirements for the supplied issues; otherwise return hypothesisSheet:null. Keep the entire sheet under 24 KiB. Knowledge remains a concise reference with applicability reasons, not extra tests.`;
@@ -1,3 +1,4 @@
1
+ import { withSceneReasoning } from '../codex-plugins/simy/hooks/scene-reasoning.mjs';
1
2
  import { hypothesisSheetSchema, diagnosisIssues, readDiagnosisSheet, diagnosisSheetInstruction } from './diagnosis-sheet.js';
2
3
  import { createHash } from 'node:crypto';
3
4
  import { dirname } from 'node:path';
@@ -17,7 +18,7 @@ const strings = { type: 'array', items: string, maxItems: 12 };
17
18
  const object = properties => ({ type: 'object', additionalProperties: false, properties, required: Object.keys(properties) });
18
19
  export const DIAGNOSIS_SCHEMA = object({
19
20
  summary: string,
20
- hypothesisSheet: hypothesisSheetSchema,
21
+ hypothesisSheet: { anyOf: [hypothesisSheetSchema, { type: 'null' }] },
21
22
  knowledgeReferences: { type: 'array', maxItems: 24, items: object({ id: string, reason: string }) },
22
23
  limitations: strings,
23
24
  });
@@ -63,7 +64,8 @@ function validateAnalysis(value, knowledge, issues) {
63
64
  !value.knowledgeReferences.every(r => knowledge.modules.some(m => m.id === r.id) && validText(r.reason)) ||
64
65
  new Set(value.knowledgeReferences.map(r => r.id)).size !== value.knowledgeReferences.length) throw new Error('Invalid diagnosis');
65
66
  const { sheet, diagnostic } = readDiagnosisSheet(value.hypothesisSheet, issues);
66
- return { summary: value.summary, hypothesisSheet: sheet, ...(diagnostic ? { sheetDiagnostic: diagnostic } : {}),
67
+ const sheetDiagnostic = diagnostic || (value.hypothesisSheet == null && value.sheetDiagnostic === 'incomplete_advisory_sheet' ? value.sheetDiagnostic : null);
68
+ return { summary: value.summary, hypothesisSheet: sheet, ...(sheetDiagnostic ? { sheetDiagnostic } : {}),
67
69
  knowledgeReferences: value.knowledgeReferences.map(({ id, reason }) => ({ id, reason })), limitations: value.limitations };
68
70
  }
69
71
 
@@ -116,7 +118,7 @@ export async function diagnoseDelivery({ root, webOrigin, provider, session, tas
116
118
  limitations: ['No task controls or proposed tests were executed. Retained history and bounded excerpts may be incomplete.', 'Diagnosis uses a separate provider process and may consume analysis usage; original task budgets are never reset or charged by this command.'] };
117
119
  if (!evidenceOnly && !signal?.aborted) {
118
120
  try {
119
- const result = await runProvider(record.provider, { system: `${SYSTEM}\n${diagnosisSheetInstruction}`, prompt: JSON.stringify({ ...projected.data, evidenceTruncated: projected.truncated }), output_schema: DIAGNOSIS_SCHEMA },
121
+ const result = await runProvider(record.provider, { system: withSceneReasoning(SYSTEM, diagnosisSheetInstruction), prompt: JSON.stringify({ ...projected.data, evidenceTruncated: projected.truncated }), output_schema: DIAGNOSIS_SCHEMA },
120
122
  { signal, readOnlyAnalysis: true, allowAutomaticRetry: false, enableEngineFallback: false, executionTimeoutMs: 120000 });
121
123
  const known = projected.data.knowledge?.modules ? projected.data.knowledge : { modules: [] };
122
124
  const analysis = diagnosticProjection(validateAnalysis(result?.output, known, issues));
@@ -2,13 +2,18 @@ import { dirname } from 'node:path';
2
2
  import { setTimeout as delay } from 'node:timers/promises';
3
3
  import { assertNoDesktopHostForOrigin } from '../desktop-process-lock.js';
4
4
  import { createManagedSupervisor } from './supervisor.js';
5
+ import { acquireSupervisorOwner } from './supervisor-owner.js';
5
6
 
6
7
  export async function superviseLocalDelivery({ root, webOrigin, once = false, signal,
7
- createSupervisor = createManagedSupervisor, checkOwner = assertNoDesktopHostForOrigin, wait = delay } = {}) {
8
+ createSupervisor = createManagedSupervisor, checkOwner = assertNoDesktopHostForOrigin, wait = delay,
9
+ acquireOwner = acquireSupervisorOwner } = {}) {
8
10
  // Delivery records share this local root across origins. A staging desktop
9
11
  // can own the same provider session, so fence every live owner in this root.
10
12
  const check = () => checkOwner(dirname(root), null);
11
13
  await check();
14
+ const owner = await acquireOwner(dirname(root));
15
+ try {
16
+ await check();
12
17
  const supervisor = createSupervisor({ root, webOrigin });
13
18
  const stop = () => { void supervisor.close(); };
14
19
  signal?.addEventListener('abort', stop, { once: true });
@@ -22,4 +27,5 @@ export async function superviseLocalDelivery({ root, webOrigin, once = false, si
22
27
  catch (error) { if (!signal?.aborted) throw error; }
23
28
  } while (!signal?.aborted);
24
29
  } finally { signal?.removeEventListener('abort', stop); await supervisor.close(); }
30
+ } finally { await owner.release(); }
25
31
  }
@@ -0,0 +1,33 @@
1
+ import { listRecords, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
2
+ import { recoveryObservation, recoveryReasons } from '../codex-plugins/simy/hooks/recovery-incident.mjs';
3
+ import { reconcilePurposeTasks } from '../session-supervision/purpose-recovery.js';
4
+
5
+ // Reuses the canonical controller; reconciliation itself never calls an
6
+ // executor, approves an operation, restarts a stopped task or certifies SQM.
7
+ export async function inspectDeliveryRecovery({ root = rootDirectory(), provider, session, taskId,
8
+ incidentId, reconcile = false, signal, now = Date.now(), observeTurn, observeAuthentication } = {}) {
9
+ if (session && !provider || provider && !['codex', 'claude'].includes(provider)) throw new Error('Specify exact --provider and --session.');
10
+ const select = async () => (await listRecords(root)).filter(record =>
11
+ (!provider || record.provider === provider) && (!session || record.session === session) &&
12
+ (!taskId || record.run?.managed?.taskId === taskId));
13
+ let records = await select();
14
+ if ((session || taskId) && records.length !== 1) throw new Error('Recovery target is missing or ambiguous. No task was changed.');
15
+ if (reconcile) {
16
+ if (!session && !taskId) throw new Error('Reconcile requires an exact session or task ID.');
17
+ signal?.throwIfAborted();
18
+ await reconcilePurposeTasks({ root, signal, now, readRecords: select, observeTurn, observeAuthentication });
19
+ signal?.throwIfAborted();
20
+ await reconcileRecords({ root, now, records: await select() });
21
+ records = await select();
22
+ }
23
+ const tasks = records.map(record => recoveryObservation(record, now)).filter(Boolean);
24
+ if (incidentId) {
25
+ const matches = records.flatMap(record => (Array.isArray(record.run?.managed?.recovery?.incidents) ? record.run.managed.recovery.incidents : [])
26
+ .filter(row => row?.id === incidentId).map(incident => ({ provider: record.provider, session: record.session,
27
+ taskId: record.run.managed.taskId, incident })));
28
+ if (matches.length !== 1) throw new Error('Incident is missing or ambiguous.');
29
+ return { version: 1, mode: 'inspection_only', ...matches[0] };
30
+ }
31
+ return { version: 1, mode: reconcile ? 'reconciled_without_dispatch' : 'inspection_only',
32
+ tasks, reasonCatalog: Object.values(recoveryReasons) };
33
+ }
@@ -0,0 +1,74 @@
1
+ import { readFile, stat, mkdir, rename, writeFile, rm } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { acquireDesktopProcessLock } from '../desktop-process-lock.js';
5
+ import { processIdentity } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
6
+ import { windowsProcessStart } from '../windows-processes.js';
7
+
8
+ export function supervisorOwnerRoot(root) { return join(root, 'delivery-supervisor'); }
9
+ export function supervisorProcessAlive(pid) {
10
+ try { process.kill(pid, 0); return true; }
11
+ catch (error) { return error.code !== 'ESRCH'; }
12
+ }
13
+ export async function supervisorProcessIdentity(pid) {
14
+ const identity = process.platform === 'win32' ? await windowsProcessStart(pid) : await processIdentity(pid);
15
+ if (!identity) throw new Error('Supervisor process identity is unavailable.');
16
+ return identity;
17
+ }
18
+ export async function saveSupervisorState(file, value) {
19
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
20
+ const temporary = `${file}.${randomUUID()}.tmp`;
21
+ try { await writeFile(temporary, JSON.stringify(value), { mode: 0o600 }); await rename(temporary, file); }
22
+ finally { await rm(temporary, { force: true }); }
23
+ }
24
+ export async function readSupervisorState(file) {
25
+ try { return JSON.parse(await readFile(file, 'utf8')); }
26
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
27
+ }
28
+ export async function observeSupervisorOwner(root, { now = Date.now(), alive = supervisorProcessAlive,
29
+ identity = supervisorProcessIdentity } = {}) {
30
+ const directory = join(supervisorOwnerRoot(root), 'desktop-app.lock');
31
+ let owner;
32
+ try { owner = await readSupervisorState(join(directory, 'owner.json')); }
33
+ catch { return { state: 'unknown' }; }
34
+ if (!owner) {
35
+ try { await stat(directory); return { state: 'starting' }; }
36
+ catch (error) { return { state: error.code === 'ENOENT' ? 'missing' : 'unknown' }; }
37
+ }
38
+ if (!Number.isSafeInteger(owner.pid) || owner.pid < 2 || !owner.startedAt || !owner.nonce) return { state: 'unknown' };
39
+ let current;
40
+ try {
41
+ if (!alive(owner.pid)) return { state: 'dead', pid: owner.pid };
42
+ current = await identity(owner.pid);
43
+ } catch { return { state: 'unknown', pid: owner.pid }; }
44
+ if (!current) return { state: 'unknown', pid: owner.pid };
45
+ if (current !== owner.startedAt) return { state: 'dead', pid: owner.pid };
46
+ let heartbeat;
47
+ try { heartbeat = await readSupervisorState(join(supervisorOwnerRoot(root), 'heartbeat.json')); } catch {}
48
+ const lastHeartbeatAt = heartbeat?.nonce === owner.nonce && Number.isFinite(heartbeat.at) ? heartbeat.at : null;
49
+ return { state: 'running', pid: owner.pid, nonce: owner.nonce, lastHeartbeatAt,
50
+ heartbeatStale: lastHeartbeatAt !== null && now - lastHeartbeatAt > 60_000 };
51
+ }
52
+
53
+ // A separate singleton for the CLI supervisor. Unknown identities throw rather
54
+ // than letting a missing identity probe reclaim a live owner. Task leases and
55
+ // desktop ownership remain separate, and keep their existing authority checks.
56
+ export async function acquireSupervisorOwner(root, { alive = supervisorProcessAlive,
57
+ identity = supervisorProcessIdentity, heartbeatMs = 15_000 } = {}) {
58
+ const directory = supervisorOwnerRoot(root);
59
+ const lock = await acquireDesktopProcessLock(directory, { alive, identity });
60
+ const file = join(directory, 'heartbeat.json');
61
+ let pending = Promise.resolve(), closed = false;
62
+ const beat = () => {
63
+ pending = pending.catch(() => {}).then(async () => {
64
+ if (!closed) await saveSupervisorState(file, { version: 1, pid: process.pid, nonce: lock.nonce, at: Date.now() });
65
+ });
66
+ return pending;
67
+ };
68
+ try { await beat(); } catch (error) { await lock.release(); throw error; }
69
+ const timer = setInterval(() => { void beat().catch(() => {}); }, heartbeatMs);
70
+ timer.unref();
71
+ return { nonce: lock.nonce, async release() {
72
+ closed = true; clearInterval(timer); await pending.catch(() => {}); await lock.release();
73
+ } };
74
+ }
@@ -0,0 +1,65 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { mkdir, lstat, readFile, writeFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { pinnedWorkflowSource } from './local-runtime.js';
7
+ import { readSupervisorState } from './supervisor-owner.js';
8
+
9
+ const exec = promisify(execFile);
10
+ const marker = 'SIMY managed supervisor watchdog v1';
11
+ const xml = text => String(text).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
12
+ const systemdArgument = text => `"${String(text).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%').replaceAll('$', () => '$$')}"`;
13
+ const windowsArgument = text => `"${String(text).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1')}"`;
14
+
15
+ export function supervisorServiceDefinition({ root, entry, node = process.execPath, platform = process.platform, home = homedir() }) {
16
+ if ([root, entry, node].some(value => typeof value !== 'string' || /[\r\n\0]/.test(value))) throw new Error('Invalid supervisor service path.');
17
+ const args = [entry, 'delivery', 'runtime', 'supervisor-watch', '--root', root, '--no-auto-update'];
18
+ if (platform === 'linux') {
19
+ const name = 'simy-delivery-supervisor.service';
20
+ return { name, file: join(home, '.config/systemd/user', name),
21
+ content: `# ${marker}\n[Unit]\nDescription=SIMY Delivery supervisor recovery\n\n[Service]\nType=simple\nExecStart=${[node, ...args].map(systemdArgument).join(' ')}\nRestart=on-failure\nRestartSec=15\nKillMode=process\n\n[Install]\nWantedBy=default.target\n` };
22
+ }
23
+ if (platform === 'darwin') {
24
+ const name = 'com.awak.simy.delivery-supervisor';
25
+ return { name, file: join(home, 'Library/LaunchAgents', `${name}.plist`), content:
26
+ `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><!-- ${marker} --><dict><key>Label</key><string>${name}</string><key>ProgramArguments</key><array>${[node, ...args].map(arg => `<string>${xml(arg)}</string>`).join('')}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict><key>ThrottleInterval</key><integer>15</integer></dict></plist>` };
27
+ }
28
+ if (platform === 'win32') {
29
+ const name = 'SIMYDeliverySupervisor';
30
+ const startAt = new Date(Date.now() + 60_000).toISOString();
31
+ return { name, file: join(root, 'supervisor-runtime', 'service.xml'), content:
32
+ `<?xml version="1.0" encoding="UTF-8"?><Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"><!-- ${marker} --><Triggers><LogonTrigger><Enabled>true</Enabled></LogonTrigger><TimeTrigger><Repetition><Interval>PT1M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition><StartBoundary>${startAt}</StartBoundary><Enabled>true</Enabled></TimeTrigger></Triggers><Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals><Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><StartWhenAvailable>true</StartWhenAvailable><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure></Settings><Actions Context="Author"><Exec><Command>${xml(node)}</Command><Arguments>${xml(args.map(windowsArgument).join(' '))}</Arguments></Exec></Actions></Task>` };
33
+ }
34
+ throw new Error('Supervisor services support Linux, macOS and Windows.');
35
+ }
36
+
37
+ // Explicit service installation only. Installing/updating a runtime alone
38
+ // never enables this service or enrolls a conversation.
39
+ export async function installSupervisorService({ root, home = homedir(), platform = process.platform,
40
+ run = exec, inspectPin = pinnedWorkflowSource } = {}) {
41
+ const control = await readSupervisorState(join(root, 'supervisor-runtime/control.json'));
42
+ if (control?.version !== 1 || control.enabled !== true) throw new Error('Enable supervisor recovery explicitly before installing its OS service.');
43
+ await inspectPin({ root });
44
+ const pin = await readSupervisorState(join(root, 'delivery-runtime/current.json'));
45
+ if (!pin || pin.digest !== control.digest || pin.release !== control.release) throw new Error('Supervisor activation no longer matches the fixed runtime.');
46
+ const definition = supervisorServiceDefinition({ root, home, platform, entry: join(pin.release, 'src/index.js') });
47
+ try {
48
+ const info = await lstat(definition.file);
49
+ if (!info.isFile() || info.isSymbolicLink() || !(await readFile(definition.file, 'utf8')).includes(marker)) throw new Error('Supervisor service path is not managed by SIMY.');
50
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
51
+ await mkdir(dirname(definition.file), { recursive: true, mode: 0o700 });
52
+ await writeFile(definition.file, definition.content, { mode: 0o600 });
53
+ if (platform === 'linux') {
54
+ await run('systemctl', ['--user', 'daemon-reload']);
55
+ await run('systemctl', ['--user', 'enable', '--now', definition.name]);
56
+ } else if (platform === 'darwin') {
57
+ await run('/usr/bin/plutil', ['-lint', definition.file]);
58
+ await run('/bin/launchctl', ['bootstrap', `gui/${process.getuid()}`, definition.file]);
59
+ } else {
60
+ const scheduler = join(process.env.SystemRoot || 'C:\\Windows', 'System32/schtasks.exe');
61
+ await run(scheduler, ['/Create', '/TN', definition.name, '/XML', definition.file, '/F']);
62
+ await run(scheduler, ['/Run', '/TN', definition.name]);
63
+ }
64
+ return { ...definition, content: undefined, status: 'registered', scope: 'previously_enrolled_tasks_only' };
65
+ }
@@ -0,0 +1,151 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { acquireDesktopProcessLock, assertNoDesktopHostForOrigin } from '../desktop-process-lock.js';
6
+ import { pinnedWorkflowSource } from './local-runtime.js';
7
+ import { resolveWebOrigin } from '../web-origin.js';
8
+ import { observeSupervisorOwner, supervisorProcessAlive, supervisorProcessIdentity,
9
+ readSupervisorState, saveSupervisorState } from './supervisor-owner.js';
10
+
11
+ const folder = root => join(root, 'supervisor-runtime');
12
+ const controlPath = root => join(folder(root), 'control.json');
13
+ const statePath = root => join(folder(root), 'watchdog.json');
14
+ async function verifiedPin(root) {
15
+ await pinnedWorkflowSource({ root });
16
+ const pin = await readSupervisorState(join(root, 'delivery-runtime/current.json'));
17
+ if (!pin) throw new Error('Install a fixed Delivery runtime before enabling supervisor recovery.');
18
+ return { digest: pin.digest, release: pin.release };
19
+ }
20
+ export async function setSupervisorWatchdog({ root, enabled, webOrigin,
21
+ inspectPin = verifiedPin, now = Date.now(), expectedEpoch } = {}) {
22
+ const pin = enabled ? await inspectPin(root) : null;
23
+ const lock = await acquireDesktopProcessLock(join(folder(root), 'control-lock'), {
24
+ alive: supervisorProcessAlive, identity: supervisorProcessIdentity });
25
+ try {
26
+ const previous = await readSupervisorState(controlPath(root));
27
+ if (expectedEpoch !== undefined && previous?.epoch !== expectedEpoch) return previous;
28
+ const control = { version: 1, enabled: enabled === true, epoch: randomUUID(), updatedAt: now,
29
+ ...(pin ? { ...pin, webOrigin: resolveWebOrigin(webOrigin) } : {}) };
30
+ await saveSupervisorState(controlPath(root), control);
31
+ return control;
32
+ } finally { await lock.release(); }
33
+ }
34
+ export async function supervisorWatchdogStatus({ root, now = Date.now(), observeOwner = observeSupervisorOwner } = {}) {
35
+ return { control: await readSupervisorState(controlPath(root)),
36
+ watchdog: await readSupervisorState(statePath(root)), supervisor: await observeOwner(root, { now }) };
37
+ }
38
+
39
+ export function createSupervisorWatchdog({ root, inspectPin = verifiedPin, readControl = () => readSupervisorState(controlPath(root)),
40
+ observeOwner = observeSupervisorOwner, checkDesktop = assertNoDesktopHostForOrigin,
41
+ launch = (bin, args, options) => spawn(bin, args, options), now = Date.now,
42
+ identity = supervisorProcessIdentity, save = state => saveSupervisorState(statePath(root), state) } = {}) {
43
+ let child = null, childIdentity = null, launchTime = null, closed = false, running = null, lastExit = null;
44
+ let pendingPin = null, lastWall = null, controlEpoch;
45
+ const clock = () => Math.max(0, now());
46
+ const tick = async () => {
47
+ if (closed) return;
48
+ const at = clock(), control = await readControl();
49
+ controlEpoch = control?.epoch;
50
+ const previous = pendingPin || await readSupervisorState(statePath(root));
51
+ const state = { version: 1, at, action: 'observe', supervisor: null, lastExit,
52
+ lastLaunchAt: launchTime ?? previous?.lastLaunchAt ?? null, epoch: previous?.epoch || null };
53
+ if (!control || control.version !== 1 || control.enabled !== true) { state.action = 'disabled'; await save(state); return state; }
54
+ state.supervisor = await observeOwner(root, { now: at });
55
+ if (state.supervisor.state === 'running') {
56
+ state.action = state.supervisor.heartbeatStale ? 'live_owner_unhealthy' : 'live_owner_preserved';
57
+ await save(state); return state;
58
+ }
59
+ if (!['missing', 'dead'].includes(state.supervisor.state)) { state.action = 'owner_unknown'; await save(state); return state; }
60
+ if (child && child.exitCode === null && child.signalCode === null) { state.action = 'child_starting'; await save(state); return state; }
61
+ // Persist the launch intent before spawn. On watchdog crash/restart this
62
+ // gives the previous child time to publish its singleton owner receipt.
63
+ const previousLaunch = launchTime ?? previous?.lastLaunchAt;
64
+ if (Number.isFinite(previousLaunch) && at - previousLaunch < 30_000) {
65
+ state.action = 'backoff'; state.lastLaunchAt = previousLaunch; await save(state); return state;
66
+ }
67
+ // Resume/sleep or clock regression is an observation boundary, never proof
68
+ // that a live provider stopped. Re-observe on the next tick first.
69
+ if (lastWall !== null && (at < lastWall || at - lastWall > 60_000)) {
70
+ lastWall = at; state.action = 'wake_reobserve'; await save(state); return state;
71
+ }
72
+ lastWall = at;
73
+ try { await checkDesktop(root, null); }
74
+ catch (error) {
75
+ if (error.code !== 'process_conflict') throw error;
76
+ state.action = 'desktop_owner_preserved'; await save(state); return state;
77
+ }
78
+ // Healthy/unknown owners and launch backoff cannot dispatch. Hash the
79
+ // complete immutable runtime only at an actual replacement boundary.
80
+ const pin = await inspectPin(root);
81
+ if (pin.digest !== control.digest || pin.release !== control.release) { state.action = 'pin_changed'; await save(state); return state; }
82
+ const fresh = await readControl();
83
+ if (closed || !fresh?.enabled || fresh.epoch !== control.epoch) { state.action = 'disabled_before_launch'; await save(state); return state; }
84
+ state.action = 'launch_prepared'; state.lastLaunchAt = at; state.epoch = control.epoch;
85
+ launchTime = at; pendingPin = { ...state }; await save(state);
86
+ // The child's independent singleton also fences the spawn/owner-publication
87
+ // crash gap. No task enrollment, scope, consent or budget is changed here.
88
+ const secondPin = await inspectPin(root);
89
+ const finalControl = await readControl();
90
+ if (closed || secondPin.digest !== pin.digest || secondPin.release !== pin.release ||
91
+ !finalControl?.enabled || finalControl.epoch !== control.epoch) {
92
+ state.action = 'launch_cancelled'; await save(state); return state;
93
+ }
94
+ const args = [join(pin.release, 'src/index.js'), 'delivery', 'supervise', '--host', control.webOrigin, '--no-auto-update'];
95
+ child = launch(process.execPath, args, { env: { ...process.env, SIMY_HOME: root, SIMY_AUTO_UPDATE: '0' },
96
+ detached: process.platform !== 'win32', stdio: 'ignore', windowsHide: true });
97
+ const launched = child;
98
+ const exited = (code, signal) => { if (child === launched) lastExit = { at: clock(), code, signal }; };
99
+ launched.once('error', () => exited(null, 'spawn_failed'));
100
+ launched.once('exit', exited);
101
+ try { childIdentity = launched.pid ? await identity(launched.pid) : null; } catch { childIdentity = null; }
102
+ launched.unref?.();
103
+ state.action = 'launch_requested'; state.childPid = launched.pid || null;
104
+ await save(state); return state;
105
+ };
106
+ return { tick() {
107
+ if (!running) running = tick().finally(() => { running = null; });
108
+ return running;
109
+ }, epoch: () => controlEpoch, async close({ stopChild = true } = {}) {
110
+ closed = true; await running?.catch(() => {});
111
+ // Only a child launched by this watcher can receive a graceful signal.
112
+ // Foreign/live/unknown owners are never killed or replaced for silence.
113
+ if (stopChild && child?.pid && child.exitCode === null && child.signalCode === null && childIdentity) {
114
+ let current; try { current = await identity(child.pid); } catch {}
115
+ if (current === childIdentity) child.kill('SIGTERM');
116
+ }
117
+ } };
118
+ }
119
+
120
+ export async function watchSupervisor({ root, signal, once = false, wait = delay,
121
+ acquireLock = () => acquireDesktopProcessLock(join(folder(root), 'watcher'), {
122
+ alive: supervisorProcessAlive, identity: supervisorProcessIdentity }),
123
+ createWatchdog = createSupervisorWatchdog } = {}) {
124
+ const lock = await acquireLock();
125
+ let watchdog, initialEpoch;
126
+ try {
127
+ initialEpoch = (await readSupervisorState(controlPath(root)))?.epoch;
128
+ watchdog = createWatchdog({ root });
129
+ do {
130
+ if (signal?.aborted) break;
131
+ await watchdog.tick();
132
+ if (once) break;
133
+ try { await wait(5_000, undefined, { signal }); }
134
+ catch (error) { if (!signal?.aborted) throw error; }
135
+ } while (!signal?.aborted);
136
+ } finally {
137
+ // An explicit watcher termination is durable across OS-service restarts.
138
+ try {
139
+ const epoch = watchdog?.epoch() ?? initialEpoch;
140
+ // SIGTERM is also used for OS service restarts/shutdown. An explicit
141
+ // disable command or foreground Ctrl-C records a durable user stop.
142
+ if (signal?.aborted && signal.reason === 'user_interrupt' && epoch !== undefined) {
143
+ await setSupervisorWatchdog({ root, enabled: false, expectedEpoch: epoch });
144
+ }
145
+ } finally {
146
+ // A service restart replaces the watcher, not its healthy supervisor.
147
+ // Foreground interruption still disables activation and stops our child.
148
+ try { await watchdog?.close({ stopChild: signal?.reason !== 'service_termination' && (signal?.aborted || !once) }); } finally { await lock.release(); }
149
+ }
150
+ }
151
+ }
@@ -4,6 +4,7 @@ import { retainControllerEvidence } from '../codex-plugins/simy/hooks/purpose-ev
4
4
  import { workspaceIdentity, reviewWorkspaceIdentity } from '../session-supervision/purpose-verification.js';
5
5
  import { searchEnabled, failureLimitReached } from '../codex-plugins/simy/hooks/purpose-search.mjs';
6
6
  import { reconcilePurposeTasks } from '../session-supervision/purpose-recovery.js';
7
+ import { recordRecoveryIncident, recordRecoveryStage, settleRecoveryIncidents, resolveRecoveryReceipt } from '../codex-plugins/simy/hooks/recovery-incident.mjs';
7
8
  import { runConsistencyMonitor } from './consistency-monitor.js';
8
9
  import { desktopRuntimeStatus } from './desktop-runtime.js';
9
10
  import { auditConsistencyTask, recoverOutcomeAuditLease } from '../session-supervision/consistency-auditor.js';
@@ -170,6 +171,8 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
170
171
  await updateRecord(root, record.provider, record.session, current => {
171
172
  if (current?.run?.state !== 'auto_resuming' || current.run.managed.resumeLease?.id !== run.managed.resumeLease?.id) return current;
172
173
  current.run.state = 'recovery_required'; current.run.recoveryCause = 'interrupted_owner'; current.run.managed.autoRecovery = false;
174
+ recordRecoveryIncident(current.run, { code: 'interrupted_owner', subject: current.run.managed.executionAttempt?.id || '', now: claimNow });
175
+ recordRecoveryStage(current.run, 'interrupted', { now: claimNow, attemptId: current.run.managed.executionAttempt?.id });
173
176
  current.run.reason = 'Managed recovery was interrupted; inspect the original session before the next bounded recovery.';
174
177
  const attempt = current.run.managed.executionAttempt;
175
178
  if (attempt?.budgetReservation && attempt.phase === 'dispatching') { attempt.budgetReservation.state = 'unknown'; current.run.managed.unknownConsumption = true; if (current.run.managed.maxTokens) current.run.managed.usageBlocked = true; }
@@ -227,6 +230,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
227
230
  live.managed.executionAttempt = { id: leaseId, decisionId: live.managed.decision?.id || null,
228
231
  action: live.managed.decision?.action || live.managed.proposedAction,
229
232
  budgetReservation: { rootTaskId: live.managed.taskId, tokens: live.managed.maxTokens ? Math.max(0, live.managed.maxTokens - (live.managed.tokensUsed || 0)) : null, state: 'reserved', epoch: leaseId }, phase: 'claimed', claimedAt: currentTime(), lastActivityAt: currentTime() };
233
+ live.managed.executionAttempt.recoveryIncidentId = live.managed.recovery?.currentId || null;
230
234
  // Retain the repair action identity during independent verification.
231
235
  // Older receipts can still be executing after a verifier; bind those to
232
236
  // the next actual diagnosis/repair only after all lease/authority fences.
@@ -301,6 +305,19 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
301
305
  await authorizeClaim();
302
306
  const runner = execute || (appServer ? executeCodexAppServer : executeCodexResume);
303
307
  result = await runner(claimed, { signal: controller.signal, root, webOrigin, leaseId, currentTime, leaseMonitored: true,
308
+ onStarted: async observation => {
309
+ await persistReceipt(current => {
310
+ const attempt = current?.run?.managed?.executionAttempt;
311
+ if (current?.run?.managed?.resumeLease?.id !== leaseId || attempt?.id !== leaseId) throw new Error('Recovery receipt lease changed.');
312
+ attempt.providerTurnId = observation.turnId;
313
+ attempt.providerSessionId = observation.session;
314
+ attempt.startedAt = currentTime();
315
+ recordRecoveryStage(current.run, 'started', { now: currentTime(), attemptId: leaseId,
316
+ decisionId: attempt.decisionId, incidentId: attempt.recoveryIncidentId,
317
+ providerSessionId: observation.session, providerTurnId: observation.turnId });
318
+ return current;
319
+ });
320
+ },
304
321
  onEvidence: async evidence => {
305
322
  await persistReceipt(current => {
306
323
  if (current?.run?.managed?.resumeLease?.id !== leaseId || current.run.managed.decision?.id !== evidence.actionId) throw new Error('Evidence capture lease changed.');
@@ -335,6 +352,8 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
335
352
  live.managed.executionAttempt.phase = 'dispatching';
336
353
  live.managed.executionAttempt.dispatchedAt = currentTime();
337
354
  live.managed.executionAttempt.lastActivityAt = currentTime();
355
+ recordRecoveryStage(live, 'dispatching', { now: currentTime(), attemptId: leaseId,
356
+ decisionId: live.managed.decision?.id, incidentId: live.managed.executionAttempt.recoveryIncidentId });
338
357
  dispatched = true;
339
358
  if (live.managed.decision?.outcome === 'wait') {
340
359
  if (approved?.id !== live.managed.decision.id || approved.dispatchedAt) throw new Error('Selected action needs fresh approval.');
@@ -366,6 +385,10 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
366
385
  if (current?.run?.managed?.executionAttempt?.id !== leaseId) return current;
367
386
  current.run.managed.executionAttempt.phase = 'checkpointed';
368
387
  current.run.managed.executionAttempt.checkpointRevision = current.run.revision;
388
+ const receipt = current.run.managed.executionAttempt;
389
+ recordRecoveryStage(current.run, 'result_observed', { now: Date.now(), attemptId: leaseId,
390
+ decisionId: receipt.decisionId, incidentId: receipt.recoveryIncidentId });
391
+ settleRecoveryIncidents(current.run, Date.now(), leaseId);
369
392
  if (claimed.run.managed.recoveryDiagnosisDecisionId === claimed.run.managed.decision?.id &&
370
393
  current.run.managed.workHistory?.at(-1)?.result?.status === 'observed') {
371
394
  // This accepts a read-only observation, never grants a retry of the
@@ -373,6 +396,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
373
396
  current.run.managed.reconciledExecutions ||= [];
374
397
  current.run.managed.reconciledExecutions.push({ ...current.run.managed.executionUncertain, reconciledAt: Date.now(), revision: current.run.revision });
375
398
  current.run.managed.reconciledExecutions = current.run.managed.reconciledExecutions.slice(-10);
399
+ resolveRecoveryReceipt(current.run, current.run.managed.executionUncertain?.attemptId);
376
400
  delete current.run.managed.executionUncertain;
377
401
  delete current.run.managed.recoveryDiagnosisDecisionId;
378
402
  }
@@ -410,6 +434,8 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
410
434
  reason: result.accessDenied ? 'Desktop organization access is required before another provider turn.' : result.busy ? 'Exact provider session currently has another writer.' : result.cancelled ? 'Managed recovery cancelled before completion.' : result.code === 0 ? 'Structured checkpoint recorded.' : 'Provider failed or returned an invalid checkpoint.',
411
435
  nextStep: live.nextStep });
412
436
  live.managed.attempts = live.managed.attempts.slice(-20);
437
+ if (result.code !== 0) recordRecoveryStage(live, result.busy ? 'waiting' : 'interrupted', {
438
+ now: currentTime(), attemptId: leaseId, decisionId: attempt?.decisionId, incidentId: attempt?.recoveryIncidentId });
413
439
  if (result.restorationUnverified) {
414
440
  live.managed.providerWarning = 'Original Codex permissions could not be verified after the interrupted turn; inspect that conversation.';
415
441
  if (live.state !== 'auto_resuming') live.reason = `${String(live.reason || '').slice(0, 400)} ${live.managed.providerWarning}`.trim();
@@ -1,5 +1,6 @@
1
1
  import { signalExecutorProcess } from "./runner.js";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { withSceneReasoning } from "./codex-plugins/simy/hooks/scene-reasoning.mjs";
3
4
 
4
5
  import { resolveDesktopExecutorCommand } from "./desktop-executor.js";
5
6
  import { createProviderStreamDecoder } from "./provider-stream.js";
@@ -63,7 +64,7 @@ export async function startDirectTask(
63
64
  if (task.invocation_count >= 1) throw new Error("Direct tasks allow only one Provider invocation.");
64
65
  const command = await resolveCommand({
65
66
  backend: task.backend,
66
- instruction: task.instruction,
67
+ instruction: withSceneReasoning("Complete only the requested bounded direct task in this invocation. Respect the assigned permission mode; do not create an automatic retry loop or expand the task scope.", task.instruction),
67
68
  repositoryPath: task.repository_path,
68
69
  executionKind: "direct",
69
70
  permissionMode: task.permission_mode,
@@ -174,20 +174,20 @@ export function buildCodingInstruction(
174
174
  section(
175
175
  "problem_solving_protocol",
176
176
  [
177
- "Treat implementation as hypothesis testing: state assumptions, list plausible causes or approaches, inspect discriminating evidence, then choose the smallest strategy supported by that evidence.",
177
+ "Use the shared scene reasoning cycle for implementation and retries. Let the current issue determine useful perspectives and the smallest next check; do not require a separate reasoning workflow.",
178
178
  attemptNumber > 1
179
179
  ? "This is a retry. Repair clear failures directly; compare plausible causes when needed. Reasoning notes are advisory, not a prerequisite to editing."
180
180
  : "For the first attempt, investigate material uncertainty before a risky operation; use a reasonable assumption for low-impact reversible choices and verify as you proceed.",
181
181
  attemptNumber > 1
182
- ? "Explain exactly what changed from the previous attempt and why the new evidence supports this strategy."
183
- : "Record the uncertainty, first verification, concrete evidence checked, observed result, selected hypothesis, and strategy in the structured result.",
182
+ ? "When useful, briefly note what changed the interpretation or next check; an extra reasoning explanation is not required to perform the repair."
183
+ : "Use existing result fields for actual checks and outcomes; reasoning notes and the hypothesis sheet are optional.",
184
184
  ].join("\n"),
185
185
  ),
186
186
  section(
187
187
  "execution_contract",
188
188
  [
189
189
  "Read the repository instructions and current git state before editing.",
190
- "Before editing, resolve the highest uncertainty with the first discriminating verification. If it cannot be resolved, stop and report a fallback instead of claiming completion.",
190
+ "Use the shared scene reasoning cycle to investigate uncertainty that affects the selected operation. If that operation cannot safely proceed, retain its blocker and choose independent authorized work or a smaller useful check. Do not claim unresolved outcomes are complete.",
191
191
  "Preserve user changes and keep the diff scoped to the requirement.",
192
192
  "Run relevant tests, inspect the final diff, commit, push, and create or update a PR.",
193
193
  `The PR must target ${charter.base_branch}.`,
@@ -631,7 +631,7 @@ async function handleFailedAudit({
631
631
  state.ai_strategy_diagnoses.push(key);
632
632
  state.ai_strategy_diagnoses = state.ai_strategy_diagnoses.slice(-32);
633
633
  findings.push({ passed: false, code: 'AI_STRATEGY_CHANGE_REQUIRED', severity: 'blocker', target: 'retry_loop',
634
- explanation: 'Investigate the repeated failure with LLM context synthesis, alternatives and tool evidence. Choose a different reversible method before another attempt; do not repeat uncertain effects or ask a person to certify technical correctness.', repairability: 'auto', blocker_kind: 'machine' });
634
+ explanation: 'Investigate the repeated failure with the shared scene reasoning cycle: understand this scene, derive and select useful perspectives, then connect the interpretation to the next check. Choose a different reversible method before another attempt; do not repeat uncertain effects or ask a person to certify technical correctness.', repairability: 'auto', blocker_kind: 'machine' });
635
635
  snapshot.final_audit = { ...(snapshot.final_audit || {}), passed: false, findings, requires_human: false, auto_repairable: true };
636
636
  appendEvent(snapshot, 're_instructing', 'AI diagnosis is required before treating technical repetition as an execution blocker.', { code: 'ai_strategy_change', attempt_number: attemptNumber });
637
637
  await publish(snapshot, 're_instructing', onUpdate);
@@ -94,9 +94,9 @@ export function evaluateRetryProblemSolving(attempt, previousAttempt) {
94
94
  export function problemSolvingResultContract() {
95
95
  return [
96
96
  hypothesisSheetInstruction,
97
- "problem_solving must be an object with hypotheses, selected_hypothesis_id, evidence_checked, strategy, and changed_from_previous.",
97
+ "problem_solving must be an object; {} is valid when no optional reasoning notes are useful. When recording notes, use hypotheses, selected_hypothesis_id, evidence_checked, strategy, and changed_from_previous.",
98
98
  "Each hypothesis must use {id, statement}; evidence_checked must list concrete observations or commands.",
99
- "On attempt 1, record the assumptions, hypotheses considered, selected hypothesis, and implementation strategy; changed_from_previous may be null.",
99
+ "Use the shared scene reasoning cycle on every attempt. Record useful assumptions or interpretations briefly when helpful; changed_from_previous may be null.",
100
100
  "On retry, use reasoning proportional to the uncertainty. If the repair is clear, make it and verify it; do not invent a fixed number of hypotheses or change a working strategy merely to satisfy a form. For persistent unexplained failures, compare plausible causes and inspect discriminating evidence. These reasoning notes are advisory; unchanged failures are still subject to the retry circuit.",
101
101
  ].join("\n");
102
102
  }