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.
@@ -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
- * Return `{dirName: {name, hash}}` from the manifest, or `{}` when absent or
152
- * unreadable (provenance is best-effort).
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 readDeployManifest(targetSkillsDir) {
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
- if (data === null || typeof data !== 'object' || Array.isArray(data))
164
- return {};
165
- const skills = data['skills'];
166
- if (skills === null || typeof skills !== 'object' || Array.isArray(skills)) {
167
- return {};
168
- }
169
- return skills;
178
+ return {
179
+ skills: manifestSection(data, 'skills'),
180
+ workflows: manifestSection(data, 'workflows'),
181
+ };
170
182
  }
171
- function writeDeployManifest(targetSkillsDir, skills) {
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 body = ensureAscii(JSON.stringify({ schemaVersion: 1, skills }, null, 2)) + '\n';
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
- constructor(shape, overlay_path = null, results = []) {
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
- /** 0 in sync, 1 drift, 2 local edits (safety refusal wins). */
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 manifest = readDeployManifest(targetSkillsDir);
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
- writeDeployManifest(targetSkillsDir, manifest);
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 = readDeployManifest(targetSkillsDir);
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
- // 0. Explicit override in .canary/company.json ("canary_shape" field).
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
- if (explicitShape) {
972
- for (const [filename, framework, , confidence] of _CONFIG_PROBES) {
973
- if (existsSync(join(root, filename))) {
974
- return [framework, explicitShape, filename, confidence];
975
- }
976
- }
977
- // Fall through to content probes.
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))) {