@haystackeditor/cli 0.15.26 → 0.15.28

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 (87) hide show
  1. package/README.md +52 -23
  2. package/dist/assets/hooks/agent-context/detect.ts +7 -7
  3. package/dist/assets/hooks/agent-context/parsers/claude.ts +1 -1
  4. package/dist/assets/hooks/package-lock.json +598 -0
  5. package/dist/assets/hooks/scripts/commit-msg.sh +1 -3
  6. package/dist/assets/hooks/scripts/post-commit.sh +1 -3
  7. package/dist/assets/hooks/scripts/pre-push.sh +1 -4
  8. package/dist/assets/hooks/scripts/prepare-commit-msg.sh +1 -2
  9. package/dist/assets/skills/map-cloud-verifier-universe/SKILL.md +2051 -0
  10. package/dist/assets/skills/map-cloud-verifier-universe/agents/openai.yaml +4 -0
  11. package/dist/assets/skills/map-cloud-verifier-universe/references/output-contract.md +3411 -0
  12. package/dist/assets/telemetry/browser-runtime.js +1469 -0
  13. package/dist/assets/telemetry/runtime.cjs +1503 -0
  14. package/dist/commands/cloud-verifier-behaviors.d.ts +27 -0
  15. package/dist/commands/cloud-verifier-behaviors.js +218 -0
  16. package/dist/commands/cloud-verifier-data-store-census.d.ts +47 -0
  17. package/dist/commands/cloud-verifier-data-store-census.js +539 -0
  18. package/dist/commands/cloud-verifier-data-store-drift.d.ts +42 -0
  19. package/dist/commands/cloud-verifier-data-store-drift.js +158 -0
  20. package/dist/commands/cloud-verifier-identity-census.d.ts +88 -0
  21. package/dist/commands/cloud-verifier-identity-census.js +4057 -0
  22. package/dist/commands/cloud-verifier-materialization.d.ts +16 -0
  23. package/dist/commands/cloud-verifier-materialization.js +704 -0
  24. package/dist/commands/cloud-verifier-pascal-selector-census.d.ts +29 -0
  25. package/dist/commands/cloud-verifier-pascal-selector-census.js +1382 -0
  26. package/dist/commands/cloud-verifier-python-manifest-selector-census.d.ts +27 -0
  27. package/dist/commands/cloud-verifier-python-manifest-selector-census.js +2015 -0
  28. package/dist/commands/cloud-verifier-specialized-operational-census.d.ts +51 -0
  29. package/dist/commands/cloud-verifier-specialized-operational-census.js +11432 -0
  30. package/dist/commands/cloud-verifier-universe.d.ts +31 -0
  31. package/dist/commands/cloud-verifier-universe.js +10178 -0
  32. package/dist/commands/design-verify.d.ts +31 -0
  33. package/dist/commands/design-verify.js +285 -0
  34. package/dist/commands/hooks.d.ts +1 -5
  35. package/dist/commands/hooks.js +12 -114
  36. package/dist/commands/install-session-hooks.d.ts +2 -2
  37. package/dist/commands/install-session-hooks.js +71 -21
  38. package/dist/commands/mcp.js +1 -34
  39. package/dist/commands/pr.js +2 -7
  40. package/dist/commands/prepare-universe-review.d.ts +115 -0
  41. package/dist/commands/prepare-universe-review.js +1092 -0
  42. package/dist/commands/production-source-deny-policy.d.ts +15 -0
  43. package/dist/commands/production-source-deny-policy.js +100 -0
  44. package/dist/commands/scaffold-provisional-universe.d.ts +468 -0
  45. package/dist/commands/scaffold-provisional-universe.js +808 -0
  46. package/dist/commands/schema-cmd.js +32 -0
  47. package/dist/commands/setup.d.ts +1 -2
  48. package/dist/commands/setup.js +18 -272
  49. package/dist/commands/skills.d.ts +2 -2
  50. package/dist/commands/skills.js +415 -47
  51. package/dist/commands/submit.js +1 -2
  52. package/dist/commands/telemetry.d.ts +53 -0
  53. package/dist/commands/telemetry.js +832 -0
  54. package/dist/commands/verify-hosted.d.ts +13 -0
  55. package/dist/commands/verify-hosted.js +49 -1
  56. package/dist/commands/verify-precompute.d.ts +19 -0
  57. package/dist/commands/verify-precompute.js +328 -0
  58. package/dist/index.js +168 -47
  59. package/dist/schema.d.ts +1 -2
  60. package/dist/schema.js +1 -2
  61. package/dist/triage/prompts.d.ts +0 -7
  62. package/dist/triage/prompts.js +0 -145
  63. package/dist/triage/runner.js +10 -28
  64. package/dist/triage/types.d.ts +1 -8
  65. package/dist/types.d.ts +8 -8
  66. package/dist/types.js +1 -1
  67. package/dist/utils/design-verifier-api.d.ts +200 -0
  68. package/dist/utils/design-verifier-api.js +271 -0
  69. package/dist/utils/design-verifier-result.d.ts +57 -0
  70. package/dist/utils/design-verifier-result.js +345 -0
  71. package/dist/utils/git.d.ts +5 -1
  72. package/dist/utils/git.js +19 -9
  73. package/dist/utils/github-api.js +11 -14
  74. package/dist/utils/haystack-api.d.ts +2 -0
  75. package/dist/utils/haystack-api.js +10 -3
  76. package/dist/utils/hooks.d.ts +1 -15
  77. package/dist/utils/hooks.js +3 -128
  78. package/dist/utils/telemetry.d.ts +2 -0
  79. package/dist/utils/telemetry.js +81 -8
  80. package/package.json +8 -2
  81. package/schemas/cloud-verifier.v1.json +69 -3
  82. package/schemas/{pr.v2.json → pr.v3.json} +3 -12
  83. package/dist/commands/traces.d.ts +0 -48
  84. package/dist/commands/traces.js +0 -92
  85. package/dist/triage/traces.d.ts +0 -20
  86. package/dist/triage/traces.js +0 -311
  87. package/schemas/traces.v1.json +0 -53
