@staix/agent-hub 0.15.0 → 0.17.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 +43 -0
- package/README.md +12 -8
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +24 -8
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/daemon.md +11 -4
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +15 -3
- package/docs/agent-notes/tests.md +8 -3
- package/docs/dogfood.md +18 -6
- package/docs/events.md +1 -1
- package/docs/memory.md +78 -10
- package/docs/operations.md +160 -47
- package/docs/quickstart.md +13 -5
- package/docs/roles.md +1 -0
- package/docs/sdlc.md +11 -11
- package/docs/security.md +94 -22
- package/docs/smoke.md +58 -6
- package/docs/specs/2026-09-19-agent-hub-design.md +124 -12
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
- package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
- package/docs/verification/2026-10-24-amem-runtime.md +69 -0
- package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
- package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
- package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
- package/docs/verification/2026-10-25-0.16.0.md +82 -0
- package/docs/verification/2026-10-25-0.17.0.md +74 -0
- package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -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-reviewer-no-verdict-diagnostics.md +35 -0
- package/docs/verified.json +30 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3421 -2176
- package/src/adapters/acp.ts +5 -2
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/codex-review-isolation.ts +10 -1
- package/src/adapters/compat.ts +9 -4
- 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 +3 -3
- 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 -4
- package/src/adapters/qwen-dual.ts +578 -0
- package/src/adapters/registry-types.ts +6 -2
- 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 +20 -2
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +5 -1
- package/src/cli/help.ts +7 -5
- package/src/cli/identity.ts +3 -1
- package/src/cli/main.ts +44 -10
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +12 -4
- package/src/cli/upgrade.ts +4 -3
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +13 -5
- package/src/hub/action-policy.ts +19 -8
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +86 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +42 -10
- package/src/hub/bus.ts +1 -1
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +7 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +384 -71
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -2
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/kimi-usage.ts +12 -0
- package/src/hub/lifecycle.ts +10 -5
- package/src/hub/memory-code-head.ts +44 -0
- package/src/hub/memory-code-overlay.ts +119 -0
- package/src/hub/memory-plan-guard.ts +11 -27
- package/src/hub/memory-stale-anchors.ts +159 -0
- package/src/hub/memory-symbols.ts +74 -0
- package/src/hub/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +17 -2
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +378 -76
- package/src/hub/token-window.ts +15 -2
- package/src/hub/window-kind.ts +13 -0
- package/src/local/tools.ts +5 -5
- package/src/memory/brief.ts +66 -16
- package/src/memory/capture.ts +26 -40
- package/src/memory/client.ts +137 -65
- package/src/memory/recall.ts +13 -22
- package/src/ui/index.html +100 -18
|
@@ -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
|
@@ -10,6 +10,7 @@ import type { SettingRow, SettingValue } from "../hub/settings.ts";
|
|
|
10
10
|
import { symbolFields, overlapFields } from "./console-symbols.ts";
|
|
11
11
|
import { choicePrompt, reviewChoiceFor, reviewHint, reviewRefusal, verdictGuide, verdictInput, type ReviewChoice, type Verdict } from "./console-review.ts";
|
|
12
12
|
import { configFormPrompt, reduceConfigForm, type ConfigEditBody, type ConfigForm } from "./console-config.ts";
|
|
13
|
+
import { windowKind } from "../hub/window-kind.ts";
|
|
13
14
|
import { USAGE_DROP, USAGE_HEAD, usageCells, usageDetail, usagePeers, usageTaskLines, type UsageState } from "./console-usage.ts";
|
|
14
15
|
|
|
15
16
|
export interface Approval {
|
|
@@ -334,8 +335,25 @@ function taskFields(detail: Record<string, unknown>): [string, unknown][] {
|
|
|
334
335
|
const at = fields.findIndex(([key]) => key === "plan");
|
|
335
336
|
return at < 0 ? [...fields, ...block] : [...fields.slice(0, at + 1), ...block, ...fields.slice(at + 1)];
|
|
336
337
|
}
|
|
337
|
-
|
|
338
|
-
|
|
338
|
+
/**
|
|
339
|
+
* The Peers panel's detail row carries the whole peer, `budget` included. Its windows read as one line each: `5h 23% resets in 10m, native, claude status line, measured 6s ago`,
|
|
340
|
+
* so a person sees what each number is and where it came from (#285 AC7). A window without a kind (an older hub) is classified like the CLI does.
|
|
341
|
+
*/
|
|
342
|
+
export function budgetFields(budget: any, now: number): [string, unknown][] {
|
|
343
|
+
if (!budget || typeof budget !== "object") return [];
|
|
344
|
+
const windows = Array.isArray(budget.windows) ? budget.windows : [];
|
|
345
|
+
const lines = windows.map((w: any) => {
|
|
346
|
+
const stale = w.stale ? " (stale)" : w.superseded ? " (information only: a measurement outranks it)" : "";
|
|
347
|
+
return `${w.id} ${Math.round(Number(w.used) * 100)}%${typeof w.resetsAt === "number" ? ` resets ${relative(w.resetsAt, now)}` : ""}, ${windowKind({ id: String(w.id), source: typeof w.source === "string" ? w.source : "", kind: w.kind })}, ${typeof w.source === "string" ? w.source : "unknown source"}${typeof w.at === "number" ? `, measured ${relative(w.at, now)}` : ""}${stale}`;
|
|
348
|
+
});
|
|
349
|
+
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
|
+
const detailLines = (detail: Detail, columns: number, now: number, task = false): Span[][] => {
|
|
352
|
+
if (typeof detail === "string") return wrap(detail, columns).map(text => [span(text)]);
|
|
353
|
+
const { budget, ...rest } = detail as Record<string, unknown>;
|
|
354
|
+
const entries: [string, unknown][] = task ? taskFields(detail) : "budget" in detail && !task ? [...Object.entries(rest), ...budgetFields(budget, now)] : Object.entries(detail);
|
|
355
|
+
return fieldLines(entries, columns, now).flat();
|
|
356
|
+
};
|
|
339
357
|
const allowOptions = (a: Approval) => a.options.filter(o => o.kind.startsWith("allow"));
|
|
340
358
|
/** A pushed request as the console keeps it: every agent-supplied field a string, so no draw can throw on one. */
|
|
341
359
|
export function approvalFrom(msg: any): Approval {
|
|
@@ -40,8 +40,9 @@ export function classifySymbols(task: any): SymbolBlock {
|
|
|
40
40
|
export function symbolFields(task: any): [string, unknown][] {
|
|
41
41
|
// Only a task as `task_show` shapes it (a numeric id and a state): any other detail an agent or a test hands the Tasks panel keeps its own layout.
|
|
42
42
|
if (!task || typeof task.id !== "number" || typeof task.state !== "string" || isPrivate(task)) return [];
|
|
43
|
+
const related: [string, unknown][] = task.relatedMemory ? [["related notes", task.relatedMemory.state === "known" && Number.isSafeInteger(task.relatedMemory.count) && task.relatedMemory.count >= 0 ? task.relatedMemory.count : "unknown"]] : [];
|
|
43
44
|
const block = classifySymbols(task);
|
|
44
|
-
if (block.state === "unknown") return [["symbols", "changed symbols: unknown (no code facts)"]];
|
|
45
|
+
if (block.state === "unknown") return [...related, ["symbols", "changed symbols: unknown (no code facts)"]];
|
|
45
46
|
const items: Record<string, string>[] = [
|
|
46
47
|
...block.matching.map(s => ({ matching: s.name, in: s.path })), ...block.unplanned.map(s => ({ unplanned: s.name, in: s.path })), ...block.untouched.map(name => ({ untouched: name })),
|
|
47
48
|
];
|
|
@@ -49,7 +50,7 @@ export function symbolFields(task: any): [string, unknown][] {
|
|
|
49
50
|
const note = [`changed symbols: ${block.matching.length} matching, ${block.unplanned.length} unplanned, ${block.untouched.length} untouched${block.head ? ` at ${block.head}` : ""}`,
|
|
50
51
|
block.complete ? "" : "incomplete: some changed symbols were not listed, so an untouched one may have changed",
|
|
51
52
|
"advisory: deleted symbols are not reported"].filter(Boolean).join("; ");
|
|
52
|
-
return [["symbols", shown.length ? shown : "none observed"], ["symbols note", items.length > shown.length ? `${note}; ${items.length - shown.length} more not shown` : note]];
|
|
53
|
+
return [...related, ["symbols", shown.length ? shown : "none observed"], ["symbols note", items.length > shown.length ? `${note}; ${items.length - shown.length} more not shown` : note]];
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
|
package/src/cli/console-usage.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { windowKind, type WindowKind } from "../hub/window-kind.ts";
|
|
1
2
|
import type { Counter, LimitsResult, UsageResult, UsageRow } from "../hub/usage-rollup.ts";
|
|
2
3
|
|
|
3
4
|
// #683 U3: what the console's Usage panel shows, as plain strings. No terminal code and no state import here: console-state.ts renders it.
|
|
@@ -32,7 +33,7 @@ export function usagePeers(usage: UsageState | undefined, budget: Record<string,
|
|
|
32
33
|
return [...ids].sort().map(id => ({ id }));
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
interface Budget { windows?: { id: string; used: number; resetsAt?: number }[]; paused?: { resetsAt: number } }
|
|
36
|
+
interface Budget { windows?: { id: string; used: number; resetsAt?: number; kind?: string; source?: string }[]; paused?: { resetsAt: number }; note?: string }
|
|
36
37
|
const windowText = (w: { id: string; used: number; resetsAt?: number }, rel: (at: number) => string) =>
|
|
37
38
|
`${w.id} ${Math.round(w.used * 100)}%${typeof w.resetsAt === "number" ? ` ${rel(w.resetsAt)}` : ""}`;
|
|
38
39
|
const lastResets = (limits: LimitsResult | undefined, peer: string, count: number) =>
|
|
@@ -66,7 +67,9 @@ export function usageDetail(usage: UsageState | undefined, peer: string, budget:
|
|
|
66
67
|
const resets = lastResets(usage?.limits, peer, 3).map(h => `${h.window} ${rel(Date.parse(h.at))}`);
|
|
67
68
|
const out: Record<string, string> = {
|
|
68
69
|
peer,
|
|
69
|
-
quota: budget?.windows?.length ? budget.windows.map(w => windowText(w, rel)).join(", ") : "no reading",
|
|
70
|
+
quota: budget?.windows?.length ? budget.windows.map(w => windowText(w, rel)).join(", ") : budget?.note ?? "no reading",
|
|
71
|
+
// What each number is and where it came from (#285 AC7), in its own field so the quota line keeps its shape.
|
|
72
|
+
...(budget?.windows?.length ? { "quota sources": budget.windows.map(w => `${w.id} ${windowKind({ id: w.id, source: w.source, kind: w.kind as WindowKind | undefined })}${w.source ? `, ${w.source}` : ""}`).join("; ") } : {}),
|
|
70
73
|
pause: budget?.paused ? `budget pause, resumes ${rel(budget.paused.resetsAt)}` : "none",
|
|
71
74
|
"last resets": resets.length ? resets.join(", ") : "none recorded",
|
|
72
75
|
today: splitText(rowOf(usage?.today, peer)),
|
package/src/cli/console.ts
CHANGED
|
@@ -226,7 +226,11 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
226
226
|
state.recoveryNeed = status.status.recovery ? { id: status.status.recovery.operationId, running: true } : undefined;
|
|
227
227
|
}
|
|
228
228
|
if (syncPermissionDefaults(state, status.status?.permissionDefaults) && state.confirm?.type === "permission_default") stream({ text: permissionDefaultText(state.confirm), kind: "permission_default", peer: state.confirm.peer, tone: "attention" });
|
|
229
|
-
if (budget.budget)
|
|
229
|
+
if (budget.budget) {
|
|
230
|
+
// #285: the hub's note per peer (no quota, nothing read yet) rides next to the windows, so the peer detail says why a number is missing.
|
|
231
|
+
const notes = new Map<string, any>((Array.isArray(budget.peers) ? budget.peers : []).map((n: any) => [String(n?.peer), n] as const));
|
|
232
|
+
state.budget = Object.fromEntries([...new Set([...Object.keys(budget.budget), ...notes.keys()])].map(peer => [peer, { windows: [], ...budget.budget[peer], ...(notes.get(peer)?.note ? { note: String(notes.get(peer).note) } : {}) }]));
|
|
233
|
+
}
|
|
230
234
|
if (usageReplies) {
|
|
231
235
|
const [today, week, life, limits, task] = usageReplies, failed = usageReplies.find(reply => reply?.ok === false);
|
|
232
236
|
if (failed || !today?.usage || !week?.usage || !life?.usage || !limits?.limits) state.usage = { ...state.usage, problem: String(failed?.error ?? "usage is unavailable") };
|
package/src/cli/help.ts
CHANGED
|
@@ -40,7 +40,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
40
40
|
["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
|
|
41
41
|
["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
|
|
42
42
|
["ahub logs [-f]", "the last 100 lines of hub.log; -f shows the last 10 and follows it"],
|
|
43
|
-
["ahub doctor [--json] [--full] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models and memory
|
|
43
|
+
["ahub doctor [--json] [--full] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models and memory library this project uses"],
|
|
44
|
+
["ahub doctor --stale-anchors [--json]", "read-only bounded report of public memory symbol anchors at current HEAD; private or unavailable evidence stays unknown"],
|
|
44
45
|
["ahub doctor --orphans [--kill] [--json] [--color=auto|always|never]", "list registrations whose project root is gone; --kill stops their daemons (SIGTERM, then SIGKILL) only after the process identity checks out"],
|
|
45
46
|
["ahub agents [--json]", "show each gated agent CLI's installed version and verdict (verified, in-line, trusted, refused or unknown)"],
|
|
46
47
|
["ahub agents trust <id> <version> [--revoke]", "accept an untested agent CLI version on this machine (person only; runtime proofs still run)"],
|
|
@@ -84,14 +85,15 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
84
85
|
["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
|
|
85
86
|
["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"],
|
|
86
87
|
["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"],
|
|
87
|
-
["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:
|
|
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)"],
|
|
88
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"],
|
|
89
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"],
|
|
90
91
|
["ahub backlog pull-ready <id> <revision> <reason...>", "person only: reserve an eligible WIP slot before routing; pending unowned work can be undone"],
|
|
91
92
|
["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
|
+
["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"],
|
|
92
94
|
["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
|
|
93
95
|
["ahub task role <id> <role|none>", "person only: change bounded catalog guidance on an open task; routing and authority are unchanged"],
|
|
94
|
-
["ahub task assign <id> <peer> [--role owner|reviewer] [--revision N]", "name the owner (default) or
|
|
96
|
+
["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"],
|
|
95
97
|
["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"],
|
|
96
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"],
|
|
97
99
|
["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)"],
|
|
@@ -103,7 +105,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
103
105
|
["ahub remember <text...>", "save a note to the memory all agents share"],
|
|
104
106
|
]],
|
|
105
107
|
["Budget", [
|
|
106
|
-
["ahub budget [--json] [--full] [--color=auto|always|never]", "quota windows per peer, and who is paused until when"],
|
|
108
|
+
["ahub budget [--json] [--full] [--color=auto|always|never]", "quota windows per peer with each number's kind (native, count, manual) and source, why a peer shows no number, and who is paused until when"],
|
|
107
109
|
["ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week]", "feed a reading by hand (also: test the relay)"],
|
|
108
110
|
["ahub budget resume <peer>", "override a budget pause; readings are ignored for that peer until the window resets"],
|
|
109
111
|
["ahub budget execution status [id] [--json] [--color=auto|always|never]", "read execution counters and limits"],
|
|
@@ -129,7 +131,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
129
131
|
["ahub bench export [--format jsonl|csv]", "benchmark records for outside analysis: ids, outcomes and counts, never suite text"],
|
|
130
132
|
["ahub check-path <file> [--peer <id>]", "other owners' open tasks that claim or changed a file"],
|
|
131
133
|
["ahub memory [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] [--json]", "the hub's own memory for this project: decisions, contracts, fails and findings, short- and long-term (docs/memory.md)"],
|
|
132
|
-
["ahub memory search <words> | show <id> | graph <id|node> [--hops 1|2] [--json]", "find entries, read one with its provenance, or list its graph neighbourhood"],
|
|
134
|
+
["ahub memory search <words> | show <id> | graph <id|node> [--hops 1|2] [--history | --as-of <iso>] [--json]", "find entries, read one with its provenance, or list its graph neighbourhood; superseded and expired entries are hidden unless a person asks for --history or --as-of"],
|
|
133
135
|
["ahub memory promote|demote|forget <id> | supersede <old> <new> | rebuild", "a person's changes to memory: move an entry between tiers, forget it, mark it superseded, or rebuild the index from the logs"],
|
|
134
136
|
["ahub memory sync [--remote <private git url>] | machine [--label <name>]", "sync hub memory with your other machines through a private git remote (a person's), or show or name this machine"],
|
|
135
137
|
["ahub memory import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <id>", "copy the hub's notes from claude-mem into hub memory (a person's); review the model's suggested edges"],
|
package/src/cli/identity.ts
CHANGED
|
@@ -17,7 +17,7 @@ export function liveTestRefusal(command: string, args: readonly string[], contex
|
|
|
17
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
18
|
if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
|
|
19
19
|
const sub = command === "recovery" ? recoveryAction(args) : args.find(arg => arg !== "--json");
|
|
20
|
-
return ["task", "role", "goal"].includes(command)
|
|
20
|
+
return ["task", "role", "goal", "deliverables"].includes(command)
|
|
21
21
|
|| (command === "backlog" && sub !== "list") || (command === "release" && !["list", "show"].includes(sub ?? ""))
|
|
22
22
|
|| (command === "memory" && sub !== "graph")
|
|
23
23
|
|| (command === "recovery" && (sub === "reattach" || sub === "dispose" && context.repairedFresh === true))
|
|
@@ -68,6 +68,8 @@ export function classifyPeerCommand(command: string, args: readonly string[], ca
|
|
|
68
68
|
if (["assign", "escalate"].includes(args[0] ?? "")) return "conductor";
|
|
69
69
|
return "console";
|
|
70
70
|
}
|
|
71
|
+
// #317: the deliverable list is the person's view of the board (it can name files); an agent reads its own tasks through hub_task_show.
|
|
72
|
+
if (command === "deliverables") return "console";
|
|
71
73
|
if (command === "route") return args[0] === "explain" ? "allowed" : "console";
|
|
72
74
|
// #691: reading agent CLI versions is harmless; trusting an untested version on this machine is a person's.
|
|
73
75
|
if (command === "agents") return args.every(arg => arg === "--json") ? "allowed" : "console";
|
package/src/cli/main.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import type { DeliverableRow } from "../hub/tasks.ts";
|
|
3
|
+
import { kimiSourceRow } from "../hub/budget-notes.ts";
|
|
2
4
|
import { hubGitSync, hubGitEnv } from "../hub/git.ts";
|
|
3
5
|
import { parseReleaseGuardArgv, parseReleasePushUpdates } from "./sdlc-release-guard.ts";
|
|
4
6
|
import { validReleaseVersion } from "../hub/sdlc-release-authorization.ts";
|
|
@@ -46,7 +48,6 @@ import { inspectProject, startProject, stopProject, runProjectDaemon } from "../
|
|
|
46
48
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
47
49
|
import { openManager, startManager, stopManager } from "../hub/manager.ts";
|
|
48
50
|
import { OmniRoute } from "../omniroute/client.ts";
|
|
49
|
-
import { MemoryClient } from "../memory/client.ts";
|
|
50
51
|
import { init, planInit } from "./init.ts";
|
|
51
52
|
import { launcherPreview } from "./preview.ts";
|
|
52
53
|
import { instanceLaunchArguments, requirePeerInstances, requireActiveInstance, cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
|
|
@@ -75,8 +76,9 @@ import { parseSince, readEvents } from "../hub/events.ts";
|
|
|
75
76
|
import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, MEMORY_MODES, readRuns, runSummary, sameSuite, type MemoryMode } from "../hub/bench.ts";
|
|
76
77
|
import { benchPreflight, defaultIo, runBench } from "./bench.ts";
|
|
77
78
|
import { ADDONS, isAddon, type AddonName } from "../hub/tooling.ts";
|
|
78
|
-
import { goalCommand, memoryCommand } from "./memory.ts";
|
|
79
|
-
import {
|
|
79
|
+
import { goalCommand, memoryCommand, memoryStaleAnchorsCommand } from "./memory.ts";
|
|
80
|
+
import { formatMemoryStaleAnchors } from "../hub/memory-stale-anchors.ts";
|
|
81
|
+
import { AGENT_MEM_VERSION, assertAgentMemVersion, selectedMemoryProfile } from "../hub/memory-profile.ts";
|
|
80
82
|
import { checkMemoryProfiles, formatMemoryProfileCheck } from "../hub/memory-profile-check.ts";
|
|
81
83
|
import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
|
|
82
84
|
import { summarize, summarizeByTask } from "../hub/report.ts";
|
|
@@ -1140,7 +1142,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1140
1142
|
const res = await hub.request({ t: "budget", ...(set ? { set } : {}) });
|
|
1141
1143
|
hub.close();
|
|
1142
1144
|
if (!res.ok) fail(res.error);
|
|
1143
|
-
const data = { budget: res.budget, gate: res.gate };
|
|
1145
|
+
const data = { budget: res.budget, ...(res.peers ? { peers: res.peers } : {}), gate: res.gate };
|
|
1144
1146
|
if (options.json) return console.log(JSON.stringify(data, null, 2));
|
|
1145
1147
|
printOutput(renderBudget(data, options.columns, Date.now(), options.full), options.color);
|
|
1146
1148
|
},
|
|
@@ -1195,6 +1197,27 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1195
1197
|
console.log(await taskOp("release_change", { name, revision: Number(revision), fields }));
|
|
1196
1198
|
},
|
|
1197
1199
|
|
|
1200
|
+
deliverables: async () => {
|
|
1201
|
+
// #317: what each task delivered and what a reviewer approved, by id, hash and size (a path only where the PII screen allows it). Person only: the daemon refuses an agent.
|
|
1202
|
+
const options = outputOptions();
|
|
1203
|
+
const { one, rest } = takeFlags(args, ["--task"], []);
|
|
1204
|
+
const approved = rest.includes("--approved"), check = rest.includes("--check");
|
|
1205
|
+
const extra = rest.filter(arg => !["--json", "--full", "--approved", "--check"].includes(arg) && !arg.startsWith("--color="));
|
|
1206
|
+
const task = one["--task"] === undefined ? undefined : Number(one["--task"]);
|
|
1207
|
+
// takeFlags keeps the last of a repeated flag: for a command that selects a task that would silently drop one of two named tasks.
|
|
1208
|
+
if (args.filter(arg => arg === "--task").length > 1) fail("usage: ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never] (--task takes one id)");
|
|
1209
|
+
if (extra.length || (one["--task"] !== undefined && !/^[1-9]\d*$/.test(one["--task"])) || (task !== undefined && !Number.isSafeInteger(task))) fail("usage: ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never]");
|
|
1210
|
+
const rows = JSON.parse(await taskOp("deliverables", { ...(task === undefined ? {} : { task }), ...(approved ? { approved: true } : {}), ...(check ? { check: true } : {}) })) as DeliverableRow[];
|
|
1211
|
+
if (options.json) return console.log(JSON.stringify(rows, null, 2));
|
|
1212
|
+
if (!rows.length) return console.log(task === undefined ? `no ${approved ? "approved " : ""}deliverables on the board` : `task #${task} has no ${approved ? "approved " : ""}deliverables`);
|
|
1213
|
+
for (const row of rows) {
|
|
1214
|
+
const flag = row.checked ? ({ changed: " (changed since review)", missing: " (a file is missing)", unknown: " (could not be measured)", unchanged: " (unchanged)" } as const)[row.result ?? "unknown"] : "";
|
|
1215
|
+
console.log(`${row.title} [${row.state}${flag}]`); // `title` is the hub's publicTitle: it already starts with `#<id>`
|
|
1216
|
+
for (const file of row.files) console.log(` ${file.id} ${file.path ?? "(private)"} ${file.format}${file.version ? ` ${file.version}` : ""} ${file.bytes} B sha256 ${file.sha256}${file.state ? ` ${file.state}` : ""}`);
|
|
1217
|
+
}
|
|
1218
|
+
if (!check) console.log("(hashes are the board's records; add --check to measure the files now)");
|
|
1219
|
+
},
|
|
1220
|
+
|
|
1198
1221
|
board: async () => {
|
|
1199
1222
|
if (args[0] === "import") {
|
|
1200
1223
|
if (args[1] !== "github" || (args.length - 2) % 2 !== 0) fail("usage: ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]");
|
|
@@ -1380,8 +1403,8 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1380
1403
|
if (sub === "assign") {
|
|
1381
1404
|
const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
|
|
1382
1405
|
const role = one["--role"] ?? "owner", rawRevision = one["--revision"], revision = rawRevision === undefined ? undefined : Number(rawRevision);
|
|
1383
|
-
if (positional.length !== 2 || !["owner", "reviewer"].includes(role) || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(revision)))) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer] [--revision N]");
|
|
1384
|
-
return console.log(await taskOp(revision === undefined ? "task_assign" : "task_assign_checked", { id: positional[0], peer: positional[1], ...(role
|
|
1406
|
+
if (positional.length !== 2 || !["owner", "reviewer", "planner"].includes(role) || (rawRevision !== undefined && (!/^\d+$/.test(rawRevision) || !Number.isSafeInteger(revision)))) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer|planner] [--revision N]");
|
|
1407
|
+
return console.log(await taskOp(revision === undefined ? "task_assign" : "task_assign_checked", { id: positional[0], peer: positional[1], ...(role !== "owner" ? { role } : {}), ...(revision === undefined ? {} : { revision }) }));
|
|
1385
1408
|
}
|
|
1386
1409
|
if (sub === "unassign") {
|
|
1387
1410
|
const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
|
|
@@ -2101,6 +2124,12 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
2101
2124
|
|
|
2102
2125
|
doctor: async () => {
|
|
2103
2126
|
const options = outputOptions();
|
|
2127
|
+
if (args.includes("--stale-anchors")) {
|
|
2128
|
+
if (args.some(arg => arg !== "--stale-anchors" && arg !== "--json")) fail("usage: ahub doctor --stale-anchors [--json]");
|
|
2129
|
+
const report = await memoryStaleAnchorsCommand(cwd, { stateDir });
|
|
2130
|
+
console.log(options.json ? JSON.stringify(report, null, 2) : formatMemoryStaleAnchors(report));
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2104
2133
|
if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"), options);
|
|
2105
2134
|
const version = (bin: string) => {
|
|
2106
2135
|
const res = spawnSync(bin, ["--version"], { encoding: "utf8" });
|
|
@@ -2128,6 +2157,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
2128
2157
|
const { status } = await hub.request({ t: "status" });
|
|
2129
2158
|
const pending = Object.values(status.peers as Record<string, PeerRow>).reduce((n, peer) => n + (peer.needsReview ?? 0), 0);
|
|
2130
2159
|
row(!status.deliveryError && pending === 0, "delivery recovery", status.deliveryError ?? (pending ? `${pending} deliveries need review; run ahub queue list` : "journal healthy; no deliveries need review"));
|
|
2160
|
+
// #285 AC10: where Kimi's number comes from, and whether the hub can trust it right now. An older hub answers without `kimiSource`: no row, not a failure.
|
|
2161
|
+
const quota = await hub.request({ t: "budget" });
|
|
2162
|
+
const kimiRow = quota.ok ? kimiSourceRow(quota.kimiSource) : undefined;
|
|
2163
|
+
if (kimiRow) row(kimiRow.ok, "kimi usage source", kimiRow.detail);
|
|
2131
2164
|
const observed = await hub.request({ t: "recovery", op: "inspect", expectedInstanceId: status.instanceId });
|
|
2132
2165
|
for (const peer of Object.values(observed.recovery?.peers ?? {}) as { id: string; state: string; sessionId?: string; threadId?: string }[]) {
|
|
2133
2166
|
const check = nativeDoctorRecoveryIdentity(peer);
|
|
@@ -2182,9 +2215,9 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
2182
2215
|
row(mlx.state === "ready" || mlx.state === "stopped", "pi mlx", `${mlx.state}${mlx.model ? ` (${mlx.model})` : ""}${mlx.lastError ? `: ${mlx.lastError}` : ""}`);
|
|
2183
2216
|
}
|
|
2184
2217
|
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
row(
|
|
2218
|
+
let memoryLibraryReady = false;
|
|
2219
|
+
try { assertAgentMemVersion({ name: "@staix/agent-mem", version: AGENT_MEM_VERSION }); memoryLibraryReady = true; } catch { /* report the failed exact pin without opening a store */ }
|
|
2220
|
+
row(memoryLibraryReady, "memory library", memoryLibraryReady ? `amem ${AGENT_MEM_VERSION}; in-process, no claude-mem worker` : "installed amem does not match the exact dependency pin");
|
|
2188
2221
|
const bridge = spawnSync("dot", ["ai", "memory", "status"], { encoding: "utf8" });
|
|
2189
2222
|
const out = bridge.error ? "" : bridge.stdout.replace(/\x1b\[[0-9;]*m/g, "");
|
|
2190
2223
|
for (const tool of DOCTOR_MEMORY_CAPTURE_PEERS) {
|
|
@@ -2260,7 +2293,8 @@ async function runConductorCommand(): Promise<void> {
|
|
|
2260
2293
|
op = args[0] === "assign" ? "hub_task_assign" : "hub_task_escalate";
|
|
2261
2294
|
const { one, rest: positional } = takeFlags(args.slice(1), args[0] === "assign" ? ["--role"] : [], []);
|
|
2262
2295
|
const role = one["--role"] ?? "owner";
|
|
2263
|
-
if (!positional[0] || positional.length !== (args[0] === "assign" ? 2 : 1) || !["owner", "reviewer"].includes(role)) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer] | escalate <id>");
|
|
2296
|
+
if (!positional[0] || positional.length !== (args[0] === "assign" ? 2 : 1) || !["owner", "reviewer", "planner"].includes(role)) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer|planner] | escalate <id>");
|
|
2297
|
+
if (role === "planner") fail("only the person may name a task planner");
|
|
2264
2298
|
input = { id: Number(positional[0]), ...(args[0] === "assign" ? { peer: positional[1], ...(role === "reviewer" ? { role } : {}) } : {}) };
|
|
2265
2299
|
} else if (cmd === "pause" || cmd === "resume") {
|
|
2266
2300
|
if (args.length !== 1) fail(`usage: ahub ${cmd} <peer>`);
|