@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/setup.ts CHANGED
@@ -17,10 +17,17 @@
17
17
  */
18
18
 
19
19
  import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
20
- import { join } from 'node:path';
21
- import { execSync } from 'node:child_process';
20
+ import { basename, dirname, join } from 'node:path';
21
+ import { execSync, spawnSync } from 'node:child_process';
22
22
 
23
23
  import { mergeManagedHookEntries } from './managed-hooks.js';
24
+ import {
25
+ CLAUDE_DESTRUCTIVE_HOOK_COMMAND,
26
+ CLAUDE_DESTRUCTIVE_HOOK_MATCHER,
27
+ CLAUDE_DESTRUCTIVE_HOOK_RELPATH,
28
+ generateClaudeDestructiveHook,
29
+ isDzManagedHookBody,
30
+ } from './claude-hooks-assets.js';
24
31
  import { applyIntegrationFragments, IntegrationApplyError } from './integration-apply.js';
25
32
 
26
33
  /** Memory backend type. */
@@ -251,6 +258,102 @@ export function commandsOf(entry: unknown): string[] {
251
258
  return [String(e?.command ?? '')];
252
259
  }
253
260
 
261
+ export interface GuardInstallReceipt {
262
+ /** True ONLY when the installed file was RUN and refused a command it must refuse. */
263
+ readonly ok: boolean;
264
+ /** What was observed — an exit code and the first line it printed, or why it could not run. */
265
+ readonly detail: string;
266
+ }
267
+
268
+ /**
269
+ * Prove the installed hook WORKS, by running it (feature `destructive-command-guard`, review
270
+ * round 4, P2).
271
+ *
272
+ * Presence is not proof and a successful write is not proof either: the body must load its decider
273
+ * (which lives in another package directory), and that resolution is exactly what broke in the
274
+ * global-install layout one round ago. So the receipt is POSITIVE and end-to-end — the file is
275
+ * spawned with a payload it is obliged to refuse, and only `exit 2` carrying our marker counts.
276
+ * Anything else — a crash, a silent pass, a missing file, a spawn that could not happen — is `ok:
277
+ * false` with the observation named, never an assumption about the cause.
278
+ *
279
+ * The registry entry is written only when this returns `ok`. A hook that is registered but cannot
280
+ * run is worse than no hook at all: the breakage lands on EVERY Bash call instead of on none.
281
+ *
282
+ * SAFETY PRECONDITION (round 11): this SPAWNS the file, so the caller must only ever call it on a
283
+ * body dz owns — one carrying the ownership marker, or one dz has just written itself. Calling it
284
+ * on a preserved foreign body turns `dz setup` into a runner for whatever a cloned repository
285
+ * committed at that path.
286
+ */
287
+ export function probeInstalledGuard(
288
+ hookPath: string,
289
+ opts: { projectRoot?: string } = {},
290
+ ): GuardInstallReceipt {
291
+ const payload = JSON.stringify({
292
+ hook_event_name: 'PreToolUse',
293
+ tool_name: 'Bash',
294
+ tool_input: { command: 'rm -rf .agentic-qe' },
295
+ });
296
+ let run;
297
+ try {
298
+ // DZ_GUARD_TRUSTED_ONLY makes the hook resolve its decision module from the INSTALLED
299
+ // harness-core alone. Without it the probe imports the project's own copy first, so a cloned
300
+ // repository that commits `packages/@dzhechkov/harness-core/dist/destructive-guard-hook.js`
301
+ // gets its top-level JavaScript executed by `dz setup` — the round-11 protection ("a foreign
302
+ // BODY is never spawned") one resolution step further in (cross-family review, gpt-5.6-sol,
303
+ // round 13). MEASURED before the fix: the planted module's marker reached stderr and its
304
+ // decider answered `allow`.
305
+ const env: NodeJS.ProcessEnv = { ...process.env, DZ_GUARD_TRUSTED_ONLY: '1' };
306
+ if (opts.projectRoot !== undefined) env.CLAUDE_PROJECT_DIR = opts.projectRoot;
307
+ run = spawnSync(process.execPath, [hookPath], {
308
+ input: payload,
309
+ encoding: 'utf-8',
310
+ timeout: 15_000,
311
+ env,
312
+ });
313
+ } catch (err) {
314
+ return { ok: false, detail: `не удалось запустить хук: ${String((err as Error).message)}` };
315
+ }
316
+ if (run.error !== undefined) return { ok: false, detail: `не удалось запустить хук: ${run.error.message}` };
317
+ const firstLine = String(run.stderr ?? '').split('\n')[0] ?? '';
318
+ if (run.status !== 2 || !firstLine.includes('DZ-DESTRUCTIVE:')) {
319
+ return {
320
+ ok: false,
321
+ detail: `установленный хук НЕ отказал на контрольной команде (код выхода ${String(run.status)}; ${firstLine || 'пустой stderr'})`,
322
+ };
323
+ }
324
+ return { ok: true, detail: 'живая проба: отказ на контрольной команде, код выхода 2' };
325
+ }
326
+
327
+ /**
328
+ * The destructive-command guard's registry entry (feature `destructive-command-guard`, task T8).
329
+ *
330
+ * Emitted for EVERY backend, because the guard has nothing to do with where learning memory is
331
+ * stored. MEASURED 2026-09-05, before this existed: `dz setup --target claude-code` into a clean
332
+ * project wrote no `PreToolUse` key at all and created no `.claude/hooks/` — the guard we document
333
+ * protected only our own checkout.
334
+ */
335
+ const CLAUDE_DESTRUCTIVE_HOOK_TIMEOUT_MS = 5000;
336
+ const LEGACY_CLAUDE_DESTRUCTIVE_HOOK_COMMANDS: ReadonlySet<string> = new Set([
337
+ `node "\${CLAUDE_PROJECT_DIR:-.}/${CLAUDE_DESTRUCTIVE_HOOK_RELPATH}"`,
338
+ ]);
339
+
340
+ /** Attribute only commands dz actually emitted, never arbitrary text that mentions the path. */
341
+ function isManagedClaudeDestructiveHookCommand(command: string): boolean {
342
+ return command === CLAUDE_DESTRUCTIVE_HOOK_COMMAND
343
+ || LEGACY_CLAUDE_DESTRUCTIVE_HOOK_COMMANDS.has(command);
344
+ }
345
+
346
+ function destructiveGuardHookEntry(): { matcher: string; hooks: { type: string; command: string; timeout: number }[] } {
347
+ return {
348
+ matcher: CLAUDE_DESTRUCTIVE_HOOK_MATCHER,
349
+ hooks: [{
350
+ type: 'command',
351
+ command: CLAUDE_DESTRUCTIVE_HOOK_COMMAND,
352
+ timeout: CLAUDE_DESTRUCTIVE_HOOK_TIMEOUT_MS,
353
+ }],
354
+ };
355
+ }
356
+
254
357
  export function generateHooksConfig(projectRoot: string, backend: MemoryBackend): string {
255
358
  const dzDir = join(projectRoot, '.dz');
256
359
 
@@ -272,6 +375,7 @@ export function generateHooksConfig(projectRoot: string, backend: MemoryBackend)
272
375
  SessionStart: [{ hooks: [{ type: 'command', command: `node ${JSON.stringify(writer)} start` }] }],
273
376
  SessionEnd: [{ hooks: [{ type: 'command', command: `node ${JSON.stringify(writer)} end` }] }],
274
377
  PreCompact: [{ hooks: [{ type: 'command', command: `node ${JSON.stringify(writer)} precompact`, runInBackground: true }] }],
378
+ PreToolUse: [destructiveGuardHookEntry()],
275
379
  },
