@milpa/comal 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ comal --help
28
28
 
29
29
  Keep the PATH line in your shell startup file if you use that prefix. If your Node manager already provides a user prefix, you can omit `--prefix "$HOME/.local"`. The package registers the `comal` executable; you do not need to create a shell alias.
30
30
 
31
- You can pin a version with `npx @milpa/comal@0.1.1` or `npm install --global @milpa/comal@0.1.1`. The local release manifest preserves SHA-256 and SHA-512 integrity is checked against npm at publish time. No independent publisher signature is claimed.
31
+ You can pin a version with `npx @milpa/comal@0.1.2` or `npm install --global @milpa/comal@0.1.2`. The local release manifest preserves SHA-256 and SHA-512 integrity is checked against npm at publish time. No independent publisher signature is claimed.
32
32
 
33
33
  ## Test without account or model
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milpa/comal",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Comal: governed operations, execution receipts, and programming assistance in any language",
5
5
  "type": "module",
6
6
  "private": false,
@@ -16,7 +16,7 @@ export function artifactPlugin({cwd,client=new CloudClient()}={}){
16
16
  add('ArtifactCapabilities','Inspect publication types, video bounds and configured media hosting before planning delivery.',object(),read,(_,options)=>client.invoke('comal.contract',{},options));
17
17
  add('ArtifactList','List artifacts owned by the Comal account or explicitly shared with it.',object(),read,(input,options)=>client.invoke('artifacts.list',input,options));
18
18
  add('ArtifactInspect','Read exact version, current verification status and executor observations; does not rerun effects. Only admitted executor receipts can certify checks.',object({artifact_id:id,version:id},['artifact_id']),read,async(input,options)=>artifactProjection(await client.invoke('artifacts.inspect',input,options)));
19
- add('ArtifactPublish','Upload a static webapp DIRECTORY inside this workspace as an immutable private candidate. Host bundles static files (8 MiB/256) and up to four MP4 clips (8 MiB/180 seconds each) hosted on protected Gumlet. clip.mp4 produces clip.player.html: embed with iframe, not video src; a .mp4 entrypoint opens its generated player. Excludes hidden entries, refuses symlinks. Repeated identical inputs reconcile the same attempt. Session provenance comes from the host. Upload does not deliver; next use ArtifactVerify. Updates must retain checks and include base_version.',object({directory:{type:'string',minLength:1,maxLength:512},workspace_id:id,title:{type:'string',minLength:1,maxLength:120},entrypoint:{type:'string',minLength:1,maxLength:512},artifact_id:id,base_version:id,checks:{type:'array',maxItems:32,items:checkSchema}},['directory','workspace_id','title','entrypoint']),{...write,workspace:'read'},async({directory,...input},options)=>{if(!options.sessionId)throw Error('La publicación requiere la identidad de sesión del host.');return artifactProjection(await publishArtifact(client,{...input,session_id:options.sessionId,directory:path.resolve(cwd,directory),workspaceRoot:cwd},options));});
19
+ add('ArtifactPublish','Upload a static webapp DIRECTORY as a private candidate. Static: 8 MiB/256 files; up to four MP4 clips (8 MiB/180s each) hosted on protected Gumlet. clip.mp4 produces clip.player.html: embed with iframe, not video src; a .mp4 entrypoint opens the generated player. Excludes hidden entries; refuses symlinks. media_processing is an acknowledged pending result with NO candidate: repeat identical directory/metadata to reconcile, never invent IDs. Identical sources are deduplicated. Once a candidate exists, use ArtifactVerify then ArtifactRelease. Session provenance comes from the host. Updates retain checks and require base_version.',object({directory:{type:'string',minLength:1,maxLength:512},workspace_id:id,title:{type:'string',minLength:1,maxLength:120},entrypoint:{type:'string',minLength:1,maxLength:512},artifact_id:{...id,description:'UPDATE ONLY: exact existing artifact ID. OMIT this field entirely when creating a new artifact; never invent a placeholder ID.'},base_version:{...id,description:'UPDATE ONLY: observed existing version. OMIT this field for a new artifact.'},checks:{type:'array',maxItems:32,items:checkSchema}},['directory','workspace_id','title','entrypoint']),{...write,workspace:'read'},async({directory,...input},options)=>{if(!options.sessionId)throw Error('La publicación requiere la identidad de sesión del host.');return artifactProjection(await publishArtifact(client,{...input,session_id:options.sessionId,directory:path.resolve(cwd,directory),workspaceRoot:cwd},options));});
20
20
  add('ArtifactVerify','Queue the current immutable candidate contract and optionally wait up to 30 seconds without extra model calls. Worker may interact with the candidate under its accepted checks. Failed checks may need an app fix. Inconclusive needs missing evidence or executor recovery; do not assume app failure. Queued is not passed. Reuse request_id to reconcile a retry.',object({artifact_id:id,version:id,request_id:{type:'string',minLength:8,maxLength:128},wait_seconds:{type:'integer',minimum:0,maximum:30}},['artifact_id']),write,async({wait_seconds=0,...input},options)=>{const queued=await client.invoke('verification.request',{...input,request_id:input.request_id||'comal_'+(options.invocation_id||randomUUID())},options);const until=Date.now()+wait_seconds*1000;while(Date.now()<until){await delay(Math.min(2000,until-Date.now()),null,{signal:options.signal});const current=await client.invoke('artifacts.inspect',{artifact_id:input.artifact_id,...(input.version?{version:input.version}:{})},options);if(!['queued','running'].includes(current.verification?.status))return artifactProjection(current);}return queued;});
21
21
  add('ArtifactRelease','Deliver exactly a current candidate whose admitted verification job passed; rejects stale access/content. expected_released_version must match last inspection. Human acceptance is unjudged. A model-written passed flag has no authority.',object({artifact_id:id,version:id,job_id:id,expected_released_version:{anyOf:[id,{type:'null'}]}},['artifact_id','version','job_id','expected_released_version']),write,(input,options)=>client.invoke('artifacts.release',input,options));
22
22
  }};
