@dzhechkov/harness-core 0.7.3 → 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.
Files changed (72) hide show
  1. package/.dz-manifest.json +131 -51
  2. package/README.md +1 -1
  3. package/dist/agentdb-index.d.ts.map +1 -1
  4. package/dist/agentdb-index.js +26 -2
  5. package/dist/agentdb-index.js.map +1 -1
  6. package/dist/amendment-trace.d.ts.map +1 -1
  7. package/dist/amendment-trace.js +6 -1
  8. package/dist/amendment-trace.js.map +1 -1
  9. package/dist/cadence.d.ts +66 -0
  10. package/dist/cadence.d.ts.map +1 -0
  11. package/dist/cadence.js +222 -0
  12. package/dist/cadence.js.map +1 -0
  13. package/dist/cli-flag-notice.d.ts +50 -0
  14. package/dist/cli-flag-notice.d.ts.map +1 -0
  15. package/dist/cli-flag-notice.js +106 -0
  16. package/dist/cli-flag-notice.js.map +1 -0
  17. package/dist/feature-adr-routing.d.ts.map +1 -1
  18. package/dist/feature-adr-routing.js +1 -1
  19. package/dist/feature-adr-routing.js.map +1 -1
  20. package/dist/index.d.ts +8 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +6 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/loop-blobs.generated.js +2 -2
  25. package/dist/loop-blobs.generated.js.map +1 -1
  26. package/dist/operations.d.ts.map +1 -1
  27. package/dist/operations.js +61 -19
  28. package/dist/operations.js.map +1 -1
  29. package/dist/publish.d.ts +31 -0
  30. package/dist/publish.d.ts.map +1 -1
  31. package/dist/publish.js +78 -0
  32. package/dist/publish.js.map +1 -1
  33. package/dist/recall-hook-policy.d.ts +3 -1
  34. package/dist/recall-hook-policy.d.ts.map +1 -1
  35. package/dist/recall-hook-policy.js +15 -2
  36. package/dist/recall-hook-policy.js.map +1 -1
  37. package/dist/recall-usage.d.ts +15 -0
  38. package/dist/recall-usage.d.ts.map +1 -1
  39. package/dist/recall-usage.js +51 -1
  40. package/dist/recall-usage.js.map +1 -1
  41. package/dist/score.d.ts.map +1 -1
  42. package/dist/score.js +38 -4
  43. package/dist/score.js.map +1 -1
  44. package/dist/tg-post.d.ts +54 -0
  45. package/dist/tg-post.d.ts.map +1 -0
  46. package/dist/tg-post.js +117 -0
  47. package/dist/tg-post.js.map +1 -0
  48. package/dist/usage.d.ts +31 -0
  49. package/dist/usage.d.ts.map +1 -1
  50. package/dist/usage.js +108 -21
  51. package/dist/usage.js.map +1 -1
  52. package/dist/writer-quiescence.d.ts +42 -0
  53. package/dist/writer-quiescence.d.ts.map +1 -0
  54. package/dist/writer-quiescence.js +82 -0
  55. package/dist/writer-quiescence.js.map +1 -0
  56. package/package.json +13 -13
  57. package/sbom.json +250 -50
  58. package/src/agentdb-index.ts +26 -2
  59. package/src/amendment-trace.ts +6 -1
  60. package/src/cadence.ts +227 -0
  61. package/src/cli-flag-notice.ts +114 -0
  62. package/src/feature-adr-routing.ts +1 -1
  63. package/src/index.ts +8 -1
  64. package/src/loop-blobs.generated.ts +2 -2
  65. package/src/operations.ts +60 -20
  66. package/src/publish.ts +98 -0
  67. package/src/recall-hook-policy.ts +15 -2
  68. package/src/recall-usage.ts +44 -1
  69. package/src/score.ts +30 -4
  70. package/src/tg-post.ts +137 -0
  71. package/src/usage.ts +132 -17
  72. package/src/writer-quiescence.ts +95 -0
