@hone-ai/cli 1.38.1 → 1.39.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/hone-cli.js CHANGED
@@ -33,13 +33,16 @@ const { parseTimeoutSeconds } = require('./lib/timeout-option.js');
33
33
  // HC-GF-143 batch: both mappings were computed inline and asserted by nothing (HC-GF-136).
34
34
  const { promotePayload } = require('./lib/promote-payload');
35
35
  const { assetFetchList } = require('./lib/asset-fetch-list');
36
+ // HC-CI-019-followup-3: no suite asserted a field of either, and this file cannot be required.
37
+ const { detectExistingTestFramework } = require('./lib/detect-test-framework');
38
+ const { classifySpec } = require('./lib/classify-spec');
36
39
 
37
40
  const pkg = require('./package.json');
38
41
  const { parseReviewJSON } = require('./lib/parse-review-json');
39
42
  const { resolveBaseRef, getMaxDiffChars } = require('./lib/release-review-config');
40
43
  const { gitEnv } = require('./lib/git-env');
41
44
  const { collectIncludePaths } = require('./lib/include-paths');
42
- const { runArgsError, orchestratePath, apiPath, storyDir: storyDirIn, isGitRev } = require('./lib/run-target'); // HC-GF-270-followup-1, -2, -3
45
+ const { runArgsError, orchestratePath, apiPath, storyDir: storyDirIn, isGitRev, isStoryId } = require('./lib/run-target'); // HC-GF-270-followup-1, -2, -3, -4
43
46
  const { normalizeQuestion, questionLines, gateQuestionLines, countPhrase } = require('./lib/operator-question'); // HC-GF-234
44
47
  const program = new Command();
45
48
 
@@ -1177,45 +1180,6 @@ function describeSandboxDisposition(runConfig) {
1177
1180
  }
1178
1181
  }
1179
1182
 
