@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.
Files changed (135) hide show
  1. package/.dz-manifest.json +194 -114
  2. package/README.md +43 -8
  3. package/dist/__tests__/golden-baseline.test.js +1 -1
  4. package/dist/__tests__/golden-baseline.test.js.map +1 -1
  5. package/dist/agentdb-index.js +3 -3
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/agentdb-reindex-marker.d.ts +2 -2
  8. package/dist/agentdb-reindex-marker.js +4 -4
  9. package/dist/agentdb-reindex-marker.js.map +1 -1
  10. package/dist/agentdb-snapshot-rotation.d.ts +1 -1
  11. package/dist/agentdb-snapshot-rotation.js +1 -1
  12. package/dist/backup-freshness.d.ts +32 -0
  13. package/dist/backup-freshness.d.ts.map +1 -0
  14. package/dist/backup-freshness.js +69 -0
  15. package/dist/backup-freshness.js.map +1 -0
  16. package/dist/cmd-usage.d.ts +2 -2
  17. package/dist/cmd-usage.d.ts.map +1 -1
  18. package/dist/cmd-usage.js +8 -6
  19. package/dist/cmd-usage.js.map +1 -1
  20. package/dist/codex-hooks-assets.d.ts +2 -1
  21. package/dist/codex-hooks-assets.d.ts.map +1 -1
  22. package/dist/codex-hooks-assets.js +15 -2
  23. package/dist/codex-hooks-assets.js.map +1 -1
  24. package/dist/compounding.d.ts +43 -1
  25. package/dist/compounding.d.ts.map +1 -1
  26. package/dist/compounding.js +96 -0
  27. package/dist/compounding.js.map +1 -1
  28. package/dist/discrimination-gate.d.ts +0 -3
  29. package/dist/discrimination-gate.d.ts.map +1 -1
  30. package/dist/discrimination-gate.js +2 -2
  31. package/dist/discrimination-gate.js.map +1 -1
  32. package/dist/guard.d.ts +22 -2
  33. package/dist/guard.d.ts.map +1 -1
  34. package/dist/guard.js +102 -14
  35. package/dist/guard.js.map +1 -1
  36. package/dist/index.d.ts +15 -6
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +11 -4
  39. package/dist/index.js.map +1 -1
  40. package/dist/integration-apply.js +2 -2
  41. package/dist/integration-apply.js.map +1 -1
  42. package/dist/lesson-payoff.js +3 -3
  43. package/dist/lesson-payoff.js.map +1 -1
  44. package/dist/named-lock.d.ts +5 -2
  45. package/dist/named-lock.d.ts.map +1 -1
  46. package/dist/named-lock.js +30 -10
  47. package/dist/named-lock.js.map +1 -1
  48. package/dist/operations.d.ts.map +1 -1
  49. package/dist/operations.js +24 -0
  50. package/dist/operations.js.map +1 -1
  51. package/dist/pack-inventory.d.ts +51 -0
  52. package/dist/pack-inventory.d.ts.map +1 -0
  53. package/dist/pack-inventory.js +306 -0
  54. package/dist/pack-inventory.js.map +1 -0
  55. package/dist/package-skill-layouts.d.ts +3 -2
  56. package/dist/package-skill-layouts.d.ts.map +1 -1
  57. package/dist/package-skill-layouts.js +3 -2
  58. package/dist/package-skill-layouts.js.map +1 -1
  59. package/dist/patterns.d.ts +8 -0
  60. package/dist/patterns.d.ts.map +1 -1
  61. package/dist/patterns.js +11 -1
  62. package/dist/patterns.js.map +1 -1
  63. package/dist/publish.d.ts +29 -7
  64. package/dist/publish.d.ts.map +1 -1
  65. package/dist/publish.js +92 -15
  66. package/dist/publish.js.map +1 -1
  67. package/dist/registry.d.ts.map +1 -1
  68. package/dist/registry.js +24 -20
  69. package/dist/registry.js.map +1 -1
  70. package/dist/reqe-verdict.d.ts +55 -0
  71. package/dist/reqe-verdict.d.ts.map +1 -0
  72. package/dist/reqe-verdict.js +173 -0
  73. package/dist/reqe-verdict.js.map +1 -0
  74. package/dist/reqe.d.ts +35 -14
  75. package/dist/reqe.d.ts.map +1 -1
  76. package/dist/reqe.js +85 -32
  77. package/dist/reqe.js.map +1 -1
  78. package/dist/round.d.ts +10 -0
  79. package/dist/round.d.ts.map +1 -1
  80. package/dist/round.js +7 -0
  81. package/dist/round.js.map +1 -1
  82. package/dist/session-retro.d.ts +1 -1
  83. package/dist/session-retro.js +3 -3
  84. package/dist/session-retro.js.map +1 -1
  85. package/dist/skill-drift.d.ts +18 -1
  86. package/dist/skill-drift.d.ts.map +1 -1
  87. package/dist/skill-drift.js +46 -13
  88. package/dist/skill-drift.js.map +1 -1
  89. package/dist/statusline.js +2 -2
  90. package/dist/statusline.js.map +1 -1
  91. package/dist/store-guard.js +3 -3
  92. package/dist/store-guard.js.map +1 -1
  93. package/dist/store-lock.d.ts +9 -0
  94. package/dist/store-lock.d.ts.map +1 -1
  95. package/dist/store-lock.js +22 -3
  96. package/dist/store-lock.js.map +1 -1
  97. package/dist/test-receipt.d.ts +66 -0
  98. package/dist/test-receipt.d.ts.map +1 -0
  99. package/dist/test-receipt.js +73 -0
  100. package/dist/test-receipt.js.map +1 -0
  101. package/dist/workflow-run.d.ts.map +1 -1
  102. package/dist/workflow-run.js +7 -8
  103. package/dist/workflow-run.js.map +1 -1
  104. package/package.json +1 -1
  105. package/sbom.json +313 -113
  106. package/src/__tests__/golden-baseline.test.ts +1 -1
  107. package/src/agentdb-index.ts +3 -3
  108. package/src/agentdb-reindex-marker.ts +4 -4
  109. package/src/agentdb-snapshot-rotation.ts +1 -1
  110. package/src/backup-freshness.ts +96 -0
  111. package/src/cmd-usage.ts +7 -6
  112. package/src/codex-hooks-assets.ts +15 -2
  113. package/src/compounding.ts +132 -1
  114. package/src/discrimination-gate.ts +2 -5
  115. package/src/guard.ts +104 -17
  116. package/src/index.ts +23 -5
  117. package/src/integration-apply.ts +2 -2
  118. package/src/lesson-payoff.ts +3 -3
  119. package/src/named-lock.ts +36 -11
  120. package/src/operations.ts +24 -0
  121. package/src/pack-inventory.ts +313 -0
  122. package/src/package-skill-layouts.ts +3 -2
  123. package/src/patterns.ts +18 -1
  124. package/src/publish.ts +101 -22
  125. package/src/registry.ts +23 -17
  126. package/src/reqe-verdict.ts +174 -0
  127. package/src/reqe.ts +111 -35
  128. package/src/round.ts +18 -0
  129. package/src/session-retro.ts +3 -3
  130. package/src/skill-drift.ts +66 -12
  131. package/src/statusline.ts +2 -2
  132. package/src/store-guard.ts +3 -3
  133. package/src/store-lock.ts +20 -3
  134. package/src/test-receipt.ts +106 -0
  135. package/src/workflow-run.ts +7 -8
