@primitive.ai/prim 0.1.0-alpha.28 → 0.1.0-alpha.30

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.
@@ -1,3 +1,7 @@
1
+ import {
2
+ getSiteUrl
3
+ } from "./chunk-26VA3ADF.js";
4
+
1
5
  // src/journal.ts
2
6
  import {
3
7
  appendFileSync,
@@ -10,9 +14,15 @@ import {
10
14
  import { homedir } from "os";
11
15
  import { dirname, join } from "path";
12
16
  var JOURNAL_DIR = join(homedir(), ".config", "prim", "moves");
13
- var LEGACY_JOURNAL_PATH = join(JOURNAL_DIR, "journal.ndjson");
14
17
  var UNBOUND_BUCKET = "_unbound";
15
18
  var JOURNAL_BASENAME = "journal.ndjson";
19
+ function envSlug(apiUrl) {
20
+ const slug = apiUrl.replace(/^https?:\/\//, "").replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
21
+ return slug.length > 0 ? slug : "default";
22
+ }
23
+ function currentEnvDir() {
24
+ return join(JOURNAL_DIR, envSlug(getSiteUrl()));
25
+ }
16
26
  var DIR_MODE = 448;
17
27
  var FILE_MODE = 384;
18
28
  var SAFE_BUCKET = /^[A-Za-z0-9_-]+$/;
@@ -27,7 +37,7 @@ function appendMoveToPath(path, move) {
27
37
  }
28
38
  function bucketDir(orgId) {
29
39
  const safe = orgId !== void 0 && SAFE_BUCKET.test(orgId) && !RESERVED_BUCKETS.has(orgId);
30
- return join(JOURNAL_DIR, safe ? orgId : UNBOUND_BUCKET);
40
+ return join(currentEnvDir(), safe ? orgId : UNBOUND_BUCKET);
31
41
  }
32
42
  function journalPath(orgId) {
33
43
  return join(bucketDir(orgId), JOURNAL_BASENAME);
@@ -54,23 +64,63 @@ function readMovesFromPath(path) {
54
64
  }
55
65
  function listBuckets() {
56
66
  const out = [];
57
- if (existsSync(LEGACY_JOURNAL_PATH)) {
58
- out.push({ bucket: "_legacy", path: LEGACY_JOURNAL_PATH });
59
- }
60
- if (!existsSync(JOURNAL_DIR)) {
67
+ const envDir = currentEnvDir();
68
+ if (!existsSync(envDir)) {
61
69
  return out;
62
70
  }
63
- for (const entry of readdirSync(JOURNAL_DIR, { withFileTypes: true })) {
71
+ for (const entry of readdirSync(envDir, { withFileTypes: true })) {
64
72
  if (!entry.isDirectory()) {
65
73
  continue;
66
74
  }
67
- const path = join(JOURNAL_DIR, entry.name, JOURNAL_BASENAME);
75
+ const path = join(envDir, entry.name, JOURNAL_BASENAME);
68
76
  if (existsSync(path)) {
69
77
  out.push({ bucket: entry.name, path });
70
78
  }
71
79
  }
72
80
  return out;
73
81
  }
82
+ var FLUSHING_PREFIX = `${JOURNAL_BASENAME}.flushing.`;
83
+ function parseFlushingPid(name) {
84
+ const segments = name.slice(FLUSHING_PREFIX.length).split(".");
85
+ const last = segments.length >= 2 ? segments[segments.length - 1] : void 0;
86
+ return last !== void 0 && /^[0-9]+$/.test(last) ? Number(last) : void 0;
87
+ }
88
+ function listFlushingInDir(dir, bucket) {
89
+ if (!existsSync(dir)) {
90
+ return [];
91
+ }
92
+ const out = [];
93
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
94
+ if (!entry.isFile() || !entry.name.startsWith(FLUSHING_PREFIX)) {
95
+ continue;
96
+ }
97
+ const path = join(dir, entry.name);
98
+ const stat = statSync(path);
99
+ const lineCount = readFileSync(path, "utf-8").split("\n").filter((l) => l.length > 0).length;
100
+ out.push({
101
+ bucket,
102
+ path,
103
+ pid: parseFlushingPid(entry.name),
104
+ sizeBytes: stat.size,
105
+ mtimeMs: stat.mtimeMs,
106
+ lineCount
107
+ });
108
+ }
109
+ return out;
110
+ }
111
+ function listFlushing() {
112
+ const envDir = currentEnvDir();
113
+ if (!existsSync(envDir)) {
114
+ return [];
115
+ }
116
+ const out = [];
117
+ for (const entry of readdirSync(envDir, { withFileTypes: true })) {
118
+ if (entry.isDirectory()) {
119
+ out.push(...listFlushingInDir(join(envDir, entry.name), entry.name));
120
+ }
121
+ }
122
+ return out;
123
+ }
74
124
  function bucketStats() {
75
125
  return listBuckets().map(({ bucket, path }) => {
76
126
  const stat = statSync(path);
@@ -193,6 +243,7 @@ export {
193
243
  appendMove,
194
244
  readMovesFromPath,
195
245
  listBuckets,
246
+ listFlushing,
196
247
  bucketStats,
197
248
  SESSIONS_DIR,
198
249
  resolveOrg
@@ -1,5 +1,6 @@
1
1
  import {
2
- getClient
2
+ getClient,
3
+ getSiteUrl
3
4
  } from "./chunk-26VA3ADF.js";
4
5
  import {
5
6
  daemonRequest
@@ -9,9 +10,11 @@ import {
9
10
  var DAEMON_HTTP_TIMEOUT_MS = 1e4;
10
11
  var DAEMON_PROBE_TIMEOUT_MS = 250;
11
12
  async function daemonOrDirect(method, params, direct) {
12
- const fromDaemon = await daemonRequest(method, params, {
13
- timeoutMs: DAEMON_PROBE_TIMEOUT_MS
14
- });
13
+ const fromDaemon = await daemonRequest(
14
+ method,
15
+ { ...params, callerEnv: getSiteUrl() },
16
+ { timeoutMs: DAEMON_PROBE_TIMEOUT_MS }
17
+ );
15
18
  if (fromDaemon !== null) {
16
19
  return fromDaemon;
17
20
  }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  getClient,
4
+ getSiteUrl,
4
5
  getTokenExpiresAt,
5
6
  refreshToken
6
7
  } from "../chunk-26VA3ADF.js";
@@ -10,6 +11,24 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "
10
11
  import { createServer } from "net";
11
12
  import { homedir } from "os";
12
13
  import { join } from "path";
14
+
15
+ // src/daemon/env-binding.ts
16
+ function normalizeApiUrl(url) {
17
+ return url.trim().replace(/\/+$/, "");
18
+ }
19
+ function apiUrlsMatch(a, b) {
20
+ return normalizeApiUrl(a) === normalizeApiUrl(b);
21
+ }
22
+ function isCrossEnv(callerEnv, boundEnv) {
23
+ return typeof callerEnv === "string" && !apiUrlsMatch(callerEnv, boundEnv);
24
+ }
25
+ function assertCallerEnvMatches(callerEnv, boundEnv) {
26
+ if (isCrossEnv(callerEnv, boundEnv)) {
27
+ throw new Error(`env mismatch: daemon is bound to ${boundEnv}, caller targets ${callerEnv}`);
28
+ }
29
+ }
30
+
31
+ // src/daemon/server.ts
13
32
  var CONFIG_DIR = join(homedir(), ".config", "prim");
14
33
  var SOCK_PATH = join(CONFIG_DIR, "sock");
15
34
  var PID_PATH = join(CONFIG_DIR, "daemon.pid");
@@ -94,6 +113,7 @@ async function ensureTokenFresh() {
94
113
  }
95
114
  }
96
115
  async function handleConflictCheck(params) {
116
+ assertCallerEnvMatches(params.callerEnv, getSiteUrl());
97
117
  if (typeof params.file !== "string") {
98
118
  throw new Error("conflict_check requires `file: string`");
99
119
  }
@@ -111,18 +131,31 @@ function assertEndpointPath(path, endpoint) {
111
131
  }
112
132
  }
113
133
  async function proxyGet(params, allowedPrefix) {
134
+ assertCallerEnvMatches(params.callerEnv, getSiteUrl());
114
135
  const path = pathParam(params);
115
136
  assertEndpointPath(path, allowedPrefix);
116
137
  return await client.get(path, { signal: AbortSignal.timeout(HTTP_PROXY_TIMEOUT_MS) });
117
138
  }
118
- function handleStatusSnapshot() {
119
- const presenceFresh = lastOkAtLocal !== void 0 && Date.now() - lastOkAtLocal < PRESENCE_FRESH_WINDOW_MS;
120
- const presenceStale = lastOkAtLocal !== void 0 && !presenceFresh;
121
- return {
139
+ function handleStatusSnapshot(params) {
140
+ const base = {
122
141
  pid: process.pid,
123
142
  uptimeMs: Date.now() - startedAt,
124
143
  sessionId: activeSessionId,
125
- lastHeartbeatAt,
144
+ lastHeartbeatAt
145
+ };
146
+ if (isCrossEnv(params.callerEnv, getSiteUrl())) {
147
+ return {
148
+ ...base,
149
+ onlineCount: void 0,
150
+ onlineNames: void 0,
151
+ presenceStale: false,
152
+ envMismatch: true
153
+ };
154
+ }
155
+ const presenceFresh = lastOkAtLocal !== void 0 && Date.now() - lastOkAtLocal < PRESENCE_FRESH_WINDOW_MS;
156
+ const presenceStale = lastOkAtLocal !== void 0 && !presenceFresh;
157
+ return {
158
+ ...base,
126
159
  // Withhold a frozen count/names once they're no longer fresh; the
127
160
  // statusline shows "presence: stale" rather than a confident, wrong list.
128
161
  onlineCount: presenceFresh ? lastOnlineCount : void 0,
@@ -166,7 +199,7 @@ async function dispatchRequest(req) {
166
199
  return { id, ok: true, result: { ack: true } };
167
200
  }
168
201
  case "status_snapshot":
169
- return { id, ok: true, result: handleStatusSnapshot() };
202
+ return { id, ok: true, result: handleStatusSnapshot(req.params ?? {}) };
170
203
  case "ping":
171
204
  return { id, ok: true, result: { pong: true } };
172
205
  default:
@@ -2,10 +2,11 @@
2
2
  import {
3
3
  appendMove,
4
4
  resolveOrg
5
- } from "../chunk-JZGWQDM5.js";
5
+ } from "../chunk-C4UYMR7X.js";
6
6
  import {
7
7
  toCommitMove
8
8
  } from "../chunk-7GHOFNJ2.js";
9
+ import "../chunk-26VA3ADF.js";
9
10
 
10
11
  // src/hooks/post-commit.ts
11
12
  import { execSync, spawn } from "child_process";
@@ -3,9 +3,6 @@ import {
3
3
  bold,
4
4
  color
5
5
  } from "../chunk-4QJOQIY6.js";
6
- import {
7
- getClient
8
- } from "../chunk-26VA3ADF.js";
9
6
  import {
10
7
  scrubFromCwd
11
8
  } from "../chunk-6LAQVM26.js";
@@ -15,6 +12,9 @@ import {
15
12
  import {
16
13
  parseAgent
17
14
  } from "../chunk-7YRBACIE.js";
15
+ import {
16
+ getClient
17
+ } from "../chunk-26VA3ADF.js";
18
18
 
19
19
  // src/hooks/post-tool-use.ts
20
20
  import { readFileSync } from "fs";
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  checkAffectedDecisions,
4
4
  formatDecisionsWarning
5
- } from "../chunk-E5UZXMZL.js";
5
+ } from "../chunk-UWAJCF7P.js";
6
6
  import "../chunk-26VA3ADF.js";
7
7
  import "../chunk-UTKQTZHL.js";
8
8
 
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- getClient
4
- } from "../chunk-26VA3ADF.js";
5
2
  import {
6
3
  parseAgent
7
4
  } from "../chunk-7YRBACIE.js";
5
+ import {
6
+ getClient,
7
+ getSiteUrl
8
+ } from "../chunk-26VA3ADF.js";
8
9
  import {
9
10
  daemonRequest
10
11
  } from "../chunk-UTKQTZHL.js";
@@ -199,7 +200,9 @@ function emit(output) {
199
200
  async function checkOneFile(file) {
200
201
  const fromDaemon = await daemonRequest(
201
202
  "conflict_check",
202
- { file },
203
+ // callerEnv lets a staging-bound daemon refuse a prod-context gate check
204
+ // (and vice versa) so we fall through to a direct call against our own env.
205
+ { file, callerEnv: getSiteUrl() },
203
206
  { timeoutMs: DAEMON_TIMEOUT_MS }
204
207
  );
205
208
  if (fromDaemon) {
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  appendMove,
4
4
  resolveOrg
5
- } from "../chunk-JZGWQDM5.js";
5
+ } from "../chunk-C4UYMR7X.js";
6
6
  import {
7
7
  scrubFromCwd
8
8
  } from "../chunk-6LAQVM26.js";
@@ -13,6 +13,7 @@ import {
13
13
  import {
14
14
  parseAgent
15
15
  } from "../chunk-7YRBACIE.js";
16
+ import "../chunk-26VA3ADF.js";
16
17
 
17
18
  // src/hooks/prim-hook.ts
18
19
  import { spawn } from "child_process";
@@ -2,6 +2,9 @@
2
2
  import {
3
3
  parseAgent
4
4
  } from "../chunk-7YRBACIE.js";
5
+ import {
6
+ getSiteUrl
7
+ } from "../chunk-26VA3ADF.js";
5
8
  import {
6
9
  daemonRequest
7
10
  } from "../chunk-UTKQTZHL.js";
@@ -71,7 +74,9 @@ async function main() {
71
74
  if (parseAgent(process.argv) === "codex") {
72
75
  const snapshot = await daemonRequest(
73
76
  "status_snapshot",
74
- {},
77
+ // callerEnv: a cross-env daemon withholds onlineCount, so a prod Codex
78
+ // session never gets a staging daemon's team count injected.
79
+ { callerEnv: getSiteUrl() },
75
80
  { timeoutMs: DAEMON_TIMEOUT_MS }
76
81
  );
77
82
  if (snapshot && !snapshot.presenceStale && typeof snapshot.onlineCount === "number") {
package/dist/index.js CHANGED
@@ -10,7 +10,15 @@ import {
10
10
  checkAffectedDecisions,
11
11
  daemonOrDirectGet,
12
12
  formatDecisionsWarning
13
- } from "./chunk-E5UZXMZL.js";
13
+ } from "./chunk-UWAJCF7P.js";
14
+ import {
15
+ JOURNAL_DIR,
16
+ SESSIONS_DIR,
17
+ bucketStats,
18
+ listBuckets,
19
+ listFlushing,
20
+ readMovesFromPath
21
+ } from "./chunk-C4UYMR7X.js";
14
22
  import {
15
23
  HttpError,
16
24
  REFRESH_TOKEN_PATH,
@@ -22,13 +30,6 @@ import {
22
30
  getTokenExpiresAt,
23
31
  saveTokenExpiry
24
32
  } from "./chunk-26VA3ADF.js";
25
- import {
26
- JOURNAL_DIR,
27
- SESSIONS_DIR,
28
- bucketStats,
29
- listBuckets,
30
- readMovesFromPath
31
- } from "./chunk-JZGWQDM5.js";
32
33
  import {
33
34
  daemonIsLive,
34
35
  daemonRequest
@@ -851,7 +852,7 @@ ${line("project", result.project)}`);
851
852
 
852
853
  // src/commands/daemon.ts
853
854
  import { spawn } from "child_process";
854
- import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync } from "fs";
855
+ import { closeSync as closeSync2, existsSync as existsSync4, mkdirSync as mkdirSync3, openSync as openSync2, readFileSync as readFileSync4, unlinkSync } from "fs";
855
856
  import { homedir as homedir3 } from "os";
856
857
  import { join as join4 } from "path";
857
858
 
@@ -871,8 +872,12 @@ function formatTeammates(names, cap) {
871
872
 
872
873
  // src/commands/daemon.ts
873
874
  var DAEMON_BIN = "prim-daemon-server";
874
- var PID_PATH = join4(homedir3(), ".config", "prim", "daemon.pid");
875
- var SOCK_PATH = join4(homedir3(), ".config", "prim", "sock");
875
+ var CONFIG_DIR = join4(homedir3(), ".config", "prim");
876
+ var PID_PATH = join4(CONFIG_DIR, "daemon.pid");
877
+ var SOCK_PATH = join4(CONFIG_DIR, "sock");
878
+ var LOG_PATH = join4(CONFIG_DIR, "daemon.log");
879
+ var CONFIG_DIR_MODE = 448;
880
+ var LOG_FILE_MODE = 384;
876
881
  var STOP_TIMEOUT_MS = 5e3;
877
882
  var STOP_POLL_MS = 100;
878
883
  var STATUS_PROBE_TIMEOUT_MS = 500;
@@ -921,6 +926,12 @@ function spawnDaemon(options) {
921
926
  const file = binFile(DAEMON_BIN);
922
927
  return file ? spawn(process.execPath, [file], options) : spawn(DAEMON_BIN, [], options);
923
928
  }
929
+ function openDaemonLog(configDir = CONFIG_DIR) {
930
+ if (!existsSync4(configDir)) {
931
+ mkdirSync3(configDir, { recursive: true, mode: CONFIG_DIR_MODE });
932
+ }
933
+ return openSync2(join4(configDir, "daemon.log"), "a", LOG_FILE_MODE);
934
+ }
924
935
  async function waitForReady() {
925
936
  const deadline = Date.now() + READY_TIMEOUT_MS;
926
937
  while (Date.now() < deadline) {
@@ -949,8 +960,20 @@ async function daemonStart(opts) {
949
960
  });
950
961
  return;
951
962
  }
952
- const child = spawnDaemon({ detached: true, stdio: ["ignore", "ignore", "ignore"] });
963
+ let logFd;
964
+ try {
965
+ logFd = openDaemonLog();
966
+ } catch {
967
+ logFd = void 0;
968
+ }
969
+ const child = spawnDaemon({
970
+ detached: true,
971
+ stdio: logFd === void 0 ? ["ignore", "ignore", "ignore"] : ["ignore", logFd, logFd]
972
+ });
953
973
  child.unref();
974
+ if (logFd !== void 0) {
975
+ closeSync2(logFd);
976
+ }
954
977
  const live = await waitForReady();
955
978
  if (live) {
956
979
  const after = readPidfile();
@@ -962,7 +985,7 @@ async function daemonStart(opts) {
962
985
  return;
963
986
  }
964
987
  process.stderr.write(
965
- `[prim] \u2717 daemon start: spawned but the socket did not respond within ${READY_TIMEOUT_MS}ms (check that \`${DAEMON_BIN}\` resolves, and see its log)
988
+ `[prim] \u2717 daemon start: spawned but the socket did not respond within ${READY_TIMEOUT_MS}ms (check that \`${DAEMON_BIN}\` resolves, and see ${LOG_PATH})
966
989
  `
967
990
  );
968
991
  console.log(JSON.stringify({ started: false }, null, 2));
@@ -1860,9 +1883,124 @@ function registerDecisionsCommands(program2) {
1860
1883
  });
1861
1884
  }
1862
1885
 
1886
+ // src/commands/doctor.ts
1887
+ import { existsSync as existsSync5 } from "fs";
1888
+ var DAEMON_PROBE_TIMEOUT_MS = 500;
1889
+ var CONNECTIVITY_TIMEOUT_MS = 3e3;
1890
+ var MS_PER_SECOND = 1e3;
1891
+ var STALE_PENDING_MS = 6e4;
1892
+ var EXIT_UNHEALTHY = 1;
1893
+ function classifyDoctor(checks) {
1894
+ const status = checks.some((c) => c.status === "fail") ? "fail" : checks.some((c) => c.status === "warn") ? "warn" : "ok";
1895
+ return {
1896
+ json: { ok: status !== "fail", status, checks },
1897
+ exitCode: status === "fail" ? EXIT_UNHEALTHY : 0
1898
+ };
1899
+ }
1900
+ function checkAuth() {
1901
+ if (!getAuthToken()) {
1902
+ return { name: "auth", status: "fail", detail: "no token \u2014 run `prim auth login`" };
1903
+ }
1904
+ const expiresAt = getTokenExpiresAt();
1905
+ const hasRefresh = existsSync5(REFRESH_TOKEN_PATH);
1906
+ if (expiresAt !== void 0 && Date.now() >= expiresAt) {
1907
+ return hasRefresh ? { name: "auth", status: "warn", detail: "access token expired (refresh available)" } : {
1908
+ name: "auth",
1909
+ status: "fail",
1910
+ detail: "token expired, no refresh \u2014 run `prim auth login`"
1911
+ };
1912
+ }
1913
+ if (!hasRefresh) {
1914
+ return { name: "auth", status: "warn", detail: "no refresh token \u2014 capture stops at expiry" };
1915
+ }
1916
+ const detail = expiresAt !== void 0 ? `valid (${String(Math.round((expiresAt - Date.now()) / MS_PER_SECOND))}s left)` : "valid";
1917
+ return { name: "auth", status: "ok", detail };
1918
+ }
1919
+ async function checkDaemon() {
1920
+ const live = await daemonIsLive(DAEMON_PROBE_TIMEOUT_MS);
1921
+ return live ? { name: "daemon", status: "ok", detail: "live" } : {
1922
+ name: "daemon",
1923
+ status: "warn",
1924
+ detail: "down \u2014 capture still journals; drains on next `prim` invocation"
1925
+ };
1926
+ }
1927
+ function checkJournal() {
1928
+ const stats = bucketStats();
1929
+ const pending = stats.reduce((n, s) => n + s.lineCount, 0);
1930
+ if (pending === 0) {
1931
+ return { name: "journal", status: "ok", detail: "no pending moves" };
1932
+ }
1933
+ const oldestMs = Math.max(...stats.map((s) => Date.now() - s.mtimeMs));
1934
+ const oldestS = Math.round(oldestMs / MS_PER_SECOND);
1935
+ if (oldestMs > STALE_PENDING_MS) {
1936
+ return {
1937
+ name: "journal",
1938
+ status: "warn",
1939
+ detail: `${String(pending)} pending, oldest ${String(oldestS)}s \u2014 drain may be stalled`
1940
+ };
1941
+ }
1942
+ return { name: "journal", status: "ok", detail: `${String(pending)} pending, draining` };
1943
+ }
1944
+ function checkStranded() {
1945
+ const stranded = listFlushing();
1946
+ if (stranded.length === 0) {
1947
+ return { name: "stranded", status: "ok", detail: "none" };
1948
+ }
1949
+ const moves = stranded.reduce((n, f) => n + f.lineCount, 0);
1950
+ return {
1951
+ name: "stranded",
1952
+ status: "warn",
1953
+ detail: `${String(moves)} move(s) in ${String(stranded.length)} file(s) \u2014 run \`prim moves flush\``
1954
+ };
1955
+ }
1956
+ async function checkConnectivity() {
1957
+ try {
1958
+ await getClient().get("/api/cli/decisions/recent?limit=1", {
1959
+ signal: AbortSignal.timeout(CONNECTIVITY_TIMEOUT_MS)
1960
+ });
1961
+ return { name: "connectivity", status: "ok", detail: "server reachable" };
1962
+ } catch (err) {
1963
+ const message = err instanceof Error ? err.message : String(err);
1964
+ const status = message.includes("Authentication") ? "fail" : "warn";
1965
+ return { name: "connectivity", status, detail: message.slice(0, 80) };
1966
+ }
1967
+ }
1968
+ async function collectChecks() {
1969
+ return [
1970
+ checkAuth(),
1971
+ await checkDaemon(),
1972
+ checkJournal(),
1973
+ checkStranded(),
1974
+ await checkConnectivity()
1975
+ ];
1976
+ }
1977
+ function icon(status) {
1978
+ return status === "ok" ? "\u2713" : status === "warn" ? "\u26A0" : "\u2717";
1979
+ }
1980
+ async function runDoctor() {
1981
+ const checks = await collectChecks();
1982
+ const { json, exitCode } = classifyDoctor(checks);
1983
+ const headline = json.status === "ok" ? "\u2713 healthy" : json.status === "warn" ? "\u26A0 degraded" : "\u2717 unhealthy";
1984
+ process.stderr.write(`[prim] doctor: ${headline}
1985
+ `);
1986
+ for (const c of checks) {
1987
+ process.stderr.write(` ${icon(c.status)} ${c.name.padEnd(13)} ${c.detail}
1988
+ `);
1989
+ }
1990
+ console.log(JSON.stringify(json, null, 2));
1991
+ if (exitCode !== 0 && !process.exitCode) {
1992
+ process.exitCode = exitCode;
1993
+ }
1994
+ }
1995
+ function registerDoctorCommands(program2) {
1996
+ program2.command("doctor").description("Check capture-pipeline health end to end (auth, daemon, journal, server)").action(async () => {
1997
+ await runDoctor();
1998
+ });
1999
+ }
2000
+
1863
2001
  // src/commands/hooks.ts
1864
2002
  import { execSync as execSync2 } from "child_process";
1865
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
2003
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync5, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
1866
2004
  import { resolve } from "path";
1867
2005
  import { Option } from "commander";
1868
2006
  var PRE_COMMIT = { hookName: "pre-commit", binName: "prim-pre-commit" };
@@ -1905,11 +2043,11 @@ function getGitRoot() {
1905
2043
  }
1906
2044
  function detectHusky(gitRoot) {
1907
2045
  const huskyDir = resolve(gitRoot, ".husky");
1908
- if (!existsSync5(huskyDir)) return false;
1909
- if (existsSync5(resolve(huskyDir, "_"))) return true;
1910
- if (existsSync5(resolve(huskyDir, "pre-commit"))) return true;
2046
+ if (!existsSync6(huskyDir)) return false;
2047
+ if (existsSync6(resolve(huskyDir, "_"))) return true;
2048
+ if (existsSync6(resolve(huskyDir, "pre-commit"))) return true;
1911
2049
  const pkgPath = resolve(gitRoot, "package.json");
1912
- if (existsSync5(pkgPath)) {
2050
+ if (existsSync6(pkgPath)) {
1913
2051
  try {
1914
2052
  const pkg2 = JSON.parse(readFileSync5(pkgPath, "utf-8"));
1915
2053
  const scripts = pkg2.scripts ?? {};
@@ -1938,7 +2076,7 @@ async function askConfirmation(question) {
1938
2076
  }
1939
2077
  function installToHusky(gitRoot, spec = PRE_COMMIT) {
1940
2078
  const hookPath = resolve(gitRoot, ".husky", spec.hookName);
1941
- if (existsSync5(hookPath)) {
2079
+ if (existsSync6(hookPath)) {
1942
2080
  const existing = readFileSync5(hookPath, "utf-8");
1943
2081
  if (containsPrimHook(existing, spec.binName)) {
1944
2082
  console.log(`Prim ${spec.hookName} hook is already installed in .husky/${spec.hookName}.`);
@@ -1963,10 +2101,10 @@ ${huskyBlock(spec)}
1963
2101
  function installToDotGit(gitRoot, spec = PRE_COMMIT) {
1964
2102
  const hooksDir = resolve(gitRoot, ".git", "hooks");
1965
2103
  const hookPath = resolve(hooksDir, spec.hookName);
1966
- if (!existsSync5(hooksDir)) {
1967
- mkdirSync3(hooksDir, { recursive: true });
2104
+ if (!existsSync6(hooksDir)) {
2105
+ mkdirSync4(hooksDir, { recursive: true });
1968
2106
  }
1969
- if (existsSync5(hookPath)) {
2107
+ if (existsSync6(hookPath)) {
1970
2108
  const existing = readFileSync5(hookPath, "utf-8");
1971
2109
  if (containsPrimHook(existing, spec.binName)) {
1972
2110
  console.log(`Prim ${spec.hookName} hook is already installed at ${hookPath}.`);
@@ -2030,7 +2168,7 @@ function registerHooksCommands(program2) {
2030
2168
  const gitRoot = getGitRoot();
2031
2169
  for (const spec of HOOKS) {
2032
2170
  const hookPath = resolve(gitRoot, ".git", "hooks", spec.hookName);
2033
- if (!existsSync5(hookPath)) {
2171
+ if (!existsSync6(hookPath)) {
2034
2172
  console.log(`No ${spec.hookName} hook found.`);
2035
2173
  continue;
2036
2174
  }
@@ -2045,7 +2183,7 @@ function registerHooksCommands(program2) {
2045
2183
  }
2046
2184
 
2047
2185
  // src/commands/moves.ts
2048
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, unlinkSync as unlinkSync4, writeFileSync as writeFileSync4 } from "fs";
2186
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, unlinkSync as unlinkSync4, writeFileSync as writeFileSync4 } from "fs";
2049
2187
  import { join as join5 } from "path";
2050
2188
 
2051
2189
  // src/flusher.ts
@@ -2053,35 +2191,70 @@ import { renameSync as renameSync2, unlinkSync as unlinkSync3 } from "fs";
2053
2191
  var BATCH_SIZE = 500;
2054
2192
  var HTTP_TIMEOUT_MS = 1e4;
2055
2193
  var OPPORTUNISTIC_FLUSH_AFTER_MS = 6e4;
2056
- async function drainPath(path) {
2057
- const tmpPath = `${path}.flushing.${String(Date.now())}.${String(process.pid)}`;
2058
- try {
2059
- renameSync2(path, tmpPath);
2060
- } catch (err) {
2061
- if (err.code === "ENOENT") {
2062
- return 0;
2063
- }
2064
- throw err;
2194
+ var ORPHAN_QUARANTINE_MS = 6e4;
2195
+ function batchMoves(moves, size = BATCH_SIZE) {
2196
+ const batches = [];
2197
+ for (let i = 0; i < moves.length; i += size) {
2198
+ batches.push(moves.slice(i, i + size));
2065
2199
  }
2066
- const moves = readMovesFromPath(tmpPath);
2200
+ return batches;
2201
+ }
2202
+ async function drainFlushingPath(flushingPath) {
2203
+ const moves = readMovesFromPath(flushingPath);
2067
2204
  if (moves.length === 0) {
2068
- unlinkSync3(tmpPath);
2205
+ unlinkSync3(flushingPath);
2069
2206
  return 0;
2070
2207
  }
2071
2208
  const client = getClient();
2072
- for (let i = 0; i < moves.length; i += BATCH_SIZE) {
2073
- const batch = moves.slice(i, i + BATCH_SIZE);
2209
+ for (const batch of batchMoves(moves)) {
2074
2210
  await client.post(
2075
2211
  "/api/cli/moves/ingest",
2076
2212
  { batch },
2077
2213
  { signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) }
2078
2214
  );
2079
2215
  }
2080
- unlinkSync3(tmpPath);
2216
+ unlinkSync3(flushingPath);
2081
2217
  return moves.length;
2082
2218
  }
2083
- async function flush() {
2219
+ async function drainPath(path) {
2220
+ const tmpPath = `${path}.flushing.${String(Date.now())}.${String(process.pid)}`;
2221
+ try {
2222
+ renameSync2(path, tmpPath);
2223
+ } catch (err) {
2224
+ if (err.code === "ENOENT") {
2225
+ return 0;
2226
+ }
2227
+ throw err;
2228
+ }
2229
+ return drainFlushingPath(tmpPath);
2230
+ }
2231
+ function processIsAlive2(pid) {
2232
+ try {
2233
+ process.kill(pid, 0);
2234
+ return true;
2235
+ } catch {
2236
+ return false;
2237
+ }
2238
+ }
2239
+ function selectRecoverable(files, now, opts = {}) {
2240
+ const quarantineMs = opts.quarantineMs ?? ORPHAN_QUARANTINE_MS;
2241
+ const isAlive = opts.isAlive ?? processIsAlive2;
2242
+ return files.filter(
2243
+ (f) => f.pid === void 0 ? now - f.mtimeMs > quarantineMs : !isAlive(f.pid)
2244
+ );
2245
+ }
2246
+ async function recoverOrphans() {
2084
2247
  let total = 0;
2248
+ for (const file of selectRecoverable(listFlushing(), Date.now())) {
2249
+ try {
2250
+ total += await drainFlushingPath(file.path);
2251
+ } catch {
2252
+ }
2253
+ }
2254
+ return total;
2255
+ }
2256
+ async function flush() {
2257
+ let total = await recoverOrphans();
2085
2258
  for (const { path } of listBuckets()) {
2086
2259
  total += await drainPath(path);
2087
2260
  }
@@ -2102,7 +2275,7 @@ async function flushIfNeeded() {
2102
2275
  }
2103
2276
 
2104
2277
  // src/commands/moves.ts
2105
- var MS_PER_SECOND = 1e3;
2278
+ var MS_PER_SECOND2 = 1e3;
2106
2279
  var DEFAULT_TAIL_LINES = "20";
2107
2280
  var RADIX_DECIMAL = 10;
2108
2281
  var ID_PREFIX_LEN = 8;
@@ -2120,17 +2293,32 @@ function registerMovesCommands(program2) {
2120
2293
  });
2121
2294
  moves.command("status").description("Show per-bucket pending stats").action(() => {
2122
2295
  const stats = bucketStats();
2123
- if (stats.length === 0) {
2296
+ const stranded = listFlushing();
2297
+ if (stats.length === 0 && stranded.length === 0) {
2124
2298
  console.log("[prim] journal: empty");
2125
2299
  return;
2126
2300
  }
2127
2301
  console.log(`[prim] root: ${JOURNAL_DIR}`);
2128
2302
  for (const s of stats) {
2129
- const ageS = Math.round((Date.now() - s.mtimeMs) / MS_PER_SECOND);
2303
+ const ageS = Math.round((Date.now() - s.mtimeMs) / MS_PER_SECOND2);
2130
2304
  console.log(
2131
2305
  ` ${s.bucket.padEnd(BUCKET_COL_WIDTH)} ${String(s.lineCount).padStart(5)} pending, ${String(s.sizeBytes).padStart(8)} bytes, last write ${String(ageS)}s ago`
2132
2306
  );
2133
2307
  }
2308
+ if (stranded.length > 0) {
2309
+ const moveCount = stranded.reduce((n, f) => n + f.lineCount, 0);
2310
+ const byteCount = stranded.reduce((n, f) => n + f.sizeBytes, 0);
2311
+ console.log(
2312
+ `[prim] \u26A0 ${String(stranded.length)} stranded flush file(s): ${String(moveCount)} move(s), ${String(byteCount)} bytes \u2014 recover with \`prim moves flush\``
2313
+ );
2314
+ for (const f of stranded) {
2315
+ const ageS = Math.round((Date.now() - f.mtimeMs) / MS_PER_SECOND2);
2316
+ const owner = f.pid === void 0 ? "no pid" : `pid ${String(f.pid)}`;
2317
+ console.log(
2318
+ ` ${f.bucket.padEnd(BUCKET_COL_WIDTH)} ${String(f.lineCount).padStart(5)} stranded, ${String(f.sizeBytes).padStart(8)} bytes, ${String(ageS)}s ago (${owner})`
2319
+ );
2320
+ }
2321
+ }
2134
2322
  });
2135
2323
  moves.command("tail").description("Pretty-print recent journal entries across all buckets").option("-n, --lines <n>", "number of lines to tail", DEFAULT_TAIL_LINES).action((opts) => {
2136
2324
  const lines = Number.parseInt(opts.lines, RADIX_DECIMAL);
@@ -2156,8 +2344,8 @@ function registerMovesCommands(program2) {
2156
2344
  });
2157
2345
  moves.command("bind").description("Pin the current directory to an org via .prim/workspace.json").requiredOption("--orgId <orgId>", "Convex organization id").action((opts) => {
2158
2346
  const dir = join5(process.cwd(), ".prim");
2159
- if (!existsSync6(dir)) {
2160
- mkdirSync4(dir, { recursive: true, mode: DIR_MODE });
2347
+ if (!existsSync7(dir)) {
2348
+ mkdirSync5(dir, { recursive: true, mode: DIR_MODE });
2161
2349
  }
2162
2350
  const file = join5(process.cwd(), WORKSPACE_FILE);
2163
2351
  writeFileSync4(file, JSON.stringify({ orgId: opts.orgId, boundAt: Date.now() }, null, 2), {
@@ -2167,7 +2355,7 @@ function registerMovesCommands(program2) {
2167
2355
  });
2168
2356
  moves.command("drop").description("Remove the .prim/workspace.json binding from the cwd").action(() => {
2169
2357
  const file = join5(process.cwd(), WORKSPACE_FILE);
2170
- if (!existsSync6(file)) {
2358
+ if (!existsSync7(file)) {
2171
2359
  console.log("[prim] no workspace binding in cwd");
2172
2360
  return;
2173
2361
  }
@@ -2260,8 +2448,8 @@ function registerReconcileCommands(program2) {
2260
2448
 
2261
2449
  // src/commands/session.ts
2262
2450
  import {
2263
- existsSync as existsSync7,
2264
- mkdirSync as mkdirSync5,
2451
+ existsSync as existsSync8,
2452
+ mkdirSync as mkdirSync6,
2265
2453
  readFileSync as readFileSync6,
2266
2454
  readdirSync,
2267
2455
  unlinkSync as unlinkSync5,
@@ -2271,8 +2459,8 @@ import { join as join6 } from "path";
2271
2459
  var DIR_MODE2 = 448;
2272
2460
  var FILE_MODE3 = 384;
2273
2461
  function ensureDir() {
2274
- if (!existsSync7(SESSIONS_DIR)) {
2275
- mkdirSync5(SESSIONS_DIR, { recursive: true, mode: DIR_MODE2 });
2462
+ if (!existsSync8(SESSIONS_DIR)) {
2463
+ mkdirSync6(SESSIONS_DIR, { recursive: true, mode: DIR_MODE2 });
2276
2464
  }
2277
2465
  }
2278
2466
  function markerPath(sessionId) {
@@ -2292,7 +2480,7 @@ function registerSessionCommands(program2) {
2292
2480
  console.log(`[prim] session ${sessionId} bound to org ${opts.orgId}`);
2293
2481
  });
2294
2482
  session.command("list").description("List active session markers").action(() => {
2295
- if (!existsSync7(SESSIONS_DIR)) {
2483
+ if (!existsSync8(SESSIONS_DIR)) {
2296
2484
  console.log("[prim] no session markers");
2297
2485
  return;
2298
2486
  }
@@ -2312,7 +2500,7 @@ function registerSessionCommands(program2) {
2312
2500
  });
2313
2501
  session.command("drop <sessionId>").description("Remove a session marker").action((sessionId) => {
2314
2502
  const p = markerPath(sessionId);
2315
- if (!existsSync7(p)) {
2503
+ if (!existsSync8(p)) {
2316
2504
  console.log(`[prim] no marker for session ${sessionId}`);
2317
2505
  return;
2318
2506
  }
@@ -2413,10 +2601,10 @@ function registerSetupCommand(program2) {
2413
2601
 
2414
2602
  // src/commands/skill.ts
2415
2603
  import {
2416
- closeSync as closeSync2,
2417
- existsSync as existsSync8,
2604
+ closeSync as closeSync3,
2605
+ existsSync as existsSync9,
2418
2606
  fsyncSync as fsyncSync2,
2419
- openSync as openSync2,
2607
+ openSync as openSync3,
2420
2608
  readFileSync as readFileSync7,
2421
2609
  renameSync as renameSync3,
2422
2610
  writeFileSync as writeFileSync6
@@ -2439,13 +2627,13 @@ function loadSkill() {
2439
2627
  let dir = __dirname;
2440
2628
  while (dir !== dirname4(dir)) {
2441
2629
  const p = resolve2(dir, "SKILL.md");
2442
- if (existsSync8(p)) return readFileSync7(p, "utf-8");
2630
+ if (existsSync9(p)) return readFileSync7(p, "utf-8");
2443
2631
  dir = dirname4(dir);
2444
2632
  }
2445
2633
  throw new Error("SKILL.md not found in package");
2446
2634
  }
2447
2635
  function detectTargets(cwd) {
2448
- return TARGET_CANDIDATES.filter((p) => existsSync8(resolve2(cwd, p)));
2636
+ return TARGET_CANDIDATES.filter((p) => existsSync9(resolve2(cwd, p)));
2449
2637
  }
2450
2638
  function detectNewline(content) {
2451
2639
  return content.includes("\r\n") ? "\r\n" : "\n";
@@ -2474,11 +2662,11 @@ function removeBlock(existing) {
2474
2662
  function atomicWrite2(target, content) {
2475
2663
  const tmp = `${target}.tmp`;
2476
2664
  writeFileSync6(tmp, content);
2477
- const fd = openSync2(tmp, "r+");
2665
+ const fd = openSync3(tmp, "r+");
2478
2666
  try {
2479
2667
  fsyncSync2(fd);
2480
2668
  } finally {
2481
- closeSync2(fd);
2669
+ closeSync3(fd);
2482
2670
  }
2483
2671
  renameSync3(tmp, target);
2484
2672
  }
@@ -2494,7 +2682,7 @@ function resolveTarget(cwd, override) {
2494
2682
  function runInstall(cwd, opts) {
2495
2683
  const target = resolveTarget(cwd, opts.target);
2496
2684
  if (target === null) return 1;
2497
- const existing = existsSync8(target) ? readFileSync7(target, "utf-8") : "";
2685
+ const existing = existsSync9(target) ? readFileSync7(target, "utf-8") : "";
2498
2686
  const eol = existing ? detectNewline(existing) : "\n";
2499
2687
  const block = composeBlock(loadSkill(), eol);
2500
2688
  const next = applyBlock(existing, block, eol);
@@ -2513,7 +2701,7 @@ function runInstall(cwd, opts) {
2513
2701
  function runUninstall(cwd, opts) {
2514
2702
  const target = resolveTarget(cwd, opts.target);
2515
2703
  if (target === null) return 1;
2516
- if (!existsSync8(target)) {
2704
+ if (!existsSync9(target)) {
2517
2705
  console.log(`Skill block not present at ${target}`);
2518
2706
  return 0;
2519
2707
  }
@@ -2530,7 +2718,7 @@ function runUninstall(cwd, opts) {
2530
2718
  function runStatus(cwd, opts) {
2531
2719
  const target = resolveTarget(cwd, opts.target);
2532
2720
  if (target === null) return 1;
2533
- const fileExists = existsSync8(target);
2721
+ const fileExists = existsSync9(target);
2534
2722
  let installed = false;
2535
2723
  if (fileExists) {
2536
2724
  const content = readFileSync7(target, "utf-8");
@@ -2602,13 +2790,18 @@ async function renderStatusline() {
2602
2790
  const version = readPackageVersion();
2603
2791
  const snapshot = await daemonRequest(
2604
2792
  "status_snapshot",
2605
- {},
2793
+ // callerEnv lets the daemon withhold presence when it is bound to a different
2794
+ // deployment than this statusline targets.
2795
+ { callerEnv: getSiteUrl() },
2606
2796
  { timeoutMs: STATUSLINE_TIMEOUT_MS }
2607
2797
  );
2608
2798
  if (!snapshot) {
2609
2799
  debug("daemon snapshot missing");
2610
2800
  return `primitive ${version} (daemon: down)`;
2611
2801
  }
2802
+ if (snapshot.envMismatch) {
2803
+ return `primitive ${version} (daemon: live \xB7 presence: other env)`;
2804
+ }
2612
2805
  if (snapshot.presenceStale) {
2613
2806
  return `primitive ${version} (daemon: live \xB7 presence: stale)`;
2614
2807
  }
@@ -2760,6 +2953,7 @@ registerDecisionsCommands(program);
2760
2953
  registerClaudeCommands(program);
2761
2954
  registerCodexCommands(program);
2762
2955
  registerDaemonCommands(program);
2956
+ registerDoctorCommands(program);
2763
2957
  registerReconcileCommands(program);
2764
2958
  registerStatuslineCommands(program);
2765
2959
  registerWelcomeCommand(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitive.ai/prim",
3
- "version": "0.1.0-alpha.28",
3
+ "version": "0.1.0-alpha.30",
4
4
  "description": "CLI for Primitive's decision graph — passive decision capture, conflict gate, and team presence",
5
5
  "type": "module",
6
6
  "license": "MIT",