@dzhechkov/harness-core 0.8.11 → 0.8.21

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 (205) hide show
  1. package/.dz-manifest.json +399 -139
  2. package/LICENSE +13 -0
  3. package/README.md +420 -6
  4. package/dist/agents-policy.d.ts +15 -1
  5. package/dist/agents-policy.d.ts.map +1 -1
  6. package/dist/agents-policy.js +27 -1
  7. package/dist/agents-policy.js.map +1 -1
  8. package/dist/amendment-trace.d.ts +72 -0
  9. package/dist/amendment-trace.d.ts.map +1 -1
  10. package/dist/amendment-trace.js +385 -17
  11. package/dist/amendment-trace.js.map +1 -1
  12. package/dist/backlog-public.d.ts +153 -0
  13. package/dist/backlog-public.d.ts.map +1 -0
  14. package/dist/backlog-public.js +415 -0
  15. package/dist/backlog-public.js.map +1 -0
  16. package/dist/backlog-transitions.d.ts +48 -0
  17. package/dist/backlog-transitions.d.ts.map +1 -0
  18. package/dist/backlog-transitions.js +64 -0
  19. package/dist/backlog-transitions.js.map +1 -0
  20. package/dist/backlog.d.ts.map +1 -1
  21. package/dist/backlog.js +13 -0
  22. package/dist/backlog.js.map +1 -1
  23. package/dist/claim-check.d.ts.map +1 -1
  24. package/dist/claim-check.js +24 -2
  25. package/dist/claim-check.js.map +1 -1
  26. package/dist/claude-hooks-assets.d.ts +93 -0
  27. package/dist/claude-hooks-assets.d.ts.map +1 -0
  28. package/dist/claude-hooks-assets.js +208 -0
  29. package/dist/claude-hooks-assets.js.map +1 -0
  30. package/dist/clean-room-smoke.d.ts +83 -0
  31. package/dist/clean-room-smoke.d.ts.map +1 -0
  32. package/dist/clean-room-smoke.js +138 -0
  33. package/dist/clean-room-smoke.js.map +1 -0
  34. package/dist/cmd-usage.d.ts.map +1 -1
  35. package/dist/cmd-usage.js +36 -6
  36. package/dist/cmd-usage.js.map +1 -1
  37. package/dist/codex-hooks-assets.d.ts +25 -7
  38. package/dist/codex-hooks-assets.d.ts.map +1 -1
  39. package/dist/codex-hooks-assets.js +138 -30
  40. package/dist/codex-hooks-assets.js.map +1 -1
  41. package/dist/codex-hooks.d.ts +21 -1
  42. package/dist/codex-hooks.d.ts.map +1 -1
  43. package/dist/codex-hooks.js +21 -1
  44. package/dist/codex-hooks.js.map +1 -1
  45. package/dist/course-staleness.d.ts +19 -0
  46. package/dist/course-staleness.d.ts.map +1 -0
  47. package/dist/course-staleness.js +95 -0
  48. package/dist/course-staleness.js.map +1 -0
  49. package/dist/destructive-guard-hook.d.ts +40 -0
  50. package/dist/destructive-guard-hook.d.ts.map +1 -0
  51. package/dist/destructive-guard-hook.js +109 -0
  52. package/dist/destructive-guard-hook.js.map +1 -0
  53. package/dist/destructive-guard.d.ts +27 -0
  54. package/dist/destructive-guard.d.ts.map +1 -0
  55. package/dist/destructive-guard.js +2808 -0
  56. package/dist/destructive-guard.js.map +1 -0
  57. package/dist/discrimination-gate.d.ts +28 -3
  58. package/dist/discrimination-gate.d.ts.map +1 -1
  59. package/dist/discrimination-gate.js +76 -16
  60. package/dist/discrimination-gate.js.map +1 -1
  61. package/dist/feature-adr-routing.d.ts +95 -1
  62. package/dist/feature-adr-routing.d.ts.map +1 -1
  63. package/dist/feature-adr-routing.js +193 -27
  64. package/dist/feature-adr-routing.js.map +1 -1
  65. package/dist/guard.d.ts +90 -0
  66. package/dist/guard.d.ts.map +1 -1
  67. package/dist/guard.js +271 -0
  68. package/dist/guard.js.map +1 -1
  69. package/dist/harness-core-location.d.ts +18 -0
  70. package/dist/harness-core-location.d.ts.map +1 -0
  71. package/dist/harness-core-location.js +42 -0
  72. package/dist/harness-core-location.js.map +1 -0
  73. package/dist/index.d.ts +24 -6
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +24 -3
  76. package/dist/index.js.map +1 -1
  77. package/dist/lead-shift-gate.d.ts +95 -0
  78. package/dist/lead-shift-gate.d.ts.map +1 -0
  79. package/dist/lead-shift-gate.js +100 -0
  80. package/dist/lead-shift-gate.js.map +1 -0
  81. package/dist/ledger-backfill.d.ts +11 -1
  82. package/dist/ledger-backfill.d.ts.map +1 -1
  83. package/dist/ledger-backfill.js +19 -0
  84. package/dist/ledger-backfill.js.map +1 -1
  85. package/dist/loop-blobs.generated.d.ts +1 -1
  86. package/dist/loop-blobs.generated.d.ts.map +1 -1
  87. package/dist/loop-blobs.generated.js +12 -3
  88. package/dist/loop-blobs.generated.js.map +1 -1
  89. package/dist/loop-lint.d.ts.map +1 -1
  90. package/dist/loop-lint.js +56 -7
  91. package/dist/loop-lint.js.map +1 -1
  92. package/dist/loop-plan-graph.d.ts +1 -3
  93. package/dist/loop-plan-graph.d.ts.map +1 -1
  94. package/dist/loop-plan-graph.js +70 -1
  95. package/dist/loop-plan-graph.js.map +1 -1
  96. package/dist/loop-trace.d.ts.map +1 -1
  97. package/dist/loop-trace.js +16 -2
  98. package/dist/loop-trace.js.map +1 -1
  99. package/dist/managed-hooks.d.ts +5 -6
  100. package/dist/managed-hooks.d.ts.map +1 -1
  101. package/dist/managed-hooks.js +2 -2
  102. package/dist/managed-hooks.js.map +1 -1
  103. package/dist/model-recommender.d.ts.map +1 -1
  104. package/dist/model-recommender.js +14 -3
  105. package/dist/model-recommender.js.map +1 -1
  106. package/dist/operations.d.ts.map +1 -1
  107. package/dist/operations.js +82 -0
  108. package/dist/operations.js.map +1 -1
  109. package/dist/patterns.d.ts +23 -0
  110. package/dist/patterns.d.ts.map +1 -1
  111. package/dist/patterns.js +10 -0
  112. package/dist/patterns.js.map +1 -1
  113. package/dist/publish.d.ts +11 -0
  114. package/dist/publish.d.ts.map +1 -1
  115. package/dist/publish.js +16 -2
  116. package/dist/publish.js.map +1 -1
  117. package/dist/registry.d.ts.map +1 -1
  118. package/dist/registry.js +3 -2
  119. package/dist/registry.js.map +1 -1
  120. package/dist/score.d.ts.map +1 -1
  121. package/dist/score.js +15 -4
  122. package/dist/score.js.map +1 -1
  123. package/dist/session-retro.d.ts +121 -2
  124. package/dist/session-retro.d.ts.map +1 -1
  125. package/dist/session-retro.js +454 -11
  126. package/dist/session-retro.js.map +1 -1
  127. package/dist/setup.d.ts +28 -0
  128. package/dist/setup.d.ts.map +1 -1
  129. package/dist/setup.js +223 -6
  130. package/dist/setup.js.map +1 -1
  131. package/dist/sign.d.ts.map +1 -1
  132. package/dist/sign.js +18 -1
  133. package/dist/sign.js.map +1 -1
  134. package/dist/skills-verify.d.ts +34 -1
  135. package/dist/skills-verify.d.ts.map +1 -1
  136. package/dist/skills-verify.js +82 -0
  137. package/dist/skills-verify.js.map +1 -1
  138. package/dist/stage-line.d.ts +68 -0
  139. package/dist/stage-line.d.ts.map +1 -0
  140. package/dist/stage-line.js +129 -0
  141. package/dist/stage-line.js.map +1 -0
  142. package/dist/statusline.d.ts +99 -0
  143. package/dist/statusline.d.ts.map +1 -1
  144. package/dist/statusline.js +310 -44
  145. package/dist/statusline.js.map +1 -1
  146. package/dist/store-counts.d.ts +26 -0
  147. package/dist/store-counts.d.ts.map +1 -0
  148. package/dist/store-counts.js +125 -0
  149. package/dist/store-counts.js.map +1 -0
  150. package/dist/store-guard.d.ts +106 -0
  151. package/dist/store-guard.d.ts.map +1 -0
  152. package/dist/store-guard.js +294 -0
  153. package/dist/store-guard.js.map +1 -0
  154. package/dist/swarm-brief.d.ts +95 -0
  155. package/dist/swarm-brief.d.ts.map +1 -0
  156. package/dist/swarm-brief.js +660 -0
  157. package/dist/swarm-brief.js.map +1 -0
  158. package/dist/trace-bundle.d.ts +8 -0
  159. package/dist/trace-bundle.d.ts.map +1 -1
  160. package/dist/trace-bundle.js +11 -0
  161. package/dist/trace-bundle.js.map +1 -1
  162. package/package.json +12 -11
  163. package/sbom.json +804 -154
  164. package/src/agents-policy.ts +46 -2
  165. package/src/amendment-trace.ts +441 -19
  166. package/src/backlog-public.ts +503 -0
  167. package/src/backlog-transitions.ts +77 -0
  168. package/src/backlog.ts +12 -0
  169. package/src/claim-check.ts +25 -2
  170. package/src/claude-hooks-assets.ts +227 -0
  171. package/src/clean-room-smoke.ts +195 -0
  172. package/src/cmd-usage.ts +29 -5
  173. package/src/codex-hooks-assets.ts +140 -30
  174. package/src/codex-hooks.ts +21 -1
  175. package/src/course-staleness.ts +125 -0
  176. package/src/destructive-guard-hook.ts +151 -0
  177. package/src/destructive-guard.ts +3027 -0
  178. package/src/discrimination-gate.ts +98 -19
  179. package/src/feature-adr-routing.ts +220 -22
  180. package/src/guard.ts +318 -0
  181. package/src/harness-core-location.ts +44 -0
  182. package/src/index.ts +111 -3
  183. package/src/lead-shift-gate.ts +145 -0
  184. package/src/ledger-backfill.ts +20 -1
  185. package/src/loop-blobs.generated.ts +12 -3
  186. package/src/loop-lint.ts +52 -7
  187. package/src/loop-plan-graph.ts +66 -1
  188. package/src/loop-trace.ts +13 -1
  189. package/src/managed-hooks.ts +5 -6
  190. package/src/model-recommender.ts +14 -3
  191. package/src/operations.ts +75 -0
  192. package/src/patterns.ts +33 -0
  193. package/src/publish.ts +27 -2
  194. package/src/registry.ts +3 -2
  195. package/src/score.ts +16 -4
  196. package/src/session-retro.ts +466 -11
  197. package/src/setup.ts +246 -9
  198. package/src/sign.ts +18 -1
  199. package/src/skills-verify.ts +99 -1
  200. package/src/stage-line.ts +151 -0
  201. package/src/statusline.ts +396 -47
  202. package/src/store-counts.ts +154 -0
  203. package/src/store-guard.ts +388 -0
  204. package/src/swarm-brief.ts +661 -0
  205. package/src/trace-bundle.ts +10 -0
