@dzhechkov/harness-core 0.8.11 → 0.8.21

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 (205) hide show
  1. package/.dz-manifest.json +399 -139
  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/course-staleness.d.ts +19 -0
  46. package/dist/course-staleness.d.ts.map +1 -0
  47. package/dist/course-staleness.js +95 -0
  48. package/dist/course-staleness.js.map +1 -0
  49. package/dist/destructive-guard-hook.d.ts +40 -0
  50. package/dist/destructive-guard-hook.d.ts.map +1 -0
  51. package/dist/destructive-guard-hook.js +109 -0
  52. package/dist/destructive-guard-hook.js.map +1 -0
  53. package/dist/destructive-guard.d.ts +27 -0
  54. package/dist/destructive-guard.d.ts.map +1 -0
  55. package/dist/destructive-guard.js +2808 -0
  56. package/dist/destructive-guard.js.map +1 -0
  57. package/dist/discrimination-gate.d.ts +28 -3
  58. package/dist/discrimination-gate.d.ts.map +1 -1
  59. package/dist/discrimination-gate.js +76 -16
  60. package/dist/discrimination-gate.js.map +1 -1
  61. package/dist/feature-adr-routing.d.ts +95 -1
  62. package/dist/feature-adr-routing.d.ts.map +1 -1
  63. package/dist/feature-adr-routing.js +193 -27
  64. package/dist/feature-adr-routing.js.map +1 -1
  65. package/dist/guard.d.ts +90 -0
  66. package/dist/guard.d.ts.map +1 -1
  67. package/dist/guard.js +271 -0
  68. package/dist/guard.js.map +1 -1
  69. package/dist/harness-core-location.d.ts +18 -0
  70. package/dist/harness-core-location.d.ts.map +1 -0
  71. package/dist/harness-core-location.js +42 -0
  72. package/dist/harness-core-location.js.map +1 -0
  73. package/dist/index.d.ts +24 -6
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +24 -3
  76. package/dist/index.js.map +1 -1
  77. package/dist/lead-shift-gate.d.ts +95 -0
  78. package/dist/lead-shift-gate.d.ts.map +1 -0
  79. package/dist/lead-shift-gate.js +100 -0
  80. package/dist/lead-shift-gate.js.map +1 -0
  81. package/dist/ledger-backfill.d.ts +11 -1
  82. package/dist/ledger-backfill.d.ts.map +1 -1
  83. package/dist/ledger-backfill.js +19 -0
  84. package/dist/ledger-backfill.js.map +1 -1
  85. package/dist/loop-blobs.generated.d.ts +1 -1
  86. package/dist/loop-blobs.generated.d.ts.map +1 -1
  87. package/dist/loop-blobs.generated.js +12 -3
  88. package/dist/loop-blobs.generated.js.map +1 -1
  89. package/dist/loop-lint.d.ts.map +1 -1
  90. package/dist/loop-lint.js +56 -7
  91. package/dist/loop-lint.js.map +1 -1
  92. package/dist/loop-plan-graph.d.ts +1 -3
  93. package/dist/loop-plan-graph.d.ts.map +1 -1
  94. package/dist/loop-plan-graph.js +70 -1
  95. package/dist/loop-plan-graph.js.map +1 -1
  96. package/dist/loop-trace.d.ts.map +1 -1
  97. package/dist/loop-trace.js +16 -2
  98. package/dist/loop-trace.js.map +1 -1
  99. package/dist/managed-hooks.d.ts +5 -6
  100. package/dist/managed-hooks.d.ts.map +1 -1
  101. package/dist/managed-hooks.js +2 -2
  102. package/dist/managed-hooks.js.map +1 -1
  103. package/dist/model-recommender.d.ts.map +1 -1
  104. package/dist/model-recommender.js +14 -3
  105. package/dist/model-recommender.js.map +1 -1
  106. package/dist/operations.d.ts.map +1 -1
  107. package/dist/operations.js +82 -0
  108. package/dist/operations.js.map +1 -1
  109. package/dist/patterns.d.ts +23 -0
  110. package/dist/patterns.d.ts.map +1 -1
  111. package/dist/patterns.js +10 -0
  112. package/dist/patterns.js.map +1 -1
  113. package/dist/publish.d.ts +11 -0
  114. package/dist/publish.d.ts.map +1 -1
  115. package/dist/publish.js +16 -2
  116. package/dist/publish.js.map +1 -1
  117. package/dist/registry.d.ts.map +1 -1
  118. package/dist/registry.js +3 -2
  119. package/dist/registry.js.map +1 -1
  120. package/dist/score.d.ts.map +1 -1
  121. package/dist/score.js +15 -4
  122. package/dist/score.js.map +1 -1
  123. package/dist/session-retro.d.ts +121 -2
  124. package/dist/session-retro.d.ts.map +1 -1
  125. package/dist/session-retro.js +454 -11
  126. package/dist/session-retro.js.map +1 -1
  127. package/dist/setup.d.ts +28 -0
  128. package/dist/setup.d.ts.map +1 -1
  129. package/dist/setup.js +223 -6
  130. package/dist/setup.js.map +1 -1
  131. package/dist/sign.d.ts.map +1 -1
  132. package/dist/sign.js +18 -1
  133. package/dist/sign.js.map +1 -1
  134. package/dist/skills-verify.d.ts +34 -1
  135. package/dist/skills-verify.d.ts.map +1 -1
  136. package/dist/skills-verify.js +82 -0
  137. package/dist/skills-verify.js.map +1 -1
  138. package/dist/stage-line.d.ts +68 -0
  139. package/dist/stage-line.d.ts.map +1 -0
  140. package/dist/stage-line.js +129 -0
  141. package/dist/stage-line.js.map +1 -0
  142. package/dist/statusline.d.ts +99 -0
  143. package/dist/statusline.d.ts.map +1 -1
  144. package/dist/statusline.js +310 -44
  145. package/dist/statusline.js.map +1 -1
  146. package/dist/store-counts.d.ts +26 -0
  147. package/dist/store-counts.d.ts.map +1 -0
  148. package/dist/store-counts.js +125 -0
  149. package/dist/store-counts.js.map +1 -0
  150. package/dist/store-guard.d.ts +106 -0
  151. package/dist/store-guard.d.ts.map +1 -0
  152. package/dist/store-guard.js +294 -0
  153. package/dist/store-guard.js.map +1 -0
  154. package/dist/swarm-brief.d.ts +95 -0
  155. package/dist/swarm-brief.d.ts.map +1 -0
  156. package/dist/swarm-brief.js +660 -0
  157. package/dist/swarm-brief.js.map +1 -0
  158. package/dist/trace-bundle.d.ts +8 -0
  159. package/dist/trace-bundle.d.ts.map +1 -1
  160. package/dist/trace-bundle.js +11 -0
  161. package/dist/trace-bundle.js.map +1 -1
  162. package/package.json +12 -11
  163. package/sbom.json +804 -154
  164. package/src/agents-policy.ts +46 -2
  165. package/src/amendment-trace.ts +441 -19
  166. package/src/backlog-public.ts +503 -0
  167. package/src/backlog-transitions.ts +77 -0
  168. package/src/backlog.ts +12 -0
  169. package/src/claim-check.ts +25 -2
  170. package/src/claude-hooks-assets.ts +227 -0
  171. package/src/clean-room-smoke.ts +195 -0
  172. package/src/cmd-usage.ts +29 -5
  173. package/src/codex-hooks-assets.ts +140 -30
  174. package/src/codex-hooks.ts +21 -1
  175. package/src/course-staleness.ts +125 -0
  176. package/src/destructive-guard-hook.ts +151 -0
  177. package/src/destructive-guard.ts +3027 -0
  178. package/src/discrimination-gate.ts +98 -19
  179. package/src/feature-adr-routing.ts +220 -22
  180. package/src/guard.ts +318 -0
  181. package/src/harness-core-location.ts +44 -0
  182. package/src/index.ts +111 -3
  183. package/src/lead-shift-gate.ts +145 -0
  184. package/src/ledger-backfill.ts +20 -1
  185. package/src/loop-blobs.generated.ts +12 -3
  186. package/src/loop-lint.ts +52 -7
  187. package/src/loop-plan-graph.ts +66 -1
  188. package/src/loop-trace.ts +13 -1
  189. package/src/managed-hooks.ts +5 -6
  190. package/src/model-recommender.ts +14 -3
  191. package/src/operations.ts +75 -0
  192. package/src/patterns.ts +33 -0
  193. package/src/publish.ts +27 -2
  194. package/src/registry.ts +3 -2
  195. package/src/score.ts +16 -4
  196. package/src/session-retro.ts +466 -11
  197. package/src/setup.ts +246 -9
  198. package/src/sign.ts +18 -1
  199. package/src/skills-verify.ts +99 -1
  200. package/src/stage-line.ts +151 -0
  201. package/src/statusline.ts +396 -47
  202. package/src/store-counts.ts +154 -0
  203. package/src/store-guard.ts +388 -0
  204. package/src/swarm-brief.ts +661 -0
  205. package/src/trace-bundle.ts +10 -0
