@awak-app/simy-cli 0.7.15 → 0.7.17

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 (121) hide show
  1. package/README.md +225 -98
  2. package/package.json +5 -3
  3. package/src/agent.js +129 -7
  4. package/src/backend-executable.js +7 -6
  5. package/src/bounded-local-task-subtask-pool.js +32 -6
  6. package/src/bounded-local-task-subtasks-contract.js +1 -1
  7. package/src/cli-contract.js +1 -1
  8. package/src/codex-plugins/simy/.codex-plugin/plugin.json +2 -2
  9. package/src/codex-plugins/simy/hooks/README.md +8 -3
  10. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +302 -78
  11. package/src/codex-plugins/simy/hooks/hooks.json +1 -1
  12. package/src/codex-plugins/simy/hooks/loop-kernel/conformance.json +136 -0
  13. package/src/codex-plugins/simy/hooks/loop-kernel/index.mjs +42 -0
  14. package/src/codex-plugins/simy/hooks/loop-kernel/outcome-review.mjs +31 -0
  15. package/src/codex-plugins/simy/hooks/loop-kernel/pin.json +6 -0
  16. package/src/codex-plugins/simy/hooks/loop-kernel/policy.json +141 -0
  17. package/src/codex-plugins/simy/hooks/loop-kernel/task-pool.mjs +34 -0
  18. package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +132 -0
  19. package/src/codex-plugins/simy/hooks/loop-kernel.mjs +6 -0
  20. package/src/codex-plugins/simy/hooks/outcome-progress.mjs +16 -5
  21. package/src/codex-plugins/simy/hooks/purpose-completion-contract.mjs +10 -0
  22. package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +12 -11
  23. package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +28 -38
  24. package/src/codex-plugins/simy/hooks/purpose-rule-policy.mjs +1 -2
  25. package/src/codex-plugins/simy/hooks/purpose-rule-review.mjs +5 -63
  26. package/src/codex-plugins/simy/hooks/purpose-search.mjs +21 -4
  27. package/src/codex-plugins/simy/hooks/result-cycle.mjs +166 -0
  28. package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +98 -12
  29. package/src/codex-plugins/simy/hooks/user-experience.mjs +26 -0
  30. package/src/codex-plugins/simy/hooks/work-frontier.mjs +2 -0
  31. package/src/codex-plugins/simy/skills/diagnose/SKILL.md +10 -4
  32. package/src/coding-run-recovery.js +4 -2
  33. package/src/delivery-hooks/claude-workflow.md +10 -7
  34. package/src/delivery-hooks/command.js +76 -10
  35. package/src/delivery-hooks/consistency-monitor.js +20 -4
  36. package/src/delivery-hooks/consult.js +60 -0
  37. package/src/delivery-hooks/diagnosis.js +2 -2
  38. package/src/delivery-hooks/legacy-hook-routing.js +46 -8
  39. package/src/delivery-hooks/local-runtime.js +81 -1
  40. package/src/delivery-hooks/local-supervisor.js +13 -4
  41. package/src/delivery-hooks/repair-continuation.js +80 -0
  42. package/src/delivery-hooks/repair.js +164 -0
  43. package/src/delivery-hooks/supervisor-handover.js +84 -0
  44. package/src/delivery-hooks/supervisor-owner.js +3 -2
  45. package/src/delivery-hooks/supervisor-service.js +38 -6
  46. package/src/delivery-hooks/supervisor-watchdog.js +15 -4
  47. package/src/delivery-hooks/supervisor.js +151 -18
  48. package/src/desktop-access.js +2 -1
  49. package/src/desktop-host.js +35 -2
  50. package/src/durable-local-task-worker.js +372 -58
  51. package/src/execution-capability-contract.js +15 -1
  52. package/src/index.js +3 -3
  53. package/src/local-photos-read.swift +33 -0
  54. package/src/local-photos.js +47 -0
  55. package/src/local-task-attachment-store.js +31 -0
  56. package/src/local-task-destination.js +174 -0
  57. package/src/local-task.js +119 -7
  58. package/src/native-delegation-contract.js +68 -0
  59. package/src/native-provider-identity.js +60 -0
  60. package/src/orchestrator/audit.js +24 -323
  61. package/src/orchestrator/budget.js +22 -11
  62. package/src/orchestrator/charter-validation.js +3 -41
  63. package/src/orchestrator/checklist-continuation.js +2 -14
  64. package/src/orchestrator/completion-contract.js +4 -49
  65. package/src/orchestrator/contract.js +5 -9
  66. package/src/orchestrator/delivery/WORKFLOW.md +30 -710
  67. package/src/orchestrator/delivery/references/acceptance-evidence.md +4 -216
  68. package/src/orchestrator/delivery/references/change-boundary-matrix.md +4 -103
  69. package/src/orchestrator/delivery/references/chrome-user-verification.md +5 -133
  70. package/src/orchestrator/delivery/references/cross-repository-control.md +4 -74
  71. package/src/orchestrator/delivery/references/delivery-levels.md +3 -117
  72. package/src/orchestrator/delivery/references/local-sqm-evidence.md +1 -1
  73. package/src/orchestrator/delivery/references/minimum-completion.md +4 -144
  74. package/src/orchestrator/delivery/references/pr-closure.md +4 -200
  75. package/src/orchestrator/delivery/references/shared-system-specifications.md +4 -161
  76. package/src/orchestrator/delivery/references/state-and-receipts.md +4 -245
  77. package/src/orchestrator/delivery/references/state-model.md +4 -185
  78. package/src/orchestrator/delivery/references/ux-specification.md +4 -95
  79. package/src/orchestrator/delivery-level.js +2 -10
  80. package/src/orchestrator/evidence.js +2 -3
  81. package/src/orchestrator/independent-audit.js +15 -164
  82. package/src/orchestrator/instruction.js +19 -254
  83. package/src/orchestrator/loop.js +35 -95
  84. package/src/orchestrator/outcome-monitor.js +21 -0
  85. package/src/orchestrator/pr-closure.js +2 -16
  86. package/src/orchestrator/recovery.js +32 -2
  87. package/src/orchestrator/ux-specification.js +3 -12
  88. package/src/provider-delegation.js +336 -0
  89. package/src/runner.js +5 -2
  90. package/src/session-supervision/acceptance-reads.js +34 -0
  91. package/src/session-supervision/artifact-observer.js +138 -0
  92. package/src/session-supervision/browser-cdp.js +59 -0
  93. package/src/session-supervision/browser-recovery.js +97 -0
  94. package/src/session-supervision/codex-app-server.js +61 -22
  95. package/src/session-supervision/completion-artifacts.js +44 -0
  96. package/src/session-supervision/consistency-auditor.js +8 -11
  97. package/src/session-supervision/continuation-analysis.js +47 -28
  98. package/src/session-supervision/final-response-recovery.js +5 -0
  99. package/src/session-supervision/index.js +22 -5
  100. package/src/session-supervision/interaction-proxy.js +250 -0
  101. package/src/session-supervision/interaction-task.js +189 -0
  102. package/src/session-supervision/native-interaction.js +102 -0
  103. package/src/session-supervision/provider-failure.js +40 -0
  104. package/src/session-supervision/purpose-instruction.js +14 -14
  105. package/src/session-supervision/purpose-recovery.js +32 -12
  106. package/src/session-supervision/purpose-verification.js +50 -216
  107. package/src/session-supervision/recovery-frontier.js +55 -0
  108. package/src/session-supervision/stop-notifications.js +8 -8
  109. package/src/sqm/agent-skill.js +1 -1
  110. package/src/sqm/checkers.js +12 -6
  111. package/src/sqm/claude-plugin-update.js +22 -8
  112. package/src/sqm/claude-plugin.js +6 -0
  113. package/src/sqm/local-tests.js +2 -1
  114. package/src/sqm/plugin-cache.js +67 -0
  115. package/src/sqm/proof.js +25 -0
  116. package/src/sqm/report.js +1 -0
  117. package/src/sqm/workflow-onboarding.js +3 -39
  118. package/src/sqm/workflow-update.js +17 -6
  119. package/src/startup-preflight.js +2 -20
  120. package/src/team-status-fence-store.js +32 -0
  121. package/src/team-threads.js +457 -0
