@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.
- package/bin/hone-mcp.js +1 -0
- package/hone-cli.js +1263 -756
- package/lib/agent-eval-pacing.js +102 -0
- package/lib/agent-eval-probes-boundary.js +0 -0
- package/lib/agent-eval-probes-stateful.js +72 -0
- package/lib/agent-eval-score.js +70 -0
- package/lib/agent-executor.js +26 -4
- package/lib/asset-fetch-list.js +72 -0
- package/lib/auto-verify.js +67 -3
- package/lib/batch-follow.js +333 -0
- package/lib/block-grammar.js +126 -0
- package/lib/bundle-context.js +43 -4
- package/lib/bundle-paths.js +698 -1
- package/lib/bundle-source-dirs.js +79 -0
- package/lib/doctor-bind-default.js +192 -18
- package/lib/doctor-local-run.js +139 -1
- package/lib/emit-pr.js +39 -6
- package/lib/git-helpers.js +19 -8
- package/lib/hard-pause-remedy.js +101 -0
- package/lib/include-paths.js +155 -0
- package/lib/learnings-parse.js +51 -6
- package/lib/materialize-diff.js +131 -73
- package/lib/pipeline-config.js +182 -0
- package/lib/private-temp.js +34 -0
- package/lib/promote-payload.js +77 -0
- package/lib/refresh-knowledge.js +15 -6
- package/lib/repo-tree.js +22 -3
- package/lib/run-context.js +698 -0
- package/lib/sandbox-lifecycle.js +357 -0
- package/lib/schedule-cron.js +1 -1
- package/lib/skill-assertions.js +61 -7
- package/lib/skill-audit-render.js +1 -1
- package/lib/story-classifier-extract.js +16 -8
- package/lib/story-classifier.js +64 -0
- package/lib/story-compose.js +69 -0
- package/lib/story-line.js +40 -0
- package/lib/story-provenance.js +267 -0
- package/lib/timeout-option.js +42 -0
- package/lib/untrusted-code-env.js +51 -0
- package/lib/verdict-env.js +31 -0
- package/lib/verify-patch.js +74 -4
- package/lib/verify-pr.js +2 -1
- package/lib/worktree-provision.js +62 -2
- package/package.json +1 -1
package/lib/pipeline-config.js
CHANGED
|
@@ -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 };
|
package/lib/refresh-knowledge.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
+
};
|