@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.
- package/.dz-manifest.json +220 -120
- package/README.md +65 -10
- package/dist/__tests__/golden-baseline.test.js +1 -1
- package/dist/__tests__/golden-baseline.test.js.map +1 -1
- package/dist/agentdb-index.js +3 -3
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-reindex-marker.d.ts +2 -2
- package/dist/agentdb-reindex-marker.js +4 -4
- package/dist/agentdb-reindex-marker.js.map +1 -1
- package/dist/agentdb-snapshot-rotation.d.ts +1 -1
- package/dist/agentdb-snapshot-rotation.js +1 -1
- package/dist/backup-freshness.d.ts +32 -0
- package/dist/backup-freshness.d.ts.map +1 -0
- package/dist/backup-freshness.js +69 -0
- package/dist/backup-freshness.js.map +1 -0
- package/dist/cmd-usage.d.ts +2 -2
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +8 -6
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +2 -1
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +15 -2
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/compounding.d.ts +43 -1
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +96 -0
- package/dist/compounding.js.map +1 -1
- package/dist/discrimination-gate.d.ts +0 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +2 -2
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/guard.d.ts +26 -2
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +162 -17
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +17 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -5
- package/dist/index.js.map +1 -1
- package/dist/integration-apply.js +2 -2
- package/dist/integration-apply.js.map +1 -1
- package/dist/lesson-payoff.js +3 -3
- package/dist/lesson-payoff.js.map +1 -1
- package/dist/named-lock.d.ts +5 -2
- package/dist/named-lock.d.ts.map +1 -1
- package/dist/named-lock.js +30 -10
- package/dist/named-lock.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +24 -0
- package/dist/operations.js.map +1 -1
- package/dist/pack-inventory.d.ts +51 -0
- package/dist/pack-inventory.d.ts.map +1 -0
- package/dist/pack-inventory.js +306 -0
- package/dist/pack-inventory.js.map +1 -0
- package/dist/package-skill-layouts.d.ts +3 -2
- package/dist/package-skill-layouts.d.ts.map +1 -1
- package/dist/package-skill-layouts.js +3 -2
- package/dist/package-skill-layouts.js.map +1 -1
- package/dist/patterns.d.ts +8 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +11 -1
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +43 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +153 -59
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +24 -20
- package/dist/registry.js.map +1 -1
- package/dist/release-line.d.ts +15 -1
- package/dist/release-line.d.ts.map +1 -1
- package/dist/release-line.js +40 -13
- package/dist/release-line.js.map +1 -1
- package/dist/reqe-verdict.d.ts +55 -0
- package/dist/reqe-verdict.d.ts.map +1 -0
- package/dist/reqe-verdict.js +173 -0
- package/dist/reqe-verdict.js.map +1 -0
- package/dist/reqe.d.ts +35 -14
- package/dist/reqe.d.ts.map +1 -1
- package/dist/reqe.js +85 -32
- package/dist/reqe.js.map +1 -1
- package/dist/round.d.ts +10 -0
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +7 -0
- package/dist/round.js.map +1 -1
- package/dist/session-retro.d.ts +9 -6
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +39 -14
- 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/tree-sync.d.ts +5 -0
- package/dist/tree-sync.d.ts.map +1 -0
- package/dist/tree-sync.js +13 -0
- package/dist/tree-sync.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 +8 -8
- package/sbom.json +369 -119
- package/src/__tests__/golden-baseline.test.ts +1 -1
- package/src/agentdb-index.ts +3 -3
- package/src/agentdb-reindex-marker.ts +4 -4
- package/src/agentdb-snapshot-rotation.ts +1 -1
- package/src/backup-freshness.ts +96 -0
- package/src/cmd-usage.ts +7 -6
- package/src/codex-hooks-assets.ts +15 -2
- package/src/compounding.ts +132 -1
- package/src/discrimination-gate.ts +2 -5
- package/src/guard.ts +166 -20
- package/src/index.ts +25 -6
- package/src/integration-apply.ts +2 -2
- package/src/lesson-payoff.ts +3 -3
- package/src/named-lock.ts +36 -11
- package/src/operations.ts +24 -0
- package/src/pack-inventory.ts +313 -0
- package/src/package-skill-layouts.ts +3 -2
- package/src/patterns.ts +18 -1
- package/src/publish.ts +168 -65
- package/src/registry.ts +23 -17
- package/src/release-line.ts +60 -16
- package/src/reqe-verdict.ts +174 -0
- package/src/reqe.ts +111 -35
- package/src/round.ts +18 -0
- package/src/session-retro.ts +54 -18
- 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/tree-sync.ts +17 -0
- 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
|
|
13
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
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:
|
|
32
|
-
'
|
|
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
|
-
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
|
|
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 (
|
|
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 (
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
qeGrade: typeof d.qeGrade === 'string'
|
|
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 + ')
|
|
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 —
|
|
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,
|
package/src/session-retro.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
647
|
-
):
|
|
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
|
-
|
|
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)
|
|
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) {
|
|
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 `
|
|
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
|
|
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:
|
|
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') {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
}
|