@awak-app/simy-cli 0.5.29 → 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.
Files changed (41) hide show
  1. package/README.md +15 -5
  2. package/package.json +3 -2
  3. package/src/agent.js +148 -3
  4. package/src/cli-contract.js +1 -1
  5. package/src/codex-plugins/simy/.codex-plugin/plugin.json +15 -6
  6. package/src/codex-plugins/simy/hooks/README.md +15 -0
  7. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +196 -0
  8. package/src/codex-plugins/simy/hooks/hooks.json +53 -0
  9. package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +28 -0
  10. package/src/codex-plugins/simy/skills/sqm-loop/SKILL.md +3 -1
  11. package/src/coding-run-store.js +69 -0
  12. package/src/delivery-hooks/command.js +31 -0
  13. package/src/delivery-hooks/desktop.js +40 -0
  14. package/src/desktop-host.js +11 -1
  15. package/src/index.js +7 -0
  16. package/src/local-task.js +55 -0
  17. package/src/orchestrator/audit.js +3 -0
  18. package/src/orchestrator/checklist-continuation.js +3 -3
  19. package/src/orchestrator/completion-contract.js +7 -0
  20. package/src/orchestrator/contract.js +6 -4
  21. package/src/orchestrator/delivery/WORKFLOW.md +93 -6
  22. package/src/orchestrator/delivery/references/acceptance-evidence.md +9 -5
  23. package/src/orchestrator/delivery/references/change-boundary-matrix.md +20 -1
  24. package/src/orchestrator/delivery/references/desktop-monitor-triggers.md +35 -0
  25. package/src/orchestrator/delivery/references/shared-system-specifications.md +3 -0
  26. package/src/orchestrator/delivery/references/state-and-receipts.md +19 -0
  27. package/src/orchestrator/execution-io.js +15 -0
  28. package/src/orchestrator/independent-audit.js +14 -1
  29. package/src/orchestrator/instruction.js +3 -0
  30. package/src/orchestrator/loop.js +71 -0
  31. package/src/orchestrator/outcome-monitor.js +123 -0
  32. package/src/provider-stream.js +3 -1
  33. package/src/runner.js +162 -20
  34. package/src/sqm/agent-skill.js +3 -2
  35. package/src/sqm/claude-plugin.js +6 -1
  36. package/src/sqm/command.js +13 -0
  37. package/src/sqm/report.js +23 -0
  38. package/src/sqm/workflow-onboarding.js +1 -1
  39. package/src/workflow-observation-sender.js +208 -0
  40. package/src/workflow-reporting.js +35 -0
  41. package/src/workflow-reporting.md +137 -0
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
 
@@ -342,6 +344,14 @@ SQM deliberately has two identity-bound stages:
342
344
  state transitions, invariants, stress/strength expectations, and scenarios,
343
345
  writes `proof.json`, and signs that proof with the local device identity.
344
346
 
347
+ The full-check summary reports applicable modules, evaluated rules, scenario
348
+ statuses and blocking/advisory scope. `passed` with `no_applicable_rules` or zero
349
+ scenario evaluations does not establish feature or historical incident coverage.
350
+ Scenario statuses come from deterministic rule evaluation, not browser/provider
351
+ execution. Keep concrete regression and delivery acceptance evidence separately.
352
+ If knowledge is unavailable, the command returns exit 2 with the service cause
353
+ and writes no new proof; any older artifact is not evidence for that attempt.
354
+
345
355
  Normal runs do not require a separate sync command: the CLI
346
356
  uses a verified local cache and revalidates it with TTL and ETag semantics.
