@awak-app/simy-cli 0.7.16 → 0.7.18
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 +180 -26
- package/package.json +3 -2
- package/src/agent.js +128 -7
- package/src/bounded-local-task-subtask-pool.js +32 -6
- package/src/bounded-local-task-subtasks-contract.js +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +2 -2
- package/src/codex-plugins/simy/hooks/README.md +8 -3
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +261 -67
- package/src/codex-plugins/simy/hooks/hooks.json +1 -1
- package/src/codex-plugins/simy/hooks/loop-kernel/conformance.json +136 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/index.mjs +42 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/outcome-review.mjs +31 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/pin.json +6 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/policy.json +141 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/task-pool.mjs +34 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +132 -0
- package/src/codex-plugins/simy/hooks/loop-kernel.mjs +6 -0
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +15 -3
- package/src/codex-plugins/simy/hooks/purpose-completion-contract.mjs +10 -0
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +8 -6
- package/src/codex-plugins/simy/hooks/result-cycle.mjs +32 -6
- package/src/codex-plugins/simy/hooks/work-frontier.mjs +2 -0
- package/src/codex-plugins/simy/skills/diagnose/SKILL.md +10 -4
- package/src/coding-run-recovery.js +4 -2
- package/src/delivery-hooks/claude-workflow.md +10 -7
- package/src/delivery-hooks/command.js +45 -10
- package/src/delivery-hooks/consult.js +60 -0
- package/src/delivery-hooks/diagnosis.js +2 -2
- package/src/delivery-hooks/legacy-hook-routing.js +46 -8
- package/src/delivery-hooks/local-runtime.js +50 -1
- package/src/delivery-hooks/local-supervisor.js +1 -1
- package/src/delivery-hooks/repair-continuation.js +80 -0
- package/src/delivery-hooks/repair.js +164 -0
- package/src/delivery-hooks/supervisor-service.js +3 -1
- package/src/delivery-hooks/supervisor.js +88 -12
- package/src/desktop-access.js +2 -1
- package/src/desktop-host.js +62 -2
- package/src/durable-local-task-worker.js +372 -58
- package/src/execution-capability-contract.js +15 -1
- package/src/index.js +3 -3
- package/src/local-photos-read.swift +33 -0
- package/src/local-photos.js +47 -0
- package/src/local-task-attachment-store.js +31 -0
- package/src/local-task-destination.js +174 -0
- package/src/local-task.js +119 -7
- package/src/native-delegation-contract.js +68 -0
- package/src/native-provider-identity.js +60 -0
- package/src/orchestrator/budget.js +22 -11
- package/src/orchestrator/independent-audit.js +9 -18
- package/src/orchestrator/loop.js +27 -59
- package/src/orchestrator/recovery.js +32 -2
- package/src/provider-delegation.js +336 -0
- package/src/runner.js +2 -1
- package/src/session-supervision/artifact-observer.js +138 -0
- package/src/session-supervision/claude-sessions.js +5 -3
- package/src/session-supervision/codex-app-server.js +20 -5
- package/src/session-supervision/completion-artifacts.js +44 -0
- package/src/session-supervision/consistency-auditor.js +8 -11
- package/src/session-supervision/continuation-analysis.js +46 -28
- package/src/session-supervision/discovery-window.js +1 -1
- package/src/session-supervision/final-response-recovery.js +5 -3
- package/src/session-supervision/index.js +25 -8
- package/src/session-supervision/purpose-recovery.js +12 -3
- package/src/session-supervision/purpose-verification.js +9 -57
- package/src/session-supervision/recovery-frontier.js +55 -0
- package/src/session-supervision/remote-hosts.js +1 -1
- package/src/session-supervision/stop-notifications.js +8 -8
- package/src/sqm/agent-skill.js +1 -1
- package/src/sqm/checkers.js +12 -6
- package/src/sqm/claude-plugin-update.js +22 -8
- package/src/sqm/claude-plugin.js +4 -0
- package/src/sqm/local-tests.js +2 -2
- package/src/sqm/plugin-cache.js +67 -0
- package/src/sqm/proof.js +25 -0
- package/src/sqm/report.js +1 -0
- package/src/sqm/workflow-onboarding.js +3 -39
- package/src/sqm/workflow-update.js +17 -6
- package/src/team-status-fence-store.js +32 -0
- package/src/team-threads.js +478 -0
|
@@ -64,7 +64,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
|
|
|
64
64
|
try {
|
|
65
65
|
let run = record.run;
|
|
66
66
|
if (run?.managed?.providerCapabilityWait) continue;
|
|
67
|
-
if (upgradeEligible(record) && (policyAdoptionPending(record) || run.managed.controllerContractVersion !== 2 || run.state === 'paused' || run.state === 'verification_pending')) {
|
|
67
|
+
if (upgradeEligible(record) && (policyAdoptionPending(record) || run.managed.purpose?.enabled !== true || run.managed.purpose?.version !== 2 || run.managed.purpose?.decisionPolicy !== 'llm-first-hc102' || run.managed.controllerContractVersion !== 2 || run.state === 'paused' || run.state === 'verification_pending')) {
|
|
68
68
|
let metadata, migrationLink;
|
|
69
69
|
try { migrationLink = await requireProviderLink({ root, provider: record.provider }); metadata = await observeMetadata(record); }
|
|
70
70
|
catch { metadata = null; }
|
|
@@ -99,6 +99,9 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
|
|
|
99
99
|
}
|
|
100
100
|
if (!purposeEnabled(run) || run.managed.resumeChannel !== 'codex_app_server') continue;
|
|
101
101
|
if (!automaticContinuationAllowed(record)) continue;
|
|
102
|
+
// A retained lease owns the recovery, regardless of its age. Only the
|
|
103
|
+
// existing supervisor may reconcile a confirmed dead owner.
|
|
104
|
+
if (run.managed.resumeLease || run.state === 'auto_resuming') continue;
|
|
102
105
|
let link;
|
|
103
106
|
try { link = await requireProviderLink({ root, provider: record.provider }); } catch { continue; }
|
|
104
107
|
const authorized = async () => {
|
|
@@ -309,20 +312,26 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
|
|
|
309
312
|
!run.managed.autoRecovery && now >= run.updatedAt + 15_000) {
|
|
310
313
|
const code = run.managed.attempts?.at(-1)?.errorCode;
|
|
311
314
|
if (code && !['checkpoint', 'checkpoint_rejected', 'closed', 'timeout', 'unavailable', 'turn_failed', 'provider_error', ...(searchEnabled(run.managed) ? ['permission_narrowed', 'permission_restore', 'manual_provider_action', 'unsupported'] : [])].includes(code)) continue;
|
|
312
|
-
obstacle = { type: 'execution_error', detail: code || run.recoveryCause
|
|
315
|
+
obstacle = { type: 'execution_error', detail: code || run.recoveryCause,
|
|
316
|
+
attemptId: run.managed.attempts?.at(-1)?.id || run.managed.executionAttempt?.id || null };
|
|
313
317
|
}
|
|
314
|
-
if (!obstacle) continue;
|
|
318
|
+
if (!obstacle || signal?.aborted) continue;
|
|
315
319
|
const key = createHash('sha256').update(JSON.stringify([run.managed.verifiedRevision, obstacle, obstacle.type === 'external_dependency' ? run.managed.purpose.dependencyChecks || 0 : null])).digest('hex');
|
|
316
320
|
await persist(async current => {
|
|
317
321
|
if (current?.run?.id !== run.id || current.run.revision !== run.revision || current.run.state !== run.state || current.run.updatedAt !== run.updatedAt) return current;
|
|
318
322
|
if (['native_wait_updated', 'native_wait_diagnosis'].includes(obstacle.type) && !nativeCurrent(current)) return current;
|
|
319
323
|
if (obstacle.type === 'research_wait' && (!nativeCurrent(current) || !canResearchReplan(current.run, record.provider, now))) return current;
|
|
324
|
+
const oneShot = ['idle_without_checkpoint', 'execution_error'].includes(obstacle.type);
|
|
325
|
+
if (oneShot && current.run.managed.purpose.lastStopObservationKey === key) return current;
|
|
326
|
+
if (signal?.aborted || current.run.managed.resumeLease) return current;
|
|
320
327
|
if (searchEnabled(current.run.managed) && ['original_context_updated', 'native_wait_updated'].includes(obstacle.type)) current.run.managed.purpose.nextSearchAt = 0;
|
|
321
328
|
const queued = queuePurposeDiagnosis(current.run,
|
|
322
329
|
obstacle.type === 'idle_without_checkpoint' || obstacle.type === 'execution_error' && ['checkpoint', 'checkpoint_rejected'].includes(obstacle.detail)
|
|
323
330
|
? finalResponseRecoveryInstruction
|
|
324
331
|
: `Reassess the original purpose after ${obstacle.type}: ${obstacle.detail}. Use the shared scene reasoning cycle to select the next useful check. Preserve unresolved approvals/dependencies; choose independent work or a safe alternative where possible, then return to the original unfinished work.`, key, now, obstacle.type);
|
|
332
|
+
if (queued && oneShot) current.run.managed.purpose.lastStopObservationKey = key;
|
|
325
333
|
if (queued) await continueResultCycle(current, resultEvent(current, { kind: 'reconciled', origin: 'reconcilePurposeTasks', sourceId: `${key}:${current.run.revision}`, now }));
|
|
334
|
+
|
|
326
335
|
if (queued && obstacle.type === 'external_dependency') {
|
|
327
336
|
const purpose = current.run.managed.purpose;
|
|
328
337
|
purpose.dependencyChecks = (purpose.dependencyChecks || 0) + 1;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { currentAcceptanceRead } from './acceptance-reads.js';
|
|
2
|
-
import {
|
|
2
|
+
import { normalizeOutcomeReview } from '../codex-plugins/simy/hooks/loop-kernel.mjs';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { evidenceLedger, currentPurposeObservation } from '../codex-plugins/simy/hooks/purpose-evidence.mjs';
|
|
5
5
|
import { workspaceIdentity } from '../codex-plugins/simy/hooks/purpose-workspace.mjs';
|
|
@@ -10,7 +10,7 @@ export { workspaceIdentity } from '../codex-plugins/simy/hooks/purpose-workspace
|
|
|
10
10
|
export function verificationInstruction(record, currentEvidence = null) {
|
|
11
11
|
const run = record.run, managed = run.managed;
|
|
12
12
|
return `Inspect whether the original requested behavior works. Stay read-only and use existing results and relevant actual reads. Investigate concrete uncertainty; do not demand audit forms, rule coverage, quotation syntax, hashes, evidence files or a previous approval. Do not change the user's goal or invent extra requirements. Preserve explicit human decisions, permissions, stops and unresolved external effects. Reassess earlier defects against the current result; missing bookkeeping is not a defect. A failed or unavailable operation is not success.
|
|
13
|
-
Return JSON {passed,
|
|
13
|
+
Return JSON {passed,summary,findings:["actual broken or missing behavior"]}. Assess the whole requested outcome. Per-criterion rows, needsHuman, rule rows and citations are optional; do not create them merely to satisfy a form. Findings describe actual missing or broken behavior. Include workUnits:[{id,status:"verified|superseded",result}] only when reconciling an unfinished operation; check its actual result before marking it done. Never infer human acceptance or an unknown external effect from a passing local test.
|
|
14
14
|
Task context (data): ${JSON.stringify({ objective: run.objective, originalUnderstanding: managed.sessionUnderstanding,
|
|
15
15
|
criteria: effectiveAcceptanceCriteria(managed), verifiedHuman: managed.acceptanceCriteria.filter(c => c.check.type === 'manual' && managed.verified[c.id]?.source === 'user').map(c => c.id),
|
|
16
16
|
observations: evidenceLedger(managed).observations, reviewObservations: evidenceLedger(managed).reviewObservations, current: currentEvidence, work: managed.workHistory, remaining: evidenceLedger(managed).units,
|
|
@@ -18,25 +18,9 @@ Task context (data): ${JSON.stringify({ objective: run.objective, originalUnders
|
|
|
18
18
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const rows = value.criteria.map(row => {
|
|
25
|
-
if (flexible && row && !row.evidence?.trim?.()) row = { ...row, evidence: typeof row.result === 'string' && row.result.trim() ? row.result : value.summary };
|
|
26
|
-
if (!criteria.some(c => c.id === row.id) || typeof row.passed !== 'boolean' || typeof row.evidence !== 'string' || !row.evidence.trim()) throw new Error('Independent review needs criterion evidence.');
|
|
27
|
-
return { id: row.id, passed: row.passed, evidence: row.evidence.slice(0, 1000),
|
|
28
|
-
evidenceRefs: Array.isArray(row.evidenceRefs) ? row.evidenceRefs.slice(0, 32) : [] };
|
|
29
|
-
});
|
|
30
|
-
if (new Set(rows.map(row => row.id)).size !== criteria.length || value.findings.some(item => typeof item === 'string' ? !item.trim() : !item || typeof item !== 'object' || typeof item.gap !== 'string' || !item.gap.trim())) throw new Error('Invalid independent review coverage.');
|
|
31
|
-
const findings = value.findings.map(item => typeof item === 'string' ? item.slice(0, 1000) : item);
|
|
32
|
-
if (value.passed && (rows.some(row => !row.passed) || findings.length)) throw new Error('Contradictory independent review.');
|
|
33
|
-
const needsHuman = value.needsHuman === true;
|
|
34
|
-
if (needsHuman && (value.passed || findings.length || !rows.some(row => !row.passed) || rows.some(row => !row.passed && criteria.find(c => c.id === row.id)?.check.type !== 'manual'))) throw new Error('Only explicit human criteria may require a human decision.');
|
|
35
|
-
if (value.resolutions !== undefined && (!Array.isArray(value.resolutions) || value.resolutions.length > 100)) throw new Error('Invalid finding resolutions.');
|
|
36
|
-
return { workUnits: Array.isArray(value.workUnits) ? value.workUnits.slice(0, 192) : [], manifest: value.manifest,
|
|
37
|
-
rules: Array.isArray(value.rules) ? value.rules.slice(0, 512).map(row => flexible && row && !row.basis?.trim?.() ? { ...row, basis: value.summary } : row) : [],
|
|
38
|
-
resolutions: value.resolutions || [], passed: value.passed, needsHuman, summary: value.summary.slice(0, 1000), criteria: rows, findings };
|
|
39
|
-
}
|
|
21
|
+
// Shared normalization preserves the verdict and actual failures without
|
|
22
|
+
// requiring criterion rows, citations, resolutions or other review paperwork.
|
|
23
|
+
export const normalizeVerification = normalizeOutcomeReview;
|
|
40
24
|
|
|
41
25
|
// Recognize only direct file readers. Arbitrary shell/Python commands and echo
|
|
42
26
|
// are useful investigation but not captured artifact inspection evidence.
|
|
@@ -98,42 +82,6 @@ const capturedReadText = item => {
|
|
|
98
82
|
return text; // Compatibility with direct plain-text capture fixtures.
|
|
99
83
|
}
|
|
100
84
|
};
|
|
101
|
-
// JSON sanitization reserializes whitespace before capture. Compare only
|
|
102
|
-
// insignificant JSON whitespace, never string values, redactions or missing data.
|
|
103
|
-
const jsonWhitespaceTokens = (text, fragment = false) => {
|
|
104
|
-
try { JSON.parse(text); } catch {
|
|
105
|
-
if (!fragment) return null;
|
|
106
|
-
try { JSON.parse(`{${text.replace(/,\s*$/, '')}}`); } catch { return null; }
|
|
107
|
-
}
|
|
108
|
-
// Preserve each scalar's complete spelling. Character substrings could turn
|
|
109
|
-
// a captured 123 into a claimed 1 (or match digits inside a quoted string).
|
|
110
|
-
return text.match(/"(?:\\[\s\S]|[^"\\])*"|true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|[{}\[\],:]/g);
|
|
111
|
-
};
|
|
112
|
-
const matchesJsonExcerpt = (body, part, excerpt) => part !== null
|
|
113
|
-
? body.some((_, index) => part.every((token, offset) => body[index + offset] === token))
|
|
114
|
-
: body.some(token => token.startsWith('"') && token.slice(1, -1) === excerpt);
|
|
115
|
-
const matchesCapturedExcerpt = (item, excerpt) => {
|
|
116
|
-
if (typeof excerpt !== 'string' || !excerpt.trim()) return false;
|
|
117
|
-
const captured = capturedReadText(item);
|
|
118
|
-
const body = jsonWhitespaceTokens(captured, true), part = jsonWhitespaceTokens(excerpt, true);
|
|
119
|
-
if (body !== null) {
|
|
120
|
-
// A cut scalar can still parse as JSON (123 cut to 1). Its boundary is unknown.
|
|
121
|
-
if (item.bodyObservation?.truncated ?? item.observation?.truncated) return false;
|
|
122
|
-
if (matchesJsonExcerpt(body, part, excerpt)) return true;
|
|
123
|
-
// Scoped observers wrap each selected JSON field in a bounded excerpt.
|
|
124
|
-
// Inspect that controller-created layer without accepting partial values.
|
|
125
|
-
const selected = item.source === 'controller_read_only_observer' && Array.isArray(item.recipe?.fields) ? JSON.parse(captured)?.selected : null;
|
|
126
|
-
return Array.isArray(selected) && selected.some(row => {
|
|
127
|
-
if (row?.found !== true || !item.recipe.fields.includes(row.path) || row.value?.truncated !== false || typeof row.value.text !== 'string') return false;
|
|
128
|
-
const field = jsonWhitespaceTokens(row.value.text);
|
|
129
|
-
return field !== null && matchesJsonExcerpt(field, part, excerpt);
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
// An incomplete JSON document cannot use the literal fallback to certify a
|
|
133
|
-
// partial scalar. Acquire a narrower complete read instead.
|
|
134
|
-
if (captured.trimStart().startsWith('{') || captured.trimStart().startsWith('[') || /^\s*"(?:\\[\s\S]|[^"\\])*"\s*:/.test(captured)) return false;
|
|
135
|
-
return captured.includes(excerpt);
|
|
136
|
-
};
|
|
137
85
|
const capturedDigestFormat = item => item.source === 'controller_file_read' ? 'file-content-sha256-v1' : item.source === 'controller_read_only_observer' ? 'observer-text-sha256-v1' :
|
|
138
86
|
item.bodyDigestFormat === 'command-output-sha256-v1' && /^[a-f0-9]{64}$/.test(item.bodyDigest) && typeof item.bodyObservation?.text === 'string' ? item.bodyDigestFormat : undefined;
|
|
139
87
|
const capturedContentDigest = item => item.source === 'controller_file_read' ? item.digest : capturedDigestFormat(item) === 'command-output-sha256-v1' ? item.bodyDigest : createHash('sha256').update(capturedReadText(item)).digest('hex');
|
|
@@ -161,6 +109,10 @@ export function assessIndependentInspection(review, { toolEvidence = [], observa
|
|
|
161
109
|
}
|
|
162
110
|
|
|
163
111
|
export function certifyReviewManifest(value, managed, toolEvidence, inventory) {
|
|
112
|
+
try { return checkedReviewManifest(value, managed, toolEvidence, inventory); }
|
|
113
|
+
catch { return null; }
|
|
114
|
+
}
|
|
115
|
+
function checkedReviewManifest(value, managed, toolEvidence, inventory) {
|
|
164
116
|
if (value === undefined) return null; // Legacy reviews keep whole-workspace safety.
|
|
165
117
|
if (value?.version !== 1 || !Array.isArray(value.exclusions) || value.exclusions.length > 32) throw new Error('Invalid review dependency manifest.');
|
|
166
118
|
const changedByWorker = (managed.purpose.evidence?.receipts || []).filter(row => row.kind === 'fileChange').map(row => row.observation?.text || '').join('\n');
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { evidenceLedger } from '../codex-plugins/simy/hooks/purpose-evidence.mjs';
|
|
2
|
+
import { reconcileWorkFrontier, revalidateWorkProposal } from '../codex-plugins/simy/hooks/work-frontier.mjs';
|
|
3
|
+
import { searchEnabled } from '../codex-plugins/simy/hooks/purpose-search.mjs';
|
|
4
|
+
import { taskDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
5
|
+
|
|
6
|
+
// Enroll the controller's canonical workspace, never a worker-provided lock ID.
|
|
7
|
+
// Read-only diagnosis/verification is the path that resolves observed effects;
|
|
8
|
+
// it must remain possible while the frontier prevents another writer.
|
|
9
|
+
export function recoveryWorkProposal(record) {
|
|
10
|
+
const managed = record.run.managed;
|
|
11
|
+
if (!searchEnabled(managed)) return null;
|
|
12
|
+
const ledger = evidenceLedger(managed);
|
|
13
|
+
const workspace = `workspace:${taskDirectory(record)}`;
|
|
14
|
+
try {
|
|
15
|
+
for (const unit of ledger.units) {
|
|
16
|
+
// Repair pre-migration understanding reads whose checkpoint settled but
|
|
17
|
+
// whose ledger occupancy was never released. Never settle an active or
|
|
18
|
+
// unknown write, or infer acceptance from the read's completion.
|
|
19
|
+
const attempt = managed.executionAttempt;
|
|
20
|
+
const liveAttempt = attempt?.decisionId === unit.id && attempt.phase !== 'checkpointed';
|
|
21
|
+
if (unit.action?.kind === 'read' && unit.status === 'dispatched' && !liveAttempt &&
|
|
22
|
+
managed.workHistory?.some(row => row.decisionId === unit.id && row.status === 'session_understanding' && row.result?.status === 'observed')) {
|
|
23
|
+
unit.status = 'observed';
|
|
24
|
+
}
|
|
25
|
+
if (!unit.action || ['verified', 'superseded', 'superseded_by_user_revision'].includes(unit.status)) continue;
|
|
26
|
+
unit.owner ||= managed.taskId;
|
|
27
|
+
if (unit.id.startsWith('wait:')) unit.waitReason ||= 'required_operation_observation';
|
|
28
|
+
unit.readResources = [...new Set((unit.readResources || []).map(id => id === 'task' ? workspace : id))];
|
|
29
|
+
unit.writeResources = [...new Set((unit.writeResources || []).map(id => id === 'task' ? workspace : id))];
|
|
30
|
+
if (unit.action.kind !== 'read' && !unit.writeResources.includes(workspace)) unit.writeResources.push(workspace);
|
|
31
|
+
}
|
|
32
|
+
// Missing PR readback stays blocked. This boundary never invents CI evidence.
|
|
33
|
+
const proposal = reconcileWorkFrontier(managed, { stopped: ['paused', 'stopped', 'cancelled'].includes(record.run.state) });
|
|
34
|
+
delete managed.frontierIssue;
|
|
35
|
+
return proposal;
|
|
36
|
+
} catch {
|
|
37
|
+
managed.frontierIssue = 'Existing work-unit ledger needs reconciliation before dispatch.';
|
|
38
|
+
return { blocked: true };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function admitRecoveryWork(record, proposal, leaseId, now) {
|
|
43
|
+
if (!proposal) return;
|
|
44
|
+
const managed = record.run.managed;
|
|
45
|
+
const unit = managed.purpose.evidence.units.find(row => row.id === managed.decision.id);
|
|
46
|
+
if (!unit && managed.decision.action.kind === 'read' && ['understanding', 'needs_clarification'].includes(managed.phase)) return;
|
|
47
|
+
if (!unit) throw new Error('Selected work unit is missing.');
|
|
48
|
+
const current = revalidateWorkProposal(managed, proposal, unit.id, {
|
|
49
|
+
stopped: ['paused', 'stopped', 'cancelled'].includes(record.run.state),
|
|
50
|
+
});
|
|
51
|
+
if (!current.eligible) {
|
|
52
|
+
throw new Error(`Work proposal needs reconciliation: ${current.reason || 'not eligible'}`);
|
|
53
|
+
}
|
|
54
|
+
unit.status = 'dispatched'; unit.dispatchedAt = now; unit.attemptId = leaseId;
|
|
55
|
+
}
|
|
@@ -13,7 +13,7 @@ export const remoteHostId = alias => `remote-ssh-discovered:${alias}`;
|
|
|
13
13
|
const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
14
14
|
const bootstrap = 'import { realpathSync } from "node:fs"; import { dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; const entry = join(dirname(realpathSync(process.argv[1])), "session-supervision", "remote-entry.js"); await import(pathToFileURL(entry).href);';
|
|
15
15
|
export const REMOTE_HOST_COMMAND = `exec sh -lc ${shellQuote(`exec env SIMY_REMOTE_CHILD=1 SIMY_AUTO_UPDATE=0 node --input-type=module -e ${shellQuote(bootstrap)} "$(command -v simy)"`)}`;
|
|
16
|
-
const allowedActions = new Set(['refresh', 'session_statistics', 'session_archive', 'session_restore', 'session_track', 'session_untrack',
|
|
16
|
+
const allowedActions = new Set(['refresh', 'session_set_pinned', 'session_statistics', 'session_archive', 'session_restore', 'session_track', 'session_untrack',
|
|
17
17
|
'session_monitor', 'session_unmonitor', 'session_enroll', 'session_set_auto_continue', 'session_analyze', 'session_continue', 'session_answer',
|
|
18
18
|
'session_deadline_decide', 'session_action_refresh', 'session_action_select', 'session_action_unlink',
|
|
19
19
|
'delivery_pause', 'delivery_stop', 'delivery_reject', 'delivery_resume', 'delivery_replan', 'delivery_approve',
|
|
@@ -4,14 +4,15 @@ import { deliveryStop } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
|
4
4
|
// Auto-continue works while nobody is watching, so a person must learn when it
|
|
5
5
|
// stops. Map a stopped run to a notification kind; the native window owns copy.
|
|
6
6
|
export function stopNotificationKind(run, now = Date.now()) {
|
|
7
|
-
if (!run?.managed?.autoContinueEpoch) return null;
|
|
7
|
+
if (!run?.managed?.autoContinueEpoch && !run?.managed?.repairRequest) return null;
|
|
8
8
|
// The clock keeps aging while paused; the persisted human decision takes
|
|
9
9
|
// precedence over a threshold that was reached only after that decision.
|
|
10
10
|
if (run.state === 'paused') return run.reason === 'Paused by user.' ? null : deliveryStop(run, now) ? 'paused' : null;
|
|
11
|
-
if (run.state === '
|
|
11
|
+
if (run.state === 'auto_resuming' && !run.managed.resumeLease?.owner) return 'needs_decision';
|
|
12
|
+
if (run.state === 'blocked' || run.state === 'verification_pending' || run.state === 'reported_done') return 'needs_decision';
|
|
12
13
|
if (run.state === 'recovery_required' && run.managed.autoRecovery !== true) return 'needs_decision';
|
|
13
14
|
if (run.state === 'failed' || run.state === 'budget_exhausted') return 'failed';
|
|
14
|
-
if (run.state === 'completed'
|
|
15
|
+
if (run.state === 'completed') return 'finished';
|
|
15
16
|
return null;
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -24,19 +25,18 @@ export function createStopNotifier({ emit, now = Date.now } = {}) {
|
|
|
24
25
|
let baseline = true;
|
|
25
26
|
return {
|
|
26
27
|
observe(records) {
|
|
27
|
-
const present = new Set();
|
|
28
28
|
for (const record of records) {
|
|
29
29
|
const run = record?.run, kind = stopNotificationKind(run, now());
|
|
30
30
|
const key = JSON.stringify([record.provider, record.session, run?.managed?.taskId]);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const id = createHash('sha256').update(JSON.stringify([record.provider, record.session, run.managed.taskId, run.state, run.revision, run.reason])).digest('hex').slice(0, 32);
|
|
31
|
+
if (!kind) continue;
|
|
32
|
+
const id = createHash('sha256').update(JSON.stringify([record.provider, record.session, run.managed.taskId, run.id, kind, run.blocker, run.reason])).digest('hex').slice(0, 32);
|
|
34
33
|
if (seen.get(key) === id) continue;
|
|
35
34
|
seen.set(key, id);
|
|
36
35
|
if (!baseline) emit({ type: 'notify', id, kind, provider: record.provider, session: record.session,
|
|
37
36
|
objective: String(run.objective || '').slice(0, 200) });
|
|
38
37
|
}
|
|
39
|
-
|
|
38
|
+
// Temporary provider absence or a diagnostic pass is not a new stop.
|
|
39
|
+
// Retain fingerprints until a materially different outcome is observed.
|
|
40
40
|
baseline = false;
|
|
41
41
|
},
|
|
42
42
|
};
|
package/src/sqm/agent-skill.js
CHANGED
|
@@ -40,7 +40,7 @@ Prefer the fixed local \`$HOME/.simy/bin/simy\` (or \`$SIMY_HOME/bin/simy\`) whe
|
|
|
40
40
|
3. Read \`proof.json\` and require \`outcome.status\` to be \`passed\`.
|
|
41
41
|
4. If either command fails, SQM is unavailable, or the proof is not passed, report the exact blocker. Do not claim SQM passed or silently bypass the requested check.
|
|
42
42
|
5. Verify the proof's repository, base, HEAD and working-tree/diff identity and its bound signature with SQM tooling. Use separate artifact paths outside the diff, and rerun after bound inputs change.
|
|
43
|
-
6. Include proof paths, outcome reasons, matched modules, evaluated rules and scenario statuses from \`executions\` in the PR evidence, separating blocking and advisory results. A pass with \`no_applicable_rules\`, zero scenario evaluations or advisory-only checks does not establish feature or historical incident coverage. Scenario statuses are deterministic evaluations, not browser/provider/deployed execution evidence. Retain relevant uncovered incidents and their concrete regression result or gap; do not promote incomplete delivery acceptance.
|
|
43
|
+
6. Include proof paths, outcome reasons, matched modules, evaluated rules and scenario statuses from \`executions\` in the PR evidence, separating blocking and advisory results. A pass with \`no_applicable_rules\`, zero scenario evaluations or advisory-only checks does not establish feature or historical incident coverage. Scenario statuses are deterministic evaluations, not browser/provider/deployed execution evidence. Retain relevant uncovered incidents and their concrete regression result or gap; do not promote incomplete delivery acceptance. Zero coverage alone is not a Delivery Loop missing_information blocker: investigate rule applicability and available verification, and continue authorized repair or testing. Keep requested SQM acceptance unmet until it is actually satisfied; alternative tests do not substitute for it. Use an information wait only for a specific question that requires the user's answer.
|
|
44
44
|
|
|
45
45
|
Do not replace SQM with an LLM review, and do not claim the check passed without reading the generated proof.
|
|
46
46
|
Use existing authorization for minimal related repair and recheck; do not request another continuation after ordinary blockers are resolved. LLM judgment is the base, relevant HC-01–HC-102 lenses are supplemental, and explicit stops, irreversible actions and real permissions remain boundaries.
|
package/src/sqm/checkers.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isAdvisoryGuidance, advisoryGuidanceDescriptions } from './advisory-guidance.js';
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { lstat, readFile } from "node:fs/promises";
|
|
3
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
6
|
import { lintKnowledgeModule } from "./reliability.js";
|
|
@@ -307,7 +307,9 @@ function activeExceptionPatterns(module) {
|
|
|
307
307
|
}
|
|
308
308
|
|
|
309
309
|
function selectPaths(paths, globs) {
|
|
310
|
-
|
|
310
|
+
const selected = paths.filter((file) => matchesAny(globs, file));
|
|
311
|
+
if (selected.length > MAX_FILES_PER_PATTERN) throw new Error("SQM inspection scope exceeds file limit; coverage is incomplete.");
|
|
312
|
+
return selected;
|
|
311
313
|
}
|
|
312
314
|
|
|
313
315
|
function matchesAny(globs, file) {
|
|
@@ -365,12 +367,16 @@ async function readText(root, relative) {
|
|
|
365
367
|
const resolved = path.resolve(root, relative);
|
|
366
368
|
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) throw new Error("SQM path escaped the repository root.");
|
|
367
369
|
try {
|
|
370
|
+
const actualRoot = await realpath(root);
|
|
371
|
+
const actualFile = await realpath(resolved);
|
|
372
|
+
if (!actualFile.startsWith(`${actualRoot}${path.sep}`)) throw new Error("SQM path escaped the repository root.");
|
|
368
373
|
const fileStat = await lstat(resolved);
|
|
369
|
-
if (!fileStat.isFile() || fileStat.isSymbolicLink())
|
|
370
|
-
|
|
371
|
-
|
|
374
|
+
if (!fileStat.isFile() || fileStat.isSymbolicLink()) throw new Error("Not a regular file.");
|
|
375
|
+
if (fileStat.size > MAX_FILE_BYTES) throw new Error("Unreadable text scope.");
|
|
376
|
+
const buffer = await readFile(actualFile);
|
|
377
|
+
if (buffer.length > MAX_FILE_BYTES || buffer.includes(0)) throw new Error("Unreadable text scope.");
|
|
372
378
|
return buffer.toString("utf8");
|
|
373
379
|
} catch {
|
|
374
|
-
|
|
380
|
+
throw new Error(`SQM could not inspect ${relative}; coverage is incomplete.`);
|
|
375
381
|
}
|
|
376
382
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFile, realpath } from "node:fs/promises";
|
|
1
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { CLAUDE_MARKETPLACE, CLAUDE_PLUGIN_SELECTOR } from "./claude-plugin.js";
|
|
4
|
+
import { pluginCacheMatches, repairPluginCache } from "./plugin-cache.js";
|
|
4
5
|
|
|
5
6
|
export async function refreshClaudePlugin({ status, home, claudeHome, resolveExecutable, run }) {
|
|
6
7
|
if (!status.claude_plugin?.installed) return { outcome: "not_enabled" };
|
|
@@ -19,21 +20,34 @@ export async function refreshClaudePlugin({ status, home, claudeHome, resolveExe
|
|
|
19
20
|
await realpath(marketplace.path) !== await realpath(status.claude_plugin.marketplace_root)) {
|
|
20
21
|
throw new Error("Enabled SIMY Claude plugin points to a different marketplace; refresh skipped.");
|
|
21
22
|
}
|
|
22
|
-
const
|
|
23
|
-
if (!entry?.installPath || entry.version !== status.claude_plugin.version) return
|
|
23
|
+
const cacheLocation = async (entry) => {
|
|
24
|
+
if (!entry?.installPath || entry.version !== status.claude_plugin.version) return null;
|
|
24
25
|
const cacheRoot = await realpath(path.join(claudeHome, "plugins/cache")).catch(() => null);
|
|
25
|
-
if (!cacheRoot) return
|
|
26
|
+
if (!cacheRoot) return null;
|
|
26
27
|
const installedPath = await realpath(entry.installPath).catch(() => null);
|
|
27
28
|
const relative = installedPath && path.relative(cacheRoot, installedPath);
|
|
28
|
-
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return
|
|
29
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
|
30
|
+
return installedPath;
|
|
31
|
+
};
|
|
32
|
+
const cacheCurrent = async (entry) => {
|
|
33
|
+
const installedPath = await cacheLocation(entry);
|
|
34
|
+
if (!installedPath) return false;
|
|
29
35
|
try {
|
|
30
|
-
return JSON.parse(await readFile(path.join(installedPath, ".claude-plugin/plugin.json"), "utf8")).version === status.claude_plugin.version
|
|
31
|
-
|
|
36
|
+
return JSON.parse(await readFile(path.join(installedPath, ".claude-plugin/plugin.json"), "utf8")).version === status.claude_plugin.version &&
|
|
37
|
+
await pluginCacheMatches(status.claude_plugin.path, installedPath);
|
|
38
|
+
} catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code) || error instanceof SyntaxError) return false; throw error; }
|
|
32
39
|
};
|
|
33
40
|
if (await cacheCurrent(installed)) return { outcome: "current" };
|
|
34
41
|
await run(executable, ["plugin", "marketplace", "update", CLAUDE_MARKETPLACE], options);
|
|
35
42
|
await run(executable, ["plugin", "update", CLAUDE_PLUGIN_SELECTOR, "--scope", "user"], options);
|
|
36
|
-
|
|
43
|
+
let refreshed = (await list()).find((entry) => entry.id === CLAUDE_PLUGIN_SELECTOR && entry.scope === "user");
|
|
44
|
+
if (refreshed?.enabled && !await cacheCurrent(refreshed)) {
|
|
45
|
+
const location = await cacheLocation(refreshed);
|
|
46
|
+
if (location && !(await lstat(refreshed.installPath)).isSymbolicLink()) {
|
|
47
|
+
await repairPluginCache(status.claude_plugin.path, location);
|
|
48
|
+
refreshed = (await list()).find((entry) => entry.id === CLAUDE_PLUGIN_SELECTOR && entry.scope === "user");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
37
51
|
if (!refreshed?.enabled || !await cacheCurrent(refreshed)) {
|
|
38
52
|
throw new Error("Claude Code did not confirm the updated SIMY plugin cache; will retry.");
|
|
39
53
|
}
|
package/src/sqm/claude-plugin.js
CHANGED
|
@@ -25,6 +25,9 @@ export async function claudePluginFiles() {
|
|
|
25
25
|
// Claude workflow without shipping it back into Codex's skill inventory.
|
|
26
26
|
files.set("skills/delivery-loop/SKILL.md", await readFile(
|
|
27
27
|
new URL("../delivery-hooks/claude-workflow.md", import.meta.url), "utf8"));
|
|
28
|
+
for (const name of ["loop-kernel.mjs", "loop-kernel/index.mjs", "loop-kernel/policy.json", "loop-kernel/pin.json", "loop-kernel/outcome-review.mjs", "loop-kernel/work-frontier.mjs", "loop-kernel/task-pool.mjs"]) {
|
|
29
|
+
files.set(`hooks/${name}`, await readFile(path.join(SOURCE, "hooks", name), "utf8"));
|
|
30
|
+
}
|
|
28
31
|
files.set("hooks/delivery-loop.mjs", await readFile(path.join(SOURCE, "hooks/delivery-loop.mjs"), "utf8"));
|
|
29
32
|
files.set("hooks/recovery-incident.mjs", await readFile(path.join(SOURCE, "hooks/recovery-incident.mjs"), "utf8"));
|
|
30
33
|
files.set("hooks/minimal-sufficient.mjs", await readFile(path.join(SOURCE, "hooks/minimal-sufficient.mjs"), "utf8"));
|
|
@@ -38,6 +41,7 @@ export async function claudePluginFiles() {
|
|
|
38
41
|
files.set("hooks/purpose-search.mjs", await readFile(path.join(SOURCE, "hooks/purpose-search.mjs"), "utf8"));
|
|
39
42
|
files.set("hooks/completion-binding.mjs", await readFile(path.join(SOURCE, "hooks/completion-binding.mjs"), "utf8"));
|
|
40
43
|
files.set("hooks/consistency-handoff.mjs", await readFile(path.join(SOURCE, "hooks/consistency-handoff.mjs"), "utf8"));
|
|
44
|
+
files.set("hooks/purpose-completion-contract.mjs", await readFile(path.join(SOURCE, "hooks/purpose-completion-contract.mjs"), "utf8"));
|
|
41
45
|
files.set("hooks/purpose-evidence.mjs", await readFile(path.join(SOURCE, "hooks/purpose-evidence.mjs"), "utf8"));
|
|
42
46
|
files.set("hooks/user-experience.mjs", await readFile(path.join(SOURCE, "hooks/user-experience.mjs"), "utf8"));
|
|
43
47
|
files.set("hooks/result-cycle.mjs", await readFile(path.join(SOURCE, "hooks/result-cycle.mjs"), "utf8"));
|
package/src/sqm/local-tests.js
CHANGED
|
@@ -8,8 +8,8 @@ import path from 'node:path';
|
|
|
8
8
|
// This catalog is shipped with the CLI; a repository cannot supply shell commands.
|
|
9
9
|
const profiles = {
|
|
10
10
|
'delivery-loop': [
|
|
11
|
-
{ id: 'stop-investigation-regressions', files: ['test/result-cycle.test.js', 'test/stop-investigation-regression.test.js', 'test/acceptance-reads.test.js', 'test/browser-recovery.test.js', 'test/browser-cdp.test.js', 'test/interaction-task.test.js', 'test/native-interaction.test.js'] },
|
|
12
|
-
{ id: 'autonomous-recovery', files: ['test/autonomous-recovery.test.js', 'test/scene-reasoning.test.js', 'test/supervisor-watchdog.test.js', 'test/supervisor-handover.test.js'] },
|
|
11
|
+
{ id: 'stop-investigation-regressions', files: ['test/result-cycle.test.js', 'test/delivery-result-followthrough.test.js', 'test/corrective-followup.test.js', 'test/stop-investigation-regression.test.js', 'test/acceptance-reads.test.js', 'test/browser-recovery.test.js', 'test/browser-cdp.test.js', 'test/interaction-task.test.js', 'test/native-interaction.test.js'] },
|
|
12
|
+
{ id: 'autonomous-recovery', files: ['test/autonomous-recovery.test.js', 'test/scene-reasoning.test.js', 'test/supervisor-watchdog.test.js', 'test/supervisor-handover.test.js', 'test/work-frontier.test.js', 'test/legacy-hook-routing.test.js', 'test/procedure-free-review.test.js'] },
|
|
13
13
|
{ id: 'perspective-memory', files: ['test/perspective-memory.test.js'] },
|
|
14
14
|
{ id: 'optional-reasoning', files: ['test/purpose-selective-rules.test.js', 'test/purpose-evidence.test.js', 'test/purpose-rule-review.test.js'] },
|
|
15
15
|
// This connected controller group now contains over 200 cases. Keep a
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
// A matching manifest version does not prove that the cached hooks can run.
|
|
6
|
+
// Compare every shipped file; provider-owned cache metadata may coexist.
|
|
7
|
+
export async function pluginCacheMatches(source, cache) {
|
|
8
|
+
async function visit(sourceDirectory, cacheDirectory) {
|
|
9
|
+
const target = await lstat(cacheDirectory);
|
|
10
|
+
if (!target.isDirectory() || target.isSymbolicLink()) return false;
|
|
11
|
+
for (const entry of await readdir(sourceDirectory, { withFileTypes: true })) {
|
|
12
|
+
const from = path.join(sourceDirectory, entry.name);
|
|
13
|
+
const to = path.join(cacheDirectory, entry.name);
|
|
14
|
+
if (entry.isDirectory()) {
|
|
15
|
+
if (!await visit(from, to)) return false;
|
|
16
|
+
} else {
|
|
17
|
+
if (!entry.isFile()) return false;
|
|
18
|
+
const cached = await lstat(to);
|
|
19
|
+
if (!cached.isFile() || cached.isSymbolicLink()) return false;
|
|
20
|
+
if (!(await readFile(from)).equals(await readFile(to))) return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
try { return await visit(source, cache); }
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (['ENOENT', 'ENOTDIR'].includes(error.code)) return false;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Some providers skip recopying a cache whose version already matches. Restore
|
|
33
|
+
// only the shipped files of an already enabled, source-verified cache. Never
|
|
34
|
+
// follow links or remove provider-owned metadata. Each file replacement is atomic.
|
|
35
|
+
export async function repairPluginCache(source, cache) {
|
|
36
|
+
const directory = async target => {
|
|
37
|
+
const info = await lstat(target);
|
|
38
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Plugin cache repair requires real directories.');
|
|
39
|
+
};
|
|
40
|
+
await directory(source);
|
|
41
|
+
await directory(cache);
|
|
42
|
+
async function visit(from, to) {
|
|
43
|
+
await directory(from);
|
|
44
|
+
await directory(to);
|
|
45
|
+
for (const entry of await readdir(from, { withFileTypes: true })) {
|
|
46
|
+
const original = path.join(from, entry.name), target = path.join(to, entry.name);
|
|
47
|
+
if (entry.isSymbolicLink() || !entry.isDirectory() && !entry.isFile()) throw new Error('Plugin source contains an unsupported file.');
|
|
48
|
+
let existing;
|
|
49
|
+
try { existing = await lstat(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
50
|
+
if (existing?.isSymbolicLink()) throw new Error('Plugin cache repair refuses symbolic links.');
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
if (!existing) await mkdir(target);
|
|
53
|
+
await visit(original, target);
|
|
54
|
+
} else {
|
|
55
|
+
if (existing && !existing.isFile()) throw new Error('Plugin cache repair requires regular files.');
|
|
56
|
+
const contents = await readFile(original);
|
|
57
|
+
if (existing && contents.equals(await readFile(target))) continue;
|
|
58
|
+
const temporary = path.join(to, `.simy-repair-${randomUUID()}`);
|
|
59
|
+
try {
|
|
60
|
+
await writeFile(temporary, contents, { flag: 'wx', mode: (await lstat(original)).mode & 0o777 });
|
|
61
|
+
await rename(temporary, target);
|
|
62
|
+
} finally { await rm(temporary, { force: true }); }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
await visit(source, cache);
|
|
67
|
+
}
|
package/src/sqm/proof.js
CHANGED
|
@@ -75,6 +75,7 @@ export function buildProof({ organizationId, repository, bundle, minCheck, evalu
|
|
|
75
75
|
enforcement: 'blocking', rule_results: [], scenarios: [], state_models: [], stressors: [],
|
|
76
76
|
local_tests: localTests,
|
|
77
77
|
}] : [])],
|
|
78
|
+
inspection_scope: sqmInspectionScope(evaluation.modules),
|
|
78
79
|
findings,
|
|
79
80
|
outcome: {
|
|
80
81
|
status,
|
|
@@ -159,6 +160,30 @@ function taggedDigest(value) {
|
|
|
159
160
|
return `sha256:${createHash("sha256").update(canonicalize(value), "utf8").digest("hex")}`;
|
|
160
161
|
}
|
|
161
162
|
|
|
163
|
+
// This scope is part of the signed proof. Static scenarios never become UI or
|
|
164
|
+
// human acceptance evidence, even when the legacy enforcement outcome is passed.
|
|
165
|
+
export function sqmInspectionScope(modules) {
|
|
166
|
+
const checks = modules.flatMap(module => (module.rule_results || []).map(rule => ({
|
|
167
|
+
module_id: module.module_id, rule_id: rule.rule_id, status: rule.status,
|
|
168
|
+
})));
|
|
169
|
+
return { evidence_kind: "static_check", checks, checked_count: checks.length,
|
|
170
|
+
status: checks.length === 0 ? "unconfirmed" : checks.every(check => check.status === "passed") ? "verified" : "unmet",
|
|
171
|
+
scenario_evaluations: modules.reduce((count, module) => count + (module.scenarios || []).length, 0),
|
|
172
|
+
scenario_generation: "not_observed", real_ui_test: "not_observed", human_acceptance: "not_observed" };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function verifyProofScope(proof, evidence, { repository, requiredChecks, trustedKeyId } = {}) {
|
|
176
|
+
const verified = verifySignedEvidence(proof, evidence);
|
|
177
|
+
if (!trustedKeyId || verified.key_id !== trustedKeyId) throw new Error("SQM signer is not the enrolled trusted key.");
|
|
178
|
+
if (!repository?.head_commit || !sameRepositoryIdentity(proof.repository_identity, repositoryIdentity(repository))) throw new Error("SQM proof revision is stale.");
|
|
179
|
+
const scope = sqmInspectionScope(proof.executions);
|
|
180
|
+
if (canonicalize(scope) !== canonicalize(proof.inspection_scope || null)) throw new Error("SQM inspection scope is missing or inconsistent.");
|
|
181
|
+
if (!Array.isArray(requiredChecks) || !requiredChecks.length || scope.status !== "verified" ||
|
|
182
|
+
requiredChecks.some(required => !scope.checks.some(check => check.module_id === required.module_id && check.rule_id === required.rule_id && check.status === "passed"))) throw new Error("SQM required inspection coverage is incomplete.");
|
|
183
|
+
if (proof.outcome.status !== "passed") throw new Error("SQM enforcement did not pass.");
|
|
184
|
+
return { ...verified, evidence_kind: "static_check", checked_count: scope.checked_count };
|
|
185
|
+
}
|
|
186
|
+
|
|
162
187
|
export function localTestsFromProof(proof) {
|
|
163
188
|
return proof?.executions?.find(row => row.kind === 'local_test_profile')?.local_tests || null;
|
|
164
189
|
}
|
package/src/sqm/report.js
CHANGED
|
@@ -25,6 +25,7 @@ export function formatSqmCoverage(proof) {
|
|
|
25
25
|
const semantic = rules.filter(rule => rule.assessment).map(rule => rule.assessment);
|
|
26
26
|
if (semantic.length) lines.push(`Semantic assessments: ${semantic.filter(result => result.executed).length} executed · ${semantic.filter(result => result.outcome === 'scored').length} scored · ${semantic.filter(result => result.outcome !== 'scored').length} inconclusive. Local model judgments are advisory and do not establish independent acceptance.`);
|
|
27
27
|
lines.push("Scenario evaluations are deterministic checks, not browser/provider/deployed journey evidence. Record incident coverage and delivery acceptance separately.");
|
|
28
|
+
if (proof.inspection_scope) lines.push(`Artifact/UI/human acceptance: not established by this static proof; inspection status=${proof.inspection_scope.status}.`);
|
|
28
29
|
return lines.join("\n");
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createInterface } from "node:readline/promises";
|
|
2
|
-
|
|
3
1
|
import { inspectSqmAgentSkill, installSqmAgentSkill } from "./agent-skill.js";
|
|
4
2
|
|
|
5
3
|
const INSTALL_FLAG = "--install-workflows";
|
|
@@ -9,11 +7,8 @@ export async function prepareDaemonWorkflows({
|
|
|
9
7
|
argv,
|
|
10
8
|
daemon,
|
|
11
9
|
daemonChild = process.env.SIMY_DAEMON_CHILD === "1",
|
|
12
|
-
stdinIsTTY = process.stdin.isTTY === true,
|
|
13
|
-
stdoutIsTTY = process.stdout.isTTY === true,
|
|
14
10
|
inspect = inspectSqmAgentSkill,
|
|
15
11
|
install = installSqmAgentSkill,
|
|
16
|
-
confirm = confirmWorkflowInstallation,
|
|
17
12
|
logger = console.log,
|
|
18
13
|
errorLogger = console.error,
|
|
19
14
|
} = {}) {
|
|
@@ -42,23 +37,12 @@ export async function prepareDaemonWorkflows({
|
|
|
42
37
|
return { outcome: "update_pending", status };
|
|
43
38
|
}
|
|
44
39
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"SIMY Codex/Claude workflows are missing or outdated. Re-run in a terminal with --install-workflows to install them.",
|
|
48
|
-
);
|
|
49
|
-
return { outcome: "confirmation_unavailable", status };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const accepted = installRequested || (await confirm({ status }));
|
|
53
|
-
if (!accepted) {
|
|
54
|
-
logger("SIMY workflow installation skipped. The daemon will still start.");
|
|
55
|
-
return { outcome: "declined", status };
|
|
56
|
-
}
|
|
57
|
-
|
|
40
|
+
// Source preparation is CLI startup work, including unattended service starts.
|
|
41
|
+
// Provider plugin enablement and hook trust remain provider-owned controls.
|
|
58
42
|
try {
|
|
59
43
|
const installed = await install();
|
|
60
44
|
logger(
|
|
61
|
-
"SIMY workflows installed. In Codex, restart, then find and install SIMY in Plugins
|
|
45
|
+
"SIMY workflows installed. In Codex, restart, then find and install SIMY in Plugins. In Claude Code, add the SIMY marketplace and install simy@simy-local before using /simy:sqm-loop or /simy:pr-review. For Delivery Loop, review and enable SIMY hooks in /hooks. Delivery Loop then starts automatically on user prompts, without per-task commands. Plugin installation does not grant hook trust.",
|
|
62
46
|
);
|
|
63
47
|
if (installed.claude_plugin?.marketplace_root) {
|
|
64
48
|
logger(`Claude Code: claude plugin marketplace add ${JSON.stringify(installed.claude_plugin.marketplace_root)}`);
|
|
@@ -71,26 +55,6 @@ export async function prepareDaemonWorkflows({
|
|
|
71
55
|
}
|
|
72
56
|
}
|
|
73
57
|
|
|
74
|
-
export async function confirmWorkflowInstallation({
|
|
75
|
-
input = process.stdin,
|
|
76
|
-
output = process.stdout,
|
|
77
|
-
} = {}) {
|
|
78
|
-
output.write(
|
|
79
|
-
[
|
|
80
|
-
"SIMY can install or update its Codex and Claude Code plugin sources and opt-in SQM skills.",
|
|
81
|
-
"Existing unrelated plugins and hooks will be preserved.",
|
|
82
|
-
"",
|
|
83
|
-
].join("\n"),
|
|
84
|
-
);
|
|
85
|
-
const prompt = createInterface({ input, output });
|
|
86
|
-
try {
|
|
87
|
-
const answer = await prompt.question("Install SIMY workflows now? [y/N] ");
|
|
88
|
-
return /^(y|yes)$/i.test(answer.trim());
|
|
89
|
-
} finally {
|
|
90
|
-
prompt.close();
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
58
|
function message(error) {
|
|
95
59
|
return error instanceof Error ? error.message : String(error);
|
|
96
60
|
}
|