@dzhechkov/harness-core 0.8.39 → 0.8.40

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 (62) hide show
  1. package/.dz-manifest.json +76 -56
  2. package/README.md +48 -0
  3. package/dist/cmd-usage.d.ts.map +1 -1
  4. package/dist/cmd-usage.js +48 -2
  5. package/dist/cmd-usage.js.map +1 -1
  6. package/dist/compounding.d.ts +66 -0
  7. package/dist/compounding.d.ts.map +1 -1
  8. package/dist/compounding.js +76 -9
  9. package/dist/compounding.js.map +1 -1
  10. package/dist/doctor-instrument.d.ts +65 -0
  11. package/dist/doctor-instrument.d.ts.map +1 -0
  12. package/dist/doctor-instrument.js +91 -0
  13. package/dist/doctor-instrument.js.map +1 -0
  14. package/dist/feature-tier.d.ts.map +1 -1
  15. package/dist/feature-tier.js +13 -1
  16. package/dist/feature-tier.js.map +1 -1
  17. package/dist/guard.d.ts +17 -0
  18. package/dist/guard.d.ts.map +1 -1
  19. package/dist/guard.js +15 -0
  20. package/dist/guard.js.map +1 -1
  21. package/dist/index.d.ts +5 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/mutation-gate.d.ts +30 -0
  26. package/dist/mutation-gate.d.ts.map +1 -1
  27. package/dist/mutation-gate.js +45 -1
  28. package/dist/mutation-gate.js.map +1 -1
  29. package/dist/operations.d.ts +13 -0
  30. package/dist/operations.d.ts.map +1 -1
  31. package/dist/operations.js +88 -2
  32. package/dist/operations.js.map +1 -1
  33. package/dist/registry.d.ts +58 -0
  34. package/dist/registry.d.ts.map +1 -1
  35. package/dist/registry.js +62 -5
  36. package/dist/registry.js.map +1 -1
  37. package/dist/release.d.ts +18 -0
  38. package/dist/release.d.ts.map +1 -1
  39. package/dist/release.js +30 -0
  40. package/dist/release.js.map +1 -1
  41. package/dist/round-exec.d.ts +10 -0
  42. package/dist/round-exec.d.ts.map +1 -1
  43. package/dist/round-exec.js +3 -2
  44. package/dist/round-exec.js.map +1 -1
  45. package/dist/round.d.ts +19 -0
  46. package/dist/round.d.ts.map +1 -1
  47. package/dist/round.js +1 -0
  48. package/dist/round.js.map +1 -1
  49. package/package.json +1 -1
  50. package/sbom.json +105 -55
  51. package/src/cmd-usage.ts +52 -2
  52. package/src/compounding.ts +112 -9
  53. package/src/doctor-instrument.ts +153 -0
  54. package/src/feature-tier.ts +14 -1
  55. package/src/guard.ts +24 -0
  56. package/src/index.ts +5 -2
  57. package/src/mutation-gate.ts +54 -1
  58. package/src/operations.ts +85 -3
  59. package/src/registry.ts +91 -1
  60. package/src/release.ts +36 -0
  61. package/src/round-exec.ts +13 -2
  62. package/src/round.ts +20 -0
package/src/operations.ts CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { execFileSync, spawnSync, type SpawnSyncOptionsWithStringEncoding } from 'node:child_process';
10
10
  import { randomBytes } from 'node:crypto';
11
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
11
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
12
12
  import { homedir, tmpdir } from 'node:os';
13
13
  import { dirname, join, resolve as resolvePath } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
@@ -51,6 +51,7 @@ import { WINDSURF_RULES_ROOT } from '@dzhechkov/adapter-windsurf';
51
51
  import { AGENTS_MD_BLOCK_BEGIN, mergeAgentsMd, mergeGeminiMd, mergePolicyBlock, renderAgentsMdSection } from '@dzhechkov/core';
52
52
  import type { CanonicalSkill, EmitResult, SkillAsset } from '@dzhechkov/core';
53
53
  import { computeRiskScore } from './risk-scoring.js';
