@staix/agent-hub 0.12.33 → 0.12.35
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 +22 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +9 -3
- package/docs/agent-notes/budget.md +7 -3
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +7 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +6 -2
- package/docs/agent-notes/tests.md +2 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +80 -16
- package/docs/quickstart.md +10 -2
- package/docs/sdlc.md +59 -34
- package/docs/security.md +7 -4
- package/docs/smoke.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +407 -10
- package/docs/verification/2026-10-18-0.12.34.md +48 -0
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verified.json +35 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +23567 -16606
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/codex-appserver.ts +82 -12
- package/src/adapters/definitions/builtin-metadata.ts +101 -7
- package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
- package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
- package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
- package/src/adapters/definitions/native-cli-doctor.ts +46 -0
- package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +34 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +27 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +57 -0
- package/src/adapters/definitions/native-cli-entry-local.ts +15 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +53 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +21 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +41 -0
- package/src/adapters/definitions/native-cli-entry.ts +19 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
- package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
- package/src/adapters/definitions/native-cli-pi.ts +33 -0
- package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
- package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
- package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
- package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
- package/src/adapters/definitions/native-cli-preview.ts +16 -0
- package/src/adapters/definitions/native-config-defaults.ts +19 -0
- package/src/adapters/definitions/native-config-policy.ts +35 -0
- package/src/adapters/definitions/native-context.ts +35 -0
- package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
- package/src/adapters/definitions/native-daemon-policy.ts +18 -0
- package/src/adapters/definitions/native-display.ts +18 -0
- package/src/adapters/definitions/native-factories.ts +48 -0
- package/src/adapters/definitions/native-policy-aux.ts +130 -0
- package/src/adapters/definitions/native-quota.ts +29 -0
- package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
- package/src/adapters/definitions/native-recovery.ts +88 -0
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/definitions/qwen.ts +8 -4
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +100 -10
- package/src/adapters/registry.ts +127 -11
- package/src/cli/console-state.ts +5 -5
- package/src/cli/identity.ts +8 -7
- package/src/cli/jcode-mcp.ts +2 -58
- package/src/cli/launch.ts +41 -102
- package/src/cli/main.ts +126 -290
- package/src/cli/output.ts +3 -2
- package/src/cli/preview.ts +26 -126
- package/src/cli/recovery-guidance.ts +43 -22
- package/src/cli/setup.ts +2 -80
- package/src/cli/shell-quote.ts +3 -0
- package/src/cli/terminal-recovery.ts +81 -22
- package/src/cli/upgrade-interactive.ts +4 -2
- package/src/cli/upgrade-restore-state.ts +17 -7
- package/src/cli/upgrade-runtime.ts +126 -147
- package/src/cli/upgrade.ts +44 -27
- package/src/hub/budget.ts +59 -31
- package/src/hub/bus.ts +1 -1
- package/src/hub/conductor.ts +19 -9
- package/src/hub/config-apply.ts +22 -15
- package/src/hub/config-reload.ts +18 -3
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +3 -27
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +15 -39
- package/src/hub/daemon.ts +506 -341
- package/src/hub/execution-budget.ts +3 -2
- package/src/hub/hub-tools.ts +19 -8
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +100 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/peer-catalog.ts +4 -13
- package/src/hub/permission-mode.ts +14 -17
- package/src/hub/recovery-continuity.ts +6 -5
- package/src/hub/report.ts +9 -8
- package/src/hub/roster.ts +9 -9
- package/src/hub/routing.ts +94 -19
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +84 -38
- package/src/hub/settings.ts +39 -10
- package/src/hub/settled-roles.ts +1 -1
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +13 -8
- package/src/hub/status-roster.ts +8 -0
- package/src/hub/tasks.ts +60 -27
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/tooling.ts +4 -60
- package/src/hub/ui.ts +3 -1
- package/src/hub/workflow.ts +30 -52
- package/src/memory/recall.ts +4 -5
- package/src/models/route/signals.ts +3 -1
- package/src/pi/launch.ts +5 -3
- package/src/ui/index.html +4 -3
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +16 -6
- package/templates/AGENTS.block.md +2 -1
- package/templates/routing.toml +2 -2
package/src/hub/snapshots.ts
CHANGED
|
@@ -216,6 +216,7 @@ export class Turns {
|
|
|
216
216
|
// ponytail: rows of finished tasks stay (a few per file per task); prune by task state if hub.db ever grows.
|
|
217
217
|
this.db.run("CREATE TABLE IF NOT EXISTS touches (task INTEGER NOT NULL, peer TEXT NOT NULL, path TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (task, peer, path))");
|
|
218
218
|
this.db.run("CREATE INDEX IF NOT EXISTS touches_path ON touches (path)"); // the PreToolUse hook looks up one path
|
|
219
|
+
this.db.run("CREATE TABLE IF NOT EXISTS turn_workspaces (turn TEXT PRIMARY KEY, root TEXT NOT NULL, start_tree TEXT, end_tree TEXT, changed TEXT NOT NULL DEFAULT '[]')");
|
|
219
220
|
this.db.run("CREATE TABLE IF NOT EXISTS task_snapshots (task INTEGER NOT NULL, proposed_at INTEGER NOT NULL, start_tree TEXT, PRIMARY KEY (task, proposed_at))");
|
|
220
221
|
}
|
|
221
222
|
/** A proposal's original project snapshot; task ids may be reused after reset. Never repair a missing baseline later. */
|
|
@@ -231,27 +232,55 @@ export class Turns {
|
|
|
231
232
|
throw error;
|
|
232
233
|
}
|
|
233
234
|
}
|
|
234
|
-
begin(id: string, peer: string, startTree: string | undefined): void {
|
|
235
|
-
this.db.
|
|
235
|
+
begin(id: string, peer: string, startTree: string | undefined, workspace?: string): void {
|
|
236
|
+
this.db.transaction(() => {
|
|
237
|
+
this.db.query("INSERT OR REPLACE INTO turns (id, peer, started, start_tree) VALUES (?, ?, ?, ?)").run(id, peer, Date.now(), workspace === undefined ? startTree ?? null : null);
|
|
238
|
+
this.db.query("DELETE FROM turn_workspaces WHERE turn = ?").run(id);
|
|
239
|
+
if (workspace !== undefined) this.db.query("INSERT INTO turn_workspaces (turn, root, start_tree) VALUES (?, ?, ?)").run(id, workspace, startTree ?? null);
|
|
240
|
+
})();
|
|
241
|
+
}
|
|
242
|
+
/** Only new extra-instance turns carry this evidence; absence preserves legacy external-peer workspace semantics. */
|
|
243
|
+
workspace(id: string): string | undefined {
|
|
244
|
+
try {
|
|
245
|
+
const row = this.db.query("SELECT root FROM turn_workspaces WHERE turn = ?").get(id) as { root: string } | null;
|
|
246
|
+
if (row && (typeof row.root !== "string" || !row.root)) throw new Error("invalid turn workspace evidence");
|
|
247
|
+
return row?.root;
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (/no such table/.test((error as Error).message)) return undefined;
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
private snapshotFields<T extends { id: string; start_tree: string | null; end_tree: string | null; changed: string }>(row: T): T {
|
|
254
|
+
if (this.workspace(row.id) === undefined) return row;
|
|
255
|
+
const fields = this.db.query("SELECT start_tree, end_tree, changed FROM turn_workspaces WHERE turn = ?").get(row.id) as { start_tree: string | null; end_tree: string | null; changed: string };
|
|
256
|
+
return { ...row, ...fields };
|
|
236
257
|
}
|
|
237
258
|
/** The first native turn of a hub turn: reverting the conversation drops it and every later one. */
|
|
238
259
|
native(id: string, native: string): void {
|
|
239
260
|
this.db.query("UPDATE turns SET native = ? WHERE id = ? AND native IS NULL").run(native, id);
|
|
240
261
|
}
|
|
241
262
|
end(id: string, endTree: string | undefined, changed: string[], keep: number): void {
|
|
242
|
-
this.db.
|
|
263
|
+
this.db.transaction(() => {
|
|
264
|
+
const scoped = this.workspace(id) !== undefined;
|
|
265
|
+
// Older CLIs do not know workspace receipts. Leave their canonical-checkout snapshot view unknown.
|
|
266
|
+
this.db.query("UPDATE turns SET ended = ?, end_tree = ?, changed = ? WHERE id = ?").run(Date.now(), scoped ? null : endTree ?? null, scoped ? "[]" : JSON.stringify(changed), id);
|
|
267
|
+
if (scoped) this.db.query("UPDATE turn_workspaces SET end_tree = ?, changed = ? WHERE turn = ?").run(endTree ?? null, JSON.stringify(changed), id);
|
|
268
|
+
})();
|
|
243
269
|
const peer = (this.db.query("SELECT peer FROM turns WHERE id = ?").get(id) as { peer: string } | null)?.peer;
|
|
244
270
|
if (peer) this.db.query("DELETE FROM turns WHERE peer = ? AND id NOT IN (SELECT id FROM turns WHERE peer = ? ORDER BY started DESC, rowid DESC LIMIT ?)").run(peer, peer, keep);
|
|
271
|
+
this.db.run("DELETE FROM turn_workspaces WHERE turn NOT IN (SELECT id FROM turns)");
|
|
245
272
|
}
|
|
246
273
|
list(peer?: string, limit = 20): TurnRecord[] {
|
|
247
274
|
const rows = (peer
|
|
248
275
|
? this.db.query("SELECT * FROM turns WHERE peer = ? ORDER BY started DESC, rowid DESC LIMIT ?").all(peer, limit)
|
|
249
276
|
: this.db.query("SELECT * FROM turns ORDER BY started DESC, rowid DESC LIMIT ?").all(limit)) as (Omit<TurnRecord, "changed"> & { changed: string })[];
|
|
250
|
-
return rows.map((r) => ({ ...r, changed: JSON.parse(r.changed) as string[] }));
|
|
277
|
+
return rows.map(row => this.snapshotFields(row)).map((r) => ({ ...r, changed: JSON.parse(r.changed) as string[] }));
|
|
251
278
|
}
|
|
252
279
|
get(id: string): TurnRecord | undefined {
|
|
253
280
|
const r = this.db.query("SELECT * FROM turns WHERE id = ?").get(id) as (Omit<TurnRecord, "changed"> & { changed: string }) | null;
|
|
254
|
-
|
|
281
|
+
if (!r) return undefined;
|
|
282
|
+
const record = this.snapshotFields(r);
|
|
283
|
+
return { ...record, changed: JSON.parse(record.changed) as string[] };
|
|
255
284
|
}
|
|
256
285
|
/**
|
|
257
286
|
* Other peers' turns that overlapped this one in time: their changes are in this turn's diff too. `unknown` lists
|
|
@@ -263,21 +292,23 @@ export class Turns {
|
|
|
263
292
|
// before" time in Turns.end if that ever matters.
|
|
264
293
|
overlapping(turn: TurnRecord, keep: number): { paths: string[]; unknown: string[] } {
|
|
265
294
|
const rows = this.db.query("SELECT id, changed, ended, start_tree, end_tree FROM turns WHERE id != ? AND peer != ? AND started < ? AND (ended IS NULL OR ended > ?)").all(turn.id, turn.peer, turn.ended ?? Date.now(), turn.started) as { id: string; changed: string; ended: number | null; start_tree: string | null; end_tree: string | null }[];
|
|
266
|
-
const
|
|
295
|
+
const scopedRows = rows.map(row => this.snapshotFields(row));
|
|
296
|
+
const known = scopedRows.filter((r) => r.ended !== null && r.start_tree !== null && r.end_tree !== null);
|
|
267
297
|
const pruned = (this.db.query("SELECT peer, MIN(started) AS oldest FROM turns WHERE peer != ? GROUP BY peer HAVING COUNT(*) >= ?").all(turn.peer, keep) as { peer: string; oldest: number }[])
|
|
268
298
|
.filter((p) => p.oldest > turn.started)
|
|
269
299
|
.map((p) => `${p.peer} (its turns from then were pruned)`);
|
|
270
|
-
return { paths: [...new Set(known.flatMap((r) => JSON.parse(r.changed) as string[]))], unknown: [...
|
|
300
|
+
return { paths: [...new Set(known.flatMap((r) => JSON.parse(r.changed) as string[]))], unknown: [...scopedRows.filter((r) => !known.includes(r)).map((r) => r.id), ...pruned] };
|
|
271
301
|
}
|
|
272
302
|
|
|
273
303
|
/** Other peers' completed, snapshotted turns that overlapped this turn and changed at least one of the same files. */
|
|
274
304
|
concurrentWith(turn: TurnRecord): TurnRecord[] {
|
|
275
305
|
if (turn.ended === null || turn.start_tree === null || turn.end_tree === null || !turn.changed.length) return [];
|
|
276
306
|
const rows = this.db.query(`SELECT * FROM turns WHERE id != ? AND peer != ? AND started < ? AND ended > ?
|
|
277
|
-
|
|
307
|
+
ORDER BY started, rowid`)
|
|
278
308
|
.all(turn.id, turn.peer, turn.ended, turn.started) as (Omit<TurnRecord, "changed"> & { changed: string })[];
|
|
279
309
|
const changed = new Set(turn.changed);
|
|
280
|
-
return rows.map((
|
|
310
|
+
return rows.map(row => this.snapshotFields(row)).filter(row => row.start_tree !== null && row.end_tree !== null)
|
|
311
|
+
.map((r) => ({ ...r, changed: JSON.parse(r.changed) as string[] }))
|
|
281
312
|
.filter((other) => other.changed.some((path) => changed.has(path)));
|
|
282
313
|
}
|
|
283
314
|
|
package/src/hub/start-mode.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SUPPORTED_PEERS,
|
|
1
|
+
import { definitionOf, SUPPORTED_PEERS, START_MODE_PEERS as REGISTRY_START_MODE_PEERS, type KindMap } from "../adapters/registry.ts";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* #269: how a peer comes up when the hub starts it: its own TUI in a terminal, or headless. A person who types
|
|
@@ -8,34 +8,39 @@ import { SUPPORTED_PEERS, isSupportedPeer } from "./peer-catalog.ts";
|
|
|
8
8
|
export const PEER_START_RETRY_MS = 30_000;
|
|
9
9
|
export const START_MODES = ["tui", "headless"] as const;
|
|
10
10
|
export type StartMode = typeof START_MODES[number];
|
|
11
|
+
// ponytail: released coordinators source-probe this exact literal before restoring headless Kimi.
|
|
12
|
+
// Keep it until those readers negotiate the registry capability.
|
|
11
13
|
/** The peers with both forms. Kimi's TUI is pull-only; Claude has only a TUI and local only headless. */
|
|
12
14
|
export const START_MODE_PEERS = ["pi", "codex", "kimi"] as const;
|
|
13
15
|
/** Every peer the hub can start itself. */
|
|
14
16
|
export const STARTABLE_PEERS = SUPPORTED_PEERS;
|
|
15
17
|
export type StartablePeer = typeof STARTABLE_PEERS[number];
|
|
16
|
-
export const isStartablePeer = (value: unknown): value is StartablePeer =>
|
|
18
|
+
export const isStartablePeer = (value: unknown): value is StartablePeer => typeof value === "string" && !!definitionOf(value)?.start.conductorStartable;
|
|
19
|
+
|
|
17
20
|
|
|
18
21
|
export type PeerStartConfig = Record<string, { start_mode?: StartMode }>;
|
|
19
22
|
/** The checked per-peer start modes; only peers with both forms may be configured. */
|
|
20
|
-
export function peerStartConfig(value: unknown): PeerStartConfig {
|
|
23
|
+
export function peerStartConfig(value: unknown, instances?: KindMap): PeerStartConfig {
|
|
21
24
|
if (value === undefined) return {};
|
|
22
25
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("peers must be an object of per-peer settings");
|
|
23
26
|
const result: PeerStartConfig = {};
|
|
24
27
|
for (const [peer, block] of Object.entries(value)) {
|
|
25
|
-
|
|
28
|
+
const extra = definitionOf(peer, instances)?.kind !== peer && !!definitionOf(peer, instances);
|
|
29
|
+
if (!(REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer) && !extra) throw new Error(`peers.${peer}: only ${REGISTRY_START_MODE_PEERS.join(" and ")} have a start mode`);
|
|
26
30
|
if (!block || typeof block !== "object" || Array.isArray(block)) throw new Error(`peers.${peer} must be an object`);
|
|
27
31
|
const mode = (block as { start_mode?: unknown }).start_mode;
|
|
28
32
|
if (mode !== undefined && !(START_MODES as readonly unknown[]).includes(mode)) throw new Error(`peers.${peer}.start_mode must be tui or headless`);
|
|
33
|
+
if (extra && mode !== undefined && mode !== "headless") throw new Error(`peers.${peer}.start_mode: extra instances require headless mode in I2`);
|
|
29
34
|
result[peer] = mode === undefined ? {} : { start_mode: mode as StartMode };
|
|
30
35
|
}
|
|
31
36
|
return result;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
/** The mode a start of `peer` takes when nothing more specific was asked: `tui` wherever the peer has one. */
|
|
35
|
-
export function startModeOf(peers: PeerStartConfig | undefined, peer: string): StartMode {
|
|
36
|
-
if (peer
|
|
37
|
-
if ((
|
|
38
|
-
return "headless";
|
|
40
|
+
export function startModeOf(peers: PeerStartConfig | undefined, peer: string, instances?: KindMap): StartMode {
|
|
41
|
+
if (definitionOf(peer, instances)?.kind !== peer && definitionOf(peer, instances)) return peers?.[peer]?.start_mode ?? "headless";
|
|
42
|
+
if ((REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer)) return peers?.[peer]?.start_mode ?? definitionOf(peer)?.start.defaultForm ?? "tui";
|
|
43
|
+
return definitionOf(peer)?.start.defaultForm ?? "headless";
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
/**
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// No imports: native CLI entries load this from inside the adapter registry's import graph (#484).
|
|
2
|
+
/** A peer's membership from a `status` reply: undefined when the hub sends no roster (older than #484), never read as off. */
|
|
3
|
+
export function rosterInStatus(status: unknown, peer: string): boolean | undefined {
|
|
4
|
+
const rows = (status as { roster?: unknown } | undefined)?.roster;
|
|
5
|
+
if (!Array.isArray(rows)) return undefined;
|
|
6
|
+
const row = rows.find((r: unknown) => !!r && typeof r === "object" && (r as { peer?: unknown }).peer === peer) as { on?: unknown } | undefined;
|
|
7
|
+
return typeof row?.on === "boolean" ? row.on : undefined;
|
|
8
|
+
}
|
package/src/hub/tasks.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { InstancesConfig } from "./instances.ts";
|
|
1
2
|
import { composeRememberText } from "./memory-fail.ts";
|
|
3
|
+
import { VERSION } from "../version.ts";
|
|
2
4
|
import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
|
|
3
5
|
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
4
6
|
import { readMergeQueue } from "./sdlc-queue.ts";
|
|
@@ -9,7 +11,7 @@ import { correctionInput, resolveVisibleMemory } from "./memory-candidates.ts";
|
|
|
9
11
|
import type { MemoryScreen } from "./read-tools.ts";
|
|
10
12
|
import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
11
13
|
import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
|
|
12
|
-
import {
|
|
14
|
+
import { criteriaView, workflowHash } from "./workflow.ts";
|
|
13
15
|
import { isAbsolute, relative } from "node:path";
|
|
14
16
|
import type { Briefs, NativeItem } from "../memory/brief.ts";
|
|
15
17
|
import type { MemoryClient } from "../memory/client.ts";
|
|
@@ -18,14 +20,14 @@ import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskHasSta
|
|
|
18
20
|
import type { Bus } from "./bus.ts";
|
|
19
21
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
20
22
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
21
|
-
import { assign, detectSignals,
|
|
23
|
+
import { assign, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
22
24
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
23
25
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
24
26
|
import { realPath } from "./project.ts";
|
|
25
27
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
26
|
-
import {
|
|
28
|
+
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
27
29
|
import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
28
|
-
import { readSdlcPr, sdlcConfig, sdlcReceipt, sdlcStatus, stageDefinition, lifecycleStage, type SdlcConfig, sdlcGh, type SdlcGh, type SdlcStatus } from "./sdlc.ts";
|
|
30
|
+
import { checklistOf, readSdlcPr, sdlcConfig, sdlcReceipt, sdlcStatus, stageDefinition, lifecycleStage, type SdlcConfig, sdlcGh, type SdlcGh, type SdlcStatus } from "./sdlc.ts";
|
|
29
31
|
|
|
30
32
|
export type TaskAssignmentRole = "owner" | "reviewer";
|
|
31
33
|
|
|
@@ -36,6 +38,10 @@ export interface TasksDeps {
|
|
|
36
38
|
board: Board;
|
|
37
39
|
bus: Bus;
|
|
38
40
|
routing: () => Routing;
|
|
41
|
+
/** Startup-bound project declarations; routing never discovers instances from peer names. */
|
|
42
|
+
instances?: () => InstancesConfig;
|
|
43
|
+
/** Host proof of each live extra runtime's actual isolated execution root; config paths alone are not proof. */
|
|
44
|
+
ownerIsolation?: () => Readonly<Record<string, boolean>>;
|
|
39
45
|
cwd: string;
|
|
40
46
|
project: string;
|
|
41
47
|
profile?: ProfileName;
|
|
@@ -125,9 +131,12 @@ export interface TasksDeps {
|
|
|
125
131
|
sweep?: TaskSweepConfig;
|
|
126
132
|
/** Shutdown or coordinator work holds task sweeps too. */
|
|
127
133
|
sweepHeld?: () => boolean;
|
|
134
|
+
/** Consecutive changes_requested verdicts that move a task to another peer (`tasks.escalate_after`, #488). */
|
|
135
|
+
escalateAfter?: () => number;
|
|
128
136
|
}
|
|
129
137
|
|
|
130
|
-
|
|
138
|
+
/** The `tasks.escalate_after` default (#488): the third consecutive changes_requested escalates. */
|
|
139
|
+
export const ESCALATE_AFTER = 3;
|
|
131
140
|
// Demotion (issue #36): a failure counts half after a day. A peer is demoted for a class once its decayed failures
|
|
132
141
|
// reach 1.5 (two within about half a day, three within two days) and outweigh its decayed successes there.
|
|
133
142
|
// ponytail: fixed constants; make them config when someone needs to tune them.
|
|
@@ -505,19 +514,31 @@ export class Tasks {
|
|
|
505
514
|
return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
|
|
506
515
|
}
|
|
507
516
|
|
|
517
|
+
/** Workload is board/queue data only: wip() never calls health() or weights(). */
|
|
518
|
+
private instanceRouting(): InstanceRoutingOptions {
|
|
519
|
+
const instances = this.d.instances?.();
|
|
520
|
+
if (!instances || !Object.keys(instances).length) return {};
|
|
521
|
+
const peers = [...new Set([...ADAPTERS.map(adapter => adapter.kind), ...Object.keys(instances), ...this.d.bus.peers.keys()])];
|
|
522
|
+
return { instances, ownerIsolation: this.d.ownerIsolation?.() ?? {}, wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])), load: Object.fromEntries(peers.map(peer => [peer, this.d.bus.queued(peer)])) };
|
|
523
|
+
}
|
|
524
|
+
|
|
508
525
|
/** Peer health and roles the daemon feeds routing (issues #89, #90, #92): failing peers are skipped, held queues explained. */
|
|
509
526
|
private health() {
|
|
510
527
|
const failing = this.d.failing?.();
|
|
511
528
|
const held = this.d.held?.();
|
|
512
|
-
|
|
529
|
+
const workload = this.instanceRouting();
|
|
530
|
+
const peers = workload.instances ? [...new Set([...ADAPTERS.map(adapter => adapter.kind), ...Object.keys(workload.instances), ...this.d.bus.peers.keys()])] : ADAPTERS.map(adapter => adapter.kind);
|
|
531
|
+
return { ...workload, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => definitionOf(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
513
532
|
}
|
|
514
533
|
|
|
515
534
|
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
516
535
|
private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: TaskAssignmentRole = "owner", preEffect = true): void {
|
|
517
|
-
|
|
536
|
+
const instances = this.d.instances?.(), adapter = definitionOf(peer, instances);
|
|
537
|
+
const declaredExtra = !!adapter && adapter.kind !== peer;
|
|
538
|
+
if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
|
|
518
539
|
const { state: _state, ...draft } = task; // backlog/readiness must not bypass an explicit reservation's admission
|
|
519
540
|
const admission = assign(draft, this.states(), this.d.routing(), { candidates: role === "owner" ? [peer] : [], ...(role === "owner" ? { namedOwner: peer } : { namedReviewer: peer }), ...this.weights(task.class) });
|
|
520
|
-
const missing = (admission.refusals ?? []).filter(row => row.peer === peer && row.role === role && (row.code === "tests" || row.code === "spec")).map(row => ` ${role} candidate ${peer}: skipped, ${row.reason}`);
|
|
541
|
+
const missing = (admission.refusals ?? []).filter(row => row.peer === peer && row.role === role && (row.code === "tests" || row.code === "spec" || declaredExtra && role === "owner" && row.code === "owner-admission")).map(row => ` ${role} candidate ${peer}: skipped, ${row.reason}`);
|
|
521
542
|
if (missing.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot assign ${peer}: ${missing.join("; ")}`);
|
|
522
543
|
}
|
|
523
544
|
|
|
@@ -799,7 +820,7 @@ export class Tasks {
|
|
|
799
820
|
if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
800
821
|
if (Object.hasOwn(patch, "deps") || Object.hasOwn(patch, "reserved")) {
|
|
801
822
|
if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
|
|
802
|
-
if (patch.reserved && !(
|
|
823
|
+
if (patch.reserved && !definitionOf(patch.reserved, this.d.instances?.()) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
|
|
803
824
|
if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
|
|
804
825
|
if (patch.reserved) this.requireCapabilities(task, patch.reserved);
|
|
805
826
|
const rows = new Map(this.d.board.list().map(row => [row.id, row]));
|
|
@@ -1387,7 +1408,7 @@ export class Tasks {
|
|
|
1387
1408
|
|
|
1388
1409
|
/** Same code as assignment, without doing it. `routing`: a candidate policy to explain under (the settings preview, #269). */
|
|
1389
1410
|
explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing: Routing = this.d.routing()): string[] {
|
|
1390
|
-
const workflow = this.d.sdlc?.config().workflow === "gsd-lite" ? [`
|
|
1411
|
+
const workflow = this.d.sdlc?.config().workflow === "gsd-lite" ? [`criteria checklist required on every task (retired gsd-lite setting, ${this.d.sdlc.config().mode}); board dependencies authoritative`] : [];
|
|
1391
1412
|
if (typeof target === "number") {
|
|
1392
1413
|
const task = this.d.board.get(target);
|
|
1393
1414
|
if (!task) throw new Error(`no task #${target}`);
|
|
@@ -1458,6 +1479,10 @@ export class Tasks {
|
|
|
1458
1479
|
if (hold) this.d.notify(`task ${this.publicTitle(task)}: ${a.owner}'s queue is held (${hold}); the task arrives once the hold is resolved`);
|
|
1459
1480
|
}
|
|
1460
1481
|
|
|
1482
|
+
private assignmentNote(note: string, assignment: Assignment): string {
|
|
1483
|
+
return assignment.sameFamilyFallback ? `${note}; sameFamilyFallback=true` : note;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1461
1486
|
/** A later move to another owner means the caller's assignment was superseded, so its success tail is not authoritative. */
|
|
1462
1487
|
private ownOwnerMove(before: Task, after: Task, by: PeerId, event: string): boolean {
|
|
1463
1488
|
const moves = (task: Task) => task.history.filter(entry => ["assigned", "reassigned", "escalated", "unassigned"].includes(entry.event));
|
|
@@ -1496,7 +1521,7 @@ export class Tasks {
|
|
|
1496
1521
|
// A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
|
|
1497
1522
|
// out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
|
|
1498
1523
|
const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
|
|
1499
|
-
const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, `${opts.note ?? `to ${a.owner}`}${passed}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) }, opts.expectedFlowRevision);
|
|
1524
|
+
const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a), { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) }, opts.expectedFlowRevision);
|
|
1500
1525
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
1501
1526
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
1502
1527
|
// yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
|
|
@@ -1596,7 +1621,7 @@ export class Tasks {
|
|
|
1596
1621
|
const pii = this.isPii(task), delivery = { ...a, reviewer: task.reviewer ?? undefined };
|
|
1597
1622
|
const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", delivery.owner, h.withheld)}`);
|
|
1598
1623
|
const handoff = context && !pii ? this.screen(task, context, "handoff", delivery.owner, contextWithheld).slice(0, 3000) : "";
|
|
1599
|
-
const facts = [`class ${profileTaskLabel(task)}`, delivery.owner
|
|
1624
|
+
const facts = [`class ${profileTaskLabel(task)}`, delivery.owner && definitionOf(delivery.owner, this.d.instances?.())?.routing?.backendLabel ? `backend ${definitionOf(delivery.owner, this.d.instances?.())!.routing!.backendLabel}/${delivery.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", delivery.reviewer ? `reviewer ${delivery.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(delivery)].filter(Boolean).join("; ");
|
|
1600
1625
|
const plan = planText(task.plan);
|
|
1601
1626
|
const body = [
|
|
1602
1627
|
`Task #${task.id} [${profileTaskLabel(task)}] ${task.title}`,
|
|
@@ -1710,7 +1735,9 @@ export class Tasks {
|
|
|
1710
1735
|
}
|
|
1711
1736
|
private ownerStallCause(task: Task, admission: Assignment): TaskStall["cause"] {
|
|
1712
1737
|
const refusal = admission.refusals?.find(row => row.role === "owner" && row.peer === task.reserved);
|
|
1713
|
-
|
|
1738
|
+
// Persist the existing stall vocabulary; the assignment/explain keeps the precise instance/family refusal.
|
|
1739
|
+
const persisted = (code: NonNullable<Assignment["refusals"]>[number]["code"]): TaskStall["cause"]["code"] => code === "instance" || code === "family" ? "policy" : code;
|
|
1740
|
+
return refusal ? { kind: "reservation", peer: refusal.peer, code: persisted(refusal.code) } : { kind: "no-eligible-peer", code: "unavailable", refusals: admission.refusals?.filter(row => row.role === "owner").map(({ peer, code }) => ({ peer, code: persisted(code) })).sort((a, b) => a.peer.localeCompare(b.peer)) ?? [] };
|
|
1714
1741
|
}
|
|
1715
1742
|
private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now()): void {
|
|
1716
1743
|
const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
|
|
@@ -1813,17 +1840,18 @@ export class Tasks {
|
|
|
1813
1840
|
const task = this.need(id, false, true);
|
|
1814
1841
|
if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
|
|
1815
1842
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1816
|
-
const next = sdlcReceipt(input, task.sdlc, config);
|
|
1843
|
+
const next = sdlcReceipt(input, task.sdlc, config, VERSION);
|
|
1817
1844
|
const definition = stageDefinition(config, next.stage);
|
|
1818
1845
|
if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
|
|
1819
1846
|
const actor = definition?.actor;
|
|
1820
1847
|
const acts = actor === "owner" ? by === task.owner : actor === "reviewer" ? by === task.reviewer : actor === "planner" ? this.d.roles?.[by]?.includes("planner") : actor === "owner/planner" && (by === task.owner || this.d.roles?.[by]?.includes("planner"));
|
|
1821
1848
|
if (!acts) throw new PreEffectToolRefusal(`lifecycle stage requires its ${actor ?? "unknown"} actor or the person`);
|
|
1822
1849
|
}
|
|
1823
|
-
if (input && typeof input === "object" && "phase" in input) {
|
|
1850
|
+
if (input && typeof input === "object" && ("criteria" in input || "phase" in input)) {
|
|
1824
1851
|
this.mine(task, by, "reviewer", true);
|
|
1825
|
-
|
|
1826
|
-
|
|
1852
|
+
// Screen whichever name stored the checklist: off keeps the #257 phase key (#481 S8).
|
|
1853
|
+
const checklist = checklistOf(next);
|
|
1854
|
+
if (checklist && !this.isPii(task) && !this.nameable(JSON.stringify(checklist))) throw new PreEffectToolRefusal("checklist metadata matches a PII pattern; use private work");
|
|
1827
1855
|
}
|
|
1828
1856
|
// Recording a head is the reviewer's; a head dropped because the repo or PR changed is not a recording, and a
|
|
1829
1857
|
// head supplied with a new PR identity is one even when its value is unchanged (#382).
|
|
@@ -1857,12 +1885,17 @@ export class Tasks {
|
|
|
1857
1885
|
}
|
|
1858
1886
|
|
|
1859
1887
|
private workflowView(task: Task) {
|
|
1860
|
-
return
|
|
1888
|
+
return criteriaView(this.d.sdlc?.config() ?? sdlcConfig(undefined), task, t => this.isPii(t));
|
|
1861
1889
|
}
|
|
1862
1890
|
workflowExport(id: unknown): Record<string, unknown> {
|
|
1863
|
-
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1864
|
-
|
|
1865
|
-
|
|
1891
|
+
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined), stage = task.sdlc?.stage ?? config.stages[0] ?? "spec";
|
|
1892
|
+
// Off keeps the #257 export bytes; schema 2 (criteria, no workflow framing) is for an enabled or retired-setting lifecycle.
|
|
1893
|
+
if (config.mode === "off" && !config.workflow) {
|
|
1894
|
+
const legacy = { id: task.id, state: task.state, stage, ...(this.isPii(task) ? { private: true } : { phase: null }) };
|
|
1895
|
+
return { schema: 1, workflow: "none", state: legacy, stateHash: workflowHash(legacy), sourceHash: workflowHash(config) };
|
|
1896
|
+
}
|
|
1897
|
+
const state = { id: task.id, state: task.state, stage, ...(this.isPii(task) ? { private: true } : { criteria: this.workflowView(task) ?? null }) };
|
|
1898
|
+
return { schema: 2, state, stateHash: workflowHash(state), sourceHash: workflowHash(config) };
|
|
1866
1899
|
}
|
|
1867
1900
|
async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
|
|
1868
1901
|
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
@@ -2338,7 +2371,7 @@ export class Tasks {
|
|
|
2338
2371
|
if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
|
|
2339
2372
|
this.contradict(rejected);
|
|
2340
2373
|
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
|
|
2341
|
-
if (rejected.rejections >= ESCALATE_AFTER) {
|
|
2374
|
+
if (rejected.rejections >= (this.d.escalateAfter?.() ?? ESCALATE_AFTER)) {
|
|
2342
2375
|
let moved: Task;
|
|
2343
2376
|
try {
|
|
2344
2377
|
moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
|
|
@@ -2442,7 +2475,7 @@ export class Tasks {
|
|
|
2442
2475
|
this.ready(task, preEffect);
|
|
2443
2476
|
const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
2444
2477
|
if (a.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot name ${peer} as reviewer: ${a.trace.filter(line => line.includes("reviewer") || line.includes("pii")).join("; ")}`);
|
|
2445
|
-
const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, `named reviewer ${peer}`, { reason: "manual" }, expectedFlowRevision);
|
|
2478
|
+
const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, this.assignmentNote(`named reviewer ${peer}`, a), { reason: "manual" }, expectedFlowRevision);
|
|
2446
2479
|
if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
|
|
2447
2480
|
this.withdrawRoleRequest(task, next, "reviewer");
|
|
2448
2481
|
}
|
|
@@ -2494,7 +2527,7 @@ export class Tasks {
|
|
|
2494
2527
|
const owner = task.owner === peer;
|
|
2495
2528
|
const weights = this.weights(task.class);
|
|
2496
2529
|
const pii = this.isPii(task);
|
|
2497
|
-
const candidates = owner ?
|
|
2530
|
+
const candidates = owner ? relayCandidates(routing.classes[task.class]?.peers ?? [], pii, { ...this.instanceRouting(), states: this.states() }) : [];
|
|
2498
2531
|
const a = assign(task, this.states(), routing, { ...weights, offRoster: [...(weights.offRoster ?? []), peer], exclude: [peer], excludeOwners: owner && this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], candidates, ...(owner ? { waitsFor: this.waitsFor(task) } : task.owner ? { notReviewer: task.owner } : {}) });
|
|
2499
2532
|
return { id: task.id, title: this.publicTitle(task), role: owner ? "owner" : "reviewer", to: (owner ? a.owner : a.reviewer) ?? null };
|
|
2500
2533
|
});
|
|
@@ -2515,7 +2548,7 @@ export class Tasks {
|
|
|
2515
2548
|
if (urgentOnly && !task.signals.includes("urgent")) continue;
|
|
2516
2549
|
if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
|
|
2517
2550
|
const pii = this.isPii(task);
|
|
2518
|
-
const candidates =
|
|
2551
|
+
const candidates = relayCandidates(routing.classes[task.class]?.peers ?? [], pii, { ...this.instanceRouting(), states: this.states() });
|
|
2519
2552
|
if (this.plannerPullRecord(task)?.pull && !assign(task, this.states(), routing, { candidates, exclude: [...this.excluded(task), peer], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).owner) continue;
|
|
2520
2553
|
// Budget keeps its original release/unassign behaviour for ordinary work. Roster changes the state in
|
|
2521
2554
|
// assignOwner's same owner write only after routing has found somebody to take it.
|
|
@@ -2543,7 +2576,7 @@ export class Tasks {
|
|
|
2543
2576
|
this.releaseNotice(`task ${this.publicTitle(task)}: its reviewer ${peer} is off roster and nobody else can review; the reviewer stays ${peer}; use ahub review ${task.id}`);
|
|
2544
2577
|
continue;
|
|
2545
2578
|
}
|
|
2546
|
-
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, why);
|
|
2579
|
+
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a));
|
|
2547
2580
|
moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
|
|
2548
2581
|
if (next.state === "in_review" && reviewer && reviewer !== USER) {
|
|
2549
2582
|
if (roster) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, `(${peer} was reviewing this and is off roster.)`));
|
|
@@ -2735,4 +2768,4 @@ export class Tasks {
|
|
|
2735
2768
|
}
|
|
2736
2769
|
}
|
|
2737
2770
|
|
|
2738
|
-
export { LOCAL };
|
|
2771
|
+
export { LOCAL } from "./routing.ts";
|
|
@@ -54,7 +54,7 @@ export class TextProjections {
|
|
|
54
54
|
private readonly warned = new Set<string>();
|
|
55
55
|
/** Inputs whose full-budget conversion failed: the same bytes under the same command are not converted again. */
|
|
56
56
|
private readonly failed = new Set<string>();
|
|
57
|
-
constructor(private readonly options: { cwd: string; stateDir: string; config: ProjectionConfig; deny?: string[]; readAllow?: string[]; log?: (line: string) => void }) {
|
|
57
|
+
constructor(private readonly options: { cwd: string; projectRoot?: string; stateDir: string; config: ProjectionConfig; deny?: string[]; readAllow?: string[]; log?: (line: string) => void }) {
|
|
58
58
|
// Rooted at an empty system directory, not the project. The project guarantee: project file data is denied, then the
|
|
59
59
|
// snapshot, the argv-named program/script files and the directory listings above an in-project script are granted, so
|
|
60
60
|
// no other project file is readable (system, toolchain, home and local.read_allow paths outside the project remain granted).
|
|
@@ -105,7 +105,9 @@ export class TextProjections {
|
|
|
105
105
|
try {
|
|
106
106
|
if (deadline !== undefined && (!Number.isFinite(deadline) || deadline <= performance.now())) throw new Error("projection deadline");
|
|
107
107
|
const rawHash = createHash("sha1").update(bytes).digest("hex"), rawBlob = createHash("sha1").update("blob " + bytes.length + "\0").update(bytes).digest("hex");
|
|
108
|
-
const inputHash = hash(bytes),
|
|
108
|
+
const inputHash = hash(bytes), commandHash = commandFingerprint(this.options.cwd, argv);
|
|
109
|
+
const configHash = this.options.projectRoot === undefined ? commandHash : hash(commandHash + "\nproject-root:" + realPath(this.options.projectRoot));
|
|
110
|
+
const key = hash(inputHash + "\n" + configHash + "\n" + ext);
|
|
109
111
|
const cacheDir = join(this.options.stateDir, "text-projections"), cache = join(cacheDir, key + ".json");
|
|
110
112
|
try {
|
|
111
113
|
const cached = boundedCache(cache);
|
|
@@ -121,7 +123,9 @@ export class TextProjections {
|
|
|
121
123
|
// A read_allow entry may enclose the project. Seatbelt lets a later rule win only for the operation it names, so the
|
|
122
124
|
// deny and the literal grants after it both name file-read-data. The converter runs in its scratch directory (getcwd
|
|
123
125
|
// needs that directory's data), with its program and script arguments resolved to absolute paths.
|
|
124
|
-
const root = realPath(this.options.cwd),
|
|
126
|
+
const root = realPath(this.options.cwd), protectedRoot = realPath(this.options.projectRoot ?? this.options.cwd);
|
|
127
|
+
if (root !== protectedRoot && !root.startsWith(protectedRoot + "/")) throw new Error("execution root outside protected project");
|
|
128
|
+
const projectDeny = "\n(deny file-read-data (subpath " + sbplString(protectedRoot) + "))";
|
|
125
129
|
const resolved = programFiles(this.options.cwd, argv);
|
|
126
130
|
// A module loader lists the directories above its script: grant those listings (not their files) up to the root.
|
|
127
131
|
const listings = new Set<string>();
|
package/src/hub/tooling.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
1
2
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import { accessSync,
|
|
3
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
|
-
import { delimiter,
|
|
5
|
+
import { delimiter, relative, resolve, isAbsolute } from "node:path";
|
|
5
6
|
import { realPath } from "./project.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -15,11 +16,6 @@ export const ADDONS = ["claude-mem", "ponytail", "ripwire", "open-code-review",
|
|
|
15
16
|
export type AddonName = (typeof ADDONS)[number];
|
|
16
17
|
export interface Addon { name: AddonName; version: string | null }
|
|
17
18
|
|
|
18
|
-
/** Plugin names (before the `@marketplace`) that stand for an add-on. */
|
|
19
|
-
// Maps, not objects: a plugin named `constructor` or `toString` must find nothing, not an inherited property.
|
|
20
|
-
const PLUGINS = new Map<string, AddonName>([["claude-mem", "claude-mem"], ["ponytail", "ponytail"], ["open-code-review", "open-code-review"]]);
|
|
21
|
-
/** Claude Code only: the Codex plugin is a Claude plugin named `codex` from the `openai-codex` marketplace. */
|
|
22
|
-
const CLAUDE_ONLY = new Map<string, AddonName>([["codex@openai-codex", "codex-plugin"]]);
|
|
23
19
|
/**
|
|
24
20
|
* Executables any peer with a shell can run, and how each is told apart from another program of the same name: the
|
|
25
21
|
* first one on PATH is asked for its version (bounded, no stdin), so Graphviz's `dot` is never resource admission.
|
|
@@ -32,60 +28,9 @@ const PROBE_MS = 2000, PROBE_BYTES = 4096;
|
|
|
32
28
|
/** `env` supplies CLAUDE_CONFIG_DIR and CODEX_HOME, the runtimes' own config roots (the hub's environment by default). */
|
|
33
29
|
export interface ToolingSources { home?: string; cwd?: string; path?: string; env?: Record<string, string | undefined> }
|
|
34
30
|
|
|
35
|
-
/** A regular file of at most 1 MiB, opened without blocking: a FIFO or device under a project's `.claude/` reads as absent. */
|
|
36
|
-
const text = (file: string): string | undefined => {
|
|
37
|
-
let fd: number | undefined;
|
|
38
|
-
try {
|
|
39
|
-
fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
40
|
-
const st = fstatSync(fd);
|
|
41
|
-
if (!st.isFile() || st.size > 1024 * 1024) return undefined;
|
|
42
|
-
return readFileSync(fd, "utf8");
|
|
43
|
-
} catch { return undefined; } finally { if (fd !== undefined) closeSync(fd); }
|
|
44
|
-
};
|
|
45
|
-
const json = (file: string): Record<string, unknown> | undefined => {
|
|
46
|
-
try { const v = JSON.parse(text(file) ?? ""); return v && typeof v === "object" ? v as Record<string, unknown> : undefined; } catch { return undefined; }
|
|
47
|
-
};
|
|
48
31
|
/** A version string as data, never anything longer or stranger. */
|
|
49
32
|
const version = (v: unknown): string | null => typeof v === "string" && /^[0-9A-Za-z.+_-]{1,40}$/.test(v) ? v : null;
|
|
50
33
|
|
|
51
|
-
function claudePlugins(root: string, cwd: string | undefined): Addon[] {
|
|
52
|
-
const enabled = new Map<string, boolean>();
|
|
53
|
-
for (const file of [join(root, "settings.json"), ...(cwd ? [join(cwd, ".claude", "settings.json"), join(cwd, ".claude", "settings.local.json")] : [])]) {
|
|
54
|
-
const map = json(file)?.enabledPlugins;
|
|
55
|
-
if (map && typeof map === "object") for (const [key, on] of Object.entries(map)) enabled.set(key, on === true);
|
|
56
|
-
}
|
|
57
|
-
const installed = (json(join(root, "plugins", "installed_plugins.json"))?.plugins ?? {}) as Record<string, { scope?: string; projectPath?: string; version?: unknown }[]>;
|
|
58
|
-
const out: Addon[] = [];
|
|
59
|
-
for (const [key, on] of enabled) {
|
|
60
|
-
if (!on) continue;
|
|
61
|
-
const name = CLAUDE_ONLY.get(key) ?? PLUGINS.get(key.split("@")[0]!);
|
|
62
|
-
const entries = Array.isArray(installed[key]) ? installed[key]! : [];
|
|
63
|
-
// An install for another project is not this one's.
|
|
64
|
-
const entry = entries.find((e) => e?.scope === "user") ?? entries.find((e) => e?.projectPath === cwd);
|
|
65
|
-
if (name && entry) out.push({ name, version: version(entry.version) });
|
|
66
|
-
}
|
|
67
|
-
return out;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function codexPlugins(root: string): Addon[] {
|
|
71
|
-
const config = text(join(root, "config.toml"));
|
|
72
|
-
if (config === undefined) return [];
|
|
73
|
-
const found = new Map<string, string>(), disabled = new Set<string>(); // plugin name -> marketplace
|
|
74
|
-
// `[plugins."name@market"]` with no `enabled = false` below it, and plugins whose hooks Codex was told to trust
|
|
75
|
-
// unless their plugin entry disables them: a trust entry outlives the plugin's own switch.
|
|
76
|
-
for (const m of config.matchAll(/^\[plugins\."([^"@]+)@([^"]+)"\]\s*\n((?:(?!\[)[^\n]*\n?)*)/gm)) if (!/^\s*enabled\s*=\s*false\b/m.test(m[3] ?? "")) found.set(m[1]!, m[2]!); else disabled.add(m[1]! + "@" + m[2]!);
|
|
77
|
-
for (const m of config.matchAll(/^\[hooks\.state\."([^"@]+)@([^":]+):/gm)) if (!disabled.has(m[1]! + "@" + m[2]!)) found.set(m[1]!, m[2]!);
|
|
78
|
-
const out: Addon[] = [];
|
|
79
|
-
for (const [plugin, market] of found) {
|
|
80
|
-
const name = PLUGINS.get(plugin);
|
|
81
|
-
if (!name) continue;
|
|
82
|
-
let versions: string[] = [];
|
|
83
|
-
try { versions = readdirSync(join(root, "plugins", "cache", market, plugin)); } catch { /* no cache: version unknown */ }
|
|
84
|
-
out.push({ name, version: versions.length === 1 ? version(versions[0]) : null });
|
|
85
|
-
}
|
|
86
|
-
return out;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
34
|
/** Never throws: a path that cannot be resolved (a project directory that went away) is compared as given. */
|
|
90
35
|
const real = (path: string): string => { try { return realPath(path); } catch { return resolve(path); } };
|
|
91
36
|
const within = (file: string, dir: string): boolean => { const r = relative(dir, file); return r === "" || (!r.startsWith("..") && !isAbsolute(r)); };
|
|
@@ -145,8 +90,7 @@ export async function detectTooling(peer: string, src: ToolingSources = {}): Pro
|
|
|
145
90
|
const found = new Map<AddonName, Addon>();
|
|
146
91
|
const add = (a: Addon) => { if (!found.has(a.name) || (found.get(a.name)!.version === null && a.version !== null)) found.set(a.name, a); };
|
|
147
92
|
const env = src.env ?? process.env;
|
|
148
|
-
|
|
149
|
-
if (peer === "codex") codexPlugins(env.CODEX_HOME?.trim() || join(home, ".codex")).forEach(add);
|
|
93
|
+
definitionOf(peer)?.hooks?.pluginInventory?.({ env, home, cwd: src.cwd }).forEach(add);
|
|
150
94
|
const path = src.path ?? process.env.PATH ?? "";
|
|
151
95
|
for (const [bin, spec] of Object.entries(BINARIES)) {
|
|
152
96
|
const file = onPath(bin, path, src.cwd ?? process.cwd());
|
package/src/hub/ui.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { PEER_PALETTE, peerPaletteCss } from "../ui/peer-palette.ts";
|
|
|
5
5
|
import { PEER_START_RETRY_MS } from "./start-mode.ts";
|
|
6
6
|
import { taskProgress } from "../ui/task-progress.ts";
|
|
7
7
|
import { officeModel } from "../ui/office-model.ts";
|
|
8
|
+
import { OFFICE_PEER_ORDER, ADAPTER_DISPLAY } from "../adapters/registry.ts";
|
|
8
9
|
import { needs } from "../ui/needs.ts";
|
|
9
10
|
import { createHash, randomBytes } from "node:crypto";
|
|
10
11
|
import { readFileSync } from "node:fs";
|
|
@@ -35,10 +36,11 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
35
36
|
// Inject the same pure public-board model used by the console before computing CSP hashes.
|
|
36
37
|
const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
|
|
37
38
|
.replace("/* BOARD_MODEL */", () => `const boardColumns = ${boardColumns.toString()}; const peerPalette = ${JSON.stringify(PEER_PALETTE)};`)
|
|
39
|
+
.replace("/* ADAPTER_DISPLAY */", () => `const adapterDisplay = ${JSON.stringify(ADAPTER_DISPLAY)};`)
|
|
38
40
|
.replace("/* PEER_PALETTE_CSS */", () => peerPaletteCss())
|
|
39
41
|
.replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`)
|
|
40
42
|
.replace("/* PEER_START_RETRY_MS */", () => `const PEER_START_RETRY_MS = ${PEER_START_RETRY_MS};`)
|
|
41
|
-
.replace("/* OFFICE_MODEL */", () => `const officeModel = ${officeModel.toString()};`)
|
|
43
|
+
.replace("/* OFFICE_MODEL */", () => `const officeModel = ((OFFICE_PEER_ORDER) => ${officeModel.toString()})(${JSON.stringify(OFFICE_PEER_ORDER)});`)
|
|
42
44
|
.replace("/* NEEDS_MODEL */", () => `const needs = ${needs.toString()};`);
|
|
43
45
|
const hashes = (tag: string) => [...html.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))]
|
|
44
46
|
.map((m) => `'sha256-${createHash("sha256").update(m[1]!).digest("base64")}'`).join(" ");
|