@haystackeditor/cli 0.23.3 → 0.24.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 CHANGED
@@ -159,7 +159,10 @@ Intent is optional there: `--intent-file <path>` accepts JSON with exactly
159
159
  `problem`, `goal`, and `intended_outcomes`. It waits up to 35 minutes by
160
160
  default; pass `--no-wait` to return after the fleet run is queued. Repeating the
161
161
  same repository, commits, and intent reuses the same run; choose a new
162
- `--idempotency-key` when a fresh execution is intentional. A wait that expires
162
+ `--idempotency-key` when a fresh execution is intentional. A fresh run on the
163
+ same base reuses the repository's onboarding: phases that finished are not
164
+ redone, and a phase that stopped terminally starts again once the fleet runs a
165
+ different engine release (on the same release it stays stopped). A wait that expires
163
166
  prints `timed_out: true` and `next_command`, then exits 2. Use the returned
164
167
  account-bound `next_command` verbatim on machines with multiple saved accounts.
165
168
 
@@ -416,6 +416,14 @@ function loadInstrumentation() {
416
416
  allowedFields: safeArrayIsArray(site.allowed_fields)
417
417
  ? safeArraySlice(site.allowed_fields, 0)
418
418
  : [],
419
+ // Source position, present only when the build instrumented source
420
+ // itself (bundler loader). Joins to the change's function line ranges.
421
+ ...(safeNumberIsSafeInteger(site.line) && site.line >= 1
422
+ ? {
423
+ line: site.line,
424
+ ...(safeNumberIsSafeInteger(site.column) && site.column >= 0 ? { column: site.column } : {}),
425
+ }
426
+ : {}),
419
427
  })]);
420
428
  const sites = new SafeMap();
