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
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Execute the commands the docs promise (#487).
3
+ *
4
+ * #472 added `canary skills run canary-blackhawk -- --help` to a SKILL.md in
5
+ * the same PR that left the command broken: the CLI landed at mode 644, the
6
+ * spawn hit `EACCES`, and that became a bare exit 1 with no output. Nobody had
7
+ * ever run the command the doc documented. #480 answered part of it with a
8
+ * hand-written `spawnSync(cli, ['--help'])` per skill — real execution, which
9
+ * is why the exec-bit bug surfaced at all — but the block is duplicated across
10
+ * five test files, so a seventh skill is covered only when somebody remembers
11
+ * to add a sixth copy (#479).
12
+ *
13
+ * This module is the discovery-driven form. It consumes the surface inventory
14
+ * from {@link ./skill-surfaces.js} — deliberately, rather than walking the skill
15
+ * tree a second time: two walkers with two notions of what a skill is would
16
+ * disagree eventually, and that disagreement is the very bug class both checks
17
+ * exist to catch.
18
+ *
19
+ * ## Which examples are executable
20
+ *
21
+ * #487 left this as an open scope decision. The answer taken here is
22
+ * conservative and mechanical, because the alternative — a fenced-block
23
+ * annotation — asks every SKILL.md author to opt in, and an opt-in that is
24
+ * forgotten reads exactly like a skill with no examples:
25
+ *
26
+ * - The command must live in a **shell-info fenced block** (` ```bash `,
27
+ * `sh`, `shell`, `zsh`, `console`). Prose backticks are illustrative.
28
+ * - It must be a **`canary` invocation**. Running arbitrary `npm`/`git` lines
29
+ * out of a doc is a different and much larger blast radius.
30
+ * - It must carry **no placeholder or shell metacharacter** (`<path>`, `$VAR`,
31
+ * a pipe, a glob). A placeholder command was never meant to run verbatim.
32
+ * - It must be **help-shaped** (`--help` / `-h` / `--version`, or a pure
33
+ * listing command). This is what keeps a documented `katana scan` from
34
+ * writing a ledger into whatever directory CI happens to be sitting in.
35
+ *
36
+ * Everything else is {@link ExampleVerdict.Unverifiable}, **with its reason
37
+ * recorded**. That is the load-bearing half of the design, and it is the same
38
+ * distinction `reachability.ts` draws between a dead link and a slow one: an
39
+ * outcome the checker is not entitled to assert on gets its own status instead
40
+ * of being folded into either pass or fail.
41
+ *
42
+ * ## Denominator
43
+ *
44
+ * `checked` counts the examples **actually executed** — never the examples
45
+ * found. Unverifiable examples travel in `GateResult.skipped`, so `gateOutcome`
46
+ * renders them in every summary line (D7) and an all-illustrative corpus
47
+ * ABSTAINS rather than reporting "all 0 examples passed" (#508).
48
+ */
49
+ import { spawnSync } from 'node:child_process';
50
+ import { SurfaceKind } from './skill-surfaces.js';
51
+ /** Fence info strings whose contents are shell commands. */
52
+ const SHELL_FENCES = new Set(['bash', 'sh', 'shell', 'zsh', 'console']);
53
+ /**
54
+ * Characters that make a command line unsafe to run verbatim: placeholder
55
+ * brackets, variable expansion, redirection, pipes, subshells, globs.
56
+ */
57
+ const PLACEHOLDER = /[<>${}|`*\\]/;
58
+ /** Flags that make an invocation a pure read of the CLI's own surface. */
59
+ const HELP_FLAGS = new Set(['--help', '-h', '--version', '-V']);
60
+ /** Non-mutating subcommands worth executing even without a help flag. */
61
+ const READ_ONLY_COMMANDS = new Set(['canary skills list']);
62
+ /**
63
+ * The author's declaration that a block is illustrative (#707).
64
+ *
65
+ * Placed on its own line immediately above the fence it governs:
66
+ *
67
+ * <!-- canary:illustrative -->
68
+ * ```bash
69
+ * canary katana scan --since HEAD~1
70
+ * ```
71
+ *
72
+ * Two facts land in the same "unverifiable" bucket and they are not the same
73
+ * fact: "nobody could run this" and "this was never meant to be run". The
74
+ * first is a gap in the corpus; the second is a deliberate authoring choice.
75
+ * Collapsing them is what let 88% of the corpus read as coverage debt when
76
+ * some of it was prose doing its job — and, worse, hid the real gaps inside
77
+ * the pile.
78
+ *
79
+ * Marking is NOT an escape hatch from the executable-example rule. It changes
80
+ * the reason on one block; a code-bearing skill still has to carry at least
81
+ * one example that actually runs (`no-executable-example`), so a skill cannot
82
+ * mark its way to green.
83
+ */
84
+ const ILLUSTRATIVE_MARKER = /^\s*<!--\s*canary:illustrative\s*-->\s*$/;
85
+ /**
86
+ * The reason carried by a declared-illustrative example.
87
+ *
88
+ * Exported because the summary line splits the unverifiable bucket on it
89
+ * (see {@link countDeclaredIllustrative}). A string literal compared in two
90
+ * files is a drift waiting to happen, and the drift would be silent: the
91
+ * split would quietly read 0 declared and the distinction this issue exists
92
+ * to draw would be gone with nothing red.
93
+ */
94
+ export const ILLUSTRATIVE_REASON = 'declared illustrative by the author, so it is not run';
95
+ /**
96
+ * How many of a gate's skipped examples were skipped BY DECLARATION.
97
+ *
98
+ * The rest are the honest gap: examples nobody could run and nobody said
99
+ * were prose.
100
+ */
101
+ export function countDeclaredIllustrative(skipped) {
102
+ return skipped.filter((s) => s.reason === ILLUSTRATIVE_REASON).length;
103
+ }
104
+ /** How an example turned out. */
105
+ export var ExampleVerdict;
106
+ (function (ExampleVerdict) {
107
+ /** Ran and exited 0 — the doc is proven. */
108
+ ExampleVerdict["Executed"] = "executed";
109
+ /** Ran and did not exit 0 — the doc promises something broken. */
110
+ ExampleVerdict["Failed"] = "failed";
111
+ /** Could not be run at all; the reason travels with it. */
112
+ ExampleVerdict["Unverifiable"] = "unverifiable";
113
+ })(ExampleVerdict || (ExampleVerdict = {}));
114
+ export var ExampleFindingKind;
115
+ (function (ExampleFindingKind) {
116
+ /** A documented command was executed and failed. */
117
+ ExampleFindingKind["ExampleFailed"] = "example-failed";
118
+ /** A code-bearing skill's doc offers no command to execute at all. */
119
+ ExampleFindingKind["NoDocumentedExample"] = "no-documented-example";
120
+ /**
121
+ * A code-bearing skill documents commands, but not one of them can be run
122
+ * (#707). Distinct from {@link NoDocumentedExample}, and it was the larger
123
+ * hole: 5 of 9 `cli:` skills sat here while the corpus looked documented.
124
+ * A skill in this state can break in every documented way and CI stays
125
+ * green, which is the false-green shape the whole check exists to close.
126
+ */
127
+ ExampleFindingKind["NoExecutableExample"] = "no-executable-example";
128
+ })(ExampleFindingKind || (ExampleFindingKind = {}));
129
+ /**
130
+ * Whether `line` closes the currently open fence.
131
+ *
132
+ * A fence closes only on a BARE delimiter run at least as long as the opener,
133
+ * so a ```` block may legitimately contain ``` -- the same fence rule
134
+ * `scripts/check_doc_links.mjs` had to get right for #686.
135
+ */
136
+ function closesFence(line, delimiter, fence) {
137
+ if (delimiter === null)
138
+ return false;
139
+ const run = delimiter[1];
140
+ return (run[0] === fence[0] && run.length >= fence.length && line.trim() === run);
141
+ }
142
+ /** Shell-fenced lines with their 1-based source line numbers. */
143
+ function fencedShellLines(text) {
144
+ const out = [];
145
+ const lines = text.split('\n');
146
+ let fence = null;
147
+ let shell = false;
148
+ let illustrative = false;
149
+ // The marker governs the NEXT fence, so it survives the blank line authors
150
+ // naturally leave between a comment and a block, and is spent by the fence
151
+ // it opens — a marker cannot leak onto a later, unrelated example.
152
+ let pendingMarker = false;
153
+ for (let i = 0; i < lines.length; i++) {
154
+ const line = lines[i];
155
+ const delimiter = /^\s*(`{3,}|~{3,})\s*([A-Za-z0-9_+-]*)/.exec(line);
156
+ if (fence === null) {
157
+ // A fence opens on a delimiter run; the info string decides whether the
158
+ // body is shell. Anything else (json, ts, text) is not a command.
159
+ if (delimiter) {
160
+ fence = delimiter[1];
161
+ shell = SHELL_FENCES.has((delimiter[2] ?? '').toLowerCase());
162
+ illustrative = pendingMarker;
163
+ pendingMarker = false;
164
+ continue;
165
+ }
166
+ if (ILLUSTRATIVE_MARKER.test(line))
167
+ pendingMarker = true;
168
+ else if (line.trim() !== '')
169
+ pendingMarker = false;
170
+ continue;
171
+ }
172
+ if (closesFence(line, delimiter, fence)) {
173
+ fence = null;
174
+ shell = false;
175
+ illustrative = false;
176
+ continue;
177
+ }
178
+ if (shell)
179
+ out.push({ line: i + 1, raw: line, illustrative });
180
+ }
181
+ return out;
182
+ }
183
+ /** Classify one command line: executable, or unverifiable with a reason. */
184
+ function classify(command) {
185
+ if (PLACEHOLDER.test(command)) {
186
+ return {
187
+ executable: false,
188
+ reason: 'contains a placeholder or shell metacharacter, so it was never ' +
189
+ 'meant to run verbatim',
190
+ };
191
+ }
192
+ if (READ_ONLY_COMMANDS.has(command))
193
+ return { executable: true, reason: null };
194
+ const tokens = command.split(/\s+/);
195
+ if (tokens.some((t) => HELP_FLAGS.has(t))) {
196
+ return { executable: true, reason: null };
197
+ }
198
+ return {
199
+ executable: false,
200
+ reason: 'not help-shaped, so running it could write files, need credentials, ' +
201
+ 'or reach the network',
202
+ };
203
+ }
204
+ /**
205
+ * Extract the documented `canary` commands from one document.
206
+ *
207
+ * `text` is the raw file body (the inventory already read it), `skill` and
208
+ * `path` are carried through onto each example so a finding is attributable.
209
+ */
210
+ export function extractExamples(text, skill, path) {
211
+ const out = [];
212
+ for (const { line, raw, illustrative } of fencedShellLines(text)) {
213
+ // Strip a `$ ` or `> ` shell prompt; a doc that shows a prompt is still
214
+ // documenting the command after it.
215
+ const command = raw
216
+ .trim()
217
+ .replace(/^[$>]\s+/, '')
218
+ .trim();
219
+ if (command === '' || command.startsWith('#'))
220
+ continue;
221
+ if (command !== 'canary' && !command.startsWith('canary '))
222
+ continue;
223
+ // A declaration beats an inference. The author saying "this is prose"
224
+ // is a better fact than the classifier guessing why it could not run,
225
+ // and it is the fact a reader of the skipped list needs.
226
+ if (illustrative) {
227
+ out.push({
228
+ skill,
229
+ path,
230
+ command,
231
+ line,
232
+ executable: false,
233
+ declaredIllustrative: true,
234
+ reason: ILLUSTRATIVE_REASON,
235
+ });
236
+ continue;
237
+ }
238
+ const { executable, reason } = classify(command);
239
+ out.push({
240
+ skill,
241
+ path,
242
+ command,
243
+ line,
244
+ executable,
245
+ declaredIllustrative: false,
246
+ reason,
247
+ });
248
+ }
249
+ return out;
250
+ }
251
+ /** Execute the executable examples; report a verdict for every example. */
252
+ export function runExamples(examples, run, cwd) {
253
+ return examples.map((example) => {
254
+ if (!example.executable) {
255
+ return {
256
+ example,
257
+ verdict: ExampleVerdict.Unverifiable,
258
+ detail: example.reason ?? 'unverifiable',
259
+ };
260
+ }
261
+ const { status, output } = run(example.command, cwd);
262
+ if (status === 0) {
263
+ return { example, verdict: ExampleVerdict.Executed, detail: 'exit 0' };
264
+ }
265
+ return {
266
+ example,
267
+ verdict: ExampleVerdict.Failed,
268
+ detail: status === null
269
+ ? `the process never started: ${output.trim() || '(no output)'}`
270
+ : `exit ${status}: ${output.trim() || '(no output)'}`,
271
+ };
272
+ });
273
+ }
274
+ /** A skill declaration that ships code, and can therefore ship broken code. */
275
+ function codeBearing(decl) {
276
+ return ((decl.kind === SurfaceKind.Skill || decl.kind === SurfaceKind.FlatSkill) &&
277
+ decl.cli !== null);
278
+ }
279
+ /** Fold one skill declaration's results into `tally`. */
280
+ function tallyDeclaration(decl, results, tally) {
281
+ for (const result of results) {
282
+ // `<skill>:<line>` rather than the absolute path: a skill's SKILL.md is
283
+ // unambiguous from its name, and 29 absolute paths turned the D7 skip
284
+ // suffix into a summary line no reader would finish. Still fully
285
+ // attributable; `--json` carries the paths.
286
+ const where = `${decl.name}:${result.example.line}`;
287
+ if (result.verdict === ExampleVerdict.Unverifiable) {
288
+ // Never silently dropped: a skip renders in the summary line, so an
289
+ // example nobody can run stays visible instead of leaving the corpus.
290
+ tally.skipped.push({ name: where, reason: result.detail });
291
+ continue;
292
+ }
293
+ tally.checked += 1;
294
+ if (result.verdict === ExampleVerdict.Failed) {
295
+ tally.findings.push({
296
+ kind: ExampleFindingKind.ExampleFailed,
297
+ skill: decl.name,
298
+ path: decl.path,
299
+ detail: `\`${result.example.command}\` (line ${result.example.line}) ${result.detail}`,
300
+ });
301
+ }
302
+ }
303
+ }
304
+ export function checkExamples(surfaces, run, cwd) {
305
+ const tally = { checked: 0, findings: [], skipped: [] };
306
+ for (const decl of surfaces) {
307
+ if (decl.kind !== SurfaceKind.Skill &&
308
+ decl.kind !== SurfaceKind.FlatSkill) {
309
+ continue;
310
+ }
311
+ const examples = extractExamples(decl.text, decl.name, decl.path);
312
+ // #487 acceptance: a code-bearing skill with no runnable command in its
313
+ // doc is UNPROVEN, not clean. A markdown-only skill has no command that a
314
+ // mode bit could break, so it is not held to this.
315
+ if (examples.length === 0) {
316
+ if (codeBearing(decl)) {
317
+ tally.findings.push({
318
+ kind: ExampleFindingKind.NoDocumentedExample,
319
+ skill: decl.name,
320
+ path: decl.path,
321
+ detail: 'declares a `cli:` but its SKILL.md documents no command in a ' +
322
+ 'shell fence, so nothing about it has ever been executed from ' +
323
+ 'the doc',
324
+ });
325
+ }
326
+ continue;
327
+ }
328
+ // #707: documenting commands is not the same as documenting a RUNNABLE
329
+ // one. The cheapest fix is the skill's own `--help`, which needs no
330
+ // fixtures, credentials or network — and marking blocks illustrative
331
+ // cannot satisfy this, so the declaration stays honest.
332
+ if (codeBearing(decl) && !examples.some((e) => e.executable)) {
333
+ tally.findings.push({
334
+ kind: ExampleFindingKind.NoExecutableExample,
335
+ skill: decl.name,
336
+ path: decl.path,
337
+ detail: `declares a \`cli:\` and documents ${examples.length} command(s), ` +
338
+ 'but none is executable, so nothing in its doc has ever been run. ' +
339
+ 'Add one placeholder-free help-shaped example (its own `--help`).',
340
+ });
341
+ }
342
+ tallyDeclaration(decl, runExamples(examples, run, cwd), tally);
343
+ }
344
+ return tally;
345
+ }
346
+ /**
347
+ * The production runner: spawn the documented command against the repo's own
348
+ * built CLI, in `cwd`.
349
+ *
350
+ * Two deliberate substitutions, both narrow:
351
+ *
352
+ * - The leading `canary` token becomes `node <canaryBin>`, because a doc
353
+ * writes the installed name and CI has a checkout.
354
+ * - `--allow-executable-skills` is inserted into a `skills run` invocation,
355
+ * ahead of any `--` separator. `isExecutableSkillAllowed` refuses `cli:`
356
+ * skills without a TTY, which a spawned process never has, so without the
357
+ * flag every example would exit 3 and the check would measure the sandbox
358
+ * rather than the doc. The flag is an execution-context opt-in and changes
359
+ * nothing about the command's behaviour once it runs. See
360
+ * {@link exampleArgv} for why the position matters.
361
+ */
362
+ export function exampleArgv(command, canaryBin) {
363
+ const [, ...rest] = command.split(/\s+/);
364
+ if (rest[0] !== 'skills' || rest[1] !== 'run')
365
+ return [canaryBin, ...rest];
366
+ // The flag must land BEFORE `--`, or canary forwards it to the skill and the
367
+ // executable-skill guard still refuses. Appending it was the first bug this
368
+ // checker found, in itself: all four documented `skills run ... -- --help`
369
+ // examples reported exit 3, which measured the sandbox rather than the doc.
370
+ const sep = rest.indexOf('--');
371
+ const at = sep === -1 ? rest.length : sep;
372
+ return [
373
+ canaryBin,
374
+ ...rest.slice(0, at),
375
+ '--allow-executable-skills',
376
+ ...rest.slice(at),
377
+ ];
378
+ }
379
+ export function spawnRunner(canaryBin) {
380
+ return (command, cwd) => {
381
+ const res = spawnSync(process.execPath, exampleArgv(command, canaryBin), {
382
+ cwd,
383
+ encoding: 'utf-8',
384
+ timeout: 60_000,
385
+ });
386
+ return {
387
+ status: res.status,
388
+ output: `${res.stdout ?? ''}${res.stderr ?? ''}`,
389
+ };
390
+ };
391
+ }
392
+ //# sourceMappingURL=skill-examples.js.map
@@ -67,11 +67,25 @@ function codePointCompare(a, b) {
67
67
  }
