@the-open-engine/zeroshot 6.2.0 → 6.4.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.
Files changed (43) hide show
  1. package/README.md +141 -474
  2. package/cli/event-copy.js +12 -0
  3. package/cli/index.js +314 -96
  4. package/cli/message-formatters-normal.js +14 -2
  5. package/cli/message-formatters-watch.js +9 -2
  6. package/cluster-hooks/block-ask-user-question.py +53 -0
  7. package/cluster-hooks/block-dangerous-git.py +145 -0
  8. package/lib/clusters-registry.js +48 -0
  9. package/lib/compose-utils.js +101 -0
  10. package/lib/detached-startup.js +12 -1
  11. package/lib/id-detector.js +8 -9
  12. package/lib/path-check.js +63 -0
  13. package/lib/run-mode.js +34 -0
  14. package/lib/run-plan.js +32 -0
  15. package/lib/start-cluster.js +58 -28
  16. package/package.json +7 -6
  17. package/scripts/check-path.js +19 -0
  18. package/scripts/validate-templates.js +11 -29
  19. package/src/agent/agent-hook-executor.js +1 -1
  20. package/src/agent/agent-lifecycle.js +2 -0
  21. package/src/agent/agent-task-executor.js +4 -3
  22. package/src/agent/pr-verification.js +27 -1
  23. package/src/agents/git-pusher-template.js +121 -4
  24. package/src/attach/socket-discovery.js +2 -3
  25. package/src/claude-credentials.js +75 -0
  26. package/src/claude-task-runner.js +1 -0
  27. package/src/isolation-manager.js +12 -9
  28. package/src/issue-providers/README.md +45 -15
  29. package/src/issue-providers/index.js +2 -0
  30. package/src/issue-providers/jira-provider.js +8 -1
  31. package/src/issue-providers/linear-provider.js +405 -0
  32. package/src/ledger.js +45 -3
  33. package/src/lib/gc.js +2 -3
  34. package/src/message-bus.js +22 -2
  35. package/src/orchestrator.js +271 -144
  36. package/src/preflight.js +12 -16
  37. package/src/providers/base-provider.js +7 -6
  38. package/src/status-footer.js +13 -1
  39. package/src/template-validation/index.js +3 -0
  40. package/src/template-validation/report-formatter.js +55 -0
  41. package/src/worktree-claude-config.js +2 -13
  42. package/task-lib/runner.js +1 -0
  43. package/task-lib/watcher.js +1 -0
@@ -20,6 +20,7 @@ const { normalizeProviderName } = require('../lib/provider-names');
20
20
  const { resolveMounts, resolveEnvs, expandEnvPatterns } = require('../lib/docker-config');
21
21
  const { getProvider } = require('./providers');
22
22
  const { readRepoSettings } = require('../lib/repo-settings');
23
+ const { provisionClaudeCredentials } = require('./claude-credentials');
23
24
 
24
25
  const DEFAULT_WORKTREE_SETUP_TIMEOUT_MS = 15 * 60 * 1000;
25
26
 