421
429
  for (let index = 0; index < siteEntries.length; index++) {
@@ -4,6 +4,9 @@ export const CRAWL_BUDGET_MIN_MS = 60_000;
4
4
  export const CRAWL_BUDGET_MAX_MS = 30 * 60_000;
5
5
  export const CRAWL_MAX_FINDINGS = 50;
6
6
  export const CRAWL_MAX_FINDING_STEPS = 64;
7
+ /** Amendment 17: an intent or one idea is at most this many characters, and a crawl takes at most CRAWL_MAX_IDEAS ideas. */
8
+ export const CRAWL_MAX_TEXT_CHARS = 2000;
9
+ export const CRAWL_MAX_IDEAS = 64;
7
10
  export const CRAWL_POOLS = ['fleet', 'freestyle', 'auto'];
8
11
  /** Amendment 9: what a crawl has found so far (`exploring`) or its answer at the budget (`answered`), before the sealed manifest;
9
12
  * findings carry no images. A read may pass `waitAfter=<updatedAt>` to be held until the crawl changes (at most CRAWL_WAIT_MAX_MS). */
@@ -12,3 +15,8 @@ export const CRAWL_WAIT_MAX_MS = 25_000;
12
15
  * (how many times the copy's calls reached the stand-in across the crawl's preparation and all its replays, not user actions). */
13
16
  export const CRAWL_MAX_STAND_IN_ROWS = 200;
14
17
  export const CRAWL_MAX_STAND_IN_LINES = 20;
18
+ /** Amendment 18: the brief's bounds. */
19
+ export const CRAWL_BRIEF_MAX_FUNCTIONS = 2000;
20
+ export const CRAWL_BRIEF_MAX_GROUPS = 2000;
21
+ /** Amendment 19: rows one brief function keeps per kind of production evidence (the rest counted). */
22
+ export const CRAWL_BRIEF_MAX_PRODUCTION_ROWS = 100;
@@ -11,7 +11,9 @@
11
11
  * 2. a short marked block in AGENTS.md (and in CLAUDE.md when the repository
12
12
  * keeps one) telling coding agents to run `haystack verify` after a change.
13
13
  * It then starts onboarding the app at the base `haystack verify` would use, so
14
- * the app is usually ready by the first verify. Running it again changes only
14
+ * the app is usually ready by the first verify. With --notes, the coding agent's
15
+ * notes on how the app runs, signs people in and is used (onboarding-notes.ts)
16
+ * become the repository's onboarding notes first, so onboarding plans from them. Running it again changes only
15
17
  * what is missing or out of date and shows where onboarding is.
16
18
  *
17
19
  * Exit codes: 0 set up (onboarding running or ready), 1 failed, 2 changes shown
@@ -30,7 +32,8 @@ import { HAYSTACK_APP_INSTALL_URL, HaystackApiError, INSTALLATION_REQUIRED_CODE
30
32
  import { findGitRoot } from '../utils/hooks.js';
31
33
  import { assertNoSymlinkOnPath } from '../utils/safe-write.js';
32
34
  import { CLAUDE_LOCAL_SETTINGS, CLAUDE_SHARED_SETTINGS, HAYSTACK_PRECOMPUTE_HOOK_COMMAND, claudeEntries, claudeSettingsPath, isCLIInstalled, isGitIgnored, isInstalledGlobally, isPrecomputeCommand, readClaudeSettings, } from './install-session-hooks.js';
33
- import { formatOnboarding, reportOnboardingState, startOnboarding } from './verify-onboarding.js';
35
+ import { NOTE_FIELDS, readNotes } from './onboarding-notes.js';
36
+ import { formatOnboarding, reportOnboardingState, startOnboarding, writeOnboardingNotes } from './verify-onboarding.js';
34
37
  import { deriveBaseSha, EXPLICIT_WALL_MS, resolveOriginRepository } from './verify-precompute.js';
35
38
  const NOTE_BEGIN = '<!-- haystack:begin -->';
36
39
  const NOTE_END = '<!-- haystack:end -->';
@@ -168,7 +171,10 @@ function ignoreRule(gitRoot, path) {
168
171
  }
169
172
  /** Claude Code's Stop hook in the per-developer settings, and the exclude line that keeps that file out of commits. */
170
173
  function planStopHook(gitRoot, notices) {
171
- if (!isPlainDirectory(join(gitRoot, '.claude')) && !isCLIInstalled('claude'))
174
+ // Claude Code sets CLAUDECODE=1 in every command it runs, including from the desktop app and IDE
175
+ // extensions, where `claude` is often not on PATH.
176
+ const claudeCodeInUse = process.env.CLAUDECODE === '1' || isPlainDirectory(join(gitRoot, '.claude')) || isCLIInstalled('claude');
177
+ if (!claudeCodeInUse)
172
178
  return [];
173
179
  const settingsPath = `.claude/${CLAUDE_LOCAL_SETTINGS}`;
174
180
  const localPath = claudeSettingsPath(gitRoot, CLAUDE_LOCAL_SETTINGS);
@@ -328,11 +334,15 @@ export async function initCommand(options) {
328
334
  throw new Error('This checkout has no GitHub `origin` remote. Haystack verifies GitHub repositories: add the repository as `origin`, then run `haystack init` again.');
329
335
  }
330
336
  const repository = `${origin.owner}/${origin.repository}`;
337
+ // Read before anything else, so notes that cannot be used stop init with nothing done.
338
+ const notes = options.notes === undefined ? null : readNotes(options.notes);
331
339
  const plan = planInit(gitRoot);
340
+ let notesWritten = null;
332
341
  const finish = (status, onboarding, exitCode) => {
333
342
  if (options.json) {
334
343
  const changes = plan.changes.map(({ path, action, reason, diff }) => ({ path, action, reason, diff }));
335
- process.stdout.write(`${JSON.stringify(withSchema('init', { status, repository, changes, notices: plan.notices, onboarding }), null, 2)}\n`);
344
+ process.stdout.write(`${JSON.stringify(withSchema('init', { status, repository, changes, notices: plan.notices, notes: notesWritten,
345
+ onboarding }), null, 2)}\n`);
336
346
  }
337
347
  process.exitCode = exitCode;
338
348
  };
@@ -347,6 +357,10 @@ export async function initCommand(options) {
347
357
  }
348
358
  for (const notice of plan.notices)
349
359
  say(`\n${chalk.yellow(notice)}`);
360
+ if (notes) {
361
+ const given = NOTE_FIELDS.filter(field => notes[field] !== '');
362
+ say(`\nOnboarding notes from ${options.notes === '-' ? 'stdin' : options.notes}: ${given.join(', ')}`);
363
+ }
350
364
  say('');
351
365
  let token;
352
366
  try {
@@ -373,6 +387,11 @@ export async function initCommand(options) {
373
387
  }
374
388
  let onboarding;
375
389
  try {
390
+ // The notes first: onboarding plans from the facts it starts with, and new notes are new facts (a new onboarding).
391
+ if (notes) {
392
+ notesWritten = await writeOnboardingNotes(repository, notes, token);
393
+ say(notesWritten === 'written' ? chalk.green('✓ Wrote the onboarding notes') : chalk.dim('The onboarding notes are already these.'));
394
+ }
376
395
  onboarding = await startOnboarding(repository, deriveBaseSha(gitRoot, Date.now() + EXPLICIT_WALL_MS), token);
377
396
  }
378
397
  catch (error) {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The notes a coding agent gives onboarding (APP-ONBOARDING-V1 rule 7, amendment 18): what it knows of how this repository's
3
+ * app runs, signs people in and is used, read from a JSON file (`haystack init --notes <file>`, `-` for stdin):
4
+ *
5
+ * { "run": "...", "signIn": "...", "workflow": "..." }
6
+ *
7
+ * Each is plain text; any may be left out, at least one must say something. Onboarding plans from them and still proves
8
+ * everything it takes from them.
9
+ */
10
+ import { readFileSync } from 'node:fs';
11
+ export const NOTE_FIELDS = ['run', 'signIn', 'workflow'];
12
+ /** ONBOARDING_NOTE_MAX_CHARS in onboarding_contracts.ts: characters (code points) per note. */
13
+ export const NOTE_MAX_CHARS = 8_000;
14
+ export const NOTES_FORMAT = `{ "run": "how production installs, builds and starts the app", "signIn": "how a person gets an account and signs in, and the kinds of users", "workflow": "what a signed-in user mainly does" }`;
15
+ /** The notes in a file's text, trimmed; an error that says what to fix otherwise. */
16
+ export function parseNotes(text, source) {
17
+ let value;
18
+ try {
19
+ value = JSON.parse(text);
20
+ }
21
+ catch (error) {
22
+ throw new Error(`${source} is not JSON (${error instanceof Error ? error.message : String(error)}). Write ${NOTES_FORMAT}.`);
23
+ }
24
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
25
+ throw new Error(`${source} is not a JSON object. Write ${NOTES_FORMAT}.`);
26
+ const row = value;
27
+ const unknown = Object.keys(row).filter(key => !NOTE_FIELDS.includes(key));
28
+ if (unknown.length)
29
+ throw new Error(`${source} has ${unknown.map(key => JSON.stringify(key)).join(', ')}; the notes are "run", "signIn" and "workflow".`);
30
+ const notes = { run: '', signIn: '', workflow: '' };
31
+ for (const field of NOTE_FIELDS) {
32
+ const note = row[field];
33
+ if (note === undefined)
34
+ continue;
35
+ if (typeof note !== 'string')
36
+ throw new Error(`${source}: "${field}" is not text.`);
37
+ if (note.includes('\0'))
38
+ throw new Error(`${source}: "${field}" contains a NUL character.`);
39
+ const trimmed = note.trim();
40
+ const length = [...trimmed].length;
41
+ if (length > NOTE_MAX_CHARS)
42
+ throw new Error(`${source}: "${field}" is ${length} characters; a note is at most ${NOTE_MAX_CHARS}.`);
43
+ notes[field] = trimmed;
44
+ }
45
+ if (NOTE_FIELDS.every(field => notes[field] === ''))
46
+ throw new Error(`${source} says nothing: give at least one of "run", "signIn" and "workflow".`);
47
+ return notes;
48
+ }
49
+ /** `--notes <file>`: the notes in that file, or in stdin for `-`. */
50
+ export function readNotes(path) {
51
+ const source = path === '-' ? 'The notes on stdin' : `The notes file ${path}`;
52
+ let text;
53
+ try {
54
+ text = readFileSync(path === '-' ? 0 : path, 'utf8');
55
+ }
56
+ catch (error) {
57
+ throw new Error(`${source} could not be read: ${error instanceof Error ? error.message : String(error)}`);
58
+ }
59
+ return parseNotes(text, source);
60
+ }
61
+ export function sameNotes(a, b) {
62
+ return a !== undefined && NOTE_FIELDS.every(field => a[field] === b[field]);
63
+ }
@@ -0,0 +1,154 @@
1
+ export declare function preloadBabel(): Promise<void>;
2
+ declare const MANIFEST_SCHEMA_VERSION = "haystack-node-telemetry-instrumentation-v3";
3
+ export interface TelemetryInstrumentOptions {
4
+ entry: string;
5
+ sourceRoot?: string;
6
+ json?: boolean;
7
+ /**
8
+ * Experimental exact symbol allow-list. Entries use the same structured
9
+ * sourcePath#qualifiedName coordinates emitted in the instrumentation
10
+ * manifest. No fuzzy name matching is performed.
11
+ */
12
+ includeSymbols?: Array<{
13
+ sourcePath: string;
14
+ qualifiedName: string;
15
+ }>;
16
+ }
17
+ export interface InstrumentedFileSummary {
18
+ output_path: string;
19
+ source_path: string;
20
+ probes: {
21
+ branches: number;
22
+ parameters: number;
23
+ bindings: number;
24
+ returns: number;
25
+ throws: number;
26
+ };
27
+ }
28
+ export interface TelemetryInstrumentationResult {
29
+ schema_version: typeof MANIFEST_SCHEMA_VERSION;
30
+ build_id: string;
31
+ dist_directory: string;
32
+ entry: string;
33
+ runtime: string;
34
+ instrumented_files: InstrumentedFileSummary[];
35
+ sites: InstrumentedSite[];
36
+ skipped_unmapped_files: string[];
37
+ already_instrumented: boolean;
38
+ }
39
+ export interface InstrumentedSite {
40
+ probe_key: string;
41
+ site_id: string;
42
+ source_path: string;
43
+ qualified_name: string;
44
+ probe_kind: 'condition' | 'parameter' | 'binding' | 'return' | 'throw';
45
+ owner_key: string;
46
+ structural_key: string;
47
+ label?: string;
48
+ allowed_fields: string[];
49
+ /**
50
+ * 1-based line and 0-based column of the probed node in `source_path`.
51
+ * Present only when the instrumented text IS the source (the bundler
52
+ * loader). Compiled-output instrumentation has no source map, so its
53
+ * positions would be output coordinates and are omitted rather than
54
+ * mislabelled; consumers join those sites by name only.
55
+ */
56
+ line?: number;
57
+ column?: number;
58
+ }
59
+ export interface FileProbeCounts {
60
+ branches: number;
61
+ parameters: number;
62
+ bindings: number;
63
+ returns: number;
64
+ throws: number;
65
+ }
66
+ /**
67
+ * The runtime source bound to one control channel, plus the integrity token
68
+ * generated probes present to it and the hash a bootstrap verifies before
69
+ * evaluating it. Deterministic in the channel, so a bundler loader running in
70
+ * several worker processes and the step that later writes the runtime agree
71
+ * without exchanging anything.
72
+ */
73
+ export declare function authenticateTelemetryRuntime(controlChannel: string): {
74
+ source: string;
75
+ integrity: string;
76
+ hash: string;
77
+ };
78
+ export declare function instrumentNodeTelemetryBuild(distDirectory: string, options: TelemetryInstrumentOptions): TelemetryInstrumentationResult;
79
+ export declare const BUNDLED_SOURCE_EXTENSIONS: string[];
80
+ export type BundledModuleResult = {
81
+ kind: 'instrumented';
82
+ code: string;
83
+ probes: FileProbeCounts;
84
+ sites: InstrumentedSite[];
85
+ }
86
+ /** Already instrumented, or a "use client" module (its server copy is only the SSR render of browser code). */
87
+ | {
88
+ kind: 'unchanged';
89
+ }
90
+ /** Babel cannot parse it (syntax the bundler's own compiler accepts but Babel does not). */
91
+ | {
92
+ kind: 'unparsed';
93
+ reason: string;
94
+ };
95
+ /**
96
+ * Instrument one source module inside a bundler. `sourcePath` is the
97
+ * repository-relative path every site records, so production counts join the
98
+ * change's files and line ranges exactly. A module Babel cannot parse is
99
+ * reported, not thrown: the bundler's compiler may accept syntax Babel does
100
+ * not, and telemetry must never be the reason a build fails. Any other error
101
+ * is ours and stays loud.
102
+ */
103
+ export declare function instrumentBundledModule(code: string, options: {
104
+ sourcePath: string;
105
+ controlChannel: string;
106
+ runtimeIntegrity: string;
107
+ }): BundledModuleResult;
108
+ export interface BundledTelemetryManifest {
109
+ schema_version: typeof MANIFEST_SCHEMA_VERSION;
110
+ build_id: string;
111
+ bundler: string;
112
+ source_root: string;
113
+ /** Bundled builds have no instrumented entry file; the preload installs. */
114
+ entry: null;
115
+ runtime: string;
116
+ instrumented_files: Array<{
117
+ source_path: string;
118
+ probes: FileProbeCounts;
119
+ }>;
120
+ /** Server modules shipped uninstrumented because Babel could not parse them, with the parser's reason. */
121
+ skipped_unparsed_files: Array<{
122
+ source_path: string;
123
+ reason: string;
124
+ }>;
125
+ sites: InstrumentedSite[];
126
+ }
127
+ /**
128
+ * Write the runtime, its manifest and the preload that installs it into
129
+ * `<outputDirectory>/.haystack-telemetry/`. The runtime reads the manifest
130
+ * beside its own real file; nothing here depends on where a bundler put the
131
+ * chunks. Returns the preload's absolute path for NODE_OPTIONS=--require.
132
+ */
133
+ export declare function writeBundledTelemetryRuntime(options: {
134
+ outputDirectory: string;
135
+ controlChannel: string;
136
+ bundler: string;
137
+ sourceRoot: string;
138
+ modules: Array<{
139
+ sourcePath: string;
140
+ probes: FileProbeCounts;
141
+ sites: InstrumentedSite[];
142
+ }>;
143
+ unparsed: Array<{
144
+ sourcePath: string;
145
+ reason: string;
146
+ }>;
147
+ }): {
148
+ registerPath: string;
149
+ manifest: BundledTelemetryManifest;
150
+ };
151
+ export declare function telemetryInstrumentCommand(distDirectory: string, options: TelemetryInstrumentOptions & {
152
+ includeSymbolsFile?: string;
153
+ }): Promise<void>;
154
+ export {};