@awak-app/simy-cli 0.6.23 → 0.6.26
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 +53 -0
- package/package.json +3 -3
- package/src/cli-contract.js +9 -1
- package/src/codex-plugins/simy/hooks/acceptance-policy.mjs +5 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +533 -74
- package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +63 -0
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +154 -0
- package/src/codex-plugins/simy/hooks/purpose-rule-policy.mjs +34 -0
- package/src/codex-plugins/simy/hooks/purpose-rule-review.mjs +65 -0
- package/src/codex-plugins/simy/hooks/purpose-search.mjs +123 -0
- package/src/codex-plugins/simy/hooks/purpose-workspace.mjs +97 -0
- package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +19 -5
- package/src/codex-plugins/simy/skills/sqm-loop/SKILL.md +19 -4
- package/src/delivery-hooks/command.js +48 -0
- package/src/delivery-hooks/consistency-monitor.js +126 -0
- package/src/delivery-hooks/desktop-runtime.js +70 -0
- package/src/delivery-hooks/desktop.js +13 -3
- package/src/delivery-hooks/legacy-hook-routing.js +61 -0
- package/src/delivery-hooks/local-runtime.js +123 -0
- package/src/delivery-hooks/local-supervisor.js +25 -0
- package/src/delivery-hooks/runtime-watchdog.js +101 -0
- package/src/delivery-hooks/supervisor.js +90 -22
- package/src/desktop-host.js +24 -1
- package/src/durable-local-task-worker.js +57 -12
- package/src/fp-narrative-image.js +180 -0
- package/src/index.js +1 -1
- package/src/local-task.js +1 -0
- package/src/orchestrator/audit.js +1 -1
- package/src/orchestrator/charter-repair.js +93 -0
- package/src/orchestrator/charter-validation.js +14 -6
- package/src/orchestrator/checklist-continuation.js +1 -1
- package/src/orchestrator/contract.js +1 -3
- package/src/orchestrator/delivery/WORKFLOW.md +33 -60
- package/src/orchestrator/delivery/controls/harness-controls.json +1326 -0
- package/src/orchestrator/delivery/references/delivery-levels.md +17 -44
- package/src/orchestrator/delivery/references/pr-closure.md +2 -3
- package/src/orchestrator/delivery/references/state-and-receipts.md +14 -5
- package/src/orchestrator/delivery/references/ux-specification.md +1 -2
- package/src/orchestrator/delivery-level.js +4 -5
- package/src/orchestrator/independent-audit.js +9 -5
- package/src/orchestrator/instruction.js +1 -0
- package/src/orchestrator/loop.js +55 -18
- package/src/orchestrator/risk.js +1 -1
- package/src/orchestrator/ux-specification.js +2 -2
- package/src/runner.js +4 -0
- package/src/session-supervision/analysis-manager.js +2 -2
- package/src/session-supervision/automatic-continuation.js +6 -5
- package/src/session-supervision/autonomous-upgrade.js +55 -0
- package/src/session-supervision/codex-app-server.js +200 -24
- package/src/session-supervision/consistency-auditor.js +113 -0
- package/src/session-supervision/index.js +38 -11
- package/src/session-supervision/purpose-candidate.js +44 -0
- package/src/session-supervision/purpose-instruction.js +47 -0
- package/src/session-supervision/purpose-observer.js +172 -0
- package/src/session-supervision/purpose-recovery.js +264 -0
- package/src/session-supervision/purpose-verification.js +154 -0
- package/src/sqm/agent-skill.js +6 -3
- package/src/sqm/artifact-paths.js +39 -0
- package/src/sqm/claude-plugin.js +7 -0
- package/src/sqm/command.js +95 -27
- package/src/sqm/history-store.js +3 -3
- package/src/sqm/repository.js +19 -5
- package/src/sqm/workflow-update.js +14 -0
- package/src/workflow-reporting.js +2 -0
|
@@ -5,10 +5,24 @@ description: Continue an explicitly requested SIMY Delivery Loop in the current
|
|
|
5
5
|
|
|
6
6
|
# SIMY Delivery Loop
|
|
7
7
|
|
|
8
|
+
## Select the matching local runtime
|
|
9
|
+
|
|
10
|
+
When `$HOME/.simy/bin/simy` exists, prefer that fixed local candidate for the
|
|
11
|
+
`simy delivery ...` commands below. Read its `--version` and `delivery runtime status`
|
|
12
|
+
first, and require version 0.6.24 or newer with `purposeVersion:2` and
|
|
13
|
+
`decisionPolicy:"llm-first-hc102"`. Use the selected absolute CLI path consistently
|
|
14
|
+
for start, checkpoint, status, controls and supervision; do not mix a new hook
|
|
15
|
+
bundle with an older global CLI or desktop supervisor. With a custom `SIMY_HOME`,
|
|
16
|
+
look in its `bin` directory instead. If no candidate exists, inspect the global
|
|
17
|
+
CLI and use a compatible current release. An unavailable matching runtime is
|
|
18
|
+
installation/research work, not a request for a human to certify compatibility.
|
|
19
|
+
Runtime selection, installation and cache refresh do not start, enable, trust,
|
|
20
|
+
resume or reset a task. A task still needs the user's explicit start request.
|
|
21
|
+
|
|
8
22
|
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
23
|
|
|
10
24
|
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. For a regular Delivery Loop, use `simy delivery start --provider <provider> --session <exact-session-id> --objective '<user outcome and acceptance criteria>' --next-step '<concrete next action>'`. For a Loop that must track acceptance, use the existing managed specification with `--managed-file <task.json>` as described below: freeze the original user outcome, the selected delivery level when one exists, and the necessary applicable acceptance criteria before work. A regular start without this specification is standard checkpoint tracking and can only end as `reported_done`; do not present it as a fully verified Agentic Loop. Do not fabricate a level selection or add engineering/SQM checks the user did not select or the task does not require. The loop stops after 3 failures/missing checkpoints and pauses for review when no progress is verified; it does not limit elapsed time or turns. Set a different failure limit only if the user requests it. Use the current working directory. Never invent a session ID, start another loop to reset limits, or resume
|
|
25
|
+
2. For a regular Delivery Loop, use `simy delivery start --provider <provider> --session <exact-session-id> --objective '<user outcome and acceptance criteria>' --next-step '<concrete next action>'`. For a Loop that must track acceptance, use the existing managed specification with `--managed-file <task.json>` as described below: freeze the original user outcome, the selected delivery level when one exists, and the necessary applicable acceptance criteria before work. A regular start without this specification is standard checkpoint tracking and can only end as `reported_done`; do not present it as a fully verified Agentic Loop. Do not fabricate a level selection or add engineering/SQM checks the user did not select or the task does not require. The loop stops after 3 failures/missing checkpoints and pauses for review when no progress is verified; it does not limit elapsed time or turns. Set a different failure limit only if the user requests it. Use the current working directory. Never invent a session ID, start another loop to reset limits, or resume user-stopped work without a user request. Authorized automatic recovery from ordinary blockers is distinct from resuming user-stopped work.
|
|
12
26
|
3. Work in the current conversation with its normal permissions. Before ending each turn, submit a JSON checkpoint through standard input with `simy delivery checkpoint --provider <provider> --session <id> --file -` (for example, a shell heredoc). This updates SIMY state; it does not require writing a checkpoint file to the user's workspace. A file path remains supported when authorized.
|
|
13
27
|
|
|
14
28
|
Checkpoint shapes:
|
|
@@ -25,7 +39,7 @@ For missing information use `blocker: "missing_information"`. For a failed attem
|
|
|
25
39
|
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. For standard tracking this ends as `reported_done`, never verified `completed`. In managed tracking SIMY independently checks frozen file predicates or waits for explicit user verification of manual 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
40
|
|
|
27
41
|
4. The Stop hook evaluates checkpoints and supplies the next instruction within the original scope while progress is verified. Missing checkpoints count toward the repeated-failure circuit. 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.
|
|
42
|
+
5. Once a blocker is resolved with current evidence, continue already-authorized work without an additional continuation request. AI handles reversible decisions by investigating, selecting and verifying a suitable option; use LLM judgment first and HC-01–HC-102 as critique lenses. A matching irreversible-operation approval or necessary answer is sufficient to continue that operation. User-directed pause/stop requires an explicit resume; actual provider permissions, rejected actions and budgets remain authoritative. For manual recovery, `simy delivery resume --provider <provider> --session <id>` retains usage, failure and progress history; local time, turn and token counters do not stop work. `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.
|
|
29
43
|
|
|
30
44
|
If the user selected **Continue this conversation** in SIMY desktop, the exact session already has a managed task. Do not run `delivery start` or ask the user to restate its goal. Follow the Stop hook's distinct `session_understanding` contract first: inspect only the original conversation context, report the original user goal and grounded progress, and submit the JSON checkpoint via `--file -`. Do not execute the next assessed work action during understanding. If context is materially unclear, report one focused question and wait for a matching `session_answer`; generic Resume is insufficient. A model-reported finished goal without an independent check is shown as `reported_done`, not verified `completed`.
|
|
31
45
|
|
|
@@ -39,10 +53,10 @@ The managed file must include `initialAction` and nonempty `acceptanceCriteria`.
|
|
|
39
53
|
{"actionId":"ACTION-123","actionDueAt":"2026-10-05T09:00:00+09:00","initialAction":{"kind":"local_edit","scope":"repository","summary":"Implement the requested parser"},"approvalPolicy":"wait","approvalScopes":[],"acceptanceCriteria":[{"id":"output","description":"Expected output is exactly READY","check":{"type":"file_equals","path":"output.txt","text":"READY"}},{"id":"review","description":"User accepts behavior","check":{"type":"manual"}}]}
|
|
40
54
|
```
|
|
41
55
|
|
|
42
|
-
File checks prove only the stated file predicate: `file_equals` compares complete UTF-8 bytes including whitespace, `file_contains` proves a substring, and `file_exists` proves existence. Tests, deployment, and subjective acceptance need explicit user verification. The user can verify each manual criterion from SIMY desktop or `simy delivery verify --provider ... --session ... --task-id ... --criterion-id ...`. `approvalPolicy` is `wait`, `automatic`, or `proceed_and_report`; the last two apply only to enrolled scopes for `read`, `local_edit`, and `local_test`.
|
|
56
|
+
File checks prove only the stated file predicate: `file_equals` compares complete UTF-8 bytes including whitespace, `file_contains` proves a substring, and `file_exists` proves existence. Tests, deployment, and subjective acceptance need explicit user verification. The user can verify each manual criterion from SIMY desktop or `simy delivery verify --provider ... --session ... --task-id ... --criterion-id ...`. `approvalPolicy` is `wait`, `automatic`, or `proceed_and_report`; the last two apply only to enrolled scopes for `read`, `local_edit`, and `local_test`. Irreversible effects require an exact proposal decision. In purpose-driven Codex autonomous mode, reversible external work in enrolled scope can proceed with actual provider permissions, concrete restoration and independent result readback. Unknown effects require research; changed objectives and absent authority are not automatically granted. SIMY approval does not change provider permissions. Rejected proposals remain blocked until the user revises or stops the task.
|
|
43
57
|
|
|
44
58
|
Cold recovery is **off by default**. Only when the user explicitly authorizes exact-session Codex CLI recovery, add `"autoRecovery":true,"resumeChannel":"codex_exec","resumeModel":"<exact model>","autoRecoverySandbox":"read-only"` (or an explicitly authorized `workspace-write`). An exact Codex UUID and observed process identity are also required; otherwise recovery stays manual. The recovered process returns a structured final checkpoint that SIMY validates and persists. It must not edit SIMY state directly. Claude and ordinary SessionEnd remain manual paths. Token usage is not measured by these hooks.
|
|
45
59
|
|
|
46
|
-
For an explicitly requested autonomous managed task, also enroll `"autonomyPolicy":"strict"|"balanced"|"autonomous"` and an `initialPlan` before work begins. Example initialPlan: `{"diagnosis":"Inspect input before repair","alternatives":[{"id":"inspect","action":{"kind":"read","scope":"repository","summary":"Inspect input.json"},"assessment":{"risk":"low","reversibility":"reversible","externalEffect":"none","blastRadius":"single_file","uncertainty":"low","inObjective":true,"userChoice":false},"expectedVerification":"Observe ready field","rollback":"No changes"}],"selectedOptionId":"inspect","recommendation":{"optionId":"inspect","reason":"Need evidence before editing"}}`. `initialAction` must exactly match the selected option and its scope must be enrolled in `approvalScopes`. For each subsequent action, report `actionResult:{decisionId,status:"observed"|"failed"|"not_run",summary,evidence}` for the current decision and a `plan` with diagnosis, one to five assessed alternatives, selectedOptionId, and recommendation for the **next** action. Never execute the next alternative in the current turn. Use only action kinds `read`, `local_edit`, `local_test`, `external`, `destructive`, `scope_change`, `unknown`; use `local_edit` for writing local files.
|
|
60
|
+
For an explicitly requested autonomous managed task, also enroll `"autonomyPolicy":"strict"|"balanced"|"autonomous"` and an `initialPlan` before work begins. Example initialPlan: `{"diagnosis":"Inspect input before repair","alternatives":[{"id":"inspect","action":{"kind":"read","scope":"repository","summary":"Inspect input.json"},"assessment":{"risk":"low","reversibility":"reversible","externalEffect":"none","blastRadius":"single_file","uncertainty":"low","inObjective":true,"userChoice":false},"expectedVerification":"Observe ready field","rollback":"No changes"}],"selectedOptionId":"inspect","recommendation":{"optionId":"inspect","reason":"Need evidence before editing"}}`. `initialAction` must exactly match the selected option and its scope must be enrolled in `approvalScopes`. For each subsequent action, report `actionResult:{decisionId,status:"observed"|"failed"|"not_run",summary,evidence}` for the current decision and a `plan` with diagnosis, one to five assessed alternatives, selectedOptionId, and recommendation for the **next** action. Never execute the next alternative in the current turn. Use only action kinds `read`, `local_edit`, `local_test`, `external`, `destructive`, `scope_change`, `unknown`; use `local_edit` for writing local files. Unknown effect boundaries call for read-only research. In autonomous purpose mode, reversible preferences, high risk or broad impact alone do not require human selection; use a smaller step, rollback and verification. Irreversible effects, absent authority and explicit stops/rejections remain boundaries. A recommendation is advisory, not an authorization. Worker reports are not independent acceptance verification. If an automatic file criterion is missing after claimed completion, SIMY schedules bounded read-only diagnosis; manual criteria still require the user. Task policy can be changed with `simy delivery set_policy --provider ... --session ... --task-id ... --autonomy-policy strict|balanced|autonomous`, which preserves usage history, approvals and scopes. Provider permissions remain authoritative.
|
|
47
61
|
|
|
48
|
-
In assessed mode, a blocked authorization checkpoint must also include an assessed `plan` whose selected action exactly matches `approval:{kind,scope,summary}` and a concrete `nextStep`. A missing-information blocker must include a specific `question` for the user. Do not turn a provider approval prompt into an automatic scheduling grant, even for a normally low-risk action.
|
|
62
|
+
In assessed mode, a blocked authorization checkpoint must also include an assessed `plan` whose selected action exactly matches `approval:{kind,scope,summary}` and a concrete `nextStep`. A missing-information blocker must include a specific `question` for the user. Do not turn a provider approval prompt into an automatic scheduling grant, even for a normally low-risk action. Provider-denied or irreversible operations resume only after their exact decision; already-authorized reversible work continues without another resume request; low-risk enrolled actions already allowed by the policy do not need an additional SIMY approval.
|
|
@@ -7,17 +7,32 @@ description: Run the SIMY Software Quality Monitor against the repository and ch
|
|
|
7
7
|
|
|
8
8
|
Treat the current Codex thread, its attachments, and the active repository as the request context. Convert that context into explicit quality expectations, then verify the actual repository state; do not merely summarize the conversation.
|
|
9
9
|
|
|
10
|
+
## Use the matching local runtime and scope
|
|
11
|
+
|
|
12
|
+
When `$HOME/.simy/bin/simy` exists, prefer its absolute path consistently for
|
|
13
|
+
SQM and Delivery Loop. Check `--version` and `delivery runtime status`: the local
|
|
14
|
+
candidate requires 0.6.24 or newer, `purposeVersion:2` and
|
|
15
|
+
`decisionPolicy:"llm-first-hc102"`. With a custom `SIMY_HOME`, use its `bin`.
|
|
16
|
+
Inspect `sqm scope --json` when diagnosing a connection. It reads the current
|
|
17
|
+
authorization and verifies the repository's signed knowledge bundle without
|
|
18
|
+
switching organization or signing in. HTTP 409 `repository_scope_unresolved`
|
|
19
|
+
does not by itself prove missing registration: compare the selected organization
|
|
20
|
+
and `SIMY_SQM_ORGANIZATION_ID` with the intended repository's SQM System first.
|
|
21
|
+
Investigate and repair authorized reversible setup; preserve real account,
|
|
22
|
+
organization, device and provider authority. Do not guess credentials or invent a
|
|
23
|
+
successful scope. A connected scope is not quality acceptance.
|
|
24
|
+
|
|
10
25
|
## Run the quality loop
|
|
11
26
|
|
|
12
27
|
1. Identify the repository root and the user-visible behavior or incident discussed in the current thread.
|
|
13
28
|
2. Preserve unrelated user changes and follow repository instructions.
|
|
14
|
-
3. Run `simy sqm
|
|
15
|
-
4.
|
|
16
|
-
5.
|
|
29
|
+
3. Run `simy sqm run --json` from the repository root. It runs min-check then check with a private min result and new proof/signature paths outside the candidate. Alternatively, run `simy sqm min-check` then `simy sqm check`; their defaults are separated by worktree and authorization scope. Keep explicit artifact paths outside the repository.
|
|
30
|
+
4. Read the returned artifacts and actual proof; require `outcome.status` to be `passed` and `candidate_verification.current`, `signature_valid`, and `artifacts_current` to be true before reporting success or proposing a pull request. A `stale` result or nonzero exit requires diagnosis and recheck; retain the historical evidence.
|
|
31
|
+
5. A pending human or post-deploy proof is not a passed command. Obtain missing evidence through authorized AI/tool verification where possible; preserve explicit human requirements.
|
|
17
32
|
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
33
|
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
34
|
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.
|
|
20
35
|
|
|
21
|
-
If implementation is required before the check can pass,
|
|
36
|
+
If implementation is required before the check can pass, use existing change authorization to make the smallest relevant repair and rerun without asking for another continuation. Apply LLM understanding, reasoning, alternatives and self-critique first, then relevant HC-01–HC-102 lenses; the full inventory is not a per-turn gate. A request to run SQM alone does not authorize unrelated edits, a PR, merge, deployment, or external messages. User-directed pause/stop, explicit rejection, irreversible operations and actual provider permissions remain authoritative.
|
|
22
37
|
|
|
23
38
|
Do not substitute an informal LLM review for SQM. If the CLI is unavailable, authentication is missing, or the proof is not passed, stop at the highest verified state and report the exact blocker.
|
|
@@ -4,6 +4,14 @@ import { startDelivery, checkpointDelivery, controlDelivery, listRecords, rootDi
|
|
|
4
4
|
import { continueSession } from '../session-supervision/index.js';
|
|
5
5
|
import { cancelHandoffSession, handoffSession } from '../session-supervision/handoff.js';
|
|
6
6
|
import { runActivityCommand } from './activity.js';
|
|
7
|
+
import { installLocalRuntime, localRuntimeStatus } from './local-runtime.js';
|
|
8
|
+
import { superviseLocalDelivery } from './local-supervisor.js';
|
|
9
|
+
import { dirname } from 'node:path';
|
|
10
|
+
import { syncInstalledWorkflows } from '../sqm/workflow-update.js';
|
|
11
|
+
import { routeLegacyHooks } from './legacy-hook-routing.js';
|
|
12
|
+
import { activateLocalDesktop, watchLocalRuntime, installRuntimeWatchdog } from './runtime-watchdog.js';
|
|
13
|
+
import { desktopRuntimeStatus, installLocalDesktop } from './desktop-runtime.js';
|
|
14
|
+
import { consistencyMonitorStatus, consistencyFindings } from './consistency-monitor.js';
|
|
7
15
|
|
|
8
16
|
async function readCheckpointInput(input) {
|
|
9
17
|
const chunks = [];
|
|
@@ -19,8 +27,43 @@ async function readCheckpointInput(input) {
|
|
|
19
27
|
|
|
20
28
|
export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), input = process.stdin, output = console.log } = {}) {
|
|
21
29
|
const [action, ...args] = argv;
|
|
30
|
+
if (action === 'runtime') {
|
|
31
|
+
if (args.length !== 1 || !['install', 'status', 'workflows', 'desktop-install', 'desktop-activate', 'watch', 'watchdog-install', 'legacy-hooks', 'doctor'].includes(args[0])) throw new Error('Use simy delivery runtime install|status|workflows|desktop-install|doctor.');
|
|
32
|
+
if (args[0] === 'legacy-hooks') { output(JSON.stringify(await routeLegacyHooks({ root: dirname(root) }), null, 2)); return 0; }
|
|
33
|
+
if (args[0] === 'watchdog-install') { output(JSON.stringify(await installRuntimeWatchdog({ root: dirname(root) }), null, 2)); return 0; }
|
|
34
|
+
if (args[0] === 'desktop-activate') { output(JSON.stringify(await activateLocalDesktop({ root: dirname(root) }), null, 2)); return 0; }
|
|
35
|
+
if (args[0] === 'watch') {
|
|
36
|
+
const controller = new AbortController(), abort = () => controller.abort();
|
|
37
|
+
process.once('SIGINT', abort); process.once('SIGTERM', abort);
|
|
38
|
+
try { await watchLocalRuntime({ root: dirname(root), signal: controller.signal }); }
|
|
39
|
+
finally { process.removeListener('SIGINT', abort); process.removeListener('SIGTERM', abort); }
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
if (args[0] === 'desktop-install') { output(JSON.stringify(await installLocalDesktop({ root: dirname(root) }), null, 2)); return 0; }
|
|
43
|
+
if (args[0] === 'doctor') {
|
|
44
|
+
const [runtime, host, monitor, records] = await Promise.all([localRuntimeStatus({ root: dirname(root), allowMissingLock: true }),
|
|
45
|
+
desktopRuntimeStatus({ root: dirname(root) }), consistencyMonitorStatus(root), listRecords(root)]);
|
|
46
|
+
output(JSON.stringify({ runtime, host, monitor, tasks: records.flatMap(record => consistencyFindings(record)) }, null, 2)); return 0;
|
|
47
|
+
}
|
|
48
|
+
const result = args[0] === 'workflows' ? await syncInstalledWorkflows({ installMissing: false })
|
|
49
|
+
: await (args[0] === 'install' ? installLocalRuntime : localRuntimeStatus)({ root: dirname(root), ...(args[0] === 'status' ? { allowMissingLock: true } : {}) });
|
|
50
|
+
output(JSON.stringify(result, null, 2));
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
if (action === 'supervise') {
|
|
54
|
+
if (args.length > 1 || args.length === 1 && args[0] !== '--once') throw new Error('Use simy delivery supervise [--once].');
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const stop = () => controller.abort();
|
|
57
|
+
process.once('SIGINT', stop); process.once('SIGTERM', stop);
|
|
58
|
+
try { await superviseLocalDelivery({ root, once: args[0] === '--once', signal: controller.signal }); }
|
|
59
|
+
finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); }
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
22
62
|
if (action === 'activity') return runActivityCommand(args, { root, output });
|
|
23
63
|
if (!action || action === '--help') {
|
|
64
|
+
output('simy delivery runtime install|status|workflows — install an immutable local candidate, inspect its version/digest, or refresh existing plugin sources/cache while preserving enabled state and hook trust. Never enables a task.');
|
|
65
|
+
output('simy delivery runtime doctor|desktop-install|desktop-activate|watch — audit live host/state/monitor alignment, or prepare a separate local desktop using the verified fixed runtime. Does not start or resume tasks.');
|
|
66
|
+
output('simy delivery supervise [--once] — service already opted-in tasks with this runtime; refuses an active desktop host.');
|
|
24
67
|
output('simy delivery evidence --provider codex|claude --session ID — read-only execution and acceptance receipts.');
|
|
25
68
|
output('simy delivery activity [--watch] [--json] [--provider codex|claude] [--session ID] — local observations every 60 seconds; not productive time.');
|
|
26
69
|
output('A task stops after repeated failures, and pauses for review when no progress is confirmed. Resume starts a new progress window.');
|
|
@@ -68,6 +111,11 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
|
|
|
68
111
|
const data = options.file === '-' ? await readCheckpointInput(input) : await readFile(options.file, 'utf8');
|
|
69
112
|
if (Buffer.byteLength(data) > 64 * 1024) throw new Error('Checkpoint exceeds 64 KiB.');
|
|
70
113
|
result = await checkpointDelivery({ ...base, checkpoint: JSON.parse(data) });
|
|
114
|
+
} else if (action === 'revise-purpose') {
|
|
115
|
+
if (!options.file) throw new Error('An explicit user revision file is required.');
|
|
116
|
+
const data=options.file==='-' ? await readCheckpointInput(input) : await readFile(options.file,'utf8');
|
|
117
|
+
if (Buffer.byteLength(data)>64*1024) throw new Error('Purpose revision exceeds 64 KiB.');
|
|
118
|
+
result=await controlDelivery({...base,taskId:options['task-id'],action:'revise_purpose',purposeUpdate:JSON.parse(data)});
|
|
71
119
|
} else if (['stop', 'pause', 'resume', 'replan', 'approve', 'reject', 'set_policy', 'dependency_ready', 'continuation_policy'].includes(action)) result = await controlDelivery({ ...base, taskId: options['task-id'], approvalId: options['approval-id'], autonomyPolicy: options['autonomy-policy'], continuationPolicy: options['continuation-policy'],
|
|
72
120
|
waitId: options['wait-id'], answer: options.answer, action });
|
|
73
121
|
else if (action === 'verify') result = await verifyDelivery({ ...base, taskId: options['task-id'], criterionId: options['criterion-id'] });
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { pauseOrigin } from '../session-supervision/autonomous-upgrade.js';
|
|
5
|
+
import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/acceptance-policy.mjs';
|
|
6
|
+
import { listRecords, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
7
|
+
|
|
8
|
+
const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
9
|
+
async function read(path) { try { return JSON.parse(await readFile(path, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } }
|
|
10
|
+
async function save(path, value) {
|
|
11
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
12
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
13
|
+
try { await writeFile(temp, JSON.stringify(value), { mode: 0o600 }); await rename(temp, path); }
|
|
14
|
+
finally { await rm(temp, { force: true }); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function consistencyFindings(record) {
|
|
18
|
+
const run = record.run, m = run?.managed;
|
|
19
|
+
if (!m) return [];
|
|
20
|
+
const findings = [];
|
|
21
|
+
const add = (code, next, suppressed = false) => findings.push({ id: hash([record.provider, record.session, m.taskId, code]),
|
|
22
|
+
code, taskId: m.taskId, provider: record.provider, session: record.session, runRevision: run.revision,
|
|
23
|
+
contractVersion: m.controllerContractVersion || null, objectiveDigest: hash([run.objective, m.acceptanceCriteria]),
|
|
24
|
+
next, suppressed });
|
|
25
|
+
if (!record.autoContinue || record.providerArchived || run.state === 'stopped' || run.state === 'paused' && pauseOrigin(run) === 'user') {
|
|
26
|
+
add('user_boundary', 'Preserve OFF, archive, explicit pause/stop and rejection.', true); return findings;
|
|
27
|
+
}
|
|
28
|
+
if (m.pendingApproval?.denied || m.rejectedActionOverflow) add('rejection_boundary', 'Preserve the rejected action; investigate a different authorized alternative.', true);
|
|
29
|
+
if (!['completed', 'reported_done'].includes(run.state) && (m.purpose?.version !== 2 || m.purpose?.decisionPolicy !== 'llm-first-hc102' || m.controllerContractVersion !== 2)) add('contract_drift', 'Inspect the original provider identity and idempotently migrate existing consent without resetting history.');
|
|
30
|
+
if (run.state === 'paused') add(pauseOrigin(run) === 'system' ? 'system_pause' : 'unclassified_pause', 'Read the original stop evidence before recovery; never infer a user resume.');
|
|
31
|
+
if (run.state === 'verification_pending' && effectiveAcceptanceCriteria(m).some(c => !m.verified?.[c.id] && c.check.type === 'semantic')) add('independent_verification_pending', 'Run an independent read-only outcome inspection using original instructions and current primary evidence.');
|
|
32
|
+
if (record.provider === 'codex' && !['completed', 'reported_done', 'budget_exhausted'].includes(run.state)) add('outcome_alignment', 'Periodically compare original user instructions and corrections with actual primary evidence, including omitted scope and runtime adoption.');
|
|
33
|
+
for (const finding of m.purpose?.evidence?.findings || []) if (finding.status === 'open' && finding.owner !== 'human') add(`outcome:${finding.id}`, 'Independently inspect the original requirement and primary evidence; repair or refute this stable finding through its existing ledger.');
|
|
34
|
+
if (record.provider !== 'codex' && !['completed', 'reported_done'].includes(run.state)) add('provider_recovery_capability', 'Use the original live provider hook; cold recovery/independent verification needs a verified provider-specific adapter.', true);
|
|
35
|
+
return findings;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Deterministic all-state watchdog, independent of execution eligibility. It
|
|
39
|
+
// hands inspection to the existing independent verifier; it never edits a
|
|
40
|
+
// workspace, authorizes effects, fabricates a pass, or resets a task.
|
|
41
|
+
export async function runConsistencyMonitor({ root, now = Date.now(), records, readRecords = () => listRecords(root),
|
|
42
|
+
runtime = null, onAudit = async () => {}, intervalMs = 60_000 } = {}) {
|
|
43
|
+
const file = join(root, 'consistency', 'monitor.json');
|
|
44
|
+
const previous = await read(file) || { schema: 1, scans: 0, failures: 0, tasks: {} };
|
|
45
|
+
let rows;
|
|
46
|
+
try { rows = records || await readRecords(); } catch (error) {
|
|
47
|
+
await save(file, { ...previous, lastAttemptAt: now, failures: previous.failures + 1, lastFailure: { at: now, code: 'consistency_read_failed' } });
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
const fingerprint = hash([runtime, rows.map(r => [r.provider, r.session, r.autoContinueEpoch, r.run?.revision, r.run?.state, r.run?.managed?.controllerContractVersion])]);
|
|
51
|
+
if (previous.fingerprint === fingerprint && now - (previous.lastSuccessAt || 0) < intervalMs) return previous;
|
|
52
|
+
const status = { ...previous, lastAttemptAt: now, ownerPid: process.pid, runtime, fingerprint,
|
|
53
|
+
scans: previous.scans + 1, tasks: {}, lastFailure: null, metrics: { tasksObserved: rows.length, actionableFindings: 0, requestsDelivered: 0, independentInspections: 0, productiveTimeFraction: null, recoveryLatencyMs: null } };
|
|
54
|
+
status.runtimeGap = runtime && runtime.state !== 'matched' ? { code: runtime.state, expected: runtime.expected, observed: runtime.observed } : null;
|
|
55
|
+
status.lastHeartbeatAt = now;
|
|
56
|
+
let writes = Promise.resolve();
|
|
57
|
+
const persist = () => { const snapshot = structuredClone(status); writes = writes.catch(() => {}).then(() => save(file, snapshot)); return writes; };
|
|
58
|
+
await persist(); // A crash here remains an observable unfinished scan.
|
|
59
|
+
const heartbeat = setInterval(() => { status.lastHeartbeatAt = Date.now(); void persist().catch(() => {}); }, 30_000);
|
|
60
|
+
heartbeat.unref?.();
|
|
61
|
+
try {
|
|
62
|
+
for (const record of rows) {
|
|
63
|
+
const findings = consistencyFindings(record);
|
|
64
|
+
const key = `${record.provider}:${record.session}`;
|
|
65
|
+
const old = previous.tasks[key];
|
|
66
|
+
const current = { taskId: record.run?.managed?.taskId || null, state: record.run?.state || 'not_started',
|
|
67
|
+
findings, observedAt: now, inspection: old?.inspection || null };
|
|
68
|
+
status.tasks[key] = current;
|
|
69
|
+
const actionable = findings.filter(f => !f.suppressed);
|
|
70
|
+
status.metrics.actionableFindings += actionable.length;
|
|
71
|
+
if (!actionable.length || record.run?.managed?.resumeLease || record.run?.managed?.liveWorkspaceLease) continue;
|
|
72
|
+
const deliveryKey = hash(actionable.map(f => [f.id, f.objectiveDigest, f.runRevision]));
|
|
73
|
+
if (old?.inspection?.key === deliveryKey && now < old.inspection.retryAt) continue;
|
|
74
|
+
const request = { id: deliveryKey, stage: 'pending', targetTask: current.taskId,
|
|
75
|
+
version: record.run?.managed?.controllerContractVersion || null, objectiveDigest: actionable[0].objectiveDigest, findings: actionable.map(f => f.id), createdAt: now };
|
|
76
|
+
current.inspection = { key: deliveryKey, request, attempts: (old?.inspection?.key === deliveryKey ? old.inspection.attempts || 0 : 0) + 1, retryAt: now + Math.min(15 * 60_000, intervalMs * 2 ** Math.min(old?.inspection?.key === deliveryKey ? old.inspection.attempts || 0 : 0, 4)) };
|
|
77
|
+
await persist();
|
|
78
|
+
await updateRecord(root, record.provider, record.session, live => {
|
|
79
|
+
if (live?.run?.managed?.taskId !== request.targetTask || live.run.revision !== record.run.revision || live.autoContinueEpoch !== record.autoContinueEpoch) return live;
|
|
80
|
+
const outbox = live.run.managed.consistencyOutbox ||= [];
|
|
81
|
+
if (!outbox.some(item => item.id === request.id)) {
|
|
82
|
+
// A newer observation supersedes only undisbursed read inspections of
|
|
83
|
+
// the same contract/findings. Never discard an executing repair.
|
|
84
|
+
for (const item of outbox) if (!item.decisionId && item.targetTask === request.targetTask && item.version === request.version &&
|
|
85
|
+
item.objectiveDigest === request.objectiveDigest && JSON.stringify(item.findings) === JSON.stringify(request.findings) &&
|
|
86
|
+
['pending', 'received', 'inspected'].includes(item.stage)) { item.stage = 'superseded'; item.supersededBy = request.id; }
|
|
87
|
+
outbox.push(request);
|
|
88
|
+
}
|
|
89
|
+
const terminal = outbox.filter(item => ['resolved', 'superseded'].includes(item.stage));
|
|
90
|
+
if (terminal.length > 32) live.run.managed.consistencyOutbox = outbox.filter(item => !['resolved', 'superseded'].includes(item.stage) || terminal.slice(-32).includes(item));
|
|
91
|
+
return live;
|
|
92
|
+
});
|
|
93
|
+
let receipt;
|
|
94
|
+
try { receipt = await onAudit(record, actionable, request); }
|
|
95
|
+
catch {
|
|
96
|
+
current.inspection.lastFailure = { at: now, code: 'audit_dispatch_failed' };
|
|
97
|
+
status.failures++; status.lastFailure = { at: now, code: 'consistency_audit_failed' };
|
|
98
|
+
await persist();
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Receipt means delivered to the canonical controller, never resolved.
|
|
102
|
+
if (receipt?.received === true) {
|
|
103
|
+
request.stage = receipt.audited === true ? 'inspected' : 'received';
|
|
104
|
+
status.metrics.requestsDelivered++;
|
|
105
|
+
if (receipt.audited === true) status.metrics.independentInspections++;
|
|
106
|
+
request.receivedAt = now;
|
|
107
|
+
await updateRecord(root, record.provider, record.session, live => {
|
|
108
|
+
const item = live?.run?.managed?.consistencyOutbox?.find(item => item.id === request.id);
|
|
109
|
+
if (item && item.stage === 'pending') { item.stage = request.stage; item.receivedAt = now; }
|
|
110
|
+
return live;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
status.lastSuccessAt = now;
|
|
115
|
+
await persist();
|
|
116
|
+
return status;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
status.failures += 1; status.lastFailure = { at: now, code: 'consistency_audit_failed' };
|
|
119
|
+
await persist(); throw error;
|
|
120
|
+
} finally { clearInterval(heartbeat); await writes.catch(() => {}); }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function consistencyMonitorStatus(root, now = Date.now()) {
|
|
124
|
+
const status = await read(join(root, 'consistency', 'monitor.json'));
|
|
125
|
+
return status ? { ...status, health: status.lastFailure ? 'failed' : status.lastAttemptAt > (status.lastSuccessAt || 0) ? 'interrupted' : now - status.lastSuccessAt > 3 * 60_000 ? 'stale' : 'healthy' } : { health: 'not_started' };
|
|
126
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { localRuntimeStatus, pinnedWorkflowSource } from './local-runtime.js';
|
|
7
|
+
import { processIdentity } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
8
|
+
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
const hash = value => createHash('sha256').update(String(value || '')).digest('hex');
|
|
11
|
+
async function json(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } }
|
|
12
|
+
|
|
13
|
+
export async function attestDesktopRuntime({ root, source, identity = processIdentity } = {}) {
|
|
14
|
+
const state = await localRuntimeStatus({ root, allowMissingLock: true, ...(source ? { source } : {}) });
|
|
15
|
+
const owner = await json(join(root, 'desktop-app.lock/owner.json'));
|
|
16
|
+
const value = { version: state.version, digest: state.sourceDigest, source: state.source, pid: process.pid,
|
|
17
|
+
identity: await identity(process.pid), ownerPid: owner?.pid, ownerStartedAt: owner?.startedAt,
|
|
18
|
+
ownerTokenDigest: hash(owner?.nonce), observedAt: Date.now(), stateContractVersion: 2 };
|
|
19
|
+
await writeFile(join(root, 'desktop-runtime.json'), JSON.stringify(value), { mode: 0o600 });
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function desktopRuntimeStatus({ root, identity = processIdentity } = {}) {
|
|
24
|
+
const [pin, observed, owner] = await Promise.all([json(join(root, 'delivery-runtime/current.json')),
|
|
25
|
+
json(join(root, 'desktop-runtime.json')), json(join(root, 'desktop-app.lock/owner.json'))]);
|
|
26
|
+
const live = observed?.identity && owner?.pid === observed.ownerPid &&
|
|
27
|
+
owner.startedAt === observed.ownerStartedAt && hash(owner.nonce) === observed.ownerTokenDigest && await identity(observed.pid) === observed.identity;
|
|
28
|
+
return { expected: pin ? { version: pin.version, digest: pin.digest } : null,
|
|
29
|
+
observed: live ? observed : null, state: !owner ? 'not_running' : !live ? 'unverified_host' :
|
|
30
|
+
pin && (pin.digest !== observed.digest || pin.version !== observed.version) ? 'runtime_mismatch' : 'matched' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A separate local app keeps the signed/public application untouched. Only a
|
|
34
|
+
// verified, explicitly installed candidate replaces its host resources. No
|
|
35
|
+
// sign-in, permission grant, task control or process termination is performed.
|
|
36
|
+
export async function installLocalDesktop({ root, template = '/Applications/SIMY.app',
|
|
37
|
+
run = exec, platform = process.platform } = {}) {
|
|
38
|
+
if (platform !== 'darwin') throw new Error('Local desktop candidate installation currently requires macOS; other platforms use their installer.');
|
|
39
|
+
const pinSource = await pinnedWorkflowSource({ root });
|
|
40
|
+
const pin = await json(join(root, 'delivery-runtime/current.json'));
|
|
41
|
+
if (!pin) throw new Error('Install the fixed local runtime before installing its desktop.');
|
|
42
|
+
const status = await localRuntimeStatus({ root, source: pinSource || pin.release });
|
|
43
|
+
if (status.sourceDigest !== pin.digest) throw new Error('Desktop candidate source failed integrity verification.');
|
|
44
|
+
if (!(await lstat(template)).isDirectory()) throw new Error('Desktop template must be a regular application.');
|
|
45
|
+
const identity = (await run('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', join(template, 'Contents/Info.plist')])).stdout.trim();
|
|
46
|
+
if (identity !== 'com.awak.simy.cli') throw new Error('Desktop template is not the SIMY application.');
|
|
47
|
+
const storage = join(root, 'desktop-runtime'); await mkdir(storage, { recursive: true, mode: 0o700 });
|
|
48
|
+
const app = join(storage, `SIMY Local-${pin.digest}.app`), staged = join(storage, `.staged-${randomUUID()}.app`);
|
|
49
|
+
try {
|
|
50
|
+
await run('/usr/bin/ditto', [template, staged]);
|
|
51
|
+
const cli = join(staged, 'Contents/Resources/cli');
|
|
52
|
+
await rm(cli, { recursive: true, force: true });
|
|
53
|
+
await run('/usr/bin/ditto', [pin.release, cli]);
|
|
54
|
+
await run('/usr/libexec/PlistBuddy', ['-c', `Set :CFBundleShortVersionString ${pin.version}`, join(staged, 'Contents/Info.plist')]);
|
|
55
|
+
await run('/usr/libexec/PlistBuddy', ['-c', `Set :CFBundleVersion ${pin.version}`, join(staged, 'Contents/Info.plist')]);
|
|
56
|
+
await run('/usr/libexec/PlistBuddy', ['-c', 'Set :CFBundleDisplayName SIMY Local', join(staged, 'Contents/Info.plist')]);
|
|
57
|
+
// Candidate host stays on the pinned source, including after reconnection.
|
|
58
|
+
await run('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', staged]);
|
|
59
|
+
await run('/usr/bin/codesign', ['--verify', '--deep', '--strict', staged]);
|
|
60
|
+
if (await json(join(app, 'Contents/Resources/cli/runtime-build.json'))) {
|
|
61
|
+
const existing = await localRuntimeStatus({ root, source: join(app, 'Contents/Resources/cli') });
|
|
62
|
+
if (existing.sourceDigest !== pin.digest || existing.version !== pin.version) throw new Error('Existing local app failed integrity verification.');
|
|
63
|
+
await run('/usr/bin/codesign', ['--verify', '--deep', '--strict', app]);
|
|
64
|
+
await rm(staged, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
else await rename(staged, app);
|
|
67
|
+
await writeFile(join(storage, 'candidate.json'), JSON.stringify({ app, version: pin.version, digest: pin.digest }), { mode: 0o600 });
|
|
68
|
+
return { app, version: pin.version, digest: pin.digest, activation: 'not_started', publicApplicationChanged: false };
|
|
69
|
+
} finally { await rm(staged, { recursive: true, force: true }); }
|
|
70
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
-
import { canReplanDelivery, pendingAcceptance, deliveryStop, deliveryStopBlocksResume, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
|
+
import { canReplanDelivery, purposeProgressAt, pendingAcceptance, deliveryStop, deliveryStopBlocksResume, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
4
4
|
import { originalSessionTarget } from '../session-supervision/original-session-target.js';
|
|
5
5
|
|
|
6
6
|
export const HOOK_SETUP = Object.freeze({
|
|
@@ -37,6 +37,10 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
|
|
|
37
37
|
stopCode: deliveryStop(r.run, now)?.code ?? null,
|
|
38
38
|
outcomeProgress: r.run.managed?.outcomeProgress || null,
|
|
39
39
|
needsAttention: ['blocked', 'failed', 'recovery_required', 'budget_exhausted', 'verification_pending'].includes(r.run.state), updatedAt: r.run.updatedAt,
|
|
40
|
+
purposeContinuation: r.run.managed?.purpose ? { enabled: r.run.managed.purpose.enabled, version: r.run.managed.purpose.version, search: r.run.managed.purpose.search || null, ruleCount: r.run.managed.purpose.ruleIds.length,
|
|
41
|
+
lastReviewAt: r.run.managed.purpose.lastReview?.at || null, lastRecoveryAt: r.run.managed.purpose.lastRecovery?.at || null,
|
|
42
|
+
hypothesis: r.run.managed.purpose.lastReview?.hypothesis || null, gap: r.run.managed.purpose.lastReview?.gap || null,
|
|
43
|
+
ruleCounts: (r.run.managed.purpose.lastReview?.rules || []).reduce((counts, rule) => { counts[rule.status] = (counts[rule.status] || 0) + 1; return counts; }, {}) } : null,
|
|
40
44
|
mode: r.run.managed ? 'managed' : 'standard_loop',
|
|
41
45
|
deliveryLevel: r.run.managed?.deliveryLevel || null, enrollment: r.run.managed?.enrollment || null,
|
|
42
46
|
pendingAcceptance: pendingAcceptance(r.run),
|
|
@@ -51,8 +55,14 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
|
|
|
51
55
|
dependencyWait: r.run.managed?.dependencyWait || null,
|
|
52
56
|
executionAttempt: r.run.managed?.executionAttempt || null, executionUncertain: r.run.managed?.executionUncertain || null,
|
|
53
57
|
handoff: r.run.managed?.handoff || null, threadHistory: r.run.managed?.threadHistory || [],
|
|
54
|
-
progressBudget: r.run.managed ? { minutes: r.run.managed.maxNoProgressMinutes,
|
|
55
|
-
|
|
58
|
+
progressBudget: r.run.managed ? { minutes: r.run.managed.maxNoProgressMinutes,
|
|
59
|
+
lastVerifiedProgressAt: r.run.managed.purpose?.enabled
|
|
60
|
+
? purposeProgressAt(r.run, now)
|
|
61
|
+
: r.run.managed.lastVerifiedProgressAt,
|
|
62
|
+
attempts: r.run.managed.purpose?.enabled ? r.run.managed.purpose.noLearning || 0 : r.run.managed.noProgress,
|
|
63
|
+
maxAttempts: r.run.managed.maxNoProgress } : null,
|
|
64
|
+
tokenBudget: r.run.managed?.maxTokens ? { limit: r.run.managed.maxTokens, used: r.run.managed.tokensUsed,
|
|
65
|
+
usageAvailable: r.run.managed.usageAvailable, source: 'provider_observed', dollarCost: null } : null,
|
|
56
66
|
pendingApproval: r.run.managed?.pendingApproval || null,
|
|
57
67
|
autonomyPolicy: r.run.managed?.autonomyPolicy || null, plan: r.run.managed?.plan || null,
|
|
58
68
|
decision: r.run.managed?.decision || null, decisionHistory: r.run.managed?.decisionHistory || [],
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { lstat, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { pinnedWorkflowSource } from './local-runtime.js';
|
|
6
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
7
|
+
// Exact shipped 0.6.23 and 0.6.24 entrypoints only. Customized hooks are retained.
|
|
8
|
+
const shipped = new Set(['8a454293d119eac7cdb46c2df70c6b54a431fcf575bbf4f48f3373ca14414f3b', '339dc64d7ac980f9bfdafd1b2feea47ef8e86b2895b9d6e040086b005a806a79']);
|
|
9
|
+
export const legacyRoutingShim = `// SIMY managed immutable-runtime route v1. Original exports retained for compatibility.
|
|
10
|
+
export * from './delivery-loop.pre-runtime-route.mjs';
|
|
11
|
+
import { readFile, readdir, lstat } from 'node:fs/promises';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { pathToFileURL } from 'node:url';
|
|
16
|
+
export async function main(provider) {
|
|
17
|
+
try {
|
|
18
|
+
const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
|
|
19
|
+
const pin = JSON.parse(await readFile(join(root, 'delivery-runtime/current.json'), 'utf8'));
|
|
20
|
+
const source = resolve(root, 'delivery-runtime/releases', pin.digest || 'invalid');
|
|
21
|
+
if (pin.schema !== 1 || !/^[a-f0-9]{64}$/.test(pin.digest || '') || resolve(pin.release || '') !== source) throw Error('Invalid runtime route');
|
|
22
|
+
const hash = createHash('sha256');
|
|
23
|
+
async function visit(relative) {
|
|
24
|
+
const file = join(source, relative), info = await lstat(file);
|
|
25
|
+
if (info.isSymbolicLink()) throw Error('Invalid runtime route');
|
|
26
|
+
if (info.isDirectory()) { for (const name of (await readdir(file)).sort()) await visit(relative + '/' + name); }
|
|
27
|
+
else if (info.isFile()) { const bytes = await readFile(file); hash.update(relative + '\\0' + bytes.length + '\\0'); hash.update(bytes); }
|
|
28
|
+
else throw Error('Invalid runtime route');
|
|
29
|
+
}
|
|
30
|
+
for (const input of ['package.json', 'package-lock.json', 'README.md', 'src']) await visit(input);
|
|
31
|
+
if (hash.digest('hex') !== pin.digest || JSON.parse(await readFile(join(source, 'package.json'), 'utf8')).version !== pin.version) throw Error('Runtime integrity mismatch');
|
|
32
|
+
const selected = await import(pathToFileURL(join(source, 'src/codex-plugins/simy/hooks/delivery-loop.mjs')).href);
|
|
33
|
+
await selected.main(provider);
|
|
34
|
+
} catch { process.stdout.write('{}\\n'); process.stderr.write('SIMY runtime route unavailable; original task state was preserved.\\n'); }
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
export async function routeLegacyHooks({ root, codexHome = process.env.CODEX_HOME || join(homedir(), '.codex') } = {}) {
|
|
38
|
+
await pinnedWorkflowSource({ root });
|
|
39
|
+
const folder = join(codexHome, 'plugins/cache/personal/simy');
|
|
40
|
+
let entries; try { entries = await readdir(folder); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
41
|
+
const receipts = [];
|
|
42
|
+
for (const version of entries) {
|
|
43
|
+
if (!/^0\.1\.0\+codex\.[a-f0-9]{64}$/.test(version)) continue;
|
|
44
|
+
const cache = join(folder, version), file = join(cache, 'hooks/delivery-loop.mjs');
|
|
45
|
+
if ((await lstat(cache)).isSymbolicLink()) continue;
|
|
46
|
+
const manifest = JSON.parse(await readFile(join(cache, '.codex-plugin/plugin.json'), 'utf8'));
|
|
47
|
+
if (manifest.name !== 'simy' || manifest.version !== version || (await lstat(file)).isSymbolicLink()) continue;
|
|
48
|
+
const bytes = await readFile(file), before = digest(bytes), next = digest(legacyRoutingShim);
|
|
49
|
+
if (before === next) { receipts.push({ version, outcome: 'current', routeDigest: next }); continue; }
|
|
50
|
+
if (!shipped.has(before)) { receipts.push({ version, outcome: 'customized_retained', originalDigest: before }); continue; }
|
|
51
|
+
const backup = join(cache, 'hooks/delivery-loop.pre-runtime-route.mjs');
|
|
52
|
+
try { await writeFile(backup, bytes, { mode: 0o600, flag: 'wx' }); }
|
|
53
|
+
catch (error) { if (error.code !== 'EEXIST' || digest(await readFile(backup)) !== before) throw error; }
|
|
54
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
55
|
+
await writeFile(temporary, legacyRoutingShim, { mode: 0o600 }); await rename(temporary, file);
|
|
56
|
+
receipts.push({ version, outcome: 'routed', originalDigest: before, routeDigest: next, backup });
|
|
57
|
+
}
|
|
58
|
+
await mkdir(join(root, 'delivery-runtime'), { recursive: true, mode: 0o700 });
|
|
59
|
+
await writeFile(join(root, 'delivery-runtime/legacy-routes.json'), JSON.stringify({ at: Date.now(), receipts }), { mode: 0o600 });
|
|
60
|
+
return receipts;
|
|
61
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { stableHookRuntime } from '../hook-runtime.js';
|
|
8
|
+
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
const packageRoot = fileURLToPath(new URL('../../', import.meta.url));
|
|
11
|
+
const inputs = ['package.json', 'package-lock.json', 'README.md', 'src'];
|
|
12
|
+
|
|
13
|
+
async function fingerprint(source, { allowMissingLock = false } = {}) {
|
|
14
|
+
const hash = createHash('sha256');
|
|
15
|
+
async function visit(relative) {
|
|
16
|
+
const target = join(source, relative);
|
|
17
|
+
const info = await lstat(target);
|
|
18
|
+
if (info.isSymbolicLink()) throw new Error('Runtime source must not contain symbolic links.');
|
|
19
|
+
if (info.isDirectory()) {
|
|
20
|
+
for (const entry of (await readdir(target)).sort()) await visit(`${relative}/${entry}`);
|
|
21
|
+
} else if (info.isFile()) {
|
|
22
|
+
const bytes = await readFile(target);
|
|
23
|
+
hash.update(`${relative}\0${bytes.length}\0`); hash.update(bytes);
|
|
24
|
+
} else throw new Error('Runtime source must contain only regular files and directories.');
|
|
25
|
+
}
|
|
26
|
+
for (const input of inputs) {
|
|
27
|
+
try { await visit(input); } catch (error) {
|
|
28
|
+
if (allowMissingLock && input === 'package-lock.json' && error.code === 'ENOENT') hash.update('packaged-without-lock\0');
|
|
29
|
+
else throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return hash.digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function atomicJson(file, value) {
|
|
36
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
37
|
+
try { await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); await rename(temporary, file); }
|
|
38
|
+
finally { await rm(temporary, { force: true }); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function quote(value) { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
42
|
+
|
|
43
|
+
export async function localRuntimeStatus({ root, source = packageRoot, allowMissingLock = false } = {}) {
|
|
44
|
+
const pkg = JSON.parse(await readFile(join(source, 'package.json'), 'utf8'));
|
|
45
|
+
let installation = null;
|
|
46
|
+
try { installation = JSON.parse(await readFile(join(root, 'delivery-runtime', 'current.json'), 'utf8')); }
|
|
47
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
48
|
+
return { version: pkg.version, source: resolve(source), sourceDigest: await fingerprint(source, { allowMissingLock }),
|
|
49
|
+
purposeVersion: 2, decisionPolicy: 'llm-first-hc102', stateContractVersion: 2, consistencyMonitorVersion: 1, installation };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function pinnedWorkflowSource({ root, source = packageRoot } = {}) {
|
|
53
|
+
let current;
|
|
54
|
+
try { current = JSON.parse(await readFile(join(root, 'delivery-runtime', 'current.json'), 'utf8')); }
|
|
55
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
56
|
+
if (current.schema !== 1 || !/^[a-f0-9]{64}$/.test(current.digest || '')) throw new Error('Invalid local workflow runtime pin.');
|
|
57
|
+
const expected = resolve(root, 'delivery-runtime', 'releases', current.digest);
|
|
58
|
+
if (resolve(current.release || '') !== expected) throw new Error('Local workflow pin points outside its immutable releases.');
|
|
59
|
+
const info = await lstat(expected);
|
|
60
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Pinned workflow source must be a regular directory.');
|
|
61
|
+
const status = await localRuntimeStatus({ root, source: expected });
|
|
62
|
+
if (status.sourceDigest !== current.digest || status.version !== current.version) throw new Error('Pinned workflow runtime failed integrity verification.');
|
|
63
|
+
return resolve(source) === expected ? null : expected;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Install a frozen candidate alongside the released app, without replacing a
|
|
67
|
+
// signed application, changing provider trust, or starting any registered task.
|
|
68
|
+
export async function installLocalRuntime({ root, source = packageRoot,
|
|
69
|
+
installDependencies = async target => exec(process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
|
70
|
+
['ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: target, timeout: 120_000, maxBuffer: 1024 * 1024 }),
|
|
71
|
+
} = {}) {
|
|
72
|
+
if (process.platform === 'win32') throw new Error('Local candidate launcher installation currently supports macOS/Linux; Windows uses the installer release.');
|
|
73
|
+
const storage = join(root, 'delivery-runtime');
|
|
74
|
+
await mkdir(storage, { recursive: true, mode: 0o700 });
|
|
75
|
+
const lock = join(storage, 'install.lock');
|
|
76
|
+
await mkdir(lock); // An existing install is not silently stolen.
|
|
77
|
+
const staged = join(storage, `.staged-${randomUUID()}`);
|
|
78
|
+
try {
|
|
79
|
+
const before = await localRuntimeStatus({ root, source });
|
|
80
|
+
const digest = before.sourceDigest;
|
|
81
|
+
const release = join(storage, 'releases', digest);
|
|
82
|
+
await mkdir(dirname(release), { recursive: true, mode: 0o700 });
|
|
83
|
+
let exists = false;
|
|
84
|
+
try {
|
|
85
|
+
const info = await lstat(release);
|
|
86
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Existing candidate must be a regular directory.');
|
|
87
|
+
exists = true;
|
|
88
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
89
|
+
if (!exists) {
|
|
90
|
+
await mkdir(staged, { mode: 0o700 });
|
|
91
|
+
for (const input of inputs) await cp(join(source, input), join(staged, input), { recursive: true, errorOnExist: true });
|
|
92
|
+
if (await fingerprint(staged) !== digest || await fingerprint(source) !== digest) throw new Error('Runtime source changed while copying; no candidate activated.');
|
|
93
|
+
await installDependencies(staged);
|
|
94
|
+
if (await fingerprint(staged) !== digest) throw new Error('Dependency installation changed the runtime source.');
|
|
95
|
+
await atomicJson(join(staged, 'runtime-build.json'), { schema: 1, version: before.version, digest });
|
|
96
|
+
await rename(staged, release);
|
|
97
|
+
} else {
|
|
98
|
+
const saved = JSON.parse(await readFile(join(release, 'runtime-build.json'), 'utf8'));
|
|
99
|
+
if (saved.schema !== 1 || saved.digest !== digest || await fingerprint(release) !== digest) throw new Error('Existing candidate failed integrity verification.');
|
|
100
|
+
}
|
|
101
|
+
const bin = join(root, 'bin');
|
|
102
|
+
await mkdir(bin, { recursive: true, mode: 0o700 });
|
|
103
|
+
const launcher = join(bin, 'simy-delivery');
|
|
104
|
+
const cli = join(bin, 'simy');
|
|
105
|
+
const temporary = `${launcher}.${randomUUID()}.tmp`;
|
|
106
|
+
// Pin this process to one immutable snapshot. Later installation cannot
|
|
107
|
+
// change modules under an active worker or implicitly resume old tasks.
|
|
108
|
+
try {
|
|
109
|
+
await writeFile(temporary, `#!/bin/sh\nexec ${quote(stableHookRuntime())} ${quote(join(release, 'src/index.js'))} --no-auto-update delivery "$@"\n`, { mode: 0o700 });
|
|
110
|
+
await rename(temporary, launcher);
|
|
111
|
+
} finally { await rm(temporary, { force: true }); }
|
|
112
|
+
const cliTemporary = `${cli}.${randomUUID()}.tmp`;
|
|
113
|
+
try {
|
|
114
|
+
await writeFile(cliTemporary, `#!/bin/sh\nexec ${quote(stableHookRuntime())} ${quote(join(release, 'src/index.js'))} --no-auto-update "$@"\n`, { mode: 0o700 });
|
|
115
|
+
await rename(cliTemporary, cli);
|
|
116
|
+
} finally { await rm(cliTemporary, { force: true }); }
|
|
117
|
+
const current = { schema: 1, version: before.version, digest, release, launcher, cli,
|
|
118
|
+
previous: before.installation?.digest === digest ? before.installation.previous
|
|
119
|
+
: before.installation ? { version: before.installation.version, digest: before.installation.digest, release: before.installation.release } : null };
|
|
120
|
+
await atomicJson(join(storage, 'current.json'), current);
|
|
121
|
+
return current;
|
|
122
|
+
} finally { await rm(staged, { recursive: true, force: true }); await rm(lock, { recursive: true, force: true }); }
|
|
123
|
+
}
|