@haystackeditor/cli 0.25.1 → 0.27.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 (109) hide show
  1. package/README.md +39 -463
  2. package/dist/capture/app-config.js +30 -6
  3. package/dist/commands/capture-brief.js +45 -35
  4. package/dist/commands/capture-contract.js +4 -4
  5. package/dist/commands/crawl-report.js +196 -0
  6. package/dist/commands/db-profile-upload.js +3 -3
  7. package/dist/commands/feedback.js +66 -0
  8. package/dist/commands/init-telemetry.js +55 -9
  9. package/dist/commands/init.js +8 -3
  10. package/dist/commands/lockfile-pin.js +307 -0
  11. package/dist/commands/telemetry-token.js +3 -3
  12. package/dist/commands/tokens.js +4 -4
  13. package/dist/commands/verify-explore.js +3 -3
  14. package/dist/commands/verify-history.js +2 -2
  15. package/dist/commands/verify-onboarding.js +13 -16
  16. package/dist/commands/verify-precompute.js +3 -4
  17. package/dist/commands/verify.js +31 -118
  18. package/dist/index.js +55 -968
  19. package/dist/schema.js +4 -10
  20. package/dist/utils/haystack-api.js +8 -36
  21. package/package.json +1 -5
  22. package/schemas/feedback.v1.json +13 -0
  23. package/schemas/pre-verify.v2.json +240 -0
  24. package/schemas/verify-raw.v1.json +1132 -0
  25. package/schemas/verify.v2.json +655 -0
  26. package/dist/assets/hooks/agent-context/detect.ts +0 -316
  27. package/dist/assets/hooks/agent-context/format.ts +0 -100
  28. package/dist/assets/hooks/agent-context/index.ts +0 -41
  29. package/dist/assets/hooks/agent-context/parsers/claude.ts +0 -262
  30. package/dist/assets/hooks/agent-context/parsers/codex.ts +0 -416
  31. package/dist/assets/hooks/agent-context/parsers/gemini.ts +0 -155
  32. package/dist/assets/hooks/agent-context/parsers/opencode.ts +0 -174
  33. package/dist/assets/hooks/agent-context/tsconfig.json +0 -14
  34. package/dist/assets/hooks/agent-context/types.ts +0 -58
  35. package/dist/assets/hooks/llm-rules-template.md +0 -59
  36. package/dist/assets/hooks/package-lock.json +0 -598
  37. package/dist/assets/hooks/package.json +0 -12
  38. package/dist/assets/hooks/scripts/commit-msg.sh +0 -5
  39. package/dist/assets/hooks/scripts/post-commit.sh +0 -5
  40. package/dist/assets/hooks/scripts/pre-commit.sh +0 -175
  41. package/dist/assets/hooks/scripts/pre-push.sh +0 -25
  42. package/dist/assets/hooks/scripts/prepare-commit-msg.sh +0 -5
  43. package/dist/assets/hooks/truncation-checker/ast-analyzer.ts +0 -528
  44. package/dist/assets/hooks/truncation-checker/index.ts +0 -595
  45. package/dist/assets/hooks/truncation-checker/tsconfig.json +0 -13
  46. package/dist/assets/skills/map-cloud-verifier-universe/SKILL.md +0 -2051
  47. package/dist/assets/skills/map-cloud-verifier-universe/agents/openai.yaml +0 -4
  48. package/dist/assets/skills/map-cloud-verifier-universe/references/output-contract.md +0 -3411
  49. package/dist/assets/skills/map-your-system.md +0 -143
  50. package/dist/assets/skills/submit.md +0 -200
  51. package/dist/commands/ask.js +0 -20
  52. package/dist/commands/cloud-verifier-behaviors.js +0 -218
  53. package/dist/commands/cloud-verifier-data-store-census.js +0 -539
  54. package/dist/commands/cloud-verifier-data-store-drift.js +0 -158
  55. package/dist/commands/cloud-verifier-identity-census.js +0 -4060
  56. package/dist/commands/cloud-verifier-materialization.js +0 -704
  57. package/dist/commands/cloud-verifier-pascal-selector-census.js +0 -1382
  58. package/dist/commands/cloud-verifier-python-manifest-selector-census.js +0 -2015
  59. package/dist/commands/cloud-verifier-specialized-operational-census.js +0 -11432
  60. package/dist/commands/cloud-verifier-universe.js +0 -10178
  61. package/dist/commands/config.js +0 -549
  62. package/dist/commands/design-verify.js +0 -311
  63. package/dist/commands/dismiss.js +0 -159
  64. package/dist/commands/hooks.js +0 -226
  65. package/dist/commands/inbox.js +0 -137
  66. package/dist/commands/mcp.js +0 -201
  67. package/dist/commands/policy.js +0 -371
  68. package/dist/commands/pr-status.js +0 -207
  69. package/dist/commands/pr.js +0 -105
  70. package/dist/commands/prepare-universe-review.js +0 -1092
  71. package/dist/commands/production-source-deny-policy.js +0 -100
  72. package/dist/commands/request-review.js +0 -74
  73. package/dist/commands/review.js +0 -191
  74. package/dist/commands/rules.js +0 -98
  75. package/dist/commands/scaffold-provisional-universe.js +0 -806
  76. package/dist/commands/setup.js +0 -1170
  77. package/dist/commands/skills.js +0 -447
  78. package/dist/commands/status.js +0 -35
  79. package/dist/commands/submit.js +0 -745
  80. package/dist/commands/system-map.js +0 -228
  81. package/dist/commands/triage.js +0 -598
  82. package/dist/commands/webhooks.js +0 -241
  83. package/dist/states.js +0 -46
  84. package/dist/tools/detect.js +0 -832
  85. package/dist/triage/astra.js +0 -202
  86. package/dist/triage/prompts.js +0 -188
  87. package/dist/triage/runner.js +0 -200
  88. package/dist/triage/types.js +0 -7
  89. package/dist/types.js +0 -326
  90. package/dist/utils/action-output.js +0 -26
  91. package/dist/utils/analysis-api.js +0 -416
  92. package/dist/utils/config.js +0 -54
  93. package/dist/utils/design-verifier-api.js +0 -294
  94. package/dist/utils/design-verifier-history.js +0 -79
  95. package/dist/utils/design-verifier-result.js +0 -424
  96. package/dist/utils/github-api.js +0 -324
  97. package/dist/utils/pending-state.js +0 -86
  98. package/dist/utils/pr-ref.js +0 -56
  99. package/dist/utils/prompter.js +0 -328
  100. package/schemas/action.v1.json +0 -22
  101. package/schemas/ask.v1.json +0 -40
  102. package/schemas/inbox.v1.json +0 -27
  103. package/schemas/pr-status.v1.json +0 -61
  104. package/schemas/pr.v1.json +0 -97
  105. package/schemas/pr.v3.json +0 -45
  106. package/schemas/setup.v1.json +0 -75
  107. package/schemas/submit.v1.json +0 -90
  108. package/schemas/triage.v1.json +0 -103
  109. package/schemas/triage.v2.json +0 -64