347
357
  The Agentic Loop runs min-check after each code modification and the full check
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.29",
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 ./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
@@ -10,6 +10,7 @@ import {
10
10
  createRun,
11
11
  increaseLocalCodingRunProviderTokenBudget,
12
12
  LocalRunRegistry,
13
+ markCodingRunMonitorFailure,
13
14
  isLocalRepositoryApprovalPending,
14
15
  pauseLocalCodingRun,
15
16
  queueLocalCodingGuidance,
@@ -19,10 +20,12 @@ import {
19
20
  restoreRun,
20
21
  resumeLocalCodingRun,
21
22
  startLocalCodingRun,
23
+ syncLocalCodingRun,
22
24
  stopLocalCodingRun,
23
25
  toCompatibleCodingLoopState,
24
26
  toLedgerSnapshot,
25
27
  } from "./runner.js";
28
+ import { codingRunsRoot, loadCodingRunSnapshots, saveCodingRunSnapshot } from "./coding-run-store.js";
26
29
  import {
27
30
  expiresAtFromNow,
28
31
  isSessionValid,
@@ -92,6 +95,7 @@ import {
92
95
  classifyExecutionRequirement,
93
96
  EXECUTION_GUARDRAIL_POLICY_VERSION,
94
97
  } from "./execution-guardrail.js";
98
+ import { normalizeWorkflowObservationBinding } from "./workflow-observation-sender.js";
95
99
  import {
96
100
  createLocalTask,
97
101
  createLocalTaskId,
@@ -134,6 +138,7 @@ import {
134
138
  } from "./connection-discovery.js";
135
139
 
136
140
  const DEVICE_HEARTBEAT_INTERVAL_MS = 20_000;
141
+ const CODING_RUN_MONITOR_INTERVAL_MS = 5 * 60_000;
137
142
 
138
143
  export async function startAgent({
139
144
  requestedPort = 0,
@@ -148,6 +153,7 @@ export async function startAgent({
148
153
  } = {}) {
149
154
  const apiOrigin = resolveWebOrigin(webOrigin);
150
155
  const registry = new LocalRunRegistry();
156
+ const codingRunStateRoot = codingRunsRoot(sessionRoot);
151
157
  const directRegistry = new DirectTaskRegistry();
152
158
  const localTaskRegistry = new LocalTaskRegistry();
153
159
  const taskStorageRoot = localTasksRoot(
@@ -217,6 +223,8 @@ export async function startAgent({
217
223
  let port = requestedPort;
218
224
  let heartbeatTimer = null;
219
225
  let heartbeatInFlight = null;
226
+ let codingRunMonitorTimer = null;
227
+ let codingRunMonitorInFlight = null;
220
228
  let shuttingDown = false;
221
229
  const notifyDesktopStatusChange = () => {
222
230
  if (shuttingDown) return;
@@ -296,6 +304,14 @@ export async function startAgent({
296
304
  return loginUrl.toString();
297
305
  };
298
306
 
307
+ const publishOrSyncRestoredRun = async (run) => {
308
+ if (run.restoredFromState || run.restoredObjectiveUnverified) {
309
+ await publishRestoredLocalCodingRun(run);
310
+ } else {
311
+ syncLocalCodingRun(run);
312
+ }
313
+ };
314
+
299
315
  const restoreRemoteRuns = async () => {
300
316
  if (shuttingDown || !isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) return;
301
317
  const snapshots = dependencies.listRemoteRuns
@@ -309,24 +325,113 @@ export async function startAgent({
309
325
  apiBaseUrl: session.api_base_url,
310
326
  token: session.token,
311
327
  });
328
+ const remoteIds = new Set();
312
329
  let restored = false;
313
330
  for (const snapshot of snapshots) {
314
331
  if (shuttingDown) return;
315
- if (!snapshot?.id || registry.has(snapshot.id)) continue;
332
+ if (!snapshot?.id) continue;
333
+ remoteIds.add(snapshot.id);
334
+ if (registry.has(snapshot.id)) {
335
+ const localRun = registry.get(snapshot.id);
336
+ localRun.session = session;
337
+ localRun.apiOrigin = apiOrigin;
338
+ const remoteTime = Date.parse(snapshot.updated_at || "") || 0;
339
+ const localTime = Date.parse(localRun.snapshot.updated_at || "") || 0;
340
+ if (remoteTime > localTime) {
341
+ const repository = findRepository(repositoryInventory, snapshot.charter?.repository);
342
+ const remoteRun = restoreRun({
343
+ snapshot, session, apiOrigin,
344
+ localPath: repository?.local_path || null,
345
+ localStateRoot: codingRunStateRoot,
346
+ });
347
+ Object.assign(localRun, {
348
+ request: remoteRun.request,
349
+ status: remoteRun.status,
350
+ snapshot: remoteRun.snapshot,
351
+ controlState: remoteRun.controlState,
352
+ completedAt: remoteRun.completedAt,
353
+ restoredFromState: remoteRun.restoredFromState,
354
+ restoredObjectiveUnverified: remoteRun.restoredObjectiveUnverified,
355
+ });
356
+ await saveCodingRunSnapshot(codingRunStateRoot, localRun.snapshot);
357
+ await publishRestoredLocalCodingRun(localRun);
358
+ } else {
359
+ await publishOrSyncRestoredRun(localRun);
360
+ }
361
+ continue;
362
+ }
316
363
  const repository = findRepository(repositoryInventory, snapshot.charter?.repository);
317
364
  const restoredRun = restoreRun({
318
365
  snapshot,
319
366
  session,
320
367
  apiOrigin,
321
368
  localPath: repository?.local_path || null,
369
+ localStateRoot: codingRunStateRoot,
322
370
  });
323
371
  registry.create(restoredRun);
324
372
  restored = true;
325
373
  await publishRestoredLocalCodingRun(restoredRun);
326
374
  }
375
+ for (const localRun of registry.list()) {
376
+ if (!remoteIds.has(localRun.id)) await publishOrSyncRestoredRun(localRun);
377
+ }
327
378
  if (restored) notifyDesktopStatusChange();
328
379
  };
329
380
 
381
+ const restoreLocalCodingRuns = async () => {
382
+ const { snapshots, errors } = await loadCodingRunSnapshots(codingRunStateRoot);
383
+ for (const error of errors) {
384
+ reportStartupConnectionError(new Error(`Coding run ${error.id}: ${error.message}`), quiet);
385
+ }
386
+ for (const snapshot of snapshots) {
387
+ const boundDevice = snapshot?.metadata?.execution_device_id;
388
+ if (boundDevice && boundDevice !== session?.device_id) continue;
389
+ if (registry.has(snapshot.id)) {
390
+ const existing = registry.get(snapshot.id);
391
+ existing.session = session;
392
+ existing.apiOrigin = apiOrigin;
393
+ continue;
394
+ }
395
+ try {
396
+ const repository = findRepository(repositoryInventory, snapshot.charter?.repository);
397
+ const run = restoreRun({
398
+ snapshot, session, apiOrigin,
399
+ localPath: repository?.local_path || null,
400
+ localStateRoot: codingRunStateRoot,
401
+ });
402
+ // Keep the persisted timestamp until the Web ledger has been compared.
403
+ // Publishing recovery here would make an older local record look newest.
404
+ registry.create(run);
405
+ } catch (error) {
406
+ reportStartupConnectionError(error, quiet);
407
+ }
408
+ }
409
+ };
410
+
411
+ const monitorCodingRuns = () => {
412
+ if (!daemon || !isSessionValid(session, Date.now(), apiOrigin)) return Promise.resolve();
413
+ if (codingRunMonitorInFlight) return codingRunMonitorInFlight;
414
+ codingRunMonitorInFlight = (async () => {
415
+ for (const run of registry.list()) {
416
+ if (!["pr_ready_for_review", "merge_ready"].includes(run.status) || run.operation || run.child) continue;
417
+ try {
418
+ await (dependencies.recheckCodingRun || recheckLocalCodingRun)(run);
419
+ } catch (error) {
420
+ await markCodingRunMonitorFailure(run, error);
421
+ }
422
+ }
423
+ })().finally(() => { codingRunMonitorInFlight = null; });
424
+ return codingRunMonitorInFlight;
425
+ };
426
+
427
+ const startCodingRunMonitor = () => {
428
+ if (!daemon || codingRunMonitorTimer) return;
429
+ codingRunMonitorTimer = setInterval(() => {
430
+ void monitorCodingRuns().catch((error) => reportStartupConnectionError(error, quiet));
431
+ }, dependencies.codingRunMonitorIntervalMs || CODING_RUN_MONITOR_INTERVAL_MS);
432
+ codingRunMonitorTimer.unref();
433
+ };
434
+
330
435
  const heartbeat = () => {
331
436
  if (shuttingDown || !isSessionValid(session, Date.now(), apiOrigin)) return Promise.resolve(false);
332
437
  if (heartbeatInFlight) return heartbeatInFlight;
@@ -406,7 +511,9 @@ export async function startAgent({
406
511
  reportHeartbeatError(error, quiet);
407
512
  }
408
513
  try {
514
+ await restoreLocalCodingRuns();
409
515
  await restoreRemoteRuns();
516
+ startCodingRunMonitor();
410
517
  } catch (error) {
411
518
  reportStartupConnectionError(error, quiet);
412
519
  }
@@ -966,6 +1073,19 @@ export async function startAgent({
966
1073
  }
967
1074
  throw error;
968
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
+ }
969
1089
  let requestFingerprint;
970
1090
  try {
971
1091
  requestFingerprint = createLocalTaskRequestFingerprint({
@@ -981,6 +1101,7 @@ export async function startAgent({
981
1101
  timeoutMs: body.timeout_ms,
982
1102
  attachmentManifest: preparedAttachments,
983
1103
  scenarioPack,
1104
+ workflowObservationBinding,
984
1105
  });
985
1106
  hashLocalTaskIdempotencyKey(idempotencyKey);
986
1107
  } catch (error) {
@@ -1105,12 +1226,15 @@ export async function startAgent({
1105
1226
  timeoutMs: body.timeout_ms,
1106
1227
  idempotencyKeyHash: hashLocalTaskIdempotencyKey(idempotencyKey),
1107
1228
  requestFingerprint,
1229
+ workflowObservationBinding,
1108
1230
  });
1109
1231
  await persistLocalTask(task);
1110
1232
  localTaskRegistry.create(task);
1111
1233
  requestClaim.complete(task);
1112
1234
  requestClaimCompleted = true;
1113
- const runLocalTask = dependencies.runLocalTask || startLocalTask;
1235
+ const runLocalTask =
1236
+ dependencies.runLocalTask ||
1237
+ ((localTask) => startLocalTask(localTask, { session, apiOrigin }));
1114
1238
  task.operation_promise = Promise.resolve(runLocalTask(task))
1115
1239
  .catch(async (error) => {
1116
1240
  task.status = "failed";
@@ -1338,6 +1462,19 @@ export async function startAgent({
1338
1462
  return;
1339
1463
  }
1340
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
+ }
1341
1478
  const run = createRun({
1342
1479
  runId,
1343
1480
  request: {
@@ -1379,6 +1516,7 @@ export async function startAgent({
1379
1516
  typeof body.design_review_url === "string" ? body.design_review_url : "",
1380
1517
  must_not: Array.isArray(body.must_not) ? body.must_not : [],
1381
1518
  proposal_id: typeof body.proposal_id === "string" ? body.proposal_id : null,
1519
+ workflow_observation_binding: workflowObservationBinding,
1382
1520
  charter_context:
1383
1521
  body.charter_context &&
1384
1522
  typeof body.charter_context === "object" &&
@@ -1389,8 +1527,10 @@ export async function startAgent({
1389
1527
  },
1390
1528
  session,
1391
1529
  apiOrigin,
1530
+ localStateRoot: codingRunStateRoot,
1392
1531
  });
1393
1532
  try {
1533
+ await saveCodingRunSnapshot(codingRunStateRoot, run.snapshot);
1394
1534
  registry.create(run);
1395
1535
  } catch (error) {
1396
1536
  await cleanupRunAttachments(stagedAttachments);
@@ -1678,8 +1818,10 @@ export async function startAgent({
1678
1818
  await refreshAuthorizedRepositoryInventory();
1679
1819
  if (shuttingDown) return;
1680
1820
  await heartbeat();
1821
+ await restoreLocalCodingRuns();
1681
1822
  if (shuttingDown) return;
1682
1823
  await restoreRemoteRuns();
1824
+ startCodingRunMonitor();
1683
1825
  } catch (error) {
1684
1826
  reportStartupConnectionError(error, quiet);
1685
1827
  }
@@ -1703,6 +1845,8 @@ export async function startAgent({
1703
1845
  shuttingDown = true;
1704
1846
  shutdownPromise = (async () => {
1705
1847
  if (heartbeatTimer) clearInterval(heartbeatTimer);
1848
+ if (codingRunMonitorTimer) clearInterval(codingRunMonitorTimer);
1849
+ if (codingRunMonitorInFlight) await codingRunMonitorInFlight;
1706
1850
  updateManager?.stop?.();
1707
1851
  await sqmLocalReviewWorker?.close?.();
1708
1852
  await durableLocalTaskWorker?.close?.();
@@ -1824,7 +1968,8 @@ export async function startAgent({
1824
1968
  });
1825
1969
  const runId = String(remoteRun?.id || "");
1826
1970
  if (!runId) throw new Error("SIMY Web did not return an Agentic Loop run id.");
1827
- const run = createRun({ runId, request, session, apiOrigin });
1971
+ const run = createRun({ runId, request, session, apiOrigin, localStateRoot: codingRunStateRoot });
1972
+ await saveCodingRunSnapshot(codingRunStateRoot, run.snapshot);
1828
1973
  registry.create(run);
1829
1974
  void startLocalCodingRun(run, runOptions);
1830
1975
  return run;
@@ -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.29";
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
+ }