@haystackeditor/cli 0.15.30 → 0.16.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 (135) hide show
  1. package/README.md +111 -60
  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 +608 -0
  8. package/dist/commands/case-batch.js +713 -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/design-verify.js +27 -1
  12. package/dist/commands/dismiss.js +2 -1
  13. package/dist/commands/hooks.js +66 -7
  14. package/dist/commands/install-session-hooks.js +131 -44
  15. package/dist/commands/policy.js +32 -45
  16. package/dist/commands/precompute-delivery.js +6 -1
  17. package/dist/commands/scaffold-provisional-universe.js +8 -10
  18. package/dist/commands/setup.js +32 -10
  19. package/dist/commands/submit.js +7 -2
  20. package/dist/commands/telemetry.js +17 -2
  21. package/dist/commands/triage.js +2 -1
  22. package/dist/commands/verify-explore.js +1 -0
  23. package/dist/commands/verify-history.js +152 -0
  24. package/dist/commands/verify-hosted-mcp.js +3 -12
  25. package/dist/commands/verify-hosted-reproducibility.js +49 -547
  26. package/dist/commands/verify-hosted.js +180 -163
  27. package/dist/commands/verify-precompute.js +57 -10
  28. package/dist/commands/verify.js +51 -139
  29. package/dist/index.js +264 -348
  30. package/dist/lazy.js +8 -0
  31. package/dist/schema.js +1 -0
  32. package/dist/tools/detect.js +3 -24
  33. package/dist/triage/prompts.js +46 -45
  34. package/dist/triage/runner.js +119 -33
  35. package/dist/types/verify-history.js +1 -0
  36. package/dist/types.js +3 -3
  37. package/dist/utils/auth.js +14 -2
  38. package/dist/utils/design-verifier-api.js +23 -0
  39. package/dist/utils/design-verifier-history.js +79 -0
  40. package/dist/utils/git.js +60 -29
  41. package/dist/utils/github-api.js +14 -1
  42. package/dist/utils/haystack-api.js +26 -7
  43. package/dist/utils/hooks.js +43 -6
  44. package/dist/utils/prompter.js +25 -10
  45. package/dist/utils/safe-write.js +31 -0
  46. package/dist/utils/secret-paths.js +115 -0
  47. package/dist/utils/secrets.js +0 -1
  48. package/dist/utils/telemetry.js +34 -14
  49. package/dist/utils/update-check.js +151 -0
  50. package/package.json +18 -14
  51. package/schemas/case-batch.v1.json +184 -0
  52. package/schemas/cloud-verifier.v1.json +55 -100
  53. package/dist/commands/ask.d.ts +0 -14
  54. package/dist/commands/cloud-verifier-behaviors.d.ts +0 -27
  55. package/dist/commands/cloud-verifier-data-store-census.d.ts +0 -47
  56. package/dist/commands/cloud-verifier-data-store-drift.d.ts +0 -42
  57. package/dist/commands/cloud-verifier-identity-census.d.ts +0 -88
  58. package/dist/commands/cloud-verifier-materialization.d.ts +0 -16
  59. package/dist/commands/cloud-verifier-pascal-selector-census.d.ts +0 -29
  60. package/dist/commands/cloud-verifier-python-manifest-selector-census.d.ts +0 -27
  61. package/dist/commands/cloud-verifier-specialized-operational-census.d.ts +0 -51
  62. package/dist/commands/cloud-verifier-universe.d.ts +0 -31
  63. package/dist/commands/config.d.ts +0 -46
  64. package/dist/commands/design-verify.d.ts +0 -31
  65. package/dist/commands/dismiss.d.ts +0 -29
  66. package/dist/commands/hooks.d.ts +0 -13
  67. package/dist/commands/inbox.d.ts +0 -65
  68. package/dist/commands/init.d.ts +0 -10
  69. package/dist/commands/install-session-hooks.d.ts +0 -17
  70. package/dist/commands/login.d.ts +0 -8
  71. package/dist/commands/mcp.d.ts +0 -1
  72. package/dist/commands/policy.d.ts +0 -31
  73. package/dist/commands/pr-status.d.ts +0 -144
  74. package/dist/commands/pr.d.ts +0 -40
  75. package/dist/commands/precompute-delivery-contract.d.ts +0 -68
  76. package/dist/commands/precompute-delivery.d.ts +0 -20
  77. package/dist/commands/prepare-universe-review.d.ts +0 -115
  78. package/dist/commands/production-source-deny-policy.d.ts +0 -15
  79. package/dist/commands/request-review.d.ts +0 -26
  80. package/dist/commands/review.d.ts +0 -25
  81. package/dist/commands/rules.d.ts +0 -4
  82. package/dist/commands/scaffold-provisional-universe.d.ts +0 -468
  83. package/dist/commands/schema-cmd.d.ts +0 -2
  84. package/dist/commands/setup.d.ts +0 -28
  85. package/dist/commands/skills.d.ts +0 -8
  86. package/dist/commands/status.d.ts +0 -4
  87. package/dist/commands/submit.d.ts +0 -30
  88. package/dist/commands/system-map.d.ts +0 -42
  89. package/dist/commands/telemetry.d.ts +0 -53
  90. package/dist/commands/tokens.d.ts +0 -14
  91. package/dist/commands/triage.d.ts +0 -35
  92. package/dist/commands/verify-core.d.ts +0 -449
  93. package/dist/commands/verify-core.js +0 -789
  94. package/dist/commands/verify-explore.d.ts +0 -14
  95. package/dist/commands/verify-hosted-mcp.d.ts +0 -12
  96. package/dist/commands/verify-hosted-reproducibility.d.ts +0 -90
  97. package/dist/commands/verify-hosted.d.ts +0 -92
  98. package/dist/commands/verify-mcp.d.ts +0 -8
  99. package/dist/commands/verify-mcp.js +0 -517
  100. package/dist/commands/verify-ops.d.ts +0 -158
  101. package/dist/commands/verify-ops.js +0 -1148
  102. package/dist/commands/verify-precompute.d.ts +0 -30
  103. package/dist/commands/verify-reproducibility.d.ts +0 -85
  104. package/dist/commands/verify-reproducibility.js +0 -494
  105. package/dist/commands/verify-reseal.d.ts +0 -9
  106. package/dist/commands/verify-reseal.js +0 -148
  107. package/dist/commands/verify-sandboxes.d.ts +0 -95
  108. package/dist/commands/verify-sandboxes.js +0 -352
  109. package/dist/commands/verify.d.ts +0 -28
  110. package/dist/commands/webhooks.d.ts +0 -30
  111. package/dist/index.d.ts +0 -22
  112. package/dist/schema.d.ts +0 -28
  113. package/dist/states.d.ts +0 -29
  114. package/dist/tools/detect.d.ts +0 -50
  115. package/dist/triage/prompts.d.ts +0 -24
  116. package/dist/triage/runner.d.ts +0 -34
  117. package/dist/triage/types.d.ts +0 -42
  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 -200
  124. package/dist/utils/design-verifier-result.d.ts +0 -74
  125. package/dist/utils/detect.d.ts +0 -43
  126. package/dist/utils/git.d.ts +0 -135
  127. package/dist/utils/github-api.d.ts +0 -104
  128. package/dist/utils/haystack-api.d.ts +0 -37
  129. package/dist/utils/hooks.d.ts +0 -12
  130. package/dist/utils/pending-state.d.ts +0 -40
  131. package/dist/utils/pr-ref.d.ts +0 -27
  132. package/dist/utils/prompter.d.ts +0 -85
  133. package/dist/utils/secrets.d.ts +0 -47
  134. package/dist/utils/telemetry.d.ts +0 -19
  135. /package/dist/commands/{precompute-delivery-worker.d.ts → combination-search-hook-contract.js} +0 -0
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { existsSync, readFileSync, statSync } from 'node:fs';
4
4
  import { basename, extname, join, posix, resolve } from 'node:path';
