@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
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
 
@@ -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) {
@@ -0,0 +1,151 @@
1
+ /**
2
+ * stage-line-before-dispatch — the two-phase stage announcement renderer used by feature-adr
3
+ * dispatch points (historical feature name `stage-line-before-dispatch`, ADR-001). The workflow's
4
+ * sole dispatch seam prints an intent line before the runtime call and a distinct final outcome
5
+ * line after the same attempt settles.
6
+ *
7
+ * WHY this file is standalone and import-free: the workflow script runs in a sandbox with no
8
+ * imports, so this module is projected into `.claude/workflows/feature-adr.js` VERBATIM by
9
+ * `scripts/gen-loop-blobs.mjs` (blob `stage-line`) and byte-checked there by
10
+ * `loop-blobs-regen.test.ts`. The generator FAILS CLOSED on any cross-file import, so the decision
11
+ * shape below is declared STRUCTURALLY here rather than imported from `feature-adr-routing.ts`.
12
+ *
13
+ * The line is PURE TEXT ASSEMBLY: every field comes from the decision the resolver returned or from
14
+ * the constant label table below. No model ever writes a word of it (AC-6), it reads nothing, and it
15
+ * prints nothing — the caller does the printing (AC-3).
16
+ *
17
+ * DESIGN CONSTRAINT (shared with feature-adr-routing.ts): the Workflow parser is stricter than
18
+ * `node --check` — string `+` concatenation only, explicit `if`/`return`, object-literal tables.
19
+ *
20
+ * @packageDocumentation
21
+ */
22
+
23
+ /** Blob version stamp read by scripts/gen-loop-blobs.mjs; bump on any semantic change here. */
24
+ export const STAGE_LINE_BLOB_VERSION = '2.0.0';
25
+
26
+ /**
27
+ * The decision shape `renderStageLine` consumes — structurally identical to
28
+ * `StageDecision` from `feature-adr-routing.ts`, declared locally because a cross-file import
29
+ * would make this file unprojectable into the sandboxed workflow (see the file header).
30
+ */
31
+ export interface StageLineDecision {
32
+ readonly opts: { readonly agentType?: string; readonly model?: string };
33
+ readonly spec: string | null;
34
+ readonly reason: string;
35
+ }
36
+
37
+ export type StageLineOutcomeState = 'dispatched' | 'probe-failed' | 'refused-before-dispatch';
38
+
39
+ /** Final state of this exact rung. Selection reasons never travel through this shape. */
40
+ export interface StageLineOutcome {
41
+ readonly state: StageLineOutcomeState;
42
+ readonly reason: string | null;
43
+ }
44
+
45
+ /**
46
+ * Human signature per resolver branch — a CLOSED table over `StageDecisionReason`. A reason with no
47
+ * entry is a hole, and `stage-line.test.ts` proves the table has none: a newcomer reading `· ` and
48
+ * then a raw enum token learns nothing, which is the whole failure this feature exists to prevent.
49
+ */
50
+ export const STAGE_LINE_REASON_LABELS: Record<string, string> = {
51
+ 'usage-override': 'usage override (Claude limit pressure)',
52
+ 'explicit-models': 'explicit args.models',
53
+ 'routing-not-requested': 'routing not requested',
54
+ 'coder-knob-codex': 'coder knob = codex',
55
+ 'planner-knob-codex': 'planner knob = codex',
56
+ 'qe-cross-family': 'cross-family QE (the coder never self-reviews)',
57
+ 'budget-table-cell': 'budget table cell',
58
+ 'default-models': 'default models table',
59
+ 'codex-id-substituted': 'unknown codex id — substituted',
60
+ 'spec-unrecognised': 'unrecognised spec — session-inherited',
61
+ // dispatch-level (11-20): what the runtime or current fallback selection did.
62
+ 'coder-fallback': 'coder fallback ladder',
63
+ 'codex-unsupported-at-dispatch': 'codex unsupported at this dispatch — Claude',
64
+ 'fallback-after-no-deliverable': 'fallback — the previous rung delivered nothing',
65
+ 'precision-second-pass': 'independent precision second pass',
66
+ 'auto-cost': 'learned-cost routing',
67
+ 'qe-same-family-degraded': 'same-family QE — cross-family review NOT obtained',
68
+ 'challenge-panel': 'adversarial plan-gate panel',
69
+ 'codex-probe-failed': 'codex probe found no usable id — Claude',
70
+ 'codex-refused-before-dispatch': 'codex refused before dispatching — nothing ran',
71
+ 'fallback-rung': 'fallback rung (prior outcome reported separately)',
72
+ };
73
+
74
+ /** Stages that write an artifact FILE out of band when they resolve to Codex, and therefore wait on
75
+ * the Codex-landed barrier before their consumer runs. Naming it in the line tells the reader why
76
+ * the run is about to sit still.
77
+ *
78
+ * The four DESIGN stages belong here too (R4-F5, cross-family review round 3): `designStage` runs an
79
+ * out-of-band artifact probe for any codex-resolved design stage, exactly as code and plan do, so a
80
+ * roster of only {code, plan} left the line silent about a barrier that was actually engaged. QE is
81
+ * deliberately absent — its codex path is a synchronous `codex exec`, not the wrapper — and so is
82
+ * delivery, whose planes never run on codex at all. */
83
+ export const STAGE_LINE_BARRIER_STAGES: Record<string, number> = {
84
+ code: 1,
85
+ plan: 1,
86
+ requirements: 1,
87
+ adr: 1,
88
+ ideation: 1,
89
+ architecture: 1,
90
+ };
91
+
92
+ /**
93
+ * One deterministic line: `▸ <stage> · <model or "session"> · <branch signature>`, plus
94
+ * ` · landed barrier` when this stage resolved to Codex AND uses the barrier.
95
+ *
96
+ * PURE: reads nothing, prints nothing, and depends on no state beyond its two arguments (AC-3).
97
+ */
98
+ function renderStagePrefix(stage: string, decision: StageLineDecision): string {
99
+ let model = 'session';
100
+ if (decision && decision.spec) model = decision.spec;
101
+ return '▸ ' + stage + ' · ' + model;
102
+ }
103
+
104
+ /** Timely statement of what the workflow is about to try. It is explicitly not proof of dispatch. */
105
+ export function renderStageIntentLine(stage: string, decision: StageLineDecision): string {
106
+ let signature = '';
107
+ if (decision) {
108
+ const labelled = STAGE_LINE_REASON_LABELS[decision.reason];
109
+ if (labelled) signature = labelled;
110
+ }
111
+ // An unlabelled reason must be VISIBLE, never silently blank: print the raw token so the hole is
112
+ // reportable from a real run instead of reading as "no reason at all".
113
+ if (!signature) {
114
+ signature = 'unlabelled branch';
115
+ if (decision && decision.reason) signature = 'unlabelled branch: ' + decision.reason;
116
+ }
117
+ let line = renderStagePrefix(stage, decision) + ' · ' + signature;
118
+ if (STAGE_LINE_BARRIER_STAGES[stage] && decision && decision.opts && decision.opts.agentType === 'codex:codex-rescue') {
119
+ line = line + ' · landed barrier';
120
+ }
121
+ return line + ' · intent';
122
+ }
123
+
124
+ /**
125
+ * Final fact for one rung. It deliberately ignores the selection reason: the outcome owns only
126
+ * this rung's state and refusal detail, so another rung's reason cannot leak into it.
127
+ */
128
+ export function renderStageOutcomeLine(
129
+ stage: string,
130
+ decision: StageLineDecision,
131
+ outcome: StageLineOutcome,
132
+ ): string {
133
+ let model = 'session';
134
+ if (decision && decision.spec) model = decision.spec;
135
+ let label = 'dispatched';
136
+ if (outcome && outcome.state === 'probe-failed') label = 'probe found no usable model';
137
+ else if (outcome && outcome.state === 'refused-before-dispatch') label = 'refused before dispatch';
138
+ let line = '◆ ' + stage + ' · ' + model + ' · outcome: ' + label;
139
+ if (outcome && outcome.state === 'refused-before-dispatch' && outcome.reason === 'codex-unsupported-at-dispatch') {
140
+ line = line + ' · codex agent type unsupported';
141
+ } else if (outcome && outcome.state === 'refused-before-dispatch' && outcome.reason) {
142
+ line = line + ' · reason: ' + outcome.reason;
143
+ }
144
+ return line;
145
+ }
146
+
147
+ /** One-release compatibility alias: the historical renderer is now explicitly the intent half. */
148
+ export function renderStageLine(stage: string, decision: StageLineDecision): string {
149
+ const intent = renderStageIntentLine(stage, decision);
150
+ return intent.slice(0, intent.length - ' · intent'.length);
151
+ }