@dzhechkov/harness-core 0.6.0 → 0.7.0

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 (58) hide show
  1. package/.dz-manifest.json +89 -773
  2. package/README.md +48 -1
  3. package/dist/agentdb-index.d.ts +11 -0
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +8 -1
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/amendment-trace.d.ts +98 -0
  8. package/dist/amendment-trace.d.ts.map +1 -0
  9. package/dist/amendment-trace.js +275 -0
  10. package/dist/amendment-trace.js.map +1 -0
  11. package/dist/guard.d.ts +29 -0
  12. package/dist/guard.d.ts.map +1 -1
  13. package/dist/guard.js +54 -0
  14. package/dist/guard.js.map +1 -1
  15. package/dist/index.d.ts +7 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +5 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/patterns.d.ts.map +1 -1
  20. package/dist/patterns.js +4 -1
  21. package/dist/patterns.js.map +1 -1
  22. package/dist/publish-signing.d.ts +113 -0
  23. package/dist/publish-signing.d.ts.map +1 -0
  24. package/dist/publish-signing.js +124 -0
  25. package/dist/publish-signing.js.map +1 -0
  26. package/dist/publish.d.ts +21 -0
  27. package/dist/publish.d.ts.map +1 -1
  28. package/dist/publish.js +106 -0
  29. package/dist/publish.js.map +1 -1
  30. package/dist/registry.d.ts +19 -0
  31. package/dist/registry.d.ts.map +1 -1
  32. package/dist/registry.js +91 -1
  33. package/dist/registry.js.map +1 -1
  34. package/dist/run-records.d.ts +66 -0
  35. package/dist/run-records.d.ts.map +1 -0
  36. package/dist/run-records.js +169 -0
  37. package/dist/run-records.js.map +1 -0
  38. package/dist/sign.d.ts +24 -2
  39. package/dist/sign.d.ts.map +1 -1
  40. package/dist/sign.js +34 -1
  41. package/dist/sign.js.map +1 -1
  42. package/dist/vector-tier.d.ts +81 -0
  43. package/dist/vector-tier.d.ts.map +1 -1
  44. package/dist/vector-tier.js +137 -10
  45. package/dist/vector-tier.js.map +1 -1
  46. package/package.json +6 -6
  47. package/sbom.json +176 -1886
  48. package/src/agentdb-index.ts +8 -1
  49. package/src/amendment-trace.ts +348 -0
  50. package/src/guard.ts +74 -0
  51. package/src/index.ts +27 -1
  52. package/src/patterns.ts +4 -1
  53. package/src/publish-signing.ts +217 -0
  54. package/src/publish.ts +102 -1
  55. package/src/registry.ts +84 -1
  56. package/src/run-records.ts +220 -0
  57. package/src/sign.ts +33 -1
  58. package/src/vector-tier.ts +202 -12
