@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
@@ -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.
@@ -14,7 +14,9 @@ Treat the current Codex thread, its attachments, and the active repository as th
14
14
  3. Run `simy sqm min-check` from the repository root.
15
15
  4. Run `simy sqm check --proof proof.json --signed-evidence proof.json.signed.json`.
16
16
  5. Read `proof.json` and require `outcome.status` to be `passed` before reporting success or proposing a pull request.
17
- 6. Report the checked repository/revision, proof paths, outcome, blockers, and any evidence gap.
17
+ 6. Verify the proof's repository, base, HEAD and working-tree/diff identity against the candidate, and verify its bound signature with SQM tooling. Use separate artifact paths outside the diff; do not edit between min-check and check or reuse stale proof after a candidate change.
18
+ 7. Report outcome reasons, matched modules, evaluated rules and scenario statuses from `executions`, separating blocking and advisory results. `passed` with `no_applicable_rules`, zero scenario evaluations, or advisory-only checks is not feature or incident coverage. Scenario statuses are deterministic evaluations, not evidence that a browser, provider or deployed journey ran. Record relevant uncovered review findings and their concrete regression evidence or remaining gap.
19
+ 8. Report the checked repository/revision, proof paths, outcome, blockers, and any evidence gap. Keep delivery acceptance pending for unverified applicable paths even when SQM passes.
18
20
 
19
21
  If implementation is required before the check can pass, obtain or confirm the user's change authorization and make the smallest relevant repair. A request to run SQM alone does not authorize unrelated edits, a PR, merge, deployment, or external messages.
20
22
 
