@cmgmyr/hive 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/claude-plugin/.claude-plugin/plugin.json +7 -0
- package/claude-plugin/README.md +47 -0
- package/claude-plugin/hooks/hooks.json +14 -0
- package/claude-plugin/kickoff.mjs +41 -0
- package/claude-plugin/skills/cleanup/SKILL.md +60 -0
- package/dist/abi.js +220 -0
- package/dist/abiProbe.js +9 -0
- package/dist/backgroundTasks.js +51 -0
- package/dist/backup.js +378 -0
- package/dist/brief.js +113 -0
- package/dist/build-info.json +1 -0
- package/dist/claudeDir.js +5 -0
- package/dist/cli.js +2901 -0
- package/dist/codexHome.js +297 -0
- package/dist/config.js +72 -0
- package/dist/context.js +256 -0
- package/dist/contextCheckpoint.js +55 -0
- package/dist/dashboard.js +1158 -0
- package/dist/dataDir.js +89 -0
- package/dist/db.js +421 -0
- package/dist/dispatcher.js +150 -0
- package/dist/firstPrompt.js +6 -0
- package/dist/harnesses.js +173 -0
- package/dist/help.js +353 -0
- package/dist/hook.js +197 -0
- package/dist/hooks.js +50 -0
- package/dist/index.js +34 -0
- package/dist/kickoff.js +191 -0
- package/dist/leadMessage.js +98 -0
- package/dist/mcpConfig.js +121 -0
- package/dist/processes.js +161 -0
- package/dist/profiles.js +278 -0
- package/dist/projectYml.js +254 -0
- package/dist/prompts.js +18 -0
- package/dist/ptys.js +146 -0
- package/dist/result.js +53 -0
- package/dist/scheduler.js +1622 -0
- package/dist/sessionProbe.js +131 -0
- package/dist/slug.js +29 -0
- package/dist/spawn.js +370 -0
- package/dist/stateProvenance.js +97 -0
- package/dist/statusline.js +82 -0
- package/dist/strictInput.js +52 -0
- package/dist/teardown.js +89 -0
- package/dist/tmux.js +1295 -0
- package/dist/tools/agents.js +1246 -0
- package/dist/tools/kv.js +89 -0
- package/dist/tools/leases.js +87 -0
- package/dist/tools/meta.js +183 -0
- package/dist/tools/pads.js +279 -0
- package/dist/tools/params.js +14 -0
- package/dist/tools/todos.js +382 -0
- package/dist/tools/wakes.js +466 -0
- package/dist/transcript.js +123 -0
- package/dist/version.js +59 -0
- package/package.json +62 -0
- package/profiles/orchestration/posture.md +27 -0
- package/profiles/orchestration/runbook.md +72 -0
- package/profiles/orchestration/worker.md +38 -0
- package/profiles/simple/posture.md +14 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { parse as parseToml } from "smol-toml";
|
|
6
|
+
const serverPath = () => fileURLToPath(new URL("./index.js", import.meta.url));
|
|
7
|
+
function addCommand(scope, name, pinned, args) {
|
|
8
|
+
return ` claude mcp add --scope ${scope} ${name} -- ${[pinned, ...args].map((a) => `"${a}"`).join(" ")}`;
|
|
9
|
+
}
|
|
10
|
+
export function registrationProblem(r, pinned) {
|
|
11
|
+
const reRegister = [addCommand(r.scope, r.name, pinned, r.args)];
|
|
12
|
+
if (!r.command.includes("/")) {
|
|
13
|
+
return [
|
|
14
|
+
`runs "${r.command}", which a Node version manager re-resolves per`,
|
|
15
|
+
"directory, so the server can start under a Node that cannot load hive's store.",
|
|
16
|
+
`Re-register with an absolute interpreter, in ${r.source}:`,
|
|
17
|
+
...reRegister,
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
if (r.command !== pinned) {
|
|
21
|
+
return [
|
|
22
|
+
`pins a different interpreter than this CLI runs (${pinned}).`,
|
|
23
|
+
"That is fine only while both were built against the same ABI.",
|
|
24
|
+
`Re-register with this one, in ${r.source}:`,
|
|
25
|
+
...reRegister,
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
function readJson(file) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const userConfigPath = () => join(resolve(process.env.CLAUDE_CONFIG_DIR || homedir()), ".claude.json");
|
|
39
|
+
export const codexConfigPath = () => join(resolve(process.env.CODEX_HOME || join(homedir(), ".codex")), "config.toml");
|
|
40
|
+
const isHive = (name, raw) => name === "hive" ||
|
|
41
|
+
(Array.isArray(raw?.args) &&
|
|
42
|
+
raw.args.some((a) => typeof a === "string" && resolve(a) === serverPath()));
|
|
43
|
+
export function hiveRegistrations(projectPath) {
|
|
44
|
+
const found = [];
|
|
45
|
+
const collect = (servers, scope, source) => {
|
|
46
|
+
if (!servers || typeof servers !== "object")
|
|
47
|
+
return;
|
|
48
|
+
for (const [name, raw] of Object.entries(servers)) {
|
|
49
|
+
const command = raw?.command;
|
|
50
|
+
if (typeof command !== "string")
|
|
51
|
+
continue;
|
|
52
|
+
if (!isHive(name, raw))
|
|
53
|
+
continue;
|
|
54
|
+
const args = Array.isArray(raw.args)
|
|
55
|
+
? raw.args.filter((a) => typeof a === "string")
|
|
56
|
+
: [];
|
|
57
|
+
found.push({ name, scope, source, command, args });
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const claudeJson = userConfigPath();
|
|
61
|
+
const user = readJson(claudeJson);
|
|
62
|
+
collect(user?.mcpServers, "user", claudeJson);
|
|
63
|
+
if (projectPath) {
|
|
64
|
+
collect(user?.projects?.[projectPath]?.mcpServers, "local", claudeJson);
|
|
65
|
+
const projectFile = join(projectPath, ".mcp.json");
|
|
66
|
+
collect(readJson(projectFile)?.mcpServers, "project", projectFile);
|
|
67
|
+
}
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
export function codexHiveRegistrations() {
|
|
71
|
+
const source = codexConfigPath();
|
|
72
|
+
let text;
|
|
73
|
+
try {
|
|
74
|
+
text = readFileSync(source, "utf8");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = parseToml(text);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const servers = parsed.mcp_servers;
|
|
87
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers))
|
|
88
|
+
return [];
|
|
89
|
+
const found = [];
|
|
90
|
+
for (const [name, raw] of Object.entries(servers)) {
|
|
91
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
92
|
+
continue;
|
|
93
|
+
const command = raw.command;
|
|
94
|
+
if (typeof command !== "string")
|
|
95
|
+
continue;
|
|
96
|
+
const argsValue = raw.args;
|
|
97
|
+
const args = Array.isArray(argsValue)
|
|
98
|
+
? argsValue.filter((arg) => typeof arg === "string")
|
|
99
|
+
: [];
|
|
100
|
+
if (isHive(name, { command, args }))
|
|
101
|
+
found.push({ name, source, command, args });
|
|
102
|
+
}
|
|
103
|
+
return found;
|
|
104
|
+
}
|
|
105
|
+
export function registrationOffer(pinned, found) {
|
|
106
|
+
if (found.length > 0)
|
|
107
|
+
return null;
|
|
108
|
+
const source = userConfigPath();
|
|
109
|
+
const servers = readJson(source)?.mcpServers;
|
|
110
|
+
if (!servers || typeof servers !== "object")
|
|
111
|
+
return null;
|
|
112
|
+
if (Object.entries(servers).some(([name, raw]) => isHive(name, raw))) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return [
|
|
116
|
+
`hive's MCP tools are not registered in ${source}.`,
|
|
117
|
+
"The `hive` command and the MCP server are registered separately, so this",
|
|
118
|
+
"adds the server, pinned to the same interpreter:",
|
|
119
|
+
addCommand("user", "hive", pinned, [serverPath()]),
|
|
120
|
+
];
|
|
121
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { getProject } from "./context.js";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { loadProjectYml } from "./projectYml.js";
|
|
4
|
+
import { closeAgentRow, killAgentPane, relayoutAfterPaneLeft } from "./spawn.js";
|
|
5
|
+
import { cancelCopyMode, paneProcessExited, paneReissued, paneVisibility, paneWindow, projectWindows, rowLive, rowLiveProbe, sessionName, tmux, } from "./tmux.js";
|
|
6
|
+
// One derivation for every surface that reports where a project's processes are: `hive status`,
|
|
7
|
+
// `hive doctor`, and the dashboard. Two tmux window lookups per project, then one per running
|
|
8
|
+
// process, and none at all for a project with neither a running command nor a defined one.
|
|
9
|
+
export function snapshotProcesses(projectId) {
|
|
10
|
+
const rows = runningCommandRows(projectId);
|
|
11
|
+
const project = getProject(projectId);
|
|
12
|
+
const defined = project ? Object.keys(loadProjectYml(project.path).config?.processes ?? {}) : [];
|
|
13
|
+
if (rows.length === 0 && defined.length === 0)
|
|
14
|
+
return [];
|
|
15
|
+
const windows = rows.length > 0 ? projectWindows(sessionName(), projectId) : null;
|
|
16
|
+
const running = new Set(rows.map((r) => r.name));
|
|
17
|
+
return [
|
|
18
|
+
...rows.map((r) => ({
|
|
19
|
+
name: r.name,
|
|
20
|
+
running: true,
|
|
21
|
+
// A row recorded on a socket this process cannot see into reads as unknown, never as a
|
|
22
|
+
// location: the pane id would name whatever holds it on THIS server.
|
|
23
|
+
visibility: windows !== null && rowLive(r.tmux_socket, r.tmux_target) === true ? paneVisibility(r.tmux_target, windows) : null,
|
|
24
|
+
startedAt: r.created_at,
|
|
25
|
+
})),
|
|
26
|
+
...defined
|
|
27
|
+
.filter((name) => !running.has(name))
|
|
28
|
+
.map((name) => ({ name, running: false, visibility: null, startedAt: null })),
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
export const COMMAND_KIND = "command";
|
|
32
|
+
export const STOP_GRACE_MS = 2000;
|
|
33
|
+
// The closed set docs/projects.md's "Lifetime" section is checked against (test/docs.test.mjs):
|
|
34
|
+
// every way a hive.yml process can be stopped, named once here and rendered by whoever prints it.
|
|
35
|
+
export const STOP_REASONS = {
|
|
36
|
+
byHand: "hive stop",
|
|
37
|
+
leadSessionEnded: "lead session ended",
|
|
38
|
+
leadPaneExited: "lead pane exited",
|
|
39
|
+
previousLead: "left running by a previous lead",
|
|
40
|
+
};
|
|
41
|
+
const STOPPABLE_COLUMNS = "id, project_id, name, tmux_target, tmux_socket, pane_pid, created_at";
|
|
42
|
+
export function runningCommandRows(projectId) {
|
|
43
|
+
return db
|
|
44
|
+
.prepare(`SELECT ${STOPPABLE_COLUMNS} FROM agents
|
|
45
|
+
WHERE project_id = ? AND status = 'running' AND kind = 'command' ORDER BY created_at`)
|
|
46
|
+
.all(projectId);
|
|
47
|
+
}
|
|
48
|
+
export function runningCommandRow(projectId, name) {
|
|
49
|
+
return db
|
|
50
|
+
.prepare(`SELECT ${STOPPABLE_COLUMNS} FROM agents
|
|
51
|
+
WHERE project_id = ? AND name = ? AND status = 'running' AND kind = 'command'`)
|
|
52
|
+
.get(projectId, name);
|
|
53
|
+
}
|
|
54
|
+
export const STOPPING_MARKER_TTL_SECONDS = 30;
|
|
55
|
+
export const stoppingMarkerKey = (agentId) => `stopping:${agentId}`;
|
|
56
|
+
// Written BEFORE the pane is touched and read by the janitor's sweep. Ordering alone used to carry
|
|
57
|
+
// "this process died on its own", and it could not: an interrupted stop left a closed row over a
|
|
58
|
+
// live process, invisible to every surface, and the next start doubled it (todo 765, measured).
|
|
59
|
+
function markStopping(projectId, agentId) {
|
|
60
|
+
try {
|
|
61
|
+
db.prepare(`INSERT INTO kv (project_id, key, value, updated_by, expires_at)
|
|
62
|
+
VALUES (?, ?, '"stopping"', 'hive', datetime('now', printf('+%d seconds', ?)))
|
|
63
|
+
ON CONFLICT(project_id, key) DO UPDATE SET
|
|
64
|
+
value = excluded.value, updated_at = datetime('now'), expires_at = excluded.expires_at`).run(projectId, stoppingMarkerKey(agentId), STOPPING_MARKER_TTL_SECONDS);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function stoppingMarkerLive(projectId, agentId) {
|
|
70
|
+
try {
|
|
71
|
+
return !!db
|
|
72
|
+
.prepare(`SELECT 1 FROM kv WHERE project_id = ? AND key = ?
|
|
73
|
+
AND (expires_at IS NULL OR expires_at >= datetime('now'))`)
|
|
74
|
+
.get(projectId, stoppingMarkerKey(agentId));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function clearStoppingMarker(projectId, agentId) {
|
|
81
|
+
try {
|
|
82
|
+
db.prepare("DELETE FROM kv WHERE project_id = ? AND key = ?").run(projectId, stoppingMarkerKey(agentId));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const STOP_POLL_MS = 50;
|
|
88
|
+
function waitForPaneToExit(target, timeoutMs) {
|
|
89
|
+
const deadline = Date.now() + timeoutMs;
|
|
90
|
+
for (;;) {
|
|
91
|
+
if (paneProcessExited(target) === true)
|
|
92
|
+
return true;
|
|
93
|
+
const remaining = deadline - Date.now();
|
|
94
|
+
if (remaining <= 0)
|
|
95
|
+
return false;
|
|
96
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(STOP_POLL_MS, remaining));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// The only thing that stops a hive.yml command. kill-pane alone is SIGHUP, which a dev server with
|
|
100
|
+
// detached grandchildren survives while still holding its port, so the graceful leg comes first and
|
|
101
|
+
// the receipt says which one ended it.
|
|
102
|
+
// `reason` is deliberately not read here and is not carried on the receipt: it is a required
|
|
103
|
+
// argument so every caller has to declare its trigger from the closed set docs/projects.md is
|
|
104
|
+
// checked against, and whichever caller prints it already knows which one it passed.
|
|
105
|
+
export function stopProcess(row, reason) {
|
|
106
|
+
const probe = rowLiveProbe(row.tmux_socket, row.tmux_target);
|
|
107
|
+
// A pane hive cannot see is left alone, row and all: closing the row here would hide a process
|
|
108
|
+
// that is still running from every surface that reports one.
|
|
109
|
+
if (probe.live === null)
|
|
110
|
+
return { name: row.name, leg: "unreachable" };
|
|
111
|
+
// A reissued pane id belongs to whoever holds it now, so it is never killed - same compare as
|
|
112
|
+
// janitor() and wake delivery.
|
|
113
|
+
if (probe.live === false || paneReissued(row.pane_pid, probe)) {
|
|
114
|
+
closeAgentRow(row.id);
|
|
115
|
+
return { name: row.name, leg: "already-gone" };
|
|
116
|
+
}
|
|
117
|
+
markStopping(row.project_id, row.id);
|
|
118
|
+
const window = paneWindow(row.tmux_target);
|
|
119
|
+
try {
|
|
120
|
+
// In copy mode C-c is `cancel`, not SIGINT, so the graceful leg would silently do nothing to a
|
|
121
|
+
// pane someone scrolled back in. A stop may take that scrollback position: the process is ending.
|
|
122
|
+
cancelCopyMode(row.tmux_target);
|
|
123
|
+
tmux("send-keys", "-t", row.tmux_target, "C-c");
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
}
|
|
127
|
+
let leg = "interrupted";
|
|
128
|
+
if (!waitForPaneToExit(row.tmux_target, STOP_GRACE_MS)) {
|
|
129
|
+
try {
|
|
130
|
+
killAgentPane(row.tmux_target);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
}
|
|
134
|
+
leg = waitForPaneToExit(row.tmux_target, STOP_GRACE_MS) ? "killed" : "still-running";
|
|
135
|
+
}
|
|
136
|
+
// The row closes only once the pane is confirmed gone. A stop that did not finish leaves the row
|
|
137
|
+
// RUNNING, so the process stays visible to hive status, hive stop and the janitor.
|
|
138
|
+
if (leg === "still-running")
|
|
139
|
+
return { name: row.name, leg };
|
|
140
|
+
closeAgentRow(row.id, row.tmux_target);
|
|
141
|
+
clearStoppingMarker(row.project_id, row.id);
|
|
142
|
+
relayoutAfterPaneLeft(window);
|
|
143
|
+
return { name: row.name, leg };
|
|
144
|
+
}
|
|
145
|
+
export function stopAllProcesses(projectId, reason) {
|
|
146
|
+
return runningCommandRows(projectId).map((row) => stopProcess(row, reason));
|
|
147
|
+
}
|
|
148
|
+
export function stopLine(stopped) {
|
|
149
|
+
switch (stopped.leg) {
|
|
150
|
+
case "interrupted":
|
|
151
|
+
return `${stopped.name}: stopped (C-c)`;
|
|
152
|
+
case "killed":
|
|
153
|
+
return `${stopped.name}: stopped (killed after ${STOP_GRACE_MS / 1000}s)`;
|
|
154
|
+
case "already-gone":
|
|
155
|
+
return `${stopped.name}: already gone (nothing was running)`;
|
|
156
|
+
case "unreachable":
|
|
157
|
+
return `${stopped.name}: tmux could not be probed, so hive left it running`;
|
|
158
|
+
case "still-running":
|
|
159
|
+
return `${stopped.name}: still running: its pane survived C-c and kill-pane, so hive left the row open`;
|
|
160
|
+
}
|
|
161
|
+
}
|
package/dist/profiles.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { storeDir } from "./dataDir.js";
|
|
6
|
+
export const PROFILE_FILES = ["posture.md", "runbook.md", "worker.md"];
|
|
7
|
+
export const checkoutRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
export const shippedProfilesDir = join(checkoutRoot, "profiles");
|
|
9
|
+
export const userProfilesDir = () => join(storeDir(), "profiles");
|
|
10
|
+
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
11
|
+
export function isValidProfileName(name) {
|
|
12
|
+
return NAME_PATTERN.test(name) && !name.includes("..");
|
|
13
|
+
}
|
|
14
|
+
const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.md$/;
|
|
15
|
+
export function isValidProfileFileName(file) {
|
|
16
|
+
return FILE_NAME_PATTERN.test(file) && !file.includes("..");
|
|
17
|
+
}
|
|
18
|
+
function candidate(dir, name, file) {
|
|
19
|
+
return join(dir, name, file);
|
|
20
|
+
}
|
|
21
|
+
export function resolveProfileFile(name, file) {
|
|
22
|
+
if (!isValidProfileName(name) || !isValidProfileFileName(file))
|
|
23
|
+
return null;
|
|
24
|
+
const user = candidate(userProfilesDir(), name, file);
|
|
25
|
+
if (existsSync(user))
|
|
26
|
+
return { file, path: user, source: "user" };
|
|
27
|
+
const shipped = candidate(shippedProfilesDir, name, file);
|
|
28
|
+
if (existsSync(shipped))
|
|
29
|
+
return { file, path: shipped, source: "shipped" };
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
export function readProfileFile(name, file) {
|
|
33
|
+
const resolved = resolveProfileFile(name, file);
|
|
34
|
+
if (!resolved)
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
return readFileSync(resolved.path, "utf8");
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function renderProfileFile(name, file, vars) {
|
|
44
|
+
const template = readProfileFile(name, file);
|
|
45
|
+
return template == null ? null : renderTemplate(template, vars);
|
|
46
|
+
}
|
|
47
|
+
export function profileExists(name) {
|
|
48
|
+
if (!isValidProfileName(name))
|
|
49
|
+
return false;
|
|
50
|
+
return existsSync(join(userProfilesDir(), name)) || existsSync(join(shippedProfilesDir, name));
|
|
51
|
+
}
|
|
52
|
+
function namesIn(dir) {
|
|
53
|
+
try {
|
|
54
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
55
|
+
.filter((e) => e.isDirectory() && isValidProfileName(e.name))
|
|
56
|
+
.map((e) => e.name);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function profileNames() {
|
|
63
|
+
return [...new Set([...namesIn(shippedProfilesDir), ...namesIn(userProfilesDir())])].sort();
|
|
64
|
+
}
|
|
65
|
+
function mdFilesIn(dir) {
|
|
66
|
+
try {
|
|
67
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
68
|
+
.filter((e) => e.isFile() && isValidProfileFileName(e.name))
|
|
69
|
+
.map((e) => e.name);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function profileFileNames(name) {
|
|
76
|
+
if (!isValidProfileName(name))
|
|
77
|
+
return [];
|
|
78
|
+
const present = new Set([
|
|
79
|
+
...mdFilesIn(join(shippedProfilesDir, name)),
|
|
80
|
+
...mdFilesIn(join(userProfilesDir(), name)),
|
|
81
|
+
]);
|
|
82
|
+
const known = PROFILE_FILES.filter((f) => present.has(f));
|
|
83
|
+
const extra = [...present].filter((f) => !PROFILE_FILES.includes(f)).sort();
|
|
84
|
+
return [...known, ...extra];
|
|
85
|
+
}
|
|
86
|
+
const ORIGIN_FILE = ".hive-origin.json";
|
|
87
|
+
function contentHash(path) {
|
|
88
|
+
try {
|
|
89
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 16);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function readOrigins(name) {
|
|
96
|
+
try {
|
|
97
|
+
const raw = JSON.parse(readFileSync(join(userProfilesDir(), name, ORIGIN_FILE), "utf8"));
|
|
98
|
+
return raw && typeof raw === "object" ? raw : {};
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function writeOrigin(name, file, hash) {
|
|
105
|
+
if (hash == null)
|
|
106
|
+
return;
|
|
107
|
+
const origins = readOrigins(name);
|
|
108
|
+
origins[file] = hash;
|
|
109
|
+
writeFileSync(join(userProfilesDir(), name, ORIGIN_FILE), `${JSON.stringify(origins, null, 2)}\n`);
|
|
110
|
+
}
|
|
111
|
+
function readFileSafe(path) {
|
|
112
|
+
try {
|
|
113
|
+
return readFileSync(path, "utf8");
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function lineDivergence(a, b) {
|
|
120
|
+
const linesA = a.split("\n");
|
|
121
|
+
const linesB = b.split("\n");
|
|
122
|
+
const counts = new Map();
|
|
123
|
+
for (const line of linesA)
|
|
124
|
+
counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
125
|
+
let common = 0;
|
|
126
|
+
for (const line of linesB) {
|
|
127
|
+
const n = counts.get(line) ?? 0;
|
|
128
|
+
if (n > 0) {
|
|
129
|
+
common += 1;
|
|
130
|
+
counts.set(line, n - 1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const total = linesA.length + linesB.length;
|
|
134
|
+
return total === 0 ? 0 : 1 - (2 * common) / total;
|
|
135
|
+
}
|
|
136
|
+
export const REWRITE_THRESHOLD = 0.5;
|
|
137
|
+
export function profileStatus(name) {
|
|
138
|
+
const origins = readOrigins(name);
|
|
139
|
+
const files = [];
|
|
140
|
+
for (const file of profileFileNames(name)) {
|
|
141
|
+
const resolved = resolveProfileFile(name, file);
|
|
142
|
+
if (!resolved)
|
|
143
|
+
continue;
|
|
144
|
+
const shippedPath = candidate(shippedProfilesDir, name, file);
|
|
145
|
+
const shippedNow = contentHash(shippedPath);
|
|
146
|
+
let divergence = null;
|
|
147
|
+
if (resolved.source === "user") {
|
|
148
|
+
const userText = readFileSafe(resolved.path);
|
|
149
|
+
const shippedText = readFileSafe(shippedPath);
|
|
150
|
+
if (userText != null && shippedText != null)
|
|
151
|
+
divergence = lineDivergence(userText, shippedText);
|
|
152
|
+
}
|
|
153
|
+
files.push({
|
|
154
|
+
...resolved,
|
|
155
|
+
upstreamMoved: resolved.source === "user" && origins[file] != null && shippedNow != null && shippedNow !== origins[file],
|
|
156
|
+
divergence,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return { name, files };
|
|
160
|
+
}
|
|
161
|
+
export class ProfileError extends Error {
|
|
162
|
+
}
|
|
163
|
+
function requireName(name) {
|
|
164
|
+
if (!isValidProfileName(name)) {
|
|
165
|
+
throw new ProfileError(`"${name}" is not a valid profile name (letters, digits, dot, dash, underscore; no path separators).`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export function forkProfile(name, only) {
|
|
169
|
+
requireName(name);
|
|
170
|
+
if (!profileExists(name))
|
|
171
|
+
throw new ProfileError(`No profile named "${name}". List them with: hive profile list`);
|
|
172
|
+
const targetDir = join(userProfilesDir(), name);
|
|
173
|
+
mkdirSync(targetDir, { recursive: true });
|
|
174
|
+
const copied = [];
|
|
175
|
+
const skipped = [];
|
|
176
|
+
for (const file of only ? [only] : PROFILE_FILES) {
|
|
177
|
+
const source = candidate(shippedProfilesDir, name, file);
|
|
178
|
+
if (!existsSync(source)) {
|
|
179
|
+
if (only)
|
|
180
|
+
throw new ProfileError(`Profile "${name}" ships no ${file}.`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const target = candidate(userProfilesDir(), name, file);
|
|
184
|
+
if (existsSync(target)) {
|
|
185
|
+
skipped.push(file);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
copyFileSync(source, target);
|
|
189
|
+
writeOrigin(name, file, contentHash(source));
|
|
190
|
+
copied.push(file);
|
|
191
|
+
}
|
|
192
|
+
return { copied, skipped };
|
|
193
|
+
}
|
|
194
|
+
export function createProfile(name, from) {
|
|
195
|
+
requireName(name);
|
|
196
|
+
const existing = join(userProfilesDir(), name);
|
|
197
|
+
if (existsSync(existing)) {
|
|
198
|
+
throw new ProfileError(`You already have a profile named "${name}" at ${existing}.`);
|
|
199
|
+
}
|
|
200
|
+
if (from != null && !profileExists(from)) {
|
|
201
|
+
throw new ProfileError(`No profile named "${from}" to copy from.`);
|
|
202
|
+
}
|
|
203
|
+
const targetDir = join(userProfilesDir(), name);
|
|
204
|
+
mkdirSync(targetDir, { recursive: true });
|
|
205
|
+
if (from != null) {
|
|
206
|
+
for (const file of PROFILE_FILES) {
|
|
207
|
+
const resolved = resolveProfileFile(from, file);
|
|
208
|
+
if (resolved)
|
|
209
|
+
copyFileSync(resolved.path, join(targetDir, file));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
writeFileSync(join(targetDir, "posture.md"), `# ${name} posture\n\n<how a session running this profile should behave>\n`);
|
|
214
|
+
}
|
|
215
|
+
return targetDir;
|
|
216
|
+
}
|
|
217
|
+
const VAR_REF = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
|
218
|
+
const IF_LINE = /^[ \t]*<!--\s*if:([A-Za-z_][A-Za-z0-9_]*)\s*-->[ \t]*$/;
|
|
219
|
+
const END_LINE = /^[ \t]*<!--\s*end\s*-->[ \t]*$/;
|
|
220
|
+
const isSet = (vars, key) => vars[key] != null && String(vars[key]).trim() !== "";
|
|
221
|
+
function renderConditionals(text, vars) {
|
|
222
|
+
const out = [];
|
|
223
|
+
const stack = [];
|
|
224
|
+
for (const line of text.split("\n")) {
|
|
225
|
+
const open = IF_LINE.exec(line);
|
|
226
|
+
if (open) {
|
|
227
|
+
stack.push(isSet(vars, open[1]));
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (END_LINE.test(line)) {
|
|
231
|
+
stack.pop();
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (stack.every(Boolean))
|
|
235
|
+
out.push(line);
|
|
236
|
+
}
|
|
237
|
+
return out.join("\n");
|
|
238
|
+
}
|
|
239
|
+
export function renderTemplate(text, vars) {
|
|
240
|
+
return (renderConditionals(text, vars)
|
|
241
|
+
.replace(VAR_REF, (match, key) => (isSet(vars, key) ? vars[key] : match))
|
|
242
|
+
.replace(/\n{3,}/g, "\n\n"));
|
|
243
|
+
}
|
|
244
|
+
export function templateVars(text) {
|
|
245
|
+
const found = new Set();
|
|
246
|
+
for (const line of text.split("\n")) {
|
|
247
|
+
const open = IF_LINE.exec(line);
|
|
248
|
+
if (open)
|
|
249
|
+
found.add(open[1]);
|
|
250
|
+
}
|
|
251
|
+
for (const match of text.matchAll(VAR_REF))
|
|
252
|
+
found.add(match[1]);
|
|
253
|
+
return [...found].sort();
|
|
254
|
+
}
|
|
255
|
+
const PAD_NAME = "[A-Za-z0-9][A-Za-z0-9_.-]*";
|
|
256
|
+
const HIVE_PAD_CMD = new RegExp(`\`hive pad (${PAD_NAME})`, "g");
|
|
257
|
+
const PAD_TOOL_CALL = /\bpad_(?:read|write)\([^)]*name\s*=\s*"([^"]+)"/g;
|
|
258
|
+
const QUOTED_PAD_BEFORE = new RegExp(`"(${PAD_NAME})"\\s+pad\\b`, "gi");
|
|
259
|
+
const QUOTED_PAD_AFTER = new RegExp(`\\bpad\\s+"(${PAD_NAME})"`, "gi");
|
|
260
|
+
export function referencedPads(text) {
|
|
261
|
+
const found = new Set();
|
|
262
|
+
for (const re of [HIVE_PAD_CMD, PAD_TOOL_CALL, QUOTED_PAD_BEFORE, QUOTED_PAD_AFTER]) {
|
|
263
|
+
for (const m of text.matchAll(re))
|
|
264
|
+
found.add(m[1]);
|
|
265
|
+
}
|
|
266
|
+
return [...found].sort();
|
|
267
|
+
}
|
|
268
|
+
const PATH_TOKEN = /^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.*-]+)+\/?$/;
|
|
269
|
+
export function referencedPaths(text) {
|
|
270
|
+
const found = new Set();
|
|
271
|
+
for (const m of text.matchAll(/`([^`\n]+)`/g)) {
|
|
272
|
+
const token = m[1].trim();
|
|
273
|
+
if (!PATH_TOKEN.test(token))
|
|
274
|
+
continue;
|
|
275
|
+
found.add(token);
|
|
276
|
+
}
|
|
277
|
+
return [...found].sort();
|
|
278
|
+
}
|