@@ -1,63 +1,73 @@
1
1
  /** CAPTURE-V1 rule 7b in `haystack pre-verify`: the routes a change touches (the brief's blast-radius files and the changed
2
2
  * files, joined through the route manifest's source entries) with their share of captured sessions over the window, the union
3
- * over all of them, freshness and drops; sessions on routes no manifest names are unmapped, never zero. The application is the
4
- * one `.haystack/capture.json` names (rule 8b, written by `haystack init`); a checkout without it has no capture and the brief
5
- * is exactly as before. A capture read that fails is reported in the block and never fails the command. */
6
- import { existsSync, readFileSync } from 'node:fs';
7
- import { join } from 'node:path';
3
+ * over all of them, freshness and drops; sessions on routes no manifest names are unmapped, never zero. One section per app
4
+ * whose `.haystack/capture.json` (rule 8b, written by `haystack init` in the app's directory, apps/web in a monorepo) records it,
5
+ * found as init finds them (app-config.ts); a checkout without one has no capture and the brief is exactly as before. A capture
6
+ * read that fails is reported in its section and never fails the command. */
7
+ import { join, posix } from 'node:path';
8
8
  import chalk from 'chalk';
9
- import { classifyHttpError } from '../utils/haystack-api.js';
9
+ import { CAPTURE_CONFIG_PATH, captureConfigDirs, readCaptureConfig } from '../capture/app-config.js';
10
+ import { classifyHttpError, repoApiPath } from '../utils/haystack-api.js';
10
11
  import { gatewayFetch } from './case-batch.js';
11
12
  import { CAPTURE_APPLICATION_ID, CAPTURE_WINDOW_PATH, captureChangedRoutes, } from './capture-contract.js';
12
- /** Rule 8b: the application identity init commits (key, repository id, application id, adapter). */
13
- export const CAPTURE_IDENTITY_FILE = '.haystack/capture.json';
14
- /** The checkout's application id, null without the identity file, or why the file cannot be read. */
15
- function captureApplication(gitRoot) {
16
- const path = join(gitRoot, CAPTURE_IDENTITY_FILE);
17
- if (!existsSync(path))
18
- return null;
19
- let value;
13
+ /** An app's application id from its record, or why the record cannot give one. */
14
+ function captureApplication(gitRoot, app) {
15
+ const file = posix.join(app, CAPTURE_CONFIG_PATH);
16
+ let config;
20
17
  try {
21
- value = JSON.parse(readFileSync(path, 'utf8'));
18
+ config = readCaptureConfig(join(gitRoot, app));
22
19
  }
23
20
  catch (error) {
24
- return { unreadable: `${CAPTURE_IDENTITY_FILE} is not JSON (${error instanceof Error ? error.message : String(error)})` };
25
- }
26
- const applicationId = value?.applicationId;
27
- if (typeof applicationId !== 'string' || !CAPTURE_APPLICATION_ID.test(applicationId)) {
28
- return { unreadable: `${CAPTURE_IDENTITY_FILE} names no valid applicationId` };
21
+ return { unreadable: `${file}: ${error instanceof Error ? error.message : String(error)}` };
29
22
  }
30
- return { applicationId };
23
+ if (config === null)
24
+ return { unreadable: `${file} is gone` };
25
+ if (config.app !== app)
26
+ return { unreadable: `${file} records the app ${config.app}, not its own directory; run \`haystack init --app ${app}\`` };
27
+ if (!CAPTURE_APPLICATION_ID.test(config.applicationId))
28
+ return { unreadable: `${file} names no valid applicationId` };
29
+ return { applicationId: config.applicationId };
31
30
  }
32
- /** The pre-verify capture block, or null for a checkout with no capture identity. `files`: the brief's function files and the
33
- * changed files. */
34
- export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
35
- const application = captureApplication(gitRoot);
36
- if (application === null)
37
- return null;
31
+ async function readApp(gitRoot, app, repository, token, files, now) {
32
+ const application = captureApplication(gitRoot, app);
38
33
  if ('unreadable' in application)
39
- return { applicationId: null, state: 'unavailable', reason: application.unreadable };
34
+ return { app, applicationId: null, state: 'unavailable', reason: application.unreadable };
40
35
  const { applicationId } = application;
41
36
  try {
42
- const response = await gatewayFetch(`${CAPTURE_WINDOW_PATH}?repository=${encodeURIComponent(repository)}&app=${encodeURIComponent(applicationId)}`, token);
37
+ const path = repoApiPath(repository, `${CAPTURE_WINDOW_PATH}?app=${encodeURIComponent(applicationId)}`);
38
+ const response = await gatewayFetch(path, token);
43
39
  if (!response.ok)
44
- throw await classifyHttpError(response, `Haystack API ${CAPTURE_WINDOW_PATH}`);
40
+ throw await classifyHttpError(response, `Haystack API ${path}`);
45
41
  const answer = await response.json();
46
42
  if (answer.version !== 'capture-v1' || answer.applicationId !== applicationId)
47
43
  throw new Error('the capture window answer names another application');
48
44
  if (answer.window === null)
49
- return { applicationId, state: 'no-data' };
50
- return { applicationId, state: 'ready', window: captureChangedRoutes(answer.window, files, now) };
45
+ return { app, applicationId, state: 'no-data' };
46
+ return { app, applicationId, state: 'ready', window: captureChangedRoutes(answer.window, files, now) };
51
47
  }
52
48
  catch (error) {
53
- return { applicationId, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) };
49
+ return { app, applicationId, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) };
54
50
  }
55
51
  }
