canary-test-cli 7.0.0 → 7.2.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.
- package/agents/skills/README.md +308 -0
- package/agents/skills/canary:generate.md +49 -0
- package/agents/skills/canary:init.md +37 -0
- package/agents/skills/canary:migrate.md +66 -0
- package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
- package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
- package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
- package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
- package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
- package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
- package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
- package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
- package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
- package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
- package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
- package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
- package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
- package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
- package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
- package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
- package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
- package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
- package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
- package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
- package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
- package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
- package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
- package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
- package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
- package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
- package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
- package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
- package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
- package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
- package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
- package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
- package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
- package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
- package/agents/skills/lib/parse-args.mjs +214 -0
- package/dist/engine/analysis/cli.js +116 -54
- package/dist/engine/analysis/engine.js +34 -16
- package/dist/engine/analysis/reports.js +5 -4
- package/dist/engine/cli-commands.js +252 -106
- package/dist/engine/cli-common.js +15 -24
- package/dist/engine/cli.core.js +37 -11
- package/dist/engine/cli.js +2 -2
- package/dist/engine/company-knowledge-cli.js +2 -2
- package/dist/engine/core/adoption.js +408 -0
- package/dist/engine/core/framework-probes.js +7 -7
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/gate-result.js +17 -0
- package/dist/engine/core/migrator.js +9 -17
- package/dist/engine/core/pattern-matcher.js +23 -5
- package/dist/engine/core/persona.js +421 -0
- package/dist/engine/core/promotion-verdict.js +261 -0
- package/dist/engine/core/quality-scorer.js +15 -2
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +392 -0
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +310 -38
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +726 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +7 -6
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +5 -5
- package/dist/engine/guardian/analysis-emit.js +19 -28
- package/dist/engine/guardian/cli.js +150 -51
- package/dist/engine/guardian/coverage.js +1 -1
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
- package/dist/engine/guardian/pr-check.js +322 -33
- package/dist/engine/guardian/pr-comment.js +4 -3
- package/dist/engine/history/cli.js +210 -6
- package/dist/engine/history/ndjson-store.js +9 -5
- package/dist/engine/history/record.js +34 -5
- package/dist/engine/history/run-recorder.js +165 -0
- package/dist/engine/history/schema.js +25 -7
- package/dist/engine/history/store.js +9 -0
- package/dist/engine/mcp-server.js +35 -13
- package/dist/engine/skills-cli.js +249 -23
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/dist/uninstall.js +12 -5
- package/package.json +3 -2
|
@@ -42,13 +42,14 @@ import { homedir } from 'node:os';
|
|
|
42
42
|
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
43
43
|
import { readJsonWithWarning } from './config-validation.js';
|
|
44
44
|
import { uncertainDetectionMessage } from './detection.js';
|
|
45
|
-
import {
|
|
45
|
+
import { CONFIG_PROBES, inferPlaywrightTestType, probeFramework, } from './framework-probes.js';
|
|
46
46
|
import { comparePathParts, globDirs, globFiles, isDir, isFile, readTextOrNull, } from './fs-glob.js';
|
|
47
47
|
import { detectWorkspace, workspaceGlobs, } from './workspace-detect.js';
|
|
48
48
|
import { FrameworkRegistry } from './framework-registry.js';
|
|
49
49
|
import { EXIT_ABSTAINED, gateOutcome } from './gate-result.js';
|
|
50
50
|
import { Scaffolder, scaffoldableFrameworks, TEMPLATES } from './scaffolder.js';
|
|
51
51
|
import { SkillRegistry } from './skill-registry.js';
|
|
52
|
+
import { ensureAscii } from '../util/ensure-ascii.js';
|
|
52
53
|
// ---------------------------------------------------------------------------
|
|
53
54
|
// Python-compatibility helpers (copied locally per-module, matching reporter.ts)
|
|
54
55
|
// ---------------------------------------------------------------------------
|
|
@@ -64,13 +65,6 @@ function pyTruthy(value) {
|
|
|
64
65
|
return Object.keys(value).length > 0;
|
|
65
66
|
return Boolean(value);
|
|
66
67
|
}
|
|
67
|
-
/**
|
|
68
|
-
* Reproduce `json.dumps(..., ensure_ascii=True)` on `JSON.stringify` output:
|
|
69
|
-
* escape every code point >= 0x80 as `\uXXXX`. (Same helper as reporter.ts.)
|
|
70
|
-
*/
|
|
71
|
-
function ensureAscii(json) {
|
|
72
|
-
return json.replace(/[\u0080-\uffff]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
73
|
-
}
|
|
74
68
|
// Non-ASCII glyphs kept out of the source text as escapes, emitted verbatim.
|
|
75
69
|
const CHECK = '\u{2705}'; // white heavy check mark
|
|
76
70
|
const WARN = '\u{26A0}'; // warning sign
|
|
@@ -272,7 +266,7 @@ function isLocallyEdited(overlayHash, targetHash, recordedHash) {
|
|
|
272
266
|
* Frameworks a user can pass to `canary migrate --framework <name>`. Surfaced in
|
|
273
267
|
* the fail-loud message when auto-detection is uncertain (issue #295).
|
|
274
268
|
*/
|
|
275
|
-
|
|
269
|
+
const KNOWN_FRAMEWORKS = [
|
|
276
270
|
'playwright',
|
|
277
271
|
'vitest',
|
|
278
272
|
'pytest',
|
|
@@ -311,9 +305,7 @@ function findWorkspaceSuites(root, framework) {
|
|
|
311
305
|
const globs = workspaceGlobs(root);
|
|
312
306
|
if (globs.length === 0)
|
|
313
307
|
return [];
|
|
314
|
-
const configNames =
|
|
315
|
-
.filter(([, fw]) => fw === framework)
|
|
316
|
-
.map(([filename]) => filename);
|
|
308
|
+
const configNames = CONFIG_PROBES.filter(([, fw]) => fw === framework).map(([filename]) => filename);
|
|
317
309
|
if (configNames.length === 0)
|
|
318
310
|
return [];
|
|
319
311
|
const dirs = new Set();
|
|
@@ -347,11 +339,11 @@ function findWorkspaceSuites(root, framework) {
|
|
|
347
339
|
* shape detection produced rather than overwriting it with a guess.
|
|
348
340
|
*/
|
|
349
341
|
function shapeForFrameworkOverride(framework, root) {
|
|
350
|
-
for (const [,
|
|
351
|
-
if (
|
|
342
|
+
for (const [, candidateFramework, shape] of CONFIG_PROBES) {
|
|
343
|
+
if (candidateFramework !== framework)
|
|
352
344
|
continue;
|
|
353
|
-
if (
|
|
354
|
-
return
|
|
345
|
+
if (candidateFramework === 'playwright' && shape === 'e2e_ui') {
|
|
346
|
+
return inferPlaywrightTestType(root);
|
|
355
347
|
}
|
|
356
348
|
return shape;
|
|
357
349
|
}
|
|
@@ -1525,7 +1517,7 @@ export class HarnessMigrator {
|
|
|
1525
1517
|
];
|
|
1526
1518
|
}
|
|
1527
1519
|
probeFramework(root, config) {
|
|
1528
|
-
return
|
|
1520
|
+
return probeFramework(root, config, ['config', 'content', 'language']);
|
|
1529
1521
|
}
|
|
1530
1522
|
findExistingTests(root) {
|
|
1531
1523
|
const found = [];
|
|
@@ -8,18 +8,36 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
10
10
|
import { extname, join, resolve } from 'node:path';
|
|
11
|
+
import { JS_TEST_EXTENSIONS } from './static-linter.js';
|
|
12
|
+
/**
|
|
13
|
+
* `**\/*.<infix>.<ext>` for every extension the scanners can actually read.
|
|
14
|
+
*
|
|
15
|
+
* Derived from {@link JS_TEST_EXTENSIONS} rather than hand-listed, because the
|
|
16
|
+
* hand-listed version is what made #566 possible and this module carried the
|
|
17
|
+
* same gap one layer deeper: the globs covered `.ts`/`.js` only, so a project
|
|
18
|
+
* whose suite is ESM (`.mjs`) or CJS (`.cjs`) scanned to `test_count: 0` — a
|
|
19
|
+
* value indistinguishable from "this project has no tests", which every
|
|
20
|
+
* consumer branching on {@link isEmpty} then treats as a clean absence.
|
|
21
|
+
*
|
|
22
|
+
* No suite in this repo is `.mjs`/`.cjs`, so CI can never trip over this by
|
|
23
|
+
* accident; `pattern-matcher.test.ts` asserts the coverage against the shared
|
|
24
|
+
* extension list instead of restating it.
|
|
25
|
+
*/
|
|
26
|
+
function jsGlobs(...infixes) {
|
|
27
|
+
return infixes.flatMap((infix) => JS_TEST_EXTENSIONS.map((ext) => `**/*.${infix}${ext}`));
|
|
28
|
+
}
|
|
11
29
|
const FILE_PATTERNS = {
|
|
12
|
-
playwright:
|
|
13
|
-
vitest:
|
|
30
|
+
playwright: jsGlobs('spec', 'test'),
|
|
31
|
+
vitest: jsGlobs('test', 'spec'),
|
|
14
32
|
pytest: ['**/test_*.py', '**/*_test.py'],
|
|
15
33
|
k6: ['**/*.load.js', '**/load.js'],
|
|
16
|
-
e2e_ui:
|
|
17
|
-
frontend_unit:
|
|
34
|
+
e2e_ui: jsGlobs('spec'),
|
|
35
|
+
frontend_unit: jsGlobs('test'),
|
|
18
36
|
api: ['**/test_*.py', '**/*_test.py'],
|
|
19
37
|
performance: ['**/*.load.js'],
|
|
20
38
|
python_unit: ['**/test_*.py', '**/*_test.py'],
|
|
21
39
|
};
|
|
22
|
-
const DEFAULT_PATTERNS = ['**/test_*.py', '
|
|
40
|
+
const DEFAULT_PATTERNS = ['**/test_*.py', ...jsGlobs('spec', 'test')];
|
|
23
41
|
const IGNORED_DIRS = new Set([
|
|
24
42
|
'node_modules',
|
|
25
43
|
'.git',
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Personas as a first-class engine concept (issue #462).
|
|
3
|
+
*
|
|
4
|
+
* Audience adaptation used to be hand-rolled per skill: each one restated some
|
|
5
|
+
* variant of "if tester, use simpler words" in its own prose, with its own
|
|
6
|
+
* vocabulary and its own inference rules. Nothing shared a definition, so tone
|
|
7
|
+
* drifted between skills and an overlay had nothing to override. The drift was
|
|
8
|
+
* measurable — `canary-edge-case-discovery` documented
|
|
9
|
+
* `--level sdet|junior|manual` while {@link
|
|
10
|
+
* import('./environment-detect.js').detectUserLevel} returned
|
|
11
|
+
* `sdet|manual|unknown`, and no code read either.
|
|
12
|
+
*
|
|
13
|
+
* This module replaces that with one definition skills **consult**:
|
|
14
|
+
*
|
|
15
|
+
* - a **registry** (`ts/src/data/personas/registry.json`) naming each
|
|
16
|
+
* audience, how much explanation it wants, which output formats suit it,
|
|
17
|
+
* and whether choices should be annotated with their reasoning;
|
|
18
|
+
* - a pure **resolver** that picks one from an explicit choice, a detected
|
|
19
|
+
* signal, or a fallback, and reports **which** and **why**;
|
|
20
|
+
* - **overlay extension** through the same `precedence` arbitration the
|
|
21
|
+
* overlay contract already defines for skill-name collisions (#333).
|
|
22
|
+
*
|
|
23
|
+
* Three deliberate boundaries, each of which was a decision rather than an
|
|
24
|
+
* omission:
|
|
25
|
+
*
|
|
26
|
+
* **Voice is not a persona field.** `voice/discovery.md` already resolves a
|
|
27
|
+
* named voice profile from its own project config, scoped by its own globs.
|
|
28
|
+
* Audience depth and character voice are orthogonal axes — a senior SDET may
|
|
29
|
+
* want a voiced-but-terse report while a manual tester wants the same voice
|
|
30
|
+
* with more explanation — so collapsing them into one field would make half
|
|
31
|
+
* the combinations inexpressible.
|
|
32
|
+
*
|
|
33
|
+
* **The fallback is explanatory, not degraded.** Most users will never
|
|
34
|
+
* configure this, so the no-signal answer has to be a good answer. Erring
|
|
35
|
+
* explanatory is the repo owner's stated default on #341 and #342: over-
|
|
36
|
+
* explaining is a mild annoyance, under-explaining silently fails a manual
|
|
37
|
+
* tester. It is *not* the maximum-explanation persona either, which would
|
|
38
|
+
* assert an audience nobody claimed.
|
|
39
|
+
*
|
|
40
|
+
* **Nothing here reads the environment.** Like `environment-detect.ts`, every
|
|
41
|
+
* input arrives as an argument, so the resolver is deterministic and the
|
|
42
|
+
* decision of where a signal comes from stays with the caller.
|
|
43
|
+
*/
|
|
44
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
45
|
+
import { dirname, join, resolve } from 'node:path';
|
|
46
|
+
import { fileURLToPath } from 'node:url';
|
|
47
|
+
import { uncertainDetectionMessage } from './detection.js';
|
|
48
|
+
import { listOverlays, registryPrecedence, resolveOverlay, } from './overlays.js';
|
|
49
|
+
/** How much explanation a persona wants around the same finding. */
|
|
50
|
+
const DEPTHS = ['terse', 'brief', 'guided'];
|
|
51
|
+
/**
|
|
52
|
+
* Confidence floor applied when a registry declares none.
|
|
53
|
+
*
|
|
54
|
+
* Note what the detector's confidence actually is: `|sdet - manual| / total`,
|
|
55
|
+
* a *margin* between two tallies, not a probability. One open `.ts` file with
|
|
56
|
+
* no opposing signal scores 1.0. So this floor screens out genuine ties and
|
|
57
|
+
* near-ties and nothing else; {@link DEFAULT_MIN_DETECTION_SIGNALS} is the
|
|
58
|
+
* screen that does the real discriminating.
|
|
59
|
+
*/
|
|
60
|
+
const DEFAULT_MIN_DETECTION_CONFIDENCE = 0.5;
|
|
61
|
+
/**
|
|
62
|
+
* Independent signals required before a detected level is trusted.
|
|
63
|
+
*
|
|
64
|
+
* A confidence floor cannot carry this decision on its own: because confidence
|
|
65
|
+
* is a margin, one unopposed observation arrives at a perfect 1.0, and a floor
|
|
66
|
+
* that screens ties but not single observations is confident-and-wrong — the
|
|
67
|
+
* failure mode this repo keeps rooting out. Two signals of genuinely different
|
|
68
|
+
* kinds is the screen that discriminates.
|
|
69
|
+
*
|
|
70
|
+
* "Independent" is per {@link independentSignalKinds}: distinct *kinds* of
|
|
71
|
+
* evidence, not a raw count. Ten open TypeScript files are one observation
|
|
72
|
+
* restated ten times.
|
|
73
|
+
*/
|
|
74
|
+
const DEFAULT_MIN_DETECTION_SIGNALS = 2;
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Parsing
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
function isRecord(v) {
|
|
79
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
80
|
+
}
|
|
81
|
+
function isDepth(v) {
|
|
82
|
+
return DEPTHS.includes(v);
|
|
83
|
+
}
|
|
84
|
+
function stringArray(v) {
|
|
85
|
+
if (!Array.isArray(v))
|
|
86
|
+
return null;
|
|
87
|
+
return v.every((x) => typeof x === 'string') ? [...v] : null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Validate one raw persona entry, or return null.
|
|
91
|
+
*
|
|
92
|
+
* Dropping a malformed entry rather than throwing is deliberate for the
|
|
93
|
+
* *overlay* path: one bad downstream file must not take the engine's own
|
|
94
|
+
* personas down with it. The count is observable through {@link personaIds},
|
|
95
|
+
* so a dropped entry shows up as a missing id rather than as silence.
|
|
96
|
+
*/
|
|
97
|
+
function parsePersona(raw) {
|
|
98
|
+
if (!isRecord(raw))
|
|
99
|
+
return null;
|
|
100
|
+
const { id, label, audience, depth, reasoning } = raw;
|
|
101
|
+
const formats = stringArray(raw['formats']);
|
|
102
|
+
if (typeof id !== 'string' || id.trim() === '')
|
|
103
|
+
return null;
|
|
104
|
+
if (typeof label !== 'string' || typeof audience !== 'string')
|
|
105
|
+
return null;
|
|
106
|
+
if (!isDepth(depth) || formats === null)
|
|
107
|
+
return null;
|
|
108
|
+
if (typeof reasoning !== 'boolean')
|
|
109
|
+
return null;
|
|
110
|
+
return { id: id.trim(), label, audience, depth, formats, reasoning };
|
|
111
|
+
}
|
|
112
|
+
function parsePersonas(raw) {
|
|
113
|
+
if (!Array.isArray(raw))
|
|
114
|
+
return [];
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const entry of raw) {
|
|
117
|
+
const parsed = parsePersona(entry);
|
|
118
|
+
if (parsed)
|
|
119
|
+
out.push(parsed);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
function parseDetectionMap(raw) {
|
|
124
|
+
if (!isRecord(raw))
|
|
125
|
+
return {};
|
|
126
|
+
const out = {};
|
|
127
|
+
for (const [level, target] of Object.entries(raw)) {
|
|
128
|
+
if (typeof target === 'string')
|
|
129
|
+
out[level] = target;
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
/** Default registry path: `<module dir>/../data/personas/registry.json`. */
|
|
134
|
+
function defaultPersonaRegistryPath() {
|
|
135
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
136
|
+
// here = <core> -> sibling data/ dir (src/data under vitest, dist/data once
|
|
137
|
+
// built and copied by ts/scripts/copy-data.mjs).
|
|
138
|
+
return resolve(here, '..', 'data', 'personas', 'registry.json');
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Read and validate a persona registry.
|
|
142
|
+
*
|
|
143
|
+
* Throws — rather than degrading to an empty registry — because the engine's
|
|
144
|
+
* own registry going missing is a build/packaging fault, and a silently empty
|
|
145
|
+
* one would make every resolution return the same fallback while reporting
|
|
146
|
+
* nothing. Overlay registries take the forgiving path instead; see
|
|
147
|
+
* {@link readOverlayPersonaLayers}.
|
|
148
|
+
*/
|
|
149
|
+
function loadPersonaRegistry(registryPath = defaultPersonaRegistryPath()) {
|
|
150
|
+
let text;
|
|
151
|
+
try {
|
|
152
|
+
text = readFileSync(registryPath, 'utf-8');
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
// `String(err)` rather than `err.message`: both throwers here raise real
|
|
156
|
+
// Errors, so an `instanceof` guard would only add an unreachable branch.
|
|
157
|
+
throw new Error(`could not read persona registry ${registryPath}: ${String(err)}`);
|
|
158
|
+
}
|
|
159
|
+
let raw;
|
|
160
|
+
try {
|
|
161
|
+
raw = JSON.parse(text);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
throw new Error(`persona registry ${registryPath} is not JSON: ${String(err)}`);
|
|
165
|
+
}
|
|
166
|
+
if (!isRecord(raw)) {
|
|
167
|
+
throw new Error(`persona registry ${registryPath} must be a JSON object, not an array` +
|
|
168
|
+
' or scalar');
|
|
169
|
+
}
|
|
170
|
+
const version = typeof raw['version'] === 'number' ? raw['version'] : 1;
|
|
171
|
+
const fallback = typeof raw['fallback'] === 'string' ? raw['fallback'] : '';
|
|
172
|
+
const floor = raw['minDetectionConfidence'];
|
|
173
|
+
const signalFloor = raw['minDetectionSignals'];
|
|
174
|
+
return {
|
|
175
|
+
version,
|
|
176
|
+
fallback,
|
|
177
|
+
minDetectionConfidence: typeof floor === 'number' ? floor : DEFAULT_MIN_DETECTION_CONFIDENCE,
|
|
178
|
+
minDetectionSignals: typeof signalFloor === 'number'
|
|
179
|
+
? signalFloor
|
|
180
|
+
: DEFAULT_MIN_DETECTION_SIGNALS,
|
|
181
|
+
detectionMap: parseDetectionMap(raw['detectionMap']),
|
|
182
|
+
personas: parsePersonas(raw['personas']),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Lookup
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
/**
|
|
189
|
+
* The distinct *kinds* of evidence in a detector signal list.
|
|
190
|
+
*
|
|
191
|
+
* `detectUserLevel` emits signals shaped `"<kind>: <detail>"` — for example
|
|
192
|
+
* `"code/test file open: a.ts"`, `"project manifest present: package.json"` —
|
|
193
|
+
* plus one kindless string (`"cwd path suggests manual testing"`), which is
|
|
194
|
+
* treated as its own kind. Grouping on the part before the first `": "` is what
|
|
195
|
+
* makes ten open TypeScript files count once: they are one observation restated,
|
|
196
|
+
* and a raw length check would have read them as ten independent facts.
|
|
197
|
+
*
|
|
198
|
+
* Splitting on `": "` rather than `":"` keeps a Windows path (`C:\src\a.ts`)
|
|
199
|
+
* from being mistaken for a separator.
|
|
200
|
+
*/
|
|
201
|
+
function independentSignalKinds(signals) {
|
|
202
|
+
const kinds = new Set();
|
|
203
|
+
for (const signal of signals) {
|
|
204
|
+
const at = signal.indexOf(': ');
|
|
205
|
+
kinds.add(at === -1 ? signal : signal.slice(0, at));
|
|
206
|
+
}
|
|
207
|
+
return [...kinds];
|
|
208
|
+
}
|
|
209
|
+
/** Ids in registry order — the discoverable persona vocabulary. */
|
|
210
|
+
function personaIds(registry) {
|
|
211
|
+
return registry.personas.map((p) => p.id);
|
|
212
|
+
}
|
|
213
|
+
/** The persona with this id (case-insensitive), or null. */
|
|
214
|
+
function findPersona(registry, id) {
|
|
215
|
+
if (typeof id !== 'string')
|
|
216
|
+
return null;
|
|
217
|
+
const want = id.trim().toLowerCase();
|
|
218
|
+
if (want === '')
|
|
219
|
+
return null;
|
|
220
|
+
return registry.personas.find((p) => p.id.toLowerCase() === want) ?? null;
|
|
221
|
+
}
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Resolution
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
function fallbackPersona(registry) {
|
|
226
|
+
const named = findPersona(registry, registry.fallback);
|
|
227
|
+
if (named)
|
|
228
|
+
return named;
|
|
229
|
+
const first = registry.personas[0];
|
|
230
|
+
if (first)
|
|
231
|
+
return first;
|
|
232
|
+
// Never invent one. An empty registry is the denominator-zero case: the
|
|
233
|
+
// caller asked for an audience and there is no vocabulary to answer from.
|
|
234
|
+
throw new Error('persona registry defines no personas, so no persona can be resolved');
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Choose a persona and say where the choice came from.
|
|
238
|
+
*
|
|
239
|
+
* Precedence, highest first: an explicit id, then a detected level whose
|
|
240
|
+
* confidence clears the registry floor, then the fallback. An explicit id that
|
|
241
|
+
* names nothing does **not** suppress the rest of the cascade — a typo is a
|
|
242
|
+
* mistake, not an instruction to ignore the signal — but it is always named in
|
|
243
|
+
* the reason, so the mistake surfaces instead of being swallowed.
|
|
244
|
+
*/
|
|
245
|
+
export function resolvePersona(input = {}) {
|
|
246
|
+
// `exactOptionalPropertyTypes` forbids passing an explicit `undefined`, so
|
|
247
|
+
// omit the key rather than forwarding it.
|
|
248
|
+
const registry = input.registry ??
|
|
249
|
+
effectivePersonaRegistry(input.home === undefined ? {} : { home: input.home });
|
|
250
|
+
const signals = [...(input.detected?.signals ?? [])];
|
|
251
|
+
const notes = [];
|
|
252
|
+
const explicit = input.explicit;
|
|
253
|
+
if (typeof explicit === 'string' && explicit.trim() !== '') {
|
|
254
|
+
const chosen = findPersona(registry, explicit);
|
|
255
|
+
if (chosen) {
|
|
256
|
+
return {
|
|
257
|
+
persona: chosen,
|
|
258
|
+
source: 'explicit',
|
|
259
|
+
reason: `explicit persona '${chosen.id}'`,
|
|
260
|
+
signals,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
notes.push(uncertainDetectionMessage('persona', {
|
|
264
|
+
reason: `'${explicit.trim()}' is not a known persona`,
|
|
265
|
+
candidates: personaIds(registry),
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
const detected = input.detected;
|
|
269
|
+
if (detected) {
|
|
270
|
+
const floor = registry.minDetectionConfidence;
|
|
271
|
+
const signalFloor = registry.minDetectionSignals;
|
|
272
|
+
const kinds = independentSignalKinds(signals);
|
|
273
|
+
const target = registry.detectionMap[detected.level];
|
|
274
|
+
const mapped = findPersona(registry, target);
|
|
275
|
+
if (!mapped) {
|
|
276
|
+
// Reported ahead of the signal count on purpose: an unmapped level is a
|
|
277
|
+
// vocabulary problem, and "open more files" would be the wrong next step.
|
|
278
|
+
notes.push(`detected user level '${detected.level}' maps to no persona, so the` +
|
|
279
|
+
' fallback applies');
|
|
280
|
+
}
|
|
281
|
+
else if (kinds.length < signalFloor) {
|
|
282
|
+
// Named as a count rather than as "no signal": a reader has to be able to
|
|
283
|
+
// tell "I looked and found too little" from "I found nothing", because
|
|
284
|
+
// only one of those is fixed by giving the detector more to look at.
|
|
285
|
+
notes.push(`detected '${detected.level}' from ${kinds.length} independent ` +
|
|
286
|
+
`signal(s) (${signalFloor} required), so the fallback applies`);
|
|
287
|
+
}
|
|
288
|
+
else if (detected.confidence < floor) {
|
|
289
|
+
notes.push(`detected '${detected.level}' at confidence ` +
|
|
290
|
+
`${detected.confidence} is below the ${floor} floor, so the` +
|
|
291
|
+
' fallback applies');
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
notes.push(`detected user level '${detected.level}' at confidence ` +
|
|
295
|
+
`${detected.confidence}`);
|
|
296
|
+
return {
|
|
297
|
+
persona: mapped,
|
|
298
|
+
source: 'detected',
|
|
299
|
+
reason: notes.join('; '),
|
|
300
|
+
signals,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const persona = fallbackPersona(registry);
|
|
305
|
+
notes.push(`fell back to '${persona.id}'`);
|
|
306
|
+
return { persona, source: 'fallback', reason: notes.join('; '), signals };
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* A JSON-serialisable view for embedding in an MCP response.
|
|
310
|
+
*
|
|
311
|
+
* Flat rather than nested, and it carries `source`, `reason`, and `signals`
|
|
312
|
+
* beside the definition: a consumer that adapts its output owes the user an
|
|
313
|
+
* answer to "why did you decide I was that?".
|
|
314
|
+
*/
|
|
315
|
+
export function personaToDict(resolved) {
|
|
316
|
+
const { persona, source, reason, signals } = resolved;
|
|
317
|
+
return {
|
|
318
|
+
id: persona.id,
|
|
319
|
+
label: persona.label,
|
|
320
|
+
audience: persona.audience,
|
|
321
|
+
depth: persona.depth,
|
|
322
|
+
formats: [...persona.formats],
|
|
323
|
+
reasoning: persona.reasoning,
|
|
324
|
+
source,
|
|
325
|
+
reason,
|
|
326
|
+
signals: [...signals],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Overlay extension (#333 precedence)
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
/**
|
|
333
|
+
* Read each installed overlay's `.canary/personas.json`.
|
|
334
|
+
*
|
|
335
|
+
* Forgiving by design: a missing file means the overlay simply has no opinion,
|
|
336
|
+
* and an unreadable or malformed one is skipped rather than fatal, because a
|
|
337
|
+
* downstream overlay must not be able to break the engine's own vocabulary.
|
|
338
|
+
*
|
|
339
|
+
* The clone path comes from `resolveOverlay` rather than being rebuilt here, so
|
|
340
|
+
* `~/.canary/overlays/<name>` stays defined in exactly one place.
|
|
341
|
+
*/
|
|
342
|
+
function readOverlayPersonaLayers(home) {
|
|
343
|
+
const precedence = registryPrecedence(home);
|
|
344
|
+
const layers = [];
|
|
345
|
+
for (const overlay of listOverlays(home)) {
|
|
346
|
+
const path = join(resolveOverlay(overlay, home), '.canary', 'personas.json');
|
|
347
|
+
if (!existsSync(path))
|
|
348
|
+
continue;
|
|
349
|
+
let raw;
|
|
350
|
+
try {
|
|
351
|
+
raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (!isRecord(raw))
|
|
357
|
+
continue;
|
|
358
|
+
const fallback = raw['fallback'];
|
|
359
|
+
layers.push({
|
|
360
|
+
overlay,
|
|
361
|
+
precedence: precedence[overlay] ?? 0,
|
|
362
|
+
personas: parsePersonas(raw['personas']),
|
|
363
|
+
fallback: typeof fallback === 'string' ? fallback : null,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return layers;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Fold overlay layers onto the base registry.
|
|
370
|
+
*
|
|
371
|
+
* Layers are applied in ascending `(precedence, overlay-name)` order and the
|
|
372
|
+
* last writer wins — byte-for-byte the rule `skill-registry.ts` uses for
|
|
373
|
+
* skill-name collisions, so a downstream operator who has already reasoned
|
|
374
|
+
* about which overlay wins a skill does not have to learn a second model for
|
|
375
|
+
* personas. An id already in the base is replaced **in place**, so extending
|
|
376
|
+
* the vocabulary never reorders it.
|
|
377
|
+
*/
|
|
378
|
+
function mergePersonaRegistries(base, layers) {
|
|
379
|
+
const ordered = [...layers].sort((a, b) => a.precedence - b.precedence || a.overlay.localeCompare(b.overlay));
|
|
380
|
+
const personas = [...base.personas];
|
|
381
|
+
let fallback = base.fallback;
|
|
382
|
+
for (const layer of ordered) {
|
|
383
|
+
for (const persona of layer.personas) {
|
|
384
|
+
const at = personas.findIndex((p) => p.id.toLowerCase() === persona.id.toLowerCase());
|
|
385
|
+
if (at >= 0)
|
|
386
|
+
personas[at] = persona;
|
|
387
|
+
else
|
|
388
|
+
personas.push(persona);
|
|
389
|
+
}
|
|
390
|
+
if (layer.fallback !== null)
|
|
391
|
+
fallback = layer.fallback;
|
|
392
|
+
}
|
|
393
|
+
const merged = { ...base, personas, fallback };
|
|
394
|
+
// A fallback naming nothing is worse than no opinion: it would silently
|
|
395
|
+
// demote resolution to "first persona in the list".
|
|
396
|
+
if (!findPersona(merged, merged.fallback))
|
|
397
|
+
merged.fallback = base.fallback;
|
|
398
|
+
return merged;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The registry a resolution actually sees: shipped, plus every overlay.
|
|
402
|
+
*
|
|
403
|
+
* This is the function that makes overlay extension real rather than
|
|
404
|
+
* decorative. {@link resolvePersona} calls it whenever the caller does not
|
|
405
|
+
* hand over a registry outright, so a downstream `.canary/personas.json` reaches
|
|
406
|
+
* every consumer without each one remembering to merge — which is the mistake
|
|
407
|
+
* #341 is a record of: a signal that was produced correctly and read by nobody.
|
|
408
|
+
*
|
|
409
|
+
* Re-read per call rather than cached. `analyze_file` already scans the project
|
|
410
|
+
* tree, so one small JSON read plus a directory listing is not the cost worth
|
|
411
|
+
* trading a stale-config bug for.
|
|
412
|
+
*
|
|
413
|
+
* `registryPath` points the base registry somewhere other than the shipped
|
|
414
|
+
* file. It is the whole public seam onto the loader: the parsing and merging
|
|
415
|
+
* helpers below are intentionally module-private, so this is how both a caller
|
|
416
|
+
* with its own registry and a test exercising a malformed one get in.
|
|
417
|
+
*/
|
|
418
|
+
export function effectivePersonaRegistry(options = {}) {
|
|
419
|
+
return mergePersonaRegistries(loadPersonaRegistry(options.registryPath), readOverlayPersonaLayers(options.home));
|
|
420
|
+
}
|
|
421
|
+
//# sourceMappingURL=persona.js.map
|