@dzhechkov/harness-core 0.7.5 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/sbom.json CHANGED
@@ -25,7 +25,7 @@
25
25
  "hashes": [
26
26
  {
27
27
  "alg": "SHA-256",
28
- "content": "e4b3fb2b1f94a81266e077a192da27dcab830172599703c4495746f560a41f07"
28
+ "content": "196e14163a267a84083c01a9c4c7d6a26b49adcf4287ade505600848baa02161"
29
29
  }
30
30
  ]
31
31
  },
@@ -1445,7 +1445,7 @@
1445
1445
  "hashes": [
1446
1446
  {
1447
1447
  "alg": "SHA-256",
1448
- "content": "ceb6662e21993cad25708c438410ad1b2d81eb05e59cae62c239719b71ac5107"
1448
+ "content": "8ee50d5067b71b1d68c57dd8c5f0f0a327ffc282755a13d17565613b9d036f92"
1449
1449
  }
1450
1450
  ]
1451
1451
  },
@@ -1455,7 +1455,7 @@
1455
1455
  "hashes": [
1456
1456
  {
1457
1457
  "alg": "SHA-256",
1458
- "content": "aaff9f23dec9c15c4d3cc74d13a5704649a305514c9e621100381ecf1c4f4143"
1458
+ "content": "ccc9f6d9ce8ba5699010c5b590fef6509d80ed928043b8747f5c52980d5c0596"
1459
1459
  }
1460
1460
  ]
1461
1461
  },
@@ -1465,7 +1465,7 @@
1465
1465
  "hashes": [
1466
1466
  {
1467
1467
  "alg": "SHA-256",
1468
- "content": "283d55283adbfe3a358b0f2e48cb05af47468274aa251a0f3419acd284937794"
1468
+ "content": "b8bfd4707746ca1ded26c2ccc9a4afbe5a9acf529b74e128dadced1631d1adde"
1469
1469
  }
1470
1470
  ]
1471
1471
  },
@@ -1635,7 +1635,7 @@
1635
1635
  "hashes": [
1636
1636
  {
1637
1637
  "alg": "SHA-256",
1638
- "content": "76a367dc0bab660eac680001e5b2f4441fd7961a4d1bb540757244459dbba7f0"
1638
+ "content": "4eadcf12e18ccfbe77f857d71e52b29d51b35ee8bb9bbdf3a34cfb6724826c10"
1639
1639
  }
1640
1640
  ]
1641
1641
  },
@@ -1645,7 +1645,7 @@
1645
1645
  "hashes": [
1646
1646
  {
1647
1647
  "alg": "SHA-256",
1648
- "content": "4d113675e5deda687e50ec4c5df54611497469187f6725236ba62dd2f9d0a7ed"
1648
+ "content": "de6cd5f7de860105804f100e487b65a78f2edd67a40645a710ad81c12e1c5b72"
1649
1649
  }
1650
1650
  ]
1651
1651
  },
@@ -1655,7 +1655,7 @@
1655
1655
  "hashes": [
1656
1656
  {
1657
1657
  "alg": "SHA-256",
1658
- "content": "368927d5b439f5180bd0f49820d8d598eaae1679bd5fd91af6a9a0a78e08d7c0"
1658
+ "content": "bdb6933fa24b0e02ed293fe4e9c52910e2ed4f5652ea7bcb0dbbb7dc996cc224"
1659
1659
  }
1660
1660
  ]
1661
1661
  },
@@ -1665,7 +1665,7 @@
1665
1665
  "hashes": [
1666
1666
  {
1667
1667
  "alg": "SHA-256",
1668
- "content": "d3ee98a0953cf6134331cc1a31bd959fbcf83313bd93973d58125164fe04f7b7"
1668
+ "content": "b47db4310de5fd7a586e795e10abe108e18cd164d1cb1862e67c227310a68001"
1669
1669
  }
1670
1670
  ]
1671
1671
  },
@@ -2555,7 +2555,7 @@
2555
2555
  "hashes": [
2556
2556
  {
2557
2557
  "alg": "SHA-256",
2558
- "content": "25497c798af7e7b614acea014826873c3db41232bdbda57399814b5b8b2e2687"
2558
+ "content": "0531b606a3f4450741ec80efbcb9c32ff0a925496e85e4071cb395eeeeaf9dfb"
2559
2559
  }
2560
2560
  ]