@@ -0,0 +1,345 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ function findingExplanation(kind) {
3
+ if (kind === 'declared-change-not-observed') {
4
+ return 'The registered intended behavior change was not observed; investigate the captured outputs before concluding why.';
5
+ }
6
+ if (kind === 'unexpected-change') {
7
+ return 'The proposed revision produced a difference outside this case’s registered intent; this is an anomaly candidate for investigation.';
8
+ }
9
+ if (kind === 'intended-change-control-confounded') {
10
+ return 'The intended difference was observed, but control-before also varied on an intersecting output path; inspect it before claiming causality.';
11
+ }
12
+ return 'Executed behavior produced a suspicious difference against the registered expectation.';
13
+ }
14
+ function sameRecordedValue(left, right) {
15
+ return isDeepStrictEqual(left, right);
16
+ }
17
+ function executionSummaries(value) {
18
+ if (!Array.isArray(value))
19
+ return [];
20
+ return value.filter((execution) => !!execution && typeof execution === 'object' &&
21
+ ['base1', 'base2', 'head'].includes(execution.side) &&
22
+ ['completed', 'failed', 'missing'].includes(execution.status) &&
23
+ Array.isArray(execution.diagnostics) && Array.isArray(execution.outputExcerpt));
24
+ }
25
+ function universeSummaries(value, caseId) {
26
+ if (!Array.isArray(value))
27
+ return [];
28
+ return value.filter((universe) => universe?.version === 'dv-case-universe-summary-v1' && universe.caseId === caseId &&
29
+ /^universe-[0-9a-f]{24}$/.test(universe.universeId || '') &&
30
+ ['control-before', 'before', 'after'].includes(universe.role) &&
31
+ universe.lease && typeof universe.lease.available === 'boolean');
32
+ }
33
+ export function buildDesignVerifierAgentReport(run) {
34
+ const repository = `${run.owner}/${run.repo}`;
35
+ if (run.status !== 'succeeded' || !run.result) {
36
+ return {
37
+ ok: false,
38
+ status: 'failed',
39
+ verdict: 'inconclusive',
40
+ headline: 'VERIFICATION INCONCLUSIVE',
41
+ explanation: run.error || 'The verifier did not produce a terminal behavioral verdict.',
42
+ runId: run.runId,
43
+ repository,
44
+ pullRequest: run.pr,
45
+ revisions: { base: run.baseSha, head: run.headSha },
46
+ durationMs: run.durationMs,
47
+ timings: null,
48
+ changedFiles: [],
49
+ cases: [],
50
+ inspectCommand: `haystack design-verify ${repository}#${run.pr} --run ${run.runId}`,
51
+ replay: null,
52
+ error: run.error,
53
+ exitCode: 2,
54
+ };
55
+ }
56
+ const verdict = run.result.verdict || {};
57
+ const summary = verdict.summary || {};
58
+ const findings = Array.isArray(verdict.findings) ? verdict.findings : [];
59
+ const confirmations = Array.isArray(verdict.confirmations) ? verdict.confirmations : [];
60
+ const findingsByCase = new Map();
61
+ for (const finding of findings) {
62
+ const caseFindings = findingsByCase.get(String(finding?.caseId || '')) || [];
63
+ caseFindings.push(finding);
64
+ findingsByCase.set(String(finding?.caseId || ''), caseFindings);
65
+ }
66
+ const confirmedIds = new Set(confirmations.map((confirmation) => confirmation.caseId));
67
+ const scenarioByCase = new Map(run.result.scenarios.map((scenario) => [scenario.caseId, scenario]));
68
+ const probeByCase = new Map(run.result.probes.map((probe) => [probe.caseId, probe]));
69
+ const cases = run.result.cases.map((testCase) => {
70
+ const id = String(testCase.id || '');
71
+ const caseFindings = findingsByCase.get(id) || [];
72
+ const finding = caseFindings[0];
73
+ const scenario = scenarioByCase.get(id);
74
+ const probe = probeByCase.get(id);
75
+ const observations = (Array.isArray(scenario?.divergences) ? scenario.divergences : []).map((item) => ({
76
+ path: String(item.display || (Array.isArray(item.path) ? item.path.join('.') : 'observation')),
77
+ base: item.base,
78
+ baseRepeat: item.base2,
79
+ head: item.head,
80
+ controlObservedAtSamePath: item.controlObservedAtSamePath === true,
81
+ controlObservedAtAncestorPath: item.controlObservedAtAncestorPath === true,
82
+ controlObservedAtDescendantPath: item.controlObservedAtDescendantPath === true,
83
+ }));
84
+ const hasControlEvidence = typeof scenario?.noisePathCount === 'number' ||
85
+ scenario?.controlScenarioErrorVariation != null;
86
+ return {
87
+ id,
88
+ title: String(testCase.title || scenario?.scenario || id),
89
+ story: String(testCase.story || ''),
90
+ expectedHead: String(testCase.expected_head_observation || ''),
91
+ verdict: caseFindings.length
92
+ ? 'finding'
93
+ : confirmedIds.has(id)
94
+ ? 'confirmed'
95
+ : scenario?.status === 'clean'
96
+ ? 'clean'
97
+ : 'inconclusive',
98
+ findingKind: finding?.kind || null,
99
+ findingKinds: caseFindings
100
+ .map((item) => String(item?.kind || ''))
101
+ .filter(Boolean),
102
+ baseStable: hasControlEvidence
103
+ ? scenario.noisePathCount === 0 && scenario.controlScenarioErrorVariation == null
104
+ : observations.length
105
+ ? observations.every((item) => sameRecordedValue(item.base, item.baseRepeat))
106
+ : scenario?.status === 'clean' ? true : null,
107
+ observations,
108
+ generatedFiles: probe?.files || [],
109
+ executedCommand: probe?.run || null,
110
+ executions: executionSummaries(scenario?.executions),
111
+ universes: universeSummaries(scenario?.universes, id),
112
+ infrastructure: scenario?.infrastructureReceipt &&
113
+ typeof scenario.infrastructureReceipt === 'object'
114
+ ? {
115
+ verified: scenario.infrastructureReceipt.status === 'passed',
116
+ recipeId: typeof scenario.infrastructureReceipt.recipeId === 'string'
117
+ ? scenario.infrastructureReceipt.recipeId
118
+ : null,
119
+ freshInstances: Array.isArray(scenario.infrastructureReceipt.environmentInstanceIds)
120
+ ? scenario.infrastructureReceipt.environmentInstanceIds.length
121
+ : 0,
122
+ baseRepeat: typeof scenario.infrastructureReceipt.baseRepeat === 'string'
123
+ ? scenario.infrastructureReceipt.baseRepeat
124
+ : null,
125
+ errors: Array.isArray(scenario.infrastructureReceipt.errors)
126
+ ? scenario.infrastructureReceipt.errors.filter((item) => typeof item === 'string')
127
+ : [],
128
+ }
129
+ : null,
130
+ };
131
+ });
132
+ let agentVerdict;
133
+ let headline;
134
+ let explanation;
135
+ let exitCode;
136
+ if (findings.length > 0) {
137
+ agentVerdict = 'anomaly_candidate';
138
+ headline = 'ANOMALY CANDIDATE';
139
+ explanation = findingExplanation(String(findings[0]?.kind || ''));
140
+ exitCode = 1;
141
+ }
142
+ else if (summary.caseDesignOutcome === 'no-supported-cases' || Number(summary.scenarios || 0) === 0) {
143
+ agentVerdict = 'inconclusive';
144
+ headline = 'VERIFICATION INCONCLUSIVE';
145
+ explanation = 'The verifier could not design and execute a supported behavioral case.';
146
+ exitCode = 2;
147
+ }
148
+ else if (confirmations.length > 0) {
149
+ agentVerdict = 'fix_confirmed';
150
+ headline = 'FIX CONFIRMED';
151
+ explanation = 'The intended behavior change was observed on output paths that did not vary between control-before and before.';
152
+ exitCode = 0;
153
+ }
154
+ else {
155
+ agentVerdict = 'no_unexpected_change';
156
+ headline = 'NO UNEXPECTED CHANGE';
157
+ explanation = 'The executed cases found no observed difference outside their registered expectations.';
158
+ exitCode = 0;
159
+ }
160
+ return {
161
+ ok: exitCode === 0,
162
+ status: 'complete',
163
+ verdict: agentVerdict,
164
+ headline,
165
+ explanation,
166
+ runId: run.runId,
167
+ repository,
168
+ pullRequest: run.pr,
169
+ revisions: { base: run.baseSha, head: run.headSha },
170
+ durationMs: run.durationMs,
171
+ timings: run.result.timings || null,
172
+ changedFiles: run.result.changedFiles,
173
+ cases,
174
+ inspectCommand: `haystack design-verify ${repository}#${run.pr} --run ${run.runId}`,
175
+ replay: run.result.replay,
176
+ error: null,
177
+ exitCode,
178
+ };
179
+ }
180
+ function displayValue(value) {
181
+ const encoded = JSON.stringify(value);
182
+ return encoded === undefined ? String(value) : encoded;
183
+ }
184
+ function durationLabel(durationMs) {
185
+ if (durationMs === null)
186
+ return '';
187
+ const totalSeconds = Math.round(durationMs / 1000);
188
+ const minutes = Math.floor(totalSeconds / 60);
189
+ const seconds = totalSeconds % 60;
190
+ return minutes ? `${minutes}m ${seconds}s` : `${seconds}s`;
191
+ }
192
+ function preciseDurationLabel(durationMs) {
193
+ if (durationMs === null)
194
+ return 'not recorded';
195
+ if (durationMs < 1000)
196
+ return `${Number(durationMs.toFixed(3))}ms`;
197
+ const minutes = Math.floor(durationMs / 60_000);
198
+ const seconds = Number(((durationMs % 60_000) / 1000).toFixed(3));
199
+ return minutes ? `${minutes}m ${seconds}s` : `${seconds}s`;
200
+ }
201
+ function timingStageLabel(stage) {
202
+ if (stage === 'blast-radius')
203
+ return 'blast radius';
204
+ if (stage === 'case-design')
205
+ return 'case design';
206
+ return stage;
207
+ }
208
+ function commandLabel(command) {
209
+ if (!command)
210
+ return null;
211
+ return command.argv.map((arg) => (/^[A-Za-z0-9_./:=+-]+$/.test(arg) ? arg : JSON.stringify(arg))).join(' ');
212
+ }
213
+ function executionSideLabel(side) {
214
+ if (side === 'base1')
215
+ return 'before';
216
+ if (side === 'base2')
217
+ return 'control-before';
218
+ return 'after';
219
+ }
220
+ function executionNeedsAttention(execution) {
221
+ return execution.status !== 'completed' || execution.diagnostics.length > 0;
222
+ }
223
+ export function formatDesignVerifierAgentReport(report) {
224
+ const timing = durationLabel(report.durationMs);
225
+ const lines = [
226
+ `${report.exitCode === 0 ? '✓' : report.exitCode === 1 ? '✗' : '?'} ${report.headline}${timing ? ` ${timing}` : ''}`,
227
+ '',
228
+ report.explanation,
229
+ ];
230
+ if (report.revisions.base || report.revisions.head) {
231
+ lines.push('', `Revisions: before ${report.revisions.base || 'unknown'} (control + comparison)`, ` after ${report.revisions.head || 'unknown'} (comparison)`);
232
+ }
233
+ if (report.timings) {
234
+ lines.push('', 'Timing breakdown:');
235
+ if (report.timings.admissionMs !== null) {
236
+ lines.push(` admission: ${preciseDurationLabel(report.timings.admissionMs)}`);
237
+ }
238
+ for (const stage of report.timings.stages) {
239
+ lines.push(` ${timingStageLabel(stage.stage)}: ${preciseDurationLabel(stage.durationMs)} (${stage.status})`);
240
+ }
241
+ lines.push(` pipeline total: ${preciseDurationLabel(report.timings.pipelineDurationMs)}`);
242
+ if (report.timings.unattributedMs !== null) {
243
+ lines.push(` orchestration/finalization not recorded in a phase: ${preciseDurationLabel(report.timings.unattributedMs)}`);
244
+ }
245
+ }
246
+ for (const testCase of report.cases) {
247
+ lines.push('', `Test: ${testCase.title}`);
248
+ if (testCase.story)
249
+ lines.push(` Behavior: ${testCase.story}`);
250
+ if (testCase.expectedHead)
251
+ lines.push(` Expected on head: ${testCase.expectedHead}`);
252
+ if (testCase.findingKinds.length > 0) {
253
+ lines.push(` Candidate reasons: ${testCase.findingKinds.join(', ')}`);
254
+ }
255
+ if (testCase.observations.length) {
256
+ lines.push(' Observed:');
257
+ for (const observation of testCase.observations) {
258
+ lines.push(` ${observation.path}`);
259
+ lines.push(` control-before: ${displayValue(observation.baseRepeat)}`);
260
+ lines.push(` before: ${displayValue(observation.base)}`);
261
+ lines.push(` after: ${displayValue(observation.head)}`);
262
+ if (observation.controlObservedAtSamePath || observation.controlObservedAtAncestorPath ||
263
+ observation.controlObservedAtDescendantPath) {
264
+ lines.push(` control evidence: ${observation.controlObservedAtSamePath
265
+ ? 'variation observed at this same path'
266
+ : observation.controlObservedAtAncestorPath
267
+ ? 'variation observed at an ancestor path'
268
+ : 'variation observed at a descendant path'}`);
269
+ }
270
+ }
271
+ }
272
+ if (testCase.baseStable !== null) {
273
+ lines.push(` Control evidence: ${testCase.baseStable
274
+ ? 'control-before and before agreed'
275
+ : 'control-before and before varied; inspect alongside the difference'}`);
276
+ }
277
+ if (testCase.infrastructure) {
278
+ lines.push(testCase.infrastructure.verified
279
+ ? ` Infrastructure: verified; same recipe, ${testCase.infrastructure.freshInstances} fresh instances`
280
+ : ` Infrastructure: unverified; ${testCase.infrastructure.errors.join('; ') || 'receipt missing'}`);
281
+ }
282
+ const executionsWithDiagnostics = testCase.executions.filter(executionNeedsAttention);
283
+ if (executionsWithDiagnostics.length) {
284
+ lines.push(' Execution diagnostics:');
285
+ for (const execution of executionsWithDiagnostics) {
286
+ const exit = execution.commandExitCode === null ? 'exit unknown' : `exit ${execution.commandExitCode}`;
287
+ const elapsed = execution.durationMs === null ? '' : `, ${durationLabel(execution.durationMs)}`;
288
+ lines.push(` ${executionSideLabel(execution.side)} (${exit}${elapsed}):`);
289
+ if (execution.scenarioError)
290
+ lines.push(` Scenario error: ${execution.scenarioError}`);
291
+ for (const diagnostic of execution.diagnostics) {
292
+ const identity = [diagnostic.type, diagnostic.code].filter(Boolean).join(', ');
293
+ lines.push(` ${diagnostic.kind}${identity ? ` (${identity})` : ''}: ${diagnostic.message}`);
294
+ }
295
+ if (execution.diagnosticsOmitted > 0) {
296
+ lines.push(` ... ${execution.diagnosticsOmitted} additional diagnostics omitted`);
297
+ }
298
+ if (execution.outputExcerpt.length) {
299
+ lines.push(' Relevant command output:');
300
+ for (const output of execution.outputExcerpt) {
301
+ lines.push(` ${output.stream}: ${output.text}`);
302
+ }
303
+ if (execution.outputLinesOmitted > 0) {
304
+ lines.push(` ... ${execution.outputLinesOmitted} output lines omitted`);
305
+ }
306
+ }
307
+ lines.push(` Full output artifact: ${execution.outputArtifact}`);
308
+ lines.push(` Structured trace artifact: ${execution.traceArtifact}`);
309
+ }
310
+ }
311
+ if (testCase.generatedFiles.length)
312
+ lines.push(` Generated probe artifact: ${testCase.generatedFiles.join(', ')}`);
313
+ const command = commandLabel(testCase.executedCommand);
314
+ if (command)
315
+ lines.push(` Executed in the sandbox: ${command}`);
316
+ for (const universe of testCase.universes.filter(item => item.lease.available)) {
317
+ lines.push(` Replay ${universe.role}: haystack design-verify ${report.repository}#${report.pullRequest}` +
318
+ ` --run ${report.runId} --case ${testCase.id} --universe ${universe.role} --replay`);
319
+ }
320
+ }
321
+ if (report.changedFiles.length) {
322
+ lines.push('', 'Changed files to inspect:', ...report.changedFiles.map((file) => ` ${file}`));
323
+ }
324
+ lines.push('', `Inspect this exact result again: ${report.inspectCommand}`, `Run: ${report.runId}`);
325
+ return lines.join('\n');
326
+ }
327
+ export function designVerifierStageLabel(stage) {
328
+ if (!stage)
329
+ return 'Waiting for the run to start';
330
+ if (stage === 'prepare' || stage === 'bake')
331
+ return 'Preparing the repository';
332
+ if (stage === 'blast-radius')
333
+ return 'Mapping affected behavior';
334
+ if (stage === 'prefetch')
335
+ return 'Retrieving relevant failure patterns';
336
+ if (stage === 'stage1')
337
+ return 'Designing behavioral test cases';
338
+ if (stage.startsWith('build:'))
339
+ return 'Building an executable probe';
340
+ if (stage.startsWith('execute:'))
341
+ return 'Running control-before/before/after comparisons';
342
+ if (stage === 'verdict')
343
+ return 'Comparing observations';
344
+ return stage;
345
+ }
@@ -93,6 +93,10 @@ export declare function getLastCommitSha(): string;
93
93
  * Returns the combined log, or empty string if no commits or on error.
