@hone-ai/cli 1.40.0 → 1.41.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 +22 -5
- package/lib/architect-config.js +5 -0
- package/lib/batch-follow.js +23 -7
- package/lib/bundle-limits.js +10 -0
- package/lib/call-sites.js +209 -26
- package/lib/context-reserve.js +11 -8
- package/lib/operator-question.js +9 -4
- package/lib/review-diff.js +188 -0
- package/lib/run-context.js +18 -41
- package/lib/run-report.js +94 -4
- package/lib/tree-artifact.js +36 -3
- package/package.json +1 -1
package/hone-cli.js
CHANGED
|
@@ -6038,6 +6038,8 @@ program
|
|
|
6038
6038
|
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); }
|
|
6039
6039
|
const fs = require('fs');
|
|
6040
6040
|
let output;
|
|
6041
|
+
let reviewDiff = null;
|
|
6042
|
+
let warned = false;
|
|
6041
6043
|
if (opts.fromTree) {
|
|
6042
6044
|
const { renderFromTree } = require('./lib/tree-artifact');
|
|
6043
6045
|
const rendered = renderFromTree({ repoRoot: process.cwd(), testReport: opts.testReport });
|
|
@@ -6046,15 +6048,32 @@ program
|
|
|
6046
6048
|
// artifact does not is a decision the operator has to be able to see.
|
|
6047
6049
|
for (const s2 of rendered.skipped) console.error(`not included: ${s2.path} — ${s2.reason}`);
|
|
6048
6050
|
console.error(`from-tree: ${rendered.files.length} file(s), ${rendered.deletes.length} deletion(s), ${rendered.skipped.length} skipped`);
|
|
6051
|
+
// HC-GF-390: for a build, the base diff of each file emitted whole whose original the reviewers saw only in
|
|
6052
|
+
// windows. Sent beside the artifact, stored apart from it, and never a reason this submit fails.
|
|
6053
|
+
if (stepKey === 'step_4') {
|
|
6054
|
+
try {
|
|
6055
|
+
const { computeReviewDiff } = require('./lib/review-diff');
|
|
6056
|
+
reviewDiff = computeReviewDiff({ repoRoot: process.cwd(), rendered,
|
|
6057
|
+
warn: (line) => { warned = true; console.error(line); } });
|
|
6058
|
+
} catch (e) { reviewDiff = null; warned = true; console.error(`review diff not sent: ${e.message}`); }
|
|
6059
|
+
if (reviewDiff) {
|
|
6060
|
+
console.error(`review diff: ${reviewDiff.stats.files} file(s), ${reviewDiff.notes.length} note(s), against ${reviewDiff.headSha.slice(0, 12)}`);
|
|
6061
|
+
for (const n of reviewDiff.notes) console.error(` review diff note: ${n}`);
|
|
6062
|
+
} else {
|
|
6063
|
+
console.error(warned ? 'review diff: none (see above)' : 'review diff: none (no modified file over the bundle cap)');
|
|
6064
|
+
}
|
|
6065
|
+
}
|
|
6049
6066
|
} else {
|
|
6050
6067
|
output = fs.readFileSync(opts.file, 'utf8');
|
|
6051
6068
|
}
|
|
6052
6069
|
const { data } = await client.post(
|
|
6053
6070
|
orchestratePath([runId, 'step', stepKey, 'editor-output']),
|
|
6054
|
-
{ contractVersion: opts.contract, output
|
|
6071
|
+
{ contractVersion: opts.contract, output,
|
|
6072
|
+
...(reviewDiff ? { reviewDiff: require('./lib/review-diff').toWire(reviewDiff) } : {}) }
|
|
6055
6073
|
);
|
|
6056
6074
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
6057
6075
|
console.log(`Step ${data.stepKey} completed (executed_by: ${data.executedBy}); gate: ${data.gateResult}.`);
|
|
6076
|
+
if (data.reviewDiffIgnored) console.error(`review diff not stored: ${data.reviewDiffIgnored} (the reviewers see the files unverified, as before)`);
|
|
6058
6077
|
console.log(`Approve when reviewed: hone run-story ${runId} --approve ${data.stepKey}`);
|
|
6059
6078
|
console.log('Verification is unchanged — check-patch/verify-patch judge this output, not trust.');
|
|
6060
6079
|
} catch (e) {
|
|
@@ -6107,10 +6126,6 @@ function runContextIo(apiConfig) {
|
|
|
6107
6126
|
e2eSpecMode: () => pc.readE2eSpecMode(cwd),
|
|
6108
6127
|
verificationGate: () => pc.readVerificationGateConfig(cwd),
|
|
6109
6128
|
verificationGateSources: () => pc.readVerificationGateSources(cwd),
|
|
6110
|
-
readPlanText: () => {
|
|
6111
|
-
const planPath = path.join(cwd, '.github', 'EXECUTION_PLAN.yml');
|
|
6112
|
-
return fs.existsSync(planPath) ? fs.readFileSync(planPath, 'utf8') : '';
|
|
6113
|
-
},
|
|
6114
6129
|
exists: (rel) => { const abs = inRepo(rel); return !!abs && fs.existsSync(abs); },
|
|
6115
6130
|
isDirectory: (rel) => { const abs = inRepo(rel); try { return !!abs && fs.statSync(abs).isDirectory(); } catch { return false; } },
|
|
6116
6131
|
readFile: (rel) => {
|
|
@@ -6200,6 +6215,7 @@ program
|
|
|
6200
6215
|
.option('--include-paths <paths>', 'Source file paths to bundle into the codebase context (HC-019n-followup-12). Comma-separated, and the flag may be repeated; both forms accumulate. Augments auto-detected paths from the issue body.', collectIncludePaths, [])
|
|
6201
6216
|
.option('--e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) ON for this run (overrides e2e.spec_generation config)')
|
|
6202
6217
|
.option('--skip-e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) OFF for this run (overrides e2e.spec_generation config)')
|
|
6218
|
+
.option('--consult-architect', 'HC-GF-397: run the architect consult (step_0b) even when the groomer says architect_engaged: false. It can only add the consult, never skip it.')
|
|
6203
6219
|
.action(async (storyIdOrRunId, opts) => {
|
|
6204
6220
|
const config = getConfig();
|
|
6205
6221
|
const client = api(config);
|
|
@@ -6678,6 +6694,7 @@ program
|
|
|
6678
6694
|
explicitSource: opts.storyFile ? { kind: 'file', label: path.basename(String(opts.storyFile)) } : { kind: 'description' },
|
|
6679
6695
|
includePaths: opts.includePaths,
|
|
6680
6696
|
axes: opts.axes,
|
|
6697
|
+
consultArchitect: opts.consultArchitect,
|
|
6681
6698
|
});
|
|
6682
6699
|
printRunContextNotes(storyCtx.notes.pre);
|
|
6683
6700
|
printRunContextNotes(repoCtx.notes);
|
package/lib/architect-config.js
CHANGED
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Pure helper — caller wraps fs.readFileSync + path resolution so the
|
|
22
22
|
* helper is unit-testable with stubs.
|
|
23
|
+
*
|
|
24
|
+
* HC-GF-030: NO PRODUCTION CODE CALLS THIS ANY MORE. `architect_consulted` and `checklist_b_completed`
|
|
25
|
+
* are server-owned (workflow-store.js SERVER_OWNED_CONFIG_KEYS; run-context.js SERVER_OWNED_KEYS): the
|
|
26
|
+
* server writes them when its step_0b consult is approved, and a plan file's claim is not evidence that
|
|
27
|
+
* a consult ran on THIS story. The module stays as the documented reader of the plan's shape.
|
|
23
28
|
*/
|
|
24
29
|
|
|
25
30
|
const path = require('node:path');
|
package/lib/batch-follow.js
CHANGED
|
@@ -59,9 +59,11 @@ const FORBIDDEN_FLAGS = Object.freeze(['--trust-source', '--skip-verify', '--rea
|
|
|
59
59
|
* Pause reasons that mean a child is NOT at a gate waiting for its step_4 output to be judged. `GET /:runId`
|
|
60
60
|
* sets `awaitingApproval` on ANY paused run with a current step, so without this a child paused by a breaker,
|
|
61
61
|
* a budget, an agent's hard pause or a guardrail would look like a gate, and the loop would run its patch.
|
|
62
|
-
* Sources: orchestrator.js (BATCH_*), orchestrator-guardrails.js GUARDRAIL_REFUSAL_CODES, the hard-pause scan
|
|
62
|
+
* Sources: orchestrator.js (BATCH_*), orchestrator-guardrails.js GUARDRAIL_REFUSAL_CODES, the hard-pause scan, and
|
|
63
|
+
* architect-consult.js CONSULT_REQUIRED_CODE (HC-GF-030): a run the consult guard refused at a step it had no
|
|
64
|
+
* consult for. Its attempt is `pending` and has no output, and approving it is refused by the server.
|
|
63
65
|
*/
|
|
64
|
-
const NOT_A_GATE = /^(BATCH_FAILURE_THRESHOLD_EXCEEDED:|BATCH_BUDGET_EXCEEDED:|MAX_RETRIES_EXCEEDED:|MAX_TOTAL_STEPS_EXCEEDED:|hard_pause at )/;
|
|
66
|
+
const NOT_A_GATE = /^(BATCH_FAILURE_THRESHOLD_EXCEEDED:|BATCH_BUDGET_EXCEEDED:|MAX_RETRIES_EXCEEDED:|MAX_TOTAL_STEPS_EXCEEDED:|ARCHITECT_CONSULT_REQUIRED:|hard_pause at )/;
|
|
65
67
|
|
|
66
68
|
/** What each child exit code means, for the action line. verify-patch.js and emit-pr.js EXIT maps. */
|
|
67
69
|
const CHECK_EXIT = { 0: 'applies', 1: 'no diff', 2: 'does not apply' };
|
|
@@ -393,6 +395,9 @@ function priorWhy(prior, runId) {
|
|
|
393
395
|
if (prior.state === 'adopted') {
|
|
394
396
|
return `a newer run of this story already exists${b ? ` (batch ${b})` : ''}, queued by an earlier re-serve or by you, so it is not re-served again`;
|
|
395
397
|
}
|
|
398
|
+
if (prior.state === 'inherited') {
|
|
399
|
+
return `this run is already the re-serve of ${prior.fromRunId ? `run ${prior.fromRunId}` : 'an earlier run'}${b ? ` (it is in batch ${b})` : ''}, so it is not re-served a second time`;
|
|
400
|
+
}
|
|
396
401
|
if (prior.state === 'queue-failed') return 're-serving it failed earlier in this run; nothing more is tried';
|
|
397
402
|
return `it names files again after being re-served as batch ${b}; a second re-serve is not tried`;
|
|
398
403
|
}
|
|
@@ -450,7 +455,7 @@ function stuckReason(waiting) {
|
|
|
450
455
|
* @param {Record<string, object>} details runId → `GET /orchestrate/:runId`, for paused runs and runs to emit
|
|
451
456
|
* @param {{emitted: Set<string>, reserved?: Map<string, {batchId: string|null, state: string}>}} memory `emitted`: runs
|
|
452
457
|
* whose emit-pr already ran in this process; `reserved`: story → what became of its re-serve (`state`: served,
|
|
453
|
-
* kill-failed, queue-failed or
|
|
458
|
+
* kill-failed, queue-failed, adopted or inherited)
|
|
454
459
|
* @param {{repoName?: string|null, openPr?: boolean, tracked?: Set<string>|null, trackedRetry?: string|null,
|
|
455
460
|
* realPath?: ((file: string) => string|null)|null}} [ctx] this checkout, so a run for another repository is
|
|
456
461
|
* not touched; `tracked`: the regular files git tracks here, which a request is checked against (`trackedRetry`:
|
|
@@ -594,7 +599,7 @@ function planTick(batch, details, memory, ctx = {}) {
|
|
|
594
599
|
* crash between the queue and the kill, leaves no marker, and only a newer run of the same story shows it happened.
|
|
595
600
|
* @returns {Promise<{lines: string[], needsYou: boolean, batchId: string|null, state: string|null, retry?: boolean}>}
|
|
596
601
|
* `batchId`: the batch the story was queued as, set whenever it was, even when the kill then failed; `state`: what
|
|
597
|
-
* became of the re-serve (served, kill-failed, queue-failed, adopted); `retry`: nothing was decided, ask again
|
|
602
|
+
* became of the re-serve (served, kill-failed, queue-failed, adopted = a newer run exists, inherited = this run is itself the re-serve); `retry`: nothing was decided, ask again
|
|
598
603
|
*/
|
|
599
604
|
async function reserve(action, io, tag) {
|
|
600
605
|
if (io.priorServe) {
|
|
@@ -606,12 +611,16 @@ async function reserve(action, io, tag) {
|
|
|
606
611
|
needsYou: false, batchId: null, state: null, retry: true };
|
|
607
612
|
}
|
|
608
613
|
if (prior && prior.kind === 'unknown') {
|
|
609
|
-
return { lines: [`${tag} not re-served: could not tell whether the story was served already; the paused run was left alone`],
|
|
614
|
+
return { lines: [`${tag} not re-served: could not tell whether the story was served already${prior.why ? ` (${prior.why})` : ''}; the paused run was left alone`],
|
|
610
615
|
needsYou: true, batchId: null, state: 'queue-failed' };
|
|
611
616
|
}
|
|
617
|
+
if (prior && prior.kind === 'earlier') {
|
|
618
|
+
return { lines: [`${tag} not re-served: this run is already the re-serve of run ${prior.runId} (it is in batch ${prior.batchId}), so it is not re-served a second time`],
|
|
619
|
+
needsYou: true, batchId: prior.batchId || null, state: 'inherited', fromRunId: prior.runId };
|
|
620
|
+
}
|
|
612
621
|
if (prior) {
|
|
613
622
|
const where = prior.batchId ? ` in batch ${prior.batchId}` : '';
|
|
614
|
-
return { lines: [`${tag} not re-served: run ${prior.runId}${where}
|
|
623
|
+
return { lines: [`${tag} not re-served: a newer run of this story exists (run ${prior.runId}${where})${prior.batchId ? '; following it' : '; its batch was not found, so it is not followed'}`],
|
|
615
624
|
needsYou: true, batchId: prior.batchId || null, state: 'adopted' };
|
|
616
625
|
}
|
|
617
626
|
}
|
|
@@ -695,6 +704,13 @@ async function autoReject(action, io, tag) {
|
|
|
695
704
|
const status = statusOf(e);
|
|
696
705
|
const f = fatal(e, `rejecting ${where}`);
|
|
697
706
|
if (f) return { lines: [], needsYou: false, state: 'retry', fatal: f };
|
|
707
|
+
// HC-GF-323-followup-2: the server caps rejects at step_4's max_retries_per_step, counting a person's rejects too, so
|
|
708
|
+
// its refusal can come before this client's own bound. That 409 is not "the run moved on": the run is still paused
|
|
709
|
+
// at the failed attempt, and only a person can approve, kill or raise the guardrail.
|
|
710
|
+
if (status === 409 && e.response.data && e.response.data.code === 'REJECT_RETRIES_EXHAUSTED') {
|
|
711
|
+
const note = `${where} failed verification and the server refused the reject: ${oneLine(e.response.data.error, 160)}`;
|
|
712
|
+
return { lines: [`${tag} ${note}`], needsYou: true, state: 'stuck', note };
|
|
713
|
+
}
|
|
698
714
|
if (status === 409) return { lines: [`${tag} ${where} was not rejected: the run moved on (409); this does not count`], needsYou: false, state: 'moved' };
|
|
699
715
|
if (status === 400) {
|
|
700
716
|
const note = `${where} failed verification and the server refused the reject (400: ${oneLine(e.response.data && e.response.data.error, 120)})`;
|
|
@@ -889,7 +905,7 @@ async function followBatch(batchId, io, opts = {}) {
|
|
|
889
905
|
}
|
|
890
906
|
if (action.kind === 'reserve') {
|
|
891
907
|
if (!out.retry) {
|
|
892
|
-
memory.reserved.set(action.storyId, { batchId: out.batchId, state: out.state });
|
|
908
|
+
memory.reserved.set(action.storyId, { batchId: out.batchId, state: out.state, fromRunId: out.fromRunId });
|
|
893
909
|
if (out.batchId && !followed.includes(out.batchId)) { followed.push(out.batchId); grew = true; }
|
|
894
910
|
}
|
|
895
911
|
} else if (action.kind === 'emit' || action.kind === 'draft') {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* HC-GF-390: the leaf home of the bundle's per-file cap. run-context.js windows a file whose text is
|
|
4
|
+
* longer than this (bundle-paths.js `planFileExcerpt`: `text.length <= limit` is sent whole), and
|
|
5
|
+
* review-diff.js diffs exactly the files a reviewer saw only windows of, so both read it from here.
|
|
6
|
+
* run-context.js re-exports it under the same name for the tests that import it from there.
|
|
7
|
+
*/
|
|
8
|
+
const BUNDLE_MAX_PER_FILE_CHARS = 25_000;
|
|
9
|
+
|
|
10
|
+
module.exports = { BUNDLE_MAX_PER_FILE_CHARS };
|
package/lib/call-sites.js
CHANGED
|
@@ -73,9 +73,22 @@ const SCOPE = 'tracked-repo';
|
|
|
73
73
|
|
|
74
74
|
// The caps. server/src/services/call-sites.js accepts at least these (pinned by a cross-package test): a server
|
|
75
75
|
// that took less would quietly turn a complete list into an incomplete one.
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
// HC-GF-385: 16, up from 6. Six slots spent on string keys (`step_0b`, `config`) left the functions a story changes
|
|
77
|
+
// NOT SEARCHED and hard-stopped step_1 four times on one story. The wire budget is held by MAX_TOTAL_HITS, not by
|
|
78
|
+
// the symbol count: a symbol past it is sent as its per-file list (FILES ONLY), so 16 costs what 6 did.
|
|
79
|
+
const MAX_SYMBOLS = 16;
|
|
80
|
+
/** Secret-looking names are reported, not searched; they take no slot of the 16 but are reported up to this many. */
|
|
78
81
|
const MAX_REFUSED = 2;
|
|
82
|
+
/**
|
|
83
|
+
* HC-GF-385: the hit lines ALL symbols together may send, spent in rank order: today's 6 x 40, so the 448 KiB
|
|
84
|
+
* per-story wire budget (server/src/lib/batch-body-limit.js) and its test keep their meaning. A symbol reached after
|
|
85
|
+
* it is spent sends `hits: []` plus its per-file list.
|
|
86
|
+
*/
|
|
87
|
+
const MAX_TOTAL_HITS = 240;
|
|
88
|
+
/** HC-GF-385: the definition probe ranks at most this many candidates, best-ranked first. */
|
|
89
|
+
const MAX_PROBE_CANDIDATES = 32;
|
|
90
|
+
/** HC-GF-385: the probe's own share of TOTAL_BUDGET_MS; past it the story's own order is kept, and said. */
|
|
91
|
+
const PROBE_TIMEOUT_MS = 8_000;
|
|
79
92
|
/** Past MAX_HITS_PER_SYMBOL a symbol also carries a per-file list (path + hit count), up to this many files... */
|
|
80
93
|
const MAX_FILE_LIST_FILES = 300;
|
|
81
94
|
/** ...and all the symbols' file lists together stay under this many bytes of JSON (server/src/lib/batch-body-limit.js budgets it). */
|
|
@@ -97,7 +110,38 @@ const PROSE_EXTENSIONS = Object.freeze(['md', 'mdx', 'markdown', 'rst', 'adoc',
|
|
|
97
110
|
|
|
98
111
|
// ── Choosing the symbols ────────────────────────────────────────────────────────────────────────
|
|
99
112
|
|
|
100
|
-
|
|
113
|
+
// Backticked spans are PAIRED first, by the CommonMark rule, and the 2-to-200 rule is applied to each paired span afterwards.
|
|
114
|
+
// A bound INSIDE a regex decides which backticks pair: at `x`, and `foo` the opening backtick of `x` cannot start a 2-character
|
|
115
|
+
// match, so its closing one does, the "span" is `, and`, and `foo` is lost (measured 2026-10-10 over the roadmap rows: 68 of 939
|
|
116
|
+
// extracted a wrong span set). A plain /`([^`\n]*)`/ fixed that and made the next one: after a run of TWO OR MORE backticks
|
|
117
|
+
// (a ``double`` span, an inline ```fence) the pairing never recovered for the rest of the line, and 7 rows lost symbols the old
|
|
118
|
+
// pattern found. The rule that has no such failure is the one Markdown itself uses: a run of N backticks opens a span that the
|
|
119
|
+
// next run of EXACTLY N backticks closes, and a run with no such closer is literal text. Per line (a span does not cross a
|
|
120
|
+
// newline here, as before). Linear: the next run of the same length is computed once per run, not searched for.
|
|
121
|
+
// The same defect as BACKTICKED_RE in bundle-paths.js (HC-GF-188-followup-2), which has the first fix only.
|
|
122
|
+
function backtickedSpans(text) {
|
|
123
|
+
const spans = [];
|
|
124
|
+
for (const line of String(text || '').split('\n')) {
|
|
125
|
+
if (!line.includes('`')) continue;
|
|
126
|
+
const runs = [];
|
|
127
|
+
for (const m of line.matchAll(/`+/g)) runs.push({ at: m.index, len: m[0].length });
|
|
128
|
+
const nextSame = new Array(runs.length).fill(-1);
|
|
129
|
+
const lastOfLen = new Map();
|
|
130
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
131
|
+
nextSame[i] = lastOfLen.has(runs[i].len) ? lastOfLen.get(runs[i].len) : -1;
|
|
132
|
+
lastOfLen.set(runs[i].len, i);
|
|
133
|
+
}
|
|
134
|
+
for (let i = 0; i < runs.length; i++) {
|
|
135
|
+
const j = nextSame[i];
|
|
136
|
+
if (j < 0) continue; // no closer of this length: the run is literal text
|
|
137
|
+
let span = line.slice(runs[i].at + runs[i].len, runs[j].at);
|
|
138
|
+
if (span.length > 2 && span.startsWith(' ') && span.endsWith(' ') && /[^ ]/.test(span)) span = span.slice(1, -1); // CommonMark: one space each side
|
|
139
|
+
spans.push(span);
|
|
140
|
+
i = j; // resume after the closer; the runs between are inside the span
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return spans;
|
|
144
|
+
}
|
|
101
145
|
// One identifier, optionally qualified (`a.b`, `A::b`, `a->b`, `A#b`) and optionally called (`f()`, `f(x)`). The LAST
|
|
102
146
|
// segment is the symbol: `this.ensureConnected` is a reference to `ensureConnected` wherever it is called from.
|
|
103
147
|
// Group 1 is the qualifier (empty when there is none), 2 the name, 3 the call's parentheses (undefined when not called).
|
|
@@ -158,25 +202,129 @@ function symbolOfSpan(span) {
|
|
|
158
202
|
* @returns {{symbols: string[], omitted: string[], omittedTotal: number, refused: string[]}}
|
|
159
203
|
*/
|
|
160
204
|
function pickSymbols(storyText, { max = MAX_SYMBOLS } = {}) {
|
|
205
|
+
const { ranked, refusedAll } = rankCandidates(storyText);
|
|
206
|
+
return sliceRanked(ranked.map((c) => c.name), refusedAll, max);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Every candidate the story names, in the story's order (explicitly written first, then by how often it is named,
|
|
211
|
+
* ties in the order first named), with the facts that order was made from. Secret-looking names are split off.
|
|
212
|
+
* Synchronous and text-only: `pickSymbols` keeps that contract for callers this file cannot see (HC-GF-385).
|
|
213
|
+
* @returns {{ranked: Array<{name: string, explicit: boolean, count: number}>, refusedAll: string[]}}
|
|
214
|
+
*/
|
|
215
|
+
function rankCandidates(storyText) {
|
|
161
216
|
const counts = new Map();
|
|
162
217
|
const explicitNames = new Set();
|
|
163
|
-
for (const
|
|
164
|
-
|
|
218
|
+
for (const span of backtickedSpans(storyText)) {
|
|
219
|
+
if (span.length < 2 || span.length > 200) continue; // the length rule, after pairing
|
|
220
|
+
const sym = symbolOfSpan(span);
|
|
165
221
|
if (!sym) continue;
|
|
166
222
|
counts.set(sym.name, (counts.get(sym.name) || 0) + 1);
|
|
167
223
|
if (sym.explicit) explicitNames.add(sym.name);
|
|
168
224
|
}
|
|
169
225
|
// Array.prototype.sort is stable (ES2019), and a Map iterates in insertion order: ties keep the order the story first names them.
|
|
170
226
|
const all = [...counts.keys()].sort((a, b) => (explicitNames.has(b) - explicitNames.has(a)) || (counts.get(b) - counts.get(a)));
|
|
171
|
-
const refusedAll = all.filter((n) => SECRET_NAME.test(n));
|
|
172
|
-
const ranked = all.filter((n) => !SECRET_NAME.test(n));
|
|
173
|
-
const refused = refusedAll.slice(0, MAX_REFUSED);
|
|
174
|
-
const beyond = [...ranked.slice(max), ...refusedAll.slice(MAX_REFUSED)];
|
|
175
227
|
return {
|
|
176
|
-
|
|
228
|
+
ranked: all.filter((n) => !SECRET_NAME.test(n)).map((name) => ({ name, explicit: explicitNames.has(name), count: counts.get(name) })),
|
|
229
|
+
refusedAll: all.filter((n) => SECRET_NAME.test(n)),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The first `max` names are searched; the rest, and the refused names past MAX_REFUSED, are reported as omitted. */
|
|
234
|
+
function sliceRanked(names, refusedAll, max) {
|
|
235
|
+
const beyond = [...names.slice(max), ...refusedAll.slice(MAX_REFUSED)];
|
|
236
|
+
return {
|
|
237
|
+
symbols: names.slice(0, max),
|
|
177
238
|
omitted: beyond.slice(0, MAX_OMITTED_NAMES),
|
|
178
239
|
omittedTotal: beyond.length,
|
|
179
|
-
refused,
|
|
240
|
+
refused: refusedAll.slice(0, MAX_REFUSED),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Ranking by definition (HC-GF-385) ───────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/** Files whose matches never define code: documentation, and data formats (an object or YAML key is not a symbol). */
|
|
247
|
+
const DATA_EXTENSIONS = Object.freeze(['json', 'jsonc', 'json5', 'yaml', 'yml', 'toml', 'lock', 'csv', 'tsv', 'xml', 'ini', 'env', 'properties']);
|
|
248
|
+
/** A word before `name(` that makes it a call or an expression, never a C-family definition. */
|
|
249
|
+
const NOT_A_RETURN_TYPE = new Set(['return', 'await', 'new', 'throw', 'yield', 'else', 'case', 'typeof', 'delete', 'void', 'in', 'of', 'do',
|
|
250
|
+
'if', 'while', 'for', 'switch', 'catch', 'and', 'or', 'not', 'is', 'as', 'echo', 'print', 'puts', 'go', 'defer', 'export', 'default']);
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* How strongly one line defines `name`: 2 for a function, method, class or type; 1 for a top-level or exported
|
|
254
|
+
* constant or variable; 0 for anything else (a call, a key, a mention). Language-neutral patterns, covering
|
|
255
|
+
* JavaScript and TypeScript, Python, Go, Ruby, Java and the C family, and `fn`/`fun`/`func` (Rust, Kotlin, Swift).
|
|
256
|
+
* `name` is an identifier ([A-Za-z0-9_]), so it is safe inside a RegExp.
|
|
257
|
+
* @param {string} line
|
|
258
|
+
* @param {string} name
|
|
259
|
+
* @returns {0|1|2}
|
|
260
|
+
*/
|
|
261
|
+
function definitionTier(line, name) {
|
|
262
|
+
const n = `${name}(?![A-Za-z0-9_$])`;
|
|
263
|
+
const t = String(line);
|
|
264
|
+
// A keyword that introduces a definition: `function f`, `class C`, `def f`, `def self.f`, `func (r *T) f`, `fn f`, `fun f`.
|
|
265
|
+
if (new RegExp(`(?:^|[^A-Za-z0-9_$.])(?:function\\*?|class|def|func|fn|fun|interface|enum|struct|trait|type|module|record|protocol)\\s+(?:self\\.)?(?:\\([^)]*\\)\\s*)?${n}`).test(t)) return 2;
|
|
266
|
+
// A function value bound at top level or exported: `export const f = async (a) =>`, `const f = function`.
|
|
267
|
+
if (new RegExp(`^(?:export\\s+)?(?:const|let|var)\\s+${n}\\s*=\\s*(?:async\\b\\s*)?(?:function\\b|\\([^)]*\\)\\s*=>|[A-Za-z_$][A-Za-z0-9_$]*\\s*=>)`).test(t)) return 2;
|
|
268
|
+
// A method, in a class body or an object literal: ` async f(a, b) {`, ` static get f() {`.
|
|
269
|
+
if (new RegExp(`^\\s*(?:(?:static|async|get|set|public|private|protected|readonly|override|abstract|final)\\s+)*${n}\\s*\\([^)]*\\)\\s*(?::\\s*[^={;]+)?\\{`).test(t)) return 2;
|
|
270
|
+
// A C-family signature: one or more type words, then `name(`, and no `;` (a prototype or a call statement).
|
|
271
|
+
const sig = new RegExp(`^\\s*((?:[A-Za-z_][A-Za-z0-9_<>\\[\\],:*&?]*\\s+)+)\\*?${n}\\s*\\([^;]*$`).exec(t);
|
|
272
|
+
if (sig) {
|
|
273
|
+
const words = sig[1].trim().split(/\s+/);
|
|
274
|
+
if (!NOT_A_RETURN_TYPE.has(words[words.length - 1])) return 2;
|
|
275
|
+
}
|
|
276
|
+
// A top-level or exported binding of anything else: `const f =`, `export let f`, `module.exports.f =`, Go `var f`.
|
|
277
|
+
if (new RegExp(`^(?:export\\s+)?(?:const|let|var|val)\\s+${n}`).test(t)) return 1;
|
|
278
|
+
if (new RegExp(`^(?:module\\.)?exports\\.${n}\\s*=`).test(t)) return 1;
|
|
279
|
+
// Python and Ruby top-level assignment: `f = ...` at column 0, not `f == x`.
|
|
280
|
+
if (new RegExp(`^${n}\\s*=(?!=)`).test(t)) return 1;
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Rank candidates by whether the repository defines them, with ONE batched `git grep -F -w` over all of them.
|
|
286
|
+
* Functions, methods, classes and types come first, then top-level or exported constants, then names with no
|
|
287
|
+
* definition (string keys, config fields, words). Within a tier the story's own order holds. Any failure, timeout
|
|
288
|
+
* or output this cannot read keeps the story's order, and the note says so: a probe never empties the search.
|
|
289
|
+
* @param {Array<{name: string}>} ranked the story's order
|
|
290
|
+
* @returns {Promise<{ranked: Array<{name: string}>, fallback: string|null, undefinedNames: string[]}>}
|
|
291
|
+
*/
|
|
292
|
+
async function rankByDefinition(ranked, { run, realPath }) {
|
|
293
|
+
if (ranked.length === 0) return { ranked, fallback: null, undefinedNames: [] };
|
|
294
|
+
const probed = ranked.slice(0, MAX_PROBE_CANDIDATES);
|
|
295
|
+
const argv = ['grep', '-n', '-z', '-w', '-F', '-I', '--no-color', '--no-recurse-submodules'];
|
|
296
|
+
for (const c of probed) argv.push('-e', c.name);
|
|
297
|
+
let r;
|
|
298
|
+
try { r = await run(argv, { timeoutMs: PROBE_TIMEOUT_MS }); } catch { r = { failure: 'the probe could not be run' }; }
|
|
299
|
+
const problem = grepProblem(r || {});
|
|
300
|
+
if (problem) return { ranked, fallback: `the definition probe gave no answer (${problem}), so the story's own order was used`, undefinedNames: [] };
|
|
301
|
+
const lines = r.status === 1 ? [] : parseGrepOutput(r.stdout);
|
|
302
|
+
if (lines === null) return { ranked, fallback: 'the definition probe printed output that could not be read, so the story\'s own order was used', undefinedNames: [] };
|
|
303
|
+
|
|
304
|
+
const tier = new Map(probed.map((c) => [c.name, 0]));
|
|
305
|
+
const kinds = new Map();
|
|
306
|
+
const wordRe = /[A-Za-z_$][A-Za-z0-9_$]*/g;
|
|
307
|
+
for (const hit of lines) {
|
|
308
|
+
let kind = kinds.get(hit.path);
|
|
309
|
+
if (kind === undefined) {
|
|
310
|
+
const ext = path.posix.extname(hit.path).slice(1).toLowerCase();
|
|
311
|
+
kind = DATA_EXTENSIONS.includes(ext) ? 'data' : classifyPath(hit.path, realPath);
|
|
312
|
+
kinds.set(hit.path, kind);
|
|
313
|
+
}
|
|
314
|
+
if (kind !== 'ok') continue; // documentation, data and withheld paths define nothing here
|
|
315
|
+
for (const w of new Set(String(hit.text).match(wordRe) || [])) {
|
|
316
|
+
if (!tier.has(w) || tier.get(w) === 2) continue;
|
|
317
|
+
const d = definitionTier(hit.text, w);
|
|
318
|
+
if (d > tier.get(w)) tier.set(w, d);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Stable: within a tier the story's order is kept. Candidates past the probe cap keep their place after the probed.
|
|
322
|
+
const order = probed.map((c, i) => ({ c, i, t: tier.get(c.name) }))
|
|
323
|
+
.sort((a, b) => (b.t - a.t) || (a.i - b.i)).map((x) => x.c);
|
|
324
|
+
return {
|
|
325
|
+
ranked: [...order, ...ranked.slice(MAX_PROBE_CANDIDATES)],
|
|
326
|
+
fallback: null,
|
|
327
|
+
undefinedNames: probed.filter((c) => tier.get(c.name) === 0).map((c) => c.name),
|
|
180
328
|
};
|
|
181
329
|
}
|
|
182
330
|
|
|
@@ -288,7 +436,7 @@ function parseFileList(out) {
|
|
|
288
436
|
* - 'not_searched': no answer, and `reason` says why. Never to be read as "no callers".
|
|
289
437
|
* `binary` is how many files match only when git's binary-file skip (-I) is off; they cannot be listed.
|
|
290
438
|
*/
|
|
291
|
-
async function searchSymbol(symbol, { run, realPath }) {
|
|
439
|
+
async function searchSymbol(symbol, { run, realPath, maxHits = MAX_HITS_PER_SYMBOL }) {
|
|
292
440
|
// Defence in depth: pickSymbols only yields identifiers, but this is exported, and the symbol reaches an argv and a RegExp.
|
|
293
441
|
if (typeof symbol !== 'string' || !/^[A-Za-z0-9_]+$/.test(symbol)) {
|
|
294
442
|
return { symbol: String(symbol).slice(0, 80), status: 'not_searched', reason: 'not an identifier' };
|
|
@@ -323,7 +471,9 @@ async function searchSymbol(symbol, { run, realPath }) {
|
|
|
323
471
|
else sendable.push(hit);
|
|
324
472
|
}
|
|
325
473
|
const total = sendable.length + withheld;
|
|
326
|
-
|
|
474
|
+
// HC-GF-385: `maxHits` is what is left of the story's MAX_TOTAL_HITS (0 once it is spent), never more than the per-symbol cap.
|
|
475
|
+
const cap = Math.max(0, Math.min(MAX_HITS_PER_SYMBOL, Number.isSafeInteger(maxHits) ? maxHits : MAX_HITS_PER_SYMBOL));
|
|
476
|
+
const hits = sendable.slice(0, cap).map((h) => ({ path: h.path, line: h.line, text: lineText(h.text, symbol) }));
|
|
327
477
|
const perFile = new Map();
|
|
328
478
|
for (const h of sendable) perFile.set(h.path, (perFile.get(h.path) || 0) + 1);
|
|
329
479
|
const entry = {
|
|
@@ -336,7 +486,10 @@ async function searchSymbol(symbol, { run, realPath }) {
|
|
|
336
486
|
binary,
|
|
337
487
|
hits,
|
|
338
488
|
};
|
|
339
|
-
|
|
489
|
+
// HC-GF-385: every searched symbol with a sendable hit carries its per-file list, not only one past the hit cap.
|
|
490
|
+
// A symbol cut by the total-hit budget needs it to be FILES ONLY, and a whole symbol whose hit lines the server's
|
|
491
|
+
// render budget drops needs it to stay an enumeration at file level.
|
|
492
|
+
if (sendable.length > 0) {
|
|
340
493
|
const all = [...perFile].map(([p, n]) => ({ path: p, hits: n }));
|
|
341
494
|
entry.file_list = all.slice(0, MAX_FILE_LIST_FILES);
|
|
342
495
|
entry.file_list_complete = all.length <= MAX_FILE_LIST_FILES;
|
|
@@ -344,11 +497,17 @@ async function searchSymbol(symbol, { run, realPath }) {
|
|
|
344
497
|
return entry;
|
|
345
498
|
}
|
|
346
499
|
|
|
347
|
-
/**
|
|
500
|
+
/**
|
|
501
|
+
* Keep the symbols' file lists inside MAX_FILE_LIST_BYTES together; a list that is cut says so.
|
|
502
|
+
* HC-GF-385: the bytes go first to the symbols whose hit lines were cut (by the per-symbol cap or the total-hit
|
|
503
|
+
* budget), in rank order, because for them the list IS the enumeration; then to the rest, in rank order. Spent
|
|
504
|
+
* in plain rank order, whole symbols at the top starved the FILES ONLY ones below them into INCOMPLETE.
|
|
505
|
+
*/
|
|
348
506
|
function fitFileLists(symbols) {
|
|
349
507
|
let room = MAX_FILE_LIST_BYTES;
|
|
350
|
-
|
|
351
|
-
|
|
508
|
+
const cut = (s) => Array.isArray(s.hits) && Number.isSafeInteger(s.total) && s.hits.length < s.total - (s.withheld || 0);
|
|
509
|
+
const ordered = [...symbols.filter((s) => s.file_list && cut(s)), ...symbols.filter((s) => s.file_list && !cut(s))];
|
|
510
|
+
for (const s of ordered) {
|
|
352
511
|
const kept = [];
|
|
353
512
|
for (const f of s.file_list) {
|
|
354
513
|
const size = Buffer.byteLength(JSON.stringify(f), 'utf8') + 1;
|
|
@@ -379,8 +538,9 @@ const warn = (text) => ({ level: 'warn', text });
|
|
|
379
538
|
*/
|
|
380
539
|
async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now, maxSymbols = MAX_SYMBOLS }) {
|
|
381
540
|
const notes = [];
|
|
382
|
-
const
|
|
383
|
-
|
|
541
|
+
const { ranked: storyOrder, refusedAll } = rankCandidates(storyText);
|
|
542
|
+
// The early return comes BEFORE the probe: a story that names nothing searchable runs no git at all.
|
|
543
|
+
if (storyOrder.length === 0 && refusedAll.length === 0) return { callSites: null, notes };
|
|
384
544
|
|
|
385
545
|
// From the repository's top level, wherever `hone` ran: `git grep` from a subdirectory searches only that subtree.
|
|
386
546
|
let root = cwd;
|
|
@@ -389,17 +549,24 @@ async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now,
|
|
|
389
549
|
const dir = top.failure || top.status !== 0 ? '' : top.stdout.trim();
|
|
390
550
|
if (dir) root = dir;
|
|
391
551
|
}
|
|
392
|
-
const git = run || ((argv) => runGit(argv, { cwd: root }));
|
|
552
|
+
const git = run || ((argv, opts = {}) => runGit(argv, { cwd: root, ...opts }));
|
|
393
553
|
const real = realPath || ((rel) => repoRealPath(root, rel));
|
|
394
554
|
const commit = await headCommit(git);
|
|
395
|
-
|
|
555
|
+
// HC-GF-385: the clock starts before the probe, so the probe's time comes out of the same TOTAL_BUDGET_MS.
|
|
396
556
|
const started = now();
|
|
557
|
+
// Rank BEFORE the slice and compute what was omitted AFTER it, or the functions never reach the slice.
|
|
558
|
+
const probe = await rankByDefinition(storyOrder, { run: git, realPath: real });
|
|
559
|
+
const picked = sliceRanked(probe.ranked.map((c) => c.name), refusedAll, maxSymbols);
|
|
560
|
+
const symbols = [];
|
|
561
|
+
let hitsLeft = MAX_TOTAL_HITS;
|
|
397
562
|
for (const symbol of picked.symbols) {
|
|
398
563
|
if (now() - started > TOTAL_BUDGET_MS) {
|
|
399
564
|
symbols.push({ symbol, status: 'not_searched', reason: `the ${TOTAL_BUDGET_MS / 1000}s search budget for this story was spent on earlier symbols` });
|
|
400
565
|
continue;
|
|
401
566
|
}
|
|
402
|
-
|
|
567
|
+
const entry = await searchSymbol(symbol, { run: git, realPath: real, maxHits: hitsLeft });
|
|
568
|
+
if (Array.isArray(entry.hits)) hitsLeft = Math.max(0, hitsLeft - entry.hits.length);
|
|
569
|
+
symbols.push(entry);
|
|
403
570
|
}
|
|
404
571
|
for (const symbol of picked.refused) symbols.push({ symbol, status: 'not_searched', reason: SECRET_REASON });
|
|
405
572
|
fitFileLists(symbols);
|
|
@@ -413,12 +580,17 @@ async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now,
|
|
|
413
580
|
omitted: picked.omitted,
|
|
414
581
|
omitted_total: picked.omittedTotal,
|
|
415
582
|
};
|
|
416
|
-
notes.push(
|
|
583
|
+
if (probe.fallback) notes.push(warn(` ⚠ call sites: ${probe.fallback}`));
|
|
584
|
+
const searchedUndefined = probe.undefinedNames.filter((n) => picked.symbols.includes(n));
|
|
585
|
+
if (!probe.fallback && searchedUndefined.length > 0) {
|
|
586
|
+
notes.push(info(` no definition found for ${searchedUndefined.join(', ')} (ranked after defined code; it may be a key, a field, or one this story creates)`));
|
|
587
|
+
}
|
|
588
|
+
notes.push(...describeCallSites(callSites, { maxSymbols }));
|
|
417
589
|
return { callSites, notes };
|
|
418
590
|
}
|
|
419
591
|
|
|
420
592
|
/** The operator's lines for a result: one info line per symbol that is whole, a warning for each that is not. */
|
|
421
|
-
function describeCallSites(callSites) {
|
|
593
|
+
function describeCallSites(callSites, { maxSymbols = MAX_SYMBOLS } = {}) {
|
|
422
594
|
const notes = [];
|
|
423
595
|
const where = callSites.commit ? `tracked files at ${callSites.commit.slice(0, 12)}` : 'tracked files (commit unknown)';
|
|
424
596
|
notes.push(info(` → call sites: searched ${callSites.symbols.length} symbol(s) the story names, in ${where}`));
|
|
@@ -437,7 +609,10 @@ function describeCallSites(callSites) {
|
|
|
437
609
|
binary ? `${binary} binary file${binary === 1 ? '' : 's'} match and cannot be listed` : '',
|
|
438
610
|
].filter(Boolean).join('; ');
|
|
439
611
|
const fileLevel = s.status === 'incomplete' && binary === 0 && s.file_list_complete === true && Array.isArray(s.file_list);
|
|
440
|
-
if (s.status === 'incomplete' && fileLevel) {
|
|
612
|
+
if (s.status === 'incomplete' && fileLevel && s.hits.length === 0) {
|
|
613
|
+
// HC-GF-385: reached after the total-hit budget was spent. The server renders it FILES ONLY.
|
|
614
|
+
notes.push(info(` ${s.symbol}: FILES ONLY — ${listable} hits in ${s.files} file${s.files === 1 ? '' : 's'}; every file with a hit is named with its count, individual lines not sent (the ${MAX_TOTAL_HITS}-hit budget was spent on higher-ranked symbols)${s.withheld ? `; EXCEPT ${s.withheld} hit${s.withheld === 1 ? '' : 's'} in withheld paths the planner cannot see` : ''}${s.prose ? `; ${s.prose} more in documentation` : ''}`));
|
|
615
|
+
} else if (s.status === 'incomplete' && fileLevel) {
|
|
441
616
|
notes.push(info(` ${s.symbol}: COMPLETE AT FILE LEVEL — ${listable} hits in ${s.files} file${s.files === 1 ? '' : 's'}; the first ${s.hits.length} are listed and every file with a hit is named with its count${s.withheld ? `; EXCEPT ${s.withheld} hit${s.withheld === 1 ? '' : 's'} in withheld paths the planner cannot see` : ''}${s.prose ? `; ${s.prose} more in documentation` : ''}`));
|
|
442
617
|
} else if (s.status === 'incomplete') {
|
|
443
618
|
notes.push(warn(` ⚠ call sites for ${s.symbol}: INCOMPLETE — ${s.hits.length} of ${listable} hits listed (cap ${MAX_HITS_PER_SYMBOL}${s.file_list ? `, ${s.file_list.length} of ${s.files} files named` : ''}); the planner cannot claim an enumeration${extra ? ` (${extra})` : ''}`));
|
|
@@ -454,16 +629,21 @@ function describeCallSites(callSites) {
|
|
|
454
629
|
const omittedTotal = callSites.omitted_total || 0;
|
|
455
630
|
if (omittedTotal > 0) {
|
|
456
631
|
const shown = callSites.omitted.join(', ') + (omittedTotal > callSites.omitted.length ? ', …' : '');
|
|
457
|
-
notes.push(warn(` ⚠ call sites: the story names ${omittedTotal} more compound identifier(s) beyond the ${
|
|
632
|
+
notes.push(warn(` ⚠ call sites: the story names ${omittedTotal} more compound identifier(s) beyond the ${maxSymbols}-symbol cap — NOT SEARCHED: ${shown}`));
|
|
458
633
|
}
|
|
459
634
|
return notes;
|
|
460
635
|
}
|
|
461
636
|
|
|
462
637
|
module.exports = {
|
|
638
|
+
backtickedSpans,
|
|
463
639
|
CALL_SITES_VERSION,
|
|
464
640
|
SCOPE,
|
|
465
641
|
MAX_SYMBOLS,
|
|
466
642
|
MAX_REFUSED,
|
|
643
|
+
MAX_TOTAL_HITS,
|
|
644
|
+
MAX_PROBE_CANDIDATES,
|
|
645
|
+
PROBE_TIMEOUT_MS,
|
|
646
|
+
DATA_EXTENSIONS,
|
|
467
647
|
MAX_FILE_LIST_FILES,
|
|
468
648
|
MAX_FILE_LIST_BYTES,
|
|
469
649
|
SECRET_NAME,
|
|
@@ -478,6 +658,9 @@ module.exports = {
|
|
|
478
658
|
MAX_OUTPUT_BYTES,
|
|
479
659
|
isCompoundIdentifier,
|
|
480
660
|
pickSymbols,
|
|
661
|
+
rankCandidates,
|
|
662
|
+
definitionTier,
|
|
663
|
+
rankByDefinition,
|
|
481
664
|
fitFileLists,
|
|
482
665
|
runGit,
|
|
483
666
|
parseGrepOutput,
|
package/lib/context-reserve.js
CHANGED
|
@@ -208,21 +208,24 @@ function batchIdFromQueueOutput(stdout) {
|
|
|
208
208
|
* status, created_at, error_message), newest first.
|
|
209
209
|
* - `later`: a run of the story in this repository, created AFTER the paused one and not killed. A re-serve that
|
|
210
210
|
* queued but whose kill failed, or that died between the two, leaves exactly this and no marker.
|
|
211
|
-
* - `earlier`: a run of the story killed with `re-served as batch <id>`
|
|
212
|
-
* re-serve (the loop was started on the new batch alone) and a second
|
|
213
|
-
*
|
|
214
|
-
*
|
|
211
|
+
* - `earlier`: a run of the story, older than this one, killed with `re-served as batch <id>` where <id> is the batch
|
|
212
|
+
* THIS run is in (`batchId`): this run is that re-serve (the loop was started on the new batch alone), and a second
|
|
213
|
+
* pause here is the second. A story re-served into some OTHER batch, however long ago, is history and not this
|
|
214
|
+
* run's lineage: it neither blocks nor justifies anything (HC-GF-351-followup-1).
|
|
215
|
+
* - `unknown`: the paused run's creation time is not a date or its batch id is not one, so neither can be told.
|
|
216
|
+
* @returns {{kind: 'later'|'earlier'|'unknown', runId?: string, batchId?: string, why?: string}|null}
|
|
215
217
|
*/
|
|
216
|
-
function serveHistory(rows, { runId, storyId, repoName, since }) {
|
|
218
|
+
function serveHistory(rows, { runId, storyId, repoName, since, batchId: ownBatch }) {
|
|
217
219
|
const t0 = Date.parse(since);
|
|
218
|
-
if (!Number.isFinite(t0)) return { kind: 'unknown' };
|
|
220
|
+
if (!Number.isFinite(t0)) return { kind: 'unknown', why: 'its creation time is not known' };
|
|
221
|
+
if (!isBatchId(ownBatch)) return { kind: 'unknown', why: 'the batch it is in is not known' };
|
|
219
222
|
for (const row of Array.isArray(rows) ? rows : []) {
|
|
220
223
|
if (!row || row.id === runId || row.story_id !== storyId || row.repo_name !== repoName) continue;
|
|
221
224
|
const t = Date.parse(row.created_at);
|
|
222
225
|
if (!Number.isFinite(t)) continue;
|
|
223
226
|
if (t > t0 && row.status !== 'killed') return { kind: 'later', runId: row.id };
|
|
224
|
-
const
|
|
225
|
-
if (
|
|
227
|
+
const reserved = t < t0 ? reservedBatchOf({ status: row.status, errorMessage: row.error_message }) : null;
|
|
228
|
+
if (reserved && reserved === ownBatch) return { kind: 'earlier', runId: row.id, batchId: reserved };
|
|
226
229
|
}
|
|
227
230
|
return null;
|
|
228
231
|
}
|
package/lib/operator-question.js
CHANGED
|
@@ -27,10 +27,15 @@
|
|
|
27
27
|
// U+061C ARABIC LETTER MARK is the third bidi mark beside LRM/RLM (review round 3 F3). Written as
|
|
28
28
|
// \u escapes: a literal bidi character in this source would reorder how THIS file reads.
|
|
29
29
|
// eslint-disable-next-line no-control-regex -- the whole point is to match control chars.
|
|
30
|
-
const TERMINAL_UNSAFE_RE = /[\x00-\x1f\x7f-\x9f\u061c\
|
|
31
|
-
|
|
30
|
+
const TERMINAL_UNSAFE_RE = /[\x00-\x1f\x7f-\x9f\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff\u{e0000}-\u{e007f}]/gu;
|
|
31
|
+
// A line break or tab is a space, so two words do not join; the characters above draw nothing yet change what a reader sees
|
|
32
|
+
// (zero-width, soft hyphen, BOM, the Unicode tag block); a lone surrogate is not a character a terminal or JSON can carry.
|
|
33
|
+
// Mirrors server/src/services/architect-consult.js scrubForTerminal: the two packages cannot share code, and a test pins them equal.
|
|
32
34
|
function scrub(text) {
|
|
33
|
-
return String(text == null ? '' : text)
|
|
35
|
+
return String(text == null ? '' : text)
|
|
36
|
+
.replace(/[\t\n\v\f\r\u0085\u2028\u2029]+/g, ' ')
|
|
37
|
+
.replace(TERMINAL_UNSAFE_RE, '')
|
|
38
|
+
.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\ufffd');
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
/**
|
|
@@ -91,4 +96,4 @@ function gateQuestionLines(status, stepKey) {
|
|
|
91
96
|
];
|
|
92
97
|
}
|
|
93
98
|
|
|
94
|
-
module.exports = { normalizeQuestion, questionLines, gateQuestionLines, countPhrase };
|
|
99
|
+
module.exports = { normalizeQuestion, questionLines, gateQuestionLines, countPhrase, scrub };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* review-diff.js — HC-GF-390: the base diff a reviewer needs beside a whole emitted file.
|
|
4
|
+
*
|
|
5
|
+
* In editor mode step_4's artifact carries every changed file whole (tree-artifact.js), while the
|
|
6
|
+
* reviewers (step_5, 5d, 5e) see the ORIGINAL only through the bundle, which windows a file longer
|
|
7
|
+
* than BUNDLE_MAX_PER_FILE_CHARS. A change outside those windows was compared with nothing. This
|
|
8
|
+
* computes, for step_4 only, `git diff HEAD` of each file the render emitted whole whose HEAD version
|
|
9
|
+
* is over that cap. It is sent beside the artifact as `reviewDiff` and stored apart from it: the
|
|
10
|
+
* artifact itself is never touched.
|
|
11
|
+
*
|
|
12
|
+
* It never fails a submit. Every git call has a timeout; anything it cannot do becomes a one-line
|
|
13
|
+
* note, and a note means "unverified", exactly as an absent diff did before.
|
|
14
|
+
*
|
|
15
|
+
* Exit modes of what it consumes (HC-GF-117):
|
|
16
|
+
* rendered.files HANDLED: the only candidates. A created, deleted, renamed, binary-at-render
|
|
17
|
+
* or over-1 MB file is never in it, so it is never diffed.
|
|
18
|
+
* git rev-parse fails HANDLED: no HEAD → null, nothing sent.
|
|
19
|
+
* git diff --name-status HANDLED: a throw → one note, nothing diffed.
|
|
20
|
+
* per-file git calls HANDLED: a throw (timeout, ENOBUFS, spawn, non-zero) → a note, next file.
|
|
21
|
+
* "Binary files … differ" HANDLED: a note (a text file .gitattributes marks binary).
|
|
22
|
+
* empty diff HANDLED: a note (the file no longer differs from HEAD).
|
|
23
|
+
* post-image ≠ rendered HANDLED: a note. The diff must describe the bytes the reviewer was sent.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const crypto = require('node:crypto');
|
|
27
|
+
const { execFileSync } = require('node:child_process');
|
|
28
|
+
const { gitEnv } = require('./git-env');
|
|
29
|
+
const { BUNDLE_MAX_PER_FILE_CHARS } = require('./bundle-limits');
|
|
30
|
+
const { isSecretPath } = require('./bundle-context');
|
|
31
|
+
const { FILE_BLOCK_RE } = require('./block-grammar');
|
|
32
|
+
|
|
33
|
+
const REVIEW_DIFF_MAX_CHARS = 60_000; // diff text, summed over the files included
|
|
34
|
+
const PER_CALL_MS = 15_000;
|
|
35
|
+
const DEADLINE_MS = 60_000; // every per-file call gets min(PER_CALL_MS, what is left)
|
|
36
|
+
const MAX_NOTES = 100;
|
|
37
|
+
const MAX_NOTE_CHARS = 300;
|
|
38
|
+
// Kept equal to server/src/services/review-diff.js REVIEW_DIFF_MAX_BYTES (pinned by the HC-GF-390 tests).
|
|
39
|
+
const REVIEW_DIFF_MAX_BYTES = 256 * 1024;
|
|
40
|
+
|
|
41
|
+
const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
42
|
+
|
|
43
|
+
/** The bundle windows a file whose text is longer than the cap (bundle-paths.js `text.length <= limit` is whole). */
|
|
44
|
+
const overCap = (length) => length > BUNDLE_MAX_PER_FILE_CHARS;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The pinned diff. Every option a user's git config could change is given on the command line, as a
|
|
48
|
+
* flag the `diff` subcommand takes (not a leading `-c`, so a caller can still recognise the call).
|
|
49
|
+
*/
|
|
50
|
+
function diffArgs(headSha, rel) {
|
|
51
|
+
return ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '--full-index',
|
|
52
|
+
'--diff-algorithm=myers', '--no-relative', '--inter-hunk-context=0', '-U3',
|
|
53
|
+
'--src-prefix=a/', '--dst-prefix=b/', headSha, '--', rel];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function defaultRunGit(repoRoot) {
|
|
57
|
+
return (args, timeoutMs) => execFileSync('git', args, {
|
|
58
|
+
cwd: repoRoot, env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
59
|
+
timeout: Math.max(1, Math.floor(timeoutMs)), stdio: ['ignore', 'pipe', 'pipe'],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The contents of the first `file:` block for `rel` in a rendered artifact, as the server's
|
|
65
|
+
* carry-forward `blocksOf` reads it (same grammar, same scope, same one-newline strip), or null.
|
|
66
|
+
*/
|
|
67
|
+
function blockContents(text, rel) {
|
|
68
|
+
const t = String(text || '');
|
|
69
|
+
const sec = t.search(/^##\s+Changed Files/im);
|
|
70
|
+
const scope = t.slice(sec === -1 ? 0 : sec);
|
|
71
|
+
for (const m of scope.matchAll(FILE_BLOCK_RE)) {
|
|
72
|
+
if (m[2].trim() === rel) return m[3].replace(/\r?\n$/, '');
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** git's object id of a blob holding `str` as UTF-8, computed in memory (never `hash-object -w`). */
|
|
78
|
+
function gitBlobId(str, algo = 'sha1') {
|
|
79
|
+
const bytes = Buffer.from(String(str), 'utf8');
|
|
80
|
+
return crypto.createHash(algo).update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One line, no control characters, at most `max` chars. */
|
|
84
|
+
function oneLine(s, max) {
|
|
85
|
+
const flat = String(s == null ? '' : s).replace(/[\x00-\x1f\x7f]+/g, ' ').trim();
|
|
86
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {object} opts
|
|
91
|
+
* @param {string} opts.repoRoot
|
|
92
|
+
* @param {{text: string, files: string[]}} opts.rendered renderFromTree()'s result
|
|
93
|
+
* @param {(args: string[], timeoutMs: number) => string} [opts.runGit]
|
|
94
|
+
* @param {() => number} [opts.now]
|
|
95
|
+
* @param {(line: string) => void} [opts.warn] why nothing is sent when that is not "no candidate"
|
|
96
|
+
* @returns {null | { headSha: string, diff: string, notes: string[], stats: object }}
|
|
97
|
+
* null when there is nothing to send: no HEAD, no candidate, or a payload over its byte cap.
|
|
98
|
+
*/
|
|
99
|
+
function computeReviewDiff({ repoRoot, rendered, runGit, now = Date.now, warn = () => {} } = {}) {
|
|
100
|
+
try {
|
|
101
|
+
const git = runGit || defaultRunGit(repoRoot);
|
|
102
|
+
const emitted = new Set(rendered && Array.isArray(rendered.files) ? rendered.files : []);
|
|
103
|
+
if (!emitted.size) return null;
|
|
104
|
+
|
|
105
|
+
const deadline = now() + DEADLINE_MS;
|
|
106
|
+
let headSha;
|
|
107
|
+
try { headSha = String(git(['rev-parse', '--verify', 'HEAD^{commit}'], PER_CALL_MS)).trim(); } catch { return null; }
|
|
108
|
+
if (!SHA_RE.test(headSha)) return null;
|
|
109
|
+
const algo = headSha.length === 64 ? 'sha256' : 'sha1';
|
|
110
|
+
|
|
111
|
+
const notes = [];
|
|
112
|
+
const sections = [];
|
|
113
|
+
let used = 0;
|
|
114
|
+
const note = (rel, reason) => notes.push(oneLine(`${JSON.stringify(rel)}: ${reason}`, MAX_NOTE_CHARS));
|
|
115
|
+
|
|
116
|
+
let candidates = [];
|
|
117
|
+
try {
|
|
118
|
+
const tokens = String(git(['diff', '--name-status', '-z', '--no-renames', headSha], PER_CALL_MS)).split('\0');
|
|
119
|
+
for (let i = 0; i + 1 < tokens.length; i += 2) {
|
|
120
|
+
const status = tokens[i]; const rel = tokens[i + 1];
|
|
121
|
+
if ((status === 'M' || status === 'T') && emitted.has(rel)) candidates.push(rel);
|
|
122
|
+
}
|
|
123
|
+
candidates = [...new Set(candidates)].sort();
|
|
124
|
+
} catch (e) {
|
|
125
|
+
notes.push(oneLine(`(all files): git failed listing changes against HEAD: ${e && e.message}`, MAX_NOTE_CHARS));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
129
|
+
const rel = candidates[i];
|
|
130
|
+
const left = () => Math.min(PER_CALL_MS, deadline - now());
|
|
131
|
+
let t = left();
|
|
132
|
+
if (t <= 0) { notes.push(`deadline reached; ${candidates.length - i} file(s) not diffed`); break; }
|
|
133
|
+
try {
|
|
134
|
+
// The size first: it reads no content, and a file at or under the cap was bundled whole.
|
|
135
|
+
// UTF-8 bytes >= UTF-16 length, so a size at or under the cap cannot be a length over it.
|
|
136
|
+
const size = Number(String(git(['cat-file', '-s', `${headSha}:${rel}`], t)).trim());
|
|
137
|
+
if (!Number.isFinite(size) || !overCap(size)) continue;
|
|
138
|
+
if (isSecretPath(rel)) { note(rel, 'secret path; not diffed'); continue; }
|
|
139
|
+
t = left();
|
|
140
|
+
if (t <= 0) { notes.push(`deadline reached; ${candidates.length - i} file(s) not diffed`); break; }
|
|
141
|
+
const head = String(git(['cat-file', 'blob', `${headSha}:${rel}`], t));
|
|
142
|
+
if (!overCap(head.length)) continue;
|
|
143
|
+
t = left();
|
|
144
|
+
if (t <= 0) { notes.push(`deadline reached; ${candidates.length - i} file(s) not diffed`); break; }
|
|
145
|
+
const out = String(git(diffArgs(headSha, rel), t));
|
|
146
|
+
if (!out.trim()) { note(rel, 'no longer differs from HEAD; not diffed'); continue; }
|
|
147
|
+
if (/^Binary files .* differ$/m.test(out) || /^GIT binary patch$/m.test(out)) { note(rel, 'binary to git; not diffed'); continue; }
|
|
148
|
+
if ((out.match(/^diff --git /gm) || []).length !== 1) { note(rel, 'unexpected diff shape; not included'); continue; }
|
|
149
|
+
const post = /^index [0-9a-f]+\.\.([0-9a-f]+)(?: [0-7]+)?$/m.exec(out);
|
|
150
|
+
const contents = blockContents(rendered.text, rel);
|
|
151
|
+
if (!post || contents === null || post[1] !== gitBlobId(`${contents}\n`, algo)) {
|
|
152
|
+
note(rel, 'post-image differs from the rendered bytes (an eol or clean filter, no final newline, '
|
|
153
|
+
+ 'non-UTF-8 content, or a change after render); not included');
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const section = out.endsWith('\n') ? out : `${out}\n`;
|
|
157
|
+
if (used + section.length > REVIEW_DIFF_MAX_CHARS) {
|
|
158
|
+
note(rel, `over the ${REVIEW_DIFF_MAX_CHARS.toLocaleString('en-US')}-char review-diff budget; not included`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
sections.push(section);
|
|
162
|
+
used += section.length;
|
|
163
|
+
} catch (e) {
|
|
164
|
+
note(rel, `git failed: ${oneLine(String((e && e.message) || e).split('\n')[0], 120)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const kept = notes.length > MAX_NOTES
|
|
169
|
+
? [...notes.slice(0, MAX_NOTES - 1), `${notes.length - (MAX_NOTES - 1)} more note(s) omitted`]
|
|
170
|
+
: notes;
|
|
171
|
+
if (!sections.length && !kept.length) return null;
|
|
172
|
+
const payload = { headSha, diff: sections.join(''), notes: kept };
|
|
173
|
+
const bytes = Buffer.byteLength(JSON.stringify(payload), 'utf8');
|
|
174
|
+
if (bytes > REVIEW_DIFF_MAX_BYTES) { warn(`review diff not sent: ${bytes} bytes is over the ${REVIEW_DIFF_MAX_BYTES}-byte limit`); return null; }
|
|
175
|
+
return { ...payload, stats: { candidates: candidates.length, files: sections.length, chars: used } };
|
|
176
|
+
} catch (e) {
|
|
177
|
+
warn(`review diff not sent: ${oneLine((e && e.message) || e, 200)}`);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Exactly what `hone submit-step` posts as `reviewDiff`: the stats stay CLI-side. One mapper, so a test can post what the CLI posts. */
|
|
183
|
+
function toWire(r) { return { headSha: r.headSha, diff: r.diff, notes: r.notes }; }
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
computeReviewDiff, blockContents, gitBlobId, toWire,
|
|
187
|
+
_internal: { overCap, diffArgs, REVIEW_DIFF_MAX_CHARS, PER_CALL_MS, DEADLINE_MS, MAX_NOTES, MAX_NOTE_CHARS, REVIEW_DIFF_MAX_BYTES },
|
|
188
|
+
};
|
package/lib/run-context.js
CHANGED
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
const { composeStoryDescription } = require('./story-compose');
|
|
28
|
-
const { extractStoryIdFromBranch } = require('./pipeline-status');
|
|
29
28
|
const {
|
|
30
29
|
extractPathRefs, extractBareRefs, resolveBareName, alreadyCovered, planFileExcerpt,
|
|
31
30
|
} = require('./bundle-paths');
|
|
@@ -37,7 +36,6 @@ const { classifyStory } = require('./story-classifier');
|
|
|
37
36
|
const {
|
|
38
37
|
extractType, extractSecurityImplications, extractCrossRepoDependency,
|
|
39
38
|
} = require('./story-classifier-extract');
|
|
40
|
-
const { readArchitectConfigFromText } = require('./architect-config');
|
|
41
39
|
const path = require('path');
|
|
42
40
|
|
|
43
41
|
/**
|
|
@@ -49,13 +47,16 @@ const path = require('path');
|
|
|
49
47
|
*/
|
|
50
48
|
const SERVER_OWNED_KEYS = Object.freeze([
|
|
51
49
|
'batchTokenBudget', 'batchFailureThreshold', 'dependsOnRunIds', 'monthlyTokenBudget', 'overnight',
|
|
50
|
+
// HC-GF-030: whether a story was consulted is what the server's step_0b recorded, never what a
|
|
51
|
+
// client says. A client that could send these true would skip the consult.
|
|
52
|
+
'architect_consulted', 'checklist_b_completed', 'architect_consult',
|
|
52
53
|
]);
|
|
53
54
|
|
|
54
55
|
// HC-019n-followup-13d: the bundle caps. OptionsFlow #96 hit 49,783 of the old 50,000 with six
|
|
55
56
|
// files, so the next slightly larger story silently dropped one. 30 / 150K / 25K.
|
|
56
57
|
const BUNDLE_MAX_FILES = 30;
|
|
57
58
|
const BUNDLE_MAX_TOTAL_CHARS = 150_000;
|
|
58
|
-
const BUNDLE_MAX_PER_FILE_CHARS =
|
|
59
|
+
const { BUNDLE_MAX_PER_FILE_CHARS } = require('./bundle-limits'); // HC-GF-390: shared with review-diff.js
|
|
59
60
|
|
|
60
61
|
const info = (text) => ({ level: 'info', text });
|
|
61
62
|
const warn = (text) => ({ level: 'warn', text });
|
|
@@ -158,7 +159,6 @@ async function issueStorySource(io, issueNumber, issue) {
|
|
|
158
159
|
* @param {() => string} io.e2eSpecMode
|
|
159
160
|
* @param {() => string} io.verificationGate
|
|
160
161
|
* @param {() => string[]} io.verificationGateSources
|
|
161
|
-
* @param {() => string} io.readPlanText EXECUTION_PLAN.yml text, '' when absent
|
|
162
162
|
* @param {{ e2eSpecs?: boolean, skipE2eSpecs?: boolean, treeByteCap?: number }} [opts] run-story's per-run
|
|
163
163
|
* flags; `treeByteCap` is queue-stories' byte cap on the tree's path lists (none for run-story)
|
|
164
164
|
*/
|
|
@@ -295,13 +295,7 @@ function resolveRepoContext(io, opts = {}) {
|
|
|
295
295
|
config.verification_gate_sources = ['verify-patch'];
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
|
|
299
|
-
let planText = '';
|
|
300
|
-
try { planText = io.readPlanText() || ''; } catch (e) {
|
|
301
|
-
notes.push(warn(` ⚠ EXECUTION_PLAN.yml read failed (non-fatal, architect flags default to {false, false}): ${firstLine(e)}`));
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return { config, notes, repoFiles, defaultIncludePaths, planText, execution };
|
|
298
|
+
return { config, notes, repoFiles, defaultIncludePaths, execution };
|
|
305
299
|
}
|
|
306
300
|
|
|
307
301
|
/**
|
|
@@ -324,8 +318,8 @@ function resolveRepoContext(io, opts = {}) {
|
|
|
324
318
|
* @param {object} repoCtx resolveRepoContext's result
|
|
325
319
|
* @param {string} storyId the id as the operator typed it — sent as `storyId`
|
|
326
320
|
* @param {{ explicitText?: string|null, explicitLabel?: string, explicitSource?: {kind: 'file'|'description', label?: string},
|
|
327
|
-
* includePaths?: string[], axes?: string }} [opts]
|
|
328
|
-
* @returns {Promise<{ storyId: string,
|
|
321
|
+
* includePaths?: string[], axes?: string, consultArchitect?: boolean }} [opts]
|
|
322
|
+
* @returns {Promise<{ storyId: string, rawText: string,
|
|
329
323
|
* textSource: 'explicit'|'issue'|'roadmap'|null, textFailure: {kind:string, detail?:string}|null,
|
|
330
324
|
* config: object, bundle: {files:number, chars:number}, notes: {pre: object[], post: object[]} }>}
|
|
331
325
|
*/
|
|
@@ -336,7 +330,6 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
336
330
|
let rawText = '';
|
|
337
331
|
let textSource = null;
|
|
338
332
|
let textFailure = null;
|
|
339
|
-
let resolvedStoryId = storyId; // canonical id for the architect-config lookup
|
|
340
333
|
|
|
341
334
|
// ── Story text: explicit → GitHub issue → Hone roadmap ────────────────────────────────────
|
|
342
335
|
// HC-GF-010: an EXPLICIT story source short-circuits both lookups, so the operator can never
|
|
@@ -369,17 +362,6 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
369
362
|
const prov = await issueStorySource(io, storyId, issue);
|
|
370
363
|
config.story_source = prov.source;
|
|
371
364
|
pre.push(...prov.notes);
|
|
372
|
-
// HC-019b-followup-1 F1: the canonical story id from the issue title, or the architect
|
|
373
|
-
// lookup keys on '104' and silently misses every EXECUTION_PLAN.yml entry.
|
|
374
|
-
try {
|
|
375
|
-
const titleId = extractStoryIdFromBranch(issue.title);
|
|
376
|
-
if (titleId) {
|
|
377
|
-
resolvedStoryId = titleId;
|
|
378
|
-
pre.push(info(` → resolved issue #${storyId} → canonical story id '${titleId}' for architect-config lookup`));
|
|
379
|
-
} else {
|
|
380
|
-
pre.push(warn(` ⚠ could not extract canonical story id from issue title '${issue.title}' — architect-config lookup will use '${storyId}' (likely silent miss)`));
|
|
381
|
-
}
|
|
382
|
-
} catch { /* extractor throws → fall back to the numeric id */ }
|
|
383
365
|
} catch (e) {
|
|
384
366
|
textFailure = { kind: 'issue-error', detail: firstLine(e) };
|
|
385
367
|
// HC-GF-270: stated rather than left absent, so the later pause is readable. Classified, not
|
|
@@ -392,7 +374,6 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
392
374
|
};
|
|
393
375
|
pre.push(warn(` ⚠ could not fetch GitHub issue context: ${firstLine(e)}`));
|
|
394
376
|
pre.push(warn(' → proceeding without story_description; step_0 may produce placeholder output'));
|
|
395
|
-
pre.push(warn(` ⚠ architect-config lookup will use '${storyId}' (likely silent miss)`));
|
|
396
377
|
}
|
|
397
378
|
} else {
|
|
398
379
|
// HC-019n-followup-18: a roadmap-sourced story. Only the SERVER knows what it says; only the
|
|
@@ -540,11 +521,14 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
540
521
|
path: candidate,
|
|
541
522
|
});
|
|
542
523
|
content = excerpt.content;
|
|
543
|
-
if (excerpt.notice) pre.push(warn(excerpt.notice));
|
|
544
|
-
for (const w of anchorWarnings(anchors, excerpt.unhonoured)) pre.push(warn(w));
|
|
545
524
|
if (totalChars + content.length > BUNDLE_MAX_TOTAL_CHARS) { budgetSpent = true; break; }
|
|
546
525
|
totalChars += content.length;
|
|
547
526
|
codebaseFiles.push({ path: candidate, content });
|
|
527
|
+
// Only a file that made the bundle gets the "bundled lines A-B" notice: announced before the
|
|
528
|
+
// budget check, a dropped file printed it AND the NOT-bundled line below, and the operator
|
|
529
|
+
// read the first one (seen 2026-10-16 on server/scripts/seed-agent-prompts.js).
|
|
530
|
+
if (excerpt.notice) pre.push(warn(excerpt.notice));
|
|
531
|
+
for (const w of anchorWarnings(anchors, excerpt.unhonoured)) pre.push(warn(w));
|
|
548
532
|
}
|
|
549
533
|
// HC-GF-288: an operator-named entry whose file never reached the bundle (the budget ran
|
|
550
534
|
// out first, or it names a directory) vanished as silently as the anchor did. A refused
|
|
@@ -597,6 +581,11 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
597
581
|
}
|
|
598
582
|
}
|
|
599
583
|
|
|
584
|
+
// ── Architect consult on request (HC-GF-397) ─────────────────────────────────────────────
|
|
585
|
+
// --consult-architect: the server runs step_0b even on a `not_engaged` grooming. Sent only as `true`; the server reads
|
|
586
|
+
// nothing else as a request, and no value of it can skip a consult.
|
|
587
|
+
if (opts.consultArchitect === true) config.consult_architect = true;
|
|
588
|
+
|
|
600
589
|
// ── Classifier axes (HC-COMM-010) ─────────────────────────────────────────────────────────
|
|
601
590
|
// Explicit --axes wins. Otherwise the DETERMINISTIC axes from the extractors; judgment axes
|
|
602
591
|
// come from the groomer at step_0 and are harvested server-side. Fail-open.
|
|
@@ -621,19 +610,7 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
621
610
|
}
|
|
622
611
|
}
|
|
623
612
|
|
|
624
|
-
|
|
625
|
-
// workflow-dag.js's validateStepPreConditions BLOCKS step_1 when architect_consulted is true
|
|
626
|
-
// and checklist_b_completed is not; without these flags every architect-engaged story
|
|
627
|
-
// deadlocks. Keyed on the RESOLVED id (HC-019b-followup-1 F1).
|
|
628
|
-
const arch = readArchitectConfigFromText(repoCtx.planText || '', resolvedStoryId);
|
|
629
|
-
config.architect_consulted = arch.architect_consulted;
|
|
630
|
-
config.checklist_b_completed = arch.checklist_b_completed;
|
|
631
|
-
if (arch.diagnostic) post.push(warn(` ⚠ ${arch.diagnostic}`));
|
|
632
|
-
if (arch.architect_consulted) {
|
|
633
|
-
post.push(info(` → architect_consulted: true, checklist_b_completed: ${arch.checklist_b_completed}`));
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
return { storyId, resolvedStoryId, rawText, textSource, textFailure, config, bundle, notes: { pre, post } };
|
|
613
|
+
return { storyId, rawText, textSource, textFailure, config, bundle, notes: { pre, post } };
|
|
637
614
|
}
|
|
638
615
|
|
|
639
616
|
/**
|
package/lib/run-report.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
// HC-GF-234: the one wording for a step's unanswered question, shared with `--status`, `show
|
|
20
20
|
// step` and the human-gate prompt. A pure leaf module, so this file stays pure.
|
|
21
|
-
const { normalizeQuestion, questionLines, countPhrase } = require('./operator-question');
|
|
21
|
+
const { normalizeQuestion, questionLines, countPhrase, scrub } = require('./operator-question');
|
|
22
22
|
|
|
23
23
|
/** Gate verdicts the pipeline already speaks. Do not invent synonyms. */
|
|
24
24
|
const ACTIONABLE_GATE_VERDICTS = ['fail', 'unavailable'];
|
|
@@ -69,6 +69,25 @@ function durationOf(startedAt, endedAt) {
|
|
|
69
69
|
const BLOCKING_REASON_CODES = Object.freeze(['MAX_RETRIES_EXCEEDED', 'MAX_TOTAL_STEPS_EXCEEDED']);
|
|
70
70
|
const BLOCKING_REASON_RE = new RegExp(`^(${BLOCKING_REASON_CODES.join('|')})[^\\n]*`);
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* HC-GF-030: the pause the architect-consult guard takes. NOT in BLOCKING_REASON_CODES, which is pinned equal to
|
|
74
|
+
* what orchestrator-guardrails.js emits (HC-GF-046b); this one is emitted by workflow-dag.js's
|
|
75
|
+
* validateStepPreConditions. Approving is refused for it too — by the route and by approveGate — so it is a decision
|
|
76
|
+
* and not a gate. The server package and this one cannot share a constant, so
|
|
77
|
+
* tests/regression/HC-GF-030-run-report.test.js pins this string equal to architect-consult.js CONSULT_REQUIRED_CODE.
|
|
78
|
+
*/
|
|
79
|
+
const CONSULT_REQUIRED_CODE = 'ARCHITECT_CONSULT_REQUIRED';
|
|
80
|
+
const CONSULT_REQUIRED_RE = new RegExp(`^${CONSULT_REQUIRED_CODE}:[^\\n]*`);
|
|
81
|
+
/** The start of the message a batch run pauses with when its consult failed the mechanical check (orchestrator.js). */
|
|
82
|
+
const CONSULT_NEEDS_PERSON_RE = /^architect consult needs a person to read it[^\n]*/;
|
|
83
|
+
/**
|
|
84
|
+
* The pause a consult whose OWN verdict is `checklist_b_completed: false` takes (workflow-dag.js FAILURE_ROUTING_TABLE.step_0b.step_0b_abort).
|
|
85
|
+
* Approving it is refused (an `_abort` needs an acknowledgement, and even then nothing records a false verdict), so offering
|
|
86
|
+
* `--approve` here is HC-GF-046 again: the line invites the one action the server has already decided to refuse.
|
|
87
|
+
* tests/regression/HC-GF-030-run-report.test.js pins this prefix to the server's message.
|
|
88
|
+
*/
|
|
89
|
+
const CONSULT_ABORT_RE = /^step_0b: the architect consult did not complete Checklist B[^\n]*/;
|
|
90
|
+
|
|
72
91
|
/**
|
|
73
92
|
* HC-GF-046(b), corrected in review: a step that used up its format retries.
|
|
74
93
|
*
|
|
@@ -107,7 +126,28 @@ function buildReport(run, extra = {}) {
|
|
|
107
126
|
// untrustworthy in the other direction.
|
|
108
127
|
const blocked = String(run.errorMessage || '').match(BLOCKING_REASON_RE);
|
|
109
128
|
const exhausted = String(run.errorMessage || '').match(EXHAUSTED_RE);
|
|
110
|
-
|
|
129
|
+
const consultRequired = String(run.errorMessage || '').match(CONSULT_REQUIRED_RE);
|
|
130
|
+
const consultNeedsPerson = String(run.errorMessage || '').match(CONSULT_NEEDS_PERSON_RE);
|
|
131
|
+
const consultAbort = String(run.errorMessage || '').match(CONSULT_ABORT_RE);
|
|
132
|
+
if (consultRequired) {
|
|
133
|
+
needsYou.push({
|
|
134
|
+
what: `run CANNOT proceed at ${run.currentStep} — ${scrub(consultRequired[0].slice(0, 400))}. There is no waiver and `
|
|
135
|
+
+ 'approving is refused, so this is a decision, not a gate',
|
|
136
|
+
how: `hone run-story ${run.runId} --kill (then re-run the story: step_0 is cached, so it costs one consult)`,
|
|
137
|
+
});
|
|
138
|
+
} else if (consultNeedsPerson) {
|
|
139
|
+
needsYou.push({
|
|
140
|
+
what: `the architect consult is waiting for a person to read it — ${scrub(consultNeedsPerson[0].slice(0, 400))}`,
|
|
141
|
+
how: `hone show step ${run.runId} step_0b (then hone run-story ${run.runId} --approve step_0b once you have read it)`,
|
|
142
|
+
});
|
|
143
|
+
} else if (consultAbort) {
|
|
144
|
+
needsYou.push({
|
|
145
|
+
what: `the architect consult did not pass at ${run.currentStep} — ${scrub(consultAbort[0].slice(0, 400))}. Approving is refused, `
|
|
146
|
+
+ 'so this is a decision, not a gate',
|
|
147
|
+
how: `hone show step ${run.runId} step_0b (read what it flagged, change the story to answer it, `
|
|
148
|
+
+ `then hone run-story ${run.runId} --kill and run the story again)`,
|
|
149
|
+
});
|
|
150
|
+
} else if (run.approvalRefusal) {
|
|
111
151
|
// HC-GF-074: rendered from the server's own decision, never re-derived from verdict records here.
|
|
112
152
|
needsYou.push({
|
|
113
153
|
what: `run CANNOT be approved at ${run.currentStep} as it stands — ${String(run.approvalRefusal.message).slice(0, 300)}`,
|
|
@@ -430,6 +470,26 @@ function buildReport(run, extra = {}) {
|
|
|
430
470
|
worthKnowing.push(`${s.stepKey} ran ${s.attempts} times`);
|
|
431
471
|
}
|
|
432
472
|
}
|
|
473
|
+
// HC-GF-030: a run that finished with an architect classified as needed and no consult recorded. The consult
|
|
474
|
+
// guard makes this unreachable for a run the server drove; it is said here because "no run records
|
|
475
|
+
// `architect_engaged: true` with no consult" is the acceptance, and a report is where that gets checked.
|
|
476
|
+
const arch = run.architect && typeof run.architect === 'object' ? run.architect : null;
|
|
477
|
+
// HC-GF-397-followup-1: `hone run-story --consult-architect` asks for a consult on a `not_engaged` grooming. Read the way
|
|
478
|
+
// the server reads it — the boolean `true` only — so a requested consult that is not approved is never "not needed".
|
|
479
|
+
const consultRequested = cfg.consult_architect === true;
|
|
480
|
+
if (arch && (arch.verdict !== 'not_engaged' || consultRequested) && !arch.consulted && run.status === 'completed') {
|
|
481
|
+
const why = arch.verdict === 'engaged' ? 'the architect classified as needed'
|
|
482
|
+
: arch.verdict === 'not_engaged' ? 'an architect consult REQUESTED with --consult-architect'
|
|
483
|
+
: 'the architect verdict unreadable';
|
|
484
|
+
needsYou.push({
|
|
485
|
+
what: `run COMPLETED with ${why} and NO approved consult`,
|
|
486
|
+
how: `hone show step ${run.runId} step_0b`,
|
|
487
|
+
// HC-026c: a new signal must not turn every run that finished BEFORE the consult step existed into a failing exit
|
|
488
|
+
// code (about 27 of 28 completed runs on the local server were engaged). Shown in full, counted in `needsYou`, and
|
|
489
|
+
// not gating: the consult guard is what stops a new run, and this line is for reading a run that predates it.
|
|
490
|
+
advisory: true,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
433
493
|
// The two "blind run" tells, both from HC-GF-006's preflight reasoning.
|
|
434
494
|
const descChars = typeof cfg.story_description === 'string' ? cfg.story_description.length : 0;
|
|
435
495
|
if (descChars === 0) {
|
|
@@ -459,9 +519,21 @@ function buildReport(run, extra = {}) {
|
|
|
459
519
|
duration: durationOf(run.startedAt, run.completedAt),
|
|
460
520
|
cost: extra.cost || null,
|
|
461
521
|
architectConsulted: cfg.architect_consulted === true,
|
|
522
|
+
// HC-GF-030: derived server-side from the step rows, never from config. null on a server that predates it.
|
|
523
|
+
architect: arch ? {
|
|
524
|
+
verdict: arch.verdict, consulted: arch.consulted === true, requested: consultRequested,
|
|
525
|
+
// A number or nothing: this is printed, and it came off the network.
|
|
526
|
+
consultAttempt: Number.isFinite(Number(arch.consultAttempt)) && Number(arch.consultAttempt) > 0 ? Math.trunc(Number(arch.consultAttempt)) : null,
|
|
527
|
+
blockers: Number(arch.blockers) || 0,
|
|
528
|
+
// Agent-written text going to a terminal: the server scrubs it, and this CLI may be talking to an older or different
|
|
529
|
+
// server, so it scrubs again where it is stored for printing (the rule cli/lib/operator-question.js already keeps).
|
|
530
|
+
findings: (Array.isArray(arch.findings) ? arch.findings : []).map(f => scrub(String(f).slice(0, 400))),
|
|
531
|
+
callSiteGaps: (Array.isArray(arch.callSiteGaps) ? arch.callSiteGaps : []).map(g => scrub(String(g).slice(0, 400))),
|
|
532
|
+
} : null,
|
|
462
533
|
storyDescriptionChars: descChars,
|
|
463
534
|
bundledFiles: bundled,
|
|
464
|
-
|
|
535
|
+
// The groomer's own words (the server harvests them from step_0's output), printed on a terminal: scrubbed, and strings only.
|
|
536
|
+
classifierAxes: Array.isArray(cfg.classifier_axes) ? cfg.classifier_axes.map(a => scrub(String(a).slice(0, 80))) : [],
|
|
465
537
|
needsYou,
|
|
466
538
|
worthKnowing,
|
|
467
539
|
steps,
|
|
@@ -530,6 +602,21 @@ function renderReport(r) {
|
|
|
530
602
|
L.push('');
|
|
531
603
|
}
|
|
532
604
|
|
|
605
|
+
if (r.architect) {
|
|
606
|
+
const a = r.architect;
|
|
607
|
+
L.push('ARCHITECT');
|
|
608
|
+
L.push(` ${a.verdict === 'engaged' ? 'engaged' : a.verdict === 'not_engaged' ? 'not engaged (step_0 said so, readably)' : 'verdict unreadable (the consult runs)'}`
|
|
609
|
+
+ `${r.classifierAxes.length ? ` · axes: ${r.classifierAxes.join(', ')}` : ''}`
|
|
610
|
+
// HC-GF-397-followup-1: the request is named only where it changed the outcome — an engaged or unreadable verdict
|
|
611
|
+
// runs the consult anyway, so its line stays as it was.
|
|
612
|
+
+ `${a.requested && a.verdict === 'not_engaged' ? ' · consult requested (--consult-architect)' : ''}`
|
|
613
|
+
+ ` · consult ${a.consulted ? `approved${a.consultAttempt ? ` (step_0b attempt ${a.consultAttempt})` : ''}` : a.verdict === 'not_engaged' && !a.requested ? 'not needed' : 'NOT approved'}`);
|
|
614
|
+
if (a.blockers > 0) L.push(` ${a.blockers} finding(s) marked BLOCKER`);
|
|
615
|
+
for (const f of a.findings) L.push(` · ${f}`);
|
|
616
|
+
for (const g of a.callSiteGaps) L.push(` ⚠ call sites the consult could not see: ${g}`);
|
|
617
|
+
L.push('');
|
|
618
|
+
}
|
|
619
|
+
|
|
533
620
|
L.push('STEPS');
|
|
534
621
|
for (const s of r.steps) {
|
|
535
622
|
const mark = s.status === 'completed' ? '✓' : s.status === 'skipped' ? '○' : '·';
|
|
@@ -540,8 +627,11 @@ function renderReport(r) {
|
|
|
540
627
|
L.push('');
|
|
541
628
|
|
|
542
629
|
L.push('RUN FACTS');
|
|
630
|
+
// HC-GF-030: the consult state comes from the server's step rows when it reports them (`architect`); the config flag is
|
|
631
|
+
// only a mirror, cleared by a new grooming, and is what an older server still reports.
|
|
632
|
+
const consultedFact = r.architect ? r.architect.consulted : r.architectConsulted;
|
|
543
633
|
L.push(` mode ${r.mode || 'unknown'} · story text ${r.storyDescriptionChars} chars · `
|
|
544
|
-
+ `${r.bundledFiles} file(s) bundled · architect_consulted ${
|
|
634
|
+
+ `${r.bundledFiles} file(s) bundled · architect_consulted ${consultedFact}`);
|
|
545
635
|
if (r.classifierAxes.length) L.push(` axes: ${r.classifierAxes.join(', ')}`);
|
|
546
636
|
if (r.verifications.length) {
|
|
547
637
|
for (const v of r.verifications) {
|
package/lib/tree-artifact.js
CHANGED
|
@@ -18,14 +18,34 @@
|
|
|
18
18
|
* malformed[] unparsable block — DELIBERATELY NOT REACHABLE: every fence we emit is longer
|
|
19
19
|
* than any fence run inside the file it wraps (see below).
|
|
20
20
|
* noChanges the honest no-op — HANDLED: a clean tree says NO CHANGES rather than nothing.
|
|
21
|
+
* HC-GF-395: it says "matches HEAD" only when git reported no
|
|
22
|
+
* change at all (`!changed.length`). When git reported changes
|
|
23
|
+
* but none could be sent (`!included.length`), it says "NO
|
|
24
|
+
* CHANGES — the working tree differs from HEAD, but no changed
|
|
25
|
+
* file could be sent as content (N skipped; see ### Not
|
|
26
|
+
* included)", N counting named skips only (symlinks are silent).
|
|
21
27
|
*
|
|
22
|
-
* Dependency-free by intent, like `cli/lib/headroom.js`: `git`, `fs` and `path
|
|
28
|
+
* Dependency-free by intent, like `cli/lib/headroom.js`: `git`, `fs` and `path`, plus the one
|
|
29
|
+
* classifier the bundler already uses — `isSecretPath` from `./bundle-context`, which itself
|
|
30
|
+
* requires only `path`.
|
|
31
|
+
*
|
|
32
|
+
* HC-GF-393: a path `isSecretPath()` classifies (`.env*`, `*.pem`, `id_rsa`-style keys, …) is
|
|
33
|
+
* never read. It is named under `### Not included`, so it is absent from the patch and the PR
|
|
34
|
+
* on purpose and visibly, not silently. A deleted one still renders as `DELETE:` (no content).
|
|
35
|
+
*
|
|
36
|
+
* HC-GF-394: a file that cannot be read is named under `### Not included` as `unreadable`. It used
|
|
37
|
+
* to throw, and the whole submission failed with it — every readable change included.
|
|
38
|
+
*
|
|
39
|
+
* HC-GF-390: for step_4 the submit also computes a review-only base diff of the over-cap files this
|
|
40
|
+
* render emitted whole (`cli/lib/review-diff.js`). It is sent as a separate `reviewDiff` field and
|
|
41
|
+
* stored apart; it is never part of this artifact, and nothing here changes because of it.
|
|
23
42
|
*/
|
|
24
43
|
|
|
25
44
|
const fs = require('node:fs');
|
|
26
45
|
const path = require('node:path');
|
|
27
46
|
const { execFileSync } = require('node:child_process');
|
|
28
47
|
const { gitEnv } = require('./git-env');
|
|
48
|
+
const { isSecretPath } = require('./bundle-context');
|
|
29
49
|
|
|
30
50
|
/** Bytes above which we do not attempt to inline a file at all. */
|
|
31
51
|
const MAX_FILE_BYTES = 1_000_000;
|
|
@@ -119,6 +139,9 @@ function renderFromTree({ repoRoot, testReport } = {}) {
|
|
|
119
139
|
const skipped = [];
|
|
120
140
|
|
|
121
141
|
for (const rel of changed) {
|
|
142
|
+
// First, before lstat or read: the bytes of a secret path never enter this process. The name
|
|
143
|
+
// is the signal, so a symlink or an oversized file with a secret name is listed as a secret.
|
|
144
|
+
if (isSecretPath(rel)) { skipped.push({ path: rel, reason: 'secret path — not sent' }); continue; }
|
|
122
145
|
const abs = path.join(repoRoot, rel);
|
|
123
146
|
let st;
|
|
124
147
|
try { st = fs.lstatSync(abs); } catch { skipped.push({ path: rel, reason: 'unreadable' }); continue; }
|
|
@@ -129,7 +152,8 @@ function renderFromTree({ repoRoot, testReport } = {}) {
|
|
|
129
152
|
if (st.isSymbolicLink() || !st.isFile()) continue;
|
|
130
153
|
if (st.size > MAX_FILE_BYTES) { skipped.push({ path: rel, reason: `larger than ${MAX_FILE_BYTES} bytes` }); continue; }
|
|
131
154
|
|
|
132
|
-
|
|
155
|
+
let buf;
|
|
156
|
+
try { buf = fs.readFileSync(abs); } catch { skipped.push({ path: rel, reason: 'unreadable' }); continue; }
|
|
133
157
|
if (looksBinary(buf)) { skipped.push({ path: rel, reason: 'binary' }); continue; }
|
|
134
158
|
|
|
135
159
|
const content = buf.toString('utf8');
|
|
@@ -141,9 +165,18 @@ function renderFromTree({ repoRoot, testReport } = {}) {
|
|
|
141
165
|
|
|
142
166
|
const lines = ['## Changed Files', ''];
|
|
143
167
|
|
|
144
|
-
if (!
|
|
168
|
+
if (!changed.length && !deleted.length) {
|
|
145
169
|
// The honest no-op, in the shape the validator and the materialiser both already accept.
|
|
146
170
|
lines.push('NO CHANGES — the working tree matches HEAD.');
|
|
171
|
+
} else if (!included.length && !deleted.length) {
|
|
172
|
+
// HC-GF-395: still a no-op, but not a clean tree. One line with the same `NO CHANGES —` prefix,
|
|
173
|
+
// which every parser keys on; the rest of the line is the reason stored in the artifact (the
|
|
174
|
+
// CLI's own no-op messages are fixed text). Only a
|
|
175
|
+
// named skip has a list to point at; a symlink or non-regular file is skipped silently.
|
|
176
|
+
const why = skipped.length
|
|
177
|
+
? `${skipped.length} skipped; see ### Not included`
|
|
178
|
+
: 'symlinks and non-regular files are not sent';
|
|
179
|
+
lines.push(`NO CHANGES — the working tree differs from HEAD, but no changed file could be sent as content (${why}).`);
|
|
147
180
|
} else {
|
|
148
181
|
for (const b of blocks) { lines.push(b, ''); }
|
|
149
182
|
for (const rel of deleted) lines.push(`DELETE: ${rel}`);
|