canary-test-cli 5.15.0 → 6.0.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 (79) hide show
  1. package/agent/frameworks/registry.json +655 -0
  2. package/bin/canary.js +20 -15
  3. package/dist/doctor-manifest.d.ts +94 -0
  4. package/dist/doctor.d.ts +67 -0
  5. package/dist/engine/analysis/cli.js +270 -0
  6. package/dist/engine/analysis/engine.js +146 -0
  7. package/dist/engine/analysis/reports.js +0 -0
  8. package/dist/engine/analysis/rows.js +9 -0
  9. package/dist/engine/cli-commands.js +618 -0
  10. package/dist/engine/cli-common.js +60 -0
  11. package/dist/engine/cli.core.js +208 -0
  12. package/dist/engine/cli.js +31 -0
  13. package/dist/engine/company-knowledge-cli.js +201 -0
  14. package/dist/engine/core/ci-env.js +33 -0
  15. package/dist/engine/core/classifier.js +192 -0
  16. package/dist/engine/core/company-knowledge.js +765 -0
  17. package/dist/engine/core/config-validation.js +74 -0
  18. package/dist/engine/core/detection.js +48 -0
  19. package/dist/engine/core/domain-scanner.js +212 -0
  20. package/dist/engine/core/environment-detect.js +410 -0
  21. package/dist/engine/core/executor.js +181 -0
  22. package/dist/engine/core/feedback.js +93 -0
  23. package/dist/engine/core/fixture-scanner.js +173 -0
  24. package/dist/engine/core/framework-registry.js +123 -0
  25. package/dist/engine/core/mcp-validator.js +218 -0
  26. package/dist/engine/core/metadata-scanner.js +147 -0
  27. package/dist/engine/core/migrator.js +1112 -0
  28. package/dist/engine/core/overlays.js +176 -0
  29. package/dist/engine/core/pattern-healer.js +147 -0
  30. package/dist/engine/core/pattern-matcher.js +255 -0
  31. package/dist/engine/core/quality-scorer.js +213 -0
  32. package/dist/engine/core/recommender.js +152 -0
  33. package/dist/engine/core/reporter.js +211 -0
  34. package/dist/engine/core/scaffolder.js +236 -0
  35. package/dist/engine/core/skill-registry.js +522 -0
  36. package/dist/engine/core/static-linter.js +237 -0
  37. package/dist/engine/core/ticket-updater.js +639 -0
  38. package/dist/engine/core/workflow-discovery.js +693 -0
  39. package/dist/engine/guardian/agent-tier.js +338 -0
  40. package/dist/engine/guardian/analysis-emit.js +201 -0
  41. package/dist/engine/guardian/cli.js +787 -0
  42. package/dist/engine/guardian/coverage.js +1055 -0
  43. package/dist/engine/guardian/delta-emitter.js +46 -0
  44. package/dist/engine/guardian/diff-extractor.js +257 -0
  45. package/dist/engine/guardian/hard-gate.js +373 -0
  46. package/dist/engine/guardian/impact-mapper.js +121 -0
  47. package/dist/engine/guardian/pr-check.js +975 -0
  48. package/dist/engine/guardian/pr-comment.js +200 -0
  49. package/dist/engine/guardian/summary-emitter.js +94 -0
  50. package/dist/engine/guardian/tier.js +58 -0
  51. package/dist/engine/history/cli.js +303 -0
  52. package/dist/engine/history/detector.js +68 -0
  53. package/dist/engine/history/ndjson-store.js +177 -0
  54. package/dist/engine/history/record.js +14 -0
  55. package/dist/engine/history/schema.js +59 -0
  56. package/dist/engine/history/store.js +47 -0
  57. package/dist/engine/history/supabase-store.js +113 -0
  58. package/dist/engine/main-deps.js +105 -0
  59. package/dist/engine/mcp-server.js +647 -0
  60. package/dist/engine/package.json +4 -0
  61. package/dist/engine/skills-cli.js +181 -0
  62. package/dist/engine/ui/banner.js +50 -0
  63. package/dist/engine/util/coalesce.js +12 -0
  64. package/dist/engine/util/round.js +43 -0
  65. package/dist/engine/workflow-cli.js +242 -0
  66. package/dist/engine-checks.d.ts +49 -0
  67. package/dist/overlay-commands.d.ts +81 -0
  68. package/dist/overlay-conflicts.d.ts +33 -0
  69. package/dist/overlay-lint.d.ts +19 -0
  70. package/dist/overlays-registry.d.ts +74 -0
  71. package/dist/reporters/testtracker.d.ts +89 -0
  72. package/dist/reporters/testtracker.js +195 -0
  73. package/dist/router.d.ts +12 -0
  74. package/dist/router.js +4 -4
  75. package/dist/skill-requirements.d.ts +57 -0
  76. package/dist/source-spec.d.ts +20 -0
  77. package/package.json +30 -6
  78. package/bin/canary +0 -0
  79. package/scripts/install.js +0 -104
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `canary skills` sub-app -- faithful port of the `skills_app` commands in
3
+ * `agent/cli.py` (`list`, `run`), wired to the already-ported `SkillRegistry`.
4
+ *
5
+ * INTENTIONAL DEVIATION (`skills run`, entry-target branch): Python's `entry:`
6
+ * skills load a Python `module:callable` via `importlib` and invoke it in-process
7
+ * -- there is no portable Node equivalent. The `cli:` branch (spawn a `.py`/`.js`
8
+ * target, the path skills actually use going forward) is ported faithfully; the
9
+ * `entry:` branch preserves the Python exit-code ladder (bad format -> 5,
10
+ * load/attr failure -> 6) over a dynamic `import()`, which will not resolve a
11
+ * Python module. No Python test exercises the cli/entry execution branches.
12
+ */
13
+ import { Command } from 'commander';
14
+ import pc from 'picocolors';
15
+ import { CliExit, normalizeUsageExit } from './cli-common.js';
16
+ import { isExecutableSkillAllowed, resolveCliPath, } from './core/skill-registry.js';
17
+ import { CROSS, EM_DASH } from './main-deps.js';
18
+ function overlayName(skill) {
19
+ // Clone layout: ~/.canary/overlays/<overlay>/.canary/skills/<name>/SKILL.md
20
+ const parts = skill.path.split(/[\\/]/);
21
+ const i = parts.indexOf('overlays');
22
+ return i >= 0 && i + 1 < parts.length ? parts[i + 1] : '?';
23
+ }
24
+ function formatSkill(skill, verbose) {
25
+ // rich rendered `\[cli]` etc. as literal "[cli]" (the backslash escapes the
26
+ // markup); the equivalent picocolors output is the literal bracket text.
27
+ let marker = '';
28
+ if (skill.error)
29
+ marker = ' [error]';
30
+ else if (skill.cli)
31
+ marker = ' [cli]';
32
+ else if (skill.entry)
33
+ marker = ' [entry]';
34
+ const desc = skill.description ? ` ${skill.description}` : '';
35
+ let line = ` /${skill.name}${marker}${desc}`;
36
+ if (verbose)
37
+ line += `\n ${pc.dim(skill.path)}`;
38
+ return line;
39
+ }
40
+ function listCmd(opts, deps) {
41
+ const verbose = opts.verbose ?? false;
42
+ const skills = deps.makeSkillRegistry().discover();
43
+ if (skills.length === 0) {
44
+ deps.out(pc.yellow('No skills found.'));
45
+ return;
46
+ }
47
+ const bundled = skills.filter((s) => s.source === 'bundled');
48
+ const overlay = skills.filter((s) => s.source === 'overlay');
49
+ const globalSkills = skills.filter((s) => s.source === 'global');
50
+ const local = skills.filter((s) => s.source === 'local');
51
+ if (bundled.length) {
52
+ deps.out(pc.bold('Bundled skills:'));
53
+ for (const skill of bundled)
54
+ deps.out(formatSkill(skill, verbose));
55
+ }
56
+ if (overlay.length) {
57
+ const sorted = [...overlay].sort((a, b) => overlayName(a).localeCompare(overlayName(b)));
58
+ let idx = 0;
59
+ let cur = null;
60
+ for (const skill of sorted) {
61
+ const oname = overlayName(skill);
62
+ if (oname !== cur) {
63
+ if (bundled.length || idx > 0)
64
+ deps.out('');
65
+ deps.out(`${pc.bold('Overlay skills')} ${pc.dim(`(${oname} ${EM_DASH} override bundled):`)}`);
66
+ cur = oname;
67
+ idx += 1;
68
+ }
69
+ deps.out(formatSkill(skill, verbose));
70
+ }
71
+ }
72
+ if (globalSkills.length) {
73
+ if (bundled.length || overlay.length)
74
+ deps.out('');
75
+ deps.out(`${pc.bold('Global skills')} ${pc.dim(`(~/.canary/skills/ ${EM_DASH} override overlay):`)}`);
76
+ for (const skill of globalSkills)
77
+ deps.out(formatSkill(skill, verbose));
78
+ }
79
+ if (local.length) {
80
+ if (bundled.length || overlay.length || globalSkills.length)
81
+ deps.out('');
82
+ deps.out(`${pc.bold('Local overlay skills')} ${pc.dim('(override global):')}`);
83
+ for (const skill of local)
84
+ deps.out(formatSkill(skill, verbose));
85
+ }
86
+ }
87
+ async function runCmd(name, args, opts, deps) {
88
+ const skill = deps.makeSkillRegistry().find(name);
89
+ if (skill === null) {
90
+ deps.out(`${pc.red(CROSS)} No skill named ${pc.bold(name)} found.`);
91
+ throw new CliExit(1);
92
+ }
93
+ if (skill.error) {
94
+ deps.out(`${pc.red(CROSS)} Skill ${pc.bold(name)}: ${skill.error}`);
95
+ throw new CliExit(2);
96
+ }
97
+ if (!skill.isExecutable) {
98
+ deps.out(pc.yellow(`Skill ${pc.bold(name)} is markdown-only ${EM_DASH} no cli: or entry: field to run.`));
99
+ throw new CliExit(2);
100
+ }
101
+ if (!isExecutableSkillAllowed(opts.allowExecutableSkills ?? false)) {
102
+ deps.out(`${pc.red(CROSS)} Refusing to invoke executable skill in non-interactive context. Pass ${pc.bold('--allow-executable-skills')} to opt in (e.g. in trusted CI configurations).`);
103
+ throw new CliExit(3);
104
+ }
105
+ const forwarded = args;
106
+ if (skill.cli) {
107
+ let target;
108
+ try {
109
+ target = resolveCliPath(skill);
110
+ }
111
+ catch (exc) {
112
+ deps.out(`${pc.red(CROSS)} ${exc instanceof Error ? exc.message : String(exc)}`);
113
+ throw new CliExit(4);
114
+ }
115
+ const cmd = target.endsWith('.py') ? [deps.pythonExe(), target] : [target];
116
+ const res = deps.runSubprocess(cmd[0], [...cmd.slice(1), ...forwarded], {
117
+ cwd: skill.dir,
118
+ inherit: true,
119
+ });
120
+ // A spawn failure (missing interpreter/binary) yields status=null; Python's
121
+ // subprocess.run raises FileNotFoundError -> nonzero exit. Map null -> 1, not
122
+ // a silent 0. A normal run passes its real exit code through.
123
+ throw new CliExit(res.status ?? 1);
124
+ }
125
+ // entry: branch -- see module docstring (Python-module semantics are not
126
+ // portable; the exit-code ladder is preserved).
127
+ const [moduleName, sep, attr] = partition(skill.entry, ':');
128
+ if (!moduleName || !attr || !sep) {
129
+ deps.out(`${pc.red(CROSS)} Skill ${pc.bold(name)} entry must be 'module:callable', got '${skill.entry}'`);
130
+ throw new CliExit(5);
131
+ }
132
+ try {
133
+ const mod = (await import(moduleName));
134
+ const target = mod[attr];
135
+ if (typeof target !== 'function')
136
+ throw new Error('not callable');
137
+ const rc = target();
138
+ throw new CliExit(typeof rc === 'number' ? rc : 0);
139
+ }
140
+ catch (exc) {
141
+ if (exc instanceof CliExit)
142
+ throw exc;
143
+ deps.out(`${pc.red(CROSS)} Skill ${pc.bold(name)} entry '${skill.entry}': ${exc instanceof Error ? exc.message : String(exc)}`);
144
+ throw new CliExit(6);
145
+ }
146
+ }
147
+ /** Python `str.partition(sep)` -> `[before, sep, after]`. */
148
+ function partition(s, sep) {
149
+ const i = s.indexOf(sep);
150
+ if (i < 0)
151
+ return [s, '', ''];
152
+ return [s.slice(0, i), sep, s.slice(i + sep.length)];
153
+ }
154
+ /** Build the `skills` sub-app wired to `deps`. */
155
+ export function buildSkillsCommand(deps) {
156
+ const program = new Command('skills');
157
+ program
158
+ .description('List and invoke discoverable Canary skills.')
159
+ .exitOverride(normalizeUsageExit);
160
+ program
161
+ .command('list')
162
+ .description('List every skill discoverable from the current directory.')
163
+ .option('-v, --verbose', 'Also print the SKILL.md path for each skill.')
164
+ .action((opts) => {
165
+ listCmd(opts, deps);
166
+ });
167
+ program
168
+ .command('run')
169
+ .description("Invoke a code-bearing skill's declared cli or entry target.")
170
+ .argument('<name>', 'Name of the skill to invoke.')
171
+ .argument('[args...]', "Arguments forwarded to the skill's cli/entry.")
172
+ .option('--allow-executable-skills', 'Opt-in to invoking cli:/entry: skills in non-interactive (CI) contexts.')
173
+ .action(async (name, args, opts) => {
174
+ await runCmd(name, args ?? [], opts, deps);
175
+ });
176
+ for (const sub of program.commands) {
177
+ sub.exitOverride(normalizeUsageExit);
178
+ }
179
+ return program;
180
+ }
181
+ //# sourceMappingURL=skills-cli.js.map
@@ -0,0 +1,50 @@
1
+ /**
2
+ * CLI startup banner -- faithful TypeScript port of `agent/ui/banner.py`.
3
+ *
4
+ * The Python banner writes raw ANSI escape codes with `print()` (NOT rich), so
5
+ * it never strips color for a non-TTY sink -- the bytes are identical whether
6
+ * stdout is a terminal or a captured test buffer. This port reproduces those
7
+ * exact bytes: the ESC byte (Python `\033`) as `\u{1b}`, the SGR sequences
8
+ * verbatim, and the box-drawing glyphs as `\u{...}` escapes (ASCII-source rule).
9
+ *
10
+ * Only `renderBanner` is ported -- it is all the CLI's `version` command and
11
+ * `--version` option need. `print_result_line` / `print_section` are unused by
12
+ * the CLI and intentionally omitted.
13
+ */
14
+ const ESC = '\u{1b}';
15
+ const RESET = `${ESC}[0m`;
16
+ const BOLD = `${ESC}[1m`;
17
+ // Canary gold (#F0C040) and the supporting palette (true-color SGR).
18
+ const GOLD = `${ESC}[38;2;240;192;64m`;
19
+ const AMBER = `${ESC}[38;2;192;144;24m`;
20
+ const WHITE = `${ESC}[38;2;245;245;245m`;
21
+ const MUTED = `${ESC}[38;2;85;85;85m`;
22
+ const DARK = `${ESC}[38;2;46;46;46m`;
23
+ // Bird mark (3-line ASCII-art): U+25B2 up-triangle, U+2588 full block,
24
+ // U+2580 upper-half block.
25
+ const BIRD = [
26
+ `${GOLD} \u{25b2}${RESET}`,
27
+ `${GOLD} \u{25b2}\u{2588}\u{25b2}${RESET}`,
28
+ `${AMBER} \u{2580}${RESET}`,
29
+ ];
30
+ const TAGLINE = 'test automation agent';
31
+ // U+00B7 middle dot separator.
32
+ const NETWORK = 'birds of prey network \u{00b7} clocktower voice system';
33
+ const RULE = '\u{2500}'.repeat(44); // U+2500 box drawings light horizontal
34
+ /**
35
+ * Render the Canary startup banner as a single string (no trailing newline;
36
+ * the caller's line sink adds one, matching Python `print(banner)`).
37
+ */
38
+ export function renderBanner(version) {
39
+ const divider = `${DARK}${RULE}${RESET}`;
40
+ const lines = [
41
+ '',
42
+ ` ${BIRD[0]} ${BOLD}${WHITE}canary${RESET} ${GOLD}v${version}${RESET}`,
43
+ ` ${BIRD[1]} ${MUTED}${TAGLINE}${RESET}`,
44
+ ` ${BIRD[2]} ${DARK}${NETWORK}${RESET}`,
45
+ ` ${divider}`,
46
+ '',
47
+ ];
48
+ return lines.join('\n');
49
+ }
50
+ //# sourceMappingURL=banner.js.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Nullish-default helper.
3
+ *
4
+ * `def(x, fallback)` is exactly `x ?? fallback`, but as a function call it is not
5
+ * a decision point — so replacing dense `??` fallback chains in field-mapper
6
+ * functions with `def(...)` keeps behaviour identical while lowering the
7
+ * cyclomatic complexity the arch ratchet measures.
8
+ */
9
+ export function def(value, fallback) {
10
+ return value ?? fallback;
11
+ }
12
+ //# sourceMappingURL=coalesce.js.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Python-compatible numeric rounding, shared by the history store (rate
3
+ * computation) and the report builders (display formatting).
4
+ */
5
+ /** Python-compatible round-half-to-even at 1 decimal place. */
6
+ export function round1(x) {
7
+ const scaled = x * 10;
8
+ const floor = Math.floor(scaled);
9
+ const diff = scaled - floor;
10
+ const eps = 1e-9;
11
+ let rounded;
12
+ if (Math.abs(diff - 0.5) < eps) {
13
+ // Exact .5 tie → round to even.
14
+ rounded = floor % 2 === 0 ? floor : floor + 1;
15
+ }
16
+ else {
17
+ rounded = Math.round(scaled);
18
+ }
19
+ return rounded / 10;
20
+ }
21
+ /** Render a 1-decimal-rounded number the way Python's `str(float)` would. */
22
+ export function num1(x) {
23
+ return round1(x).toFixed(1);
24
+ }
25
+ /** Python-compatible `round(x)` to the nearest integer, half-to-even. */
26
+ export function roundHalfEvenInt(x) {
27
+ const floor = Math.floor(x);
28
+ const diff = x - floor;
29
+ const eps = 1e-9;
30
+ if (Math.abs(diff - 0.5) < eps) {
31
+ return floor % 2 === 0 ? floor : floor + 1;
32
+ }
33
+ return Math.round(x);
34
+ }
35
+ /**
36
+ * Render a float the way Python's `str(float)` would: an integer-valued float
37
+ * keeps a trailing ".0" (Python `str(10.0)` → "10.0", whereas JS `${10.0}`
38
+ * yields "10"). Used for threshold parameters interpolated raw into headers.
39
+ */
40
+ export function pyFloat(x) {
41
+ return Number.isInteger(x) ? `${x}.0` : `${x}`;
42
+ }
43
+ //# sourceMappingURL=round.js.map
@@ -0,0 +1,242 @@
1
+ /**
2
+ * `canary workflow` sub-app -- faithful port of the `workflow_app` commands in
3
+ * `agent/cli.py` (`discover`, `show`, `init`), wired to the already-ported
4
+ * `WorkflowDiscovery` / `WorkflowMapping` / `SemanticRole`.
5
+ *
6
+ * `discover` is async (the discovery HTTP path returns Promises), so its handler
7
+ * is awaited. `show` / `init` are synchronous. Ticket keys, mapping paths, and
8
+ * the `.canary` dir all resolve from `process.cwd()` inside the injected
9
+ * `makeWorkflowDiscovery()` factory, so a test drives them by chdir-ing.
10
+ */
11
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { Command } from 'commander';
14
+ import pc from 'picocolors';
15
+ import { CliExit, jsonIndent2, normalizeUsageExit } from './cli-common.js';
16
+ import { SemanticRole, WorkflowDiscoveryError, WorkflowMapping, } from './core/workflow-discovery.js';
17
+ import { CHECK, CROSS, MAGNIFIER, WARN, ELLIPSIS, } from './main-deps.js';
18
+ /** ISO-8601 UTC timestamp truncated to seconds (Python `isoformat(timespec)`). */
19
+ function nowIsoCli() {
20
+ return new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00');
21
+ }
22
+ async function discoverCmd(opts, deps) {
23
+ const wd = deps.makeWorkflowDiscovery();
24
+ let keys = [];
25
+ if (opts.project) {
26
+ keys = [opts.project];
27
+ }
28
+ else {
29
+ const companyPath = join(deps.cwd(), '.canary', 'company.json');
30
+ if (existsSync(companyPath)) {
31
+ try {
32
+ const data = JSON.parse(readFileSync(companyPath, 'utf-8'));
33
+ keys = data.jira_projects ?? [];
34
+ }
35
+ catch {
36
+ // JSONDecodeError / OSError -> leave keys empty.
37
+ }
38
+ }
39
+ if (keys.length === 0) {
40
+ deps.out(`${pc.yellow('No project keys found.')} Pass ${pc.bold('--project <key>')} or add keys to ${pc.bold('.canary/company.json')} ${'\u{2192}'} ${pc.bold('jira_projects')}.`);
41
+ throw new CliExit(1);
42
+ }
43
+ }
44
+ const errors = [];
45
+ for (const key of keys) {
46
+ deps.out(`\n${pc.bold(pc.cyan(`${MAGNIFIER} Discovering workflow for ${key}${ELLIPSIS}`))}`);
47
+ let mapping;
48
+ try {
49
+ mapping = await wd.discover(key, {
50
+ refresh: opts.refresh ?? false,
51
+ dryRun: opts.dryRun ?? false,
52
+ });
53
+ }
54
+ catch (exc) {
55
+ if (exc instanceof WorkflowDiscoveryError) {
56
+ deps.out(`${pc.red(CROSS)} ${exc.message}`);
57
+ errors.push(key);
58
+ continue;
59
+ }
60
+ throw exc;
61
+ }
62
+ const nTypes = mapping.issue_types.length;
63
+ const nRoles = Object.keys(mapping.semantic_roles).length;
64
+ const confirmed = mapping.role_annotations_confirmed
65
+ ? `${CHECK} confirmed`
66
+ : `${WARN} unconfirmed`;
67
+ if (opts.dryRun) {
68
+ deps.out(`${pc.dim('(dry-run)')} ${mapping.toJson()}`);
69
+ }
70
+ else {
71
+ deps.out(`${pc.green(CHECK)} ${key}: ${nTypes} issue type(s), ${nRoles} semantic role(s) [${confirmed}]`);
72
+ if (!mapping.role_annotations_confirmed) {
73
+ deps.out(pc.dim(` Tip: verify role assignments with ${pc.bold(`canary workflow show --project ${key} --roles-only`)}`));
74
+ }
75
+ }
76
+ }
77
+ if (errors.length) {
78
+ deps.out(`\n${pc.red(`Discovery failed for: ${errors.join(', ')}`)}`);
79
+ throw new CliExit(1);
80
+ }
81
+ }
82
+ function showCmd(opts, deps) {
83
+ const wd = deps.makeWorkflowDiscovery();
84
+ let keys = [];
85
+ if (opts.project) {
86
+ keys = [opts.project];
87
+ }
88
+ else {
89
+ const canaryDir = join(deps.cwd(), '.canary');
90
+ if (existsSync(canaryDir)) {
91
+ try {
92
+ keys = readdirSync(canaryDir)
93
+ .filter((f) => f.startsWith('workflow-') && f.endsWith('.json'))
94
+ .map((f) => f.slice('workflow-'.length, -'.json'.length));
95
+ }
96
+ catch {
97
+ keys = [];
98
+ }
99
+ }
100
+ if (keys.length === 0) {
101
+ deps.out(pc.yellow('No cached workflow mappings found.'));
102
+ throw new CliExit(0);
103
+ }
104
+ }
105
+ let anyFound = false;
106
+ for (const key of keys) {
107
+ const mapping = wd.show(key);
108
+ if (mapping === null) {
109
+ deps.out(`${pc.yellow(`No cached mapping for ${key}.`)} Run ${pc.bold(`canary workflow discover --project ${key}`)} first.`);
110
+ continue;
111
+ }
112
+ anyFound = true;
113
+ if (opts.json) {
114
+ if (opts.rolesOnly) {
115
+ const rolesDict = {};
116
+ for (const [r, sr] of Object.entries(mapping.semantic_roles)) {
117
+ rolesDict[r] = {
118
+ status_name: sr.status_name,
119
+ issue_type: sr.issue_type,
120
+ };
121
+ }
122
+ deps.out(jsonIndent2(rolesDict));
123
+ }
124
+ else {
125
+ deps.out(mapping.toJson());
126
+ }
127
+ continue;
128
+ }
129
+ const confirmedTag = mapping.role_annotations_confirmed
130
+ ? pc.green('confirmed')
131
+ : pc.yellow('unconfirmed');
132
+ deps.out(`\n${pc.bold(key)} ${pc.dim(`source=${mapping.source} discovered=${mapping.discovered_at} roles=${confirmedTag}`)}`);
133
+ if (opts.rolesOnly) {
134
+ if (Object.keys(mapping.semantic_roles).length) {
135
+ deps.out(` ${pc.bold('Semantic roles:')}`);
136
+ for (const [role, sr] of Object.entries(mapping.semantic_roles)) {
137
+ deps.out(` ${role.padEnd(20)} ${'\u{2192}'} '${sr.status_name}' ${pc.dim(`(${sr.issue_type})`)}`);
138
+ }
139
+ }
140
+ else {
141
+ deps.out(` ${pc.yellow('No semantic roles resolved yet.')}`);
142
+ }
143
+ continue;
144
+ }
145
+ for (const it of mapping.issue_types) {
146
+ deps.out(`\n ${pc.bold(it.name)}`);
147
+ for (const s of it.statuses) {
148
+ deps.out(` [${s.category}] ${s.name}`);
149
+ }
150
+ if (it.transitions.length) {
151
+ deps.out(' Transitions:');
152
+ for (const t of it.transitions) {
153
+ deps.out(` ${t.from_status} ${'\u{2192}'} ${t.to_status} ${pc.dim(`(${t.name})`)}`);
154
+ }
155
+ }
156
+ }
157
+ if (Object.keys(mapping.semantic_roles).length) {
158
+ deps.out(`\n ${pc.bold('Semantic roles:')}`);
159
+ for (const [role, sr] of Object.entries(mapping.semantic_roles)) {
160
+ deps.out(` ${role.padEnd(20)} ${'\u{2192}'} '${sr.status_name}' ${pc.dim(`(${sr.issue_type})`)}`);
161
+ }
162
+ }
163
+ }
164
+ if (!anyFound) {
165
+ throw new CliExit(1);
166
+ }
167
+ }
168
+ function initCmd(opts, deps) {
169
+ const wd = deps.makeWorkflowDiscovery();
170
+ const mappingPath = wd.mappingPath(opts.project);
171
+ if (existsSync(mappingPath) && !opts.force) {
172
+ deps.out(`${pc.yellow(WARN)} Mapping already exists at ${mappingPath}.\nUse ${pc.bold('--force')} to overwrite.`);
173
+ throw new CliExit(1);
174
+ }
175
+ const resolvedUrl = (opts.atlassianUrl || deps.env['ATLASSIAN_URL'] || '').replace(/\/+$/, '') || null;
176
+ const semanticRoles = {
177
+ qa_passed: new SemanticRole(opts.qaPassed, 'Story'),
178
+ };
179
+ if (opts.inQa) {
180
+ semanticRoles['in_qa'] = new SemanticRole(opts.inQa, 'Story');
181
+ }
182
+ const mapping = new WorkflowMapping({
183
+ project_key: opts.project,
184
+ source: 'jira',
185
+ discovered_at: nowIsoCli(),
186
+ issue_types: [],
187
+ semantic_roles: semanticRoles,
188
+ role_annotations_confirmed: true,
189
+ atlassian_url: resolvedUrl,
190
+ });
191
+ wd.write(mapping);
192
+ deps.out(`${pc.green(CHECK)} Created ${mappingPath}`);
193
+ deps.out(` qa_passed ${'\u{2192}'} '${opts.qaPassed}'`);
194
+ if (opts.inQa) {
195
+ deps.out(` in_qa ${'\u{2192}'} '${opts.inQa}'`);
196
+ }
197
+ if (resolvedUrl) {
198
+ deps.out(` atlassian_url ${'\u{2192}'} ${resolvedUrl}`);
199
+ }
200
+ deps.out(`\n${pc.dim(`Verify with: ${pc.bold(`canary workflow show --project ${opts.project} --roles-only`)}`)}`);
201
+ }
202
+ /** Build the `workflow` sub-app wired to `deps`. */
203
+ export function buildWorkflowCommand(deps) {
204
+ const program = new Command('workflow');
205
+ program
206
+ .description('Discover and inspect per-project issue-workflow mappings.')
207
+ .exitOverride(normalizeUsageExit);
208
+ program
209
+ .command('discover')
210
+ .description('Discover the Jira or GitHub workflow for one or more projects and persist the mapping.')
211
+ .option('-p, --project <project>', 'Jira project key or GitHub repo slug. Defaults to company.json jira_projects.')
212
+ .option('--refresh', 'Re-discover even if a cached mapping already exists.')
213
+ .option('--dry-run', 'Print the mapping that would be written without writing it.')
214
+ .action(async (opts) => {
215
+ await discoverCmd(opts, deps);
216
+ });
217
+ program
218
+ .command('show')
219
+ .description('Print the persisted workflow mapping for a project.')
220
+ .option('-p, --project <project>', 'Jira project key or GitHub repo slug. Shows all cached mappings if omitted.')
221
+ .option('--roles-only', 'Print only the semantic_roles block.')
222
+ .option('--json', 'Emit raw JSON instead of styled output.')
223
+ .action((opts) => {
224
+ showCmd(opts, deps);
225
+ });
226
+ program
227
+ .command('init')
228
+ .description('Create a minimal workflow mapping for a project without running discovery.')
229
+ .requiredOption('-p, --project <project>', 'Jira project key (e.g. ACME).')
230
+ .requiredOption('--qa-passed <status>', 'Exact Jira status name that means QA passed.')
231
+ .option('--in-qa <status>', "Exact Jira status name for 'in QA'. Optional.")
232
+ .option('--atlassian-url <url>', 'Jira base URL for this project.')
233
+ .option('--force', 'Overwrite an existing mapping file.')
234
+ .action((opts) => {
235
+ initCmd(opts, deps);
236
+ });
237
+ for (const sub of program.commands) {
238
+ sub.exitOverride(normalizeUsageExit);
239
+ }
240
+ return program;
241
+ }
242
+ //# sourceMappingURL=workflow-cli.js.map
@@ -0,0 +1,49 @@
1
+ import type { CheckResult } from './doctor.js';
2
+ import { type GitRunner } from './overlay-commands.js';
3
+ import { type CommandProbe } from './skill-requirements.js';
4
+ export interface EngineCheckDeps {
5
+ git?: GitRunner;
6
+ homeDir?: string;
7
+ /** Project directory scanned for `.canary/` config and `.mcp.json`. */
8
+ cwd?: string;
9
+ /** Current CLI version; defaults to reading the package's own package.json. */
10
+ currentVersion?: string;
11
+ /** Latest published version, or null when unknown/offline. Injectable. */
12
+ getLatestVersion?: () => Promise<string | null>;
13
+ timeoutMs?: number;
14
+ /** Probe a command's presence + version (#336). Injectable for tests. */
15
+ skillProbe?: CommandProbe;
16
+ }
17
+ /** String `version` from a registry body; null on parse error or non-string shape (SEC-DES-001: registry JSON is untrusted). */
18
+ export declare function parseRegistryVersion(rawBody: string): string | null;
19
+ /** True when semver `a` is strictly older than `b` (numeric compare, no prerelease). */
20
+ export declare function isOlder(a: string, b: string): boolean;
21
+ /** CLI version vs latest release. Offline degrades to info, never a failure. */
22
+ export declare function checkVersion(deps?: EngineCheckDeps): Promise<CheckResult>;
23
+ /** git present on PATH. */
24
+ export declare function checkGit(deps?: EngineCheckDeps): CheckResult;
25
+ /** Registered overlays present, fresh, and free of local modifications. */
26
+ export declare function checkOverlays(deps?: EngineCheckDeps): CheckResult[];
27
+ /** Project `.canary/` config files parse as JSON. */
28
+ export declare function checkProjectConfig(deps?: EngineCheckDeps): CheckResult;
29
+ /** MCP config references resolvable — read project and home `.mcp.json` directly. */
30
+ export declare function checkMcpConfig(deps?: EngineCheckDeps): CheckResult;
31
+ /**
32
+ * Skill-name collisions across registered overlays are resolved by a declared
33
+ * precedence (#333). A collision with no precedence winner is a `fail` — which
34
+ * definition wins is otherwise accidental (directory-name order). No overlays
35
+ * or no collisions → an informational/passing line, never a false alarm.
36
+ */
37
+ export declare function checkOverlayConflicts(deps?: EngineCheckDeps): CheckResult;
38
+ /**
39
+ * Verify the runtime requirements declared by installed skills (#336). Reads
40
+ * `requires:` from each overlay/global/local skill's SKILL.md and checks every
41
+ * declared command (and optional version) against the environment. A missing
42
+ * or too-old requirement is a `fail` naming the skill and command; a bare
43
+ * presence check that passes, or a token whose version cannot be read, never
44
+ * fails. No declarations at all → an informational line (not a false "all
45
+ * good"). Bundled skills ship inside the engine and are out of scope here.
46
+ */
47
+ export declare function checkSkillRequirements(deps?: EngineCheckDeps): CheckResult;
48
+ /** Run every engine check, in display order. */
49
+ export declare function runEngineChecks(deps?: EngineCheckDeps): Promise<CheckResult[]>;
@@ -0,0 +1,81 @@
1
+ import * as registry from './overlays-registry.js';
2
+ /** Result of running a git subcommand. */
3
+ export interface GitResult {
4
+ status: number;
5
+ stdout: string;
6
+ stderr: string;
7
+ }
8
+ /** Runs a git subcommand and captures status/stdout/stderr (never throws). */
9
+ export type GitRunner = (args: string[], opts?: {
10
+ cwd?: string;
11
+ }) => GitResult;
12
+ /** Sink for command output — matches the write() slice of a WritableStream. */
13
+ export interface Writer {
14
+ write(chunk: string): void;
15
+ }
16
+ export interface CommandDeps {
17
+ git?: GitRunner;
18
+ homeDir?: string;
19
+ out?: Writer;
20
+ err?: Writer;
21
+ /** ISO date stamp for new registry entries (injectable for tests). */
22
+ now?: () => string;
23
+ /** Interactive yes/no prompt for the `overlay add` consent gate (injectable). */
24
+ confirm?: (question: string) => boolean;
25
+ }
26
+ /** Best-effort classification of a failed `git clone`, for a useful remedy. */
27
+ export declare function classifyCloneFailure(res: GitResult): string;
28
+ /**
29
+ * `canary overlay add <source> [--ref <tag>]` — clone a tracked overlay into
30
+ * `~/.canary/overlays/<name>/` and register it. Returns a process exit code.
31
+ * Nothing is registered unless the clone succeeds.
32
+ */
33
+ export declare function add(source: string, options?: {
34
+ ref?: string | null;
35
+ }, deps?: CommandDeps): number;
36
+ /** Count `.canary/skills/<name>/SKILL.md` entries in a clone. */
37
+ export declare function skillCount(dest: string): number;
38
+ /** Working-tree cleanliness of a clone. */
39
+ export type CleanStatus = 'clean' | 'dirty' | 'unreadable';
40
+ /**
41
+ * Whether a clone's working tree is clean, dirty (local modifications), or its
42
+ * git status is unreadable. Shared by `overlay update` (refuses on dirty) and
43
+ * the `doctor` engine check ("no local overlay modifications").
44
+ */
45
+ export declare function workingTreeStatus(dest: string, git: GitRunner): CleanStatus;
46
+ /**
47
+ * Freshness of a clone against its LOCAL knowledge of the upstream — no fetch
48
+ * is performed (that is `overlay update`'s job). Returns a human-readable
49
+ * status string.
50
+ */
51
+ export declare function freshness(dest: string, entry: registry.OverlayEntry, git: GitRunner): string;
52
+ /**
53
+ * `canary overlay list` — one block per registered overlay: name, source, ref,
54
+ * freshness, and skill count.
55
+ */
56
+ /** Options for {@link list}. */
57
+ export interface ListOptions {
58
+ /** `--conflicts`: report skill-name collisions across overlays (#333). */
59
+ conflicts?: boolean;
60
+ }
61
+ export declare function list(deps?: CommandDeps, opts?: ListOptions): number;
62
+ /** Options for {@link lint}. */
63
+ export interface LintOptions {
64
+ json?: boolean;
65
+ }
66
+ /**
67
+ * `canary overlay lint <name|path>` (#332) — validate an overlay against the
68
+ * authoring contract. Exits 0 when there are no errors (warnings are advisory),
69
+ * 1 on any error or an unresolvable target.
70
+ */
71
+ export declare function lint(nameOrPath: string | undefined, deps?: CommandDeps, opts?: LintOptions): number;
72
+ /**
73
+ * `canary overlay update [name]` — fast-forward tracked overlays. With no name,
74
+ * updates all; refuses on local modifications or a non-fast-forward.
75
+ */
76
+ export declare function update(name: string | null, deps?: CommandDeps): number;
77
+ /**
78
+ * `canary overlay remove <name>` — deregister an overlay and delete its clone.
79
+ * Unknown name is an error; the registry is left unchanged in that case.
80
+ */
81
+ export declare function remove(name: string, deps?: CommandDeps): number;