@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.
- package/README.md +141 -474
- package/cli/event-copy.js +12 -0
- package/cli/index.js +314 -96
- package/cli/message-formatters-normal.js +14 -2
- package/cli/message-formatters-watch.js +9 -2
- package/cluster-hooks/block-ask-user-question.py +53 -0
- package/cluster-hooks/block-dangerous-git.py +145 -0
- package/lib/clusters-registry.js +48 -0
- package/lib/compose-utils.js +101 -0
- package/lib/detached-startup.js +12 -1
- package/lib/id-detector.js +8 -9
- package/lib/path-check.js +63 -0
- package/lib/run-mode.js +34 -0
- package/lib/run-plan.js +32 -0
- package/lib/start-cluster.js +58 -28
- package/package.json +7 -6
- package/scripts/check-path.js +19 -0
- package/scripts/validate-templates.js +11 -29
- package/src/agent/agent-hook-executor.js +1 -1
- package/src/agent/agent-lifecycle.js +2 -0
- package/src/agent/agent-task-executor.js +4 -3
- package/src/agent/pr-verification.js +27 -1
- package/src/agents/git-pusher-template.js +121 -4
- package/src/attach/socket-discovery.js +2 -3
- package/src/claude-credentials.js +75 -0
- package/src/claude-task-runner.js +1 -0
- package/src/isolation-manager.js +12 -9
- package/src/issue-providers/README.md +45 -15
- package/src/issue-providers/index.js +2 -0
- package/src/issue-providers/jira-provider.js +8 -1
- package/src/issue-providers/linear-provider.js +405 -0
- package/src/ledger.js +45 -3
- package/src/lib/gc.js +2 -3
- package/src/message-bus.js +22 -2
- package/src/orchestrator.js +271 -144
- package/src/preflight.js +12 -16
- package/src/providers/base-provider.js +7 -6
- package/src/status-footer.js +13 -1
- package/src/template-validation/index.js +3 -0
- package/src/template-validation/report-formatter.js +55 -0
- package/src/worktree-claude-config.js +2 -13
- package/task-lib/runner.js +1 -0
- package/task-lib/watcher.js +1 -0
package/src/isolation-manager.js
CHANGED
|
@@ -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
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
-
|
|
1655
|
-
|
|
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',
|
|
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,
|
|
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
|
|
8
|
-
| ---------------- |
|
|
9
|
-
| **GitHub** | `gh`
|
|
10
|
-
| **GitLab** | `glab`
|
|
11
|
-
| **Jira** | `jira`
|
|
12
|
-
| **Azure DevOps** | `az`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|