@@ -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({ model: model.model, dimension: model.dim, provider: 'transformers' });
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({ model: model.model, dimension: model.dim, provider: 'transformers' });
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) {
@@ -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
- const re = /\b(?:it|test|describe)(?:\.\w+)*(?:\s*\([^()]{0,200}\))?\s*(?:`[^`]*`)?\s*\(\s*(['"`])([\s\S]{1,300}?)\1/g;
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
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * An unrecognised `--flag` must not pass in silence.
3
+ *
4
+ * MEASURED 2026-08-24: `dz recall "x" --breif --limit 2` printed the full ordinary output and exited
5
+ * 0. Someone who typed `--breif` for `--brief` reads that as "the mode worked" — and every `dz`
6
+ * command behaves the same way, because the argv parser accepts any `--name` it is handed.
7
+ *
8
+ * WHY THIS WARNS RATHER THAN REFUSES, and the measurement behind it. Two ways to build a per-command
9
+ * allowlist were tried and BOTH are unsafe:
10
+ *
11
+ * - from the help text: 53 of the 220 flag names the CLI actually reads appear nowhere in help, so
12
+ * refusing on a help-derived list would break 53 working invocations;
13
+ * - from static extraction over the dispatch table: it lost `--week` from `dz recap` (those flags
14
+ * are read through a loop over a constant, not a literal `flags.has('week')`) and picked up a
15
+ * neighbouring command's flags for `dz usage`. It both under- and over-covers.
16
+ *
17
+ * A refusal built on either would reject working commands, and breaking a correct invocation is a
18
+ * worse failure than the one being fixed. So the KNOWN set here is the union of every name the CLI
19
+ * reads and every name its help documents, and an unrecognised name is reported loudly while the
20
+ * command still does its work. That removes the SILENCE, which is the actual harm.
21
+ *
22
+ * HONEST LIMIT, and it is real: this catches a name no command anywhere knows. It does NOT catch a
23
+ * name that is valid for a different command — `dz recap --manifest` stays quiet. Closing that needs
24
+ * a hand-curated per-command list, which is filed with these measurements rather than guessed at.
25
+ */
26
+
27
+ /**
28
+ * Damerau-Levenshtein distance — Levenshtein plus ADJACENT TRANSPOSITION at cost 1.
29
+ *
30
+ * The transposition case is not a refinement, it is the common case: `--limti` for `--limit` is one
31
+ * swapped pair, which plain Levenshtein scores 2 and a length-scaled bound then rejected, so the
32
+ * most frequent kind of typo got no suggestion at all (measured on this very set 2026-08-24).
33
+ * Suggestion only — never a decision.
34
+ */
35
+ function editDistance(a: string, b: string): number {
36
+ const m = a.length;
37
+ const n = b.length;
38
+ if (m === 0) return n;
39
+ if (n === 0) return m;
40
+ const d: number[][] = Array.from({ length: m + 1 }, (_, i) => [i, ...Array<number>(n).fill(0)]);
41
+ for (let j = 0; j <= n; j++) (d[0] as number[])[j] = j;
42
+ for (let i = 1; i <= m; i++) {
43
+ for (let j = 1; j <= n; j++) {
44
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
45
+ let best = Math.min(
46
+ ((d[i] as number[])[j - 1] as number) + 1,
47
+ ((d[i - 1] as number[])[j] as number) + 1,
48
+ ((d[i - 1] as number[])[j - 1] as number) + cost,
49
+ );
50
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
51
+ best = Math.min(best, ((d[i - 2] as number[])[j - 2] as number) + 1);
52
+ }
53
+ (d[i] as number[])[j] = best;
54
+ }
55
+ }
56
+ return (d[m] as number[])[n] as number;
57
+ }
58
+
59
+ /**
60
+ * Every known name as close to `name` as the closest one is.
61
+ *
62
+ * TIES ARE NOT BROKEN. `--wek` sits one edit from both `--week` and `--weak`, and picking whichever
63
+ * came first in the list points the reader confidently at a coin flip. All of them are named, and
64
+ * the reader decides.
65
+ *
66
+ * The bound scales with length so a three-letter name cannot match everything: at most a third of
67
+ * the name may differ, and never more than two characters.
68
+ */
69
+ export function nearestKnownFlag(name: string, known: readonly string[]): string[] {
70
+ const limit = Math.min(2, Math.max(1, Math.floor(name.length / 3)));
71
+ let bestScore = limit + 1;
72
+ let best: string[] = [];
73
+ for (const candidate of known) {
74
+ const dist = editDistance(name, candidate);
75
+ if (dist < bestScore) {
76
+ bestScore = dist;
77
+ best = [candidate];
78
+ } else if (dist === bestScore) {
79
+ best.push(candidate);
80
+ }
81
+ }
82
+ return bestScore <= limit ? [...new Set(best)].sort() : [];
83
+ }
84
+
85
+ export interface UnknownFlagNotice {
86
+ readonly name: string;
87
+ /** Every equally-close known name. Empty when nothing is close enough to be worth naming. */
88
+ readonly suggestions: readonly string[];
89
+ readonly line: string;
90
+ }
91
+
92
+ /**
93
+ * One notice per unrecognised name, or an empty list when everything is known.
94
+ *
95
+ * `passed` is every `--name` the user typed, whether it took a value or not: a typo'd OPTION
96
+ * (`--limti 5`) is exactly as silent as a typo'd flag, and was equally unreported.
97
+ */
98
+ export function unknownFlagNotice(passed: readonly string[], known: readonly string[]): UnknownFlagNotice[] {
99
+ const set = new Set(known);
100
+ const out: UnknownFlagNotice[] = [];
101
+ for (const name of passed) {
102
+ if (name === '' || set.has(name)) continue;
103
+ const suggestions = nearestKnownFlag(name, known);
104
+ const hint = suggestions.length === 0
105
+ ? 'no dz command reads it.'
106
+ : `did you mean ${suggestions.map((s) => `--${s}`).join(' or ')}?`;
107
+ out.push({
108
+ name,
109
+ suggestions,
110
+ line: `dz: unknown option --${name} — ${hint} It was IGNORED, not applied`,
111
+ });
112
+ }
113
+ return out;
114
+ }
@@ -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';
@@ -817,6 +818,8 @@ export * from './score.js';
817
818
  export * from './recap.js';
