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.
- package/agent/frameworks/registry.json +655 -0
- package/bin/canary.js +20 -15
- package/dist/doctor-manifest.d.ts +94 -0
- package/dist/doctor.d.ts +67 -0
- package/dist/engine/analysis/cli.js +270 -0
- package/dist/engine/analysis/engine.js +146 -0
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/analysis/rows.js +9 -0
- package/dist/engine/cli-commands.js +618 -0
- package/dist/engine/cli-common.js +60 -0
- package/dist/engine/cli.core.js +208 -0
- package/dist/engine/cli.js +31 -0
- package/dist/engine/company-knowledge-cli.js +201 -0
- package/dist/engine/core/ci-env.js +33 -0
- package/dist/engine/core/classifier.js +192 -0
- package/dist/engine/core/company-knowledge.js +765 -0
- package/dist/engine/core/config-validation.js +74 -0
- package/dist/engine/core/detection.js +48 -0
- package/dist/engine/core/domain-scanner.js +212 -0
- package/dist/engine/core/environment-detect.js +410 -0
- package/dist/engine/core/executor.js +181 -0
- package/dist/engine/core/feedback.js +93 -0
- package/dist/engine/core/fixture-scanner.js +173 -0
- package/dist/engine/core/framework-registry.js +123 -0
- package/dist/engine/core/mcp-validator.js +218 -0
- package/dist/engine/core/metadata-scanner.js +147 -0
- package/dist/engine/core/migrator.js +1112 -0
- package/dist/engine/core/overlays.js +176 -0
- package/dist/engine/core/pattern-healer.js +147 -0
- package/dist/engine/core/pattern-matcher.js +255 -0
- package/dist/engine/core/quality-scorer.js +213 -0
- package/dist/engine/core/recommender.js +152 -0
- package/dist/engine/core/reporter.js +211 -0
- package/dist/engine/core/scaffolder.js +236 -0
- package/dist/engine/core/skill-registry.js +522 -0
- package/dist/engine/core/static-linter.js +237 -0
- package/dist/engine/core/ticket-updater.js +639 -0
- package/dist/engine/core/workflow-discovery.js +693 -0
- package/dist/engine/guardian/agent-tier.js +338 -0
- package/dist/engine/guardian/analysis-emit.js +201 -0
- package/dist/engine/guardian/cli.js +787 -0
- package/dist/engine/guardian/coverage.js +1055 -0
- package/dist/engine/guardian/delta-emitter.js +46 -0
- package/dist/engine/guardian/diff-extractor.js +257 -0
- package/dist/engine/guardian/hard-gate.js +373 -0
- package/dist/engine/guardian/impact-mapper.js +121 -0
- package/dist/engine/guardian/pr-check.js +975 -0
- package/dist/engine/guardian/pr-comment.js +200 -0
- package/dist/engine/guardian/summary-emitter.js +94 -0
- package/dist/engine/guardian/tier.js +58 -0
- package/dist/engine/history/cli.js +303 -0
- package/dist/engine/history/detector.js +68 -0
- package/dist/engine/history/ndjson-store.js +177 -0
- package/dist/engine/history/record.js +14 -0
- package/dist/engine/history/schema.js +59 -0
- package/dist/engine/history/store.js +47 -0
- package/dist/engine/history/supabase-store.js +113 -0
- package/dist/engine/main-deps.js +105 -0
- package/dist/engine/mcp-server.js +647 -0
- package/dist/engine/package.json +4 -0
- package/dist/engine/skills-cli.js +181 -0
- package/dist/engine/ui/banner.js +50 -0
- package/dist/engine/util/coalesce.js +12 -0
- package/dist/engine/util/round.js +43 -0
- package/dist/engine/workflow-cli.js +242 -0
- package/dist/engine-checks.d.ts +49 -0
- package/dist/overlay-commands.d.ts +81 -0
- package/dist/overlay-conflicts.d.ts +33 -0
- package/dist/overlay-lint.d.ts +19 -0
- package/dist/overlays-registry.d.ts +74 -0
- package/dist/reporters/testtracker.d.ts +89 -0
- package/dist/reporters/testtracker.js +195 -0
- package/dist/router.d.ts +12 -0
- package/dist/router.js +4 -4
- package/dist/skill-requirements.d.ts +57 -0
- package/dist/source-spec.d.ts +20 -0
- package/package.json +30 -6
- package/bin/canary +0 -0
- 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;
|