@awak-app/simy-cli 0.5.30 → 0.5.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -76,11 +76,13 @@ marketplace still points to SIMY’s generated local source. Plugin versions inc
76
76
  receive a fresh cache. Use a new Codex task to pick up updated skills; running
77
77
  tasks are not restarted. Missing or disabled plugins are not enabled automatically.
78
78
  Failed refreshes retry on the next check without blocking daemon startup.
79
- Delivery Loop is a SIMY Web/CLI runtime workflow, not a local Codex skill.
80
- The plugin ships only SQM and PR review. Updating to this release removes the
81
- previously bundled Delivery Loop from managed plugin sources and refreshes the
82
- enabled cache. Older running CLI releases can restore their old bundled skill
83
- until upgraded; deleting a local skill alone does not change their updater.
79
+ The plugin also provides an opt-in, hook-based Delivery Loop in the user's existing
80
+ Codex or Claude Code conversation. Install the plugin, review and enable SIMY hooks
81
+ with `/hooks`, then request `/simy:delivery-loop` for the task. Installing or updating
82
+ the plugin never approves hook trust. SIMY's Connection & settings panel links to
83
+ Codex settings and the Claude setup guide and shows observed hook activity and
84
+ blocked/recovery states. The legacy `agentic-delivery-loop` skill remains retired;
85
+ the Web/CLI executor is unchanged. See [hook delivery behavior and limits](docs/delivery-hooks.md).
84
86
  `--no-auto-update` or `SIMY_AUTO_UPDATE=0` disables these background updates too;
85
87
  `--no-workflow-prompt` only suppresses the initial installation prompt.
86
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.30",
3
+ "version": "0.5.31",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,9 +38,10 @@
38
38
  "test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
39
39
  "check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./src/local-task.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/pre-guardrail-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
40
40
  "check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
41
+ "check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
41
42
  "check:sqm": "node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
42
43
  "check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js",
43
- "check:syntax": "node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js",
44
+ "check:syntax": "node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js",
44
45
  "check:package": "node ./scripts/check-package-contents.js",
45
46
  "build:macos-installer:dev": "bash packaging/build-macos-arm64.sh --unsigned-dev",
46
47
  "build:macos-installer": "bash packaging/build-macos-arm64.sh --production",
package/src/agent.js CHANGED
@@ -95,6 +95,7 @@ import {
95
95
  classifyExecutionRequirement,
96
96
  EXECUTION_GUARDRAIL_POLICY_VERSION,
97
97
  } from "./execution-guardrail.js";
