@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
@@ -0,0 +1,115 @@
1
+ import { scanForSecrets } from './secrets.js';
2
+ /**
3
+ * File names that conventionally hold credentials. The precompute upload
4
+ * withholds these regardless of content, because a scanner cannot recognise
5
+ * every secret format (a raw key file has no telltale prefix).
6
+ */
7
+ const EXACT_NAMES = new Set([
8
+ '.env',
9
+ 'credentials.json',
10
+ '.npmrc',
11
+ '.pypirc',
12
+ '.netrc',
13
+ ]);
14
+ // Committed templates are how teams document which variables a service needs.
15
+ const ENV_TEMPLATES = new Set(['.env.example', '.env.sample']);
16
+ const SUFFIXES = ['.pem', '.key', '.p12', '.pfx', '.keystore', '.tfvars'];
17
+ const PREFIXES = ['id_rsa', 'id_ed25519', 'secrets.'];
18
+ /** True when a repository-relative path names a conventionally secret-bearing file. */
19
+ export function isSecretBearingPath(path) {
20
+ const segments = path.split('/').filter(segment => segment.length > 0);
21
+ if (segments.length === 0)
22
+ return false;
23
+ for (let index = 0; index + 1 < segments.length; index += 1) {
24
+ if (segments[index] === '.haystack' && segments[index + 1] === 'secrets')
25
+ return true;
26
+ }
27
+ const name = segments[segments.length - 1].toLowerCase();
28
+ if (EXACT_NAMES.has(name))
29
+ return true;
30
+ if (name.startsWith('.env.'))
31
+ return !ENV_TEMPLATES.has(name);
32
+ if (SUFFIXES.some(suffix => name.endsWith(suffix)))
33
+ return true;
34
+ if (PREFIXES.some(prefix => name.startsWith(prefix)))
35
+ return true;
36
+ return name.startsWith('service-account') && name.endsWith('.json');
37
+ }
38
+ const GIT_ESCAPES = {
39
+ a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92,
40
+ };
41
+ /** Reverses git's C-style path quoting (named escapes and octal bytes). */
42
+ function unquoteGitPath(quoted) {
43
+ const bytes = [];
44
+ for (let index = 0; index < quoted.length; index += 1) {
45
+ const character = quoted[index];
46
+ if (character !== '\\') {
47
+ bytes.push(...Buffer.from(character, 'utf8'));
48
+ continue;
49
+ }
50
+ const next = quoted[index + 1];
51
+ const octal = quoted.slice(index + 1, index + 4);
52
+ if (next !== undefined && next in GIT_ESCAPES) {
53
+ bytes.push(GIT_ESCAPES[next]);
54
+ index += 1;
55
+ }
56
+ else if (/^[0-7]{3}$/.test(octal)) {
57
+ bytes.push(Number.parseInt(octal, 8));
58
+ index += 3;
59
+ }
60
+ else {
61
+ throw new Error('Unrecognised escape in a git path.');
62
+ }
63
+ }
64
+ return Buffer.from(bytes).toString('utf8');
65
+ }
66
+ /** Path named by a `diff --git a/P b/P` header produced with --no-renames. */
67
+ export function pathFromDiffHeader(header) {
68
+ const prefix = 'diff --git ';
69
+ if (!header.startsWith(prefix))
70
+ throw new Error('Not a diff header.');
71
+ const rest = header.slice(prefix.length);
72
+ // Without renames both sides name the same path, so the header is symmetric.
73
+ const side = rest.slice(0, (rest.length - 1) / 2);
74
+ const quoted = side.startsWith('"');
75
+ const other = quoted ? `"b/${side.slice(3)}` : `b/${side.slice(2)}`;
76
+ if (rest !== `${side} ${other}` || !side.startsWith(quoted ? '"a/' : 'a/')) {
77
+ throw new Error('Unexpected diff header.');
78
+ }
79
+ return quoted ? unquoteGitPath(side.slice(1, -1)).slice(2) : side.slice(2);
80
+ }
81
+ /**
82
+ * Scans only the lines a `git diff --no-renames` patch adds and returns, per
83
+ * file, the name of the first high-severity secret pattern found. The matched
84
+ * value never leaves this function.
85
+ */
86
+ export function findSecretsInPatch(patch) {
87
+ const hits = new Map();
88
+ let currentPath;
89
+ let inHunk = false;
90
+ let added = [];
91
+ const flush = () => {
92
+ if (currentPath === undefined || added.length === 0)
93
+ return;
94
+ const finding = scanForSecrets(added.join('\n'), currentPath)
95
+ .find(item => item.severity === 'high');
96
+ if (finding && !hits.has(currentPath))
97
+ hits.set(currentPath, finding.pattern);
98
+ };
99
+ for (const line of patch.split('\n')) {
100
+ if (line.startsWith('diff --git ')) {
101
+ flush();
102
+ currentPath = pathFromDiffHeader(line);
103
+ inHunk = false;
104
+ added = [];
105
+ }
106
+ else if (line.startsWith('@@')) {
107
+ inHunk = true;
108
+ }
109
+ else if (inHunk && line.startsWith('+')) {
110
+ added.push(line.slice(1));
111
+ }
112
+ }
113
+ flush();
114
+ return hits;
115
+ }
@@ -160,7 +160,6 @@ function isSafePattern(match) {
160
160
  */
161
161
  export function scanForSecrets(content, filename) {
162
162
  const findings = [];
163
- const lines = content.split('\n');
164
163
  for (const secretPattern of SECRET_PATTERNS) {
165
164
  // Reset regex state
166
165
  secretPattern.pattern.lastIndex = 0;
@@ -7,11 +7,11 @@
7
7
  * All calls are non-blocking and never throw — telemetry failures must not
8
8
  * break CLI commands.
9
9
  */
10
- import { readFileSync } from 'node:fs';
11
- import { dirname, resolve } from 'node:path';
10
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join, resolve } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
- import * as os from 'os';
14
- import { createHash } from 'node:crypto';
13
+ import * as os from 'node:os';
14
+ import { randomBytes } from 'node:crypto';
15
15
  const POSTHOG_HOST = 'https://us.i.posthog.com';
16
16
  const POSTHOG_API_KEY = process.env.HAYSTACK_POSTHOG_KEY || 'phc_7wHrCoMlcwuftLe6N38hPb9wKHb4OK0wNw9ayeRySad';
17
17
  const CURRENT_FILE_DIR = dirname(fileURLToPath(import.meta.url));
@@ -55,25 +55,45 @@ function minimizeMetadata(metadata) {
55
55
  }
56
56
  return minimized;
57
57
  }
58
+ const TELEMETRY_ID_PATTERN = /^[0-9a-f]{24}$/;
59
+ let cachedTelemetryId;
58
60
  /**
59
- * Pseudonymous stable machine identifier for PostHog distinct_id.
60
- * The local username and hostname are never transmitted.
61
- * Never throws — falls back to hostname-only or 'anonymous'.
61
+ * Pseudonymous stable install identifier for PostHog distinct_id: 24 random
62
+ * hex chars generated once and kept in ~/.haystack/telemetry-id (0600). It is
63
+ * not derived from the username or hostname, so it cannot be reversed to
64
+ * them. A missing, unreadable or malformed file gets a fresh id. Never throws.
62
65
  */
63
66
  function getDistinctId() {
67
+ if (cachedTelemetryId)
68
+ return `cli:${cachedTelemetryId}`;
69
+ let idPath;
64
70
  try {
65
- const hostname = os.hostname();
71
+ idPath = join(os.homedir(), '.haystack', 'telemetry-id');
72
+ const stored = readFileSync(idPath, 'utf8').trim();
73
+ if (TELEMETRY_ID_PATTERN.test(stored)) {
74
+ cachedTelemetryId = stored;
75
+ return `cli:${stored}`;
76
+ }
77
+ }
78
+ catch {
79
+ // Missing or unreadable: a new id is generated below.
80
+ cachedTelemetryId = undefined;
81
+ }
82
+ const fresh = randomBytes(12).toString('hex');
83
+ cachedTelemetryId = fresh;
84
+ if (idPath) {
66
85
  try {
67
- const localMachine = `${os.userInfo().username}\0${hostname}`;
68
- return `cli:${createHash('sha256').update(localMachine).digest('hex').slice(0, 24)}`;
86
+ mkdirSync(dirname(idPath), { recursive: true });
87
+ writeFileSync(idPath, `${fresh}\n`, { mode: 0o600 });
88
+ chmodSync(idPath, 0o600);
69
89
  }
70
90
  catch {
71
- return `cli:${createHash('sha256').update(hostname).digest('hex').slice(0, 24)}`;
91
+ // Could not persist (read-only home, etc.): this process keeps the
92
+ // random id and the next run tries again. Telemetry must not break the CLI.
93
+ cachedTelemetryId = fresh;
72
94
  }
73
95
  }
74
- catch {
75
- return 'cli:anonymous';
76
- }
96
+ return `cli:${fresh}`;
77
97
  }
78
98
  /**
79
99
  * Track an error event. Fire-and-forget — never throws.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * "New version available" notice.
3
+ *
4
+ * Runs after a command finishes, never before it starts. At most once per 24
5
+ * hours (timestamp in ~/.haystack/update-check.json, mode 0600) it asks the
6
+ * npm registry for the latest @haystackeditor/cli with a 1.5 second timeout,
7
+ * and prints one dim stderr line when that version is newer than this one.
8
+ *
9
+ * It stays off when stdout is not a TTY, when --json is passed, and when
10
+ * HAYSTACK_NO_UPDATE_CHECK or CI is set, so scripts and agents never see it.
11
+ * Failures print nothing unless DEBUG is set.
12
+ */
13
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { dirname, join } from 'node:path';
16
+ import chalk from 'chalk';
17
+ export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
18
+ export const UPDATE_CHECK_TIMEOUT_MS = 1500;
19
+ export const LATEST_URL = 'https://registry.npmjs.org/@haystackeditor/cli/latest';
20
+ /** Parse `X.Y.Z` or `X.Y.Z-pre`; anything else is not comparable. */
21
+ function parseVersion(version) {
22
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
23
+ if (!match)
24
+ return null;
25
+ return {
26
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
27
+ prerelease: match[4] !== undefined,
28
+ };
29
+ }
30
+ /**
31
+ * True when `latest` is a strictly newer release than `current`. A prerelease
32
+ * `latest` is never offered. A release with the same core as a prerelease
33
+ * `current` is newer (0.16.0 is newer than 0.16.0-rc.1).
34
+ */
35
+ export function isNewerVersion(latest, current) {
36
+ const l = parseVersion(latest);
37
+ const c = parseVersion(current);
38
+ if (!l || !c || l.prerelease)
39
+ return false;
40
+ for (let i = 0; i < 3; i++) {
41
+ if (l.core[i] !== c.core[i])
42
+ return l.core[i] > c.core[i];
43
+ }
44
+ return c.prerelease;
45
+ }
46
+ /** True when no check has run within the interval (or the clock went backwards). */
47
+ export function isCheckDue(state, now, intervalMs = UPDATE_CHECK_INTERVAL_MS) {
48
+ if (!state)
49
+ return true;
50
+ const elapsed = now - state.lastCheckedAt;
51
+ return elapsed < 0 || elapsed >= intervalMs;
52
+ }
53
+ export function isUpdateCheckEnabled({ argv, env, stdoutIsTTY }) {
54
+ if (!stdoutIsTTY)
55
+ return false;
56
+ if (env.HAYSTACK_NO_UPDATE_CHECK || env.CI)
57
+ return false;
58
+ return !argv.includes('--json');
59
+ }
60
+ export function defaultStatePath() {
61
+ return join(homedir(), '.haystack', 'update-check.json');
62
+ }
63
+ function readState(statePath, debug) {
64
+ let raw;
65
+ try {
66
+ raw = readFileSync(statePath, 'utf8');
67
+ }
68
+ catch (err) {
69
+ if (err.code !== 'ENOENT') {
70
+ debug(`could not read ${statePath}: ${err instanceof Error ? err.message : String(err)}`);
71
+ }
72
+ return null;
73
+ }
74
+ try {
75
+ const parsed = JSON.parse(raw);
76
+ return typeof parsed.lastCheckedAt === 'number' ? { lastCheckedAt: parsed.lastCheckedAt } : null;
77
+ }
78
+ catch (err) {
79
+ debug(`ignoring unreadable ${statePath}: ${err instanceof Error ? err.message : String(err)}`);
80
+ return null;
81
+ }
82
+ }
83
+ function writeState(statePath, state) {
84
+ mkdirSync(dirname(statePath), { recursive: true, mode: 0o700 });
85
+ writeFileSync(statePath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
86
+ // writeFileSync only applies `mode` when it creates the file.
87
+ chmodSync(statePath, 0o600);
88
+ }
89
+ /**
90
+ * Returns the newer version when one should be announced, otherwise null.
91
+ * The timestamp is recorded before the request so an offline machine pays the
92
+ * timeout at most once per interval.
93
+ */
94
+ export async function checkForUpdate(deps) {
95
+ const { currentVersion, now, statePath, fetchLatest, debug } = deps;
96
+ if (!isCheckDue(readState(statePath, debug), now))
97
+ return null;
98
+ try {
99
+ writeState(statePath, { lastCheckedAt: now });
100
+ }
101
+ catch (err) {
102
+ debug(`could not write ${statePath}: ${err instanceof Error ? err.message : String(err)}`);
103
+ return null;
104
+ }
105
+ const controller = new AbortController();
106
+ const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
107
+ try {
108
+ const latest = await fetchLatest(controller.signal);
109
+ return isNewerVersion(latest, currentVersion) ? latest : null;
110
+ }
111
+ catch (err) {
112
+ debug(`registry lookup failed: ${err instanceof Error ? err.message : String(err)}`);
113
+ return null;
114
+ }
115
+ finally {
116
+ clearTimeout(timer);
117
+ }
118
+ }
119
+ async function fetchLatestFromRegistry(signal) {
120
+ const response = await fetch(LATEST_URL, { signal, headers: { accept: 'application/json' } });
121
+ if (!response.ok)
122
+ throw new Error(`registry returned HTTP ${response.status}`);
123
+ const body = (await response.json());
124
+ if (typeof body.version !== 'string')
125
+ throw new Error('registry response has no version');
126
+ return body.version;
127
+ }
128
+ /** Entry point for src/index.ts, called once the command has finished. */
129
+ export async function notifyIfUpdateAvailable(currentVersion) {
130
+ const environment = {
131
+ argv: process.argv.slice(2),
132
+ env: process.env,
133
+ stdoutIsTTY: process.stdout.isTTY === true,
134
+ };
135
+ if (!isUpdateCheckEnabled(environment))
136
+ return;
137
+ const debug = (message) => {
138
+ if (process.env.DEBUG)
139
+ console.error(`haystack: update check: ${message}`);
140
+ };
141
+ const newer = await checkForUpdate({
142
+ currentVersion,
143
+ now: Date.now(),
144
+ statePath: defaultStatePath(),
145
+ fetchLatest: fetchLatestFromRegistry,
146
+ debug,
147
+ });
148
+ if (!newer)
149
+ return;
150
+ console.error(chalk.dim(`haystack ${newer} is available (you have ${currentVersion}). Update: npm i -g @haystackeditor/cli@latest`));
151
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.15.31",
3
+ "version": "0.17.0",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "scripts": {
10
10
  "build": "rm -rf dist && tsc && cp -r src/assets dist/assets",
11
11
  "dev": "tsc --watch",
12
+ "lint": "eslint src",
12
13
  "start": "node dist/index.js",
13
14
  "benchmark:telemetry": "pnpm build && node scripts/benchmark-telemetry-overhead.mjs",
14
15
  "benchmark:telemetry:corpus": "pnpm build && node scripts/benchmark-open-source-telemetry.mjs",
@@ -39,23 +40,27 @@
39
40
  "url": "https://github.com/haystackeditor/haystack-review/issues"
40
41
  },
41
42
  "dependencies": {
42
- "@babel/core": "7.29.0",
43
+ "@aws-sdk/client-ssm": "3.987.0",
44
+ "@babel/core": "8.0.6",
45
+ "@inquirer/prompts": "8.7.2",
43
46
  "@modelcontextprotocol/sdk": "1.26.0",
44
- "chalk": "5.6.2",
45
- "commander": "12.1.0",
47
+ "chalk": "6.0.0",
48
+ "commander": "15.0.0",
46
49
  "fast-glob": "3.3.3",
47
- "glob": "10.5.0",
48
- "inquirer": "9.3.8",
49
- "ws": "8.21.0",
50
- "yaml": "2.8.2",
51
- "zod": "3.25.76"
50
+ "ws": "8.21.3",
51
+ "yaml": "2.9.1",
52
+ "zod": "4.6.5"
52
53
  },
53
54
  "devDependencies": {
54
- "@types/inquirer": "9.0.9",
55
- "@types/node": "20.19.30",
55
+ "@eslint/js": "8.57.1",
56
+ "@types/node": "22.19.8",
56
57
  "@types/ws": "8.18.1",
57
- "typescript": "5.9.3",
58
- "vitest": "4.1.10"
58
+ "@typescript-eslint/eslint-plugin": "7.18.0",
59
+ "@typescript-eslint/parser": "7.18.0",
60
+ "eslint": "8.57.1",
61
+ "globals": "13.24.0",
62
+ "typescript": "6.0.3",
63
+ "vitest": "5.0.1"
59
64
  },
60
65
  "files": [
61
66
  "dist",
@@ -63,6 +68,6 @@
63
68
  ],
64
69
  "bundledDependencies": [],
65
70
  "engines": {
66
- "node": ">=18"
71
+ "node": ">=22.18"
67
72
  }
68
73
  }
@@ -0,0 +1,235 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://docs.haystackeditor.com/cli/schemas/case-batch.v1.json",
4
+ "title": "Haystack hosted case batch (CASE-BATCH-V1)",
5
+ "description": "Machine-readable output of `haystack case-batch submit|status|cancel|finalize|bundle --json`. The run-side shapes mirror infra/lambda/haystack-design-verifier-shared/case_batch_contracts.ts.",
6
+ "type": "object",
7
+ "required": ["schema_version", "operation", "run_id"],
8
+ "properties": {
9
+ "schema_version": { "const": "1.0.1" },
10
+ "operation": { "enum": ["submit", "status", "cancel", "finalize", "bundle"] },
11
+ "run_id": { "type": "string", "pattern": "^cv_[0-9a-f]{48}$" },
12
+ "repository": { "type": "string", "pattern": "^[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}$" },
13
+ "admitted": { "enum": ["created", "existing"] },
14
+ "base_commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
15
+ "head_commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
16
+ "base_world": { "type": "string", "pattern": "^frz_[0-9a-f]{16}$" },
17
+ "head_world": { "type": "string", "pattern": "^frz_[0-9a-f]{16}$" },
18
+ "idempotency_key": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$" },
19
+ "cases": {
20
+ "description": "On submit, the number of cases sent. On status, the per-case rows.",
21
+ "oneOf": [
22
+ { "type": "integer", "minimum": 1, "maximum": 5000 },
23
+ {
24
+ "type": "array",
25
+ "items": {
26
+ "type": "object",
27
+ "required": ["id", "position", "status", "outcome_status", "unknown_reason", "wall_ms", "cleanup_proven"],
28
+ "properties": {
29
+ "id": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,160}$" },
30
+ "position": { "type": "integer", "minimum": 0, "maximum": 4999 },
31
+ "label": { "type": "string" },
32
+ "condition_id": { "type": "string" },
33
+ "status": { "enum": ["running", "completed", "incomplete"] },
34
+ "launched_at_ms": { "type": "integer", "minimum": 0 },
35
+ "finished_at_ms": { "type": ["integer", "null"], "minimum": 0 },
36
+ "outcome_status": { "enum": ["AGREE", "DIFFER", "UNKNOWN", null] },
37
+ "unknown_reason": {
38
+ "type": ["string", "null"],
39
+ "description": "Open by ruling 1: an unrecognised reason still reads as UNKNOWN."
40
+ },
41
+ "wall_ms": { "type": ["integer", "null"], "minimum": 0 },
42
+ "cleanup_proven": { "type": ["boolean", "null"] },
43
+ "comparison_path": { "type": ["string", "null"] },
44
+ "case_bundle_path": { "type": ["string", "null"] },
45
+ "differing_outcomes": { "type": "array", "items": { "type": "string" } },
46
+ "detail": { "type": ["string", "null"] },
47
+ "execution_path": { "enum": ["resident-sealed-plan", "sealed-plan", "record-replay", null] },
48
+ "node_ids": {
49
+ "description": "v4: the node each arm's VM was placed on; null when the outcome names none.",
50
+ "type": ["object", "null"],
51
+ "required": ["base1", "head"],
52
+ "properties": {
53
+ "base1": { "type": ["string", "null"] },
54
+ "head": { "type": ["string", "null"] }
55
+ }
56
+ },
57
+ "failure": {
58
+ "description": "v4: the typed failure behind an UNKNOWN that is not an arrival or wall outcome, and behind an arrival-failed whose two arms stalled on the same step (error_class same-step-stall); null otherwise. Source is open like unknown_reason: a source added later still reads.",
59
+ "type": ["object", "null"],
60
+ "required": ["source", "arm", "error_class", "error_code", "node_id"],
61
+ "properties": {
62
+ "source": { "type": "string", "description": "control-plane, transport, harness or runner" },
63
+ "arm": { "enum": ["base1", "head", null] },
64
+ "error_class": { "type": "string" },
65
+ "error_code": { "type": ["string", "null"] },
66
+ "node_id": { "type": ["string", "null"] }
67
+ }
68
+ },
69
+ "phases": { "type": ["object", "null"] },
70
+ "vm_ids": { "type": "array", "items": { "type": "string" } },
71
+ "lease_ids": { "type": "array", "items": { "type": "string" } }
72
+ }
73
+ }
74
+ }
75
+ ]
76
+ },
77
+ "limits": {
78
+ "type": "object",
79
+ "required": ["max_concurrent_cases", "per_case_wall_ms", "total_budget_ms"],
80
+ "properties": {
81
+ "max_concurrent_cases": { "type": "integer", "minimum": 1, "maximum": 500 },
82
+ "per_case_wall_ms": { "type": "integer", "minimum": 1000, "maximum": 600000 },
83
+ "total_budget_ms": { "type": "integer", "minimum": 10000, "maximum": 1800000 }
84
+ }
85
+ },
86
+ "source_patch_attached": { "type": "boolean" },
87
+ "status": { "enum": ["queued", "preparing", "searching", "draining", "completed", "incomplete", "cancelled"] },
88
+ "terminal": { "type": "boolean" },
89
+ "timed_out": { "type": "boolean" },
90
+ "generation": { "type": "integer", "minimum": 0 },
91
+ "observed_at": { "type": "string" },
92
+ "cleanup_complete": { "type": "boolean" },
93
+ "vm_ids": { "type": "integer", "minimum": 0 },
94
+ "worlds": {
95
+ "type": "object",
96
+ "properties": {
97
+ "base": { "type": ["string", "null"] },
98
+ "head": { "type": ["string", "null"] }
99
+ }
100
+ },
101
+ "clocks": {
102
+ "type": "object",
103
+ "properties": {
104
+ "preparation_started_at": { "type": ["string", "null"] },
105
+ "search_started_at": { "type": ["string", "null"] },
106
+ "launches_closed_at": { "type": ["string", "null"] },
107
+ "finished_at": { "type": ["string", "null"] },
108
+ "search_budget_ms": { "type": "integer", "minimum": 0 },
109
+ "search_wall_ms": { "type": "integer", "minimum": 0 },
110
+ "drain_wall_ms": { "type": "integer", "minimum": 0 }
111
+ }
112
+ },
113
+ "totals": {
114
+ "type": "object",
115
+ "required": ["cases", "launched", "settled", "agree", "differ", "unknown", "cleanup_proven", "cleanup_unproven", "leases_released"],
116
+ "properties": {
117
+ "cases": { "type": "integer", "minimum": 0 },
118
+ "launched": { "type": "integer", "minimum": 0 },
119
+ "settled": { "type": "integer", "minimum": 0 },
120
+ "agree": { "type": "integer", "minimum": 0 },
121
+ "differ": { "type": "integer", "minimum": 0 },
122
+ "unknown": { "type": "integer", "minimum": 0 },
123
+ "cleanup_proven": { "type": "integer", "minimum": 0 },
124
+ "cleanup_unproven": { "type": "integer", "minimum": 0 },
125
+ "leases_released": { "type": "integer", "minimum": 0 }
126
+ }
127
+ },
128
+ "admission": {
129
+ "type": "object",
130
+ "required": ["requested_cases", "admitted_cases", "pool", "free_vm_slots", "reserved_vm_slots", "worker_processes", "cases_per_process"],
131
+ "properties": {
132
+ "requested_cases": { "type": "integer", "minimum": 0 },
133
+ "admitted_cases": { "type": "integer", "minimum": 0 },
134
+ "pool": { "enum": ["small", "big"] },
135
+ "free_vm_slots": { "type": "integer", "minimum": 0 },
136
+ "reserved_vm_slots": { "type": "integer", "minimum": 0 },
137
+ "worker_processes": { "type": "integer", "minimum": 0 },
138
+ "cases_per_process": { "type": "integer", "minimum": 0 }
139
+ }
140
+ },
141
+ "unknown_reasons": { "type": "object", "additionalProperties": { "type": "integer", "minimum": 1 } },
142
+ "cases_returned": { "type": "integer", "minimum": 0 },
143
+ "cases_truncated": { "type": "boolean" },
144
+ "pages": { "type": "integer", "minimum": 1 },
145
+ "waves": {
146
+ "type": "array",
147
+ "items": {
148
+ "type": "object",
149
+ "required": ["wave", "at_ms", "launched"],
150
+ "properties": {
151
+ "wave": { "type": "integer", "minimum": 0 },
152
+ "at_ms": { "type": "integer", "minimum": 0 },
153
+ "launched": { "type": "integer", "minimum": 0 }
154
+ }
155
+ }
156
+ },
157
+ "waves_truncated": { "type": "boolean" },
158
+ "exit_code": { "type": "integer", "minimum": 0, "maximum": 2 },
159
+ "error": { "type": "string" },
160
+ "cancel_requested": { "const": true },
161
+ "previous_status": { "description": "On finalize, the batch's recorded status just before it.", "type": "string" },
162
+ "last_updated_at": { "description": "On finalize, the batch record's last update before it.", "type": "string" },
163
+ "eligibility": { "description": "On finalize, what made the batch finalizable.", "enum": ["cancel-requested", "stale"] },
164
+ "reason": { "description": "On finalize, the operator's reason, recorded as the batch error.", "type": "string" },
165
+ "finalized_at": { "type": "string" },
166
+ "next_command": { "type": "string" },
167
+ "out_dir": { "type": "string" },
168
+ "manifest": {
169
+ "type": "object",
170
+ "required": ["path", "sha256", "bytes"],
171
+ "properties": {
172
+ "path": { "const": "manifest.json" },
173
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
174
+ "bytes": { "type": "integer", "minimum": 0 }
175
+ }
176
+ },
177
+ "artifacts": {
178
+ "type": "array",
179
+ "items": {
180
+ "type": "object",
181
+ "required": ["path", "sha256", "bytes", "verified"],
182
+ "properties": {
183
+ "path": { "type": "string" },
184
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
185
+ "bytes": { "type": "integer", "minimum": 0 },
186
+ "verified": { "const": true }
187
+ }
188
+ }
189
+ },
190
+ "artifact_count": { "type": "integer", "minimum": 0 },
191
+ "pending": {
192
+ "description": "On bundle, the listed PNGs the service reported as still uploading (HTTP 409, reason artifact-pending): the coordinator publishes a batch's PNGs after it is terminal. Nothing is written for them. Non-empty means the command exited 75 (EX_TEMPFAIL); retry, for example with --only for each path.",
193
+ "type": "array",
194
+ "items": { "type": "string" }
195
+ },
196
+ "failed": {
197
+ "description": "On bundle, the listed PNGs the service reported as failed for good (HTTP 410, reason artifact-failed): the coordinator gave up on their upload and recorded why. Nothing is written for them and a retry cannot fetch them. Non-empty with nothing pending means the command exited 1.",
198
+ "type": "array",
199
+ "items": {
200
+ "type": "object",
201
+ "additionalProperties": false,
202
+ "required": ["path", "error_class", "status", "message"],
203
+ "properties": {
204
+ "path": { "type": "string" },
205
+ "error_class": { "type": "string" },
206
+ "status": { "type": ["integer", "null"] },
207
+ "message": { "type": "string" }
208
+ }
209
+ }
210
+ },
211
+ "total_bytes": { "type": "integer", "minimum": 0 }
212
+ },
213
+ "allOf": [
214
+ {
215
+ "if": { "properties": { "operation": { "const": "status" } }, "required": ["operation"] },
216
+ "then": { "required": ["repository", "status", "terminal", "timed_out", "totals", "cases", "cases_returned", "cases_truncated", "exit_code"] }
217
+ },
218
+ {
219
+ "if": { "properties": { "operation": { "const": "submit" } }, "required": ["operation"] },
220
+ "then": { "required": ["repository", "admitted", "base_commit", "head_commit", "idempotency_key", "cases", "limits", "next_command"] }
221
+ },
222
+ {
223
+ "if": { "properties": { "operation": { "const": "cancel" } }, "required": ["operation"] },
224
+ "then": { "required": ["repository", "cancel_requested"] }
225
+ },
226
+ {
227
+ "if": { "properties": { "operation": { "const": "finalize" } }, "required": ["operation"] },
228
+ "then": { "required": ["repository", "status", "terminal", "cleanup_complete", "generation", "previous_status", "last_updated_at", "eligibility", "reason", "finalized_at"] }
229
+ },
230
+ {
231
+ "if": { "properties": { "operation": { "const": "bundle" } }, "required": ["operation"] },
232
+ "then": { "required": ["repository", "out_dir", "manifest", "artifacts", "artifact_count", "total_bytes"] }
233
+ }
234
+ ]
235
+ }