@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.
Files changed (161) hide show
  1. package/.dz-manifest.json +235 -135
  2. package/README.md +91 -8
  3. package/dist/__tests__/golden-baseline.test.js +1 -1
  4. package/dist/__tests__/golden-baseline.test.js.map +1 -1
  5. package/dist/agentdb-index.js +3 -3
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/agentdb-reindex-marker.d.ts +2 -2
  8. package/dist/agentdb-reindex-marker.js +4 -4
  9. package/dist/agentdb-reindex-marker.js.map +1 -1
  10. package/dist/agentdb-snapshot-rotation.d.ts +1 -1
  11. package/dist/agentdb-snapshot-rotation.js +1 -1
  12. package/dist/backup-freshness.d.ts +32 -0
  13. package/dist/backup-freshness.d.ts.map +1 -0
  14. package/dist/backup-freshness.js +69 -0
  15. package/dist/backup-freshness.js.map +1 -0
  16. package/dist/cmd-usage.d.ts +2 -2
  17. package/dist/cmd-usage.d.ts.map +1 -1
  18. package/dist/cmd-usage.js +56 -8
  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 +109 -1
  25. package/dist/compounding.d.ts.map +1 -1
  26. package/dist/compounding.js +172 -9
  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/doctor-instrument.d.ts +65 -0
  33. package/dist/doctor-instrument.d.ts.map +1 -0
  34. package/dist/doctor-instrument.js +91 -0
  35. package/dist/doctor-instrument.js.map +1 -0
  36. package/dist/feature-tier.d.ts.map +1 -1
  37. package/dist/feature-tier.js +13 -1
  38. package/dist/feature-tier.js.map +1 -1
  39. package/dist/guard.d.ts +39 -2
  40. package/dist/guard.d.ts.map +1 -1
  41. package/dist/guard.js +117 -14
  42. package/dist/guard.js.map +1 -1
  43. package/dist/index.d.ts +20 -9
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +14 -6
  46. package/dist/index.js.map +1 -1
  47. package/dist/integration-apply.js +2 -2
  48. package/dist/integration-apply.js.map +1 -1
  49. package/dist/lesson-payoff.js +3 -3
  50. package/dist/lesson-payoff.js.map +1 -1
  51. package/dist/mutation-gate.d.ts +30 -0
  52. package/dist/mutation-gate.d.ts.map +1 -1
  53. package/dist/mutation-gate.js +45 -1
  54. package/dist/mutation-gate.js.map +1 -1
  55. package/dist/named-lock.d.ts +5 -2
  56. package/dist/named-lock.d.ts.map +1 -1
  57. package/dist/named-lock.js +30 -10
  58. package/dist/named-lock.js.map +1 -1
  59. package/dist/operations.d.ts +13 -0
  60. package/dist/operations.d.ts.map +1 -1
  61. package/dist/operations.js +112 -2
  62. package/dist/operations.js.map +1 -1
  63. package/dist/pack-inventory.d.ts +51 -0
  64. package/dist/pack-inventory.d.ts.map +1 -0
  65. package/dist/pack-inventory.js +306 -0
  66. package/dist/pack-inventory.js.map +1 -0
  67. package/dist/package-skill-layouts.d.ts +3 -2
  68. package/dist/package-skill-layouts.d.ts.map +1 -1
  69. package/dist/package-skill-layouts.js +3 -2
  70. package/dist/package-skill-layouts.js.map +1 -1
  71. package/dist/patterns.d.ts +8 -0
  72. package/dist/patterns.d.ts.map +1 -1
  73. package/dist/patterns.js +11 -1
  74. package/dist/patterns.js.map +1 -1
  75. package/dist/publish.d.ts +29 -7
  76. package/dist/publish.d.ts.map +1 -1
  77. package/dist/publish.js +92 -15
  78. package/dist/publish.js.map +1 -1
  79. package/dist/registry.d.ts +58 -0
  80. package/dist/registry.d.ts.map +1 -1
  81. package/dist/registry.js +86 -25
  82. package/dist/registry.js.map +1 -1
  83. package/dist/release.d.ts +18 -0
  84. package/dist/release.d.ts.map +1 -1
  85. package/dist/release.js +30 -0
  86. package/dist/release.js.map +1 -1
  87. package/dist/reqe-verdict.d.ts +55 -0
  88. package/dist/reqe-verdict.d.ts.map +1 -0
  89. package/dist/reqe-verdict.js +173 -0
  90. package/dist/reqe-verdict.js.map +1 -0
  91. package/dist/reqe.d.ts +35 -14
  92. package/dist/reqe.d.ts.map +1 -1
  93. package/dist/reqe.js +85 -32
  94. package/dist/reqe.js.map +1 -1
  95. package/dist/round-exec.d.ts +10 -0
  96. package/dist/round-exec.d.ts.map +1 -1
  97. package/dist/round-exec.js +3 -2
  98. package/dist/round-exec.js.map +1 -1
  99. package/dist/round.d.ts +29 -0
  100. package/dist/round.d.ts.map +1 -1
  101. package/dist/round.js +8 -0
  102. package/dist/round.js.map +1 -1
  103. package/dist/session-retro.d.ts +1 -1
  104. package/dist/session-retro.js +3 -3
  105. package/dist/session-retro.js.map +1 -1
  106. package/dist/skill-drift.d.ts +18 -1
  107. package/dist/skill-drift.d.ts.map +1 -1
  108. package/dist/skill-drift.js +46 -13
  109. package/dist/skill-drift.js.map +1 -1
  110. package/dist/statusline.js +2 -2
  111. package/dist/statusline.js.map +1 -1
  112. package/dist/store-guard.js +3 -3
  113. package/dist/store-guard.js.map +1 -1
  114. package/dist/store-lock.d.ts +9 -0
  115. package/dist/store-lock.d.ts.map +1 -1
  116. package/dist/store-lock.js +22 -3
  117. package/dist/store-lock.js.map +1 -1
  118. package/dist/test-receipt.d.ts +66 -0
  119. package/dist/test-receipt.d.ts.map +1 -0
  120. package/dist/test-receipt.js +73 -0
  121. package/dist/test-receipt.js.map +1 -0
  122. package/dist/workflow-run.d.ts.map +1 -1
  123. package/dist/workflow-run.js +7 -8
  124. package/dist/workflow-run.js.map +1 -1
  125. package/package.json +1 -1
  126. package/sbom.json +384 -134
  127. package/src/__tests__/golden-baseline.test.ts +1 -1
  128. package/src/agentdb-index.ts +3 -3
  129. package/src/agentdb-reindex-marker.ts +4 -4
  130. package/src/agentdb-snapshot-rotation.ts +1 -1
  131. package/src/backup-freshness.ts +96 -0
  132. package/src/cmd-usage.ts +59 -8
  133. package/src/codex-hooks-assets.ts +15 -2
  134. package/src/compounding.ts +244 -10
  135. package/src/discrimination-gate.ts +2 -5
  136. package/src/doctor-instrument.ts +153 -0
  137. package/src/feature-tier.ts +14 -1
  138. package/src/guard.ts +128 -17
  139. package/src/index.ts +28 -7
  140. package/src/integration-apply.ts +2 -2
  141. package/src/lesson-payoff.ts +3 -3
  142. package/src/mutation-gate.ts +54 -1
  143. package/src/named-lock.ts +36 -11
  144. package/src/operations.ts +109 -3
  145. package/src/pack-inventory.ts +313 -0
  146. package/src/package-skill-layouts.ts +3 -2
  147. package/src/patterns.ts +18 -1
  148. package/src/publish.ts +101 -22
  149. package/src/registry.ts +114 -18
  150. package/src/release.ts +36 -0
  151. package/src/reqe-verdict.ts +174 -0
  152. package/src/reqe.ts +111 -35
  153. package/src/round-exec.ts +13 -2
  154. package/src/round.ts +38 -0
  155. package/src/session-retro.ts +3 -3
  156. package/src/skill-drift.ts +66 -12
  157. package/src/statusline.ts +2 -2
  158. package/src/store-guard.ts +3 -3
  159. package/src/store-lock.ts +20 -3
  160. package/src/test-receipt.ts +106 -0
  161. 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 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-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 marker = input.tail.lastIndexOf('\ncodex\n');
