@hone-ai/cli 1.34.0 → 1.35.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.
Files changed (46) hide show
  1. package/bin/hone-mcp.js +1 -0
  2. package/hone-cli.js +1341 -795
  3. package/lib/agent-eval-pacing.js +102 -0
  4. package/lib/agent-eval-probes-boundary.js +0 -0
  5. package/lib/agent-eval-probes-stateful.js +72 -0
  6. package/lib/agent-eval-score.js +70 -0
  7. package/lib/agent-executor.js +26 -4
  8. package/lib/asset-fetch-list.js +72 -0
  9. package/lib/auto-verify.js +67 -3
  10. package/lib/batch-follow.js +333 -0
  11. package/lib/block-grammar.js +126 -0
  12. package/lib/bundle-context.js +43 -4
  13. package/lib/bundle-paths.js +698 -1
  14. package/lib/bundle-source-dirs.js +79 -0
  15. package/lib/doctor-bind-default.js +192 -18
  16. package/lib/doctor-local-run.js +139 -1
  17. package/lib/emit-pr.js +39 -6
  18. package/lib/git-helpers.js +19 -8
  19. package/lib/hard-pause-remedy.js +101 -0
  20. package/lib/include-paths.js +155 -0
  21. package/lib/learnings-parse.js +97 -7
  22. package/lib/materialize-diff.js +131 -73
  23. package/lib/mcp-tools.js +20 -14
  24. package/lib/pipeline-config.js +182 -0
  25. package/lib/private-temp.js +52 -0
  26. package/lib/promote-payload.js +77 -0
  27. package/lib/refresh-knowledge.js +15 -6
  28. package/lib/repo-tree.js +22 -3
  29. package/lib/run-context.js +698 -0
  30. package/lib/run-target.js +66 -1
  31. package/lib/sandbox-lifecycle.js +357 -0
  32. package/lib/schedule-cron.js +1 -1
  33. package/lib/skill-assertions.js +61 -7
  34. package/lib/skill-audit-render.js +1 -1
  35. package/lib/story-classifier-extract.js +16 -8
  36. package/lib/story-classifier.js +64 -0
  37. package/lib/story-compose.js +69 -0
  38. package/lib/story-line.js +40 -0
  39. package/lib/story-provenance.js +267 -0
  40. package/lib/timeout-option.js +42 -0
  41. package/lib/untrusted-code-env.js +51 -0
  42. package/lib/verdict-env.js +31 -0
  43. package/lib/verify-patch.js +74 -4
  44. package/lib/verify-pr.js +2 -1
  45. package/lib/worktree-provision.js +62 -2
  46. package/package.json +1 -1
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+ /**
3
+ * promote-payload.js — what `hone promote-learnings` sends to `POST /learnings/promote`.
4
+ *
5
+ * Extracted by HC-GF-143's batch because HC-GF-136's gate found all seven fields computed inline
6
+ * and asserted by nothing: deleting any of them broke no test.
7
+ *
8
+ * This mapping is an IP BOUNDARY, which is why it is the one to extract first. A learning carries
9
+ * BOTH `local_summary` (this repo's specifics — paths, story ids, internal detail) and
10
+ * `enterprise_summary` (the generalised rule). Only the second may leave the machine. A rename, a
11
+ * copy-paste, or a spread of the whole object would ship the first, and nothing would have noticed.
12
+ */
13
+
14
+ /** Fields that may cross to the server. Anything not named here does not leave. */
15
+ const PROMOTED_FIELDS = Object.freeze([
16
+ 'id', 'repo_name', 'type', 'agent', 'target_skill', 'target_kind', 'enterprise_summary',
17
+ 'proposed_fix',
18
+ ]);
19
+
20
+ /** A path that looks like a pipeline script, named in prose. */
21
+ // HC-GF-161: `tests` added. A learning whose fix is "extend this regression test" names a real,
22
+ // editable target in exactly the same way a script does, and 3 learnings were unroutable only
23
+ // because this list omitted it. The backticks and the extension list are NOT relaxed: the
24
+ // backticks are what stop this matching prose that merely mentions a file, and the extensions
25
+ // are what stop it claiming a doc, for which there is no kind.
26
+ const SCRIPT_PATH = /`((?:scripts|cli|server|tests|\.github)\/[\w./-]+\.(?:js|mjs|cjs|sh))`/;
27
+
28
+ /**
29
+ * HC-GF-159a: WHAT a learning would change, and what it is called.
30
+ *
31
+ * A learning is not always about a skill. It can change a skill, an agent's PROMPT, or a pipeline
32
+ * SCRIPT — and sending only a field named `target_skill` is why all 418 promoted learnings routed
33
+ * to the server's `|| 'general'` fallback. The kind travels with the name so the far side can tell
34
+ * "change pipeline-integrity" from "change the code-reviewer prompt" from "change mutate-check.js".
35
+ *
36
+ * Order matters: a learning that names a skill targets the skill even when an agent captured it —
37
+ * `agent:` records WHO noticed, which is not the same as what should change.
38
+ *
39
+ * An unroutable learning says `unrouted` rather than borrowing a default. 'general' is a claim that
40
+ * the lesson belongs everywhere, which is how 418 of them became indistinguishable.
41
+ */
42
+ function targetOf(l) {
43
+ // An explicitly-set `target_skill` is the most specific statement of intent there is — a caller
44
+ // that already decided. Deriving over the top of it silently overrode the decision, which the
45
+ // HC-GF-143b suite caught when its fixture's `target_skill` was replaced by the agent name.
46
+ if (l.target_skill) return { kind: 'skill', name: l.target_skill };
47
+ if (l.referenced_skill) return { kind: 'skill', name: l.referenced_skill };
48
+ if (l.agent) return { kind: 'prompt', name: l.agent };
49
+ const m = SCRIPT_PATH.exec(String(l.proposed_fix || ''));
50
+ if (m) return { kind: 'script', name: m[1] };
51
+ return { kind: 'unrouted', name: null };
52
+ }
53
+
54
+ /**
55
+ * @param {Array<object>} eligible learnings selected for promotion
56
+ * @returns {Array<object>} one payload row per learning
57
+ */
58
+ function promotePayload(eligible) {
59
+ return (eligible || []).map((l) => {
60
+ const target = targetOf(l);
61
+ return {
62
+ id: l.id || l._file,
63
+ repo_name: l.repo_name,
64
+ // HC-GF-159a: `type` and `agent` come from the parser now. They were mapped here from names
65
+ // the parser never emitted, so JSON.stringify dropped them and the server saw `unknown`.
66
+ type: l.type || null,
67
+ agent: l.agent || null,
68
+ // The NAME the far side routes on, whatever kind of thing it is.
69
+ target_skill: target.name,
70
+ target_kind: target.kind,
71
+ enterprise_summary: l.enterprise_summary,
72
+ proposed_fix: l.proposed_fix || l.absorbed_into || null,
73
+ };
74
+ });
75
+ }
76
+
77
+ module.exports = { promotePayload, PROMOTED_FIELDS, targetOf };
@@ -24,9 +24,12 @@
24
24
  const fs = require('node:fs');