@@ -0,0 +1,174 @@
1
+ export type ReqeSeverity = 'BLOCKER' | 'HIGH' | 'MEDIUM' | 'LOW' | 'unknown';
2
+ export interface ReqeSeverityCounts {
3
+ readonly blocker: number; readonly high: number; readonly medium: number;
4
+ readonly low: number; readonly unknown: number;
5
+ }
6
+ /** `where` is null when the location cell is an em dash — the title alone is echoed (ADR AM-5). */
7
+ export interface ReqeBlockingRow {
8
+ readonly severity: 'BLOCKER' | 'HIGH'; readonly title: string; readonly where: string | null;
9
+ }
10
+ /** THREE states, never two (03.5 C5b): `absent` = no Findings section; `rejected` = section present,
11
+ * table not canonical, reason NAMED; `accepted` = parsed. */
12
+ export type ReqeFindingsClass =
13
+ | { readonly kind: 'accepted'; readonly counts: ReqeSeverityCounts; readonly rows: number;
14
+ readonly declared: number | null; readonly blocking: readonly ReqeBlockingRow[] }
15
+ | { readonly kind: 'rejected'; readonly reason: string }
16
+ | { readonly kind: 'absent' };
17
+ export type ReqePriorVerdict =
18
+ | { readonly kind: 'closed'; readonly closed: number; readonly total: number }
19
+ | { readonly kind: 'open'; readonly open: readonly number[]; readonly total: number }
20
+ | { readonly kind: 'unassessed' };
21
+ export interface ReqeVerdict {
22
+ readonly new: 'ready' | 'blocked' | 'unassessed';
23
+ readonly prior: ReqePriorVerdict;
24
+ readonly findings: ReqeFindingsClass;
25
+ readonly counts: ReqeSeverityCounts; // all zeros unless findings.kind === 'accepted'
26
+ readonly rows: number; // 0 unless accepted
27
+ readonly blocking: readonly ReqeBlockingRow[]; // [] unless accepted
28
+ readonly source: 'reviewer-declared'; // FR-6: the declarer is named in the data too
29
+ }
30
+
31
+ const emptyCounts = (): ReqeSeverityCounts => ({ blocker: 0, high: 0, medium: 0, low: 0, unknown: 0 });
32
+ const heading = /^#{1,6}[ \t]+/;
33
+ const findingsHeading = /^#{1,6}[ \t]*Findings(?:[ \t]+\((\d+)\))?[ \t]*$/;
34
+ const expectedTable = 'expected severity plus finding/title and location/where columns';
35
+
36
+ /** qe-findings.ts does not export maskMarkdown. This local fallback blanks fences and
37
+ * HTML comments without moving source lines, as permitted by the TASK-1 contract. */
38
+ function maskMarkdown(text: string): string {
39
+ let fence: string | null = null;
40
+ let inComment = false;
41
+ return text.split('\n').map((line) => {
42
+ if (fence !== null) {
43
+ const close = /^ {0,3}(`{3,}|~{3,})[ \t]*\r?$/.exec(line)?.[1];
44
+ if (close && close[0] === fence[0] && close.length >= fence.length) fence = null;
45
+ return ' '.repeat(line.length);
46
+ }
47
+ let visible = '';
48
+ for (let i = 0; i < line.length;) {
49
+ if (inComment) {
50
+ const end = line.indexOf('-->', i);
51
+ if (end === -1) { visible += ' '.repeat(line.length - i); break; }
52
+ visible += ' '.repeat(end + 3 - i);
53
+ i = end + 3;
54
+ inComment = false;
55
+ } else {
56
+ const start = line.indexOf('<!--', i);
57
+ if (start === -1) { visible += line.slice(i); break; }
58
+ visible += line.slice(i, start) + ' ';
59
+ i = start + 4;
60
+ inComment = true;
61
+ }
62
+ }
63
+ const open = /^ {0,3}(`{3,}|~{3,})/.exec(visible)?.[1];
64
+ if (open) { fence = open; return ' '.repeat(line.length); }
65
+ return visible;
66
+ }).join('\n');
67
+ }
68
+
69
+ function cells(line: string): string[] {
70
+ const parts = line.trim().split(/(?<!\\)\|/);
71
+ if (parts[0] === '') parts.shift();
72
+ if (parts[parts.length - 1] === '') parts.pop();
73
+ return parts.map((cell) => cell.replace(/\\\|/g, '|').trim());
74
+ }
75
+
76
+ function severityOf(cell: string): ReqeSeverity {
77
+ switch (cell.trim().toLowerCase()) {
78
+ case 'blocker': case 'critical': return 'BLOCKER';
79
+ case 'high': case 'major': return 'HIGH';
80
+ case 'medium': case 'med': return 'MEDIUM';
81
+ case 'low': case 'minor': return 'LOW';
82
+ default: return 'unknown';
83
+ }
84
+ }
85
+
86
+ /** Read only the single Findings section; malformed attempts carry an explicit reason. */
87
+ export function classifyReqeFindings(text: string): ReqeFindingsClass {
88
+ const lines = maskMarkdown(text).split(/\r?\n/);
89
+ const sections = lines.flatMap((line, index) => {
90
+ const match = findingsHeading.exec(line);
91
+ return match ? [{ index, declared: match[1] === undefined ? null : Number(match[1]) }] : [];
92
+ });
93
+ if (sections.length === 0) return { kind: 'absent' };
94
+ if (sections.length !== 1) return { kind: 'rejected', reason: `ambiguous: ${sections.length} Findings headings` };
95
+ const section = sections[0]!;
96
+ let end = section.index + 1;
97
+ while (end < lines.length && !heading.test(lines[end]!)) end++;
98
+ let start = section.index + 1;
99
+ while (start < end && !/(?<!\\)\|/.test(lines[start]!)) start++;
100
+ if (start === end) {
101
+ if (section.declared === 0) {
102
+ return { kind: 'accepted', counts: emptyCounts(), rows: 0, declared: 0, blocking: [] };
103
+ }
104
+ return { kind: 'rejected', reason: `no canonical table; ${expectedTable}` };
105
+ }
106
+ const headers = cells(lines[start]!).map((cell) => cell.toLowerCase());
107
+ const severityIndex = headers.indexOf('severity');
108
+ if (severityIndex === -1) return { kind: 'rejected', reason: `no severity column; ${expectedTable}; parsed columns: ${headers.join(', ')}` };
109
+ if (headers.lastIndexOf('severity') !== severityIndex) return { kind: 'rejected', reason: 'ambiguous: multiple severity columns' };
110
+ const titleIndex = headers.findIndex((cell) => cell === 'finding' || cell === 'title');
111
+ const whereIndex = headers.findIndex((cell) => cell === 'location' || cell === 'where');
112
+ if (titleIndex === -1 || whereIndex === -1) return { kind: 'rejected', reason: `missing finding/title or location/where column; ${expectedTable}` };
113
+ const delimiter = start + 1 < end ? cells(lines[start + 1]!) : [];
114
+ if (delimiter.length !== headers.length || !delimiter.every((cell) => /^:?-{3,}:?$/.test(cell))) {
115
+ return { kind: 'rejected', reason: `row ${start + 2}: invalid delimiter; expected ${headers.length} delimiter cells for columns: ${headers.join(', ')}` };
116
+ }
117
+ const counts = { ...emptyCounts() };
118
+ const blocking: ReqeBlockingRow[] = [];
119
+ let rows = 0;
120
+ let finished = false;
121
+ for (let index = start + 2; index < end; index++) {
122
+ const line = lines[index]!;
123
+ if (!/(?<!\\)\|/.test(line)) { finished = true; continue; }
124
+ if (finished) return { kind: 'rejected', reason: `row ${index + 1}: ambiguous table continuation` };
125
+ const row = cells(line);
126
+ if (row.length !== headers.length) {
127
+ return { kind: 'rejected', reason: `row ${index + 1}: ${row.length} cells for ${headers.length} columns (unescaped pipe?); parsed columns: ${headers.join(', ')}` };
128
+ }
129
+ if (row.every((cell) => /^:?-{3,}:?$/.test(cell))) return { kind: 'rejected', reason: `row ${index + 1}: ambiguous additional table delimiter` };
130
+ const severity = severityOf(row[severityIndex]!);
131
+ const key = severity.toLowerCase() as keyof ReqeSeverityCounts;
132
+ counts[key]++;
133
+ rows++;
134
+ if (severity === 'BLOCKER' || severity === 'HIGH') {
135
+ const where = row[whereIndex]!;
136
+ blocking.push({ severity, title: row[titleIndex]!, where: where === '—' || where === '' ? null : where });
137
+ }
138
+ }
139
+ return { kind: 'accepted', counts, rows, declared: section.declared, blocking };
140
+ }
141
+
142
+ /** Independent pass: no classification or new-findings state participates in prior status. */
143
+ export function parsePriorFindings(text: string): ReqePriorVerdict {
144
+ const lines = maskMarkdown(text).split(/\r?\n/);
145
+ const sections = lines.flatMap((line, index) => /^#{1,6}[ \t]*Prior findings[ \t]*$/.test(line) ? [index] : []);
146
+ if (sections.length !== 1) return { kind: 'unassessed' };
147
+ let total = 0;
148
+ const open: number[] = [];
149
+ for (let index = sections[0]! + 1; index < lines.length; index++) {
150
+ const line = lines[index]!;
151
+ if (heading.test(line)) break;
152
+ const match = /^[ \t]*(\d+):[ \t]+(closed|open)[ \t]+—[ \t]+\S.*$/.exec(line);
153
+ if (!match) continue;
154
+ total++;
155
+ if (match[2] === 'open') open.push(Number(match[1]));
156
+ }
157
+ if (total === 0) return { kind: 'unassessed' };
158
+ return open.length > 0 ? { kind: 'open', open, total } : { kind: 'closed', closed: total, total };
159
+ }
160
+
161
+ /** The only blocking rule; prior status is reported independently. */
162
+ export function buildReqeVerdict(text: string): ReqeVerdict {
163
+ const findings = classifyReqeFindings(text);
164
+ const prior = parsePriorFindings(text);
165
+ const accepted = findings.kind === 'accepted';
166
+ const counts = accepted ? findings.counts : emptyCounts();
167
+ return {
168
+ new: accepted ? (counts.blocker >= 1 || counts.high >= 1 ? 'blocked' : 'ready') : 'unassessed',
169
+ prior, findings, counts,
170
+ rows: accepted ? findings.rows : 0,
171
+ blocking: accepted ? findings.blocking : [],
172
+ source: 'reviewer-declared',
173
+ };
174
+ }
package/src/reqe.ts CHANGED
@@ -9,8 +9,8 @@
9
9
  * DEBT with a lifecycle:
10
10
  *
11
11
  * emit — the workflow records features/<slug>/.fa-state/reqe-due.json when Step-8 actually ran
12
- * same-family under the override (not on every switch — a switch before Step 8 that
13
- * still got cross-family QE creates no debt);
12
+ * same-family after a usage switch, failed probe, fallback or explicit QE pin;
13
+ * a review that stayed cross-family creates no debt;
14
14
  * list — `dz reqe` scans the debts; `dz usage` surfaces the count so the moment limits free up
15
15
  * is the moment the debt is visible;
16
16
  * brief — `dz reqe --slug <s>` prints a ready cross-family review brief (the OTHER family than
@@ -18,19 +18,37 @@
18
18
  * settle — `dz reqe --slug <s> --done --report <file>` clears the debt FAIL-CLOSED: only against
19
19
  * an existing, non-trivial report that names a grade; the settlement is appended to
20
20
  * 08_qe_report.md so the artifact trail closes.
21
+ * The two-part verdict reports new and prior findings separately; blocked new findings signal CLI exit 3.
21
22
  *
22
23
  * HONEST SCOPE: nothing here re-runs QE automatically (no background spend — the human decides);
23
- * the same-family CLAUDE belt fallback (codex unavailable) is out of scope by design — it is
24
- * already logged loudly at run time and is not a limit-pressure artifact; runs from before this
25
- * feature carry no marker and are UNDETERMINABLE, not debt-free.
24
+ * routing-off runs are excluded by configuration. Old checkpoints without bridge facts or a usage
25
+ * marker are UNDETERMINABLE and explicitly degraded.
26
26
  */
27
27
 
28
+ import { buildReqeVerdict, type ReqeVerdict } from './reqe-verdict.js';
29
+
28
30
  export const REQE_SCHEMA = 'reqe-due-1';
29
31
 
30
32
  export const REQE_SCOPE =
31
- 'scope: debt is emitted only when Step-8 QE actually ran on the coder’s own family under the ' +
32
- 'usage override; settling requires a graded cross-family report (fail-closed); nothing re-runs QE ' +
33
- 'automatically.';
33
+ 'scope: same-family Step-8 QE records cause=usage-switched, probe-failed, same-family-fallback ' +
34
+ 'or same-family-pinned; routing OFF is excluded by configuration; settling requires a graded ' +
35
+ 'cross-family report (fail-closed); nothing re-runs QE automatically.';
36
+
37
+ export type ReqeCause = 'usage-switched' | 'probe-failed' | 'same-family-fallback' | 'same-family-pinned';
38
+ export type ReqeRungState = 'pending' | 'dispatched' | 'probe-failed' | 'refused-before-dispatch';
39
+ export interface ReqeBridgeFacts {
40
+ happened: boolean | null;
41
+ rungState: ReqeRungState | null;
42
+ rungReason: string | null;
43
+ decline: string | null;
44
+ }
45
+ export interface ReqeEmitInput {
46
+ coderUsed: string | null | undefined;
47
+ qeReviewerUsed: string | null | undefined;
48
+ qeModelLabel: string | null | undefined;
49
+ routingRequested: boolean | null | undefined;
50
+ bridge?: ReqeBridgeFacts | null;
51
+ }
34
52
 
35
53
  export type ModelFamily = 'claude' | 'openai';
36
54
 
@@ -46,35 +64,57 @@ export function modelFamily(spec: string | null | undefined): ModelFamily {
46
64
  export interface ReqeEmitDecision {
47
65
  emit: boolean;
48
66
  reason: string;
67
+ cause: ReqeCause | null;
68
+ degraded: boolean;
49
69
  }
50
70
 
51
- /** Emit iff the QE stage label carries the workflow's ' (usage-switched)' marker AND the reviewer
52
- * family equals the coder family. Marker-only (cross-family survived the switch) or same-family
53
- * WITHOUT the marker (the codex-unavailable Claude belt — degraded loudly at run time, not a
54
- * limit-pressure artifact) both create NO debt. */
55
- export function shouldEmitReqeDebt(input: {
56
- coderUsed: string | null | undefined;
57
- qeReviewerUsed: string | null | undefined;
58
- qeModelLabel: string | null | undefined;
59
- }): ReqeEmitDecision {
60
- const switched = /\(usage-switched\)/.test(String(input.qeModelLabel ?? ''));
71
+ /** Families are authoritative; routing OFF is an explicit exclusion. For same-family reviews,
72
+ * classify usage overrides before probe failures, fallbacks and explicit pins. Missing checkpoint
73
+ * facts and unknown rung states stay visibly degraded rather than inventing a cause. */
74
+ export function shouldEmitReqeDebt(input: ReqeEmitInput): ReqeEmitDecision {
61
75
  const coderFam = modelFamily(input.coderUsed);
62
76
  const qeFam = modelFamily(input.qeReviewerUsed);
63
- if (switched && coderFam === qeFam) {
77
+ if (coderFam !== qeFam) {
78
+ return { emit: false, cause: null, degraded: false, reason: 'cross-family QE ran' };
79
+ }
80
+ if (input.routingRequested === false) {
81
+ return { emit: false, cause: null, degraded: false,
82
+ reason: 'same-family by configuration (cross-family never requested)' };
83
+ }
84
+ if (/\(usage-switched\)/.test(String(input.qeModelLabel ?? ''))) {
64
85
  return {
65
- emit: true,
86
+ emit: true, cause: 'usage-switched', degraded: false,
66
87
  reason:
67
88
  'usage-switched self-review: Step-8 QE ran on the coder’s own family (' + coderFam +
68
89
  ') under the limit override — the cross-model guard was suspended (FR-2.9)',
69
90
  };
70
91
  }
71
- if (switched) return { emit: false, reason: 'usage-switched but QE stayed cross-family (' + qeFam + ' vs coder ' + coderFam + ')' };
72
- if (coderFam === qeFam) return { emit: false, reason: 'same-family without the usage override (belt degrade — logged loudly at run time; out of re-QE-debt scope)' };
73
- return { emit: false, reason: 'cross-family QE ran' };
92
+ if (input.bridge?.rungState === 'probe-failed') {
93
+ return { emit: true, cause: 'probe-failed', degraded: false,
94
+ reason: 'codex probe found no usable id; review ran on the coder’s own family (' + coderFam + ')' };
95
+ }
96
+ if (input.bridge?.rungState === 'dispatched' || input.bridge?.rungState === 'refused-before-dispatch' ||
97
+ input.qeReviewerUsed === 'codex-fallback') {
98
+ return { emit: true, cause: 'same-family-fallback', degraded: false,
99
+ reason: 'same-family fallback: ' + (input.bridge?.decline ?? input.bridge?.rungReason ?? 'cross-family reviewer unavailable') +
100
+ '; review ran on the coder’s own family (' + coderFam + ')' };
101
+ }
102
+ if (input.bridge == null) {
103
+ return { emit: false, cause: null, degraded: true,
104
+ reason: "cause undeterminable (pre-change checkpoint) — re-run with resume:'never' to classify" };
105
+ }
106
+ if (input.routingRequested === true && input.bridge.rungState === 'pending') {
107
+ return { emit: true, cause: 'same-family-pinned', degraded: false,
108
+ reason: 'same-family by explicit qe pin (args.models.qe) while cross-family routing was requested' };
109
+ }
110
+ return { emit: false, cause: null, degraded: true,
111
+ reason: 'cause undeterminable (unknown rung state or routing request)' };
74
112
  }
75
113
 
76
114
  export interface ReqeDebt {
77
115
  schema: typeof REQE_SCHEMA;
116
+ cause: ReqeCause;
117
+ bridge?: ReqeBridgeFacts | null;
78
118
  slug: string;
79
119
  coderFamily: ModelFamily;
80
120
  qeFamily: ModelFamily;
@@ -97,15 +137,21 @@ export function buildReqeDebt(input: {
97
137
  qeGrade: string | null | undefined;
98
138
  reason: string;
99
139
  emittedAt?: string | null;
140
+ cause: ReqeCause;
141
+ bridge?: ReqeBridgeFacts | null;
142
+ runStamp?: string | null;
100
143
  }): ReqeDebt {
101
144
  return {
102
145
  schema: REQE_SCHEMA,
146
+ cause: input.cause,
147
+ ...(input.bridge === undefined ? {} : { bridge: input.bridge }),
103
148
  slug: input.slug,
104
149
  coderFamily: modelFamily(input.coderUsed),
105
150
  qeFamily: modelFamily(input.qeReviewerUsed),
106
151
  qeGrade: input.qeGrade == null || String(input.qeGrade).trim() === '' ? null : String(input.qeGrade).trim(),
107
152
  reason: input.reason,
108
153
  emittedAt: input.emittedAt ?? null,
154
+ runStamp: input.runStamp ?? null,
109
155
  };
110
156
  }
111
157
 
@@ -125,16 +171,21 @@ export function parseReqeDebt(text: string): ReqeDebt | null {
125
171
  if (d.coderFamily !== 'claude' && d.coderFamily !== 'openai') return null;
126
172
  if (d.qeFamily !== 'claude' && d.qeFamily !== 'openai') return null;
127
173
  if (typeof d.reason !== 'string' || d.reason.trim() === '') return null;
128
- return {
129
- schema: REQE_SCHEMA,
174
+ if (d.cause !== undefined && d.cause !== 'usage-switched' && d.cause !== 'probe-failed' &&
175
+ d.cause !== 'same-family-fallback' && d.cause !== 'same-family-pinned') return null;
176
+ return buildReqeDebt({
130
177
  slug: d.slug,
131
- coderFamily: d.coderFamily,
132
- qeFamily: d.qeFamily,
133
- qeGrade: typeof d.qeGrade === 'string' && d.qeGrade.trim() !== '' ? d.qeGrade.trim() : null,
178
+ coderUsed: d.coderFamily,
179
+ qeReviewerUsed: d.qeFamily,
180
+ qeGrade: typeof d.qeGrade === 'string' ? d.qeGrade : null,
134
181
  reason: d.reason,
182
+ cause: d.cause ?? 'usage-switched',
183
+ ...(d.bridge === undefined ? {} : {
184
+ bridge: d.bridge && typeof d.bridge === 'object' && !Array.isArray(d.bridge) ? d.bridge : null,
185
+ }),
135
186
  emittedAt: typeof d.emittedAt === 'string' && d.emittedAt.trim() !== '' ? d.emittedAt : null,
136
187
  runStamp: typeof d.runStamp === 'string' && d.runStamp.trim() !== '' ? d.runStamp : null,
137
- };
188
+ });
138
189
  }
139
190
 
140
191
  export interface ReqeBrief {
@@ -155,11 +206,12 @@ export function buildReqeBrief(debt: ReqeDebt, artifactsDir: string): ReqeBrief
155
206
  artifactsDir + '/03_adr/',
156
207
  ];
157
208
  const instructions = [
158
- 'Independent re-QE for "' + debt.slug + '": the recorded Step-8 review ran on the coder’s own family (' + debt.coderFamily + ') under the usage override' + (debt.qeGrade ? ' and graded ' + debt.qeGrade : '') + '.',
209
+ 'Independent re-QE for "' + debt.slug + '": the recorded Step-8 review ran on the coder’s own family (' + debt.coderFamily + '); cause=' + debt.cause + (debt.qeGrade ? ' and graded ' + debt.qeGrade : '') + '.',
159
210
  'Review with the ' + reviewFamily.toUpperCase() + ' family (the OTHER family than the coder — the suspended guard, restored).',
160
211
  'Read: ' + files.join(' , ') + ' plus every file the change manifest lists.',
161
212
  'Adversarially verify: correctness, edge cases, the ADR-named load-bearing property HAS a test, and whether the same-family review missed anything.',
162
213
  'Output: GRADE A-F + numbered findings with file:line and severity; write the report to ' + artifactsDir + '/08b_reqe_report.md.',
214
+ 'Also list every finding of the same-family 08_qe_report.md by number under a "## Prior findings" heading, one line each: <n>: closed|open — <reason>.',
163
215
  'Then settle the debt: dz reqe --slug ' + debt.slug + ' --done --report ' + artifactsDir + '/08b_reqe_report.md',
164
216
  ];
165
217
  const codexCmdTemplate = reviewFamily === 'openai'
@@ -178,6 +230,7 @@ export interface ReqeSettlement {
178
230
  error: string | null;
179
231
  grade: string | null;
180
232
  epilogue: string | null;
233
+ verdict: ReqeVerdict | null;
181
234
  }
182
235
 
183
236
  /** Extract the verdict grade from a report, or null. LINE-ANCHORED and range-proof (Codex QE #7):
@@ -212,12 +265,18 @@ export function extractReportGrade(text: string): string | null {
212
265
  export function settleReqeDebt(debt: ReqeDebt, reportText: string, reportPath: string): ReqeSettlement {
213
266
  const text = String(reportText ?? '');
214
267
  if (text.trim().length < 200) {
215
- return { ok: false, error: 'report too small to be a review (< 200 chars of substance) — refusing to settle', grade: null, epilogue: null };
268
+ return { ok: false, error: 'report too small to be a review (< 200 chars of substance) — refusing to settle', grade: null, epilogue: null, verdict: null };
216
269
  }
217
270
  const grade = extractReportGrade(text);
218
271
  if (grade === null) {
219
- return { ok: false, error: 'report names no unambiguous line-anchored GRADE (A-F) — text without exactly one verdict grade is not a verdict; refusing to settle', grade: null, epilogue: null };
272
+ return { ok: false, error: 'report names no unambiguous line-anchored GRADE (A-F) — text without exactly one verdict grade is not a verdict; refusing to settle', grade: null, epilogue: null, verdict: null };
220
273
  }
274
+ const verdict = buildReqeVerdict(text);
275
+ const { counts, findings, prior } = verdict;
276
+ const priorSummary = prior.kind === 'closed' ? `closed (${prior.closed}/${prior.total})`
277
+ : prior.kind === 'open' ? `open(${prior.open.length}) of ${prior.total}` : 'unassessed';
278
+ const mismatch = findings.kind === 'accepted' && findings.declared !== null && findings.declared !== findings.rows
279
+ ? ` (declared ${findings.declared}, parsed ${findings.rows})` : '';
221
280
  const reviewFamily: ModelFamily = debt.coderFamily === 'openai' ? 'claude' : 'openai';
222
281
  const epilogue = [
223
282
  '',
@@ -230,24 +289,41 @@ export function settleReqeDebt(debt: ReqeDebt, reportText: string, reportPath: s
230
289
  '',
231
290
  '- report: `' + reportPath + '`',
232
291
  '- re-QE grade: **' + grade + '**' + (debt.qeGrade ? ' (same-family grade on record: ' + debt.qeGrade + ')' : ''),
292
+ `- new findings: ${verdict.new} — BLOCKER ${counts.blocker}, HIGH ${counts.high}, MEDIUM ${counts.medium}, LOW ${counts.low}, unknown ${counts.unknown} (rows ${verdict.rows})${mismatch}`,
293
+ `- prior findings: ${priorSummary}`,
294
+ ...(findings.kind === 'accepted' ? [] : [
295
+ `- findings: unclassified (${findings.kind === 'absent' ? 'absent' : `rejected: ${findings.reason}`})`,
296
+ ]),
297
+ ...verdict.blocking.map((row) => `- ${row.severity} — ${row.title}${row.where === null ? '' : ` (${row.where})`}`),
298
+ '- severities and prior-finding status as declared by the reviewer; dz reqe counts them, it does not judge them.',
233
299
  '- settled via `dz reqe --done` (fail-closed: an existing graded report is required).',
234
300
  '',
235
301
  ].join('\n');
236
- return { ok: true, error: null, grade, epilogue };
302
+ return { ok: true, error: null, grade, epilogue, verdict };
237
303
  }
238
304
 
239
305
  /** Render the debt list for `dz reqe` / the `dz usage` surfacing line. */
240
306
  export function renderReqeList(debts: readonly ReqeDebt[], malformed: number): string[] {
241
307
  const lines: string[] = [];
242
308
  if (debts.length === 0 && malformed === 0) {
243
- lines.push('dz reqe: no re-QE debts — every recorded run kept cross-model QE (or none used the usage override).');
309
+ lines.push('dz reqe: no re-QE debts — no unsettled machine debts recorded.');
244
310
  } else {
245
311
  lines.push('dz reqe — ' + debts.length + ' unsettled re-QE debt(s):');
246
312
  for (const d of debts) {
247
- lines.push(' ' + d.slug + ' coder=' + d.coderFamily + ' qe=' + d.qeFamily + (d.qeGrade ? ' grade=' + d.qeGrade : '') + (d.emittedAt ? ' ' + d.emittedAt : '') + ' → dz reqe --slug ' + d.slug);
313
+ lines.push(' ' + d.slug + ' coder=' + d.coderFamily + ' qe=' + d.qeFamily + (d.qeGrade ? ' grade=' + d.qeGrade : '') + (d.emittedAt ? ' ' + d.emittedAt : '') + ' cause=' + d.cause + ' → dz reqe --slug ' + d.slug);
248
314
  }
249
315
  }
250
316
  if (malformed > 0) lines.push(' ' + malformed + ' malformed debt file(s) skipped (named, never silent).');
251
317
  lines.push(REQE_SCOPE);
252
318
  return lines;
253
319
  }
320
+
321
+
322
+ /** Stable keys, including zero counts, for the dz usage JSON output. */
323
+ export function countReqeByCause(debts: readonly ReqeDebt[]): Record<ReqeCause, number> {
324
+ const counts: Record<ReqeCause, number> = {
325
+ 'usage-switched': 0, 'probe-failed': 0, 'same-family-fallback': 0, 'same-family-pinned': 0,
326
+ };
327
+ for (const debt of debts) counts[debt.cause] += 1;
328
+ return counts;
329
+ }
package/src/round.ts CHANGED
@@ -78,6 +78,16 @@ export interface RoundLedgerRow {
78
78
  readonly reason: string | null;
79
79
  readonly round: number;
80
80
  readonly lessons: readonly string[];
81
+ /** Recall coverage for the "доведённая работа" metric: measured on 2026-09-21, 0 of 462
82
+ * ledger rows carried `recalled`, losing the shown-to-used link across 124 round rows.
83
+ * Preserve the lessons shown at open as exact IDs, deduplicated by Set and sorted; omit when empty. */
84
+ readonly recalled?: readonly string[];
85
+ /** Used lessons drawn from the shown set: `recalled` intersected with `lessons`, sorted and
86
+ * omitted when empty. Lessons obtained elsewhere do not count toward recall coverage. */
87
+ readonly recalledUsed?: readonly string[];
88
+ /** Shown lessons not used: `recalled` minus `lessons`, sorted and omitted when empty. Together
89
+ * with `recalledUsed`, preserves the denominator for the "доведённая работа" metric. */
90
+ readonly recalledUnused?: readonly string[];
81
91
  readonly noNewKnowledge: string | null;
82
92
  readonly note: string;
83
93
  readonly date: null;
@@ -568,6 +578,11 @@ export function closeRound(input: {
568
578
  ? (nonEmpty(input.shipTreeDirtyReason) ? input.shipTreeDirtyReason.trim() : 'not provided')
569
579
  : undefined;
570
580
 
581
+ const recalled = [...new Set(input.state.recalled)].sort();
582
+ const usedLessons = new Set(lessons);
583
+ const recalledUsed = recalled.filter((id) => usedLessons.has(id));
584
+ const recalledUnused = recalled.filter((id) => !usedLessons.has(id));
585
+
571
586
  const row: RoundLedgerRow = {
572
587
  slug: input.state.slug,
573
588
  stage: 'round',
@@ -584,6 +599,9 @@ export function closeRound(input: {
584
599
  reason: nonEmpty(input.reason) ? input.reason.trim() : null,
585
600
  round: input.state.round,
586
601
  lessons,
602
+ ...(recalled.length > 0 ? { recalled } : {}),
603
+ ...(recalledUsed.length > 0 ? { recalledUsed } : {}),
604
+ ...(recalledUnused.length > 0 ? { recalledUnused } : {}),
587
605
  noNewKnowledge: noNewKnowledge === '' ? null : noNewKnowledge,
588
606
  note: note === '' ? marker : `${marker} | ${note}`,
589
607
  date: null,
@@ -18,7 +18,7 @@ import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, cl
18
18
  import { join, basename, dirname } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
20
 
21
- import { withNamedLockSync, NamedLockTimeoutError } from './named-lock.js';
21
+ import { withProjectLockSync, NamedLockTimeoutError } from './named-lock.js';
22
22
 
23
23
  export interface SessionEvent {
24
24
  readonly kind: 'user' | 'assistant' | 'tool';
@@ -696,7 +696,7 @@ const writeJsonAtomic = (path: string, value: unknown): void => {
696
696
  * a partial trailing line is left for the next scan) → fold the debt → persist state + sentinel.
697
697
  * NEVER throws (it runs inside a Stop hook; a broken scan must never surface as a turn failure).
698
698
  *
699
- * The WHOLE read→fold→write runs under `withNamedLockSync` (cross-family QE P1-3): two overlapping
699
+ * The WHOLE read→fold→write runs under `withProjectLockSync` (cross-family QE P1-3): two overlapping
700
700
  * Stop scans that both read the same offset/sentinel and then rename/unlink independently lose one
701
701
  * side's update — atomic per-file renames do not prevent that, only mutual exclusion does. The
702
702
  * critical section is short and synchronous (bounded ≤8 MB read, no subprocess, 65 ms measured on
@@ -707,7 +707,7 @@ const writeJsonAtomic = (path: string, value: unknown): void => {
707
707
  export function runRetroTailScan(dzDir: string, transcriptPath: string | null, nowIso?: string): TailScanOutcome {
708
708
  if (transcriptPath === null || transcriptPath === '') return { status: 'no-transcript', scannedBytes: 0, offset: 0 };
709
709
  try {
710
- return withNamedLockSync(
710
+ return withProjectLockSync(
711
711
  dirname(dzDir),
712
712
  RETRO_SCAN_LOCK_NAME,
713
713
  () => scanTailUnderLock(dzDir, transcriptPath, nowIso),