@staix/agent-hub 0.29.0 → 0.31.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 +51 -0
- package/README.md +15 -7
- package/docs/agent-notes/README.md +13 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +10 -4
- package/docs/agent-notes/daemon.md +16 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +10 -4
- package/docs/agent-notes/tests.md +8 -4
- package/docs/design/adapters.md +108 -0
- package/docs/design/agent-hub-architecture.md +58 -0
- package/docs/design/bus.md +147 -1
- package/docs/design/task-board.md +100 -0
- package/docs/dogfood.md +29 -8
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +410 -86
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +25 -5
- package/docs/sdlc.md +1 -1
- package/docs/security.md +182 -62
- package/docs/smoke.md +13 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/specs/2026-10-12-office-view-design.md +86 -3
- package/docs/verification/2026-11-03-0.30.0.md +54 -0
- package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
- package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
- package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
- package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
- package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
- package/docs/verification/2026-11-03-pi-title-api.md +26 -0
- package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
- package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verification/dogfood/2026-11-03.md +3095 -0
- package/docs/verification/pi-150-model-free.ts +57 -0
- package/docs/verification/pi-150-native.mjs +21 -0
- package/docs/verification/pi-150-title-api.mjs +31 -0
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4565 -3617
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +13 -13
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +82 -23
- package/src/adapters/pi.ts +140 -16
- package/src/adapters/registry-types.ts +16 -0
- package/src/adapters/registry.ts +4 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/autonomy.ts +66 -1
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +82 -17
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +10 -5
- package/src/cli/identity.ts +21 -4
- package/src/cli/main.ts +167 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/cli/worktree-tidy.ts +158 -0
- package/src/cli/worktrees-output.ts +43 -0
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +396 -20
- package/src/hub/bus.ts +497 -57
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/conductor.ts +4 -3
- package/src/hub/config-apply.ts +8 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1795 -142
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/dogfood-read.ts +62 -9
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +66 -11
- package/src/hub/manager.ts +67 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peer-doing.ts +33 -3
- package/src/hub/peers.ts +32 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/settings.ts +3 -0
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +607 -71
- package/src/hub/ui.ts +2 -0
- package/src/hub/worktree-prs.ts +100 -0
- package/src/hub/worktree-shelf.ts +246 -0
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/pi/extension.ts +43 -3
- package/src/rooms/link.ts +209 -21
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +338 -22
- package/src/rooms/store.ts +437 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +271 -17
- package/src/ui/needs.ts +10 -4
- package/src/ui/office-model.ts +64 -8
- package/src/ui/office-space.ts +1 -0
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +219 -28
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ActionMeta, ReviewAnchor, ReviewThread } from "@staix/agent-console/contract";
|
|
2
|
+
import { types } from "node:util";
|
|
3
|
+
import { sanitize } from "./envelope.ts";
|
|
4
|
+
import { parseReviewAnchor, type ReviewAnchorStatus as AnchorPlacement } from "./review-anchor.ts";
|
|
5
|
+
|
|
6
|
+
export const REVIEW_COMMENT_LIMIT = 64;
|
|
7
|
+
export const REVIEW_COMMENT_BODY_BYTES = 2048;
|
|
8
|
+
export const REVIEW_PACKAGE_BYTES = 12000;
|
|
9
|
+
export type ReviewCommentLifecycle = "draft" | "sent" | "resolved" | "dismissed";
|
|
10
|
+
export interface ReviewCommentCounts { draft: number; sent: number; resolved: number; dismissed: number }
|
|
11
|
+
export interface ReviewCommentRecord {
|
|
12
|
+
thread: ReviewThread; task: number; taskCreated: number; commit: string;
|
|
13
|
+
lifecycle: ReviewCommentLifecycle; revision: number; sentAt?: string; private: boolean;
|
|
14
|
+
}
|
|
15
|
+
export type ReviewCommentView = {
|
|
16
|
+
thread: ReviewThread; id?: never; task: number; commit: string; lifecycle: ReviewCommentLifecycle;
|
|
17
|
+
revision: number; sentAt?: string; placement: AnchorPlacement;
|
|
18
|
+
} | { id: string; thread?: never; task: number; commit: string; lifecycle: ReviewCommentLifecycle;
|
|
19
|
+
revision: number; sentAt?: string; placement: { status: "unavailable" } };
|
|
20
|
+
export interface ReviewCommentFence { task: number; taskCreated: number; flow: number; head?: string; owner: string | null }
|
|
21
|
+
export interface ReviewCommentMutation { task: number; id: string; revision: number; lifecycle?: ReviewCommentLifecycle; deleted?: true }
|
|
22
|
+
export interface ReviewCommentPackage {
|
|
23
|
+
task: number; taskCreated: number; id: string; ids: string[]; owner: string; head: string; at: string;
|
|
24
|
+
/** Immutable delivery payload. Missing on pre-recovery packages; those require explicit manual inspection. */
|
|
25
|
+
body?: string; envelopeId?: string; trace?: string; ts?: number; delivery?: "unconfirmed" | "queued"; publicationEvidenceVersion?: 1;
|
|
26
|
+
/** Ids whose quote/body were included in the immutable public envelope; absent on legacy packets. */
|
|
27
|
+
visibleIds?: string[];
|
|
28
|
+
}
|
|
29
|
+
export interface ReviewCommentPackageView { id: string; count: number; delivery: "queued" | "unconfirmed" | "unavailable" }
|
|
30
|
+
export const emptyReviewCommentCounts = (): ReviewCommentCounts => ({ draft: 0, sent: 0, resolved: 0, dismissed: 0 });
|
|
31
|
+
|
|
32
|
+
function fail(cause: string): never { throw new Error(`review comments ${cause}; refresh the task review and retry with a current bounded comment`); }
|
|
33
|
+
export function reviewCommentFields(input: unknown, allowed: readonly string[]): Record<string, unknown> {
|
|
34
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || types.isProxy(input) || ![Object.prototype, null].includes(Object.getPrototypeOf(input))
|
|
35
|
+
|| Object.getOwnPropertySymbols(input).length) fail("request must be a plain object");
|
|
36
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
37
|
+
if (Object.entries(descriptors).some(([key, value]) => !allowed.includes(key) || !value.enumerable || !Object.hasOwn(value, "value"))) fail("request has unknown, hidden fields or accessors");
|
|
38
|
+
return Object.fromEntries(Object.entries(descriptors).map(([key, descriptor]) => [key, descriptor.value]));
|
|
39
|
+
}
|
|
40
|
+
export function reviewCommentNumber(value: unknown, name: string, minimum = 1): number {
|
|
41
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) fail(`${name} is invalid`);
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
export function reviewCommentId(value: unknown): string {
|
|
45
|
+
if (typeof value !== "string" || !/^comment-[a-f0-9-]{36}$/.test(value)) fail("id is invalid");
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
export function reviewCommentIds(value: unknown): string[] {
|
|
49
|
+
if (!Array.isArray(value) || types.isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype || Object.getOwnPropertySymbols(value).length) fail("ids must be an inert array");
|
|
50
|
+
const descriptors = Object.getOwnPropertyDescriptors(value), length = Object.getOwnPropertyDescriptor(value, "length")?.value;
|
|
51
|
+
if (!Number.isSafeInteger(length) || length < 0 || length > REVIEW_COMMENT_LIMIT || Object.keys(descriptors).some(key => key !== "length" && (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= length))) fail("ids must be a bounded distinct list");
|
|
52
|
+
const ids: string[] = [];
|
|
53
|
+
for (let index = 0; index < length; index++) {
|
|
54
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
55
|
+
if (!descriptor?.enumerable || !Object.hasOwn(descriptor, "value")) fail("ids cannot contain holes, hidden fields or accessors");
|
|
56
|
+
ids.push(reviewCommentId(descriptor.value));
|
|
57
|
+
}
|
|
58
|
+
if (new Set(ids).size !== ids.length) fail("ids must be distinct");
|
|
59
|
+
return ids;
|
|
60
|
+
}
|
|
61
|
+
export function reviewCommentHead(value: unknown): string {
|
|
62
|
+
if (typeof value !== "string" || !/^[a-f0-9]{40}$/.test(value)) fail("commit is not a full lowercase SHA");
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
export function reviewCommentBody(value: unknown): string {
|
|
66
|
+
if (typeof value !== "string" || !value.trim()) fail("body is empty or invalid");
|
|
67
|
+
const body = sanitize(value);
|
|
68
|
+
if (Buffer.byteLength(body, "utf8") > REVIEW_COMMENT_BODY_BYTES) fail("body exceeds 2048 UTF-8 bytes");
|
|
69
|
+
return body;
|
|
70
|
+
}
|
|
71
|
+
function authorText(value: unknown): string {
|
|
72
|
+
if (typeof value !== "string" || !value.trim()) fail("verified person identity is invalid");
|
|
73
|
+
const identity = sanitize(value);
|
|
74
|
+
if (Buffer.byteLength(identity, "utf8") > 512 || /[\p{Cc}\p{Cf}]/u.test(identity) || Buffer.from(identity).toString("utf8") !== identity) fail("verified person identity is invalid");
|
|
75
|
+
return identity;
|
|
76
|
+
}
|
|
77
|
+
export function reviewCommentAuthor(meta?: ActionMeta): string {
|
|
78
|
+
if (meta === undefined) return "local";
|
|
79
|
+
if (meta.via !== "console" || typeof meta.node !== "string" || !meta.node.trim()) fail("verified person identity is unavailable");
|
|
80
|
+
return authorText(meta.identity);
|
|
81
|
+
}
|
|
82
|
+
/** Private-path quotes are discarded, never converted into invented line/page coordinates. */
|
|
83
|
+
export function privateReviewAnchor(anchor: ReviewAnchor): ReviewAnchor {
|
|
84
|
+
const selectors = anchor.selectors.filter(selector => selector.kind === "lineRange" || selector.kind === "structural");
|
|
85
|
+
if (!selectors.length) fail("private path needs an existing lineRange or structural selector");
|
|
86
|
+
return parseReviewAnchor({ ...anchor, selectors });
|
|
87
|
+
}
|
|
88
|
+
export function reviewThreadProjection(record: ReviewCommentRecord): ReviewThread {
|
|
89
|
+
return { ...record.thread, state: record.lifecycle === "resolved" || record.lifecycle === "dismissed" ? "resolved" : "open" };
|
|
90
|
+
}
|
|
91
|
+
/** Fixed storage validation; no stored body or parser cause is echoed on failure. */
|
|
92
|
+
export function parseStoredReviewComment(input: unknown): ReviewCommentRecord {
|
|
93
|
+
try {
|
|
94
|
+
const row = reviewCommentFields(input, ["thread", "task", "taskCreated", "commit", "lifecycle", "revision", "sentAt", "private"]);
|
|
95
|
+
const thread = reviewCommentFields(row.thread, ["id", "anchor", "body", "author", "at"]);
|
|
96
|
+
const author = authorText(thread.author);
|
|
97
|
+
if (author !== thread.author) fail("stored author is not sanitized");
|
|
98
|
+
if (!["draft", "sent", "resolved", "dismissed"].includes(String(row.lifecycle)) || typeof row.private !== "boolean"
|
|
99
|
+
|| typeof thread.author !== "string" || !thread.author || typeof thread.at !== "string" || !Number.isFinite(Date.parse(thread.at))
|
|
100
|
+
|| row.sentAt !== undefined && (typeof row.sentAt !== "string" || !Number.isFinite(Date.parse(row.sentAt)))) fail("stored metadata is invalid");
|
|
101
|
+
if ((row.lifecycle === "sent" || row.lifecycle === "resolved") && row.sentAt === undefined) fail("stored sent time is missing");
|
|
102
|
+
return { task: reviewCommentNumber(row.task, "task"), taskCreated: reviewCommentNumber(row.taskCreated, "taskCreated", 0), commit: reviewCommentHead(row.commit),
|
|
103
|
+
lifecycle: row.lifecycle as ReviewCommentLifecycle, revision: reviewCommentNumber(row.revision, "revision"), private: row.private,
|
|
104
|
+
...(row.sentAt === undefined ? {} : { sentAt: row.sentAt as string }), thread: { id: reviewCommentId(thread.id), anchor: parseReviewAnchor(thread.anchor), body: reviewCommentBody(thread.body), author, at: thread.at } };
|
|
105
|
+
} catch { throw new Error("review comment storage invalid; stop the hub and restore a trusted hub.db backup, then restart and retry"); }
|
|
106
|
+
}
|
|
107
|
+
export function renderReviewComment(record: ReviewCommentRecord, placement: AnchorPlacement, visible: boolean): string {
|
|
108
|
+
if (!visible) return `[${record.thread.id}] unavailable: this comment is kept on the board.`;
|
|
109
|
+
if (placement.status === "unavailable") return `[${record.thread.id}] unavailable: refresh the task review before using its text.`;
|
|
110
|
+
const { anchor } = record.thread;
|
|
111
|
+
const range = placement.status === "outdated" ? undefined : placement.range ?? anchor.selectors.find(selector => selector.kind === "lineRange");
|
|
112
|
+
const location = sanitize(anchor.path) + (range ? `:${range.startLine}-${range.endLine}` : "");
|
|
113
|
+
const quotes = anchor.selectors.filter(selector => selector.kind === "textQuote").map(selector => sanitize(selector.exact));
|
|
114
|
+
return [`[${record.thread.id}] ${location} (${placement.status}) at ${record.commit}`, ...quotes.map(quote => `> ${quote.replace(/\n/g, "\n> ")}`), sanitize(record.thread.body)].join("\n");
|
|
115
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { types } from "node:util";
|
|
2
2
|
import { sanitize } from "./envelope.ts";
|
|
3
|
+
import { parseReviewAnchor } from "./review-anchor.ts";
|
|
4
|
+
import type { ReviewAnchor } from "@staix/agent-console/contract";
|
|
3
5
|
|
|
4
6
|
export interface ReviewFinding {
|
|
5
7
|
id: string;
|
|
@@ -8,6 +10,7 @@ export interface ReviewFinding {
|
|
|
8
10
|
path?: string;
|
|
9
11
|
disposition: "open" | "fixed" | "false_positive" | "accepted";
|
|
10
12
|
rationale?: string;
|
|
13
|
+
anchor?: ReviewAnchor;
|
|
11
14
|
}
|
|
12
15
|
export interface ReviewFindingSet { head?: string; items: ReviewFinding[] }
|
|
13
16
|
export interface ReviewFindingTally { important: number; nit: number; openImportant: number; openNit: number; fixed: number; falsePositive: number; accepted: number }
|
|
@@ -19,7 +22,7 @@ function fields(raw: unknown): Record<string, PropertyDescriptor> {
|
|
|
19
22
|
|| ![Object.prototype, null].includes(Object.getPrototypeOf(raw)) || Object.getOwnPropertySymbols(raw).length) return fail("plain own-data finding required");
|
|
20
23
|
const descriptors: Record<string, PropertyDescriptor> = Object.assign(Object.create(null), Object.getOwnPropertyDescriptors(raw));
|
|
21
24
|
if (Object.values(descriptors).some(field => !Object.hasOwn(field, "value") || !field.enumerable)) return fail("accessors and hidden fields refused");
|
|
22
|
-
if (Object.keys(descriptors).some(key => !["id", "pass", "severity", "path", "disposition", "rationale"].includes(key))) return fail("unknown finding field");
|
|
25
|
+
if (Object.keys(descriptors).some(key => !["id", "pass", "severity", "path", "disposition", "rationale", "anchor"].includes(key))) return fail("unknown finding field");
|
|
23
26
|
return descriptors;
|
|
24
27
|
}
|
|
25
28
|
function text(raw: unknown, max: number, path: boolean): string {
|
|
@@ -50,8 +53,10 @@ export function parseReviewFindings(raw: unknown, options: { person: boolean; ma
|
|
|
50
53
|
if (disposition !== "open" && disposition !== "fixed" && disposition !== "false_positive" && disposition !== "accepted") return fail("invalid finding disposition");
|
|
51
54
|
const path = item.path ? text(item.path.value, 512, true) : undefined;
|
|
52
55
|
const rationale = item.rationale ? text(item.rationale.value, 2048, false) : undefined;
|
|
56
|
+
const anchor = item.anchor ? parseReviewAnchor(item.anchor.value) : undefined;
|
|
57
|
+
if (path !== undefined && anchor && path !== anchor.path) return fail("path and anchor.path differ; use the same task-scoped file path");
|
|
53
58
|
if (disposition === "accepted" && (!options.person || !rationale)) return fail("accepted findings require a person and rationale");
|
|
54
|
-
return { id, pass, severity, disposition, ...(path !== undefined ? { path } : {}), ...(rationale !== undefined ? { rationale } : {}) };
|
|
59
|
+
return { id, pass, severity, disposition, ...(path !== undefined ? { path } : {}), ...(rationale !== undefined ? { rationale } : {}), ...(anchor ? { anchor } : {}) };
|
|
55
60
|
});
|
|
56
61
|
if (result.filter(item => item.severity === "nit").length > options.maxNits) return fail("nit limit exceeded");
|
|
57
62
|
if (Buffer.byteLength(JSON.stringify(result)) > MAX_BYTES) return fail("finding report exceeds 64 KiB");
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { extname, join } from "node:path";
|
|
3
|
+
import { projectionPath, snapshotBytes, PROJECTED_DIFF_BYTES } from "./projected-diff.ts";
|
|
4
|
+
import type { Repo } from "./snapshots.ts";
|
|
5
|
+
import type { TextProjections } from "./text-projection.ts";
|
|
6
|
+
|
|
7
|
+
export interface ReviewReadRequest { path: string; side?: "base" | "head" }
|
|
8
|
+
export type ReviewMaterial =
|
|
9
|
+
| { state: "available"; path: string; side: "base" | "head"; text: string; rawHash: string; contentHash: string; projection?: { name: string; version: string } }
|
|
10
|
+
| { state: "unavailable"; path: string; side: "base" | "head" };
|
|
11
|
+
export interface ReviewMaterialBatch { head: string; materials: ReviewMaterial[] }
|
|
12
|
+
export interface ReviewMaterialOptions {
|
|
13
|
+
repo: Repo;
|
|
14
|
+
base: string;
|
|
15
|
+
head: string;
|
|
16
|
+
/** Trusted execution-root-relative scopes, prepared by the host, never enlarged from a read request. */
|
|
17
|
+
paths: readonly string[];
|
|
18
|
+
projections: Pick<TextProjections, "handles" | "projectBytes">;
|
|
19
|
+
deny?: readonly string[];
|
|
20
|
+
privatePath?: (path: string) => boolean;
|
|
21
|
+
nameable?: (text: string) => boolean;
|
|
22
|
+
/** Host-only clock for the shared call budget. */
|
|
23
|
+
now?: () => number;
|
|
24
|
+
/** An earlier operation deadline includes the host's head/scope preparation in the same 8 s budget. */
|
|
25
|
+
deadline?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MAX_FILES = 8, READ_MS = 8000;
|
|
29
|
+
const hash = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
|
|
30
|
+
|
|
31
|
+
/** Immutable Git material only. Create one reader per read/send operation so every side shares its budgets. */
|
|
32
|
+
export function createReviewMaterialReader(options: ReviewMaterialOptions): { readonly head: string; read(path: string, side?: "base" | "head"): ReviewMaterial } {
|
|
33
|
+
const now = options.now ?? (() => performance.now()), deadline = Math.min(now() + READ_MS, options.deadline ?? Infinity);
|
|
34
|
+
const files = new Set<string>(), scopes = [...options.paths];
|
|
35
|
+
let presentationBytes = 0;
|
|
36
|
+
const remaining = () => deadline - now();
|
|
37
|
+
const allowed = (path: string, repoPath: string): boolean => {
|
|
38
|
+
try {
|
|
39
|
+
const deny = [...(options.deny ?? [])];
|
|
40
|
+
return !/[\p{Cc}\p{Cf}]/u.test(path) && projectionPath({ ...options.repo, prefix: "" }, path, deny)
|
|
41
|
+
&& projectionPath(options.repo, repoPath, deny)
|
|
42
|
+
&& scopes.some(scope => scope === "" || path === scope || path.startsWith(scope + "/"))
|
|
43
|
+
&& (!options.privatePath || options.privatePath(path) === false) && (!options.nameable || options.nameable(path) === true);
|
|
44
|
+
} catch { return false; }
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
head: options.head,
|
|
48
|
+
read(path, side = "head") {
|
|
49
|
+
const unavailable: ReviewMaterial = { state: "unavailable", path, side };
|
|
50
|
+
try {
|
|
51
|
+
if (typeof path !== "string" || (side !== "base" && side !== "head") || !Number.isFinite(deadline) || remaining() <= 0) return unavailable;
|
|
52
|
+
const repoPath = options.repo.prefix + path;
|
|
53
|
+
if (!allowed(path, repoPath) || !files.has(path) && files.size >= MAX_FILES) return unavailable;
|
|
54
|
+
files.add(path);
|
|
55
|
+
// The existing Git/converter ports use performance.now(); translate the host clock's remaining budget once per operation.
|
|
56
|
+
const bytes = snapshotBytes(options.repo, side === "base" ? options.base : options.head, repoPath, performance.now() + remaining());
|
|
57
|
+
if (!bytes || remaining() <= 0) return unavailable;
|
|
58
|
+
const rawHash = hash(bytes), file = join(options.repo.top, repoPath);
|
|
59
|
+
let text: string, projection: { name: string; version: string } | undefined;
|
|
60
|
+
if (options.projections.handles(file)) {
|
|
61
|
+
const converted = options.projections.projectBytes(file, bytes, performance.now() + remaining());
|
|
62
|
+
if (!converted || converted.inputHash !== rawHash || !/^[a-f0-9]{64}$/.test(converted.configHash)) return unavailable;
|
|
63
|
+
text = converted.text;
|
|
64
|
+
// This is the existing configured converter fingerprint, not a claimed upstream semantic version.
|
|
65
|
+
projection = { name: "text-projection:" + extname(path).toLowerCase(), version: converted.configHash };
|
|
66
|
+
} else text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
67
|
+
if (typeof text !== "string" || text.includes("\0") || remaining() <= 0 || !allowed(path, repoPath) || options.nameable && options.nameable(text) !== true) return unavailable;
|
|
68
|
+
const material: ReviewMaterial = { state: "available", path, side, text, rawHash, contentHash: projection ? hash(Buffer.from(text, "utf8")) : rawHash, ...(projection ? { projection } : {}) };
|
|
69
|
+
const size = Buffer.byteLength(JSON.stringify(material), "utf8");
|
|
70
|
+
if (presentationBytes + size > PROJECTED_DIFF_BYTES || remaining() <= 0) return unavailable;
|
|
71
|
+
presentationBytes += size;
|
|
72
|
+
return material;
|
|
73
|
+
} catch { return unavailable; }
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
package/src/hub/routing.ts
CHANGED
|
@@ -422,7 +422,7 @@ export function plannerWalk(input: PlannerWalkInput): PlannerWalk {
|
|
|
422
422
|
* prints its trace. `states`: effective bus states of attached peers (a peer that is not in the map is not attached).
|
|
423
423
|
*/
|
|
424
424
|
export function assign(
|
|
425
|
-
task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state" | "title" | "detail" | "refs" | "external" | "id" | "sdlc" | "history">>,
|
|
425
|
+
task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state" | "title" | "detail" | "refs" | "external" | "id" | "sdlc" | "history" | "needsPerson">>,
|
|
426
426
|
states: Record<PeerId, PeerState>,
|
|
427
427
|
routing: Routing,
|
|
428
428
|
opts: InstanceRoutingOptions & {
|
|
@@ -464,6 +464,8 @@ export function assign(
|
|
|
464
464
|
): Assignment {
|
|
465
465
|
if (task.state === "cancelled" || task.state === "approved") return { trace: [`${task.state}: closed; not routed`] };
|
|
466
466
|
if (task.state === "backlog") return { trace: ["backlog: not routed; pull into To Do first"] };
|
|
467
|
+
// #1176: a person-run leg is nobody's to take, whatever its reservation says (the reservation stays as the "who will run it" hint). A pure input, so `ahub route explain` reads the same line.
|
|
468
|
+
if (task.needsPerson) return { trace: [`person-run: only the person can run this leg${task.reserved ? ` (reserved for ${task.reserved} as a hint only)` : ""}; no agent is offered it`] };
|
|
467
469
|
const instanceMode = hasInstances(opts);
|
|
468
470
|
const policy = routing.classes[task.class];
|
|
469
471
|
const trace: string[] = [`class ${task.class}${policy ? "" : " (no [classes] entry: only an explicit owner can take it)"}`, `signals: ${task.signals.join(", ") || "none"}`];
|
package/src/hub/settings.ts
CHANGED
|
@@ -85,6 +85,9 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
85
85
|
{ key: "specs.review", group: "Switches", label: "Covering spec review", type: "enum", values: ["off", "report", "require"], store: "config", applies: "live", risk: "safe", path: ["specs", "review"] },
|
|
86
86
|
{ key: "approvals.notify", group: "Switches", label: "Desktop notice for a waiting approval", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["approvals", "notify"] },
|
|
87
87
|
{ key: "snapshots.enabled", group: "Switches", label: "Per-turn snapshots", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["snapshots", "enabled"] },
|
|
88
|
+
// #1057: the worktree shelf. Both only choose what the shelf shows and when it raises one inbox alert; neither deletes anything, so they are safe and live.
|
|
89
|
+
{ key: "worktrees.quiet_days", group: "Switches", label: "Worktrees: shelve one idle this many days (0 never)", type: "integer", min: 0, max: 3650, store: "config", applies: "live", risk: "safe", path: ["worktrees", "quiet_days"] },
|
|
90
|
+
{ key: "worktrees.alert_over", group: "Switches", label: "Worktrees: inbox alert above this many settled (0 never)", type: "integer", min: 0, max: 100000, store: "config", applies: "live", risk: "safe", path: ["worktrees", "alert_over"] },
|
|
88
91
|
{ key: "coordination", group: "Switches", label: "Coordination of overlapping owners", type: "enum", values: ["advisory", "turn-free"], store: "config", applies: "next use", risk: "safe", path: ["coordination"] },
|
|
89
92
|
];
|
|
90
93
|
|
package/src/hub/task-sweep.ts
CHANGED
|
@@ -52,6 +52,8 @@ export function taskSweepConfig(input: unknown): TaskSweepConfig {
|
|
|
52
52
|
/** Pure time/kind ladder; Tasks decides whether the responsible peer is available. */
|
|
53
53
|
export function nextSweep(task: Task, config: TaskSweepConfig, now: number, ready = true): SweepRecord | undefined {
|
|
54
54
|
if (!config.enabled || !Number.isFinite(now)) return undefined;
|
|
55
|
+
// #1176: a person-run leg waits on the person, so no idle step ever escalates it to an agent.
|
|
56
|
+
if (task.needsPerson) return undefined;
|
|
55
57
|
const kind = task.state === "proposed" && task.owner ? "unaccepted-assignment"
|
|
56
58
|
: task.state === "in_progress" && task.owner ? "idle-owner"
|
|
57
59
|
: task.state === "in_review" && task.reviewer ? "review-pending" : task.state === "proposed" && !task.owner && !task.reviewer && ready ? "ready-unassigned" : undefined;
|