@@ -173,7 +173,14 @@ export async function indexPatternsToAgentdb(
173
173
  * The RECALL/search default task_types. Deliberately EXCLUDES `dz-backlog`: `dz recall` (and
174
174
  * feature-adr Step-0) must never surface raw backlog ideas as if they were earned lessons (ADR-005).
175
175
  */
176
- const DZ_TASK_TYPES = ['dz-teach', 'dz-learning'] as const;
176
+ /**
177
+ * The PATTERN scope — the task types a learned-pattern count covers. Exported so `dz vector status`
178
+ * can report a mirrored count comparable to its lexical one; the lifecycle superset below is for
179
+ * ownership, and reporting IT beside a pattern count once led a reader to conclude half the index
180
+ * was orphaned when none of it was.
181
+ */
182
+ export const DZ_PATTERN_TASK_TYPES = ['dz-teach', 'dz-learning'] as const;
183
+ const DZ_TASK_TYPES = DZ_PATTERN_TASK_TYPES;
177
184
 
178
185
  /**
179
186
  * The dz-owned task_types for LIFECYCLE scans (id enumeration + reindex ownership) — a SUPERSET of
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Amendment traceability — the deterministic half of the Step-8 amendment gate (ADR-001).
3
+ *
4
+ * The gate used to be a paragraph of prompt text asking the QE agent to confirm that every `AM-N`
5
+ * row names a real test. That is layer 4 on the cost-of-detection ladder, and the recalled lesson at
6
+ * reward 1.00 says what happens next: a safety property that lives only in a prompt disappears with
7
+ * the next prompt. `features/qe-scoped-review/08_qe_report.md` recorded the outcome — five ids named,
8
+ * none existing, and the plan writing `## Amendments: None`.
9
+ *
10
+ * Everything here is PURE: text in, verdicts out, file access through an injected reader (NFR-1).
11
+ * The CLI owns I/O and the exit code; this module owns the grammar and the rules.
12
+ *
13
+ * NOT PROVEN HERE: that a resolved test is non-vacuous. `dz discrimination-check` owns "would this
14
+ * test still pass with the protection deleted" (non-goal NG-1, acid case A8). A checker that implied
15
+ * it proved vacuity would be the same lie in a new place.
16
+ */
17
+
18
+ /** One `AM-N` row as it appears in a `## Amendments` section. */
19
+ export interface AmendmentRow {
20
+ readonly id: string;
21
+ readonly testIds: readonly string[];
22
+ readonly file: string | null;
23
+ readonly raw: string;
24
+ }
25
+
26
+ export type AmendmentVerdict =
27
+ | 'resolved'
28
+ | 'placeholder'
29
+ | 'unnamed'
30
+ | 'no-file-named'
31
+ | 'file-missing'
32
+ | 'name-absent-in-file';
33
+
34
+ export interface AmendmentResolution {
35
+ readonly id: string;
36
+ readonly testId: string | null;
37
+ readonly file: string | null;
38
+ readonly verdict: AmendmentVerdict;
39
+ readonly detail: string;
40
+ }
41
+
42
+ export type AmendmentOutcome = 'pass' | 'fail' | 'skip' | 'not-established';
43
+
44
+ export interface AmendmentDecision {
45
+ readonly outcome: AmendmentOutcome;
46
+ /** Derived FROM the outcome — one mapping, never two (AM-3, acid case A3/A7). */
47
+ readonly exit: 0 | 1 | 3;
48
+ readonly reasons: readonly string[];
49
+ readonly counts: Readonly<Record<AmendmentVerdict, number>>;
50
+ }
51
+
52
+ /**
53
+ * Template placeholders that reach shipped reports. A stub read as an ordinary unresolvable id lets
54
+ * the author believe they merely mistyped a name, so it earns its own verdict (acid case A1).
55
+ */
56
+ const PLACEHOLDER_IDS = new Set(['test_name', 'test-name', '<test>', '<test_name>', 'tbd', 'todo', 'name']);
57
+
58
+ /**
59
+ * Below this many normalised characters an id is too short to match anything meaningfully: the
60
+ * substring rule ADR-002 accepts would fire on unrelated prose. Guards the degenerate case the ADR
61
+ * names as its known false-positive risk.
62
+ */
63
+ export const MIN_MATCHABLE_ID_LENGTH = 8;
64
+
65
+ /** Case- and separator-folded form. Authors write ids in prose (`a_b_c`); test titles are sentences. */
66
+ /**
67
+ * Every `it()` / `test()` / `describe()` title in a test file. Empty when none parse.
68
+ *
69
+ * Comments are stripped FIRST. A commented-out `it('deny admin writes')` is not a test, and counting
70
+ * it would leave open the very forgery the title basis exists to close — the cross-family reviewer's
71
+ * two-comment-line attack in a slightly better costume. Table forms (`test.each([…])('…')`) carry an
72
+ * argument list between the modifier and the title, so the pattern allows one.
73
+ */
74
+ export function extractTestTitles(body: string): string[] {
75
+ const code = body.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
76
+ const out: string[] = [];
77
+ const re = /\b(?:it|test|describe)(?:\.\w+)*(?:\s*\([^()]{0,200}\))?\s*(?:`[^`]*`)?\s*\(\s*(['"`])([\s\S]{1,300}?)\1/g;
78
+ for (let m = re.exec(code); m !== null; m = re.exec(code)) if (m[2]) out.push(m[2]);
79
+ return out;
80
+ }
81
+
82
+ export function normalizeTestId(s: string): string {
83
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, '');
84
+ }
85
+
86
+ /**
87
+ * Row starts, in BOTH shapes the corpus actually contains: a bullet (`- **AM-1 (…):**`) and a table
88
+ * cell (`| **AM-40** |`). A format LEGEND — the literal `AM-N` with an `N` that is not a digit, as in
89
+ * `features/ha-consilium/03.5_ideation_report.md` — is deliberately NOT a row: counting a legend as
90
+ * an amendment would open this feature by falsely accusing a feature that did nothing wrong.
91
+ */
92
+ const ROW_START = /^(?:[-*]\s+\*{0,2}AM-(\d+)|\|\s*\*{0,2}AM-(\d+))/gm;
93
+
94
+ /** The `## Amendments` section body, or null when the document has none (acid case A5). */
95
+ export function amendmentSection(md: string): string | null {
96
+ const m = /^ {0,3}#{2,4}\s+Amendments\b[^\n]*\n/m.exec(md);
97
+ if (!m) return null;
98
+ const start = m.index + m[0].length;
99
+ const rest = md.slice(start);
100
+ const next = /^ {0,3}#{2,4}\s+\S/m.exec(rest);
101
+ return next ? rest.slice(0, next.index) : rest;
102
+ }
103
+
104
+ /** `## Amendments` present but recording nothing to check — distinct from the section being absent. */
105
+ export function planSaysNoAmendments(planMd: string): boolean {
106
+ const sec = amendmentSection(planMd);
107
+ if (sec === null) return false;
108
+ return /^\s*(none|n\/a|нет)\b/i.test(sec.trim());
109
+ }
110
+
111
+ export function parseAmendments(md: string): AmendmentRow[] {
112
+ const section = amendmentSection(md);
113
+ if (section === null) return [];
114
+ const starts: { index: number; num: string }[] = [];
115
+ ROW_START.lastIndex = 0;
116
+ for (let m = ROW_START.exec(section); m !== null; m = ROW_START.exec(section)) {
117
+ starts.push({ index: m.index, num: (m[1] ?? m[2]) as string });
118
+ }
119
+ const rows: AmendmentRow[] = [];
120
+ for (let i = 0; i < starts.length; i++) {
121
+ const s = starts[i] as { index: number; num: string };
122
+ const end = i + 1 < starts.length ? (starts[i + 1] as { index: number }).index : section.length;
123
+ const raw = section.slice(s.index, end);
124
+ rows.push({ id: `AM-${s.num}`, testIds: extractTestIds(raw), file: extractFile(raw), raw });
125
+ }
126
+ return rows;
127
+ }
128
+
129
+ /** `→ test \`a\`` and the two-id shape `→ tests \`a\` and \`b\`` — both are in the corpus. */
130
+ function extractTestIds(raw: string): string[] {
131
+ const out: string[] = [];
132
+ const re = /→\s*tests?\s+`([^`]+)`(?:\s*(?:and|и)\s*`([^`]+)`)?/g;
133
+ for (let m = re.exec(raw); m !== null; m = re.exec(raw)) {
134
+ if (m[1]) out.push(m[1].trim());
135
+ if (m[2]) out.push(m[2].trim());
136
+ }
137
+ return out;
138
+ }
139
+
140
+ /**
141
+ * The `in \`<path>\`` half, which in real reports frequently opens the line AFTER the id. A pattern
142
+ * that cannot cross a newline finds almost nothing here — measured while writing this: three
143
+ * successive shell-written extractors returned 108, 111 and 13 rows over the identical corpus.
144
+ */
145
+ function extractFile(raw: string): string | null {
146
+ const m = /→\s*tests?\s+`[^`]+`(?:\s*(?:and|и)\s*`[^`]+`)?[\s\S]{0,40}?\bin\s+`([^`]+)`/.exec(raw);
147
+ return m && m[1] ? m[1].trim() : null;
148
+ }
149
+
150
+ export function resolveAmendments(
151
+ rows: readonly AmendmentRow[],
152
+ opts: { readFile: (p: string) => string | null },
153
+ ): AmendmentResolution[] {
154
+ const out: AmendmentResolution[] = [];
155
+ for (const row of rows) {
156
+ if (row.testIds.length === 0) {
157
+ out.push({
158
+ id: row.id,
159
+ testId: null,
160
+ file: row.file,
161
+ verdict: 'unnamed',
162
+ detail: 'the row carries no `→ test` token — an amendment with no pointer is not a passing amendment',
163
+ });
164
+ continue;
165
+ }
166
+ for (const testId of row.testIds) {
167
+ out.push(resolveOne(row, testId, opts.readFile));
168
+ }
169
+ }
170
+ return out;
171
+ }
172
+
173
+ function resolveOne(
174
+ row: AmendmentRow,
175
+ testId: string,
176
+ readFile: (p: string) => string | null,
177
+ ): AmendmentResolution {
178
+ const base = { id: row.id, testId, file: row.file };
179
+ if (PLACEHOLDER_IDS.has(testId.trim().toLowerCase())) {
180
+ return { ...base, verdict: 'placeholder', detail: `\`${testId}\` is a template placeholder, not a test name` };
181
+ }
182
+ if (row.file === null) {
183
+ return { ...base, verdict: 'no-file-named', detail: 'the row names a test id but no file to find it in' };
184
+ }
185
+ const body = readFile(row.file);
186
+ if (body === null) {
187
+ return { ...base, verdict: 'file-missing', detail: `\`${row.file}\` does not exist or cannot be read` };
188
+ }
189
+ const needle = normalizeTestId(testId);
190
+ if (needle.length < MIN_MATCHABLE_ID_LENGTH) {
191
+ return {
192
+ ...base,
193
+ verdict: 'name-absent-in-file',
194
+ detail: `\`${testId}\` normalises to ${needle.length} characters — below the ${MIN_MATCHABLE_ID_LENGTH}-character floor, so a match would prove nothing`,
195
+ };
196
+ }
197
+ // An existing FILE never stands in for an existing TEST (ADR-002) — and neither does an existing
198
+ // COMMENT. Matching the whole file body is forgeable with two comment lines whose letters happen to
199
+ // spell the id, so the basis is the file's TEST TITLES. Falling back to the body when none parse is
200
+ // stated in the detail rather than done quietly: a silent fallback restores the hole it closes.
201
+ const titles = extractTestTitles(body);
202
+ const basis = titles.length > 0 ? titles.map(normalizeTestId).join('\n') : normalizeTestId(body);
203
+ const basisNote = titles.length > 0 ? `${titles.length} test title(s)` : 'the whole file body — NO test titles parsed, so this match is weaker';
204
+ if (!basis.includes(needle)) {
205
+ return {
206
+ ...base,
207
+ verdict: 'name-absent-in-file',
208
+ detail: `\`${row.file}\` exists but no test in it is named \`${testId}\` (searched ${basisNote})`,
209
+ };
210
+ }
211
+ return { ...base, verdict: 'resolved', detail: `found in \`${row.file}\` (searched ${basisNote})` };
212
+ }
213
+
214
+ const ZERO_COUNTS: Record<AmendmentVerdict, number> = {
215
+ resolved: 0,
216
+ placeholder: 0,
217
+ unnamed: 0,
218
+ 'no-file-named': 0,
219
+ 'file-missing': 0,
220
+ 'name-absent-in-file': 0,
221
+ };
222
+
223
+ export function decideAmendmentOutcome(input: {
224
+ sectionPresent: boolean;
225
+ rows: readonly AmendmentRow[];
226
+ resolutions: readonly AmendmentResolution[];
227
+ planSaysNone: boolean;
228
+ readError?: string | null;
229
+ /** Ideation amendments the plan fails to carry — absent, or reworded under the same id. */
230
+ missingFromPlan?: readonly { id: string; kind: 'dropped' | 'subject-changed' }[];
231
+ }): AmendmentDecision {
232
+ const counts: Record<AmendmentVerdict, number> = { ...ZERO_COUNTS };
233
+ for (const r of input.resolutions) counts[r.verdict]++;
234
+ const reasons: string[] = [];
235
+
236
+ // Inputs we could not read are never a verdict about the feature (acid case A7).
237
+ if (input.readError) {
238
+ return { outcome: 'not-established', exit: 3, reasons: [`inputs unreadable: ${input.readError}`], counts };
239
+ }
240
+ // Absence is a skip with a stated reason, never a pass and never a silent zero (acid case A5).
241
+ if (!input.sectionPresent) {
242
+ return {
243
+ outcome: 'skip',
244
+ exit: 0,
245
+ reasons: ['no `## Amendments` section — nothing to check (this is an absence, not a pass)'],
246
+ counts,
247
+ };
248
+ }
249
+ // The whole class this feature removes: a check that silently checked nothing (AM-1, acid case A7).
250
+ if (input.rows.length === 0) {
251
+ return {
252
+ outcome: 'not-established',
253
+ exit: 3,
254
+ reasons: [
255
+ 'the `## Amendments` section is present but ZERO rows parsed — the grammar matched nothing, which is not the same as nothing being wrong',
256
+ ],
257
+ counts,
258
+ };
259
+ }
260
+ // Ideation carries rows while the plan records "None" — this is HIGH-2 itself (acid case A6).
261
+ // Discovered while closing HIGH-2: the pointers belong in the PLAN. Step 6's own instruction is
262
+ // "carry AM-N into 06_implementation_plan.md verbatim", and the ideation report is a historical
263
+ // artifact — editing its rows to match tests that were named later would be rewriting the record
264
+ // rather than closing the trail. So the plan's rows are authoritative when present, and the rule
265
+ // that keeps that honest is coverage: an ideation amendment the plan never mentions is a DROPPED
266
+ // amendment, which is the renegotiating-away failure in a quieter form.
267
+ for (const gap of input.missingFromPlan ?? []) {
268
+ reasons.push(
269
+ gap.kind === 'dropped'
270
+ ? `${gap.id} is an amendment in 03.5_ideation_report.md that 06_implementation_plan.md never carries — an amendment dropped in planning is one nobody can audit`
271
+ : `${gap.id} appears in both documents but the plan describes a DIFFERENT change — "carry verbatim" means the subject survives; only the test pointer may be renamed`,
272
+ );
273
+ }
274
+ if (input.planSaysNone) {
275
+ reasons.push(
276
+ `the ideation report carries ${input.rows.length} amendment row(s) while 06_implementation_plan.md records \`## Amendments: None\` — the amendments were renegotiated away, and an amendment nobody can resolve is one nobody can audit`,
277
+ );
278
+ }
279
+ for (const r of input.resolutions) {
280
+ if (r.verdict !== 'resolved') reasons.push(`${r.id} → ${r.verdict}: ${r.detail}`);
281
+ }
282
+ return { outcome: reasons.length > 0 ? 'fail' : 'pass', exit: reasons.length > 0 ? 1 : 0, reasons, counts };
283
+ }
284
+
285
+ /** The one line every caller reads last, in the K2 gate's own shape so the two read alike. */
286
+ export function amendmentVerdictLine(d: AmendmentDecision): string {
287
+ const label = d.outcome === 'not-established' ? 'NOT-ESTABLISHED' : d.outcome.toUpperCase();
288
+ const head = `amendment traceability: ${label}`;
289
+ const tail =
290
+ d.outcome === 'pass'
291
+ ? `${d.counts.resolved} row(s) resolved`
292
+ : (d.reasons[0] ?? 'no reason recorded');
293
+ return `${head} — ${tail}`;
294
+ }
295
+
296
+ /** Printed on every run: this checker does NOT prove a resolved test discriminates (NG-1, A8). */
297
+ export const AMENDMENT_VACUITY_NOTE =
298
+ 'note: this checks that each amendment RESOLVES to a real test, not that the test is non-vacuous — `dz discrimination-check` owns vacuity.';
299
+
300
+
301
+ /**
302
+ * The amendment's own text with the `→ test …` pointer clause and markdown furniture removed — what
303
+ * "carry AM-N into the plan verbatim" is actually about. The POINTER may legitimately change (tests
304
+ * are named later than ideation guesses); the SUBJECT may not.
305
+ */
306
+ export function amendmentSubject(raw: string): string {
307
+ const withoutPointer = raw.split(/→\s*tests?\s/)[0] ?? '';
308
+ // Strip ONLY the row's furniture: bullet/table marks, the bold id, an optional `(source)` tag and
309
+ // a colon. An earlier version consumed up to 80 characters after the id, which ate the SUBJECT
310
+ // itself whenever a row carried no `(source):` tag — the checker then compared two truncations
311
+ // and called honest rows a mismatch.
312
+ const stripped = withoutPointer.replace(/^[\s|*\-]*\**AM-\d+\**\s*(?:\([^)]{0,80}\))?\s*:?\s*/, '');
313
+ return normalizeTestId(stripped);
314
+ }
315
+
316
+ export interface PlanCoverageGap {
317
+ readonly id: string;
318
+ readonly kind: 'dropped' | 'subject-changed';
319
+ }
320
+
321
+ /**
322
+ * Ideation amendments the plan fails to carry: either absent outright, or present under the same id
323
+ * with a DIFFERENT subject. Cross-family review (Codex gpt-5.6-sol, 2026-08-21) found the second
324
+ * case: comparing ids alone let a plan swap "deny unauthenticated deletes" for "render footer" under
325
+ * the same `AM-1` and still pass.
326
+ */
327
+ export function amendmentsMissingFromPlan(
328
+ ideationRows: readonly AmendmentRow[],
329
+ planRows: readonly AmendmentRow[],
330
+ ): PlanCoverageGap[] {
331
+ const byId = new Map(planRows.map((r) => [r.id, r]));
332
+ const gaps: PlanCoverageGap[] = [];
333
+ for (const row of ideationRows) {
334
+ const planRow = byId.get(row.id);
335
+ if (planRow === undefined) {
336
+ gaps.push({ id: row.id, kind: 'dropped' });
337
+ continue;
338
+ }
339
+ const want = amendmentSubject(row.raw);
340
+ const got = amendmentSubject(planRow.raw);
341
+ // Containment either way: a plan may append a note ("closes HIGH-2"), and ideation may be the
342
+ // longer prose. What it may not do is describe a different change.
343
+ if (want.length >= MIN_MATCHABLE_ID_LENGTH && !got.includes(want) && !want.includes(got)) {
344
+ gaps.push({ id: row.id, kind: 'subject-changed' });
345
+ }
346
+ }
347
+ return gaps;
348
+ }
package/src/guard.ts CHANGED
@@ -73,6 +73,28 @@ export interface GuardFacts {
73
73
  readonly skillPacks?: readonly { readonly name: string; readonly nonRegistrable: readonly string[] }[];
74
74
  /** for readme-first: per publishable package, is a version bump staged without a README change? */
75
75
  readonly readmeFirst?: readonly { readonly name: string; readonly versionBumped: boolean; readonly readmeChanged: boolean }[];
76
+ /**
77
+ * for review-round: per publishable package, does this change bump a version AND touch SOURCE, and
78
+ * did it bring a GRADED QE report with it? `undefined` (the whole fact absent) means the tree could
79
+ * not be read — the rule then reports nothing, which is different from reporting "no review".
80
+ */
81
+ readonly reviewRound?: {
82
+ readonly packages: readonly { readonly name: string; readonly versionBumped: boolean; readonly sourceChanged: boolean }[];
83
+ /** grades parsed out of `features/*∕08_qe_report.md` files in this change set, in file order. */
84
+ readonly grades: readonly { readonly report: string; readonly grade: string }[];
85
+ /**
86
+ * Optional floor from `.dz/guard.json` → `reviewRound.minGrade`. Carried in the FACT because a
87
+ * rule body is a pure function of facts and takes no config — and because the owner reserved the
88
+ * choice of threshold, so the DEFAULT must stay "a grade is present".
89
+ */
90
+ readonly minGrade?: string | undefined;
91
+ /**
92
+ * `false` when the gatherer TRIED and could not read the tree. The note below fires only on
93
+ * that, never on a caller that simply never gathered — otherwise every synthetic evaluation
94
+ * carries a warning about evidence nobody asked for.
95
+ */
96
+ readonly gathered?: boolean | undefined;
97
+ };
76
98
  /**
77
99
  * for agents-md-policy-sync: result of the pure policy drift detector, gathered by the CLI.
78
100
  * `applicable:false` is a repo whose canonical policy sources are unreadable; omission means the
@@ -246,6 +268,7 @@ export const DEFAULT_RULES: readonly GuardRule[] = [
246
268
  // Description ASSEMBLED from STUB_MARKERS so guard.ts itself stays clean under the scan it defines
247
269
  // (structural self-exemption — tested in no-stubs.test.ts).
248
270
  { id: 'no-stubs', severity: 'soft', ops: ['publish'], description: `an unfinished-stub marker (${STUB_MARKERS.join('/')} / "${STUB_PHRASES.join('", "')}") left in a CHANGED file — any unwaived match means the change ships incomplete; waive per line with "no-stubs: <reason>" or per path in .dz/guard.json stubWaivers (reason MANDATORY)` },
271
+ { id: 'review-round', severity: 'hard', ops: ['publish'], description: 'a package publishing CHANGED SOURCE must bring a GRADED features/*/08_qe_report.md in the same change. Scoped to source so a docs-only republish is never blocked; the floor is PRESENCE of a grade unless .dz/guard.json sets reviewRound.minGrade. It proves a graded report EXISTS for this change — NOT that the review was independent, competent, or taken against this exact revision' },
249
272
  { id: 'licence-hold', severity: 'hard', ops: ['publish'], description: 'a pack that declares a licence hold (package.json.licenseHold — ADR-001 hermes-claude-adaptation) must not become publishable until the hold is satisfied: LICENSE present without the PENDING grant placeholder, a Grant-Confirmation URL, non-empty THIRD_PARTY_NOTICES, and a clean SPDX license field' },
250
273
  ];
