@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
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { SUPPORTED_PEERS } from "./peer-catalog.ts";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { basename, dirname, join } from "node:path";
|
|
6
|
+
import { CLASSES, type TaskClass } from "./board.ts";
|
|
7
|
+
import { configTracked } from "./config-trust.ts";
|
|
8
|
+
import { PERMISSION_MODES, permissionBoundary } from "./permission-mode.ts";
|
|
9
|
+
import { START_MODE_PEERS, START_MODES } from "./start-mode.ts";
|
|
10
|
+
import { OVERLAY_FILE, overlayToml, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "./routing.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* #269: the settings a person may change from the dashboard or `ahub settings`. A key that is not in SETTINGS cannot be
|
|
14
|
+
* written by either. Writes go only to the machine-local files (`.agenthub/config.local.json`,
|
|
15
|
+
* `.agenthub/routing.local.toml`), never to a tracked one.
|
|
16
|
+
*/
|
|
17
|
+
export const SETTING_PEERS = SUPPORTED_PEERS;
|
|
18
|
+
/** `null` removes the machine-local value, so the shared file's or the default applies again. */
|
|
19
|
+
export type SettingValue = string | boolean | number | string[] | null;
|
|
20
|
+
export interface SettingDef {
|
|
21
|
+
key: string;
|
|
22
|
+
group: "Permissions" | "Start" | "Routing" | "Switches" | "Roster" | "Roles";
|
|
23
|
+
label: string;
|
|
24
|
+
type: "enum" | "boolean" | "peers" | "roles" | "integer";
|
|
25
|
+
min?: number; max?: number;
|
|
26
|
+
/** The closed values of an enum; a route setting takes the project's own route ids instead. */
|
|
27
|
+
values?: readonly string[];
|
|
28
|
+
/** `runtime` lives in the running hub only; the others are the machine-local file that holds the value. */
|
|
29
|
+
store: "runtime" | "config" | "routing";
|
|
30
|
+
/** `peer start`: stored, and taken up by the running hub at that peer's next start. */
|
|
31
|
+
applies: "live" | "peer start" | "hub start";
|
|
32
|
+
/** `raises`: only a settings session or a person's terminal may set it, except to `floor`. */
|
|
33
|
+
risk: "safe" | "raises";
|
|
34
|
+
floor?: string | boolean | number;
|
|
35
|
+
/** Where the value sits inside its file (or, for `runtime`, the peer). */
|
|
36
|
+
path: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PEER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
40
|
+
const PEERS_MAX = 8;
|
|
41
|
+
const perClass = (name: TaskClass): SettingDef[] => [
|
|
42
|
+
{ key: `routing.classes.${name}.peers`, group: "Routing", label: `${name}: owner preference order`, type: "peers", store: "routing", applies: "live", risk: "raises", path: ["classes", name, "peers"] },
|
|
43
|
+
{ key: `routing.classes.${name}.escalate_to`, group: "Routing", label: `${name}: escalation order`, type: "peers", store: "routing", applies: "live", risk: "raises", path: ["classes", name, "escalate_to"] },
|
|
44
|
+
{ key: `routing.classes.${name}.route`, group: "Routing", label: `${name}: local worker route`, type: "enum", store: "routing", applies: "live", risk: "raises", path: ["classes", name, "route"] },
|
|
45
|
+
{ key: `routing.classes.${name}.pi_backend`, group: "Routing", label: `${name}: Pi backend`, type: "enum", values: ["dgx", "mlx"], store: "routing", applies: "live", risk: "raises", path: ["classes", name, "pi_backend"] },
|
|
46
|
+
];
|
|
47
|
+
export const SETTINGS: readonly SettingDef[] = [
|
|
48
|
+
...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "hub start", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
|
|
49
|
+
...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "peer start", risk: "safe", path: ["catalog_roles", peer] })),
|
|
50
|
+
{ key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
|
|
51
|
+
...SETTING_PEERS.map((peer): SettingDef => ({ key: `roster.${peer}`, group: "Roster", label: `${peer}: participates`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", peer] })),
|
|
52
|
+
...SETTING_PEERS.filter(peer => peer !== "jcode").map((peer): SettingDef => ({ key: `permission.${peer}`, group: "Permissions", label: `${peer}: mode now`, type: "enum", values: PERMISSION_MODES, store: "runtime", applies: "live", risk: "raises", floor: "ask", path: [peer] })),
|
|
53
|
+
...SETTING_PEERS.filter(peer => peer !== "jcode").map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at hub start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "hub start", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
|
|
54
|
+
...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "peer start", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
|
|
55
|
+
{ key: "pi.auto_start", group: "Start", label: "Start Pi with the hub", type: "boolean", store: "config", applies: "hub start", risk: "raises", floor: false, path: ["pi", "auto_start"] },
|
|
56
|
+
{ key: "routing.stay_switch", group: "Routing", label: "Stay or switch (hub/auto and stage routes)", type: "enum", values: ["off", "shadow", "enforce"], store: "routing", applies: "live", risk: "raises", path: ["stay_switch"] },
|
|
57
|
+
...CLASSES.flatMap(perClass),
|
|
58
|
+
{ key: "research.enabled", group: "Switches", label: "Research records", type: "boolean", store: "config", applies: "hub start", risk: "safe", path: ["research", "enabled"] },
|
|
59
|
+
{ key: "approvals.notify", group: "Switches", label: "Desktop notice for a waiting approval", type: "boolean", store: "config", applies: "hub start", risk: "safe", path: ["approvals", "notify"] },
|
|
60
|
+
{ key: "snapshots.enabled", group: "Switches", label: "Per-turn snapshots", type: "boolean", store: "config", applies: "hub start", risk: "safe", path: ["snapshots", "enabled"] },
|
|
61
|
+
{ key: "coordination", group: "Switches", label: "Coordination of overlapping owners", type: "enum", values: ["advisory", "turn-free"], store: "config", applies: "hub start", risk: "safe", path: ["coordination"] },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
|
|
66
|
+
* text go. A key equal to an entry, or below it, is refused by name.
|
|
67
|
+
*/
|
|
68
|
+
export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal"] as const;
|
|
69
|
+
|
|
70
|
+
/** The definition of a key, or why there is none. */
|
|
71
|
+
export function settingDef(key: unknown): SettingDef | string {
|
|
72
|
+
if (typeof key !== "string" || key.length > 80) return "unknown setting";
|
|
73
|
+
const found = SETTINGS.find((s) => s.key === key);
|
|
74
|
+
if (found) return found;
|
|
75
|
+
if (NEVER_EDITABLE.some((never) => key === never || key.startsWith(`${never}.`))) return `${key} chooses what the hub runs or where secrets and task text go: it is never editable from settings; edit .agenthub/config.local.json in your editor`;
|
|
76
|
+
return "unknown setting; run ahub settings list";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The checked value, or a thrown reason. `routes`: the route ids the project's routing defines. */
|
|
80
|
+
export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonly string[] = []): SettingValue {
|
|
81
|
+
if (raw === null) {
|
|
82
|
+
if (def.store === "runtime") throw new Error(`${def.key} has no stored value to remove`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (def.type === "integer") {
|
|
86
|
+
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < (def.min ?? 0) || raw > (def.max ?? Number.MAX_SAFE_INTEGER)) throw new Error(`${def.key} takes an integer from ${def.min ?? 0} to ${def.max ?? Number.MAX_SAFE_INTEGER}`);
|
|
87
|
+
return raw;
|
|
88
|
+
}
|
|
89
|
+
if (def.type === "boolean") {
|
|
90
|
+
if (typeof raw !== "boolean") throw new Error(`${def.key} takes true or false`);
|
|
91
|
+
return raw;
|
|
92
|
+
}
|
|
93
|
+
if (def.type === "roles") {
|
|
94
|
+
const allowed = def.values ?? routes;
|
|
95
|
+
if (!Array.isArray(raw) || raw.length > 2 || new Set(raw).size !== raw.length || raw.some(id => typeof id !== "string" || !allowed.includes(id))) throw new Error(`${def.key} takes at most two distinct compatible catalog ids; allowed: ${allowed.join(", ") || "(none)"}`);
|
|
96
|
+
return [...raw] as string[];
|
|
97
|
+
}
|
|
98
|
+
if (def.type === "peers") {
|
|
99
|
+
if (!Array.isArray(raw) || raw.length > PEERS_MAX || raw.some((p) => typeof p !== "string" || !PEER.test(p)) || new Set(raw).size !== raw.length) throw new Error(`${def.key} takes up to ${PEERS_MAX} distinct peer ids`);
|
|
100
|
+
return [...(raw as string[])];
|
|
101
|
+
}
|
|
102
|
+
const values = def.values ?? routes;
|
|
103
|
+
if (typeof raw !== "string" || !values.includes(raw)) throw new Error(`${def.key} takes one of: ${values.join(", ") || "(the project defines no route)"}`);
|
|
104
|
+
return raw;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A terminal's text for a value: `inherit` removes the machine-local value, a peers setting takes a comma list. */
|
|
108
|
+
export function parseSettingText(def: SettingDef, text: string): unknown {
|
|
109
|
+
if (text === "inherit") return null;
|
|
110
|
+
if (def.type === "integer") return /^(0|[1-9]\d*)$/.test(text) ? Number(text) : text;
|
|
111
|
+
if (def.type === "boolean") return text === "true" ? true : text === "false" ? false : text;
|
|
112
|
+
if (def.type === "peers" || def.type === "roles") return text.split(",").map((p) => p.trim()).filter(Boolean);
|
|
113
|
+
return text;
|
|
114
|
+
}
|
|
115
|
+
export const settingText = (value: unknown): string => value === null || value === undefined ? "(not set)" : Array.isArray(value) ? value.join(",") || "(none)" : String(value);
|
|
116
|
+
|
|
117
|
+
/** `ordinary`: a dashboard session from `ahub ui`. `settings`: one from `ahub ui --settings`. `terminal`: `ahub settings`. */
|
|
118
|
+
export type SettingAuthority = "ordinary" | "settings" | "terminal";
|
|
119
|
+
/** Why this authority may not set this value, or undefined. Removing a stored value can raise, so it counts as one. */
|
|
120
|
+
export function settingRefusal(def: SettingDef | string, value: SettingValue, authority: SettingAuthority): string | undefined {
|
|
121
|
+
if (typeof def === "string") return def || "invalid setting definition";
|
|
122
|
+
if (def.risk === "safe" || authority !== "ordinary") return undefined;
|
|
123
|
+
if (value !== null && value === def.floor) return undefined;
|
|
124
|
+
return `${def.key} can widen what agents do without asking: open a settings session with ahub ui --settings, or use ahub settings in a terminal`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const CONFIG_LOCAL = "config.local.json";
|
|
128
|
+
type StoredFile = typeof CONFIG_LOCAL | typeof OVERLAY_FILE;
|
|
129
|
+
interface UndoRecord { file: StoredFile; key: string; previous: string | null; written: string | null; at: number }
|
|
130
|
+
const undoFile = (stateDir: string): string => join(stateDir, "settings-undo.json");
|
|
131
|
+
const digest = (text: string | null): string | null => text === null ? null : createHash("sha256").update(text).digest("hex");
|
|
132
|
+
/** The file's text, or null when it is not there. Any other failure is named by its code: its own text holds absolute paths. */
|
|
133
|
+
const readText = (file: string): string | null => {
|
|
134
|
+
try { return readFileSync(file, "utf8"); } catch (error) {
|
|
135
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
136
|
+
throw new Error(`.agenthub/${basename(file)} could not be read (${(error as NodeJS.ErrnoException).code ?? "unknown error"})`);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const isObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
140
|
+
function parseObject(text: string, name: string): Record<string, unknown> {
|
|
141
|
+
let doc: unknown;
|
|
142
|
+
try { doc = JSON.parse(text); } catch { throw new Error(`.agenthub/${name} is not valid JSON; fix it in your editor first`); }
|
|
143
|
+
if (!isObject(doc)) throw new Error(`.agenthub/${name} must hold a JSON object`);
|
|
144
|
+
return doc;
|
|
145
|
+
}
|
|
146
|
+
/** Temp file and rename in the same directory: a reader sees the old file or the new one, never half of it. */
|
|
147
|
+
function atomicWrite(file: string, text: string): void {
|
|
148
|
+
let mode = 0o600;
|
|
149
|
+
try { mode = statSync(file).mode & 0o777; } catch { /* a new file is the owner's alone */ }
|
|
150
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
151
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
152
|
+
try {
|
|
153
|
+
writeFileSync(tmp, text, { mode, flag: "wx" });
|
|
154
|
+
renameSync(tmp, file);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
try { unlinkSync(tmp); } catch { /* nothing was written */ }
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** A settings write never changes a file git tracks: the machine-local files are meant to stay out of the repository. */
|
|
161
|
+
function refuseTracked(cwd: string, file: StoredFile): void {
|
|
162
|
+
const tracked = configTracked(cwd, file);
|
|
163
|
+
if (tracked) throw new Error(`.agenthub/${file} is committed to git; settings write only machine-local files. Remove it from the repository (git rm --cached) and ignore it`);
|
|
164
|
+
// Unknown is not untracked where there is a repository to ask; only a project outside git has nothing tracked.
|
|
165
|
+
if (tracked === undefined && insideRepository(cwd)) throw new Error(`git could not confirm that .agenthub/${file} is untracked; nothing was written`);
|
|
166
|
+
}
|
|
167
|
+
/** Whether this directory or one above it holds a `.git`: a repository git should have been able to answer for. */
|
|
168
|
+
function insideRepository(dir: string): boolean {
|
|
169
|
+
for (let at = dir; ; at = dirname(at)) {
|
|
170
|
+
if (existsSync(join(at, ".git"))) return true;
|
|
171
|
+
if (dirname(at) === at) return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Keeps the one previous version, then replaces (or removes) the file. */
|
|
175
|
+
function store(cwd: string, stateDir: string, file: StoredFile, key: string, previous: string | null, next: string | null): void {
|
|
176
|
+
refuseTracked(cwd, file);
|
|
177
|
+
replaceFile(join(cwd, ".agenthub", file), file, next);
|
|
178
|
+
// The record follows the file: a write that failed leaves the earlier record valid, because the file is as it left it.
|
|
179
|
+
const record: UndoRecord = { file, key, previous, written: digest(next), at: Date.now() };
|
|
180
|
+
try {
|
|
181
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
182
|
+
rmSync(undoFile(stateDir), { force: true });
|
|
183
|
+
writeFileSync(undoFile(stateDir), JSON.stringify(record), { mode: 0o600 });
|
|
184
|
+
} catch {
|
|
185
|
+
rmSync(undoFile(stateDir), { force: true }); // this write has no undo, and an older record must not offer one
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Replaces or removes the file. A file-system failure is reported by its code only: its text names absolute paths, and this reaches the dashboard. */
|
|
189
|
+
function replaceFile(path: string, file: StoredFile, text: string | null): void {
|
|
190
|
+
try {
|
|
191
|
+
if (text === null) rmSync(path, { force: true });
|
|
192
|
+
else atomicWrite(path, text);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
throw new Error(`.agenthub/${file} could not be written (${(error as NodeJS.ErrnoException).code ?? "unknown error"}); nothing was changed`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The value a config file holds at a path, or undefined. */
|
|
199
|
+
export function valueAt(doc: unknown, path: readonly string[]): unknown {
|
|
200
|
+
let node = doc;
|
|
201
|
+
for (const part of path) {
|
|
202
|
+
if (!isObject(node) || !Object.hasOwn(node, part)) return undefined;
|
|
203
|
+
node = node[part];
|
|
204
|
+
}
|
|
205
|
+
return node;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Sets (or with null removes) one key of `.agenthub/config.local.json`. `validate` gets a scratch project that holds
|
|
210
|
+
* the shared config and the candidate, and throws when the hub's own loader would refuse it: nothing is written then.
|
|
211
|
+
*/
|
|
212
|
+
export function writeConfigSetting(cwd: string, stateDir: string, def: SettingDef, value: SettingValue, validate: (scratch: string) => unknown): boolean {
|
|
213
|
+
const file = join(cwd, ".agenthub", CONFIG_LOCAL);
|
|
214
|
+
const before = readText(file);
|
|
215
|
+
const doc = before === null ? {} : parseObject(before, CONFIG_LOCAL);
|
|
216
|
+
// Nothing to change: no file is created or rewritten, and the undo record keeps the last real write.
|
|
217
|
+
const held = valueAt(doc, def.path);
|
|
218
|
+
if (value === null ? held === undefined : same(held, value)) return false;
|
|
219
|
+
// The loader gives a project that has any config file its project defaults (snapshots, limits, the budget relay,
|
|
220
|
+
// the approval notice). A settings write must not be what silently makes the first one.
|
|
221
|
+
if (before === null && !existsSync(join(cwd, ".agenthub", "config.json"))) throw new Error("this project has no .agenthub/config.json: run ahub init first. A first config file also turns on the project defaults (snapshots, limits, the budget relay), which a settings write does not do for you");
|
|
222
|
+
let node = doc;
|
|
223
|
+
const trail: [Record<string, unknown>, string][] = [];
|
|
224
|
+
for (const part of def.path.slice(0, -1)) {
|
|
225
|
+
if (node[part] === undefined) {
|
|
226
|
+
if (value === null) { node = {}; break; } // nothing to remove below a block that is not there
|
|
227
|
+
node[part] = {};
|
|
228
|
+
}
|
|
229
|
+
const next = node[part];
|
|
230
|
+
if (!isObject(next)) throw new Error(`.agenthub/${CONFIG_LOCAL}: ${part} is not an object; fix it in your editor first`);
|
|
231
|
+
trail.push([node, part]);
|
|
232
|
+
node = next;
|
|
233
|
+
}
|
|
234
|
+
const leaf = def.path.at(-1)!;
|
|
235
|
+
if (value === null) {
|
|
236
|
+
delete node[leaf];
|
|
237
|
+
// A block the removal emptied goes with it, so setting a value and removing it again leaves the file as it was.
|
|
238
|
+
for (const [parent, part] of trail.reverse()) {
|
|
239
|
+
if (Object.keys(parent[part] as object).length) break;
|
|
240
|
+
delete parent[part];
|
|
241
|
+
}
|
|
242
|
+
} else node[leaf] = value;
|
|
243
|
+
const text = `${JSON.stringify(doc, null, 2)}\n`;
|
|
244
|
+
checkConfigText(cwd, text, validate);
|
|
245
|
+
store(cwd, stateDir, CONFIG_LOCAL, def.key, before, text);
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
/** Runs `validate` on a scratch project holding the shared config and this candidate for the machine-local one. */
|
|
249
|
+
function checkConfigText(cwd: string, text: string | null, validate: (scratch: string) => unknown): void {
|
|
250
|
+
const scratch = mkdtempSync(join(tmpdir(), "ahub-settings-"));
|
|
251
|
+
try {
|
|
252
|
+
mkdirSync(join(scratch, ".agenthub"));
|
|
253
|
+
if (existsSync(join(cwd, ".agenthub", "config.json"))) copyFileSync(join(cwd, ".agenthub", "config.json"), join(scratch, ".agenthub", "config.json"));
|
|
254
|
+
if (text !== null) writeFileSync(join(scratch, ".agenthub", CONFIG_LOCAL), text);
|
|
255
|
+
validate(scratch);
|
|
256
|
+
} finally {
|
|
257
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Sets (or with null removes) one key of `.agenthub/routing.local.toml`; the real parser reads the result first, and
|
|
263
|
+
* `check` may refuse the policy it yields. Undefined when the overlay already holds that value: nothing is written.
|
|
264
|
+
*/
|
|
265
|
+
export function writeRoutingSetting(cwd: string, stateDir: string, def: SettingDef, value: SettingValue, check?: (routing: Routing) => void): Routing | undefined {
|
|
266
|
+
const file = join(cwd, ".agenthub", OVERLAY_FILE);
|
|
267
|
+
const before = readText(file);
|
|
268
|
+
const current = before === null ? {} : parseOverlay(before);
|
|
269
|
+
const held = def.path[0] === "stay_switch" ? current.stay_switch : (current.classes?.[def.path[1] as TaskClass] as Record<string, unknown> | undefined)?.[def.path[2]!];
|
|
270
|
+
if (value === null ? held === undefined : same(held, value)) return undefined;
|
|
271
|
+
const overlay = routingCandidate(current, def, value);
|
|
272
|
+
const routing = parseRouting(routingText(cwd), overlay);
|
|
273
|
+
check?.(routing);
|
|
274
|
+
const empty = !overlay.stay_switch && !Object.keys(overlay.classes ?? {}).length;
|
|
275
|
+
store(cwd, stateDir, OVERLAY_FILE, def.key, before, empty ? null : overlayToml(overlay));
|
|
276
|
+
return routing;
|
|
277
|
+
}
|
|
278
|
+
/** An overlay with one key set or removed: the candidate a write stores and a preview only reads. */
|
|
279
|
+
export function routingCandidate(current: RoutingOverlay, def: SettingDef, value: SettingValue): RoutingOverlay {
|
|
280
|
+
const overlay: RoutingOverlay = { ...(current.stay_switch ? { stay_switch: current.stay_switch } : {}), classes: Object.fromEntries(Object.entries(current.classes ?? {}).map(([name, entry]) => [name, { ...entry }])) };
|
|
281
|
+
if (def.path[0] === "stay_switch") {
|
|
282
|
+
if (value === null) delete overlay.stay_switch;
|
|
283
|
+
else overlay.stay_switch = value as RoutingOverlay["stay_switch"];
|
|
284
|
+
return overlay;
|
|
285
|
+
}
|
|
286
|
+
const [, name, field] = def.path as [string, TaskClass, "peers" | "escalate_to" | "route" | "pi_backend"];
|
|
287
|
+
const entry: Record<string, unknown> = { ...overlay.classes![name] };
|
|
288
|
+
if (value === null) delete entry[field];
|
|
289
|
+
else entry[field] = value;
|
|
290
|
+
if (Object.keys(entry).length) overlay.classes![name] = entry;
|
|
291
|
+
else delete overlay.classes![name];
|
|
292
|
+
return overlay;
|
|
293
|
+
}
|
|
294
|
+
/** The overlay on disk, or an empty one. */
|
|
295
|
+
export function readOverlay(cwd: string): RoutingOverlay {
|
|
296
|
+
const text = readText(join(cwd, ".agenthub", OVERLAY_FILE));
|
|
297
|
+
return text === null ? {} : parseOverlay(text);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Puts back the one previous version of the file the last settings write changed. Refused when the file changed since
|
|
302
|
+
* that write (a hand edit would be lost), when the loaders no longer accept that version, or when there is nothing to undo.
|
|
303
|
+
*/
|
|
304
|
+
export function undoSetting(cwd: string, stateDir: string, validate: (scratch: string) => unknown, checkRouting?: (routing: Routing) => void): { key: string; file: StoredFile } {
|
|
305
|
+
const raw = readText(undoFile(stateDir));
|
|
306
|
+
if (raw === null) throw new Error("nothing to undo");
|
|
307
|
+
let record: UndoRecord;
|
|
308
|
+
try { record = JSON.parse(raw) as UndoRecord; } catch { throw new Error("nothing to undo"); }
|
|
309
|
+
if (record.file !== CONFIG_LOCAL && record.file !== OVERLAY_FILE) throw new Error("nothing to undo");
|
|
310
|
+
const path = join(cwd, ".agenthub", record.file);
|
|
311
|
+
if (digest(readText(path)) !== record.written) throw new Error(`.agenthub/${record.file} changed since the last settings write; nothing was undone`);
|
|
312
|
+
if (record.previous !== null && typeof record.previous !== "string") throw new Error("nothing to undo");
|
|
313
|
+
refuseTracked(cwd, record.file);
|
|
314
|
+
if (record.file === OVERLAY_FILE) {
|
|
315
|
+
// The parser always reads what is put back; the caller's check is on top of it.
|
|
316
|
+
const restored = parseRouting(routingText(cwd), record.previous === null ? undefined : parseOverlay(record.previous));
|
|
317
|
+
checkRouting?.(restored);
|
|
318
|
+
}
|
|
319
|
+
else checkConfigText(cwd, record.previous, validate);
|
|
320
|
+
replaceFile(path, record.file, record.previous);
|
|
321
|
+
rmSync(undoFile(stateDir), { force: true });
|
|
322
|
+
return { key: String(record.key), file: record.file };
|
|
323
|
+
}
|
|
324
|
+
/** Whether the machine-local config file holds a value for this setting now. */
|
|
325
|
+
export function storedLocally(cwd: string, def: SettingDef): boolean {
|
|
326
|
+
try { return valueAt(JSON.parse(readFileSync(join(cwd, ".agenthub", CONFIG_LOCAL), "utf8")), def.path) !== undefined; } catch { return false; }
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* For a config setting: the value the config files would hold for it after the pending undo (the previous version of
|
|
330
|
+
* the machine-local file, else the shared file), or, with `inherit`, after the machine-local value is removed (the
|
|
331
|
+
* shared file's). Undefined when neither sets it or nothing reads.
|
|
332
|
+
*/
|
|
333
|
+
export function restoredValue(cwd: string, stateDir: string, def: SettingDef, inherit = false): unknown {
|
|
334
|
+
const parse = (text: string | null): unknown => { try { return text === null ? {} : JSON.parse(text); } catch { return {}; } };
|
|
335
|
+
let local: unknown = {};
|
|
336
|
+
if (!inherit) {
|
|
337
|
+
try { const record = JSON.parse(readFileSync(undoFile(stateDir), "utf8")) as UndoRecord; if (record.file !== CONFIG_LOCAL) return undefined; local = parse(record.previous); } catch { return undefined; }
|
|
338
|
+
}
|
|
339
|
+
return valueAt(local, def.path) ?? valueAt(parse(readText(join(cwd, ".agenthub", "config.json"))), def.path);
|
|
340
|
+
}
|
|
341
|
+
/** The key the next undo would put back, if any. */
|
|
342
|
+
export function pendingUndo(stateDir: string): string | undefined {
|
|
343
|
+
try { const record = JSON.parse(readFileSync(undoFile(stateDir), "utf8")) as UndoRecord; return typeof record.key === "string" ? record.key : undefined; } catch { return undefined; }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The loader's line saying it ignored this setting's field in the machine-local file, if it did (config trust, #17). */
|
|
347
|
+
export function ignoredLine(ignored: readonly string[] | undefined, def: SettingDef): string | undefined {
|
|
348
|
+
return ignored?.find((line) => {
|
|
349
|
+
const at = line.indexOf(` in .agenthub/${CONFIG_LOCAL} ignored:`);
|
|
350
|
+
return at > 0 && line.slice(0, at).split(", ").some((field) => field === def.path[0] || field === def.path.slice(0, 2).join("."));
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
export interface SettingRow {
|
|
354
|
+
key: string; group: SettingDef["group"]; label: string; type: SettingDef["type"]; values?: readonly string[]; min?: number; max?: number;
|
|
355
|
+
/** The file a write goes to, or "this hub run". */
|
|
356
|
+
file: string;
|
|
357
|
+
applies: SettingDef["applies"]; risk: SettingDef["risk"]; floor?: string | boolean | number;
|
|
358
|
+
/** What is in force now; null when nothing sets it. */
|
|
359
|
+
value: SettingValue;
|
|
360
|
+
/** Which file the value in force comes from. */
|
|
361
|
+
source: string;
|
|
362
|
+
/** The stored value the next hub start reads, when it differs from the one in force. */
|
|
363
|
+
pending?: SettingValue;
|
|
364
|
+
/** Why the row cannot be changed now. */
|
|
365
|
+
note?: string;
|
|
366
|
+
/** For a permission row: the bounds the peer's tools run in, whatever the mode. */
|
|
367
|
+
hint?: string;
|
|
368
|
+
}
|
|
369
|
+
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* One row per setting, for the dashboard and `ahub settings list`. `running` is the configuration the hub started
|
|
373
|
+
* with, `next` what its loader reads from the files now (undefined when they do not load), `mode` a peer's mode now.
|
|
374
|
+
*/
|
|
375
|
+
export function settingRows(input: {
|
|
376
|
+
cwd: string; routing: Routing;
|
|
377
|
+
running: (path: readonly string[]) => unknown;
|
|
378
|
+
next: ((path: readonly string[]) => unknown) | undefined;
|
|
379
|
+
mode: (peer: string) => { value?: string; note?: string };
|
|
380
|
+
/** Why the overlay does not load, when it does not: the rows then show the last policy that did. */
|
|
381
|
+
routingProblem?: string;
|
|
382
|
+
/** The loader's own lines about machine-local fields it ignored (`HubConfig.ignored`). */
|
|
383
|
+
ignored?: string[];
|
|
384
|
+
roles?: (peer: string) => { value: SettingValue; pending?: SettingValue; values: string[]; note?: string };
|
|
385
|
+
}): SettingRow[] {
|
|
386
|
+
const raw = (name: string): unknown => { try { return JSON.parse(readFileSync(join(input.cwd, ".agenthub", name), "utf8")); } catch { return undefined; } };
|
|
387
|
+
const local = raw(CONFIG_LOCAL), shared = raw("config.json");
|
|
388
|
+
const projectRouting = existsSync(join(input.cwd, ".agenthub", "routing.toml"));
|
|
389
|
+
const routes = [...Object.keys(input.routing.routes), ...Object.keys(input.routing.hub_routes ?? {})];
|
|
390
|
+
const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
|
|
391
|
+
return SETTINGS.map((def): SettingRow => {
|
|
392
|
+
const base = { key: def.key, group: def.group, label: def.label, type: def.type, ...(def.type === "integer" ? { min: def.min, max: def.max } : {}), applies: def.applies, risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
|
|
393
|
+
...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!) } : {}) };
|
|
394
|
+
if (def.store === "runtime") {
|
|
395
|
+
const mode = input.mode(def.path[0]!);
|
|
396
|
+
return { ...base, values: def.values!, file: "this hub run", value: mode.value ?? null, source: "this hub run", ...(mode.note ? { note: mode.note } : {}) };
|
|
397
|
+
}
|
|
398
|
+
if (def.store === "routing") {
|
|
399
|
+
const value = scalar(def.path[0] === "stay_switch" ? input.routing.stay_switch : (input.routing.classes[def.path[1] as TaskClass] as Record<string, unknown> | undefined)?.[def.path[2]!]);
|
|
400
|
+
return { ...base, ...(def.type === "enum" ? { values: def.values ?? routes } : {}), file: OVERLAY_FILE, value, source: input.routing.sources?.[def.key.slice("routing.".length)] ?? (value === null ? "default" : projectRouting ? "routing.toml" : "default"),
|
|
401
|
+
...(input.routingProblem ? { note: `the routing files do not load, so the last policy that did stays in force: ${input.routingProblem}` } : {}) };
|
|
402
|
+
}
|
|
403
|
+
if (def.type === "roles" && input.roles) return { ...base, ...input.roles(def.path[1]!), file: CONFIG_LOCAL, source: valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "profile/default", hint: "Guidance only; grants no tool or permission. At most two roles, applied at the next peer start." };
|
|
404
|
+
const value = scalar(input.running(def.path));
|
|
405
|
+
const stored = input.next ? scalar(input.next(def.path)) : value;
|
|
406
|
+
const source = valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "default";
|
|
407
|
+
const dropped = ignoredLine(input.ignored, def);
|
|
408
|
+
return { ...base, ...(def.values ? { values: def.values } : {}), file: CONFIG_LOCAL, value, source, ...(same(value, stored) ? {} : { pending: stored }),
|
|
409
|
+
...(!input.next ? { note: "the config files do not load; fix them in your editor" } : dropped ? { note: `the hub does not read this value: ${dropped}` } : {}) };
|
|
410
|
+
});
|
|
411
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import role0 from "../../templates/roles/dev/software-architect.md" with { type: "text" };
|
|
2
|
+
import role1 from "../../templates/roles/dev/backend-engineer.md" with { type: "text" };
|
|
3
|
+
import role2 from "../../templates/roles/dev/frontend-engineer.md" with { type: "text" };
|
|
4
|
+
import role3 from "../../templates/roles/dev/test-engineer.md" with { type: "text" };
|
|
5
|
+
import role4 from "../../templates/roles/dev/code-reviewer.md" with { type: "text" };
|
|
6
|
+
import role5 from "../../templates/roles/dev/security-reviewer.md" with { type: "text" };
|
|
7
|
+
import role6 from "../../templates/roles/research/research-lead.md" with { type: "text" };
|
|
8
|
+
import role7 from "../../templates/roles/research/researcher.md" with { type: "text" };
|
|
9
|
+
import role8 from "../../templates/roles/research/analyst.md" with { type: "text" };
|
|
10
|
+
import role9 from "../../templates/roles/research/statistician.md" with { type: "text" };
|
|
11
|
+
import role10 from "../../templates/roles/research/fact-checker.md" with { type: "text" };
|
|
12
|
+
|
|
13
|
+
export const SHIPPED_ROLES: Readonly<Record<string, string>> = {
|
|
14
|
+
"software-architect": role0,
|
|
15
|
+
"backend-engineer": role1,
|
|
16
|
+
"frontend-engineer": role2,
|
|
17
|
+
"test-engineer": role3,
|
|
18
|
+
"code-reviewer": role4,
|
|
19
|
+
"security-reviewer": role5,
|
|
20
|
+
"research-lead": role6,
|
|
21
|
+
"researcher": role7,
|
|
22
|
+
"analyst": role8,
|
|
23
|
+
"statistician": role9,
|
|
24
|
+
"fact-checker": role10,
|
|
25
|
+
};
|
package/src/hub/snapshots.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import {
|
|
3
|
-
import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync, utimesSync } from "node:fs";
|
|
2
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
4
3
|
import { tmpdir } from "node:os";
|
|
5
4
|
import { join } from "node:path";
|
|
6
5
|
import { childEnv } from "./child-process.ts";
|
|
7
6
|
import type { Touch } from "./conflicts.ts";
|
|
7
|
+
import { hubGitSync } from "./git.ts";
|
|
8
|
+
import { realPath } from "./project.ts";
|
|
9
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Per-turn workspace snapshots (issue #33): git tree objects written through a copy of the index, so the user's index
|
|
@@ -14,10 +16,10 @@ import type { Touch } from "./conflicts.ts";
|
|
|
14
16
|
* ponytail: the trees are unreferenced, so `git gc` may prune them after gc.pruneExpire (two weeks by default); undo
|
|
15
17
|
* of an older turn says so. Refs under refs/agenthub/ would keep them, at the cost of showing up in the user's repo.
|
|
16
18
|
*/
|
|
17
|
-
const git = (top: string, args: string[], env: Record<string, string> = {}) =>
|
|
18
|
-
|
|
19
|
+
const git = (top: string | Repo, args: string[], env: Record<string, string> = {}) =>
|
|
20
|
+
hubGitSync(repoGitArgs(top, args), {
|
|
19
21
|
encoding: "utf8",
|
|
20
|
-
env: { ...
|
|
22
|
+
env: { ...repoGitEnv(top), ...env },
|
|
21
23
|
// These run inside the bus's state change: a hung git (a huge new directory, a stuck filter) must not freeze the hub.
|
|
22
24
|
timeout: SNAPSHOT_TIMEOUT_MS,
|
|
23
25
|
});
|
|
@@ -28,14 +30,73 @@ export interface Repo {
|
|
|
28
30
|
top: string;
|
|
29
31
|
dir: string;
|
|
30
32
|
prefix: string;
|
|
33
|
+
shadow?: true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Refuse links throughout metadata, including loose-object fan-outs and packed-object files. */
|
|
37
|
+
function shadowStorageSafe(dir: string): boolean {
|
|
38
|
+
let entries = 0;
|
|
39
|
+
const visit = (path: string, depth: number): boolean => {
|
|
40
|
+
if (++entries > 20000 || depth > 32) return false;
|
|
41
|
+
const st = lstatSync(path);
|
|
42
|
+
if (st.isSymbolicLink()) return false;
|
|
43
|
+
if (st.isFile()) return st.nlink === 1;
|
|
44
|
+
if (!st.isDirectory()) return false;
|
|
45
|
+
return readdirSync(path).every(name => visit(join(path, name), depth + 1));
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
if (lstatSync(join(dir, "commondir"), { throwIfNoEntry: false }) || lstatSync(join(dir, "objects/info/alternates"), { throwIfNoEntry: false })) return false;
|
|
49
|
+
return visit(dir, 0);
|
|
50
|
+
} catch { return false; }
|
|
51
|
+
}
|
|
52
|
+
/** Explicit flags keep the shadow work tree at its verified project root. */
|
|
53
|
+
export function repoGitArgs(repo: string | Repo, args: string[]): string[] {
|
|
54
|
+
const top = typeof repo === "string" ? repo : repo.top;
|
|
55
|
+
if (typeof repo !== "string" && repo.shadow) {
|
|
56
|
+
const parent = lstatSync(join(repo.top, ".agenthub"), { throwIfNoEntry: false });
|
|
57
|
+
const ignore = parent?.isDirectory() && !parent.isSymbolicLink() ? lstatSync(join(repo.top, ".agenthub/ignore"), { throwIfNoEntry: false }) : undefined;
|
|
58
|
+
if (!parent?.isDirectory() || parent.isSymbolicLink() || ignore && (!ignore.isFile() || ignore.isSymbolicLink() || ignore.nlink !== 1) || repo.dir !== join(repo.top, ".agenthub/shadow.git") || !shadowStorageSafe(repo.dir)) throw new Error("shadow repository metadata is invalid or linked; operation refused");
|
|
59
|
+
}
|
|
60
|
+
return ["-C", top, ...(typeof repo !== "string" && repo.shadow ? ["--git-dir=" + repo.dir, "--work-tree=" + repo.top, "-c", "core.excludesFile=" + join(repo.top, ".agenthub", "ignore")] : []), ...args];
|
|
61
|
+
}
|
|
62
|
+
export function repoGitEnv(repo: string | Repo, env = process.env): NodeJS.ProcessEnv {
|
|
63
|
+
return { ...childEnv(env), ...(typeof repo !== "string" && repo.shadow ? { GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", GIT_ATTR_NOSYSTEM: "1" } : {}) };
|
|
31
64
|
}
|
|
32
65
|
|
|
33
66
|
/** The repository holding `root`, or undefined when it is not inside a work tree. */
|
|
34
67
|
export function repoOf(root: string): Repo | undefined {
|
|
35
68
|
const r = git(root, ["rev-parse", "--show-toplevel", "--absolute-git-dir", "--show-prefix"]);
|
|
36
|
-
if (r.status
|
|
37
|
-
|
|
38
|
-
|
|
69
|
+
if (r.status === 0) {
|
|
70
|
+
const [top, dir, prefix = ""] = r.stdout.replace(/\n$/, "").split("\n");
|
|
71
|
+
if (top && dir) return { top, dir, prefix };
|
|
72
|
+
}
|
|
73
|
+
const top = realPath(root), hub = join(top, ".agenthub"), dir = join(hub, "shadow.git");
|
|
74
|
+
const parent = lstatSync(hub, { throwIfNoEntry: false });
|
|
75
|
+
if (!parent?.isDirectory() || parent.isSymbolicLink()) return undefined;
|
|
76
|
+
const own = lstatSync(dir, { throwIfNoEntry: false });
|
|
77
|
+
if (!own?.isDirectory() || own.isSymbolicLink()) return undefined;
|
|
78
|
+
for (const name of ["objects", "refs", "hooks", "info"]) { const st = lstatSync(join(dir, name), { throwIfNoEntry: false }); if (!st?.isDirectory() || st.isSymbolicLink()) return undefined; }
|
|
79
|
+
for (const file of ["config", "HEAD"]) { const st = lstatSync(join(dir, file), { throwIfNoEntry: false }); if (!st?.isFile() || st.isSymbolicLink() || st.nlink !== 1) return undefined; }
|
|
80
|
+
if (existsSync(join(dir, "commondir")) || existsSync(join(dir, "objects/info/alternates"))) return undefined;
|
|
81
|
+
const ignore = lstatSync(join(hub, "ignore"), { throwIfNoEntry: false });
|
|
82
|
+
if (ignore && (!ignore.isFile() || ignore.isSymbolicLink() || ignore.nlink !== 1)) return undefined;
|
|
83
|
+
if (!shadowStorageSafe(dir)) return undefined;
|
|
84
|
+
const repo: Repo = { top, dir: realPath(dir), prefix: "", shadow: true };
|
|
85
|
+
const probe = git(repo, ["rev-parse", "--absolute-git-dir"]);
|
|
86
|
+
return probe.status === 0 && realPath(probe.stdout.trim()) === repo.dir ? repo : undefined;
|
|
87
|
+
}
|
|
88
|
+
/** Opt-in creation only; a real repository is never replaced or modified. */
|
|
89
|
+
export function initShadow(root: string): boolean {
|
|
90
|
+
const top = realPath(root), hub = join(top, ".agenthub"), dir = join(hub, "shadow.git"), existing = repoOf(top);
|
|
91
|
+
if (existing) { if (!existing.shadow) throw new Error("project already has a Git work tree; shadow mode is for non-git folders"); return false; }
|
|
92
|
+
const parent = lstatSync(hub, { throwIfNoEntry: false });
|
|
93
|
+
if (parent && (!parent.isDirectory() || parent.isSymbolicLink())) throw new Error(".agenthub must be a project directory, not a link");
|
|
94
|
+
if (lstatSync(dir, { throwIfNoEntry: false })) throw new Error("existing shadow metadata is invalid; nothing was replaced");
|
|
95
|
+
mkdirSync(hub, { recursive: true });
|
|
96
|
+
const result = hubGitSync(["init", "--bare", "--quiet", dir], { cwd: top, encoding: "utf8", timeout: SNAPSHOT_TIMEOUT_MS, env: { ...childEnv(process.env), GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" } });
|
|
97
|
+
if (result.status !== 0) throw new Error("could not initialize shadow repository");
|
|
98
|
+
writeFileSync(join(dir, "info/exclude"), ".agenthub/shadow.git/\n.agenthub/state/\n.agenthub/archive/\n.agenthub/config.local.json\n.agenthub/routing.local.toml\n", { mode: 0o600 });
|
|
99
|
+
return true;
|
|
39
100
|
}
|
|
40
101
|
|
|
41
102
|
/**
|
|
@@ -61,12 +122,12 @@ export function snapshot(repo: Repo): string | undefined {
|
|
|
61
122
|
utimesSync(env.GIT_INDEX_FILE, atime, mtime);
|
|
62
123
|
}
|
|
63
124
|
// `ahub reset --all` archives hub.db (task text, PII included) under .agenthub/archive: never copy it either.
|
|
64
|
-
if (git(repo
|
|
125
|
+
if (git(repo, ["add", "-A", "--", scope(repo), ":(exclude,glob)**/.agenthub/state/**", ":(exclude,glob)**/.agenthub/archive/**", ...(repo.shadow ? [":(exclude,glob)**/.agenthub/shadow.git/**"] : [])], env).status !== 0) return undefined;
|
|
65
126
|
// The copy starts from the user's index: hub state somebody tracked or staged is still in it, and the exclude above
|
|
66
127
|
// only keeps `add` from touching it. Take it out explicitly; -f because staged state the hub has rewritten since
|
|
67
128
|
// matches neither HEAD nor the file, which `rm --cached` otherwise refuses. Only the copy changes.
|
|
68
|
-
if (git(repo
|
|
69
|
-
const r = git(repo
|
|
129
|
+
if (git(repo, ["rm", "-r", "-f", "--cached", "--quiet", "--ignore-unmatch", "--", ":(glob)**/.agenthub/state/**", ":(glob)**/.agenthub/archive/**", ...(repo.shadow ? [":(glob)**/.agenthub/shadow.git/**"] : [])], env).status !== 0) return undefined;
|
|
130
|
+
const r = git(repo, ["write-tree"], env);
|
|
70
131
|
return r.status === 0 ? r.stdout.trim() : undefined;
|
|
71
132
|
} finally {
|
|
72
133
|
rmSync(tmp, { recursive: true, force: true });
|
|
@@ -75,14 +136,14 @@ export function snapshot(repo: Repo): string | undefined {
|
|
|
75
136
|
|
|
76
137
|
/** Project paths (relative to the top level) that differ between two trees. */
|
|
77
138
|
export function changedPaths(repo: Repo, from: string, to: string): string[] {
|
|
78
|
-
const r = git(repo
|
|
139
|
+
const r = git(repo, ["diff-tree", "-r", "--no-renames", "--name-only", "-z", from, to, "--", scope(repo)]);
|
|
79
140
|
return r.status === 0 ? r.stdout.split("\0").filter(Boolean) : [];
|
|
80
141
|
}
|
|
81
142
|
|
|
82
|
-
const inTree = (top: string, tree: string, path: string) => git(top, ["rev-parse", "--verify", "--quiet", `${tree}:${path}`]).status === 0;
|
|
143
|
+
const inTree = (top: string | Repo, tree: string, path: string) => git(top, ["rev-parse", "--verify", "--quiet", `${tree}:${path}`]).status === 0;
|
|
83
144
|
|
|
84
145
|
/** Whether a snapshot's tree is still in the object store (`git gc` prunes unreferenced ones eventually). */
|
|
85
|
-
export const hasTree = (top: string, tree: string): boolean => git(top, ["cat-file", "-e", `${tree}^{tree}`]).status === 0;
|
|
146
|
+
export const hasTree = (top: string | Repo, tree: string): boolean => git(top, ["cat-file", "-e", `${tree}^{tree}`]).status === 0;
|
|
86
147
|
|
|
87
148
|
export interface UndoPlan {
|
|
88
149
|
restore: string[];
|
|
@@ -121,10 +182,10 @@ export function planUndo(repo: Repo, turn: { start_tree: string; end_tree: strin
|
|
|
121
182
|
export function restore(repo: Repo, startTree: string, paths: string[]): void {
|
|
122
183
|
// Removals first: after a case-only rename on a case-insensitive disk, `Foo.ts` and `foo.ts` are one file, and
|
|
123
184
|
// removing the created spelling after restoring the old one would delete it.
|
|
124
|
-
const back = paths.filter((p) => inTree(repo
|
|
185
|
+
const back = paths.filter((p) => inTree(repo, startTree, p));
|
|
125
186
|
for (const path of paths.filter((p) => !back.includes(p))) rmSync(join(repo.top, path), { force: true });
|
|
126
187
|
for (const path of back) {
|
|
127
|
-
if (git(repo
|
|
188
|
+
if (git(repo, ["restore", `--source=${startTree}`, "--worktree", "--", literal(path)]).status !== 0) throw new Error(`could not restore ${path}`);
|
|
128
189
|
}
|
|
129
190
|
}
|
|
130
191
|
|
|
@@ -144,6 +205,7 @@ export class Turns {
|
|
|
144
205
|
private readonly db: Database;
|
|
145
206
|
constructor(path: string, readonly?: boolean) {
|
|
146
207
|
this.db = new Database(path, readonly ? { readonly: true } : { create: true });
|
|
208
|
+
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
147
209
|
if (readonly) return;
|
|
148
210
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
149
211
|
this.db.run(`CREATE TABLE IF NOT EXISTS turns (id TEXT PRIMARY KEY, peer TEXT NOT NULL, started INTEGER NOT NULL, ended INTEGER,
|
|
@@ -154,6 +216,20 @@ export class Turns {
|
|
|
154
216
|
// ponytail: rows of finished tasks stay (a few per file per task); prune by task state if hub.db ever grows.
|
|
155
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))");
|
|
156
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 task_snapshots (task INTEGER NOT NULL, proposed_at INTEGER NOT NULL, start_tree TEXT, PRIMARY KEY (task, proposed_at))");
|
|
220
|
+
}
|
|
221
|
+
/** A proposal's original project snapshot; task ids may be reused after reset. Never repair a missing baseline later. */
|
|
222
|
+
rememberTaskStart(task: number, proposedAt: number, tree: string | undefined): void {
|
|
223
|
+
this.db.query("INSERT OR IGNORE INTO task_snapshots (task, proposed_at, start_tree) VALUES (?, ?, ?)").run(task, proposedAt, tree ?? null);
|
|
224
|
+
}
|
|
225
|
+
taskStart(task: number, proposedAt: number): string | undefined {
|
|
226
|
+
try {
|
|
227
|
+
const row = this.db.query("SELECT start_tree FROM task_snapshots WHERE task = ? AND proposed_at = ?").get(task, proposedAt) as { start_tree: string | null } | null;
|
|
228
|
+
return row?.start_tree ?? undefined;
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (/no such table/.test((error as Error).message)) return undefined;
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
157
233
|
}
|
|
158
234
|
begin(id: string, peer: string, startTree: string | undefined): void {
|
|
159
235
|
this.db.query("INSERT OR REPLACE INTO turns (id, peer, started, start_tree) VALUES (?, ?, ?, ?)").run(id, peer, Date.now(), startTree ?? null);
|