25
25
  const path = require('node:path');
26
26
  const crypto = require('node:crypto');
27
- const { execSync } = require('node:child_process');
27
+ const { execFileSync } = require('node:child_process');
28
28
  const { gitEnv } = require('./git-env');
29
29
 
30
+ // A backup of the knowledge dirs is seconds of work; this only stops a stuck tar holding the run.
31
+ const TAR_TIMEOUT_MS = 2 * 60 * 1000;
32
+
30
33
  // HC-019y (2026-05-29): removed `.github/agents` from refresh paths.
31
34
  // Agents live exclusively at `.claude/agents/` (Claude Code's read path)
32
35
  // after the install-time mirror was eliminated. `.github/agents/` is now
@@ -42,7 +45,7 @@ const ENTERPRISE_MANAGED_END_RE = /<!--\s*END ENTERPRISE-MANAGED\s*-->/;
42
45
  const FRONTMATTER_MANAGED_RE = /^---[\s\S]*?managed_by:\s*\S+[\s\S]*?---/m;
43
46
 
44
47
  /**
45
- * Default channel runner — invokes `hone <name>` via execSync.
48
+ * Default channel runner — invokes `hone <name>` via execFileSync (argv, no shell).
46
49
  * Tests inject a fake runner; real CLI uses this default.
47
50
  */