251
274
 
@@ -337,6 +360,50 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
337
360
  }
338
361
  return out;
339
362
  },
363
+ 'review-round': (f, sev) => {
364
+ // The publish gate had eleven rules and not one asked whether anyone but the author had read the
365
+ // code. MEASURED cost (health-advisor slice H): five rounds graded F, thirteen packages published
366
+ // on the author's own verification, and round six found six defects in ALREADY-PUBLISHED code.
367
+ //
368
+ // Scoped to CHANGED SOURCE on purpose (ADR-001): a HARD rule that also fired on a docs-only
369
+ // republish would be a rule someone switches off. `undefined` facts mean the tree could not be
370
+ // read — silence, not an accusation.
371
+ const rr = f.reviewRound;
372
+ if (rr === undefined) return [];
373
+ const min = typeof rr.minGrade === 'string' ? rr.minGrade.trim().toUpperCase() : undefined;
374
+ // The grade must BE a letter, not merely START with one: keyed on the first character alone,
375
+ // "approved" reads as an A and "broken" as a B (found by cross-family review). The fact gatherer
376
+ // already extracts a bounded letter, so this is defence in depth — and a pure function has no
377
+ // business being looser than its caller.
378
+ const rank = (g: string): number => {
379
+ const t = String(g ?? '').trim().toUpperCase();
380
+ return t.length === 1 ? 'ABCDF'.indexOf(t) : -1;
381
+ };
382
+ const graded = rr.grades.filter((g) => rank(g.grade) >= 0);
383
+ const out: Violation[] = [];
384
+ for (const p of rr.packages) {
385
+ if (!(p.versionBumped === true && p.sourceChanged === true)) continue;
386
+ if (graded.length === 0) {
387
+ out.push({
388
+ rule: 'review-round',
389
+ severity: sev,
390
+ detail: `${p.name}: source changed and the version is bumped, but this change brings no GRADED features/*/08_qe_report.md — a publish gate that cannot tell "reviewed" from "not reviewed" treats them alike. (This proves a graded report EXISTS in this change; it does NOT prove the review was independent, was competent, covered THIS package, or was taken against this revision.)`,
391
+ });
392
+ continue;
393
+ }
394
+ if (min !== undefined && rank(min) >= 0) {
395
+ const best = graded.reduce((a, b) => (rank(a.grade) <= rank(b.grade) ? a : b));
396
+ if (rank(best.grade) > rank(min)) {
397
+ out.push({
398
+ rule: 'review-round',
399
+ severity: sev,
400
+ detail: `${p.name}: the best review grade in this change is ${best.grade.trim()} (${best.report}), below the configured floor ${min} — .dz/guard.json reviewRound.minGrade`,
401
+ });
402
+ }
403
+ }
404
+ }
405
+ return out;
406
+ },
340
407
  'agents-md-policy-sync': (f, _sev) => {
341
408
  const fact = f.policyDrift;
342
409
  if (!fact || fact.applicable !== true || !Array.isArray(fact.drifted)) return [];
@@ -557,6 +624,13 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
557
624
  notes.push(`no-stubs: ${skipped} changed scannable file(s) not scanned (deleted/oversize/unreadable/beyond the file cap) — the stub scan is fail-open, so this is a coverage gap on the record, not a violation`);
558
625
  }
