@the-open-engine/zeroshot 6.1.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +141 -474
  2. package/cli/commands/cmdproof.js +180 -6
  3. package/cli/event-copy.js +12 -0
  4. package/cli/index.js +311 -96
  5. package/cli/message-formatters-normal.js +14 -2
  6. package/cli/message-formatters-watch.js +9 -2
  7. package/cluster-hooks/block-ask-user-question.py +53 -0
  8. package/cluster-hooks/block-dangerous-git.py +145 -0
  9. package/lib/clusters-registry.js +48 -0
  10. package/lib/compose-utils.js +101 -0
  11. package/lib/detached-startup.js +9 -0
  12. package/lib/id-detector.js +8 -9
  13. package/lib/path-check.js +63 -0
  14. package/lib/run-mode.js +22 -0
  15. package/lib/start-cluster.js +39 -23
  16. package/package.json +3 -4
  17. package/scripts/check-path.js +19 -0
  18. package/scripts/validate-templates.js +11 -29
  19. package/src/agent/agent-hook-executor.js +1 -1
  20. package/src/agent/agent-task-executor.js +4 -3
  21. package/src/agent/pr-verification.js +27 -1
  22. package/src/agents/git-pusher-template.js +121 -4
  23. package/src/attach/socket-discovery.js +2 -3
  24. package/src/claude-credentials.js +75 -0
  25. package/src/claude-task-runner.js +1 -0
  26. package/src/isolation-manager.js +12 -9
  27. package/src/issue-providers/README.md +45 -15
  28. package/src/issue-providers/index.js +2 -0
  29. package/src/issue-providers/jira-provider.js +8 -1
  30. package/src/issue-providers/linear-provider.js +405 -0
  31. package/src/ledger.js +42 -3
  32. package/src/lib/gc.js +2 -3
  33. package/src/message-bus.js +10 -0
  34. package/src/orchestrator.js +264 -144
  35. package/src/preflight.js +12 -16
  36. package/src/providers/base-provider.js +7 -6
  37. package/src/status-footer.js +13 -1
  38. package/src/template-validation/index.js +3 -0
  39. package/src/template-validation/report-formatter.js +55 -0
  40. package/src/worktree-claude-config.js +2 -13
  41. package/task-lib/runner.js +1 -0
  42. package/task-lib/watcher.js +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-open-engine/zeroshot",
3
- "version": "6.1.0",
3
+ "version": "6.3.0",
4
4
  "description": "Multi-agent orchestration engine for Claude, Codex, and Gemini",
5
5
  "main": "src/orchestrator.js",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "test:all": "npm run test && npm run test:slow",
20
20
  "test:coverage": "c8 npm run test:unit",
21
21
  "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",
22
+ "postinstall": "node scripts/fix-node-pty-permissions.js && node scripts/check-path.js",
23
23
  "start": "node cli/index.js",
24
24
  "typecheck": "tsc --noEmit",
25
25
  "typecheck:agent-cli-provider": "tsc --project tsconfig.agent-cli-provider.json",
@@ -94,7 +94,7 @@
94
94
  "cli/",
95
95
  "task-lib/",
96
96
  "cluster-templates/",
97
- "hooks/",
97
+ "cluster-hooks/",
98
98
  "docker/",
99
99
  "scripts/",
100
100
  "README.md",
@@ -110,7 +110,6 @@
110
110
  "node-pty": "^1.1.0",
111
111
  "omelette": "^0.4.17",
112
112
  "pidusage": "^4.0.1",
113
- "puppeteer": "^24.34.0",
114
113
  "proper-lockfile": "^4.1.2"
115
114
  },
116
115
  "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
 
@@ -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 = '';
@@ -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 only credentials file (essential for auth)
1040
- const credentialsFile = path.join(sourceDir, '.credentials.json');
1041
- if (fs.existsSync(credentialsFile)) {
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
- const composePath = path.join(worktreeInfo.path, 'docker-compose.yml');
1655
- if (fs.existsSync(composePath)) {
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', ['compose', 'down', '--remove-orphans', '--volumes', '--timeout', '10'], {
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, and Azure DevOps.
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 | 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` |
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 = {