2561
2561
  },
@@ -2565,7 +2565,7 @@
2565
2565
  "hashes": [
2566
2566
  {
2567
2567
  "alg": "SHA-256",
2568
- "content": "ec7f64eb9a3938a446beba21cf1134e901522c87e69639ba2d27fa161a9f0236"
2568
+ "content": "edefc472f25e03e14d27545cd3fbc0a78e59cee49e4cddc397d0f05321feac22"
2569
2569
  }
2570
2570
  ]
2571
2571
  },
@@ -2575,7 +2575,7 @@
2575
2575
  "hashes": [
2576
2576
  {
2577
2577
  "alg": "SHA-256",
2578
- "content": "09bf8999ae95138b5fe21e70af3642cb6bea0593365108e8756e820f53c6aafc"
2578
+ "content": "a5656ebc2049c324ebe7707e1263beb16ceb0875ee078353203e346eb67fff42"
2579
2579
  }
2580
2580
  ]
2581
2581
  },
@@ -2585,7 +2585,7 @@
2585
2585
  "hashes": [
2586
2586
  {
2587
2587
  "alg": "SHA-256",
2588
- "content": "db6c50a2c5cbba8dc47671bd540740ed8e3bfbc742a51d9dd3a4ecc343ff32a8"
2588
+ "content": "74f4d718f0ca68857448c043eb8f439c841604db64ee2d799ddec9f66dc15556"
2589
2589
  }
2590
2590
  ]
2591
2591
  },
@@ -3555,7 +3555,7 @@
3555
3555
  "hashes": [
3556
3556
  {
3557
3557
  "alg": "SHA-256",
3558
- "content": "cbbf348e4693f5d8bbffa9b53b014f414121b04efcd71a3c9af185d78fc29227"
3558
+ "content": "01cc22fc645fc6656d6dd5d32d124a6d2ffe29130724a2142c3e12b84b891378"
3559
3559
  }
3560
3560
  ]
3561
3561
  },
@@ -3565,7 +3565,7 @@
3565
3565
  "hashes": [
3566
3566
  {
3567
3567
  "alg": "SHA-256",
3568
- "content": "086c9da449289de56c0267c45b6dfc88ef924e5377d6da93216fde2b6c91f8d3"
3568
+ "content": "c6ab4133ae6464684e14f619cd8175adec744bb775e9724bfd0808752a62692b"
3569
3569
  }
3570
3570
  ]
3571
3571
  },
@@ -3575,7 +3575,7 @@
3575
3575
  "hashes": [
3576
3576
  {
3577
3577
  "alg": "SHA-256",
3578
- "content": "8897f3f2cb32e671477bfb8f803e69aae64f3944594e1d33ebfbb5fea910d223"
3578
+ "content": "dfa4e1ef2b211af0957cea622c6066891c34452b4f9092bc143350b150ff6708"
3579
3579
  }
3580
3580
  ]
3581
3581
  },
@@ -3585,7 +3585,47 @@
3585
3585
  "hashes": [
3586
3586
  {
3587
3587
  "alg": "SHA-256",
3588
- "content": "fe02423ea0ca2460f97cfa47ff6f9c564106920fae151a8552ce4520ab4a463f"
3588
+ "content": "51ced5700c3efafbb1de270302a789578e80cf80d4859190f5ecdb646378a479"
3589
+ }
3590
+ ]
3591
+ },
3592
+ {
3593
+ "type": "file",
3594
+ "name": "dist/skill-install-roots.d.ts",
3595
+ "hashes": [
3596
+ {
3597
+ "alg": "SHA-256",
3598
+ "content": "7aebdacaa5d1641e739628049a5bfcc94d5191bc62dfc70744906c579d0bf52c"
3599
+ }
3600
+ ]
3601
+ },
3602
+ {
3603
+ "type": "file",
3604
+ "name": "dist/skill-install-roots.d.ts.map",
3605
+ "hashes": [
3606
+ {
3607
+ "alg": "SHA-256",
3608
+ "content": "948876ad8b0b1cab37d52957e0ffa69afd18a3154a6204f85159e748ed2c3830"
3609
+ }
3610
+ ]
3611
+ },
3612
+ {
3613
+ "type": "file",
3614
+ "name": "dist/skill-install-roots.js",
3615
+ "hashes": [
3616
+ {
3617
+ "alg": "SHA-256",
3618
+ "content": "5e218e069af73918ab7d028ce7694640e0cc506f714608873c6b1138587bed1b"
3619
+ }
3620
+ ]
3621
+ },
3622
+ {
3623
+ "type": "file",
3624
+ "name": "dist/skill-install-roots.js.map",
3625
+ "hashes": [
3626
+ {
3627
+ "alg": "SHA-256",
3628
+ "content": "6f46e1622b721bb009e7da347487af5a730279cdd26a712d34ed885c366bfebc"
3589
3629
  }
3590
3630
  ]
3591
3631
  },
