@dzhechkov/harness-core 0.8.10 → 0.8.11
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 +120 -60
- package/dist/codex-invoke.d.ts +73 -0
- package/dist/codex-invoke.d.ts.map +1 -0
- package/dist/codex-invoke.js +80 -0
- package/dist/codex-invoke.js.map +1 -0
- package/dist/discrimination-gate.d.ts +63 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +113 -16
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/event-chain.d.ts +30 -0
- package/dist/event-chain.d.ts.map +1 -1
- package/dist/event-chain.js +24 -0
- package/dist/event-chain.js.map +1 -1
- package/dist/guard.d.ts +8 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +37 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/mutation-gate.d.ts +39 -36
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +111 -5
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +8 -5
- package/dist/operations.js.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +27 -5
- package/dist/plugin.js.map +1 -1
- package/dist/recommend.d.ts +4 -5
- package/dist/recommend.d.ts.map +1 -1
- package/dist/recommend.js +110 -45
- package/dist/recommend.js.map +1 -1
- package/dist/registry.d.ts +32 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +165 -9
- package/dist/registry.js.map +1 -1
- package/dist/run-records.d.ts +3 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +18 -0
- package/dist/run-records.js.map +1 -1
- package/dist/score.d.ts +95 -0
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +274 -2
- package/dist/score.js.map +1 -1
- package/dist/skill-selection.d.ts +72 -0
- package/dist/skill-selection.d.ts.map +1 -0
- package/dist/skill-selection.js +76 -0
- package/dist/skill-selection.js.map +1 -0
- package/dist/stem.d.ts +12 -0
- package/dist/stem.d.ts.map +1 -0
- package/dist/stem.js +89 -0
- package/dist/stem.js.map +1 -0
- package/dist/telemetry-vocabulary.d.ts +7 -0
- package/dist/telemetry-vocabulary.d.ts.map +1 -1
- package/dist/telemetry-vocabulary.js +29 -0
- package/dist/telemetry-vocabulary.js.map +1 -1
- package/package.json +8 -8
- package/sbom.json +209 -59
- package/src/codex-invoke.ts +138 -0
- package/src/discrimination-gate.ts +183 -19
- package/src/event-chain.ts +41 -0
- package/src/guard.ts +40 -0
- package/src/index.ts +10 -4
- package/src/mutation-gate.ts +165 -5
- package/src/operations.ts +8 -5
- package/src/plugin.ts +27 -5
- package/src/recommend.ts +116 -46
- package/src/registry.ts +144 -11
- package/src/run-records.ts +23 -0
- package/src/score.ts +361 -3
- package/src/skill-selection.ts +111 -0
- package/src/stem.ts +87 -0
- package/src/telemetry-vocabulary.ts +36 -0
package/src/mutation-gate.ts
CHANGED
|
@@ -108,6 +108,10 @@ export interface MutationObservation {
|
|
|
108
108
|
readonly rebaselineExitCode?: number | null;
|
|
109
109
|
/** named no-exit reason for the restored-tree attribution run, when it produced none. */
|
|
110
110
|
readonly rebaselineFailureReason?: string;
|
|
111
|
+
/** parsed failing files from a RED restored-tree run; absent when no red rebaseline ran. */
|
|
112
|
+
readonly rebaselineAttribution?: BaselineAttribution;
|
|
113
|
+
/** bounded log proving an internal runner failure received at most one retry. */
|
|
114
|
+
readonly internalAttemptLog?: string;
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
export interface MutationEntryResult {
|
|
@@ -135,6 +139,60 @@ export interface MutationEntryResult {
|
|
|
135
139
|
readonly detail: string;
|
|
136
140
|
}
|
|
137
141
|
|
|
142
|
+
// ── Internal runner crash containment — exactly one retry, both attempts observable ───────────
|
|
143
|
+
|
|
144
|
+
export type InternalRunnerAttemptOutcome = 'completed' | 'runner-internal-error';
|
|
145
|
+
|
|
146
|
+
export interface InternalRunnerAttempt {
|
|
147
|
+
readonly attempt: 1 | 2;
|
|
148
|
+
readonly outcome: InternalRunnerAttemptOutcome;
|
|
149
|
+
readonly detail: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface InternalRunnerRetryResult<T> {
|
|
153
|
+
/** The completed attempt's value. null means both attempts threw internally. */
|
|
154
|
+
readonly value: T | null;
|
|
155
|
+
readonly attempts: readonly InternalRunnerAttempt[];
|
|
156
|
+
/** Closed by construction: a runner receives either zero retries or exactly one. */
|
|
157
|
+
readonly internalRetries: 0 | 1;
|
|
158
|
+
/** Named reason consumed by the existing no-exit → INCONCLUSIVE arm. */
|
|
159
|
+
readonly failureReason?: `runner-internal-error: ${string}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function internalRunnerErrorHead(error: unknown): string {
|
|
163
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
164
|
+
const firstLine = raw.split(/\r?\n/, 1)[0]?.trim() || 'unknown internal runner error';
|
|
165
|
+
return Array.from(firstLine).slice(0, 160).join('');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Run one internal runner invocation. Only a THROWN internal error is retried; normal green/red
|
|
170
|
+
* observations and ordinary no-exit observations are values and therefore never retried.
|
|
171
|
+
*/
|
|
172
|
+
export function runWithOneInternalRetry<T>(runner: () => T): InternalRunnerRetryResult<T> {
|
|
173
|
+
const attempts: InternalRunnerAttempt[] = [];
|
|
174
|
+
for (const attempt of [1, 2] as const) {
|
|
175
|
+
try {
|
|
176
|
+
const value = runner();
|
|
177
|
+
attempts.push({ attempt, outcome: 'completed', detail: `attempt ${attempt}: completed` });
|
|
178
|
+
return { value, attempts, internalRetries: attempt === 1 ? 0 : 1 };
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const head = internalRunnerErrorHead(error);
|
|
181
|
+
attempts.push({
|
|
182
|
+
attempt,
|
|
183
|
+
outcome: 'runner-internal-error',
|
|
184
|
+
detail: `attempt ${attempt}: runner-internal-error: ${head}`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
value: null,
|
|
190
|
+
attempts,
|
|
191
|
+
internalRetries: 1,
|
|
192
|
+
failureReason: 'runner-internal-error: persistent after 2/2 attempts',
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
138
196
|
// ── Registry parsing — declarative DATA, validated loudly ─────────────────────────────────────
|
|
139
197
|
|
|
140
198
|
const SAFE_ID = /^[a-z0-9][a-z0-9-]{0,79}$/;
|
|
@@ -495,21 +553,114 @@ export function classifyRunFailure(rawOutput: string): RunFailureClassification
|
|
|
495
553
|
|
|
496
554
|
// ── Baseline (rule 3's runnability half) ──────────────────────────────────────────────────────
|
|
497
555
|
|
|
556
|
+
export type BaselineAttributionSource = 'node-test' | 'vitest' | 'unparseable';
|
|
557
|
+
|
|
558
|
+
export interface BaselineAttribution {
|
|
559
|
+
readonly parsedFrom: BaselineAttributionSource;
|
|
560
|
+
/** Package-relative failing paths, in first-seen order. */
|
|
561
|
+
readonly failingFiles: readonly string[];
|
|
562
|
+
/** Failing paths that match a registry file exactly (or by package-relative suffix). */
|
|
563
|
+
readonly covered: readonly string[];
|
|
564
|
+
/** Failing paths with no matching registry file. */
|
|
565
|
+
readonly extraneous: readonly string[];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function normaliseReportedFile(raw: string): string | null {
|
|
569
|
+
let value = raw.trim().replace(/^['"]|['"]$/g, '').replace(/:\d+(?::\d+)?$/, '');
|
|
570
|
+
value = value.replace(/\\/g, '/');
|
|
571
|
+
const testSegment = value.lastIndexOf('/test/');
|
|
572
|
+
if (testSegment >= 0) value = value.slice(testSegment + 1);
|
|
573
|
+
if (!value.includes('/') || !/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(value)) return null;
|
|
574
|
+
return value;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Parse the failing FILE paths already exposed by supported node --test and vitest shapes. */
|
|
578
|
+
export function attributeBaselineRedness(
|
|
579
|
+
rawOutput: string,
|
|
580
|
+
registryFiles: readonly string[],
|
|
581
|
+
): BaselineAttribution {
|
|
582
|
+
const output = stripSgr(rawOutput);
|
|
583
|
+
const files: string[] = [];
|
|
584
|
+
const add = (raw: string): void => {
|
|
585
|
+
const file = normaliseReportedFile(raw);
|
|
586
|
+
if (file !== null && !files.includes(file)) files.push(file);
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const vitestMatches = [...output.matchAll(/^\s*FAIL\s+(\S+)/gm)];
|
|
590
|
+
for (const match of vitestMatches) add(match[1] ?? '');
|
|
591
|
+
|
|
592
|
+
const tapMatches = [...output.matchAll(/^not ok \d+\s+-\s+(.+)$/gm)];
|
|
593
|
+
for (const match of tapMatches) add(match[1] ?? '');
|
|
594
|
+
for (const match of output.matchAll(/^\s*location:\s*['"]([^'"]+)['"]\s*$/gm)) {
|
|
595
|
+
add(match[1] ?? '');
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const parsedFrom: BaselineAttributionSource = files.length === 0
|
|
599
|
+
? 'unparseable'
|
|
600
|
+
: vitestMatches.length > 0 ? 'vitest' : 'node-test';
|
|
601
|
+
if (parsedFrom === 'unparseable') {
|
|
602
|
+
return { parsedFrom, failingFiles: [], covered: [], extraneous: [] };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const normalisedRegistry = registryFiles
|
|
606
|
+
.map((file) => normaliseReportedFile(file) ?? file.replace(/\\/g, '/'));
|
|
607
|
+
const covered = files.filter((file) => normalisedRegistry.some((registryFile) =>
|
|
608
|
+
file === registryFile || file.endsWith(`/${registryFile}`) || registryFile.endsWith(`/${file}`)));
|
|
609
|
+
const extraneous = files.filter((file) => !covered.includes(file));
|
|
610
|
+
return { parsedFrom, failingFiles: files, covered, extraneous };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export type BaselineFailureReason =
|
|
614
|
+
| 'runner-internal-error'
|
|
615
|
+
| 'runner-no-exit'
|
|
616
|
+
| 'extraneous-red-in-allowlist'
|
|
617
|
+
| 'baseline-red-covered-files'
|
|
618
|
+
| 'baseline-red-files-unparseable';
|
|
619
|
+
|
|
498
620
|
export interface BaselineResult {
|
|
499
621
|
readonly ok: boolean;
|
|
500
622
|
readonly detail: string;
|
|
623
|
+
readonly reason?: BaselineFailureReason;
|
|
501
624
|
}
|
|
502
625
|
|
|
503
626
|
/**
|
|
504
627
|
* A RED baseline in the scratch copy is a SETUP error, never a mutation result: every subsequent
|
|
505
628
|
* "red under mutation" would be noise, and every "green" a lie about an unrunnable copy.
|
|
506
629
|
*/
|
|
507
|
-
export function classifyBaseline(
|
|
630
|
+
export function classifyBaseline(
|
|
631
|
+
exitCode: number | null,
|
|
632
|
+
runFailureReason?: string,
|
|
633
|
+
attribution?: BaselineAttribution,
|
|
634
|
+
): BaselineResult {
|
|
508
635
|
if (exitCode === 0) return { ok: true, detail: 'baseline suite green in the scratch copy' };
|
|
509
636
|
if (exitCode === null) {
|
|
510
|
-
|
|
637
|
+
const internal = runFailureReason?.startsWith('runner-internal-error:') === true;
|
|
638
|
+
return {
|
|
639
|
+
ok: false,
|
|
640
|
+
reason: internal ? 'runner-internal-error' : 'runner-no-exit',
|
|
641
|
+
detail: `baseline INCONCLUSIVE — suite produced no exit code (${runFailureReason ?? 'unknown timeout/spawn failure'}) — the copy is not runnable; do not read this as a mutation result`,
|
|
642
|
+
};
|
|
511
643
|
}
|
|
512
|
-
|
|
644
|
+
if (attribution === undefined || attribution.parsedFrom === 'unparseable') {
|
|
645
|
+
return {
|
|
646
|
+
ok: false,
|
|
647
|
+
reason: 'baseline-red-files-unparseable',
|
|
648
|
+
detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — failing files: unparseable from runner output — the broken copy cannot prove anything`,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
const failing = `failing files: ${attribution.failingFiles.join(', ')}`;
|
|
652
|
+
if (attribution.extraneous.length > 0 && attribution.covered.length === 0) {
|
|
653
|
+
return {
|
|
654
|
+
ok: false,
|
|
655
|
+
reason: 'extraneous-red-in-allowlist',
|
|
656
|
+
detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — ${failing} — extraneous red in the testCommand allowlist; the registry entries themselves are not disproven`,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
return {
|
|
660
|
+
ok: false,
|
|
661
|
+
reason: 'baseline-red-covered-files',
|
|
662
|
+
detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — ${failing} — broken-copy baseline redness touches registry-covered files; fix the copy before evaluating mutations`,
|
|
663
|
+
};
|
|
513
664
|
}
|
|
514
665
|
|
|
515
666
|
// ── Classification (rules 1 + 2, and the drop decision) ───────────────────────────────────────
|
|
@@ -549,7 +700,7 @@ export function classifyBaseline(exitCode: number | null, runFailureReason?: str
|
|
|
549
700
|
* while the contract still holds is the early warning, reported loudly so a human re-pins
|
|
550
701
|
* `observed` or investigates — silently normalising it would erase the signal.
|
|
551
702
|
*/
|
|
552
|
-
|
|
703
|
+
function classifyMutationOutcomeWithoutAttemptLog(obs: MutationObservation): MutationEntryResult {
|
|
553
704
|
const e = obs.entry;
|
|
554
705
|
const minFailing = e.minFailing ?? 1;
|
|
555
706
|
const base = {
|
|
@@ -653,12 +804,15 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
|
|
|
653
804
|
// not come back green, so the suite is flaky and an unrelated neighbour may be what went red.
|
|
654
805
|
// Not attributable ⇒ INCONCLUSIVE (a failure, never a pass).
|
|
655
806
|
if (obs.rebaselineExitCode !== undefined && obs.rebaselineExitCode !== 0) {
|
|
807
|
+
const failing = obs.rebaselineAttribution === undefined || obs.rebaselineAttribution.parsedFrom === 'unparseable'
|
|
808
|
+
? 'failing files: unparseable from runner output'
|
|
809
|
+
: `failing files: ${obs.rebaselineAttribution.failingFiles.join(', ')}`;
|
|
656
810
|
return {
|
|
657
811
|
...base,
|
|
658
812
|
applied: true,
|
|
659
813
|
verdict: 'INCONCLUSIVE',
|
|
660
814
|
drop: false,
|
|
661
|
-
detail: `suite red under the mutation BUT the restored baseline did not reproduce green (${obs.rebaselineExitCode === null ? `no exit code: ${obs.rebaselineFailureReason ?? 'unknown timeout / spawn failure'}` : `exit ${obs.rebaselineExitCode}`}) —
|
|
815
|
+
detail: `suite red under the mutation BUT the restored baseline did not reproduce green (${obs.rebaselineExitCode === null ? `no exit code: ${obs.rebaselineFailureReason ?? 'unknown timeout / spawn failure'}` : `exit ${obs.rebaselineExitCode}`}) — mutation did not revert / flaky restored-tree route; ${failing}; the redness is not attributable to the protection`,
|
|
662
816
|
};
|
|
663
817
|
}
|
|
664
818
|
|
|
@@ -698,6 +852,12 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
|
|
|
698
852
|
};
|
|
699
853
|
}
|
|
700
854
|
|
|
855
|
+
export function classifyMutationOutcome(obs: MutationObservation): MutationEntryResult {
|
|
856
|
+
const result = classifyMutationOutcomeWithoutAttemptLog(obs);
|
|
857
|
+
if (obs.internalAttemptLog === undefined) return result;
|
|
858
|
+
return { ...result, detail: `${result.detail}; ${obs.internalAttemptLog}` };
|
|
859
|
+
}
|
|
860
|
+
|
|
701
861
|
/** Verdicts that fail the gate. INCONCLUSIVE and NOT_APPLIED fail (inconclusive ≠ pass). */
|
|
702
862
|
const FAILING_VERDICTS: ReadonlySet<MutationVerdict> = new Set(['UNDEFENDED', 'RECEIPT_MISMATCH', 'NOT_APPLIED', 'BELOW_MIN', 'MUTATION_UNPARSEABLE', 'MUTATION_LOAD_FATAL', 'OVER_FAILING', 'INCONCLUSIVE']);
|
|
703
863
|
|
package/src/operations.ts
CHANGED
|
@@ -1383,9 +1383,12 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
1383
1383
|
// Silent when a log is absent or has never been chained: an unchained file is legal (FR-5), not a
|
|
1384
1384
|
// fault, and reporting it would train the reader to ignore this line.
|
|
1385
1385
|
try {
|
|
1386
|
-
const { verifyEventChainText, classifyChainDefects, EVENT_CHAIN_SCOPE } = await import('./event-chain.js');
|
|
1387
|
-
|
|
1388
|
-
|
|
1386
|
+
const { verifyEventChainText, classifyChainDefects, EVENT_CHAIN_SCOPE, CHAINED_JOURNALS } = await import('./event-chain.js');
|
|
1387
|
+
// W0-chain (bc4ee35c): enumerated from THE registry, never from a list kept here. The inline
|
|
1388
|
+
// array this replaces is why a journal could be given a chain and still be checked by nobody —
|
|
1389
|
+
// the mechanism present, the coverage absent, and no red anywhere to say so.
|
|
1390
|
+
for (const journal of CHAINED_JOURNALS) {
|
|
1391
|
+
const p = join(root, journal.rel);
|
|
1389
1392
|
if (!existsSync(p)) continue;
|
|
1390
1393
|
const text = readFileSync(p, 'utf-8');
|
|
1391
1394
|
const v = verifyEventChainText(text);
|
|
@@ -1400,7 +1403,7 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
1400
1403
|
// file and false of the present, and a red nobody can act on is a red nobody reads.
|
|
1401
1404
|
if (age.inRun.length === 0 && age.runRecords > 0) {
|
|
1402
1405
|
checks.push({
|
|
1403
|
-
name: `evidence chain (.
|
|
1406
|
+
name: `evidence chain (${journal.rel})`,
|
|
1404
1407
|
ok: true,
|
|
1405
1408
|
// The COUNT carries the meaning, and is printed first for that reason: "1 record forms an
|
|
1406
1409
|
// unbroken run" is true and says almost nothing, while 998 says a great deal. Naming the
|
|
@@ -1413,7 +1416,7 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
1413
1416
|
continue;
|
|
1414
1417
|
}
|
|
1415
1418
|
checks.push({
|
|
1416
|
-
name: `evidence chain (.
|
|
1419
|
+
name: `evidence chain (${journal.rel})`,
|
|
1417
1420
|
ok: false,
|
|
1418
1421
|
detail: `${named} — with NO sound records after them: learning verdicts computed from this log are unsafe. Scope: ${EVENT_CHAIN_SCOPE}`,
|
|
1419
1422
|
});
|
package/src/plugin.ts
CHANGED
|
@@ -27,6 +27,13 @@ export interface PluginManifest {
|
|
|
27
27
|
readonly skills: readonly string[];
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** id→path index built from the registry, so the generator never guesses a layout. */
|
|
31
|
+
function skillPathIndex(registry: { entries: readonly { id: string; pack: string; path?: string }[] }): Map<string, string> {
|
|
32
|
+
const m = new Map<string, string>();
|
|
33
|
+
for (const e of registry.entries) if (e.path !== undefined && e.path !== '') m.set(`${e.pack}/${e.id}`, e.path);
|
|
34
|
+
return m;
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
/** Generate .claude-plugin/ directory from registry. */
|
|
31
38
|
export function generatePlugin(
|
|
32
39
|
projectRoot: string,
|
|
@@ -61,16 +68,26 @@ export function generatePlugin(
|
|
|
61
68
|
};
|
|
62
69
|
|
|
63
70
|
const skillPacks = [...packMap.entries()].map(([name, count]) => ({
|
|
71
|
+
// `pack` is the REAL directory name; `name` is only the display short form. Deriving the key
|
|
72
|
+
// back by re-adding the `skills-` prefix broke the moment the catalogue learned packs that
|
|
73
|
+
// never had one (health-advisor, keysarium, …): the lookup missed and their entries listed
|
|
74
|
+
// zero skills.
|
|
75
|
+
pack: name,
|
|
64
76
|
name: name.replace('skills-', ''),
|
|
65
77
|
skills: count,
|
|
66
78
|
description: packDescriptions[name] ?? `${count} skills`,
|
|
67
79
|
}));
|
|
68
80
|
|
|
81
|
+
const pathById = skillPathIndex(registry);
|
|
82
|
+
|
|
69
83
|
// Explicit skill paths for the full-suite plugin (source `./`): every skill
|
|
70
84
|
// dir across every pack, relative to the repo root. This is what makes
|
|
71
85
|
// `claude plugin details` report the real skill count instead of 0.
|
|
72
86
|
const allSkillPaths = [...packSkillIds.entries()]
|
|
73
|
-
|
|
87
|
+
// Prefer the path the catalogue recorded; `<pack>/<id>` is only a fallback for entries built
|
|
88
|
+
// before the field existed. Reconstructing it is what produced unresolvable paths for skills
|
|
89
|
+
// that live under `skills/` or `templates/.claude/skills/`.
|
|
90
|
+
.flatMap(([pack, ids]) => ids.map((id) => pathById.get(`${pack}/${id}`) ?? `packages/@dzhechkov/${pack}/${id}`))
|
|
74
91
|
.sort();
|
|
75
92
|
|
|
76
93
|
const pluginJson = {
|
|
@@ -103,12 +120,17 @@ export function generatePlugin(
|
|
|
103
120
|
...skillPacks.map((pack) => ({
|
|
104
121
|
name: `dz-${pack.name}`,
|
|
105
122
|
displayName: `DZ ${pack.name.charAt(0).toUpperCase() + pack.name.slice(1)} Skills`,
|
|
106
|
-
source: `packages/@dzhechkov
|
|
123
|
+
source: `packages/@dzhechkov/${pack.pack}`,
|
|
107
124
|
description: `${pack.skills} ${pack.name} skills — ${pack.description}`,
|
|
108
125
|
keywords: [pack.name],
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
126
|
+
// Listed relative to `source` — required for Claude Code to discover them. The path comes
|
|
127
|
+
// from the catalogue rather than from the assumption "skill dirs sit at the pack root",
|
|
128
|
+
// which holds for `skills-*` packs and for neither of the two other layouts.
|
|
129
|
+
skills: (packSkillIds.get(pack.pack) ?? []).slice().sort().map((id) => {
|
|
130
|
+
const full = pathById.get(`${pack.pack}/${id}`);
|
|
131
|
+
const base = `packages/@dzhechkov/${pack.pack}/`;
|
|
132
|
+
return full !== undefined && full.startsWith(base) ? `./${full.slice(base.length)}` : `./${id}`;
|
|
133
|
+
}),
|
|
112
134
|
})),
|
|
113
135
|
],
|
|
114
136
|
};
|
package/src/recommend.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { Registry, RegistryEntry } from './registry.js';
|
|
|
17
17
|
import { pretrain } from './pretrain.js';
|
|
18
18
|
import { computePatternBoost, loadPatterns, loadStoreRecords, readLearningConfig, readReinforcementState, recordToPattern } from './patterns.js';
|
|
19
19
|
import { resolveLearningBackend } from './learning-backend.js';
|
|
20
|
+
import { stems } from './stem.js';
|
|
20
21
|
|
|
21
22
|
/** A recommended skill with relevance score. */
|
|
22
23
|
export interface SkillRecommendation {
|
|
@@ -63,41 +64,43 @@ export interface RecommendationReport {
|
|
|
63
64
|
readonly commands: readonly CommandRecommendation[];
|
|
64
65
|
readonly installCommand: string;
|
|
65
66
|
readonly plan: readonly string[];
|
|
66
|
-
/**
|
|
67
|
+
/** Provenance of the topics used for ranking. */
|
|
68
|
+
readonly topicSource: 'task' | 'project-stack' | 'none';
|
|
69
|
+
/** @deprecated Compatibility alias; true exactly when topicSource is project-stack. */
|
|
67
70
|
readonly pretrainFallback?: boolean;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
/** Topic → keywords mapping for task decomposition. */
|
|
71
74
|
const TOPIC_KEYWORDS: Record<string, string[]> = {
|
|
72
|
-
'api': ['api', 'rest', 'graphql', 'endpoint', 'openapi', 'swagger', 'http', 'grpc'],
|
|
73
|
-
'testing': ['test', 'testing', 'tdd', 'unit test', 'integration test', 'e2e', 'coverage', 'spec'],
|
|
74
|
-
'ci-cd': ['ci/cd', 'ci cd', 'pipeline', 'github actions', 'gitlab', 'jenkins', 'deploy', 'continuous integration', 'continuous delivery'],
|
|
75
|
-
'security': ['security', 'audit', 'vulnerability', 'owasp', 'injection', 'auth', 'codeql', 'sast'],
|
|
76
|
-
'database': ['database', 'migration', 'schema', 'sql', 'postgres', 'mysql', 'query', 'index'],
|
|
77
|
-
'kubernetes': ['kubernetes', 'k8s', 'helm', 'pod', 'deployment', 'container', 'cluster', 'service mesh'],
|
|
78
|
-
'docker': ['docker', 'compose', 'container', 'dockerfile', 'image', 'registry'],
|
|
79
|
-
'terraform': ['terraform', 'iac', 'infrastructure', 'cloud', 'aws', 'gcp', 'azure', 'provision'],
|
|
80
|
-
'monitoring': ['monitoring', 'observability', 'metrics', 'logs', 'traces', 'alerting', 'slo', 'grafana', 'prometheus'],
|
|
81
|
-
'incident': ['incident', 'outage', 'postmortem', 'oncall', 'pagerduty', 'sev1', 'downtime'],
|
|
82
|
-
'monorepo': ['monorepo', 'workspace', 'pnpm', 'turborepo', 'nx', 'changeset', 'lerna'],
|
|
83
|
-
'review': ['review', 'pull request', 'code review', 'merge', ' pr ', 'pr '],
|
|
84
|
-
'debug': ['debug', 'error', 'crash', 'stack trace', 'bug', 'fix', 'troubleshoot'],
|
|
85
|
-
'frontend': ['frontend', 'react', 'vue', 'component', 'ui', 'css', 'tailwind'],
|
|
86
|
-
'git': ['git', 'merge', 'rebase', 'conflict', 'branch', 'cherry-pick'],
|
|
87
|
-
'web3': ['web3', 'blockchain', 'defi', 'crypto', 'ethereum', 'solana', 'nft', 'token', 'swap', 'wallet'],
|
|
88
|
-
'search': ['search', 'brave', 'exa', 'web search', 'find information'],
|
|
89
|
-
'email': ['email', 'gmail', 'inbox', 'send email', 'mail'],
|
|
90
|
-
'productivity': ['sheets', 'calendar', 'tasks', 'todo', 'schedule', 'meeting', 'clickup', 'project management'],
|
|
91
|
-
'data': ['data', 'etl', 'elt', 'pipeline', 'transform', 'dbt', 'airflow', 'warehouse'],
|
|
92
|
-
'social': ['farcaster', 'reddit', 'social', 'community'],
|
|
93
|
-
'research': ['research', 'explore', 'casarium', 'competitor', 'market', 'analysis'],
|
|
94
|
-
'docs': ['documentation', 'docs', 'context7', 'library', 'reference'],
|
|
95
|
-
'scrape': ['scrape', 'crawl', 'extract', 'jina', 'content', 'markdown'],
|
|
96
|
-
'design-thinking': ['design thinking', 'user research', 'prototype', 'empathize', 'jtbd', 'jobs to be done', 'cjm', 'customer journey', 'vsm', 'value stream', 'hadi', 'lean canvas', 'usability', 'product discovery', 'mvp'],
|
|
97
|
-
'product': ['product', 'feature', 'roadmap', 'prd', 'requirements', 'sprint', 'backlog'],
|
|
98
|
-
'academic': ['thesis', 'dissertation', 'defense', 'ВКР', 'защита', 'ГЭК', 'рецензия', 'academic'],
|
|
99
|
-
'quality': ['quality', 'qa', 'qe', 'quality engineering', 'test strategy', 'coverage'],
|
|
100
|
-
'health': ['health', 'medical', 'clinical', 'diagnosis', 'drug', 'lab', 'patient'],
|
|
75
|
+
'api': ['api', 'rest', 'graphql', 'endpoint', 'openapi', 'swagger', 'http', 'grpc', 'апи', 'эндпоинт', 'интерфейс api', 'http запрос'],
|
|
76
|
+
'testing': ['test', 'testing', 'tdd', 'unit test', 'integration test', 'e2e', 'coverage', 'spec', 'тест', 'тестирование', 'автотест', 'покрытие тестами'],
|
|
77
|
+
'ci-cd': ['ci/cd', 'ci cd', 'pipeline', 'github actions', 'gitlab', 'jenkins', 'deploy', 'continuous integration', 'continuous delivery', 'непрерывная интеграция', 'непрерывную интеграцию', 'непрерывная доставка', 'пайплайн сборки', 'автодеплой'],
|
|
78
|
+
'security': ['security', 'audit', 'vulnerability', 'owasp', 'injection', 'auth', 'codeql', 'sast', 'безопасность', 'уязвимость', 'аудит безопасности', 'авторизация'],
|
|
79
|
+
'database': ['database', 'migration', 'schema', 'sql', 'postgres', 'mysql', 'query', 'index', 'база данных', 'миграция базы', 'схема данных', 'запрос к базе'],
|
|
80
|
+
'kubernetes': ['kubernetes', 'k8s', 'helm', 'pod', 'deployment', 'container', 'cluster', 'service mesh', 'кубернетес', 'кластер кубернетес', 'оркестрация контейнеров', 'хелм чарт'],
|
|
81
|
+
'docker': ['docker', 'compose', 'container', 'dockerfile', 'image', 'registry', 'докер', 'докерфайл', 'образ контейнера', 'докер композ'],
|
|
82
|
+
'terraform': ['terraform', 'iac', 'infrastructure', 'cloud', 'aws', 'gcp', 'azure', 'provision', 'терраформ', 'инфраструктура как код', 'облачная инфраструктура', 'провижининг'],
|
|
83
|
+
'monitoring': ['monitoring', 'observability', 'metrics', 'logs', 'traces', 'alerting', 'slo', 'grafana', 'prometheus', 'мониторинг', 'наблюдаемость', 'метрика', 'трассировка', 'оповещение'],
|
|
84
|
+
'incident': ['incident', 'outage', 'postmortem', 'oncall', 'pagerduty', 'sev1', 'downtime', 'инцидент', 'авария', 'простой сервиса', 'постмортем', 'дежурство'],
|
|
85
|
+
'monorepo': ['monorepo', 'workspace', 'pnpm', 'turborepo', 'nx', 'changeset', 'lerna', 'монорепо', 'рабочее пространство', 'турборепо', 'чейнджсет'],
|
|
86
|
+
'review': ['review', 'pull request', 'code review', 'merge', ' pr ', 'pr ', 'repo', 'repository review', 'ревью', 'код ревью', 'чужой репозиторий', 'разбор кода'],
|
|
87
|
+
'debug': ['debug', 'error', 'crash', 'stack trace', 'bug', 'fix', 'troubleshoot', 'отладка', 'ошибка', 'баг', 'падение', 'не работает'],
|
|
88
|
+
'frontend': ['frontend', 'react', 'vue', 'component', 'ui', 'css', 'tailwind', 'фронтенд', 'пользовательский интерфейс', 'компонент интерфейса', 'верстка'],
|
|
89
|
+
'git': ['git', 'merge', 'rebase', 'conflict', 'branch', 'cherry-pick', 'гит', 'слияние веток', 'ребейз', 'конфликт git', 'ветка git'],
|
|
90
|
+
'web3': ['web3', 'blockchain', 'defi', 'crypto', 'ethereum', 'solana', 'nft', 'token', 'swap', 'wallet', 'блокчейн', 'криптовалюта', 'эфириум', 'токен', 'криптокошелек'],
|
|
91
|
+
'search': ['search', 'brave', 'exa', 'web search', 'find information', 'поиск', 'веб поиск', 'найти информацию', 'искать в интернете'],
|
|
92
|
+
'email': ['email', 'gmail', 'inbox', 'send email', 'mail', 'электронная почта', 'электронную почту', 'отправить письмо', 'входящие письма', 'почтовый ящик'],
|
|
93
|
+
'productivity': ['sheets', 'calendar', 'tasks', 'todo', 'schedule', 'meeting', 'clickup', 'project management', 'календарь', 'список дел', 'расписание', 'встреча', 'управление проектом'],
|
|
94
|
+
'data': ['data', 'etl', 'elt', 'pipeline', 'transform', 'dbt', 'airflow', 'warehouse', 'обработка данных', 'хранилище данных', 'преобразование данных', 'конвейер данных'],
|
|
95
|
+
'social': ['farcaster', 'reddit', 'social', 'community', 'соцсеть', 'сообщество', 'реддит', 'социальные сети'],
|
|
96
|
+
'research': ['research', 'explore', 'casarium', 'competitor', 'market', 'analysis', 'исследование', 'рынок', 'конкурент', 'анализ рынка'],
|
|
97
|
+
'docs': ['documentation', 'docs', 'context7', 'library', 'reference', 'документация', 'документацию', 'справочник', 'руководство', 'описание библиотеки'],
|
|
98
|
+
'scrape': ['scrape', 'crawl', 'extract', 'jina', 'content', 'markdown', 'скрапинг', 'парсинг сайта', 'извлечь контент', 'обход сайта'],
|
|
99
|
+
'design-thinking': ['design thinking', 'user research', 'prototype', 'empathize', 'jtbd', 'jobs to be done', 'cjm', 'customer journey', 'vsm', 'value stream', 'hadi', 'lean canvas', 'usability', 'product discovery', 'mvp', 'дизайн мышление', 'исследование пользователей', 'прототип продукта', 'путь клиента', 'ценностный поток'],
|
|
100
|
+
'product': ['product', 'feature', 'roadmap', 'prd', 'requirements', 'sprint', 'backlog', 'продукт', 'функция продукта', 'дорожная карта', 'требования', 'спринт', 'бэклог'],
|
|
101
|
+
'academic': ['thesis', 'dissertation', 'defense', 'ВКР', 'защита', 'ГЭК', 'рецензия', 'academic', 'диссертация', 'дипломная работа', 'научная работа'],
|
|
102
|
+
'quality': ['quality', 'qa', 'qe', 'quality engineering', 'test strategy', 'coverage', 'качество', 'обеспечение качества', 'инженерия качества', 'стратегия тестирования'],
|
|
103
|
+
'health': ['health', 'medical', 'clinical', 'diagnosis', 'drug', 'lab', 'patient', 'blood', 'blood test', 'lab results', 'анализ', 'кровь', 'здоровье', 'врач', 'диагноз', 'лаборатория', 'симптом'],
|
|
101
104
|
};
|
|
102
105
|
|
|
103
106
|
/** Command knowledge base — what each command does and when to use it. */
|
|
@@ -144,44 +147,84 @@ const TOOLKIT_KB: { name: string; npmPackage: string; install: string; descripti
|
|
|
144
147
|
{ name: 'skills-analyst-manual', npmPackage: '@dzhechkov/skills-analyst-manual', install: 'npx @dzhechkov/skills-analyst-manual init', description: '3-phase analyst composite (explore → research → solve)', topics: ['research', 'product'] },
|
|
145
148
|
];
|
|
146
149
|
|
|
150
|
+
const CYRILLIC_KEYWORD = /\p{Script=Cyrillic}/u;
|
|
151
|
+
|
|
152
|
+
function containsStemSequence(textStems: readonly string[], keywordStems: readonly string[]): boolean {
|
|
153
|
+
if (keywordStems.length === 0 || keywordStems.length > textStems.length) return false;
|
|
154
|
+
return textStems.some((_, start) => keywordStems.every(
|
|
155
|
+
(keywordStem, offset) => textStems[start + offset] === keywordStem,
|
|
156
|
+
));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function keywordMatches(lowerText: string, textStems: readonly string[], keyword: string): boolean {
|
|
160
|
+
const normalizedKeyword = keyword.normalize('NFC').toLowerCase().replaceAll('ё', 'е');
|
|
161
|
+
// Preserve the old raw-substring behavior for the pre-existing Latin dictionary,
|
|
162
|
+
// including deliberately padded ` pr `. New Cyrillic rows use token/stem equality,
|
|
163
|
+
// so a word such as `протест` cannot accidentally activate the `тест` topic.
|
|
164
|
+
const rawMatch = !CYRILLIC_KEYWORD.test(normalizedKeyword) && lowerText.includes(normalizedKeyword);
|
|
165
|
+
return rawMatch || containsStemSequence(textStems, stems(normalizedKeyword));
|
|
166
|
+
}
|
|
167
|
+
|
|
147
168
|
/** Extract topics from a task description. */
|
|
148
169
|
function extractTopics(task: string): string[] {
|
|
149
|
-
const lower = task.toLowerCase();
|
|
170
|
+
const lower = task.normalize('NFC').toLowerCase().replaceAll('ё', 'е');
|
|
171
|
+
const taskStems = stems(task);
|
|
150
172
|
const matched: string[] = [];
|
|
151
173
|
for (const [topic, keywords] of Object.entries(TOPIC_KEYWORDS)) {
|
|
152
174
|
for (const kw of keywords) {
|
|
153
|
-
if (lower
|
|
175
|
+
if (keywordMatches(lower, taskStems, kw)) {
|
|
154
176
|
matched.push(topic);
|
|
155
177
|
break;
|
|
156
178
|
}
|
|
157
179
|
}
|
|
158
180
|
}
|
|
159
|
-
return matched
|
|
181
|
+
return matched;
|
|
160
182
|
}
|
|
161
183
|
|
|
162
184
|
/** Score a skill against extracted topics. */
|
|
163
|
-
function scoreSkill(entry: RegistryEntry, topics: string[]): number {
|
|
164
|
-
const
|
|
185
|
+
function scoreSkill(entry: RegistryEntry, topics: string[], task?: string): number {
|
|
186
|
+
const text = `${entry.id} ${entry.description} ${entry.category}`;
|
|
187
|
+
const lower = text.normalize('NFC').toLowerCase().replaceAll('ё', 'е');
|
|
188
|
+
const textStems = stems(text);
|
|
165
189
|
let score = 0;
|
|
166
190
|
for (const topic of topics) {
|
|
167
191
|
const keywords = TOPIC_KEYWORDS[topic] ?? [topic];
|
|
168
192
|
for (const kw of keywords) {
|
|
169
|
-
if (lower
|
|
193
|
+
if (keywordMatches(lower, textStems, kw)) { score += 10; break; }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Topic points alone are FLAT: every skill of a matching topic scores exactly 10, so dozens tie
|
|
197
|
+
// and the top-N cut falls arbitrarily among them. Harmless while the catalogue was small; the
|
|
198
|
+
// moment it grew from 202 to 249 skills (layout fix, 2026-09-01) the right answer started losing
|
|
199
|
+
// ties to same-topic neighbours. A small tie-break by ACTUAL overlap with the asked task keeps
|
|
200
|
+
// the topic signal dominant (10 per topic) while letting the skill the user literally described
|
|
201
|
+
// rise above its topic-mates: an id word is worth more than a description word, because an id
|
|
202
|
+
// match is rarely accidental.
|
|
203
|
+
if (task !== undefined && task !== '') {
|
|
204
|
+
const words = [...new Set(task.toLowerCase().match(/[\p{L}\p{N}]{3,}/gu) ?? [])];
|
|
205
|
+
const id = entry.id.toLowerCase();
|
|
206
|
+
const desc = String(entry.description ?? '').toLowerCase();
|
|
207
|
+
const idStems = new Set(stems(entry.id));
|
|
208
|
+
const descStems = new Set(stems(String(entry.description ?? '')));
|
|
209
|
+
let overlap = 0;
|
|
210
|
+
for (const w of words) {
|
|
211
|
+
const wordStem = stems(w)[0];
|
|
212
|
+
if (id.includes(w) || (wordStem !== undefined && idStems.has(wordStem))) overlap += 3;
|
|
213
|
+
else if (desc.includes(w) || (wordStem !== undefined && descStems.has(wordStem))) overlap += 1;
|
|
170
214
|
}
|
|
215
|
+
// Capped below one topic point so a tie-break can never outrank a genuine topic match.
|
|
216
|
+
score += Math.min(overlap, 9);
|
|
171
217
|
}
|
|
172
218
|
return score;
|
|
173
219
|
}
|
|
174
220
|
|
|
175
|
-
/** Generate
|
|
176
|
-
* When task is too generic (only 'general' topic), falls back to pretrain
|
|
177
|
-
* to analyze the actual project and recommend based on tech stack.
|
|
178
|
-
*/
|
|
221
|
+
/** Generate recommendations from task topics, or visibly-labelled project-stack topics on a miss. */
|
|
179
222
|
export function recommend(task: string, registry: Registry, projectRoot?: string): RecommendationReport {
|
|
180
223
|
let topics = extractTopics(task);
|
|
181
|
-
let
|
|
224
|
+
let topicSource: RecommendationReport['topicSource'] = topics.length > 0 ? 'task' : 'none';
|
|
182
225
|
|
|
183
|
-
// Fallback: if task
|
|
184
|
-
if (topics.length ===
|
|
226
|
+
// Fallback: if no task keyword matched, use pretrain only as explicitly stack-derived advice.
|
|
227
|
+
if (topics.length === 0 && projectRoot) {
|
|
185
228
|
const analysis = pretrain(projectRoot);
|
|
186
229
|
const pretrainTopics: string[] = [];
|
|
187
230
|
const techNames = analysis.techs.map((t) => t.name.toLowerCase());
|
|
@@ -195,9 +238,25 @@ export function recommend(task: string, registry: Registry, projectRoot?: string
|
|
|
195
238
|
if (analysis.hasTests) pretrainTopics.push('testing');
|
|
196
239
|
if (pretrainTopics.length > 0) {
|
|
197
240
|
topics = [...new Set(pretrainTopics)];
|
|
198
|
-
|
|
241
|
+
topicSource = 'project-stack';
|
|
199
242
|
}
|
|
200
243
|
}
|
|
244
|
+
const pretrainFallback = topicSource === 'project-stack';
|
|
245
|
+
|
|
246
|
+
if (topicSource === 'none') {
|
|
247
|
+
return {
|
|
248
|
+
task,
|
|
249
|
+
topics: [],
|
|
250
|
+
skills: [],
|
|
251
|
+
presets: [],
|
|
252
|
+
toolkits: [],
|
|
253
|
+
commands: [],
|
|
254
|
+
installCommand: '',
|
|
255
|
+
plan: [],
|
|
256
|
+
topicSource,
|
|
257
|
+
pretrainFallback,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
201
260
|
|
|
202
261
|
// Learned-pattern read-back (audit #2): reward-rank taught patterns into a
|
|
203
262
|
// bounded, monotonic boost. Gated on the rollout flag; when memory is empty or
|
|
@@ -223,7 +282,7 @@ export function recommend(task: string, registry: Registry, projectRoot?: string
|
|
|
223
282
|
const scored = registry.entries
|
|
224
283
|
.map((e) => ({
|
|
225
284
|
entry: e,
|
|
226
|
-
score: scoreSkill(e, topics) + (patterns.length ? boostFor(e) : 0),
|
|
285
|
+
score: scoreSkill(e, topics, task) + (patterns.length ? boostFor(e) : 0),
|
|
227
286
|
}))
|
|
228
287
|
.filter((s) => s.score > 0)
|
|
229
288
|
.sort((a, b) => b.score - a.score);
|
|
@@ -309,5 +368,16 @@ export function recommend(task: string, registry: Registry, projectRoot?: string
|
|
|
309
368
|
}
|
|
310
369
|
plan.push(`${plan.length + 1}. Use your agent normally — skills auto-activate on matching tasks`);
|
|
311
370
|
|
|
312
|
-
return {
|
|
371
|
+
return {
|
|
372
|
+
task,
|
|
373
|
+
topics,
|
|
374
|
+
skills,
|
|
375
|
+
presets,
|
|
376
|
+
toolkits,
|
|
377
|
+
commands,
|
|
378
|
+
installCommand,
|
|
379
|
+
plan,
|
|
380
|
+
topicSource,
|
|
381
|
+
pretrainFallback,
|
|
382
|
+
};
|
|
313
383
|
}
|