@dzhechkov/harness-core 0.8.10 → 0.8.20

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 (251) hide show
  1. package/.dz-manifest.json +490 -170
  2. package/LICENSE +13 -0
  3. package/README.md +420 -6
  4. package/dist/agents-policy.d.ts +15 -1
  5. package/dist/agents-policy.d.ts.map +1 -1
  6. package/dist/agents-policy.js +27 -1
  7. package/dist/agents-policy.js.map +1 -1
  8. package/dist/amendment-trace.d.ts +72 -0
  9. package/dist/amendment-trace.d.ts.map +1 -1
  10. package/dist/amendment-trace.js +385 -17
  11. package/dist/amendment-trace.js.map +1 -1
  12. package/dist/backlog-public.d.ts +153 -0
  13. package/dist/backlog-public.d.ts.map +1 -0
  14. package/dist/backlog-public.js +415 -0
  15. package/dist/backlog-public.js.map +1 -0
  16. package/dist/backlog-transitions.d.ts +48 -0
  17. package/dist/backlog-transitions.d.ts.map +1 -0
  18. package/dist/backlog-transitions.js +64 -0
  19. package/dist/backlog-transitions.js.map +1 -0
  20. package/dist/backlog.d.ts.map +1 -1
  21. package/dist/backlog.js +13 -0
  22. package/dist/backlog.js.map +1 -1
  23. package/dist/claim-check.d.ts.map +1 -1
  24. package/dist/claim-check.js +24 -2
  25. package/dist/claim-check.js.map +1 -1
  26. package/dist/claude-hooks-assets.d.ts +93 -0
  27. package/dist/claude-hooks-assets.d.ts.map +1 -0
  28. package/dist/claude-hooks-assets.js +208 -0
  29. package/dist/claude-hooks-assets.js.map +1 -0
  30. package/dist/clean-room-smoke.d.ts +83 -0
  31. package/dist/clean-room-smoke.d.ts.map +1 -0
  32. package/dist/clean-room-smoke.js +138 -0
  33. package/dist/clean-room-smoke.js.map +1 -0
  34. package/dist/cmd-usage.d.ts.map +1 -1
  35. package/dist/cmd-usage.js +36 -6
  36. package/dist/cmd-usage.js.map +1 -1
  37. package/dist/codex-hooks-assets.d.ts +25 -7
  38. package/dist/codex-hooks-assets.d.ts.map +1 -1
  39. package/dist/codex-hooks-assets.js +138 -30
  40. package/dist/codex-hooks-assets.js.map +1 -1
  41. package/dist/codex-hooks.d.ts +21 -1
  42. package/dist/codex-hooks.d.ts.map +1 -1
  43. package/dist/codex-hooks.js +21 -1
  44. package/dist/codex-hooks.js.map +1 -1
  45. package/dist/codex-invoke.d.ts +73 -0
  46. package/dist/codex-invoke.d.ts.map +1 -0
  47. package/dist/codex-invoke.js +80 -0
  48. package/dist/codex-invoke.js.map +1 -0
  49. package/dist/course-staleness.d.ts +19 -0
  50. package/dist/course-staleness.d.ts.map +1 -0
  51. package/dist/course-staleness.js +95 -0
  52. package/dist/course-staleness.js.map +1 -0
  53. package/dist/destructive-guard-hook.d.ts +40 -0
  54. package/dist/destructive-guard-hook.d.ts.map +1 -0
  55. package/dist/destructive-guard-hook.js +109 -0
  56. package/dist/destructive-guard-hook.js.map +1 -0
  57. package/dist/destructive-guard.d.ts +27 -0
  58. package/dist/destructive-guard.d.ts.map +1 -0
  59. package/dist/destructive-guard.js +2808 -0
  60. package/dist/destructive-guard.js.map +1 -0
  61. package/dist/discrimination-gate.d.ts +88 -3
  62. package/dist/discrimination-gate.d.ts.map +1 -1
  63. package/dist/discrimination-gate.js +173 -16
  64. package/dist/discrimination-gate.js.map +1 -1
  65. package/dist/event-chain.d.ts +30 -0
  66. package/dist/event-chain.d.ts.map +1 -1
  67. package/dist/event-chain.js +24 -0
  68. package/dist/event-chain.js.map +1 -1
  69. package/dist/feature-adr-routing.d.ts +95 -1
  70. package/dist/feature-adr-routing.d.ts.map +1 -1
  71. package/dist/feature-adr-routing.js +193 -27
  72. package/dist/feature-adr-routing.js.map +1 -1
  73. package/dist/guard.d.ts +98 -0
  74. package/dist/guard.d.ts.map +1 -1
  75. package/dist/guard.js +308 -0
  76. package/dist/guard.js.map +1 -1
  77. package/dist/harness-core-location.d.ts +18 -0
  78. package/dist/harness-core-location.d.ts.map +1 -0
  79. package/dist/harness-core-location.js +42 -0
  80. package/dist/harness-core-location.js.map +1 -0
  81. package/dist/index.d.ts +32 -11
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +32 -7
  84. package/dist/index.js.map +1 -1
  85. package/dist/lead-shift-gate.d.ts +95 -0
  86. package/dist/lead-shift-gate.d.ts.map +1 -0
  87. package/dist/lead-shift-gate.js +100 -0
  88. package/dist/lead-shift-gate.js.map +1 -0
  89. package/dist/ledger-backfill.d.ts +11 -1
  90. package/dist/ledger-backfill.d.ts.map +1 -1
  91. package/dist/ledger-backfill.js +19 -0
  92. package/dist/ledger-backfill.js.map +1 -1
  93. package/dist/loop-blobs.generated.d.ts +1 -1
  94. package/dist/loop-blobs.generated.d.ts.map +1 -1
  95. package/dist/loop-blobs.generated.js +12 -3
  96. package/dist/loop-blobs.generated.js.map +1 -1
  97. package/dist/loop-lint.d.ts.map +1 -1
  98. package/dist/loop-lint.js +56 -7
  99. package/dist/loop-lint.js.map +1 -1
  100. package/dist/loop-plan-graph.d.ts +1 -3
  101. package/dist/loop-plan-graph.d.ts.map +1 -1
  102. package/dist/loop-plan-graph.js +70 -1
  103. package/dist/loop-plan-graph.js.map +1 -1
  104. package/dist/loop-trace.d.ts.map +1 -1
  105. package/dist/loop-trace.js +16 -2
  106. package/dist/loop-trace.js.map +1 -1
  107. package/dist/managed-hooks.d.ts +5 -6
  108. package/dist/managed-hooks.d.ts.map +1 -1
  109. package/dist/managed-hooks.js +2 -2
  110. package/dist/managed-hooks.js.map +1 -1
  111. package/dist/model-recommender.d.ts.map +1 -1
  112. package/dist/model-recommender.js +14 -3
  113. package/dist/model-recommender.js.map +1 -1
  114. package/dist/mutation-gate.d.ts +39 -36
  115. package/dist/mutation-gate.d.ts.map +1 -1
  116. package/dist/mutation-gate.js +111 -5
  117. package/dist/mutation-gate.js.map +1 -1
  118. package/dist/operations.d.ts.map +1 -1
  119. package/dist/operations.js +90 -5
  120. package/dist/operations.js.map +1 -1
  121. package/dist/patterns.d.ts +23 -0
  122. package/dist/patterns.d.ts.map +1 -1
  123. package/dist/patterns.js +10 -0
  124. package/dist/patterns.js.map +1 -1
  125. package/dist/plugin.d.ts.map +1 -1
  126. package/dist/plugin.js +27 -5
  127. package/dist/plugin.js.map +1 -1
  128. package/dist/publish.d.ts +11 -0
  129. package/dist/publish.d.ts.map +1 -1
  130. package/dist/publish.js +16 -2
  131. package/dist/publish.js.map +1 -1
  132. package/dist/recommend.d.ts +4 -5
  133. package/dist/recommend.d.ts.map +1 -1
  134. package/dist/recommend.js +110 -45
  135. package/dist/recommend.js.map +1 -1
  136. package/dist/registry.d.ts +32 -1
  137. package/dist/registry.d.ts.map +1 -1
  138. package/dist/registry.js +168 -11
  139. package/dist/registry.js.map +1 -1
  140. package/dist/run-records.d.ts +3 -0
  141. package/dist/run-records.d.ts.map +1 -1
  142. package/dist/run-records.js +18 -0
  143. package/dist/run-records.js.map +1 -1
  144. package/dist/score.d.ts +95 -0
  145. package/dist/score.d.ts.map +1 -1
  146. package/dist/score.js +289 -6
  147. package/dist/score.js.map +1 -1
  148. package/dist/session-retro.d.ts +121 -2
  149. package/dist/session-retro.d.ts.map +1 -1
  150. package/dist/session-retro.js +454 -11
  151. package/dist/session-retro.js.map +1 -1
  152. package/dist/setup.d.ts +28 -0
  153. package/dist/setup.d.ts.map +1 -1
  154. package/dist/setup.js +223 -6
  155. package/dist/setup.js.map +1 -1
  156. package/dist/sign.d.ts.map +1 -1
  157. package/dist/sign.js +18 -1
  158. package/dist/sign.js.map +1 -1
  159. package/dist/skill-selection.d.ts +72 -0
  160. package/dist/skill-selection.d.ts.map +1 -0
  161. package/dist/skill-selection.js +76 -0
  162. package/dist/skill-selection.js.map +1 -0
  163. package/dist/skills-verify.d.ts +34 -1
  164. package/dist/skills-verify.d.ts.map +1 -1
  165. package/dist/skills-verify.js +82 -0
  166. package/dist/skills-verify.js.map +1 -1
  167. package/dist/stage-line.d.ts +68 -0
  168. package/dist/stage-line.d.ts.map +1 -0
  169. package/dist/stage-line.js +129 -0
  170. package/dist/stage-line.js.map +1 -0
  171. package/dist/statusline.d.ts +99 -0
  172. package/dist/statusline.d.ts.map +1 -1
  173. package/dist/statusline.js +310 -44
  174. package/dist/statusline.js.map +1 -1
  175. package/dist/stem.d.ts +12 -0
  176. package/dist/stem.d.ts.map +1 -0
  177. package/dist/stem.js +89 -0
  178. package/dist/stem.js.map +1 -0
  179. package/dist/store-counts.d.ts +26 -0
  180. package/dist/store-counts.d.ts.map +1 -0
  181. package/dist/store-counts.js +125 -0
  182. package/dist/store-counts.js.map +1 -0
  183. package/dist/store-guard.d.ts +106 -0
  184. package/dist/store-guard.d.ts.map +1 -0
  185. package/dist/store-guard.js +294 -0
  186. package/dist/store-guard.js.map +1 -0
  187. package/dist/swarm-brief.d.ts +95 -0
  188. package/dist/swarm-brief.d.ts.map +1 -0
  189. package/dist/swarm-brief.js +660 -0
  190. package/dist/swarm-brief.js.map +1 -0
  191. package/dist/telemetry-vocabulary.d.ts +7 -0
  192. package/dist/telemetry-vocabulary.d.ts.map +1 -1
  193. package/dist/telemetry-vocabulary.js +29 -0
  194. package/dist/telemetry-vocabulary.js.map +1 -1
  195. package/dist/trace-bundle.d.ts +8 -0
  196. package/dist/trace-bundle.d.ts.map +1 -1
  197. package/dist/trace-bundle.js +11 -0
  198. package/dist/trace-bundle.js.map +1 -1
  199. package/package.json +10 -9
  200. package/sbom.json +1033 -233
  201. package/src/agents-policy.ts +46 -2
  202. package/src/amendment-trace.ts +441 -19
  203. package/src/backlog-public.ts +503 -0
  204. package/src/backlog-transitions.ts +77 -0
  205. package/src/backlog.ts +12 -0
  206. package/src/claim-check.ts +25 -2
  207. package/src/claude-hooks-assets.ts +227 -0
  208. package/src/clean-room-smoke.ts +195 -0
  209. package/src/cmd-usage.ts +29 -5
  210. package/src/codex-hooks-assets.ts +140 -30
  211. package/src/codex-hooks.ts +21 -1
  212. package/src/codex-invoke.ts +138 -0
  213. package/src/course-staleness.ts +125 -0
  214. package/src/destructive-guard-hook.ts +151 -0
  215. package/src/destructive-guard.ts +3027 -0
  216. package/src/discrimination-gate.ts +262 -19
  217. package/src/event-chain.ts +41 -0
  218. package/src/feature-adr-routing.ts +220 -22
  219. package/src/guard.ts +358 -0
  220. package/src/harness-core-location.ts +44 -0
  221. package/src/index.ts +121 -7
  222. package/src/lead-shift-gate.ts +145 -0
  223. package/src/ledger-backfill.ts +20 -1
  224. package/src/loop-blobs.generated.ts +12 -3
  225. package/src/loop-lint.ts +52 -7
  226. package/src/loop-plan-graph.ts +66 -1
  227. package/src/loop-trace.ts +13 -1
  228. package/src/managed-hooks.ts +5 -6
  229. package/src/model-recommender.ts +14 -3
  230. package/src/mutation-gate.ts +165 -5
  231. package/src/operations.ts +83 -5
  232. package/src/patterns.ts +33 -0
  233. package/src/plugin.ts +27 -5
  234. package/src/publish.ts +27 -2
  235. package/src/recommend.ts +116 -46
  236. package/src/registry.ts +147 -13
  237. package/src/run-records.ts +23 -0
  238. package/src/score.ts +377 -7
  239. package/src/session-retro.ts +466 -11
  240. package/src/setup.ts +246 -9
  241. package/src/sign.ts +18 -1
  242. package/src/skill-selection.ts +111 -0
  243. package/src/skills-verify.ts +99 -1
  244. package/src/stage-line.ts +151 -0
  245. package/src/statusline.ts +396 -47
  246. package/src/stem.ts +87 -0
  247. package/src/store-counts.ts +154 -0
  248. package/src/store-guard.ts +388 -0
  249. package/src/swarm-brief.ts +661 -0
  250. package/src/telemetry-vocabulary.ts +36 -0
  251. package/src/trace-bundle.ts +10 -0
