@hone-ai/cli 1.28.0 → 1.29.1

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 ADDED
@@ -0,0 +1,90 @@
1
+ # @hone-ai/cli
2
+
3
+ **Hone AI runs an 18-stage AI SDLC pipeline over your repository.** You describe a story; the
4
+ pipeline grooms it, plans the implementation against your real code, writes failing tests, builds,
5
+ reviews for logic and security, and hands you a pull request to review like any other.
6
+
7
+ The orchestration, prompts and skills run on Hone's server. This CLI is the client.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install -g @hone-ai/cli
13
+ hone init --token <YOUR_TOKEN>
14
+ ```
15
+
16
+ A token is issued when your organization is provisioned. Without one the CLI installs fine but
17
+ every server-backed command will refuse.
18
+
19
+ ## The three commands that matter
20
+
21
+ ```bash
22
+ hone setup # detect your stack, write .github/.pipeline-config.yml, install agents + skills
23
+ hone derive # generate domain skills from YOUR codebase (the part that makes plans specific)
24
+ hone run-story # run a story through the pipeline
25
+ ```
26
+
27
+ `hone setup` inspects the repository rather than guessing: it finds where your source actually
28
+ lives and writes that into `codebase.source_dirs`, which is what the pipeline uses to ground every
29
+ agent in your real file layout. Check it afterwards with:
30
+
31
+ ```bash
32
+ hone doctor --check source-dirs
33
+ hone detect-layout
34
+ ```
35
+
36
+ If `source_dirs` is wrong the pipeline is grounded on a partial map, so this is worth thirty seconds.
37
+
38
+ ## Running a story
39
+
40
+ ```bash
41
+ hone run-story 412 # a GitHub issue number
42
+ hone run-story HC-101 --include-paths src/api/orders.js
43
+ ```
44
+
45
+ The run pauses at review gates. Approve or inspect:
46
+
47
+ ```bash
48
+ hone run-story <run-id> --status
49
+ hone show step <run-id> step_1 # what the agent actually produced
50
+ hone run-story <run-id> --approve step_1
51
+ ```
52
+
53
+ ## Editor mode
54
+
55
+ By default the pipeline's generation steps run on Hone's server. In **editor mode** the build step
56
+ runs in your own editor's AI on your own plan, and Hone orchestrates and verifies around it. Set
57
+ `execution.mode: editor` in `.github/.pipeline-config.yml`, then:
58
+
59
+ ```bash
60
+ hone step-instruction <run-id> # what your editor should execute
61
+ hone submit-step <run-id> # hand the result back for gating
62
+ ```
63
+
64
+ Hone does not select or provision your editor's plan, and you remain responsible for complying with
65
+ your agreement with that provider.
66
+
67
+ ## Useful extras
68
+
69
+ ```bash
70
+ hone status # pipeline state for a story
71
+ hone workflows # in-flight runs
72
+ hone usage # your org's token usage against its budget
73
+ hone doctor # repo health checks
74
+ hone billing # subscription status
75
+ ```
76
+
77
+ `hone --help` lists the full command surface.
78
+
79
+ ## Requirements
80
+
81
+ Node.js 20 or newer, and a git repository.
82
+
83
+ ## Support
84
+
85
+ `hone doctor` first — it catches most configuration problems and tells you the fix. Beyond that,
86
+ contact the person who issued your token.
87
+
88
+ ## License
89
+
90
+ UNLICENSED. Proprietary; use is governed by your agreement with Hone AI.
package/hone-cli.js CHANGED
@@ -1463,7 +1463,16 @@ program
1463
1463
  console.log(`Stack: ${stack} | Repo: ${repoName}`);
1464
1464
 
1465
1465
  // 2. Bundle source files
1466
- const sourceDirs = pipelineConfig?.source_dirs || ['src/'];
1466
+ // HC-DF-002: was `pipelineConfig?.source_dirs || ['src/']`. With setup's hardcoded `src/`
1467
+ // on a repo that has no src/, every dir failed the existsSync below and derive bundled ZERO
1468
+ // files — the flagship IP flow generating skills from nothing, silently. readSourceDirs now
1469
+ // drops dead entries and detects the real layout, so derive sees code even on a stale config.
1470
+ const _sd = require('./lib/pipeline-config').readSourceDirs(repoRoot);
1471
+ const sourceDirs = (_sd.source_dirs.length ? _sd.source_dirs : (pipelineConfig?.source_dirs || ['src/']));
1472
+ if (_sd.repaired) {
1473
+ console.log(` → source dirs repaired: ${_sd.dropped.join(', ')} not on disk → using ${sourceDirs.join(', ')}${_sd.detected ? ' (detected)' : ''}`);
1474
+ console.log(' Fix it permanently: set codebase.source_dirs in .github/.pipeline-config.yml');
1475
+ }
1467
1476
  const files = {};
1468
1477
  const maxFiles = parseInt(opts.maxFiles);
1469
1478
  let fileCount = 0;
@@ -3092,6 +3101,32 @@ program
3092
3101
  // Health checks for the adopter repo. First subcheck: docs freshness (catches
3093
3102
  // drifted README test-count badges). Future subchecks (H-018, H-021) plug into
3094
3103
  // the same `--check <name>` switch.
3104
+ program
3105
+ .command('detect-layout')
3106
+ .description('Print the detected source/test directories and framework for this repo as JSON (used by hone setup)')
3107
+ .option('--json', 'JSON output (default)')
3108
+ .action(() => {
3109
+ // HC-DF-002: setup-ai-pipeline.sh is bash and used to HARDCODE `source_dirs: src/` for every
3110
+ // Node repo. Detection belongs in a testable node module; the script shells out to this.
3111
+ try {
3112
+ const { execSync } = require('child_process');
3113
+ const { gitEnv } = require('./lib/git-env');
3114
+ const { detectLayout } = require('./lib/layout-detect');
3115
+ const root = execSync('git rev-parse --show-toplevel', { env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
3116
+ const tracked = execSync('git ls-files', { cwd: root, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\n').filter(Boolean);
3117
+ const fs2 = require('fs'); const path2 = require('path');
3118
+ const d = detectLayout({ root, trackedPaths: tracked });
3119
+ const { detectFrameworks } = require('./lib/layout-detect');
3120
+ const fw = detectFrameworks(tracked, d.manifests, (m) => {
3121
+ try { return fs2.readFileSync(path2.join(root, m), 'utf8'); } catch { return ''; }
3122
+ });
3123
+ process.stdout.write(JSON.stringify({ ...d, ...fw }, null, 2) + '\n');
3124
+ } catch (e) {
3125
+ process.stdout.write(JSON.stringify({ error: String((e && e.message) || e), source_dirs: [], test_dirs: [] }) + '\n');
3126
+ process.exitCode = 1;
3127
+ }
3128
+ });
3129
+
3095
3130
  program
3096
3131
  .command('doctor')
3097
3132
  .description('Run health checks on this repo (docs freshness, etc.)')
@@ -3108,6 +3143,7 @@ program
3108
3143
  // which is the worst doctor outcome ("looks healthy" but
3109
3144
  // actually skipped everything).
3110
3145
  const KNOWN_CHECKS = new Set([
3146
+ 'source-dirs', // HC-DF-002
3111
3147
  'all',
3112
3148
  'docs',
3113
3149
  'admin-merge',
@@ -3115,6 +3151,7 @@ program
3115
3151
  'placeholders',
3116
3152
  'skill-staleness',
3117
3153
  'architecture',
3154
+ 'local-run', // HC-LC-004
3118
3155
  ]);
3119
3156
  if (!KNOWN_CHECKS.has(opts.check)) {
3120
3157
  const known = [...KNOWN_CHECKS].sort().join(', ');
@@ -3178,6 +3215,23 @@ program
3178
3215
  results.push(checkAdminMerge({ repoRoot, lookback: parsedLookback }));
3179
3216
  }
3180
3217
 
3218
+ // HC-DF-002: source_dirs must point at directories that exist — the misconfiguration that
3219
+ // silently guts the repository-structure grounding and `hone derive`.
3220
+ if (opts.check === 'all' || opts.check === 'source-dirs') {
3221
+ const { checkSourceDirs } = require('./lib/doctor-source-dirs');
3222
+ results.push(checkSourceDirs({ repoRoot }));
3223
+ }
3224
+
3225
+ // HC-LC-004: "will a run started now cost me nothing?" Deliberately NOT part of 'all':
3226
+ // it needs a reachable server and spends ~20 tokens of the operator's own subscription
3227
+ // on a live credential probe. That probe is the point — `claude auth status` reported
3228
+ // loggedIn:true through a fully expired token on 2026-09-08, and reported the identical
3229
+ // thing after re-authentication, so the field cannot distinguish the two states.
3230
+ if (opts.check === 'local-run') {
3231
+ const { checkLocalRun } = require('./lib/doctor-local-run');
3232
+ results.push(await checkLocalRun({ apiUrl: getConfig().apiUrl }));
3233
+ }
3234
+
3181
3235
  // HC-002: bind-default check (per E13-A-L1; security-flagged per SC-006-L1)
3182
3236
  if (opts.check === 'all' || opts.check === 'bind-default') {
3183
3237
  const { checkBindDefault } = require('./lib/doctor-bind-default');
@@ -5552,6 +5606,8 @@ program
5552
5606
  .option('--approve <step>', 'Approve a paused gate (e.g., --approve step_0). Exit codes: 2 = paused at a human gate, 3 = parked for your editor (execution.mode: editor)')
5553
5607
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
5554
5608
  .option('--poll-interval <s>', 'Poll interval in seconds', '5')
5609
+ .option('--story-file <path>', 'HC-GF-010: read the story text from a local file. Skips the GitHub-issue and Hone-roadmap lookups entirely, so a repo with neither can still run the pipeline.')
5610
+ .option('--description <text>', 'HC-GF-010: the story text, inline. Same effect as --story-file.')
5555
5611
  .option('--include-paths <paths>', 'Comma-separated list of source file paths to bundle into the codebase context (HC-019n-followup-12). Augments auto-detected paths from the issue body.')
5556
5612
  .option('--e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) ON for this run (overrides e2e.spec_generation config)')
5557
5613
  .option('--skip-e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) OFF for this run (overrides e2e.spec_generation config)')
@@ -5559,6 +5615,23 @@ program
5559
5615
  const config = getConfig();
5560
5616
  const client = api(config);
5561
5617
 
5618
+ // HC-LC-004: the FIRST line, before any other diagnostic, naming which server this run
5619
+ // will use and what it will spend. Sourced from the server's /health, NEVER from
5620
+ // `config` — a banner computed client-side prints whatever this shell believes, which
5621
+ // is exactly the belief that is wrong when it matters. `getConfig` has resolved the API
5622
+ // URL at :48 since forever and never printed it, so ~12 diagnostic lines could scroll
5623
+ // past without once naming the target. That is the silent-wrong-result class that made
5624
+ // the 2026-09-08 editor test nearly meaningless; here it costs money rather than time.
5625
+ try {
5626
+ const h = await client.get('/health').then(r => r.data).catch(() => null);
5627
+ const llm = (h && h.llm) || null;
5628
+ if (llm && llm.local) {
5629
+ console.log(`LOCAL ${config.apiUrl} · provider claude_cli · no paid fallback · this run costs $0.00`);
5630
+ } else {
5631
+ console.log(`PROD ${config.apiUrl} · provider ${llm ? llm.primary : 'unknown'} · THIS RUN COSTS MONEY`);
5632
+ }
5633
+ } catch { /* never block a run on a banner */ }
5634
+
5562
5635
  // Resolve: if it looks like a UUID, use as runId directly.
5563
5636
  // Otherwise treat as storyId — will be used for POST /orchestrate body.
5564
5637
  const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(storyIdOrRunId);
@@ -5597,6 +5670,17 @@ program
5597
5670
  console.log(`Approve: hone run-story ${data.runId} --approve ${data.currentStep}`);
5598
5671
  }
5599
5672
  console.log('');
5673
+ // HC-GF-016: at a TERMINAL state, print the report unasked. The whole
5674
+ // point is that the operator should not have to remember to ask — the
5675
+ // action items are what get lost in the scrollback. Best-effort: a
5676
+ // rendering problem must never change what `--status` reports.
5677
+ if (['completed', 'failed', 'killed'].includes(data.status)) {
5678
+ try {
5679
+ const { buildReport, renderReport } = require('./lib/run-report');
5680
+ console.log(renderReport(buildReport(data)));
5681
+ console.log('');
5682
+ } catch { /* the status above is the contract; this is an extra */ }
5683
+ }
5600
5684
  } catch (e) {
5601
5685
  console.error(`Failed: ${e.response?.data?.error || e.message}`);
5602
5686
  process.exit(1);
@@ -5665,7 +5749,47 @@ program
5665
5749
  const orchestrateConfig = {};
5666
5750
  let issueBodyForFiles = null;
5667
5751
  let resolvedStoryId = storyIdOrRunId; // canonical id for architect-config lookup
5668
- if (/^\d+$/.test(storyIdOrRunId)) {
5752
+ // HC-GF-010: an EXPLICIT story source, and the only one that works on a repository
5753
+ // that is neither a GitHub issue tracker nor listed on Hone's own roadmap.
5754
+ //
5755
+ // Why this exists: story text reached step_0 from exactly two places — a numeric GitHub
5756
+ // issue, or `GET /stories/:id/context` against Hone's server-side roadmap behind a
5757
+ // fail-closed org allowlist. On any other product every story id 404s, and the CLI
5758
+ // WARNS AND PROCEEDS, so step_0 grooms a story it never received. That is the
5759
+ // documented cause of the placeholder cascades, and it is why FlowForge's 33
5760
+ // pre-written stories in hone/*.issue.md could not be run at all.
5761
+ //
5762
+ // It SHORT-CIRCUITS both lookups rather than merely winning a precedence contest: with
5763
+ // an explicit description the 404-and-proceed path cannot fire, so the operator can
5764
+ // never be told "no roadmap row found" about a story they just handed us.
5765
+ let explicitStory = null;
5766
+ if (opts.storyFile) {
5767
+ const p = path.resolve(opts.storyFile);
5768
+ try {
5769
+ explicitStory = fs.readFileSync(p, 'utf8');
5770
+ } catch (e) {
5771
+ console.error(`✗ --story-file: cannot read ${p} — ${e.message}`);
5772
+ process.exit(1);
5773
+ }
5774
+ if (!explicitStory.trim()) {
5775
+ console.error(`✗ --story-file: ${p} is empty. A blank story would groom to placeholders.`);
5776
+ process.exit(1);
5777
+ }
5778
+ } else if (opts.description) {
5779
+ explicitStory = String(opts.description);
5780
+ if (!explicitStory.trim()) {
5781
+ console.error('✗ --description is empty. A blank story would groom to placeholders.');
5782
+ process.exit(1);
5783
+ }
5784
+ }
5785
+
5786
+ if (explicitStory) {
5787
+ orchestrateConfig.story_description = explicitStory;
5788
+ issueBodyForFiles = explicitStory;
5789
+ console.log(
5790
+ ` → story text from ${opts.storyFile ? path.relative(process.cwd(), path.resolve(opts.storyFile)) : '--description'} ` +
5791
+ `(${explicitStory.length} chars) — roadmap and issue lookups skipped`);
5792
+ } else if (/^\d+$/.test(storyIdOrRunId)) {
5669
5793
  try {
5670
5794
  const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8' }).trim();
5671
5795
  const m = remoteUrl.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
@@ -5944,6 +6068,13 @@ program
5944
6068
  const { readSourceDirs } = require('./lib/pipeline-config');
5945
6069
  const tracked = execSync('git ls-files', { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\n').filter(Boolean);
5946
6070
  const dirs = readSourceDirs(process.cwd());
6071
+ // HC-DF-002: readSourceDirs self-heals a stale config; say so, or the operator sees a
6072
+ // confident success line built from directories that do not exist.
6073
+ if (dirs.repaired) {
6074
+ console.log(` ⚠ source dirs in .pipeline-config.yml not found on disk: ${dirs.dropped.join(', ')}`);
6075
+ console.log(` using ${dirs.detected ? 'detected' : 'the surviving'} dirs instead: ${dirs.source_dirs.join(', ') || '(none)'}`);
6076
+ console.log(' fix it permanently: hone detect-layout, then set codebase.source_dirs in .github/.pipeline-config.yml');
6077
+ }
5947
6078
  const tree = buildRepoTree(tracked, { sourceDirs: dirs.source_dirs, testDirs: dirs.test_dirs });
5948
6079
  if (tree.selected_total > 0) {
5949
6080
  orchestrateConfig.repo_tree = tree;
@@ -7386,7 +7517,7 @@ program
7386
7517
  const { execFileSync, spawnSync } = require('child_process');
7387
7518
  const os = require('os');
7388
7519
  const { extractFileBlocks, buildDiff, nodeMaterializeIO, MaterializeError } = require('./lib/materialize-diff');
7389
- const { resolveCommand, verdictFromRun, EXIT } = require('./lib/verify-patch');
7520
+ const { resolveCommand, verdictFromRun, EXIT, resolveAuxGates, classifyGateRun } = require('./lib/verify-patch');
7390
7521
  const { gitEnv } = require('./lib/git-env');
7391
7522
  const config = getConfig();
7392
7523
  const client = api(config);
@@ -7465,6 +7596,10 @@ program
7465
7596
  // HC-019n-followup-34: capture the verdict + HEAD sha for the closed-loop
7466
7597
  // report (--report). Hoisted so they survive the try/finally to the POST.
7467
7598
  let reportVerdict = null, reportDetail = null, headSha = null;
7599
+ // HC-GF-026: measured verdicts for the gates step_4 asserts. Hoisted so they
7600
+ // survive the try/finally to the --report POST, exactly like reportVerdict.
7601
+ const reportGates = [];
7602
+ const ciCfg = require('./lib/pipeline-config').readCIConfigDetails(process.cwd());
7468
7603
  try { headSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8' }).trim(); } catch { /* best-effort */ }
7469
7604
  try {
7470
7605
  execFileSync('git', ['worktree', 'add', '--detach', wt, 'HEAD'],
@@ -7504,11 +7639,69 @@ program
7504
7639
  const timedOut = run.error && run.error.code === 'ETIMEDOUT';
7505
7640
  const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr });
7506
7641
  const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'timeout' ? '⏱' : '✗';
7642
+
7643
+ // HC-GF-023: on a failure the operator needs the OUTPUT, not a quote from it.
7644
+ // The full log is written OUTSIDE the worktree (cleanup removes the worktree)
7645
+ // and its path is named, so diagnosing does not require re-running by hand.
7646
+ let logPath = null;
7647
+ if (v.verdict !== 'pass') {
7648
+ try {
7649
+ logPath = path.join(os.tmpdir(), `hone-verify-${workflowId.slice(0, 8)}-${opts.stepKey}.log`);
7650
+ fs.writeFileSync(logPath,
7651
+ `$ ${command}\n(cwd: isolated worktree from HEAD ${headSha || 'unknown'})\n\n` +
7652
+ `${run.stdout || ''}\n${run.stderr || ''}\n`);
7653
+ } catch { logPath = null; }
7654
+ }
7655
+
7656
+ let pretty = `${sym} ${v.detail}\n (command: ${command})`;
7657
+ if (v.failure && v.failure.excerpt && v.failure.excerpt.length) {
7658
+ pretty += `\n ── last ${v.failure.excerpt.length} lines ──\n`
7659
+ + v.failure.excerpt.map(l => ` │ ${l}`).join('\n');
7660
+ }
7661
+ if (logPath) pretty += `\n full output: ${logPath}`;
7662
+ if (v.verdict !== 'pass') pretty += `\n inspect the tree: re-run with --keep`;
7663
+
7507
7664
  emit(
7508
- { ok: v.verdict === 'pass', verdict: v.verdict, command, detail: v.detail },
7509
- `${sym} ${v.detail}\n (command: ${command})`);
7665
+ { ok: v.verdict === 'pass', verdict: v.verdict, command, detail: v.detail,
7666
+ failureCount: v.failure ? v.failure.count : undefined, logPath: logPath || undefined },
7667
+ pretty);
7510
7668
  exitCode = v.exitCode;
7511
7669
  reportVerdict = v.verdict; reportDetail = v.detail;
7670
+
7671
+ // ── HC-GF-026: run the gates step_4 only CLAIMED to run ──────────────
7672
+ // step_4 emitted `$ ruff check … All checks passed!` for a command it
7673
+ // cannot execute, and step_5 repeated it as `STANDARDS: Clean`. These
7674
+ // run in the SAME worktree, on the SAME applied diff, so there is a
7675
+ // measured verdict to set against the asserted one.
7676
+ //
7677
+ // REPORTED, NOT BLOCKING: exitCode is untouched. A lint finding must not
7678
+ // veto a build whose tests pass — that would silently change what
7679
+ // verify-patch's exit code means for every existing adopter, and style
7680
+ // is not correctness. `unavailable` is a third verdict on purpose: a
7681
+ // missing linter must never read as a failing one.
7682
+ try {
7683
+ for (const gate of resolveAuxGates({ configured: ciCfg, cwd: process.cwd() })) {
7684
+ const gr = spawnSync(gate.command, {
7685
+ cwd: wt, shell: true, encoding: 'utf8',
7686
+ timeout: Number(opts.timeout) * 1000, env: gitEnv(),
7687
+ stdio: ['ignore', 'pipe', 'pipe'],
7688
+ });
7689
+ const c = classifyGateRun({
7690
+ code: gr.status, error: gr.error,
7691
+ timedOut: gr.error && gr.error.code === 'ETIMEDOUT',
7692
+ stdout: gr.stdout, stderr: gr.stderr,
7693
+ });
7694
+ reportGates.push({ name: gate.name, command: gate.command, source: gate.source,
7695
+ verdict: c.verdict, detail: c.detail });
7696
+ if (opts.format !== 'json') {
7697
+ const gsym = c.verdict === 'pass' ? '✓' : c.verdict === 'fail' ? '✗' : '○';
7698
+ console.log(` ${gsym} ${gate.name}: ${c.verdict} — ${c.detail} (${gate.command}, via ${gate.source})`);
7699
+ for (const l of (c.verdict === 'fail' ? (c.excerpt || []) : [])) console.log(` │ ${l}`);
7700
+ }
7701
+ }
7702
+ } catch (e) {
7703
+ if (opts.format !== 'json') console.warn(` ⚠ aux gates skipped (${e.message})`);
7704
+ }
7512
7705
  }
7513
7706
  } catch (e) {
7514
7707
  emit({ ok: false, verdict: 'error', detail: e.message },
@@ -7532,6 +7725,7 @@ program
7532
7725
  detail: reportDetail,
7533
7726
  attempt: opts.attempt ? Number(opts.attempt) : undefined,
7534
7727
  headSha,
7728
+ gates: reportGates.length ? reportGates : undefined,
7535
7729
  });
7536
7730
  if (opts.format !== 'json') console.log(` → reported verdict '${reportVerdict}' to the server (advisory)`);
7537
7731
  } catch (e) {
@@ -7541,6 +7735,30 @@ program
7541
7735
  process.exit(exitCode);
7542
7736
  });
7543
7737
 
7738
+ // ── HC-GF-016: run-report — what happened, and what still needs you ──────────
7739
+ // Derived, never narrated. See cli/lib/run-report.js for why that matters.
7740
+ program
7741
+ .command('run-report <runId>')
7742
+ .description('What happened on a run, and what still needs you')
7743
+ .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7744
+ .action(async (runId, opts) => {
7745
+ const { buildReport, renderReport } = require('./lib/run-report');
7746
+ const config = getConfig();
7747
+ const client = api(config);
7748
+ try {
7749
+ const { data } = await client.get(`/orchestrate/${runId}`);
7750
+ const report = buildReport(data);
7751
+ if (opts.format === 'json') console.log(JSON.stringify(report, null, 2));
7752
+ else console.log(renderReport(report));
7753
+ // Exit non-zero when the operator has something to do, so `hone run-report`
7754
+ // is usable as a gate in a script and not merely as prose.
7755
+ process.exit(report.needsYou.length ? 1 : 0);
7756
+ } catch (e) {
7757
+ console.error(`run-report failed: ${e.response?.data?.error || e.message}`);
7758
+ process.exit(1);
7759
+ }
7760
+ });
7761
+
7544
7762
  // ── HC-019n-followup-25: emit-pr (pipeline-recovery condition 7) ──────────────
7545
7763
  // Condition 6 proved step_4's diff applies AND its tests pass in isolation.
7546
7764
  // Condition 7 turns that verified diff into the reviewable artifact: a branch
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+ /**
3
+ * doctor-local-run.js — HC-LC-004. "Will a run started right now cost me nothing?"
4
+ *
5
+ * THE DESIGN CONSTRAINT THAT SHAPED THIS, learned on 2026-09-08: `claude auth status --json`
6
+ * reported `loggedIn: true`, `authMethod: claude.ai` and `subscriptionType: max` while every
7
+ * real call returned `401 OAuth access token has expired`. Then, after re-authentication, it
8
+ * reported EXACTLY THE SAME THING. The field is identical whether the credentials work or
9
+ * not, so a check built on it would have been green through a completely broken state.
10
+ *
11
+ * Therefore this check PROBES. It spends about 20 tokens of the operator's own subscription
12
+ * to learn something no status field can tell it. That is the correct trade: 20 tokens
13
+ * against a run that dies at step 1, or worse, silently completes on a metered key.
14
+ *
15
+ * The two questions it answers, both of which cost money to get wrong:
16
+ * 1. Am I pointed at a server that will run locally, or at production?
17
+ * 2. Do my local credentials actually WORK, right now?
18
+ */
19
+
20
+ const { execFile } = require('child_process');
21
+
22
+ const PROBE_TIMEOUT_MS = 60_000;
23
+
24
+ /** Ask the server what it will spend on. The answer must come from the server, never config. */
25
+ async function fetchHealth(apiUrl, fetchImpl) {
26
+ const f = fetchImpl || globalThis.fetch;
27
+ const res = await f(`${apiUrl.replace(/\/$/, '')}/health`, { method: 'GET' });
28
+ if (!res.ok) throw new Error(`/health returned ${res.status}`);
29
+ return res.json();
30
+ }
31
+
32
+ /**
33
+ * Spend ~20 tokens to prove the credentials work. Resolves {ok, detail}.
34
+ * Injectable so tests never touch the real binary.
35
+ */
36
+ function probeLocalClaude(bin, execImpl) {
37
+ const run = execImpl || execFile;
38
+ return new Promise((resolve) => {
39
+ const env = { ...process.env };
40
+ for (const n of ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL',
41
+ 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX']) delete env[n];
42
+
43
+ const child = run(bin, ['-p', '--output-format', 'json', '--model', 'claude-haiku-4-5'],
44
+ { env, timeout: PROBE_TIMEOUT_MS },
45
+ (err, stdout) => {
46
+ let env2;
47
+ try { env2 = JSON.parse(stdout); } catch {
48
+ return resolve({ ok: false, detail: err ? String(err.message).slice(0, 160) : 'unparseable probe output' });
49
+ }
50
+ if (env2.is_error === true) {
51
+ const status = env2.api_error_status;
52
+ const msg = String(env2.result || '').slice(0, 160);
53
+ if (status === 401 || status === 403 || /authenticate|expired|oauth/i.test(msg)) {
54
+ return resolve({ ok: false, kind: 'auth', detail: msg });
55
+ }
56
+ if (status === 429 || /rate.?limit|usage limit/i.test(msg)) {
57
+ return resolve({ ok: false, kind: 'rate_limit', detail: msg });
58
+ }
59
+ return resolve({ ok: false, kind: 'api', detail: msg });
60
+ }
61
+ resolve({ ok: true, model: Object.keys(env2.modelUsage || {})[0] || null });
62
+ });
63
+ try { child.stdin && child.stdin.end('hi'); } catch { /* the callback reports it */ }
64
+ });
65
+ }
66
+
67
+ /**
68
+ * @returns {{name:'local-run', status:'ok'|'drift'|'info', examined:number, reason:string, suggestedFix?:string}}
69
+ *
70
+ * FIELD NAMES ARE A CONTRACT, not a preference. The doctor renderer at cli/hone-cli.js
71
+ * prints `${icon} ${r.name} — ${r.reason}` and then `Fix: ${r.suggestedFix}`. A check that
72
+ * returns `summary`/`detail` renders as "✓ undefined — undefined" and its remediation is
73
+ * never shown. HC-GF-001 documented exactly that defect in checkSourceDirs on 2026-09-07 —
74
+ * and this check reproduced it verbatim on its first real run, which is why the contract is
75
+ * now pinned by a test rather than remembered.
76
+ *
77
+ * `examined` is mandatory per HC-GF-005 — a result that examined nothing may not report ok.
78
+ */
79
+ async function checkLocalRun(opts = {}) {
80
+ const apiUrl = opts.apiUrl;
81
+ const examined = [];
82
+
83
+ let health;
84
+ try {
85
+ health = await fetchHealth(apiUrl, opts.fetchImpl);
86
+ examined.push('server /health');
87
+ } catch (e) {
88
+ return {
89
+ name: 'local-run', status: 'drift', examined: examined.length,
90
+ reason: `cannot reach ${apiUrl} — is the local server running?`,
91
+ suggestedFix: `Start it with: make services.up && (cd server && npm start). Error: ${e.message}`,
92
+ };
93
+ }
94
+
95
+ const llm = health.llm || {};
96
+
97
+ // Question 1: which server am I on, and what will it spend?
98
+ if (!llm.local) {
99
+ return {
100
+ name: 'local-run', status: 'info', examined: examined.length,
101
+ reason: `${apiUrl} runs on ${llm.primary || 'a metered provider'} — a run started now COSTS MONEY`,
102
+ suggestedFix: 'This is correct for Path A. For a free local run, point HONE_API at your local ' +
103
+ 'server and start it with HONE_LOCAL_CLAUDE=1 and NODE_ENV=development.',
104
+ };
105
+ }
106
+ examined.push('provider identity');
107
+
108
+ if (llm.fallback_disabled !== true) {
109
+ return {
110
+ name: 'local-run', status: 'drift', examined: examined.length,
111
+ reason: 'the local provider is active but a paid fallback is still reachable',
112
+ suggestedFix: 'A local failure could silently complete on a metered provider. The provider ' +
113
+ 'array must be REPLACED when armed, not prepended (HC-LC-003).',
114
+ };
115
+ }
116
+ examined.push('fallback disabled');
117
+
118
+ // A metered credential visible to the child is not fatal — the provider scrubs it — but the
119
+ // operator should know their shell carries one.
120
+ if (llm.auth && llm.auth.api_key_visible) {
121
+ examined.push('credential hygiene');
122
+ }
123
+
124
+ // Question 2: do the credentials actually WORK? The status field cannot answer this.
125
+ if (opts.skipProbe) {
126
+ return {
127
+ name: 'local-run', status: 'info', examined: examined.length,
128
+ reason: 'server is local and fallback is disabled; credentials NOT probed',
129
+ suggestedFix: 'Re-run without --skip-probe to spend ~20 tokens proving the credentials work. ' +
130
+ 'auth status alone is not evidence: it reported loggedIn true through a fully ' +
131
+ 'expired token on 2026-09-08.',
132
+ };
133
+ }
134
+
135
+ const bin = opts.bin || process.env.HONE_LOCAL_CLAUDE_BIN;
136
+ if (!bin) {
137
+ return {
138
+ name: 'local-run', status: 'drift', examined: examined.length,
139
+ reason: 'HONE_LOCAL_CLAUDE_BIN is not set in this shell, so the credentials cannot be probed',
140
+ suggestedFix: 'Set it to the absolute path of your `claude` binary (`which claude`).',
141
+ };
142
+ }
143
+
144
+ const probe = await probeLocalClaude(bin, opts.execImpl);
145
+ examined.push('live credential probe');
146
+
147
+ if (!probe.ok) {
148
+ if (probe.kind === 'auth') {
149
+ return {
150
+ name: 'local-run', status: 'drift', examined: examined.length,
151
+ reason: 'local credentials are NOT working — a run started now would fail at step 0',
152
+ suggestedFix: `Run: claude auth login --claudeai\n The probe said: ${probe.detail}\n ` +
153
+ 'Note `claude auth status` reports loggedIn true in this state — it reports ' +
154
+ 'that credentials exist, not that they work.',
155
+ };
156
+ }
157
+ if (probe.kind === 'rate_limit') {
158
+ return {
159
+ name: 'local-run', status: 'info', examined: examined.length,
160
+ reason: 'your subscription window is currently exhausted — a run would park, not fail',
161
+ suggestedFix: `Wait for the window to refill and resume. The probe said: ${probe.detail}`,
162
+ };
163
+ }
164
+ return {
165
+ name: 'local-run', status: 'drift', examined: examined.length,
166
+ reason: 'the local provider could not complete a probe call',
167
+ suggestedFix: probe.detail,
168
+ };
169
+ }
170
+
171
+ return {
172
+ name: 'local-run', status: 'ok', examined: examined.length,
173
+ reason: 'a run started now costs $0.00 and cannot reach a paid provider',
174
+ suggestedFix: `server: ${apiUrl} · provider: claude_cli · fallback: disabled · ` +
175
+ `probe: ok${probe.model ? ` (${probe.model})` : ''}` +
176
+ (llm.auth && llm.auth.api_key_visible
177
+ ? '\n Note: a metered API key is visible in this shell. The provider scrubs it ' +
178
+ 'from the child, but you may want it unset.'
179
+ : ''),
180
+ };
181
+ }
182
+
183
+ module.exports = { checkLocalRun, probeLocalClaude, fetchHealth };