@dzhechkov/harness-core 0.4.1 → 0.4.3
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 +136 -56
- package/README.md +4 -2
- package/dist/backlog.d.ts +35 -0
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +167 -3
- package/dist/backlog.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +48 -3
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +85 -24
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +9 -9
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-plan-graph.d.ts +49 -0
- package/dist/loop-plan-graph.d.ts.map +1 -0
- package/dist/loop-plan-graph.js +128 -0
- package/dist/loop-plan-graph.js.map +1 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +13 -15
- package/dist/loop-plan.js.map +1 -1
- package/dist/loop-render.d.ts.map +1 -1
- package/dist/loop-render.js +95 -16
- package/dist/loop-render.js.map +1 -1
- package/dist/loop-trace.d.ts +26 -1
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +65 -1
- package/dist/loop-trace.js.map +1 -1
- package/dist/model-recommender.d.ts +91 -0
- package/dist/model-recommender.d.ts.map +1 -0
- package/dist/model-recommender.js +186 -0
- package/dist/model-recommender.js.map +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -1
- package/dist/registry.js.map +1 -1
- package/dist/statusline.d.ts +10 -2
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +122 -36
- package/dist/statusline.js.map +1 -1
- package/dist/trace-bundle.d.ts +209 -0
- package/dist/trace-bundle.d.ts.map +1 -0
- package/dist/trace-bundle.js +601 -0
- package/dist/trace-bundle.js.map +1 -0
- package/dist/usage.d.ts +7 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +30 -2
- package/dist/usage.js.map +1 -1
- package/package.json +5 -5
- package/sbom.json +255 -55
- package/src/backlog.ts +176 -3
- package/src/feature-adr-checkpoints.ts +103 -4
- package/src/index.ts +4 -1
- package/src/loop-blobs.generated.ts +9 -9
- package/src/loop-plan-graph.ts +132 -0
- package/src/loop-plan.ts +13 -15
- package/src/loop-render.ts +93 -17
- package/src/loop-trace.ts +78 -1
- package/src/model-recommender.ts +228 -0
- package/src/registry.ts +4 -1
- package/src/statusline.ts +117 -30
- package/src/trace-bundle.ts +743 -0
- package/src/usage.ts +42 -2
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-recommender (backlog a9c3dd5c, function 3) — the PURE half of `dz routing recommend`.
|
|
3
|
+
*
|
|
4
|
+
* NOT a fifth analyzer (ADR-001 D1): this module HARVESTS per-stage (model → success) samples out of
|
|
5
|
+
* the harness's own workflow records (and imported run-meta sidecars) and hands them to the EXISTING
|
|
6
|
+
* `selectAutoCost` brain in routing-outcomes.ts — the same brain the `auto-cost` plan spec reads. The
|
|
7
|
+
* store that brain trusts (`.dz/routing-outcomes.json`) had never been fed before this feature.
|
|
8
|
+
*
|
|
9
|
+
* Honesty rules, load-bearing:
|
|
10
|
+
* - The ONLY grade a record carries is RUN-level. Attributing it to every stage's model is an
|
|
11
|
+
* INFERENCE, and the printed basis states the rule rather than implying it (ADR-001 D2).
|
|
12
|
+
* - Cross-family QE is UNREPRESENTABLE, not filtered: the qe pick is computed with the family
|
|
13
|
+
* parameter forced to the cross of the code pick's family (ADR-001 D3).
|
|
14
|
+
* - `--apply` idempotency lives here as a pure plan (`planFeed`): double-feeding the same runs
|
|
15
|
+
* would manufacture confidence the data does not contain (ADR-001 D4).
|
|
16
|
+
*
|
|
17
|
+
* No fs, no clock, no randomness — the CLI reads records and does the I/O.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { COST_LADDER, selectAutoCost, type AutoCostPick, type Family, type ModelRung } from './routing-outcomes.js';
|
|
21
|
+
|
|
22
|
+
/** success ⇔ grade ≥ this floor. DATA, exported, and printed in every basis (FR-4). */
|
|
23
|
+
export const GRADE_SUCCESS_FLOOR = 'B';
|
|
24
|
+
const SUCCESS_GRADES = new Set(['A+', 'A', 'A-', 'B+', 'B']);
|
|
25
|
+
|
|
26
|
+
export function gradeIsSuccess(grade: string): boolean {
|
|
27
|
+
return SUCCESS_GRADES.has(grade.trim().toUpperCase());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HarvestSample {
|
|
31
|
+
readonly runId: string;
|
|
32
|
+
readonly ts: string | null;
|
|
33
|
+
readonly tier: string;
|
|
34
|
+
readonly stage: string;
|
|
35
|
+
/** Normalized to a COST_LADDER rung id (e.g. `codex:gpt-5.5:xhigh (usage-switched)` → `gpt-5.5`). */
|
|
36
|
+
readonly model: string;
|
|
37
|
+
readonly success: boolean;
|
|
38
|
+
readonly grade: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Harvest {
|
|
42
|
+
readonly samples: HarvestSample[];
|
|
43
|
+
readonly runsUsed: number;
|
|
44
|
+
readonly window: { min: string; max: string } | null;
|
|
45
|
+
/** Records that contributed nothing, by WHY — printed in the basis, never silent (FR-7). */
|
|
46
|
+
readonly skipped: { noResult: number; noModels: number; noGrade: number; unknownModel: number };
|
|
47
|
+
/** The attribution rule, stated for the reader of every recommendation. */
|
|
48
|
+
readonly rule: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const RULE_TEXT =
|
|
52
|
+
`success ⇔ QE grade ≥ ${GRADE_SUCCESS_FLOOR} (run-level); the run's ONE grade is attributed to every ` +
|
|
53
|
+
`stage's model of that run — an inference, stated here because a hidden basis is an opinion in uniform`;
|
|
54
|
+
|
|
55
|
+
/** `codex:gpt-5.5:xhigh (usage-switched)` → `gpt-5.5`; claude ids pass through; unknown → null. */
|
|
56
|
+
export function normalizeModelId(raw: unknown): string | null {
|
|
57
|
+
if (typeof raw !== 'string' || raw === '') return null;
|
|
58
|
+
let id = raw.replace(/ \(usage-switched\)$/, '').trim();
|
|
59
|
+
const codex = /^codex:([^:]+)(?::[a-z]+)?$/.exec(id);
|
|
60
|
+
if (codex !== null) id = codex[1]!;
|
|
61
|
+
return COST_LADDER.some((r) => r.id === id) ? id : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
65
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The run-level grade, wherever this record's era put it (`result.qeGrade`, `result.grade`,
|
|
69
|
+
* `result.qe.grade`). A string that does not look like a grade is not one. */
|
|
70
|
+
function extractGrade(result: Record<string, unknown>): string | null {
|
|
71
|
+
const looksLikeGrade = (v: unknown): v is string => typeof v === 'string' && /^[A-F][+-]?$/.test(v.trim().toUpperCase());
|
|
72
|
+
if (looksLikeGrade(result['qeGrade'])) return (result['qeGrade'] as string).trim().toUpperCase();
|
|
73
|
+
if (looksLikeGrade(result['grade'])) return (result['grade'] as string).trim().toUpperCase();
|
|
74
|
+
const qe = result['qe'];
|
|
75
|
+
if (isRecord(qe) && looksLikeGrade(qe['grade'])) return (qe['grade'] as string).trim().toUpperCase();
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Harvest per-stage samples from already-read records (live harness records AND the `runMeta.records`
|
|
80
|
+
* of imported run-meta sidecars — they are the same shape by construction). */
|
|
81
|
+
export function harvestStageOutcomes(records: readonly unknown[]): Harvest {
|
|
82
|
+
const samples: HarvestSample[] = [];
|
|
83
|
+
const skipped = { noResult: 0, noModels: 0, noGrade: 0, unknownModel: 0 };
|
|
84
|
+
const runs = new Set<string>();
|
|
85
|
+
let min: string | null = null;
|
|
86
|
+
let max: string | null = null;
|
|
87
|
+
for (const source of records) {
|
|
88
|
+
if (!isRecord(source)) { skipped.noResult += 1; continue; }
|
|
89
|
+
const result = source['result'];
|
|
90
|
+
if (!isRecord(result)) { skipped.noResult += 1; continue; }
|
|
91
|
+
const modelsUsed = result['modelsUsed'];
|
|
92
|
+
if (!isRecord(modelsUsed)) { skipped.noModels += 1; continue; }
|
|
93
|
+
const grade = extractGrade(result);
|
|
94
|
+
if (grade === null) { skipped.noGrade += 1; continue; }
|
|
95
|
+
const runId = typeof source['runId'] === 'string' ? source['runId'] : JSON.stringify(modelsUsed).slice(0, 40);
|
|
96
|
+
const ts = typeof source['timestamp'] === 'string' ? source['timestamp'] : null;
|
|
97
|
+
const tier = typeof result['tier'] === 'string' && result['tier'] !== '' ? result['tier'] : 'unknown';
|
|
98
|
+
let contributed = false;
|
|
99
|
+
for (const [stage, rawModel] of Object.entries(modelsUsed)) {
|
|
100
|
+
const model = normalizeModelId(rawModel);
|
|
101
|
+
if (model === null) { skipped.unknownModel += 1; continue; }
|
|
102
|
+
contributed = true;
|
|
103
|
+
samples.push({ runId, ts, tier, stage, model, success: gradeIsSuccess(grade), grade });
|
|
104
|
+
}
|
|
105
|
+
if (contributed) {
|
|
106
|
+
runs.add(runId);
|
|
107
|
+
if (ts !== null) {
|
|
108
|
+
if (min === null || ts < min) min = ts;
|
|
109
|
+
if (max === null || ts > max) max = ts;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
samples,
|
|
115
|
+
runsUsed: runs.size,
|
|
116
|
+
window: min !== null && max !== null ? { min, max } : null,
|
|
117
|
+
skipped,
|
|
118
|
+
rule: RULE_TEXT,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface StageRecommendation {
|
|
123
|
+
readonly stage: string;
|
|
124
|
+
/** The spec string for `args.models` — claude rung ids pass through; openai rungs render as `codex:<id>:high`. */
|
|
125
|
+
readonly spec: string;
|
|
126
|
+
readonly pick: AutoCostPick;
|
|
127
|
+
readonly family: Family;
|
|
128
|
+
readonly samples: number;
|
|
129
|
+
/** `selectAutoCost` met its quality bar on ≥minSamples — otherwise this is cold-start, SAID. */
|
|
130
|
+
readonly insufficientData: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface Recommendation {
|
|
134
|
+
readonly perStage: StageRecommendation[];
|
|
135
|
+
readonly basis: {
|
|
136
|
+
readonly runsUsed: number;
|
|
137
|
+
readonly window: Harvest['window'];
|
|
138
|
+
readonly rule: string;
|
|
139
|
+
readonly skipped: Harvest['skipped'];
|
|
140
|
+
readonly crossFamilyNote: string;
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function rungFamily(id: string): Family {
|
|
145
|
+
const rung: ModelRung | undefined = COST_LADDER.find((r) => r.id === id);
|
|
146
|
+
return rung !== undefined ? rung.family : 'claude';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function toSpec(id: string): string {
|
|
150
|
+
return rungFamily(id) === 'openai' ? `codex:${id}:high` : id;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const CROSS_NOTE =
|
|
154
|
+
'the qe pick is computed with the family FORCED to the cross of the code pick — a same-family qe recommendation is unrepresentable (ADR-001 D3)';
|
|
155
|
+
|
|
156
|
+
/** Recommend per stage over the harvested samples (optionally one tier's slice). */
|
|
157
|
+
export function recommendModels(harvest: Harvest, opts: { tier?: string; qualityBar?: number; minSamples?: number } = {}): Recommendation {
|
|
158
|
+
const slice = opts.tier === undefined ? harvest.samples : harvest.samples.filter((s) => s.tier === opts.tier);
|
|
159
|
+
const tierLabel = opts.tier ?? 'all';
|
|
160
|
+
const byStage = new Map<string, HarvestSample[]>();
|
|
161
|
+
for (const s of slice) {
|
|
162
|
+
const bucket = byStage.get(s.stage) ?? [];
|
|
163
|
+
bucket.push(s);
|
|
164
|
+
byStage.set(s.stage, bucket);
|
|
165
|
+
}
|
|
166
|
+
const statsFor = (stage: string) => (model: string) => {
|
|
167
|
+
const rows = (byStage.get(stage) ?? []).filter((s) => s.model === model);
|
|
168
|
+
const successes = rows.filter((s) => s.success).length;
|
|
169
|
+
return { attempts: rows.length, successes, successRate: rows.length === 0 ? 0 : successes / rows.length };
|
|
170
|
+
};
|
|
171
|
+
const pickFor = (stage: string, family?: Family): StageRecommendation => {
|
|
172
|
+
const pick = selectAutoCost(stage, tierLabel, statsFor(stage), {
|
|
173
|
+
...(opts.qualityBar !== undefined ? { qualityBar: opts.qualityBar } : {}),
|
|
174
|
+
...(opts.minSamples !== undefined ? { minSamples: opts.minSamples } : {}),
|
|
175
|
+
...(family !== undefined ? { family } : {}),
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
stage,
|
|
179
|
+
spec: toSpec(pick.model),
|
|
180
|
+
pick,
|
|
181
|
+
family: rungFamily(pick.model),
|
|
182
|
+
samples: (byStage.get(stage) ?? []).length,
|
|
183
|
+
insufficientData: !pick.metBar,
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const stages = [...byStage.keys()].sort();
|
|
188
|
+
const perStage: StageRecommendation[] = [];
|
|
189
|
+
// code first — the qe family depends on it (D3).
|
|
190
|
+
const code = pickFor('code');
|
|
191
|
+
for (const stage of stages) {
|
|
192
|
+
if (stage === 'qe') {
|
|
193
|
+
const cross: Family = code.family === 'claude' ? 'openai' : 'claude';
|
|
194
|
+
perStage.push(pickFor('qe', cross));
|
|
195
|
+
} else {
|
|
196
|
+
perStage.push(pickFor(stage));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!stages.includes('code')) perStage.unshift(code);
|
|
200
|
+
return {
|
|
201
|
+
perStage,
|
|
202
|
+
basis: { runsUsed: harvest.runsUsed, window: harvest.window, rule: harvest.rule, skipped: harvest.skipped, crossFamilyNote: CROSS_NOTE },
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ── the idempotent feed plan (ADR-001 D4) ─────────────────────────────────────────────── */
|
|
207
|
+
|
|
208
|
+
export interface FeedPlan {
|
|
209
|
+
/** Samples whose runId has not been fed before — the CLI calls finalizeOutcome for each. */
|
|
210
|
+
readonly toFeed: HarvestSample[];
|
|
211
|
+
readonly skippedRuns: string[];
|
|
212
|
+
/** The new fed-set the CLI persists after feeding. */
|
|
213
|
+
readonly fedAfter: string[];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A run feeds ONCE. Double-feeding the same telemetry manufactures confidence the data does not
|
|
217
|
+
* contain — the second `--apply` must feed 0 and say which runs it skipped. */
|
|
218
|
+
export function planFeed(samples: readonly HarvestSample[], alreadyFed: readonly string[]): FeedPlan {
|
|
219
|
+
const fed = new Set(alreadyFed);
|
|
220
|
+
const toFeed: HarvestSample[] = [];
|
|
221
|
+
const skippedRuns = new Set<string>();
|
|
222
|
+
for (const s of samples) {
|
|
223
|
+
if (fed.has(s.runId)) skippedRuns.add(s.runId);
|
|
224
|
+
else toFeed.push(s);
|
|
225
|
+
}
|
|
226
|
+
const fedAfter = [...new Set([...alreadyFed, ...toFeed.map((s) => s.runId)])].sort();
|
|
227
|
+
return { toFeed, skippedRuns: [...skippedRuns].sort(), fedAfter };
|
|
228
|
+
}
|
package/src/registry.ts
CHANGED
|
@@ -184,7 +184,10 @@ function categoryFromPack(pack: string): string {
|
|
|
184
184
|
pack.includes('pm') ||
|
|
185
185
|
pack.includes('idea2prd') ||
|
|
186
186
|
pack.includes('reverse-engineering') ||
|
|
187
|
-
pack.includes('presentation')
|
|
187
|
+
pack.includes('presentation') ||
|
|
188
|
+
// decision-mockups: an owner-facing decision page is stakeholder communication, the same
|
|
189
|
+
// cluster as PRDs and presentations — not design, and not a QE artifact.
|
|
190
|
+
pack.includes('decision-mockups')
|
|
188
191
|
)
|
|
189
192
|
return 'product';
|
|
190
193
|
// Digitized-book knowledge packs (ADR-001 book-knowledge-digitizer) — `skills-book-*` and named
|
package/src/statusline.ts
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* @packageDocumentation
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
18
|
-
import {
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { join, resolve } from 'node:path';
|
|
19
19
|
import { createRequire } from 'node:module';
|
|
20
20
|
|
|
21
21
|
import { listBrain } from './brain.js';
|
|
@@ -28,6 +28,8 @@ import { RECALL_USAGE_LOG_RELATIVE, aggregateRecallUsage, parseRecallUsageLog }
|
|
|
28
28
|
* path (readonly, best-effort) by `readFeatureAdrState`.
|
|
29
29
|
*/
|
|
30
30
|
export interface FeatureAdrState {
|
|
31
|
+
/** Producer of this panel state. Missing/invalid legacy values are treated as `feature-adr`. */
|
|
32
|
+
readonly kind?: 'feature-adr' | 'loop';
|
|
31
33
|
/** The feature slug the pipeline is working on (kebab-case). */
|
|
32
34
|
readonly slug: string;
|
|
33
35
|
/** Human-readable step label (e.g. "Step 0", "Step 8 QE"). */
|
|
@@ -70,9 +72,29 @@ function consolidateWatermarkPath(projectRoot: string): string {
|
|
|
70
72
|
return join(projectRoot, '.dz', 'memory', 'consolidate.json');
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
/**
|
|
74
|
-
export function
|
|
75
|
-
return join(projectRoot, '.dz', 'feature-adr', 'learning-state
|
|
75
|
+
/** Directory of the per-slug live `/feature-adr` learning-state slots. */
|
|
76
|
+
export function featureAdrStateDir(projectRoot: string): string {
|
|
77
|
+
return join(projectRoot, '.dz', 'feature-adr', 'learning-state');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Make a slug safe as one bounded filename component (never `/`, `..`, or leading dot/dash). */
|
|
81
|
+
function featureAdrStateSlug(slug: string): string {
|
|
82
|
+
const safe = slug
|
|
83
|
+
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
84
|
+
.replace(/^[.-]+/, '_')
|
|
85
|
+
.replace(/\.{2,}/g, '_')
|
|
86
|
+
.slice(0, 60);
|
|
87
|
+
return safe.length > 0 ? safe : '_unnamed';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Path of a live `/feature-adr` learning-state file. With a slug this is its namespaced slot;
|
|
92
|
+
* without one this remains the legacy single-slot path for backward-compatible readers/callers.
|
|
93
|
+
*/
|
|
94
|
+
export function featureAdrStatePath(projectRoot: string, slug?: string): string {
|
|
95
|
+
return slug === undefined
|
|
96
|
+
? join(projectRoot, '.dz', 'feature-adr', 'learning-state.json')
|
|
97
|
+
: join(featureAdrStateDir(projectRoot), `${featureAdrStateSlug(slug)}.json`);
|
|
76
98
|
}
|
|
77
99
|
|
|
78
100
|
/**
|
|
@@ -181,35 +203,84 @@ function consolidatedAgeHours(projectRoot: string, now: number): number | undefi
|
|
|
181
203
|
* @param now Injectable clock (epoch ms) for the freshness check — defaults to `Date.now()`.
|
|
182
204
|
*/
|
|
183
205
|
export function readFeatureAdrState(projectRoot: string, now: number = Date.now()): FeatureAdrState | undefined {
|
|
184
|
-
const
|
|
185
|
-
|
|
206
|
+
const root = resolve(projectRoot);
|
|
207
|
+
const candidates: string[] = [];
|
|
208
|
+
|
|
209
|
+
// Keep the legacy single slot in the candidate set: an older dz may still be writing it while a
|
|
210
|
+
// newer statusline renders. Directory discovery is guarded separately because this is the hot,
|
|
211
|
+
// readonly ~300ms render path; it never performs housekeeping or any other write.
|
|
186
212
|
try {
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
213
|
+
const legacyPath = featureAdrStatePath(root);
|
|
214
|
+
if (existsSync(legacyPath)) candidates.push(legacyPath);
|
|
215
|
+
} catch { /* best-effort candidate discovery */ }
|
|
216
|
+
try {
|
|
217
|
+
const remaining = 64 - candidates.length;
|
|
218
|
+
if (remaining > 0) {
|
|
219
|
+
const dir = featureAdrStateDir(root);
|
|
220
|
+
const names = readdirSync(dir)
|
|
221
|
+
.filter((name) => name.endsWith('.json'))
|
|
222
|
+
.map((name) => {
|
|
223
|
+
let mtimeMs = -Infinity;
|
|
224
|
+
try {
|
|
225
|
+
mtimeMs = statSync(join(dir, name)).mtimeMs;
|
|
226
|
+
} catch { /* a disappearing/unreadable entry sorts last */ }
|
|
227
|
+
return { name, mtimeMs };
|
|
228
|
+
})
|
|
229
|
+
// Truncation may only drop the least recent slots: kind-rank arbitration cannot rescue a non-candidate.
|
|
230
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
231
|
+
.slice(0, remaining);
|
|
232
|
+
for (const { name } of names) candidates.push(join(dir, name));
|
|
233
|
+
}
|
|
234
|
+
} catch { /* absent/unreadable per-slug directory is normal */ }
|
|
235
|
+
|
|
236
|
+
const parseCandidate = (path: string): { state: FeatureAdrState; tsMs: number; rank: number } | undefined => {
|
|
237
|
+
try {
|
|
238
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<FeatureAdrState>;
|
|
239
|
+
if (typeof parsed.slug !== 'string' || parsed.slug.length === 0) return undefined;
|
|
240
|
+
if (typeof parsed.step !== 'string' || parsed.step.length === 0) return undefined;
|
|
241
|
+
if (typeof parsed.ts !== 'string') return undefined;
|
|
242
|
+
const tsMs = Date.parse(parsed.ts);
|
|
243
|
+
if (Number.isNaN(tsMs)) return undefined;
|
|
244
|
+
if (now - tsMs > FEATURE_ADR_FRESH_MS) return undefined; // stale run — do not surface a panel
|
|
245
|
+
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
246
|
+
// The panel exists to surface the /feature-adr Pattern-memory loop. A generated loop writes
|
|
247
|
+
// zero recalled/stored counters far more often, so freshest-wins would recreate F5 by making
|
|
248
|
+
// a live pipeline's meaningful counters disappear. Missing/invalid legacy markers therefore
|
|
249
|
+
// retain the historical `feature-adr` rank, which outranks every loop slot regardless of ts.
|
|
250
|
+
const kind: 'feature-adr' | 'loop' = parsed.kind === 'loop' ? 'loop' : 'feature-adr';
|
|
251
|
+
const state: FeatureAdrState = {
|
|
252
|
+
kind,
|
|
253
|
+
slug: parsed.slug,
|
|
254
|
+
step: parsed.step,
|
|
255
|
+
pool: num(parsed.pool),
|
|
256
|
+
recalled: num(parsed.recalled),
|
|
257
|
+
stored: num(parsed.stored),
|
|
258
|
+
...(num(parsed.reinforced) > 0 ? { reinforced: num(parsed.reinforced) } : {}),
|
|
259
|
+
ts: parsed.ts,
|
|
260
|
+
...(typeof parsed.mode === 'string' && parsed.mode.length > 0 ? { mode: parsed.mode } : {}),
|
|
261
|
+
};
|
|
262
|
+
return { state, tsMs, rank: kind === 'feature-adr' ? 1 : 0 };
|
|
263
|
+
} catch {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
let winner: ReturnType<typeof parseCandidate>;
|
|
269
|
+
for (const path of candidates) {
|
|
270
|
+
const candidate = parseCandidate(path);
|
|
271
|
+
if (candidate === undefined) continue;
|
|
272
|
+
if (winner === undefined
|
|
273
|
+
|| candidate.rank > winner.rank
|
|
274
|
+
|| (candidate.rank === winner.rank && candidate.tsMs > winner.tsMs)) {
|
|
275
|
+
winner = candidate;
|
|
276
|
+
}
|
|
208
277
|
}
|
|
278
|
+
return winner?.state;
|
|
209
279
|
}
|
|
210
280
|
|
|
211
281
|
/** Fields the `/feature-adr` pipeline supplies when recording its live learning state. */
|
|
212
282
|
export interface WriteFeatureAdrStateInput {
|
|
283
|
+
readonly kind?: 'feature-adr' | 'loop';
|
|
213
284
|
readonly slug: string;
|
|
214
285
|
readonly step: string;
|
|
215
286
|
readonly recalled: number;
|
|
@@ -239,6 +310,7 @@ export function writeFeatureAdrState(
|
|
|
239
310
|
pool = 0;
|
|
240
311
|
}
|
|
241
312
|
const state: FeatureAdrState = {
|
|
313
|
+
kind: input.kind === 'loop' ? 'loop' : 'feature-adr',
|
|
242
314
|
slug: input.slug,
|
|
243
315
|
step: input.step,
|
|
244
316
|
pool,
|
|
@@ -249,8 +321,23 @@ export function writeFeatureAdrState(
|
|
|
249
321
|
...(input.mode !== undefined && input.mode.length > 0 ? { mode: input.mode } : {}),
|
|
250
322
|
};
|
|
251
323
|
try {
|
|
252
|
-
const
|
|
253
|
-
mkdirSync(
|
|
324
|
+
const dir = featureAdrStateDir(root);
|
|
325
|
+
mkdirSync(dir, { recursive: true });
|
|
326
|
+
|
|
327
|
+
// Housekeeping belongs only on this write path, never the ~300ms render path. Every file is
|
|
328
|
+
// independently guarded so an unreadable/racing entry cannot prevent the live state write.
|
|
329
|
+
try {
|
|
330
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1_000;
|
|
331
|
+
for (const name of readdirSync(dir)) {
|
|
332
|
+
if (!name.endsWith('.json')) continue;
|
|
333
|
+
const stalePath = join(dir, name);
|
|
334
|
+
try {
|
|
335
|
+
if (statSync(stalePath).mtimeMs < cutoff) unlinkSync(stalePath);
|
|
336
|
+
} catch { /* best-effort per-file cleanup */ }
|
|
337
|
+
}
|
|
338
|
+
} catch { /* best-effort directory cleanup */ }
|
|
339
|
+
|
|
340
|
+
const path = featureAdrStatePath(root, input.slug);
|
|
254
341
|
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
255
342
|
} catch {
|
|
256
343
|
return undefined;
|