@milpa/comal 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-device-v1.json +423 -0
  4. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  5. package/package.json +4 -1
  6. package/skills/mv3/SKILL.md +44 -0
  7. package/skills/mv3/skill.json +4 -0
  8. package/src/agent.mjs +299 -32
  9. package/src/auto-guard.mjs +40 -3
  10. package/src/autocompact.mjs +233 -0
  11. package/src/bash-timeouts.mjs +29 -0
  12. package/src/child-env.mjs +23 -0
  13. package/src/cli.mjs +261 -62
  14. package/src/code-run-artifact.mjs +30 -0
  15. package/src/code-run-journal.mjs +135 -0
  16. package/src/code-run-session-proof.mjs +50 -0
  17. package/src/code-run-session.mjs +97 -0
  18. package/src/code-run-worker.mjs +20 -4
  19. package/src/code-run.mjs +49 -14
  20. package/src/codex-provider.mjs +4 -4
  21. package/src/compaction-notice.mjs +22 -0
  22. package/src/compaction.mjs +162 -23
  23. package/src/connection-settings.mjs +36 -4
  24. package/src/context-pressure.mjs +5 -4
  25. package/src/context.mjs +27 -22
  26. package/src/execution-metrics.mjs +36 -5
  27. package/src/file-recovery-proof.mjs +9 -1
  28. package/src/file-recovery.mjs +36 -3
  29. package/src/gemini-provider.mjs +5 -3
  30. package/src/git-integration-operations.mjs +2 -1
  31. package/src/git-plumbing.mjs +2 -1
  32. package/src/git-source.mjs +2 -1
  33. package/src/git-workspaces.mjs +2 -1
  34. package/src/house.mjs +13 -1
  35. package/src/i18n/cli-es.mjs +12 -1
  36. package/src/i18n/es.mjs +38 -1
  37. package/src/judges.mjs +2 -1
  38. package/src/mcp.mjs +2 -1
  39. package/src/model-controls.mjs +16 -2
  40. package/src/model-deadline.mjs +2 -2
  41. package/src/openai-provider.mjs +8 -4
  42. package/src/operations.mjs +17 -2
  43. package/src/package-identity.mjs +68 -0
  44. package/src/peer-probe.mjs +16 -0
  45. package/src/permissions.mjs +97 -4
  46. package/src/plugin-config.mjs +18 -1
  47. package/src/plugin-controls.mjs +31 -8
  48. package/src/plugin-trust.mjs +15 -6
  49. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  50. package/src/plugins/chrome-lab/client.mjs +242 -12
  51. package/src/plugins/chrome-lab/index.mjs +106 -24
  52. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  53. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  54. package/src/plugins/open-relay/actions.mjs +60 -9
  55. package/src/plugins/open-relay/client.mjs +3 -3
  56. package/src/plugins/open-relay/decision.mjs +3 -2
  57. package/src/plugins/open-relay/index.mjs +17 -7
  58. package/src/plugins/open-relay/lookup.mjs +41 -4
  59. package/src/plugins/open-relay/notices.mjs +44 -3
  60. package/src/plugins/open-relay/panel.mjs +4 -1
  61. package/src/plugins/open-relay/watch.mjs +27 -8
  62. package/src/process-lock.mjs +20 -12
  63. package/src/provider-auth.mjs +6 -3
  64. package/src/provider-profiles.mjs +13 -3
  65. package/src/provider.mjs +175 -30
  66. package/src/qr-scan.mjs +240 -0
  67. package/src/read-only-check.mjs +6 -1
  68. package/src/relay/broker.mjs +173 -0
  69. package/src/relay/cards.mjs +702 -0
  70. package/src/relay/flow.mjs +886 -0
  71. package/src/relay/follow.mjs +119 -0
  72. package/src/relay/git.mjs +23 -0
  73. package/src/relay/hub-client.mjs +163 -0
  74. package/src/relay/state.mjs +55 -0
  75. package/src/remote-control.mjs +283 -15
  76. package/src/remote-device-card.mjs +111 -0
  77. package/src/remote-device.mjs +92 -0
  78. package/src/remote-permissions.mjs +159 -0
  79. package/src/remote-relay.mjs +468 -0
  80. package/src/remote-stats.mjs +205 -0
  81. package/src/session-bus.mjs +13 -3
  82. package/src/session-provider-settings.mjs +2 -2
  83. package/src/skill-runtime.mjs +8 -6
  84. package/src/source-ownership.mjs +3 -1
  85. package/src/tool-manifest.mjs +71 -0
  86. package/src/tool-packs.mjs +22 -6
  87. package/src/tool-schema.mjs +14 -2
  88. package/src/tools.mjs +96 -27
  89. package/src/tui/controller.mjs +4 -1
  90. package/src/tui/discovery.mjs +17 -7
  91. package/src/tui/inbound-blocks.mjs +5 -1
  92. package/src/tui/screen.mjs +53 -14
  93. package/src/tui/swarm-panel.mjs +19 -8
  94. package/src/turn-timing.mjs +82 -0
  95. package/src/usage-accounting.mjs +12 -1
  96. package/src/vision.mjs +6 -1
  97. package/src/visual/chrome.mjs +379 -0
  98. package/src/visual/fingerprint.mjs +30 -0
  99. package/src/visual/fixtures.mjs +201 -0
  100. package/src/visual/gate.mjs +92 -0
  101. package/src/visual/judge.mjs +114 -0
  102. package/src/visual/ledger.mjs +160 -0
  103. package/src/visual/measure.mjs +145 -0
  104. package/src/visual/names.mjs +7 -0
  105. package/src/visual/operations.mjs +211 -0
  106. package/src/visual/pixel-diff.mjs +96 -0
  107. package/src/visual/png.mjs +103 -0
  108. package/src/visual/registry.mjs +265 -0
  109. package/src/visual/report.mjs +64 -0
  110. package/src/visual/verify.mjs +292 -0
  111. package/src/work-assertions.mjs +15 -4
  112. package/src/work-plan.mjs +166 -30
  113. package/src/work-state.mjs +4 -4
  114. package/src/workflows/chrome-task-adoption.mjs +7 -3
  115. package/src/workspace-serve.mjs +59 -0
