@hone-ai/cli 1.34.0 → 1.35.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.
Files changed (44) hide show
  1. package/bin/hone-mcp.js +1 -0
  2. package/hone-cli.js +1263 -756
  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 +51 -6
  22. package/lib/materialize-diff.js +131 -73
  23. package/lib/pipeline-config.js +182 -0
  24. package/lib/private-temp.js +34 -0
  25. package/lib/promote-payload.js +77 -0
  26. package/lib/refresh-knowledge.js +15 -6
  27. package/lib/repo-tree.js +22 -3
  28. package/lib/run-context.js +698 -0
  29. package/lib/sandbox-lifecycle.js +357 -0
  30. package/lib/schedule-cron.js +1 -1
  31. package/lib/skill-assertions.js +61 -7
  32. package/lib/skill-audit-render.js +1 -1
  33. package/lib/story-classifier-extract.js +16 -8
  34. package/lib/story-classifier.js +64 -0
  35. package/lib/story-compose.js +69 -0
  36. package/lib/story-line.js +40 -0
  37. package/lib/story-provenance.js +267 -0
  38. package/lib/timeout-option.js +42 -0
  39. package/lib/untrusted-code-env.js +51 -0
  40. package/lib/verdict-env.js +31 -0
  41. package/lib/verify-patch.js +74 -4
  42. package/lib/verify-pr.js +2 -1
  43. package/lib/worktree-provision.js +62 -2
  44. package/package.json +1 -1
@@ -388,6 +388,47 @@ function readExecutionMode(repoRoot) {
388
388
  return DEFAULT_EXECUTION_MODE;
389
389
  }
390
390
 
391
+ /**
392
+ * HC-GF-097: the execution mode AS WRITTEN, beside the normalized one. `readExecutionMode` maps any
393
+ * value it does not know to 'server', which is the right default for a run an operator is
394
+ * watching — and the wrong one for an unattended overnight batch, where "server" is a guess about a
395
+ * typo nobody will see. queue-stories refuses `--overnight` on an unrecognised mode, so it has to
396
+ * be able to tell `mode: edtior` from no `mode:` at all. Same candidate order as readExecutionMode.
397
+ *
398
+ * @param {string} repoRoot
399
+ * @returns {{ raw: string|null, mode: 'server'|'editor', recognized: boolean }}
400
+ * raw is null when no config declares a mode (the default is a recognised state).
401
+ */
402
+ function readExecutionModeRaw(repoRoot) {
403
+ const none = { raw: null, mode: DEFAULT_EXECUTION_MODE, recognized: true };
404
+ if (!repoRoot || typeof repoRoot !== 'string') return none;
405
+ const candidates = [
406
+ path.join(repoRoot, '.pipeline-config.yml'),
407
+ path.join(repoRoot, '.github/.pipeline-config.yml'),
408
+ ];
409
+ for (const p of candidates) {
410
+ if (!fs.existsSync(p)) continue;
411
+ let raw;
412
+ try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; }
413
+ let parsed;
414
+ try {
415
+ const yaml = require('js-yaml');
416
+ parsed = yaml.load(raw);
417
+ } catch { continue; }
418
+ if (!parsed || typeof parsed !== 'object') continue;
419
+ const block = parsed.execution;
420
+ if (!block || typeof block !== 'object') continue;
421
+ if (block.mode === undefined) continue;
422
+ const written = String(block.mode == null ? '' : block.mode);
423
+ return {
424
+ raw: written,
425
+ mode: normalizeExecutionMode(block.mode),
426
+ recognized: EXECUTION_MODES.includes(written.toLowerCase().trim()),
427
+ };
428
+ }
429
+ return none;
430
+ }
431
+
391
432
  // ── HC-019n-followup-34: closed-loop verification gate ────────────────────────
392
433
  // The CLI verbs (verify-patch/verify-pr) EXECUTE and report a verdict to the
393
434
  // server. `verification.gate` controls what the server does with it:
@@ -542,7 +583,147 @@ function readAgentEvalConfig(repoRoot) {
542
583
  return { targets: [] };
543
584
  }
544
585
 
