@staix/agent-hub 0.20.0 → 0.21.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 +59 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +2 -2
- package/docs/agent-notes/daemon.md +15 -6
- package/docs/agent-notes/local-worker.md +4 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +17 -9
- package/docs/agent-notes/tests.md +9 -2
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +37 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +565 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +14 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1070 -22
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +15 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1016 -280
- package/src/adapters/acp.ts +79 -3
- package/src/adapters/channel-peer.ts +4 -0
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +60 -7
- package/src/adapters/codex-appserver.ts +77 -4
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +3 -9
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +40 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +33 -0
- package/src/cli/smoke-pty.ts +145 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +42 -20
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +7 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1150 -171
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +85 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +76 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +12 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +6 -1
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/tasks.ts +910 -110
- package/src/hub/workflow.ts +1 -1
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { constants, lstatSync, type Stats } from "node:fs";
|
|
2
2
|
import { open } from "node:fs/promises";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { relative } from "node:path";
|
|
4
|
+
import { relative, resolve } from "node:path";
|
|
5
5
|
import { guardPath } from "../local/tools.ts";
|
|
6
|
+
import { isDenied } from "../local/deny.ts";
|
|
6
7
|
import { isHubWritePath } from "./permission-mode.ts";
|
|
7
8
|
import { realPath } from "./project.ts";
|
|
9
|
+
import { insideRoot } from "./path-policy.ts";
|
|
8
10
|
import { DELIVERABLE_LIMITS, deliverableId, type DeliverableClaim, type DeliverableRecord, type DeliverableState } from "./deliverables.ts";
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -49,7 +51,11 @@ function locate(path: string, ports: DeliverablePorts): Located {
|
|
|
49
51
|
// git file ever reaches a record (a hash of a low-entropy file confirms guesses). Compared on the resolved path, folded like the write guard.
|
|
50
52
|
let root: string;
|
|
51
53
|
try { root = realPath(ports.cwd); } catch { return refuse(path, "the project root cannot be resolved"); }
|
|
52
|
-
|
|
54
|
+
// Judged on the resolved name and on the name the caller gave (#552, the #541 class): Bun's realpath can name another hardlink of the same file.
|
|
55
|
+
const typed = relative(root, resolve(root, path));
|
|
56
|
+
const inside = insideRoot(typed);
|
|
57
|
+
if (isHubWritePath(relative(root, real)) || inside && isHubWritePath(typed)) return refuse(path, "the hub's own state is not a deliverable");
|
|
58
|
+
if (inside && isDenied(typed, [...ports.deny])) return refuse(path, "the path is on the secrets denylist");
|
|
53
59
|
let stat: Stats;
|
|
54
60
|
try { stat = lstatSync(real); }
|
|
55
61
|
catch (error) { return refuse(path, (error as NodeJS.ErrnoException).code === "ENOENT" ? "the file does not exist" : "the file cannot be read"); }
|
|
@@ -2,10 +2,12 @@ import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { Database } from "bun:sqlite";
|
|
4
4
|
import type { BusSnapshot } from "./bus.ts";
|
|
5
|
+
import type { QueuePressure } from "./queue-backpressure.ts";
|
|
5
6
|
import { HUB, USER, type Envelope, type Kind } from "./envelope.ts";
|
|
6
7
|
import { heldDeliveryHints } from "./delivery-hints.ts";
|
|
7
8
|
import { processLiveness } from "../pi/process-signature.ts";
|
|
8
9
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
10
|
+
import { parseRemotePeerId } from "../rooms/application.ts";
|
|
9
11
|
import { validateDeferredHub } from "./deferred-hub.ts";
|
|
10
12
|
|
|
11
13
|
export type JournalDeliveryState =
|
|
@@ -168,6 +170,32 @@ function validateBus(value: unknown, name = "bus"): BusSnapshot {
|
|
|
168
170
|
list(b.manualPaused, `${name}.manualPaused`, 4096).forEach((id) =>
|
|
169
171
|
text(id, `${name}.manualPaused[]`, 256),
|
|
170
172
|
);
|
|
173
|
+
if (b.remoteQueueMembers !== undefined) {
|
|
174
|
+
const members = obj(b.remoteQueueMembers, `${name}.remoteQueueMembers`);
|
|
175
|
+
if (Object.keys(members).length > 12800) fail(`${name}.remoteQueueMembers is too large`);
|
|
176
|
+
for (const [peer, member] of Object.entries(members)) {
|
|
177
|
+
if (!parseRemotePeerId(peer) || !Array.isArray(queues[peer]) || typeof member !== "string" || !/^[0-9a-f]{64}$/.test(member)) fail(`${name}.remoteQueueMembers is invalid`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (b.remoteQueueRooms !== undefined) {
|
|
181
|
+
const rooms = obj(b.remoteQueueRooms, `${name}.remoteQueueRooms`);
|
|
182
|
+
if (Object.keys(rooms).length > 12800) fail(`${name}.remoteQueueRooms is too large`);
|
|
183
|
+
for (const [peer, room] of Object.entries(rooms)) {
|
|
184
|
+
if (!parseRemotePeerId(peer) || !Array.isArray(queues[peer])) fail(`${name}.remoteQueueRooms key is invalid`);
|
|
185
|
+
text(room, `${name}.remoteQueueRooms room`, 256);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (b.remoteQueueExpires !== undefined) {
|
|
189
|
+
const expiries = obj(b.remoteQueueExpires, `${name}.remoteQueueExpires`);
|
|
190
|
+
if (Object.keys(expiries).length > 4096) fail(`${name}.remoteQueueExpires is too large`);
|
|
191
|
+
let count = 0;
|
|
192
|
+
for (const [peer, values] of Object.entries(expiries)) {
|
|
193
|
+
if (!parseRemotePeerId(peer)) fail(`${name}.remoteQueueExpires key is invalid`);
|
|
194
|
+
const entries = Object.entries(obj(values, `${name}.remoteQueueExpires.${peer}`));
|
|
195
|
+
if (entries.length > 200 || (count += entries.length) > 12800) fail(`${name}.remoteQueueExpires is too large`);
|
|
196
|
+
for (const [id, expiry] of entries) { text(id, `${name}.remoteQueueExpires id`, 256); integer(expiry, `${name}.remoteQueueExpires expiry`); }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
171
199
|
const occupied = new Set<string>([
|
|
172
200
|
...(b.seen as Envelope[]).map(env => env.id),
|
|
173
201
|
...Object.values(queues).flatMap(items => (items as Envelope[]).map(env => env.id)),
|
|
@@ -241,6 +269,8 @@ export class DeliveryJournal {
|
|
|
241
269
|
readonly instanceId: string;
|
|
242
270
|
private readonly db: Database;
|
|
243
271
|
private closed = false;
|
|
272
|
+
private queueMetadataTracking = false;
|
|
273
|
+
private queueMetadataInvalidated = false;
|
|
244
274
|
private readonly operationId?: string;
|
|
245
275
|
constructor(options: DeliveryJournalOptions) {
|
|
246
276
|
this.file = options.file;
|
|
@@ -323,6 +353,8 @@ export class DeliveryJournal {
|
|
|
323
353
|
this.db.run(
|
|
324
354
|
`CREATE TABLE IF NOT EXISTS deliveries (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, peer TEXT NOT NULL, state TEXT NOT NULL, revision INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, originals TEXT NOT NULL, out_json TEXT NOT NULL, reason TEXT, attempt INTEGER, previous_id TEXT)`,
|
|
325
355
|
);
|
|
356
|
+
this.db.run("CREATE TABLE IF NOT EXISTS delivery_queue_meta_state (project_id TEXT PRIMARY KEY, revision INTEGER NOT NULL)");
|
|
357
|
+
this.db.run("CREATE TABLE IF NOT EXISTS delivery_queue_meta (project_id TEXT NOT NULL, peer TEXT NOT NULL, queued INTEGER NOT NULL, oldest_at INTEGER, unknown_age INTEGER NOT NULL, PRIMARY KEY(project_id,peer))");
|
|
326
358
|
this.db.run("CREATE TABLE IF NOT EXISTS task_offer_reads (project_id TEXT NOT NULL, peer TEXT NOT NULL, envelope_id TEXT NOT NULL, delivery_id TEXT NOT NULL, source TEXT NOT NULL CHECK(source IN ('adapter','reply','pull')), offer_revision INTEGER NOT NULL CHECK(offer_revision >= 1), PRIMARY KEY(project_id,peer,envelope_id))");
|
|
327
359
|
this.db.run(
|
|
328
360
|
`CREATE TABLE IF NOT EXISTS resolution_history (id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, delivery_id TEXT NOT NULL, observed_revision INTEGER NOT NULL, action TEXT NOT NULL, reason TEXT NOT NULL, result_revision INTEGER NOT NULL, result_state TEXT NOT NULL, UNIQUE(project_id, delivery_id, observed_revision, action, reason))`,
|
|
@@ -474,11 +506,71 @@ export class DeliveryJournal {
|
|
|
474
506
|
) !== 1
|
|
475
507
|
)
|
|
476
508
|
throw new Error("delivery journal instance fence lost");
|
|
509
|
+
// New writers carry unchanged summary provenance across non-queue revisions.
|
|
510
|
+
// An older writer does not, so its next revision makes these rows unknown.
|
|
511
|
+
if (this.queueMetadataTracking) this.db.query("UPDATE delivery_queue_meta_state SET revision=? WHERE project_id=? AND revision=?")
|
|
512
|
+
.run(revision, this.projectId, revision - 1);
|
|
477
513
|
return revision;
|
|
478
514
|
}
|
|
515
|
+
|
|
516
|
+
/** Atomically accompanies the bus snapshot, never extracts body JSON on a periodic path. */
|
|
517
|
+
private writeQueueMetadata(bus: BusSnapshot, revision: number, tracking: boolean): void {
|
|
518
|
+
if (!tracking) {
|
|
519
|
+
// Invalidate once per disabled period; ordinary revisions already stale old summaries.
|
|
520
|
+
if (!this.queueMetadataInvalidated) {
|
|
521
|
+
this.db.query("UPDATE delivery_queue_meta_state SET revision=-1 WHERE project_id=? AND revision>=0").run(this.projectId);
|
|
522
|
+
this.queueMetadataInvalidated = true;
|
|
523
|
+
}
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
this.db.query("DELETE FROM delivery_queue_meta WHERE project_id=?").run(this.projectId);
|
|
527
|
+
const put = this.db.query("INSERT INTO delivery_queue_meta(project_id,peer,queued,oldest_at,unknown_age) VALUES(?,?,?,?,?)");
|
|
528
|
+
for (const [peer, queue] of Object.entries(bus.queues)) {
|
|
529
|
+
const provenance = bus.queuedAt;
|
|
530
|
+
const times = provenance && typeof provenance === "object" && !Array.isArray(provenance) && Object.hasOwn(provenance, peer) ? provenance[peer] : undefined;
|
|
531
|
+
let oldest: number | undefined, unknown = false;
|
|
532
|
+
for (const env of queue) {
|
|
533
|
+
const at = times && typeof times === "object" && !Array.isArray(times) && Object.hasOwn(times, env.id) ? times[env.id] : null;
|
|
534
|
+
if (typeof at !== "number" || !Number.isSafeInteger(at) || at <= 0) unknown = true;
|
|
535
|
+
else oldest = oldest === undefined ? at : Math.min(oldest, at);
|
|
536
|
+
}
|
|
537
|
+
put.run(this.projectId, peer, queue.length, oldest ?? null, unknown ? 1 : 0);
|
|
538
|
+
}
|
|
539
|
+
this.db.query("INSERT INTO delivery_queue_meta_state(project_id,revision) VALUES(?,?) ON CONFLICT(project_id) DO UPDATE SET revision=excluded.revision")
|
|
540
|
+
.run(this.projectId, revision);
|
|
541
|
+
this.queueMetadataInvalidated = false;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Host policy only, refreshed before receipt/reply mutations as well as snapshot writes. No SQL. */
|
|
545
|
+
setQueueMetadataTracking(tracking: boolean): void { this.queueMetadataTracking = tracking === true; }
|
|
546
|
+
|
|
547
|
+
private queueMetadataTransaction<T>(tracking: boolean, fn: () => T): T {
|
|
548
|
+
return this.transaction(() => { this.setQueueMetadataTracking(tracking); return fn(); });
|
|
549
|
+
}
|
|
550
|
+
private queueMetadataSnapshot(bus: BusSnapshot, tracking: boolean): BusSnapshot {
|
|
551
|
+
if (tracking || bus.queuedAt === undefined) return bus;
|
|
552
|
+
const legacy = { ...bus }; delete legacy.queuedAt;
|
|
553
|
+
return legacy;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Indexed per-peer pending counts/age only, independent of delivery history and payload columns. */
|
|
557
|
+
pendingQueueMetadata(peer: string): QueuePressure | undefined {
|
|
558
|
+
this.ensureOpen();
|
|
559
|
+
const row = this.db.query("SELECT m.revision,s.revision AS metadata_revision,q.peer AS metadata_peer,q.queued,q.oldest_at,q.unknown_age FROM delivery_meta m LEFT JOIN delivery_queue_meta_state s ON s.project_id=m.project_id LEFT JOIN delivery_queue_meta q ON q.project_id=m.project_id AND q.peer=? WHERE m.project_id=?")
|
|
560
|
+
.get(peer, this.projectId) as { revision: number; metadata_revision: number | null; metadata_peer: string | null; queued: number | null; oldest_at: number | null; unknown_age: number | null } | null;
|
|
561
|
+
if (!row || !Number.isSafeInteger(row.revision) || row.revision < 0 || row.metadata_revision !== row.revision) return undefined;
|
|
562
|
+
if (row.metadata_peer === null) return { queued: 0, oldestQueuedAt: null };
|
|
563
|
+
if (typeof row.queued !== "number" || !Number.isSafeInteger(row.queued) || row.queued < 0 || row.queued > 2048 || ![0, 1].includes(row.unknown_age!)) return undefined;
|
|
564
|
+
if (row.queued === 0) return row.oldest_at === null && row.unknown_age === 0 ? { queued: 0, oldestQueuedAt: null } : undefined;
|
|
565
|
+
if (row.unknown_age === 1) return { queued: row.queued };
|
|
566
|
+
if (!Number.isSafeInteger(row.oldest_at) || row.oldest_at! <= 0) return undefined;
|
|
567
|
+
return { queued: row.queued, oldestQueuedAt: row.oldest_at! };
|
|
568
|
+
}
|
|
479
569
|
transaction<T>(fn: () => T): T {
|
|
480
570
|
this.ensureOpen();
|
|
481
|
-
|
|
571
|
+
const invalidated = this.queueMetadataInvalidated;
|
|
572
|
+
try { return this.db.transaction(fn)(); }
|
|
573
|
+
catch (error) { this.queueMetadataInvalidated = invalidated; throw error; }
|
|
482
574
|
}
|
|
483
575
|
snapshot(): JournalSnapshot {
|
|
484
576
|
this.ensureOpen();
|
|
@@ -513,26 +605,34 @@ export class DeliveryJournal {
|
|
|
513
605
|
validateJournalSnapshot(result);
|
|
514
606
|
return result;
|
|
515
607
|
}
|
|
516
|
-
persistBus(bus: unknown, manualPaused: string[]): number {
|
|
608
|
+
persistBus(bus: unknown, manualPaused: string[], trackQueuePressure = false): number {
|
|
517
609
|
this.ensureOpen();
|
|
518
|
-
validateBus(bus);
|
|
610
|
+
let checkedBus = this.queueMetadataSnapshot(validateBus(bus), trackQueuePressure);
|
|
611
|
+
let encodedBus: string;
|
|
612
|
+
try { encodedBus = JSON.stringify(checkedJSON(checkedBus, "bus")); }
|
|
613
|
+
catch (error) {
|
|
614
|
+
if (checkedBus.queuedAt === undefined || !(error instanceof Error) || error.message !== "invalid delivery journal: bus is too large") throw error;
|
|
615
|
+
checkedBus = { ...checkedBus }; delete checkedBus.queuedAt;
|
|
616
|
+
encodedBus = JSON.stringify(checkedJSON(checkedBus, "bus"));
|
|
617
|
+
}
|
|
519
618
|
list(manualPaused, "manualPaused", 4096).forEach((id) =>
|
|
520
619
|
text(id, "manualPaused[]", 256),
|
|
521
620
|
);
|
|
522
|
-
return this.
|
|
621
|
+
return this.queueMetadataTransaction(trackQueuePressure, () => {
|
|
523
622
|
const revision = this.bump();
|
|
524
623
|
this.db
|
|
525
624
|
.query(
|
|
526
625
|
"UPDATE delivery_meta SET bus_snapshot = ?, manual_paused = ? WHERE project_id = ?",
|
|
527
626
|
)
|
|
528
627
|
.run(
|
|
529
|
-
|
|
628
|
+
encodedBus,
|
|
530
629
|
JSON.stringify([...new Set(manualPaused)].sort()),
|
|
531
630
|
this.projectId,
|
|
532
631
|
);
|
|
632
|
+
this.writeQueueMetadata(checkedBus, revision, trackQueuePressure);
|
|
533
633
|
this.harden();
|
|
534
634
|
return revision;
|
|
535
|
-
})
|
|
635
|
+
});
|
|
536
636
|
}
|
|
537
637
|
/** Atomically persist a bus snapshot and any fan-out rows created for that snapshot. */
|
|
538
638
|
persistBusAndDeliveries(
|
|
@@ -541,9 +641,10 @@ export class DeliveryJournal {
|
|
|
541
641
|
deliveries: Array<
|
|
542
642
|
Omit<JournalDelivery, "revision" | "updatedAt"> & { updatedAt?: number }
|
|
543
643
|
>,
|
|
644
|
+
trackQueuePressure = false,
|
|
544
645
|
): number {
|
|
545
646
|
this.ensureOpen();
|
|
546
|
-
validateBus(bus);
|
|
647
|
+
bus = this.queueMetadataSnapshot(validateBus(bus), trackQueuePressure);
|
|
547
648
|
list(manualPaused, "manualPaused", 4096).forEach((id) =>
|
|
548
649
|
text(id, "manualPaused[]", 256),
|
|
549
650
|
);
|
|
@@ -554,7 +655,7 @@ export class DeliveryJournal {
|
|
|
554
655
|
updatedAt: d.updatedAt ?? d.createdAt,
|
|
555
656
|
}),
|
|
556
657
|
);
|
|
557
|
-
return this.
|
|
658
|
+
return this.queueMetadataTransaction(trackQueuePressure, () => {
|
|
558
659
|
const revision = this.bump();
|
|
559
660
|
this.db
|
|
560
661
|
.query(
|
|
@@ -565,6 +666,7 @@ export class DeliveryJournal {
|
|
|
565
666
|
JSON.stringify([...new Set(manualPaused)].sort()),
|
|
566
667
|
this.projectId,
|
|
567
668
|
);
|
|
669
|
+
this.writeQueueMetadata(bus, revision, trackQueuePressure);
|
|
568
670
|
for (const d of deliveries)
|
|
569
671
|
this.db
|
|
570
672
|
.query(
|
|
@@ -586,21 +688,22 @@ export class DeliveryJournal {
|
|
|
586
688
|
);
|
|
587
689
|
this.harden();
|
|
588
690
|
return revision;
|
|
589
|
-
})
|
|
691
|
+
});
|
|
590
692
|
}
|
|
591
693
|
checkpointDelivery(
|
|
592
694
|
bus: BusSnapshot,
|
|
593
695
|
delivery: Omit<JournalDelivery, "revision" | "updatedAt"> & {
|
|
594
696
|
state: "dispatching";
|
|
595
697
|
},
|
|
698
|
+
trackQueuePressure = false,
|
|
596
699
|
): JournalDelivery {
|
|
597
|
-
validateBus(bus);
|
|
700
|
+
bus = this.queueMetadataSnapshot(validateBus(bus), trackQueuePressure);
|
|
598
701
|
validateDelivery({
|
|
599
702
|
...delivery,
|
|
600
703
|
revision: 0,
|
|
601
704
|
updatedAt: delivery.createdAt,
|
|
602
705
|
});
|
|
603
|
-
return this.
|
|
706
|
+
return this.queueMetadataTransaction(trackQueuePressure, () => {
|
|
604
707
|
const evidence = this.retryEvidence(delivery.peer, delivery.originals);
|
|
605
708
|
if (evidence === "used") {
|
|
606
709
|
// Inheritance commits with the handoff, before safe failure or pruning can erase a grouped parent.
|
|
@@ -627,6 +730,7 @@ export class DeliveryJournal {
|
|
|
627
730
|
JSON.stringify(bus.manualPaused ?? []),
|
|
628
731
|
this.projectId,
|
|
629
732
|
);
|
|
733
|
+
this.writeQueueMetadata(bus, revision, trackQueuePressure);
|
|
630
734
|
this.db
|
|
631
735
|
.query(
|
|
632
736
|
"INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
@@ -647,7 +751,7 @@ export class DeliveryJournal {
|
|
|
647
751
|
);
|
|
648
752
|
this.harden();
|
|
649
753
|
return { ...delivery, revision, updatedAt: now };
|
|
650
|
-
})
|
|
754
|
+
});
|
|
651
755
|
}
|
|
652
756
|
createDelivery(
|
|
653
757
|
input: Omit<JournalDelivery, "revision" | "updatedAt"> & {
|
|
@@ -944,9 +1048,10 @@ export class DeliveryJournal {
|
|
|
944
1048
|
)
|
|
945
1049
|
.run(this.projectId, count - TERMINAL_CAP);
|
|
946
1050
|
}
|
|
947
|
-
importSnapshot(snapshot: JournalSnapshot, operationId: string): boolean {
|
|
1051
|
+
importSnapshot(snapshot: JournalSnapshot, operationId: string, trackQueuePressure = false): boolean {
|
|
948
1052
|
this.ensureOpen();
|
|
949
1053
|
validateJournalSnapshot(snapshot);
|
|
1054
|
+
const bus = this.queueMetadataSnapshot(snapshot.bus, trackQueuePressure);
|
|
950
1055
|
text(operationId, "operationId", 256);
|
|
951
1056
|
if (this.operationId !== undefined && this.operationId !== operationId)
|
|
952
1057
|
throw new Error("recovery operation does not own delivery journal");
|
|
@@ -963,18 +1068,19 @@ export class DeliveryJournal {
|
|
|
963
1068
|
Object.keys(storedBus).length !== 0
|
|
964
1069
|
)
|
|
965
1070
|
return false;
|
|
966
|
-
return this.
|
|
1071
|
+
return this.queueMetadataTransaction(trackQueuePressure, () => {
|
|
967
1072
|
this.db
|
|
968
1073
|
.query(
|
|
969
1074
|
"UPDATE delivery_meta SET revision = ?, bus_snapshot = ?, manual_paused = ?, operation_id = ? WHERE project_id = ?",
|
|
970
1075
|
)
|
|
971
1076
|
.run(
|
|
972
1077
|
snapshot.revision,
|
|
973
|
-
JSON.stringify(
|
|
1078
|
+
JSON.stringify(bus),
|
|
974
1079
|
JSON.stringify(snapshot.manualPaused),
|
|
975
1080
|
operationId,
|
|
976
1081
|
this.projectId,
|
|
977
1082
|
);
|
|
1083
|
+
this.writeQueueMetadata(bus, snapshot.revision, trackQueuePressure);
|
|
978
1084
|
for (const row of snapshot.deliveries)
|
|
979
1085
|
this.db
|
|
980
1086
|
.query(
|
|
@@ -995,9 +1101,9 @@ export class DeliveryJournal {
|
|
|
995
1101
|
row.previousId ?? null,
|
|
996
1102
|
);
|
|
997
1103
|
// Snapshots deliberately omit resolution history; only these imported originals have unknown retry rights.
|
|
998
|
-
this.markRetryUnknown(snapshot.deliveries,
|
|
1104
|
+
this.markRetryUnknown(snapshot.deliveries, bus);
|
|
999
1105
|
return true;
|
|
1000
|
-
})
|
|
1106
|
+
});
|
|
1001
1107
|
}
|
|
1002
1108
|
close(): void {
|
|
1003
1109
|
if (!this.closed) {
|
|
@@ -8,10 +8,12 @@ import type { StampedEvent } from "./events.ts";
|
|
|
8
8
|
|
|
9
9
|
export interface ProfileHistory {
|
|
10
10
|
at: number; event: string; criteria?: string[]; criteriaPrivate?: true; deliverables?: string[];
|
|
11
|
+
/** #901: on the hub's own check entries, the check key and the example check's unresolved-key count; on a `claim verdict` entry, the person's verdict. Closed values only, no claim text. */
|
|
12
|
+
checkKey?: string; missingKeys?: number; claim?: { slot: number; verdict: "supported" | "unsupported" | "unclear"; model?: string; modelVerdict?: "supported" | "unsupported" | "unclear"; list?: string };
|
|
11
13
|
}
|
|
12
14
|
export interface ProfileTaskMetadata {
|
|
13
15
|
profile: ProfileName; state?: string; class: string; alias: string | null; history: ProfileHistory[];
|
|
14
|
-
plan?: { sources: number | null; claims: number | null };
|
|
16
|
+
plan?: { sources: number | null; claims: number | null; /** #901: `claimListId` of the plan's claims now (an opaque hash, never their text); absent when the list could not be read. */ claimList?: string };
|
|
15
17
|
}
|
|
16
18
|
const complete = (s: Source<unknown>) => s.available && s.complete;
|
|
17
19
|
const number = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
@@ -24,7 +26,7 @@ const criterion = (s: unknown): s is string => typeof s === "string" && /^[A-Za-
|
|
|
24
26
|
|
|
25
27
|
// Exhaustive maps make producer union changes a compile-time review point.
|
|
26
28
|
const taskStates: Record<TaskState,true> = {backlog:true,proposed:true,in_progress:true,in_review:true,approved:true,changes_requested:true,cancelled:true};
|
|
27
|
-
const usageSources: Record<Extract<StampedEvent,{type:"usage"}>["source"],true> = {omniroute:true,claude_transcript:true,claude_result:true,codex_appserver:true,kimi_wire:true};
|
|
29
|
+
const usageSources: Record<Extract<StampedEvent,{type:"usage"}>["source"],true> = {omniroute:true,claude_transcript:true,claude_result:true,codex_appserver:true,kimi_wire:true,jcode_acp:true};
|
|
28
30
|
const attributions: Record<Attribution,true> = {delivery:true,single_open:true,unattributed:true};
|
|
29
31
|
const screenItems: Record<ScreenItem,true> = {task:true,summary:true,"review note":true,handoff:true,note:true};
|
|
30
32
|
const screenMisses: Record<ScreenMiss,true> = {"too long":true,"off campus":true,timeout:true,unreadable:true,failed:true};
|
|
@@ -228,12 +230,37 @@ export function profileScorecard(input: DogfoodInput) {
|
|
|
228
230
|
const count=t.metadata.plan?.[key];
|
|
229
231
|
return full && declarationCount(count) ? count : null;
|
|
230
232
|
};
|
|
233
|
+
// #901: claim verdicts and check provenance, read only from entries the hub or the person wrote with closed values. Unknown stays unknown, never zero: a count exists only when its source
|
|
234
|
+
// history is complete, and a row without its basis (no verdict, no model verdict, no recorded check key) is null.
|
|
235
|
+
type Verdict = "supported" | "unsupported" | "unclear";
|
|
236
|
+
// A verdict counts only for the claim list it evaluated: its `list` must equal the plan's current claim-list id. After the owner replaces or reorders the claims, an earlier verdict names no claim
|
|
237
|
+
// of the new list (it is unknown again, not carried over to whatever now sits in its slot), and a verdict from before ids were recorded cannot be bound, so it does not count.
|
|
238
|
+
const lastVerdict = (t: BoundTask, slot: number) => t.metadata.history.filter(h => h.event === "claim verdict" && h.claim?.slot === slot && inWindow(h.at) && t.metadata.plan?.claimList !== undefined && h.claim.list === t.metadata.plan.claimList).sort((a, b) => a.at - b.at).at(-1)?.claim;
|
|
239
|
+
const slotRows = group.tasks.map(t => {
|
|
240
|
+
const count = planCount(t, "claims");
|
|
241
|
+
return { task: t.id, claims: count === null ? null : Array.from({ length: count }, (_, i) => {
|
|
242
|
+
const c = lastVerdict(t, i + 1);
|
|
243
|
+
return { slot: i + 1, modelLabel: c?.model ?? null, personVerdict: c?.verdict ?? null, agreement: c?.modelVerdict ? c.modelVerdict === c.verdict : null };
|
|
244
|
+
}) };
|
|
245
|
+
});
|
|
246
|
+
const slotted = slotRows.flatMap(row => row.claims ?? []);
|
|
247
|
+
const decided = slotted.filter(c => c.personVerdict !== null);
|
|
248
|
+
const checkEntries = history.filter(h => (h.event === "check passed" || h.event === "check failed") && h.checkKey === "fact_check");
|
|
249
|
+
const everyCheckKeyed = full && history.filter(h => h.event === "check passed" || h.event === "check failed").every(h => typeof h.checkKey === "string");
|
|
250
|
+
const compared = decided.filter(c => c.agreement !== null);
|
|
231
251
|
const research = profile === "research" ? {research:{
|
|
232
252
|
declarations:{scope:"current_plan",perTask:group.tasks.map(t=>({task:t.id,sources:planCount(t,"sources"),claims:planCount(t,"claims")}))},
|
|
233
|
-
|
|
253
|
+
// A verdict is the person's, entered with `ahub task claim`; task approval never supplies one. Null until at least the declarations are known; then the number of slots with a recorded verdict.
|
|
254
|
+
claims:{reachedPersonVerdict:full && slotRows.every(r => r.claims !== null) && decided.length > 0 ? decided.length : null,perTask:slotRows},
|
|
234
255
|
observedCheckOutcomes:{scope:"task_check_history",passed:full?history.filter(h=>h.event==="check passed").length:null,failed:full?history.filter(h=>h.event==="check failed").length:null},
|
|
235
|
-
|
|
236
|
-
|
|
256
|
+
// Selection and pass/fail are proven only for entries that carry the hub's own check key, and only when every check entry of the group carries one (an older entry has no key: that is unknown, not "another check").
|
|
257
|
+
factCheck:{scope:everyCheckKeyed && checkEntries.length > 0 ? "task_check_history_with_key" : null,selection:everyCheckKeyed && checkEntries.length > 0 ? "checks.fact_check" : null,
|
|
258
|
+
passed:everyCheckKeyed && checkEntries.length > 0 ? checkEntries.filter(h => h.event === "check passed").length : null,
|
|
259
|
+
failed:everyCheckKeyed && checkEntries.length > 0 ? checkEntries.filter(h => h.event === "check failed").length : null,
|
|
260
|
+
missingKeysPerRun:everyCheckKeyed && checkEntries.length > 0 && checkEntries.every(h => h.missingKeys !== undefined) ? checkEntries.map(h => h.missingKeys!) : null,
|
|
261
|
+
// Agreement is a count of slots where the model's own verdict was entered and equals the person's, per label. A slot without the model's verdict (agreement null) is unknown and counts for nothing; one where they differ
|
|
262
|
+
// (agreement false) is a disagreement and counts for no label. With no comparable slot every label is null, never zero.
|
|
263
|
+
agreement:(["supported","unsupported","unclear"] as Verdict[]).map(label=>({label,count:compared.length > 0 ? compared.filter(c => c.agreement === true && c.personVerdict === label).length : null}))}
|
|
237
264
|
}} : {};
|
|
238
265
|
return {...research,profile, alias:group.alias, baseClass:group.base, tasks:[...ids].sort((a,b)=>a-b),
|
|
239
266
|
flow:{scope,autoApprovalTransitions:{scope:"recorded_events",value:eventsFull?new Set(autoApprovals.map(e=>e.id)).size:null},proposed:count("proposed"),accepted:count("accepted"),approved:milestoneKnown&&!approvalUnknown?approvalIds.size:null,cancelled:count("cancelled"),proposeToApproveMs:full&&!approvalUnknown?quantiles(leads):{samples:null,p50:null,p90:null},reviewRounds:milestoneKnown?rounds:null,firstPassApproval:full&&!approvalUnknown&&first.length?first.reduce((a,b)=>a+b,0)/first.length:null,escalations:count("escalated")},
|
package/src/hub/dogfood-read.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { claimListId } from "./board.ts";
|
|
1
2
|
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { Database } from "bun:sqlite";
|
|
@@ -96,7 +97,10 @@ export function dogfoodTasks(file: string): Source<DogfoodTask[]> {
|
|
|
96
97
|
if (history.some((h:any) => h && (h.unmetCriteria !== undefined && (!Array.isArray(h.unmetCriteria) || h.unmetCriteria.length>32 || new Set(h.unmetCriteria).size!==h.unmetCriteria.length || h.unmetCriteria.some((v:unknown)=>typeof v!=="string" || !/^[A-Za-z][A-Za-z0-9-]{0,47}$/.test(v))) || h.deliverables !== undefined && (!Array.isArray(h.deliverables) || h.deliverables.length>16 || h.deliverables.some((v:any)=>typeof v?.id!=="string" || !/^d-[0-9a-f]{12}$/.test(v.id)))))) complete=false;
|
|
97
98
|
const projection = history.filter((h:any)=>h && Number.isFinite(h.at) && typeof h.event==="string").map((h:any) => ({at:h.at,event:h.event,
|
|
98
99
|
...(Array.isArray(h.unmetCriteria) ? {criteria:h.unmetCriteria.slice(0,32).filter((v:unknown)=>typeof v === "string" && /^[A-Za-z][A-Za-z0-9-]{0,47}$/.test(v)).map((v:string)=>pii || h.withheld ? "withheld" : v), ...(h.withheld ? {criteriaPrivate:true as const} : {})} : {}),
|
|
99
|
-
...(Array.isArray(h.deliverables) ? {deliverables:h.deliverables.slice(0,16).filter((v:any)=>typeof v?.id==="string" && /^d-[0-9a-f]{12}$/.test(v.id)).map((v:any)=>v.id)} : {})
|
|
100
|
+
...(Array.isArray(h.deliverables) ? {deliverables:h.deliverables.slice(0,16).filter((v:any)=>typeof v?.id==="string" && /^d-[0-9a-f]{12}$/.test(v.id)).map((v:any)=>v.id)} : {}),
|
|
101
|
+
// #901: only closed values cross: a check key is a bounded identifier, a count a safe integer, a verdict one of three words and a model label a bounded token. Anything else is dropped (unknown).
|
|
102
|
+
...(h.event==="check passed" || h.event==="check failed" ? {...(typeof h.checkKey==="string" && /^[a-z][a-z0-9_]{0,47}$/.test(h.checkKey) ? {checkKey:h.checkKey} : {}), ...(Number.isSafeInteger(h.missingKeys) && h.missingKeys>=0 && h.missingKeys<=100000 ? {missingKeys:h.missingKeys} : {})} : {}),
|
|
103
|
+
...(h.event==="claim verdict" && h.claim && Number.isSafeInteger(h.claim.slot) && h.claim.slot>=1 && h.claim.slot<=50 && ["supported","unsupported","unclear"].includes(h.claim.verdict) ? {claim:{slot:h.claim.slot,verdict:h.claim.verdict,...(typeof h.claim.list==="string" && /^[0-9a-f]{16}$/.test(h.claim.list) ? {list:h.claim.list} : {}),...(typeof h.claim.model==="string" && /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,63}$/.test(h.claim.model) && !pii ? {model:h.claim.model} : {}),...(["supported","unsupported","unclear"].includes(h.claim.modelVerdict) ? {modelVerdict:h.claim.modelVerdict} : {})}} : {})}));
|
|
100
104
|
let declarations: ProfileTaskMetadata["plan"];
|
|
101
105
|
if (!row.plan_cut && row.plan !== null) {
|
|
102
106
|
try {
|
|
@@ -110,7 +114,7 @@ export function dogfoodTasks(file: string): Source<DogfoodTask[]> {
|
|
|
110
114
|
if (!Array.isArray(value) || value.length>50 || value.some((v:unknown)=>typeof v!=="string" || v.length>300)) throw new Error("plan list malformed");
|
|
111
115
|
return value.length;
|
|
112
116
|
};
|
|
113
|
-
declarations={sources:count("sources"),claims:count("claims")};
|
|
117
|
+
declarations={sources:count("sources"),claims:count("claims"),claimList:claimListId(Array.isArray(rawPlan.claims) ? rawPlan.claims : undefined)};
|
|
114
118
|
}
|
|
115
119
|
} catch {planBad=true;}
|
|
116
120
|
}
|
package/src/hub/envelope.ts
CHANGED
|
@@ -78,6 +78,17 @@ export const STANDING_INSTRUCTION =
|
|
|
78
78
|
"with [IMPORTANT] only when the recipient must see it before finishing what it is doing. Unmarked messages are batched. " +
|
|
79
79
|
HUB_MESSAGE_INSTRUCTION;
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* #912: Jcode defers MCP tools. Only its built-in tools are listed, and an MCP tool appears once the model searches for it, so a Jcode peer with no hint
|
|
83
|
+
* concludes it has no `hub_*` tools and answers in its final message instead of calling `hub_send`, `hub_task_accept`, `hub_task_done` or `hub_review`.
|
|
84
|
+
* This is the ONE place the guidance is written: `JcodePeer` puts it in front of the session's role contract (sent once, with the first prompt of a session),
|
|
85
|
+
* and no other peer's prompt, the AGENTS block or a profile template repeats it. The server name is `JCODE_MCP_NAME` (`src/cli/jcode-mcp.ts`); a test pins them equal.
|
|
86
|
+
*/
|
|
87
|
+
export const JCODE_DEFERRED_TOOLS_INSTRUCTION =
|
|
88
|
+
"[agent-hub] Your hub tools (hub_send, hub_inbox, hub_task_accept, hub_task_done, hub_review and the rest) are deferred MCP tools from the agent-hub-jcode server: " +
|
|
89
|
+
"they are not in your visible tool list until you load them. Before you conclude a hub tool is missing, search for it by name with your tool search and load it, " +
|
|
90
|
+
"then call it. Never report the hub as unavailable without having searched. Answer a hub message with hub_send, not only in your final message.";
|
|
91
|
+
|
|
81
92
|
const MARKER = /^\s*\[(IMPORTANT|STATUS|FYI)\]\s*/i;
|
|
82
93
|
|
|
83
94
|
/** A leading [IMPORTANT] / [STATUS] / [FYI] sets the priority and is stripped from the body. */
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { PrEvidence, SdlcConfig, SdlcGh } from "./sdlc.ts";
|
|
2
|
+
import { completePlanInventory } from "./plan-conformance.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* #481 S5: the gate MECHANISM for agent-configuration PRs. A PR whose changed paths match `config_paths` needs the one check named by `eval_check`.
|
|
6
|
+
* The hub chooses, creates and defaults to no suite: `eval_check` has no default, and the person names it (suite choice pending the person).
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_CONFIG_PATHS: readonly string[] = [
|
|
9
|
+
"AGENTS.md", "docs/agent-notes/**", "templates/**", ".agenthub/roles/**", ".agenthub/routing.toml", "routing.toml", "src/hub/hub-tools.ts",
|
|
10
|
+
];
|
|
11
|
+
const MAX_PATTERNS = 32;
|
|
12
|
+
|
|
13
|
+
/** Repository-relative patterns: literal names, `*` and `?` inside one segment, and a whole-segment `**`. Anything else is refused at load. */
|
|
14
|
+
export function configPathList(value: unknown, what: string): string[] {
|
|
15
|
+
const bad = () => new Error(`${what}: expected at most ${MAX_PATTERNS} unique repository-relative patterns (literal names, * and ? inside a segment, whole-segment **)`);
|
|
16
|
+
if (!Array.isArray(value) || value.length > MAX_PATTERNS || new Set(value).size !== value.length) throw bad();
|
|
17
|
+
for (const pattern of value) {
|
|
18
|
+
if (typeof pattern !== "string" || !pattern || pattern.length > 200 || /[\p{Cc}\p{Cf}\\]/u.test(pattern) || pattern.startsWith("/") || /^[A-Za-z]:/.test(pattern)) throw bad();
|
|
19
|
+
const parts = pattern.split("/");
|
|
20
|
+
if (parts.some(part => part === "" || part === "." || part === ".." || (part.includes("**") && part !== "**"))) throw bad();
|
|
21
|
+
}
|
|
22
|
+
return [...value] as string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const segmentRegex = (part: string) => new RegExp(`^${part.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")}$`);
|
|
26
|
+
/** Pure, lexical, no filesystem. `dir/**` matches everything under `dir`, never `dir` itself. */
|
|
27
|
+
export function matchConfigPath(pattern: string, path: string): boolean {
|
|
28
|
+
const p = pattern.split("/"), s = path.split("/");
|
|
29
|
+
const walk = (i: number, j: number): boolean => {
|
|
30
|
+
if (i === p.length) return j === s.length;
|
|
31
|
+
if (p[i] === "**") { for (let k = j + 1; k <= s.length; k++) if (walk(i + 1, k)) return true; return false; }
|
|
32
|
+
return j < s.length && segmentRegex(p[i]!).test(s[j]!) && walk(i + 1, j + 1);
|
|
33
|
+
};
|
|
34
|
+
return walk(0, 0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type EvalApplies = "config" | "code" | "unknown";
|
|
38
|
+
export const configPathsOf = (config: Pick<SdlcConfig, "configPaths">): readonly string[] => config.configPaths ?? DEFAULT_CONFIG_PATHS;
|
|
39
|
+
|
|
40
|
+
/** `unknown` when the file inventory is not complete (never a clean code-only claim); `code` for a PR touching no configured path, an empty `config_paths` included. */
|
|
41
|
+
export function evalApplies(config: Pick<SdlcConfig, "configPaths">, evidence: PrEvidence | undefined): { applies: EvalApplies; matched: string[] } {
|
|
42
|
+
const paths = configPathsOf(config);
|
|
43
|
+
if (!paths.length) return { applies: "code", matched: [] };
|
|
44
|
+
const inventory = completePlanInventory(evidence);
|
|
45
|
+
if (inventory === undefined) return { applies: "unknown", matched: [] };
|
|
46
|
+
const matched = inventory.filter(path => paths.some(pattern => matchConfigPath(pattern, path))).sort();
|
|
47
|
+
return { applies: matched.length ? "config" : "code", matched };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The extra required check for this reading of the PR: the configured eval check, only for a configuration PR. */
|
|
51
|
+
export function evalRequiredChecks(config: Pick<SdlcConfig, "configPaths" | "evalCheck" | "mode">, evidence: PrEvidence | undefined): string[] {
|
|
52
|
+
if (config.mode === "off" || !config.evalCheck) return [];
|
|
53
|
+
return evalApplies(config, evidence).applies === "config" ? [config.evalCheck] : [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What the eval check run reported, never its text: two bounded values read from a fixed marker (`eval_pass_rate=<0..1>`, `eval_suite=<version>`). */
|
|
57
|
+
export interface EvalResult { check: string; head: string; passRate?: number; suite?: string; read: boolean }
|
|
58
|
+
export interface EvalGate {
|
|
59
|
+
applies: "config" | "unknown";
|
|
60
|
+
/** How many changed files matched `config_paths`; names stay out of the view (counts and identities only). */
|
|
61
|
+
files: number;
|
|
62
|
+
check?: string;
|
|
63
|
+
state: "not_configured" | "coverage_unknown" | "passed" | "failed" | "pending" | "missing" | "unknown";
|
|
64
|
+
passRate?: number;
|
|
65
|
+
suite?: string;
|
|
66
|
+
/** Evidence gaps that were required of a configured gate: shown in every enabled mode, blockers only in enforce. */
|
|
67
|
+
gaps: string[];
|
|
68
|
+
/** Shown, never a blocker in any mode: the gate has no suite behind it yet (suite choice pending the person). */
|
|
69
|
+
notes?: string[];
|
|
70
|
+
}
|
|
71
|
+
export const EVAL_NOT_CONFIGURED = "eval check not configured: the person names one with eval_check in sdlc.toml (suite choice pending the person)";
|
|
72
|
+
export const EVAL_COVERAGE_UNKNOWN = "eval gate: PR file coverage incomplete or unknown";
|
|
73
|
+
export const evalReportedGap = (check: string) => `eval gate: pass rate or suite version not reported by ${check}`;
|
|
74
|
+
const reported = evalReportedGap;
|
|
75
|
+
|
|
76
|
+
/** Undefined for off mode and for a code-only PR: those outputs stay byte-identical. */
|
|
77
|
+
export function evalGateView(config: Pick<SdlcConfig, "configPaths" | "evalCheck" | "mode">, evidence: PrEvidence | undefined, result?: EvalResult): EvalGate | undefined {
|
|
78
|
+
if (config.mode === "off") return undefined;
|
|
79
|
+
const { applies, matched } = evalApplies(config, evidence);
|
|
80
|
+
if (applies === "code") return undefined;
|
|
81
|
+
const check = config.evalCheck;
|
|
82
|
+
// With no eval check named there is nothing to require: say so, and never block. Enforce users gain no new refusal until the person names a suite.
|
|
83
|
+
if (!check) return applies === "unknown" ? undefined : { applies, files: matched.length, state: "not_configured", gaps: [], notes: [EVAL_NOT_CONFIGURED] };
|
|
84
|
+
if (applies === "unknown") return { applies, files: 0, state: "coverage_unknown", check, gaps: [EVAL_COVERAGE_UNKNOWN] };
|
|
85
|
+
const found = evidence!.checks.filter(c => c.name === check);
|
|
86
|
+
const state = !found.length ? "missing" : found.some(c => c.state === "failed") ? "failed" : found.some(c => c.state === "pending") ? "pending" : found.every(c => c.state === "passed") ? "passed" : "unknown";
|
|
87
|
+
const view: EvalGate = { applies, files: matched.length, check, state, gaps: [] };
|
|
88
|
+
// The record is read only for a passed check at this head; a result read for another head or check is never shown.
|
|
89
|
+
if (result && result.read && result.check === check && result.head === evidence!.head) {
|
|
90
|
+
if (result.passRate !== undefined) view.passRate = result.passRate;
|
|
91
|
+
if (result.suite !== undefined) view.suite = result.suite;
|
|
92
|
+
}
|
|
93
|
+
if (state === "passed" && result && (view.passRate === undefined || view.suite === undefined)) view.gaps.push(reported(check));
|
|
94
|
+
return view;
|
|
95
|
+
}
|
|
96
|
+
/** Evidence gaps that block in enforce. The state of the check itself is an ordinary required-check blocker. */
|
|
97
|
+
export const evalGapBlockers = (view: EvalGate | undefined): string[] => view?.gaps ?? [];
|
|
98
|
+
/** What advise shows beside the gaps: gaps and notes, each once. */
|
|
99
|
+
export const evalAdvice = (view: EvalGate | undefined): string[] => [...(view?.gaps ?? []), ...(view?.notes ?? [])];
|
|
100
|
+
|
|
101
|
+
const MARK_RATE = /(?:^|\s)eval_pass_rate=(0(?:\.[0-9]{1,6})?|1(?:\.0{1,6})?)(?=\s|$)/;
|
|
102
|
+
const MARK_SUITE = /(?:^|\s)eval_suite=([A-Za-z0-9][A-Za-z0-9._-]{0,39})(?=\s|$)/;
|
|
103
|
+
const SHA = /^[a-f0-9]{40}$/;
|
|
104
|
+
/** Pure: the two markers, or nothing. The rest of the text is never kept. */
|
|
105
|
+
export function parseEvalMarkers(text: string): { passRate?: number; suite?: string } {
|
|
106
|
+
const rate = MARK_RATE.exec(text.slice(0, 2000))?.[1], suite = MARK_SUITE.exec(text.slice(0, 2000))?.[1];
|
|
107
|
+
return { ...(rate === undefined ? {} : { passRate: Number(rate) }), ...(suite === undefined ? {} : { suite }) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Read-only, fixed argv, bounded output and time. One completed check run of that exact name at that exact head, else `read: false`. */
|
|
111
|
+
export async function readEvalResult(repository: string, head: string, check: string, run: SdlcGh): Promise<EvalResult> {
|
|
112
|
+
const none: EvalResult = { check, head, read: false };
|
|
113
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || repository.startsWith("-") || !SHA.test(head) || !check || check.length > 120 || /[\x00-\x1f]/.test(check)) return none;
|
|
114
|
+
try {
|
|
115
|
+
const reply = await run(["api", `repos/${repository}/commits/${head}/check-runs?check_name=${encodeURIComponent(check)}&per_page=10`, "--jq",
|
|
116
|
+
'{total_count,check_runs:[.check_runs[]|{name,status,conclusion,head_sha,text:(((.output.title // "")+" "+(.output.summary // ""))|.[0:1000])}]}'], 10_000, undefined, 32 * 1024);
|
|
117
|
+
if (reply.code !== 0) return none;
|
|
118
|
+
const parsed = JSON.parse(reply.stdout), runs = parsed?.check_runs;
|
|
119
|
+
if (!Array.isArray(runs) || runs.length > 10 || parsed.total_count !== runs.length) return none;
|
|
120
|
+
const same = runs.filter((r: { name?: string }) => r?.name === check);
|
|
121
|
+
const only = same[0];
|
|
122
|
+
if (same.length !== 1 || only.head_sha !== head || only.status !== "completed" || typeof only.text !== "string") return none;
|
|
123
|
+
return { check, head, read: true, ...parseEvalMarkers(only.text) };
|
|
124
|
+
} catch { return none; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The read happens only where it can matter: an enabled mode, a configured eval check and a configuration PR. */
|
|
128
|
+
export async function readEvalFor(config: Pick<SdlcConfig, "configPaths" | "evalCheck" | "mode">, evidence: PrEvidence | undefined, repository: string | undefined, run: SdlcGh): Promise<EvalResult | undefined> {
|
|
129
|
+
if (!evidence || !repository || !config.evalCheck || evalRequiredChecks(config, evidence).length === 0) return undefined;
|
|
130
|
+
return readEvalResult(repository, evidence.head, config.evalCheck, run);
|
|
131
|
+
}
|
package/src/hub/events.ts
CHANGED
|
@@ -53,7 +53,7 @@ export type HubEvent =
|
|
|
53
53
|
| { type: "hook_stats"; peer: string; n: number; startupMs: number; hubMs: number; maxStartupMs: number }
|
|
54
54
|
| { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
|
|
55
55
|
| ({ type: "tokens"; peer: string; n: number } & Partial<TaskAttribution>)
|
|
56
|
-
| ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript" | "claude_result" | "codex_appserver" | "kimi_wire"; id: string; reasoningTokens?: number; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
|
|
56
|
+
| ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript" | "claude_result" | "codex_appserver" | "kimi_wire" | "jcode_acp"; id: string; reasoningTokens?: number; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
|
|
57
57
|
| { type: "planning"; by: string; task?: number; release?: number; fields: string[]; revision: number }
|
|
58
58
|
| { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; profile?: string; profileClass?: string; role?: string; roleIdentity?: { id: string; layer: "shipped" | "project"; hash: string }; pii: boolean; reason?: string; release?: number | null; priority?: number | null; plannedStart?: number | null; due?: number | null }
|
|
59
59
|
| { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
|