68
68
  return ca.length - cb.length;
69
69
  }
70
- /** Bundled skills live at `<repo>/agents/skills`. Python: `_AGENTS_SKILLS_DIR`. */
70
+ /**
71
+ * Bundled skills live at `<root>/agents/skills`, where `<root>` is three
72
+ * directories above this module. Python: `_AGENTS_SKILLS_DIR`.
73
+ *
74
+ * The "three levels up" is a PACKAGING CONTRACT, not an implementation detail
75
+ * (#757). It holds in the source tree (`ts/src/core`), in the compiled tree
76
+ * (`ts/dist/core`), and in the published npm package (`dist/engine/core`) --
77
+ * but only while whatever sits at that root actually ships an `agents/skills`.
78
+ * It did not: `canary-test-cli@7.1.0` published `bin/` and `dist/` only, so an
79
+ * installed CLI resolved this to a directory that has never existed and
80
+ * reported every bundled skill as absent, from any cwd. Exported so
81
+ * `ts/test/skill-packaging.test.ts` can pin both halves of the contract.
82
+ */
83
+ export function bundledSkillsDirFrom(moduleDir) {
84
+ // moduleDir = <root>/<a>/<b>/core -> the root is three levels up.
85
+ return resolve(moduleDir, '..', '..', '..', 'agents', 'skills');
86
+ }
71
87
  function defaultAgentsSkillsDir() {
72
- const here = dirname(fileURLToPath(import.meta.url));
73
- // here = ts/src/core -> repo root is three levels up (Python: parents[2]).
74
- return resolve(here, '..', '..', '..', 'agents', 'skills');
88
+ return bundledSkillsDirFrom(dirname(fileURLToPath(import.meta.url)));
75
89
  }
