@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.
- package/.dz-manifest.json +141 -61
- package/README.md +38 -1
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +26 -2
- package/dist/agentdb-index.js.map +1 -1
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +6 -1
- package/dist/amendment-trace.js.map +1 -1
- package/dist/cadence.d.ts +66 -0
- package/dist/cadence.d.ts.map +1 -0
- package/dist/cadence.js +222 -0
- package/dist/cadence.js.map +1 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +7 -2
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +2 -2
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +53 -18
- package/dist/operations.js.map +1 -1
- package/dist/provenance.d.ts +6 -0
- package/dist/provenance.d.ts.map +1 -1
- package/dist/provenance.js +22 -1
- package/dist/provenance.js.map +1 -1
- package/dist/publish.d.ts +31 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +78 -0
- package/dist/publish.js.map +1 -1
- package/dist/recall-hook-policy.d.ts +3 -1
- package/dist/recall-hook-policy.d.ts.map +1 -1
- package/dist/recall-hook-policy.js +15 -2
- package/dist/recall-hook-policy.js.map +1 -1
- package/dist/recall-usage.d.ts +15 -0
- package/dist/recall-usage.d.ts.map +1 -1
- package/dist/recall-usage.js +51 -1
- package/dist/recall-usage.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +38 -4
- package/dist/score.js.map +1 -1
- package/dist/skill-drift.d.ts +8 -2
- package/dist/skill-drift.d.ts.map +1 -1
- package/dist/skill-drift.js +60 -11
- package/dist/skill-drift.js.map +1 -1
- package/dist/skill-install-roots.d.ts +100 -0
- package/dist/skill-install-roots.d.ts.map +1 -0
- package/dist/skill-install-roots.js +116 -0
- package/dist/skill-install-roots.js.map +1 -0
- package/dist/tg-post.d.ts +76 -0
- package/dist/tg-post.d.ts.map +1 -0
- package/dist/tg-post.js +158 -0
- package/dist/tg-post.js.map +1 -0
- package/dist/usage.d.ts +31 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +108 -21
- package/dist/usage.js.map +1 -1
- package/dist/writer-quiescence.d.ts +42 -0
- package/dist/writer-quiescence.d.ts.map +1 -0
- package/dist/writer-quiescence.js +82 -0
- package/dist/writer-quiescence.js.map +1 -0
- package/package.json +13 -13
- package/sbom.json +260 -60
- package/src/agentdb-index.ts +26 -2
- package/src/amendment-trace.ts +6 -1
- package/src/cadence.ts +227 -0
- package/src/feature-adr-routing.ts +7 -2
- package/src/index.ts +8 -1
- package/src/loop-blobs.generated.ts +2 -2
- package/src/operations.ts +52 -19
- package/src/provenance.ts +23 -1
- package/src/publish.ts +98 -0
- package/src/recall-hook-policy.ts +15 -2
- package/src/recall-usage.ts +44 -1
- package/src/score.ts +30 -4
- package/src/skill-drift.ts +70 -13
- package/src/skill-install-roots.ts +119 -0
- package/src/tg-post.ts +192 -0
- package/src/usage.ts +132 -17
- 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
|
|
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
|
-
|
|
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;
|
package/src/recall-usage.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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.
|
package/src/skill-drift.ts
CHANGED
|
@@ -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
|
-
* - `'
|
|
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))
|
|
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
|
-
|
|
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/` +
|
|
202
|
-
* `node_modules` / `__pycache__`.
|
|
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
|
-
|
|
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
|
-
|
|
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);
|