@staix/agent-hub 0.17.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +5 -0
  4. package/docs/agent-notes/adapters.md +2 -0
  5. package/docs/agent-notes/benchmarks.md +1 -0
  6. package/docs/agent-notes/daemon.md +1 -0
  7. package/docs/agent-notes/local-worker.md +1 -0
  8. package/docs/agent-notes/tasks.md +7 -1
  9. package/docs/agent-notes/tests.md +1 -0
  10. package/docs/dogfood.md +15 -1
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +105 -16
  13. package/docs/profiles.md +36 -0
  14. package/docs/quickstart.md +9 -3
  15. package/docs/sdlc.md +29 -3
  16. package/docs/security.md +26 -4
  17. package/docs/specs/2026-09-19-agent-hub-design.md +16 -6
  18. package/docs/verification/2026-10-25-0.18.0.md +76 -0
  19. package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
  20. package/docs/verification/2026-10-25-remote-docker-transport.md +46 -0
  21. package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
  22. package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
  23. package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
  24. package/docs/verified.json +24 -18
  25. package/package.json +2 -2
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +94 -33
  28. package/src/adapters/definitions/builtin-metadata.ts +1 -1
  29. package/src/adapters/local-worker.ts +5 -3
  30. package/src/cli/console-state.ts +9 -2
  31. package/src/cli/help.ts +29 -28
  32. package/src/cli/identity.ts +87 -6
  33. package/src/cli/main.ts +190 -69
  34. package/src/cli/output.ts +7 -2
  35. package/src/cli/sdlc-output.ts +242 -0
  36. package/src/cli/task-output.ts +212 -0
  37. package/src/cli/upgrade-output.ts +57 -0
  38. package/src/hub/board.ts +17 -0
  39. package/src/hub/check-runner.ts +252 -22
  40. package/src/hub/conductor.ts +5 -0
  41. package/src/hub/config-apply.ts +16 -7
  42. package/src/hub/config-edit-host.ts +1 -1
  43. package/src/hub/config-tier.ts +1 -1
  44. package/src/hub/daemon.ts +353 -72
  45. package/src/hub/dogfood-profile.ts +248 -0
  46. package/src/hub/dogfood-read.ts +77 -14
  47. package/src/hub/dogfood.ts +5 -1
  48. package/src/hub/events.ts +1 -1
  49. package/src/hub/host-executable.ts +2 -1
  50. package/src/hub/hub-tools.ts +2 -2
  51. package/src/hub/operational-authority.ts +60 -0
  52. package/src/hub/operational-view.ts +141 -0
  53. package/src/hub/orphan-process.ts +69 -0
  54. package/src/hub/permission-mode.ts +1 -1
  55. package/src/hub/read-only-shell-proof.ts +80 -0
  56. package/src/hub/read-only-shell.ts +162 -0
  57. package/src/hub/reviewer-pool-config.ts +3 -2
  58. package/src/hub/reviewer-pool.ts +3 -0
  59. package/src/hub/routing.ts +23 -6
  60. package/src/hub/sdlc-merge.ts +299 -0
  61. package/src/hub/sdlc-mirror.ts +129 -0
  62. package/src/hub/sdlc.ts +58 -12
  63. package/src/hub/settings.ts +11 -5
  64. package/src/hub/snapshots.ts +60 -0
  65. package/src/hub/task-repository.ts +31 -5
  66. package/src/hub/tasks.ts +241 -16
  67. package/src/local/tools.ts +17 -4
  68. 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 edit inside the project run without asking; shell and git writes still ask";
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
- return { pool: [...pool], rerotate: value.rerotate === true };
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
  }
@@ -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);
@@ -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
- const ownerFamily = instanceMode && reviewOwner ? (opts.adapterCatalog?.familyOf ?? familyOf)(reviewOwner, opts.instances) : undefined;
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
- const pool = independent.length ? independent : automatic;
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";