@@ -1036,14 +1037,12 @@ class IsolationManager {
1036
1037
  const projectsDir = path.join(configDir, 'projects');
1037
1038
  fs.mkdirSync(projectsDir, { recursive: true });
1038
1039
 
1039
- // Copy only credentials file (essential for auth)
1040
- const credentialsFile = path.join(sourceDir, '.credentials.json');
1041
- if (fs.existsSync(credentialsFile)) {
1042
- fs.copyFileSync(credentialsFile, path.join(configDir, '.credentials.json'));
1043
- }
1040
+ // Copy credentials file, or materialize from macOS Keychain if none exists
1041
+ // (essential for auth; see src/claude-credentials.js)
1042
+ provisionClaudeCredentials({ sourceDir, destDir: configDir });
1044
1043
 
1045
1044
  // Copy hook script to block AskUserQuestion (CRITICAL for autonomous execution)
1046
- const hookScriptSrc = path.join(__dirname, '..', 'hooks', 'block-ask-user-question.py');
1045
+ const hookScriptSrc = path.join(__dirname, '..', 'cluster-hooks', 'block-ask-user-question.py');
1047
1046
  const hookScriptDst = path.join(hooksDir, 'block-ask-user-question.py');
1048
1047
  if (fs.existsSync(hookScriptSrc)) {
1049
1048
  fs.copyFileSync(hookScriptSrc, hookScriptDst);
@@ -1651,10 +1650,14 @@ class IsolationManager {
1651
1650
  // Tear down any Docker Compose services that may have been started in this worktree.
1652
1651
  // Without this, containers keep running with host port mappings after the worktree is deleted,
1653
1652
  // blocking port allocation for the main project or other worktrees.
1654
- const composePath = path.join(worktreeInfo.path, 'docker-compose.yml');
1655
- if (fs.existsSync(composePath)) {
1653
+ // NEVER pass --volumes (irreversible data loss) and NEVER tear down a pinned/shared
1654
+ // Compose project — only a project scoped to the worktree directory basename, which is
1655
+ // the only kind zeroshot could itself have created, is touched.
1656
+ const { resolveWorktreeComposeTeardown } = require('../lib/compose-utils');
1657
+ const teardown = resolveWorktreeComposeTeardown(worktreeInfo.path);
1658
+ if (teardown.shouldTeardown) {
1656
1659
  try {
1657
- runSync('docker', ['compose', 'down', '--remove-orphans', '--volumes', '--timeout', '10'], {
1660
+ runSync('docker', teardown.args, {
1658
1661
  cwd: worktreeInfo.path,
1659
1662
  encoding: 'utf8',
1660
1663
  stdio: 'pipe',
@@ -1,15 +1,16 @@
1
1
  # Issue Providers
2
2
 
3
- Multi-platform issue support for Zeroshot. Fetch issues from GitHub, GitLab, Jira, and Azure DevOps.
3
+ Multi-platform issue support for Zeroshot. Fetch issues from GitHub, GitLab, Jira, Azure DevOps, and Linear.
4
4
 
5
5
  ## Supported Providers
6
6
 
7
- | Provider | CLI Tool | URL Pattern | Issue Key Format |
8
- | ---------------- | -------- | -------------------------------------------- | --------------------- |
9
- | **GitHub** | `gh` | `github.com/org/repo/issues/123` | `123`, `org/repo#123` |
10
- | **GitLab** | `glab` | `gitlab.com/org/repo/-/issues/123` | `123`, `org/repo#123` |
11
- | **Jira** | `jira` | `*.atlassian.net/browse/KEY-123` | `KEY-123` |
12
- | **Azure DevOps** | `az` | `dev.azure.com/org/proj/_workitems/edit/123` | `123` |
7
+ | Provider | CLI Tool | URL Pattern | Issue Key Format |
8
+ | ---------------- | ---------------------- | -------------------------------------------- | --------------------- |
9
+ | **GitHub** | `gh` | `github.com/org/repo/issues/123` | `123`, `org/repo#123` |
10
+ | **GitLab** | `glab` | `gitlab.com/org/repo/-/issues/123` | `123`, `org/repo#123` |
11
+ | **Jira** | `jira` | `*.atlassian.net/browse/KEY-123` | `KEY-123` |
12
+ | **Azure DevOps** | `az` | `dev.azure.com/org/proj/_workitems/edit/123` | `123` |
13
+ | **Linear** | _(none — GraphQL API)_ | `linear.app/ws/issue/ENG-42` | `ENG-42` |
13
14
 
14
15
  ## Quick Start
15
16
 
@@ -30,6 +31,11 @@ zeroshot run https://company.atlassian.net/browse/PROJ-123
30
31
  # Azure DevOps
31
32
  zeroshot run https://dev.azure.com/org/project/_workitems/edit/123
32
33
  zeroshot run 123 --devops
34
+
35
+ # Linear
36
+ zeroshot run ENG-42
37
+ zeroshot run https://linear.app/workspace/issue/ENG-42/some-title
38
+ zeroshot run 42 --linear
33
39
  ```
34
40
 
35
41
  ## Automatic Git Remote Detection
@@ -59,6 +65,7 @@ Override auto-detection with explicit provider flags:
59
65
  -L, --gitlab # Force GitLab as issue source
60
66
  -J, --jira # Force Jira as issue source
61
67
  -D, --devops # Force Azure DevOps as issue source
68
+ -N, --linear # Force Linear as issue source
62
69
  ```
63
70
 
64
71
  **Example:**
@@ -100,18 +107,24 @@ zeroshot settings set jiraProject MYPROJECT # Default project for bare numbers
100
107
  # Azure DevOps configuration
101
108
  zeroshot settings set azureOrg mycompany
102
109
  zeroshot settings set azureProject myproject # Default project for bare numbers
110
+
111
+ # Linear configuration
112
+ zeroshot settings set linearApiKey lin_api_... # API key (falls back to LINEAR_API_KEY env var)
113
+ zeroshot settings set linearTeam ENG # Default team key for bare numbers (auto-derived on first use if unset)
103
114
  ```
104
115
 
105
116
  ### Settings Reference
106
117
 
107
- | Setting | Type | Default | Description |
108
- | -------------------- | ------ | ------- | ------------------------------------------ |
109
- | `defaultIssueSource` | string | github | Provider for bare numbers (123) |
110
- | `gitlabInstance` | string | null | Self-hosted GitLab URL |
111
- | `jiraInstance` | string | null | Self-hosted Jira URL |
112
- | `jiraProject` | string | null | Default Jira project key for bare numbers |
113
- | `azureOrg` | string | null | Azure DevOps organization name |
114
- | `azureProject` | string | null | Azure DevOps project name for bare numbers |
118
+ | Setting | Type | Default | Description |
119
+ | -------------------- | ------ | ------- | --------------------------------------------------------------------------------------------------- |
120
+ | `defaultIssueSource` | string | github | Provider for bare numbers (123) |
121
+ | `gitlabInstance` | string | null | Self-hosted GitLab URL |
122
+ | `jiraInstance` | string | null | Self-hosted Jira URL |
123
+ | `jiraProject` | string | null | Default Jira project key for bare numbers |
124
+ | `azureOrg` | string | null | Azure DevOps organization name |
125
+ | `azureProject` | string | null | Azure DevOps project name for bare numbers |
126
+ | `linearApiKey` | string | null | Linear personal API key (falls back to `LINEAR_API_KEY` env var) |
127
+ | `linearTeam` | string | null | Default Linear team key for bare numbers (auto-derived from the workspace if unset and unambiguous) |
115
128
 
116
129
  ## CLI Tool Setup
117
130
 
@@ -166,6 +179,23 @@ az login
166
179
  az devops configure --defaults organization=https://dev.azure.com/yourorg
167
180
  ```
168
181
 
182
+ ### Linear
183
+
184
+ No CLI install needed — Linear is accessed directly via its GraphQL API.
185
+
186
+ ```bash
187
+ # Create a personal API key at https://linear.app/settings/api
188
+ zeroshot settings set linearApiKey lin_api_...
189
+
190
+ # Or, as a fallback, export it as an env var (checked if linearApiKey is unset):
191
+ export LINEAR_API_KEY=lin_api_...
192
+ ```
193
+
194
+ Note: `KEY-NUMBER` input (e.g. `ENG-42`) is ambiguous between Jira and Linear.
195
+ It's routed to Linear automatically when Linear is configured
196
+ (`linearApiKey`/`linearTeam`) and Jira isn't; otherwise Jira wins. Use
197
+ `--linear`/`--jira` to force a specific provider.
198
+
169
199
  ## Self-Hosted Instances
170
200
 
171
201
  ### GitLab Self-Hosted
@@ -7,6 +7,7 @@
7
7
  const GitHubProvider = require('./github-provider');
8
8
  const GitLabProvider = require('./gitlab-provider');
9
9
  const JiraProvider = require('./jira-provider');
10
+ const LinearProvider = require('./linear-provider');
10
11
  const AzureDevOpsProvider = require('./azure-devops-provider');
11
12
  const { detectGitContext } = require('../../lib/git-remote-utils');
12
13
 
@@ -180,6 +181,7 @@ function validateIssueProviderSetting(key, value) {
180
181
  registerProvider(GitHubProvider);
181
182
  registerProvider(GitLabProvider);
182
183
  registerProvider(JiraProvider);
184
+ registerProvider(LinearProvider);
183
185
  registerProvider(AzureDevOpsProvider);
184
186
 
185
187
  module.exports = {
@@ -44,8 +44,15 @@ class JiraProvider extends IssueProvider {
44
44
  return true;
45
45
  }
46
46
 
47
- // Jira issue key pattern (KEY-123)
47
+ // Jira issue key pattern (KEY-123) - shared with Linear (ENG-42 vs PROJ-123).
48
+ // Defer to Linear when it's configured and Jira isn't, so the sole
49
+ // configured tracker wins instead of registration order.
48
50
  if (/^[A-Z][A-Z0-9]+-\d+$/.test(input)) {
51
+ const jiraConfigured = !!(settings.jiraInstance || settings.jiraProject);
52
+ const linearConfigured = !!(settings.linearApiKey || settings.linearTeam);
53
+ if (!jiraConfigured && linearConfigured) {
54
+ return false;
55
+ }
49
56
  return true;
50
57
  }
51
58
 
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Linear Provider - Fetch issues from Linear via GraphQL API
3
+ * Linear is not a git host; no CLI binary required (talks to api.linear.app directly)
4
+ */
5
+
6
+ const IssueProvider = require('./base-provider');
7
+
8
+ const AUTH_CHECK_TIMEOUT_MS = 2000;
9
+ const FETCH_TIMEOUT_MS = 10000;
10
+ const LINEAR_API_URL = 'https://api.linear.app/graphql';
11
+ const LINEAR_URL_PATTERN = /linear\.app\/[^/]+\/issue\/([A-Z][A-Z0-9]*-\d+)/;
12
+ const LINEAR_KEY_PATTERN = /^[A-Z][A-Z0-9]*-\d+$/;
13
+
14
+ class LinearProvider extends IssueProvider {
15
+ static id = 'linear';
16
+ static displayName = 'Linear';
17
+
18
+ /**
19
+ * Detect Linear issue keys and URLs
20
+ * Matches:
21
+ * - Linear issue URLs (linear.app/<workspace>/issue/<TEAM>-<n>/...)
22
+ * - Linear issue keys (TEAM-123 format)
23
+ * - Bare numbers when defaultIssueSource=linear (requires linearTeam setting)
24
+ *
25
+ * Note: Linear doesn't use git context since it's not a git hosting platform.
26
+ * Git context will never match 'linear', so this provider only activates via
27
+ * explicit Linear URLs, Linear issue keys, or settings.
28
+ *
29
+ * Note: Linear and Jira share the same KEY-NUMBER key format (e.g. ENG-42 vs
30
+ * PROJ-123), so a bare key is ambiguous between the two providers. This is
31
+ * resolved by configuration, not registration order: JiraProvider.detectIdentifier
32
+ * defers to Linear for KEY-NUMBER input when Linear is configured
33
+ * (linearApiKey/linearTeam) and Jira is not. If neither or both are configured,
34
+ * Jira wins by registration order. Use --linear or defaultIssueSource=linear
35
+ * for unambiguous Linear selection regardless of configuration.
36
+ *
37
+ * @param {string} input - Issue identifier (URL, key, or number)
38
+ * @param {Object} settings - User settings
39
+ * @param {Object|null} gitContext - Git context (unused for Linear, but kept for API consistency)
40
+ * @returns {boolean} True if this provider should handle the input
41
+ */
42
+ static detectIdentifier(input, settings, gitContext = null) {
43
+ // Linear issue URLs
44
+ if (LINEAR_URL_PATTERN.test(input)) {
45
+ return true;
46
+ }
47
+
48
+ // Linear issue key pattern (TEAM-123)
49
+ if (LINEAR_KEY_PATTERN.test(input)) {
50
+ return true;
51
+ }
52
+
53
+ // Bare numbers - use shared priority cascade logic
54
+ // Linear requires linearTeam setting to convert bare numbers to issue keys
55
+ // Note: gitContext check will never match 'linear' since Linear isn't a git host
56
+ return IssueProvider.detectBareNumber(input, settings, gitContext, 'linear', {
57
+ requiredSettings: ['linearTeam'],
58
+ });
59
+ }
60
+
61
+ static getRequiredTool() {
62
+ // No CLI binary required - talks to Linear's GraphQL API directly via fetch
63
+ return {
64
+ name: null,
65
+ checkCmd: null,
66
+ installHint: null,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Validate response status and parse GraphQL JSON body.
72
+ * Shared by checkAuth and fetchIssue so both surface 401/429/non-OK/invalid-body
73
+ * failures the same way instead of falling through to a generic JSON-parse error.
74
+ * @private
75
+ */
76
+ static async _handleGraphQLResponse(response) {
77
+ if (response.status === 401) {
78
+ throw new Error('Linear API key invalid');
79
+ }
80
+
81
+ if (response.status === 429) {
82
+ throw new Error('Linear API rate limit exceeded, try again later');
83
+ }
84
+
85
+ if (!response.ok) {
86
+ throw new Error(`Linear API request failed with status ${response.status}`);
87
+ }
88
+
89
+ try {
90
+ return await response.json();
91
+ } catch {
92
+ throw new Error(`Linear API returned an invalid response (status ${response.status})`);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Resolve the Linear API key: settings-owned, with env var fallback.
98
+ * @param {Object} [settings] - User settings (may be undefined/null)
99
+ * @returns {string|null}
100
+ */
101
+ static _resolveApiKey(settings) {
102
+ return settings?.linearApiKey || process.env.LINEAR_API_KEY || null;
103
+ }
104
+
105
+ /**
106
+ * Recovery hints pointing at the settings-based fix for a missing/invalid key.
107
+ * @returns {string[]}
108
+ */
109
+ static _recoveryHints() {
110
+ return [
111
+ 'Set it: zeroshot settings set linearApiKey <key>',
112
+ 'Get a key at https://linear.app/settings/api',
113
+ 'Or export LINEAR_API_KEY=lin_api_...',
114
+ ];
115
+ }
116
+
117
+ /**
118
+ * Check Linear API key authentication via a minimal GraphQL query
119
+ */
120
+ static async checkAuth() {
121
+ // Lazy require avoids circular dependency (settings.js lazy-loads issue-providers).
122
+ const { loadSettings } = require('../../lib/settings');
123
+ const apiKey = LinearProvider._resolveApiKey(loadSettings());
124
+
125
+ if (!apiKey) {
126
+ return {
127
+ authenticated: false,
128
+ error: 'Linear API key not configured',
129
+ recovery: LinearProvider._recoveryHints(),
130
+ };
131
+ }
132
+
133
+ try {
134
+ const controller = new AbortController();
135
+ const timeout = setTimeout(() => controller.abort(), AUTH_CHECK_TIMEOUT_MS);
136
+
137
+ let response;
138
+ try {
139
+ response = await fetch(LINEAR_API_URL, {
140
+ method: 'POST',
141
+ headers: {
142
+ 'Content-Type': 'application/json',
143
+ Authorization: apiKey,
144
+ },
145
+ body: JSON.stringify({ query: '{ viewer { id } }' }),
146
+ signal: controller.signal,
147
+ });
148
+ } finally {
149
+ clearTimeout(timeout);
150
+ }
151
+
152
+ const json = await LinearProvider._handleGraphQLResponse(response);
153
+ if (json.errors) {
154
+ return {
155
+ authenticated: false,
156
+ error: 'Linear API key invalid',
157
+ recovery: ['Verify LINEAR_API_KEY at https://linear.app/settings/api'],
158
+ };
159
+ }
160
+
161
+ return { authenticated: true, error: null, recovery: [] };
162
+ } catch (err) {
163
+ if (err.message === 'Linear API key invalid') {
164
+ return {
165
+ authenticated: false,
166
+ error: err.message,
167
+ recovery: ['Verify LINEAR_API_KEY at https://linear.app/settings/api'],
168
+ };
169
+ }
170
+
171
+ return {
172
+ authenticated: false,
173
+ error: err.message,
174
+ recovery: ['Check network connectivity', 'Verify https://api.linear.app is reachable'],
175
+ };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Linear-specific settings schema
181
+ */
182
+ static getSettingsSchema() {
183
+ return {
184
+ linearTeam: {
185
+ type: 'string',
186
+ nullable: true,
187
+ default: null,
188
+ description: "Default Linear team key for bare numbers (e.g., 'ENG')",
189
+ pattern: /^[A-Z][A-Z0-9]*$/,
190
+ patternMsg: 'linearTeam must be a valid Linear team key (e.g., ENG, PROJ)',
191
+ },
192
+ linearApiKey: {
193
+ type: 'string',
194
+ nullable: true,
195
+ default: null,
196
+ description: 'Linear personal API key (get one at https://linear.app/settings/api)',
197
+ },
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Fetch the workspace's Linear teams and, if there's exactly one, persist its
203
+ * key as `linearTeam` so future bare-number runs skip this round-trip.
204
+ * @private
205
+ */
206
+ static async _resolveTeamKey(apiKey) {
207
+ const controller = new AbortController();
208
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
209
+
210
+ let response;
211
+ try {
212
+ response = await fetch(LINEAR_API_URL, {
213
+ method: 'POST',
214
+ headers: {
215
+ 'Content-Type': 'application/json',
216
+ Authorization: apiKey,
217
+ },
218
+ body: JSON.stringify({ query: '{ teams { nodes { key } } }' }),
219
+ signal: controller.signal,
220
+ });
221
+ } finally {
222
+ clearTimeout(timeout);
223
+ }
224
+
225
+ const json = await LinearProvider._handleGraphQLResponse(response);
226
+ if (json.errors) {
227
+ throw new Error(json.errors.map((e) => e.message).join('; '));
228
+ }
229
+
230
+ const teams = json.data?.teams?.nodes || [];
231
+ if (teams.length === 0) {
232
+ throw new Error('No Linear teams found for this API key.');
233
+ }
234
+ if (teams.length > 1) {
235
+ const keys = teams.map((t) => t.key).join(', ');
236
+ throw new Error(
237
+ `Multiple Linear teams found (${keys}). Set one: zeroshot settings set linearTeam <KEY>`
238
+ );
239
+ }
240
+
241
+ const key = teams[0].key;
242
+ const { loadSettings, saveSettings } = require('../../lib/settings');
243
+ const current = loadSettings();
244
+ current.linearTeam = key;
245
+ saveSettings(current);
246
+ return key;
247
+ }
248
+
249
+ /**
250
+ * Resolve the issue key, auto-deriving `linearTeam` for bare numbers when it
251
+ * isn't configured yet.
252
+ * @private
253
+ */
254
+ async _resolveIssueKey(identifier, settings, apiKey) {
255
+ if (/^\d+$/.test(identifier) && !settings.linearTeam) {
256
+ const team = await LinearProvider._resolveTeamKey(apiKey);
257
+ return `${team}-${identifier}`;
258
+ }
259
+ return this._extractIssueKey(identifier, settings);
260
+ }
261
+
262
+ async fetchIssue(identifier, settings) {
263
+ const apiKey = LinearProvider._resolveApiKey(settings);
264
+ if (!apiKey) {
265
+ throw new Error(
266
+ `Failed to fetch Linear issue: Linear API key not configured. ${LinearProvider._recoveryHints().join(' ')}`
267
+ );
268
+ }
269
+
270
+ try {
271
+ const issueKey = await this._resolveIssueKey(identifier, settings, apiKey);
272
+
273
+ const query = `
274
+ query($id: String!) {
275
+ issue(id: $id) {
276
+ identifier
277
+ number
278
+ title
279
+ description
280
+ url
281
+ # Linear defaults to a 50-node first page with no pagination here; long
282
+ # label sets or comment threads beyond that are silently truncated.
283
+ labels {
284
+ nodes {
285
+ name
286
+ }
287
+ }
288
+ comments {
289
+ nodes {
290
+ user {
291
+ name
292
+ }
293
+ createdAt
294
+ body
295
+ }
296
+ }
297
+ }
298
+ }
299
+ `;
300
+
301
+ const controller = new AbortController();
302
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
303
+
304
+ let response;
305
+ try {
306
+ response = await fetch(LINEAR_API_URL, {
307
+ method: 'POST',
308
+ headers: {
309
+ 'Content-Type': 'application/json',
310
+ Authorization: apiKey,
311
+ },
312
+ body: JSON.stringify({ query, variables: { id: issueKey } }),
313
+ signal: controller.signal,
314
+ });
315
+ } finally {
316
+ clearTimeout(timeout);
317
+ }
318
+
319
+ const json = await LinearProvider._handleGraphQLResponse(response);
320
+
321
+ if (json.errors) {
322
+ throw new Error(json.errors.map((e) => e.message).join('; '));
323
+ }
324
+
325
+ if (!json.data?.issue) {
326
+ throw new Error(`Linear issue not found: ${issueKey}`);
327
+ }
328
+
329
+ return this._parseIssue(json.data.issue);
330
+ } catch (error) {
331
+ throw new Error(`Failed to fetch Linear issue: ${error.message}`);
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Extract Linear issue key from URL or return as-is
337
+ * @private
338
+ */
339
+ _extractIssueKey(identifier, settings) {
340
+ // URL format: https://linear.app/<workspace>/issue/TEAM-123/...
341
+ const urlMatch = identifier.match(LINEAR_URL_PATTERN);
342
+ if (urlMatch) {
343
+ return urlMatch[1];
344
+ }
345
+
346
+ // Bare number: construct from linearTeam setting
347
+ if (/^\d+$/.test(identifier) && settings.linearTeam) {
348
+ return `${settings.linearTeam}-${identifier}`;
349
+ }
350
+
351
+ // Assume it's already a key
352
+ return identifier;
353
+ }
354
+
355
+ /**
356
+ * Parse Linear issue into standardized InputData format
357
+ * @private
358
+ */
359
+ _parseIssue(issue) {
360
+ const labels = issue.labels?.nodes || [];
361
+ const comments = issue.comments?.nodes || [];
362
+ const description = issue.description || '';
363
+
364
+ let context = `# Linear Issue ${issue.identifier}\n\n`;
365
+ context += `## Title\n${issue.title}\n\n`;
366
+
367
+ if (description) {
368
+ context += `## Description\n${description}\n\n`;
369
+ }
370
+
371
+ if (labels.length > 0) {
372
+ context += `## Labels\n`;
373
+ context += labels.map((l) => `- ${l.name}`).join('\n');
374
+ context += '\n\n';
375
+ }
376
+
377
+ if (comments.length > 0) {
378
+ context += `## Comments\n\n`;
379
+ for (const comment of comments) {
380
+ const author = comment.user?.name || 'unknown';
381
+ context += `### ${author} (${comment.createdAt})\n`;
382
+ context += `${comment.body}\n\n`;
383
+ }
384
+ }
385
+
386
+ const mappedLabels = labels.map((l) => ({ name: l.name }));
387
+ const mappedComments = comments.map((c) => ({
388
+ author: { login: c.user?.name || 'unknown' },
389
+ createdAt: c.createdAt,
390
+ body: c.body,
391
+ }));
392
+
393
+ return {
394
+ number: issue.number,
395
+ title: issue.title,
396
+ body: description,
397
+ labels: mappedLabels,
398
+ comments: mappedComments,
399
+ url: issue.url || null,
400
+ context,
401
+ };
402
+ }
403
+ }
404
+
405
+ module.exports = LinearProvider;
package/src/ledger.js CHANGED
@@ -18,9 +18,10 @@ const {
18
18
  } = require('./guidance-topics');
19
19
 
20
20
  class Ledger extends EventEmitter {
21
- constructor(dbPath = ':memory:') {
21
+ constructor(dbPath = ':memory:', options = {}) {
22
22
  super();
23
23
  this.dbPath = dbPath;
24
+ this.readonly = options.readonly === true;
24
25
  const busyTimeoutMs = (() => {
25
26
  const raw = process.env.ZEROSHOT_SQLITE_BUSY_TIMEOUT_MS;
26
27
  if (!raw) return 5000;
@@ -28,12 +29,23 @@ class Ledger extends EventEmitter {
28
29
  return Number.isFinite(value) && value >= 0 ? value : 5000;
29
30
  })();
30
31
 
31
- this.db = new Database(dbPath, { timeout: busyTimeoutMs });
32
+ // Read-only connections (CLI list/status/logs) never take a write lock on
33
+ // another process's live database and skip schema DDL entirely - the schema
34
+ // is guaranteed to already exist for any cluster with a live daemon.
35
+ this.db = this.readonly
36
+ ? new Database(dbPath, { readonly: true, fileMustExist: true, timeout: busyTimeoutMs })
37
+ : new Database(dbPath, { timeout: busyTimeoutMs });
32
38
  this.cache = new Map(); // LRU cache for queries
33
39
  this.cacheLimit = 1000;
34
40
  this._closed = false; // Track closed state to prevent write-after-close
35
41
  this._lastTimestamp = 0;
36
- this._initSchema();
42
+
43
+ if (this.readonly) {
44
+ this._prepareStatements();
45
+ this._loadLastTimestamp();
46
+ } else {
47
+ this._initSchema();
48
+ }
37
49
  }
38
50
 
39
51
  _initSchema() {
@@ -475,7 +487,13 @@ class Ledger extends EventEmitter {
475
487
  if (!cluster_id) {
476
488
  throw new Error('cluster_id is required for getTokensByRole');
477
489
  }
490
+ return this._computeTokensByRole(cluster_id);
491
+ }
478
492
 
493
+ /**
494
+ * @private
495
+ */
496
+ _computeTokensByRole(cluster_id) {
479
497
  // Query all TOKEN_USAGE messages for this cluster
480
498
  const sql = `SELECT * FROM messages WHERE cluster_id = ? AND topic = 'TOKEN_USAGE' ORDER BY timestamp ASC`;
481
499
  const stmt = this.db.prepare(sql);
@@ -531,6 +549,27 @@ class Ledger extends EventEmitter {
531
549
  return byRole;
532
550
  }
533
551
 
552
+ /**
553
+ * Read messageCount and tokensByRole as one consistent point-in-time view.
554
+ * Runs both queries inside a single BEGIN DEFERRED transaction so a concurrent
555
+ * writer's commit can never be observed by one query but not the other
556
+ * (the exact straddle that produced the msgs=0/$0 phantom in `zeroshot list`).
557
+ * @param {String} cluster_id - Cluster ID
558
+ * @returns {{ messageCount: number, tokensByRole: Object }}
559
+ */
560
+ readSnapshot(cluster_id) {
561
+ if (!cluster_id) {
562
+ throw new Error('cluster_id is required for readSnapshot');
563
+ }
564
+ if (!this._snapshotTxn) {
565
+ this._snapshotTxn = this.db.transaction((id) => ({
566
+ messageCount: this.stmts.count.get(id).count,
567
+ tokensByRole: this._computeTokensByRole(id),
568
+ })).deferred;
569
+ }
570
+ return this._snapshotTxn(cluster_id);
571
+ }
572
+
534
573
  /**
535
574
  * Subscribe to new messages
536
575
  * @param {Function} callback - Called with each new message
@@ -676,6 +715,9 @@ class Ledger extends EventEmitter {
676
715
  * Close the database connection
677
716
  */
678
717
  close() {
718
+ if (this._closed) {
719
+ return;
720
+ }
679
721
  this._closed = true; // Set flag BEFORE closing to prevent race conditions
680
722
  this.db.close();
681
723
  }