spur-board 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { O as ERRORS, l as renderMcpError, o as OPERATIONS } from "./src-CqH_W7D8.mjs";
2
- import { r as CliError, t as requestOperation } from "./client-C1hJzdYE.mjs";
3
- import { t as loadClientConfig } from "./config-DQmktVi-.mjs";
1
+ import { j as ERRORS, s as OPERATIONS, u as renderMcpError } from "./src-BDNEze3S.mjs";
2
+ import { r as CliError, t as requestOperation } from "./client-C81xon5D.mjs";
3
+ import { t as loadClientConfig } from "./config-D8AEf8vU.mjs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { z } from "zod";
@@ -0,0 +1,40 @@
1
+ -- Manual ticket links and observed PR openness. Preserve existing link IDs and provenance.
2
+ CREATE TABLE link_new (
3
+ id INTEGER PRIMARY KEY,
4
+ task_id INTEGER NOT NULL REFERENCES task (id),
5
+ entity TEXT NOT NULL,
6
+ repo TEXT,
7
+ number INTEGER,
8
+ pr_state TEXT,
9
+ session_id TEXT,
10
+ role TEXT,
11
+ source TEXT NOT NULL,
12
+ banned INTEGER NOT NULL DEFAULT 0 CHECK (banned IN (0, 1)),
13
+ created_at TEXT NOT NULL,
14
+ ticket_key TEXT,
15
+ pr_open INTEGER CHECK (pr_open IN (0, 1)),
16
+ CHECK (entity <> 'pr' OR
17
+ (repo IS NOT NULL AND number IS NOT NULL AND pr_state IS NOT NULL AND pr_open IS NOT NULL)),
18
+ CHECK (entity <> 'session' OR session_id IS NOT NULL),
19
+ CHECK (entity <> 'ticket' OR ticket_key IS NOT NULL)
20
+ ) STRICT;
21
+
22
+ INSERT INTO link_new (id, task_id, entity, repo, number, pr_state, session_id, role,
23
+ source, banned, created_at, ticket_key, pr_open)
24
+ SELECT link.id, link.task_id, link.entity, link.repo, link.number, link.pr_state,
25
+ link.session_id, link.role, link.source, link.banned, link.created_at, NULL,
26
+ CASE WHEN link.entity = 'pr' THEN
27
+ CASE WHEN EXISTS (
28
+ SELECT 1 FROM pr_snapshot AS pr
29
+ WHERE pr.repo = link.repo AND pr.number = link.number AND pr.state IN ('closed', 'merged')
30
+ ) THEN 0 ELSE 1 END
31
+ ELSE NULL END
32
+ FROM link;
33
+
34
+ DROP TABLE link;
35
+ ALTER TABLE link_new RENAME TO link;
36
+ CREATE UNIQUE INDEX link_pr_pair ON link (task_id, repo, number) WHERE entity = 'pr';
37
+ CREATE UNIQUE INDEX link_session_pair ON link (task_id, session_id) WHERE entity = 'session';
38
+ CREATE UNIQUE INDEX link_ticket_pair ON link (task_id, ticket_key) WHERE entity = 'ticket';
39
+ CREATE UNIQUE INDEX link_one_active_pr ON link (task_id)
40
+ WHERE entity = 'pr' AND pr_state = 'active' AND banned = 0 AND pr_open = 1;
@@ -98,6 +98,16 @@ const ACTORS = [
98
98
  "agent",
99
99
  "system"
100
100
  ];
101
+ /** Bot OAuth scopes required by the generated Slack manifest and checked by doctor. */
102
+ const SLACK_BOT_SCOPES = [
103
+ "chat:write",
104
+ "chat:write.customize",
105
+ "groups:history",
106
+ "groups:read",
107
+ "app_mentions:read",
108
+ "commands",
109
+ "reactions:write"
110
+ ];
101
111
  //#endregion
102
112
  //#region ../contracts/src/config.ts
