@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 +1 -1
- package/package.json +1 -1
- package/src/artifact-tools.mjs +1 -1
- package/src/auto-guard.mjs +5 -1
- package/src/cli.mjs +6 -2
- package/src/cloud-client.mjs +13 -5
- package/src/codex-provider.mjs +1 -1
- package/src/peer-tools.mjs +4 -3
- package/src/permissions.mjs +1 -1
- package/src/remote-control.mjs +11 -3
- package/src/tools.mjs +12 -4
- package/src/tui/controller.mjs +1 -0
- package/src/tui/discovery.mjs +2 -2
- package/src/work-plan.mjs +86 -8
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.
|
|
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
package/src/artifact-tools.mjs
CHANGED
|
@@ -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
|
|
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
|
}};
|
package/src/auto-guard.mjs
CHANGED
|
@@ -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', {});
|
package/src/cloud-client.mjs
CHANGED
|
@@ -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
|
|
46
|
-
|
|
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
|
}
|
package/src/codex-provider.mjs
CHANGED
|
@@ -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 });
|
package/src/peer-tools.mjs
CHANGED
|
@@ -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:'
|
|
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('
|
|
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.');
|
package/src/permissions.mjs
CHANGED
|
@@ -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
|
|
package/src/remote-control.mjs
CHANGED
|
@@ -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')
|
|
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')
|
|
15
|
-
|
|
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
|
});
|
package/src/tui/controller.mjs
CHANGED
|
@@ -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; }
|
package/src/tui/discovery.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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'
|
|
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
|
-
|
|
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
|
|