@dzhechkov/harness-core 0.8.39 → 0.8.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +235 -135
- package/README.md +91 -8
- package/dist/__tests__/golden-baseline.test.js +1 -1
- package/dist/__tests__/golden-baseline.test.js.map +1 -1
- package/dist/agentdb-index.js +3 -3
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-reindex-marker.d.ts +2 -2
- package/dist/agentdb-reindex-marker.js +4 -4
- package/dist/agentdb-reindex-marker.js.map +1 -1
- package/dist/agentdb-snapshot-rotation.d.ts +1 -1
- package/dist/agentdb-snapshot-rotation.js +1 -1
- package/dist/backup-freshness.d.ts +32 -0
- package/dist/backup-freshness.d.ts.map +1 -0
- package/dist/backup-freshness.js +69 -0
- package/dist/backup-freshness.js.map +1 -0
- package/dist/cmd-usage.d.ts +2 -2
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +56 -8
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +2 -1
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +15 -2
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/compounding.d.ts +109 -1
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +172 -9
- package/dist/compounding.js.map +1 -1
- package/dist/discrimination-gate.d.ts +0 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +2 -2
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/doctor-instrument.d.ts +65 -0
- package/dist/doctor-instrument.d.ts.map +1 -0
- package/dist/doctor-instrument.js +91 -0
- package/dist/doctor-instrument.js.map +1 -0
- package/dist/feature-tier.d.ts.map +1 -1
- package/dist/feature-tier.js +13 -1
- package/dist/feature-tier.js.map +1 -1
- package/dist/guard.d.ts +39 -2
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +117 -14
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +20 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -6
- package/dist/index.js.map +1 -1
- package/dist/integration-apply.js +2 -2
- package/dist/integration-apply.js.map +1 -1
- package/dist/lesson-payoff.js +3 -3
- package/dist/lesson-payoff.js.map +1 -1
- package/dist/mutation-gate.d.ts +30 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +45 -1
- package/dist/mutation-gate.js.map +1 -1
- package/dist/named-lock.d.ts +5 -2
- package/dist/named-lock.d.ts.map +1 -1
- package/dist/named-lock.js +30 -10
- package/dist/named-lock.js.map +1 -1
- package/dist/operations.d.ts +13 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +112 -2
- package/dist/operations.js.map +1 -1
- package/dist/pack-inventory.d.ts +51 -0
- package/dist/pack-inventory.d.ts.map +1 -0
- package/dist/pack-inventory.js +306 -0
- package/dist/pack-inventory.js.map +1 -0
- package/dist/package-skill-layouts.d.ts +3 -2
- package/dist/package-skill-layouts.d.ts.map +1 -1
- package/dist/package-skill-layouts.js +3 -2
- package/dist/package-skill-layouts.js.map +1 -1
- package/dist/patterns.d.ts +8 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +11 -1
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +29 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +92 -15
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts +58 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +86 -25
- package/dist/registry.js.map +1 -1
- package/dist/release.d.ts +18 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +30 -0
- package/dist/release.js.map +1 -1
- package/dist/reqe-verdict.d.ts +55 -0
- package/dist/reqe-verdict.d.ts.map +1 -0
- package/dist/reqe-verdict.js +173 -0
- package/dist/reqe-verdict.js.map +1 -0
- package/dist/reqe.d.ts +35 -14
- package/dist/reqe.d.ts.map +1 -1
- package/dist/reqe.js +85 -32
- package/dist/reqe.js.map +1 -1
- package/dist/round-exec.d.ts +10 -0
- package/dist/round-exec.d.ts.map +1 -1
- package/dist/round-exec.js +3 -2
- package/dist/round-exec.js.map +1 -1
- package/dist/round.d.ts +29 -0
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +8 -0
- package/dist/round.js.map +1 -1
- package/dist/session-retro.d.ts +1 -1
- package/dist/session-retro.js +3 -3
- package/dist/session-retro.js.map +1 -1
- package/dist/skill-drift.d.ts +18 -1
- package/dist/skill-drift.d.ts.map +1 -1
- package/dist/skill-drift.js +46 -13
- package/dist/skill-drift.js.map +1 -1
- package/dist/statusline.js +2 -2
- package/dist/statusline.js.map +1 -1
- package/dist/store-guard.js +3 -3
- package/dist/store-guard.js.map +1 -1
- package/dist/store-lock.d.ts +9 -0
- package/dist/store-lock.d.ts.map +1 -1
- package/dist/store-lock.js +22 -3
- package/dist/store-lock.js.map +1 -1
- package/dist/test-receipt.d.ts +66 -0
- package/dist/test-receipt.d.ts.map +1 -0
- package/dist/test-receipt.js +73 -0
- package/dist/test-receipt.js.map +1 -0
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +7 -8
- package/dist/workflow-run.js.map +1 -1
- package/package.json +1 -1
- package/sbom.json +384 -134
- package/src/__tests__/golden-baseline.test.ts +1 -1
- package/src/agentdb-index.ts +3 -3
- package/src/agentdb-reindex-marker.ts +4 -4
- package/src/agentdb-snapshot-rotation.ts +1 -1
- package/src/backup-freshness.ts +96 -0
- package/src/cmd-usage.ts +59 -8
- package/src/codex-hooks-assets.ts +15 -2
- package/src/compounding.ts +244 -10
- package/src/discrimination-gate.ts +2 -5
- package/src/doctor-instrument.ts +153 -0
- package/src/feature-tier.ts +14 -1
- package/src/guard.ts +128 -17
- package/src/index.ts +28 -7
- package/src/integration-apply.ts +2 -2
- package/src/lesson-payoff.ts +3 -3
- package/src/mutation-gate.ts +54 -1
- package/src/named-lock.ts +36 -11
- package/src/operations.ts +109 -3
- package/src/pack-inventory.ts +313 -0
- package/src/package-skill-layouts.ts +3 -2
- package/src/patterns.ts +18 -1
- package/src/publish.ts +101 -22
- package/src/registry.ts +114 -18
- package/src/release.ts +36 -0
- package/src/reqe-verdict.ts +174 -0
- package/src/reqe.ts +111 -35
- package/src/round-exec.ts +13 -2
- package/src/round.ts +38 -0
- package/src/session-retro.ts +3 -3
- package/src/skill-drift.ts +66 -12
- package/src/statusline.ts +2 -2
- package/src/store-guard.ts +3 -3
- package/src/store-lock.ts +20 -3
- package/src/test-receipt.ts +106 -0
- package/src/workflow-run.ts +7 -8
package/src/guard.ts
CHANGED
|
@@ -24,7 +24,18 @@ import {
|
|
|
24
24
|
import { findReleaseLine } from './release-line.js';
|
|
25
25
|
|
|
26
26
|
export type GuardSeverity = 'hard' | 'soft';
|
|
27
|
-
|
|
27
|
+
// The 'code' operation checks facts already established when code changes, such as shared skill-copy
|
|
28
|
+
// drift or a stale signature on a pack whose files changed. Publication readiness (README, release
|
|
29
|
+
// lines, versions) belongs to publish. On 2026-09-21, story-page's stale signature surfaced six hours
|
|
30
|
+
// after the edit in harness-cli's tests, demonstrating why edit-time facts need an edit-time gate.
|
|
31
|
+
export const GUARD_OPS = [
|
|
32
|
+
'publish',
|
|
33
|
+
'teach',
|
|
34
|
+
'consolidate',
|
|
35
|
+
'reindex',
|
|
36
|
+
'code',
|
|
37
|
+
] as const;
|
|
38
|
+
export type GuardOp = typeof GUARD_OPS[number];
|
|
28
39
|
export type GuardVerdict = 'pass' | 'warn' | 'block';
|
|
29
40
|
|
|
30
41
|
/** A declarative rule. Built-in rules ship with a checker (below); config can only tune/disable them. */
|
|
@@ -154,6 +165,8 @@ export interface GuardFacts {
|
|
|
154
165
|
};
|
|
155
166
|
/** for no-skill-drift: the names that byte-drift between copies (from sweepSkillDrift). */
|
|
156
167
|
readonly drift?: readonly string[];
|
|
168
|
+
/** for no-skill-drift: canonical-side defects as skill:path (from sweepSkillDrift). */
|
|
169
|
+
readonly canonicalDefects?: readonly string[];
|
|
157
170
|
/**
|
|
158
171
|
* for codex-wrapper-for-value-stage: workflow scripts to scan, as (path, text). Absent ⇒ the
|
|
159
172
|
* rule reports nothing: a guard with no evidence must stay silent rather than invent a verdict.
|
|
@@ -161,10 +174,15 @@ export interface GuardFacts {
|
|
|
161
174
|
readonly workflowScripts?: readonly { readonly path: string; readonly text: string }[];
|
|
162
175
|
/** for no-secrets: labelled blobs to scan (lesson text, staged files). */
|
|
163
176
|
readonly secretTargets?: readonly { readonly label: string; readonly text: string }[];
|
|
177
|
+
/** for no-secrets: findings already scanned from the streaming publish inventory. */
|
|
178
|
+
readonly secretFindings?: readonly { readonly label: string; readonly name: string }[];
|
|
164
179
|
/** for no-secrets: exact path waivers; entries without a non-empty reason are ignored and noted. */
|
|
165
180
|
readonly secretWaivers?: readonly SecretWaiver[];
|
|
166
181
|
/** Publish secret-scan coverage gaps; skips are informational and never verdict inputs. */
|
|
167
|
-
|
|
182
|
+
/** backlog a530eaca: `skippedPaths` names what the fail-open scan never read (repo-relative, with the
|
|
183
|
+
* reason) so the coverage gap is auditable — a bare count told the owner nothing to check. Optional:
|
|
184
|
+
* an older CLI that sends only the count still renders the count-only note. */
|
|
185
|
+
readonly secretScan?: { readonly skipped: number; readonly skippedPaths?: readonly string[]; readonly scanned?: number; readonly inventory?: string };
|
|
168
186
|
/** for readme-consistency: labelled (a,b) count pairs that must be equal. */
|
|
169
187
|
readonly counts?: readonly { readonly label: string; readonly a: number; readonly b: number }[];
|
|
170
188
|
/** for store-bloat-cap: current learned-store size vs its cap. */
|
|
@@ -199,7 +217,7 @@ export interface GuardFacts {
|
|
|
199
217
|
/**
|
|
200
218
|
* for review-round: per publishable package, does this change bump a version AND touch SOURCE, and
|
|
201
219
|
* did it bring a GRADED QE report with it? `undefined` (the whole fact absent) means the tree could
|
|
202
|
-
* not be read — the rule
|
|
220
|
+
* not be read — the rule is NOT-ESTABLISHED, which is different from reporting "no review".
|
|
203
221
|
*/
|
|
204
222
|
readonly reviewRound?: {
|
|
205
223
|
readonly packages: readonly { readonly name: string; readonly versionBumped: boolean; readonly sourceChanged: boolean }[];
|
|
@@ -477,12 +495,12 @@ export const DEFAULT_RULES: readonly GuardRule[] = [
|
|
|
477
495
|
{ id: 'no-workspace-star', severity: 'hard', ops: ['publish'], description: 'a published package.json must carry no workspace:* dep (npm ships it verbatim → the install breaks)' },
|
|
478
496
|
{ id: 'plugin-manifest-audit', severity: 'hard', ops: ['publish'], description: 'every .claude-plugin/plugin.json parses and declares a non-empty name, description and a STRICT N.N.N version' },
|
|
479
497
|
{ id: 'sibling-dep-protocol', severity: 'hard', ops: ['publish'], description: 'a dependencies/devDependencies entry on a sibling @dzhechkov package must use the workspace: protocol on disk (peer/optional deps are deliberately exempt — a range is their point)' },
|
|
480
|
-
{ id: 'no-skill-drift', severity: 'hard', ops: ['publish', 'consolidate'], description: 'no unexpected byte-drift between shared skill copies' },
|
|
498
|
+
{ id: 'no-skill-drift', severity: 'hard', ops: ['publish', 'consolidate', 'code'], description: 'no unexpected byte-drift between shared skill copies' },
|
|
481
499
|
{ id: 'backlog-covers-features', severity: 'soft', ops: ['publish', 'consolidate'], description: 'каталог фичи, заведённый после базовой даты, назван записью бэклога — либо несёт именованную оговорку с причиной' },
|
|
482
500
|
{ id: 'no-secrets', severity: 'hard', ops: ['teach', 'publish'], description: 'no private key or API token in lesson text or a published file' },
|
|
483
501
|
{ id: 'readme-consistency', severity: 'soft', ops: ['publish'], description: 'README counts agree (CJM header vs All Commands, etc.)' },
|
|
484
502
|
{ id: 'release-line-in-sync', severity: 'soft', ops: ['publish'], description: 'root and harness-cli README release lines agree with the harness-core and harness-cli package versions' },
|
|
485
|
-
{ id: 'signature-fresh', severity: 'soft', ops: ['publish'], description: 'a pack whose files changed in this diff still verifies against its signed .dz-manifest.json — a stale signature is named before publish, not at the gate' },
|
|
503
|
+
{ id: 'signature-fresh', severity: 'soft', ops: ['publish', 'code'], description: 'a pack whose files changed in this diff still verifies against its signed .dz-manifest.json — a stale signature is named before publish, not at the gate' },
|
|
486
504
|
{ id: 'skills-registrable', severity: 'soft', ops: ['publish'], description: 'every skill directory in a skill pack has a depth-1 SKILL.md (a buried or missing one ships un-registrable — the health-advisor 1.2.0 class)' },
|
|
487
505
|
{ id: 'readme-first', severity: 'soft', ops: ['publish'], description: 'a package with a staged version bump must update its own README.md in the same change (README-first)' },
|
|
488
506
|
{ id: 'routing-store-stale', severity: 'soft', ops: ['publish'], description: 'harvested routing telemetry has been applied to the auto-cost outcome store' },
|
|
@@ -527,6 +545,22 @@ export function scanSecrets(text: unknown): { readonly name: string }[] {
|
|
|
527
545
|
return [...hits].map((name) => ({ name }));
|
|
528
546
|
}
|
|
529
547
|
|
|
548
|
+
export const SECRET_SCAN_OVERLAP_BYTES = 4096;
|
|
549
|
+
|
|
550
|
+
export function scanSecretsChunked(chunks: Iterable<Buffer>, opts?: { overlapBytes?: number }): { name: string }[] {
|
|
551
|
+
const overlapBytes = opts?.overlapBytes ?? SECRET_SCAN_OVERLAP_BYTES;
|
|
552
|
+
const hits = new Set<string>();
|
|
553
|
+
let tail = Buffer.alloc(0);
|
|
554
|
+
for (const chunk of chunks) {
|
|
555
|
+
const window = Buffer.concat([tail, chunk]);
|
|
556
|
+
const text = window.toString('utf8');
|
|
557
|
+
for (const p of SECRET_PATTERNS) if (p.re.test(text)) hits.add(p.name);
|
|
558
|
+
if (hits.size === SECRET_PATTERNS.length) break;
|
|
559
|
+
tail = window.subarray(Math.max(0, window.length - overlapBytes));
|
|
560
|
+
}
|
|
561
|
+
return [...hits].map((name) => ({ name }));
|
|
562
|
+
}
|
|
563
|
+
|
|
530
564
|
function secretWaiverState(waivers: readonly SecretWaiver[] | undefined): { waived: ReadonlySet<string>; reasonless: number } {
|
|
531
565
|
const waived = new Set<string>();
|
|
532
566
|
let reasonless = 0;
|
|
@@ -613,7 +647,7 @@ function mentionsSlug(haystackLower: string, slug: string): boolean {
|
|
|
613
647
|
}
|
|
614
648
|
|
|
615
649
|
const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[]> = {
|
|
616
|
-
'rounds-closed': (f, sev) =>
|
|
650
|
+
'rounds-closed': (f, sev) => f.openRounds!
|
|
617
651
|
.filter((round) => Number.isFinite(round.ageMinutes) && round.ageMinutes > 120)
|
|
618
652
|
.map((round) => ({
|
|
619
653
|
rule: 'rounds-closed',
|
|
@@ -762,7 +796,11 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
762
796
|
},
|
|
763
797
|
'no-skill-drift': (f, sev) => {
|
|
764
798
|
const drifted = Array.isArray(f.drift) ? f.drift.filter((d) => typeof d === 'string') : [];
|
|
765
|
-
|
|
799
|
+
const defects = Array.isArray(f.canonicalDefects) ? f.canonicalDefects.filter((d) => typeof d === 'string') : [];
|
|
800
|
+
const out: Violation[] = [];
|
|
801
|
+
if (drifted.length > 0) out.push({ rule: 'no-skill-drift', severity: sev, detail: `${drifted.length} skill(s) drift between copies: ${drifted.slice(0, 8).join(', ')}${drifted.length > 8 ? '…' : ''} — heal with dz sync-canonical` });
|
|
802
|
+
if (defects.length > 0) out.push({ rule: 'no-skill-drift', severity: sev, detail: `${defects.length} canonical defect(s): ${defects.join(', ')} — fix the canon, not the copies` });
|
|
803
|
+
return out;
|
|
766
804
|
},
|
|
767
805
|
'backlog-covers-features': (f, sev) => {
|
|
768
806
|
const ev = f.featureBacklog;
|
|
@@ -787,11 +825,15 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
787
825
|
out.push({ rule: 'no-secrets', severity: sev, detail: `${t.label}: looks like a ${hit.name} — do not teach/publish a credential` });
|
|
788
826
|
}
|
|
789
827
|
}
|
|
828
|
+
for (const hit of f.secretFindings ?? []) {
|
|
829
|
+
if (waived.has(hit.label)) continue;
|
|
830
|
+
out.push({ rule: 'no-secrets', severity: sev, detail: `${hit.label}: looks like a ${hit.name} — do not teach/publish a credential` });
|
|
831
|
+
}
|
|
790
832
|
return out;
|
|
791
833
|
},
|
|
792
834
|
'readme-consistency': (f, sev) => {
|
|
793
835
|
const out: Violation[] = [];
|
|
794
|
-
for (const c of f.counts
|
|
836
|
+
for (const c of f.counts!) {
|
|
795
837
|
if (Number.isFinite(c.a) && Number.isFinite(c.b) && c.a !== c.b) {
|
|
796
838
|
out.push({ rule: 'readme-consistency', severity: sev, detail: `${c.label}: ${c.a} ≠ ${c.b} (README counts disagree)` });
|
|
797
839
|
}
|
|
@@ -804,7 +846,7 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
804
846
|
// a heuristic (a pack counts only if it already has one registrable skill, and only
|
|
805
847
|
// markdown-bearing dirs are considered intended), so it informs rather than blocks.
|
|
806
848
|
const out: Violation[] = [];
|
|
807
|
-
for (const p of f.skillPacks
|
|
849
|
+
for (const p of f.skillPacks!) {
|
|
808
850
|
if (!p || !Array.isArray(p.nonRegistrable) || p.nonRegistrable.length === 0) continue;
|
|
809
851
|
out.push({
|
|
810
852
|
rule: 'skills-registrable',
|
|
@@ -819,7 +861,7 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
819
861
|
// whose own README.md is untouched in the same diff. SOFT: some republishes legitimately need no doc
|
|
820
862
|
// change — the point is that skipping the README becomes a VISIBLE decision, not a silent lapse.
|
|
821
863
|
const out: Violation[] = [];
|
|
822
|
-
for (const p of f.readmeFirst
|
|
864
|
+
for (const p of f.readmeFirst!) {
|
|
823
865
|
if (p && p.versionBumped === true && p.readmeChanged !== true) {
|
|
824
866
|
out.push({ rule: 'readme-first', severity: sev, detail: `${p.name}: version bumped but its README.md is untouched in this change — README-first: document the change (or consciously proceed; this warning is the record)` });
|
|
825
867
|
}
|
|
@@ -886,7 +928,7 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
886
928
|
//
|
|
887
929
|
// Scoped to CHANGED SOURCE on purpose (ADR-001): a HARD rule that also fired on a docs-only
|
|
888
930
|
// republish would be a rule someone switches off. `undefined` facts mean the tree could not be
|
|
889
|
-
// read —
|
|
931
|
+
// read — HAS_INPUT records NOT-ESTABLISHED, not an accusation.
|
|
890
932
|
const rr = f.reviewRound;
|
|
891
933
|
if (rr === undefined) return [];
|
|
892
934
|
const min = typeof rr.minGrade === 'string' ? rr.minGrade.trim().toUpperCase() : undefined;
|
|
@@ -942,8 +984,8 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
942
984
|
* so it belongs on layer 1 rather than in a rule nobody re-reads.
|
|
943
985
|
*/
|
|
944
986
|
'codex-wrapper-for-value-stage': (f, sev) => {
|
|
945
|
-
const scripts = f.workflowScripts
|
|
946
|
-
if (scripts
|
|
987
|
+
const scripts = f.workflowScripts!; // Presence is established by HAS_INPUT.
|
|
988
|
+
if (scripts.length === 0) return [];
|
|
947
989
|
const out: Violation[] = [];
|
|
948
990
|
for (const s of scripts) {
|
|
949
991
|
const text = String(s.text ?? '');
|
|
@@ -1055,6 +1097,14 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
1055
1097
|
},
|
|
1056
1098
|
};
|
|
1057
1099
|
|
|
1100
|
+
// Missing volume means NOT-ESTABLISHED. A throwing getter is INTENTIONALLY treated as present
|
|
1101
|
+
// so volume() can emit honest 'unknown' observations instead of failing in the precondition.
|
|
1102
|
+
// This preserves the older contract: test/guard.test.ts:725,
|
|
1103
|
+
// "a hostile volume getter becomes visible unknown evidence instead of a HARD checker error".
|
|
1104
|
+
const volumeInputPresent = (f: GuardFacts): boolean => {
|
|
1105
|
+
try { return f.volume !== undefined; } catch { return true; }
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1058
1108
|
/** Per-rule evidence predicates. No entry preserves the rule's existing checked behaviour exactly. */
|
|
1059
1109
|
const HAS_INPUT: Partial<Record<string, (f: GuardFacts) => boolean>> = {
|
|
1060
1110
|
'rounds-traced': (f) => {
|
|
@@ -1070,9 +1120,24 @@ const HAS_INPUT: Partial<Record<string, (f: GuardFacts) => boolean>> = {
|
|
|
1070
1120
|
'sibling-dep-protocol': (f) => Array.isArray(f.siblingDeps) && f.siblingDeps.length > 0,
|
|
1071
1121
|
// Дерево без плагин-манифестов правилу нечего сказать: «прошло» тут значило бы «не смотрели».
|
|
1072
1122
|
'plugin-manifest-audit': (f) => Array.isArray(f.pluginManifests) && f.pluginManifests.length > 0,
|
|
1073
|
-
'no-secrets': (f) => Array.isArray(f.secretTargets) && f.secretTargets.length > 0
|
|
1123
|
+
'no-secrets': (f) => (Array.isArray(f.secretTargets) && f.secretTargets.length > 0)
|
|
1124
|
+
|| Array.isArray(f.secretFindings) || typeof f.secretScan?.scanned === 'number',
|
|
1074
1125
|
'release-line-in-sync': (f) => typeof f.releaseLines === 'object' && f.releaseLines !== null,
|
|
1126
|
+
// 2026-09-21: на одном дереве с дрейфом publish=block, а code=pass без собранного факта drift.
|
|
1127
|
+
'no-skill-drift': (f) => Array.isArray(f.drift),
|
|
1128
|
+
'review-round': (f) => f.reviewRound !== undefined,
|
|
1129
|
+
'no-workspace-star': (f) => Array.isArray(f.packages),
|
|
1130
|
+
'codex-wrapper-for-value-stage': (f) => Array.isArray(f.workflowScripts),
|
|
1075
1131
|
'signature-fresh': (f) => Array.isArray(f.signedPacks),
|
|
1132
|
+
'rounds-closed': (f) => Array.isArray(f.openRounds),
|
|
1133
|
+
'readme-consistency': (f) => Array.isArray(f.counts),
|
|
1134
|
+
'skills-registrable': (f) => Array.isArray(f.skillPacks),
|
|
1135
|
+
'readme-first': (f) => Array.isArray(f.readmeFirst),
|
|
1136
|
+
'store-bloat-cap': (f) => f.store !== undefined,
|
|
1137
|
+
'template-context-token-weight': volumeInputPresent,
|
|
1138
|
+
'template-context-largest-file-share': volumeInputPresent,
|
|
1139
|
+
'feature-artifact-diff-ratio': volumeInputPresent,
|
|
1140
|
+
'feature-tier-artifact-set': volumeInputPresent,
|
|
1076
1141
|
};
|
|
1077
1142
|
|
|
1078
1143
|
/**
|
|
@@ -1138,7 +1203,7 @@ export function resolveRules(userRules?: readonly Partial<GuardRule>[]): GuardRu
|
|
|
1138
1203
|
// than `lockfile-in-sync`'s tolerant parser, which is already SOFT-only. "I might be wrong"
|
|
1139
1204
|
// plus "block the publish" is the wrong pair (ADR-004).
|
|
1140
1205
|
if (isTemplateRule(u)) {
|
|
1141
|
-
const ops = Array.isArray(u.ops) && u.ops.every((o) =>
|
|
1206
|
+
const ops = Array.isArray(u.ops) && u.ops.every((o) => (GUARD_OPS as readonly string[]).includes(o as string)) && u.ops.length > 0 ? (u.ops as readonly GuardOp[]) : (['publish'] as const);
|
|
1142
1207
|
byId.set(u.id, {
|
|
1143
1208
|
id: u.id,
|
|
1144
1209
|
severity: 'soft',
|
|
@@ -1203,9 +1268,11 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
|
|
|
1203
1268
|
try {
|
|
1204
1269
|
inputPresent = hasInput === undefined ? true : hasInput(facts);
|
|
1205
1270
|
} catch (error) {
|
|
1271
|
+
// Бросок в предикате улик и бросок в чекере обязаны нести общий маркер fail-closed:
|
|
1272
|
+
// страж не смог решить и потому обвиняет, а не оправдывает; какой перехватчик был первым, потребителю неважно.
|
|
1206
1273
|
violations.push({
|
|
1207
1274
|
rule: r.id, severity: r.severity,
|
|
1208
|
-
detail: `улики правила нечитаемы (${error instanceof Error ? error.message : String(error)}) — правило считается НАРУШЕННЫМ, а не
|
|
1275
|
+
detail: `улики правила нечитаемы (${error instanceof Error ? error.message : String(error)}) — правило считается НАРУШЕННЫМ, а не пройденным (fail-closed)`,
|
|
1209
1276
|
});
|
|
1210
1277
|
checked.push(r.id);
|
|
1211
1278
|
continue;
|
|
@@ -1291,10 +1358,30 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
|
|
|
1291
1358
|
}
|
|
1292
1359
|
const skipped = facts.secretScan?.skipped;
|
|
1293
1360
|
if (typeof skipped === 'number' && Number.isFinite(skipped) && skipped > 0) {
|
|
1294
|
-
|
|
1361
|
+
// MEASURED 2026-09-21: the count alone hid that harness-cli's own src/cli.ts and dist/cli.js
|
|
1362
|
+
// (1.3 MiB each, both packed) are among the never-scanned items — name them, capped.
|
|
1363
|
+
// Codex review (B): the legacy sentence stays byte-identical for consumers that match it whole;
|
|
1364
|
+
// the names are APPENDED after it. "…and N more" counts from the FACT's `skipped`, not from the
|
|
1365
|
+
// list length, so a partial list never understates the gap.
|
|
1366
|
+
const paths = facts.secretScan?.skippedPaths ?? [];
|
|
1367
|
+
const shown = paths.slice(0, 6);
|
|
1368
|
+
const rest = Math.max(skipped - shown.length, 0);
|
|
1369
|
+
const named = shown.length > 0
|
|
1370
|
+
? ` — skipped: ${shown.join(', ')}${rest > 0 ? `, …and ${rest} more` : ''}`
|
|
1371
|
+
: '';
|
|
1372
|
+
// Codex r2: NO trailing period — the legacy sentence must stay byte-identical, names follow after a space.
|
|
1373
|
+
notes.push(`no-secrets: ${skipped} packed inventory item(s) not scanned (oversize/unreadable/binary) — the secret scan is fail-open, so this is a coverage gap on the record, not a violation${named}`);
|
|
1374
|
+
}
|
|
1375
|
+
// Lead fix (guard-change-fact regression): a fixture with zero publishable packages yields an EMPTY
|
|
1376
|
+
// inventory string — 'present but empty' is not a source to report; only a non-empty summary is a line.
|
|
1377
|
+
const inventoryLine = facts.secretScan?.inventory;
|
|
1378
|
+
if (typeof inventoryLine === 'string' && inventoryLine.trim().length > 0) {
|
|
1379
|
+
notes.push(`no-secrets: inventory: ${inventoryLine}`);
|
|
1295
1380
|
}
|
|
1296
1381
|
}
|
|
1297
1382
|
if (checked.includes('review-round') && facts.reviewRound?.gathered === false) {
|
|
1383
|
+
// A wholly absent fact is already NOT-ESTABLISHED via HAS_INPUT; this note covers an explicit
|
|
1384
|
+
// failed gathering attempt on a present fact only.
|
|
1298
1385
|
// A HARD gate that passes SILENTLY when it could not gather its evidence is a gate you cannot
|
|
1299
1386
|
// tell from one that checked and approved (raised by cross-family review). It still does not
|
|
1300
1387
|
// BLOCK — absence of facts is ignorance, not an accusation, and blocking every non-git checkout
|
|
@@ -1338,6 +1425,18 @@ export interface GuardAuditRecord {
|
|
|
1338
1425
|
readonly observations?: readonly GuardObservation[];
|
|
1339
1426
|
/** set when the operator overrode a block with `--force <reason>` — the override is logged, never silent. */
|
|
1340
1427
|
readonly override?: { readonly forced: true; readonly reason: string };
|
|
1428
|
+
/**
|
|
1429
|
+
* Ids of every rule this run EVALUATED — `checked` plus `notEstablished`, because both mean the
|
|
1430
|
+
* rule was active for the op and got its turn.
|
|
1431
|
+
*
|
|
1432
|
+
* Why the log needs it (backlog 1bee49dd): a guard rule's zero-firing state is its HEALTHY state,
|
|
1433
|
+
* so `violations[]` cannot tell a working safety net from a dead rule. MEASURED 2026-09-21 on
|
|
1434
|
+
* `.dz/guard-audit.jsonl`: 1854 rows, 26 default rules, 6 of which never appear in any violations
|
|
1435
|
+
* array — and 4 of those 6 are not allowlisted, so the moment the report's history floor is met
|
|
1436
|
+
* they would be named dead for doing their job. The evaluation set was already computed in
|
|
1437
|
+
* `GuardResult`; only the record dropped it.
|
|
1438
|
+
*/
|
|
1439
|
+
readonly evaluated?: readonly string[];
|
|
1341
1440
|
}
|
|
1342
1441
|
|
|
1343
1442
|
/** Build the audit record for a guard evaluation (+ an optional forced-override reason). Pure. */
|
|
@@ -1350,9 +1449,21 @@ export function auditRecord(result: GuardResult, ts: string, override?: { reason
|
|
|
1350
1449
|
...(Array.isArray(result.notes) && result.notes.length > 0 ? { notes: result.notes } : {}),
|
|
1351
1450
|
...(Array.isArray(result.observations) && result.observations.length > 0 ? { observations: result.observations } : {}),
|
|
1352
1451
|
...(override && typeof override.reason === 'string' ? { override: { forced: true, reason: override.reason } } : {}),
|
|
1452
|
+
...(evaluatedRuleIds(result).length > 0 ? { evaluated: evaluatedRuleIds(result) } : {}),
|
|
1353
1453
|
};
|
|
1354
1454
|
}
|
|
1355
1455
|
|
|
1456
|
+
/**
|
|
1457
|
+
* Every rule that got its turn this run, sorted and de-duplicated. A rule with no input still RAN —
|
|
1458
|
+
* calling that "not evaluated" would reintroduce the very conflation this field exists to remove.
|
|
1459
|
+
*/
|
|
1460
|
+
export function evaluatedRuleIds(result: GuardResult): string[] {
|
|
1461
|
+
const ids = new Set<string>();
|
|
1462
|
+
for (const id of Array.isArray(result.checked) ? result.checked : []) if (typeof id === 'string' && id !== '') ids.add(id);
|
|
1463
|
+
for (const id of Array.isArray(result.notEstablished) ? result.notEstablished : []) if (typeof id === 'string' && id !== '') ids.add(id);
|
|
1464
|
+
return [...ids].sort();
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1356
1467
|
/** The exit-code contract: a block is non-zero unless forced; a warn/pass is zero. */
|
|
1357
1468
|
export function guardExitCode(result: GuardResult, forced: boolean): number {
|
|
1358
1469
|
return result.verdict === 'block' && !forced ? 1 : 0;
|
package/src/index.ts
CHANGED
|
@@ -78,6 +78,8 @@ export {
|
|
|
78
78
|
} from './parity.js';
|
|
79
79
|
export type { RuntimeCapability, FeatureForm, ParityFeature, ParityCell, ParityReportCell, ParityMatrixRow, CapabilityEvidence, UnbackedCapability, ProbedRuntimeVersions } from './parity.js';
|
|
80
80
|
export * from './operations.js';
|
|
81
|
+
export { checkInstrumentFreshness, checkRankingState } from './doctor-instrument.js';
|
|
82
|
+
export type { InstrumentFreshness, InstrumentCheckInput, InstrumentCheckResult, RankingStateCheckInput, RankingStateCheckResult } from './doctor-instrument.js';
|
|
81
83
|
// workflows.ts: the ADR-005 templates are RETIRED (feature loop-designer, AM-6) — the module is a
|
|
82
84
|
// deprecation shim (empty WORKFLOW_NAMES). BREAKING for external harness-core consumers of
|
|
83
85
|
// WorkflowTemplate/WORKFLOWS/getWorkflow — deliberately channeled through the 0.x MINOR bump and
|
|
@@ -116,6 +118,7 @@ export * from './trace-bundle.js';
|
|
|
116
118
|
export { BLOBS as LOOP_BLOBS, LOOP_BLOB_NAMES, BLOB_COVERAGE_MANIFEST } from './loop-blobs.generated.js';
|
|
117
119
|
export type { LoopBlob } from './loop-blobs.generated.js';
|
|
118
120
|
export * from './sign.js';
|
|
121
|
+
export * from './pack-inventory.js';
|
|
119
122
|
export * from './publish-source-scope.js';
|
|
120
123
|
export * from './swarm-brief.js';
|
|
121
124
|
export * from './skill-schema.js';
|
|
@@ -123,7 +126,7 @@ export { createSkill } from './create-skill.js';
|
|
|
123
126
|
export type { CreateSkillOptions, CreateSkillResult } from './create-skill.js';
|
|
124
127
|
export { checkUpstream, checkAllUpstream, discoverSourcePackages, loadSourcesManifest } from './sync-upstream.js';
|
|
125
128
|
export type { SyncUpstreamReport, UpstreamCheckResult, SourcesManifest, SourcePackageInfo } from './sync-upstream.js';
|
|
126
|
-
export { sweepSkillDrift, syncCanonicalSkill } from './skill-drift.js';
|
|
129
|
+
export { sweepSkillDrift, syncCanonicalSkill, findCanonicalDefects } from './skill-drift.js';
|
|
127
130
|
export { SKILL_INSTALL_ROOTS, SKILL_INSTALL_ROOT_BY_TARGET, DEV_SKILL_ROOT, TARGET_ENRICHMENT_ASSETS } from './skill-install-roots.js';
|
|
128
131
|
export { stampCheckpointLine } from './checkpoint-stamp.js';
|
|
129
132
|
export { TELEMETRY_VOCAB_VERSION, TELEMETRY_FIELDS, PROVISIONAL_TELEMETRY_FIELDS, LOCAL_FIELD_ALIASES, RUN_OUTCOMES, telemetryFieldFor, runOutcomeOf } from './telemetry-vocabulary.js';
|
|
@@ -135,9 +138,9 @@ export { projectSkillsOneRoot, projectSkillsProbeCommand } from './project-skill
|
|
|
135
138
|
export { isRepoBoundary } from './repo-boundary.js';
|
|
136
139
|
export type { RepoBoundaryIo } from './repo-boundary.js';
|
|
137
140
|
export type { LedgerBackfillPlan, LedgerBackfillRow, RunCostFacts } from './ledger-backfill.js';
|
|
138
|
-
export type { SweepResult, DriftedSkill, SyncResult, SyncCanonicalOptions } from './skill-drift.js';
|
|
141
|
+
export type { SweepResult, DriftedSkill, SyncResult, SyncCanonicalOptions, CanonicalDefect, CanonicalDefectKind } from './skill-drift.js';
|
|
139
142
|
export { benchmarkSkill, benchmarkSkills, compareSkills } from './benchmark.js';
|
|
140
|
-
export { buildRegistry, searchRegistry, filterByCategory, skillPackBaseDirs, discoverSkillPackDirs, discoverSkillCarryingDirs, discoverVerifiablePackDirs } from './registry.js';
|
|
143
|
+
export { buildRegistry, buildShowcaseRegistry, searchRegistry, filterByCategory, skillPackBaseDirs, discoverSkillPackDirs, discoverSkillCarryingDirs, discoverVerifiablePackDirs, packScope, verifiedScopeNote } from './registry.js';
|
|
141
144
|
export { tokenize, stemToken, stems } from './stem.js';
|
|
142
145
|
// Package skill-layout resolution (feature dz-install-npx-init) — the ONE seam that knows where an
|
|
143
146
|
// npm package keeps its skills (flat / templates/.claude/skills / skills). `cmdInstall` calls it;
|
|
@@ -147,9 +150,9 @@ export { recommend } from './recommend.js';
|
|
|
147
150
|
export { pretrain } from './pretrain.js';
|
|
148
151
|
export { loadPatterns, loadSessions, computePatternBoost, readLearningConfig, readMemoryLearningConfig, BOOST_CAP, recordPattern, recordLessonForms, loadStorePatternsSync, loadStoreRecords, findExactLesson, patternToRecord, recordToPattern, patternRecordId, patternIdentityOf, dreamRecordId, isMirrorableLearning, consolidateSessions, recallPatterns, pruneNoisePatterns, removePatternsByIds, snapshotStore, readReinforcementState, encodeReinforcementState, reinforcePattern, resolveReinforceTarget, updateReinforcementState, storeStats, lessonDeltaReport, lessonDeltaMap, readQuarantineState, encodeQuarantineState, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns } from './patterns.js';
|
|
149
152
|
export { normalizeLessonForms, validateClassTemplate, lessonPairIdOf, mergeLessonFormHits, mergeLessonMatchedForms } from './lesson-generalization.js';
|
|
150
|
-
export { withStoreLock, withStoreLockSync, storeLockPath, StoreLockTimeoutError, StoreLockCompromisedError, STALE_LOCK_MS, LOCK_TIMEOUT_MS } from './store-lock.js';
|
|
153
|
+
export { withStoreLock, withStoreLockSync, storeLockPath, storeExists, StoreAbsentError, StoreLockTimeoutError, StoreLockCompromisedError, STALE_LOCK_MS, LOCK_TIMEOUT_MS } from './store-lock.js';
|
|
151
154
|
export type { StoreLockOptions } from './store-lock.js';
|
|
152
|
-
export {
|
|
155
|
+
export { withProjectLockSync, withDirLockSync, namedLockPath, isSafeLockName, NamedLockNameError, NamedLockTimeoutError, NamedLockCompromisedError } from './named-lock.js';
|
|
153
156
|
export {
|
|
154
157
|
QE_BRIDGE_SCHEMA,
|
|
155
158
|
QE_BRIDGE_FAILURE_SCHEMA,
|
|
@@ -601,8 +604,11 @@ export {
|
|
|
601
604
|
extractReportGrade,
|
|
602
605
|
settleReqeDebt,
|
|
603
606
|
renderReqeList,
|
|
607
|
+
countReqeByCause,
|
|
604
608
|
} from './reqe.js';
|
|
605
|
-
export type { ModelFamily, ReqeEmitDecision, ReqeDebt, ReqeBrief, ReqeSettlement } from './reqe.js';
|
|
609
|
+
export type { ModelFamily, ReqeCause, ReqeRungState, ReqeBridgeFacts, ReqeEmitInput, ReqeEmitDecision, ReqeDebt, ReqeBrief, ReqeSettlement } from './reqe.js';
|
|
610
|
+
export { classifyReqeFindings, parsePriorFindings, buildReqeVerdict } from './reqe-verdict.js';
|
|
611
|
+
export type { ReqeVerdict, ReqeFindingsClass, ReqeSeverity, ReqeSeverityCounts, ReqeBlockingRow, ReqePriorVerdict } from './reqe-verdict.js';
|
|
606
612
|
export {
|
|
607
613
|
detectQueryLang,
|
|
608
614
|
relevanceFloorFor,
|
|
@@ -782,6 +788,7 @@ export {
|
|
|
782
788
|
planReleaseGates,
|
|
783
789
|
classifyGateExecutions,
|
|
784
790
|
buildFailureIssue,
|
|
791
|
+
shouldRetryGhWithoutToken,
|
|
785
792
|
buildReleaseNotes,
|
|
786
793
|
releaseTagName,
|
|
787
794
|
firstOutputLine,
|
|
@@ -881,7 +888,7 @@ export type {
|
|
|
881
888
|
McpSeverity,
|
|
882
889
|
McpCapability,
|
|
883
890
|
} from './mcp-scan.js';
|
|
884
|
-
export type { RegistryEntry, Registry } from './registry.js';
|
|
891
|
+
export type { RegistryEntry, Registry, ShowcaseRegistry, ShowcaseSkill } from './registry.js';
|
|
885
892
|
export type { BenchmarkCheck, BenchmarkScore, BenchmarkReport, CompareResult } from './benchmark.js';
|
|
886
893
|
export {
|
|
887
894
|
specToOpts,
|
|
@@ -1153,6 +1160,14 @@ export * from './skills-verify.js';
|
|
|
1153
1160
|
// from darwin-mode; measurements are dz-native and NEVER fake a verdict (INSUFFICIENT_DATA is a
|
|
1154
1161
|
// finding, not a pass).
|
|
1155
1162
|
export * from './compounding.js';
|
|
1163
|
+
// Дополнительная метрика доведённой работы: публичные функции и типы.
|
|
1164
|
+
export { joinLessonOutcomes, renderLessonOutcomes } from './compounding.js';
|
|
1165
|
+
export type {
|
|
1166
|
+
LessonOutcomeRow,
|
|
1167
|
+
LessonOutcomeCounters,
|
|
1168
|
+
LessonOutcomeCoverage,
|
|
1169
|
+
JoinedLessonOutcomes,
|
|
1170
|
+
} from './compounding.js';
|
|
1156
1171
|
|
|
1157
1172
|
// Advisory command-invocation telemetry + deadwood report (feature dz-deadwood).
|
|
1158
1173
|
export * from './cmd-usage.js';
|
|
@@ -1346,3 +1361,9 @@ export * from './cross-family-control.js';
|
|
|
1346
1361
|
|
|
1347
1362
|
export { debtRatchetVerdict, parsePinnedCeiling, ceilingUnreadableMessage } from './debt-ratchet.js';
|
|
1348
1363
|
export type { DebtRatchetArgs, DebtRatchetVerdict, PinnedCeiling } from './debt-ratchet.js';
|
|
1364
|
+
|
|
1365
|
+
// Квитанция зелёного прогона: проверка на PUSH вместо проверки на коммите (развилка 4, 21.09).
|
|
1366
|
+
export { decideTestReceipt, renderTestReceiptVerdict } from './test-receipt.js';
|
|
1367
|
+
export type { TestReceipt, TestReceiptVerdict, TestReceiptScopeState } from './test-receipt.js';
|
|
1368
|
+
export { decideBackupFreshness, renderBackupFreshness } from './backup-freshness.js';
|
|
1369
|
+
export type { BackupFreshnessVerdict } from './backup-freshness.js';
|
package/src/integration-apply.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
|
20
20
|
|
|
21
21
|
import { canonicalIntegrationJson, parseStrictJson, type CarrierFragment, type IntegrationReasonCode } from '@dzhechkov/core';
|
|
22
22
|
|
|
23
|
-
import {
|
|
23
|
+
import { withProjectLockSync } from './named-lock.js';
|
|
24
24
|
|
|
25
25
|
const MAX_CARRIER_BYTES = 1024 * 1024;
|
|
26
26
|
const JOURNAL_REL = '.dz/integrations-ownership.json';
|
|
@@ -226,7 +226,7 @@ export function applyIntegrationFragments(options: ApplyIntegrationOptions): Int
|
|
|
226
226
|
}
|
|
227
227
|
if (groups.size === 0) return { written: [], alreadyCurrent: [], journalPath: JOURNAL_REL };
|
|
228
228
|
|
|
229
|
-
return
|
|
229
|
+
return withProjectLockSync(root, 'integrations', () => {
|
|
230
230
|
const rootBefore = statSync(root);
|
|
231
231
|
const dzBefore = statSync(join(root, '.dz'));
|
|
232
232
|
const journalRead = readBoundedJson(journalPath, JOURNAL_REL);
|
package/src/lesson-payoff.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
37
37
|
import { join } from 'node:path';
|
|
38
38
|
|
|
39
39
|
import { LessonBandit, type ArmStats, type SerializedBanditState } from './lesson-bandit.js';
|
|
40
|
-
import {
|
|
40
|
+
import { withProjectLockSync } from './named-lock.js';
|
|
41
41
|
import { readMemoryLearningConfig, type MemoryLearningConfig } from './patterns.js';
|
|
42
42
|
|
|
43
43
|
// ── Paths and constants ─────────────────────────────────────────────────────────────────────────
|
|
@@ -360,7 +360,7 @@ export interface BanditWriteOutcome {
|
|
|
360
360
|
*
|
|
361
361
|
* **Why the lock and not just the atomic rename.** MEASURED in this repo (2026-08-19, four
|
|
362
362
|
* barrier-synchronised writers on one JSON file, all exit 0): ONE of four updates survived without a
|
|
363
|
-
* lock; all four survived with `
|
|
363
|
+
* lock; all four survived with `withProjectLockSync`. Temp+rename prevents a TORN read; it does
|
|
364
364
|
* nothing about a LOST update, and the loss is silent — both writers report success.
|
|
365
365
|
*
|
|
366
366
|
* No network call, no subprocess, no model turn inside the section — an advisory lock held past its
|
|
@@ -372,7 +372,7 @@ function mutateBanditState(
|
|
|
372
372
|
mutate: (bandit: LessonBandit, env: { exposures: Record<string, Record<string, number>>; explorePulls: Record<string, Record<string, number>> }) => void,
|
|
373
373
|
): BanditWriteOutcome {
|
|
374
374
|
try {
|
|
375
|
-
return
|
|
375
|
+
return withProjectLockSync(projectRoot, BANDIT_LOCK_NAME, (): BanditWriteOutcome => {
|
|
376
376
|
const { state, reason } = loadBanditState(projectRoot);
|
|
377
377
|
if (reason === 'future-schema') {
|
|
378
378
|
// Refuse to clobber a state written by a newer dz. The reward is dropped, loudly.
|
package/src/mutation-gate.ts
CHANGED
|
@@ -494,6 +494,12 @@ export interface MutationObservation {
|
|
|
494
494
|
readonly outputError?: string;
|
|
495
495
|
/** bounded log proving an internal runner failure received at most one retry. */
|
|
496
496
|
readonly internalAttemptLog?: string;
|
|
497
|
+
/**
|
|
498
|
+
* Whether any suite the entry's `testCommand` selects NAMES the mutated module — see
|
|
499
|
+
* {@link suiteSelectionNamesModule}. Only read on the UNDEFENDED path, and only to add a hint:
|
|
500
|
+
* `false` says "check the command before the tests", never "the property is fine".
|
|
501
|
+
*/
|
|
502
|
+
readonly suiteNamesModule?: boolean | 'unknown';
|
|
497
503
|
}
|
|
498
504
|
|
|
499
505
|
export interface MutationEntryResult {
|
|
@@ -1333,7 +1339,13 @@ function classifyMutationOutcomeWithoutAttemptLog(obs: MutationObservation): Mut
|
|
|
1333
1339
|
applied: true,
|
|
1334
1340
|
verdict: 'UNDEFENDED',
|
|
1335
1341
|
drop: false,
|
|
1336
|
-
detail: `suite stayed GREEN with the protection deleted — property UNDEFENDED: "${e.property}" (${e.file}). The suite would not notice this protection regressing (rule 2)
|
|
1342
|
+
detail: `suite stayed GREEN with the protection deleted — property UNDEFENDED: "${e.property}" (${e.file}). The suite would not notice this protection regressing (rule 2).`
|
|
1343
|
+
+ (obs.suiteNamesModule === false
|
|
1344
|
+
? ' HINT: no suite in this entry\'s testCommand NAMES this module, so check the COMMAND before the tests'
|
|
1345
|
+
+ ' — a suite that never loads the module cannot notice its protection (MEASURED 2026-09-04: two properties'
|
|
1346
|
+
+ ' read as UNDEFENDED for exactly this reason, and both tests existed). The hint matches the module stem,'
|
|
1347
|
+
+ ' so a transitive import would not be seen by it: it narrows where to look, it does not decide.'
|
|
1348
|
+
: ''),
|
|
1337
1349
|
};
|
|
1338
1350
|
}
|
|
1339
1351
|
|
|
@@ -1408,6 +1420,47 @@ function classifyMutationOutcomeWithoutAttemptLog(obs: MutationObservation): Mut
|
|
|
1408
1420
|
};
|
|
1409
1421
|
}
|
|
1410
1422
|
|
|
1423
|
+
/**
|
|
1424
|
+
* Suite paths a registry `testCommand` selects, in order. Tokens that are not suite files (the
|
|
1425
|
+
* runner, its flags) are ignored — the command is a shell line, not a schema, so this reads the
|
|
1426
|
+
* shape it actually has rather than assuming one.
|
|
1427
|
+
*/
|
|
1428
|
+
export function parseSuitePaths(testCommand: string): readonly string[] {
|
|
1429
|
+
return String(testCommand ?? '')
|
|
1430
|
+
.split(/\s+/)
|
|
1431
|
+
.filter((token) => /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(token) && !token.startsWith('-'));
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Does ANY suite the command selects even name the mutated module?
|
|
1436
|
+
*
|
|
1437
|
+
* Why this exists: `UNDEFENDED` reads as "this property has no test", and MEASURED 2026-09-04 that
|
|
1438
|
+
* reading was wrong twice in one run — both tests existed; the registry's `testCommand` simply did
|
|
1439
|
+
* not select the suites that import them (backlog 1f4e4f66). The author filed a finding about two
|
|
1440
|
+
* "unprotected properties" before checking the instrument, which is the failure this hint prevents.
|
|
1441
|
+
*
|
|
1442
|
+
* Deliberately a HINT, never a verdict: it matches the module's STEM in each suite's text, so a
|
|
1443
|
+
* suite that reaches the module through a transitive import is invisible to it. `'unknown'` when no
|
|
1444
|
+
* suite could be read — absence of evidence is not evidence, and a hint that guesses is worse than
|
|
1445
|
+
* no hint.
|
|
1446
|
+
*/
|
|
1447
|
+
export function suiteSelectionNamesModule(input: {
|
|
1448
|
+
readonly file: string;
|
|
1449
|
+
readonly suitePaths: readonly string[];
|
|
1450
|
+
readonly readSuite: (path: string) => string | null;
|
|
1451
|
+
}): boolean | 'unknown' {
|
|
1452
|
+
const stem = String(input.file ?? '').split('/').pop()?.replace(/\.[cm]?[jt]sx?$/, '') ?? '';
|
|
1453
|
+
if (stem === '') return 'unknown';
|
|
1454
|
+
let read = 0;
|
|
1455
|
+
for (const suite of input.suitePaths) {
|
|
1456
|
+
const text = input.readSuite(suite);
|
|
1457
|
+
if (text === null) continue;
|
|
1458
|
+
read += 1;
|
|
1459
|
+
if (text.includes(stem)) return true;
|
|
1460
|
+
}
|
|
1461
|
+
return read === 0 ? 'unknown' : false;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1411
1464
|
export function classifyMutationOutcome(obs: MutationObservation): MutationEntryResult {
|
|
1412
1465
|
const result = classifyMutationOutcomeWithoutAttemptLog(obs);
|
|
1413
1466
|
if (obs.internalAttemptLog === undefined) return result;
|
package/src/named-lock.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Named advisory locks — `.dz/locks/<name>.lock
|
|
2
|
+
* Named advisory locks — project stores use `.dz/locks/<name>.lock`; generic directories
|
|
3
|
+
* use `.dz-locks/<name>.lock`, honouring an existing legacy `.dz/locks` directory.
|
|
3
4
|
*
|
|
4
5
|
* WHY. The pattern store already has a cross-process lock (`store-lock.ts`), but it guards ONE
|
|
5
6
|
* resource. Other read-modify-write surfaces in this repo have the same lost-update shape and no
|
|
@@ -27,13 +28,13 @@
|
|
|
27
28
|
* backstop for that case, and the rule doc says so in as many words.
|
|
28
29
|
*/
|
|
29
30
|
|
|
30
|
-
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
31
32
|
import { randomBytes } from 'node:crypto';
|
|
32
33
|
import { createRequire } from 'node:module';
|
|
33
|
-
import { join, resolve } from 'node:path';
|
|
34
|
+
import { dirname, join, resolve } from 'node:path';
|
|
34
35
|
import { lockSync } from 'proper-lockfile';
|
|
35
36
|
|
|
36
|
-
import { LOCK_TIMEOUT_MS, resolveStaleMs } from './store-lock.js';
|
|
37
|
+
import { LOCK_TIMEOUT_MS, resolveStaleMs, storeExists, StoreAbsentError } from './store-lock.js';
|
|
37
38
|
import type { StoreLockOptions } from './store-lock.js';
|
|
38
39
|
|
|
39
40
|
/** `proper-lockfile` silently clamps `stale` up to this minimum. */
|
|
@@ -172,13 +173,11 @@ function ownerMarkerPath(lockPath: string): string {
|
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
function tryAcquire(
|
|
175
|
-
|
|
176
|
-
|
|
176
|
+
lockPath: string,
|
|
177
|
+
resourceKey: string,
|
|
177
178
|
staleMs: number,
|
|
178
179
|
onCompromised: (e: Error) => void,
|
|
179
180
|
): Acquired | 'held' {
|
|
180
|
-
const lockPath = namedLockPath(projectRoot, name);
|
|
181
|
-
const resourceKey = resolve(projectRoot, '.dz', 'locks', name);
|
|
182
181
|
try {
|
|
183
182
|
const release = lockSync(resourceKey, {
|
|
184
183
|
lockfilePath: lockPath,
|
|
@@ -231,21 +230,47 @@ function tryAcquire(
|
|
|
231
230
|
* heartbeat cannot fire while a synchronous `fn` blocks the event loop, so keep bodies well under
|
|
232
231
|
* `staleMs` — the same caveat `withStoreLockSync` carries.
|
|
233
232
|
*/
|
|
234
|
-
export function
|
|
233
|
+
export function withProjectLockSync<T>(
|
|
235
234
|
projectRoot: string,
|
|
236
235
|
name: string,
|
|
237
236
|
fn: () => T,
|
|
238
237
|
opts: StoreLockOptions = {},
|
|
239
238
|
): T {
|
|
240
239
|
const lockPath = namedLockPath(projectRoot, name); // validates the name BEFORE any mkdir
|
|
240
|
+
if (!storeExists(projectRoot)) throw new StoreAbsentError(projectRoot, name);
|
|
241
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
242
|
+
return withLockSync(lockPath, name, fn, opts);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A directory mutex never seeds a store; an existing legacy lock directory is shared. */
|
|
246
|
+
export function withDirLockSync<T>(
|
|
247
|
+
dir: string,
|
|
248
|
+
name: string,
|
|
249
|
+
fn: () => T,
|
|
250
|
+
opts: StoreLockOptions = {},
|
|
251
|
+
): T {
|
|
252
|
+
const legacyPath = namedLockPath(dir, name); // validation precedes the filesystem question
|
|
253
|
+
const legacyDir = dirname(legacyPath);
|
|
254
|
+
let lockPath: string;
|
|
255
|
+
if (existsSync(legacyDir)) {
|
|
256
|
+
lockPath = legacyPath; // never create the legacy directory
|
|
257
|
+
} else {
|
|
258
|
+
const lockDir = join(dir, '.dz-locks');
|
|
259
|
+
mkdirSync(lockDir, { recursive: true });
|
|
260
|
+
lockPath = join(lockDir, `${name}.lock`);
|
|
261
|
+
}
|
|
262
|
+
return withLockSync(lockPath, name, fn, opts);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function withLockSync<T>(lockPath: string, name: string, fn: () => T, opts: StoreLockOptions): T {
|
|
266
|
+
const resourceKey = resolve(dirname(lockPath), name);
|
|
241
267
|
const { staleMs, timeoutMs, pollMs } = resolveOpts(opts);
|
|
242
|
-
mkdirSync(join(projectRoot, '.dz', 'locks'), { recursive: true });
|
|
243
268
|
const started = Date.now();
|
|
244
269
|
const deadline = started + timeoutMs;
|
|
245
270
|
let compromised: Error | undefined;
|
|
246
271
|
const onCompromised = (e: Error): void => { compromised = e; };
|
|
247
272
|
for (;;) {
|
|
248
|
-
const got = tryAcquire(
|
|
273
|
+
const got = tryAcquire(lockPath, resourceKey, staleMs, onCompromised);
|
|
249
274
|
if (got !== 'held') {
|
|
250
275
|
let result: T;
|
|
251
276
|
let outcome: 'released' | 'stolen' | 'stolen-undisarmed' = 'released';
|