48
51
  function defaultRunChannel(name, repoRoot) {
@@ -53,7 +56,9 @@ function defaultRunChannel(name, repoRoot) {
53
56
  // down an ambient GIT_DIR — under a hook, `rebase -x`, or
54
57
  // `submodule foreach` — the child would resolve those against the OUTER
55
58
  // repo while we intended repoRoot. Sanitize at the boundary instead.
56
- const out = execSync(`node "${cliPath}" ${name}`, {
59
+ // HC-GF-097-followup-2: argv, not a shell string — double quotes around a path do not
60
+ // stop `$(...)` or backticks, and a channel name is one argument, not a command line.
61
+ const out = execFileSync('node', [cliPath, name], {
57
62
  cwd: repoRoot,
58
63
  env: gitEnv(),
59
64
  encoding: 'utf8',
@@ -319,11 +324,14 @@ function createBackup({ repoRoot, backupDir, knowledgeFiles, now }) {
319
324
 
320
325
  if (relPaths.length === 0) return null;
321
326
 
322
- // Use system tar for portability
327
+ // Use system tar for portability. HC-GF-097-followup-2: argv, not a shell string. These are
328
+ // the adopter repo's own file names, and a double-quoted `$(...)` in one runs as a command.
329
+ // Every relPath starts with a KNOWLEDGE_DIRS entry, so none can be read as a tar option.
323
330
  try {
324
- execSync(`tar -czf "${backupPath}" ${relPaths.map(p => `"${p}"`).join(' ')}`, {
331
+ execFileSync('tar', ['-czf', backupPath, ...relPaths], {
325
332
  cwd: repoRoot,
326
333
  stdio: ['ignore', 'pipe', 'pipe'],
334
+ timeout: TAR_TIMEOUT_MS,
327
335
  });
328
336
  } catch (e) {
329
337
  throw new Error(`tar failed: ${e.message}`);
@@ -333,9 +341,10 @@ function createBackup({ repoRoot, backupDir, knowledgeFiles, now }) {
333
341
  }
334
342
 
335
343
  function restoreFromBackup({ repoRoot, backupPath }) {
336
- execSync(`tar -xzf "${backupPath}"`, {
344
+ execFileSync('tar', ['-xzf', backupPath], {
337
345
  cwd: repoRoot,
338
346
  stdio: ['ignore', 'pipe', 'pipe'],
347
+ timeout: TAR_TIMEOUT_MS,
339
348
  });
340
349
  }
341
350
 
package/lib/repo-tree.js CHANGED
@@ -88,6 +88,16 @@ function renderTree(paths, { maxEntries = DEFAULT_MAX_ENTRIES, maxBytes = DEFAUL
88
88
 
89
89
  const MAX_TRANSPORT_PATHS = 5000;
90
90
  const MAX_TRANSPORT_DIRS = 3000;
91
+ // HC-GF-097: a BYTE bound on the two lists, because the count caps alone bound nothing a request
92
+ // body limit can be derived from — 5000 paths can be any length. Sized so the count caps are what
93
+ // normally decides: 8000 entries at 64 bytes each. This repo transports 2,452 paths + 227 dirs in
94
+ // 116,634 bytes (~44 bytes an entry, measured 2026-09-24), well inside it; a 64 KB cap would have
95
+ // dropped both lists here and switched off the server's `## Files` guard on every run. Over the
96
+ // cap the lists are omitted exactly as over the count caps: the server reads v1 and skips the guard.
97
+ const MAX_TRANSPORT_BYTES = (MAX_TRANSPORT_PATHS + MAX_TRANSPORT_DIRS) * 64;
98
+ // The whole transported tree: both lists, the rendered `text`, and the scalar and detected-dir
99
+ // fields (a few hundred bytes on a real repo; 16 KB leaves room for a pathological layout).
100
+ const MAX_TREE_TRANSPORT_BYTES = MAX_TRANSPORT_BYTES + DEFAULT_MAX_BYTES + 16 * 1024;
91
101
 
92
102
  /**
93
103
  * HC-019n-followup-43: every tracked directory repo-wide (minus generated/dependency dirs),
@@ -111,7 +121,7 @@ function allDirs(allPaths) {
111
121
  * from v1 (prompt bytes stable). Both lists are capped; over the cap they are omitted and
112
122
  * the server treats the tree as v1 (guard skipped, never a false rejection).
113
123
  */
114
- function buildRepoTree(allPaths, { sourceDirs = [], testDirs = [], maxEntries, maxBytes } = {}) {
124
+ function buildRepoTree(allPaths, { sourceDirs = [], testDirs = [], maxEntries, maxBytes, maxTransportBytes = Infinity } = {}) {
115
125
  const selected = selectPaths(allPaths, { sourceDirs, testDirs });
116
126
  const r = renderTree(selected, { maxEntries, maxBytes });
117
127
  const dirs = allDirs(allPaths);
@@ -130,7 +140,13 @@ function buildRepoTree(allPaths, { sourceDirs = [], testDirs = [], maxEntries, m
130
140
  shown: r.shown,
131
141
  text: r.text,
132
142
  };
133
- if (selected.length <= MAX_TRANSPORT_PATHS && dirs.length <= MAX_TRANSPORT_DIRS) {
143
+ // The BYTE cap applies only when the caller passes one: `hone queue-stories`, whose request
144
+ // limit is derived from it. `hone run-story` sends one story and keeps the count caps alone, as
145
+ // before HC-GF-097 — review of #1100 found a 4,900-path repository (under both count caps) that
146
+ // the byte cap would have stripped of its lists, switching off the `## Files` guard on single runs.
147
+ if (selected.length <= MAX_TRANSPORT_PATHS && dirs.length <= MAX_TRANSPORT_DIRS
148
+ && (maxTransportBytes === Infinity
149
+ || Buffer.byteLength(JSON.stringify({ paths: selected, dirs }), 'utf8') <= maxTransportBytes)) {
134
150
  tree.paths = selected; tree.dirs = dirs;
135
151
  }
136
152
  try {
@@ -145,4 +161,7 @@ function buildRepoTree(allPaths, { sourceDirs = [], testDirs = [], maxEntries, m
145
161
  return tree;
146
162
  }
147
163
 
148
- module.exports = { selectPaths, renderTree, buildRepoTree, allDirs, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_BYTES, MAX_TRANSPORT_PATHS, MAX_TRANSPORT_DIRS };
164
+ module.exports = {
165
+ selectPaths, renderTree, buildRepoTree, allDirs, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_BYTES,
166
+ MAX_TRANSPORT_PATHS, MAX_TRANSPORT_DIRS, MAX_TRANSPORT_BYTES, MAX_TREE_TRANSPORT_BYTES,
167
+ };