@dzhechkov/harness-core 0.7.4 → 0.7.5
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 +111 -51
- package/README.md +1 -1
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +26 -2
- package/dist/agentdb-index.js.map +1 -1
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +6 -1
- package/dist/amendment-trace.js.map +1 -1
- package/dist/cadence.d.ts +66 -0
- package/dist/cadence.d.ts.map +1 -0
- package/dist/cadence.js +222 -0
- package/dist/cadence.js.map +1 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +1 -1
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +2 -2
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +53 -18
- package/dist/operations.js.map +1 -1
- package/dist/publish.d.ts +31 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +78 -0
- package/dist/publish.js.map +1 -1
- package/dist/recall-hook-policy.d.ts +3 -1
- package/dist/recall-hook-policy.d.ts.map +1 -1
- package/dist/recall-hook-policy.js +15 -2
- package/dist/recall-hook-policy.js.map +1 -1
- package/dist/recall-usage.d.ts +15 -0
- package/dist/recall-usage.d.ts.map +1 -1
- package/dist/recall-usage.js +51 -1
- package/dist/recall-usage.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +38 -4
- package/dist/score.js.map +1 -1
- package/dist/tg-post.d.ts +54 -0
- package/dist/tg-post.d.ts.map +1 -0
- package/dist/tg-post.js +117 -0
- package/dist/tg-post.js.map +1 -0
- package/dist/usage.d.ts +31 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +108 -21
- package/dist/usage.js.map +1 -1
- package/dist/writer-quiescence.d.ts +42 -0
- package/dist/writer-quiescence.d.ts.map +1 -0
- package/dist/writer-quiescence.js +82 -0
- package/dist/writer-quiescence.js.map +1 -0
- package/package.json +13 -13
- package/sbom.json +200 -50
- package/src/agentdb-index.ts +26 -2
- package/src/amendment-trace.ts +6 -1
- package/src/cadence.ts +227 -0
- package/src/feature-adr-routing.ts +1 -1
- package/src/index.ts +7 -1
- package/src/loop-blobs.generated.ts +2 -2
- package/src/operations.ts +52 -19
- package/src/publish.ts +98 -0
- package/src/recall-hook-policy.ts +15 -2
- package/src/recall-usage.ts +44 -1
- package/src/score.ts +30 -4
- package/src/tg-post.ts +137 -0
- package/src/usage.ts +132 -17
- package/src/writer-quiescence.ts +95 -0
package/src/agentdb-index.ts
CHANGED
|
@@ -110,7 +110,19 @@ export async function indexPatternsToAgentdb(
|
|
|
110
110
|
};
|
|
111
111
|
const model = resolveEmbedModel(projectRoot);
|
|
112
112
|
if ('error' in model) return { indexed: 0, error: model.error };
|
|
113
|
-
const emb = new EmbeddingService({
|
|
113
|
+
const emb = new EmbeddingService({
|
|
114
|
+
model: model.model,
|
|
115
|
+
dimension: model.dim,
|
|
116
|
+
provider: 'transformers',
|
|
117
|
+
// agentdb >= 3.0.0-alpha.20 refuses UNREGISTERED models without an explicit role policy
|
|
118
|
+
// (its built-in registry knows all-MiniLM-L6-v2 but not our multilingual variant — grounded
|
|
119
|
+
// in dist/src/controllers/EmbeddingService.js:53). paraphrase-multilingual-MiniLM is a
|
|
120
|
+
// SYMMETRIC sentence-transformer (no query/passage instruction prefixes), so the policy is
|
|
121
|
+
// {kind:'symmetric'} — the same one the registry assigns its own symmetric models. On
|
|
122
|
+
// alpha.18 the extra field is ignored; without it alpha.20 threw and the vector tier fell
|
|
123
|
+
// to lexical SILENTLY (mirror writes answered {indexed:0, error} — measured 2026-08-24).
|
|
124
|
+
rolePolicy: { kind: 'symmetric' },
|
|
125
|
+
} as never);
|
|
114
126
|
await emb.initialize();
|
|
115
127
|
|
|
116
128
|
const dbFile = resolveAgentdbPath(projectRoot, opts.dbPath);
|
|
@@ -242,7 +254,19 @@ export async function resolveAgentdbEmbedder(
|
|
|
242
254
|
};
|
|
243
255
|
const model = resolveEmbedModel(projectRoot);
|
|
244
256
|
if ('error' in model) return { error: model.error };
|
|
245
|
-
const emb = new EmbeddingService({
|
|
257
|
+
const emb = new EmbeddingService({
|
|
258
|
+
model: model.model,
|
|
259
|
+
dimension: model.dim,
|
|
260
|
+
provider: 'transformers',
|
|
261
|
+
// agentdb >= 3.0.0-alpha.20 refuses UNREGISTERED models without an explicit role policy
|
|
262
|
+
// (its built-in registry knows all-MiniLM-L6-v2 but not our multilingual variant — grounded
|
|
263
|
+
// in dist/src/controllers/EmbeddingService.js:53). paraphrase-multilingual-MiniLM is a
|
|
264
|
+
// SYMMETRIC sentence-transformer (no query/passage instruction prefixes), so the policy is
|
|
265
|
+
// {kind:'symmetric'} — the same one the registry assigns its own symmetric models. On
|
|
266
|
+
// alpha.18 the extra field is ignored; without it alpha.20 threw and the vector tier fell
|
|
267
|
+
// to lexical SILENTLY (mirror writes answered {indexed:0, error} — measured 2026-08-24).
|
|
268
|
+
rolePolicy: { kind: 'symmetric' },
|
|
269
|
+
} as never);
|
|
246
270
|
await emb.initialize();
|
|
247
271
|
return { embed: (t: string) => emb.embed(t) };
|
|
248
272
|
} catch (err) {
|
package/src/amendment-trace.ts
CHANGED
|
@@ -74,7 +74,12 @@ export const MIN_MATCHABLE_ID_LENGTH = 8;
|
|
|
74
74
|
export function extractTestTitles(body: string): string[] {
|
|
75
75
|
const code = body.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
|
76
76
|
const out: string[] = [];
|
|
77
|
-
|
|
77
|
+
// The modifier-argument group admits ONE level of nested parens: `it.skipIf(!existsSync(BIN))`
|
|
78
|
+
// carries a call inside the guard, and the flat `[^()]{0,200}` failed on it — so every title in
|
|
79
|
+
// a file whose tests were guarded that way was invisible, and `dz amendment-check` reported
|
|
80
|
+
// `searched 1 test title(s)` over a nine-test file (MEASURED 2026-08-24 on the name-check
|
|
81
|
+
// feature; worked around there by de-guarding the tests, fixed here at the extractor).
|
|
82
|
+
const re = /\b(?:it|test|describe)(?:\.\w+)*(?:\s*\((?:[^()]|\([^()]*\)){0,200}\))?\s*(?:`[^`]*`)?\s*\(\s*(['"`])([\s\S]{1,300}?)\1/g;
|
|
78
83
|
for (let m = re.exec(code); m !== null; m = re.exec(code)) if (m[2]) out.push(m[2]);
|
|
79
84
|
return out;
|
|
80
85
|
}
|
package/src/cadence.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dz cadence` — the "what shipped" aggregator (backlog ef740b44), built on one spine:
|
|
3
|
+
* A WINDOW DEEPER THAN THE RECORD IS REFUSED (ADR-001). The weekly-digest research (2026-08-22)
|
|
4
|
+
* caught a «year» digest standing on 174 days of data — scale forgery by aggregation; an
|
|
5
|
+
* aggregator that silently computes any requested period repeats it mechanically.
|
|
6
|
+
*
|
|
7
|
+
* Four sources, every degradation NAMED in the report, never a silent zero:
|
|
8
|
+
* - graded shipments: features/<slug>/08_qe_report.md through the hardened readQeGrade
|
|
9
|
+
* (prefix-negation aware, all measured real-world grade forms); ungraded reports are a COLUMN;
|
|
10
|
+
* - npm publishes: the dz recap registry-time cache (third-party timestamps);
|
|
11
|
+
* - guard repeat decay on a FIXED rule set: a rule enters only with events BEFORE the window
|
|
12
|
+
* start (the data-driven birth proxy — the no-stubs class of «zero repeats because the rule is
|
|
13
|
+
* young» is excluded by construction);
|
|
14
|
+
* - knowledge reuse: recall events per bucket from .dz/recall-usage.jsonl.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { readQeGrade } from './score.js';
|
|
20
|
+
|
|
21
|
+
export type CadenceWindow = 'day' | 'week' | 'month' | 'quarter' | 'halfyear' | 'year';
|
|
22
|
+
|
|
23
|
+
export const CADENCE_WINDOW_DAYS: Record<CadenceWindow, number> = {
|
|
24
|
+
day: 1, week: 7, month: 30, quarter: 91, halfyear: 182, year: 365,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface CadenceWindowDecision {
|
|
28
|
+
readonly ok: boolean;
|
|
29
|
+
readonly reason: string;
|
|
30
|
+
/** The largest window today's record CAN honestly carry, or null when even `day` cannot. */
|
|
31
|
+
readonly largestAllowed: CadenceWindow | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* ADR-001: a window is accepted only when the record is at least TWO windows deep — two full units
|
|
36
|
+
* are the minimum for the word «cadence»; one point has no rhythm.
|
|
37
|
+
*/
|
|
38
|
+
export function decideCadenceWindow(window: CadenceWindow, dataDepthDays: number): CadenceWindowDecision {
|
|
39
|
+
const need = CADENCE_WINDOW_DAYS[window] * 2;
|
|
40
|
+
const order: CadenceWindow[] = ['year', 'halfyear', 'quarter', 'month', 'week', 'day'];
|
|
41
|
+
const largestAllowed = order.find((w) => dataDepthDays >= CADENCE_WINDOW_DAYS[w] * 2) ?? null;
|
|
42
|
+
if (dataDepthDays >= need) return { ok: true, reason: `record depth ${dataDepthDays}d covers 2×${window}`, largestAllowed };
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
reason: `REFUSED: the record is ${dataDepthDays} day(s) deep and a ${window} cadence needs ${need} — a cadence computed from under two full windows is a scale forgery, not a number` +
|
|
46
|
+
(largestAllowed ? `; the largest honest window today is «${largestAllowed}»` : '; even «day» is not established yet'),
|
|
47
|
+
largestAllowed,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** ISO week key (YYYY-Www) for a ms timestamp. */
|
|
52
|
+
export function isoWeekOf(ms: number): string {
|
|
53
|
+
const d = new Date(ms);
|
|
54
|
+
const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
55
|
+
const day = t.getUTCDay() === 0 ? 7 : t.getUTCDay();
|
|
56
|
+
t.setUTCDate(t.getUTCDate() + 4 - day);
|
|
57
|
+
const yearStart = Date.UTC(t.getUTCFullYear(), 0, 1);
|
|
58
|
+
const week = Math.ceil(((t.getTime() - yearStart) / 86400000 + 1) / 7);
|
|
59
|
+
return `${t.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CadenceEvent { readonly ts: number; readonly kind: string; readonly detail?: string }
|
|
63
|
+
|
|
64
|
+
/** Bucket events into ISO weeks inside [windowStart, now]. */
|
|
65
|
+
export function weeklyBuckets(events: readonly CadenceEvent[], windowStartMs: number, nowMs: number): Map<string, CadenceEvent[]> {
|
|
66
|
+
const out = new Map<string, CadenceEvent[]>();
|
|
67
|
+
for (const e of events) {
|
|
68
|
+
if (!isFinite(e.ts) || e.ts < windowStartMs || e.ts > nowMs) continue;
|
|
69
|
+
const k = isoWeekOf(e.ts);
|
|
70
|
+
const list = out.get(k) ?? [];
|
|
71
|
+
list.push(e);
|
|
72
|
+
out.set(k, list);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface GuardDecayRow { readonly rule: string; readonly before: number; readonly inWindow: number }
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Repeat decay over the FIXED set: only rules with at least one event BEFORE the window start
|
|
81
|
+
* qualify (their existence predates the window); newborn rules are EXCLUDED by construction and
|
|
82
|
+
* returned separately so the exclusion is visible.
|
|
83
|
+
*/
|
|
84
|
+
export function guardRepeatDecay(
|
|
85
|
+
events: readonly { ts: number; rule: string }[],
|
|
86
|
+
windowStartMs: number,
|
|
87
|
+
): { decay: GuardDecayRow[]; excludedNewborn: string[] } {
|
|
88
|
+
const before = new Map<string, number>();
|
|
89
|
+
const inWindow = new Map<string, number>();
|
|
90
|
+
for (const e of events) {
|
|
91
|
+
if (!isFinite(e.ts) || e.rule === '') continue;
|
|
92
|
+
if (e.ts < windowStartMs) before.set(e.rule, (before.get(e.rule) ?? 0) + 1);
|
|
93
|
+
else inWindow.set(e.rule, (inWindow.get(e.rule) ?? 0) + 1);
|
|
94
|
+
}
|
|
95
|
+
const decay: GuardDecayRow[] = [...before.entries()]
|
|
96
|
+
.map(([rule, b]) => ({ rule, before: b, inWindow: inWindow.get(rule) ?? 0 }))
|
|
97
|
+
.sort((a, b) => b.before - a.before);
|
|
98
|
+
const excludedNewborn = [...inWindow.keys()].filter((r) => !before.has(r)).sort();
|
|
99
|
+
return { decay, excludedNewborn };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface CadenceReport {
|
|
103
|
+
readonly window: CadenceWindow;
|
|
104
|
+
readonly decision: CadenceWindowDecision;
|
|
105
|
+
readonly depthDays: number;
|
|
106
|
+
readonly shipments: { graded: Record<string, number>; ungraded: number; gradedTotal: number; byGrade: Record<string, number> };
|
|
107
|
+
readonly npmPublishes: { weekly: Record<string, number>; degraded: string | null };
|
|
108
|
+
readonly guard: { decay: GuardDecayRow[]; excludedNewborn: string[]; degraded: string | null };
|
|
109
|
+
readonly recalls: { weekly: Record<string, number>; degraded: string | null };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function safeJsonl(path: string): unknown[] {
|
|
113
|
+
const out: unknown[] = [];
|
|
114
|
+
try {
|
|
115
|
+
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
|
116
|
+
if (line.trim() === '') continue;
|
|
117
|
+
try { out.push(JSON.parse(line)); } catch { /* torn line — skip, counted nowhere */ }
|
|
118
|
+
}
|
|
119
|
+
} catch { /* absent file — callers name the degradation */ }
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Build the full report. `now` injectable — the refusal decision must be testable. */
|
|
124
|
+
export function buildCadenceReport(root: string, window: CadenceWindow, now?: number): CadenceReport {
|
|
125
|
+
const nowMs = typeof now === 'number' && isFinite(now) ? now : Date.now();
|
|
126
|
+
|
|
127
|
+
// Shipment events: graded 08 reports, dated by the run-cost ledger (fallback: report mtime is
|
|
128
|
+
// NOT used — an mtime moves on every touch; an undatable report lands in `ungraded`... no: in
|
|
129
|
+
// its own named bucket via ledger-missing) — v1 keeps the honest subset: ledger-dated only.
|
|
130
|
+
const ledger = safeJsonl(join(root, '.dz', 'feature-adr', 'run-cost-ledger.jsonl')) as Array<{ slug?: string; date?: string }>;
|
|
131
|
+
const dateBySlug = new Map<string, number>();
|
|
132
|
+
let earliest = nowMs;
|
|
133
|
+
for (const r of ledger) {
|
|
134
|
+
if (typeof r.slug !== 'string' || typeof r.date !== 'string') continue;
|
|
135
|
+
const ts = Date.parse(r.date);
|
|
136
|
+
if (!isFinite(ts)) continue;
|
|
137
|
+
if (!dateBySlug.has(r.slug) || ts > (dateBySlug.get(r.slug) as number)) dateBySlug.set(r.slug, ts);
|
|
138
|
+
if (ts < earliest) earliest = ts;
|
|
139
|
+
}
|
|
140
|
+
// Record depth is the UNION of sources — the npm registry reaches months past the ledger, and a
|
|
141
|
+
// refusal computed from the shallowest source alone would under-admit honest windows.
|
|
142
|
+
let unionEarliest = earliest;
|
|
143
|
+
try {
|
|
144
|
+
const cache = JSON.parse(readFileSync(join(root, '.dz', 'recap', 'npm-times.json'), 'utf-8')) as { packages?: Record<string, { versions?: Record<string, string> }> };
|
|
145
|
+
for (const entry of Object.values(cache.packages ?? {})) for (const iso of Object.values(entry.versions ?? {})) {
|
|
146
|
+
const ts = Date.parse(iso); if (isFinite(ts) && ts < unionEarliest) unionEarliest = ts;
|
|
147
|
+
}
|
|
148
|
+
} catch { /* cache absent — named later */ }
|
|
149
|
+
for (const row of safeJsonl(join(root, '.dz', 'guard-audit.jsonl')) as Array<{ ts?: string }>) {
|
|
150
|
+
const ts = Date.parse(String(row.ts ?? '')); if (isFinite(ts) && ts < unionEarliest) unionEarliest = ts;
|
|
151
|
+
}
|
|
152
|
+
const depthDays = Math.floor((nowMs - unionEarliest) / 86400000);
|
|
153
|
+
const decision = decideCadenceWindow(window, depthDays);
|
|
154
|
+
const windowStart = nowMs - CADENCE_WINDOW_DAYS[window] * 86400000;
|
|
155
|
+
|
|
156
|
+
const gradedWeekly: Record<string, number> = {};
|
|
157
|
+
const byGrade: Record<string, number> = {};
|
|
158
|
+
let ungraded = 0;
|
|
159
|
+
let gradedTotal = 0;
|
|
160
|
+
const featuresDir = join(root, 'features');
|
|
161
|
+
if (existsSync(featuresDir) && decision.ok) {
|
|
162
|
+
for (const slug of readdirSync(featuresDir)) {
|
|
163
|
+
const report = join(featuresDir, slug, '08_qe_report.md');
|
|
164
|
+
if (!existsSync(report)) continue;
|
|
165
|
+
const ts = dateBySlug.get(slug);
|
|
166
|
+
if (ts === undefined || ts < windowStart || ts > nowMs) continue;
|
|
167
|
+
const grade = readQeGrade(readFileSync(report, 'utf-8')).grade;
|
|
168
|
+
if (grade === null) { ungraded += 1; continue; }
|
|
169
|
+
gradedTotal += 1;
|
|
170
|
+
byGrade[grade] = (byGrade[grade] ?? 0) + 1;
|
|
171
|
+
const wk = isoWeekOf(ts);
|
|
172
|
+
gradedWeekly[wk] = (gradedWeekly[wk] ?? 0) + 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// npm publishes from the recap cache — third-party registry timestamps.
|
|
177
|
+
const npmWeekly: Record<string, number> = {};
|
|
178
|
+
let npmDegraded: string | null = null;
|
|
179
|
+
const npmCache = join(root, '.dz', 'recap', 'npm-times.json');
|
|
180
|
+
if (!existsSync(npmCache)) {
|
|
181
|
+
npmDegraded = 'no npm-times cache — run `dz recap --refresh-publishes` first (registry timestamps are third-party data this command never fetches itself)';
|
|
182
|
+
} else if (decision.ok) {
|
|
183
|
+
try {
|
|
184
|
+
const cache = JSON.parse(readFileSync(npmCache, 'utf-8')) as { packages?: Record<string, { versions?: Record<string, string> }> };
|
|
185
|
+
for (const entry of Object.values(cache.packages ?? {})) {
|
|
186
|
+
for (const iso of Object.values(entry.versions ?? {})) {
|
|
187
|
+
const ts = Date.parse(iso);
|
|
188
|
+
if (!isFinite(ts) || ts < windowStart || ts > nowMs) continue;
|
|
189
|
+
const wk = isoWeekOf(ts);
|
|
190
|
+
npmWeekly[wk] = (npmWeekly[wk] ?? 0) + 1;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch { npmDegraded = 'npm-times cache unreadable — refresh it (`dz recap --refresh-publishes`)'; }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Guard decay on the fixed set.
|
|
197
|
+
const guardRows = (safeJsonl(join(root, '.dz', 'guard-audit.jsonl')) as Array<{ ts?: string; at?: string; rule?: string; violations?: Array<{ rule?: string }> }>)
|
|
198
|
+
.flatMap((r) => {
|
|
199
|
+
const ts = Date.parse(String(r.ts ?? r.at ?? ''));
|
|
200
|
+
if (!isFinite(ts)) return [];
|
|
201
|
+
// the live shape: one audit row carries violations[] each naming its rule
|
|
202
|
+
if (Array.isArray(r.violations)) return r.violations.map((v) => ({ ts, rule: String(v?.rule ?? '') })).filter((x) => x.rule !== '');
|
|
203
|
+
return typeof r.rule === 'string' && r.rule !== '' ? [{ ts, rule: r.rule }] : [];
|
|
204
|
+
});
|
|
205
|
+
const guard = decision.ok ? guardRepeatDecay(guardRows, windowStart) : { decay: [], excludedNewborn: [] };
|
|
206
|
+
const guardDegraded = guardRows.length === 0 ? 'no guard-audit events on disk — decay has nothing to stand on' : null;
|
|
207
|
+
|
|
208
|
+
// Knowledge reuse: recall events.
|
|
209
|
+
const recallRows = (safeJsonl(join(root, '.dz', 'recall-usage.jsonl')) as Array<{ ts?: string; at?: string }>)
|
|
210
|
+
.map((r) => Date.parse(String(r.ts ?? r.at ?? '')))
|
|
211
|
+
.filter((t) => isFinite(t));
|
|
212
|
+
const recallWeekly: Record<string, number> = {};
|
|
213
|
+
if (decision.ok) for (const ts of recallRows) {
|
|
214
|
+
if (ts < windowStart || ts > nowMs) continue;
|
|
215
|
+
const wk = isoWeekOf(ts);
|
|
216
|
+
recallWeekly[wk] = (recallWeekly[wk] ?? 0) + 1;
|
|
217
|
+
}
|
|
218
|
+
const recallDegraded = recallRows.length === 0 ? 'no recall-usage events — the reuse leg has nothing to stand on' : null;
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
window, decision, depthDays,
|
|
222
|
+
shipments: { graded: gradedWeekly, ungraded, gradedTotal, byGrade },
|
|
223
|
+
npmPublishes: { weekly: npmWeekly, degraded: npmDegraded },
|
|
224
|
+
guard: { ...guard, degraded: guardDegraded },
|
|
225
|
+
recalls: { weekly: recallWeekly, degraded: recallDegraded },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -188,7 +188,7 @@ export function decideUsageAction(
|
|
|
188
188
|
// ── Data tables (data-only extensibility — gpt-5.6-ready) ───────────────────
|
|
189
189
|
|
|
190
190
|
/** Known codex ids. Adding a new id (e.g. `'gpt-5.7'`) is a DATA-ONLY change. */
|
|
191
|
-
export const KNOWN_CODEX: Record<string, number> = { auto: 1, 'gpt-5.5': 1, 'gpt-5.6': 1, 'gpt-5.6-sol': 1 };
|
|
191
|
+
export const KNOWN_CODEX: Record<string, number> = { auto: 1, 'gpt-5.5': 1, 'gpt-5.6': 1, 'gpt-5.6-luna': 1, 'gpt-5.6-terra': 1, 'gpt-5.6-sol': 1 };
|
|
192
192
|
|
|
193
193
|
/** The Claude model names the Workflow runtime accepts as `agent()` `model`. */
|
|
194
194
|
export const CLAUDE_NAMES: Record<string, number> = { fable: 1, opus: 1, sonnet: 1, haiku: 1 };
|
package/src/index.ts
CHANGED
|
@@ -331,6 +331,7 @@ export {
|
|
|
331
331
|
compactRecallUsageLog,
|
|
332
332
|
compactRecallUsageLogChecked,
|
|
333
333
|
appendRecallUsage,
|
|
334
|
+
countRecallEventsForRun,
|
|
334
335
|
runtimeOf,
|
|
335
336
|
RUNTIMES,
|
|
336
337
|
} from './recall-usage.js';
|
|
@@ -461,7 +462,7 @@ export type {
|
|
|
461
462
|
ChainDefectAge,
|
|
462
463
|
ChainDefectAges,
|
|
463
464
|
} from './event-chain.js';
|
|
464
|
-
export { decideProvenance, environmentCanMintProvenance, publishArgv, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, orderByDependencies, syncReadmeVersion } from './publish.js';
|
|
465
|
+
export { decideProvenance, environmentCanMintProvenance, publishArgv, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, orderByDependencies, syncReadmeVersion } from './publish.js';
|
|
465
466
|
export { fetchAllDownloads } from './downloads.js';
|
|
466
467
|
export type { PackageDownloads, DownloadsReport } from './downloads.js';
|
|
467
468
|
export { discoverInstalled, checkUpgrades } from './upgrade.js';
|
|
@@ -818,6 +819,7 @@ export * from './recap.js';
|
|
|
818
819
|
export * from './provenance.js';
|
|
819
820
|
export * from './name-check.js';
|
|
820
821
|
export * from './cli-flag-notice.js';
|
|
822
|
+
export * from './tg-post.js';
|
|
821
823
|
|
|
822
824
|
// Mutation gate (feature ha-mutation-gate) — deliberately break each NAMED protection in a scratch
|
|
823
825
|
// copy, run the suite, REQUIRE red. Proves a test DISCRIMINATES, not merely that it is green.
|
|
@@ -832,3 +834,7 @@ export * from './backlog.js';
|
|
|
832
834
|
// no-stubs (backlog 0b403a0106103901) — deterministic unfinished-stub-marker scan over the
|
|
833
835
|
// CHANGE-SET, wired as the `no-stubs` SOFT publish guard rule + the feature-adr Step-8 QE item.
|
|
834
836
|
export * from './no-stubs.js';
|
|
837
|
+
export { quiescenceProbeScript, decideWriterQuiescence, WQ_WINDOW_SECONDS, WQ_MAX_WINDOWS, WQ_REQUIRED_QUIET } from './writer-quiescence.js';
|
|
838
|
+
export type { WriterQuiescenceDecision } from './writer-quiescence.js';
|
|
839
|
+
export { decideCadenceWindow, isoWeekOf, weeklyBuckets, guardRepeatDecay, buildCadenceReport, CADENCE_WINDOW_DAYS } from './cadence.js';
|
|
840
|
+
export type { CadenceWindow, CadenceReport, CadenceWindowDecision } from './cadence.js';
|
|
@@ -61,11 +61,11 @@ export const BLOBS: Record<string, LoopBlob> = {
|
|
|
61
61
|
"model-resolver": {
|
|
62
62
|
name: "model-resolver",
|
|
63
63
|
version: "1.0.0",
|
|
64
|
-
contentHash: "
|
|
64
|
+
contentHash: "e82ac2137279537f5b65725cbd8de5817fcbc89571068e2df843587352fa83f5",
|
|
65
65
|
sourcePath: "packages/@dzhechkov/harness-core/src/feature-adr-routing.ts",
|
|
66
66
|
requires: [],
|
|
67
67
|
exports: ["specToOpts","resolveStageModel","KNOWN_CODEX","mergeOpts","stageLabel","modelLabel"],
|
|
68
|
-
code: "const OVERRIDE_REASONING = {\n router: 'high',\n requirements: 'xhigh',\n research: 'xhigh',\n adr: 'xhigh',\n ideation: 'xhigh',\n ddd: 'xhigh',\n architecture: 'xhigh',\n plan: 'xhigh',\n code: 'xhigh',\n qe: 'high',\n fleet: 'high',\n};\nfunction topCodexId(env) {\n let top = env.CODEX_MODEL;\n if (top === 'auto') {\n const ids = Object.keys(KNOWN_CODEX);\n for (let i = 0; i < ids.length; i++) {\n if (ids[i] !== 'auto')\n top = ids[i] || top;\n }\n }\n return top;\n}\nconst KNOWN_CODEX = { auto: 1, 'gpt-5.5': 1, 'gpt-5.6': 1, 'gpt-5.6-sol': 1 };\nconst CLAUDE_NAMES = { fable: 1, opus: 1, sonnet: 1, haiku: 1 };\nconst VALID_REASONING = { none: 1, minimal: 1, low: 1, medium: 1, high: 1, xhigh: 1 };\nconst DEFAULT_MODELS = {\n router: 'fable',\n requirements: 'sonnet',\n research: 'sonnet',\n adr: 'opus',\n ideation: 'sonnet',\n ddd: 'opus',\n architecture: 'opus',\n plan: 'sonnet',\n code: null,\n qe: null,\n fleet: 'sonnet',\n};\nfunction specToOpts(spec, env) {\n const log = env.log || function () { };\n if (!spec)\n return {};\n const parts = String(spec).split(':');\n const head = parts[0] || '';\n if (head === 'codex') {\n let id = parts[1] || env.CODEX_MODEL;\n if (id !== 'auto' && !KNOWN_CODEX[id]) {\n log('models: unknown codex id ' + id + ' — using ' + env.CODEX_MODEL);\n id = env.CODEX_MODEL;\n }\n let reasoning = parts[2] || 'high';\n if (!VALID_REASONING[reasoning]) {\n log('models: unknown reasoning ' + reasoning + ' — using high');\n reasoning = 'high';\n }\n return { agentType: 'codex:codex-rescue', codexModel: id, _reasoning: reasoning };\n }\n if (CLAUDE_NAMES[head])\n return { model: head };\n log('models: unknown spec ' + spec + ' — session-inherited');\n return {};\n}\nfunction resolveCoderSpec(env) {\n if (env.CODER === 'codex' || env.CODER === 'codex-fallback')\n return 'codex:' + env.CODEX_MODEL + ':high';\n return 'opus';\n}\nfunction coderIsCodex(env) {\n if (env.CODER === 'codex' || env.CODER === 'codex-fallback')\n return true;\n const codeSpec = env.MODELS.code;\n if (codeSpec && String(codeSpec).split(':')[0] === 'codex')\n return true;\n return false;\n}\nfunction resolveQeSpec(env) {\n if (coderIsCodex(env))\n return 'opus';\n const CODEX_AVAILABLE = env.codexAvailable !== false;\n if (!CODEX_AVAILABLE)\n return 'opus';\n return 'codex:' + topCodexId(env) + ':high';\n}\nfunction routingRequested(env) {\n return (Object.keys(env.MODELS).length > 0 ||\n env.PLANNER === 'codex' ||\n env.CODER === 'codex' ||\n env.CODER === 'codex-fallback' ||\n env.QE_REVIEWER === 'codex' ||\n env.QE_REVIEWER === 'codex-fallback');\n}\nfunction resolveStageModel(stage, env) {\n if (env.usageOverride) {\n const r = (env.usageReasoning && env.usageReasoning[stage]) || OVERRIDE_REASONING[stage] || 'high';\n const o = specToOpts('codex:' + topCodexId(env) + ':' + r, env);\n o._usageSwitched = true;\n return o;\n }\n let spec = env.MODELS[stage];\n if (spec === undefined) {\n if (!routingRequested(env))\n return {};\n spec = DEFAULT_MODELS[stage];\n }\n if (stage === 'code' && (spec === null || spec === undefined))\n return specToOpts(resolveCoderSpec(env), env);\n if (stage === 'qe' && (spec === null || spec === undefined))\n return specToOpts(resolveQeSpec(env), env);\n return specToOpts(spec, env);\n}\nfunction modelLabel(opts) {\n if (opts && opts.agentType === 'codex:codex-rescue') {\n const base = 'codex:' + opts.codexModel + ':' + opts._reasoning;\n return opts._usageSwitched ? base + ' (usage-switched)' : base;\n }\n if (opts && opts.model)\n return opts.model;\n return 'session';\n}\nfunction stageLabel(base, opts) {\n const m = modelLabel(opts);\n return m === 'session' ? base : base + ' · ' + m;\n}\nfunction mergeOpts(base, extra) {\n const out = {};\n for (const k in base)\n out[k] = base[k];\n for (const k in extra)\n out[k] = extra[k];\n return out;\n}",
|
|
68
|
+
code: "const OVERRIDE_REASONING = {\n router: 'high',\n requirements: 'xhigh',\n research: 'xhigh',\n adr: 'xhigh',\n ideation: 'xhigh',\n ddd: 'xhigh',\n architecture: 'xhigh',\n plan: 'xhigh',\n code: 'xhigh',\n qe: 'high',\n fleet: 'high',\n};\nfunction topCodexId(env) {\n let top = env.CODEX_MODEL;\n if (top === 'auto') {\n const ids = Object.keys(KNOWN_CODEX);\n for (let i = 0; i < ids.length; i++) {\n if (ids[i] !== 'auto')\n top = ids[i] || top;\n }\n }\n return top;\n}\nconst KNOWN_CODEX = { auto: 1, 'gpt-5.5': 1, 'gpt-5.6': 1, 'gpt-5.6-luna': 1, 'gpt-5.6-terra': 1, 'gpt-5.6-sol': 1 };\nconst CLAUDE_NAMES = { fable: 1, opus: 1, sonnet: 1, haiku: 1 };\nconst VALID_REASONING = { none: 1, minimal: 1, low: 1, medium: 1, high: 1, xhigh: 1 };\nconst DEFAULT_MODELS = {\n router: 'fable',\n requirements: 'sonnet',\n research: 'sonnet',\n adr: 'opus',\n ideation: 'sonnet',\n ddd: 'opus',\n architecture: 'opus',\n plan: 'sonnet',\n code: null,\n qe: null,\n fleet: 'sonnet',\n};\nfunction specToOpts(spec, env) {\n const log = env.log || function () { };\n if (!spec)\n return {};\n const parts = String(spec).split(':');\n const head = parts[0] || '';\n if (head === 'codex') {\n let id = parts[1] || env.CODEX_MODEL;\n if (id !== 'auto' && !KNOWN_CODEX[id]) {\n log('models: unknown codex id ' + id + ' — using ' + env.CODEX_MODEL);\n id = env.CODEX_MODEL;\n }\n let reasoning = parts[2] || 'high';\n if (!VALID_REASONING[reasoning]) {\n log('models: unknown reasoning ' + reasoning + ' — using high');\n reasoning = 'high';\n }\n return { agentType: 'codex:codex-rescue', codexModel: id, _reasoning: reasoning };\n }\n if (CLAUDE_NAMES[head])\n return { model: head };\n log('models: unknown spec ' + spec + ' — session-inherited');\n return {};\n}\nfunction resolveCoderSpec(env) {\n if (env.CODER === 'codex' || env.CODER === 'codex-fallback')\n return 'codex:' + env.CODEX_MODEL + ':high';\n return 'opus';\n}\nfunction coderIsCodex(env) {\n if (env.CODER === 'codex' || env.CODER === 'codex-fallback')\n return true;\n const codeSpec = env.MODELS.code;\n if (codeSpec && String(codeSpec).split(':')[0] === 'codex')\n return true;\n return false;\n}\nfunction resolveQeSpec(env) {\n if (coderIsCodex(env))\n return 'opus';\n const CODEX_AVAILABLE = env.codexAvailable !== false;\n if (!CODEX_AVAILABLE)\n return 'opus';\n return 'codex:' + topCodexId(env) + ':high';\n}\nfunction routingRequested(env) {\n return (Object.keys(env.MODELS).length > 0 ||\n env.PLANNER === 'codex' ||\n env.CODER === 'codex' ||\n env.CODER === 'codex-fallback' ||\n env.QE_REVIEWER === 'codex' ||\n env.QE_REVIEWER === 'codex-fallback');\n}\nfunction resolveStageModel(stage, env) {\n if (env.usageOverride) {\n const r = (env.usageReasoning && env.usageReasoning[stage]) || OVERRIDE_REASONING[stage] || 'high';\n const o = specToOpts('codex:' + topCodexId(env) + ':' + r, env);\n o._usageSwitched = true;\n return o;\n }\n let spec = env.MODELS[stage];\n if (spec === undefined) {\n if (!routingRequested(env))\n return {};\n spec = DEFAULT_MODELS[stage];\n }\n if (stage === 'code' && (spec === null || spec === undefined))\n return specToOpts(resolveCoderSpec(env), env);\n if (stage === 'qe' && (spec === null || spec === undefined))\n return specToOpts(resolveQeSpec(env), env);\n return specToOpts(spec, env);\n}\nfunction modelLabel(opts) {\n if (opts && opts.agentType === 'codex:codex-rescue') {\n const base = 'codex:' + opts.codexModel + ':' + opts._reasoning;\n return opts._usageSwitched ? base + ' (usage-switched)' : base;\n }\n if (opts && opts.model)\n return opts.model;\n return 'session';\n}\nfunction stageLabel(base, opts) {\n const m = modelLabel(opts);\n return m === 'session' ? base : base + ' · ' + m;\n}\nfunction mergeOpts(base, extra) {\n const out = {};\n for (const k in base)\n out[k] = base[k];\n for (const k in extra)\n out[k] = extra[k];\n return out;\n}",
|
|
69
69
|
},
|
|
70
70
|
"usage-probes": {
|
|
71
71
|
name: "usage-probes",
|
package/src/operations.ts
CHANGED
|
@@ -196,7 +196,12 @@ function enrichEmitForTarget(
|
|
|
196
196
|
`interface:`,
|
|
197
197
|
` display_name: "${name}"`,
|
|
198
198
|
`policy:`,
|
|
199
|
-
|
|
199
|
+
// MEASURED (hermes research, codex.md §63, twin-test): codex PARSES this file and
|
|
200
|
+
// `allow_implicit_invocation: false` HIDES the skill from the session entirely — it is a
|
|
201
|
+
// visibility switch, not an ask-first switch. Risk-gating through it silently disappeared a
|
|
202
|
+
// compiled pack (the terraform smoke pack never registered, backlog 2b80420f). Visibility is
|
|
203
|
+
// therefore ALWAYS true; the risk score stays as INFORMATION for the operator below.
|
|
204
|
+
` allow_implicit_invocation: true # false would HIDE the skill from codex entirely (measured); risk is informational, see risk_level`,
|
|
200
205
|
` risk_level: "${risk.level}"`,
|
|
201
206
|
` risk_score: ${risk.total.toFixed(2)}`,
|
|
202
207
|
` risk_axes:`,
|
|
@@ -835,24 +840,39 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
835
840
|
const nodeMajor = Number(process.versions.node.split('.')[0] ?? '0');
|
|
836
841
|
checks.push({ name: 'node >= 20', ok: nodeMajor >= 20, detail: `node ${process.version}` });
|
|
837
842
|
|
|
838
|
-
// 2.
|
|
839
|
-
|
|
843
|
+
// 2/3. MONOREPO-ONLY checks, gated on PROJECT KIND (backlog fcf29728: in a consumer project
|
|
844
|
+
// skills-meta and the 10 adapters are not there and MUST not be — both checks were red forever
|
|
845
|
+
// and dz doctor could never exit 0 outside this repository, a standing false BLOCK for any
|
|
846
|
+
// consumer CI). Kind detection is structural: a checkout that carries packages/@dzhechkov IS the
|
|
847
|
+
// monorepo (or a fork of it) and owes itself these checks; anything else is a consumer project
|
|
848
|
+
// and gets a NAMED skip — a skip, never a silent pass and never a fail.
|
|
849
|
+
const isMonorepo = existsSync(join(root, 'packages', '@dzhechkov'));
|
|
850
|
+
checks.push({
|
|
851
|
+
name: '.claude/skills present',
|
|
852
|
+
ok: existsSync(join(root, '.claude', 'skills')),
|
|
853
|
+
detail: '.claude/skills',
|
|
854
|
+
});
|
|
855
|
+
if (isMonorepo) {
|
|
856
|
+
checks.push({
|
|
857
|
+
name: 'packages/@dzhechkov/skills-meta present',
|
|
858
|
+
ok: existsSync(join(root, 'packages/@dzhechkov/skills-meta')),
|
|
859
|
+
detail: 'packages/@dzhechkov/skills-meta',
|
|
860
|
+
});
|
|
861
|
+
const adapters = ['adapter-claude', 'adapter-codex', 'adapter-opencode', 'adapter-hermes', 'adapter-openclaude', 'adapter-copilot', 'adapter-agents-md', 'adapter-cursor', 'adapter-gemini', 'adapter-windsurf'];
|
|
862
|
+
const foundAdapters = adapters.filter((a) => existsSync(join(root, 'packages/@dzhechkov', a)));
|
|
863
|
+
checks.push({
|
|
864
|
+
name: 'adapters present',
|
|
865
|
+
ok: foundAdapters.length === adapters.length,
|
|
866
|
+
detail: `${foundAdapters.length}/${adapters.length} adapters found`,
|
|
867
|
+
});
|
|
868
|
+
} else {
|
|
840
869
|
checks.push({
|
|
841
|
-
name:
|
|
842
|
-
ok:
|
|
843
|
-
detail:
|
|
870
|
+
name: 'monorepo checks',
|
|
871
|
+
ok: true,
|
|
872
|
+
detail: 'consumer project (no packages/@dzhechkov) — skills-meta/adapter checks are the MONOREPO\'s own duty and are skipped here by kind, not by silence',
|
|
844
873
|
});
|
|
845
874
|
}
|
|
846
875
|
|
|
847
|
-
// 3. Adapter resolvability (one per target platform)
|
|
848
|
-
const adapters = ['adapter-claude', 'adapter-codex', 'adapter-opencode', 'adapter-hermes', 'adapter-openclaude', 'adapter-copilot', 'adapter-agents-md', 'adapter-cursor', 'adapter-gemini', 'adapter-windsurf'];
|
|
849
|
-
const foundAdapters = adapters.filter((a) => existsSync(join(root, 'packages/@dzhechkov', a)));
|
|
850
|
-
checks.push({
|
|
851
|
-
name: 'adapters present',
|
|
852
|
-
ok: foundAdapters.length === adapters.length,
|
|
853
|
-
detail: `${foundAdapters.length}/${adapters.length} adapters found`,
|
|
854
|
-
});
|
|
855
|
-
|
|
856
876
|
// 4. Package version consistency
|
|
857
877
|
const pkgsDir = join(root, 'packages/@dzhechkov');
|
|
858
878
|
if (existsSync(pkgsDir)) {
|
|
@@ -937,10 +957,17 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
937
957
|
const skillDirs = readdirSync(skillsDir, { withFileTypes: true })
|
|
938
958
|
.filter((e) => e.isDirectory() && !e.name.startsWith('.'));
|
|
939
959
|
const withSkillMd = skillDirs.filter((e) => existsSync(join(skillsDir, e.name, 'SKILL.md')));
|
|
960
|
+
// A visible dir with NO SKILL.md is almost never a broken skill — it is a FOREIGN directory
|
|
961
|
+
// (measured: a 102-file health-advisor/ residue from an old `ha init` read as «27/28», which a
|
|
962
|
+
// human parses as "one skill is broken" and goes fixing a skill; the true cure is cleanup).
|
|
963
|
+
// Name the offenders and say which treatment applies (HA-improvements 2026-08, b5ba7b4a).
|
|
964
|
+
const foreign = skillDirs.filter((e) => !existsSync(join(skillsDir, e.name, 'SKILL.md'))).map((e) => e.name);
|
|
940
965
|
checks.push({
|
|
941
966
|
name: 'skills health',
|
|
942
967
|
ok: withSkillMd.length === skillDirs.length,
|
|
943
|
-
detail:
|
|
968
|
+
detail: foreign.length === 0
|
|
969
|
+
? `${withSkillMd.length}/${skillDirs.length} skill dirs have SKILL.md`
|
|
970
|
+
: `${withSkillMd.length}/${skillDirs.length} skill dirs have SKILL.md — ${foreign.length} FOREIGN dir(s) in the skills root (not broken skills; the cure is cleanup, not repair): ${foreign.slice(0, 5).join(', ')}${foreign.length > 5 ? ', …' : ''}`,
|
|
944
971
|
});
|
|
945
972
|
}
|
|
946
973
|
|
|
@@ -1007,14 +1034,20 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
1007
1034
|
const usageLog = join(root, '.dz', 'recall-usage.jsonl');
|
|
1008
1035
|
const { newest, hasCodexRow } = newestRecallUsageRuntime(usageLog);
|
|
1009
1036
|
const fresh = newest !== undefined && Date.now() - Date.parse(newest) < 14 * 24 * 60 * 60 * 1000;
|
|
1037
|
+
// In a CONSUMER project the machine-global hook is wired but a FRESH project has zero rows
|
|
1038
|
+
// by construction — dead-leg and new-project are indistinguishable there, and a permanent
|
|
1039
|
+
// red on arrival is a false CI block (fcf29728). The row degrades to an ADVISORY (ok:true,
|
|
1040
|
+
// wording intact) outside the monorepo; in the monorepo it stays the hard row that killed
|
|
1041
|
+
// the 19-day dark leg.
|
|
1042
|
+
const applyLegOk = hasCodexRow && fresh;
|
|
1010
1043
|
checks.push({
|
|
1011
1044
|
name: 'codex apply-leg (recall hook)',
|
|
1012
|
-
ok:
|
|
1013
|
-
detail: hasCodexRow
|
|
1045
|
+
ok: isMonorepo ? applyLegOk : true,
|
|
1046
|
+
detail: (isMonorepo || applyLegOk ? '' : 'advisory (consumer project — a fresh store has no rows by construction): ') + (hasCodexRow
|
|
1014
1047
|
? fresh
|
|
1015
1048
|
? `codex recall rows present, newest ${newest}`
|
|
1016
1049
|
: `codex recall hook is WIRED but SILENT: newest recall-usage row is ${String(newest)} — the entry may have lost hook trust (re-run dz hooks-sync --target codex --verify)`
|
|
1017
|
-
: 'codex recall hook is WIRED but has NEVER written a row — a dead leg looks exactly like a correctly-silent one, so this is reported non-OK until one lands',
|
|
1050
|
+
: 'codex recall hook is WIRED but has NEVER written a row — a dead leg looks exactly like a correctly-silent one, so this is reported non-OK until one lands'),
|
|
1018
1051
|
});
|
|
1019
1052
|
}
|
|
1020
1053
|
}
|
package/src/publish.ts
CHANGED
|
@@ -89,6 +89,68 @@ function maxPublished(name: string, localVersion: string): string {
|
|
|
89
89
|
return pub !== undefined && compareVersions(pub, localVersion) > 0 ? pub : localVersion;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// ── workspace-floor preflight (feature workspace-dep-protocol, Codex P1) ─────
|
|
93
|
+
//
|
|
94
|
+
// Sibling deps are declared `workspace:^`, and pnpm rewrites them at pack time to `^<the sibling's
|
|
95
|
+
// DISK version>`. That version is not necessarily PUBLISHED: `--bump-only` stages versions on disk,
|
|
96
|
+
// and a later `--filter`ed publish of just the dependent would ship a floor nobody can install —
|
|
97
|
+
// the publish itself succeeds, and every consumer `npm install` then fails with ETARGET. Staged is
|
|
98
|
+
// not shipped; this preflight makes the difference a refusal instead of a broken release.
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pure half: which `workspace:`-declared deps of a package would pack to a floor that is neither
|
|
102
|
+
* being published in this batch nor already on the registry?
|
|
103
|
+
*
|
|
104
|
+
* Fail-closed by design: a probe that cannot answer (offline, 404) reports the floor as
|
|
105
|
+
* unpublished — a publish needs the network anyway, and refusing beats shipping ETARGET.
|
|
106
|
+
*/
|
|
107
|
+
export function findUnpublishedWorkspaceFloors(opts: {
|
|
108
|
+
readonly dependencies: Record<string, string> | undefined;
|
|
109
|
+
/** pnpm rewrites `workspace:` in peerDependencies at pack time too (Codex P2) — same hazard. */
|
|
110
|
+
readonly peerDependencies?: Record<string, string> | undefined;
|
|
111
|
+
/** name → version on DISK, for every package in the workspace (what pnpm packs the floor from). */
|
|
112
|
+
readonly workspaceVersions: ReadonlyMap<string, string>;
|
|
113
|
+
/**
|
|
114
|
+
* Names whose publish has LANDED (or, in a dry-run preview, would land) BEFORE this package.
|
|
115
|
+
* Static batch membership is not enough (Codex P1): a sibling that failed its own gates earlier
|
|
116
|
+
* in the batch has no published floor, and its dependents must fall through to the probe.
|
|
117
|
+
*/
|
|
118
|
+
readonly batch: ReadonlySet<string>;
|
|
119
|
+
readonly probe: (name: string, version: string) => boolean;
|
|
120
|
+
}): { name: string; version: string }[] {
|
|
121
|
+
const missing: { name: string; version: string }[] = [];
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
// Sections are inspected INDEPENDENTLY, never object-merged: a plain peer range for the same
|
|
124
|
+
// sibling would overwrite a `workspace:^` dependency entry in a spread, and pnpm still rewrites
|
|
125
|
+
// the dependency section — the protocol in EITHER section makes the floor pack from disk.
|
|
126
|
+
const entries = [...Object.entries(opts.dependencies ?? {}), ...Object.entries(opts.peerDependencies ?? {})];
|
|
127
|
+
for (const [dep, spec] of entries) {
|
|
128
|
+
if (!String(spec).startsWith('workspace:')) continue;
|
|
129
|
+
if (seen.has(dep)) continue;
|
|
130
|
+
seen.add(dep);
|
|
131
|
+
if (opts.batch.has(dep)) continue; // publishes before this package (deps-first order)
|
|
132
|
+
const version = opts.workspaceVersions.get(dep);
|
|
133
|
+
if (version === undefined) {
|
|
134
|
+
// A workspace: spec naming a package that is not in the workspace — pnpm pack would die on
|
|
135
|
+
// it anyway, but die HERE with a name, not mid-batch.
|
|
136
|
+
missing.push({ name: dep, version: '(not in workspace)' });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!opts.probe(dep, version)) missing.push({ name: dep, version });
|
|
140
|
+
}
|
|
141
|
+
return missing;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Registry probe: is exactly `name@version` published? Empty output / 404 / offline ⇒ no. */
|
|
145
|
+
function versionPublished(name: string, version: string): boolean {
|
|
146
|
+
try {
|
|
147
|
+
const out = execSync(`npm view ${name}@${version} version`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', timeout: 20000 }).trim();
|
|
148
|
+
return out === version;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
92
154
|
/**
|
|
93
155
|
* `execSync` throws an Error whose `.message` is only `Command failed: <cmd>` — the child's real output
|
|
94
156
|
* (the `npm ERR!` lines that say WHY a publish failed) sits on `.stdout` / `.stderr` and was being
|
|
@@ -331,6 +393,13 @@ export function publishPackages(
|
|
|
331
393
|
claimGate?: 'off' | 'warn' | 'error' | undefined;
|
|
332
394
|
/** ADR-001: `auto` (default) decides from the environment; `on` fails where it cannot work. */
|
|
333
395
|
provenance?: ProvenanceMode | undefined;
|
|
396
|
+
/**
|
|
397
|
+
* Floor probe injection for the workspace-floor preflight (see
|
|
398
|
+
* `findUnpublishedWorkspaceFloors`). Default: a real `npm view` probe, which runs only on LIVE
|
|
399
|
+
* publishes — dry-run stays offline, matching `maxPublished`. Injecting a probe also arms the
|
|
400
|
+
* preflight under dry-run, which is how the wiring test drives it without network.
|
|
401
|
+
*/
|
|
402
|
+
probeFloor?: ((name: string, version: string) => boolean) | undefined;
|
|
334
403
|
} = {},
|
|
335
404
|
): PublishReport {
|
|
336
405
|
// Decide ONCE, before the batch: `--provenance` in an incapable environment must fail here, not on
|
|
@@ -346,6 +415,15 @@ export function publishPackages(
|
|
|
346
415
|
// freshly-bumped version, never a stale one (the harness-cli@0.3.122 breakage).
|
|
347
416
|
const ordered = orderByDependencies(filtered);
|
|
348
417
|
|
|
418
|
+
// Workspace-floor preflight inputs: the full workspace version map (what pnpm would pack each
|
|
419
|
+
// floor from), and the names whose publish has LANDED so far in this run — grown as the loop
|
|
420
|
+
// proceeds, never assumed from batch membership (Codex P1: a sibling that failed its own gates
|
|
421
|
+
// has no published floor, and static membership would still have covered its dependents).
|
|
422
|
+
const workspaceVersions = new Map(packages.map((p) => [p.name, p.version]));
|
|
423
|
+
const landedInBatch = new Set<string>();
|
|
424
|
+
const armFloorPreflight = opts.bumpOnly !== true && (opts.dryRun !== true || opts.probeFloor !== undefined);
|
|
425
|
+
const probeFloor = opts.probeFloor ?? versionPublished;
|
|
426
|
+
|
|
349
427
|
for (const pkg of ordered) {
|
|
350
428
|
const oldVersion = pkg.version;
|
|
351
429
|
// Bump from max(local, npm-published) so a locally-reverted version can't
|
|
@@ -370,6 +448,24 @@ export function publishPackages(
|
|
|
370
448
|
continue;
|
|
371
449
|
}
|
|
372
450
|
|
|
451
|
+
// Workspace-floor preflight (Codex P1, feature workspace-dep-protocol): a `workspace:^` dep
|
|
452
|
+
// packs to `^<sibling's DISK version>` — refuse if that floor is neither in this batch nor on
|
|
453
|
+
// the registry, or the publish succeeds and every consumer install dies with ETARGET.
|
|
454
|
+
if (armFloorPreflight) {
|
|
455
|
+
const manifest = JSON.parse(readFileSync(join(pkg.dir, 'package.json'), 'utf-8')) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
|
|
456
|
+
const unpublishedFloors = findUnpublishedWorkspaceFloors({ dependencies: manifest.dependencies, peerDependencies: manifest.peerDependencies, workspaceVersions, batch: landedInBatch, probe: probeFloor });
|
|
457
|
+
if (unpublishedFloors.length > 0) {
|
|
458
|
+
results.push({
|
|
459
|
+
name: pkg.name,
|
|
460
|
+
oldVersion,
|
|
461
|
+
newVersion,
|
|
462
|
+
status: 'error',
|
|
463
|
+
error: `workspace floor(s) not published: ${unpublishedFloors.map((f) => `${f.name}@${f.version}`).join(', ')}. Publish the sibling(s) first or include them in --filter — a staged disk version is not a shipped one.`,
|
|
464
|
+
});
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
373
469
|
// Pre-publish claim-check gate. Default `'warn'` per ADR-001: publishing SURFACES a
|
|
374
470
|
// README's untagged claims by default, but `'warn'` NEVER changes publish status, so the
|
|
375
471
|
// existing publish path is unaffected. `'error'` fails only THIS package when it carries a
|
|
@@ -405,6 +501,7 @@ export function publishPackages(
|
|
|
405
501
|
|
|
406
502
|
if (opts.dryRun) {
|
|
407
503
|
results.push({ name: pkg.name, oldVersion, newVersion, status: 'skipped', claimCheck: claimCheckSummary });
|
|
504
|
+
landedInBatch.add(pkg.name); // preview: this package passed its gates and WOULD land
|
|
408
505
|
continue;
|
|
409
506
|
}
|
|
410
507
|
|
|
@@ -511,6 +608,7 @@ export function publishPackages(
|
|
|
511
608
|
});
|
|
512
609
|
|
|
513
610
|
results.push({ name: pkg.name, oldVersion, newVersion, status: 'published', claimCheck: claimCheckSummary });
|
|
611
|
+
landedInBatch.add(pkg.name); // only an ACTUAL publish covers dependents (Codex P1)
|
|
514
612
|
} catch (err) {
|
|
515
613
|
// The version was written BEFORE build+publish; on any failure restore the
|
|
516
614
|
// original package.json (and README, if we rewrote its version) so a failed
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
* where an irrelevant English one reaches 0.254. A single floor still works, with a thin 0.032
|
|
27
27
|
* margin; per-language floors triple it. Hence the defaults below.
|
|
28
28
|
*
|
|
29
|
-
* The turns that must stay silent do
|
|
29
|
+
* The turns that must stay silent do (2026-07-09 numbers; re-measured 2026-08-24 — «спасибо» rose
|
|
30
|
+
* to 0.416 but is cut by the SIGNAL gate before any floor, and «какой статус?» rose to 0.386, which
|
|
31
|
+
* is what forced the recalibration above): both under
|
|
30
32
|
* every floor here.
|
|
31
33
|
*
|
|
32
34
|
* @packageDocumentation
|
|
@@ -46,7 +48,18 @@ export interface RecallFloors {
|
|
|
46
48
|
* slightly closer to any Latin text than two unrelated Latin texts are to each other — the baseline,
|
|
47
49
|
* not the signal, is what shifts.
|
|
48
50
|
*/
|
|
49
|
-
|
|
51
|
+
// RECALIBRATED 2026-08-24 on the LIVE 281-pattern store, end to end through `dz recall --json`
|
|
52
|
+
// (the closeness feature made the true cosine visible, which is what exposed the drift): over the
|
|
53
|
+
// probes that actually REACH the floor — the signal gate cuts "спасибо"/"thanks" first —
|
|
54
|
+
// ru: min(relevant)=0.409, max(irrelevant)=0.386 ("какой статус?", ABOVE the old 0.38 floor);
|
|
55
|
+
// en: min(relevant)=0.413, max(irrelevant)=0.332 (nonsense scored 0.327, above the old 0.31).
|
|
56
|
+
// The 2026-07-09 floors were calibrated on 103 patterns; at 281 the irrelevant tail rose. The RU
|
|
57
|
+
// window is now THIN (+0.023) — an honest limit, not a solved problem: it narrows again as the
|
|
58
|
+
// store grows, and the next recalibration should follow the next major store growth.
|
|
59
|
+
// en is 0.36 rather than the live midpoint 0.37 because the hermetic fixture's weakest relevant
|
|
60
|
+
// probe sits at 0.369, and a floor above it would fail the calibration test that guards this file.
|
|
61
|
+
// Probe set + raw results: test/fixtures/recall-floor-live-2026-08-24.json.
|
|
62
|
+
export const DEFAULT_RECALL_FLOORS: RecallFloors = { ru: 0.40, en: 0.36 };
|
|
50
63
|
|
|
51
64
|
/** Max hits injected into a turn. Three is the ADR default; more is noise, not context. */
|
|
52
65
|
export const DEFAULT_RECALL_HOOK_LIMIT = 3;
|