@@ -34,7 +34,72 @@ export type InterfaceGraph = ReadonlyMap<string, readonly GraphField[]>;
34
34
 
35
35
  /** Brace-matched interface extraction. A regex-only scan truncates at the first nested brace
36
36
  * (inline object fields are everywhere in this file), so bodies are cut by depth counting. */
37
- export function parseInterfaceGraph(source: string): InterfaceGraph {
37
+ /**
38
+ * Убрать из текста КОММЕНТАРИИ и СОДЕРЖИМОЕ строковых литералов, сохранив длину и переводы строк.
39
+ *
40
+ * ЗАЧЕМ, с воспроизведёнными случаями (ИЗМЕРЕНО 2026-09-04, бэклог f23e97dc). Разбор считал скобки
41
+ * и точки с запятой по СЫРОМУ тексту, и четыре конструкции давали ТИХО НЕВЕРНЫЙ граф — а граф
42
+ * решает, достижим ли интерфейс, то есть живой он или мёртвый:
43
+ *
44
+ * • `interface X { note: "смотри Y"; }` — упоминание Y ВНУТРИ строки становилось НАСТОЯЩИМ
45
+ * ребром. Мёртвый интерфейс выглядел живым: ложно-зелёное в проверке достижимости.
46
+ * • `interface X { note: "a; ref: Y"; }` — точка с запятой внутри строки резала запись, и
47
+ * появлялось призрачное поле `ref` со своим ребром.
48
+ * • `interface X { // закрывает } раньше\n ref: Y; }` — закрывающая скобка в комментарии
49
+ * обрезала тело, и настоящее ребро ТЕРЯЛОСЬ: живой интерфейс выглядел мёртвым.
50
+ * • `interface X { kind: "{"; ref: Y; }` — открывающая скобка в строке раздувала тело так, что
51
+ * интерфейс вовсе исчезал из графа.
52
+ *
53
+ * ДЛИНА СОХРАНЯЕТСЯ НАМЕРЕННО: разбор ниже работает индексами по этому же тексту, и замена на
54
+ * строку другой длины сдвинула бы каждую последующую позицию. Переводы строк сохраняются, чтобы
55
+ * номера строк оставались верными, если их когда-нибудь понадобится сообщить.
56
+ */
57
+ function stripCommentsAndStrings(source: string): string {
58
+ const out = source.split('');
59
+ const blank = (from: number, to: number): void => {
60
+ for (let i = from; i < to && i < out.length; i += 1) {
61
+ if (out[i] !== '\n') out[i] = ' ';
62
+ }
63
+ };
64
+ let i = 0;
65
+ while (i < source.length) {
66
+ const two = source.slice(i, i + 2);
67
+ if (two === '//') {
68
+ const end = source.indexOf('\n', i);
69
+ blank(i, end === -1 ? source.length : end);
70
+ i = end === -1 ? source.length : end;
71
+ continue;
72
+ }
73
+ if (two === '/*') {
74
+ const end = source.indexOf('*/', i + 2);
75
+ const stop = end === -1 ? source.length : end + 2;
76
+ blank(i, stop);
77
+ i = stop;
78
+ continue;
79
+ }
80
+ const ch = source[i];
81
+ if (ch === '"' || ch === "'" || ch === '`') {
82
+ let j = i + 1;
83
+ while (j < source.length) {
84
+ if (source[j] === '\\') { j += 2; continue; } // экранированный символ не закрывает строку
85
+ if (source[j] === ch) break;
86
+ j += 1;
87
+ }
88
+ // Гасится СОДЕРЖИМОЕ, а кавычки остаются: тип `"a" | "b"` обязан выглядеть как тип, а не
89
+ // как склеенное слово.
90
+ blank(i + 1, Math.min(j, source.length));
91
+ i = Math.min(j + 1, source.length);
92
+ continue;
93
+ }
94
+ i += 1;
95
+ }
96
+ return out.join('');
97
+ }
98
+
99
+ export function parseInterfaceGraph(rawSource: string): InterfaceGraph {
100
+ // Комментарии и содержимое строк гасятся ДО подсчёта скобок: иначе `}` в комментарии обрезает
101
+ // тело, `{` в строке его раздувает, а имя интерфейса внутри строки становится ребром графа.
102
+ const source = stripCommentsAndStrings(rawSource);
38
103
  const names = new Set<string>();
39
104
  const headRe = /(?:^|\n)\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)/g;
40
105
  for (let m = headRe.exec(source); m !== null; m = headRe.exec(source)) names.add(m[1]!);
package/src/loop-trace.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  * consumed by `dz workflow-trace` and by the fitness suite (same runInvariants, two call sites).
24
24
  */
25
25
 
26
+ import { isNonRunRow } from './ledger-backfill.js';
26
27
  import type { TraceProjection } from './loop-plan.js';
27
28
 
28
29
  /** Blob version stamp for the emitter half (read by scripts/gen-loop-blobs.mjs). */
@@ -941,10 +942,21 @@ export function assembleTimeline(input: {
941
942
  }
942
943
  }
943
944
  if (input.ledger) {
945
+ // The source is declared even when every row is filtered out: the file WAS read, and saying so
946
+ // is the difference between "no cost rows" and "no ledger".
944
947
  sources.push('ledger');
945
948
  for (const line of input.ledger.split('\n')) {
946
949
  const t = line.trim();
947
- if (t !== '') rows.push({ seq: 0, kind: 'ledger', label: 'cost', detail: t.slice(0, 160), wallTime: null });
950
+ if (t === '') continue;
951
+ // AM-4: `kind`-carrying rows are per-phase TELEMETRY, not cost. Labelling one `cost` in a run
952
+ // timeline states a cost claim the row does not make — MEASURED 2026-09-06 as a real defect,
953
+ // not a hypothetical. A malformed line is still shown: unparseable is not "not a cost row".
954
+ try {
955
+ const parsed: unknown = JSON.parse(t);
956
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
957
+ && isNonRunRow(parsed as Record<string, unknown>)) continue;
958
+ } catch { /* unparseable → keep it visible, exactly as before */ }
959
+ rows.push({ seq: 0, kind: 'ledger', label: 'cost', detail: t.slice(0, 160), wallTime: null });
948
960
  }
949
961
  }