@@ -3875,7 +3915,7 @@
3875
3915
  "hashes": [
3876
3916
  {
3877
3917
  "alg": "SHA-256",
3878
- "content": "b07d9ab76b98d7fe7c0d576e8a027681d2ee134b93fc7e4faaa411a088f1776b"
3918
+ "content": "f05456c49df6e2c4e5162a95b879a71fc2a89f509f2e1c94fd9cae926ef761f0"
3879
3919
  }
3880
3920
  ]
3881
3921
  },
@@ -3885,7 +3925,7 @@
3885
3925
  "hashes": [
3886
3926
  {
3887
3927
  "alg": "SHA-256",
3888
- "content": "282906840e019b31b8e4749e21e3e858378bdf72a0f71045546461c0e320137b"
3928
+ "content": "4c2311420512b9e7d61a8eef321a0ed7704428b5b5daaaaba09ac09e2040dfa6"
3889
3929
  }
3890
3930
  ]
3891
3931
  },
@@ -3895,7 +3935,7 @@
3895
3935
  "hashes": [
3896
3936
  {
3897
3937
  "alg": "SHA-256",
3898
- "content": "dd60c66726cfe835f80e9e3a1714df8364814fbca6fcee82b397960a7ad092c8"
3938
+ "content": "c30c63f093e12f152b9d4eac12e1b3921d2f81a27cac062dd783e9a7d91663cf"
3899
3939
  }
3900
3940
  ]
3901
3941
  },
@@ -3905,7 +3945,7 @@
3905
3945
  "hashes": [
3906
3946
  {
3907
3947
  "alg": "SHA-256",
3908
- "content": "9c1c302f824a99de31ddafad8780e7369a61903cc81e1d0a8bbabab05a22a493"
3948
+ "content": "e698d6514ca71031acce40edf875cf0fd2c7acabf3ab336601abec26a0f0bd86"
3909
3949
  }
3910
3950
  ]
3911
3951
  },
@@ -4275,7 +4315,7 @@
4275
4315
  "hashes": [
4276
4316
  {
4277
4317
  "alg": "SHA-256",
4278
- "content": "57cb5a9676f0013a407ad3b7afb9915da56b37447bd4bd3806a8c63415f9c563"
4318
+ "content": "a75aa6acab6e787f214313004d83fcde7a3332e9828b61bf383fdc5e58a98186"
4279
4319
  }
4280
4320
  ]
4281
4321
  },
@@ -4635,7 +4675,7 @@
4635
4675
  "hashes": [
4636
4676
  {
4637
4677
  "alg": "SHA-256",
4638
- "content": "4431a710b1d5d6a025aab2d7790eb1cb46816a0332c5e45637467036f7ac8f37"
4678
+ "content": "4a2d4c4c9c952521e0265fc62a01de3a7cd277c1c9eab8be252532bb72d0ae96"
4639
4679
  }
4640
4680
  ]
4641
4681
  },