103
113
  const WEEKDAYS = [
@@ -235,10 +245,14 @@ const configSchema = z.strictObject({
235
245
  rejectDuplicates(items, "id", "project id", ctx);
236
246
  }),
237
247
  operator: z.strictObject({ slack: nonEmpty }),
238
- slack: z.strictObject({ channels: z.strictObject({
239
- orchestrators: nonEmpty.optional(),
240
- feed: nonEmpty.optional()
241
- }).prefault({}) }).prefault({}),
248
+ slack: z.strictObject({
249
+ command: z.string().regex(/^\/[a-z0-9_-]+$/, "Expected a slash command such as /board").default("/board"),
250
+ appId: z.string().regex(/^A[A-Z0-9]+$/, "Expected a Slack app ID").optional(),
251
+ channels: z.strictObject({
252
+ orchestrators: nonEmpty.optional(),
253
+ feed: nonEmpty.optional()
254
+ }).prefault({})
255
+ }).prefault({}),
242
256
  thresholds: z.strictObject({
243
257
  staleMultiplier: z.number().min(1).default(3),
244
258
  blockOpenMs: durationMs(864e5, "How long a block may stay open before an item is raised")
@@ -418,8 +432,15 @@ const ERRORS = {
418
432
  status: 409,
419
433
  exit: EXIT_CODES.refused,
420
434
  message: "The task is archived, so it cannot change.",
421
- hint: "Add the task to the board again with `board task add <KEY>` if work goes on.",
422
- recovery: noRetry("Add the task to the board again if work continues.")
435
+ hint: "Ask an operator to add the task to the board again if work continues.",
436
+ recovery: noRetry("Ask an operator to add the task to the board again if work continues.")
437
+ }),
438
+ link_conflict: spec("link_conflict", {
439
+ status: 409,
440
+ exit: EXIT_CODES.refused,
441
+ message: "Another linked pull request prevents this manual link.",
442
+ hint: "Read the task links and use a different task or resolve the existing pull request link.",
443
+ recovery: noRetry("Read the task links and resolve the blocking pull request.")
423
444
  }),
424
445
  retry_not_applicable: spec("retry_not_applicable", {
425
446
  status: 409,
@@ -601,8 +622,9 @@ const taskKeySchema = refSchema("task", "Task key: a Jira key (PROJ-123) or a Gi
601
622
  function errorCodeOf(error) {
602
623
  return error.issues.some((issue) => issue.code === "custom" && issue.params?.["code"] === "wrong_id_kind") ? "wrong_id_kind" : "usage";
603
624
  }
625
+ const ACTOR_PART = /^[A-Za-z0-9._-]+$/;
604
626
  /** A spur project or session name inside an actor ID. It never contains `/`. */
605
- const actorPartSchema = z.string().regex(/^[A-Za-z0-9._-]+$/, "Expected letters, digits, \".\", \"_\" or \"-\"");
627
+ const actorPartSchema = z.string().regex(ACTOR_PART, "Expected letters, digits, \".\", \"_\" or \"-\"");
606
628
  /**
607
629
  * Who did something, as one string: `operator`, `system`, or `agent:<project>/<session>` for
608
630
  * an agent, naming its spur project and session. Every store column that records a doer uses
@@ -621,6 +643,17 @@ const actorIdSchema = z.union([
621
643
  actorPartSchema
622
644
  ])
623
645
  ]).describe("Actor: operator, system, or agent:<project>/<session>.");
646
+ /** Joins the journal's `actor` and `actorRef` into an `ActorId`. Throws on an invalid pair. */
647
+ function encodeActorId(value) {
648
+ const { actor, actorRef } = value;
649
+ if (actor !== "agent") {
650
+ if (actorRef !== void 0) throw new Error("actorRef is set only when actor is agent");
651
+ return actor;
652
+ }
653
+ if (actorRef === void 0) throw new Error("actorRef is required when actor is agent");
654
+ for (const part of [actorRef.project, actorRef.session]) if (!ACTOR_PART.test(part)) throw new Error(`Invalid actor part ${JSON.stringify(part)}: use letters, digits, . _ -`);
655
+ return `agent:${actorRef.project}/${actorRef.session}`;
656
+ }
624
657
  /** Splits an `ActorId` into `actor` and `actorRef`; `null` when the text is not an actor ID. */
625
658
  function parseActorId(text) {
626
659
  const result = actorIdSchema.safeParse(text);
@@ -866,28 +899,49 @@ const eventPayloadSchemas = {
866
899
  kind: z.enum(["unknown_state", "unknown_status"]),
867
900
  value: S
868
901
  }),
869
- link_added: z.discriminatedUnion("entity", [z.looseObject({
870
- entity: z.literal("session"),
871
- session: S,
872
- role: z.enum(SESSION_LINK_ROLES),
873
- source: z.enum(LINK_SOURCES),
874
- facts: sessionFactsSchema.optional()
875
- }), z.looseObject({
876
- entity: z.literal("pr"),
877
- repo: S,
878
- pr: N,
879
- state: z.enum(PR_LINK_STATES),
880
- source: z.enum(LINK_SOURCES),
881
- facts: prFactsSchema.optional()
882
- })]),
883
- link_removed: z.discriminatedUnion("entity", [z.looseObject({
884
- entity: z.literal("session"),
885
- session: S
886
- }), z.looseObject({
887
- entity: z.literal("pr"),
888
- repo: S,
889
- pr: N
890
- })]),
902
+ link_added: z.discriminatedUnion("entity", [
903
+ z.looseObject({
904
+ entity: z.literal("session"),
905
+ session: S,
906
+ role: z.enum(SESSION_LINK_ROLES),
907
+ source: z.enum(LINK_SOURCES),
908
+ facts: sessionFactsSchema.optional(),
909
+ reason: S.optional()
910
+ }),
911
+ z.looseObject({
912
+ entity: z.literal("pr"),
913
+ repo: S,
914
+ pr: N,
915
+ state: z.enum(PR_LINK_STATES),
916
+ source: z.enum(LINK_SOURCES),
917
+ facts: prFactsSchema.optional(),
918
+ reason: S.optional()
919
+ }),
920
+ z.looseObject({
921
+ entity: z.literal("ticket"),
922
+ ticket: S,
923
+ source: z.enum(LINK_SOURCES),
924
+ reason: S.optional()
925
+ })
926
+ ]),
927
+ link_removed: z.discriminatedUnion("entity", [
928
+ z.looseObject({
929
+ entity: z.literal("session"),
930
+ session: S,
931
+ reason: S.optional()
932
+ }),
933
+ z.looseObject({
934
+ entity: z.literal("pr"),
935
+ repo: S,
936
+ pr: N,
937
+ reason: S.optional()
938
+ }),
939
+ z.looseObject({
940
+ entity: z.literal("ticket"),
941
+ ticket: S,
942
+ reason: S.optional()
943
+ })
944
+ ]),
891
945
  task_added: z.looseObject({ column: z.enum(COLUMN_IDS) }),
892
946
  task_archived: z.looseObject({ reason: z.enum(ARCHIVE_REASONS) }),
893
947
  block_opened: z.looseObject({
@@ -933,7 +987,10 @@ const eventPayloadSchemas = {
933
987
  gateway: S,
934
988
  reason: S
935
989
  }),
936
- from_you_raised: z.looseObject({ item: S }),
990
+ from_you_raised: z.looseObject({
991
+ item: S,
992
+ rule: S.optional()
993
+ }),
937
994
  from_you_cleared: z.looseObject({ item: S }),
938
995
  flag_raised: z.looseObject({
939
996
  flag: S,
@@ -1311,19 +1368,24 @@ const statusSessionFactsSchema = z.strictObject({
1311
1368
  url: z.url({ protocol: /^https?$/ }).describe("Published link URL.")
1312
1369
  })).describe("Links published by this session.")
1313
1370
  }).describe("Session facts with links as display entries.");
1371
+ const linkViewSchema = z.strictObject({
1372
+ kind: z.enum([
1373
+ "pr",
1374
+ "session",
1375
+ "ticket"
1376
+ ]).describe("Linked entity kind."),
1377
+ ref: z.string().min(1).describe("Pull request, session, or ticket identifier."),
1378
+ role: z.enum([
1379
+ "worker",
1380
+ "other",
1381
+ "orchestrator"
1382
+ ]).nullable().describe("Session role; null for a pull request or ticket.")
1383
+ }).describe("An effective link to a task.");
1314
1384
  const statusTaskSchema = taskViewSchema.extend({
1315
1385
  ticket: ticketFactsSchema.nullable().describe("Latest ticket facts, or null before observation."),
1316
1386
  prs: z.array(prFactsSchema).describe("Latest facts for linked pull requests."),
1317
1387
  sessions: z.array(statusSessionFactsSchema).describe("Latest facts for linked sessions."),
1318
- links: z.array(z.strictObject({
1319
- kind: z.enum(["pr", "session"]).describe("Linked entity kind."),
1320
- ref: z.string().min(1).describe("Pull request or session identifier."),
1321
- role: z.enum([
1322
- "worker",
1323
- "other",
1324
- "orchestrator"
1325
- ]).nullable().describe("Session role; null for a pull request.")
1326
- })).describe("Links fixed when each entity first joined the task.")
1388
+ links: z.array(linkViewSchema).describe("Effective pull request, session, and ticket links.")
1327
1389
  }).describe("A board task with its latest observed facts and links.");
1328
1390
  const statusItemSchema = itemViewSchema.extend({
1329
1391
  key: z.string().min(1).describe("Stable key of this actionable item."),
@@ -1378,7 +1440,44 @@ const schemaGetOutputSchema = z.strictObject({
1378
1440
  //#region ../contracts/src/ops/catalog.ts
1379
1441
  const project = z.string().min(1).optional().describe("Project to limit the answer to; absent means every project the caller may see.");
1380
1442
  const text = (what) => z.string().min(1).describe(what);
1381
- const target = text("What to link: a spur session name, or a pull request as owner/repo#n.");
1443
+ const target = text("Pull request, session, or ticket linked to the task.");
1444
+ const linkInput = z.discriminatedUnion("mode", [
1445
+ z.strictObject({
1446
+ mode: z.literal("pr").describe("Link a pull request."),
1447
+ task: taskKeySchema,
1448
+ pr: z.url({ protocol: /^https?$/ }).describe("Pull request URL."),
1449
+ reason: text("Why this link is being changed.")
1450
+ }),
1451
+ z.strictObject({
1452
+ mode: z.literal("session").describe("Link a spur session."),
1453
+ task: taskKeySchema,
1454
+ session: text("Spur session ID."),
1455
+ reason: text("Why this link is being changed.")
1456
+ }),
1457
+ z.strictObject({
1458
+ mode: z.literal("ticket").describe("Link an additional tracker ticket."),
1459
+ task: taskKeySchema,
1460
+ ticket: taskKeySchema,
1461
+ reason: text("Why this link is being changed.")
1462
+ })
1463
+ ]);
1464
+ const linkModes = [
1465
+ {
1466
+ mode: "pr",
1467
+ flag: "--pr",
1468
+ value: "pr"
1469
+ },
1470
+ {
1471
+ mode: "session",
1472
+ flag: "--session",
1473
+ value: "session"
1474
+ },
1475
+ {
1476
+ mode: "ticket",
1477
+ flag: "--ticket",
1478
+ value: "ticket"
1479
+ }
1480
+ ];
1382
1481
  const at = "2026-01-05T09:30:00.000Z";
1383
1482
  const task = {
1384
1483
  key: "PROJ-123",
@@ -1497,7 +1596,7 @@ const OPERATIONS = [
1497
1596
  defineOperation({
1498
1597
  id: "task.get",
1499
1598
  kind: "query",
1500
- description: "Show a task with its blocks and comments.",
1599
+ description: "Show a task with its links, blocks and comments.",
1501
1600
  mcp: {
1502
1601
  whenToUse: "Inspect one task now.",
1503
1602
  whenNotToUse: "Use task_history for past events."
@@ -1505,6 +1604,7 @@ const OPERATIONS = [
1505
1604
  input: z.strictObject({ task: taskKeySchema }),
1506
1605
  output: z.strictObject({
1507
1606
  task: taskViewSchema,
1607
+ links: z.array(linkViewSchema).describe("Effective pull request, session, and ticket links."),
1508
1608
  blocks: z.array(blockViewSchema).describe("Blocks on the task, open and unblocked."),
1509
1609
  comments: z.array(commentSchema).describe("Comments on the task, oldest first.")
1510
1610
  }),
@@ -1523,6 +1623,7 @@ const OPERATIONS = [
1523
1623
  input: { task: "PROJ-123" },
1524
1624
  output: {
1525
1625
  task,
1626
+ links: [],
1526
1627
  blocks: [question],
1527
1628
  comments: [comment]
1528
1629
  }
@@ -1809,51 +1910,49 @@ const OPERATIONS = [
1809
1910
  defineOperation({
1810
1911
  id: "link.add",
1811
1912
  kind: "mutation",
1812
- description: "Link a spur session or a pull request to a task.",
1913
+ description: "Manually link a pull request, session, or additional ticket to a task.",
1813
1914
  mcp: {
1814
- whenToUse: "Attach a session or pull request.",
1915
+ whenToUse: "Attach a pull request, session, or additional ticket.",
1815
1916
  whenNotToUse: "Use link_remove to detach one."
1816
1917
  },
1817
- input: z.strictObject({
1818
- task: taskKeySchema,
1819
- target
1820
- }),
1918
+ input: linkInput,
1821
1919
  output: linkOutput,
1822
1920
  grants: operatorOnly,
1823
1921
  errors: [
1824
1922
  "wrong_id_kind",
1825
1923
  "not_found",
1826
- "task_archived"
1924
+ "task_archived",
1925
+ "link_conflict"
1827
1926
  ],
1828
1927
  cli: {
1829
1928
  command: "link",
1830
- args: ["task", "target"]
1929
+ args: ["task"],
1930
+ modes: linkModes
1831
1931
  },
1832
1932
  slack: null,
1833
1933
  examples: [{
1834
1934
  summary: "Link a pull request",
1835
1935
  input: {
1936
+ mode: "pr",
1836
1937
  task: "PROJ-123",
1837
- target: "acme/web#7"
1938
+ pr: "https://github.com/example/web/pull/7",
1939
+ reason: "Attach work started before the ticket was named."
1838
1940
  },
1839
1941
  output: {
1840
1942
  task: "PROJ-123",
1841
- target: "acme/web#7"
1943
+ target: "example/web#7"
1842
1944
  }
1843
1945
  }]
1844
1946
  }),
1845
1947
  defineOperation({
1846
1948
  id: "link.remove",
1847
1949
  kind: "mutation",
1848
- description: "Remove a link between a task and a spur session or a pull request.",
1950
+ description: "Manually remove a pull request, session, or ticket link from a task.",
1849
1951
  mcp: {
1850
- whenToUse: "Detach a linked session or pull request.",
1952
+ whenToUse: "Detach a linked pull request, session, or ticket.",
1851
1953
  whenNotToUse: "Use link_add to attach one."
1852
1954
  },
1853
- input: z.strictObject({
1854
- task: taskKeySchema,
1855
- target
1856
- }),
1955
+ input: linkInput,
1857
1956
  output: linkOutput,
1858
1957
  grants: operatorOnly,
1859
1958
  errors: [
@@ -1863,14 +1962,17 @@ const OPERATIONS = [
1863
1962
  ],
1864
1963
  cli: {
1865
1964
  command: "unlink",
1866
- args: ["task", "target"]
1965
+ args: ["task"],
1966
+ modes: linkModes
1867
1967
  },
1868
1968
  slack: null,
1869
1969
  examples: [{
1870
1970
  summary: "Unlink a session",
1871
1971
  input: {
1972
+ mode: "session",
1872
1973
  task: "PROJ-123",
1873
- target: "web-7"
1974
+ session: "web-7",
1975
+ reason: "Wrong task."
1874
1976
  },
1875
1977
  output: {
1876
1978
  task: "PROJ-123",
@@ -2245,39 +2347,83 @@ const OPERATIONS = [
2245
2347
  ]
2246
2348
  },
2247
2349
  slack: null,
2248
- examples: [{
2249
- summary: "Unblock after fixing the build",
2250
- input: {
2251
- mode: "done",
2252
- block: "B13",
2253
- ref: "example/web#9"
2350
+ examples: [
2351
+ {
2352
+ summary: "Unblock after fixing the build",
2353
+ input: {
2354
+ mode: "done",
2355
+ block: "B13",
2356
+ ref: "example/web#9"
2357
+ },
2358
+ output: { block: {
2359
+ ...codeBlock,
2360
+ state: "unblocked",
2361
+ outcome: "done",
2362
+ outcomeReason: "example/web#9",
2363
+ unblockedBy: "agent:web/web-8",
2364
+ unblockedAt: at
2365
+ } }
2254
2366
  },
2255
- output: { block: {
2256
- ...codeBlock,
2257
- state: "unblocked",
2258
- outcome: "done",
2259
- outcomeReason: "example/web#9",
2260
- unblockedBy: "agent:web/web-8",
2261
- unblockedAt: at
2262
- } }
2263
- }, {
2264
- summary: "Withdraw a question",
2265
- input: {
2266
- mode: "withdraw",
2267
- block: "B12",
2268
- answer: "Answered outside the board."
2367
+ {
2368
+ summary: "Explain why a code block no longer applies",
2369
+ input: {
2370
+ mode: "reason",
2371
+ block: "B13",
2372
+ reason: "The build issue was resolved."
2373
+ },
2374
+ output: { block: {
2375
+ ...codeBlock,
2376
+ state: "unblocked",
2377
+ outcome: "reason",
2378
+ outcomeReason: "The build issue was resolved.",
2379
+ unblockedBy: "agent:web/web-8",
2380
+ unblockedAt: at
2381
+ } }
2269
2382
  },
2270
- output: { block: {
2271
- ...question,
2272
- state: "unblocked",
2273
- outcome: "withdrawn",
2274
- unblockedBy: "agent:web/web-7",
2275
- unblockedAt: at
2276
- } }
2277
- }]
2383
+ {
2384
+ summary: "Withdraw a question",
2385
+ input: {
2386
+ mode: "withdraw",
2387
+ block: "B12",
2388
+ answer: "Answered outside the board."
2389
+ },
2390
+ output: { block: {
2391
+ ...question,
2392
+ state: "unblocked",
2393
+ outcome: "withdrawn",
2394
+ unblockedBy: "agent:web/web-7",
2395
+ unblockedAt: at
2396
+ } }
2397
+ }
2398
+ ]
2278
2399
  })
2279
2400
  ];
2280
2401
  //#endregion
2402
+ //#region ../contracts/src/ops/roles.ts
2403
+ const ROLE_GRANTS = {
2404
+ operator: /* @__PURE__ */ new Set([
2405
+ "operator",
2406
+ "block-addressee",
2407
+ "block-author"
2408
+ ]),
2409
+ worker: /* @__PURE__ */ new Set([
2410
+ "project-agent",
2411
+ "task-worker",
2412
+ "block-addressee",
2413
+ "block-author"
2414
+ ]),
2415
+ orchestrator: /* @__PURE__ */ new Set([
2416
+ "project-agent",
2417
+ "column-orchestrator",
2418
+ "block-addressee",
2419
+ "block-author"
2420
+ ])
2421
+ };
2422
+ /** Whether a role has at least one catalog grant for an operation or a particular input mode. */
2423
+ function roleCanUseOperation(operation, role, mode) {
2424
+ return operation.grants === "any" || operation.grants.some((rule) => ROLE_GRANTS[role].has(rule.grant) && (mode === void 0 || rule.mode === void 0 || rule.mode === mode));
2425
+ }
2426
+ //#endregion
2281
2427
  //#region ../contracts/src/ops/snapshot.ts
2282
2428
  /**
2283
2429
  * The catalog as plain JSON: grants, exit codes, errors, and every operation with its JSON
@@ -2472,4 +2618,4 @@ var AdapterError = class extends Error {
2472
2618
  }
2473
2619
  };
2474
2620
  //#endregion
2475
- export { errorEnvelopeSchema as A, actorIdSchema as C, taskKeySchema as D, parseRef as E, configSchema as M, ERRORS as O, ticketFactsSchema as S, parseActorId as T, SESSION_HEALTH_STATES as _, buildCatalogSnapshot as a, prRefSchema as b, renderJsonError as c, conversationRefSchema as d, inboundEventSchema as f, eventPayloadSchemas as g, userIdSchema as h, sourceCursorSchema as i, successEnvelopeSchema as j, errorContextSchema as k, renderMcpError as l, messageRefSchema as m, batchSchema as n, OPERATIONS as o, interactionRefSchema as p, entityRefSchema as r, renderHttpProblem as s, AdapterError as t, SPACE_IDS as u, SESSION_STATUSES as v, errorCodeOf as w, sessionFactsSchema as x, prFactsSchema as y };
2621
+ export { taskKeySchema as A, sessionFactsSchema as C, errorCodeOf as D, encodeActorId as E, configSchema as F, SLACK_BOT_SCOPES as I, errorContextSchema as M, errorEnvelopeSchema as N, parseActorId as O, successEnvelopeSchema as P, 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spur-board",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "engines": {
6
6
  "node": "^24.12.0 || ^26.0.0"
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: spur-board-setup
3
+ description: Install, configure, check, and update spur-board with an operator. Use when asked to set up or upgrade the Board. Don't use for daily operations; use spur-board.
4
+ ---
5
+
6
+ # Set up spur-board
7
+
8
+ Ask one question at a time, only when current state cannot answer it. Check before every write. Ask before changing package, config, secret, service, token, skill-folder, or project-checkout state, or enabling lingering. Never show secret values or put them in chat, logs, or shell history. A same-version rerun changes nothing and repeats no answered question. Never uninstall the service or revoke a token without an explicit operator request.
9
+
10
+ ## 1. Inspect
11
+
12
+ - Check Linux, systemd user services, npm, `flock`, and Node 24.12+ (24.x) or 26.x. Read the PATH and Node executable from a live Spur session and check its version; the interactive shell may use another Node. Stop with a specific fix for any missing prerequisite.
13
+ - Check Spur through its HTTP API, or run its CLI from the home folder. Running it inside a folder with `spur.yaml` can attach that folder to the daemon.
14
+ - Locate `config.yaml` and `secrets.env` under `$XDG_CONFIG_HOME/spur-board/` (default `$HOME/.config/spur-board/`), or under `$BOARD_HOME` when set. Inspect configured fields, required secret _names_ and file modes, package and skill versions, service status, operator access, and product-skill visibility. Never print private file contents.
15
+ - If config exists, run `board doctor --offline`; it checks config only. If the service is reachable, record failed and warning check names from `board doctor --json`.
16
+
17
+ ## 2. Install the package
18
+
19
+ - Check whether the required package version and `board` binary are visible on the live session PATH. If current, skip installation.
20
+ - Otherwise choose a user npm prefix whose `bin` is on that PATH, commonly `$HOME/.local`; use the live session's Node and npm for `npm i -g --prefix <prefix> spur-board`. Verify `board --version` with the live session PATH.
21
+
22
+ ## 3. Complete missing configuration
23
+
24
+ - Discover repositories, Spur projects, and GitHub logins where possible. For each unset value, ask one question and write only that answer: Jira filter and statuses if used, repositories, project names and roles, operator GitHub login per project, and the operator's Slack user ID. Preserve settled and unrelated fields; validate against the shipped config schema.
25
+ - If Slack roles are unset, follow [Slack setup](references/slack.md). List channels visible to the bot and ask which serve orchestrators and feed; store their IDs. The bot sees only private channels it joined.
26
+ - Check required secret names and permissions. Follow [secret files](references/secrets.md) for private entry and any existing `slack.env` migration. Write `config.yaml` and a regular mode `0600` `secrets.env` only when needed.
27
+ - After config edits, run `board doctor --offline` and apply its config fixes. An offline pass does not prove service or integration readiness.
28
+
29
+ ## 4. Start service and grant access
30
+
31
+ - Check `board service status`; if absent, run `board service install`. Ask before enabling user lingering, which keeps the service running after logout.
32
+ - If operator access is unset, ask whether the operator will use a Spur project marked `operator` or a session outside Spur rooted in a chosen folder. The project path needs no token. For the folder path, check for an existing valid token; only if absent run `board token create --name <name> --into <folder>`, then ask the operator to approve the request in Slack. Check existing agent registration before registering `board mcp`.
33
+ - Check installed skill stamps and content. If absent or older, ask before writing skill folders, run `board skills install`, then verify both Claude Code and Codex copies.
34
+ - For a Spur project used by Codex, check `.agents/skills/spur-board/SKILL.md` in a fresh session. If missing, ask before touching the project's main checkout. In that checkout, link the path to the installed product skill, for example `ln -s "$HOME/.codex/skills/spur-board" .agents/skills/spur-board`. Add `/.agents/skills/spur-board` to the Git exclude file resolved by `git rev-parse --git-path info/exclude`. Add `.agents/skills/spur-board` to that project's `symlinks` in `spur.yaml` and reload Spur config. Start a new session and verify the injected SKILL.md resolves.
35
+
36
+ ## 5. Finish
37
+
38
+ - Run `board doctor --json`. For each failed check, inspect state, apply its `fix` with any needed consent, and repeat until green. Report warnings, especially live sessions started before the last skill install; they must restart to load it.
39
+ - Wait for the feed's `spur-board is live` line after the first complete poll. If absent, inspect doctor checks and service logs and continue the fix loop. Confirm receipt without exposing a channel ID.
40
+
41
+ ## Update
42
+
43
+ For a newer package, follow [the update run](references/update.md). Keep completed config and operator access intact.
@@ -0,0 +1,9 @@
1
+ # Secret files
2
+
3
+ Use this when secret names are missing or a legacy `slack.env` is present. Work in the resolved Board config directory; never display values or paste them into chat, logs, or shell history.
4
+
5
+ 1. Inspect whether `secrets.env` and `slack.env` are regular files, their modes, and their key names only. The config directory must be private and `secrets.env` must have mode `0600`.
6
+ 2. Ask the operator to enter any missing values through a private local mechanism. Required names are `GITHUB_TOKEN`; `JIRA_EMAIL` and `JIRA_API_TOKEN` when Jira is used; and `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` when Slack is configured. Do not create placeholder values.
7
+ 3. If `slack.env` exists, ask before migration. For duplicate names, ask which value to keep without revealing either. Preserve every unrelated existing `secrets.env` key.
8
+ 4. Build a new regular `secrets.env` with mode `0600` without logging its content. Verify its key names and mode before removing `slack.env`. Leave the source untouched on any error or if consent is withheld.
9
+ 5. Check only presence and permissions afterward. Online doctor performs the integration checks; an offline doctor pass does not validate the secret values.
@@ -0,0 +1,9 @@
1
+ # Slack setup
2
+
3
+ Use this when the orchestrators or feed channel is not configured, or the app-level token is missing.
4
+
5
+ 1. Check the bot's current channel membership. List the private channels it can see and ask the operator, one choice at a time, which should serve orchestrators and feed. An empty list does not prove no channels exist: the bot sees only private channels to which it was invited. Store channel IDs in config, never guessed names.
6
+ 2. If no channel fits, ask for an operator-chosen prefix and suggest available names using it. The operator creates or invites the app to channels; ask before any app or channel change.
7
+ 3. Check whether an app and slash command already exist. If either is missing, use `board slack manifest --app-name <name> --command <slash-command>` to prepare a manifest with operator-chosen names. The operator creates the Slack app; do not claim the manifest creates it.
8
+ 4. The manifest cannot create the app-level token with `connections:write`. Guide the operator through creating that token in Slack, then use the private entry procedure in [secret files](secrets.md). Never ask for a token value in chat.
9
+ 5. Check channel membership and command configuration with online doctor. If a check fails, apply its reported fix after inspecting state.
@@ -0,0 +1,11 @@
1
+ # Update run
2
+
3
+ Use this when the installed package is older than the intended release. Keep completed configuration and operator access intact.
4
+
5
+ 1. Check the package version, live Spur session Node and PATH, installed skill stamps and content, service version, and current doctor results. Ask no question already answered in config.
6
+ 2. Install the newer package with the Node and user prefix visible to live Spur sessions. Verify `board --version` there. Do not reinstall a matching package.
7
+ 3. If skills are older, ask before writing agent skill folders, run `board skills install`, and verify both installed copies. Record the install time so older live sessions can be identified.
8
+ For a local same-version trial, compare packed and installed bytes too; a matching stamp can hide old content.
9
+ 4. Compare the unit's absolute Node and daemon paths with the newly selected Node and installed package. If either path changed, run the new package's `board service install`; it rewrites the unit, reloads systemd, and restarts an active service. If paths are unchanged but the daemon version changed, run `board service restart`. Do not restart twice or restart an unchanged service.
10
+ 5. Run `board doctor --json` until no blocking check remains. Report live sessions that still use the older skill and ask the operator to restart those sessions. Do not assume an existing agent or MCP process reloaded updated files.
11
+ 6. Never rewrite settled config, reissue an operator token, uninstall the service, or revoke a token as part of an update.