47
- const finalLine = marker < 0 ? '' : input.tail.slice(marker + '\ncodex\n'.length).split(/\r?\n/, 1)[0]?.trim() ?? '';
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,
@@ -18,7 +18,7 @@ import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, cl
18
18
  import { join, basename, dirname } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
20
 
21
- import { withNamedLockSync, NamedLockTimeoutError } from './named-lock.js';
21
+ import { withProjectLockSync, NamedLockTimeoutError } from './named-lock.js';
22
22
 
23
23
  export interface SessionEvent {
24
24
  readonly kind: 'user' | 'assistant' | 'tool';
@@ -696,7 +696,7 @@ const writeJsonAtomic = (path: string, value: unknown): void => {
696
696
  * a partial trailing line is left for the next scan) → fold the debt → persist state + sentinel.
697
697
  * NEVER throws (it runs inside a Stop hook; a broken scan must never surface as a turn failure).
698
698
  *
699
- * The WHOLE read→fold→write runs under `withNamedLockSync` (cross-family QE P1-3): two overlapping
699
+ * The WHOLE read→fold→write runs under `withProjectLockSync` (cross-family QE P1-3): two overlapping
700
700
  * Stop scans that both read the same offset/sentinel and then rename/unlink independently lose one
701
701
  * side's update — atomic per-file renames do not prevent that, only mutual exclusion does. The
702
702
  * critical section is short and synchronous (bounded ≤8 MB read, no subprocess, 65 ms measured on
@@ -707,7 +707,7 @@ const writeJsonAtomic = (path: string, value: unknown): void => {
707
707
  export function runRetroTailScan(dzDir: string, transcriptPath: string | null, nowIso?: string): TailScanOutcome {
708
708
  if (transcriptPath === null || transcriptPath === '') return { status: 'no-transcript', scannedBytes: 0, offset: 0 };
709
709
  try {
710
- return withNamedLockSync(
710
+ return withProjectLockSync(
711
711
  dirname(dzDir),
712
712
  RETRO_SCAN_LOCK_NAME,
713
713
  () => scanTailUnderLock(dzDir, transcriptPath, nowIso),
@@ -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
- const IGNORED_ENTRIES = new Set(['node_modules', '__pycache__', '.DS_Store', 'run-history.json']);
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
- for (const entry of readdirSync(dir)) {
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
- function comparePeers(copies: readonly string[]): { driftFiles: number; missingFiles: number; totalFiles: number } {
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
- if (existsSync(p)) hashes.add(md5(p));
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
- * `result.drifted.length === 0` is the exact condition the CI gate keys on.
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(copies);
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; // report only — write NOTHING
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 { withNamedLockSync } from './named-lock.js';
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
- : withNamedLockSync(root, 'fa-phase-slot', transition);
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
@@ -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 { withNamedLockSync } from './named-lock.js';
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 withNamedLockSync(guardRoot, `store-guard-${basename(path, '.json')}`, () => {
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 withNamedLockSync(guardRoot, `store-guard-${basename(path, '.json')}`, () => {
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,