@dzhechkov/harness-core 0.8.38 → 0.8.40
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 +281 -161
- package/README.md +145 -2
- package/dist/agentdb-index.d.ts +3 -3
- package/dist/agentdb-index.js +5 -5
- package/dist/agentdb-index.js.map +1 -1
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +4 -1
- package/dist/amendment-trace.js.map +1 -1
- package/dist/backlog.d.ts +2 -1
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +3 -2
- package/dist/backlog.js.map +1 -1
- package/dist/brain.d.ts.map +1 -1
- package/dist/brain.js +9 -2
- package/dist/brain.js.map +1 -1
- package/dist/bto-optimize.d.ts.map +1 -1
- package/dist/bto-optimize.js +9 -12
- package/dist/bto-optimize.js.map +1 -1
- package/dist/claim-check.d.ts.map +1 -1
- package/dist/claim-check.js +50 -14
- package/dist/claim-check.js.map +1 -1
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +48 -2
- package/dist/cmd-usage.js.map +1 -1
- package/dist/compounding.d.ts +66 -0
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +76 -9
- package/dist/compounding.js.map +1 -1
- package/dist/doctor-instrument.d.ts +65 -0
- package/dist/doctor-instrument.d.ts.map +1 -0
- package/dist/doctor-instrument.js +91 -0
- package/dist/doctor-instrument.js.map +1 -0
- package/dist/experiment-assign.d.ts +110 -0
- package/dist/experiment-assign.d.ts.map +1 -0
- package/dist/experiment-assign.js +229 -0
- package/dist/experiment-assign.js.map +1 -0
- package/dist/feature-adr-checkpoints.d.ts +7 -2
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +20 -5
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-envelope.d.ts +12 -0
- package/dist/feature-adr-envelope.d.ts.map +1 -1
- package/dist/feature-adr-envelope.js +12 -1
- package/dist/feature-adr-envelope.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +4 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +4 -0
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/feature-tier.d.ts.map +1 -1
- package/dist/feature-tier.js +13 -1
- package/dist/feature-tier.js.map +1 -1
- package/dist/guard.d.ts +17 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +15 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +14 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger-cost-fill.d.ts +58 -0
- package/dist/ledger-cost-fill.d.ts.map +1 -0
- package/dist/ledger-cost-fill.js +78 -0
- package/dist/ledger-cost-fill.js.map +1 -0
- package/dist/loop-blobs.generated.js +2 -2
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/mutation-gate.d.ts +59 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +65 -1
- package/dist/mutation-gate.js.map +1 -1
- package/dist/name-check.d.ts +20 -1
- package/dist/name-check.d.ts.map +1 -1
- package/dist/name-check.js +42 -1
- package/dist/name-check.js.map +1 -1
- package/dist/no-stubs.d.ts +10 -0
- package/dist/no-stubs.d.ts.map +1 -1
- package/dist/no-stubs.js +13 -9
- package/dist/no-stubs.js.map +1 -1
- package/dist/operations.d.ts +13 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +88 -2
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts +24 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +49 -9
- package/dist/patterns.js.map +1 -1
- package/dist/publish-source-scope.d.ts +32 -0
- package/dist/publish-source-scope.d.ts.map +1 -0
- package/dist/publish-source-scope.js +53 -0
- package/dist/publish-source-scope.js.map +1 -0
- package/dist/publish.d.ts +5 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +32 -21
- package/dist/publish.js.map +1 -1
- package/dist/qe-bridge.d.ts +4 -1
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +21 -11
- package/dist/qe-bridge.js.map +1 -1
- package/dist/rake-analyzer.d.ts +10 -3
- package/dist/rake-analyzer.d.ts.map +1 -1
- package/dist/rake-analyzer.js +84 -22
- package/dist/rake-analyzer.js.map +1 -1
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +8 -4
- package/dist/recap.js.map +1 -1
- package/dist/registry.d.ts +58 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +62 -5
- package/dist/registry.js.map +1 -1
- package/dist/release.d.ts +20 -1
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +48 -4
- package/dist/release.js.map +1 -1
- package/dist/reqe.d.ts.map +1 -1
- package/dist/reqe.js +11 -1
- package/dist/reqe.js.map +1 -1
- package/dist/round-exec.d.ts +10 -0
- package/dist/round-exec.d.ts.map +1 -1
- package/dist/round-exec.js +3 -2
- package/dist/round-exec.js.map +1 -1
- package/dist/round.d.ts +19 -0
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +1 -0
- package/dist/round.js.map +1 -1
- package/dist/run-records.d.ts +18 -4
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +61 -7
- package/dist/run-records.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +8 -1
- package/dist/score.js.map +1 -1
- package/dist/sign.d.ts +23 -0
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +52 -0
- package/dist/sign.js.map +1 -1
- package/dist/skills-verify.d.ts +8 -5
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +59 -7
- package/dist/skills-verify.js.map +1 -1
- package/dist/store-guard-prune.d.ts +22 -0
- package/dist/store-guard-prune.d.ts.map +1 -0
- package/dist/store-guard-prune.js +54 -0
- package/dist/store-guard-prune.js.map +1 -0
- package/dist/sweep-failure-classify.d.ts +45 -0
- package/dist/sweep-failure-classify.d.ts.map +1 -0
- package/dist/sweep-failure-classify.js +90 -0
- package/dist/sweep-failure-classify.js.map +1 -0
- package/dist/workflow-run-dispatch.d.ts +14 -2
- package/dist/workflow-run-dispatch.d.ts.map +1 -1
- package/dist/workflow-run-dispatch.js +14 -2
- package/dist/workflow-run-dispatch.js.map +1 -1
- package/package.json +1 -1
- package/sbom.json +460 -160
- package/src/agentdb-index.ts +5 -5
- package/src/amendment-trace.ts +4 -1
- package/src/backlog.ts +3 -2
- package/src/brain.ts +8 -2
- package/src/bto-optimize.ts +10 -8
- package/src/claim-check.ts +48 -12
- package/src/cmd-usage.ts +52 -2
- package/src/compounding.ts +112 -9
- package/src/doctor-instrument.ts +153 -0
- package/src/experiment-assign.ts +276 -0
- package/src/feature-adr-checkpoints.ts +20 -5
- package/src/feature-adr-envelope.ts +24 -1
- package/src/feature-adr-routing.ts +4 -0
- package/src/feature-tier.ts +14 -1
- package/src/guard.ts +24 -0
- package/src/index.ts +22 -5
- package/src/loop-blobs.generated.ts +2 -2
- package/src/mutation-gate.ts +113 -1
- package/src/name-check.ts +43 -1
- package/src/no-stubs.ts +14 -5
- package/src/operations.ts +85 -3
- package/src/patterns.ts +49 -8
- package/src/publish-source-scope.ts +53 -0
- package/src/publish.ts +38 -16
- package/src/qe-bridge.ts +22 -11
- package/src/rake-analyzer.ts +75 -20
- package/src/rake-signatures.json +98 -0
- package/src/recap.ts +8 -4
- package/src/registry.ts +91 -1
- package/src/release.ts +54 -4
- package/src/reqe.ts +11 -1
- package/src/round-exec.ts +13 -2
- package/src/round.ts +20 -0
- package/src/run-records.ts +78 -10
- package/src/score.ts +8 -1
- package/src/sign.ts +53 -0
- package/src/skills-verify.ts +69 -9
- package/src/store-guard-prune.ts +81 -0
- package/src/sweep-failure-classify.ts +88 -0
- package/src/workflow-run-dispatch.ts +14 -2
package/src/qe-bridge.ts
CHANGED
|
@@ -50,7 +50,10 @@ export const CLAUDE_BRIDGE_PROMPT_CEILING_CHARS = 200_000;
|
|
|
50
50
|
*/
|
|
51
51
|
export const KNOWN_CLAUDE: Record<string, 1> = { opus: 1, sonnet: 1, haiku: 1 };
|
|
52
52
|
|
|
53
|
-
/** The terminal verdict grammar: `QE-BRIDGE-SIGNOFF grade=<A-F
|
|
53
|
+
/** The terminal verdict grammar: `QE-BRIDGE-SIGNOFF grade=<A-F, optionally + or -> findings=<n>`.
|
|
54
|
+
* bridge-grade-grammar (ddf83072): a live Sonnet review wrote `grade=B-` in all three channels on
|
|
55
|
+
* 2026-09-17 06:52 and the whole 145-second review was discarded as no-grade-marker, while every
|
|
56
|
+
* other consumer (round close, score, recap) already accepted modifiers. */
|
|
54
57
|
export const BRIDGE_MARKER = 'QE-BRIDGE-SIGNOFF';
|
|
55
58
|
|
|
56
59
|
/** The fenced block's info string. */
|
|
@@ -345,7 +348,7 @@ export function defangSignoffEchoes(text: string): string {
|
|
|
345
348
|
out = out.replace(/qe-bridge-signoff/gi, '[quoted-fence-label]');
|
|
346
349
|
out = out.split(BRIDGE_EXTRACT_END).join('[quoted-boundary]');
|
|
347
350
|
// Line-anchored verdict lines only — prose such as "upgrade the grade later" is untouched.
|
|
348
|
-
out = out.replace(/^([ \t>*~-]*)(?:\*{0,2}#{0,4}[ \t]*)?GRADE[ \t]*[:=—–-]?[ \t]*([A-F]
|
|
351
|
+
out = out.replace(/^([ \t>*~-]*)(?:\*{0,2}#{0,4}[ \t]*)?GRADE[ \t]*[:=—–-]?[ \t]*([A-F](?:[+-](?![ \t]*[A-F]\b))?)(?![A-Za-z])/gim, '$1[quoted-grade] $2');
|
|
349
352
|
return out;
|
|
350
353
|
}
|
|
351
354
|
|
|
@@ -410,13 +413,14 @@ export function buildBridgePrompt(
|
|
|
410
413
|
'happen in this environment.',
|
|
411
414
|
'',
|
|
412
415
|
'Output format — ALL THREE parts are required, in this order, at the END of your answer:',
|
|
413
|
-
' (a) your review prose, containing exactly ONE line that reads: GRADE:
|
|
416
|
+
' (a) your review prose, containing exactly ONE line that reads: GRADE: ' + GRADE_PLACEHOLDER,
|
|
414
417
|
' (b) a fenced code block labelled ' + BRIDGE_FENCE_LABEL + ' whose body is JSON:',
|
|
415
|
-
' {"grade":"
|
|
418
|
+
' {"grade":"' + GRADE_PLACEHOLDER + '","findings":[{"n":1,"severity":"major","title":"…","file":"path","line":12}]}',
|
|
416
419
|
' A genuinely clean review writes "findings": [] — but it must WRITE it.',
|
|
417
|
-
' (c) a final line, on its own: ' + BRIDGE_MARKER + ' grade
|
|
418
|
-
'The grade in all three places must be the SAME
|
|
419
|
-
'
|
|
420
|
+
' (c) a final line, on its own: ' + BRIDGE_MARKER + ' grade=' + GRADE_PLACEHOLDER + ' findings=<n>',
|
|
421
|
+
'The grade in all three places must be the SAME grade, character for character — one capital',
|
|
422
|
+
'letter A, B, C, D, E or F, optionally followed by + or -.',
|
|
423
|
+
'A modifier is fine (B+, B, B-) and is carried through verbatim; what breaks the parse is a RANGE (A-F), prose in place of the letter, or two channels disagreeing.',
|
|
420
424
|
'Anything else is discarded as a failed call — an unparseable answer is treated as no review at',
|
|
421
425
|
'all, never as a passing one.',
|
|
422
426
|
'',
|
|
@@ -447,7 +451,14 @@ export function buildBridgePrompt(
|
|
|
447
451
|
|
|
448
452
|
/** Line-anchored marker. Leading quote/list decoration is allowed on purpose: a planted marker
|
|
449
453
|
* SHOULD match the grammar — and then lose to the LAST one. Anchoring is the defence, not evasion. */
|
|
450
|
-
|
|
454
|
+
/** The grade placeholder the reviewer prompt shows in ALL THREE output slots. It lives in one
|
|
455
|
+
* constant because round-2 review (2026-09-18, HIGH #1 partial) found the opposite: the marker slot
|
|
456
|
+
* invited a modifier while the prose and JSON slots still read `<A-F>` — which is also the exact
|
|
457
|
+
* spelling of the RANGE the parser refuses, so the template taught the reviewer to write the one
|
|
458
|
+
* thing that breaks the parse. One spelling, three slots, no drift. */
|
|
459
|
+
const GRADE_PLACEHOLDER = '<A-F, optionally with + or ->';
|
|
460
|
+
|
|
461
|
+
const MARKER_LINE = /^[ \t>*~-]*QE-BRIDGE-SIGNOFF[ \t]+grade=([A-F][+-]?)[ \t]+findings=(\d{1,6})[ \t]*$/gim;
|
|
451
462
|
|
|
452
463
|
/** Fenced `qe-bridge-signoff` block. */
|
|
453
464
|
const SIGNOFF_FENCE = /^[ \t]*(?:`{3,}|~{3,})[ \t]*qe-bridge-signoff[ \t]*\r?\n([\s\S]*?)^[ \t]*(?:`{3,}|~{3,})[ \t]*$/gim;
|
|
@@ -538,7 +549,7 @@ export function parseBridgeOutput(
|
|
|
538
549
|
// channel 1 — the LAST terminal marker line, which must be the FINAL content of the answer
|
|
539
550
|
const marker = lastMatch(MARKER_LINE, text);
|
|
540
551
|
if (marker === null) {
|
|
541
|
-
return fail('no-grade-marker', 'no `' + BRIDGE_MARKER + ' grade=<A-F
|
|
552
|
+
return fail('no-grade-marker', 'no `' + BRIDGE_MARKER + ' grade=<A-F, optionally with + or -> findings=<n>` line anywhere in ' + text.length + ' chars of reviewer output — text without a verdict marker is not a verdict');
|
|
542
553
|
}
|
|
543
554
|
const markerGrade = String(marker[1]).toUpperCase();
|
|
544
555
|
const markerCount = Number(marker[2]);
|
|
@@ -576,8 +587,8 @@ export function parseBridgeOutput(
|
|
|
576
587
|
}
|
|
577
588
|
const obj = parsedJson as Record<string, unknown>;
|
|
578
589
|
const jsonGradeRaw = obj['grade'];
|
|
579
|
-
if (typeof jsonGradeRaw !== 'string' || !/^[A-Fa-f]
|
|
580
|
-
return fail('no-signoff-json', 'the fenced JSON block names no `grade` letter A-F (saw ' + JSON.stringify(jsonGradeRaw) + ')');
|
|
590
|
+
if (typeof jsonGradeRaw !== 'string' || !/^[A-Fa-f][+-]?$/.test(jsonGradeRaw.trim())) {
|
|
591
|
+
return fail('no-signoff-json', 'the fenced JSON block names no `grade` letter A-F (optionally with + or -) (saw ' + JSON.stringify(jsonGradeRaw) + ')');
|
|
581
592
|
}
|
|
582
593
|
const jsonGrade = jsonGradeRaw.trim().toUpperCase();
|
|
583
594
|
const validated = validateFindings(obj['findings']);
|
package/src/rake-analyzer.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'node:fs';
|
|
18
|
+
import { maskMarkdown } from './markdown-masker.js';
|
|
18
19
|
import { join } from 'node:path';
|
|
19
20
|
|
|
20
21
|
export type Severity = 'blocker' | 'high' | 'medium' | 'low' | 'unknown';
|
|
@@ -47,28 +48,67 @@ export interface RakeReport {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export interface RakeSignature { readonly id: string; readonly label: string; readonly patterns: readonly RegExp[] }
|
|
51
|
+
export interface RakeSignatureSpec { readonly id: string; readonly label: string; readonly patterns: readonly string[]; readonly flags?: string }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Decode the data-only signature table. This fails closed: stateful `g`/`y` patterns would make
|
|
55
|
+
* RegExp.test() depend on lastIndex, which breaks signatureOf's determinism.
|
|
56
|
+
*/
|
|
57
|
+
export function loadRakeSignatures(raw: unknown): readonly RakeSignature[] {
|
|
58
|
+
if (!Array.isArray(raw)) throw new Error('Rake signatures: expected an array');
|
|
59
|
+
const signatures = raw.map((entry, index): RakeSignature => {
|
|
60
|
+
const name = typeof entry === 'object' && entry !== null && typeof (entry as { id?: unknown }).id === 'string'
|
|
61
|
+
? (entry as { id: string }).id
|
|
62
|
+
: `entry #${index}`;
|
|
63
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) throw new Error(`Rake signature ${name}: expected an object`);
|
|
64
|
+
const spec = entry as Partial<RakeSignatureSpec>;
|
|
65
|
+
if (typeof spec.id !== 'string' || spec.id === '') throw new Error(`Rake signature ${name}: missing id`);
|
|
66
|
+
if (typeof spec.label !== 'string' || spec.label === '') throw new Error(`Rake signature ${name}: missing label`);
|
|
67
|
+
if (!Array.isArray(spec.patterns) || spec.patterns.length === 0 || spec.patterns.some((p) => typeof p !== 'string' || p === '')) {
|
|
68
|
+
throw new Error(`Rake signature ${name}: patterns must be a non-empty string array`);
|
|
69
|
+
}
|
|
70
|
+
const flags = spec.flags ?? '';
|
|
71
|
+
if (typeof flags !== 'string') throw new Error(`Rake signature ${name}: flags must be a string`);
|
|
72
|
+
if (/[gy]/.test(flags)) throw new Error(`Rake signature ${name}: stateful g/y flags are forbidden`);
|
|
73
|
+
let patterns: RegExp[];
|
|
74
|
+
try { patterns = spec.patterns.map((pattern) => new RegExp(pattern, flags)); }
|
|
75
|
+
catch (error) { throw new Error(`Rake signature ${name}: invalid regular expression (${error instanceof Error ? error.message : String(error)})`); }
|
|
76
|
+
for (const pattern of patterns) Object.freeze(pattern);
|
|
77
|
+
return Object.freeze({ id: spec.id, label: spec.label, patterns: Object.freeze(patterns) });
|
|
78
|
+
});
|
|
79
|
+
return Object.freeze(signatures);
|
|
80
|
+
}
|
|
50
81
|
|
|
51
82
|
/**
|
|
52
83
|
* Known rake classes (extensible, data-only). Seeded from the classes that actually recur in this repo's
|
|
53
84
|
* QE reports — that IS the dogfood. First match in order wins; unmatched → normalized-text bucket.
|
|
54
85
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Таблица живёт В ПАКЕТЕ, а не в `docs/`, и путь записан ровно как у близнеца
|
|
88
|
+
* `slop-lint.ts` → `../src/slop-markers.json`: из собранного `dist/` он указывает в `src/`,
|
|
89
|
+
* который входит в `files[]` и потому ПУБЛИКУЕТСЯ. Запись бэклога предлагала
|
|
90
|
+
* `docs/methodology/`, но чтение стоит на верхнем уровне модуля — в опубликованном пакете
|
|
91
|
+
* такого пути нет, и модуль упал бы ПРИ ИМПОРТЕ, утащив за собой каждого потребителя.
|
|
92
|
+
* Человекочитаемое описание схемы осталось в `docs/methodology/rake-signatures.md`.
|
|
93
|
+
*/
|
|
94
|
+
const RAKE_SIGNATURES_FILE = new URL('../src/rake-signatures.json', import.meta.url);
|
|
95
|
+
export const RAKE_SIGNATURES = loadRakeSignatures(readRakeSignaturesFile(RAKE_SIGNATURES_FILE));
|
|
96
|
+
|
|
97
|
+
/** Отдельный читатель, чтобы отсутствие или порча файла назывались СВОИМИ ИМЕНАМИ, а не
|
|
98
|
+
* прилетали сырым ENOENT/SyntaxError из середины загрузки модуля. */
|
|
99
|
+
function readRakeSignaturesFile(url: URL): unknown {
|
|
100
|
+
let raw: string;
|
|
101
|
+
try {
|
|
102
|
+
raw = readFileSync(url, 'utf8');
|
|
103
|
+
} catch (err) {
|
|
104
|
+
throw new Error(`rake-signatures: таблица не найдена по пути ${url.pathname} — пакет собран без src/? (${err instanceof Error ? err.message : String(err)})`);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(raw);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
throw new Error(`rake-signatures: ${url.pathname} не разбирается как JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
72
112
|
|
|
73
113
|
const byStr = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
74
114
|
const uniqSorted = (xs: readonly string[]): string[] => [...new Set(xs)].sort(byStr);
|
|
@@ -97,8 +137,8 @@ const toSeverity = (raw: string): Severity => SEV_MAP[raw.trim().toLowerCase()]
|
|
|
97
137
|
const SITE_RE = /([\w./-]+\.(?:ts|js|tsx|jsx|py|go|md|json|yml|yaml):\d+)/;
|
|
98
138
|
|
|
99
139
|
/** The signature of a finding: first matching rule, else the normalized-text bucket. */
|
|
100
|
-
export function signatureOf(finding: Finding): { id: string; label: string } {
|
|
101
|
-
for (const s of
|
|
140
|
+
export function signatureOf(finding: Finding, signatures: readonly RakeSignature[] = RAKE_SIGNATURES): { id: string; label: string } {
|
|
141
|
+
for (const s of signatures) {
|
|
102
142
|
if (s.patterns.some((p) => p.test(finding.text))) return { id: s.id, label: s.label };
|
|
103
143
|
}
|
|
104
144
|
const key = normalizeText(finding.text);
|
|
@@ -115,13 +155,28 @@ export function signatureOf(finding: Finding): { id: string; label: string } {
|
|
|
115
155
|
*/
|
|
116
156
|
export function extractFindings(markdown: string, source: string): Finding[] {
|
|
117
157
|
const out: Finding[] = [];
|
|
158
|
+
// Строки ВНУТРИ блока кода находками не являются: отчёт QE регулярно показывает ПРИМЕР того,
|
|
159
|
+
// как писать не надо, и пример оформлен той же таблицей. ИЗМЕРЕНО 2026-09-19: отчёт с одной
|
|
160
|
+
// настоящей находкой и одной строкой-примером в ```-блоке давал ДВЕ находки.
|
|
161
|
+
// Маскировщик здесь ОБЩИЙ (`markdown-masker`), тот же, что у `amendment-trace` и у гейта K2 —
|
|
162
|
+
// запись 8b309a04 просила один проход на весь харнесс, а не третий собственный разбор.
|
|
163
|
+
// Решение «считать ли строку» принимается по МАСКЕ, а текст находки берётся из ИСХОДНИКА:
|
|
164
|
+
// маска сохраняет разбиение на строки, но не содержимое.
|
|
165
|
+
// ОДИН механизм: читаем МАСКУ. Маскировщик заменяет строки внутри блока кода филлером той же
|
|
166
|
+
// длины и не трогает остальные, поэтому отдельный список «пропустить замаскированные» был бы
|
|
167
|
+
// ВТОРОЙ защитой — и мутация, снимающая только её, осталась бы эквивалентной (проверено:
|
|
168
|
+
// 35 тестов из 35 зелёные при «снятой» починке). Ровно тот же капкан, что сегодня в гейте K2.
|
|
169
|
+
const maskLines = String(maskMarkdown(markdown)).split('\n');
|
|
118
170
|
const push = (severity: Severity, text: string): void => {
|
|
119
171
|
const t = text.replace(/\s+/g, ' ').trim();
|
|
120
172
|
if (t.length < 8) return; // too short to be a finding
|
|
121
173
|
const site = SITE_RE.exec(t)?.[1];
|
|
122
174
|
out.push(site ? { source, severity, text: t, site } : { source, severity, text: t });
|
|
123
175
|
};
|
|
124
|
-
|
|
176
|
+
const sourceLines = markdown.split('\n');
|
|
177
|
+
for (let i = 0; i < sourceLines.length; i++) {
|
|
178
|
+
// Читаем маску, а не исходник: строка, спрятанная блоком кода, до правил ниже не доходит.
|
|
179
|
+
const line = maskLines[i] ?? sourceLines[i] ?? '';
|
|
125
180
|
// (a) table row: | ... | <sev> | <finding> | ...
|
|
126
181
|
const cells = line.includes('|') ? line.split('|').map((c) => c.trim()) : null;
|
|
127
182
|
if (cells && cells.length >= 4) {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "esm-require-footgun",
|
|
4
|
+
"label": "ESM lazy require() undefined at runtime",
|
|
5
|
+
"patterns": ["require\\(['\\\"]node:", "\\besm\\b.*require", "lazy require"],
|
|
6
|
+
"flags": "i"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "untested-adr-property",
|
|
10
|
+
"label": "ADR-named safety property left untested",
|
|
11
|
+
"patterns": ["load-bearing.*(untested|not\\s+tested|no\\s+test)", "adr.*names.*(property|test)", "safety property.*test"],
|
|
12
|
+
"flags": "i"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "claim-check-fp",
|
|
16
|
+
"label": "claim-check false positive / untagged count",
|
|
17
|
+
"patterns": ["claim-check.*(false positive|\\bfp\\b)", "untagged.*(count|claim)", "metric term"],
|
|
18
|
+
"flags": "i"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "path-traversal",
|
|
22
|
+
"label": "path not constrained to the repo (traversal)",
|
|
23
|
+
"patterns": ["\\.\\./", "(?=.*travers)(?=.*(repo|root|\\bpath\\b|director|/etc/))", "escapes.{0,12}repo"],
|
|
24
|
+
"flags": "i"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "silent-drop-or-inject",
|
|
28
|
+
"label": "silent drop / silent injection (no report)",
|
|
29
|
+
"patterns": ["silent(ly)?\\s+(drop|inject|discard|dropped)", "no silent (injection|caps|drop)"],
|
|
30
|
+
"flags": "i"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "swallow-generic-exception",
|
|
34
|
+
"label": "generic except/catch swallows real bugs",
|
|
35
|
+
"patterns": ["except\\s+Exception", "catch.*swallow", "generic (exception|catch)"],
|
|
36
|
+
"flags": "i"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "determinism-hole",
|
|
40
|
+
"label": "non-deterministic output (unsorted/clock/random)",
|
|
41
|
+
"patterns": ["non-determinis", "determinism hole", "unsorted|not sorted"],
|
|
42
|
+
"flags": "i"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "cross-model-self-qe",
|
|
46
|
+
"label": "coder self-QE instead of cross-model",
|
|
47
|
+
"patterns": ["self-qe", "coder.*(review|qe).*(itself|self)", "cross-model", "same-family.{0,40}(qe|review)", "cross-family.{0,60}(not obtained|did not happen|fell back)"],
|
|
48
|
+
"flags": "i"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "malformed-input-bypass",
|
|
52
|
+
"label": "malformed input bypasses validation",
|
|
53
|
+
"patterns": ["array.*(pass|bypass)", "malformed.*(bypass|pass|manifest)", "typeof.*object"],
|
|
54
|
+
"flags": "i"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "wiring-tail",
|
|
58
|
+
"label": "implementation wiring tail left unfinished",
|
|
59
|
+
"patterns": ["wiring[-\\s]tail", "wiring-хвост", "незавершённая проводка"],
|
|
60
|
+
"flags": "i"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "canonical-copy-edit",
|
|
64
|
+
"label": "edit made in a canonical copy",
|
|
65
|
+
"patterns": ["canonical\\s+copy", "каноническ\\w*\\s+коп", "edited in the\\s+\\*\\*canonical\\*\\*\\s+copy"],
|
|
66
|
+
"flags": "i"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"id": "zero-or-partial-landing",
|
|
70
|
+
"label": "zero or partial implementation landing",
|
|
71
|
+
"patterns": ["zero\\s+code\\s+landed", "genuinely-not-landed", "step\\s*7.{0,50}did not land"],
|
|
72
|
+
"flags": "i"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "corpus-preimage-not-repinned",
|
|
76
|
+
"label": "corpus preimage was not re-pinned",
|
|
77
|
+
"patterns": ["preimage.{0,80}(never|not).{0,30}re-?pinned", "(?:registry|corpus).{0,40}(?:find|anchor).{0,80}(never|not).{0,30}re-?pinned", "find.{0,10}string was never re-pinned"],
|
|
78
|
+
"flags": "i"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "verdict-from-proxy",
|
|
82
|
+
"label": "verdict inferred from a proxy instead of the subject",
|
|
83
|
+
"patterns": ["(?:verdict|decision|grade).{0,60}proxy", "proxy.{0,60}(?:verdict|decision|grade|property)", "asserted a\\s+\\*\\*?proxy\\*\\*?.{0,80}not the\\s+\\*\\*?property"],
|
|
84
|
+
"flags": "i"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"id": "single-language-book-silence",
|
|
88
|
+
"label": "book result silently omitted for a single-language query",
|
|
89
|
+
"patterns": ["books are silent", "книги молчат", "book.{0,50}(silent|silence)"],
|
|
90
|
+
"flags": "i"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"id": "reserved-heading-collision",
|
|
94
|
+
"label": "reserved heading collision",
|
|
95
|
+
"patterns": ["(?:name|heading).{0,12}collision.{0,60}reused.{0,8}#{1,6}\\s", "reused.{0,8}#{1,6}\\s+scenario coverage", "заголов.{0,50}(?:collision|столкнов)"],
|
|
96
|
+
"flags": "i"
|
|
97
|
+
}
|
|
98
|
+
]
|
package/src/recap.ts
CHANGED
|
@@ -351,16 +351,20 @@ export function buildRecap(facts: RecapFacts): RecapReport {
|
|
|
351
351
|
const covered = coveredWindow(v, facts.window);
|
|
352
352
|
const items = (facts.deliveries?.items ?? []).map(normaliseDelivery).filter((d) => withinWindow(covered, d.createdIso));
|
|
353
353
|
const graded = items.filter((d): d is Extract<Delivery, { gradeStatus: 'unique' }> => d.gradeStatus === 'unique');
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
354
|
+
// recap-invalid-bucket (backlog 3bd73eec): the two were printed as ONE number, so a reader could
|
|
355
|
+
// not tell "the report states two grades" from "the report's verdict line is malformed" — and the
|
|
356
|
+
// second is a FIXABLE authoring defect in our own artifact, while the first is a real
|
|
357
|
+
// disagreement between reviews. `dz score --slug` already separated them; recap now does too.
|
|
358
|
+
const ambiguous = items.filter((d) => d.gradeStatus === 'ambiguous');
|
|
359
|
+
const invalid = items.filter((d) => d.gradeStatus === 'invalid');
|
|
357
360
|
const ungraded = items.filter((d) => d.gradeStatus === 'none' || d.gradeStatus === 'no-report');
|
|
358
361
|
const lines = sectionLines(v, facts.window, (scope) => items.length === 0
|
|
359
362
|
? emptyOrUnavailable(v, `no feature directories were created in ${scope}`)
|
|
360
363
|
: [
|
|
361
364
|
`${items.length} feature director${items.length === 1 ? 'y' : 'ies'} created in ${scope}`,
|
|
362
365
|
`${graded.length} carry a grade an independent review stated unambiguously${graded.length > 0 ? `: ${tally(graded.map((d) => d.grade))}` : ''}`,
|
|
363
|
-
`${ambiguous.length} have a report that states MORE THAN ONE grade
|
|
366
|
+
`${ambiguous.length} have a report that states MORE THAN ONE grade — reported as ambiguous, never guessed`,
|
|
367
|
+
`${invalid.length} have a findings table but a MALFORMED or missing QE-VERDICT line — the report cannot be read, which is a formatting problem rather than two reviews disagreeing`,
|
|
364
368
|
`${ungraded.length} have no letter grade in their report, or no report at all`,
|
|
365
369
|
'cadence is not value: this counts deliveries, not what they were worth',
|
|
366
370
|
]);
|
package/src/registry.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, type Dirent } from 'node:fs';
|
|
11
|
-
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
11
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
|
|
14
14
|
import { stems } from './stem.js';
|
|
@@ -188,6 +188,42 @@ export function discoverSkillCarryingDirs(cwd: string): { pack: string; dir: str
|
|
|
188
188
|
* drop the `unsigned` verdict for a `skills-*` pack that carries no manifest — turning "unsigned" into
|
|
189
189
|
* "absent", which is the same class of silence this fixes.
|
|
190
190
|
*/
|
|
191
|
+
/**
|
|
192
|
+
* Чей это пак: проекта, который проверяют, или самого прибора, который проверяет.
|
|
193
|
+
*
|
|
194
|
+
* ИЗМЕРЕНО 2026-09-21 на одном репозитории в одну минуту: `dz` из PATH перечисляет 88 паков и
|
|
195
|
+
* сообщает «31 verified», сборка из дерева — 57 и «0 verified». Оба ответа ВЕРНЫ и расходятся
|
|
196
|
+
* ровно на 29 подписанных манифестов внутри глобальной установки: `discoverVerifiablePackDirs`
|
|
197
|
+
* намеренно дотягивается до упакованных копий самого прибора. Беда не в числе, а в строке, где оно
|
|
198
|
+
* стоит: «31 verified» рядом с именем проекта читается как охват ПРОЕКТА, хотя проверены копии
|
|
199
|
+
* инструмента. Разделение считается по пути, потому что только путь и отличает эти два случая.
|
|
200
|
+
*
|
|
201
|
+
* Сравнение путей РАЗДЕЛИТЕЛЬНОЕ: `/repo-2/x` не внутри `/repo`. Наивный `startsWith` этот репозиторий
|
|
202
|
+
* уже однажды отгружал.
|
|
203
|
+
*/
|
|
204
|
+
export function packScope(dir: string, projectRoot: string): 'project' | 'instrument' {
|
|
205
|
+
const rel = relative(projectRoot, dir);
|
|
206
|
+
const inside = rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
|
207
|
+
return inside ? 'project' : 'instrument';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Уточнение к числу проверенных подписей: сколько из них — паки ЭТОГО проекта, а сколько принадлежат
|
|
212
|
+
* самому прибору. Возвращает ПУСТУЮ строку, когда уточнять нечего (ничего не проверено либо всё
|
|
213
|
+
* проверенное — проектное): приписка к нулю была бы шумом, а шум в строке охвата её и обесценивает.
|
|
214
|
+
*/
|
|
215
|
+
export function verifiedScopeNote(
|
|
216
|
+
checks: readonly { readonly dir: string; readonly verdict: string }[],
|
|
217
|
+
projectRoot: string,
|
|
218
|
+
): string {
|
|
219
|
+
const verified = checks.filter((c) => c.verdict === 'verified');
|
|
220
|
+
if (verified.length === 0) return '';
|
|
221
|
+
const outside = verified.filter((c) => packScope(c.dir, projectRoot) === 'instrument').length;
|
|
222
|
+
if (outside === 0) return '';
|
|
223
|
+
return ` (${verified.length - outside} in this project, ${outside} in the instrument's own install`
|
|
224
|
+
+ " — not this project's artifacts)";
|
|
225
|
+
}
|
|
226
|
+
|
|
191
227
|
export function discoverVerifiablePackDirs(cwd: string): { pack: string; dir: string }[] {
|
|
192
228
|
const out: { pack: string; dir: string }[] = [];
|
|
193
229
|
// Keyed on the RESOLVED path: a globally-installed `dz` reaches its own bundled packs as well as the
|
|
@@ -388,6 +424,60 @@ function categoryFromPack(pack: string): string {
|
|
|
388
424
|
* `node_modules/@dzhechkov`, **and** the CLI's own install location. This is why
|
|
389
425
|
* `dz registry` works for a globally-installed `dz`, not only inside the monorepo.
|
|
390
426
|
*/
|
|
427
|
+
/** One skill as the public showcase page (`marketplace/index.html`) renders it. */
|
|
428
|
+
export interface ShowcaseSkill {
|
|
429
|
+
readonly id: string;
|
|
430
|
+
readonly pack: string;
|
|
431
|
+
readonly description: string;
|
|
432
|
+
readonly trustTier: number;
|
|
433
|
+
readonly category: string;
|
|
434
|
+
/** The exact command a visitor copies. Derived, never stored — one spelling, one place. */
|
|
435
|
+
readonly install: string;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export interface ShowcaseRegistry {
|
|
439
|
+
readonly version: string;
|
|
440
|
+
readonly generated: string;
|
|
441
|
+
readonly totalSkills: number;
|
|
442
|
+
readonly totalPacks: number;
|
|
443
|
+
readonly categories: readonly string[];
|
|
444
|
+
readonly skills: readonly ShowcaseSkill[];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The public showcase catalogue, projected from the SAME {@link Registry} the plugin manifests come
|
|
449
|
+
* from — so the page and the manifests can never answer "how many skills are there" differently.
|
|
450
|
+
*
|
|
451
|
+
* Why this exists: MEASURED 2026-09-04 and re-measured 2026-09-20, `marketplace/registry.json` said
|
|
452
|
+
* 57 skills / 10 packs while `buildRegistry` counted 260 / 37, and the file's last commit was
|
|
453
|
+
* 2026-06-03 — because NOTHING wrote it. A grep for its path across the whole source tree returned
|
|
454
|
+
* zero writers (backlog 9595b21e). A catalogue with no generator does not go stale slowly; it stops
|
|
455
|
+
* being true the first time anything ships, and it was the one surface a visitor actually reads.
|
|
456
|
+
*
|
|
457
|
+
* `generated` is the caller's to supply: a timestamp minted inside would make the output
|
|
458
|
+
* non-reproducible and the drift gate unable to compare a fresh build against the published file.
|
|
459
|
+
*/
|
|
460
|
+
export function buildShowcaseRegistry(
|
|
461
|
+
registry: Registry,
|
|
462
|
+
opts: { readonly version: string; readonly generated: string },
|
|
463
|
+
): ShowcaseRegistry {
|
|
464
|
+
return {
|
|
465
|
+
version: opts.version,
|
|
466
|
+
generated: opts.generated,
|
|
467
|
+
totalSkills: registry.totalSkills,
|
|
468
|
+
totalPacks: registry.totalPacks,
|
|
469
|
+
categories: [...registry.categories],
|
|
470
|
+
skills: registry.entries.map((e) => ({
|
|
471
|
+
id: e.id,
|
|
472
|
+
pack: e.pack,
|
|
473
|
+
description: e.description,
|
|
474
|
+
trustTier: e.trustTier,
|
|
475
|
+
category: e.category,
|
|
476
|
+
install: `dz init --target claude-code --select ${e.id}`,
|
|
477
|
+
})),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
391
481
|
export function buildRegistry(cwd: string): Registry {
|
|
392
482
|
const entries: RegistryEntry[] = [];
|
|
393
483
|
const packs = discoverSkillCarryingDirs(cwd);
|
package/src/release.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
25
25
|
import type { Dirent } from 'node:fs';
|
|
26
26
|
import { join, relative } from 'node:path';
|
|
27
27
|
|
|
28
|
-
import { discoverPackages, orderByDependencies } from './publish.js';
|
|
28
|
+
import { discoverPackages, matchesPublishFilter, orderByDependencies } from './publish.js';
|
|
29
29
|
import { planPackedInstallSmoke, judgePackedInstallSmoke } from './packed-install-smoke.js';
|
|
30
30
|
import type { PackedInstallPlan, PackedInstallExecution } from './packed-install-smoke.js';
|
|
31
31
|
|
|
@@ -243,9 +243,14 @@ export function collectPackageFacts(monorepoRoot: string, filter?: readonly stri
|
|
|
243
243
|
if (filter !== undefined && filter.length === 0) {
|
|
244
244
|
throw new Error('release: --filter requires a non-empty list of package-name substrings (empty would match ALL packages)');
|
|
245
245
|
}
|
|
246
|
+
// The twin of the publish guard: an empty ELEMENT matches every name, so the list being
|
|
247
|
+
// non-empty is not enough. Both doors must be shut or the weaker one becomes the entrance.
|
|
248
|
+
if (filter?.some((f) => f.length === 0)) {
|
|
249
|
+
throw new Error('release: --filter requires non-empty package-name substrings (empty would match ALL packages)');
|
|
250
|
+
}
|
|
246
251
|
const discovered = discoverPackages(monorepoRoot);
|
|
247
252
|
const selected =
|
|
248
|
-
filter === undefined ? discovered : discovered.filter((p) => filter.some((f) => p
|
|
253
|
+
filter === undefined ? discovered : discovered.filter((p) => filter.some((f) => matchesPublishFilter(p, f, monorepoRoot)));
|
|
249
254
|
const ordered = orderByDependencies(selected);
|
|
250
255
|
|
|
251
256
|
return ordered.map((p) => {
|
|
@@ -727,7 +732,8 @@ export function outputTail(stdout: unknown, stderr: unknown, maxLines = 40, maxB
|
|
|
727
732
|
* - `token`/`secret`/`password` (case-insensitive) as a `key: value` or `key=value` pair — the
|
|
728
733
|
* KEY survives, only the value is replaced;
|
|
729
734
|
* - `Bearer <token>` HTTP auth headers;
|
|
730
|
-
* - vendor-prefixed tokens: `npm_…`, `ghp_…`, `sk
|
|
735
|
+
* - vendor-prefixed tokens: `npm_…`, `ghp_…`, `github_pat_…`, `sk-…` (hyphenated forms whole), `AKIA…`;
|
|
736
|
+
* - JSON keys (`"token":"…"`) and env-style names ending in TOKEN/SECRET/PASSWORD/API_KEY;
|
|
731
737
|
* - long opaque strings (base64/hex-ish, `[A-Za-z0-9+/=]{32,}`) that look like a key/secret even
|
|
732
738
|
* without a recognisable prefix.
|
|
733
739
|
* Order matters: prefixed/labelled patterns run BEFORE the generic long-opaque-string pattern so
|
|
@@ -738,8 +744,16 @@ export function redactSecrets(text: string): string {
|
|
|
738
744
|
out = out.replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]');
|
|
739
745
|
out = out.replace(/\bnpm_[A-Za-z0-9]+/g, '[redacted]');
|
|
740
746
|
out = out.replace(/\bghp_[A-Za-z0-9]+/g, '[redacted]');
|
|
741
|
-
out = out.replace(/\
|
|
747
|
+
out = out.replace(/\bgithub_pat_[A-Za-z0-9_]+/g, '[redacted]');
|
|
748
|
+
// `sk-proj-…` (and any other hyphenated vendor form) is redacted WHOLE — the old `[A-Za-z0-9]+` stopped
|
|
749
|
+
// at the first hyphen and let the tail through (MEASURED 2026-09-13, backlog 2df653d9).
|
|
750
|
+
out = out.replace(/\bsk-[A-Za-z0-9_-]+/g, '[redacted]');
|
|
742
751
|
out = out.replace(/\bAKIA[A-Za-z0-9]+/g, '[redacted]');
|
|
752
|
+
// JSON keys: `"token":"…"` has a quote between key and colon, so the labelled rule below never saw it.
|
|
753
|
+
out = out.replace(/"(token|secret|password|api[_-]?key|access[_-]?token)"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"');
|
|
754
|
+
// Env-style names that END in a secret word (`GITHUB_TOKEN=`, `NPM_ACCESS_TOKEN=`): `_` is a word
|
|
755
|
+
// character, so `\btoken` never matched them.
|
|
756
|
+
out = out.replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY))(\s*[:=]\s*)(\S+)/g, '$1$2[redacted]');
|
|
743
757
|
out = out.replace(/\b(token|secret|password)(\s*[:=]\s*)(\S+)/gi, '$1$2[redacted]');
|
|
744
758
|
out = out.replace(/\b[A-Za-z0-9+/=]{32,}\b/g, '[redacted]');
|
|
745
759
|
return out;
|
|
@@ -915,6 +929,42 @@ function fencedBlock(text: string, indent = ' '): string[] {
|
|
|
915
929
|
return [`${indent}${fence}`, ...contentLines, `${indent}${fence}`];
|
|
916
930
|
}
|
|
917
931
|
|
|
932
|
+
/**
|
|
933
|
+
* Формы отказа `gh`, означающие «учётные данные не приняты», а не «команда не та».
|
|
934
|
+
* Список узкий намеренно: широкая сетка превратила бы любой сбой в повод лезть в окружение.
|
|
935
|
+
*/
|
|
936
|
+
const GH_AUTH_FAILURE_SHAPES: readonly RegExp[] = [
|
|
937
|
+
/no longer valid/i,
|
|
938
|
+
/bad credentials/i,
|
|
939
|
+
/authentication failed/i,
|
|
940
|
+
/requires authentication/i,
|
|
941
|
+
/gh auth login/i,
|
|
942
|
+
/HTTP 401/i,
|
|
943
|
+
];
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Стоит ли повторить вызов `gh` БЕЗ переменной `GITHUB_TOKEN`.
|
|
947
|
+
*
|
|
948
|
+
* ИЗМЕРЕНО 2026-09-21 в этой среде: `gh auth status` → «the github.com token in GITHUB_TOKEN is no
|
|
949
|
+
* longer valid», а `env -u GITHUB_TOKEN gh auth status` → вход как djd1m через keyring. То есть
|
|
950
|
+
* мёртвая переменная ЗАТЕНЯЕТ рабочие учётные данные, и всякий вызов `gh` из скрипта падает
|
|
951
|
+
* (бэклог ead5f8e0). Переменная живёт в окружении tmux-сервера и снимается только владельцем.
|
|
952
|
+
*
|
|
953
|
+
* Почему повтор, а не безусловное снятие: в сборочной среде `GITHUB_TOKEN` — ШТАТНЫЙ способ
|
|
954
|
+
* авторизации, и выбрасывать его всегда значило бы ломать работающее ради сломанного. Поэтому
|
|
955
|
+
* сначала обычный вызов, и лишь на отказе ИМЕННО по авторизации — одна попытка без переменной.
|
|
956
|
+
*/
|
|
957
|
+
export function shouldRetryGhWithoutToken(input: {
|
|
958
|
+
readonly exitCode: number;
|
|
959
|
+
readonly stderr: string;
|
|
960
|
+
readonly stdout: string;
|
|
961
|
+
readonly tokenPresent: boolean;
|
|
962
|
+
}): boolean {
|
|
963
|
+
if (input.exitCode === 0 || !input.tokenPresent) return false;
|
|
964
|
+
const text = `${input.stderr}\n${input.stdout}`;
|
|
965
|
+
return GH_AUTH_FAILURE_SHAPES.some((re) => re.test(text));
|
|
966
|
+
}
|
|
967
|
+
|
|
918
968
|
/**
|
|
919
969
|
* gh-2.4-safe `gh issue create` payload (only `--title`/`--body` are assumed downstream).
|
|
920
970
|
* Pure + deterministic for a fixed verdict — the issue is the verdict's echo, never its judge.
|
package/src/reqe.ts
CHANGED
|
@@ -187,7 +187,17 @@ export interface ReqeSettlement {
|
|
|
187
187
|
export function extractReportGrade(text: string): string | null {
|
|
188
188
|
// the lookahead rejects RANGES in punctuation form (A-F, A/F) AND word form (A through F,
|
|
189
189
|
// A to F) — Codex QE round-2 #6: 'GRADE A through F' must not read as grade A
|
|
190
|
-
|
|
190
|
+
// bridge-grade-grammar (backlog ddf83072): the rest of the instrument already speaks in modifiers —
|
|
191
|
+
// `dz round close --grade B-` is accepted and `dz recap` tallies `A-×21` — so a review that writes
|
|
192
|
+
// `GRADE B-` must be readable here too. The modifier is allowed ONLY when a letter does not follow
|
|
193
|
+
// it, because `GRADE A-F` is a RANGE (the boilerplate "GRADE A-F + findings"), not an A-minus.
|
|
194
|
+
// The dash class below is the UNICODE dash family, not just the ASCII hyphen (Codex review HIGH #2):
|
|
195
|
+
// a reviewer's editor turns `A-F` into `A−F` (U+2212) or `A‐F` (U+2010) without asking, and a range
|
|
196
|
+
// written that way used to read as a bare `A`. Any dash-family character following the letter means
|
|
197
|
+
// the text is not a plain grade, so the extractor refuses instead of guessing which half was meant —
|
|
198
|
+
// the same rule that makes `GRADE B-Critical` null. A modifier is only ever its ASCII form (`+`, `-`).
|
|
199
|
+
// hyphen-minus - | hyphen ‐ | non-breaking ‑ | figure ‒ | en – | em — | horizontal bar ― | minus −
|
|
200
|
+
const matches = [...String(text ?? '').matchAll(/^\s*(?:\*{0,2}#{0,4}\s*)?GRADE\s*[:=‐-―−-]?\s*([A-F](?:[+-](?![ \t]*[A-F]\b))?)(?![A-Za-z])(?![ \t]*[-‐-―−])(?!\s*(?:through|to|thru)\s*[A-F]\b)/gim)];
|
|
191
201
|
const distinct = new Set(matches.map((m) => (m[1] ?? '').toUpperCase()).filter((g) => g !== ''));
|
|
192
202
|
if (distinct.size !== 1) return null;
|
|
193
203
|
return [...distinct][0] ?? null;
|
package/src/round-exec.ts
CHANGED
|
@@ -37,14 +37,25 @@ export function classifyRoundExecOutcome(input: {
|
|
|
37
37
|
readonly exitCode: number | null;
|
|
38
38
|
readonly timedOut: boolean;
|
|
39
39
|
readonly bytes: number;
|
|
40
|
+
/** How the run ENDED: the limit/refusal signatures are end-state facts, so they read the tail. */
|
|
40
41
|
readonly tail: string;
|
|
42
|
+
/**
|
|
43
|
+
* The WHOLE log, when the caller has it. The turn marker is not an end-state fact — it is the last
|
|
44
|
+
* `\ncodex\n` ANYWHERE in the log — and a fixed tail window cannot hold it: MEASURED 2026-09-20 on
|
|
45
|
+
* three real rounds, Codex's final answer ran 12–20 KB (it quotes runner output), so the marker sat
|
|
46
|
+
* at ~95% of the file and fell outside the caller's 4 KB tail. All three runs had landed their files
|
|
47
|
+
* and were green, and all three were recorded `failed`. Defaults to `tail` for callers that only
|
|
48
|
+
* have the window.
|
|
49
|
+
*/
|
|
50
|
+
readonly fullText?: string;
|
|
41
51
|
}): RoundExecOutcome {
|
|
42
52
|
if (input.timedOut) return 'timeout';
|
|
43
53
|
if (/rate limit|usage limit|limit reached/i.test(input.tail)) return 'session-limit';
|
|
44
54
|
if (/HTTP 400|not supported when using Codex/i.test(input.tail)) return 'model-refused';
|
|
45
55
|
if (input.exitCode === 0 && input.bytes > 0) {
|
|
46
|
-
const
|
|
47
|
-
const
|
|
56
|
+
const haystack = input.fullText ?? input.tail;
|
|
57
|
+
const marker = haystack.lastIndexOf('\ncodex\n');
|
|
58
|
+
const finalLine = marker < 0 ? '' : haystack.slice(marker + '\ncodex\n'.length).split(/\r?\n/, 1)[0]?.trim() ?? '';
|
|
48
59
|
if (finalLine !== '') return 'done';
|
|
49
60
|
}
|
|
50
61
|
if (input.exitCode === 0 && input.bytes === 0) return 'empty';
|