@milpa/comal 0.2.1 → 0.2.3
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 +1 -1
- package/backend/contracts/remote-commands-v1.json +0 -12
- package/backend/contracts/remote-relay-answers-v1.json +209 -0
- package/package.json +3 -1
- package/skills/mv3/SKILL.md +44 -0
- package/skills/mv3/skill.json +4 -0
- package/src/agent.mjs +299 -32
- package/src/auto-guard.mjs +40 -3
- package/src/autocompact.mjs +233 -0
- package/src/bash-timeouts.mjs +29 -0
- package/src/child-env.mjs +23 -0
- package/src/cli.mjs +232 -63
- package/src/code-run-artifact.mjs +30 -0
- package/src/code-run-journal.mjs +135 -0
- package/src/code-run-session-proof.mjs +50 -0
- package/src/code-run-session.mjs +97 -0
- package/src/code-run-worker.mjs +20 -4
- package/src/code-run.mjs +49 -14
- package/src/codex-provider.mjs +4 -4
- package/src/compaction-notice.mjs +22 -0
- package/src/compaction.mjs +162 -23
- package/src/connection-settings.mjs +36 -4
- package/src/context-pressure.mjs +5 -4
- package/src/context.mjs +27 -22
- package/src/execution-metrics.mjs +36 -5
- package/src/file-recovery-proof.mjs +9 -1
- package/src/file-recovery.mjs +36 -3
- package/src/gemini-provider.mjs +5 -3
- package/src/git-integration-operations.mjs +2 -1
- package/src/git-plumbing.mjs +2 -1
- package/src/git-source.mjs +2 -1
- package/src/git-workspaces.mjs +2 -1
- package/src/house.mjs +13 -1
- package/src/i18n/cli-es.mjs +12 -1
- package/src/i18n/es.mjs +133 -2
- package/src/judges.mjs +2 -1
- package/src/mcp.mjs +2 -1
- package/src/model-controls.mjs +16 -2
- package/src/model-deadline.mjs +2 -2
- package/src/openai-provider.mjs +8 -4
- package/src/operations.mjs +17 -2
- package/src/package-identity.mjs +68 -0
- package/src/peer-probe.mjs +16 -0
- package/src/permissions.mjs +99 -6
- package/src/plugin-catalog.mjs +19 -4
- package/src/plugin-config.mjs +18 -1
- package/src/plugin-controls.mjs +31 -8
- package/src/plugin-trust.mjs +15 -6
- package/src/plugins/chrome-lab/bridge.mjs +19 -6
- package/src/plugins/chrome-lab/client.mjs +242 -12
- package/src/plugins/chrome-lab/index.mjs +106 -24
- package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
- package/src/plugins/chrome-lab/native-reply.mjs +31 -0
- package/src/plugins/omarchy/catalog.mjs +96 -0
- package/src/plugins/omarchy/environment.mjs +78 -0
- package/src/plugins/omarchy/guide.mjs +54 -0
- package/src/plugins/omarchy/index.mjs +213 -0
- package/src/plugins/omarchy/runner.mjs +62 -0
- package/src/plugins/omarchy/text.mjs +26 -0
- package/src/plugins/omarchy/tiers.mjs +157 -0
- package/src/plugins/open-relay/actions.mjs +212 -16
- package/src/plugins/open-relay/client.mjs +93 -35
- package/src/plugins/open-relay/decision.mjs +89 -0
- package/src/plugins/open-relay/dossier.mjs +23 -5
- package/src/plugins/open-relay/index.mjs +68 -28
- package/src/plugins/open-relay/lookup.mjs +41 -4
- package/src/plugins/open-relay/notices.mjs +48 -4
- package/src/plugins/open-relay/panel.mjs +59 -0
- package/src/plugins/open-relay/watch.mjs +198 -85
- package/src/plugins.mjs +12 -1
- package/src/process-lock.mjs +20 -12
- package/src/provider-auth.mjs +6 -3
- package/src/provider-profiles.mjs +13 -3
- package/src/provider.mjs +175 -30
- package/src/read-only-check.mjs +12 -4
- package/src/relay/broker.mjs +160 -0
- package/src/relay/cards.mjs +702 -0
- package/src/relay/flow.mjs +886 -0
- package/src/relay/follow.mjs +119 -0
- package/src/relay/git.mjs +23 -0
- package/src/relay/hub-client.mjs +163 -0
- package/src/relay/state.mjs +55 -0
- package/src/remote-control.mjs +115 -12
- package/src/remote-relay.mjs +468 -0
- package/src/session-bus.mjs +13 -3
- package/src/session-provider-settings.mjs +2 -2
- package/src/skill-runtime.mjs +8 -6
- package/src/source-ownership.mjs +3 -1
- package/src/swarm-view.mjs +53 -0
- package/src/tool-manifest.mjs +71 -0
- package/src/tool-packs.mjs +22 -6
- package/src/tool-schema.mjs +14 -2
- package/src/tools.mjs +96 -27
- package/src/tui/connection-transition.mjs +10 -0
- package/src/tui/controller.mjs +35 -9
- package/src/tui/discovery.mjs +13 -8
- package/src/tui/inbound-blocks.mjs +5 -1
- package/src/tui/screen.mjs +78 -15
- package/src/tui/swarm-panel.mjs +217 -0
- package/src/turn-timing.mjs +82 -0
- package/src/usage-accounting.mjs +12 -1
- package/src/vision.mjs +6 -1
- package/src/visual/chrome.mjs +379 -0
- package/src/visual/fingerprint.mjs +30 -0
- package/src/visual/fixtures.mjs +201 -0
- package/src/visual/gate.mjs +92 -0
- package/src/visual/judge.mjs +114 -0
- package/src/visual/ledger.mjs +160 -0
- package/src/visual/measure.mjs +145 -0
- package/src/visual/names.mjs +7 -0
- package/src/visual/operations.mjs +211 -0
- package/src/visual/pixel-diff.mjs +96 -0
- package/src/visual/png.mjs +103 -0
- package/src/visual/registry.mjs +265 -0
- package/src/visual/report.mjs +64 -0
- package/src/visual/verify.mjs +292 -0
- package/src/work-assertions.mjs +15 -4
- package/src/work-plan.mjs +166 -30
- package/src/work-state.mjs +4 -4
- package/src/workflows/chrome-task-adoption.mjs +7 -3
- package/src/workspace-serve.mjs +59 -0
package/src/work-assertions.mjs
CHANGED
|
@@ -16,18 +16,29 @@ export function validateAssertions(assertions = []) {
|
|
|
16
16
|
if (assertion.pointer && (!assertion.pointer.startsWith('/') || assertion.pointer.split('/').some(part => ['__proto__', 'constructor', 'prototype'].includes(part)))) throw new Error('Invalid JSON pointer.');
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
// An MCP receipt stores its result as JSON, so text the model read (quotes, newlines) is escaped there.
|
|
20
|
+
// Text assertions also look inside each text block (never across two); an error result never counts (tools often echo their input).
|
|
21
|
+
// A tool that shows a returned value as a JSON string in a ```json fence (chrome-devtools evaluate_script) escapes
|
|
22
|
+
// its quotes once more: the decoded string is also searchable.
|
|
23
|
+
const fencedStrings = text => [...text.matchAll(/```json\n([\s\S]*?)\n```/g)].flatMap(([, body]) => { try { const value = JSON.parse(body); return typeof value === 'string' ? [value] : []; } catch { return []; } });
|
|
24
|
+
const mcpText = output => {
|
|
25
|
+
if (!output.startsWith('{"content":[')) return null;
|
|
26
|
+
try { const result = JSON.parse(output), blocks = result.content; return Array.isArray(blocks) && result.is_error !== true && result.isError !== true ? blocks.filter(block => block?.type === 'text' && typeof block.text === 'string').flatMap(block => [block.text, ...fencedStrings(block.text)]) : null; } catch { return null; }
|
|
27
|
+
};
|
|
19
28
|
export function assertOutput(output, assertions = []) {
|
|
20
29
|
validateAssertions(assertions);
|
|
21
|
-
|
|
30
|
+
const text = assertions.some(assertion => ['contains', 'not_contains'].includes(assertion.kind)) ? mcpText(output) : null;
|
|
31
|
+
const has = value => output.includes(value) || Boolean(text?.some(block => block.includes(value)));
|
|
32
|
+
for (const [index, assertion] of assertions.entries()) {
|
|
22
33
|
let passed = false;
|
|
23
34
|
if (assertion.kind === 'nonempty') passed = Boolean(output.trim()) && !/^\((?:sin (?:salida|coincidencias)|no (?:output|matches))\)$/.test(output.trim());
|
|
24
|
-
if (assertion.kind === 'contains') passed =
|
|
25
|
-
if (assertion.kind === 'not_contains') passed = !
|
|
35
|
+
if (assertion.kind === 'contains') passed = has(assertion.value);
|
|
36
|
+
if (assertion.kind === 'not_contains') passed = !has(assertion.value);
|
|
26
37
|
if (assertion.kind === 'json_equals') {
|
|
27
38
|
let value; try { value = JSON.parse(output); } catch { throw Object.assign(new Error('The check did not return JSON for json_equals.'), { code: 'WORK_ASSERTION' }); }
|
|
28
39
|
for (const part of assertion.pointer ? assertion.pointer.slice(1).split('/').map(part => part.replace(/~1/g, '/').replace(/~0/g, '~')) : []) value = value && Object.hasOwn(value, part) ? value[part] : undefined;
|
|
29
40
|
passed = isDeepStrictEqual(value, assertion.value);
|
|
30
41
|
}
|
|
31
|
-
if (!passed) throw Object.assign(new Error(`Assertion failed: ${assertion.kind}${assertion.pointer ? ' ' + assertion.pointer : ''}. Inspect the evidence; recheck preserves the same step_id.`), { code: 'WORK_ASSERTION' });
|
|
42
|
+
if (!passed) throw Object.assign(new Error(`Assertion failed: ${assertion.kind}${assertion.pointer ? ' ' + assertion.pointer : ''}${typeof assertion.value === 'string' ? ' ' + JSON.stringify(assertion.value.slice(0, 120)) : ''} (assertion ${index + 1} of ${assertions.length}). Inspect the evidence; recheck preserves the same step_id.`), { code: 'WORK_ASSERTION' });
|
|
32
43
|
}
|
|
33
44
|
}
|
package/src/work-plan.mjs
CHANGED
|
@@ -15,6 +15,7 @@ export const WORK_NAMES = ['WorkPlanSet', 'WorkCheckRepair', 'WorkTaskUpdate', '
|
|
|
15
15
|
const id = { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$' }, version = { type: 'integer', minimum: 0 };
|
|
16
16
|
const args = { type: 'object' }, str = maxLength => ({ type: 'string', minLength: 1, maxLength });
|
|
17
17
|
const schema = (properties, required) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
18
|
+
const MAX_TASK_CHECKS = 4;
|
|
18
19
|
const keyOf = (task, step) => JSON.stringify([task, step]);
|
|
19
20
|
const failure = (message, code = 'WORK_CONFLICT') => Object.assign(new Error(message), { code });
|
|
20
21
|
export const planControl = work => ({ plan_exists: Boolean(work?.plan), expected_version: work?.plan?.version || 0,
|
|
@@ -22,6 +23,21 @@ export const planControl = work => ({ plan_exists: Boolean(work?.plan), expected
|
|
|
22
23
|
|
|
23
24
|
// A generation owns produced work; its current check definition has a separate hash.
|
|
24
25
|
export const workDigest = task => task?.work_digest || task?.definition_digest;
|
|
26
|
+
// A close guard that requires a check the task never declared offers the exact
|
|
27
|
+
// replan: retire the task and redeclare it under the same id with that check, so
|
|
28
|
+
// dependents stay valid and the requirement is satisfied, never skipped.
|
|
29
|
+
function withRequiredCheckRecovery(error, task, book) {
|
|
30
|
+
const { capability, check } = error.required_check, own = definitionOf(task), taken = new Set((own.checks || []).map(c => c.step_id));
|
|
31
|
+
let stepId = check.step_id; for (let n = 2; taken.has(stepId); n++) stepId = `${check.step_id}-${n}`;
|
|
32
|
+
// A task holds at most MAX_TASK_CHECKS; when full, the last check moves to a dependent task so the
|
|
33
|
+
// call stays valid and no declared check is dropped.
|
|
34
|
+
const checks = own.checks || [], keep = checks.length < MAX_TASK_CHECKS ? checks : checks.slice(0, MAX_TASK_CHECKS - 1), moved = checks.slice(keep.length);
|
|
35
|
+
const replacement = { ...own, capabilities: [...new Set([...(own.capabilities || []), capability])], checks: [...keep, { ...check, step_id: stepId }] };
|
|
36
|
+
const ids = new Set([...book.tasks.keys?.() ?? Object.keys(book.tasks || {})]); let restId = `${task.id}-checks`.slice(0, 64); for (let n = 2; ids.has(restId); n++) restId = `${task.id}-checks-${n}`.slice(0, 64);
|
|
37
|
+
const rest = moved.length ? [{ id: restId, title: `${own.title} (checks restantes)`.slice(0, 300), criterion: own.criterion, ...(own.phase ? { phase: own.phase } : {}), depends_on: [task.id], checks: moved }] : [];
|
|
38
|
+
const recovery_call = { name: 'WorkPlanSet', input: { expected_version: book.plan.version, append: true, objective: book.plan.objective, retire_ids: [task.id], reason: `Redeclare ${task.id} with the check the house requires to close it.`, tasks: [replacement, ...rest] } };
|
|
39
|
+
return Object.assign(new Error(`${error.message} ${JSON.stringify({ recovery_call, next_action: 'Run recovery_call as a top-level call: it keeps the task id, its work and its dependents, and adds the required check. Then set the task in_progress, run the new check when it can pass, and close. WorkCheckRepair cannot add checks.' })}`), { code: error.code });
|
|
40
|
+
}
|
|
25
41
|
const definitionOf = task => Object.fromEntries(['id','title','criterion','phase','capabilities','artifacts','depends_on','checks'].filter(key => task[key] !== undefined).map(key => [key,task[key]]));
|
|
26
42
|
const isMcpCheck = check => /^mcp(?:__|:)/.test(check.operation);
|
|
27
43
|
const mcpServer = check => /^mcp:([^\s]+)/.exec(check.operation)?.[1] || /^mcp__(.+?)__/.exec(check.operation)?.[1];
|
|
@@ -96,6 +112,21 @@ function closedProcessFailure(session,step,audit,active){
|
|
|
96
112
|
return {kind:'native_process_nonzero_exit',exit_code:Number(match[1]),receipt_id:leaf.invocation_id,parent_receipt_id:parent.invocation_id,derivation:exits.length?'native_process_exit_observation':'legacy_native_shell_error',partial_effects:'preserved_unjudged',retry_authorized:false};
|
|
97
113
|
}
|
|
98
114
|
|
|
115
|
+
// An MCP server that answers a step with its own error result (is_error) has already judged the call:
|
|
116
|
+
// the house records the failure (the CLI raises that result as the tool error), and the step closes like
|
|
117
|
+
// a shell exit code. Effects stay unjudged; nothing retries that step id, and new steps are not fenced.
|
|
118
|
+
function closedMcpErrorResult(session,step,audit,active){
|
|
119
|
+
if(step.status!=='failed'||!/^mcp__.+__.+$/.test(step.operation||'')||active.has(step.invocation_id)||!audit.journal.verified||audit.violations.length||audit.unverified.length)return null;
|
|
120
|
+
const rows=session.rows,leaf=audit.receipts.find(row=>row.invocation_id===step.receipt_id),parent=audit.receipts.find(row=>row.invocation_id===step.invocation_id);
|
|
121
|
+
if(!leaf||!parent||leaf.code||leaf.status!=='failed'||parent.status!=='failed'||leaf.parent_invocation_id!==parent.invocation_id||parent.operation!=='WorkStepRun'||leaf.operation!==step.operation||leaf.error!==parent.error)return null;
|
|
122
|
+
const proposal=rows.find(row=>row.type==='house_proposal'&&row.invocation_id===parent.invocation_id);
|
|
123
|
+
const contract=rows.findLast(row=>row.type==='house_contract'&&row.contract_digest===leaf.contract_digest)?.contract;
|
|
124
|
+
if(!proposal||!nativeWorkParent(rows,proposal)||contract?.name!==step.operation||contract.origin?.kind!=='mcp')return null;
|
|
125
|
+
let result;try{result=JSON.parse(leaf.error||'');}catch{return null;}
|
|
126
|
+
if(!result||typeof result!=='object'||result.is_error!==true||!Array.isArray(result.content))return null;
|
|
127
|
+
return {kind:'mcp_error_result',receipt_id:leaf.invocation_id,parent_receipt_id:parent.invocation_id,server:contract.origin.server||null,partial_effects:'preserved_unjudged',external_effects:'unjudged',retry_authorized:false};
|
|
128
|
+
}
|
|
129
|
+
|
|
99
130
|
// This closes only a native file handler's known pre-replacement failure. It
|
|
100
131
|
// neither credits its check nor retries that step; directories/temp files may
|
|
101
132
|
// already exist. Plugin error text or a source error code alone is insufficient.
|
|
@@ -166,7 +197,9 @@ function foldWorkWithAudit(session, { active = new Set() } = {},auditFor) {
|
|
|
166
197
|
}
|
|
167
198
|
if (row.source === 'work_host' && workDigest(tasks.get(row.task_id)) === row.task_digest) {
|
|
168
199
|
const task = tasks.get(row.task_id);
|
|
169
|
-
if (row.type === 'work_task_changed') Object.assign(task, { status: row.status, reason: row.reason, receipt_ids: row.receipt_ids || [], dependency_proofs: row.dependency_proofs || {}, ref: i
|
|
200
|
+
if (row.type === 'work_task_changed') Object.assign(task, { status: row.status, reason: row.reason, receipt_ids: row.receipt_ids || [], dependency_proofs: row.dependency_proofs || {}, ref: i,
|
|
201
|
+
// First start of the task: work done before a later block/resume still belongs to it.
|
|
202
|
+
started_ref: task.started_ref ?? (row.status === 'in_progress' ? i : undefined) });
|
|
170
203
|
if (row.type === 'work_check_verified') attestations.set(keyOf(row.task_id, row.step_id), { ...row, ref: i });
|
|
171
204
|
if (row.type === 'work_step_started') { steps.set(keyOf(row.task_id, row.step_id), { ...row, ref: i, status: active.has(row.invocation_id) ? 'running' : 'interrupted', receipt_id: null }); task.last_step = row.step_id; }
|
|
172
205
|
if (row.type === 'work_draft_chunk') {
|
|
@@ -195,7 +228,7 @@ function foldWorkWithAudit(session, { active = new Set() } = {},auditFor) {
|
|
|
195
228
|
if (proof) Object.assign(step, { status: 'refused', preflight_failure: proof, recovery_ref: proof.parent_receipt_ref,
|
|
196
229
|
next_action: 'The target was not dispatched: an artifact preflight failed. Prepare the missing or invalid output with new work, then retry this original step under current permissions. No failed check was credited.' });
|
|
197
230
|
}
|
|
198
|
-
for(const step of steps.values()){const proof=closedProcessFailure(session,step,audit,active)||closedSourcePreflight(session,step,audit,active)||closedMediaPreflight(session,step,audit,active)||closedArtifactProcessing(session,step,audit,active);if(proof)step.failure_closed=proof;}
|
|
231
|
+
for(const step of steps.values()){const proof=closedProcessFailure(session,step,audit,active)||closedMcpErrorResult(session,step,audit,active)||closedSourcePreflight(session,step,audit,active)||closedMediaPreflight(session,step,audit,active)||closedArtifactProcessing(session,step,audit,active);if(proof)step.failure_closed=proof;}
|
|
199
232
|
// Explicit local operator review is an attestation, not reconstructed execution
|
|
200
233
|
// success. Only sealed human-control receipts can review a closed native Bash
|
|
201
234
|
// failure; raw facts and model prose never clear a fence.
|
|
@@ -267,9 +300,12 @@ function projectWork(book) {
|
|
|
267
300
|
const preflightFailures = book.steps.filter(step => step.preflight_failure).map(step => {
|
|
268
301
|
const task = book.tasks.find(task => task.id === step.task_id);
|
|
269
302
|
return { task_id: step.task_id, step_id: step.step_id, status: 'not_dispatched', ...step.preflight_failure,
|
|
270
|
-
|
|
303
|
+
// A directory never becomes hashable by retrying: no retry_call for the recovery grace to point at.
|
|
304
|
+
retry_call: step.preflight_failure.code === 'FILE_HASH_TYPE' ? undefined : step.preflight_failure.retry_call || { name: 'WorkStepRun', input: { task_id: step.task_id, plan_version: book.plan.version, step_id: step.step_id } },
|
|
271
305
|
...(task?.declared_status === 'blocked' && !task.held_by.length ? { resume_call: { name: 'WorkTaskUpdate', input: { task_id: task.id, plan_version: book.plan.version, status: 'in_progress' } } } : {}),
|
|
272
|
-
next_action:
|
|
306
|
+
next_action: step.preflight_failure.code === 'FILE_HASH_TYPE'
|
|
307
|
+
? 'The declared artifact is a directory or special file, which cannot be hashed as an artifact. Retrying the same path will fail again. Either redeclare the task (retire and redeclare its id) with a regular file as its artifact — for example a manifest file you write that lists the directory contents — or drop the artifact and assert over the directory with a Glob check in the task checks instead. The failed check remains uncredited.'
|
|
308
|
+
: 'Prepare the missing or invalid artifact with a new step or publish the preserved draft; then retry_call under current permissions. If the task is blocked, resume_call only changes its recorded status. The failed check remains uncredited.' };
|
|
273
309
|
});
|
|
274
310
|
return { plan: book.plan, scope: 'recorded_work_execution', asked: 'unjudged', counts: Object.fromEntries(['pending', 'in_progress', 'blocked', 'done', 'needs_verification'].map(status => [status, book.tasks.filter(task => task.status === status).length])), cards: book.tasks.map(summarize), current: next ? { ...summarize(next), criterion: next.criterion, checks: next.checks, capabilities: next.capabilities || [], artifacts: next.artifacts || [], definition_digest: next.definition_digest, work_digest: workDigest(next) } : null,
|
|
275
311
|
recent_steps: book.steps.slice(-6).map(({ task_id, step_id, status, receipt_id, receipt_ref, operation, next_action, ref, preflight_failure, recovery_ref }) => ({ task_id, step_id, status, receipt_id, operation, next_action,
|
|
@@ -278,6 +314,7 @@ function projectWork(book) {
|
|
|
278
314
|
drafts: book.drafts.map(({ id, task_id, file_path, digest, bytes, complete, parts, part_ids }) => ({ id, task_id, file_path, digest, bytes, complete, next_index: parts.length, recent_part_ids: (part_ids || []).slice(-3).filter(Boolean) })),
|
|
279
315
|
checkpoint: book.checkpoint ? { reason: book.checkpoint.reason, plan_digest: book.checkpoint.plan_digest, task_id: book.checkpoint.task_id, next_action: book.checkpoint.next_action, ref: book.checkpoint.ref } : null,
|
|
280
316
|
source_preflight_failures: book.steps.filter(step=>step.failure_closed?.kind==='native_file_pre_replacement_rejection').map(step=>({task_id:step.task_id,step_id:step.step_id,...step.failure_closed,next_action:'The native destination replacement was not dispatched. Preserve this failed step, inspect current source, and use a NEW step with its explicit expected version. Current permissions and checks still apply; do not replay the failed step ID.'})),
|
|
317
|
+
mcp_failures: book.steps.filter(step=>step.failure_closed?.kind==='mcp_error_result').map(step=>({task_id:step.task_id,step_id:step.step_id,...step.failure_closed,next_action:'The MCP server itself returned an error result for this step; that reply is the receipt. Effects stay unjudged: the server may have applied part of a mutating action, so inspect the page state (snapshot, console) before repeating one. A declared check runs again with the same step_id and recheck:true; any other step continues under a NEW step id.'})),
|
|
281
318
|
process_failures: book.steps.filter(step=>step.failure_closed?.kind==='native_process_nonzero_exit').map(step=>({task_id:step.task_id,step_id:step.step_id,...step.failure_closed,next_action:'The shell ended with a nonzero exit. Preserve partial outputs and failed receipts; use a new judged correction step, then rerun mandatory checks. Do not replay the failed step ID.'})),
|
|
282
319
|
media_preflight_failures: book.steps.filter(step=>step.failure_closed?.kind==='native_media_preflight_rejection').map(step=>({task_id:step.task_id,step_id:step.step_id,...step.failure_closed,next_action:'No provider media request was sent. Use a supported MIME in a new observation step; an image-only provider can inspect locally extracted video frames. Failed checks remain uncredited.'})),
|
|
283
320
|
media_processing: book.steps.filter(step=>step.failure_closed?.kind==='native_media_processing_acknowledged').map(step=>({task_id:step.task_id,step_id:step.step_id,...step.failure_closed,next_action:'The media upload was acknowledged; no artifact candidate exists from this failed attempt. Preserve its ID and failed receipt. Reconcile the SAME directory/metadata in a new judged ArtifactPublish step; do not replay this failed step ID or invent an artifact ID.'})),
|
|
@@ -290,12 +327,25 @@ function projectWork(book) {
|
|
|
290
327
|
}
|
|
291
328
|
|
|
292
329
|
export class WorkCoordinator {
|
|
293
|
-
|
|
330
|
+
// closeGuards: async ({task, checks:[{check, receipt}], sessionId}) => void; a throw refuses the close.
|
|
331
|
+
constructor({ store, house, onEvent = () => {}, closeGuards = [] }) { Object.assign(this, { store, house, onEvent, closeGuards }); }
|
|
294
332
|
async notify(sessionId) { await this.onEvent({ type: 'work_board', session_id: sessionId, board: workProjection(await this.store.load(sessionId), this.house.stateContext()) }); }
|
|
295
333
|
async mutate(sessionId, action) { return this.store.workTransaction(sessionId, async () => { const session = await this.store.load(sessionId); if (!session.journalAudit.consistent) throw failure('El libro no tiene una cadena consistente.', 'AUDIT_INVALID'); return action(session, foldWorkBook(session, this.house.stateContext())); }); }
|
|
296
|
-
task(book, input) {
|
|
334
|
+
task(book, input, tool = null) {
|
|
297
335
|
if (!book.plan || input.plan_version !== book.plan.version) throw failure(`Versión de plan obsoleta: requiere plan_version:${book.plan?.version || 0}. Continúa el borrador/tarea con esa precondición; la versión vigente está en este resultado, no necesitas WorkBoard.`);
|
|
298
|
-
const task = book.tasks.find(task => task.id === input.task_id); if (
|
|
336
|
+
const task = book.tasks.find(task => task.id === input.task_id); if (task) return task;
|
|
337
|
+
// Gate OR-8: «Tarea inexistente.» a secas dejaba al modelo sin salida (no_progress). Se dice qué falta,
|
|
338
|
+
// qué tareas siguen abiertas y, con una sola en curso, la llamada exacta (recovery_call: una respuesta de gracia).
|
|
339
|
+
const open = book.tasks.filter(item => item.status !== 'done').map(item => ({ id: item.id, status: item.status }));
|
|
340
|
+
const running = book.tasks.filter(item => item.status === 'in_progress');
|
|
341
|
+
// Solo donde la llamada corregida no puede cambiar otra tarea (WorkTaskUpdate cambiaría el estado de la que
|
|
342
|
+
// está en curso). El content no se repite: un rechazo de más de 16384 caracteres se pagina y la gracia se pierde.
|
|
343
|
+
const offer = ['WorkDraft', 'WorkStepRun'].includes(tool) && running.length === 1;
|
|
344
|
+
const { content, ...rest } = input;
|
|
345
|
+
const hint = { code: 'WORK_TASK_UNKNOWN', executed: false, given: input.task_id ?? null, open_tasks: open,
|
|
346
|
+
...(offer ? { recovery_call: { name: tool, input: { ...rest, task_id: running[0].id }, ...(content !== undefined ? { resend_from_previous_call: ['content'] } : {}) },
|
|
347
|
+
next_action: `Repite la misma llamada con task_id:${running[0].id}` + (content !== undefined ? ' y el mismo content.' : '.') } : {}) };
|
|
348
|
+
throw failure((input.task_id == null ? 'Falta task_id. ' : `Tarea inexistente: ${input.task_id}. `) + JSON.stringify(hint), 'WORK_TASK_UNKNOWN');
|
|
299
349
|
}
|
|
300
350
|
async fact(options, row) { await options.recordWork({ ...row, source: 'work_host' }); }
|
|
301
351
|
async setPlan(input, options) {
|
|
@@ -305,7 +355,14 @@ export class WorkCoordinator {
|
|
|
305
355
|
? `Plan cambiado: versión vigente ${expected}; WorkPlanSet requiere expected_version:${expected}. Conserva las tareas abiertas o declara retiros explícitos. El estado ya está en la proyección.`
|
|
306
356
|
: 'No existe plan aceptado. Para crear el primer plan usa WorkPlanSet con expected_version:0 y una tarea pequeña. La versión 1 será el resultado, no la precondición. No necesitas consultar WorkBoard.');
|
|
307
357
|
if (Buffer.byteLength(JSON.stringify(input)) > 64000) throw failure('Plan demasiado grande: usa tareas más pequeñas.');
|
|
308
|
-
const
|
|
358
|
+
const retiring = new Set(input.retire_ids || []), unknownRetired = [...retiring].filter(id => !book.tasks.some(task => task.id === id));
|
|
359
|
+
if (unknownRetired.length) throw failure(`retire_ids nombra tareas que no están en el plan: ${unknownRetired.join(', ')}. Usa los ids de la proyección.`, 'WORK_PLAN_INPUT');
|
|
360
|
+
// Un paso que espera permiso humano lo frena la casa antes del efecto (WORK_STALE); uno en curso o interrumpido no.
|
|
361
|
+
for (const task of book.tasks.filter(task => retiring.has(task.id))) {
|
|
362
|
+
if (book.steps.some(step => step.task_id === task.id && (step.status === 'interrupted' || step.status === 'running' && !task.held_by?.includes('human_permission') || step.status === 'failed' && !step.failure_closed && !readOnlyWorkCheck(step.operation, step.arguments)))) throw failure(`La tarea ${task.id} tiene un paso con efectos inciertos; inspecciona y ciérralo antes de retirarla (retirarla borraría ese estado del tablero).`, 'WORK_UNCERTAIN');
|
|
363
|
+
}
|
|
364
|
+
// append conserva las tareas anteriores salvo las retiradas.
|
|
365
|
+
const proposed = input.append ? [...book.tasks.filter(task => !retiring.has(task.id)).map(definitionOf), ...input.tasks] : input.tasks;
|
|
309
366
|
if (proposed.length > 24) throw failure('Máximo 24 tareas por plan, incluidas las conservadas.', 'WORK_PLAN_INPUT');
|
|
310
367
|
const ids = new Set(proposed.map(task => task.id)); if (ids.size !== proposed.length) throw failure('IDs de tareas duplicados; append sólo admite IDs nuevos y conserva las tareas anteriores.', 'WORK_PLAN_INPUT');
|
|
311
368
|
const definitions = proposed.map(task => ({ ...task, depends_on: task.depends_on || [], checks: task.checks || [] }));
|
|
@@ -313,14 +370,18 @@ export class WorkCoordinator {
|
|
|
313
370
|
if (contractIssues.length) throw failure('Checks inválidos; el plan no se guardó. ' + (book.plan ? 'Para observaciones adicionales de DOM/consola/red NO hace falta reemplazar el plan: usa WorkStepRun con task_id vigente, plan_version:'+book.plan.version+', step_id NUEVO, operation y arguments. ' : '') + JSON.stringify({issues:contractIssues,available_operation_names:(options.catalogue || []).map(item=>item.name).filter(name=>!WORK_NAMES.includes(name)).slice(0,60),next_action:'Use an exact offered operation and its arguments; Read uses file_path. Preserve the requested artifacts and task criterion. No checks executed and no plan saved.'}), 'WORK_CHECK_CONTRACT');
|
|
314
371
|
for (const task of definitions) {
|
|
315
372
|
for (const check of task.checks) validateAssertions(check.assertions);
|
|
316
|
-
if (task.phase === 'verification' && (!task.checks.length || task.checks.some(check => !check.assertions?.length)))
|
|
373
|
+
if (task.phase === 'verification' && (!task.checks.length || task.checks.some(check => !check.assertions?.length))) {
|
|
374
|
+
// Gate OR-9: sin decir qué check ni cómo, el modelo encadenó planes rechazados hasta no_progress.
|
|
375
|
+
const missing = task.checks.filter(check => !check.assertions?.length).map(check => `${task.id}/${check.step_id}`);
|
|
376
|
+
throw failure(`Una tarea verification requiere aserciones explícitas en cada check. ${task.checks.length ? 'Sin aserciones: ' + missing.join(', ') + '.' : `${task.id} no tiene checks.`} Ejemplo: "assertions":[{"kind":"contains","value":"PASS"}] (kinds: nonempty, contains, not_contains, json_equals con pointer). Si la tarea solo lee, usa phase discovery.`, 'WORK_ASSERTION');
|
|
377
|
+
}
|
|
317
378
|
}
|
|
318
379
|
for (const task of definitions) { if (new Set(task.checks.map(check => check.step_id)).size !== task.checks.length) throw failure('IDs de comprobación duplicados.', 'WORK_PLAN_INPUT'); if (task.checks.some(check => Buffer.byteLength(JSON.stringify(check.arguments)) > 4000)) throw failure('Comprobación demasiado grande; declara una llamada pequeña.', 'WORK_PLAN_INPUT'); if (task.depends_on.some(parent => !ids.has(parent) || parent === task.id)) throw failure(`Dependencia inexistente o circular en ${task.id}: ${JSON.stringify(task.depends_on)}. Una tarea no depende de sí misma; IDs disponibles: ${[...ids].join(', ')}.`, 'WORK_PLAN_INPUT'); }
|
|
319
380
|
const visiting = new Set(), visited = new Set();
|
|
320
381
|
const visit = id => { if (visiting.has(id)) throw failure('Dependencias circulares.', 'WORK_PLAN_INPUT'); if (visited.has(id)) return; visiting.add(id); definitions.find(task => task.id === id).depends_on.forEach(visit); visiting.delete(id); visited.add(id); }; ids.forEach(visit);
|
|
321
382
|
definitions.forEach(task => { task.definition_digest = digest(task); const old = book.tasks.find(old => old.id === task.id); if (old?.definition_digest === task.definition_digest && old.work_digest) task.work_digest = old.work_digest; });
|
|
322
383
|
for (const old of book.tasks.filter(task => task.status !== 'done' && task.phase === 'verification' && task.checks.some(isMcpCheck))) if (!definitions.some(task => task.phase === 'verification' && task.checks.some(check => isMcpCheck(check) && old.checks.some(before => mcpServer(before) === mcpServer(check))))) throw failure('La verificación MCP declarada debe conservar checks MCP; Read/wc no sustituyen la etapa de navegador.', 'WORK_VERIFICATION_REQUIRED');
|
|
323
|
-
for (const old of book.tasks.filter(task => task.status !== 'done')) if (!definitions.some(task => task.id === old.id && task.definition_digest === old.definition_digest) && !(input.retire_ids || []).includes(old.id)) throw failure(`La tarea abierta ${old.id} no puede desaparecer: para corregir sólo checks usa WorkCheckRepair y conserva el borrador; para cambiar el encargo
|
|
384
|
+
for (const old of book.tasks.filter(task => task.status !== 'done')) if (!definitions.some(task => task.id === old.id && task.definition_digest === old.definition_digest) && !(input.retire_ids || []).includes(old.id)) { const readable = old.checks.length && old.checks.every(check => readOnlyWorkCheck(check.operation, check.arguments)); if (readable) throw failure(`La tarea abierta ${old.id} no puede desaparecer: para corregir sólo los argumentos de checks existentes usa WorkCheckRepair y conserva el borrador; para añadir checks o cambiar el encargo, retírala y redeclárala con el MISMO id en la misma llamada (append:true, retire_ids y reason); así sus dependientes siguen válidos.`); throw failure(`La tarea abierta ${old.id} no puede desaparecer y sus checks (MCP u otros efectos opacos) no se reparan con la casa: conserva su definición tal cual, o retírala y declara una tarea nueva con los checks corregidos. `+JSON.stringify({ retire_example: { retire_ids: [old.id], reason: 'Sustituida por una tarea con checks corregidos.' } }), 'WORK_PLAN_INPUT'); }
|
|
324
385
|
if (input.retire_ids?.length && !input.reason?.trim()) throw failure('Retirar tareas requiere reason.');
|
|
325
386
|
const planDigest = digest({ objective: input.objective, tasks: definitions });
|
|
326
387
|
if (book.plan?.digest === planDigest) return { version: book.plan.version, plan_digest: planDigest, unchanged: true };
|
|
@@ -399,11 +460,11 @@ export class WorkCoordinator {
|
|
|
399
460
|
if (input.verify) {
|
|
400
461
|
if (input.status !== 'done') throw failure('verify sólo corresponde al cierre de una tarea.');
|
|
401
462
|
const loaded = await this.store.load(options.sessionId);
|
|
402
|
-
let book = foldWorkBook(loaded, this.house.stateContext()), task = this.task(book, input);
|
|
463
|
+
let book = foldWorkBook(loaded, this.house.stateContext()), task = this.task(book, input, 'WorkTaskUpdate');
|
|
403
464
|
if (task.declared_status !== 'in_progress' || task.held_by.some(id => book.tasks.find(parent => parent.id === id)?.declared_status !== 'done')) {
|
|
404
465
|
const finish = workProjection(loaded,this.house.stateContext())?.finish_call;
|
|
405
466
|
if (finish && task.declared_status === 'done') throw failure(JSON.stringify({code:'WORK_FINISH_REQUIRED',executed:false,direct_call:finish,next_action:'Invoke this active task finish call directly. It refreshes stale closed parents without reopening them.'}),'WORK_FINISH_REQUIRED');
|
|
406
|
-
throw failure('Activa una tarea sin dependencias pendientes antes de verificar.');
|
|
467
|
+
throw failure('Activa una tarea sin dependencias pendientes antes de verificar. '+JSON.stringify(inactiveTaskHint(book, task)), 'WORK_TASK_INACTIVE');
|
|
407
468
|
}
|
|
408
469
|
const refresh = async taskId => {
|
|
409
470
|
const latest = foldWorkBook(await this.store.load(options.sessionId), this.house.stateContext()), old = this.task(latest, { task_id: taskId, plan_version: input.plan_version });
|
|
@@ -416,19 +477,19 @@ export class WorkCoordinator {
|
|
|
416
477
|
} catch (error) { throw failure(`El check de la tarea cerrada ${old.id} perdió vigencia: ${error.message}. Repara la superficie antes de cerrar ${input.task_id}.`, error.code || 'WORK_UNVERIFIED'); }
|
|
417
478
|
};
|
|
418
479
|
for (const old of book.tasks.filter(old => old.id !== task.id && old.declared_status === 'done')) await refresh(old.id);
|
|
419
|
-
book = foldWorkBook(await this.store.load(options.sessionId), this.house.stateContext()); task = this.task(book, input);
|
|
420
|
-
if (task.status !== 'in_progress' || task.held_by.length) throw failure('Activa una tarea sin dependencias pendientes antes de verificar.');
|
|
480
|
+
book = foldWorkBook(await this.store.load(options.sessionId), this.house.stateContext()); task = this.task(book, input, 'WorkTaskUpdate');
|
|
481
|
+
if (task.status !== 'in_progress' || task.held_by.length) throw failure('Activa una tarea sin dependencias pendientes antes de verificar. '+JSON.stringify(inactiveTaskHint(book, task)), 'WORK_TASK_INACTIVE');
|
|
421
482
|
for (const check of task.checks) {
|
|
422
483
|
const step = book.steps.find(step => step.task_id === task.id && step.step_id === check.step_id);
|
|
423
484
|
const receipt = book.receipts.get(step?.receipt_id);
|
|
424
485
|
const staleDependency = receipt && task.depends_on.some(parent => receipt.evidence_row <= book.tasks.find(row => row.id === parent).ref);
|
|
425
486
|
if (book.checkValid(task, check, step) && !staleDependency) continue;
|
|
426
|
-
if (!readOnlyWorkCheck(check.operation, check.arguments)) throw failure(`Check ${check.step_id} requiere WorkStepRun explícito; verify no repite efectos opacos
|
|
487
|
+
if (!readOnlyWorkCheck(check.operation, check.arguments)) throw failure(`Check ${check.step_id} requiere WorkStepRun explícito; verify no repite efectos opacos. `+retryHint(task, check, input.plan_version, book), 'WORK_UNCERTAIN');
|
|
427
488
|
await this.runStep({ task_id: task.id, plan_version: input.plan_version, step_id: check.step_id, recheck: true }, options);
|
|
428
489
|
}
|
|
429
490
|
}
|
|
430
491
|
const result = await this.mutate(options.sessionId, async (_, book) => {
|
|
431
|
-
const task = this.task(book, input); let receiptIds = []; const dependencyProofs = {};
|
|
492
|
+
const task = this.task(book, input, 'WorkTaskUpdate'); let receiptIds = []; const dependencyProofs = {};
|
|
432
493
|
if (input.status === 'in_progress') {
|
|
433
494
|
if (task.status === 'done') throw failure('Una tarea cerrada no se reabre por accidente; declara un nuevo plan para trabajo nuevo.');
|
|
434
495
|
if (task.held_by.length) throw failure('Dependencias sin cerrar.');
|
|
@@ -437,12 +498,17 @@ export class WorkCoordinator {
|
|
|
437
498
|
if (input.status === 'blocked' && !input.reason?.trim()) throw failure('Un bloqueo requiere reason.');
|
|
438
499
|
if (input.status === 'done') {
|
|
439
500
|
if (task.held_by.length || !task.checks.length) throw failure('Cierre requiere dependencias cerradas y comprobaciones declaradas.', 'WORK_UNVERIFIED');
|
|
501
|
+
const verifiedChecks = [];
|
|
440
502
|
for (const check of task.checks) {
|
|
441
503
|
const step = book.steps.find(step => step.task_id === task.id && step.task_digest === workDigest(task) && step.step_id === check.step_id && step.operation === check.operation && step.arguments_digest === digest(check.arguments));
|
|
442
504
|
const receipt = book.receipts.get(step?.receipt_id);
|
|
443
505
|
if (receipt && task.depends_on.some(parent => receipt.evidence_row <= book.tasks.find(row => row.id === parent).ref)) throw failure('La comprobación precede al cierre de una dependencia; usa recheck:true.', 'WORK_UNVERIFIED');
|
|
444
|
-
if (!book.checkValid(task, check, step)) throw failure(`Comprobación ${check.step_id} sin recibo vigente o aserción satisfecha de la casa
|
|
445
|
-
receiptIds.push(receipt.invocation_id);
|
|
506
|
+
if (!book.checkValid(task, check, step)) throw failure(`Comprobación ${check.step_id} sin recibo vigente o aserción satisfecha de la casa. `+retryHint(task, check, book.plan.version, book), 'WORK_UNVERIFIED');
|
|
507
|
+
receiptIds.push(receipt.invocation_id); verifiedChecks.push({ check, receipt });
|
|
508
|
+
}
|
|
509
|
+
for (const guard of this.closeGuards) {
|
|
510
|
+
try { await guard({ task, checks: verifiedChecks, receipts: [...book.receipts.values()], sessionId: options.sessionId }); }
|
|
511
|
+
catch (error) { throw error.required_check ? withRequiredCheckRecovery(error, task, book) : error; }
|
|
446
512
|
}
|
|
447
513
|
const artifacts = new Map();
|
|
448
514
|
const candidates = book.steps.filter(step => step.task_id === task.id && step.status === 'returned').map(step => book.receipts.get(step.receipt_id));
|
|
@@ -457,7 +523,7 @@ export class WorkCoordinator {
|
|
|
457
523
|
for (const [file, receipt] of artifacts) { if (!book.receiptValid(receipt)) throw failure(`Un artefacto de la tarea cambió después de su observación: ${file}. Verifica los bytes actuales mediante un check declarado con artifacts o WorkStepRun Read nuevo; no restaures versiones antiguas.`, 'WORK_UNVERIFIED'); receiptIds.push(receipt.invocation_id); }
|
|
458
524
|
receiptIds = [...new Set(receiptIds)];
|
|
459
525
|
for (const parent of task.depends_on) dependencyProofs[parent] = digest(book.tasks.find(row => row.id === parent).receipt_ids);
|
|
460
|
-
if (book.steps.some(step => step.task_id === task.id && (['running', 'interrupted'].includes(step.status)||step.status==='failed'&&!step.failure_closed))) throw failure('Hay un paso con efectos inciertos: inspecciona antes de cerrar.', 'WORK_UNVERIFIED');
|
|
526
|
+
if (book.steps.some(step => step.task_id === task.id && (['running', 'interrupted'].includes(step.status)||step.status==='failed'&&!step.failure_closed&&!readOnlyWorkCheck(step.operation, step.arguments)))) throw failure('Hay un paso con efectos inciertos: inspecciona antes de cerrar.', 'WORK_UNVERIFIED');
|
|
461
527
|
}
|
|
462
528
|
// Artifact/check reads above can yield to external changes. Recheck the
|
|
463
529
|
// original authority and close guards at the final durable done frontier.
|
|
@@ -478,7 +544,7 @@ export class WorkCoordinator {
|
|
|
478
544
|
async runStep(input, options) {
|
|
479
545
|
if(!options.draftPublication)await this.fact(options,{type:'work_call_prepared',operation:'WorkStepRun',input});
|
|
480
546
|
if (input.operation === undefined || input.arguments === undefined) {
|
|
481
|
-
const book = foldWorkBook(await this.store.load(options.sessionId), this.house.stateContext()), task = this.task(book, input), check = task.checks.find(check => check.step_id === input.step_id);
|
|
547
|
+
const book = foldWorkBook(await this.store.load(options.sessionId), this.house.stateContext()), task = this.task(book, input, 'WorkStepRun'), check = task.checks.find(check => check.step_id === input.step_id);
|
|
482
548
|
if (!check) throw failure('Un paso nuevo requiere operation y arguments; para checks basta su step_id.');
|
|
483
549
|
input = { ...input, operation: input.operation ?? check.operation, arguments: input.arguments ?? check.arguments };
|
|
484
550
|
}
|
|
@@ -488,22 +554,35 @@ export class WorkCoordinator {
|
|
|
488
554
|
throw failure(JSON.stringify({code:'WORK_CONTROL_DIRECT',executed:false,direct_call:{name:input.operation,input:input.operation==='WorkCheckRepair'?{plan_version:version}:input.arguments},next_action:'Invoke direct_call as a TOP-LEVEL tool call, never inside WorkStepRun. No step was reserved and no effect executed.'}), 'WORK_CONTROL_DIRECT');
|
|
489
555
|
}
|
|
490
556
|
const staged = await this.mutate(options.sessionId, async (session, book) => {
|
|
491
|
-
const task = this.task(book, input);
|
|
557
|
+
const task = this.task(book, input, 'WorkStepRun');
|
|
492
558
|
const check = task.checks.find(check => check.step_id === input.step_id);
|
|
493
559
|
const refresh = options.refreshCompleted && task.declared_status === 'done' && check && readOnlyWorkCheck(check.operation, check.arguments);
|
|
494
560
|
if ((!refresh && task.status !== 'in_progress') || task.held_by.length) {
|
|
495
561
|
const finish = workProjection(session,this.house.stateContext())?.finish_call;
|
|
496
562
|
if (finish?.input.task_id === task.id) throw failure(JSON.stringify({code:'WORK_FINISH_REQUIRED',executed:false,direct_call:finish,next_action:'Use this TOP-LEVEL finish call; the house refreshes already closed read dependencies before checking this task. No step was reserved.'}),'WORK_FINISH_REQUIRED');
|
|
497
|
-
throw failure('Activa una tarea sin dependencias pendientes antes de ejecutar.');
|
|
563
|
+
throw failure('Activa una tarea sin dependencias pendientes antes de ejecutar. '+JSON.stringify(inactiveTaskHint(book, task)), 'WORK_TASK_INACTIVE');
|
|
498
564
|
}
|
|
499
565
|
if (input.recheck && !check) throw failure('recheck sólo corresponde a una comprobación declarada.');
|
|
500
|
-
if (check && (check.operation !== input.operation || digest(check.arguments) !== digest(input.arguments)))
|
|
566
|
+
if (check && (check.operation !== input.operation || digest(check.arguments) !== digest(input.arguments))) {
|
|
567
|
+
// Same operation, new arguments: the declared check itself may be wrong (it cannot pass as written).
|
|
568
|
+
// WorkCheckRepair is the path that keeps assertions and refuses checks with recorded effects; offer
|
|
569
|
+
// its exact call instead of leaving the model between "new step_id" and "do not replace the plan".
|
|
570
|
+
// Offer it only where WorkCheckRepair accepts it: a check not run yet (no recorded step whose
|
|
571
|
+
// receipts a repair would have to retire) and outside the runtime-ID-only rule for read checks.
|
|
572
|
+
const ranBefore = book.steps.some(step => step.task_id === task.id && step.step_id === check.step_id);
|
|
573
|
+
const runtimeOnly = readOnlyWorkCheck(check.operation, check.arguments) && task.checks.some(item => runtimeBoundReadCheck(item.operation, item.arguments));
|
|
574
|
+
const repairable = input.operation === check.operation && input.arguments !== undefined && !ranBefore && !runtimeOnly;
|
|
575
|
+
const recovery = repairable ? { recovery_call: { name: 'WorkCheckRepair', input: { plan_version: book.plan.version, repairs: [{ task_id: task.id, step_id: check.step_id, expected_digest: digest(check), operation: input.operation, arguments: input.arguments }], reason: 'The declared check cannot pass as written; repair its arguments, keeping its assertions.' } } } : {};
|
|
576
|
+
throw failure('La comprobación propuesta no coincide con el plan. No se cambió nada. '+JSON.stringify({expected_check:check,check_call:{name:'WorkStepRun',input:{task_id:task.id,plan_version:book.plan.version,step_id:check.step_id}},...recovery,observation_hint:(repairable?'Si el check declarado está mal escrito y no puede pasar tal cual, repáralo con recovery_call (WorkCheckRepair conserva sus aserciones) y luego corre check_call con el plan_version nuevo. ':'')+'Para una observación distinta usa otro step_id NUEVO con operation y arguments explícitos; adquiere su pack antes. No reemplaces el plan entero ni reutilices el ID de este check para otra cosa.'}),'WORK_CHECK_MISMATCH');
|
|
577
|
+
}
|
|
501
578
|
const existing = book.steps.find(step => step.task_id === task.id && step.step_id === input.step_id);
|
|
502
579
|
if (existing) {
|
|
503
580
|
if (existing.operation !== input.operation || existing.arguments_digest !== digest(input.arguments)) throw failure('El ID del paso ya nombra otros argumentos. '+JSON.stringify({code:'WORK_STEP_ID_CONFLICT',executed:false,step_id:existing.step_id,operation:existing.operation,arguments_digest:existing.arguments_digest,status:existing.status,receipt_id:existing.receipt_id,read_call:{name:'EvidenceRead',input:{row:existing.receipt_ref ?? existing.ref}},next_action:'Read the original record with read_call; do not repeat a completed effect. Only genuinely new work uses a new step_id.'}), 'WORK_STEP_ID_CONFLICT');
|
|
504
|
-
if (existing.status === 'returned' && !input.recheck) { const receipt = book.receipts.get(existing.receipt_id); if (check ? !book.checkValid(task, check, existing) : !book.receiptValid(receipt)) throw failure('El efecto registrado perdió vigencia o su aserción falló; usa recheck para un check, sin repetir mutaciones.', 'WORK_UNVERIFIED'); return { reuse: receipt }; }
|
|
581
|
+
if (existing.status === 'returned' && !input.recheck) { const receipt = book.receipts.get(existing.receipt_id); if (check ? !book.checkValid(task, check, existing) : !book.receiptValid(receipt)) throw failure('El efecto registrado perdió vigencia o su aserción falló; usa recheck para un check, sin repetir mutaciones.'+(check ? ' '+retryHint(task, check, book.plan.version) : ''), 'WORK_UNVERIFIED'); return { reuse: receipt }; }
|
|
505
582
|
const retryFailedRead = input.recheck && check && existing.status === 'failed' && book.receipts.has(existing.receipt_id) && readOnlyWorkCheck(check.operation, check.arguments);
|
|
506
|
-
|
|
583
|
+
// A declared MCP check whose server answered with an error result is closed: it runs again with recheck.
|
|
584
|
+
const retryClosedMcp = input.recheck && check && existing.status === 'failed' && existing.failure_closed?.kind === 'mcp_error_result';
|
|
585
|
+
if (existing.status !== 'refused' && !(input.recheck && check && existing.status === 'returned') && !retryFailedRead && !retryClosedMcp) { const guide = check && effectfulGuide(book, task, check, book.plan.version); if (guide) throw failure('El check declarado no se repite. '+JSON.stringify(guide), 'WORK_UNCERTAIN'); throw failure('El paso quedó interrumpido o falló con posibles efectos; inspecciona y registra otro paso antes de repetir. Un check de lectura con recibo failed se recupera con el mismo step_id y recheck:true.', 'WORK_UNCERTAIN'); }
|
|
507
586
|
}
|
|
508
587
|
const incoming=this.house.registry.get(input.operation),pureObservation=readOnlyWorkCheck(input.operation,input.arguments)&&incoming?.contract.origin===null&&incoming.contract.effects.workspace==='read'&&incoming.contract.effects.external==='none'&&incoming.contract.effects.authority==='none'&&(incoming.contract.effects.process==='none'||input.operation==='GitInspect'&&incoming.contract.effects.process==='read'||input.operation==='Bash'&&Boolean(readOnlyWorkCheck('Bash',input.arguments)));
|
|
509
588
|
if (!pureObservation&&book.steps.some(step => step.task_id === task.id && (['running', 'interrupted'].includes(step.status) || step.status === 'failed' && !step.failure_closed && !readOnlyWorkCheck(step.operation, step.arguments)))) throw failure('The task has uncertain effects. New mutations remain fenced. Native Read/FileInfo/ListDirectory/Grep observations may inspect current state; do not repeat the uncertain action.', 'WORK_UNCERTAIN');
|
|
@@ -559,14 +638,14 @@ export class WorkCoordinator {
|
|
|
559
638
|
async draft(input, options) {
|
|
560
639
|
await this.fact(options,{type:'work_call_prepared',operation:'WorkDraft',input});
|
|
561
640
|
if (input.action === 'read' || input.action === 'publish') {
|
|
562
|
-
const book = foldWorkBook(await this.store.load(options.sessionId)), task = this.task(book, input), draft = book.drafts.find(draft => draft.task_id === task.id && draft.id === input.draft_id);
|
|
641
|
+
const book = foldWorkBook(await this.store.load(options.sessionId)), task = this.task(book, input, 'WorkDraft'), draft = book.drafts.find(draft => draft.task_id === task.id && draft.id === input.draft_id);
|
|
563
642
|
if (!draft) throw failure('Borrador inexistente.');
|
|
564
643
|
if (input.action === 'read') { const content = draft.parts.join(''), offset = input.offset || 0, limit = input.limit || 4000; return JSON.stringify({ draft_id: draft.id, digest: draft.digest, complete: draft.complete, content: content.slice(offset, offset + limit), ...(offset + limit < content.length ? { next_offset: offset + limit } : {}), bytes: draft.bytes }); }
|
|
565
644
|
if (!draft.complete || input.expected_digest !== undefined && input.expected_digest !== draft.digest) throw failure('No se publica un borrador incompleto o con digest obsoleto.');
|
|
566
645
|
return this.runStep({ ...input, step_id: `publish:${draft.id}:${draft.digest.slice(0, 12)}`, operation: 'Write', arguments: { file_path: draft.file_path, content: draft.parts.join('') }, next_action: input.next_action || 'Run the declared checks on the published artifact.' }, { ...options, draftPublication: true });
|
|
567
646
|
}
|
|
568
647
|
const result = await this.mutate(options.sessionId, async (_, book) => {
|
|
569
|
-
const task = this.task(book, input); if (task.status === 'done') throw failure('La tarea ya está cerrada.');
|
|
648
|
+
const task = this.task(book, input, 'WorkDraft'); if (task.status === 'done') throw failure('La tarea ya está cerrada.');
|
|
570
649
|
if (input.content === undefined || !input.file_path || input.action === 'chunk' && input.index === undefined || input.action === 'append' && !input.part_id) throw failure('append requiere part_id, content y file_path; chunk requiere index, content y file_path.');
|
|
571
650
|
if (book.drafts.filter(draft => draft.task_id === task.id).length >= 32 && !book.drafts.some(draft => draft.task_id === task.id && draft.id === input.draft_id)) throw failure('Máximo 32 borradores por tarea; divide el trabajo.');
|
|
572
651
|
const draft = book.drafts.find(draft => draft.task_id === task.id && draft.id === input.draft_id), parts = draft?.parts || [];
|
|
@@ -584,7 +663,7 @@ export class WorkCoordinator {
|
|
|
584
663
|
const task = { task_id: id, plan_version: version };
|
|
585
664
|
return [
|
|
586
665
|
{name:'WorkFailureReview',description:'Local operator attests review of an exact terminal native Bash failure after inspecting partial state. Keeps failure, mandatory checks and no replay. This is NOT objective execution success or human task acceptance.',audience:'human',surfaces:['cli','tui'],permission:null,effects:{workspace:'none',external:'none',process:'none',authority:'work_failure_review'},input_schema:schema({...task,step_id:id,expected_receipt:{type:'string',format:'uuid'},reason:{type:'string',minLength:20,maxLength:1000}},['task_id','plan_version','step_id','expected_receipt','reason']),run:(input,options)=>this.reviewFailure(input,options)},
|
|
587
|
-
{ name: 'WorkPlanSet', description: 'Plan small tasks before effects; each needs id/title/criterion/checks. Up to 32 artifact files. Checks use exact tools, e.g. Read:{file_path:"file"}. Checks need complete, observed arguments: never invent a target_id. For discovery/implementation, declare a source Read check with a nonempty assertion. Add a separate verification task after observing runtime IDs; an open task cannot be closed without checks or have checks added by repair. Verification phase requires assertions. append:true adds tasks without resending old ones; no permission grant.', input_schema: schema({ objective: str(2200), expected_version: version, append: { type: 'boolean' }, tasks: { type: 'array', minItems: 1, maxItems: 24, items: schema({ id, title: str(300), criterion: str(1000), phase: { type: 'string', enum: ['discovery','implementation','verification'] }, capabilities: { type: 'array', maxItems: 4, uniqueItems: true, items: str(100) }, artifacts: { type: 'array', maxItems: 32, uniqueItems: true, items: str(1024) }, depends_on: { type: 'array', maxItems: 24, items: id }, checks: { type: 'array', maxItems:
|
|
666
|
+
{ name: 'WorkPlanSet', description: 'Plan small tasks before effects; each needs id/title/criterion/checks. Up to 32 artifact files. Checks use exact tools, e.g. Read:{file_path:"file"}. Checks need complete, observed arguments: never invent a target_id. For discovery/implementation, declare a source Read check with a nonempty assertion. Add a separate verification task after observing runtime IDs; an open task cannot be closed without checks or have checks added by repair. Verification phase requires assertions. append:true adds tasks without resending old ones; no permission grant.', input_schema: schema({ objective: str(2200), expected_version: version, append: { type: 'boolean' }, tasks: { type: 'array', minItems: 1, maxItems: 24, items: schema({ id, title: str(300), criterion: str(1000), phase: { type: 'string', enum: ['discovery','implementation','verification'] }, capabilities: { type: 'array', maxItems: 4, uniqueItems: true, items: str(100) }, artifacts: { type: 'array', maxItems: 32, uniqueItems: true, items: str(1024) }, depends_on: { type: 'array', maxItems: 24, items: id }, checks: { type: 'array', maxItems: MAX_TASK_CHECKS, items: schema({ step_id: id, operation: { ...str(128), description: 'Exact callable tool name from the catalogue, never an instruction or test description.' }, arguments: args, assertions: { type: 'array', minItems: 1, maxItems: 4, items: assertionSchema } }, ['step_id', 'operation', 'arguments']) } }, ['id', 'title', 'criterion', 'checks']) }, retire_ids: { type: 'array', maxItems: 24, items: id }, reason: str(1000) }, ['objective', 'expected_version', 'tasks']), run: (input, options) => this.setPlan(input, options) },
|
|
588
667
|
{ name: 'WorkCheckRepair', description: 'Repair check contracts locally without replacing tasks, retiring drafts or replaying effects. Supply current plan_version only for deterministic suggestions (Read/Grep keys, integer types, unique canonical MCP names). Optional repairs use exact check digests. Assertions/criteria/artifacts stay unchanged. Missing MCP: acquire browser.verify then retry. No permission grant.', input_schema: schema({ plan_version: version, literal_types_only: { type: 'boolean' }, repairs: { type: 'array', minItems: 1, maxItems: 8, items: schema({task_id:id,step_id:id,expected_digest:{type:'string',pattern:'^[a-f0-9]{64}$'},operation:str(128),arguments:args},['task_id','step_id','expected_digest']) }, reason:str(1000) }, ['plan_version']), run: (input,options) => this.repairChecks(input,options) },
|
|
589
668
|
{ name: 'WorkTaskUpdate', description: 'Start/block/close a task. done with verify:true runs missing/stale read-only checks from the plan through the judge, then closes. No model roundtrip per check. Opaque/mutating checks require explicit WorkStepRun. Records declared checks, not semantic objective proof.', input_schema: schema({ ...task, status: { type: 'string', enum: ['in_progress', 'blocked', 'done'] }, verify: { type: 'boolean' }, reason: str(1000) }, ['task_id', 'plan_version', 'status']), run: (input, options) => this.updateTask(input, options) },
|
|
590
669
|
{ name: 'WorkStepRun', description: 'Call WorkCheckRepair, ToolPack and WorkTaskUpdate DIRECTLY, never nested here. Run a declared check OR additional work/observations through the judge. Declared check: existing step_id, omit operation/arguments. Additional observation (DOM, console, network, discovery) or implementation: NEW unique step_id plus exact operation and arguments; NO plan edit or check repair is needed. Extra steps do not change assertions. Do not rename existing checks. Retry failed read-only checks with SAME step_id and recheck:true; never repeat uncertain mutations. Completed steps reuse receipts.', input_schema: schema({ ...task, step_id: id, operation: str(128), arguments: args, next_action: str(1000), recheck: { type: 'boolean' } }, ['task_id', 'plan_version', 'step_id']), run: (input, options) => this.runStep(input, options) },
|
|
@@ -594,15 +673,72 @@ export class WorkCoordinator {
|
|
|
594
673
|
}
|
|
595
674
|
}
|
|
596
675
|
|
|
597
|
-
|
|
676
|
+
// Por qué una tarea no está activa y cuál es la llamada siguiente (gate «extensión de bóveda»: con
|
|
677
|
+
// «Activa una tarea…» a secas el modelo repitió WorkStepRun hasta no_progress).
|
|
678
|
+
export function inactiveTaskHint(book, task) {
|
|
679
|
+
const byId = id => book.tasks.find(parent => parent.id === id);
|
|
680
|
+
const held = task.held_by || [];
|
|
681
|
+
// held_by sale del estado derivado: una dependencia cerrada que perdió vigencia sigue bloqueando.
|
|
682
|
+
const stale = held.filter(id => byId(id)?.declared_status === 'done' && byId(id)?.status !== 'done');
|
|
683
|
+
const pending = held.filter(id => byId(id) && byId(id).declared_status !== 'done');
|
|
684
|
+
const active = book.tasks.find(other => other.id !== task.id && other.declared_status === 'in_progress')?.id || null;
|
|
685
|
+
const base = { code: 'WORK_TASK_INACTIVE', executed: false, task_id: task.id, status: task.status, held_by: held, stale_parents: stale, active_task: active, next_call: null };
|
|
686
|
+
const replan = (ids, why) => ({ ...base, replan_call: { name: 'WorkPlanSet', input: { expected_version: book.plan.version } },
|
|
687
|
+
next_action: `${why} Una tarea cerrada no se reabre: declara un plan nuevo (WorkPlanSet con expected_version ${book.plan.version}) con una tarea que vuelva a hacer las comprobaciones de ${ids.join(', ')}.` });
|
|
688
|
+
if (task.declared_status === 'done' && task.status === 'needs_verification') return replan([task.id], `La tarea ${task.id} está cerrada y perdió vigencia.`);
|
|
689
|
+
if (task.declared_status === 'done' && task.status === 'done') return { ...base, next_action: `La tarea ${task.id} ya está cerrada y vigente: no hace falta activarla ni ejecutarla. Sigue con la siguiente tarea del plan.` };
|
|
690
|
+
if (stale.length) return replan(stale, `Sus dependencias ${stale.join(', ')} están cerradas pero perdieron vigencia.`);
|
|
691
|
+
if (pending.length) return { ...base, next_action: `Cierra antes sus dependencias: ${pending.join(', ')}.` };
|
|
692
|
+
if (held.includes('human_permission')) return { ...base, next_action: 'Espera la respuesta del humano a la autorización pendiente.' };
|
|
693
|
+
if (active) return { ...base, next_action: `Ya está activa ${active}: termínala o bloquéala antes de activar ${task.id}.` };
|
|
694
|
+
return { ...base, next_call: { name: 'WorkTaskUpdate', input: { task_id: task.id, plan_version: book.plan.version, status: 'in_progress' } }, next_action: `Activa ${task.id} con next_call y repite la llamada.` };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// La llamada exacta para volver a comprobar un check sin vigencia: el modelo la copia en vez de adivinar
|
|
698
|
+
// (gate «extensión de bóveda»: con solo «usa recheck» probó WorkBoard y WorkTaskUpdate hasta no_progress).
|
|
699
|
+
// Gate OR-9: un check con efectos cuyo paso ya falló no se repite (ni recheck ni reparación: duplicaría sus
|
|
700
|
+
// efectos). Ofrecer el recheck dejaba al modelo en un bucle con la llamada que la casa rechaza; el camino que
|
|
701
|
+
// existe es replantear: retirar la tarea con reason y declarar una nueva con el check bajo otro step_id.
|
|
702
|
+
const replanGuide = (task, plan_version) => ({ code: 'WORK_CHECK_FAILED_EFFECTS', executed: false, task_id: task.id,
|
|
703
|
+
next_action: `El check falló con posibles efectos y no se repite (ni recheck ni WorkCheckRepair). Replantea con WorkPlanSet: expected_version:${plan_version}, append:true, el mismo objective, retire_ids:["${task.id}"] y reason, y una tarea nueva con el check bajo otro step_id (mejor de solo lectura: Read/Grep o un Bash sin escrituras; la evidencia, en un paso aparte).` });
|
|
704
|
+
// Revisión independiente: con efectos inciertos (paso interrumpido o falla sin cierre) setPlan tampoco deja
|
|
705
|
+
// retirar la tarea; lo único honesto es inspeccionar y pedir la revisión del operador.
|
|
706
|
+
const operatorGuide = task => ({ code: 'WORK_CHECK_UNCERTAIN', executed: false, task_id: task.id,
|
|
707
|
+
next_action: 'El paso del check quedó con efectos inciertos (interrumpido o sin cierre de la casa): no se repite ni se puede retirar la tarea. Inspecciona el estado con Read y pide la revisión del operador (report_blocked o ask; WorkFailureReview es solo humano).' });
|
|
708
|
+
// null: el recheck procede. 'closed': falló con final conocido (replantear). 'uncertain': efectos inciertos.
|
|
709
|
+
function effectfulCheckState(book, task, check) {
|
|
710
|
+
if (readOnlyWorkCheck(check.operation, check.arguments)) return null;
|
|
711
|
+
const step = book.steps.find(step => step.task_id === task.id && step.step_id === check.step_id);
|
|
712
|
+
if (!step) return null;
|
|
713
|
+
if (['interrupted', 'running'].includes(step.status)) return 'uncertain';
|
|
714
|
+
if (step.status !== 'failed' || step.failure_closed?.kind === 'mcp_error_result') return null;
|
|
715
|
+
return step.failure_closed ? 'closed' : 'uncertain';
|
|
716
|
+
}
|
|
717
|
+
const effectfulGuide = (book, task, check, plan_version) => {
|
|
718
|
+
const state = effectfulCheckState(book, task, check);
|
|
719
|
+
return state === 'closed' ? replanGuide(task, plan_version) : state === 'uncertain' ? operatorGuide(task) : null;
|
|
720
|
+
};
|
|
721
|
+
function retryHint(task, check, plan_version, book = null) {
|
|
722
|
+
const guide = book && effectfulGuide(book, task, check, plan_version);
|
|
723
|
+
if (guide) return JSON.stringify(guide);
|
|
724
|
+
return JSON.stringify({ retry_call: { name: 'WorkStepRun', input: { task_id: task.id, plan_version, step_id: check.step_id, operation: check.operation, arguments: check.arguments, recheck: true } } });
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const coordinators = new WeakMap();
|
|
728
|
+
export function registerWorkOperations({ store, house, onEvent, artifactObservation, closeGuards }) {
|
|
598
729
|
if (artifactObservation) {
|
|
599
730
|
const { sessionId, operation } = artifactObservation;
|
|
600
731
|
if (typeof sessionId !== 'string' || !['Read', 'FileInfo'].includes(operation)) throw failure('Invalid host artifact observer.');
|
|
601
732
|
if (!artifactObservers.has(house)) artifactObservers.set(house, new Map());
|
|
602
733
|
artifactObservers.get(house).set(sessionId, operation);
|
|
603
734
|
}
|
|
604
|
-
|
|
735
|
+
// The first coordinator owns the registered operations; later calls on the same
|
|
736
|
+
// house add their close guards to it instead of silently dropping them.
|
|
737
|
+
const coordinator = new WorkCoordinator({ store, house, onEvent, closeGuards: [...(closeGuards || [])] });
|
|
738
|
+
const owner = coordinators.get(house);
|
|
739
|
+
if (owner && house.registry.get('WorkTaskUpdate')) { owner.closeGuards.push(...(closeGuards || [])); return coordinator; }
|
|
605
740
|
for (const definition of coordinator.operations()) if (!house.registry.get(definition.name)) house.registry.add(definition);
|
|
741
|
+
coordinators.set(house, coordinator);
|
|
606
742
|
return coordinator;
|
|
607
743
|
}
|
|
608
744
|
|
package/src/work-state.mjs
CHANGED
|
@@ -102,7 +102,7 @@ export function modelWorkState(session, options = {}) {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
const preferredPaths = (state.work?.current?.artifacts || []).map(file => path.resolve(session.cwd, file));
|
|
105
|
-
state.file_working_set = recordedFilePages(session, 24000, { preferredPaths });
|
|
105
|
+
state.file_working_set = recordedFilePages(session, options.files?.byteBudget ?? 24000, { preferredPaths, maxFiles: options.files?.maxFiles, preferEdited: options.files?.preferEdited });
|
|
106
106
|
// Fold and validate the complete host state before projecting away its plan,
|
|
107
107
|
// step history and identifiers. Long Chrome tasks also reach this path.
|
|
108
108
|
if (state.workflow?.kind === 'chrome-task' && state.work) state.work = {
|
|
@@ -116,7 +116,7 @@ export function modelWorkState(session, options = {}) {
|
|
|
116
116
|
|
|
117
117
|
// Reconstruct already-read pages, never fetch files or interpret model prose.
|
|
118
118
|
// Hash currency is only the latest RECORDED observation, not a live disk claim.
|
|
119
|
-
export function recordedFilePages(session, byteBudget = 24000, { preferredPaths = [] } = {}) {
|
|
119
|
+
export function recordedFilePages(session, byteBudget = 24000, { preferredPaths = [], maxFiles = 4, preferEdited = false } = {}) {
|
|
120
120
|
const audit = auditExecution(session), files = new Map(), argumentsByCall = new Map(), workArguments = new Map();
|
|
121
121
|
const fileKey = (path, hash) => JSON.stringify([path, hash]);
|
|
122
122
|
const textHash = text => createHash('sha256').update(text).digest('hex');
|
|
@@ -146,7 +146,7 @@ export function recordedFilePages(session, byteBudget = 24000, { preferredPaths
|
|
|
146
146
|
}
|
|
147
147
|
if (typeof content !== 'string' || textHash(content) !== write.after_sha256) continue;
|
|
148
148
|
const lines = new Map(content.split('\n').map((line,index)=>[index+1,line]));
|
|
149
|
-
files.set(fileKey(write.file_path,write.after_sha256), {file_path:write.file_path,sha256:write.after_sha256,lines,pages:[{row:receipt.evidence_row,call_id:receipt.call_id,first:1,last:lines.size}],last:receipt.evidence_row});
|
|
149
|
+
files.set(fileKey(write.file_path,write.after_sha256), {file_path:write.file_path,sha256:write.after_sha256,lines,pages:[{row:receipt.evidence_row,call_id:receipt.call_id,first:1,last:lines.size}],last:receipt.evidence_row,edited:receipt.evidence_row});
|
|
150
150
|
continue;
|
|
151
151
|
}
|
|
152
152
|
const read = receipt.observations?.find(observation => observation.kind === 'file_read');
|
|
@@ -162,7 +162,7 @@ export function recordedFilePages(session, byteBudget = 24000, { preferredPaths
|
|
|
162
162
|
}
|
|
163
163
|
let budget = byteBudget, backgroundBudget = 2000; const result = [];
|
|
164
164
|
const preferred = new Set(preferredPaths);
|
|
165
|
-
for (const file of [...files.values()].filter(file=>audit.state.find(item=>item.path===file.file_path)?.sha256===file.sha256).sort((a,b) => Number(preferred.has(path.resolve(session.cwd, b.file_path))) - Number(preferred.has(path.resolve(session.cwd, a.file_path))) || b.last - a.last).slice(0,
|
|
165
|
+
for (const file of [...files.values()].filter(file=>audit.state.find(item=>item.path===file.file_path)?.sha256===file.sha256).sort((a,b) => Number(preferred.has(path.resolve(session.cwd, b.file_path))) - Number(preferred.has(path.resolve(session.cwd, a.file_path))) || (preferEdited ? (b.edited ?? -1) - (a.edited ?? -1) : 0) || b.last - a.last).slice(0, maxFiles)) {
|
|
166
166
|
const chosen = new Map(), background = preferred.size > 0 && !preferred.has(path.resolve(session.cwd, file.file_path));
|
|
167
167
|
let available = background ? Math.min(budget, backgroundBudget) : budget;
|
|
168
168
|
for (const page of file.pages.slice().reverse()) for (let line = page.first; line <= page.last; line++) {
|
|
@@ -8,7 +8,11 @@ import { isHumanIntentRecord } from '../trusted-context.mjs';
|
|
|
8
8
|
const required = ['Deploy','Deployment','Open','Targets','Assert','Contexts','Interact','ReadDOM','Console'];
|
|
9
9
|
const entryNames = new Set(['ChromeTask','Read','Glob','Grep','ListDirectory','FileInfo','WebSearch','WebFetch']);
|
|
10
10
|
const fail = (message, code = 'CHROME_TASK_DECLARATION') => Object.assign(new Error(message), { code });
|
|
11
|
-
const activeSkill = session => session.rows.
|
|
11
|
+
const activeSkill = session => session.rows.findLast(row => row.type === 'skill_scope_started' && row.source === 'skill_host' && !session.rows.some(end => end.type === 'skill_scope_completed' && end.source === 'skill_host' && end.scope_id === row.scope_id));
|
|
12
|
+
const skillBlocksAdoption = session => {
|
|
13
|
+
const skill = activeSkill(session);
|
|
14
|
+
return Boolean(skill && (skill.manifest?.context !== 'inline' || skill.manifest?.allowed_tools !== undefined));
|
|
15
|
+
};
|
|
12
16
|
const declarationSchema = structuredClone(chromeTaskSchema);
|
|
13
17
|
// Conversational declaration retains its historical popup defaults. A page-only
|
|
14
18
|
// verifier is selected explicitly by the host at startup, never by model input.
|
|
@@ -48,7 +52,7 @@ export class ChromeTaskAdoption {
|
|
|
48
52
|
async sync() {
|
|
49
53
|
const session = await this.store.load(this.id());
|
|
50
54
|
this.pendingDeclaration = !savedChromeTask(session) && session.rows.some(row => row.type === 'chrome_task_proposed' && row.source === 'workflow_host');
|
|
51
|
-
const available = this.available() && !savedChromeTask(session) && !workProjection(session) && !
|
|
55
|
+
const available = this.available() && !savedChromeTask(session) && !workProjection(session) && !skillBlocksAdoption(session);
|
|
52
56
|
this.focused = Boolean(this.coordinated && available);
|
|
53
57
|
if (!available) { this.house.registry.replace(['ChromeTask'], []); return; }
|
|
54
58
|
const deploy = this.house.registry.get('plugin__chrome-lab__Deploy');
|
|
@@ -66,7 +70,7 @@ export class ChromeTaskAdoption {
|
|
|
66
70
|
const session = await this.store.load(this.id());
|
|
67
71
|
if (!session.journalAudit.verified) throw fail('Invalid session journal.', 'AUDIT_INVALID');
|
|
68
72
|
if (!this.available()) throw fail('Chrome Lab is not active; load its configured plugin before declaring a task.');
|
|
69
|
-
if (
|
|
73
|
+
if (skillBlocksAdoption(session)) throw fail('Only an unrestricted inline skill may declare a coordinated task in its resident session. No contract or tool scope changed.', 'CHROME_TASK_SCOPE');
|
|
70
74
|
if (savedChromeTask(session) || workProjection(session)) throw fail('Existing work is preserved. Continue its contract; start a new session for a different Chrome task.');
|
|
71
75
|
const task = normalizeTask({ directory: 'extension', popup: 'popup.html', ...input, project: 'comal-' + this.id().replaceAll('-', '').slice(0, 24) });
|
|
72
76
|
this.house.registry.get('plugin__chrome-lab__Deploy').validate({ project: task.project, directory: task.directory });
|