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.
Files changed (122) hide show
  1. package/agents/skills/README.md +308 -0
  2. package/agents/skills/canary:generate.md +49 -0
  3. package/agents/skills/canary:init.md +37 -0
  4. package/agents/skills/canary:migrate.md +66 -0
  5. package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
  6. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  7. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  11. package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
  12. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  14. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
  15. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  16. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  17. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  18. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  19. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  21. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  22. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  27. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  28. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  29. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
  30. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  31. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  32. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  37. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  38. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  42. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  44. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  45. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  46. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  52. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  53. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  54. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  55. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  56. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  57. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  58. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  59. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  60. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  61. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  62. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  63. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  64. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  65. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  66. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  67. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  68. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  69. package/agents/skills/lib/parse-args.mjs +214 -0
  70. package/dist/engine/analysis/cli.js +116 -54
  71. package/dist/engine/analysis/engine.js +34 -16
  72. package/dist/engine/analysis/reports.js +5 -4
  73. package/dist/engine/cli-commands.js +252 -106
  74. package/dist/engine/cli-common.js +15 -24
  75. package/dist/engine/cli.core.js +37 -11
  76. package/dist/engine/cli.js +2 -2
  77. package/dist/engine/company-knowledge-cli.js +2 -2
  78. package/dist/engine/core/adoption.js +408 -0
  79. package/dist/engine/core/framework-probes.js +7 -7
  80. package/dist/engine/core/fs-glob.js +2 -2
  81. package/dist/engine/core/gate-result.js +17 -0
  82. package/dist/engine/core/migrator.js +9 -17
  83. package/dist/engine/core/pattern-matcher.js +23 -5
  84. package/dist/engine/core/persona.js +421 -0
  85. package/dist/engine/core/promotion-verdict.js +261 -0
  86. package/dist/engine/core/quality-scorer.js +15 -2
  87. package/dist/engine/core/reporter.js +1 -9
  88. package/dist/engine/core/skill-dispatch.js +115 -0
  89. package/dist/engine/core/skill-examples.js +392 -0
  90. package/dist/engine/core/skill-registry.js +59 -4
  91. package/dist/engine/core/skill-surfaces.js +307 -0
  92. package/dist/engine/core/static-linter.js +310 -38
  93. package/dist/engine/core/test-files.js +77 -0
  94. package/dist/engine/core/ticket-updater.js +1 -7
  95. package/dist/engine/core/vacuity-scanner.js +726 -0
  96. package/dist/engine/core/workflow-discovery.js +2 -8
  97. package/dist/engine/core/workspace-detect.js +7 -6
  98. package/dist/engine/data/personas/registry.json +36 -0
  99. package/dist/engine/guardian/adjudication.js +5 -5
  100. package/dist/engine/guardian/analysis-emit.js +19 -28
  101. package/dist/engine/guardian/cli.js +150 -51
  102. package/dist/engine/guardian/coverage.js +1 -1
  103. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  104. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  105. package/dist/engine/guardian/pr-check.js +322 -33
  106. package/dist/engine/guardian/pr-comment.js +4 -3
  107. package/dist/engine/history/cli.js +210 -6
  108. package/dist/engine/history/ndjson-store.js +9 -5
  109. package/dist/engine/history/record.js +34 -5
  110. package/dist/engine/history/run-recorder.js +165 -0
  111. package/dist/engine/history/schema.js +25 -7
  112. package/dist/engine/history/store.js +9 -0
  113. package/dist/engine/mcp-server.js +35 -13
  114. package/dist/engine/skills-cli.js +249 -23
  115. package/dist/engine/util/ensure-ascii.js +37 -0
  116. package/dist/engine/workflow-cli.js +6 -6
  117. package/dist/gate-result.d.ts +11 -0
  118. package/dist/gate-result.js +18 -0
  119. package/dist/reporters/testtracker.d.ts +1 -1
  120. package/dist/reporters/testtracker.js +1 -1
  121. package/dist/uninstall.js +12 -5
  122. 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 { _CONFIG_PROBES, inferPlaywrightShape, probe, } from './framework-probes.js';
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
- export const KNOWN_FRAMEWORKS = [
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 = _CONFIG_PROBES
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 [, probeFramework, shape] of _CONFIG_PROBES) {
351
- if (probeFramework !== framework)
342
+ for (const [, candidateFramework, shape] of CONFIG_PROBES) {
343
+ if (candidateFramework !== framework)
352
344
  continue;
353
- if (probeFramework === 'playwright' && shape === 'e2e_ui') {
354
- return inferPlaywrightShape(root);
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 probe(root, config, ['config', 'content', 'language']);
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: ['**/*.spec.ts', '**/*.spec.js', '**/*.test.ts', '**/*.test.js'],
13
- vitest: ['**/*.test.ts', '**/*.test.js', '**/*.spec.ts', '**/*.spec.js'],
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: ['**/*.spec.ts', '**/*.spec.js'],
17
- frontend_unit: ['**/*.test.ts', '**/*.test.js'],
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', '**/*.spec.ts', '**/*.test.ts'];
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