@staix/agent-hub 0.12.43 → 0.12.45
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 +38 -3
- package/README.md +3 -3
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +228 -0
- package/docs/agent-notes/README.md +14 -0
- package/docs/agent-notes/adapters.md +61 -42
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/daemon.md +16 -10
- package/docs/agent-notes/local-worker.md +4 -3
- package/docs/agent-notes/tasks.md +13 -6
- package/docs/agent-notes/tests.md +9 -7
- package/docs/operations.md +163 -12
- package/docs/quickstart.md +10 -2
- package/docs/sdlc.md +26 -8
- package/docs/security.md +11 -6
- package/docs/specs/2026-09-19-agent-hub-design.md +273 -8
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verified.json +38 -20
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10287 -9560
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +4 -4
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +21 -12
- package/src/adapters/codex-appserver.ts +17 -2
- package/src/adapters/configured-acp.ts +124 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +60 -39
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +9 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +7 -9
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/jcode.ts +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +6 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +28 -16
- package/src/adapters/registry.ts +115 -17
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +9 -4
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +27 -16
- package/src/cli/identity.ts +8 -7
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.ts +101 -32
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +16 -13
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +6 -4
- package/src/cli/upgrade-runtime.ts +25 -7
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +37 -8
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +37 -17
- package/src/hub/config-reload.ts +42 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +412 -328
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +57 -5
- package/src/hub/instance-config.ts +33 -12
- package/src/hub/instance-execution.ts +23 -11
- package/src/hub/instances.ts +47 -22
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/peers.ts +9 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +35 -6
- package/src/hub/routing.ts +21 -20
- package/src/hub/sdlc-queue.ts +19 -4
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +27 -12
- package/src/hub/settings.ts +54 -16
- package/src/hub/snapshots.ts +32 -6
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +358 -60
- package/src/hub/ui.ts +2 -2
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +563 -77
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/hub/facts.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { repoGitArgs, repoGitEnv, type Repo } from "./snapshots.ts";
|
|
2
2
|
import { isHubWritePath } from "./permission-mode.ts";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync, type Stats } from "node:fs";
|
|
5
5
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
6
|
import { sanitize, type PeerId } from "./envelope.ts";
|
|
7
7
|
import { isDenied } from "../local/deny.ts";
|
|
@@ -24,6 +24,22 @@ import { realPath } from "./project.ts";
|
|
|
24
24
|
const MAX_BYTES = 256 * 1024;
|
|
25
25
|
/** Larger files that are not read are hashed for the HEAD comparison while all of them in one comparison total this much or less; beyond it, none is compared. */
|
|
26
26
|
const HASHED_BYTES = 64 * 1024 * 1024;
|
|
27
|
+
/**
|
|
28
|
+
* A large file whose last change (ctime) is this recent may still be rewritten with the same stat: Linux before 6.13
|
|
29
|
+
* stamps files from a coarse clock, so a same-size rewrite within one tick keeps size, mtime, ctime and inode (#428).
|
|
30
|
+
* Such a file is named by its bytes, as git treats a racily clean entry: at most RACY_HASH_MAX_BYTES each, hashed once
|
|
31
|
+
* per version in a public entry point, RACY_BURST_BYTES in all per store (Facts instance) per event-loop turn (the hooks
|
|
32
|
+
* run on the daemon's loop). The HEAD comparison keeps its own bound (HASHED_BYTES) and is not limited by these.
|
|
33
|
+
* ponytail: ceilings, each falling back to the stat-only stamp (no claim either way): a racy file over the per-file cap,
|
|
34
|
+
* or past the turn's budget, misses a same-size rewrite within one timestamp tick; a racy file first seen past the
|
|
35
|
+
* budget (kept stat-only) reads as changed once when a later turn hashes it with the same bytes; a file written continuously never settles and is hashed at every look, within that budget; FAT's 2 s mtime
|
|
36
|
+
* is the widest covered; a settled file made racy with the same bytes (chmod, `cp -p`, touch) reads as changed once; a
|
|
37
|
+
* version first seen racy costs an open and a stat at every later look (largeBlob's cache answers). A per-file history
|
|
38
|
+
* of racy versions if any of these matters.
|
|
39
|
+
*/
|
|
40
|
+
const RACY_MS = 2000;
|
|
41
|
+
const RACY_HASH_MAX_BYTES = 4 * 1024 * 1024;
|
|
42
|
+
const RACY_BURST_BYTES = 16 * 1024 * 1024;
|
|
27
43
|
/** Changed lines shown in one fact, all files together; the rest is counted and the files named. */
|
|
28
44
|
export const MAX_LINES = 60;
|
|
29
45
|
/** What Claude Code's Read returns by default: the first 2000 lines, each cut at 2000 characters. */
|
|
@@ -145,6 +161,10 @@ export class Facts {
|
|
|
145
161
|
private readonly seenDropped = new Map<PeerId, Set<string>>();
|
|
146
162
|
/** Git blob ids of large files, by path, with the stat they were taken at. */
|
|
147
163
|
private readonly largeBlobs = new Map<string, { stamp: string; blob: string }>();
|
|
164
|
+
/** Blob ids of racy large files hashed in the current boundary, by path and stat (#428). */
|
|
165
|
+
private racyMemo = new Map<string, string>();
|
|
166
|
+
/** Bytes of racy large files left to hash in this event-loop turn. */
|
|
167
|
+
private racyLeft?: { bytes: number };
|
|
148
168
|
/** Directories each peer acknowledged being told were cut: once per session, not at every boundary. */
|
|
149
169
|
private readonly cutTold = new Map<PeerId, Set<string>>();
|
|
150
170
|
/**
|
|
@@ -217,7 +237,7 @@ export class Facts {
|
|
|
217
237
|
* ponytail: a directory swapped between the containment check and the open is followed once; an fd-relative walk
|
|
218
238
|
* (openat per component) closes that if it matters.
|
|
219
239
|
*/
|
|
220
|
-
private load(file: string): Omit<Version, "seq"> {
|
|
240
|
+
private load(file: string, statOnly = false): Omit<Version, "seq"> {
|
|
221
241
|
if (this.rel(join(this.root, file)) !== file) return { hash: "missing" };
|
|
222
242
|
const projected = this.o.projection?.(join(this.root, file));
|
|
223
243
|
if (projected) return { hash: projected.rawHash, blob: projected.rawBlob, text: projected.text, projected: true };
|
|
@@ -226,7 +246,10 @@ export class Facts {
|
|
|
226
246
|
fd = openSync(join(this.root, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
227
247
|
const st = fstatSync(fd);
|
|
228
248
|
if (!st.isFile()) return { hash: "missing" }; // a directory, a device or a fifo: never read
|
|
229
|
-
if (st.size > MAX_BYTES)
|
|
249
|
+
if (st.size > MAX_BYTES) {
|
|
250
|
+
const blob = statOnly ? undefined : this.racyBlob(file, fd, st);
|
|
251
|
+
return blob ? { hash: `large:${st.size}:${st.mtimeMs}:${blob}`, blob } : { hash: `large:${st.size}:${st.mtimeMs}` };
|
|
252
|
+
}
|
|
230
253
|
const buf = Buffer.alloc(st.size);
|
|
231
254
|
let got = 0;
|
|
232
255
|
while (got < buf.length) {
|
|
@@ -317,13 +340,17 @@ export class Facts {
|
|
|
317
340
|
const seen = files.filter((f) => head.has(f)).map((f) => [f, version(f)] as const);
|
|
318
341
|
const large = seen.reduce((n, [, v]) => n + (v?.hash.startsWith("large:") ? Number(v.hash.split(":")[1]) : 0), 0);
|
|
319
342
|
return new Set(seen.filter(([f, v]) => {
|
|
320
|
-
const blob = v?.
|
|
343
|
+
const blob = !v?.hash.startsWith("large:") ? v?.blob : large <= HASHED_BYTES ? v.blob ?? this.largeBlob(f, v.hash, true) : undefined;
|
|
321
344
|
return blob !== undefined && blob === head.get(f);
|
|
322
345
|
}).map(([f]) => f));
|
|
323
346
|
}
|
|
324
347
|
|
|
325
348
|
/** Git's blob id of a file too large to load, streamed; undefined for anything that is not a small enough regular file. */
|
|
326
|
-
|
|
349
|
+
/**
|
|
350
|
+
* `head`: for the HEAD comparison, which bounds its own hashing (HASHED_BYTES): a racy file is streamed whatever the
|
|
351
|
+
* racy cap and the turn's budget, uncached. Otherwise a racy file goes through racyBlob's limits.
|
|
352
|
+
*/
|
|
353
|
+
private largeBlob(file: string, observed: string, head = false): string | undefined {
|
|
327
354
|
if (this.rel(join(this.root, file)) !== file) return undefined;
|
|
328
355
|
let fd: number | undefined;
|
|
329
356
|
try {
|
|
@@ -333,13 +360,10 @@ export class Facts {
|
|
|
333
360
|
// A file that stays stat-dirty but equal to HEAD is looked at again at every boundary: hashed once per version.
|
|
334
361
|
const stamp = `${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`, known = this.largeBlobs.get(file);
|
|
335
362
|
if (known?.stamp === stamp) return known.blob;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (got !== st.size) return undefined; // changed while read: no claim
|
|
341
|
-
const blob = h.digest("hex");
|
|
342
|
-
this.largeBlobs.set(file, { stamp, blob });
|
|
363
|
+
// A racy stamp may name other bytes later: never cached.
|
|
364
|
+
if (Date.now() - st.ctimeMs < RACY_MS) return head ? this.racyMemo.get(racyKey(file, st)) ?? streamBlob(fd, st.size) : this.racyBlob(file, fd, st);
|
|
365
|
+
const blob = streamBlob(fd, st.size);
|
|
366
|
+
if (blob) this.largeBlobs.set(file, { stamp, blob });
|
|
343
367
|
return blob;
|
|
344
368
|
} catch {
|
|
345
369
|
return undefined;
|
|
@@ -348,6 +372,22 @@ export class Facts {
|
|
|
348
372
|
}
|
|
349
373
|
}
|
|
350
374
|
|
|
375
|
+
/** A racy large file's blob id (#428), or undefined when it is settled, over the per-file cap or past the turn's budget. */
|
|
376
|
+
private racyBlob(file: string, fd: number, st: Stats): string | undefined {
|
|
377
|
+
if (Date.now() - st.ctimeMs >= RACY_MS || st.size > RACY_HASH_MAX_BYTES) return undefined;
|
|
378
|
+
const key = racyKey(file, st), known = this.racyMemo.get(key);
|
|
379
|
+
if (known) return known;
|
|
380
|
+
if (!this.racyLeft) {
|
|
381
|
+
const turn = this.racyLeft = { bytes: RACY_BURST_BYTES };
|
|
382
|
+
setImmediate(() => { if (this.racyLeft === turn) this.racyLeft = undefined; });
|
|
383
|
+
}
|
|
384
|
+
if (st.size > this.racyLeft.bytes) return undefined;
|
|
385
|
+
this.racyLeft.bytes -= st.size;
|
|
386
|
+
const blob = streamBlob(fd, st.size);
|
|
387
|
+
if (blob) this.racyMemo.set(key, blob);
|
|
388
|
+
return blob;
|
|
389
|
+
}
|
|
390
|
+
|
|
351
391
|
/** `file` is covered again: an offer still unread that named it as dropped must not spend the notice of a later drop. */
|
|
352
392
|
private coveredAgain(peer: PeerId, notice: "untracked" | "unfollowed", file: string): void {
|
|
353
393
|
for (const o of this.offers.get(peer) ?? []) if (o[notice]?.includes(file)) o[notice] = o[notice]!.filter((f) => f !== file);
|
|
@@ -357,6 +397,7 @@ export class Facts {
|
|
|
357
397
|
private boundary(): void {
|
|
358
398
|
this.expanded = new Map();
|
|
359
399
|
this.expandedCut = new Set();
|
|
400
|
+
this.racyMemo = new Map(); // not per turn: a rewrite between two entry points of one turn is another version
|
|
360
401
|
}
|
|
361
402
|
|
|
362
403
|
/** `peer` worked on `file`; `seen` is the file as it was when it did, the baseline if it has no view of it yet. */
|
|
@@ -463,6 +504,12 @@ export class Facts {
|
|
|
463
504
|
const now = this.load(file);
|
|
464
505
|
const was = this.latest.get(file);
|
|
465
506
|
if (was && was.hash === now.hash) return was;
|
|
507
|
+
// Seen while racy (named by its bytes), now with the same stat but no blob (settled, or past the turn's budget): the
|
|
508
|
+
// same version if the bytes are, or when they cannot be hashed now (no claim).
|
|
509
|
+
if (was?.blob && was.hash === `${now.hash}:${was.blob}`) {
|
|
510
|
+
const blob = this.largeBlob(file, now.hash);
|
|
511
|
+
if (blob === undefined || blob === was.blob) return was;
|
|
512
|
+
}
|
|
466
513
|
const version = { ...now, seq: ++this.seq };
|
|
467
514
|
this.latest.set(file, version);
|
|
468
515
|
if (was) {
|
|
@@ -720,7 +767,9 @@ export class Facts {
|
|
|
720
767
|
// does, nor what they wrote before or after (a member settling keeps its files in the set).
|
|
721
768
|
const written = windows.flatMap((w) => [...(this.writes.get(w.peer) ?? [])].filter(([, times]) => times.some((t) => t >= w.since && (w.until === undefined || t <= w.until))).map(([file]) => file));
|
|
722
769
|
const files = [...new Set([...this.expand(paths.flatMap((p) => { const r = this.rel(p); return r ? [r] : []; }), true), ...written])].sort();
|
|
723
|
-
|
|
770
|
+
// ponytail: a large file counts by its stat, racy or not, so the target does not move when it settles; a same-size
|
|
771
|
+
// rewrite within one timestamp tick does not move it either (observe() sees that one).
|
|
772
|
+
for (const file of files) h.update(file).update("\0").update(this.load(file, true).hash).update("\0");
|
|
724
773
|
return h.digest("hex");
|
|
725
774
|
}
|
|
726
775
|
|
|
@@ -871,3 +920,17 @@ export function codexEffect(before: string | undefined, now: { hash: string; tex
|
|
|
871
920
|
const observed = changedLines(diff(before, now.text));
|
|
872
921
|
return reported.length > 0 && reported.length === observed.length && reported.every((l, i) => l === observed[i]);
|
|
873
922
|
}
|
|
923
|
+
|
|
924
|
+
/** Git's blob id of the `size` bytes `fd` reads, streamed; undefined when fewer were read (changed while read). */
|
|
925
|
+
function streamBlob(fd: number, size: number): string | undefined {
|
|
926
|
+
const h = createHash("sha1").update(`blob ${size}\0`);
|
|
927
|
+
const buf = Buffer.alloc(1024 * 1024);
|
|
928
|
+
let got = 0;
|
|
929
|
+
for (let n = 0; got < size && (n = readSync(fd, buf, 0, buf.length, got)) > 0; got += n) h.update(buf.subarray(0, n));
|
|
930
|
+
return got === size ? h.digest("hex") : undefined;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** A racy large file's version within one boundary: its path and every stat field. */
|
|
934
|
+
function racyKey(file: string, st: Stats): string {
|
|
935
|
+
return `${file}\0${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`;
|
|
936
|
+
}
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -17,10 +17,26 @@ const refs = {
|
|
|
17
17
|
additionalProperties: false,
|
|
18
18
|
} as const;
|
|
19
19
|
const list = (description: string) => ({ type: "array", items: str, description }) as const;
|
|
20
|
+
const reviewFindings = {
|
|
21
|
+
type: "array", maxItems: 64,
|
|
22
|
+
description: "Head-bound findings; open Important findings carry across heads until explicitly fixed, false_positive or person-accepted; omission never settles them. Nits stay on their head. accepted is person-only and requires rationale. Nit count is capped by sdlc.max_nits (default 5).",
|
|
23
|
+
items: { type: "object", properties: {
|
|
24
|
+
id: { type: "string", minLength: 1, maxLength: 96, pattern: "^[A-Za-z0-9._:-]+$" },
|
|
25
|
+
pass: { type: "string", enum: ["bugs", "security", "compliance"] },
|
|
26
|
+
severity: { type: "string", enum: ["important", "nit"] },
|
|
27
|
+
path: { type: "string", minLength: 1, maxLength: 512 },
|
|
28
|
+
disposition: { type: "string", enum: ["open", "fixed", "false_positive", "accepted"] },
|
|
29
|
+
rationale: { type: "string", minLength: 1, maxLength: 2048 },
|
|
30
|
+
}, required: ["id", "pass", "severity", "disposition"], additionalProperties: false },
|
|
31
|
+
} as const;
|
|
20
32
|
const costPlanProperties = {
|
|
21
33
|
review: { type: "object", properties: { scope: { type: "string", enum: ["none", "delta", "full"] }, reviewers: { type: "integer", minimum: 0, maximum: 8 } }, required: ["scope", "reviewers"], additionalProperties: false },
|
|
22
34
|
ci: { type: "string", enum: ["focused", "full", "reuse"] },
|
|
23
35
|
} as const;
|
|
36
|
+
const evidencePlanProperties = {
|
|
37
|
+
proof: { type: "array", maxItems: 32, items: { type: "string", maxLength: 512 }, description: "test paths or check names planned to prove the change" },
|
|
38
|
+
risks: { type: "array", maxItems: 32, items: { type: "string", maxLength: 512 }, description: "short risks to check during implementation and review" },
|
|
39
|
+
} as const;
|
|
24
40
|
const plan = {
|
|
25
41
|
type: "object",
|
|
26
42
|
description: "what you will change, before you start. Owners of open tasks on the same files or symbols see it, and get a notice when your task is done.",
|
|
@@ -75,7 +91,7 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
75
91
|
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
|
|
76
92
|
tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
|
|
77
93
|
tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
|
|
78
|
-
tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" }, coverage: { type: "object", description: "the review brief's reviewable paths you read (reviewed) and any you did not (skipped, each with a reason); in enforce an approval must account for every one", properties: { reviewed: { type: "array", items: str }, skipped: { type: "array", items: { type: "object", properties: { path: str, reason: str }, required: ["path", "reason"] } } } } }, ["id", "verdict"]),
|
|
94
|
+
tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, findings: reviewFindings, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" }, coverage: { type: "object", description: "the review brief's reviewable paths you read (reviewed) and any you did not (skipped, each with a reason); in enforce an approval must account for every one", properties: { reviewed: { type: "array", items: str }, skipped: { type: "array", items: { type: "object", properties: { path: str, reason: str }, required: ["path", "reason"] } } } } }, ["id", "verdict"]),
|
|
79
95
|
tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
|
|
80
96
|
tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
|
|
81
97
|
tool("hub_memory_show", "Read up to 8 visible memory ids or unique prefixes (6..64 UTF-8 bytes each). Original ids redirect; unavailable ids share one refusal. Whole output <=8 KB.", { ids: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 6, maxLength: 64, description: "6 to 64 UTF-8 bytes" } } }, ["ids"]),
|
|
@@ -105,6 +121,42 @@ export const CONDUCTOR_TOOLS: HubTool[] = [
|
|
|
105
121
|
];
|
|
106
122
|
export const CONDUCTOR_TOOL_NAMES = new Set(CONDUCTOR_TOOLS.map((t) => t.name));
|
|
107
123
|
|
|
124
|
+
/** Authenticated status metadata only; malformed values never add a tool target. */
|
|
125
|
+
function projectPeerIds(value: unknown): string[] | undefined {
|
|
126
|
+
if (!Array.isArray(value) || value.length > 256 || value.some(id => typeof id !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(id) || ["user", "hub", "digest"].includes(id)) || new Set(value).size !== value.length) return undefined;
|
|
127
|
+
return [...value] as string[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A project-local schema projection; released builtin arrays and all other tools keep their identity. */
|
|
131
|
+
export function projectConductorTools(startPeers?: unknown): HubTool[] {
|
|
132
|
+
const peers = startPeers === undefined ? undefined : projectPeerIds(startPeers);
|
|
133
|
+
if (peers === undefined) return CONDUCTOR_TOOLS;
|
|
134
|
+
return CONDUCTOR_TOOLS.map(tool => tool.name !== "hub_peer_start" ? tool : {
|
|
135
|
+
...tool, inputSchema: { ...tool.inputSchema, properties: { ...tool.inputSchema.properties,
|
|
136
|
+
peer: { ...(tool.inputSchema.properties.peer as Record<string, unknown>), enum: [...new Set([...CONDUCTOR_START_PEERS, ...peers])] },
|
|
137
|
+
} },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface ProjectToolContext { startPeers?: string[]; readOnly?: boolean }
|
|
142
|
+
/** Ordinary projects retain their exact prior publication context; configured metadata is body-free and bounded. */
|
|
143
|
+
export function configuredToolContext(status: unknown, peer: string): ProjectToolContext {
|
|
144
|
+
if (!status || typeof status !== "object" || Array.isArray(status)) return {};
|
|
145
|
+
const raw = status as Record<string, unknown>, configured = projectPeerIds(raw.configuredAdapters);
|
|
146
|
+
if (!configured?.length) return {};
|
|
147
|
+
const startPeers = projectPeerIds(raw.startablePeers), readOnlyPeers = projectPeerIds(raw.readOnlyPeers);
|
|
148
|
+
// With an incomplete configured-project reading, publish only R2 reads until authenticated status is complete.
|
|
149
|
+
if (!startPeers || !readOnlyPeers) return { readOnly: true };
|
|
150
|
+
return { startPeers, readOnly: readOnlyPeers.includes(peer) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_status"]);
|
|
154
|
+
/** Publication restricts exposure only; server-side authority remains mandatory for every call. */
|
|
155
|
+
export function projectToolList<T extends { name: string }>(tools: T[], readOnly = false): T[] {
|
|
156
|
+
return readOnly ? tools.filter(tool => READ_ONLY_PROJECT_TOOLS.includes(tool.name)) : tools;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
108
160
|
/** Role contracts, by role name. Shown to each peer for the roles `.agenthub/config.json` gives it. */
|
|
109
161
|
export const ROLE_TEXT: Record<string, string> = {
|
|
110
162
|
conductor: "conductor: plan and split work into tasks with owners, watch the team with hub_status, move stalled work, and ensure every task is reviewed (review it yourself only if you also hold reviewer). Report results and open decisions to the person. Do not implement tasks you handed out. Never ask a peer to answer an approval; ask the person for human-only actions. You may release only holds you placed, never human holds or budget pauses.",
|
|
@@ -144,19 +196,19 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
|
|
|
144
196
|
properties.receipt = { ...receipt, properties: fields };
|
|
145
197
|
}
|
|
146
198
|
if (customProfile && tool.name === "hub_task_propose") properties.class = { type: "string", enum: [...new Set([...Object.keys(data.classes), ...PROFILE_CLASS_NAMES.filter(v => projectProfile("dev").classes[v])])] };
|
|
147
|
-
if (customProfile && properties.plan && tool.name !== "hub_task_accept") properties.plan = { ...plan, properties: Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) };
|
|
199
|
+
if (customProfile && properties.plan && tool.name !== "hub_task_accept") properties.plan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
|
|
148
200
|
if (customProfile && tool.name === "hub_backlog_split") {
|
|
149
201
|
const parts = properties.parts as { items: { properties: Record<string, unknown> } };
|
|
150
|
-
const selectedPlan = { ...plan, properties: Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) };
|
|
202
|
+
const selectedPlan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
|
|
151
203
|
properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
|
|
152
204
|
}
|
|
153
205
|
if (criteria && properties.plan) {
|
|
154
206
|
const current = properties.plan as { properties: Record<string, unknown> };
|
|
155
|
-
properties.plan = { ...current, properties: { ...current.properties, ...costPlanProperties } };
|
|
207
|
+
properties.plan = { ...current, properties: { ...current.properties, ...costPlanProperties, ...evidencePlanProperties } };
|
|
156
208
|
}
|
|
157
209
|
if (criteria && tool.name === "hub_backlog_split") {
|
|
158
210
|
const parts = properties.parts as { items: { properties: { plan: { properties: Record<string, unknown> } } } };
|
|
159
|
-
properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: { ...parts.items.properties.plan, properties: { ...parts.items.properties.plan.properties, ...costPlanProperties } } } } };
|
|
211
|
+
properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: { ...parts.items.properties.plan, properties: { ...parts.items.properties.plan.properties, ...costPlanProperties, ...evidencePlanProperties } } } } };
|
|
160
212
|
}
|
|
161
213
|
return { ...tool, ...(customProfile && tool.name === "hub_task_propose" ? { description: tool.description + ` Selected profile ${data.name}: ${Object.keys(data.classes).join(", ")}. Routing uses their existing base classes.` } : {}), inputSchema: { ...tool.inputSchema, properties } };
|
|
162
214
|
});
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { defaultPeerRoles } from "../adapters/registry.ts";
|
|
3
|
+
import { createProjectAdapterCatalog, defaultPeerRoles, definitionOf, type ProjectAdapterCatalog, type AdapterDefinition } from "../adapters/registry.ts";
|
|
4
|
+
import { readAdapterModuleDeclarations, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
|
|
5
|
+
import { externalMetadataDefinitions } from "../adapters/external-metadata.ts";
|
|
6
|
+
import { readControl, stateDirFor } from "./control-client.ts";
|
|
4
7
|
import { conductorPeer } from "./conductor.ts";
|
|
5
8
|
import { configRefusal } from "./config-trust.ts";
|
|
6
9
|
import { validateInstances, type InstancesConfig } from "./instances.ts";
|
|
@@ -17,24 +20,42 @@ export function readInstanceConfigLayers(cwd: string): InstanceConfigLayer[] {
|
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
/** Declaration authority is file provenance, never a suffix or a claim made by a native child. */
|
|
20
|
-
export
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
export interface InstanceRoleConfig {
|
|
24
|
+
adapter_modules?: readonly AdapterModuleDeclaration[];
|
|
25
|
+
adapters?: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
|
|
26
|
+
instances?: InstancesConfig;
|
|
27
|
+
catalog: ProjectAdapterCatalog;
|
|
28
|
+
ignored: string[];
|
|
29
|
+
}
|
|
30
|
+
export function readInstanceConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd), externalDefinitions?: readonly AdapterDefinition[]): InstanceRoleConfig {
|
|
31
|
+
const moduleConfig = readAdapterModuleDeclarations(cwd, layers);
|
|
32
|
+
const ignored: string[] = [...moduleConfig.ignored];
|
|
33
|
+
let adapters: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>> | undefined, rawInstances: unknown;
|
|
23
34
|
for (const { name, raw } of layers) {
|
|
24
|
-
const
|
|
25
|
-
if (!
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
35
|
+
const keys = ["adapters", "instances"].filter(key => raw[key] !== undefined);
|
|
36
|
+
if (!keys.length) continue;
|
|
37
|
+
const refusal = name !== "config.local.json" ? "declarations are machine-local config.local.json values" : configRefusal(cwd, name);
|
|
38
|
+
if (refusal) { ignored.push(`${keys.join(", ")} in .agenthub/${name} ignored: ${refusal}`); continue; }
|
|
39
|
+
if (raw.adapters !== undefined) adapters = raw.adapters as Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
|
|
40
|
+
if (raw.instances !== undefined) rawInstances = raw.instances;
|
|
41
|
+
}
|
|
42
|
+
let external = externalDefinitions;
|
|
43
|
+
if (external === undefined) {
|
|
44
|
+
// Metadata discovery is optional: retained recovery receipts may name a project that no longer exists.
|
|
45
|
+
let metadata;
|
|
46
|
+
try { metadata = readControl(stateDirFor(cwd))?.externalAdapters; } catch { /* no running catalog available */ }
|
|
47
|
+
external = externalMetadataDefinitions(metadata);
|
|
30
48
|
}
|
|
31
|
-
|
|
49
|
+
const catalog = createProjectAdapterCatalog(adapters, external);
|
|
50
|
+
const instances = validateInstances(rawInstances, catalog);
|
|
51
|
+
return { ...(moduleConfig.modules.length ? { adapter_modules: moduleConfig.modules } : {}), ...(adapters !== undefined ? { adapters } : {}), ...(Object.keys(instances).length ? { instances } : {}), catalog, ignored };
|
|
32
52
|
}
|
|
33
53
|
|
|
34
54
|
/** Capture ordinary role policy and trusted instance declarations together, without loading daemon configuration. */
|
|
35
|
-
export function readInstanceRoleConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)):
|
|
55
|
+
export function readInstanceRoleConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)): InstanceRoleConfig & { roles: Record<string, string[]> } {
|
|
36
56
|
const declarationConfig = readInstanceConfig(cwd, layers);
|
|
37
57
|
const roles = defaultPeerRoles(false);
|
|
58
|
+
for (const definition of declarationConfig.catalog.definitions) if (!definitionOf(definition.kind)) roles[definition.kind] = [...definition.roles];
|
|
38
59
|
for (const name of INSTANCE_CONFIG_FILES) {
|
|
39
60
|
const raw = layers.find(layer => layer.name === name)?.raw;
|
|
40
61
|
if (raw?.roles !== undefined) {
|
|
@@ -1,23 +1,35 @@
|
|
|
1
1
|
import { lstatSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import type { ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
4
4
|
import { hubGitSync } from "./git.ts";
|
|
5
5
|
import { realPath } from "./project.ts";
|
|
6
6
|
import { validateInstances, type InstancesConfig } from "./instances.ts";
|
|
7
7
|
|
|
8
8
|
const refuse = (reason: string): never => { throw new Error(`instance execution workspace refused: ${reason}`); };
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
// #225: this runs synchronously on the daemon's event loop (an extra-instance start, turn_revert), and a resolution makes up to four git
|
|
10
|
+
// calls. One allowance covers all of them, so the loop is held for at most GIT_BUDGET_MS in git however many calls remain; a call that
|
|
11
|
+
// finds the allowance spent refuses without starting git. ponytail: the calls stay synchronous because the CLI and recovery callers are
|
|
12
|
+
// synchronous by contract; an async twin for the two daemon call sites is the upgrade if a typical resolution ever costs more than a few ms
|
|
13
|
+
// (measured about 15 ms for four calls on a warm local repository).
|
|
14
|
+
const GIT_BUDGET_MS = 5000, GIT_OUTPUT_BYTES = 1024 * 1024;
|
|
15
|
+
type Gitter = (cwd: string, args: string[]) => string;
|
|
16
|
+
function gitWithin(budgetMs: number, clock: () => number, run: typeof hubGitSync = hubGitSync): Gitter {
|
|
17
|
+
const deadline = clock() + budgetMs;
|
|
18
|
+
return (cwd, args) => {
|
|
19
|
+
const left = Math.floor(deadline - clock());
|
|
20
|
+
if (left <= 0) return refuse("Git workspace evidence unavailable");
|
|
21
|
+
const result = run(["-C", cwd, ...args], { encoding: "utf8", timeout: left, maxBuffer: GIT_OUTPUT_BYTES });
|
|
22
|
+
if (result.error || result.status !== 0) return refuse("Git workspace evidence unavailable");
|
|
23
|
+
return result.stdout;
|
|
24
|
+
};
|
|
14
25
|
}
|
|
15
|
-
const commonDirectory = (cwd: string): string => realPath(git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).replace(/\n$/, ""));
|
|
26
|
+
const commonDirectory = (git: Gitter, cwd: string): string => realPath(git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).replace(/\n$/, ""));
|
|
16
27
|
|
|
17
28
|
/** Point-in-time read only. No worktree is created, repaired or chosen implicitly. */
|
|
18
|
-
export function resolveInstanceExecutionCwd(projectRoot: string, id: string, declarations: InstancesConfig | undefined, roles: readonly string[]): string {
|
|
19
|
-
if (definitionOf(id)) return projectRoot;
|
|
20
|
-
const
|
|
29
|
+
export function resolveInstanceExecutionCwd(projectRoot: string, id: string, declarations: InstancesConfig | undefined, roles: readonly string[], adapterCatalog: ProjectAdapterCatalog, budgetMs = GIT_BUDGET_MS, clock: () => number = () => performance.now(), run: typeof hubGitSync = hubGitSync): string {
|
|
30
|
+
if (adapterCatalog.definitionOf(id)) return projectRoot;
|
|
31
|
+
const git = gitWithin(budgetMs, clock, run);
|
|
32
|
+
const declaration = validateInstances(declarations, adapterCatalog)[id];
|
|
21
33
|
if (!declaration) return refuse("extra instance is not declared");
|
|
22
34
|
const implementer = roles.includes("implementer");
|
|
23
35
|
if (!declaration.worktree) {
|
|
@@ -41,7 +53,7 @@ export function resolveInstanceExecutionCwd(projectRoot: string, id: string, dec
|
|
|
41
53
|
try { return realPath(path) === cwd; } catch { return false; }
|
|
42
54
|
});
|
|
43
55
|
if (matches.length !== 1 || matches[0]!.some(field => field === "prunable" || field.startsWith("prunable "))) return refuse("directory is not an available registered project worktree");
|
|
44
|
-
if (commonDirectory(root) !== commonDirectory(cwd)) return refuse("worktree belongs to a different Git repository");
|
|
56
|
+
if (commonDirectory(git, root) !== commonDirectory(git, cwd)) return refuse("worktree belongs to a different Git repository");
|
|
45
57
|
const top = git(cwd, ["rev-parse", "--show-toplevel"]).replace(/\n$/, "");
|
|
46
58
|
if (realPath(top) !== cwd) return refuse("directory is not the registered worktree root");
|
|
47
59
|
if (implementer && !matches[0]!.some(field => field.startsWith("branch refs/heads/") && field.length > "branch refs/heads/".length)) return refuse("an implementer worktree requires an attached branch");
|
package/src/hub/instances.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { types } from "node:util";
|
|
2
|
-
import {
|
|
2
|
+
import { defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
3
3
|
import { isPermissionMode, type PermissionMode } from "./permission-mode.ts";
|
|
4
4
|
|
|
5
5
|
export interface InstanceConfig {
|
|
@@ -9,6 +9,7 @@ export interface InstanceConfig {
|
|
|
9
9
|
permission_mode?: PermissionMode;
|
|
10
10
|
review_mode?: "fresh";
|
|
11
11
|
worktree?: string;
|
|
12
|
+
readOnly?: boolean;
|
|
12
13
|
}
|
|
13
14
|
export type InstancesConfig = Readonly<Record<string, InstanceConfig>>;
|
|
14
15
|
export interface InstanceCatalogEntry { id: string; kind: string; definition: AdapterDefinition; config?: InstanceConfig }
|
|
@@ -40,20 +41,32 @@ function roles(value: unknown, field: string): string[] {
|
|
|
40
41
|
return out;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
/**
|
|
44
|
-
export function
|
|
44
|
+
/** Declaration support is explicit policy, independent of the native MCP binding mechanism. */
|
|
45
|
+
export function supportsHeadlessInstances(definition: AdapterDefinition): boolean {
|
|
46
|
+
return definition.instances?.mode === "headless" && !!definition.start.create && definition.start.forms.includes("headless");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resolve project declarations only through their captured, explicit catalog. */
|
|
50
|
+
export function instanceDefinitionOf(id: string, declarations: InstancesConfig | undefined, catalog: ProjectAdapterCatalog): AdapterDefinition | undefined {
|
|
51
|
+
return catalog.definitionOf(id, declarations);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Pure I2 validation; physical worktree and startup authority stay host-bound. */
|
|
55
|
+
export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog): InstancesConfig {
|
|
56
|
+
const resolve = catalog.definitionOf;
|
|
45
57
|
const result: Record<string, InstanceConfig> = Object.create(null);
|
|
46
58
|
if (raw === undefined) return Object.freeze(result);
|
|
47
59
|
const declarations = fields(raw, "instances");
|
|
48
60
|
if (Object.keys(declarations).length > MAX_INSTANCES) return fail("instances", "at most 64 extra instances are supported");
|
|
49
61
|
for (const [id, descriptor] of Object.entries(declarations)) {
|
|
50
|
-
if (!PEER_ID.test(id) || RESERVED.has(id) ||
|
|
62
|
+
if (!PEER_ID.test(id) || RESERVED.has(id) || resolve(id)) return fail("instances", "ids must be nonreserved extra instance ids, not builtin ids");
|
|
51
63
|
const path = `instances.${id}`, entry = fields(descriptor.value, path);
|
|
52
|
-
if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode"].includes(key))) return fail(path, "unsupported field");
|
|
64
|
+
if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode", "readOnly"].includes(key))) return fail(path, "unsupported field");
|
|
53
65
|
const kind: unknown = entry.kind?.value;
|
|
54
66
|
if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
|
|
55
|
-
const definition =
|
|
56
|
-
if (!definition) return fail(`${path}.kind`, "unknown native kind");
|
|
67
|
+
const definition = resolve(kind), prefix = `${kind}-`;
|
|
68
|
+
if (!definition) return fail(`${path}.kind`, "unknown native kind (external adapter instances are unsupported in A4)");
|
|
69
|
+
if (catalog.externalDefinitions.some(item => item.kind === kind)) return fail(`${path}.kind`, "external adapter instances are unsupported in A4");
|
|
57
70
|
if (!id.startsWith(prefix) || !/^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length))) return fail(path, "id must be <kind>-<name>");
|
|
58
71
|
const declaredRoles = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
|
|
59
72
|
const reviewer = (definition.reviewerVariant ?? definition).reviewer;
|
|
@@ -63,11 +76,12 @@ export function validateInstances(raw: unknown): InstancesConfig {
|
|
|
63
76
|
if (definition.routing?.reviewOnly && !freshReviewer) return fail(path, "a review-only kind requires headless roles exactly [\"reviewer\"]");
|
|
64
77
|
if (freshReviewer && entry.worktree) return fail(`${path}.worktree`, "a fresh reviewer reads the project checkout: worktree is not supported");
|
|
65
78
|
if (freshReviewer && entry.permission_mode) return fail(`${path}.permission_mode`, "a fresh reviewer has a fixed read-only policy");
|
|
66
|
-
//
|
|
67
|
-
const
|
|
79
|
+
// An external instance runs only in the person's terminal; its definition bounds the declared roles.
|
|
80
|
+
const external = definition.instances?.mode === "external";
|
|
81
|
+
const runtime = freshReviewer ? definition.reviewerVariant ?? definition : definition;
|
|
68
82
|
const unavailableReviewer = freshReviewer && !!(definition.reviewerVariant ?? definition).reviewer?.unavailable;
|
|
69
|
-
if (!
|
|
70
|
-
if (
|
|
83
|
+
if (!unavailableReviewer && runtime.instances?.mode !== "external" && !supportsHeadlessInstances(runtime)) return fail(`${path}.kind`, definition.reviewerVariant ? 'unsupported; this kind only declares headless reviewer instances with roles exactly ["reviewer"]' : "unsupported; explicit external instance policy or a headless instance policy with a headless factory is required");
|
|
84
|
+
if (external) {
|
|
71
85
|
const declared = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
|
|
72
86
|
// #223: a headless one is the kind's reviewer variant, one fresh hub-run session per review request.
|
|
73
87
|
if (entry.start_mode && definition.reviewerVariant) {
|
|
@@ -75,10 +89,20 @@ export function validateInstances(raw: unknown): InstancesConfig {
|
|
|
75
89
|
if (declared.length !== 1 || declared[0] !== "reviewer") return fail(`${path}.roles`, "a headless channel-kind instance is a reviewer: roles must be exactly [\"reviewer\"]");
|
|
76
90
|
} else {
|
|
77
91
|
if (entry.start_mode || entry.worktree) return fail(path, "a channel instance starts only from the person's terminal (ahub <kind> --as <id>): start_mode and worktree are not supported; a headless reviewer declares roles [\"reviewer\"] and start_mode \"headless\"");
|
|
78
|
-
|
|
92
|
+
const allowed = definition.instances?.roles ?? definition.roles;
|
|
93
|
+
if (!declared.length || declared.some(role => !allowed.includes(role))) return fail(`${path}.roles`, "a channel instance must declare its roles: reviewer and/or planner");
|
|
79
94
|
}
|
|
95
|
+
} else {
|
|
96
|
+
const allowed = runtime.instances?.roles;
|
|
97
|
+
if (allowed && (declaredRoles.length ? declaredRoles : runtime.roles).some(role => !allowed.includes(role))) return fail(`${path}.roles`, "roles must fit the definition's instance policy");
|
|
98
|
+
}
|
|
99
|
+
if (entry.readOnly) {
|
|
100
|
+
if (typeof entry.readOnly.value !== "boolean") return fail(`${path}.readOnly`, "must be a boolean");
|
|
101
|
+
if (!runtime.permission.genericAcp?.readOnly) return fail(`${path}.readOnly`, "native adapter has no verified read-only mode");
|
|
102
|
+
if (entry.readOnly.value && entry.permission_mode) return fail(`${path}.permission_mode`, "read-only instances cannot select a permission mode");
|
|
80
103
|
}
|
|
81
104
|
const value: InstanceConfig = { kind, ...(entry.review_mode ? { review_mode: "fresh" as const } : {}) };
|
|
105
|
+
if (entry.readOnly) value.readOnly = entry.readOnly.value as boolean;
|
|
82
106
|
if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
|
|
83
107
|
if (entry.start_mode) {
|
|
84
108
|
if (entry.start_mode.value !== "headless") return fail(`${path}.start_mode`, "I2 extra instances require headless mode");
|
|
@@ -99,24 +123,25 @@ export function validateInstances(raw: unknown): InstancesConfig {
|
|
|
99
123
|
}
|
|
100
124
|
|
|
101
125
|
/** Each call derives a fresh project-local projection, without registration or suffix discovery. */
|
|
102
|
-
export function instanceCatalog(declarations
|
|
103
|
-
const instances = validateInstances(declarations);
|
|
126
|
+
export function instanceCatalog(declarations: InstancesConfig | undefined, catalog: ProjectAdapterCatalog): readonly InstanceCatalogEntry[] {
|
|
127
|
+
const instances = validateInstances(declarations, catalog);
|
|
104
128
|
return Object.freeze([
|
|
105
|
-
...
|
|
106
|
-
...Object.entries(instances).map(([id, config]) => Object.freeze({ id, kind: config.kind, definition: definitionOf(id, instances)!, config })),
|
|
129
|
+
...catalog.definitions.map(definition => Object.freeze({ id: definition.kind, kind: definition.kind, definition })),
|
|
130
|
+
...Object.entries(instances).map(([id, config]) => Object.freeze({ id, kind: config.kind, definition: catalog.definitionOf(id, instances)!, config })),
|
|
107
131
|
]);
|
|
108
132
|
}
|
|
109
|
-
export function instancePeerIds(declarations
|
|
110
|
-
export function instanceDefaultRoles(declarations
|
|
133
|
+
export function instancePeerIds(declarations: InstancesConfig | undefined, catalog: ProjectAdapterCatalog): string[] { return instanceCatalog(declarations, catalog).map(entry => entry.id); }
|
|
134
|
+
export function instanceDefaultRoles(declarations: InstancesConfig | undefined, includeOptional: boolean, catalog: ProjectAdapterCatalog): Record<string, string[]> {
|
|
111
135
|
const result = defaultPeerRoles(includeOptional);
|
|
112
|
-
const
|
|
113
|
-
|
|
136
|
+
for (const definition of catalog.definitions) if (!definitionOf(definition.kind) && (includeOptional || !definition.optional)) result[definition.kind] = [...definition.roles];
|
|
137
|
+
const instances = validateInstances(declarations, catalog);
|
|
138
|
+
for (const [id, config] of Object.entries(instances)) result[id] = [...(config.roles ?? catalog.definitionOf(id, instances)!.roles)];
|
|
114
139
|
return result;
|
|
115
140
|
}
|
|
116
141
|
|
|
117
142
|
/** One precedence rule for startup, native launch and recovery; ordinary roles may use the instance id or its kind. */
|
|
118
|
-
export function effectiveInstanceRoles(id: string, declarations: InstancesConfig | undefined, policyRoles: Readonly<Record<string, readonly string[]
|
|
119
|
-
const definition = definitionOf(id, declarations);
|
|
143
|
+
export function effectiveInstanceRoles(id: string, declarations: InstancesConfig | undefined, policyRoles: Readonly<Record<string, readonly string[]>>, catalog: ProjectAdapterCatalog): string[] {
|
|
144
|
+
const definition = catalog.definitionOf(id, declarations);
|
|
120
145
|
if (!definition) throw new Error(`${id}: no declared native instance`);
|
|
121
146
|
const declaration = declarations && Object.hasOwn(declarations, id) ? declarations[id] : undefined;
|
|
122
147
|
const ownRoles = (key: string): readonly string[] | undefined => Object.hasOwn(policyRoles, key) ? policyRoles[key] : undefined;
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { ControlClient } from "./control-client.ts";
|
|
2
|
+
import { configuredToolContext, type ProjectToolContext } from "./hub-tools.ts";
|
|
2
3
|
import { SDLC_STAGES } from "./sdlc.ts";
|
|
3
4
|
/** The daemon owns the active definition. Unavailable reads never invent a project-file stage set. */
|
|
4
5
|
/** The active stage ids and whether the lifecycle is enabled (`criteria`, #481 S8); an older daemon never says so. */
|
|
5
|
-
export async function activeLifecycle(stateDir: string, peer: string, projectRoot: string, active?: ControlClient): Promise<{ stages: string[]; criteria: boolean } | undefined> {
|
|
6
|
+
export async function activeLifecycle(stateDir: string, peer: string, projectRoot: string, active?: ControlClient): Promise<({ stages: string[]; criteria: boolean } & ProjectToolContext) | undefined> {
|
|
6
7
|
let client: ControlClient | undefined;
|
|
7
8
|
try {
|
|
8
9
|
client = active ?? await ControlClient.connect(stateDir, { role: "tools", peer, projectRoot }, 1000);
|
|
9
10
|
const reply = await client.request({ t: "status" }, 1000);
|
|
10
11
|
if (reply.t !== "status" || !reply.status || typeof reply.status !== "object" || Array.isArray(reply.status)) return undefined;
|
|
12
|
+
const projectTools = configuredToolContext(reply.status, peer);
|
|
11
13
|
// A live older daemon has the fixed seven-stage contract and no optional metadata field.
|
|
12
|
-
if (!Object.hasOwn(reply.status, "lifecycleStages")) return { stages: [...SDLC_STAGES], criteria: false };
|
|
14
|
+
if (!Object.hasOwn(reply.status, "lifecycleStages")) return { stages: [...SDLC_STAGES], criteria: false, ...projectTools };
|
|
13
15
|
const stages: unknown = reply.status.lifecycleStages;
|
|
14
16
|
if (!Array.isArray(stages) || !stages.length || stages.length > 32 || stages.some(s => typeof s !== "string" || !/^[a-z][a-z0-9_-]{0,47}$/.test(s)) || new Set(stages).size !== stages.length) return undefined;
|
|
15
|
-
return { stages: [...stages] as string[], criteria: reply.status.lifecycleCriteria === true };
|
|
17
|
+
return { stages: [...stages] as string[], criteria: reply.status.lifecycleCriteria === true, ...projectTools };
|
|
16
18
|
} catch { return undefined; }
|
|
17
19
|
finally { if (client !== active) client?.close(); }
|
|
18
20
|
}
|
|
@@ -117,6 +117,14 @@ export function memoryProjectFor(cwd: string, opts: Pick<StoreOptions, "home"> =
|
|
|
117
117
|
return resolveAlias(homeFor(opts), memoryProjectKey(cwd));
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* #240: the project key for a caller-injected store. Its alias log is the one in its own bound home (`profile.home`),
|
|
122
|
+
* never the ambient memory home's: a different alias log could hide that store's rows or refuse its writes.
|
|
123
|
+
*/
|
|
124
|
+
export function memoryProjectForStore(store: MemoryStore, cwd: string): string {
|
|
125
|
+
return resolveAlias(canonicalHome(store.profile.home), memoryProjectKey(cwd));
|
|
126
|
+
}
|
|
127
|
+
|
|
120
128
|
/** Inspect selection without constructing a store, index, or candidate sidecar. */
|
|
121
129
|
export function selectedMemoryProfile(opts: StoreOptions = {}): MemoryProfileReceipt {
|
|
122
130
|
assertLoadedAgentMem();
|
|
@@ -241,11 +249,16 @@ export function openProfileMemory(opts: ProfileMemoryOptions = {}, onUnavailable
|
|
|
241
249
|
}
|
|
242
250
|
}
|
|
243
251
|
|
|
244
|
-
/**
|
|
245
|
-
|
|
252
|
+
/**
|
|
253
|
+
* Bind a caller-owned store only when it exactly matches the current person selection. `projectKey` is the raw key
|
|
254
|
+
* the caller resolves its project from (#240): a sharded store whose home now aliases that key to another project is
|
|
255
|
+
* refused, and a later remap latches the binding unavailable, so a command never runs as B through A's shard.
|
|
256
|
+
*/
|
|
257
|
+
export function bindProfileMemory(store: MemoryStore, onUnavailable?: (neutralReason: string) => void, projectKey?: string): {
|
|
246
258
|
store: MemoryStore; binding: MemoryProfileBinding;
|
|
247
259
|
} {
|
|
248
|
-
const
|
|
260
|
+
const sharded = store.profile.shardKey !== undefined;
|
|
261
|
+
const binding = new MemoryProfileBinding({ project: store.profile.shardKey, ...(sharded && projectKey !== undefined ? { projectKey } : {}) }, onUnavailable, store.profile.home);
|
|
249
262
|
binding.assertCurrent();
|
|
250
263
|
if (!sameProfile(store.profile, binding.profile)) throw new Error(UNAVAILABLE);
|
|
251
264
|
return { store, binding };
|