@@ -1,4 +1,4 @@
1
- import { suggestedProviderTokenBudget } from "./budget.js";
1
+ import { suggestedProviderTokenBudget, MAX_PROVIDER_TOKEN_BUDGET } from "./budget.js";
2
2
 
3
3
  const RECOVERABLE_STATES = new Set(["waiting_human", "blocked", "failed"]);
4
4
 
@@ -13,7 +13,7 @@ export function recoveryContractForEvent({ snapshot, state, message, detail = {}
13
13
  schema_version: 1,
14
14
  state,
15
15
  reason_code: reasonCode,
16
- user_message: recoveryMessage(reasonCode),
16
+ user_message: isSafetyCeiling(reasonCode) ? safetyCeilingRecoveryMessage(reasonCode, detail.budget) : recoveryMessage(reasonCode),
17
17
  primary_action_id: actions[0].id,
18
18
  actions,
19
19
  technical_details: {
@@ -22,6 +22,9 @@ export function recoveryContractForEvent({ snapshot, state, message, detail = {}
22
22
  max_attempts: positiveInteger(snapshot?.charter?.max_attempts),
23
23
  provider_token_budget: positiveInteger(snapshot?.charter?.token_budget),
24
24
  provider_tokens_used: nonNegativeInteger(detail?.budget?.tokens_used),
25
+ provider_token_ceiling: positiveInteger(detail?.budget?.hard_token_ceiling),
26
+ attempt_ceiling: positiveInteger(detail?.budget?.hard_attempt_ceiling),
27
+ continuation_policy: clean(detail?.budget?.continuation_policy) || null,
25
28
  token_budget: positiveInteger(snapshot?.charter?.token_budget),
26
29
  retry_budget: nonNegativeInteger(snapshot?.charter?.retry_budget),
27
30
  tokens_used: nonNegativeInteger(detail?.budget?.tokens_used),
@@ -115,6 +118,7 @@ function recoveryReasonCode(state, message, detail) {
115
118
  return "retry_strategy_unchanged";
116
119
  }
117
120
  if (code === "outcome_assessment_unavailable") return "outcome_assessment_unavailable";
121
+ if (["provider_token_ceiling_reached", "attempt_ceiling_reached"].includes(code)) return code;
118
122
  if (code === "token_budget_exhausted") return "token_budget_exhausted";
119
123
  if (code === "retry_budget_exhausted") return "retry_budget_exhausted";
120
124
  const findings = strings(detail.finding_codes);
@@ -138,6 +142,25 @@ function recoveryReasonCode(state, message, detail) {
138
142
  return "local_orchestration_failed";
139
143
  }
140
144
 
145
+ function isSafetyCeiling(code) {
146
+ return ["provider_token_ceiling_reached", "attempt_ceiling_reached"].includes(code);
147
+ }
148
+
149
+ export function safetyCeilingRecoveryMessage(code, budget = {}) {
150
+ const token = code === "provider_token_ceiling_reached";
151
+ const limit = token ? budget?.hard_token_ceiling : budget?.hard_attempt_ceiling;
152
+ const used = token ? budget?.tokens_used : (budget?.retries_used ?? 0) + 1;
153
+ const maximum = token ? MAX_PROVIDER_TOKEN_BUDGET : 100;
154
+ const base = `SIMY retained this same run's work and usage. The ${token ? "Provider token" : "executor attempt"} safety ceiling prevents another call. Increasing a notification threshold alone does not permit another call. `;
155
+ if (budget?.continuation_policy !== "progress") {
156
+ return base + "The bounded policy does not permit a same-run safety extension. Keep this run and review its remaining acceptance conditions with the operator; guidance alone cannot resume provider work.";
157
+ }
158
+ if (!Number.isFinite(limit) || !Number.isFinite(used) || limit >= maximum || used >= maximum) {
159
+ return base + `The validated maximum is ${maximum.toLocaleString("en-US")}; no larger same-run ceiling is available. Preserve evidence and review remaining work with the operator; guidance alone cannot resume provider work.`;
160
+ }
161
+ return base + `Review remaining acceptance conditions and explicitly increase the relevant ceiling above both recorded usage and its current limit, up to ${maximum.toLocaleString("en-US")}. Then continue this same run. Extension never resets usage or resumes work, and other approvals remain required.`;
162
+ }
163
+
141
164
  function recoveryMessage(reasonCode) {
142
165
  const messages = {
143
166
  repository_authorization_required:
@@ -198,6 +221,13 @@ function recoveryActions(reasonCode, budget = null) {
198
221
  newRunAction(),
199
222
  ];
200
223
  }
224
+ if (isSafetyCeiling(reasonCode)) {
225
+ return [guidanceAction(
226
+ "review_safety_ceiling",
227
+ "Review this run's safety ceiling",
228
+ safetyCeilingRecoveryMessage(reasonCode, budget),
229
+ )];
230
+ }
201
231
  if (reasonCode === "token_budget_exhausted") {
202
232
  const suggestedBudget = suggestedProviderTokenBudget(budget);
203
233
  if (!suggestedBudget || budget?.retry_exhausted === true) return [newRunAction()];
@@ -1,3 +1,4 @@
1
+ import { experienceInstruction } from '../codex-plugins/simy/hooks/user-experience.mjs';
1
2
  import { fileURLToPath } from "node:url";
2
3
  import { section } from "./shared.js";
3
4
 
@@ -5,16 +6,6 @@ export const UX_SPECIFICATION_PATH = fileURLToPath(
5
6
  new URL("./delivery/references/ux-specification.md", import.meta.url),
6
7
  );
7
8
 
8
- export function buildUxSpecificationInstruction({ auditor = false } = {}) {
9
- return section("ux_specification_contract", [
10
- `Apply the same behavioral-completeness standard throughout requirements, design, implementation mapping, verification, review, release, closure and handoff. Read: ${UX_SPECIFICATION_PATH}`,
11
- "Describe observable interaction units, not feature inventories. Reuse stable acceptance IDs. For each unit specify actor/goal, context/preconditions, trigger/reachable entry, perceived information, available action/choice/control, system response/state transition, usable outcome/postconditions, next action/handoff, alternative/failure/recovery paths, and verification oracle/provenance. Treat these as semantic obligations in the existing specification, not new CLI JSON fields.",
12
- "Decompose when actor, precondition, decision, observable state, postcondition or recovery differs materially, or when any clause could fail independently. Link predecessors/successors and preserve end-to-end criteria. Derive branches and applicable constraints from authoritative requirements and observed system boundaries; do not invent scenarios, defaults, thresholds or product policy to fill gaps.",
13
- "Description is sufficient only when another implementer can derive the intended behavior and an independent verifier can determine pass/fail without guessing a material requirement. Review source coverage, causal continuity, branch closure, controllability, observability, traceability, consistency and epistemic separation. Word count, row count and verbosity are not quality thresholds.",
14
- "Keep required/intended/observed/assumed/unknown distinct. Unknown facts stay explicit with affected IDs and resolution evidence; a reversible missing requirement decision is resolved by grounded AI inference and a recorded assumption, while missing runtime evidence blocks the affected verification claim. Genuine non-applicability needs a source and reason, not an empty cell.",
15
- "Persist the complete contract in the canonical specification and link the same IDs from summaries, matrices, PRs and receipts. Summaries may shorten repetition but must not replace or drop behavioral obligations, unresolved gaps or evidence. A phase change revalidates affected descriptions and results; plans never become observed proof by wording alone.",
16
- auditor
17
- ? "Remain read-only. Independently derive coverage from original sources, identify any material behavior that still requires guessing, and report affected criterion IDs and required clarification/evidence as findings. Do not infer completeness from filled fields or copied executor claims."
18
- : "Before dependent implementation, resolve material specification ambiguity with source research, a reversible default and verification; reserve needs_decision for irreversible or explicitly user-reserved decisions; continue independent work. Before verification or completion, repair missing descriptions/evidence rather than relabeling incomplete UX as complete. This procedural contract does not add automatic schema or GitHub enforcement.",
19
- ].join("\n"));
9
+ export function buildUxSpecificationInstruction() {
10
+ return section("requested_outcome", experienceInstruction + '\n' + 'Make the requested experience easy, accurate and concise. Resolve material ambiguity by inspecting the existing product and trying a reversible change. Exercise the actual user flow, including relevant errors and recovery. Specify only what helps implementation or use; no compulsory interaction-unit forms, traceability matrices or twelve-view reviews.');
20
11
  }
@@ -0,0 +1,336 @@
1
+ import { StringDecoder } from 'node:string_decoder';
2
+ import { isAbsolute } from 'node:path';
3
+ import { matchesPairedNativeAccount } from './native-provider-identity.js';
4
+ import { readRecord } from './codex-plugins/simy/hooks/delivery-loop.mjs';
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import { readClaudeSession } from './session-supervision/claude-sessions.js';
8
+ import { EventEmitter } from 'node:events';
9
+ import { PassThrough } from 'node:stream';
10
+ import { realpath } from 'node:fs/promises';
11
+ import { PROVIDER_LINKS } from './provider-links.js';
12
+ import { normalizeProviderModelSelection } from './provider-model-selection.js';
13
+ import { resolveDesktopExecutorCommand } from './desktop-executor.js';
14
+ import { openCodexAppServer, readCodexSession } from './session-supervision/codex-app-server.js';
15
+
16
+ const NATIVE_SESSION_MODES = Object.freeze({ codex: 'existing', claude: 'fork' });
17
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
+ const fail = code => Object.assign(new Error(`Provider delegation unavailable: ${code}.`), { code });
19
+ const identifier = value => typeof value === 'string' && value.trim() && value.length <= 256;
20
+
21
+ // Routing identifiers and provider-reported account email, never credentials. API models stay in Crew's
22
+ // registry/gateway and SIMY conversation; this native adapter does not invent
23
+ // local executables or sessions for them.
24
+ export function normalizeProviderDelegation(value, { provider, model } = {}) {
25
+ if (value == null) return null;
26
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
27
+ value.schema_version !== 'provider_delegation.v1' || value.provider !== provider ||
28
+ value.model !== model || !identifier(value.model) || !identifier(value.origin_task_id) ||
29
+ !identifier(value.origin_conversation_id) || !identifier(value.request_id) ||
30
+ !identifier(value.account_id) || !UUID.test(value.session_id || '') ||
31
+ !['existing', 'fork'].includes(value.session_mode)) throw fail('invalid_provider_delegation');
32
+ normalizeProviderModelSelection({ model: value.model });
33
+ if (!PROVIDER_LINKS.includes(provider)) throw fail('crew_gateway_required');
34
+ if (!Object.hasOwn(NATIVE_SESSION_MODES, provider)) throw fail('native_adapter_unavailable');
35
+ if (provider === 'claude' && value.session_mode !== 'fork') throw fail('claude_exact_session_writer_unavailable');
36
+ if (provider === 'claude' && value.allow_new_task !== true) throw fail('new_task_permission_required');
37
+ if (provider === 'codex' && value.session_mode !== 'existing') throw fail('unsupported_session_mode');
38
+ return Object.freeze(Object.fromEntries(['schema_version', 'provider', 'model', 'account_id', 'session_id', 'session_mode',
39
+ 'origin_task_id', 'origin_conversation_id', 'request_id', 'allow_new_task', 'allow_new_task_request_id'].filter(key => value[key] !== undefined).map(key => [key, value[key]])));
40
+ }
41
+
42
+ // Accept P01's exact target shape at the existing Local Task boundary. A
43
+ // target alone carries no task/session execution authority and cannot silently
44
+ // fall back to a fresh native invocation.
45
+ export function providerDelegationFromRequest(body, requestId) {
46
+ const target = body.delegation_target;
47
+ if (!body.delegation) {
48
+ if (target) throw fail('delegation_context_required');
49
+ return null;
50
+ }
51
+ const value = { ...body.delegation };
52
+ if (target) {
53
+ if (target.transport !== 'native') throw fail('crew_gateway_required');
54
+ for (const field of ['provider', 'model', 'account_id', 'session_id']) {
55
+ if (value[field] !== undefined && value[field] !== target[field]) throw fail('delegation_target_mismatch');
56
+ value[field] = target[field];
57
+ }
58
+ }
59
+ if (value.request_id !== requestId) throw fail('delegation_request_mismatch');
60
+ const normalized = normalizeProviderDelegation(value, { provider: body.backend, model: body.model });
61
+ if (body.native_identity && normalized.session_mode === 'fork' && normalized.allow_new_task_request_id !== requestId)
62
+ throw fail('native_new_task_permission_required');
63
+ return normalized;
64
+ }
65
+
66
+ export function providerDelegationTarget(value) {
67
+ return value ? Object.fromEntries(['provider', 'model', 'account_id', 'session_id'].map(key => [key, value[key]]).concat([['transport', 'native']])) : null;
68
+ }
69
+
70
+ export function providerDelegationCoverage(capabilities = []) {
71
+ return capabilities.map(item => {
72
+ const native = item.transport !== 'api' && PROVIDER_LINKS.includes(item.provider);
73
+ const mode = Object.hasOwn(NATIVE_SESSION_MODES, item.provider) ? NATIVE_SESSION_MODES[item.provider] : null;
74
+ return { provider: item.provider, readiness: item.readiness,
75
+ execution_target: native ? 'local' : 'crew_gateway',
76
+ session_mode: native ? mode : 'simy_context',
77
+ ...(native && mode ? { supported_session_modes: [mode],
78
+ continuation_mode: mode === 'existing' ? 'same_session' : 'fresh_explicit_fork',
79
+ cancel_scope: mode === 'existing' ? 'owned_turn' : 'owned_fork_process',
80
+ disconnect_recovery: 'hold_unknown_without_replay' } : {}),
81
+ limitation: !native ? 'requires_crew_gateway' : !mode ? 'native_adapter_unavailable' :
82
+ item.provider === 'claude' ? 'exact_session_writer_unavailable' : null };
83
+ });
84
+ }
85
+
86
+ export async function resolveDelegatedProviderCommand(input, { openClient = openCodexAppServer,
87
+ resolveCommand = resolveDesktopExecutorCommand, readClaude = readClaudeSession,
88
+ supervisionRoot, pairedDeviceId, providerLinkEpoch,
89
+ onWorkspaceAuthorized = null,
90
+ readSupervision = target => readRecord(supervisionRoot, target.provider, target.session_id),
91
+ inspectClaudeAccount = async bin => JSON.parse((await promisify(execFile)(bin, ['auth', 'status', '--json'], { timeout: 5000, maxBuffer: 65536 })).stdout) } = {}) {
92
+ const selection = normalizeProviderDelegation(input.delegation, { provider: input.backend, model: input.model });
93
+ const accountMatches = email => input.nativeIdentity ? matchesPairedNativeAccount(input.nativeIdentity,
94
+ { deviceId: pairedDeviceId, provider: selection.provider, email, connectionEpoch: providerLinkEpoch ?? null }) && input.nativeIdentity.account_id === selection.account_id
95
+ : email === selection.account_id;
96
+ if (!selection) return resolveCommand(input);
97
+ let claudeCommand;
98
+ if (selection.provider === 'claude') {
99
+ if (input.nativeIdentity && selection.allow_new_task_request_id !== selection.request_id) throw fail('new_task_permission_required');
100
+ claudeCommand = await resolveCommand(input);
101
+ if (claudeCommand.verification === 'explicit_command_override') throw fail('provider_selection_unverifiable');
102
+ // Keep ordinary provider approval. Never use the desktop adapter's legacy
103
+ // skip-permissions flag for a delegation.
104
+ claudeCommand.args = ['-p', input.instruction, '--output-format', 'stream-json', '--verbose',
105
+ '--model', selection.model, '--resume', selection.session_id, '--fork-session',
106
+ '--permission-mode', input.permissionMode === 'read_only' ? 'plan' : 'default'];
107
+ }
108
+ return { bin: 'codex-app-server', args: [], env: {}, backend: input.backend,
109
+ spawn() {
110
+ const child = new EventEmitter();
111
+ child.stdout = new PassThrough(); child.stderr = new PassThrough();
112
+ const controller = new AbortController();
113
+ let ownedChild;
114
+ child.kill = signal => {
115
+ const alreadyAborted = controller.signal.aborted;
116
+ controller.abort(Object.assign(fail('provider_cancelled'), { nativeSignal: signal || 'SIGINT' }));
117
+ // Abort invokes the owned Claude listener exactly once. Only a later
118
+ // escalation forwards another signal; Codex owns an RPC turn instead.
119
+ if (alreadyAborted) ownedChild?.kill(signal);
120
+ return true;
121
+ };
122
+ // Match the existing process interface; stopLocalTask only signals this
123
+ // owned turn through kill(), never an original session process.
124
+ setImmediate(async () => {
125
+ try {
126
+ const emit = event => child.stdout.write(`${JSON.stringify(event)}\n`);
127
+ if (selection.provider === 'claude') await runClaudeDelegation({ ...input, selection,
128
+ command: claudeCommand, readClaude, inspectClaudeAccount, accountMatches, signal: controller.signal, emit, onChild: value => { ownedChild = value; } });
129
+ else await runCodexDelegation({ ...input, selection, signal: controller.signal, openClient, emit, readSupervision, accountMatches, onWorkspaceAuthorized });
130
+ child.stdout.end(); child.stderr.end(); child.emit('close', 0);
131
+ } catch (error) {
132
+ const message = fail(error.code || 'provider_result_unknown').message;
133
+ child.stdout.write(`${JSON.stringify({ type: 'simy.delegation.state', state: error.delegationState || (selection.provider === 'claude' && !ownedChild ? 'failed' : 'result_unknown') })}\n`);
134
+ child.stdout.end(`${JSON.stringify(selection.provider === 'claude' ?
135
+ { type: 'result', is_error: true, result: message } : { type: 'turn.failed', error: { message } })}\n`);
136
+ child.stderr.end(); child.emit('close', 1);
137
+ }
138
+ });
139
+ return child;
140
+ },
141
+ };
142
+ }
143
+
144
+ async function runCodexDelegation({ selection, instruction, getInstruction, repositoryPath, permissionMode, signal, openClient, emit, beforeDispatch, readSupervision, accountMatches, nativeIdentity, workspaceMode, onWorkspaceAuthorized, dispatchNative = send => send() }) {
145
+ if (signal.aborted) throw Object.assign(fail('provider_cancelled'), { delegationState: 'stopped' });
146
+ const preserveSupervision = async () => {
147
+ if ((await readSupervision(selection))?.run) throw fail('existing_supervised_task');
148
+ };
149
+ let client;
150
+ try { await preserveSupervision(); client = await openClient({}); }
151
+ catch (error) { error.delegationState = 'failed'; throw error; }
152
+ let turnId, starting, primaryError, completed = false, unsubscribe = () => {};
153
+ try {
154
+ signal.throwIfAborted();
155
+ const account = await client.request('account/read', { refreshToken: false });
156
+ if (!accountMatches(account.account?.email)) throw fail('provider_account_mismatch');
157
+ const metadata = await readCodexSession(client, selection.session_id, { signal });
158
+ if (metadata.isRunning === true) throw fail('provider_session_busy');
159
+ const configuration = await client.request('thread/resume', { threadId: selection.session_id, excludeTurns: true });
160
+ if (configuration.thread?.id !== selection.session_id || configuration.model !== selection.model ||
161
+ !configuration.cwd || await realpath(configuration.cwd) !== await realpath(nativeIdentity ? metadata.cwd : repositoryPath) ||
162
+ workspaceMode === 'repository' && await realpath(configuration.cwd) !== await realpath(repositoryPath)) throw fail('provider_session_mismatch');
163
+ // Do not mutate provider permissions. An incompatible requested scope must
164
+ // be resolved in the original provider, never silently widened here.
165
+ if (permissionMode === 'read_only' ? configuration.sandbox?.type !== 'readOnly' :
166
+ configuration.sandbox?.type !== 'workspaceWrite' || configuration.sandbox.networkAccess !== false) throw fail('provider_permission_mismatch');
167
+ if (configuration.sandbox?.type === 'workspaceWrite') {
168
+ const roots = configuration.sandbox.writableRoots ?? [];
169
+ if (!Array.isArray(roots) || roots.some(root => typeof root !== 'string' || !isAbsolute(root))) throw fail('provider_permission_mismatch');
170
+ await onWorkspaceAuthorized?.([configuration.cwd, ...roots]);
171
+ }
172
+ await beforeDispatch?.();
173
+ signal.throwIfAborted();
174
+ await preserveSupervision();
175
+ const currentAccount = await client.request('account/read', { refreshToken: false });
176
+ if (!accountMatches(currentAccount.account?.email)) throw fail('provider_account_mismatch');
177
+ await beforeDispatch?.();
178
+ signal.throwIfAborted();
179
+ const currentInstruction = getInstruction ? await getInstruction() : instruction;
180
+ if (typeof currentInstruction !== 'string' || !currentInstruction.trim()) throw fail('provider_instruction_unavailable');
181
+ await new Promise((resolve, reject) => {
182
+ let started = false, hasResult = false, settled = false;
183
+ let cleanup = () => {};
184
+ const finish = error => {
185
+ if (settled) return;
186
+ settled = true; cleanup();
187
+ if (error) reject(error); else resolve();
188
+ };
189
+ const buffered = [];
190
+ const consume = (method, params, requestId) => {
191
+ if (settled) return;
192
+ if (params.threadId !== selection.session_id || (params.turnId || params.turn?.id) !== turnId) return;
193
+ if (requestId !== undefined) {
194
+ emit({ type: 'simy.delegation.question', question: { ...params, method }, request_id: requestId });
195
+ finish(fail('provider_input_required')); return;
196
+ }
197
+ if (method === 'item/completed' && params.item?.type === 'agentMessage' && typeof params.item.text === 'string') {
198
+ hasResult ||= Boolean(params.item.text.trim());
199
+ emit({ type: 'item.completed', item: { type: 'agent_message', text: params.item.text } });
200
+ }
201
+ if (method === 'turn/completed') {
202
+ completed = true;
203
+ if (params.turn.status === 'completed' && hasResult) finish();
204
+ else finish(fail('provider_turn_failed'));
205
+ }
206
+ };
207
+ unsubscribe = client.onNotification((method, params, requestId) => {
208
+ if (!started) { if (buffered.length < 100) buffered.push([method, params, requestId]); return; }
209
+ consume(method, params, requestId);
210
+ });
211
+ const cancelled = () => finish(fail('provider_cancelled'));
212
+ signal.addEventListener('abort', cancelled, { once: true });
213
+ const closed = () => finish(fail('provider_result_unknown'));
214
+ client.child.once('close', closed);
215
+ cleanup = () => { signal.removeEventListener('abort', cancelled); client.child.off('close', closed); };
216
+ // The official provider writer lock acquired by thread/resume arbitrates
217
+ // races with the original app. No busy fallback, steer, or second task.
218
+ if (signal.aborted) { finish(fail('provider_cancelled')); return; }
219
+ starting = dispatchNative(() => client.request('turn/start', { threadId: selection.session_id, model: selection.model,
220
+ input: [{ type: 'text', text: currentInstruction }] })).then(response => {
221
+ turnId = response?.turn?.id; started = true;
222
+ if (!turnId) finish(fail('provider_result_unknown'));
223
+ else { emit({ type: 'thread.started', thread_id: selection.session_id }); for (const event of buffered) consume(...event); }
224
+ }, finish);
225
+ });
226
+ emit({ type: 'turn.completed' });
227
+ } catch (error) {
228
+ primaryError = error;
229
+ if (!starting || completed) error.delegationState = 'failed';
230
+ throw error;
231
+ } finally {
232
+ unsubscribe();
233
+ // A cancelled request may have been accepted before its response arrived.
234
+ // Collect that exact ID before interrupting; never issue another start.
235
+ if (starting && !turnId) await starting.catch(() => {});
236
+ if (turnId && !completed && !client.closed) {
237
+ try {
238
+ await client.request('turn/interrupt', { threadId: selection.session_id, turnId }, 2000);
239
+ if (primaryError?.code === 'provider_input_required') primaryError.delegationState = 'needs_user';
240
+ if (primaryError?.code === 'provider_cancelled') primaryError.delegationState = 'stopped';
241
+ } catch {
242
+ if (primaryError) primaryError.delegationState = 'result_unknown';
243
+ }
244
+ }
245
+ // Transport cleanup cannot erase a known terminal result or primary error.
246
+ try { await client.close(); } catch { /* The submitted turn is never replayed. */ }
247
+ }
248
+ }
249
+
250
+ async function runClaudeDelegation({ selection, repositoryPath, command, readClaude,
251
+ inspectClaudeAccount, signal, emit, beforeDispatch, onChild, accountMatches, nativeIdentity, workspaceMode, dispatchNative = send => send() }) {
252
+ signal.throwIfAborted();
253
+ const metadata = await readClaude(selection.session_id);
254
+ if (!metadata?.cwd || (!nativeIdentity || workspaceMode === 'repository') && await realpath(metadata.cwd) !== await realpath(repositoryPath)) throw fail('provider_session_mismatch');
255
+ const nativeCwd = await realpath(metadata.cwd);
256
+ const account = await inspectClaudeAccount(command.bin);
257
+ if (account.loggedIn !== true || !accountMatches(account.email)) throw fail('provider_account_mismatch');
258
+ await beforeDispatch?.();
259
+ signal.throwIfAborted();
260
+ // Recheck the official identity after asynchronous lease/link guards, just as
261
+ // the Codex adapter does. A selected account cannot change while waiting.
262
+ const currentAccount = await inspectClaudeAccount(command.bin);
263
+ if (currentAccount.loggedIn !== true || !accountMatches(currentAccount.email)) throw fail('provider_account_mismatch');
264
+ await beforeDispatch?.();
265
+ signal.throwIfAborted();
266
+ await new Promise((resolve, reject) => {
267
+ let pending = '', terminal = false, nativeSession = null, question = false, terminalFailed = false, invalidStream = false, settled = false;
268
+ const decoder = new StringDecoder('utf8');
269
+ const child = dispatchNative(() => command.spawn(command.bin, command.args, { cwd: nativeCwd,
270
+ env: { ...process.env, ...command.env }, stdio: ['ignore', 'pipe', 'pipe'] }));
271
+ // This is only the newly forked process; the original owner is never signalled.
272
+ onChild?.(child);
273
+ const cancel = () => child.kill(signal.reason?.nativeSignal || 'SIGINT');
274
+ signal.addEventListener('abort', cancel, { once: true });
275
+ const cleanup = () => signal.removeEventListener('abort', cancel);
276
+ child.stderr?.resume();
277
+ const consume = line => {
278
+ let event; try { event = JSON.parse(line); } catch { return; }
279
+ if (!event || typeof event !== 'object' || Array.isArray(event)) return;
280
+ if (event.type === 'system' && event.subtype === 'init') {
281
+ if (!UUID.test(event.session_id || '') || event.session_id === selection.session_id || event.model !== selection.model ||
282
+ nativeSession && nativeSession !== event.session_id) {
283
+ invalidStream = true; child.kill('SIGINT'); return;
284
+ }
285
+ nativeSession = event.session_id;
286
+ emit({ type: 'simy.delegation.session', session_id: nativeSession });
287
+ }
288
+ if (!nativeSession || event.session_id && event.session_id !== nativeSession) return;
289
+ if (event.type === 'result') {
290
+ const denials = Array.isArray(event.permission_denials) ? event.permission_denials : [];
291
+ const questions = denials.flatMap(item => Array.isArray(item?.tool_input?.questions) ? item.tool_input.questions : []);
292
+ const message = questions.map(item => item?.question).filter(item => typeof item === 'string').join('\n') ||
293
+ (typeof event.result === 'string' && event.result.trim() ? event.result : null) ||
294
+ (Array.isArray(event.errors) ? event.errors.filter(item => typeof item === 'string').join('\n') : '') ||
295
+ denials.map(item => item?.tool_input?.description || item?.tool_input?.command).filter(item => typeof item === 'string').join('\n');
296
+ terminal = typeof event.result === 'string' && Boolean(event.result.trim()) || denials.length > 0 ||
297
+ Array.isArray(event.errors) && event.errors.some(item => typeof item === 'string' && item.trim());
298
+ terminalFailed = event.is_error === true || String(event.subtype || '').startsWith('error');
299
+ if (denials.length && !message) invalidStream = true;
300
+ if (denials.length && message) {
301
+ question = true;
302
+ emit({ type: 'simy.delegation.question', question: { message, permission_denials: denials, ...(questions.length ? { questions } : {}) } });
303
+ event.is_error = true;
304
+ }
305
+ }
306
+ if (event.type === 'result' && typeof event.result === 'string')
307
+ emit({ type: 'assistant', message: { content: [{ type: 'text', text: event.result }] } });
308
+ emit(event);
309
+ };
310
+ child.stdout.on('data', chunk => {
311
+ if (settled) return;
312
+ pending += decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
313
+ if (Buffer.byteLength(pending) > 256 * 1024) { invalidStream = true; child.kill('SIGINT'); return; }
314
+ const lines = pending.split('\n'); pending = lines.pop();
315
+ for (const line of lines) consume(line);
316
+ });
317
+ child.once('error', error => { if (settled) return; settled = true; cleanup(); const failure = fail('provider_result_unknown');
318
+ if (['ENOENT', 'EACCES'].includes(error?.code) && !child.pid) failure.delegationState = 'failed';
319
+ reject(failure); });
320
+ child.once('close', (code, closeSignal) => {
321
+ if (settled) return; settled = true;
322
+ cleanup();
323
+ pending += decoder.end();
324
+ if (!invalidStream && pending.trim()) consume(pending);
325
+ const rejectState = (reason, state) => reject(Object.assign(fail(reason), { delegationState: state }));
326
+ if (invalidStream || !nativeSession) rejectState('provider_result_unknown', 'result_unknown');
327
+ else if (signal.aborted) rejectState('provider_cancelled',
328
+ code === 130 || closeSignal === 'SIGINT' ? 'stopped' : 'result_unknown');
329
+ else if (question && terminal) rejectState('provider_input_required', 'needs_user');
330
+ else if (terminalFailed && terminal) rejectState('provider_turn_failed', 'failed');
331
+ else if (code !== 0 || !terminal) rejectState('provider_result_unknown', 'result_unknown');
332
+ else resolve();
333
+ });
334
+ if (signal.aborted) cancel();
335
+ });
336
+ }
package/src/runner.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { safetyCeilingRecoveryMessage } from "./orchestrator/recovery.js";
1
2
  import { captureDispatchProvenance } from './update-detachment/provenance.js';
2
3
  import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, observeDispatchEvent, queueDispatchReceipt } from './update-detachment/dispatch-gate.js';
3
4
  import { execFile, execFileSync } from "node:child_process";
@@ -258,7 +259,9 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, loc
258
259
  (persistedState === "failed" && restoredSnapshot.metadata?.controller_result_recovery)
259
260
  ? persistedState : null;
260
261
  const objectiveCheck = verifyObjective(restoredSnapshot.charter);
261
- const savedGoalUnproven = (persistedState === "merge_ready" || persistedState === "pr_ready_for_review") &&
262
+ // Snapshots predating this flag still carry the original review requirement.
263
+ const savedGoalUnproven = restoredSnapshot.charter.independent_review_required !== false &&
264
+ (persistedState === "merge_ready" || persistedState === "pr_ready_for_review") &&
262
265
  !hasUserOutcomeProof(restoredSnapshot.attempts?.at(-1)?.independent_audit?.coverage_review);
263
266
  const objectiveUnverified = !objectiveCheck.valid || objectiveCheck.legacy || savedGoalUnproven;
264
267
  const restoredState = restoredFromState || objectiveUnverified ? "waiting_human" : persistedState;
@@ -459,7 +462,7 @@ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
459
462
  }
460
463
  const currentBudget = refreshBudgetState(run.snapshot);
461
464
  if (currentBudget.retry_exhausted) {
462
- throw new Error("The Agentic Loop run also used its retry budget. Start a new run to continue.");
465
+ throw new Error(safetyCeilingRecoveryMessage("attempt_ceiling_reached", currentBudget));
463
466
  }
464
467
  const previousBudget = currentBudget.token_budget;
465
468
  const previousAudit = run.snapshot.final_audit;
@@ -0,0 +1,34 @@
1
+ import { open, realpath } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { resolve, relative, isAbsolute } from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { evidenceExcerpt } from '../codex-plugins/simy/hooks/purpose-evidence.mjs';
6
+
7
+ // Read only original file predicates in the authorized task directory. These
8
+ // are current independent controller reads, not worker claims or authored proof.
9
+ export async function captureAcceptanceReads(cwd, criteria, { signal } = {}) {
10
+ const root = await realpath(cwd), rows = [], seen = new Set(); let retained = 0;
11
+ for (const criterion of criteria) {
12
+ signal?.throwIfAborted();
13
+ const check = criterion.check, target = check?.exactPath || check?.path;
14
+ if (!['file_equals', 'file_contains', 'file_exists'].includes(check?.type) || typeof target !== 'string' || !target || seen.has(target) || rows.length >= 8) continue;
15
+ seen.add(target); let handle;
16
+ try {
17
+ const path = await realpath(resolve(root, target)), within = relative(root, path);
18
+ if (!within || within === '..' || within.startsWith('../') || within.startsWith('..\\') || isAbsolute(within)) continue;
19
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
20
+ const info = await handle.stat(); if (!info.isFile() || info.size > 32768 || retained + info.size > 65536) continue;
21
+ const data = Buffer.alloc(32769), { bytesRead } = await handle.read(data, 0, data.length, 0);
22
+ if (bytesRead > 32768) continue;
23
+ const body = data.subarray(0, bytesRead); retained += bytesRead;
24
+ const digest = createHash('sha256').update(body).digest('hex');
25
+ rows.push({ id: `file:${target}`, source: 'controller_file_read', status: 'observed', at: Date.now(), digest,
26
+ recipe: { type: 'file_read', path: target }, observation: evidenceExcerpt(bytesRead ? body.toString('utf8') : JSON.stringify({ byteLength: 0, content: '' }), 4000) });
27
+ } catch { signal?.throwIfAborted(); /* Missing/large/unreadable files stay unverified; tool investigation remains available. */ }
28
+ finally { await handle?.close(); }
29
+ }
30
+ return rows;
31
+ }
32
+ export const currentAcceptanceRead = (item, now) => item?.source === 'controller_file_read' && item.status === 'observed' &&
33
+ item.recipe?.type === 'file_read' && typeof item.recipe.path === 'string' && Number.isFinite(item.at) && item.at <= now && now - item.at < 60000 &&
34
+ /^[a-f0-9]{64}$/.test(item.digest) && typeof item.observation?.text === 'string';
@@ -0,0 +1,138 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, realpath, readFile, mkdtemp, writeFile, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { PNG } from 'pngjs';
9
+ import { canonicalize } from '../sqm/canonical.js';
10
+
11
+ const exec = promisify(execFile);
12
+ const collectedManifests = new WeakSet();
13
+ const digest = bytes => `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
14
+ const fail = code => Object.assign(new Error(code), { code });
15
+ const hashPattern = /^sha256:[a-f0-9]{64}$/;
16
+
17
+ // These are explicit read contracts, never commands or permission grants. A trusted
18
+ // controller supplies both the contract and authorize; worker manifests are not input.
19
+ export async function observeArtifacts(contract, { root, authorize, signal, adapters = {}, now = Date.now } = {}) {
20
+ if (!contract?.revision || !Array.isArray(contract.targets) || !contract.targets.length ||
21
+ new Set(contract.targets.map(t => t.id)).size !== contract.targets.length) throw fail('invalid_contract');
22
+ if (typeof authorize !== 'function') throw fail('authorization_required');
23
+ const snapshot = structuredClone(contract);
24
+ const contractDigest = digest(canonicalize(snapshot));
25
+ const targets = [];
26
+ for (const target of snapshot.targets) {
27
+ if (!target.id || !target.requirementId || !target.destination || !hashPattern.test(target.digest) ||
28
+ !Number.isSafeInteger(target.unitCount) || target.unitCount < 1 ||
29
+ !Array.isArray(target.expectedText) || target.expectedText.length !== target.unitCount ||
30
+ target.expectedText.some(text => typeof text !== 'string' || !text.trim())) throw fail('invalid_target');
31
+ await authorize(target); signal?.throwIfAborted();
32
+ let observation;
33
+ try {
34
+ const adapter = Object.hasOwn(adapters, target.kind) ? adapters[target.kind] :
35
+ ['file', 'code', 'pdf', 'pptx'].includes(target.kind) ? localArtifactAdapter : null;
36
+ if (typeof adapter !== 'function') throw fail('unsupported_kind');
37
+ observation = await adapter(target, { root, signal, authorize });
38
+ const units = observation.units || [];
39
+ const complete = units.length === target.unitCount && new Set(units.map(u => u.index)).size === target.unitCount &&
40
+ units.every(u => Number.isSafeInteger(u.index) && u.index >= 1 && u.index <= target.unitCount &&
41
+ u.read === true && hashPattern.test(u.digest) &&
42
+ (!['pdf', 'pptx'].includes(target.kind) || u.rendered === true && hashPattern.test(u.renderDigest)));
43
+ const reasons = [];
44
+ if (observation.destination !== target.destination) reasons.push('destination_mismatch');
45
+ if (observation.digest !== target.digest) reasons.push('revision_mismatch');
46
+ if (!complete || observation.unitCount !== target.unitCount) reasons.push('incomplete_coverage');
47
+ if (complete && units.some(u => typeof u.text !== 'string' || !u.text.includes(target.expectedText[u.index - 1]))) reasons.push('content_mismatch');
48
+ observation = { ...observation, status: reasons.length ? 'unmet' : 'verified', reasons };
49
+ } catch (error) {
50
+ signal?.throwIfAborted();
51
+ observation = { status: 'unconfirmed', reasons: [error.code === 'ENOENT' ? 'reader_or_renderer_unavailable' :
52
+ ['unsupported_kind', 'outside_root', 'not_regular_file', 'too_large', 'invalid_pdf', 'changed_during_read'].includes(error.code) ? error.code : 'observation_failed'] };
53
+ }
54
+ await authorize(target); signal?.throwIfAborted();
55
+ targets.push({ ...observation, id: target.id, requirementId: target.requirementId, kind: target.kind,
56
+ evidenceKind: ['pdf', 'pptx'].includes(target.kind) ? 'artifact_render_readback' : 'artifact_readback',
57
+ source: 'controller_read_only_observer', observedAt: now() });
58
+ }
59
+ const status = targets.some(t => t.status === 'unmet') ? 'unmet' : targets.every(t => t.status === 'verified') ? 'verified' : 'unconfirmed';
60
+ const manifest = { schemaVersion: 1, revision: snapshot.revision, contractDigest, status,
61
+ expectedCount: snapshot.targets.length, inspectedCount: targets.filter(t => t.units?.length).length, targets,
62
+ scope: 'declared_destination_bytes_unit_coverage_and_text_only' };
63
+ const result = { ...manifest, digest: digest(canonicalize(manifest)) };
64
+ collectedManifests.add(result);
65
+ return result;
66
+ }
67
+
68
+ async function scopedRead(root, destination) {
69
+ const base = await realpath(root);
70
+ const requested = path.resolve(base, destination);
71
+ const resolved = await realpath(requested);
72
+ if (!resolved.startsWith(`${base}${path.sep}`)) throw fail('outside_root');
73
+ const stat = await lstat(requested);
74
+ if (!stat.isFile() || stat.isSymbolicLink()) throw fail('not_regular_file');
75
+ if (stat.size > 32 * 1024 * 1024) throw fail('too_large');
76
+ const bytes = await readFile(resolved);
77
+ if (bytes.length > 32 * 1024 * 1024) throw fail('too_large');
78
+ return bytes;
79
+ }
80
+
81
+ export async function localArtifactAdapter(target, { root, signal, authorize, execute = exec } = {}) {
82
+ const bytes = await scopedRead(root, target.destination);
83
+ const sourceDigest = digest(bytes);
84
+ let units;
85
+ if (target.kind === 'file' || target.kind === 'code') {
86
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
87
+ units = [{ index: 1, read: true, digest: sourceDigest, text }];
88
+ } else if (target.kind === 'pdf' || target.kind === 'pptx') {
89
+ // Render an immutable snapshot; never allow document content to choose commands.
90
+ const temp = await mkdtemp(path.join(tmpdir(), 'simy-artifact-observer-'));
91
+ try {
92
+ const input = path.join(temp, 'input.pdf');
93
+ const options = { signal, timeout: 30000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' } };
94
+ let slideCount = null;
95
+ if (target.kind === 'pptx') {
96
+ const presentation = path.join(temp, 'input.pptx');
97
+ await writeFile(presentation, bytes, { mode: 0o600 });
98
+ const listing = await execute('unzip', ['-Z1', presentation], options);
99
+ slideCount = listing.stdout.split(/\r?\n/).filter(name => /^ppt\/slides\/slide[1-9]\d*\.xml$/.test(name)).length;
100
+ if (!slideCount || slideCount > 500) throw fail('invalid_pdf');
101
+ await authorize(target); signal?.throwIfAborted();
102
+ await execute('soffice', [`-env:UserInstallation=${pathToFileURL(path.join(temp, 'profile')).href}`,
103
+ '--headless', '--convert-to', 'pdf:impress_pdf_Export:{"ExportHiddenSlides":{"type":"boolean","value":"true"}}',
104
+ '--outdir', temp, presentation], { ...options, timeout: 60000 });
105
+ } else await writeFile(input, bytes, { mode: 0o600 });
106
+ const info = await execute('pdfinfo', [input], options);
107
+ const count = Number(info.stdout.match(/^Pages:\s+(\d+)\s*$/m)?.[1]);
108
+ if (!Number.isSafeInteger(count) || count < 1 || count > 500 || slideCount !== null && count !== slideCount) throw fail('invalid_pdf');
109
+ units = [];
110
+ for (let index = 1; index <= count; index++) {
111
+ await authorize(target); signal?.throwIfAborted();
112
+ const prefix = path.join(temp, `page-${index}`);
113
+ await execute('pdftoppm', ['-f', String(index), '-l', String(index), '-singlefile', '-scale-to', '1200', '-png', input, prefix], options);
114
+ const png = await readFile(`${prefix}.png`);
115
+ const image = PNG.sync.read(png);
116
+ if (!image.width || !image.height) throw fail('invalid_pdf');
117
+ const { stdout: text } = await execute('pdftotext', ['-f', String(index), '-l', String(index), input, '-'], options);
118
+ units.push({ index, read: true, rendered: true, digest: digest(text), renderDigest: digest(png), text });
119
+ }
120
+ } finally { await rm(temp, { recursive: true, force: true }); }
121
+ } else throw fail('unsupported_kind');
122
+ if (digest(await scopedRead(root, target.destination)) !== sourceDigest) throw fail('changed_during_read');
123
+ return { destination: target.destination, digest: sourceDigest, unitCount: units.length, units };
124
+ }
125
+
126
+ // Only consume an in-memory controller-produced manifest after current-contract
127
+ // comparison. The digest detects corruption, not authenticity of a worker JSON file.
128
+ export function artifactRequirementEvidence(manifest, contract) {
129
+ if (!manifest || !collectedManifests.has(manifest)) return [];
130
+ const { digest: claimed, ...payload } = manifest;
131
+ if (claimed !== digest(canonicalize(payload)) || manifest.contractDigest !== digest(canonicalize(contract)) ||
132
+ manifest.revision !== contract.revision || manifest.status !== 'verified' ||
133
+ manifest.expectedCount !== contract.targets.length || manifest.targets.length !== contract.targets.length) return [];
134
+ return manifest.targets.map(target => ({ requirementId: target.requirementId, observations: [{
135
+ ref: `artifact:${target.id}`, target: target.destination, digest: target.digest,
136
+ scope: manifest.scope,
137
+ }] }));
138
+ }