@awak-app/simy-cli 0.6.0 → 0.6.1
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 +6 -0
- package/package.json +2 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +2 -0
- package/src/delivery-hooks/activity.js +125 -0
- package/src/delivery-hooks/command.js +3 -0
- package/src/index.js +3 -0
- package/src/workflow-observation-sender.js +6 -2
package/README.md
CHANGED
|
@@ -188,6 +188,12 @@ checks the bundled CLI in ChatGPT and Codex apps in system and user Applications
|
|
|
188
188
|
Claude discovery likewise falls back to a compatible native or Desktop bundle
|
|
189
189
|
when an older PATH installation cannot meet its version requirement.
|
|
190
190
|
|
|
191
|
+
Use `simy delivery activity --watch` for a read-only local snapshot every minute,
|
|
192
|
+
or add `--json` for one JSON object per sample. It separates execution, waits,
|
|
193
|
+
stops, failures and repeated attempts without new verified outcomes, with task
|
|
194
|
+
identity, agent, reasons and outcome timestamps. Missing evidence is shown as
|
|
195
|
+
unknown; open time is never counted as productive time. See [work activity](docs/work-activity.md).
|
|
196
|
+
|
|
191
197
|
The plugin also provides an opt-in, hook-based Delivery Loop in the user's existing
|
|
192
198
|
Codex or Claude Code conversation. Install the plugin, review and enable SIMY hooks
|
|
193
199
|
with `/hooks`, then request `/simy:delivery-loop` for the task. Installing or updating
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awak-app/simy-cli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"start": "node ./src/index.js",
|
|
15
15
|
"test": "node --test",
|
|
16
16
|
"test:e2e:console": "node ./scripts/console-e2e.js",
|
|
17
|
+
"test:e2e:work-activity": "node ./scripts/work-activity-e2e.js",
|
|
17
18
|
"test:e2e:sqm-complete-loop": "node ./scripts/sqm-complete-loop-e2e.js",
|
|
18
19
|
"test:e2e:sqm-local-review": "node ./scripts/sqm-local-review-e2e.js",
|
|
19
20
|
"test:e2e:console:narrow": "CLI_E2E_COLUMNS=78 CLI_E2E_ROWS=40 node ./scripts/console-e2e.js",
|
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.6.
|
|
16
|
+
export const CLI_VERSION = "0.6.1";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
|
@@ -689,6 +689,8 @@ function newVerifiedCriterion(run, id, now) {
|
|
|
689
689
|
managed.verifiedCriterionHistory.push(id);
|
|
690
690
|
} else if (Object.hasOwn(managed.verified, id)) return;
|
|
691
691
|
managed.verifiedRevision += 1;
|
|
692
|
+
// Unlike the budget baseline, this is never initialized by enrollment/migration.
|
|
693
|
+
if (!Object.hasOwn(managed.verified, id)) managed.lastOutcomeAt = now;
|
|
692
694
|
managed.lastVerifiedProgressAt = Math.max(managed.lastVerifiedProgressAt ?? run.startedAt, now);
|
|
693
695
|
}
|
|
694
696
|
export async function verifyAutomaticCriteria(record, now) {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { listRecords, processIdentity } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
|
+
|
|
4
|
+
export const ACTIVITY_INTERVAL_MS = 60_000;
|
|
5
|
+
const clean = value => typeof value === 'string'
|
|
6
|
+
? value.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ').trim() : '';
|
|
7
|
+
const timestamp = (value, now) => Number.isFinite(value) && value >= 0 && value <= now ? value : null;
|
|
8
|
+
const iso = value => value === null ? null : new Date(value).toISOString();
|
|
9
|
+
|
|
10
|
+
function stateOf(record, now, liveRuntime) {
|
|
11
|
+
const run = record.run;
|
|
12
|
+
const state = run?.state;
|
|
13
|
+
if (['failed', 'stopped', 'completed', 'reported_done'].includes(state)) return state;
|
|
14
|
+
if (state === 'paused') return 'stopped';
|
|
15
|
+
if (['blocked', 'verification_pending', 'budget_exhausted'].includes(state)) return 'waiting';
|
|
16
|
+
const recent = value => timestamp(value, now) !== null && now - value < ACTIVITY_INTERVAL_MS;
|
|
17
|
+
const runtime = record.runtime;
|
|
18
|
+
// An open process alone is not execution evidence. A positive hook observation
|
|
19
|
+
// also needs a matching live identity and a recent timestamp.
|
|
20
|
+
if (runtime?.isRunning === true && liveRuntime && recent(runtime.observedAt)) return 'running';
|
|
21
|
+
const attempt = run?.managed?.executionAttempt;
|
|
22
|
+
if (state === 'auto_resuming' && attempt?.phase === 'dispatching' &&
|
|
23
|
+
recent(attempt.lastActivityAt)) return 'running';
|
|
24
|
+
if (state === 'recovery_required') return 'waiting';
|
|
25
|
+
if (runtime?.isRunning === false && recent(runtime.observedAt)) return 'waiting';
|
|
26
|
+
if (attempt?.phase === 'claimed' && recent(attempt.claimedAt)) return 'waiting';
|
|
27
|
+
return 'unknown';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function workActivitySnapshot(records, { now = Date.now(), inspectProcess = processIdentity } = {}) {
|
|
31
|
+
const items = [];
|
|
32
|
+
for (const record of records) {
|
|
33
|
+
const run = record.run;
|
|
34
|
+
if (!run && !record.monitor && !record.tracked) continue;
|
|
35
|
+
const managed = run?.managed;
|
|
36
|
+
const runtime = record.runtime;
|
|
37
|
+
let liveRuntime = false;
|
|
38
|
+
if (runtime?.isRunning === true && runtime.process?.identity &&
|
|
39
|
+
timestamp(runtime.observedAt, now) !== null && now - runtime.observedAt < ACTIVITY_INTERVAL_MS) {
|
|
40
|
+
try { liveRuntime = await inspectProcess(runtime.process.pid) === runtime.process.identity; }
|
|
41
|
+
catch { /* Unknown identity cannot prove execution. */ }
|
|
42
|
+
}
|
|
43
|
+
const executionState = stateOf(record, now, liveRuntime);
|
|
44
|
+
const lastOutcomeAt = timestamp(managed?.lastOutcomeAt, now);
|
|
45
|
+
const attempts = Number.isInteger(managed?.noProgress) && managed.noProgress >= 0 ? managed.noProgress : null;
|
|
46
|
+
const progressState = attempts >= 2 ? 'repeating_without_verified_progress'
|
|
47
|
+
: lastOutcomeAt !== null ? 'verified_outcome_recorded' : 'unknown';
|
|
48
|
+
const observed = [record.observedAt, runtime?.observedAt, managed?.executionAttempt?.lastActivityAt]
|
|
49
|
+
.map(value => timestamp(value, now)).filter(value => value !== null);
|
|
50
|
+
const observedAt = observed.length ? Math.max(...observed) : null;
|
|
51
|
+
items.push({
|
|
52
|
+
taskId: clean(managed?.taskId) || clean(record.associationTaskId) || null,
|
|
53
|
+
provider: clean(record.provider), session: clean(record.session),
|
|
54
|
+
agent: `${clean(record.provider)}:${clean(record.session)}`, humanAssignee: null,
|
|
55
|
+
objective: clean(run?.objective) || null, executionState, recordedState: clean(run?.state) || null,
|
|
56
|
+
progressState, noProgressAttempts: attempts,
|
|
57
|
+
lastOutcomeAt: iso(lastOutcomeAt),
|
|
58
|
+
minutesSinceOutcome: lastOutcomeAt === null ? null : Math.floor((now - lastOutcomeAt) / ACTIVITY_INTERVAL_MS),
|
|
59
|
+
observedAt: iso(observedAt), observationAgeMinutes: observedAt === null ? null : Math.floor((now - observedAt) / ACTIVITY_INTERVAL_MS),
|
|
60
|
+
reason: executionState === 'unknown' ? 'No fresh execution observation; activity is unknown.'
|
|
61
|
+
: clean(run?.reason) || (executionState === 'running' ? 'Recent execution observation.' : 'Provider turn is waiting.'),
|
|
62
|
+
recordedReason: clean(run?.reason) || null,
|
|
63
|
+
waitOwner: clean(managed?.dependencyWait?.owner) || null,
|
|
64
|
+
nextCheck: clean(managed?.dependencyWait?.nextCheck) || null,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
items.sort((a, b) => a.agent.localeCompare(b.agent));
|
|
68
|
+
return { schemaVersion: 1, sampledAt: new Date(now).toISOString(), intervalMs: ACTIVITY_INTERVAL_MS,
|
|
69
|
+
measurement: 'point_in_time_observations_not_productive_duration', items };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function wrap(value, width = 78) {
|
|
73
|
+
const chars = Array.from(value);
|
|
74
|
+
const lines = [];
|
|
75
|
+
while (chars.length) lines.push(chars.splice(0, width).join(''));
|
|
76
|
+
return lines.join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatWorkActivity(snapshot) {
|
|
80
|
+
const lines = [`Work activity | ${snapshot.sampledAt}`, 'Sampling: 60s. Observations are not productive time.'];
|
|
81
|
+
if (!snapshot.items.length) lines.push('No locally recorded tasks or monitored sessions.');
|
|
82
|
+
for (const item of snapshot.items) {
|
|
83
|
+
lines.push('', `Task: ${item.taskId || 'unassigned (see session)'}`,
|
|
84
|
+
`Agent: ${item.agent}`, 'Human assignee: unknown',
|
|
85
|
+
`State: ${item.executionState} | Progress: ${item.progressState}`,
|
|
86
|
+
`Last verified outcome: ${item.lastOutcomeAt || 'unknown'}`,
|
|
87
|
+
`Attempts without new verified outcome: ${item.noProgressAttempts ?? 'unknown'}`,
|
|
88
|
+
`Last observation: ${item.observedAt || 'unknown'}`,
|
|
89
|
+
`Reason: ${item.reason}`);
|
|
90
|
+
if (item.recordedReason && item.recordedReason !== item.reason) lines.push(`Recorded reason: ${item.recordedReason}`);
|
|
91
|
+
if (item.waitOwner) lines.push(`Waiting on: ${item.waitOwner}`);
|
|
92
|
+
if (item.nextCheck) lines.push(`Next check: ${item.nextCheck}`);
|
|
93
|
+
}
|
|
94
|
+
return lines.map(line => wrap(line)).join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function runActivityCommand(args, { root, output = console.log, now = Date.now,
|
|
98
|
+
readRecords = listRecords, inspectProcess = processIdentity, wait = delay, signal } = {}) {
|
|
99
|
+
const options = {};
|
|
100
|
+
for (let i = 0; i < args.length; i++) {
|
|
101
|
+
const key = args[i];
|
|
102
|
+
if (!['--watch', '--json', '--provider', '--session'].includes(key) || Object.hasOwn(options, key)) {
|
|
103
|
+
throw new Error('Use delivery activity [--watch] [--json] [--provider codex|claude] [--session ID].');
|
|
104
|
+
}
|
|
105
|
+
if (['--watch', '--json'].includes(key)) options[key] = true;
|
|
106
|
+
else {
|
|
107
|
+
const value = args[++i];
|
|
108
|
+
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${key}.`);
|
|
109
|
+
options[key] = value;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (options['--provider'] && !['codex', 'claude'].includes(options['--provider'])) throw new Error('Unsupported provider.');
|
|
113
|
+
do {
|
|
114
|
+
if (signal?.aborted) break;
|
|
115
|
+
const records = (await readRecords(root)).filter(record =>
|
|
116
|
+
(!options['--provider'] || record.provider === options['--provider']) &&
|
|
117
|
+
(!options['--session'] || record.session === options['--session']));
|
|
118
|
+
const snapshot = await workActivitySnapshot(records, { now: now(), inspectProcess });
|
|
119
|
+
output(options['--json'] ? JSON.stringify(snapshot) : formatWorkActivity(snapshot));
|
|
120
|
+
if (!options['--watch']) break;
|
|
121
|
+
try { await wait(ACTIVITY_INTERVAL_MS, undefined, { signal }); }
|
|
122
|
+
catch (error) { if (error.name === 'AbortError' && signal?.aborted) break; throw error; }
|
|
123
|
+
} while (!signal?.aborted);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { startDelivery, checkpointDelivery, controlDelivery, listRecords, rootDirectory, verifyDelivery } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
3
|
import { continueSession } from '../session-supervision/index.js';
|
|
4
4
|
import { cancelHandoffSession, handoffSession } from '../session-supervision/handoff.js';
|
|
5
|
+
import { runActivityCommand } from './activity.js';
|
|
5
6
|
|
|
6
7
|
async function readCheckpointInput(input) {
|
|
7
8
|
const chunks = [];
|
|
@@ -17,7 +18,9 @@ async function readCheckpointInput(input) {
|
|
|
17
18
|
|
|
18
19
|
export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), input = process.stdin, output = console.log } = {}) {
|
|
19
20
|
const [action, ...args] = argv;
|
|
21
|
+
if (action === 'activity') return runActivityCommand(args, { root, output });
|
|
20
22
|
if (!action || action === '--help') {
|
|
23
|
+
output('simy delivery activity [--watch] [--json] [--provider codex|claude] [--session ID] — local observations every 60 seconds; not productive time.');
|
|
21
24
|
output('Progress: --continuation-policy progress [--hard-max-minutes 1440] [--hard-max-continuations 50] [--provider-token-ceiling 15000000 (Codex only)]. Soft thresholds warn; approvals, missing usage and no verified progress still stop.');
|
|
22
25
|
output('simy delivery continuation_policy --provider PROVIDER --session ID --task-id ID --continuation-policy bounded|progress');
|
|
23
26
|
output('simy delivery extend --provider PROVIDER --session ID --task-id ID --hard-max-minutes N|--hard-max-continuations N|--provider-token-ceiling N');
|
package/src/index.js
CHANGED
|
@@ -57,6 +57,9 @@ if (!configuredWebOrigin) {
|
|
|
57
57
|
} else if (dispatchArgv[0] === "doctor") {
|
|
58
58
|
const { runDoctor } = await import("./doctor.js");
|
|
59
59
|
process.exitCode = await runDoctor({ webOrigin: configuredWebOrigin, cliVersion: packageInfo.version });
|
|
60
|
+
} else if (dispatchArgv[0] === "delivery" && dispatchArgv[1] === "activity") {
|
|
61
|
+
// A local observation must not install updates or require a network connection.
|
|
62
|
+
await runCommand();
|
|
60
63
|
} else if (dispatchArgv[0] === "update") {
|
|
61
64
|
const checkOnly = args.has("--check");
|
|
62
65
|
const result = await runCliUpdateGate({
|
|
@@ -95,6 +95,7 @@ export function createWorkflowObservationSender({
|
|
|
95
95
|
let terminal = null;
|
|
96
96
|
let startPromise = null;
|
|
97
97
|
let admittedAtMs = null;
|
|
98
|
+
let submittedTerminalAt = null;
|
|
98
99
|
|
|
99
100
|
async function post(path, body, attempts) {
|
|
100
101
|
let response = null;
|
|
@@ -191,10 +192,13 @@ export function createWorkflowObservationSender({
|
|
|
191
192
|
// A very short provider turn can finish before the start RPC commits.
|
|
192
193
|
// For a new run, record when the terminal event is submitted after
|
|
193
194
|
// admission; the event itself remains the real provider event.
|
|
194
|
-
|
|
195
|
+
// The intake's immutable replay identity includes observed_at. Freeze it
|
|
196
|
+
// before the first send so another flush after a lost response (including
|
|
197
|
+
// concurrent flushes) resubmits the exact same event.
|
|
198
|
+
submittedTerminalAt ??= new Date(Math.max(now().getTime(), admittedAtMs + 1)).toISOString();
|
|
195
199
|
return sendTypedTerminal({
|
|
196
200
|
idempotencyKey: terminalIdempotencyKey(normalizedIntent, normalizedBinding),
|
|
197
|
-
observedAt,
|
|
201
|
+
observedAt: submittedTerminalAt,
|
|
198
202
|
});
|
|
199
203
|
}
|
|
200
204
|
|