@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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* HC-GF-178 — plan a bundle that represents EVERY declared source directory.
|
|
4
|
+
*
|
|
5
|
+
* The previous loop drained `source_dirs` in order and stopped at the cap:
|
|
6
|
+
*
|
|
7
|
+
* for (const dir of sourceDirs) { bundleDirectory(...); if (count >= maxFiles) break; }
|
|
8
|
+
*
|
|
9
|
+
* Measured 2026-09-17 on this repo, `cli/` holds 137 bundlable files against a default cap of 30,
|
|
10
|
+
* so it filled the cap alone and `enterprise-assets`, `scripts`, `server` and `.github` were never
|
|
11
|
+
* read. The derive agent graded the repository B (21/24) on a fifth of it and said so itself; the
|
|
12
|
+
* CLI printed `Bundling 30 source files...` and nothing else.
|
|
13
|
+
*
|
|
14
|
+
* It decides the artefact, not just the report: bundling `server` first produced a `node-developer`
|
|
15
|
+
* skill describing the server, bundling `cli` first produced one describing the CLI. Four pipeline
|
|
16
|
+
* steps load whichever one exists.
|
|
17
|
+
*
|
|
18
|
+
* Raising the cap is not the fix — whole files up to 50,000 chars are sent with no truncation, so
|
|
19
|
+
* a few hundred files is megabytes. This allocates the cap fairly instead, and reports what it
|
|
20
|
+
* could not include so the caller can say so out loud.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{sourceDirs: string[], maxFiles: number, listFiles: (dir: string) => string[]}} input
|
|
25
|
+
* @returns {{selected: string[], perDir: Record<string, {available: number, included: number}>,
|
|
26
|
+
* dropped: string[]}}
|
|
27
|
+
*/
|
|
28
|
+
function planBundle({ sourceDirs = [], maxFiles = 0, listFiles }) {
|
|
29
|
+
if (typeof listFiles !== 'function') {
|
|
30
|
+
throw new TypeError('planBundle requires a `listFiles` function — refusing to guess a layout');
|
|
31
|
+
}
|
|
32
|
+
const dirs = sourceDirs.filter(d => typeof d === 'string' && d.trim());
|
|
33
|
+
const available = new Map(dirs.map(d => [d, listFiles(d) || []]));
|
|
34
|
+
const perDir = {};
|
|
35
|
+
for (const d of dirs) perDir[d] = { available: available.get(d).length, included: 0 };
|
|
36
|
+
|
|
37
|
+
const cap = Math.max(0, Number(maxFiles) || 0);
|
|
38
|
+
const taken = new Map(dirs.map(d => [d, 0]));
|
|
39
|
+
|
|
40
|
+
// Round 1 — an equal share, so a huge first directory cannot drain the cap. Directories with
|
|
41
|
+
// fewer files than their share simply take what they have and release the remainder.
|
|
42
|
+
const share = dirs.length ? Math.floor(cap / dirs.length) : 0;
|
|
43
|
+
for (const d of dirs) taken.set(d, Math.min(share, available.get(d).length));
|
|
44
|
+
|
|
45
|
+
// Round 2 — redistribute what is left, one at a time, so a fair share never wastes capacity on
|
|
46
|
+
// a repo with several tiny directories and one large one.
|
|
47
|
+
let used = [...taken.values()].reduce((a, b) => a + b, 0);
|
|
48
|
+
let progress = true;
|
|
49
|
+
while (used < cap && progress) {
|
|
50
|
+
progress = false;
|
|
51
|
+
for (const d of dirs) {
|
|
52
|
+
if (used >= cap) break;
|
|
53
|
+
if (taken.get(d) < available.get(d).length) {
|
|
54
|
+
taken.set(d, taken.get(d) + 1);
|
|
55
|
+
used++;
|
|
56
|
+
progress = true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Round 3 — when the cap is smaller than the directory count, round 1 gave everyone 0 and round 2
|
|
62
|
+
// filled from the front. That is unavoidable; what is NOT acceptable is staying silent about it.
|
|
63
|
+
const selected = [];
|
|
64
|
+
for (const d of dirs) {
|
|
65
|
+
const n = taken.get(d);
|
|
66
|
+
perDir[d].included = n;
|
|
67
|
+
selected.push(...available.get(d).slice(0, n));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const dropped = dirs
|
|
71
|
+
.filter(d => perDir[d].included < perDir[d].available || perDir[d].available === 0)
|
|
72
|
+
.map(d => (perDir[d].available === 0
|
|
73
|
+
? `${d}: no bundlable files found`
|
|
74
|
+
: `${d}: ${perDir[d].included} of ${perDir[d].available} files`));
|
|
75
|
+
|
|
76
|
+
return { selected, perDir, dropped };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { planBundle };
|
|
@@ -20,7 +20,19 @@
|
|
|
20
20
|
|
|
21
21
|
const fs = require('node:fs');
|
|
22
22
|
const path = require('node:path');
|
|
23
|
-
const {
|
|
23
|
+
const { execFileSync } = require('node:child_process');
|
|
24
|
+
const { gitEnv } = require('./git-env');
|
|
25
|
+
|
|
26
|
+
// HC-GF-180: directories that are vendored or generated wherever they SIT. The allow-list below
|
|
27
|
+
// is prefix-matched, which silently failed for `.claude/worktrees/<name>/node_modules/…` — the
|
|
28
|
+
// check reported 179 violations, every one a vendored playwright file, burying the single real
|
|
29
|
+
// violation in this repo's own entrypoint. Matched per path SEGMENT instead.
|
|
30
|
+
const ANYWHERE_DIRS = ['node_modules', '.git', 'dist', 'build', 'coverage', '.venv', '__pycache__'];
|
|
31
|
+
// A git worktree is a DUPLICATE checkout of this repo. Every allow-listed prefix above fails
|
|
32
|
+
// inside one (`.claude/worktrees/<name>/server/seeds/learnings/…` does not start with
|
|
33
|
+
// `server/seeds/learnings/`), so a worktree both re-reports every finding and defeats every
|
|
34
|
+
// exemption. Scanning it is duplicate work on files nobody would edit there.
|
|
35
|
+
const ANYWHERE_PATHS = ['.claude/worktrees/'];
|
|
24
36
|
|
|
25
37
|
const DEFAULT_ALLOWED_PATH_PREFIXES = [
|
|
26
38
|
'tests/',
|
|
@@ -43,14 +55,117 @@ const DEFAULT_ALLOWED_PATH_PREFIXES = [
|
|
|
43
55
|
// Files to ALSO skip by extension
|
|
44
56
|
const SKIP_EXT = ['.md', '.test.js', '.test.ts', '.test.py', '.spec.js'];
|
|
45
57
|
|
|
58
|
+
// What each pass reads. Applied to the file LIST, so grep is never handed anything else.
|
|
59
|
+
const LITERAL_EXT = ['.py', '.js', '.ts', '.go', '.rb', '.java', '.kt', '.yml', '.yaml', '.toml', '.json', '.sh'];
|
|
60
|
+
const LISTEN_EXT = ['.js', '.ts', '.mjs'];
|
|
61
|
+
|
|
62
|
+
// Paths per grep call, in bytes. Under the smallest ARG_MAX in use (macOS: 1 MiB, shared with the
|
|
63
|
+
// environment), so the list is split here rather than by xargs, whose exit status on macOS is 1 for
|
|
64
|
+
// "no match" and for "grep was killed" alike.
|
|
65
|
+
const BATCH_BYTES = 128 * 1024;
|
|
66
|
+
// Each subprocess is local, but a hung one must not hang `hone doctor`: git falls back to the walk,
|
|
67
|
+
// and grep reports the scan as not completed.
|
|
68
|
+
const SCAN_TIMEOUT_MS = 60 * 1000;
|
|
69
|
+
|
|
70
|
+
/** A repo-relative, `/`-separated path the scan must never read (a directory is pruned whole). */
|
|
71
|
+
function isExcluded(rel) {
|
|
72
|
+
if (rel.split('/').some(seg => ANYWHERE_DIRS.includes(seg))) return true;
|
|
73
|
+
return ANYWHERE_PATHS.some(pth => `${rel}/`.includes(pth));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A regular file, not a symlink. grep follows a symlink it is handed, so a tracked link into
|
|
78
|
+
* `node_modules/` (or out of the repo) would be read; `grep -r` never followed one, nor does the walk.
|
|
79
|
+
* A tracked file deleted from the working tree is dropped here too, so grep is never handed a path
|
|
80
|
+
* it cannot open, and a read error can mean only that the scan failed.
|
|
81
|
+
*/
|
|
82
|
+
function isRegularFile(repoRoot, rel) {
|
|
83
|
+
try { return fs.lstatSync(path.join(repoRoot, rel)).isFile(); } catch { return false; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// HC-GF-313: the scan used to be `grep -r .`, which descends into every directory and left the
|
|
87
|
+
// exclusions above to JS, AFTER grep had read the files. Measured 2026-09-25 in a checkout with 23
|
|
88
|
+
// worktrees: one pass took 47.99 s and 1,811 of its 1,917 lines came from `.claude/worktrees/`.
|
|
89
|
+
// The list is built first, so an excluded directory is never opened.
|
|
90
|
+
//
|
|
91
|
+
// `git ls-files --cached --others --exclude-standard` is every tracked file plus every untracked one
|
|
92
|
+
// that is not ignored — a new file is scanned before it is committed — and it never enters an ignored
|
|
93
|
+
// `node_modules/` or a nested checkout. It does not descend into submodules: those are separate repos.
|
|
94
|
+
function listScanFiles(repoRoot, run) {
|
|
95
|
+
try {
|
|
96
|
+
const out = run('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], {
|
|
97
|
+
cwd: repoRoot, env: gitEnv(), encoding: 'utf8',
|
|
98
|
+
stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024, timeout: SCAN_TIMEOUT_MS,
|
|
99
|
+
});
|
|
100
|
+
const listed = [...new Set(out.split('\0').filter(Boolean))];
|
|
101
|
+
// An empty list is a directory git ignores or does not track, not an empty repo — walk it
|
|
102
|
+
// rather than report `ok` having read nothing.
|
|
103
|
+
if (listed.length > 0) return listed.filter(f => !isExcluded(f));
|
|
104
|
+
} catch { /* not a git work tree, no git, or it timed out: walk it */ }
|
|
105
|
+
return walkScanFiles(repoRoot);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The non-git fallback: a walk that prunes an excluded directory instead of reading it. */
|
|
109
|
+
function walkScanFiles(repoRoot) {
|
|
110
|
+
const files = [];
|
|
111
|
+
const pending = [''];
|
|
112
|
+
while (pending.length) {
|
|
113
|
+
const dir = pending.pop();
|
|
114
|
+
let entries;
|
|
115
|
+
try { entries = fs.readdirSync(path.join(repoRoot, dir), { withFileTypes: true }); } catch { continue; }
|
|
116
|
+
for (const e of entries) {
|
|
117
|
+
const rel = dir ? `${dir}/${e.name}` : e.name;
|
|
118
|
+
if (isExcluded(rel)) continue;
|
|
119
|
+
// Symlinks are not followed, as `grep -r` did not follow them.
|
|
120
|
+
if (e.isDirectory()) pending.push(rel);
|
|
121
|
+
else if (e.isFile()) files.push(rel);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return files;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `grep -EnH` over exactly `files`, printed as `./path:line:text`, the shape the parsers expect.
|
|
129
|
+
* Returns null when any batch did not complete, so a partial result is never read as a clean one.
|
|
130
|
+
*/
|
|
131
|
+
function grepFiles(repoRoot, files, pattern, run) {
|
|
132
|
+
let out = '';
|
|
133
|
+
for (let i = 0; i < files.length;) {
|
|
134
|
+
const batch = [];
|
|
135
|
+
let bytes = 0;
|
|
136
|
+
while (i < files.length && (batch.length === 0 || bytes + Buffer.byteLength(files[i]) + 3 <= BATCH_BYTES)) {
|
|
137
|
+
bytes += Buffer.byteLength(files[i]) + 3;
|
|
138
|
+
batch.push(`./${files[i]}`);
|
|
139
|
+
i += 1;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
out += run('grep', ['-EnH', '-e', pattern, '--', ...batch], {
|
|
143
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
144
|
+
maxBuffer: 4 * 1024 * 1024, timeout: SCAN_TIMEOUT_MS,
|
|
145
|
+
});
|
|
146
|
+
} catch (e) {
|
|
147
|
+
// Exit 1 is "nothing in this batch matched". Anything else — a file grep could not read (2),
|
|
148
|
+
// no grep (ENOENT), a signal, a timeout, output cut off at maxBuffer (ENOBUFS): the last three
|
|
149
|
+
// have no exit status at all — means the batch was not scanned, and whatever it printed is a
|
|
150
|
+
// prefix, not an answer.
|
|
151
|
+
if (e.status === 1) continue;
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
46
158
|
/**
|
|
47
159
|
* @param {object} args
|
|
48
160
|
* @param {string} args.repoRoot
|
|
49
161
|
* @param {string[]} [args.allowedPaths] path prefixes to skip (default: DEFAULT_ALLOWED_PATH_PREFIXES)
|
|
162
|
+
* @param {Function} [args.execFileSyncImpl] injectable for tests; defaults to child_process.execFileSync
|
|
50
163
|
* @returns {{ name: 'bind-default', status: 'ok'|'drift'|'skip', reason: string, suggestedFix?: string }}
|
|
51
164
|
*/
|
|
52
165
|
function checkBindDefault(args) {
|
|
53
|
-
const {
|
|
166
|
+
const {
|
|
167
|
+
repoRoot, allowedPaths = DEFAULT_ALLOWED_PATH_PREFIXES, execFileSyncImpl = execFileSync,
|
|
168
|
+
} = args || {};
|
|
54
169
|
const name = 'bind-default';
|
|
55
170
|
|
|
56
171
|
if (!repoRoot || typeof repoRoot !== 'string') {
|
|
@@ -60,19 +175,25 @@ function checkBindDefault(args) {
|
|
|
60
175
|
return { name, status: 'skip', reason: `repoRoot does not exist: ${repoRoot}` };
|
|
61
176
|
}
|
|
62
177
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
178
|
+
const scanFiles = listScanFiles(repoRoot, execFileSyncImpl)
|
|
179
|
+
.filter(f => [...LITERAL_EXT, ...LISTEN_EXT].some(ext => f.endsWith(ext)))
|
|
180
|
+
.filter(f => isRegularFile(repoRoot, f));
|
|
181
|
+
const withExt = (exts) => scanFiles.filter(f => exts.some(ext => f.endsWith(ext)));
|
|
182
|
+
const notCompleted = (what) => ({
|
|
183
|
+
name, status: 'skip', reason: `grep did not complete, so the ${what} scan has no result`,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const raw = grepFiles(repoRoot, withExt(LITERAL_EXT), '0\\.0\\.0\\.0', execFileSyncImpl);
|
|
187
|
+
if (raw === null) return notCompleted('wildcard-literal');
|
|
188
|
+
|
|
189
|
+
// ── HC-GF-180: a bind with NO host is the violation the literal scan cannot see ──────────
|
|
190
|
+
// `app.listen(PORT, cb)` binds every interface at runtime and contains no `0.0.0.0` to grep for.
|
|
191
|
+
// This repo's own entrypoint was exactly that (`server/src/index.js:312`, confirmed by `lsof`
|
|
192
|
+
// reporting `*:4000`) while the check reported `ok` — a clean result from a scan that could not
|
|
193
|
+
// have found it. Only the first argument matters: a second argument that is a host (a string, or
|
|
194
|
+
// anything reading an env var) is a correct bind; a function is a callback and means no host.
|
|
195
|
+
const hostless = grepFiles(repoRoot, withExt(LISTEN_EXT), '\\.listen\\(', execFileSyncImpl);
|
|
196
|
+
if (hostless === null) return notCompleted('listen()');
|
|
76
197
|
|
|
77
198
|
const offenders = [];
|
|
78
199
|
for (const line of raw.split('\n')) {
|
|
@@ -84,6 +205,9 @@ function checkBindDefault(args) {
|
|
|
84
205
|
|
|
85
206
|
// Filter out allowed paths
|
|
86
207
|
if (allowedPaths.some(p => file.startsWith(p))) continue;
|
|
208
|
+
// HC-GF-180: …and vendored/generated dirs at ANY depth, not just the repo root.
|
|
209
|
+
if (file.split('/').some(seg => ANYWHERE_DIRS.includes(seg))) continue;
|
|
210
|
+
if (ANYWHERE_PATHS.some(pth => file.includes(pth))) continue;
|
|
87
211
|
// Filter out test/spec files by extension
|
|
88
212
|
if (SKIP_EXT.some(ext => file.endsWith(ext))) continue;
|
|
89
213
|
// Filter out env-var-driven binds (legitimate use)
|
|
@@ -92,24 +216,74 @@ function checkBindDefault(args) {
|
|
|
92
216
|
if (/^\s*(#|\/\/|--).*0\.0\.0\.0/.test(content)) continue;
|
|
93
217
|
// Filter out docstrings/multiline strings (Python """, JS template literals, block comments)
|
|
94
218
|
if (/^\s*("""|'''|\/\*|\*)/.test(content)) continue;
|
|
95
|
-
//
|
|
96
|
-
|
|
219
|
+
// HC-GF-180: the literal counts only as a VALUE, not as a mention. The heuristics below used
|
|
220
|
+
// to guess from surrounding words ("example", "should", …) and let six mentions through on
|
|
221
|
+
// this repo — prompt text quoting the rule, a trailing `// 0.0.0.0` comment, and this file's
|
|
222
|
+
// own strings — while the one real violation was a bind with no literal at all.
|
|
223
|
+
//
|
|
224
|
+
// A value sits in argument or assignment position: preceded by `(`, `,`, `=` or `:` and
|
|
225
|
+
// followed by `)`, `,`, or the end of the line. Prose keeps its quotes but not that shape.
|
|
226
|
+
const asValue = /(?:[(,=:]\s*)['"`]0\.0\.0\.0['"`]\s*(?:[),\]}]|$)/.test(content)
|
|
227
|
+
|| /\b(host|hostname|bind|address|addr)\b\s*[:=]\s*['"`]?0\.0\.0\.0/i.test(content);
|
|
228
|
+
if (!asValue) continue;
|
|
97
229
|
|
|
98
230
|
offenders.push({ file, line: lineNo, content: content.trim().slice(0, 100) });
|
|
99
231
|
}
|
|
100
232
|
|
|
233
|
+
for (const line of hostless.split('\n')) {
|
|
234
|
+
if (!line.trim()) continue;
|
|
235
|
+
const m = line.match(/^\.\/(.+?):(\d+):(.*)$/);
|
|
236
|
+
if (!m) continue;
|
|
237
|
+
const [, file, lineNo, content] = m;
|
|
238
|
+
if (allowedPaths.some(p => file.startsWith(p))) continue;
|
|
239
|
+
if (file.split('/').some(seg => ANYWHERE_DIRS.includes(seg))) continue;
|
|
240
|
+
if (ANYWHERE_PATHS.some(pth => file.includes(pth))) continue;
|
|
241
|
+
if (SKIP_EXT.some(ext => file.endsWith(ext))) continue;
|
|
242
|
+
if (/^\s*(#|\/\/|--|\*)/.test(content)) continue;
|
|
243
|
+
|
|
244
|
+
// What follows `.listen(` up to the matching-ish close. A second argument that is a string
|
|
245
|
+
// literal or an env-var read is a host; `() =>`, `function`, or no second argument at all is
|
|
246
|
+
// not, and Node then defaults to every interface.
|
|
247
|
+
const call = /\.listen\(([^)]*)/.exec(content);
|
|
248
|
+
if (!call) continue;
|
|
249
|
+
const args = call[1];
|
|
250
|
+
const second = args.split(',').slice(1).join(',').trim();
|
|
251
|
+
const hasHost = second.length > 0
|
|
252
|
+
&& !/^(\(|function\b|async\b)/.test(second)
|
|
253
|
+
&& !/^\w+\s*=>/.test(second);
|
|
254
|
+
if (hasHost) continue;
|
|
255
|
+
// `.listen()` with no args at all is usually a net/http server reusing a bound socket.
|
|
256
|
+
if (args.trim() === '') continue;
|
|
257
|
+
|
|
258
|
+
offenders.push({
|
|
259
|
+
file, line: lineNo,
|
|
260
|
+
content: `${content.trim().slice(0, 80)} [no host argument — binds every interface]`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
101
264
|
if (offenders.length === 0) {
|
|
102
265
|
return {
|
|
103
266
|
name,
|
|
104
267
|
status: 'ok',
|
|
105
|
-
|
|
268
|
+
// HC-GF-180: say what was actually checked. The old wording named only the literal
|
|
269
|
+
// scan, so a clean result read as proof of something narrower than the check now does
|
|
270
|
+
// — and a reader comparing message to behaviour would have concluded the hostless
|
|
271
|
+
// pass did not run.
|
|
272
|
+
reason: 'no wildcard binds in production code: no 0.0.0.0 literal in value position, '
|
|
273
|
+
+ 'and no listen() without an explicit host',
|
|
106
274
|
};
|
|
107
275
|
}
|
|
108
276
|
|
|
109
277
|
// Prescriptive language per SC-006-L1
|
|
278
|
+
// HC-GF-180: a hostless bind in an ENTRYPOINT is the one an operator must see; it is what
|
|
279
|
+
// actually opens a port on every interface. Sorting it to the front stops it hiding behind
|
|
280
|
+
// "(+N more)", which is where `server/src/index.js` sat while the check reported 179 findings.
|
|
281
|
+
const rank = (o) => (/(^|\/)(index|server|app|main)\.(js|ts|mjs)$/.test(o.file) ? 0 : 1);
|
|
282
|
+
offenders.sort((a, b) => rank(a) - rank(b));
|
|
110
283
|
const detail = offenders.slice(0, 5).map(o => `${o.file}:${o.line}`).join(', ');
|
|
111
284
|
return {
|
|
112
285
|
name,
|
|
286
|
+
offenders,
|
|
113
287
|
status: 'drift',
|
|
114
288
|
reason: `MUST NOT bind 0.0.0.0 from code (per E13-A-L1). Never default to a wildcard host; always read from BIND_HOST env-var with 127.0.0.1 fallback. Found ${offenders.length} violation(s): ${detail}${offenders.length > 5 ? ` (+${offenders.length - 5} more)` : ''}`,
|
|
115
289
|
suggestedFix: 'Use os.getenv("BIND_HOST", "127.0.0.1") or process.env.BIND_HOST || "127.0.0.1". See reliability/SKILL.md §Bind to localhost by default.',
|
package/lib/doctor-local-run.js
CHANGED
|
@@ -20,8 +20,118 @@
|
|
|
20
20
|
const { execFile } = require('child_process');
|
|
21
21
|
|
|
22
22
|
const PROBE_TIMEOUT_MS = 60_000;
|
|
23
|
+
const TOKEN_PROBE_TIMEOUT_MS = 10_000;
|
|
23
24
|
|
|
24
25
|
/** Ask the server what it will spend on. The answer must come from the server, never config. */
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* HC-GF-180: name the wrong-server case, which the server cannot.
|
|
29
|
+
*
|
|
30
|
+
* A token minted by one server does not validate against another (different `JWT_SECRET`), and the
|
|
31
|
+
* only signal is `Submit failed: Invalid or expired token` — which reads as an expiry. Recovering
|
|
32
|
+
* from that message meant reading route source to learn how to mint a local token.
|
|
33
|
+
*
|
|
34
|
+
* @param {string|null|undefined} configApi the `api` recorded in ~/.honerc
|
|
35
|
+
* @param {string} apiUrl the server about to be used
|
|
36
|
+
* @returns {string|null} a warning naming BOTH servers, or null when they agree / nothing is known
|
|
37
|
+
*/
|
|
38
|
+
function describeTokenMismatch(configApi, apiUrl) {
|
|
39
|
+
const norm = (u) => (typeof u === 'string' ? u.trim().replace(/\/+$/, '') : null);
|
|
40
|
+
const a = norm(configApi);
|
|
41
|
+
const b = norm(apiUrl);
|
|
42
|
+
if (!a || !b || a === b) return null;
|
|
43
|
+
return `the token in ~/.honerc was minted by ${a}, but this check targets ${b}. `
|
|
44
|
+
+ 'A token does not validate across servers — they sign with different JWT_SECRETs — so a run '
|
|
45
|
+
+ 'will be refused with a message that says the token is invalid, not that it belongs '
|
|
46
|
+
+ 'elsewhere. Mint one for this server (POST /admin/tokens) and export it as HONE_TOKEN — it '
|
|
47
|
+
+ 'takes precedence over ~/.honerc, so the credentials kept there stay untouched.';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* HC-GF-267: which token will a run SEND? The same resolution as getConfig() in cli/hone-cli.js —
|
|
52
|
+
* `process.env.HONE_TOKEN || rc.token` — including the `||`, so an EMPTY HONE_TOKEN falls through
|
|
53
|
+
* to ~/.honerc there and here alike. Returns the source label; the value is never rendered.
|
|
54
|
+
*/
|
|
55
|
+
function resolveTokenInEffect(env, rc) {
|
|
56
|
+
if (env && env.HONE_TOKEN) return { token: env.HONE_TOKEN, source: 'HONE_TOKEN' };
|
|
57
|
+
if (rc && rc.token) return { token: rc.token, source: '~/.honerc' };
|
|
58
|
+
return { token: null, source: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* HC-GF-267: ask the server whether it accepts the token a run will send, instead of inferring it
|
|
63
|
+
* from the `api` field ~/.honerc happens to record. That field is the same whether the token works
|
|
64
|
+
* or not — exactly the status-field trap this file was written around. `GET /usage/me` sits behind
|
|
65
|
+
* the same `auth` middleware a run does and changes nothing but the `tokens.last_used` audit stamp
|
|
66
|
+
* that middleware writes on every authenticated request.
|
|
67
|
+
*
|
|
68
|
+
* Only a 401 means refused. Anything else — a network error, a 5xx, a 503 with no DB — says
|
|
69
|
+
* nothing about the token, so it is `unknown` and the caller falls back to the inference.
|
|
70
|
+
* The token goes in the header and nowhere else: no field of the result carries it.
|
|
71
|
+
*/
|
|
72
|
+
async function probeToken(apiUrl, token, fetchImpl) {
|
|
73
|
+
if (!token) return { verdict: 'unknown' };
|
|
74
|
+
const f = fetchImpl || globalThis.fetch;
|
|
75
|
+
try {
|
|
76
|
+
const res = await f(`${apiUrl.replace(/\/$/, '')}/usage/me`, {
|
|
77
|
+
method: 'GET',
|
|
78
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
79
|
+
signal: AbortSignal.timeout(TOKEN_PROBE_TIMEOUT_MS),
|
|
80
|
+
});
|
|
81
|
+
if (res.ok) return { verdict: 'accepted' };
|
|
82
|
+
if (res.status !== 401) return { verdict: 'unknown' };
|
|
83
|
+
let error = null;
|
|
84
|
+
try {
|
|
85
|
+
const body = await res.json();
|
|
86
|
+
if (body && typeof body.error === 'string') error = body.error.slice(0, 80);
|
|
87
|
+
} catch { /* an unparseable 401 is still a refusal */ }
|
|
88
|
+
return { verdict: 'refused', error };
|
|
89
|
+
} catch {
|
|
90
|
+
return { verdict: 'unknown' };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* HC-GF-267: the token advice, decided by the token a run will actually send.
|
|
96
|
+
*
|
|
97
|
+
* describeTokenMismatch compares ~/.honerc's `api` with the target — but when HONE_TOKEN is set,
|
|
98
|
+
* ~/.honerc's token is never sent, so that comparison describes a credential no run uses. Measured
|
|
99
|
+
* 2026-09-24: with a local HONE_TOKEN exported, runs got 200s while this check warned that "a run
|
|
100
|
+
* will be refused" and prescribed `hone init`, which overwrites ~/.honerc and the production
|
|
101
|
+
* credentials kept there.
|
|
102
|
+
*
|
|
103
|
+
* @param {{source:string|null, probe:{verdict:string,error?:string|null}, rcApi:?string, apiUrl:string}} s
|
|
104
|
+
* @returns {string|null}
|
|
105
|
+
*/
|
|
106
|
+
function describeTokenInEffect({ source, probe, rcApi, apiUrl }) {
|
|
107
|
+
if (probe.verdict === 'accepted') return null;
|
|
108
|
+
const target = String(apiUrl).trim().replace(/\/+$/, '');
|
|
109
|
+
const said = probe.error ? ` ("${probe.error}")` : '';
|
|
110
|
+
|
|
111
|
+
if (source === 'HONE_TOKEN') {
|
|
112
|
+
// ~/.honerc is not consulted, so nothing it records can be evidence either way. Only the
|
|
113
|
+
// server's own refusal is worth reporting.
|
|
114
|
+
if (probe.verdict !== 'refused') return null;
|
|
115
|
+
return `the token in HONE_TOKEN is refused by ${target}${said}. HONE_TOKEN takes precedence, `
|
|
116
|
+
+ 'so ~/.honerc is not consulted and changing it will not help. Mint a token for this '
|
|
117
|
+
+ 'server (POST /admin/tokens) and export that as HONE_TOKEN instead.';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const mismatch = describeTokenMismatch(rcApi, apiUrl);
|
|
121
|
+
if (mismatch || probe.verdict !== 'refused') return mismatch;
|
|
122
|
+
const remedy = 'Mint one (POST /admin/tokens) and export it as HONE_TOKEN — it takes precedence '
|
|
123
|
+
+ 'over ~/.honerc, so the credentials kept there stay untouched.';
|
|
124
|
+
// No mismatch and refused means ~/.honerc either names THIS server or names none. Only the first
|
|
125
|
+
// supports a diagnosis; a hand-edited ~/.honerc with no `api` says nothing about where it came from.
|
|
126
|
+
if (typeof rcApi !== 'string' || !rcApi.trim()) {
|
|
127
|
+
return `the token in ~/.honerc is refused by ${target}${said}, and ~/.honerc records no server `
|
|
128
|
+
+ `for it, so it may have been minted by another one. ${remedy}`;
|
|
129
|
+
}
|
|
130
|
+
return `the token in ~/.honerc is refused by ${target}${said}, although ~/.honerc records this `
|
|
131
|
+
+ 'server — so it was revoked, or the server\'s database or JWT_SECRET changed after it was '
|
|
132
|
+
+ `minted (HC-GF-212). ${remedy}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
25
135
|
async function fetchHealth(apiUrl, fetchImpl) {
|
|
26
136
|
const f = fetchImpl || globalThis.fetch;
|
|
27
137
|
const res = await f(`${apiUrl.replace(/\/$/, '')}/health`, { method: 'GET' });
|
|
@@ -80,6 +190,21 @@ async function checkLocalRun(opts = {}) {
|
|
|
80
190
|
const apiUrl = opts.apiUrl;
|
|
81
191
|
const examined = [];
|
|
82
192
|
|
|
193
|
+
// HC-GF-180: a $0 verdict is useless if the run is refused before it starts. The stored token
|
|
194
|
+
// may belong to a DIFFERENT server, and the only signal then is "Invalid or expired token",
|
|
195
|
+
// which names an expiry rather than the mismatch. Read it here so the check can say which.
|
|
196
|
+
let rc = null;
|
|
197
|
+
try {
|
|
198
|
+
const readConfig = opts.readConfig || (() => {
|
|
199
|
+
const p = require('node:path').join(require('node:os').homedir(), '.honerc');
|
|
200
|
+
return JSON.parse(require('node:fs').readFileSync(p, 'utf8'));
|
|
201
|
+
});
|
|
202
|
+
rc = readConfig() || {};
|
|
203
|
+
examined.push('~/.honerc target');
|
|
204
|
+
} catch { /* no config, or unreadable — a different problem with a different fix */ }
|
|
205
|
+
// HC-GF-267: …but ~/.honerc's token is only the one a run sends when HONE_TOKEN is unset.
|
|
206
|
+
const inEffect = resolveTokenInEffect(opts.env || process.env, rc);
|
|
207
|
+
|
|
83
208
|
let health;
|
|
84
209
|
try {
|
|
85
210
|
health = await fetchHealth(apiUrl, opts.fetchImpl);
|
|
@@ -168,11 +293,22 @@ async function checkLocalRun(opts = {}) {
|
|
|
168
293
|
};
|
|
169
294
|
}
|
|
170
295
|
|
|
296
|
+
// HC-GF-180: the mismatch is ADVICE, not a verdict. This check answers one question — what will
|
|
297
|
+
// a run cost — and `status: ok` means $0.00 (pinned by cli/lib/doctor-local-run.test.js). An
|
|
298
|
+
// earlier version returned `drift` here, which hijacked the cost contract for an auth concern
|
|
299
|
+
// and made the result depend on whatever `~/.honerc` happened to hold. It is appended to the
|
|
300
|
+
// guidance instead, where an operator reads it before the server refuses them.
|
|
301
|
+
// HC-GF-267: decided by the token a run will send, and by the server when it can be asked.
|
|
302
|
+
const tokenProbe = await probeToken(apiUrl, inEffect.token, opts.fetchImpl);
|
|
303
|
+
if (tokenProbe.verdict !== 'unknown') examined.push('token in effect');
|
|
304
|
+
const tokenNote = describeTokenInEffect({
|
|
305
|
+
source: inEffect.source, probe: tokenProbe, rcApi: rc && rc.api, apiUrl });
|
|
171
306
|
return {
|
|
172
307
|
name: 'local-run', status: 'ok', examined: examined.length,
|
|
173
308
|
reason: 'a run started now costs $0.00 and cannot reach a paid provider',
|
|
174
309
|
suggestedFix: `server: ${apiUrl} · provider: claude_cli · fallback: disabled · ` +
|
|
175
310
|
`probe: ok${probe.model ? ` (${probe.model})` : ''}` +
|
|
311
|
+
(tokenNote ? `\n ⚠ ${tokenNote}` : '') +
|
|
176
312
|
(llm.auth && llm.auth.api_key_visible
|
|
177
313
|
? '\n Note: a metered API key is visible in this shell. The provider scrubs it ' +
|
|
178
314
|
'from the child, but you may want it unset.'
|
|
@@ -180,4 +316,6 @@ async function checkLocalRun(opts = {}) {
|
|
|
180
316
|
};
|
|
181
317
|
}
|
|
182
318
|
|
|
183
|
-
module.exports = {
|
|
319
|
+
module.exports = {
|
|
320
|
+
describeTokenMismatch, describeTokenInEffect, resolveTokenInEffect, probeToken,
|
|
321
|
+
checkLocalRun, probeLocalClaude, fetchHealth };
|
package/lib/emit-pr.js
CHANGED
|
@@ -35,6 +35,10 @@ const EXIT = {
|
|
|
35
35
|
BRANCH_CONFLICT: 5, // local branch already exists (not overwritten)
|
|
36
36
|
PUBLISH_FAILED: 6, // push or gh pr create failed (no remote / no auth / rejected)
|
|
37
37
|
UNRESOLVED: 7, // --verify could not start the test command (HC-GF-008) — no commit made
|
|
38
|
+
// HC-GF-270: the run's story text came from an untrusted source — nothing executed, no
|
|
39
|
+
// branch created. MUST stay equal to verify-patch.js's EXIT.UNTRUSTED_SOURCE (this map is
|
|
40
|
+
// byte-compatible with verify-patch's 0–4 by contract, and 9 is shared for the same reason).
|
|
41
|
+
UNTRUSTED_SOURCE: 9,
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
/**
|
|
@@ -77,14 +81,36 @@ function buildBranchName({ storyId, workflowId, override }) {
|
|
|
77
81
|
* spoofed (the commit is authored by the adopter's own git identity).
|
|
78
82
|
*
|
|
79
83
|
* @param {{storyId:string, summary?:string, workflowId:string, headSha:string,
|
|
80
|
-
* verifyState:'pass'|'skipped'}} o
|
|
84
|
+
* verifyState:'pass'|'pass_narrowed'|'skipped', replacedCommand?:string}} o
|
|
81
85
|
*/
|
|
82
|
-
|
|
86
|
+
/**
|
|
87
|
+
* HC-GF-223: the verify verdict → what emit-pr does with it, as a function rather than a branch
|
|
88
|
+
* buried in a 200-line action, so it can be tested by behaviour (the move HC-GF-144 made for
|
|
89
|
+
* `verdictEnv`). `null` means no branch is emitted.
|
|
90
|
+
*
|
|
91
|
+
* `pass_narrowed` emits: the tests that ran DID pass, so there is a deliverable, and refusing it
|
|
92
|
+
* would punish the operator who cannot run the configured command in that worktree at all — the
|
|
93
|
+
* half of HC-GF-223 still open. The qualification travels in the commit message and the PR body,
|
|
94
|
+
* and the gate is blocked by the verdict `verify-patch --report` sends, not by withholding a branch.
|
|
95
|
+
*/
|
|
96
|
+
function verifyStateFor(verdict) {
|
|
97
|
+
if (verdict === 'pass') return 'pass';
|
|
98
|
+
if (verdict === 'pass_narrowed') return 'pass_narrowed';
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildCommitMessage({ storyId, summary, workflowId, headSha, verifyState, replacedCommand }) {
|
|
83
103
|
const subjectStory = storyId || 'story';
|
|
84
104
|
const subjectText = (summary && summary.trim().split('\n')[0].trim()) || 'apply generated changes';
|
|
105
|
+
// HC-GF-223: three states, not two. A run under an operator-supplied command passed on a SUBSET,
|
|
106
|
+
// and a commit that says "pass" for it is the overclaim this repo keeps finding.
|
|
85
107
|
const verifyLine = verifyState === 'pass'
|
|
86
108
|
? `Verified by \`hone verify-patch\` against HEAD ${headSha}: pass.`
|
|
87
|
-
:
|
|
109
|
+
: verifyState === 'pass_narrowed'
|
|
110
|
+
? `Partially verified against HEAD ${headSha}: the tests run under an operator-supplied `
|
|
111
|
+
+ `command passed${replacedCommand ? `, but \`${replacedCommand}\` did not run` : ''} — `
|
|
112
|
+
+ 'evidence about a subset, not a verification of the change.'
|
|
113
|
+
: `Diff applies against HEAD ${headSha}; verify skipped.`;
|
|
88
114
|
return [
|
|
89
115
|
`${subjectStory}: ${subjectText}`,
|
|
90
116
|
'',
|
|
@@ -112,13 +138,19 @@ const HONEST_LIMIT = [
|
|
|
112
138
|
* PR body. Always a draft's body; always carries HONEST_LIMIT verbatim.
|
|
113
139
|
*
|
|
114
140
|
* @param {{storyId:string, workflowId:string, headSha:string,
|
|
115
|
-
* verifyState:'pass'|'skipped', command?:string, runUrl?:string}} o
|
|
141
|
+
* verifyState:'pass'|'pass_narrowed'|'skipped', command?:string, replacedCommand?:string, runUrl?:string}} o
|
|
116
142
|
*/
|
|
117
|
-
function buildPrBody({ storyId, workflowId, headSha, verifyState, command, runUrl }) {
|
|
143
|
+
function buildPrBody({ storyId, workflowId, headSha, verifyState, command, replacedCommand, runUrl }) {
|
|
118
144
|
const verification = verifyState === 'pass'
|
|
119
145
|
? `- Verification: \`hone verify-patch\` — tests PASSED against HEAD \`${headSha}\`` +
|
|
120
146
|
(command ? ` using \`${command}\`` : '')
|
|
121
|
-
|
|
147
|
+
// HC-GF-223: a reviewer must be able to see that the configured command never ran.
|
|
148
|
+
: verifyState === 'pass_narrowed'
|
|
149
|
+
? `- Verification: PARTIAL — tests passed against HEAD \`${headSha}\``
|
|
150
|
+
+ (command ? ` using \`${command}\`` : '')
|
|
151
|
+
+ `${replacedCommand ? `, but \`${replacedCommand}\` did not run` : ''}`
|
|
152
|
+
+ '; this is evidence about a subset, not a verification of the change'
|
|
153
|
+
: `- Verification: SKIPPED — diff applies against HEAD \`${headSha}\`, tests not run at emit time`;
|
|
122
154
|
const lines = [
|
|
123
155
|
'## Summary',
|
|
124
156
|
`Automated change for ${storyId || 'a story'}, generated by the Hone SDLC pipeline.`,
|
|
@@ -157,6 +189,7 @@ function decidePublish({ branchExists, openPrUrl }) {
|
|
|
157
189
|
}
|
|
158
190
|
|
|
159
191
|
module.exports = {
|
|
192
|
+
verifyStateFor,
|
|
160
193
|
EXIT,
|
|
161
194
|
HONEST_LIMIT,
|
|
162
195
|
slugifyRef,
|
package/lib/git-helpers.js
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
* Extracted from cli/hone-cli.js's inline `getSkillLastModifiedDays`
|
|
6
6
|
* (which was added inline for LC-003 — `hone audit-learnings`). LC-004
|
|
7
7
|
* adds a sibling `getFirstCommitDateMs` for the captured_at-fallback
|
|
8
|
-
* path — both helpers share the same
|
|
8
|
+
* path — both helpers share the same error-handling shape.
|
|
9
|
+
*
|
|
10
|
+
* Both run git with an argument array, never a shell string, and a bounded
|
|
11
|
+
* timeout (HC-GF-097-followup-2): the path is a tracked file's name, and
|
|
12
|
+
* double quotes do not stop `$(...)` or backticks inside it.
|
|
9
13
|
*
|
|
10
14
|
* Pure-helper-with-injected-IO style: each helper accepts `repoRoot`
|
|
11
15
|
* + the relative file path, returns a primitive (number-of-days,
|
|
@@ -19,11 +23,18 @@
|
|
|
19
23
|
*
|
|
20
24
|
* Issue: #143 (LC-004) — captured_at fallback to git log file mtime.
|
|
21
25
|
*/
|
|
22
|
-
const {
|
|
26
|
+
const { execFileSync } = require('node:child_process');
|
|
23
27
|
const { gitEnv } = require('./git-env');
|
|
24
28
|
const fs = require('node:fs');
|
|
25
29
|
const path = require('node:path');
|
|
26
30
|
|
|
31
|
+
// A timeout throws, which each helper already reads as null ("unknown"). `git log -1` stops at the
|
|
32
|
+
// first match. `--follow` walks the whole history: measured 2026-09-26 at 11.4-16.9 s through this
|
|
33
|
+
// helper on a synthetic 1M-commit repo (a loaded laptop), so 10 s would turn a large adopter repo's
|
|
34
|
+
// answer into null. It gets 60 s.
|
|
35
|
+
const GIT_LOG_TIMEOUT_MS = 10_000;
|
|
36
|
+
const GIT_FOLLOW_TIMEOUT_MS = 60_000;
|
|
37
|
+
|
|
27
38
|
/**
|
|
28
39
|
* Last-modified Unix timestamp (seconds) for a tracked file, via
|
|
29
40
|
* `git log -1 --format=%ct -- <path>`. Returns null if:
|
|
@@ -39,9 +50,9 @@ function getLastCommitTimestampSeconds(repoRoot, relativePath) {
|
|
|
39
50
|
const abs = path.join(repoRoot, relativePath);
|
|
40
51
|
if (!fs.existsSync(abs)) return null;
|
|
41
52
|
try {
|
|
42
|
-
const out =
|
|
43
|
-
|
|
44
|
-
{ cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
53
|
+
const out = execFileSync(
|
|
54
|
+
'git', ['log', '-1', '--format=%ct', '--', relativePath],
|
|
55
|
+
{ cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: GIT_LOG_TIMEOUT_MS }
|
|
45
56
|
).trim();
|
|
46
57
|
if (!out) return null;
|
|
47
58
|
const seconds = Number(out);
|
|
@@ -74,9 +85,9 @@ function getFirstCommitDateMs(repoRoot, relativePath) {
|
|
|
74
85
|
const abs = path.join(repoRoot, relativePath);
|
|
75
86
|
if (!fs.existsSync(abs)) return null;
|
|
76
87
|
try {
|
|
77
|
-
const out =
|
|
78
|
-
|
|
79
|
-
{ cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
88
|
+
const out = execFileSync(
|
|
89
|
+
'git', ['log', '--diff-filter=A', '--follow', '--format=%at', '--', relativePath],
|
|
90
|
+
{ cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: GIT_FOLLOW_TIMEOUT_MS }
|
|
80
91
|
).trim();
|
|
81
92
|
if (!out) return null;
|
|
82
93
|
// Multiple ADD events possible (file renames); take the OLDEST (last line)
|