@dzhechkov/harness-core 0.8.40 → 0.8.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.dz-manifest.json +220 -120
  2. package/README.md +65 -10
  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 +26 -2
  33. package/dist/guard.d.ts.map +1 -1
  34. package/dist/guard.js +162 -17
  35. package/dist/guard.js.map +1 -1
  36. package/dist/index.d.ts +17 -7
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +12 -5
  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 +43 -7
  64. package/dist/publish.d.ts.map +1 -1
  65. package/dist/publish.js +153 -59
  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/release-line.d.ts +15 -1
  71. package/dist/release-line.d.ts.map +1 -1
  72. package/dist/release-line.js +40 -13
  73. package/dist/release-line.js.map +1 -1
  74. package/dist/reqe-verdict.d.ts +55 -0
  75. package/dist/reqe-verdict.d.ts.map +1 -0
  76. package/dist/reqe-verdict.js +173 -0
  77. package/dist/reqe-verdict.js.map +1 -0
  78. package/dist/reqe.d.ts +35 -14
  79. package/dist/reqe.d.ts.map +1 -1
  80. package/dist/reqe.js +85 -32
  81. package/dist/reqe.js.map +1 -1
  82. package/dist/round.d.ts +10 -0
  83. package/dist/round.d.ts.map +1 -1
  84. package/dist/round.js +7 -0
  85. package/dist/round.js.map +1 -1
  86. package/dist/session-retro.d.ts +9 -6
  87. package/dist/session-retro.d.ts.map +1 -1
  88. package/dist/session-retro.js +39 -14
  89. package/dist/session-retro.js.map +1 -1
  90. package/dist/skill-drift.d.ts +18 -1
  91. package/dist/skill-drift.d.ts.map +1 -1
  92. package/dist/skill-drift.js +46 -13
  93. package/dist/skill-drift.js.map +1 -1
  94. package/dist/statusline.js +2 -2
  95. package/dist/statusline.js.map +1 -1
  96. package/dist/store-guard.js +3 -3
  97. package/dist/store-guard.js.map +1 -1
  98. package/dist/store-lock.d.ts +9 -0
  99. package/dist/store-lock.d.ts.map +1 -1
  100. package/dist/store-lock.js +22 -3
  101. package/dist/store-lock.js.map +1 -1
  102. package/dist/test-receipt.d.ts +66 -0
  103. package/dist/test-receipt.d.ts.map +1 -0
  104. package/dist/test-receipt.js +73 -0
  105. package/dist/test-receipt.js.map +1 -0
  106. package/dist/tree-sync.d.ts +5 -0
  107. package/dist/tree-sync.d.ts.map +1 -0
  108. package/dist/tree-sync.js +13 -0
  109. package/dist/tree-sync.js.map +1 -0
  110. package/dist/workflow-run.d.ts.map +1 -1
  111. package/dist/workflow-run.js +7 -8
  112. package/dist/workflow-run.js.map +1 -1
  113. package/package.json +8 -8
  114. package/sbom.json +369 -119
  115. package/src/__tests__/golden-baseline.test.ts +1 -1
  116. package/src/agentdb-index.ts +3 -3
  117. package/src/agentdb-reindex-marker.ts +4 -4
  118. package/src/agentdb-snapshot-rotation.ts +1 -1
  119. package/src/backup-freshness.ts +96 -0
  120. package/src/cmd-usage.ts +7 -6
  121. package/src/codex-hooks-assets.ts +15 -2
  122. package/src/compounding.ts +132 -1
  123. package/src/discrimination-gate.ts +2 -5
  124. package/src/guard.ts +166 -20
  125. package/src/index.ts +25 -6
  126. package/src/integration-apply.ts +2 -2
  127. package/src/lesson-payoff.ts +3 -3
  128. package/src/named-lock.ts +36 -11
  129. package/src/operations.ts +24 -0
  130. package/src/pack-inventory.ts +313 -0
  131. package/src/package-skill-layouts.ts +3 -2
  132. package/src/patterns.ts +18 -1
  133. package/src/publish.ts +168 -65
  134. package/src/registry.ts +23 -17
  135. package/src/release-line.ts +60 -16
  136. package/src/reqe-verdict.ts +174 -0
  137. package/src/reqe.ts +111 -35
  138. package/src/round.ts +18 -0
  139. package/src/session-retro.ts +54 -18
  140. package/src/skill-drift.ts +66 -12
  141. package/src/statusline.ts +2 -2
  142. package/src/store-guard.ts +3 -3
  143. package/src/store-lock.ts +20 -3
  144. package/src/test-receipt.ts +106 -0
  145. package/src/tree-sync.ts +17 -0
  146. 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';
@@ -547,6 +547,14 @@ export const RETRO_PENDING_FILE = 'retro-pending.json';
547
547
  const MAX_TAIL_SCAN_BYTES = 8 * 1024 * 1024;