276
380
  }, null, 2);
277
381
  }
@@ -289,6 +393,7 @@ export function generateHooksConfig(projectRoot: string, backend: MemoryBackend)
289
393
  SessionStart: [{ hooks: [{ type: 'command', command: jsonlCmd('start') }] }],
290
394
  SessionEnd: [{ hooks: [{ type: 'command', command: jsonlCmd('end') }] }],
291
395
  PreCompact: [{ hooks: [{ type: 'command', command: jsonlCmd('precompact'), runInBackground: true }] }],
396
+ PreToolUse: [destructiveGuardHookEntry()],
292
397
  },
293
398
  }, null, 2);
294
399
  }
@@ -614,10 +719,125 @@ export function runSetup(opts: SetupOptions): SetupResult {
614
719
  if (!opts.noHooks) {
615
720
  const settingsDir = join(opts.projectRoot, '.claude');
616
721
  const settingsPath = join(settingsDir, 'settings.json');
722
+
723
+ // The BODY goes in first, and the ENTRY goes in only after a LIVE receipt that the body runs
724
+ // and refuses. Written from the INSTALLED package, never copied out of our repository — a
725
+ // consumer has no `packages/@dzhechkov/...` above their project.
726
+ //
727
+ // Round 4, P2: these two used to be independent. A failed write was recorded as an error and
728
+ // the entry was merged anyway, so a consumer whose install failed got a `PreToolUse` entry
729
+ // pointing at something that is not a runnable hook — and that breaks EVERY Bash call, not one.
730
+ //
731
+ // Round 5, P1: the write was also UNCONDITIONAL. Setup is additive everywhere else — the
732
+ // settings merge keeps the user's own hooks, `.gitignore` is appended to, an existing skill is
733
+ // skipped — and this path overwrote a well-known filename with no ownership check, so a
734
+ // consumer's hand-authored `.claude/hooks/destructive-guard.cjs` was destroyed by a routine
735
+ // run. A body we wrote carries a MARKER; a file without it is the consumer's, and only an
736
+ // explicit `--force` may replace it, after a timestamped backup.
737
+ const hookPath = join(opts.projectRoot, ...CLAUDE_DESTRUCTIVE_HOOK_RELPATH.split('/'));
738
+ let installError: string | null = null;
739
+ let preserved: string | null = null;
740
+ let backupPath: string | null = null;
741
+
742
+ // Unreadable (absent, or something that is not a file at all) is NOT a claim of ownership: it
743
+ // falls through to the write, whose failure the round-4 receipt below already reports.
744
+ let current: string | null = null;
745
+ try {
746
+ current = readFileSync(hookPath, 'utf-8');
747
+ } catch {
748
+ current = null;
749
+ }
750
+ const foreign = current !== null && !isDzManagedHookBody(current);
751
+
752
+ if (foreign && opts.force !== true) {
753
+ preserved =
754
+ 'файл на этом пути не наш (нет маркера dz) — ОСТАВЛЕН нетронутым и НЕ ЗАПУСКАЛСЯ; запись в settings.json на этот путь тоже не трогаем (ни своей не добавляем, ни вашу не снимаем); заменить: dz setup --force';
755
+ } else {
756
+ try {
757
+ if (foreign && current !== null) {
758
+ // Same shape as the codex `hooks.json` backup: the original beside the original, stamped,
759
+ // so `--force` is recoverable rather than merely loud.
760
+ backupPath = `${hookPath}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
761
+ writeFileSync(backupPath, current);
762
+ }
763
+ mkdirSync(dirname(hookPath), { recursive: true });
764
+ writeFileSync(hookPath, generateClaudeDestructiveHook(), { mode: 0o755 });
765
+ } catch (err) {
766
+ installError = String((err as Error).message);
767
+ }
768
+ }
769
+ const foreignBodyKept = preserved !== null;
770
+
771
+ // The receipt SPAWNS the file, so it may only ever be taken on a body dz owns.
772
+ //
773
+ // Round 11, P1 SECURITY — correcting my own round-5 sentence, "the receipt is taken from the
774
+ // file that IS there". Combined with round 6, which preserves a body dz does not own, that made
775
+ // `dz setup` EXECUTE whatever a repository had committed at this path: clone a hostile repo,
776
+ // run the documented setup command, and its `.claude/hooks/destructive-guard.cjs` ran — with
777
+ // none of the host's hook-trust prompting in between. MEASURED: a foreign body writing a marker
778
+ // file had written it by the time setup returned.
779
+ //
780
+ // So a preserved foreign body is NOT probed, NOT registered, and NOT run. `--force` is consent
781
+ // to REPLACE it (our body is written above, before this line) — never consent to execute it.
782
+ // The receipt is still taken whether or not the write threw, because a failed write over an
783
+ // OLDER BODY OF OURS leaves something we may legitimately run.
784
+ const receipt: GuardInstallReceipt = foreignBodyKept
785
+ ? { ok: false, detail: 'проба не проводилась — запускать чужой файл не наше право' }
786
+ : probeInstalledGuard(hookPath);
787
+ // Round 8, P2: ownership of the ENTRY follows ownership of the BODY, never the filename.
788
+ //
789
+ // Round 6 preserved a consumer's hook file; attribution of its registry entry stayed path-only,
790
+ // so a routine run deleted the registration of the very file it had just decided not to touch —
791
+ // their hook left on disk and switched off (MEASURED: their `PreToolUse` entry came back `[]`).
792
+ // The reverse was just as wrong: a foreign body that happened to refuse made dz ADD an entry
793
+ // for somebody else's file (MEASURED), taking responsibility for code it may neither read as
794
+ // its own nor replace.
795
+ //
796
+ // So when a foreign body is kept, dz stands down from the whole event: it adds nothing, and
797
+ // `isManaged` below stops claiming an entry that points at that path. Whether the foreign hook
798
+ // refuses is not merely the consumer's business — it is a question dz no longer ASKS, because
799
+ // asking meant running their file (round 11). All of it is said in one line rather than left
800
+ // for them to find by diffing settings.json.
801
+ const guardArmed = receipt.ok && !foreignBodyKept;
802
+ // The receipt is taken from the file that IS at the path — ours, or the one we preserved. A
803
+ // foreign hook that demonstrably refuses is registered on its own merits; a foreign hook that
804
+ // does not refuse gets no entry, exactly like a failed install (round 4).
805
+ const notes = [
806
+ preserved === null ? '' : `${preserved}; `,
807
+ backupPath === null ? '' : `прежний файл сохранён: ${basename(backupPath)}; `,
808
+ installError === null ? '' : `${installError}; `,
809
+ ].join('');
810
+ steps.push(
811
+ guardArmed
812
+ ? { name: 'Install destructive guard', status: 'done', detail: `${notes}${CLAUDE_DESTRUCTIVE_HOOK_RELPATH} — ${receipt.detail}` }
813
+ : {
814
+ name: 'Install destructive guard',
815
+ status: preserved === null ? 'error' : 'skipped',
816
+ detail: `${notes}${receipt.detail} — запись в settings.json НЕ добавлена`,
817
+ },
818
+ );
617
819
  const generated = JSON.parse(generateHooksConfig(opts.projectRoot, backend)) as {
618
820
  hooks: Record<string, { hooks: { type: string; command: string; runInBackground?: boolean }[] }[]>;
619
821
  };
620
822
 
823
+ // No working body ⇒ no entry, and the EVENT KEY STAYS — as an empty managed list when nothing
824
+ // else of ours belongs there.
825
+ //
826
+ // CORRECTION OF RECORD (round 7, P1). The round-5 version DELETED the key and this comment
827
+ // claimed the merge would then also drop a guard entry left by an earlier setup. That was
828
+ // asserted without measuring and it is false: `mergeManagedHookEntries` iterates
829
+ // `Object.keys(managed)`, so an event absent from the managed input is copied through
830
+ // UNTOUCHED — a project whose guard used to be armed kept invoking it on every Bash call while
831
+ // the report said the entry was not added. Handing the event an EMPTY list is what makes the
832
+ // merge EXAMINE it: our entries are dropped by `isManaged`, the user's are preserved in order,
833
+ // and nothing is appended. The round-5 test passed for the wrong reason — its project had no
834
+ // pre-existing settings.json, so there was no stale entry for the claim to be wrong about.
835
+ if (!guardArmed) {
836
+ generated.hooks['PreToolUse'] = (generated.hooks['PreToolUse'] ?? []).filter(
837
+ (entry) => !entry.hooks.some((h) => isManagedClaudeDestructiveHookCommand(h.command)),
838
+ );
839
+ }
840
+
621
841
  if (!existsSync(settingsPath)) {
622
842
  mkdirSync(settingsDir, { recursive: true });
623
843
  writeFileSync(settingsPath, JSON.stringify({ hooks: generated.hooks }, null, 2));
@@ -625,9 +845,18 @@ export function runSetup(opts: SetupOptions): SetupResult {
625
845
  } else {
626
846
  try {
627
847
  const existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>;
628
- // ONE merge implementation, shared with the Codex target (AM-3 / G-E). The Claude path's
629
- // historical SUBSTRING attribution is passed IN verbatim rather than reimplemented, so the
630
- // emitted bytes, the report tail string and the no-write path all stay identical (AM-37).
848
+ // ONE merge implementation, shared with the Codex target (AM-3 / G-E). Claude's exact
849
+ // command attribution is passed in rather than reimplemented, so emitted bytes, report
850
+ // tail text, and the no-write path stay on the shared merge contract (AM-37).
851
+ const isManagedCommand = (cmd: string): boolean =>
852
+ cmd.includes('agentdb add') ||
853
+ cmd.includes('agentdb-writer.mjs') ||
854
+ cmd.includes('sessions.jsonl') ||
855
+ // Ours ONLY while the body at that path is ours (round 8, P2). Without the
856
+ // path clause a second `dz setup` would append a duplicate guard entry instead of
857
+ // replacing the first; without the ownership clause it would delete the entry a
858
+ // consumer wrote for their own preserved hook.
859
+ (!foreignBodyKept && isManagedClaudeDestructiveHookCommand(cmd));
631
860
  const plan = mergeManagedHookEntries(
632
861
  (existing['hooks'] ?? {}) as Record<string, unknown[]>,
633
862
  generated.hooks as unknown as Record<string, unknown[]>,
@@ -635,13 +864,21 @@ export function runSetup(opts: SetupOptions): SetupResult {
635
864
  // Drop dz-generated entries (any vintage, either shape) — keep the user's own hooks
636
865
  // untouched. Flat dz entries (≤0.3.43) are dropped too, migrating them to the valid
637
866
  // matcher-group shape appended below.
638
- isManaged: (entry) =>
639
- commandsOf(entry).some(
640
- (cmd) => cmd.includes('agentdb add') || cmd.includes('agentdb-writer.mjs') || cmd.includes('sessions.jsonl'),
641
- ),
867
+ isManaged: (entry) => commandsOf(entry).some(isManagedCommand),
642
868
  isLegacy: (entry) =>
643
869
  !Array.isArray((entry as { hooks?: unknown[] })?.hooks) ||
644
870
  commandsOf(entry).some((cmd) => cmd.includes('agentdb add')),
871
+ // Ownership is per HANDLER, not per matcher group. A user's handler may deliberately
872
+ // share the Bash group with dz's guard; replacing ours must retain their handler object
873
+ // and every surrounding group field byte-for-byte through JSON serialization.
874
+ retainForeign: (entry) => {
875
+ const grouped = entry as { hooks?: { command?: unknown }[] };
876
+ if (!Array.isArray(grouped?.hooks)) return null;
877
+ const kept = grouped.hooks.filter((hook) => !isManagedCommand(String(hook?.command ?? '')));
878
+ return kept.length === 0
879
+ ? null
880
+ : { ...(entry as Record<string, unknown>), hooks: kept };
881
+ },
645
882
  reportLabel: backend,
646
883
  },
647
884
  );
package/src/sign.ts CHANGED
@@ -504,7 +504,24 @@ export function verifyManifest(
504
504
  continue;
505
505
  }
506
506
  if (digest !== entry.sha256) {
507
- failures.push({ path: entry.path, reason: 'content does not match its signed hash' });
507
+ // ПРИЧИНА НАЗЫВАЕТСЯ ТОЧНО, А НЕ ОБОБЩЁННО. Для файла, который переписывает упаковщик,
508
+ // расхождение чаще означает НЕ подделку, а сверку двух РАЗНЫХ объектов: манифест снят с
509
+ // байтов упакованного пакета, а здесь читается исходный каталог. ИЗМЕРЕНО 2026-09-03: так
510
+ // расходятся ВСЕ 56 подписанных пакетов, ни один не проверяется — на свежеподписанном
511
+ // манифест хранит cd7bd97a…, а хэш байтов с диска при любой версии канонизации даёт
512
+ // 40d926ff…, потому что pnpm вырезает из package.json блок scripts.
513
+ //
514
+ // Вердикт НЕ меняется: расхождение остаётся расхождением и выход остаётся ненулевым.
515
+ // Меняется только прочтение — «подделали» против «сверяете не то». Полное лечение (два
516
+ // режима проверки: исходный каталог упаковывать, установленный сверять напрямую) —
517
+ // запись бэклога 58dc4d58, решение владельца получено, работа не сделана.
518
+ const packerRewritten = CANONICALISED_PACK_FILES.has(entry.path);
519
+ failures.push({
520
+ path: entry.path,
521
+ reason: packerRewritten
522
+ ? 'content does not match its signed hash — NOTE: this file is rewritten by the packer, so a mismatch here may mean the manifest was built from PACKED bytes while a SOURCE directory is being verified, not tampering (backlog 58dc4d58)'
523
+ : 'content does not match its signed hash',
524
+ });
508
525
  }
509
526
  }
510
527
 
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `--select` resolution: one provider per requested skill, decided ONCE (backlog 9d15b9b6, PR-A).
3
+ *
4
+ * THE DEFECT THIS REPLACES. `installSkills` looped the installer once PER ROOT, handing each root
5
+ * the same select list. Asking each root independently produces two failures that look like
6
+ * opposites but share one cause — the REQUEST was never resolved, only re-asked:
7
+ *
8
+ * - a skill present in two roots was installed TWICE and counted twice. The field report's
9
+ * observed `2 skill(s)` was ONE skill installed twice, read by everyone as an orchestrator plus
10
+ * its stage skill (features/digitize-book-codex-init-research/00_research/00-root-cause.md);
11
+ * - a skill present in NO root produced a warning and exit 0. `0 skill(s)` read as success, which
12
+ * is the same class as a health check reporting clean because it could not look.
13
+ *
14
+ * WHY A PURE MODULE. The decision must happen BEFORE anything is written — an exit 1 that arrives
15
+ * after hooks and memory are installed leaves a half-configured project, which is worse than either
16
+ * outcome. A pure resolver can be called at the top of the seam and interrogated without side
17
+ * effects; a resolver tangled with the filesystem cannot.
18
+ *
19
+ * DELIBERATELY NOT HERE: dependency closure (an orchestrator pulling its stage skills). That is
20
+ * PR-B. Mixing the two would put a correctness fix and a semantic change in one unreviewable
21
+ * change, and the exit-contract fix is what unblocks the rest.
22
+ */
23
+
24
+ /** One skill root and the ids it offers, in the order discovery returned them. */
25
+ export interface SkillRoot {
26
+ readonly dir: string;
27
+ readonly ids: readonly string[];
28
+ }
29
+
30
+ /** The single root chosen to provide one requested id. */
31
+ export interface ChosenProvider {
32
+ readonly id: string;
33
+ readonly dir: string;
34
+ }
35
+
36
+ /**
37
+ * A requested id offered by more than one root. NOT an error: precedence resolves it. But it is
38
+ * NAMED, because an operator who sees one install where two roots offer the skill must be able to
39
+ * learn which root lost — otherwise the precedence is folklore, and the next person re-derives it.
40
+ */
41
+ export interface ShadowedProvider {
42
+ readonly id: string;
43
+ readonly chosen: string;
44
+ readonly alsoIn: readonly string[];
45
+ }
46
+
47
+ export interface SelectResolution {
48
+ /** One entry per DISTINCT requested id that resolved, in request order. */
49
+ readonly chosen: readonly ChosenProvider[];
50
+ /** Requested ids no root offers. Non-empty means the caller must refuse and write nothing. */
51
+ readonly missing: readonly string[];
52
+ /** Requested ids offered by several roots, with the losers named. */
53
+ readonly shadowed: readonly ShadowedProvider[];
54
+ /** True only when EVERY requested id resolved. Shadowing does not make it false. */
55
+ readonly ok: boolean;
56
+ }
57
+
58
+ /**
59
+ * PRECEDENCE IS ROOT ORDER, and that is a promise, not an implementation detail: the first root in
60
+ * `roots` that offers an id provides it. Callers pass roots in discovery order, so the rule the
61
+ * operator can state ("the earlier root wins") is the rule that runs. The previous behaviour
62
+ * depended on `readdir` order inside each root and on the loop visiting roots — an order nobody
63
+ * documented and nobody could rely on.
64
+ *
65
+ * Asking for the same id twice is asking once: a request is a SET of skills, and duplicating a name
66
+ * in the argument does not duplicate an installation.
67
+ */
68
+ export function resolveSelection(
69
+ select: readonly string[],
70
+ roots: readonly SkillRoot[],
71
+ ): SelectResolution {
72
+ const chosen: ChosenProvider[] = [];
73
+ const missing: string[] = [];
74
+ const shadowed: ShadowedProvider[] = [];
75
+ const seen = new Set<string>();
76
+
77
+ for (const id of select) {
78
+ if (seen.has(id)) continue;
79
+ seen.add(id);
80
+ const providers = roots.filter((root) => root.ids.includes(id)).map((root) => root.dir);
81
+ const first = providers[0];
82
+ if (first === undefined) {
83
+ missing.push(id);
84
+ continue;
85
+ }
86
+ chosen.push({ id, dir: first });
87
+ // EVERY losing root is listed, not just the second: with three providers, naming one of the two
88
+ // losers would send the reader to fix the wrong copy.
89
+ if (providers.length > 1) shadowed.push({ id, chosen: first, alsoIn: providers.slice(1) });
90
+ }
91
+
92
+ return { chosen, missing, shadowed, ok: missing.length === 0 };
93
+ }
94
+
95
+ /**
96
+ * The refusal an explicit `--select` owes its caller. Returns null when there is nothing to refuse.
97
+ *
98
+ * Shaped as text rather than an exception so the seam can print it and return a code without a
99
+ * try/catch that a later edit might swallow — the failure mode this whole change exists to remove
100
+ * is a problem that produced a warning and kept going.
101
+ */
102
+ export function formatSelectRefusal(resolution: SelectResolution, roots: readonly SkillRoot[]): string | null {
103
+ if (resolution.ok) return null;
104
+ const lines = [
105
+ `dz: --select refused — ${resolution.missing.length} requested skill(s) exist in none of the ${roots.length} skill root(s):`,
106
+ ];
107
+ for (const id of resolution.missing) lines.push(` missing: ${id}`);
108
+ for (const root of roots) lines.push(` searched: ${root.dir} (${root.ids.length} skill(s))`);
109
+ lines.push('Nothing was written. Fix the id, or install the pack that provides it, then re-run.');
110
+ return lines.join('\n');
111
+ }
@@ -23,7 +23,9 @@ import { basename, isAbsolute, join, resolve } from 'node:path';
23
23
  export type SkillIssueKind =
24
24
  | 'no-skill-md' // a skill dir with no SKILL.md at depth 1 → never registers
25
25
  | 'buried-skill-md' // a SKILL.md at depth >= 2 → the loader does not scan that deep
26
- | 'plugin-manifest-trap'; // .claude-plugin/plugin.json under .claude/skills → does NOT auto-register
26
+ | 'plugin-manifest-trap' // .claude-plugin/plugin.json under .claude/skills → does NOT auto-register
27
+ | 'wildcard-allowed-tools' // `allowed-tools: *` → every tool granted; a FINDING (see below)
28
+ | 'empty-allowed-tools'; // `allowed-tools:` with no value → looks restrictive, restricts nothing
27
29
 
28
30
  export interface SkillLayoutFinding {
29
31
  readonly dir: string;
@@ -170,6 +172,98 @@ export function looksLikeSkillDir(dir: string): boolean {
170
172
  return walk(dir, 1);
171
173
  }
172
174
 
175
+ /**
176
+ * What a SKILL.md's front matter says about the tools it may use.
177
+ *
178
+ * Three states, and they are NOT the same thing:
179
+ * `absent` — no `allowed-tools` key. The skill honestly inherits; this is the ordinary case
180
+ * (7 170 SKILL.md files in this tree, MEASURED 2026-09-03).
181
+ * `empty` — the key is present with no value. It LOOKS like a restriction and restricts
182
+ * nothing. Reported, never failed: 70 files are in this state today, and a gate
183
+ * that goes red on the day it is introduced gets switched off — taking the
184
+ * wildcard check down with it.
185
+ * `listed` — actual values, which may include a wildcard.
186
+ */
187
+ export type AllowedToolsState =
188
+ | { readonly state: 'absent' }
189
+ | { readonly state: 'empty' }
190
+ | { readonly state: 'listed'; readonly values: readonly string[]; readonly wildcard: boolean };
191
+
192
+ /**
193
+ * Read `allowed-tools` from the FRONT MATTER only.
194
+ *
195
+ * Scanning the whole file would turn `allowed-tools: *` written inside a skill's own documentation
196
+ * into a violation — and a gate that INVENTS violations is worse than no gate: it teaches people to
197
+ * ignore it. So the parse stops at the closing `---`.
198
+ *
199
+ * Both YAML spellings are accepted, because both appear in this tree: an inline list
200
+ * (`allowed-tools: Read, Write`) and a flow sequence (`allowed-tools: [Read, Write]`). Quotes are
201
+ * stripped before the wildcard test — `allowed-tools: "*"` grants exactly as much as a bare one.
202
+ */
203
+ export function parseAllowedTools(markdown: string): AllowedToolsState {
204
+ const lines = markdown.split(/\r?\n/);
205
+ if (lines[0]?.trim() !== '---') return { state: 'absent' };
206
+ let raw: string | null = null;
207
+ for (let i = 1; i < lines.length; i++) {
208
+ const line = lines[i] ?? '';
209
+ if (line.trim() === '---') break; // конец заголовка
210
+ const m = /^allowed-tools:(.*)$/.exec(line);
211
+ if (m) { raw = m[1] ?? ''; break; }
212
+ }
213
+ if (raw === null) return { state: 'absent' };
214
+
215
+ // ЯВНО ПУСТОЙ МАССИВ — НЕ ТО ЖЕ, ЧТО ПУСТОЕ ПОЛЕ. `allowed-tools: []` — это утверждение
216
+ // («никаких инструментов»), а `allowed-tools:` — незаполненное поле, которое ВЫГЛЯДИТ
217
+ // ограничением и ничего не ограничивает. Свалить их в одно состояние значило бы сказать про
218
+ // первый случай неправду в тексте предупреждения.
219
+ const trimmed = raw.trim();
220
+ if (trimmed === '') return { state: 'empty' };
221
+ const bracketed = /^\[(.*)\]$/s.exec(trimmed);
222
+ const body = (bracketed ? bracketed[1] ?? '' : trimmed).trim();
223
+ if (body === '') return { state: 'listed', values: [], wildcard: false };
224
+
225
+ const values = body.split(',')
226
+ .map((v) => v.trim().replace(/^["']|["']$/g, '').trim())
227
+ .filter((v) => v !== '');
228
+ return { state: 'listed', values, wildcard: values.includes('*') };
229
+ }
230
+
231
+ /**
232
+ * Privilege findings for one skill directory.
233
+ *
234
+ * WHY THIS FAILS RATHER THAN WARNS. The advisory carve-out in this module exists for layout classes
235
+ * whose support this gate CANNOT observe (workspace trust). A wildcard grant is not that: the text
236
+ * is right there in the file, the reading is unambiguous, and nothing about the environment can make
237
+ * it narrow. It is also a pure regression guard — MEASURED 2026-09-03: zero wildcards exist in this
238
+ * tree, so introducing it breaks no one.
239
+ */
240
+ function scanSkillPrivileges(
241
+ dir: string, label: string, findings: SkillLayoutFinding[], advisories: SkillLayoutFinding[],
242
+ ): void {
243
+ let text: string;
244
+ try {
245
+ text = readFileSync(join(dir, 'SKILL.md'), 'utf8');
246
+ } catch {
247
+ return; // нечитаемый файл — забота других проверок этого же слоя, не этой
248
+ }
249
+ const tools = parseAllowedTools(text);
250
+ if (tools.state === 'empty') {
251
+ advisories.push({
252
+ dir: label,
253
+ kind: 'empty-allowed-tools',
254
+ detail: 'allowed-tools is present but empty — it looks like a restriction and grants everything; either list the tools or drop the key',
255
+ });
256
+ return;
257
+ }
258
+ if (tools.state === 'listed' && tools.wildcard) {
259
+ findings.push({
260
+ dir: label,
261
+ kind: 'wildcard-allowed-tools',
262
+ detail: `allowed-tools grants every tool via "*" (${tools.values.join(', ')}) — name the tools this skill actually needs`,
263
+ });
264
+ }
265
+ }
266
+
173
267
  /**
174
268
  * A directory registers only if `SKILL.md` is a regular FILE. `existsSync` also answers true for a
175
269
  * DIRECTORY named SKILL.md, which registers nothing yet suppressed every other check (QE2 #4).
@@ -332,6 +426,10 @@ export function scanSkillsLayout(projectDir: string): StaticScan {
332
426
  const isPluginContainer = hasPluginManifest(dir);
333
427
  const bucket = isPluginContainer ? advisories : findings;
334
428
 
429
+ // Права проверяются у КАЖДОГО навыка с читаемым SKILL.md, включая одно-навыковый плагин: щедрая
430
+ // выдача не становится безопаснее оттого, что навык лежит в контейнере.
431
+ if (registers) scanSkillPrivileges(dir, name, findings, advisories);
432
+
335
433
  if (registers && !isPluginContainer) {
336
434
  registrable.push(name);
337
435
  } else if (registers && isPluginContainer) {