@staix/agent-hub 0.12.22 → 0.12.24
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 +117 -0
- package/README.md +22 -9
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +18 -6
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +6 -3
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +4 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +10 -3
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +873 -30
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +9 -4
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +124 -8
- package/docs/smoke.md +354 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +34 -16
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1108 -261
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +201 -20
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +54 -13
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +26 -6
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +131 -39
- package/src/cli/console.ts +73 -17
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +52 -21
- package/src/cli/identity.ts +19 -8
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +641 -193
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +404 -0
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +40 -6
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +1 -29
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +56 -10
- package/src/cli/upgrade.ts +23 -5
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +224 -16
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +58 -20
- package/src/hub/child-process.ts +11 -0
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +34 -17
- package/src/hub/config-trust.ts +20 -7
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +11 -1
- package/src/hub/daemon.ts +1218 -113
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +7 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +14 -2
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +28 -8
- package/src/hub/git.ts +38 -0
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +26 -6
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +33 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +6 -5
- package/src/hub/registry.ts +2 -1
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +132 -18
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +411 -0
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +92 -16
- package/src/hub/start-mode.ts +50 -0
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +1014 -111
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +40 -14
- package/src/hub/workflow.ts +71 -0
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +50 -10
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +86 -2
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/models/mlx.ts +2 -1
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/tool-receipts.ts +2 -1
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +983 -40
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
|
@@ -4,6 +4,7 @@ import { Database } from "bun:sqlite";
|
|
|
4
4
|
import type { BusSnapshot } from "./bus.ts";
|
|
5
5
|
import type { Envelope } from "./envelope.ts";
|
|
6
6
|
import { processLiveness } from "../pi/process-signature.ts";
|
|
7
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
7
8
|
|
|
8
9
|
export type JournalDeliveryState =
|
|
9
10
|
| "queued"
|
|
@@ -239,10 +240,10 @@ export class DeliveryJournal {
|
|
|
239
240
|
this.operationId = options.operationId;
|
|
240
241
|
mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
|
|
241
242
|
this.db = new Database(options.file, { create: true });
|
|
243
|
+
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
242
244
|
this.harden();
|
|
243
245
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
244
246
|
this.db.run("PRAGMA synchronous = FULL");
|
|
245
|
-
this.db.run("PRAGMA busy_timeout = 3000");
|
|
246
247
|
this.createSchema();
|
|
247
248
|
const meta = this.readMeta();
|
|
248
249
|
if (!meta)
|
|
@@ -657,6 +658,11 @@ export class DeliveryJournal {
|
|
|
657
658
|
) as Record<string, unknown>[];
|
|
658
659
|
return rows.map(parseDelivery);
|
|
659
660
|
}
|
|
661
|
+
unresolved(peer: string): JournalDelivery[] {
|
|
662
|
+
this.ensureOpen();
|
|
663
|
+
const rows = this.db.query("SELECT * FROM deliveries WHERE project_id = ? AND peer = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY created_at, id").all(this.projectId, peer) as Record<string, unknown>[];
|
|
664
|
+
return rows.map(parseDelivery);
|
|
665
|
+
}
|
|
660
666
|
resolve(
|
|
661
667
|
id: string,
|
|
662
668
|
revision: number,
|
package/src/hub/envelope.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface Envelope {
|
|
|
14
14
|
kind: Kind;
|
|
15
15
|
priority: Priority;
|
|
16
16
|
body: string; // agent conclusions only, never tool noise
|
|
17
|
-
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; supervision?: boolean; supervisionKey?: string };
|
|
17
|
+
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; backlog?: number[]; supervision?: boolean; supervisionKey?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
|
|
18
18
|
/** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
|
|
19
19
|
private?: boolean;
|
|
20
20
|
ts: number;
|
package/src/hub/events.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { TaskAttribution } from "./attribution.ts";
|
|
|
3
3
|
import type { SwitchTrace } from "../models/route/stage.ts";
|
|
4
4
|
import type { ScreenRecord } from "./inference.ts";
|
|
5
5
|
|
|
6
|
+
export interface RoleIdentity { id: string; layer: "shipped" | "project"; hash: string }
|
|
7
|
+
|
|
6
8
|
/** Bumped whenever a field changes meaning or goes away; new fields and new types do not bump it. */
|
|
7
9
|
export const EVENTS_SCHEMA = 1;
|
|
8
10
|
|
|
@@ -12,6 +14,9 @@ export const EVENTS_SCHEMA = 1;
|
|
|
12
14
|
*/
|
|
13
15
|
export type HubEvent =
|
|
14
16
|
| { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
|
|
17
|
+
/** A settings change (#269): the registry key, the value in force before and after (closed words, ids or booleans, never free text) and where it came from. */
|
|
18
|
+
| { type: "role"; peer?: string; task?: number; actor: string; stage: "prepared" | "assigned"; roles: { id: string; layer: "shipped" | "project"; hash: string }[] }
|
|
19
|
+
| { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal"; undo?: boolean }
|
|
15
20
|
| { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
|
|
16
21
|
| { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
|
|
17
22
|
| { type: "stale"; id: string; from: string; peer: string; task?: string }
|
|
@@ -25,11 +30,17 @@ export type HubEvent =
|
|
|
25
30
|
| { type: "progress"; peer: string; task: number; severity: number; spinning: number; exploring: number; production: number }
|
|
26
31
|
| { type: "stuck"; peer: string; task: number; category: "repetition" | "false_progress" | "drift" | "desperation" | "capability_gap"; streak: number; latched: boolean }
|
|
27
32
|
| { type: "capability"; peer: string; state: "verified" | "lost"; via?: string }
|
|
33
|
+
/** A hub run began: every peer is detached until its next state event (#248: attachments never span a crash). */
|
|
34
|
+
| { type: "hub_start"; instance: string }
|
|
35
|
+
/** #248: the add-ons a peer works with, names and versions from a closed list, at each attach. */
|
|
36
|
+
| { type: "tooling"; peer: string; addons: import("./tooling.ts").Addon[] }
|
|
37
|
+
/** #258: a task brief handed to its owner: how many hub-memory items (and fails among them) and claude-mem items, never their text. */
|
|
38
|
+
| { type: "memory_brief"; peer: string; task: number; native: number; fails: number; claudeMem: number }
|
|
28
39
|
| { type: "split"; task: number; where?: "routing" | "cohort"; verdict: "split" | "single" | "unknown"; single?: string; splitS?: number; singleS?: number; reason?: string; trace?: string[] }
|
|
29
40
|
| { type: "state"; peer: string; state: string }
|
|
30
41
|
| { type: "turn_start"; peer: string; turn: string }
|
|
31
42
|
| { type: "native_thread"; peer: string; thread: string; fresh: boolean }
|
|
32
|
-
| ({ type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number } & Partial<TaskAttribution>)
|
|
43
|
+
| ({ type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number; preparedRoles?: RoleIdentity[] | null } & Partial<TaskAttribution>)
|
|
33
44
|
| { type: "native_turn_end"; peer: string; id?: string }
|
|
34
45
|
| { type: "conduct"; peer: string; action: string; task?: number; target?: string }
|
|
35
46
|
| { type: "agent_cli"; peer: string; command: string; refused: boolean }
|
|
@@ -40,7 +51,8 @@ export type HubEvent =
|
|
|
40
51
|
| { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
|
|
41
52
|
| ({ type: "tokens"; peer: string; n: number } & Partial<TaskAttribution>)
|
|
42
53
|
| ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript"; id: string; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
|
|
43
|
-
| { type: "
|
|
54
|
+
| { type: "planning"; by: string; task?: number; release?: number; fields: string[]; revision: number }
|
|
55
|
+
| { 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 }
|
|
44
56
|
| { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
|
|
45
57
|
/** A PII screen verdict (issue #198): what was screened and the closed label, source and category, never the text. */
|
|
46
58
|
| ({ type: "pii_screen" } & ScreenRecord)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
2
3
|
|
|
3
4
|
export type ExecutionUnit = "model_calls" | "tool_calls" | "elapsed_ms" | "tokens";
|
|
4
5
|
export type ExecutionBudgetLimits = Partial<Record<ExecutionUnit, number>>;
|
|
@@ -42,6 +43,7 @@ export class ExecutionBudget {
|
|
|
42
43
|
private readonly db: Database;
|
|
43
44
|
constructor(path: string) {
|
|
44
45
|
this.db = new Database(path, { create: true });
|
|
46
|
+
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
45
47
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
46
48
|
this.db.run(`CREATE TABLE IF NOT EXISTS execution_budgets (
|
|
47
49
|
id TEXT PRIMARY KEY, kind TEXT NOT NULL, task_id INTEGER, peers TEXT NOT NULL,
|
package/src/hub/facts.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { repoGitArgs, repoGitEnv, type Repo } from "./snapshots.ts";
|
|
2
|
+
import { isHubWritePath } from "./permission-mode.ts";
|
|
1
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
4
|
import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
5
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
6
|
import { sanitize, type PeerId } from "./envelope.ts";
|
|
5
7
|
import { isDenied } from "../local/deny.ts";
|
|
8
|
+
import { hubGitArgv, hubGitEnv } from "./git.ts";
|
|
6
9
|
import { realPath } from "./project.ts";
|
|
7
10
|
|
|
8
11
|
/**
|
|
@@ -53,6 +56,8 @@ export interface FactScope {
|
|
|
53
56
|
export interface FactsOptions {
|
|
54
57
|
/** The project root, a real absolute path. */
|
|
55
58
|
root: string;
|
|
59
|
+
/** An explicitly discovered shadow repository; ordinary git retains its existing cwd semantics. */
|
|
60
|
+
repo?: Repo;
|
|
56
61
|
/** A private directory for the two sides of a diff. */
|
|
57
62
|
tmp: string;
|
|
58
63
|
/** Names this hub run's offers, so an acknowledgement for another run's offer matches nothing. */
|
|
@@ -64,9 +69,14 @@ export interface FactsOptions {
|
|
|
64
69
|
nameable: (s: string) => boolean;
|
|
65
70
|
/** `local.deny` entries: with the one denylist (`src/local/deny.ts`), paths facts never read or show. */
|
|
66
71
|
deny?: string[];
|
|
72
|
+
/** Configured private paths are never read or shown as automatic facts. */
|
|
73
|
+
privatePath?: (path: string) => boolean;
|
|
74
|
+
/** Bounded machine-local projection; absent retains raw/binary behavior. */
|
|
75
|
+
projection?: (file: string) => import("./text-projection.ts").ProjectedText | undefined;
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
interface Version {
|
|
79
|
+
projected?: true;
|
|
70
80
|
hash: string;
|
|
71
81
|
text?: string;
|
|
72
82
|
/** Git's blob id of the same bytes: a file equal to HEAD's is no change, whatever its stat data says. */
|
|
@@ -170,11 +180,15 @@ export class Facts {
|
|
|
170
180
|
this.root = root;
|
|
171
181
|
}
|
|
172
182
|
|
|
183
|
+
private gitArgs(args: string[]): string[] { return hubGitArgv(this.o.repo?.shadow ? repoGitArgs(this.o.repo, args) : args); }
|
|
184
|
+
private gitEnv(): NodeJS.ProcessEnv { return hubGitEnv({ ...(this.o.repo?.shadow ? repoGitEnv(this.o.repo) : process.env), GIT_OPTIONAL_LOCKS: "0" }); }
|
|
185
|
+
|
|
173
186
|
/**
|
|
174
187
|
* A project-relative path, or undefined for one outside the project. Resolved as a real path (symlinks and `..` are
|
|
175
188
|
* followed first), so neither a task's refs nor a tool's input can point the hub at a file outside the project.
|
|
176
189
|
*/
|
|
177
190
|
rel(path: string): string | undefined {
|
|
191
|
+
if (this.o.privatePath?.(path)) return undefined;
|
|
178
192
|
const abs = isAbsolute(path) ? path : resolve(this.root, path);
|
|
179
193
|
let real = abs;
|
|
180
194
|
try {
|
|
@@ -187,7 +201,11 @@ export class Facts {
|
|
|
187
201
|
}
|
|
188
202
|
}
|
|
189
203
|
const r = relative(this.root, real);
|
|
190
|
-
if (!r || r.startsWith("..") || isAbsolute(r)) return undefined;
|
|
204
|
+
if (!r || r.startsWith("..") || isAbsolute(r) || this.o.repo?.shadow && isHubWritePath(r)) return undefined;
|
|
205
|
+
if (this.o.repo?.shadow) {
|
|
206
|
+
const ignored = Bun.spawnSync(this.gitArgs(["check-ignore", "--no-index", "--quiet", "--", r]), { cwd: this.root, stdout: "pipe", stderr: "pipe", env: this.gitEnv(), timeout: 3000 });
|
|
207
|
+
if (ignored.exitCode !== 1) return undefined; // ignored or unavailable policy is not readable fact evidence
|
|
208
|
+
}
|
|
191
209
|
// Never git's own files, and never what the denylist keeps from every agent.
|
|
192
210
|
return /(^|\/)\.git(\/|$)/i.test(r) || isDenied(r, this.o.deny ?? []) ? undefined : r;
|
|
193
211
|
}
|
|
@@ -201,6 +219,8 @@ export class Facts {
|
|
|
201
219
|
*/
|
|
202
220
|
private load(file: string): Omit<Version, "seq"> {
|
|
203
221
|
if (this.rel(join(this.root, file)) !== file) return { hash: "missing" };
|
|
222
|
+
const projected = this.o.projection?.(join(this.root, file));
|
|
223
|
+
if (projected) return { hash: projected.rawHash, blob: projected.rawBlob, text: projected.text, projected: true };
|
|
204
224
|
let fd: number | undefined;
|
|
205
225
|
try {
|
|
206
226
|
fd = openSync(join(this.root, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
@@ -275,7 +295,7 @@ export class Facts {
|
|
|
275
295
|
// One path per line: a newline in a name would shift the answers, and git drops a final carriage return (another file).
|
|
276
296
|
files = files.filter((f) => !/[\r\n]/.test(f));
|
|
277
297
|
if (!files.length) return out;
|
|
278
|
-
const r = Bun.spawnSync(["
|
|
298
|
+
const r = Bun.spawnSync(this.gitArgs(["cat-file", "--batch-check=%(objectname) %(objecttype)"]), { cwd: this.root, stdin: new TextEncoder().encode(files.map((f) => `HEAD:./${f}`).join("\n") + "\n"), stdout: "pipe", stderr: "pipe", env: this.gitEnv() });
|
|
279
299
|
if (r.exitCode !== 0) return out;
|
|
280
300
|
r.stdout.toString().split("\n").forEach((line, i) => {
|
|
281
301
|
const [oid, type] = line.split(" ");
|
|
@@ -414,7 +434,7 @@ export class Facts {
|
|
|
414
434
|
if (!files) {
|
|
415
435
|
// Plumbing only, with optional locks off: an agent's own git commands must never meet a lock the hub holds.
|
|
416
436
|
const git = (...args: string[]) => {
|
|
417
|
-
const r = Bun.spawnSync([
|
|
437
|
+
const r = Bun.spawnSync(this.gitArgs([...args, "--", p]), { cwd: this.root, stdout: "pipe", stderr: "pipe", env: this.gitEnv() });
|
|
418
438
|
return r.exitCode === 0 ? r.stdout.toString().split("\0").filter(Boolean) : undefined;
|
|
419
439
|
};
|
|
420
440
|
// Against HEAD, so a staged change counts (a staged move by both names); a repository without a commit yet has
|
|
@@ -496,7 +516,7 @@ export class Facts {
|
|
|
496
516
|
this.touch(peer, file, pre.version);
|
|
497
517
|
if (["Edit", "MultiEdit", "Write"].includes(tool)) {
|
|
498
518
|
this.wrote(peer, file);
|
|
499
|
-
const expected = this.latest.get(file)?.hash === pre.version.hash ? applyEdit(pre.version.text, tool, input) : undefined;
|
|
519
|
+
const expected = !pre.version.projected && this.latest.get(file)?.hash === pre.version.hash ? applyEdit(pre.version.text, tool, input) : undefined;
|
|
500
520
|
const version = this.observe(file, expected === undefined ? undefined : { peer, expected: sha1(expected) });
|
|
501
521
|
const last = this.transitions.get(file)?.at(-1);
|
|
502
522
|
if (last?.seq === version.seq && last.by === peer && this.view(peer).get(file)?.hash === pre.version.hash) this.view(peer).set(file, version);
|
|
@@ -505,7 +525,7 @@ export class Facts {
|
|
|
505
525
|
if (tool === "Read") {
|
|
506
526
|
const version = this.observe(file);
|
|
507
527
|
// Only a whole read of an unchanged file says what the peer saw: no offset or limit, and nothing Read cuts.
|
|
508
|
-
if (version.hash === pre.version.hash && input.offset === undefined && input.limit === undefined && readWhole(pre.version.text)) this.view(peer).set(file, version);
|
|
528
|
+
if (!pre.version.projected && version.hash === pre.version.hash && input.offset === undefined && input.limit === undefined && readWhole(pre.version.text)) this.view(peer).set(file, version);
|
|
509
529
|
return this.observeAll();
|
|
510
530
|
}
|
|
511
531
|
}
|
|
@@ -533,7 +553,7 @@ export class Facts {
|
|
|
533
553
|
this.touch(peer, file, was);
|
|
534
554
|
this.wrote(peer, file);
|
|
535
555
|
const now = this.load(file);
|
|
536
|
-
const matches = was ? codexEffect(was.text, now, change, (a, b) => this.diff(a, b)) : false;
|
|
556
|
+
const matches = was && !was.projected && !now.projected ? codexEffect(was.text, now, change, (a, b) => this.diff(a, b)) : false;
|
|
537
557
|
const version = this.observe(file, matches ? { peer, expected: now.hash } : undefined);
|
|
538
558
|
const last = this.transitions.get(file)?.at(-1);
|
|
539
559
|
if (was && last?.seq === version.seq && last.by === peer && this.view(peer).get(file)?.hash === was.hash) this.view(peer).set(file, version);
|
|
@@ -693,7 +713,7 @@ export class Facts {
|
|
|
693
713
|
tree(paths: string[], windows: { peer: PeerId; since: number; until?: number }[] = []): string {
|
|
694
714
|
this.boundary();
|
|
695
715
|
const h = createHash("sha1");
|
|
696
|
-
const head = Bun.spawnSync(["
|
|
716
|
+
const head = Bun.spawnSync(this.gitArgs(["rev-parse", "-q", "--verify", "HEAD"]), { cwd: this.root, stdout: "pipe", stderr: "pipe", env: this.gitEnv() });
|
|
697
717
|
h.update(head.exitCode === 0 ? head.stdout.toString().trim() : "no-head").update("\0");
|
|
698
718
|
// The files the members wrote while at work in the cohort count too: a symbol-only overlap names no path, and a
|
|
699
719
|
// tool's write outside the named paths still moves the work the integration checked. What they only read never
|
|
@@ -780,7 +800,7 @@ export class Facts {
|
|
|
780
800
|
try {
|
|
781
801
|
writeFileSync(a, before, { mode: 0o600 });
|
|
782
802
|
writeFileSync(b, after, { mode: 0o600 });
|
|
783
|
-
const r = Bun.spawnSync(["
|
|
803
|
+
const r = Bun.spawnSync(hubGitArgv(["-c", "core.quotepath=off", "diff", "--no-index", "--no-color", "--no-ext-diff", "--no-textconv", "--unified=2", "--", a, b]), { stdout: "pipe", stderr: "pipe", env: hubGitEnv() });
|
|
784
804
|
const lines = r.stdout.toString().split("\n");
|
|
785
805
|
const start = lines.findIndex((l) => l.startsWith("@@"));
|
|
786
806
|
if (start === -1) return [];
|
package/src/hub/git.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { spawnSync, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one helper for every git call the hub makes outside the sandbox (#281): the repository's own configuration
|
|
5
|
+
* must not name a program the hub then runs with its own rights (defense in depth behind the seatbelt profile,
|
|
6
|
+
* which refuses a sandboxed command the writes that would plant one). Every call gets:
|
|
7
|
+
* - core.fsmonitor=false: the file-system monitor is an arbitrary command read from the repo config.
|
|
8
|
+
* - core.hooksPath=/dev/null: hooks come from a non-directory, so every hook lookup fails closed.
|
|
9
|
+
* - diff.external= (empty): no external diff driver.
|
|
10
|
+
* - core.pager=cat and --no-pager: no pager, whatever the config says (stdout is a pipe everywhere anyway).
|
|
11
|
+
* - credential.helper= (empty resets the list) and core.askpass= (empty fails closed), with GIT_TERMINAL_PROMPT=0:
|
|
12
|
+
* no hub command authenticates, so any prompt is a bug and must fail instead of running a configured helper.
|
|
13
|
+
* git has no global switch for filter drivers or textconv: both are per-driver config keys. Of the hub's commands
|
|
14
|
+
* only the snapshot's `add`/`restore` would run a filter driver, and only a content diff would run textconv — the
|
|
15
|
+
* hub's one content diff is `diff --no-index`, which DOES read the repository's config (verified: a planted
|
|
16
|
+
* diff.external runs for it), so that call passes `--no-ext-diff` and `--no-textconv` on top of the config here,
|
|
17
|
+
* and the driver command of a filter lives in the `.git/config` the seatbelt profile refuses to a sandboxed
|
|
18
|
+
* command. GIT_CONFIG_NOSYSTEM/global are left alone: those files are the user's own.
|
|
19
|
+
*/
|
|
20
|
+
const SAFE_CONFIG = ["core.fsmonitor=false", "core.hooksPath=/dev/null", "diff.external=", "core.pager=cat", "credential.helper=", "core.askpass="];
|
|
21
|
+
/** Hub memory sync (#258) pushes to the person's own remote: their global credential helper stays, the rest does not. */
|
|
22
|
+
const CREDENTIAL_KEYS = new Set(["credential.helper=", "core.askpass="]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `["git", ...safe options, ...args]`: use instead of a literal "git" argv anywhere under src/. `credentials` keeps the
|
|
26
|
+
* person's credential helper, for a repository the hub created itself (the memory store), never a project's.
|
|
27
|
+
*/
|
|
28
|
+
export const hubGitArgv = (args: string[], opts: { credentials?: boolean } = {}): string[] =>
|
|
29
|
+
["git", "--no-pager", ...SAFE_CONFIG.filter((c) => !opts.credentials || !CREDENTIAL_KEYS.has(c)).flatMap((c) => ["-c", c]), ...args];
|
|
30
|
+
|
|
31
|
+
/** The caller's env plus the no-prompt switch; existing keys (childEnv, GIT_OPTIONAL_LOCKS=0) pass through. */
|
|
32
|
+
export const hubGitEnv = (env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => ({ ...env, GIT_TERMINAL_PROMPT: "0" });
|
|
33
|
+
|
|
34
|
+
/** spawnSync form of the same call: the env option is merged with hubGitEnv, everything else passes through. */
|
|
35
|
+
export function hubGitSync(args: string[], options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<string> {
|
|
36
|
+
const argv = hubGitArgv(args);
|
|
37
|
+
return spawnSync(argv[0]!, argv.slice(1), { ...options, env: hubGitEnv(options.env ?? process.env) });
|
|
38
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { sdlcGh, type SdlcGh } from "./sdlc.ts";
|
|
2
|
+
export interface GitHubSource { provider: "github"; repo: string; issue: number; url: string; }
|
|
3
|
+
export interface GitHubImportIssue { source: GitHubSource; title: string; detail: string; }
|
|
4
|
+
export interface GitHubImportSelection { repo?: string; label: string; milestone?: string; }
|
|
5
|
+
const REPO=/^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9_.-]{1,100}$/;
|
|
6
|
+
const selectionText=(value:unknown):value is string=>typeof value==="string"&&value.trim()===value&&value.length>0&&value.length<=100&&!value.startsWith("-")&&!/\p{Cc}/u.test(value);
|
|
7
|
+
/** Read-only issue intake; the existing reader bounds process time, stdout and diagnostics. */
|
|
8
|
+
export async function readGitHubImport(input: GitHubImportSelection, run: SdlcGh=sdlcGh): Promise<{ issues: GitHubImportIssue[]; partial: boolean; }> {
|
|
9
|
+
if(!selectionText(input.label)||input.label.includes(",")||input.label.includes('"')||input.milestone!==undefined&&!selectionText(input.milestone)||input.repo!==undefined&&!REPO.test(input.repo))throw new Error("invalid GitHub import selection");
|
|
10
|
+
const {label,milestone}=input;
|
|
11
|
+
const deadline=performance.now()+10_000;
|
|
12
|
+
const read=async(args:string[])=>{const left=deadline-performance.now();if(left<=0)throw new Error("GitHub import read deadline expired");const result=await run(args,Math.min(left,10_000),undefined,2*1024*1024);if(performance.now()>deadline)throw new Error("GitHub import read deadline expired");if(result.code!==0||new TextEncoder().encode(result.stdout).length>2*1024*1024)throw new Error("GitHub import read failed or exceeded its output bound");return JSON.parse(result.stdout) as unknown;};
|
|
13
|
+
let repo=input.repo;
|
|
14
|
+
if(repo===undefined){const raw=await read(["repo","view","--json","nameWithOwner"]);if(!raw||typeof raw!=="object"||typeof (raw as Record<string,unknown>).nameWithOwner!=="string")throw new Error("GitHub repository identity unavailable");repo=(raw as {nameWithOwner:string}).nameWithOwner;}
|
|
15
|
+
if(!REPO.test(repo))throw new Error("invalid GitHub repository identity");
|
|
16
|
+
// ponytail: one bounded page of32 issues; add paginated reads under the shared deadline for larger intake.
|
|
17
|
+
const args=["issue","list","--repo",repo,"--state","open","--label",label,...(milestone===undefined?[]:["--milestone",milestone]),"--limit","33","--json","number,title,body,url,state"];
|
|
18
|
+
const raw=await read(args);if(!Array.isArray(raw)||raw.length>33)throw new Error("invalid GitHub issue page");
|
|
19
|
+
const seen=new Set<number>();
|
|
20
|
+
const issues=raw.map(row=>{
|
|
21
|
+
if(!row||typeof row!=="object"||!Number.isSafeInteger(row.number)||row.number<=0||seen.has(row.number)||row.state!=="OPEN"||typeof row.title!=="string"||!row.title.trim()||row.title.length>300||typeof row.body!=="string"||row.body.length>8000||typeof row.url!=="string")throw new Error("invalid GitHub issue data");
|
|
22
|
+
seen.add(row.number);
|
|
23
|
+
const expected=`https://github.com/${repo}/issues/${row.number}`;
|
|
24
|
+
if(row.url.toLowerCase()!==expected.toLowerCase())throw new Error("GitHub issue identity mismatch");
|
|
25
|
+
return {source:{provider:"github" as const,repo:repo!.toLowerCase(),issue:row.number,url:expected},title:row.title,detail:row.body};
|
|
26
|
+
});
|
|
27
|
+
return {issues:issues.slice(0,32),partial:issues.length>32};
|
|
28
|
+
}
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { SDLC_STAGES } from "./sdlc.ts";
|
|
2
|
+
import { projectProfile, profileData, PROFILE_CLASS_NAMES, type ProfileName, type ProfileSelection } from "./profiles.ts";
|
|
3
|
+
import { catalogSection, type CatalogRole } from "./roles.ts";
|
|
4
|
+
import { SUPPORTED_PEERS, defaultPeerRoles } from "./peer-catalog.ts";
|
|
1
5
|
// The hub's task tools, defined once: the MCP server (Claude plugin, Kimi, Codex) and the local worker both read this.
|
|
2
6
|
import { NOTE_KINDS } from "./envelope.ts";
|
|
3
7
|
|
|
@@ -22,6 +26,9 @@ const plan = {
|
|
|
22
26
|
additionalProperties: false,
|
|
23
27
|
} as const;
|
|
24
28
|
|
|
29
|
+
// Accept may target a task from an older project profile; Tasks normalizes by the stored task profile.
|
|
30
|
+
const acceptPlan = { ...plan, properties: { ...plan.properties, sections: list("document heading anchors, such as report.md#methods"), sources: list("citation ids, DOI or source URL ids"), claims: list("short claim ids to verify") } } as const;
|
|
31
|
+
|
|
25
32
|
export interface HubTool {
|
|
26
33
|
name: string;
|
|
27
34
|
description: string;
|
|
@@ -35,14 +42,22 @@ const tool = (name: string, description: string, properties: Record<string, unkn
|
|
|
35
42
|
});
|
|
36
43
|
|
|
37
44
|
export const TASK_TOOLS: HubTool[] = [
|
|
38
|
-
tool("
|
|
39
|
-
tool("
|
|
45
|
+
tool("hub_backlog_list", "Read public backlog cards with planning revisions. Configured planners only; PII remains a stub.", {}),
|
|
46
|
+
tool("hub_backlog_plan", "Plan an unpinned backlog item outside frozen releases with the revision you read and a bounded reason. A person's rank is retained. Actual dates cannot be set.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", maxLength: 1000 }, fields: { type: "object", properties: { rank: { type: ["integer", "null"], minimum: 0 }, priority: { type: ["integer", "null"], minimum: 0, maximum: 3 }, release: { type: ["integer", "null"], minimum: 1 }, plannedStart: { type: ["integer", "null"], minimum: 1 }, due: { type: ["integer", "null"], minimum: 1 }, deps: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, reserved: { type: ["string", "null"], minLength: 1, maxLength: 32 } }, additionalProperties: false } }, ["id", "revision", "reason", "fields"]),
|
|
47
|
+
tool("hub_backlog_pull", "Reserve a WIP slot and pull ready unpinned backlog work into To Do at its current revision. Preserves person order/reservations and frozen scope; pending unowned work can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "reason"]),
|
|
48
|
+
tool("hub_backlog_split", "Split unstarted backlog work into two to eight staged parts. The original card retains integration/acceptance and depends on the children. Use after for existing prerequisite ids and afterParts for earlier zero-based part indexes. Latest unused unchanged children can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 }, parts: { type: "array", minItems: 2, maxItems: 8, items: { type: "object", properties: { title: { type: "string", minLength: 1, maxLength: 300 }, detail: { type: "string", maxLength: 8000 }, class: str, refs, plan, after: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, afterParts: { type: "array", maxItems: 8, uniqueItems: true, items: { type: "integer", minimum: 0 } } }, required: ["title"], additionalProperties: false } } }, ["id", "revision", "reason", "parts"]),
|
|
49
|
+
tool("hub_backlog_undo", "Undo your latest planning change at its current revision. Refuses intervening changes and another actor's edit.", { id, revision: { type: "integer", minimum: 1 } }, ["id", "revision"]),
|
|
50
|
+
tool("hub_release_list", "Read public release scope and planned/actual dates for phased planning. Configured planners only.", {}),
|
|
51
|
+
|
|
52
|
+
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, role: { type: "string", description: "catalog role id for this task class; guidance only, no routing or authority change" }, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it. With after, the owner is reserved: offered the task first once it is ready" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, backlog: { type: "boolean", description: "stage unoffered work; owner is only a reservation, even when naming yourself" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" }, goal: { type: "string", description: "id of this project's long-term task (ahub goal list) the work is part of" } }, ["title"]),
|
|
53
|
+
tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it.", { id, plan: acceptPlan }, ["id"]),
|
|
40
54
|
tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
|
|
41
55
|
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
|
|
56
|
+
tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
|
|
42
57
|
tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
|
|
43
|
-
tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
|
|
58
|
+
tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
|
|
44
59
|
tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
|
|
45
|
-
tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id }, ["text"]),
|
|
60
|
+
tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (the hub's memory and claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter. A note on an open task is short-term until the task is approved, when its decisions, contracts and fails become long-term; horizon long keeps it now, scope shared lets the person's other projects find it.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id, horizon: { type: "string", enum: ["short", "long"] }, scope: { type: "string", enum: ["project", "shared"] } }, ["text"]),
|
|
46
61
|
];
|
|
47
62
|
|
|
48
63
|
export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
|
|
@@ -51,9 +66,9 @@ export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
|
|
|
51
66
|
export const CONDUCTOR_TOOLS: HubTool[] = [
|
|
52
67
|
tool("hub_status", "Inspect public team state, holds, quota windows, task counts and pending approval peer/tool/age. Only the configured conductor may use this tool.", {}),
|
|
53
68
|
tool("hub_task_show", "Read a task's public view and history, its done summary and check result included. PII tasks remain stubs. Only the conductor and the task's current owner and reviewer may use this tool.", { id }, ["id"]),
|
|
54
|
-
tool("hub_task_assign", "
|
|
69
|
+
tool("hub_task_assign", "Name an owner (default role) or reviewer. Only the conductor may name a reviewer through this tool. A proposer may redirect an unaccepted proposed task's owner; a task that still waits reserves that owner. Pull-only peers receive work when named or when they claim it. With an explicit capabilities list, the conductor needs assign capability for either role, the proposer only to give ownership to another peer.", { id, peer: str, role: { type: "string", enum: ["owner", "reviewer"] } }, ["id", "peer"]),
|
|
55
70
|
tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
|
|
56
|
-
tool("hub_peer_start", "Start local, kimi or headless
|
|
71
|
+
tool("hub_peer_start", "Start local, kimi, qwen, jcode, pi, claude or codex in the start mode a person set for it; you cannot choose the mode. A headless peer starts at once. A TUI peer (claude, and pi and codex by default) opens in a terminal with a fixed command; when the hub cannot open a terminal the call fails with the command for the person to run.", { peer: { type: "string", enum: ["local", "kimi", "pi", ...SUPPORTED_PEERS.filter(peer => !["local", "kimi", "pi"].includes(peer))] } }, ["peer"]),
|
|
57
72
|
tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str }, ["peer"]),
|
|
58
73
|
tool("hub_peer_release", "Release only the conductor hold you placed. Never lifts a person's hold or a budget pause.", { peer: str }, ["peer"]),
|
|
59
74
|
];
|
|
@@ -68,10 +83,34 @@ export const ROLE_TEXT: Record<string, string> = {
|
|
|
68
83
|
reviewer: "reviewer: when asked to review, read the change itself, then hub_review with approved or changes_requested and a note that says what to fix.",
|
|
69
84
|
};
|
|
70
85
|
|
|
71
|
-
export const DEFAULT_ROLES: Record<string, string[]> =
|
|
86
|
+
export const DEFAULT_ROLES: Record<string, string[]> = defaultPeerRoles(true);
|
|
72
87
|
|
|
73
|
-
export function roleContract(peer: string, roles: Record<string, string[]> = DEFAULT_ROLES): string {
|
|
74
|
-
const
|
|
88
|
+
export function roleContract(peer: string, roles: Record<string, string[]> = DEFAULT_ROLES, catalog: CatalogRole[] = [], profile: ProfileSelection = "dev"): string {
|
|
89
|
+
const data = profileData(profile);
|
|
90
|
+
const mine = (roles[peer] ?? []).map((r) => data.role_text[r] ?? ROLE_TEXT[r]).filter(Boolean);
|
|
75
91
|
if (!mine.length) return "";
|
|
76
|
-
return [`Your roles in this project ("${peer}"):`, ...mine.map((t) => `- ${t}`), "The task board (hub_task_list) is the shared record of who does what."].join("\n");
|
|
92
|
+
return [`Your roles in this project ("${peer}"):`, ...mine.map((t) => `- ${t}`), ...(data !== projectProfile("dev") ? [`Project profile ${data.name}: classes ${Object.entries(data.classes).map(([name, base]) => `${name}=${base}`).join(", ")}. Profile guidance grants no authority.`] : []), ...(catalog.length ? [catalogSection(catalog)] : []), "The task board (hub_task_list) is the shared record of who does what."].join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Profile schemas are data over the existing tools; dev retains the exact existing definitions. */
|
|
96
|
+
export function profileTaskTools(profile: ProfileSelection = "dev", stages?: readonly string[]): HubTool[] {
|
|
97
|
+
const customStages = stages && (stages.length !== SDLC_STAGES.length || stages.some((s, i) => s !== SDLC_STAGES[i]));
|
|
98
|
+
const customProfile = !(profile === "dev" || profileData(profile) === projectProfile("dev"));
|
|
99
|
+
if (!customStages && !customProfile) return TASK_TOOLS;
|
|
100
|
+
const data = profileData(profile);
|
|
101
|
+
return TASK_TOOLS.map(tool => {
|
|
102
|
+
const properties = { ...tool.inputSchema.properties };
|
|
103
|
+
if (customStages && tool.name === "hub_sdlc_record") {
|
|
104
|
+
const receipt = properties.receipt as { type: string; properties: Record<string, unknown>; additionalProperties: boolean };
|
|
105
|
+
properties.receipt = { ...receipt, properties: { ...receipt.properties, stage: { type: "string", enum: [...(stages ?? SDLC_STAGES)] } } };
|
|
106
|
+
}
|
|
107
|
+
if (customProfile && tool.name === "hub_task_propose") properties.class = { type: "string", enum: [...new Set([...Object.keys(data.classes), ...PROFILE_CLASS_NAMES.filter(v => projectProfile("dev").classes[v])])] };
|
|
108
|
+
if (customProfile && properties.plan && tool.name !== "hub_task_accept") properties.plan = { ...plan, properties: Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) };
|
|
109
|
+
if (customProfile && tool.name === "hub_backlog_split") {
|
|
110
|
+
const parts = properties.parts as { items: { properties: Record<string, unknown> } };
|
|
111
|
+
const selectedPlan = { ...plan, properties: Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) };
|
|
112
|
+
properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
|
|
113
|
+
}
|
|
114
|
+
return { ...tool, ...(customProfile && tool.name === "hub_task_propose" ? { description: tool.description + ` Selected profile ${data.name}: ${Object.keys(data.classes).join(", ")}. Routing uses their existing base classes.` } : {}), inputSchema: { ...tool.inputSchema, properties } };
|
|
115
|
+
});
|
|
77
116
|
}
|
package/src/hub/inference.ts
CHANGED
|
@@ -168,7 +168,7 @@ export interface ScreenRecord {
|
|
|
168
168
|
task?: number;
|
|
169
169
|
item: ScreenItem;
|
|
170
170
|
label: "pii" | "clear";
|
|
171
|
-
source: "regex" | "screen" | "unknown";
|
|
171
|
+
source: "regex" | "screen" | "unknown" | "inherited";
|
|
172
172
|
category?: PiiCategory;
|
|
173
173
|
miss?: ScreenMiss;
|
|
174
174
|
ms?: number;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ControlClient } from "./control-client.ts";
|
|
2
|
+
import { SDLC_STAGES } from "./sdlc.ts";
|
|
3
|
+
/** The daemon owns the active definition. Unavailable reads never invent a project-file stage set. */
|
|
4
|
+
export async function activeLifecycleStages(stateDir: string, peer: string, projectRoot: string, active?: ControlClient): Promise<string[] | undefined> {
|
|
5
|
+
let client: ControlClient | undefined;
|
|
6
|
+
try {
|
|
7
|
+
client = active ?? await ControlClient.connect(stateDir, { role: "tools", peer, projectRoot }, 1000);
|
|
8
|
+
const reply = await client.request({ t: "status" }, 1000);
|
|
9
|
+
if (reply.t !== "status" || !reply.status || typeof reply.status !== "object" || Array.isArray(reply.status)) return undefined;
|
|
10
|
+
// A live older daemon has the fixed seven-stage contract and no optional metadata field.
|
|
11
|
+
if (!Object.hasOwn(reply.status, "lifecycleStages")) return [...SDLC_STAGES];
|
|
12
|
+
const stages: unknown = reply.status.lifecycleStages;
|
|
13
|
+
if (!Array.isArray(stages) || !stages.length || stages.length > 32 || stages.some(s => typeof s !== "string" || !/^[a-z][a-z0-9_-]{0,47}$/.test(s)) || new Set(stages).size !== stages.length) return undefined;
|
|
14
|
+
return [...stages] as string[];
|
|
15
|
+
} catch { return undefined; }
|
|
16
|
+
finally { if (client !== active) client?.close(); }
|
|
17
|
+
}
|
package/src/hub/lifecycle.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isDatabaseBusyReply, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
1
2
|
import { spawn } from "node:child_process";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -5,19 +6,21 @@ import { join } from "node:path";
|
|
|
5
6
|
import { createServer, type Server } from "node:net";
|
|
6
7
|
import { ControlClient, PROTOCOL, readControl } from "./control-client.ts";
|
|
7
8
|
import { startDaemon } from "./daemon.ts";
|
|
9
|
+
import { terminalOpener } from "./terminal-open.ts";
|
|
8
10
|
import { Registry, type Project } from "./registry.ts";
|
|
9
11
|
import { CODEX_APP, CODEX_PROXY, CONTROL, SWITCHYARD } from "./ports.ts";
|
|
10
12
|
import { assertLifecycleAvailable, recoveryLock } from "./recovery-store.ts";
|
|
11
13
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
12
14
|
|
|
13
15
|
export type ProjectInspection = {
|
|
16
|
+
failure?: { code: "database_busy" };
|
|
14
17
|
state: "running" | "stopped" | "stopping" | "unavailable" | "incompatible" | "missing" | "starting";
|
|
15
18
|
status?: any;
|
|
16
19
|
error?: string;
|
|
17
20
|
};
|
|
18
21
|
|
|
19
22
|
/** A port or PID alone is never evidence that the selected project's hub is alive. */
|
|
20
|
-
export async function inspectProject(project: Project): Promise<ProjectInspection> {
|
|
23
|
+
export async function inspectProject(project: Project, timeoutMs = 3000): Promise<ProjectInspection> {
|
|
21
24
|
if (!existsSync(project.root)) return { state: "missing", error: "project directory is missing" };
|
|
22
25
|
const control = readControl(project.stateDir);
|
|
23
26
|
if (control) {
|
|
@@ -32,8 +35,9 @@ export async function inspectProject(project: Project): Promise<ProjectInspectio
|
|
|
32
35
|
try {
|
|
33
36
|
// Older manifests omit identity. Authenticate first, then require the full current contract.
|
|
34
37
|
client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root,
|
|
35
|
-
...(control.projectId ? { projectId: project.id } : {}), ...(control.instanceId ? { instanceId: control.instanceId } : {}) });
|
|
36
|
-
const response = await client.request({ t: "status" },
|
|
38
|
+
...(control.projectId ? { projectId: project.id } : {}), ...(control.instanceId ? { instanceId: control.instanceId } : {}) }, timeoutMs);
|
|
39
|
+
const response = await client.request({ t: "status" }, timeoutMs);
|
|
40
|
+
if (isDatabaseBusyReply(response)) return { state: "unavailable", error: new RecoveryDatabaseBusy().message, failure: { code: "database_busy" } };
|
|
37
41
|
if (!response.status || response.status.projectId !== project.id || !response.status.instanceId || response.status.cwd !== project.root) {
|
|
38
42
|
return { state: "unavailable", error: "authenticated hub identity does not match registration" };
|
|
39
43
|
}
|
|
@@ -112,7 +116,9 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
|
|
|
112
116
|
try {
|
|
113
117
|
daemon = await startDaemon({ cwd: project.root, stateDir: project.stateDir, projectId: project.id, instanceId,
|
|
114
118
|
controlPort: base + CONTROL, codexAppPort: base + CODEX_APP, codexProxyPort: base + CODEX_PROXY,
|
|
115
|
-
switchyardPort: base + SWITCHYARD, unattended, onShutdownStart
|
|
119
|
+
switchyardPort: base + SWITCHYARD, unattended, onShutdownStart,
|
|
120
|
+
// #269: the real terminal providers belong to the running product; a daemon built without them opens nothing.
|
|
121
|
+
terminal: (template) => terminalOpener({ template, cwd: project.root, stateDir: project.stateDir }) });
|
|
116
122
|
break;
|
|
117
123
|
} catch (error) {
|
|
118
124
|
if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Keep rejected owned listeners bound until a replacement cannot reuse their ports. */
|
|
2
|
+
export function listenWithoutPortCollision<T extends { port?: number; stop(closeActiveConnections?: boolean): void }>(listen: () => T, reservedPorts: readonly number[]): T {
|
|
3
|
+
const reserved = new Set(reservedPorts.filter(port => Number.isSafeInteger(port) && port > 0));
|
|
4
|
+
const temporary: T[] = [];
|
|
5
|
+
try {
|
|
6
|
+
for (let attempt = 0; attempt <= reserved.size; attempt++) {
|
|
7
|
+
const candidate = listen();
|
|
8
|
+
if (!reserved.has(candidate.port ?? -1)) return candidate;
|
|
9
|
+
temporary.push(candidate);
|
|
10
|
+
}
|
|
11
|
+
throw new Error("automatic control listener could not avoid requested peer ports");
|
|
12
|
+
} finally {
|
|
13
|
+
for (const listener of temporary) listener.stop(true);
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/hub/manager.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS } from "./planning.ts";
|
|
2
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
1
3
|
import { Database } from "bun:sqlite";
|
|
2
4
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
5
|
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -9,15 +11,19 @@ import { hubHome } from "./project.ts";
|
|
|
9
11
|
import { startDashboard } from "./ui.ts";
|
|
10
12
|
import { projectChain } from "../memory/recall.ts";
|
|
11
13
|
import type { ProjectInspection } from "./lifecycle.ts";
|
|
14
|
+
import { MAX_COMMAND_MS } from "../cli/terminal-recovery.ts";
|
|
12
15
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
13
16
|
|
|
17
|
+
const MANAGER_OWNER_DB_BUSY_TIMEOUT_MS = 5000;
|
|
14
18
|
export type ProjectRecord = Project;
|
|
15
19
|
export type Lifecycle = {
|
|
16
20
|
inspectProject(project: Project): Promise<ProjectInspection>;
|
|
17
21
|
startProject(project: Project, options?: { unattended?: boolean; env?: NodeJS.ProcessEnv }): Promise<any>;
|
|
18
22
|
stopProject(project: Project, expectedInstance?: string): Promise<void>;
|
|
19
23
|
};
|
|
20
|
-
export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number
|
|
24
|
+
export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number;
|
|
25
|
+
/** Test clock seam: maps nominal forwarding deadlines, never comes from dashboard input. */
|
|
26
|
+
forwardTimeoutMs?: (action: string, nominalMs: number) => number };
|
|
21
27
|
type ManagerHandle = { stop(): Promise<void>; stopped: Promise<void> };
|
|
22
28
|
type Manifest = { port: number; protocol: number; instanceId: string; pid: number; pidSignature?: string };
|
|
23
29
|
const active = new Map<string, { handle: ManagerHandle; issue(): string }>();
|
|
@@ -45,7 +51,7 @@ async function bounded<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
|
45
51
|
function claimOwner(path: string, instanceId: string, signature: string | undefined): Database {
|
|
46
52
|
const db = new Database(path, { create: true });
|
|
47
53
|
try {
|
|
48
|
-
db.run(
|
|
54
|
+
db.run(`PRAGMA busy_timeout = ${MANAGER_OWNER_DB_BUSY_TIMEOUT_MS}`);
|
|
49
55
|
db.run("CREATE TABLE IF NOT EXISTS owner (slot INTEGER PRIMARY KEY CHECK (slot = 1), instance_id TEXT NOT NULL, pid INTEGER NOT NULL, signature TEXT)");
|
|
50
56
|
if (!(db.query("PRAGMA table_info(owner)").all() as { name: string }[]).some((c) => c.name === "signature")) {
|
|
51
57
|
try { db.run("ALTER TABLE owner ADD COLUMN signature TEXT"); }
|
|
@@ -148,11 +154,25 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
|
|
|
148
154
|
await bounded(lifecycle.stopProject(project, input.instanceId), 20_000);
|
|
149
155
|
return { ok: true };
|
|
150
156
|
}
|
|
151
|
-
|
|
157
|
+
// The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
|
|
158
|
+
if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
|
|
152
159
|
client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
160
|
+
const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
|
|
161
|
+
let nominalMs = 10_000;
|
|
162
|
+
if (input.action === "start_peer") nominalMs = MAX_COMMAND_MS + 5_000;
|
|
163
|
+
else if (input.action === "backlog_split") nominalMs = BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000;
|
|
164
|
+
else if (deliveryRead) nominalMs = 30_000;
|
|
165
|
+
const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
|
|
166
|
+
const result = await client.request(input.action === "snapshot"
|
|
167
|
+
? { t: "ui_snapshot", after: input.after ?? 0, ...(input.memory !== undefined ? { memory: input.memory } : {}) }
|
|
168
|
+
: { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
|
|
169
|
+
// ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
|
|
170
|
+
if (["start_peer", "backlog_split"].includes(String(input.action)) && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
|
|
171
|
+
const text = input.action === "backlog_split" ? "Split result is unconfirmed. Reload the task before retrying; the hub may still commit its children." : "Start result is unconfirmed. Check the Peers panel before retrying; the hub may still be starting the peer.";
|
|
172
|
+
return { ok: false, unconfirmed: true, text, error: text };
|
|
173
|
+
}
|
|
174
|
+
if (deliveryRead) { const { rid: _transportId, ...report } = result; return report; }
|
|
175
|
+
return result;
|
|
156
176
|
} catch (error) { return { ok: false, error: safeError(error) }; }
|
|
157
177
|
finally { client?.close(); }
|
|
158
178
|
};
|