@dzhechkov/harness-core 0.8.40 → 0.8.42
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 +220 -120
- package/README.md +65 -10
- 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 +8 -6
- 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 +43 -1
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +96 -0
- 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/guard.d.ts +26 -2
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +162 -17
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +17 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -5
- 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/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.map +1 -1
- package/dist/operations.js +24 -0
- 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 +43 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +153 -59
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +24 -20
- package/dist/registry.js.map +1 -1
- package/dist/release-line.d.ts +15 -1
- package/dist/release-line.d.ts.map +1 -1
- package/dist/release-line.js +40 -13
- package/dist/release-line.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.d.ts +10 -0
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +7 -0
- package/dist/round.js.map +1 -1
- package/dist/session-retro.d.ts +9 -6
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +39 -14
- 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/tree-sync.d.ts +5 -0
- package/dist/tree-sync.d.ts.map +1 -0
- package/dist/tree-sync.js +13 -0
- package/dist/tree-sync.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 +8 -8
- package/sbom.json +369 -119
- 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 +7 -6
- package/src/codex-hooks-assets.ts +15 -2
- package/src/compounding.ts +132 -1
- package/src/discrimination-gate.ts +2 -5
- package/src/guard.ts +166 -20
- package/src/index.ts +25 -6
- package/src/integration-apply.ts +2 -2
- package/src/lesson-payoff.ts +3 -3
- package/src/named-lock.ts +36 -11
- package/src/operations.ts +24 -0
- 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 +168 -65
- package/src/registry.ts +23 -17
- package/src/release-line.ts +60 -16
- package/src/reqe-verdict.ts +174 -0
- package/src/reqe.ts +111 -35
- package/src/round.ts +18 -0
- package/src/session-retro.ts +54 -18
- 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/tree-sync.ts +17 -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. */
|
|
@@ -108,6 +119,7 @@ export interface GuardFacts {
|
|
|
108
119
|
readonly readmes: readonly { readonly path: string; readonly text: string | null }[];
|
|
109
120
|
readonly coreVersion: string | null;
|
|
110
121
|
readonly cliVersion: string | null;
|
|
122
|
+
readonly workspaceVersions?: Readonly<Record<string, string | null>>;
|
|
111
123
|
};
|
|
112
124
|
/** Publish-only raw volume facts. Absence preserves the legacy result shape. */
|
|
113
125
|
readonly volume?: VolumeShadowInput;
|
|
@@ -150,10 +162,15 @@ export interface GuardFacts {
|
|
|
150
162
|
readonly featureBacklog?: {
|
|
151
163
|
readonly baseline: string;
|
|
152
164
|
readonly features: readonly { readonly slug: string; readonly createdIso: string; readonly waiver?: string }[];
|
|
165
|
+
/** Тексты записей бэклога и непустые причины переходов. */
|
|
153
166
|
readonly backlogTexts: readonly string[];
|
|
167
|
+
/** Летопись ЕСТЬ, но не прочиталась: «<путь>: <причина>». */
|
|
168
|
+
readonly transitionLogError?: string;
|
|
154
169
|
};
|
|
155
170
|
/** for no-skill-drift: the names that byte-drift between copies (from sweepSkillDrift). */
|
|
156
171
|
readonly drift?: readonly string[];
|
|
172
|
+
/** for no-skill-drift: canonical-side defects as skill:path (from sweepSkillDrift). */
|
|
173
|
+
readonly canonicalDefects?: readonly string[];
|
|
157
174
|
/**
|
|
158
175
|
* for codex-wrapper-for-value-stage: workflow scripts to scan, as (path, text). Absent ⇒ the
|
|
159
176
|
* rule reports nothing: a guard with no evidence must stay silent rather than invent a verdict.
|
|
@@ -161,10 +178,15 @@ export interface GuardFacts {
|
|
|
161
178
|
readonly workflowScripts?: readonly { readonly path: string; readonly text: string }[];
|
|
162
179
|
/** for no-secrets: labelled blobs to scan (lesson text, staged files). */
|
|
163
180
|
readonly secretTargets?: readonly { readonly label: string; readonly text: string }[];
|
|
181
|
+
/** for no-secrets: findings already scanned from the streaming publish inventory. */
|
|
182
|
+
readonly secretFindings?: readonly { readonly label: string; readonly name: string }[];
|
|
164
183
|
/** for no-secrets: exact path waivers; entries without a non-empty reason are ignored and noted. */
|
|
165
184
|
readonly secretWaivers?: readonly SecretWaiver[];
|
|
166
185
|
/** Publish secret-scan coverage gaps; skips are informational and never verdict inputs. */
|
|
167
|
-
|
|
186
|
+
/** backlog a530eaca: `skippedPaths` names what the fail-open scan never read (repo-relative, with the
|
|
187
|
+
* reason) so the coverage gap is auditable — a bare count told the owner nothing to check. Optional:
|
|
188
|
+
* an older CLI that sends only the count still renders the count-only note. */
|
|
189
|
+
readonly secretScan?: { readonly skipped: number; readonly skippedPaths?: readonly string[]; readonly scanned?: number; readonly inventory?: string };
|
|
168
190
|
/** for readme-consistency: labelled (a,b) count pairs that must be equal. */
|
|
169
191
|
readonly counts?: readonly { readonly label: string; readonly a: number; readonly b: number }[];
|
|
170
192
|
/** for store-bloat-cap: current learned-store size vs its cap. */
|
|
@@ -199,7 +221,7 @@ export interface GuardFacts {
|
|
|
199
221
|
/**
|
|
200
222
|
* for review-round: per publishable package, does this change bump a version AND touch SOURCE, and
|
|
201
223
|
* did it bring a GRADED QE report with it? `undefined` (the whole fact absent) means the tree could
|
|
202
|
-
* not be read — the rule
|
|
224
|
+
* not be read — the rule is NOT-ESTABLISHED, which is different from reporting "no review".
|
|
203
225
|
*/
|
|
204
226
|
readonly reviewRound?: {
|
|
205
227
|
readonly packages: readonly { readonly name: string; readonly versionBumped: boolean; readonly sourceChanged: boolean }[];
|
|
@@ -410,6 +432,50 @@ function inspectReleaseLines(
|
|
|
410
432
|
});
|
|
411
433
|
continue;
|
|
412
434
|
}
|
|
435
|
+
if (evidence.workspaceVersions !== undefined) {
|
|
436
|
+
const stale: string[] = [];
|
|
437
|
+
const actual: string[] = [];
|
|
438
|
+
const versions: string[] = [];
|
|
439
|
+
const unknown = (detail: string): void => {
|
|
440
|
+
observations.push({
|
|
441
|
+
schemaVersion: 'volume-shadow/v1', rule: 'release-line-in-sync' as never,
|
|
442
|
+
metric: 'release-line-version-sync', scope: readme.path, status: 'unknown', value: null,
|
|
443
|
+
unit: 'artifact_set', signal: false, operands: {}, method: 'release-line-regex/v1', detail,
|
|
444
|
+
});
|
|
445
|
+
};
|
|
446
|
+
for (const token of found.tokens) {
|
|
447
|
+
if (!Object.hasOwn(evidence.workspaceVersions, token.name)) {
|
|
448
|
+
unknown(`пакет \`${token.name}\` не собирается в этом монорепо — сверить не с чем`);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const version = evidence.workspaceVersions[token.name];
|
|
452
|
+
if (typeof version !== 'string') {
|
|
453
|
+
unknown(`${token.name} package.json version не прочитана — сверить не с чем`);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
actual.push(`${token.name} v${token.version}`);
|
|
457
|
+
versions.push(`${token.name} v${version}`);
|
|
458
|
+
if (token.version !== version) {
|
|
459
|
+
stale.push(`строка релиза говорит ${token.name} v${token.version}, package.json — v${version}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (found.wrapped) unknown('цепочка перенесена на следующую строку — продолжение вне охвата правила');
|
|
463
|
+
if (actual.length > 0) {
|
|
464
|
+
const mismatch = stale.length > 0;
|
|
465
|
+
const detail = mismatch
|
|
466
|
+
? `${readme.path}: ${stale.join('; ')}`
|
|
467
|
+
: `${readme.path}: строка релиза совпадает с package.json (${versions.join(', ')})`;
|
|
468
|
+
observations.push({
|
|
469
|
+
schemaVersion: 'volume-shadow/v1', rule: 'release-line-in-sync' as never,
|
|
470
|
+
metric: 'release-line-version-sync', scope: readme.path,
|
|
471
|
+
status: mismatch ? 'outside-reference' : 'within-reference', value: actual,
|
|
472
|
+
unit: 'artifact_set', signal: mismatch, operands: { actual, expected: versions },
|
|
473
|
+
method: 'release-line-regex/v1', detail,
|
|
474
|
+
});
|
|
475
|
+
if (mismatch) violations.push({ rule: 'release-line-in-sync', severity, detail });
|
|
476
|
+
}
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
413
479
|
if (expected === null) {
|
|
414
480
|
const missing = [
|
|
415
481
|
...(evidence.coreVersion === null ? ['harness-core package.json version'] : []),
|
|
@@ -477,12 +543,12 @@ export const DEFAULT_RULES: readonly GuardRule[] = [
|
|
|
477
543
|
{ 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
544
|
{ 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
545
|
{ 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' },
|
|
546
|
+
{ id: 'no-skill-drift', severity: 'hard', ops: ['publish', 'consolidate', 'code'], description: 'no unexpected byte-drift between shared skill copies' },
|
|
481
547
|
{ id: 'backlog-covers-features', severity: 'soft', ops: ['publish', 'consolidate'], description: 'каталог фичи, заведённый после базовой даты, назван записью бэклога — либо несёт именованную оговорку с причиной' },
|
|
482
548
|
{ id: 'no-secrets', severity: 'hard', ops: ['teach', 'publish'], description: 'no private key or API token in lesson text or a published file' },
|
|
483
549
|
{ id: 'readme-consistency', severity: 'soft', ops: ['publish'], description: 'README counts agree (CJM header vs All Commands, etc.)' },
|
|
484
|
-
{ id: 'release-line-in-sync', severity: 'soft', ops: ['publish'], description: 'root and harness-cli README release lines agree with the
|
|
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' },
|
|
550
|
+
{ id: 'release-line-in-sync', severity: 'soft', ops: ['publish'], description: 'root and harness-cli README release lines agree with the package.json version of every workspace package the line names' },
|
|
551
|
+
{ 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
552
|
{ 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
553
|
{ 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
554
|
{ id: 'routing-store-stale', severity: 'soft', ops: ['publish'], description: 'harvested routing telemetry has been applied to the auto-cost outcome store' },
|
|
@@ -527,6 +593,22 @@ export function scanSecrets(text: unknown): { readonly name: string }[] {
|
|
|
527
593
|
return [...hits].map((name) => ({ name }));
|
|
528
594
|
}
|
|
529
595
|
|
|
596
|
+
export const SECRET_SCAN_OVERLAP_BYTES = 4096;
|
|
597
|
+
|
|
598
|
+
export function scanSecretsChunked(chunks: Iterable<Buffer>, opts?: { overlapBytes?: number }): { name: string }[] {
|
|
599
|
+
const overlapBytes = opts?.overlapBytes ?? SECRET_SCAN_OVERLAP_BYTES;
|
|
600
|
+
const hits = new Set<string>();
|
|
601
|
+
let tail = Buffer.alloc(0);
|
|
602
|
+
for (const chunk of chunks) {
|
|
603
|
+
const window = Buffer.concat([tail, chunk]);
|
|
604
|
+
const text = window.toString('utf8');
|
|
605
|
+
for (const p of SECRET_PATTERNS) if (p.re.test(text)) hits.add(p.name);
|
|
606
|
+
if (hits.size === SECRET_PATTERNS.length) break;
|
|
607
|
+
tail = window.subarray(Math.max(0, window.length - overlapBytes));
|
|
608
|
+
}
|
|
609
|
+
return [...hits].map((name) => ({ name }));
|
|
610
|
+
}
|
|
611
|
+
|
|
530
612
|
function secretWaiverState(waivers: readonly SecretWaiver[] | undefined): { waived: ReadonlySet<string>; reasonless: number } {
|
|
531
613
|
const waived = new Set<string>();
|
|
532
614
|
let reasonless = 0;
|
|
@@ -613,7 +695,7 @@ function mentionsSlug(haystackLower: string, slug: string): boolean {
|
|
|
613
695
|
}
|
|
614
696
|
|
|
615
697
|
const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[]> = {
|
|
616
|
-
'rounds-closed': (f, sev) =>
|
|
698
|
+
'rounds-closed': (f, sev) => f.openRounds!
|
|
617
699
|
.filter((round) => Number.isFinite(round.ageMinutes) && round.ageMinutes > 120)
|
|
618
700
|
.map((round) => ({
|
|
619
701
|
rule: 'rounds-closed',
|
|
@@ -762,7 +844,11 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
762
844
|
},
|
|
763
845
|
'no-skill-drift': (f, sev) => {
|
|
764
846
|
const drifted = Array.isArray(f.drift) ? f.drift.filter((d) => typeof d === 'string') : [];
|
|
765
|
-
|
|
847
|
+
const defects = Array.isArray(f.canonicalDefects) ? f.canonicalDefects.filter((d) => typeof d === 'string') : [];
|
|
848
|
+
const out: Violation[] = [];
|
|
849
|
+
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` });
|
|
850
|
+
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` });
|
|
851
|
+
return out;
|
|
766
852
|
},
|
|
767
853
|
'backlog-covers-features': (f, sev) => {
|
|
768
854
|
const ev = f.featureBacklog;
|
|
@@ -772,9 +858,10 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
772
858
|
return [{
|
|
773
859
|
rule: 'backlog-covers-features',
|
|
774
860
|
severity: sev,
|
|
775
|
-
detail: `${uncovered.length} фич(и) заведены после ${ev.baseline} и не названы ни
|
|
861
|
+
detail: `${uncovered.length} фич(и) заведены после ${ev.baseline} и не названы ни записью бэклога, ни причиной перехода: `
|
|
776
862
|
+ `${uncovered.slice(0, 8).join(', ')}${uncovered.length > 8 ? '…' : ''}`
|
|
777
|
-
+ ` —
|
|
863
|
+
+ ` — назови слаг в записи (dz backlog add) ЛИБО в причине закрытия`
|
|
864
|
+
+ ` (dz backlog ship <id> --reason "<слаг>: что сделано") ЛИБО впиши оговорку в features/<slug>/README.md строкой`
|
|
778
865
|
+ ` "Backlog: не заведено — <причина>". Оговорка без причины не считается.`,
|
|
779
866
|
}];
|
|
780
867
|
},
|
|
@@ -787,11 +874,15 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
787
874
|
out.push({ rule: 'no-secrets', severity: sev, detail: `${t.label}: looks like a ${hit.name} — do not teach/publish a credential` });
|
|
788
875
|
}
|
|
789
876
|
}
|
|
877
|
+
for (const hit of f.secretFindings ?? []) {
|
|
878
|
+
if (waived.has(hit.label)) continue;
|
|
879
|
+
out.push({ rule: 'no-secrets', severity: sev, detail: `${hit.label}: looks like a ${hit.name} — do not teach/publish a credential` });
|
|
880
|
+
}
|
|
790
881
|
return out;
|
|
791
882
|
},
|
|
792
883
|
'readme-consistency': (f, sev) => {
|
|
793
884
|
const out: Violation[] = [];
|
|
794
|
-
for (const c of f.counts
|
|
885
|
+
for (const c of f.counts!) {
|
|
795
886
|
if (Number.isFinite(c.a) && Number.isFinite(c.b) && c.a !== c.b) {
|
|
796
887
|
out.push({ rule: 'readme-consistency', severity: sev, detail: `${c.label}: ${c.a} ≠ ${c.b} (README counts disagree)` });
|
|
797
888
|
}
|
|
@@ -804,7 +895,7 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
804
895
|
// a heuristic (a pack counts only if it already has one registrable skill, and only
|
|
805
896
|
// markdown-bearing dirs are considered intended), so it informs rather than blocks.
|
|
806
897
|
const out: Violation[] = [];
|
|
807
|
-
for (const p of f.skillPacks
|
|
898
|
+
for (const p of f.skillPacks!) {
|
|
808
899
|
if (!p || !Array.isArray(p.nonRegistrable) || p.nonRegistrable.length === 0) continue;
|
|
809
900
|
out.push({
|
|
810
901
|
rule: 'skills-registrable',
|
|
@@ -819,7 +910,7 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
819
910
|
// whose own README.md is untouched in the same diff. SOFT: some republishes legitimately need no doc
|
|
820
911
|
// change — the point is that skipping the README becomes a VISIBLE decision, not a silent lapse.
|
|
821
912
|
const out: Violation[] = [];
|
|
822
|
-
for (const p of f.readmeFirst
|
|
913
|
+
for (const p of f.readmeFirst!) {
|
|
823
914
|
if (p && p.versionBumped === true && p.readmeChanged !== true) {
|
|
824
915
|
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
916
|
}
|
|
@@ -886,7 +977,7 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
886
977
|
//
|
|
887
978
|
// Scoped to CHANGED SOURCE on purpose (ADR-001): a HARD rule that also fired on a docs-only
|
|
888
979
|
// republish would be a rule someone switches off. `undefined` facts mean the tree could not be
|
|
889
|
-
// read —
|
|
980
|
+
// read — HAS_INPUT records NOT-ESTABLISHED, not an accusation.
|
|
890
981
|
const rr = f.reviewRound;
|
|
891
982
|
if (rr === undefined) return [];
|
|
892
983
|
const min = typeof rr.minGrade === 'string' ? rr.minGrade.trim().toUpperCase() : undefined;
|
|
@@ -942,8 +1033,8 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
942
1033
|
* so it belongs on layer 1 rather than in a rule nobody re-reads.
|
|
943
1034
|
*/
|
|
944
1035
|
'codex-wrapper-for-value-stage': (f, sev) => {
|
|
945
|
-
const scripts = f.workflowScripts
|
|
946
|
-
if (scripts
|
|
1036
|
+
const scripts = f.workflowScripts!; // Presence is established by HAS_INPUT.
|
|
1037
|
+
if (scripts.length === 0) return [];
|
|
947
1038
|
const out: Violation[] = [];
|
|
948
1039
|
for (const s of scripts) {
|
|
949
1040
|
const text = String(s.text ?? '');
|
|
@@ -1055,6 +1146,14 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
1055
1146
|
},
|
|
1056
1147
|
};
|
|
1057
1148
|
|
|
1149
|
+
// Missing volume means NOT-ESTABLISHED. A throwing getter is INTENTIONALLY treated as present
|
|
1150
|
+
// so volume() can emit honest 'unknown' observations instead of failing in the precondition.
|
|
1151
|
+
// This preserves the older contract: test/guard.test.ts:725,
|
|
1152
|
+
// "a hostile volume getter becomes visible unknown evidence instead of a HARD checker error".
|
|
1153
|
+
const volumeInputPresent = (f: GuardFacts): boolean => {
|
|
1154
|
+
try { return f.volume !== undefined; } catch { return true; }
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1058
1157
|
/** Per-rule evidence predicates. No entry preserves the rule's existing checked behaviour exactly. */
|
|
1059
1158
|
const HAS_INPUT: Partial<Record<string, (f: GuardFacts) => boolean>> = {
|
|
1060
1159
|
'rounds-traced': (f) => {
|
|
@@ -1070,9 +1169,24 @@ const HAS_INPUT: Partial<Record<string, (f: GuardFacts) => boolean>> = {
|
|
|
1070
1169
|
'sibling-dep-protocol': (f) => Array.isArray(f.siblingDeps) && f.siblingDeps.length > 0,
|
|
1071
1170
|
// Дерево без плагин-манифестов правилу нечего сказать: «прошло» тут значило бы «не смотрели».
|
|
1072
1171
|
'plugin-manifest-audit': (f) => Array.isArray(f.pluginManifests) && f.pluginManifests.length > 0,
|
|
1073
|
-
'no-secrets': (f) => Array.isArray(f.secretTargets) && f.secretTargets.length > 0
|
|
1172
|
+
'no-secrets': (f) => (Array.isArray(f.secretTargets) && f.secretTargets.length > 0)
|
|
1173
|
+
|| Array.isArray(f.secretFindings) || typeof f.secretScan?.scanned === 'number',
|
|
1074
1174
|
'release-line-in-sync': (f) => typeof f.releaseLines === 'object' && f.releaseLines !== null,
|
|
1175
|
+
// 2026-09-21: на одном дереве с дрейфом publish=block, а code=pass без собранного факта drift.
|
|
1176
|
+
'no-skill-drift': (f) => Array.isArray(f.drift),
|
|
1177
|
+
'review-round': (f) => f.reviewRound !== undefined,
|
|
1178
|
+
'no-workspace-star': (f) => Array.isArray(f.packages),
|
|
1179
|
+
'codex-wrapper-for-value-stage': (f) => Array.isArray(f.workflowScripts),
|
|
1075
1180
|
'signature-fresh': (f) => Array.isArray(f.signedPacks),
|
|
1181
|
+
'rounds-closed': (f) => Array.isArray(f.openRounds),
|
|
1182
|
+
'readme-consistency': (f) => Array.isArray(f.counts),
|
|
1183
|
+
'skills-registrable': (f) => Array.isArray(f.skillPacks),
|
|
1184
|
+
'readme-first': (f) => Array.isArray(f.readmeFirst),
|
|
1185
|
+
'store-bloat-cap': (f) => f.store !== undefined,
|
|
1186
|
+
'template-context-token-weight': volumeInputPresent,
|
|
1187
|
+
'template-context-largest-file-share': volumeInputPresent,
|
|
1188
|
+
'feature-artifact-diff-ratio': volumeInputPresent,
|
|
1189
|
+
'feature-tier-artifact-set': volumeInputPresent,
|
|
1076
1190
|
};
|
|
1077
1191
|
|
|
1078
1192
|
/**
|
|
@@ -1138,7 +1252,7 @@ export function resolveRules(userRules?: readonly Partial<GuardRule>[]): GuardRu
|
|
|
1138
1252
|
// than `lockfile-in-sync`'s tolerant parser, which is already SOFT-only. "I might be wrong"
|
|
1139
1253
|
// plus "block the publish" is the wrong pair (ADR-004).
|
|
1140
1254
|
if (isTemplateRule(u)) {
|
|
1141
|
-
const ops = Array.isArray(u.ops) && u.ops.every((o) =>
|
|
1255
|
+
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
1256
|
byId.set(u.id, {
|
|
1143
1257
|
id: u.id,
|
|
1144
1258
|
severity: 'soft',
|
|
@@ -1203,9 +1317,11 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
|
|
|
1203
1317
|
try {
|
|
1204
1318
|
inputPresent = hasInput === undefined ? true : hasInput(facts);
|
|
1205
1319
|
} catch (error) {
|
|
1320
|
+
// Бросок в предикате улик и бросок в чекере обязаны нести общий маркер fail-closed:
|
|
1321
|
+
// страж не смог решить и потому обвиняет, а не оправдывает; какой перехватчик был первым, потребителю неважно.
|
|
1206
1322
|
violations.push({
|
|
1207
1323
|
rule: r.id, severity: r.severity,
|
|
1208
|
-
detail: `улики правила нечитаемы (${error instanceof Error ? error.message : String(error)}) — правило считается НАРУШЕННЫМ, а не
|
|
1324
|
+
detail: `улики правила нечитаемы (${error instanceof Error ? error.message : String(error)}) — правило считается НАРУШЕННЫМ, а не пройденным (fail-closed)`,
|
|
1209
1325
|
});
|
|
1210
1326
|
checked.push(r.id);
|
|
1211
1327
|
continue;
|
|
@@ -1271,6 +1387,16 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
|
|
|
1271
1387
|
// (missing contents ⇒ nothing reported), but a skip nobody can see is fail-SILENT. One aggregate
|
|
1272
1388
|
// note, computed AFTER the verdict so it can never block or warn: information, not a violation.
|
|
1273
1389
|
const notes: string[] = [];
|
|
1390
|
+
// Only `checked`: when the chronicle read fails, the collector still supplies `featureBacklog`
|
|
1391
|
+
// (the error field can only live INSIDE that object), so HAS_INPUT holds and the rule is never
|
|
1392
|
+
// in `notEstablished` with an error to report. The second half was dead by construction —
|
|
1393
|
+
// MEASURED by mutation, Step 8, 22.09.2026: removing it turned no test red.
|
|
1394
|
+
if (checked.includes('backlog-covers-features')) {
|
|
1395
|
+
const error = facts.featureBacklog?.transitionLogError;
|
|
1396
|
+
if (typeof error === 'string' && error.trim() !== '') {
|
|
1397
|
+
notes.push(`backlog-covers-features: летопись переходов не прочитана (${error}) — покрытие проверено только по текстам записей бэклога`);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1274
1400
|
if (notEstablished.includes('rounds-traced')) {
|
|
1275
1401
|
const fact = facts.codeCommitsSinceLastRound;
|
|
1276
1402
|
if (fact?.enabled === false) notes.push('rounds-traced: skipped (.dz/config.json rounds.traced=false)');
|
|
@@ -1291,10 +1417,30 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
|
|
|
1291
1417
|
}
|
|
1292
1418
|
const skipped = facts.secretScan?.skipped;
|
|
1293
1419
|
if (typeof skipped === 'number' && Number.isFinite(skipped) && skipped > 0) {
|
|
1294
|
-
|
|
1420
|
+
// MEASURED 2026-09-21: the count alone hid that harness-cli's own src/cli.ts and dist/cli.js
|
|
1421
|
+
// (1.3 MiB each, both packed) are among the never-scanned items — name them, capped.
|
|
1422
|
+
// Codex review (B): the legacy sentence stays byte-identical for consumers that match it whole;
|
|
1423
|
+
// the names are APPENDED after it. "…and N more" counts from the FACT's `skipped`, not from the
|
|
1424
|
+
// list length, so a partial list never understates the gap.
|
|
1425
|
+
const paths = facts.secretScan?.skippedPaths ?? [];
|
|
1426
|
+
const shown = paths.slice(0, 6);
|
|
1427
|
+
const rest = Math.max(skipped - shown.length, 0);
|
|
1428
|
+
const named = shown.length > 0
|
|
1429
|
+
? ` — skipped: ${shown.join(', ')}${rest > 0 ? `, …and ${rest} more` : ''}`
|
|
1430
|
+
: '';
|
|
1431
|
+
// Codex r2: NO trailing period — the legacy sentence must stay byte-identical, names follow after a space.
|
|
1432
|
+
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}`);
|
|
1433
|
+
}
|
|
1434
|
+
// Lead fix (guard-change-fact regression): a fixture with zero publishable packages yields an EMPTY
|
|
1435
|
+
// inventory string — 'present but empty' is not a source to report; only a non-empty summary is a line.
|
|
1436
|
+
const inventoryLine = facts.secretScan?.inventory;
|
|
1437
|
+
if (typeof inventoryLine === 'string' && inventoryLine.trim().length > 0) {
|
|
1438
|
+
notes.push(`no-secrets: inventory: ${inventoryLine}`);
|
|
1295
1439
|
}
|
|
1296
1440
|
}
|
|
1297
1441
|
if (checked.includes('review-round') && facts.reviewRound?.gathered === false) {
|
|
1442
|
+
// A wholly absent fact is already NOT-ESTABLISHED via HAS_INPUT; this note covers an explicit
|
|
1443
|
+
// failed gathering attempt on a present fact only.
|
|
1298
1444
|
// A HARD gate that passes SILENTLY when it could not gather its evidence is a gate you cannot
|
|
1299
1445
|
// tell from one that checked and approved (raised by cross-family review). It still does not
|
|
1300
1446
|
// BLOCK — absence of facts is ignorance, not an accusation, and blocking every non-git checkout
|
package/src/index.ts
CHANGED
|
@@ -118,6 +118,7 @@ export * from './trace-bundle.js';
|
|
|
118
118
|
export { BLOBS as LOOP_BLOBS, LOOP_BLOB_NAMES, BLOB_COVERAGE_MANIFEST } from './loop-blobs.generated.js';
|
|
119
119
|
export type { LoopBlob } from './loop-blobs.generated.js';
|
|
120
120
|
export * from './sign.js';
|
|
121
|
+
export * from './pack-inventory.js';
|
|
121
122
|
export * from './publish-source-scope.js';
|
|
122
123
|
export * from './swarm-brief.js';
|
|
123
124
|
export * from './skill-schema.js';
|
|
@@ -125,7 +126,7 @@ export { createSkill } from './create-skill.js';
|
|
|
125
126
|
export type { CreateSkillOptions, CreateSkillResult } from './create-skill.js';
|
|
126
127
|
export { checkUpstream, checkAllUpstream, discoverSourcePackages, loadSourcesManifest } from './sync-upstream.js';
|
|
127
128
|
export type { SyncUpstreamReport, UpstreamCheckResult, SourcesManifest, SourcePackageInfo } from './sync-upstream.js';
|
|
128
|
-
export { sweepSkillDrift, syncCanonicalSkill } from './skill-drift.js';
|
|
129
|
+
export { sweepSkillDrift, syncCanonicalSkill, findCanonicalDefects } from './skill-drift.js';
|
|
129
130
|
export { SKILL_INSTALL_ROOTS, SKILL_INSTALL_ROOT_BY_TARGET, DEV_SKILL_ROOT, TARGET_ENRICHMENT_ASSETS } from './skill-install-roots.js';
|
|
130
131
|
export { stampCheckpointLine } from './checkpoint-stamp.js';
|
|
131
132
|
export { TELEMETRY_VOCAB_VERSION, TELEMETRY_FIELDS, PROVISIONAL_TELEMETRY_FIELDS, LOCAL_FIELD_ALIASES, RUN_OUTCOMES, telemetryFieldFor, runOutcomeOf } from './telemetry-vocabulary.js';
|
|
@@ -137,7 +138,7 @@ export { projectSkillsOneRoot, projectSkillsProbeCommand } from './project-skill
|
|
|
137
138
|
export { isRepoBoundary } from './repo-boundary.js';
|
|
138
139
|
export type { RepoBoundaryIo } from './repo-boundary.js';
|
|
139
140
|
export type { LedgerBackfillPlan, LedgerBackfillRow, RunCostFacts } from './ledger-backfill.js';
|
|
140
|
-
export type { SweepResult, DriftedSkill, SyncResult, SyncCanonicalOptions } from './skill-drift.js';
|
|
141
|
+
export type { SweepResult, DriftedSkill, SyncResult, SyncCanonicalOptions, CanonicalDefect, CanonicalDefectKind } from './skill-drift.js';
|
|
141
142
|
export { benchmarkSkill, benchmarkSkills, compareSkills } from './benchmark.js';
|
|
142
143
|
export { buildRegistry, buildShowcaseRegistry, searchRegistry, filterByCategory, skillPackBaseDirs, discoverSkillPackDirs, discoverSkillCarryingDirs, discoverVerifiablePackDirs, packScope, verifiedScopeNote } from './registry.js';
|
|
143
144
|
export { tokenize, stemToken, stems } from './stem.js';
|
|
@@ -149,9 +150,9 @@ export { recommend } from './recommend.js';
|
|
|
149
150
|
export { pretrain } from './pretrain.js';
|
|
150
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';
|
|
151
152
|
export { normalizeLessonForms, validateClassTemplate, lessonPairIdOf, mergeLessonFormHits, mergeLessonMatchedForms } from './lesson-generalization.js';
|
|
152
|
-
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';
|
|
153
154
|
export type { StoreLockOptions } from './store-lock.js';
|
|
154
|
-
export {
|
|
155
|
+
export { withProjectLockSync, withDirLockSync, namedLockPath, isSafeLockName, NamedLockNameError, NamedLockTimeoutError, NamedLockCompromisedError } from './named-lock.js';
|
|
155
156
|
export {
|
|
156
157
|
QE_BRIDGE_SCHEMA,
|
|
157
158
|
QE_BRIDGE_FAILURE_SCHEMA,
|
|
@@ -603,8 +604,11 @@ export {
|
|
|
603
604
|
extractReportGrade,
|
|
604
605
|
settleReqeDebt,
|
|
605
606
|
renderReqeList,
|
|
607
|
+
countReqeByCause,
|
|
606
608
|
} from './reqe.js';
|
|
607
|
-
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';
|
|
608
612
|
export {
|
|
609
613
|
detectQueryLang,
|
|
610
614
|
relevanceFloorFor,
|
|
@@ -768,7 +772,8 @@ export type {
|
|
|
768
772
|
} from './event-chain.js';
|
|
769
773
|
export { decideProvenance, environmentCanMintProvenance, publishArgv, matchesPublishFilter, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, rewriteWorkspaceSpecs, orderByDependencies, syncReadmeVersion, isChangelogEntryLine, changelogRegion, planReadmeVersionSync } from './publish.js';
|
|
770
774
|
export type { ReadmeVersionSyncPlan, ReadmeSyncRewrite } from './publish.js';
|
|
771
|
-
export { RELEASE_LINE_RE, findReleaseLine, rewriteReleaseLine } from './release-line.js';
|
|
775
|
+
export { RELEASE_LINE_RE, parseReleaseLine, findReleaseLine, rewriteReleaseLine, shortPackageName } from './release-line.js';
|
|
776
|
+
export type { ReleaseLineToken, ReleaseLineParse, ReleaseLineMatch } from './release-line.js';
|
|
772
777
|
export * from './course-staleness.js';
|
|
773
778
|
export { fetchAllDownloads } from './downloads.js';
|
|
774
779
|
export type { PackageDownloads, DownloadsReport } from './downloads.js';
|
|
@@ -1156,6 +1161,14 @@ export * from './skills-verify.js';
|
|
|
1156
1161
|
// from darwin-mode; measurements are dz-native and NEVER fake a verdict (INSUFFICIENT_DATA is a
|
|
1157
1162
|
// finding, not a pass).
|
|
1158
1163
|
export * from './compounding.js';
|
|
1164
|
+
// Дополнительная метрика доведённой работы: публичные функции и типы.
|
|
1165
|
+
export { joinLessonOutcomes, renderLessonOutcomes } from './compounding.js';
|
|
1166
|
+
export type {
|
|
1167
|
+
LessonOutcomeRow,
|
|
1168
|
+
LessonOutcomeCounters,
|
|
1169
|
+
LessonOutcomeCoverage,
|
|
1170
|
+
JoinedLessonOutcomes,
|
|
1171
|
+
} from './compounding.js';
|
|
1159
1172
|
|
|
1160
1173
|
// Advisory command-invocation telemetry + deadwood report (feature dz-deadwood).
|
|
1161
1174
|
export * from './cmd-usage.js';
|
|
@@ -1349,3 +1362,9 @@ export * from './cross-family-control.js';
|
|
|
1349
1362
|
|
|
1350
1363
|
export { debtRatchetVerdict, parsePinnedCeiling, ceilingUnreadableMessage } from './debt-ratchet.js';
|
|
1351
1364
|
export type { DebtRatchetArgs, DebtRatchetVerdict, PinnedCeiling } from './debt-ratchet.js';
|
|
1365
|
+
|
|
1366
|
+
// Квитанция зелёного прогона: проверка на PUSH вместо проверки на коммите (развилка 4, 21.09).
|
|
1367
|
+
export { decideTestReceipt, renderTestReceiptVerdict } from './test-receipt.js';
|
|
1368
|
+
export type { TestReceipt, TestReceiptVerdict, TestReceiptScopeState } from './test-receipt.js';
|
|
1369
|
+
export { decideBackupFreshness, renderBackupFreshness } from './backup-freshness.js';
|
|
1370
|
+
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/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';
|
package/src/operations.ts
CHANGED
|
@@ -52,6 +52,7 @@ import { AGENTS_MD_BLOCK_BEGIN, mergeAgentsMd, mergeGeminiMd, mergePolicyBlock,
|
|
|
52
52
|
import type { CanonicalSkill, EmitResult, SkillAsset } from '@dzhechkov/core';
|
|
53
53
|
import { computeRiskScore } from './risk-scoring.js';
|
|
54
54
|
import { checkInstrumentFreshness, checkRankingState } from './doctor-instrument.js';
|
|
55
|
+
import { decideBackupFreshness, renderBackupFreshness } from './backup-freshness.js';
|
|
55
56
|
|
|
56
57
|
import { applyEmitResult } from './apply.js';
|
|
57
58
|
import { describeSkillLoadFailure, discoverSkillIds, loadSkillFromDir } from './skills.js';
|
|
@@ -1071,6 +1072,29 @@ export async function runDoctor(options: { projectRoot: string; instrumentPath?:
|
|
|
1071
1072
|
const nodeMajor = Number(process.versions.node.split('.')[0] ?? '0');
|
|
1072
1073
|
checks.push({ name: 'node >= 20', ok: nodeMajor >= 20, detail: `node ${process.version}` });
|
|
1073
1074
|
|
|
1075
|
+
// Свежесть копии — отдельная ось: ok:false изменил бы код выхода doctor и заблокировал
|
|
1076
|
+
// автоматику публикации. Поэтому отказ копии — WARN, отсутствие свидетельства — UNKNOWN.
|
|
1077
|
+
const backupLogPath = process.env.DZ_BACKLOG_BACKUP_LOG ?? '/var/log/backlog-backup.log';
|
|
1078
|
+
try {
|
|
1079
|
+
const verdict = decideBackupFreshness({
|
|
1080
|
+
lines: readFileSync(backupLogPath, 'utf8').split(/\r?\n/),
|
|
1081
|
+
now: new Date(),
|
|
1082
|
+
maxAgeHours: 36,
|
|
1083
|
+
});
|
|
1084
|
+
checks.push({
|
|
1085
|
+
name: 'backlog backup',
|
|
1086
|
+
ok: true,
|
|
1087
|
+
...(verdict.state === 'fresh' ? {} : { level: verdict.state === 'stale' ? 'warn' as const : 'unknown' as const }),
|
|
1088
|
+
detail: renderBackupFreshness(verdict),
|
|
1089
|
+
});
|
|
1090
|
+
} catch (error) {
|
|
1091
|
+
const code = (error as NodeJS.ErrnoException).code ?? 'UNKNOWN';
|
|
1092
|
+
const detail = code === 'ENOENT'
|
|
1093
|
+
? `журнал копии не найден: ${backupLogPath}`
|
|
1094
|
+
: `журнал копии нечитаем: ${backupLogPath} (${code})`;
|
|
1095
|
+
checks.push({ name: 'backlog backup', ok: true, level: 'unknown', detail });
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1074
1098
|
// 2/3. MONOREPO-ONLY checks, gated on PROJECT KIND (backlog fcf29728: in a consumer project
|
|
1075
1099
|
// skills-meta and the 10 adapters are not there and MUST not be — both checks were red forever
|
|
1076
1100
|
// and dz doctor could never exit 0 outside this repository, a standing false BLOCK for any
|