spur-board 0.7.0 → 0.8.1

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/dist/mcp.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { j as ERRORS, s as OPERATIONS, u as renderMcpError } from "./src-BFgTh55w.mjs";
2
- import { r as CliError, t as requestOperation } from "./client-Ln76BdDz.mjs";
3
- import { n as loadClientConfig } from "./config-B_6fA4Au.mjs";
1
+ import { j as ERRORS, s as OPERATIONS, u as renderMcpError } from "./src-g7u1onIP.mjs";
2
+ import { i as CliError, n as requestOperation } from "./client-D4cveOHQ.mjs";
3
+ import { a as memoryVerdictCache, r as compareCatalogs, s as loadClientConfig, t as checkCatalog } from "./config-B1fVOLzp.mjs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { z } from "zod";
@@ -79,6 +79,32 @@ function createMcpServer(env = process.env, loadConfig = loadClientConfig) {
79
79
  version
80
80
  });
81
81
  const agent = Boolean(env["SPUR_SESSION"]);
82
+ const verdicts = memoryVerdictCache();
83
+ /** Addresses whose daemon served a different catalog; each call probes before sending. */
84
+ const mismatched = /* @__PURE__ */ new Map();
85
+ /** Refuses the call unless the daemon at a marked address now serves the client's catalog. */
86
+ const probe = async (config) => {
87
+ if (mismatched.get(config.address.href) === void 0) return;
88
+ const { clientVersion, serverVersion, matches, instance } = await compareCatalogs(config).catch((cause) => {
89
+ throw cause instanceof CliError && ERRORS[cause.code].recovery.sideEffects === "none" ? cause : new CliError("daemon_unavailable");
90
+ });
91
+ if (!matches) {
92
+ mismatched.set(config.address.href, {
93
+ clientVersion,
94
+ serverVersion
95
+ });
96
+ throw new CliError("version_mismatch", {
97
+ clientVersion,
98
+ serverVersion
99
+ });
100
+ }
101
+ mismatched.delete(config.address.href);
102
+ if (instance !== void 0) verdicts.remember(config.address, instance, {
103
+ clientVersion,
104
+ serverVersion,
105
+ matches: true
106
+ });
107
+ };
82
108
  for (const op of OPERATIONS) server.registerTool(op.mcp.name, {
83
109
  description: toolDescription(op),
84
110
  inputSchema: fromJsonSchema(toolInputSchema(op, agent), handlerValidator),
@@ -93,7 +119,29 @@ function createMcpServer(env = process.env, loadConfig = loadClientConfig) {
93
119
  const parsed = parseArguments(op, argumentsValue, agent);
94
120
  key = parsed.key;
95
121
  const config = loadConfig(env);
96
- const value = await requestOperation(op, parsed.input, key, config);
122
+ await probe(config);
123
+ let instance;
124
+ let outcome;
125
+ try {
126
+ outcome = { value: await requestOperation(op, parsed.input, key, config, (seen) => {
127
+ instance = seen;
128
+ }) };
129
+ } catch (error) {
130
+ outcome = { error };
131
+ }
132
+ if (instance !== void 0) {
133
+ const verdict = await checkCatalog(config, instance, verdicts);
134
+ if (verdict !== void 0 && !verdict.matches) {
135
+ const context = {
136
+ clientVersion: verdict.clientVersion,
137
+ serverVersion: verdict.serverVersion
138
+ };
139
+ mismatched.set(config.address.href, context);
140
+ if (op.kind === "query") throw new CliError("version_mismatch", context);
141
+ }
142
+ }
143
+ if ("error" in outcome) throw outcome.error;
144
+ const value = outcome.value;
97
145
  return {
98
146
  content: [{
99
147
  type: "text",
@@ -113,6 +113,12 @@ const SLACK_BOT_SCOPES = [
113
113
  const DEFAULT_HTTP_PORT = 4311;
114
114
  const DEFAULT_SLACK_APP_NAME = "spur-board";
115
115
  const DEFAULT_SLACK_COMMAND = "/board";
116
+ /**
117
+ * The journal project of board-wide events that belong to no configured project: token
118
+ * lifecycle, unknown Spur states and denials with no resolvable project. It sits outside the
119
+ * project id pattern, so no configured project can take it.
120
+ */
121
+ const RESERVED_PROJECT = "_board";
116
122
  const WEEKDAYS = [
117
123
  "monday",
118
124
  "tuesday",
@@ -195,7 +201,10 @@ function checkBoard(items, ctx) {
195
201
  });
196
202
  }
197
203
  const projectSchema = z.strictObject({
198
- id: z.string().regex(/^[a-z][a-z0-9-]*$/, "Expected lowercase letters, digits and dashes"),
204
+ id: z.string().refine((id) => id !== RESERVED_PROJECT, {
205
+ message: `"${RESERVED_PROJECT}" is reserved for board-wide journal events; choose another project id`,
206
+ abort: true
207
+ }).regex(/^[a-z][a-z0-9-]*$/, "Expected lowercase letters, digits and dashes"),
199
208
  tracker: z.discriminatedUnion("type", [jiraTracker, githubIssuesTracker]),
200
209
  repos: z.array(repoSlugSchema).min(1),
201
210
  operatorGithubLogin: z.string().regex(/^[A-Za-z0-9-]{1,39}$/, "Expected a GitHub login"),
@@ -522,7 +531,11 @@ const ERRORS = {
522
531
  exit: EXIT_CODES.notApplied,
523
532
  message: "The client and daemon catalog versions do not match; nothing was applied.",
524
533
  hint: "Run `board service restart` to update the daemon, then retry.",
525
- recovery: noRetry("Run `board service restart`, then retry the command.")
534
+ recovery: noRetry("Run `board service restart`, then retry the command."),
535
+ mcp: {
536
+ hint: "Restart your agent session so `board mcp` loads the daemon's catalog; if the mismatch remains, run `board service restart`.",
537
+ recovery: noRetry("Restart your agent session; if the mismatch remains, run `board service restart`. Then retry the tool call.")
538
+ }
526
539
  }),
527
540
  daemon_unavailable: spec("daemon_unavailable", {
528
541
  status: null,
@@ -894,6 +907,7 @@ const EVENT_KINDS = [
894
907
  "doorbell_read",
895
908
  "doorbell_undelivered",
896
909
  "inbound_failed",
910
+ "inbound_skipped",
897
911
  "from_you_raised",
898
912
  "from_you_cleared",
899
913
  "flag_raised",
@@ -1034,6 +1048,10 @@ const eventPayloadSchemas = {
1034
1048
  gateway: S,
1035
1049
  reason: S
1036
1050
  }),
1051
+ inbound_skipped: z.looseObject({
1052
+ gateway: S,
1053
+ reason: S
1054
+ }),
1037
1055
  from_you_raised: z.looseObject({
1038
1056
  item: S,
1039
1057
  rule: S.optional()
@@ -1242,9 +1260,10 @@ const errorInputSchema = z.strictObject({
1242
1260
  requestId: z.string().min(1),
1243
1261
  context: errorContextSchema.optional()
1244
1262
  });
1245
- function fields(input) {
1263
+ function fields(input, transport = "default") {
1246
1264
  const { code, requestId, context } = errorInputSchema.parse(input);
1247
1265
  const spec = ERRORS[code];
1266
+ const guidance = (transport === "mcp" ? spec.mcp : void 0) ?? spec;
1248
1267
  const trace = code === "unexpected" ? {
1249
1268
  requestId,
1250
1269
  logLocation: "daemon log"
@@ -1253,9 +1272,9 @@ function fields(input) {
1253
1272
  code,
1254
1273
  requestId,
1255
1274
  message: spec.message,
1256
- hint: spec.hint,
1275
+ hint: guidance.hint,
1257
1276
  docs: spec.docs,
1258
- recovery: spec.recovery,
1277
+ recovery: guidance.recovery,
1259
1278
  context: context ?? {},
1260
1279
  ...trace ? { trace } : {}
1261
1280
  };
@@ -1275,12 +1294,12 @@ function renderHttpProblem(input) {
1275
1294
  };
1276
1295
  }
1277
1296
  function renderMcpError(input) {
1278
- const content = fields(input);
1297
+ const content = fields(input, "mcp");
1279
1298
  return {
1280
1299
  isError: true,
1281
1300
  content: [{
1282
1301
  type: "text",
1283
- text: renderStderrError(input)
1302
+ text: renderLines(content)
1284
1303
  }],
1285
1304
  structuredContent: content
1286
1305
  };
@@ -1292,8 +1311,7 @@ function renderJsonError(input) {
1292
1311
  error: fields(input)
1293
1312
  };
1294
1313
  }
1295
- function renderStderrError(input) {
1296
- const value = fields(input);
1314
+ function renderLines(value) {
1297
1315
  const context = Object.entries(value.context);
1298
1316
  return [
1299
1317
  `error: ${value.code}: ${value.message}`,
@@ -2678,4 +2696,4 @@ var AdapterError = class extends Error {
2678
2696
  }
2679
2697
  };
2680
2698
  //#endregion
2681
- export { taskKeySchema as A, SLACK_BOT_SCOPES as B, sessionFactsSchema as C, errorCodeOf as D, encodeActorId as E, DEFAULT_HTTP_PORT as F, DEFAULT_SLACK_APP_NAME as I, DEFAULT_SLACK_COMMAND as L, errorContextSchema as M, errorEnvelopeSchema as N, parseActorId as O, successEnvelopeSchema as P, configSchema as R, prRefSchema as S, actorIdSchema as T, EVENT_KINDS as _, buildCatalogSnapshot as a, SESSION_STATUSES as b, renderHttpProblem as c, SPACE_IDS as d, conversationRefSchema as f, userIdSchema as g, messageRefSchema as h, sourceCursorSchema as i, ERRORS as j, parseRef as k, renderJsonError as l, interactionRefSchema as m, batchSchema as n, roleCanUseOperation as o, inboundEventSchema as p, entityRefSchema as r, OPERATIONS as s, AdapterError as t, renderMcpError as u, eventPayloadSchemas as v, ticketFactsSchema as w, prFactsSchema as x, SESSION_HEALTH_STATES as y, requiredSecretKeys as z };
2699
+ export { taskKeySchema as A, requiredSecretKeys as B, sessionFactsSchema as C, errorCodeOf as D, encodeActorId as E, DEFAULT_HTTP_PORT as F, DEFAULT_SLACK_APP_NAME as I, DEFAULT_SLACK_COMMAND as L, errorContextSchema as M, errorEnvelopeSchema as N, parseActorId as O, successEnvelopeSchema as P, RESERVED_PROJECT as R, prRefSchema as S, actorIdSchema as T, SLACK_BOT_SCOPES as V, EVENT_KINDS as _, buildCatalogSnapshot as a, SESSION_STATUSES as b, renderHttpProblem as c, SPACE_IDS as d, conversationRefSchema as f, userIdSchema as g, messageRefSchema as h, sourceCursorSchema as i, ERRORS as j, parseRef as k, renderJsonError as l, interactionRefSchema as m, batchSchema as n, roleCanUseOperation as o, inboundEventSchema as p, entityRefSchema as r, OPERATIONS as s, AdapterError as t, renderMcpError as u, eventPayloadSchemas as v, ticketFactsSchema as w, prFactsSchema as x, SESSION_HEALTH_STATES as y, configSchema as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spur-board",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "license": "MIT",
5
5
  "engines": {
6
6
  "node": "^24.12.0 || ^26.0.0"
@@ -1,111 +0,0 @@
1
- import { R as configSchema } from "./src-BFgTh55w.mjs";
2
- import { r as CliError } from "./client-Ln76BdDz.mjs";
3
- import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
4
- import { homedir } from "node:os";
5
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
- import YAML from "yaml";
7
- //#region src/config.ts
8
- const CONNECT_TIMEOUT_MS = 2e3;
9
- const RESPONSE_TIMEOUT_MS = 3e4;
10
- function configFile(env, home) {
11
- const boardHome = env["BOARD_HOME"];
12
- if (boardHome) return join(resolve(boardHome), "config.yaml");
13
- const xdg = env["XDG_CONFIG_HOME"];
14
- return join(xdg && isAbsolute(xdg) ? xdg : join(home, ".config"), "spur-board", "config.yaml");
15
- }
16
- function resolveDaemonAddress(env = process.env, home = homedir(), configuredPort) {
17
- const rawAddress = env["BOARD_DAEMON_URL"];
18
- if (rawAddress === void 0) {
19
- let port = configuredPort ?? 4311;
20
- if (configuredPort === void 0) try {
21
- const parsed = YAML.parseDocument(readFileSync(configFile(env, home), "utf8"));
22
- if (parsed.errors.length === 0) {
23
- const config = configSchema.safeParse(parsed.toJS());
24
- if (config.success) port = config.data.http.port;
25
- }
26
- } catch {}
27
- if (port === 0) throw new CliError("config", void 0, void 0, "http.port: 0 assigns a runtime port that clients cannot discover. Set BOARD_DAEMON_URL to the daemon listening address.");
28
- return new URL(`http://127.0.0.1:${String(port)}`);
29
- }
30
- let address;
31
- try {
32
- address = new URL(rawAddress);
33
- } catch {
34
- throw new CliError("config", { environmentVariable: "BOARD_DAEMON_URL" });
35
- }
36
- if (!["http:", "https:"].includes(address.protocol) || address.username || address.password) throw new CliError("config", { environmentVariable: "BOARD_DAEMON_URL" });
37
- return address;
38
- }
39
- function tokenInFolder(folder, warn) {
40
- const directory = join(folder, ".spur-board");
41
- const file = join(directory, "token");
42
- try {
43
- const owner = process.getuid?.();
44
- const directoryInfo = lstatSync(directory);
45
- if (!directoryInfo.isDirectory() || directoryInfo.uid !== owner || (directoryInfo.mode & 63) !== 0) throw new Error("unsafe token directory");
46
- const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
47
- let value;
48
- try {
49
- const fileInfo = fstatSync(descriptor);
50
- if (!fileInfo.isFile() || fileInfo.uid !== owner || (fileInfo.mode & 63) !== 0) throw new Error("unsafe token file");
51
- value = readFileSync(descriptor, "utf8").trimEnd();
52
- } finally {
53
- closeSync(descriptor);
54
- }
55
- if (value === "") throw new Error("empty token file");
56
- return value;
57
- } catch (error) {
58
- if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") return;
59
- warn("Ignoring an unsafe .spur-board/token candidate.\n");
60
- return;
61
- }
62
- }
63
- function findTokenFile(cwd, home, warn = (message) => {
64
- process.stderr.write(message);
65
- }) {
66
- const current = realpathSync(cwd);
67
- const boundary = realpathSync(home);
68
- if (current === boundary) return void 0;
69
- const toCurrent = relative(boundary, current);
70
- const outsideHome = toCurrent === ".." || toCurrent.startsWith(`..${sep}`) || isAbsolute(toCurrent);
71
- let folder = current;
72
- while (folder !== boundary) {
73
- if (outsideHome) {
74
- const info = statSync(folder);
75
- if (info.uid !== process.getuid?.() || (info.mode & 2) !== 0) break;
76
- }
77
- const token = tokenInFolder(folder, warn);
78
- if (token !== void 0) return token;
79
- const parent = dirname(folder);
80
- if (parent === folder) break;
81
- folder = parent;
82
- }
83
- }
84
- function loadClientConfig(env = process.env, paths = {
85
- cwd: process.cwd(),
86
- home: homedir()
87
- }) {
88
- const address = resolveDaemonAddress(env, paths.home);
89
- const session = env["SPUR_SESSION"] || void 0;
90
- const token = session ? void 0 : env["BOARD_TOKEN"] === void 0 ? findTokenFile(paths.cwd, paths.home) : env["BOARD_TOKEN"];
91
- if (!session && !token) throw new CliError("config", { environmentVariable: "BOARD_TOKEN" });
92
- return {
93
- address,
94
- session,
95
- token,
96
- connectTimeoutMs: CONNECT_TIMEOUT_MS,
97
- responseTimeoutMs: RESPONSE_TIMEOUT_MS
98
- };
99
- }
100
- /** Enrollment sends only a hash and works before any operator credential exists. */
101
- function loadEnrollmentConfig(env = process.env) {
102
- return {
103
- address: resolveDaemonAddress(env),
104
- session: void 0,
105
- token: void 0,
106
- connectTimeoutMs: CONNECT_TIMEOUT_MS,
107
- responseTimeoutMs: RESPONSE_TIMEOUT_MS
108
- };
109
- }
110
- //#endregion
111
- export { resolveDaemonAddress as i, loadClientConfig as n, loadEnrollmentConfig as r, configFile as t };