@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
|
@@ -10,6 +10,8 @@ export interface ReviewerMcpOptions {
|
|
|
10
10
|
tools: readonly HubTool[];
|
|
11
11
|
/** Fixed host port; the host still applies Tasks' role, privacy, head and running-child fences. */
|
|
12
12
|
call(name: ReviewerToolName, args: Record<string, unknown>): Promise<string>;
|
|
13
|
+
/** Diagnostic metadata only; never tool arguments or successful result text. */
|
|
14
|
+
onTool?(name: ReviewerToolName, refusal?: string): void;
|
|
13
15
|
}
|
|
14
16
|
const NAMES: readonly ReviewerToolName[] = ["hub_review", "hub_task_show"];
|
|
15
17
|
const BODY_CAP = 256 * 1024, RESULT_CAP = 4 * 1024 * 1024;
|
|
@@ -60,20 +62,24 @@ export function createReviewerMcpSession(options: ReviewerMcpOptions): ReviewerM
|
|
|
60
62
|
if (!record(body.params)) return error("tool parameters required");
|
|
61
63
|
const name = body.params.name;
|
|
62
64
|
if (name !== "hub_review" && name !== "hub_task_show") return error("tool unavailable");
|
|
65
|
+
const observe = (refusal?: string) => { try { options.onTool?.(name, refusal); } catch { /* Diagnostics never change tool authority or results. */ } };
|
|
66
|
+
const refuse = (message: string, code?: number) => { observe(message); return error(message, code); };
|
|
67
|
+
observe();
|
|
63
68
|
let args = body.params.arguments;
|
|
64
|
-
if (typeof args === "string") { try { args = JSON.parse(args); } catch { return
|
|
65
|
-
if (!record(args) || args.id !== options.task || !Number.isSafeInteger(args.id)) return
|
|
66
|
-
if (++calls > 64) return
|
|
67
|
-
if (!current()) return
|
|
69
|
+
if (typeof args === "string") { try { args = JSON.parse(args); } catch { return refuse("invalid tool arguments"); } }
|
|
70
|
+
if (!record(args) || args.id !== options.task || !Number.isSafeInteger(args.id)) return refuse("tool is bound to its review task");
|
|
71
|
+
if (++calls > 64) return refuse("review tool call limit", -32002);
|
|
72
|
+
if (!current()) return refuse("review authority ended", -32001);
|
|
68
73
|
try {
|
|
69
74
|
const result = await options.call(name, args);
|
|
70
75
|
// Tasks.review rechecks its captured authority through its final write. A successful verdict
|
|
71
76
|
// ends that in_review binding itself; acknowledge its committed result, never fresh reads.
|
|
72
|
-
if (name !== "hub_review" && !current()) return
|
|
73
|
-
if (Buffer.byteLength(result) > RESULT_CAP) return
|
|
77
|
+
if (name !== "hub_review" && !current()) return refuse("review authority ended", -32001);
|
|
78
|
+
if (Buffer.byteLength(result) > RESULT_CAP) return refuse("tool result exceeds review limit");
|
|
74
79
|
return reply({ content: [{ type: "text", text: result }] });
|
|
75
80
|
} catch (failure) {
|
|
76
81
|
// The same host tool boundary supplies refusals to the released MCP transport.
|
|
82
|
+
observe(failure instanceof Error ? failure.message : "review tool refused");
|
|
77
83
|
const message = failure instanceof Error ? sanitize(failure.message).slice(0, 4000) : "review tool refused";
|
|
78
84
|
return reply({ isError: true, content: [{ type: "text", text: message }] });
|
|
79
85
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ChildProcess } from "node:child_process";
|
|
2
2
|
import { OwnedPrintChild, PrintChildCleanupError } from "./claude-print-child.ts";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { HUB, frame } from "../hub/envelope.ts";
|
|
4
|
+
import { HUB, frame, sanitize } from "../hub/envelope.ts";
|
|
5
5
|
import { BasePeer } from "../hub/peers.ts";
|
|
6
6
|
import type { Envelope } from "../hub/envelope.ts";
|
|
7
|
-
import type { ReviewerMcpSession } from "./reviewer-mcp.ts";
|
|
7
|
+
import type { ReviewerMcpSession, ReviewerToolName } from "./reviewer-mcp.ts";
|
|
8
8
|
|
|
9
9
|
/** #722: the closed list of legs a reviewer's per-review start can fail on. A class names a leg, never a path, content or token. */
|
|
10
10
|
export const REVIEWER_CAUSE_CLASSES = ["version", "binary", "mcp_inventory", "plugins_hooks_apps", "sandbox_proof", "credential_root", "timeout", "unknown"] as const;
|
|
@@ -27,6 +27,10 @@ export function reviewerUnavailableReason(cause: ReviewerCauseClass, detail?: st
|
|
|
27
27
|
export class ReviewerUnavailableError extends Error {
|
|
28
28
|
constructor(message: string, readonly causeClass: ReviewerCauseClass, readonly detail?: string) { super(message); }
|
|
29
29
|
}
|
|
30
|
+
/** A refused request says nothing about the reviewer's native isolation or ability to start. */
|
|
31
|
+
class ReviewerAdmissionError extends Error {
|
|
32
|
+
constructor(readonly reason: "stale_binding" | "policy_changed") { super("review admission changed"); }
|
|
33
|
+
}
|
|
30
34
|
|
|
31
35
|
export interface ReviewerPrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
|
|
32
36
|
export interface ReviewerIsolationReceipt {
|
|
@@ -76,6 +80,31 @@ export interface ReviewerBackend {
|
|
|
76
80
|
prepare(context: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string; causeClass?: ReviewerCauseClass; detail?: string }>;
|
|
77
81
|
usage?(event: unknown): ReviewerPrintUsage | undefined;
|
|
78
82
|
failure?(event: unknown): "quota" | undefined;
|
|
83
|
+
/** Structured native MCP events only, never model messages or tool arguments. */
|
|
84
|
+
toolTrace?(event: unknown): ReviewerToolTrace | undefined;
|
|
85
|
+
}
|
|
86
|
+
export interface ReviewerToolTrace { tool: ReviewerToolName; refusal?: string }
|
|
87
|
+
export interface ReviewerNoVerdict extends ReviewerReviewBinding {
|
|
88
|
+
task: number;
|
|
89
|
+
exitCode: number | null;
|
|
90
|
+
reviewCalled: boolean;
|
|
91
|
+
lastTool?: ReviewerToolName;
|
|
92
|
+
refusal?: string;
|
|
93
|
+
/** Preserve the first full host error across later truncated native echoes; differing host errors stay uncertain. */
|
|
94
|
+
hostRefusal?: string;
|
|
95
|
+
refusalUncertain?: true;
|
|
96
|
+
}
|
|
97
|
+
type ReviewerTraceState = Pick<ReviewerNoVerdict, "reviewCalled" | "lastTool" | "refusal" | "hostRefusal" | "refusalUncertain">;
|
|
98
|
+
/** Host-produced receipt reason: an unconfirmed normal review fallback still needs a person's settlement. */
|
|
99
|
+
export const REVIEWER_FALLBACK_UNCONFIRMED = "reviewer fallback unconfirmed: ";
|
|
100
|
+
/** `safeRefusal` must have passed the host's complete privacy/secret screen before truncation. */
|
|
101
|
+
export function reviewerNoVerdictLine(peer: string, diagnostic: ReviewerNoVerdict, safeRefusal?: string, withheld = false): string {
|
|
102
|
+
const prefix = `[${peer}] review of task #${diagnostic.task}: no verdict exit=${diagnostic.exitCode ?? "unknown"}; `;
|
|
103
|
+
if (withheld) return prefix + "diagnostic withheld";
|
|
104
|
+
const detail = diagnostic.lastTool ? `last hub tool ${diagnostic.lastTool}; ${diagnostic.reviewCalled ? "hub_review called" : "no hub_review call"}` : "no hub_review call";
|
|
105
|
+
const line = safeRefusal === undefined ? undefined : Array.from(sanitize(safeRefusal).replace(/\s+/g, " "))
|
|
106
|
+
.filter(char => !/\p{C}/u.test(char)).slice(0, 240).join("");
|
|
107
|
+
return prefix + detail + (diagnostic.refusal === undefined && diagnostic.hostRefusal === undefined ? "" : `; refusal: ${line ?? "withheld"}`);
|
|
79
108
|
}
|
|
80
109
|
export interface ReviewerFailure {
|
|
81
110
|
task: number;
|
|
@@ -99,7 +128,9 @@ export interface ReviewerPrintOptions {
|
|
|
99
128
|
facts?(env: Envelope): string | undefined;
|
|
100
129
|
verdictSince(task: number, since: number): boolean;
|
|
101
130
|
onUsage?(usage: ReviewerPrintUsage, task: number): void;
|
|
102
|
-
|
|
131
|
+
/** True confirms that the host re-offered the current review; void/false retains uncertainty. */
|
|
132
|
+
onUnavailable?(failure: ReviewerFailure, current: () => boolean): boolean | void | Promise<boolean | void>;
|
|
133
|
+
onNoVerdict?(diagnostic: ReviewerNoVerdict, current: () => boolean): void;
|
|
103
134
|
log?(line: string): void;
|
|
104
135
|
taskMcp?(task: number, current: () => boolean): ReviewerMcpSession;
|
|
105
136
|
/** Synchronous host task/privacy/policy fence, checked again after awaited preparation. */
|
|
@@ -139,6 +170,24 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
139
170
|
reviewing: number | undefined;
|
|
140
171
|
private reviewSeq = 0;
|
|
141
172
|
private runningBinding: { task: number; binding: ReviewerReviewBinding } | undefined;
|
|
173
|
+
private activeTrace: { task: number; generation: number; value: ReviewerTraceState } | undefined;
|
|
174
|
+
/** Host observations are diagnostic data and never grant tool or verdict authority. */
|
|
175
|
+
recordToolTrace(task: number, trace: ReviewerToolTrace): void {
|
|
176
|
+
if (trace.tool !== "hub_review" && trace.tool !== "hub_task_show") return;
|
|
177
|
+
const active = this.activeTrace, binding = this.runningBinding;
|
|
178
|
+
if (!active || active.task !== task || active.generation !== this.generation || this.reviewing !== task || binding?.task !== task || this.opts.bindingCurrent?.(task, binding.binding) === false) return;
|
|
179
|
+
if (typeof trace.refusal === "string") {
|
|
180
|
+
if (active.value.hostRefusal === undefined) active.value.hostRefusal = trace.refusal;
|
|
181
|
+
else if (active.value.hostRefusal !== trace.refusal) active.value.refusalUncertain = true;
|
|
182
|
+
}
|
|
183
|
+
this.observeTool(active.value, trace);
|
|
184
|
+
}
|
|
185
|
+
private observeTool(value: ReviewerTraceState, trace: ReviewerToolTrace): void {
|
|
186
|
+
if (trace.tool !== "hub_review" && trace.tool !== "hub_task_show") return;
|
|
187
|
+
value.lastTool = trace.tool;
|
|
188
|
+
value.refusal = typeof trace.refusal === "string" ? trace.refusal : undefined;
|
|
189
|
+
if (trace.tool === "hub_review") value.reviewCalled = true;
|
|
190
|
+
}
|
|
142
191
|
/** Immutable envelope identity of this running child, never the board's later head. */
|
|
143
192
|
reviewBinding(task: number): ReviewerReviewBinding | undefined {
|
|
144
193
|
return this.reviewing === task && this.runningBinding?.task === task ? this.runningBinding.binding : undefined;
|
|
@@ -234,10 +283,20 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
234
283
|
if (task === undefined) continue; // a withdrawal or other notice: nothing to review
|
|
235
284
|
if (env.private) { failure = { state: "needs_review", reason: "a private review is the person's, never a cloud reviewer's" }; break; }
|
|
236
285
|
const since = Date.now();
|
|
237
|
-
let result: { code: number | null; quota: boolean };
|
|
286
|
+
let result: { code: number | null; quota: boolean; trace: ReviewerTraceState };
|
|
238
287
|
try { result = await this.review(env, task, generation); }
|
|
239
288
|
catch (error) {
|
|
240
289
|
if (generation !== this.generation) return;
|
|
290
|
+
if (error instanceof ReviewerAdmissionError) {
|
|
291
|
+
this.opts.log?.(`[${this.id}] review of task #${task} skipped: ${error.reason === "stale_binding" ? "stale binding" : "review policy changed"}`);
|
|
292
|
+
if (error.reason === "policy_changed") {
|
|
293
|
+
missing.push(task);
|
|
294
|
+
const target = batch.targets[index];
|
|
295
|
+
if (target) reroute.push({ ...target, cause: "no_verdict" });
|
|
296
|
+
}
|
|
297
|
+
batch.next = index + 1;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
241
300
|
if (error instanceof PrintChildCleanupError) {
|
|
242
301
|
// Unavailable until a stop confirms the group gone: no new review runs beside the survivors.
|
|
243
302
|
this.opts.log?.(`[${this.id}] review of task #${task}: ${error.message}`);
|
|
@@ -259,6 +318,11 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
259
318
|
if (!recorded) {
|
|
260
319
|
missing.push(task);
|
|
261
320
|
const target = batch.targets[index];
|
|
321
|
+
const diagnostic = { ...target, task, exitCode: result.code, ...result.trace };
|
|
322
|
+
try {
|
|
323
|
+
if (this.opts.onNoVerdict) this.opts.onNoVerdict(diagnostic, () => generation === this.generation);
|
|
324
|
+
else this.opts.log?.(reviewerNoVerdictLine(this.id, diagnostic));
|
|
325
|
+
} catch { this.opts.log?.(reviewerNoVerdictLine(this.id, { task, exitCode: result.code, reviewCalled: false }, undefined, true)); }
|
|
262
326
|
if (target) reroute.push({ ...target, cause: result.quota ? "quota" : "no_verdict" });
|
|
263
327
|
if (result.quota) {
|
|
264
328
|
reroute.push(...remainingFailures(batch, index + 1, "quota"));
|
|
@@ -267,25 +331,30 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
267
331
|
}
|
|
268
332
|
}
|
|
269
333
|
batch.next = index + 1;
|
|
270
|
-
if (result.code !== 0) this.opts.log?.(`[${this.id}] review of task #${task} exited ${result.code}`);
|
|
334
|
+
if (recorded && result.code !== 0) this.opts.log?.(`[${this.id}] review of task #${task} exited ${result.code}`);
|
|
271
335
|
}
|
|
272
336
|
if (generation !== this.generation) return;
|
|
273
337
|
this.clearWatchdog(); // the child is gone; keep busy while the host settles the remaining walk
|
|
274
|
-
|
|
275
|
-
|
|
338
|
+
const reoffered = await this.reportFailures(reroute, generation);
|
|
339
|
+
if (generation !== this.generation) return;
|
|
340
|
+
const unresolved = missing.length > 0 && !reoffered;
|
|
341
|
+
const normalNoVerdict = !failure && reroute.length > 0 && reroute.every(value => value.cause === "no_verdict");
|
|
342
|
+
this.settle(failure?.state ?? (unresolved ? "needs_review" : "completed"), failure?.reason ?? (unresolved ? `${normalNoVerdict ? REVIEWER_FALLBACK_UNCONFIRMED : ""}no verdict recorded for task ${missing.map(id => `#${id}`).join(", ")}` : undefined));
|
|
276
343
|
if (generation === this.generation) { this.batch = undefined; this.setState("idle"); }
|
|
277
344
|
}
|
|
278
345
|
|
|
279
|
-
private async reportFailures(failures: readonly ReviewerFailure[], generation: number): Promise<
|
|
346
|
+
private async reportFailures(failures: readonly ReviewerFailure[], generation: number): Promise<boolean> {
|
|
280
347
|
const seen = new Set<string>();
|
|
348
|
+
let reoffered = true;
|
|
281
349
|
for (const failed of failures) {
|
|
282
|
-
if (generation !== this.generation) return;
|
|
350
|
+
if (generation !== this.generation) return false;
|
|
283
351
|
const identity = JSON.stringify([failed.task, failed.head, failed.flowRevision]);
|
|
284
352
|
if (seen.has(identity)) continue;
|
|
285
353
|
seen.add(identity);
|
|
286
|
-
try { await this.opts.onUnavailable?.(failed, () => generation === this.generation); }
|
|
287
|
-
catch { this.opts.log?.(`[${this.id}] reviewer fallback could not be recorded; inspect the task`); }
|
|
354
|
+
try { if (await this.opts.onUnavailable?.(failed, () => generation === this.generation) !== true) reoffered = false; }
|
|
355
|
+
catch { reoffered = false; this.opts.log?.(`[${this.id}] reviewer fallback could not be recorded; inspect the task`); }
|
|
288
356
|
}
|
|
357
|
+
return reoffered;
|
|
289
358
|
}
|
|
290
359
|
|
|
291
360
|
private prepare(sessionId: string, input?: string, task?: number, mcpConfig?: string): Promise<ReviewerLaunch> {
|
|
@@ -313,14 +382,21 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
313
382
|
return done;
|
|
314
383
|
}
|
|
315
384
|
|
|
316
|
-
private
|
|
317
|
-
if (this.opts.
|
|
385
|
+
private assertAdmission(env: Envelope, task: number, binding: ReviewerReviewBinding): void {
|
|
386
|
+
if (this.opts.bindingCurrent?.(task, binding) === false) throw new ReviewerAdmissionError("stale_binding");
|
|
387
|
+
if (this.opts.admit?.(env) === false) throw new ReviewerAdmissionError(this.opts.bindingCurrent?.(task, binding) === false ? "stale_binding" : "policy_changed");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private async review(env: Envelope, task: number, generation: number): Promise<{ code: number | null; quota: boolean; trace: ReviewerTraceState }> {
|
|
391
|
+
const revision=Number(env.refs?.reviewRevision);
|
|
392
|
+
const binding: ReviewerReviewBinding=Object.freeze({head:env.refs?.commit,flowRevision:Number.isSafeInteger(revision)&&revision>0?revision:undefined});
|
|
393
|
+
this.assertAdmission(env, task, binding);
|
|
318
394
|
let facts: string | undefined;
|
|
319
395
|
try { facts = this.opts.facts?.(env); } catch (error) { this.opts.log?.(`[${this.id}] review facts for task #${task} failed: ${(error as Error).message}`); }
|
|
320
396
|
const input = `${frame(env)}${facts ? `\n\n${facts}` : ""}`;
|
|
321
397
|
let launch: ReviewerLaunch | undefined;
|
|
322
|
-
const
|
|
323
|
-
|
|
398
|
+
const trace: ReviewerTraceState = { reviewCalled: false };
|
|
399
|
+
this.activeTrace = { task, generation, value: trace };
|
|
324
400
|
const mcp = this.opts.backend.taskMcp ? this.opts.taskMcp?.(task, () => generation === this.generation && this.reviewToken(task) !== undefined && this.opts.bindingCurrent?.(task,binding) !== false) : undefined;
|
|
325
401
|
let mcpClosed=false;
|
|
326
402
|
const closeMcp=()=>{ if(mcpClosed)return; mcpClosed=true; mcp?.close(); if(this.reviewMcpClose===closeMcp)this.reviewMcpClose=undefined; };
|
|
@@ -330,7 +406,9 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
330
406
|
const release = () => releasing ??= Promise.resolve().then(() => launch?.cleanup?.()).then(() => {});
|
|
331
407
|
try {
|
|
332
408
|
launch = await this.prepare(randomUUID(), input, task, mcp?.config);
|
|
333
|
-
if (generation !== this.generation
|
|
409
|
+
if (generation !== this.generation) throw new ReviewerAdmissionError("stale_binding");
|
|
410
|
+
this.assertAdmission(env, task, binding);
|
|
411
|
+
if (launch.current?.() === false) throw new ReviewerUnavailableError("reviewer isolation changed before child spawn", "binary");
|
|
334
412
|
this.receipt = launch.isolation;
|
|
335
413
|
let quota = false;
|
|
336
414
|
const child = new OwnedPrintChild({ peer: this.id, command: launch.command, args: launch.args,
|
|
@@ -343,6 +421,10 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
343
421
|
if (this.childCurrent) policyTimer = setInterval(() => { this.reviewToken(task); }, 100);
|
|
344
422
|
},
|
|
345
423
|
onFrame: event => {
|
|
424
|
+
try {
|
|
425
|
+
const observed = this.opts.backend.toolTrace?.(event);
|
|
426
|
+
if (observed && generation === this.generation) this.observeTool(trace, observed);
|
|
427
|
+
} catch { /* Optional trace metadata never changes review execution. */ }
|
|
346
428
|
const usage = this.opts.backend.usage?.(event);
|
|
347
429
|
if (usage) this.opts.onUsage?.(usage, task);
|
|
348
430
|
if (this.opts.backend.failure?.(event) === "quota") quota = true;
|
|
@@ -359,8 +441,9 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
359
441
|
this.childCurrent = launch.watchCurrent ? launch.current : undefined;
|
|
360
442
|
const code = await child.done;
|
|
361
443
|
if (this.child === child) this.child = undefined;
|
|
362
|
-
return { code, quota };
|
|
444
|
+
return { code, quota, trace };
|
|
363
445
|
} finally {
|
|
446
|
+
if (this.activeTrace?.value === trace) this.activeTrace = undefined;
|
|
364
447
|
closeMcp();
|
|
365
448
|
if (policyTimer) clearInterval(policyTimer);
|
|
366
449
|
try { if (!ownedChild || this.child !== ownedChild) { await release(); if (this.childCleanup === release) this.childCleanup = undefined; } }
|
package/src/cli/console-state.ts
CHANGED
|
@@ -47,7 +47,8 @@ export interface ConsoleState {
|
|
|
47
47
|
autoSelected?: boolean;
|
|
48
48
|
input: string; editing: boolean; history: string[]; historyIndex: number;
|
|
49
49
|
approvals: Approval[]; events: ConsoleEvent[]; peers: Record<string, any>; budget: Record<string, any>;
|
|
50
|
-
|
|
50
|
+
/** #784 AC3: the split of `wip` (active, review-wait, reserved); absent from an older hub. */
|
|
51
|
+
taskWip?: Record<string, { wip: number; wipLimit: number; active?: number; reviewWait?: number; reserved?: number }>;
|
|
51
52
|
backlogView?: boolean;
|
|
52
53
|
tasks: any[]; tasksKnown?: boolean; taskCounts?: Record<string, number>; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
|
|
53
54
|
peerFilter?: string; kindFilter?: string; project?: string;
|
|
@@ -348,6 +349,12 @@ export function budgetFields(budget: any, now: number): [string, unknown][] {
|
|
|
348
349
|
});
|
|
349
350
|
return [["quota", lines.length ? lines : typeof budget.note === "string" ? budget.note : "no reading"], ...(lines.length && typeof budget.note === "string" ? [["quota note", budget.note] as [string, unknown]] : [])];
|
|
350
351
|
}
|
|
352
|
+
/** #784 AC3: the Peers detail carries the planner WIP split as one line (`1 of 2: active 1, review-wait 0, reserved 0`); a hub that sends none adds nothing. */
|
|
353
|
+
function peerDetailRow(s: ConsoleState, row: any): Record<string, unknown> {
|
|
354
|
+
const wip = s.taskWip?.[row.id];
|
|
355
|
+
const split = wip && [wip.active, wip.reviewWait, wip.reserved].every(n => Number.isSafeInteger(n)) ? `${wip.wip} of ${wip.wipLimit}: active ${wip.active}, review-wait ${wip.reviewWait}, reserved ${wip.reserved}` : undefined;
|
|
356
|
+
return { ...row, ...(split ? { "planner WIP": split } : {}) };
|
|
357
|
+
}
|
|
351
358
|
const detailLines = (detail: Detail, columns: number, now: number, task = false): Span[][] => {
|
|
352
359
|
if (typeof detail === "string") return wrap(detail, columns).map(text => [span(text)]);
|
|
353
360
|
const { budget, ...rest } = detail as Record<string, unknown>;
|
|
@@ -794,7 +801,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
794
801
|
if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
|
|
795
802
|
else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
|
|
796
803
|
else if (s.panel === 7) { s.detail = { usagePeer: row.id }; s.detailOffset = 0; } // rendered live from s.usage, so a refresh updates it
|
|
797
|
-
else { s.detail = s.panel === 5 ? String(row.text) : { ...row }; s.detailOffset = 0; }
|
|
804
|
+
else { s.detail = s.panel === 5 ? String(row.text) : s.panel === 1 ? peerDetailRow(s, row) : { ...row }; s.detailOffset = 0; }
|
|
798
805
|
return done();
|
|
799
806
|
}
|
|
800
807
|
if (s.mode === "panels" && item !== undefined && item !== null) {
|
package/src/cli/help.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface HelpEntry { section: string; usage: string; description: string
|
|
|
8
8
|
const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
9
9
|
["Projects", [
|
|
10
10
|
["ahub --project <path|id> <command>", "select a repository or worktree explicitly"],
|
|
11
|
-
["ahub projects [--json] [--color=auto|always|never]", "list registered projects and live status"],
|
|
11
|
+
["ahub projects [--json] [--full] [--color=auto|always|never]", "list registered projects and live status"],
|
|
12
12
|
["ahub projects remove <id>", "forget a stopped registration (keeps project files)"],
|
|
13
13
|
["ahub setup [--yes]", "install or update the Claude Code channel plugin from this package, then run doctor"],
|
|
14
14
|
["ahub init [--profile dev|research|writing|general] [--shadow] [--release-guard] [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)"],
|
|
@@ -24,9 +24,10 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
24
24
|
["ahub pet --window [--close]", "start or close the macOS floating window, from a person's terminal"],
|
|
25
25
|
["ahub pet [--short]", "read attention without connecting; --short prints ahub!N only when action is needed"],
|
|
26
26
|
["ahub pet --palette <blue|teal|violet|ochre>", "save the floating window's base color to pet.palette in .agenthub/config.local.json"],
|
|
27
|
-
["ahub sdlc status [task] | explain <task> | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action (person only
|
|
28
|
-
["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...]", "read-only merge and release evidence
|
|
29
|
-
["ahub sdlc
|
|
27
|
+
["ahub sdlc status [task] | explain <task> [--json] [--color=auto|always|never] | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action; status and explain are open to any agent as a public view (private tasks stay stubs), export and record are person only; export stays JSON"],
|
|
28
|
+
["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...] [--json] [--color=auto|always|never]", "read-only merge and release evidence; open to any agent as public readback of the checkout's repositories, never an authorization"],
|
|
29
|
+
["ahub sdlc merge <task> [--dry-run]", "guarded squash merge of an approved task at its approved head after every gate is re-read; follows sdlc.toml auto_merge (off, dry-run, on); --dry-run only reports (person or conductor)"],
|
|
30
|
+
["ahub sdlc authorize-release <version> [--json] [--color=auto|always|never]", "approve this daemon checkout commit, version and fresh docs (person only; restart revokes)"],
|
|
30
31
|
["ahub sdlc release-guard -- <literal git push|gh release create|npm publish argv>", "read-only authorization preflight; init --release-guard installs the tag pre-push hook"],
|
|
31
32
|
["ahub sdlc release-guard-hook <remote-name> <remote-url>", "internal pre-push readback; validates version tags and forwards exact stdin on success"],
|
|
32
33
|
["ahub sdlc accept <task> --issue <n> [--repo owner/repo]", "accept the task's spec issue at its current body; build entry needs it in enforce (person only)"],
|
|
@@ -34,22 +35,22 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
34
35
|
["ahub sdlc plan-review <task> <revision> <reason...> [--review full|delta|none --reviewers N] [--ci full|focused|reuse]", "record planner review, scope and host-read issue metadata"],
|
|
35
36
|
["ahub sdlc train report|configure|cut <release-id> [revision] [json]", "plan a release and freeze its approved scope after resource/quota admission"],
|
|
36
37
|
["ahub sdlc release-assign <task> <revision> <release-id|none> <reason...>", "assign approved/in-flight work without changing execution"],
|
|
37
|
-
["ahub sdlc costs [owner/repo]", "read bounded planner cost metrics for linked merged PRs"],
|
|
38
|
-
["ahub sdlc metrics [--since <YYYY-MM-DD|ISO time>]", "read SDLC metrics and control band tiers from board history (planner or person; read-only)"],
|
|
39
|
-
["ahub backlog groom | return <task> <revision> <reason...>", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
|
|
38
|
+
["ahub sdlc costs [owner/repo] [--json] [--color=auto|always|never]", "read bounded planner cost metrics for linked merged PRs"],
|
|
39
|
+
["ahub sdlc metrics [--since <YYYY-MM-DD|ISO time>] [--json] [--color=auto|always|never]", "read SDLC metrics and control band tiers from board history (planner or person; read-only)"],
|
|
40
|
+
["ahub backlog groom | return <task> <revision> <reason...> [--json] [--color=auto|always|never]", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
|
|
40
41
|
["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
|
|
41
42
|
["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
|
|
42
|
-
["ahub logs [-f]", "
|
|
43
|
+
["ahub logs [-f]", "person: last 100 hub.log lines (-f starts at 10); conductor: bounded public lines and follow while authority remains"],
|
|
43
44
|
["ahub doctor [--json] [--full] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models and memory library this project uses"],
|
|
44
45
|
["ahub doctor --stale-anchors [--json]", "read-only bounded report of public memory symbol anchors at current HEAD; private or unavailable evidence stays unknown"],
|
|
45
|
-
["ahub doctor --orphans [--kill] [--json] [--color=auto|always|never]", "
|
|
46
|
+
["ahub doctor --orphans [--kill] [--json] [--color=auto|always|never]", "person: inspect missing-root registrations; conductor --kill: selected project only; signed hub ownership is required before any stop"],
|
|
46
47
|
["ahub agents [--json]", "show each gated agent CLI's installed version and verdict (verified, in-line, trusted, refused or unknown)"],
|
|
47
48
|
["ahub agents trust <id> <version> [--revoke]", "accept an untested agent CLI version on this machine (person only; runtime proofs still run)"],
|
|
48
49
|
["ahub kill", "stop this project's hub"],
|
|
49
50
|
["ahub reset [--all] [--yes]", "list, then with --yes stop the hub and discard every queued and needs_review delivery, clear holds and pauses and drop session pointers; --all moves the state directory to .agenthub/archive instead"],
|
|
50
51
|
["ahub restart [--dry-run] [--yes] [--json] [--verbose] [--color=auto|always|never]", "recover this project's runtime; on a terminal without a flag it reviews the plan with you, follows the progress and offers the next steps"],
|
|
51
52
|
["ahub upgrade [--to <version>] [--dry-run] [--yes] [--json] [--verbose] [--color=auto|always|never]", "review and upgrade running projects; --to defaults to the latest release (--yes needs it), and a newer release's own coordinator takes over; on a terminal without --dry-run or --yes: review, end agents first if you choose, apply, follow, and resume, cancel, end or reset on the spot"],
|
|
52
|
-
["ahub recovery [status|resume|abort] [<operation-id>]", "inspect, resume or cancel a preflight; status names the next actions; without an id, the operation that holds the lock, and with nothing else on a terminal its screen"],
|
|
53
|
+
["ahub recovery [status|resume|abort] [<operation-id>] [--json] [--color=auto|always|never]", "inspect, resume or cancel a preflight; status names the next actions; without an id, the operation that holds the lock, and with nothing else on a terminal its screen"],
|
|
53
54
|
["ahub recovery reattach <operation-id> codex|claude|pi", "person only: repair that blocked peer with its recorded session; leaves the rest for recovery resume"],
|
|
54
55
|
["ahub recovery dispose [<operation-id>] --fresh-session codex|claude --reason <text>", "end a partial operation by starting that peer as a new session, recording the lost conversation"],
|
|
55
56
|
["ahub recovery dispose [<operation-id>] --stop-and-archive --reason <text>", "end a partial operation: stop its own targets, archive its snapshot and release the lock; the upgrade is abandoned, not completed"],
|
|
@@ -60,9 +61,9 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
60
61
|
["ahub <configured-kind> [--mode headless] [--as <instance>] [--session-id <id>] [--print-command | --dry-run]", "start a trusted config.local.json ACP kind (default off roster); native argv is withheld in previews"],
|
|
61
62
|
["ahub role list [--group <group>] | show <id>", "read bounded catalog guidance with layer and content hash"],
|
|
62
63
|
["ahub role assign <peer> <id> | clear <peer> [<id>]", "person only: add or clear catalog guidance, applied at the next peer start"],
|
|
63
|
-
["ahub stop <peer>", "
|
|
64
|
+
["ahub stop <peer>", "person or configured conductor: stop a hub-owned headless peer; end TUI agents in their terminals"],
|
|
64
65
|
["ahub models status [--json] [--color=auto|always|never]", "inspect local model state as labelled fields"],
|
|
65
|
-
["ahub models setup|start|stop", "prepare or inspect local Ollama MLX (legacy stop is explicit)"],
|
|
66
|
+
["ahub models setup|start [--json] [--color=auto|always|never] | stop", "prepare or inspect local Ollama MLX; setup and start print the model state like status (legacy stop is explicit)"],
|
|
66
67
|
]],
|
|
67
68
|
["Messages and approvals", [
|
|
68
69
|
["ahub say [@peer ...] <text>", "send as the console user (no @peer = broadcast); delivered at once, start the text with [STATUS] to let it batch or [FYI] for the record only"],
|
|
@@ -70,32 +71,32 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
70
71
|
["ahub tail", "live stream of messages, states and permission requests"],
|
|
71
72
|
["ahub permission [<peer> [ask|ask-when-needed|never-ask]] [--yes]", "human only: inspect or set a running peer mode; never-ask requires --yes; config never-ask waits for console y"],
|
|
72
73
|
["ahub permit <id> <option>", "answer a permission request shown by tail (\"deny\" cancels)"],
|
|
73
|
-
["ahub roster [--json] [--color=auto|always|never]", "
|
|
74
|
-
["ahub roster add <peer> | remove <peer> [--yes] [--now]", "
|
|
75
|
-
["ahub settings [list] [--json] | get <key> [--json]", "
|
|
76
|
-
["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "
|
|
74
|
+
["ahub roster [--json] [--color=auto|always|never]", "every known peer, participation, start mode and retained work"],
|
|
75
|
+
["ahub roster add <peer> | remove <peer> [--yes] [--now]", "person or configured conductor: change live participation through settings; removal previews retained messages and task handoff; remove also deletes the roster key of an instance no longer declared"],
|
|
76
|
+
["ahub settings [list] [--json] | get <key> [--json]", "the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
|
|
77
|
+
["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "validated settings: conductor set is limited to exact live operational keys; undo/reload and other keys are person only; --preview saves nothing; conductor writes keep the person undo record"],
|
|
77
78
|
["ahub settings edit <config.local.json|sdlc.toml> (set <path> <json> | remove <path>)... [--reason <text>] [--preview] [--yes] | backups [--json]", "human only: structural config (instances, review, snapshots, approvals, context.gate, sdlc.toml mode and required_checks) validated by the hub's own loaders, previewed as a diff and written with a backup; a key that runs code or locates secrets needs its name typed, never --yes"],
|
|
78
79
|
["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
|
|
79
|
-
["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
|
|
80
|
-
["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
|
|
81
|
-
["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text> [--revision N]", "resolve a queued or needs_review delivery; --revision binds the displayed row instead of a fresh show; a queued delivery can be discarded while its peer is busy, and a refusal names its cause"],
|
|
82
|
-
["ahub queue discard --peer <id> --reason <text> [--state queued|needs_review] [--yes] [--json]", "person
|
|
80
|
+
["ahub queue list [--peer <id>] [--json] [--full] [--color=auto|always|never]", "inspect durable deliveries; open to any agent as public queue metadata, held bodies stay with the person or conductor"],
|
|
81
|
+
["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "person or configured conductor: inspect one delivery and revision; agent output uses the public view"],
|
|
82
|
+
["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text> [--revision N] [--json] [--color=auto|always|never]", "person or configured conductor: resolve a queued or needs_review delivery; --revision binds the displayed row instead of a fresh show; a queued delivery can be discarded while its peer is busy, and a refusal names its cause"],
|
|
83
|
+
["ahub queue discard --peer <id> --reason <text> [--state queued|needs_review] [--yes] [--json]", "person or configured conductor: without --yes count one peer's queued (default) deliveries; with --yes discard them all with the reason, one recorded discard each; other peers' queues are untouched"],
|
|
83
84
|
]],
|
|
84
85
|
["Tasks and review", [
|
|
85
86
|
["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
|
|
86
87
|
["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--json] [--full] [--color=auto|always|never]", "the task board, one line per task at a terminal width; --full: whole titles and SDLC advice; --ready: proposed tasks with nothing left to wait for"],
|
|
87
88
|
["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--backlog] [--detail <text>]", "put a task on the board; a first word that names a class is taken as the class; while a task named by --after is not approved yet, --owner reserves the task for that peer, offered to it first once it is ready; otherwise --owner assigns it now"],
|
|
88
|
-
["ahub backlog list [--json] [--full] [--color=auto|always|never] | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision>", "person only: plan work with a reason; {} records a note-only revision, fields.sdlc sets backlog repo/issue identity and clears old spec acceptance; preserve pins/frozen scope and refuse stale edits; list is by rank (--json: raw listing)"],
|
|
89
|
-
["ahub release new <name> <goal> <order> | list | show <name> | plan <name> <revision> <dates-json> | activate|freeze|ship|drop <name> <revision>", "person only: planned UTC millisecond dates and phased scope; ship records a time after approval, never tags or publishes"],
|
|
90
|
-
["ahub backlog split <id> <revision> <parts-json> <reason...>", "person only: stage2..8 child prerequisites; original task retains integration, undo refuses used or changed children"],
|
|
91
|
-
["ahub backlog pull-ready <id> <revision> <reason...>", "person only: reserve an eligible WIP slot before routing; pending unowned work can be undone"],
|
|
89
|
+
["ahub backlog list [--json] [--full] [--color=auto|always|never] | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision> [--json] [--color=auto|always|never]", "person only: plan work with a reason; {} records a note-only revision, fields.sdlc sets backlog repo/issue identity and clears old spec acceptance; preserve pins/frozen scope and refuse stale edits; list is by rank (--json: raw listing)"],
|
|
90
|
+
["ahub release new <name> <goal> <order> | list | show <name> | plan <name> <revision> <dates-json> | activate|freeze|ship|drop <name> <revision> [--json] [--color=auto|always|never]", "person only, except show (any agent, public metadata): planned UTC millisecond dates and phased scope; ship records a time after approval, never tags or publishes"],
|
|
91
|
+
["ahub backlog split <id> <revision> <parts-json> <reason...> [--json] [--color=auto|always|never]", "person only: stage2..8 child prerequisites; original task retains integration, undo refuses used or changed children"],
|
|
92
|
+
["ahub backlog pull-ready <id> <revision> <reason...> [--json] [--color=auto|always|never]", "person only: reserve an eligible WIP slot before routing; pending unowned work can be undone"],
|
|
92
93
|
["ahub backlog pull|return <id>", "person only: pull backlog into To Do or return ownerless proposed work; --backlog on task propose stages unoffered work"],
|
|
93
94
|
["ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never]", "person only: what each task delivered and a reviewer approved, by id, hash and size (a path only where the PII screen allows); --check measures the files now and marks one changed since review"],
|
|
94
|
-
["ahub task show|escalate <id>", "full task with history (PII text
|
|
95
|
+
["ahub task show <id> [--json] [--full] [--color=auto|always|never] | escalate <id>", "full task with history (the person sees PII text, an agent the public view) / hand it to the next peer in escalate_to (person or configured conductor)"],
|
|
95
96
|
["ahub task role <id> <role|none>", "person only: change bounded catalog guidance on an open task; routing and authority are unchanged"],
|
|
96
97
|
["ahub task assign <id> <peer> [--role owner|reviewer|planner] [--revision N]", "name the owner (default), reviewer or person-only planner; a task that still waits reserves its owner; a peer that cannot take it is refused with the reason; --revision refuses stale console actions"],
|
|
97
98
|
["ahub task unassign <id> [--role owner|reviewer] [--revision N]", "person only: remove the owner/reservation or reviewer without routing a replacement or stopping native work"],
|
|
98
|
-
["ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]", "person only: close obsolete work with a reason; retain its history without stopping native work or releasing dependents"],
|
|
99
|
+
["ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N] [--json] [--color=auto|always|never]", "person only, plus the configured conductor: close obsolete work with a reason; retain its history without stopping native work or releasing dependents; eligibility follows the task-board policy"],
|
|
99
100
|
["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned [--created <time>]", "your later verdict on an approved task, kept in the research records (research must be on)"],
|
|
100
101
|
["ahub review <id> approved|changes_requested [note...] [--unmet <item>]... [--head <sha>] [--findings <json>]", "give a review verdict on a task; the person can do the same in ahub console: Tasks, select the task, r, then a (approve) or c (request changes, with a note)"],
|
|
101
102
|
["ahub review brief <id>", "print the review brief of a task's commit: reviewable and excluded files, rules, coverage to account for"],
|
|
@@ -109,7 +110,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
109
110
|
["ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week]", "feed a reading by hand (also: test the relay)"],
|
|
110
111
|
["ahub budget resume <peer>", "override a budget pause; readings are ignored for that peer until the window resets"],
|
|
111
112
|
["ahub budget execution status [id] [--json] [--color=auto|always|never]", "read execution counters and limits"],
|
|
112
|
-
["ahub budget execution configure <config.json> | disable <id>", "opt-in limits on model calls, tool calls, time and tokens for pi and local"],
|
|
113
|
+
["ahub budget execution configure <config.json> | disable <id> [--json] [--color=auto|always|never]", "opt-in limits on model calls, tool calls, time and tokens for pi and local"],
|
|
113
114
|
]],
|
|
114
115
|
["History and reports", [
|
|
115
116
|
["ahub turns [peer] [--limit N] [--json] [--color=auto|always|never]", "recent turns and the files each changed (a git work tree only)"],
|
|
@@ -119,7 +120,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
119
120
|
["ahub usage [--since 7d|<iso>] [--by peer|task|issue|pr|release|day|model] [--json] [--color=auto|always|never]", "tokens per group from the incremental roll-up: the provider's total with and without cache reads, input, output, reasoning and the hub's own count; each number names its source, unknown is never 0"],
|
|
120
121
|
["ahub usage limits [--peer <id>] [--limit <n>] [--json] [--color=auto|always|never]", "current quota windows and the history of pauses, resumes and resets"],
|
|
121
122
|
["ahub usage total [--json] [--color=auto|always|never]", "lifetime development tokens since the first event, per peer and overall"],
|
|
122
|
-
["ahub export [--since 7d|<iso>]", "structured events (events.jsonl) as JSON lines; never message bodies"],
|
|
123
|
+
["ahub export [--since 7d|<iso>]", "structured events (events.jsonl) as JSON lines; never message bodies; the configured conductor gets a bounded projection (last 1000 events)"],
|
|
123
124
|
["ahub research [--since 30d] [--all] [--json]", "success, first-pass, rework and check-failure rates, tokens and wall time per approved task, from the opt-in research records"],
|
|
124
125
|
["ahub research addons [--since 30d] [--all] [--json]", "approved tasks with each add-on (claude-mem, ponytail, ripwire, ...) on against off, with intervals; association, not cause"],
|
|
125
126
|
["ahub research export [--format jsonl|csv] [--since 30d] [--all]", "the research records for outside analysis: ids, counts and tokens, never text"],
|
package/src/cli/identity.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { conductorOperation, OPERATIONAL_PERSON_GUIDANCE } from "../hub/operational-authority.ts";
|
|
1
2
|
import { nativeCliMarkerPeer } from "../adapters/definitions/native-identity-defaults.ts";
|
|
2
3
|
import { recoveryPresentationFlag } from "./recovery-presentation-options.ts";
|
|
3
4
|
import { definitionOf, kindOf, type KindMap, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
@@ -14,7 +15,7 @@ export const LIVE_TEST_PERSON_REFUSAL = "live-test cannot write person-attribute
|
|
|
14
15
|
|
|
15
16
|
/** Person verdicts and user-authored workflow content are never delegated. */
|
|
16
17
|
export function liveTestRefusal(command: string, args: readonly string[], context: { repairedFresh?: boolean } = {}): boolean {
|
|
17
|
-
if (command === "sdlc" && (args[0] === "authorize-release" || args[0] === "plan-review" || args[0] === "release-assign" || args[0] === "train" && args[1] !== "report")) return true;
|
|
18
|
+
if (command === "sdlc" && (args[0] === "authorize-release" || args[0] === "plan-review" || args[0] === "release-assign" || args[0] === "merge" || args[0] === "train" && args[1] !== "report")) return true;
|
|
18
19
|
if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
|
|
19
20
|
const sub = command === "recovery" ? recoveryAction(args) : args.find(arg => arg !== "--json");
|
|
20
21
|
return ["task", "role", "goal", "deliverables"].includes(command)
|
|
@@ -53,15 +54,85 @@ export function detectCliIdentity(env: Readonly<Record<string, string | undefine
|
|
|
53
54
|
|
|
54
55
|
export type PeerCommandAccess = "allowed" | "conductor" | "console";
|
|
55
56
|
|
|
56
|
-
/**
|
|
57
|
+
/** Flags the read-only forms of #812 accept: display options that change no behaviour. */
|
|
58
|
+
const peerReadFlags = (args: readonly string[]): boolean =>
|
|
59
|
+
args.every((arg) => ["--json", "--full", "--verbose", "--color=always", "--color=never", "--color=auto"].includes(arg) || arg.startsWith("--color="));
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The read-only command forms #812 makes Allowed: command -> subcommand -> what the rest of argv may hold.
|
|
63
|
+
* positional: bare words naming what to read (an operation id, a task, a release, a repo);
|
|
64
|
+
* valueFlags: options consuming one value an agent may name (--peer, --to, --since).
|
|
65
|
+
* A subcommand of "" is the bare form: display flags only. dryRun is checked separately for upgrade and restart.
|
|
66
|
+
*/
|
|
67
|
+
interface PeerReadForm {
|
|
68
|
+
positional?: number;
|
|
69
|
+
minimum?: number;
|
|
70
|
+
valueFlags?: readonly string[];
|
|
71
|
+
displayFlags?: readonly string[];
|
|
72
|
+
}
|
|
73
|
+
const publicReadDisplayFlags = ["--json", "--full", "--color=auto", "--color=always", "--color=never"] as const;
|
|
74
|
+
const sdlcReadDisplayFlags = ["--json", "--color=auto", "--color=always", "--color=never"] as const;
|
|
75
|
+
const peerReadForms: Readonly<Record<string, Readonly<Record<string, PeerReadForm>>>> = {
|
|
76
|
+
queue: { list: { valueFlags: ["--peer"], displayFlags: publicReadDisplayFlags } },
|
|
77
|
+
roster: { "": { displayFlags: publicReadDisplayFlags } },
|
|
78
|
+
settings: { "": { displayFlags: ["--json"] }, list: { displayFlags: ["--json"] }, get: { positional: 1, minimum: 1, displayFlags: ["--json"] } },
|
|
79
|
+
// #814 renders these reads for people and keeps JSON as an explicit data format.
|
|
80
|
+
sdlc: { status: { positional: 1, displayFlags: sdlcReadDisplayFlags }, explain: { positional: 1, displayFlags: sdlcReadDisplayFlags }, queue: { positional: 1, minimum: 1, displayFlags: sdlcReadDisplayFlags }, release: { positional: 33, minimum: 1, displayFlags: sdlcReadDisplayFlags } },
|
|
81
|
+
release: { show: { positional: 1, minimum: 1, displayFlags: [] } },
|
|
82
|
+
projects: { "": {} },
|
|
83
|
+
models: { status: {} },
|
|
84
|
+
recovery: { status: { positional: 1 } },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Whether argv matches one Allowed read-form of this command: display flags, the subcommand shape's values only. */
|
|
88
|
+
const readFormArgs = (args: readonly string[], { valueFlags = [], positional = 0, minimum = 0, displayFlags }: PeerReadForm = {}): boolean => {
|
|
89
|
+
let positionals = 0;
|
|
90
|
+
let expectValue: string | undefined;
|
|
91
|
+
for (const arg of args) {
|
|
92
|
+
if (expectValue !== undefined) {
|
|
93
|
+
if (arg.startsWith("-")) return false;
|
|
94
|
+
expectValue = undefined;
|
|
95
|
+
} else if (valueFlags.includes(arg)) {
|
|
96
|
+
expectValue = arg;
|
|
97
|
+
} else if (arg.startsWith("--")) {
|
|
98
|
+
if (displayFlags === undefined ? !peerReadFlags([arg]) : !displayFlags.includes(arg)) return false;
|
|
99
|
+
} else if (positionals >= positional) {
|
|
100
|
+
return false;
|
|
101
|
+
} else {
|
|
102
|
+
positionals++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return expectValue === undefined && positionals >= minimum;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const isPeerReadForm = (command: string, args: readonly string[]): boolean => {
|
|
109
|
+
const subs = peerReadForms[command];
|
|
110
|
+
if (subs === undefined) return false;
|
|
111
|
+
// The bare form ("") carries no subcommand: args empty or all display flags; otherwise args[0] is the subcommand.
|
|
112
|
+
const first = args[0];
|
|
113
|
+
const bare = first === undefined || first.startsWith("--");
|
|
114
|
+
const sub = bare ? "" : first;
|
|
115
|
+
if (!Object.hasOwn(subs, sub)) return false;
|
|
116
|
+
return readFormArgs(bare ? args : args.slice(1), subs[sub]!);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A closed list: unknown commands cannot acquire console authority through an agent shell.
|
|
121
|
+
* #812: the read-only forms matched by the branches just above the catch-alls are Allowed for any agent.
|
|
122
|
+
* The branches above them are the older authority rules and stay unchanged.
|
|
123
|
+
*/
|
|
57
124
|
export function classifyPeerCommand(command: string, args: readonly string[], catalog?: ProjectAdapterCatalog): PeerCommandAccess {
|
|
58
125
|
if (command === "recovery" && recoveryAction(args) === "reattach") return "console";
|
|
59
126
|
const native = catalog?.definitionOf(command) ?? definitionOf(command);
|
|
60
127
|
if (native?.cli.personOnlyFlags?.some(flag => args.includes(flag))) return "console";
|
|
61
128
|
if (command === "pet" && args.includes("--window")) return "console";
|
|
62
|
-
if (command === "permission" || command === "
|
|
129
|
+
if (command === "permission" || command === "watch") return "console";
|
|
63
130
|
if (args.some((arg) => arg === "--as-user" || arg.startsWith("--as-user="))) return "console";
|
|
131
|
+
if (conductorOperation(command,args)) return "conductor";
|
|
132
|
+
if (command === "stop") return "console";
|
|
64
133
|
if (command === "sdlc" && ["release-guard", "release-guard-hook"].includes(args[0] ?? "")) return "allowed";
|
|
134
|
+
// #786: the guarded merge is the person's or the configured conductor's; the daemon refuses any other peer by role.
|
|
135
|
+
if (command === "sdlc" && args[0] === "merge") return "conductor";
|
|
65
136
|
if (command === "role") return ["list", "show"].includes(args[0] ?? "") ? "allowed" : "console";
|
|
66
137
|
if (command === "task") {
|
|
67
138
|
if (["propose", "show"].includes(args[0] ?? "")) return "allowed";
|
|
@@ -82,6 +153,16 @@ export function classifyPeerCommand(command: string, args: readonly string[], ca
|
|
|
82
153
|
// The subcommand is the first argument that is not --json, which the command accepts anywhere.
|
|
83
154
|
if (command === "bench") return args.find((arg) => arg !== "--json") === "run" ? "console" : "allowed";
|
|
84
155
|
if (command === "budget") return !args.length || args.every((arg) => arg === "--json" || arg === "--full" || arg.startsWith("--color=")) ? "allowed" : "console";
|
|
156
|
+
// #812 Allowed: the read-only forms below print what the person sees and change nothing. Each command's
|
|
157
|
+
// other forms fall to the existing rules underneath, so every older branch above and below keeps its class.
|
|
158
|
+
if (command === "doctor") {
|
|
159
|
+
if (peerReadFlags(args)) return "allowed"; // no args, or only --json, --full, --color=...
|
|
160
|
+
if (args[0] === "--stale-anchors" && peerReadFlags(args.slice(1))) return "allowed"; // a bounded read-only anchor report
|
|
161
|
+
// --orphans (it can kill) and anything else stay as today.
|
|
162
|
+
}
|
|
163
|
+
if ((command === "upgrade" || command === "restart") && !args.includes("--yes") && args.includes("--dry-run")
|
|
164
|
+
&& readFormArgs(args.filter((arg) => arg !== "--dry-run"), { valueFlags: ["--to"] })) return "allowed"; // a preview that applies nothing; --yes stays console
|
|
165
|
+
if (isPeerReadForm(command, args)) return "allowed"; // exact read forms; every mutating neighbour remains below its existing authority gate
|
|
85
166
|
if (native?.cli.authority === "person") return "console";
|
|
86
167
|
if (native || ["pause", "resume"].includes(command)) return "conductor";
|
|
87
168
|
if (command === "board" && args[0] === "import") return "console";
|
|
@@ -95,9 +176,9 @@ export function cliCommandLabel(command: string, args: readonly string[], catalo
|
|
|
95
176
|
if (command === "board" && args[0] === "import") return "board import";
|
|
96
177
|
if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
|
|
97
178
|
if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor", "agents"].includes(command)) return "unknown";
|
|
98
|
-
if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench", "agents"].includes(command)) {
|
|
179
|
+
if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench", "agents", "roster", "settings"].includes(command)) {
|
|
99
180
|
const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
|
|
100
|
-
if (sub && ["propose", "show", "assign", "unassign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "reattach", "setup", "start", "stop", "remove", "discard", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "decay", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take", "trust"].includes(sub)) return `${command} ${sub}`;
|
|
181
|
+
if (sub && ["propose", "show", "assign", "unassign", "escalate", "cancel", "label", "list", "add", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "reattach", "setup", "start", "stop", "remove", "discard", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "decay", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take", "trust"].includes(sub)) return `${command} ${sub}`;
|
|
101
182
|
}
|
|
102
183
|
return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
|
|
103
184
|
}
|
|
@@ -122,5 +203,5 @@ export function agentShellCause(identity: CliIdentity, error: string, env: Reado
|
|
|
122
203
|
|
|
123
204
|
export function peerCommandRefusal(peer: string, command: string, env: Readonly<Record<string, string | undefined>> = process.env): string {
|
|
124
205
|
const markers = ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "AGENTHUB_PEER_ID", "CODEX_THREAD_ID"].filter(name => env[name] !== undefined);
|
|
125
|
-
return `${peer} cannot run ahub ${command};
|
|
206
|
+
return `${peer} cannot run ahub ${command}; ${OPERATIONAL_PERSON_GUIDANCE}. Refused because inherited agent markers${markers.length ? ` (${markers.join(", ")})` : ""} identify this shell as an agent. A ! command in an agent session runs as that agent; use a separate terminal outside the agent session`;
|
|
126
207
|
}
|