spur-board 0.7.1 → 0.8.2

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/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { B as SLACK_BOT_SCOPES, R as configSchema, s as OPERATIONS, z as requiredSecretKeys } from "./src-BFgTh55w.mjs";
2
- import { a as renderCliError, i as errorExitCode, n as requestTokenEnrollment, o as escapeTerminalControls, r as CliError, t as requestOperation } from "./client-Ln76BdDz.mjs";
3
- import { i as resolveDaemonAddress, n as loadClientConfig, r as loadEnrollmentConfig, t as configFile } from "./config-B_6fA4Au.mjs";
1
+ import { B as requiredSecretKeys, V as SLACK_BOT_SCOPES, j as ERRORS, s as OPERATIONS, z as configSchema } from "./src-g7u1onIP.mjs";
2
+ import { a as errorExitCode, i as CliError, n as requestOperation, o as renderCliError, r as requestTokenEnrollment, s as escapeTerminalControls } from "./client-D4cveOHQ.mjs";
3
+ import { a as memoryVerdictCache, c as loadEnrollmentConfig, i as fileVerdictCache, l as resolveDaemonAddress, n as cliPackageVersion, o as configFile, s as loadClientConfig, t as checkCatalog } from "./config-B1fVOLzp.mjs";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
5
  import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { homedir, hostname, userInfo } from "node:os";