@@ -207,6 +207,22 @@ const REPRODUCER_HINTS = [
207
207
  'test output', 'coverage report', 'measured on',
208
208
  ];
209
209
 
210
+ /**
211
+ * ЯКОРЬ СТРАНИЦЫ ПЕРВОИСТОЧНИКА — ЭТО ПРОВЕНАНС, а не украшение.
212
+ *
213
+ * Числу, взятому из книги, страница даёт то же, что репродьюсер даёт измерению: адрес, по которому
214
+ * его можно проверить. Требовать рядом `MEASURED` бессмысленно — мы ничего не мерили, мы
215
+ * процитировали; а `CLAIMED` было бы слабее правды, потому что источник НАЗВАН точнее, чем словом.
216
+ *
217
+ * ИЗМЕРЕНО 2026-09-04 на книжных пакетах: 333 находки, из них 86 несут якорь на самой строке и ещё
218
+ * 58 — в абзаце, то есть 144 из 333 (43%) — ложные. Их цена не в них самих: автор, видящий звон на
219
+ * каждую цитату, перестаёт реагировать на сигнализацию, и она пропустит настоящую находку.
220
+ *
221
+ * ГРАНИЦА ОСЛАБЛЕНИЯ, названная явно: якорь снимает требование ТЕГА и ничего больше. Утверждение
222
+ * «идеальная точность / 100%» он НЕ лицензирует — та ветка стоит выше и до якоря не доходит.
223
+ */
224
+ const PAGE_ANCHOR_RE = /\[\s*(?:p|pp|с|стр)\.?\s?\d+/i;
225
+
210
226
  const PERCENT_RE = /\b(\d{1,3}(?:\.\d+)?)\s?%/g;
211
227
  // "perfect" / "100%" framing is the specific retracted claim — always high severity.
212
228
  // NOTE: no trailing \b after "%": "%"→" " is non-word→non-word, so a trailing \b
@@ -286,8 +302,15 @@ export function claimCheck(text: string): ClaimCheckResult {
286
302
  // number and is not a taggable claim (ADR-263 F11).
287
303
  if (!hasPercent && !HAS_NUMBER_RE.test(lower.replace(LABEL_TOKEN_RE, ' '))) return;
288
304
 
289
- // A metric/percent with no honesty tag at all.
290
- if (!tagged) {
305
+ // A metric/percent with no honesty tag at all — unless the paragraph carries a SOURCE PAGE
306
+ // anchor, which is provenance of a different and stricter kind (see PAGE_ANCHOR_RE).
307
+ // ЯКОРЬ НЕ ЛИЦЕНЗИРУЕТ РАМКУ «ИДЕАЛЬНО / 100%» — и проверяется это по ФОРМЕ строки, а не по
308
+ // ветке `perfect` выше: та требует РАСПОЗНАННОГО метрического слова, а словарь метрик
309
+ // англоязычный. Русское «100% точности [p.77]» до этой оговорки уходило в тишину: ветка
310
+ // `perfect` его не видела, а якорь снимал требование тега. Поймано СВОИМ ЖЕ тестом.
311
+ const perfectShape = PERFECT_WORD_RE.test(line) || PERFECT_PCT_RE.test(line);
312
+ const pageAnchored = PAGE_ANCHOR_RE.test(para) && !perfectShape;
313
+ if (!tagged && !pageAnchored) {
291
314
  findings.push({
292
315
  severity: 'medium',
293
316
  line: i + 1,
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The Claude-side destructive-command guard, as an INSTALLABLE asset
3
+ * (feature `destructive-command-guard`, task T8, cross-family review round 3, finding P1-1).
4
+ *
5
+ * ## Why this file exists
6
+ *
7
+ * The guard shipped as one file committed at this repository's root, registered in this
8
+ * repository's `.claude/settings.json`. MEASURED 2026-09-05, running the documented command in a
9
+ * clean project — `dz setup --target claude-code --project <tmp>`:
10
+ * the written `.claude/settings.json` carries SessionStart / SessionEnd / PreCompact and no
11
+ * `PreToolUse` key at all, and `.claude/hooks/` does not exist.
12
+ * So the advertised Claude-side guard protected exactly one checkout: ours. A guard that only ever
13
+ * guards its author is a demo.
14
+ *
15
+ * Setup therefore has to install BOTH halves into the consumer project — the body and the registry
16
+ * entry — and the body must come from the INSTALLED package, never from a path inside our
17
+ * repository, which a consumer does not have.
18
+ *
19
+ * ## Why the body carries an absolute path
20
+ *
21
+ * The emitted hook runs from `<consumer>/.claude/hooks/`, where `require('@dzhechkov/harness-core')`
22
+ * cannot resolve: Node resolves a bare specifier upward from the FILE, and the package is not above
23
+ * that file in a global install. The body therefore resolves by PATH, and the LAST candidate is the
24
+ * absolute location of the installation that emitted it (see `harness-core-location.ts` for the
25
+ * measurement that forced this). Project-local candidates come FIRST, so a consumer that installed
26
+ * harness-core itself keeps using its own copy and is never pinned to ours.
27
+ *
28
+ * The generated text is behaviourally identical to this repository's own
29
+ * `.claude/hooks/destructive-guard.cjs`, and a test drives BOTH bodies through the same matrix
30
+ * rather than trusting that they stayed alike.
31
+ *
32
+ * @packageDocumentation
33
+ */
34
+
35
+ import { harnessCoreDistDir } from './harness-core-location.js';
36
+
37
+ /** Bump when the emitted BODY changes. Unlike Codex, Claude has no trust gate — this is provenance. */
38
+ export const DZ_CLAUDE_HOOK_VERSION = 1;
39
+
40
+ /**
41
+ * The OWNERSHIP marker. A file carrying this line is one dz wrote and may replace; a file without it
42
+ * belongs to the consumer and is never overwritten without `--force`.
43
+ *
44
+ * MEASURED 2026-09-05, before this existed: a hand-authored
45
+ * `.claude/hooks/destructive-guard.cjs` was replaced by a routine `dz setup` run, no `--force`
46
+ * involved — the one path in setup that was not additive. Ownership is asserted by a MARKER rather
47
+ * than by a hash of the bytes we wrote, because an UPGRADE must still be able to replace an older
48
+ * dz body; the cost of that choice is stated in the emitted file itself, on the marker line.
49
+ */
50
+ export const CLAUDE_DESTRUCTIVE_HOOK_MARKER = 'dz-managed-hook: destructive-guard';
51
+
52
+ /** Is this file body one dz owns? Absence of the marker is the consumer's claim to the file. */
53
+ export function isDzManagedHookBody(body: string): boolean {
54
+ return body.includes(CLAUDE_DESTRUCTIVE_HOOK_MARKER);
55
+ }
56
+
57
+ /** Where the body is installed inside a consumer project, and what the registry entry points at. */
58
+ export const CLAUDE_DESTRUCTIVE_HOOK_RELPATH = '.claude/hooks/destructive-guard.cjs';
59
+
60
+ /**
61
+ * The command Claude Code runs — the EXACT documented placeholder, never a shell-only variant.
62
+ *
63
+ * Relative to the project rather than absolute: the settings file is committed by consumers and
64
+ * shared across machines, and an absolute path in it breaks for every collaborator. The BODY may
65
+ * carry an absolute fallback because it is generated per machine; the SETTINGS entry may not.
66
+ *
67
+ * WHY THE EXACT TOKEN [DOC, code.claude.com/docs/en/hooks, read 2026-09-06]. `${CLAUDE_PROJECT_DIR}`
68
+ * is a PLACEHOLDER Claude Code substitutes itself — "Use these placeholders to reference hook
69
+ * scripts relative to the project or plugin root, regardless of the working directory when the hook
70
+ * runs: ${CLAUDE_PROJECT_DIR}: the project root where the session started" — and the documentation
71
+ * describes NO default syntax. The previous `${CLAUDE_PROJECT_DIR:-.}` is therefore not the
72
+ * placeholder at all; it worked only where a POSIX shell happened to expand the variable, and on a
73
+ * host where Claude Code's own substitution is what fills the token in, it stays literal and the
74
+ * hook cannot be found. NOT REPRODUCED LIVE: there is no Windows host here, so the Windows half of
75
+ * this is documentation plus the reviewer's report, not a measurement of mine.
76
+ *
77
+ * MEASURED here (POSIX `sh`, 2026-09-06), which is the part that IS checkable: with the variable
78
+ * set the two forms are identical (`/tmp/x/p`); with it unset the exact token yields `/p` while the
79
+ * old form yielded `./p`. So on the only path this change can degrade — a hand-run command with no
80
+ * variable and no substitution — the failure is a loud "cannot find module", never a silent pass.
81
+ * The fallback that matters (finding the DECIDER) lives inside the body, where it can actually run.
82
+ */
83
+ export const CLAUDE_DESTRUCTIVE_HOOK_COMMAND =
84
+ 'node "${CLAUDE_PROJECT_DIR}/' + CLAUDE_DESTRUCTIVE_HOOK_RELPATH + '"';
85
+
86
+ /** The PreToolUse matcher. Bash only — the decider skips every other tool without reading it. */
87
+ export const CLAUDE_DESTRUCTIVE_HOOK_MATCHER = '^Bash$';
88
+
89
+ export interface ClaudeHookAssetOptions {
90
+ /**
91
+ * Absolute directory holding the BUILT harness-core modules, baked in as the last resort.
92
+ * Defaults to the installation generating the file. `null` omits it — used for a body that is
93
+ * committed to a repository, where a machine-specific path would be wrong for everyone else.
94
+ */
95
+ readonly installedDistDir?: string | null | undefined;
96
+ }
97
+
98
+ /**
99
+ * The emitted `.claude/hooks/destructive-guard.cjs`.
100
+ *
101
+ * Thin by contract: read stdin, hand the payload to `decideDestructiveHook`, print, exit. Runtime
102
+ * failures of ours exit 0 with one loud line, but a deterministically missing decision module exits
103
+ * 2: setup cannot leave an armed-looking hook that allows every command until somebody builds it.
104
+ */
105
+ export function generateClaudeDestructiveHook(opts: ClaudeHookAssetOptions = {}): string {
106
+ const installed = opts.installedDistDir === undefined ? harnessCoreDistDir() : opts.installedDistDir;
107
+ const bakedLine =
108
+ installed === null
109
+ ? ' // No installed path was baked in (a body committed to a repository): project-local only.\n'
110
+ : ` candidates.push(${JSON.stringify(installed)} + '/destructive-guard-hook.js'); // the installation that emitted this file\n`;
111
+
112
+ return `#!/usr/bin/env node
113
+ // GENERATED by @dzhechkov/harness-core — dz-claude-hook-version: ${DZ_CLAUDE_HOOK_VERSION}
114
+ // ${CLAUDE_DESTRUCTIVE_HOOK_MARKER}
115
+ // Do not edit: this marker line is what tells dz the file is ITS OWN, so dz rewrites it on the next
116
+ // 'dz setup --target claude-code'. Delete the marker line to claim the file: dz then leaves it
117
+ // alone and only '--force' replaces it, after a timestamped backup.
118
+ //
119
+ // PreToolUse guard on Bash: a deletion by a LITERAL path into a protected store never runs.
120
+ // All of the policy — the three outcomes, the refusal text, the limits the guard must name — lives
121
+ // in @dzhechkov/harness-core (destructive-guard.js classifies, destructive-guard-hook.js decides).
122
+ // The same module decides for the Codex host, so both runtimes refuse the same commands in the
123
+ // same words.
124
+ 'use strict';
125
+
126
+ const path = require('node:path');
127
+ const { writeSync } = require('node:fs');
128
+ const { pathToFileURL } = require('node:url');
129
+
130
+ /**
131
+ * Load the decision module by PATH, project-local first, then the installation that emitted this
132
+ * file. 'import()' and never 'require()': harness-core is "type": "module", and require() of an ES
133
+ * module throws ERR_REQUIRE_ESM below Node 20.19 / 22.12 — which used to be caught and turned into
134
+ * a silent allow.
135
+ */
136
+ async function loadDecider() {
137
+ // CLAUDE_PROJECT_DIR when the host set it, else this file's own location. The env var is the
138
+ // host's own statement of where the project root is; __dirname is what still works when nobody
139
+ // said anything, including when this hook is run by hand.
140
+ const projectRoot = process.env.CLAUDE_PROJECT_DIR || path.resolve(__dirname, '..', '..');
141
+ // DZ_GUARD_TRUSTED_ONLY is set by 'dz setup' while it certifies this file. The probe SPAWNS the
142
+ // hook, so with project-local candidates first it would import — and execute the top level of —
143
+ // whatever a cloned repository committed at packages/@dzhechkov/harness-core/dist/, during setup
144
+ // and outside the host's hook-trust flow. The receipt must certify the TRUSTED installation, so
145
+ // for the probe the project's own copies are switched off. At RUNTIME they stay first: a project
146
+ // that ships its own harness-core is exactly who should decide there.
147
+ const trustedOnly = process.env.DZ_GUARD_TRUSTED_ONLY === '1';
148
+ const candidates = trustedOnly ? [] : [
149
+ path.join(projectRoot, 'node_modules', '@dzhechkov', 'harness-core', 'dist', 'destructive-guard-hook.js'),
150
+ path.join(projectRoot, 'packages', '@dzhechkov', 'harness-core', 'dist', 'destructive-guard-hook.js'),
151
+ ];
152
+ ${bakedLine} const failures = [];
153
+ for (const candidate of candidates) {
154
+ try {
155
+ const mod = await import(pathToFileURL(candidate).href);
156
+ const decide = mod.decideDestructiveHook || (mod.default && mod.default.decideDestructiveHook);
157
+ if (typeof decide === 'function') return { decide: decide, reason: null };
158
+ failures.push(candidate + ': загружен, но decideDestructiveHook не экспортирован');
159
+ } catch (err) {
160
+ const code = (err && err.code) || 'ERR_UNKNOWN';
161
+ failures.push(candidate + ': ' + code + ' ' + String((err && err.message) || err));
162
+ }
163
+ }
164
+ return { decide: null, reason: failures.join(' | ').replace(/\\s*\\n\\s*/g, ' ') };
165
+ }
166
+
167
+ let raw = '';
168
+ process.stdin.on('data', (c) => { raw += c; });
169
+ process.stdin.on('end', () => {
170
+ let payload = null;
171
+ try {
172
+ payload = JSON.parse(raw);
173
+ } catch (_) {
174
+ payload = null; // handed over as-is: the decider owns the "unreadable" wording too.
175
+ }
176
+ loadDecider().then(
177
+ (loaded) => onDecider(loaded, payload),
178
+ (err) => failOpen('страж опасных команд не смог загрузиться: ' + String((err && err.message) || err)),
179
+ );
180
+ });
181
+
182
+ /** One line, exit 0. Every failure of OURS ends here — loudly, and never as a refusal. */
183
+ function failOpen(what) {
184
+ process.stderr.write(
185
+ 'DZ-DESTRUCTIVE-WARN: ' + String(what).replace(/\\s*\\n\\s*/g, ' ') + ' — команда ПРОПУЩЕНА без проверки.\\n',
186
+ );
187
+ process.exit(0);
188
+ }
189
+
190
+ /** One line, exit 2. No decider means the guard installation is incomplete, so nothing may run. */
191
+ function failUnarmed(reason) {
192
+ const line =
193
+ 'DZ-DESTRUCTIVE: страж опасных команд не собран и не может проверить запрос — команда НЕ ВЫПОЛНЕНА. ' +
194
+ 'Причина: ' + String(reason).replace(/\\s*\\n\\s*/g, ' ') +
195
+ ' Починка: npm install -g @dzhechkov/harness-cli@latest, затем dz setup --target claude-code.\\n';
196
+ // Hook stderr is a pipe under the host and under spawnSync. An immediate process.exit can drop a
197
+ // buffered write entirely, so write this installation verdict synchronously before exiting.
198
+ writeSync(2, line);
199
+ process.exit(2);
200
+ }
201
+
202
+ function onDecider(loaded, payload) {
203
+ const decide = loaded.decide;
204
+ if (decide === null) {
205
+ failUnarmed(loaded.reason);
206
+ return;
207
+ }
208
+
209
+ let out = '';
210
+ let code = 0;
211
+ try {
212
+ const decision = decide(payload, 'claude');
213
+ if (!decision || typeof decision.stderr !== 'string') {
214
+ throw new TypeError('решение стража нечитаемо: ' + JSON.stringify(decision));
215
+ }
216
+ out = decision.stderr;
217
+ code = decision.exitCode === 2 ? 2 : 0; // 2 = block the call and show the reason.
218
+ } catch (err) {
219
+ failOpen('страж опасных команд упал (' + String((err && err.message) || err) + ')');
220
+ return;
221
+ }
222
+
223
+ if (out !== '') process.stderr.write(out);
224
+ process.exit(code);
225
+ }
226
+ `;
227
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Чистая комната: проверить пакет так, как его увидит НОВЫЙ пользователь.
3
+ *
4
+ * ЗАЧЕМ. Разработчик забыл включить файл в поставку — на его машине всё работает, потому что файл
5
+ * лежит рядом в репозитории; у покупателя коробка приходит без детали. Класс называется
6
+ * `files[]`/install-layout, и локальный прогон его не ловит ПО ПОСТРОЕНИЮ: он видит рабочее дерево.
7
+ *
8
+ * ЧЕСТНАЯ ГРАНИЦА, названная сразу и повторённая в вердикте: «в чистой комнате завелось» НЕ РАВНО
9
+ * «Claude Code пакет увидел». ИЗМЕРЕНО ранее: health-advisor 1.2.0 был зелёным в чистой комнате на
10
+ * пакете, чьи навыки не регистрировались вовсе. Это ДОПОЛНЕНИЕ к живой пробе регистрации, а не её
11
+ * замена.
12
+ *
13
+ * ЧИСТАЯ ПОЛОВИНА. Здесь нет ни Docker, ни файловой системы: `planCleanRoom` решает, ЧТО осмысленно
14
+ * запустить для этого манифеста, а `cleanRoomVerdict` превращает НАБЛЮДЕНИЕ в вердикт. Обе половины
15
+ * проверяются без контейнера; запускает их скрипт `scripts/clean-room-smoke.mjs`.
16
+ */
17
+
18
+ /** Что и почему запускать в контейнере. `kind: 'refuse'` — смотреть нечего, и это НЕ успех. */
19
+ export type CleanRoomPlan =
20
+ | {
21
+ readonly kind: 'bin';
22
+ /** Имя исполняемого файла из `bin`, которое будет вызвано с `--help`. */
23
+ readonly binName: string;
24
+ readonly argv: readonly string[];
25
+ readonly why: string;
26
+ }
27
+ | {
28
+ readonly kind: 'import';
29
+ /** Имя пакета, которое будет импортировано из установленной копии. */
30
+ readonly packageName: string;
31
+ readonly why: string;
32
+ }
33
+ | {
34
+ readonly kind: 'files-only';
35
+ /** Файл из поставки, чьё присутствие проверяется после установки. */
36
+ readonly probeFile: string;
37
+ readonly why: string;
38
+ }
39
+ | {
40
+ readonly kind: 'refuse';
41
+ readonly reason: string;
42
+ };
43
+
44
+ interface ManifestLike {
45
+ readonly name?: unknown;
46
+ readonly bin?: unknown;
47
+ readonly main?: unknown;
48
+ readonly exports?: unknown;
49
+ readonly files?: unknown;
50
+ readonly private?: unknown;
51
+ }
52
+
53
+ /**
54
+ * Выбрать пробу по манифесту — от самой сильной к самой слабой.
55
+ *
56
+ * ПОРЯДОК НЕ ПРОИЗВОЛЕН. Запуск бинаря проверяет и установку, и разрешение зависимостей, и то,
57
+ * что точка входа физически приехала. Импорт проверяет первые две. Присутствие файла — только
58
+ * состав поставки. Слабейшая проба ВЫБИРАЕТСЯ ТОЛЬКО тогда, когда сильнее нечего запустить, и её
59
+ * слабость названа в тексте `why`, чтобы читатель отчёта не принял её за полную проверку.
60
+ */
61
+ export function planCleanRoom(manifest: ManifestLike): CleanRoomPlan {
62
+ if (manifest.private === true) {
63
+ return { kind: 'refuse', reason: 'пакет приватный — он не публикуется, и проверять поставку не для кого' };
64
+ }
65
+ const name = typeof manifest.name === 'string' && manifest.name.trim() !== '' ? manifest.name.trim() : null;
66
+ if (name === null) {
67
+ return { kind: 'refuse', reason: 'манифест не называет пакет — установить его в чистой комнате нельзя' };
68
+ }
69
+
70
+ const binName = firstBinName(manifest.bin, name);
71
+ if (binName !== null) {
72
+ return {
73
+ kind: 'bin', binName, argv: ['--help'],
74
+ why: 'запуск исполняемого файла проверяет установку, разрешение зависимостей И то, что точка входа приехала в поставке',
75
+ };
76
+ }
77
+
78
+ // ИМПОРТ ОСМЫСЛЕН, ТОЛЬКО ЕСЛИ ТОЧКА ВХОДА — ЗАГРУЖАЕМЫЙ МОДУЛЬ. ИЗМЕРЕНО 2026-09-04:
79
+ // `@dzhechkov/keysarium-core` объявляет `main: "index.md"`, и импорт падает с «Unknown file
80
+ // extension ".md"» — это верное наблюдение О МАНИФЕСТЕ, но негодная ПРОБА поставки: пакет
81
+ // документационный, и его состав проверяется присутствием файла. Обвинять поставку в том, что
82
+ // объявлено в манифесте, — путать два разных дефекта.
83
+ if (isLoadableEntry(manifest.main) || manifest.exports !== undefined) {
84
+ return {
85
+ kind: 'import', packageName: name,
86
+ why: 'импорт проверяет установку и разрешение зависимостей; ЧТО пакет делает — не проверяется',
87
+ };
88
+ }
89
+
90
+ const probe = firstFile(manifest.files);
91
+ if (probe !== null) {
92
+ return {
93
+ kind: 'files-only', probeFile: probe,
94
+ why: 'у пакета нет ни исполняемого файла, ни точки входа — проверяется ТОЛЬКО то, что объявленный файл приехал; это самая слабая из проб',
95
+ };
96
+ }
97
+
98
+ return { kind: 'refuse', reason: 'манифест не объявляет ни bin, ни точки входа, ни files — запускать в чистой комнате нечего' };
99
+ }
100
+
101
+ /** Загружаемая ли это точка входа: расширение, которое Node умеет исполнять как модуль. */
102
+ function isLoadableEntry(main: unknown): boolean {
103
+ return typeof main === 'string' && /\.(?:js|mjs|cjs|node)$/i.test(main.trim());
104
+ }
105
+
106
+ /** Первое имя из `bin`. Строка — это имя самого пакета (без области), объект — первый ключ. */
107
+ function firstBinName(bin: unknown, packageName: string): string | null {
108
+ if (typeof bin === 'string' && bin.trim() !== '') {
109
+ const base = packageName.startsWith('@') ? packageName.split('/')[1] ?? packageName : packageName;
110
+ return base;
111
+ }
112
+ if (typeof bin === 'object' && bin !== null && !Array.isArray(bin)) {
113
+ for (const key of Object.keys(bin)) {
114
+ if (key.trim() !== '') return key;
115
+ }
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /** Первый ОБЫЧНЫЙ путь из `files` — образцы (`*`) проверять присутствием нельзя. */
121
+ function firstFile(files: unknown): string | null {
122
+ if (!Array.isArray(files)) return null;
123
+ for (const f of files) {
124
+ if (typeof f === 'string' && f.trim() !== '' && !f.includes('*')) return f.replace(/\/+$/, '');
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /** Что наблюдалось в контейнере. `exitCode: null` — процесс не завершился сам (убит, срок). */
130
+ export interface CleanRoomObservation {
131
+ readonly plan: CleanRoomPlan;
132
+ readonly installOk: boolean;
133
+ readonly installOutput: string;
134
+ readonly exitCode: number | null;
135
+ readonly stdout: string;
136
+ readonly stderr: string;
137
+ }
138
+
139
+ export type CleanRoomVerdict =
140
+ | { readonly ok: true; readonly detail: string; readonly limit: string }
141
+ | { readonly ok: false; readonly detail: string };
142
+
143
+ /** Оговорка, которую вердикт несёт ВСЕГДА: зелень чистой комнаты ≠ регистрация у клиента. */
144
+ export const CLEAN_ROOM_LIMIT =
145
+ 'чистая комната доказывает, что поставка УСТАНАВЛИВАЕТСЯ и запускается; она НЕ доказывает, '
146
+ + 'что Claude Code увидел навыки пакета — для этого живая проба регистрации (dz skills-verify)';
147
+
148
+ /**
149
+ * Превратить наблюдение в вердикт.
150
+ *
151
+ * УСПЕХ НЕ ВЫВОДИТСЯ ИЗ ТИШИНЫ: отсутствие вывода при нулевом коде — не доказательство запуска, и
152
+ * для пробы бинаря требуется НЕПУСТОЙ вывод. Установка, не завершившаяся успехом, делает любой
153
+ * последующий результат бессмысленным, и это говорится первой строкой.
154
+ */
155
+ export function cleanRoomVerdict(o: CleanRoomObservation): CleanRoomVerdict {
156
+ if (o.plan.kind === 'refuse') {
157
+ return { ok: false, detail: `проба не выбрана: ${o.plan.reason}` };
158
+ }
159
+ if (!o.installOk) {
160
+ // Провалившаяся установка делает любой последующий результат бессмысленным — и это говорится
161
+ // ПЕРВОЙ строкой, чтобы читатель не искал причину в проверке.
162
+ return { ok: false, detail: `установка из тарбола НЕ прошла — всё остальное о ней ничего не говорит: ${firstLine(o.installOutput)}` };
163
+ }
164
+ if (o.exitCode === null) {
165
+ return { ok: false, detail: 'проба не завершилась сама (убита или истёк срок) — исход НЕ УСТАНОВЛЕН, и это не успех' };
166
+ }
167
+ if (o.exitCode !== 0) {
168
+ return { ok: false, detail: `проба вышла с кодом ${o.exitCode}: ${firstLine(o.stderr) || firstLine(o.stdout) || '(вывода нет)'}` };
169
+ }
170
+ if (o.plan.kind === 'bin' && o.stdout.trim() === '' && o.stderr.trim() === '') {
171
+ // Ноль и тишина — это не «сработало». Ровно так выглядит бинарь, чья точка входа не приехала,
172
+ // если оболочка проглотила ошибку: успех, выведенный из тишины, и есть тот дефект, который
173
+ // чистая комната обязана ловить.
174
+ return { ok: false, detail: 'исполняемый файл вышел с нулём и НИЧЕГО не написал — тишина не доказывает запуск' };
175
+ }
176
+ const what = o.plan.kind === 'bin' ? `\`${o.plan.binName} ${o.plan.argv.join(' ')}\``
177
+ : o.plan.kind === 'import' ? `импорт ${o.plan.packageName}`
178
+ : `присутствие ${o.plan.probeFile}`;
179
+ return { ok: true, detail: `установка прошла, ${what} — успешно`, limit: CLEAN_ROOM_LIMIT };
180
+ }
181
+
182
+ /**
183
+ * Первая строка, которая ОБЪЯСНЯЕТ отказ, а не первая непустая.
184
+ *
185
+ * ИЗМЕРЕНО 2026-09-04 на живом прогоне: у сломанной поставки первой непустой строкой оказалась
186
+ * `node:fs:560` — кадр стека. Такой текст в вердикте отправляет читателя в файл среды выполнения
187
+ * вместо причины (`ENOENT … templates/…`). Поэтому сначала ищется строка, ПОХОЖАЯ на сообщение об
188
+ * ошибке, и только если такой нет — берётся первая непустая.
189
+ */
190
+ function firstLine(text: string): string {
191
+ const lines = String(text ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '');
192
+ const explains = lines.find((l) => /\b(?:Error|ENOENT|EACCES|ERR_[A-Z_]+|Cannot find|not found|npm ERR!)\b/i.test(l));
193
+ const chosen = explains ?? lines[0];
194
+ return chosen === undefined ? '' : chosen.slice(0, 200);
195
+ }
package/src/cmd-usage.ts CHANGED
@@ -158,14 +158,38 @@ interface RuleUsage {
158
158
  export function resolveCmdUsageRoot(startDir: string): string {
159
159
  try {
160
160
  let dir = resolve(startDir);
161
+ let nearestStore: string | null = null;
161
162
  for (let level = 0; level < 64; level += 1) {
162
- try {
163
- if (statSync(join(dir, '.dz')).isDirectory()) return dir;
164
- } catch {
165
- /* not here; keep walking */
163
+ // Ближайший `.dz` запоминается, но НЕ возвращается сразу — см. объяснение ниже.
164
+ if (nearestStore === null) {
165
+ try {
166
+ if (statSync(join(dir, '.dz')).isDirectory()) nearestStore = dir;
167
+ } catch { /* not here; keep walking */ }
168
+ }
169
+ // ГРАНИЦА РЕПОЗИТОРИЯ ВЫИГРЫВАЕТ У БЛИЖАЙШЕГО СКЛАДА, и вот почему.
170
+ //
171
+ // ИЗМЕРЕНО 2026-09-03: прогон `dz`, чей рабочий каталог оказался внутри пакета, создал там
172
+ // `.dz/` — и с того момента поиск останавливался на нём. Итог: 1149 записей об использовании
173
+ // команд уехали в склад внутри пакета вместо корневого, где их 17 265. Журнал разошёлся
174
+ // надвое молча, и обнаружил это только тест, требовавший корень монорепо.
175
+ //
176
+ // Тот же класс, что чинился в p-replicator неделей раньше, но зеркально: там поиск шёл ВЫШЕ
177
+ // границы репозитория, здесь останавливается НИЖЕ неё. Общее правило одно — у поиска корня
178
+ // должна быть НАЗВАННАЯ граница, а не первое попавшееся совпадение.
179
+ const atBoundary = ((): boolean => {
180
+ for (const marker of ['.git', 'pnpm-workspace.yaml']) {
181
+ try { statSync(join(dir, marker)); return true; } catch { /* keep checking */ }
182
+ }
183
+ return false;
184
+ })();
185
+ if (atBoundary) {
186
+ try {
187
+ if (statSync(join(dir, '.dz')).isDirectory()) return dir;
188
+ } catch { /* граница без склада — отдаём ближайший найденный */ }
189
+ return nearestStore ?? startDir;
166
190
  }
167
191
  const parent = dirname(dir);
168
- if (parent === dir) return startDir;
192
+ if (parent === dir) return nearestStore ?? startDir;
169
193
  dir = parent;
170
194
  }
171
195
  } catch {