@hone-ai/cli 1.33.0 → 1.34.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/hone-cli.js CHANGED
@@ -5552,7 +5552,9 @@ program
5552
5552
  const config = getConfig();
5553
5553
  const client = api(config);
5554
5554
  try {
5555
- const { data } = await client.get(`/orchestrate/${encodeURIComponent(runId)}/step-instruction`);
5555
+ // HC-GF-092: advertise what this CLI can honour. The server serves the highest it also
5556
+ // knows, so an older CLI — which sends nothing — keeps being served contract 1.
5557
+ const { data } = await client.get(`/orchestrate/${encodeURIComponent(runId)}/step-instruction?contracts=1,2`);
5556
5558
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
5557
5559
  console.log(`Step: ${data.stepKey} (${data.agent})`);
5558
5560
  console.log(`Contract: ${data.contractVersion}`);
@@ -5573,15 +5575,36 @@ program
5573
5575
  .command('submit-step <runId> <stepKey>')
5574
5576
  .description('Submit editor-executed output for an awaiting_editor step (HC-COMM-010 B3)')
5575
5577
  .option('--file <path>', 'Read the step output from this file')
5578
+ .option('--from-tree', 'HC-GF-091: render the artifact from the working tree against HEAD instead of reading one file — the agent writes files with its own tools, so no single model response has to hold the whole build')
5579
+ .option('--test-report <text>', 'With --from-tree: the test-run report to carry verbatim. Never invented when absent.')
5576
5580
  .option('--contract <version>', 'Instruction contract version (from step-instruction)', '1')
5577
5581
  .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
5578
5582
  .action(async (runId, stepKey, opts) => {
5579
5583
  const config = getConfig();
5580
5584
  const client = api(config);
5581
5585
  try {
5582
- if (!opts.file) { console.error('Provide the output via --file <path> (the text your editor produced).'); process.exit(1); }
5586
+ if (opts.file && opts.fromTree) { console.error('Use --file OR --from-tree, not both: they are two different claims about where the build is.'); process.exit(1); }
5587
+ // HC-GF-092: under contract 2 the instruction told the agent to WRITE the files with its own
5588
+ // tools. A hand-assembled --file artifact means the instruction and the submission disagree
5589
+ // about where the build is, and the server cannot see that — only this side can.
5590
+ if (String(opts.contract) === '2' && !opts.fromTree) {
5591
+ console.error('Contract 2 is the from-tree contract: the instruction told the agent to write the files with its own tools. Re-run with --from-tree, or fetch a contract 1 instruction if you really did assemble the output by hand.');
5592
+ process.exit(1);
5593
+ }
5594
+ if (!opts.file && !opts.fromTree) { console.error('Provide the output via --file <path> (the text your editor produced), or --from-tree to render it from the working tree.'); process.exit(1); }
5583
5595
  const fs = require('fs');
5584
- const output = fs.readFileSync(opts.file, 'utf8');
5596
+ let output;
5597
+ if (opts.fromTree) {
5598
+ const { renderFromTree } = require('./lib/tree-artifact');
5599
+ const rendered = renderFromTree({ repoRoot: process.cwd(), testReport: opts.testReport });
5600
+ output = rendered.text;
5601
+ // Say what was left out BEFORE the submission, not after: a file the tree has and the
5602
+ // artifact does not is a decision the operator has to be able to see.
5603
+ for (const s2 of rendered.skipped) console.error(`not included: ${s2.path} — ${s2.reason}`);
5604
+ console.error(`from-tree: ${rendered.files.length} file(s), ${rendered.deletes.length} deletion(s), ${rendered.skipped.length} skipped`);
5605
+ } else {
5606
+ output = fs.readFileSync(opts.file, 'utf8');
5607
+ }
5585
5608
  const { data } = await client.post(
5586
5609
  `/orchestrate/${encodeURIComponent(runId)}/step/${encodeURIComponent(stepKey)}/editor-output`,
5587
5610
  { contractVersion: opts.contract, output }
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+ /**
3
+ * HC-GF-089 — how close a step came to its limits, said before it hits one.
4
+ *
5
+ * The largest local build used 793s of its 900s limit and nothing said so. Until now the only way to
6
+ * learn where a limit bites was to hit it: HC-GF-114 exists because the cut-off path had never fired
7
+ * in the entire run history, and reaching it took lowering a cap by hand. A run one slow story away
8
+ * from a timeout should say so while it still completes.
9
+ *
10
+ * ADVISORY by construction. This never blocks, never routes back and never changes a verdict — it
11
+ * prints. A signal that could fail a run would need a far higher bar than "approaching a limit", and
12
+ * a warning that reads like a rejection trains operators to ignore it.
13
+ */
14
+
15
+ /** HC-GF-080's per-message output cap — what the CLI child actually runs with. */
16
+ const TOKEN_CAP_PER_MESSAGE = 128_000;
17
+
18
+ /** Three quarters. Low enough to give a story's worth of warning, high enough not to fire routinely. */
19
+ const THRESHOLD = 0.75;
20
+
21
+ /** A ratio, or null when there is nothing to judge — a missing limit is not a limit of zero. */
22
+ function ratio(used, limit) {
23
+ const u = Number(used);
24
+ const l = Number(limit);
25
+ if (!Number.isFinite(u) || !Number.isFinite(l) || l <= 0 || u < 0) return null;
26
+ return u / l;
27
+ }
28
+
29
+ /**
30
+ * One line when a step passed THRESHOLD of its time or per-message token budget, else null.
31
+ * @param {object} generation HC-GF-079's generation report, as stored on the step row.
32
+ */
33
+ function headroomLine(generation) {
34
+ const g = generation && typeof generation === 'object' ? generation : {};
35
+
36
+ const timeRatio = ratio(g.elapsed_ms, g.timeout_ms);
37
+ // The BUSIEST message decides: a build whose last message nearly filled the cap is at risk even
38
+ // when the earlier ones were small, and an average would hide exactly that.
39
+ const counts = (Array.isArray(g.output_tokens) ? g.output_tokens : [])
40
+ .map(Number).filter((n) => Number.isFinite(n) && n >= 0);
41
+ const busiest = counts.length ? Math.max(...counts) : null;
42
+ const tokenRatio = busiest === null ? null : ratio(busiest, TOKEN_CAP_PER_MESSAGE);
43
+
44
+ const parts = [];
45
+ if (timeRatio !== null && timeRatio >= THRESHOLD) {
46
+ parts.push(`time ${Math.round(timeRatio * 100)}% `
47
+ + `(${Math.round(Number(g.elapsed_ms) / 1000)}s of ${Math.round(Number(g.timeout_ms) / 1000)}s)`);
48
+ }
49
+ if (tokenRatio !== null && tokenRatio >= THRESHOLD) {
50
+ parts.push(`tokens ${Math.round(tokenRatio * 100)}% `
51
+ + `(${busiest} of ${TOKEN_CAP_PER_MESSAGE} in one message)`);
52
+ }
53
+ if (!parts.length) return null;
54
+ // HC-GF-129: the threshold has no upper bound, so every ratio from 75% to infinity used to get
55
+ // the same reassurance. On run 692b0c29 a call that ran 550,017ms against a 550,000ms ceiling —
56
+ // it TIMED OUT, `complete: false`, the build was cut off — was reported as "not over it".
57
+ // At or past 1.0 the limit was reached, and saying otherwise is simply false.
58
+ const over = (timeRatio !== null && timeRatio >= 1) || (tokenRatio !== null && tokenRatio >= 1);
59
+ return `headroom: ${parts.join(', ')} — `
60
+ + (over ? 'the limit was REACHED: this call was cut off' : 'close to the limit, not over it');
61
+ }
62
+
63
+ module.exports = { headroomLine, TOKEN_CAP_PER_MESSAGE, THRESHOLD };
@@ -37,7 +37,18 @@ const path = require('node:path');
37
37
  * perfectly valid. Line-anchoring is also strictly better for a body that
38
38
  * contains an indented or info-tagged fence of its own.
39
39
  */
40
- const FILE_BLOCK_RE = /^[ \t]*```[ \t]*file[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?\n([\s\S]*?)^[ \t]*```[ \t]*\r?$/gim;
40
+ /**
41
+ * HC-GF-127: the fence is 3 OR MORE backticks, and the closer must MATCH the opener (backreference).
42
+ *
43
+ * It used to be exactly three, so a block closed on the first ``` line inside it: a README carrying
44
+ * a ```js sample ended AT the sample, the rest was lost, and `malformed` came back empty — the
45
+ * artifact looked valid and materialized to the wrong bytes. Widening the opener alone would not
46
+ * help; only a matching closer lets a wider fence carry content that contains a narrower one.
47
+ *
48
+ * 3-backtick blocks parse exactly as before: `\1` on a 3-backtick opener IS the old literal. Pinned
49
+ * against 48 artifacts captured from real runs.
50
+ */
51
+ const FILE_BLOCK_RE = /^[ \t]*(`{3,})[ \t]*file[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?\n([\s\S]*?)^[ \t]*\1[ \t]*\r?$/gim;
41
52
 
42
53
  /**
43
54
  * The line-anchored closer necessarily absorbs the newline that terminates the
@@ -69,7 +80,7 @@ const EDIT_BLOCK_RE = new RegExp(
69
80
  'gim');
70
81
 
71
82
  /** HC-GF-109: the opening line of an edit block, whether or not the block parses — compared, so none is lost. */
72
- const EDIT_OPEN_RE = /^[ \t]*```[ \t]*edit[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?$/gim;
83
+ const EDIT_OPEN_RE = /^[ \t]*`{3,}[ \t]*edit[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?$/gim;
73
84
 
74
85
  /**
75
86
  * The OLD/NEW split inside an edit block. `<<<<<<< OLD` … `=======` … `>>>>>>> NEW`.
@@ -129,11 +140,12 @@ function extractFileBlocks(output) {
129
140
  const files = [];
130
141
  const seen = new Set();
131
142
  for (const m of scope.matchAll(FILE_BLOCK_RE)) {
132
- const rel = normalizeRepoPath(m[1]);
143
+ // HC-GF-127: m[1] is the fence itself now, so the path and body moved to m[2]/m[3].
144
+ const rel = normalizeRepoPath(m[2]);
133
145
  if (!rel) continue; // rejected (traversal/absolute) — see below
134
146
  if (seen.has(rel)) continue; // first block wins on a dup path
135
147
  seen.add(rel);
136
- files.push({ path: rel, contents: blockBody(m[2]) });
148
+ files.push({ path: rel, contents: blockBody(m[3]) });
137
149
  }
138
150
 
139
151
  // HC-019n-followup-27: region edits for files too big to bundle whole.
package/lib/mcp-tools.js CHANGED
@@ -164,18 +164,39 @@ async function stepInstruction({ runId, cwd } = {}) {
164
164
  // hone_submit_step — submit editor-executed output (HC-COMM-010 B3). Output
165
165
  // travels via a temp file (1MB-class payloads must not ride argv); the file
166
166
  // is best-effort deleted after the shell-out.
167
- async function submitStep({ runId, stepKey, output, contractVersion = '1', cwd } = {}) {
167
+ async function submitStep({ runId, stepKey, output, contractVersion = '1', fromTree = false, testReport, cwd } = {}) {
168
168
  if (!runId) return { isError: true, detail: 'runId is required' };
169
169
  if (!stepKey) return { isError: true, detail: 'stepKey is required' };
170
- if (typeof output !== 'string' || output.trim() === '') {
171
- return { isError: true, detail: 'output is required (the complete step output text)' };
170
+
171
+ // HC-GF-130: DELEGATE the from-tree render to the CLI rather than rendering here and submitting
172
+ // the result with `--file`. That is what shipped in HC-GF-091, and HC-GF-092's guard — which
173
+ // refuses `--contract 2` without `--from-tree` — turned the one caller that HAD built from the
174
+ // tree away. The guard reads the flag, not the provenance, so the adopter's primary path was
175
+ // blocked while the CLI fallback worked. Delegating also removes a second producer of the same
176
+ // artifact, which is the shape HC-GF-127 had to unpick across three parsers.
177
+ const hasOutput = typeof output === 'string' && output.trim() !== '';
178
+ if (fromTree && hasOutput) {
179
+ return { isError: true, detail: 'pass output OR fromTree, not both: they are two different claims about where the build is' };
172
180
  }
173
- const tmp = path.join(os.tmpdir(), `hone-editor-output-${Date.now()}-${process.pid}.txt`);
174
- try {
181
+ if (!fromTree && !hasOutput) {
182
+ return { isError: true, detail: 'output is required (the complete step output text), or set fromTree' };
183
+ }
184
+
185
+ const base = ['submit-step', String(runId), String(stepKey), '--contract', String(contractVersion), '--format', 'json'];
186
+ let tmp = null;
187
+ if (fromTree) {
188
+ base.push('--from-tree');
189
+ // Never invented: an absent report stays absent, the same rule the CLI and the prompt carry.
190
+ if (typeof testReport === 'string' && testReport.trim() !== '') base.push('--test-report', testReport);
191
+ } else {
192
+ // Output travels via a temp file — 1MB-class payloads must not ride argv.
193
+ tmp = path.join(os.tmpdir(), `hone-editor-output-${Date.now()}-${process.pid}.txt`);
175
194
  fsSync.writeFileSync(tmp, output, 'utf8');
176
- const r = await runHone(
177
- ['submit-step', String(runId), String(stepKey), '--file', tmp, '--contract', String(contractVersion), '--format', 'json'],
178
- { cwd });
195
+ base.push('--file', tmp);
196
+ }
197
+
198
+ try {
199
+ const r = await module.exports._internal.runHone(base, { cwd });
179
200
  if (r.error) return { isError: true, detail: r.error };
180
201
  const data = parseLastJson(r.stdout);
181
202
  return {
@@ -188,7 +209,7 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', cwd }
188
209
  : (r.stderr || r.stdout || 'submission failed').slice(0, 500),
189
210
  };
190
211
  } finally {
191
- try { fsSync.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
212
+ if (tmp) { try { fsSync.unlinkSync(tmp); } catch { /* best-effort cleanup */ } }
192
213
  }
193
214
  }
194
215
 
@@ -432,10 +453,12 @@ const TOOLS = [
432
453
  properties: {
433
454
  runId: { type: 'string' },
434
455
  stepKey: { type: 'string' },
435
- output: { type: 'string' },
456
+ output: { type: 'string', description: 'The complete step output text. Omit when fromTree is true.' },
436
457
  contractVersion: { type: 'string', default: '1' },
458
+ fromTree: { type: 'boolean', default: false, description: 'HC-GF-091: render the artifact from the working tree against HEAD — write the files with your own tools first, then submit. Removes the single-response output ceiling.' },
459
+ testReport: { type: 'string', description: 'With fromTree: your test-run report, carried verbatim. Omit if you did not run them; it is never invented for you.' },
437
460
  },
438
- required: ['runId', 'stepKey', 'output'],
461
+ required: ['runId', 'stepKey'],
439
462
  },
440
463
  handler: submitStep,
441
464
  },
@@ -455,4 +478,6 @@ module.exports = {
455
478
  killWorkflow,
456
479
  runStory, status, verifyPatch, checkPatch, showStep, approve, emitPr, verifyPr, agentEval, derive, sync, doctor,
457
480
  runHone, parseRunStarted, parseLastJson, honeBin, readHoneConfig, TOOLS,
481
+ // HC-GF-130: exposed so a test can observe the argv handed to the CLI without running it.
482
+ _internal: { runHone },
458
483
  };
package/lib/run-report.js CHANGED
@@ -262,6 +262,42 @@ function buildReport(run, extra = {}) {
262
262
  // "none" would be a claim the report cannot support for a run that never ran the check.
263
263
  for (const s of steps) {
264
264
  const d = s.diagnostics || {};
265
+ // HC-GF-089: how close this step came to its limits. worthKnowing, never needsYou — approaching
266
+ // a limit is not an action item, and an advisory that reads like one trains operators to ignore
267
+ // it. Absent when the server projected no headroom fields, which means "nothing to judge".
268
+ //
269
+ // Computed inline rather than imported: this module is deliberately dependency-free (see the
270
+ // header), and the numbers come from stored data like every other line here. cli/lib/headroom.js
271
+ // owns the same thresholds for the status surface, and HC-GF-089's tests pin the two agree.
272
+ const hr = d.headroom;
273
+ if (hr && typeof hr === 'object') {
274
+ const el = Number(hr.elapsed_ms);
275
+ const to = Number(hr.timeout_ms);
276
+ const toks = (Array.isArray(hr.output_tokens) ? hr.output_tokens : [])
277
+ .map(Number).filter((n) => Number.isFinite(n) && n >= 0);
278
+ const busiest = toks.length ? Math.max(...toks) : null;
279
+ const near = [];
280
+ if (Number.isFinite(el) && Number.isFinite(to) && to > 0 && el / to >= 0.75) {
281
+ near.push(`time ${Math.round((el / to) * 100)}% (${Math.round(el / 1000)}s of ${Math.round(to / 1000)}s)`);
282
+ }
283
+ if (busiest !== null && busiest / 128000 >= 0.75) {
284
+ near.push(`tokens ${Math.round((busiest / 128000) * 100)}% (${busiest} of 128000 in one message)`);
285
+ }
286
+ // HC-GF-129: `>= 0.75` has no upper bound, so this said "not over it" about a call that had
287
+ // gone over and died (run 692b0c29: 550,017ms of a 550,000ms ceiling, cut off). The wording
288
+ // and the PLACEMENT are wrong together — worthKnowing is right for a near miss, and wrong
289
+ // for a call that actually hit its limit, which is exactly what an operator needs to see.
290
+ const overTime = Number.isFinite(el) && Number.isFinite(to) && to > 0 && el >= to;
291
+ const overTokens = busiest !== null && busiest >= 128000;
292
+ if (near.length && (overTime || overTokens)) {
293
+ needsYou.push({
294
+ what: `${s.stepKey} headroom: ${near.join(', ')} — the limit was REACHED: that call was cut off`,
295
+ how: `hone show step ${run.runId} ${s.stepKey}`,
296
+ });
297
+ } else if (near.length) {
298
+ worthKnowing.push(`${s.stepKey} headroom: ${near.join(', ')} — close to the limit, not over it`);
299
+ }
300
+ }
265
301
  if (Array.isArray(d.invented_paths) && d.invented_paths.length) {
266
302
  needsYou.push({
267
303
  what: `${s.stepKey} named ${d.invented_paths.length} path(s) that do not exist in the repo, and was accepted anyway`,
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+ /**
3
+ * tree-artifact.js — HC-GF-091: render a step_4 artifact from the WORKING TREE.
4
+ *
5
+ * Editor mode had the same single-response ceiling as the server: `hone submit-step` read one
6
+ * file and `hone_submit_step` took a string, so the editor model still re-typed every changed
7
+ * file into a single response under its own output cap. The agent has already written those
8
+ * files with its own tools; this renders the artifact from what is on disk instead.
9
+ *
10
+ * The output is consumed by `cli/lib/materialize-diff.js`. Its exit modes, enumerated before a
11
+ * line was written (HC-GF-117), and what this module does about each:
12
+ *
13
+ * files[] whole-file blocks — HANDLED: created + modified files
14
+ * deletes[] `DELETE: <path>` — HANDLED: deleted files
15
+ * edits[] region edits — DELIBERATELY NOT: from-tree always sends whole files. The
16
+ * tree is the truth; an anchor-based edit would reintroduce
17
+ * exactly the drift this removes.
18
+ * malformed[] unparsable block — DELIBERATELY NOT REACHABLE: every fence we emit is longer
19
+ * than any fence run inside the file it wraps (see below).
20
+ * noChanges the honest no-op — HANDLED: a clean tree says NO CHANGES rather than nothing.
21
+ *
22
+ * Dependency-free by intent, like `cli/lib/headroom.js`: `git`, `fs` and `path` only.
23
+ */
24
+
25
+ const fs = require('node:fs');
26
+ const path = require('node:path');
27
+ const { execFileSync } = require('node:child_process');
28
+ const { gitEnv } = require('./git-env');
29
+
30
+ /** Bytes above which we do not attempt to inline a file at all. */
31
+ const MAX_FILE_BYTES = 1_000_000;
32
+
33
+ /**
34
+ * SC-002-followup-2: git exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE to the processes it
35
+ * spawns, and those BEAT `cwd`. Without gitEnv() this renders a different repository's tree when
36
+ * it runs under a git hook — and it would look like a perfectly normal build.
37
+ */
38
+ function gitOut(repoRoot, args) {
39
+ return execFileSync('git', args, { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
40
+ }
41
+
42
+ /**
43
+ * A fence wide enough to carry this content.
44
+ *
45
+ * HC-GF-127: the block closes on a line matching its OWN fence, so a file holding a ```js sample
46
+ * needs at least four backticks. Before that fix the format could not carry such a file at all and
47
+ * this module refused it by name; the parsers now take 3-or-more with a matching closer.
48
+ */
49
+ function fenceFor(content) {
50
+ let longest = 2;
51
+ for (const line of String(content).split('\n')) {
52
+ const m = /^[ \t]*(`{3,})/.exec(line);
53
+ if (m && m[1].length > longest) longest = m[1].length;
54
+ }
55
+ return '`'.repeat(longest + 1);
56
+ }
57
+
58
+ /** NUL in the first 8000 bytes is git's own binary heuristic. */
59
+ function looksBinary(buf) {
60
+ const n = Math.min(buf.length, 8000);
61
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
62
+ return false;
63
+ }
64
+
65
+ /**
66
+ * Paths changed against HEAD, plus untracked files.
67
+ *
68
+ * `git diff --name-status HEAD` alone does NOT see untracked files, so the obvious one-command
69
+ * implementation ships a build missing every file the agent created — the first named mutation
70
+ * target. `ls-files --others --exclude-standard` is the other half, and it is what honours
71
+ * .gitignore: a node_modules SYMLINK is a file to a naive directory walk
72
+ * (HC-COMM-010-followup-8), and git excludes it because the adopter said so.
73
+ */
74
+ function collectPaths(repoRoot) {
75
+ const changed = [];
76
+ const deleted = [];
77
+ let hasHead = true;
78
+ try { gitOut(repoRoot, ['rev-parse', '--verify', 'HEAD']); } catch { hasHead = false; }
79
+
80
+ if (hasHead) {
81
+ const out = gitOut(repoRoot, ['diff', '--name-status', '--no-renames', 'HEAD']);
82
+ for (const line of out.split('\n')) {
83
+ if (!line.trim()) continue;
84
+ const tab = line.indexOf('\t');
85
+ if (tab === -1) continue;
86
+ const status = line.slice(0, tab).trim();
87
+ const rel = line.slice(tab + 1).trim();
88
+ if (!rel) continue;
89
+ if (status.startsWith('D')) deleted.push(rel);
90
+ else changed.push(rel);
91
+ }
92
+ }
93
+ const others = gitOut(repoRoot, ['ls-files', '--others', '--exclude-standard']);
94
+ for (const rel of others.split('\n')) if (rel.trim()) changed.push(rel.trim());
95
+
96
+ return {
97
+ changed: [...new Set(changed)].sort(),
98
+ deleted: [...new Set(deleted)].sort(),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Render the artifact.
104
+ *
105
+ * @param {object} opts
106
+ * @param {string} opts.repoRoot repository to read
107
+ * @param {string} [opts.testReport] the agent's own test-run report, carried verbatim. NEVER
108
+ * invented: an absent report stays absent, because a fabricated
109
+ * "N passing" is the anti-fabrication failure the step_4
110
+ * validator already exists to catch.
111
+ * @returns {{ text: string, files: string[], deletes: string[], skipped: Array<{path,reason}> }}
112
+ */
113
+ function renderFromTree({ repoRoot, testReport } = {}) {
114
+ if (!repoRoot) throw new Error('renderFromTree: repoRoot is required');
115
+ const { changed, deleted } = collectPaths(repoRoot);
116
+
117
+ const blocks = [];
118
+ const included = [];
119
+ const skipped = [];
120
+
121
+ for (const rel of changed) {
122
+ const abs = path.join(repoRoot, rel);
123
+ let st;
124
+ try { st = fs.lstatSync(abs); } catch { skipped.push({ path: rel, reason: 'unreadable' }); continue; }
125
+ // Structural, and deliberately silent: a symlink is not file content. materialize-diff would
126
+ // write a REGULAR file holding the link's target bytes, converting it without saying so. Naming
127
+ // every one of these would also put `node_modules` in the artifact of any repo whose .gitignore
128
+ // says `node_modules/` — a directory pattern that does not match the symlink (HC-COMM-010-followup-8).
129
+ if (st.isSymbolicLink() || !st.isFile()) continue;
130
+ if (st.size > MAX_FILE_BYTES) { skipped.push({ path: rel, reason: `larger than ${MAX_FILE_BYTES} bytes` }); continue; }
131
+
132
+ const buf = fs.readFileSync(abs);
133
+ if (looksBinary(buf)) { skipped.push({ path: rel, reason: 'binary' }); continue; }
134
+
135
+ const content = buf.toString('utf8');
136
+ const fence = fenceFor(content);
137
+ const body = content.endsWith('\n') ? content.slice(0, -1) : content;
138
+ blocks.push(`${fence}file: ${rel}\n${body}\n${fence}`);
139
+ included.push(rel);
140
+ }
141
+
142
+ const lines = ['## Changed Files', ''];
143
+
144
+ if (!included.length && !deleted.length) {
145
+ // The honest no-op, in the shape the validator and the materialiser both already accept.
146
+ lines.push('NO CHANGES — the working tree matches HEAD.');
147
+ } else {
148
+ for (const b of blocks) { lines.push(b, ''); }
149
+ for (const rel of deleted) lines.push(`DELETE: ${rel}`);
150
+ if (deleted.length) lines.push('');
151
+ }
152
+
153
+ if (skipped.length) {
154
+ // Named, not silent: a file that is in the tree and not in the artifact must be visible as a
155
+ // decision. Kept out of the Changed Files blocks so it can never parse as one.
156
+ lines.push('### Not included', '');
157
+ for (const s of skipped) lines.push(`- \`${s.path}\` — ${s.reason}`);
158
+ lines.push('');
159
+ }
160
+
161
+ if (testReport) lines.push('### Test run', '', String(testReport).trim(), '');
162
+
163
+ return { text: lines.join('\n'), files: included, deletes: deleted, skipped };
164
+ }
165
+
166
+ module.exports = { renderFromTree, _internal: { fenceFor, looksBinary, collectPaths, MAX_FILE_BYTES } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.33.0",
3
+ "version": "1.34.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {