@dzhechkov/harness-core 0.3.142 → 0.3.144
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 +49 -25
- package/README.md +2 -1
- package/dist/backlog.d.ts +97 -1
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +214 -31
- package/dist/backlog.js.map +1 -1
- package/dist/compounding.d.ts +22 -0
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +29 -10
- package/dist/compounding.js.map +1 -1
- package/dist/epoch-replay.d.ts +399 -0
- package/dist/epoch-replay.d.ts.map +1 -0
- package/dist/epoch-replay.js +695 -0
- package/dist/epoch-replay.js.map +1 -0
- package/dist/guard.d.ts +44 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +144 -1
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/sbom.json +84 -24
- package/src/backlog.ts +282 -27
- package/src/compounding.ts +45 -8
- package/src/epoch-replay.ts +955 -0
- package/src/guard.ts +151 -1
- package/src/index.ts +52 -0
package/src/guard.ts
CHANGED
|
@@ -56,6 +56,110 @@ export interface GuardFacts {
|
|
|
56
56
|
readonly skillPacks?: readonly { readonly name: string; readonly nonRegistrable: readonly string[] }[];
|
|
57
57
|
/** for readme-first: per publishable package, is a version bump staged without a README change? */
|
|
58
58
|
readonly readmeFirst?: readonly { readonly name: string; readonly versionBumped: boolean; readonly readmeChanged: boolean }[];
|
|
59
|
+
/**
|
|
60
|
+
* for lockfile-in-sync: what each workspace package DECLARES vs what pnpm-lock.yaml RECORDS for that
|
|
61
|
+
* importer. `parsed:false` (or the fact absent) ⇒ the rule reports nothing — fail-open by construction,
|
|
62
|
+
* because a lockfile we could not read is not evidence of a defect.
|
|
63
|
+
*/
|
|
64
|
+
readonly lockfile?: {
|
|
65
|
+
readonly parsed: boolean;
|
|
66
|
+
readonly importers?: readonly {
|
|
67
|
+
/** importer path as pnpm keys it, e.g. `packages/@dzhechkov/harness-cli`. */
|
|
68
|
+
readonly importer: string;
|
|
69
|
+
/** the `@dzhechkov/*` specs the package.json declares (deps + devDeps). */
|
|
70
|
+
readonly declared: Readonly<Record<string, string>>;
|
|
71
|
+
/** the specs pnpm-lock.yaml records for this importer; `undefined` ⇒ the importer is absent. */
|
|
72
|
+
readonly locked?: Readonly<Record<string, string>> | undefined;
|
|
73
|
+
}[];
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The lowest `lockfileVersion` whose importers carry the `specifier:`/`version:` pair this parser reads. */
|
|
78
|
+
export const MIN_RECOGNISED_LOCKFILE_VERSION = 9;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* RECOGNISE-OR-REFUSE `pnpm-lock.yaml` importers parser — PURE, no YAML dependency. Reads exactly one
|
|
82
|
+
* shape: `importers:` → `<path>:` → `<section>:` → `'<dep>':` → `specifier: <value>` (lockfileVersion 9+).
|
|
83
|
+
*
|
|
84
|
+
* "Tolerant" must mean *refuses to guess*, NOT *guesses quietly*. A half-parse is the dangerous outcome:
|
|
85
|
+
* a lockfileVersion-6 file lists deps as `dep: version` one-liners under a separate `specifiers:` map, so
|
|
86
|
+
* a lenient reader finds the importer KEYS, records ZERO specifiers, and the rule then reports every real
|
|
87
|
+
* dependency as *"not recorded in pnpm-lock.yaml"* — a false-positive storm dressed up as fail-open. So we
|
|
88
|
+
* return `undefined` (⇒ the rule reports NOTHING) unless every one of these holds:
|
|
89
|
+
* 1. `lockfileVersion` is present and ≥ {@link MIN_RECOGNISED_LOCKFILE_VERSION};
|
|
90
|
+
* 2. an `importers:` section exists and yields at least one importer;
|
|
91
|
+
* 3. no legacy inline `dep: value` line appears at dependency depth (the v5/v6 shape);
|
|
92
|
+
* 4. at least one `specifier:` was read, and NO importer came out empty (a truncated file, or a shape
|
|
93
|
+
* we do not understand, always trips this).
|
|
94
|
+
*
|
|
95
|
+
* `dependencies` and `devDependencies` are merged: a dep appears in only one of them per importer, and
|
|
96
|
+
* the rule compares specifier strings only.
|
|
97
|
+
*/
|
|
98
|
+
export function parsePnpmLockImporters(lockText: unknown): Record<string, Record<string, string>> | undefined {
|
|
99
|
+
if (typeof lockText !== 'string' || lockText === '') return undefined;
|
|
100
|
+
|
|
101
|
+
// (1) version gate — the ONLY layout this parser claims to understand.
|
|
102
|
+
const versionLine = lockText.match(/^lockfileVersion:\s*['"]?([0-9]+(?:\.[0-9]+)?)['"]?\s*$/m);
|
|
103
|
+
const version = versionLine?.[1] !== undefined ? Number.parseFloat(versionLine[1]) : Number.NaN;
|
|
104
|
+
if (!Number.isFinite(version) || version < MIN_RECOGNISED_LOCKFILE_VERSION) return undefined;
|
|
105
|
+
|
|
106
|
+
const importers: Record<string, Record<string, string>> = {};
|
|
107
|
+
let inImporters = false;
|
|
108
|
+
let current: string | undefined;
|
|
109
|
+
let currentDep: string | undefined;
|
|
110
|
+
let specifiersSeen = 0;
|
|
111
|
+
let sawImportersKey = false;
|
|
112
|
+
for (const line of lockText.split('\n')) {
|
|
113
|
+
if (/^importers:\s*$/.test(line)) {
|
|
114
|
+
inImporters = true;
|
|
115
|
+
sawImportersKey = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!inImporters) continue;
|
|
119
|
+
if (/^\S/.test(line)) break; // a new top-level key ends the importers section
|
|
120
|
+
if (line.trim() === '') continue;
|
|
121
|
+
const importer = line.match(/^ {2}(\S.*?):\s*$/);
|
|
122
|
+
if (importer && importer[1] !== undefined) {
|
|
123
|
+
current = unquoteYaml(importer[1]);
|
|
124
|
+
importers[current] = importers[current] ?? {};
|
|
125
|
+
currentDep = undefined;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (current === undefined) continue;
|
|
129
|
+
// (3) a dependency-depth line that carries an INLINE value is the pre-v9 shape → refuse outright
|
|
130
|
+
// rather than silently recording nothing for this importer.
|
|
131
|
+
if (/^ {6}\S.*?:\s+\S/.test(line)) return undefined;
|
|
132
|
+
const dep = line.match(/^ {6}(\S.*?):\s*$/);
|
|
133
|
+
if (dep && dep[1] !== undefined) {
|
|
134
|
+
// A dep line while the PREVIOUS dep never got its specifier = a truncated/unrecognized shape —
|
|
135
|
+
// refuse the whole parse rather than warn on a half-read (Codex re-QE: pending currentDep).
|
|
136
|
+
if (currentDep !== undefined) return undefined;
|
|
137
|
+
currentDep = unquoteYaml(dep[1]);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const spec = line.match(/^ {8}specifier:\s*(.+?)\s*$/);
|
|
141
|
+
if (spec && spec[1] !== undefined && currentDep !== undefined) {
|
|
142
|
+
importers[current]![currentDep] = unquoteYaml(spec[1]);
|
|
143
|
+
specifiersSeen += 1;
|
|
144
|
+
currentDep = undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// EOF with a dep still awaiting its specifier: truncated — refuse, never warn on a half-parse.
|
|
148
|
+
if (currentDep !== undefined) return undefined;
|
|
149
|
+
|
|
150
|
+
// (2) + (4) structural confidence: no importers, no specifiers, or ANY importer that came out empty
|
|
151
|
+
// (truncation, an unread section shape) means we did not really parse this file — report nothing.
|
|
152
|
+
if (!sawImportersKey || Object.keys(importers).length === 0 || specifiersSeen === 0) return undefined;
|
|
153
|
+
for (const deps of Object.values(importers)) if (Object.keys(deps).length === 0) return undefined;
|
|
154
|
+
return importers;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function unquoteYaml(s: string): string {
|
|
158
|
+
const t = s.trim();
|
|
159
|
+
if ((t.startsWith("'") && t.endsWith("'") && t.length >= 2) || (t.startsWith('"') && t.endsWith('"') && t.length >= 2)) {
|
|
160
|
+
return t.slice(1, -1);
|
|
161
|
+
}
|
|
162
|
+
return t;
|
|
59
163
|
}
|
|
60
164
|
|
|
61
165
|
/** The built-in rule set (works with no config). Ops are the mutating operations each rule guards. */
|
|
@@ -66,6 +170,7 @@ export const DEFAULT_RULES: readonly GuardRule[] = [
|
|
|
66
170
|
{ id: 'readme-consistency', severity: 'soft', ops: ['publish'], description: 'README counts agree (CJM header vs All Commands, etc.)' },
|
|
67
171
|
{ 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)' },
|
|
68
172
|
{ 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)' },
|
|
173
|
+
{ id: 'lockfile-in-sync', severity: 'soft', ops: ['publish'], description: 'every workspace @dzhechkov/* dependency spec matches the specifier pnpm-lock.yaml records for that importer (a dep bump without a lockfile refresh breaks CI with ERR_PNPM_OUTDATED_LOCKFILE). SOFT-ONLY — a config cannot promote it to HARD' },
|
|
69
174
|
{ id: 'store-bloat-cap', severity: 'soft', ops: ['teach', 'consolidate'], description: 'the learned store is within its size cap' },
|
|
70
175
|
];
|
|
71
176
|
|
|
@@ -154,6 +259,40 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
154
259
|
}
|
|
155
260
|
return out;
|
|
156
261
|
},
|
|
262
|
+
'lockfile-in-sync': (f, _sev) => {
|
|
263
|
+
// The 2026-07-28 CI break, mechanized: an overnight dep bump edited package.json and left
|
|
264
|
+
// pnpm-lock.yaml stale, so `pnpm install --frozen-lockfile` died with ERR_PNPM_OUTDATED_LOCKFILE.
|
|
265
|
+
// SOFT + FAIL-OPEN: no lockfile evidence ⇒ NO violation. A guard that cannot read the lockfile must
|
|
266
|
+
// never invent one — a false block on publish costs more than the miss it prevents.
|
|
267
|
+
// The injected severity is IGNORED on purpose (MED-6): this rule emits `soft` unconditionally, so
|
|
268
|
+
// neither a config promotion nor a hand-built rules array can turn a tolerant parser into a blocker.
|
|
269
|
+
const sev: GuardSeverity = 'soft';
|
|
270
|
+
const lock = f.lockfile;
|
|
271
|
+
if (!lock || typeof lock !== 'object' || lock.parsed !== true || !Array.isArray(lock.importers)) return [];
|
|
272
|
+
const out: Violation[] = [];
|
|
273
|
+
for (const imp of lock.importers) {
|
|
274
|
+
if (!imp || typeof imp.importer !== 'string' || !imp.declared || typeof imp.declared !== 'object') continue;
|
|
275
|
+
const declared = Object.entries(imp.declared).filter(([dep, spec]) => dep.startsWith('@dzhechkov/') && typeof spec === 'string');
|
|
276
|
+
if (declared.length === 0) continue;
|
|
277
|
+
if (imp.locked === undefined || imp.locked === null) {
|
|
278
|
+
out.push({
|
|
279
|
+
rule: 'lockfile-in-sync',
|
|
280
|
+
severity: sev,
|
|
281
|
+
detail: `${imp.importer}: declares ${declared.length} @dzhechkov/* dep(s) but has no importer entry in pnpm-lock.yaml — run \`pnpm install\` (CI installs with --frozen-lockfile)`,
|
|
282
|
+
});
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
for (const [dep, spec] of declared) {
|
|
286
|
+
const locked = imp.locked[dep];
|
|
287
|
+
if (locked === undefined) {
|
|
288
|
+
out.push({ rule: 'lockfile-in-sync', severity: sev, detail: `${imp.importer}: ${dep} = "${spec}" is not recorded in pnpm-lock.yaml — run \`pnpm install\` (CI installs with --frozen-lockfile)` });
|
|
289
|
+
} else if (locked !== spec) {
|
|
290
|
+
out.push({ rule: 'lockfile-in-sync', severity: sev, detail: `${imp.importer}: ${dep} = "${spec}" in package.json but "${locked}" in pnpm-lock.yaml — run \`pnpm install\` to refresh the lockfile (CI installs with --frozen-lockfile)` });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
},
|
|
157
296
|
'store-bloat-cap': (f, sev) => {
|
|
158
297
|
const s = f.store;
|
|
159
298
|
if (!s || !Number.isFinite(s.count) || !Number.isFinite(s.cap) || s.cap <= 0) return [];
|
|
@@ -161,6 +300,14 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
161
300
|
},
|
|
162
301
|
};
|
|
163
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Rules that may NEVER be promoted to HARD, whatever a config says. A rule whose evidence comes from a
|
|
305
|
+
* deliberately tolerant parser must not be able to BLOCK an operation: the parser's own design admits it
|
|
306
|
+
* may not understand a file, and "I might be wrong" plus "block the publish" is the wrong pair. Disabling
|
|
307
|
+
* such a rule stays allowed — only the promotion is refused.
|
|
308
|
+
*/
|
|
309
|
+
export const SOFT_ONLY_RULES: readonly string[] = ['lockfile-in-sync'];
|
|
310
|
+
|
|
164
311
|
/** Merge a user config over the defaults: override severity, disable (enabled:false), never add an un-checked rule. */
|
|
165
312
|
export function resolveRules(userRules?: readonly Partial<GuardRule>[]): GuardRule[] {
|
|
166
313
|
const byId = new Map<string, GuardRule>(DEFAULT_RULES.map((r) => [r.id, r]));
|
|
@@ -168,9 +315,12 @@ export function resolveRules(userRules?: readonly Partial<GuardRule>[]): GuardRu
|
|
|
168
315
|
if (!u || typeof u.id !== 'string') continue;
|
|
169
316
|
const base = byId.get(u.id);
|
|
170
317
|
if (!base) continue; // a config rule with no built-in checker is ignored (fail-safe: no un-enforceable rules)
|
|
318
|
+
// A SOFT-ONLY rule keeps its severity even when the config asks for hard (see SOFT_ONLY_RULES).
|
|
319
|
+
const severity = u.severity === 'hard' || u.severity === 'soft' ? u.severity : undefined;
|
|
320
|
+
const allowedSeverity = severity !== undefined && !(severity === 'hard' && SOFT_ONLY_RULES.includes(u.id)) ? severity : undefined;
|
|
171
321
|
byId.set(u.id, {
|
|
172
322
|
...base,
|
|
173
|
-
...(
|
|
323
|
+
...(allowedSeverity !== undefined ? { severity: allowedSeverity } : {}),
|
|
174
324
|
...(typeof u.enabled === 'boolean' ? { enabled: u.enabled } : {}),
|
|
175
325
|
});
|
|
176
326
|
}
|
package/src/index.ts
CHANGED
|
@@ -327,6 +327,58 @@ export * from './skills-verify.js';
|
|
|
327
327
|
// finding, not a pass).
|
|
328
328
|
export * from './compounding.js';
|
|
329
329
|
|
|
330
|
+
// Cold-vs-warm EPOCH RUNNER (feature epoch-replay, scout idea #4) — the RESULT leg to compounding's
|
|
331
|
+
// readiness leg. Orchestrates + scores; never calls a model. SUPPORTED requires two DISJOINT Wilson
|
|
332
|
+
// intervals; INCONCLUSIVE is a first-class honest outcome.
|
|
333
|
+
// NOTE: `replayableInstances` / `ReplayInstance` are OWNED by compounding.js and merely re-exported
|
|
334
|
+
// there, so this star-export must not re-export them again (duplicate-export error).
|
|
335
|
+
export {
|
|
336
|
+
WILSON_Z,
|
|
337
|
+
MIN_INSTANCES,
|
|
338
|
+
FALSIFY_NO_LIFT_MIN_N,
|
|
339
|
+
NO_LIFT_MARGIN,
|
|
340
|
+
MARGIN_MAX,
|
|
341
|
+
MARGIN_MIN_EXCLUSIVE,
|
|
342
|
+
DIGEST_HONEST_SCOPE,
|
|
343
|
+
WORK_ORDER_KIND,
|
|
344
|
+
WORK_ORDER_VERSION,
|
|
345
|
+
DEFAULT_WORD_MIN,
|
|
346
|
+
DEFAULT_WORD_MAX,
|
|
347
|
+
DEFAULT_MOCK_N,
|
|
348
|
+
DEFAULT_MOCK_SEED,
|
|
349
|
+
wilsonInterval,
|
|
350
|
+
liftInterval,
|
|
351
|
+
corpusFingerprint,
|
|
352
|
+
isValidMargin,
|
|
353
|
+
workOrderDigest,
|
|
354
|
+
verifyWorkOrder,
|
|
355
|
+
buildWorkOrder,
|
|
356
|
+
buildJudgePrompts,
|
|
357
|
+
unblindJudgments,
|
|
358
|
+
scoreEpochReplay,
|
|
359
|
+
generateMockOutcomes,
|
|
360
|
+
renderEpochReplayResult,
|
|
361
|
+
renderWorkOrderSummary,
|
|
362
|
+
renderJudgePromptsSummary,
|
|
363
|
+
type WilsonInterval,
|
|
364
|
+
type LiftInterval,
|
|
365
|
+
type WorkOrder,
|
|
366
|
+
type WorkOrderItem,
|
|
367
|
+
type WorkOrderOptions,
|
|
368
|
+
type WorkOrderVerification,
|
|
369
|
+
type JudgePrompt,
|
|
370
|
+
type JudgePromptsResult,
|
|
371
|
+
type Arm,
|
|
372
|
+
type EpochOutcome,
|
|
373
|
+
type Judgment,
|
|
374
|
+
type UnblindResult,
|
|
375
|
+
type EpochVerdict,
|
|
376
|
+
type ArmResult,
|
|
377
|
+
type EpochReplayResult,
|
|
378
|
+
type ScoreOptions,
|
|
379
|
+
type MockOptions,
|
|
380
|
+
} from './epoch-replay.js';
|
|
381
|
+
|
|
330
382
|
// Run-process scorecard (feature dz-score, Reading C) — scores the DISCIPLINE of a feature-adr run
|
|
331
383
|
// from its artifacts. Descriptive-only, permanently: it never gates.
|
|
332
384
|
export * from './score.js';
|