@staix/agent-hub 0.18.0 → 0.20.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 (90) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +13 -10
  5. package/docs/agent-notes/bus.md +1 -1
  6. package/docs/agent-notes/daemon.md +11 -4
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +6 -1
  9. package/docs/agent-notes/tests.md +8 -4
  10. package/docs/dogfood.md +13 -4
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +98 -28
  13. package/docs/quickstart.md +13 -5
  14. package/docs/sdlc.md +12 -0
  15. package/docs/security.md +24 -15
  16. package/docs/smoke.md +3 -3
  17. package/docs/specs/2026-09-19-agent-hub-design.md +101 -8
  18. package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
  19. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  20. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  21. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  22. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  23. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  24. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  25. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  26. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/claude-permission-transport-native.py +187 -0
  29. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  30. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  31. package/docs/verification/pi-140-model-free.ts +57 -0
  32. package/docs/verification/pi-140-native.mjs +21 -0
  33. package/docs/verified.json +25 -17
  34. package/package.json +2 -2
  35. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  36. package/plugins/agent-hub/server.js +373 -72
  37. package/src/adapters/acp.ts +8 -0
  38. package/src/adapters/agy/entry.ts +1 -1
  39. package/src/adapters/channel-peer.ts +7 -5
  40. package/src/adapters/claude-channel.ts +7 -3
  41. package/src/adapters/claude-implementer.ts +6 -3
  42. package/src/adapters/codex-appserver.ts +11 -1
  43. package/src/adapters/compat.ts +32 -18
  44. package/src/adapters/conformance/enrollment.ts +4 -4
  45. package/src/adapters/conformance/factories.ts +2 -1
  46. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  47. package/src/adapters/conformance/kit.ts +10 -0
  48. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  49. package/src/adapters/definitions/native-factories.ts +7 -2
  50. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  51. package/src/adapters/local-worker.ts +2 -0
  52. package/src/adapters/pi.ts +46 -3
  53. package/src/adapters/qwen-dual.ts +25 -0
  54. package/src/adapters/registry-types.ts +6 -2
  55. package/src/adapters/registry.ts +1 -0
  56. package/src/adapters/reviewer-print.ts +1 -0
  57. package/src/cli/agents.ts +69 -7
  58. package/src/cli/claude-shell-wrapper.ts +54 -0
  59. package/src/cli/main.ts +30 -24
  60. package/src/cli/output.ts +4 -0
  61. package/src/hub/agent-drift.ts +61 -0
  62. package/src/hub/board.ts +14 -2
  63. package/src/hub/check-runner.ts +148 -18
  64. package/src/hub/ci-watch.ts +140 -0
  65. package/src/hub/claude-launch-proof.ts +67 -2
  66. package/src/hub/claude-shell-wrapper.ts +349 -0
  67. package/src/hub/config-apply.ts +2 -2
  68. package/src/hub/config-edit.ts +2 -2
  69. package/src/hub/config-tier.ts +2 -2
  70. package/src/hub/config-trust.ts +1 -0
  71. package/src/hub/daemon.ts +282 -113
  72. package/src/hub/host-executable.ts +20 -1
  73. package/src/hub/instances.ts +18 -2
  74. package/src/hub/lifecycle.ts +1 -1
  75. package/src/hub/operational-authority.ts +7 -0
  76. package/src/hub/operational-view.ts +6 -10
  77. package/src/hub/peers.ts +34 -0
  78. package/src/hub/read-only-shell-proof.ts +7 -6
  79. package/src/hub/review-scope.ts +13 -0
  80. package/src/hub/sdlc-metrics.ts +6 -4
  81. package/src/hub/settings.ts +1 -0
  82. package/src/hub/task-shell-command.ts +85 -0
  83. package/src/hub/task-shell-default.ts +79 -0
  84. package/src/hub/task-shell-proof.ts +171 -0
  85. package/src/hub/task-shell-sandbox.ts +61 -0
  86. package/src/hub/tasks.ts +274 -16
  87. package/src/local/deny.ts +112 -11
  88. package/src/local/sandbox.ts +70 -29
  89. package/src/local/shell-environment.ts +10 -0
  90. package/src/ui/index.html +1 -0
@@ -1,12 +1,31 @@
1
1
  import type { ReadOnlyProgram } from "./read-only-shell.ts";
2
2
  import { isAbsolute, relative } from "node:path";
3
+ import { lstatSync } from "node:fs";
3
4
  import { realPath } from "./project.ts";
4
5
 
5
6
  /** A repo-writable PATH shim is not a host-owned authenticated capability. No caller-selected binary. */