52
+ /** The pre-verify capture sections, one per app with a record, or null for a checkout with none. `files`: the brief's function
53
+ * files and the changed files. */
54
+ export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
55
+ let apps;
56
+ try {
57
+ apps = captureConfigDirs(gitRoot, []);
58
+ }
59
+ catch (error) {
60
+ return [{ app: '.', applicationId: null, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) }];
61
+ }
62
+ if (apps.length === 0)
63
+ return null;
64
+ return Promise.all(apps.map(app => readApp(gitRoot, app, repository, token, files, now)));
65
+ }
56
66
  const percent = (share) => share === null ? 'n/a' : `${(share * 100).toFixed(1)}%`;
57
67
  const plain = (value) => value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' '); // eslint-disable-line no-control-regex
58
- /** The block as a coding agent reads it, after the brief. */
68
+ /** One app's block as a coding agent reads it, after the brief. */
59
69
  export function formatCapture(capture) {
60
- const lines = [chalk.bold('What real users do where your change is (captured sessions)')];
70
+ const lines = [chalk.bold(`What real users do where your change is (captured sessions of ${capture.app === '.' ? 'the app at the repository root' : plain(capture.app)})`)];
61
71
  if (capture.state === 'unavailable') {
62
72
  lines.push(` Captured sessions could not be read${capture.applicationId ? ` for ${plain(capture.applicationId)}` : ''}: ${plain(capture.reason)}`);
63
73
  return lines.join('\n');
@@ -10,7 +10,7 @@ export const CAPTURE_SCRIPT_MAX_BYTES = 8 * 1024;
10
10
  export const CAPTURE_ROUTE_ID_MAX = 200;
11
11
  /** Rule 4 Publishing: the largest route manifest (current.json and <release>.json), in bytes. */
12
12
  export const CAPTURE_MANIFEST_MAX_BYTES = 256 * 1024;
13
- export const CAPTURE_WINDOW_PATH = '/api/agent/cloud-verifier/capture/window';
13
+ export const CAPTURE_WINDOW_PATH = '/capture/window';
14
14
  /** Rule 8(e): telemetry older than this is reported stale; rule 7b: a capture window older than this is stale. */
15
15
  export const CAPTURE_EVIDENCE_FRESH_HOURS = 24;
16
16
  export const CAPTURE_ROUTE_UNKNOWN = 'unknown';
@@ -39,8 +39,8 @@ export function captureChangedRoutes(window, files, now) {
39
39
  }
40
40
  export const TELEMETRY_TOKEN_VERSION = 'telemetry-token-v1';
41
41
  export const TELEMETRY_ENVIRONMENT = 'production';
42
- export const TELEMETRY_TOKEN_PATH = '/api/agent/cloud-verifier/telemetry/token';
43
- export const TELEMETRY_SERVER_STATUS_PATH = '/api/agent/cloud-verifier/telemetry/server';
42
+ export const TELEMETRY_TOKEN_PATH = '/telemetry/token';
43
+ export const TELEMETRY_SERVER_STATUS_PATH = '/telemetry/server';
44
44
  export const TELEMETRY_TOKEN_SHA256 = /^[0-9a-f]{64}$/;
45
45
  /** Lane A's capture status: GET /api/agent/cloud-verifier/capture/status?repository=&app= (rule 8b's key registration). */
46
- export const CAPTURE_STATUS_PATH = '/api/agent/cloud-verifier/capture/status';
46
+ export const CAPTURE_STATUS_PATH = '/capture/status';
@@ -0,0 +1,196 @@
1
+ export const TERMINAL = new Set(['completed', 'prepared', 'incomplete', 'cancelled']);
2
+ export const VERDICTS = ['bug', 'cant-tell', 'being-judged', 'not-a-bug'];
3
+ export const STATUS_WORDS = {
4
+ queued: 'waiting for a machine',
5
+ preparing: 'getting ready',
6
+ crawling: 'exploring the app',
7
+ draining: 'finishing the last checks',
8
+ completed: 'finished',
9
+ prepared: 'built and ready to check',
10
+ incomplete: 'stopped before finishing',
11
+ cancelled: 'cancelled',
12
+ };
13
+ export const STAGE_WORDS = {
14
+ reconcile: 'cleaning up after an earlier try',
15
+ source: 'rebuilding your change',
16
+ worlds: 'building and starting the app with and without your change',
17
+ blast: 'waiting for the analysis of what your change touches',
18
+ knobs: "reading the app's settings",
19
+ prepare: 'setting up starting points next to your change',
20
+ crawl: 'exploring the app',
21
+ publish: 'saving the results',
22
+ cleanup: 'shutting down the machines it used',
23
+ };
24
+ export const ERROR_WORDS = {
25
+ 'source-mismatch': 'your change could not be rebuilt exactly as it was captured',
26
+ 'blast-unavailable': 'the analysis of what your change touches was not available',
27
+ 'world-build-failed': 'the app did not build or start, with or without your change',
28
+ 'prepare-failed': 'setting up starting points next to your change failed',
29
+ 'crawl-failed': 'exploring the app failed',
30
+ 'no-capacity': 'there was no room on the machines for it',
31
+ stalled: 'it stopped making progress',
32
+ 'lease-lost': 'the machine running it stopped responding',
33
+ cancelled: 'it was cancelled',
34
+ };
35
+ export const SPOT_WORDS = {
36
+ resolved: 'never reached',
37
+ loaded: 'loaded but never ran',
38
+ exposed: 'shown on screen',
39
+ activated: 'ran',
40
+ pressed: 'ran when its control was pressed',
41
+ observed: 'a difference seen',
42
+ confirmed: 'a difference seen and confirmed',
43
+ // The stage, not the verdict: a judged difference may be judged not a bug. The findings carry the verdicts.
44
+ judged: 'a difference seen, confirmed and judged',
45
+ };
46
+ export const VERDICT_WORDS = {
47
+ bug: 'Bug',
48
+ 'cant-tell': "Can't tell",
49
+ 'being-judged': 'Still being judged',
50
+ 'not-a-bug': 'Not a bug',
51
+ };
52
+ export function plural(count, noun) {
53
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
54
+ }
55
+ export function errorWords(error) {
56
+ return error.code === 'stalled'
57
+ ? `${ERROR_WORDS.stalled} while ${STAGE_WORDS[error.stage]}`
58
+ : ERROR_WORDS[error.code];
59
+ }
60
+ /** What a running crawl is doing now: its stage, except while it waits for a
61
+ * machine or finishes its last checks, which its status says better. */
62
+ export function currentStep(view) {
63
+ return view.status === 'queued' || view.status === 'draining' ? STATUS_WORDS[view.status] : STAGE_WORDS[view.stage];
64
+ }
65
+ /** The crawl's results: its sealed manifest, else (amendment 9) its answer at the budget, which has the same findings and reach. */
66
+ export function results(view) {
67
+ return view.manifest ?? (view.answer?.status === 'answered' ? view.answer : null);
68
+ }
69
+ /** The status the command reports: a running crawl that has answered (amendment 9: its budget is up and its work is
70
+ * done; only its shutdown remains) reads as completed. */
71
+ export function reportedStatus(view) {
72
+ return !TERMINAL.has(view.status) && view.answer?.status === 'answered' ? 'completed' : view.status;
73
+ }
74
+ export function headline(view) {
75
+ const bugs = results(view)?.bugs ?? view.totals.bugs;
76
+ const found = bugs > 0 ? ` It found ${plural(bugs, 'bug')} before it stopped.` : '';
77
+ switch (reportedStatus(view)) {
78
+ case 'completed':
79
+ return bugs > 0 ? `Found ${plural(bugs, 'bug')} in your change.` : 'No bugs found in your change.';
80
+ case 'incomplete': {
81
+ const error = results(view)?.error ?? view.error;
82
+ return `The crawl could not finish: ${error ? errorWords(error) : 'no reason was recorded'}.${found}`;
83
+ }
84
+ case 'prepared':
85
+ // Amendment 12: the turn-end hook built and froze this change; `haystack verify` asks for the crawl itself.
86
+ return 'Your change is built and ready to check, but was not crawled yet. Run `haystack verify` to check it.';
87
+ case 'cancelled':
88
+ return `The crawl was cancelled.${found}`;
89
+ default: {
90
+ const so = bugs > 0 ? ` ${plural(bugs, 'bug')} found so far.` : '';
91
+ if (view.cancelRequested) {
92
+ return `Being cancelled: ${currentStep(view)}.${so}`;
93
+ }
94
+ return `Still running: ${currentStep(view)}.${so}`;
95
+ }
96
+ }
97
+ }
98
+ export function verifyReport(view) {
99
+ const status = reportedStatus(view);
100
+ const published = results(view);
101
+ const error = published?.error ?? view.error;
102
+ const explored = published !== null && view.clocks.crawlStartedAt !== null;
103
+ const findings = explored ? VERDICTS.flatMap(verdict => published.findings.filter(finding => finding.verdict === verdict)) : [];
104
+ const ideaWords = { covered: 'tried', blocked: 'could not try', unfinished: 'not finished' };
105
+ return {
106
+ runId: view.runId,
107
+ repository: view.repository,
108
+ title: view.changeTitle,
109
+ baseCommit: view.baseCommit,
110
+ workCommit: view.workCommit,
111
+ status,
112
+ headline: headline(view),
113
+ bugs: published?.bugs ?? view.totals.bugs,
114
+ findings: findings.map(finding => ({
115
+ id: finding.id,
116
+ verdict: finding.verdict,
117
+ why: finding.why,
118
+ start: finding.start,
119
+ steps: finding.steps,
120
+ outsideServices: finding.standIns && (finding.standIns.old.length > 0 || finding.standIns.new.length > 0)
121
+ ? { withoutChange: finding.standIns.old, withChange: finding.standIns.new }
122
+ : null,
123
+ })),
124
+ spots: explored ? published.reach.spots.map(spot => ({
125
+ file: spot.file,
126
+ line: spot.line,
127
+ reached: spot.furthest === null ? 'never reached' : SPOT_WORDS[spot.furthest],
128
+ reachedBy: spot.reachedBy && { start: spot.reachedBy.start, steps: spot.reachedBy.steps, page: spot.reachedBy.page },
129
+ findingIds: spot.findingIds,
130
+ })) : [],
131
+ ideas: explored && published.ideas !== undefined
132
+ ? published.ideas.map(idea => ({ text: idea.text, result: ideaWords[idea.state], reason: idea.reason, findingIds: idea.findingIds }))
133
+ : null,
134
+ neverRan: explored ? {
135
+ files: published.reach.unreachedChangedFiles,
136
+ functions: published.reach.blast === null ? null : { ran: published.reach.blast.ran, total: published.reach.blast.functions },
137
+ } : null,
138
+ notFinishedInTime: explored ? published.notFinishedInTime : 0,
139
+ outsideServiceGaps: explored ? published.standIns?.gaps ?? [] : [],
140
+ error: error ? { code: error.code, message: error.message } : null,
141
+ machinesNotProvenShutDown: TERMINAL.has(view.status) ? view.totals.cleanupUnproven : 0,
142
+ };
143
+ }
144
+ /** The report as markdown: headings an agent can navigate, bugs first, each with the steps to see it. */
145
+ export function reportMarkdown(report) {
146
+ const lines = [`# ${report.headline}`, '',
147
+ `Change: ${report.title}`, `Check ${report.runId} of ${report.repository}: base ${report.baseCommit.slice(0, 12)}, change ${report.workCommit.slice(0, 12)}.`];
148
+ if (report.error && report.status !== 'completed')
149
+ lines.push(`Details: ${report.error.message}`);
150
+ if (report.neverRan === null) {
151
+ lines.push('', TERMINAL.has(report.status)
152
+ ? 'Nothing about the change was measured: the crawl did not explore the app.'
153
+ : 'Where the change shows up, and what the crawl found, appear when it finishes.');
154
+ return lines.join('\n') + '\n';
155
+ }
156
+ lines.push('', '## Where the change shows up in the app');
157
+ if (report.spots.length === 0)
158
+ lines.push('No changed spots were recorded.');
159
+ for (const spot of report.spots) {
160
+ lines.push(`- \`${spot.file}:${spot.line}\`: ${spot.reached}${spot.findingIds.length ? ` (${plural(spot.findingIds.length, 'finding')})` : ''}`);
161
+ if (spot.reachedBy)
162
+ lines.push(` - reached by: ${[spot.reachedBy.start, ...spot.reachedBy.steps].join(' > ')}${spot.reachedBy.page ? ` (page ${spot.reachedBy.page})` : ''}`);
163
+ }
164
+ lines.push('', '## What the crawl found');
165
+ if (report.findings.length === 0)
166
+ lines.push('No confirmed differences between the app with and without the change.');
167
+ report.findings.forEach((finding, index) => {
168
+ lines.push(`${index + 1}. **${VERDICT_WORDS[finding.verdict]}** (\`${finding.id}\`): ${finding.why ?? 'no reason was recorded.'}`, ` Starting from ${finding.start}:`, ...finding.steps.map((step, n) => ` ${n + 1}. ${step}`));
169
+ if (finding.outsideServices) {
170
+ lines.push(` Outside services on this path, answered by stand-ins: without the change ${finding.outsideServices.withoutChange.join('; ') || 'none'};`
171
+ + ` with it ${finding.outsideServices.withChange.join('; ') || 'none'}.`);
172
+ }
173
+ });
174
+ if (report.notFinishedInTime > 0) {
175
+ lines.push(`${plural(report.notFinishedInTime, 'difference')} ${report.notFinishedInTime === 1 ? 'was' : 'were'} still being checked when its time ran out.`);
176
+ }
177
+ if (report.ideas !== null) {
178
+ lines.push('', '## Your ideas');
179
+ for (const idea of report.ideas)
180
+ lines.push(`- ${idea.result}: ${idea.text}. ${idea.reason}`);
181
+ }
182
+ lines.push('', '## What the crawl never ran');
183
+ if (report.neverRan.files.length === 0)
184
+ lines.push('Every changed file ran at least once.');
185
+ else
186
+ lines.push('Changed files no path ran:', ...report.neverRan.files.map(file => `- \`${file}\``));
187
+ if (report.neverRan.functions)
188
+ lines.push(`${report.neverRan.functions.ran} of ${plural(report.neverRan.functions.total, 'affected function')} ran.`);
189
+ if (report.outsideServiceGaps.length > 0) {
190
+ lines.push('', '## Outside services the copy could not imitate', 'Missing coverage of the environment, not bugs in the change:', ...report.outsideServiceGaps.map(gap => `- ${gap}`));
191
+ }
192
+ if (report.machinesNotProvenShutDown > 0) {
193
+ lines.push('', `${plural(report.machinesNotProvenShutDown, 'machine')} it used could not be proven shut down.`);
194
+ }
195
+ return lines.join('\n') + '\n';
196
+ }
@@ -14,8 +14,8 @@ import chalk from 'chalk';
14
14
  import { DATABASE_PROFILE_UPLOAD_REQUEST_VERSION, parseDatabaseProfileUpload, } from './db-profile-contract.js';
15
15
  import { readDatabaseProfileFile } from './db-profile-show.js';
16
16
  import { resolveAuthContext } from '../utils/auth.js';
17
- import { classifyHttpError, haystackApiUrl, readWithRetries, transientReadFailure } from '../utils/haystack-api.js';
18
- export const DATABASE_PROFILES_PATH = '/api/agent/cloud-verifier/database-profiles';
17
+ import { classifyHttpError, haystackApiUrl, readWithRetries, transientReadFailure, repoApiPath } from '../utils/haystack-api.js';
18
+ export const DATABASE_PROFILES_PATH = '/database-profiles';
19
19
  /** GitHub's owner and repository name alphabets. */
20
20
  const REPOSITORY = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9_.-]{1,100})$/u;