586
+
587
+ // ── HC-GF-169: the adopter-declared scratch datastore ────────────────────────────────────────
588
+ //
589
+ // `.pipeline-config.yml` had no data-layer concept, so no step could validate behaviour needing
590
+ // stored state — which is most of an adopter's backend. The contract is the one `ci.local_command`
591
+ // already uses: Hone cannot know their datastore, so the adopter declares it and the CLI executes
592
+ // it adopter-side, where `verify-patch` already runs with their credentials.
593
+ //
594
+ // THE GUARD IS THE LOAD-BEARING PART. Seeding test rows into a customer's production database is
595
+ // the worst outcome this design can produce, so `verify` is REQUIRED whenever commands are declared
596
+ // and the assertion reads the LIVE CONNECTION, never the URL. A URL can spell `_sandbox` and point
597
+ // anywhere — DNS, a pooler, a tunnel, an overridden PGDATABASE.
598
+ //
599
+ // HC-GF-183, learned from the first live run: THE DATABASE AND THE VERIFY TABLE MUST ALREADY
600
+ // EXIST. The probe runs BEFORE `create` — deliberately, because proving a target is scratch has to
601
+ // precede writing to it — so there is nothing for it to read on a fresh database. That makes
602
+ // `create` responsible for the TABLES, not the database: an adopter writing `create: createdb foo`
603
+ // is refused on their first run with nothing explaining why. Provision the database itself once,
604
+ // out of band, then let `create` build the schema inside it.
605
+ //
606
+ // And `reset` must leave the sandbox PROBEABLE. The CLI runs `reset` then `create` as the teardown
607
+ // (see cli/lib/sandbox-lifecycle.js) precisely so a reset that drops the schema cannot brick the
608
+ // next run — which is exactly what happened the first time this was declared against a real
609
+ // database.
610
+ //
611
+ // Wired since HC-GF-171: verify-patch probes, guards, seeds and tears down.
612
+ const SANDBOX_COMMAND_KEYS = Object.freeze(['create', 'migrate', 'reset']);
613
+
614
+ /**
615
+ * @param {string} repoRoot
616
+ * @returns {null | {create: string|null, migrate: string|null, reset: string|null,
617
+ * capabilities: string[], verify: {database: string, table: string, max_rows: number}}}
618
+ * @throws when a sandbox declares commands without a usable `verify` block.
619
+ */
620
+ function readSandboxConfig(repoRoot) {
621
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
622
+ for (const p of [path.join(repoRoot, '.pipeline-config.yml'),
623
+ path.join(repoRoot, '.github/.pipeline-config.yml')]) {
624
+ if (!fs.existsSync(p)) continue;
625
+ let parsed;
626
+ try { parsed = require('js-yaml').load(fs.readFileSync(p, 'utf8')); } catch { continue; }
627
+ const block = parsed && typeof parsed === 'object' ? parsed.sandbox : null;
628
+ if (!block || typeof block !== 'object') continue;
629
+
630
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
631
+ const commands = {};
632
+ for (const k of SANDBOX_COMMAND_KEYS) commands[k] = str(block[k]);
633
+ const declaresWork = SANDBOX_COMMAND_KEYS.some(k => commands[k]);
634
+
635
+ const v = block.verify && typeof block.verify === 'object' ? block.verify : null;
636
+ // HC-GF-171: `probe` is the command that PRODUCES the evidence `assertScratchTarget` demands.
637
+ // Carried through here, required at ENGAGEMENT (see cli/lib/sandbox-lifecycle.js) rather than
638
+ // at parse time: a declaration missing it is incomplete, not malformed, and the refusal is far
639
+ // more useful at the moment the sandbox would have fired — it can name what to add.
640
+ const verify = v && str(v.database) && str(v.table) && Number.isFinite(Number(v.max_rows))
641
+ ? { database: str(v.database), table: str(v.table), max_rows: Number(v.max_rows),
642
+ probe: str(v.probe) }
643
+ : null;
644
+
645
+ if (declaresWork && !verify) {
646
+ throw new Error(
647
+ `${p}: sandbox declares ${SANDBOX_COMMAND_KEYS.filter(k => commands[k]).join('/')} but no `
648
+ + 'usable `verify` block (needs database, table and max_rows). Without it the CLI cannot '
649
+ + 'prove the target is scratch before writing to it, and an optional guard on this action '
650
+ + 'is worse than no feature.');
651
+ }
652
+ if (!declaresWork && !verify) return null;
653
+
654
+ const capabilities = Array.isArray(block.capabilities)
655
+ ? block.capabilities.map(str).filter(Boolean) : [];
656
+
657
+ // HC-GF-192: `test` is the command that READS the sandbox, and `env` is what it reads the
658
+ // connection from. Carried here and required at ENGAGEMENT, for the same reason `probe` is: a
659
+ // declaration missing them is incomplete rather than malformed, and the refusal is far more
660
+ // useful at the moment the sandbox would have fired, where it can name what to add.
661
+ //
662
+ // Shape is checked in the plan, not here — `env: DATABASE_URL=…` is valid YAML (a string) and
663
+ // an invalid env map, and refusing it next to the other engagement refusals keeps one place
664
+ // that decides whether this sandbox can be used.
665
+ const test = str(block.test);
666
+ // Passed through UNVALIDATED, deliberately. An earlier draft tested the type here and then
667
+ // returned `block.env` on both branches — a guard that read like validation and changed no
668
+ // outcome, which is the shape this whole chain keeps finding. The real check is
669
+ // `plainStringMap` in sandbox-lifecycle.js, where a bad `env` becomes a refusal that names the
670
+ // fix; duplicating it here would mean two places deciding, and eventually disagreeing.
671
+ const env = block.env === undefined ? null : block.env;
672
+
673
+ return { ...commands, capabilities, verify, test, env };
674
+ }
675
+ return null;
676
+ }
677
+
678
+ /**
679
+ * Refuse unless the LIVE connection proves the target is scratch.
680
+ *
681
+ * @param {{database: string, table: string, max_rows: number}} verify what the adopter declared
682
+ * @param {{database?: string, rows?: number, url?: string}} probe what the SERVER reported
683
+ * @throws when the probe is absent, incomplete, names another database, or the target is populated.
684
+ */
685
+ function assertScratchTarget(verify, probe) {
686
+ if (!verify || typeof verify !== 'object') {
687
+ throw new Error('sandbox verify block missing — refusing to treat any target as scratch');
688
+ }
689
+ if (!probe || typeof probe !== 'object') {
690
+ throw new Error('no live probe — refusing to write. "Could not check" is not "checked and '
691
+ + 'found nothing"; run the probe or stop.');
692
+ }
693
+ // `probe.url` is deliberately ignored. It is accepted only so a caller passing it for logging
694
+ // cannot mistake this for a check of it.
695
+ const live = typeof probe.database === 'string' ? probe.database.trim() : null;
696
+ if (!live) {
697
+ throw new Error('the probe reported no database name — refusing. The name must come from the '
698
+ + 'server (e.g. `SELECT current_database()`), not from the connection string.');
699
+ }
700
+ if (live !== verify.database) {
701
+ throw new Error(
702
+ `refusing to write: the live connection reports database "${live}", but the sandbox declares `
703
+ + `"${verify.database}". A connection string can spell the sandbox name and still point `
704
+ + 'elsewhere.');
705
+ }
706
+ // A real NUMBER, not something that coerces to one. `Number(null)`, `Number('')`,
707
+ // `Number(false)` and `Number([])` are all 0, so a probe that reported nothing would read as an
708
+ // empty database and be allowed — the exact "could not check treated as checked" this refuses.
709
+ if (typeof probe.rows !== 'number' || !Number.isFinite(probe.rows)) {
710
+ throw new Error(`refusing to write to "${live}": no row count was probed `
711
+ + `(got ${JSON.stringify(probe.rows)}). A name match alone is not proof — a restored copy of `
712
+ + 'production can carry the scratch name.');
713
+ }
714
+ const rows = probe.rows;
715
+ if (rows > verify.max_rows) {
716
+ throw new Error(
717
+ `refusing to write to "${live}": ${verify.table} holds ${rows} rows, above the declared `
718
+ + `ceiling of ${verify.max_rows}. This looks like real data.`);
719
+ }
720
+ return true;
721
+ }
722
+
545
723
  module.exports = {
724
+ readSandboxConfig,
725
+ assertScratchTarget,
726
+ SANDBOX_COMMAND_KEYS,
546
727
  readSourceDirs,
547
728
  readCodebaseDefaults,
548
729
  readCIConfigDetails,
@@ -551,6 +732,7 @@ module.exports = {
551
732
  EDITOR_MODE_CONSENT_NOTE,
552
733
  normalizeExecutionMode,
553
734
  readExecutionMode,
735
+ readExecutionModeRaw,
554
736
  readStoryClassifierConfig,
555
737
  KNOWN_THRESHOLD_KEYS,
556
738
  readCIGateConfig,
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+ /**
3
+ * private-temp.js — HC-GF-304. A temp file no other process can share.
4
+ *
5
+ * check-patch, verify-patch and emit-pr named their diff `os.tmpdir()/hone-<verb>-${Date.now()}.diff`. Two of
6
+ * them started in the same millisecond on one machine (two worktrees, the MCP server beside the CLI, the
7
+ * regression suite's parallel files) wrote the SAME path, so one applied, verified or committed the other's
8
+ * patch. On the runner it was caught applying HC-GF-077's `src/app.js` diff inside HC-GF-098's test repository.
9
+ * A verify-patch that reads another run's diff can report a pass for a patch that is not its run's, and under
10
+ * `verification_gate: enforce` that pass approves step_4.
11
+ *
12
+ * A clock is not an identity. `fs.mkdtempSync` creates a new directory with a random suffix, atomically, with
13
+ * mode 0700, so the file inside it belongs to exactly one invocation whatever the time, and nobody else can
14
+ * plant or replace it.
15
+ */
16
+
17
+ const fs = require('node:fs');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+
21
+ /**
22
+ * @param {string} prefix names the owner in the directory, e.g. 'hone-emit'
23
+ * @param {string} name the file's name inside its private directory, e.g. 'patch.diff'
24
+ * @returns {{ file: string, remove: () => void }} `remove` deletes the directory and never throws
25
+ */
26
+ function privateTempFile(prefix, name) {
27
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
28
+ return {
29
+ file: path.join(dir, name),
30
+ remove: () => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } },
31
+ };
32
+ }
33
+
34
+ module.exports = { privateTempFile };
@@ -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
+ };