98
+ import { normalizeWorkflowObservationBinding } from "./workflow-observation-sender.js";
98
99
  import {
99
100
  createLocalTask,
100
101
  createLocalTaskId,
@@ -1072,6 +1073,19 @@ export async function startAgent({
1072
1073
  }
1073
1074
  throw error;
1074
1075
  }
1076
+ let workflowObservationBinding;
1077
+ try {
1078
+ workflowObservationBinding = normalizeWorkflowObservationBinding(
1079
+ body.workflow_observation_binding,
1080
+ { provider: body.backend },
1081
+ );
1082
+ } catch (error) {
1083
+ json(res, 400, {
1084
+ error: error instanceof Error ? error.message : "WorkFlow observation binding is invalid.",
1085
+ code: "workflow_observation_binding_invalid",
1086
+ });
1087
+ return;
1088
+ }
1075
1089
  let requestFingerprint;
1076
1090
  try {
1077
1091
  requestFingerprint = createLocalTaskRequestFingerprint({
@@ -1087,6 +1101,7 @@ export async function startAgent({
1087
1101
  timeoutMs: body.timeout_ms,
1088
1102
  attachmentManifest: preparedAttachments,
1089
1103
  scenarioPack,
1104
+ workflowObservationBinding,
1090
1105
  });
1091
1106
  hashLocalTaskIdempotencyKey(idempotencyKey);
1092
1107
  } catch (error) {
@@ -1211,12 +1226,15 @@ export async function startAgent({
1211
1226
  timeoutMs: body.timeout_ms,
1212
1227
  idempotencyKeyHash: hashLocalTaskIdempotencyKey(idempotencyKey),
1213
1228
  requestFingerprint,
1229
+ workflowObservationBinding,
1214
1230
  });
1215
1231
  await persistLocalTask(task);
1216
1232
  localTaskRegistry.create(task);
1217
1233
  requestClaim.complete(task);
1218
1234
  requestClaimCompleted = true;
1219
- const runLocalTask = dependencies.runLocalTask || startLocalTask;
1235
+ const runLocalTask =
1236
+ dependencies.runLocalTask ||
1237
+ ((localTask) => startLocalTask(localTask, { session, apiOrigin }));
1220
1238
  task.operation_promise = Promise.resolve(runLocalTask(task))
1221
1239
  .catch(async (error) => {
1222
1240
  task.status = "failed";
@@ -1444,6 +1462,19 @@ export async function startAgent({
1444
1462
  return;
1445
1463
  }
1446
1464
  const indexedRepository = await ensureRepositoryIndexed(body.repository);
1465
+ let workflowObservationBinding;
1466
+ try {
1467
+ workflowObservationBinding = normalizeWorkflowObservationBinding(
1468
+ body.workflow_observation_binding,
1469
+ { provider: body.backend },
1470
+ );
1471
+ } catch (error) {
1472
+ json(res, 400, {
1473
+ error: error instanceof Error ? error.message : "WorkFlow observation binding is invalid.",
1474
+ code: "workflow_observation_binding_invalid",
1475
+ });
1476
+ return;
1477
+ }
1447
1478
  const run = createRun({
1448
1479
  runId,
1449
1480
  request: {
@@ -1485,6 +1516,7 @@ export async function startAgent({
1485
1516
  typeof body.design_review_url === "string" ? body.design_review_url : "",
1486
1517
  must_not: Array.isArray(body.must_not) ? body.must_not : [],
1487
1518
  proposal_id: typeof body.proposal_id === "string" ? body.proposal_id : null,
1519
+ workflow_observation_binding: workflowObservationBinding,
1488
1520
  charter_context:
1489
1521
  body.charter_context &&
1490
1522
  typeof body.charter_context === "object" &&
@@ -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.5.30";
16
+ export const CLI_VERSION = "0.5.31";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -1,25 +1,34 @@
1
1
  {
2
2
  "name": "simy",
3
3
  "version": "0.1.0",
4
- "description": "Run SIMY quality and pull request review workflows from the current Codex thread.",
4
+ "description": "SIMY quality, PR review, and opt-in hook-based Delivery Loop in the current conversation.",
5
5
  "author": {
6
6
  "name": "Specialist Doctors Inc.",
7
7
  "url": "https://github.com/SpecialistDoctors-Inc"
8
8
  },
9
9
  "repository": "https://github.com/SpecialistDoctors-Inc/simy-cli",
10
10
  "license": "UNLICENSED",
11
- "keywords": ["simy", "sqm", "pull-request"],
11
+ "keywords": [
12
+ "simy",
13
+ "sqm",
14
+ "pull-request"
15
+ ],
12
16
  "skills": "./skills/",
13
17
  "interface": {
14
18
  "displayName": "SIMY",
15
- "shortDescription": "Quality and PR review workflows for SIMY.",
16
- "longDescription": "SIMY turns the current Codex thread into a bounded quality check or a read-only pull request review. Delivery Loop runs through SIMY Web and CLI, not a local Codex skill.",
19
+ "shortDescription": "Quality, PR review, and bounded Delivery Loop.",
20
+ "longDescription": "Run quality checks and PR reviews, or explicitly start a bounded Delivery Loop using user-reviewed hooks in the current conversation. Hook trust and per-task opt-in are required; Web/CLI delivery remains available.",
17
21
  "developerName": "Specialist Doctors Inc.",
18
22
  "category": "Developer Tools",
19
- "capabilities": ["Quality", "Review"],
23
+ "capabilities": [
24
+ "Quality",
25
+ "Review",
26
+ "Delivery"
27
+ ],
20
28
  "defaultPrompt": [
21
29
  "Run the SIMY SQM Loop for the current thread.",
22
- "Review the current pull request with SIMY."
30
+ "Review the current pull request with SIMY.",
31
+ "Start SIMY Delivery Loop for the current task."
23
32
  ]
24
33
  }
25
34
  }
@@ -15,3 +15,18 @@ When `SIMY_WEB_ORIGIN` or `SIMY_API_ORIGIN` is set, only that origin is accepted
15
15
  Otherwise the hook checks at most 16 origin-keyed discovery records and accepts
16
16
  the first live, authenticated connection with a fresh cloud heartbeat. It never
17
17
  scans ports or reads stored session tokens.
18
+
19
+ ## Delivery Loop hooks
20
+
21
+ `delivery-loop.mjs` is an independent opt-in workflow, shared by the Codex and
22
+ Claude distributions. SessionStart supplies the exact session identity. It does
23
+ not start a loop. After an explicit `simy delivery start`, Stop checks that
24
+ session's checkpoint and returns bounded next-step instructions. Approval/input
25
+ blocks, interruption, completion, failures and budgets stop automatic continuation.
26
+ Provider errors/exits are surfaced to SIMY's desktop; a dead provider is not
27
+ silently restarted. No hook writes trust, permission settings, or SQM evidence.
28
+
29
+ Private state is under `SIMY_HOME/delivery-hooks` (default `~/.simy/delivery-hooks`),
30
+ keyed by provider and session ID and bound to the canonical working directory.
31
+ Historical hook observations do not prove hooks are still enabled. See the
32
+ bundled delivery-loop skill for checkpoint commands and explicit recovery.
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ // Standalone by design: this exact module is shipped to both provider plugins.
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { mkdir, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+
10
+ export const TERMINAL = new Set(['completed', 'stopped', 'failed', 'budget_exhausted']);
11
+ const EVENTS = new Set(['SessionStart', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'PermissionRequest', 'Notification']);
12
+ const MAX_BYTES = 64 * 1024;
13
+ const RETRYABLE = new Set(['overloaded', 'server_error', 'rate_limit']);
14
+ export const rootDirectory = (env = process.env) => join(env.SIMY_HOME?.trim() || join(homedir(), '.simy'), 'delivery-hooks');
15
+ const digest = value => createHash('sha256').update(value).digest('hex');
16
+ const text = (value, max = 4000) => typeof value === 'string' ? value.trim().slice(0, max) : '';
17
+ export function recordPath(root, provider, session) {
18
+ if (!['codex', 'claude'].includes(provider) || typeof session !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(session)) throw new Error('A provider and exact session ID are required.');
19
+ return join(root, `${digest(`${provider}\0${session}`)}.json`);
20
+ }
21
+ async function read(file) {
22
+ try { const raw = await readFile(file, 'utf8'); if (Buffer.byteLength(raw) > MAX_BYTES) throw new Error('State exceeds limit.'); return JSON.parse(raw); }
23
+ catch (e) { if (e.code === 'ENOENT') return null; throw e; }
24
+ }
25
+ export async function updateRecord(root, provider, session, mutate) {
26
+ await mkdir(root, { recursive: true, mode: 0o700 });
27
+ const file = recordPath(root, provider, session), lock = `${file}.lock`;
28
+ // Short, non-waiting critical sections keep hooks bounded. A crashed writer's
29
+ // stale lock is surfaced, never silently removed while a writer may own it.
30
+ try { await mkdir(lock); } catch (e) { if (e.code === 'EEXIST') throw new Error('Delivery state is busy; retry the command.'); throw e; }
31
+ let temporary;
32
+ try {
33
+ const next = await mutate(await read(file));
34
+ if (next) {
35
+ temporary = `${file}.${randomUUID()}.tmp`;
36
+ await writeFile(temporary, JSON.stringify(next), { mode: 0o600 });
37
+ await rename(temporary, file);
38
+ }
39
+ return next;
40
+ } finally { if (temporary) await rm(temporary, { force: true }); await rm(lock, { recursive: true, force: true }); }
41
+ }
42
+ export async function listRecords(root = rootDirectory()) {
43
+ let names; try { names = await readdir(root); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
44
+ const results = await Promise.all(names.filter(n => /^[a-f0-9]{64}\.json$/.test(n)).slice(0, 500).map(async n => {
45
+ try { const r = await read(join(root, n)); return r?.schemaVersion === 1 && recordPath(root, r.provider, r.session) === join(root, n) ? r : null; } catch { return null; }
46
+ }));
47
+ return results.filter(Boolean);
48
+ }
49
+ function positive(value, fallback, max) {
50
+ if (value === undefined) return fallback;
51
+ if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error('Invalid delivery budget.');
52
+ return value;
53
+ }
54
+ export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), objective, nextStep, maxContinuations, maxFailures, maxMinutes, now = Date.now() }) {
55
+ cwd = await realpath(cwd);
56
+ if (!text(objective) || !text(nextStep)) throw new Error('Objective and concrete next step are required.');
57
+ return updateRecord(root, provider, session, previous => {
58
+ if (!previous?.observedAt || previous.cwd !== cwd) throw new Error('No matching hook session observed here. Enable SIMY hooks, then start a new turn and use the session ID from hook context.');
59
+ if (previous.run) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
60
+ return { ...previous, run: { id: randomUUID(), objective: text(objective), state: 'active', nextStep: text(nextStep), startedAt: now, updatedAt: now,
61
+ deadline: now + positive(maxMinutes, 30, 1440) * 60_000, maxContinuations: positive(maxContinuations, 6, 50), maxFailures: positive(maxFailures, 3, 10),
62
+ continuations: 0, failures: 0, revision: 0, consumedRevision: -1, checkpoint: null, reason: 'started' } };
63
+ });
64
+ }
65
+ export async function checkpointDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), checkpoint, now = Date.now() }) {
66
+ try { cwd = await realpath(cwd); } catch { throw new Error('No matching active Delivery Loop.'); }
67
+ const statuses = ['in_progress', 'completed', 'blocked', 'error'];
68
+ if (!checkpoint || !statuses.includes(checkpoint.status)) throw new Error('Checkpoint status must be in_progress, completed, blocked, or error.');
69
+ if (!text(checkpoint.summary)) throw new Error('Checkpoint summary is required.');
70
+ if (['in_progress', 'error'].includes(checkpoint.status) && !text(checkpoint.nextStep)) throw new Error('A concrete nextStep is required.');
71
+ if (checkpoint.status === 'blocked' && !['authorization', 'missing_information'].includes(checkpoint.blocker)) throw new Error('Specify authorization or missing_information.');
72
+ const evidence = Array.isArray(checkpoint.evidence) ? checkpoint.evidence.map(v => text(v, 1000)).filter(Boolean).slice(0, 20) : [];
73
+ if (checkpoint.status === 'completed' && (!evidence.length || checkpoint.acceptanceMet !== true)) throw new Error('Completion requires acceptanceMet and concrete verification evidence.');
74
+ return updateRecord(root, provider, session, record => {
75
+ const run = record?.run;
76
+ if (!run || record.cwd !== cwd) throw new Error('No matching active Delivery Loop.');
77
+ if (TERMINAL.has(run.state) || run.state === 'blocked' || run.state === 'recovery_required') throw new Error('Loop is stopped or awaiting a human resume.');
78
+ run.checkpoint = { status: checkpoint.status, summary: text(checkpoint.summary), nextStep: text(checkpoint.nextStep), blocker: checkpoint.blocker, evidence, acceptanceMet: checkpoint.acceptanceMet === true };
79
+ run.revision += 1; run.updatedAt = now;
80
+ return record;
81
+ });
82
+ }
83
+ export function decideStop(run, now = Date.now()) {
84
+ if (TERMINAL.has(run.state) || ['blocked', 'recovery_required'].includes(run.state)) return { run, output: {} };
85
+ const next = structuredClone(run);
86
+ const finish = (state, reason) => { next.state = state; next.reason = reason; next.updatedAt = now; return { run: next, output: {} }; };
87
+ // A fresh, explicitly evidenced completion may finish at the budget boundary.
88
+ const fresh = next.checkpoint && next.revision > next.consumedRevision;
89
+ const cp = fresh ? next.checkpoint : null;
90
+ if (cp?.status === 'completed') return finish('completed', cp.summary);
91
+ if (cp?.status === 'blocked') { next.blocker = cp.blocker; return finish('blocked', cp.summary); }
92
+ if (now >= next.deadline || next.continuations >= next.maxContinuations) return finish('budget_exhausted', 'Delivery time or continuation budget exhausted.');
93
+ if (!fresh || cp.status === 'error') next.failures += 1;
94
+ else next.failures = 0;
95
+ if (next.failures >= next.maxFailures) return finish('failed', 'Repeated failure or missing progress checkpoint; human review required.');
96
+ next.consumedRevision = next.revision;
97
+ if (cp?.nextStep) next.nextStep = cp.nextStep;
98
+ next.continuations += 1; next.updatedAt = now; next.state = 'active';
99
+ next.reason = fresh ? 'continue_pending_work' : 'checkpoint_missing';
100
+ const instruction = fresh ? next.nextStep : `Check the actual delivery status first. Record an honest checkpoint with simy delivery checkpoint before stopping; do not claim completion without verification. Pending step: ${next.nextStep}`;
101
+ return { run: next, output: { decision: 'block', reason: `SIMY Delivery Loop (${next.continuations}/${next.maxContinuations}). ${instruction}\nStay within the original request and permissions. If authorization or information is missing, record a blocked checkpoint and stop. Do not reset budgets or start a replacement loop.` } };
102
+ }
103
+ // A process signature protects against PID reuse. Never treat a stale timer alone
104
+ // as a crash; an idle provider may be waiting for its user.
105
+ export async function processIdentity(pid) {
106
+ if (!Number.isSafeInteger(pid) || pid < 2 || process.platform === 'win32') return null;
107
+ try { const { stdout } = await promisify(execFile)('/bin/ps', ['-p', String(pid), '-o', 'lstart=,comm='], { timeout: 500, maxBuffer: 2048 }); return text(stdout, 1000) || null; }
108
+ catch (e) { return e.code === 1 ? '' : null; }
109
+ }
110
+ async function providerProcess(provider) {
111
+ if (process.platform === 'win32') return null;
112
+ let pid = process.ppid;
113
+ for (let i = 0; i < 3 && pid > 1; i++) {
114
+ const identity = await processIdentity(pid);
115
+ if (identity && new RegExp(`(?:^|[/\\\\ ])${provider}(?:$|[ .-])`, 'i').test(identity)) return { pid, identity };
116
+ try { const r = await promisify(execFile)('/bin/ps', ['-p', String(pid), '-o', 'ppid='], { timeout: 300, maxBuffer: 128 }); pid = Number(r.stdout.trim()); } catch { return null; }
117
+ }
118
+ return null;
119
+ }
120
+ export async function handleHook({ input, provider, root = rootDirectory(), now = Date.now(), observedProcess = undefined }) {
121
+ if (!EVENTS.has(input?.hook_event_name) || !text(input.session_id) || !text(input.cwd)) return {};
122
+ let cwd; try { cwd = await realpath(input.cwd); } catch { return {}; }
123
+ const event = input.hook_event_name;
124
+ const identity = observedProcess === undefined && event === 'SessionStart' ? await providerProcess(provider) : observedProcess;
125
+ let output = {};
126
+ await updateRecord(root, provider, input.session_id, old => {
127
+ if (old && old.cwd !== cwd) return old;
128
+ const record = old || { schemaVersion: 1, provider, session: input.session_id, cwd, run: null };
129
+ record.observedAt = now; record.lastEvent = event;
130
+ if (identity) record.process = identity;
131
+ if (event === 'SessionStart') {
132
+ output = { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `SIMY hooks observed this ${provider} session: ${input.session_id}. Only if the user requests SIMY Delivery Loop, use simy delivery start --provider ${provider} --session ${input.session_id} with the agreed objective and next step. Existing loop: ${record.run?.state || 'not started'}. Never enable, trust, resume, or reset a loop on the user's behalf without their request.` } };
133
+ }
134
+ const run = record.run;
135
+ if (!run || TERMINAL.has(run.state)) return record;
136
+ if (event === 'Interrupt') { run.state = 'stopped'; run.reason = 'User interrupted the provider.'; }
137
+ else if (event === 'PermissionRequest' || event === 'Notification' && ['permission_prompt', 'elicitation_dialog'].includes(input.notification_type)) {
138
+ run.state = 'blocked'; run.blocker = 'authorization'; run.reason = 'Resolve the provider approval or input request, then explicitly resume Delivery Loop.';
139
+ } else if (event === 'SessionEnd') {
140
+ run.state = 'recovery_required'; run.reason = 'Provider session ended. Check why it ended before resuming; no automatic restart.';
141
+ } else if (event === 'StopFailure') {
142
+ run.failures += 1;
143
+ run.state = run.failures >= run.maxFailures ? 'failed' : 'recovery_required';
144
+ run.reason = RETRYABLE.has(input.error) ? 'Transient provider error. Resume the existing conversation after the provider recovers.' : 'Provider error requires attention before resuming.';
145
+ run.errorCode = text(input.error, 100); run.retryable = RETRYABLE.has(input.error);
146
+ } else if (event === 'Stop') {
147
+ // Codex turn IDs make re-delivered Stop events idempotent. Claude has no
148
+ // stable event ID; never dedupe on text (identical text can be a new turn).
149
+ const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;
150
+ if (key && run.lastStopKey === key) return record;
151
+ const decision = decideStop(run, now); record.run = decision.run; output = decision.output;
152
+ record.run.lastStopKey = key;
153
+ }
154
+ record.run.updatedAt = now;
155
+ return record;
156
+ });
157
+ return output;
158
+ }
159
+ export async function controlDelivery({ root = rootDirectory(), provider, session, action, now = Date.now() }) {
160
+ return updateRecord(root, provider, session, record => {
161
+ const run = record?.run;
162
+ if (!run) throw new Error('Delivery Loop not found.');
163
+ if (action === 'stop') { run.state = 'stopped'; run.reason = 'Stopped by user.'; }
164
+ else if (action === 'resume') {
165
+ if (!['blocked', 'recovery_required'].includes(run.state)) throw new Error('Only a blocked or recoverable loop can resume.');
166
+ if (now >= run.deadline || run.continuations >= run.maxContinuations || run.failures >= run.maxFailures) throw new Error('Original budget exhausted. Loop cannot resume.');
167
+ run.state = 'active'; run.reason = 'Explicit user resume'; run.checkpoint = null; run.consumedRevision = run.revision;
168
+ } else throw new Error('Unsupported control.');
169
+ run.updatedAt = now; return record;
170
+ });
171
+ }
172
+ export async function reconcileRecords({ root = rootDirectory(), now = Date.now(), inspectProcess = processIdentity } = {}) {
173
+ const records = await listRecords(root);
174
+ for (const record of records) {
175
+ if (!record.run || TERMINAL.has(record.run.state)) continue;
176
+ const observed = record.process ? await inspectProcess(record.process.pid) : null;
177
+ await updateRecord(root, record.provider, record.session, current => {
178
+ if (!current?.run || TERMINAL.has(current.run.state)) return current;
179
+ const run = current.run;
180
+ if (now >= run.deadline) { run.state = 'budget_exhausted'; run.reason = 'Delivery time budget exhausted.'; run.updatedAt = now; }
181
+ else if (run.state === 'active' && observed !== null && observed !== current.process?.identity && current.process?.identity === record.process?.identity) {
182
+ run.state = 'recovery_required'; run.reason = 'Observed provider process exited. Reopen the existing conversation and explicitly resume.'; run.updatedAt = now;
183
+ }
184
+ return current;
185
+ }).catch(() => {});
186
+ }
187
+ return listRecords(root);
188
+ }
189
+ export async function main(provider) {
190
+ let raw = '';
191
+ try {
192
+ for await (const chunk of process.stdin) { raw += String(chunk); if (Buffer.byteLength(raw) > MAX_BYTES) throw new Error('Hook input too large.'); }
193
+ const output = await handleHook({ input: JSON.parse(raw || '{}'), provider });
194
+ process.stdout.write(`${JSON.stringify(output)}\n`);
195
+ } catch { process.stdout.write('{}\n'); process.stderr.write('SIMY could not check delivery state; continuation was skipped. Inspect simy delivery status.\n'); }
196
+ }
@@ -10,6 +10,15 @@
10
10
  "timeout": 3
11
11
  }
12
12
  ]
13
+ },
14
+ {
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'delivery-loop.mjs')).href); await hook.main('codex');\"",
19
+ "timeout": 3
20
+ }
21
+ ]
13
22
  }