@@ -4685,7 +4725,7 @@
4685
4725
  "hashes": [
4686
4726
  {
4687
4727
  "alg": "SHA-256",
4688
- "content": "d75e2db8aacda1d4a5c0271721a08da0bedee0a9d293396ddfc71229c3c5cb9f"
4728
+ "content": "18a9f1e7f84b56f0c7aa23b096a15c414418ecdd4fafc2f29d38d6dcbf761c84"
4689
4729
  }
4690
4730
  ]
4691
4731
  },
@@ -4915,7 +4955,7 @@
4915
4955
  "hashes": [
4916
4956
  {
4917
4957
  "alg": "SHA-256",
4918
- "content": "d76e4adf1de495beb6c30cc665c17a7d9a43657fb4e1ec0640208b1617a22cb6"
4958
+ "content": "09930294d2b7fcd2938c15b8d8a62a9d1615c064a2479cae1918e0ea4d4ba045"
4919
4959
  }
4920
4960
  ]
4921
4961
  },
@@ -5155,7 +5195,17 @@
5155
5195
  "hashes": [
5156
5196
  {
5157
5197
  "alg": "SHA-256",
5158
- "content": "ced99bc1638fbc2a54c61182b06f5f014018c945fd1dd84720bcef3597442ad4"
5198
+ "content": "64814af9f30ed22e5b3e77e01c73b06995092ca54ac84d8cea327cd1b25402d3"
5199
+ }
5200
+ ]
5201
+ },
5202
+ {
5203
+ "type": "file",
5204
+ "name": "src/skill-install-roots.ts",
5205
+ "hashes": [
5206
+ {
5207
+ "alg": "SHA-256",
5208
+ "content": "513710bca94206f9f50abfcf0bd934b315a5af97cad4f5f302b2559d5821848f"
5159
5209
  }
5160
5210
  ]
5161
5211
  },
@@ -5235,7 +5285,7 @@
5235
5285
  "hashes": [
5236
5286
  {
5237
5287
  "alg": "SHA-256",
5238
- "content": "33ef88f037ea2e34b41836d04f21812fea743022dcbd30f51361c696ddc46e9a"
5288
+ "content": "003d420be10bd0dd61eb6fe5c724d5de6d0c02b72e37bbae5a57c269545e3abb"
5239
5289
  }
5240
5290
  ]
5241
5291
  },
@@ -2029,7 +2029,12 @@ export function planCompletenessGateCmd(repo: string, featureDir: string, tier?:
2029
2029
  // and the tried paths live OUTSIDE the verdict line so no path can smuggle a second verdict word
2030
2030
  // into it.
2031
2031
  'echo "K2_GATE_SCRIPT=${GS:-none}"',
2032
- 'echo "K2_GATE_TRIED=${C1:-(none)} | $C2 | $C3"',
2032
+ 'echo "K2_GATE_TRIED=C1(args.gateScript)=${C1:-<unset>} | C2(workspace)=$C2 | C3(target-repo)=$C3"',
2033
+ // A COLLAPSE is not a second candidate. When the workspace was not pinned, WS falls back to the
2034
+ // gate agent own cwd — in the field that WAS the target repo, so C2 and C3 printed the same path
2035
+ // twice and the chain silently degenerated from three candidates to two. Saying so turns a
2036
+ // puzzling duplicate into an instruction. Not verdict-shaped, so the parser anchoring is untouched.
2037
+ '[ "$C2" = "$C3" ] && echo "K2_GATE_NOTE=the workspace candidate resolved to the TARGET repo (WS==repo), so only two distinct candidates were tried; pass args.workspace or args.gateScript when the feature-adr skill is installed outside the target repo"',
2033
2038
  'if [ -z "$GS" ]; then echo "K2 plan-completeness: NOT-ESTABLISHED — tooling-missing: no gate script at any candidate on the K2_GATE_TRIED line above"; echo "K2_EXIT=3"; else cd ' + q(repo) + ' && node "$GS" ' + q(featureDir) + t + ' 2>&1; echo "K2_EXIT=$?"; fi',
2034
2039
  ].join('\n')
2035
2040
  }
package/src/index.ts CHANGED
@@ -76,6 +76,7 @@ export type { CreateSkillOptions, CreateSkillResult } from './create-skill.js';
76
76
  export { checkUpstream, checkAllUpstream, discoverSourcePackages, loadSourcesManifest } from './sync-upstream.js';