54
+ import { checkInstrumentFreshness, checkRankingState } from './doctor-instrument.js';
54
55
 
55
56
  import { applyEmitResult } from './apply.js';
56
57
  import { describeSkillLoadFailure, discoverSkillIds, loadSkillFromDir } from './skills.js';
@@ -1039,6 +1040,12 @@ export function runMigrate(options: { projectRoot: string }): MigrateReport {
1039
1040
  export interface DoctorCheck {
1040
1041
  readonly name: string;
1041
1042
  readonly ok: boolean;
1043
+ /**
1044
+ * Rendering severity for a check that is NOT a failure. `warn` = measured and worth saying out
1045
+ * loud; `unknown` = the evidence could not be gathered, which is never rendered as a pass. Neither
1046
+ * value participates in `DoctorReport.ok`, so neither can change any caller's exit code.
1047
+ */
1048
+ readonly level?: 'warn' | 'unknown';
1042
1049
  readonly detail: string;
1043
1050
  }
1044
1051
 
@@ -1050,7 +1057,13 @@ export interface DoctorReport {
1050
1057
  }
1051
1058
 
1052
1059
  /** Report environment diagnostics for the harness. */
1053
- export async function runDoctor(options: { projectRoot: string }): Promise<DoctorReport> {
1060
+ /**
1061
+ * `instrumentPath` is the executable that is answering — normally the CLI's own `process.argv[1]`.
1062
+ * It is an INPUT, not something this module reads for itself: `core-boundary` rule A forbids core
1063
+ * from touching process globals, because core is a library and the process belongs to whoever hosts
1064
+ * it. Omitted ⇒ the instrument-freshness check honestly reports that it could not tell.
1065
+ */
1066
+ export async function runDoctor(options: { projectRoot: string; instrumentPath?: string | null }): Promise<DoctorReport> {
1054
1067
  const checks: DoctorCheck[] = [];
1055
1068
  const root = options.projectRoot;
1056
1069
 
@@ -1065,6 +1078,74 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1065
1078
  // monorepo (or a fork of it) and owes itself these checks; anything else is a consumer project
1066
1079
  // and gets a NAMED skip — a skip, never a silent pass and never a fail.
1067
1080
  const isMonorepo = existsSync(join(root, 'packages', '@dzhechkov'));
1081
+
1082
+ // The executable is part of the measurement. Resolve the CALLER-SUPPLIED path (including its
1083
+ // symlinks), then attribute it to the nearest package.json. Every read is independent and
1084
+ // fail-quiet so a broken install becomes an explicit UNKNOWN diagnostic instead of throwing.
1085
+ let binPath: string | null = null;
1086
+ try {
1087
+ const given = options.instrumentPath;
1088
+ binPath = given === undefined || given === null ? null : realpathSync(given);
1089
+ } catch { /* unresolved is reported by the pure decider */ }
1090
+ let binVersion: string | null = null;
1091
+ if (binPath !== null) {
1092
+ try {
1093
+ let cursor = dirname(binPath);
1094
+ while (true) {
1095
+ const packagePath = join(cursor, 'package.json');
1096
+ if (existsSync(packagePath)) {
1097
+ const parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as { version?: unknown };
1098
+ binVersion = typeof parsed.version === 'string' ? parsed.version : null;
1099
+ break;
1100
+ }
1101
+ const parent = dirname(cursor);
1102
+ if (parent === cursor) break;
1103
+ cursor = parent;
1104
+ }
1105
+ } catch { binVersion = null; }
1106
+ }
1107
+ let treeVersion: string | null = null;
1108
+ if (isMonorepo) {
1109
+ try {
1110
+ const parsed = JSON.parse(readFileSync(join(root, 'packages', '@dzhechkov', 'harness-cli', 'package.json'), 'utf8')) as { version?: unknown };
1111
+ treeVersion = typeof parsed.version === 'string' ? parsed.version : null;
1112
+ } catch { treeVersion = null; }
1113
+ }
1114
+ let realRoot = resolvePath(root);
1115
+ let realRootResolved = true;
1116
+ // A root that cannot be resolved through its symlinks makes the containment question undecidable
1117
+ // rather than false — the decider is told so explicitly instead of silently comparing two path
1118
+ // forms that need not agree.
1119
+ try { realRoot = realpathSync(root); } catch { realRootResolved = false; }
1120
+ const instrument = checkInstrumentFreshness({ binPath, binVersion, treeVersion, projectRoot: realRoot, projectRootRealpathed: realRootResolved, isMonorepo });
1121
+ checks.push({
1122
+ name: 'doctor instrument freshness',
1123
+ // The pure decider owns the verdict; this wiring only carries it. Never re-derive `ok` here.
1124
+ ok: true,
1125
+ ...(instrument.level === 'ok' ? {} : { level: instrument.level }),
1126
+ detail: instrument.detail,
1127
+ });
1128
+
1129
+ const rankingStatePath = join(realRoot, '.dz', 'lesson-bandit', 'state.json');
1130
+ let rankingFlagOn = false;
1131
+ try {
1132
+ const parsed = JSON.parse(readFileSync(join(root, '.dz', 'config.json'), 'utf8')) as {
1133
+ memory?: { learning?: { banditRerank?: unknown } };
1134
+ };
1135
+ rankingFlagOn = parsed.memory?.learning?.banditRerank === true;
1136
+ } catch { /* malformed config is owned by the config diagnostic; ranking defaults off */ }
1137
+ const ranking = checkRankingState({
1138
+ flagOn: rankingFlagOn,
1139
+ statePath: rankingStatePath,
1140
+ stateExists: existsSync(rankingStatePath),
1141
+ binPath,
1142
+ });
1143
+ checks.push({
1144
+ name: 'bandit ranking state',
1145
+ ok: true,
1146
+ ...(ranking.level === 'warn' ? { level: 'warn' as const } : {}),
1147
+ detail: ranking.detail,
1148
+ });
1068
1149
  checks.push({
1069
1150
  name: '.claude/skills present',
1070
1151
  ok: existsSync(join(root, '.claude', 'skills')),
@@ -1648,7 +1729,8 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1648
1729
  const text = readFileSync(p, 'utf-8');
1649
1730
  const v = verifyEventChainText(text);
1650
1731
  if (v.chained === 0 || v.ok) continue;
1651
- const total = text.split('\n').filter((l) => l.trim() !== '').length;
1732
+ // The verifier already counted non-empty lines; re-deriving it drifts (see cli.ts note).
1733
+ const total = v.lines;
1652
1734
  const age = classifyChainDefects(v, total);
1653
1735
  const named = `${v.defects.length} defect(s): ${v.defects.slice(0, 3).map((d) => `${d.kind}@L${d.line}`).join(', ')}`;
1654
1736
  // A break that an unbroken run has already outlived is not a reason to distrust today's
package/src/registry.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync, type Dirent } from 'node:fs';
11
- import { basename, dirname, join, relative, resolve } from 'node:path';
11
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
 
14
14
  import { stems } from './stem.js';
@@ -188,6 +188,42 @@ export function discoverSkillCarryingDirs(cwd: string): { pack: string; dir: str
188
188
  * drop the `unsigned` verdict for a `skills-*` pack that carries no manifest — turning "unsigned" into
189
189
  * "absent", which is the same class of silence this fixes.
190
190
  */
191
+ /**
192
+ * Чей это пак: проекта, который проверяют, или самого прибора, который проверяет.
193
+ *
194
+ * ИЗМЕРЕНО 2026-09-21 на одном репозитории в одну минуту: `dz` из PATH перечисляет 88 паков и
195
+ * сообщает «31 verified», сборка из дерева — 57 и «0 verified». Оба ответа ВЕРНЫ и расходятся
196
+ * ровно на 29 подписанных манифестов внутри глобальной установки: `discoverVerifiablePackDirs`
197
+ * намеренно дотягивается до упакованных копий самого прибора. Беда не в числе, а в строке, где оно
198
+ * стоит: «31 verified» рядом с именем проекта читается как охват ПРОЕКТА, хотя проверены копии
199
+ * инструмента. Разделение считается по пути, потому что только путь и отличает эти два случая.
200
+ *
201
+ * Сравнение путей РАЗДЕЛИТЕЛЬНОЕ: `/repo-2/x` не внутри `/repo`. Наивный `startsWith` этот репозиторий
202
+ * уже однажды отгружал.
203
+ */
204
+ export function packScope(dir: string, projectRoot: string): 'project' | 'instrument' {
205
+ const rel = relative(projectRoot, dir);
206
+ const inside = rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
207
+ return inside ? 'project' : 'instrument';
208
+ }
209
+
210
+ /**
211
+ * Уточнение к числу проверенных подписей: сколько из них — паки ЭТОГО проекта, а сколько принадлежат
212
+ * самому прибору. Возвращает ПУСТУЮ строку, когда уточнять нечего (ничего не проверено либо всё
213
+ * проверенное — проектное): приписка к нулю была бы шумом, а шум в строке охвата её и обесценивает.
214
+ */
215
+ export function verifiedScopeNote(
216
+ checks: readonly { readonly dir: string; readonly verdict: string }[],
217
+ projectRoot: string,
218
+ ): string {
219
+ const verified = checks.filter((c) => c.verdict === 'verified');
220
+ if (verified.length === 0) return '';
221
+ const outside = verified.filter((c) => packScope(c.dir, projectRoot) === 'instrument').length;
222
+ if (outside === 0) return '';
223
+ return ` (${verified.length - outside} in this project, ${outside} in the instrument's own install`
224
+ + " — not this project's artifacts)";
225
+ }
226
+
191
227
  export function discoverVerifiablePackDirs(cwd: string): { pack: string; dir: string }[] {
192
228
  const out: { pack: string; dir: string }[] = [];
193
229
  // Keyed on the RESOLVED path: a globally-installed `dz` reaches its own bundled packs as well as the
@@ -388,6 +424,60 @@ function categoryFromPack(pack: string): string {
388
424
  * `node_modules/@dzhechkov`, **and** the CLI's own install location. This is why
389
425
  * `dz registry` works for a globally-installed `dz`, not only inside the monorepo.
390
426
  */
427
+ /** One skill as the public showcase page (`marketplace/index.html`) renders it. */
428
+ export interface ShowcaseSkill {
429
+ readonly id: string;
430
+ readonly pack: string;
431
+ readonly description: string;
432
+ readonly trustTier: number;
433
+ readonly category: string;
434
+ /** The exact command a visitor copies. Derived, never stored — one spelling, one place. */
435
+ readonly install: string;
436
+ }
437
+
438
+ export interface ShowcaseRegistry {
439
+ readonly version: string;
440
+ readonly generated: string;
441
+ readonly totalSkills: number;
442
+ readonly totalPacks: number;
443
+ readonly categories: readonly string[];
444
+ readonly skills: readonly ShowcaseSkill[];
445
+ }
446
+
447
+ /**
448
+ * The public showcase catalogue, projected from the SAME {@link Registry} the plugin manifests come
449
+ * from — so the page and the manifests can never answer "how many skills are there" differently.
450
+ *
451
+ * Why this exists: MEASURED 2026-09-04 and re-measured 2026-09-20, `marketplace/registry.json` said
452
+ * 57 skills / 10 packs while `buildRegistry` counted 260 / 37, and the file's last commit was
453
+ * 2026-06-03 — because NOTHING wrote it. A grep for its path across the whole source tree returned
454
+ * zero writers (backlog 9595b21e). A catalogue with no generator does not go stale slowly; it stops
455
+ * being true the first time anything ships, and it was the one surface a visitor actually reads.
456
+ *
457
+ * `generated` is the caller's to supply: a timestamp minted inside would make the output
458
+ * non-reproducible and the drift gate unable to compare a fresh build against the published file.
459
+ */
460
+ export function buildShowcaseRegistry(
461
+ registry: Registry,
462
+ opts: { readonly version: string; readonly generated: string },
463
+ ): ShowcaseRegistry {
464
+ return {
465
+ version: opts.version,
466
+ generated: opts.generated,
467
+ totalSkills: registry.totalSkills,
468
+ totalPacks: registry.totalPacks,
469
+ categories: [...registry.categories],
470
+ skills: registry.entries.map((e) => ({
471
+ id: e.id,
472
+ pack: e.pack,
473
+ description: e.description,
474
+ trustTier: e.trustTier,
475
+ category: e.category,
476
+ install: `dz init --target claude-code --select ${e.id}`,
477
+ })),
478
+ };
479
+ }
480
+
391
481
  export function buildRegistry(cwd: string): Registry {
392
482
  const entries: RegistryEntry[] = [];
393
483
  const packs = discoverSkillCarryingDirs(cwd);
package/src/release.ts CHANGED
@@ -929,6 +929,42 @@ function fencedBlock(text: string, indent = ' '): string[] {
929
929
  return [`${indent}${fence}`, ...contentLines, `${indent}${fence}`];
930
930
  }
931
931
 
932
+ /**
933
+ * Формы отказа `gh`, означающие «учётные данные не приняты», а не «команда не та».
934
+ * Список узкий намеренно: широкая сетка превратила бы любой сбой в повод лезть в окружение.
935
+ */
936
+ const GH_AUTH_FAILURE_SHAPES: readonly RegExp[] = [
937
+ /no longer valid/i,
938
+ /bad credentials/i,
939
+ /authentication failed/i,
940
+ /requires authentication/i,
941
+ /gh auth login/i,
942
+ /HTTP 401/i,
943
+ ];
944
+
945
+ /**
946
+ * Стоит ли повторить вызов `gh` БЕЗ переменной `GITHUB_TOKEN`.
947
+ *
948
+ * ИЗМЕРЕНО 2026-09-21 в этой среде: `gh auth status` → «the github.com token in GITHUB_TOKEN is no
949
+ * longer valid», а `env -u GITHUB_TOKEN gh auth status` → вход как djd1m через keyring. То есть
950
+ * мёртвая переменная ЗАТЕНЯЕТ рабочие учётные данные, и всякий вызов `gh` из скрипта падает
951
+ * (бэклог ead5f8e0). Переменная живёт в окружении tmux-сервера и снимается только владельцем.
952
+ *
953
+ * Почему повтор, а не безусловное снятие: в сборочной среде `GITHUB_TOKEN` — ШТАТНЫЙ способ
954
+ * авторизации, и выбрасывать его всегда значило бы ломать работающее ради сломанного. Поэтому
955
+ * сначала обычный вызов, и лишь на отказе ИМЕННО по авторизации — одна попытка без переменной.
956
+ */
957
+ export function shouldRetryGhWithoutToken(input: {
958
+ readonly exitCode: number;
959
+ readonly stderr: string;
960
+ readonly stdout: string;
961
+ readonly tokenPresent: boolean;
962
+ }): boolean {
963
+ if (input.exitCode === 0 || !input.tokenPresent) return false;
964
+ const text = `${input.stderr}\n${input.stdout}`;
965
+ return GH_AUTH_FAILURE_SHAPES.some((re) => re.test(text));
966
+ }
967
+
932
968
  /**
933
969
  * gh-2.4-safe `gh issue create` payload (only `--title`/`--body` are assumed downstream).
934
970
  * Pure + deterministic for a fixed verdict — the issue is the verdict's echo, never its judge.
package/src/round-exec.ts CHANGED
@@ -37,14 +37,25 @@ export function classifyRoundExecOutcome(input: {
37
37
  readonly exitCode: number | null;
38
38
  readonly timedOut: boolean;
39
39
  readonly bytes: number;
40
+ /** How the run ENDED: the limit/refusal signatures are end-state facts, so they read the tail. */
40
41
  readonly tail: string;
42
+ /**
43
+ * The WHOLE log, when the caller has it. The turn marker is not an end-state fact — it is the last
44
+ * `\ncodex\n` ANYWHERE in the log — and a fixed tail window cannot hold it: MEASURED 2026-09-20 on
45
+ * three real rounds, Codex's final answer ran 12–20 KB (it quotes runner output), so the marker sat
46
+ * at ~95% of the file and fell outside the caller's 4 KB tail. All three runs had landed their files
47
+ * and were green, and all three were recorded `failed`. Defaults to `tail` for callers that only
48
+ * have the window.
49
+ */
50
+ readonly fullText?: string;
41
51
  }): RoundExecOutcome {
42
52
  if (input.timedOut) return 'timeout';
43
53
  if (/rate limit|usage limit|limit reached/i.test(input.tail)) return 'session-limit';
44
54
  if (/HTTP 400|not supported when using Codex/i.test(input.tail)) return 'model-refused';
45
55
  if (input.exitCode === 0 && input.bytes > 0) {
46
- const marker = input.tail.lastIndexOf('\ncodex\n');
47
- const finalLine = marker < 0 ? '' : input.tail.slice(marker + '\ncodex\n'.length).split(/\r?\n/, 1)[0]?.trim() ?? '';
56
+ const haystack = input.fullText ?? input.tail;
57
+ const marker = haystack.lastIndexOf('\ncodex\n');
58
+ const finalLine = marker < 0 ? '' : haystack.slice(marker + '\ncodex\n'.length).split(/\r?\n/, 1)[0]?.trim() ?? '';
48
59
  if (finalLine !== '') return 'done';
49
60
  }
50
61
  if (input.exitCode === 0 && input.bytes === 0) return 'empty';
package/src/round.ts CHANGED
@@ -41,9 +41,28 @@ export interface RoundExecState {
41
41
  }
42
42
 
43
43
  /** Additive row shape accepted by the existing run-cost ledger readers. */
44
+ /**
45
+ * The row this version WRITES. It is deliberately not the shape the FILE holds: the ledger is
46
+ * append-only, so rows written before a field existed do not carry it. Read a stored line as
47
+ * `unknown` and put it through {@link validateClosedRoundLedgerRow} or your own guard — typing a
48
+ * historical line with this interface and dereferencing a later field as a guaranteed string is the
49
+ * defect this note exists to prevent (named by cross-family review, Codex gpt-5.6-sol, 2026-09-21).
50
+ */
44
51
  export interface RoundLedgerRow {
45
52
  readonly slug: string;
46
53
  readonly stage: 'round';
54
+ /**
55
+ * This round's identity as a FIELD. It is also the first token of `note`, and that prefix is what
56
+ * the confirmation read greps for — but a key that lives inside prose can only be joined by
57
+ * parsing prose. MEASURED on the COMMITTED `.dz/feature-adr/run-cost-ledger.jsonl` at `488f596c`
58
+ * (backlog c60cc857, whose own numbers had gone stale): of 436 rows, 99 are `round`, and the
59
+ * round's own identity sat in a FIELD 0 times out of 99 while sitting inside `note` prose 99 times
60
+ * out of 99. Other id fields exist on those rows — 28 carry `taskId`, 76 carry `stateId` — but
61
+ * they identify the TASK and the STATE FILE, not the round's ledger identity; 22 carry none of
62
+ * `runId`/`taskId`/`stateId` (those three exactly — `runnerId`, the host, is on all 99).
63
+ * Reproducer and pinning commit: package README, section `roundId`.
64
+ */
65
+ readonly roundId: string;
47
66
  readonly tier: null;
48
67
  readonly coder: string | null;
49
68
  readonly reviewer: string | null;
@@ -552,6 +571,7 @@ export function closeRound(input: {
552
571
  const row: RoundLedgerRow = {
553
572
  slug: input.state.slug,
554
573
  stage: 'round',
574
+ roundId: marker,
555
575
  tier: null,
556
576
  coder: nonEmpty(input.coder) ? input.coder.trim() : null,
557
577
  reviewer,