@hone-ai/cli 1.27.0 → 1.29.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 +90 -0
- package/hone-cli.js +250 -6
- package/lib/doctor-local-run.js +183 -0
- package/lib/doctor-source-dirs.js +78 -0
- package/lib/layout-detect.js +145 -0
- package/lib/learnings-parse.js +64 -0
- package/lib/materialize-diff.js +46 -3
- package/lib/pipeline-config.js +108 -3
- package/lib/repo-tree.js +148 -0
- package/lib/run-report.js +217 -0
- package/lib/verify-patch.js +157 -4
- package/lib/worktree-provision.js +71 -2
- package/package.json +5 -3
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
|
-
|
|
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
|
-
|
|
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)?$/);
|
|
@@ -5755,7 +5879,11 @@ program
|
|
|
5755
5879
|
// escape the repo)
|
|
5756
5880
|
// - .md / .txt / .yml docs (rarely needed for code-builder; would
|
|
5757
5881
|
// blow context with prose)
|
|
5758
|
-
|
|
5882
|
+
// HC-DF-001: config-level default include paths (codebase.default_include_paths)
|
|
5883
|
+
// are merged with story-text auto-detection and --include-paths.
|
|
5884
|
+
const { readCodebaseDefaults } = require('./lib/pipeline-config');
|
|
5885
|
+
const defaultIncludePaths = readCodebaseDefaults(process.cwd()).include_paths;
|
|
5886
|
+
if (issueBodyForFiles || opts.includePaths || defaultIncludePaths.length > 0) {
|
|
5759
5887
|
try {
|
|
5760
5888
|
// HC-019n-followup-13c: word-boundary `\b` at end of extension
|
|
5761
5889
|
// group so `.jsonl` doesn't match as `.js` and `.tsx` doesn't
|
|
@@ -5776,6 +5904,7 @@ program
|
|
|
5776
5904
|
candidates.add(p);
|
|
5777
5905
|
}
|
|
5778
5906
|
}
|
|
5907
|
+
for (const p of defaultIncludePaths) candidates.add(p); // HC-DF-001 config defaults
|
|
5779
5908
|
// HC-019n-followup-26: FILE_PATH_RE above requires a directory segment,
|
|
5780
5909
|
// so a story that names a file BARE (`derive-worker.js:787` in prose)
|
|
5781
5910
|
// extracts 0 paths and step_4 runs blind — and, blind, hallucinates a
|
|
@@ -5928,6 +6057,34 @@ program
|
|
|
5928
6057
|
}
|
|
5929
6058
|
}
|
|
5930
6059
|
|
|
6060
|
+
// HC-019n-followup-42: repository-structure grounding. Bundling FILES is not
|
|
6061
|
+
// grounding — with no tree in the prompt the groomer invents a layout and the
|
|
6062
|
+
// planner inherits it (dogfood run f276fe00). Stamp a compact, capped tree of
|
|
6063
|
+
// tracked paths (scoped to source_dirs/test_dirs when configured) into the run
|
|
6064
|
+
// config; the server renders it for the steps that name paths. Fail-open.
|
|
6065
|
+
try {
|
|
6066
|
+
const { execSync } = require('child_process');
|
|
6067
|
+
const { buildRepoTree } = require('./lib/repo-tree');
|
|
6068
|
+
const { readSourceDirs } = require('./lib/pipeline-config');
|
|
6069
|
+
const tracked = execSync('git ls-files', { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\n').filter(Boolean);
|
|
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
|
+
}
|
|
6078
|
+
const tree = buildRepoTree(tracked, { sourceDirs: dirs.source_dirs, testDirs: dirs.test_dirs });
|
|
6079
|
+
if (tree.selected_total > 0) {
|
|
6080
|
+
orchestrateConfig.repo_tree = tree;
|
|
6081
|
+
const scope = dirs.source_dirs.length ? dirs.source_dirs.join(', ') : 'all tracked';
|
|
6082
|
+
console.log(` → repository structure: ${tree.shown} of ${tree.selected_total} paths under ${scope} (${(Buffer.byteLength(tree.text, 'utf8') / 1024).toFixed(1)} KB) — the groomer/planner/builder are told to derive every path from it`);
|
|
6083
|
+
}
|
|
6084
|
+
} catch (e) {
|
|
6085
|
+
console.warn(` ⚠ repository structure not bundled (non-fatal): ${(e && e.message || String(e)).split('\n')[0]}`);
|
|
6086
|
+
}
|
|
6087
|
+
|
|
5931
6088
|
// HC-101-followup-2: pass the adopter's CI gate config to the
|
|
5932
6089
|
// orchestrator so step_5c can branch (github / local / both / none).
|
|
5933
6090
|
// Defaults to gate=github + local_command='make ci' when the config
|
|
@@ -7360,7 +7517,7 @@ program
|
|
|
7360
7517
|
const { execFileSync, spawnSync } = require('child_process');
|
|
7361
7518
|
const os = require('os');
|
|
7362
7519
|
const { extractFileBlocks, buildDiff, nodeMaterializeIO, MaterializeError } = require('./lib/materialize-diff');
|
|
7363
|
-
const { resolveCommand, verdictFromRun, EXIT } = require('./lib/verify-patch');
|
|
7520
|
+
const { resolveCommand, verdictFromRun, EXIT, resolveAuxGates, classifyGateRun } = require('./lib/verify-patch');
|
|
7364
7521
|
const { gitEnv } = require('./lib/git-env');
|
|
7365
7522
|
const config = getConfig();
|
|
7366
7523
|
const client = api(config);
|
|
@@ -7439,6 +7596,10 @@ program
|
|
|
7439
7596
|
// HC-019n-followup-34: capture the verdict + HEAD sha for the closed-loop
|
|
7440
7597
|
// report (--report). Hoisted so they survive the try/finally to the POST.
|
|
7441
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());
|
|
7442
7603
|
try { headSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8' }).trim(); } catch { /* best-effort */ }
|
|
7443
7604
|
try {
|
|
7444
7605
|
execFileSync('git', ['worktree', 'add', '--detach', wt, 'HEAD'],
|
|
@@ -7478,11 +7639,69 @@ program
|
|
|
7478
7639
|
const timedOut = run.error && run.error.code === 'ETIMEDOUT';
|
|
7479
7640
|
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr });
|
|
7480
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
|
+
|
|
7481
7664
|
emit(
|
|
7482
|
-
{ ok: v.verdict === 'pass', verdict: v.verdict, command, detail: v.detail
|
|
7483
|
-
|
|
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);
|
|
7484
7668
|
exitCode = v.exitCode;
|
|
7485
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
|
+
}
|
|
7486
7705
|
}
|
|
7487
7706
|
} catch (e) {
|
|
7488
7707
|
emit({ ok: false, verdict: 'error', detail: e.message },
|
|
@@ -7506,6 +7725,7 @@ program
|
|
|
7506
7725
|
detail: reportDetail,
|
|
7507
7726
|
attempt: opts.attempt ? Number(opts.attempt) : undefined,
|
|
7508
7727
|
headSha,
|
|
7728
|
+
gates: reportGates.length ? reportGates : undefined,
|
|
7509
7729
|
});
|
|
7510
7730
|
if (opts.format !== 'json') console.log(` → reported verdict '${reportVerdict}' to the server (advisory)`);
|
|
7511
7731
|
} catch (e) {
|
|
@@ -7515,6 +7735,30 @@ program
|
|
|
7515
7735
|
process.exit(exitCode);
|
|
7516
7736
|
});
|
|
7517
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
|
+
|
|
7518
7762
|
// ── HC-019n-followup-25: emit-pr (pipeline-recovery condition 7) ──────────────
|
|
7519
7763
|
// Condition 6 proved step_4's diff applies AND its tests pass in isolation.
|
|
7520
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 };
|