14
23
  ],
15
24
  "UserPromptSubmit": [
@@ -22,6 +31,50 @@
22
31
  }
23
32
  ]
24
33
  }
34
+ ],
35
+ "Stop": [
36
+ {
37
+ "hooks": [
38
+ {
39
+ "type": "command",
40
+ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'delivery-loop.mjs')).href); await hook.main('codex');\"",
41
+ "timeout": 3
42
+ }
43
+ ]
44
+ }
45
+ ],
46
+ "SessionEnd": [
47
+ {
48
+ "hooks": [
49
+ {
50
+ "type": "command",
51
+ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'delivery-loop.mjs')).href); await hook.main('codex');\"",
52
+ "timeout": 3
53
+ }
54
+ ]
55
+ }
56
+ ],
57
+ "Interrupt": [
58
+ {
59
+ "hooks": [
60
+ {
61
+ "type": "command",
62
+ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'delivery-loop.mjs')).href); await hook.main('codex');\"",
63
+ "timeout": 3
64
+ }
65
+ ]
66
+ }
67
+ ],
68
+ "PermissionRequest": [
69
+ {
70
+ "hooks": [
71
+ {
72
+ "type": "command",
73
+ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'delivery-loop.mjs')).href); await hook.main('codex');\"",
74
+ "timeout": 3
75
+ }
76
+ ]
77
+ }
25
78
  ]
