@haystackeditor/cli 0.15.31 → 0.17.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 (136) hide show
  1. package/README.md +141 -61
  2. package/dist/assets/hooks/scripts/commit-msg.sh +3 -0
  3. package/dist/assets/hooks/scripts/post-commit.sh +3 -0
  4. package/dist/assets/hooks/scripts/pre-commit.sh +11 -6
  5. package/dist/assets/hooks/scripts/pre-push.sh +3 -0
  6. package/dist/assets/hooks/scripts/prepare-commit-msg.sh +3 -0
  7. package/dist/commands/case-batch-contract.js +694 -0
  8. package/dist/commands/case-batch.js +1011 -0
  9. package/dist/commands/cloud-verifier-identity-census.js +5 -2
  10. package/dist/commands/combination-search-hook.js +139 -0
  11. package/dist/commands/dismiss.js +2 -1
  12. package/dist/commands/hooks.js +66 -7
  13. package/dist/commands/install-session-hooks.js +131 -44
  14. package/dist/commands/policy.js +32 -45
  15. package/dist/commands/precompute-delivery.js +6 -1
  16. package/dist/commands/scaffold-provisional-universe.js +8 -10
  17. package/dist/commands/setup.js +32 -10
  18. package/dist/commands/submit.js +39 -71
  19. package/dist/commands/telemetry.js +17 -2
  20. package/dist/commands/triage.js +2 -1
  21. package/dist/commands/verify-explore.js +1 -0
  22. package/dist/commands/verify-hosted-mcp.js +3 -12
  23. package/dist/commands/verify-hosted-reproducibility.js +49 -547
  24. package/dist/commands/verify-hosted.js +66 -168
  25. package/dist/commands/verify-precompute.js +64 -11
  26. package/dist/commands/verify.js +51 -139
  27. package/dist/index.js +290 -366
  28. package/dist/lazy.js +8 -0
  29. package/dist/schema.js +2 -1
  30. package/dist/tools/detect.js +3 -24
  31. package/dist/triage/astra.js +199 -0
  32. package/dist/triage/prompts.js +145 -179
  33. package/dist/triage/runner.js +104 -300
  34. package/dist/triage/types.js +2 -2
  35. package/dist/types.js +2 -6
  36. package/dist/utils/auth.js +14 -2
  37. package/dist/utils/git.js +60 -29
  38. package/dist/utils/github-api.js +14 -1
  39. package/dist/utils/haystack-api.js +38 -7
  40. package/dist/utils/hooks.js +43 -6
  41. package/dist/utils/prompter.js +25 -10
  42. package/dist/utils/safe-write.js +31 -0
  43. package/dist/utils/secret-paths.js +115 -0
  44. package/dist/utils/secrets.js +0 -1
  45. package/dist/utils/telemetry.js +34 -14
  46. package/dist/utils/update-check.js +151 -0
  47. package/package.json +19 -14
  48. package/schemas/case-batch.v1.json +235 -0
  49. package/schemas/cloud-verifier.v1.json +55 -100
  50. package/schemas/submit.v1.json +4 -2
  51. package/dist/commands/ask.d.ts +0 -14
  52. package/dist/commands/cloud-verifier-behaviors.d.ts +0 -27
  53. package/dist/commands/cloud-verifier-data-store-census.d.ts +0 -47
  54. package/dist/commands/cloud-verifier-data-store-drift.d.ts +0 -42
  55. package/dist/commands/cloud-verifier-identity-census.d.ts +0 -88
  56. package/dist/commands/cloud-verifier-materialization.d.ts +0 -16
  57. package/dist/commands/cloud-verifier-pascal-selector-census.d.ts +0 -29
  58. package/dist/commands/cloud-verifier-python-manifest-selector-census.d.ts +0 -27
  59. package/dist/commands/cloud-verifier-specialized-operational-census.d.ts +0 -51
  60. package/dist/commands/cloud-verifier-universe.d.ts +0 -31
  61. package/dist/commands/config.d.ts +0 -46
  62. package/dist/commands/design-verify.d.ts +0 -33
  63. package/dist/commands/dismiss.d.ts +0 -29
  64. package/dist/commands/hooks.d.ts +0 -13
  65. package/dist/commands/inbox.d.ts +0 -65
  66. package/dist/commands/init.d.ts +0 -10
  67. package/dist/commands/install-session-hooks.d.ts +0 -17
  68. package/dist/commands/login.d.ts +0 -8
  69. package/dist/commands/mcp.d.ts +0 -1
  70. package/dist/commands/policy.d.ts +0 -31
  71. package/dist/commands/pr-status.d.ts +0 -144
  72. package/dist/commands/pr.d.ts +0 -40
  73. package/dist/commands/precompute-delivery-contract.d.ts +0 -68
  74. package/dist/commands/precompute-delivery.d.ts +0 -20
  75. package/dist/commands/prepare-universe-review.d.ts +0 -115
  76. package/dist/commands/production-source-deny-policy.d.ts +0 -15
  77. package/dist/commands/request-review.d.ts +0 -26
  78. package/dist/commands/review.d.ts +0 -25
  79. package/dist/commands/rules.d.ts +0 -4
  80. package/dist/commands/scaffold-provisional-universe.d.ts +0 -468
  81. package/dist/commands/schema-cmd.d.ts +0 -2
  82. package/dist/commands/setup.d.ts +0 -28
  83. package/dist/commands/skills.d.ts +0 -8
  84. package/dist/commands/status.d.ts +0 -4
  85. package/dist/commands/submit.d.ts +0 -30
  86. package/dist/commands/system-map.d.ts +0 -42
  87. package/dist/commands/telemetry.d.ts +0 -53
  88. package/dist/commands/tokens.d.ts +0 -14
  89. package/dist/commands/triage.d.ts +0 -35
  90. package/dist/commands/verify-core.d.ts +0 -449
  91. package/dist/commands/verify-core.js +0 -789
  92. package/dist/commands/verify-explore.d.ts +0 -14
  93. package/dist/commands/verify-history.d.ts +0 -14
  94. package/dist/commands/verify-hosted-mcp.d.ts +0 -12
  95. package/dist/commands/verify-hosted-reproducibility.d.ts +0 -90
  96. package/dist/commands/verify-hosted.d.ts +0 -92
  97. package/dist/commands/verify-mcp.d.ts +0 -8
  98. package/dist/commands/verify-mcp.js +0 -517
  99. package/dist/commands/verify-ops.d.ts +0 -158
  100. package/dist/commands/verify-ops.js +0 -1148
  101. package/dist/commands/verify-precompute.d.ts +0 -30
  102. package/dist/commands/verify-reproducibility.d.ts +0 -85
  103. package/dist/commands/verify-reproducibility.js +0 -494
  104. package/dist/commands/verify-reseal.d.ts +0 -9
  105. package/dist/commands/verify-reseal.js +0 -148
  106. package/dist/commands/verify-sandboxes.d.ts +0 -95
  107. package/dist/commands/verify-sandboxes.js +0 -352
  108. package/dist/commands/verify.d.ts +0 -28
  109. package/dist/commands/webhooks.d.ts +0 -30
  110. package/dist/index.d.ts +0 -22
  111. package/dist/schema.d.ts +0 -28
  112. package/dist/states.d.ts +0 -29
  113. package/dist/tools/detect.d.ts +0 -50
  114. package/dist/triage/prompts.d.ts +0 -24
  115. package/dist/triage/runner.d.ts +0 -34
  116. package/dist/triage/types.d.ts +0 -42
  117. package/dist/types/verify-history.d.ts +0 -38
  118. package/dist/types.d.ts +0 -1684
  119. package/dist/utils/action-output.d.ts +0 -24
  120. package/dist/utils/analysis-api.d.ts +0 -187
  121. package/dist/utils/auth.d.ts +0 -79
  122. package/dist/utils/config.d.ts +0 -24
  123. package/dist/utils/design-verifier-api.d.ts +0 -208
  124. package/dist/utils/design-verifier-history.d.ts +0 -46
  125. package/dist/utils/design-verifier-result.d.ts +0 -74
  126. package/dist/utils/detect.d.ts +0 -43
  127. package/dist/utils/git.d.ts +0 -135
  128. package/dist/utils/github-api.d.ts +0 -104
  129. package/dist/utils/haystack-api.d.ts +0 -37
  130. package/dist/utils/hooks.d.ts +0 -12
  131. package/dist/utils/pending-state.d.ts +0 -40
  132. package/dist/utils/pr-ref.d.ts +0 -27
  133. package/dist/utils/prompter.d.ts +0 -85
  134. package/dist/utils/secrets.d.ts +0 -47
  135. package/dist/utils/telemetry.d.ts +0 -19
  136. /package/dist/commands/{precompute-delivery-worker.d.ts → combination-search-hook-contract.js} +0 -0