94
94
  */
95
95
  export declare function getCommitMessagesSinceBase(baseBranch: string): string;
96
+ interface ResolveDiffBaseRefOptions {
97
+ cwd?: string;
98
+ timeoutMs?: number;
99
+ }
96
100
  /**
97
101
  * Resolve the git ref to diff a PR branch against for review.
98
102
  *
@@ -124,7 +128,7 @@ export declare function getCommitMessagesSinceBase(baseBranch: string): string;
124
128
  * 4. Fall back to whatever single qualified ref resolves, then the bare
125
129
  * <base> name (preserves prior behavior rather than breaking triage).
126
130
  */
127
- export declare function resolveDiffBaseRef(baseBranch: string): string;
131
+ export declare function resolveDiffBaseRef(baseBranch: string, options?: ResolveDiffBaseRefOptions): string;
128
132
  /**
129
133
  * Get the number of commits ahead of base branch
130
134
  */
package/dist/utils/git.js CHANGED
@@ -376,11 +376,18 @@ export function getCommitMessagesSinceBase(baseBranch) {
376
376
  }
377
377
  }
378
378
  }
379
+ function gitCommandOptions(options) {
380
+ return {
381
+ ...(options.cwd ? { cwd: options.cwd } : {}),
382
+ ...(options.timeoutMs ? { timeout: options.timeoutMs } : {}),
383
+ stdio: ['pipe', 'pipe', 'pipe'],
384
+ };
385
+ }
379
386
  /** True if `ref` resolves to a commit in the current repo. */
