@milpa/comal 0.2.6 → 0.2.8

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.
@@ -14,6 +14,7 @@ import { noticeMailbox } from '../plugin-notices.mjs';
14
14
  import { presentResultBlock, presentToolBlock, presentPermission } from './present.mjs';
15
15
  import { normalizeSwarmView, swarmPanelLines, swarmStripLines, swarmActions } from './swarm-panel.mjs';
16
16
  import { formatTurnTiming, measureHumanWait } from '../turn-timing.mjs';
17
+ import { AgentStatus, agentState, statusLabel } from '../agent-status.mjs';
17
18
 
18
19
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
19
20
  const MOUSE_ON = '\x1b[?1000h\x1b[?1006h';
@@ -33,6 +34,7 @@ export class TerminalScreen {
33
34
  start() {
34
35
  if (this.started || this.closed) return this;
35
36
  this.started = true;
37
+ this.agentStatus ||= new AgentStatus({ output: this.output, env: this.env });
36
38
  this.originalRaw = this.input.isRaw || false;
37
39
  this.input.setRawMode?.(true); this.input.resume();
38
40
  this.decoder = new TerminalInput({ onKey: (text, key) => { Promise.resolve(this.handleKey(text, key)).catch(error => this.notice(error.message, true)); }, onMouse: event => this.handleMouse(event), onPaste: text => this.paste(text), onError: error => this.notice(error.message, true) });
@@ -67,20 +69,22 @@ export class TerminalScreen {
67
69
  clearSetup() { this.setup?.finish(null); }
68
70
  // keys: atajos de una letra → id de opción (p. ej. {e:'edit', r:'reject'}), sólo sin filtro.
69
71
  // hint: línea de ayuda propia. Una descripción larga (tarjetas de /relay) se desplaza con PgUp/PgDn.
70
- setupChoice({ title, description = '', items, signal, searchable = false, selected, keys, hint } = {}) {
72
+ // waits: the choice was pushed to the human (a swarm card, a connection review) and Comal waits on the answer; it is
73
+ // reported as a question waiting. A picker the human opened (/model, /provider) is not.
74
+ setupChoice({ title, description = '', items, signal, searchable = false, selected, keys, hint, waits = false } = {}) {
71
75
  if (!Array.isArray(items) || !items.length || items.length > 1024 || items.some(item => typeof item?.id !== 'string' || !item.id || typeof item.label !== 'string') || new Set(items.map(item => item.id)).size !== items.length) throw new Error(t('Choose between 1 and 1024 distinct options.'));
72
76
  const shortcuts = !searchable && keys && typeof keys === 'object' ? Object.fromEntries(Object.entries(keys).filter(([key, id]) => /^[a-z]$/.test(key) && items.some(item => item.id === id))) : null;
73
- return new Promise(resolve => this.beginSetup({ kind: 'choice', title: safeText(title || t('Choose a connection')), description: safeText(description), items: items.map(item => ({ id: item.id, label: safeText(item.label), description: safeText(item.description || ''), badge: safeText(item.badge || '') })), index: Math.max(0,items.findIndex(item=>item.id===selected)), searchable, query: '', keys: shortcuts, hint: hint ? safeText(hint) : null, scroll: 0 }, signal, resolve));
77
+ return new Promise(resolve => this.beginSetup({ kind: 'choice', waits: Boolean(waits), title: safeText(title || t('Choose a connection')), description: safeText(description), items: items.map(item => ({ id: item.id, label: safeText(item.label), description: safeText(item.description || ''), badge: safeText(item.badge || '') })), index: Math.max(0,items.findIndex(item=>item.id===selected)), searchable, query: '', keys: shortcuts, hint: hint ? safeText(hint) : null, scroll: 0 }, signal, resolve));
74
78
  }
75
79
  setupItems() {
76
80
  const state=this.setup;
77
81
  const terms=(state?.query || '').toLocaleLowerCase().split(/\s+/).filter(Boolean);
78
82
  return (state?.items || []).filter(item=>terms.every(term=>`${item.label} ${item.id} ${item.description}`.toLocaleLowerCase().includes(term)));
79
83
  }
80
- setupInput({ title, description = '', value = '', secret = false, signal, validate } = {}) {
84
+ setupInput({ title, description = '', value = '', secret = false, signal, validate, waits = false } = {}) {
81
85
  const clean = safeText(value).replace(/[\r\n\t]+/g, ' ');
82
86
  if (Buffer.byteLength(clean) > 4096) throw new Error(t('Setup values support up to 4096 bytes.'));
83
- return new Promise(resolve => this.beginSetup({ kind: 'input', title: safeText(title || t('Connection settings')), description: safeText(description), editor: new Composer(clean), secret: Boolean(secret), validate }, signal, resolve));
87
+ return new Promise(resolve => this.beginSetup({ kind: 'input', waits: Boolean(waits), title: safeText(title || t('Connection settings')), description: safeText(description), editor: new Composer(clean), secret: Boolean(secret), validate }, signal, resolve));
84
88
  }
85
89
  setupStatus({ title, description = '', signal } = {}) {
86
90
  this.beginSetup({ kind: 'status', title: safeText(title || t('Connecting')), description: safeText(description) }, signal);
@@ -193,6 +197,8 @@ export class TerminalScreen {
193
197
  }
194
198
 
195
199
  schedule() {
200
+ // The state goes out even while an editor owns the terminal (Ctrl+G) or a selection freezes the screen.
201
+ if (!this.closed && (this.suspended || this.selecting)) this.syncAgentStatus();
196
202
  if (this.closed || this.suspended || this.selecting || this.renderTimer) return;
197
203
  this.renderTimer = setTimeout(() => { this.renderTimer = null; this.render(); }, 24);
198
204
  }
@@ -395,13 +401,16 @@ export class TerminalScreen {
395
401
  const options = permission.choices.map((choice, index) => index === permission.choice ? (choice === 'no' ? tone.warning(`❯ ${labels[choice]}`) : tone.good(`❯ ${labels[choice]}`)) : ` ${labels[choice]}`);
396
402
  return [clip(t(' Always = save rule {v0}', { v0: permission.remember.rule }), columns - 1), ' ' + options.join(' '), tone.muted(clip(t(' Enter confirms · y once · {v0}u always for me · n/Esc denies', { v0: permission.choices.includes('project') ? t('p this project · ') : '' }), columns - 1))];
397
403
  }
398
- async confirm(name, input, signal, { summary, remember, edit } = {}) {
399
- if (signal?.aborted || this.closed) return false;
404
+ // onOutcome(why) says why the card closed — 'allowed', 'no' (the human's plain No), 'edit', 'interrupt',
405
+ // 'aborted', 'closed' or 'unknown' — without changing what confirm resolves to.
406
+ async confirm(name, input, signal, { summary, remember, edit, onOutcome } = {}) {
407
+ const tell = why => { try { onOutcome?.(why); } catch {} };
408
+ if (signal?.aborted || this.closed) { tell(signal?.aborted ? 'aborted' : 'closed'); return false; }
400
409
  if (this.selecting) this.setSelection(false);
401
410
  return measureHumanWait(new Promise(resolve => {
402
411
  let settled=false;
403
- const finish = allowed => { if(settled)return;settled=true;signal?.removeEventListener('abort', abort);if(this.permission===question)this.permission = null;this.schedule();resolve(allowed); };
404
- const abort = () => finish(false);
412
+ const finish = (allowed, why = allowed ? 'allowed' : 'unknown') => { if(settled)return;settled=true;signal?.removeEventListener('abort', abort);if(this.permission===question)this.permission = null;this.schedule();tell(why);resolve(allowed); };
413
+ const abort = () => finish(false, 'aborted');
405
414
  // remember: { rule, project } ofrece «sí, siempre»; la regla se muestra antes de elegir.
406
415
  const choices = remember?.rule ? ['once', ...(remember.project ? ['project'] : []), 'user', 'no'] : null;
407
416
  const question = { name, input, summary, edit: edit && typeof edit.field === 'string' && typeof edit.text === 'string' ? { field: edit.field, text: edit.text } : null, allowed: false, scroll: 0, finish, ...(choices ? { remember, choices, choice: choices.length - 1 } : {}) };
@@ -412,7 +421,7 @@ export class TerminalScreen {
412
421
  // pasa al composer con una instrucción. El humano lo corrige y lo envía; el modelo
413
422
  // vuelve a proponer la operación y la tarjeta nueva muestra el texto exacto otra vez.
414
423
  editPermission() {
415
- const { name, edit } = this.permission; this.permission.finish(false);
424
+ const { name, edit } = this.permission; this.permission.finish(false, 'edit');
416
425
  const before = this.composer.text;
417
426
  try {
418
427
  this.composer.setText(before ? before + '\n\n' : '');
@@ -487,15 +496,15 @@ export class TerminalScreen {
487
496
  return;
488
497
  }
489
498
  if (key.ctrl && name === 'c' && this.overlay?.kind === 'palette' && !this.permission) { this.closeOverlay(); return; }
490
- if (key.ctrl && name === 'c') { if (this.permission) this.permission.finish(false); if (this.busy) await this.callbacks.onAction?.('interrupt'); else if (this.composer.text) { this.composer.clear(); this.draftChanged(); } else await this.callbacks.onAction?.('exit'); return; }
499
+ if (key.ctrl && name === 'c') { if (this.permission) this.permission.finish(false, 'interrupt'); if (this.busy) await this.callbacks.onAction?.('interrupt'); else if (this.composer.text) { this.composer.clear(); this.draftChanged(); } else await this.callbacks.onAction?.('exit'); return; }
491
500
  if (this.permission) {
492
501
  const permission = this.permission, answer = choice => choice === 'project' || choice === 'user' ? { allowed: true, remember: choice } : choice === 'once';
493
- if (name === 'escape' || name === 'n') permission.finish(false);
502
+ if (name === 'escape' || name === 'n') permission.finish(false, 'no');
494
503
  else if (name === 'y') permission.finish(true);
495
504
  else if (permission.choices && ((name === 'p' && permission.choices.includes('project')) || name === 'u')) permission.finish(answer(name === 'p' ? 'project' : 'user'));
496
505
  else if (permission.choices && (name === 'left' || name === 'right' || name === 'tab')) permission.choice = (permission.choice + (name === 'left' ? permission.choices.length - 1 : 1)) % permission.choices.length;
497
506
  else if (name === 'left' || name === 'right' || name === 'tab') permission.allowed = !permission.allowed;
498
- else if (name === 'return') permission.finish(permission.choices ? answer(permission.choices[permission.choice]) : permission.allowed);
507
+ else if (name === 'return') { const value = permission.choices ? answer(permission.choices[permission.choice]) : permission.allowed; permission.finish(value, value ? 'allowed' : 'no'); }
499
508
  else if (name === 'pageup') this.permission.scroll = Math.max(0, this.permission.scroll - 10);
500
509
  else if (name === 'pagedown') this.permission.scroll += 10;
501
510
  else if (name === 'r') { this.permission.raw = !(this.permission.raw ?? this.rawView); this.permission.scroll = 0; }
@@ -710,6 +719,7 @@ export class TerminalScreen {
710
719
  }
711
720
  render(selectionEntry = false) {
712
721
  if (this.closed || this.suspended || (this.selecting && !selectionEntry)) return;
722
+ this.syncAgentStatus();
713
723
  const columns = Math.max(1, (this.output.columns || 80) - 1), rows = this.output.rows || 24;
714
724
  if (columns < 23 || rows < 10) { this.draw([clip(t("Enlarge the terminal to 24×10"), columns)], null, columns, rows); return; }
715
725
  if (this.setup && !this.permission) { this.renderSetup(columns, rows); return; }
@@ -803,11 +813,20 @@ export class TerminalScreen {
803
813
  this.suspended = true; clearTimeout(this.renderTimer); this.renderTimer = null;
804
814
  this.input.setRawMode?.(this.originalRaw); this.input.pause(); this.output.write(MOUSE_OFF + '\x1b[?2004l\x1b[?25h\x1b[?1049l');
805
815
  }
806
- resume() { this.input.setRawMode?.(true); this.input.resume(); this.suspended = false; this.previous = []; this.output.write('\x1b[?1049h\x1b[?2004h' + MOUSE_ON); this.render(); }
816
+ resume() { this.input.setRawMode?.(true); this.input.resume(); this.suspended = false; this.previous = []; this.agentStatus?.forgetTitle(); this.output.write('\x1b[?1049h\x1b[?2004h' + MOUSE_ON); this.render(); }
817
+ // The terminal title and, inside herdr, the pane's agent state follow what the screen shows (working, a question
818
+ // waiting for the human, or idle). Best-effort: see agent-status.mjs.
819
+ syncAgentStatus() {
820
+ if (!this.agentStatus) return;
821
+ const state = agentState({ busy: this.busy, permission: this.permission, setup: this.setup });
822
+ const message = state !== 'blocked' ? '' : this.permission ? t('Permission: {v0}', { v0: this.permission.name }) : (this.setup?.title || t('Waiting for your answer'));
823
+ this.agentStatus.update({ state, label: statusLabel({ name: this.metadata.name, cwd: this.metadata.cwd }), message, sessionId: this.metadata.sessionId, titles: !this.suspended });
824
+ }
807
825
  close() {
808
826
  if (this.closed) return; this.closed = true;
827
+ this.agentStatus?.close();
809
828
  this.clearSetup();
810
- this.permission?.finish(false); clearInterval(this.timer); clearTimeout(this.renderTimer); this.decoder?.close();
829
+ this.permission?.finish(false, 'closed'); clearInterval(this.timer); clearTimeout(this.renderTimer); this.decoder?.close();
811
830
  if (this.onData) this.input.off('data', this.onData); if (this.onResize) this.output.off('resize', this.onResize);
812
831
  if (this.started) { this.input.setRawMode?.(this.originalRaw); this.input.pause(); if (!this.suspended) this.output.write('\x1b[0m' + MOUSE_OFF + '\x1b[?2004l\x1b[?25h\x1b[?1049l'); }
813
832
  this.resolveFinished();
package/src/work-plan.mjs CHANGED
@@ -171,6 +171,77 @@ function closedArtifactProcessing(session,step,audit,active){
171
171
  return {kind:'native_media_processing_acknowledged',media_id:media[1],receipt_id:leaf.invocation_id,parent_receipt_id:parent.invocation_id,candidate_created:false,retry_authorized:false,derivation:'legacy_builtin_publisher_processing_result'};
172
172
  }
173
173
 
174
+ // A step that failed on a plugin's broken promise, when that promise was then repaired in this session.
175
+ // The plugin threw, so the house cannot close the failure by itself: it does not know whether the call had
176
+ // an outside effect first. What closes it is narrow and read from sealed rows only:
177
+ // - the failed receipt is an operation of plugin P, run by this step;
178
+ // - a plugin_repair_accepted row for P was sealed after it (a repair that rolled back seals none);
179
+ // - a finding owned by P about that SAME call (the operation and the arguments of the failed receipt, as the
180
+ // reproduction's own receipt shows) was resolved repaired_in_run with that accepted row as proof.
181
+ // The cost is stated, not hidden: effects of the failed call stay unjudged, and the step may run again.
182
+ const PLUGIN_OPERATION = /^plugin__([a-z][a-z0-9]*(?:-[a-z0-9]+)*)__[A-Za-z][A-Za-z0-9_]*$/;
183
+ const sealedRef = row => row?.audit && Number.isInteger(row.audit.sequence) && typeof row.audit.sha256 === 'string' ? { sequence: row.audit.sequence, sha256: row.audit.sha256 } : null;
184
+ const sameRef = (row, ref) => Boolean(sealedRef(row)) && Boolean(ref) && row.audit.sequence === ref.sequence && row.audit.sha256 === ref.sha256;
185
+ function repairProof(rows, step, acceptedRef) {
186
+ const plugin = PLUGIN_OPERATION.exec(step.operation || '')?.[1];
187
+ if (!plugin || step.status !== 'failed' || !step.receipt_id) return null;
188
+ const failedAt = rows.findIndex(row => row.type === 'house_receipt' && row.invocation_id === step.receipt_id), failed = rows[failedAt];
189
+ if (!failed || failed.status !== 'failed' || failed.operation !== step.operation || failed.parent_invocation_id !== step.invocation_id || !sealedRef(failed)) return null;
190
+ const acceptedAt = rows.findIndex(row => row.type === 'plugin_repair_accepted' && row.source === 'plugin_repair' && row.plugin === plugin && sameRef(row, acceptedRef));
191
+ if (acceptedAt <= failedAt) return null;
192
+ for (let index = acceptedAt + 1; index < rows.length; index++) {
193
+ const resolution = rows[index];
194
+ if (resolution.type !== 'finding_resolution' || resolution.state !== 'repaired_in_run' || !sameRef(rows[acceptedAt], resolution.proof?.accepted)) continue;
195
+ const finding = rows.find(row => row.type === 'finding' && row.id === resolution.finding_id && row.owner === `plugin:${plugin}` && row.where?.operation === step.operation);
196
+ // The repaired case must be the very call that failed: the receipt of its reproduction (the finding's
197
+ // evidence) carries the same arguments as the step's failed receipt. Another call of the same operation
198
+ // proves nothing about this one.
199
+ const sameCall = finding && typeof failed.arguments_digest === 'string' && (finding.evidence ?? []).some(item => rows.some(row => row.type === 'house_receipt' && sameRef(row, item) && row.operation === step.operation && row.arguments_digest === failed.arguments_digest));
200
+ if (sameCall) return { plugin, operation: step.operation, failed_receipt: { invocation_id: failed.invocation_id, ...sealedRef(failed) }, accepted: sealedRef(rows[acceptedAt]), accepted_at: acceptedAt, finding: finding.id };
201
+ }
202
+ return null;
203
+ }
204
+ function closedPluginRepair(session, step, audit, active) {
205
+ if (step.status !== 'failed' || active.has(step.invocation_id) || !audit.journal.verified || audit.violations.length || audit.unverified.length) return null;
206
+ const rows = session.rows;
207
+ for (let index = rows.length - 1; index >= 0; index--) {
208
+ const row = rows[index];
209
+ if (row.type !== 'work_fence_released' || row.source !== 'work_host' || row.task_id !== step.task_id || row.step_id !== step.step_id || row.attempt_id !== step.attempt_id || row.task_digest !== step.task_digest) continue;
210
+ const proof = repairProof(rows, step, row.accepted);
211
+ if (!proof || index <= proof.accepted_at || row.plugin !== proof.plugin || row.failed_receipt?.invocation_id !== proof.failed_receipt.invocation_id || row.failed_receipt.sequence !== proof.failed_receipt.sequence || row.failed_receipt.sha256 !== proof.failed_receipt.sha256) continue;
212
+ return { kind: 'plugin_repaired_in_run', receipt_id: proof.failed_receipt.invocation_id, plugin: proof.plugin, accepted: proof.accepted, finding: proof.finding, release_ref: index, partial_effects: 'preserved_unjudged', external_effects: 'unjudged', retry_authorized: true };
213
+ }
214
+ return null;
215
+ }
216
+ /**
217
+ * After the house accepted a repair of `plugin` (the sealed plugin_repair_accepted row `accepted`: { sequence,
218
+ * sha256 }): every step that stays fenced because it failed on the very call that repair reproduced (the same operation
219
+ * with the same arguments) is released,
220
+ * each with a sealed work_fence_released row naming the task, the step, the failed receipt and the accepted
221
+ * row. Returns the steps the fold now reads as released: [{ task_id, step_id, operation }]. Nothing else is
222
+ * touched: another operation, another call of that operation, another plugin, or a step that is not a plugin
223
+ * operation stay fenced.
224
+ */
225
+ export async function releaseRepairedSteps({ store, house, sessionId, plugin, accepted, invocation_id }) {
226
+ const context = () => house?.stateContext?.() ?? {};
227
+ const release = async () => {
228
+ const session = await store.load(sessionId);
229
+ if (!session.journalAudit.consistent) return [];
230
+ const wrote = [];
231
+ for (const step of foldWorkBook(session, context()).steps) {
232
+ if (step.status !== 'failed' || step.failure_closed) continue;
233
+ const proof = repairProof(session.rows, step, accepted);
234
+ if (!proof || proof.plugin !== plugin) continue;
235
+ await store.append(sessionId, { type: 'work_fence_released', source: 'work_host', reason: 'plugin_repaired_in_run', ...(invocation_id ? { invocation_id } : {}), task_id: step.task_id, task_digest: step.task_digest, plan_version: step.plan_version, step_id: step.step_id, attempt_id: step.attempt_id,
236
+ operation: step.operation, plugin, failed_receipt: proof.failed_receipt, accepted: proof.accepted, finding: proof.finding, effects_of_failed_call: 'unjudged' });
237
+ wrote.push(step.attempt_id);
238
+ }
239
+ if (!wrote.length) return [];
240
+ return foldWorkBook(await store.load(sessionId), context()).steps.filter(step => wrote.includes(step.attempt_id) && step.failure_closed?.kind === 'plugin_repaired_in_run').map(step => ({ task_id: step.task_id, step_id: step.step_id, operation: step.operation }));
241
+ };
242
+ return typeof store.workTransaction === 'function' ? store.workTransaction(sessionId, release) : release();
243
+ }
244
+
174
245
  // A board is a fold of host facts. Model prose, nested receipts and thinking are not facts.
175
246
  export function foldWorkBook(session,context={}) {return foldWorkWithAudit(session,context,()=>auditExecution(session));}
176
247
  function foldWorkWithAudit(session, { active = new Set() } = {},auditFor) {
@@ -228,7 +299,7 @@ function foldWorkWithAudit(session, { active = new Set() } = {},auditFor) {
228
299
  if (proof) Object.assign(step, { status: 'refused', preflight_failure: proof, recovery_ref: proof.parent_receipt_ref,
229
300
  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.' });
230
301
  }
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;}
302
+ 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)||closedPluginRepair(session,step,audit,active);if(proof)step.failure_closed=proof;}
232
303
  // Explicit local operator review is an attestation, not reconstructed execution
233
304
  // success. Only sealed human-control receipts can review a closed native Bash
234
305
  // failure; raw facts and model prose never clear a fence.
@@ -320,6 +391,7 @@ function projectWork(book) {
320
391
  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.'})),
321
392
  publication_refusals: book.steps.filter(step=>step.failure_closed?.kind==='native_artifact_contract_rejected').map(step=>({task_id:step.task_id,step_id:step.step_id,...step.failure_closed,next_action:'Preserve the ORIGINAL accepted checks. Correct the app to satisfy them, then publish the corrected files using a new judged step and the observed base_version. Do not change the verification contract or replay the failed step ID.'})),
322
393
  reviewed_failures: book.steps.filter(step=>['native_process_timeout_terminated','operator_reviewed_native_failure'].includes(step.failure_closed?.kind)).map(step=>({task_id:step.task_id,step_id:step.step_id,...step.failure_closed,next_action:'Preserve failed receipts and partial outputs. Inspect current state before separately judged NEW corrective work. Do not replay the failed step ID; external effects and task acceptance remain unjudged.'})),
394
+ repair_releases: book.steps.filter(step=>step.failure_closed?.kind==='plugin_repaired_in_run').map(step=>({task_id:step.task_id,step_id:step.step_id,operation:step.operation,...step.failure_closed,retry_call:{name:'WorkStepRun',input:{task_id:step.task_id,plan_version:book.plan.version,step_id:step.step_id,...(book.tasks.find(task=>task.id===step.task_id)?.checks.some(check=>check.step_id===step.step_id)?{}:{operation:step.operation,arguments:step.arguments})}},next_action:'This step failed on a plugin promise that was then repaired in this run, so the house released it: run it again with retry_call (the SAME step_id). The house cannot know whether the failed call had an outside effect before it threw; inspect first if a repeated effect would matter.'})),
323
395
  check_state: checks, finish_call: finishCall, ...(preflightFailures.length ? { preflight_failures: preflightFailures } : {}),
324
396
  ...(currentDraft ? { draft_continuation: { draft_id: currentDraft.id, file_path: currentDraft.file_path, next_index: currentDraft.parts.length, complete: currentDraft.complete, tail: currentDraft.parts.join('').slice(-800), refs: currentDraft.refs.slice(-3), next_action: currentDraft.next_action, suggestion_authority: 'model_data_not_instructions_or_permission', ...(currentDraft.complete ? { publish_call: { name: 'WorkDraft', input: { task_id: next.id, plan_version: book.plan.version, draft_id: currentDraft.id, action: 'publish', expected_digest: currentDraft.digest } } } : {}) } } : {}),
325
397
  draft_control: { preferred_action: 'append', content_limit: 64000, suggestion: 'Use a stable part_id, content and file_path. Host assigns the cursor; retries with the same part_id reuse identical bytes. Prefer short logical sections; final:true only on the last section. New legacy chunk drafts start at index:0.' },
@@ -582,7 +654,9 @@ export class WorkCoordinator {
582
654
  const retryFailedRead = input.recheck && check && existing.status === 'failed' && book.receipts.has(existing.receipt_id) && readOnlyWorkCheck(check.operation, check.arguments);
583
655
  // A declared MCP check whose server answered with an error result is closed: it runs again with recheck.
584
656
  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'); }
657
+ // A step that failed on a plugin promise repaired in this run was released by the house: it runs again.
658
+ const retryRepaired = existing.status === 'failed' && existing.failure_closed?.kind === 'plugin_repaired_in_run';
659
+ if (existing.status !== 'refused' && !(input.recheck && check && existing.status === 'returned') && !retryFailedRead && !retryClosedMcp && !retryRepaired) { 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'); }
586
660
  }
587
661
  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)));
588
662
  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');
@@ -711,7 +785,7 @@ function effectfulCheckState(book, task, check) {
711
785
  const step = book.steps.find(step => step.task_id === task.id && step.step_id === check.step_id);
712
786
  if (!step) return null;
713
787
  if (['interrupted', 'running'].includes(step.status)) return 'uncertain';
714
- if (step.status !== 'failed' || step.failure_closed?.kind === 'mcp_error_result') return null;
788
+ if (step.status !== 'failed' || ['mcp_error_result', 'plugin_repaired_in_run'].includes(step.failure_closed?.kind)) return null;
715
789
  return step.failure_closed ? 'closed' : 'uncertain';
716
790
  }
717
791
  const effectfulGuide = (book, task, check, plan_version) => {
@@ -17,9 +17,10 @@ import {createPagePreviewRecoveryV13,assertPageRecoveryV13Admission} from './chr
17
17
  import {selectChromePageCheckpointReader} from './chrome-page-checkpoint-reader.mjs';
18
18
  import { nativePageFailure, nativeClosureRefusal } from '../native-refusal.mjs';
19
19
  import { pagePublicationDiagnostic, markPagePublicationDiagnostic } from './chrome-page-diagnostics.mjs';
20
+ import { REPAIR_NAMES, admitRepair, pluginFailure } from './chrome-repair.mjs';
20
21
 
21
22
  const prefix = 'plugin__chrome-lab__', own = ['ChromePublish', 'ChromeVerify', 'ChromeTest', 'ChromeFinish', 'ChromeDeliver', 'ChromeRecover', 'ChromeInspect'];
22
- const publicNames = new Set(['Read', 'Write', 'Edit', ...own, ...CHROME_SOURCE_NAMES]);
23
+ const publicNames = new Set(['Read', 'Write', 'Edit', ...own, ...CHROME_SOURCE_NAMES, ...REPAIR_NAMES]);
23
24
  const empty = { type: 'object', properties: {}, additionalProperties: false };
24
25
  const error = (message, code = 'CHROME_TASK_CONFLICT') => nativePageFailure(message,code);
25
26
  const sourceHash = value => digest(value.files.map(({ path, sha256 }) => ({ path, sha256 })));
@@ -104,7 +105,8 @@ export async function prepareChromePageTask({ cwd, house, store, sessionId, exec
104
105
  throw failure;
105
106
  }
106
107
  };
107
- const invoke = async (options, name, input = {}) => JSON.parse(await options.invoke(prefix + name, input, { signal: options.signal }));
108
+ // A plugin operation the house runs for a task action: a thrown handler error names the operation and its input (chrome-repair.mjs).
109
+ const invoke = async (options, name, input = {}) => { let text; try { text = await options.invoke(prefix + name, input, { signal: options.signal }); } catch (failure) { throw pluginFailure(failure, { house, options, plugin: 'chrome-lab', operation: name, input }); } return JSON.parse(text); };
108
110
  if(recoverySelected)publicationRecovery=createPagePreviewRecoveryV13({house,store,sessionId,cwd,task:brief,taskDigest,plan,policy,transportGuard:publicationRecoveryGuard,snapshot,state,save,invoke});
109
111
  let behaviorOperation;
110
112
  const context = async () => {
@@ -342,6 +344,9 @@ export async function prepareChromePageTask({ cwd, house, store, sessionId, exec
342
344
  return Boolean(frame && PAGE_VERIFIER_CONTRACT[frame.input.phase === 'run' ? 'run_ports' : 'final_ports'].includes(name) && view.input.target_id === (await state()).binding?.target_id && (name !== 'InspectValue' || view.input.context_unique_id === (await state()).binding.identity.context_unique_id));
343
345
  }
344
346
  if (['WorkPlanSet', 'WorkCheckRepair'].includes(view.operation)) return false;
347
+ // Repair by contract: its operations, the open candidate's files, and replays of calls that failed for this task.
348
+ const repair = await admitRepair(view, { policy, cwd, store, sessionId, prefix, plugin: 'chrome-lab', actions: [...own, PAGE_CASE_OPERATION, PAGE_BEHAVIOR_OPERATION] });
349
+ if (repair !== undefined) return repair;
345
350
  if (['Read', 'Write', 'Edit'].includes(view.operation)) return sourcePath(view.input.file_path, view.operation !== 'Read');
346
351
  if (CHROME_SOURCE_NAMES.includes(view.operation)) return Boolean(sourceAccess?.active() && await sourceAccess.admit(view));
347
352
  if (view.operation === 'FileInfo') return true; // Separate narrow artifact gate.
@@ -383,7 +388,7 @@ export async function prepareChromePageTask({ cwd, house, store, sessionId, exec
383
388
  if (initial.current?.status === 'pending') await executor.execute('WorkTaskUpdate', { task_id: 'chrome-task', plan_version: initial.plan.version, status: 'in_progress' });
384
389
  sourceAccess = bindChromeSource({ executor, store, sessionId, cwd, plan, taskDigest, pathAllowed: sourcePath, directory: brief.directory });
385
390
  await house.activateGovernance({ sessionId, plan, bindings: {
386
- 'runtime:chrome-task.scope': { revision: 'chrome-page-scope-v1', operations: ['*'], configuration_digest: taskDigest, check: scope },
391
+ 'runtime:chrome-task.scope': { revision: 'chrome-page-scope-v1-repair2', operations: ['*'], configuration_digest: taskDigest, check: scope },
387
392
  'runtime:chrome-task.close': { revision: 'chrome-page-close-v1', operations: ['WorkTaskUpdate'], configuration_digest: taskDigest, check: close },
388
393
  'runtime:chrome-task.artifact-hash': { revision: 'chrome-page-artifact-v1', operations: ['FileInfo'], configuration_digest: digest({ taskDigest, artifact, contract_digest: hashOp.digest }), check: async view => !policy.denied.has('FileInfo') && view.contract_digest === hashOp.digest && (['WorkStepRun', 'WorkTaskUpdate'].includes(view.parent_operation) && digest(view.input) === digest({ file_path: artifact, hash: true }) || await sourceAccess.inspection(view)) },
389
394
  } });
@@ -0,0 +1,88 @@
1
+ import path from 'node:path';
2
+ import { digest, PLUGIN_REPAIR_OPERATION_NAMES } from '../operations.mjs';
3
+
4
+ // Repair by contract inside a coordinated Chrome task. There the model never calls a chrome-lab operation:
5
+ // the house runs them as steps of the task's own actions, under a scope that admits exactly the calls the
6
+ // task needs. A broken promise of the plugin therefore shows up inside a house action, and the model needs
7
+ // three things to repair it without the scope opening anything else:
8
+ // - to be told which plugin operation failed, with what input and what error (pluginFailure);
9
+ // - the five repair operations beside the domain actions, while the plugin is repairable (REPAIR_NAMES);
10
+ // - the calls a repair makes to be admitted, and only those (admitRepair), all for the task's own plugin:
11
+ // · the repair operations themselves, proposed by the model, never nested, naming that plugin or an
12
+ // open candidate of that plugin;
13
+ // · Read/Edit/Write inside the candidate tree PluginRepairOpen opened for that plugin;
14
+ // · under Reproduce, Test and Accept, a plugin call that is EXACTLY one the house ran in this session
15
+ // for the task and that failed (same operation, same arguments), and only when the operation is
16
+ // one a repair may repeat (REPEATABLE).
17
+ // The task's own uncertainty rules are untouched. REPEATABLE is an allow-list: the plugin's read-only
18
+ // operations, plus Open. Every operation that writes outside (the deploy, the fixture preview, an
19
+ // interaction, a message, arbitrary page JavaScript, and any operation added later) is never run again by
20
+ // a repair: whether it took effect is settled by the task's reconciliation, which looks instead of
21
+ // repeating. Opening the task's own popup is the one effect a repair may replay: nothing observes a popup
22
+ // that was never opened, and a second one is seen by the reconciliation as ambiguous, never adopted.
23
+
24
+ export const REPAIR_NAMES = Object.freeze([...PLUGIN_REPAIR_OPERATION_NAMES]);
25
+ export const REPEATABLE = Object.freeze(['Status', 'Deployment', 'Targets', 'ReadDOM', 'Assert', 'Contexts', 'InspectValue', 'Console', 'WorkerDiagnostics', 'Open']);
26
+ const REPLAYING = ['PluginRepairReproduce', 'PluginRepairTest', 'PluginRepairAccept'];
27
+ const INPUT_BYTES = 4096, ERROR_CHARS = 700;
28
+ const NO_REPEAT = 'This call may have taken effect before it failed. It is not repeated, and a repair cannot reproduce it: settle it with ChromeRecover, which observes what is really published and open.';
29
+
30
+ // A candidate tree is <state>/plugin-candidates/<plugin>/<candidate>/tree (plugin-repair.mjs).
31
+ function candidateOf(root) {
32
+ const parts = String(root).split(path.sep);
33
+ return parts.at(-1) === 'tree' && parts.at(-4) === 'plugin-candidates' ? { plugin: parts.at(-3), candidate: parts.at(-2) } : null;
34
+ }
35
+ /** The open candidate trees of `plugin`. */
36
+ export const candidateRoots = (policy, plugin) => [...(policy?.candidateRoots ?? [])].filter(root => candidateOf(root)?.plugin === plugin);
37
+ /** The open candidate tree of `plugin` that holds `file`, or null. */
38
+ export function candidateRoot(policy, cwd, file, plugin) {
39
+ if (typeof file !== 'string' || typeof policy?.candidateRootOf !== 'function') return null;
40
+ const root = policy.candidateRootOf(path.resolve(cwd, file));
41
+ return root && candidateOf(root)?.plugin === plugin ? root : null;
42
+ }
43
+
44
+ /**
45
+ * A scope verdict for a call that belongs to a repair, or undefined when the call is none of a repair's
46
+ * business (the caller's own rules decide). `plugin` is the task's plugin and `prefix` its operation prefix;
47
+ * `actions` are the task's own operations, whose failed nested plugin calls may be replayed.
48
+ */
49
+ export async function admitRepair(view, { policy, cwd, store, sessionId, prefix, plugin, actions }) {
50
+ if (REPAIR_NAMES.includes(view.operation)) {
51
+ if (view.parent_operation) return { allowed: false, reason: 'A repair operation is proposed by the model, never nested.' };
52
+ const own = ['PluginRepairReproduce', 'PluginRepairOpen'].includes(view.operation) ? view.input?.plugin === plugin
53
+ : candidateRoots(policy, plugin).some(root => candidateOf(root).candidate === view.input?.candidate);
54
+ return own ? true : { allowed: false, reason: `Inside this task only plugin ${plugin} and its own open candidate can be repaired.` };
55
+ }
56
+ if (['Read', 'Edit', 'Write'].includes(view.operation) && candidateRoot(policy, cwd, view.input?.file_path, plugin)) return true;
57
+ if (!REPLAYING.includes(view.parent_operation) || !view.operation.startsWith(prefix)) return undefined;
58
+ if (!REPEATABLE.includes(view.operation.slice(prefix.length))) return { allowed: false, reason: NO_REPEAT };
59
+ const rows = (await store.load(sessionId)).rows, wanted = digest(view.input);
60
+ const receipts = rows.filter(row => row.type === 'house_receipt');
61
+ const failed = receipts.some(row => row.operation === view.operation && row.status === 'failed' && row.arguments_digest === wanted
62
+ && receipts.some(parent => parent.invocation_id === row.parent_invocation_id && actions.includes(parent.operation)));
63
+ return failed ? true : { allowed: false, reason: 'In this coordinated task a repair replays exactly a plugin call the house ran for the task and that failed (the operation and input the failed action reported). No other plugin call is admitted.' };
64
+ }
65
+
66
+ // Refusals before the handler ran (permission, guard, scope, stale catalogue, invalid input): not the plugin's failure.
67
+ const NOT_RUN = /^(HOUSE_(REFUSED|GUARD|STALE|AUTHORITY_CHANGED|SURFACE_CHANGED|INPUT_SCHEMA|PERMISSION_DENIED)|GOVERNANCE_REFUSED|TOOL_PACK_REQUIRED)$/;
68
+ /**
69
+ * Called with the error of a plugin operation the house ran as a step of a task action. When the plugin's
70
+ * handler ran and threw (not a refusal before it ran, not an interrupted turn), the error is given
71
+ * `plugin_failure`: the plugin, the operation, the input and the error. For an operation a repair may
72
+ * replay it says how to reproduce it (when the repair operations are offered); for one the task never
73
+ * repeats it says so and names the reconciliation instead. The error keeps its identity, code and message;
74
+ * it is returned for the caller to rethrow. Synchronous and cheap: the task's own polling loops pass through here.
75
+ */
76
+ export function pluginFailure(error, { house, options, plugin, operation, input }) {
77
+ if (!error || typeof error !== 'object' || error.plugin_failure || options?.signal?.aborted || NOT_RUN.test(String(error.code ?? ''))) return error;
78
+ let shown = {}; try { const text = JSON.stringify(input ?? {}); shown = Buffer.byteLength(text) <= INPUT_BYTES ? { input: JSON.parse(text) } : { input_omitted: 'too_large' }; } catch { shown = { input_omitted: 'not_json' }; }
79
+ const repairable = REPAIR_NAMES.every(name => house?.registry?.get(name));
80
+ const advice = !REPEATABLE.includes(operation) ? { next_action: NO_REPEAT }
81
+ : repairable && shown.input ? { repair: `The house ran this plugin operation for your action and it failed. If the cause is the page, the source or the lab, correct that. If the plugin itself broke its promise, reproduce exactly this call: PluginRepairReproduce {"plugin":"${plugin}","case":{"name":"<what it should do>","operation":"${operation}","input":<the input above>,"expect":<what it should return>}}, then PluginRepairOpen, fix the candidate, PluginRepairAccept. After an accepted repair continue with the task's own actions; an uncertain publication is settled with ChromeRecover, not repeated.` } : {};
82
+ try { error.plugin_failure = { plugin, operation, ...shown, ...(typeof error.code === 'string' ? { code: error.code } : {}), error: String(error.message ?? error).slice(0, ERROR_CHARS), ...advice }; } catch {}
83
+ return error;
84
+ }
85
+
86
+ const escaped = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
87
+ /** The alternatives a task's file_path pattern gains while candidate trees of `plugin` are open: each tree, escaped. */
88
+ export const candidatePathAlternatives = (policy, plugin) => candidateRoots(policy, plugin).map(root => escaped(root) + '/');
@@ -10,6 +10,7 @@ import { CHROME_SOURCE_NAMES, bindChromeSource } from './chrome-source.mjs';
10
10
  import { PAGE_PREVIEW_RECOVERY_V13 } from './chrome-page-recovery-v13.mjs';
11
11
  import { suppliedInputsSchema, normalizeSuppliedInputs, verifySuppliedInputs, isSuppliedPath, assertSuppliedSnapshot } from './chrome-supplied-inputs.mjs';
12
12
  import { safeRelative, snapshotProject, chromeProjectNameSchema } from '../plugins/chrome-lab/project.mjs';
13
+ import { REPAIR_NAMES, admitRepair, pluginFailure, candidatePathAlternatives } from './chrome-repair.mjs';
13
14
 
14
15
  const prefix = 'plugin__chrome-lab__';
15
16
  const PUBLIC = new Set(['Read', 'Edit', 'Write', 'ChromePublish', 'ChromeVerify', 'ChromeTest', 'ChromeFinish', 'ChromeRecover', 'ChromeInspect', 'ChromeDeliver']);
@@ -145,7 +146,9 @@ export async function prepareChromeTask({ cwd, house, store, sessionId, executor
145
146
  };
146
147
  const snapshot = async signal => {if(suppliedDigest)await verifySuppliedInputs(directory,brief.supplied_inputs,{signal});const result=await snapshotProject(cwd, brief.directory, { signal }); if(fixtureSource&&result.files.find(f=>f.path===brief.fixture.file)?.sha256!==fixtureSource)throw failure('The immutable test fixture changed.','CHROME_TASK_FIXTURE_CHANGED');if(suppliedDigest)assertSuppliedSnapshot(result,brief.supplied_inputs); return result;};
147
148
  const save = (options, value) => options.recordWork({ type: 'chrome_task_checkpoint', source: 'workflow_host', task_digest: taskDigest, state: value });
148
- const invoke = async (options, name, input) => JSON.parse(await options.invoke(prefix+name, input, { signal: options.signal }));
149
+ // A plugin operation the house runs for a task action. When its handler throws, the error says which
150
+ // operation failed and with what input (chrome-repair.mjs), so the model can tell a broken plugin from a broken page.
151
+ const invoke = async (options, name, input) => { let text; try { text = await options.invoke(prefix+name, input, { signal: options.signal }); } catch (error) { throw pluginFailure(error, { house, options, plugin: 'chrome-lab', operation: name, input }); } return JSON.parse(text); };
149
152
  const assertReady = value => { if (value.stage.endsWith('_pending') || value.status === 'uncertain') throw failure('Publicación incierta: no se repite. Requiere reconciliación explícita.', 'CHROME_TASK_UNCERTAIN'); };
150
153
  const observeDocument = async (options,binding,previousIdentity) => {
151
154
  if(!observeIdentity)return;
@@ -416,6 +419,9 @@ export async function prepareChromeTask({ cwd, house, store, sessionId, executor
416
419
  if (policy.denied.has(view.operation)) return { allowed: false, reason: 'Explicit session deny.' };
417
420
  if (view.operation === 'WorkPlanSet' || view.operation === 'WorkCheckRepair') return { allowed: false, reason: 'Accepted task/criterion is immutable in this workflow.' };
418
421
  if (view.actor.kind === 'human' || view.surface === 'internal') return true;
422
+ // Repair by contract: its operations, the open candidate's files, and replays of calls that failed for this task.
423
+ const repair = await admitRepair(view, { policy, cwd, store, sessionId, prefix, plugin: 'chrome-lab', actions: OWN });
424
+ if (repair !== undefined) return repair;
419
425
  if (['Read','Edit','Write'].includes(view.operation)) return sourcePath(view.input.file_path,view.operation!=='Read');
420
426
  if (retainedSource && CHROME_SOURCE_NAMES.includes(view.operation)) return Boolean(sourceAccess?.active() && await sourceAccess.admit(view));
421
427
  if (!legacy && view.operation === 'FileInfo') return { allowed: true, reason: 'Subject to the separately enforced artifact-hash capability.' };
@@ -460,16 +466,19 @@ export async function prepareChromeTask({ cwd, house, store, sessionId, executor
460
466
  if (initial.current?.status === 'pending') await executor.execute('WorkTaskUpdate', { task_id: 'chrome-task', plan_version: initial.plan.version, status: 'in_progress' });
461
467
  if(retainedSource)sourceAccess=bindChromeSource({executor,store,sessionId,cwd,plan,taskDigest,pathAllowed:async(file,mutable)=>(await sourcePath(file,mutable)).allowed,directory:brief.directory});
462
468
  await house.activateGovernance({ sessionId, plan, bindings: {
463
- 'runtime:chrome-task.scope': { revision: suppliedInputs?'chrome-scope-v15':retainedSource?'chrome-scope-v14':workerInspection?'chrome-scope-v13':legacy ? 'chrome-scope-v3' : documentProof ? 'chrome-scope-v12' : pages ? 'chrome-scope-v11' : pipeline ? 'chrome-scope-v9' : currency ? 'chrome-scope-v7' : inspection ? 'chrome-scope-v6' : 'chrome-scope-v5', operations: ['*'], configuration_digest: taskDigest, check: scope },
469
+ 'runtime:chrome-task.scope': { revision: (suppliedInputs?'chrome-scope-v15':retainedSource?'chrome-scope-v14':workerInspection?'chrome-scope-v13':legacy ? 'chrome-scope-v3' : documentProof ? 'chrome-scope-v12' : pages ? 'chrome-scope-v11' : pipeline ? 'chrome-scope-v9' : currency ? 'chrome-scope-v7' : inspection ? 'chrome-scope-v6' : 'chrome-scope-v5')+'-repair2', operations: ['*'], configuration_digest: taskDigest, check: scope },
464
470
  'runtime:chrome-task.close': { revision: 'chrome-close-v1', operations: ['WorkTaskUpdate'], configuration_digest: taskDigest, check: close },
465
471
  ...(!legacy ? { 'runtime:chrome-task.artifact-hash': { revision: retainedSource?'chrome-artifact-hash-v2':'chrome-artifact-hash-v1', operations: ['FileInfo'], configuration_digest: digest({ taskDigest, artifacts: declaredTask.artifacts, contract_digest: hashOperation.digest }), check: artifactHash } } : {})
466
472
  } });
467
473
  const project = snapshot => {
468
- const definitions = snapshot.definitions().filter(tool => PUBLIC.has(tool.name) || retainedSource && CHROME_SOURCE_NAMES.includes(tool.name)).map(tool=>{
474
+ // The repair operations are offered beside the domain actions while the house offers them at all (a local
475
+ // plugin is repairable); while a candidate is open, the file tools also name its tree.
476
+ const candidates=candidatePathAlternatives(policy, 'chrome-lab');
477
+ const definitions = snapshot.definitions().filter(tool => PUBLIC.has(tool.name) || retainedSource && CHROME_SOURCE_NAMES.includes(tool.name) || REPAIR_NAMES.includes(tool.name)).map(tool=>{
469
478
  if(!pages||!['Read','Write','Edit',...(retainedSource?['SourceDraft']:[])].includes(tool.name))return tool;
470
479
  const escaped=value=>value.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
471
- const view=structuredClone(tool);view.input_schema.properties.file_path.pattern='^('+escaped(brief.directory)+'/|'+escaped(directory)+'/).+$';
472
- view.input_schema.properties.file_path.description='Accepted extension source path under '+brief.directory+'/. Host/plugin files are outside this task. '+(brief.fixture?'The fixture '+brief.fixture.file+' is read-only. ':'')+(suppliedDigest?'Supplied read-only inputs: '+brief.supplied_inputs.map(item=>item.file).join(', ')+'. ':'')+'Use existing source observations; reread only an unresolved file.';
480
+ const view=structuredClone(tool);view.input_schema.properties.file_path.pattern='^('+[escaped(brief.directory)+'/',escaped(directory)+'/',...(tool.name==='SourceDraft'?[]:candidates)].join('|')+').+$';
481
+ view.input_schema.properties.file_path.description='Accepted extension source path under '+brief.directory+'/. Host/plugin files are outside this task. '+(brief.fixture?'The fixture '+brief.fixture.file+' is read-only. ':'')+(suppliedDigest?'Supplied read-only inputs: '+brief.supplied_inputs.map(item=>item.file).join(', ')+'. ':'')+'Use existing source observations; reread only an unresolved file.'+(candidates.length&&tool.name!=='SourceDraft'?' While a plugin repair candidate is open, its directory (the one PluginRepairOpen returned) is admitted too.':'');
473
482
  return view;
474
483
  });
475
484
  const offered = new Set(definitions.map(tool => tool.name));