@danypops/tickets 0.4.5 → 0.4.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Unified CLI, daemon, and TypeScript library for issue tracking across GitHub, GitLab, and Jira.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,7 +24,9 @@
24
24
  "typecheck": "tsc --noEmit"
25
25
  },
26
26
  "dependencies": {
27
- "@danypops/daemon-kit": "^0.3.0",
27
+ "@danypops/vehicle-core": "^0.1.1",
28
+ "@danypops/vehicle-server": "^0.1.1",
29
+ "@danypops/vehicle-client": "^0.1.1",
28
30
  "@danypops/enigma-client": "^0.3.0",
29
31
  "@gitbeaker/rest": "^43.8.0",
30
32
  "commander": "^12.1.0",
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Local, per-backend persistence for delegated OAuth tokens — separate from
3
- * daemon-kit's own daemon-auth token (paths.token, which authenticates RPC
3
+ * vehicle-server's own daemon-auth token (paths.token, which authenticates RPC
4
4
  * callers to the daemon). This one holds what the daemon uses to authenticate
5
5
  * *to* GitHub/GitLab/Jira on the user's behalf. Same security posture as
6
- * daemon-kit's ensureAuthToken: 0700 directory, 0600 files, atomic write.
6
+ * vehicle-server's ensureAuthToken: 0700 directory, 0600 files, atomic write.
7
7
  */
8
8
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
@@ -7,9 +7,9 @@
7
7
  import { spawn } from "node:child_process";
8
8
  import { dirname, join } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
- import type { DaemonHandle } from "@danypops/daemon-kit/paths";
11
- import { ensureAuthToken, readDaemonHandle, resolveDaemonPaths } from "@danypops/daemon-kit/paths";
12
- import { AuthenticatedRpcClient } from "@danypops/daemon-kit/rpc-client";
10
+ import type { DaemonHandle } from "@danypops/vehicle-server/paths";
11
+ import { ensureAuthToken, readDaemonHandle, resolveDaemonPaths } from "@danypops/vehicle-server/paths";
12
+ import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
13
13
  import { packageRoot } from "../util/package-root.js";
14
14
  import { TICKETS_DAEMON_NAMES, type TicketOpInputs, type TicketOperation, type TicketOpOutputs } from "../daemon/ops.js";
15
15
 
@@ -83,6 +83,32 @@ export async function ensureDaemonRunning(
83
83
  return { baseUrl: `http://${handle.host}:${handle.port}`, token };
84
84
  }
85
85
 
86
+ export interface VehicleClientTarget {
87
+ /** Base URL for tickets' VehicleRegistry (see ../vehicle/tickets-vehicle.ts) -- @danypops/vehicle-client's RemoteVehicleClient mounts its own /vehicle/manifest, /vehicle/invoke, /vehicle/cancel routes under this. */
88
+ baseUrl: string;
89
+ token: string;
90
+ }
91
+
92
+ /**
93
+ * Narrow, side-effect-free surface for a Vehicle-projected domain consumer --
94
+ * same daemon, same handle file, same Bearer token every other tickets RPC
95
+ * call already uses (see daemon/server.ts's buildApp, which mounts the
96
+ * Vehicle HTTP app at /vehicle/* on this same port). Deliberately does NOT
97
+ * call ensureDaemonRunning: that spawns the daemon and mints a fresh auth
98
+ * token file as a side effect, which is wrong to do just from a Pi
99
+ * extension loading and registering its tool schemas -- only reads the
100
+ * handle if the daemon has already started, mirroring how Papyrus's own
101
+ * resolveVehicleClientTarget() tolerates "never started" by returning
102
+ * undefined rather than throwing or spawning.
103
+ */
104
+ export function resolveVehicleClientTarget(env?: Record<string, string | undefined>): VehicleClientTarget | undefined {
105
+ const paths = ticketsPaths(env);
106
+ const handle = readDaemonHandle(paths.handle);
107
+ if (!handle) return undefined;
108
+ const token = ensureAuthToken(paths.token, "Tickets");
109
+ return { baseUrl: `http://${handle.host}:${handle.port}`, token };
110
+ }
111
+
86
112
  export type TicketsRpcClient = AuthenticatedRpcClient<TicketOperation, TicketOpInputs, TicketOpOutputs>;
87
113
 
