@dzhechkov/harness-core 0.8.39 → 0.8.41

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 (161) hide show
  1. package/.dz-manifest.json +235 -135
  2. package/README.md +91 -8
  3. package/dist/__tests__/golden-baseline.test.js +1 -1
  4. package/dist/__tests__/golden-baseline.test.js.map +1 -1
  5. package/dist/agentdb-index.js +3 -3
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/agentdb-reindex-marker.d.ts +2 -2
  8. package/dist/agentdb-reindex-marker.js +4 -4
  9. package/dist/agentdb-reindex-marker.js.map +1 -1
  10. package/dist/agentdb-snapshot-rotation.d.ts +1 -1
  11. package/dist/agentdb-snapshot-rotation.js +1 -1
  12. package/dist/backup-freshness.d.ts +32 -0
  13. package/dist/backup-freshness.d.ts.map +1 -0
  14. package/dist/backup-freshness.js +69 -0
  15. package/dist/backup-freshness.js.map +1 -0
  16. package/dist/cmd-usage.d.ts +2 -2
  17. package/dist/cmd-usage.d.ts.map +1 -1
  18. package/dist/cmd-usage.js +56 -8
  19. package/dist/cmd-usage.js.map +1 -1
  20. package/dist/codex-hooks-assets.d.ts +2 -1
  21. package/dist/codex-hooks-assets.d.ts.map +1 -1
  22. package/dist/codex-hooks-assets.js +15 -2
  23. package/dist/codex-hooks-assets.js.map +1 -1
  24. package/dist/compounding.d.ts +109 -1
  25. package/dist/compounding.d.ts.map +1 -1
  26. package/dist/compounding.js +172 -9
  27. package/dist/compounding.js.map +1 -1
  28. package/dist/discrimination-gate.d.ts +0 -3
  29. package/dist/discrimination-gate.d.ts.map +1 -1
  30. package/dist/discrimination-gate.js +2 -2
  31. package/dist/discrimination-gate.js.map +1 -1
  32. package/dist/doctor-instrument.d.ts +65 -0
  33. package/dist/doctor-instrument.d.ts.map +1 -0
  34. package/dist/doctor-instrument.js +91 -0
  35. package/dist/doctor-instrument.js.map +1 -0
  36. package/dist/feature-tier.d.ts.map +1 -1
  37. package/dist/feature-tier.js +13 -1
  38. package/dist/feature-tier.js.map +1 -1
  39. package/dist/guard.d.ts +39 -2
  40. package/dist/guard.d.ts.map +1 -1
  41. package/dist/guard.js +117 -14
  42. package/dist/guard.js.map +1 -1
  43. package/dist/index.d.ts +20 -9
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +14 -6
  46. package/dist/index.js.map +1 -1
  47. package/dist/integration-apply.js +2 -2
  48. package/dist/integration-apply.js.map +1 -1
  49. package/dist/lesson-payoff.js +3 -3
  50. package/dist/lesson-payoff.js.map +1 -1
  51. package/dist/mutation-gate.d.ts +30 -0
  52. package/dist/mutation-gate.d.ts.map +1 -1
  53. package/dist/mutation-gate.js +45 -1
  54. package/dist/mutation-gate.js.map +1 -1
  55. package/dist/named-lock.d.ts +5 -2
  56. package/dist/named-lock.d.ts.map +1 -1
  57. package/dist/named-lock.js +30 -10
  58. package/dist/named-lock.js.map +1 -1
  59. package/dist/operations.d.ts +13 -0
  60. package/dist/operations.d.ts.map +1 -1
  61. package/dist/operations.js +112 -2
  62. package/dist/operations.js.map +1 -1
  63. package/dist/pack-inventory.d.ts +51 -0
  64. package/dist/pack-inventory.d.ts.map +1 -0
  65. package/dist/pack-inventory.js +306 -0
  66. package/dist/pack-inventory.js.map +1 -0
  67. package/dist/package-skill-layouts.d.ts +3 -2
  68. package/dist/package-skill-layouts.d.ts.map +1 -1
  69. package/dist/package-skill-layouts.js +3 -2
  70. package/dist/package-skill-layouts.js.map +1 -1
  71. package/dist/patterns.d.ts +8 -0
  72. package/dist/patterns.d.ts.map +1 -1
  73. package/dist/patterns.js +11 -1
  74. package/dist/patterns.js.map +1 -1
  75. package/dist/publish.d.ts +29 -7
  76. package/dist/publish.d.ts.map +1 -1
  77. package/dist/publish.js +92 -15
  78. package/dist/publish.js.map +1 -1
  79. package/dist/registry.d.ts +58 -0
  80. package/dist/registry.d.ts.map +1 -1
  81. package/dist/registry.js +86 -25
  82. package/dist/registry.js.map +1 -1
  83. package/dist/release.d.ts +18 -0
  84. package/dist/release.d.ts.map +1 -1
  85. package/dist/release.js +30 -0
  86. package/dist/release.js.map +1 -1
  87. package/dist/reqe-verdict.d.ts +55 -0
  88. package/dist/reqe-verdict.d.ts.map +1 -0
  89. package/dist/reqe-verdict.js +173 -0
  90. package/dist/reqe-verdict.js.map +1 -0
  91. package/dist/reqe.d.ts +35 -14
  92. package/dist/reqe.d.ts.map +1 -1
  93. package/dist/reqe.js +85 -32
  94. package/dist/reqe.js.map +1 -1
  95. package/dist/round-exec.d.ts +10 -0
  96. package/dist/round-exec.d.ts.map +1 -1
  97. package/dist/round-exec.js +3 -2
  98. package/dist/round-exec.js.map +1 -1
  99. package/dist/round.d.ts +29 -0
  100. package/dist/round.d.ts.map +1 -1
  101. package/dist/round.js +8 -0
  102. package/dist/round.js.map +1 -1
  103. package/dist/session-retro.d.ts +1 -1
  104. package/dist/session-retro.js +3 -3
  105. package/dist/session-retro.js.map +1 -1
  106. package/dist/skill-drift.d.ts +18 -1
  107. package/dist/skill-drift.d.ts.map +1 -1
  108. package/dist/skill-drift.js +46 -13
  109. package/dist/skill-drift.js.map +1 -1
  110. package/dist/statusline.js +2 -2
  111. package/dist/statusline.js.map +1 -1
  112. package/dist/store-guard.js +3 -3
  113. package/dist/store-guard.js.map +1 -1
  114. package/dist/store-lock.d.ts +9 -0
  115. package/dist/store-lock.d.ts.map +1 -1
  116. package/dist/store-lock.js +22 -3
  117. package/dist/store-lock.js.map +1 -1
  118. package/dist/test-receipt.d.ts +66 -0
  119. package/dist/test-receipt.d.ts.map +1 -0
  120. package/dist/test-receipt.js +73 -0
  121. package/dist/test-receipt.js.map +1 -0
  122. package/dist/workflow-run.d.ts.map +1 -1
  123. package/dist/workflow-run.js +7 -8
  124. package/dist/workflow-run.js.map +1 -1
  125. package/package.json +1 -1
  126. package/sbom.json +384 -134
  127. package/src/__tests__/golden-baseline.test.ts +1 -1
  128. package/src/agentdb-index.ts +3 -3
  129. package/src/agentdb-reindex-marker.ts +4 -4
  130. package/src/agentdb-snapshot-rotation.ts +1 -1
  131. package/src/backup-freshness.ts +96 -0
  132. package/src/cmd-usage.ts +59 -8
  133. package/src/codex-hooks-assets.ts +15 -2
  134. package/src/compounding.ts +244 -10
  135. package/src/discrimination-gate.ts +2 -5
  136. package/src/doctor-instrument.ts +153 -0
  137. package/src/feature-tier.ts +14 -1
  138. package/src/guard.ts +128 -17
  139. package/src/index.ts +28 -7
  140. package/src/integration-apply.ts +2 -2
  141. package/src/lesson-payoff.ts +3 -3
  142. package/src/mutation-gate.ts +54 -1
  143. package/src/named-lock.ts +36 -11
  144. package/src/operations.ts +109 -3
  145. package/src/pack-inventory.ts +313 -0
  146. package/src/package-skill-layouts.ts +3 -2
  147. package/src/patterns.ts +18 -1
  148. package/src/publish.ts +101 -22
  149. package/src/registry.ts +114 -18
  150. package/src/release.ts +36 -0
  151. package/src/reqe-verdict.ts +174 -0
  152. package/src/reqe.ts +111 -35
  153. package/src/round-exec.ts +13 -2
  154. package/src/round.ts +38 -0
  155. package/src/session-retro.ts +3 -3
  156. package/src/skill-drift.ts +66 -12
  157. package/src/statusline.ts +2 -2
  158. package/src/store-guard.ts +3 -3
  159. package/src/store-lock.ts +20 -3
  160. package/src/test-receipt.ts +106 -0
  161. package/src/workflow-run.ts +7 -8