package/src/statusline.ts CHANGED
@@ -14,12 +14,20 @@
14
14
  * @packageDocumentation
15
15
  */
16
16
 
17
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
18
- import { join, resolve } from 'node:path';
17
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
18
+ 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
23
  import { RECALL_USAGE_LOG_RELATIVE, aggregateRecallUsage, parseRecallUsageLog } from './recall-usage.js';
24
+ import { countLearningStoreRowsReadonly, countSqliteRowsReadonly } from './store-counts.js';
25
+ import {
26
+ checkStoreHealth,
27
+ readStoreMark,
28
+ storeSnapshotPath,
29
+ type StoreHealthVerdict,
30
+ } from './store-guard.js';
23
31
 
24
32
  /**
25
33
  * Live learning state for one in-flight `/feature-adr` run — the per-run visibility panel
@@ -44,14 +52,32 @@ export interface FeatureAdrState {
44
52
  readonly reinforced?: number;
45
53
  /** ISO timestamp of the write — drives the freshness window on the render path. */
46
54
  readonly ts: string;
55
+ /** Identity of one pipeline invocation. The monotonic guard never crosses two distinct runs. */
56
+ readonly runId?: string;
47
57
  /** Optional run mode (e.g. "reference", "full-qe", "full-qe-extended"). */
