@dzhechkov/harness-core 0.7.4 → 0.7.6

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 (82) hide show
  1. package/.dz-manifest.json +141 -61
  2. package/README.md +38 -1
  3. package/dist/agentdb-index.d.ts.map +1 -1
  4. package/dist/agentdb-index.js +26 -2
  5. package/dist/agentdb-index.js.map +1 -1
  6. package/dist/amendment-trace.d.ts.map +1 -1
  7. package/dist/amendment-trace.js +6 -1
  8. package/dist/amendment-trace.js.map +1 -1
  9. package/dist/cadence.d.ts +66 -0
  10. package/dist/cadence.d.ts.map +1 -0
  11. package/dist/cadence.js +222 -0
  12. package/dist/cadence.js.map +1 -0
  13. package/dist/feature-adr-routing.d.ts.map +1 -1
  14. package/dist/feature-adr-routing.js +7 -2
  15. package/dist/feature-adr-routing.js.map +1 -1
  16. package/dist/index.d.ts +8 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +6 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/loop-blobs.generated.js +2 -2
  21. package/dist/loop-blobs.generated.js.map +1 -1
  22. package/dist/operations.d.ts.map +1 -1
  23. package/dist/operations.js +53 -18
  24. package/dist/operations.js.map +1 -1
  25. package/dist/provenance.d.ts +6 -0
  26. package/dist/provenance.d.ts.map +1 -1
  27. package/dist/provenance.js +22 -1
  28. package/dist/provenance.js.map +1 -1
  29. package/dist/publish.d.ts +31 -0
  30. package/dist/publish.d.ts.map +1 -1
  31. package/dist/publish.js +78 -0
  32. package/dist/publish.js.map +1 -1
  33. package/dist/recall-hook-policy.d.ts +3 -1
  34. package/dist/recall-hook-policy.d.ts.map +1 -1
  35. package/dist/recall-hook-policy.js +15 -2
  36. package/dist/recall-hook-policy.js.map +1 -1
  37. package/dist/recall-usage.d.ts +15 -0
  38. package/dist/recall-usage.d.ts.map +1 -1
  39. package/dist/recall-usage.js +51 -1
  40. package/dist/recall-usage.js.map +1 -1
  41. package/dist/score.d.ts.map +1 -1
  42. package/dist/score.js +38 -4
  43. package/dist/score.js.map +1 -1
  44. package/dist/skill-drift.d.ts +8 -2
  45. package/dist/skill-drift.d.ts.map +1 -1
  46. package/dist/skill-drift.js +60 -11
  47. package/dist/skill-drift.js.map +1 -1
  48. package/dist/skill-install-roots.d.ts +100 -0
  49. package/dist/skill-install-roots.d.ts.map +1 -0
  50. package/dist/skill-install-roots.js +116 -0
  51. package/dist/skill-install-roots.js.map +1 -0
  52. package/dist/tg-post.d.ts +76 -0
  53. package/dist/tg-post.d.ts.map +1 -0
  54. package/dist/tg-post.js +158 -0
  55. package/dist/tg-post.js.map +1 -0
  56. package/dist/usage.d.ts +31 -0
  57. package/dist/usage.d.ts.map +1 -1
  58. package/dist/usage.js +108 -21
  59. package/dist/usage.js.map +1 -1
  60. package/dist/writer-quiescence.d.ts +42 -0
  61. package/dist/writer-quiescence.d.ts.map +1 -0
  62. package/dist/writer-quiescence.js +82 -0
  63. package/dist/writer-quiescence.js.map +1 -0
  64. package/package.json +13 -13
  65. package/sbom.json +260 -60
  66. package/src/agentdb-index.ts +26 -2
  67. package/src/amendment-trace.ts +6 -1
  68. package/src/cadence.ts +227 -0
  69. package/src/feature-adr-routing.ts +7 -2
  70. package/src/index.ts +8 -1
  71. package/src/loop-blobs.generated.ts +2 -2
  72. package/src/operations.ts +52 -19
  73. package/src/provenance.ts +23 -1
  74. package/src/publish.ts +98 -0
  75. package/src/recall-hook-policy.ts +15 -2
  76. package/src/recall-usage.ts +44 -1
  77. package/src/score.ts +30 -4
  78. package/src/skill-drift.ts +70 -13
  79. package/src/skill-install-roots.ts +119 -0
  80. package/src/tg-post.ts +192 -0
  81. package/src/usage.ts +132 -17
  82. package/src/writer-quiescence.ts +95 -0
package/src/publish.ts CHANGED
@@ -89,6 +89,68 @@ function maxPublished(name: string, localVersion: string): string {
89
89
  return pub !== undefined && compareVersions(pub, localVersion) > 0 ? pub : localVersion;
90
90
  }
91
91
 