package/src/publish.ts CHANGED
@@ -38,12 +38,29 @@ export type ProbeOutcome = {
38
38
  export const REGISTRY_PROBE_BUDGET = 90;
39
39
  export const REGISTRY_PROBE_INTERVAL_MS = 10_000;
40
40
 
41
+ export type RegistryProbe =
42
+ | { kind: 'published'; version: string; scripted?: true }
43
+ | { kind: 'never-published'; scripted?: true }
44
+ | { kind: 'unknown'; reason: string; scripted?: true };
45
+
46
+ /**
47
+ * Scripted registry seam for tests that spawn the REAL bin and therefore cannot inject `exec`
48
+ * (first-publish-not-offline AM-A; same shape as `WF_RUN_DISPATCH_SCRIPT_ENV`). Value: path to a JSON
49
+ * file `{ "<name>": "<x.y.z>" | "E404" | "<npm code>" }`. When set, no `npm view` runs at all and EVERY
50
+ * row carries `probeOverride: true` — a scripted plan can never read as a verified one.
51
+ */
52
+ export const PUBLISH_PROBE_SCRIPT_ENV = 'DZ_PUBLISH_PROBE_SCRIPT';
53
+
41
54
  /** Result for a single package publish attempt. */
42
55
  export interface PublishResult {
43
56
  readonly name: string;
44
57
  readonly oldVersion: string;
45
58
  readonly newVersion: string;
46
59
  readonly status: 'published' | 'skipped' | 'error';
60
+ readonly firstPublish?: boolean;
61
+ readonly probe?: RegistryProbe['kind'];
62
+ /** Present (true) only when `DZ_PUBLISH_PROBE_SCRIPT` answered instead of the registry. */
63
+ readonly probeOverride?: boolean;
47
64
  readonly error?: string | undefined;
48
65
  /** Live publish only: how many registry probes were needed to confirm the exact new version. */
49
66
  readonly registryProbes?: number | undefined;
@@ -176,26 +193,54 @@ export function compareVersions(a: string, b: string): number {
176
193
  return a0 - b0 || a1 - b1 || a2 - b2;
177
194
  }
178
195
 
179
- /**
180
- * The version already published to npm for `name`, or `undefined` if the package
181
- * has never been published (or npm is unreachable). Used to bump from
182
- * max(local, published) so a locally-reverted version can't collide (audit #10).
183
- */
184
196
  type PublishExec = (command: string, options: ExecSyncOptionsWithStringEncoding) => string;
185
197
 
186
- function publishedVersion(name: string, exec: PublishExec = execSync): string | undefined {
198
+ /** Only explicit npm absence signals establish that a package has never been published. */
199
+ export function classifyRegistryProbe(stderr: string, message: string): RegistryProbe {
200
+ const detail = `${stderr}\n${message}`;
201
+ if (/\bcode E404\b/.test(detail) || /\b404 Not Found\b/.test(detail) || /is not in this registry/.test(detail)) {
202
+ return { kind: 'never-published' };
203
+ }
204
+ const reason = /npm error code (\S+)/.exec(detail)?.[1]
205
+ ?? stderr.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0)
206
+ ?? message;
207
+ return { kind: 'unknown', reason };
208
+ }
209
+
210
+ function scriptedProbe(name: string): RegistryProbe | undefined {
211
+ const scriptPath = process.env[PUBLISH_PROBE_SCRIPT_ENV];
212
+ if (typeof scriptPath !== 'string' || scriptPath === '') return undefined;
213
+ let table: Record<string, unknown>;
187
214
  try {
188
- const out = exec(`npm view ${name} version --prefer-online`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', timeout: 20000 }).trim();
189
- return /^\d+\.\d+\.\d+/.test(out) ? out : undefined;
190
- } catch {
191
- return undefined; // 404 (never published) or offline → fall back to local
215
+ table = JSON.parse(readFileSync(scriptPath, 'utf-8')) as Record<string, unknown>;
216
+ } catch (err) {
217
+ return { kind: 'unknown', reason: `scripted probe unreadable: ${err instanceof Error ? err.message : String(err)}`, scripted: true };
218
+ }
219
+ const answer = table[name];
220
+ if (typeof answer !== 'string') return { kind: 'unknown', reason: `scripted probe has no entry for ${name}`, scripted: true };
221
+ if (/^\d+\.\d+\.\d+/.test(answer)) return { kind: 'published', version: answer, scripted: true };
222
+ if (answer === 'E404') return { kind: 'never-published', scripted: true };
223
+ return { kind: 'unknown', reason: answer, scripted: true };
224
+ }
225
+
226
+ function probeRegistry(name: string, exec: PublishExec = execSync): RegistryProbe {
227
+ const scripted = scriptedProbe(name);
228
+ if (scripted !== undefined) return scripted;
229
+ try {
230
+ const out = exec(`npm view ${name} version --prefer-online`, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', timeout: 20000 }).trim();
231
+ return /^\d+\.\d+\.\d+/.test(out)
232
+ ? { kind: 'published', version: out }
233
+ : { kind: 'unknown', reason: 'unparsable npm view output' };
234
+ } catch (err) {
235
+ const error = err as { stderr?: unknown; message?: unknown } | null | undefined;
236
+ return classifyRegistryProbe(String(error?.stderr ?? ''), String(error?.message ?? ''));
192
237
  }
193
238
  }
194
239
 
195
240
  /** The higher of the local version and the npm-published version (audit #10). */
196
241
  function maxPublished(name: string, localVersion: string, exec: PublishExec = execSync): string {
197
- const pub = publishedVersion(name, exec);
198
- return pub !== undefined && compareVersions(pub, localVersion) > 0 ? pub : localVersion;
242
+ const probe = probeRegistry(name, exec);
243
+ return probe.kind === 'published' && compareVersions(probe.version, localVersion) > 0 ? probe.version : localVersion;
199
244
  }
200
245
 
201
246
  // ── workspace-floor preflight (feature workspace-dep-protocol, Codex P1) ─────
@@ -829,6 +874,8 @@ export function publishPackages(
829
874
  opts: {
830
875
  dryRun?: boolean | undefined;
831
876
  filter?: string[] | undefined;
877
+ /** Exact CLI-selected batch; an empty list means no targets. Discovery remains unfiltered. */
878
+ targetNames?: readonly string[] | undefined;
832
879
  bumpOnly?: boolean | undefined;
833
880
  /**
834
881
  * Path to the Ed25519 signing key, OUTSIDE the repository. A pack that carries a
@@ -860,8 +907,8 @@ export function publishPackages(
860
907
  /**
861
908
  * Floor probe injection for the workspace-floor preflight (see
862
909
  * `findUnpublishedWorkspaceFloors`). Default: a real `npm view` probe, which runs only on LIVE
863
- * publishes — dry-run stays offline, matching `maxPublished`. Injecting a probe also arms the
864
- * preflight under dry-run, which is how the wiring test drives it without network.
910
+ * publishes. Injecting a probe also arms this floor preflight under dry-run. The package's
911
+ * own registry classification runs in both live and dry-run modes independently.
865
912
  */
866
913
  probeFloor?: ((name: string, version: string) => boolean) | undefined;
867
914
  /** Subprocess injection for tests; the default is Node's synchronous executor. */
@@ -907,9 +954,13 @@ export function publishPackages(
907
954
 
908
955
  const packages = discoverPackages(monorepoRoot);
909
956
  const results: PublishResult[] = [];
910
- const filtered = opts.filter && opts.filter.length > 0
957
+ const registryProbes = new Map<string, RegistryProbe>();
958
+ const matching = opts.filter && opts.filter.length > 0
911
959
  ? packages.filter((p) => opts.filter!.some((f) => matchesPublishFilter(p, f, monorepoRoot)))
912
960
  : packages;
961
+ const filtered = opts.targetNames === undefined
962
+ ? matching
963
+ : matching.filter((p) => opts.targetNames!.includes(p.name));
913
964
  // Publish dependencies before dependents so pnpm rewrites workspace:* to the
914
965
  // freshly-bumped version, never a stale one (the harness-cli@0.3.122 breakage).
915
966
  const ordered = orderByDependencies(filtered);
@@ -1014,11 +1065,29 @@ export function publishPackages(
1014
1065
  failedInBatch.add(pkg.name);
1015
1066
  continue;
1016
1067
  }
1017
- // Bump from max(local, npm-published) so a locally-reverted version can't
1018
- // collide with an already-published one (audit #10). Dry-run stays offline
1019
- // (local only) to keep previews fast and network-free.
1020
- const base = opts.dryRun ? oldVersion : maxPublished(pkg.name, oldVersion, exec);
1021
- const newVersion = bumpPatch(base);
1068
+ // Both modes establish registry state before planning: absence keeps the disk version,
1069
+ // uncertainty refuses a bump, and an existing release bumps from max(local, published).
1070
+ const registryProbe = probeRegistry(pkg.name, exec);
1071
+ registryProbes.set(pkg.name, registryProbe);
1072
+ if (registryProbe.kind === 'unknown') {
1073
+ results.push({
1074
+ name: pkg.name, oldVersion, newVersion: oldVersion,
1075
+ status: opts.dryRun ? 'skipped' : 'error',
1076
+ probe: 'unknown',
1077
+ error: opts.dryRun
1078
+ ? `NOT ESTABLISHED (registry unreachable: ${registryProbe.reason})`
1079
+ : `registry unreachable: ${registryProbe.reason} — cannot tell "never published" from "offline"; not bumping blind`,
1080
+ });
1081
+ if (!opts.dryRun) failedInBatch.add(pkg.name);
1082
+ continue;
1083
+ }
1084
+ const plan = {
1085
+ base: registryProbe.kind === 'published' && compareVersions(registryProbe.version, oldVersion) > 0
1086
+ ? registryProbe.version : oldVersion,
1087
+ firstPublish: registryProbe.kind === 'never-published',
1088
+ probe: registryProbe.kind,
1089
+ };
1090
+ const newVersion = plan.firstPublish ? oldVersion : bumpPatch(plan.base);
1022
1091
 
1023
1092
  // Preflight: refuse to publish a pack whose `files` whitelist would silently
1024
1093
  // drop a skill dir from the tarball (the bug that shipped skills-meta without
@@ -1113,7 +1182,7 @@ export function publishPackages(
1113
1182
  if (opts.dryRun) {
1114
1183
  // NOT a statement that the package would publish cleanly — only that the gates checked ABOVE
1115
1184
  // this line passed. Everything below it (build, re-sign, pack, the package's own
1116
- // `prepublishOnly`, the registry itself) is untouched by a dry run and is named as such.
1185
+ // `prepublishOnly`, the registry's publish receipt) is untouched by a dry run and named as such.
1117
1186
  const NOT_VERIFIED_BY_DRY_RUN = Object.freeze([
1118
1187
  'prepublishOnly пакета (его собственный гейт публикации)',
1119
1188
  'сборка dist из исходников',
@@ -1492,7 +1561,17 @@ export function publishPackages(
1492
1561
  }
1493
1562
 
1494
1563
  return {
1495
- packages: results,
1564
+ // Decorate every final path, including packed transport and gate failures. A dependency
1565
+ // blocked before its own probe has unknown registry state; keep the deps-first short circuit.
1566
+ packages: results.map((result) => {
1567
+ const registryProbe = registryProbes.get(result.name);
1568
+ return {
1569
+ ...result,
1570
+ probe: registryProbe?.kind ?? 'unknown',
1571
+ ...(registryProbe?.kind === 'never-published' ? { firstPublish: true } : {}),
1572
+ ...(registryProbe?.scripted === true ? { probeOverride: true } : {}),
1573
+ };
1574
+ }),
1496
1575
  published: results.filter((r) => r.status === 'published').length,
1497
1576
  skipped: results.filter((r) => r.status === 'skipped').length,
1498
1577
  errors: results.filter((r) => r.status === 'error').length,
package/src/registry.ts CHANGED
@@ -8,9 +8,10 @@
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
+ import { PACKAGE_SKILL_LAYOUTS } from './package-skill-layouts.js';
14
15
  import { stems } from './stem.js';
15
16
 
16
17
  /**
@@ -81,11 +82,13 @@ function readSkillDiscoveryConfig(cwd: string): { skillScopes: string[]; skillDi
81
82
  /** List `skills-*` pack directories across all base dirs, de-duplicated by pack name (first wins). */
82
83
  /**
83
84
  * Does this directory actually carry skills? Answers by LOOKING, so a pack is catalogued for what it
84
- * contains rather than for how it is named. Bounded on purpose: only the three layouts real packs
85
- * use (`skills/<id>/SKILL.md`, `<pack>/<id>/SKILL.md` for a single-skill pack, and a bare
86
- * `SKILL.md`), never a full-tree walk — an unbounded scan over `node_modules` would cost more than
87
- * the catalogue it builds. Templates are excluded: a template is a stamp for making skills, not an
88
- * installed skill, and counting it would list the same name twice.
85
+ * contains rather than for how it is named. Probes PACKAGE_SKILL_LAYOUTS — the ONE table that says
86
+ * where a package keeps its skills (backlog 86b787b8: the same three layouts used to be spelled out
87
+ * by hand here and in `buildRegistry`, so the next layout would land in one place and be forgotten
88
+ * in the other). Bounded on purpose: never a full-tree walk, because an unbounded scan over
89
+ * `node_modules` would cost more than the catalogue it builds. For the flat layout the package
90
+ * itself may also be a skill (a bare `SKILL.md`). `hasSkillMd` skips a `templates` child so a
91
+ * template stamp is not counted twice — it is reached only through its own allowed layout.
89
92
  */
90
93
  function packCarriesSkills(dir: string): boolean {
91
94
  const hasSkillMd = (d: string): boolean => {
@@ -98,13 +101,11 @@ function packCarriesSkills(dir: string): boolean {
98
101
  return false;
99
102
  };
100
103
  try {
101
- if (existsSync(join(dir, 'SKILL.md'))) return true;
102
- if (hasSkillMd(join(dir, 'skills'))) return true;
103
- // A template pack ships the skills it will roll out into the user's project. From the
104
- // catalogue's point of view those skills EXIST — `trip-planner` and `presentation-storyteller`
105
- // are installable answers to a task — so hiding them makes the advisor deny a real capability.
106
- if (hasSkillMd(join(dir, 'templates', '.claude', 'skills'))) return true;
107
- return hasSkillMd(dir);
104
+ return PACKAGE_SKILL_LAYOUTS.some(({ rel }) => {
105
+ const root = rel === '.' ? dir : join(dir, rel);
106
+ if (rel === '.' && existsSync(join(root, 'SKILL.md'))) return true;
107
+ return hasSkillMd(root);
108
+ });
108
109
  } catch {
109
110
  return false;
110
111
  }
@@ -188,6 +189,42 @@ export function discoverSkillCarryingDirs(cwd: string): { pack: string; dir: str
188
189
  * drop the `unsigned` verdict for a `skills-*` pack that carries no manifest — turning "unsigned" into
189
190
  * "absent", which is the same class of silence this fixes.
190
191
  */
192
+ /**
193
+ * Чей это пак: проекта, который проверяют, или самого прибора, который проверяет.
194
+ *
195
+ * ИЗМЕРЕНО 2026-09-21 на одном репозитории в одну минуту: `dz` из PATH перечисляет 88 паков и
196
+ * сообщает «31 verified», сборка из дерева — 57 и «0 verified». Оба ответа ВЕРНЫ и расходятся
197
+ * ровно на 29 подписанных манифестов внутри глобальной установки: `discoverVerifiablePackDirs`
198
+ * намеренно дотягивается до упакованных копий самого прибора. Беда не в числе, а в строке, где оно
199
+ * стоит: «31 verified» рядом с именем проекта читается как охват ПРОЕКТА, хотя проверены копии
200
+ * инструмента. Разделение считается по пути, потому что только путь и отличает эти два случая.
201
+ *
202
+ * Сравнение путей РАЗДЕЛИТЕЛЬНОЕ: `/repo-2/x` не внутри `/repo`. Наивный `startsWith` этот репозиторий
203
+ * уже однажды отгружал.
204
+ */
205
+ export function packScope(dir: string, projectRoot: string): 'project' | 'instrument' {
206
+ const rel = relative(projectRoot, dir);
207
+ const inside = rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
208
+ return inside ? 'project' : 'instrument';
209
+ }
210
+
211
+ /**
212
+ * Уточнение к числу проверенных подписей: сколько из них — паки ЭТОГО проекта, а сколько принадлежат
213
+ * самому прибору. Возвращает ПУСТУЮ строку, когда уточнять нечего (ничего не проверено либо всё
214
+ * проверенное — проектное): приписка к нулю была бы шумом, а шум в строке охвата её и обесценивает.
215
+ */
216
+ export function verifiedScopeNote(
217
+ checks: readonly { readonly dir: string; readonly verdict: string }[],
218
+ projectRoot: string,
219
+ ): string {
220
+ const verified = checks.filter((c) => c.verdict === 'verified');
221
+ if (verified.length === 0) return '';
222
+ const outside = verified.filter((c) => packScope(c.dir, projectRoot) === 'instrument').length;
223
+ if (outside === 0) return '';
224
+ return ` (${verified.length - outside} in this project, ${outside} in the instrument's own install`
225
+ + " — not this project's artifacts)";
226
+ }
227
+
191
228
  export function discoverVerifiablePackDirs(cwd: string): { pack: string; dir: string }[] {
192
229
  const out: { pack: string; dir: string }[] = [];
193
230
  // Keyed on the RESOLVED path: a globally-installed `dz` reaches its own bundled packs as well as the
@@ -388,21 +425,80 @@ function categoryFromPack(pack: string): string {
388
425
  * `node_modules/@dzhechkov`, **and** the CLI's own install location. This is why
389
426
  * `dz registry` works for a globally-installed `dz`, not only inside the monorepo.
390
427
  */
428
+ /** One skill as the public showcase page (`marketplace/index.html`) renders it. */
429
+ export interface ShowcaseSkill {
430
+ readonly id: string;
431
+ readonly pack: string;
432
+ readonly description: string;
433
+ readonly trustTier: number;
434
+ readonly category: string;
435
+ /** The exact command a visitor copies. Derived, never stored — one spelling, one place. */
436
+ readonly install: string;
437
+ }
438
+
439
+ export interface ShowcaseRegistry {
440
+ readonly version: string;
441
+ readonly generated: string;
442
+ readonly totalSkills: number;
443
+ readonly totalPacks: number;
444
+ readonly categories: readonly string[];
445
+ readonly skills: readonly ShowcaseSkill[];
446
+ }
447
+
448
+ /**
449
+ * The public showcase catalogue, projected from the SAME {@link Registry} the plugin manifests come
450
+ * from — so the page and the manifests can never answer "how many skills are there" differently.
451
+ *
452
+ * Why this exists: MEASURED 2026-09-04 and re-measured 2026-09-20, `marketplace/registry.json` said
453
+ * 57 skills / 10 packs while `buildRegistry` counted 260 / 37, and the file's last commit was
454
+ * 2026-06-03 — because NOTHING wrote it. A grep for its path across the whole source tree returned
455
+ * zero writers (backlog 9595b21e). A catalogue with no generator does not go stale slowly; it stops
456
+ * being true the first time anything ships, and it was the one surface a visitor actually reads.
457
+ *
458
+ * `generated` is the caller's to supply: a timestamp minted inside would make the output
459
+ * non-reproducible and the drift gate unable to compare a fresh build against the published file.
460
+ */
461
+ export function buildShowcaseRegistry(
462
+ registry: Registry,
463
+ opts: { readonly version: string; readonly generated: string },
464
+ ): ShowcaseRegistry {
465
+ return {
466
+ version: opts.version,
467
+ generated: opts.generated,
468
+ totalSkills: registry.totalSkills,
469
+ totalPacks: registry.totalPacks,
470
+ categories: [...registry.categories],
471
+ skills: registry.entries.map((e) => ({
472
+ id: e.id,
473
+ pack: e.pack,
474
+ description: e.description,
475
+ trustTier: e.trustTier,
476
+ category: e.category,
477
+ install: `dz init --target claude-code --select ${e.id}`,
478
+ })),
479
+ };
480
+ }
481
+
391
482
  export function buildRegistry(cwd: string): Registry {
392
483
  const entries: RegistryEntry[] = [];
393
484
  const packs = discoverSkillCarryingDirs(cwd);
394
485
 
395
- // A pack keeps its skills in one of three shapes, and reading only the first made 41 real skills
486
+ // The catalogue collects ALL allowed layouts, and reading only the first made 41 real skills
396
487
  // invisible (MEASURED 2026-09-01): the pack root (`skills-*` packs), a `skills/` subdirectory
397
488
  // (health-advisor and friends), and `templates/.claude/skills/` for packs that roll their skills
398
- // out into the user's project. The catalogue answers "what can I use", so all three count.
399
- const SKILL_LAYOUTS = [[], ['skills'], ['templates', '.claude', 'skills']] as const;
489
+ // out into the user's project. A template pack ships the skills it will roll out; from the
490
+ // catalogue's point of view those skills EXIST — `trip-planner` and `presentation-storyteller`
491
+ // are installable answers to a task — so hiding them would make the advisor deny a real
492
+ // capability. The layouts themselves now come from PACKAGE_SKILL_LAYOUTS so the knowledge lives
493
+ // in ONE place; resolvePackageSkillRoots deliberately selects only the FIRST non-empty root
494
+ // (installation semantics, plan AM-4) and therefore cannot serve this enumeration — calling it
495
+ // here would silently drop skills from a pack that fills two layouts (backlog 86b787b8).
400
496
  const seenSkillIds = new Set<string>();
401
497
 
402
498
  for (const { pack, dir: packDir } of packs) {
403
499
  const found: { name: string; root: string }[] = [];
404
- for (const layout of SKILL_LAYOUTS) {
405
- const root = layout.length === 0 ? packDir : join(packDir, ...layout);
500
+ for (const { rel } of PACKAGE_SKILL_LAYOUTS) {
501
+ const root = rel === '.' ? packDir : join(packDir, rel);
406
502
  if (!existsSync(root)) continue;
407
503
  try {
408
504
  for (const e of readdirSync(root, { withFileTypes: true })) {
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.
@@ -0,0 +1,174 @@
1
+ export type ReqeSeverity = 'BLOCKER' | 'HIGH' | 'MEDIUM' | 'LOW' | 'unknown';
2
+ export interface ReqeSeverityCounts {
3
+ readonly blocker: number; readonly high: number; readonly medium: number;
4
+ readonly low: number; readonly unknown: number;
5
+ }
6
+ /** `where` is null when the location cell is an em dash — the title alone is echoed (ADR AM-5). */
7
+ export interface ReqeBlockingRow {
8
+ readonly severity: 'BLOCKER' | 'HIGH'; readonly title: string; readonly where: string | null;
9
+ }
10
+ /** THREE states, never two (03.5 C5b): `absent` = no Findings section; `rejected` = section present,
11
+ * table not canonical, reason NAMED; `accepted` = parsed. */
12
+ export type ReqeFindingsClass =
13
+ | { readonly kind: 'accepted'; readonly counts: ReqeSeverityCounts; readonly rows: number;
14
+ readonly declared: number | null; readonly blocking: readonly ReqeBlockingRow[] }
15
+ | { readonly kind: 'rejected'; readonly reason: string }
16
+ | { readonly kind: 'absent' };
17
+ export type ReqePriorVerdict =
18
+ | { readonly kind: 'closed'; readonly closed: number; readonly total: number }
19
+ | { readonly kind: 'open'; readonly open: readonly number[]; readonly total: number }
20
+ | { readonly kind: 'unassessed' };
21
+ export interface ReqeVerdict {
22
+ readonly new: 'ready' | 'blocked' | 'unassessed';
23
+ readonly prior: ReqePriorVerdict;
24
+ readonly findings: ReqeFindingsClass;
25
+ readonly counts: ReqeSeverityCounts; // all zeros unless findings.kind === 'accepted'
26
+ readonly rows: number; // 0 unless accepted
27
+ readonly blocking: readonly ReqeBlockingRow[]; // [] unless accepted
28
+ readonly source: 'reviewer-declared'; // FR-6: the declarer is named in the data too
29
+ }
30
+
31
+ const emptyCounts = (): ReqeSeverityCounts => ({ blocker: 0, high: 0, medium: 0, low: 0, unknown: 0 });
32
+ const heading = /^#{1,6}[ \t]+/;
33
+ const findingsHeading = /^#{1,6}[ \t]*Findings(?:[ \t]+\((\d+)\))?[ \t]*$/;
34
+ const expectedTable = 'expected severity plus finding/title and location/where columns';
35
+
36
+ /** qe-findings.ts does not export maskMarkdown. This local fallback blanks fences and
37
+ * HTML comments without moving source lines, as permitted by the TASK-1 contract. */
38
+ function maskMarkdown(text: string): string {
39
+ let fence: string | null = null;
40
+ let inComment = false;
41
+ return text.split('\n').map((line) => {
42
+ if (fence !== null) {
43
+ const close = /^ {0,3}(`{3,}|~{3,})[ \t]*\r?$/.exec(line)?.[1];
44
+ if (close && close[0] === fence[0] && close.length >= fence.length) fence = null;
45
+ return ' '.repeat(line.length);
46
+ }
47
+ let visible = '';
48
+ for (let i = 0; i < line.length;) {
49
+ if (inComment) {
50
+ const end = line.indexOf('-->', i);
51
+ if (end === -1) { visible += ' '.repeat(line.length - i); break; }
52
+ visible += ' '.repeat(end + 3 - i);
53
+ i = end + 3;
54
+ inComment = false;
55
+ } else {
56
+ const start = line.indexOf('<!--', i);
57
+ if (start === -1) { visible += line.slice(i); break; }
58
+ visible += line.slice(i, start) + ' ';
59
+ i = start + 4;
60
+ inComment = true;
61
+ }
62
+ }
63
+ const open = /^ {0,3}(`{3,}|~{3,})/.exec(visible)?.[1];
64
+ if (open) { fence = open; return ' '.repeat(line.length); }
65
+ return visible;
66
+ }).join('\n');
67
+ }
68
+
69
+ function cells(line: string): string[] {
70
+ const parts = line.trim().split(/(?<!\\)\|/);
71
+ if (parts[0] === '') parts.shift();
72
+ if (parts[parts.length - 1] === '') parts.pop();
73
+ return parts.map((cell) => cell.replace(/\\\|/g, '|').trim());
74
+ }
75
+
76
+ function severityOf(cell: string): ReqeSeverity {
77
+ switch (cell.trim().toLowerCase()) {
78
+ case 'blocker': case 'critical': return 'BLOCKER';
79
+ case 'high': case 'major': return 'HIGH';
80
+ case 'medium': case 'med': return 'MEDIUM';
81
+ case 'low': case 'minor': return 'LOW';
82
+ default: return 'unknown';
83
+ }
84
+ }
85
+
86
+ /** Read only the single Findings section; malformed attempts carry an explicit reason. */
87
+ export function classifyReqeFindings(text: string): ReqeFindingsClass {
88
+ const lines = maskMarkdown(text).split(/\r?\n/);
89
+ const sections = lines.flatMap((line, index) => {
90
+ const match = findingsHeading.exec(line);
91
+ return match ? [{ index, declared: match[1] === undefined ? null : Number(match[1]) }] : [];
92
+ });
93
+ if (sections.length === 0) return { kind: 'absent' };
94
+ if (sections.length !== 1) return { kind: 'rejected', reason: `ambiguous: ${sections.length} Findings headings` };
95
+ const section = sections[0]!;
96
+ let end = section.index + 1;
97
+ while (end < lines.length && !heading.test(lines[end]!)) end++;
98
+ let start = section.index + 1;
99
+ while (start < end && !/(?<!\\)\|/.test(lines[start]!)) start++;
100
+ if (start === end) {
101
+ if (section.declared === 0) {
102
+ return { kind: 'accepted', counts: emptyCounts(), rows: 0, declared: 0, blocking: [] };
103
+ }
104
+ return { kind: 'rejected', reason: `no canonical table; ${expectedTable}` };
105
+ }
106
+ const headers = cells(lines[start]!).map((cell) => cell.toLowerCase());
107
+ const severityIndex = headers.indexOf('severity');
108
+ if (severityIndex === -1) return { kind: 'rejected', reason: `no severity column; ${expectedTable}; parsed columns: ${headers.join(', ')}` };
109
+ if (headers.lastIndexOf('severity') !== severityIndex) return { kind: 'rejected', reason: 'ambiguous: multiple severity columns' };
110
+ const titleIndex = headers.findIndex((cell) => cell === 'finding' || cell === 'title');
111
+ const whereIndex = headers.findIndex((cell) => cell === 'location' || cell === 'where');
112
+ if (titleIndex === -1 || whereIndex === -1) return { kind: 'rejected', reason: `missing finding/title or location/where column; ${expectedTable}` };
113
+ const delimiter = start + 1 < end ? cells(lines[start + 1]!) : [];
114
+ if (delimiter.length !== headers.length || !delimiter.every((cell) => /^:?-{3,}:?$/.test(cell))) {
115
+ return { kind: 'rejected', reason: `row ${start + 2}: invalid delimiter; expected ${headers.length} delimiter cells for columns: ${headers.join(', ')}` };
116
+ }
117
+ const counts = { ...emptyCounts() };
118
+ const blocking: ReqeBlockingRow[] = [];
119
+ let rows = 0;
120
+ let finished = false;
121
+ for (let index = start + 2; index < end; index++) {
122
+ const line = lines[index]!;
123
+ if (!/(?<!\\)\|/.test(line)) { finished = true; continue; }
124
+ if (finished) return { kind: 'rejected', reason: `row ${index + 1}: ambiguous table continuation` };
125
+ const row = cells(line);
126
+ if (row.length !== headers.length) {
127
+ return { kind: 'rejected', reason: `row ${index + 1}: ${row.length} cells for ${headers.length} columns (unescaped pipe?); parsed columns: ${headers.join(', ')}` };
128
+ }
129
+ if (row.every((cell) => /^:?-{3,}:?$/.test(cell))) return { kind: 'rejected', reason: `row ${index + 1}: ambiguous additional table delimiter` };
130
+ const severity = severityOf(row[severityIndex]!);
131
+ const key = severity.toLowerCase() as keyof ReqeSeverityCounts;
132
+ counts[key]++;
133
+ rows++;
134
+ if (severity === 'BLOCKER' || severity === 'HIGH') {
135
+ const where = row[whereIndex]!;
136
+ blocking.push({ severity, title: row[titleIndex]!, where: where === '—' || where === '' ? null : where });
137
+ }
138
+ }
139
+ return { kind: 'accepted', counts, rows, declared: section.declared, blocking };
140
+ }
141
+
142
+ /** Independent pass: no classification or new-findings state participates in prior status. */
143
+ export function parsePriorFindings(text: string): ReqePriorVerdict {
144
+ const lines = maskMarkdown(text).split(/\r?\n/);
145
+ const sections = lines.flatMap((line, index) => /^#{1,6}[ \t]*Prior findings[ \t]*$/.test(line) ? [index] : []);
146
+ if (sections.length !== 1) return { kind: 'unassessed' };
147
+ let total = 0;
148
+ const open: number[] = [];
149
+ for (let index = sections[0]! + 1; index < lines.length; index++) {
150
+ const line = lines[index]!;
151
+ if (heading.test(line)) break;
152
+ const match = /^[ \t]*(\d+):[ \t]+(closed|open)[ \t]+—[ \t]+\S.*$/.exec(line);
153
+ if (!match) continue;
154
+ total++;
155
+ if (match[2] === 'open') open.push(Number(match[1]));
156
+ }
157
+ if (total === 0) return { kind: 'unassessed' };
158
+ return open.length > 0 ? { kind: 'open', open, total } : { kind: 'closed', closed: total, total };
159
+ }
160
+
161
+ /** The only blocking rule; prior status is reported independently. */
162
+ export function buildReqeVerdict(text: string): ReqeVerdict {
163
+ const findings = classifyReqeFindings(text);
164
+ const prior = parsePriorFindings(text);
165
+ const accepted = findings.kind === 'accepted';
166
+ const counts = accepted ? findings.counts : emptyCounts();
167
+ return {
168
+ new: accepted ? (counts.blocker >= 1 || counts.high >= 1 ? 'blocked' : 'ready') : 'unassessed',
169
+ prior, findings, counts,
170
+ rows: accepted ? findings.rows : 0,
171
+ blocking: accepted ? findings.blocking : [],
172
+ source: 'reviewer-declared',
173
+ };
174
+ }