76
90
  /**
77
91
  * A discovered skill (Python: `SkillInfo` dataclass).
@@ -150,6 +164,47 @@ export class SkillRegistry {
150
164
  }
151
165
  return [...skills.values()].sort((a, b) => codePointCompare(a.name, b.name));
152
166
  }
167
+ /**
168
+ * The directory tiers {@link discover} consults, in precedence order.
169
+ *
170
+ * Exists so an empty discovery can state its denominator (#757). "No skills
171
+ * found." is a claim about the world; what discovery can actually attest is
172
+ * "none of THESE four roots held one", and the two read very differently to
173
+ * someone standing in a directory full of SKILL.md files. The local tier
174
+ * walks cwd up to the git root, so it renders as the range it swept rather
175
+ * than one line per ancestor.
176
+ */
177
+ searchRoots(root) {
178
+ const searchRoot = resolve(root ?? process.cwd());
179
+ const ancestors = SkillRegistry.ancestorsToGitRoot(searchRoot);
180
+ const localDirs = ancestors.map((a) => join(a, '.canary', 'skills'));
181
+ const overlaysRoot = join(this.home, '.canary', 'overlays');
182
+ const globalDir = join(this.home, '.canary', 'skills');
183
+ return [
184
+ {
185
+ tier: 'bundled',
186
+ path: this.agentsSkillsDir,
187
+ exists: existsSync(this.agentsSkillsDir),
188
+ },
189
+ {
190
+ tier: 'overlay',
191
+ path: overlaysRoot,
192
+ exists: SkillRegistry.isDir(overlaysRoot),
193
+ },
194
+ {
195
+ tier: 'global',
196
+ path: globalDir,
197
+ exists: SkillRegistry.isDir(globalDir),
198
+ },
199
+ {
200
+ tier: 'local',
201
+ path: localDirs.length > 1
202
+ ? `${localDirs[0]} (and ${localDirs.length - 1} ancestor(s) up to the git root)`
203
+ : (localDirs[0] ?? join(searchRoot, '.canary', 'skills')),
204
+ exists: localDirs.some((d) => existsSync(d)),
205
+ },
206
+ ];
207
+ }
153
208
  /** Return the SkillInfo for `name` honoring precedence, or null. */
154
209
  find(name, root) {
155
210
  for (const skill of this.discover(root)) {