6
- export function hostExecutable(name: "gh" | "container" | "git" | "dot" | "ssh" | "tar" | ReadOnlyProgram, projectDir: string, path = process.env.PATH ?? ""): string {
7
+ export function hostExecutable(name: "gh" | "bun" | "container" | "git" | "dot" | "ssh" | "tar" | ReadOnlyProgram, projectDir: string, path = process.env.PATH ?? ""): string {
7
8
  const found = Bun.which(name, { PATH: path });
8
9
  if (!found || !isAbsolute(found)) throw new Error(`${name} unavailable`);
9
10
  const target = realPath(found), inside = relative(realPath(projectDir), target);
10
11
  if (!inside || inside !== ".." && !inside.startsWith("../") && !isAbsolute(inside)) throw new Error(`${name} unavailable`);
11
12
  return target;
12
13
  }
14
+
15
+ /** A privileged command's children must not discover a project-controlled PATH shim either. */
16
+ export function hostShellPath(projectDir: string, executionDir: string, value = process.env.PATH ?? "/usr/bin:/bin"): string | undefined {
17
+ try {
18
+ if (!value || value.length > 16384 || value.split(":").length > 128) return;
19
+ const roots = [realPath(projectDir), realPath(executionDir)], directories = new Set<string>();
20
+ const inside = (path: string) => roots.some(root => { const p = relative(root, path); return !p || p !== ".." && !p.startsWith("../") && !isAbsolute(p); });
21
+ for (const entry of value.split(":")) {
22
+ if (!isAbsolute(entry) || entry.split("/").includes("..")) return;
23
+ if (inside(entry)) continue;
24
+ try {
25
+ const canonical = realPath(entry);
26
+ if (!inside(canonical) && lstatSync(canonical).isDirectory()) directories.add(canonical);
27
+ } catch { /* A missing PATH directory supplies no trusted program. */ }
28
+ }
29
+ return directories.size ? [...directories].join(":") : undefined;
30
+ } catch { return undefined; }
31
+ }
@@ -4,6 +4,7 @@ import { dirname, isAbsolute, resolve, sep } from "node:path";
4
4
  import { canonicalPath, realPath } from "./project.ts";
5
5
  import { defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm, type ProjectAdapterCatalog } from "../adapters/registry.ts";
6
6
  import { isPermissionMode, type PermissionMode } from "./permission-mode.ts";
7
+ import { isReviewScope } from "./review-scope.ts";
7
8
 
8
9
  export interface InstanceConfig {
9
10
  kind: string;
@@ -11,9 +12,11 @@ export interface InstanceConfig {
11
12
  start_mode?: StartForm;
12
13
  permission_mode?: PermissionMode;
13
14
  review_mode?: "fresh";
15
+ /** A fresh reviewer's review scope (#852): `code` reads the code only, `full` also runs tests and reads CI. Machine-local, tier 3. */
16
+ review_scope?: "full" | "code";
14
17
  worktree?: string;
15
18
  readOnly?: boolean;
16
- /** Jcode instances only (#685): their own native data home, and optional model selection. Machine-local, never editable from settings. */
19
+ /** Jcode instances (#685): their own native data home, and optional model selection. A headless Claude implementer also takes `model` (#844). Machine-local, never editable from settings. */
17
20
  jcode_home?: string;
18
21
  provider?: string;
19
22
  model?: string;
@@ -129,7 +132,9 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
129
132
  const path = `instances.${id}`, entry = fields(descriptor.value, path);
130
133
  const declaredKind: unknown = entry.kind?.value;
131
134
  const jcodeFields = typeof declaredKind === "string" && resolve(declaredKind)?.instances?.nativeHome === true;
132
- if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode", "readOnly", ...(jcodeFields ? JCODE_FIELDS : [])].includes(key))) return fail(path, "unsupported field");
135
+ // #844: a kind whose headless variant declares `instances.modelSelect` may name its model; no other kind but Jcode has the field.
136
+ const modelSelect = typeof declaredKind === "string" && resolve(declaredKind)?.headlessVariant?.instances?.modelSelect === true;
137
+ if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode", "readOnly", "review_scope", ...(jcodeFields ? JCODE_FIELDS : []), ...(modelSelect ? ["model"] : [])].includes(key))) return fail(path, "unsupported field");
133
138
  const kind: unknown = entry.kind?.value;
134
139
  if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
135
140
  const definition = resolve(kind), prefix = `${kind}-`;
@@ -185,6 +190,17 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
185
190
  if (++jcodeCount > MAX_JCODE_INSTANCES) return fail("instances", `at most ${MAX_JCODE_INSTANCES} extra Jcode instances are supported per project`);
186
191
  Object.assign(value, jcodeInstanceFields(entry, path));
187
192
  }
193
+ if (entry.model && modelSelect) {
194
+ if (!headlessWriter) return fail(`${path}.model`, "only a headless implementer takes a model");
195
+ const word: unknown = entry.model.value;
196
+ if (typeof word !== "string" || !NATIVE_WORD.test(word)) return fail(`${path}.model`, "must be one bounded printable word without spaces");
197
+ value.model = word;
198
+ }
199
+ if (entry.review_scope) {
200
+ if (!freshReviewer) return fail(`${path}.review_scope`, "only a fresh reviewer instance has a review scope");
201
+ if (!isReviewScope(entry.review_scope.value)) return fail(`${path}.review_scope`, 'must be "code" or "full"');
202
+ value.review_scope = entry.review_scope.value;
203
+ }
188
204
  if (entry.readOnly) value.readOnly = entry.readOnly.value as boolean;
189
205
  if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
190
206
  if (entry.start_mode) {
@@ -119,7 +119,7 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
119
119
  try {
120
120
  daemon = await startDaemon({ cwd: project.root, stateDir: project.stateDir, projectId: project.id, instanceId,
121
121
  controlPort: base + CONTROL, codexAppPort: base + CODEX_APP, codexProxyPort: base + CODEX_PROXY,
122
- unattended, onShutdownStart,
122
+ unattended, onShutdownStart, agentDrift: true,
123
123
  // #269: the real terminal providers belong to the running product; a daemon built without them opens nothing.
124
124
  terminal: (template) => terminalOpener({ template, cwd: project.root, stateDir: project.stateDir }) });
125
125
  break;
@@ -41,6 +41,12 @@ export function conductorOperation(command:string,args:readonly string[]): Condu
41
41
  }
42
42
 
43
43
 
44
+ /** Only a failed conductor/operational authority fence carries this marker, never an ordinary operation error. */
45
+ export class OperationalAuthorityError extends Error {
46
+ readonly code = "AHUB_OPERATIONAL_AUTHORITY";
47
+ constructor(message:string,readonly scope:"conductor"|"person"|"operational"="operational") { super(message); }
48
+ }
49
+
44
50
  export const OPERATIONAL_PERSON_GUIDANCE="the person runs it in ahub console or a terminal";
45
51
  export function operationalRefusal(peer:string,command:string):string {
46
52
  return `${peer} cannot run ahub ${command}; ${OPERATIONAL_PERSON_GUIDANCE}; this operation requires the explicit conductor role`;
@@ -54,6 +60,7 @@ export function operationalRequestLabel(value:unknown):string|undefined {
54
60
  return message.op==="orphans"?"doctor orphans kill":message.op==="logs"||message.op==="export"?message.op:"unknown";
55
61
  }
56
62
  if(message.t==="peer_stop")return "stop";
63
+ if(message.t==="task" && message.op==="hub_task_cancel")return "task cancel";
57
64
  if(message.t==="queue" && typeof message.op==="string" && ["show","resolve","discard"].includes(message.op))return `queue ${message.op}`;
58
65
  if(message.t==="settings_set"||message.t==="settings_preview")return "settings set";
59
66
  return undefined;
@@ -87,16 +87,12 @@ const positive=(value:unknown):value is number=>Number.isSafeInteger(value) && (
87
87
 
88
88
  /** Bind the existing task/privacy projections once per read; no private title or path leaves this closure. */
89
89
  export function operationalView(ports:OperationalViewPorts) {
90
- const tasks=new Map(ports.tasks.map(task=>[task.id,task]));
90
+ const publicTasks=new Set<number>();
91
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
- };
92
+ // The caller caches this complete inventory by board/privacy revision; there is no task-count display ceiling.
93
+ let known=true;
94
+ try {for(const task of ports.tasks) {const view=ports.publicView(task);if(ports.isPii(task) || view.title!==task.title) privateTitles.push(task.title);else publicTasks.add(task.id);}} catch {known=false;}
95
+ const safeTask=(id:number):boolean=>known && publicTasks.has(id);
100
96
  const safeText=(text:string):boolean=>{
101
97
  if (!known || Buffer.byteLength(text)>65536 || privateTitles.some(title=>title && text.includes(title))) return false;
102
98
  try {return ports.publicMetadataText(text);} catch {return false;}
@@ -137,5 +133,5 @@ export function operationalView(ports:OperationalViewPorts) {
137
133
  return JSON.parse(text,(_key,item)=>typeof item==="string"?sanitize(item):item) as Record<string,unknown>;
138
134
  } catch {return withheld;}
139
135
  };
140
- return {line,event,complete:known};
136
+ return {line,event,complete:known,...(!known?{incompleteReason:"privacy inventory unavailable"}: {})};
141
137
  }
package/src/hub/peers.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Envelope, EnvelopeOpts, PeerId, PeerState } from "./envelope.ts";
2
2
  import type { PermissionMode } from "./permission-mode.ts";
3
+ import type { StartForm } from "../adapters/registry-types.ts";
3
4
 
4
5
  export interface DeliveryReceipt {
5
6
  id: string;
@@ -14,6 +15,15 @@ export interface OwnedToolsLaunch {
14
15
  launchId: string; generation: string; sessionId: string; signal: AbortSignal; task?: number; permissionHook: true;
15
16
  }
16
17
 
18
+ export interface FrontendProxyPort {
19
+ readonly url: string;
20
+ readonly attached: boolean;
21
+ /** Only before a frontend attaches, while offline and with no native restoration debt. */
22
+ setStartupPermissionMode(mode: PermissionMode): void;
23
+ /** Clear to ask while retaining any native restoration debt; false means its baseline is unknown. */
24
+ clearPermissionMode(): boolean;
25
+ }
26
+
17
27
  export interface PeerAdapter {
18
28
  readonly id: PeerId;
19
29
  ownedTools?(launchId: unknown): OwnedToolsLaunch | undefined;
@@ -27,6 +37,30 @@ export interface PeerAdapter {
27
37
  readonly permissionModeState?: PermissionMode | "unknown" | "unmanaged";
28
38
  /** Resolve only after native acknowledgement. This changes approval frequency, never the sandbox. */
29
39
  setPermissionMode?(mode: PermissionMode): Promise<void>;
40
+ /** Never offered delivery retry; absent/false permits retry, present undefined refuses it. */
41
+ readonly deliveryOnly?: boolean;
42
+ /** The hub owns this runtime and may stop it; absent or undefined refuses. */
43
+ readonly hubOwned?: boolean;
44
+ /** An owned process remains whose exit is unconfirmed. */
45
+ readonly pendingOwnedProcess?: boolean;
46
+ /** Native running form; absent keeps the site's default, present undefined is unknown. */
47
+ readonly form?: StartForm;
48
+ /** Absent is settled; false or a present undefined value refuses recovery readiness. */
49
+ readonly recoveryReady?: boolean;
50
+ /** Capture live resume evidence before a requested stop; throws when unavailable. */
51
+ captureResume?(): Promise<Record<string, unknown>>;
52
+ /** Native session binding; absence or undefined is unbound, never inferred from a different reader. */
53
+ readonly nativeSession?: string;
54
+ /** Fixed-builtin native proof only; present undefined is unknown, not an absent proxy. */
55
+ readonly frontendProxy?: FrontendProxyPort;
56
+ /** Fixed operator notices from builtin native adapters, never provider text or native ids. */
57
+ readonly statusNotice?: { code: "unidentified-cancellation" | "qwen-detached" | "qwen-stalled"; message: string };
58
+ /** Fixed builtins with session.nativeRevert only. Native conversation effects remain in the adapter. */
59
+ revertTurn?(nativeTurn: string): Promise<void>;
60
+ /** Leave the roster without stopping a person's terminal. */
61
+ detachForRoster?(resetMode: PermissionMode): void | Promise<void>;
62
+ /** Return to the roster after detachForRoster. */
63
+ resumeFromRoster?(): Promise<void>;
30
64
  /** A peer the hub drives itself (Pi, the local worker). It cannot be trusted to rate its own urgency: the bus caps it. */
31
65
  readonly hubNative?: boolean;
32
66
  /** Never handed a delivery: its queue waits for `Bus.pull` (a Claude session that cannot show channel pushes, issue #205). */
@@ -1,5 +1,4 @@
1
1
  import { accessSync, constants, lstatSync } from "node:fs";
2
- import { homedir } from "node:os";
3
2
  import { dirname, isAbsolute, relative, resolve } from "node:path";
4
3
  import { spawnSync } from "node:child_process";
5
4
  import { createHash } from "node:crypto";
@@ -7,10 +6,11 @@ import { guardPath, type ShellExecutionCheck } from "../local/tools.ts";
7
6
  import { hostExecutable } from "./host-executable.ts";
8
7
  import { hubGitArgv, hubGitEnv } from "./git.ts";
9
8
  import { realPath } from "./project.ts";
10
- import { readOnlyShellForm, type ReadOnlyProgram } from "./read-only-shell.ts";
9
+ import { parseSimpleShell, readOnlyShellForm, type ReadOnlyProgram } from "./read-only-shell.ts";
10
+ import { hostShellEnvironment } from "../local/shell-environment.ts";
11
11
 
12
12
  export interface ShellProofOptions { root:string; projectRoot?:string; deny:string[]; readOnly?:string[]; env?:Record<string,string> }
13
- export interface ReadOnlyShellProof { fingerprint:string; program:ReadOnlyProgram }
13
+ export interface ReadOnlyShellProof { fingerprint:string; program:ReadOnlyProgram; executable:string; argv:readonly string[] }
14
14
  const within=(root:string,path:string)=>{const p=relative(root,path);return p==="" || p!==".."&&!p.startsWith("../")&&!isAbsolute(p);};
15
15
  function firstProgram(name:string,root:string,path:string):string|undefined {
16
16
  if(path.length>16384)return undefined;
@@ -49,14 +49,15 @@ export function proveReadOnlyShell(input:unknown,options:ShellProofOptions,curre
49
49
  const root=realPath(options.root),projectRoot=realPath(options.projectRoot??options.root),names:string[]=[root,projectRoot];
50
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
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};
52
+ const env=hostShellEnvironment(options.env);
53
53
  const lexical=firstProgram(form.program,root,env.PATH);if(!lexical || within(root,lexical) || within(projectRoot,lexical))return undefined;
54
54
  const executable=hostExecutable(form.program,root,env.PATH);if(realPath(lexical)!==executable || within(projectRoot,executable) || !current())return undefined;
55
55
  if(form.gitCwd && !gitHelpersUnconfigured(executable,form.gitCwd,env,current))return undefined;
56
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
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;
58
+ const fingerprint=createHash("sha256").update(JSON.stringify([root,projectRoot,args.command,env,lexical,executable,[...paths].map(path=>stamp(path,path===lexical||path===executable))])).digest("hex");
59
+ const argv=parseSimpleShell(args.command)!.words.slice(1);
60
+ return current()?{fingerprint,program:form.program,executable,argv}:undefined;
60
61
  } catch {return undefined;}
61
62
  }
62
63
  interface Decision { command:string; current:()=>boolean; proof?:ReadOnlyShellProof }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * #852: a reviewer's review scope. `full` reads the code, runs the affected tests and reads CI; `code` reads the code only, because its sandbox
3
+ * cannot run tests or read CI. AC3 (amended 2026-10-26) is structural, never a reading of free text: a `code` reviewer's changes_requested is always
4
+ * recorded, and one with no open structured finding is recorded as evidence-only (see Tasks.review).
5
+ */
6
+ export type ReviewScope = "full" | "code";
7
+ export const REVIEW_SCOPES: readonly ReviewScope[] = ["full", "code"];
8
+ export const isReviewScope = (value: unknown): value is ReviewScope => value === "full" || value === "code";
9
+
10
+ /** The one review-request line for a `code` reviewer (AC2). Fixed text: no hub or PR content is interpolated. */
11
+ export const CODE_SCOPE_LINE = "Your sandbox cannot run tests or read CI: review the code against the plan. The merger gates on exact-head CI (#852). Findings are only for defects in the reviewed code, docs or tests. A missing check result, CI result, test run or PR/branch identity is an evidence request: put it in unmet, never in findings. Report every code defect as a structured finding with an id (hub_review findings, disposition open); a verdict with no open finding is recorded as evidence-only and does not count as a rejection.";
12
+ /** The one task notice the conductor gets when a code-scope reviewer's verdict is recorded as evidence-only (ids only, never text). */
13
+ export const evidenceOnlyNotice = (task: number, reviewer: string): string => `Task #${task}: a code-scope review by ${reviewer} was recorded as evidence-only (no open finding) and the task returned to its owner. Take the review.`;
@@ -11,8 +11,8 @@ export type BandTier = typeof BAND_TIERS[number];
11
11
  export interface MetricTask {
12
12
  id: number;
13
13
  private?: boolean;
14
- /** `requestHead` is the head a `done` asked review for (reviewCost.head); `verdictHead` the head a verdict was bound to (reviewFindings.head). Compared for equality only, never printed. */
15
- history: { at: number; event: string; requestHead?: string; verdictHead?: string }[];
14
+ /** `evidenceOnly` marks a code-scope verdict with no open finding (#852): it is not a rework round. `requestHead` is the head a `done` asked review for (reviewCost.head); `verdictHead` the head a verdict was bound to (reviewFindings.head). Compared for equality only, never printed. */
15
+ history: { at: number; event: string; requestHead?: string; verdictHead?: string; evidenceOnly?: true }[];
16
16
  }
17
17
 
18
18
  /** Per-task values; `null` is unknown. */
@@ -98,6 +98,8 @@ export function taskSample(task: MetricTask, extra: { mergedAt?: number; ciRerun
98
98
  const spec = first(view, ["spec accepted"]);
99
99
  const plan = spec ? first(view, ["plan reviewed"], spec.at) : undefined;
100
100
  const verdict = accepted ? first(view, VERDICTS, accepted.at) : first(view, VERDICTS);
101
+ // Evidence-only still ends the actual wait for a first review, but says nothing about whether the work passed.
102
+ const substantiveVerdict = history.find(entry => entry.at >= (accepted?.at ?? -Infinity) && VERDICTS.includes(entry.event) && entry.evidenceOnly !== true);
101
103
  const dones = history.filter(entry => entry.event === "done");
102
104
  const ci = first(view, ["check passed", "check failed"]);
103
105
  const approvedAt = history.find(entry => entry.event === "approved")?.at ?? null;
@@ -108,10 +110,10 @@ export function taskSample(task: MetricTask, extra: { mergedAt?: number; ciRerun
108
110
  acceptance_to_plan_ms: spec && plan ? plan.at - spec.at : null,
109
111
  acceptance_to_first_review_ms: accepted && verdict ? verdict.at - accepted.at : null,
110
112
  acceptance_to_merge_ms: accepted && extra.mergedAt !== undefined && Number.isFinite(extra.mergedAt) && extra.mergedAt >= accepted.at ? extra.mergedAt - accepted.at : null,
111
- first_pass_approval: verdict ? (verdict.event === "approved" ? 1 : 0) : null,
113
+ first_pass_approval: substantiveVerdict ? (substantiveVerdict.event === "approved" ? 1 : 0) : null,
112
114
  // Rework grows with every round, so it is a value only once the task is approved: a task still in flight has no final one, and a
113
115
  // provisional point would move at each verdict and could start a second breach episode inside one shift.
114
- rework_rounds: dones.length && approvedAt !== null ? history.filter(entry => entry.event === "changes_requested").length : null,
116
+ rework_rounds: dones.length && approvedAt !== null ? history.filter(entry => entry.event === "changes_requested" && entry.evidenceOnly !== true).length : null,
115
117
  first_pass_check: ci ? (ci.event === "check passed" ? 1 : 0) : null,
116
118
  same_head_ci_reruns: extra.ciReruns === undefined ? null : extra.ciReruns,
117
119
  stale_head_re_reviews: approvedAt !== null ? staleHeadRounds(history) : null,
@@ -57,6 +57,7 @@ export const SETTINGS: readonly SettingDef[] = [
57
57
  ...["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "number", min: 1, max: 2_147_483_647 / (key === "interval_s" ? 1000 : 60_000), store: "config", applies: "live", risk: "raises", path: ["task_sweep", key] })),
58
58
  ...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "live", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
59
59
  { key: "tasks.escalate_after", group: "Routing", label: "Escalate after N consecutive changes_requested", type: "integer", min: 1, max: 10, store: "config", applies: "live", risk: "raises", path: ["tasks", "escalate_after"] },
60
+ { key: "tasks.auto_pull", group: "Switches", label: "Auto-pull the top reserved ready card when a planner slot frees", type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["tasks", "auto_pull"] },
60
61
  { key: "planner", group: "Roles", label: "Planners (task-local selection)", type: "peers", values: PLANNER_PEERS, store: "config", applies: "live", risk: "raises", path: ["planner"] },
61
62
  ...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "next use", risk: "safe", path: ["catalog_roles", peer] })),
62
63
  { key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
@@ -0,0 +1,85 @@
1
+ import { parseSimpleShell } from "./read-only-shell.ts";
2
+
3
+ /** Short branch names from host evidence, never from the permission request. */
4
+ export interface TaskShellBinding { branch: string; defaultBranch: string }
5
+ export interface TaskShellCommand {
6
+ kind: "check" | "build" | "test" | "add" | "commit" | "push" | "pr-view" | "pr-checks" | "pr-create";
7
+ program: "scripts/check.sh" | "bun" | "git" | "gh";
8
+ argv: readonly string[];
9
+ /** Original literal path operands. The host must guard them under its verified execution root. */
10
+ paths: readonly string[];
11
+ }
12
+
13
+ export function shortBranch(value: unknown): value is string {
14
+ return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/.test(value)
15
+ && value !== "HEAD" && !value.startsWith("refs/") && !value.includes("..") && !value.includes("//")
16
+ && !value.endsWith("/") && !value.endsWith(".")
17
+ && value.split("/").every(part => !part.startsWith(".") && !part.endsWith(".lock"));
18
+ }
19
+
20
+ function literalPath(value: string): boolean {
21
+ if (!value || value.length > 512 || value.startsWith("-") || !/^[A-Za-z0-9_./-]+$/.test(value)) return false;
22
+ const relative = value.startsWith("./") ? value.slice(2) : value;
23
+ return !relative.startsWith("-") && relative.split("/").every(part => !!part && part !== "." && part !== ".."
24
+ && ![".git", ".agenthub"].includes(part.toLowerCase()));
25
+ }
26
+
27
+ const data = (value: string | undefined, limit: number): value is string =>
28
+ typeof value === "string" && value.length <= limit && !!value.trim() && !/[\p{Cc}\p{Cf}]/u.test(value);
29
+
30
+ export function taskShellBindingValid(binding: TaskShellBinding): boolean {
31
+ return !!binding && shortBranch(binding.branch) && shortBranch(binding.defaultBranch)
32
+ && ![binding.defaultBranch.toLowerCase(), "main", "master"].includes(binding.branch.toLowerCase());
33
+ }
34
+
35
+ /**
36
+ * Lexical data only: this does not authorize execution, bind a task, resolve a program or read Git/files.
37
+ * The caller supplies authoritative native cwd/environment, current child/task/worktree/branch evidence and
38
+ * action/privacy policy, then guards every returned path and executable before answering the native prompt.
39
+ * Quoted punctuation is data only in commit messages and PR title/body values; path/ref operands stay plain.
40
+ */
41
+ export function taskShellCommand(command: unknown, binding: TaskShellBinding): TaskShellCommand | undefined {
42
+ if (!taskShellBindingValid(binding)) return undefined;
43
+ const parsed = parseSimpleShell(command);
44
+ if (!parsed) return undefined;
45
+ const argv = parsed.words;
46
+ const result = (kind: TaskShellCommand["kind"], program: TaskShellCommand["program"], paths: readonly string[] = []): TaskShellCommand =>
47
+ ({ kind, program, argv: [...argv], paths: [...paths] });
48
+ if (argv.length === 2 && argv[0] === "scripts/check.sh" && argv[1] === "--changed") return result("check", "scripts/check.sh", ["scripts/check.sh"]);
49
+ if (argv[0] === "bun") {
50
+ if (argv.length === 3 && argv[1] === "run" && argv[2] === "build") return result("build", "bun");
51
+ const paths = argv.slice(2);
52
+ if (argv[1] === "test" && paths.length >= 1 && paths.length <= 16 && paths.every(path => {
53
+ const relative = path.startsWith("./") ? path.slice(2) : path;
54
+ return literalPath(path) && relative.startsWith("test/") && /^[A-Za-z0-9_][A-Za-z0-9_.-]*\.test\.ts$/.test(relative.split("/").at(-1)!);
55
+ })) return result("test", "bun", paths);
56
+ return undefined;
57
+ }
58
+ if (argv[0] === "git") {
59
+ if (argv[1] === "add") {
60
+ const paths = argv.slice(argv[2] === "--" ? 3 : 2);
61
+ if (paths.length >= 1 && paths.length <= 64 && paths.every(literalPath)) return result("add", "git", paths);
62
+ }
63
+ if (argv.length === 4 && argv[1] === "commit" && argv[2] === "-m" && data(argv[3], 1000)) return result("commit", "git");
64
+ if (argv[1] === "push" && (argv.length === 4 && argv[2] === "origin" && argv[3] === binding.branch
65
+ || argv.length === 5 && argv[2] === "-u" && argv[3] === "origin" && argv[4] === binding.branch)) return result("push", "git");
66
+ return undefined;
67
+ }
68
+ if (argv[0] !== "gh" || argv[1] !== "pr") return undefined;
69
+ if (argv.length === 4 && argv[3] === binding.branch) {
70
+ if (argv[2] === "view") return result("pr-view", "gh");
71
+ if (argv[2] === "checks") return result("pr-checks", "gh");
72
+ }
73
+ if (argv[2] !== "create" || argv.length !== 11) return undefined;
74
+ const allowed = new Set(["--base", "--head", "--title", "--body", "--body-file"]), flags = new Map<string, string>();
75
+ for (let index = 3; index < argv.length; index += 2) {
76
+ const flag = argv[index]!, value = argv[index + 1];
77
+ if (!allowed.has(flag) || value === undefined || flags.has(flag)) return undefined;
78
+ flags.set(flag, value);
79
+ }
80
+ if (flags.get("--base") !== binding.defaultBranch || flags.get("--head") !== binding.branch || !data(flags.get("--title"), 1000)) return undefined;
81
+ if (flags.has("--body") === flags.has("--body-file")) return undefined;
82
+ if (flags.has("--body")) return data(flags.get("--body"), 3000) ? result("pr-create", "gh") : undefined;
83
+ const file = flags.get("--body-file")!;
84
+ return literalPath(file) ? result("pr-create", "gh", [file]) : undefined;
85
+ }
@@ -0,0 +1,79 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { chmodSync, lstatSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative } from "node:path";
4
+ import { stopOwnedProcess, trackGroup } from "./child-process.ts";
5
+ import { hostExecutable } from "./host-executable.ts";
6
+ import { hubGitArgv, hubGitEnv } from "./git.ts";
7
+ import { realPath } from "./project.ts";
8
+ import { isGitHubRepository } from "./spec-read.ts";
9
+ import { shortBranch } from "./task-shell-command.ts";
10
+
11
+ const OUTPUT_LIMIT = 64 * 1024;
12
+ function remoteHead(bytes: Buffer): string | undefined {
13
+ const output = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
14
+ if (!output.endsWith("\n")) return;
15
+ const lines = output.slice(0, -1).split("\n");
16
+ if (lines.length !== 2) return;
17
+ const symbolic = lines.filter(line => /^ref: refs\/heads\/.+\tHEAD$/.test(line));
18
+ const oid = lines.filter(line => /^[0-9a-f]{40}\tHEAD$/.test(line));
19
+ if (symbolic.length !== 1 || oid.length !== 1) return;
20
+ const branch = symbolic[0]!.slice("ref: refs/heads/".length, -"\tHEAD".length);
21
+ return shortBranch(branch) ? branch : undefined;
22
+ }
23
+
24
+ /** Startup authority comes from the fixed GitHub remote, never a mutable local origin/HEAD or repository config.
25
+ * No task/native environment or account credentials are inherited. Unknown remote evidence keeps automatic execution unavailable. */
26
+ export async function readTaskShellDefaultBranch(root: string, env: Record<string, string>, current: () => boolean, projectRoot: string, repository?: string, timeoutMs = 5000): Promise<string | undefined> {
27
+ let temporary: string | undefined, proc: ChildProcess | undefined;
28
+ try {
29
+ if (!isGitHubRepository(repository) || !current() || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000
30
+ || !env.PATH || env.PATH.length > 16384 || env.PATH.split(":").some(dir => !isAbsolute(dir) || dir.split("/").includes(".."))) return;
31
+ root = realPath(root);
32
+ const project = realPath(projectRoot), executable = hostExecutable("git", root, env.PATH), rel = relative(project, executable);
33
+ if (!rel || rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel) || !lstatSync(executable).isFile()) return;
34
+ temporary = realPath(mkdtempSync(join(realPath("/tmp"), "ahub-default-"))); chmodSync(temporary, 0o700);
35
+ const own = temporary, stat = lstatSync(own), identity = JSON.stringify([stat.dev, stat.ino, stat.mode]);
36
+ const remove = () => {
37
+ const held = lstatSync(own);
38
+ if (!held.isDirectory() || realPath(own) !== own || JSON.stringify([held.dev, held.ino, held.mode]) !== identity) throw new Error("unavailable");
39
+ rmSync(own, { recursive: true, force: true }); temporary = undefined;
40
+ };
41
+ // Git stops before inspecting a ceiling ancestor; naming cwd itself still permits discovery in its parent.
42
+ const environment = hubGitEnv({ PATH: env.PATH, HOME: own, XDG_CONFIG_HOME: own, LANG: env.LANG ?? "C", TERM: "dumb", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CEILING_DIRECTORIES: dirname(own) }, { repositoryIsolation: true });
43
+ const argv = hubGitArgv(["-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "ls-remote", "--symref", "--exit-code", `https://github.com/${repository}.git`, "HEAD"]);
44
+ if (!current()) { remove(); return; }
45
+ return await new Promise<string | undefined>(resolve => {
46
+ let done = false, bytes = 0, overflowed = false;
47
+ const output: Buffer[] = [];
48
+ let timer: ReturnType<typeof setTimeout> | undefined, watcher: ReturnType<typeof setInterval> | undefined;
49
+ const finish = async (code: number | null) => {
50
+ if (done) return; done = true; clearTimeout(timer); clearInterval(watcher);
51
+ // Do not remove a private cwd/HOME while an owned group may still use it. The existing helper refuses unknown ownership.
52
+ try { if (proc) await stopOwnedProcess(proc, { group: true }); remove(); }
53
+ catch { resolve(undefined); return; }
54
+ try { resolve(code === 0 && !overflowed && current() ? remoteHead(Buffer.concat(output)) : undefined); }
55
+ catch { resolve(undefined); }
56
+ };
57
+ try { proc = spawn(executable, argv.slice(1), { cwd: own, env: environment, detached: true, stdio: ["ignore", "pipe", "pipe"] }); }
58
+ catch { void finish(null); return; }
59
+ timer = setTimeout(() => void finish(null), timeoutMs);
60
+ watcher = setInterval(() => { try { if (!current()) void finish(null); } catch { void finish(null); } }, 50); watcher.unref();
61
+ proc.once("spawn", () => trackGroup(proc!));
62
+ const take = (chunk: Buffer, stdout: boolean) => {
63
+ if (done) return;
64
+ bytes += chunk.length;
65
+ if (bytes > OUTPUT_LIMIT) { overflowed = true; void finish(null); return; }
66
+ if (stdout) output.push(Buffer.from(chunk));
67
+ };
68
+ proc.stdout!.on("data", (chunk: Buffer) => take(chunk, true));
69
+ proc.stderr!.on("data", (chunk: Buffer) => take(chunk, false));
70
+ proc.once("error", () => void finish(null));
71
+ proc.once("exit", code => { if (code !== 0) void finish(null); });
72
+ proc.once("close", code => void finish(code));
73
+ });
74
+ } catch {
75
+ // A spawn/setup failure owns no live group; otherwise only confirmed stop above permits removing the private directory.
76
+ if (!proc && temporary) { try { rmSync(temporary, { recursive: true, force: true }); } catch { /* unknown cleanup remains private */ } }
77
+ return undefined;
78
+ }
79
+ }