88
114
  export async function createTicketsClient(opts: EnsureDaemonOptions = {}): Promise<TicketsRpcClient> {
@@ -7,8 +7,8 @@ import { existsSync, readFileSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { parse as parseYaml } from "yaml";
10
- import type { MaintenanceTask } from "@danypops/daemon-kit/daemon";
11
- import type { Logger } from "@danypops/daemon-kit/logging";
10
+ import type { MaintenanceTask } from "@danypops/vehicle-server/daemon";
11
+ import type { Logger } from "@danypops/vehicle-server/logging";
12
12
  import { GitHubRepository } from "../adapters/github.js";
13
13
  import { GitLabRepository } from "../adapters/gitlab.js";
14
14
  import { JiraRepository } from "../adapters/jira.js";
@@ -1,23 +1,24 @@
1
1
  /**
2
2
  * Composition root for the tickets daemon: wires paths, auth token, ledger
3
3
  * storage, real backend repositories, and the sync poller into the options
4
- * daemon-kit's startDaemon()/runDaemonProcess() expect. Everything here is
4
+ * vehicle-server's startDaemon()/runDaemonProcess() expect. Everything here is
5
5
  * injectable so tests can substitute fake repositories and a scratch XDG
6
6
  * root instead of hitting real GitHub/GitLab/Jira or the real home directory.
7
7
  */
8
8
  import type { Database } from "bun:sqlite";
9
- import { createLogger, type Logger } from "@danypops/daemon-kit/logging";
10
- import { ensureAuthToken, type PathEnvironment, resolveDaemonPaths } from "@danypops/daemon-kit/paths";
11
- import { checkpoint, openSqliteWithPragmas } from "@danypops/daemon-kit/storage";
12
- import type { StartDaemonOptions } from "@danypops/daemon-kit/daemon";
9
+ import { createLogger, type Logger } from "@danypops/vehicle-server/logging";
10
+ import { ensureAuthToken, type PathEnvironment, resolveDaemonPaths } from "@danypops/vehicle-server/paths";
11
+ import { checkpoint, openSqliteWithPragmas } from "@danypops/vehicle-server/storage";
12
+ import type { StartDaemonOptions } from "@danypops/vehicle-server/daemon";
13
13
  import { TicketService } from "../application/service.js";
14
14
  import { buildRepositories, type BuildRepositories, type Config, createBackendRefreshTask, loadConfig } from "../config/config.js";
15
15
  import type { IssueRepository } from "../ports/repository.js";
16
16
  import { FOCUS_MIGRATIONS, FocusStore } from "./focus.js";
17
17
  import { Ledger, LEDGER_MIGRATIONS } from "./ledger.js";
18
18
  import { TICKETS_DAEMON_NAMES } from "./ops.js";
19
- import { buildApp } from "./server.js";
19
+ import { buildApp, type TicketsAppDeps } from "./server.js";
20
20
  import { createSyncTask } from "./poller.js";
21
+ import { createTicketsVehicleRegistry } from "../vehicle/tickets-vehicle.js";
21
22
 
22
23
  export interface BootstrapOptions {
23
24
  pathEnv?: PathEnvironment;
@@ -38,7 +39,7 @@ export interface BootstrapOptions {
38
39
  backendRefreshIntervalMs?: number;
39
40
  /**
40
41
  * Overrides the daemon.shutdown op's effect. Defaults to sending this
41
- * process SIGTERM, which daemon-kit's runDaemonProcess already handles
42
+ * process SIGTERM, which vehicle-server's runDaemonProcess already handles
42
43
  * with a tested graceful stop (see main.ts). Tests override this instead
43
44
  * of self-signaling the test runner's own process.
44
45
  */
@@ -69,6 +70,13 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
69
70
  const repos = opts.repos ?? (await buildRepos(config));
70
71
  const service = new TicketService(repos);
71
72
  const version = opts.version ?? "0.0.0-dev";
73
+ const onShutdownRequested = opts.onShutdownRequested ?? (() => process.kill(process.pid, "SIGTERM"));
74
+
75
+ // Built from the same base deps buildApp's TicketsAppDeps describes, minus
76
+ // the registry field itself -- createTicketsVehicleRegistry never reads
77
+ // deps.vehicleRegistry, so this ordering is safe (see server.ts's own
78
+ // comment on why the registry is built outside it, not imported into it).
79
+ const vehicleRegistry = createTicketsVehicleRegistry({ service, ledger, focusStore, token, version, logger, onShutdownRequested } as TicketsAppDeps);
72
80
 
73
81
  const options: StartDaemonOptions = {
74
82
  daemonLabel: "Tickets",
@@ -95,7 +103,8 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
95
103
  token,
96
104
  version,
97
105
  logger,
98
- onShutdownRequested: opts.onShutdownRequested ?? (() => process.kill(process.pid, "SIGTERM")),
106
+ onShutdownRequested,
107
+ vehicleRegistry,
99
108
  }),
100
109
  onShutdown: () => {
101
110
  db.close();
@@ -9,7 +9,7 @@
9
9
  * ticket in real life replaces what you were just looking at.
10
10
  */
11
11
  import type { Database } from "bun:sqlite";
12
- import type { Migration } from "@danypops/daemon-kit/storage";
12
+ import type { Migration } from "@danypops/vehicle-server/storage";
13
13
 
14
14
  export const FOCUS_MIGRATIONS: Migration[] = [
15
15
  {
@@ -7,7 +7,7 @@
7
7
  * of any live upstream call.
8
8
  */
9
9
  import type { Database } from "bun:sqlite";
10
- import type { Migration } from "@danypops/daemon-kit/storage";
10
+ import type { Migration } from "@danypops/vehicle-server/storage";
11
11
  import type { Issue } from "../domain/issue.js";
12
12
 
13
13
  export const LEDGER_MIGRATIONS: Migration[] = [
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
3
  * The real tickets-daemon binary. Requires Bun (bun:sqlite, Bun.serve via
4
- * daemon-kit). Everything else in this package (the library, the CLI, the
4
+ * vehicle-server). Everything else in this package (the library, the CLI, the
5
5
  * pi-tickets extension) is plain Node-compatible TypeScript and talks to
6
6
  * this process only over the loopback HTTP RPC surface — see client.ts.
7
7
  */
8
- import { runDaemonProcess } from "@danypops/daemon-kit/daemon";
9
- import { readPackageVersion } from "@danypops/daemon-kit/version";
8
+ import { runDaemonProcess } from "@danypops/vehicle-server/daemon";
9
+ import { readPackageVersion } from "@danypops/vehicle-server/version";
10
10
  import { bootstrap } from "./bootstrap.js";
11
11
 
12
12
  const version = readPackageVersion(new URL("../../package.json", import.meta.url), "Tickets");
@@ -5,8 +5,8 @@
5
5
  * network, bad creds) is logged and skipped; it never crashes the daemon
6
6
  * and never blocks other backends' syncs.
7
7
  */
8
- import type { Logger } from "@danypops/daemon-kit/logging";
9
- import type { MaintenanceTask } from "@danypops/daemon-kit/daemon";
8
+ import type { Logger } from "@danypops/vehicle-server/logging";
9
+ import type { MaintenanceTask } from "@danypops/vehicle-server/daemon";
10
10
  import type { TicketService } from "../application/service.js";
11
11
  import type { Ledger } from "./ledger.js";
12
12
 
@@ -1,11 +1,15 @@
1
1
  /**
2
- * Daemon HTTP surface: Bearer-token auth, /health, /ready, and a single
3
- * dispatch endpoint (/api/v1/ops) per daemon-kit's http.ts convention.
4
- * Every operation here has a CLI command (cli/index.ts) and a pi-tickets
5
- * tool action no operation exists only for one caller.
2
+ * Daemon HTTP surface: Bearer-token auth, /health, /ready, a single
3
+ * dispatch endpoint (/api/v1/ops) per vehicle-server's http.ts convention,
4
+ * and a VehicleRegistry (see ../vehicle/tickets-vehicle.ts) mounted at
5
+ * /vehicle/* -- same daemon, same auth, same port, not a second service to
6
+ * stand up. Every operation here has a CLI command (cli/index.ts) and a
7
+ * pi-tickets tool action — no operation exists only for one caller.
6
8
  */
7
- import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBearerToken } from "@danypops/daemon-kit/http";
8
- import type { Logger } from "@danypops/daemon-kit/logging";
9
+ import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBearerToken } from "@danypops/vehicle-server/rpc-http";
10
+ import type { Logger } from "@danypops/vehicle-server/logging";
11
+ import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
12
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
9
13
  import { AuthRequiredError, IssueNotFoundError } from "../adapters/errors.js";
10
14
  import { NotSupportedError, type TicketService, UnknownBackendError } from "../application/service.js";
11
15
  import { parseRef } from "../domain/issue.js";
@@ -23,19 +27,36 @@ export interface TicketsAppDeps {
23
27
  /**
24
28
  * Invoked by the `daemon.shutdown` op, after the HTTP response is already
25
29
  * queued to flush. Defaults set by bootstrap.ts self-signal the process so
26
- * the same tested SIGINT/SIGTERM path (daemon-kit's runDaemonProcess) does
30
+ * the same tested SIGINT/SIGTERM path (vehicle-server's runDaemonProcess) does
27
31
  * the actual graceful stop — this hook only ever *requests* shutdown, it
28
32
  * never calls process.exit directly.
29
33
  */
30
34
  onShutdownRequested?: () => void;
35
+ /**
36
+ * Built by ../vehicle/tickets-vehicle.ts's createTicketsVehicleRegistry,
37
+ * from the same base deps this interface describes -- passed in rather
38
+ * than built here to avoid a server.ts <-> tickets-vehicle.ts import cycle
39
+ * (tickets-vehicle.ts already imports TICKET_OP_HANDLERS and this type
40
+ * from this file).
41
+ */
42
+ vehicleRegistry: VehicleRegistry;
31
43
  }
32
44
 
33
- type Handler<Op extends TicketOperation> = (
34
- deps: TicketsAppDeps,
45
+ // Narrower than TicketsAppDeps on purpose: no real handler reads
46
+ // deps.vehicleRegistry, and vehicle/tickets-vehicle.ts's own registry
47
+ // builder needs to call these before a registry exists to put there.
48
+ export type Handler<Op extends TicketOperation> = (
49
+ deps: Omit<TicketsAppDeps, "vehicleRegistry">,
35
50
  input: TicketOpInputs[Op],
36
51
  ) => Promise<TicketOpOutputs[Op]>;
37
52
 
38
- const handlers: { [Op in TicketOperation]: Handler<Op> } = {
53
+ /**
54
+ * The one real implementation of every ticket operation, shared verbatim by
55
+ * the hand-rolled /api/v1/ops dispatch below and vehicle/tickets-vehicle.ts's
56
+ * VehicleRegistry projection -- never reimplemented a second time for the
57
+ * newer surface.
58
+ */
59
+ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
39
60
  "backends.list": async (deps) => ({ backends: deps.service.backends() }),
40
61
  "issue.list": async (deps, input) => ({ issues: await deps.service.list(input.backend, input.filter) }),
41
62
  "issue.get": async (deps, input) => ({ issue: await deps.service.get(input.ref) }),
@@ -88,11 +109,17 @@ function statusFor(error: unknown): number {
88
109
  }
89
110
 
90
111
  export function buildApp(deps: TicketsAppDeps): { fetch(request: Request): Promise<Response> } {
112
+ // Same Bearer token as the rest of this API -- the Vehicle-projected
113
+ // domain (see ../vehicle/tickets-vehicle.ts) rides the same daemon, same
114
+ // auth, same port; it is not a second service to stand up or
115
+ // authenticate against separately.
116
+ const vehicleApp = createVehicleHttpApp({ registry: deps.vehicleRegistry, token: deps.token });
91
117
  return {
92
118
  async fetch(request: Request): Promise<Response> {
93
119
  const url = new URL(request.url);
94
120
 
95
121
  if (!requireBearerToken(request, deps.token)) return errorResponse("unauthorized", 401);
122
+ if (url.pathname.startsWith("/vehicle/")) return vehicleApp.fetch(request);
96
123
  if (request.method === "GET" && url.pathname === "/health") return healthResponse(deps.version);
97
124
  if (request.method === "GET" && url.pathname === "/ready") return readyResponse(true);
98
125
 
@@ -106,7 +133,7 @@ export function buildApp(deps: TicketsAppDeps): { fetch(request: Request): Promi
106
133
  return errorResponse("invalid JSON body", 400);
107
134
  }
108
135
  if (!isTicketOperation(body.op)) return errorResponse(`unknown op: ${String(body.op)}`, 400);
109
- const handler = handlers[body.op] as Handler<TicketOperation>;
136
+ const handler = TICKET_OP_HANDLERS[body.op] as Handler<TicketOperation>;
110
137
  try {
111
138
  const result = await handler(deps, (body.input ?? {}) as never);
112
139
  return jsonResponse({ result });
package/src/index.ts CHANGED
@@ -18,9 +18,11 @@ export type { FocusStatus, TicketFocusState } from "./daemon/focus.js";
18
18
  export {
19
19
  createTicketsClient,
20
20
  ensureDaemonRunning,
21
+ resolveVehicleClientTarget,
21
22
  ticketsPaths,
22
23
  type EnsureDaemonOptions,
23
24
  type TicketsRpcClient,
25
+ type VehicleClientTarget,
24
26
  } from "./client/tickets-client.js";
25
27
 
26
28
  // Delegated OAuth (device flow for GitHub/GitLab, authorization code for Jira)
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Every real ticket operation projected as its own VehicleRegistry entry,
3
+ * one per TicketOperation, instead of pi-tickets' hand-rolled
4
+ * `tickets(action=X)` mega-tool. Operation names are already dotted
5
+ * (issue.list, focus.set, discover.fields, ...) in daemon/ops.ts -- Vehicle's
6
+ * tool-name projection turns each into its own Pi tool (issue_list,
7
+ * focus_set, discover_fields, ...) with zero renaming needed.
8
+ *
9
+ * Delegates every operation to daemon/server.ts's TICKET_OP_HANDLERS, the
10
+ * exact same implementation the existing /api/v1/ops dispatch calls -- this
11
+ * is a projection/contract layer on top of the existing application logic,
12
+ * not a second copy of it.
13
+ *
14
+ * daemon.shutdown is deliberately excluded: it's an admin/lifecycle
15
+ * operation, not something an agent should be able to call as a tool.
16
+ */
17
+ import { bindVehicleOperation, defineLooseObjectSchema, defineVehicleOperation, passthroughVehicleSchema, type VehicleEffect, type LooseObjectProperty } from "@danypops/vehicle-core";
18
+ import { VehicleRegistry } from "@danypops/vehicle-server";
19
+ import { TICKET_OP_HANDLERS, type TicketsAppDeps } from "../daemon/server.js";
20
+ import type { TicketOperation } from "../daemon/ops.js";
21
+
22
+ const OWNER = "tickets";
23
+
24
+ const LIMITS = { defaultTimeoutMs: 10_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
25
+
26
+ const stringProp: LooseObjectProperty = { type: "string" };
27
+ const numberProp: LooseObjectProperty = { type: "number" };
28
+
29
+ interface OperationSpec {
30
+ readonly action: TicketOperation;
31
+ readonly description: string;
32
+ readonly effect: VehicleEffect;
33
+ readonly properties: Record<string, LooseObjectProperty>;
34
+ readonly required: readonly string[];
35
+ /**
36
+ * Reshapes the flat tool-facing input into whatever TICKET_OP_HANDLERS
37
+ * expects, for the one operation where those differ: issue.list flattens
38
+ * project/status/assignee/labels/limit as top-level tool properties
39
+ * (matching issue.search's own flat convention, and pi-stef/atlassian's
40
+ * jira_search_issues/jira_get_project_issues -- every filter param is its
41
+ * own top-level property there too, never an opaque nested bag), while
42
+ * the RPC/CLI-level handler contract keeps its existing nested
43
+ * `filter: ListFilter` shape. Identity by default.
44
+ */
45
+ readonly mapInput?: (input: Record<string, unknown>) => Record<string, unknown>;
46
+ }
47
+
48
+ const stringArrayProp: LooseObjectProperty = { type: "array" };
49
+
50
+ function definedEntriesOnly(input: Record<string, unknown>): Record<string, unknown> {
51
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
52
+ }
53
+
54
+ const OPERATIONS: readonly OperationSpec[] = [
55
+ { action: "backends.list", description: "Lists every configured backend name (github, gitlab, jira, ...).", effect: "read", properties: {}, required: [] },
56
+ {
57
+ action: "issue.list",
58
+ description: "Lists issues from one backend, optionally filtered.",
59
+ effect: "read",
60
+ properties: { backend: stringProp, project: stringProp, status: stringProp, assignee: stringProp, labels: stringArrayProp, limit: numberProp },
61
+ required: ["backend"],
62
+ mapInput: ({ backend, project, status, assignee, labels, limit }) => ({
63
+ backend,
64
+ filter: definedEntriesOnly({ project, status, assignee, labels, limit }),
65
+ }),
66
+ },
67
+ { action: "issue.get", description: "Gets one issue by its ref (e.g. \"github:#42\").", effect: "read", properties: { ref: stringProp }, required: ["ref"] },
68
+ {
69
+ action: "issue.create",
70
+ description: "Creates a new issue on a live backend -- a real, externally visible write, not a local draft.",
71
+ effect: "external-write",
72
+ properties: { backend: stringProp, input: { type: "object" } },
73
+ required: ["backend", "input"],
74
+ },
75
+ {
76
+ action: "issue.update",
77
+ description: "Updates an existing issue on its live backend -- a real, externally visible write.",
78
+ effect: "external-write",
79
+ properties: { ref: stringProp, input: { type: "object" } },
80
+ required: ["ref", "input"],
81
+ },
82
+ { action: "issue.search", description: "Searches one backend's issues by text query.", effect: "read", properties: { backend: stringProp, query: stringProp, limit: numberProp, project: stringProp }, required: ["backend", "query"] },
83
+ { action: "issue.children", description: "Lists an issue's child issues.", effect: "read", properties: { ref: stringProp }, required: ["ref"] },
84
+ { action: "issue.comments", description: "Lists an issue's comments.", effect: "read", properties: { ref: stringProp }, required: ["ref"] },
85
+ {
86
+ action: "issue.comment_add",
87
+ description: "Adds a comment to an issue on its live backend -- a real, externally visible write.",
88
+ effect: "external-write",
89
+ properties: { ref: stringProp, body: stringProp },
90
+ required: ["ref", "body"],
91
+ },
92
+ { action: "ledger.search", description: "Searches the local pooled-issue ledger (no live backend call).", effect: "read", properties: { query: stringProp, limit: numberProp }, required: ["query"] },
93
+ { action: "ledger.stats", description: "Per-backend counts of issues pooled into the local ledger.", effect: "read", properties: {}, required: [] },
94
+ { action: "focus.set", description: "Sets the currently focused issue, by ref.", effect: "local-write", properties: { ref: stringProp }, required: ["ref"] },
95
+ { action: "focus.get", description: "Gets the currently focused issue, if any.", effect: "read", properties: {}, required: [] },
96
+ { action: "focus.pause", description: "Pauses focus with an optional reason, without clearing it.", effect: "local-write", properties: { reason: stringProp }, required: [] },
97
+ { action: "focus.unpause", description: "Resumes a paused focus.", effect: "local-write", properties: {}, required: [] },
98
+ { action: "focus.clear", description: "Clears the currently focused issue.", effect: "local-write", properties: {}, required: [] },
99
+ { action: "discover.fields", description: "Discovers a backend's custom field display names and IDs (Jira).", effect: "read", properties: { backend: stringProp }, required: ["backend"] },
100
+ { action: "discover.statuses", description: "Discovers a backend's real status names.", effect: "read", properties: { backend: stringProp }, required: ["backend"] },
101
+ {
102
+ action: "discover.template",
103
+ description: "Samples recent issues for a project/issueType and extracts a reusable description template (Jira).",
104
+ effect: "read",
105
+ properties: { backend: stringProp, project: stringProp, issueType: stringProp, sampleSize: numberProp },
106
+ required: ["backend", "project", "issueType"],
107
+ },
108
+ ];
109
+
110
+ /**
111
+ * Builds a VehicleRegistry exposing every real ticket operation, backed by
112
+ * the exact same deps shape the daemon's own hand-rolled dispatch already
113
+ * uses -- minus vehicleRegistry itself, which doesn't exist yet while this
114
+ * is being built (bootstrap.ts constructs the full TicketsAppDeps by adding
115
+ * this registry to the same base object afterward).
116
+ */
117
+ export function createTicketsVehicleRegistry(deps: Omit<TicketsAppDeps, "vehicleRegistry">): VehicleRegistry {
118
+ const registry = new VehicleRegistry({ name: "tickets", version: "1.0.0", description: "Unified issue tracking across GitHub, GitLab, and Jira." });
119
+
120
+ for (const spec of OPERATIONS) {
121
+ const operation = defineVehicleOperation({
122
+ name: spec.action,
123
+ version: 1,
124
+ description: spec.description,
125
+ input: defineLooseObjectSchema(spec.properties, spec.required),
126
+ output: passthroughVehicleSchema,
127
+ permissions: ["tickets:read", "tickets:write"],
128
+ effect: spec.effect,
129
+ idempotency: { mode: spec.effect === "read" ? "safe" : "unsafe" },
130
+ limits: LIMITS,
131
+ });
132
+ const handler = TICKET_OP_HANDLERS[spec.action];
133
+ const mapInput = spec.mapInput ?? ((input: Record<string, unknown>) => input);
134
+ registry.register(
135
+ OWNER,
136
+ bindVehicleOperation(operation, () => async (context) => handler(deps, mapInput(context.input as Record<string, unknown>) as never)),
137
+ );
138
+ }
139
+
140
+ return registry;
141
+ }