@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.
- package/.dz-manifest.json +399 -139
- package/LICENSE +13 -0
- package/README.md +420 -6
- package/dist/agents-policy.d.ts +15 -1
- package/dist/agents-policy.d.ts.map +1 -1
- package/dist/agents-policy.js +27 -1
- package/dist/agents-policy.js.map +1 -1
- package/dist/amendment-trace.d.ts +72 -0
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +385 -17
- package/dist/amendment-trace.js.map +1 -1
- package/dist/backlog-public.d.ts +153 -0
- package/dist/backlog-public.d.ts.map +1 -0
- package/dist/backlog-public.js +415 -0
- package/dist/backlog-public.js.map +1 -0
- package/dist/backlog-transitions.d.ts +48 -0
- package/dist/backlog-transitions.d.ts.map +1 -0
- package/dist/backlog-transitions.js +64 -0
- package/dist/backlog-transitions.js.map +1 -0
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +13 -0
- package/dist/backlog.js.map +1 -1
- package/dist/claim-check.d.ts.map +1 -1
- package/dist/claim-check.js +24 -2
- package/dist/claim-check.js.map +1 -1
- package/dist/claude-hooks-assets.d.ts +93 -0
- package/dist/claude-hooks-assets.d.ts.map +1 -0
- package/dist/claude-hooks-assets.js +208 -0
- package/dist/claude-hooks-assets.js.map +1 -0
- package/dist/clean-room-smoke.d.ts +83 -0
- package/dist/clean-room-smoke.d.ts.map +1 -0
- package/dist/clean-room-smoke.js +138 -0
- package/dist/clean-room-smoke.js.map +1 -0
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +36 -6
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +25 -7
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +138 -30
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +21 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +21 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/course-staleness.d.ts +19 -0
- package/dist/course-staleness.d.ts.map +1 -0
- package/dist/course-staleness.js +95 -0
- package/dist/course-staleness.js.map +1 -0
- package/dist/destructive-guard-hook.d.ts +40 -0
- package/dist/destructive-guard-hook.d.ts.map +1 -0
- package/dist/destructive-guard-hook.js +109 -0
- package/dist/destructive-guard-hook.js.map +1 -0
- package/dist/destructive-guard.d.ts +27 -0
- package/dist/destructive-guard.d.ts.map +1 -0
- package/dist/destructive-guard.js +2808 -0
- package/dist/destructive-guard.js.map +1 -0
- package/dist/discrimination-gate.d.ts +28 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +76 -16
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +95 -1
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +193 -27
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +90 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +271 -0
- package/dist/guard.js.map +1 -1
- package/dist/harness-core-location.d.ts +18 -0
- package/dist/harness-core-location.d.ts.map +1 -0
- package/dist/harness-core-location.js +42 -0
- package/dist/harness-core-location.js.map +1 -0
- package/dist/index.d.ts +24 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -3
- package/dist/index.js.map +1 -1
- package/dist/lead-shift-gate.d.ts +95 -0
- package/dist/lead-shift-gate.d.ts.map +1 -0
- package/dist/lead-shift-gate.js +100 -0
- package/dist/lead-shift-gate.js.map +1 -0
- package/dist/ledger-backfill.d.ts +11 -1
- package/dist/ledger-backfill.d.ts.map +1 -1
- package/dist/ledger-backfill.js +19 -0
- package/dist/ledger-backfill.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +12 -3
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-lint.d.ts.map +1 -1
- package/dist/loop-lint.js +56 -7
- package/dist/loop-lint.js.map +1 -1
- package/dist/loop-plan-graph.d.ts +1 -3
- package/dist/loop-plan-graph.d.ts.map +1 -1
- package/dist/loop-plan-graph.js +70 -1
- package/dist/loop-plan-graph.js.map +1 -1
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +16 -2
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +5 -6
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +2 -2
- package/dist/managed-hooks.js.map +1 -1
- package/dist/model-recommender.d.ts.map +1 -1
- package/dist/model-recommender.js +14 -3
- package/dist/model-recommender.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +82 -0
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts +23 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +10 -0
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +11 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +16 -2
- package/dist/publish.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +3 -2
- package/dist/registry.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +15 -4
- package/dist/score.js.map +1 -1
- package/dist/session-retro.d.ts +121 -2
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +454 -11
- package/dist/session-retro.js.map +1 -1
- package/dist/setup.d.ts +28 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +223 -6
- package/dist/setup.js.map +1 -1
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +18 -1
- package/dist/sign.js.map +1 -1
- package/dist/skills-verify.d.ts +34 -1
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +82 -0
- package/dist/skills-verify.js.map +1 -1
- package/dist/stage-line.d.ts +68 -0
- package/dist/stage-line.d.ts.map +1 -0
- package/dist/stage-line.js +129 -0
- package/dist/stage-line.js.map +1 -0
- package/dist/statusline.d.ts +99 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +310 -44
- package/dist/statusline.js.map +1 -1
- package/dist/store-counts.d.ts +26 -0
- package/dist/store-counts.d.ts.map +1 -0
- package/dist/store-counts.js +125 -0
- package/dist/store-counts.js.map +1 -0
- package/dist/store-guard.d.ts +106 -0
- package/dist/store-guard.d.ts.map +1 -0
- package/dist/store-guard.js +294 -0
- package/dist/store-guard.js.map +1 -0
- package/dist/swarm-brief.d.ts +95 -0
- package/dist/swarm-brief.d.ts.map +1 -0
- package/dist/swarm-brief.js +660 -0
- package/dist/swarm-brief.js.map +1 -0
- package/dist/trace-bundle.d.ts +8 -0
- package/dist/trace-bundle.d.ts.map +1 -1
- package/dist/trace-bundle.js +11 -0
- package/dist/trace-bundle.js.map +1 -1
- package/package.json +12 -11
- package/sbom.json +804 -154
- package/src/agents-policy.ts +46 -2
- package/src/amendment-trace.ts +441 -19
- package/src/backlog-public.ts +503 -0
- package/src/backlog-transitions.ts +77 -0
- package/src/backlog.ts +12 -0
- package/src/claim-check.ts +25 -2
- package/src/claude-hooks-assets.ts +227 -0
- package/src/clean-room-smoke.ts +195 -0
- package/src/cmd-usage.ts +29 -5
- package/src/codex-hooks-assets.ts +140 -30
- package/src/codex-hooks.ts +21 -1
- package/src/course-staleness.ts +125 -0
- package/src/destructive-guard-hook.ts +151 -0
- package/src/destructive-guard.ts +3027 -0
- package/src/discrimination-gate.ts +98 -19
- package/src/feature-adr-routing.ts +220 -22
- package/src/guard.ts +318 -0
- package/src/harness-core-location.ts +44 -0
- package/src/index.ts +111 -3
- package/src/lead-shift-gate.ts +145 -0
- package/src/ledger-backfill.ts +20 -1
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-lint.ts +52 -7
- package/src/loop-plan-graph.ts +66 -1
- package/src/loop-trace.ts +13 -1
- package/src/managed-hooks.ts +5 -6
- package/src/model-recommender.ts +14 -3
- package/src/operations.ts +75 -0
- package/src/patterns.ts +33 -0
- package/src/publish.ts +27 -2
- package/src/registry.ts +3 -2
- package/src/score.ts +16 -4
- package/src/session-retro.ts +466 -11
- package/src/setup.ts +246 -9
- package/src/sign.ts +18 -1
- package/src/skills-verify.ts +99 -1
- package/src/stage-line.ts +151 -0
- package/src/statusline.ts +396 -47
- package/src/store-counts.ts +154 -0
- package/src/store-guard.ts +388 -0
- package/src/swarm-brief.ts +661 -0
- 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.
|
|
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 =
|
|
150
|
+
const FEATURE_ADR_FRESH_MS = 90 * 60 * 1_000;
|
|
105
151
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|