@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
@@ -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(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.yellow('✅ IMPLEMENTATION READY')}`);
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(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.green('🎉 PULL REQUEST CREATED')}`);
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} completed implementation`;
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
- const eventText = `${agentName} created PR${prNum ? ` #${prNum}` : ''}`;
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 };
@@ -2,6 +2,7 @@ const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
4
  const lockfile = require('proper-lockfile');
5
+ const { resolveRunPlan } = require('./run-plan');
5
6
 
6
7
  const DEFAULT_WAIT_TIMEOUT_SECONDS = 180;
7
8
  const DEFAULT_WAIT_POLL_MS = 1000;
@@ -116,6 +117,7 @@ async function registerDetachedSetupCluster({
116
117
  runOptions = {},
117
118
  cwd,
118
119
  }) {
120
+ const plan = resolveRunPlan(runOptions);
119
121
  await updateClustersFile(storageDir, (clusters) => {
120
122
  clusters[clusterId] = {
121
123
  id: clusterId,
@@ -125,12 +127,13 @@ async function registerDetachedSetupCluster({
125
127
  setupLogPath: logPath || null,
126
128
  setupStartedAt: Date.now(),
127
129
  setupStage: 'starting',
128
- autoPr: Boolean(runOptions.pr || runOptions.ship),
130
+ autoPr: plan.delivery !== 'none',
129
131
  prOptions: runOptions.prBase
130
132
  ? {
131
133
  prBase: runOptions.prBase,
132
134
  mergeQueue: runOptions.mergeQueue || false,
133
135
  closeIssue: runOptions.closeIssue || null,
136
+ autoMerge: plan.autoMerge,
134
137
  cwd: cwd || null,
135
138
  }
136
139
  : null,
@@ -146,6 +149,13 @@ async function registerDetachedSetupCluster({
146
149
  });
147
150
  }
148
151
 
152
+ async function removeDetachedSetupCluster({ clusterId, storageDir }) {
153
+ await updateClustersFile(storageDir, (clusters) => {
154
+ delete clusters[clusterId];
155
+ return clusters;
156
+ });
157
+ }
158
+
149
159
  async function markDetachedSetupFailed({ clusterId, storageDir, error, logPath }) {
150
160
  await updateClustersFile(storageDir, (clusters) => {
151
161
  const existing = clusters[clusterId] || { id: clusterId, createdAt: Date.now() };
@@ -215,6 +225,7 @@ module.exports = {
215
225
  isProcessAlive,
216
226
  markDetachedSetupFailed,
217
227
  registerDetachedSetupCluster,
228
+ removeDetachedSetupCluster,
218
229
  resolveWaitTimeoutMs,
219
230
  waitForClusterRegistration,
220
231
  };
@@ -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 clusterFile = path.join(homeDir, '.zeroshot', 'clusters.json');
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
- if (fs.existsSync(clusterFile)) {
28
- try {
29
- const clusters = JSON.parse(fs.readFileSync(clusterFile, 'utf8'));
30
- if (clusters[id]) {
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
+ };
@@ -0,0 +1,34 @@
1
+ const { resolveRunPlan } = require('./run-plan');
2
+
3
+ // The run-mode label is a VIEW of the canonical plan, never an independent
4
+ // cascade. Deriving it from the plan is what keeps the user-facing label and the
5
+ // actual isolation/delivery/autoMerge behavior from drifting apart. Callers that
6
+ // already hold a plan (e.g. the effective plan with env/settings folded in) use
7
+ // runModeFromPlan directly so the label reflects the SAME plan as behavior.
8
+ function runModeFromPlan({ isolation, delivery }) {
9
+ const dockerSuffix = isolation === 'docker' ? '+docker' : '';
10
+ if (delivery === 'ship') return `ship${dockerSuffix}`;
11
+ if (delivery === 'pr') return `pr${dockerSuffix}`;
12
+ if (isolation === 'docker') return 'docker';
13
+ if (isolation === 'worktree') return 'worktree';
14
+ return null;
15
+ }
16
+
17
+ function resolveRunMode(options) {
18
+ return runModeFromPlan(resolveRunPlan(options));
19
+ }
20
+
21
+ const RUN_MODE_LABELS = {
22
+ ship: 'ship (worktree + PR + auto-merge)',
23
+ 'ship+docker': 'ship (docker + PR + auto-merge)',
24
+ pr: 'pr (worktree + PR)',
25
+ 'pr+docker': 'pr (docker + PR)',
26
+ docker: 'docker (isolated container)',
27
+ worktree: 'worktree (isolated branch)',
28
+ };
29
+
30
+ function describeRunMode(mode) {
31
+ return RUN_MODE_LABELS[mode] || 'local (no isolation)';
32
+ }
33
+
34
+ module.exports = { resolveRunMode, runModeFromPlan, describeRunMode };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The single canonical run-mode resolver.
3
+ *
4
+ * Every consumer (orchestrator, daemon startup, CLI label) derives isolation,
5
+ * delivery, and autoMerge from THIS function and nowhere else. The raw
6
+ * worktree/docker/pr/ship/autoMerge booleans are inputs; the frozen plan is the
7
+ * one truth. Deriving autoMerge separately (the old `Boolean(ship)` scatter) is
8
+ * what let `--pr` merge and let the run-mode label drift from behavior.
9
+ */
10
+
11
+ function resolveIsolation(options) {
12
+ if (options.docker) return 'docker';
13
+ if (options.worktree || options.pr || options.ship) return 'worktree';
14
+ return 'none';
15
+ }
16
+
17
+ function resolveDelivery(options) {
18
+ // Explicit autoMerge (e.g. a future `--auto-merge` flag) is ship-equivalent:
19
+ // "merge it" implies the ship delivery. This is the ONLY place that intent is
20
+ // interpreted, so it can never be silently overwritten downstream.
21
+ if (options.ship || options.autoMerge === true) return 'ship';
22
+ if (options.pr) return 'pr';
23
+ return 'none';
24
+ }
25
+
26
+ function resolveRunPlan(options = {}) {
27
+ const isolation = resolveIsolation(options);
28
+ const delivery = resolveDelivery(options);
29
+ return Object.freeze({ isolation, delivery, autoMerge: delivery === 'ship' });
30
+ }
31
+
32
+ module.exports = { resolveRunPlan };