@@ -12,6 +12,9 @@ export function shellCommands(command) {
12
12
  else { if (quote === '"' && /[$`\\]/.test(c)) return null; token += c; }
13
13
  continue;
14
14
  }
15
+ // Exact stderr-to-stdout duplication has no filesystem target. Other
16
+ // redirections/expansions remain opaque; preserve quoted literals verbatim.
17
+ if (c === '>' && token === '2' && command.slice(i,i+3)==='>&1' && (!command[i+3] || /[\s;&|]/.test(command[i+3]))) { token='';started=false;i+=2;continue; }
15
18
  if (c === "'" || c === '"') { quote = c; started = true; }
16
19
  else if (/[$`\\<>(){}]/.test(c)) return null;
17
20
  else if (/[;&|\n]/.test(c)) { finish(); if (command[i + 1] === c) i++; }
@@ -60,8 +63,9 @@ export function inspectAutoAction(name, input, cwd) {
60
63
  }
61
64
  if (executable === 'git' && (args.includes('--hard') || args.includes('clean') || args.some(a => /^--force(?:-with-lease)?$/.test(a) || /^-[^-]*f/.test(a)))) destructive = true;
62
65
  if (['dd', 'docker', 'kubectl', 'terraform', 'psql', 'mysql', 'sqlite3'].includes(executable)) { opaque = true; destructive = true; }
66
+ const commandLookup = executable === 'command' && args[0] === '-v' && args.length > 1 && args.slice(1).every(value => !value.startsWith('-'));
63
67
  const testRunner = (executable === 'node' && args[0] === '--test') || (executable.startsWith('python') && args[0] === '-m' && args[1] === 'pytest');
64
- if (/^(?:sh|bash|zsh|fish|node|python\d*|ruby|perl|php|eval|exec|xargs|env|command|timeout|busybox|npx|for|while|until|if|then|do|done|case|esac|function)$/.test(executable) && !testRunner) opaque = true;
68
+ if (/^(?:sh|bash|zsh|fish|node|python\d*|ruby|perl|php|eval|exec|xargs|env|command|timeout|busybox|npx|for|while|until|if|then|do|done|case|esac|function)$/.test(executable) && !testRunner && !commandLookup) opaque = true;
65
69
  if (tokens[0].includes('=')) opaque = true;
66
70
  if (executable === 'find' && args.some(arg => ['-delete', '-exec', '-execdir'].includes(arg))) { destructive = true; opaque = true; }
67
71
  if ((executable === 'curl' || executable === 'wget') && args.some(a => /(?:\.env|id_rsa|id_ed25519|credentials)/i.test(a))) return { hard: 'Transferencia de credenciales o secretos.' };
package/src/cli.mjs CHANGED
@@ -364,7 +364,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
364
364
  for(const definition of peerToolDefinitions)registry.add({...definition,native:true,effects:{workspace:'none',external:definition.name==='SendMessage'?'write':'none',process:'none',authority:'none'},run:async(input,options)=>JSON.stringify(definition.name==='ListAgents'?await peerBus.list(input):await peerBus.send(input,options))});
365
365
  let providerOperations=[];
366
366
  const publishProviderFeatures=()=>{
367
- const definitions=providerFeatureDefinitions(provider).map(definition=>({...definition,effects:{workspace:definition.name==='AnalyzeMedia'?'read':'none',external:definition.name==='ProviderCapabilities'?'none':'read',process:'none',authority:'none'},run:(input,options)=>executeProviderFeature(definition.name,input,{provider,workspace:tools,record:event=>store.append(session.id,event),signal:options.signal,invoke:(request,execute)=>activeGoalBudget?activeGoalBudget.complete(request,execute):execute(request)})}));
367
+ const definitions=providerFeatureDefinitions(provider).map(definition=>({...definition,effects:{workspace:definition.name==='AnalyzeMedia'?'read':'none',external:definition.name==='ProviderCapabilities'?'none':'read',process:'none',authority:'none'},run:(input,options)=>executeProviderFeature(definition.name,input,{provider,workspace:tools,record:event=>store.append(session.id,event),signal:options.signal,observe:options.observe,invoke:(request,execute)=>activeGoalBudget?activeGoalBudget.complete(request,execute):execute(request)})}));
368
368
  house.registry.replace(providerOperations,definitions);providerOperations=definitions.map(definition=>definition.name);
369
369
  };
370
370
  publishProviderFeatures();
@@ -475,7 +475,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
475
475
  if (!plugin?.operations.includes(name)) throw new Error(t("Plugin operation not published.")); return humanTools.execute(name, input.arguments, { signal });
476
476
  } },
477
477
  ]) house.registry.add({ ...definition, audience: 'human', surfaces: ['cli', 'tui'], permission: null, effects: definition.effects || { workspace: 'unknown', external: 'unknown', process: 'unknown', authority: 'plugin_lifecycle' } });
478
- house.registry.add({ name: 'PluginInitialize', description: 'Host initializes built-ins and discovers plugins; local code requires explicit startup trust.', input_schema: { type: 'object', properties: { trust: { type: 'boolean' } }, required: ['trust'], additionalProperties: false }, audience: 'human', surfaces: ['internal'], permission: null, run: async (input, options) => { await plugins.enable('web', options); if (!v.demo) await plugins.enable('delegation', options); return JSON.stringify(await plugins.initialize({ ...options, trust: input.trust })); } });
478
+ house.registry.add({ name: 'PluginInitialize', description: 'Host initializes built-ins and discovers plugins; local code requires explicit startup trust.', input_schema: { type: 'object', properties: { trust: { type: 'boolean' } }, required: ['trust'], additionalProperties: false }, audience: 'human', surfaces: ['internal'], permission: null, run: async (input, options) => { await plugins.enable('web', options); await plugins.enable('artifacts', options); if (!v.demo) await plugins.enable('delegation', options); return JSON.stringify(await plugins.initialize({ ...options, trust: input.trust })); } });
479
479
  house.registry.add({ name: 'PluginClose', description: 'Host drains and disposes all owned plugins at shutdown.', input_schema: emptyInput, audience: 'human', surfaces: ['internal'], permission: null, run: async () => { await plugins.close(); return '(plugins closed)'; } });