48
58
  readonly mode?: string;
59
+ /** Complexity tier of the run (S/M/L/XL) — drives done/total on the phase line. Carried forward
60
+ * by the write path when a later record omits it. */
61
+ readonly tier?: string;
62
+ /** ISO timestamp of the CURRENT phase's start — reset by the write path when the step label
63
+ * changes, carried forward while it stays the same. Drives the phase line's elapsed minutes. */
64
+ readonly phaseStartTs?: string;
49
65
  }
50
66
 
51
67
  /** A snapshot of dz's self-learning state for one project (all fields best-effort). */
52
68
  export interface StatuslineData {
53
69
  /** Count of learned patterns in the project's unified memory store. */
54
70
  readonly patterns: number;
71
+ /** Exact lexical-tier availability split; omitted when the enhanced readonly count cannot be established. */
72
+ readonly patternBreakdown?: {
73
+ readonly source: 'lexical';
74
+ readonly active: number;
75
+ readonly quarantined: number;
76
+ /** True once quarantine contains at least one third of the lexical pool. */
77
+ readonly attention: boolean;
78
+ /** Absolute lexical/vector quarantine-label delta, present only above the tolerated drift threshold. */
79
+ readonly tierDelta?: number;
80
+ };
55
81
  /** Count of learned patterns that the live recall hook has actually injected at least once. */
56
82
  readonly usedPatterns?: number;
57
83
  /** Number of sources registered in the durable cross-project knowledge brain. */
@@ -60,8 +86,23 @@ export interface StatuslineData {
60
86
  readonly consolidatedAgeH?: number;
61
87
  /** Live `/feature-adr` learning state — present ONLY when a fresh run is in flight. */
62
88
  readonly featureAdr?: FeatureAdrState;
89
+ /** Persistent owner-facing store warning; healthy/no-mark/error paths omit it. */
90
+ readonly storeHealth?: StatuslineStoreHealth;
91
+ }
92
+
93
+ /** Render-ready details for a non-healthy store verdict. */
94
+ export interface StatuslineStoreHealth {
95
+ readonly verdict: Exclude<StoreHealthVerdict, 'ok' | 'no-mark'>;
96
+ /** Previous maximum for the affected store tier. */
97
+ readonly previousMax?: number;
98
+ /** Snapshot directory used by cold-start recovery guidance. */
99
+ readonly snapshotPath?: string;
100
+ /** Basenames of store files whose readonly count failed. */
101
+ readonly unreadableFiles?: readonly string[];
63
102
  }
64
103
 
104
+ const QUARANTINE_TIER_DRIFT_TOLERANCE = 5;
105
+
65
106
  /** Path of the SQLite pattern store (the Tier-3 backend). */
66
107
  function sqlitePatternPath(projectRoot: string): string {
67
108
  return join(projectRoot, '.dz', 'memory', 'patterns.sqlite');
@@ -99,38 +140,32 @@ export function featureAdrStatePath(projectRoot: string, slug?: string): string
99
140
 
100
141
  /**
101
142
  * Freshness window for the `/feature-adr` panel: a run older than this is considered finished, so
102
- * its state must NOT keep showing a stale panel in the status bar. 30 minutes (in ms).
143
+ * its state must NOT keep showing a stale panel in the status bar. 90 minutes (in ms).
144
+ *
145
+ * fa-phase-statusline (ADR-001 D4): extended 30→90 min as an EXPLICIT behaviour change so the phase
146
+ * line can fade in stages — the renderer shows `⚠ <N>м без отчёта` from 30 min of slot silence and
147
+ * nothing at all from 90. One window for the reader; the staging lives in
148
+ * {@link renderFeatureAdrPhaseLine}.
103
149
  */
104
- const FEATURE_ADR_FRESH_MS = 30 * 60 * 1_000;
150
+ const FEATURE_ADR_FRESH_MS = 90 * 60 * 1_000;
105
151
 
106
- interface ReadonlyCountDb {
107
- pragma: (s: string) => void;
108
- prepare: (q: string) => { get: (...a: unknown[]) => unknown };
109
- close: () => void;
110
- }
152
+ /** Slot silence (ms) after which the phase line renders `⚠ <N>м без отчёта` instead of progress. */
153
+ const FEATURE_ADR_WARN_MS = 30 * 60 * 1_000;
154
+
155
+ /** Valid complexity tiers for the phase line — anything else renders as `[?]` with `?/?`. */
156
+ const FEATURE_ADR_TIERS = new Set(['S', 'M', 'L', 'XL']);
111
157
 
112
158
  /**
113
- * Count learned patterns via a direct **readonly** `COUNT(*)` with a short
114
- * `busy_timeout` no FTS rebuild, no schema write, no waiting on a live writer.
115
- * Returns `undefined` (not `0`) when the fast path is unavailable so the caller
116
- * can fall back to a readonly jsonl line-count; any error → `undefined`.
159
+ * Active-step lists per tier (mirrors the Step-0 router bands in the /feature-adr SKILL):
160
+ * done/total on the phase line is the label's step-number POSITION in its tier's list. A label
161
+ * whose number is not in the list renders `?/?` the panel never guesses progress.
117
162
  */
118
- function countSqlitePatternsReadonly(sqlitePath: string): number | undefined {
119
- try {
120
- const require = createRequire(import.meta.url);
121
- const Database = require('better-sqlite3') as new (p: string, o?: object) => ReadonlyCountDb;
122
- const db = new Database(sqlitePath, { readonly: true });
123
- try {
124
- db.pragma('busy_timeout = 100'); // back off fast — never block the 300ms refresh on a write lock
125
- const row = db.prepare('SELECT COUNT(*) as cnt FROM memory_records').get() as { cnt?: unknown };
126
- return typeof row?.cnt === 'number' ? row.cnt : undefined;
127
- } finally {
128
- db.close();
129
- }
130
- } catch {
131
- return undefined;
132
- }
133
- }
163
+ const FEATURE_ADR_ACTIVE_STEPS: Record<string, readonly number[]> = {
164
+ S: [0, 1, 6, 7, 8],
165
+ M: [0, 1, 3, 3.5, 5, 6, 7, 8],
166
+ L: [0, 1, 2, 3, 3.5, 4, 5, 6, 7, 8, 9],
167
+ XL: [0, 1, 2, 3, 3.5, 4, 5, 6, 7, 8, 9],
168
+ };
134
169
 
135
170
  /**
136
171
  * Count the legacy/default `.dz/patterns.jsonl` store by reading the FILE directly — READONLY, no
@@ -157,7 +192,7 @@ function countJsonlPatternsReadonly(projectRoot: string): number {
157
192
  function countLearnedPatterns(projectRoot: string): number {
158
193
  const sqlitePath = sqlitePatternPath(projectRoot);
159
194
  if (existsSync(sqlitePath)) {
160
- const fast = countSqlitePatternsReadonly(sqlitePath);
195
+ const fast = countSqliteRowsReadonly(sqlitePath, 'memory_records');
161
196
  if (fast !== undefined) return fast;
162
197
  }
163
198
  return countJsonlPatternsReadonly(projectRoot);
@@ -238,6 +273,7 @@ export function readFeatureAdrState(projectRoot: string, now: number = Date.now(
238
273
  const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<FeatureAdrState>;
239
274
  if (typeof parsed.slug !== 'string' || parsed.slug.length === 0) return undefined;
240
275
  if (typeof parsed.step !== 'string' || parsed.step.length === 0) return undefined;
276
+ if (parsed.step.trim() === 'done') return undefined; // terminal slots never enter arbitration
241
277
  if (typeof parsed.ts !== 'string') return undefined;
242
278
  const tsMs = Date.parse(parsed.ts);
243
279
  if (Number.isNaN(tsMs)) return undefined;
@@ -257,7 +293,11 @@ export function readFeatureAdrState(projectRoot: string, now: number = Date.now(
257
293
  stored: num(parsed.stored),
258
294
  ...(num(parsed.reinforced) > 0 ? { reinforced: num(parsed.reinforced) } : {}),
259
295
  ts: parsed.ts,
296
+ ...(typeof parsed.runId === 'string' && parsed.runId.trim() !== '' ? { runId: parsed.runId } : {}),
260
297
  ...(typeof parsed.mode === 'string' && parsed.mode.length > 0 ? { mode: parsed.mode } : {}),
298
+ ...(typeof parsed.tier === 'string' && FEATURE_ADR_TIERS.has(parsed.tier) ? { tier: parsed.tier } : {}),
299
+ ...(typeof parsed.phaseStartTs === 'string' && !Number.isNaN(Date.parse(parsed.phaseStartTs))
300
+ ? { phaseStartTs: parsed.phaseStartTs } : {}),
261
301
  };
262
302
  return { state, tsMs, rank: kind === 'feature-adr' ? 1 : 0 };
263
303
  } catch {
@@ -287,6 +327,97 @@ export interface WriteFeatureAdrStateInput {
287
327
  readonly stored: number;
288
328
  readonly reinforced?: number;
289
329
  readonly mode?: string;
330
+ /** Identity of one pipeline invocation. Empty strings are not identities. */
331
+ readonly runId?: string;
332
+ /** Complexity tier (S/M/L/XL). Invalid/absent → the previous slot's tier is carried forward. */
333
+ readonly tier?: string;
334
+ /**
335
+ * TEST-ONLY seam (ADR-002 C2 RED half): run the slot transaction WITHOUT the
336
+ * `fa-phase-slot` named lock, so `statusline-phase-lock.test.ts` can reproduce the lost update
337
+ * the lock prevents. Never set by any shipped caller — `statusline-phase-lock.test.ts` asserts
338
+ * `grep -c _unsafeSkipLock` on the CLI source is 0, so this cannot be reached from `dz`.
339
+ */
340
+ readonly _unsafeSkipLock?: boolean;
341
+ /**
342
+ * TEST-ONLY seam, the twin of `_unsafeSkipLock`: busy-hold this many ms INSIDE the transition,
343
+ * between reading the previous slot and writing the new one. Without it the read-modify-write
344
+ * window is sub-millisecond and the RED half would be reproducing a race by LUCK — a flaky test
345
+ * is not a proof. With it the interleaving is deterministic in both halves: unlocked, two writers
346
+ * provably observe the same previous slot; locked, they provably do not. Never set by any shipped
347
+ * caller (same grep assertion as `_unsafeSkipLock`).
348
+ */
349
+ readonly _unsafeHoldMs?: number;
350
+ /**
351
+ * TEST-ONLY seam: fail after phase telemetry has been appended but before the slot is written.
352
+ * This makes the partial-write receipt deterministic without fabricating a phase row from a
353
+ * missing previous slot. Never set by a shipped caller.
354
+ */
355
+ readonly _unsafeFailSlotWrite?: boolean;
356
+ }
357
+
358
+ /**
359
+ * The outcome of a slot write. `state` is the slot as written; `refused` is a SHORT machine-ish
360
+ * reason when the operation did not complete. `refusedEffect` says whether no disk effect is known
361
+ * (`none`, callback never began) or a partial effect is possible (`unknown`, callback began).
362
+ * Exactly one of `state` and `refused` is present. The CLI prints `refused` on stderr.
363
+ */
364
+ export interface WriteFeatureAdrStateResult {
365
+ readonly state?: FeatureAdrState;
366
+ readonly refused?: string;
367
+ /** Known disk effect of a refusal: `none` before the transaction, `unknown` once it began. */
368
+ readonly refusedEffect?: 'none' | 'unknown';
369
+ }
370
+
371
+ /**
372
+ * Read ONE slot file raw (no freshness window, no arbitration) — the write path's "previous state".
373
+ * Best-effort: absent/corrupt → `undefined`.
374
+ */
375
+ function readPreviousSlot(root: string, slug: string): Partial<FeatureAdrState> | undefined {
376
+ try {
377
+ const parsed = JSON.parse(readFileSync(featureAdrStatePath(root, slug), 'utf-8')) as Partial<FeatureAdrState>;
378
+ if (typeof parsed.step !== 'string' || typeof parsed.ts !== 'string') return undefined;
379
+ return parsed;
380
+ } catch {
381
+ return undefined;
382
+ }
383
+ }
384
+
385
+ /**
386
+ * fa-phase-statusline (ADR-001 D3): when a step label CHANGES, the phase that just ended leaves one
387
+ * telemetry row in the EXISTING `.dz/feature-adr/run-cost-ledger.jsonl`:
388
+ * `{"_schema":"feature-adr-phase/1","kind":"phase",slug,tier,mode,step,wallSec,ts}`.
389
+ * DELIBERATE field choices, both load-bearing:
390
+ * - `ts`, never `date` — cadence.ts skips ledger rows without a string `date`, so shipment dating
391
+ * is untouched by construction (pinned in cadence.test.ts);
392
+ * - `wallSec` (wall seconds of the ended phase), never `minutes` — 1-3 minute phases keep resolution.
393
+ * The ledger is NEVER created here: phase rows enrich the existing witness file, they are not a new
394
+ * store. Append-only (`appendFileSync`, one line) — not a read-modify-write, so no named lock owed.
395
+ * Best-effort: any failure is swallowed (the panel write must never break the pipeline).
396
+ */
397
+ function appendPhaseRow(
398
+ root: string,
399
+ prev: Partial<FeatureAdrState>,
400
+ resolved: { slug: string; tier?: string; mode?: string },
401
+ now: number,
402
+ ): void {
403
+ try {
404
+ const ledger = join(root, '.dz', 'feature-adr', 'run-cost-ledger.jsonl');
405
+ if (!existsSync(ledger)) return; // append to the EXISTING ledger only — never create one
406
+ const startedMs = Date.parse(prev.phaseStartTs ?? prev.ts ?? '');
407
+ if (Number.isNaN(startedMs)) return; // an unmeasurable phase leaves no row — never a guess
408
+ const wallSec = Math.max(0, Math.round((now - startedMs) / 1000));
409
+ const row = {
410
+ _schema: 'feature-adr-phase/1',
411
+ kind: 'phase',
412
+ slug: resolved.slug,
413
+ tier: resolved.tier ?? null,
414
+ mode: prev.mode ?? resolved.mode ?? null,
415
+ step: prev.step,
416
+ wallSec,
417
+ ts: new Date(now).toISOString(),
418
+ };
419
+ appendFileSync(ledger, `${JSON.stringify(row)}\n`);
420
+ } catch { /* best-effort telemetry — never blocks the slot write */ }
290
421
  }
291
422
 
292
423
  /**
@@ -302,30 +433,36 @@ export function writeFeatureAdrState(
302
433
  input: WriteFeatureAdrStateInput,
303
434
  now: number = Date.now(),
304
435
  ): FeatureAdrState | undefined {
436
+ return writeFeatureAdrStateDetailed(projectRoot, input, now).state;
437
+ }
438
+
439
+ /**
440
+ * The same write as {@link writeFeatureAdrState}, but it SAYS WHY it did not complete and what is
441
+ * known about its disk effect. A refusal before the transition returns `refusedEffect:'none'`; once
442
+ * the callback begins, a refusal returns `refusedEffect:'unknown'` because an append or slot write
443
+ * may already have landed. The pipeline still never breaks — the caller decides, this never throws.
444
+ */
445
+ export function writeFeatureAdrStateDetailed(
446
+ projectRoot: string,
447
+ input: WriteFeatureAdrStateInput,
448
+ now: number = Date.now(),
449
+ ): WriteFeatureAdrStateResult {
305
450
  const root = resolve(projectRoot);
451
+ let transactionStarted = false;
306
452
  let pool = 0;
307
453
  try {
308
454
  pool = countLearnedPatterns(root);
309
455
  } catch {
310
456
  pool = 0;
311
457
  }
312
- const state: FeatureAdrState = {
313
- kind: input.kind === 'loop' ? 'loop' : 'feature-adr',
314
- slug: input.slug,
315
- step: input.step,
316
- pool,
317
- recalled: Number.isFinite(input.recalled) ? input.recalled : 0,
318
- stored: Number.isFinite(input.stored) ? input.stored : 0,
319
- ...(input.reinforced !== undefined && Number.isFinite(input.reinforced) ? { reinforced: input.reinforced } : {}),
320
- ts: new Date(now).toISOString(),
321
- ...(input.mode !== undefined && input.mode.length > 0 ? { mode: input.mode } : {}),
322
- };
458
+
323
459
  try {
324
460
  const dir = featureAdrStateDir(root);
325
461
  mkdirSync(dir, { recursive: true });
326
462
 
327
463
  // Housekeeping belongs only on this write path, never the ~300ms render path. Every file is
328
464
  // independently guarded so an unreadable/racing entry cannot prevent the live state write.
465
+ // Kept OUTSIDE the lock below: it is per-file guarded and takes no part in the transition race.
329
466
  try {
330
467
  const cutoff = Date.now() - 24 * 60 * 60 * 1_000;
331
468
  for (const name of readdirSync(dir)) {
@@ -337,12 +474,104 @@ export function writeFeatureAdrState(
337
474
  }
338
475
  } catch { /* best-effort directory cleanup */ }
339
476
 
340
- const path = featureAdrStatePath(root, input.slug);
341
- writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
342
- } catch {
343
- return undefined;
477
+ // fa-phase-statusline QE fix (P2, cross-family review of 656d6903): read/compare/append/write
478
+ // is a read-modify-write over a shared slot — two concurrent design-sibling ckpt agents both
479
+ // read the same previous slot, both see a label change, and both append a phase row, breaking
480
+ // the one-row-per-transition promise. So the whole transaction runs under the repo's named
481
+ // per-store lock (cross-runtime-concurrency rule) — a short, fully synchronous critical
482
+ // section, no subprocess, no model turn. A lock timeout throws → the outer catch refuses the
483
+ // write (returns undefined): no new claim rather than a raced one.
484
+ const transition = (): FeatureAdrState => {
485
+ transactionStarted = true;
486
+ // ADR-001 D3/D5: the previous slot is the phase clock. Same step label → the phase start
487
+ // carries forward; a CHANGED label ends the previous phase (one telemetry row) and starts a
488
+ // new one now. Tier is stored state, carried forward when a record omits it.
489
+ const prev = readPreviousSlot(root, input.slug);
490
+ const tier = typeof input.tier === 'string' && FEATURE_ADR_TIERS.has(input.tier)
491
+ ? input.tier
492
+ : (typeof prev?.tier === 'string' && FEATURE_ADR_TIERS.has(prev.tier) ? prev.tier : undefined);
493
+ const nowIso = new Date(now).toISOString();
494
+ const prevRunId = typeof prev?.runId === 'string' && prev.runId.trim() !== '' ? prev.runId.trim() : undefined;
495
+ const runId = typeof input.runId === 'string' && input.runId.trim() !== '' ? input.runId.trim() : undefined;
496
+ const sameRun = prevRunId !== undefined && runId !== undefined && prevRunId === runId;
497
+ const legacyRunScope = prevRunId === undefined && runId === undefined;
498
+ const sameRunScope = sameRun || legacyRunScope;
499
+
500
+ // MONOTONIC STEP GUARD (P1, cross-family review of 656d6903): the pipeline's pre-existing
501
+ // fallback recorder fires AFTER the router checkpoint, so "Step 0 recall" used to overwrite
502
+ // "Step 1 Design" and the panel deterministically regressed to Step 0 for the whole design
503
+ // fan (with the design time mis-attributed to Step 0 in telemetry). A plain step label whose
504
+ // number goes BACKWARDS within the SAME identified invocation is a stale duplicate of an
505
+ // earlier step: its counters/tier/mode/ts are absorbed, but the step and phase clock stand,
506
+ // and no phase row is written. Two absent identities retain the legacy fresh-slot behavior.
507
+ // Distinct identities, a one-sided identity, an explicit gate/waiting label (⛔/⏸), or an
508
+ // unparseable label never authorizes the guard to cross an unproven run boundary.
509
+ let step = input.step;
510
+ const isGateLabel = input.step.trimStart().startsWith('⛔') || input.step.trimStart().startsWith('⏸');
511
+ if (prev !== undefined && input.kind !== 'loop' && prev.kind !== 'loop' && !isGateLabel) {
512
+ const prevTsMs = Date.parse(prev.ts ?? '');
513
+ const prevFresh = !Number.isNaN(prevTsMs) && now - prevTsMs <= FEATURE_ADR_FRESH_MS;
514
+ const prevNum = parseStepNumber(prev.step ?? '');
515
+ const newNum = parseStepNumber(input.step);
516
+ const guardThisRun = sameRun || (legacyRunScope && prevFresh);
517
+ if (guardThisRun && prevNum !== undefined && newNum !== undefined && newNum < prevNum) {
518
+ step = prev.step as string; // stale duplicate — the run is further along than this record
519
+ }
520
+ }
521
+
522
+ // TEST-ONLY hold (see WriteFeatureAdrStateInput._unsafeHoldMs): widens the read→write window
523
+ // so the lost update is deterministic instead of lucky. Zero cost when unset.
524
+ if (typeof input._unsafeHoldMs === 'number' && input._unsafeHoldMs > 0) {
525
+ const until = Date.now() + input._unsafeHoldMs;
526
+ while (Date.now() < until) { /* deliberate busy hold — test seam only */ }
527
+ }
528
+
529
+ const samePhase = prev !== undefined && sameRunScope && prev.step === step;
530
+ const phaseStartTs = samePhase ? (prev.phaseStartTs ?? prev.ts ?? nowIso) : nowIso;
531
+ // Phase telemetry is a /feature-adr concept — loop slots never leave phase rows in the ledger.
532
+ if (prev !== undefined && sameRunScope && !samePhase && input.kind !== 'loop' && prev.kind !== 'loop') {
533
+ appendPhaseRow(root, prev, { slug: input.slug, ...(tier !== undefined ? { tier } : {}), ...(input.mode !== undefined && input.mode.length > 0 ? { mode: input.mode } : {}) }, now);
534
+ }
535
+
536
+ const state: FeatureAdrState = {
537
+ kind: input.kind === 'loop' ? 'loop' : 'feature-adr',
538
+ slug: input.slug,
539
+ step,
540
+ pool,
541
+ recalled: Number.isFinite(input.recalled) ? input.recalled : 0,
542
+ stored: Number.isFinite(input.stored) ? input.stored : 0,
543
+ ...(input.reinforced !== undefined && Number.isFinite(input.reinforced) ? { reinforced: input.reinforced } : {}),
544
+ ts: nowIso,
545
+ ...(runId !== undefined ? { runId } : {}),
546
+ ...(input.mode !== undefined && input.mode.length > 0 ? { mode: input.mode } : {}),
547
+ ...(tier !== undefined ? { tier } : {}),
548
+ phaseStartTs,
549
+ };
550
+ if (input._unsafeFailSlotWrite === true) {
551
+ throw new Error('test-only slot write failure after phase append');
552
+ }
553
+ writeFileSync(featureAdrStatePath(root, input.slug), `${JSON.stringify(state, null, 2)}\n`);
554
+ return state;
555
+ };
556
+
557
+ // The seam is TEST-ONLY (see WriteFeatureAdrStateInput._unsafeSkipLock): it exists so the RED
558
+ // half of the lock test can reproduce the lost update. Shipped callers never pass it.
559
+ const written = input._unsafeSkipLock === true
560
+ ? transition()
561
+ : withNamedLockSync(root, 'fa-phase-slot', transition);
562
+ return { state: written };
563
+ } catch (err) {
564
+ // A refusal is REPORTED, never mistaken for success. A lock timeout before the callback proves
565
+ // effect `none`; once the callback began, append/write progress is unknowable after an error.
566
+ const reason = err instanceof Error && err.message.trim() !== '' ? err.message : String(err);
567
+ return { refused: reason, refusedEffect: transactionStarted ? 'unknown' : 'none' };
344
568
  }
345
- return state;
569
+ }
570
+
571
+ /** First `Step <n>` number in a label, or `undefined` — shared by the guard and the renderer. */
572
+ function parseStepNumber(label: string): number | undefined {
573
+ const m = /Step\s+(\d+(?:\.\d+)?)/.exec(label);
574
+ return m !== null ? Number(m[1]) : undefined;
346
575
  }
347
576
 
348
577
  /**
@@ -357,12 +586,81 @@ export function statuslineData(projectRoot: string, now: number = Date.now()): S
357
586
  const root = resolve(projectRoot);
358
587
 
359
588
  let patterns = 0;
589
+ let storeRows: ReturnType<typeof countLearningStoreRowsReadonly> | undefined;
360
590
  try {
361
- patterns = countLearnedPatterns(root);
591
+ storeRows = countLearningStoreRowsReadonly(root);
592
+ patterns = typeof storeRows.lexicalRows === 'number'
593
+ ? storeRows.lexicalRows
594
+ : countJsonlPatternsReadonly(root);
362
595
  } catch {
363
596
  patterns = 0;
364
597
  }
365
598
 
599
+ let storeHealth: StatuslineStoreHealth | undefined;
600
+ try {
601
+ if (storeRows === undefined) throw new Error('store row counts unavailable');
602
+ const rows = storeRows;
603
+ const mark = readStoreMark(root);
604
+ const health = checkStoreHealth({
605
+ projectRoot: root,
606
+ lexicalRows: rows.lexicalRows,
607
+ lexicalSource: rows.lexicalSource,
608
+ vectorRows: rows.vectorRows,
609
+ mark,
610
+ });
611
+ if (health.verdict === 'collapsed') {
612
+ const vectorAffected = health.reason.startsWith('vector ');
613
+ const previousMax = vectorAffected ? mark?.vectorMax : mark?.lexicalMax;
614
+ storeHealth = {
615
+ verdict: health.verdict,
616
+ ...(previousMax === undefined ? {} : { previousMax }),
617
+ };
618
+ } else if (health.verdict === 'cold-start-over-existing') {
619
+ const previousMax = mark === undefined
620
+ ? undefined
621
+ : rows.lexicalRows === 0 && mark.lexicalMax > 0 ? mark.lexicalMax : mark.vectorMax;
622
+ storeHealth = {
623
+ verdict: health.verdict,
624
+ ...(previousMax === undefined ? {} : { previousMax }),
625
+ snapshotPath: storeSnapshotPath(root),
626
+ };
627
+ } else if (health.verdict === 'unreadable') {
628
+ storeHealth = {
629
+ verdict: health.verdict,
630
+ unreadableFiles: [
631
+ ...(rows.lexicalRows === 'unreadable' ? [basename(rows.lexicalSourcePath)] : []),
632
+ ...(rows.vectorRows === 'unreadable' && rows.vectorSourcePath !== undefined
633
+ ? [basename(rows.vectorSourcePath)] : []),
634
+ ],
635
+ };
636
+ } else if (health.verdict === 'source-changed') {
637
+ storeHealth = { verdict: health.verdict };
638
+ }
639
+ } catch {
640
+ storeHealth = undefined;
641
+ }
642
+
643
+ let patternBreakdown: StatuslineData['patternBreakdown'];
644
+ try {
645
+ if (storeRows !== undefined
646
+ && typeof storeRows.lexicalRows === 'number'
647
+ && typeof storeRows.lexicalQuarantinedRows === 'number') {
648
+ const quarantined = storeRows.lexicalQuarantinedRows;
649
+ const tierDelta = typeof storeRows.vectorQuarantinedRows === 'number'
650
+ ? Math.abs(quarantined - storeRows.vectorQuarantinedRows)
651
+ : undefined;
652
+ patternBreakdown = {
653
+ source: 'lexical',
654
+ active: storeRows.lexicalRows - quarantined,
655
+ quarantined,
656
+ attention: quarantined > 0 && quarantined * 3 >= storeRows.lexicalRows,
657
+ ...(tierDelta !== undefined && tierDelta > QUARANTINE_TIER_DRIFT_TOLERANCE ? { tierDelta } : {}),
658
+ };
659
+ }
660
+ } catch {
661
+ patternBreakdown = undefined;
662
+ }
663
+
366
664
  let brainSources = 0;
367
665
  try {
368
666
  brainSources = listBrain().length;
@@ -389,9 +687,60 @@ export function statuslineData(projectRoot: string, now: number = Date.now()): S
389
687
 
390
688
  return {
391
689
  patterns,
690
+ ...(patternBreakdown !== undefined ? { patternBreakdown } : {}),
392
691
  ...(usedPatterns !== undefined ? { usedPatterns } : {}),
393
692
  brainSources,
394
693
  ...(ageH !== undefined ? { consolidatedAgeH: ageH } : {}),
395
694
  ...(featureAdr !== undefined ? { featureAdr } : {}),
695
+ ...(storeHealth !== undefined ? { storeHealth } : {}),
396
696
  };
397
697
  }
698
+
699
+ /**
700
+ * Render the /feature-adr PHASE line (line format B, fa-phase-statusline ADR-001) — the second
701
+ * status-bar line:
702
+ *
703
+ * `📐 <slug> [<tier>] <done>/<total> ▶ <step label> · <N>м/—`
704
+ *
705
+ * States, in precedence order:
706
+ * - `undefined` (no line): a loop slot, the terminal label `done`, or ≥90 min of slot silence;
707
+ * - `⚠ <N>м без отчёта` from 30 min of slot silence (counted from the last report `ts`);
708
+ * - a label starting `⛔` or `⏸` replaces the `▶ <label>` portion VERBATIM (failure/waiting states
709
+ * are reported by the pipeline through the label itself, never synthesised here);
710
+ * - otherwise `▶ <label>` with elapsed minutes counted from the PHASE start (`phaseStartTs`).
711
+ *
712
+ * done/total is the label's step-number position in its tier's active-step list; an unknown tier,
713
+ * an unparseable label, or a number outside the list renders `?/?` — the panel never guesses.
714
+ * The estimate slot is fixed-width at the tail: v1 defers estimates entirely, so it is always `/—`
715
+ * (a future median would be computed on the WRITE path and stored in the slot as a ready number —
716
+ * this render path stays a pure function over the slot, no DB, no subprocess, no features/* sweep).
717
+ *
718
+ * @param state A fresh slot from {@link readFeatureAdrState} (the ONLY source of "in progress").
719
+ * @param now Injectable clock (epoch ms) — defaults to `Date.now()`.
720
+ */
721
+ export function renderFeatureAdrPhaseLine(state: FeatureAdrState, now: number = Date.now()): string | undefined {
722
+ if (state.kind === 'loop') return undefined;
723
+ const label = state.step.trim();
724
+ if (label === 'done') return undefined; // a finished run leaves the bar immediately
725
+
726
+ const tsMs = Date.parse(state.ts);
727
+ if (Number.isNaN(tsMs)) return undefined;
728
+ const silenceMs = now - tsMs;
729
+ const silenceMin = Math.floor(silenceMs / 60_000);
730
+ if (silenceMs >= FEATURE_ADR_FRESH_MS) return undefined; // fully faded (90 min)
731
+
732
+ const tier = state.tier !== undefined && FEATURE_ADR_TIERS.has(state.tier) ? state.tier : undefined;
733
+ const steps = tier !== undefined ? FEATURE_ADR_ACTIVE_STEPS[tier] : undefined;
734
+ const stepNum = parseStepNumber(label);
735
+ const idx = steps !== undefined && stepNum !== undefined ? steps.indexOf(stepNum) : -1;
736
+ const ratio = idx >= 0 && steps !== undefined ? `${idx}/${steps.length}` : '?/?';
737
+ const head = `📐 ${state.slug} [${tier ?? '?'}] ${ratio}`;
738
+
739
+ if (silenceMs >= FEATURE_ADR_WARN_MS) return `${head} ⚠ ${silenceMin}м без отчёта`;
740
+
741
+ const phaseStartMs = Date.parse(state.phaseStartTs ?? state.ts);
742
+ const elapsedMin = Number.isNaN(phaseStartMs) ? 0 : Math.max(0, Math.floor((now - phaseStartMs) / 60_000));
743
+ // ⛔ (a gate failure) and ⏸ (a waiting state) labels carry their own marker — verbatim, no ▶.
744
+ const stepPart = label.startsWith('⛔') || label.startsWith('⏸') ? label : `▶ ${label}`;
745
+ return `${head} ${stepPart} · ${elapsedMin}м/—`;
746
+ }