950
962
  if (Array.isArray(input.usageEvents)) {
@@ -8,8 +8,8 @@
8
8
  * obvious way to write it and the wrong one.
9
9
  *
10
10
  * What differs between the targets is ONLY the attribution predicate, so that is the parameter:
11
- * the Claude path passes its historical substring list verbatim (bytes must not move — AM-3), the
12
- * Codex path passes sha-over-manifest (ADR-001 §3: dz never deletes what it cannot prove it wrote).
11
+ * the Claude path passes its historical command identities, the Codex path passes sha-over-manifest
12
+ * (ADR-001 §3), and both salvage foreign handlers that share a matcher group with an owned handler.
13
13
  *
14
14
  * The plan (AM-37) is explicit that the extracted block has **three** outputs, not one: the merged
15
15
  * body, the REPORT tail string, and the no-write path (`changed === false`). A golden test on the
@@ -69,10 +69,9 @@ export interface MergeManagedHookOptions {
69
69
  * Per-HANDLER salvage for an entry `isManaged` claimed. Given an OWNED entry, return it rebuilt
70
70
  * from only the handlers that are NOT ours, or `null` when every handler was ours.
71
71
  *
72
- * Optional, and absent means the historical whole-entry behaviour — the Claude path passes
73
- * nothing and is byte-identical to before (AM-3). The Codex path passes it because attribution at
74
- * matcher-group granularity deleted a foreign handler that merely shared a group with dz's
75
- * (independent review, finding 6).
72
+ * Optional, and absent means the historical whole-entry behaviour. Both live registry paths pass
73
+ * it because attribution at matcher-group granularity would delete a foreign handler that merely
74
+ * shared a group with dz's (Codex finding 6; Claude delivery review round 19).
76
75
  */
77
76
  readonly retainForeign?: (entry: ManagedHookEntry, event: string) => ManagedHookEntry | null;
78
77
  /** e.g. `'agentdb'` → `merged agentdb hooks (user hooks preserved)`. */
@@ -54,8 +54,12 @@ const RULE_TEXT =
54
54
 
55
55
  /** `codex:gpt-5.5:xhigh (usage-switched)` → `gpt-5.5`; claude ids pass through; unknown → null. */
56
56
  export function normalizeModelId(raw: unknown): string | null {
57
- if (typeof raw !== 'string' || raw === '') return null;
58
- let id = raw.replace(/ \(usage-switched\)$/, '').trim();
57
+ if (typeof raw !== 'string' || raw.trim() === '') return null;
58
+ // ОБРЕЗКА ИДЁТ ПЕРВОЙ. Прежде метка снималась якорем `$` по СЫРОЙ строке, и один хвостовой
59
+ // пробел — `codex:gpt-5.5:xhigh (usage-switched) ` — делал весь образец неопознанным (null),
60
+ // то есть телеметрия молча теряла запись. ИЗМЕРЕНО 2026-09-04: без пробела «gpt-5.5», с
61
+ // пробелом null.
62
+ let id = raw.trim().replace(/\s*\(usage-switched\)$/, '').trim();
59
63
  const codex = /^codex:([^:]+)(?::[a-z]+)?$/.exec(id);
60
64
  if (codex !== null) id = codex[1]!;
61
65
  return COST_LADDER.some((r) => r.id === id) ? id : null;
@@ -94,7 +98,14 @@ export function harvestStageOutcomes(records: readonly unknown[]): Harvest {
94
98
  if (grade === null) { skipped.noGrade += 1; continue; }
95
99
  // Full canonical JSON is intentional: the former 40-character prefix merged distinct records
96
100
  // whose stage names shared a long prefix, manufacturing idempotency across different runs.
97
- const runId = typeof source['runId'] === 'string' ? source['runId'] : `models:${JSON.stringify(modelsUsed)}`;
101
+ // ПУСТОЙ runId — НЕ ИДЕНТИФИКАТОР. Прежде проверка была `typeof === 'string'`, и пустая строка
102
+ // принималась: ВСЕ такие записи схлопывались в один «прогон», а число прогонов и признак
103
+ // «скормлено» считались по нему. Отсутствующий и пустой теперь одинаково уходят на запасной
104
+ // ключ по составу моделей.
105
+ const rawRunId = source['runId'];
106
+ const runId = typeof rawRunId === 'string' && rawRunId.trim() !== ''
107
+ ? rawRunId.trim()
108
+ : `models:${JSON.stringify(modelsUsed)}`;
98
109
  const ts = typeof source['timestamp'] === 'string' ? source['timestamp'] : null;
99
110
  const tier = typeof result['tier'] === 'string' && result['tier'] !== '' ? result['tier'] : 'unknown';
100
111
  let contributed = false;
@@ -108,6 +108,10 @@ export interface MutationObservation {
108
108
  readonly rebaselineExitCode?: number | null;
109
109
  /** named no-exit reason for the restored-tree attribution run, when it produced none. */
110
110
  readonly rebaselineFailureReason?: string;
111
+ /** parsed failing files from a RED restored-tree run; absent when no red rebaseline ran. */
112
+ readonly rebaselineAttribution?: BaselineAttribution;
113
+ /** bounded log proving an internal runner failure received at most one retry. */
114
+ readonly internalAttemptLog?: string;
111
115
  }
112
116
 
113
117
  export interface MutationEntryResult {
@@ -135,6 +139,60 @@ export interface MutationEntryResult {
135
139
  readonly detail: string;
136
140
  }
137
141
 
142
+ // ── Internal runner crash containment — exactly one retry, both attempts observable ───────────
143
+
144
+ export type InternalRunnerAttemptOutcome = 'completed' | 'runner-internal-error';
145
+
146
+ export interface InternalRunnerAttempt {
147
+ readonly attempt: 1 | 2;
148
+ readonly outcome: InternalRunnerAttemptOutcome;
149
+ readonly detail: string;
150
+ }
151
+
152
+ export interface InternalRunnerRetryResult<T> {
153
+ /** The completed attempt's value. null means both attempts threw internally. */
154
+ readonly value: T | null;
155
+ readonly attempts: readonly InternalRunnerAttempt[];
156
+ /** Closed by construction: a runner receives either zero retries or exactly one. */
157
+ readonly internalRetries: 0 | 1;
158
+ /** Named reason consumed by the existing no-exit → INCONCLUSIVE arm. */
159
+ readonly failureReason?: `runner-internal-error: ${string}`;
160
+ }
161
+
162
+ function internalRunnerErrorHead(error: unknown): string {
163
+ const raw = error instanceof Error ? error.message : String(error);
164
+ const firstLine = raw.split(/\r?\n/, 1)[0]?.trim() || 'unknown internal runner error';
165
+ return Array.from(firstLine).slice(0, 160).join('');
166
+ }
167
+
168
+ /**
169
+ * Run one internal runner invocation. Only a THROWN internal error is retried; normal green/red
170
+ * observations and ordinary no-exit observations are values and therefore never retried.
171
+ */
172
+ export function runWithOneInternalRetry<T>(runner: () => T): InternalRunnerRetryResult<T> {
173
+ const attempts: InternalRunnerAttempt[] = [];
174
+ for (const attempt of [1, 2] as const) {
175
+ try {
176
+ const value = runner();
177
+ attempts.push({ attempt, outcome: 'completed', detail: `attempt ${attempt}: completed` });
178
+ return { value, attempts, internalRetries: attempt === 1 ? 0 : 1 };
179
+ } catch (error) {
180
+ const head = internalRunnerErrorHead(error);
181
+ attempts.push({
182
+ attempt,
183
+ outcome: 'runner-internal-error',
184
+ detail: `attempt ${attempt}: runner-internal-error: ${head}`,
185
+ });
186
+ }
187
+ }
188
+ return {
189
+ value: null,
190
+ attempts,
191
+ internalRetries: 1,
192
+ failureReason: 'runner-internal-error: persistent after 2/2 attempts',
193
+ };
194
+ }
195
+
138
196
  // ── Registry parsing — declarative DATA, validated loudly ─────────────────────────────────────
139
197
 
140
198
  const SAFE_ID = /^[a-z0-9][a-z0-9-]{0,79}$/;
@@ -495,21 +553,114 @@ export function classifyRunFailure(rawOutput: string): RunFailureClassification
495
553
 
496
554
  // ── Baseline (rule 3's runnability half) ──────────────────────────────────────────────────────
497
555
 
556
+ export type BaselineAttributionSource = 'node-test' | 'vitest' | 'unparseable';
557
+
558
+ export interface BaselineAttribution {
559
+ readonly parsedFrom: BaselineAttributionSource;
560
+ /** Package-relative failing paths, in first-seen order. */
561
+ readonly failingFiles: readonly string[];
562
+ /** Failing paths that match a registry file exactly (or by package-relative suffix). */
563
+ readonly covered: readonly string[];
564
+ /** Failing paths with no matching registry file. */
565
+ readonly extraneous: readonly string[];
566
+ }
567
+
568
+ function normaliseReportedFile(raw: string): string | null {
569
+ let value = raw.trim().replace(/^['"]|['"]$/g, '').replace(/:\d+(?::\d+)?$/, '');
570
+ value = value.replace(/\\/g, '/');
571
+ const testSegment = value.lastIndexOf('/test/');
572
+ if (testSegment >= 0) value = value.slice(testSegment + 1);
573
+ if (!value.includes('/') || !/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(value)) return null;
574
+ return value;
575
+ }
576
+
577
+ /** Parse the failing FILE paths already exposed by supported node --test and vitest shapes. */
578
+ export function attributeBaselineRedness(
579
+ rawOutput: string,
580
+ registryFiles: readonly string[],
581
+ ): BaselineAttribution {
582
+ const output = stripSgr(rawOutput);
583
+ const files: string[] = [];
584
+ const add = (raw: string): void => {
585
+ const file = normaliseReportedFile(raw);
586
+ if (file !== null && !files.includes(file)) files.push(file);
587
+ };
588
+
589
+ const vitestMatches = [...output.matchAll(/^\s*FAIL\s+(\S+)/gm)];
590
+ for (const match of vitestMatches) add(match[1] ?? '');
591
+
592
+ const tapMatches = [...output.matchAll(/^not ok \d+\s+-\s+(.+)$/gm)];
593
+ for (const match of tapMatches) add(match[1] ?? '');
594
+ for (const match of output.matchAll(/^\s*location:\s*['"]([^'"]+)['"]\s*$/gm)) {
595
+ add(match[1] ?? '');
596
+ }
597
+
598
+ const parsedFrom: BaselineAttributionSource = files.length === 0
599
+ ? 'unparseable'
600
+ : vitestMatches.length > 0 ? 'vitest' : 'node-test';
601
+ if (parsedFrom === 'unparseable') {
602
+ return { parsedFrom, failingFiles: [], covered: [], extraneous: [] };
603
+ }
604
+
605
+ const normalisedRegistry = registryFiles
606
+ .map((file) => normaliseReportedFile(file) ?? file.replace(/\\/g, '/'));
607
+ const covered = files.filter((file) => normalisedRegistry.some((registryFile) =>
608
+ file === registryFile || file.endsWith(`/${registryFile}`) || registryFile.endsWith(`/${file}`)));
609
+ const extraneous = files.filter((file) => !covered.includes(file));
610
+ return { parsedFrom, failingFiles: files, covered, extraneous };
611
+ }
612
+
613
+ export type BaselineFailureReason =
614
+ | 'runner-internal-error'
615
+ | 'runner-no-exit'
616
+ | 'extraneous-red-in-allowlist'
617
+ | 'baseline-red-covered-files'
618
+ | 'baseline-red-files-unparseable';
619
+
498
620
  export interface BaselineResult {
499
621
  readonly ok: boolean;
500
622
  readonly detail: string;
623
+ readonly reason?: BaselineFailureReason;
501
624
  }
502
625
 
503
626
  /**
504
627
  * A RED baseline in the scratch copy is a SETUP error, never a mutation result: every subsequent
505
628
  * "red under mutation" would be noise, and every "green" a lie about an unrunnable copy.
506
629
  */
507
- export function classifyBaseline(exitCode: number | null, runFailureReason?: string): BaselineResult {
630
+ export function classifyBaseline(
631
+ exitCode: number | null,
632
+ runFailureReason?: string,
633
+ attribution?: BaselineAttribution,
634
+ ): BaselineResult {
508
635
  if (exitCode === 0) return { ok: true, detail: 'baseline suite green in the scratch copy' };
509
636
  if (exitCode === null) {
510
- return { ok: false, detail: `baseline suite produced no exit code (${runFailureReason ?? 'unknown timeout/spawn failure'}) — the copy is not runnable; fix the copy, do not read this as a mutation result` };
637
+ const internal = runFailureReason?.startsWith('runner-internal-error:') === true;
638
+ return {
639
+ ok: false,
640
+ reason: internal ? 'runner-internal-error' : 'runner-no-exit',
641
+ detail: `baseline INCONCLUSIVE — suite produced no exit code (${runFailureReason ?? 'unknown timeout/spawn failure'}) — the copy is not runnable; do not read this as a mutation result`,
642
+ };
511
643
  }
512
- return { ok: false, detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — a broken copy cannot prove anything; fix the copy (node_modules link? path-dependent test?) before trusting any verdict` };
644
+ if (attribution === undefined || attribution.parsedFrom === 'unparseable') {
645
+ return {
646
+ ok: false,
647
+ reason: 'baseline-red-files-unparseable',
648
+ detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — failing files: unparseable from runner output — the broken copy cannot prove anything`,
649
+ };
650
+ }
651
+ const failing = `failing files: ${attribution.failingFiles.join(', ')}`;
652
+ if (attribution.extraneous.length > 0 && attribution.covered.length === 0) {
653
+ return {
654
+ ok: false,
655
+ reason: 'extraneous-red-in-allowlist',
656
+ detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — ${failing} — extraneous red in the testCommand allowlist; the registry entries themselves are not disproven`,
657
+ };
658
+ }
659
+ return {
660
+ ok: false,
661
+ reason: 'baseline-red-covered-files',
662
+ detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — ${failing} — broken-copy baseline redness touches registry-covered files; fix the copy before evaluating mutations`,
663
+ };
513
664
  }
514
665
 
515
666
  // ── Classification (rules 1 + 2, and the drop decision) ───────────────────────────────────────
@@ -549,7 +700,7 @@ export function classifyBaseline(exitCode: number | null, runFailureReason?: str
549
700
  * while the contract still holds is the early warning, reported loudly so a human re-pins
550
701
  * `observed` or investigates — silently normalising it would erase the signal.
551
702
  */
552
- export function classifyMutationOutcome(obs: MutationObservation): MutationEntryResult {
703
+ function classifyMutationOutcomeWithoutAttemptLog(obs: MutationObservation): MutationEntryResult {
553
704
  const e = obs.entry;
554
705
  const minFailing = e.minFailing ?? 1;
555
706
  const base = {
@@ -653,12 +804,15 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
653
804
  // not come back green, so the suite is flaky and an unrelated neighbour may be what went red.
654
805
  // Not attributable ⇒ INCONCLUSIVE (a failure, never a pass).
655
806
  if (obs.rebaselineExitCode !== undefined && obs.rebaselineExitCode !== 0) {
807
+ const failing = obs.rebaselineAttribution === undefined || obs.rebaselineAttribution.parsedFrom === 'unparseable'
808
+ ? 'failing files: unparseable from runner output'
809
+ : `failing files: ${obs.rebaselineAttribution.failingFiles.join(', ')}`;
656
810
  return {
657
811
  ...base,
658
812
  applied: true,
659
813
  verdict: 'INCONCLUSIVE',
660
814
  drop: false,
661
- detail: `suite red under the mutation BUT the restored baseline did not reproduce green (${obs.rebaselineExitCode === null ? `no exit code: ${obs.rebaselineFailureReason ?? 'unknown timeout / spawn failure'}` : `exit ${obs.rebaselineExitCode}`}) — the suite is flaky; the redness is not attributable to the protection and may be an unrelated neighbour`,
815
+ detail: `suite red under the mutation BUT the restored baseline did not reproduce green (${obs.rebaselineExitCode === null ? `no exit code: ${obs.rebaselineFailureReason ?? 'unknown timeout / spawn failure'}` : `exit ${obs.rebaselineExitCode}`}) — mutation did not revert / flaky restored-tree route; ${failing}; the redness is not attributable to the protection`,
662
816
  };
663
817
  }
664
818
 
@@ -698,6 +852,12 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
698
852
  };
699
853
  }
700
854
 
855
+ export function classifyMutationOutcome(obs: MutationObservation): MutationEntryResult {
856
+ const result = classifyMutationOutcomeWithoutAttemptLog(obs);
857
+ if (obs.internalAttemptLog === undefined) return result;
858
+ return { ...result, detail: `${result.detail}; ${obs.internalAttemptLog}` };
859
+ }
860
+
701
861
  /** Verdicts that fail the gate. INCONCLUSIVE and NOT_APPLIED fail (inconclusive ≠ pass). */
702
862
  const FAILING_VERDICTS: ReadonlySet<MutationVerdict> = new Set(['UNDEFENDED', 'RECEIPT_MISMATCH', 'NOT_APPLIED', 'BELOW_MIN', 'MUTATION_UNPARSEABLE', 'MUTATION_LOAD_FATAL', 'OVER_FAILING', 'INCONCLUSIVE']);
703
863
 
package/src/operations.ts CHANGED
@@ -169,6 +169,26 @@ export interface InitReport {
169
169
  // Platform enrichment — optional extras beyond SKILL.md
170
170
  // ---------------------------------------------------------------------------
171
171
 
172
+ /**
173
+ * Размер RVF-файла, при котором в нём заведомо НЕТ ни одной записи.
174
+ *
175
+ * ИЗМЕРЕНО 2026-09-04 прямым прогоном на `@ruvector/rvf-node` 0.1.8 (linux-x64): пустое хранилище —
176
+ * РОВНО 162 байта при любой размерности; первая запись доводит файл до 615 байт при размерности 8,
177
+ * 839 при 64, 2119 при 384 и 6727 при 1536. Порог 200 — небольшой запас над измеренным пустым
178
+ * размером: он покрывает возможный чуть больший заголовок и втрое ниже самого маленького
179
+ * наблюдавшегося хранилища С записью.
180
+ *
181
+ * ПОПРАВКА К ПЕРВОЙ РЕДАКЦИИ (ревью 2026-09-04). Здесь стояло «первая запись добавляет ~1550 байт»
182
+ * и «порог — середина измеренного зазора». Оба утверждения неверны: 1550 нигде не измерялось, а
183
+ * серединой зазора [162, 2119] было бы 1140, не 200. Строгое на вид обоснование опиралось на
184
+ * арифметическую ошибку в собственных числах документа.
185
+ *
186
+ * Оговорка «при меньшей размерности запись могла бы уместиться в зазор» тоже СНЯТА как
187
+ * неизмеренная: замер по четырём размерностям показывает обратное — даже при размерности 8
188
+ * хранилище с записью втрое больше порога.
189
+ */
190
+ const RVF_HEADER_ONLY_MAX_BYTES = 200;
191
+
172
192
  /**
173
193
  * Where each target's skill tree is rooted. These MUST agree with the
174
194
  * `skillsRoot` each adapter actually emits to — so we import the adapters'
@@ -1187,6 +1207,61 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1187
1207
  detail: 'orphan .dz/memory.rvf placeholder from an old setup (nothing reads it) — delete it; the real store is .dz/agentdb.db',
1188
1208
  });
1189
1209
  }
1210
+ // 8y. ПУСТОЕ ВЕКТОРНОЕ ХРАНИЛИЩЕ МОЛЧИТ, А НЕ ЖАЛУЕТСЯ (решение владельца 2026-09-04).
1211
+ //
1212
+ // Пустой RVF-файл не даёт ошибки: поиск по нему возвращает пустой список, и всё выглядит
1213
+ // работающим. ИЗМЕРЕНО 2026-09-04: `.agentic-qe/patterns.rvf` — 162 байта (записей не помещается)
1214
+ // при 288 паттернах и 275 векторах, целых в `.agentic-qe/memory.db` рядом. Подробности,
1215
+ // измерения и названные пределы — features/doctor-empty-vector-store/01_requirements.md.
1216
+ //
1217
+ // ПОЧЕМУ ПО РАЗМЕРУ, А НЕ ПО ЧИСЛУ ВЕКТОРОВ. Единственная причина — не потревожить наблюдение:
1218
+ // идёт семидневный замер «читает ли этот файл хоть кто-нибудь», а открытие сдвигает время
1219
+ // последнего чтения. Прежняя редакция называла второй причиной файловый замок — ЭТО ОПРОВЕРГНУТО
1220
+ // ревью экспериментально: `openReadonly` под живым замком открывает хранилище, не создаёт `.lock`
1221
+ // и не меняет ни размер, ни mtime. Ложное обоснование убрано, верное оставлено.
1222
+ //
1223
+ // ЧТО ДОКАЗЫВАЕТ РАЗМЕР: ровно «записей тут поместиться не может». НЕ доказывает, что файл —
1224
+ // валидный заголовок формата: пустой, обрезанный или посторонний файл с тем же расширением
1225
+ // попадёт в ту же ветку, поэтому текст находки говорит про размер и следствие.
1226
+ //
1227
+ // ОБЛАСТЬ — ТРИ ИЗВЕСТНЫХ КАТАЛОГА ХРАНИЛИЩ, без рекурсии. `.dz/memory` добавлен по находке
1228
+ // ревью: собственное RVF-хранилище dz лежит именно там (vector-tier.ts), и без него ветка `.dz`
1229
+ // покрывала только legacy-заглушку, то есть была декоративной. Рекурсия по дереву не делается:
1230
+ // она нашла бы десятки копий в `.claude/worktrees/agent-*/`, и отчёт утонул бы в шуме.
1231
+ for (const dir of ['.agentic-qe', '.dz', '.dz/memory']) {
1232
+ let names: string[] = [];
1233
+ try {
1234
+ names = readdirSync(join(root, dir)).filter((n) => n.endsWith('.rvf'));
1235
+ } catch { continue; } // каталога нет — проверять нечего, и это не находка
1236
+ for (const name of names) {
1237
+ // `.dz/memory.rvf` уже разобран проверкой выше как legacy-сирота с советом «удалить».
1238
+ // Вторая находка на тот же файл советовала бы искать держателя у файла, который никто не
1239
+ // читает — два совета об одном объекте, и один из них бессмысленный (находка ревью).
1240
+ if (dir === '.dz' && name === 'memory.rvf') continue;
1241
+ let size: number;
1242
+ try {
1243
+ const st = statSync(join(root, dir, name));
1244
+ // Каталог с таким именем — не хранилище; `isFile` дешевле и честнее, чем гадать по размеру.
1245
+ if (!st.isFile()) continue;
1246
+ size = st.size;
1247
+ } catch { continue; } // нечитаемый или исчезнувший файл — не находка: мы про него ничего не установили
1248
+ if (size > RVF_HEADER_ONLY_MAX_BYTES) continue;
1249
+ // СООБЩЕНИЕ, А НЕ ПРОВАЛ. Проверка не умеет отличить испорченное хранилище от только что
1250
+ // созданного, в которое ещё ничего не писали, — а это здоровое состояние первого дня. Ронять
1251
+ // код возврата на здоровом состоянии значит приучить читателя к красному; соседняя проверка
1252
+ // целостности в этой же функции решает ту же дилемму так же и говорит об этом вслух.
1253
+ checks.push({
1254
+ name: 'vector store',
1255
+ ok: true,
1256
+ detail: `${dir}/${name} — ${size} bytes: too small to hold a single record, so a search `
1257
+ + 'over it returns an empty answer instead of an error, and nothing looks broken. '
1258
+ + 'Size alone does not say WHY: an empty store, a truncated file, or not a store at all. '
1259
+ + 'If this project HAS learned patterns, the store is behind — compare with the SQLite '
1260
+ + 'store beside it before deciding.',
1261
+ });
1262
+ }
1263
+ }
1264
+
1190
1265
  const legacyMcp = join(root, '.claude', 'mcp.json');
1191
1266
  if (existsSync(legacyMcp) && readFileSync(legacyMcp, 'utf-8').includes('agentdb')) {
1192
1267
  checks.push({
@@ -1383,9 +1458,12 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1383
1458
  // Silent when a log is absent or has never been chained: an unchained file is legal (FR-5), not a
1384
1459
  // fault, and reporting it would train the reader to ignore this line.
1385
1460
  try {
1386
- const { verifyEventChainText, classifyChainDefects, EVENT_CHAIN_SCOPE } = await import('./event-chain.js');
1387
- for (const rel of ['recall-usage.jsonl', 'guard-audit.jsonl']) {
1388
- const p = join(root, '.dz', rel);
1461
+ const { verifyEventChainText, classifyChainDefects, EVENT_CHAIN_SCOPE, CHAINED_JOURNALS } = await import('./event-chain.js');
1462
+ // W0-chain (bc4ee35c): enumerated from THE registry, never from a list kept here. The inline
1463
+ // array this replaces is why a journal could be given a chain and still be checked by nobody —
1464
+ // the mechanism present, the coverage absent, and no red anywhere to say so.
1465
+ for (const journal of CHAINED_JOURNALS) {
1466
+ const p = join(root, journal.rel);
1389
1467
  if (!existsSync(p)) continue;
1390
1468
  const text = readFileSync(p, 'utf-8');
1391
1469
  const v = verifyEventChainText(text);
@@ -1400,7 +1478,7 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1400
1478
  // file and false of the present, and a red nobody can act on is a red nobody reads.
1401
1479
  if (age.inRun.length === 0 && age.runRecords > 0) {
1402
1480
  checks.push({
1403
- name: `evidence chain (.dz/${rel})`,
1481
+ name: `evidence chain (${journal.rel})`,
1404
1482
  ok: true,
1405
1483
  // The COUNT carries the meaning, and is printed first for that reason: "1 record forms an
1406
1484
  // unbroken run" is true and says almost nothing, while 998 says a great deal. Naming the
@@ -1413,7 +1491,7 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1413
1491
  continue;
1414
1492
  }
1415
1493
  checks.push({
1416
- name: `evidence chain (.dz/${rel})`,
1494
+ name: `evidence chain (${journal.rel})`,
1417
1495
  ok: false,
1418
1496
  detail: `${named} — with NO sound records after them: learning verdicts computed from this log are unsafe. Scope: ${EVENT_CHAIN_SCOPE}`,
1419
1497
  });
package/src/patterns.ts CHANGED
@@ -60,6 +60,29 @@ export interface PatternRecord {
60
60
  readonly lessonPairId?: string;
61
61
  /** Read-model companion; derived from the linked class row, never persisted on this row. */
62
62
  readonly classForm?: string;
63
+ /**
64
+ * Address of this record IN THE STORE IT CAME FROM — `MemoryRecord.id`, carried so that
65
+ * `dz recall --forget` / `--promote` can be driven from `dz recall --all --json`, which their own
66
+ * help text already tells the reader to use. Deriving it instead is the trap `cmdRecallForget`
67
+ * already warns about: MEASURED on this store, `patternRecordId(recordToPattern(r))` reproduces
68
+ * the id for 535 of 537 records and hands a plausible WRONG address for the other two (a
69
+ * `dream:`-written row and one on an older scheme) — and a wrong id makes `--forget` report
70
+ * success while deleting nothing. So the id travels rather than being recomputed.
71
+ *
72
+ * VALID ONLY IN THE ISSUING STORE. `dz teach --from-json` must never adopt it: an id names a
73
+ * DIFFERENT record on the receiving machine, so an adopted id would point `--forget` at the wrong
74
+ * lesson. The importer builds its candidate from named fields (never a spread), and a test pins
75
+ * that — the property is what keeps the portable export safe to accept from anyone.
76
+ */
77
+ readonly dzId?: string;
78
+ /**
79
+ * Quarantine marker (feature lesson-quarantine): the lesson is a HYPOTHESIS, not knowledge.
80
+ * Present ONLY when true — absence means promoted, matching `readQuarantineState`'s AM-1
81
+ * fail-safe, so a missing field is never read as a claim about the record. Carried because
82
+ * `--promote` addresses exactly the quarantined rows and had no way to name them from an export.
83
+ * Like `dzId`, it is a fact about the ISSUING store and is not adopted on import.
84
+ */
85
+ readonly quarantined?: boolean;
63
86
  }
64
87
 
65
88
  /** A session lifecycle event as written by the session hooks to `.dz/sessions.jsonl`. */
@@ -498,6 +521,15 @@ export function recordToPattern(r: MemoryRecord): PatternRecord {
498
521
  && typeof lessonPairId === 'string' && lessonPairId !== ''
499
522
  ? { lessonForm, lessonPairId }
500
523
  : {};
524
+ // The id and the quarantine marker travel with the row. Both are facts about THIS store, and
525
+ // both were previously dropped here — the single place the whole `--all --json` export is built
526
+ // (`loadStorePatternsSync` = `loadStoreRecordsSync().map(recordToPattern)`), which is why
527
+ // `--forget`'s own "find them with `dz recall --all --json`" hint pointed at a file that could
528
+ // not answer. `quarantined` is emitted only when true, so absence keeps meaning "promoted".
529
+ const addressing: { dzId?: string; quarantined?: boolean } = {
530
+ ...(typeof r.id === 'string' && r.id !== '' ? { dzId: r.id } : {}),
531
+ ...(readQuarantineState(r).quarantined ? { quarantined: true } : {}),
532
+ };
501
533
  return {
502
534
  pattern: r.text,
503
535
  type: PATTERN_TYPES.has(r.outcome) ? (r.outcome as PatternRecord['type']) : 'lesson-learned',
@@ -506,6 +538,7 @@ export function recordToPattern(r: MemoryRecord): PatternRecord {
506
538
  ts: r.timestamp,
507
539
  source: r.metadata?.['source'] ?? 'dz-teach',
508
540
  ...pair,
541
+ ...addressing,
509
542
  };
510
543
  }
511
544
 
package/src/plugin.ts CHANGED
@@ -27,6 +27,13 @@ export interface PluginManifest {
27
27
  readonly skills: readonly string[];
28
28
  }
29
29
 
30
+ /** id→path index built from the registry, so the generator never guesses a layout. */
31
+ function skillPathIndex(registry: { entries: readonly { id: string; pack: string; path?: string }[] }): Map<string, string> {
32
+ const m = new Map<string, string>();
33
+ for (const e of registry.entries) if (e.path !== undefined && e.path !== '') m.set(`${e.pack}/${e.id}`, e.path);
34
+ return m;
35
+ }
36
+
30
37
  /** Generate .claude-plugin/ directory from registry. */
31
38
  export function generatePlugin(
32
39
  projectRoot: string,
@@ -61,16 +68,26 @@ export function generatePlugin(
61
68
  };
62
69
 
63
70
  const skillPacks = [...packMap.entries()].map(([name, count]) => ({
71
+ // `pack` is the REAL directory name; `name` is only the display short form. Deriving the key
72
+ // back by re-adding the `skills-` prefix broke the moment the catalogue learned packs that
73
+ // never had one (health-advisor, keysarium, …): the lookup missed and their entries listed
74
+ // zero skills.
75
+ pack: name,
64
76
  name: name.replace('skills-', ''),
65
77
  skills: count,
66
78
  description: packDescriptions[name] ?? `${count} skills`,
67
79
  }));
68
80
 
81
+ const pathById = skillPathIndex(registry);
82
+
69
83
  // Explicit skill paths for the full-suite plugin (source `./`): every skill
70
84
  // dir across every pack, relative to the repo root. This is what makes
71
85
  // `claude plugin details` report the real skill count instead of 0.
72
86
  const allSkillPaths = [...packSkillIds.entries()]
73
- .flatMap(([pack, ids]) => ids.map((id) => `packages/@dzhechkov/${pack}/${id}`))
87
+ // Prefer the path the catalogue recorded; `<pack>/<id>` is only a fallback for entries built
88
+ // before the field existed. Reconstructing it is what produced unresolvable paths for skills
89
+ // that live under `skills/` or `templates/.claude/skills/`.
90
+ .flatMap(([pack, ids]) => ids.map((id) => pathById.get(`${pack}/${id}`) ?? `packages/@dzhechkov/${pack}/${id}`))
74
91
  .sort();
75
92
 
76
93
  const pluginJson = {
@@ -103,12 +120,17 @@ export function generatePlugin(
103
120
  ...skillPacks.map((pack) => ({
104
121
  name: `dz-${pack.name}`,
105
122
  displayName: `DZ ${pack.name.charAt(0).toUpperCase() + pack.name.slice(1)} Skills`,
106
- source: `packages/@dzhechkov/skills-${pack.name}`,
123
+ source: `packages/@dzhechkov/${pack.pack}`,
107
124
  description: `${pack.skills} ${pack.name} skills — ${pack.description}`,
108
125
  keywords: [pack.name],
109
- // Skill dirs sit at the pack source root, so they are listed relative
110
- // to `source` here (`./<id>`) — required for Claude Code to discover them.
111
- skills: (packSkillIds.get(`skills-${pack.name}`) ?? []).slice().sort().map((id) => `./${id}`),
126
+ // Listed relative to `source` — required for Claude Code to discover them. The path comes
127
+ // from the catalogue rather than from the assumption "skill dirs sit at the pack root",
128
+ // which holds for `skills-*` packs and for neither of the two other layouts.
129
+ skills: (packSkillIds.get(pack.pack) ?? []).slice().sort().map((id) => {
130
+ const full = pathById.get(`${pack.pack}/${id}`);
131
+ const base = `packages/@dzhechkov/${pack.pack}/`;
132
+ return full !== undefined && full.startsWith(base) ? `./${full.slice(base.length)}` : `./${id}`;
133
+ }),
112
134
  })),
113
135
  ],
114
136
  };