5
- import { globSync } from 'glob';
5
+ import fg from 'fast-glob';
6
6
  import { findGitRoot } from '../utils/hooks.js';
7
7
  import { scanPascalSelectorIdentities } from './cloud-verifier-pascal-selector-census.js';
8
8
  import { scanPythonManifestSelectorIdentities } from './cloud-verifier-python-manifest-selector-census.js';
@@ -208,7 +208,10 @@ function trackedFiles(root) {
208
208
  .sort();
209
209
  }
210
210
  catch {
211
- return globSync('**/*', { cwd: root, dot: true, nodir: true })
211
+ // Same entries as glob's { dot: true, nodir: true }: every non-directory,
212
+ // with symlinks listed as themselves and never followed.
213
+ return fg.sync('**/*', { cwd: root, dot: true, onlyFiles: false, followSymbolicLinks: false, markDirectories: true })
214
+ .filter((path) => !path.endsWith('/'))
212
215
  .map(normalizePath)
213
216
  .filter((path) => !path.startsWith('.git/') &&
214
217
  !path.startsWith('.haystack/') &&
@@ -0,0 +1,139 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ const maximumBytes = 8 * 1024 * 1024;
4
+ const configPath = '.haystack/combination-search.json';
5
+ function hasHexSuffix(value, prefix, length) {
6
+ return typeof value === 'string' && value.length === prefix.length + length && value.startsWith(prefix)
7
+ && [...value.slice(prefix.length)].every(character => '0123456789abcdef'.includes(character));
8
+ }
9
+ function record(value) {
10
+ if (!value || typeof value !== 'object' || Array.isArray(value))
11
+ throw new Error('Invalid combination-search configuration.');
12
+ return value;
13
+ }
14
+ function keys(value, expected) {
15
+ if (Object.keys(value).length !== expected.length || expected.some(key => !Object.hasOwn(value, key)))
16
+ throw new Error('Invalid combination-search fields.');
17
+ }
18
+ function integer(value, minimum, maximum) {
19
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum)
20
+ throw new Error('Invalid combination-search limit.');
21
+ return value;
22
+ }
23
+ function settings(value) {
24
+ const row = record(value);
25
+ keys(row, ['budgetMs', 'maxUniversesInFlight', 'picksPerBatch', 'seed', 'rankingPoolSize']);
26
+ const picks = record(row.picksPerBatch);
27
+ keys(picks, ['ranked', 'baseline']);
28
+ const ranked = integer(picks.ranked, 0, 500), baseline = integer(picks.baseline, 0, 500);
29
+ if (ranked + baseline < 1 || ranked + baseline > 500)
30
+ throw new Error('Invalid combination-search picks.');
31
+ return { budgetMs: integer(row.budgetMs, 10000, 1800000), maxUniversesInFlight: integer(row.maxUniversesInFlight, 1, 500),
32
+ picksPerBatch: { ranked, baseline }, seed: integer(row.seed, 0, 0xffffffff), rankingPoolSize: integer(row.rankingPoolSize, 1, 100000) };
33
+ }
34
+ function boundedJson(value) {
35
+ const visit = (item, depth) => {
36
+ if (depth > 64)
37
+ throw new Error('Combination-search input is too deeply nested.');
38
+ if (item === null || typeof item === 'string' || typeof item === 'boolean')
39
+ return;
40
+ if (typeof item === 'number' && Number.isFinite(item))
41
+ return;
42
+ if (Array.isArray(item)) {
43
+ for (const entry of item)
44
+ visit(entry, depth + 1);
45
+ return;
46
+ }
47
+ for (const [key, entry] of Object.entries(record(item))) {
48
+ if (['__proto__', 'constructor', 'prototype'].includes(key))
49
+ throw new Error('Invalid combination-search JSON key.');
50
+ visit(entry, depth + 1);
51
+ }
52
+ };
53
+ visit(value, 0);
54
+ if (Buffer.byteLength(JSON.stringify(value)) > maximumBytes)
55
+ throw new Error('Combination-search input exceeds 8 MiB.');
56
+ return value;
57
+ }
58
+ function configuration(value) {
59
+ const row = record(value);
60
+ keys(row, ['schemaVersion', 'combinations', 'prIntent', 'search', 'ideas']);
61
+ const ideas = record(row.ideas);
62
+ keys(ideas, ['count']);
63
+ if (row.schemaVersion !== 1 || typeof row.prIntent !== 'string' || !row.prIntent || row.prIntent.length > 65536)
64
+ throw new Error('Invalid combination-search intent.');
65
+ return { schemaVersion: 1, combinations: boundedJson(row.combinations), prIntent: row.prIntent,
66
+ search: settings(row.search), ideas: { count: integer(ideas.count, 1, 100) } };
67
+ }
68
+ /** Git reads only the captured immutable commit. Local changes made while the
69
+ * detached sender runs cannot alter the search's source or combination space. */
70
+ export function captureCombinationSearchHook(gitRoot, repository, baseCommit, headCommit, deadline) {
71
+ const git = (args) => {
72
+ const remaining = deadline - Date.now();
73
+ if (remaining <= 0)
74
+ throw new Error('Combination-search capture timed out.');
75
+ return execFileSync('git', ['--literal-pathspecs', ...args], { cwd: gitRoot, encoding: 'utf8',
76
+ timeout: remaining, maxBuffer: maximumBytes, stdio: ['ignore', 'pipe', 'pipe'] });
77
+ };
78
+ const present = git(['ls-tree', '-z', '--name-only', headCommit, '--', configPath]);
79
+ if (!present)
80
+ return undefined;
81
+ if (present !== `${configPath}\0`)
82
+ throw new Error('Unexpected combination-search configuration identity.');
83
+ const config = configuration(JSON.parse(git(['show', `${headCommit}:${configPath}`])));
84
+ const stats = git(['diff', '--no-renames', '--numstat', '-z', baseCommit, headCommit, '--']);
85
+ const files = stats.split('\0').filter(Boolean).map(stat => {
86
+ const first = stat.indexOf('\t'), second = stat.indexOf('\t', first + 1);
87
+ if (first < 0 || second < 0)
88
+ throw new Error('Invalid captured diff metadata.');
89
+ const file = stat.slice(second + 1);
90
+ const count = (raw) => raw === '-' ? 0 : integer(Number(raw), 0, Number.MAX_SAFE_INTEGER);
91
+ return { file, additions: count(stat.slice(0, first)), deletions: count(stat.slice(first + 1, second)),
92
+ patch: git(['diff', '--no-renames', '--binary', '--no-ext-diff', '--no-textconv', baseCommit, headCommit, '--', file]) };
93
+ });
94
+ const inputs = { repository, baseCommit, headCommit, combinationsRef: { inline: config.combinations },
95
+ change: { diffRef: { inline: { sha: headCommit, parent: baseCommit, message: config.prIntent, files } },
96
+ prIntentRef: { inline: config.prIntent } }, search: config.search, ideas: config.ideas };
97
+ boundedJson(inputs);
98
+ return { ...inputs, idempotencyKey: `search:${createHash('sha256').update(JSON.stringify(inputs)).digest('hex')}` };
99
+ }
100
+ export function validateCapturedSearch(value, identity) {
101
+ boundedJson(value);
102
+ const row = record(value);
103
+ keys(row, ['repository', 'baseCommit', 'headCommit', 'idempotencyKey', 'combinationsRef', 'change', 'search', 'ideas']);
104
+ if (row.repository !== `${identity.owner}/${identity.repository}` || row.baseCommit !== identity.baseCommit || row.headCommit !== identity.workCommit
105
+ || !hasHexSuffix(row.idempotencyKey, 'search:', 64))
106
+ throw new Error('Captured search source identity differs.');
107
+ settings(row.search);
108
+ const combinations = record(row.combinationsRef);
109
+ keys(combinations, ['inline']);
110
+ const change = record(row.change);
111
+ keys(change, ['diffRef', 'prIntentRef']);
112
+ const diffRef = record(change.diffRef);
113
+ keys(diffRef, ['inline']);
114
+ const diff = record(diffRef.inline);
115
+ if (diff.sha !== identity.workCommit || diff.parent !== identity.baseCommit)
116
+ throw new Error('Captured search diff identity differs.');
117
+ const prIntent = record(change.prIntentRef);
118
+ keys(prIntent, ['inline']);
119
+ if (typeof prIntent.inline !== 'string' || !prIntent.inline)
120
+ throw new Error('Captured search intent is missing.');
121
+ const ideas = record(row.ideas);
122
+ keys(ideas, ['count']);
123
+ integer(ideas.count, 1, 100);
124
+ const { idempotencyKey, ...inputs } = row;
125
+ if (idempotencyKey !== `search:${createHash('sha256').update(JSON.stringify(inputs)).digest('hex')}`)
126
+ throw new Error('Captured search digest differs.');
127
+ }
128
+ export function readCombinationSearchHookStatus(value) {
129
+ const row = record(value);
130
+ if ((row.status === 'queued' || row.status === 'existing') && hasHexSuffix(row.runId, 'cv_', 48)) {
131
+ keys(row, ['status', 'runId']);
132
+ return { status: row.status, runId: row.runId };
133
+ }
134
+ if (row.status === 'unavailable' && ['not-configured', 'runner-unavailable', 'enqueue-failed'].includes(String(row.reason))) {
135
+ keys(row, ['status', 'reason']);
136
+ return { status: row.status, reason: row.reason };
137
+ }
138
+ throw new Error('Invalid combination-search acknowledgment.');
139
+ }
@@ -16,8 +16,9 @@
16
16
  import chalk from 'chalk';
17
17
  import { loadToken } from '../utils/auth.js';
18
18
  import { parsePrRef } from '../utils/pr-ref.js';
19
- import { getDesignVerifierUniverseAction, startDesignVerifierUniverseAction, triggerDesignVerifier, waitForDesignVerifier, waitForDesignVerifierUniverseAction, } from '../utils/design-verifier-api.js';
19
+ import { getDesignVerifierHistory, getDesignVerifierUniverseAction, startDesignVerifierUniverseAction, triggerDesignVerifier, waitForDesignVerifier, waitForDesignVerifierUniverseAction, } from '../utils/design-verifier-api.js';
20
20
  import { buildDesignVerifierAgentReport, designVerifierStageLabel, formatDesignVerifierAgentReport, } from '../utils/design-verifier-result.js';
21
+ import { formatDesignVerifierHistory } from '../utils/design-verifier-history.js';
21
22
  function emit(json, payload, human) {
22
23
  if (json) {
23
24
  console.log(JSON.stringify(payload));
@@ -189,6 +190,31 @@ export async function designVerifyCommand(identifier, options) {
189
190
  process.exitCode = 1;
190
191
  return;
191
192
  }
193
+ if (options.history || options.limit !== undefined) {
194
+ try {
195
+ if (!options.history)
196
+ throw new Error('--limit requires --history');
197
+ if (options.run || options.case || options.universe || options.replay || options.execJson ||
198
+ options.cwd || options.commandTimeout || options.action || options.wait === false) {
199
+ throw new Error('--history cannot be combined with run or universe operations');
200
+ }
201
+ const limit = Number(options.limit ?? '20');
202
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
203
+ throw new Error('--limit must be between 1 and 100');
204
+ }
205
+ const loaded = await getDesignVerifierHistory(pr.owner, pr.repo, token, limit);
206
+ if (loaded.status !== 'ok')
207
+ throw new Error(loaded.message);
208
+ emit(options.json, { ...loaded.history }, () => console.log(formatDesignVerifierHistory(loaded.history)));
209
+ }
210
+ catch (error) {
211
+ emit(options.json, { ok: false, error: error.message }, () => {
212
+ console.error(chalk.red(error.message));
213
+ });
214
+ process.exitCode = 2;
215
+ }
216
+ return;
217
+ }
192
218
  const pollIntervalSeconds = Number(options.pollInterval || '3');
193
219
  const timeoutSeconds = Number(options.timeout || '7200');
194
220
  if (!(Number.isFinite(pollIntervalSeconds) && pollIntervalSeconds >= 1 && pollIntervalSeconds <= 60)) {
@@ -16,12 +16,13 @@ import chalk from 'chalk';
16
16
  import { emitActionPayload, failAction } from '../utils/action-output.js';
17
17
  import { resolveAuthContext } from '../utils/auth.js';
18
18
  import { classifyHttpError } from '../utils/haystack-api.js';
19
+ import { githubRestBaseFor } from '../utils/github-api.js';
19
20
  import { parsePrRef } from '../utils/pr-ref.js';
20
21
  // ============================================================================
21
22
  // GitHub API helpers
22
23
  // ============================================================================
23
24
  async function getPRHeadSha(owner, repo, prNumber, token) {
24
- const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`, {
25
+ const response = await fetch(`${githubRestBaseFor(token)}/repos/${owner}/${repo}/pulls/${prNumber}`, {
25
26
  headers: {
26
27
  Authorization: `Bearer ${token}`,
27
28
  Accept: 'application/vnd.github.v3+json',
@@ -6,10 +6,65 @@
6
6
  * - Git hooks for Haystack quality checks and workflow guidance
7
7
  */
8
8
  import chalk from 'chalk';
9
- import { existsSync, accessSync, constants } from 'fs';
9
+ import { existsSync, accessSync, constants, lstatSync, readFileSync, readdirSync } from 'fs';
10
10
  import * as path from 'path';
11
- import { execSync } from 'child_process';
12
- import { findGitRoot, copyHookFiles, installHookDeps, copyLlmRulesTemplate, } from '../utils/hooks.js';
11
+ import { execFileSync, execSync } from 'child_process';
12
+ import { findGitRoot, copyHookFiles, installHookDeps, copyLlmRulesTemplate, listHookInstallFiles, } from '../utils/hooks.js';
13
+ function gitOutput(gitRoot, args) {
14
+ return execFileSync('git', args, { cwd: gitRoot, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
15
+ }
16
+ function configuredHooksPath(gitRoot) {
17
+ try {
18
+ return gitOutput(gitRoot, ['config', '--get', 'core.hooksPath']);
19
+ }
20
+ catch (error) {
21
+ // `git config --get` exits 1 when the key is unset.
22
+ if (error.status === 1)
23
+ return undefined;
24
+ throw error;
25
+ }
26
+ }
27
+ /**
28
+ * Everything `haystack hooks install` would change that Haystack did not
29
+ * itself put there: hook files in hooks/ whose content differs from what this
30
+ * CLI installs, and a core.hooksPath change (including one that silences
31
+ * hooks already active in .git/hooks). Empty means installing is safe.
32
+ */
33
+ export async function describeHookOverwrites(gitRoot) {
34
+ const changes = [];
35
+ const hooksDir = path.join(gitRoot, 'hooks');
36
+ if (existsSync(hooksDir)) {
37
+ for (const file of await listHookInstallFiles()) {
38
+ const dest = path.join(hooksDir, file.rel);
39
+ let stat;
40
+ try {
41
+ stat = lstatSync(dest);
42
+ }
43
+ catch (error) {
44
+ if (error.code === 'ENOENT')
45
+ continue;
46
+ throw error;
47
+ }
48
+ if (!stat.isFile() || !readFileSync(dest).equals(readFileSync(file.src))) {
49
+ changes.push(`overwrite hooks/${file.rel}`);
50
+ }
51
+ }
52
+ }
53
+ const hooksPath = configuredHooksPath(gitRoot);
54
+ if (hooksPath === undefined) {
55
+ const gitHooksDir = path.resolve(gitRoot, gitOutput(gitRoot, ['rev-parse', '--git-path', 'hooks']));
56
+ const active = existsSync(gitHooksDir)
57
+ ? readdirSync(gitHooksDir).filter(name => !name.endsWith('.sample')).sort()
58
+ : [];
59
+ if (active.length > 0) {
60
+ changes.push(`set core.hooksPath to "hooks", so git stops running ${active.map(name => `.git/hooks/${name}`).join(', ')}`);
61
+ }
62
+ }
63
+ else if (hooksPath !== 'hooks') {
64
+ changes.push(`change core.hooksPath from "${hooksPath}" to "hooks"`);
65
+ }
66
+ return changes;
67
+ }
13
68
  export async function hooksInstall(options) {
14
69
  console.log(chalk.cyan('\nHaystack Hooks Setup\n'));
15
70
  // Step 1: Validate git repo
@@ -19,12 +74,16 @@ export async function hooksInstall(options) {
19
74
  process.exit(1);
20
75
  }
21
76
  const hooksDir = path.join(gitRoot, 'hooks');
22
- // Step 2: Check for existing installation
23
- if (existsSync(hooksDir) && !options.force) {
24
- console.error(chalk.yellow('hooks/ directory already exists. Use --force to overwrite.\n'));
77
+ // Step 2: Refuse to replace anything Haystack did not install unless forced.
78
+ const overwrites = await describeHookOverwrites(gitRoot);
79
+ if (overwrites.length > 0 && !options.force) {
80
+ console.error(chalk.yellow('Installing Haystack hooks would:'));
81
+ for (const change of overwrites)
82
+ console.error(chalk.yellow(` - ${change}`));
83
+ console.error(chalk.yellow('Use --force to proceed.\n'));
25
84
  process.exit(1);
26
85
  }
27
- if (existsSync(hooksDir) && options.force) {
86
+ if (overwrites.length > 0) {
28
87
  console.log(chalk.yellow('Overwriting existing hooks...'));
29
88
  }
30
89
  // Step 3: Copy hook files
@@ -6,21 +6,23 @@
6
6
  * "Needs input", with the Haystack rating and auto-fixer status.
7
7
  *
8
8
  * Supported CLIs:
9
- * - Claude Code: Native SessionStart and Stop hooks in .claude/settings.json
9
+ * - Claude Code: Native SessionStart and Stop hooks in .claude/settings.local.json
10
+ * (per developer; `shared` writes the committed .claude/settings.json)
10
11
  * - Codex CLI: AGENTS.md instructions (no native hook support)
11
12
  * - Gemini CLI: GEMINI.md instructions (no native hook support)
12
13
  */
13
- import { execSync } from 'child_process';
14
+ import { execFileSync } from 'child_process';
14
15
  import chalk from 'chalk';
15
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
16
17
  import { join } from 'path';
17
18
  import { findGitRoot } from '../utils/git.js';
19
+ import { assertNoSymlinkOnPath } from '../utils/safe-write.js';
18
20
  // ============================================================================
19
21
  // CLI detection (reused from triage runner pattern)
20
22
  // ============================================================================
21
23
  function isCLIInstalled(name) {
22
24
  try {
23
- execSync(process.platform === 'win32' ? `where ${name}` : `which ${name}`, {
25
+ execFileSync(process.platform === 'win32' ? 'where' : 'which', [name], {
24
26
  stdio: 'ignore',
25
27
  });
26
28
  return true;
@@ -44,19 +46,96 @@ function detectInstalledCLIs() {
44
46
  // ============================================================================
45
47
  const HAYSTACK_HOOK_COMMAND = 'haystack triage --hook';
46
48
  const HAYSTACK_PRECOMPUTE_HOOK_COMMAND = 'haystack verify precompute --hook';
47
- function installClaudeHook(gitRoot) {
49
+ // The per-developer file is the default: a hook that uploads working-tree
50
+ // snapshots must be opted into by each developer, not inherited from a commit.
51
+ const CLAUDE_LOCAL_SETTINGS = 'settings.local.json';
52
+ const CLAUDE_SHARED_SETTINGS = 'settings.json';
53
+ function claudeSettingsPath(gitRoot, fileName) {
54
+ return join(gitRoot, '.claude', fileName);
55
+ }
56
+ function readClaudeSettings(settingsPath) {
57
+ if (!existsSync(settingsPath))
58
+ return undefined;
59
+ return JSON.parse(readFileSync(settingsPath, 'utf-8'));
60
+ }
61
+ function claudeEntries(settings, event) {
62
+ const hooks = settings?.hooks;
63
+ const entries = hooks?.[event];
64
+ return Array.isArray(entries) ? entries : [];
65
+ }
66
+ const isTriageCommand = (h) => Boolean(h.command?.includes('haystack triage') || h.command?.includes('haystack check-pending'));
67
+ const isPrecomputeCommand = (h) => Boolean(h.command?.includes(HAYSTACK_PRECOMPUTE_HOOK_COMMAND));
68
+ function isGitIgnored(gitRoot, path) {
69
+ try {
70
+ execFileSync('git', ['check-ignore', '-q', '--', path], { cwd: gitRoot, stdio: 'ignore' });
71
+ return true;
72
+ }
73
+ catch (error) {
74
+ // Exit status 1 means "not ignored"; anything else is a real failure.
75
+ if (error.status === 1)
76
+ return false;
77
+ throw error;
78
+ }
79
+ }
80
+ /**
81
+ * Drops Haystack's own SessionStart and Stop commands from
82
+ * `.claude/settings.local.json` after a shared install. Every entry that is
83
+ * not Haystack's is preserved byte-for-byte in meaning; the file is removed
84
+ * only when nothing but Haystack's hooks was in it.
85
+ */
86
+ function removeHaystackHooksFromLocalSettings(gitRoot) {
87
+ const localPath = claudeSettingsPath(gitRoot, CLAUDE_LOCAL_SETTINGS);
88
+ const relativePath = `.claude/${CLAUDE_LOCAL_SETTINGS}`;
89
+ assertNoSymlinkOnPath(gitRoot, localPath);
90
+ const local = readClaudeSettings(localPath);
91
+ if (!local || !local.hooks || typeof local.hooks !== 'object')
92
+ return;
93
+ const hooks = local.hooks;
94
+ const isHaystackCommand = (h) => isTriageCommand(h) || isPrecomputeCommand(h);
95
+ let removed = 0;
96
+ for (const event of ['SessionStart', 'Stop']) {
97
+ const entries = hooks[event];
98
+ if (!Array.isArray(entries))
99
+ continue;
100
+ const kept = entries
101
+ .map(entry => {
102
+ const own = entry.hooks?.filter(h => isHaystackCommand(h)).length ?? 0;
103
+ removed += own;
104
+ return own === 0 ? entry : { ...entry, hooks: (entry.hooks ?? []).filter(h => !isHaystackCommand(h)) };
105
+ })
106
+ .filter(entry => (entry.hooks ?? []).length > 0);
107
+ if (kept.length > 0)
108
+ hooks[event] = kept;
109
+ else
110
+ delete hooks[event];
111
+ }
112
+ if (removed === 0)
113
+ return;
114
+ if (Object.keys(hooks).length === 0)
115
+ delete local.hooks;
116
+ if (Object.keys(local).length === 0) {
117
+ unlinkSync(localPath);
118
+ console.log(chalk.dim(` Removed ${relativePath}: it held only the Haystack hooks that now live in the shared file.`));
119
+ return;
120
+ }
121
+ writeFileSync(localPath, JSON.stringify(local, null, 2));
122
+ console.log(chalk.dim(` Removed ${removed} Haystack hook command(s) from ${relativePath}; the shared file now carries them.`));
123
+ }
124
+ function installClaudeHook(gitRoot, options) {
125
+ const fileName = options.shared ? CLAUDE_SHARED_SETTINGS : CLAUDE_LOCAL_SETTINGS;
126
+ const relativePath = `.claude/${fileName}`;
48
127
  const settingsDir = join(gitRoot, '.claude');
49
- const settingsPath = join(settingsDir, 'settings.json');
128
+ const settingsPath = claudeSettingsPath(gitRoot, fileName);
50
129
  try {
51
- // Ensure .claude directory exists
52
- if (!existsSync(settingsDir)) {
53
- mkdirSync(settingsDir, { recursive: true });
54
- }
130
+ assertNoSymlinkOnPath(gitRoot, settingsPath);
55
131
  // Read or create settings
56
- let settings = {};
57
- if (existsSync(settingsPath)) {
58
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
59
- }
132
+ const settings = readClaudeSettings(settingsPath) ?? {};
133
+ // Claude Code merges both files, so for a local install a hook the shared
134
+ // file already carries is installed; adding it here would only duplicate
135
+ // it. A shared install always writes, because teammates only see that file.
136
+ const otherSettings = options.shared
137
+ ? undefined
138
+ : readClaudeSettings(claudeSettingsPath(gitRoot, CLAUDE_SHARED_SETTINGS));
60
139
  // Ensure hooks object exists
61
140
  const hooks = (settings.hooks || {});
62
141
  settings.hooks = hooks;
@@ -64,8 +143,9 @@ function installClaudeHook(gitRoot) {
64
143
  const sessionStart = (hooks.SessionStart || []);
65
144
  hooks.SessionStart = sessionStart;
66
145
  // Check if haystack hook already installed (current or legacy)
67
- const isHaystackHook = (h) => h.command?.includes('haystack triage') || h.command?.includes('haystack check-pending');
68
- const existingIndex = sessionStart.findIndex(entry => entry.hooks?.some(h => isHaystackHook(h)));
146
+ const existingIndex = sessionStart.findIndex(entry => entry.hooks?.some(h => isTriageCommand(h)));
147
+ const sessionInOther = claudeEntries(otherSettings, 'SessionStart')
148
+ .some(entry => entry.hooks?.some(h => isTriageCommand(h)));
69
149
  let changed = false;
70
150
  let migrated = false;
71
151
  if (existingIndex !== -1) {
@@ -82,7 +162,7 @@ function installClaudeHook(gitRoot) {
82
162
  }
83
163
  }
84
164
  }
85
- else {
165
+ else if (!sessionInOther) {
86
166
  // Add the session-start hook (fresh install).
87
167
  sessionStart.push({
88
168
  matcher: '',
@@ -99,7 +179,8 @@ function installClaudeHook(gitRoot) {
99
179
  // entry and add the exact command only when no matching command exists.
100
180
  const stop = (hooks.Stop || []);
101
181
  hooks.Stop = stop;
102
- const hasPrecomputeHook = stop.some(entry => entry.hooks?.some(h => h.command?.includes(HAYSTACK_PRECOMPUTE_HOOK_COMMAND)));
182
+ const hasPrecomputeHook = [...stop, ...claudeEntries(otherSettings, 'Stop')]
183
+ .some(entry => entry.hooks?.some(h => isPrecomputeCommand(h)));
103
184
  if (!hasPrecomputeHook) {
104
185
  stop.push({
105
186
  matcher: '',
@@ -113,7 +194,19 @@ function installClaudeHook(gitRoot) {
113
194
  changed = true;
114
195
  }
115
196
  if (changed) {
197
+ if (!existsSync(settingsDir))
198
+ mkdirSync(settingsDir, { recursive: true });
116
199
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
200
+ console.log(chalk.dim(` Wrote ${relativePath}${options.shared ? ' (shared with everyone who clones this repository)' : ' (this checkout only)'}.`));
201
+ }
202
+ // Claude Code unions hooks from both files, so once the shared file
203
+ // carries Haystack's hooks, copies left in the local file would run twice.
204
+ if (options.shared)
205
+ removeHaystackHooksFromLocalSettings(gitRoot);
206
+ if (changed) {
207
+ if (!options.shared && !isGitIgnored(gitRoot, relativePath)) {
208
+ console.log(chalk.yellow(` ${relativePath} is not git-ignored; add it to .gitignore or .git/info/exclude so it stays personal.`));
209
+ }
117
210
  if (migrated) {
118
211
  console.log(chalk.green(' Claude Code hook migrated from check-pending to triage.'));
119
212
  }
@@ -124,35 +217,27 @@ function installClaudeHook(gitRoot) {
124
217
  return true;
125
218
  }
126
219
  catch (err) {
127
- console.error(chalk.red(` Failed to update .claude/settings.json: ${err}`));
220
+ console.error(chalk.red(` Failed to update ${relativePath}: ${err instanceof Error ? err.message : err}`));
128
221
  return false;
129
222
  }
130
223
  }
224
+ function claudeHookPresent(gitRoot, event, matches) {
225
+ return [CLAUDE_LOCAL_SETTINGS, CLAUDE_SHARED_SETTINGS].some(fileName => {
226
+ try {
227
+ return claudeEntries(readClaudeSettings(claudeSettingsPath(gitRoot, fileName)), event)
228
+ .some(entry => entry.hooks?.some(h => matches(h)));
229
+ }
230
+ catch (error) {
231
+ console.error(chalk.yellow(` Could not read .claude/${fileName}: ${error instanceof Error ? error.message : error}`));
232
+ return false;
233
+ }
234
+ });
235
+ }
131
236
  function checkClaudePrecomputeHook(gitRoot) {
132
- const settingsPath = join(gitRoot, '.claude', 'settings.json');
133
- if (!existsSync(settingsPath))
134
- return false;
135
- try {
136
- const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
137
- const stop = settings?.hooks?.Stop || [];
138
- return stop.some((entry) => entry.hooks?.some(h => h.command?.includes(HAYSTACK_PRECOMPUTE_HOOK_COMMAND)));
139
- }
140
- catch {
141
- return false;
142
- }
237
+ return claudeHookPresent(gitRoot, 'Stop', isPrecomputeCommand);
143
238
  }
144
239
  function checkClaudeHook(gitRoot) {
145
- const settingsPath = join(gitRoot, '.claude', 'settings.json');
146
- if (!existsSync(settingsPath))
147
- return false;
148
- try {
149
- const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
150
- const sessionStart = settings?.hooks?.SessionStart || [];
151
- return sessionStart.some((entry) => entry.hooks?.some((h) => h.command?.includes('haystack triage') || h.command?.includes('haystack check-pending')));
152
- }
153
- catch {
154
- return false;
155
- }
240
+ return claudeHookPresent(gitRoot, 'SessionStart', isTriageCommand);
156
241
  }
157
242
  // ============================================================================
158
243
  // Codex CLI: AGENTS.md instructions
@@ -181,12 +266,13 @@ function installCodexHook(gitRoot) {
181
266
  return true;
182
267
  }
183
268
  }
269
+ assertNoSymlinkOnPath(gitRoot, agentsPath);
184
270
  content += CODEX_HOOK_BLOCK;
185
271
  writeFileSync(agentsPath, content);
186
272
  return true;
187
273
  }
188
274
  catch (err) {
189
- console.error(chalk.red(` Failed to update AGENTS.md: ${err}`));
275
+ console.error(chalk.red(` Failed to update AGENTS.md: ${err instanceof Error ? err.message : err}`));
190
276
  return false;
191
277
  }
192
278
  }
@@ -229,12 +315,13 @@ function installGeminiHook(gitRoot) {
229
315
  return true;
230
316
  }
231
317
  }
318
+ assertNoSymlinkOnPath(gitRoot, geminiPath);
232
319
  content += GEMINI_HOOK_BLOCK;
233
320
  writeFileSync(geminiPath, content);
234
321
  return true;
235
322
  }
236
323
  catch (err) {
237
- console.error(chalk.red(` Failed to update GEMINI.md: ${err}`));
324
+ console.error(chalk.red(` Failed to update GEMINI.md: ${err instanceof Error ? err.message : err}`));
238
325
  return false;
239
326
  }
240
327
  }
@@ -318,7 +405,7 @@ export async function installSessionHooks(options) {
318
405
  const config = CLI_HOOKS[target];
319
406
  const hookType = config.hasNativeHook ? 'native hook' : 'markdown instructions';
320
407
  console.log(` Installing ${config.displayName} (${hookType})...`);
321
- const success = config.install(gitRoot);
408
+ const success = config.install(gitRoot, { shared: options.shared === true });
322
409
  if (success) {
323
410
  console.log(chalk.green(` ✓ ${config.displayName} configured`));
324
411
  }