818
819
  export * from './provenance.js';
819
820
  export * from './name-check.js';
821
+ export * from './cli-flag-notice.js';
822
+ export * from './tg-post.js';
820
823
 
821
824
  // Mutation gate (feature ha-mutation-gate) — deliberately break each NAMED protection in a scratch
822
825
  // copy, run the suite, REQUIRE red. Proves a test DISCRIMINATES, not merely that it is green.
@@ -831,3 +834,7 @@ export * from './backlog.js';
831
834
  // no-stubs (backlog 0b403a0106103901) — deterministic unfinished-stub-marker scan over the
832
835
  // CHANGE-SET, wired as the `no-stubs` SOFT publish guard rule + the feature-adr Step-8 QE item.
833
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: "dd4020b613dc3814aaaf5c6a07a82bff844680f3be43d2943754f46b0b1bea3c",
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
- ` allow_implicit_invocation: ${risk.level === 'low' || risk.level === 'medium'}`,
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. Key directories
839
- for (const dir of ['.claude/skills', 'packages/@dzhechkov/skills-meta']) {
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: `${dir} present`,
842
- ok: existsSync(join(root, dir)),
843
- detail: dir,
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)) {
@@ -928,12 +948,26 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
928
948
  // 7. Skills directory health
929
949
  const skillsDir = join(root, '.claude', 'skills');
930
950
  if (existsSync(skillsDir)) {
931
- const skillDirs = readdirSync(skillsDir, { withFileTypes: true }).filter((e) => e.isDirectory());
951
+ // A DOT-prefixed directory is not a skill. `.claude/skills/.validation` holds the shared schemas
952
+ // and eval templates the whole tree references (30+ eval files name its path), and it has no
953
+ // SKILL.md because it is not invokable. Counting it made this check permanently red at 270/271 —
954
+ // and TWO earlier investigations reached that same conclusion and left the checker alone
955
+ // (features/autonomous-2026-07-27/health-sweep.md, features/audit-2026-06-12). A red that three
956
+ // people diagnose and nobody fixes is a red that has stopped being read.
957
+ const skillDirs = readdirSync(skillsDir, { withFileTypes: true })
958
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'));
932
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);
933
965
  checks.push({
934
966
  name: 'skills health',
935
967
  ok: withSkillMd.length === skillDirs.length,
936
- detail: `${withSkillMd.length}/${skillDirs.length} skill dirs have SKILL.md`,
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 ? ', …' : ''}`,
937
971
  });
938
972
  }
939
973
 
@@ -1000,14 +1034,20 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1000
1034
  const usageLog = join(root, '.dz', 'recall-usage.jsonl');
1001
1035
  const { newest, hasCodexRow } = newestRecallUsageRuntime(usageLog);
1002
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;
1003
1043
  checks.push({
1004
1044
  name: 'codex apply-leg (recall hook)',
1005
- ok: hasCodexRow && fresh,
1006
- detail: hasCodexRow
1045
+ ok: isMonorepo ? applyLegOk : true,
1046
+ detail: (isMonorepo || applyLegOk ? '' : 'advisory (consumer project — a fresh store has no rows by construction): ') + (hasCodexRow
1007
1047
  ? fresh
1008
1048
  ? `codex recall rows present, newest ${newest}`
1009
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)`
1010
- : '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'),
1011
1051
  });
1012
1052
  }
1013
1053
  }