548
548
  /** Without a session id to compare, a sentinel older than this is stale (fallback freshness only). */
549
549
  const SENTINEL_FRESH_MS = 30 * 60 * 1000;
550
+ const MAX_AWAITING_TEACHES = 16;
551
+
552
+ export interface AdmissionDebt {
553
+ readonly snippet: string;
554
+ /** Paired call ids issued against THIS debt, in registration order (oldest first).
555
+ * Absent, rather than an empty array, when no calls await confirmation. */
556
+ readonly awaiting?: readonly string[];
557
+ }
550
558
 
551
559
  export interface RetroPendingSentinel {
552
560
  readonly schema: 1;
@@ -554,6 +562,7 @@ export interface RetroPendingSentinel {
554
562
  readonly transcript: string; // absolute transcript path — the debt belongs to THIS session
555
563
  readonly snippet: string; // ≤200 chars around the admission marker
556
564
  readonly ts: string; // ISO time of the scan that recorded the debt
565
+ readonly awaiting?: readonly string[];
557
566
  }
558
567
 
559
568
  /** Where the Stop-hook scan got its transcript path — or why it has none. */
@@ -643,20 +652,27 @@ const RETRO_SCAN_LOCK_TIMEOUT_MS = 2_000;
643
652
  */
644
653
  export function foldAdmissionDebt(
645
654
  events: readonly SessionEvent[],
646
- prior: { snippet: string } | null,
647
- ): { snippet: string } | null {
655
+ prior: AdmissionDebt | null,
656
+ ): AdmissionDebt | null {
648
657
  let pending = prior;
649
658
  // EVERY teach issued against the live debt and still awaiting its own result, by `tool_use_id`.
650
659
  // Round 3, P1-2: a single `lastPaid` slot lost the FIRST of two parallel teaches — the second call
651
660
  // saw an already-cleared `pending` and overwrote the slot with null, so when both results came back
652
661
  // receipt-less neither could re-arm and the debt was silently forgiven. A set, and settlement moved
653
662
  // to the RECEIPT, removes the whole class: the CALL now registers a candidate and changes nothing.
654
- const awaiting = new Set<string>();
663
+ // Old or malformed sentinels mean no candidates, without skipping the rest of the scan.
664
+ const saved = prior?.awaiting;
665
+ const awaiting = new Set<string>(
666
+ Array.isArray(saved) && saved.every((id): id is string => typeof id === 'string') ? saved : [],
667
+ );
655
668
  for (const e of events) {
656
669
  if (isTeachCommand(e)) {
657
670
  if (typeof e.toolUseId === 'string') {
658
671
  // Registered, not settled. The debt stands until this call's own result carries a receipt.
659
- if (pending !== null) awaiting.add(e.toolUseId);
672
+ if (pending !== null) {
673
+ awaiting.add(e.toolUseId);
674
+ if (awaiting.size > MAX_AWAITING_TEACHES) awaiting.delete(awaiting.values().next().value!);
675
+ }
660
676
  } else {
661
677
  // No pairing key ⇒ no result can ever confirm OR refute this call, so it pays on the command
662
678
  // alone — the pre-ADR-004 behaviour, kept deliberately so every id-less fixture and every
@@ -679,10 +695,16 @@ export function foldAdmissionDebt(
679
695
  const snippet = admissionSnippet(e.text);
680
696
  // A NEW admission supersedes every teach still in flight: those calls were issued against the
681
697
  // OLDER debt, so their receipts must not settle this one (ADR-001 D4 asymmetry).
682
- if (snippet !== null) { pending = { snippet }; awaiting.clear(); }
698
+ if (snippet !== null) {
699
+ if (pending === null || pending.snippet !== snippet) awaiting.clear();
700
+ pending = { snippet };
701
+ }
683
702
  }
684
703
  }
685
- return pending;
704
+ return pending === null ? null : {
705
+ snippet: pending.snippet,
706
+ ...(awaiting.size > 0 ? { awaiting: [...awaiting] } : {}),
707
+ };
686
708
  }
687
709
 
688
710
  const writeJsonAtomic = (path: string, value: unknown): void => {
@@ -696,7 +718,7 @@ const writeJsonAtomic = (path: string, value: unknown): void => {
696
718
  * a partial trailing line is left for the next scan) → fold the debt → persist state + sentinel.
697
719
  * NEVER throws (it runs inside a Stop hook; a broken scan must never surface as a turn failure).
698
720
  *
699
- * The WHOLE read→fold→write runs under `withNamedLockSync` (cross-family QE P1-3): two overlapping
721
+ * The WHOLE read→fold→write runs under `withProjectLockSync` (cross-family QE P1-3): two overlapping
700
722
  * Stop scans that both read the same offset/sentinel and then rename/unlink independently lose one
701
723
  * side's update — atomic per-file renames do not prevent that, only mutual exclusion does. The
702
724
  * critical section is short and synchronous (bounded ≤8 MB read, no subprocess, 65 ms measured on
@@ -707,7 +729,7 @@ const writeJsonAtomic = (path: string, value: unknown): void => {
707
729
  export function runRetroTailScan(dzDir: string, transcriptPath: string | null, nowIso?: string): TailScanOutcome {
708
730
  if (transcriptPath === null || transcriptPath === '') return { status: 'no-transcript', scannedBytes: 0, offset: 0 };
709
731
  try {
710
- return withNamedLockSync(
732
+ return withProjectLockSync(
711
733
  dirname(dzDir),
712
734
  RETRO_SCAN_LOCK_NAME,
713
735
  () => scanTailUnderLock(dzDir, transcriptPath, nowIso),
@@ -735,11 +757,14 @@ function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: strin
735
757
  // Prior debt carries over ONLY for the same session; a stale sentinel (another session's debt)
736
758
  // is dropped — the PreCompact/SessionEnd retro of THAT session was its collector, and injecting
737
759
  // an old session's debt into a new one is noise (acid A9).
738
- let prior: { snippet: string } | null = null;
760
+ let prior: AdmissionDebt | null = null;
739
761
  let hadSentinel = false;
740
762
  try {
741
763
  const s = JSON.parse(readFileSync(pendingPath, 'utf8')) as Partial<RetroPendingSentinel>;
742
- if (s.transcript === transcriptPath && typeof s.snippet === 'string') { prior = { snippet: s.snippet }; hadSentinel = true; }
764
+ if (s.transcript === transcriptPath && typeof s.snippet === 'string') {
765
+ prior = { snippet: s.snippet, ...(s.awaiting !== undefined ? { awaiting: s.awaiting } : {}) };
766
+ hadSentinel = true;
767
+ }
743
768
  else { try { unlinkSync(pendingPath); } catch { /* already gone */ } }
744
769
  } catch { /* no sentinel */ }
745
770
 
@@ -778,7 +803,7 @@ function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: strin
778
803
  const next = foldAdmissionDebt(events, prior);
779
804
  const newOffset = offset + consumed;
780
805
  mkdirSync(dzDir, { recursive: true });
781
- writeJsonAtomic(statePath, { schema: 1, transcript: transcriptPath, offset: newOffset });
806
+ let outcome: TailScanOutcome;
782
807
  if (next !== null) {
783
808
  const sentinel: RetroPendingSentinel = {
784
809
  schema: 1,
@@ -786,15 +811,26 @@ function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: strin
786
811
  transcript: transcriptPath,
787
812
  snippet: next.snippet.replace(/\s+/g, ' ').trim().slice(0, 200),
788
813
  ts: nowIso ?? new Date().toISOString(),
814
+ ...(next.awaiting !== undefined ? { awaiting: next.awaiting } : {}),
789
815
  };
790
816
  writeJsonAtomic(pendingPath, sentinel);
791
- return { status: 'pending', snippet: sentinel.snippet, scannedBytes: consumed, offset: newOffset };
817
+ outcome = { status: 'pending', snippet: sentinel.snippet, scannedBytes: consumed, offset: newOffset };
818
+ } else if (hadSentinel) {
819
+ // Step-8 finding (23.09.2026, HIGH): swallowing EVERY unlink failure here and then advancing
820
+ // the offset anyway recreates the very loss this feature removes — the sentinel survives, the
821
+ // receipt that paid it is consumed, and every later scan re-arms the SAME stale snippet
822
+ // forever. Only "already gone" is benign; any other failure must leave the bytes replayable,
823
+ // so it propagates and the offset write below never runs. This makes the CLEAR branch
824
+ // symmetric with the ARM branch, which already blocks the offset on a failed write.
825
+ try { unlinkSync(pendingPath); }
826
+ catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
827
+ outcome = { status: 'cleared', scannedBytes: consumed, offset: newOffset };
828
+ } else {
829
+ outcome = { status: 'none', scannedBytes: consumed, offset: newOffset };
792
830
  }
793
- if (hadSentinel) {
794
- try { unlinkSync(pendingPath); } catch { /* already gone */ }
795
- return { status: 'cleared', scannedBytes: consumed, offset: newOffset };
796
- }
797
- return { status: 'none', scannedBytes: consumed, offset: newOffset };
831
+ // Commit the debt BEFORE its offset: an interruption must leave these bytes replayable.
832
+ writeJsonAtomic(statePath, { schema: 1, transcript: transcriptPath, offset: newOffset });
833
+ return outcome;
798
834
  } catch {
799
835
  return { status: 'none', scannedBytes: 0, offset: 0 };
800
836
  }