@@ -0,0 +1,69 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ import { redactExecutionValue } from "./orchestrator/execution-io.js";
7
+
8
+ const RUN_ID = /^[A-Za-z0-9_-]{1,160}$/;
9
+ const writes = new Map();
10
+
11
+ export function codingRunsRoot(sessionRoot) {
12
+ return path.resolve(sessionRoot || path.join(homedir(), ".simy"), "coding-runs");
13
+ }
14
+
15
+ export async function saveCodingRunSnapshot(root, snapshot) {
16
+ const id = String(snapshot?.id || "");
17
+ if (!RUN_ID.test(id)) throw new Error("Invalid coding run ID for durable storage.");
18
+ // Capture at enqueue time: later mutations must not rewrite a queued version.
19
+ snapshot = redactExecutionValue(snapshot);
20
+ const directory = path.resolve(root);
21
+ const key = path.join(directory, id);
22
+ const previous = writes.get(key) || Promise.resolve();
23
+ const current = previous.catch(() => undefined).then(async () => {
24
+ await mkdir(directory, { recursive: true, mode: 0o700 });
25
+ const body = JSON.stringify(snapshot);
26
+ const document = JSON.stringify({
27
+ version: 1,
28
+ sha256: createHash("sha256").update(body).digest("hex"),
29
+ snapshot,
30
+ });
31
+ const target = `${key}.json`;
32
+ const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
33
+ await writeFile(temporary, `${document}\n`, { mode: 0o600 });
34
+ await rename(temporary, target);
35
+ });
36
+ writes.set(key, current);
37
+ try { await current; } finally {
38
+ if (writes.get(key) === current) writes.delete(key);
39
+ }
40
+ }
41
+
42
+ export async function loadCodingRunSnapshots(root) {
43
+ let files;
44
+ try {
45
+ files = await readdir(root, { withFileTypes: true });
46
+ } catch (error) {
47
+ if (error?.code === "ENOENT") return { snapshots: [], errors: [] };
48
+ throw error;
49
+ }
50
+ const snapshots = [];
51
+ const errors = [];
52
+ for (const file of files) {
53
+ if (!file.isFile() || !file.name.endsWith(".json")) continue;
54
+ const id = file.name.slice(0, -5);
55
+ if (!RUN_ID.test(id)) continue;
56
+ try {
57
+ const document = JSON.parse(await readFile(path.join(root, file.name), "utf8"));
58
+ const body = JSON.stringify(document.snapshot);
59
+ const hash = createHash("sha256").update(body).digest("hex");
60
+ if (document.version !== 1 || document.sha256 !== hash || document.snapshot?.id !== id) {
61
+ throw new Error("Coding run record failed integrity check.");
62
+ }
63
+ snapshots.push(document.snapshot);
64
+ } catch (error) {
65
+ errors.push({ id, message: error instanceof Error ? error.message : String(error) });
66
+ }
67
+ }
68
+ return { snapshots, errors };
69
+ }
@@ -0,0 +1,31 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { startDelivery, checkpointDelivery, controlDelivery, listRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+
4
+ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), output = console.log } = {}) {
5
+ const [action, ...args] = argv;
6
+ if (!action || action === '--help') {
7
+ output('simy delivery start --provider codex|claude --session ID --objective TEXT --next-step TEXT [--max-continuations 6] [--max-failures 3] [--max-minutes 30]\nsimy delivery checkpoint --provider codex|claude --session ID --file checkpoint.json\nsimy delivery status\nsimy delivery stop|resume --provider codex|claude --session ID\nEnable and review SIMY hooks in your provider first. Starting a loop opts in only this session. Resume requires the user to resolve the blocker; original budgets are retained.');
8
+ return 0;
9
+ }
10
+ const options = {};
11
+ const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-continuations', 'max-failures', 'max-minutes', 'file']);
12
+ for (let i = 0; i < args.length; i += 2) {
13
+ const key = args[i]?.replace(/^--/, '');
14
+ if (!args[i]?.startsWith('--') || !allowed.has(key) || args[i + 1] === undefined || Object.hasOwn(options, key)) throw new Error('Invalid delivery option. Use simy delivery --help.');
15
+ options[key] = args[i + 1];
16
+ }
17
+ const base = { root, cwd, provider: options.provider, session: options.session };
18
+ let result;
19
+ if (action === 'status') result = (await listRecords(root)).filter(r => (!options.provider || r.provider === options.provider) && (!options.session || r.session === options.session));
20
+ else if (action === 'start') result = await startDelivery({ ...base, objective: options.objective, nextStep: options['next-step'],
21
+ ...Object.fromEntries([['max-continuations', 'maxContinuations'], ['max-failures', 'maxFailures'], ['max-minutes', 'maxMinutes']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
22
+ else if (action === 'checkpoint') {
23
+ if (!options.file) throw new Error('--file is required.');
24
+ const data = await readFile(options.file, 'utf8');
25
+ if (Buffer.byteLength(data) > 64 * 1024) throw new Error('Checkpoint exceeds 64 KiB.');
26
+ result = await checkpointDelivery({ ...base, checkpoint: JSON.parse(data) });
27
+ } else if (['stop', 'resume'].includes(action)) result = await controlDelivery({ ...base, action });
28
+ else throw new Error('Unknown delivery command.');
29
+ output(JSON.stringify(result, null, 2));
30
+ return 0;
31
+ }
@@ -0,0 +1,40 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+
5
+ export const HOOK_SETUP = Object.freeze({
6
+ codex: { settingsUrl: 'codex://settings', docsUrl: 'https://learn.chatgpt.com/docs/hooks', command: '/hooks' },
7
+ claude: { settingsUrl: null, docsUrl: 'https://code.claude.com/docs/en/hooks-guide', command: '/hooks' },
8
+ });
9
+
10
+ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.now() } = {}) {
11
+ const records = await reconcileRecords({ root, now });
12
+ return {
13
+ providers: Object.fromEntries(['codex', 'claude'].map(provider => {
14
+ const latest = records.filter(r => r.provider === provider).sort((a, b) => b.observedAt - a.observedAt)[0];
15
+ return [provider, { ...HOOK_SETUP[provider], state: latest ? 'observed' : 'not_observed', lastObservedAt: latest?.observedAt || null }];
16
+ })),
17
+ runs: records.filter(r => r.run).sort((a, b) => b.run.updatedAt - a.run.updatedAt).slice(0, 30).map(r => ({
18
+ provider: r.provider, session: r.session, objective: r.run.objective, state: r.run.state, reason: r.run.reason,
19
+ continuations: r.run.continuations, maxContinuations: r.run.maxContinuations, deadline: r.run.deadline,
20
+ needsAttention: ['blocked', 'failed', 'recovery_required', 'budget_exhausted'].includes(r.run.state), updatedAt: r.run.updatedAt,
21
+ })),
22
+ };
23
+ }
24
+
25
+ // Wait for the OS opener to exit. A successful spawn alone does not prove that
26
+ // a custom URL scheme has a registered handler.
27
+ export async function openHookSetup(provider, { platform = process.platform, run = promisify(execFile) } = {}) {
28
+ const setup = HOOK_SETUP[provider];
29
+ if (!setup) throw new Error("Unsupported provider.");
30
+ const targets = platform === "darwin" && setup.settingsUrl ? [setup.settingsUrl, setup.docsUrl] : [setup.docsUrl];
31
+ for (const url of targets) {
32
+ try {
33
+ const command = platform === "darwin" ? "open" : platform === "win32" ? "rundll32" : "xdg-open";
34
+ const args = platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
35
+ await run(command, args, { timeout: 5000, maxBuffer: 4096 });
36
+ return { opened: true, url };
37
+ } catch {}
38
+ }
39
+ throw new Error("Could not open hook setup. Use /hooks inside your provider.");
40
+ }
@@ -1,3 +1,5 @@
1
+ import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
2
+ import { controlDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
1
3
  import { createWindowsDesktopUpdater } from "./windows-desktop-updater.js";
2
4
  import { spawn, execFile } from "node:child_process";
3
5
  import { promisify } from "node:util";
@@ -42,7 +44,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
42
44
  const publish = () => {
43
45
  if (closed || relay || !agent) return Promise.resolve();
44
46
  if (publishing) return publishing;
45
- publishing = Promise.resolve(agent.getDesktopStatus()).then((status) => {
47
+ publishing = Promise.resolve(agent.getDesktopStatus()).then(async (status) => {
48
+ status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: join(root, "delivery-hooks") }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
46
49
  if (!closed && !relay) emit({ type: "status", status });
47
50
  }).finally(() => { publishing = null; });
48
51
  return publishing;
@@ -178,6 +181,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
178
181
  if (login) await login(root);
179
182
  else await openProviderLogin(provider, root);
180
183
  }
184
+ else if (action === "codex_hooks" || action === "claude_hooks") {
185
+ const provider = action === "codex_hooks" ? "codex" : "claude";
186
+ await (dependencies.openHookSettings || openHookSetup)(provider);
187
+ } else if (action === "delivery_stop" || action === "delivery_resume") {
188
+ await controlDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
189
+ action: action === "delivery_stop" ? "stop" : "resume" });
190
+ }
181
191
  else if (action === "quit") {
182
192
  const status = await agent.getDesktopStatus();
183
193
  if (status.tasks.activeCount > 0 && command.force !== true) throw new Error("Tasks are still running. Confirm before quitting.");
package/src/index.js CHANGED
@@ -70,6 +70,13 @@ if (!configuredWebOrigin) {
70
70
  }
71
71
 
72
72
  async function runCommand(installMode) {
73
+ if (dispatchArgv[0] === "delivery") {
74
+ try {
75
+ const { runDeliveryCommand } = await import("./delivery-hooks/command.js");
76
+ process.exitCode = await runDeliveryCommand(dispatchArgv.slice(1));
77
+ } catch (error) { console.error(error.message); process.exitCode = 2; }
78
+ return;
79
+ }
73
80
  if (dispatchArgv[0] === "sqm") {
74
81
  try {
75
82
  const { runSqmCommand } = await import("./sqm/command.js");
package/src/local-task.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { buildWorkflowReportingInstruction } from "./workflow-reporting.js";
1
2
  import { execFile } from "node:child_process";
2
3
  import { createHash, randomUUID } from "node:crypto";
3
4
  import { EventEmitter, once } from "node:events";
@@ -37,6 +38,10 @@ import {
37
38
  } from "./local-task-scenario-packs.js";
38
39
  import { createProviderStreamDecoder } from "./provider-stream.js";
39
40
  import { signalExecutorProcess } from "./runner.js";
41
+ import {
42
+ createWorkflowObservationSender,
43
+ normalizeWorkflowObservationBinding,
44
+ } from "./workflow-observation-sender.js";
40
45
 
41
46
  export const DEFAULT_LOCAL_TASK_TOKEN_BUDGET = 100_000;
42
47
  export const DEFAULT_LOCAL_TASK_TIMEOUT_MS = 10 * 60_000;
@@ -250,6 +255,7 @@ export function createLocalTask({
250
255
  timeoutMs = DEFAULT_LOCAL_TASK_TIMEOUT_MS,
251
256
  idempotencyKeyHash = null,
252
257
  requestFingerprint = null,
258
+ workflowObservationBinding = null,
253
259
  }) {
254
260
  validateTaskId(id);
255
261
  const now = new Date().toISOString();
@@ -258,6 +264,10 @@ export function createLocalTask({
258
264
  const normalizedScenarioPack = normalizeLocalTaskScenarioPack(scenarioPack);
259
265
  const scenarioExpectedOutputs =
260
266
  localTaskScenarioExpectedOutputs(normalizedScenarioPack);
267
+ const normalizedWorkflowObservationBinding = normalizeWorkflowObservationBinding(
268
+ workflowObservationBinding,
269
+ { provider: backend },
270
+ );
261
271
  return {
262
272
  id,
263
273
  kind: "local_task",
@@ -295,6 +305,17 @@ export function createLocalTask({
295
305
  provider_token_budget: tokenBudget,
296
306
  provider_tokens_used: 0,
297
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
+ : {}),
298
319
  timeout_ms: normalizedTimeout,
299
320
  created_at: now,
300
321
  updated_at: now,
@@ -326,6 +347,7 @@ export function createLocalTaskRequestFingerprint({
326
347
  timeoutMs,
327
348
  attachmentManifest,
328
349
  scenarioPack,
350
+ workflowObservationBinding = null,
329
351
  }) {
330
352
  const normalizedModel = normalizeLocalTaskModel(model);
331
353
  const manifest = Array.isArray(attachmentManifest)
@@ -338,6 +360,10 @@ export function createLocalTaskRequestFingerprint({
338
360
  sha256: String(item.sha256 || "").toLowerCase(),
339
361
  }))
340
362
  : [];
363
+ const normalizedWorkflowObservationBinding = normalizeWorkflowObservationBinding(
364
+ workflowObservationBinding,
365
+ { provider: backend },
366
+ );
341
367
  const payload = {
342
368
  instruction: String(instruction || "").trim(),
343
369
  backend: String(backend || ""),
@@ -351,6 +377,9 @@ export function createLocalTaskRequestFingerprint({
351
377
  timeout_ms: normalizeTimeoutMs(timeoutMs),
352
378
  attachment_manifest: manifest,
353
379
  scenario_pack: normalizeLocalTaskScenarioPack(scenarioPack),
380
+ ...(normalizedWorkflowObservationBinding
381
+ ? { workflow_observation_binding: normalizedWorkflowObservationBinding }
382
+ : {}),
354
383
  };
355
384
  return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
356
385
  }
@@ -362,6 +391,9 @@ export async function startLocalTask(
362
391
  environment = process.env,
363
392
  persist = persistLocalTask,
364
393
  beforeProviderInvocation = null,
394
+ session = null,
395
+ apiOrigin = null,
396
+ observationFetch = globalThis.fetch,
365
397
  } = {},
366
398
  ) {
367
399
  if (!task || task.status !== "queued") throw new Error("Local Task must be queued.");
@@ -441,6 +473,14 @@ export async function startLocalTask(
441
473
  // synchronous spawn boundary so a stopped task can never start Provider work.
442
474
  if (task.stop_requested || task.status !== "running") return task;
443
475
 
476
+ const observationSender = createWorkflowObservationSender({
477
+ binding: task.workflow_observation_binding,
478
+ backend: task.backend,
479
+ session,
480
+ apiOrigin,
481
+ fetchImpl: observationFetch,
482
+ });
483
+
444
484
  const runtimeTempBase = process.platform === "darwin" ? "/tmp" : tmpdir();
445
485
  const runtimeTempRoot = await mkdtemp(
446
486
  path.join(runtimeTempBase, `simy-local-task-${task.id}-`),
@@ -512,6 +552,7 @@ export async function startLocalTask(
512
552
  stream: "stdout",
513
553
  onLine,
514
554
  onUsage,
555
+ onEvent: (event) => observationSender?.observe(event),
515
556
  onTerminalError: (message) => {
516
557
  task.error ||= message;
517
558
  task.reason_code ||= "provider_execution_failed";
@@ -559,6 +600,9 @@ export async function startLocalTask(
559
600
  (total, item) => total + providerTokenCount(item),
560
601
  0,
561
602
  );
603
+ if (observationSender) {
604
+ task.workflow_observation = await observationSender.flush();
605
+ }
562
606
 
563
607
  let artifacts = [];
564
608
  let scenarioAcceptance = null;
@@ -708,6 +752,9 @@ export function localTaskSnapshot(task, { includeEvents = true, includeLocalPath
708
752
  provider_token_budget: task.provider_token_budget,
709
753
  provider_tokens_used: task.provider_tokens_used,
710
754
  token_usage: structuredClone(task.token_usage),
755
+ ...(task.workflow_observation
756
+ ? { workflow_observation: structuredClone(task.workflow_observation) }
757
+ : {}),
711
758
  timeout_ms: task.timeout_ms,
712
759
  created_at: task.created_at,
713
760
  updated_at: task.updated_at,
@@ -723,6 +770,7 @@ export function localTaskSnapshot(task, { includeEvents = true, includeLocalPath
723
770
  outputs_root_expected: task.outputs_root_expected,
724
771
  idempotency_key_hash: task.idempotency_key_hash || null,
725
772
  request_fingerprint: task.request_fingerprint || null,
773
+ workflow_observation_binding: task.workflow_observation_binding || null,
726
774
  }
727
775
  : {}),
728
776
  };
@@ -912,6 +960,10 @@ export async function localTaskArtifact(task, artifactId) {
912
960
  }
913
961
 
914
962
  function restoreLocalTask(snapshot) {
963
+ const workflowObservationBinding = normalizeWorkflowObservationBinding(
964
+ snapshot.workflow_observation_binding,
965
+ { provider: snapshot.backend },
966
+ );
915
967
  return {
916
968
  ...snapshot,
917
969
  outputs_root_expected:
@@ -925,6 +977,8 @@ function restoreLocalTask(snapshot) {
925
977
  scenario_acceptance: snapshot.scenario_acceptance || null,
926
978
  warnings: Array.isArray(snapshot.warnings) ? snapshot.warnings : [],
927
979
  token_usage: snapshot.token_usage || { records: [] },
980
+ workflow_observation_binding: workflowObservationBinding,
981
+ workflow_observation: snapshot.workflow_observation || null,
928
982
  child: null,
929
983
  operation_promise: null,
930
984
  stop_requested: false,
@@ -957,6 +1011,7 @@ function localTaskProviderInstruction(task) {
957
1011
  : null;
958
1012
  return [
959
1013
  "You are the local executor for a SIMY Local Task, not an Agentic Loop.",
1014
+ buildWorkflowReportingInstruction(),
960
1015
  "Complete this bounded task once. Do not create Git branches, commits, pull requests, audits, or automatic retries.",
961
1016
  `Permission mode: ${task.permission_mode}.`,
962
1017
  `Task workspace: ${task.workspace_path}`,
@@ -16,6 +16,7 @@ import {
16
16
  } from "./pr-preparation.js";
17
17
  import {
18
18
  completionEvidenceChecks,
19
+ hasUserOutcomeProof,
19
20
  normalizeVisualReview,
20
21
  } from "./completion-contract.js";
21
22
 
@@ -340,6 +341,8 @@ export function evaluatePrReadiness(charter, attempt) {
340
341
  readinessCheck("minimum_independent_binding",
341
342
  Boolean(independentAudit?.evidence_identity_sha256 && independentAudit.evidence_identity_sha256 === independentAuditEvidenceIdentity(attempt)),
342
343
  "Independent review must cover the current head, base, CI, skips, preparation rows, companion revisions, and minimum evidence."),
344
+ readinessCheck("user_outcome_proven", hasUserOutcomeProof(independentAudit?.coverage_review),
345
+ "Independent review must observe the original user outcome and cite its evidence."),
343
346
  readinessCheck("implementation_gate", attempt.audit?.passed === true, "Implementation gate must pass."),
344
347
  readinessCheck("pr_preparation_gate", preparation.passed, preparation.detail),
345
348
  readinessCheck(
@@ -8,12 +8,12 @@ export const ACCEPTANCE_EVIDENCE_PATH = fileURLToPath(
8
8
  export function buildChecklistContinuationInstruction({ auditor = false } = {}) {
9
9
  return section("checklist_continuation_contract", [
10
10
  `The table is a progress checkpoint, not the deliverable or a stopping condition. Read: ${ACCEPTANCE_EVIDENCE_PATH}`,
11
- "Show the complete 依頼チェックリスト with columns #, ご依頼, 現状, 判定 after chartering, each evaluated attempt, and in the final receipt. Preserve stable criterion IDs, all rows and both applicable evidence layers. ✅ requires current valid passing evidence for every applicable layer; ❌ means an observed failure; 🟡 means pending, missing, stale, blocked or unverifiable evidence. Show counts and highest proven state.",
12
- "After presenting any non-green row, classify its cause and immediately take the next safe authorized action: implement missing behavior, repair a failure, exercise an untested path, collect missing evidence, or rerun stale verification. Re-observe the same criterion against the current candidate, run the validator, update the table, and repeat until every applicable requested outcome and gate for the authorized endpoint is verified. A next-action list without performing feasible actions is not completion. Never require another continue request merely because a table was shown.",
11
+ "Maintain the complete 依頼チェックリスト with columns #, ご依頼, 現状, 判定 in the evidence record after chartering, each evaluated attempt, and in the final receipt. User-facing reporting follows workflow_reporting_contract; show the full table only when requested or when it is the most useful requested answer, never as mandatory commentary. Preserve stable criterion IDs, all rows and both applicable evidence layers. ✅ requires current valid passing evidence for every applicable layer; ❌ means an observed failure; 🟡 means pending, missing, stale, blocked or unverifiable evidence. Retain counts and highest proven state in that record.",
12
+ "After observing any non-green row, classify its cause and immediately take the next safe authorized action: implement missing behavior, repair a failure, exercise an untested path, collect missing evidence, or rerun stale verification. Re-observe the same criterion against the current candidate, run the validator, update the table, and repeat until every applicable requested outcome and gate for the authorized endpoint is verified. A next-action list without performing feasible actions is not completion. Never require another continue request merely because a table was shown.",
13
13
  "For CI or other in-progress dependencies, use the available bounded wait/recheck or approved monitoring mechanism and maintain progress communication. Unchanged external state is not itself a blocker. Do not start duplicate jobs or busy-poll. Recheck current revision/results, repair authorized failures, and keep waiting only within the existing time, attempt and cost limits. If waiting cannot continue, report the concrete limit and explicit resumption condition; do not silently abandon it or promise an unscheduled follow-up.",
14
14
  "If a row genuinely requires new authority, credentials, a user/product decision, an unavailable dependency or an exhausted safety budget, retain it as non-green and record affected IDs, evidence of the blocker, safe alternatives attempted, exact missing input, owner and resumption trigger. Continue independent in-scope work. Stop with blocked, best_available, failed or cancelled as appropriate, explicitly unfinished, not verified_complete. Never raise budgets, bypass a stop, or expand authority to force green.",
15
15
  "Do not end successfully while a safe authorized repair, verification or bounded wait remains. All-green applies to the frozen authorized endpoint: PR-only work does not authorize merge/deploy, but a PR URL, an improvement PR, an issue, a narrower test or a future plan does not satisfy an unmet outcome. Never drop/merge away rows, weaken criteria, relabel applicable checks as not_applicable, recolor unknowns, or copy new digests onto old evidence to force green. Do not change the requested endpoint to match the work already completed.",
16
- "Before the final response, independently reconcile the original request with all criterion IDs and current evidence, check remaining endpoint gates, and display the full table and explicit disposition. Distinguish a partial artifact from verified completion and state both yellow/red counts and what remains. A pr_ready_for_review or blocked controller state is not proof of overall completion. These instructions strengthen continuation and reporting; they do not add a background scheduler or remove controller limits.",
16
+ "Before the final response, independently reconcile the original request with all criterion IDs and current evidence, check remaining endpoint gates, and preserve the full table and explicit disposition in the evidence record. Distinguish a partial artifact from verified completion and disclose material remaining outcomes in the requested response format; keep yellow/red counts in the record. A pr_ready_for_review or blocked controller state is not proof of overall completion. These instructions strengthen continuation and reporting; they do not add a background scheduler or remove controller limits.",
17
17
  auditor
18
18
  ? "Remain read-only. Check that the executor performed feasible follow-up actions after non-green tables. Report premature stopping, omitted rows, unsupported green claims, or a blocker without evidence as findings, alongside exact criterion IDs and the necessary next action. Do not approve from a table's colors or the executor's completion wording."
19
19
  : "Execute the continuation within this attempt instead of merely returning a yellow table or delegating feasible remaining work to the requester. Return control honestly at a genuine boundary; never fabricate completion evidence.",
@@ -4,6 +4,13 @@ import { cleanString, stringArray } from "./shared.js";
4
4
  export const COMPLETION_CONTRACT_VERSION = 2;
5
5
  const criterionIdPattern = /^[A-Za-z][A-Za-z0-9]*(?:[-_.][A-Za-z0-9]+)*$/;
6
6
 
7
+ export function hasUserOutcomeProof(coverage) {
8
+ const proof = coverage?.user_outcome;
9
+ return proof?.passed === true && Boolean(cleanString(proof.verification_method)) &&
10
+ Boolean(cleanString(proof.observed_result)) && Array.isArray(proof.evidence_refs) &&
11
+ proof.evidence_refs.some((ref) => Boolean(cleanString(ref)));
12
+ }
13
+
7
14
  export function buildCompletionContract({
8
15
  inherited,
9
16
  requirement,
@@ -10,6 +10,7 @@ import {
10
10
  import { budgetState, resolveRunBudgets } from "./budget.js";
11
11
  import { classifyRisk } from "./risk.js";
12
12
  import { buildCompletionContract } from "./completion-contract.js";
13
+ import { freezeObjective } from "./outcome-monitor.js";
13
14
  import { defaultPrPreparationContract } from "./pr-preparation.js";
14
15
  import {
15
16
  DESKTOP_EXECUTION_TARGET,
@@ -56,11 +57,11 @@ export function createCodingLoopSnapshot({ runId, request, metadata = {} }) {
56
57
  cleanString(charterContext.acceptance_criteria_source) === "explicit"
57
58
  ? "explicit"
58
59
  : acceptanceCriteria.length > 0 &&
59
- cleanString(charterContext.acceptance_criteria_source) === "default"
60
- ? "default"
60
+ cleanString(charterContext.acceptance_criteria_source) === "request"
61
+ ? "request"
61
62
  : acceptanceCriteria.length > 0
62
63
  ? "explicit"
63
- : "default";
64
+ : "request";
64
65
  const inheritedRequiredChecks = stringArray(charterContext.required_checks);
65
66
  const inheritedNonGoals = stringArray(charterContext.non_goals);
66
67
  const inheritedArtifacts = stringArray(charterContext.artifacts_required);
@@ -71,7 +72,7 @@ export function createCodingLoopSnapshot({ runId, request, metadata = {} }) {
71
72
  acceptanceCriteria.length > 0
72
73
  ? acceptanceCriteria
73
74
  : [
74
- "The requested change is implemented without unrelated scope.",
75
+ cleanString(request.requirement),
75
76
  "Relevant tests pass and are recorded.",
76
77
  `A pull request targets ${baseBranch}.`,
77
78
  ];
@@ -216,6 +217,7 @@ export function createCodingLoopSnapshot({ runId, request, metadata = {} }) {
216
217
  updated_at: now,
217
218
  };
218
219
  snapshot.budget = budgetState(snapshot);
220
+ freezeObjective(snapshot.charter);
219
221
  return snapshot;
220
222
  }
221
223