1180
- function detectExistingTestFramework(repoRoot) {
1181
- const pkgPath = path.join(repoRoot, 'package.json');
1182
- if (!fs.existsSync(pkgPath)) return { stack: 'unknown', testFw: null, pkgMgr: null };
1183
-
1184
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
1185
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
1186
-
1187
- // Detect stack
1188
- let stack = 'node';
1189
- if (allDeps['react']) stack = allDeps['vite'] ? 'react-vite' : 'react';
1190
- if (allDeps['next']) stack = 'nextjs';
1191
- if (allDeps['@nestjs/core']) stack = 'nestjs';
1192
-
1193
- // Detect existing test framework
1194
- let testFw = null;
1195
- if (allDeps['vitest']) testFw = 'vitest';
1196
- else if (allDeps['jest']) testFw = 'jest';
1197
- else if (allDeps['mocha']) testFw = 'mocha';
1198
-
1199
- // Detect Playwright
1200
- const hasPlaywright = !!allDeps['@playwright/test'];
1201
-
1202
- // Detect package manager
1203
- let pkgMgr = 'npm';
1204
- if (fs.existsSync(path.join(repoRoot, 'yarn.lock'))) pkgMgr = 'yarn';
1205
- if (fs.existsSync(path.join(repoRoot, 'pnpm-lock.yaml'))) pkgMgr = 'pnpm';
1206
-
1207
- // Detect existing configs
1208
- const hasTestConfig = ['vitest.config.js', 'vitest.config.ts', 'jest.config.js', 'jest.config.ts', 'jest.config.cjs']
1209
- .some(f => fs.existsSync(path.join(repoRoot, f)));
1210
- const hasPlaywrightConfig = fs.existsSync(path.join(repoRoot, 'playwright.config.js'))
1211
- || fs.existsSync(path.join(repoRoot, 'playwright.config.ts'));
1212
-
1213
- // Detect existing test script
1214
- const hasTestScript = !!pkg.scripts?.test && pkg.scripts.test !== 'echo "Error: no test specified" && exit 1';
1215
-
1216
- return { stack, testFw, hasPlaywright, pkgMgr, hasTestConfig, hasPlaywrightConfig, hasTestScript, pkg };
1217
- }
1218
-
1219
1183
  function getInstallPackages(stack) {
1220
1184
  const packages = {
1221
1185
  'react-vite': {
@@ -2727,7 +2691,15 @@ program
2727
2691
  if (!e || !e.id || !e.status) continue;
2728
2692
  // Map E13-A-L1 → E13-A (story id strips trailing -L<n>)
2729
2693
  const storyId = String(e.id).replace(/-L\d+$/i, '');
2730
- const yamlPath = path.join(learningsRoot, `${storyId}.yml`);
2694
+ // HC-GF-270-followup-4: the id and the status come from the server — the id is a file name an org member
2695
+ // uploaded — and they choose and rewrite a local file. An id that is not a story id is skipped and the
2696
+ // path is contained; the status must be one word, because it is written into the YAML as text.
2697
+ if (!isStoryId(storyId) || !/^[a-z][a-z_-]{0,31}$/.test(String(e.status))) {
2698
+ console.log(` ⚠ skipped a report entry whose id is not a story id or whose status is not one word: ${JSON.stringify({ id: e.id, status: e.status })}`);
2699
+ skippedCount++;
2700
+ continue;
2701
+ }
2702
+ const yamlPath = `${storyDirIn(learningsRoot, storyId)}.yml`;
2731
2703
  if (!fs.existsSync(yamlPath)) {
2732
2704
  console.log(` ⚠ no local learnings file for ${storyId} — skip`);
2733
2705
  skippedCount++;
@@ -3890,63 +3862,6 @@ function scanForSpecs(dir, repoRoot, results) {
3890
3862
  } catch {}
3891
3863
  }
3892
3864
 
3893
- function classifySpec(file, repoRoot) {
3894
- let content = '';
3895
- try { content = fs.readFileSync(file.fullPath, 'utf8'); } catch { }
3896
-
3897
- const hasPage = /page\.(goto|click|locator|fill|waitFor|getBy)/.test(content);
3898
- const hasRequest = /request\.(get|post|put|delete|patch)\(/.test(content);
3899
- const hasExpectResponse = /response\.(status|json|ok|body)/.test(content) ||
3900
- /expect\(resp/.test(content);
3901
-
3902
- // Detect type
3903
- let type = 'UNKNOWN';
3904
- if (hasPage && (hasRequest || hasExpectResponse)) type = 'HYBRID';
3905
- else if (hasPage) type = 'BROWSER';
3906
- else if (hasRequest || hasExpectResponse) type = 'API';
3907
- else if (/import.*playwright|from.*@playwright/.test(content)) type = 'BROWSER';
3908
- else if (/import.*supertest|from.*supertest/.test(content)) type = 'API';
3909
-
3910
- // Detect module from filename or content
3911
- // Detect module — filename takes priority over content, most specific first
3912
- let module = 'general';
3913
- const name = file.name.toLowerCase();
3914
- const contentLower = content.toLowerCase();
3915
-
3916
- // Pass 1: check filename (strongest signal)
3917
- if (/auth|login|signup|oauth|session/.test(name)) module = 'auth';
3918
- else if (/trade|order|execution/.test(name)) module = 'trading';
3919
- else if (/position|portfolio|pnl|p&l/.test(name)) module = 'portfolio';
3920
- else if (/alert|notification/.test(name)) module = 'alerts';
3921
- else if (/dashboard|widget|overview/.test(name)) module = 'dashboard';
3922
- else if (/scanner|scan|market/.test(name)) module = 'market';
3923
- else if (/setting|config|preference/.test(name)) module = 'settings';
3924
-
3925
- // Pass 2: fallback to content (if filename didn't match)
3926
- if (module === 'general') {
3927
- if (/login|authenticate|sign.?in/.test(contentLower)) module = 'auth';
3928
- else if (/trading|order|execute/.test(contentLower)) module = 'trading';
3929
- else if (/portfolio/.test(contentLower)) module = 'portfolio';
3930
- else if (/alert|notification/.test(contentLower)) module = 'alerts';
3931
- else if (/dashboard/.test(contentLower)) module = 'dashboard';
3932
- else if (/market|scanner/.test(contentLower)) module = 'market';
3933
- }
3934
-
3935
- // Determine destination
3936
- let destination;
3937
- if (type === 'API') {
3938
- destination = `e2e/api/${file.name}`;
3939
- } else {
3940
- destination = `e2e/regression/${module}/${file.name}`;
3941
- }
3942
-
3943
- // Smoke candidate: login, auth, critical navigation, app loads
3944
- const smoke = /login|auth|app.load|critical|smoke|health/.test(name) ||
3945
- /login|authenticate/.test(contentLower);
3946
-
3947
- return { ...file, type, module, destination, smoke };
3948
- }
3949
-
3950
3865
  // ── Helpers ───────────────────────────────────────────────────────────────────
3951
3866
  function bundleDirectory(dir, root, files, maxFiles, currentCount) {
3952
3867
  if (currentCount >= maxFiles) return;
@@ -6172,6 +6087,8 @@ function runContextIo(apiConfig) {
6172
6087
  },
6173
6088
  recentlyModifiedOverlap: (touched) =>
6174
6089
  require('./lib/story-classifier-extract').extractRecentlyModifiedOverlap(cwd, touched, 14),
6090
+ // HC-GF-217: the compound symbols the story names, searched in THIS checkout (the server has none to search).
6091
+ callSites: (storyText) => require('./lib/call-sites').collectCallSites({ cwd, storyText }),
6175
6092
  };
6176
6093
  }
6177
6094
 
@@ -6737,7 +6654,7 @@ process.on('SIGINT', () => {
6737
6654
  program
6738
6655
  .command('queue-stories')
6739
6656
  .description('Queue a batch of stories for autonomous (overnight) execution')
6740
- .option('--file <path>', 'Text file with one story ID per line (cap: 50 lines, 16KB)')
6657
+ .option('--file <path>', 'Text file with one story per line (cap: 50 lines, 16KB): STORY-ID, then optionally depends:STORY-B,STORY-C and paths:a.js,b.js (files to bundle into that story, as run-story --include-paths does)')
6741
6658
  .option('--repo <name>', 'Repository name applied to every story (default: directory name)')
6742
6659
  .option('--branch <name>', 'Git branch applied to every story (default: current branch)')
6743
6660
  .option('--status <batchId>', 'Show status of a previously submitted batch')
@@ -6934,11 +6851,12 @@ program
6934
6851
 
6935
6852
  // HC-059: `STORY-A depends:STORY-B,STORY-C` per line. HC-GF-140 stage 2: the parse lives in
6936
6853
  // cli/lib so it is asserted by BEHAVIOUR. Every line is parsed before anything is resolved.
6854
+ // HC-GF-351: and `paths:a.js,b.js` for the files that story's context must hold.
6937
6855
  const lines = storyIds.map(line => {
6938
6856
  const parsed = parseStoryLine(line);
6939
6857
  if (parsed.error) {
6940
6858
  console.error(parsed.error);
6941
- console.error(` Expected: "STORY-ID" OR "STORY-ID depends:STORY-B,STORY-C"`);
6859
+ console.error(` Expected: "STORY-ID", optionally followed by depends:STORY-B,STORY-C and/or paths:a.js,b.js`);
6942
6860
  process.exit(1);
6943
6861
  }
6944
6862
  return parsed;
@@ -6979,7 +6897,8 @@ program
6979
6897
  await Promise.all(Array.from({ length: Math.min(RESOLVE_CONCURRENCY, lines.length) }, async () => {
6980
6898
  while (nextLine < lines.length) {
6981
6899
  const i = nextLine++;
6982
- resolved[i] = await resolveStoryContext(rcIo, repoCtx, lines[i].storyId);
6900
+ // HC-GF-351: a line's `paths:` is that story's --include-paths, resolved by the same code.
6901
+ resolved[i] = await resolveStoryContext(rcIo, repoCtx, lines[i].storyId, { includePaths: lines[i].paths });
6983
6902
  }
6984
6903
  }));
6985
6904
  // One summary line per story, and every warning labelled with its story. The info lines
@@ -6987,7 +6906,9 @@ program
6987
6906
  for (const ctx of resolved) {
6988
6907
  const { storyId } = ctx;
6989
6908
  const src = ctx.textSource ? `${ctx.textSource} text (${ctx.rawText.length} chars)` : 'no story text';
6990
- console.log(` [${storyId}] ${src} · ${ctx.bundle.files} codebase file(s) bundled`);
6909
+ const cs = ctx.config.call_sites;
6910
+ const csNote = cs ? ` · ${cs.symbols.length} symbol(s) searched for call sites${cs.symbols.some((s) => s.status !== 'complete') ? ' (not all whole)' : ''}` : '';
6911
+ console.log(` [${storyId}] ${src} · ${ctx.bundle.files} codebase file(s) bundled${csNote}`);
6991
6912
  printRunContextNotes(ctx.notes.pre, { prefix: `[${storyId}]`, only: 'warn' });
6992
6913
  printRunContextNotes(ctx.notes.post, { prefix: `[${storyId}]`, only: 'warn' });
6993
6914
  }
@@ -7290,6 +7211,8 @@ program
7290
7211
  const raw = execFileSync('git', ['diff', '--name-only', `${baseRef}...HEAD`], { encoding: 'utf8', cwd: repoRoot, env: gitEnv() });
7291
7212
  changedFiles = raw.trim().split('\n').filter(Boolean);
7292
7213
  } catch {
7214
+ // HC-GF-270-followup-4: the review covers a different range from here on, so it says so.
7215
+ console.error(`⚠ git could not diff ${baseRef}...HEAD — reviewing the last 10 commits (HEAD~10) instead`);
7293
7216
  try {
7294
7217
  const raw = execSync('git diff --name-only HEAD~10', { encoding: 'utf8', cwd: repoRoot, env: gitEnv() });
7295
7218
  changedFiles = raw.trim().split('\n').filter(Boolean);
@@ -7355,10 +7278,13 @@ program
7355
7278
  try {
7356
7279
  // HC-GF-270-followup-3: each changed file is ONE argument after `--`. They were wrapped in single quotes
7357
7280
  // inside a shell string, so a file a PR author named with a `'` in it ran as a command.
7358
- diffContent = execFileSync('git', ['diff', `${baseRef}...HEAD`, '--', ...filesToReview], {
7281
+ // HC-GF-270-followup-4: and each is read as a literal path. Without --literal-pathspecs a file named `*.js`
7282
+ // matched every .js file, and one named `:(evil)x.js` made git exit, which fell back to HEAD~10 silently.
7283
+ diffContent = execFileSync('git', ['--literal-pathspecs', 'diff', `${baseRef}...HEAD`, '--', ...filesToReview], {
7359
7284
  encoding: 'utf8', cwd: repoRoot, env: gitEnv(), maxBuffer: 10 * 1024 * 1024,
7360
7285
  });
7361
7286
  } catch {
7287
+ console.error(`⚠ git could not diff the changed files against ${baseRef} — reviewing the last 10 commits (HEAD~10) instead`);
7362
7288
  try {
7363
7289
  diffContent = execSync('git diff HEAD~10', { encoding: 'utf8', cwd: repoRoot, env: gitEnv(), maxBuffer: 10 * 1024 * 1024 });
7364
7290
  } catch (e) {
@@ -8432,10 +8358,11 @@ program
8432
8358
  // The server cannot execute, so a batch queued at night reached step_4 and waited for a person to type
8433
8359
  // check-patch, verify-patch and emit-pr. This is the I/O around cli/lib/batch-follow.js, which makes every
8434
8360
  // decision. The verbs run as children of THIS CLI, one at a time, so the loop runs exactly what a person runs.
8435
- // Exit: 0 the batch completed, 1 fatal (refused, unknown batch, unreachable), 2 something needs you, 3 deadline.
8361
+ // Exit: 0 the batch completed, 1 fatal (refused, unknown batch, unreachable), 2 something needs you (HC-GF-351: or every
8362
+ // unfinished run is waiting on a person, named with its step and why), 3 deadline.
8436
8363
  program
8437
8364
  .command('follow-batch <batchId>')
8438
- .description('Follow a queued batch: check and verify each step_4 patch, and emit each completed run (HC-GF-098)')
8365
+ .description('Follow a queued batch: check and verify each step_4 patch, and emit each completed run (HC-GF-098). A run hard-paused for files it was not given is re-served once with them (HC-GF-351); every pause it cannot act on is named, and it exits 2 when only people are left to act')
8439
8366
  .option('--poll-interval <sec>', 'Seconds between polls', '30')
8440
8367
  .option('--deadline <min>', 'Stop after this many minutes, even if the batch is still running')
8441
8368
  .option('--max-offline <min>', 'Give up once the server has been unreachable this long', '15')
@@ -8450,7 +8377,7 @@ program
8450
8377
  console.error(badBatch);
8451
8378
  process.exit(EXIT.FATAL);
8452
8379
  }
8453
- const { spawn, spawnSync } = require('child_process');
8380
+ const { spawn, spawnSync, execFileSync } = require('child_process');
8454
8381
  const positive = (raw, flag, unit) => {
8455
8382
  const n = Number(raw);
8456
8383
  if (!Number.isFinite(n) || n <= 0) {
@@ -8481,12 +8408,42 @@ program
8481
8408
  const io = {
8482
8409
  fetchBatch: async (id) => (await client.get(orchestratePath(['batch', id]))).data,
8483
8410
  fetchRun: async (id) => (await client.get(orchestratePath([id]))).data,
8484
- runVerb: (argv) => new Promise((resolve) => {
8411
+ // `capture` also returns the child's stdout (the tail of it), for the one verb whose answer the loop reads
8412
+ // (queue-stories, HC-GF-351). Either way the child's output reaches this process's own stdout.
8413
+ runVerb: (argv, { capture = false } = {}) => new Promise((resolve) => {
8485
8414
  const child = spawn(process.execPath, [process.argv[1], ...argv],
8486
- { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'inherit', 'inherit'] });
8487
- child.on('error', (e) => resolve({ code: null, error: e }));
8488
- child.on('close', (code) => resolve({ code }));
8415
+ { cwd: process.cwd(), env: process.env, stdio: ['ignore', capture ? 'pipe' : 'inherit', 'inherit'] });
8416
+ let stdout = '';
8417
+ if (capture) child.stdout.on('data', (d) => { process.stdout.write(d); stdout = (stdout + d).slice(-262144); });
8418
+ child.on('error', (e) => resolve({ code: null, error: e, stdout }));
8419
+ child.on('close', (code) => resolve({ code, stdout }));
8489
8420
  }),
8421
+ // HC-GF-351: what a re-serve needs. The regular files git tracks in THIS checkout, listed with their modes so
8422
+ // a tracked symlink (which the bundler would follow out of the checkout) is not among them. Run where the
8423
+ // bundler runs, so the paths are the ones it resolves.
8424
+ trackedPaths: () => require('./lib/context-reserve').trackedRegularFiles(execFileSync('git', ['ls-files', '-z', '-s'], {
8425
+ cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], timeout: 60_000,
8426
+ })),
8427
+ // Where a requested path resolves on the disk NOW, relative to this checkout, or null: the index cannot see a
8428
+ // file or a directory that was replaced by a symlink after the commit, and the bundler reads through it.
8429
+ realPath: (rel) => require('./lib/context-reserve').repoRealPath(process.cwd(), rel),
8430
+ // Was this story served already? A kill that failed, or a crash between the queue and the kill, leaves no marker
8431
+ // and only a newer run of the same story, so the server's own run list is asked (GET /orchestrate?storyId=&repoName=).
8432
+ priorServe: (action) => require('./lib/context-reserve').findServe({
8433
+ listRuns: async (storyId, repo) => (await client.get(apiPath('orchestrate', [], { storyId, repoName: repo, limit: 100 }))).data.runs,
8434
+ listBatches: async () => (await client.get(orchestratePath(['batch'], { limit: 20 }))).data.batches,
8435
+ fetchBatch: async (id) => (await client.get(orchestratePath(['batch', id]))).data,
8436
+ }, action),
8437
+ // One line, in a directory only this invocation can read, for `queue-stories --file`.
8438
+ writeStoriesFile: (text) => {
8439
+ const t = require('./lib/private-temp').privateTempFile('hone-reserve', 'stories.txt');
8440
+ fs.writeFileSync(t.file, text, { mode: 0o600 });
8441
+ return t;
8442
+ },
8443
+ killRun: async (runId, reason) => {
8444
+ if (!require('./lib/run-target').isRunId(runId)) throw new Error('not a run id');
8445
+ await client.post(orchestratePath([runId, 'kill']), { reason });
8446
+ },
8490
8447
  // The consent and provenance rule run-story applies before it runs a patch (HC-GF-064, HC-GF-270).
8491
8448
  verifyDecision: (run) => {
8492
8449
  let assessment;