@dzhechkov/harness-core 0.8.40 → 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 +194 -114
- package/README.md +43 -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 +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 +22 -2
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +102 -14
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +15 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -4
- 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 +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.map +1 -1
- package/dist/registry.js +24 -20
- package/dist/registry.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 +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 +313 -113
- 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 +104 -17
- package/src/index.ts +23 -5
- 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 +101 -22
- package/src/registry.ts +23 -17
- package/src/reqe-verdict.ts +174 -0
- package/src/reqe.ts +111 -35
- package/src/round.ts +18 -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/skill-drift.ts
CHANGED
|
@@ -42,6 +42,18 @@ export interface DriftedSkill {
|
|
|
42
42
|
readonly locations: readonly string[];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export type CanonicalDefectKind = 'misplaced-enrichment-asset';
|
|
46
|
+
|
|
47
|
+
/** A finding in the canonical source, reported without propagating or removing it. */
|
|
48
|
+
export interface CanonicalDefect {
|
|
49
|
+
readonly skill: string;
|
|
50
|
+
readonly kind: CanonicalDefectKind;
|
|
51
|
+
/** Skill-dir-relative POSIX path. */
|
|
52
|
+
readonly path: string;
|
|
53
|
+
/** Absolute canonical skill directory. */
|
|
54
|
+
readonly canonical: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
/** Options for {@link sweepSkillDrift}. */
|
|
46
58
|
export interface SweepOptions {
|
|
47
59
|
/**
|
|
@@ -69,6 +81,8 @@ export interface SweepResult {
|
|
|
69
81
|
readonly duplicated: number;
|
|
70
82
|
/** Skills that byte-differ AND are not allowlisted, sorted by `driftFiles` desc — the gate keys on this. */
|
|
71
83
|
readonly drifted: readonly DriftedSkill[];
|
|
84
|
+
/** Canonical-side findings, independent of copy drift and its allowlist. */
|
|
85
|
+
readonly canonicalDefects: readonly CanonicalDefect[];
|
|
72
86
|
/** Skills that byte-differ but are allowlisted (intentional) — surfaced for transparency, not gated. */
|
|
73
87
|
readonly allowlisted: readonly DriftedSkill[];
|
|
74
88
|
}
|
|
@@ -112,12 +126,22 @@ export interface SyncResult {
|
|
|
112
126
|
readonly synced: number;
|
|
113
127
|
/** # copies/files that differ (from canonical in resolved modes; between peers in canonical-free mode). */
|
|
114
128
|
readonly drifted: number;
|
|
129
|
+
/** Nonempty in write mode means refusal: `synced: 0`, `wrote: []`. */
|
|
130
|
+
readonly canonicalDefects: readonly CanonicalDefect[];
|
|
115
131
|
/** Abs paths of copies written (empty when `check:true` / peer / refuse — proves no writes). */
|
|
116
132
|
readonly wrote: readonly string[];
|
|
117
133
|
}
|
|
118
134
|
|
|
119
135
|
const SKILL_MANIFEST = 'SKILL.md';
|
|
120
|
-
|
|
136
|
+
// Runtime state, never skill CONTENT. `.agentic-qe/` earned its place by measurement on
|
|
137
|
+
// 2026-09-21: merely INVOKING the decision-mockups skill made its AQE runtime create a SQLite
|
|
138
|
+
// store inside the live `.claude/skills/decision-mockups/`, which the packaged copies do not
|
|
139
|
+
// have — so `no-skill-drift` reported drift and BLOCKED `dz guard --op publish`. The directory
|
|
140
|
+
// is gitignored repo-wide (`**/.agentic-qe/`) and untracked; the drift sweep simply did not read
|
|
141
|
+
// .gitignore. Note the denylist stays narrow on purpose: a blanket "skip dot-entries" would
|
|
142
|
+
// silence real drift in the nine packages whose skill content legitimately lives under
|
|
143
|
+
// `templates/.claude/`.
|
|
144
|
+
const IGNORED_ENTRIES = new Set(['node_modules', '__pycache__', '.DS_Store', 'run-history.json', '.agentic-qe']);
|
|
121
145
|
|
|
122
146
|
/** md5 of a file's bytes (identical to both prototype scripts ⇒ identical drift verdicts). */
|
|
123
147
|
function md5(path: string): string {
|
|
@@ -127,7 +151,13 @@ function md5(path: string): string {
|
|
|
127
151
|
/** Recursive file list under `dir`; skips `node_modules` / `__pycache__` / `.DS_Store`. */
|
|
128
152
|
function walk(dir: string): string[] {
|
|
129
153
|
const out: string[] = [];
|
|
130
|
-
|
|
154
|
+
let entries: string[];
|
|
155
|
+
try {
|
|
156
|
+
entries = readdirSync(dir);
|
|
157
|
+
} catch {
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
for (const entry of entries) {
|
|
131
161
|
if (IGNORED_ENTRIES.has(entry)) continue;
|
|
132
162
|
const p = join(dir, entry);
|
|
133
163
|
let st;
|
|
@@ -181,11 +211,25 @@ function withoutEnrichmentNames(rels: readonly string[]): string[] {
|
|
|
181
211
|
return rels.filter((r) => TARGET_ENRICHMENT_ASSETS[r.split(sep).join('/')] === undefined);
|
|
182
212
|
}
|
|
183
213
|
|
|
184
|
-
|
|
214
|
+
/** Find misplaced target metadata in a canonical source, sorted by relative POSIX path. */
|
|
215
|
+
export function findCanonicalDefects(canonicalDir: string, skill: string): CanonicalDefect[] {
|
|
216
|
+
const canonical = resolve(canonicalDir);
|
|
217
|
+
return walk(canonical)
|
|
218
|
+
.map((p) => relative(canonical, p).split(sep).join('/'))
|
|
219
|
+
.filter((rel) => TARGET_ENRICHMENT_ASSETS[rel] !== undefined && !isEnrichmentAsset(rel, canonical))
|
|
220
|
+
.sort()
|
|
221
|
+
.map((path) => ({ skill, kind: 'misplaced-enrichment-asset', path, canonical }));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function comparePeers(
|
|
225
|
+
copies: readonly string[],
|
|
226
|
+
exclusions: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
|
|
227
|
+
): { driftFiles: number; missingFiles: number; totalFiles: number } {
|
|
228
|
+
const excluded = (copy: string, rel: string): boolean => exclusions.get(copy)?.has(rel.split(sep).join('/')) === true;
|
|
185
229
|
const relFiles = new Set<string>();
|
|
186
230
|
for (const c of copies) for (const f of walk(c)) {
|
|
187
231
|
const rel = relative(c, f);
|
|
188
|
-
if (isEnrichmentAsset(rel, c)) continue;
|
|
232
|
+
if (isEnrichmentAsset(rel, c) || excluded(c, rel)) continue;
|
|
189
233
|
relFiles.add(rel);
|
|
190
234
|
}
|
|
191
235
|
|
|
@@ -195,7 +239,9 @@ function comparePeers(copies: readonly string[]): { driftFiles: number; missingF
|
|
|
195
239
|
const hashes = new Set<string>();
|
|
196
240
|
for (const c of copies) {
|
|
197
241
|
const p = join(c, rel);
|
|
198
|
-
|
|
242
|
+
// An excluded canonical file is absent from its content; another package's same path
|
|
243
|
+
// still contributes to the union and must compare as an extra file.
|
|
244
|
+
if (!excluded(c, rel) && existsSync(p)) hashes.add(md5(p));
|
|
199
245
|
else {
|
|
200
246
|
missingFiles++;
|
|
201
247
|
hashes.add('__MISSING__');
|
|
@@ -291,7 +337,7 @@ function findSkillDirs(root: string, scope: 'packages' | 'installs' | 'all' = 'a
|
|
|
291
337
|
* Detect intra-monorepo skill drift: find every skill duplicated across ≥2 locations and report
|
|
292
338
|
* which copies byte-differ. Pure port of `scripts/drift-sweep-skills.mjs`.
|
|
293
339
|
*
|
|
294
|
-
*
|
|
340
|
+
* Gates must inspect both copy drift and canonical defects.
|
|
295
341
|
*/
|
|
296
342
|
export function sweepSkillDrift(root: string, opts: SweepOptions = {}): SweepResult {
|
|
297
343
|
const scope = opts.scope ?? 'all';
|
|
@@ -308,16 +354,22 @@ export function sweepSkillDrift(root: string, opts: SweepOptions = {}): SweepRes
|
|
|
308
354
|
|
|
309
355
|
let duplicated = 0;
|
|
310
356
|
const drifted: DriftedSkill[] = [];
|
|
357
|
+
const canonicalDefects: CanonicalDefect[] = [];
|
|
311
358
|
const allowlisted: DriftedSkill[] = [];
|
|
312
359
|
|
|
313
360
|
for (const [name, unsorted] of [...byName.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
361
|
+
const meta = join(root, 'packages/@dzhechkov/skills-meta', name);
|
|
362
|
+
const defects = unsorted.includes(meta) ? findCanonicalDefects(meta, name) : [];
|
|
363
|
+
canonicalDefects.push(...defects);
|
|
314
364
|
if (unsorted.length < 2) continue;
|
|
315
365
|
duplicated++;
|
|
316
366
|
const copies = [...unsorted].sort();
|
|
317
367
|
|
|
318
368
|
// Per-skill byte-comparison of every copy against each other (extracted to `comparePeers`
|
|
319
369
|
// so the canonical-free `sync-canonical --check` reuses the EXACT same logic).
|
|
320
|
-
const { driftFiles, missingFiles, totalFiles } = comparePeers(
|
|
370
|
+
const { driftFiles, missingFiles, totalFiles } = comparePeers(
|
|
371
|
+
copies, new Map([[meta, new Set(defects.map((d) => d.path))]]),
|
|
372
|
+
);
|
|
321
373
|
|
|
322
374
|
if (driftFiles > 0) {
|
|
323
375
|
const entry: DriftedSkill = {
|
|
@@ -335,7 +387,7 @@ export function sweepSkillDrift(root: string, opts: SweepOptions = {}): SweepRes
|
|
|
335
387
|
const byDrift = (a: DriftedSkill, b: DriftedSkill): number => b.driftFiles - a.driftFiles;
|
|
336
388
|
drifted.sort(byDrift);
|
|
337
389
|
allowlisted.sort(byDrift);
|
|
338
|
-
return { duplicated, drifted, allowlisted };
|
|
390
|
+
return { duplicated, drifted, canonicalDefects, allowlisted };
|
|
339
391
|
}
|
|
340
392
|
|
|
341
393
|
/**
|
|
@@ -344,6 +396,7 @@ export function sweepSkillDrift(root: string, opts: SweepOptions = {}): SweepRes
|
|
|
344
396
|
* byte-identity. Pure port of `scripts/sync-canonical-skill.mjs`, extended with a canonical-free path.
|
|
345
397
|
*
|
|
346
398
|
* `check:true` writes NOTHING (`wrote` stays empty) and only reports the drift count.
|
|
399
|
+
* A canonical defect also prevents all writes, while reporting the same drift as check mode.
|
|
347
400
|
* Default overwrites drifting copies; a subsequent {@link sweepSkillDrift} then reports 0 drift.
|
|
348
401
|
*
|
|
349
402
|
* When NO canonical resolves (`resolvedFrom === 'none'` — no `--from`, no `skills-meta`, no `--auto`):
|
|
@@ -372,12 +425,13 @@ export function syncCanonicalSkill(root: string, skill: string, opts: SyncCanoni
|
|
|
372
425
|
if (check) {
|
|
373
426
|
// CANONICAL-FREE peer check: are the copies byte-identical to EACH OTHER? (<2 ⇒ vacuously so.)
|
|
374
427
|
const drifted = allCopies.length < 2 ? 0 : comparePeers(allCopies).driftFiles;
|
|
375
|
-
return { canonical: '', canonicalExists: false, resolvedFrom, copies: allCopies.length, synced: 0, drifted, wrote: [] };
|
|
428
|
+
return { canonical: '', canonicalExists: false, resolvedFrom, copies: allCopies.length, synced: 0, drifted, canonicalDefects: [], wrote: [] };
|
|
376
429
|
}
|
|
377
430
|
// Bare WRITE with no resolvable canonical → REFUSE. Mutates nothing (`wrote:[]` proves it).
|
|
378
|
-
return { canonical: '', canonicalExists: false, resolvedFrom, copies: allCopies.length, synced: 0, drifted: 0, wrote: [] };
|
|
431
|
+
return { canonical: '', canonicalExists: false, resolvedFrom, copies: allCopies.length, synced: 0, drifted: 0, canonicalDefects: [], wrote: [] };
|
|
379
432
|
}
|
|
380
433
|
|
|
434
|
+
const canonicalDefects = findCanonicalDefects(canonical, skill);
|
|
381
435
|
const canonFiles = withoutEnrichmentNames(walk(canonical).map((p) => relative(canonical, p))).sort();
|
|
382
436
|
|
|
383
437
|
// Every <skill>/ dir except the canonical itself.
|
|
@@ -403,7 +457,7 @@ export function syncCanonicalSkill(root: string, skill: string, opts: SyncCanoni
|
|
|
403
457
|
if (!differs) continue;
|
|
404
458
|
drifted++;
|
|
405
459
|
|
|
406
|
-
if (check) continue; //
|
|
460
|
+
if (check || canonicalDefects.length > 0) continue; // check or refusal — write NOTHING
|
|
407
461
|
|
|
408
462
|
// Overwrite: remove extra files, then copy every canonical file byte-for-byte.
|
|
409
463
|
for (const f of copyFiles) if (!canonFiles.includes(f)) rmSync(join(copy, f));
|
|
@@ -417,5 +471,5 @@ export function syncCanonicalSkill(root: string, skill: string, opts: SyncCanoni
|
|
|
417
471
|
wrote.push(copy);
|
|
418
472
|
}
|
|
419
473
|
|
|
420
|
-
return { canonical, canonicalExists: true, resolvedFrom, copies: copies.length, synced, drifted, wrote };
|
|
474
|
+
return { canonical, canonicalExists: true, resolvedFrom, copies: copies.length, synced, drifted, canonicalDefects, wrote };
|
|
421
475
|
}
|
package/src/statusline.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { basename, join, resolve } from 'node:path';
|
|
|
19
19
|
import { createRequire } from 'node:module';
|
|
20
20
|
|
|
21
21
|
import { listBrain } from './brain.js';
|
|
22
|
-
import {
|
|
22
|
+
import { withProjectLockSync } from './named-lock.js';
|
|
23
23
|
import { RECALL_USAGE_LOG_RELATIVE, aggregateRecallUsage, parseRecallUsageLog } from './recall-usage.js';
|
|
24
24
|
import { countLearningStoreRowsReadonly, countSqliteRowsReadonly } from './store-counts.js';
|
|
25
25
|
import {
|
|
@@ -585,7 +585,7 @@ export function writeFeatureAdrStateDetailed(
|
|
|
585
585
|
// half of the lock test can reproduce the lost update. Shipped callers never pass it.
|
|
586
586
|
const written = input._unsafeSkipLock === true
|
|
587
587
|
? transition()
|
|
588
|
-
:
|
|
588
|
+
: withProjectLockSync(root, 'fa-phase-slot', transition);
|
|
589
589
|
return { state: written };
|
|
590
590
|
} catch (err) {
|
|
591
591
|
// A refusal is REPORTED, never mistaken for success. A lock timeout before the callback proves
|
package/src/store-guard.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSy
|
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { withDirLockSync } from './named-lock.js';
|
|
17
17
|
|
|
18
18
|
export const STORE_GUARD_VERSION = 1 as const;
|
|
19
19
|
|
|
@@ -256,7 +256,7 @@ export function writeStoreMark(
|
|
|
256
256
|
validateObservation(observation);
|
|
257
257
|
mkdirSync(guardRoot, { recursive: true, mode: 0o700 });
|
|
258
258
|
|
|
259
|
-
return
|
|
259
|
+
return withDirLockSync(guardRoot, `store-guard-${basename(path, '.json')}`, () => {
|
|
260
260
|
const previous = readStoreMark(project);
|
|
261
261
|
if (options.expectedPreviousLexicalSource !== undefined
|
|
262
262
|
&& previous?.lexicalSource !== options.expectedPreviousLexicalSource) {
|
|
@@ -295,7 +295,7 @@ export function resetStoreMark(projectRoot: string, observation: StoreMarkObserv
|
|
|
295
295
|
throw new Error('store guard reset requires command dz store-guard --reset');
|
|
296
296
|
}
|
|
297
297
|
mkdirSync(guardRoot, { recursive: true, mode: 0o700 });
|
|
298
|
-
return
|
|
298
|
+
return withDirLockSync(guardRoot, `store-guard-${basename(path, '.json')}`, () => {
|
|
299
299
|
const previous = readStoreMark(project);
|
|
300
300
|
const before: StoreCountSnapshot = {
|
|
301
301
|
lexicalRows: previous?.lexicalMax ?? observation.lexicalRows,
|
package/src/store-lock.ts
CHANGED
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
* one is honoured as held, same as any other lock.
|
|
58
58
|
*/
|
|
59
59
|
|
|
60
|
-
import {
|
|
60
|
+
import { existsSync, rmSync, statSync } from 'node:fs';
|
|
61
61
|
import { join, resolve } from 'node:path';
|
|
62
62
|
import { lockSync } from 'proper-lockfile';
|
|
63
63
|
|
|
@@ -71,6 +71,23 @@ const MIN_STALE_MS = 2_000;
|
|
|
71
71
|
/** Highest accepted environment override; keeps proper-lockfile's heartbeat timer in range. */
|
|
72
72
|
const MAX_STALE_MS = 600_000;
|
|
73
73
|
|
|
74
|
+
/** A store exists exactly when the project's .dz entry exists. */
|
|
75
|
+
export function storeExists(projectRoot: string): boolean {
|
|
76
|
+
return existsSync(join(projectRoot, '.dz'));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Refusing a store-scoped lock must never seed a store. */
|
|
80
|
+
export class StoreAbsentError extends Error {
|
|
81
|
+
readonly code = 'ESTOREABSENT';
|
|
82
|
+
constructor(readonly root: string, readonly lock: string) {
|
|
83
|
+
super(
|
|
84
|
+
`no store at ${root} — the lock ${lock} was not taken and nothing was created; ` +
|
|
85
|
+
'run `dz init` there, or pass --project <the real project root>',
|
|
86
|
+
);
|
|
87
|
+
this.name = 'StoreAbsentError';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
74
91
|
/** Path of the lock guarding a project's pattern store (a DIRECTORY when held). */
|
|
75
92
|
export function storeLockPath(projectRoot: string): string {
|
|
76
93
|
return join(projectRoot, '.dz', 'store.lock');
|
|
@@ -201,8 +218,8 @@ export async function withStoreLock<T>(
|
|
|
201
218
|
fn: () => Promise<T>,
|
|
202
219
|
opts: StoreLockOptions = {},
|
|
203
220
|
): Promise<T> {
|
|
221
|
+
if (!storeExists(projectRoot)) throw new StoreAbsentError(projectRoot, 'store');
|
|
204
222
|
const { staleMs, timeoutMs, pollMs } = resolveOpts(opts);
|
|
205
|
-
mkdirSync(join(projectRoot, '.dz'), { recursive: true });
|
|
206
223
|
const started = Date.now();
|
|
207
224
|
const deadline = started + timeoutMs;
|
|
208
225
|
let compromised: Error | undefined;
|
|
@@ -240,8 +257,8 @@ export function withStoreLockSync<T>(
|
|
|
240
257
|
fn: () => T,
|
|
241
258
|
opts: StoreLockOptions = {},
|
|
242
259
|
): T {
|
|
260
|
+
if (!storeExists(projectRoot)) throw new StoreAbsentError(projectRoot, 'store');
|
|
243
261
|
const { staleMs, timeoutMs, pollMs } = resolveOpts(opts);
|
|
244
|
-
mkdirSync(join(projectRoot, '.dz'), { recursive: true });
|
|
245
262
|
const started = Date.now();
|
|
246
263
|
const deadline = started + timeoutMs;
|
|
247
264
|
let compromised: Error | undefined;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Квитанция зелёного прогона: «эта область исходников была протестирована ИМЕННО в этом виде».
|
|
3
|
+
*
|
|
4
|
+
* ЗАЧЕМ ЭТО ЕСТЬ. Два процессных грабля повторялись месяцами и повторились снова 21.09 ПОСЛЕ того,
|
|
5
|
+
* как напоминание о них было поднято: «объявил сделанным без проверки» (пять случаев) и «коммит без
|
|
6
|
+
* прогона тестов» (шесть). Напоминание — слой суждения, и он молчит, когда отказывает. Владелец
|
|
7
|
+
* согласился поднять проверку на слой 1, но с условием: коммиты должны остаться дешёвыми. Поэтому
|
|
8
|
+
* проверка стоит не на коммите, а на ПУШЕ, и спрашивает не «прогони тесты», а квитанцию.
|
|
9
|
+
*
|
|
10
|
+
* ПОЧЕМУ ПО ОБЛАСТЯМ, А НЕ ЦЕЛИКОМ. ИЗМЕРЕНО 21.09: полный прогон набора harness-cli на этой машине
|
|
11
|
+
* не завершается — надзиратель фоновых задач убивает его по памяти при любом дроблении. Гейт,
|
|
12
|
+
* требующий квитанцию ПОЛНОГО прогона, заблокировал бы каждый push навсегда. Квитанция покрывает
|
|
13
|
+
* названные области, и отказ касается только тех, что изменились без покрытия.
|
|
14
|
+
*
|
|
15
|
+
* ЧЕГО ЭТА ПРОВЕРКА НЕ ОБЕЩАЕТ, и это сказано здесь, а не в отчёте потом:
|
|
16
|
+
* · она не запускает тесты и не знает, ХОРОШИ ли они — только что прогон был и был зелёным;
|
|
17
|
+
* · она сравнивает СОДЕРЖИМОЕ рабочего дерева, а не набор пушимых коммитов;
|
|
18
|
+
* · она ничего не говорит о коммитах, которые никогда не пушат;
|
|
19
|
+
* · зелёный прогон ДРУГОЙ области не покрывает изменённую — это и есть смысл разбиения.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Одна запись журнала квитанций. Пишется ТОЛЬКО наблюдателем, видевшим код возврата 0. */
|
|
23
|
+
export interface TestReceipt {
|
|
24
|
+
readonly v: 1;
|
|
25
|
+
/** Когда прогон закончился, ISO-8601. */
|
|
26
|
+
readonly ts: string;
|
|
27
|
+
/** Что именно было запущено — для сообщения об отказе, чтобы читатель мог повторить. */
|
|
28
|
+
readonly command: string;
|
|
29
|
+
/** Область → отпечаток её содержимого на момент зелёного прогона. */
|
|
30
|
+
readonly scopes: Readonly<Record<string, string>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Состояние одной области в вердикте. */
|
|
34
|
+
export interface TestReceiptScopeState {
|
|
35
|
+
readonly scope: string;
|
|
36
|
+
/** Отпечаток содержимого СЕЙЧАС. */
|
|
37
|
+
readonly digest: string;
|
|
38
|
+
/**
|
|
39
|
+
* `never` — область не встречается ни в одной квитанции: прогона не было вовсе.
|
|
40
|
+
* `changed` — квитанции есть, но ни одна не покрывает текущее содержимое.
|
|
41
|
+
*/
|
|
42
|
+
readonly why: 'never' | 'changed';
|
|
43
|
+
/** Время самой свежей квитанции, покрывавшей эту область в ЛЮБОМ виде; null при `never`. */
|
|
44
|
+
readonly lastGreenAt: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type TestReceiptVerdict =
|
|
48
|
+
| { readonly state: 'covered'; readonly scopes: readonly string[] }
|
|
49
|
+
| { readonly state: 'stale'; readonly uncovered: readonly TestReceiptScopeState[] }
|
|
50
|
+
| { readonly state: 'not-established'; readonly reason: string };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Чистое решение. Ничего не читает и не запускает — отпечатки и журнал подаёт вызывающий.
|
|
54
|
+
*
|
|
55
|
+
* Три исхода, а не два, намеренно: «не установлено» — это НЕ «покрыто». Гейт, выводящий пропуск из
|
|
56
|
+
* пустого входа, тихо ломается на каждом новом способе получить пустой вход.
|
|
57
|
+
*/
|
|
58
|
+
export function decideTestReceipt(input: {
|
|
59
|
+
readonly current: Readonly<Record<string, string>>;
|
|
60
|
+
readonly receipts: readonly TestReceipt[];
|
|
61
|
+
}): TestReceiptVerdict {
|
|
62
|
+
const current = input.current ?? {};
|
|
63
|
+
const scopes = Object.keys(current).sort();
|
|
64
|
+
if (scopes.length === 0) {
|
|
65
|
+
return { state: 'not-established', reason: 'ни одна наблюдаемая область не разрешилась — проверка была бы вакуумной' };
|
|
66
|
+
}
|
|
67
|
+
for (const scope of scopes) {
|
|
68
|
+
if (typeof current[scope] !== 'string' || current[scope] === '') {
|
|
69
|
+
return { state: 'not-established', reason: `отпечаток области ${scope} не вычислен — вердикт не о чем выносить` };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const receipts = (Array.isArray(input.receipts) ? input.receipts : []).filter(
|
|
74
|
+
(r): r is TestReceipt => !!r && typeof r === 'object' && r.scopes !== null && typeof r.scopes === 'object',
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const uncovered: TestReceiptScopeState[] = [];
|
|
78
|
+
for (const scope of scopes) {
|
|
79
|
+
const digest = current[scope]!;
|
|
80
|
+
let seenAt: string | null = null;
|
|
81
|
+
let covered = false;
|
|
82
|
+
for (const receipt of receipts) {
|
|
83
|
+
const recorded = receipt.scopes[scope];
|
|
84
|
+
if (typeof recorded !== 'string') continue;
|
|
85
|
+
if (typeof receipt.ts === 'string' && (seenAt === null || receipt.ts > seenAt)) seenAt = receipt.ts;
|
|
86
|
+
if (recorded === digest) { covered = true; break; }
|
|
87
|
+
}
|
|
88
|
+
if (!covered) uncovered.push({ scope, digest, why: seenAt === null ? 'never' : 'changed', lastGreenAt: seenAt });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return uncovered.length === 0 ? { state: 'covered', scopes } : { state: 'stale', uncovered };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Одна строка для человека. Отказ обязан говорить, ЧТО запустить, а не только что всё плохо. */
|
|
95
|
+
export function renderTestReceiptVerdict(verdict: TestReceiptVerdict): string {
|
|
96
|
+
if (verdict.state === 'covered') {
|
|
97
|
+
return `test-receipt: покрыто — ${verdict.scopes.length} област(и) в том же виде, в каком были зелёными`;
|
|
98
|
+
}
|
|
99
|
+
if (verdict.state === 'not-established') {
|
|
100
|
+
return `test-receipt: НЕ УСТАНОВЛЕНО — ${verdict.reason}. Это не пропуск и не отказ по существу`;
|
|
101
|
+
}
|
|
102
|
+
const lines = verdict.uncovered.map((item) => item.why === 'never'
|
|
103
|
+
? ` · ${item.scope}: зелёного прогона не было НИ РАЗУ`
|
|
104
|
+
: ` · ${item.scope}: изменилась после последнего зелёного прогона (${item.lastGreenAt})`);
|
|
105
|
+
return [`test-receipt: НЕ ПОКРЫТО — ${verdict.uncovered.length} област(и):`, ...lines].join('\n');
|
|
106
|
+
}
|
package/src/workflow-run.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
import { classifyFailure, errSnap, gateVerdict, joinRegion, stepContractLines, type JoinOutcome } from './loop-run-semantics.js';
|
|
43
43
|
import { checkpointInputHash, decideCheckpointResume, parseCheckpointRead, serializeCheckpoint } from './feature-adr-checkpoints.js';
|
|
44
44
|
import { modelFamily, type BridgeFamily } from './qe-bridge.js';
|
|
45
|
+
import { buildReqeDebt } from './reqe.js';
|
|
45
46
|
import { CODEX_EXEC_XHIGH_TIMEOUT_MS, defangGateEchoes, type DispatchResult, type Dispatcher } from './workflow-run-dispatch.js';
|
|
46
47
|
|
|
47
48
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1203,19 +1204,17 @@ export async function runWorkflow(inputs: RunnerInputs, pre: PreflightOk, deps:
|
|
|
1203
1204
|
|
|
1204
1205
|
// a waived same-family QE step owes a machine debt, not a doc instruction (the FR-2.9 precedent)
|
|
1205
1206
|
for (const w of pre.qeWaivers) {
|
|
1206
|
-
//
|
|
1207
|
-
|
|
1208
|
-
// debt. `qeFamily` equals `coderFamily` here: that identity IS the waiver.
|
|
1209
|
-
store.writeReqeDebt({
|
|
1210
|
-
schema: 'reqe-due-1',
|
|
1207
|
+
// Use the shared builder so the waiver stays readable by every debt consumer.
|
|
1208
|
+
store.writeReqeDebt(buildReqeDebt({
|
|
1211
1209
|
slug: deps.slug ?? inputs.runId,
|
|
1212
|
-
|
|
1213
|
-
|
|
1210
|
+
coderUsed: inputs.coderFamily,
|
|
1211
|
+
qeReviewerUsed: inputs.coderFamily,
|
|
1214
1212
|
qeGrade: null,
|
|
1213
|
+
cause: 'same-family-fallback',
|
|
1215
1214
|
reason: `dz workflow run ${inputs.runId}: step ${w.step} (x-role: qe) resolved to the CODER family ${inputs.coderFamily} and ran under --allow-same-family-qe — the cross-family guard was consciously suspended, so an independent review is OWED`,
|
|
1216
1215
|
emittedAt: startedAt,
|
|
1217
1216
|
runStamp: `${inputs.runId}:${pre.execFp.slice(0, 16)}`,
|
|
1218
|
-
});
|
|
1217
|
+
}));
|
|
1219
1218
|
}
|
|
1220
1219
|
|
|
1221
1220
|
// the PROBED ids (established above, before the resume decision) become this run's resolution
|