@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
package/src/guard.ts CHANGED
@@ -51,12 +51,20 @@ export interface Violation {
51
51
  readonly contentAnchor?: string;
52
52
  }
53
53
 
54
+ /** A publish-secret waiver: exact repo-relative path, with a mandatory explanation. */
55
+ interface SecretWaiver {
56
+ readonly path?: string;
57
+ readonly reason?: string;
58
+ }
59
+
54
60
  export interface GuardResult {
55
61
  readonly op: GuardOp;
56
62
  readonly verdict: GuardVerdict;
57
63
  readonly violations: readonly Violation[];
58
64
  /** ids of the rules that ran for this op (so a report can show what was checked, not just what failed). */
59
65
  readonly checked: readonly string[];
66
+ /** ids of the rules that ran for this op but received no input to examine. */
67
+ readonly notEstablished: readonly string[];
60
68
  /**
61
69
  * Informational notes (FN-7): things a rule wants ON THE RECORD that are NOT violations and never
62
70
  * touch the verdict — e.g. "no-stubs: N changed scannable file(s) not scanned". A fail-open skip
@@ -75,10 +83,58 @@ export interface GuardFacts {
75
83
  readonly volume?: VolumeShadowInput;
76
84
  /** for no-workspace-star: each publishable package's deps map. */
77
85
  readonly packages?: readonly { readonly name: string; readonly deps: Readonly<Record<string, string>> }[];
86
+ /**
87
+ * for sibling-dep-protocol: КАЖДАЯ зависимость на соседний пакет монорепозитория, как она
88
+ * записана НА ДИСКЕ (без подстановки версии), с полем, в котором она объявлена. Поле нужно:
89
+ * `dependencies` и `devDependencies` обязаны идти через workspace-протокол, а `peerDependencies`
90
+ * и `optionalDependencies` — НЕТ, и это не послабление, а разная семантика (см. правило).
91
+ */
92
+ /**
93
+ * for plugin-manifest-audit: каждый найденный `.claude-plugin/plugin.json` и объявленные им
94
+ * поля. `parseError` заполнен ⇒ манифест не разобрался; остальные поля тогда не смотрят.
95
+ */
96
+ readonly pluginManifests?: readonly {
97
+ readonly path: string;
98
+ readonly parseError?: string;
99
+ readonly name?: string;
100
+ readonly version?: string;
101
+ readonly description?: string;
102
+ /** Имена навыков, ОБЪЯВЛЕННЫЕ манифестом. */
103
+ readonly declaredSkills?: readonly string[];
104
+ /** Имена навыков, НАЙДЕННЫЕ на диске рядом с манифестом. */
105
+ readonly skillsOnDisk?: readonly string[];
106
+ }[];
107
+ readonly siblingDeps?: readonly {
108
+ readonly name: string;
109
+ readonly field: string;
110
+ readonly dep: string;
111
+ readonly spec: string;
112
+ }[];
113
+ /**
114
+ * for backlog-covers-features: каталоги фич, дата их ПЕРВОГО коммита, тексты записей бэклога и
115
+ * базовая дата. Базовая дата обязательна и делает правило зелёным на приходе: 336 существующих
116
+ * каталогов заведены до правила, и краснеть на них — значит учить себя игнорировать (ИЗМЕРЕНО
117
+ * 2026-09-03: без базы 236 нарушений, в окне 7 дней — 48). Отсутствие факта ⇒ правило молчит:
118
+ * страж без улик не выдумывает вердикт.
119
+ */
120
+ readonly featureBacklog?: {
121
+ readonly baseline: string;
122
+ readonly features: readonly { readonly slug: string; readonly createdIso: string; readonly waiver?: string }[];
123
+ readonly backlogTexts: readonly string[];
124
+ };
78
125
  /** for no-skill-drift: the names that byte-drift between copies (from sweepSkillDrift). */
79
126
  readonly drift?: readonly string[];
127
+ /**
128
+ * for codex-wrapper-for-value-stage: workflow scripts to scan, as (path, text). Absent ⇒ the
129
+ * rule reports nothing: a guard with no evidence must stay silent rather than invent a verdict.
130
+ */
131
+ readonly workflowScripts?: readonly { readonly path: string; readonly text: string }[];
80
132
  /** for no-secrets: labelled blobs to scan (lesson text, staged files). */
81
133
  readonly secretTargets?: readonly { readonly label: string; readonly text: string }[];
134
+ /** for no-secrets: exact path waivers; entries without a non-empty reason are ignored and noted. */
135
+ readonly secretWaivers?: readonly SecretWaiver[];
136
+ /** Publish secret-scan coverage gaps; skips are informational and never verdict inputs. */
137
+ readonly secretScan?: { readonly skipped: number };
82
138
  /** for readme-consistency: labelled (a,b) count pairs that must be equal. */
83
139
  readonly counts?: readonly { readonly label: string; readonly a: number; readonly b: number }[];
84
140
  /** for store-bloat-cap: current learned-store size vs its cap. */
@@ -294,7 +350,10 @@ function unquoteYaml(s: string): string {
294
350
  /** The built-in rule set (works with no config). Ops are the mutating operations each rule guards. */
295
351
  export const DEFAULT_RULES: readonly GuardRule[] = [
296
352
  { id: 'no-workspace-star', severity: 'hard', ops: ['publish'], description: 'a published package.json must carry no workspace:* dep (npm ships it verbatim → the install breaks)' },
353
+ { id: 'plugin-manifest-audit', severity: 'hard', ops: ['publish'], description: 'every .claude-plugin/plugin.json parses and declares a non-empty name, description and a STRICT N.N.N version' },
354
+ { id: 'sibling-dep-protocol', severity: 'hard', ops: ['publish'], description: 'a dependencies/devDependencies entry on a sibling @dzhechkov package must use the workspace: protocol on disk (peer/optional deps are deliberately exempt — a range is their point)' },
297
355
  { id: 'no-skill-drift', severity: 'hard', ops: ['publish', 'consolidate'], description: 'no unexpected byte-drift between shared skill copies' },
356
+ { id: 'backlog-covers-features', severity: 'soft', ops: ['publish', 'consolidate'], description: 'каталог фичи, заведённый после базовой даты, назван записью бэклога — либо несёт именованную оговорку с причиной' },
298
357
  { id: 'no-secrets', severity: 'hard', ops: ['teach', 'publish'], description: 'no private key or API token in lesson text or a published file' },
299
358
  { id: 'readme-consistency', severity: 'soft', ops: ['publish'], description: 'README counts agree (CJM header vs All Commands, etc.)' },
300
359
  { id: 'skills-registrable', severity: 'soft', ops: ['publish'], description: 'every skill directory in a skill pack has a depth-1 SKILL.md (a buried or missing one ships un-registrable — the health-advisor 1.2.0 class)' },
@@ -306,6 +365,7 @@ export const DEFAULT_RULES: readonly GuardRule[] = [
306
365
  { id: 'feature-artifact-diff-ratio', severity: 'soft', ops: ['publish'], description: 'observe feature artifact bytes against attributable unified-diff bytes, explicitly a proxy; advisory only' },
307
366
  { id: 'feature-tier-artifact-set', severity: 'soft', ops: ['publish'], description: 'observe artifacts due for the recorded feature tier, active steps, consumers, and lifecycle; advisory only' },
308
367
  { id: 'agents-md-policy-sync', severity: 'soft', ops: ['publish'], description: 'proves the AGENTS.md copy is in SYNC with its source — not that the runtime read or obeyed it; heal drift with dz agents-sync' },
368
+ { id: 'codex-wrapper-for-value-stage', severity: 'hard', ops: ['publish'], description: 'a workflow stage routed to the fire-and-forget codex wrapper must not have its return value consumed — the wrapper answers with a dispatch stub, never with the model' },
309
369
  { id: 'lockfile-in-sync', severity: 'soft', ops: ['publish'], description: 'every workspace @dzhechkov/* dependency spec matches the specifier pnpm-lock.yaml records for that importer (a dep bump without a lockfile refresh breaks CI with ERR_PNPM_OUTDATED_LOCKFILE). SOFT-ONLY — a config cannot promote it to HARD' },
310
370
  { id: 'store-bloat-cap', severity: 'soft', ops: ['teach', 'consolidate'], description: 'the learned store is within its size cap' },
311
371
  // Description ASSEMBLED from STUB_MARKERS so guard.ts itself stays clean under the scan it defines
@@ -340,7 +400,91 @@ export function scanSecrets(text: unknown): { readonly name: string }[] {
340
400
  return [...hits].map((name) => ({ name }));
341
401
  }
342
402
 
403
+ function secretWaiverState(waivers: readonly SecretWaiver[] | undefined): { waived: ReadonlySet<string>; reasonless: number } {
404
+ const waived = new Set<string>();
405
+ let reasonless = 0;
406
+ for (const waiver of Array.isArray(waivers) ? waivers : []) {
407
+ if (!waiver || typeof waiver !== 'object' || typeof waiver.path !== 'string' || waiver.path.trim() === '') continue;
408
+ const reason = typeof waiver.reason === 'string' ? waiver.reason.trim() : '';
409
+ if (reason.length > 0) waived.add(waiver.path);
410
+ else reasonless++;
411
+ }
412
+ return { waived, reasonless };
413
+ }
414
+
343
415
  /** Per-rule pure checkers. Each returns the violations it found (empty ⇒ clean). Missing evidence ⇒ []. */
416
+ /**
417
+ * Какие каталоги фич заведены после базовой даты и НЕ названы ни одной записью бэклога.
418
+ *
419
+ * БАЗОВАЯ ДАТА — не украшение, а условие осмысленности. ИЗМЕРЕНО 2026-09-03: без неё правило даёт
420
+ * 236 нарушений из 336 каталогов, а в окне «последние 7 дней» — 48 из 85. Проверка, изобретающая
421
+ * полсотни нарушений в первый день, учит людей себя игнорировать, то есть хуже отсутствующей.
422
+ * База делает правило зелёным на приходе и красным ровно на новом.
423
+ *
424
+ * ДАТА ПЕРВОГО КОММИТА, А НЕ mtime. Время правки меняет любой посторонний процесс — пересборка,
425
+ * перенос, чтение с обновлением. Дата появления каталога в истории неподвижна. ЧЕСТНАЯ ГРАНИЦА:
426
+ * функция ДОВЕРЯЕТ переданной строке и происхождение её не подтверждает — обязанность подать
427
+ * именно git-дату лежит на вызывающем (сбор фактов в cli.ts). Здесь проверяется только то, что
428
+ * строка вообще разбирается в дату.
429
+ *
430
+ * ЧТО ИМЕННО ПРОВЕРЯЕТСЯ В ОГОВОРКЕ — сказано точно, потому что ревью 2026-09-03 поймало
431
+ * расхождение обещания с кодом. Машинно проверяется РОВНО одно: строка непуста после обрезки
432
+ * пробелов. Осмысленность причины машинно не проверяема, и оговорка `x` пройдёт. Это сознательная
433
+ * граница слоя: гейт заставляет РЕШЕНИЕ БЫТЬ ЗАПИСАННЫМ, а качество формулировки остаётся делом
434
+ * человека — ровно как у освобождений заставы секретов и списка исключений дрейфа.
435
+ *
436
+ * БАЗА ВКЛЮЧИТЕЛЬНА: каталог, заведённый В САМ день базы, правилом контролируется («не раньше
437
+ * базы», а не «после базы»).
438
+ *
439
+ * ЧЕГО ЭТА ПРОВЕРКА НЕ ЛОВИТ, названо честно: работу БЕЗ каталога фичи — разбор, ремонт, рой,
440
+ * обещание «вернёмся». Машинного следа у них нет, и они остаются на слое 2 (текст правила в
441
+ * CLAUDE.md). Утверждать, что правило покрыто целиком, было бы ложной гарантией.
442
+ */
443
+ export function backlogCoversFeatures(
444
+ features: readonly { readonly slug: string; readonly createdIso: string; readonly waiver?: string }[],
445
+ backlogTexts: readonly string[],
446
+ baseline: string,
447
+ ): readonly string[] {
448
+ // ДАТЫ СРАВНИВАЮТСЯ ЧИСЛАМИ, А НЕ СТРОКАМИ. Кросс-семейное ревью 2026-09-03 (gpt-5.6-sol, Grade D)
449
+ // предъявило три входа, на которых строковое сравнение ISO даёт неверный ответ, и я воспроизвёл их
450
+ // прогоном: `" "` (пробелы лексикографически меньше любой даты — каталог освобождался),
451
+ // `"2026-09-02T23:30:00-02:00"` (фактически ПОЗЖЕ базы, строково раньше), и разные формы записи
452
+ // одной даты. Разбор в число снимает весь класс разом.
453
+ const baseMs = Date.parse(baseline);
454
+ const blob = backlogTexts.join('\n').toLowerCase();
455
+ const out: string[] = [];
456
+ for (const f of features) {
457
+ if (typeof f.slug !== 'string' || f.slug.trim() === '') continue;
458
+ // НЕРАЗБИРАЕМАЯ ИЛИ ОТСУТСТВУЮЩАЯ ДАТА — НЕ ОСВОБОЖДЕНИЕ. Каталог, чью дату появления не удалось
459
+ // установить, считается новым: это отказ в сторону строгости. Обратный выбор превращал бы порчу
460
+ // входа в способ обойти правило.
461
+ const bornMs = typeof f.createdIso === 'string' ? Date.parse(f.createdIso.trim()) : Number.NaN;
462
+ const born = Number.isFinite(bornMs) ? bornMs : Number.POSITIVE_INFINITY;
463
+ if (Number.isFinite(baseMs) && born < baseMs) continue;
464
+ if (typeof f.waiver === 'string' && f.waiver.trim() !== '') continue;
465
+ if (mentionsSlug(blob, f.slug)) continue;
466
+ out.push(f.slug);
467
+ }
468
+ return out;
469
+ }
470
+
471
+ /**
472
+ * Назван ли slug в тексте КАК ОТДЕЛЬНОЕ СЛОВО.
473
+ *
474
+ * Голый `includes` засчитывал совпадение внутри чужого слова: slug `log` считался покрытым записью
475
+ * «обновить catalog schema», а `api` — записью «починить capitalization report» (примеры из
476
+ * кросс-семейного ревью 2026-09-03, воспроизведены прогоном). Ложное покрытие опаснее ложного
477
+ * срабатывания: оно ТИХО гасит правило ровно там, где оно нужно.
478
+ *
479
+ * Границей считается всё, кроме латинской буквы, цифры, подчёркивания и дефиса — дефис входит в
480
+ * слово, потому что сами slug'и кебабные и `date-layers` не должен совпасть внутри
481
+ * `dashboard-date-layers`.
482
+ */
483
+ function mentionsSlug(haystackLower: string, slug: string): boolean {
484
+ const s = slug.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
485
+ return new RegExp(`(^|[^a-z0-9_-])${s}($|[^a-z0-9_-])`).test(haystackLower);
486
+ }
487
+
344
488
  const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[]> = {
345
489
  'no-workspace-star': (f, sev) => {
346
490
  const out: Violation[] = [];
@@ -353,13 +497,146 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
353
497
  }
354
498
  return out;
355
499
  },
500
+ 'plugin-manifest-audit': (f, sev) => {
501
+ /**
502
+ * ЗНАЧЕНИЕ ЕСТЬ, ТОЛЬКО ЕСЛИ ОНО ВИДНО. `trim()` не убирает нулевой ширины пробел и его
503
+ * родню, поэтому описание из одного `\u200B` проходило как заполненное — назвал независимый
504
+ * ревьюер 2026-09-04. Считается видимым лишь то, что остаётся после удаления пробельных И
505
+ * невидимых символов.
506
+ */
507
+ const visible = (v: unknown): v is string =>
508
+ typeof v === 'string' && v.replace(/[\s\u00a0\u180e\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\ufeff]/g, '') !== '';
509
+ /** Путь состава в сравнимом виде: `./skills/a` и `skills/a` — один каталог, а не два. */
510
+ const normPath = (v: unknown): string | null =>
511
+ typeof v === 'string'
512
+ ? v.normalize('NFC').replace(/^\.\//, '').replace(/\/+$/, '').replace(/\/{2,}/g, '/')
513
+ : null;
514
+ // ПРОВЕРКА ПАСПОРТА НА ВХОДЕ. Правило `marketplace-parity` отвечает на другой вопрос — совпадает
515
+ // ли витрина со свежей регенерацией. Если ГЕНЕРАТОР выдаст пустое описание или версию `1.0`,
516
+ // parity будет доволен: копия совпадает с оригиналом, оба неверны. Здесь проверяется САМО
517
+ // содержимое.
518
+ //
519
+ // ЧЕГО ЗДЕСЬ НЕТ И ПОЧЕМУ: приёма «имя манифеста равно имени каталога». ИЗМЕРЕНО 2026-09-04 на
520
+ // 10 манифестах дерева — расхождение ровно одно и оно НАМЕРЕННОЕ: пакет называется
521
+ // `loop-designer-plugin`, а плагин внутри него — `loop-designer`. Имя плагина есть
522
+ // опубликованная личность и законно отличается от имени каталога, поэтому такая проверка
523
+ // краснела бы на верном коде. Правило, изобретающее нарушения, учит людей себя игнорировать.
524
+ // Ведущие нули запрещены (`01.2.3` — не строгая форма), и значение НЕ обрезается: пробел
525
+ // вокруг версии в манифесте есть дефект манифеста, а не мелочь, которую следует простить.
526
+ const STRICT_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
527
+ const out: Violation[] = [];
528
+ for (const m of f.pluginManifests ?? []) {
529
+ if (m.parseError !== undefined) {
530
+ out.push({ rule: 'plugin-manifest-audit', severity: sev, detail: `${m.path}: манифест не разобрался — ${m.parseError}` });
531
+ continue; // остальные поля у неразобранного манифеста не существуют
532
+ }
533
+ for (const field of ['name', 'description', 'version'] as const) {
534
+ // СОБСТВЕННОЕ свойство: манифест, чьи поля приходят из прототипа, ничего не объявил.
535
+ const value = Object.hasOwn(m, field) ? (m as Record<string, unknown>)[field] : undefined;
536
+ if (!visible(value)) {
537
+ out.push({ rule: 'plugin-manifest-audit', severity: sev, detail: `${m.path}: поле ${field} пусто или отсутствует` });
538
+ }
539
+ }
540
+ const rawVersion = Object.hasOwn(m, 'version') ? (m as Record<string, unknown>)['version'] : undefined;
541
+ if (visible(rawVersion) && !STRICT_SEMVER.test(rawVersion)) {
542
+ out.push({ rule: 'plugin-manifest-audit', severity: sev, detail: `${m.path}: version = ${JSON.stringify(rawVersion)} — нужна строгая форма N.N.N без ведущих нулей и без пробелов` });
543
+ }
544
+ // ИНВЕНТАРИЗАЦИЯ В ОБЕ СТОРОНЫ: всё, что записано в накладной, лежит на складе, И всё, что
545
+ // лежит на складе, вписано в накладную. Одна сторона ловит обещание без товара, другая —
546
+ // товар, о котором никто не узнает. Класс, который эта пара закрывает: «счётчики поправили,
547
+ // файлы не тронули».
548
+ //
549
+ // Обе стороны проверяются ТОЛЬКО когда обе улики есть: манифест без поля `skills` не
550
+ // объявляет состава вовсе, и требовать от него совпадения значило бы выдумать обязательство.
551
+ const declared = Array.isArray(m.declaredSkills) ? m.declaredSkills : undefined;
552
+ const onDisk = Array.isArray(m.skillsOnDisk) ? m.skillsOnDisk : undefined;
553
+ // ПОЛОВИНА УЛИК — НЕ УЛИКИ. Прежде состав, объявленный без осмотра диска, ТИХО пропускался,
554
+ // а правило продолжало числиться проверенным: то самое «успех из тишины», против которого
555
+ // оно и заведено (назвал независимый ревьюер 2026-09-04).
556
+ if ((declared === undefined) !== (onDisk === undefined)) {
557
+ out.push({
558
+ rule: 'plugin-manifest-audit', severity: sev,
559
+ detail: `${m.path}: есть только одна половина улик о составе (${declared !== undefined ? 'объявленное без осмотра диска' : 'осмотр диска без объявленного'}) — сверить нечем`,
560
+ });
561
+ } else if (declared !== undefined && onDisk !== undefined) {
562
+ const normList = (xs: readonly unknown[]): string[] => xs.map(normPath).filter((x): x is string => x !== null && x !== '');
563
+ // Элемент не-строка — не путь. Молча его пропустить значило бы сверять неполные списки.
564
+ const badDeclared = declared.filter((x) => normPath(x) === null).length;
565
+ const badOnDisk = onDisk.filter((x) => normPath(x) === null).length;
566
+ if (badDeclared > 0 || badOnDisk > 0) {
567
+ out.push({ rule: 'plugin-manifest-audit', severity: sev, detail: `${m.path}: в составе есть элементы, которые не являются путями (объявлено ${badDeclared}, на диске ${badOnDisk})` });
568
+ }
569
+ const have = new Set(normList(onDisk));
570
+ const said = new Set(normList(declared));
571
+ const missing = [...said].filter((n) => !have.has(n));
572
+ const undeclared = [...have].filter((n) => !said.has(n));
573
+ if (missing.length > 0) {
574
+ out.push({ rule: 'plugin-manifest-audit', severity: sev, detail: `${m.path}: объявлены, но НЕ найдены на диске: ${missing.join(', ')}` });
575
+ }
576
+ if (undeclared.length > 0) {
577
+ out.push({ rule: 'plugin-manifest-audit', severity: sev, detail: `${m.path}: лежат на диске, но НЕ объявлены: ${undeclared.join(', ')}` });
578
+ }
579
+ }
580
+ }
581
+ return out;
582
+ },
583
+ 'sibling-dep-protocol': (f, sev) => {
584
+ // ПАРА К `no-workspace-star`, а не противоречие ему: они говорят о РАЗНЫХ МОМЕНТАХ. На ДИСКЕ
585
+ // сиблинг-зависимость обязана быть `workspace:` — тогда pnpm подставит реальную версию при
586
+ // паковке. В ОПУБЛИКОВАННОМ манифесте `workspace:` быть не должно — npm отправляет его
587
+ // дословно и ломает установку. Одно правило охраняет вход, другое выход.
588
+ //
589
+ // ОБЪЁМ СУЖЕН ИЗМЕРЕНИЕМ, а не осторожностью. Запись бэклога требовала протокол для ЛЮБОЙ
590
+ // сиблинг-зависимости. Замер 2026-09-03 (56 пакетов, 60 сиблинг-зависимостей) показал 8
591
+ // исключений — и ВСЕ восемь оказались `peerDependencies` (7) и `optionalDependencies` (1), где
592
+ // диапазон и есть смысл записи: peer-зависимость объявляет, чему обязан удовлетворять
593
+ // ПОТРЕБИТЕЛЬ, а `workspace:` при паковке превращается в точный пин, враждебный потребителю.
594
+ // Одна из восьми — намеренная схема «dz как ОБНАРУЖИВАЕМАЯ необязательная зависимость».
595
+ // То есть правило в исходной формулировке сломало бы работающий замысел; здесь оно охраняет
596
+ // ровно те два поля, где протокол уместен, и на них нарушений сегодня НОЛЬ.
597
+ const out: Violation[] = [];
598
+ for (const d of f.siblingDeps ?? []) {
599
+ // Поля читаются как ПРИМИТИВНЫЕ строки. Объект с унаследованным `startsWith`, всегда
600
+ // возвращающим true, прежде проходил мимо правила — назвал независимый ревьюер 2026-09-04.
601
+ const field = typeof d?.field === 'string' ? d.field : '';
602
+ const spec = typeof d?.spec === 'string' ? d.spec : null;
603
+ if (field !== 'dependencies' && field !== 'devDependencies') continue;
604
+ if (spec === null) {
605
+ out.push({ rule: 'sibling-dep-protocol', severity: sev, detail: `${String(d?.name)}: ${field}.${String(d?.dep)} — спецификатор не строка, сверить нечем` });
606
+ continue;
607
+ }
608
+ if (spec.startsWith('workspace:')) continue;
609
+ out.push({
610
+ rule: 'sibling-dep-protocol',
611
+ severity: sev,
612
+ detail: `${d.name}: ${field}.${d.dep} = "${spec}" — a sibling package must be referenced through the workspace: protocol on disk, so pnpm substitutes the real version at pack time`,
613
+ });
614
+ }
615
+ return out;
616
+ },
356
617
  'no-skill-drift': (f, sev) => {
357
618
  const drifted = Array.isArray(f.drift) ? f.drift.filter((d) => typeof d === 'string') : [];
358
619
  return drifted.length === 0 ? [] : [{ rule: 'no-skill-drift', severity: sev, detail: `${drifted.length} skill(s) drift between copies: ${drifted.slice(0, 8).join(', ')}${drifted.length > 8 ? '…' : ''} — heal with dz sync-canonical` }];
359
620
  },
621
+ 'backlog-covers-features': (f, sev) => {
622
+ const ev = f.featureBacklog;
623
+ if (!ev) return [];
624
+ const uncovered = backlogCoversFeatures(ev.features, ev.backlogTexts, ev.baseline);
625
+ if (uncovered.length === 0) return [];
626
+ return [{
627
+ rule: 'backlog-covers-features',
628
+ severity: sev,
629
+ detail: `${uncovered.length} фич(и) заведены после ${ev.baseline} и не названы ни одной записью бэклога: `
630
+ + `${uncovered.slice(0, 8).join(', ')}${uncovered.length > 8 ? '…' : ''}`
631
+ + ` — заведи запись (dz backlog add) ЛИБО впиши оговорку в features/<slug>/README.md строкой`
632
+ + ` "Backlog: не заведено — <причина>". Оговорка без причины не считается.`,
633
+ }];
634
+ },
360
635
  'no-secrets': (f, sev) => {
361
636
  const out: Violation[] = [];
637
+ const waived = secretWaiverState(f.secretWaivers).waived;
362
638
  for (const t of f.secretTargets ?? []) {
639
+ if (waived.has(t.label)) continue;
363
640
  for (const hit of scanSecrets(t.text)) {
364
641
  out.push({ rule: 'no-secrets', severity: sev, detail: `${t.label}: looks like a ${hit.name} — do not teach/publish a credential` });
365
642
  }
@@ -510,6 +787,40 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
510
787
  detail: `${drifted.length} AGENTS.md policy section(s) are out of sync: ${drifted.slice(0, 8).join(', ')}${drifted.length > 8 ? '…' : ''} — heal with: dz agents-sync`,
511
788
  }];
512
789
  },
790
+ /**
791
+ * The fire-and-forget wrapper returns a DISPATCH STUB, so a stage whose deliverable is its
792
+ * return value gets a receipt instead of an answer. MEASURED 2026-08-31: eight stages of one
793
+ * research swarm each returned "Codex Task started in the background as task-…", downstream
794
+ * agents built on those stubs, and no artifact was produced. The misuse is visible in the
795
+ * program text — the stage's result is assigned to a name that a later prompt interpolates —
796
+ * so it belongs on layer 1 rather than in a rule nobody re-reads.
797
+ */
798
+ 'codex-wrapper-for-value-stage': (f, sev) => {
799
+ const scripts = f.workflowScripts;
800
+ if (scripts === undefined || scripts.length === 0) return [];
801
+ const out: Violation[] = [];
802
+ for (const s of scripts) {
803
+ const text = String(s.text ?? '');
804
+ // Find `const <name> = await agent(… codex:codex-rescue …)` and ask whether <name> is later
805
+ // interpolated into another prompt. Assignment alone is not the defect: a stage may keep its
806
+ // handle for logging. Consumption in a prompt is what proves the VALUE was the deliverable.
807
+ const re = /(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*await\s+agent\(([\s\S]{0,4000}?)\)\s*(?:\n|;)/g;
808
+ for (const m of text.matchAll(re)) {
809
+ const name = String(m[1]);
810
+ const call = String(m[2]);
811
+ if (!/codex:codex-rescue/.test(call)) continue;
812
+ const consumed = new RegExp('\\$\\{\\s*(?:String\\()?' + name.replace(/[.*+?^{}()|[\]\\]/g, '\\$&') + '\\b');
813
+ if (consumed.test(text)) {
814
+ out.push({
815
+ rule: 'codex-wrapper-for-value-stage',
816
+ severity: sev,
817
+ detail: `${s.path}: stage "${name}" is routed to codex:codex-rescue AND its result is interpolated into another prompt — the wrapper returns a dispatch stub, so that prompt would receive a receipt, not an answer. Invoke codex synchronously (dz codex / codex exec) for a stage whose deliverable is its return value.`,
818
+ });
819
+ }
820
+ }
821
+ }
822
+ return out;
823
+ },
513
824
  'lockfile-in-sync': (f, _sev) => {
514
825
  // The 2026-07-28 CI break, mechanized: an overnight dep bump edited package.json and left
515
826
  // pnpm-lock.yaml stale, so `pnpm install --frozen-lockfile` died with ERR_PNPM_OUTDATED_LOCKFILE.
@@ -598,6 +909,20 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
598
909
  },
599
910
  };
600
911
 
912
+ /** Per-rule evidence predicates. No entry preserves the rule's existing checked behaviour exactly. */
913
+ const HAS_INPUT: Partial<Record<string, (f: GuardFacts) => boolean>> = {
914
+ // `!== undefined` пропускал `null`: правило объявлялось проверенным и возвращало чисто по
915
+ // ветке «улик нет». Это буквально отсутствие улик, отчитанное как проверка (назвал независимый
916
+ // ревьюер 2026-09-04).
917
+ 'backlog-covers-features': (f) => typeof f.featureBacklog === 'object' && f.featureBacklog !== null,
918
+ // Страж без улик не выдумывает вердикт: в дереве без сиблинг-зависимостей правилу нечего
919
+ // сказать, и «прошло» тут значило бы «не смотрели».
920
+ 'sibling-dep-protocol': (f) => Array.isArray(f.siblingDeps) && f.siblingDeps.length > 0,
921
+ // Дерево без плагин-манифестов правилу нечего сказать: «прошло» тут значило бы «не смотрели».
922
+ 'plugin-manifest-audit': (f) => Array.isArray(f.pluginManifests) && f.pluginManifests.length > 0,
923
+ 'no-secrets': (f) => Array.isArray(f.secretTargets) && f.secretTargets.length > 0,
924
+ };
925
+
601
926
  /**
602
927
  * Rules that may NEVER be promoted to HARD, whatever a config says. A rule whose evidence comes from a
603
928
  * deliberately tolerant parser must not be able to BLOCK an operation: the parser's own design admits it
@@ -700,6 +1025,7 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
700
1025
  );
701
1026
  const violations: Violation[] = [];
702
1027
  const checked: string[] = [];
1028
+ const notEstablished: string[] = [];
703
1029
  const observations: GuardObservation[] = [];
704
1030
  let volumeResult: VolumeShadowResult | undefined;
705
1031
  let volumeEvaluated = false;
@@ -717,6 +1043,27 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
717
1043
  return volumeResult;
718
1044
  };
719
1045
  for (const r of active) {
1046
+ const hasInput = HAS_INPUT[r.id];
1047
+ // ПРЕДИКАТ УЛИК ИСПОЛНЯЕТСЯ ПОД ЗАЩИТОЙ. Прежде он стоял вне `try`, и факт с бросающим
1048
+ // геттером ронял весь страж вместо того, чтобы стать отказом (назвал независимый ревьюер
1049
+ // 2026-09-04). Гейт, падающий на враждебном входе, не даёт вердикта вообще.
1050
+ let inputPresent: boolean;
1051
+ try {
1052
+ inputPresent = hasInput === undefined ? true : hasInput(facts);
1053
+ } catch (error) {
1054
+ violations.push({
1055
+ rule: r.id, severity: r.severity,
1056
+ detail: `улики правила нечитаемы (${error instanceof Error ? error.message : String(error)}) — правило считается НАРУШЕННЫМ, а не пройденным`,
1057
+ });
1058
+ checked.push(r.id);
1059
+ continue;
1060
+ }
1061
+ if (!inputPresent) {
1062
+ // A rule with nothing to measure cannot produce a positive receipt. Keep the verdict unchanged,
1063
+ // but record the missing input explicitly instead of calling the rule checked.
1064
+ notEstablished.push(r.id);
1065
+ continue;
1066
+ }
720
1067
  checked.push(r.id);
721
1068
  if ((VOLUME_SHADOW_RULE_IDS as readonly string[]).includes(r.id)) {
722
1069
  const emission = volume();
@@ -755,6 +1102,16 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
755
1102
  notes.push(`no-stubs: ${skipped} changed scannable file(s) not scanned (deleted/oversize/unreadable/beyond the file cap) — the stub scan is fail-open, so this is a coverage gap on the record, not a violation`);
756
1103
  }
757
1104
  }
1105
+ if (checked.includes('no-secrets') || notEstablished.includes('no-secrets')) {
1106
+ const reasonless = secretWaiverState(facts.secretWaivers).reasonless;
1107
+ if (reasonless > 0) {
1108
+ notes.push(`no-secrets: ${reasonless} reasonless secret waiver(s) ignored — add a non-empty reason or remove the entry`);
1109
+ }
1110
+ const skipped = facts.secretScan?.skipped;
1111
+ if (typeof skipped === 'number' && Number.isFinite(skipped) && skipped > 0) {
1112
+ notes.push(`no-secrets: ${skipped} packed inventory item(s) not scanned (oversize/unreadable/binary) — the secret scan is fail-open, so this is a coverage gap on the record, not a violation`);
1113
+ }
1114
+ }
758
1115
  if (checked.includes('review-round') && facts.reviewRound?.gathered === false) {
759
1116
  // A HARD gate that passes SILENTLY when it could not gather its evidence is a gate you cannot
760
1117
  // tell from one that checked and approved (raised by cross-family review). It still does not
@@ -781,6 +1138,7 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
781
1138
  verdict,
782
1139
  violations,
783
1140
  checked,
1141
+ notEstablished,
784
1142
  ...(notes.length > 0 ? { notes } : {}),
785
1143
  ...(observations.length > 0 ? { observations } : {}),
786
1144
  };
@@ -0,0 +1,44 @@
1
+ // Where THIS harness-core installation lives on disk (feature `destructive-command-guard`, task T8,
2
+ // cross-family review round 3, both P1 findings).
3
+ //
4
+ // WHY A GUARD NEEDS TO KNOW ITS OWN ADDRESS. Both emitted hook bodies — the Claude PreToolUse hook
5
+ // and the Codex veto helper — are files that run OUTSIDE this package: one in a consumer's
6
+ // `.claude/hooks/`, one in `$CODEX_HOME/dz-hooks/`. They cannot `require('@dzhechkov/harness-core')`,
7
+ // because Node resolves a bare specifier from the FILE's location, and neither location has the
8
+ // package above it. So they resolve by PATH — and the paths they were given were all relative to the
9
+ // project being guarded. MEASURED 2026-09-05, the documented global-install workflow
10
+ // (`npm install -g @dzhechkov/harness-cli`, then a target repo with no local install):
11
+ // every candidate → ERR_MODULE_NOT_FOUND, helper exits 0, `rm -rf .agentic-qe` ALLOWED.
12
+ // The guard was advertised, installed, trusted — and inert. The tests hid it by symlinking the
13
+ // package into the temp project, which is exactly the condition a real target repo does not have.
14
+ //
15
+ // The fix is to bake the EMITTING installation's absolute path into the body at emission time, as a
16
+ // FALLBACK after the project-local candidates: a consumer that has its own harness-core keeps using
17
+ // its own copy, and everyone else reaches the installation that wrote the hook.
18
+
19
+ import { dirname } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ /**
23
+ * The directory holding this module's own compiled siblings.
24
+ *
25
+ * In a published install that is `<pkg>/dist`; under vitest, where the SOURCE is executed, it is
26
+ * `<pkg>/src`. Callers that need a BUILT sibling must therefore ask for {@link harnessCoreDistDir},
27
+ * which normalises the second case — a body that baked `<pkg>/src/foo.js` would be a path that
28
+ * exists in no installation at all.
29
+ */
30
+ export function harnessCoreModuleDir(): string {
31
+ return dirname(fileURLToPath(import.meta.url));
32
+ }
33
+
34
+ /**
35
+ * The directory the emitted hook bodies must point at: the one holding the BUILT modules.
36
+ *
37
+ * `src` → its sibling `dist`, anything else → itself. The mapping is deliberately this narrow: the
38
+ * only two layouts that exist are "running the build" and "running the sources under the test
39
+ * runner", and inventing a search would turn a fact into a guess.
40
+ */
41
+ export function harnessCoreDistDir(): string {
42
+ const dir = harnessCoreModuleDir();
43
+ return dir.endsWith('/src') || dir.endsWith('\\src') ? `${dir.slice(0, -3)}dist` : dir;
44
+ }