77
77
  export type { SyncUpstreamReport, UpstreamCheckResult, SourcesManifest, SourcePackageInfo } from './sync-upstream.js';
78
78
  export { sweepSkillDrift, syncCanonicalSkill } from './skill-drift.js';
79
+ export { SKILL_INSTALL_ROOTS, SKILL_INSTALL_ROOT_BY_TARGET, DEV_SKILL_ROOT, TARGET_ENRICHMENT_ASSETS } from './skill-install-roots.js';
79
80
  export type { SweepResult, DriftedSkill, SyncResult, SyncCanonicalOptions } from './skill-drift.js';
80
81
  export { benchmarkSkill, benchmarkSkills, compareSkills } from './benchmark.js';
81
82
  export { buildRegistry, searchRegistry, filterByCategory, skillPackBaseDirs, discoverSkillPackDirs, discoverVerifiablePackDirs } from './registry.js';
package/src/provenance.ts CHANGED
@@ -25,6 +25,12 @@ export type SourceVerdict =
25
25
  | 'no-source'
26
26
  /** The manifest did not declare what kind of source this is — never inferred from its shape. */
27
27
  | 'unknown-kind'
28
+ /** A PUBLIC external web URL (http/https). It is already public by construction, so it cannot
29
+ * "leave this machine" — nothing local to protect. Refused only if the URL is malformed. */
30
+ | 'public-url'
31
+ /** A `url` claim whose value is not a well-formed http(s) URL — a scheme this gate will not clear
32
+ * (a file://, a bare word, or a non-URL) must not pass as a public web source. */
33
+ | 'malformed-url'
28
34
  /** A store record that the TRACKED public list does not name. Default-deny. */
29
35
  | 'not-marked-public'
30
36
  /** The path does not resolve. You cannot cite what does not exist. */
@@ -164,6 +170,18 @@ export function classifySource(claim: SourceClaim, facts: SourceProvenanceFacts)
164
170
  return at('allowed', 'committed, reviewed, and not refused by the owner\'s boundary');
165
171
  }
166
172
 
173
+ if (claim.kind === 'url') {
174
+ // The channel translates OTHERS' public tweets/posts; their sources are PUBLIC web URLs. Such a
175
+ // URL is already public — it cannot leak off this machine, so there is no local secret to
176
+ // protect (the whole point of the path/record checks). It is cleared iff it is a well-formed
177
+ // http(s) URL; anything else (file://, a bare path, a non-URL) is refused, never inferred.
178
+ let ok = false;
179
+ try { const u = new URL(source); ok = u.protocol === 'http:' || u.protocol === 'https:'; } catch { ok = false; }
180
+ return ok
181
+ ? at('public-url', 'a public http(s) web source — already public, nothing local to protect')
182
+ : at('malformed-url', 'kind "url" but the value is not a well-formed http(s) URL — a public web source must be a real http(s) URL');
183
+ }
184
+
167
185
  return at('unknown-kind', `the manifest declares kind ${JSON.stringify(claim.kind ?? null)} — a kind this gate cannot check is refused, never inferred from the path's shape`);
168
186
  }
169
187
 
@@ -187,7 +205,11 @@ export function decideSourceProvenance(manifest: SourceManifest | null, facts: S
187
205
  return { outcome: 'not-established', exit: 3, claims: [], reason: 'the manifest lists no claims — a draft with nothing to check has not been shown to be safe, only left unchecked' };
188
206
  }
189
207
  const claims = manifest.claims.map((c) => classifySource(c, facts));
190
- const blocked = claims.filter((c) => c.verdict !== 'allowed');
208
+ // A claim passes iff its verdict is a CLEARED one: a repo-internal source proven safe (allowed),
209
+ // or a public web URL that is already public by construction (public-url — the tg-post channel
210
+ // case). Every other verdict is a refusal.
211
+ const CLEARED = new Set(['allowed', 'public-url']);
212
+ const blocked = claims.filter((c) => !CLEARED.has(c.verdict));
191
213
  if (blocked.length > 0) {
192
214
  return {
193
215
  outcome: 'blocked',
@@ -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);