480
480
  for (const [name, description, run] of [
481
481
  ['HouseContext', 'Read the host house identity, boundaries and judgment scopes. This does not grant permissions.', () => JSON.stringify({ name: 'Comal', house: cwd, runtime: 'node', implementation_languages: 'any', model_role: 'propose_operations', policy: policy.mode, authority: 'host_permission_policy', goal_evaluation: 'advisory', receipts: 'house_execution', asked: 'unjudged', boundaries: ['workspace file paths', 'human-only session controls', 'no OS sandbox', 'opaque MCP/process effects are unknown'] })],
@@ -790,6 +790,10 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
790
790
  if (!chromeWorkflow) throw new Error(t("/work recover requires an accepted Chrome task."));
791
791
  return await humanTools.execute('ChromeRecover', {}, { signal: controller?.signal });
792
792
  }
793
+ if(command==='work'&&arg.trim().startsWith('review ')){
794
+ const [task_id,step_id,expected_receipt,...reason]=arg.trim().slice(7).split(/\s+/),book=await store.load(session.id);
795
+ return humanTools.execute('WorkFailureReview',{task_id,step_id,expected_receipt,plan_version:workProjection(book)?.plan?.version||0,reason:reason.join(' ')});
796
+ }
793
797
  if (['board', 'tasks', 'work'].includes(command)) return await humanTools.execute('WorkBoard', {});
794
798
  if (command === 'help') return remoteCommandHelp();
795
799
  if (command === 'state') return await humanTools.execute('WorkSnapshot', {});
@@ -41,9 +41,10 @@ async function sendArtifactVideo(client,content,{workspace_id,session_id},option
41
41
  if(options.expectedAccount&&(options.expectedAccount.origin!==account.origin||options.expectedAccount.user_id!==account.user_id))throw Error('The Comal account changed before video upload.');
42
42
  const authority={...options,expectedAccount:{origin:account.origin,user_id:account.user_id}};
43
43
  let result=await client.invoke('media.upload',{workspace_id,session_id,sha256:hash(content),content_base64:content.toString('base64')},authority);
44
- const deadline=Date.now()+90000;
45
- while(result.status!=='ready'&&Date.now()<deadline){if(result.status==='failed')throw Error('Gumlet could not process this video. Inspect media '+result.id+'.');await delay(2000,null,{signal:options.signal});result=await client.invoke('media.inspect',{media_id:result.id},authority);}
46
- if(result.status!=='ready')throw Error('Video '+result.id+' is still processing. Retry the same source to reconcile it without another upload.');
44
+ const deadline=Date.now()+Math.max(0,Math.min(90000,options.mediaWaitMs??90000));
45
+ while(!['ready','failed'].includes(result.status)&&Date.now()<deadline){await delay(Math.min(2000,deadline-Date.now()),null,{signal:options.signal});result=await client.invoke('media.inspect',{media_id:result.id},authority);}
46
+ // Acknowledged asynchronous processing is a domain result, not an uncertain
47
+ // invocation failure. Only a ready media binding can reach artifacts.publish.
47
48
  return result;
48
49
  }
49
50
  export async function uploadArtifactVideo(client,{file,workspaceRoot,...metadata},options={}){return sendArtifactVideo(client,await captureArtifactVideo(file,workspaceRoot),metadata,options);}
@@ -59,8 +60,15 @@ export async function publishArtifact(client,{directory,workspaceRoot=directory,
59
60
  if(paths.size>256||files.reduce((sum,file)=>sum+Buffer.byteLength(file.content_base64,'base64'),0)+videos.length*2048>8388608)throw Error('The artifact exceeds its static budget including generated video players.');
60
61
  if(!paths.has(metadata.entrypoint)||!/[.]html?$/i.test(metadata.entrypoint))throw Error('The entrypoint must exist as HTML or a selected MP4; use clip.mp4 for its generated player.');
61
62
  }
62
- for(const capture of videos){const video=await sendArtifactVideo(client,capture.content,metadata,authority);media.push({path:capture.path,media_id:video.id});}
63
+ for(const capture of videos){const video=await sendArtifactVideo(client,capture.content,metadata,authority);if(video.status!=='ready')return{status:video.status==='failed'?'media_failed':'media_processing',candidate_created:false,media:{id:video.id,status:video.status,source_sha256:hash(capture.content)},next_action:video.status==='failed'?'The acknowledged media job failed. Preserve its ID; inspect the source and hosting before a corrected publication. No artifact candidate was created.':'Media upload was acknowledged and is still processing. Retry ArtifactPublish with the SAME directory and metadata to reconcile the source binding; the backend deduplicates the upload. No artifact ID exists yet; do not verify or release.'};media.push({path:capture.path,media_id:video.id});}
63
64
  const input={...metadata,files,...(media.length?{media}:{})};
64
65
  input.request_id='comal_'+hash(JSON.stringify({origin:account.origin,user:account.user_id,input}));
65
- return client.invoke('artifacts.publish',input,authority);
66
+ try{return await client.invoke('artifacts.publish',input,authority);}
67
+ catch(error){
68
+ // This authenticated backend precondition rejects changing the accepted
69
+ // checks before any candidate mutation. It is a domain refusal, not a lost
70
+ // transport outcome. Earlier acknowledged media stays source-deduplicated.
71
+ if(error.code==='CONTRACT_CHANGE'&&error.status===409)return{status:'refused',code:'CONTRACT_CHANGE',candidate_created:false,accepted_checks_preserved:true,next_action:'The backend rejected changing the accepted verification contract. Inspect the current artifact and correct the app to satisfy its ORIGINAL checks. Re-publish the corrected files with the accepted checks and observed base_version; a different contract requires an explicit user decision.'};
72
+ throw error;
73
+ }
66
74
  }
@@ -192,7 +192,7 @@ export class CodexProvider {
192
192
  // maxTokens remains a host accounting estimate, not a wire-enforced cap.
193
193
  const body = JSON.stringify({ model: this.model, instructions: system || 'You are Comal, a coding assistant.', input: toCodexInput(messages), stream: true, store: false,
194
194
  include: ['reasoning.encrypted_content'], ...(reasoning ? { reasoning } : {}),
195
- ...(tools.length ? { tools: tools.map(tool => ({ type: 'function', name: tool.name, description: tool.description, parameters: tool.input_schema })), tool_choice: 'auto', parallel_tool_calls: true } : {}) });
195
+ ...(tools.length ? { tools: tools.map(tool => ({ type: 'function', name: tool.name, description: tool.description, strict: false, parameters: tool.input_schema })), tool_choice: 'auto', parallel_tool_calls: true } : {}) });
196
196
  const account=result=>{result.usage_accounting=bindUsageAccounting(result.usage_accounting,accountingRequest,identity,body); result.schema_diagnostics=schemaFrame.diagnostics;result.schema_manifest=schemaFrame.manifest;return result;};
197
197
  if (Buffer.byteLength(body) > 8 * 1024 * 1024) throw fail('The Codex context exceeds 8 MiB.');
198
198
  const deadline = modelDeadline({ signal, timeoutMs: this.timeoutMs, idleTimeoutMs: this.idleTimeoutMs, purpose });