@@ -10,7 +10,8 @@
10
10
  import chalk from 'chalk';
11
11
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
12
12
  import { join, dirname } from 'path';
13
- import inquirer from 'inquirer';
13
+ import { confirm as confirmPrompt, input, select } from '@inquirer/prompts';
14
+ import { runPrompt } from '../utils/prompter.js';
14
15
  const POLICY_PATH = '.haystack/review-policy.md';
15
16
  // ============================================================================
16
17
  // Markdown Parsing
@@ -18,7 +19,7 @@ const POLICY_PATH = '.haystack/review-policy.md';
18
19
  /**
19
20
  * Parse review-policy.md content into ReviewPolicy objects and instructions
20
21
  */
21
- function parsePolicyMarkdown(content) {
22
+ export function parsePolicyMarkdown(content) {
22
23
  const policies = [];
23
24
  const instructions = [];
24
25
  // Split by H2 headings (## )
@@ -45,9 +46,11 @@ function parsePolicyMarkdown(content) {
45
46
  let paths = [];
46
47
  let severity = 'medium';
47
48
  let reason = '';
48
- // Parse bullet points
49
+ // Parse fields. serializePolicies (and therefore policy init/add/remove and
50
+ // setup) writes them as bullets, `- **Paths**: ...`; hand-written files may
51
+ // omit the bullet, so accept both.
49
52
  for (const line of lines.slice(1)) {
50
- const trimmed = line.trim();
53
+ const trimmed = line.trim().replace(/^[-*]\s+/, '');
51
54
  // Match **Paths**: `pattern1`, `pattern2`
52
55
  const pathsMatch = trimmed.match(/^\*\*Paths?\*\*:\s*(.+)$/i);
53
56
  if (pathsMatch) {
@@ -84,7 +87,7 @@ function parsePolicyMarkdown(content) {
84
87
  /**
85
88
  * Serialize ReviewPolicy objects and instructions back to markdown
86
89
  */
87
- function serializePolicies(policies, instructions = []) {
90
+ export function serializePolicies(policies, instructions = []) {
88
91
  const lines = ['# Review Policies', ''];
89
92
  if (instructions.length > 0) {
90
93
  lines.push('## Instructions', '');
@@ -177,30 +180,24 @@ export async function addPolicy(nameArg) {
177
180
  }
178
181
  console.log(chalk.cyan('\n➕ Add Review Policy\n'));
179
182
  // Prompt for policy details
180
- const answers = await inquirer.prompt([
181
- {
182
- type: 'input',
183
- name: 'name',
183
+ const answers = {
184
+ name: await runPrompt(input({
184
185
  message: 'Policy name:',
185
186
  default: nameArg,
186
- validate: (input) => {
187
- if (!input.trim())
187
+ validate: (value) => {
188
+ if (!value.trim())
188
189
  return 'Name is required';
189
- if (policies.some(p => p.name.toLowerCase() === input.toLowerCase())) {
190
+ if (policies.some(p => p.name.toLowerCase() === value.toLowerCase())) {
190
191
  return 'A policy with this name already exists';
191
192
  }
192
193
  return true;
193
194
  },
194
- },
195
- {
196
- type: 'input',
197
- name: 'paths',
195
+ })),
196
+ paths: await runPrompt(input({
198
197
  message: 'File patterns (comma-separated globs):',
199
- validate: (input) => input.trim() ? true : 'At least one path pattern is required',
200
- },
201
- {
202
- type: 'list',
203
- name: 'severity',
198
+ validate: (value) => value.trim() ? true : 'At least one path pattern is required',
199
+ })),
200
+ severity: await runPrompt(select({
204
201
  message: 'Severity:',
205
202
  choices: [
206
203
  { name: chalk.red('critical') + ' - Always requires human review', value: 'critical' },
@@ -209,14 +206,12 @@ export async function addPolicy(nameArg) {
209
206
  { name: chalk.dim('low') + ' - Nice to review', value: 'low' },
210
207
  ],
211
208
  default: 'medium',
212
- },
213
- {
214
- type: 'input',
215
- name: 'reason',
209
+ })),
210
+ reason: await runPrompt(input({
216
211
  message: 'Reason for requiring review:',
217
- validate: (input) => input.trim() ? true : 'Reason is required',
218
- },
219
- ]);
212
+ validate: (value) => value.trim() ? true : 'Reason is required',
213
+ })),
214
+ };
220
215
  // Parse paths from comma-separated input
221
216
  const paths = answers.paths.split(',').map((p) => p.trim()).filter(Boolean);
222
217
  const newPolicy = {
@@ -264,14 +259,10 @@ export async function removePolicy(name) {
264
259
  // Confirm removal. Without a TTY the prompt would hang — the command names
265
260
  // an exact policy, so treat the invocation itself as the confirmation.
266
261
  if (process.stdin.isTTY) {
267
- const { confirm } = await inquirer.prompt([
268
- {
269
- type: 'confirm',
270
- name: 'confirm',
271
- message: `Remove policy "${removed.name}"?`,
272
- default: false,
273
- },
274
- ]);
262
+ const confirm = await runPrompt(confirmPrompt({
263
+ message: `Remove policy "${removed.name}"?`,
264
+ default: false,
265
+ }));
275
266
  if (!confirm) {
276
267
  console.log(chalk.dim('\nCancelled.\n'));
277
268
  return;
@@ -307,15 +298,11 @@ export async function addInstruction(text) {
307
298
  'Pass the text directly: haystack policy add-instruction "Never flag weak test coverage"\n'));
308
299
  process.exit(1);
309
300
  }
310
- const answers = await inquirer.prompt([
311
- {
312
- type: 'input',
313
- name: 'instruction',
314
- message: 'Review instruction:',
315
- validate: (input) => input.trim() ? true : 'Instruction text is required',
316
- },
317
- ]);
318
- instruction = answers.instruction.trim();
301
+ const answer = await runPrompt(input({
302
+ message: 'Review instruction:',
303
+ validate: (value) => value.trim() ? true : 'Instruction text is required',
304
+ }));
305
+ instruction = answer.trim();
319
306
  }
320
307
  instructions.push(instruction);
321
308
  // Ensure directory exists
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
4
4
  import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { validateCapturedSearch } from './combination-search-hook.js';
6
7
  const requestFile = 'request.json';
7
8
  const receiptFile = 'receipt.json';
8
9
  const deliverySchema = 'precompute-delivery-v1';
@@ -126,7 +127,7 @@ function validateRequest(value) {
126
127
  const request = value.request;
127
128
  const component = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-';
128
129
  if (!onlyKeys(request, ['owner', 'repository', 'baseCommit', 'workCommit', 'observedAt',
129
- 'cacheKey', 'patchGzBase64', 'patchSha256'])
130
+ 'cacheKey', 'patchGzBase64', 'patchSha256', ...(request.combinationSearch === undefined ? [] : ['combinationSearch'])])
130
131
  || !characters(request.owner, component) || request.owner.length > 100
131
132
  || !characters(request.repository, component) || request.repository.length > 100
132
133
  || !characters(request.baseCommit, '0123456789abcdef', 40)
@@ -141,6 +142,10 @@ function validateRequest(value) {
141
142
  || createHash('sha256').update(patch).digest('hex') !== request.patchSha256) {
142
143
  throw new Error('Captured patch identity does not match.');
143
144
  }
145
+ if (request.combinationSearch !== undefined)
146
+ validateCapturedSearch(request.combinationSearch, {
147
+ owner: request.owner, repository: request.repository, baseCommit: request.baseCommit, workCommit: request.workCommit,
148
+ });
144
149
  }
145
150
  export class PrecomputeDeliveryAccessError extends Error {
146
151
  delivery;
@@ -49,11 +49,11 @@ const UNSAFE_VALUE_PATTERNS = [
49
49
  function sanitizedStringSchema(schema, label) {
50
50
  return schema.superRefine((value, context) => {
51
51
  if (value.includes('\0')) {
52
- context.addIssue({ code: z.ZodIssueCode.custom, message: `${label} must not contain NUL` });
52
+ context.addIssue({ code: 'custom', message: `${label} must not contain NUL` });
53
53
  }
54
54
  for (const [pattern, message] of UNSAFE_VALUE_PATTERNS) {
55
55
  if (pattern.test(value)) {
56
- context.addIssue({ code: z.ZodIssueCode.custom, message: `${label}: ${message}` });
56
+ context.addIssue({ code: 'custom', message: `${label}: ${message}` });
57
57
  }
58
58
  }
59
59
  });
@@ -84,13 +84,13 @@ const repositoryPathSchema = safeStringSchema('repository path').superRefine((va
84
84
  normalize(value) !== value ||
85
85
  value.split('/').some((component) => component === '..' || component === '')) {
86
86
  context.addIssue({
87
- code: z.ZodIssueCode.custom,
87
+ code: 'custom',
88
88
  message: 'must be a normalized repository-relative POSIX path',
89
89
  });
90
90
  }
91
91
  if (value.split('/').includes('.haystack')) {
92
92
  context.addIssue({
93
- code: z.ZodIssueCode.custom,
93
+ code: 'custom',
94
94
  message: 'must not reference generated .haystack artifacts',
95
95
  });
96
96
  }
@@ -120,7 +120,7 @@ const evidenceSourceSchema = z
120
120
  if (source.locatorKind === 'line-span') {
121
121
  if (!source.lines || source.lines.end < source.lines.start) {
122
122
  context.addIssue({
123
- code: z.ZodIssueCode.custom,
123
+ code: 'custom',
124
124
  path: ['lines'],
125
125
  message: 'must contain positive start/end positions with end >= start for locatorKind line-span',
126
126
  });
@@ -128,7 +128,7 @@ const evidenceSourceSchema = z
128
128
  }
129
129
  else if (source.lines !== undefined) {
130
130
  context.addIssue({
131
- code: z.ZodIssueCode.custom,
131
+ code: 'custom',
132
132
  path: ['lines'],
133
133
  message: 'is only valid for locatorKind line-span',
134
134
  });
@@ -149,7 +149,7 @@ const evidenceRecordSchema = z
149
149
  const budgetUsageSchema = z
150
150
  .object({
151
151
  repositoryInspectionCallsUsed: z.number().int().nonnegative(),
152
- elapsedSeconds: z.number().finite().nonnegative(),
152
+ elapsedSeconds: z.number().nonnegative(),
153
153
  })
154
154
  .strict();
155
155
  const provisionalUniverseScaffoldInputSchema = z
@@ -160,9 +160,7 @@ const provisionalUniverseScaffoldInputSchema = z
160
160
  id: stableIdSchema,
161
161
  requestedScope: stableIdSchema,
162
162
  evaluationMode: z.literal('real-production', {
163
- errorMap: () => ({
164
- message: 'must be "real-production" because this scaffold records missing real-production authority',
165
- }),
163
+ error: 'must be "real-production" because this scaffold records missing real-production authority',
166
164
  }),
167
165
  })
168
166
  .strict(),
@@ -15,7 +15,8 @@ import chalk from 'chalk';
15
15
  import { execFileSync, execSync } from 'child_process';
16
16
  import { basename } from 'path';
17
17
  import { resolveAuthContext } from '../utils/auth.js';
18
- import { hooksInstall } from './hooks.js';
18
+ import { githubRestBaseFor } from '../utils/github-api.js';
19
+ import { describeHookOverwrites, hooksInstall } from './hooks.js';
19
20
  import { installSessionHooks } from './install-session-hooks.js';
20
21
  import { findGitRoot } from '../utils/hooks.js';
21
22
  import { trackError } from '../utils/telemetry.js';
@@ -61,10 +62,9 @@ function buildAnswerMap(options) {
61
62
  // Constants
62
63
  // =============================================================================
63
64
  const HAYSTACK_API = 'https://haystackeditor.com';
64
- // User-level GitHub calls (repo listing, App-installation check, token
65
- // verification) go direct: they're scoped to the authenticated user, so OAuth
66
- // App Policy org restrictions don't apply to them.
67
- const GITHUB_API = 'https://api.github.com';
65
+ // The token verification call below uses githubRestBaseFor: direct to GitHub
66
+ // for an OAuth token (user-scoped, so OAuth App Policy does not apply), the
67
+ // Haystack proxy for an hsk_live token, which must never reach GitHub.
68
68
  // Per-repo reads + writes go through the auth-worker proxy, which swaps in the
69
69
  // GitHub App installation token server-side. That's what lets the config write
70
70
  // and the bootstrap PR succeed on orgs that restrict the OAuth app — the same
@@ -1041,7 +1041,7 @@ async function runSetupFlow(options) {
1041
1041
  }
1042
1042
  const token = authContext.token;
1043
1043
  // Verify token
1044
- const userResponse = await fetch(`${GITHUB_API}/user`, {
1044
+ const userResponse = await fetch(`${githubRestBaseFor(token)}/user`, {
1045
1045
  headers: {
1046
1046
  Authorization: `Bearer ${token}`,
1047
1047
  'User-Agent': 'Haystack-CLI',
@@ -1073,7 +1073,7 @@ async function runSetupFlow(options) {
1073
1073
  return;
1074
1074
  }
1075
1075
  // Step 7: Offer Haystack's local quality hooks when setup runs in a selected repo.
1076
- await stepInstallHooks(selectedRepos);
1076
+ await stepInstallHooks(selectedRepos, options.json === true);
1077
1077
  // Close the loop with ONE prominent CTA: the Haystack feed. The Configure
1078
1078
  // Haystack PR (and every PR after it) is reviewed and merged from the feed,
1079
1079
  // NOT the GitHub PR page, so the feed is the only link we surface. In JSON
@@ -1110,7 +1110,7 @@ async function runSetupFlow(options) {
1110
1110
  // =============================================================================
1111
1111
  // Step 7: Install Haystack git hooks
1112
1112
  // =============================================================================
1113
- async function stepInstallHooks(selectedRepos) {
1113
+ async function stepInstallHooks(selectedRepos, json) {
1114
1114
  // Offer to install local git hooks if cwd is inside one of the selected repos
1115
1115
  const gitRoot = findGitRoot();
1116
1116
  if (gitRoot) {
@@ -1123,12 +1123,34 @@ async function stepInstallHooks(selectedRepos) {
1123
1123
  try {
1124
1124
  const installHooks = await ui.confirm({
1125
1125
  id: 'install_hooks',
1126
- message: `Install local hooks for ${repoName}? (agent checks, session status, verification precompute)`,
1126
+ message: `Install local hooks for ${repoName}? (git hooks in hooks/ via core.hooksPath; Claude Code session and verification-precompute hooks in your .claude/settings.local.json)`,
1127
1127
  default: true,
1128
1128
  });
1129
1129
  if (installHooks) {
1130
1130
  console.log('');
1131
- await hooksInstall({ force: true });
1131
+ const overwrites = await describeHookOverwrites(gitRoot);
1132
+ let installGitHooks = true;
1133
+ if (overwrites.length > 0) {
1134
+ console.log(chalk.yellow(' Installing Haystack hooks would:'));
1135
+ for (const change of overwrites)
1136
+ console.log(chalk.yellow(` - ${change}`));
1137
+ if (json || !process.stdin.isTTY) {
1138
+ console.log(' Existing git hooks were left alone; to replace them, run `haystack hooks install --force` yourself.');
1139
+ installGitHooks = false;
1140
+ }
1141
+ else {
1142
+ installGitHooks = await ui.confirm({
1143
+ id: 'overwrite_hooks',
1144
+ message: 'Overwrite these existing hooks?',
1145
+ default: false,
1146
+ });
1147
+ if (!installGitHooks) {
1148
+ console.log(chalk.dim(' Existing git hooks were left alone. To replace them later: haystack hooks install --force'));
1149
+ }
1150
+ }
1151
+ }
1152
+ if (installGitHooks)
1153
+ await hooksInstall({ force: overwrites.length > 0 });
1132
1154
  await installSessionHooks({ cli: 'claude' });
1133
1155
  }
1134
1156
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Submit command - Create a PR from current changes
3
3
  *
4
- * Runs pre-PR triage (code review, rules, intent drift) via parallel sub-agents,
4
+ * Runs pre-PR triage (code review, rules) on gpt-6-astra in parallel,
5
5
  * then pushes the current branch and creates a PR.
6
6
  *
7
7
  * PRs are routed based on flags:
@@ -11,14 +11,14 @@
11
11
  */
12
12
  import chalk from 'chalk';
13
13
  import fs from 'fs';
14
- import { findGitRoot, getCurrentBranch, getDefaultBranch, hasUncommittedChanges, parseRemoteUrl, pushBranch, getLastCommitMessage, getCommitMessagesSinceBase, } from '../utils/git.js';
14
+ import { findGitRoot, getCurrentBranch, getDefaultBranch, hasUncommittedChanges, parseRemoteUrl, pushBranch, getLastCommitMessage, getCommitMessagesSinceBase, HEADLESS_PUSH_ERROR, } from '../utils/git.js';
15
15
  import { createPullRequest, findExistingPR, getRepositoryAccess, requestReviewers, } from '../utils/github-api.js';
16
16
  import { runTriage } from '../triage/runner.js';
17
17
  import { loadConfig } from '../utils/config.js';
18
18
  import { checkAnalysisReady, fetchAnalysisResults } from '../utils/analysis-api.js';
19
19
  import { savePendingSubmit, updatePendingSubmit } from '../utils/pending-state.js';
20
20
  import { ensureHaystackLabels, addLabelsToIssue, } from '../utils/github-api.js';
21
- import { listStoredAccounts, resolveAuthContext, setRepoDefaultAccount, } from '../utils/auth.js';
21
+ import { isHeadlessToken, listStoredAccounts, resolveAuthContext, setRepoDefaultAccount, } from '../utils/auth.js';
22
22
  import { HaystackApiError } from '../utils/haystack-api.js';
23
23
  import { withSchema } from '../schema.js';
24
24
  // ============================================================================
@@ -141,9 +141,6 @@ function checkerDisplayName(checker) {
141
141
  /**
142
142
  * Merge triage-tuning knobs from two sources, in ascending precedence:
143
143
  * defaults (in runner.ts) < .haystack.json triage.* < CLI flags
144
- *
145
- * --max-turns <N> applies the same number to every checker. Per-checker
146
- * granularity stays in .haystack.json (less common, more verbose).
147
144
  */
148
145
  async function resolveTriageOptions(options) {
149
146
  let config = null;
@@ -158,30 +155,16 @@ async function resolveTriageOptions(options) {
158
155
  console.warn(chalk.dim(' Triage will use CLI flags / defaults — fix the config to apply project overrides.'));
159
156
  }
160
157
  const triageConfig = config?.triage ?? {};
161
- const maxTurns = { ...(triageConfig.maxTurns ?? {}) };
162
- if (typeof options.maxTurns === 'number' && options.maxTurns > 0) {
163
- for (const name of ['code-review', 'rules-validator']) {
164
- maxTurns[name] = options.maxTurns;
165
- }
166
- }
167
158
  // loadConfig does a raw `as HaystackConfig` cast (no Zod runtime parse), so
168
159
  // malformed .haystack.json values can reach us as strings/negatives/NaN.
169
- // Mirror the positive-number guard we already apply to maxTurns before
170
- // letting this flow into setTimeout.
160
+ // Guard before letting this flow into setTimeout.
171
161
  const configTimeoutMs = typeof triageConfig.timeoutMs === 'number' && triageConfig.timeoutMs > 0
172
162
  ? triageConfig.timeoutMs
173
163
  : undefined;
174
164
  const timeoutMs = typeof options.triageTimeout === 'number' && options.triageTimeout > 0
175
165
  ? options.triageTimeout * 1000
176
166
  : configTimeoutMs;
177
- return {
178
- maxTurns: Object.keys(maxTurns).length > 0 ? maxTurns : undefined,
179
- timeoutMs,
180
- };
181
- }
182
- function isCodexNetworkDisabledTriageError(err) {
183
- const message = err instanceof Error ? err.message : String(err);
184
- return message.includes('CODEX_SANDBOX_NETWORK_DISABLED=1');
167
+ return { timeoutMs };
185
168
  }
186
169
  function printTriageSummary(result) {
187
170
  console.log(chalk.bold('\n' + '━'.repeat(50)));
@@ -200,27 +183,21 @@ function printTriageSummary(result) {
200
183
  : issue.file;
201
184
  const ruleTag = issue.rule ? chalk.dim(` [${issue.rule}]`) : '';
202
185
  const patternTag = issue.pattern ? chalk.dim(` (${issue.pattern})`) : '';
203
- console.log(` ${severityIcon(issue.severity)} ${chalk.dim(location)} ${severityColor(issue.severity)(issue.message)}${ruleTag}${patternTag}`);
186
+ const categoryTag = issue.category ? chalk.dim(` {${issue.category}}`) : '';
187
+ console.log(` ${severityIcon(issue.severity)} ${chalk.dim(location)} ${severityColor(issue.severity)(issue.message)}${ruleTag}${patternTag}${categoryTag}`);
188
+ if (issue.failureScenario) {
189
+ console.log(chalk.dim(` ${issue.failureScenario}`));
190
+ }
204
191
  }
205
192
  console.log('');
206
193
  }
207
194
  }
208
- // Hard errors from sub-agent failures (auth, crashes) — these block submit.
209
- if (result.errors.length > 0) {
210
- console.log(chalk.red(' Checker errors:'));
211
- for (const err of result.errors) {
212
- console.log(chalk.red(` ✗ ${err}`));
213
- }
214
- console.log('');
195
+ // Checkers that could not run (API error, missing key, stall). Non-blocking.
196
+ for (const err of result.errors) {
197
+ console.log(chalk.yellow(` ! ${err}`));
215
198
  }
216
- // Soft failures (max turns, timeout) — non-blocking, purely informational.
217
- if (result.warnings.length > 0) {
218
- console.log(chalk.yellow(' Checker warnings (non-blocking):'));
219
- for (const warn of result.warnings) {
220
- console.log(chalk.yellow(` ! ${warn}`));
221
- }
199
+ if (result.errors.length > 0)
222
200
  console.log('');
223
- }
224
201
  // Total summary
225
202
  const totalIssues = result.results.reduce((sum, r) => sum + r.issues.length, 0);
226
203
  const errorCount = result.results.reduce((sum, r) => sum + r.issues.filter((i) => i.severity === 'error').length, 0);
@@ -316,6 +293,11 @@ export async function submitCommand(options) {
316
293
  catch (err) {
317
294
  fail(err.message);
318
295
  }
296
+ // A headless (hsk_live_) token cannot authenticate a git push, and must not
297
+ // be sent to github.com as a password. Stop before triage and the push.
298
+ if (githubToken && isHeadlessToken(githubToken)) {
299
+ fail(HEADLESS_PUSH_ERROR);
300
+ }
319
301
  // Check for uncommitted changes
320
302
  if (hasUncommittedChanges()) {
321
303
  console.log(chalk.yellow('⚠ You have uncommitted changes. They will not be included in the PR.'));
@@ -338,49 +320,35 @@ export async function submitCommand(options) {
338
320
  const triageOptions = await resolveTriageOptions(options);
339
321
  const triageResult = await runTriage(gitRoot, baseBranch, triageOptions);
340
322
  printTriageSummary(triageResult);
341
- // No valid results means we got zero signal from triage. That's
342
- // "unavailable" whether the cause was a hard error (auth crash) or a
343
- // soft cap (every checker hit max turns). Either way don't pretend it
344
- // passed — print the warning and continue.
345
- const triageInfraFailure = triageResult.results.length === 0 &&
346
- (triageResult.errors.length > 0 || triageResult.warnings.length > 0);
323
+ // No valid results means we got zero signal from triage: every
324
+ // checker failed to run. Don't pretend it passed — warn and continue.
325
+ const triageInfraFailure = triageResult.results.length === 0 && triageResult.errors.length > 0;
347
326
  if (triageInfraFailure) {
348
327
  console.log(chalk.yellow('\n⚠ Triage unavailable (no checkers produced results). Continuing with submit.\n'));
349
328
  }
329
+ // Only findings with severity "error" block; a failed checker never does.
350
330
  if (!triageResult.passed) {
351
- if (triageInfraFailure) {
352
- // Infrastructure failure should not block submission.
353
- // We already warned above and continue.
354
- }
355
- else {
356
- fail('Triage failed. Fix the issues above or re-run with --force.', {
357
- triage: {
358
- status: 'failed',
359
- issues: triageResult.results.flatMap((r) => r.issues.map((issue) => ({
360
- checker: r.checker,
361
- severity: issue.severity,
362
- file: issue.file,
363
- line: issue.line ?? null,
364
- message: issue.message,
365
- rule: issue.rule ?? null,
366
- }))),
367
- },
368
- });
369
- }
331
+ fail('Triage failed. Fix the issues above or re-run with --force.', {
332
+ triage: {
333
+ status: 'failed',
334
+ issues: triageResult.results.flatMap((r) => r.issues.map((issue) => ({
335
+ checker: r.checker,
336
+ severity: issue.severity,
337
+ file: issue.file,
338
+ line: issue.line ?? null,
339
+ message: issue.message,
340
+ rule: issue.rule ?? null,
341
+ category: issue.category ?? null,
342
+ failure_scenario: issue.failureScenario ?? null,
343
+ }))),
344
+ },
345
+ });
370
346
  }
371
347
  triageStatus = triageInfraFailure ? 'unavailable' : 'passed';
372
348
  }
373
349
  catch (err) {
374
350
  // Triage failure should not block submission
375
- if (isCodexNetworkDisabledTriageError(err)) {
376
- console.log(chalk.yellow('\n⚠ Triage skipped in Codex app shell.'));
377
- console.log(chalk.dim(' Nested Codex sub-agents cannot access model endpoints from this shell.'));
378
- console.log(chalk.dim(' Submit will continue normally.'));
379
- console.log(chalk.dim(' For full triage, run `haystack submit` from a regular terminal.\n'));
380
- }
381
- else {
382
- console.log(chalk.yellow(`\n⚠ Triage skipped: ${err.message}`));
383
- }
351
+ console.log(chalk.yellow(`\n⚠ Triage skipped: ${err.message}`));
384
352
  triageStatus = 'unavailable';
385
353
  }
386
354
  }
@@ -7,8 +7,20 @@ import chalk from 'chalk';
7
7
  import fg from 'fast-glob';
8
8
  // @babel/core intentionally has no bundled declarations. Keep this small
9
9
  // build-transform boundary dynamic instead of leaking `any` into CLI contracts.
10
+ // It is loaded on first use, not at module top: loading it costs tens of
11
+ // milliseconds, and only the instrument transforms below need it.
10
12
  const require = createRequire(import.meta.url);
11
- const babel = require('@babel/core');
13
+ let babelModule;
14
+ function loadBabel() {
15
+ babelModule ??= require('@babel/core');
16
+ return babelModule;
17
+ }
18
+ // @babel/core 8 is ESM-only. require() of ESM works from Node 22.12, but 22.12
19
+ // prints an ExperimentalWarning for it, so the command path loads Babel with
20
+ // import() first and loadBabel() then reuses that module.
21
+ async function preloadBabel() {
22
+ babelModule ??= await import('@babel/core');
23
+ }
12
24
  const INSTRUMENTED_MARKER = '__haystack_telemetry_instrumented_v1__';
13
25
  const INSTRUMENTED_MARKER_COMMENT = `/* ${INSTRUMENTED_MARKER} */`;
14
26
  const BOOTSTRAP_MARKER = '__haystack_telemetry_bootstrap_v1__';
@@ -486,6 +498,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
486
498
  },
487
499
  };
488
500
  };
501
+ const babel = loadBabel();
489
502
  const transformed = babel.transformSync(code, {
490
503
  parserOpts: {
491
504
  sourceType: 'unambiguous',
@@ -541,7 +554,7 @@ function findNearestPackageType(start, stop) {
541
554
  current = dirname(current);
542
555
  }
543
556
  }
544
- function moduleTypeForOutput(output, dist) {
557
+ function moduleTypeForOutput(output, _dist) {
545
558
  const extension = extname(output);
546
559
  if (extension === '.mjs')
547
560
  return 'module';
@@ -570,6 +583,7 @@ function bootstrapFor(entry, dist, buildId, runtimeHash, runtimeGuardChannel, ru
570
583
  return `/* ${BOOTSTRAP_MARKER} */ (() => { const processHost = require('node:process'); const define = (name, descriptor) => ({}).constructor.defineProperty(processHost, name, descriptor); const block = () => { try { if (!(${JSON.stringify(runtimeBlockedChannel)} in processHost)) define(${JSON.stringify(runtimeBlockedChannel)}, { configurable: false, enumerable: false, writable: false, value: true }); } catch (_ignored) {} }; try { if (${JSON.stringify(runtimeGuardChannel)} in processHost || ${JSON.stringify(runtimeBlockedChannel)} in processHost) { block(); return; } const runtimePath = require.resolve(${JSON.stringify(specifier)}); const source = require('node:fs').readFileSync(runtimePath, 'utf8'); if (require('node:crypto').createHash('sha256').update(source).digest('hex') !== ${JSON.stringify(runtimeHash)}) { block(); return; } const Module = require('node:module'); const loaded = new Module(runtimePath); loaded.filename = runtimePath; loaded.paths = Module._nodeModulePaths(require('node:path').dirname(runtimePath)); loaded._compile(source, runtimePath); loaded.loaded = true; const runtime = loaded.exports; if (!runtime || typeof runtime.install !== 'function' || typeof runtime.safeSelectProbe !== 'function') { block(); return; } define(${JSON.stringify(runtimeGuardChannel)}, { configurable: false, enumerable: false, writable: false, value: runtime }); require.cache[runtimePath] = loaded; runtime.install({ buildId: ${JSON.stringify(buildId)} }); } catch (_error) { block(); } })();\n`;
571
584
  }
572
585
  function insertEntryBootstrap(code, bootstrap) {
586
+ const babel = loadBabel();
573
587
  const bootstrapAst = babel.parseSync(bootstrap, {
574
588
  sourceType: 'unambiguous',
575
589
  });
@@ -814,6 +828,7 @@ export async function telemetryInstrumentCommand(distDirectory, options) {
814
828
  }
815
829
  includeSymbols = rows;
816
830
  }
831
+ await preloadBabel();
817
832
  const result = instrumentNodeTelemetryBuild(distDirectory, { ...options, includeSymbols });
818
833
  if (options.json) {
819
834
  console.log(JSON.stringify(result, null, 2));
@@ -28,6 +28,7 @@ import { parsePrRef } from '../utils/pr-ref.js';
28
28
  import { toPublicVerdict } from '../states.js';
29
29
  import { withSchema } from '../schema.js';
30
30
  import { fetchGitHubJson } from '../utils/haystack-api.js';
31
+ import { githubRestBaseFor } from '../utils/github-api.js';
31
32
  // ============================================================================
32
33
  // Output formatting
33
34
  // ============================================================================
@@ -562,7 +563,7 @@ function updatePendingSubmitIfExists(pr, status) {
562
563
  */
563
564
  async function recordFindingsSeen(pr, token, findingKeys) {
564
565
  // Get PR head SHA
565
- const ghResp = await fetch(`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.prNumber}`, {
566
+ const ghResp = await fetch(`${githubRestBaseFor(token)}/repos/${pr.owner}/${pr.repo}/pulls/${pr.prNumber}`, {
566
567
  headers: {
567
568
  Authorization: `Bearer ${token}`,
568
569
  Accept: 'application/vnd.github.v3+json',
@@ -12,6 +12,7 @@ function timestamp(value) {
12
12
  return typeof value === 'string' && Number.isFinite(Date.parse(value));
13
13
  }
14
14
  function terminalSafe(value) {
15
+ // eslint-disable-next-line no-control-regex -- rejecting/stripping control characters is the point
15
16
  return value.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '');
16
17
  }
17
18
  async function context(runId, repository) {
@@ -1,8 +1,5 @@
1
1
  // Production Cloud Verifier MCP server: `haystack verify hosted mcp`.
2
2
  //
3
- // This intentionally exposes only the durable hosted run lifecycle. The local
4
- // specimen MCP server has artifact and retained-sandbox tools whose data model
5
- // does not exist in the source-free production result contract.
6
3
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
5
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
@@ -58,13 +55,9 @@ export const HOSTED_VERIFY_TOOLS = [
58
55
  pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$',
59
56
  description: 'Optional stable retry key. Omit to derive it from the exact repository and commits.',
60
57
  },
61
- replay_plan_from_run_id: {
62
- ...runIdInput,
63
- description: 'Optional completed owned run whose exact validated risk plan must be replayed.',
64
- },
65
58
  account: accountInput,
66
59
  },
67
- required: ['repository', 'base_commit', 'head_commit', 'intent_file'],
60
+ required: ['repository', 'base_commit', 'head_commit'],
68
61
  },
69
62
  },
70
63
  {
@@ -130,7 +123,6 @@ function assertToolInputFields(name, input) {
130
123
  'head_commit',
131
124
  'idempotency_key',
132
125
  'intent_file',
133
- 'replay_plan_from_run_id',
134
126
  'repository',
135
127
  ]);
136
128
  return;
@@ -157,9 +149,8 @@ export async function executeHostedVerifyTool(name, input, context) {
157
149
  const started = await startHostedVerification(repository, {
158
150
  base: requireInputString(input, 'base_commit'),
159
151
  head: requireInputString(input, 'head_commit'),
160
- intentFile: requireInputString(input, 'intent_file'),
152
+ intentFile: optionalInputString(input, 'intent_file'),
161
153
  idempotencyKey: optionalInputString(input, 'idempotency_key'),
162
- replayPlanFrom: optionalInputString(input, 'replay_plan_from_run_id'),
163
154
  wait: false,
164
155
  }, context.token);
165
156
  return buildHostedJson('start', started.state, {
@@ -224,7 +215,7 @@ export async function runHostedVerifyMcpServer(options) {
224
215
  // Reject undeclared fields before account resolution so callers cannot
225
216
  // use an invalid request to trigger credential or repository lookup.
226
217
  assertToolInputFields(name, input);
227
- if (name === 'verify_start') {
218
+ if (name === 'verify_start' && input.intent_file !== undefined) {
228
219
  readHostedIntentFile(requireInputString(input, 'intent_file'));
229
220
  }
230
221
  const context = await resolveToolContext(name, input, options);