@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
@@ -3,7 +3,10 @@ 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 { runModeFromPlan } = require('./run-mode');
9
+ const { resolveRunPlan } = require('./run-plan');
7
10
 
8
11
  const PACKAGE_ROOT = path.resolve(__dirname, '..');
9
12
 
@@ -96,35 +99,44 @@ function buildFileInput(file) {
96
99
  return { file };
97
100
  }
98
101
 
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);
102
+ function detectRunInput(inputArg, settings = {}, forceProvider = null) {
109
103
  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
104
  if (isMarkdownFile) {
123
105
  return buildFileInput(inputArg);
124
106
  }
107
+
108
+ const ProviderClass = detectProvider(inputArg, settings, forceProvider);
109
+ if (ProviderClass) {
110
+ return buildIssueInput(inputArg);
111
+ }
112
+
125
113
  return buildTextInput(inputArg);
126
114
  }
127
115
 
116
+ const STDIN_MARKER = '-';
117
+
118
+ function isStdinInput(inputArg) {
119
+ return inputArg === STDIN_MARKER;
120
+ }
121
+
122
+ async function readStdinText(stream = process.stdin) {
123
+ const chunks = [];
124
+ for await (const chunk of stream) {
125
+ chunks.push(chunk);
126
+ }
127
+ return Buffer.concat(chunks.map((chunk) => (Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))))
128
+ .toString('utf8')
129
+ .trim();
130
+ }
131
+
132
+ function encodeStdinEnv(text) {
133
+ return Buffer.from(text, 'utf8').toString('base64');
134
+ }
135
+
136
+ function decodeStdinEnv(value) {
137
+ return Buffer.from(value, 'base64').toString('utf8');
138
+ }
139
+
128
140
  function resolveProviderOverride(options = {}) {
129
141
  const override = options.provider || options.envProvider || process.env.ZEROSHOT_PROVIDER;
130
142
  if (!override || (typeof override === 'string' && !override.trim())) {
@@ -187,8 +199,20 @@ function mergeRunOptions(options) {
187
199
  return envRunOptions ? { ...envRunOptions, ...options } : options;
188
200
  }
189
201
 
190
- function resolveIsolation(mergedOptions, settings) {
191
- return mergedOptions.docker || process.env.ZEROSHOT_DOCKER === '1' || settings.defaultDocker;
202
+ // The single producer of the run plan for a cluster start: fold env + settings
203
+ // into the flags, then resolve the canonical isolation/delivery/autoMerge plan.
204
+ // buildStartOptions reads EVERY mode field off this one plan — no field
205
+ // (isolation, worktree, autoPr, autoMerge, runMode) is derived independently.
206
+ function resolveEffectiveRunPlan(mergedOptions, settings) {
207
+ return resolveRunPlan({
208
+ ...mergedOptions,
209
+ docker: anyTruthy(
210
+ mergedOptions.docker,
211
+ process.env.ZEROSHOT_DOCKER === '1',
212
+ settings.defaultDocker
213
+ ),
214
+ worktree: anyTruthy(mergedOptions.worktree, process.env.ZEROSHOT_WORKTREE === '1'),
215
+ });
192
216
  }
193
217
 
194
218
  function resolveMergeQueue(mergedOptions) {
@@ -227,14 +251,15 @@ function buildStartOptions({
227
251
  forceProvider,
228
252
  }) {
229
253
  const mergedOptions = mergeRunOptions(options);
254
+ const plan = resolveEffectiveRunPlan(mergedOptions, settings);
230
255
  return {
231
256
  clusterId,
232
257
  cwd: resolveTargetCwd(),
233
- isolation: resolveIsolation(mergedOptions, settings),
258
+ isolation: plan.isolation === 'docker',
234
259
  isolationImage: firstTruthy(mergedOptions.dockerImage, process.env.ZEROSHOT_DOCKER_IMAGE),
235
- worktree: anyTruthy(mergedOptions.worktree, process.env.ZEROSHOT_WORKTREE === '1'),
236
- autoPr: anyTruthy(mergedOptions.pr, process.env.ZEROSHOT_PR === '1'),
237
- autoMerge: process.env.ZEROSHOT_MERGE === '1',
260
+ worktree: plan.isolation === 'worktree',
261
+ autoPr: plan.delivery !== 'none',
262
+ autoMerge: plan.autoMerge,
238
263
  autoPush: process.env.ZEROSHOT_PUSH === '1',
239
264
  modelOverride: optionalValue(modelOverride),
240
265
  providerOverride: optionalValue(providerOverride),
@@ -246,6 +271,7 @@ function buildStartOptions({
246
271
  mergeQueue: resolveMergeQueue(mergedOptions),
247
272
  closeIssue: resolveCloseIssue(mergedOptions),
248
273
  ship: mergedOptions.ship,
274
+ runMode: runModeFromPlan(plan),
249
275
  requiredQualityGates: mergedOptions.requiredQualityGates,
250
276
  settings,
251
277
  };
@@ -310,6 +336,10 @@ module.exports = {
310
336
  buildIssueInput,
311
337
  buildFileInput,
312
338
  detectRunInput,
339
+ isStdinInput,
340
+ readStdinText,
341
+ encodeStdinEnv,
342
+ decodeStdinEnv,
313
343
  resolveProviderOverride,
314
344
  resolveConfigPath,
315
345
  loadClusterConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-open-engine/zeroshot",
3
- "version": "6.2.0",
3
+ "version": "6.4.0",
4
4
  "description": "Multi-agent orchestration engine for Claude, Codex, and Gemini",
5
5
  "main": "src/orchestrator.js",
6
6
  "bin": {
@@ -15,11 +15,13 @@
15
15
  "pretest": "npm run build:agent-cli-provider",
16
16
  "test": "node tests/run-tests.js",
17
17
  "test:unit": "node tests/run-tests.js",
18
- "test:slow": "mocha 'tests/integration/**/*.test.js' --timeout 180000",
19
- "test:all": "npm run test && npm run test:slow",
18
+ "test:e2e": "mocha 'tests/e2e/**/*.test.js' --timeout 120000",
19
+ "test:e2e:docker": "bash tests/integration/e2e-isolation-and-auto.test.sh",
20
+ "test:slow": "mocha 'tests/integration/**/*.test.js' 'tests/providers/**/*.test.js' --timeout 180000",
21
+ "test:all": "npm run test && npm run test:e2e && npm run test:slow",
20
22
  "test:coverage": "c8 npm run test:unit",
21
23
  "test:coverage:report": "c8 --reporter=html npm run test:unit && echo 'Coverage report generated at coverage/index.html'",
22
- "postinstall": "node scripts/fix-node-pty-permissions.js",
24
+ "postinstall": "node scripts/fix-node-pty-permissions.js && node scripts/check-path.js",
23
25
  "start": "node cli/index.js",
24
26
  "typecheck": "tsc --noEmit",
25
27
  "typecheck:agent-cli-provider": "tsc --project tsconfig.agent-cli-provider.json",
@@ -94,7 +96,7 @@
94
96
  "cli/",
95
97
  "task-lib/",
96
98
  "cluster-templates/",
97
- "hooks/",
99
+ "cluster-hooks/",
98
100
  "docker/",
99
101
  "scripts/",
100
102
  "README.md",
@@ -110,7 +112,6 @@
110
112
  "node-pty": "^1.1.0",
111
113
  "omelette": "^0.4.17",
112
114
  "pidusage": "^4.0.1",
113
- "puppeteer": "^24.34.0",
114
115
  "proper-lockfile": "^4.1.2"
115
116
  },
116
117
  "devDependencies": {
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Postinstall check: warn if the npm global bin dir isn't on PATH.
4
+ *
5
+ * Runs after `npm install -g` regardless of whether the user can invoke
6
+ * `zeroshot` afterward (this is the only guaranteed-execution point in
7
+ * that failure scenario).
8
+ */
9
+
10
+ const { checkBinDirOnPath, printPathWarning } = require('../lib/path-check');
11
+
12
+ try {
13
+ const { onPath, binDir } = checkBinDirOnPath();
14
+ if (!onPath && binDir) {
15
+ printPathWarning(binDir);
16
+ }
17
+ } catch (err) {
18
+ console.warn(`[postinstall] Warning: PATH check failed: ${err.message}`);
19
+ }
@@ -8,11 +8,15 @@
8
8
  */
9
9
 
10
10
  const path = require('path');
11
- const { validateTemplates } = require('../src/template-validation');
11
+ const { validateTemplates, formatValidationReport } = require('../src/template-validation');
12
12
 
13
13
  const TEMPLATES_DIR = path.join(__dirname, '../cluster-templates');
14
14
 
15
15
  function parseArgs(argv) {
16
+ const changedArg = argv.find((arg) => arg.startsWith('--changed='));
17
+ const changedFiles = changedArg
18
+ ? new Set(changedArg.split('=').slice(1).join('=').split(',').filter(Boolean))
19
+ : null;
16
20
  const simModeArg = argv.find((arg) => arg.startsWith('--sim='));
17
21
  const simMode = simModeArg ? simModeArg.split('=')[1] : process.env.ZEROSHOT_TEMPLATE_SIM;
18
22
  const randomScopeArg = argv.find((arg) => arg.startsWith('--random-scope='));
@@ -49,13 +53,16 @@ function parseArgs(argv) {
49
53
  maxSteps: Number.isFinite(sampleSteps) && sampleSteps > 0 ? sampleSteps : undefined,
50
54
  maxScenarioMs: Number.isFinite(sampleMs) && sampleMs > 0 ? sampleMs : undefined,
51
55
  },
56
+ changedFiles,
52
57
  };
53
58
  }
54
59
 
55
60
  async function main() {
56
61
  console.log('Validating cluster templates...\n');
57
62
 
58
- const { deep, randomSampling, randomScope, randomOptions } = parseArgs(process.argv.slice(2));
63
+ const { deep, randomSampling, randomScope, randomOptions, changedFiles } = parseArgs(
64
+ process.argv.slice(2)
65
+ );
59
66
  const report = await validateTemplates({
60
67
  templatesDir: TEMPLATES_DIR,
61
68
  deep,
@@ -64,33 +71,8 @@ async function main() {
64
71
  randomOptions,
65
72
  });
66
73
 
67
- let hasErrors = false;
68
-
69
- for (const { filePath, result } of report.results) {
70
- const relativePath = path.relative(process.cwd(), filePath);
71
-
72
- if (!result.valid) {
73
- hasErrors = true;
74
- console.error(`\nāŒ ${relativePath}`);
75
- for (const error of result.errors) {
76
- console.error(` ERROR: ${error}`);
77
- }
78
- continue;
79
- }
80
-
81
- if (result.warnings.length > 0) {
82
- console.warn(`\nāš ļø ${relativePath}`);
83
- for (const warning of result.warnings) {
84
- console.warn(` WARN: ${warning}`);
85
- }
86
- continue;
87
- }
88
-
89
- console.log(`āœ“ ${relativePath}`);
90
- }
91
-
92
- console.log(`\n${'='.repeat(60)}`);
93
- console.log(`Validated: ${report.validated} templates, Skipped: ${report.skipped} files`);
74
+ const { lines, hasErrors } = formatValidationReport(report, { changedFiles });
75
+ console.log(lines.join('\n'));
94
76
 
95
77
  if (hasErrors) {
96
78
  console.error('\nāŒ VALIDATION FAILED - Fix errors above before merging\n');
@@ -141,7 +141,7 @@ async function executeHook(params) {
141
141
  }
142
142
 
143
143
  if (hook.action === 'verify_pull_request') {
144
- await verifyPullRequest({ result, agent });
144
+ await verifyPullRequest({ result, agent, autoMerge: hook.config?.autoMerge });
145
145
  return;
146
146
  }
147
147
 
@@ -200,6 +200,8 @@ function start(agent) {
200
200
  * @returns {Promise<void>}
201
201
  */
202
202
  async function stop(agent) {
203
+ stopLivenessCheck(agent);
204
+
203
205
  if (!agent.running) {
204
206
  return;
205
207
  }
@@ -118,7 +118,7 @@ function buildClaudeEnv(modelSpec, options = {}) {
118
118
  env.ANTHROPIC_MODEL = modelSpec.model;
119
119
  }
120
120
 
121
- // Activate AskUserQuestion blocking hook (see hooks/block-ask-user-question.py)
121
+ // Activate AskUserQuestion blocking hook (see cluster-hooks/block-ask-user-question.py)
122
122
  env.ZEROSHOT_BLOCK_ASK_USER = '1';
123
123
 
124
124
  return env;
@@ -446,7 +446,7 @@ function ensureAskUserQuestionHook(targetClaudeDir = null) {
446
446
  }
447
447
 
448
448
  // Copy hook script if not present or outdated
449
- const hookScriptSrc = path.join(__dirname, '..', '..', 'hooks', hookScriptName);
449
+ const hookScriptSrc = path.join(__dirname, '..', '..', 'cluster-hooks', hookScriptName);
450
450
  if (fs.existsSync(hookScriptSrc)) {
451
451
  // Always copy to ensure latest version
452
452
  fs.copyFileSync(hookScriptSrc, hookScriptDst);
@@ -524,7 +524,7 @@ function ensureDangerousGitHook(targetClaudeDir = null) {
524
524
  }
525
525
 
526
526
  // Copy hook script if not present or outdated
527
- const hookScriptSrc = path.join(__dirname, '..', '..', 'hooks', hookScriptName);
527
+ const hookScriptSrc = path.join(__dirname, '..', '..', 'cluster-hooks', hookScriptName);
528
528
  if (fs.existsSync(hookScriptSrc)) {
529
529
  // Always copy to ensure latest version
530
530
  fs.copyFileSync(hookScriptSrc, hookScriptDst);
@@ -834,6 +834,7 @@ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv }) {
834
834
  cwd,
835
835
  stdio: ['ignore', 'pipe', 'pipe'],
836
836
  env: spawnEnv,
837
+ windowsHide: true,
837
838
  });
838
839
 
839
840
  // NOTE: Don't emit PROCESS_SPAWNED here - proc.pid is a wrapper that exits immediately.
@@ -579,7 +579,9 @@ function shouldSkipVerification(adapter) {
579
579
  return skipVars.some((name) => process.env[name] === '1');
580
580
  }
581
581
 
582
- async function verifyPullRequest({ result, agent }) {
582
+ async function verifyPullRequest({ result, agent, autoMerge }) {
583
+ // Default to true (merge required) so existing callers/tests and --ship semantics stay fail-closed.
584
+ const requireMerge = autoMerge !== false;
583
585
  const platform = resolveVerificationPlatform(agent);
584
586
  const adapter = getVerificationAdapter(platform);
585
587
  const providerName =
@@ -622,6 +624,30 @@ async function verifyPullRequest({ result, agent }) {
622
624
  );
623
625
  }
624
626
 
627
+ if (!requireMerge) {
628
+ // --pr mode: the PR/MR was created and verified to exist. It's left OPEN for human
629
+ // review - do NOT poll for merge or treat an unmerged PR as a failure.
630
+ if (adapter.isMerged(prData)) {
631
+ throw new Error(
632
+ `VERIFICATION FAILED: ${adapter.itemName} #${prData.number} is already merged, ` +
633
+ 'but this run was started in review mode (autoMerge=false).'
634
+ );
635
+ }
636
+
637
+ agent._log(
638
+ `āœ… VERIFICATION PASSED: ${adapter.itemName} #${prData.number} created (open for human review)`
639
+ );
640
+ publishClusterComplete(agent, {
641
+ ...buildVerificationPayload({
642
+ platform,
643
+ prData,
644
+ reason: 'git-pusher-complete-verified',
645
+ }),
646
+ merged: adapter.isMerged(prData),
647
+ });
648
+ return;
649
+ }
650
+
625
651
  if (!adapter.isMerged(prData)) {
626
652
  prData = await pollForMerge({ adapter, prData, agent, cwd: agent.workingDirectory });
627
653
  }
@@ -341,7 +341,13 @@ function resolveGitHubConfig(options = {}) {
341
341
  (parseBool(repoGithub.closeIssue) === true ? 'always' : null) ||
342
342
  'never';
343
343
 
344
- return { prBase, useMergeQueue, closeIssueMode };
344
+ // --ship (or explicit autoMerge) merges automatically; --pr alone stops after PR creation
345
+ // for human review. Repo settings can opt in to auto-merge when the caller hasn't decided.
346
+ const autoMerge =
347
+ options.autoMerge === true ||
348
+ (options.autoMerge !== false && parseBool(repoGithub.autoMerge) === true);
349
+
350
+ return { prBase, useMergeQueue, closeIssueMode, autoMerge };
345
351
  }
346
352
 
347
353
  /**
@@ -352,7 +358,7 @@ function resolveGitHubConfig(options = {}) {
352
358
  * @returns {Object|null} Platform configuration or null if unsupported
353
359
  */
354
360
  function getPlatformConfig(platform, config = {}) {
355
- const { prBase, useMergeQueue, closeIssueMode } = config;
361
+ const { prBase, useMergeQueue, closeIssueMode, autoMerge } = config;
356
362
 
357
363
  const PLATFORM_CONFIGS = {
358
364
  github: {
@@ -373,6 +379,7 @@ for i in $(seq 1 90); do if timeout 30 gh pr view --json mergedAt --jq .mergedAt
373
379
  rebaseBranch: prBase || 'main',
374
380
  usesMergeQueue: useMergeQueue,
375
381
  closeIssueMode: closeIssueMode || 'never',
382
+ autoMerge: Boolean(autoMerge),
376
383
  },
377
384
  gitlab: {
378
385
  prName: 'MR',
@@ -384,6 +391,7 @@ for i in $(seq 1 90); do if timeout 30 gh pr view --json mergedAt --jq .mergedAt
384
391
  prUrlExample: 'https://gitlab.com/owner/repo/-/merge_requests/123',
385
392
  outputFields: { urlField: 'mr_url', numberField: 'mr_number', mergedField: 'merged' },
386
393
  closeIssueMode: closeIssueMode || 'never',
394
+ autoMerge: Boolean(autoMerge),
387
395
  },
388
396
  'azure-devops': {
389
397
  prName: 'PR',
@@ -402,6 +410,7 @@ for i in $(seq 1 90); do if timeout 30 gh pr view --json mergedAt --jq .mergedAt
402
410
  // Azure requires extracting PR ID from create output
403
411
  requiresPrIdExtraction: true,
404
412
  closeIssueMode: closeIssueMode || 'never',
413
+ autoMerge: Boolean(autoMerge),
405
414
  },
406
415
  };
407
416
 
@@ -414,6 +423,107 @@ for i in $(seq 1 90); do if timeout 30 gh pr view --json mergedAt --jq .mergedAt
414
423
  */
415
424
  const SUPPORTED_PLATFORMS = ['github', 'gitlab', 'azure-devops'];
416
425
 
426
+ /**
427
+ * Generate the review-mode prompt (--pr without --ship): create the PR/MR and STOP.
428
+ * No merge step, no issue-closing - the PR is left open for human review.
429
+ * @param {Object} config - Platform configuration from PLATFORM_CONFIGS
430
+ * @returns {string} The complete review-mode prompt
431
+ */
432
+ function generateReviewModePrompt(config) {
433
+ const { prName, prNameLower, createCmd, prUrlExample, outputFields, requiresPrIdExtraction } =
434
+ config;
435
+
436
+ return `CRITICAL: ALL VALIDATORS APPROVED. YOU ARE A TRANSPORT-ONLY GIT PUSHER.
437
+
438
+ Your job is to preserve validator ownership: stage, commit, push, and create the ${prName} for HUMAN REVIEW.
439
+
440
+ Do NOT edit source files, tests, configs, generated artifacts, or lockfiles.
441
+ Do NOT inspect CI logs to debug product code.
442
+ Do NOT resolve merge conflicts or rebase conflicts.
443
+ Do NOT run implementation/debugging workflows after validators hand off.
444
+ Do NOT merge the ${prName} - it is left OPEN for human review.
445
+ Do NOT close the linked issue - it stays open until a human merges the ${prName}.
446
+
447
+ Allowed after validation:
448
+ - git add/status/commit/push
449
+ - ${createCmd.split(' ').slice(0, 3).join(' ')}
450
+ - status-only commands such as ${prName === 'PR' ? 'gh pr view/gh pr checks' : 'the platform PR/MR status command'}
451
+
452
+ If commit hooks, push, ${prName} creation, or conflict handling requires code changes, STOP and report the blocked state in JSON. The implementation and validator agents must fix code and rerun quality gates.
453
+
454
+ ## MANDATORY STEPS - EXECUTE EACH ONE IN ORDER - DO NOT SKIP ANY STEP
455
+
456
+ ### STEP 1: Stage ALL changes (MANDATORY)
457
+ \`\`\`bash
458
+ git add -A
459
+ \`\`\`
460
+ Run this command. Do not skip it. If commit fails because hooks/checks fail, do not edit files. Output blocked JSON with the failure summary.
461
+
462
+ ### STEP 2: Check what's staged
463
+ \`\`\`bash
464
+ git status
465
+ \`\`\`
466
+ Run this. If nothing to commit, output JSON with ${outputFields.urlField}: null and stop.
467
+
468
+ ### STEP 3: Commit the changes (MANDATORY if there are changes)
469
+ \`\`\`bash
470
+ git commit -m "feat: implement #{{issue_number}} - {{issue_title}}"
471
+ \`\`\`
472
+ Run this command. Do not skip it.
473
+
474
+ ### STEP 4: Push to origin (MANDATORY)
475
+ \`\`\`bash
476
+ git push -u origin HEAD
477
+ \`\`\`
478
+ Run this. If it fails, do not edit files, rebase, or resolve conflicts. Output blocked JSON with the failure summary.
479
+
480
+ āš ļø AFTER PUSH YOU ARE NOT DONE! CONTINUE TO STEP 5! āš ļø
481
+
482
+ ### STEP 5: CREATE THE ${prName.toUpperCase()} (MANDATORY - YOU MUST RUN THIS COMMAND)
483
+ \`\`\`bash
484
+ ${createCmd}
485
+ \`\`\`
486
+ 🚨 YOU MUST RUN \`${createCmd.split(' ').slice(0, 3).join(' ')}\`! Outputting a link is NOT creating a ${prName}! 🚨
487
+ The push output shows a "Create a ${prNameLower}" link - IGNORE IT.
488
+ You MUST run the \`${createCmd.split(' ').slice(0, 3).join(' ')}\` command above.${requiresPrIdExtraction ? '' : ` Save the actual ${prName} URL from the output.`}
489
+
490
+ āš ļø AFTER THE ${prName} IS CREATED, YOU ARE DONE. DO NOT MERGE. DO NOT CLOSE THE ISSUE. āš ļø
491
+
492
+ ## CRITICAL RULES
493
+ - Execute EVERY step in order (1, 2, 3, 4, 5)
494
+ - Do NOT skip git add -A
495
+ - Do NOT skip git commit
496
+ - Do NOT skip ${createCmd.split(' ').slice(0, 3).join(' ')} - THE TASK IS NOT DONE UNTIL ${prName} EXISTS
497
+ - Do NOT merge the ${prName} - this run is for human review only
498
+ - Do NOT close the issue - it stays open until a human merges the ${prName}
499
+ - Do NOT edit files after validator handoff
500
+ - Do NOT debug product failures after validator handoff
501
+ - If push or ${prName} creation fails, report it instead of fixing code
502
+ - Output JSON as soon as the ${prName} is created (OPEN, unmerged), or a non-code transport failure blocks progress
503
+ - A link from git push is NOT a ${prName} - you must run ${createCmd.split(' ').slice(0, 3).join(' ')}
504
+
505
+ ## Final Output
506
+ ONLY after the ${prName} is CREATED (left OPEN for review), output:
507
+ \`\`\`json
508
+ {"${outputFields.urlField}": "${prUrlExample}", "${outputFields.numberField}": 123, "merged": false}
509
+ \`\`\`
510
+
511
+ If truly no changes exist, output:
512
+ \`\`\`json
513
+ {"${outputFields.urlField}": null, "${outputFields.numberField}": null, "merged": false}
514
+ \`\`\`
515
+
516
+ If blocked after creating a ${prName}, output:
517
+ \`\`\`json
518
+ {"${outputFields.urlField}": "${prUrlExample}", "${outputFields.numberField}": 123, "merged": false, "blocked": true, "blocked_reason": "ci_failed: test job failed"}
519
+ \`\`\`
520
+
521
+ If blocked before creating a ${prName}, output:
522
+ \`\`\`json
523
+ {"${outputFields.urlField}": null, "${outputFields.numberField}": null, "merged": false, "blocked": true, "blocked_reason": "commit_failed: pre-commit hook failed"}
524
+ \`\`\``;
525
+ }
526
+
417
527
  /**
418
528
  * Generate the prompt for a specific platform
419
529
  * @param {Object} config - Platform configuration from PLATFORM_CONFIGS
@@ -432,8 +542,13 @@ function generatePrompt(config) {
432
542
  rebaseBranch,
433
543
  usesMergeQueue,
434
544
  closeIssueMode,
545
+ autoMerge,
435
546
  } = config;
436
547
 
548
+ if (!autoMerge) {
549
+ return generateReviewModePrompt(config);
550
+ }
551
+
437
552
  // Azure-specific instructions for PR ID extraction
438
553
  const azurePrIdNote = requiresPrIdExtraction
439
554
  ? `\n\nšŸ’” IMPORTANT: The output will contain the PR ID. You MUST extract it for the next step.
@@ -614,6 +729,7 @@ If blocked before creating a ${prName}, output:
614
729
  * @param {boolean} [options.mergeQueue] - Use GitHub merge queue
615
730
  * @param {string} [options.closeIssue] - When to close issue: auto|always|never
616
731
  * @param {Array} [options.requiredQualityGates] - Required handoff quality gates
732
+ * @param {boolean} [options.autoMerge] - Merge the PR (--ship). False stops after PR creation (--pr).
617
733
  * @returns {Object} Agent configuration object
618
734
  * @throws {Error} If platform is not supported
619
735
  */
@@ -647,8 +763,9 @@ function generateGitPusherAgent(platform, options = {}) {
647
763
  hooks: {
648
764
  onComplete: {
649
765
  action: 'verify_pull_request',
650
- // No config needed - verification reads from result.structured_output
651
- // and publishes CLUSTER_COMPLETE only if verification passes
766
+ // Verification reads PR data from result.structured_output; autoMerge controls
767
+ // whether an OPEN unmerged PR counts as success (--pr) or must be merged (--ship).
768
+ config: { autoMerge: Boolean(platformConfig.autoMerge) },
652
769
  },
653
770
  },
654
771
  output: {
@@ -11,10 +11,10 @@ const path = require('path');
11
11
  const fs = require('fs');
12
12
  const os = require('os');
13
13
  const net = require('net');
14
+ const { readClustersFileSync } = require('../../lib/clusters-registry');
14
15
 
15
16
  const ZEROSHOT_DIR = path.join(os.homedir(), '.zeroshot');
16
17
  const SOCKET_DIR = path.join(ZEROSHOT_DIR, 'sockets');
17
- const CLUSTERS_FILE = path.join(ZEROSHOT_DIR, 'clusters.json');
18
18
 
19
19
  /**
20
20
  * Check if an ID is a known cluster by looking up clusters.json
@@ -24,8 +24,7 @@ const CLUSTERS_FILE = path.join(ZEROSHOT_DIR, 'clusters.json');
24
24
  */
25
25
  function isKnownCluster(id) {
26
26
  try {
27
- if (!fs.existsSync(CLUSTERS_FILE)) return false;
28
- const clusters = JSON.parse(fs.readFileSync(CLUSTERS_FILE, 'utf8'));
27
+ const clusters = readClustersFileSync(ZEROSHOT_DIR);
29
28
  return id in clusters;
30
29
  } catch {
31
30
  return false;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Provisions Claude Code credentials into an isolated CLAUDE_CONFIG_DIR.
3
+ *
4
+ * Claude Code only reads the macOS Keychain when using the DEFAULT config dir.
5
+ * Isolated agent runs (--worktree, --docker) use a custom CLAUDE_CONFIG_DIR, so
6
+ * a Keychain-only (OAuth subscription) login must be materialized to a file or
7
+ * every isolated agent fails auth even though the host is logged in.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const { execSync } = require('./lib/safe-exec');
14
+
15
+ const CREDENTIALS_BASENAME = '.credentials.json';
16
+ const KEYCHAIN_SERVICE = 'Claude Code-credentials';
17
+
18
+ /**
19
+ * Read the Claude Code OAuth credentials JSON from the macOS Keychain.
20
+ * @returns {string|null} Raw credentials JSON, or null if unavailable/invalid/non-darwin.
21
+ */
22
+ function readKeychainCredentials() {
23
+ if (os.platform() !== 'darwin') {
24
+ return null;
25
+ }
26
+
27
+ try {
28
+ const output = execSync(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -w`, {
29
+ encoding: 'utf8',
30
+ stdio: ['ignore', 'pipe', 'ignore'],
31
+ timeout: 2000,
32
+ });
33
+ const trimmed = output.trim();
34
+ if (!trimmed) {
35
+ return null;
36
+ }
37
+ JSON.parse(trimmed); // reject garbage output that isn't real credentials
38
+ return trimmed;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Copy an existing credentials file into an isolated config dir, or materialize
46
+ * one from the macOS Keychain when no source file exists. Resynced on every call
47
+ * so an expired-then-refreshed OAuth token in the Keychain is picked up per run.
48
+ * @param {{ sourceDir: string, destDir: string }} options
49
+ * @returns {boolean} true if a credentials file was written into destDir
50
+ */
51
+ function provisionClaudeCredentials({ sourceDir, destDir }) {
52
+ fs.mkdirSync(destDir, { recursive: true });
53
+
54
+ const destPath = path.join(destDir, CREDENTIALS_BASENAME);
55
+ const sourcePath = path.join(sourceDir, CREDENTIALS_BASENAME);
56
+
57
+ if (fs.existsSync(sourcePath)) {
58
+ fs.copyFileSync(sourcePath, destPath);
59
+ fs.chmodSync(destPath, 0o600);
60
+ return true;
61
+ }
62
+
63
+ const keychainCredentials = readKeychainCredentials();
64
+ if (keychainCredentials) {
65
+ fs.writeFileSync(destPath, keychainCredentials, { mode: 0o600 });
66
+ return true;
67
+ }
68
+
69
+ return false;
70
+ }
71
+
72
+ module.exports = {
73
+ readKeychainCredentials,
74
+ provisionClaudeCredentials,
75
+ };
@@ -287,6 +287,7 @@ class ClaudeTaskRunner extends TaskRunner {
287
287
  cwd,
288
288
  stdio: ['ignore', 'pipe', 'pipe'],
289
289
  env: spawnEnv,
290
+ windowsHide: true,
290
291
  });
291
292
 
292
293
  let stdout = '';