26
79
  }
27
80
  }
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: delivery-loop
3
+ description: Continue an explicitly requested SIMY Delivery Loop in the current provider conversation using user-reviewed hooks, bounded checkpoints, and human blocker handling.
4
+ ---
5
+
6
+ # SIMY Delivery Loop
7
+
8
+ Use only when the user explicitly requests this skill or SIMY Delivery Loop for the current task. Installing this plugin does not opt a task in. Do not run SQM unless separately requested.
9
+
10
+ 1. Require the SIMY SessionStart hook context with the exact provider and session ID. If absent, explain how to install the SIMY plugin, review and enable its hooks with `/hooks`, and start a new conversation. Codex settings can be opened at `codex://settings`; a hooks subpage deep link is not guaranteed. Never change hook trust or bypass permissions yourself.
11
+ 2. Use `simy delivery start --provider <provider> --session <exact-session-id> --objective '<user outcome and acceptance criteria>' --next-step '<concrete next action>'`. Default limits: 6 automatic continuations, 3 consecutive failures/missing checkpoints, 30 minutes. Set different finite limits only if the user requests them. Use the current working directory. Never invent a session ID, start another loop to reset limits, or resume a stopped loop without a user request.
12
+ 3. Work in the current conversation with its normal permissions. Before ending each turn, write a JSON checkpoint and run `simy delivery checkpoint --provider <provider> --session <id> --file <file>` from the same directory. Keep checkpoints outside the tracked source diff.
13
+
14
+ Checkpoint shapes:
15
+
16
+ ```json
17
+ {"status":"in_progress","summary":"Tests identified one remaining failure","nextStep":"Fix the failing validation test and rerun the targeted suite"}
18
+ ```
19
+ ```json
20
+ {"status":"blocked","blocker":"authorization","summary":"Deployment requires the user's approval"}
21
+ ```
22
+
23
+ For missing information use `blocker: "missing_information"`. For a failed attempt use `status: "error"` and a concrete `nextStep`; repeated errors stop the loop. Do not reclassify blocked work as in_progress to force continuation. Never assume a provider error means permission to switch accounts, credentials, or bypass approvals.
24
+
25
+ For completed work use `{"status":"completed","summary":"Actual result","acceptanceMet":true,"evidence":["Exact checks and observed results"]}` only after checking the user's acceptance criteria. Evidence is a report, not independent certification: preserve failed checks and deployment gaps. The hook must not infer success from optimistic closing text.
26
+
27
+ 4. The Stop hook evaluates checkpoints and supplies the next instruction within the original budgets. Missing checkpoints consume the failure budget. A human interrupt stops the loop. Approval/input events block it. Provider errors/exits may require reopening the existing conversation; these hooks cannot restart a dead provider process.
28
+ 5. After the user explicitly resolves the blocker and requests continuation, `simy delivery resume --provider <provider> --session <id>` retains all budgets. `simy delivery stop` permanently ends this loop. `simy delivery status` shows observed hook activity and loop states; old observations do not prove hooks remain enabled today.
@@ -0,0 +1,31 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { startDelivery, checkpointDelivery, controlDelivery, listRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+
4
+ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), output = console.log } = {}) {
5
+ const [action, ...args] = argv;
6
+ if (!action || action === '--help') {
7
+ output('simy delivery start --provider codex|claude --session ID --objective TEXT --next-step TEXT [--max-continuations 6] [--max-failures 3] [--max-minutes 30]\nsimy delivery checkpoint --provider codex|claude --session ID --file checkpoint.json\nsimy delivery status\nsimy delivery stop|resume --provider codex|claude --session ID\nEnable and review SIMY hooks in your provider first. Starting a loop opts in only this session. Resume requires the user to resolve the blocker; original budgets are retained.');
8
+ return 0;
9
+ }
10
+ const options = {};
11
+ const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-continuations', 'max-failures', 'max-minutes', 'file']);
12
+ for (let i = 0; i < args.length; i += 2) {
13
+ const key = args[i]?.replace(/^--/, '');
14
+ if (!args[i]?.startsWith('--') || !allowed.has(key) || args[i + 1] === undefined || Object.hasOwn(options, key)) throw new Error('Invalid delivery option. Use simy delivery --help.');
15
+ options[key] = args[i + 1];
16
+ }
17
+ const base = { root, cwd, provider: options.provider, session: options.session };
18
+ let result;
19
+ if (action === 'status') result = (await listRecords(root)).filter(r => (!options.provider || r.provider === options.provider) && (!options.session || r.session === options.session));
20
+ else if (action === 'start') result = await startDelivery({ ...base, objective: options.objective, nextStep: options['next-step'],
21
+ ...Object.fromEntries([['max-continuations', 'maxContinuations'], ['max-failures', 'maxFailures'], ['max-minutes', 'maxMinutes']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
22
+ else if (action === 'checkpoint') {
23
+ if (!options.file) throw new Error('--file is required.');
24
+ const data = await readFile(options.file, 'utf8');
25
+ if (Buffer.byteLength(data) > 64 * 1024) throw new Error('Checkpoint exceeds 64 KiB.');
26
+ result = await checkpointDelivery({ ...base, checkpoint: JSON.parse(data) });
27
+ } else if (['stop', 'resume'].includes(action)) result = await controlDelivery({ ...base, action });
28
+ else throw new Error('Unknown delivery command.');
29
+ output(JSON.stringify(result, null, 2));
30
+ return 0;
31
+ }
@@ -0,0 +1,40 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+
5
+ export const HOOK_SETUP = Object.freeze({
6
+ codex: { settingsUrl: 'codex://settings', docsUrl: 'https://learn.chatgpt.com/docs/hooks', command: '/hooks' },
7
+ claude: { settingsUrl: null, docsUrl: 'https://code.claude.com/docs/en/hooks-guide', command: '/hooks' },
8
+ });
9
+
10
+ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.now() } = {}) {
11
+ const records = await reconcileRecords({ root, now });
12
+ return {
13
+ providers: Object.fromEntries(['codex', 'claude'].map(provider => {
14
+ const latest = records.filter(r => r.provider === provider).sort((a, b) => b.observedAt - a.observedAt)[0];
15
+ return [provider, { ...HOOK_SETUP[provider], state: latest ? 'observed' : 'not_observed', lastObservedAt: latest?.observedAt || null }];
16
+ })),
17
+ runs: records.filter(r => r.run).sort((a, b) => b.run.updatedAt - a.run.updatedAt).slice(0, 30).map(r => ({
18
+ provider: r.provider, session: r.session, objective: r.run.objective, state: r.run.state, reason: r.run.reason,
19
+ continuations: r.run.continuations, maxContinuations: r.run.maxContinuations, deadline: r.run.deadline,
20
+ needsAttention: ['blocked', 'failed', 'recovery_required', 'budget_exhausted'].includes(r.run.state), updatedAt: r.run.updatedAt,
21
+ })),
22
+ };
23
+ }
24
+
25
+ // Wait for the OS opener to exit. A successful spawn alone does not prove that
26
+ // a custom URL scheme has a registered handler.
27
+ export async function openHookSetup(provider, { platform = process.platform, run = promisify(execFile) } = {}) {
28
+ const setup = HOOK_SETUP[provider];
29
+ if (!setup) throw new Error("Unsupported provider.");
30
+ const targets = platform === "darwin" && setup.settingsUrl ? [setup.settingsUrl, setup.docsUrl] : [setup.docsUrl];
31
+ for (const url of targets) {
32
+ try {
33
+ const command = platform === "darwin" ? "open" : platform === "win32" ? "rundll32" : "xdg-open";
34
+ const args = platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
35
+ await run(command, args, { timeout: 5000, maxBuffer: 4096 });
36
+ return { opened: true, url };
37
+ } catch {}
38
+ }
39
+ throw new Error("Could not open hook setup. Use /hooks inside your provider.");
40
+ }
@@ -1,3 +1,5 @@
1
+ import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
2
+ import { controlDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
1
3
  import { createWindowsDesktopUpdater } from "./windows-desktop-updater.js";
2
4
  import { spawn, execFile } from "node:child_process";
3
5
  import { promisify } from "node:util";
@@ -42,7 +44,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
42
44
  const publish = () => {
43
45
  if (closed || relay || !agent) return Promise.resolve();
44
46
  if (publishing) return publishing;
45
- publishing = Promise.resolve(agent.getDesktopStatus()).then((status) => {
47
+ publishing = Promise.resolve(agent.getDesktopStatus()).then(async (status) => {
48
+ status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: join(root, "delivery-hooks") }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
46
49
  if (!closed && !relay) emit({ type: "status", status });
47
50
  }).finally(() => { publishing = null; });
48
51
  return publishing;
@@ -178,6 +181,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
178
181
  if (login) await login(root);
179
182
  else await openProviderLogin(provider, root);
180
183
  }
184
+ else if (action === "codex_hooks" || action === "claude_hooks") {
185
+ const provider = action === "codex_hooks" ? "codex" : "claude";
186
+ await (dependencies.openHookSettings || openHookSetup)(provider);
187
+ } else if (action === "delivery_stop" || action === "delivery_resume") {
188
+ await controlDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
189
+ action: action === "delivery_stop" ? "stop" : "resume" });
190
+ }
181
191
  else if (action === "quit") {
182
192
  const status = await agent.getDesktopStatus();
183
193
  if (status.tasks.activeCount > 0 && command.force !== true) throw new Error("Tasks are still running. Confirm before quitting.");
package/src/index.js CHANGED
@@ -70,6 +70,13 @@ if (!configuredWebOrigin) {
70
70
  }
71
71
 
72
72
  async function runCommand(installMode) {
73
+ if (dispatchArgv[0] === "delivery") {
74
+ try {
75
+ const { runDeliveryCommand } = await import("./delivery-hooks/command.js");
76
+ process.exitCode = await runDeliveryCommand(dispatchArgv.slice(1));
77
+ } catch (error) { console.error(error.message); process.exitCode = 2; }
78
+ return;
79
+ }
73
80
  if (dispatchArgv[0] === "sqm") {
74
81
  try {
75
82
  const { runSqmCommand } = await import("./sqm/command.js");
package/src/local-task.js CHANGED
@@ -38,6 +38,10 @@ import {
38
38
  } from "./local-task-scenario-packs.js";
39
39
  import { createProviderStreamDecoder } from "./provider-stream.js";
40
40
  import { signalExecutorProcess } from "./runner.js";
41
+ import {
42
+ createWorkflowObservationSender,
43
+ normalizeWorkflowObservationBinding,
44
+ } from "./workflow-observation-sender.js";
41
45
 
42
46
  export const DEFAULT_LOCAL_TASK_TOKEN_BUDGET = 100_000;
43
47
  export const DEFAULT_LOCAL_TASK_TIMEOUT_MS = 10 * 60_000;
@@ -251,6 +255,7 @@ export function createLocalTask({
251
255
  timeoutMs = DEFAULT_LOCAL_TASK_TIMEOUT_MS,
252
256
  idempotencyKeyHash = null,
253
257
  requestFingerprint = null,
258
+ workflowObservationBinding = null,
254
259
  }) {
255
260
  validateTaskId(id);
256
261
  const now = new Date().toISOString();
@@ -259,6 +264,10 @@ export function createLocalTask({
259
264
  const normalizedScenarioPack = normalizeLocalTaskScenarioPack(scenarioPack);
260
265
  const scenarioExpectedOutputs =
261
266
  localTaskScenarioExpectedOutputs(normalizedScenarioPack);
267
+ const normalizedWorkflowObservationBinding = normalizeWorkflowObservationBinding(
268
+ workflowObservationBinding,
269
+ { provider: backend },
270
+ );
262
271
  return {
263
272
  id,
264
273
  kind: "local_task",
@@ -296,6 +305,17 @@ export function createLocalTask({
296
305
  provider_token_budget: tokenBudget,
297
306
  provider_tokens_used: 0,
298
307
  token_usage: { records: [] },
308
+ ...(normalizedWorkflowObservationBinding
309
+ ? {
310
+ workflow_observation_binding: normalizedWorkflowObservationBinding,
311
+ workflow_observation: {
312
+ status: "pending",
313
+ workflow_id: normalizedWorkflowObservationBinding.workflow_id,
314
+ workflow_run_id: normalizedWorkflowObservationBinding.workflow_run_id,
315
+ development_outcome: "unverified",
316
+ },
317
+ }
318
+ : {}),
299
319
  timeout_ms: normalizedTimeout,
300
320
  created_at: now,
301
321
  updated_at: now,
@@ -327,6 +347,7 @@ export function createLocalTaskRequestFingerprint({
327
347
  timeoutMs,
328
348
  attachmentManifest,
329
349
  scenarioPack,
350
+ workflowObservationBinding = null,
330
351
  }) {
331
352
  const normalizedModel = normalizeLocalTaskModel(model);
332
353
  const manifest = Array.isArray(attachmentManifest)
@@ -339,6 +360,10 @@ export function createLocalTaskRequestFingerprint({
339
360
  sha256: String(item.sha256 || "").toLowerCase(),
340
361
  }))
341
362
  : [];
363
+ const normalizedWorkflowObservationBinding = normalizeWorkflowObservationBinding(
364
+ workflowObservationBinding,
365
+ { provider: backend },
366
+ );
342
367
  const payload = {
343
368
  instruction: String(instruction || "").trim(),
344
369
  backend: String(backend || ""),
@@ -352,6 +377,9 @@ export function createLocalTaskRequestFingerprint({
352
377
  timeout_ms: normalizeTimeoutMs(timeoutMs),
353
378
  attachment_manifest: manifest,
354
379
  scenario_pack: normalizeLocalTaskScenarioPack(scenarioPack),
380
+ ...(normalizedWorkflowObservationBinding
381
+ ? { workflow_observation_binding: normalizedWorkflowObservationBinding }
382
+ : {}),
355
383
  };
356
384
  return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
357
385
  }
@@ -363,6 +391,9 @@ export async function startLocalTask(
363
391
  environment = process.env,
364
392
  persist = persistLocalTask,
365
393
  beforeProviderInvocation = null,
394
+ session = null,
395
+ apiOrigin = null,
396
+ observationFetch = globalThis.fetch,
366
397
  } = {},
367
398
  ) {
368
399
  if (!task || task.status !== "queued") throw new Error("Local Task must be queued.");
@@ -442,6 +473,14 @@ export async function startLocalTask(
442
473
  // synchronous spawn boundary so a stopped task can never start Provider work.
443
474
  if (task.stop_requested || task.status !== "running") return task;
444
475
 
476
+ const observationSender = createWorkflowObservationSender({
477
+ binding: task.workflow_observation_binding,
478
+ backend: task.backend,
479
+ session,
480
+ apiOrigin,
481
+ fetchImpl: observationFetch,
482
+ });
483
+
445
484
  const runtimeTempBase = process.platform === "darwin" ? "/tmp" : tmpdir();
446
485
  const runtimeTempRoot = await mkdtemp(
447
486
  path.join(runtimeTempBase, `simy-local-task-${task.id}-`),
@@ -513,6 +552,7 @@ export async function startLocalTask(
513
552
  stream: "stdout",
514
553
  onLine,
515
554
  onUsage,
555
+ onEvent: (event) => observationSender?.observe(event),
516
556
  onTerminalError: (message) => {
517
557
  task.error ||= message;
518
558
  task.reason_code ||= "provider_execution_failed";
@@ -560,6 +600,9 @@ export async function startLocalTask(
560
600
  (total, item) => total + providerTokenCount(item),
561
601
  0,
562
602
  );
603
+ if (observationSender) {
604
+ task.workflow_observation = await observationSender.flush();
605
+ }
563
606
 
564
607
  let artifacts = [];
565
608
  let scenarioAcceptance = null;
@@ -709,6 +752,9 @@ export function localTaskSnapshot(task, { includeEvents = true, includeLocalPath
709
752
  provider_token_budget: task.provider_token_budget,
710
753
  provider_tokens_used: task.provider_tokens_used,
711
754
  token_usage: structuredClone(task.token_usage),
755
+ ...(task.workflow_observation
756
+ ? { workflow_observation: structuredClone(task.workflow_observation) }
757
+ : {}),
712
758
  timeout_ms: task.timeout_ms,
713
759
  created_at: task.created_at,
714
760
  updated_at: task.updated_at,
@@ -724,6 +770,7 @@ export function localTaskSnapshot(task, { includeEvents = true, includeLocalPath
724
770
  outputs_root_expected: task.outputs_root_expected,
725
771
  idempotency_key_hash: task.idempotency_key_hash || null,
726
772
  request_fingerprint: task.request_fingerprint || null,
773
+ workflow_observation_binding: task.workflow_observation_binding || null,
727
774
  }
728
775
  : {}),
729
776
  };
@@ -913,6 +960,10 @@ export async function localTaskArtifact(task, artifactId) {
913
960
  }
914
961
 
915
962
  function restoreLocalTask(snapshot) {
963
+ const workflowObservationBinding = normalizeWorkflowObservationBinding(
964
+ snapshot.workflow_observation_binding,
965
+ { provider: snapshot.backend },
966
+ );
916
967
  return {
917
968
  ...snapshot,
918
969
  outputs_root_expected:
@@ -926,6 +977,8 @@ function restoreLocalTask(snapshot) {
926
977
  scenario_acceptance: snapshot.scenario_acceptance || null,
927
978
  warnings: Array.isArray(snapshot.warnings) ? snapshot.warnings : [],
928
979
  token_usage: snapshot.token_usage || { records: [] },
980
+ workflow_observation_binding: workflowObservationBinding,
981
+ workflow_observation: snapshot.workflow_observation || null,
929
982
  child: null,
930
983
  operation_promise: null,
931
984
  stop_requested: false,
@@ -5,7 +5,7 @@ const STRUCTURED_MARKERS = ["SIMY_RESULT_JSON:", "SIMY_AUDIT_JSON:"];
5
5
  const CODEX_MCP_AUTH_WARNING =
6
6
  "[Codex] MCP warning: optional connector authorization expired; coding continues.";
7
7
 
8
- export function createProviderStreamDecoder({ backend, stream = "stdout", onLine, onUsage, onTerminalError }) {
8
+ export function createProviderStreamDecoder({ backend, stream = "stdout", onLine, onUsage, onTerminalError, onEvent }) {
9
9
  let pending = "";
10
10
  let previous = "";
11
11
  let model = "";
@@ -22,6 +22,8 @@ export function createProviderStreamDecoder({ backend, stream = "stdout", onLine
22
22
  return;
23
23
  }
24
24
 
25
+ if (onEvent) onEvent(event);
26
+
25
27
  const terminalError = providerTerminalError(backend, event);
26
28
  if (terminalError && onTerminalError) onTerminalError(terminalError);
27
29
 
package/src/runner.js CHANGED
@@ -45,6 +45,10 @@ import { CLI_VERSION } from "./cli-contract.js";
45
45
  import { reviseObjective, verifyObjective } from "./orchestrator/outcome-monitor.js";
46
46
  import { hasUserOutcomeProof } from "./orchestrator/completion-contract.js";
47
47
  import { createSqmChecker } from "./sqm/index.js";
48
+ import {
49
+ createWorkflowObservationSender,
50
+ normalizeWorkflowObservationBinding,
51
+ } from "./workflow-observation-sender.js";
48
52
  import { saveCodingRunSnapshot } from "./coding-run-store.js";
49
53
 
50
54
  const execFileAsync = promisify(execFile);
@@ -88,6 +92,10 @@ export function createRun({ runId, request, session, apiOrigin, localStateRoot =
88
92
  execution_target: normalizeDesktopExecutionTarget(request?.execution_target),
89
93
  execution_device_id:
90
94
  String(request?.execution_device_id || session?.device_id || "").trim() || null,
95
+ workflow_observation_binding: normalizeWorkflowObservationBinding(
96
+ request?.workflow_observation_binding,
97
+ { provider: request?.backend },
98
+ ),
91
99
  };
92
100
  return {
93
101
  id: runId,
@@ -116,6 +124,18 @@ export function createRun({ runId, request, session, apiOrigin, localStateRoot =
116
124
  metadata: {
117
125
  local_orchestrator_version: 1,
118
126
  local_output_persisted: false,
127
+ ...(normalizedRequest.workflow_observation_binding
128
+ ? {
129
+ workflow_observation: {
130
+ status: "pending",
131
+ workflow_id: normalizedRequest.workflow_observation_binding.workflow_id,
132
+ workflow_run_id:
133
+ normalizedRequest.workflow_observation_binding.workflow_run_id,
134
+ development_outcome: "unverified",
135
+ binding: normalizedRequest.workflow_observation_binding,
136
+ },
137
+ }
138
+ : {}),
119
139
  },
120
140
  }),
121
141
  };
@@ -159,6 +179,8 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, loc
159
179
  require_human_approval: charter.require_human_approval !== false,
160
180
  must_not: Array.isArray(charter.must_not) ? charter.must_not : [],
161
181
  proposal_id: typeof charter.proposal_id === "string" ? charter.proposal_id : null,
182
+ workflow_observation_binding:
183
+ snapshot.metadata?.workflow_observation?.binding ?? null,
162
184
  charter_context: {
163
185
  source_charter_id: charter.id,
164
186
  audit_backend: charter.audit_backend,
@@ -757,7 +779,7 @@ function sqmEvidenceEndpoint(session) {
757
779
  return new URL("sqm/run-evidence", session.api_base_url).href;
758
780
  }
759
781
 
760
- async function executeProcessAttempt({
782
+ export async function executeProcessAttempt({
761
783
  backend,
762
784
  instruction,
763
785
  repositoryPath,
@@ -775,6 +797,15 @@ async function executeProcessAttempt({
775
797
  const stderr = [];
776
798
  const tokenUsageRecords = [];
777
799
  const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
800
+ const observationSender =
801
+ phase === "executor" && run.snapshot.metadata?.workflow_observation?.status !== "accepted"
802
+ ? createWorkflowObservationSender({
803
+ binding: run.request.workflow_observation_binding,
804
+ backend,
805
+ session: run.session,
806
+ apiOrigin: run.apiOrigin,
807
+ })
808
+ : null;
778
809
 
779
810
  return new Promise((resolve) => {
780
811
  let settled = false;
@@ -791,6 +822,7 @@ async function executeProcessAttempt({
791
822
  backend,
792
823
  stream: "stdout",
793
824
  onLine: (line) => emitOutput(run, line, { backend }),
825
+ onEvent: (event) => observationSender?.observe(event),
794
826
  onTerminalError: (message) => { terminalError ||= message; },
795
827
  onUsage: (usage) =>
796
828
  tokenUsageRecords.push({
@@ -817,12 +849,26 @@ async function executeProcessAttempt({
817
849
  child.stdout?.on("data", (chunk) => collect(stdout, stdoutDecoder, chunk));
818
850
  child.stderr?.on("data", (chunk) => collect(stderr, stderrDecoder, chunk));
819
851
 
820
- const finish = ({ exitCode = null, error = null } = {}) => {
852
+ const finish = async ({ exitCode = null, error = null } = {}) => {
821
853
  if (settled) return;
822
854
  settled = true;
823
855
  stdoutDecoder.flush();
824
856
  stderrDecoder.flush();
825
857
  run.child = null;
858
+ if (observationSender) {
859
+ const observation = await observationSender.flush();
860
+ run.snapshot.metadata.workflow_observation = {
861
+ ...observation,
862
+ binding: run.request.workflow_observation_binding,
863
+ };
864
+ if (observation.status === "failed") {
865
+ emitOutput(
866
+ run,
867
+ "WorkFlow observation was not accepted; the Provider result is unchanged and development completion remains unverified.",
868
+ { backend },
869
+ );
870
+ }
871
+ }
826
872
  const rawStdout = stdout.join("");
827
873
  const assistantText = extractAssistantText(rawStdout);
828
874
  const parse =
@@ -846,8 +892,8 @@ async function executeProcessAttempt({
846
892
  });
847
893
  };
848
894
 
849
- child.on("error", (error) => finish({ error: error.message }));
850
- child.on("close", (code) => finish({ exitCode: code }));
895
+ child.on("error", (error) => void finish({ error: error.message }));
896
+ child.on("close", (code) => void finish({ exitCode: code }));
851
897
  });
852
898
  }
853
899
 
@@ -20,13 +20,18 @@ export async function claudePluginFiles() {
20
20
  const content = await readFile(path.join(SOURCE, relative), "utf8");
21
21
  files.set(relative, content.replaceAll("current Codex thread", "current Claude Code conversation"));
22
22
  }
23
+ files.set("hooks/delivery-loop.mjs", await readFile(path.join(SOURCE, "hooks/delivery-loop.mjs"), "utf8"));
23
24
  files.set("hooks/connection-reminder.mjs", await readFile(path.join(SOURCE, "hooks/connection-reminder.mjs"), "utf8"));
24
25
  const hooks = JSON.parse(await readFile(path.join(SOURCE, "hooks/hooks.json"), "utf8"));
26
+ delete hooks.hooks.Interrupt; // Codex-only event; Claude interruptions cannot be vetoed.
27
+ hooks.hooks.StopFailure = structuredClone(hooks.hooks.Stop);
28
+ hooks.hooks.Notification = structuredClone(hooks.hooks.PermissionRequest);
25
29
  for (const groups of Object.values(hooks.hooks)) {
26
30
  for (const group of groups) {
27
31
  for (const hook of group.hooks) {
28
32
  hook.command = hook.command.replace(/^node /, `${quoteRuntime(process.execPath)} `)
29
- .replaceAll("process.env.PLUGIN_ROOT", "process.env.CLAUDE_PLUGIN_ROOT");
33
+ .replaceAll("process.env.PLUGIN_ROOT", "process.env.CLAUDE_PLUGIN_ROOT")
34
+ .replaceAll("hook.main('codex')", "hook.main('claude')");
30
35
  }
31
36
  }
32
37
  }
@@ -58,7 +58,7 @@ export async function prepareDaemonWorkflows({
58
58
  try {
59
59
  const installed = await install();
60
60
  logger(
61
- "SIMY workflows installed. In Codex, restart, then find and install SIMY in Plugins. In Claude Code, add the SIMY marketplace and install simy@simy-local before using /simy:sqm-loop or /simy:pr-review.",
61
+ "SIMY workflows installed. In Codex, restart, then find and install SIMY in Plugins. In Claude Code, add the SIMY marketplace and install simy@simy-local before using /simy:sqm-loop or /simy:pr-review. For Delivery Loop, review and enable SIMY hooks in /hooks, then request /simy:delivery-loop for each task. Plugin installation does not grant hook trust.",
62
62
  );
63
63
  if (installed.claude_plugin?.marketplace_root) {
64
64
  logger(`Claude Code: claude plugin marketplace add ${JSON.stringify(installed.claude_plugin.marketplace_root)}`);
@@ -0,0 +1,208 @@
1
+ import { webApiHeaders, webApiUrl } from "./web-api.js";
2
+
3
+ const DIGEST = /^sha256:[0-9a-f]{64}$/;
4
+ const SOURCE_REF = /^(codex|claude):[0-9a-f]{64}$/;
5
+ const WORKFLOW_UUID =
6
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7
+
8
+ export function normalizeWorkflowObservationBinding(value, { provider = null } = {}) {
9
+ if (value === undefined || value === null) return null;
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
11
+ throw bindingError();
12
+ }
13
+ const keys = Object.keys(value).sort();
14
+ const expected = ["action_digest", "source_ref", "workflow_id", "workflow_run_id"];
15
+ if (
16
+ keys.length !== expected.length ||
17
+ keys.some((key, index) => key !== expected[index]) ||
18
+ !WORKFLOW_UUID.test(String(value.workflow_id || "")) ||
19
+ !WORKFLOW_UUID.test(String(value.workflow_run_id || "")) ||
20
+ !DIGEST.test(String(value.action_digest || "")) ||
21
+ !SOURCE_REF.test(String(value.source_ref || "")) ||
22
+ (provider !== null && !String(value.source_ref).startsWith(`${provider}:`))
23
+ ) {
24
+ throw bindingError();
25
+ }
26
+ return Object.freeze({
27
+ workflow_id: String(value.workflow_id),
28
+ workflow_run_id: String(value.workflow_run_id),
29
+ action_digest: String(value.action_digest),
30
+ source_ref: String(value.source_ref),
31
+ });
32
+ }
33
+
34
+ export function createWorkflowObservationSender({
35
+ binding,
36
+ backend,
37
+ session,
38
+ apiOrigin,
39
+ fetchImpl = globalThis.fetch,
40
+ now = () => new Date(),
41
+ }) {
42
+ const normalizedBinding = normalizeWorkflowObservationBinding(binding, { provider: backend });
43
+ if (!normalizedBinding) return null;
44
+ if (backend !== "codex" && backend !== "claude") {
45
+ throw new Error("WorkFlow observation provider must be codex or claude.");
46
+ }
47
+
48
+ let providerSessionId = null;
49
+ let terminal = null;
50
+
51
+ return {
52
+ observe(event) {
53
+ if (!event || typeof event !== "object" || terminal) return;
54
+ providerSessionId ||= providerSessionIdentifier(backend, event);
55
+ const terminalEvent = terminalProviderState(backend, event);
56
+ if (!terminalEvent) return;
57
+ terminal = {
58
+ ...terminalEvent,
59
+ observedAt: now().toISOString(),
60
+ };
61
+ },
62
+
63
+ async flush() {
64
+ if (!terminal) {
65
+ return observationState("failed", normalizedBinding, {
66
+ reason_code: "provider_terminal_event_missing",
67
+ });
68
+ }
69
+ if (!providerSessionId) {
70
+ return observationState("failed", normalizedBinding, {
71
+ reason_code: "provider_session_identity_missing",
72
+ });
73
+ }
74
+ if (!session?.token || !session?.api_base_url) {
75
+ return observationState("failed", normalizedBinding, {
76
+ reason_code: "device_session_unavailable",
77
+ });
78
+ }
79
+
80
+ const body = {
81
+ action_digest: normalizedBinding.action_digest,
82
+ source_ref: normalizedBinding.source_ref,
83
+ observed_at: terminal.observedAt,
84
+ event: {
85
+ provider: backend,
86
+ type: terminal.eventType,
87
+ outcome: terminal.outcome,
88
+ },
89
+ };
90
+
91
+ let response;
92
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
93
+ try {
94
+ response = await fetchImpl(
95
+ webApiUrl(
96
+ `workflows/${encodeURIComponent(normalizedBinding.workflow_id)}/runs/${encodeURIComponent(normalizedBinding.workflow_run_id)}/observations`,
97
+ {
98
+ webOrigin: apiOrigin,
99
+ apiBaseUrl: session.api_base_url,
100
+ },
101
+ ),
102
+ {
103
+ method: "POST",
104
+ headers: webApiHeaders(
105
+ { token: session.token },
106
+ { "Content-Type": "application/json" },
107
+ ),
108
+ body: JSON.stringify(body),
109
+ signal: AbortSignal.timeout(10_000),
110
+ },
111
+ );
112
+ } catch {
113
+ if (attempt === 2) {
114
+ return observationState("failed", normalizedBinding, {
115
+ reason_code: "observation_service_unavailable",
116
+ });
117
+ }
118
+ continue;
119
+ }
120
+ if (response.status < 500 || attempt === 2) break;
121
+ }
122
+
123
+ const payload = await response.json().catch(() => null);
124
+ if (!response.ok) {
125
+ return observationState("failed", normalizedBinding, {
126
+ reason_code: response.status === 401 || response.status === 403
127
+ ? "device_or_source_not_authorized"
128
+ : response.status === 404 || response.status === 410
129
+ ? "workflow_binding_unavailable"
130
+ : "observation_not_accepted",
131
+ http_status: response.status,
132
+ });
133
+ }
134
+ const receipt = payload?.receipt;
135
+ if (!validReceipt(receipt, normalizedBinding)) {
136
+ return observationState("failed", normalizedBinding, {
137
+ reason_code: "invalid_observation_receipt",
138
+ });
139
+ }
140
+ return observationState("accepted", normalizedBinding, {
141
+ observation_id: receipt.observation_id,
142
+ });
143
+ },
144
+ };
145
+ }
146
+
147
+ function providerSessionIdentifier(backend, event) {
148
+ if (backend === "codex" && event.type === "thread.started") {
149
+ return cleanIdentifier(event.thread_id);
150
+ }
151
+ if (backend === "claude" && event.type === "system" && event.subtype === "init") {
152
+ return cleanIdentifier(event.session_id);
153
+ }
154
+ return null;
155
+ }
156
+
157
+ function terminalProviderState(backend, event) {
158
+ if (backend === "codex") {
159
+ if (event.type === "turn.completed") {
160
+ return { eventType: "turn.completed", outcome: "succeeded" };
161
+ }
162
+ if (event.type === "turn.failed") {
163
+ return { eventType: "turn.failed", outcome: "failed" };
164
+ }
165
+ return null;
166
+ }
167
+ if (event.type !== "result") return null;
168
+ return {
169
+ eventType: "result",
170
+ outcome:
171
+ event.is_error === true || String(event.subtype || "").startsWith("error")
172
+ ? "failed"
173
+ : "succeeded",
174
+ };
175
+ }
176
+
177
+ function validReceipt(receipt, binding) {
178
+ return Boolean(
179
+ receipt &&
180
+ typeof receipt === "object" &&
181
+ receipt.accepted === true &&
182
+ receipt.workflow_id === binding.workflow_id &&
183
+ receipt.workflow_run_id === binding.workflow_run_id &&
184
+ typeof receipt.observation_id === "string" &&
185
+ WORKFLOW_UUID.test(receipt.observation_id),
186
+ );
187
+ }
188
+
189
+ function observationState(status, binding, extra = {}) {
190
+ return {
191
+ status,
192
+ workflow_id: binding.workflow_id,
193
+ workflow_run_id: binding.workflow_run_id,
194
+ development_outcome: "unverified",
195
+ ...extra,
196
+ };
197
+ }
198
+
199
+ function cleanIdentifier(value) {
200
+ const identifier = String(value || "").trim();
201
+ return identifier && identifier.length <= 500 ? identifier : null;
202
+ }
203
+
204
+ function bindingError() {
205
+ const error = new Error("WorkFlow observation binding is invalid.");
206
+ error.code = "workflow_observation_binding_invalid";
207
+ return error;
208
+ }