@awak-app/simy-cli 0.5.30 → 0.5.32
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 +7 -5
- package/package.json +3 -2
- package/src/agent.js +33 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +15 -6
- package/src/codex-plugins/simy/hooks/README.md +15 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +196 -0
- package/src/codex-plugins/simy/hooks/hooks.json +53 -0
- package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +28 -0
- package/src/delivery-hooks/command.js +31 -0
- package/src/delivery-hooks/desktop.js +40 -0
- package/src/desktop-host.js +39 -2
- package/src/index.js +7 -0
- package/src/local-task.js +53 -0
- package/src/provider-stream.js +3 -1
- package/src/runner.js +50 -4
- package/src/sqm/claude-plugin.js +6 -1
- package/src/sqm/workflow-onboarding.js +1 -1
- package/src/sqm/workflow-update.js +5 -2
- package/src/workflow-observation-sender.js +208 -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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
3
|
+
"version": "0.5.32",
|
|
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 =
|
|
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" &&
|
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.5.
|
|
16
|
+
export const CLI_VERSION = "0.5.32";
|
|
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": "
|
|
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": [
|
|
11
|
+
"keywords": [
|
|
12
|
+
"simy",
|
|
13
|
+
"sqm",
|
|
14
|
+
"pull-request"
|
|
15
|
+
],
|
|
12
16
|
"skills": "./skills/",
|
|
13
17
|
"interface": {
|
|
14
18
|
"displayName": "SIMY",
|
|
15
|
-
"shortDescription": "Quality
|
|
16
|
-
"longDescription": "
|
|
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": [
|
|
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
|
+
}
|
package/src/desktop-host.js
CHANGED
|
@@ -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";
|
|
@@ -13,7 +15,8 @@ import { createAgentShutdown } from "./shutdown.js";
|
|
|
13
15
|
import { resolveWebOrigin } from "./web-origin.js";
|
|
14
16
|
import { openAuthorizationUrl } from "./browser.js";
|
|
15
17
|
import { inspectBackendExecutable } from "./backend-executable.js";
|
|
16
|
-
import {
|
|
18
|
+
import { inspectSqmAgentSkill } from "./sqm/agent-skill.js";
|
|
19
|
+
import { startWorkflowUpdates, syncInstalledWorkflows } from "./sqm/workflow-update.js";
|
|
17
20
|
import {
|
|
18
21
|
acquireDesktopProcessLock,
|
|
19
22
|
assertNoLegacyCliDaemon,
|
|
@@ -38,11 +41,21 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
38
41
|
let agent, updater, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null;
|
|
39
42
|
let shuttingDown = null;
|
|
40
43
|
let commands;
|
|
44
|
+
let pluginSetup = { state: "unknown" };
|
|
45
|
+
const inspectPlugins = dependencies.inspectPlugins || inspectSqmAgentSkill;
|
|
46
|
+
const refreshPlugins = async () => {
|
|
47
|
+
try {
|
|
48
|
+
const status = await inspectPlugins();
|
|
49
|
+
pluginSetup = { state: status.installed ? "ready" : "missing", refreshPending: status.installed && pluginSetup.refreshPending === true };
|
|
50
|
+
} catch { pluginSetup = { state: "error" }; }
|
|
51
|
+
};
|
|
41
52
|
const packageInfo = dependencies.packageInfo || await readCliPackageInfo();
|
|
42
53
|
const publish = () => {
|
|
43
54
|
if (closed || relay || !agent) return Promise.resolve();
|
|
44
55
|
if (publishing) return publishing;
|
|
45
|
-
publishing = Promise.resolve(agent.getDesktopStatus()).then((status) => {
|
|
56
|
+
publishing = Promise.resolve(agent.getDesktopStatus()).then(async (status) => {
|
|
57
|
+
status.pluginSetup = { ...pluginSetup };
|
|
58
|
+
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: join(root, "delivery-hooks") }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
|
46
59
|
if (!closed && !relay) emit({ type: "status", status });
|
|
47
60
|
}).finally(() => { publishing = null; });
|
|
48
61
|
return publishing;
|
|
@@ -132,6 +145,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
132
145
|
lines.close(); child.kill("SIGTERM"); throw error;
|
|
133
146
|
}
|
|
134
147
|
} });
|
|
148
|
+
await refreshPlugins();
|
|
135
149
|
await publish();
|
|
136
150
|
timer = setInterval(() => { void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." })); }, 3_000);
|
|
137
151
|
} catch (error) { await close(); throw error; }
|
|
@@ -160,6 +174,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
160
174
|
if (agent.refreshProviderStatus) await agent.refreshProviderStatus();
|
|
161
175
|
else await agent.refreshCodexStatus?.();
|
|
162
176
|
await agent.refreshDesktopStatus();
|
|
177
|
+
await refreshPlugins();
|
|
163
178
|
}
|
|
164
179
|
else if (action === "login") {
|
|
165
180
|
const url = await agent.requestDesktopLogin();
|
|
@@ -178,6 +193,28 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
178
193
|
if (login) await login(root);
|
|
179
194
|
else await openProviderLogin(provider, root);
|
|
180
195
|
}
|
|
196
|
+
else if (action === "install_plugins") {
|
|
197
|
+
pluginSetup = { state: "installing" };
|
|
198
|
+
await publish();
|
|
199
|
+
try {
|
|
200
|
+
const result = await (dependencies.installPlugins || syncInstalledWorkflows)({ installMissing: true });
|
|
201
|
+
if (result.outcome === "busy") throw new Error("Plugin installation is busy.");
|
|
202
|
+
await refreshPlugins();
|
|
203
|
+
if (pluginSetup.state !== "ready") throw new Error("Plugin files could not be verified.");
|
|
204
|
+
pluginSetup.refreshPending = result.refreshPending === true;
|
|
205
|
+
} catch {
|
|
206
|
+
pluginSetup = { state: "error" };
|
|
207
|
+
await publish();
|
|
208
|
+
throw new Error("Plugin setup failed.");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (action === "codex_hooks" || action === "claude_hooks") {
|
|
212
|
+
const provider = action === "codex_hooks" ? "codex" : "claude";
|
|
213
|
+
await (dependencies.openHookSettings || openHookSetup)(provider);
|
|
214
|
+
} else if (action === "delivery_stop" || action === "delivery_resume") {
|
|
215
|
+
await controlDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
|
|
216
|
+
action: action === "delivery_stop" ? "stop" : "resume" });
|
|
217
|
+
}
|
|
181
218
|
else if (action === "quit") {
|
|
182
219
|
const status = await agent.getDesktopStatus();
|
|
183
220
|
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,
|
package/src/provider-stream.js
CHANGED
|
@@ -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
|
|
package/src/sqm/claude-plugin.js
CHANGED
|
@@ -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)}`);
|
|
@@ -31,7 +31,7 @@ export function startWorkflowUpdates({ enabled, sync = syncInstalledWorkflows, i
|
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export async function syncInstalledWorkflows({ home = homedir(),
|
|
34
|
+
export async function syncInstalledWorkflows({ home = homedir(), installMissing = false,
|
|
35
35
|
codexHome = process.env.CODEX_HOME || path.join(home, ".codex"),
|
|
36
36
|
claudeHome = process.env.CLAUDE_CONFIG_DIR || path.join(home, ".claude"),
|
|
37
37
|
inspect = inspectSqmAgentSkill, install = installSqmAgentSkill,
|
|
@@ -52,7 +52,7 @@ export async function syncInstalledWorkflows({ home = homedir(),
|
|
|
52
52
|
const migrated = await retireLegacySqmGate({ home });
|
|
53
53
|
let status = await inspect({ home });
|
|
54
54
|
const platforms = Object.entries(status.platforms)
|
|
55
|
-
.filter(([, entry]) => entry.state === "outdated").map(([platform]) => platform);
|
|
55
|
+
.filter(([, entry]) => entry.state === "outdated" || (installMissing && entry.state === "missing")).map(([platform]) => platform);
|
|
56
56
|
if (platforms.length) status = await install({ home, platforms });
|
|
57
57
|
const results = {};
|
|
58
58
|
const failures = [];
|
|
@@ -63,6 +63,9 @@ export async function syncInstalledWorkflows({ home = homedir(),
|
|
|
63
63
|
try { results[provider] = await refresh(); }
|
|
64
64
|
catch (error) { failures.push(error); }
|
|
65
65
|
}
|
|
66
|
+
// Explicit desktop setup prepares both sources even when a provider CLI is
|
|
67
|
+
// absent. Cache refresh remains retryable; no provider is enabled here.
|
|
68
|
+
if (failures.length && installMissing) return { outcome: "prepared", refreshPending: true };
|
|
66
69
|
if (failures.length) throw new AggregateError(failures, failures.map((error) => error.message).join("; "));
|
|
67
70
|
if (results.claude.outcome === "updated") return { ...results.claude, providers: results };
|
|
68
71
|
if (results.codex.outcome !== "not_enabled") return results.codex;
|
|
@@ -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
|
+
}
|