@@ -1082,8 +1082,25 @@ function createProgram(options = {}) {
1082
1082
  const config = options.clientConfig ?? loadClientConfig(env);
1083
1083
  const key = op.kind === "mutation" ? keyOption || randomUUID() : void 0;
1084
1084
  if (key) io.stderr(`key: ${escapeTerminalControls(key)}\n`);
1085
+ let instance;
1086
+ let outcome;
1085
1087
  try {
1086
- const result = await (options.sendOperation ?? requestOperation)(op, input, key, config);
1088
+ outcome = { value: await (options.sendOperation ?? requestOperation)(op, input, key, config, (seen) => {
1089
+ instance = seen;
1090
+ }) };
1091
+ } catch (error) {
1092
+ outcome = { error };
1093
+ }
1094
+ const reportedMismatch = "error" in outcome && outcome.error instanceof CliError && outcome.error.code === "version_mismatch";
1095
+ if (instance !== void 0 && !reportedMismatch) {
1096
+ const cliVersion = cliPackageVersion();
1097
+ const cache = cliVersion === void 0 ? memoryVerdictCache() : fileVerdictCache(join(dirname(configFile(env, options.home ?? homedir())), "version-check.json"), cliVersion);
1098
+ const verdict = await checkCatalog(config, instance, cache);
1099
+ if (verdict !== void 0 && !verdict.matches) io.stderr(`warning: the daemon catalog ${verdict.serverVersion.slice(0, 12)} differs from this CLI's ${verdict.clientVersion.slice(0, 12)}. ${ERRORS.version_mismatch.hint}\n`);
1100
+ }
1101
+ try {
1102
+ if ("error" in outcome) throw outcome.error;
1103
+ const result = outcome.value;
1087
1104
  io.stdout(`${json || !io.isTTY ? JSON.stringify(result) : formatHuman(result)}\n`);
1088
1105
  } catch (error) {
1089
1106
  if (error instanceof CliError) {
@@ -1,4 +1,4 @@
1
- import { M as errorContextSchema, N as errorEnvelopeSchema, P as successEnvelopeSchema, j as ERRORS, l as renderJsonError } from "./src-BFgTh55w.mjs";
1
+ import { M as errorContextSchema, N as errorEnvelopeSchema, P as successEnvelopeSchema, j as ERRORS, l as renderJsonError } from "./src-g7u1onIP.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { z } from "zod";
4
4
  import { request } from "node:http";
@@ -84,9 +84,15 @@ function decodeResponse(output, response, raw) {
84
84
  }
85
85
  throw problemError(response.statusCode ?? 0, response.headers["content-type"], body);
86
86
  }
87
- async function post(path, body, config, output, anonymous) {
87
+ const instanceHeader = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
88
+ /** The daemon instance a response came from, when it sends a well-formed one. */
89
+ function responseInstance(response) {
90
+ const value = response.headers["x-board-instance"];
91
+ return typeof value === "string" && instanceHeader.test(value) ? value : void 0;
92
+ }
93
+ function send({ method, path, body, config, anonymous, decode, observe }) {
88
94
  const target = new URL(path, config.address);
89
- const payload = JSON.stringify(body);
95
+ const payload = method === "POST" ? JSON.stringify(body) : void 0;
90
96
  const transport = target.protocol === "https:" ? request$1 : request;
91
97
  return new Promise((resolve, reject) => {
92
98
  let connected = false;
@@ -100,15 +106,19 @@ async function post(path, body, config, output, anonymous) {
100
106
  else resolve(result.value);
101
107
  };
102
108
  const req = transport(target, {
103
- method: "POST",
109
+ method,
104
110
  agent: false,
105
111
  headers: {
106
- "content-type": "application/json",
107
112
  accept: "application/json, application/problem+json",
108
- "content-length": Buffer.byteLength(payload),
113
+ ...payload === void 0 ? {} : {
114
+ "content-type": "application/json",
115
+ "content-length": Buffer.byteLength(payload)
116
+ },
109
117
  ...anonymous ? {} : config.session ? { "x-board-session": config.session } : { authorization: `Bearer ${config.token ?? ""}` }
110
118
  }
111
119
  }, (response) => {
120
+ const instance = responseInstance(response);
121
+ if (instance !== void 0) observe?.(instance);
112
122
  const chunks = [];
113
123
  response.on("data", (chunk) => {
114
124
  chunks.push(chunk);
@@ -118,7 +128,7 @@ async function post(path, body, config, output, anonymous) {
118
128
  });
119
129
  response.once("end", () => {
120
130
  try {
121
- settle({ value: decodeResponse(output, response, Buffer.concat(chunks).toString()) });
131
+ settle({ value: decode(response, Buffer.concat(chunks).toString()) });
122
132
  } catch (error) {
123
133
  settle({ error: error instanceof CliError ? error : new CliError("outcome_unknown") });
124
134
  }
@@ -142,12 +152,48 @@ async function post(path, body, config, output, anonymous) {
142
152
  });
143
153
  }
144
154
  /** Shared daemon HTTP client. The CLI owns output; this module owns transport and wire parsing. */
145
- async function requestOperation(op, input, key, config) {
155
+ async function requestOperation(op, input, key, config, observe) {
146
156
  if (!config.session && !config.token) throw new CliError("config");
147
- return post(`/v1/ops/${encodeURIComponent(op.id)}`, {
148
- input,
149
- ...key ? { key } : {}
150
- }, config, op.output, false);
157
+ return send({
158
+ method: "POST",
159
+ path: `/v1/ops/${encodeURIComponent(op.id)}`,
160
+ body: {
161
+ input,
162
+ ...key ? { key } : {}
163
+ },
164
+ config,
165
+ anonymous: false,
166
+ decode: (response, raw) => decodeResponse(op.output, response, raw),
167
+ observe
168
+ });
169
+ }
170
+ const schemaVersion = z.looseObject({ version: z.string().regex(/^[0-9a-f]{64}$/) });
171
+ /**
172
+ * The daemon's catalog version and instance. `/v1/schema` answers with the bare snapshot, not a
173
+ * success envelope.
174
+ */
175
+ function fetchCatalogVersion(config) {
176
+ return send({
177
+ method: "GET",
178
+ path: "/v1/schema",
179
+ config,
180
+ anonymous: true,
181
+ decode: (response, raw) => {
182
+ let body;
183
+ try {
184
+ body = JSON.parse(raw);
185
+ } catch {
186
+ throw new CliError("outcome_unknown");
187
+ }
188
+ if (response.statusCode !== 200) throw problemError(response.statusCode ?? 0, response.headers["content-type"], body);
189
+ const parsed = schemaVersion.safeParse(body);
190
+ if (!parsed.success) throw new CliError("outcome_unknown");
191
+ return {
192
+ version: parsed.data.version,
193
+ instance: responseInstance(response)
194
+ };
195
+ }
196
+ });
151
197
  }
152
198
  const enrollmentOutput = z.strictObject({
153
199
  name: z.string(),
@@ -156,7 +202,14 @@ const enrollmentOutput = z.strictObject({
156
202
  });
157
203
  /** Bootstrap sends a hash and no authorization header. */
158
204
  function requestTokenEnrollment(input, config) {
159
- return post("/v1/tokens/requests", input, config, enrollmentOutput, true);
205
+ return send({
206
+ method: "POST",
207
+ path: "/v1/tokens/requests",
208
+ body: input,
209
+ config,
210
+ anonymous: true,
211
+ decode: (response, raw) => decodeResponse(enrollmentOutput, response, raw)
212
+ });
160
213
  }
161
214
  //#endregion
162
- export { renderCliError as a, errorExitCode as i, requestTokenEnrollment as n, escapeTerminalControls as o, CliError as r, requestOperation as t };
215
+ export { errorExitCode as a, CliError as i, requestOperation as n, renderCliError as o, requestTokenEnrollment as r, escapeTerminalControls as s, fetchCatalogVersion as t };
package/dist/client.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as requestTokenEnrollment, t as requestOperation } from "./client-Ln76BdDz.mjs";
2
- export { requestOperation, requestTokenEnrollment };
1
+ import { n as requestOperation, r as requestTokenEnrollment, t as fetchCatalogVersion } from "./client-D4cveOHQ.mjs";
2
+ export { fetchCatalogVersion, requestOperation, requestTokenEnrollment };
@@ -0,0 +1,229 @@
1
+ import { a as buildCatalogSnapshot, s as OPERATIONS, z as configSchema } from "./src-g7u1onIP.mjs";
2
+ import { i as CliError, t as fetchCatalogVersion } from "./client-D4cveOHQ.mjs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { z } from "zod";
8
+ import YAML from "yaml";
9
+ //#region src/config.ts
10
+ const CONNECT_TIMEOUT_MS = 2e3;
11
+ const RESPONSE_TIMEOUT_MS = 3e4;
12
+ function configFile(env, home) {
13
+ const boardHome = env["BOARD_HOME"];
14
+ if (boardHome) return join(resolve(boardHome), "config.yaml");
15
+ const xdg = env["XDG_CONFIG_HOME"];
16
+ return join(xdg && isAbsolute(xdg) ? xdg : join(home, ".config"), "spur-board", "config.yaml");
17
+ }
18
+ function resolveDaemonAddress(env = process.env, home = homedir(), configuredPort) {
19
+ const rawAddress = env["BOARD_DAEMON_URL"];
20
+ if (rawAddress === void 0) {
21
+ let port = configuredPort ?? 4311;
22
+ if (configuredPort === void 0) try {
23
+ const parsed = YAML.parseDocument(readFileSync(configFile(env, home), "utf8"));
24
+ if (parsed.errors.length === 0) {
25
+ const config = configSchema.safeParse(parsed.toJS());
26
+ if (config.success) port = config.data.http.port;
27
+ }
28
+ } catch {}
29
+ 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.");
30
+ return new URL(`http://127.0.0.1:${String(port)}`);
31
+ }
32
+ let address;
33
+ try {
34
+ address = new URL(rawAddress);
35
+ } catch {
36
+ throw new CliError("config", { environmentVariable: "BOARD_DAEMON_URL" });
37
+ }
38
+ if (!["http:", "https:"].includes(address.protocol) || address.username || address.password) throw new CliError("config", { environmentVariable: "BOARD_DAEMON_URL" });
39
+ return address;
40
+ }
41
+ function tokenInFolder(folder, warn) {
42
+ const directory = join(folder, ".spur-board");
43
+ const file = join(directory, "token");
44
+ try {
45
+ const owner = process.getuid?.();
46
+ const directoryInfo = lstatSync(directory);
47
+ if (!directoryInfo.isDirectory() || directoryInfo.uid !== owner || (directoryInfo.mode & 63) !== 0) throw new Error("unsafe token directory");
48
+ const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
49
+ let value;
50
+ try {
51
+ const fileInfo = fstatSync(descriptor);
52
+ if (!fileInfo.isFile() || fileInfo.uid !== owner || (fileInfo.mode & 63) !== 0) throw new Error("unsafe token file");
53
+ value = readFileSync(descriptor, "utf8").trimEnd();
54
+ } finally {
55
+ closeSync(descriptor);
56
+ }
57
+ if (value === "") throw new Error("empty token file");
58
+ return value;
59
+ } catch (error) {
60
+ if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") return;
61
+ warn("Ignoring an unsafe .spur-board/token candidate.\n");
62
+ return;
63
+ }
64
+ }
65
+ function findTokenFile(cwd, home, warn = (message) => {
66
+ process.stderr.write(message);
67
+ }) {
68
+ const current = realpathSync(cwd);
69
+ const boundary = realpathSync(home);
70
+ if (current === boundary) return void 0;
71
+ const toCurrent = relative(boundary, current);
72
+ const outsideHome = toCurrent === ".." || toCurrent.startsWith(`..${sep}`) || isAbsolute(toCurrent);
73
+ let folder = current;
74
+ while (folder !== boundary) {
75
+ if (outsideHome) {
76
+ const info = statSync(folder);
77
+ if (info.uid !== process.getuid?.() || (info.mode & 2) !== 0) break;
78
+ }
79
+ const token = tokenInFolder(folder, warn);
80
+ if (token !== void 0) return token;
81
+ const parent = dirname(folder);
82
+ if (parent === folder) break;
83
+ folder = parent;
84
+ }
85
+ }
86
+ function loadClientConfig(env = process.env, paths = {
87
+ cwd: process.cwd(),
88
+ home: homedir()
89
+ }) {
90
+ const address = resolveDaemonAddress(env, paths.home);
91
+ const session = env["SPUR_SESSION"] || void 0;
92
+ const token = session ? void 0 : env["BOARD_TOKEN"] === void 0 ? findTokenFile(paths.cwd, paths.home) : env["BOARD_TOKEN"];
93
+ if (!session && !token) throw new CliError("config", { environmentVariable: "BOARD_TOKEN" });
94
+ return {
95
+ address,
96
+ session,
97
+ token,
98
+ connectTimeoutMs: CONNECT_TIMEOUT_MS,
99
+ responseTimeoutMs: RESPONSE_TIMEOUT_MS
100
+ };
101
+ }
102
+ /** Enrollment sends only a hash and works before any operator credential exists. */
103
+ function loadEnrollmentConfig(env = process.env) {
104
+ return {
105
+ address: resolveDaemonAddress(env),
106
+ session: void 0,
107
+ token: void 0,
108
+ connectTimeoutMs: CONNECT_TIMEOUT_MS,
109
+ responseTimeoutMs: RESPONSE_TIMEOUT_MS
110
+ };
111
+ }
112
+ //#endregion
113
+ //#region src/version.ts
114
+ let clientVersion;
115
+ /** The catalog version this client was built with; the snapshot is built once per process. */
116
+ function clientCatalogVersion() {
117
+ if (clientVersion === void 0) {
118
+ const pending = buildCatalogSnapshot(OPERATIONS).then((snapshot) => snapshot.version);
119
+ clientVersion = pending;
120
+ pending.catch(() => {
121
+ if (clientVersion === pending) clientVersion = void 0;
122
+ });
123
+ }
124
+ return clientVersion;
125
+ }
126
+ /** The CLI package version, or `undefined` when its manifest cannot be read. */
127
+ function cliPackageVersion() {
128
+ try {
129
+ const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))?.version;
130
+ return typeof version === "string" && version ? version : void 0;
131
+ } catch {
132
+ return;
133
+ }
134
+ }
135
+ /** Compares the client catalog with the daemon's; rejects when the daemon cannot answer. */
136
+ async function compareCatalogs(config) {
137
+ const [clientVersion, served] = await Promise.all([clientCatalogVersion(), fetchCatalogVersion(config)]);
138
+ return {
139
+ clientVersion,
140
+ serverVersion: served.version,
141
+ matches: clientVersion === served.version,
142
+ instance: served.instance
143
+ };
144
+ }
145
+ /**
146
+ * Compares the client catalog with the daemon instance's catalog once per cache entry. Any
147
+ * failure returns `undefined`, so the caller carries on unchanged.
148
+ */
149
+ function checkCatalog(config, instance, cache) {
150
+ return cache.check(config.address, instance, () => compareCatalogs(config).catch(() => void 0));
151
+ }
152
+ /** In-process cache that shares an in-flight check between overlapping callers. */
153
+ function memoryVerdictCache() {
154
+ const entries = /* @__PURE__ */ new Map();
155
+ const keyOf = (address, instance) => `${address.href} ${instance}`;
156
+ return {
157
+ check(address, instance, run) {
158
+ const key = keyOf(address, instance);
159
+ const held = entries.get(key);
160
+ if (held !== void 0) return held;
161
+ const pending = run();
162
+ entries.set(key, pending);
163
+ pending.then((verdict) => {
164
+ if (verdict === void 0 && entries.get(key) === pending) entries.delete(key);
165
+ });
166
+ return pending;
167
+ },
168
+ remember(address, instance, verdict) {
169
+ entries.set(keyOf(address, instance), Promise.resolve(verdict));
170
+ }
171
+ };
172
+ }
173
+ const fileEntrySchema = z.object({
174
+ cliPackageVersion: z.string(),
175
+ address: z.string(),
176
+ instance: z.string(),
177
+ clientVersion: z.string(),
178
+ serverVersion: z.string()
179
+ });
180
+ const fileSchema = z.object({ entries: z.array(fileEntrySchema) });
181
+ function readEntries(path) {
182
+ try {
183
+ const parsed = fileSchema.safeParse(JSON.parse(readFileSync(path, "utf8")));
184
+ return parsed.success ? parsed.data.entries : [];
185
+ } catch {
186
+ return [];
187
+ }
188
+ }
189
+ function writeEntries(path, entries) {
190
+ const temporary = `${path}.${randomUUID()}.tmp`;
191
+ try {
192
+ mkdirSync(dirname(path), {
193
+ recursive: true,
194
+ mode: 448
195
+ });
196
+ writeFileSync(temporary, `${JSON.stringify({ entries })}\n`, { mode: 384 });
197
+ renameSync(temporary, path);
198
+ } catch {
199
+ try {
200
+ unlinkSync(temporary);
201
+ } catch {}
202
+ }
203
+ }
204
+ /** Verdicts kept across CLI invocations, keyed by CLI package version, address and instance. */
205
+ function fileVerdictCache(path, cliVersion) {
206
+ return { async check(address, instance, run) {
207
+ const matchesKey = (entry) => entry.cliPackageVersion === cliVersion && entry.address === address.href && entry.instance === instance;
208
+ const held = readEntries(path).find(matchesKey);
209
+ if (held !== void 0) return {
210
+ clientVersion: held.clientVersion,
211
+ serverVersion: held.serverVersion,
212
+ matches: held.clientVersion === held.serverVersion
213
+ };
214
+ const verdict = await run();
215
+ if (verdict !== void 0) {
216
+ const entry = {
217
+ cliPackageVersion: cliVersion,
218
+ address: address.href,
219
+ instance,
220
+ clientVersion: verdict.clientVersion,
221
+ serverVersion: verdict.serverVersion
222
+ };
223
+ writeEntries(path, [...readEntries(path).filter((item) => !matchesKey(item)), entry].slice(-16));
224
+ }
225
+ return verdict;
226
+ } };
227
+ }
228
+ //#endregion
229
+ export { memoryVerdictCache as a, loadEnrollmentConfig as c, fileVerdictCache as i, resolveDaemonAddress as l, cliPackageVersion as n, configFile as o, compareCatalogs as r, loadClientConfig as s, checkCatalog as t };
package/dist/daemon.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { A as taskKeySchema, B as SLACK_BOT_SCOPES, C as sessionFactsSchema, D as errorCodeOf, E as encodeActorId, L as DEFAULT_SLACK_COMMAND, O as parseActorId, P as successEnvelopeSchema, R as configSchema, S as prRefSchema, T as actorIdSchema, _ as EVENT_KINDS, a as buildCatalogSnapshot, b as SESSION_STATUSES, c as renderHttpProblem, d as SPACE_IDS, f as conversationRefSchema, g as userIdSchema, h as messageRefSchema, i as sourceCursorSchema, j as ERRORS, k as parseRef$1, m as interactionRefSchema, n as batchSchema, o as roleCanUseOperation, p as inboundEventSchema, r as entityRefSchema, s as OPERATIONS, t as AdapterError, v as eventPayloadSchemas, w as ticketFactsSchema, x as prFactsSchema, y as SESSION_HEALTH_STATES, z as requiredSecretKeys } from "./src-BFgTh55w.mjs";
1
+ import { A as taskKeySchema, B as requiredSecretKeys, C as sessionFactsSchema, D as errorCodeOf, E as encodeActorId, L as DEFAULT_SLACK_COMMAND, O as parseActorId, P as successEnvelopeSchema, R as RESERVED_PROJECT, S as prRefSchema, T as actorIdSchema, V as SLACK_BOT_SCOPES, _ as EVENT_KINDS, a as buildCatalogSnapshot, b as SESSION_STATUSES, c as renderHttpProblem, d as SPACE_IDS, f as conversationRefSchema, g as userIdSchema, h as messageRefSchema, i as sourceCursorSchema, j as ERRORS, k as parseRef$1, m as interactionRefSchema, n as batchSchema, o as roleCanUseOperation, p as inboundEventSchema, r as entityRefSchema, s as OPERATIONS, t as AdapterError, v as eventPayloadSchemas, w as ticketFactsSchema, x as prFactsSchema, y as SESSION_HEALTH_STATES, z as configSchema } from "./src-g7u1onIP.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { closeSync, existsSync, fstatSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, writeSync } from "node:fs";
@@ -7661,18 +7661,92 @@ async function scanTransaction(store, fn) {
7661
7661
  throw new Error("Gateway secret scan replay limit exceeded");
7662
7662
  }
7663
7663
  //#endregion
7664
+ //#region ../core/src/app/board-utils.ts
7665
+ function trackerSource(project) {
7666
+ return project.tracker.type === "jira" ? `jira:${project.id}` : `github-issues:${project.id}:${project.tracker.repo.toLowerCase()}`;
7667
+ }
7668
+ function projectForKey(config, key) {
7669
+ const lowerKey = key.toLowerCase();
7670
+ const matched = config.projects.filter((project) => {
7671
+ const tracker = project.tracker;
7672
+ if (tracker.type === "github-issues") {
7673
+ const prefix = `${tracker.repo.toLowerCase()}#`;
7674
+ const short = tracker.repo.split("/")[1];
7675
+ return lowerKey.startsWith(prefix) || short !== void 0 && lowerKey.startsWith(`${short.toLowerCase()}#`);
7676
+ }
7677
+ const prefix = /^([A-Za-z][A-Za-z0-9_]*)-/.exec(key)?.[1]?.toUpperCase();
7678
+ return prefix !== void 0 && (tracker.projectKeys.length === 0 || tracker.projectKeys.includes(prefix));
7679
+ });
7680
+ return matched.length === 1 ? matched[0] ?? null : null;
7681
+ }
7682
+ async function emit(trx, kind, project, taskId, at, payload, actor = "system") {
7683
+ const parsed = parseActorId(actor);
7684
+ if (parsed === null) throw new Error("Invalid journal actor");
7685
+ await trx.insertInto("event").values({
7686
+ at,
7687
+ project,
7688
+ task_id: taskId,
7689
+ kind,
7690
+ actor: parsed.actor,
7691
+ actor_ref: parsed.actor === "agent" ? JSON.stringify(parsed.actorRef) : null,
7692
+ payload: toJson(eventPayloadSchemas[kind], payload),
7693
+ v: 1,
7694
+ request_id: null
7695
+ }).execute();
7696
+ }
7697
+ function taskView(task) {
7698
+ return {
7699
+ key: task.key,
7700
+ project: task.project,
7701
+ column: task.column,
7702
+ membership: task.membership
7703
+ };
7704
+ }
7705
+ function linkView(link) {
7706
+ return {
7707
+ kind: link.entity,
7708
+ ref: link.entity === "pr" ? `${link.repo ?? ""}#${String(link.number ?? "")}` : link.entity === "session" ? link.session_id ?? "" : link.ticket_key ?? "",
7709
+ role: link.role
7710
+ };
7711
+ }
7712
+ //#endregion
7664
7713
  //#region ../core/src/app/denied-event.ts
7665
7714
  const actorRefSchema$2 = z.strictObject({
7666
7715
  project: z.string().min(1),
7667
7716
  session: z.string().min(1)
7668
7717
  });
7718
+ /**
7719
+ * The journal project of a denial with no resolved target, in order: the configured project
7720
+ * the input's `task` key or `project` names; else the only configured project listing the agent
7721
+ * actor's Spur project; else `RESERVED_PROJECT`. Never throws.
7722
+ */
7723
+ function deniedProject(config, actor, input) {
7724
+ if (typeof input === "object" && input !== null) {
7725
+ const hints = input;
7726
+ if (typeof hints.task === "string") {
7727
+ const project = projectForKey(config, hints.task);
7728
+ if (project !== null) return project.id;
7729
+ }
7730
+ if (typeof hints.project === "string") {
7731
+ const named = hints.project;
7732
+ if (config.projects.some((project) => project.id === named)) return named;
7733
+ }
7734
+ }
7735
+ const parsed = parseActorId(actor);
7736
+ if (parsed?.actor === "agent") {
7737
+ const owners = config.projects.filter((project) => project.spurProjects.some((spur) => spur.name === parsed.actorRef.project));
7738
+ if (owners.length === 1 && owners[0] !== void 0) return owners[0].id;
7739
+ }
7740
+ return RESERVED_PROJECT;
7741
+ }
7669
7742
  /** Persist an authorization refusal through the caller's transaction. */
7670
- async function writeDeniedEvent(trx, actor, operation, target, at) {
7743
+ async function writeDeniedEvent(trx, denial) {
7744
+ const { config, actor, operation, input, target, at } = denial;
7671
7745
  const parsed = parseActorId(actor);
7672
7746
  if (parsed === null) throw new Error("Invalid actor");
7673
7747
  await trx.insertInto("event").values({
7674
7748
  at,
7675
- project: target.kind === "found" ? target.value.project : "board",
7749
+ project: target.kind === "found" ? target.value.project : deniedProject(config, actor, input),
7676
7750
  task_id: target.kind === "found" ? target.value.task?.id ?? null : null,
7677
7751
  kind: "operation_denied",
7678
7752
  actor: parsed.actor,
@@ -7787,7 +7861,14 @@ function createApply(deps) {
7787
7861
  ...mode === void 0 ? {} : { mode }
7788
7862
  });
7789
7863
  if (decision.status === "denied") {
7790
- await writeDeniedEvent(trx, actor, entry.id, target, at);
7864
+ await writeDeniedEvent(trx, {
7865
+ config,
7866
+ actor,
7867
+ operation: entry.id,
7868
+ input: parsedInput,
7869
+ target,
7870
+ at
7871
+ });
7791
7872
  deniedHint = decision.hint;
7792
7873
  return;
7793
7874
  }
@@ -7849,7 +7930,14 @@ function createQuery(deps) {
7849
7930
  target
7850
7931
  });
7851
7932
  if (decision.status === "denied") {
7852
- await writeDeniedEvent(trx, actor, entry.id, target, new Date(clock.now()).toISOString());
7933
+ await writeDeniedEvent(trx, {
7934
+ config,
7935
+ actor,
7936
+ operation: entry.id,
7937
+ input: parsedInput,
7938
+ target,
7939
+ at: new Date(clock.now()).toISOString()
7940
+ });
7853
7941
  return {
7854
7942
  status: "denied",
7855
7943
  hint: decision.hint
@@ -8490,11 +8578,13 @@ const NO_FEED = /* @__PURE__ */ new Set([
8490
8578
  "answer_undelivered",
8491
8579
  "transport_health"
8492
8580
  ]);
8581
+ /** Display label of a feed line with no task; not part of the line's identity. */
8582
+ const TASKLESS_FEED_KEY = "spur-board";
8493
8583
  async function journal$2(trx, item, kind, at, rule) {
8494
8584
  const task = item.task_id === null ? void 0 : await trx.selectFrom("task").select(["project"]).where("id", "=", item.task_id).executeTakeFirstOrThrow();
8495
8585
  await trx.insertInto("event").values({
8496
8586
  at: at.toISOString(),
8497
- project: task?.project ?? "board",
8587
+ project: task?.project ?? "_board",
8498
8588
  task_id: item.task_id,
8499
8589
  kind,
8500
8590
  actor: "system",
@@ -8531,7 +8621,7 @@ async function raiseItem(trx, input) {
8531
8621
  }).returningAll().executeTakeFirstOrThrow();
8532
8622
  await journal$2(trx, item, "from_you_raised", input.at, input.rule);
8533
8623
  if (!NO_FEED.has(item.kind) && !input.suppressFeed) {
8534
- const key = (item.task_id === null ? null : await trx.selectFrom("task").select("key").where("id", "=", item.task_id).executeTakeFirstOrThrow())?.key ?? "board";
8624
+ const key = (item.task_id === null ? null : await trx.selectFrom("task").select("key").where("id", "=", item.task_id).executeTakeFirstOrThrow())?.key ?? TASKLESS_FEED_KEY;
8535
8625
  const line = await enqueueFeedLine(trx, {
8536
8626
  itemId: item.id,
8537
8627
  itemKey: item.item_key,
@@ -8580,7 +8670,7 @@ async function clearItem(trx, itemId, at) {
8580
8670
  itemKey: item.item_key,
8581
8671
  raise: item.raise_no,
8582
8672
  lineId: item.line_outbox_id,
8583
- key: task?.key ?? "board",
8673
+ key: task?.key ?? TASKLESS_FEED_KEY,
8584
8674
  taskId: item.task_id,
8585
8675
  at
8586
8676
  });
@@ -8595,7 +8685,7 @@ function effectiveStatus(row, at) {
8595
8685
  async function journalToken(trx, kind, name, at) {
8596
8686
  await trx.insertInto("event").values({
8597
8687
  at: at.toISOString(),
8598
- project: "board",
8688
+ project: RESERVED_PROJECT,
8599
8689
  task_id: null,
8600
8690
  kind,
8601
8691
  actor: kind === "token_approved" || kind === "token_denied" || kind === "token_revoked" ? "operator" : "system",
@@ -8821,12 +8911,15 @@ async function createApiServer(options) {
8821
8911
  trustProxy: false
8822
8912
  });
8823
8913
  const snapshot = await buildCatalogSnapshot(OPERATIONS);
8914
+ /** Lets clients notice a daemon restart and check its catalog again. */
8915
+ const instance = randomUUID();
8824
8916
  const callers = /* @__PURE__ */ new WeakMap();
8825
8917
  let doctorInFlight = null;
8826
8918
  let doctorCached = null;
8827
8919
  let doctorNextProbeAt = 0;
8828
8920
  app.addHook("onRequest", async (request, reply) => {
8829
8921
  reply.header("x-request-id", request.id);
8922
+ reply.header("x-board-instance", instance);
8830
8923
  if (request.headers.origin !== void 0) throw new CommandError("forbidden", "Browser origins are not accepted.");
8831
8924
  const address = app.server.address();
8832
8925
  const port = address !== null && typeof address === "object" ? address.port : options.port;
@@ -8912,7 +9005,7 @@ async function createApiServer(options) {
8912
9005
  const caller = callers.get(request);
8913
9006
  if (caller === void 0) throw new Error("Operation identity was not resolved.");
8914
9007
  const operation = OPERATIONS.find((entry) => entry.id === request.params.id);
8915
- if (operation === void 0) throw new CommandError("usage", "Unknown catalog operation.");
9008
+ if (operation === void 0) throw new CommandError("version_mismatch", "This daemon does not know the operation. Run `board service restart` to update the daemon, then retry.");
8916
9009
  const body = inputBody(request.body);
8917
9010
  const parsed = operation.input.safeParse(body.input);
8918
9011
  if (!parsed.success) throw new CommandError(errorCodeOf(parsed.error), "Fix the operation input.");
@@ -9530,55 +9623,6 @@ function createBlockMutations(config) {
9530
9623
  };
9531
9624
  }
9532
9625
  //#endregion
9533
- //#region ../core/src/app/board-utils.ts
9534
- function trackerSource(project) {
9535
- return project.tracker.type === "jira" ? `jira:${project.id}` : `github-issues:${project.id}:${project.tracker.repo.toLowerCase()}`;
9536
- }
9537
- function projectForKey(config, key) {
9538
- const lowerKey = key.toLowerCase();
9539
- const matched = config.projects.filter((project) => {
9540
- const tracker = project.tracker;
9541
- if (tracker.type === "github-issues") {
9542
- const prefix = `${tracker.repo.toLowerCase()}#`;
9543
- const short = tracker.repo.split("/")[1];
9544
- return lowerKey.startsWith(prefix) || short !== void 0 && lowerKey.startsWith(`${short.toLowerCase()}#`);
9545
- }
9546
- const prefix = /^([A-Za-z][A-Za-z0-9_]*)-/.exec(key)?.[1]?.toUpperCase();
9547
- return prefix !== void 0 && (tracker.projectKeys.length === 0 || tracker.projectKeys.includes(prefix));
9548
- });
9549
- return matched.length === 1 ? matched[0] ?? null : null;
9550
- }
9551
- async function emit(trx, kind, project, taskId, at, payload, actor = "system") {
9552
- const parsed = parseActorId(actor);
9553
- if (parsed === null) throw new Error("Invalid journal actor");
9554
- await trx.insertInto("event").values({
9555
- at,
9556
- project,
9557
- task_id: taskId,
9558
- kind,
9559
- actor: parsed.actor,
9560
- actor_ref: parsed.actor === "agent" ? JSON.stringify(parsed.actorRef) : null,
9561
- payload: toJson(eventPayloadSchemas[kind], payload),
9562
- v: 1,
9563
- request_id: null
9564
- }).execute();
9565
- }
9566
- function taskView(task) {
9567
- return {
9568
- key: task.key,
9569
- project: task.project,
9570
- column: task.column,
9571
- membership: task.membership
9572
- };
9573
- }
9574
- function linkView(link) {
9575
- return {
9576
- kind: link.entity,
9577
- ref: link.entity === "pr" ? `${link.repo ?? ""}#${String(link.number ?? "")}` : link.entity === "session" ? link.session_id ?? "" : link.ticket_key ?? "",
9578
- role: link.role
9579
- };
9580
- }
9581
- //#endregion
9582
9626
  //#region ../core/src/app/membership.ts
9583
9627
  function canonicalKey(config, key) {
9584
9628
  const parsed = parseRef$1(key, "task");
@@ -9988,6 +10032,7 @@ const EVENT_SUMMARIES = {
9988
10032
  doorbell_read: "Doorbell read",
9989
10033
  doorbell_undelivered: "Doorbell delivery exhausted",
9990
10034
  inbound_failed: "Inbound message failed",
10035
+ inbound_skipped: "Inbound message skipped",
9991
10036
  from_you_raised: "Operator item raised",
9992
10037
  from_you_cleared: "Operator item cleared",
9993
10038
  flag_raised: "Flag raised",
@@ -11100,7 +11145,7 @@ function createObservationCore(deps) {
11100
11145
  key,
11101
11146
  value: "true"
11102
11147
  }).execute();
11103
- await emit(trx, "spur_unknown_state", "board", null, new Date(clock.now()).toISOString(), {
11148
+ await emit(trx, "spur_unknown_state", RESERVED_PROJECT, null, new Date(clock.now()).toISOString(), {
11104
11149
  kind,
11105
11150
  value
11106
11151
  });
@@ -11343,18 +11388,12 @@ async function journal(trx, input) {
11343
11388
  async function taskContext(trx, event, gateway) {
11344
11389
  if (!("conversation" in event) || event.conversation === void 0) return {
11345
11390
  taskId: null,
11346
- project: "board",
11347
- taskKey: null
11391
+ project: null
11348
11392
  };
11349
- const row = await trx.selectFrom("conversation").leftJoin("task", "task.id", "conversation.task_id").select([
11350
- "conversation.task_id as taskId",
11351
- "task.project",
11352
- "task.key"
11353
- ]).where("conversation.gateway", "=", gateway).where("conversation.ref", "=", event.conversation).executeTakeFirst();
11393
+ const row = await trx.selectFrom("conversation").leftJoin("task", "task.id", "conversation.task_id").select(["conversation.task_id as taskId", "task.project"]).where("conversation.gateway", "=", gateway).where("conversation.ref", "=", event.conversation).executeTakeFirst();
11354
11394
  return {
11355
11395
  taskId: row?.taskId ?? null,
11356
- project: row?.project ?? "board",
11357
- taskKey: row?.key ?? null
11396
+ project: row?.project ?? null
11358
11397
  };
11359
11398
  }
11360
11399
  async function raiseInboundItem(trx, input) {
@@ -11370,6 +11409,10 @@ async function raiseInboundItem(trx, input) {
11370
11409
  if (input.retry && item.inbound_event_json === null) await trx.updateTable("operator_item").set({ inbound_event_json: toJson(inboundEventSchema, input.event) }).where("id", "=", item.id).execute();
11371
11410
  return item;
11372
11411
  }
11412
+ /** The operation input a parsed query command carries; legacy stored commands carry none. */
11413
+ function commandInput(event) {
11414
+ return event.kind === "command" && event.command?.kind === "query" ? event.command.input : void 0;
11415
+ }
11373
11416
  async function deny(trx, input) {
11374
11417
  const context = await taskContext(trx, input.event, input.gateway);
11375
11418
  await journal(trx, {
@@ -11382,7 +11425,7 @@ async function deny(trx, input) {
11382
11425
  },
11383
11426
  actor: input.actor,
11384
11427
  taskId: context.taskId,
11385
- project: context.project,
11428
+ project: context.project ?? deniedProject(input.config, input.actor, commandInput(input.event)),
11386
11429
  at: input.at
11387
11430
  });
11388
11431
  return input.privateResponse ? {
@@ -11390,6 +11433,24 @@ async function deny(trx, input) {
11390
11433
  response: "not allowed"
11391
11434
  } : { status: "ignored" };
11392
11435
  }
11436
+ /** Journal an inbound message the board does not act on; no gateway response. */
11437
+ async function skip(trx, input) {
11438
+ const context = await taskContext(trx, input.event, input.gateway);
11439
+ await journal(trx, {
11440
+ kind: "inbound_skipped",
11441
+ payload: {
11442
+ gateway: input.gateway,
11443
+ reason: input.reason,
11444
+ normalizedEvent: input.event,
11445
+ ..."author" in input.event ? { platformUser: input.event.author } : {}
11446
+ },
11447
+ actor: input.actor,
11448
+ taskId: context.taskId,
11449
+ project: context.project ?? "_board",
11450
+ at: input.at
11451
+ });
11452
+ return { status: "ignored" };
11453
+ }
11393
11454
  async function linkedComment(trx, event) {
11394
11455
  return trx.selectFrom("comment").selectAll().where("conversation_ref", "=", event.conversation).where("message_ref", "=", event.message).executeTakeFirst();
11395
11456
  }
@@ -11470,7 +11531,7 @@ function createInbound(input) {
11470
11531
  }
11471
11532
  async function processMessage(trx, event, at) {
11472
11533
  const conversation = await sourceContext(trx, event, at);
11473
- if (conversation?.block_id === null || conversation === void 0) return deny(trx, {
11534
+ if (conversation?.block_id === null || conversation === void 0) return skip(trx, {
11474
11535
  event,
11475
11536
  reason: "Unknown board conversation.",
11476
11537
  actor: "operator",
@@ -11498,7 +11559,7 @@ function createInbound(input) {
11498
11559
  return { status: "applied" };
11499
11560
  }
11500
11561
  const comment = await linkedComment(trx, event);
11501
- if (comment === void 0 || comment.author !== "operator") return deny(trx, {
11562
+ if (comment === void 0 || comment.author !== "operator") return skip(trx, {
11502
11563
  event,
11503
11564
  reason: "Original operator comment not found.",
11504
11565
  actor: "system",
@@ -11547,6 +11608,7 @@ function createInbound(input) {
11547
11608
  }
11548
11609
  async function runUser(trx, event, at) {
11549
11610
  if (event.kind === "command" && event.name !== void 0 && event.name !== config.slack.command) return deny(trx, {
11611
+ config,
11550
11612
  event,
11551
11613
  reason: "Command name does not match the configured command.",
11552
11614
  actor: "system",
@@ -11554,6 +11616,7 @@ function createInbound(input) {
11554
11616
  at
11555
11617
  });
11556
11618
  if (event.kind !== "message_deleted" && event.author !== config.operator.slack) return deny(trx, {
11619
+ config,
11557
11620
  event,
11558
11621
  reason: "Only the configured operator may act.",
11559
11622
  actor: "system",
@@ -11563,6 +11626,7 @@ function createInbound(input) {
11563
11626
  });
11564
11627
  if (event.kind === "action" || event.kind === "command") {
11565
11628
  if (!(event.kind === "action" ? event.sourceSpace !== null : true)) return deny(trx, {
11629
+ config,
11566
11630
  event,
11567
11631
  reason: "Interaction came from outside a board space.",
11568
11632
  actor: "operator",
@@ -11663,7 +11727,7 @@ function createInbound(input) {
11663
11727
  },
11664
11728
  actor: "system",
11665
11729
  taskId: context.taskId,
11666
- project: context.project,
11730
+ project: context.project ?? "_board",
11667
11731
  at
11668
11732
  });
11669
11733
  await raiseInboundItem(trx, {
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.1",
3
+ "version": "0.8.2",
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 };