@haystackeditor/cli 0.15.31 → 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.
- package/README.md +111 -60
- package/dist/assets/hooks/scripts/commit-msg.sh +3 -0
- package/dist/assets/hooks/scripts/post-commit.sh +3 -0
- package/dist/assets/hooks/scripts/pre-commit.sh +11 -6
- package/dist/assets/hooks/scripts/pre-push.sh +3 -0
- package/dist/assets/hooks/scripts/prepare-commit-msg.sh +3 -0
- package/dist/commands/case-batch-contract.js +608 -0
- package/dist/commands/case-batch.js +713 -0
- package/dist/commands/cloud-verifier-identity-census.js +5 -2
- package/dist/commands/combination-search-hook.js +139 -0
- package/dist/commands/dismiss.js +2 -1
- package/dist/commands/hooks.js +66 -7
- package/dist/commands/install-session-hooks.js +131 -44
- package/dist/commands/policy.js +32 -45
- package/dist/commands/precompute-delivery.js +6 -1
- package/dist/commands/scaffold-provisional-universe.js +8 -10
- package/dist/commands/setup.js +32 -10
- package/dist/commands/submit.js +7 -2
- package/dist/commands/telemetry.js +17 -2
- package/dist/commands/triage.js +2 -1
- package/dist/commands/verify-explore.js +1 -0
- package/dist/commands/verify-hosted-mcp.js +3 -12
- package/dist/commands/verify-hosted-reproducibility.js +49 -547
- package/dist/commands/verify-hosted.js +66 -168
- package/dist/commands/verify-precompute.js +57 -10
- package/dist/commands/verify.js +51 -139
- package/dist/index.js +238 -350
- package/dist/lazy.js +8 -0
- package/dist/schema.js +1 -0
- package/dist/tools/detect.js +3 -24
- package/dist/triage/prompts.js +46 -45
- package/dist/triage/runner.js +119 -33
- package/dist/types.js +3 -3
- package/dist/utils/auth.js +14 -2
- package/dist/utils/git.js +60 -29
- package/dist/utils/github-api.js +14 -1
- package/dist/utils/haystack-api.js +26 -7
- package/dist/utils/hooks.js +43 -6
- package/dist/utils/prompter.js +25 -10
- package/dist/utils/safe-write.js +31 -0
- package/dist/utils/secret-paths.js +115 -0
- package/dist/utils/secrets.js +0 -1
- package/dist/utils/telemetry.js +34 -14
- package/dist/utils/update-check.js +151 -0
- package/package.json +18 -14
- package/schemas/case-batch.v1.json +184 -0
- package/schemas/cloud-verifier.v1.json +55 -100
- package/dist/commands/ask.d.ts +0 -14
- package/dist/commands/cloud-verifier-behaviors.d.ts +0 -27
- package/dist/commands/cloud-verifier-data-store-census.d.ts +0 -47
- package/dist/commands/cloud-verifier-data-store-drift.d.ts +0 -42
- package/dist/commands/cloud-verifier-identity-census.d.ts +0 -88
- package/dist/commands/cloud-verifier-materialization.d.ts +0 -16
- package/dist/commands/cloud-verifier-pascal-selector-census.d.ts +0 -29
- package/dist/commands/cloud-verifier-python-manifest-selector-census.d.ts +0 -27
- package/dist/commands/cloud-verifier-specialized-operational-census.d.ts +0 -51
- package/dist/commands/cloud-verifier-universe.d.ts +0 -31
- package/dist/commands/config.d.ts +0 -46
- package/dist/commands/design-verify.d.ts +0 -33
- package/dist/commands/dismiss.d.ts +0 -29
- package/dist/commands/hooks.d.ts +0 -13
- package/dist/commands/inbox.d.ts +0 -65
- package/dist/commands/init.d.ts +0 -10
- package/dist/commands/install-session-hooks.d.ts +0 -17
- package/dist/commands/login.d.ts +0 -8
- package/dist/commands/mcp.d.ts +0 -1
- package/dist/commands/policy.d.ts +0 -31
- package/dist/commands/pr-status.d.ts +0 -144
- package/dist/commands/pr.d.ts +0 -40
- package/dist/commands/precompute-delivery-contract.d.ts +0 -68
- package/dist/commands/precompute-delivery.d.ts +0 -20
- package/dist/commands/prepare-universe-review.d.ts +0 -115
- package/dist/commands/production-source-deny-policy.d.ts +0 -15
- package/dist/commands/request-review.d.ts +0 -26
- package/dist/commands/review.d.ts +0 -25
- package/dist/commands/rules.d.ts +0 -4
- package/dist/commands/scaffold-provisional-universe.d.ts +0 -468
- package/dist/commands/schema-cmd.d.ts +0 -2
- package/dist/commands/setup.d.ts +0 -28
- package/dist/commands/skills.d.ts +0 -8
- package/dist/commands/status.d.ts +0 -4
- package/dist/commands/submit.d.ts +0 -30
- package/dist/commands/system-map.d.ts +0 -42
- package/dist/commands/telemetry.d.ts +0 -53
- package/dist/commands/tokens.d.ts +0 -14
- package/dist/commands/triage.d.ts +0 -35
- package/dist/commands/verify-core.d.ts +0 -449
- package/dist/commands/verify-core.js +0 -789
- package/dist/commands/verify-explore.d.ts +0 -14
- package/dist/commands/verify-history.d.ts +0 -14
- package/dist/commands/verify-hosted-mcp.d.ts +0 -12
- package/dist/commands/verify-hosted-reproducibility.d.ts +0 -90
- package/dist/commands/verify-hosted.d.ts +0 -92
- package/dist/commands/verify-mcp.d.ts +0 -8
- package/dist/commands/verify-mcp.js +0 -517
- package/dist/commands/verify-ops.d.ts +0 -158
- package/dist/commands/verify-ops.js +0 -1148
- package/dist/commands/verify-precompute.d.ts +0 -30
- package/dist/commands/verify-reproducibility.d.ts +0 -85
- package/dist/commands/verify-reproducibility.js +0 -494
- package/dist/commands/verify-reseal.d.ts +0 -9
- package/dist/commands/verify-reseal.js +0 -148
- package/dist/commands/verify-sandboxes.d.ts +0 -95
- package/dist/commands/verify-sandboxes.js +0 -352
- package/dist/commands/verify.d.ts +0 -28
- package/dist/commands/webhooks.d.ts +0 -30
- package/dist/index.d.ts +0 -22
- package/dist/schema.d.ts +0 -28
- package/dist/states.d.ts +0 -29
- package/dist/tools/detect.d.ts +0 -50
- package/dist/triage/prompts.d.ts +0 -24
- package/dist/triage/runner.d.ts +0 -34
- package/dist/triage/types.d.ts +0 -42
- package/dist/types/verify-history.d.ts +0 -38
- package/dist/types.d.ts +0 -1684
- package/dist/utils/action-output.d.ts +0 -24
- package/dist/utils/analysis-api.d.ts +0 -187
- package/dist/utils/auth.d.ts +0 -79
- package/dist/utils/config.d.ts +0 -24
- package/dist/utils/design-verifier-api.d.ts +0 -208
- package/dist/utils/design-verifier-history.d.ts +0 -46
- package/dist/utils/design-verifier-result.d.ts +0 -74
- package/dist/utils/detect.d.ts +0 -43
- package/dist/utils/git.d.ts +0 -135
- package/dist/utils/github-api.d.ts +0 -104
- package/dist/utils/haystack-api.d.ts +0 -37
- package/dist/utils/hooks.d.ts +0 -12
- package/dist/utils/pending-state.d.ts +0 -40
- package/dist/utils/pr-ref.d.ts +0 -27
- package/dist/utils/prompter.d.ts +0 -85
- package/dist/utils/secrets.d.ts +0 -47
- package/dist/utils/telemetry.d.ts +0 -19
- /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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/commands/dismiss.js
CHANGED
|
@@ -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(
|
|
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',
|
package/dist/commands/hooks.js
CHANGED
|
@@ -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:
|
|
23
|
-
|
|
24
|
-
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
128
|
+
const settingsPath = claudeSettingsPath(gitRoot, fileName);
|
|
50
129
|
try {
|
|
51
|
-
|
|
52
|
-
if (!existsSync(settingsDir)) {
|
|
53
|
-
mkdirSync(settingsDir, { recursive: true });
|
|
54
|
-
}
|
|
130
|
+
assertNoSymlinkOnPath(gitRoot, settingsPath);
|
|
55
131
|
// Read or create settings
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
68
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|