@dzhechkov/harness-core 0.8.39 → 0.8.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +235 -135
- package/README.md +91 -8
- package/dist/__tests__/golden-baseline.test.js +1 -1
- package/dist/__tests__/golden-baseline.test.js.map +1 -1
- package/dist/agentdb-index.js +3 -3
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-reindex-marker.d.ts +2 -2
- package/dist/agentdb-reindex-marker.js +4 -4
- package/dist/agentdb-reindex-marker.js.map +1 -1
- package/dist/agentdb-snapshot-rotation.d.ts +1 -1
- package/dist/agentdb-snapshot-rotation.js +1 -1
- package/dist/backup-freshness.d.ts +32 -0
- package/dist/backup-freshness.d.ts.map +1 -0
- package/dist/backup-freshness.js +69 -0
- package/dist/backup-freshness.js.map +1 -0
- package/dist/cmd-usage.d.ts +2 -2
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +56 -8
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +2 -1
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +15 -2
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/compounding.d.ts +109 -1
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +172 -9
- package/dist/compounding.js.map +1 -1
- package/dist/discrimination-gate.d.ts +0 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +2 -2
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/doctor-instrument.d.ts +65 -0
- package/dist/doctor-instrument.d.ts.map +1 -0
- package/dist/doctor-instrument.js +91 -0
- package/dist/doctor-instrument.js.map +1 -0
- package/dist/feature-tier.d.ts.map +1 -1
- package/dist/feature-tier.js +13 -1
- package/dist/feature-tier.js.map +1 -1
- package/dist/guard.d.ts +39 -2
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +117 -14
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +20 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -6
- package/dist/index.js.map +1 -1
- package/dist/integration-apply.js +2 -2
- package/dist/integration-apply.js.map +1 -1
- package/dist/lesson-payoff.js +3 -3
- package/dist/lesson-payoff.js.map +1 -1
- package/dist/mutation-gate.d.ts +30 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +45 -1
- package/dist/mutation-gate.js.map +1 -1
- package/dist/named-lock.d.ts +5 -2
- package/dist/named-lock.d.ts.map +1 -1
- package/dist/named-lock.js +30 -10
- package/dist/named-lock.js.map +1 -1
- package/dist/operations.d.ts +13 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +112 -2
- package/dist/operations.js.map +1 -1
- package/dist/pack-inventory.d.ts +51 -0
- package/dist/pack-inventory.d.ts.map +1 -0
- package/dist/pack-inventory.js +306 -0
- package/dist/pack-inventory.js.map +1 -0
- package/dist/package-skill-layouts.d.ts +3 -2
- package/dist/package-skill-layouts.d.ts.map +1 -1
- package/dist/package-skill-layouts.js +3 -2
- package/dist/package-skill-layouts.js.map +1 -1
- package/dist/patterns.d.ts +8 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +11 -1
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +29 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +92 -15
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts +58 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +86 -25
- package/dist/registry.js.map +1 -1
- package/dist/release.d.ts +18 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +30 -0
- package/dist/release.js.map +1 -1
- package/dist/reqe-verdict.d.ts +55 -0
- package/dist/reqe-verdict.d.ts.map +1 -0
- package/dist/reqe-verdict.js +173 -0
- package/dist/reqe-verdict.js.map +1 -0
- package/dist/reqe.d.ts +35 -14
- package/dist/reqe.d.ts.map +1 -1
- package/dist/reqe.js +85 -32
- package/dist/reqe.js.map +1 -1
- package/dist/round-exec.d.ts +10 -0
- package/dist/round-exec.d.ts.map +1 -1
- package/dist/round-exec.js +3 -2
- package/dist/round-exec.js.map +1 -1
- package/dist/round.d.ts +29 -0
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +8 -0
- package/dist/round.js.map +1 -1
- package/dist/session-retro.d.ts +1 -1
- package/dist/session-retro.js +3 -3
- package/dist/session-retro.js.map +1 -1
- package/dist/skill-drift.d.ts +18 -1
- package/dist/skill-drift.d.ts.map +1 -1
- package/dist/skill-drift.js +46 -13
- package/dist/skill-drift.js.map +1 -1
- package/dist/statusline.js +2 -2
- package/dist/statusline.js.map +1 -1
- package/dist/store-guard.js +3 -3
- package/dist/store-guard.js.map +1 -1
- package/dist/store-lock.d.ts +9 -0
- package/dist/store-lock.d.ts.map +1 -1
- package/dist/store-lock.js +22 -3
- package/dist/store-lock.js.map +1 -1
- package/dist/test-receipt.d.ts +66 -0
- package/dist/test-receipt.d.ts.map +1 -0
- package/dist/test-receipt.js +73 -0
- package/dist/test-receipt.js.map +1 -0
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +7 -8
- package/dist/workflow-run.js.map +1 -1
- package/package.json +1 -1
- package/sbom.json +384 -134
- package/src/__tests__/golden-baseline.test.ts +1 -1
- package/src/agentdb-index.ts +3 -3
- package/src/agentdb-reindex-marker.ts +4 -4
- package/src/agentdb-snapshot-rotation.ts +1 -1
- package/src/backup-freshness.ts +96 -0
- package/src/cmd-usage.ts +59 -8
- package/src/codex-hooks-assets.ts +15 -2
- package/src/compounding.ts +244 -10
- package/src/discrimination-gate.ts +2 -5
- package/src/doctor-instrument.ts +153 -0
- package/src/feature-tier.ts +14 -1
- package/src/guard.ts +128 -17
- package/src/index.ts +28 -7
- package/src/integration-apply.ts +2 -2
- package/src/lesson-payoff.ts +3 -3
- package/src/mutation-gate.ts +54 -1
- package/src/named-lock.ts +36 -11
- package/src/operations.ts +109 -3
- package/src/pack-inventory.ts +313 -0
- package/src/package-skill-layouts.ts +3 -2
- package/src/patterns.ts +18 -1
- package/src/publish.ts +101 -22
- package/src/registry.ts +114 -18
- package/src/release.ts +36 -0
- package/src/reqe-verdict.ts +174 -0
- package/src/reqe.ts +111 -35
- package/src/round-exec.ts +13 -2
- package/src/round.ts +38 -0
- package/src/session-retro.ts +3 -3
- package/src/skill-drift.ts +66 -12
- package/src/statusline.ts +2 -2
- package/src/store-guard.ts +3 -3
- package/src/store-lock.ts +20 -3
- package/src/test-receipt.ts +106 -0
- package/src/workflow-run.ts +7 -8
package/src/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-exec.ts
CHANGED
|
@@ -37,14 +37,25 @@ export function classifyRoundExecOutcome(input: {
|
|
|
37
37
|
readonly exitCode: number | null;
|
|
38
38
|
readonly timedOut: boolean;
|
|
39
39
|
readonly bytes: number;
|
|
40
|
+
/** How the run ENDED: the limit/refusal signatures are end-state facts, so they read the tail. */
|
|
40
41
|
readonly tail: string;
|
|
42
|
+
/**
|
|
43
|
+
* The WHOLE log, when the caller has it. The turn marker is not an end-state fact — it is the last
|
|
44
|
+
* `\ncodex\n` ANYWHERE in the log — and a fixed tail window cannot hold it: MEASURED 2026-09-20 on
|
|
45
|
+
* three real rounds, Codex's final answer ran 12–20 KB (it quotes runner output), so the marker sat
|
|
46
|
+
* at ~95% of the file and fell outside the caller's 4 KB tail. All three runs had landed their files
|
|
47
|
+
* and were green, and all three were recorded `failed`. Defaults to `tail` for callers that only
|
|
48
|
+
* have the window.
|
|
49
|
+
*/
|
|
50
|
+
readonly fullText?: string;
|
|
41
51
|
}): RoundExecOutcome {
|
|
42
52
|
if (input.timedOut) return 'timeout';
|
|
43
53
|
if (/rate limit|usage limit|limit reached/i.test(input.tail)) return 'session-limit';
|
|
44
54
|
if (/HTTP 400|not supported when using Codex/i.test(input.tail)) return 'model-refused';
|
|
45
55
|
if (input.exitCode === 0 && input.bytes > 0) {
|
|
46
|
-
const
|
|
47
|
-
const
|
|
56
|
+
const haystack = input.fullText ?? input.tail;
|
|
57
|
+
const marker = haystack.lastIndexOf('\ncodex\n');
|
|
58
|
+
const finalLine = marker < 0 ? '' : haystack.slice(marker + '\ncodex\n'.length).split(/\r?\n/, 1)[0]?.trim() ?? '';
|
|
48
59
|
if (finalLine !== '') return 'done';
|
|
49
60
|
}
|
|
50
61
|
if (input.exitCode === 0 && input.bytes === 0) return 'empty';
|
package/src/round.ts
CHANGED
|
@@ -41,9 +41,28 @@ export interface RoundExecState {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** Additive row shape accepted by the existing run-cost ledger readers. */
|
|
44
|
+
/**
|
|
45
|
+
* The row this version WRITES. It is deliberately not the shape the FILE holds: the ledger is
|
|
46
|
+
* append-only, so rows written before a field existed do not carry it. Read a stored line as
|
|
47
|
+
* `unknown` and put it through {@link validateClosedRoundLedgerRow} or your own guard — typing a
|
|
48
|
+
* historical line with this interface and dereferencing a later field as a guaranteed string is the
|
|
49
|
+
* defect this note exists to prevent (named by cross-family review, Codex gpt-5.6-sol, 2026-09-21).
|
|
50
|
+
*/
|
|
44
51
|
export interface RoundLedgerRow {
|
|
45
52
|
readonly slug: string;
|
|
46
53
|
readonly stage: 'round';
|
|
54
|
+
/**
|
|
55
|
+
* This round's identity as a FIELD. It is also the first token of `note`, and that prefix is what
|
|
56
|
+
* the confirmation read greps for — but a key that lives inside prose can only be joined by
|
|
57
|
+
* parsing prose. MEASURED on the COMMITTED `.dz/feature-adr/run-cost-ledger.jsonl` at `488f596c`
|
|
58
|
+
* (backlog c60cc857, whose own numbers had gone stale): of 436 rows, 99 are `round`, and the
|
|
59
|
+
* round's own identity sat in a FIELD 0 times out of 99 while sitting inside `note` prose 99 times
|
|
60
|
+
* out of 99. Other id fields exist on those rows — 28 carry `taskId`, 76 carry `stateId` — but
|
|
61
|
+
* they identify the TASK and the STATE FILE, not the round's ledger identity; 22 carry none of
|
|
62
|
+
* `runId`/`taskId`/`stateId` (those three exactly — `runnerId`, the host, is on all 99).
|
|
63
|
+
* Reproducer and pinning commit: package README, section `roundId`.
|
|
64
|
+
*/
|
|
65
|
+
readonly roundId: string;
|
|
47
66
|
readonly tier: null;
|
|
48
67
|
readonly coder: string | null;
|
|
49
68
|
readonly reviewer: string | null;
|
|
@@ -59,6 +78,16 @@ export interface RoundLedgerRow {
|
|
|
59
78
|
readonly reason: string | null;
|
|
60
79
|
readonly round: number;
|
|
61
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[];
|
|
62
91
|
readonly noNewKnowledge: string | null;
|
|
63
92
|
readonly note: string;
|
|
64
93
|
readonly date: null;
|
|
@@ -549,9 +578,15 @@ export function closeRound(input: {
|
|
|
549
578
|
? (nonEmpty(input.shipTreeDirtyReason) ? input.shipTreeDirtyReason.trim() : 'not provided')
|
|
550
579
|
: undefined;
|
|
551
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
|
+
|
|
552
586
|
const row: RoundLedgerRow = {
|
|
553
587
|
slug: input.state.slug,
|
|
554
588
|
stage: 'round',
|
|
589
|
+
roundId: marker,
|
|
555
590
|
tier: null,
|
|
556
591
|
coder: nonEmpty(input.coder) ? input.coder.trim() : null,
|
|
557
592
|
reviewer,
|
|
@@ -564,6 +599,9 @@ export function closeRound(input: {
|
|
|
564
599
|
reason: nonEmpty(input.reason) ? input.reason.trim() : null,
|
|
565
600
|
round: input.state.round,
|
|
566
601
|
lessons,
|
|
602
|
+
...(recalled.length > 0 ? { recalled } : {}),
|
|
603
|
+
...(recalledUsed.length > 0 ? { recalledUsed } : {}),
|
|
604
|
+
...(recalledUnused.length > 0 ? { recalledUnused } : {}),
|
|
567
605
|
noNewKnowledge: noNewKnowledge === '' ? null : noNewKnowledge,
|
|
568
606
|
note: note === '' ? marker : `${marker} | ${note}`,
|
|
569
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';
|
|
@@ -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 `
|
|
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
|
|
710
|
+
return withProjectLockSync(
|
|
711
711
|
dirname(dzDir),
|
|
712
712
|
RETRO_SCAN_LOCK_NAME,
|
|
713
713
|
() => scanTailUnderLock(dzDir, transcriptPath, nowIso),
|
package/src/skill-drift.ts
CHANGED
|
@@ -42,6 +42,18 @@ export interface DriftedSkill {
|
|
|
42
42
|
readonly locations: readonly string[];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export type CanonicalDefectKind = 'misplaced-enrichment-asset';
|
|
46
|
+
|
|
47
|
+
/** A finding in the canonical source, reported without propagating or removing it. */
|
|
48
|
+
export interface CanonicalDefect {
|
|
49
|
+
readonly skill: string;
|
|
50
|
+
readonly kind: CanonicalDefectKind;
|
|
51
|
+
/** Skill-dir-relative POSIX path. */
|
|
52
|
+
readonly path: string;
|
|
53
|
+
/** Absolute canonical skill directory. */
|
|
54
|
+
readonly canonical: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
/** Options for {@link sweepSkillDrift}. */
|
|
46
58
|
export interface SweepOptions {
|
|
47
59
|
/**
|
|
@@ -69,6 +81,8 @@ export interface SweepResult {
|
|
|
69
81
|
readonly duplicated: number;
|
|
70
82
|
/** Skills that byte-differ AND are not allowlisted, sorted by `driftFiles` desc — the gate keys on this. */
|
|
71
83
|
readonly drifted: readonly DriftedSkill[];
|
|
84
|
+
/** Canonical-side findings, independent of copy drift and its allowlist. */
|
|
85
|
+
readonly canonicalDefects: readonly CanonicalDefect[];
|
|
72
86
|
/** Skills that byte-differ but are allowlisted (intentional) — surfaced for transparency, not gated. */
|
|
73
87
|
readonly allowlisted: readonly DriftedSkill[];
|
|
74
88
|
}
|
|
@@ -112,12 +126,22 @@ export interface SyncResult {
|
|
|
112
126
|
readonly synced: number;
|
|
113
127
|
/** # copies/files that differ (from canonical in resolved modes; between peers in canonical-free mode). */
|
|
114
128
|
readonly drifted: number;
|
|
129
|
+
/** Nonempty in write mode means refusal: `synced: 0`, `wrote: []`. */
|
|
130
|
+
readonly canonicalDefects: readonly CanonicalDefect[];
|
|
115
131
|
/** Abs paths of copies written (empty when `check:true` / peer / refuse — proves no writes). */
|
|
116
132
|
readonly wrote: readonly string[];
|
|
117
133
|
}
|
|
118
134
|
|
|
119
135
|
const SKILL_MANIFEST = 'SKILL.md';
|
|
120
|
-
|
|
136
|
+
// Runtime state, never skill CONTENT. `.agentic-qe/` earned its place by measurement on
|
|
137
|
+
// 2026-09-21: merely INVOKING the decision-mockups skill made its AQE runtime create a SQLite
|
|
138
|
+
// store inside the live `.claude/skills/decision-mockups/`, which the packaged copies do not
|
|
139
|
+
// have — so `no-skill-drift` reported drift and BLOCKED `dz guard --op publish`. The directory
|
|
140
|
+
// is gitignored repo-wide (`**/.agentic-qe/`) and untracked; the drift sweep simply did not read
|
|
141
|
+
// .gitignore. Note the denylist stays narrow on purpose: a blanket "skip dot-entries" would
|
|
142
|
+
// silence real drift in the nine packages whose skill content legitimately lives under
|
|
143
|
+
// `templates/.claude/`.
|
|
144
|
+
const IGNORED_ENTRIES = new Set(['node_modules', '__pycache__', '.DS_Store', 'run-history.json', '.agentic-qe']);
|
|
121
145
|
|
|
122
146
|
/** md5 of a file's bytes (identical to both prototype scripts ⇒ identical drift verdicts). */
|
|
123
147
|
function md5(path: string): string {
|
|
@@ -127,7 +151,13 @@ function md5(path: string): string {
|
|
|
127
151
|
/** Recursive file list under `dir`; skips `node_modules` / `__pycache__` / `.DS_Store`. */
|
|
128
152
|
function walk(dir: string): string[] {
|
|
129
153
|
const out: string[] = [];
|
|
130
|
-
|
|
154
|
+
let entries: string[];
|
|
155
|
+
try {
|
|
156
|
+
entries = readdirSync(dir);
|
|
157
|
+
} catch {
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
for (const entry of entries) {
|
|
131
161
|
if (IGNORED_ENTRIES.has(entry)) continue;
|
|
132
162
|
const p = join(dir, entry);
|
|
133
163
|
let st;
|
|
@@ -181,11 +211,25 @@ function withoutEnrichmentNames(rels: readonly string[]): string[] {
|
|
|
181
211
|
return rels.filter((r) => TARGET_ENRICHMENT_ASSETS[r.split(sep).join('/')] === undefined);
|
|
182
212
|
}
|
|
183
213
|
|
|
184
|
-
|
|
214
|
+
/** Find misplaced target metadata in a canonical source, sorted by relative POSIX path. */
|
|
215
|
+
export function findCanonicalDefects(canonicalDir: string, skill: string): CanonicalDefect[] {
|
|
216
|
+
const canonical = resolve(canonicalDir);
|
|
217
|
+
return walk(canonical)
|
|
218
|
+
.map((p) => relative(canonical, p).split(sep).join('/'))
|
|
219
|
+
.filter((rel) => TARGET_ENRICHMENT_ASSETS[rel] !== undefined && !isEnrichmentAsset(rel, canonical))
|
|
220
|
+
.sort()
|
|
221
|
+
.map((path) => ({ skill, kind: 'misplaced-enrichment-asset', path, canonical }));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function comparePeers(
|
|
225
|
+
copies: readonly string[],
|
|
226
|
+
exclusions: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
|
|
227
|
+
): { driftFiles: number; missingFiles: number; totalFiles: number } {
|
|
228
|
+
const excluded = (copy: string, rel: string): boolean => exclusions.get(copy)?.has(rel.split(sep).join('/')) === true;
|
|
185
229
|
const relFiles = new Set<string>();
|
|
186
230
|
for (const c of copies) for (const f of walk(c)) {
|
|
187
231
|
const rel = relative(c, f);
|
|
188
|
-
if (isEnrichmentAsset(rel, c)) continue;
|
|
232
|
+
if (isEnrichmentAsset(rel, c) || excluded(c, rel)) continue;
|
|
189
233
|
relFiles.add(rel);
|
|
190
234
|
}
|
|
191
235
|
|
|
@@ -195,7 +239,9 @@ function comparePeers(copies: readonly string[]): { driftFiles: number; missingF
|
|
|
195
239
|
const hashes = new Set<string>();
|
|
196
240
|
for (const c of copies) {
|
|
197
241
|
const p = join(c, rel);
|
|
198
|
-
|
|
242
|
+
// An excluded canonical file is absent from its content; another package's same path
|
|
243
|
+
// still contributes to the union and must compare as an extra file.
|
|
244
|
+
if (!excluded(c, rel) && existsSync(p)) hashes.add(md5(p));
|
|
199
245
|
else {
|
|
200
246
|
missingFiles++;
|
|
201
247
|
hashes.add('__MISSING__');
|
|
@@ -291,7 +337,7 @@ function findSkillDirs(root: string, scope: 'packages' | 'installs' | 'all' = 'a
|
|
|
291
337
|
* Detect intra-monorepo skill drift: find every skill duplicated across ≥2 locations and report
|
|
292
338
|
* which copies byte-differ. Pure port of `scripts/drift-sweep-skills.mjs`.
|
|
293
339
|
*
|
|
294
|
-
*
|
|
340
|
+
* Gates must inspect both copy drift and canonical defects.
|
|
295
341
|
*/
|
|
296
342
|
export function sweepSkillDrift(root: string, opts: SweepOptions = {}): SweepResult {
|
|
297
343
|
const scope = opts.scope ?? 'all';
|
|
@@ -308,16 +354,22 @@ export function sweepSkillDrift(root: string, opts: SweepOptions = {}): SweepRes
|
|
|
308
354
|
|
|
309
355
|
let duplicated = 0;
|
|
310
356
|
const drifted: DriftedSkill[] = [];
|
|
357
|
+
const canonicalDefects: CanonicalDefect[] = [];
|
|
311
358
|
const allowlisted: DriftedSkill[] = [];
|
|
312
359
|
|
|
313
360
|
for (const [name, unsorted] of [...byName.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
361
|
+
const meta = join(root, 'packages/@dzhechkov/skills-meta', name);
|
|
362
|
+
const defects = unsorted.includes(meta) ? findCanonicalDefects(meta, name) : [];
|
|
363
|
+
canonicalDefects.push(...defects);
|
|
314
364
|
if (unsorted.length < 2) continue;
|
|
315
365
|
duplicated++;
|
|
316
366
|
const copies = [...unsorted].sort();
|
|
317
367
|
|
|
318
368
|
// Per-skill byte-comparison of every copy against each other (extracted to `comparePeers`
|
|
319
369
|
// so the canonical-free `sync-canonical --check` reuses the EXACT same logic).
|
|
320
|
-
const { driftFiles, missingFiles, totalFiles } = comparePeers(
|
|
370
|
+
const { driftFiles, missingFiles, totalFiles } = comparePeers(
|
|
371
|
+
copies, new Map([[meta, new Set(defects.map((d) => d.path))]]),
|
|
372
|
+
);
|
|
321
373
|
|
|
322
374
|
if (driftFiles > 0) {
|
|
323
375
|
const entry: DriftedSkill = {
|
|
@@ -335,7 +387,7 @@ export function sweepSkillDrift(root: string, opts: SweepOptions = {}): SweepRes
|
|
|
335
387
|
const byDrift = (a: DriftedSkill, b: DriftedSkill): number => b.driftFiles - a.driftFiles;
|
|
336
388
|
drifted.sort(byDrift);
|
|
337
389
|
allowlisted.sort(byDrift);
|
|
338
|
-
return { duplicated, drifted, allowlisted };
|
|
390
|
+
return { duplicated, drifted, canonicalDefects, allowlisted };
|
|
339
391
|
}
|
|
340
392
|
|
|
341
393
|
/**
|
|
@@ -344,6 +396,7 @@ export function sweepSkillDrift(root: string, opts: SweepOptions = {}): SweepRes
|
|
|
344
396
|
* byte-identity. Pure port of `scripts/sync-canonical-skill.mjs`, extended with a canonical-free path.
|
|
345
397
|
*
|
|
346
398
|
* `check:true` writes NOTHING (`wrote` stays empty) and only reports the drift count.
|
|
399
|
+
* A canonical defect also prevents all writes, while reporting the same drift as check mode.
|
|
347
400
|
* Default overwrites drifting copies; a subsequent {@link sweepSkillDrift} then reports 0 drift.
|
|
348
401
|
*
|
|
349
402
|
* When NO canonical resolves (`resolvedFrom === 'none'` — no `--from`, no `skills-meta`, no `--auto`):
|
|
@@ -372,12 +425,13 @@ export function syncCanonicalSkill(root: string, skill: string, opts: SyncCanoni
|
|
|
372
425
|
if (check) {
|
|
373
426
|
// CANONICAL-FREE peer check: are the copies byte-identical to EACH OTHER? (<2 ⇒ vacuously so.)
|
|
374
427
|
const drifted = allCopies.length < 2 ? 0 : comparePeers(allCopies).driftFiles;
|
|
375
|
-
return { canonical: '', canonicalExists: false, resolvedFrom, copies: allCopies.length, synced: 0, drifted, wrote: [] };
|
|
428
|
+
return { canonical: '', canonicalExists: false, resolvedFrom, copies: allCopies.length, synced: 0, drifted, canonicalDefects: [], wrote: [] };
|
|
376
429
|
}
|
|
377
430
|
// Bare WRITE with no resolvable canonical → REFUSE. Mutates nothing (`wrote:[]` proves it).
|
|
378
|
-
return { canonical: '', canonicalExists: false, resolvedFrom, copies: allCopies.length, synced: 0, drifted: 0, wrote: [] };
|
|
431
|
+
return { canonical: '', canonicalExists: false, resolvedFrom, copies: allCopies.length, synced: 0, drifted: 0, canonicalDefects: [], wrote: [] };
|
|
379
432
|
}
|
|
380
433
|
|
|
434
|
+
const canonicalDefects = findCanonicalDefects(canonical, skill);
|
|
381
435
|
const canonFiles = withoutEnrichmentNames(walk(canonical).map((p) => relative(canonical, p))).sort();
|
|
382
436
|
|
|
383
437
|
// Every <skill>/ dir except the canonical itself.
|
|
@@ -403,7 +457,7 @@ export function syncCanonicalSkill(root: string, skill: string, opts: SyncCanoni
|
|
|
403
457
|
if (!differs) continue;
|
|
404
458
|
drifted++;
|
|
405
459
|
|
|
406
|
-
if (check) continue; //
|
|
460
|
+
if (check || canonicalDefects.length > 0) continue; // check or refusal — write NOTHING
|
|
407
461
|
|
|
408
462
|
// Overwrite: remove extra files, then copy every canonical file byte-for-byte.
|
|
409
463
|
for (const f of copyFiles) if (!canonFiles.includes(f)) rmSync(join(copy, f));
|
|
@@ -417,5 +471,5 @@ export function syncCanonicalSkill(root: string, skill: string, opts: SyncCanoni
|
|
|
417
471
|
wrote.push(copy);
|
|
418
472
|
}
|
|
419
473
|
|
|
420
|
-
return { canonical, canonicalExists: true, resolvedFrom, copies: copies.length, synced, drifted, wrote };
|
|
474
|
+
return { canonical, canonicalExists: true, resolvedFrom, copies: copies.length, synced, drifted, canonicalDefects, wrote };
|
|
421
475
|
}
|
package/src/statusline.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { basename, join, resolve } from 'node:path';
|
|
|
19
19
|
import { createRequire } from 'node:module';
|
|
20
20
|
|
|
21
21
|
import { listBrain } from './brain.js';
|
|
22
|
-
import {
|
|
22
|
+
import { withProjectLockSync } from './named-lock.js';
|
|
23
23
|
import { RECALL_USAGE_LOG_RELATIVE, aggregateRecallUsage, parseRecallUsageLog } from './recall-usage.js';
|
|
24
24
|
import { countLearningStoreRowsReadonly, countSqliteRowsReadonly } from './store-counts.js';
|
|
25
25
|
import {
|
|
@@ -585,7 +585,7 @@ export function writeFeatureAdrStateDetailed(
|
|
|
585
585
|
// half of the lock test can reproduce the lost update. Shipped callers never pass it.
|
|
586
586
|
const written = input._unsafeSkipLock === true
|
|
587
587
|
? transition()
|
|
588
|
-
:
|
|
588
|
+
: withProjectLockSync(root, 'fa-phase-slot', transition);
|
|
589
589
|
return { state: written };
|
|
590
590
|
} catch (err) {
|
|
591
591
|
// A refusal is REPORTED, never mistaken for success. A lock timeout before the callback proves
|
package/src/store-guard.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSy
|
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { withDirLockSync } from './named-lock.js';
|
|
17
17
|
|
|
18
18
|
export const STORE_GUARD_VERSION = 1 as const;
|
|
19
19
|
|
|
@@ -256,7 +256,7 @@ export function writeStoreMark(
|
|
|
256
256
|
validateObservation(observation);
|
|
257
257
|
mkdirSync(guardRoot, { recursive: true, mode: 0o700 });
|
|
258
258
|
|
|
259
|
-
return
|
|
259
|
+
return withDirLockSync(guardRoot, `store-guard-${basename(path, '.json')}`, () => {
|
|
260
260
|
const previous = readStoreMark(project);
|
|
261
261
|
if (options.expectedPreviousLexicalSource !== undefined
|
|
262
262
|
&& previous?.lexicalSource !== options.expectedPreviousLexicalSource) {
|
|
@@ -295,7 +295,7 @@ export function resetStoreMark(projectRoot: string, observation: StoreMarkObserv
|
|
|
295
295
|
throw new Error('store guard reset requires command dz store-guard --reset');
|
|
296
296
|
}
|
|
297
297
|
mkdirSync(guardRoot, { recursive: true, mode: 0o700 });
|
|
298
|
-
return
|
|
298
|
+
return withDirLockSync(guardRoot, `store-guard-${basename(path, '.json')}`, () => {
|
|
299
299
|
const previous = readStoreMark(project);
|
|
300
300
|
const before: StoreCountSnapshot = {
|
|
301
301
|
lexicalRows: previous?.lexicalMax ?? observation.lexicalRows,
|