@@ -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
- for (const assertion of assertions) {
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 = output.includes(assertion.value);
25
- if (assertion.kind === 'not_contains') passed = !output.includes(assertion.value);
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
- retry_call: step.preflight_failure.retry_call || { name: 'WorkStepRun', input: { task_id: step.task_id, plan_version: book.plan.version, step_id: step.step_id } },
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: '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.' };
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
- constructor({ store, house, onEvent = () => {} }) { Object.assign(this, { store, house, onEvent }); }
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 (!task) throw failure('Tarea inexistente.'); return task;
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 proposed = input.append ? [...book.tasks.map(definitionOf), ...input.tasks] : input.tasks;
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))) throw failure('Una tarea verification requiere aserciones explícitas en cada check.', 'WORK_ASSERTION');
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 preserva su definición o declara retire_ids y reason.`);
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.`, 'WORK_UNCERTAIN');
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.`, 'WORK_UNVERIFIED');
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))) 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}},observation_hint:'Para una observación distinta usa otro step_id NUEVO con operation y arguments explícitos; adquiere su pack antes. No reemplaces el plan ni reutilices el ID de este check.'}),'WORK_CHECK_MISMATCH');
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
- if (existing.status !== 'refused' && !(input.recheck && check && existing.status === 'returned') && !retryFailedRead) 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');
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: 4, 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) },
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
- export function registerWorkOperations({ store, house, onEvent, artifactObservation }) {
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
- const coordinator = new WorkCoordinator({ store, house, onEvent });
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
 
@@ -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, 4)) {
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.some(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));
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) && !activeSkill(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 (activeSkill(session)) throw fail('Declare the coordinated task in the resident session before entering a temporary skill scope. No contract or tool scope changed.', 'CHROME_TASK_SCOPE');
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 });