@staix/agent-hub 0.12.45 → 0.13.0
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/CHANGELOG.md +18 -0
- package/README.md +83 -116
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -7
- package/docs/agent-notes/budget.md +6 -1
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +6 -1
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +10 -0
- package/docs/agent-notes/tests.md +4 -2
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +250 -19
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +8 -2
- package/docs/sdlc.md +36 -6
- package/docs/security.md +20 -11
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +759 -8
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +14 -14
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +613 -329
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +20 -9
- package/src/adapters/codex-appserver.ts +18 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/definitions/builtin-metadata.ts +2 -1
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +2 -1
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/registry-types.ts +3 -1
- package/src/adapters/registry.ts +1 -0
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +16 -6
- package/src/cli/identity.ts +29 -6
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +122 -11
- package/src/cli/output.ts +44 -1
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +43 -8
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +10 -1
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/daemon.ts +237 -22
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/hub-tools.ts +24 -2
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instances.ts +73 -1
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/sdlc-queue.ts +31 -9
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc.ts +49 -11
- package/src/hub/settings.ts +5 -2
- package/src/hub/snapshots.ts +93 -8
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +292 -54
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +1 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
5
|
+
import { childEnv, stopOwnedProcess, trackGroup } from "./child-process.ts";
|
|
6
|
+
import { hubGitEnv, hubGitSync } from "./git.ts";
|
|
7
|
+
import { foldSegment } from "./permission-mode.ts";
|
|
8
|
+
import { realPath } from "./project.ts";
|
|
9
|
+
import { releaseTrackedClean } from "./release-worktree.ts";
|
|
10
|
+
|
|
11
|
+
export interface CodeSymbol { name: string; kind: string; path: string; line: number; endLine?: number; ambiguous: boolean; change: "unknown" }
|
|
12
|
+
export type CodeFactResult<T> = { state: "known"; value: T } | { state: "unknown" };
|
|
13
|
+
export interface CodeLocation { repo: string; head: string; private?: boolean }
|
|
14
|
+
const unknown = (): {state:"unknown"} => ({state:"unknown"});
|
|
15
|
+
const SHA = /^[a-f0-9]{40}$/;
|
|
16
|
+
// Keep the owned group leader alive after the CLI exits, until the host has stopped every group member.
|
|
17
|
+
const LAUNCHER = String.raw`
|
|
18
|
+
const {writeSync,closeSync}=require("node:fs");
|
|
19
|
+
setInterval(()=>{},1000);
|
|
20
|
+
let code=null;
|
|
21
|
+
try { code=await Bun.spawn(process.argv.slice(1),{stdin:"ignore",stdout:"inherit",stderr:"ignore"}).exited; } catch {}
|
|
22
|
+
try { closeSync(1); } catch {}
|
|
23
|
+
writeSync(3,JSON.stringify({code})+"\n");
|
|
24
|
+
`;
|
|
25
|
+
const MAX_OUTPUT = 256 * 1024, MAX_SYMBOLS = 256, MAX_FILES = 4096, MAX_TREE_BYTES = 32 * 1024 * 1024;
|
|
26
|
+
const within = (root:string,file:string) => {const r=relative(root,file);return r !== ".." && !r.startsWith("../") && !isAbsolute(r);};
|
|
27
|
+
const text = (value:unknown,cap=512): value is string => typeof value === "string" && !!value && Buffer.byteLength(value)<=cap && !/[\p{Cc}\p{Cf}]/u.test(value);
|
|
28
|
+
const pathName = (value:unknown): value is string => text(value,2048) && !isAbsolute(value) && !value.includes("\\") && !value.split("/").some(p=>!p || [".","..",".git"].includes(foldSegment(p)));
|
|
29
|
+
const integer = (value:unknown) => typeof value === "string" && /^\d+$/.test(value) && Number.isSafeInteger(Number(value));
|
|
30
|
+
interface Xml { tag:string; attrs:Record<string,string>; children:Xml[] }
|
|
31
|
+
/** Small bounded XML data reader: no DTD, entities, processing instructions or external resources. */
|
|
32
|
+
function xml(raw:string): Xml {
|
|
33
|
+
let root:Xml|undefined;const stack:Xml[]=[];let count=0;
|
|
34
|
+
const decode=(s:string)=>s.replace(/&(amp|lt|gt|quot|apos);/g,(_,e:string)=>({amp:"&",lt:"<",gt:">",quot:'"',apos:"'"})[e]!);
|
|
35
|
+
const tokens=raw.match(/<!--[\s\S]*?-->|<[^>]*>|[^<]+/g)??[];if(tokens.join("")!==raw)throw Error();
|
|
36
|
+
for(const part of tokens) {
|
|
37
|
+
if(part.startsWith("<!--"))continue;
|
|
38
|
+
if(!part.startsWith("<")){if(part.trim())throw Error();continue;}
|
|
39
|
+
if(part.startsWith("</")){if(!/^<\/[a-z][a-z0-9_-]*>$/.test(part)||stack.pop()?.tag!==part.slice(2,-1))throw Error();continue;}
|
|
40
|
+
const m=/^<([a-z][a-z0-9_-]*)([\s\S]*?)(\/?)>$/.exec(part);if(!m || ++count>4096 || stack.length>16)throw Error();
|
|
41
|
+
const attrs:Record<string,string>=Object.create(null);let rest=m[2]!;
|
|
42
|
+
while(rest.trim()) {
|
|
43
|
+
const a=/^\s+([a-z_][a-z0-9_-]*)="([^"<>]*)"/.exec(rest);if(!a || Object.hasOwn(attrs,a[1]!) || /&(?!amp;|lt;|gt;|quot;|apos;)/.test(a[2]!))throw Error();
|
|
44
|
+
attrs[a[1]!]=decode(a[2]!);rest=rest.slice(a[0].length);
|
|
45
|
+
}
|
|
46
|
+
const node={tag:m[1]!,attrs,children:[] as Xml[]};
|
|
47
|
+
if(stack.length)stack.at(-1)!.children.push(node);else {if(root)throw Error();root=node;}
|
|
48
|
+
if(!m[3])stack.push(node);
|
|
49
|
+
}
|
|
50
|
+
if(!root || stack.length)throw Error();return root;
|
|
51
|
+
}
|
|
52
|
+
function cleanSymbol(name:unknown,kind:unknown,position:unknown,via?:unknown): CodeSymbol {
|
|
53
|
+
if(via!==undefined && via!=="name" && via!=="resolved")throw Error();
|
|
54
|
+
if(!text(name) || !text(kind,32) || !/^[a-z_]+$/.test(kind) || !text(position,2100))throw Error();
|
|
55
|
+
const p=/^(.*):(\d+)$/.exec(position);if(!p || !pathName(p[1]) || !integer(p[2]) || Number(p[2])<1)throw Error();
|
|
56
|
+
return {name,kind,path:p[1],line:Number(p[2]),ambiguous:via === "name",change:"unknown"};
|
|
57
|
+
}
|
|
58
|
+
function changed(raw:string,head:string,base:string,cwd:string): CodeSymbol[] {
|
|
59
|
+
const root=xml(raw),a=root.attrs;
|
|
60
|
+
if(root.tag!=="pr-context" || a.schema!=="ripwire.pr-context/v1" || !a.root || realPath(a.root)!==realPath(cwd) || !a.at || !head.startsWith(a.at) || a.at.length<9 || !a.base_sha || !base.startsWith(a.base_sha) || a.base_sha.length<9
|
|
61
|
+
|| a.anchor!=="merge-base" || a.direction!=="head-since-fork" || ![undefined,"0","false","none"].includes(a.truncated) || ![undefined,"0"].includes(a.trim_level) || ![undefined,"0","1","false","true"].includes(a.counts_floor))throw Error();
|
|
62
|
+
const files=root.children.filter(n=>n.tag==="file");if(!integer(a.files)||Number(a.files)!==files.length)throw Error();
|
|
63
|
+
const out:CodeSymbol[]=[];
|
|
64
|
+
for(const file of files) {
|
|
65
|
+
if(!pathName(file.attrs.p))throw Error();
|
|
66
|
+
const group=file.children.find(n=>n.tag==="changed-symbols");if(!group || !integer(group.attrs.count))throw Error();
|
|
67
|
+
const symbols=group.children.filter(n=>n.tag==="s");if(Number(group.attrs.count)!==symbols.length || !integer(file.attrs.symbols) || Number(file.attrs.symbols)<symbols.length)throw Error();
|
|
68
|
+
for(const symbol of symbols) {
|
|
69
|
+
if(![undefined,"0","false"].includes(symbol.attrs.capped))throw Error();
|
|
70
|
+
const s=cleanSymbol(symbol.attrs.n,symbol.attrs.t,symbol.attrs.p,symbol.attrs.via);if(s.path!==file.attrs.p)throw Error();out.push(s);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if(out.length>MAX_SYMBOLS)throw Error();return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Sole optional ripwire boundary. No model/config-selected command, no raw output in results or errors. */
|
|
77
|
+
export class CodeFacts {
|
|
78
|
+
private readonly cache=new Map<string,Promise<CodeFactResult<unknown>>>();
|
|
79
|
+
private busy=false;
|
|
80
|
+
private closing=false;
|
|
81
|
+
private pending?:Promise<CodeFactResult<unknown>>;
|
|
82
|
+
private cancelActive?:()=>void;
|
|
83
|
+
private failedChild?:ReturnType<typeof spawn>;
|
|
84
|
+
private retained?:{tree:{cwd:string;close:()=>void};child:ReturnType<typeof spawn>};
|
|
85
|
+
async close():Promise<void> {
|
|
86
|
+
this.closing=true;this.cancelActive?.();await this.pending;
|
|
87
|
+
if(this.retained){
|
|
88
|
+
try{await stopOwnedProcess(this.retained.child,{group:true,termMs:50,killMs:500});this.retained.tree.close();this.retained=undefined;}
|
|
89
|
+
catch{throw Error("code facts cleanup unconfirmed");}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
constructor(private readonly options:{env?:NodeJS.ProcessEnv;timeoutMs?:number}={}) {}
|
|
93
|
+
private git(repo:string,args:string[]) {
|
|
94
|
+
return hubGitSync(["-C",repo,...args],{encoding:"utf8",env:hubGitEnv(childEnv(),{repositoryIsolation:true}),timeout:5000,maxBuffer:1024*1024});
|
|
95
|
+
}
|
|
96
|
+
private value(repo:string,args:string[]):string {const r=this.git(repo,args);if(r.status!==0)throw Error();return r.stdout.trim();}
|
|
97
|
+
private nativeEnv(cwd:string):NodeJS.ProcessEnv {
|
|
98
|
+
const env=hubGitEnv(childEnv(this.options.env ?? process.env),{repositoryIsolation:true});
|
|
99
|
+
const filters=this.git(cwd,["config","--name-only","--get-regexp","^filter\\..*\\.(clean|smudge|process|required)$"]);
|
|
100
|
+
if(filters.status!==0 && filters.status!==1)throw Error();
|
|
101
|
+
const keys=filters.stdout.trim()?filters.stdout.trim().split("\n"):[];
|
|
102
|
+
if(keys.length>128 || keys.some(key=>!/^filter\.[A-Za-z0-9_.-]+\.(clean|smudge|process|required)$/.test(key)))throw Error();
|
|
103
|
+
const config=["core.fsmonitor=false","core.hooksPath=/dev/null","diff.external=","core.pager=cat",...keys.map(key=>`${key}=${key.endsWith(".required")?"false":""}`)];
|
|
104
|
+
env.GIT_CONFIG_GLOBAL="/dev/null";env.GIT_CONFIG_NOSYSTEM="1";env.GIT_ATTR_NOSYSTEM="1";env.GIT_PAGER="cat";delete env.GIT_EXTERNAL_DIFF;
|
|
105
|
+
env.GIT_CONFIG_COUNT=String(config.length);
|
|
106
|
+
config.forEach((item,index)=>{const at=item.indexOf("=");env[`GIT_CONFIG_KEY_${index}`]=item.slice(0,at);env[`GIT_CONFIG_VALUE_${index}`]=item.slice(at+1);});
|
|
107
|
+
return env;
|
|
108
|
+
}
|
|
109
|
+
private async run(bin:string,args:string[],cwd:string):Promise<{code:number;out:string}> {
|
|
110
|
+
if(this.closing)return {code:1,out:""};
|
|
111
|
+
return new Promise(resolve=>{
|
|
112
|
+
let child:ReturnType<typeof spawn>;
|
|
113
|
+
try{child=spawn(process.execPath,["-e",LAUNCHER,"--",bin,...args],{cwd,env:this.nativeEnv(cwd),detached:true,stdio:["ignore","pipe","ignore","pipe"]});}catch{resolve({code:1,out:""});return;}
|
|
114
|
+
let size=0,done=false,failed=false,control="";const parts:Buffer[]=[];
|
|
115
|
+
let ended=false;const outputEnd=new Promise<void>(resolveEnd=>child.stdout!.once("end",()=>{ended=true;resolveEnd();}));
|
|
116
|
+
const finish=async(code:number|null)=>{
|
|
117
|
+
if(done)return;done=true;clearTimeout(timer);if(code===null)failed=true;
|
|
118
|
+
if(code!==null && !ended)await Promise.race([outputEnd,Bun.sleep(500)]);
|
|
119
|
+
if(!ended)code=null;
|
|
120
|
+
try{await stopOwnedProcess(child,{group:true,termMs:50,killMs:500});}catch{this.failedChild=child;code=null;}
|
|
121
|
+
if(this.cancelActive===cancel)this.cancelActive=undefined;
|
|
122
|
+
resolve({code:failed?1:code??1,out:!failed && code!==null?Buffer.concat(parts).toString("utf8"):""});
|
|
123
|
+
};
|
|
124
|
+
const cancel=()=>{void finish(null);};this.cancelActive=cancel;
|
|
125
|
+
const timer=setTimeout(cancel,Math.max(1,Math.min(5000,this.options.timeoutMs ?? 5000)));
|
|
126
|
+
child.once("spawn",()=>trackGroup(child));
|
|
127
|
+
child.stdout!.on("data",(b:Buffer)=>{if(failed)return;if(size+b.length>MAX_OUTPUT){failed=true;void finish(null);return;}parts.push(b);size+=b.length;});
|
|
128
|
+
child.stdio[3]!.on("data",(b:Buffer)=>{
|
|
129
|
+
if(done)return;control+=b.toString("utf8");if(control.length>64){void finish(null);return;}
|
|
130
|
+
if(!control.endsWith("\n"))return;
|
|
131
|
+
try{const message=JSON.parse(control);if(Object.keys(message).length!==1 || !(message.code===null || Number.isSafeInteger(message.code)&&message.code>=0&&message.code<=255))throw Error();void finish(message.code);}catch{void finish(null);}
|
|
132
|
+
});
|
|
133
|
+
child.once("error",()=>{void finish(null);});child.once("close",()=>{void finish(null);});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
private tree(repo:string,head:string):{cwd:string;close:()=>void} {
|
|
137
|
+
const inventory=this.value(repo,["ls-tree","-rz","--full-tree",head]);
|
|
138
|
+
const entries=inventory ? inventory.split("\0").filter(Boolean).map(line=>{
|
|
139
|
+
const m=/^(100644|100755) blob ([a-f0-9]{40})\t([\s\S]+)$/.exec(line);if(!m || !pathName(m[3]))throw Error();return {mode:m[1]!,oid:m[2]!,name:m[3]!};
|
|
140
|
+
}):[];
|
|
141
|
+
// ponytail: regular-file trees only, <=4096 entries/32 MiB/4 MiB per file. Symlink/gitlink trees stay unknown; a verified materializer is the upgrade.
|
|
142
|
+
if(entries.length>MAX_FILES)throw Error();
|
|
143
|
+
// Always materialize: tracked-clean owner trees can still contain untracked or ignored analysis inputs.
|
|
144
|
+
const parent=mkdtempSync(join(tmpdir(),"ahub-code-facts-")),cwd=join(parent,"tree");let registered=false;
|
|
145
|
+
const close=()=>{
|
|
146
|
+
let failed=false;
|
|
147
|
+
if(registered){const r=this.git(repo,["worktree","remove","--force",cwd]);failed=r.status!==0;}
|
|
148
|
+
rmSync(parent,{recursive:true,force:true});if(failed)throw Error();
|
|
149
|
+
};
|
|
150
|
+
try{
|
|
151
|
+
if(this.git(repo,["worktree","add","--detach","--no-checkout",cwd,head]).status!==0)throw Error();registered=true;
|
|
152
|
+
if(this.git(cwd,["read-tree",head]).status!==0)throw Error();
|
|
153
|
+
// --batch needs explicit object ids; no working-tree filters or checkout hooks are invoked.
|
|
154
|
+
const result=hubGitSync(["-C",repo,"cat-file","--batch"],{input:entries.map(e=>e.oid).join("\n")+"\n",encoding:"latin1",env:hubGitEnv(childEnv(),{repositoryIsolation:true}),timeout:5000,maxBuffer:MAX_TREE_BYTES+1024*1024});
|
|
155
|
+
if(result.status!==0)throw Error();
|
|
156
|
+
const bytes=Buffer.from(result.stdout,"latin1");let offset=0,total=0;
|
|
157
|
+
for(const entry of entries){
|
|
158
|
+
const end=bytes.indexOf(10,offset);if(end<0)throw Error();const m=/^([a-f0-9]{40}) blob (\d+)$/.exec(bytes.subarray(offset,end).toString("ascii"));
|
|
159
|
+
if(!m || m[1]!==entry.oid || !integer(m[2]))throw Error();const size=Number(m[2]);total+=size;offset=end+1;
|
|
160
|
+
if(size>4*1024*1024 || total>MAX_TREE_BYTES || offset+size>=bytes.length || bytes[offset+size]!==10)throw Error();
|
|
161
|
+
const file=join(cwd,entry.name);mkdirSync(dirname(file),{recursive:true});writeFileSync(file,bytes.subarray(offset,offset+size));chmodSync(file,entry.mode==="100755"?0o755:0o644);offset+=size+1;
|
|
162
|
+
}
|
|
163
|
+
if(offset!==bytes.length || !releaseTrackedClean(cwd,args=>{const r=this.git(cwd,args);return{code:r.status??1,stdout:r.stdout};}))throw Error();
|
|
164
|
+
return{cwd,close};
|
|
165
|
+
}catch{close();throw Error();}
|
|
166
|
+
}
|
|
167
|
+
private query<T>(at:CodeLocation,key:string,read:(cwd:string,bin:string)=>Promise<T>):Promise<CodeFactResult<T>> {
|
|
168
|
+
if(this.closing || this.retained || at.private || !SHA.test(at.head))return Promise.resolve(unknown());
|
|
169
|
+
let repo:string;try{repo=realPath(at.repo);}catch{return Promise.resolve(unknown());}
|
|
170
|
+
const id=JSON.stringify([repo,at.head,key]),hit=this.cache.get(id);if(hit)return (hit as Promise<CodeFactResult<T>>).then(value=>structuredClone(value));
|
|
171
|
+
if(this.busy)return Promise.resolve(unknown());
|
|
172
|
+
this.busy=true;this.failedChild=undefined;
|
|
173
|
+
const job=(async()=>{
|
|
174
|
+
let tree:ReturnType<CodeFacts["tree"]>|undefined;let answer:CodeFactResult<T>=unknown();
|
|
175
|
+
try{
|
|
176
|
+
const found=Bun.which("ripwire",{PATH:(this.options.env??process.env).PATH});if(!found)throw Error();const bin=realPath(found);if(within(repo,bin))throw Error();
|
|
177
|
+
tree=this.tree(repo,at.head);
|
|
178
|
+
const version=await this.run(bin,["--version"],tree.cwd);if(version.code!==0 || !/^ripwire 0\.6\.7(?:\s|$)/.test(version.out))throw Error();
|
|
179
|
+
const value=await read(tree.cwd,bin);
|
|
180
|
+
if(this.value(tree.cwd,["rev-parse","HEAD"])!==at.head || !releaseTrackedClean(tree.cwd,args=>{const r=this.git(tree!.cwd,args);return{code:r.status??1,stdout:r.stdout};}))throw Error();
|
|
181
|
+
answer={state:"known",value};
|
|
182
|
+
}catch{/* Optional derived evidence is unknown, never a review refusal. */}
|
|
183
|
+
finally{if(tree && this.failedChild){this.retained={tree,child:this.failedChild};answer=unknown();}else try{tree?.close();}catch{answer=unknown();}this.busy=false;}
|
|
184
|
+
return answer;
|
|
185
|
+
})();
|
|
186
|
+
this.pending=job;this.cache.set(id,job);
|
|
187
|
+
if(this.cache.size>128)this.cache.delete(this.cache.keys().next().value!);
|
|
188
|
+
void job.then(answer=>{if(answer.state==="unknown" && this.cache.get(id)===job)this.cache.delete(id);});
|
|
189
|
+
return job.then(value=>structuredClone(value));
|
|
190
|
+
}
|
|
191
|
+
changedSymbols(repo:string,base:string,head:string,options:Omit<CodeLocation,"repo"|"head">={}):Promise<CodeFactResult<CodeSymbol[]>> {
|
|
192
|
+
if(!SHA.test(base))return Promise.resolve(unknown());
|
|
193
|
+
return this.query({...options,repo,head},`changed:${base}`,async(cwd,bin)=>{const r=await this.run(bin,[cwd,`--pr-context=${base}`,"--no-cache"],cwd);if(r.code!==0)throw Error();return changed(r.out,head,base,cwd);});
|
|
194
|
+
}
|
|
195
|
+
enclosing(repo:string,head:string,file:string,line:number,options:Omit<CodeLocation,"repo"|"head">={}):Promise<CodeFactResult<CodeSymbol[]>> {
|
|
196
|
+
if(!pathName(file)||!Number.isSafeInteger(line)||line<1)return Promise.resolve(unknown());
|
|
197
|
+
return this.query({...options,repo,head},`at:${file}:${line}`,async(cwd,bin)=>{
|
|
198
|
+
const r=await this.run(bin,[cwd,`--at=${file}:${line}`,"--no-cache"],cwd);if(r.code!==0)throw Error();const node=xml(r.out),a=node.attrs;
|
|
199
|
+
if(node.tag!=="at" || a.schema!=="ripwire.at/v1" || a.p!==file || Number(a.l)!==line || !integer(a.chain)||Number(a.chain)!==node.children.length || node.children.length>32)throw Error();
|
|
200
|
+
return node.children.map(n=>{if(n.tag!=="s" || !integer(n.attrs.l)||!integer(n.attrs.el)||Number(n.attrs.el)<line || Number(n.attrs.l)>line)throw Error();return {...cleanSymbol(n.attrs.n,n.attrs.t,`${file}:${n.attrs.l}`,n.attrs.via),endLine:Number(n.attrs.el)};});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
private async relations(cwd:string,bin:string,symbol:string,kind:"callers"|"callees"):Promise<{defs:number;items:CodeSymbol[];countsFloor:boolean}> {
|
|
204
|
+
const r=await this.run(bin,[cwd,`--${kind}=${symbol}`,"--json","--no-cache"],cwd);if(r.code!==0)throw Error();const v=JSON.parse(r.out);
|
|
205
|
+
if(v.of!==symbol || !Number.isSafeInteger(v.defs)||v.defs<1 || !Number.isSafeInteger(v.count)||v.count<0 || !Array.isArray(v[kind])||v.count!==v[kind].length || v.count>MAX_SYMBOLS || realPath(v.root)!==realPath(cwd) || ![0,1,false,true].includes(v.counts_floor)
|
|
206
|
+
|| !Number.isSafeInteger(v.hop_tested)||v.hop_tested<0||!Number.isSafeInteger(v.hop_untested)||v.hop_untested<0
|
|
207
|
+
|| !Number.isSafeInteger(v.graph_ambiguous)||v.graph_ambiguous<0||!Number.isSafeInteger(v.graph_unresolved)||v.graph_unresolved<0)throw Error();
|
|
208
|
+
return {defs:v.defs,countsFloor:!!v.counts_floor,items:v[kind].map((s:Record<string,unknown>)=>({...cleanSymbol(s.n,s.t,s.p,s.via),ambiguous:s.via==="name" || v.defs>1 || v.graph_ambiguous>0}))};
|
|
209
|
+
}
|
|
210
|
+
exists(repo:string,head:string,symbol:string,options:Omit<CodeLocation,"repo"|"head">={}):Promise<CodeFactResult<{exists:boolean;ambiguous:boolean}>> {
|
|
211
|
+
if(!text(symbol))return Promise.resolve(unknown());
|
|
212
|
+
return this.query({...options,repo,head},`exists:${symbol}`,async(cwd,bin)=>{const r=await this.run(bin,[cwd,`--callers=${symbol}`,"--no-cache"],cwd);
|
|
213
|
+
if(r.code!==0){const n=xml(r.out);if(n.tag==="callers" && n.attrs.schema==="ripwire.callers/v1" && n.attrs.of===symbol && n.attrs.found==="0")return{exists:false,ambiguous:false};throw Error();}
|
|
214
|
+
const n=xml(r.out);if(n.tag!=="callers" || n.attrs.schema!=="ripwire.callers/v1" || n.attrs.of!==symbol || !integer(n.attrs.defs)||Number(n.attrs.defs)<1)throw Error();return{exists:true,ambiguous:n.attrs.via==="name" || Number(n.attrs.defs)>1};});
|
|
215
|
+
}
|
|
216
|
+
neighbours(repo:string,head:string,symbol:string,depth=1,options:Omit<CodeLocation,"repo"|"head">={}):Promise<CodeFactResult<{callers:CodeSymbol[];callees:CodeSymbol[];hiddenAmbiguous:number;countsFloor:boolean}>> {
|
|
217
|
+
if(depth!==1 || !text(symbol))return Promise.resolve(unknown());
|
|
218
|
+
return this.query({...options,repo,head},`neighbours:${symbol}:1`,async(cwd,bin)=>{
|
|
219
|
+
const incoming=await this.relations(cwd,bin,symbol,"callers"),outgoing=await this.relations(cwd,bin,symbol,"callees"),callers=incoming.items,callees=outgoing.items;
|
|
220
|
+
return {countsFloor:incoming.countsFloor || outgoing.countsFloor,callers:callers.filter(s=>!s.ambiguous),callees:callees.filter(s=>!s.ambiguous),hiddenAmbiguous:[...callers,...callees].filter(s=>s.ambiguous).length};
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ACTION_CLASSES } from "./action-class.ts";
|
|
2
|
+
import { actionPolicyConfig } from "./action-policy.ts";
|
|
1
3
|
import { adapterModuleDeclarations } from "../adapters/external-modules.ts";
|
|
2
4
|
import { externalMetadataDefinitions, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
|
|
3
5
|
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
@@ -32,6 +34,8 @@ export interface ConfigAuthorizationInput {
|
|
|
32
34
|
before: unknown;
|
|
33
35
|
after: unknown;
|
|
34
36
|
source: "config" | "routing";
|
|
37
|
+
/** Captured host filename, not the requested setting or a caller-provided authority. */
|
|
38
|
+
sourceFile?: string;
|
|
35
39
|
tracked: boolean | undefined;
|
|
36
40
|
authority: "ordinary" | "settings" | "terminal";
|
|
37
41
|
/** The daemon supplies this only for the selected setting or an explicitly confirmed reload key. */
|
|
@@ -48,6 +52,10 @@ export interface ConfigAuthorizationDecision { key: string; allowed: boolean; gu
|
|
|
48
52
|
export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuthorizationDecision {
|
|
49
53
|
const { key, before, after } = input;
|
|
50
54
|
const result = (allowed: boolean, guarded: boolean, reason: string): ConfigAuthorizationDecision => ({ key, allowed, guarded, reason });
|
|
55
|
+
if (key === "action_policy" || key.startsWith("action_policy.")) {
|
|
56
|
+
if (input.sourceFile !== "config.local.json") return result(false, true, "action policy requires .agenthub/config.local.json");
|
|
57
|
+
if (input.tracked !== false) return result(false, true, "action policy requires an untracked machine-local source");
|
|
58
|
+
}
|
|
51
59
|
if (same(before, after)) return result(true, false, "unchanged");
|
|
52
60
|
const supplied = input.guard;
|
|
53
61
|
const guard = supplied && (key === supplied.key || key === `${supplied.store === "routing" ? "routing." : ""}${supplied.path.join(".")}`) ? supplied : undefined;
|
|
@@ -135,6 +143,7 @@ const nativeValidators = { bool, text, strings, argv, number, words, block };
|
|
|
135
143
|
const schema: Record<string, Check> = {
|
|
136
144
|
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
|
|
137
145
|
watchdog_ms: number(1, 2_147_483_647), kimi_cmd: argv, qwen_cmd: argv, jcode_cmd: argv, codex_bin: text, jcode_home: text, jcode_provider_profile: text,
|
|
146
|
+
action_policy: block({ enabled: bool, always_ask: (v, k) => { actionPolicyConfig({ always_ask: v }); }, budgets: map(number(0, 1000, true), ACTION_CLASSES) }),
|
|
138
147
|
permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
|
|
139
148
|
adapter_modules: v => { adapterModuleDeclarations(v); },
|
|
140
149
|
adapters: v => { createProjectAdapterCatalog(v); }, instances: v => { validateInstances(v, createProjectAdapterCatalog()); }, pet: validatePetConfig,
|
|
@@ -201,7 +210,7 @@ const restart: Record<string, string> = {
|
|
|
201
210
|
profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
|
|
202
211
|
text_projection: "Converter and cache ownership are bound at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
|
|
203
212
|
};
|
|
204
|
-
const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify", "pet"]);
|
|
213
|
+
const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify", "pet"]);
|
|
205
214
|
const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
|
|
206
215
|
/** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
|
|
207
216
|
export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
|
package/src/hub/config-trust.ts
CHANGED
|
@@ -29,7 +29,7 @@ export const MACHINE_LOCAL = [
|
|
|
29
29
|
"terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
|
|
30
30
|
] as const;
|
|
31
31
|
/** Legacy exported field list stays compatible; new policies use the complete list below. */
|
|
32
|
-
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "adapter_modules", "text_projection", "instances"] as const;
|
|
32
|
+
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "action_policy", "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "adapter_modules", "text_projection", "instances"] as const;
|
|
33
33
|
|
|
34
34
|
/** A known repository, including an unreadable marker, must never be mistaken for a plain directory. */
|
|
35
35
|
export function configRepositoryPresent(cwd: string): boolean {
|
|
@@ -95,14 +95,17 @@ const drop = (file: Record<string, unknown>, path: string) => {
|
|
|
95
95
|
* once resolved to the project root). A committed copy of the template sets them to defaults and asks git nothing.
|
|
96
96
|
*/
|
|
97
97
|
export function stripUntrusted(file: Record<string, unknown>, defaults: object, cwd: string, name: string): string | undefined {
|
|
98
|
+
const localOnly = name !== "config.local.json" && file.action_policy !== undefined;
|
|
99
|
+
if (localOnly) delete file.action_policy;
|
|
100
|
+
const originNotice = localOnly ? "action_policy ignored: only .agenthub/config.local.json may set it" : undefined;
|
|
98
101
|
for (const p of ALL_MACHINE_LOCAL) if (valueAt(file, p) !== undefined && !chooses(valueAt(file, p))) drop(file, p);
|
|
99
102
|
const set = ALL_MACHINE_LOCAL.filter((p) => {
|
|
100
103
|
const v = valueAt(file, p);
|
|
101
104
|
return v !== undefined && JSON.stringify(v) !== JSON.stringify(valueAt(defaults, p));
|
|
102
105
|
});
|
|
103
|
-
if (!set.length) return
|
|
106
|
+
if (!set.length) return originNotice;
|
|
104
107
|
const why = configRefusal(cwd, name);
|
|
105
|
-
if (!why) return
|
|
108
|
+
if (!why) return originNotice;
|
|
106
109
|
for (const p of set) drop(file, p);
|
|
107
|
-
return `${set.join(", ")} in .agenthub/${name} ignored: ${why}
|
|
110
|
+
return [originNotice, `${set.join(", ")} in .agenthub/${name} ignored: ${why}`].filter(Boolean).join("; ");
|
|
108
111
|
}
|