@staix/agent-hub 0.16.0 → 0.18.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 +34 -0
- package/README.md +2 -2
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +20 -5
- package/docs/agent-notes/benchmarks.md +1 -0
- package/docs/agent-notes/daemon.md +5 -3
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +9 -3
- package/docs/agent-notes/tests.md +6 -3
- package/docs/dogfood.md +21 -4
- package/docs/memory.md +1 -1
- package/docs/operations.md +150 -34
- package/docs/profiles.md +36 -0
- package/docs/quickstart.md +14 -4
- package/docs/roles.md +1 -0
- package/docs/sdlc.md +39 -13
- package/docs/security.md +41 -9
- package/docs/smoke.md +51 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +56 -10
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
- package/docs/verification/2026-10-25-0.17.0.md +74 -0
- package/docs/verification/2026-10-25-0.18.0.md +76 -0
- package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
- package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
- package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
- package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
- package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
- package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
- package/docs/verification/2026-10-25-remote-docker-transport.md +46 -0
- package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
- package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
- package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
- package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
- package/docs/verified.json +28 -19
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1115 -192
- package/src/adapters/codex-review-isolation.ts +10 -1
- package/src/adapters/compat.ts +7 -2
- package/src/adapters/conformance/enrollment.ts +18 -4
- package/src/adapters/conformance/factories.ts +4 -1
- package/src/adapters/conformance/kit.ts +11 -0
- package/src/adapters/definitions/builtin-metadata.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
- package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/definitions/native-recovery.ts +8 -0
- package/src/adapters/definitions/qwen.ts +7 -4
- package/src/adapters/local-worker.ts +5 -3
- package/src/adapters/qwen-dual.ts +578 -0
- package/src/adapters/registry-types.ts +5 -1
- package/src/adapters/registry.ts +9 -1
- package/src/adapters/reviewer-mcp.ts +12 -6
- package/src/adapters/reviewer-print.ts +100 -17
- package/src/cli/console-state.ts +9 -2
- package/src/cli/help.ts +29 -28
- package/src/cli/identity.ts +87 -6
- package/src/cli/main.ts +190 -69
- package/src/cli/output.ts +7 -2
- package/src/cli/sdlc-output.ts +242 -0
- package/src/cli/task-output.ts +212 -0
- package/src/cli/upgrade-output.ts +57 -0
- package/src/cli/upgrade.ts +4 -3
- package/src/hub/board.ts +17 -0
- package/src/hub/check-runner.ts +252 -22
- package/src/hub/conductor.ts +5 -0
- package/src/hub/config-apply.ts +16 -7
- package/src/hub/config-edit-host.ts +1 -1
- package/src/hub/config-tier.ts +1 -1
- package/src/hub/daemon.ts +487 -94
- package/src/hub/dogfood-profile.ts +248 -0
- package/src/hub/dogfood-read.ts +77 -14
- package/src/hub/dogfood.ts +5 -1
- package/src/hub/events.ts +1 -1
- package/src/hub/host-executable.ts +2 -1
- package/src/hub/hub-tools.ts +2 -2
- package/src/hub/memory-plan-guard.ts +8 -33
- package/src/hub/operational-authority.ts +60 -0
- package/src/hub/operational-view.ts +141 -0
- package/src/hub/orphan-process.ts +69 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/read-only-shell-proof.ts +80 -0
- package/src/hub/read-only-shell.ts +162 -0
- package/src/hub/reviewer-pool-config.ts +3 -2
- package/src/hub/reviewer-pool.ts +3 -0
- package/src/hub/routing.ts +23 -6
- package/src/hub/sdlc-merge.ts +299 -0
- package/src/hub/sdlc-mirror.ts +129 -0
- package/src/hub/sdlc.ts +58 -12
- package/src/hub/settings.ts +11 -5
- package/src/hub/snapshots.ts +60 -0
- package/src/hub/task-repository.ts +31 -5
- package/src/hub/tasks.ts +266 -28
- package/src/local/tools.ts +17 -4
- package/src/memory/brief.ts +45 -5
- package/src/ui/index.html +2 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { sanitize } from "./envelope.ts";
|
|
2
|
+
import type { BusEvent } from "./bus.ts";
|
|
3
|
+
import { CLASSES, type Task } from "./board.ts";
|
|
4
|
+
|
|
5
|
+
export interface OperationalViewPorts {
|
|
6
|
+
tasks: readonly Task[];
|
|
7
|
+
isPii(task:Task):boolean;
|
|
8
|
+
publicView(task:Task):Record<string,unknown>;
|
|
9
|
+
publicMetadataText(text:string):boolean;
|
|
10
|
+
redact(record:unknown):unknown;
|
|
11
|
+
}
|
|
12
|
+
/** Closed metadata receipts only. Unsupported nested records are withheld until given their own projection. */
|
|
13
|
+
const EVENT_FIELDS: Record<string,readonly string[]>={
|
|
14
|
+
state:["peer","state"],hub_start:["instance"],native_thread:["peer","thread","fresh"],turn_start:["peer","turn"],native_turn_end:["peer","id"],
|
|
15
|
+
task:["id","event","by","state","owner","reviewer","class","profile","profileClass","role","pii","reason","release","priority","plannedStart","due"],
|
|
16
|
+
tokens:["peer","n","task","attribution","pii"],
|
|
17
|
+
usage:["peer","source","id","task","attribution","pii","reasoningTokens","measuredAt","inputTokens","outputTokens","cacheReadTokens","cacheWriteTokens","totalTokens"],
|
|
18
|
+
turn_end:["peer","turn","ms","tokens","files","snapshotMs","task","attribution","pii"],
|
|
19
|
+
conduct:["peer","action","task","target"],agent_cli:["peer","command","refused","delegated"],
|
|
20
|
+
pii_screen:["task","item","label","source","category","miss","ms"],
|
|
21
|
+
overflow:["id","from","peer"],undeliverable:["id","from","peer"],stale:["id","from","peer","task"],quiet:["id","from","peers"],
|
|
22
|
+
envelope:["id","from","to","priority","hop","kind","task","bytes","private","dropped"],
|
|
23
|
+
memory_brief:["source","peer","task","native","fails","claudeMem"],
|
|
24
|
+
fact:["peer","id","files","plans","unknown","named","bytes","via","ms","hookMs","rttMs","accepted","unanswered","probe","coverage"],
|
|
25
|
+
fact_ack:["peer","id","via","ms"],permission_mode:["peer","from","to"],
|
|
26
|
+
permission:["id","peer","event","latencyMs","surface","option"],
|
|
27
|
+
supervision_turn:["peer","turn","tokens","ms"],hook_stats:["peer","n","startupMs","hubMs","maxStartupMs"],
|
|
28
|
+
capability:["peer","state","via"],cohort:["id","event","silent","tasks","owners"],
|
|
29
|
+
planning:["by","task","release","fields","revision"],context_pressure:["peer","source","measuredAt","used","window"],
|
|
30
|
+
budget_pause:["peer","reason","window","resetsAt","by"],budget_resume:["peer","reason","window","resetsAt","by"],
|
|
31
|
+
quota_reset:["peer","window","cause","resetsAt","used","previousUsed","previousResetsAt","measuredAt"],
|
|
32
|
+
};
|
|
33
|
+
const EVENT_REQUIRED:Record<string,readonly string[]>={
|
|
34
|
+
state:["peer","state"],hub_start:["instance"],native_thread:["peer","thread","fresh"],turn_start:["peer","turn"],native_turn_end:["peer"],
|
|
35
|
+
task:["id","event","by","state","owner","reviewer","class","pii"],tokens:["peer","n"],usage:["peer","source","id"],turn_end:["peer","turn","ms"],
|
|
36
|
+
conduct:["peer","action"],agent_cli:["peer","command","refused"],pii_screen:["item","label","source"],
|
|
37
|
+
overflow:["id","from","peer"],undeliverable:["id","from","peer"],stale:["id","from","peer"],quiet:["id","from","peers"],envelope:["id","from","priority","hop"],
|
|
38
|
+
memory_brief:["peer","task","native","fails","claudeMem"],fact:["peer","id","files","plans","unknown","named","bytes","via"],fact_ack:["peer","id","via","ms"],
|
|
39
|
+
permission_mode:["peer","from","to"],permission:["id","peer","event"],supervision_turn:["peer","turn"],hook_stats:["peer","n","startupMs","hubMs","maxStartupMs"],
|
|
40
|
+
capability:["peer","state"],cohort:["id","event","silent","tasks","owners"],planning:["by","fields","revision"],context_pressure:["peer","source","measuredAt","used","window"],
|
|
41
|
+
budget_pause:["peer","reason","window","resetsAt","by"],budget_resume:["peer","reason","window","resetsAt","by"],quota_reset:["peer","window","cause","used","previousUsed"],
|
|
42
|
+
};
|
|
43
|
+
const NUMBERS=new Set(["n","ms","tokens","files","snapshotMs","reasoningTokens","inputTokens","outputTokens","cacheReadTokens","cacheWriteTokens","totalTokens","hop","bytes","native","fails","claudeMem","plans","unknown","named","hookMs","rttMs","latencyMs","startupMs","hubMs","maxStartupMs","used","previousUsed","previousResetsAt","resetsAt","plannedStart","due","revision"]);
|
|
44
|
+
const BOOLEANS=new Set(["pii","private","fresh","refused","delegated","accepted","unanswered","probe","coverage","silent"]);
|
|
45
|
+
const TASK_EVENTS=new Set(["proposed","backlogged","backlog pulled","backlog returned","accepted","assigned","reassigned","unassigned","reserved","ready","blocked","declined","escalated","done","approved","changes_requested","cancelled","lifecycle"]);
|
|
46
|
+
const identifier=(value:unknown):value is string=>typeof value==="string" && /^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,255}$/.test(value);
|
|
47
|
+
const peerId=(value:unknown):value is string=>typeof value==="string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
|
|
48
|
+
const oneOf=(value:unknown,values:readonly string[])=>typeof value==="string" && values.includes(value);
|
|
49
|
+
function metadataValue(type:string,key:string,value:unknown):boolean {
|
|
50
|
+
if(value===null && ["plannedStart","due"].includes(key))return true;
|
|
51
|
+
if(NUMBERS.has(key))return typeof value==="number" && Number.isFinite(value) && value>=0;
|
|
52
|
+
if(BOOLEANS.has(key))return typeof value==="boolean";
|
|
53
|
+
if(["peer","owner","reviewer","target","by"].includes(key))return value===null && ["owner","reviewer"].includes(key) || peerId(value);
|
|
54
|
+
if(key==="from" || key==="to")return type==="permission_mode"?oneOf(value,["ask","ask-when-needed","never-ask"]):key==="to"?Array.isArray(value)&&value.length<=64&&value.every(peerId):peerId(value);
|
|
55
|
+
if(["peers","owners"].includes(key))return Array.isArray(value)&&value.length<=64&&value.every(peerId);
|
|
56
|
+
if(key==="tasks")return Array.isArray(value)&&value.length<=500&&value.every(positive);
|
|
57
|
+
if(key==="task" || key==="release")return key==="release"&&value===null || positive(value) || key==="task"&&typeof value==="string"&&/^[1-9][0-9]*$/.test(value);
|
|
58
|
+
if(key==="id")return type==="task" || type==="cohort"?positive(value):identifier(value);
|
|
59
|
+
if(key==="class")return oneOf(value,CLASSES);
|
|
60
|
+
if(key==="profile")return oneOf(value,["dev","research","writing","general"]);
|
|
61
|
+
if(key==="profileClass")return typeof value==="string" && /^[a-z][a-z0-9_]{0,47}$/.test(value);
|
|
62
|
+
if(key==="state")return type==="task"?oneOf(value,["backlog","proposed","in_progress","in_review","approved","changes_requested","cancelled"]):type==="capability"?oneOf(value,["verified","lost"]):oneOf(value,["idle","busy","paused","offline"]);
|
|
63
|
+
if(key==="event")return type==="task"?typeof value==="string"&&TASK_EVENTS.has(value):type==="cohort"?oneOf(value,["formed","joined","lifted"]):oneOf(value,["requested","answered","expired","cancelled"]);
|
|
64
|
+
if(key==="attribution")return oneOf(value,["delivery","single_open","unattributed"]);
|
|
65
|
+
if(key==="label")return oneOf(value,["pii","clear"]);
|
|
66
|
+
if(key==="item")return oneOf(value,["task","summary","review note","handoff","note"]);
|
|
67
|
+
if(key==="category")return oneOf(value,["name","student_id","phone","address","grade","health","other"]);
|
|
68
|
+
if(key==="miss")return oneOf(value,["too long","off campus","timeout","unreadable","failed"]);
|
|
69
|
+
if(key==="source")return type==="pii_screen"?oneOf(value,["regex","screen","unknown","inherited"]):type==="usage"?oneOf(value,["omniroute","claude_transcript","claude_result","codex_appserver","kimi_wire"]):identifier(value);
|
|
70
|
+
if(key==="measuredAt")return type==="context_pressure" || type==="quota_reset"?typeof value==="number"&&Number.isFinite(value):typeof value==="string"&&Number.isFinite(Date.parse(value));
|
|
71
|
+
if(key==="window")return value===null || typeof value==="number"&&Number.isFinite(value)&&value>=0 || identifier(value);
|
|
72
|
+
if(key==="priority")return value===null || typeof value==="number"&&Number.isFinite(value) || oneOf(value,["status","important","fyi"]);
|
|
73
|
+
if(key==="fields")return Array.isArray(value)&&value.length<=32&&value.every(identifier);
|
|
74
|
+
if(["action","command","reason"].includes(key))return typeof value==="string" && /^[a-z][a-z0-9_ .:-]{0,63}$/.test(value);
|
|
75
|
+
return identifier(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Shared by the daemon's existing console stream and operational projection tests. */
|
|
79
|
+
export function redactBusEvent(e:BusEvent):BusEvent {
|
|
80
|
+
if (!("env" in e) || !e.env.private) return e;
|
|
81
|
+
const {refs,...env}=e.env;
|
|
82
|
+
const task=refs?.task && /^[0-9]+$/.test(refs.task)?refs.task:undefined;
|
|
83
|
+
return {...e,env:{...env,...(task?{refs:{task}}:{}),body:`[private${task?`: task #${task}, see ahub task show ${task}`:""}]`}};
|
|
84
|
+
}
|
|
85
|
+
const WITHHELD="[withheld: private or unclassifiable operational record]";
|
|
86
|
+
const positive=(value:unknown):value is number=>Number.isSafeInteger(value) && (value as number)>0;
|
|
87
|
+
|
|
88
|
+
/** Bind the existing task/privacy projections once per read; no private title or path leaves this closure. */
|
|
89
|
+
export function operationalView(ports:OperationalViewPorts) {
|
|
90
|
+
const tasks=new Map(ports.tasks.map(task=>[task.id,task]));
|
|
91
|
+
const privateTitles:string[]=[];
|
|
92
|
+
// ponytail: a complete privacy inventory is capped at 500 tasks; indexed scoped log records can extend it.
|
|
93
|
+
let known=ports.tasks.length<=500;
|
|
94
|
+
try {for(const task of ports.tasks.slice(0,500)) {const view=ports.publicView(task);if(ports.isPii(task) || view.title!==task.title) privateTitles.push(task.title);}} catch {known=false;}
|
|
95
|
+
const safeTask=(id:number):boolean=>{
|
|
96
|
+
const task=tasks.get(id);
|
|
97
|
+
if (!task) return false;
|
|
98
|
+
try {return !ports.isPii(task) && ports.publicView(task).title===task.title;} catch {return false;}
|
|
99
|
+
};
|
|
100
|
+
const safeText=(text:string):boolean=>{
|
|
101
|
+
if (!known || Buffer.byteLength(text)>65536 || privateTitles.some(title=>title && text.includes(title))) return false;
|
|
102
|
+
try {return ports.publicMetadataText(text);} catch {return false;}
|
|
103
|
+
};
|
|
104
|
+
const line=(value:unknown):string=>{
|
|
105
|
+
if (typeof value!=="string" || !safeText(value)) return WITHHELD;
|
|
106
|
+
for(const match of value.matchAll(/\btask\s+#?([1-9][0-9]*)\b/gi)) if(!safeTask(Number(match[1])))return WITHHELD;
|
|
107
|
+
return sanitize(value);
|
|
108
|
+
};
|
|
109
|
+
const event=(value:unknown):Record<string,unknown>=>{
|
|
110
|
+
const withheld={type:"withheld"};
|
|
111
|
+
try {
|
|
112
|
+
if (!value || typeof value!=="object" || Array.isArray(value)) return withheld;
|
|
113
|
+
const raw=value as Record<string,unknown>;
|
|
114
|
+
if(raw.v!==1 || typeof raw.at!=="string" || !Number.isFinite(Date.parse(raw.at)) || typeof raw.type!=="string")return withheld;
|
|
115
|
+
const fields=EVENT_FIELDS[raw.type];
|
|
116
|
+
if(!fields || !Object.hasOwn(EVENT_FIELDS,raw.type))return withheld;
|
|
117
|
+
const allowed=new Set(["v","at","type",...fields]);
|
|
118
|
+
if(Object.keys(raw).some(key=>!allowed.has(key)) || EVENT_REQUIRED[raw.type]!.some(key=>!Object.hasOwn(raw,key) || raw[key]===undefined))return withheld;
|
|
119
|
+
if(fields.some(key=>raw[key]!==undefined && !metadataValue(raw.type as string,key,raw[key])))return withheld;
|
|
120
|
+
if (raw.pii===true || raw.private===true) return withheld;
|
|
121
|
+
const ids:unknown[]=[];
|
|
122
|
+
if (raw.type==="task") ids.push(raw.id);
|
|
123
|
+
if (raw.task!==undefined) ids.push(typeof raw.task==="string" && /^[1-9][0-9]*$/.test(raw.task)?Number(raw.task):raw.task);
|
|
124
|
+
if (raw.type==="conflict") ids.push(raw.other);
|
|
125
|
+
if (raw.type==="overlap") {
|
|
126
|
+
if(!Array.isArray(raw.others))return withheld;
|
|
127
|
+
for(const other of raw.others) {if(!other || typeof other!=="object")return withheld;ids.push(other.task);}
|
|
128
|
+
}
|
|
129
|
+
if(raw.type==="cohort") {if(!Array.isArray(raw.tasks))return withheld;ids.push(...raw.tasks);}
|
|
130
|
+
if(ids.some(id=>!positive(id) || !safeTask(id)))return withheld;
|
|
131
|
+
const redacted=ports.redact(value);
|
|
132
|
+
if(!redacted || typeof redacted!=="object" || Array.isArray(redacted))return withheld;
|
|
133
|
+
const projected=Object.fromEntries(["v","at","type",...fields].filter(key=>Object.hasOwn(redacted,key)).map(key=>[key,(redacted as Record<string,unknown>)[key]]));
|
|
134
|
+
const text=JSON.stringify(projected);
|
|
135
|
+
if(!safeText(text))return withheld;
|
|
136
|
+
// Rebuild through JSON, then sanitize every rendered string, including metadata strings.
|
|
137
|
+
return JSON.parse(text,(_key,item)=>typeof item==="string"?sanitize(item):item) as Record<string,unknown>;
|
|
138
|
+
} catch {return withheld;}
|
|
139
|
+
};
|
|
140
|
+
return {line,event,complete:known};
|
|
141
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { processLiveness, type Liveness } from "../pi/process-signature.ts";
|
|
4
|
+
import type { Project } from "./registry.ts";
|
|
5
|
+
import type { ControlDescriptor } from "./control-client.ts";
|
|
6
|
+
|
|
7
|
+
export interface OrphanSelection { root:string; projectId:string; instanceId:string; pid:number; executable:string; programs:readonly string[] }
|
|
8
|
+
export interface SignedOrphan { pid:number; signature:string; root:string; projectId:string; instanceId:string; self:boolean }
|
|
9
|
+
export interface OrphanPorts {
|
|
10
|
+
rootState(root:string):"missing"|"present"|"unknown";
|
|
11
|
+
liveness(pid:number,signature:string):Liveness;
|
|
12
|
+
command(pid:number):string|undefined;
|
|
13
|
+
signal(pid:number,signal:"SIGTERM"|"SIGKILL"):void;
|
|
14
|
+
current():void;
|
|
15
|
+
now():number;
|
|
16
|
+
sleep(ms:number):Promise<unknown>;
|
|
17
|
+
}
|
|
18
|
+
export const orphanProcessPorts: OrphanPorts = {
|
|
19
|
+
rootState(root) { try {lstatSync(root);return "present";} catch(error) {return ["ENOENT","ENOTDIR"].includes((error as NodeJS.ErrnoException).code??"")?"missing":"unknown";} },
|
|
20
|
+
liveness:processLiveness,
|
|
21
|
+
command(pid) {const r=spawnSync("/bin/ps",["-p",String(pid),"-o","command="],{encoding:"utf8",timeout:2000,maxBuffer:65536});return r.status===0?r.stdout.trim()||undefined:undefined;},
|
|
22
|
+
signal:(pid,signal)=>process.kill(pid,signal),current:()=>{throw new Error("conductor authority unavailable");},now:Date.now,sleep:ms=>Bun.sleep(ms),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Host-known Bun executable and hub entrypoint only; joined argv cannot prove paths containing whitespace. */
|
|
26
|
+
export function exactOrphanCommand(command:string|undefined,selection:Pick<OrphanSelection,"root"|"executable"|"programs">):boolean {
|
|
27
|
+
if (!command || [selection.root,selection.executable,...selection.programs].some(word=>/\s/.test(word))) return false;
|
|
28
|
+
const words=command.trim().split(/\s+/);
|
|
29
|
+
return words[0]===selection.executable && selection.programs.includes(words[1]??"") && words[2]==="--project"
|
|
30
|
+
&& words[3]===selection.root && words[4]==="daemon" && (words.length===5 || words.length===6 && words[5]==="--unattended");
|
|
31
|
+
}
|
|
32
|
+
function owns(candidate:SignedOrphan,selection:OrphanSelection,ports:OrphanPorts):boolean {
|
|
33
|
+
ports.current();
|
|
34
|
+
return candidate.root===selection.root && candidate.projectId===selection.projectId && candidate.instanceId===selection.instanceId && ports.rootState(selection.root)==="missing"
|
|
35
|
+
&& ports.liveness(candidate.pid,candidate.signature)==="live" && exactOrphanCommand(ports.command(candidate.pid),selection)
|
|
36
|
+
// Recheck the signature after argv inspection, immediately before the caller may signal.
|
|
37
|
+
&& ports.liveness(candidate.pid,candidate.signature)==="live";
|
|
38
|
+
}
|
|
39
|
+
/** Host-selected project only. Unsigned legacy pid files are never conductor signal authority. */
|
|
40
|
+
export function inspectSelectedOrphans(selection:OrphanSelection,registration:Project|undefined,control:ControlDescriptor|undefined,ports:OrphanPorts=orphanProcessPorts) {
|
|
41
|
+
ports.current();
|
|
42
|
+
const root=ports.rootState(selection.root);
|
|
43
|
+
if (root!=="missing") return {state:root==="present"?"not_orphan" as const:"unknown" as const,candidates:[] as SignedOrphan[]};
|
|
44
|
+
const proofs:{pid:number|null|undefined;signature:string|null|undefined}[]=[];
|
|
45
|
+
if (registration?.root===selection.root && registration.id===selection.projectId && registration.instanceId===selection.instanceId) proofs.push({pid:registration.pid,signature:registration.pidSignature});
|
|
46
|
+
if (control?.cwd===selection.root && control.projectId===selection.projectId && control.instanceId===selection.instanceId && control.pid===selection.pid) proofs.push({pid:control.pid,signature:control.pidSignature});
|
|
47
|
+
let unknown=proofs.length===0;
|
|
48
|
+
const candidates:SignedOrphan[]=[];
|
|
49
|
+
for (const proof of proofs) {
|
|
50
|
+
if (!Number.isSafeInteger(proof.pid) || proof.pid!<=0 || typeof proof.signature!=="string" || !proof.signature) {unknown=true;continue;}
|
|
51
|
+
const prior=candidates.find(c=>c.pid===proof.pid);
|
|
52
|
+
if (prior) {if(prior.signature!==proof.signature){unknown=true;candidates.splice(candidates.indexOf(prior),1);}continue;}
|
|
53
|
+
const candidate={pid:proof.pid!,signature:proof.signature,root:selection.root,projectId:selection.projectId,instanceId:selection.instanceId,self:proof.pid===selection.pid};
|
|
54
|
+
if (owns(candidate,selection,ports)) candidates.push(candidate); else if(ports.liveness(candidate.pid,candidate.signature)!=="gone") unknown=true;
|
|
55
|
+
}
|
|
56
|
+
return {state:unknown?"unknown" as const:"ready" as const,candidates};
|
|
57
|
+
}
|
|
58
|
+
/** A self candidate is returned for the daemon's existing reply-then-lifecycle-stop path, never self-signalled. */
|
|
59
|
+
export async function stopSelectedOrphan(candidate:SignedOrphan,selection:OrphanSelection,ports:OrphanPorts=orphanProcessPorts):Promise<"stopped"|"self"|"unknown"> {
|
|
60
|
+
if (!owns(candidate,selection,ports)) return "unknown";
|
|
61
|
+
if (candidate.pid===selection.pid) return "self";
|
|
62
|
+
for (const [signal,wait] of [["SIGTERM",3000],["SIGKILL",2000]] as const) {
|
|
63
|
+
if (!owns(candidate,selection,ports)) return ports.liveness(candidate.pid,candidate.signature)==="gone"?"stopped":"unknown";
|
|
64
|
+
try {ports.signal(candidate.pid,signal);} catch {return ports.liveness(candidate.pid,candidate.signature)==="gone"?"stopped":"unknown";}
|
|
65
|
+
const until=ports.now()+wait;
|
|
66
|
+
while (ports.now()<until) {ports.current();const alive=ports.liveness(candidate.pid,candidate.signature);if(alive==="gone")return "stopped";if(alive!=="live")return "unknown";await ports.sleep(100);}
|
|
67
|
+
}
|
|
68
|
+
return ports.liveness(candidate.pid,candidate.signature)==="gone"?"stopped":"unknown";
|
|
69
|
+
}
|
|
@@ -36,7 +36,7 @@ export function permissionGrant(peer: string, mode: PermissionMode, instances?:
|
|
|
36
36
|
const grant = (catalog?.definitionOf ?? definitionOf)(peer, instances)?.permission.grants?.[mode];
|
|
37
37
|
if (grant !== undefined) return grant;
|
|
38
38
|
if (mode === "ask") return "every write, edit and shell command asks";
|
|
39
|
-
return mode === "never-ask" ? "every tool runs without asking, inside the hub sandbox and path guard" : "write and
|
|
39
|
+
return mode === "never-ask" ? "every tool runs without asking, inside the hub sandbox and path guard" : "write, edit and proven bounded read-only shell commands inside the project run without asking; other shell commands and git writes still ask";
|
|
40
40
|
}
|
|
41
41
|
export const PI_EDIT_TOOLS = new Set(["edit", "write"]);
|
|
42
42
|
/** Native agent policy files are never included in scoped automatic file grants. */
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { accessSync, constants, lstatSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { guardPath, type ShellExecutionCheck } from "../local/tools.ts";
|
|
7
|
+
import { hostExecutable } from "./host-executable.ts";
|
|
8
|
+
import { hubGitArgv, hubGitEnv } from "./git.ts";
|
|
9
|
+
import { realPath } from "./project.ts";
|
|
10
|
+
import { readOnlyShellForm, type ReadOnlyProgram } from "./read-only-shell.ts";
|
|
11
|
+
|
|
12
|
+
export interface ShellProofOptions { root:string; projectRoot?:string; deny:string[]; readOnly?:string[]; env?:Record<string,string> }
|
|
13
|
+
export interface ReadOnlyShellProof { fingerprint:string; program:ReadOnlyProgram }
|
|
14
|
+
const within=(root:string,path:string)=>{const p=relative(root,path);return p==="" || p!==".."&&!p.startsWith("../")&&!isAbsolute(p);};
|
|
15
|
+
function firstProgram(name:string,root:string,path:string):string|undefined {
|
|
16
|
+
if(path.length>16384)return undefined;
|
|
17
|
+
const dirs=path.split(":");if(dirs.length>128)return undefined;
|
|
18
|
+
for(const dir of dirs) {
|
|
19
|
+
// Bash consumes the original PATH: do not normalize an ambiguous entry into a different program.
|
|
20
|
+
if(!isAbsolute(dir) || dir.split("/").includes(".."))return undefined;
|
|
21
|
+
const candidate=resolve(root,dir,name);
|
|
22
|
+
try {accessSync(candidate,constants.X_OK);if(lstatSync(candidate).isDirectory())continue;return candidate;}catch{/* Try only the next actual PATH entry. */}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
function stamp(path:string,program:boolean):unknown {
|
|
27
|
+
try {const s=lstatSync(path);return program?[path,s.dev,s.ino,s.mode,s.size,s.mtimeMs,s.ctimeMs]:[path,s.dev,s.ino,s.mode];}catch(error){if((error as NodeJS.ErrnoException).code==="ENOENT")return [path,"missing"];throw error;}
|
|
28
|
+
}
|
|
29
|
+
function ancestors(path:string,stop:string):string[] {
|
|
30
|
+
const out=[path];for(let n=0;path!==stop && n<64;n++){const parent=dirname(path);if(parent===path)break;path=parent;out.push(path);}return out;
|
|
31
|
+
}
|
|
32
|
+
// Git's subsection names retain case. Expand the literal letters so the entire anchored key pattern is case-insensitive.
|
|
33
|
+
const GIT_HELPER_KEYS = /^(core\.fsmonitor|log\.showsignature|gpg\.(program|.+\.program)|diff\.(external|.+\.(command|textconv))|format\.pretty|pretty\..+)$/.source
|
|
34
|
+
.replace(/[a-z]/g, letter => `[${letter}${letter.toUpperCase()}]`);
|
|
35
|
+
/** No config values escape this predicate. Accept only the two exact helper overrides injected by the core. */
|
|
36
|
+
export function gitHelpersUnconfigured(git:string,cwd:string,env:NodeJS.ProcessEnv,current:()=>boolean):boolean {
|
|
37
|
+
if(!current() || Object.keys(env).some(key=>key==="GIT_CONFIG_COUNT"||key==="GIT_CONFIG_PARAMETERS"||key.startsWith("GIT_CONFIG_KEY_")||key.startsWith("GIT_CONFIG_VALUE_")))return false;
|
|
38
|
+
const argv=hubGitArgv(["-C",cwd,"config","--show-scope","--get-regexp",GIT_HELPER_KEYS]);
|
|
39
|
+
const result=spawnSync(git,argv.slice(1),{cwd,env:hubGitEnv(env),encoding:"utf8",timeout:1000,maxBuffer:16384,stdio:["ignore","pipe","pipe"]});
|
|
40
|
+
if(!current() || result.error || result.status!==0 || !result.stdout.endsWith("\n"))return false;
|
|
41
|
+
return result.stdout === "command\tcore.fsmonitor false\ncommand\tdiff.external \n";
|
|
42
|
+
}
|
|
43
|
+
/** A proof is private host metadata, never an argv/env rewrite or an authorization by itself. */
|
|
44
|
+
export function proveReadOnlyShell(input:unknown,options:ShellProofOptions,current:()=>boolean):ReadOnlyShellProof|undefined {
|
|
45
|
+
try {
|
|
46
|
+
if(!current() || !input || typeof input!=="object" || Array.isArray(input))return undefined;
|
|
47
|
+
const args=input as Record<string,unknown>;if(typeof args.command!=="string")return undefined;
|
|
48
|
+
if(args.timeout_s!==undefined && (!Number.isFinite(args.timeout_s)||typeof args.timeout_s!=="number"||args.timeout_s<1||args.timeout_s>600))return undefined;
|
|
49
|
+
const root=realPath(options.root),projectRoot=realPath(options.projectRoot??options.root),names:string[]=[root,projectRoot];
|
|
50
|
+
const form=readOnlyShellForm(args.command,root,(path,base=root)=>{if(path.split("/").includes(".."))throw new Error("ambiguous parent traversal");const lexical=resolve(base,path),canonical=guardPath({cwd:root,deny:options.deny,readOnly:options.readOnly},lexical,"read");names.push(lexical,canonical);return canonical;});
|
|
51
|
+
if(!form || !current())return undefined;
|
|
52
|
+
const env={PATH:process.env.PATH??"/usr/bin:/bin",HOME:homedir(),LANG:process.env.LANG??"en_US.UTF-8",TERM:"dumb",...options.env};
|
|
53
|
+
const lexical=firstProgram(form.program,root,env.PATH);if(!lexical || within(root,lexical) || within(projectRoot,lexical))return undefined;
|
|
54
|
+
const executable=hostExecutable(form.program,root,env.PATH);if(realPath(lexical)!==executable || within(projectRoot,executable) || !current())return undefined;
|
|
55
|
+
if(form.gitCwd && !gitHelpersUnconfigured(executable,form.gitCwd,env,current))return undefined;
|
|
56
|
+
// ponytail: this screens the declared helper-bearing config keys and explicit diff/textconv flags; other config-mediated helpers need dedicated audited forms before extending the allowlist.
|
|
57
|
+
const paths=new Set([...names.flatMap(name=>ancestors(name,root)),...ancestors(lexical,"/"),...ancestors(executable,"/")]);
|
|
58
|
+
const fingerprint=createHash("sha256").update(JSON.stringify([root,projectRoot,args.command,env.PATH,lexical,executable,[...paths].map(path=>stamp(path,path===lexical||path===executable))])).digest("hex");
|
|
59
|
+
return current()?{fingerprint,program:form.program}:undefined;
|
|
60
|
+
} catch {return undefined;}
|
|
61
|
+
}
|
|
62
|
+
interface Decision { command:string; current:()=>boolean; proof?:ReadOnlyShellProof }
|
|
63
|
+
export class ShellExecutionProofs {
|
|
64
|
+
private decisions=new WeakMap<object,Decision>();
|
|
65
|
+
constructor(private readonly options:ShellProofOptions){}
|
|
66
|
+
private key(input:unknown):input is Record<string,unknown>{return !!input&&typeof input==="object"&&!Array.isArray(input)&&typeof (input as Record<string,unknown>).command==="string";}
|
|
67
|
+
ordinary(input:unknown,current:()=>boolean):void {if(this.key(input))this.decisions.set(input,{command:input.command as string,current});}
|
|
68
|
+
readonly(input:unknown,proof:ReadOnlyShellProof,current:()=>boolean):void {if(this.key(input))this.decisions.set(input,{command:input.command as string,current,proof});}
|
|
69
|
+
check(input:unknown):ShellExecutionCheck {
|
|
70
|
+
if(!this.key(input))return "ask";const decision=this.decisions.get(input);
|
|
71
|
+
if(!decision)return "ask";
|
|
72
|
+
if(!decision.current())return "aborted";
|
|
73
|
+
if(input.command!==decision.command)return "ask";
|
|
74
|
+
if(!decision.proof)return "valid";
|
|
75
|
+
const proof=proveReadOnlyShell(input,this.options,decision.current);
|
|
76
|
+
if(!decision.current())return "aborted";
|
|
77
|
+
if(!proof || proof.fingerprint!==decision.proof.fingerprint){this.decisions.delete(input);return "ask";}
|
|
78
|
+
return "valid";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/** Lexical data only. A successful parse grants no permission and proves no program read-only. */
|
|
2
|
+
export interface SimpleShellWords {
|
|
3
|
+
words: readonly string[];
|
|
4
|
+
/** Syntax-looking characters survived as quoted/escaped data; the grant policy must decide whether to accept them. */
|
|
5
|
+
quotedMeta: boolean;
|
|
6
|
+
}
|
|
7
|
+
export const READ_ONLY_SHELL_LIMITS = { characters: 4000, words: 128 } as const;
|
|
8
|
+
const ACTIVE = ";|&<>`$(){}*?[]~#";
|
|
9
|
+
|
|
10
|
+
/** One bounded literal command, without active operators or expansion. Never executes or resolves any word. */
|
|
11
|
+
export function parseSimpleShell(command: unknown): SimpleShellWords | undefined {
|
|
12
|
+
if (typeof command !== "string" || command.length > READ_ONLY_SHELL_LIMITS.characters
|
|
13
|
+
|| command.includes("\n") || command.includes("\r") || command.includes("\0")) return undefined;
|
|
14
|
+
const words: string[] = [];
|
|
15
|
+
let word = "", started = false, quote: "" | "'" | '"' = "", quotedMeta = false;
|
|
16
|
+
const append = (character: string, literal = false) => {
|
|
17
|
+
word += character;
|
|
18
|
+
started = true;
|
|
19
|
+
if (literal && (ACTIVE.includes(character) || character === "\\")) quotedMeta = true;
|
|
20
|
+
};
|
|
21
|
+
const finish = () => {
|
|
22
|
+
if (!started) return true;
|
|
23
|
+
words.push(word);
|
|
24
|
+
word = ""; started = false;
|
|
25
|
+
return words.length <= READ_ONLY_SHELL_LIMITS.words;
|
|
26
|
+
};
|
|
27
|
+
for (let i = 0; i < command.length; i++) {
|
|
28
|
+
const character = command[i]!;
|
|
29
|
+
if (quote === "'") {
|
|
30
|
+
if (character === "'") quote = "";
|
|
31
|
+
else append(character, true);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (quote === '"') {
|
|
35
|
+
if (character === '"') { quote = ""; continue; }
|
|
36
|
+
if (character === "$" || character === "`") return undefined;
|
|
37
|
+
if (character === "\\") return undefined;
|
|
38
|
+
append(character, true);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (character === " " || character === "\t") { if (!finish()) return undefined; continue; }
|
|
42
|
+
if (character === "'" || character === '"') { quote = character; started = true; continue; }
|
|
43
|
+
if (character === "\\") {
|
|
44
|
+
const next = command[++i];
|
|
45
|
+
if (next === undefined) return undefined;
|
|
46
|
+
append(next, true);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (ACTIVE.includes(character) && !(character === "~" && started)) return undefined;
|
|
50
|
+
append(character);
|
|
51
|
+
}
|
|
52
|
+
if (quote || !finish() || !words.length) return undefined;
|
|
53
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*[+]?=/.test(words[0]!)) return undefined;
|
|
54
|
+
return { words, quotedMeta };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const READ_ONLY_PROGRAMS = ["ls","cat","head","tail","wc","rg","grep","stat","file","tree","pwd","find","git"] as const;
|
|
58
|
+
export type ReadOnlyProgram = typeof READ_ONLY_PROGRAMS[number];
|
|
59
|
+
export interface ReadOnlyShellForm { program:ReadOnlyProgram; paths:readonly string[]; gitCwd?:string }
|
|
60
|
+
type FlagKind = "none"|"number"|"pattern"|"path";
|
|
61
|
+
const flags=(none:string[],values:Record<string,FlagKind>={}):Record<string,FlagKind>=>Object.assign(Object.fromEntries(none.map(flag=>[flag,"none"])),values);
|
|
62
|
+
const numberArg=(value:string)=>/^[0-9]{1,7}$/.test(value) && Number(value)<=1_000_000;
|
|
63
|
+
function options(args:string[],rules:Record<string,FlagKind>,path:(value:string)=>void) {
|
|
64
|
+
const positional:string[]=[],seen=new Set<string>();let ended=false;
|
|
65
|
+
for(let i=0;i<args.length;i++) {
|
|
66
|
+
const word=args[i]!;
|
|
67
|
+
if(!ended && word==="--"){ended=true;continue;}
|
|
68
|
+
if(ended || !word.startsWith("-") || word==="-"){positional.push(word);continue;}
|
|
69
|
+
let key=word,value:string|undefined;
|
|
70
|
+
if(word.startsWith("--") && word.includes("=")){const at=word.indexOf("=");key=word.slice(0,at);value=word.slice(at+1);}
|
|
71
|
+
if(!Object.hasOwn(rules,key) && /^-[^-].+/.test(word)) {
|
|
72
|
+
const letters=word.slice(1);let consumed=false;
|
|
73
|
+
for(let n=0;n<letters.length;n++) {
|
|
74
|
+
const short=`-${letters[n]}`;
|
|
75
|
+
if(!Object.hasOwn(rules,short))return undefined;
|
|
76
|
+
seen.add(short);
|
|
77
|
+
if(rules[short]!=="none") {key=short;value=letters.slice(n+1)||args[++i];consumed=true;break;}
|
|
78
|
+
}
|
|
79
|
+
if(!consumed)continue;
|
|
80
|
+
}
|
|
81
|
+
if(!Object.hasOwn(rules,key))return undefined;
|
|
82
|
+
seen.add(key);const kind=rules[key]!;
|
|
83
|
+
if(kind==="none"){if(value!==undefined)return undefined;continue;}
|
|
84
|
+
value??=args[++i];if(value===undefined || value.startsWith("-") || kind==="number"&&!numberArg(value))return undefined;
|
|
85
|
+
if(kind==="path")path(value);
|
|
86
|
+
}
|
|
87
|
+
return {positional,seen};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Structural allowlist only; the host must still prove executable identity, paths, config and current authority. */
|
|
91
|
+
export function readOnlyShellForm(command:unknown,root:string,readPath:(path:string,base?:string)=>string):ReadOnlyShellForm|undefined {
|
|
92
|
+
try {
|
|
93
|
+
const parsed=parseSimpleShell(command);if(!parsed)return undefined;
|
|
94
|
+
const [name,...args]=parsed.words;
|
|
95
|
+
if(!READ_ONLY_PROGRAMS.includes(name as ReadOnlyProgram))return undefined;
|
|
96
|
+
const program=name as ReadOnlyProgram,paths:string[]=[];
|
|
97
|
+
const path=(value:string,base=root)=>{if(value!=="-")paths.push(readPath(value,base));};
|
|
98
|
+
path(".");
|
|
99
|
+
if(program==="git") {
|
|
100
|
+
let cwd=root,i=0;
|
|
101
|
+
while(i<args.length){if(args[i]==="--no-pager"||args[i]==="--no-optional-locks"){i++;continue;}if(args[i]==="-C"&&args[i+1]!==undefined){cwd=readPath(args[i+1]!,cwd);paths.push(cwd);i+=2;continue;}break;}
|
|
102
|
+
const sub=args[i++],rest=args.slice(i);
|
|
103
|
+
const ref=(value:string)=>{
|
|
104
|
+
if(!value || value.startsWith("-") || value.startsWith("/") || value.startsWith("~") || value.includes("..")&&value.startsWith("."))throw new Error("unknown revision");
|
|
105
|
+
if(value.startsWith(":") || /[*?\[\]]/.test(value))throw new Error("unresolved pathspec");
|
|
106
|
+
const colon=value.indexOf(":");if(colon>=0)path(value.slice(colon+1),cwd);else path(value,cwd);
|
|
107
|
+
};
|
|
108
|
+
if(sub==="branch") {if(rest[0]!=="--list" || rest.slice(1).some(a=>a.startsWith("-")))return undefined;return {program,paths,gitCwd:cwd};}
|
|
109
|
+
if(sub==="cat-file") {if(rest.length!==2||!["-p","-t","-s"].includes(rest[0]!))return undefined;ref(rest[1]!);return {program,paths,gitCwd:cwd};}
|
|
110
|
+
// These commands do not accept both required diff-disabling flags, so their shell forms keep asking.
|
|
111
|
+
if(sub==="blame"||sub==="grep")return undefined;
|
|
112
|
+
let rule:Record<string,FlagKind>;
|
|
113
|
+
if(sub==="status")rule=flags(["-s","-b","--short","--branch","--porcelain","--ignored"],{"--untracked-files":"pattern"});
|
|
114
|
+
else if(sub==="rev-parse")rule=flags(["--show-toplevel","--git-dir","--is-inside-work-tree","--show-prefix","--verify","--short"]);
|
|
115
|
+
else if(sub==="ls-files")rule=flags(["-z","--cached","--deleted","--modified","--others","--exclude-standard","--stage","--error-unmatch"]);
|
|
116
|
+
else if(sub==="describe")rule=flags(["--always","--tags","--long","--all","--dirty"],{"--abbrev":"number","--match":"pattern","--exclude":"pattern"});
|
|
117
|
+
else if(sub==="log"||sub==="diff"||sub==="show") {
|
|
118
|
+
rule=flags(["--no-ext-diff","--no-textconv","--no-patch","--oneline","--all","--decorate","--no-decorate","--name-only","--name-status","--stat","-p","--patch","--cached","--staged"],{"-n":"number","--max-count":"number"});
|
|
119
|
+
} else return undefined;
|
|
120
|
+
const parsedArgs=options(rest,rule,value=>path(value,cwd));if(!parsedArgs)return undefined;
|
|
121
|
+
const patch=sub==="diff" || sub==="show" || sub==="log" && ["-p","--patch","--stat"].some(flag=>parsedArgs.seen.has(flag));
|
|
122
|
+
if(patch && !(parsedArgs.seen.has("--no-ext-diff")&&parsedArgs.seen.has("--no-textconv")))return undefined;
|
|
123
|
+
for(const value of parsedArgs.positional)ref(value);
|
|
124
|
+
return {program,paths,gitCwd:cwd};
|
|
125
|
+
}
|
|
126
|
+
if(program==="find") {
|
|
127
|
+
let i=0,depth=0;while(i<args.length && !args[i]!.startsWith("-") && !["(",")","!"].includes(args[i]!))path(args[i++]!);
|
|
128
|
+
for(;i<args.length;i++) {
|
|
129
|
+
const value=args[i]!;
|
|
130
|
+
if(value==="("){if(++depth>16)return undefined;continue;}if(value===")"){if(--depth<0)return undefined;continue;}
|
|
131
|
+
if(["!","-a","-o","-not","-print","-print0","-ls","-depth"].includes(value))continue;
|
|
132
|
+
if(["-name","-iname","-path","-ipath","-regex","-iregex"].includes(value)){if(args[++i]===undefined)return undefined;continue;}
|
|
133
|
+
if(value==="-type"){if(!["f","d","l","b","c","p","s"].includes(args[++i]??""))return undefined;continue;}
|
|
134
|
+
if(["-maxdepth","-mindepth"].includes(value)){if(!numberArg(args[++i]??""))return undefined;continue;}
|
|
135
|
+
if(["-newer","-samefile"].includes(value)){if(args[i+1]===undefined)return undefined;path(args[++i]!);continue;}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
return depth===0?{program,paths}:undefined;
|
|
139
|
+
}
|
|
140
|
+
let rule:Record<string,FlagKind>;
|
|
141
|
+
if(program==="ls")rule=flags(["-a","-A","-l","-h","-1","-d","-F","-i","-p","-r","-R","-s","-S","-t","-u"]);
|
|
142
|
+
else if(program==="cat")rule=flags(["-b","-e","-n","-s","-t","-u","-v"]);
|
|
143
|
+
else if(program==="head"||program==="tail")rule=flags(["-q","-v"],{"-n":"number","-c":"number","--lines":"number","--bytes":"number"});
|
|
144
|
+
else if(program==="wc")rule=flags(["-c","-l","-m","-w","-L"]);
|
|
145
|
+
else if(program==="stat")rule=flags(["-L"]);
|
|
146
|
+
else if(program==="file")rule=flags(["-b","-i","-L","-h"]);
|
|
147
|
+
else if(program==="pwd")rule=flags(["-L","-P"]);
|
|
148
|
+
else if(program==="tree")rule=flags(["-a","-d","-f","-i","-p","-s","-h","-D","-F","-q","-N","-n","--noreport","--dirsfirst"],{"-L":"number","-I":"pattern","-P":"pattern"});
|
|
149
|
+
else if(program==="rg")rule=flags(["-n","-l","-i","-w","-s","-S","-F","-v","-H","-h","-o","-0","-q","-c","-u","-U","-a","--files","--hidden","--no-ignore","--no-heading","--line-number","--files-with-matches","--count","--ignore-case","--fixed-strings"],{"-e":"pattern","--regexp":"pattern","-f":"path","--file":"path","-g":"pattern","--glob":"pattern","--iglob":"pattern","-t":"pattern","--type":"pattern","-T":"pattern","--type-not":"pattern","-m":"number","--max-count":"number","--max-depth":"number","-A":"number","-B":"number","-C":"number"});
|
|
150
|
+
else if(program==="grep")rule=flags(["-n","-l","-i","-w","-x","-F","-E","-v","-H","-h","-o","-c","-q","-s","-r"],{"-e":"pattern","-f":"path","-A":"number","-B":"number","-C":"number","-m":"number","--include":"pattern","--exclude":"pattern","--exclude-dir":"pattern"});
|
|
151
|
+
else return undefined;
|
|
152
|
+
const parsedArgs=options(args,rule,path);if(!parsedArgs)return undefined;
|
|
153
|
+
const operands=[...parsedArgs.positional];
|
|
154
|
+
if(program==="pwd" && operands.length)return undefined;
|
|
155
|
+
if(program==="rg"||program==="grep") {
|
|
156
|
+
const pattern=parsedArgs.seen.has("-e")||parsedArgs.seen.has("--regexp")||parsedArgs.seen.has("-f")||parsedArgs.seen.has("--file")||parsedArgs.seen.has("--files");
|
|
157
|
+
if(!pattern && operands.shift()===undefined)return undefined;
|
|
158
|
+
}
|
|
159
|
+
for(const value of operands)path(value);
|
|
160
|
+
return {program,paths};
|
|
161
|
+
} catch {return undefined;}
|
|
162
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export interface ReviewerPoolConfig { pool: string[]; rerotate: boolean }
|
|
1
|
+
export interface ReviewerPoolConfig { pool: string[]; rerotate: boolean; sameFamily?: "never" }
|
|
2
2
|
|
|
3
3
|
/** Kind order is project policy; capability and isolation evidence remain host-owned registry facts. */
|
|
4
4
|
export function reviewerPoolConfig(raw: unknown, defaults: readonly string[], known: readonly string[]): ReviewerPoolConfig {
|
|
@@ -7,5 +7,6 @@ export function reviewerPoolConfig(raw: unknown, defaults: readonly string[], kn
|
|
|
7
7
|
const pool = value.pool === undefined ? defaults : value.pool;
|
|
8
8
|
if (!Array.isArray(pool) || pool.length < 1 || pool.length > 16 || pool.some(kind => typeof kind !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(kind) || !known.includes(kind)) || new Set(pool).size !== pool.length) throw new Error("review.pool must list 1..16 distinct known kinds");
|
|
9
9
|
if (value.rerotate !== undefined && typeof value.rerotate !== "boolean") throw new Error("review.rerotate must be a boolean");
|
|
10
|
-
|
|
10
|
+
if (value.same_family !== undefined && value.same_family !== "fallback" && value.same_family !== "never") throw new Error("review.same_family must be \"fallback\" or \"never\"");
|
|
11
|
+
return { pool: [...pool], rerotate: value.rerotate === true, ...(value.same_family === "never" ? { sameFamily: "never" as const } : {}) };
|
|
11
12
|
}
|
package/src/hub/reviewer-pool.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface ReviewerPoolInput {
|
|
|
8
8
|
continuityFamily?: string;
|
|
9
9
|
rerotate?: boolean;
|
|
10
10
|
failed?: readonly string[];
|
|
11
|
+
/** `never` (#820): an implementer-family candidate is never selected, not even as the last resort. */
|
|
12
|
+
sameFamily?: "fallback" | "never";
|
|
11
13
|
}
|
|
12
14
|
export interface ReviewerPoolSelection {
|
|
13
15
|
reviewer: string | null;
|
|
@@ -65,6 +67,7 @@ export function selectReviewerPool(input: ReviewerPoolInput): ReviewerPoolSelect
|
|
|
65
67
|
return finish({ reviewer: candidate.id, family: candidate.family, reason });
|
|
66
68
|
}
|
|
67
69
|
// The caller supplies separate fresh reviewer instances; this does not authorize an implementer's own session.
|
|
70
|
+
if (input.sameFamily === "never") return finish({ reviewer: null, blocked: "no reviewer can run" });
|
|
68
71
|
for (const candidate of ordered) {
|
|
69
72
|
if (!candidate.id || input.implementerFamily === undefined || candidate.family !== input.implementerFamily) continue;
|
|
70
73
|
const blocked = unavailable(candidate);
|
package/src/hub/routing.ts
CHANGED
|
@@ -419,7 +419,7 @@ export function assign(
|
|
|
419
419
|
namedPlanner?: PeerId;
|
|
420
420
|
/** The planner already recorded on the task (525-2): kept while it is still a holder. */
|
|
421
421
|
keptPlanner?: PeerId;
|
|
422
|
-
reviewPool?: { pool: readonly string[]; rerotate?: boolean; failed?: readonly string[]; required?: boolean };
|
|
422
|
+
reviewPool?: { pool: readonly string[]; rerotate?: boolean; failed?: readonly string[]; required?: boolean; sameFamily?: "fallback" | "never" };
|
|
423
423
|
candidates?: PeerId[];
|
|
424
424
|
notReviewer?: PeerId;
|
|
425
425
|
waitsFor?: number[];
|
|
@@ -602,8 +602,13 @@ export function assign(
|
|
|
602
602
|
trace.push(opts.namedReviewer === undefined ? ` reviewer candidates: [classes.review] peers ${classPeers.join(", ") || "none"}${rolePeers.length ? `; reviewer role ${rolePeers.join(", ")}` : ""}` : ` reviewer candidate explicitly named: ${opts.namedReviewer}`);
|
|
603
603
|
const reviewOwner = owner ?? opts.notReviewer;
|
|
604
604
|
const automatic = [...new Set([...expandClassPeers(classPeers, states, opts), ...rolePeers])];
|
|
605
|
-
|
|
605
|
+
// #820: the rule needs the owner's family even when no instance is declared (builtin peers have families too).
|
|
606
|
+
const never = opts.reviewPool?.sameFamily === "never" && reviewOwner !== undefined;
|
|
607
|
+
const ownerFamily = (instanceMode || never) && reviewOwner ? (opts.adapterCatalog?.familyOf ?? familyOf)(reviewOwner, opts.instances) : undefined;
|
|
606
608
|
const sameFamily = (peer: PeerId) => ownerFamily !== undefined && (opts.adapterCatalog?.familyOf ?? familyOf)(peer, opts.instances) === ownerFamily;
|
|
609
|
+
// #820: with review.same_family = never, a reviewer of the owner's family is never chosen; the planner or the person reviews instead.
|
|
610
|
+
const neverSame = never;
|
|
611
|
+
if (neverSame) trace.push(` review.same_family = never: no reviewer of the owner's family (${ownerFamily ?? "unknown"}) is chosen; another family, the planner or the person reviews`);
|
|
607
612
|
const independent = ownerFamily === undefined ? [] : automatic.filter(peer => peer !== reviewOwner && !blocked(peer, "reviewer") && !sameFamily(peer));
|
|
608
613
|
const named = opts.namedReviewer;
|
|
609
614
|
const declaredPool = opts.reviewPool && named === undefined ? Object.keys(opts.instances ?? {}).flatMap(id => {
|
|
@@ -622,21 +627,25 @@ export function assign(
|
|
|
622
627
|
const continuityFamily = last && "reviewerFamily" in last && typeof last.reviewerFamily === "string" ? last.reviewerFamily : last ? (opts.adapterCatalog?.familyOf ?? familyOf)(last.by, opts.instances) : undefined;
|
|
623
628
|
const pr = task.sdlc?.pr;
|
|
624
629
|
const seed = typeof pr === "number" && Number.isSafeInteger(pr) && pr > 0 ? pr : task.id ?? 0;
|
|
625
|
-
reviewSelection = selectReviewerPool({ pool: opts.reviewPool!.pool, candidates: declaredPool, seed, implementerFamily: ownerFamily, continuityFamily, rerotate: opts.reviewPool!.rerotate, failed: opts.reviewPool!.failed });
|
|
630
|
+
reviewSelection = selectReviewerPool({ pool: opts.reviewPool!.pool, candidates: declaredPool, seed, implementerFamily: ownerFamily, continuityFamily, rerotate: opts.reviewPool!.rerotate, failed: opts.reviewPool!.failed, ...(neverSame ? { sameFamily: "never" as const } : {}) });
|
|
626
631
|
for (const row of reviewSelection.walk) trace.push(row.id ? ` reviewer pool candidate ${row.id} (${row.family}): ${row.reason}` : ` reviewer pool kind ${row.kind}: ${row.reason}`);
|
|
627
632
|
reviewer = reviewSelection.reviewer ?? "user";
|
|
628
633
|
if (reviewSelection.blocked) {
|
|
629
634
|
trace.push("blocked: no reviewer can run");
|
|
630
635
|
// Optional defaults retain the legacy planner escape when no fresh reviewer can run.
|
|
631
|
-
const planners = opts.reviewPool?.required ? [] : automatic.filter(peer => peer !== reviewOwner && peerEntry(opts.roles, peer)?.includes("planner") && !(opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.reviewOnly && !blocked(peer, "reviewer"));
|
|
636
|
+
const planners = opts.reviewPool?.required && !neverSame ? [] : automatic.filter(peer => peer !== reviewOwner && peerEntry(opts.roles, peer)?.includes("planner") && (!neverSame || peerEntry(opts.roles, peer)?.includes("reviewer") === true) && !(opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.reviewOnly && !blocked(peer, "reviewer"));
|
|
632
637
|
const otherFamily = planners.filter(peer => !sameFamily(peer));
|
|
633
|
-
const planner = pick(otherFamily.length ? otherFamily : planners, "reviewer", reviewOwner);
|
|
638
|
+
const planner = pick(neverSame || otherFamily.length ? otherFamily : planners, "reviewer", reviewOwner);
|
|
634
639
|
if (planner) {
|
|
635
640
|
reviewer = planner;
|
|
636
641
|
plannerFallback = true;
|
|
637
642
|
trace.push(` planner fallback: no declared reviewer-only instance is eligible; ${planner} reviews`);
|
|
638
643
|
} else reviewBlocked = true;
|
|
639
644
|
}
|
|
645
|
+
} else if (neverSame && named !== undefined && named !== reviewOwner && !blocked(named, "reviewer") && sameFamily(named)) {
|
|
646
|
+
const reason = `same family as ${reviewOwner}; review.same_family is never`;
|
|
647
|
+
refusals.set(`reviewer:${named}`, { peer: named, role: "reviewer", code: "family", reason });
|
|
648
|
+
trace.push(` reviewer candidate ${named}: skipped, ${reason}`);
|
|
640
649
|
} else if (named !== undefined && named !== reviewOwner && !blocked(named, "reviewer") && sameFamily(named) && independent.length) {
|
|
641
650
|
const reason = `same family as ${reviewOwner}; eligible other-family reviewer available`;
|
|
642
651
|
refusals.set(`reviewer:${named}`, { peer: named, role: "reviewer", code: "family", reason });
|
|
@@ -645,7 +654,9 @@ export function assign(
|
|
|
645
654
|
// #223 R1: a declared instance that reviews and does not plan goes first, busy or not.
|
|
646
655
|
const holds = (peer: PeerId, role: string) => opts.roles?.[peer]?.includes(role) === true;
|
|
647
656
|
const dedicated = (peer: PeerId) => !!opts.instances && Object.hasOwn(opts.instances, peer) && holds(peer, "reviewer") && !holds(peer, "planner");
|
|
648
|
-
|
|
657
|
+
// Under never the planner escape needs the reviewer role: a peer that plans and does not review is not a reviewer here, class listing or not.
|
|
658
|
+
const planOnly = (peer: PeerId) => neverSame && holds(peer, "planner") && !holds(peer, "reviewer");
|
|
659
|
+
const pool = independent.length || neverSame ? independent.filter(peer => !planOnly(peer)) : automatic;
|
|
649
660
|
const ready = named === undefined ? pool.filter(peer => dedicated(peer) && peer !== reviewOwner && !blocked(peer, "reviewer")) : [];
|
|
650
661
|
reviewer = pick(named === undefined ? ready.length ? ready : pool : [named], "reviewer", reviewOwner, named === undefined);
|
|
651
662
|
if (named === undefined && !ready.length && reviewer && holds(reviewer, "planner") && Object.keys(opts.roles ?? {}).some(dedicated)) {
|
|
@@ -657,6 +668,12 @@ export function assign(
|
|
|
657
668
|
sameFamilyFallback = true;
|
|
658
669
|
trace.push(` same-family fallback: ${reviewer} and ${reviewOwner} share ${ownerFamily}; no eligible other family`);
|
|
659
670
|
}
|
|
671
|
+
// Under never an exhausted automatic choice (nobody eligible, all offline, no candidate at all) is the person's, never an approval without review.
|
|
672
|
+
if (neverSame && !reviewer && named === undefined && !reviewSelection) {
|
|
673
|
+
reviewer = "user";
|
|
674
|
+
reviewBlocked = true;
|
|
675
|
+
trace.push(" review.same_family = never: no reviewer of another family and no eligible planner; the person reviews");
|
|
676
|
+
}
|
|
660
677
|
// I-2 (#525-3): when the peers that implemented this task were the only candidates left, the person reviews. Never a fallback to an implementer.
|
|
661
678
|
if (!reviewer && [...refusals.values()].some(row => row.role === "reviewer" && row.code === "policy" && row.reason === "implemented this task")) {
|
|
662
679
|
reviewer = "user";
|