@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.
- package/README.md +141 -474
- package/cli/commands/cmdproof.js +180 -6
- package/cli/event-copy.js +12 -0
- package/cli/index.js +311 -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 +9 -0
- package/lib/id-detector.js +8 -9
- package/lib/path-check.js +63 -0
- package/lib/run-mode.js +22 -0
- package/lib/start-cluster.js +39 -23
- package/package.json +3 -4
- 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-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 +42 -3
- package/src/lib/gc.js +2 -3
- package/src/message-bus.js +10 -0
- package/src/orchestrator.js +264 -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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const chalk = require('chalk');
|
|
10
|
+
const { EVENT_COPY, formatMergeStatus } = require('./event-copy');
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Format AGENT_LIFECYCLE events
|
|
@@ -115,7 +116,9 @@ function formatIssueOpened(msg, prefix, timestamp, shownNewTaskForCluster, print
|
|
|
115
116
|
* @returns {boolean} True if message was handled
|
|
116
117
|
*/
|
|
117
118
|
function formatImplementationReady(msg, prefix, timestamp, print = console.log) {
|
|
118
|
-
print(
|
|
119
|
+
print(
|
|
120
|
+
`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.yellow(`✅ ${EVENT_COPY.IMPLEMENTATION_READY.toUpperCase()}`)}`
|
|
121
|
+
);
|
|
119
122
|
|
|
120
123
|
if (msg.content?.data?.commit) {
|
|
121
124
|
print(
|
|
@@ -233,7 +236,9 @@ function formatPrCreated(msg, prefix, timestamp, print = console.log) {
|
|
|
233
236
|
|
|
234
237
|
print(''); // Blank line before PR notification
|
|
235
238
|
print(chalk.bold.green(`${'─'.repeat(60)}`));
|
|
236
|
-
print(
|
|
239
|
+
print(
|
|
240
|
+
`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.green(`🎉 ${EVENT_COPY.PR_CREATED.toUpperCase()}`)}`
|
|
241
|
+
);
|
|
237
242
|
|
|
238
243
|
if (prNumber) {
|
|
239
244
|
print(`${prefix} ${chalk.gray('PR:')} ${chalk.cyan(`#${prNumber}`)}`);
|
|
@@ -242,6 +247,13 @@ function formatPrCreated(msg, prefix, timestamp, print = console.log) {
|
|
|
242
247
|
print(`${prefix} ${chalk.gray('URL:')} ${chalk.blue(prUrl)}`);
|
|
243
248
|
}
|
|
244
249
|
|
|
250
|
+
const mergeStatus = formatMergeStatus(msg.content?.data?.merged);
|
|
251
|
+
if (mergeStatus) {
|
|
252
|
+
print(
|
|
253
|
+
`${prefix} ${chalk.gray('Merge:')} ${mergeStatus === 'merged' ? chalk.green(mergeStatus) : chalk.yellow(mergeStatus)}`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
245
257
|
print(chalk.bold.green(`${'─'.repeat(60)}`));
|
|
246
258
|
return true;
|
|
247
259
|
}
|
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
getColorForSender,
|
|
10
10
|
parseDataField,
|
|
11
11
|
} = require('./message-formatter-utils');
|
|
12
|
+
const { EVENT_COPY, formatMergeStatus } = require('./event-copy');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Format AGENT_ERROR for watch mode
|
|
@@ -49,7 +50,7 @@ function formatIssueOpened(msg, clusterPrefix) {
|
|
|
49
50
|
function formatImplementationReady(msg, clusterPrefix) {
|
|
50
51
|
const agentColor = getColorForSender(msg.sender);
|
|
51
52
|
const agentName = agentColor(msg.sender);
|
|
52
|
-
const eventText = `${agentName}
|
|
53
|
+
const eventText = `${agentName} ${EVENT_COPY.IMPLEMENTATION_READY.toLowerCase()}`;
|
|
53
54
|
console.log(`${clusterPrefix} ${eventText}`);
|
|
54
55
|
}
|
|
55
56
|
|
|
@@ -109,7 +110,11 @@ function formatPrCreated(msg, clusterPrefix) {
|
|
|
109
110
|
const agentColor = getColorForSender(msg.sender);
|
|
110
111
|
const agentName = agentColor(msg.sender);
|
|
111
112
|
const prNum = msg.content?.data?.pr_number || '';
|
|
112
|
-
|
|
113
|
+
let eventText = `${agentName} ${EVENT_COPY.PR_CREATED.toLowerCase()}${prNum ? ` #${prNum}` : ''}`;
|
|
114
|
+
const mergeStatus = formatMergeStatus(msg.content?.data?.merged);
|
|
115
|
+
if (mergeStatus) {
|
|
116
|
+
eventText += chalk.dim(` — ${mergeStatus}`);
|
|
117
|
+
}
|
|
113
118
|
console.log(`${clusterPrefix} ${eventText}`);
|
|
114
119
|
}
|
|
115
120
|
|
|
@@ -182,4 +187,6 @@ function formatWatchMode(msg, isActive) {
|
|
|
182
187
|
|
|
183
188
|
module.exports = {
|
|
184
189
|
formatWatchMode,
|
|
190
|
+
formatImplementationReady,
|
|
191
|
+
formatPrCreated,
|
|
185
192
|
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PreToolUse hook to block AskUserQuestion in zeroshot agent mode.
|
|
4
|
+
|
|
5
|
+
This hook is installed globally but ONLY activates when the environment
|
|
6
|
+
variable ZEROSHOT_BLOCK_ASK_USER=1 is set. This keeps the blocking
|
|
7
|
+
specific to zeroshot invocations without affecting normal Claude usage.
|
|
8
|
+
|
|
9
|
+
Exit codes:
|
|
10
|
+
- 0 with JSON: Normal execution (allow/deny decision)
|
|
11
|
+
- 0 without output: No decision (pass through)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
# Only activate in zeroshot mode (env var set by agent-wrapper)
|
|
20
|
+
if os.environ.get("ZEROSHOT_BLOCK_ASK_USER") != "1":
|
|
21
|
+
# Not in zeroshot mode - pass through without decision
|
|
22
|
+
sys.exit(0)
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
input_data = json.load(sys.stdin)
|
|
26
|
+
except json.JSONDecodeError:
|
|
27
|
+
# Invalid input - let it through
|
|
28
|
+
sys.exit(0)
|
|
29
|
+
|
|
30
|
+
tool_name = input_data.get("tool_name", "")
|
|
31
|
+
|
|
32
|
+
if tool_name == "AskUserQuestion":
|
|
33
|
+
# Block the tool with a clear explanation
|
|
34
|
+
output = {
|
|
35
|
+
"hookSpecificOutput": {
|
|
36
|
+
"hookEventName": "PreToolUse",
|
|
37
|
+
"permissionDecision": "deny",
|
|
38
|
+
"permissionDecisionReason": (
|
|
39
|
+
"AskUserQuestion is BLOCKED in zeroshot cluster mode. "
|
|
40
|
+
"You are running non-interactively with no user to respond. "
|
|
41
|
+
"Make autonomous decisions instead. "
|
|
42
|
+
"If choosing between 'fix code' vs 'relax rules', ALWAYS fix the code."
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
print(json.dumps(output))
|
|
47
|
+
sys.exit(0)
|
|
48
|
+
|
|
49
|
+
# All other tools - no decision (let normal permissions apply)
|
|
50
|
+
sys.exit(0)
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
main()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PreToolUse hook to block dangerous git commands in zeroshot worktree mode.
|
|
4
|
+
|
|
5
|
+
This hook is installed globally but ONLY activates when the environment
|
|
6
|
+
variable ZEROSHOT_WORKTREE=1 is set. This keeps the blocking specific to
|
|
7
|
+
worktree-isolated zeroshot invocations.
|
|
8
|
+
|
|
9
|
+
Blocks:
|
|
10
|
+
- git stash (all forms) - hides work from other agents
|
|
11
|
+
- git checkout -- <file> - discards uncommitted changes
|
|
12
|
+
- git checkout -f / git checkout . - discards all local changes
|
|
13
|
+
- git reset --hard - destroys commits AND changes
|
|
14
|
+
- git push --force / -f - rewrites remote history
|
|
15
|
+
- git clean -f/-fd - deletes untracked files
|
|
16
|
+
- git branch -D - force deletes unmerged branch
|
|
17
|
+
- git rebase -i / git add -i/-p - interactive (hangs in non-interactive mode)
|
|
18
|
+
|
|
19
|
+
Exit codes:
|
|
20
|
+
- 0 with JSON: Normal execution (allow/deny decision)
|
|
21
|
+
- 0 without output: No decision (pass through)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
from typing import Optional, Tuple
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Patterns that should be BLOCKED
|
|
32
|
+
# Each tuple: (pattern, reason, safe_alternative)
|
|
33
|
+
DANGEROUS_PATTERNS = [
|
|
34
|
+
# Stash - hides work from other agents
|
|
35
|
+
(r"\bgit\s+stash\b",
|
|
36
|
+
"git stash hides work from other agents",
|
|
37
|
+
"Use 'git add -A && git commit -m \"WIP: ...\"' instead"),
|
|
38
|
+
|
|
39
|
+
# Checkout discarding changes
|
|
40
|
+
(r"\bgit\s+checkout\s+--\s+\S+",
|
|
41
|
+
"git checkout -- <file> discards uncommitted changes permanently",
|
|
42
|
+
"Commit your work first: 'git add -A && git commit -m \"WIP: ...\"'"),
|
|
43
|
+
|
|
44
|
+
(r"\bgit\s+checkout\s+-f\b",
|
|
45
|
+
"git checkout -f discards ALL local changes permanently",
|
|
46
|
+
"Commit your work first: 'git add -A && git commit -m \"WIP: ...\"'"),
|
|
47
|
+
|
|
48
|
+
(r"\bgit\s+checkout\s+\.\s*$",
|
|
49
|
+
"git checkout . discards all changes in the directory",
|
|
50
|
+
"Commit your work first: 'git add -A && git commit -m \"WIP: ...\"'"),
|
|
51
|
+
|
|
52
|
+
# Reset --hard
|
|
53
|
+
(r"\bgit\s+reset\s+--hard\b",
|
|
54
|
+
"git reset --hard destroys commits AND uncommitted changes",
|
|
55
|
+
"Use 'git reset --soft' to keep changes, or 'git revert' to undo commits"),
|
|
56
|
+
|
|
57
|
+
# Force push
|
|
58
|
+
(r"\bgit\s+push\s+--force\b",
|
|
59
|
+
"git push --force rewrites remote history",
|
|
60
|
+
"Use 'git push' (normal push) or 'git pull --rebase' to sync"),
|
|
61
|
+
|
|
62
|
+
(r"\bgit\s+push\s+-f\b",
|
|
63
|
+
"git push -f rewrites remote history",
|
|
64
|
+
"Use 'git push' (normal push) or 'git pull --rebase' to sync"),
|
|
65
|
+
|
|
66
|
+
# Clean with force
|
|
67
|
+
(r"\bgit\s+clean\s+-[fd]*f",
|
|
68
|
+
"git clean -f deletes untracked files permanently",
|
|
69
|
+
"Use 'git clean -n' (dry run) first to see what would be deleted"),
|
|
70
|
+
|
|
71
|
+
# Branch force delete
|
|
72
|
+
(r"\bgit\s+branch\s+-D\b",
|
|
73
|
+
"git branch -D force deletes unmerged branch",
|
|
74
|
+
"Use 'git branch -d' (lowercase) which only deletes merged branches"),
|
|
75
|
+
|
|
76
|
+
# Interactive commands (hang in non-interactive mode)
|
|
77
|
+
(r"\bgit\s+rebase\s+-i\b",
|
|
78
|
+
"git rebase -i is interactive and will hang in non-interactive mode",
|
|
79
|
+
"Use 'git reset --soft HEAD~N' to undo commits while keeping changes"),
|
|
80
|
+
|
|
81
|
+
(r"\bgit\s+add\s+-[ip]\b",
|
|
82
|
+
"git add -i/-p is interactive and will hang in non-interactive mode",
|
|
83
|
+
"Use 'git add <files>' or 'git add -A' instead"),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_command(command: str) -> Optional[Tuple[bool, str, str]]:
|
|
88
|
+
"""Check if command matches any dangerous pattern.
|
|
89
|
+
|
|
90
|
+
Returns (matched, reason, alternative) if dangerous, None if safe.
|
|
91
|
+
|
|
92
|
+
NOTE: We do NOT use re.IGNORECASE because git flags are case-sensitive.
|
|
93
|
+
For example: 'git branch -d' (lowercase) is safe, 'git branch -D' (uppercase) is dangerous.
|
|
94
|
+
"""
|
|
95
|
+
for pattern, reason, alternative in DANGEROUS_PATTERNS:
|
|
96
|
+
if re.search(pattern, command):
|
|
97
|
+
return (True, reason, alternative)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main():
|
|
102
|
+
# Only activate in zeroshot worktree mode
|
|
103
|
+
if os.environ.get("ZEROSHOT_WORKTREE") != "1":
|
|
104
|
+
# Not in worktree mode - pass through without decision
|
|
105
|
+
sys.exit(0)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
input_data = json.load(sys.stdin)
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
# Invalid input - let it through
|
|
111
|
+
sys.exit(0)
|
|
112
|
+
|
|
113
|
+
tool_name = input_data.get("tool_name", "")
|
|
114
|
+
|
|
115
|
+
# Only check Bash tool
|
|
116
|
+
if tool_name != "Bash":
|
|
117
|
+
sys.exit(0)
|
|
118
|
+
|
|
119
|
+
tool_input = input_data.get("tool_input", {})
|
|
120
|
+
command = tool_input.get("command", "")
|
|
121
|
+
|
|
122
|
+
# Check for dangerous patterns
|
|
123
|
+
result = check_command(command)
|
|
124
|
+
if result:
|
|
125
|
+
_, reason, alternative = result
|
|
126
|
+
output = {
|
|
127
|
+
"hookSpecificOutput": {
|
|
128
|
+
"hookEventName": "PreToolUse",
|
|
129
|
+
"permissionDecision": "deny",
|
|
130
|
+
"permissionDecisionReason": (
|
|
131
|
+
f"[GIT-SAFE BLOCKED] {reason}\n\n"
|
|
132
|
+
f"Safe alternative: {alternative}\n\n"
|
|
133
|
+
"NO BYPASS EXISTS. Use the safe alternative above."
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
print(json.dumps(output))
|
|
138
|
+
sys.exit(0)
|
|
139
|
+
|
|
140
|
+
# Command is safe - no decision (let normal permissions apply)
|
|
141
|
+
sys.exit(0)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
main()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single read/write path for clusters.json.
|
|
3
|
+
*
|
|
4
|
+
* All callers that need the raw registry (Orchestrator, id-detector, gc,
|
|
5
|
+
* socket-discovery, CLI) go through readClustersFileSync/writeClustersFileAtomic
|
|
6
|
+
* instead of ad-hoc JSON.parse(fs.readFileSync(...)) / fs.writeFileSync(...).
|
|
7
|
+
*
|
|
8
|
+
* Writes are atomic (temp file + rename) so a reader can never observe a
|
|
9
|
+
* partially-written file, even without taking the write lock. Callers that
|
|
10
|
+
* read-modify-write (Orchestrator._saveClusters) still need proper-lockfile
|
|
11
|
+
* around the whole operation to avoid losing concurrent updates.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
function clustersFilePath(storageDir) {
|
|
18
|
+
return path.join(storageDir, 'clusters.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read clusters.json. Returns {} if missing or unparsable.
|
|
23
|
+
* @param {string} storageDir
|
|
24
|
+
* @returns {Object}
|
|
25
|
+
*/
|
|
26
|
+
function readClustersFileSync(storageDir) {
|
|
27
|
+
const clustersFile = clustersFilePath(storageDir);
|
|
28
|
+
if (!fs.existsSync(clustersFile)) {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
const raw = fs.readFileSync(clustersFile, 'utf8');
|
|
32
|
+
return JSON.parse(raw);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Write clusters.json atomically (write to a pid-scoped temp file, then rename).
|
|
37
|
+
* rename(2) is atomic on the same filesystem, so no reader can observe a partial file.
|
|
38
|
+
* @param {string} storageDir
|
|
39
|
+
* @param {Object} data
|
|
40
|
+
*/
|
|
41
|
+
function writeClustersFileAtomic(storageDir, data) {
|
|
42
|
+
const clustersFile = clustersFilePath(storageDir);
|
|
43
|
+
const tmpPath = `${clustersFile}.tmp-${process.pid}`;
|
|
44
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
45
|
+
fs.renameSync(tmpPath, clustersFile);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { clustersFilePath, readClustersFileSync, writeClustersFileAtomic };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Compose teardown resolution for worktree cleanup.
|
|
3
|
+
*
|
|
4
|
+
* Zeroshot never runs `docker compose up` — any compose file found in a worktree
|
|
5
|
+
* belongs to the target repo, not to zeroshot. Tearing it down is only safe when
|
|
6
|
+
* the resolved Compose project name is scoped to the worktree directory itself
|
|
7
|
+
* (i.e. nothing pins it to the host's real, possibly-already-running project).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const COMPOSE_FILENAMES = [
|
|
14
|
+
'docker-compose.yml',
|
|
15
|
+
'docker-compose.yaml',
|
|
16
|
+
'compose.yml',
|
|
17
|
+
'compose.yaml',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reproduce Docker Compose's default project-name normalization
|
|
22
|
+
* (compose-go NormalizeProjectName: lowercase -> keep [a-z0-9_-] -> trim leading '_'/'-').
|
|
23
|
+
*
|
|
24
|
+
* @param {string} name
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function normalizeComposeProjectName(name) {
|
|
28
|
+
return name
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/[^a-z0-9_-]/g, '')
|
|
31
|
+
.replace(/^[-_]+/, '');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Decide whether it's safe to run `docker compose down` in a worktree, and with what args.
|
|
36
|
+
* Never includes `--volumes` — automatic cleanup must not delete named volumes.
|
|
37
|
+
* Skips teardown entirely when the Compose project name is pinned (top-level `name:`
|
|
38
|
+
* in the compose file, or `COMPOSE_PROJECT_NAME` env var), since that resolves to a
|
|
39
|
+
* shared/host project zeroshot did not create.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} worktreePath - Path to the worktree directory
|
|
42
|
+
* @returns {{ shouldTeardown: boolean, reason?: string, composePath?: string, args?: string[] }}
|
|
43
|
+
*/
|
|
44
|
+
function resolveWorktreeComposeTeardown(worktreePath) {
|
|
45
|
+
let composePath = null;
|
|
46
|
+
for (const filename of COMPOSE_FILENAMES) {
|
|
47
|
+
const candidate = path.join(worktreePath, filename);
|
|
48
|
+
if (fs.existsSync(candidate)) {
|
|
49
|
+
composePath = candidate;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!composePath) {
|
|
55
|
+
return { shouldTeardown: false, reason: 'no compose file' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (
|
|
59
|
+
typeof process.env.COMPOSE_PROJECT_NAME === 'string' &&
|
|
60
|
+
process.env.COMPOSE_PROJECT_NAME.trim() !== ''
|
|
61
|
+
) {
|
|
62
|
+
return {
|
|
63
|
+
shouldTeardown: false,
|
|
64
|
+
reason: 'pinned compose project name (shared host project)',
|
|
65
|
+
composePath,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const yaml = require('js-yaml');
|
|
71
|
+
const contents = fs.readFileSync(composePath, 'utf8');
|
|
72
|
+
const parsed = yaml.load(contents);
|
|
73
|
+
if (parsed && typeof parsed === 'object' && parsed.name) {
|
|
74
|
+
return {
|
|
75
|
+
shouldTeardown: false,
|
|
76
|
+
reason: 'pinned compose project name (shared host project)',
|
|
77
|
+
composePath,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Fail safe: if the compose file can't be read/parsed, we cannot confirm project
|
|
82
|
+
// identity is worktree-scoped, so never tear down.
|
|
83
|
+
return { shouldTeardown: false, reason: 'compose file unreadable or unparsable', composePath };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
shouldTeardown: true,
|
|
88
|
+
composePath,
|
|
89
|
+
args: [
|
|
90
|
+
'compose',
|
|
91
|
+
'-p',
|
|
92
|
+
normalizeComposeProjectName(path.basename(worktreePath)),
|
|
93
|
+
'down',
|
|
94
|
+
'--remove-orphans',
|
|
95
|
+
'--timeout',
|
|
96
|
+
'10',
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { resolveWorktreeComposeTeardown, normalizeComposeProjectName };
|
package/lib/detached-startup.js
CHANGED
|
@@ -131,6 +131,7 @@ async function registerDetachedSetupCluster({
|
|
|
131
131
|
prBase: runOptions.prBase,
|
|
132
132
|
mergeQueue: runOptions.mergeQueue || false,
|
|
133
133
|
closeIssue: runOptions.closeIssue || null,
|
|
134
|
+
autoMerge: Boolean(runOptions.ship),
|
|
134
135
|
cwd: cwd || null,
|
|
135
136
|
}
|
|
136
137
|
: null,
|
|
@@ -146,6 +147,13 @@ async function registerDetachedSetupCluster({
|
|
|
146
147
|
});
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
async function removeDetachedSetupCluster({ clusterId, storageDir }) {
|
|
151
|
+
await updateClustersFile(storageDir, (clusters) => {
|
|
152
|
+
delete clusters[clusterId];
|
|
153
|
+
return clusters;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
149
157
|
async function markDetachedSetupFailed({ clusterId, storageDir, error, logPath }) {
|
|
150
158
|
await updateClustersFile(storageDir, (clusters) => {
|
|
151
159
|
const existing = clusters[clusterId] || { id: clusterId, createdAt: Date.now() };
|
|
@@ -215,6 +223,7 @@ module.exports = {
|
|
|
215
223
|
isProcessAlive,
|
|
216
224
|
markDetachedSetupFailed,
|
|
217
225
|
registerDetachedSetupCluster,
|
|
226
|
+
removeDetachedSetupCluster,
|
|
218
227
|
resolveWaitTimeoutMs,
|
|
219
228
|
waitForClusterRegistration,
|
|
220
229
|
};
|
package/lib/id-detector.js
CHANGED
|
@@ -11,6 +11,7 @@ const path = require('path');
|
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const os = require('os');
|
|
13
13
|
const Database = require('better-sqlite3');
|
|
14
|
+
const { readClustersFileSync } = require('./clusters-registry');
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Detect if ID is a cluster or task
|
|
@@ -20,19 +21,17 @@ const Database = require('better-sqlite3');
|
|
|
20
21
|
function detectIdType(id) {
|
|
21
22
|
const homeDir =
|
|
22
23
|
process.env.ZEROSHOT_HOME || process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
23
|
-
const
|
|
24
|
+
const storageDir = path.join(homeDir, '.zeroshot');
|
|
24
25
|
const taskDbFile = path.join(homeDir, '.claude-zeroshot', 'store.db');
|
|
25
26
|
|
|
26
27
|
// Check clusters
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return 'cluster';
|
|
32
|
-
}
|
|
33
|
-
} catch {
|
|
34
|
-
// Ignore parse errors
|
|
28
|
+
try {
|
|
29
|
+
const clusters = readClustersFileSync(storageDir);
|
|
30
|
+
if (clusters[id]) {
|
|
31
|
+
return 'cluster';
|
|
35
32
|
}
|
|
33
|
+
} catch {
|
|
34
|
+
// Ignore parse errors
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
// Check tasks in SQLite
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects whether the npm global bin directory is on PATH, so we can warn
|
|
3
|
+
* the user when `npm install -g` succeeds but the `zeroshot` binary is
|
|
4
|
+
* unreachable (e.g. non-standard Node installs whose global bin dir isn't
|
|
5
|
+
* exported).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
function getGlobalBinDir(installPrefix) {
|
|
11
|
+
if (process.platform === 'win32') {
|
|
12
|
+
return installPrefix;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return path.join(installPrefix, 'bin');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isDirOnPath(dir, pathEnv = process.env.PATH || '') {
|
|
19
|
+
const resolvedDir = path.resolve(dir);
|
|
20
|
+
|
|
21
|
+
return pathEnv
|
|
22
|
+
.split(path.delimiter)
|
|
23
|
+
.filter((entry) => entry.length > 0)
|
|
24
|
+
.some((entry) => path.resolve(entry) === resolvedDir);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getPathExportLine(dir) {
|
|
28
|
+
return `export PATH="${dir}:$PATH"`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function checkBinDirOnPath(options = {}) {
|
|
32
|
+
if (process.platform === 'win32') {
|
|
33
|
+
return { onPath: true, binDir: null };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const { getInstallPrefix } = require('../cli/lib/update-checker');
|
|
38
|
+
const installPrefix = options.installPrefix || getInstallPrefix(options);
|
|
39
|
+
const binDir = getGlobalBinDir(installPrefix);
|
|
40
|
+
|
|
41
|
+
return { onPath: isDirOnPath(binDir, options.pathEnv), binDir };
|
|
42
|
+
} catch {
|
|
43
|
+
return { onPath: true, binDir: null };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function printPathWarning(binDir) {
|
|
48
|
+
console.error(
|
|
49
|
+
`[zeroshot] Warning: ${binDir} is not on your PATH — the 'zeroshot' command may not be found.`
|
|
50
|
+
);
|
|
51
|
+
console.error(` ${getPathExportLine(binDir)}`);
|
|
52
|
+
console.error(
|
|
53
|
+
' Add this line to your shell profile (~/.zshrc, ~/.bashrc, or ~/.profile) to fix this permanently.'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
getGlobalBinDir,
|
|
59
|
+
isDirOnPath,
|
|
60
|
+
getPathExportLine,
|
|
61
|
+
checkBinDirOnPath,
|
|
62
|
+
printPathWarning,
|
|
63
|
+
};
|
package/lib/run-mode.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
function resolveRunMode(options) {
|
|
2
|
+
if (options.ship) return options.docker ? 'ship+docker' : 'ship';
|
|
3
|
+
if (options.pr) return options.docker ? 'pr+docker' : 'pr';
|
|
4
|
+
if (options.docker) return 'docker';
|
|
5
|
+
if (options.worktree) return 'worktree';
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const RUN_MODE_LABELS = {
|
|
10
|
+
ship: 'ship (worktree + PR + auto-merge)',
|
|
11
|
+
'ship+docker': 'ship (docker + PR + auto-merge)',
|
|
12
|
+
pr: 'pr (worktree + PR)',
|
|
13
|
+
'pr+docker': 'pr (docker + PR)',
|
|
14
|
+
docker: 'docker (isolated container)',
|
|
15
|
+
worktree: 'worktree (isolated branch)',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function describeRunMode(mode) {
|
|
19
|
+
return RUN_MODE_LABELS[mode] || 'local (no isolation)';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { resolveRunMode, describeRunMode };
|
package/lib/start-cluster.js
CHANGED
|
@@ -3,7 +3,9 @@ const { spawnSync } = require('child_process');
|
|
|
3
3
|
const chalk = require('chalk');
|
|
4
4
|
const { normalizeProviderName } = require('./provider-names');
|
|
5
5
|
const { getProvider } = require('../src/providers');
|
|
6
|
+
const { detectProvider } = require('../src/issue-providers');
|
|
6
7
|
const TemplateResolver = require('../src/template-resolver');
|
|
8
|
+
const { resolveRunMode } = require('./run-mode');
|
|
7
9
|
|
|
8
10
|
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
9
11
|
|
|
@@ -96,35 +98,44 @@ function buildFileInput(file) {
|
|
|
96
98
|
return { file };
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
function detectRunInput(inputArg) {
|
|
100
|
-
const isGitHubUrl = /^https?:\/\/github\.com\/[\w-]+\/[\w-]+\/issues\/\d+/.test(inputArg);
|
|
101
|
-
const isGitLabUrl = /gitlab\.(com|[\w.-]+)\/[\w-]+\/[\w-]+\/-\/issues\/\d+/.test(inputArg);
|
|
102
|
-
const isJiraUrl = /(atlassian\.net|jira\.[\w.-]+)\/browse\/[A-Z][A-Z0-9]+-\d+/.test(inputArg);
|
|
103
|
-
const isAzureUrl =
|
|
104
|
-
/dev\.azure\.com\/.*\/_workitems\/edit\/\d+/.test(inputArg) ||
|
|
105
|
-
/visualstudio\.com\/.*\/_workitems\/edit\/\d+/.test(inputArg);
|
|
106
|
-
const isJiraKey = /^[A-Z][A-Z0-9]+-\d+$/.test(inputArg);
|
|
107
|
-
const isIssueNumber = /^\d+$/.test(inputArg);
|
|
108
|
-
const isRepoIssue = /^[\w-]+\/[\w-]+#\d+$/.test(inputArg);
|
|
101
|
+
function detectRunInput(inputArg, settings = {}, forceProvider = null) {
|
|
109
102
|
const isMarkdownFile = /\.(md|markdown)$/i.test(inputArg);
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
isGitHubUrl ||
|
|
113
|
-
isGitLabUrl ||
|
|
114
|
-
isJiraUrl ||
|
|
115
|
-
isAzureUrl ||
|
|
116
|
-
isJiraKey ||
|
|
117
|
-
isIssueNumber ||
|
|
118
|
-
isRepoIssue
|
|
119
|
-
) {
|
|
120
|
-
return buildIssueInput(inputArg);
|
|
121
|
-
}
|
|
122
103
|
if (isMarkdownFile) {
|
|
123
104
|
return buildFileInput(inputArg);
|
|
124
105
|
}
|
|
106
|
+
|
|
107
|
+
const ProviderClass = detectProvider(inputArg, settings, forceProvider);
|
|
108
|
+
if (ProviderClass) {
|
|
109
|
+
return buildIssueInput(inputArg);
|
|
110
|
+
}
|
|
111
|
+
|
|
125
112
|
return buildTextInput(inputArg);
|
|
126
113
|
}
|
|
127
114
|
|
|
115
|
+
const STDIN_MARKER = '-';
|
|
116
|
+
|
|
117
|
+
function isStdinInput(inputArg) {
|
|
118
|
+
return inputArg === STDIN_MARKER;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function readStdinText(stream = process.stdin) {
|
|
122
|
+
const chunks = [];
|
|
123
|
+
for await (const chunk of stream) {
|
|
124
|
+
chunks.push(chunk);
|
|
125
|
+
}
|
|
126
|
+
return Buffer.concat(chunks.map((chunk) => (Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))))
|
|
127
|
+
.toString('utf8')
|
|
128
|
+
.trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function encodeStdinEnv(text) {
|
|
132
|
+
return Buffer.from(text, 'utf8').toString('base64');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function decodeStdinEnv(value) {
|
|
136
|
+
return Buffer.from(value, 'base64').toString('utf8');
|
|
137
|
+
}
|
|
138
|
+
|
|
128
139
|
function resolveProviderOverride(options = {}) {
|
|
129
140
|
const override = options.provider || options.envProvider || process.env.ZEROSHOT_PROVIDER;
|
|
130
141
|
if (!override || (typeof override === 'string' && !override.trim())) {
|
|
@@ -234,7 +245,7 @@ function buildStartOptions({
|
|
|
234
245
|
isolationImage: firstTruthy(mergedOptions.dockerImage, process.env.ZEROSHOT_DOCKER_IMAGE),
|
|
235
246
|
worktree: anyTruthy(mergedOptions.worktree, process.env.ZEROSHOT_WORKTREE === '1'),
|
|
236
247
|
autoPr: anyTruthy(mergedOptions.pr, process.env.ZEROSHOT_PR === '1'),
|
|
237
|
-
autoMerge:
|
|
248
|
+
autoMerge: Boolean(mergedOptions.ship),
|
|
238
249
|
autoPush: process.env.ZEROSHOT_PUSH === '1',
|
|
239
250
|
modelOverride: optionalValue(modelOverride),
|
|
240
251
|
providerOverride: optionalValue(providerOverride),
|
|
@@ -246,6 +257,7 @@ function buildStartOptions({
|
|
|
246
257
|
mergeQueue: resolveMergeQueue(mergedOptions),
|
|
247
258
|
closeIssue: resolveCloseIssue(mergedOptions),
|
|
248
259
|
ship: mergedOptions.ship,
|
|
260
|
+
runMode: resolveRunMode(mergedOptions) || null,
|
|
249
261
|
requiredQualityGates: mergedOptions.requiredQualityGates,
|
|
250
262
|
settings,
|
|
251
263
|
};
|
|
@@ -310,6 +322,10 @@ module.exports = {
|
|
|
310
322
|
buildIssueInput,
|
|
311
323
|
buildFileInput,
|
|
312
324
|
detectRunInput,
|
|
325
|
+
isStdinInput,
|
|
326
|
+
readStdinText,
|
|
327
|
+
encodeStdinEnv,
|
|
328
|
+
decodeStdinEnv,
|
|
313
329
|
resolveProviderOverride,
|
|
314
330
|
resolveConfigPath,
|
|
315
331
|
loadClusterConfig,
|