21
21
  /** Run-map dependency ids are stable kebab-case ids (the same check as
@@ -60,7 +60,7 @@ export async function uploadDatabaseProfile(request, token) {
60
60
  }
61
61
  }
62
62
  async function uploadDatabaseProfileOnce(request, token) {
63
- const response = await fetch(haystackApiUrl(DATABASE_PROFILES_PATH), {
63
+ const response = await fetch(haystackApiUrl(repoApiPath(request.repository, DATABASE_PROFILES_PATH)), {
64
64
  method: 'POST',
65
65
  headers: {
66
66
  Authorization: `Bearer ${token}`,
@@ -0,0 +1,66 @@
1
+ /**
2
+ * haystack feedback <message...>
3
+ *
4
+ * Tells the Haystack team something, in a sentence: a verify that went wrong,
5
+ * an output that confused you, a missing option. Meant for coding agents as
6
+ * much as for people: when Haystack gets in the way, say so here and keep
7
+ * going. The message lands in the team's Slack at once, with the CLI version,
8
+ * the platform and the repository (when run inside a GitHub checkout).
9
+ *
10
+ * `-` as the whole message reads it from stdin.
11
+ */
12
+ import { readFileSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import chalk from 'chalk';
16
+ import { withSchema } from '../schema.js';
17
+ import { resolveAuthContext } from '../utils/auth.js';
18
+ import { parseRemoteUrl } from '../utils/git.js';
19
+ import { haystackJson } from '../utils/haystack-api.js';
20
+ export const FEEDBACK_MAX_CHARS = 4000;
21
+ function cliVersion() {
22
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
23
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
24
+ }
25
+ /** The checkout's GitHub repository, or null outside one (feedback never needs a repository). */
26
+ function currentRepository() {
27
+ try {
28
+ const remote = parseRemoteUrl();
29
+ return `${remote.owner}/${remote.repo}`;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function buildFeedbackRequest(message, options, repository) {
36
+ const text = message.trim();
37
+ if (!text)
38
+ throw new Error('Say what happened: haystack feedback "<message>" (or - to read it from stdin)');
39
+ if (text.length > FEEDBACK_MAX_CHARS) {
40
+ throw new Error(`Feedback is ${text.length} characters; keep it under ${FEEDBACK_MAX_CHARS}`);
41
+ }
42
+ return {
43
+ message: text,
44
+ context: {
45
+ cliVersion: cliVersion(),
46
+ platform: `${process.platform}-${process.arch} node ${process.versions.node}`,
47
+ repository,
48
+ runId: options.run ?? null,
49
+ },
50
+ };
51
+ }
52
+ export async function feedbackCommand(words, options) {
53
+ const message = words.length === 1 && words[0] === '-' ? readFileSync(0, 'utf8') : words.join(' ');
54
+ const request = buildFeedbackRequest(message, options, currentRepository());
55
+ const { token } = await resolveAuthContext({ preferredLogin: options.account });
56
+ await haystackJson('/v1/feedback', token, {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify(request),
60
+ });
61
+ if (options.json) {
62
+ process.stdout.write(JSON.stringify(withSchema('feedback', { status: 'sent' }), null, 2) + '\n');
63
+ return;
64
+ }
65
+ console.log(chalk.green('Sent to the Haystack team.'));
66
+ }
@@ -20,19 +20,21 @@
20
20
  * - the report (8e). InitTelemetryReport, plus the person's steps beside it: the ingest token (init never mints, prints,
21
21
  * stores or reads one), production's environment, the start command, the deploy and the off switches (13c).
22
22
  */
23
- import { spawnSync } from 'node:child_process';
24
- import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs';
23
+ import { execFileSync, spawnSync } from 'node:child_process';
24
+ import { existsSync, lstatSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
25
25
  import { createRequire } from 'node:module';
26
26
  import { dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import chalk from 'chalk';
29
- import { classifyHttpError } from '../utils/haystack-api.js';
29
+ import { classifyHttpError, repoApiPath } from '../utils/haystack-api.js';
30
30
  import { gatewayFetch } from './case-batch.js';
31
31
  import { CAPTURE_EVIDENCE_FRESH_HOURS, CAPTURE_STATUS_PATH, } from './capture-contract.js';
32
32
  import { captureAnswers, planCapture } from './init-capture.js';
33
33
  import { discoverApps } from '../capture/adapters/index.js';
34
+ import { CAPTURE_CONFIG_PATH, recordedApps } from '../capture/app-config.js';
34
35
  import { loadBabel } from '../capture/js-ast.js';
35
36
  import { lineDiff } from './init.js';
37
+ import { lockfileDrift, mergePnpmLockfile } from './lockfile-pin.js';
36
38
  import { readTelemetryServerStatus } from './telemetry-token.js';
37
39
  import { readFacts } from './verify-onboarding.js';
38
40
  /** Lane D1: with the user's yes, init checks browser capture's registration with Haystack around the writes. */
@@ -214,15 +216,20 @@ function planDependency(gitRoot, appDir, pkg, after, need) {
214
216
  const spec = `${CLI_PACKAGE}@${version}`;
215
217
  const dev = field === 'devDependencies';
216
218
  const workspaceRoot = isFile(join(appDir, 'pnpm-workspace.yaml')) ? ['--workspace-root'] : [];
219
+ // Each package manager's add, as it changes no lockfile entry but the CLI's own (lockfile-pin.ts): npm with its tree
220
+ // nested under the CLI (hoisting would replace packages the app already locks), pnpm through the merge.
217
221
  const argv = manager === null ? null : {
218
222
  pnpm: ['pnpm', 'add', '--save-exact', dev ? '--save-dev' : '--save-prod', ...workspaceRoot, spec],
219
- npm: ['npm', 'install', '--save-exact', dev ? '--save-dev' : '--save-prod', spec],
223
+ npm: ['npm', 'install', '--save-exact', dev ? '--save-dev' : '--save-prod', '--install-strategy=nested', spec],
220
224
  yarn: ['yarn', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
221
225
  bun: ['bun', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
222
226
  }[manager.name];
223
227
  const action = promoting ? `Moved from devDependencies to dependencies at ${version}` : `Pinned to ${version}`;
228
+ const lockfileNote = manager?.name === 'pnpm'
229
+ ? `, keeping every other entry of pnpm's lockfile as it was (pnpm's own add re-resolves packages the CLI shares with the app; init adds only the CLI's entries, then \`pnpm install --frozen-lockfile\`)`
230
+ : `, which adds only the CLI's entries to ${manager?.name}'s lockfile`;
224
231
  const reason = argv
225
- ? `${why} ${action} with \`${argv.join(' ')}\` in ${inside(gitRoot, appDir)}, which updates ${manager?.name}'s lockfile too.`
232
+ ? `${why} ${action} with \`${argv.join(' ')}\` in ${inside(gitRoot, appDir)}${lockfileNote}.`
226
233
  : `${why} ${action}: a newer release never reaches your build unless you change it.`;
227
234
  // The package manager's own word is not taken for it: the manifest must say what the integration needs.
228
235
  const check = () => {
@@ -235,7 +242,39 @@ function planDependency(gitRoot, appDir, pkg, after, need) {
235
242
  return null;
236
243
  };
237
244
  const touches = manager === null ? [] : LOCKFILES.map(([lockfile]) => join(manager.root, lockfile));
238
- return { kind: 'change', change: change(gitRoot, pkg.path, reason, after, content, argv ? { argv, cwd: appDir, touches, check } : undefined) };
245
+ const exec = manager === null || argv === null ? undefined : () => pin(manager, appDir, argv);
246
+ return { kind: 'change', change: change(gitRoot, pkg.path, reason, after, content, argv ? { argv, cwd: appDir, touches, check, exec } : undefined) };
247
+ }
248
+ /** The lockfile a package manager writes, in a format lockfile-pin.ts reads (bun's binary lockb aside). */
249
+ function textLockfile(manager) {
250
+ const name = { pnpm: 'pnpm-lock.yaml', npm: 'package-lock.json', yarn: 'yarn.lock', bun: 'bun.lock' }[manager.name];
251
+ const path = join(manager.root, name);
252
+ return isFile(path) ? { path, format: manager.name } : null;
253
+ }
254
+ /** Runs the add, so that the lockfile gains the CLI's entries and nothing else moves (rule 13); throws (init then restores
255
+ * every file) when the package manager moved anything else. */
256
+ function pin(manager, appDir, argv) {
257
+ const run = (command) => { execFileSync(command[0], command.slice(1), { cwd: appDir, stdio: ['ignore', 2, 2] }); };
258
+ const lockfile = textLockfile(manager);
259
+ const before = lockfile ? readFileSync(lockfile.path, 'utf8') : null;
260
+ const importer = posix.normalize(relative(manager.root, appDir).split(sep).join('/')) || '.';
261
+ if (manager.name === 'pnpm' && lockfile && before !== null) {
262
+ // pnpm resolves the CLI, init keeps the rest as it was, and the install takes exactly that lockfile (proving it).
263
+ run([...argv, '--lockfile-only']);
264
+ writeFileSync(lockfile.path, mergePnpmLockfile(before, readFileSync(lockfile.path, 'utf8'), importer, CLI_PACKAGE));
265
+ run(['pnpm', 'install', '--frozen-lockfile']);
266
+ }
267
+ else {
268
+ run(argv);
269
+ }
270
+ if (!lockfile || before === null)
271
+ return;
272
+ const moved = lockfileDrift(lockfile.format, before, readFileSync(lockfile.path, 'utf8'), importer, CLI_PACKAGE);
273
+ if (moved.length > 0) {
274
+ throw new Error(`\`${argv.join(' ')}\` also changed ${moved.length} lockfile ${moved.length === 1 ? 'entry' : 'entries'} the CLI does not own`
275
+ + ` (${moved.slice(0, 5).join(', ')}${moved.length > 5 ? ', …' : ''}), so init put every file back. Add ${CLI_PACKAGE} at its exact version`
276
+ + ` to ${inside(manager.root, appDir) || '.'} yourself, keeping the lockfile's other entries, then run \`haystack init\` again`);
277
+ }
239
278
  }
240
279
  function isNode(value) {
241
280
  return !!value && typeof value === 'object' && typeof value.type === 'string' && typeof value.start === 'number';
@@ -819,13 +858,18 @@ export async function planTelemetry(gitRoot, repository, options) {
819
858
  // One rule for which directories are apps (capture/adapters/index.ts): a web framework an adapter claims, or a package
820
859
  // with a start script.
821
860
  await loadBabel();
822
- const apps = await discoverApps(gitRoot);
861
+ // The user's earlier answer first: the one app a .haystack/capture.json records (with its origins and consent, which
862
+ // captureAnswers reads from it). Several records are as ambiguous as several apps.
863
+ const discovered = await discoverApps(gitRoot);
864
+ const recorded = recordedApps(gitRoot, discovered);
865
+ const apps = recorded.length > 0 ? recorded : discovered;
823
866
  if (apps.length !== 1) {
824
867
  // Rule 8(a): init never picks among several apps, and never guesses one where it found none.
825
868
  const ask = apps.length === 0
826
869
  ? 'init found no app here (no web framework it recognizes and no package with a start script). If the repository has one that serves production, run '
827
870
  + '`haystack init` again with `--app <its directory>`.'
828
- : `Ask your user which of these apps serves production: ${apps.join(', ')}; then run \`haystack init\` again with \`--app <that directory>\`.`;
871
+ : `Ask your user which of these apps serves production: ${apps.join(', ')}${recorded.length > 0 ? ` (each has a ${CAPTURE_CONFIG_PATH})` : ''};`
872
+ + ' then run `haystack init` again with `--app <that directory>`.';
829
873
  const questions = [ask, ...captureQuestions(options.origins, options.consent)];
830
874
  // A notice too, so a preview (which reports no telemetry yet) already says what to ask.
831
875
  notices.push(`Telemetry waits for your user's answers: ${questions.join(' ')}`);
@@ -834,6 +878,8 @@ export async function planTelemetry(gitRoot, repository, options) {
834
878
  captureRegistration: null };
835
879
  }
836
880
  appDir = realpathSync(join(gitRoot, apps[0]));
881
+ if (recorded.length > 0)
882
+ notices.push(`Telemetry: the app is ${apps[0]}, as its ${CAPTURE_CONFIG_PATH} records (\`--app\` picks another).`);
837
883
  }
838
884
  const app = inside(gitRoot, appDir);
839
885
  const changes = [];
@@ -915,7 +961,7 @@ async function readJson(path, token) {
915
961
  }
916
962
  /** Lane A's registration of the application: its newest capture event, or null when it is not registered (404) or has none. */
917
963
  async function captureReceived(repository, app, token) {
918
- const status = await readJson(`${CAPTURE_STATUS_PATH}?repository=${encodeURIComponent(repository)}&app=${encodeURIComponent(app)}`, token);
964
+ const status = await readJson(repoApiPath(repository, `${CAPTURE_STATUS_PATH}?app=${encodeURIComponent(app)}`), token);
919
965
  return status === null ? null : received(status.lastSeenAt, status.release);
920
966
  }
921
967
  function withReceived(part, evidence) {
@@ -54,9 +54,11 @@ export const AGENT_NOTE = [
54
54
  '- Fix each bug it reports that your change caused, then run `haystack verify` again.',
55
55
  '- If it says onboarding needs an answer, ask the user that question and file their choice with',
56
56
  ' `haystack verify answer <question-id> <choice>`.',
57
- '- Read its short report. `--json` prints the full result, often hundreds of kilobytes, for scripts;',
57
+ '- Read its short report. `--json` prints the same report as data; `--raw` prints the crawl\'s whole record (large), for scripts;',
58
58
  ' `haystack verify --help` explains the output and exit codes.',
59
- '- If `haystack` is not installed, run each command as `npx -y @haystackeditor/cli@latest <command>`.',
59
+ '- If `haystack` is not installed, run each command as `npx -y -p @haystackeditor/cli@latest haystack <command>`.',
60
+ '- If Haystack fails, confuses you or gets in your way, say so in a sentence with',
61
+ ' `haystack feedback "<what happened>"` and keep going: the Haystack team sees it at once.',
60
62
  NOTE_END,
61
63
  ].join('\n');
62
64
  const EXIT_CODES = { planned: 2, 'login-required': 4, 'app-required': 5 };
@@ -310,7 +312,10 @@ function applyChange(gitRoot, planned) {
310
312
  if (planned.run) {
311
313
  const before = existsSync(planned.target) ? readFileSync(planned.target, 'utf8') : '';
312
314
  // Its output goes to stderr: in --json mode stdout carries only the one document.
313
- execFileSync(planned.run.argv[0], planned.run.argv.slice(1), { cwd: planned.run.cwd, stdio: ['ignore', 2, 2] });
315
+ if (planned.run.exec)
316
+ planned.run.exec();
317
+ else
318
+ execFileSync(planned.run.argv[0], planned.run.argv.slice(1), { cwd: planned.run.cwd, stdio: ['ignore', 2, 2] });
314
319
  const failed = planned.run.check?.() ?? null;
315
320
  if (failed !== null)
316
321
  throw new Error(`\`${planned.run.argv.join(' ')}\` ran, but ${failed}.`);