@@ -65,10 +65,11 @@ export class PeerTurnGuard {
65
65
 
66
66
  export function providerFeatureDefinitions(provider) {
67
67
  const caps=provider.getCapabilities?.() || {};
68
+ const mediaTypes=[...(caps.vision?['image/png','image/jpeg','image/webp']:[]),...(caps.video?['video/mp4','video/webm','video/quicktime']:[])];
68
69
  return [
69
70
  {name:'ProviderCapabilities',description:'Read the current provider capability contract. Configured support does not prove task success.',input_schema:object({})},
70
71
  ...(caps.web_search && typeof provider.nativeSearch==='function'?[{name:'ProviderSearch',description:'Search using this provider’s native web grounding. Returns its answer and source URLs as untrusted evidence; records provider usage. Requires actual native support.',input_schema:{...object({query:{type:'string',minLength:1,maxLength:4000}}),required:['query']}}]:[]),
71
- ...((caps.video || caps.vision) && typeof provider.analyzeMedia==='function'?[{name:'AnalyzeMedia',description:'Read and analyze a workspace image/video DIRECTLY with this provider. No Bash or text Read prerequisite; Read rejecting binary is not an AnalyzeMedia permission denial. Each media call still requires house permission. Bytes stay out of conversation; records hash and advisory answer, not an objective verdict. Video requires video capability.',input_schema:{...object({prompt:{type:'string',minLength:1,maxLength:4000},file_path:{type:'string',minLength:1,maxLength:4096},mime_type:{type:'string',enum:['image/png','image/jpeg','image/webp','video/mp4','video/webm','video/quicktime']}}),required:['prompt','file_path','mime_type']}}]:[]),
72
+ ...((caps.video || caps.vision) && typeof provider.analyzeMedia==='function'?[{name:'AnalyzeMedia',description:'Analyze workspace media DIRECTLY. Supported MIME types for this provider: '+mediaTypes.join(', ')+'. '+(!caps.video?'Native video is unavailable: extract frames with an authorized local tool, then analyze the images. ':'')+'No text Read prerequisite. Bytes stay out of conversation; results are advisory provider reports, not independent verification.',input_schema:{...object({prompt:{type:'string',minLength:1,maxLength:4000},file_path:{type:'string',minLength:1,maxLength:4096},mime_type:{type:'string',enum:mediaTypes}}),required:['prompt','file_path','mime_type']}}]:[]),
72
73
  ];
73
74
  }
74
75
 
@@ -139,7 +140,7 @@ export async function completePeerReplies(final,{source,baseline=0,requests,stor
139
140
  }
140
141
  }
141
142
 
142
- export async function executeProviderFeature(name,input,{provider,workspace,record,signal,invoke=async(request,run)=>run(request)}) {
143
+ export async function executeProviderFeature(name,input,{provider,workspace,record,signal,observe=()=>{},invoke=async(request,run)=>run(request)}) {
143
144
  const caps=provider.getCapabilities?.() || {text:true,tools:true,vision:false,video:false,web_search:false};
144
145
  if(name==='ProviderCapabilities')return JSON.stringify({model:provider.model,capabilities:caps,authority:'capability_contract_not_execution'});
145
146
  let result, media;
@@ -148,7 +149,7 @@ export async function executeProviderFeature(name,input,{provider,workspace,reco
148
149
  if(name==='ProviderSearch' && caps.web_search && provider.nativeSearch)result=await invoke({messages:[{role:'user',content:input.query}],tools:[],signal,maxTokens:provider.maxTokens},options=>provider.nativeSearch({query:input.query,signal,maxTokens:options.maxTokens}));
149
150
  else if(name==='AnalyzeMedia' && provider.analyzeMedia) {
150
151
  const video=input.mime_type.startsWith('video/');
151
- if(!(video?caps.video:caps.vision))throw new Error('Este perfil no ofrece la capacidad de medios solicitada.');
152
+ if(!(video?caps.video:caps.vision)){observe({kind:'provider_media_preflight_rejected',reason:'unsupported_modality',mime_type:input.mime_type,target_dispatched:false});throw Object.assign(new Error('The current provider does not support this media type. Use a supported MIME from the live schema; extract video frames for an image-only provider.'),{code:'PROVIDER_MEDIA_UNSUPPORTED'});}
152
153
  const file=await workspace.resolve(input.file_path),info=await stat(file);
153
154
  if(!info.isFile() || info.size>16*1024*1024)throw new Error('El medio debe ser un archivo del workspace de hasta 16 MiB.');
154
155
  const bytes=await readFile(file);if(bytes.length>16*1024*1024)throw new Error('El medio excede 16 MiB.');
@@ -5,7 +5,7 @@ import { inspectAutoAction, intentCoversAction } from './auto-guard.mjs';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { readOnlyBashCheck, chromeLabObservation } from './read-only-check.mjs';
7
7
  READ_TOOLS.add('ToolPack');
8
- for (const name of ['ArtifactAccount','ArtifactList','ArtifactInspect']) READ_TOOLS.add(name);
8
+ for (const name of ['ArtifactAccount','ArtifactCapabilities','ArtifactList','ArtifactInspect']) READ_TOOLS.add(name);
9
9
  for (const name of ['ListDirectory', 'FileInfo']) READ_TOOLS.add(name);
10
10
  for (const name of ['ListAgents','SendMessage','ProviderCapabilities','ProviderSearch','AnalyzeMedia']) READ_TOOLS.add(name);
11
11
 
@@ -9,10 +9,15 @@ const boundText = (text, max=30000) => Buffer.from(String(text || '')).subarray(
9
9
  export function projectRemoteEvent(event, turnId) {
10
10
  const base={turn_id:turnId || 'local',text:''};
11
11
  if(event.type==='user')return {...base,type:'message.user',text:boundText(event.text)};
12
- if(event.type==='assistant')return {...base,type:'message.assistant',text:boundText((event.message?.content || []).filter(b=>b.type==='text').map(b=>b.text).join(''))};
12
+ if(event.type==='assistant'){const text=boundText((event.message?.content || []).filter(b=>b.type==='text').map(b=>b.text).join(''));return text.trim()?{...base,type:'message.assistant',text}:null;}
13
13
  if(event.type==='result'&&event.turns===0&&typeof event.result==='string')return {...base,type:'message.control',text:boundText(event.result)};
14
- if(event.type==='tool_use')return {...base,type:'tool.started',tool_name:boundText(event.name,128)};
15
- if(event.type==='tool_result')return {...base,type:'tool.finished',tool_name:boundText(event.name,128),text:event.is_error?'failed':'returned'};
14
+ if(event.type==='tool_use'){
15
+ const detail=event.name==='WorkStepRun'&&['Read','Write','Edit','Bash','Glob','Grep','FileInfo','ListDirectory'].includes(event.input?.operation)?event.input.operation:event.name==='ToolPack'&&['search','list','acquire','release','status'].includes(event.input?.action)?event.input.action:undefined;
16
+ return {...base,type:'tool.started',tool_name:boundText(event.name,128),...(event.id?{tool_call_id:boundText(event.id,128)}:{}),...(detail?{tool_detail:detail}:{})};
17
+ }
18
+ if(event.type==='tool_result')return {...base,type:'tool.finished',tool_name:boundText(event.name,128),text:event.is_error?'failed':'returned',...(event.tool_use_id?{tool_call_id:boundText(event.tool_use_id,128)}:{}),...(Number.isSafeInteger(event.duration_ms)&&event.duration_ms>=0&&event.duration_ms<=86400000?{duration_ms:event.duration_ms}:{})};
19
+ if(event.type==='house_waiting')return {...base,type:'permission.waiting',tool_name:boundText(event.operation,128),text:'Waiting for approval in the local terminal'};
20
+ if(event.type==='house_answered')return {...base,type:'permission.answered',tool_name:boundText(event.operation,128),text:event.allowed?'Approved locally':'Declined locally'};
16
21
  if(event.type==='response_discarded'||event.type==='model_retry')return {...base,type:'stream.discarded'};
17
22
  return null;
18
23
  }
@@ -118,6 +123,9 @@ export class RemoteHost {
118
123
  return;
119
124
  }
120
125
  if(['assistant','response_discarded','model_retry'].includes(event.type)){clearTimeout(this.deltaTimer);this.deltaTimer=null;this.delta='';}
126
+ this.toolStarts??=new Map();
127
+ if(event.type==='tool_use'&&event.id)this.toolStarts.set(event.id,Date.now());
128
+ if(event.type==='tool_result'&&this.toolStarts.has(event.tool_use_id)){event={...event,duration_ms:Date.now()-this.toolStarts.get(event.tool_use_id)};this.toolStarts.delete(event.tool_use_id);}
121
129
  const projected=projectRemoteEvent(event,this.turnId);if(!projected)return;
122
130
  if(this.pending.length>=512){this.onNotice('Remote projection paused: outbound buffer full. Local work continues.');this.stop().catch(()=>{});return;}
123
131
  append(projected);this.flush().catch(()=>{});
package/src/tools.mjs CHANGED
@@ -176,7 +176,7 @@ export class WorkspaceTools {
176
176
  }
177
177
  if (name === 'Bash') {
178
178
  const check = readOnlyBashCheck(input.command);
179
- if (!check) return runProcess('/bin/sh', ['-c', input.command], { cwd: this.cwd, timeout: input.timeout ?? 30000, signal });
179
+ if (!check) return runProcess('/bin/sh', ['-c', input.command], { cwd: this.cwd, timeout: input.timeout ?? 30000, signal, observe });
180
180
  const files = new Map();
181
181
  for (const name of check.files) { const file = await this.resolve(name); files.set(name, { file, before: hash(await this.text(file)) }); }
182
182
  let output = '';
@@ -278,10 +278,11 @@ function globRegex(pattern) {
278
278
  return new RegExp(source + '$');
279
279
  }
280
280
 
281
- export function runProcess(command, args, { cwd, timeout = 30000, signal, allowNoMatch = false }) {
281
+ export function runProcess(command, args, { cwd, timeout = 30000, signal, allowNoMatch = false, observe = () => {} }) {
282
282
  return new Promise((resolve, reject) => {
283
283
  signal?.throwIfAborted();
284
284
  const child = spawn(command, args, { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
285
+ observe({kind:'process_started',pid:child.pid,process_group:child.pid});
285
286
  let output = '', truncated = false, failure, killTimer;
286
287
  const terminate = reason => {
287
288
  if (failure) return; failure = reason;
@@ -299,11 +300,18 @@ export function runProcess(command, args, { cwd, timeout = 30000, signal, allowN
299
300
  signal?.addEventListener('abort', abort, { once: true });
300
301
  const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); };
301
302
  child.once('error', err => { cleanup(); clearTimeout(killTimer); reject(err); });
302
- child.once('close', code => {
303
+ child.once('close', async (code, terminationSignal) => {
303
304
  cleanup();
305
+ if(failure){
306
+ // Wait for the bounded escalation before recording a terminal receipt.
307
+ await new Promise(resolve=>setTimeout(resolve,250));
308
+ let absent=false;try{process.kill(-child.pid,0);}catch(error){absent=error.code==='ESRCH';}
309
+ observe({kind:'process_group_termination',process_group:child.pid,status:absent?'absent':'unconfirmed',scope:'local_process_group_only',external_effects:'unjudged'});
310
+ }
311
+ observe({kind:'process_exit',exit_code:code,termination_signal:terminationSignal || null,interrupted:Boolean(failure)});
304
312
  // Leave the short kill timer alive after timeout to stop remaining descendants.
305
313
  if (failure) reject(failure);
306
- else if (code !== 0 && !(allowNoMatch && code === 1)) reject(new Error(`Command exited with code ${code}: ${output}`));
314
+ else if (code !== 0 && !(allowNoMatch && code === 1)) reject(Object.assign(new Error(`Command exited with code ${code}: ${output}`),{code:'PROCESS_EXIT_NONZERO'}));
307
315
  else resolve(output + (truncated ? '\n[Output limited to 64 KiB]' : '') || '(no output)');
308
316
  });
309
317
  });
@@ -171,6 +171,7 @@ export class InteractiveController {
171
171
  else throw new Error(t("Use /queue, /queue pause|resume|clear or /queue edit N."));
172
172
  this.syncQueue(); return;
173
173
  }
174
+ if(command==='work'&&(arg.trim()==='recover'||arg.trim().startsWith('review '))){await this.requireIdle();const output=await this.backend.control(command,arg);if(output)this.ui.notice(output);return;}
174
175
  if (['board', 'tasks', 'work'].includes(command)) { this.ui.showBoard(JSON.parse(await this.backend.control('board', ''))); return; }
175
176
  if (['cloud', 'model', 'effort', 'provider', 'login', 'logout'].includes(command) && (arg.trim() || ['login', 'logout'].includes(command))) await this.requireIdle();
176
177
  if (command === 'table') { if (!arg) throw new Error(t("Use /table file.csv|tsv|json|md.")); const table = await this.backend.readTable(arg); this.ui.addTable(table.text, table.format, arg); return; }
@@ -13,7 +13,7 @@ const COMMANDS = [
13
13
  ['copy', '', "Copy the last complete response"], ['select', '', "Select text with the terminal"],
14
14
  ['compact', '', "Derive ledger state without an LLM"], ['context', '', "Check the context budget"],
15
15
  ['state', '', "Request and current job status"], ['audit', '', "Verify ledger integrity"],
16
- ['board', '', "Open the task board"], ['work', 'resume|recover', "Continue work or reconcile a Chrome publication"],
16
+ ['board', '', "Open the task board"], ['work', 'resume|recover|review', "Continue work, recover Chrome or review a native failure locally"],
17
17
  ['tasks', '', "Open the task board"], ['model', 'ID', "Check or change the model"],
18
18
  ['models', '', "Check provider models; can make a request"],
19
19
  ['effort', "LEVEL", "Check or change the effort announced by the model"],
@@ -128,7 +128,7 @@ function argumentItems(command, args, context) {
128
128
  if (command === 'agents') return [...named(['status','wait','stop','resume','background','continue'],t("Child run controls")),...entries(context.agents,'id')];
129
129
  if (command === 'send') return entries(context.peers, 'id').map(peer => ({ ...peer, description: t("Known live session; sending does not equal executing") }));
130
130
  if (command === 'capabilities') return named(['refresh'], 'Volver a consultar capacidades; puede hacer peticiones');
131
- if (command === 'work') return named(['resume', 'recover'], t("Continue work or reconcile a Chrome publication"));
131
+ if (command === 'work') return named(['resume', 'recover', 'review'], t("Continue work, recover Chrome or review a native failure locally"));
132
132
  if (command === 'goal') return named(['status', 'pause', 'resume', 'clear'], t("Target control; you can also write a condition"));
133
133
  if (command === 'queue') return named(['pause', 'resume', 'clear', 'edit'], t("Action on the queue"));
134
134
  if (command === 'mcp') return named(['status', 'list', 'config', 'reload', 'reconnect', 'add', 'remove'], t("Action on MCP servers"));
package/src/work-plan.mjs CHANGED
@@ -11,7 +11,7 @@ const artifactObservers = new WeakMap();
11
11
  const artifactCall = (house, sessionId, file_path) => artifactObservers.get(house)?.get(sessionId) === 'Read'
12
12
  ? ['Read', { file_path }] : ['FileInfo', { file_path, hash: true }];
13
13
 
14
- export const WORK_NAMES = ['WorkPlanSet', 'WorkCheckRepair', 'WorkTaskUpdate', 'WorkStepRun', 'WorkDraft', 'WorkBoard'];
14
+ export const WORK_NAMES = ['WorkPlanSet', 'WorkCheckRepair', 'WorkTaskUpdate', 'WorkStepRun', 'WorkDraft', 'WorkBoard', 'WorkFailureReview'];
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 });
@@ -78,8 +78,57 @@ function preflightFailure(session, step, task, audit, active) {
78
78
  ...(managed ? { retry_call: { name: 'WorkTaskUpdate', input: { task_id: input.task_id, plan_version: input.plan_version, status: 'done', verify: true } } } : {}) };
79
79
  }
80
80
 
81
+ // A closed native shell failure is a terminal outcome, not an interrupted effect.
82
+ // Keep its failed receipt, partial files and no-replay rule. Only a NEW judged
83
+ // corrective step may proceed; mandatory task checks still require fresh proof.
84
+ function closedProcessFailure(session,step,audit,active){
85
+ if(step.status!=='failed'||step.operation!=='Bash'||active.has(step.invocation_id)||!audit.journal.verified||audit.violations.length||audit.unverified.length)return null;
86
+ 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);
87
+ if(!leaf||!parent||leaf.status!=='failed'||parent.status!=='failed'||leaf.parent_invocation_id!==parent.invocation_id||parent.operation!=='WorkStepRun'||leaf.operation!=='Bash'||leaf.error!==parent.error)return null;
88
+ const proposal=rows.find(row=>row.type==='house_proposal'&&row.invocation_id===parent.invocation_id);
89
+ const contract=rows.findLast(row=>row.type==='house_contract'&&row.contract_digest===leaf.contract_digest)?.contract;
90
+ if(!proposal||!nativeWorkParent(rows,proposal)||contract?.name!=='Bash'||contract.origin!==null||contract.permission!=='Bash'||contract.steps||contract.effects?.process!=='unknown')return null;
91
+ const exits=(leaf.observations||[]).filter(item=>item.kind==='process_exit');
92
+ const started=(leaf.observations||[]).filter(item=>item.kind==='process_started'),terminated=(leaf.observations||[]).filter(item=>item.kind==='process_group_termination');
93
+ if(/^Command exceeded [0-9]+ ms\.$/.test(leaf.error||'')&&exits.length===1&&exits[0].interrupted===true&&started.length===1&&Number.isInteger(started[0].process_group)&&started[0].process_group>0&&terminated.length===1&&terminated[0].process_group===started[0].process_group&&terminated[0].status==='absent'&&terminated[0].scope==='local_process_group_only')return{kind:'native_process_timeout_terminated',receipt_id:leaf.invocation_id,parent_receipt_id:parent.invocation_id,process_group:started[0].process_group,partial_effects:'preserved_unjudged',external_effects:'unjudged',retry_authorized:false};
94
+ const match=/^Command exited with code ([1-9][0-9]{0,2}):/.exec(leaf.error||'');if(!match||Number(match[1])>255)return null;
95
+ if(exits.length&&(exits.length!==1||exits[0].interrupted||exits[0].exit_code!==Number(match[1])||exits[0].termination_signal))return null;
96
+ 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
+ }
98
+
99
+ // A native media capability rejection precedes file capture and provider dispatch.
100
+ // This is not a generic exemption for provider/plugin failures or mutations.
101
+ function closedMediaPreflight(session,step,audit,active){
102
+ if(step.status!=='failed'||step.operation!=='AnalyzeMedia'||active.has(step.invocation_id)||!audit.journal.verified||audit.violations.length||audit.unverified.length)return null;
103
+ 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);
104
+ if(!leaf||!parent||leaf.status!=='failed'||parent.status!=='failed'||leaf.parent_invocation_id!==parent.invocation_id||parent.operation!=='WorkStepRun'||leaf.error!==parent.error)return null;
105
+ const proposal=rows.find(row=>row.type==='house_proposal'&&row.invocation_id===parent.invocation_id),contract=rows.findLast(row=>row.type==='house_contract'&&row.contract_digest===leaf.contract_digest)?.contract;
106
+ if(!proposal||!nativeWorkParent(rows,proposal)||contract?.name!=='AnalyzeMedia'||contract.origin!==null||contract.effects?.workspace!=='read'||contract.effects?.external!=='read'||contract.effects?.process!=='none'||contract.effects?.authority!=='none')return null;
107
+ const native=(leaf.observations||[]).filter(o=>o.kind==='provider_media_preflight_rejected'&&o.target_dispatched===false&&o.reason==='unsupported_modality');
108
+ const start=rows.findIndex(row=>row.type==='house_started'&&row.invocation_id===leaf.invocation_id),end=rows.indexOf(rows.find(row=>row.type==='house_receipt'&&row.invocation_id===leaf.invocation_id));
109
+ const usage=rows.slice(start+1,end).filter(row=>row.type==='model_usage'&&row.purpose==='provider_media');
110
+ const legacy=leaf.error==='Este perfil no ofrece la capacidad de medios solicitada.'&&usage.length===1&&usage[0].error==='Error'&&usage[0].usage===null&&!usage[0].media;
111
+ if(!(leaf.code==='PROVIDER_MEDIA_UNSUPPORTED'&&native.length===1)&&!legacy)return null;
112
+ return {kind:'native_media_preflight_rejection',receipt_id:leaf.invocation_id,parent_receipt_id:parent.invocation_id,target_dispatched:false,retry_authorized:false,derivation:legacy?'legacy_native_capability_rejection':'native_preflight_observation'};
113
+ }
114
+
115
+ // Historical native publisher threw after an acknowledged media job remained
116
+ // processing. The upload exists; no artifact publication was dispatched. Keep
117
+ // that distinction, its failed receipt and no-replay rule on existing sessions.
118
+ function closedArtifactProcessing(session,step,audit,active){
119
+ if(step.status!=='failed'||step.operation!=='ArtifactPublish'||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.status!=='failed'||parent.status!=='failed'||leaf.parent_invocation_id!==parent.invocation_id||parent.operation!=='WorkStepRun'||leaf.error!==parent.error)return null;
122
+ const proposal=rows.find(row=>row.type==='house_proposal'&&row.invocation_id===parent.invocation_id),contract=rows.findLast(row=>row.type==='house_contract'&&row.contract_digest===leaf.contract_digest)?.contract;
123
+ const media=/^Video ([a-f0-9]{32}) is still processing\. Retry the same source to reconcile it without another upload\.$/.exec(leaf.error||''),rejected=leaf.code==='CONTRACT_CHANGE'&&leaf.error==='CONTRACT_CHANGE: Updates must retain the accepted checks; changing the contract requires a separate explicit decision.';
124
+ if((!media&&!rejected)||!proposal||!nativeWorkParent(rows,proposal)||contract?.name!=='ArtifactPublish'||contract.origin?.plugin!=='artifacts'||contract.origin.builtin!==true||contract.origin.version!=='0.1.0'||contract.origin.module_sha256!==null||contract.effects?.workspace!=='read'||contract.effects?.external!=='write'||contract.effects?.process!=='none'||contract.effects?.authority!=='none')return null;
125
+ if(rejected)return{kind:'native_artifact_contract_rejected',receipt_id:leaf.invocation_id,parent_receipt_id:parent.invocation_id,candidate_created:false,accepted_checks_preserved:true,retry_authorized:false,derivation:'legacy_builtin_contract_precondition_rejection'};
126
+ 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'};
127
+ }
128
+
81
129
  // A board is a fold of host facts. Model prose, nested receipts and thinking are not facts.
82
130
  export function foldWorkBook(session, { active = new Set() } = {}) {
131
+ const rows=session.rows||[];
83
132
  let plan = null, checkpoint = null; const tasks = new Map(), steps = new Map(), drafts = new Map(), waiting = new Map(), attestations = new Map(), draftCalls = new Map(), draftInputs = new Map();
84
133
  for (let i = 0; i < (session.rows || []).length; i++) {
85
134
  const row = session.rows[i];
@@ -131,10 +180,23 @@ export function foldWorkBook(session, { active = new Set() } = {}) {
131
180
  if (proof) Object.assign(step, { status: 'refused', preflight_failure: proof, recovery_ref: proof.parent_receipt_ref,
132
181
  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.' });
133
182
  }
134
- const receiptValid = receipt => {
183
+ for(const step of steps.values()){const proof=closedProcessFailure(session,step,audit,active)||closedMediaPreflight(session,step,audit,active)||closedArtifactProcessing(session,step,audit,active);if(proof)step.failure_closed=proof;}
184
+ // Explicit local operator review is an attestation, not reconstructed execution
185
+ // success. Only sealed human-control receipts can review a closed native Bash
186
+ // failure; raw facts and model prose never clear a fence.
187
+ for(const receipt of audit.receipts.filter(r=>r.operation==='WorkFailureReview'&&r.status==='returned')){
188
+ if(!audit.journal.verified||audit.violations.length||audit.unverified.length||receipt.actor?.kind!=='human'||receipt.origin!==null||receipt.parent_invocation_id)continue;
189
+ const contract=rows.findLast(r=>r.type==='house_contract'&&r.contract_digest===receipt.contract_digest)?.contract,proposal=rows.find(r=>r.type==='house_proposal'&&r.invocation_id===receipt.invocation_id);
190
+ if(contract?.audience!=='human'||contract.permission!==null||!proposal)continue;
191
+ let review;try{review=JSON.parse(receipt.output);}catch{continue;}
192
+ const input=recoveryInput(rows,proposal),step=[...steps.values()].find(s=>s.task_id===input?.task_id&&s.step_id===input?.step_id),leaf=step&&audit.receipts.find(r=>r.invocation_id===step.receipt_id),leafContract=leaf&&rows.findLast(r=>r.type==='house_contract'&&r.contract_digest===leaf.contract_digest)?.contract;
193
+ if(!step||step.status!=='failed'||active.has(step.invocation_id)||step.operation!=='Bash'||leaf?.status!=='failed'||leafContract?.origin!==null||leafContract?.name!=='Bash'||input.expected_receipt!==leaf.invocation_id||review.reviewed_receipt!==leaf.invocation_id||review.scope!=='operator_review_not_execution_success'||review.reason!==input.reason)continue;
194
+ step.failure_closed={kind:'operator_reviewed_native_failure',receipt_id:leaf.invocation_id,review_receipt:receipt.invocation_id,scope:review.scope,partial_effects:'preserved_unjudged',retry_authorized:false};
195
+ }
196
+ const receiptValid = (receipt,files) => {
135
197
  if (!receipt || receipt.status !== 'returned' || receipt.output_truncated || !audit.journal.consistent) return false;
136
198
  if (audit.violations.some(v => v.invocation_id === receipt.invocation_id) || audit.unverified.some(v => v.invocation_id === receipt.invocation_id)) return false;
137
- return (receipt.observations || []).every(observation => !['file_write', 'file_read'].includes(observation.kind) || audit.state.find(state => state.path === observation.file_path)?.sha256 === (observation.after_sha256 || observation.sha256));
199
+ return (receipt.observations || []).every(observation => !['file_write', 'file_read'].includes(observation.kind) || files&&!files.has(observation.file_path) || audit.state.find(state => state.path === observation.file_path)?.sha256 === (observation.after_sha256 || observation.sha256));
138
200
  };
139
201
  const checkValid = (task, check, step) => {
140
202
  const receipt = receipts.get(step?.receipt_id);
@@ -148,7 +210,7 @@ export function foldWorkBook(session, { active = new Set() } = {}) {
148
210
  for (const task of tasks.values()) {
149
211
  task.declared_status = task.status;
150
212
  // Latest accepted state is derived; a done claim loses currency after a recorded touch.
151
- if (task.status === 'done' && (!task.receipt_ids.length || !task.receipt_ids.every(receiptId => receiptValid(receipts.get(receiptId))) || !task.checks.every(check => checkValid(task, check, steps.get(keyOf(task.id, check.step_id)))))) { task.status = 'needs_verification'; task.reason = 'Una comprobación perdió vigencia en el libro.'; }
213
+ if (task.status === 'done' && (!task.receipt_ids.length || !task.receipt_ids.every(receiptId => receiptValid(receipts.get(receiptId),new Set(task.artifacts||[]))) || !task.checks.every(check => checkValid(task, check, steps.get(keyOf(task.id, check.step_id)))))) { task.status = 'needs_verification'; task.reason = 'Una comprobación perdió vigencia en el libro.'; }
152
214
  }
153
215
  for (let pass = 0; pass < tasks.size; pass++) for (const task of tasks.values()) {
154
216
  task.held_by = task.depends_on.filter(parent => tasks.get(parent)?.status !== 'done');
@@ -193,6 +255,11 @@ export function workProjection(session, context = {}) {
193
255
  read_call: { name: 'EvidenceRead', input: { row: receipt_ref ?? recovery_ref ?? ref } } })),
194
256
  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) })),
195
257
  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,
258
+ 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.'})),
259
+ 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.'})),
260
+ 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.'})),
261
+ 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.'})),
262
+ 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.'})),
196
263
  check_state: checks, finish_call: finishCall, ...(preflightFailures.length ? { preflight_failures: preflightFailures } : {}),
197
264
  ...(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 } } } : {}) } } : {}),
198
265
  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.' },
@@ -363,16 +430,25 @@ export class WorkCoordinator {
363
430
  const proof = book.attestations.get(keyOf(task.id, check.step_id));
364
431
  for (const receiptId of proof?.artifact_receipts || []) candidates.push(book.receipts.get(receiptId));
365
432
  }
366
- for (const receipt of candidates.filter(Boolean).sort((a, b) => a.evidence_row - b.evidence_row)) for (const observation of receipt.observations || []) if (['file_write', 'file_read'].includes(observation.kind)) artifacts.set(observation.file_path, receipt);
433
+ for (const receipt of candidates.filter(Boolean).sort((a, b) => a.evidence_row - b.evidence_row)) for (const observation of receipt.observations || []) if (['file_write', 'file_read'].includes(observation.kind)&&(task.artifacts||[]).includes(observation.file_path)) artifacts.set(observation.file_path, receipt);
367
434
  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); }
368
435
  receiptIds = [...new Set(receiptIds)];
369
436
  for (const parent of task.depends_on) dependencyProofs[parent] = digest(book.tasks.find(row => row.id === parent).receipt_ids);
370
- if (book.steps.some(step => step.task_id === task.id && ['running', 'interrupted', 'failed'].includes(step.status))) throw failure('Hay un paso con efectos inciertos: inspecciona antes de cerrar.', 'WORK_UNVERIFIED');
437
+ 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');
371
438
  }
372
439
  await this.fact(options, { type: 'work_task_changed', task_id: task.id, task_digest: workDigest(task), plan_version: book.plan.version, status: input.status, reason: input.reason, receipt_ids: receiptIds, dependency_proofs: dependencyProofs });
373
440
  return { task_id: task.id, status: input.status, receipt_ids: receiptIds, scope: input.status === 'done' && task.checks.every(check=>check.assertions?.length) ? 'declared_assertions_verified' : 'declared_checks_returned', asked: 'unjudged' };
374
441
  }); await this.notify(options.sessionId); return JSON.stringify({ ...result, ...(refreshed.length ? { refreshed_tasks: refreshed } : {}) });
375
442
  }
443
+ async reviewFailure(input,options){
444
+ if(options.actor?.kind!=='human')throw failure('A native failure review requires local operator control.','WORK_REVIEW_AUTHORITY');
445
+ await this.fact(options,{type:'work_call_prepared',operation:'WorkFailureReview',input});
446
+ return this.mutate(options.sessionId,async(session,book)=>{
447
+ const task=this.task(book,input),step=book.steps.find(s=>s.task_id===task.id&&s.step_id===input.step_id),audit=auditExecution(session),leaf=step&&audit.receipts.find(r=>r.invocation_id===step.receipt_id),contract=leaf&&session.rows.findLast(r=>r.type==='house_contract'&&r.contract_digest===leaf.contract_digest)?.contract;
448
+ if(!audit.journal.verified||audit.violations.length||audit.unverified.length||step?.status!=='failed'||step.operation!=='Bash'||leaf?.status!=='failed'||leaf.invocation_id!==input.expected_receipt||contract?.name!=='Bash'||contract.origin!==null||this.house.stateContext().active.has(step.invocation_id))throw failure('Review requires the exact sealed, terminal native Bash failure; active, missing, stale and plugin effects cannot be reviewed.','WORK_REVIEW_INVALID');
449
+ return JSON.stringify({reviewed_receipt:leaf.invocation_id,scope:'operator_review_not_execution_success',reason:input.reason,task_id:task.id,step_id:step.step_id,failed_receipt_preserved:true,replay_authorized:false});
450
+ });
451
+ }
376
452
  async runStep(input, options) {
377
453
  if(!options.draftPublication)await this.fact(options,{type:'work_call_prepared',operation:'WorkStepRun',input});
378
454
  if (input.operation === undefined || input.arguments === undefined) {
@@ -403,7 +479,8 @@ export class WorkCoordinator {
403
479
  const retryFailedRead = input.recheck && check && existing.status === 'failed' && book.receipts.has(existing.receipt_id) && readOnlyWorkCheck(check.operation, check.arguments);
404
480
  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');
405
481
  }
406
- if (book.steps.some(step => step.task_id === task.id && (['running', 'interrupted'].includes(step.status) || step.status === 'failed' && !readOnlyWorkCheck(step.operation, step.arguments)))) throw failure('La tarea tiene un paso sin cierre o con efectos inciertos; no se iniciará otro efecto.', 'WORK_UNCERTAIN');
482
+ 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==='Bash'&&Boolean(readOnlyWorkCheck('Bash',input.arguments)));
483
+ 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');
407
484
  const offered=options.catalogue?.find(op=>op.name===input.operation), operation=this.house.registry.get(input.operation);
408
485
  if(offered && operation?.digest===offered.contract_digest) {
409
486
  try{operation.validate(input.arguments);}catch(cause){throw failure(cause.message+' Entrada rechazada ANTES de reservar un nuevo intento o iniciar efectos. Corrige los tipos/argumentos y reintenta el MISMO step_id; no hace falta otro plan.','WORK_STEP_INPUT_SCHEMA');}
@@ -480,13 +557,14 @@ export class WorkCoordinator {
480
557
  operations() {
481
558
  const task = { task_id: id, plan_version: version };
482
559
  return [
560
+ {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)},
483
561
  { 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"}. Verification needs 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) },
484
562
  { 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) },
485
563
  { 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) },
486
564
  { 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) },
487
565
  { name: 'WorkDraft', description: 'Durable scripts in ANY language. Prefer append with a stable part_id: host assigns cursor and deduplicates identical retries. Send short logical sections; up to 64000 chars per complete call. final:true marks last section; publish independently judges Write on the immutable final draft. Legacy chunk uses zero-based index (first:0). Archived chunks are never executed.', input_schema: schema({ ...task, draft_id: id, action: { type: 'string', enum: ['append', 'chunk', 'read', 'publish'] }, file_path: str(1024), part_id: id, index: { type: 'integer', minimum: 0, description: 'Legacy chunk cursor: first index is 0, then use returned next_index.' }, content: { type: 'string', maxLength: 64000 }, final: { type: 'boolean' }, expected_digest: { type: 'string', pattern: '^[a-f0-9]{64}$' }, offset: { type: 'integer', minimum: 0 }, limit: { type: 'integer', minimum: 1, maximum: 8000 }, next_action: str(1000) }, ['task_id', 'plan_version', 'draft_id', 'action']), run: (input, options) => this.draft(input, options) },
488
566
  { name: 'WorkBoard', description: 'Read a missing board/check definition and exact repair_call. Conversation carries normal progress; query only when information is missing, without polling. task_id retrieves exact definitions/checks and small step references for ONE task.', input_schema: schema({ task_id: id }, []), run: async (input, { sessionId }) => { const session = await this.store.load(sessionId), book = foldWorkBook(session, this.house.stateContext()); const projected = workProjection(session, this.house.stateContext()), issues = checkContractIssues(book.tasks,name=>this.house.registry.get(name),{rejectMissing:true}); return JSON.stringify({ ...(issues.length ? {check_contract_issues:{issues,repair_call:{name:'WorkCheckRepair',input:{plan_version:book.plan.version}}}} : {}), plan_control: planControl(projected), ...(projected || { plan: null, cards: [], counts: { done: 0, pending: 0, in_progress: 0, blocked: 0, needs_verification: 0 }, current: null, drafts: [], next_action: 'Declare a plan with WorkPlanSet before multi-step work.' }), ...(input.task_id ? { tasks: book.tasks.filter(task => task.id === input.task_id), steps: book.steps.filter(step => step.task_id === input.task_id).map(({ step_id, operation, status, receipt_id, receipt_ref, next_action }) => ({ step_id, operation, status, receipt_id, receipt_ref, next_action })) } : {}) }); } },
489
- ].map(definition => ({ ...definition, input_schema: definition.name === 'WorkDraft' ? { ...definition.input_schema, allOf: [{ if: { properties: { action: { const: 'chunk' } } }, then: { required: ['file_path', 'index', 'content'] } }, { if: { properties: { action: { const: 'append' } } }, then: { required: ['file_path', 'part_id', 'content'], not: { required: ['index'] } } }] } : definition.input_schema, native: true, effects: ['WorkStepRun', 'WorkDraft', 'WorkTaskUpdate'].includes(definition.name) ? { workspace: 'unknown', external: 'unknown', process: 'unknown', authority: 'none' } : { workspace: 'none', external: 'none', process: 'none', authority: 'none' } }));
567
+ ].map(definition => ({ ...definition, input_schema: definition.name === 'WorkDraft' ? { ...definition.input_schema, allOf: [{ if: { properties: { action: { const: 'chunk' } } }, then: { required: ['file_path', 'index', 'content'] } }, { if: { properties: { action: { const: 'append' } } }, then: { required: ['file_path', 'part_id', 'content'], not: { required: ['index'] } } }] } : definition.input_schema, native: true, effects: definition.effects || (['WorkStepRun', 'WorkDraft', 'WorkTaskUpdate'].includes(definition.name) ? { workspace: 'unknown', external: 'unknown', process: 'unknown', authority: 'none' } : { workspace: 'none', external: 'none', process: 'none', authority: 'none' }) }));
490
568
  }
491
569
  }
492
570