@staix/agent-hub 0.27.0 → 0.29.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 +33 -0
- package/README.md +11 -3
- package/docs/agent-notes/adapters.md +4 -2
- package/docs/agent-notes/bus.md +4 -4
- package/docs/agent-notes/daemon.md +9 -3
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +7 -0
- package/docs/agent-notes/tests.md +7 -5
- package/docs/design/adapters.md +76 -0
- package/docs/design/agent-hub-architecture.md +67 -0
- package/docs/design/agent-hub.md +52 -0
- package/docs/design/bus.md +48 -0
- package/docs/design/task-board.md +86 -0
- package/docs/dogfood.md +21 -3
- package/docs/operations.md +370 -12
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +19 -3
- package/docs/sdlc.md +2 -2
- package/docs/security.md +24 -4
- package/docs/smoke.md +2 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
- package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
- package/docs/verification/2026-11-02-0.28.0.md +54 -0
- package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
- package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
- package/docs/verification/2026-11-02-control-write-after.json +139 -0
- package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
- package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
- package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
- package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
- package/docs/verification/2026-11-03-0.29.0.md +59 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
- package/docs/verification/2026-11-03-live-board-latency.json +556 -0
- package/docs/verification/2026-11-03-live-board-latency.md +64 -0
- package/docs/verified.json +14 -14
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +857 -125
- package/src/adapters/claude-channel.ts +12 -2
- package/src/adapters/compat.ts +1 -1
- package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
- package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
- package/src/adapters/jcode.ts +37 -4
- package/src/adapters/qwen-dual.ts +3 -2
- package/src/cli/agents.ts +4 -2
- package/src/cli/bench.ts +4 -2
- package/src/cli/board-read.ts +25 -0
- package/src/cli/claude-homes.ts +2 -2
- package/src/cli/console-state.ts +8 -3
- package/src/cli/help.ts +6 -5
- package/src/cli/identity.ts +9 -1
- package/src/cli/init-prompt.ts +179 -0
- package/src/cli/init-settings.ts +229 -0
- package/src/cli/init.ts +102 -9
- package/src/cli/launch.ts +3 -1
- package/src/cli/main.ts +106 -35
- package/src/cli/output.ts +19 -0
- package/src/cli/recovery-package.ts +9 -0
- package/src/cli/setup-homes.ts +18 -3
- package/src/cli/specs.ts +150 -0
- package/src/cli/terminal-recovery.ts +35 -11
- package/src/hub/board.ts +59 -5
- package/src/hub/bus.ts +54 -7
- package/src/hub/child-process.ts +6 -4
- package/src/hub/config-apply.ts +4 -1
- package/src/hub/config-edit.ts +1 -1
- package/src/hub/config-tier.ts +1 -0
- package/src/hub/control-client.ts +43 -4
- package/src/hub/control-write-trace.ts +409 -0
- package/src/hub/cost-governance.ts +22 -6
- package/src/hub/crash.ts +20 -0
- package/src/hub/daemon.ts +470 -98
- package/src/hub/delivery-hints.ts +6 -3
- package/src/hub/delivery-journal.ts +14 -3
- package/src/hub/hub-tools.ts +10 -6
- package/src/hub/peers.ts +1 -1
- package/src/hub/review-brief.ts +18 -0
- package/src/hub/routing.ts +14 -3
- package/src/hub/runtime-capture-prune.ts +4 -1
- package/src/hub/sdlc-release-authorization.ts +13 -4
- package/src/hub/sdlc-release.ts +23 -10
- package/src/hub/sdlc.ts +15 -2
- package/src/hub/settings.ts +2 -0
- package/src/hub/specs-config.ts +43 -0
- package/src/hub/specs-task.ts +108 -0
- package/src/hub/specs.ts +1008 -0
- package/src/hub/task-list-page.ts +75 -0
- package/src/hub/task-sweep.ts +10 -1
- package/src/hub/task-worktree.ts +331 -0
- package/src/hub/tasks.ts +606 -53
- package/src/hub/terminal-open.ts +10 -5
- package/src/hub/tool-request.ts +131 -0
- package/src/local/deny.ts +49 -0
- package/src/local/tools.ts +4 -2
- package/src/memory/brief.ts +5 -0
- package/src/memory/client.ts +5 -2
- package/src/ui/office-model.ts +17 -1
- package/src/ui/office-space.ts +1 -1
- package/templates/config.json +1 -0
- package/templates/profiles/dev.json +1 -0
- package/templates/profiles/general.json +1 -0
- package/templates/profiles/research.json +1 -0
- package/templates/profiles/writing.json +1 -0
- package/templates/specs.block.md +5 -0
package/src/hub/terminal-open.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { personEnv } from "./child-process.ts";
|
|
3
|
-
import { openStartTerminal, orcaExecutable, shellQuote,
|
|
3
|
+
import { openStartTerminal, orcaExecutable, resolveStartTerminalWorktree, shellQuote, startTerminalCandidate, type CommandRunner } from "../cli/terminal-recovery.ts";
|
|
4
4
|
|
|
5
5
|
/** #269: where a terminal for a hub-made start comes from, or why there is none. */
|
|
6
6
|
export type TerminalOpened = { ok: true; via: string } | { ok: false; why: string; detail?: string };
|
|
@@ -18,14 +18,15 @@ const PLACEHOLDER = /\{(command|title|cwd)\}/g;
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* The providers, in order: the machine-local `terminal.open` command template, then Orca (the path recovery uses)
|
|
21
|
-
* when this project
|
|
21
|
+
* when Orca is in play for this project. `available` only says Orca could be used; `open` asks Orca for the worktree
|
|
22
|
+
* whose root is this project's root and refuses when there is none (#1089). `run` replaces the Orca CLI in tests.
|
|
22
23
|
*/
|
|
23
24
|
export function terminalOpener(opts: { template: string[]; cwd: string; stateDir: string; env?: NodeJS.ProcessEnv; run?: CommandRunner }): TerminalOpener {
|
|
24
25
|
const env = opts.env ?? process.env;
|
|
25
|
-
const
|
|
26
|
+
const orcaInPlay = () => startTerminalCandidate(opts.cwd, opts.stateDir, env);
|
|
26
27
|
const available = (): TerminalOpened => {
|
|
27
28
|
if (opts.template.length) return { ok: true, via: "terminal.open" };
|
|
28
|
-
if (!
|
|
29
|
+
if (!orcaInPlay()) return { ok: false, why: "no terminal.open command is configured and this project has no Orca worktree on record" };
|
|
29
30
|
if (!opts.run && !Bun.which(orcaExecutable())) return { ok: false, why: `the ${orcaExecutable()} command is not on PATH` };
|
|
30
31
|
return { ok: true, via: "orca" };
|
|
31
32
|
};
|
|
@@ -38,7 +39,11 @@ export function terminalOpener(opts: { template: string[]; cwd: string; stateDir
|
|
|
38
39
|
const command = argv.map(shellQuote).join(" ");
|
|
39
40
|
try {
|
|
40
41
|
if (can.via === "orca") {
|
|
41
|
-
|
|
42
|
+
// The worktree comes from Orca by this project's root, never from the environment of whatever terminal started this hub (#1089).
|
|
43
|
+
const orca = opts.run ? { runner: opts.run } : undefined;
|
|
44
|
+
const target = await resolveStartTerminalWorktree(opts.cwd, orca);
|
|
45
|
+
if (!target.ok) return target;
|
|
46
|
+
await openStartTerminal(target.worktreeId, command, title, orca);
|
|
42
47
|
return can;
|
|
43
48
|
}
|
|
44
49
|
// One pass with a function: a placeholder inside a substituted value (a project path that contains "{cwd}")
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { canonical } from "./capabilities.ts";
|
|
3
|
+
|
|
4
|
+
export const TOOL_WRITE_FEATURE = "tool_write_idempotency";
|
|
5
|
+
const RETENTION_MS = 15 * 60 * 1000;
|
|
6
|
+
const MAX_ENTRIES = 512;
|
|
7
|
+
const MAX_REPLY_BYTES = 64 * 1024;
|
|
8
|
+
const MAX_PAYLOAD_BYTES = 1024 * 1024;
|
|
9
|
+
const MAX_FUTURE_MS = 30_000;
|
|
10
|
+
type Reply = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
/** Writes with uncertain transport outcomes; delivery completion uses its journal rather than the replay registry. */
|
|
13
|
+
export function toolWriteOperation(msg: Record<string, unknown>): "hub_task_propose" | "hub_review" | "hub_delivery_done" | undefined {
|
|
14
|
+
if (msg.t === "delivery_complete") return "hub_delivery_done";
|
|
15
|
+
if (msg.t === "task") {
|
|
16
|
+
// Match the control dispatcher's legacy operation coercion, so accepted writes cannot bypass replay protection.
|
|
17
|
+
let operation: string;
|
|
18
|
+
try { operation = String(msg.op); } catch { return undefined; }
|
|
19
|
+
if (operation === "hub_task_propose" || operation === "hub_review") return operation;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function newToolRequestId(instanceId: string, now = Date.now()): string {
|
|
25
|
+
if (!/^[A-Za-z0-9._-]{1,128}$/.test(instanceId) || !Number.isSafeInteger(now) || now < 1) throw new Error("tool request identity is unavailable");
|
|
26
|
+
return `v1/${instanceId}/${now}/${randomUUID()}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function settlingRead(msg: Record<string, unknown>): { read: string; absent: string } {
|
|
30
|
+
const args = msg.args && typeof msg.args === "object" && !Array.isArray(msg.args) ? msg.args as Record<string, unknown> : {};
|
|
31
|
+
const id = typeof args.id === "number" ? args.id : typeof args.id === "string" && /^\s*\d+\s*$/.test(args.id) ? Number(args.id) : undefined;
|
|
32
|
+
const delivery = typeof msg.deliveryId === "string" && /^(?:[a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/.test(msg.deliveryId) ? msg.deliveryId : "<delivery-id>";
|
|
33
|
+
if (toolWriteOperation(msg) === "hub_delivery_done") return { read: `ahub queue show ${delivery} (person or conductor)`, absent: "completion is absent" };
|
|
34
|
+
if (toolWriteOperation(msg) === "hub_review" && Number.isSafeInteger(id) && id! > 0) return { read: `hub_task_show {id: ${id}}`, absent: "this verdict is absent" };
|
|
35
|
+
return { read: "hub_task_list (then hub_task_show for the matching task)", absent: "the task is absent" };
|
|
36
|
+
}
|
|
37
|
+
function safeRequestId(requestId: unknown): string | undefined {
|
|
38
|
+
return typeof requestId === "string" && requestId.length <= 256 && !/[\p{Cc}\p{Cf}]/u.test(requestId) ? requestId : undefined;
|
|
39
|
+
}
|
|
40
|
+
export function toolWriteUnknownReply(msg: Record<string, unknown>, requestId?: string, cause?: string): Reply {
|
|
41
|
+
const token = safeRequestId(requestId), settlement = settlingRead(msg);
|
|
42
|
+
const replay = token && toolWriteOperation(msg) !== "hub_delivery_done" ? `, reusing request_id ${JSON.stringify(token)}` : "";
|
|
43
|
+
return { ok: false, outcome: "unknown", ...(token ? { requestId: token } : {}),
|
|
44
|
+
error: `Write outcome unknown${cause ? `: ${cause}` : ". The hub may have applied the write"}. Read ${settlement.read}; call again only if ${settlement.absent}${replay}.` };
|
|
45
|
+
}
|
|
46
|
+
export function toolWriteRefusedReply(msg: Record<string, unknown>, requestId: unknown, cause: string): Reply {
|
|
47
|
+
const token = safeRequestId(requestId);
|
|
48
|
+
return { ok: false, outcome: "refused", ...(token ? { requestId: token } : {}),
|
|
49
|
+
error: `Write request refused: ${cause}. This attempt did not run. Read ${settlingRead(msg).read} before another write.` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Entry { bucket: string; scope: string; payload: string; issuedAt: number; settled: boolean; reply: Promise<Reply> }
|
|
53
|
+
interface Request { id: unknown; scope: string; bucket?: string; payload: unknown; current: (reply?: Reply) => boolean }
|
|
54
|
+
|
|
55
|
+
/** One daemon epoch. Expired ids are rejected, not forgotten and reapplied. */
|
|
56
|
+
export class ToolWriteRegistry {
|
|
57
|
+
private readonly entries = new Map<string, Entry>();
|
|
58
|
+
private watermark = 0;
|
|
59
|
+
private readonly clock: () => number;
|
|
60
|
+
private readonly maxEntries: number;
|
|
61
|
+
private readonly retentionMs: number;
|
|
62
|
+
private readonly maxReplyBytes: number;
|
|
63
|
+
constructor(private readonly instanceId: string, options: { now?: () => number; maxEntries?: number; retentionMs?: number; maxReplyBytes?: number } = {}) {
|
|
64
|
+
this.clock = options.now ?? Date.now;
|
|
65
|
+
this.maxEntries = options.maxEntries ?? MAX_ENTRIES;
|
|
66
|
+
this.retentionMs = options.retentionMs ?? RETENTION_MS;
|
|
67
|
+
this.maxReplyBytes = options.maxReplyBytes ?? MAX_REPLY_BYTES;
|
|
68
|
+
if (!/^[A-Za-z0-9._-]{1,128}$/.test(instanceId) || ![this.maxEntries, this.retentionMs, this.maxReplyBytes].every(value => Number.isSafeInteger(value) && value > 0)) throw new Error("invalid tool replay registry bounds");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async run(request: Request, work: () => Promise<Reply>): Promise<Reply> {
|
|
72
|
+
const refuse = (cause: string) => toolWriteRefusedReply(this.message(request.payload), request.id, cause);
|
|
73
|
+
const unavailable = (cause: string) => toolWriteUnknownReply(this.message(request.payload), typeof request.id === "string" ? request.id : undefined, cause);
|
|
74
|
+
const current = (reply?: Reply) => { try { return request.current(reply === undefined ? undefined : structuredClone(reply)) === true; } catch { return false; } };
|
|
75
|
+
if (!current()) return refuse("current caller authority or privacy is unavailable");
|
|
76
|
+
const observed = this.clock();
|
|
77
|
+
if (!Number.isSafeInteger(observed) || observed < 1) return refuse("the replay clock is unavailable");
|
|
78
|
+
const now = this.watermark = Math.max(this.watermark, observed);
|
|
79
|
+
const parts = typeof request.id === "string" && request.id.length <= 256 ? request.id.split("/") : [];
|
|
80
|
+
const issuedAt = Number(parts[2]);
|
|
81
|
+
if (parts.length !== 4 || parts[0] !== "v1" || parts[1] !== this.instanceId || !/^[1-9][0-9]{0,15}$/.test(parts[2] ?? "")
|
|
82
|
+
|| !Number.isSafeInteger(issuedAt) || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(parts[3] ?? "")) return refuse("the request ID is invalid or belongs to another daemon instance; inspect its original outcome");
|
|
83
|
+
let payload: string, scope: string, bucket: string;
|
|
84
|
+
try {
|
|
85
|
+
if (typeof request.scope !== "string" || request.scope.length > 4096) throw new Error("invalid scope");
|
|
86
|
+
const bucketKey = request.bucket ?? request.scope;
|
|
87
|
+
if (typeof bucketKey !== "string" || bucketKey.length > 4096) throw new Error("invalid bucket");
|
|
88
|
+
scope = createHash("sha256").update(request.scope).digest("hex");
|
|
89
|
+
bucket = createHash("sha256").update(bucketKey).digest("hex");
|
|
90
|
+
const encoded = JSON.stringify(request.payload);
|
|
91
|
+
if (!encoded || Buffer.byteLength(encoded) > MAX_PAYLOAD_BYTES) throw new Error("request too large");
|
|
92
|
+
const value: unknown = JSON.parse(encoded);
|
|
93
|
+
const stack = [{ value, depth: 0 }]; let nodes = 0;
|
|
94
|
+
while (stack.length) {
|
|
95
|
+
const row = stack.pop()!;
|
|
96
|
+
if (++nodes > 20_000 || row.depth > 32) throw new Error("request too complex");
|
|
97
|
+
if (row.value && typeof row.value === "object") for (const child of Object.values(row.value)) stack.push({ value: child, depth: row.depth + 1 });
|
|
98
|
+
}
|
|
99
|
+
payload = createHash("sha256").update(canonical(value)).digest("hex");
|
|
100
|
+
} catch { return refuse("the request is too large or malformed for replay protection"); }
|
|
101
|
+
// Pending writes never age out: losing their key could apply an unfinished write twice.
|
|
102
|
+
for (const [id, entry] of this.entries) if (entry.settled && now - entry.issuedAt >= this.retentionMs) this.entries.delete(id);
|
|
103
|
+
const existing = this.entries.get(request.id as string);
|
|
104
|
+
if (existing) {
|
|
105
|
+
if (existing.scope !== scope || existing.bucket !== bucket || existing.payload !== payload) return refuse("the request ID was already used by another authority or for different arguments");
|
|
106
|
+
const reply = await existing.reply;
|
|
107
|
+
return current(reply) ? structuredClone(reply) : unavailable("caller authority or privacy changed before replay");
|
|
108
|
+
}
|
|
109
|
+
if (now - issuedAt >= this.retentionMs || issuedAt > now + MAX_FUTURE_MS) return refuse("the request ID is expired or its timestamp is outside the replay window; inspect its original outcome");
|
|
110
|
+
// ponytail: 512 calls per actor/scope per 15 min and 64 KiB per reply; a durable outcome ledger is the upgrade path for higher sustained write volume.
|
|
111
|
+
if ([...this.entries.values()].filter(entry => entry.bucket === bucket).length >= this.maxEntries) return refuse("this actor's replay window is full; inspect its outstanding writes and wait for that window to drain");
|
|
112
|
+
const entry: Entry = { bucket, scope, payload, issuedAt, settled: false, reply: Promise.resolve({}) };
|
|
113
|
+
this.entries.set(request.id as string, entry);
|
|
114
|
+
entry.reply = Promise.resolve().then(async () => {
|
|
115
|
+
if (!current()) return refuse("caller authority or privacy changed before execution");
|
|
116
|
+
try {
|
|
117
|
+
const result = await work();
|
|
118
|
+
const { rid: _rid, ...body } = result;
|
|
119
|
+
const encoded = JSON.stringify({ ...body, requestId: request.id });
|
|
120
|
+
if (Buffer.byteLength(encoded) > this.maxReplyBytes) return unavailable("the write result exceeds the bounded replay record; inspect the write directly");
|
|
121
|
+
return JSON.parse(encoded) as Reply;
|
|
122
|
+
} catch { return unavailable("the write raised an unexpected failure and may already have applied"); }
|
|
123
|
+
}).finally(() => { entry.settled = true; });
|
|
124
|
+
const reply = await entry.reply;
|
|
125
|
+
return current(reply) ? structuredClone(reply) : unavailable("caller authority or privacy changed before the result");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private message(payload: unknown): Record<string, unknown> {
|
|
129
|
+
return payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : {};
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/local/deny.ts
CHANGED
|
@@ -77,6 +77,54 @@ const hooksUnder = (parent: string): string => {
|
|
|
77
77
|
return depth > MAX_ANCHOR_DEPTH ? `^.*/${hfsFoldedName("hooks")}(/|$)` : `^${"/[^/]+".repeat(depth)}/${hfsFoldedName("hooks")}(/|$)`;
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* #1123: a sandboxed command may not move what a branch, HEAD or a linked worktree points at. `refs/**` (branches, tags, `refs/stash`), `packed-refs*`, `HEAD*` (its lock too) and
|
|
82
|
+
* `worktrees/**` (a linked worktree's admin dir: its HEAD, index, logs) are refused directly under every plain `.git` and under each external git dir (the common dir and a linked
|
|
83
|
+
* worktree's own, whatever they are named). The hub's own git runs outside the sandbox and is unaffected, so #1115's hub commit still lands.
|
|
84
|
+
*
|
|
85
|
+
* Every name must sit DIRECTLY under its git dir. An unanchored `refs` or `worktrees` filter would also match an ANCESTOR of the project with that name, and a project under
|
|
86
|
+
* `~/worktrees/` would lose every `.git` write (the #520 `hooks` lesson). So each rule is adjacent by construction: one literal for `.git` + a short name, or, for the long names
|
|
87
|
+
* (`worktrees`, `packed-refs`, where `.git` + name would pass the 1024-byte literal limit), a front half and a back half that must both match, or a depth anchor for an external dir.
|
|
88
|
+
* A submodule's git dir (`.git/modules/<name>`, nested ones, and `.git/worktrees/<wt>/modules/<name>` for a linked worktree) is covered the same way (`subDirs`, the discovered
|
|
89
|
+
* ones: a git dir that is not discovered is refused whole by the #518/#520 rules), per the person's "deny .git writes" decision: nothing is listed as a residual.
|
|
90
|
+
*/
|
|
91
|
+
const refDepth = (d: string): number => d.split("/").filter(Boolean).length;
|
|
92
|
+
/**
|
|
93
|
+
* `^(/[^/]+){depth}/<name>(/|$)`: `name` as the entry directly under `d`, the depth written out because seatbelt ignores `{n}`. The folded names cost (measured, bytes of regex): `refs` 334,
|
|
94
|
+
* `worktrees` 669, so past the depth where the path plus the name would near 1024 it falls back to the unanchored name, which over-refuses only under an ancestor with that name and
|
|
95
|
+
* never under-protects.
|
|
96
|
+
*/
|
|
97
|
+
const refUnder = (d: string, name: string): string => {
|
|
98
|
+
const depth = refDepth(d), cost = Buffer.byteLength(hfsFoldedName(name)) + depth * 6 + 8;
|
|
99
|
+
return cost > 1000 ? `^.*/${hfsFoldedName(name)}(/|$)` : `^${"/[^/]+".repeat(depth)}/${hfsFoldedName(name)}(/|$)`;
|
|
100
|
+
};
|
|
101
|
+
function gitRefRules(gitDirs: string[], subDirs: string[] = []): string[] {
|
|
102
|
+
const git = hfsFoldedName(".git");
|
|
103
|
+
const re = (s: string) => `(regex ${sbplString(s)})`;
|
|
104
|
+
const name = (n: string) => hfsFoldedName(n);
|
|
105
|
+
// A linked worktree's own submodule git dirs (`worktrees/<wt>/modules/**`) are governed by the #518/#520 rules, not these.
|
|
106
|
+
const plainSub = `(require-not ${re(`^.*/${git}/worktrees/[^/]+/modules(/|$)`)})`;
|
|
107
|
+
const plain = [
|
|
108
|
+
// `.git` (335 bytes) + `HEAD` or `refs` (334) fit one literal: adjacent by construction. `HEAD.lock` goes with `HEAD`.
|
|
109
|
+
re(`^.*/${git}/${name("HEAD")}(\\.lock)?$`),
|
|
110
|
+
re(`^.*/${git}/${name("refs")}(/|$)`),
|
|
111
|
+
// `.git` + `packed-refs` (803) and `.git` + `worktrees` (669) do not fit one literal. The front literal pins the start of the name right under a `.git`, and for `worktrees` the
|
|
112
|
+
// back literal pins its end; `packed` alone covers `packed-refs` and its lock/new files, which is the intent.
|
|
113
|
+
re(`^.*/${git}/${name("packed")}`),
|
|
114
|
+
`(require-all ${re(`^.*/${git}/${name("work")}`)} ${re(`^.*${name("trees")}(/|$)`)} ${plainSub})`,
|
|
115
|
+
];
|
|
116
|
+
const external = [...gitDirs, ...subDirs].flatMap((d) => {
|
|
117
|
+
const own = sbplString(d), direct = re(`^${sbplEscape(d)}/[^/]+$`);
|
|
118
|
+
return [
|
|
119
|
+
`(require-all ${direct} ${re(`^.*/${name("HEAD")}(\\.lock)?$`)})`,
|
|
120
|
+
`(require-all ${direct} ${re(`^.*/${name("packed")}`)})`,
|
|
121
|
+
`(require-all (subpath ${own}) ${re(refUnder(d, "refs"))})`,
|
|
122
|
+
`(require-all (subpath ${own}) ${re(refUnder(d, "worktrees"))} (require-not ${re(`^${sbplEscape(d)}/worktrees/[^/]+/modules(/|$)`)}))`,
|
|
123
|
+
];
|
|
124
|
+
});
|
|
125
|
+
return [...plain, ...external];
|
|
126
|
+
}
|
|
127
|
+
|
|
80
128
|
export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit: readonly { root: string; entries: string[]; dirs?: string[] }[] = [], worktreesRoot?: string): string[] {
|
|
81
129
|
const git = hfsFoldedName(".git");
|
|
82
130
|
const re = (s: string) => `(regex ${sbplString(s)})`;
|
|
@@ -157,6 +205,7 @@ export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit:
|
|
|
157
205
|
`(require-all ${re(`^.*/${git}/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
|
|
158
206
|
re(`^.*/${git}/${hfsFoldedName("hooks")}(/|$)`),
|
|
159
207
|
`(require-all ${re(`^.*/${git}/.*$`)} ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
|
|
208
|
+
...gitRefRules(gitDirs, allDirs),
|
|
160
209
|
...modules,
|
|
161
210
|
...siblings,
|
|
162
211
|
...external,
|
package/src/local/tools.ts
CHANGED
|
@@ -117,7 +117,7 @@ export const TOOL_SCHEMAS = [
|
|
|
117
117
|
fn("write", "Create or overwrite a file inside the project. Needs the user's approval.", { path: str, content: str }, ["path", "content"]),
|
|
118
118
|
fn("edit", "Replace one exact, unique occurrence of `old` with `new` in a file. Needs the user's approval.", { path: str, old: str, new: str }, ["path", "old", "new"]),
|
|
119
119
|
fn("bash", "Run a shell command in the project directory, sandboxed: no writes outside the project, no credential reads, no network unless the hub enables it. Follows hub permission policy: proven bounded read-only commands may run automatically in ask-when-needed; other commands ask.", { command: str, timeout_s: { type: "integer", minimum: 1, maximum: 600 } }, ["command"]),
|
|
120
|
-
fn("git", "Run git in the project. status, diff, log, show, ls-files, blame, rev-parse run freely; add, commit, restore, checkout, switch, stash, mv, rm, branch, merge, rebase, cherry-pick, revert, tag need approval; everything else (push, fetch, config, remote ...) is refused.", { args: { type: "array", items: str, minItems: 1 } }, ["args"]),
|
|
120
|
+
fn("git", "Run git in the project. status, diff, log, show, ls-files, blame, rev-parse run freely; add, commit, restore, checkout, switch, stash, mv, rm, branch, merge, rebase, cherry-pick, revert, tag need approval; everything else (push, fetch, config, remote ...) is refused. The sandbox does not let you move a branch, tag or HEAD anywhere (commit, branch, tag, stash, switch, merge, rebase and reset fail, submodules included), nor stage with add inside a linked worktree: edit files, and the hub commits and pushes them when you call hub_task_done.", { args: { type: "array", items: str, minItems: 1 } }, ["args"]),
|
|
121
121
|
fn("hub_send", "Send a message to the other agents now, before your turn ends. Conclusions only. Your final answer is shared anyway; use this for something that cannot wait.", { text: str, to: { type: "array", items: str } }, ["text"]),
|
|
122
122
|
];
|
|
123
123
|
|
|
@@ -235,7 +235,9 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
235
235
|
// Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
|
|
236
236
|
const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
237
237
|
try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
|
|
238
|
-
|
|
238
|
+
// #1123: the profile refuses writes to refs, HEAD, packed-refs and a linked worktree's admin dir, so a commit, branch, tag, stash or switch cannot succeed here. Say so, with the next step.
|
|
239
|
+
const refused = res.code !== 0 && /(?:\.git\/(?:refs|HEAD|packed-refs|worktrees)\b|cannot lock ref|index\.lock)[^\n]*(?:Operation not permitted|not permitted)/i.test(res.output);
|
|
240
|
+
return `${res.output}${refused ? "\nnote: the sandbox does not let you move a branch, tag or HEAD, or stage inside a linked worktree (#1123). Leave your edits in the working tree and finish with hub_task_done. For implementation work the hub stages, commits and pushes them for you; for work that changes no files, or a private task, there is nothing for it to commit, so do not try to commit it yourself." : ""}\n(exit ${res.code})`;
|
|
239
241
|
}
|
|
240
242
|
case "doc_read": {
|
|
241
243
|
if (!ctx.docRead) return "error: doc_read is not enabled for this project (local.doc_read in .agenthub/config.local.json)";
|
package/src/memory/brief.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildIntentBrief, type IntentBriefOptions, type IntentBriefSource } from "@staix/agent-mem";
|
|
2
|
+
import { controlWriteSpan } from "../hub/control-write-trace.ts";
|
|
2
3
|
import type { PeerId } from "../hub/envelope.ts";
|
|
3
4
|
import type { MemoryClient } from "./client.ts";
|
|
4
5
|
|
|
@@ -70,6 +71,10 @@ export class Briefs {
|
|
|
70
71
|
) {}
|
|
71
72
|
|
|
72
73
|
async forTask(peer: PeerId, task: BriefTask, priority: readonly NativeItem[] = []): Promise<string | undefined> {
|
|
74
|
+
return controlWriteSpan("memory", () => this.forTaskBody(peer, task, priority));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async forTaskBody(peer: PeerId, task: BriefTask, priority: readonly NativeItem[]): Promise<string | undefined> {
|
|
73
78
|
// A brief snapshots its configured cap once, before either optional source is selected.
|
|
74
79
|
const maximum = typeof this.maxItems === "function" ? this.maxItems() : this.maxItems;
|
|
75
80
|
let corpusRevision: string | undefined;
|
package/src/memory/client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import packageJson from "../../package.json";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { controlWriteSpan } from "../hub/control-write-trace.ts";
|
|
3
4
|
import { CaptureStore, SearchIndex, looksSecret, memoryBase, readCaptureConfig, type CaptureOptions as StoreCaptureOptions, type MemoryStore, type CandidateStore, type View } from "@staix/agent-mem";
|
|
4
5
|
|
|
5
6
|
export interface CaptureEvent {
|
|
@@ -54,8 +55,10 @@ export class MemoryClient {
|
|
|
54
55
|
}
|
|
55
56
|
close(): void { this.closed = true; this.releaseCapture(); }
|
|
56
57
|
private run<T>(work: (o: MemoryClientOptions) => T): T | undefined {
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
return controlWriteSpan("memory", () => {
|
|
59
|
+
try { if (!this.options || this.closed) return undefined; this.options.check(); return work(this.options); }
|
|
60
|
+
catch { this.releaseCapture(); this.options?.log?.("amem operation unavailable"); return undefined; }
|
|
61
|
+
});
|
|
59
62
|
}
|
|
60
63
|
private rows(o: MemoryClientOptions): View[] {
|
|
61
64
|
const store = o.store;
|
package/src/ui/office-model.ts
CHANGED
|
@@ -163,6 +163,11 @@ export interface OfficeScene {
|
|
|
163
163
|
links: OfficeLink[];
|
|
164
164
|
/** Sticky desk assignment; pass the whole scene back as `previous` so slots survive churn. */
|
|
165
165
|
slots: Record<string, number>;
|
|
166
|
+
/**
|
|
167
|
+
* Public card data for tasks a desk bubble names that the rendering limits (8 per room, 2 on an overfull desk) left out of
|
|
168
|
+
* every `cards` list. Zero geometry: the dashboard never draws it, acon Space maps it. Same allowlisted fields as `cards`.
|
|
169
|
+
*/
|
|
170
|
+
attributedCards: OfficeCard[];
|
|
166
171
|
/** Task id -> placement ("room:todo" | "room:work" | "room:review" | "shelf:done" | "desk:<peer>" | "counted:unknown"). */
|
|
167
172
|
placed: Record<string, string>;
|
|
168
173
|
arcs: OfficeArc[];
|
|
@@ -359,6 +364,17 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
|
|
|
359
364
|
: state === "in_progress" || state === "changes_requested" ? (owner && shownSet.has(owner) ? `desk:${owner}` : "room:work")
|
|
360
365
|
: state === "backlog" ? "counted:backlog" : "counted:unknown";
|
|
361
366
|
}
|
|
367
|
+
// A bubble's attributed task may sit past the rendering cut; keep its public card so a consumer that draws more cards than the page can.
|
|
368
|
+
const drawn = new Set<number>([...desks.flatMap((desk) => desk.cards), ...rooms.flatMap((room) => room.cards)].map((entry) => entry.id));
|
|
369
|
+
const taskById = new Map<number, OfficeTaskInput>(tasks.map((task) => [task.id, task]));
|
|
370
|
+
const attributedCards: OfficeCard[] = [];
|
|
371
|
+
for (const desk of desks) {
|
|
372
|
+
const id = /^#(\d+) /.exec(desk.bubble)?.[1];
|
|
373
|
+
const task = id === undefined ? undefined : taskById.get(Number(id));
|
|
374
|
+
if (!task || drawn.has(task.id)) continue;
|
|
375
|
+
drawn.add(task.id);
|
|
376
|
+
attributedCards.push(card(task, 0, 0, 0, 0));
|
|
377
|
+
}
|
|
362
378
|
const transitions: OfficeTransitions = { desksAdded: [], desksRemoved: [], cardsMoved: [] };
|
|
363
379
|
if (previous) {
|
|
364
380
|
const prevPlaced = previous.placed ?? {};
|
|
@@ -440,7 +456,7 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
|
|
|
440
456
|
cols,
|
|
441
457
|
width: Math.max(desksWidth, ...rooms.map(room => room.x + room.w), 1),
|
|
442
458
|
height: Math.max(desksBottom, ...rooms.map(room => room.y + room.h), 1),
|
|
443
|
-
desks, extraPeers, rooms, counts, transitions, slots, placed,
|
|
459
|
+
desks, extraPeers, rooms, counts, transitions, slots, placed, attributedCards,
|
|
444
460
|
links,
|
|
445
461
|
arcs: keptArcs, alerts: keptAlerts, latched, settle, at, eventTime, lastSeq,
|
|
446
462
|
};
|
package/src/ui/office-space.ts
CHANGED
|
@@ -70,7 +70,7 @@ export function officeSpace(model: OfficeScene, project: { id: string; label?: s
|
|
|
70
70
|
};
|
|
71
71
|
const cards: SpaceScene["cards"] = [];
|
|
72
72
|
const seen = new Set<number>();
|
|
73
|
-
for (const card of [...model.desks.flatMap((d) => d.cards), ...model.rooms.flatMap((r) => r.cards)]) {
|
|
73
|
+
for (const card of [...model.desks.flatMap((d) => d.cards), ...model.rooms.flatMap((r) => r.cards), ...(model.attributedCards ?? [])]) {
|
|
74
74
|
if (seen.has(card.id)) continue;
|
|
75
75
|
seen.add(card.id);
|
|
76
76
|
const zone = zoneOf(model.placed[String(card.id)]);
|
package/templates/config.json
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"tasks": { "release_after_min": 30 },
|
|
33
33
|
"checks": { "timeout_s": 600 },
|
|
34
34
|
"snapshots": { "enabled": true, "keep": 20 },
|
|
35
|
+
"specs": { "enabled": true, "review": "report", "roots": [], "stamps_file": "" },
|
|
35
36
|
"review": { "adaptive": false, "min_reviews": 5 },
|
|
36
37
|
"recovery": { "auto_resume_after_crash": false },
|
|
37
38
|
"capabilities": {},
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"review_checklist": [
|
|
23
23
|
"Map the changed signatures and call sites to {target}, and name each one that does not match.",
|
|
24
24
|
"{check}",
|
|
25
|
+
"Name each new or changed error, refusal or failure message that lacks its cause (what failed and why) or its next step (the command, setting or target that fixes it); where secrets, ids or paths must stay out, it names the cause class and how to check it.",
|
|
25
26
|
"List what is unmet in hub_review's unmet, one item each."
|
|
26
27
|
]
|
|
27
28
|
}
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"review_checklist": [
|
|
27
27
|
"Assess the deliverables against {target}, and name each one that does not match.",
|
|
28
28
|
"{check}",
|
|
29
|
+
"Name each new or changed error, refusal or failure message that lacks its cause (what failed and why) or its next step (the command, setting or target that fixes it); where secrets, ids or paths must stay out, it names the cause class and how to check it.",
|
|
29
30
|
"List what is unmet in hub_review's unmet, one item each."
|
|
30
31
|
]
|
|
31
32
|
}
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"review_checklist": [
|
|
58
58
|
"Assess scope, provenance and unsupported claims against {target}, and name each one that does not match.",
|
|
59
59
|
"{check}",
|
|
60
|
+
"Name each new or changed error, refusal or failure message that lacks its cause (what failed and why) or its next step (the command, setting or target that fixes it); where secrets, ids or paths must stay out, it names the cause class and how to check it.",
|
|
60
61
|
"List what is unmet in hub_review's unmet, one item each."
|
|
61
62
|
]
|
|
62
63
|
}
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"review_checklist": [
|
|
29
29
|
"Assess scope, provenance and unsupported claims against {target}, and name each one that does not match.",
|
|
30
30
|
"{check}",
|
|
31
|
+
"Name each new or changed error, refusal or failure message that lacks its cause (what failed and why) or its next step (the command, setting or target that fixes it); where secrets, ids or paths must stay out, it names the cause class and how to check it.",
|
|
31
32
|
"List what is unmet in hub_review's unmet, one item each."
|
|
32
33
|
]
|
|
33
34
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
## Design specs
|
|
2
|
+
|
|
3
|
+
- Design specs are Markdown files with `id` and `type` frontmatter whose `paths` name the files they govern. The hub names the specs covering a task when you propose or accept it, and `hub_specs` answers on request.
|
|
4
|
+
- Read a covering spec when your change touches a boundary, contract, invariant or decision it states. Follow it, or reconcile the contradiction; never silently diverge.
|
|
5
|
+
- When your change alters one of those, update the owning spec in the same change. Never edit `verified-against`; a person stamps it.
|