380
- function gitRefResolves(ref) {
387
+ function gitRefResolves(ref, options = {}) {
381
388
  try {
382
389
  execFileSync('git', ['rev-parse', '--verify', '--quiet', '--', `${ref}^{commit}`], {
383
- stdio: ['pipe', 'pipe', 'pipe'],
390
+ ...gitCommandOptions(options),
384
391
  });
385
392
  return true;
386
393
  }
@@ -389,11 +396,11 @@ function gitRefResolves(ref) {
389
396
  }
390
397
  }
391
398
  /** True if `ancestor` is an ancestor of (or equal to) `descendant`. */
392
- function gitIsAncestor(ancestor, descendant) {
399
+ function gitIsAncestor(ancestor, descendant, options = {}) {
393
400
  try {
394
401
  // exit 0 => ancestor; exit 1 => not; both non-throwing for us via stdio pipe
395
402
  execFileSync('git', ['merge-base', '--is-ancestor', '--', ancestor, descendant], {
396
- stdio: ['pipe', 'pipe', 'pipe'],
403
+ ...gitCommandOptions(options),
397
404
  });
398
405
  return true;
399
406
  }
@@ -432,7 +439,7 @@ function gitIsAncestor(ancestor, descendant) {
432
439
  * 4. Fall back to whatever single qualified ref resolves, then the bare
433
440
  * <base> name (preserves prior behavior rather than breaking triage).
434
441
  */
435
- export function resolveDiffBaseRef(baseBranch) {
442
+ export function resolveDiffBaseRef(baseBranch, options = {}) {
436
443
  const remoteRef = `refs/remotes/origin/${baseBranch}`;
437
444
  const localRef = `refs/heads/${baseBranch}`;
438
445
  // Freshen the remote base so the merge-base reflects the true fork point.
@@ -451,7 +458,10 @@ export function resolveDiffBaseRef(baseBranch) {
451
458
  let fetched = false;
452
459
  try {
453
460
  execFileSync('git', ['fetch', 'origin', `+refs/heads/${baseBranch}:${remoteRef}`], {
454
- stdio: ['pipe', 'pipe', 'pipe'],
461
+ ...gitCommandOptions(options),
462
+ env: options.timeoutMs
463
+ ? { ...process.env, GIT_TERMINAL_PROMPT: '0' }
464
+ : process.env,
455
465
  });
456
466
  fetched = true;
457
467
  }
@@ -459,17 +469,17 @@ export function resolveDiffBaseRef(baseBranch) {
459
469
  // Offline / no creds / base not on remote — fall through to degraded
460
470
  // selection below using whatever refs already exist locally.
461
471
  }
462
- const remoteResolves = gitRefResolves(remoteRef);
472
+ const remoteResolves = gitRefResolves(remoteRef, options);
463
473
  // Happy path: we just refreshed the remote base, so it's authoritative.
464
474
  if (fetched && remoteResolves)
465
475
  return remoteRef;
466
476
  // Degraded path: fetch failed (or didn't produce the ref). Prefer the more
467
477
  // up-to-date of the cached remote ref and the local branch; only a base that
468
478
  // lags HEAD's fork point balloons the diff, so pick the one that's ahead.
469
- const localResolves = gitRefResolves(localRef);
479
+ const localResolves = gitRefResolves(localRef, options);
470
480
  if (remoteResolves && localResolves) {
471
481
  // If the remote ref is an ancestor of the local ref, local is ahead → use it.
472
- return gitIsAncestor(remoteRef, localRef) ? localRef : remoteRef;
482
+ return gitIsAncestor(remoteRef, localRef, options) ? localRef : remoteRef;
473
483
  }
474
484
  if (remoteResolves)
475
485
  return remoteRef;
@@ -86,10 +86,7 @@ async function githubApiRequest(method, path, token, body) {
86
86
  const proxied = await fetch(`${GITHUB_PROXY_BASE}${path}`, init);
87
87
  return proxied.status === 403 ? direct : proxied;
88
88
  }
89
- /**
90
- * Handle API errors consistently
91
- */
92
- async function handleApiError(response, context) {
89
+ async function handleApiError(response, context, operation) {
93
90
  if (response.status === 401) {
94
91
  throw new Error('GitHub token expired or invalid. Run `haystack login` again.');
95
92
  }
@@ -100,7 +97,7 @@ async function handleApiError(response, context) {
100
97
  // whether the CLI needs its own token bucket. Routes to PostHog (and on to
101
98
  // Slack via the ops-alert pipeline). Fire-and-forget; never blocks.
102
99
  trackError('cli_github_rate_limited', {
103
- context,
100
+ operation,
104
101
  status: 403,
105
102
  rate_limit_limit: response.headers.get('x-ratelimit-limit'),
106
103
  rate_limit_reset: response.headers.get('x-ratelimit-reset'),
@@ -149,7 +146,7 @@ export async function createPullRequest(options) {
149
146
  `Push to a new branch name and try again.`);
150
147
  }
151
148
  }
152
- await handleApiError(response, `creating PR in ${options.owner}/${options.repo}`);
149
+ await handleApiError(response, `creating PR in ${options.owner}/${options.repo}`, 'create_pull_request');
153
150
  }
154
151
  return response.json();
155
152
  }
@@ -190,7 +187,7 @@ export async function findExistingPR(owner, repo, head, token) {
190
187
  if (!response.ok) {
191
188
  if (response.status === 404)
192
189
  return null;
193
- await handleApiError(response, `checking for existing PR`);
190
+ await handleApiError(response, 'checking for existing PR', 'find_existing_pull_request');
194
191
  }
195
192
  const prs = await response.json();
196
193
  return prs.length > 0 ? prs[0] : null;
@@ -205,7 +202,7 @@ export async function addLabelsToIssue(owner, repo, issueNumber, labels, token)
205
202
  const authToken = await resolveToken(token, { owner, repo });
206
203
  const response = await githubApiRequest('POST', `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, authToken, { labels });
207
204
  if (!response.ok) {
208
- await handleApiError(response, `adding labels to PR #${issueNumber}`);
205
+ await handleApiError(response, `adding labels to PR #${issueNumber}`, 'add_labels');
209
206
  }
210
207
  }
211
208
  /**
@@ -215,7 +212,7 @@ export async function getLabelsFromIssue(owner, repo, issueNumber, token) {
215
212
  const authToken = await resolveToken(token, { owner, repo });
216
213
  const response = await githubApiRequest('GET', `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, authToken);
217
214
  if (!response.ok) {
218
- await handleApiError(response, `getting labels from PR #${issueNumber}`);
215
+ await handleApiError(response, `getting labels from PR #${issueNumber}`, 'get_labels');
219
216
  }
220
217
  const labels = await response.json();
221
218
  return labels.map((l) => l.name);
@@ -228,7 +225,7 @@ export async function removeLabelFromIssue(owner, repo, issueNumber, labelName,
228
225
  const response = await githubApiRequest('DELETE', `/repos/${owner}/${repo}/issues/${issueNumber}/labels/${encodeURIComponent(labelName)}`, authToken);
229
226
  // 404 is fine - label wasn't on the issue
230
227
  if (!response.ok && response.status !== 404) {
231
- await handleApiError(response, `removing label '${labelName}' from PR #${issueNumber}`);
228
+ await handleApiError(response, `removing label '${labelName}' from PR #${issueNumber}`, 'remove_label');
232
229
  }
233
230
  }
234
231
  /**
@@ -251,7 +248,7 @@ export async function createLabel(owner, repo, label, token) {
251
248
  });
252
249
  if (!response.ok && response.status !== 422) {
253
250
  // 422 means label already exists, which is fine
254
- await handleApiError(response, `creating label '${label.name}'`);
251
+ await handleApiError(response, `creating label '${label.name}'`, 'create_label');
255
252
  }
256
253
  }
257
254
  /**
@@ -278,7 +275,7 @@ export async function requestReviewers(owner, repo, prNumber, reviewers, token)
278
275
  const authToken = await resolveToken(token, { owner, repo });
279
276
  const response = await githubApiRequest('POST', `/repos/${owner}/${repo}/pulls/${prNumber}/requested_reviewers`, authToken, { reviewers });
280
277
  if (!response.ok) {
281
- await handleApiError(response, `requesting reviewers on PR #${prNumber}`);
278
+ await handleApiError(response, `requesting reviewers on PR #${prNumber}`, 'request_reviewers');
282
279
  }
283
280
  }
284
281
  // ============================================================================
@@ -291,7 +288,7 @@ export async function getCurrentUser(token) {
291
288
  const authToken = await resolveToken(token);
292
289
  const response = await githubApiRequest('GET', '/user', authToken);
293
290
  if (!response.ok) {
294
- await handleApiError(response, 'getting current user');
291
+ await handleApiError(response, 'getting current user', 'get_current_user');
295
292
  }
296
293
  return response.json();
297
294
  }
@@ -299,7 +296,7 @@ export async function getRepositoryAccess(owner, repo, token) {
299
296
  const authToken = await resolveToken(token, { owner, repo });
300
297
  const response = await githubApiRequest('GET', `/repos/${owner}/${repo}`, authToken);
301
298
  if (!response.ok) {
302
- await handleApiError(response, `getting repository access for ${owner}/${repo}`);
299
+ await handleApiError(response, `getting repository access for ${owner}/${repo}`, 'get_repository_access');
303
300
  }
304
301
  return response.json();
305
302
  }
@@ -1,3 +1,5 @@
1
+ export declare function haystackApiBase(): string;
2
+ export declare function haystackApiUrl(path: string): string;
1
3
  export interface UserInstallation {
2
4
  id: number;
3
5
  account?: {
@@ -1,4 +1,11 @@
1
- const HAYSTACK_API_BASE = process.env.HAYSTACK_API_BASE ?? 'https://haystackeditor.com';
1
+ export function haystackApiBase() {
2
+ return process.env.HAYSTACK_API_BASE ?? 'https://haystackeditor.com';
3
+ }
4
+ export function haystackApiUrl(path) {
5
+ if (!path.startsWith('/'))
6
+ throw new Error('Haystack API path must be absolute');
7
+ return `${haystackApiBase()}${path}`;
8
+ }
2
9
  /**
3
10
  * Typed error for non-OK Haystack API responses. Callers classify by
4
11
  * `status` (never by message substring) and the message always carries a
@@ -70,7 +77,7 @@ export async function classifyHttpError(response, context) {
70
77
  return new HaystackApiError(response.status, `${context}: unexpected response (HTTP ${response.status})${suffix}.`);
71
78
  }
72
79
  export async function haystackJson(path, token, init = {}, acceptedStatuses = []) {
73
- const response = await fetch(`${HAYSTACK_API_BASE}${path}`, {
80
+ const response = await fetch(haystackApiUrl(path), {
74
81
  ...init,
75
82
  headers: headers(token, init.headers),
76
83
  });
@@ -91,7 +98,7 @@ export async function fetchGitHubJson(path, token, init = {}) {
91
98
  }
92
99
  export async function fetchAnalysisArtifact(owner, repo, prNumber, file, token) {
93
100
  const ref = encodeURIComponent(`${owner}/${repo}#${prNumber}`);
94
- const response = await fetch(`${HAYSTACK_API_BASE}/api/analysis/${ref}/${file}`, {
101
+ const response = await fetch(haystackApiUrl(`/api/analysis/${ref}/${file}`), {
95
102
  headers: headers(token, { 'Cache-Control': 'no-cache' }),
96
103
  });
97
104
  if (response.status === 404)
@@ -1,17 +1,7 @@
1
1
  /**
2
- * Utilities for haystack hooks install/status/update commands.
3
- *
4
- * Handles Entire CLI binary download, hook file copying, and git configuration.
2
+ * Utilities for haystack hooks install/status commands.
5
3
  */
6
- export declare const ENTIRE_DEFAULT_VERSION = "0.4.8";
7
- export declare const ENTIRE_GITHUB_REPO = "entireio/cli";
8
- export declare const HAYSTACK_BIN_DIR: string;
9
- export declare const ENTIRE_BIN_PATH: string;
10
- export declare function getPlatformAssetName(): string;
11
4
  export declare function findGitRoot(): string | null;
12
- export declare function getInstalledEntireVersion(): Promise<string | null>;
13
- export declare function getLatestEntireVersion(): Promise<string>;
14
- export declare function downloadEntireBinary(version: string): Promise<void>;
15
5
  /** Resolves the path to dist/assets/hooks/ (or src/assets/hooks/ in dev) */
16
6
  export declare function getAssetsDir(): string;
17
7
  /** Copy hook scripts and TypeScript modules into the target hooks directory */
@@ -20,7 +10,3 @@ export declare function copyHookFiles(hooksDir: string): Promise<void>;
20
10
  export declare function installHookDeps(hooksDir: string): Promise<void>;
21
11
  /** Copy LLM rules template if none exists */
22
12
  export declare function copyLlmRulesTemplate(repoRoot: string): Promise<boolean>;
23
- /** Create .entire/settings.json and .entire/.gitignore */
24
- export declare function createEntireConfig(repoRoot: string): Promise<void>;
25
- /** Add .entire/metadata/ to repo .gitignore if not already present */
26
- export declare function updateGitignore(repoRoot: string): Promise<boolean>;