92
+ // ── workspace-floor preflight (feature workspace-dep-protocol, Codex P1) ─────
93
+ //
94
+ // Sibling deps are declared `workspace:^`, and pnpm rewrites them at pack time to `^<the sibling's
95
+ // DISK version>`. That version is not necessarily PUBLISHED: `--bump-only` stages versions on disk,
96
+ // and a later `--filter`ed publish of just the dependent would ship a floor nobody can install —
97
+ // the publish itself succeeds, and every consumer `npm install` then fails with ETARGET. Staged is
98
+ // not shipped; this preflight makes the difference a refusal instead of a broken release.
99
+
100
+ /**
101
+ * Pure half: which `workspace:`-declared deps of a package would pack to a floor that is neither
102
+ * being published in this batch nor already on the registry?
103
+ *
104
+ * Fail-closed by design: a probe that cannot answer (offline, 404) reports the floor as
105
+ * unpublished — a publish needs the network anyway, and refusing beats shipping ETARGET.
106
+ */
107
+ export function findUnpublishedWorkspaceFloors(opts: {
108
+ readonly dependencies: Record<string, string> | undefined;
109
+ /** pnpm rewrites `workspace:` in peerDependencies at pack time too (Codex P2) — same hazard. */
110
+ readonly peerDependencies?: Record<string, string> | undefined;
111
+ /** name → version on DISK, for every package in the workspace (what pnpm packs the floor from). */
112
+ readonly workspaceVersions: ReadonlyMap<string, string>;
113
+ /**
114
+ * Names whose publish has LANDED (or, in a dry-run preview, would land) BEFORE this package.
115
+ * Static batch membership is not enough (Codex P1): a sibling that failed its own gates earlier
116
+ * in the batch has no published floor, and its dependents must fall through to the probe.
117
+ */
118
+ readonly batch: ReadonlySet<string>;
119
+ readonly probe: (name: string, version: string) => boolean;
120
+ }): { name: string; version: string }[] {
121
+ const missing: { name: string; version: string }[] = [];
122
+ const seen = new Set<string>();
123
+ // Sections are inspected INDEPENDENTLY, never object-merged: a plain peer range for the same
124
+ // sibling would overwrite a `workspace:^` dependency entry in a spread, and pnpm still rewrites
125
+ // the dependency section — the protocol in EITHER section makes the floor pack from disk.
126
+ const entries = [...Object.entries(opts.dependencies ?? {}), ...Object.entries(opts.peerDependencies ?? {})];
127
+ for (const [dep, spec] of entries) {
128
+ if (!String(spec).startsWith('workspace:')) continue;
129
+ if (seen.has(dep)) continue;
130
+ seen.add(dep);
131
+ if (opts.batch.has(dep)) continue; // publishes before this package (deps-first order)
132
+ const version = opts.workspaceVersions.get(dep);
133
+ if (version === undefined) {
134
+ // A workspace: spec naming a package that is not in the workspace — pnpm pack would die on
135
+ // it anyway, but die HERE with a name, not mid-batch.
136
+ missing.push({ name: dep, version: '(not in workspace)' });
137
+ continue;
138
+ }
139
+ if (!opts.probe(dep, version)) missing.push({ name: dep, version });
140
+ }
141
+ return missing;
142
+ }
143
+
144
+ /** Registry probe: is exactly `name@version` published? Empty output / 404 / offline ⇒ no. */
145
+ function versionPublished(name: string, version: string): boolean {
146
+ try {
147
+ const out = execSync(`npm view ${name}@${version} version`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', timeout: 20000 }).trim();
148
+ return out === version;
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+
92
154
  /**
93
155
  * `execSync` throws an Error whose `.message` is only `Command failed: <cmd>` — the child's real output
94
156
  * (the `npm ERR!` lines that say WHY a publish failed) sits on `.stdout` / `.stderr` and was being
@@ -331,6 +393,13 @@ export function publishPackages(
331
393
  claimGate?: 'off' | 'warn' | 'error' | undefined;
332
394
  /** ADR-001: `auto` (default) decides from the environment; `on` fails where it cannot work. */
333
395
  provenance?: ProvenanceMode | undefined;
396
+ /**
397
+ * Floor probe injection for the workspace-floor preflight (see
398
+ * `findUnpublishedWorkspaceFloors`). Default: a real `npm view` probe, which runs only on LIVE
399
+ * publishes — dry-run stays offline, matching `maxPublished`. Injecting a probe also arms the
400
+ * preflight under dry-run, which is how the wiring test drives it without network.
401
+ */
402
+ probeFloor?: ((name: string, version: string) => boolean) | undefined;
334
403
  } = {},
335
404
  ): PublishReport {
336
405
  // Decide ONCE, before the batch: `--provenance` in an incapable environment must fail here, not on
@@ -346,6 +415,15 @@ export function publishPackages(
346
415
  // freshly-bumped version, never a stale one (the harness-cli@0.3.122 breakage).
347
416
  const ordered = orderByDependencies(filtered);
348
417
 
418
+ // Workspace-floor preflight inputs: the full workspace version map (what pnpm would pack each
419
+ // floor from), and the names whose publish has LANDED so far in this run — grown as the loop
420
+ // proceeds, never assumed from batch membership (Codex P1: a sibling that failed its own gates
421
+ // has no published floor, and static membership would still have covered its dependents).
422
+ const workspaceVersions = new Map(packages.map((p) => [p.name, p.version]));
423
+ const landedInBatch = new Set<string>();
424
+ const armFloorPreflight = opts.bumpOnly !== true && (opts.dryRun !== true || opts.probeFloor !== undefined);
425
+ const probeFloor = opts.probeFloor ?? versionPublished;
426
+
349
427
  for (const pkg of ordered) {
350
428
  const oldVersion = pkg.version;
351
429
  // Bump from max(local, npm-published) so a locally-reverted version can't
@@ -370,6 +448,24 @@ export function publishPackages(
370
448
  continue;
371
449
  }
372
450
 
451
+ // Workspace-floor preflight (Codex P1, feature workspace-dep-protocol): a `workspace:^` dep
452
+ // packs to `^<sibling's DISK version>` — refuse if that floor is neither in this batch nor on
453
+ // the registry, or the publish succeeds and every consumer install dies with ETARGET.
454
+ if (armFloorPreflight) {
455
+ const manifest = JSON.parse(readFileSync(join(pkg.dir, 'package.json'), 'utf-8')) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
456
+ const unpublishedFloors = findUnpublishedWorkspaceFloors({ dependencies: manifest.dependencies, peerDependencies: manifest.peerDependencies, workspaceVersions, batch: landedInBatch, probe: probeFloor });
457
+ if (unpublishedFloors.length > 0) {
458
+ results.push({
459
+ name: pkg.name,
460
+ oldVersion,
461
+ newVersion,
462
+ status: 'error',
463
+ error: `workspace floor(s) not published: ${unpublishedFloors.map((f) => `${f.name}@${f.version}`).join(', ')}. Publish the sibling(s) first or include them in --filter — a staged disk version is not a shipped one.`,
464
+ });
465
+ continue;
466
+ }
467
+ }
468
+
373
469
  // Pre-publish claim-check gate. Default `'warn'` per ADR-001: publishing SURFACES a
374
470
  // README's untagged claims by default, but `'warn'` NEVER changes publish status, so the
375
471
  // existing publish path is unaffected. `'error'` fails only THIS package when it carries a
@@ -405,6 +501,7 @@ export function publishPackages(
405
501
 
406
502
  if (opts.dryRun) {
407
503
  results.push({ name: pkg.name, oldVersion, newVersion, status: 'skipped', claimCheck: claimCheckSummary });
504
+ landedInBatch.add(pkg.name); // preview: this package passed its gates and WOULD land
408
505
  continue;
409
506
  }
410
507
 
@@ -511,6 +608,7 @@ export function publishPackages(
511
608
  });
512
609
 
513
610
  results.push({ name: pkg.name, oldVersion, newVersion, status: 'published', claimCheck: claimCheckSummary });
611
+ landedInBatch.add(pkg.name); // only an ACTUAL publish covers dependents (Codex P1)
514
612
  } catch (err) {
515
613
  // The version was written BEFORE build+publish; on any failure restore the
516
614
  // original package.json (and README, if we rewrote its version) so a failed
@@ -26,7 +26,9 @@
26
26
  * where an irrelevant English one reaches 0.254. A single floor still works, with a thin 0.032
27
27
  * margin; per-language floors triple it. Hence the defaults below.
28
28
  *
29
- * The turns that must stay silent do: `"спасибо"` scores 0.259, `"какой статус?"` 0.318 both under
29
+ * The turns that must stay silent do (2026-07-09 numbers; re-measured 2026-08-24«спасибо» rose
30
+ * to 0.416 but is cut by the SIGNAL gate before any floor, and «какой статус?» rose to 0.386, which
31
+ * is what forced the recalibration above): both under
30
32
  * every floor here.
31
33
  *
32
34
  * @packageDocumentation
@@ -46,7 +48,18 @@ export interface RecallFloors {
46
48
  * slightly closer to any Latin text than two unrelated Latin texts are to each other — the baseline,
47
49
  * not the signal, is what shifts.
48
50
  */
49
- export const DEFAULT_RECALL_FLOORS: RecallFloors = { ru: 0.38, en: 0.31 };
51
+ // RECALIBRATED 2026-08-24 on the LIVE 281-pattern store, end to end through `dz recall --json`
52
+ // (the closeness feature made the true cosine visible, which is what exposed the drift): over the
53
+ // probes that actually REACH the floor — the signal gate cuts "спасибо"/"thanks" first —
54
+ // ru: min(relevant)=0.409, max(irrelevant)=0.386 ("какой статус?", ABOVE the old 0.38 floor);
55
+ // en: min(relevant)=0.413, max(irrelevant)=0.332 (nonsense scored 0.327, above the old 0.31).
56
+ // The 2026-07-09 floors were calibrated on 103 patterns; at 281 the irrelevant tail rose. The RU
57
+ // window is now THIN (+0.023) — an honest limit, not a solved problem: it narrows again as the
58
+ // store grows, and the next recalibration should follow the next major store growth.
59
+ // en is 0.36 rather than the live midpoint 0.37 because the hermetic fixture's weakest relevant
60
+ // probe sits at 0.369, and a floor above it would fail the calibration test that guards this file.
61
+ // Probe set + raw results: test/fixtures/recall-floor-live-2026-08-24.json.
62
+ export const DEFAULT_RECALL_FLOORS: RecallFloors = { ru: 0.40, en: 0.36 };
50
63
 
51
64
  /** Max hits injected into a turn. Three is the ADR default; more is noise, not context. */
52
65
  export const DEFAULT_RECALL_HOOK_LIMIT = 3;
@@ -18,7 +18,7 @@
18
18
  // ReferenceError into `appendRecallUsage`'s own catch and returned **0 rows appended, silently**.
19
19
  // The Codex recall leg looked wired and correctly-silent for exactly the reason AM-4's forced-hit
20
20
  // canary exists to expose. Importing node: modules at the top costs nothing — they are built in.
21
- import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync } from 'node:fs';
21
+ import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync } from 'node:fs';
22
22
  import { dirname, join } from 'node:path';
23
23
 
24
24
  import {
@@ -718,3 +718,46 @@ function readLogTailSync(file: string): LogTail {
718
718
  return EMPTY_LOG_TAIL;
719
719
  }
720
720
  }
721
+
722
+ /**
723
+ * Recall events recorded for one run key — DISTINCT prompts, not rows.
724
+ *
725
+ * Why this exists: the /feature-adr live panel asserted `--recalled 3` as a LITERAL at three call
726
+ * sites, because the fallback writer that lights the panel had nowhere to get a real number. Now
727
+ * that `dz recall` records its own reads, the number is derivable — this is the derivation.
728
+ *
729
+ * Counted by `eventId`, not by row: one prompt that surfaced four lessons writes four rows sharing
730
+ * one eventId, and "recalled 4" would overstate what the operator did by a factor of hits-per-query.
731
+ * Rows predating eventIds (the log's schema grew) count one each — their rows WERE one-per-event.
732
+ *
733
+ * Returns null when the log cannot be read — the caller must record "unknown", never zero: an
734
+ * unreadable log and a run that recalled nothing are different facts (the dz sync 0/0 class).
735
+ */
736
+ export function countRecallEventsForRun(projectRoot: string, runId: string): number | null {
737
+ const wanted = runId.trim();
738
+ if (wanted === '') return null;
739
+ const path = join(projectRoot, ...RECALL_USAGE_LOG_RELATIVE.split('/'));
740
+ if (!existsSync(path)) return 0; // a real, readable absence: nothing has ever been recorded
741
+ let text: string;
742
+ try {
743
+ text = readFileSync(path, 'utf-8');
744
+ } catch {
745
+ return null;
746
+ }
747
+ const events = new Set<string>();
748
+ let preEventRows = 0;
749
+ for (const line of text.split('\n')) {
750
+ if (line.trim() === '') continue;
751
+ let row: Record<string, unknown>;
752
+ try {
753
+ row = JSON.parse(line) as Record<string, unknown>;
754
+ } catch {
755
+ continue; // a torn row is the chain verifier's business, not a reason to fail the count
756
+ }
757
+ if (row['runId'] !== wanted) continue;
758
+ const ev = row['eventId'];
759
+ if (typeof ev === 'string' && ev !== '') events.add(ev);
760
+ else preEventRows += 1;
761
+ }
762
+ return events.size + preEventRows;
763
+ }
package/src/score.ts CHANGED
@@ -76,7 +76,12 @@ const NEGATION_RE = /\b(no|not|never|without|wasn'?t|isn'?t)\b/i;
76
76
  * `\bno\b` does NOT match "Nothing". Hedges like "skipped" stay out of both lists — they routinely
77
77
  * appear inside genuine evidence lines.
78
78
  */
79
- const NEGATION_QUANTIFIED_RE = /\b(no|not|never|without|nothing|none|neither|nor|nobody|wasn'?t|isn'?t)\b/i;
79
+ // RU negation quantifiers joined 2026-08-24 with the RU live-markers (773185ca): a corpus where
80
+ // 63% of traffic is Russian was screened by an English-only list — «ничего не измерено» would have
81
+ // read as a live marker the moment ИЗМЕРЕНО joined the positives.
82
+ // \b is ASCII-only in JS even under /u — «не» never matched through it (measured by the pin the
83
+ // moment it was written). Unicode lookarounds carry the boundary instead.
84
+ const NEGATION_QUANTIFIED_RE = /\b(no|not|never|without|nothing|none|neither|nor|nobody|wasn'?t|isn'?t)\b|(?<![\p{L}\p{N}])(не|нет|ни одного|ничего|никогда|без)(?![\p{L}\p{N}])/iu;
80
85
 
81
86
  function evidenceLinePositive(text: string, re: RegExp, negationRe: RegExp = NEGATION_RE): string | null {
82
87
  for (const line of text.split('\n')) {
@@ -137,7 +142,25 @@ export interface GradeReading {
137
142
  */
138
143
  export function readQeGrade(qeText: string): GradeReading {
139
144
  const found: string[] = [];
145
+ const lines = qeText.split('\n');
146
+ // Line offsets once, so each match maps to ITS line for the negation screen (67d7883d: the
147
+ // parser was negation-blind — «No Grade: B was assigned» contributed B and a cross-model PASS;
148
+ // the display-locator fix could not reach this because the GRADE rests here). The same
149
+ // deliberate trade as evidenceLinePositive: a genuine grade on a line that happens to carry a
150
+ // negation is SKIPPED (visible in `found`'s absence) rather than a negated line being COUNTED.
151
+ const lineStarts: number[] = [0];
152
+ for (let i = 0; i < lines.length - 1; i++) lineStarts.push((lineStarts[i] as number) + (lines[i] as string).length + 1);
153
+ // The screen covers the line PREFIX up to the match, not the whole line: «No Grade: B was
154
+ // assigned» negates BEFORE the grade; «**Grade: B** — no blockers remain» carries its negation
155
+ // AFTER, about something else entirely, and whole-line screening dropped that real, common
156
+ // phrase (caught by the standing display-locator pin the moment the sweep was «completed»).
157
+ const linePrefixOf = (idx: number): string => {
158
+ let lo = 0, hi = lineStarts.length - 1;
159
+ while (lo < hi) { const mid = (lo + hi + 1) >> 1; if ((lineStarts[mid] as number) <= idx) lo = mid; else hi = mid - 1; }
160
+ return qeText.slice(lineStarts[lo] as number, idx);
161
+ };
140
162
  for (const m of qeText.matchAll(new RegExp(GRADE_RE.source, 'g'))) {
163
+ if (NEGATION_QUANTIFIED_RE.test(linePrefixOf(m.index ?? 0))) continue;
141
164
  const g = normaliseGradeSign(m[1] as string);
142
165
  if (!found.includes(g)) found.push(g);
143
166
  }
@@ -226,14 +249,17 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
226
249
  // report is the cautionary case: "✅ (mechanism)" with no live evidence shipped a dead feature.
227
250
  // POSITIVE (wave1-scorer-negation): "nothing was MEASURED" / "no reproducer was run" is the
228
251
  // claim's exact opposite and used to score as proof of it (the crossrt-1 6/7 shape).
229
- const live = evidenceLinePositive(qeText, /MEASURED|verified live|VERIFIED LIVE|reproducer/i, NEGATION_QUANTIFIED_RE);
252
+ // 773185ca: MEASURED-class markers in BOTH working languages. dz-recap carried 10× ИЗМЕРЕНО and
253
+ // 0× MEASURED and scored «всё выведено рассуждением» — the cyrillic-tokenizer class again.
254
+ const LIVE_MARKER_RE = /MEASURED|verified live|VERIFIED LIVE|reproducer|ИЗМЕРЕНО|МЕРЕНО|измерено|проверено живьём|живой прогон|репродьюсер/iu;
255
+ const live = evidenceLinePositive(qeText, LIVE_MARKER_RE, NEGATION_QUANTIFIED_RE);
230
256
  const liveAnywhere =
231
- live ?? evidenceLinePositive(allText, /MEASURED|verified live|VERIFIED LIVE|reproducer/i, NEGATION_QUANTIFIED_RE);
257
+ live ?? evidenceLinePositive(allText, LIVE_MARKER_RE, NEGATION_QUANTIFIED_RE);
232
258
  add(
233
259
  'live-verification',
234
260
  'claims verified by running, not by reasoning',
235
261
  live !== null ? 'pass' : liveAnywhere !== null ? 'partial' : 'absent',
236
- live ?? liveAnywhere ?? 'no MEASURED / verified-live / reproducer marker anywhere — every claim is inferred',
262
+ live ?? liveAnywhere ?? 'no MEASURED/ИЗМЕРЕНО/verified-live/reproducer marker anywhere — every claim is inferred',
237
263
  );
238
264
 
239
265
  // 5. README-first — the docs travelled in the same change.
@@ -21,9 +21,11 @@
21
21
  */
22
22
 
23
23
  import { readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
24
- import { join, relative, resolve, dirname, basename } from 'node:path';
24
+ import { join, relative, resolve, dirname, basename, sep } from 'node:path';
25
25
  import { createHash } from 'node:crypto';
26
26
 
27
+ import { DEV_SKILL_ROOT, SKILL_INSTALL_ROOTS, TARGET_ENRICHMENT_ASSETS } from './skill-install-roots.js';
28
+
27
29
  /** One shared skill whose copies byte-differ (`driftFiles > 0`). */
28
30
  export interface DriftedSkill {
29
31
  /** Skill dir basename (e.g. `goap-research-ed25519`). */
@@ -48,9 +50,15 @@ export interface SweepOptions {
48
50
  * `.claude/skills/<skill>` dev copies are excluded — the repo's own `dz sync` test treats them as
49
51
  * "legitimately lagging" the published version, so counting them makes the gate red-on-arrival.
50
52
  * The dangerous drift (goap, brutal-honesty) was always between PUBLISHED packages.
51
- * - `'all'`: packages + `.claude/skills` (the raw sweep the audit script does).
53
+ * - `'installs'` (what the `no-skill-drift` HARD rule uses): packages + every per-target install
54
+ * root EXCEPT {@link DEV_SKILL_ROOT}. Machine-generated installs have no licence to lag, so
55
+ * holding them to byte-identity is a gate that can actually be satisfied — unlike `'all'`,
56
+ * which includes the hand-edited dev tree and is therefore red-on-arrival as a gate.
57
+ * - `'all'`: packages + EVERY per-target skills install root ({@link SKILL_INSTALL_ROOTS}) — the
58
+ * raw sweep the audit script does. A root that is absent, or present without a `SKILL.md`,
59
+ * contributes nothing, so this is inert for a repo that installs only one target.
52
60
  */
53
- readonly scope?: 'packages' | 'all';
61
+ readonly scope?: 'packages' | 'installs' | 'all';
54
62
  /** Skill basenames whose drift is ACCEPTED (documented intentional forks) — reported separately, never counted as gate drift. */
55
63
  readonly allowlist?: readonly string[];
56
64
  }
@@ -144,9 +152,42 @@ function walk(dir: string): string[] {
144
152
  * so BOTH the CI sweep AND the canonical-free `sync-canonical --check` share one implementation and
145
153
  * yield the same verdict. Behavior-preserving refactor — no numbers change.
146
154
  */
155
+ /**
156
+ * Is this skill-dir-relative path a TARGET ENRICHMENT asset ({@link TARGET_ENRICHMENT_ASSETS})?
157
+ *
158
+ * Such a file exists in ONE copy by design — `dz init --enrich` writes it into the install tree and
159
+ * the canonical never has it. Counting it as drift makes the gate unsatisfiable; removing it during
160
+ * a heal destroys valid output. Compared with POSIX separators so a Windows `relative()` still matches.
161
+ */
162
+ function isEnrichmentAsset(rel: string, copyDir: string): boolean {
163
+ const owner = TARGET_ENRICHMENT_ASSETS[rel.split(sep).join('/')];
164
+ if (owner === undefined) return false;
165
+ // The exemption is only valid inside the root that OWNS the asset. Elsewhere the same filename is
166
+ // a misplaced extra file, and waving it through would make the sweep report clean while the healer
167
+ // preserved it. ANCHORED at the skill dir, not searched across the whole absolute path: a repo that
168
+ // itself lives under a directory containing `/.agents/skills/` would otherwise have every copy in
169
+ // it — packages included — classified as codex-owned (cross-family review, 2026-08-25).
170
+ const posix = copyDir.split(sep).join('/');
171
+ return posix.endsWith('/' + owner + '/' + posix.split('/').slice(-1)[0]);
172
+ }
173
+
174
+ /**
175
+ * The canonical's own file list, with every enrichment-asset NAME removed regardless of where the
176
+ * canonical came from. A canonical picked by `--auto` (or handed in with `--from`) can itself be an
177
+ * enriched install copy; propagating its target-specific metadata into the package and other target
178
+ * copies is never correct, and the exemption above would then stop it ever being cleaned up.
179
+ */
180
+ function withoutEnrichmentNames(rels: readonly string[]): string[] {
181
+ return rels.filter((r) => TARGET_ENRICHMENT_ASSETS[r.split(sep).join('/')] === undefined);
182
+ }
183
+
147
184
  function comparePeers(copies: readonly string[]): { driftFiles: number; missingFiles: number; totalFiles: number } {
148
185
  const relFiles = new Set<string>();
149
- for (const c of copies) for (const f of walk(c)) relFiles.add(relative(c, f));
186
+ for (const c of copies) for (const f of walk(c)) {
187
+ const rel = relative(c, f);
188
+ if (isEnrichmentAsset(rel, c)) continue;
189
+ relFiles.add(rel);
190
+ }
150
191
 
151
192
  let driftFiles = 0;
152
193
  let missingFiles = 0;
@@ -172,7 +213,10 @@ function comparePeers(copies: readonly string[]): { driftFiles: number; missingF
172
213
  * hence it is only ever reached behind an explicit `--auto` opt-in plus a loud warning.
173
214
  */
174
215
  function pickMostComplete(copies: readonly string[]): string {
175
- return [...copies].sort().reduce((best, c) => (walk(c).length > walk(best).length ? c : best));
216
+ // Enrichment assets do not make a copy more COMPLETE — they make it a target install. Counting
217
+ // them would let an enriched copy win the heuristic on files no other copy is supposed to have.
218
+ const size = (d: string): number => withoutEnrichmentNames(walk(d).map((p) => relative(d, p))).length;
219
+ return [...copies].sort().reduce((best, c) => (size(c) > size(best) ? c : best));
176
220
  }
177
221
 
178
222
  /**
@@ -198,12 +242,24 @@ function resolveCanonical(
198
242
  }
199
243
 
200
244
  /**
201
- * Every skill dir (a dir containing `SKILL.md`) under `packages/` + `.claude/skills`, excluding
202
- * `node_modules` / `__pycache__`. Ported verbatim from `scripts/drift-sweep-skills.mjs`.
245
+ * Every skill dir (a dir containing `SKILL.md`) under `packages/` + every per-target install root
246
+ * in {@link SKILL_INSTALL_ROOTS}, excluding `node_modules` / `__pycache__`. Originally ported from
247
+ * `scripts/drift-sweep-skills.mjs`, which searched `.claude/skills` alone — see ADR-001
248
+ * (skill-copy-discovery) for why one hardcoded root let the Codex install drift unseen.
203
249
  */
204
- function findSkillDirs(root: string, scope: 'packages' | 'all' = 'all'): string[] {
250
+ function findSkillDirs(root: string, scope: 'packages' | 'installs' | 'all' = 'all'): string[] {
205
251
  const dirs: string[] = [];
206
- const roots = scope === 'packages' ? [join(root, 'packages')] : [join(root, 'packages'), join(root, '.claude', 'skills')];
252
+ // Roots are ANCHORED at the repo root never a recursive search for `*/skills`. A stale agent
253
+ // worktree under `.claude/worktrees/<id>/` holds a full second copy of `packages/`,
254
+ // `.claude/skills` AND `.agents/skills`; a recursive sweep would report every skill in the repo
255
+ // as drifting against a checkout nobody maintains, and the healer would be entitled to WRITE
256
+ // into it (ADR-001, Option C rejected on exactly this measurement).
257
+ const installRoots = scope === 'installs'
258
+ ? SKILL_INSTALL_ROOTS.filter((r) => r !== DEV_SKILL_ROOT)
259
+ : SKILL_INSTALL_ROOTS;
260
+ const roots = scope === 'packages'
261
+ ? [join(root, 'packages')]
262
+ : [join(root, 'packages'), ...installRoots.map((r) => join(root, ...r.split('/')))];
207
263
  const stack = roots.filter((p) => existsSync(p));
208
264
  while (stack.length) {
209
265
  const d = stack.pop() as string;
@@ -322,9 +378,7 @@ export function syncCanonicalSkill(root: string, skill: string, opts: SyncCanoni
322
378
  return { canonical: '', canonicalExists: false, resolvedFrom, copies: allCopies.length, synced: 0, drifted: 0, wrote: [] };
323
379
  }
324
380
 
325
- const canonFiles = walk(canonical)
326
- .map((p) => relative(canonical, p))
327
- .sort();
381
+ const canonFiles = withoutEnrichmentNames(walk(canonical).map((p) => relative(canonical, p))).sort();
328
382
 
329
383
  // Every <skill>/ dir except the canonical itself.
330
384
  const copies = allCopies.filter((d) => relative(canonical, d) !== '');
@@ -334,7 +388,10 @@ export function syncCanonicalSkill(root: string, skill: string, opts: SyncCanoni
334
388
  const wrote: string[] = [];
335
389
 
336
390
  for (const copy of copies) {
337
- const copyFiles = new Set(walk(copy).map((p) => relative(copy, p)));
391
+ // Target ENRICHMENT assets are excluded from the copy's file set entirely: they exist in the
392
+ // install tree by design, the canonical never has them, and both the drift verdict and the
393
+ // removal pass below must leave them alone.
394
+ const copyFiles = new Set(walk(copy).map((p) => relative(copy, p)).filter((f) => !isEnrichmentAsset(f, copy)));
338
395
  let differs = false;
339
396
  // Extra files in the copy not present in canonical ⇒ drift.
340
397
  for (const f of copyFiles) if (!canonFiles.includes(f)) differs = true;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Where installed skill dirs live — the single source of truth for skill-copy DISCOVERY.
3
+ *
4
+ * The harness installs skills into a per-TARGET tree (`dz install --target <name>`). Five of the
5
+ * ten targets in {@link TARGET_NAMES} emit an agentskills.io-shaped directory containing a
6
+ * `SKILL.md`; the other five (copilot, cursor, windsurf, gemini, agents-md) emit rules/instructions
7
+ * FILES and therefore hold no skill dirs to keep in sync.
8
+ *
9
+ * | Target | Root | Adapter constant |
10
+ * |---|---|---|
11
+ * | `claude-code` | `.claude/skills` | — (the adapter exports no root constant) |
12
+ * | `codex` | `.agents/skills` | `CODEX_SKILLS_ROOT` |
13
+ * | `opencode` | `.opencode/skills` | `OPENCODE_SKILLS_ROOT` |
14
+ * | `hermes` | `.hermes/skills` | `HERMES_SKILLS_ROOT` |
15
+ * | `openclaude` | `.openclaude/skills` | `OPENCLAUDE_SKILLS_ROOT` |
16
+ *
17
+ * **Why this list is data with no imports.** `skill-drift.ts` — the drift detector behind the
18
+ * `no-skill-drift` HARD guard rule — is deliberately dependency-free (`node:fs`/`node:path`/
19
+ * `node:crypto`). `targets.ts`, which owns the adapter registry, imports all ten adapter packages;
20
+ * importing it from the drift sweep would drag ten packages into the guard's load path for a list
21
+ * of five strings. So the list lives here as data, and the link back to the adapters is asserted in
22
+ * `test/skill-install-roots.test.ts`, where importing them costs nothing.
23
+ *
24
+ * **Why this matters (the defect that produced it, MEASURED 2026-08-25).** `findSkillDirs` searched
25
+ * `packages/` + `.claude/skills` only. `.agents/skills/feature-adr` — the Codex install — drifted
26
+ * from the canonical for a day while `dz sync-canonical feature-adr --check` reported "all 3 copies
27
+ * match canonical" and the HARD guard passed. Two feature-adr gates were degraded in that copy: the
28
+ * K1 name-availability section was missing from `SKILL.md`, and the C6 amendment-integrity check
29
+ * was missing from the K2 script. A gate that cannot see a copy cannot gate it.
30
+ *
31
+ * **Honest limit.** A target whose adapter exports no root constant (today: `claude-code`) must be
32
+ * listed here by hand — the completeness test proves every EXPORTED constant is covered, and cannot
33
+ * prove a constant that does not exist is covered.
34
+ *
35
+ * @packageDocumentation
36
+ */
37
+
38
+ /**
39
+ * Files a TARGET legitimately adds INSIDE an installed skill dir — enrichment, not drift.
40
+ *
41
+ * `dz init --enrich` / `dz setup --enrich` write per-target metadata into the skill dir itself
42
+ * (`operations.ts`): codex gets `<skillDir>/agents/openai.yaml` (UI metadata + risk scoring),
43
+ * hermes gets `<skillDir>/hermes-config.yaml`. OpenCode's enrichment lands in
44
+ * `.opencode/agents/<id>.md`, OUTSIDE any skill dir, so it needs no entry here.
45
+ *
46
+ * These paths must be invisible to the drift comparison and untouchable by the healer. Without the
47
+ * exemption, widening discovery to install roots would (a) report permanent, unfixable drift on any
48
+ * enriched install — making the HARD gate unsatisfiable — and (b) worse, let `dz sync-canonical`
49
+ * DELETE the enrichment, because the heal removes every file the canonical does not have. Found by
50
+ * cross-family review (Codex `gpt-5.6-sol`, 2026-08-25) on a latent defect: this repo's own
51
+ * `.agents/` install was never enriched, so no test and no live run would have shown it.
52
+ *
53
+ * Each asset is mapped to the install root that OWNS it, because the exemption must depend on WHERE
54
+ * the file sits, not only on its name: `agents/openai.yaml` under a package copy, or
55
+ * `hermes-config.yaml` outside the hermes root, is a misplaced file — waving it through would let
56
+ * the sweep report clean and the healer preserve it (second cross-family round, 2026-08-25).
57
+ *
58
+ * Keys are relative to the skill dir root and POSIX-separated; values are repo-root-relative install
59
+ * roots from {@link SKILL_INSTALL_ROOT_BY_TARGET}.
60
+ */
61
+ export const TARGET_ENRICHMENT_ASSETS: Readonly<Record<string, string>> = {
62
+ 'agents/openai.yaml': '.agents/skills',
63
+ 'hermes-config.yaml': '.hermes/skills',
64
+ };
65
+
66
+ /**
67
+ * The one install root that is ALSO the repo's hand-edited development tree.
68
+ *
69
+ * `.claude/skills` is where this repo's own skills are authored before they are synced into
70
+ * `packages/`, so it legitimately LAGS the published copies — MEASURED 2026-08-25, `dz drift-check
71
+ * --all` reports three skills drifting for exactly that reason (`decision-mockups` 12/22 files,
72
+ * `idea2prd-manual` 3/11, `observability` 1/5). A byte-identity GATE that included it would be
73
+ * red-on-arrival, which is why `scope: 'packages'` exists at all.
74
+ *
75
+ * Every OTHER root in {@link SKILL_INSTALL_ROOTS} is machine-generated by `dz install --target …`
76
+ * and has no licence to lag: a stale generated copy is the defect this module was written for.
77
+ * That asymmetry is what `scope: 'installs'` encodes.
78
+ */
79
+ export const DEV_SKILL_ROOT = '.claude/skills';
80
+
81
+ /**
82
+ * Every `--target` name → the repo-root-relative dir into which it installs `SKILL.md`-bearing
83
+ * skill dirs, or `null` for a target that emits rules/instructions FILES and therefore holds no
84
+ * skill dirs to keep in sync.
85
+ *
86
+ * **This map, not the derived list, is the thing a new target must touch.** Its keys are asserted
87
+ * against `TARGET_NAMES` in `test/skill-install-roots.test.ts`, so adding target #11 turns that
88
+ * test RED until someone states whether it installs skill dirs — which is exactly the question
89
+ * nobody was asked when `codex` was added and its install tree went ungated. A test that
90
+ * hand-enumerated the adapter constants would have stayed green through that (raised by
91
+ * cross-family review, Codex `gpt-5.6-sol`, 2026-08-25).
92
+ *
93
+ * Kept as a literal with NO imports: `skill-drift.ts` consumes it and is documented dependency-free,
94
+ * while `targets.ts` — which owns the adapter registry — pulls in all ten adapter packages.
95
+ */
96
+ export const SKILL_INSTALL_ROOT_BY_TARGET: Readonly<Record<string, string | null>> = {
97
+ 'claude-code': '.claude/skills',
98
+ codex: '.agents/skills',
99
+ opencode: '.opencode/skills',
100
+ hermes: '.hermes/skills',
101
+ openclaude: '.openclaude/skills',
102
+ // Rules/instructions FILE targets — no skill dirs, nothing to compare.
103
+ copilot: null,
104
+ 'agents-md': null,
105
+ cursor: null,
106
+ gemini: null,
107
+ windsurf: null,
108
+ };
109
+
110
+ /**
111
+ * Repo-root-relative directories that hold installed `SKILL.md`-bearing skill dirs — DERIVED from
112
+ * {@link SKILL_INSTALL_ROOT_BY_TARGET} so the two can never disagree. POSIX separators; consumers
113
+ * `join()` them onto the repo root themselves.
114
+ *
115
+ * Consumed by `findSkillDirs` (`skill-drift.ts`). A root that does not exist, or exists without any
116
+ * `SKILL.md`, contributes nothing — so listing a root the current repo does not use is inert.
117
+ */
118
+ export const SKILL_INSTALL_ROOTS: readonly string[] = Object.values(SKILL_INSTALL_ROOT_BY_TARGET)
119
+ .filter((r): r is string => r !== null);