@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.
- package/.dz-manifest.json +399 -139
- package/LICENSE +13 -0
- package/README.md +420 -6
- package/dist/agents-policy.d.ts +15 -1
- package/dist/agents-policy.d.ts.map +1 -1
- package/dist/agents-policy.js +27 -1
- package/dist/agents-policy.js.map +1 -1
- package/dist/amendment-trace.d.ts +72 -0
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +385 -17
- package/dist/amendment-trace.js.map +1 -1
- package/dist/backlog-public.d.ts +153 -0
- package/dist/backlog-public.d.ts.map +1 -0
- package/dist/backlog-public.js +415 -0
- package/dist/backlog-public.js.map +1 -0
- package/dist/backlog-transitions.d.ts +48 -0
- package/dist/backlog-transitions.d.ts.map +1 -0
- package/dist/backlog-transitions.js +64 -0
- package/dist/backlog-transitions.js.map +1 -0
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +13 -0
- package/dist/backlog.js.map +1 -1
- package/dist/claim-check.d.ts.map +1 -1
- package/dist/claim-check.js +24 -2
- package/dist/claim-check.js.map +1 -1
- package/dist/claude-hooks-assets.d.ts +93 -0
- package/dist/claude-hooks-assets.d.ts.map +1 -0
- package/dist/claude-hooks-assets.js +208 -0
- package/dist/claude-hooks-assets.js.map +1 -0
- package/dist/clean-room-smoke.d.ts +83 -0
- package/dist/clean-room-smoke.d.ts.map +1 -0
- package/dist/clean-room-smoke.js +138 -0
- package/dist/clean-room-smoke.js.map +1 -0
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +36 -6
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +25 -7
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +138 -30
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +21 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +21 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/course-staleness.d.ts +19 -0
- package/dist/course-staleness.d.ts.map +1 -0
- package/dist/course-staleness.js +95 -0
- package/dist/course-staleness.js.map +1 -0
- package/dist/destructive-guard-hook.d.ts +40 -0
- package/dist/destructive-guard-hook.d.ts.map +1 -0
- package/dist/destructive-guard-hook.js +109 -0
- package/dist/destructive-guard-hook.js.map +1 -0
- package/dist/destructive-guard.d.ts +27 -0
- package/dist/destructive-guard.d.ts.map +1 -0
- package/dist/destructive-guard.js +2808 -0
- package/dist/destructive-guard.js.map +1 -0
- package/dist/discrimination-gate.d.ts +28 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +76 -16
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +95 -1
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +193 -27
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +90 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +271 -0
- package/dist/guard.js.map +1 -1
- package/dist/harness-core-location.d.ts +18 -0
- package/dist/harness-core-location.d.ts.map +1 -0
- package/dist/harness-core-location.js +42 -0
- package/dist/harness-core-location.js.map +1 -0
- package/dist/index.d.ts +24 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -3
- package/dist/index.js.map +1 -1
- package/dist/lead-shift-gate.d.ts +95 -0
- package/dist/lead-shift-gate.d.ts.map +1 -0
- package/dist/lead-shift-gate.js +100 -0
- package/dist/lead-shift-gate.js.map +1 -0
- package/dist/ledger-backfill.d.ts +11 -1
- package/dist/ledger-backfill.d.ts.map +1 -1
- package/dist/ledger-backfill.js +19 -0
- package/dist/ledger-backfill.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +12 -3
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-lint.d.ts.map +1 -1
- package/dist/loop-lint.js +56 -7
- package/dist/loop-lint.js.map +1 -1
- package/dist/loop-plan-graph.d.ts +1 -3
- package/dist/loop-plan-graph.d.ts.map +1 -1
- package/dist/loop-plan-graph.js +70 -1
- package/dist/loop-plan-graph.js.map +1 -1
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +16 -2
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +5 -6
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +2 -2
- package/dist/managed-hooks.js.map +1 -1
- package/dist/model-recommender.d.ts.map +1 -1
- package/dist/model-recommender.js +14 -3
- package/dist/model-recommender.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +82 -0
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts +23 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +10 -0
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +11 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +16 -2
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +3 -2
- package/dist/registry.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +15 -4
- package/dist/score.js.map +1 -1
- package/dist/session-retro.d.ts +121 -2
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +454 -11
- package/dist/session-retro.js.map +1 -1
- package/dist/setup.d.ts +28 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +223 -6
- package/dist/setup.js.map +1 -1
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +18 -1
- package/dist/sign.js.map +1 -1
- package/dist/skills-verify.d.ts +34 -1
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +82 -0
- package/dist/skills-verify.js.map +1 -1
- package/dist/stage-line.d.ts +68 -0
- package/dist/stage-line.d.ts.map +1 -0
- package/dist/stage-line.js +129 -0
- package/dist/stage-line.js.map +1 -0
- package/dist/statusline.d.ts +99 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +310 -44
- package/dist/statusline.js.map +1 -1
- package/dist/store-counts.d.ts +26 -0
- package/dist/store-counts.d.ts.map +1 -0
- package/dist/store-counts.js +125 -0
- package/dist/store-counts.js.map +1 -0
- package/dist/store-guard.d.ts +106 -0
- package/dist/store-guard.d.ts.map +1 -0
- package/dist/store-guard.js +294 -0
- package/dist/store-guard.js.map +1 -0
- package/dist/swarm-brief.d.ts +95 -0
- package/dist/swarm-brief.d.ts.map +1 -0
- package/dist/swarm-brief.js +660 -0
- package/dist/swarm-brief.js.map +1 -0
- package/dist/trace-bundle.d.ts +8 -0
- package/dist/trace-bundle.d.ts.map +1 -1
- package/dist/trace-bundle.js +11 -0
- package/dist/trace-bundle.js.map +1 -1
- package/package.json +12 -11
- package/sbom.json +804 -154
- package/src/agents-policy.ts +46 -2
- package/src/amendment-trace.ts +441 -19
- package/src/backlog-public.ts +503 -0
- package/src/backlog-transitions.ts +77 -0
- package/src/backlog.ts +12 -0
- package/src/claim-check.ts +25 -2
- package/src/claude-hooks-assets.ts +227 -0
- package/src/clean-room-smoke.ts +195 -0
- package/src/cmd-usage.ts +29 -5
- package/src/codex-hooks-assets.ts +140 -30
- package/src/codex-hooks.ts +21 -1
- package/src/course-staleness.ts +125 -0
- package/src/destructive-guard-hook.ts +151 -0
- package/src/destructive-guard.ts +3027 -0
- package/src/discrimination-gate.ts +98 -19
- package/src/feature-adr-routing.ts +220 -22
- package/src/guard.ts +318 -0
- package/src/harness-core-location.ts +44 -0
- package/src/index.ts +111 -3
- package/src/lead-shift-gate.ts +145 -0
- package/src/ledger-backfill.ts +20 -1
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-lint.ts +52 -7
- package/src/loop-plan-graph.ts +66 -1
- package/src/loop-trace.ts +13 -1
- package/src/managed-hooks.ts +5 -6
- package/src/model-recommender.ts +14 -3
- package/src/operations.ts +75 -0
- package/src/patterns.ts +33 -0
- package/src/publish.ts +27 -2
- package/src/registry.ts +3 -2
- package/src/score.ts +16 -4
- package/src/session-retro.ts +466 -11
- package/src/setup.ts +246 -9
- package/src/sign.ts +18 -1
- package/src/skills-verify.ts +99 -1
- package/src/stage-line.ts +151 -0
- package/src/statusline.ts +396 -47
- package/src/store-counts.ts +154 -0
- package/src/store-guard.ts +388 -0
- package/src/swarm-brief.ts +661 -0
- package/src/trace-bundle.ts +10 -0
package/src/claim-check.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 {
|