559
626
  }
627
+ if (checked.includes('review-round') && facts.reviewRound?.gathered === false) {
628
+ // A HARD gate that passes SILENTLY when it could not gather its evidence is a gate you cannot
629
+ // tell from one that checked and approved (raised by cross-family review). It still does not
630
+ // BLOCK — absence of facts is ignorance, not an accusation, and blocking every non-git checkout
631
+ // would make the rule unusable — but the ignorance goes on the record.
632
+ notes.push('review-round: the working-tree change could not be read, so NO review evidence was gathered — this run neither confirms nor denies that the code was reviewed');
633
+ }
560
634
  if (checked.includes('agents-md-policy-sync')) {
561
635
  // A repo that never opted in (no `dz:policies` fence in AGENTS.md) is OUT OF SCOPE, not
562
636
  // inconclusive — noting it on every run would put a permanent line in a channel that exists to
package/src/index.ts CHANGED
@@ -78,7 +78,7 @@ export type { SyncUpstreamReport, UpstreamCheckResult, SourcesManifest, SourcePa
78
78
  export { sweepSkillDrift, syncCanonicalSkill } from './skill-drift.js';
79
79
  export type { SweepResult, DriftedSkill, SyncResult, SyncCanonicalOptions } from './skill-drift.js';
80
80
  export { benchmarkSkill, benchmarkSkills, compareSkills } from './benchmark.js';
81
- export { buildRegistry, searchRegistry, filterByCategory, skillPackBaseDirs, discoverSkillPackDirs } from './registry.js';
81
+ export { buildRegistry, searchRegistry, filterByCategory, skillPackBaseDirs, discoverSkillPackDirs, discoverVerifiablePackDirs } from './registry.js';
82
82
  // Package skill-layout resolution (feature dz-install-npx-init) — the ONE seam that knows where an
83
83
  // npm package keeps its skills (flat / templates/.claude/skills / skills). `cmdInstall` calls it;
84
84
  // `dz init`/`dz registry` are the filed follow-up consumers.
@@ -240,6 +240,32 @@ export {
240
240
  export type { CheckpointStage, CheckpointEntry, ResumeMode, ResumeDecision, ParsedCheckpointRead, TrainingPairFamily, TrainingPair, TrainingPairEvaluation, TrainingPairProvenance, TrainingPairTruncation,
241
241
  CheckpointWriteVerdict,
242
242
  } from './feature-adr-checkpoints.js';
243
+
244
+ // amendment-traceability (ADR-001/002/003): the deterministic half of the Step-8 amendment gate.
245
+ export {
246
+ MIN_MATCHABLE_ID_LENGTH,
247
+ AMENDMENT_VACUITY_NOTE,
248
+ normalizeTestId,
249
+ amendmentSection,
250
+ planSaysNoAmendments,
251
+ parseAmendments,
252
+ resolveAmendments,
253
+ decideAmendmentOutcome,
254
+ amendmentVerdictLine,
255
+ amendmentsMissingFromPlan,
256
+ amendmentSubject,
257
+ extractTestTitles,
258
+ } from './amendment-trace.js';
259
+ export {
260
+ RECORD_MAX_LINE_CHARS,
261
+ decideRecordWrite,
262
+ decideReadBack,
263
+ recordVerdictLine,
264
+ } from './run-records.js';
265
+ export { decidePublishSigning, decidePostSigningVerification, decideSignableSet, publishSigningLine, signableSetLine } from './publish-signing.js';
266
+ export type { PublishSigningVerdict, PublishSigningDecision, SignableSetDecision } from './publish-signing.js';
267
+ export type { RecordKind, RecordVerdict, RecordDecision } from './run-records.js';
268
+ export type { AmendmentRow, AmendmentVerdict, AmendmentResolution, AmendmentOutcome, AmendmentDecision, PlanCoverageGap } from './amendment-trace.js';
243
269
  export {
244
270
  DOMAIN_LIFT_EXACT,
245
271
  DOMAIN_LIFT_RELATED,
package/src/patterns.ts CHANGED
@@ -287,7 +287,10 @@ export function computePatternBoost(
287
287
  matched = true;
288
288
  } else {
289
289
  // medium: a meaningful word from the pattern appears in the skill haystack
290
- for (const word of text.split(/[^a-z0-9]+/)) {
290
+ // `\p{L}\p{N}`, not `a-z0-9`: the ASCII-only class made every non-Latin word invisible to
291
+ // the boost, so a Cyrillic pattern could never match a skill haystack. The `>= 5` rule below
292
+ // is this site's OWN threshold and is deliberately unchanged — only the alphabet moved.
293
+ for (const word of text.split(/[^\p{L}\p{N}]+/u)) {
291
294
  if (word.length >= 5 && haystack.includes(word)) {
292
295
  matched = true;
293
296
  break;