@dzhechkov/harness-core 0.8.39 → 0.8.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +235 -135
- package/README.md +91 -8
- package/dist/__tests__/golden-baseline.test.js +1 -1
- package/dist/__tests__/golden-baseline.test.js.map +1 -1
- package/dist/agentdb-index.js +3 -3
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-reindex-marker.d.ts +2 -2
- package/dist/agentdb-reindex-marker.js +4 -4
- package/dist/agentdb-reindex-marker.js.map +1 -1
- package/dist/agentdb-snapshot-rotation.d.ts +1 -1
- package/dist/agentdb-snapshot-rotation.js +1 -1
- package/dist/backup-freshness.d.ts +32 -0
- package/dist/backup-freshness.d.ts.map +1 -0
- package/dist/backup-freshness.js +69 -0
- package/dist/backup-freshness.js.map +1 -0
- package/dist/cmd-usage.d.ts +2 -2
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +56 -8
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +2 -1
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +15 -2
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/compounding.d.ts +109 -1
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +172 -9
- package/dist/compounding.js.map +1 -1
- package/dist/discrimination-gate.d.ts +0 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +2 -2
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/doctor-instrument.d.ts +65 -0
- package/dist/doctor-instrument.d.ts.map +1 -0
- package/dist/doctor-instrument.js +91 -0
- package/dist/doctor-instrument.js.map +1 -0
- package/dist/feature-tier.d.ts.map +1 -1
- package/dist/feature-tier.js +13 -1
- package/dist/feature-tier.js.map +1 -1
- package/dist/guard.d.ts +39 -2
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +117 -14
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +20 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -6
- package/dist/index.js.map +1 -1
- package/dist/integration-apply.js +2 -2
- package/dist/integration-apply.js.map +1 -1
- package/dist/lesson-payoff.js +3 -3
- package/dist/lesson-payoff.js.map +1 -1
- package/dist/mutation-gate.d.ts +30 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +45 -1
- package/dist/mutation-gate.js.map +1 -1
- package/dist/named-lock.d.ts +5 -2
- package/dist/named-lock.d.ts.map +1 -1
- package/dist/named-lock.js +30 -10
- package/dist/named-lock.js.map +1 -1
- package/dist/operations.d.ts +13 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +112 -2
- package/dist/operations.js.map +1 -1
- package/dist/pack-inventory.d.ts +51 -0
- package/dist/pack-inventory.d.ts.map +1 -0
- package/dist/pack-inventory.js +306 -0
- package/dist/pack-inventory.js.map +1 -0
- package/dist/package-skill-layouts.d.ts +3 -2
- package/dist/package-skill-layouts.d.ts.map +1 -1
- package/dist/package-skill-layouts.js +3 -2
- package/dist/package-skill-layouts.js.map +1 -1
- package/dist/patterns.d.ts +8 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +11 -1
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +29 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +92 -15
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts +58 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +86 -25
- package/dist/registry.js.map +1 -1
- package/dist/release.d.ts +18 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +30 -0
- package/dist/release.js.map +1 -1
- package/dist/reqe-verdict.d.ts +55 -0
- package/dist/reqe-verdict.d.ts.map +1 -0
- package/dist/reqe-verdict.js +173 -0
- package/dist/reqe-verdict.js.map +1 -0
- package/dist/reqe.d.ts +35 -14
- package/dist/reqe.d.ts.map +1 -1
- package/dist/reqe.js +85 -32
- package/dist/reqe.js.map +1 -1
- package/dist/round-exec.d.ts +10 -0
- package/dist/round-exec.d.ts.map +1 -1
- package/dist/round-exec.js +3 -2
- package/dist/round-exec.js.map +1 -1
- package/dist/round.d.ts +29 -0
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +8 -0
- package/dist/round.js.map +1 -1
- package/dist/session-retro.d.ts +1 -1
- package/dist/session-retro.js +3 -3
- package/dist/session-retro.js.map +1 -1
- package/dist/skill-drift.d.ts +18 -1
- package/dist/skill-drift.d.ts.map +1 -1
- package/dist/skill-drift.js +46 -13
- package/dist/skill-drift.js.map +1 -1
- package/dist/statusline.js +2 -2
- package/dist/statusline.js.map +1 -1
- package/dist/store-guard.js +3 -3
- package/dist/store-guard.js.map +1 -1
- package/dist/store-lock.d.ts +9 -0
- package/dist/store-lock.d.ts.map +1 -1
- package/dist/store-lock.js +22 -3
- package/dist/store-lock.js.map +1 -1
- package/dist/test-receipt.d.ts +66 -0
- package/dist/test-receipt.d.ts.map +1 -0
- package/dist/test-receipt.js +73 -0
- package/dist/test-receipt.js.map +1 -0
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +7 -8
- package/dist/workflow-run.js.map +1 -1
- package/package.json +1 -1
- package/sbom.json +384 -134
- package/src/__tests__/golden-baseline.test.ts +1 -1
- package/src/agentdb-index.ts +3 -3
- package/src/agentdb-reindex-marker.ts +4 -4
- package/src/agentdb-snapshot-rotation.ts +1 -1
- package/src/backup-freshness.ts +96 -0
- package/src/cmd-usage.ts +59 -8
- package/src/codex-hooks-assets.ts +15 -2
- package/src/compounding.ts +244 -10
- package/src/discrimination-gate.ts +2 -5
- package/src/doctor-instrument.ts +153 -0
- package/src/feature-tier.ts +14 -1
- package/src/guard.ts +128 -17
- package/src/index.ts +28 -7
- package/src/integration-apply.ts +2 -2
- package/src/lesson-payoff.ts +3 -3
- package/src/mutation-gate.ts +54 -1
- package/src/named-lock.ts +36 -11
- package/src/operations.ts +109 -3
- package/src/pack-inventory.ts +313 -0
- package/src/package-skill-layouts.ts +3 -2
- package/src/patterns.ts +18 -1
- package/src/publish.ts +101 -22
- package/src/registry.ts +114 -18
- package/src/release.ts +36 -0
- package/src/reqe-verdict.ts +174 -0
- package/src/reqe.ts +111 -35
- package/src/round-exec.ts +13 -2
- package/src/round.ts +38 -0
- package/src/session-retro.ts +3 -3
- package/src/skill-drift.ts +66 -12
- package/src/statusline.ts +2 -2
- package/src/store-guard.ts +3 -3
- package/src/store-lock.ts +20 -3
- package/src/test-receipt.ts +106 -0
- package/src/workflow-run.ts +7 -8
package/src/compounding.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* Everything here is PURE: callers gather facts (files, store rows); this module only computes.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { EVENT_CHAIN_SCOPE, verifyEventChainText } from './event-chain.js';
|
|
18
|
+
import { EVENT_CHAIN_SCOPE, classifyChainDefects, verifyEventChainText } from './event-chain.js';
|
|
19
|
+
import type { GuardOp } from './guard.js';
|
|
19
20
|
import {
|
|
20
21
|
isOffsetIsoTimestamp,
|
|
21
22
|
type PromotionAcceptanceEvidence,
|
|
@@ -156,19 +157,41 @@ export function replayableInstances(
|
|
|
156
157
|
|
|
157
158
|
export interface GuardEvent {
|
|
158
159
|
readonly ts: string;
|
|
159
|
-
|
|
160
|
+
// Includes 'code': the code guard writes audit rows for this operation too.
|
|
161
|
+
readonly op?: GuardOp;
|
|
160
162
|
readonly verdict: string;
|
|
161
163
|
readonly rules: readonly string[]; // violated rule ids
|
|
162
164
|
readonly violations?: readonly { readonly rule: string; readonly contentAnchor?: string }[];
|
|
165
|
+
/**
|
|
166
|
+
* 1-based position of this record among the log's non-empty lines — the ONLY thing that can place
|
|
167
|
+
* it relative to a chain defect. Absent when the caller read the rows without a chain.
|
|
168
|
+
*/
|
|
169
|
+
readonly chainLine?: number;
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
export type FunnelEvidenceSource<T> =
|
|
166
173
|
| { readonly status: 'measured'; readonly rows: readonly T[] }
|
|
167
174
|
| { readonly status: 'not-measured'; readonly reason: string };
|
|
168
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Where the guard journal's chain damage sits, so a PERIOD can be judged instead of the whole FILE.
|
|
178
|
+
*
|
|
179
|
+
* A log damaged once in March and unbroken since is not evidence against September's rows, and
|
|
180
|
+
* refusing to measure September because of March is the same "verdict answers a different question"
|
|
181
|
+
* defect the chain headline was fixed for (backlog b38dd3ba, MEASURED 2026-09-21: 28 defects, all
|
|
182
|
+
* before a run of 1169 unbroken records, suppressed BOTH measured months).
|
|
183
|
+
*/
|
|
184
|
+
export interface GuardAuditChainWindow {
|
|
185
|
+
/** First non-empty line of the current unbroken run: one past the last defect. */
|
|
186
|
+
readonly runFrom: number;
|
|
187
|
+
/** Total defects in the file. Zero means the window imposes nothing. */
|
|
188
|
+
readonly defects: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
169
191
|
export interface LessonToRuleFunnelFacts {
|
|
170
192
|
readonly promotionRuns: FunnelEvidenceSource<PromotionRunEvidence>;
|
|
171
193
|
readonly guardAudits: FunnelEvidenceSource<GuardEvent>;
|
|
194
|
+
readonly guardAuditChain?: GuardAuditChainWindow;
|
|
172
195
|
readonly promotionAcceptances?: readonly PromotionAcceptanceEvidence[];
|
|
173
196
|
readonly truncatedPromotionPeriods?: readonly string[];
|
|
174
197
|
readonly acceptanceHistoryComplete?: boolean;
|
|
@@ -274,6 +297,20 @@ export interface EvidenceChainHealth {
|
|
|
274
297
|
readonly preChainPrefix: number;
|
|
275
298
|
readonly defects: number;
|
|
276
299
|
readonly defectKinds: readonly string[];
|
|
300
|
+
/**
|
|
301
|
+
* WHERE the defects sit relative to the log's current unbroken run, and HOW MUCH of a run that is.
|
|
302
|
+
* Without this a bare `FAILED` over a log whose damage is entirely historical reads as "today's
|
|
303
|
+
* numbers are garbage", while `dz chain` over the SAME file says "healed … verdicts over those are
|
|
304
|
+
* sound" — MEASURED 2026-09-20 on `.dz/guard-audit.jsonl`: 28 defects, all before the current run,
|
|
305
|
+
* 1095 unbroken records after them; one instrument printed FAILED, the other healed, both exit 0
|
|
306
|
+
* (backlog 79ce6262). Neither was lying; neither named its WINDOW. `event-chain.ts` says it
|
|
307
|
+
* outright: a caller that reports soundness without printing the run size overclaims on its behalf,
|
|
308
|
+
* and the same holds for a caller that reports damage without printing where the damage sits.
|
|
309
|
+
*/
|
|
310
|
+
readonly defectsBeforeRun: number;
|
|
311
|
+
readonly defectsInRun: number;
|
|
312
|
+
/** Records in the current unbroken run — the evidence behind any "sound for today" reading. */
|
|
313
|
+
readonly runRecords: number;
|
|
277
314
|
}
|
|
278
315
|
|
|
279
316
|
export interface InstrumentationHealth {
|
|
@@ -394,6 +431,13 @@ function executionMeasurement(
|
|
|
394
431
|
if (periodAudits.length === 0) {
|
|
395
432
|
return LESSON_TO_RULE_FUNNEL_POLICY.unavailable(`guard-audit-not-recorded:${period}`);
|
|
396
433
|
}
|
|
434
|
+
// Damage that PRECEDES this period's rows says nothing about them; damage that touches them does.
|
|
435
|
+
// A row with no position cannot be placed, and unplaceable is not the same as sound — it refuses.
|
|
436
|
+
const chain = facts.guardAuditChain;
|
|
437
|
+
if (chain !== undefined && chain.defects > 0
|
|
438
|
+
&& periodAudits.some((row) => row.chainLine === undefined || row.chainLine < chain.runFrom)) {
|
|
439
|
+
return LESSON_TO_RULE_FUNNEL_POLICY.unavailable(`guard-audit-chain-damaged:${period}`);
|
|
440
|
+
}
|
|
397
441
|
const audits = periodAudits.filter((row) => row.op === 'publish');
|
|
398
442
|
if (audits.length === 0) {
|
|
399
443
|
return LESSON_TO_RULE_FUNNEL_POLICY.unavailable(`guard-publish-not-recorded:${period}`);
|
|
@@ -594,6 +638,11 @@ export function assembleCompoundingReport(facts: CompoundingFacts): CompoundingR
|
|
|
594
638
|
preChainPrefix: v.preChainPrefix,
|
|
595
639
|
defects: v.defects.length,
|
|
596
640
|
defectKinds: [...new Set(v.defects.map((d) => d.kind))],
|
|
641
|
+
...((age) => ({
|
|
642
|
+
defectsBeforeRun: age.beforeRun.length,
|
|
643
|
+
defectsInRun: age.inRun.length,
|
|
644
|
+
runRecords: age.runRecords,
|
|
645
|
+
}))(classifyChainDefects(v, v.lines)),
|
|
597
646
|
};
|
|
598
647
|
});
|
|
599
648
|
|
|
@@ -623,13 +672,7 @@ export function assembleCompoundingReport(facts: CompoundingFacts): CompoundingR
|
|
|
623
672
|
trajectory.length > 0 ? `guard: ${improvedRules}/${trajectory.length} rules recur less in the later half` : 'guard: not enough history',
|
|
624
673
|
`cold-vs-warm: ${replay.verdict === 'insufficient-data' ? 'INSUFFICIENT DATA (accruing)' : 'READY to measure'}`,
|
|
625
674
|
instrumentation.applyLegLive ? 'apply leg: live' : 'apply leg: STALE — fix the instrumentation before trusting anything above',
|
|
626
|
-
...(chains.length === 0
|
|
627
|
-
? []
|
|
628
|
-
: [
|
|
629
|
-
instrumentation.chainsOk
|
|
630
|
-
? 'evidence chain: verified'
|
|
631
|
-
: 'evidence chain: CORRUPT — the numbers above are computed from a damaged log',
|
|
632
|
-
]),
|
|
675
|
+
...(chains.length === 0 ? [] : [chainHeadline(chains)]),
|
|
633
676
|
].join(' · ');
|
|
634
677
|
|
|
635
678
|
return { pool, guardTrajectory: trajectory, replay, instrumentation, lessonToRuleFunnel, verdict };
|
|
@@ -671,6 +714,68 @@ function renderFunnelPeriodMeasurements(row: LessonToRuleFunnelPeriod): string {
|
|
|
671
714
|
return `${renderPromotionMeasurements(row)} · ${renderFunnelMeasurement('executions', row.executions)}`;
|
|
672
715
|
}
|
|
673
716
|
|
|
717
|
+
/**
|
|
718
|
+
* The HEADLINE verdict over every evidence log — three-valued, because two values lied.
|
|
719
|
+
*
|
|
720
|
+
* MEASURED 2026-09-21 on `.dz/guard-audit.jsonl`: 28 defects, the LAST of them dated 2026-09-05,
|
|
721
|
+
* followed by more than a thousand unbroken records. The old headline read
|
|
722
|
+
* "CORRUPT — the numbers above are computed from a damaged log", which is true of the FILE'S
|
|
723
|
+
* HISTORY and false about the numbers it was printed next to. The distinction already existed one
|
|
724
|
+
* function below, in {@link chainVerdictPhrase}; it simply never reached the line a reader sees
|
|
725
|
+
* first. That is the same defect class this report exists to find: a verdict answering a different
|
|
726
|
+
* question than the one it appears to answer.
|
|
727
|
+
*/
|
|
728
|
+
/**
|
|
729
|
+
* Whether the report's OWN numbers may be trusted, as a value the caller can turn into an exit code.
|
|
730
|
+
*
|
|
731
|
+
* Backlog 79ce6262 named the defect: the report printed "the numbers above are computed from a
|
|
732
|
+
* damaged log" and exited 0 anyway — a tool announcing its own output untrustworthy and reporting
|
|
733
|
+
* success. That record offered two lawful cures and asked which applies. Both do, on different
|
|
734
|
+
* branches, and only the three-valued verdict lets them coexist: damage BEHIND the current run
|
|
735
|
+
* narrows the WORDING (the numbers stand, exit 0), damage INSIDE it makes the numbers genuinely
|
|
736
|
+
* unreliable and must reach the exit code.
|
|
737
|
+
*
|
|
738
|
+
* `'trusted'` ⇒ 0. `'unreliable'` ⇒ a non-zero the caller chooses — the run succeeded, the verdict
|
|
739
|
+
* cannot be relied on, which is this repository's INCONCLUSIVE shape, not its failure shape.
|
|
740
|
+
*/
|
|
741
|
+
export function chainTrust(chains: readonly EvidenceChainHealth[]): 'trusted' | 'unreliable' {
|
|
742
|
+
return chains.some((c) => !c.ok && c.defectsInRun > 0) ? 'unreliable' : 'trusted';
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
export function chainHeadline(chains: readonly EvidenceChainHealth[]): string {
|
|
746
|
+
const broken = chains.filter((c) => !c.ok);
|
|
747
|
+
if (broken.length === 0) return 'evidence chain: verified';
|
|
748
|
+
const live = broken.filter((c) => c.defectsInRun > 0);
|
|
749
|
+
if (live.length === 0) {
|
|
750
|
+
const runs = broken.reduce((n, c) => n + c.runRecords, 0);
|
|
751
|
+
const defects = broken.reduce((n, c) => n + c.defects, 0);
|
|
752
|
+
return `evidence chain: damaged EARLIER — ${defects} defect(s), none inside the current run of `
|
|
753
|
+
+ `${runs} unbroken record(s); the numbers above stand, the file's history does not`;
|
|
754
|
+
}
|
|
755
|
+
const inRun = live.reduce((n, c) => n + c.defectsInRun, 0);
|
|
756
|
+
return `evidence chain: CORRUPT — ${inRun} defect(s) INSIDE the current run; the numbers above are `
|
|
757
|
+
+ 'computed from a damaged log';
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* The verdict phrase for one evidence log, with its WINDOW named. A bare `FAILED` over damage that an
|
|
762
|
+
* unbroken run has already followed is true of the FILE and misleading about TODAY — see
|
|
763
|
+
* {@link EvidenceChainHealth.defectsBeforeRun}.
|
|
764
|
+
*/
|
|
765
|
+
export function chainVerdictPhrase(c: EvidenceChainHealth): string {
|
|
766
|
+
if (c.ok) return 'verified';
|
|
767
|
+
const kinds = `[${c.defectKinds.join(', ')}]`;
|
|
768
|
+
if (c.defectsInRun === 0) {
|
|
769
|
+
return `DAMAGED EARLIER — ${c.defects} defect(s) ${kinds}, all BEFORE the current run of `
|
|
770
|
+
+ `${c.runRecords} unbroken record(s); numbers over that run stand, the file's history does not`;
|
|
771
|
+
}
|
|
772
|
+
if (c.defectsBeforeRun === 0) {
|
|
773
|
+
return `FAILED — ${c.defects} defect(s) ${kinds} with NO sound records after them`;
|
|
774
|
+
}
|
|
775
|
+
return `FAILED — ${c.defects} defect(s) ${kinds}: ${c.defectsInRun} inside the current run of `
|
|
776
|
+
+ `${c.runRecords} record(s), ${c.defectsBeforeRun} before it`;
|
|
777
|
+
}
|
|
778
|
+
|
|
674
779
|
export function renderCompoundingReport(r: CompoundingReport): string {
|
|
675
780
|
const out: string[] = [];
|
|
676
781
|
out.push('dz compounding — does the learning loop pay? (honest report: gates without data say so)');
|
|
@@ -695,7 +800,7 @@ export function renderCompoundingReport(r: CompoundingReport): string {
|
|
|
695
800
|
);
|
|
696
801
|
for (const c of r.instrumentation.chains) {
|
|
697
802
|
out.push(
|
|
698
|
-
` EVIDENCE CHAIN ${c.log}: ${
|
|
803
|
+
` EVIDENCE CHAIN ${c.log}: ${chainVerdictPhrase(c)}` +
|
|
699
804
|
` · ${c.chained} chained · ${c.preChainPrefix} pre-chain (uncovered)`,
|
|
700
805
|
);
|
|
701
806
|
}
|
|
@@ -722,3 +827,132 @@ export function renderCompoundingReport(r: CompoundingReport): string {
|
|
|
722
827
|
out.push(` VERDICT: ${r.verdict}`);
|
|
723
828
|
return out.join('\n');
|
|
724
829
|
}
|
|
830
|
+
|
|
831
|
+
// ── Доведённая работа: дополнительная метрика, не влияющая на отбор ──
|
|
832
|
+
|
|
833
|
+
export interface LessonOutcomeRow {
|
|
834
|
+
readonly lessons?: readonly string[];
|
|
835
|
+
readonly outcome?: string;
|
|
836
|
+
readonly grade?: string | null;
|
|
837
|
+
readonly slug?: string;
|
|
838
|
+
readonly stage?: string;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export interface LessonOutcomeCounters {
|
|
842
|
+
pairs: number;
|
|
843
|
+
shipped: number;
|
|
844
|
+
refuted: number;
|
|
845
|
+
blocked: number;
|
|
846
|
+
other: number;
|
|
847
|
+
graded: number;
|
|
848
|
+
grades: Record<string, number>;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export interface LessonOutcomeCoverage {
|
|
852
|
+
readonly lessonsWithOutcome: number;
|
|
853
|
+
readonly pairsTotal: number;
|
|
854
|
+
readonly pairsUngraded: number;
|
|
855
|
+
readonly duplicateRowsDropped: number;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export interface JoinedLessonOutcomes {
|
|
859
|
+
readonly perLesson: ReadonlyMap<string, LessonOutcomeCounters>;
|
|
860
|
+
readonly totals: LessonOutcomeCounters;
|
|
861
|
+
readonly coverage: LessonOutcomeCoverage;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function emptyLessonOutcomeCounters(): LessonOutcomeCounters {
|
|
865
|
+
return {
|
|
866
|
+
pairs: 0, shipped: 0, refuted: 0, blocked: 0, other: 0, graded: 0,
|
|
867
|
+
grades: Object.create(null) as Record<string, number>,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Считает пары «урок ↔ исход работы» из уже прочитанных строк леджера.
|
|
873
|
+
* unknown допускает мусор после разбора JSON; поля проверяются перед использованием.
|
|
874
|
+
* Неизвестный или отсутствующий исход попадает в other, пустой грейд — в пары без грейда.
|
|
875
|
+
* Буквы грейдов сохраняются как категории, без перевода в единый балл пользы.
|
|
876
|
+
* Полные JSON-дубликаты строк и повторные id внутри одной строки не умножают пары.
|
|
877
|
+
*/
|
|
878
|
+
export function joinLessonOutcomes(rows: readonly unknown[]): JoinedLessonOutcomes {
|
|
879
|
+
const perLesson = new Map<string, LessonOutcomeCounters>();
|
|
880
|
+
const totals = emptyLessonOutcomeCounters();
|
|
881
|
+
const seenRows = new Set<string>();
|
|
882
|
+
let duplicateRowsDropped = 0;
|
|
883
|
+
for (const value of rows) {
|
|
884
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
885
|
+
const row = value as LessonOutcomeRow;
|
|
886
|
+
if (!Array.isArray(row.lessons)) continue;
|
|
887
|
+
// Compare ALL fields; object key order is irrelevant, array order is preserved.
|
|
888
|
+
const canonicalRow = JSON.stringify(value, (_key, part: unknown) => {
|
|
889
|
+
if (part === null || typeof part !== 'object' || Array.isArray(part)) return part;
|
|
890
|
+
const object = part as Record<string, unknown>;
|
|
891
|
+
return Object.fromEntries(Object.keys(object).sort().map((key) => [key, object[key]]));
|
|
892
|
+
});
|
|
893
|
+
if (seenRows.has(canonicalRow)) {
|
|
894
|
+
duplicateRowsDropped++;
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
seenRows.add(canonicalRow);
|
|
898
|
+
const outcome = row.outcome === 'shipped' || row.outcome === 'refuted' || row.outcome === 'blocked'
|
|
899
|
+
? row.outcome : 'other';
|
|
900
|
+
const grade = typeof row.grade === 'string' ? row.grade.trim() : '';
|
|
901
|
+
for (const dzId of new Set(row.lessons)) {
|
|
902
|
+
if (typeof dzId !== 'string' || !dzId.startsWith('teach:')) continue;
|
|
903
|
+
const counts = perLesson.get(dzId) ?? emptyLessonOutcomeCounters();
|
|
904
|
+
perLesson.set(dzId, counts);
|
|
905
|
+
for (const target of [counts, totals]) {
|
|
906
|
+
target.pairs++;
|
|
907
|
+
target[outcome]++;
|
|
908
|
+
if (grade !== '') {
|
|
909
|
+
target.graded++;
|
|
910
|
+
target.grades[grade] = (target.grades[grade] ?? 0) + 1;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
perLesson,
|
|
917
|
+
totals,
|
|
918
|
+
coverage: {
|
|
919
|
+
lessonsWithOutcome: perLesson.size,
|
|
920
|
+
pairsTotal: totals.pairs,
|
|
921
|
+
pairsUngraded: totals.pairs - totals.graded,
|
|
922
|
+
duplicateRowsDropped,
|
|
923
|
+
},
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Размер стора передаёт вызывающий код: в строках леджера этого знаменателя нет.
|
|
929
|
+
* Для доли покрытия набор joined должен относиться к урокам этого стора.
|
|
930
|
+
* Без знаменателя доля остаётся неизвестной, а не превращается в 100%.
|
|
931
|
+
*/
|
|
932
|
+
export function renderLessonOutcomes(joined: JoinedLessonOutcomes, storeLessonCount?: number): string {
|
|
933
|
+
const { totals, coverage } = joined;
|
|
934
|
+
const percent = (part: number, whole: number): string =>
|
|
935
|
+
`${(whole === 0 ? 0 : part / whole * 100).toFixed(1).replace('.', ',')}%`;
|
|
936
|
+
const validStoreCount = typeof storeLessonCount === 'number'
|
|
937
|
+
&& Number.isSafeInteger(storeLessonCount)
|
|
938
|
+
&& storeLessonCount >= coverage.lessonsWithOutcome;
|
|
939
|
+
const storeCoverage = validStoreCount
|
|
940
|
+
? `${coverage.lessonsWithOutcome}/${storeLessonCount} (${percent(coverage.lessonsWithOutcome, storeLessonCount)})`
|
|
941
|
+
: `${coverage.lessonsWithOutcome}/неизвестно (доля неизвестна: размер стора не задан или некорректен)`;
|
|
942
|
+
const grades = Object.entries(totals.grades)
|
|
943
|
+
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
|
|
944
|
+
.map(([grade, count]) => `${grade}: ${count}`)
|
|
945
|
+
.join(', ');
|
|
946
|
+
return [
|
|
947
|
+
'ДОВЕДЁННАЯ РАБОТА — дополнительная метрика пользы уроков',
|
|
948
|
+
` Пар «урок ↔ исход работы»: ${totals.pairs}; доведено (shipped): ${totals.shipped}; `
|
|
949
|
+
+ `опровергнуто (refuted): ${totals.refuted}; заблокировано (blocked): ${totals.blocked}; прочие исходы: ${totals.other}.`,
|
|
950
|
+
` Грейды (${totals.graded} пар): ${grades || 'нет'}; самоотчёт — грейд ставит ведущий при закрытии круга, это не независимая оценка.`,
|
|
951
|
+
` Доля пар без грейда: ${coverage.pairsUngraded}/${coverage.pairsTotal} (${percent(coverage.pairsUngraded, coverage.pairsTotal)}).`,
|
|
952
|
+
` Покрытие уроков стора хотя бы одной парой: ${storeCoverage}.`,
|
|
953
|
+
...(coverage.duplicateRowsDropped > 0
|
|
954
|
+
? [` Отброшено дубликатов строк леджера: ${coverage.duplicateRowsDropped}.`]
|
|
955
|
+
: []),
|
|
956
|
+
' Отбор уроков по-прежнему использует оценку намерения.',
|
|
957
|
+
].join('\n');
|
|
958
|
+
}
|
|
@@ -394,9 +394,6 @@ export interface DiscriminationResult {
|
|
|
394
394
|
/** compat scalar: worst-of via RANK. A total order can only answer "worst thing present" —
|
|
395
395
|
* everything it destroys travels in findings[] / measurementValid / primaryAction. */
|
|
396
396
|
readonly aggregate: DiscriminationVerdict;
|
|
397
|
-
/** @deprecated compat alias for ONE release — always `findings[0] ?? null` (worst first).
|
|
398
|
-
* Removal in the next minor is a recorded release obligation (ADR-002 Decision item 6). */
|
|
399
|
-
readonly finding: DiscriminationFinding | null;
|
|
400
397
|
/** one per distinct non-clean verdict present, worst-first. */
|
|
401
398
|
readonly findings: readonly DiscriminationFinding[];
|
|
402
399
|
readonly measurementValid: MeasurementValid;
|
|
@@ -821,7 +818,7 @@ export function classifyDiscrimination(input: ClassifyInput): DiscriminationResu
|
|
|
821
818
|
detail:
|
|
822
819
|
'No test was mapped to the ADR safety property, so discrimination could not be evaluated — this is the existing "property untested" finding. Action: map-a-test.',
|
|
823
820
|
};
|
|
824
|
-
return { perTest: [], aggregate: 'CANNOT_ISOLATE',
|
|
821
|
+
return { perTest: [], aggregate: 'CANNOT_ISOLATE', findings: [finding], measurementValid: false, primaryAction: 'map-a-test' };
|
|
825
822
|
}
|
|
826
823
|
|
|
827
824
|
let missingRow = false;
|
|
@@ -876,5 +873,5 @@ export function classifyDiscrimination(input: ClassifyInput): DiscriminationResu
|
|
|
876
873
|
const primaryAction: PrimaryAction =
|
|
877
874
|
aggregate === 'CANNOT_ISOLATE' && missingRow ? 'map-a-test' : ACTION_OF[aggregate];
|
|
878
875
|
|
|
879
|
-
return { perTest, aggregate,
|
|
876
|
+
return { perTest, aggregate, findings, measurementValid, primaryAction };
|
|
880
877
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { isAbsolute, relative, sep } from 'node:path';
|
|
2
|
+
|
|
3
|
+
export type InstrumentFreshness = 'same' | 'stale' | 'unknown';
|
|
4
|
+
|
|
5
|
+
export interface InstrumentCheckInput {
|
|
6
|
+
/** realpath of the running binary, or null when it could not be resolved. */
|
|
7
|
+
readonly binPath: string | null;
|
|
8
|
+
/** version from the package.json that owns binPath, or null. */
|
|
9
|
+
readonly binVersion: string | null;
|
|
10
|
+
/** version from packages/@dzhechkov/harness-cli/package.json, or null outside the monorepo. */
|
|
11
|
+
readonly treeVersion: string | null;
|
|
12
|
+
/** absolute, realpath'd project root. */
|
|
13
|
+
readonly projectRoot: string;
|
|
14
|
+
/**
|
|
15
|
+
* Whether `projectRoot` above really IS realpath'd. The caller resolves it and falls back to a
|
|
16
|
+
* plain resolve when that throws; with a symlinked root that fallback compares a realpath'd
|
|
17
|
+
* binary against a non-realpath'd root, and an IN-TREE binary then looks external. Containment is
|
|
18
|
+
* undecidable in that state, so it is answered `unknown` rather than guessed either way.
|
|
19
|
+
* Named by independent review (Claude Sonnet, 2026-09-20).
|
|
20
|
+
*/
|
|
21
|
+
readonly projectRootRealpathed: boolean;
|
|
22
|
+
readonly isMonorepo: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `level` is the WHOLE verdict — the caller renders it and never re-derives one of its own. That is
|
|
27
|
+
* deliberate: the first wiring of this module answered `unknown` with `ok: false` while this decider
|
|
28
|
+
* answered `ok`, and two answers to one question is the defect class this repo pays for most often.
|
|
29
|
+
*
|
|
30
|
+
* Three values, because two would lie: `ok` (the instrument is the tree's, or the check does not
|
|
31
|
+
* apply here), `warn` (measured stale — worth saying loudly, never worth failing a health command
|
|
32
|
+
* that gates other people's CI), `unknown` (the evidence could not be gathered — never rendered as
|
|
33
|
+
* a pass, and never as a failure either, since absence of evidence is not a defect).
|
|
34
|
+
*/
|
|
35
|
+
export interface InstrumentCheckResult {
|
|
36
|
+
readonly freshness: InstrumentFreshness;
|
|
37
|
+
readonly level: 'ok' | 'warn' | 'unknown';
|
|
38
|
+
readonly detail: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RankingStateCheckInput {
|
|
42
|
+
readonly flagOn: boolean;
|
|
43
|
+
/** absolute path the state was looked for at. */
|
|
44
|
+
readonly statePath: string;
|
|
45
|
+
readonly stateExists: boolean;
|
|
46
|
+
/** resolved binary path, for the detail — null when unknown. */
|
|
47
|
+
readonly binPath: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Ranking state has no freshness concept, so it deliberately has its own result type. */
|
|
51
|
+
export interface RankingStateCheckResult {
|
|
52
|
+
readonly level: 'ok' | 'warn';
|
|
53
|
+
readonly detail: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Strip semver build metadata: `0.8.32+a1b2c3` and `0.8.32` are the same release. */
|
|
57
|
+
function withoutBuildMetadata(version: string): string {
|
|
58
|
+
const plus = version.indexOf('+');
|
|
59
|
+
return plus < 0 ? version : version.slice(0, plus);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isInsideRoot(candidate: string, root: string): boolean {
|
|
63
|
+
const fromRoot = relative(root, candidate);
|
|
64
|
+
return fromRoot === '' || (!isAbsolute(fromRoot) && fromRoot !== '..' && !fromRoot.startsWith(`..${sep}`));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Decide whether the executable answering `dz doctor` is the workspace's current instrument.
|
|
69
|
+
*
|
|
70
|
+
* LIMITS NAMED BY INDEPENDENT REVIEW (Claude Sonnet, 2026-09-20), none of them hidden behind a
|
|
71
|
+
* passing test:
|
|
72
|
+
* - Containment is a case-SENSITIVE path comparison. On a case-insensitive filesystem, or where the
|
|
73
|
+
* same location is reachable under two path forms, an in-tree binary can read as external. This
|
|
74
|
+
* repo runs on Linux; the cost of being wrong is one extra `warn` line and never an exit code.
|
|
75
|
+
* - The caller attributes a version by walking up from the binary to the NEAREST `package.json`.
|
|
76
|
+
* A shim in package A that loads package B's code is attributed to A, and a broken install with
|
|
77
|
+
* no own manifest is attributed to whatever ancestor has one. The detail always prints the
|
|
78
|
+
* resolved binary path so a reader can see which file was actually measured.
|
|
79
|
+
*/
|
|
80
|
+
export function checkInstrumentFreshness(input: InstrumentCheckInput): InstrumentCheckResult {
|
|
81
|
+
const binary = input.binPath ?? '(unresolved)';
|
|
82
|
+
const binaryVersion = input.binVersion ?? 'unknown';
|
|
83
|
+
const treeVersion = input.treeVersion ?? 'unknown';
|
|
84
|
+
|
|
85
|
+
if (!input.isMonorepo) {
|
|
86
|
+
return {
|
|
87
|
+
freshness: 'unknown',
|
|
88
|
+
level: 'ok',
|
|
89
|
+
detail: `not applicable in a consumer project: no packages/@dzhechkov tree version to compare; resolved binary ${binary}; binary version ${binaryVersion}; tree version ${treeVersion}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (input.binPath !== null && input.projectRootRealpathed && isInsideRoot(input.binPath, input.projectRoot)) {
|
|
94
|
+
return {
|
|
95
|
+
freshness: 'same',
|
|
96
|
+
level: 'ok',
|
|
97
|
+
detail: `resolved binary ${input.binPath} is inside project root ${input.projectRoot}; binary version ${binaryVersion}; tree version ${treeVersion}; this binary is the tree instrument`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!input.projectRootRealpathed) {
|
|
102
|
+
return {
|
|
103
|
+
freshness: 'unknown',
|
|
104
|
+
level: 'unknown',
|
|
105
|
+
detail: `project root ${input.projectRoot} could not be resolved through its symlinks, so it cannot be told whether the answering binary is the tree's own; version could not be determined safely; resolved binary ${binary}; binary version ${binaryVersion}; tree version ${treeVersion}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (input.binPath === null || input.binVersion === null || input.treeVersion === null) {
|
|
110
|
+
return {
|
|
111
|
+
freshness: 'unknown',
|
|
112
|
+
level: 'unknown',
|
|
113
|
+
detail: `instrument version could not be determined; resolved binary ${binary}; binary version ${binaryVersion}; tree version ${treeVersion}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Semver says build metadata after `+` does not participate in equality, so a pipeline that
|
|
118
|
+
// stamps a commit hash onto the version must not read as a stale instrument.
|
|
119
|
+
if (withoutBuildMetadata(input.binVersion) === withoutBuildMetadata(input.treeVersion)) {
|
|
120
|
+
return {
|
|
121
|
+
freshness: 'same',
|
|
122
|
+
level: 'ok',
|
|
123
|
+
detail: `resolved binary ${input.binPath}; binary version ${input.binVersion}; tree version ${input.treeVersion}; versions match`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
freshness: 'stale',
|
|
129
|
+
level: 'warn',
|
|
130
|
+
detail: `resolved binary ${input.binPath}; binary version ${input.binVersion}; tree version ${input.treeVersion}; the answering instrument is stale`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Decide whether enabled bandit re-ranking has the on-disk state needed to operate. */
|
|
135
|
+
export function checkRankingState(input: RankingStateCheckInput): RankingStateCheckResult {
|
|
136
|
+
const binary = input.binPath ?? '(unresolved)';
|
|
137
|
+
if (!input.flagOn) {
|
|
138
|
+
return {
|
|
139
|
+
level: 'ok',
|
|
140
|
+
detail: `bandit re-ranking feature is off; no state is expected at ${input.statePath}; answering binary ${binary}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (input.stateExists) {
|
|
144
|
+
return {
|
|
145
|
+
level: 'ok',
|
|
146
|
+
detail: `bandit re-ranking is on and state is present at ${input.statePath}; answering binary ${binary}`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
level: 'warn',
|
|
151
|
+
detail: `bandit re-ranking is on but state is absent at ${input.statePath}; answering binary ${binary}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/feature-tier.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import type { FeatureTier } from './guard-volume.js';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Начало строки, на котором тир ещё считается ОБЪЯВЛЕННЫМ, а не упомянутым в прозе: необязательный
|
|
5
|
+
* маркер списка (`-`, `*`, `+`, `1.`), необязательный заголовок и необязательное выделение.
|
|
6
|
+
*
|
|
7
|
+
* Маркер списка добавлен 2026-09-21 по измерению: `00_complexity_assessment.md` фичи
|
|
8
|
+
* `amendment-seams` несёт строку `- **Tier: S** — …`, и парсер её НЕ видел. Следствие было не
|
|
9
|
+
* косметическим: `dz contract-check --slug amendment-seams` отвечал NOT-ESTABLISHED с диагнозом
|
|
10
|
+
* «required ADR directory cannot be resolved», хотя в самом приборе есть верная ветка «тиру S
|
|
11
|
+
* каталог ADR не требуется» — она просто никогда не исполнялась, потому что тир читался как
|
|
12
|
+
* неизвестный. То есть вердикт называл не ту причину и отправлял чинить не то (бэклог 5d436aa6).
|
|
13
|
+
*/
|
|
14
|
+
const TIER_LINE_START = /^\s*(?:[-*+]\s+|\d+[.)]\s+)?(?:##+\s*)?(?:\*\*)?(?:Tier|Тир)(?=$|[\s:*])/iu;
|
|
15
|
+
|
|
3
16
|
function tiersAfterMarkers(line: string): FeatureTier[] {
|
|
4
|
-
if (
|
|
17
|
+
if (!TIER_LINE_START.test(line)) return [];
|
|
5
18
|
const tiers: FeatureTier[] = [];
|
|
6
19
|
for (const marker of line.matchAll(/Tier|Тир/giu)) {
|
|
7
20
|
const markerIndex = marker.index ?? 0;
|