canary-test-cli 6.2.0 → 6.4.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/bin/canary-mcp.js +52 -0
- package/dist/engine/cli-commands.js +3 -1
- package/dist/engine/cli.core.js +1 -0
- package/dist/engine/core/company-knowledge.js +125 -40
- package/dist/engine/core/feedback.js +32 -18
- package/dist/engine/core/gate-result.js +73 -0
- package/dist/engine/core/migrator.js +400 -30
- package/dist/engine/core/skill-registry.js +95 -30
- package/dist/engine/guardian/agent-tier.js +7 -2
- package/dist/engine/guardian/cli.js +70 -2
- package/dist/overlay-lint.d.ts +6 -1
- package/dist/overlay-lint.js +53 -68
- package/dist/skill-frontmatter.d.ts +24 -0
- package/dist/skill-frontmatter.js +89 -0
- package/package.json +4 -2
|
@@ -39,10 +39,11 @@
|
|
|
39
39
|
import { createHash } from 'node:crypto';
|
|
40
40
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
41
41
|
import { homedir } from 'node:os';
|
|
42
|
-
import { basename, join, relative, resolve, sep } from 'node:path';
|
|
42
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
43
43
|
import { readJsonWithWarning } from './config-validation.js';
|
|
44
44
|
import { uncertainDetectionMessage } from './detection.js';
|
|
45
45
|
import { FrameworkRegistry } from './framework-registry.js';
|
|
46
|
+
import { EXIT_ABSTAINED, gateOutcome } from './gate-result.js';
|
|
46
47
|
import { Scaffolder, scaffoldableFrameworks, TEMPLATES } from './scaffolder.js';
|
|
47
48
|
import { SkillRegistry } from './skill-registry.js';
|
|
48
49
|
// ---------------------------------------------------------------------------
|
|
@@ -147,34 +148,127 @@ function collectRelFiles(dir) {
|
|
|
147
148
|
walk(dir, '');
|
|
148
149
|
return out;
|
|
149
150
|
}
|
|
151
|
+
/** An object-valued manifest section, or `{}` when absent/malformed. */
|
|
152
|
+
function manifestSection(data, key) {
|
|
153
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
154
|
+
return {};
|
|
155
|
+
}
|
|
156
|
+
const section = data[key];
|
|
157
|
+
if (section === null ||
|
|
158
|
+
typeof section !== 'object' ||
|
|
159
|
+
Array.isArray(section)) {
|
|
160
|
+
return {};
|
|
161
|
+
}
|
|
162
|
+
return section;
|
|
163
|
+
}
|
|
150
164
|
/**
|
|
151
|
-
*
|
|
152
|
-
* unreadable
|
|
165
|
+
* Read the whole manifest document (skills + workflows). Both sections default
|
|
166
|
+
* to `{}` when the file is absent, unreadable, or malformed -- provenance is
|
|
167
|
+
* best-effort and never blocks a deploy.
|
|
153
168
|
*/
|
|
154
|
-
function
|
|
169
|
+
function readManifestDoc(targetSkillsDir) {
|
|
155
170
|
const manifestPath = join(targetSkillsDir, DEPLOY_MANIFEST_NAME);
|
|
156
171
|
let data;
|
|
157
172
|
try {
|
|
158
173
|
data = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
159
174
|
}
|
|
160
175
|
catch {
|
|
161
|
-
return {};
|
|
176
|
+
return { skills: {}, workflows: {} };
|
|
162
177
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return {};
|
|
168
|
-
}
|
|
169
|
-
return skills;
|
|
178
|
+
return {
|
|
179
|
+
skills: manifestSection(data, 'skills'),
|
|
180
|
+
workflows: manifestSection(data, 'workflows'),
|
|
181
|
+
};
|
|
170
182
|
}
|
|
171
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Write the manifest. Read-modify-write of the whole document, because the
|
|
185
|
+
* skill-deploy phase and the workflow-install phase each own one section and
|
|
186
|
+
* both write this one file. `workflows` is omitted entirely when empty so a
|
|
187
|
+
* skills-only manifest keeps its historical bytes.
|
|
188
|
+
*/
|
|
189
|
+
function writeManifestDoc(targetSkillsDir, doc) {
|
|
172
190
|
const manifestPath = join(targetSkillsDir, DEPLOY_MANIFEST_NAME);
|
|
173
191
|
mkdirSync(targetSkillsDir, { recursive: true });
|
|
174
|
-
const
|
|
192
|
+
const payload = {
|
|
193
|
+
schemaVersion: 1,
|
|
194
|
+
skills: doc.skills,
|
|
195
|
+
};
|
|
196
|
+
if (Object.keys(doc.workflows).length > 0)
|
|
197
|
+
payload['workflows'] = doc.workflows;
|
|
198
|
+
const body = ensureAscii(JSON.stringify(payload, null, 2)) + '\n';
|
|
175
199
|
writeFileSync(manifestPath, body, 'utf-8');
|
|
176
200
|
}
|
|
177
201
|
// ---------------------------------------------------------------------------
|
|
202
|
+
// Workflow templates (#459)
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
/** Frontmatter key: templates this skill installs into `.github/workflows/`. */
|
|
205
|
+
const WORKFLOW_DECL_KEY = 'install_workflows';
|
|
206
|
+
/** Frontmatter key: the version stamped into the deploy manifest. */
|
|
207
|
+
const WORKFLOW_VERSION_KEY = 'workflow_template_version';
|
|
208
|
+
/** Default when a skill declares templates but no explicit version. */
|
|
209
|
+
const WORKFLOW_DEFAULT_VERSION = '1';
|
|
210
|
+
/**
|
|
211
|
+
* An optional `<shape>:` prefix on a declared entry, e.g.
|
|
212
|
+
* `api:templates/guardian-api.yml`. Two or more leading characters are required
|
|
213
|
+
* so a Windows drive letter (`C:\...`) can never be mistaken for a shape -- such
|
|
214
|
+
* a path is refused by {@link resolveTemplatePath} instead.
|
|
215
|
+
*/
|
|
216
|
+
const _WORKFLOW_ENTRY_RE = /^([A-Za-z][A-Za-z0-9_-]+):(.+)$/;
|
|
217
|
+
function sha256(bytes) {
|
|
218
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
219
|
+
}
|
|
220
|
+
/** File bytes, or null when unreadable (e.g. the path is a directory). */
|
|
221
|
+
function readBytesOrNull(path) {
|
|
222
|
+
try {
|
|
223
|
+
return readFileSync(path);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The workflow declaration in a skill's SKILL.md frontmatter.
|
|
231
|
+
*
|
|
232
|
+
* Parsed here rather than on {@link SkillInfo} because these fields are
|
|
233
|
+
* migrator-specific: the registry's job is discovery, and every other consumer
|
|
234
|
+
* of `SkillInfo` would carry two fields it never reads.
|
|
235
|
+
*/
|
|
236
|
+
function readWorkflowDeclaration(skillMd) {
|
|
237
|
+
const text = readTextOrNull(skillMd);
|
|
238
|
+
if (text === null)
|
|
239
|
+
return { entries: [], version: WORKFLOW_DEFAULT_VERSION };
|
|
240
|
+
const fm = SkillRegistry.parseFrontmatter(text);
|
|
241
|
+
const entries = SkillRegistry.parseStrList(fm, WORKFLOW_DECL_KEY);
|
|
242
|
+
const rawVersion = fm[WORKFLOW_VERSION_KEY];
|
|
243
|
+
const version = typeof rawVersion === 'string' && rawVersion.trim()
|
|
244
|
+
? rawVersion.trim()
|
|
245
|
+
: WORKFLOW_DEFAULT_VERSION;
|
|
246
|
+
return { entries, version };
|
|
247
|
+
}
|
|
248
|
+
/** Split `[<shape>:]<relative-path>` into its shape filter and path. */
|
|
249
|
+
function parseWorkflowEntry(entry) {
|
|
250
|
+
const m = _WORKFLOW_ENTRY_RE.exec(entry.trim());
|
|
251
|
+
if (m === null)
|
|
252
|
+
return [null, entry.trim()];
|
|
253
|
+
return [m[1].toLowerCase(), m[2].trim()];
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Resolve a declared template path inside *skillDir*, or null when it escapes.
|
|
257
|
+
*
|
|
258
|
+
* An overlay is third-party content, so a declared path is untrusted input: an
|
|
259
|
+
* absolute path or a `..` climb would let an overlay copy an arbitrary file
|
|
260
|
+
* from the machine running `migrate` into the consumer's CI directory.
|
|
261
|
+
*/
|
|
262
|
+
function resolveTemplatePath(skillDir, rel) {
|
|
263
|
+
if (!rel || isAbsolute(rel) || /^[A-Za-z]:/.test(rel))
|
|
264
|
+
return null;
|
|
265
|
+
const base = resolve(skillDir);
|
|
266
|
+
const full = resolve(base, rel);
|
|
267
|
+
if (full !== base && !full.startsWith(base + sep))
|
|
268
|
+
return null;
|
|
269
|
+
return full;
|
|
270
|
+
}
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
178
272
|
// Constants / probes
|
|
179
273
|
// ---------------------------------------------------------------------------
|
|
180
274
|
/**
|
|
@@ -416,6 +510,43 @@ export class SkillDeployResult {
|
|
|
416
510
|
this.note = note;
|
|
417
511
|
}
|
|
418
512
|
}
|
|
513
|
+
/**
|
|
514
|
+
* The outcome of installing one declared workflow template (#459).
|
|
515
|
+
*
|
|
516
|
+
* `status` is one of:
|
|
517
|
+
* - `installed` -- the target had no such workflow; it was written
|
|
518
|
+
* - `skipped` -- byte-identical to the template; nothing to do
|
|
519
|
+
* - `outdated` -- differs, but is unmodified since canary installed it, so
|
|
520
|
+
* the overlay simply moved on (a template fix is waiting)
|
|
521
|
+
* - `conflict` -- differs and was edited locally / has unknown provenance
|
|
522
|
+
* - `updated` -- differed and `--force` replaced it
|
|
523
|
+
* - `dry_run` -- what an `--apply` run would have done
|
|
524
|
+
* - `missing` -- the overlay declares a template it does not ship
|
|
525
|
+
* - `invalid` -- the declared path escapes the skill directory (refused)
|
|
526
|
+
*
|
|
527
|
+
* `outdated` and `conflict` are REPORTS. Neither ever writes.
|
|
528
|
+
*/
|
|
529
|
+
export class WorkflowInstallResult {
|
|
530
|
+
/** File name under `.github/workflows/`. */
|
|
531
|
+
workflow;
|
|
532
|
+
skill_name;
|
|
533
|
+
status;
|
|
534
|
+
detail;
|
|
535
|
+
constructor(workflow, skill_name, status, detail = '') {
|
|
536
|
+
this.workflow = workflow;
|
|
537
|
+
this.skill_name = skill_name;
|
|
538
|
+
this.status = status;
|
|
539
|
+
this.detail = detail;
|
|
540
|
+
}
|
|
541
|
+
to_dict() {
|
|
542
|
+
return {
|
|
543
|
+
workflow: this.workflow,
|
|
544
|
+
skill_name: this.skill_name,
|
|
545
|
+
status: this.status,
|
|
546
|
+
detail: this.detail,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
}
|
|
419
550
|
export class SkillFreshnessResult {
|
|
420
551
|
skill_name;
|
|
421
552
|
dir_name;
|
|
@@ -433,10 +564,21 @@ export class FreshnessReport {
|
|
|
433
564
|
shape;
|
|
434
565
|
overlay_path;
|
|
435
566
|
results;
|
|
436
|
-
|
|
567
|
+
/**
|
|
568
|
+
* What a workflow install WOULD do (#459) -- informational only.
|
|
569
|
+
*
|
|
570
|
+
* Deliberately excluded from `has_drift` / `has_local_edits` / `exit_code`:
|
|
571
|
+
* the freshness gate speaks for overlay-owned skills, and a consumer's
|
|
572
|
+
* `.github/workflows/` is not overlay-owned. Failing CI because someone
|
|
573
|
+
* hand-tuned their own workflow, or has not adopted one at all, would be
|
|
574
|
+
* nagging about something canary has no claim over.
|
|
575
|
+
*/
|
|
576
|
+
workflows;
|
|
577
|
+
constructor(shape, overlay_path = null, results = [], workflows = []) {
|
|
437
578
|
this.shape = shape;
|
|
438
579
|
this.overlay_path = overlay_path;
|
|
439
580
|
this.results = results;
|
|
581
|
+
this.workflows = workflows;
|
|
440
582
|
}
|
|
441
583
|
get stale() {
|
|
442
584
|
return this.results.filter((r) => r.status === 'stale' || r.status === 'missing');
|
|
@@ -453,8 +595,34 @@ export class FreshnessReport {
|
|
|
453
595
|
get in_sync() {
|
|
454
596
|
return !this.has_drift && !this.has_local_edits;
|
|
455
597
|
}
|
|
456
|
-
/**
|
|
598
|
+
/**
|
|
599
|
+
* The freshness gate as a {@link GateResult}: denominator = skills
|
|
600
|
+
* verified, findings = drift + local edits. Feeds the shared abstention
|
|
601
|
+
* helper (#508) so "verified zero skills" can never render as a pass.
|
|
602
|
+
*/
|
|
603
|
+
gateResult() {
|
|
604
|
+
return {
|
|
605
|
+
checked: this.results.length,
|
|
606
|
+
findings: [...this.stale, ...this.local_edits],
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* A gate that verified zero skills has abstained, not passed (#503): the
|
|
611
|
+
* shape matched nothing, so nothing was checked and "in sync" would be a
|
|
612
|
+
* silent false pass -- the #456 class. Reported as its own exit code and
|
|
613
|
+
* flagged in every output surface. Delegates to the shared helper (#508).
|
|
614
|
+
*/
|
|
615
|
+
get abstained() {
|
|
616
|
+
return gateOutcome(this.gateResult(), 'gate').abstained;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* 0 in sync, 1 drift, 2 local edits (safety refusal wins), 3 abstained.
|
|
620
|
+
* The abstention path comes from the shared helper; the 1/2 mapping is
|
|
621
|
+
* this surface's own contract (local edits outrank drift).
|
|
622
|
+
*/
|
|
457
623
|
exit_code() {
|
|
624
|
+
if (this.abstained)
|
|
625
|
+
return EXIT_ABSTAINED;
|
|
458
626
|
if (this.has_local_edits)
|
|
459
627
|
return 2;
|
|
460
628
|
if (this.has_drift)
|
|
@@ -468,6 +636,8 @@ export class FreshnessReport {
|
|
|
468
636
|
in_sync: this.in_sync,
|
|
469
637
|
has_drift: this.has_drift,
|
|
470
638
|
has_local_edits: this.has_local_edits,
|
|
639
|
+
checked: this.results.length,
|
|
640
|
+
abstained: this.abstained,
|
|
471
641
|
exit_code: this.exit_code(),
|
|
472
642
|
skills: this.results.map((r) => ({
|
|
473
643
|
skill_name: r.skill_name,
|
|
@@ -475,6 +645,7 @@ export class FreshnessReport {
|
|
|
475
645
|
status: r.status,
|
|
476
646
|
detail: r.detail,
|
|
477
647
|
})),
|
|
648
|
+
workflows: this.workflows.map((r) => r.to_dict()),
|
|
478
649
|
};
|
|
479
650
|
}
|
|
480
651
|
to_markdown() {
|
|
@@ -486,6 +657,14 @@ export class FreshnessReport {
|
|
|
486
657
|
];
|
|
487
658
|
if (this.results.length === 0) {
|
|
488
659
|
lines.push("_No overlay skills match this project's shape._", '');
|
|
660
|
+
lines.push(`${WARN} **Abstained** ${EMDASH} the gate verified zero skills, so this is not a pass.`, '');
|
|
661
|
+
if (this.shape === 'unknown') {
|
|
662
|
+
lines.push('The shape could not be detected. Set `canary_shape` in', '`.canary/company.json` or pass `--framework <name>`.', '');
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
lines.push(`The overlay ships no skills with \`deploy_to\` covering \`${this.shape}\`.`, "Check the overlay's `deploy_to` lists or the resolved `canary_shape`.", '');
|
|
666
|
+
}
|
|
667
|
+
lines.push(...workflowMarkdown(this.workflows, false));
|
|
489
668
|
return lines.join('\n');
|
|
490
669
|
}
|
|
491
670
|
if (this.in_sync) {
|
|
@@ -514,9 +693,37 @@ export class FreshnessReport {
|
|
|
514
693
|
'edits above (revert them, or upstream them into the overlay) ' +
|
|
515
694
|
'before the freshness gate can pass.', '');
|
|
516
695
|
}
|
|
696
|
+
lines.push(...workflowMarkdown(this.workflows, false));
|
|
517
697
|
return lines.join('\n');
|
|
518
698
|
}
|
|
519
699
|
}
|
|
700
|
+
/**
|
|
701
|
+
* Render the workflow-install section shared by both reports.
|
|
702
|
+
*
|
|
703
|
+
* The closing note is not decoration: it is the only place a consumer is told
|
|
704
|
+
* that a reported difference will never be applied behind their back, and how
|
|
705
|
+
* to opt in when they do want the overlay's version.
|
|
706
|
+
*/
|
|
707
|
+
function workflowMarkdown(results, dryRun) {
|
|
708
|
+
if (results.length === 0)
|
|
709
|
+
return [];
|
|
710
|
+
const heading = dryRun
|
|
711
|
+
? '## Workflows (would install into `.github/workflows/`)'
|
|
712
|
+
: '## Workflows (`.github/workflows/`)';
|
|
713
|
+
const lines = [heading, ''];
|
|
714
|
+
// Every result carries a detail; the status alone would not tell a consumer
|
|
715
|
+
// which file was touched or what to do next.
|
|
716
|
+
for (const r of results) {
|
|
717
|
+
lines.push(`- \`${r.workflow}\` ${EMDASH} ${r.detail}`);
|
|
718
|
+
}
|
|
719
|
+
lines.push('');
|
|
720
|
+
if (results.some((r) => r.status === 'conflict' || r.status === 'outdated')) {
|
|
721
|
+
lines.push(`${WARN} Your CI is yours: canary never overwrites a workflow that ` +
|
|
722
|
+
'differs from the template. Re-run with `--force` to take the ' +
|
|
723
|
+
"overlay's version.", '');
|
|
724
|
+
}
|
|
725
|
+
return lines;
|
|
726
|
+
}
|
|
520
727
|
export class MigrationReport {
|
|
521
728
|
framework;
|
|
522
729
|
shape;
|
|
@@ -530,6 +737,7 @@ export class MigrationReport {
|
|
|
530
737
|
would_create;
|
|
531
738
|
manual_followups;
|
|
532
739
|
deployed_skills;
|
|
740
|
+
installed_workflows;
|
|
533
741
|
config_warnings;
|
|
534
742
|
constructor(init) {
|
|
535
743
|
this.framework = init.framework;
|
|
@@ -544,8 +752,20 @@ export class MigrationReport {
|
|
|
544
752
|
this.would_create = init.would_create ?? [];
|
|
545
753
|
this.manual_followups = init.manual_followups ?? [];
|
|
546
754
|
this.deployed_skills = init.deployed_skills ?? [];
|
|
755
|
+
this.installed_workflows = init.installed_workflows ?? [];
|
|
547
756
|
this.config_warnings = init.config_warnings ?? [];
|
|
548
757
|
}
|
|
758
|
+
/**
|
|
759
|
+
* The dry run's denominator (#504): config files that would be created,
|
|
760
|
+
* skills that would deploy, workflows that would install. Zero means the
|
|
761
|
+
* dry run has nothing to apply -- an advisory abstention, not a
|
|
762
|
+
* completed migration.
|
|
763
|
+
*/
|
|
764
|
+
get would_migrate_count() {
|
|
765
|
+
return (this.would_create.length +
|
|
766
|
+
this.deployed_skills.filter((r) => r.status === 'dry_run').length +
|
|
767
|
+
this.installed_workflows.filter((r) => r.status === 'dry_run').length);
|
|
768
|
+
}
|
|
549
769
|
to_markdown() {
|
|
550
770
|
const lines = ['# Canary Migration Report', ''];
|
|
551
771
|
if (this.dry_run) {
|
|
@@ -642,12 +862,35 @@ export class MigrationReport {
|
|
|
642
862
|
}
|
|
643
863
|
lines.push('');
|
|
644
864
|
}
|
|
865
|
+
lines.push(...workflowMarkdown(this.installed_workflows, this.dry_run));
|
|
645
866
|
if (this.manual_followups.length > 0) {
|
|
646
867
|
lines.push('## Manual Follow-ups Required', '');
|
|
647
868
|
for (const item of this.manual_followups)
|
|
648
869
|
lines.push(`- ${item}`);
|
|
649
870
|
lines.push('');
|
|
650
871
|
}
|
|
872
|
+
else if (this.dry_run) {
|
|
873
|
+
// #504 abstention half: a dry run never completed anything. Zero
|
|
874
|
+
// pending work is an advisory abstention (D3) -- gateOutcome is the
|
|
875
|
+
// only summary-line path AND the only decision point (no local
|
|
876
|
+
// n === 0 arithmetic), so the refusal is structural.
|
|
877
|
+
const n = this.would_migrate_count;
|
|
878
|
+
const outcome = gateOutcome({ checked: n, findings: [] }, 'advisory', {
|
|
879
|
+
noun: 'item(s)',
|
|
880
|
+
});
|
|
881
|
+
lines.push('## Status', '');
|
|
882
|
+
if (outcome.abstained) {
|
|
883
|
+
lines.push(outcome.summaryLine, '', 'This dry run would migrate zero item(s) ' +
|
|
884
|
+
EMDASH +
|
|
885
|
+
' the project already carries everything this migration would ' +
|
|
886
|
+
'produce. If you expected changes, check `--from <overlay>` and ' +
|
|
887
|
+
'the detected framework/shape above.', '');
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
lines.push(`Dry run ${EMDASH} would migrate ${n} item(s). ` +
|
|
891
|
+
'Re-run with `--apply` to write them.', '');
|
|
892
|
+
}
|
|
893
|
+
}
|
|
651
894
|
else {
|
|
652
895
|
lines.push('## Status', '', 'Migration complete. Run `canary recommend "<test description>"` to verify framework detection.', '');
|
|
653
896
|
}
|
|
@@ -719,6 +962,7 @@ export class HarnessMigrator {
|
|
|
719
962
|
const dryRun = options.dryRun ?? true;
|
|
720
963
|
const framework = options.framework ?? null;
|
|
721
964
|
const overlayPath = options.overlayPath ?? null;
|
|
965
|
+
const force = options.force ?? false;
|
|
722
966
|
const ctx = this.detect(projectRoot);
|
|
723
967
|
if (!ctx.is_harness_project) {
|
|
724
968
|
if (ctx.not_test_project_reason)
|
|
@@ -741,7 +985,9 @@ export class HarnessMigrator {
|
|
|
741
985
|
candidates: KNOWN_FRAMEWORKS,
|
|
742
986
|
overrideHint: '`canary migrate --framework <name>`',
|
|
743
987
|
}));
|
|
744
|
-
// Issue #295 point 3: a detection miss must not block skill deployment
|
|
988
|
+
// Issue #295 point 3: a detection miss must not block skill deployment --
|
|
989
|
+
// nor, for the same reason, the workflow install (#459). The guardian
|
|
990
|
+
// workflow is exactly what an unrecognised repo most needs.
|
|
745
991
|
const deployed = this.deploySkills(shape, overlayPath, projectRoot, dryRun);
|
|
746
992
|
return new MigrationReport({
|
|
747
993
|
framework: 'unknown',
|
|
@@ -752,6 +998,7 @@ export class HarnessMigrator {
|
|
|
752
998
|
manual_followups: followups,
|
|
753
999
|
config_warnings: ctx.config_warnings,
|
|
754
1000
|
deployed_skills: deployed,
|
|
1001
|
+
installed_workflows: this.installWorkflows(shape, overlayPath, projectRoot, dryRun, force),
|
|
755
1002
|
});
|
|
756
1003
|
}
|
|
757
1004
|
// A framework canary knows but cannot scaffold gets no config boilerplate.
|
|
@@ -768,6 +1015,9 @@ export class HarnessMigrator {
|
|
|
768
1015
|
const preserved = this.findExistingTests(projectRoot);
|
|
769
1016
|
const scaffolder = new Scaffolder();
|
|
770
1017
|
const deployed = this.deploySkills(shape, overlayPath, projectRoot, dryRun);
|
|
1018
|
+
// Post-copy install phase: the template bytes already landed under
|
|
1019
|
+
// .canary/skills/ with the skill; this puts them where Actions looks.
|
|
1020
|
+
const installedWorkflows = this.installWorkflows(shape, overlayPath, projectRoot, dryRun, force);
|
|
771
1021
|
if (dryRun) {
|
|
772
1022
|
const tmpl = TEMPLATES[effectiveFramework];
|
|
773
1023
|
const files = tmpl?.files ?? {};
|
|
@@ -788,6 +1038,7 @@ export class HarnessMigrator {
|
|
|
788
1038
|
preserved_files: preserved,
|
|
789
1039
|
manual_followups: followups,
|
|
790
1040
|
deployed_skills: deployed,
|
|
1041
|
+
installed_workflows: installedWorkflows,
|
|
791
1042
|
config_warnings: ctx.config_warnings,
|
|
792
1043
|
});
|
|
793
1044
|
}
|
|
@@ -804,6 +1055,7 @@ export class HarnessMigrator {
|
|
|
804
1055
|
preserved_files: preserved,
|
|
805
1056
|
manual_followups: followups,
|
|
806
1057
|
deployed_skills: deployed,
|
|
1058
|
+
installed_workflows: installedWorkflows,
|
|
807
1059
|
config_warnings: ctx.config_warnings,
|
|
808
1060
|
});
|
|
809
1061
|
}
|
|
@@ -873,7 +1125,8 @@ export class HarnessMigrator {
|
|
|
873
1125
|
const results = [];
|
|
874
1126
|
const skillsToDeploy = this.collectOverlaySkills(shape, overlayPath);
|
|
875
1127
|
const targetSkillsDir = join(targetRoot, '.canary', 'skills');
|
|
876
|
-
const
|
|
1128
|
+
const doc = readManifestDoc(targetSkillsDir);
|
|
1129
|
+
const manifest = doc.skills;
|
|
877
1130
|
let manifestDirty = false;
|
|
878
1131
|
for (const [info, skillDir] of skillsToDeploy) {
|
|
879
1132
|
const dirName = basename(skillDir);
|
|
@@ -918,7 +1171,117 @@ export class HarnessMigrator {
|
|
|
918
1171
|
results.push(new SkillDeployResult(info.name, 'copied'));
|
|
919
1172
|
}
|
|
920
1173
|
if (manifestDirty && !dryRun)
|
|
921
|
-
|
|
1174
|
+
writeManifestDoc(targetSkillsDir, doc);
|
|
1175
|
+
return results;
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Install the workflow templates the shape-matching overlay skills declare
|
|
1179
|
+
* into the target's `.github/workflows/` (#459).
|
|
1180
|
+
*
|
|
1181
|
+
* This runs AFTER the skill copy and is a distinct phase, not an extension of
|
|
1182
|
+
* it: the bytes already arrive (whole skill dirs are copied, templates
|
|
1183
|
+
* included) -- what was missing is putting them where GitHub Actions looks.
|
|
1184
|
+
*
|
|
1185
|
+
* **Ownership deliberately differs from `deploySkills`.** Deployed skills are
|
|
1186
|
+
* owned one-way by the overlay (#334); a consumer's CI is NOT. Absent ->
|
|
1187
|
+
* write. Byte-identical -> no-op. Different -> report and leave alone, always,
|
|
1188
|
+
* whatever the provenance. `force` is the deliberate escape hatch. Clobbering
|
|
1189
|
+
* a hand-tuned workflow -- or nagging that it is "stale" via an exit code --
|
|
1190
|
+
* would be a worse failure than the partial adoption this fixes.
|
|
1191
|
+
*
|
|
1192
|
+
* Shape selection reuses the same resolved `canary_shape` that drives
|
|
1193
|
+
* `deploy_to` matching: skills are gated by {@link collectOverlaySkills}, and
|
|
1194
|
+
* an entry may additionally carry a `<shape>:` prefix to pick a variant.
|
|
1195
|
+
*/
|
|
1196
|
+
installWorkflows(shape, overlayPath, targetRoot, dryRun, force = false) {
|
|
1197
|
+
const results = [];
|
|
1198
|
+
const skills = this.collectOverlaySkills(shape, overlayPath);
|
|
1199
|
+
const targetSkillsDir = join(targetRoot, '.canary', 'skills');
|
|
1200
|
+
const doc = readManifestDoc(targetSkillsDir);
|
|
1201
|
+
const workflowsDir = join(targetRoot, '.github', 'workflows');
|
|
1202
|
+
let manifestDirty = false;
|
|
1203
|
+
for (const [info, skillDir] of skills) {
|
|
1204
|
+
const { entries, version } = readWorkflowDeclaration(info.path);
|
|
1205
|
+
for (const entry of entries) {
|
|
1206
|
+
const [wantShape, rel] = parseWorkflowEntry(entry);
|
|
1207
|
+
if (wantShape !== null && wantShape !== shape && wantShape !== 'all') {
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
const src = resolveTemplatePath(skillDir, rel);
|
|
1211
|
+
if (src === null) {
|
|
1212
|
+
results.push(new WorkflowInstallResult(basename(rel), info.name, 'invalid', `declared template '${rel}' resolves outside the skill directory ` +
|
|
1213
|
+
`${EMDASH} refused`));
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
if (!isFile(src)) {
|
|
1217
|
+
results.push(new WorkflowInstallResult(basename(rel), info.name, 'missing', `the overlay declares '${rel}' but does not ship it`));
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
const name = basename(src);
|
|
1221
|
+
const dest = join(workflowsDir, name);
|
|
1222
|
+
const templateBytes = readFileSync(src);
|
|
1223
|
+
const templateHash = sha256(templateBytes);
|
|
1224
|
+
const record = () => {
|
|
1225
|
+
doc.workflows[name] = {
|
|
1226
|
+
skill: info.name,
|
|
1227
|
+
template: rel,
|
|
1228
|
+
version,
|
|
1229
|
+
hash: templateHash,
|
|
1230
|
+
};
|
|
1231
|
+
manifestDirty = true;
|
|
1232
|
+
};
|
|
1233
|
+
const install = () => {
|
|
1234
|
+
mkdirSync(workflowsDir, { recursive: true });
|
|
1235
|
+
writeFileSync(dest, templateBytes);
|
|
1236
|
+
record();
|
|
1237
|
+
};
|
|
1238
|
+
const push = (status, detail) => {
|
|
1239
|
+
results.push(new WorkflowInstallResult(name, info.name, status, detail));
|
|
1240
|
+
};
|
|
1241
|
+
if (!existsSync(dest)) {
|
|
1242
|
+
if (dryRun) {
|
|
1243
|
+
push('dry_run', `would install .github/workflows/${name} (v${version})`);
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
install();
|
|
1247
|
+
push('installed', `installed .github/workflows/${name} (v${version})`);
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
const installedBytes = readBytesOrNull(dest);
|
|
1251
|
+
if (installedBytes !== null && installedBytes.equals(templateBytes)) {
|
|
1252
|
+
// Back-fill provenance for a hand-placed but identical file so a later
|
|
1253
|
+
// template fix can be reported as `outdated` rather than `conflict`.
|
|
1254
|
+
if (doc.workflows[name]?.hash !== templateHash)
|
|
1255
|
+
record();
|
|
1256
|
+
push('skipped', `.github/workflows/${name} already current`);
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
if (force) {
|
|
1260
|
+
if (dryRun) {
|
|
1261
|
+
push('dry_run', `would overwrite .github/workflows/${name} with v${version} (--force)`);
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
install();
|
|
1265
|
+
push('updated', `overwrote .github/workflows/${name} with v${version} (--force)`);
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
const recorded = doc.workflows[name];
|
|
1269
|
+
const untouched = installedBytes !== null &&
|
|
1270
|
+
recorded !== undefined &&
|
|
1271
|
+
recorded.hash === sha256(installedBytes);
|
|
1272
|
+
if (untouched) {
|
|
1273
|
+
push('outdated', `.github/workflows/${name} is at v${recorded.version}, the overlay ` +
|
|
1274
|
+
`ships v${version} ${EMDASH} unmodified since install, so re-run ` +
|
|
1275
|
+
'with --force to take the update');
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
1278
|
+
push('conflict', `.github/workflows/${name} differs from the overlay template and was ` +
|
|
1279
|
+
`left untouched ${EMDASH} your CI is yours; re-run with --force to ` +
|
|
1280
|
+
'replace it');
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
if (manifestDirty && !dryRun)
|
|
1284
|
+
writeManifestDoc(targetSkillsDir, doc);
|
|
922
1285
|
return results;
|
|
923
1286
|
}
|
|
924
1287
|
/**
|
|
@@ -937,7 +1300,7 @@ export class HarnessMigrator {
|
|
|
937
1300
|
const shape = ctx.detected_shape;
|
|
938
1301
|
const skills = this.collectOverlaySkills(shape, overlayPath);
|
|
939
1302
|
const targetSkillsDir = join(projectRoot, '.canary', 'skills');
|
|
940
|
-
const manifest =
|
|
1303
|
+
const manifest = readManifestDoc(targetSkillsDir).skills;
|
|
941
1304
|
const results = [];
|
|
942
1305
|
for (const [info, skillDir] of skills) {
|
|
943
1306
|
const dirName = basename(skillDir);
|
|
@@ -960,22 +1323,29 @@ export class HarnessMigrator {
|
|
|
960
1323
|
results.push(new SkillFreshnessResult(info.name, dirName, 'local_edit', 'deployed skill has local edits; refusing to overwrite'));
|
|
961
1324
|
}
|
|
962
1325
|
}
|
|
963
|
-
return new FreshnessReport(shape, overlayPath !== null ? String(overlayPath) : null, results
|
|
1326
|
+
return new FreshnessReport(shape, overlayPath !== null ? String(overlayPath) : null, results,
|
|
1327
|
+
// dryRun = true: `--check` reports what an install WOULD do and never
|
|
1328
|
+
// writes. Informational only -- see FreshnessReport.workflows.
|
|
1329
|
+
this.installWorkflows(shape, overlayPath, projectRoot, true, false));
|
|
964
1330
|
}
|
|
965
1331
|
detectFramework(root, config) {
|
|
966
|
-
//
|
|
1332
|
+
// Explicit override in .canary/company.json ("canary_shape" field) is
|
|
1333
|
+
// user intent: it wins over every probe tier's shape, including a total
|
|
1334
|
+
// probe miss (#502 — monorepos often have no root framework config).
|
|
1335
|
+
// Framework detection still runs so framework-dependent behavior keeps
|
|
1336
|
+
// working when a probe does match.
|
|
967
1337
|
const rawShape = config['canary_shape'];
|
|
968
1338
|
const explicitShape = (rawShape == null ? '' : String(rawShape))
|
|
969
1339
|
.trim()
|
|
970
1340
|
.toLowerCase();
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1341
|
+
const [framework, shape, source, confidence] = this.probeFramework(root, config);
|
|
1342
|
+
if (!explicitShape)
|
|
1343
|
+
return [framework, shape, source, confidence];
|
|
1344
|
+
return framework === null
|
|
1345
|
+
? [null, explicitShape, 'canary_shape (.canary/company.json)', 'explicit']
|
|
1346
|
+
: [framework, explicitShape, source, confidence];
|
|
1347
|
+
}
|
|
1348
|
+
probeFramework(root, config) {
|
|
979
1349
|
// 1. Dedicated config file (highest confidence).
|
|
980
1350
|
for (const [filename, framework, shape, confidence] of _CONFIG_PROBES) {
|
|
981
1351
|
if (existsSync(join(root, filename))) {
|