@the-open-engine/zeroshot 6.1.0 → 6.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.
Files changed (42) hide show
  1. package/README.md +141 -474
  2. package/cli/commands/cmdproof.js +180 -6
  3. package/cli/event-copy.js +12 -0
  4. package/cli/index.js +311 -96
  5. package/cli/message-formatters-normal.js +14 -2
  6. package/cli/message-formatters-watch.js +9 -2
  7. package/cluster-hooks/block-ask-user-question.py +53 -0
  8. package/cluster-hooks/block-dangerous-git.py +145 -0
  9. package/lib/clusters-registry.js +48 -0
  10. package/lib/compose-utils.js +101 -0
  11. package/lib/detached-startup.js +9 -0
  12. package/lib/id-detector.js +8 -9
  13. package/lib/path-check.js +63 -0
  14. package/lib/run-mode.js +22 -0
  15. package/lib/start-cluster.js +39 -23
  16. package/package.json +3 -4
  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-task-executor.js +4 -3
  21. package/src/agent/pr-verification.js +27 -1
  22. package/src/agents/git-pusher-template.js +121 -4
  23. package/src/attach/socket-discovery.js +2 -3
  24. package/src/claude-credentials.js +75 -0
  25. package/src/claude-task-runner.js +1 -0
  26. package/src/isolation-manager.js +12 -9
  27. package/src/issue-providers/README.md +45 -15
  28. package/src/issue-providers/index.js +2 -0
  29. package/src/issue-providers/jira-provider.js +8 -1
  30. package/src/issue-providers/linear-provider.js +405 -0
  31. package/src/ledger.js +42 -3
  32. package/src/lib/gc.js +2 -3
  33. package/src/message-bus.js +10 -0
  34. package/src/orchestrator.js +264 -144
  35. package/src/preflight.js +12 -16
  36. package/src/providers/base-provider.js +7 -6
  37. package/src/status-footer.js +13 -1
  38. package/src/template-validation/index.js +3 -0
  39. package/src/template-validation/report-formatter.js +55 -0
  40. package/src/worktree-claude-config.js +2 -13
  41. package/task-lib/runner.js +1 -0
  42. package/task-lib/watcher.js +1 -0
@@ -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
package/src/lib/gc.js CHANGED
@@ -9,6 +9,7 @@
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const os = require('os');
12
+ const { readClustersFileSync } = require('../../lib/clusters-registry');
12
13
 
13
14
  const DEFAULT_STORAGE_DIR = path.join(os.homedir(), '.zeroshot');
14
15
 
@@ -35,10 +36,8 @@ function resolveActiveClusterIdFromEnv() {
35
36
  */
36
37
  function readKnownClusterIds(storageDir) {
37
38
  const ids = new Set();
38
- const clustersFile = path.join(storageDir, 'clusters.json');
39
39
  try {
40
- if (!fs.existsSync(clustersFile)) return ids;
41
- const raw = JSON.parse(fs.readFileSync(clustersFile, 'utf8'));
40
+ const raw = readClustersFileSync(storageDir);
42
41
  for (const id of Object.keys(raw)) ids.add(id);
43
42
  } catch {
44
43
  // Corrupt/missing — treat as empty (safe: nothing deleted incorrectly)
@@ -180,6 +180,16 @@ class MessageBus extends EventEmitter {
180
180
  return this.ledger.getTokensByRole(cluster_id);
181
181
  }
182
182
 
183
+ /**
184
+ * Read messageCount and tokensByRole as one consistent point-in-time view
185
+ * (passthrough to ledger)
186
+ * @param {String} cluster_id - Cluster ID
187
+ * @returns {{ messageCount: number, tokensByRole: Object }}
188
+ */
189
+ readSnapshot(cluster_id) {
190
+ return this.ledger.readSnapshot(cluster_id);
191
+ }
192
+
183
193
  /**
184
194
  * Register a WebSocket client for broadcasts
185
195
  * @param {WebSocket} ws - WebSocket connection